@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
@@ -84,12 +84,11 @@ export async function synthesizeSkill(
84
84
  }
85
85
  const frontmatter = parsed.data as Skill['frontmatter'];
86
86
 
87
- const finalPath = await uniqueFilename(baseDir, slugify(frontmatter.name));
88
- await fs.writeFile(finalPath, draft.raw, 'utf8');
87
+ const finalPath = await writeUniqueSkill(baseDir, slugify(frontmatter.name), draft.raw);
89
88
 
90
89
  // Derive the skill id from the on-disk filename, not from the LLM-supplied
91
90
  // frontmatter name — otherwise synthesizing the same name twice collides
92
- // even when uniqueFilename has just bumped the filename to `<slug>-2.md`.
91
+ // even when writeUniqueSkill has just bumped the filename to `<slug>-2.md`.
93
92
  const basename = path.basename(finalPath, '.md');
94
93
  const skill: Skill = {
95
94
  id: asSkillId(`${scope}/${basename}`),
@@ -100,26 +99,46 @@ export async function synthesizeSkill(
100
99
  };
101
100
  session.skills.register(skill);
102
101
 
103
- await session.log.append({
104
- type: 'skill_created',
105
- sessionId: session.id,
106
- turnId: turnId ?? session.startTurn().turnId,
107
- source: 'system',
108
- skillId: skill.id,
109
- name: skill.frontmatter.name,
110
- path: finalPath,
111
- scope,
112
- originatingPrompt: intent,
113
- });
102
+ // The skill file on disk + the live registration are the product; the
103
+ // skill_created event and the audit JSONL line are telemetry. A failure in
104
+ // either (listener/persistence reject, EACCES on .meta, read-only fs) must
105
+ // NOT throw after the skill is already written + registered — that would
106
+ // surface an error the model retries, orphaning the just-created skill and
107
+ // duplicating it. Make both best-effort and warn instead.
108
+ try {
109
+ await session.log.append({
110
+ type: 'skill_created',
111
+ sessionId: session.id,
112
+ turnId: turnId ?? session.startTurn().turnId,
113
+ source: 'system',
114
+ skillId: skill.id,
115
+ name: skill.frontmatter.name,
116
+ path: finalPath,
117
+ scope,
118
+ originatingPrompt: intent,
119
+ });
120
+ } catch (err) {
121
+ process.stderr.write(
122
+ `moxxy: synthesize_skill could not emit skill_created for "${skill.frontmatter.name}": ` +
123
+ `${err instanceof Error ? err.message : String(err)}\n`,
124
+ );
125
+ }
114
126
 
115
127
  const auditPath = opts.auditPath ?? path.join(defaultUserSkillsDir(), '.meta', 'created.jsonl');
116
- await appendAudit(auditPath, {
117
- slug: path.basename(finalPath, '.md'),
118
- ts: new Date().toISOString(),
119
- sessionId: String(session.id),
120
- originatingPrompt: intent,
121
- scope,
122
- });
128
+ try {
129
+ await appendAudit(auditPath, {
130
+ slug: path.basename(finalPath, '.md'),
131
+ ts: new Date().toISOString(),
132
+ sessionId: String(session.id),
133
+ originatingPrompt: intent,
134
+ scope,
135
+ });
136
+ } catch (err) {
137
+ process.stderr.write(
138
+ `moxxy: synthesize_skill audit append failed (${auditPath}): ` +
139
+ `${err instanceof Error ? err.message : String(err)}\n`,
140
+ );
141
+ }
123
142
 
124
143
  return { skill, path: finalPath, scope };
125
144
  }
@@ -132,28 +151,62 @@ function slugify(name: string): string {
132
151
  .slice(0, 60);
133
152
  }
134
153
 
135
- async function uniqueFilename(dir: string, base: string): Promise<string> {
136
- let candidate = path.join(dir, `${base}.md`);
137
- let n = 2;
138
- while (await exists(candidate)) {
139
- candidate = path.join(dir, `${base}-${n}.md`);
140
- n += 1;
154
+ /**
155
+ * Pick a unique `<base>[-N].md` name AND create it atomically with the `wx`
156
+ * flag (fail if exists). Name selection and creation are one step, so two
157
+ * concurrent synthesizeSkill calls (workflows / self-improver) racing on the
158
+ * same slug can't both pick the same candidate and have the later writeFile
159
+ * truncate the earlier skill — the loser gets EEXIST and bumps the suffix.
160
+ */
161
+ async function writeUniqueSkill(dir: string, base: string, data: string): Promise<string> {
162
+ let n = 1;
163
+ // Bound the search so a directory already saturated with `<base>-*.md` (or a
164
+ // persistent EEXIST race) can't spin forever — surface a clear error instead.
165
+ const MAX_ATTEMPTS = 1000;
166
+ for (;;) {
167
+ const candidate = path.join(dir, n === 1 ? `${base}.md` : `${base}-${n}.md`);
168
+ try {
169
+ await fs.writeFile(candidate, data, { encoding: 'utf8', flag: 'wx' });
170
+ return candidate;
171
+ } catch (err) {
172
+ if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err;
173
+ n += 1;
174
+ if (n > MAX_ATTEMPTS) {
175
+ throw new Error(
176
+ `synthesize_skill: could not find a free filename for "${base}" after ${MAX_ATTEMPTS} attempts`,
177
+ );
178
+ }
179
+ }
141
180
  }
142
- return candidate;
143
181
  }
144
182
 
145
- async function exists(p: string): Promise<boolean> {
146
- try {
147
- await fs.access(p);
148
- return true;
149
- } catch {
150
- return false;
151
- }
152
- }
183
+ /** Cap the audit JSONL so a long-lived/self-synthesizing install can't grow it without bound. */
184
+ const MAX_AUDIT_LINES = 2000;
153
185
 
154
186
  async function appendAudit(filePath: string, entry: Record<string, unknown>): Promise<void> {
155
187
  await fs.mkdir(path.dirname(filePath), { recursive: true });
156
188
  await fs.appendFile(filePath, JSON.stringify(entry) + '\n', 'utf8');
189
+ await rotateAuditIfNeeded(filePath);
190
+ }
191
+
192
+ /**
193
+ * Keep only the most recent {@link MAX_AUDIT_LINES} lines. Best-effort and
194
+ * non-atomic by design — this file is pure telemetry that nothing reads back
195
+ * with a bound, so a crash mid-rewrite at worst loses a few audit lines.
196
+ */
197
+ async function rotateAuditIfNeeded(filePath: string): Promise<void> {
198
+ let raw: string;
199
+ try {
200
+ raw = await fs.readFile(filePath, 'utf8');
201
+ } catch {
202
+ return;
203
+ }
204
+ const lines = raw.split('\n');
205
+ // Account for the trailing empty element from the final newline.
206
+ const nonEmpty = lines.filter((l) => l.length > 0);
207
+ if (nonEmpty.length <= MAX_AUDIT_LINES) return;
208
+ const kept = nonEmpty.slice(nonEmpty.length - MAX_AUDIT_LINES);
209
+ await fs.writeFile(filePath, kept.join('\n') + '\n', 'utf8');
157
210
  }
158
211
 
159
212
  export function buildSynthesizeSkillPlugin(
@@ -53,4 +53,60 @@ describe('streamChildEventToParent', () => {
53
53
  expect(arg.type).toBe('plugin_event');
54
54
  expect(arg.subtype).toBe('subagent_tool_call');
55
55
  });
56
+
57
+ // A child Bash/Read returning a multi-MB blob must NOT be copied verbatim into
58
+ // the parent log (unbounded amplification): the forwarded output is truncated.
59
+ it('truncates an oversized child tool_result output before mirroring to the parent', async () => {
60
+ const append = vi.fn().mockResolvedValue(undefined);
61
+ const parent = {
62
+ id: asSessionId('parent'),
63
+ log: { append } as unknown as EventLog,
64
+ } as unknown as SessionRuntime;
65
+
66
+ const huge = 'x'.repeat(2 * 1024 * 1024); // 2 MiB
67
+ const bigResult = {
68
+ type: 'tool_result',
69
+ seq: 1,
70
+ id: 'e1',
71
+ ts: 0,
72
+ sessionId: asSessionId('child'),
73
+ turnId: asTurnId('ct'),
74
+ source: 'model',
75
+ callId: 'call-2',
76
+ ok: true,
77
+ output: huge,
78
+ } as unknown as MoxxyEvent;
79
+
80
+ await streamChildEventToParent(parent, asTurnId('pt'), 'researcher', asSessionId('child'), bigResult);
81
+ expect(append).toHaveBeenCalledTimes(1);
82
+ const arg = append.mock.calls[0]![0] as { payload: { output: string } };
83
+ expect(arg.payload.output.length).toBeLessThan(huge.length);
84
+ expect(arg.payload.output.length).toBeLessThan(64 * 1024);
85
+ expect(arg.payload.output).toContain('elided');
86
+ });
87
+
88
+ it('passes a small child tool_result output through unchanged', async () => {
89
+ const append = vi.fn().mockResolvedValue(undefined);
90
+ const parent = {
91
+ id: asSessionId('parent'),
92
+ log: { append } as unknown as EventLog,
93
+ } as unknown as SessionRuntime;
94
+
95
+ const smallResult = {
96
+ type: 'tool_result',
97
+ seq: 1,
98
+ id: 'e1',
99
+ ts: 0,
100
+ sessionId: asSessionId('child'),
101
+ turnId: asTurnId('ct'),
102
+ source: 'model',
103
+ callId: 'call-3',
104
+ ok: true,
105
+ output: 'short output',
106
+ } as unknown as MoxxyEvent;
107
+
108
+ await streamChildEventToParent(parent, asTurnId('pt'), 'researcher', asSessionId('child'), smallResult);
109
+ const arg = append.mock.calls[0]![0] as { payload: { output: unknown } };
110
+ expect(arg.payload.output).toBe('short output');
111
+ });
56
112
  });
@@ -133,6 +133,32 @@ export async function streamChildEventToParent(
133
133
  }
134
134
  }
135
135
 
136
+ /** Max chars of a forwarded child tool output/error mirrored onto the parent log. */
137
+ const MAX_FORWARD_CHARS = 16 * 1024;
138
+
139
+ /**
140
+ * Bound an arbitrary child tool result before mirroring it onto the parent log.
141
+ * Strings are truncated with an elision marker; other values pass through when
142
+ * their serialized length is small and are replaced with a marker when not (we
143
+ * avoid re-parsing — the full payload still lives in the child log).
144
+ */
145
+ function truncateForward(value: unknown): unknown {
146
+ if (typeof value === 'string') {
147
+ return value.length > MAX_FORWARD_CHARS
148
+ ? `${value.slice(0, MAX_FORWARD_CHARS)}… [${value.length - MAX_FORWARD_CHARS} more chars elided]`
149
+ : value;
150
+ }
151
+ if (value === null || value === undefined || typeof value !== 'object') return value;
152
+ let serialized: string;
153
+ try {
154
+ serialized = JSON.stringify(value);
155
+ } catch {
156
+ return '[unserializable subagent output elided]';
157
+ }
158
+ if (serialized.length <= MAX_FORWARD_CHARS) return value;
159
+ return `${serialized.slice(0, MAX_FORWARD_CHARS)}… [${serialized.length - MAX_FORWARD_CHARS} more chars elided]`;
160
+ }
161
+
136
162
  function mapChildEvent(
137
163
  label: string,
138
164
  childSessionId: SessionId,
@@ -154,8 +180,12 @@ function mapChildEvent(
154
180
  case 'tool_result':
155
181
  payload.callId = String(childEvt.callId);
156
182
  payload.ok = childEvt.ok;
157
- if (childEvt.ok) payload.output = childEvt.output;
158
- else payload.error = childEvt.error;
183
+ // Child tool outputs are `unknown` and unbounded (a child Read/Bash can
184
+ // return multi-MB blobs). Forwarding them verbatim copies the full payload
185
+ // into the in-memory parent log too — doubling memory/persistence cost and
186
+ // multiplying it under deep nesting. The TUI only needs a preview, so cap it.
187
+ if (childEvt.ok) payload.output = truncateForward(childEvt.output);
188
+ else payload.error = truncateForward(childEvt.error);
159
189
  return { subtype: 'subagent_tool_result', payload };
160
190
  case 'error':
161
191
  payload.kind = childEvt.kind;
@@ -0,0 +1,106 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { asSessionId } from '@moxxy/sdk';
3
+ import type { SessionRuntime } from '../session-runtime.js';
4
+ import {
5
+ claimRetainedChild,
6
+ clearRetainedChildren,
7
+ getRetainedChild,
8
+ registerRetainedChild,
9
+ releaseRetainedChild,
10
+ unclaimRetainedChild,
11
+ type RetainedChildSession,
12
+ } from './registry.js';
13
+
14
+ // Minimal retained entry — the registry only reads childSessionId, parentSession.id,
15
+ // and retainedAt; the rest can be cast through `unknown`.
16
+ function makeEntry(id: string, parentId = 'parent'): RetainedChildSession {
17
+ return {
18
+ childSessionId: asSessionId(id),
19
+ parentSession: { id: asSessionId(parentId) } as unknown as SessionRuntime,
20
+ } as unknown as RetainedChildSession;
21
+ }
22
+
23
+ afterEach(() => {
24
+ clearRetainedChildren();
25
+ vi.useRealTimers();
26
+ });
27
+
28
+ describe('retained-child registry hardening', () => {
29
+ it('claim removes the entry and marks it busy so a racing claim cannot double-drive it', () => {
30
+ const id = asSessionId('c1');
31
+ registerRetainedChild(makeEntry('c1'));
32
+
33
+ const first = claimRetainedChild(id);
34
+ expect(first).toBeDefined();
35
+ // Entry is gone from the registry the instant it is claimed.
36
+ expect(getRetainedChild(id)).toBeUndefined();
37
+ // A racing claim for the same id finds nothing (and is also blocked by busy).
38
+ expect(claimRetainedChild(id)).toBeUndefined();
39
+
40
+ unclaimRetainedChild(id);
41
+ expect(claimRetainedChild(id)).toBeUndefined(); // still gone from registry
42
+ });
43
+
44
+ it('release while a claim is in flight drops the busy marker without resurrecting the entry', () => {
45
+ const id = asSessionId('c2');
46
+ registerRetainedChild(makeEntry('c2'));
47
+ expect(claimRetainedChild(id)).toBeDefined();
48
+ releaseRetainedChild(id); // racing release()
49
+ // Re-registering then claiming works again (busy was cleared).
50
+ registerRetainedChild(makeEntry('c2'));
51
+ expect(claimRetainedChild(id)).toBeDefined();
52
+ });
53
+
54
+ it('caps the number of retained children, evicting the oldest', () => {
55
+ for (let i = 0; i < 100; i++) registerRetainedChild(makeEntry(`k${i}`));
56
+ // The earliest entries must have been evicted; the latest survive.
57
+ expect(getRetainedChild(asSessionId('k0'))).toBeUndefined();
58
+ expect(getRetainedChild(asSessionId('k99'))).toBeDefined();
59
+ // Bounded: never more than the cap.
60
+ let live = 0;
61
+ for (let i = 0; i < 100; i++) if (getRetainedChild(asSessionId(`k${i}`))) live++;
62
+ expect(live).toBeLessThanOrEqual(64);
63
+ });
64
+
65
+ it('returns the entry evicted under the cap so the caller can warn the operator', () => {
66
+ // Fill to the cap, then one more — the registering call must hand back the
67
+ // oldest (k0) it dropped so run-child can emit a subagent_warning for it.
68
+ for (let i = 0; i < 64; i++) registerRetainedChild(makeEntry(`c${i}`));
69
+ const evicted = registerRetainedChild(makeEntry('overflow'));
70
+ expect(evicted.map((e) => String(e.childSessionId))).toContain('c0');
71
+ expect(getRetainedChild(asSessionId('c0'))).toBeUndefined();
72
+ });
73
+
74
+ it('returns TTL-expired entries it pruned on the next register', () => {
75
+ vi.useFakeTimers();
76
+ vi.setSystemTime(0);
77
+ registerRetainedChild(makeEntry('aged'));
78
+
79
+ vi.setSystemTime(31 * 60 * 1000);
80
+ const evicted = registerRetainedChild(makeEntry('newcomer'));
81
+ expect(evicted.map((e) => String(e.childSessionId))).toContain('aged');
82
+ expect(getRetainedChild(asSessionId('aged'))).toBeUndefined();
83
+ });
84
+
85
+ it('evicts a stale paused child after the TTL on the next register', () => {
86
+ vi.useFakeTimers();
87
+ vi.setSystemTime(0);
88
+ registerRetainedChild(makeEntry('stale'));
89
+ expect(getRetainedChild(asSessionId('stale'))).toBeDefined();
90
+
91
+ // Advance past the 30-minute TTL, then register a fresh child — that
92
+ // triggers pruning of the expired one.
93
+ vi.setSystemTime(31 * 60 * 1000);
94
+ registerRetainedChild(makeEntry('fresh'));
95
+ expect(getRetainedChild(asSessionId('stale'))).toBeUndefined();
96
+ expect(getRetainedChild(asSessionId('fresh'))).toBeDefined();
97
+ });
98
+
99
+ it('clearRetainedChildren scopes to the owning session', () => {
100
+ registerRetainedChild(makeEntry('a1', 'sessA'));
101
+ registerRetainedChild(makeEntry('b1', 'sessB'));
102
+ clearRetainedChildren(asSessionId('sessA'));
103
+ expect(getRetainedChild(asSessionId('a1'))).toBeUndefined();
104
+ expect(getRetainedChild(asSessionId('b1'))).toBeDefined();
105
+ });
106
+ });
@@ -28,20 +28,93 @@ export interface RetainedChildSession {
28
28
  readonly strategyName: string;
29
29
  readonly parentSession: SessionRuntime;
30
30
  readonly parentTurnId: TurnId;
31
+ /** Cumulative provider token cost across this retained session's turns. */
32
+ tokensUsed?: number;
33
+ /** Wall-clock ms when the entry was (re)registered — drives TTL eviction. */
34
+ readonly retainedAt?: number;
31
35
  }
32
36
 
37
+ /**
38
+ * Cap the number of live retained children. Each entry pins a full EventLog +
39
+ * ModeContext (provider, tool registry, plugin host, signal); an `awaitInput`
40
+ * child whose resume never arrives would otherwise leak for the life of a
41
+ * long-lived in-process runner. When the cap is exceeded the oldest entry is
42
+ * evicted (its `continue()` will then fail with "no retained subagent session").
43
+ */
44
+ const MAX_RETAINED = 64;
45
+ /** Evict a paused child this long after registration if never resumed. */
46
+ const RETAIN_TTL_MS = 30 * 60 * 1000;
47
+
33
48
  const retained = new Map<string, RetainedChildSession>();
49
+ /** Child session ids with a `continue()` currently in flight (claim-then-run). */
50
+ const busy = new Set<string>();
34
51
 
35
- export function registerRetainedChild(session: RetainedChildSession): void {
36
- retained.set(String(session.childSessionId), session);
52
+ /** Drop entries past their TTL and return them so the caller can warn. */
53
+ function pruneExpired(now: number): RetainedChildSession[] {
54
+ const expired: RetainedChildSession[] = [];
55
+ for (const [id, entry] of retained) {
56
+ if (entry.retainedAt !== undefined && now - entry.retainedAt >= RETAIN_TTL_MS) {
57
+ retained.delete(id);
58
+ expired.push(entry);
59
+ }
60
+ }
61
+ return expired;
62
+ }
63
+
64
+ /**
65
+ * Register (or re-register) a retained child. Returns the entries DROPPED by
66
+ * this call — TTL-expired children plus any evicted to make room under the cap —
67
+ * so the caller can surface a `subagent_warning` for each (a paused `awaitInput`
68
+ * child whose `continue()` will now fail with "no retained subagent session").
69
+ * The registry has no SessionRuntime handle of its own, so it can't emit.
70
+ */
71
+ export function registerRetainedChild(session: RetainedChildSession): RetainedChildSession[] {
72
+ const now = Date.now();
73
+ const evicted = pruneExpired(now);
74
+ const id = String(session.childSessionId);
75
+ // Map preserves insertion order, so the first key is the oldest. Evict
76
+ // oldest until we have room for this one (Map.set on an existing id is an
77
+ // update, not an insert, so don't count a re-register against the cap).
78
+ while (retained.size >= MAX_RETAINED && !retained.has(id)) {
79
+ const [oldestId, oldestEntry] = retained.entries().next().value ?? [];
80
+ if (oldestId === undefined) break;
81
+ retained.delete(oldestId);
82
+ if (oldestEntry) evicted.push(oldestEntry);
83
+ }
84
+ retained.set(id, { ...session, retainedAt: now });
85
+ return evicted;
37
86
  }
38
87
 
39
88
  export function getRetainedChild(childSessionId: SessionId): RetainedChildSession | undefined {
40
89
  return retained.get(String(childSessionId));
41
90
  }
42
91
 
92
+ /**
93
+ * Atomically claim a retained child for a `continue()` turn: remove it from the
94
+ * registry and mark it busy, so a racing `continue()`/`release()` for the same
95
+ * id can't observe the live entry and drive `strategy.run` over the same
96
+ * childLog/childCtx concurrently. Returns `undefined` if the id is unknown or
97
+ * already in flight.
98
+ */
99
+ export function claimRetainedChild(childSessionId: SessionId): RetainedChildSession | undefined {
100
+ const id = String(childSessionId);
101
+ if (busy.has(id)) return undefined;
102
+ const entry = retained.get(id);
103
+ if (!entry) return undefined;
104
+ retained.delete(id);
105
+ busy.add(id);
106
+ return entry;
107
+ }
108
+
109
+ /** Release a claim taken by {@link claimRetainedChild} (after the turn settles). */
110
+ export function unclaimRetainedChild(childSessionId: SessionId): void {
111
+ busy.delete(String(childSessionId));
112
+ }
113
+
43
114
  export function releaseRetainedChild(childSessionId: SessionId): void {
44
- retained.delete(String(childSessionId));
115
+ const id = String(childSessionId);
116
+ retained.delete(id);
117
+ busy.delete(id);
45
118
  }
46
119
 
47
120
  /**
@@ -53,6 +126,7 @@ export function releaseRetainedChild(childSessionId: SessionId): void {
53
126
  export function clearRetainedChildren(parentSessionId?: SessionId): void {
54
127
  if (parentSessionId === undefined) {
55
128
  retained.clear();
129
+ busy.clear();
56
130
  return;
57
131
  }
58
132
  const target = String(parentSessionId);
@@ -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,158 @@ 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('a rejecting child never aborts its siblings; per-child failure carries the spec label', async () => {
253
+ const session = buildSession(['parent-model']);
254
+ const rt = buildRuntime(session, 'parent-model');
255
+ const spawner = createSubagentSpawner(rt);
256
+
257
+ // Make the THIRD child's runChildTurn reject outright (a pre-`try` setup throw
258
+ // inside buildChildContext) so the promise actually rejects rather than being
259
+ // self-degraded. With Promise.all the first rejection short-circuits the batch
260
+ // and orphans the two still-running siblings; Promise.allSettled must let every
261
+ // sibling finish and turn the rejection into one error-bearing result.
262
+ const realAppContext = session.appContext.bind(session);
263
+ let appCtxCalls = 0;
264
+ vi.spyOn(session, 'appContext').mockImplementation(() => {
265
+ appCtxCalls += 1;
266
+ if (appCtxCalls === 3) throw new Error('boom: setup exploded');
267
+ return realAppContext();
268
+ });
269
+
270
+ const results = await spawner.spawnAll([
271
+ { prompt: 'a', mode: 'echo-model', label: 'one' },
272
+ { prompt: 'b', mode: 'echo-model', label: 'two' },
273
+ { prompt: 'c', mode: 'echo-model', label: 'three' },
274
+ ]);
275
+
276
+ // The batch resolves (never rejects) with one result per spec, in input order.
277
+ expect(results.map((r) => r.label)).toEqual(['one', 'two', 'three']);
278
+ // The two non-throwing siblings completed normally...
279
+ expect(results[0]!.stopReason).toBe('end_turn');
280
+ expect(results[0]!.text).toBe('parent-model');
281
+ expect(results[1]!.stopReason).toBe('end_turn');
282
+ expect(results[1]!.text).toBe('parent-model');
283
+ // ...and only the doomed child degraded to a per-child failure that keeps its label.
284
+ expect(results[2]!.stopReason).toBe('error');
285
+ expect(results[2]!.label).toBe('three');
286
+ expect(results[2]!.error?.message).toContain('boom: setup exploded');
287
+ vi.restoreAllMocks();
288
+ });
289
+
290
+ it('returns one result per spec in input order on the all-success path', async () => {
291
+ const session = buildSession(['parent-model']);
292
+ const rt = buildRuntime(session, 'parent-model');
293
+ const spawner = createSubagentSpawner(rt);
294
+
295
+ const results = await spawner.spawnAll([
296
+ { prompt: 'a', mode: 'echo-model', label: 'one' },
297
+ { prompt: 'b', mode: 'echo-model', label: 'two' },
298
+ { prompt: 'c', mode: 'echo-model', label: 'three' },
299
+ ]);
300
+
301
+ expect(results.map((r) => r.label)).toEqual(['one', 'two', 'three']);
302
+ expect(results.every((r) => r.stopReason === 'end_turn')).toBe(true);
303
+ });
304
+ });
305
+
306
+ describe('continueChildTurn re-entrancy', () => {
307
+ it('rejects a concurrent continue() for the same retained child (claim-then-run)', async () => {
308
+ let openGate!: () => void;
309
+ const gate = new Promise<void>((resolve) => {
310
+ openGate = resolve;
311
+ });
312
+ const session = new Session({ cwd: '/tmp', silent: true });
313
+ session.pluginHost.registerStatic(
314
+ definePlugin({
315
+ name: 'gated-test',
316
+ version: '0.0.0',
317
+ providers: [makeProvider(['m'])],
318
+ modes: [makeGatedMode(gate)],
319
+ }),
320
+ );
321
+ session.providers.setActive('listed');
322
+ session.modes.setActive('gated');
323
+
324
+ const rt = buildRuntime(session, 'm');
325
+ const first = await runChildTurn({
326
+ rt,
327
+ spec: { prompt: 'go', mode: 'gated', retainSession: true },
328
+ retainSession: true,
329
+ });
330
+ const childId = first.childSessionId;
331
+
332
+ // Start the first continue (blocks inside the gated mode), then race a
333
+ // second continue for the same id before the first settles.
334
+ const p1 = continueChildTurn({ childSessionId: childId, prompt: 'again' });
335
+ // The entry is claimed synchronously, so the second continue must fail fast.
336
+ await expect(
337
+ continueChildTurn({ childSessionId: childId, prompt: 'race' }),
338
+ ).rejects.toThrow(/no retained subagent session/);
339
+
340
+ openGate();
341
+ const r1 = await p1;
342
+ expect(r1.text).toBe('done');
343
+
344
+ clearRetainedChildren(session.id);
345
+ });
346
+ });