@deepseek-ai/dsh-subagent 0.0.1-rc.1

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 (41) hide show
  1. package/LICENSE +28 -0
  2. package/README.i18n.yaml +6 -0
  3. package/README.md +132 -0
  4. package/README.zh.md +132 -0
  5. package/lib/index.js +2392 -0
  6. package/lib/invariant.js +76 -0
  7. package/lib/types/activation-setup-registry.d.ts +57 -0
  8. package/lib/types/activation-setup-registry.js +148 -0
  9. package/lib/types/child-agent.d.ts +139 -0
  10. package/lib/types/child-agent.js +169 -0
  11. package/lib/types/client.d.ts +7 -0
  12. package/lib/types/client.js +7 -0
  13. package/lib/types/continuation.d.ts +375 -0
  14. package/lib/types/continuation.js +951 -0
  15. package/lib/types/depth.d.ts +31 -0
  16. package/lib/types/depth.js +39 -0
  17. package/lib/types/descriptor-seed.d.ts +21 -0
  18. package/lib/types/descriptor-seed.js +24 -0
  19. package/lib/types/descriptor.d.ts +139 -0
  20. package/lib/types/descriptor.js +189 -0
  21. package/lib/types/error.d.ts +11 -0
  22. package/lib/types/error.js +14 -0
  23. package/lib/types/index.d.ts +278 -0
  24. package/lib/types/index.js +338 -0
  25. package/lib/types/invariant.d.ts +13 -0
  26. package/lib/types/invariant.js +91 -0
  27. package/lib/types/lifecycle.d.ts +93 -0
  28. package/lib/types/lifecycle.js +169 -0
  29. package/lib/types/list-children.d.ts +112 -0
  30. package/lib/types/list-children.js +316 -0
  31. package/lib/types/out-of-process.d.ts +115 -0
  32. package/lib/types/out-of-process.js +181 -0
  33. package/lib/types/projection-types.d.ts +60 -0
  34. package/lib/types/projection-types.js +7 -0
  35. package/lib/types/projection.d.ts +48 -0
  36. package/lib/types/projection.js +135 -0
  37. package/lib/types/run-settlement.d.ts +17 -0
  38. package/lib/types/run-settlement.js +59 -0
  39. package/lib/types/types.d.ts +293 -0
  40. package/lib/types/types.js +19 -0
  41. package/package.json +106 -0
@@ -0,0 +1,316 @@
1
+ /**
2
+ * Read-only enumeration of durable subagent children and descendant trees
3
+ * straight from the live session store and optional session persistence — no
4
+ * query service. Candidates come from one live-preferred corpus; each child's
5
+ * mode/label is the registered `subagent` projection unit's value, resolved
6
+ * down a three-rung ladder: the registry's watermark cache for a live child,
7
+ * a durable projection-cache row when it serves an own-suffix identity (the
8
+ * seq gate), and one persistence inspection folded through the registry
9
+ * otherwise, validated against the enumerated lifecycle. The projection fold
10
+ * is the single classification authority — this module parses no descriptor
11
+ * itself. Absent persistence, enumeration is live-only: a cold child is
12
+ * unreachable for resume anyway, so its absence is capability absence, not an
13
+ * error. The module owns no catalog state and does not consult Activation,
14
+ * Agent-registry, continuation-manager, or provider state.
15
+ *
16
+ * @module @deepseek-ai/dsh-subagent
17
+ */
18
+ import { SubagentError } from "./error.js";
19
+ /**
20
+ * Concurrent cold inspections per listing; a constant because it bounds one
21
+ * read-only scan of local media, not deployment behavior. Should a networked
22
+ * persistence backend appear, promote it to a validated `Config` field.
23
+ */
24
+ const COLD_READ_CONCURRENCY = 4;
25
+ /**
26
+ * Enumerate one parent's origin-classified direct children from the
27
+ * live-preferred merge of `ctx.sessions` and optional session persistence,
28
+ * serving each identity from the `subagent` projection unit: the registry's
29
+ * watermark snapshot for a live child; for a cold one, a durable
30
+ * projection-cache row when it serves an own-suffix identity (the seq gate),
31
+ * else one bounded-concurrency persistence inspection folded through the
32
+ * registry.
33
+ * @see SubagentService.listChildren for the public cancellation and failure contract.
34
+ * @param ctx - context carrying the session store, the projection registry,
35
+ * optional persistence, and the optional projection cache.
36
+ * @param parentSessionId - parent session whose direct children are listed.
37
+ * @param signal - caller-owned cancellation observed around every persistence read.
38
+ * @returns children and per-child diagnostics ordered by `createdAt`, then id.
39
+ * @throws {@link SubagentError} when the projection registry or the session
40
+ * store is not mounted, or the caller cancels the listing.
41
+ */
42
+ export async function listChildren(ctx, parentSessionId, signal) {
43
+ const listing = await prepareListing(ctx, signal);
44
+ const candidates = [...listing.corpus.values()]
45
+ .filter(record => record.header.parentSession === parentSessionId
46
+ && record.header.origin === 'subagent')
47
+ .sort(compareCorpusRecords);
48
+ const rows = await resolveCandidateRows(candidates, listing, signal);
49
+ return rows.filter((row) => row !== undefined);
50
+ }
51
+ /**
52
+ * Enumerate every session-backed subagent below one root in stable pre-order.
53
+ * Ordinary sessions and one-shot children remain traversal nodes, so a
54
+ * continuable child below either is still discovered. Classification uses the
55
+ * same projection-backed runtime as {@link listChildren}; no Agent is loaded or
56
+ * resumed.
57
+ * @see SubagentService.listDescendants for the public cancellation and failure contract.
58
+ * @param ctx - context carrying the session store, projection registry, and optional persistence/cache.
59
+ * @param rootSessionId - session whose complete descendant tree is listed.
60
+ * @param signal - caller-owned cancellation observed around every persistence read.
61
+ * @returns interpreted subagents with durable direct-parent and root-relative depth.
62
+ * @throws {@link SubagentError} under the same conditions as {@link listChildren}.
63
+ */
64
+ export async function listDescendants(ctx, rootSessionId, signal) {
65
+ const listing = await prepareListing(ctx, signal);
66
+ const positioned = descendantCandidates(listing.corpus, rootSessionId);
67
+ const rows = await resolveCandidateRows(positioned.map(candidate => candidate.record), listing, signal);
68
+ const entries = [];
69
+ positioned.forEach((position, index) => {
70
+ const row = rows[index];
71
+ if (row !== undefined) {
72
+ entries.push({ ...row, parentId: position.parentId, depth: position.depth });
73
+ }
74
+ });
75
+ return entries;
76
+ }
77
+ /** Resolve listing services once and build one live-preferred session corpus. */
78
+ async function prepareListing(ctx, signal) {
79
+ const projections = ctx.get('sessionProjections');
80
+ // Checked before any read, even with zero candidates: mode/label are the
81
+ // row's strong contract, so a missing fold capability is a deterministic
82
+ // deployment configuration error, never an empty success.
83
+ if (projections === undefined) {
84
+ throw new SubagentError('listing subagents requires the sessionProjections registry (load @deepseek-ai/dsh-session-projection)', 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE');
85
+ }
86
+ // Strict global read, never the `ctx.sessions` property proxy: the proxy is
87
+ // caller-scope bound, so a consumer plugin without its own `sessions`
88
+ // injection (the model-facing tool, the API proxy) would throw on access.
89
+ const sessions = ctx.get('sessions');
90
+ if (sessions === undefined) {
91
+ throw new SubagentError('listing subagents requires the session store (load @deepseek-ai/dsh-session)', 'SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE');
92
+ }
93
+ assertListingNotCancelled(signal);
94
+ const persistence = ctx.get('sessionPersistence');
95
+ // Optional acceleration only: an absent cache service just means every
96
+ // cold candidate takes the authoritative preparation rung, so it carries
97
+ // no error code and no configuration check.
98
+ const cache = ctx.get('sessionProjectionCache');
99
+ let persistedHeaders = [];
100
+ if (persistence !== undefined) {
101
+ try {
102
+ persistedHeaders = await persistence.list(signal);
103
+ }
104
+ catch (error) {
105
+ // The backend may reject with its own abort failure after observing the
106
+ // forwarded signal; cancellation stays a stable subagent failure.
107
+ assertListingNotCancelled(signal);
108
+ throw error;
109
+ }
110
+ assertListingNotCancelled(signal);
111
+ }
112
+ // Live-preferred merge without header reconciliation: a live record wins
113
+ // its id wholesale, exactly as a live-preferred corpus would serve it.
114
+ const corpus = new Map();
115
+ for (const header of persistedHeaders)
116
+ corpus.set(header.id, { header, live: undefined });
117
+ for (const session of sessions.list()) {
118
+ corpus.set(session.header.id, { header: session.header, live: session });
119
+ }
120
+ const subagentParents = new Set();
121
+ for (const record of corpus.values()) {
122
+ if (record.header.origin === 'subagent' && record.header.parentSession !== undefined) {
123
+ subagentParents.add(record.header.parentSession);
124
+ }
125
+ }
126
+ return { projections, persistence, cache, corpus, subagentParents };
127
+ }
128
+ /** Resolve projection-backed rows for aligned candidates with bounded cold reads. */
129
+ async function resolveCandidateRows(candidates, listing, signal) {
130
+ const { projections, persistence, cache, subagentParents } = listing;
131
+ const rows = Array.from({ length: candidates.length });
132
+ const coldReads = [];
133
+ candidates.forEach((candidate, index) => {
134
+ const childId = candidate.header.id;
135
+ if (candidate.live === undefined) {
136
+ coldReads.push({ index, header: candidate.header });
137
+ return;
138
+ }
139
+ // The registry's watermark cache serves the live value with zero log
140
+ // reads; a live child without an identity yet is the creation window
141
+ // before the establishing provider appends its descriptor.
142
+ let identity;
143
+ try {
144
+ identity = projections.snapshot(candidate.live).values.subagent;
145
+ }
146
+ catch {
147
+ // The snapshot folds EVERY registered unit over this child's log, so
148
+ // any unit's fold or schema can reject damaged payloads. That is
149
+ // deterministic data damage in this one child; it degrades to one
150
+ // corrupt diagnostic instead of failing the whole listing.
151
+ rows[index] = { kind: 'diagnostic', id: childId, reason: 'corrupt' };
152
+ return;
153
+ }
154
+ // The unit's serializable no-value sentinel is `null`; `undefined` can
155
+ // only mean the key was dropped at a JSON boundary. Both are no value.
156
+ if (identity === undefined || identity === null)
157
+ return;
158
+ rows[index] = childRow(childId, identity, 'running', subagentParents.has(childId));
159
+ });
160
+ // Cold candidates exist only when persistence listed them, so the narrow
161
+ // re-check is about types, not reachability.
162
+ if (persistence !== undefined && coldReads.length > 0) {
163
+ const queue = [...coldReads];
164
+ await Promise.all(Array.from({ length: Math.min(COLD_READ_CONCURRENCY, queue.length) }, async () => {
165
+ for (let job = queue.shift(); job !== undefined; job = queue.shift()) {
166
+ rows[job.index] = await resolveColdIdentity(persistence, projections, cache, job.header, subagentParents.has(job.header.id), signal);
167
+ }
168
+ }));
169
+ }
170
+ assertListingNotCancelled(signal);
171
+ return rows;
172
+ }
173
+ /** Build origin-classified candidates from the complete tree without recursion. */
174
+ function descendantCandidates(corpus, rootSessionId) {
175
+ const children = new Map();
176
+ for (const record of corpus.values()) {
177
+ const parentId = record.header.parentSession;
178
+ if (parentId === undefined)
179
+ continue;
180
+ const siblings = children.get(parentId);
181
+ if (siblings === undefined)
182
+ children.set(parentId, [record]);
183
+ else
184
+ siblings.push(record);
185
+ }
186
+ for (const siblings of children.values())
187
+ siblings.sort(compareCorpusRecords);
188
+ const positioned = [];
189
+ const stack = (children.get(rootSessionId) ?? [])
190
+ .map(record => ({ record, parentId: rootSessionId, depth: 1 }))
191
+ .reverse();
192
+ const visited = new Set([rootSessionId]);
193
+ while (stack.length > 0) {
194
+ // The length guard proves one frame exists.
195
+ // oxlint-disable-next-line typescript/no-non-null-assertion
196
+ const position = stack.pop();
197
+ const id = position.record.header.id;
198
+ if (visited.has(id))
199
+ continue;
200
+ visited.add(id);
201
+ if (position.record.header.origin === 'subagent')
202
+ positioned.push(position);
203
+ const descendants = children.get(id) ?? [];
204
+ for (const record of [...descendants].reverse()) {
205
+ stack.push({ record, parentId: id, depth: position.depth + 1 });
206
+ }
207
+ }
208
+ return positioned;
209
+ }
210
+ /** Compare siblings by durable creation time, then id. */
211
+ function compareCorpusRecords(a, b) {
212
+ return a.header.createdAt - b.header.createdAt || a.header.id.localeCompare(b.header.id);
213
+ }
214
+ /**
215
+ * Resolve one cold candidate down the remaining ladder: a durable
216
+ * projection-cache row when it serves an own-suffix identity (the seq gate),
217
+ * otherwise one persistence inspection folded through the projection
218
+ * registry (the same detached recipe the API proxy uses for detached session
219
+ * projections). A failed inspection is one transient `unavailable` row
220
+ * retried on the next listing; an inspection naming another lifecycle, and a
221
+ * settled log the fold cannot identify — or that makes any registered unit
222
+ * throw — are final, so they report `corrupt`.
223
+ */
224
+ async function resolveColdIdentity(persistence, projections, cache, header, hasChildren, signal) {
225
+ const childId = header.id;
226
+ if (cache !== undefined) {
227
+ let cached;
228
+ try {
229
+ cached = cache.cachedSnapshot(header)?.values.subagent;
230
+ }
231
+ catch {
232
+ // Unlike the preparation fold below, a throwing cache read renders no
233
+ // verdict: the cache is derived data, so its damage (a poisoned stored
234
+ // row of ANY unit) silently falls through to the authoritative re-fold.
235
+ cached = undefined;
236
+ }
237
+ // A child's OWN descriptor is immutable once appended, so a cached
238
+ // identity is final only when the seq gate proves it was folded from the
239
+ // own suffix: a creation-window checkpoint may instead carry a fork
240
+ // seed's replayed ANCESTOR descriptor (seq below `seedLength`), which
241
+ // must not outrank the re-fold. Everything else also falls through to
242
+ // preparation: an absent key (a cut before any descriptor) and the
243
+ // `null` sentinel, whose verdict belongs to the authoritative re-fold,
244
+ // not to a derived row.
245
+ if (cached !== undefined && cached !== null && cached.seq >= (header.seedLength ?? 0)) {
246
+ return childRow(childId, cached, 'inactive', hasChildren);
247
+ }
248
+ }
249
+ assertListingNotCancelled(signal);
250
+ let inspected;
251
+ try {
252
+ inspected = await persistence.inspect(childId, signal);
253
+ }
254
+ catch {
255
+ // Per-child isolation: the child vanished or its backend read failed —
256
+ // one diagnostic row, and the listing itself still succeeds.
257
+ assertListingNotCancelled(signal);
258
+ return { kind: 'diagnostic', id: childId, reason: 'unavailable' };
259
+ }
260
+ assertListingNotCancelled(signal);
261
+ // A session id names a slot, not a lifecycle: a child deleted and
262
+ // re-published under another owner between the enumeration and this read
263
+ // must not leak into the old parent's listing.
264
+ if (!sameLifecycle(inspected.meta, header)) {
265
+ return { kind: 'diagnostic', id: childId, reason: 'corrupt' };
266
+ }
267
+ let identity;
268
+ try {
269
+ identity = projections.restore({}, inspected.events, 0).snapshot.values.subagent;
270
+ }
271
+ catch {
272
+ // The restore folds EVERY registered unit over this child's log, so any
273
+ // unit's fold or schema can reject damaged payloads — deterministic data
274
+ // damage in this one child, contained as its own corrupt diagnostic.
275
+ return { kind: 'diagnostic', id: childId, reason: 'corrupt' };
276
+ }
277
+ if (identity === undefined || identity === null) {
278
+ return { kind: 'diagnostic', id: childId, reason: 'corrupt' };
279
+ }
280
+ return childRow(childId, identity, 'inactive', hasChildren);
281
+ }
282
+ /** Materialize one served identity as its child row. */
283
+ function childRow(id, identity, activity, hasChildren) {
284
+ return identity.mode === 'one-shot'
285
+ ? {
286
+ kind: 'child',
287
+ id,
288
+ mode: 'one-shot',
289
+ ...identity.label !== undefined ? { label: identity.label } : {},
290
+ activity,
291
+ hasChildren,
292
+ }
293
+ : {
294
+ kind: 'child',
295
+ id,
296
+ mode: 'continuable',
297
+ label: identity.label,
298
+ activity,
299
+ hasChildren,
300
+ };
301
+ }
302
+ /** Immutable header fields that distinguish one session lifecycle from another under the same id. */
303
+ const LIFECYCLE_WITNESS_KEYS = [
304
+ 'version', 'id', 'createdAt', 'cwd', 'parentSession', 'seedLength', 'delegationDepth',
305
+ ];
306
+ /** Whether an inspected log still belongs to the enumerated lifecycle. */
307
+ function sameLifecycle(meta, expected) {
308
+ return LIFECYCLE_WITNESS_KEYS.every(key => meta[key] === expected[key]);
309
+ }
310
+ /** Stop a listing at its next cancellation checkpoint. */
311
+ function assertListingNotCancelled(signal) {
312
+ if (signal?.aborted) {
313
+ throw new SubagentError('subagent listing was cancelled', 'CANCELLED');
314
+ }
315
+ }
316
+ //# sourceMappingURL=list-children.js.map
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Provider-side vocabulary for OUT-OF-PROCESS subagent backends — the pieces
3
+ * that enforce this seam's own contracts around a child in another process:
4
+ * the no-capabilities advertisement, timing-bound validation, child
5
+ * working-directory resolution (config override, else the delegating parent
6
+ * session's workspace), the never-reject result settlement, and the standard
7
+ * run-handle publication. Backends compose these with their own wire drivers;
8
+ * the process machinery itself (spawn, env scrub, tree-scoped teardown)
9
+ * belongs to the `dsh-subprocess` seam.
10
+ *
11
+ * @module @deepseek-ai/dsh-subagent/out-of-process
12
+ */
13
+ import type { ContentBlock } from '@deepseek-ai/dsh-llm';
14
+ import type { SubagentCapabilities, SubagentResult, SubagentRun, SubagentStopReason } from './types.ts';
15
+ /**
16
+ * The capability advertisement of an out-of-process backend: NONE. A child in
17
+ * another process cannot honor parent-enforced start features
18
+ * (`outputSchema`/`maxDepth`/`toolFilter`/`persona`), so the service rejects a
19
+ * request needing any of them before `start` runs — never accepted-then-ignored.
20
+ */
21
+ export declare const NO_START_CAPABILITIES: SubagentCapabilities;
22
+ /**
23
+ * Assert a configured timing bound is a positive finite number (it bounds a
24
+ * teardown or shutdown wait; zero, negative, or NaN would skip or wedge it).
25
+ * @param prefix - the consuming plugin's diagnostic prefix (e.g. `subagent-acp`).
26
+ * @param name - the config field name, for the diagnostic.
27
+ * @param value - the configured value.
28
+ */
29
+ export declare function assertPositiveFinite(prefix: string, name: string, value: number): void;
30
+ /**
31
+ * Assert `cwd` can actually host the child: absolute (it doubles as the
32
+ * child's workspace identity, and a relative path would be re-anchored to the
33
+ * server process's launch directory) and an existing directory (fail here,
34
+ * before the process boundary, instead of as an ambiguous spawn ENOENT).
35
+ * @param prefix - the consuming plugin's diagnostic prefix.
36
+ * @param label - which source supplied the value, for the diagnostic.
37
+ * @param cwd - the candidate working directory.
38
+ * @returns `cwd`, validated.
39
+ */
40
+ export declare function assertUsableCwd(prefix: string, label: string, cwd: string): string;
41
+ /**
42
+ * Validate a configured `cwd` override ONCE, at plugin load: reject the empty
43
+ * string (`path.resolve('')` is the process cwd — it would silently
44
+ * reintroduce the launch-directory fallback this resolution removes),
45
+ * interpret a relative path against the harness launch directory, and require
46
+ * an enterable directory.
47
+ * @param prefix - the consuming plugin's diagnostic prefix.
48
+ * @param cwd - the configured override, or `undefined` when the config omits it.
49
+ * @returns the validated absolute override, or `undefined` when omitted.
50
+ */
51
+ export declare function validateConfiguredCwd(prefix: string, cwd: string | undefined): string | undefined;
52
+ /**
53
+ * Resolve the child's working directory at start: the deployment override
54
+ * when configured (already validated at load), else the parent session's
55
+ * workspace cwd (validated here, its earliest resolvable point). Fails loud
56
+ * when neither exists — falling back to the harness process cwd would
57
+ * silently bind the child to the server's launch directory instead of the
58
+ * delegating session's workspace (one server process serves many sessions,
59
+ * each with its own cwd).
60
+ * @param prefix - the consuming plugin's diagnostic prefix.
61
+ * @param configured - the load-validated override, or `undefined`.
62
+ * @param parentCwd - the delegating parent session's workspace cwd, if any.
63
+ * @returns the absolute child working directory.
64
+ */
65
+ export declare function resolveChildCwd(prefix: string, configured: string | undefined, parentCwd: string | undefined): string;
66
+ /** Inputs to {@link settleRunResult}. */
67
+ export interface RunResultSettlement {
68
+ /** The turn attempt (typically racing local cancellation); returns the terminal result. */
69
+ attempt: () => Promise<SubagentResult>;
70
+ /** Snapshot the provider exposes when cancellation or failure wins settlement. */
71
+ collectOutput: () => ContentBlock[];
72
+ /** Whether local cancellation settled before the attempt's outcome is observed. */
73
+ cancelled: () => boolean;
74
+ /** Diagnostic sink for a failure flattened to a stop reason; a throw from it is contained. */
75
+ onError?: ((error: Error, stopReason: SubagentStopReason) => void) | undefined;
76
+ /** The request's cancellation signal (the listener is removed at settlement). */
77
+ signal: AbortSignal;
78
+ /** The abort listener registered on {@link signal} at start. */
79
+ onAbort: () => void;
80
+ }
81
+ /**
82
+ * Settle an out-of-process run result under the seam contract: `result` never
83
+ * rejects after publication. A normally completed or rejected attempt resolves
84
+ * as `aborted` when cancellation already settled locally; another rejection is
85
+ * flattened to `stopReason: 'error'` through the contained diagnostic sink.
86
+ * The abort listener is removed on every path.
87
+ * @param parts - the attempt, output snapshot, cancellation state, sink, and signal wiring.
88
+ * @returns the terminal result (never a rejection).
89
+ */
90
+ export declare function settleRunResult(parts: RunResultSettlement): Promise<SubagentResult>;
91
+ /** Inputs to {@link subprocessRunHandle}. */
92
+ export interface SubprocessRunHandleParts {
93
+ /** The parent-scoped run id. */
94
+ id: SubagentRun['id'];
95
+ /** The flattened, never-rejecting result (the seam contract). */
96
+ result: Promise<SubagentResult>;
97
+ /** The request's cancellation signal (the listener is removed on dispose). */
98
+ signal: AbortSignal;
99
+ /** The abort listener registered on {@link signal} at start. */
100
+ onAbort: () => void;
101
+ /** Settle local cancellation so {@link result} resolves without the child. */
102
+ requestCancel: () => void;
103
+ /** Tear the child process down to quiescence (backend-owned ladder). */
104
+ teardown: () => Promise<void>;
105
+ }
106
+ /**
107
+ * Publish the seam run handle for an out-of-process child. `dispose()` is
108
+ * idempotent (one memoized teardown): it removes the abort listener, settles
109
+ * local cancellation — there is no assumption the child cooperates — and then
110
+ * awaits the backend's teardown to actual exit.
111
+ * @param parts - the run identity, result, cancellation wiring, and teardown.
112
+ * @returns the seam run handle (`localAgent` is `undefined` for remote runs).
113
+ */
114
+ export declare function subprocessRunHandle(parts: SubprocessRunHandleParts): SubagentRun;
115
+ //# sourceMappingURL=out-of-process.d.ts.map
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Provider-side vocabulary for OUT-OF-PROCESS subagent backends — the pieces
3
+ * that enforce this seam's own contracts around a child in another process:
4
+ * the no-capabilities advertisement, timing-bound validation, child
5
+ * working-directory resolution (config override, else the delegating parent
6
+ * session's workspace), the never-reject result settlement, and the standard
7
+ * run-handle publication. Backends compose these with their own wire drivers;
8
+ * the process machinery itself (spawn, env scrub, tree-scoped teardown)
9
+ * belongs to the `dsh-subprocess` seam.
10
+ *
11
+ * @module @deepseek-ai/dsh-subagent/out-of-process
12
+ */
13
+ import { accessSync, constants, statSync } from 'node:fs';
14
+ import { isAbsolute, resolve } from 'node:path';
15
+ /**
16
+ * The capability advertisement of an out-of-process backend: NONE. A child in
17
+ * another process cannot honor parent-enforced start features
18
+ * (`outputSchema`/`maxDepth`/`toolFilter`/`persona`), so the service rejects a
19
+ * request needing any of them before `start` runs — never accepted-then-ignored.
20
+ */
21
+ export const NO_START_CAPABILITIES = Object.freeze({
22
+ outputSchema: false,
23
+ depthLimit: false,
24
+ toolFilter: false,
25
+ persona: false,
26
+ });
27
+ /**
28
+ * Assert a configured timing bound is a positive finite number (it bounds a
29
+ * teardown or shutdown wait; zero, negative, or NaN would skip or wedge it).
30
+ * @param prefix - the consuming plugin's diagnostic prefix (e.g. `subagent-acp`).
31
+ * @param name - the config field name, for the diagnostic.
32
+ * @param value - the configured value.
33
+ */
34
+ export function assertPositiveFinite(prefix, name, value) {
35
+ if (!Number.isFinite(value) || value <= 0) {
36
+ throw new Error(`${prefix}: ${name} must be a positive finite number`);
37
+ }
38
+ }
39
+ /**
40
+ * Whether `path` names an existing directory the harness can ENTER. The
41
+ * search-permission probe matters: `statSync().isDirectory()` is true for a
42
+ * mode-600 directory, but a subprocess cwd needs `X_OK` or spawn fails EACCES.
43
+ */
44
+ function isEnterableDirectory(path) {
45
+ try {
46
+ if (!statSync(path).isDirectory())
47
+ return false;
48
+ accessSync(path, constants.X_OK);
49
+ return true;
50
+ }
51
+ catch {
52
+ // statSync/accessSync throw only filesystem access errors here
53
+ // (ENOENT/EACCES/ENOTDIR/…), and every one of them means the path cannot
54
+ // serve as the child's cwd.
55
+ return false;
56
+ }
57
+ }
58
+ /**
59
+ * Assert `cwd` can actually host the child: absolute (it doubles as the
60
+ * child's workspace identity, and a relative path would be re-anchored to the
61
+ * server process's launch directory) and an existing directory (fail here,
62
+ * before the process boundary, instead of as an ambiguous spawn ENOENT).
63
+ * @param prefix - the consuming plugin's diagnostic prefix.
64
+ * @param label - which source supplied the value, for the diagnostic.
65
+ * @param cwd - the candidate working directory.
66
+ * @returns `cwd`, validated.
67
+ */
68
+ export function assertUsableCwd(prefix, label, cwd) {
69
+ if (!isAbsolute(cwd)) {
70
+ throw new Error(`${prefix}: ${label} must be an absolute path: ${cwd}`);
71
+ }
72
+ if (!isEnterableDirectory(cwd)) {
73
+ throw new Error(`${prefix}: ${label} is not an accessible directory: ${cwd}`);
74
+ }
75
+ return cwd;
76
+ }
77
+ /**
78
+ * Validate a configured `cwd` override ONCE, at plugin load: reject the empty
79
+ * string (`path.resolve('')` is the process cwd — it would silently
80
+ * reintroduce the launch-directory fallback this resolution removes),
81
+ * interpret a relative path against the harness launch directory, and require
82
+ * an enterable directory.
83
+ * @param prefix - the consuming plugin's diagnostic prefix.
84
+ * @param cwd - the configured override, or `undefined` when the config omits it.
85
+ * @returns the validated absolute override, or `undefined` when omitted.
86
+ */
87
+ export function validateConfiguredCwd(prefix, cwd) {
88
+ if (cwd === undefined)
89
+ return undefined;
90
+ if (cwd === '') {
91
+ throw new Error(`${prefix}: config cwd must not be empty — omit the key to inherit the parent session cwd`);
92
+ }
93
+ return assertUsableCwd(prefix, 'config cwd', resolve(cwd));
94
+ }
95
+ /**
96
+ * Resolve the child's working directory at start: the deployment override
97
+ * when configured (already validated at load), else the parent session's
98
+ * workspace cwd (validated here, its earliest resolvable point). Fails loud
99
+ * when neither exists — falling back to the harness process cwd would
100
+ * silently bind the child to the server's launch directory instead of the
101
+ * delegating session's workspace (one server process serves many sessions,
102
+ * each with its own cwd).
103
+ * @param prefix - the consuming plugin's diagnostic prefix.
104
+ * @param configured - the load-validated override, or `undefined`.
105
+ * @param parentCwd - the delegating parent session's workspace cwd, if any.
106
+ * @returns the absolute child working directory.
107
+ */
108
+ export function resolveChildCwd(prefix, configured, parentCwd) {
109
+ if (configured !== undefined)
110
+ return configured;
111
+ if (parentCwd === undefined) {
112
+ throw new Error(`${prefix}: no working directory for the child — configure \`cwd\` or delegate from a parent session that has one`);
113
+ }
114
+ return assertUsableCwd(prefix, 'parent session cwd', parentCwd);
115
+ }
116
+ /** Normalize an unknown thrown value to an Error (the catch binding is `unknown`). */
117
+ function toError(value) {
118
+ // The rejecting surfaces (wire clients, spawn failures) only throw
119
+ // `Error`s; the `String(value)` arm is a defensive fallback for a non-Error
120
+ // throw the typed surfaces cannot produce.
121
+ /* v8 ignore next */
122
+ return value instanceof Error ? value : new Error(String(value));
123
+ }
124
+ /**
125
+ * Settle an out-of-process run result under the seam contract: `result` never
126
+ * rejects after publication. A normally completed or rejected attempt resolves
127
+ * as `aborted` when cancellation already settled locally; another rejection is
128
+ * flattened to `stopReason: 'error'` through the contained diagnostic sink.
129
+ * The abort listener is removed on every path.
130
+ * @param parts - the attempt, output snapshot, cancellation state, sink, and signal wiring.
131
+ * @returns the terminal result (never a rejection).
132
+ */
133
+ export async function settleRunResult(parts) {
134
+ try {
135
+ const result = await parts.attempt();
136
+ return parts.cancelled()
137
+ ? { output: parts.collectOutput(), stopReason: 'aborted' }
138
+ : result;
139
+ }
140
+ catch (error) {
141
+ // Cover a rejection already queued when cancellation arrives.
142
+ if (parts.cancelled())
143
+ return { output: parts.collectOutput(), stopReason: 'aborted' };
144
+ // Flatten post-publication transport failures while preserving diagnostics.
145
+ try {
146
+ parts.onError?.(toError(error), 'error');
147
+ }
148
+ catch {
149
+ // The diagnostic sink cannot reject the run result.
150
+ }
151
+ return { output: parts.collectOutput(), stopReason: 'error' };
152
+ }
153
+ finally {
154
+ parts.signal.removeEventListener('abort', parts.onAbort);
155
+ }
156
+ }
157
+ /**
158
+ * Publish the seam run handle for an out-of-process child. `dispose()` is
159
+ * idempotent (one memoized teardown): it removes the abort listener, settles
160
+ * local cancellation — there is no assumption the child cooperates — and then
161
+ * awaits the backend's teardown to actual exit.
162
+ * @param parts - the run identity, result, cancellation wiring, and teardown.
163
+ * @returns the seam run handle (`localAgent` is `undefined` for remote runs).
164
+ */
165
+ export function subprocessRunHandle(parts) {
166
+ let disposal;
167
+ return {
168
+ id: parts.id,
169
+ localAgent: undefined,
170
+ result: parts.result,
171
+ dispose() {
172
+ if (disposal !== undefined)
173
+ return disposal;
174
+ parts.signal.removeEventListener('abort', parts.onAbort);
175
+ parts.requestCancel();
176
+ disposal = parts.teardown();
177
+ return disposal;
178
+ },
179
+ };
180
+ }
181
+ //# sourceMappingURL=out-of-process.js.map
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Pure client-safe subagent projection vocabulary.
3
+ *
4
+ * @module @deepseek-ai/dsh-subagent/projection-types
5
+ */
6
+ /** Durable active-turn timing for one descriptor-backed child session. */
7
+ export interface SubagentTimingProjection {
8
+ /** Milliseconds accumulated across completed turns after the child's own descriptor. */
9
+ settledMs: number;
10
+ /** Same-cut bounds of the currently open turn, when one has not reached `turn/end`. */
11
+ active?: {
12
+ /** Start of the open turn. */
13
+ since: number;
14
+ /** Latest event time folded into this projection cut. */
15
+ through: number;
16
+ };
17
+ }
18
+ /**
19
+ * Durable identity of one descriptor-backed subagent session: lifecycle mode
20
+ * plus creation label, folded last-wins from `subagent/descriptor` events.
21
+ * Label strength follows the descriptor schema: a continuable child always
22
+ * carries one, a one-shot child may omit it.
23
+ */
24
+ export type SubagentIdentityProjection = {
25
+ /** A terminal one-shot child. */
26
+ mode: 'one-shot';
27
+ /** Optional durable creation label from the child's descriptor. */
28
+ label?: string;
29
+ /**
30
+ * Seq of the `subagent/descriptor` event this identity was folded from.
31
+ * `seq >= header.seedLength` proves the identity comes from the child's
32
+ * OWN log suffix — where a descriptor is immutable once appended — and
33
+ * not from a fork seed's replayed ancestor descriptor.
34
+ */
35
+ seq: number;
36
+ } | {
37
+ /** A resumable conversation. */
38
+ mode: 'continuable';
39
+ /** Durable creation label from the child's descriptor. */
40
+ label: string;
41
+ /** Seq of the folded descriptor event; see the one-shot arm for the own-suffix proof. */
42
+ seq: number;
43
+ };
44
+ declare module '@deepseek-ai/dsh-session-projection/types' {
45
+ interface SessionProjectionMap {
46
+ /** Active-turn duration for a descriptor-backed subagent session. */
47
+ subagentTiming: SubagentTimingProjection;
48
+ /**
49
+ * Identity of a descriptor-backed subagent session. `null` ⟺ no valid
50
+ * descriptor (missing, malformed, or unrecognized-version — deliberately
51
+ * undistinguished). The sentinel is deliberately serializable: a
52
+ * value pushed over JSON transports must survive `JSON.stringify`
53
+ * losslessly, where an `undefined` field would be dropped and a stale
54
+ * identity would survive on the receiving side. The entry itself stays
55
+ * non-optional.
56
+ */
57
+ subagent: SubagentIdentityProjection | null;
58
+ }
59
+ }
60
+ //# sourceMappingURL=projection-types.d.ts.map