@moxxy/core 0.4.0 → 0.5.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 (151) hide show
  1. package/dist/events/log.d.ts.map +1 -1
  2. package/dist/events/log.js +38 -1
  3. package/dist/events/log.js.map +1 -1
  4. package/dist/permissions/engine.d.ts.map +1 -1
  5. package/dist/permissions/engine.js +80 -17
  6. package/dist/permissions/engine.js.map +1 -1
  7. package/dist/plugins/discovery.d.ts.map +1 -1
  8. package/dist/plugins/discovery.js +48 -7
  9. package/dist/plugins/discovery.js.map +1 -1
  10. package/dist/plugins/host.d.ts.map +1 -1
  11. package/dist/plugins/host.js +54 -11
  12. package/dist/plugins/host.js.map +1 -1
  13. package/dist/plugins/lifecycle.d.ts.map +1 -1
  14. package/dist/plugins/lifecycle.js +7 -0
  15. package/dist/plugins/lifecycle.js.map +1 -1
  16. package/dist/plugins/loader.d.ts +9 -0
  17. package/dist/plugins/loader.d.ts.map +1 -1
  18. package/dist/plugins/loader.js +34 -8
  19. package/dist/plugins/loader.js.map +1 -1
  20. package/dist/plugins/registry-kinds.d.ts +21 -2
  21. package/dist/plugins/registry-kinds.d.ts.map +1 -1
  22. package/dist/plugins/registry-kinds.js +3 -1
  23. package/dist/plugins/registry-kinds.js.map +1 -1
  24. package/dist/plugins/toposort.d.ts.map +1 -1
  25. package/dist/plugins/toposort.js +43 -18
  26. package/dist/plugins/toposort.js.map +1 -1
  27. package/dist/preferences.d.ts.map +1 -1
  28. package/dist/preferences.js +5 -4
  29. package/dist/preferences.js.map +1 -1
  30. package/dist/registries/active-backend-registry.d.ts.map +1 -1
  31. package/dist/registries/active-backend-registry.js +10 -1
  32. package/dist/registries/active-backend-registry.js.map +1 -1
  33. package/dist/registries/channels.d.ts +2 -1
  34. package/dist/registries/channels.d.ts.map +1 -1
  35. package/dist/registries/channels.js +30 -20
  36. package/dist/registries/channels.js.map +1 -1
  37. package/dist/registries/commands.d.ts.map +1 -1
  38. package/dist/registries/commands.js +27 -8
  39. package/dist/registries/commands.js.map +1 -1
  40. package/dist/registries/isolators.d.ts +27 -1
  41. package/dist/registries/isolators.d.ts.map +1 -1
  42. package/dist/registries/isolators.js +35 -1
  43. package/dist/registries/isolators.js.map +1 -1
  44. package/dist/registries/modes.d.ts +4 -0
  45. package/dist/registries/modes.d.ts.map +1 -1
  46. package/dist/registries/modes.js +15 -4
  47. package/dist/registries/modes.js.map +1 -1
  48. package/dist/registries/providers.d.ts +8 -0
  49. package/dist/registries/providers.d.ts.map +1 -1
  50. package/dist/registries/providers.js +8 -0
  51. package/dist/registries/providers.js.map +1 -1
  52. package/dist/registries/skills.d.ts +9 -0
  53. package/dist/registries/skills.d.ts.map +1 -1
  54. package/dist/registries/skills.js +22 -0
  55. package/dist/registries/skills.js.map +1 -1
  56. package/dist/requirements.d.ts.map +1 -1
  57. package/dist/requirements.js +7 -1
  58. package/dist/requirements.js.map +1 -1
  59. package/dist/run-turn.d.ts.map +1 -1
  60. package/dist/run-turn.js +65 -17
  61. package/dist/run-turn.js.map +1 -1
  62. package/dist/session.d.ts +9 -0
  63. package/dist/session.d.ts.map +1 -1
  64. package/dist/session.js +25 -3
  65. package/dist/session.js.map +1 -1
  66. package/dist/sessions/persistence.d.ts +15 -0
  67. package/dist/sessions/persistence.d.ts.map +1 -1
  68. package/dist/sessions/persistence.js +116 -13
  69. package/dist/sessions/persistence.js.map +1 -1
  70. package/dist/skills/loader.d.ts.map +1 -1
  71. package/dist/skills/loader.js +11 -2
  72. package/dist/skills/loader.js.map +1 -1
  73. package/dist/skills/synthesize.d.ts.map +1 -1
  74. package/dist/skills/synthesize.js +87 -36
  75. package/dist/skills/synthesize.js.map +1 -1
  76. package/dist/subagents/events.js +33 -2
  77. package/dist/subagents/events.js.map +1 -1
  78. package/dist/subagents/registry.d.ts +22 -1
  79. package/dist/subagents/registry.d.ts.map +1 -1
  80. package/dist/subagents/registry.js +72 -2
  81. package/dist/subagents/registry.js.map +1 -1
  82. package/dist/subagents/run-child.d.ts.map +1 -1
  83. package/dist/subagents/run-child.js +138 -46
  84. package/dist/subagents/run-child.js.map +1 -1
  85. package/dist/surfaces/host.d.ts +10 -0
  86. package/dist/surfaces/host.d.ts.map +1 -1
  87. package/dist/surfaces/host.js +41 -3
  88. package/dist/surfaces/host.js.map +1 -1
  89. package/dist/usage-stats.d.ts +7 -1
  90. package/dist/usage-stats.d.ts.map +1 -1
  91. package/dist/usage-stats.js +41 -15
  92. package/dist/usage-stats.js.map +1 -1
  93. package/dist/view/parse.d.ts.map +1 -1
  94. package/dist/view/parse.js +119 -19
  95. package/dist/view/parse.js.map +1 -1
  96. package/package.json +4 -4
  97. package/src/events/log.test.ts +45 -0
  98. package/src/events/log.ts +39 -1
  99. package/src/permissions/engine.test.ts +60 -0
  100. package/src/permissions/engine.ts +81 -16
  101. package/src/plugins/discovery.test.ts +59 -1
  102. package/src/plugins/discovery.ts +57 -9
  103. package/src/plugins/host.test.ts +40 -0
  104. package/src/plugins/host.ts +48 -10
  105. package/src/plugins/lifecycle.test.ts +29 -0
  106. package/src/plugins/lifecycle.ts +7 -0
  107. package/src/plugins/loader.ts +45 -7
  108. package/src/plugins/registry-kinds.test.ts +53 -0
  109. package/src/plugins/registry-kinds.ts +25 -3
  110. package/src/plugins/toposort.ts +45 -17
  111. package/src/preferences.test.ts +52 -1
  112. package/src/preferences.ts +5 -4
  113. package/src/registries/active-backend-registry.ts +10 -1
  114. package/src/registries/channels.test.ts +79 -0
  115. package/src/registries/channels.ts +33 -18
  116. package/src/registries/commands.test.ts +65 -0
  117. package/src/registries/commands.ts +25 -8
  118. package/src/registries/isolators.test.ts +37 -0
  119. package/src/registries/isolators.ts +48 -1
  120. package/src/registries/modes.test.ts +21 -1
  121. package/src/registries/modes.ts +15 -2
  122. package/src/registries/providers.ts +8 -0
  123. package/src/registries/semantics.test.ts +33 -1
  124. package/src/registries/skills.ts +28 -0
  125. package/src/registries/transcribers.test.ts +13 -0
  126. package/src/requirements.test.ts +35 -0
  127. package/src/requirements.ts +7 -1
  128. package/src/run-turn.test.ts +219 -0
  129. package/src/run-turn.ts +68 -18
  130. package/src/session.test.ts +24 -0
  131. package/src/session.ts +24 -3
  132. package/src/sessions/page.test.ts +17 -0
  133. package/src/sessions/persistence.test.ts +93 -1
  134. package/src/sessions/persistence.ts +138 -30
  135. package/src/skills/loader.test.ts +18 -0
  136. package/src/skills/loader.ts +12 -1
  137. package/src/skills/synthesize.test.ts +59 -0
  138. package/src/skills/synthesize.ts +89 -36
  139. package/src/subagents/events.test.ts +56 -0
  140. package/src/subagents/events.ts +32 -2
  141. package/src/subagents/registry.test.ts +106 -0
  142. package/src/subagents/registry.ts +77 -3
  143. package/src/subagents/run-child.test.ts +125 -2
  144. package/src/subagents/run-child.ts +150 -47
  145. package/src/surfaces/host.test.ts +71 -1
  146. package/src/surfaces/host.ts +39 -2
  147. package/src/usage-stats.test.ts +99 -1
  148. package/src/usage-stats.ts +42 -17
  149. package/src/view/parse-extended.test.ts +80 -0
  150. package/src/view/parse.test.ts +14 -0
  151. package/src/view/parse.ts +113 -18
@@ -1,8 +1,14 @@
1
- import { describe, expect, it } from 'vitest';
1
+ import { describe, expect, it, vi } from 'vitest';
2
2
  import type { ModeContext, ModeDef, MoxxyEvent, ProviderDef } from '@moxxy/sdk';
3
3
  import { defineMode, defineProvider, definePlugin } from '@moxxy/sdk';
4
4
  import { Session } from '../session.js';
5
- import { runChildTurn, type SubagentRuntime } from './run-child.js';
5
+ import {
6
+ continueChildTurn,
7
+ createSubagentSpawner,
8
+ runChildTurn,
9
+ type SubagentRuntime,
10
+ } from './run-child.js';
11
+ import { clearRetainedChildren } from './registry.js';
6
12
 
7
13
  // A mode that reports the model it was handed — lets tests observe the
8
14
  // child's EFFECTIVE model through the returned result text.
@@ -183,3 +189,120 @@ describe('runChildTurn model resolution', () => {
183
189
  expect(result.text).toBe('cheap-model');
184
190
  });
185
191
  });
192
+
193
+ // A mode that runs instantly on its FIRST invocation but blocks on an external
194
+ // gate on every later turn — lets a test hold a continue() turn mid-flight while
195
+ // a second continue() races it (without hanging the initial spawn).
196
+ function makeGatedMode(gate: Promise<void>): ModeDef {
197
+ let calls = 0;
198
+ return defineMode({
199
+ name: 'gated',
200
+ run: async function* (ctx: ModeContext): AsyncIterable<MoxxyEvent> {
201
+ calls += 1;
202
+ if (calls > 1) await gate;
203
+ await ctx.emit({
204
+ type: 'assistant_message',
205
+ sessionId: ctx.sessionId,
206
+ turnId: ctx.turnId,
207
+ source: 'assistant',
208
+ content: 'done',
209
+ stopReason: 'end_turn',
210
+ });
211
+ },
212
+ });
213
+ }
214
+
215
+ describe('spawnAll per-child degradation', () => {
216
+ it('isolates one child setup throw — siblings still resolve, batch never rejects', async () => {
217
+ const session = buildSession(['parent-model']);
218
+ const rt = buildRuntime(session, 'parent-model');
219
+ const spawner = createSubagentSpawner(rt);
220
+
221
+ // Force a PRE-`try` setup throw for exactly the SECOND child: buildChildContext
222
+ // calls appContext() for every child, so throw on its second invocation. With
223
+ // Promise.all this would reject the whole batch and orphan the first (still
224
+ // running) child's promise → an unhandledRejection; with allSettled it must
225
+ // degrade to one error-bearing result while the sibling succeeds.
226
+ const realAppContext = session.appContext.bind(session);
227
+ let appCtxCalls = 0;
228
+ vi.spyOn(session, 'appContext').mockImplementation(() => {
229
+ appCtxCalls += 1;
230
+ if (appCtxCalls === 2) throw new Error('boom: provider config gone');
231
+ return realAppContext();
232
+ });
233
+
234
+ const results = await spawner.spawnAll([
235
+ { prompt: 'echo', mode: 'echo-model', label: 'good' },
236
+ { prompt: 'echo', mode: 'echo-model', label: 'doomed' },
237
+ ]);
238
+
239
+ // The batch resolves (never rejects) with one result PER spec, in input order.
240
+ expect(results).toHaveLength(2);
241
+ expect(results.map((r) => r.label)).toEqual(['good', 'doomed']);
242
+ // Exactly one child degraded to an error-bearing result; the sibling succeeded.
243
+ const errored = results.filter((r) => r.stopReason === 'error');
244
+ const ok = results.filter((r) => r.stopReason === 'end_turn');
245
+ expect(errored).toHaveLength(1);
246
+ expect(ok).toHaveLength(1);
247
+ expect(errored[0]!.error?.message).toContain('boom');
248
+ expect(ok[0]!.text).toBe('parent-model');
249
+ vi.restoreAllMocks();
250
+ });
251
+
252
+ it('returns one result per spec in input order on the all-success path', async () => {
253
+ const session = buildSession(['parent-model']);
254
+ const rt = buildRuntime(session, 'parent-model');
255
+ const spawner = createSubagentSpawner(rt);
256
+
257
+ const results = await spawner.spawnAll([
258
+ { prompt: 'a', mode: 'echo-model', label: 'one' },
259
+ { prompt: 'b', mode: 'echo-model', label: 'two' },
260
+ { prompt: 'c', mode: 'echo-model', label: 'three' },
261
+ ]);
262
+
263
+ expect(results.map((r) => r.label)).toEqual(['one', 'two', 'three']);
264
+ expect(results.every((r) => r.stopReason === 'end_turn')).toBe(true);
265
+ });
266
+ });
267
+
268
+ describe('continueChildTurn re-entrancy', () => {
269
+ it('rejects a concurrent continue() for the same retained child (claim-then-run)', async () => {
270
+ let openGate!: () => void;
271
+ const gate = new Promise<void>((resolve) => {
272
+ openGate = resolve;
273
+ });
274
+ const session = new Session({ cwd: '/tmp', silent: true });
275
+ session.pluginHost.registerStatic(
276
+ definePlugin({
277
+ name: 'gated-test',
278
+ version: '0.0.0',
279
+ providers: [makeProvider(['m'])],
280
+ modes: [makeGatedMode(gate)],
281
+ }),
282
+ );
283
+ session.providers.setActive('listed');
284
+ session.modes.setActive('gated');
285
+
286
+ const rt = buildRuntime(session, 'm');
287
+ const first = await runChildTurn({
288
+ rt,
289
+ spec: { prompt: 'go', mode: 'gated', retainSession: true },
290
+ retainSession: true,
291
+ });
292
+ const childId = first.childSessionId;
293
+
294
+ // Start the first continue (blocks inside the gated mode), then race a
295
+ // second continue for the same id before the first settles.
296
+ const p1 = continueChildTurn({ childSessionId: childId, prompt: 'again' });
297
+ // The entry is claimed synchronously, so the second continue must fail fast.
298
+ await expect(
299
+ continueChildTurn({ childSessionId: childId, prompt: 'race' }),
300
+ ).rejects.toThrow(/no retained subagent session/);
301
+
302
+ openGate();
303
+ const r1 = await p1;
304
+ expect(r1.text).toBe('done');
305
+
306
+ clearRetainedChildren(session.id);
307
+ });
308
+ });
@@ -46,9 +46,10 @@ import {
46
46
  } from './events.js';
47
47
  import { buildFilteredToolRegistry } from './tools.js';
48
48
  import {
49
- getRetainedChild,
49
+ claimRetainedChild,
50
50
  registerRetainedChild,
51
51
  releaseRetainedChild,
52
+ unclaimRetainedChild,
52
53
  type RetainedChildSession,
53
54
  } from './registry.js';
54
55
 
@@ -86,10 +87,13 @@ export async function runChildTurn(args: {
86
87
  if ('failure' in resolved) return resolved.failure;
87
88
  const { strategy, strategyName } = resolved;
88
89
 
90
+ // `undefined` means "inherit the full parent registry"; a present-but-empty
91
+ // array means "deny all" (least-privilege). Collapsing the two would turn an
92
+ // explicit [] into full tool inheritance — the opposite of the caller's intent.
89
93
  const toolRegistry: ToolRegistry =
90
- spec.allowedTools && spec.allowedTools.length > 0
91
- ? buildFilteredToolRegistry(parentSession.tools, new Set(spec.allowedTools))
92
- : parentSession.tools;
94
+ spec.allowedTools === undefined
95
+ ? parentSession.tools
96
+ : buildFilteredToolRegistry(parentSession.tools, new Set(spec.allowedTools));
93
97
 
94
98
  const childModel = await resolveChildModel(rt, spec, label, childSessionId);
95
99
 
@@ -122,7 +126,7 @@ export async function runChildTurn(args: {
122
126
  });
123
127
 
124
128
  if (retainSession) {
125
- registerRetainedChild({
129
+ const evicted = registerRetainedChild({
126
130
  label,
127
131
  childSessionId,
128
132
  childTurnId,
@@ -133,53 +137,98 @@ export async function runChildTurn(args: {
133
137
  strategyName,
134
138
  parentSession,
135
139
  parentTurnId,
140
+ tokensUsed: capture.tokensUsed,
136
141
  });
142
+ // A capped/TTL-expired paused child is now unreachable: its `continue()`
143
+ // will fail. Surface that on the EVICTED child's own parent log (best-effort
144
+ // — a warning append must never take this spawn down) so the operator who
145
+ // started it isn't left waiting on a resume that can no longer happen.
146
+ await warnEvicted(evicted);
137
147
  }
138
148
 
139
149
  return capture.result;
140
150
  }
141
151
 
152
+ /**
153
+ * Emit a `subagent_warning` on each evicted child's OWN parent log so the
154
+ * operator who spawned a now-unreachable paused child sees why its `continue()`
155
+ * will fail. Best-effort: a warning-append reject (or a since-closed parent
156
+ * session) must never propagate out of the spawn that triggered the eviction.
157
+ */
158
+ async function warnEvicted(evicted: ReadonlyArray<RetainedChildSession>): Promise<void> {
159
+ for (const e of evicted) {
160
+ try {
161
+ await emitSubagentWarning(
162
+ e.parentSession,
163
+ e.parentTurnId,
164
+ e.label,
165
+ e.childSessionId,
166
+ `retained subagent "${e.label}" was evicted before resume ` +
167
+ `(retention cap or TTL reached); its continue() will no longer work`,
168
+ );
169
+ } catch {
170
+ // A closed/failing parent log must not abort the spawn that evicted it.
171
+ }
172
+ }
173
+ }
174
+
142
175
  export async function continueChildTurn(args: {
143
176
  childSessionId: SessionId;
144
177
  prompt: string;
145
178
  label?: string;
146
179
  }): Promise<SubagentResult> {
147
- const retained = getRetainedChild(args.childSessionId);
180
+ // Claim-then-run: atomically remove the entry from the registry and mark it
181
+ // busy so a racing continue()/release() for the same id can't observe the
182
+ // live entry and drive strategy.run over the same childLog/childCtx in
183
+ // parallel (interleaved appends, double-seeded prompts, double release).
184
+ const retained = claimRetainedChild(args.childSessionId);
148
185
  if (!retained) {
149
186
  throw new Error(`no retained subagent session for "${String(args.childSessionId)}"`);
150
187
  }
151
188
 
152
- await retained.childLog.append({
153
- type: 'user_prompt',
154
- sessionId: retained.childSessionId,
155
- turnId: retained.childTurnId,
156
- source: 'user',
157
- text: args.prompt,
158
- });
189
+ try {
190
+ await retained.childLog.append({
191
+ type: 'user_prompt',
192
+ sessionId: retained.childSessionId,
193
+ turnId: retained.childTurnId,
194
+ source: 'user',
195
+ text: args.prompt,
196
+ });
159
197
 
160
- const rt: SubagentRuntime = {
161
- parentSession: retained.parentSession,
162
- parentTurnId: retained.parentTurnId,
163
- parentSignal: retained.childCtx.signal,
164
- parentModel: retained.childCtx.model,
165
- };
198
+ const rt: SubagentRuntime = {
199
+ parentSession: retained.parentSession,
200
+ parentTurnId: retained.parentTurnId,
201
+ // Re-derive the resume signal from the still-live owning session rather
202
+ // than reusing the possibly-already-aborted per-turn signal captured at
203
+ // first-turn spawn time (which would cancel the resume before any work).
204
+ parentSignal: retained.parentSession.signal,
205
+ parentModel: retained.childCtx.model,
206
+ };
166
207
 
167
- const capture = await executeChildLoop({
168
- rt,
169
- spec: retained.spec,
170
- label: args.label ?? retained.label,
171
- childSessionId: retained.childSessionId,
172
- childTurnId: retained.childTurnId,
173
- childLog: retained.childLog,
174
- childCtx: retained.childCtx,
175
- strategy: retained.strategy,
176
- strategyName: retained.strategyName,
177
- emitCompleted: true,
178
- skipStartEvent: true,
179
- });
208
+ const capture = await executeChildLoop({
209
+ rt,
210
+ spec: retained.spec,
211
+ label: args.label ?? retained.label,
212
+ childSessionId: retained.childSessionId,
213
+ childTurnId: retained.childTurnId,
214
+ childLog: retained.childLog,
215
+ childCtx: retained.childCtx,
216
+ strategy: retained.strategy,
217
+ strategyName: retained.strategyName,
218
+ emitCompleted: true,
219
+ skipStartEvent: true,
220
+ // Carry forward the cost accumulated across prior turns so the deferred
221
+ // subagent_completed reports the retained session's cumulative usage, not
222
+ // just this last continue's delta.
223
+ priorTokensUsed: retained.tokensUsed ?? 0,
224
+ });
180
225
 
181
- releaseRetainedChild(args.childSessionId);
182
- return capture.result;
226
+ return capture.result;
227
+ } finally {
228
+ // The entry was already removed by the claim; just drop the busy marker so
229
+ // a future continue() (if the session were re-registered) isn't blocked.
230
+ unclaimRetainedChild(args.childSessionId);
231
+ }
183
232
  }
184
233
 
185
234
  async function executeChildLoop(args: {
@@ -194,7 +243,8 @@ async function executeChildLoop(args: {
194
243
  strategyName: string;
195
244
  emitCompleted: boolean;
196
245
  skipStartEvent?: boolean;
197
- }): Promise<{ result: SubagentResult }> {
246
+ priorTokensUsed?: number;
247
+ }): Promise<{ result: SubagentResult; tokensUsed: number }> {
198
248
  const {
199
249
  rt,
200
250
  spec,
@@ -207,6 +257,7 @@ async function executeChildLoop(args: {
207
257
  strategyName,
208
258
  emitCompleted,
209
259
  skipStartEvent,
260
+ priorTokensUsed = 0,
210
261
  } = args;
211
262
  const { parentSession, parentTurnId } = rt;
212
263
 
@@ -214,7 +265,7 @@ async function executeChildLoop(args: {
214
265
  text: '',
215
266
  stopReason: 'end_turn' as StopReason,
216
267
  error: null as string | null,
217
- tokensUsed: 0,
268
+ tokensUsed: priorTokensUsed,
218
269
  };
219
270
 
220
271
  const unsubCapture = childLog.subscribe((e) => {
@@ -280,7 +331,7 @@ async function executeChildLoop(args: {
280
331
  );
281
332
  }
282
333
 
283
- return { result };
334
+ return { result, tokensUsed: capture.tokensUsed };
284
335
  }
285
336
 
286
337
  async function resolveStrategy(
@@ -296,23 +347,26 @@ async function resolveStrategy(
296
347
  const exact = parentSession.modes.list().find((s) => s.name === requestedStrategy);
297
348
  if (exact) return { strategy: exact, strategyName: requestedStrategy };
298
349
 
299
- // Fall back to the default mode if the model invented a name
300
- // (e.g. "react"). Failing the child outright wastes the user's turn —
301
- // any reasonable agent task can run on the default mode. We surface the
302
- // fallback as a non-fatal warning event so the operator sees it.
303
- const fallback = parentSession.modes.list().find((s) => s.name === 'default');
350
+ // Fall back when the model invented a name (e.g. "react"). Failing the child
351
+ // outright wastes the user's turn — any reasonable agent task can run on the
352
+ // session's current strategy. Prefer a mode literally named "default", then
353
+ // the session's active mode (a host may ship a renamed/alternative default),
354
+ // surfacing the fallback as a non-fatal warning so the operator sees it.
355
+ const fallback =
356
+ parentSession.modes.list().find((s) => s.name === 'default') ??
357
+ safeActiveMode(parentSession);
304
358
  if (fallback) {
305
359
  await emitSubagentWarning(
306
360
  parentSession,
307
361
  parentTurnId,
308
362
  label,
309
363
  childSessionId,
310
- `unknown mode "${requestedStrategy}" — falling back to "default"`,
364
+ `unknown mode "${requestedStrategy}" — falling back to "${fallback.name}"`,
311
365
  );
312
- return { strategy: fallback, strategyName: 'default' };
366
+ return { strategy: fallback, strategyName: fallback.name };
313
367
  }
314
368
 
315
- // No default mode either — that's a config error, not a model mistake.
369
+ // No fallback mode at all — that's a config error, not a model mistake.
316
370
  await emitSubagentStart(parentSession, parentTurnId, label, childSessionId, spec, requestedStrategy);
317
371
  const errorMsg = `Subagent failed: unknown mode "${requestedStrategy}" and no fallback available`;
318
372
  await emitSubagentCompleted(parentSession, parentTurnId, label, childSessionId, '', 'error', errorMsg, spec.agentType ?? 'default', 0);
@@ -327,6 +381,17 @@ async function resolveStrategy(
327
381
  };
328
382
  }
329
383
 
384
+ /** `modes.getActive()` throws when no mode is active; treat that as "no fallback". */
385
+ function safeActiveMode(
386
+ parentSession: SessionRuntime,
387
+ ): ReturnType<SessionRuntime['modes']['list']>[number] | undefined {
388
+ try {
389
+ return parentSession.modes.getActive();
390
+ } catch {
391
+ return undefined;
392
+ }
393
+ }
394
+
330
395
  /**
331
396
  * Resolve the child's effective model. A `spec.model` usually comes from the
332
397
  * calling LLM (dispatch_agent's free-form string field), which sometimes
@@ -405,15 +470,53 @@ function buildChildContext(
405
470
  };
406
471
  }
407
472
 
473
+ /**
474
+ * Build an error-bearing {@link SubagentResult} for a spec whose `runChildTurn`
475
+ * threw during setup (before it could degrade the error into a result itself).
476
+ * Keeps `spawnAll` total: one result per spec, in input order, never a thrown
477
+ * batch. The real child id is unknown here (setup failed before it was captured),
478
+ * so a fresh placeholder id is minted; the result still carries the failing
479
+ * spec's label and the error message.
480
+ */
481
+ function spawnFailureResult(spec: SubagentSpec, reason: unknown): SubagentResult {
482
+ const message = reason instanceof Error ? reason.message : String(reason);
483
+ return {
484
+ label: spec.label ?? 'subagent',
485
+ childSessionId: newSessionId(),
486
+ text: '',
487
+ stopReason: 'error' as StopReason,
488
+ error: { message },
489
+ };
490
+ }
491
+
408
492
  export function createSubagentSpawner(rt: SubagentRuntime): SubagentSpawner {
409
493
  return {
410
494
  async spawn(spec) {
411
495
  return runChildTurn({ rt, spec, retainSession: spec.retainSession === true });
412
496
  },
413
497
  async spawnAll(specs) {
414
- return Promise.all(
415
- specs.map((s) => runChildTurn({ rt, spec: s, retainSession: s.retainSession === true })),
498
+ // Per-child degradation: a single child's PRE-`try` setup throw (no active
499
+ // provider, a `compactors.getActive()` config error, a log-append reject in
500
+ // `resolveStrategy`) must NOT reject the whole batch. `Promise.all` would do
501
+ // exactly that, and worse — it would orphan the still-running sibling
502
+ // promises, whose later settlement surfaces as an `unhandledRejection`.
503
+ // Convert every spec into one result in input order; a setup throw becomes
504
+ // an error-bearing `SubagentResult` so a fan-out partially succeeds instead
505
+ // of taking the parent turn down with it.
506
+ const settled = await Promise.all(
507
+ specs.map(async (spec): Promise<SubagentResult> => {
508
+ try {
509
+ return await runChildTurn({ rt, spec, retainSession: spec.retainSession === true });
510
+ } catch (err) {
511
+ // A PRE-`try` setup throw inside runChildTurn (no active provider, a
512
+ // failing getActive(), a log-append reject in resolveStrategy) is
513
+ // caught HERE per-child so it can't reject the batch or orphan a
514
+ // sibling. The outer Promise.all is now safe: every element resolves.
515
+ return spawnFailureResult(spec, err);
516
+ }
517
+ }),
416
518
  );
519
+ return settled;
417
520
  },
418
521
  async continue(args: SubagentContinueArgs) {
419
522
  return continueChildTurn(args);
@@ -1,4 +1,4 @@
1
- import { describe, it, expect } from 'vitest';
1
+ import { describe, it, expect, vi } from 'vitest';
2
2
  import {
3
3
  defineSurface,
4
4
  type SurfaceDataMessage,
@@ -93,6 +93,76 @@ describe('SurfaceHostImpl viewer ref-counting', () => {
93
93
  });
94
94
  });
95
95
 
96
+ describe('SurfaceHostImpl shutdown leak/hang hardening', () => {
97
+ it('closeAll closes an open() that resolves AFTER teardown (no leaked instance)', async () => {
98
+ // The open()'s def.open promise resolves only after closeAll has run; the
99
+ // freshly-created instance must be torn down, not installed as an orphan.
100
+ let resolveOpen!: () => void;
101
+ const gate = new Promise<void>((r) => {
102
+ resolveOpen = r;
103
+ });
104
+ const state = { closed: 0, instances: [] as SurfaceInstance[] };
105
+ const def = defineSurface({
106
+ kind: 'terminal',
107
+ open: async () => {
108
+ await gate;
109
+ const instance: SurfaceInstance = {
110
+ id: 'terminal',
111
+ kind: 'terminal',
112
+ onData: () => () => {},
113
+ input: () => {},
114
+ close: () => {
115
+ state.closed += 1;
116
+ },
117
+ };
118
+ state.instances.push(instance);
119
+ return instance;
120
+ },
121
+ });
122
+ const host = makeHost(def);
123
+
124
+ const openPromise = host.open('terminal'); // in-flight, parked on the gate
125
+ const closeAll = host.closeAll(); // sets disposed; awaits the in-flight open
126
+ resolveOpen(); // open resolves only now, after teardown began
127
+ await expect(openPromise).rejects.toThrow(/has been closed/);
128
+ await closeAll;
129
+
130
+ // The instance was created — and immediately closed, never installed.
131
+ expect(state.instances).toHaveLength(1);
132
+ expect(state.closed).toBe(1);
133
+ // A stray open() after shutdown is rejected before creating anything.
134
+ await expect(host.open('terminal')).rejects.toThrow(/has been closed/);
135
+ });
136
+
137
+ it('a wedged instance.close() does not hang closeAll forever', async () => {
138
+ vi.useFakeTimers();
139
+ try {
140
+ const state = { closed: 0 };
141
+ const def = defineSurface({
142
+ kind: 'terminal',
143
+ open: () => ({
144
+ id: 'terminal',
145
+ kind: 'terminal',
146
+ onData: () => () => {},
147
+ input: () => {},
148
+ // Never resolves: a wedged PTY/browser teardown.
149
+ close: () => new Promise<void>(() => {}),
150
+ }),
151
+ });
152
+ const host = makeHost(def);
153
+ await host.open('terminal');
154
+
155
+ const done = host.closeAll();
156
+ // Without the timeout this would never settle; advance past it.
157
+ await vi.advanceTimersByTimeAsync(6000);
158
+ await expect(done).resolves.toBeUndefined();
159
+ expect(state.closed).toBe(0); // close() never resolved, but we moved on
160
+ } finally {
161
+ vi.useRealTimers();
162
+ }
163
+ });
164
+ });
165
+
96
166
  /**
97
167
  * A controllable surface whose instance lets the test PUSH frames (`emit`),
98
168
  * record resize calls, and run unsubscribe on close — enough to assert the
@@ -25,6 +25,8 @@ import type { SurfaceRegistryImpl } from '../registries/surfaces.js';
25
25
  * existing resource and gets its {@link SurfaceInstance.snapshot}.
26
26
  */
27
27
  export class SurfaceHostImpl implements SurfaceHost {
28
+ /** Upper bound on a single instance.close() before we give up waiting on it. */
29
+ private static readonly CLOSE_TIMEOUT_MS = 5000;
28
30
  /** Open instances, keyed by kind (one shared instance per kind). */
29
31
  private readonly instances = new Map<SurfaceKind, SurfaceInstance>();
30
32
  /** Per-instance unsubscribe from its onData, dropped on close. */
@@ -44,6 +46,8 @@ export class SurfaceHostImpl implements SurfaceHost {
44
46
  * snapshot, but keystrokes/navigation silently vanish).
45
47
  */
46
48
  private readonly refs = new Map<SurfaceKind, number>();
49
+ /** Set once closeAll() runs: rejects/ tears down any open() that races teardown. */
50
+ private disposed = false;
47
51
 
48
52
  constructor(
49
53
  private readonly registry: SurfaceRegistryImpl,
@@ -77,6 +81,7 @@ export class SurfaceHostImpl implements SurfaceHost {
77
81
  }
78
82
 
79
83
  async open(kind: SurfaceKind): Promise<OpenSurfaceResult> {
84
+ if (this.disposed) throw new Error('SurfaceHost has been closed');
80
85
  const existing = this.instances.get(kind);
81
86
  if (existing) {
82
87
  this.retain(kind);
@@ -95,6 +100,12 @@ export class SurfaceHostImpl implements SurfaceHost {
95
100
 
96
101
  const promise = (async (): Promise<OpenSurfaceResult> => {
97
102
  const instance = await def.open(this.ctx);
103
+ // If teardown raced this create, never install the live resource — close
104
+ // it immediately so we don't leak a PTY/page/child process post-shutdown.
105
+ if (this.disposed) {
106
+ await this.safeClose(instance);
107
+ throw new Error('SurfaceHost has been closed');
108
+ }
98
109
  this.instances.set(kind, instance);
99
110
  this.refs.set(kind, 1);
100
111
  // Re-emit this instance's frames as multiplexed SurfaceDataMessages.
@@ -138,13 +149,33 @@ export class SurfaceHostImpl implements SurfaceHost {
138
149
  this.unsubs.get(surfaceId)?.();
139
150
  this.unsubs.delete(surfaceId);
140
151
  this.instances.delete(kind);
152
+ await this.safeClose(instance, surfaceId);
153
+ }
154
+
155
+ /**
156
+ * Tear an instance down without ever blocking forever: a wedged PTY/browser
157
+ * whose close() neither resolves nor rejects must not stall session shutdown.
158
+ * Races close() against a timeout and swallows (logs) any rejection.
159
+ */
160
+ private async safeClose(instance: SurfaceInstance, surfaceId = instance.id): Promise<void> {
161
+ let timer: ReturnType<typeof setTimeout> | undefined;
141
162
  try {
142
- await instance.close();
163
+ await Promise.race([
164
+ Promise.resolve(instance.close()),
165
+ new Promise<void>((resolve) => {
166
+ timer = setTimeout(() => {
167
+ this.logger?.warn?.('SurfaceHost: close timed out', { surfaceId });
168
+ resolve();
169
+ }, SurfaceHostImpl.CLOSE_TIMEOUT_MS);
170
+ }),
171
+ ]);
143
172
  } catch (err) {
144
173
  this.logger?.warn?.('SurfaceHost: close failed', {
145
174
  surfaceId,
146
175
  err: err instanceof Error ? err.message : String(err),
147
176
  });
177
+ } finally {
178
+ if (timer) clearTimeout(timer);
148
179
  }
149
180
  }
150
181
 
@@ -168,9 +199,15 @@ export class SurfaceHostImpl implements SurfaceHost {
168
199
 
169
200
  async closeAll(): Promise<void> {
170
201
  // Session shutdown: tear every instance down regardless of viewer refs.
202
+ this.disposed = true;
203
+ // Drain in-flight opens first. With `disposed` set, each racing open closes
204
+ // its freshly-created instance itself and rejects, so nothing new lands in
205
+ // `instances` after this await — no leaked PTY/page/child process.
206
+ await Promise.allSettled([...this.opening.values()]);
171
207
  for (const kind of [...this.instances.keys()]) this.refs.set(kind, 1);
208
+ // Run closes concurrently so one wedged instance can't block the others.
172
209
  const ids = [...this.unsubs.keys()];
173
- for (const id of ids) await this.close(id);
210
+ await Promise.allSettled(ids.map((id) => this.close(id)));
174
211
  }
175
212
 
176
213
  private byId(surfaceId: string): SurfaceInstance | undefined {