@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
@@ -126,6 +126,65 @@ describe('synthesizeSkill', () => {
126
126
  expect(b.path).not.toBe(a.path);
127
127
  expect(b.path).toMatch(/-2\.md$/);
128
128
  });
129
+
130
+ it('does not overwrite a same-slug file already on disk (atomic wx create)', async () => {
131
+ const provider = new InlineProvider([draftReply()]);
132
+ const session = newSessionWithProvider(provider);
133
+ const skillsDir = path.join(tmp, 'skills');
134
+ await fs.mkdir(skillsDir, { recursive: true });
135
+ // Pre-existing skill file with the slug that this synthesis will produce.
136
+ const squatted = path.join(skillsDir, 'refactor-component.md');
137
+ await fs.writeFile(squatted, 'PRE-EXISTING — MUST NOT BE TRUNCATED');
138
+
139
+ const result = await synthesizeSkill(session, 'split component up', 'user', {
140
+ userDir: skillsDir,
141
+ auditPath: path.join(tmp, 'audit.jsonl'),
142
+ });
143
+
144
+ // The pre-existing file is untouched and the new skill landed on a bumped name.
145
+ expect(await fs.readFile(squatted, 'utf8')).toBe('PRE-EXISTING — MUST NOT BE TRUNCATED');
146
+ expect(result.path).toMatch(/-2\.md$/);
147
+ });
148
+
149
+ it('still returns the created skill when the audit append fails (audit is best-effort)', async () => {
150
+ const provider = new InlineProvider([draftReply()]);
151
+ const session = newSessionWithProvider(provider);
152
+ // Make the audit path unwritable by planting a FILE where its parent dir
153
+ // must be — appendAudit's mkdir(dirname) then throws ENOTDIR/EEXIST.
154
+ const blocker = path.join(tmp, 'blocker');
155
+ await fs.writeFile(blocker, 'i am a file, not a directory');
156
+ const auditPath = path.join(blocker, 'created.jsonl');
157
+
158
+ const result = await synthesizeSkill(session, 'split component up', 'user', {
159
+ userDir: path.join(tmp, 'skills'),
160
+ auditPath,
161
+ });
162
+
163
+ // The skill is the product — it was written, registered, and returned despite
164
+ // the telemetry write failing.
165
+ expect(result.skill.frontmatter.name).toBe('refactor-component');
166
+ expect(await fs.readFile(result.path, 'utf8')).toContain('refactor-component');
167
+ expect(session.skills.byName('refactor-component')).toBeDefined();
168
+ });
169
+
170
+ it('caps the audit JSONL at MAX_AUDIT_LINES', async () => {
171
+ const auditPath = path.join(tmp, 'audit.jsonl');
172
+ // Seed the audit with way more than the cap.
173
+ const seed = Array.from({ length: 2100 }, (_, i) => JSON.stringify({ n: i })).join('\n') + '\n';
174
+ await fs.writeFile(auditPath, seed);
175
+
176
+ const provider = new InlineProvider([draftReply()]);
177
+ const session = newSessionWithProvider(provider);
178
+ await synthesizeSkill(session, 'split component up', 'user', {
179
+ userDir: path.join(tmp, 'skills'),
180
+ auditPath,
181
+ });
182
+
183
+ const lines = (await fs.readFile(auditPath, 'utf8')).split('\n').filter((l) => l.length > 0);
184
+ expect(lines.length).toBeLessThanOrEqual(2000);
185
+ // The most-recent line is the one we just appended (the skill slug).
186
+ expect(lines[lines.length - 1]).toContain('refactor-component');
187
+ });
129
188
  });
130
189
 
131
190
  describe('buildSynthesizeSkillPlugin', () => {
@@ -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);