@dsh-electron/dsh-plugin-multi-root-workspace 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.en.md +89 -0
  2. package/CHANGELOG.md +89 -0
  3. package/README.en.md +96 -20
  4. package/README.md +97 -19
  5. package/cordis.patch.yml +22 -1
  6. package/lib/client.js +234 -71
  7. package/lib/client.js.map +1 -1
  8. package/lib/{command-BSkrYOAf.js → command-DyOWujPo.js} +24 -61
  9. package/lib/command.js +2 -2
  10. package/lib/compat-CRdv0vPw.js +272 -0
  11. package/lib/compat.js +2 -0
  12. package/lib/{fs-D7UoCb7T.js → fs-D_Sw3o5z.js} +7 -3
  13. package/lib/fs.js +1 -1
  14. package/lib/index.js +8 -6
  15. package/lib/instructions-Cn1Jj5Xi.js +703 -0
  16. package/lib/instructions.js +2 -0
  17. package/lib/registry-DySMbjgW.js +793 -0
  18. package/lib/registry-lease-win32-dy8TQMD9.js +93 -0
  19. package/lib/registry.js +2 -466
  20. package/lib/{sandbox-BhuMTDgH.js → sandbox-D8Fw95FM.js} +51 -4
  21. package/lib/sandbox.js +1 -1
  22. package/lib/scope.js +1 -1
  23. package/lib/types/client/locales.d.ts +6 -0
  24. package/lib/types/client/panel-client.d.ts +0 -2
  25. package/lib/types/command.d.ts +17 -0
  26. package/lib/types/compat/agent-instructions.d.ts +138 -0
  27. package/lib/types/compat/client-icons.d.ts +40 -0
  28. package/lib/types/compat/client-session.d.ts +32 -0
  29. package/lib/types/compat/dsh-version.d.ts +119 -0
  30. package/lib/types/compat/llm-message.d.ts +72 -0
  31. package/lib/types/compat/sandbox-confine.d.ts +58 -0
  32. package/lib/types/compat/tool-result.d.ts +29 -0
  33. package/lib/types/compat.d.ts +85 -0
  34. package/lib/types/contract.d.ts +6 -8
  35. package/lib/types/index.d.ts +19 -5
  36. package/lib/types/instructions.d.ts +125 -0
  37. package/lib/types/registry-lease-win32.d.ts +29 -0
  38. package/lib/types/registry-lease.d.ts +62 -0
  39. package/lib/types/registry.d.ts +130 -19
  40. package/lib/types/roots.d.ts +1 -1
  41. package/lib/types/sandbox.d.ts +22 -2
  42. package/package.json +60 -13
  43. package/lib/{roots-DyT2jgEP.js → roots-DasWEBJk.js} +1 -1
@@ -94,16 +94,14 @@ export interface PanelResponseMap {
94
94
  readonly reveal: RevealedView;
95
95
  }
96
96
  /**
97
- * The fields a request may carry. `primaryRoot` and `sessionId` are shared by
98
- * every endpoint (a client may name the workspace root directly or let the host
99
- * resolve it from the session); the remaining fields are used per endpoint and
100
- * the host asserts which ones that endpoint requires.
97
+ * The fields a request may carry. `sessionId` is required on every endpoint:
98
+ * the host derives the primary root from that session's immutable cwd and
99
+ * never accepts a client-named path. The remaining fields are used per
100
+ * endpoint and the host asserts which ones that endpoint requires.
101
101
  */
102
102
  export interface PanelRequest {
103
- /** The workspace root to act on; the host validates that it is an existing directory. */
104
- readonly primaryRoot?: string;
105
- /** The session whose workspace root should be used when `primaryRoot` is absent. */
106
- readonly sessionId?: string;
103
+ /** The live host session whose cwd is the primary root this call acts on. */
104
+ readonly sessionId: string;
107
105
  /** Exact target row from the most recent list snapshot. */
108
106
  readonly entry?: RootEntryView;
109
107
  /** Compatibility reference; accepted only when the id is unique. */
@@ -13,22 +13,36 @@
13
13
  * whole public surface from a single specifier.
14
14
  *
15
15
  * The two facts worth remembering about this surface: a registration carries the
16
- * canonical directory it was GRANTED for (`recordedPath`), and the panel channel
17
- * has one declared response shape per endpoint (`PanelResponseMap`).
16
+ * canonical directory it was GRANTED for (`recordedPath`), the panel channel
17
+ * has one declared response shape per endpoint (`PanelResponseMap`), and the
18
+ * panel primary root is derived from a live host session — never named by the
19
+ * browser.
18
20
  *
19
21
  * @module @dsh-electron/dsh-plugin-multi-root-workspace
20
22
  */
23
+ export { DshCompatUnsupportedError, MultiRootCompatService, assertSupportedInstallation } from './compat.ts';
24
+ export { classifyInstallation, compatEnforcement, CORE_PACKAGES, OPTIONAL_CORE_PACKAGES, readInstalledVersion, REQUIRED_CORE_PACKAGES, SUPPORTED_DSH_RELEASES, } from './compat/dsh-version.ts';
25
+ export type { CompatEnforcement, CompatReport, CompatVerdict, InspectedPackage, SupportedDshRelease, VersionReader, } from './compat/dsh-version.ts';
26
+ export { instructionsApi, renderInstructions } from './compat/agent-instructions.ts';
27
+ export type { InstructionsApi, RenderedInstructions } from './compat/agent-instructions.ts';
28
+ export { createInstructionMessage } from './compat/llm-message.ts';
29
+ export type { InstructionMessageInput } from './compat/llm-message.ts';
30
+ export { widenConfined } from './compat/sandbox-confine.ts';
31
+ export type { ConfineCall, UpstreamConfined } from './compat/sandbox-confine.ts';
32
+ export { composeInstructionMessage, DEFAULT_MAX_BYTES, PLUGIN_SOURCE } from './instructions.ts';
33
+ export type { Config as InstructionsConfig } from './instructions.ts';
21
34
  export { MultiRootFileSystem } from './fs.ts';
22
35
  export type { Config as FileSystemConfig } from './fs.ts';
23
36
  export { MultiRootSandboxProvider } from './sandbox.ts';
24
37
  export type { Config as SandboxConfig } from './sandbox.ts';
25
38
  export { MultiRootScopeService, sanitizeAdditionalRoots, renderWorkspaceRootsContext } from './scope.ts';
26
39
  export type { AdditionalWorkspaceRoot, FilesystemScope } from './scope.ts';
27
- export { MultiRootRegistry, DOMAIN_NAME, MAX_ALIAS_LENGTH, multiRootDomainSpec } from './registry.ts';
28
- export type { AddRootInput, PersistedPrimaryRoot } from './registry.ts';
40
+ export { MultiRootRegistry, DOMAIN_NAME, MAX_ALIAS_LENGTH, REGISTRY_CONTENDED_MESSAGE, multiRootDomainSpec } from './registry.ts';
41
+ export type { AddRootInput, Config as RegistryConfig, PersistedPrimaryRoot, RegistryAuthorityState } from './registry.ts';
42
+ export { RegistryAuthorityLease, RegistryLeaseContendedError } from './registry-lease.ts';
29
43
  export { availableRoots, canonicalRoot, classifyStoredRoots, expandRootInput, indexedStatuses, isCanonicallyUnder, additionalRootId, removeStatusAt, resolveRootRef, RootValidationError, validateRootCandidate, } from './roots.ts';
30
44
  export type { AdditionalRootId, RegisteredRoot, RootRef, RootState, RootStatus, RootValidationCode, } from './roots.ts';
31
- export { COMMAND_NAME, parseFoldersCommand, renderRootsReport, revealArgv } from './command.ts';
45
+ export { COMMAND_NAME, parseFoldersCommand, renderRootsReport, resolvePanelPrimaryRoot, revealArgv } from './command.ts';
32
46
  export type { FoldersCommand } from './command.ts';
33
47
  export { PANEL_CHANNEL, PANEL_ENDPOINTS, parsePanelCall, parseRevealedView, parseRootsView } from './contract.ts';
34
48
  export type { PanelCall, PanelEndpoint, PanelFailure, PanelRequest, PanelResponseMap, RevealedView, RootView, RootsView, } from './contract.ts';
@@ -0,0 +1,125 @@
1
+ /**
2
+ * `multi-root-instructions`: the additional roots' own instruction files, made
3
+ * visible to the model.
4
+ *
5
+ * Upstream `agent-instructions` discovers instruction files by walking UP from
6
+ * the session cwd (plus the one user-global file). In a multi-root workspace
7
+ * that walk never reaches an additional root, so `repo-b/AGENTS.md` is
8
+ * invisible to a model that is nevertheless allowed to write `repo-b`. This row
9
+ * closes exactly that gap, and nothing else: the primary root's chain — nested
10
+ * files included — and the user-global file stay upstream's business.
11
+ *
12
+ * Four deliberate choices:
13
+ *
14
+ * 1. **Not a system-prompt contribution.** Instruction text is
15
+ * producer-supplied context, not system authority, so it enters as a
16
+ * user-role message tagged `form: 'instructions'`. Through session format 3
17
+ * the source kind is `plugin`; format 4 retired that shared kind, so the
18
+ * adapter persists this plugin's own (`multi-root-workspace`). It is also
19
+ * the only channel that is logged, and model-visible content must be logged.
20
+ * 2. **Injected from `agent/pre-step`, not a session lifecycle event.**
21
+ * `pre-step` is an awaited waterfall, so discovery, reading and rendering all
22
+ * complete BEFORE the step it feeds — deterministically, on the first step.
23
+ * A synchronous prompt callback could not await them, and an emit-mode
24
+ * lifecycle listener would race the first step. It is also the one hook whose
25
+ * shape is identical across every supported release, which keeps the
26
+ * instruction feature out of the compatibility matrix (ADR-0009).
27
+ * 3. **Discovery is pinned to the root.** `projectRoot` is the additional root,
28
+ * so the walk can never leave it, and every candidate is then required to be
29
+ * canonically INSIDE that root. That is what keeps `$DSH_HOME/AGENTS.md`, the
30
+ * primary root's `AGENTS.md`, and any ancestor's file from being injected a
31
+ * second time by this plugin.
32
+ * 4. **Nested files are driven by the session's own tool activity.** A
33
+ * subdirectory's `AGENTS.md` only matters once the session has worked in that
34
+ * subdirectory, so the directories worth examining are derived from the
35
+ * session's persisted events: `tool/call` and `tool/result` are paired, and a
36
+ * successful `read`/`write`/`edit` makes the path from the root down to the
37
+ * touched file's directory relevant (ADR-0010). A file that disappears is
38
+ * retracted explicitly, because the text it contributed earlier is still in
39
+ * the conversation and silence does not withdraw it.
40
+ *
41
+ * The byte budget is shared: `maxBytes` bounds the WHOLE additional-root
42
+ * snapshot, not each root, because ten roots must not quietly cost ten budgets
43
+ * of context. Roots consume it in scope order. Retractions are exempt, because a
44
+ * truncated withdrawal is worse than a slightly over-budget message.
45
+ *
46
+ * @module @dsh-electron/dsh-plugin-multi-root-workspace/instructions
47
+ */
48
+ import { Context } from '@deepseek-ai/cordis';
49
+ /** The `plugin` attribution every message this row produces carries. */
50
+ export declare const PLUGIN_SOURCE = "@dsh-electron/dsh-plugin-multi-root-workspace";
51
+ /**
52
+ * Total UTF-8 byte budget for the additional-root snapshot, matching the
53
+ * default the upstream `agent-instructions` row is composed with so that one
54
+ * extra workspace root costs at most as much as the primary one does.
55
+ */
56
+ export declare const DEFAULT_MAX_BYTES = 65536;
57
+ /** Largest instruction file this row will read; larger files are ignored. */
58
+ export declare const DEFAULT_MAX_SOURCE_BYTES = 32768;
59
+ /** Plugin configuration for the `multi-root-instructions` row. */
60
+ export interface Config {
61
+ /** Total budget for ALL additional roots (default {@link DEFAULT_MAX_BYTES}); non-positive disables the row. */
62
+ readonly maxBytes?: number;
63
+ /** Per-file source cap (default {@link DEFAULT_MAX_SOURCE_BYTES}). */
64
+ readonly maxSourceBytes?: number;
65
+ /** Ordered same-directory instruction candidates; upstream's defaults when omitted. */
66
+ readonly instructionFileCandidates?: readonly string[];
67
+ /** Ordered same-directory local-overlay candidates; upstream's defaults when omitted. */
68
+ readonly localInstructionFileCandidates?: readonly string[];
69
+ }
70
+ /** One root's freshly rendered instruction text. */
71
+ export interface RenderedRoot {
72
+ /** The canonical additional root. */
73
+ readonly root: string;
74
+ /** The rendered instruction text. */
75
+ readonly text: string;
76
+ }
77
+ /** One instruction file that is no longer present. */
78
+ export interface RemovedScope {
79
+ /** The additional root it belonged to. */
80
+ readonly root: string;
81
+ /** The instruction file's absolute path. */
82
+ readonly path: string;
83
+ }
84
+ /**
85
+ * The path one RAW tool call touched.
86
+ *
87
+ * `arguments` is the model-produced JSON string, so the path is only known after
88
+ * parsing it; a call that names no `file_path`, or is not one of the tools whose
89
+ * success makes a directory relevant, touched nothing this row cares about.
90
+ * @param name - the tool name the model called.
91
+ * @param argumentsJson - the tool call's raw arguments, exactly as produced.
92
+ * @returns the trimmed absolute-or-relative path, or `undefined`.
93
+ */
94
+ export declare function touchedPathOfToolCall(name: string, argumentsJson: string): string | undefined;
95
+ /**
96
+ * Compose the model-facing message body for one batch of changes.
97
+ *
98
+ * All three halves matter. A newly visible root — or a newly touched
99
+ * subdirectory — needs its rules; a root that left needs an explicit
100
+ * revocation; and a file that disappeared needs an explicit withdrawal, because
101
+ * the instructions it contributed are still sitting in the conversation history
102
+ * and would otherwise keep applying forever. Silence is not a retraction.
103
+ * @param rendered - roots whose instruction text is new or changed.
104
+ * @param revoked - canonical roots that left the workspace scope.
105
+ * @param removed - instruction files that are no longer present.
106
+ * @returns the message text, or `undefined` when there is nothing to say.
107
+ */
108
+ export declare function composeInstructionMessage(rendered: readonly RenderedRoot[], revoked: readonly string[], removed: readonly RemovedScope[]): string | undefined;
109
+ /** This row is gated on the compatibility contract like every other provider. */
110
+ export declare const inject: string[];
111
+ /**
112
+ * Mount the additional-root instruction provider.
113
+ *
114
+ * `fs` is a SOFT dependency, like the scope service's prompt contribution: a
115
+ * composition with no filesystem seam (headless probes, bare test contexts)
116
+ * contributes nothing instead of failing to load. The same is true of the
117
+ * upstream instruction package, which is an optional peer.
118
+ *
119
+ * Touch recording is registered OUTSIDE the `fs` injection, so a composition
120
+ * without a filesystem seam still learns which directories the session worked
121
+ * in; delivery is what needs the seam.
122
+ * @param ctx - the row's context.
123
+ * @param config - the row's configuration.
124
+ */
125
+ export declare function apply(ctx: Context, config?: Config): void;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Windows named-kernel-semaphore adapter for the registry authority lease.
3
+ *
4
+ * This follows the same kernel-object contract as upstream
5
+ * `SessionWriteLease` (published `@deepseek-ai/dsh-session-persistence-jsonl`
6
+ * 0.1.5-rc.2): a count-1 named semaphore, a zero-timeout wait, and release by
7
+ * restoring the count then closing the handle — the object dies with its last
8
+ * handle, including on process death. It is a local reimplementation; it does
9
+ * not deep-import `session-persistence-jsonl/src/win32.ts` (ADR-0002).
10
+ *
11
+ * Koffi is loaded lazily so POSIX processes never load it.
12
+ *
13
+ * @module @dsh-electron/dsh-plugin-multi-root-workspace/registry-lease-win32
14
+ */
15
+ /**
16
+ * Derive the semaphore name from the physical parent directory, resolving
17
+ * junctions, symlinks and short path aliases before case folding and hashing.
18
+ * @param path - the lock path; its parent exists, but the file need not.
19
+ * @returns the store's kernel-object name.
20
+ */
21
+ export declare function leaseNameWin32(path: string): string;
22
+ /** Acquire the semaphore for the physical store directory. */
23
+ export declare function acquireLockHandleWin32(path: string): Promise<number>;
24
+ /**
25
+ * Restore the semaphore count and close the handle (the object dies with its
26
+ * last handle, including on process death).
27
+ * @param handle - the open semaphore handle.
28
+ */
29
+ export declare function releaseLockHandleWin32(handle: number): Promise<void>;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Store-wide kernel lease for the multi-root registry.
3
+ *
4
+ * The JSON storage backend is memory-authoritative after `open`: it reads the
5
+ * domain document once, then every write is an atomic rewrite of that in-memory
6
+ * snapshot. Two DSH processes sharing one storage root can therefore each grant
7
+ * a stale set, and a later rewrite silently drops the other process's mutation.
8
+ * The in-process `chains` queue in `registry.ts` cannot see that race.
9
+ *
10
+ * This lease makes one process the Registry Authority for the whole
11
+ * `multi_root_workspace` medium. The arbiter is the kernel, matching upstream
12
+ * session JSONL persistence: POSIX non-blocking `flock(2)` via
13
+ * `@deepseek-ai/node-addon-system/flock`, Windows a named kernel semaphore.
14
+ * There is no TTL and no stale-PID algorithm — the kernel releases the lock
15
+ * when the holder's descriptor (or last object handle) closes, including on
16
+ * crash. See ADR-0007.
17
+ *
18
+ * @module @dsh-electron/dsh-plugin-multi-root-workspace/registry-lease
19
+ */
20
+ /** Thrown when another live process already holds the registry lease. */
21
+ export declare class RegistryLeaseContendedError extends Error {
22
+ /** Stable code the registry maps onto `registry-contended`. */
23
+ readonly code: "registry-contended";
24
+ /** Absolute lock path that was contended. */
25
+ readonly leasePath: string;
26
+ /**
27
+ * @param leasePath - the store-wide lock path that was contended.
28
+ */
29
+ constructor(leasePath: string);
30
+ }
31
+ /**
32
+ * One held store-wide registry lock. Constructed only by
33
+ * {@link RegistryAuthorityLease.acquire}; `release` closes the descriptor or
34
+ * handle, which is what releases the lock.
35
+ */
36
+ export declare class RegistryAuthorityLease {
37
+ private readonly held;
38
+ private released;
39
+ private constructor();
40
+ /**
41
+ * Acquire the exclusive kernel lock for one registry store.
42
+ *
43
+ * POSIX opens `leasePath` (creating it and its parent) and takes a
44
+ * non-blocking exclusive flock. Because flock names an inode, the holder then
45
+ * verifies the locked inode is still the file at `leasePath` and retries
46
+ * otherwise — an unlinked-and-recreated lock file carries a fresh inode.
47
+ * Windows never flocks the file: it holds a named kernel semaphore derived
48
+ * from the canonical path so readers and directory removal stay free.
49
+ * @param leasePath - absolute path of the lock file (store-wide, beside the domain JSON).
50
+ * @returns the held lease.
51
+ * @throws {RegistryLeaseContendedError} while another holder keeps the lock.
52
+ */
53
+ static acquire(leasePath: string): Promise<RegistryAuthorityLease>;
54
+ /**
55
+ * Release the kernel lock by closing its descriptor or handle. The POSIX
56
+ * lock file is never removed: keeping it preserves the stable inode later
57
+ * lockers verify against. POSIX close is synchronous so a fiber disposer that
58
+ * does not await this promise still drops the flock before returning.
59
+ * Idempotent.
60
+ */
61
+ release(): Promise<void>;
62
+ }
@@ -8,7 +8,7 @@
8
8
  * (`ctx.multiRootScope`). Neither provider learns that a registry exists —
9
9
  * `setAdditionalRoots()` stays the scope's only write port.
10
10
  *
11
- * Four deliberate properties:
11
+ * Five deliberate properties:
12
12
  *
13
13
  * - **Only usable roots are granted.** A registered directory that is absent
14
14
  * right now stays registered and is reported as `missing`, but is withheld
@@ -33,6 +33,11 @@
33
33
  * re-stats and re-canonicalizes every record, republishes the scope, and
34
34
  * leaves storage untouched — a read must not rewrite the store, and a
35
35
  * read-only refresh must not resurrect anything.
36
+ * - **One process is the Registry Authority.** The JSON backend is
37
+ * memory-authoritative after open, so two DSH processes sharing a storage
38
+ * root cannot each keep a live domain snapshot. A store-wide kernel lease
39
+ * (ADR-0007) elects one authority; a contended process publishes an empty
40
+ * scope, rejects mutations, and retries the lease from {@link MultiRootRegistry.refresh}.
36
41
  *
37
42
  * @module @dsh-electron/dsh-plugin-multi-root-workspace/registry
38
43
  */
@@ -46,6 +51,34 @@ export declare const DOMAIN_NAME = "multi_root_workspace";
46
51
  export declare const TABLE_NAME = "roots";
47
52
  /** Longest accepted display alias. */
48
53
  export declare const MAX_ALIAS_LENGTH = 120;
54
+ /**
55
+ * Operator-facing English for a contended lease. Host-side commands have no
56
+ * locale; the panel also shows this string verbatim via `RootsView.unavailable`.
57
+ */
58
+ export declare const REGISTRY_CONTENDED_MESSAGE = "the root registry is owned by another DSH process; additional roots are not granted here. Close the other process, then retry.";
59
+ /**
60
+ * Whether this process is the Registry Authority for the store-wide domain
61
+ * medium. Contended and storage-failed are both fail-closed: the scope stays
62
+ * empty and mutations reject.
63
+ */
64
+ export type RegistryAuthorityState = {
65
+ readonly kind: 'active';
66
+ } | {
67
+ readonly kind: 'contended';
68
+ } | {
69
+ readonly kind: 'storage-failed';
70
+ readonly reason: string;
71
+ };
72
+ /**
73
+ * Plugin configuration for the registry row. `leasePath` must sit beside the
74
+ * JSON backend's domain document; the patch default is
75
+ * `$DSH_HOME/storages/multi_root_workspace.lock`. A custom `storage-json.root`
76
+ * must override this in lockstep (ADR-0007).
77
+ */
78
+ export interface Config {
79
+ /** Absolute path of the store-wide kernel lock file. */
80
+ readonly leasePath?: string;
81
+ }
49
82
  declare module '@deepseek-ai/cordis' {
50
83
  interface Context {
51
84
  multiRootRegistry: MultiRootRegistry;
@@ -99,40 +132,59 @@ export interface AddRootInput {
99
132
  /** The registry service: `ctx.multiRootRegistry`. */
100
133
  export declare class MultiRootRegistry extends Service {
101
134
  static inject: string[];
102
- /** The open table; present once the service has started. */
103
- private table?;
104
- /** Why the store is unusable, when it is; every mutation then fails loudly. */
105
- private failure?;
135
+ /** Absolute lock path; absent means the row was composed without `leasePath`. */
136
+ private readonly leasePath;
137
+ /** Whether this process currently owns the store. */
138
+ private authorityState;
139
+ /** Held kernel lease; present once acquire succeeded, including after a failed open. */
140
+ private lease;
141
+ /** Open domain handle; present only while {@link authority} is `active`. */
142
+ private domain;
143
+ /** The open table; present once this process is the authority. */
144
+ private table;
106
145
  /** Last classified status list per canonical primary root. */
107
146
  private readonly cache;
108
147
  /** Signature of the last published grant per key, so a no-op refresh stays a no-op. */
109
148
  private readonly published;
110
149
  /** One promise chain per canonical primary root: mutations never interleave. */
111
150
  private readonly chains;
151
+ /** Store-wide queue for lease acquire / open / release. */
152
+ private authorityChain;
112
153
  /** Change listeners, keyed by their own disposer identity. */
113
154
  private readonly listeners;
155
+ /** True after a contended-lease warning has been emitted, so refresh does not spam. */
156
+ private loggedContended;
157
+ /** True once the fiber disposer has run: no further acquisition may happen. */
158
+ private disposed;
114
159
  /**
115
160
  * @param ctx - the host context; `storageDomain` and `multiRootScope` are injected.
161
+ * @param config - the patch row's `leasePath`.
116
162
  */
117
- constructor(ctx: Context);
163
+ constructor(ctx: Context, config?: Config);
118
164
  /**
119
- * Open the domain and publish every stored root into the scope.
165
+ * Become the Registry Authority if possible: acquire the store-wide lease,
166
+ * then open the domain and publish every stored root into the scope.
120
167
  *
121
- * A store this build cannot read (corrupt records, a version written by a
122
- * different plugin version) is reported loudly and degrades to the empty
123
- * root set instead of failing activation: granting nothing is the safe
124
- * direction, and a broken side store must not keep the whole harness from
125
- * starting. Every mutation then reports `storage-unavailable` with that
126
- * reason until the file is repaired or removed.
168
+ * A contended lease is fail-closed rather than a stale open: this process
169
+ * grants nothing and every mutation reports `registry-contended`. A store
170
+ * this build cannot read is reported loudly and also grants nothing. Neither
171
+ * case fails activation — a broken or contended side store must not keep the
172
+ * whole harness from starting. {@link MultiRootRegistry.refresh} retries.
127
173
  */
128
174
  protected [Service.init](): Promise<void>;
129
175
  /**
130
- * Why the store is unusable, when it is. Surfaces show this instead of a
131
- * silently empty list, so an operator can tell "no roots configured" from
132
- * "the configuration could not be read".
133
- * @returns the recorded failure text, or `undefined` while the store is healthy.
176
+ * Why the registry is not granting, when it is not. Surfaces show this
177
+ * instead of a silently empty list, so an operator can tell "no roots
178
+ * configured" from "another DSH process owns the store" or "the file could
179
+ * not be read".
180
+ * @returns the recorded failure text, or `undefined` while this process is the authority.
134
181
  */
135
182
  get unavailable(): string | undefined;
183
+ /**
184
+ * Whether this process is the Registry Authority for the store-wide medium.
185
+ * Tests and diagnostics read this; surfaces use {@link unavailable}.
186
+ */
187
+ get authority(): RegistryAuthorityState;
136
188
  /**
137
189
  * Every registration of one primary root, in registry order, as last
138
190
  * classified. A READ of the in-memory state: it never touches the filesystem
@@ -156,6 +208,11 @@ export declare class MultiRootRegistry extends Service {
156
208
  *
157
209
  * The serialized queue is shared with the mutations, so a refresh never
158
210
  * interleaves with a write that is mid-flight.
211
+ *
212
+ * Refresh is also the takeover path: it calls {@link MultiRootRegistry.ensureAuthority}
213
+ * first, so a process that started contended can become the authority after
214
+ * the previous holder exits, re-open the domain from disk, and publish the
215
+ * last durable state — without a restart.
159
216
  * @param primaryRoot - the session workspace root to re-check.
160
217
  * @returns the refreshed status list.
161
218
  */
@@ -284,10 +341,64 @@ export declare class MultiRootRegistry extends Service {
284
341
  /**
285
342
  * The open table.
286
343
  * @returns the live table.
287
- * @throws {RootValidationError} `storage-unavailable` when the store could
288
- * not be opened at activation, or the service has not started.
344
+ * @throws {RootValidationError} `registry-contended` when another DSH process
345
+ * owns the store, `storage-unavailable` when the store could not be opened,
346
+ * or an error when the service has not started.
289
347
  */
290
348
  private requireTable;
349
+ /**
350
+ * Reject mutations unless this process is the Registry Authority.
351
+ * Called before path validation so a contended process never mis-reports
352
+ * `missing` / `not-absolute` for a directory it is not allowed to register.
353
+ */
354
+ private requireActive;
355
+ /**
356
+ * Acquire the store-wide lease if this process does not already hold it, then
357
+ * open the domain from disk. Idempotent while active. A contended process
358
+ * retries here (the command `list` and the panel Retry button both call
359
+ * {@link MultiRootRegistry.refresh}).
360
+ */
361
+ private ensureAuthority;
362
+ /**
363
+ * Open the domain under an already-held lease and publish every stored root.
364
+ * On failure the lease is kept so a later refresh can retry the open without
365
+ * letting a second process grant from a stale snapshot.
366
+ */
367
+ private openDomain;
368
+ /** Fail closed: empty scope, no table, contended state. */
369
+ private enterContended;
370
+ /** Fail closed: empty scope, no table, storage-failed state. The lease, if held, stays. */
371
+ private enterStorageFailed;
372
+ /** Drop every grant this process has published. Idempotent. */
373
+ private withdrawPublished;
374
+ /**
375
+ * Drain every queued mutation, close the domain, then release the kernel
376
+ * lease — in that order, and inside the authority-transition queue.
377
+ *
378
+ * The order is the invariant, not a detail. The lease is what makes "the
379
+ * store is mine" true, so it must outlive every write this process will ever
380
+ * make, including the queued writes `close()` itself drains: a successor that
381
+ * acquired the lease and opened the medium in the window between a release
382
+ * and a `put()` would read a snapshot missing that mutation. Running the
383
+ * whole teardown through {@link MultiRootRegistry.serializeAuthority} is the
384
+ * other half: a `refresh()` that is mid-flight when the fiber disposes must
385
+ * not finish by opening a domain and a lease that nothing will ever close.
386
+ */
387
+ private releaseAuthority;
388
+ /**
389
+ * Wait until no per-primary-root mutation is queued or running.
390
+ *
391
+ * One pass is enough, and only because the authority state has already been
392
+ * flipped: no further mutation can be admitted (it would fail in
393
+ * `requireTable()`), and every mutation admitted earlier put its own tail in
394
+ * `chains` before it ran.
395
+ */
396
+ private drainMutations;
397
+ /**
398
+ * Run one authority-transition job after every job already queued. Acquire,
399
+ * open, and release never interleave with each other.
400
+ */
401
+ private serializeAuthority;
291
402
  }
292
403
  export type { AdditionalRootId, RegisteredRoot, RootRef, RootStatus };
293
404
  export default MultiRootRegistry;
@@ -108,7 +108,7 @@ export type RootRef = {
108
108
  readonly addedAt: string;
109
109
  };
110
110
  /** Every way root handling can fail, as a stable code the surfaces localize. */
111
- export type RootValidationCode = 'not-absolute' | 'missing' | 'not-a-directory' | 'equals-primary' | 'primary-overlap' | 'duplicate' | 'nested' | 'invalid-alias' | 'not-found' | 'invalid-ref' | 'storage-unavailable' | 'reveal-unavailable';
111
+ export type RootValidationCode = 'not-absolute' | 'missing' | 'not-a-directory' | 'equals-primary' | 'primary-overlap' | 'duplicate' | 'nested' | 'invalid-alias' | 'not-found' | 'invalid-ref' | 'storage-unavailable' | 'registry-contended' | 'reveal-unavailable' | 'session-not-found';
112
112
  /** A rejected root operation; `code` is the stable contract, `message` the fallback prose. */
113
113
  export declare class RootValidationError extends Error {
114
114
  readonly code: RootValidationCode;
@@ -26,9 +26,10 @@
26
26
  *
27
27
  * @module @dsh-electron/dsh-plugin-multi-root-workspace/sandbox
28
28
  */
29
- import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox';
29
+ import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox';
30
30
  import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local';
31
31
  import type { Config } from '@deepseek-ai/dsh-sandbox-local';
32
+ import type { UpstreamConfined } from './compat/sandbox-confine.ts';
32
33
  export type { Config };
33
34
  /**
34
35
  * Local process-sandbox provider over the multi-root scope. Registers as
@@ -48,11 +49,30 @@ export declare class MultiRootSandboxProvider extends LocalSandboxProvider {
48
49
  * bash executor and the PTY backend both hand their argv straight to the local
49
50
  * runtime in that mode. Widening therefore only ever ADDS the scope's
50
51
  * additional roots to an upstream `workspace-write` profile.
52
+ *
53
+ * The return shape is the INSTALLED upstream provider's: synchronous on a
54
+ * release with a synchronous `confine`, a promise on a release with an
55
+ * asynchronous one. `widenConfined` preserves whichever it is — see
56
+ * `./compat/sandbox-confine.ts`.
51
57
  * @param argv - the exact argv the caller is about to spawn.
52
58
  * @param policy - the file-effect policy this execution runs under.
59
+ * @param signal - cancellation, forwarded to releases that accept it.
53
60
  * @returns the argv to spawn instead, plus the selected backend's facts.
54
61
  */
55
- confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv;
62
+ confine(argv: readonly string[], policy: SandboxPolicy, signal?: AbortSignal): UpstreamConfined;
63
+ /**
64
+ * Graft the scope's additional roots onto one upstream `confine` result.
65
+ *
66
+ * Pure with respect to the upstream call: it only ever reads the scope and
67
+ * rewrites `argv`. `enforcement`, `denialSignatures` and `runnerFailureRules`
68
+ * are passed through untouched, because the bash executor derives its denial
69
+ * and enforcement reporting from them.
70
+ * @param confined - the upstream result for this call.
71
+ * @param argv - the caller's argv, needed to re-split the wrapped form.
72
+ * @param policy - the per-call policy.
73
+ * @returns the upstream result element for element, or one with a widened argv.
74
+ */
75
+ private widenForScope;
56
76
  /**
57
77
  * Report the documented first-release limitation exactly once: the Windows ACL
58
78
  * rung grants through per-workspace write SIDs rather than argv paths, so