11---
22id : 2606292015
33title : Scope the LSP workspace singleton per client so instances coexist
4- status : " 🔳 "
4+ status : " 🔲 "
55model : opus
66summary : >-
7- Key the newest-wins LSP workspace singleton on a
8- client-supplied scope token, not the workspace root
9- alone, and make the claim opt-in. VS Code sends a
10- token stable across an extension-host reload
11- (`vscode.env.sessionId`); other clients send none and
12- run without the singleton. That lets the VS Code
13- server, a Claude Code plugin server, and a second
14- Claude in another terminal all run on one workspace at
15- once, while the VS Code extension-upgrade hand-off
16- (old server stops, new starts) keeps working.
7+ Make the newest-wins LSP workspace singleton opt-in.
8+ Key its owner record on the workspace root plus a
9+ client-supplied scope token, not the root alone. The
10+ VS Code extension sends a per-workspace UUID it
11+ persists in `workspaceState`; the id is read from
12+ disk, so it is stable across an extension reload or
13+ update. Other clients send no token and run without
14+ the singleton. That lets the VS Code server, a Claude
15+ Code plugin server, and a second Claude in another
16+ terminal all run on one workspace at once, while the
17+ VS Code upgrade hand-off (old server stops, new one
18+ starts) keeps working.
1719---
1820# Scope the LSP workspace singleton per client so instances coexist
1921
@@ -78,6 +80,10 @@ client on the workspace.
7880- Coordinating fix-on-save writes across processes. Each editor
7981 writes its own buffer; concurrent on-disk writes are out of
8082 scope.
83+ - Re-keying mid-session. The root is read once at ` initialize `
84+ (from ` workspaceFolders[0] ` ), exactly as today. A multi-root
85+ folder add/remove does not re-claim. This matches current
86+ behavior and is unchanged here.
8187- Changing the LSP wire surface beyond reading
8288 ` initializationOptions ` on ` initialize ` .
8389
@@ -86,57 +92,113 @@ client on the workspace.
8692### Opt-in scope token
8793
8894Add a client opt-in. The client may send a ` singletonScope `
89- string in its ` initializationOptions ` . When it does, the owner
90- key hashes the root * and* that token:
95+ string under ` initializationOptions.mdsmith ` . The namespace keeps
96+ the key off the generic top level and leaves room to add sibling
97+ fields later. When the scope is non-empty, the owner key hashes
98+ the root * and* that token:
9199
92100``` text
93101key = sha256(filepath.Clean(root) + "\x00" + scope)
94102```
95103
96- When the token is empty or absent, the server does not claim the
104+ When the scope is empty or absent, the server does not claim the
97105registry and does not start the watcher. The singleton is then a
98- no-op for that client. So a client opts in by sending a token and
99- opts out by sending nothing.
106+ no-op for that client. So a client opts in by sending a scope and
107+ opts out by sending nothing. An empty scope also hashes to the
108+ legacy root-only key, so a no-token client and an old root-only
109+ binary still agree on the same key (see Backward compatibility).
100110
101111### What each client sends
102112
103- | Client | ` singletonScope ` value | Effect |
104- | --------------------------- | ---------------------- | ---------------------------------------------- |
105- | VS Code extension | ` vscode.env.sessionId ` | Orphan and respawn share one slot; newest wins |
106- | Claude Code plugin | none | No claim; never supersedes or is superseded |
107- | Second Claude in a terminal | none | No claim; coexists with the first |
108- | Neovim / Helix / JetBrains | none | No claim; coexists |
109-
110- ` vscode.env.sessionId ` is stable across an extension-host reload
111- and across an extension update within one VS Code application
112- session, and it is unique per application session. On a full app
113- upgrade VS Code restarts, the old process exits, its pipes close,
114- and there is no orphan to reap. So the token reaps exactly the
115- orphan the singleton targets.
113+ | Client | ` singletonScope ` value | Effect |
114+ | --------------------------- | ---------------------------- | ---------------------------------------------- |
115+ | VS Code extension | persisted per-workspace UUID | Orphan and respawn share one slot; newest wins |
116+ | Claude Code plugin | none | No claim; never supersedes or is superseded |
117+ | Second Claude in a terminal | none | No claim; coexists with the first |
118+ | Neovim / Helix / JetBrains | none | No claim; coexists |
119+
120+ ### The VS Code token must be disk-backed
121+
122+ The orphan and its respawn are two different extension-host
123+ processes. The token must be identical for both, or the new
124+ server never reaps the orphan. So the token must survive an
125+ extension-host restart.
126+
127+ The extension generates a UUID once with ` crypto.randomUUID() `
128+ and stores it in ` context.workspaceState ` . That store is written
129+ to disk and read back unchanged after any reload or update. So
130+ both hosts read the same id. The id is per workspace on that
131+ machine, which is the grain the key needs.
132+
133+ ` vscode.env.sessionId ` is the obvious shortcut, but it is not
134+ safe here. The API documents it as changing "each time the editor
135+ is started," and it is injected per extension-host process. So a
136+ leaked host and a fresh host may hold different session ids. That
137+ would silently break reaping — the exact case the singleton
138+ exists for. A disk-backed id removes that doubt.
139+
140+ One known limit follows from the per-workspace grain. Two VS Code
141+ windows on the * same* folder read the same stored id, so the
142+ newest still wins between them. VS Code already focuses an open
143+ folder instead of opening a duplicate window, and today's
144+ root-only key behaves the same way, so this is not a regression.
116145
117146### Why a client token, not an inferred identity
118147
119- | Identity | Reaps the upgrade orphan? | Lets two Claude terminals coexist? |
120- | --------------------- | ------------------------------ | ---------------------------------- |
121- | Workspace root only | yes | no — they supersede each other |
122- | ` processId ` | no — orphan and respawn differ | yes |
123- | ` clientInfo.name ` | yes | no — both report ` claude-code ` |
124- | Client-supplied token | yes | yes |
148+ | Identity | Reaps the upgrade orphan? | Two Claude terminals coexist? |
149+ | ---------------------------- | ----------------------------------- | ------------------------------ |
150+ | Workspace root only | yes | no — they supersede each other |
151+ | ` processId ` | no — orphan and respawn differ | yes |
152+ | ` clientInfo.name ` | yes | no — both report ` claude-code ` |
153+ | ` vscode.env.sessionId ` | unclear — may change on host reload | yes (VS Code only) |
154+ | Persisted per-workspace UUID | yes — read from disk | yes |
125155
126- Only a client-supplied, reload-stable token reaps the orphan and
156+ Only a disk-backed, client-supplied token reaps the orphan and
127157lets independent clients coexist. The mechanism is generic: any
128158future client with the leaked-host problem opts in with its own
129159stable token, with no name-specific branch in the server.
130160
161+ ### One key function, one gate
162+
163+ Keep a single key function. Extend ` workspaceKey ` to take the
164+ scope rather than adding a sibling. An empty scope hashes the
165+ root alone, so every call site agrees on one derivation and the
166+ legacy behavior is preserved.
167+
168+ Keep one switch for "is the singleton active." ` EnableWorkspaceSingleton `
169+ stays the process capability: it wires the registry seams and
170+ keeps unit tests hermetic. The scope is only the key input and
171+ the claim gate. The claim fires only when the scope is non-empty.
172+ The server never treats the scope as content; any client that
173+ sends one has opted in by definition.
174+
175+ ### Backward compatibility
176+
177+ The key format changes from ` sha256(root) ` to
178+ ` sha256(root + "\x00" + scope) ` . An empty scope reproduces the
179+ old key byte for byte. So a no-token client, and an older
180+ root-only binary, still key the same way and still see each
181+ other. Only the VS Code (now UUID-keyed) path moves to a new key.
182+
183+ Old ` .owner ` records written under the legacy root-only key are
184+ not migrated. They are tiny files in the user cache dir, and
185+ nothing reads them once VS Code keys by UUID. They are harmless;
186+ the plan does not add a prune.
187+
131188### Rollout
132189
133190The VS Code extension bundles its own ` mdsmith ` binary. So the
134191server change and the extension change ship together for the
135- common path. Until the extension sends the token, its singleton
136- stays off. That only disables orphan reaping, a rare edge case.
137- EOF and the ` processId ` watchdog still handle normal exits. A
138- user who points ` mdsmith.path ` at a newer external binary gets
139- the same interim behavior until they update the extension.
192+ common path. There is no skew there.
193+
194+ Skew is possible only when a user points ` mdsmith.path ` at a
195+ newer external binary while running an older extension that sends
196+ no token. In that window the singleton is off, so the leaked-host
197+ orphan is not reaped — and that orphan is the one case the
198+ subsystem exists for. EOF and the ` processId ` watchdog still
199+ handle normal exits. The honest cost is: for the override case
200+ only, the "Two mdsmith servers running" note can recur until the
201+ extension updates.
140202
141203### Documentation
142204
@@ -145,34 +207,43 @@ gains a "Multiple instances" section. It states that many servers
145207per workspace are supported, and that the singleton is opt-in via
146208` singletonScope ` . The [ VS Code guide] ( ../docs/guides/editors/vscode.md )
147209note "Two mdsmith servers running" is updated. It says the scope
148- is per VS Code session . So a Claude plugin or another editor on
210+ is per VS Code workspace . So a Claude plugin or another editor on
149211the same workspace is unaffected.
150212
151213## Tasks
152214
1532151 . [ ] Capture ` initializationOptions ` in ` initializeParams `
154- ([ protocol.go] ( ../internal/lsp/protocol.go ) ); add a typed
155- ` singletonScope ` string field. Unit-test the unmarshal.
156- 2 . [ ] Add ` singletonKey(root, scope) ` and route the claim and
157- watcher through it. Make
158- [ ` startSingletonWatch ` ] ( ../internal/lsp/singleton.go ) a no-op
159- on an empty scope. Red/green: same ` (root, scope) ` supersedes;
160- different scopes coexist; empty scope never claims.
161- 3 . [ ] Thread the scope from ` handleInitialize `
162- ([ server_lifecycle.go] ( ../internal/lsp/server_lifecycle.go ) )
163- into the claim.
164- 4 . [ ] VS Code: send
165- ` initializationOptions: { singletonScope: env.sessionId } `
216+ ([ protocol.go] ( ../internal/lsp/protocol.go ) ); read
217+ ` mdsmith.singletonScope ` as an optional string. Unit-test the
218+ unmarshal, including the absent / ` null ` / non-object case,
219+ which must decode to an empty scope (the opt-out path).
220+ 2 . [ ] Extend ` workspaceKey ` to take the scope and fold the
221+ empty-scope case in (empty scope hashes the root alone).
222+ Add ` TestWorkspaceKey… ` cases: same root + different scope →
223+ different key; same root + same scope → same key; empty scope
224+ → the legacy key. Do not add a second key function.
225+ 3 . [ ] Gate the claim and watcher on a non-empty scope in
226+ [ ` startSingletonWatch ` ] ( ../internal/lsp/singleton.go ) , and
227+ thread the scope from ` handleInitialize `
228+ ([ server_lifecycle.go] ( ../internal/lsp/server_lifecycle.go ) ).
229+ Drive the new empty-scope no-op red/green, distinct from the
230+ existing empty-root / empty-instanceID guard.
231+ 4 . [ ] VS Code: generate a UUID once and persist it in
232+ ` context.workspaceState ` ; send it as
233+ ` initializationOptions.mdsmith.singletonScope `
166234 ([ extension.ts] ( ../editors/vscode/src/extension.ts ) /
167- [ wiring.ts] ( ../editors/vscode/src/wiring.ts ) ). Test that the
168- built client options carry it.
169- 5 . [ ] Add a test that the upgrade hand-off still works: two
170- processes with the same scope, newest wins, older emits
171- ` mdsmith/superseded ` . Leave ` decideClose ` unchanged.
235+ [ wiring.ts] ( ../editors/vscode/src/wiring.ts ) ). Widen the
236+ injected context type to expose ` workspaceState ` . A ` bun:test `
237+ asserts the built client options carry the token and that the
238+ id is stable across two activations.
239+ 5 . [ ] Unit-test the supersede logic on the existing seams
240+ (` watchSingleton ` / the key): same scope → newest wins, older
241+ emits ` mdsmith/superseded ` ; different scopes → both stay; no
242+ scope → never claims. Leave ` decideClose ` unchanged.
1722436 . [ ] Update [ lsp.md] ( ../docs/reference/cli/lsp.md ) and
173244 [ vscode.md] ( ../docs/guides/editors/vscode.md ) .
174- 7 . [ ] Run ` mdsmith fix PLAN.md ` after the front-matter status
175- flips to done .
245+ 7 . [ ] On completion, flip the front-matter status and run
246+ ` mdsmith fix PLAN.md ` .
176247
177248## Acceptance Criteria
178249
@@ -181,19 +252,27 @@ the same workspace is unaffected.
181252 superseded.
182253- [ ] Two servers with the same ` singletonScope ` : the newest
183254 wins, the older sends ` mdsmith/superseded ` and exits.
184- - [ ] A server that receives no ` singletonScope ` never writes the
185- owner registry and is never superseded.
255+ - [ ] A server that receives an empty or absent ` singletonScope `
256+ never writes the owner registry and is never superseded;
257+ the absent / ` null ` ` initializationOptions ` decode is
258+ covered by a test.
259+ - [ ] The empty-scope no-op is driven red/green and is distinct
260+ from the pre-existing empty-root guard.
261+ - [ ] There is exactly one key function; an empty scope yields
262+ the legacy root-only key (a unit test pins this).
186263- [ ] The VS Code extension sends
187- ` initializationOptions.singletonScope = vscode.env.sessionId ` ;
188- an extension test asserts it.
189- - [ ] The VS Code upgrade hand-off still works — old server stops,
190- new server starts — verified by a test using one scope
191- across two processes.
264+ ` initializationOptions.mdsmith.singletonScope ` = a UUID it
265+ persists in ` workspaceState ` ; a ` bun:test ` asserts the
266+ token is sent and is stable across activations.
267+ - [ ] The VS Code upgrade hand-off still works — old server
268+ stops, new server starts — verified by a unit test that
269+ feeds one scope to two server instances.
192270- [ ] [ ` docs/reference/cli/lsp.md ` ] ( ../docs/reference/cli/lsp.md )
193271 documents multi-instance coexistence, and the
194272 [ VS Code guide] ( ../docs/guides/editors/vscode.md ) note
195- reflects the per-session scope.
196- - [ ] All tests pass: ` go test ./... ` .
273+ reflects the per-workspace scope.
274+ - [ ] All tests pass: ` go test ./... ` and the extension
275+ ` bun:test ` suite.
197276- [ ] ` go tool -modfile=tools/go.mod golangci-lint run ` reports no
198277 issues.
199278- [ ] ` mdsmith check . ` passes.
0 commit comments