@moxxy/core 0.5.0 → 0.5.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 (156) 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/index.d.ts +1 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +1 -1
  7. package/dist/index.js.map +1 -1
  8. package/dist/permissions/engine.d.ts.map +1 -1
  9. package/dist/permissions/engine.js +80 -17
  10. package/dist/permissions/engine.js.map +1 -1
  11. package/dist/plugins/discovery.d.ts.map +1 -1
  12. package/dist/plugins/discovery.js +48 -7
  13. package/dist/plugins/discovery.js.map +1 -1
  14. package/dist/plugins/host.d.ts.map +1 -1
  15. package/dist/plugins/host.js +54 -11
  16. package/dist/plugins/host.js.map +1 -1
  17. package/dist/plugins/lifecycle.d.ts.map +1 -1
  18. package/dist/plugins/lifecycle.js +7 -0
  19. package/dist/plugins/lifecycle.js.map +1 -1
  20. package/dist/plugins/loader.d.ts +9 -0
  21. package/dist/plugins/loader.d.ts.map +1 -1
  22. package/dist/plugins/loader.js +34 -8
  23. package/dist/plugins/loader.js.map +1 -1
  24. package/dist/plugins/registry-kinds.d.ts +21 -2
  25. package/dist/plugins/registry-kinds.d.ts.map +1 -1
  26. package/dist/plugins/registry-kinds.js +3 -1
  27. package/dist/plugins/registry-kinds.js.map +1 -1
  28. package/dist/plugins/toposort.d.ts.map +1 -1
  29. package/dist/plugins/toposort.js +43 -18
  30. package/dist/plugins/toposort.js.map +1 -1
  31. package/dist/preferences.d.ts.map +1 -1
  32. package/dist/preferences.js +5 -4
  33. package/dist/preferences.js.map +1 -1
  34. package/dist/registries/active-backend-registry.d.ts.map +1 -1
  35. package/dist/registries/active-backend-registry.js +10 -1
  36. package/dist/registries/active-backend-registry.js.map +1 -1
  37. package/dist/registries/channels.d.ts +2 -1
  38. package/dist/registries/channels.d.ts.map +1 -1
  39. package/dist/registries/channels.js +30 -20
  40. package/dist/registries/channels.js.map +1 -1
  41. package/dist/registries/commands.d.ts.map +1 -1
  42. package/dist/registries/commands.js +27 -8
  43. package/dist/registries/commands.js.map +1 -1
  44. package/dist/registries/isolators.d.ts +27 -1
  45. package/dist/registries/isolators.d.ts.map +1 -1
  46. package/dist/registries/isolators.js +35 -1
  47. package/dist/registries/isolators.js.map +1 -1
  48. package/dist/registries/modes.d.ts +4 -0
  49. package/dist/registries/modes.d.ts.map +1 -1
  50. package/dist/registries/modes.js +15 -4
  51. package/dist/registries/modes.js.map +1 -1
  52. package/dist/registries/providers.d.ts +8 -0
  53. package/dist/registries/providers.d.ts.map +1 -1
  54. package/dist/registries/providers.js +8 -0
  55. package/dist/registries/providers.js.map +1 -1
  56. package/dist/registries/skills.d.ts +9 -0
  57. package/dist/registries/skills.d.ts.map +1 -1
  58. package/dist/registries/skills.js +22 -0
  59. package/dist/registries/skills.js.map +1 -1
  60. package/dist/requirements.d.ts.map +1 -1
  61. package/dist/requirements.js +7 -1
  62. package/dist/requirements.js.map +1 -1
  63. package/dist/run-turn.d.ts.map +1 -1
  64. package/dist/run-turn.js +65 -17
  65. package/dist/run-turn.js.map +1 -1
  66. package/dist/session.d.ts +9 -0
  67. package/dist/session.d.ts.map +1 -1
  68. package/dist/session.js +25 -3
  69. package/dist/session.js.map +1 -1
  70. package/dist/sessions/persistence.d.ts +15 -19
  71. package/dist/sessions/persistence.d.ts.map +1 -1
  72. package/dist/sessions/persistence.js +116 -49
  73. package/dist/sessions/persistence.js.map +1 -1
  74. package/dist/skills/loader.d.ts.map +1 -1
  75. package/dist/skills/loader.js +11 -2
  76. package/dist/skills/loader.js.map +1 -1
  77. package/dist/skills/synthesize.d.ts.map +1 -1
  78. package/dist/skills/synthesize.js +87 -36
  79. package/dist/skills/synthesize.js.map +1 -1
  80. package/dist/subagents/events.js +33 -2
  81. package/dist/subagents/events.js.map +1 -1
  82. package/dist/subagents/registry.d.ts +22 -1
  83. package/dist/subagents/registry.d.ts.map +1 -1
  84. package/dist/subagents/registry.js +72 -2
  85. package/dist/subagents/registry.js.map +1 -1
  86. package/dist/subagents/run-child.d.ts.map +1 -1
  87. package/dist/subagents/run-child.js +137 -46
  88. package/dist/subagents/run-child.js.map +1 -1
  89. package/dist/surfaces/host.d.ts +10 -0
  90. package/dist/surfaces/host.d.ts.map +1 -1
  91. package/dist/surfaces/host.js +41 -3
  92. package/dist/surfaces/host.js.map +1 -1
  93. package/dist/usage-stats.d.ts +7 -1
  94. package/dist/usage-stats.d.ts.map +1 -1
  95. package/dist/usage-stats.js +41 -15
  96. package/dist/usage-stats.js.map +1 -1
  97. package/dist/view/parse.d.ts.map +1 -1
  98. package/dist/view/parse.js +119 -19
  99. package/dist/view/parse.js.map +1 -1
  100. package/package.json +2 -2
  101. package/src/events/log.test.ts +45 -0
  102. package/src/events/log.ts +39 -1
  103. package/src/index.ts +0 -1
  104. package/src/permissions/engine.test.ts +60 -0
  105. package/src/permissions/engine.ts +81 -16
  106. package/src/plugins/discovery.test.ts +59 -1
  107. package/src/plugins/discovery.ts +57 -9
  108. package/src/plugins/host.test.ts +40 -0
  109. package/src/plugins/host.ts +48 -10
  110. package/src/plugins/lifecycle.test.ts +29 -0
  111. package/src/plugins/lifecycle.ts +7 -0
  112. package/src/plugins/loader.ts +45 -7
  113. package/src/plugins/registry-kinds.test.ts +53 -0
  114. package/src/plugins/registry-kinds.ts +25 -3
  115. package/src/plugins/toposort.ts +45 -17
  116. package/src/preferences.test.ts +52 -1
  117. package/src/preferences.ts +5 -4
  118. package/src/registries/active-backend-registry.ts +10 -1
  119. package/src/registries/channels.test.ts +79 -0
  120. package/src/registries/channels.ts +33 -18
  121. package/src/registries/commands.test.ts +65 -0
  122. package/src/registries/commands.ts +25 -8
  123. package/src/registries/isolators.test.ts +37 -0
  124. package/src/registries/isolators.ts +48 -1
  125. package/src/registries/modes.test.ts +21 -1
  126. package/src/registries/modes.ts +15 -2
  127. package/src/registries/providers.ts +8 -0
  128. package/src/registries/semantics.test.ts +33 -1
  129. package/src/registries/skills.ts +28 -0
  130. package/src/registries/transcribers.test.ts +13 -0
  131. package/src/requirements.test.ts +35 -0
  132. package/src/requirements.ts +7 -1
  133. package/src/run-turn.test.ts +219 -0
  134. package/src/run-turn.ts +68 -18
  135. package/src/session.test.ts +24 -0
  136. package/src/session.ts +24 -3
  137. package/src/sessions/page.test.ts +18 -39
  138. package/src/sessions/persistence.test.ts +93 -1
  139. package/src/sessions/persistence.ts +138 -68
  140. package/src/skills/loader.test.ts +18 -0
  141. package/src/skills/loader.ts +12 -1
  142. package/src/skills/synthesize.test.ts +59 -0
  143. package/src/skills/synthesize.ts +89 -36
  144. package/src/subagents/events.test.ts +56 -0
  145. package/src/subagents/events.ts +32 -2
  146. package/src/subagents/registry.test.ts +106 -0
  147. package/src/subagents/registry.ts +77 -3
  148. package/src/subagents/run-child.test.ts +163 -2
  149. package/src/subagents/run-child.ts +151 -47
  150. package/src/surfaces/host.test.ts +71 -1
  151. package/src/surfaces/host.ts +39 -2
  152. package/src/usage-stats.test.ts +99 -1
  153. package/src/usage-stats.ts +42 -17
  154. package/src/view/parse-extended.test.ts +80 -0
  155. package/src/view/parse.test.ts +14 -0
  156. package/src/view/parse.ts +113 -18
@@ -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,54 @@ 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, and must NEVER abort or
501
+ // orphan its still-running siblings (whose later settlement would otherwise
502
+ // surface as an `unhandledRejection`). `Promise.all` short-circuits on the
503
+ // first rejection and leaks the rest — `Promise.allSettled` always waits for
504
+ // every child and never rejects. Each spec yields exactly one result, in
505
+ // input order; a setup throw becomes an error-bearing `SubagentResult` so a
506
+ // fan-out partially succeeds instead of taking the parent turn down with it.
507
+ const outcomes = await Promise.allSettled(
508
+ specs.map((spec) =>
509
+ runChildTurn({ rt, spec, retainSession: spec.retainSession === true }),
510
+ ),
416
511
  );
512
+ return outcomes.map((outcome, i): SubagentResult => {
513
+ if (outcome.status === 'fulfilled') return outcome.value;
514
+ // `specs[i]` is always defined here (i indexes the same array we mapped),
515
+ // but `noUncheckedIndexedAccess` widens it to `| undefined`; narrow so the
516
+ // failure result still carries the failing spec's label rather than the
517
+ // generic fallback.
518
+ const spec = specs[i];
519
+ return spawnFailureResult(spec ?? { prompt: '' }, outcome.reason);
520
+ });
417
521
  },
418
522
  async continue(args: SubagentContinueArgs) {
419
523
  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 {
@@ -1,4 +1,4 @@
1
- import { afterEach, beforeEach, describe, expect, it } from 'vitest';
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
2
  import { promises as fs } from 'node:fs';
3
3
  import * as os from 'node:os';
4
4
  import * as path from 'node:path';
@@ -74,4 +74,102 @@ describe('usage-stats store', () => {
74
74
  await fs.writeFile(statsPath, '{ not json', 'utf8');
75
75
  expect((await loadUsageStats(statsPath)).models).toEqual({});
76
76
  });
77
+
78
+ it('rejects a shape-valid file with a non-numeric counter (no string-concat corruption)', async () => {
79
+ // A hand-edited / partially-written file where a counter is a string used to
80
+ // pass the old `typeof models === 'object'` cast and flow straight into
81
+ // addModelTotals, corrupting the aggregate via "100" + 50 = "10050".
82
+ await fs.writeFile(
83
+ statsPath,
84
+ JSON.stringify({
85
+ version: 1,
86
+ updatedAt: new Date().toISOString(),
87
+ models: {
88
+ 'anthropic/opus': {
89
+ calls: 1,
90
+ inputTokens: '100',
91
+ outputTokens: 0,
92
+ cacheReadTokens: 0,
93
+ cacheCreationTokens: 0,
94
+ firstSeen: new Date().toISOString(),
95
+ lastSeen: new Date().toISOString(),
96
+ },
97
+ },
98
+ }),
99
+ 'utf8',
100
+ );
101
+ // Shape-invalid → fall through to empty, exactly like malformed JSON.
102
+ expect((await loadUsageStats(statsPath)).models).toEqual({});
103
+
104
+ // And a subsequent merge produces purely numeric totals (no concatenation).
105
+ await mergeUsageStats({ 'anthropic/opus': totals({ inputTokens: 50 }) }, statsPath);
106
+ const file = await loadUsageStats(statsPath);
107
+ expect(file.models['anthropic/opus']!.inputTokens).toBe(50);
108
+ expect(typeof file.models['anthropic/opus']!.inputTokens).toBe('number');
109
+ });
110
+
111
+ it('clear and merge serialize on the same mutex (clear cannot resurrect a cleared aggregate)', async () => {
112
+ await mergeUsageStats({ 'anthropic/opus': totals({ inputTokens: 100 }) }, statsPath);
113
+ // Fire a clear and a merge concurrently. Whatever the interleaving, the two
114
+ // must serialize against each other so the file is never the stale
115
+ // pre-clear snapshot reconstituted by a merge that read before the clear.
116
+ // The only sound end-states are: empty (merge ran then clear), or exactly
117
+ // the merged delta (clear ran then merge) — never the resurrected old 100
118
+ // plus delta.
119
+ await Promise.all([
120
+ clearUsageStats(statsPath),
121
+ mergeUsageStats({ 'anthropic/opus': totals({ inputTokens: 7 }) }, statsPath),
122
+ ]);
123
+ const file = await loadUsageStats(statsPath);
124
+ const entry = file.models['anthropic/opus'];
125
+ if (entry) {
126
+ // clear-then-merge ordering: only the post-clear delta survives.
127
+ expect(entry.inputTokens).toBe(7);
128
+ } else {
129
+ // merge-then-clear ordering: aggregate is empty.
130
+ expect(file.models).toEqual({});
131
+ }
132
+ });
133
+
134
+ it('swallows a write failure on merge: logs to stderr, returns the merged file, never throws', async () => {
135
+ // mergeUsageStats runs on shutdown and is documented best-effort — losing one
136
+ // session's stats must never block shutdown. Force an unwritable target by
137
+ // pointing the file *under a regular file* so the atomic writer's
138
+ // `mkdir(dirname)` fails with ENOTDIR.
139
+ const blocker = path.join(tmpDir, 'blocker');
140
+ await fs.writeFile(blocker, 'x', 'utf8');
141
+ const unwritable = path.join(blocker, 'nested', 'usage.json');
142
+ const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true);
143
+ try {
144
+ const result = await mergeUsageStats(
145
+ { 'anthropic/opus': totals({ inputTokens: 5 }) },
146
+ unwritable,
147
+ );
148
+ // Resolves with the in-memory merged file (the caller still sees the delta),
149
+ // and surfaces the persist failure on stderr rather than throwing.
150
+ expect(result.models['anthropic/opus']!.inputTokens).toBe(5);
151
+ expect(stderr).toHaveBeenCalled();
152
+ } finally {
153
+ stderr.mockRestore();
154
+ }
155
+ });
156
+
157
+ it('clearUsageStats rejects (loud) if it cannot write — a clear that did not persist must not look successful', async () => {
158
+ // Unlike merge, clear is an explicit user action (`/usage clear`); silently
159
+ // swallowing its failure would tell the user the aggregate was wiped when it
160
+ // was not. We pin the current behavior so a future refactor can't quietly
161
+ // turn it into a no-op success.
162
+ const blocker = path.join(tmpDir, 'clear-blocker');
163
+ await fs.writeFile(blocker, 'x', 'utf8');
164
+ const unwritable = path.join(blocker, 'nested', 'usage.json');
165
+ await expect(clearUsageStats(unwritable)).rejects.toBeInstanceOf(Error);
166
+ });
167
+
168
+ it('coalesces a missing firstSeen on merge rather than emitting undefined', async () => {
169
+ // Defense in depth: even if an entry somehow lacks firstSeen, a merge must
170
+ // not propagate `firstSeen: undefined` forward.
171
+ await mergeUsageStats({ 'anthropic/opus': totals({ inputTokens: 1 }) }, statsPath);
172
+ const file = await loadUsageStats(statsPath);
173
+ expect(file.models['anthropic/opus']!.firstSeen).toBeTruthy();
174
+ });
77
175
  });