@animalabs/connectome-host 0.3.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 (123) hide show
  1. package/.env.example +20 -0
  2. package/.github/workflows/publish.yml +65 -0
  3. package/ARCHITECTURE.md +355 -0
  4. package/CHANGELOG.md +30 -0
  5. package/HEADLESS-FLEET-PLAN.md +330 -0
  6. package/README.md +189 -0
  7. package/UNIFIED-TREE-PLAN.md +242 -0
  8. package/bun.lock +288 -0
  9. package/docs/AGENT-MEMORY-GUIDE.md +214 -0
  10. package/docs/AGENT-ONBOARDING.md +541 -0
  11. package/docs/ATTENTION-AND-GATING.md +120 -0
  12. package/docs/DEPLOYMENTS.md +130 -0
  13. package/docs/DEV-ENVIRONMENT.md +215 -0
  14. package/docs/LIBRARY-PIPELINE.md +286 -0
  15. package/docs/LOCUS-ROUTING-DESIGN.md +115 -0
  16. package/docs/claudeai-evacuation.md +228 -0
  17. package/docs/debug-context-api.md +208 -0
  18. package/docs/webui-deployment.md +219 -0
  19. package/package.json +33 -0
  20. package/recipes/SETUP.md +308 -0
  21. package/recipes/TRIUMVIRATE-SETUP.md +467 -0
  22. package/recipes/claude-export-revive.json +19 -0
  23. package/recipes/clerk.json +157 -0
  24. package/recipes/knowledge-miner.json +174 -0
  25. package/recipes/knowledge-reviewer.json +76 -0
  26. package/recipes/mcpl-editor-test.json +38 -0
  27. package/recipes/prompts/transplant-addendum.md +17 -0
  28. package/recipes/triumvirate.json +63 -0
  29. package/recipes/webui-fleet-test.json +27 -0
  30. package/recipes/webui-test.json +16 -0
  31. package/recipes/zulip-miner.json +87 -0
  32. package/scripts/connectome-doctor +373 -0
  33. package/scripts/evacuator.ts +956 -0
  34. package/scripts/import-claudeai-export.ts +747 -0
  35. package/scripts/lib/line-reader.ts +53 -0
  36. package/scripts/test-historical-thinking.ts +148 -0
  37. package/scripts/warmup-session.ts +336 -0
  38. package/src/agent-name.ts +58 -0
  39. package/src/commands.ts +1074 -0
  40. package/src/headless.ts +528 -0
  41. package/src/index.ts +867 -0
  42. package/src/logging-adapter.ts +208 -0
  43. package/src/mcpl-config.ts +199 -0
  44. package/src/modules/activity-module.ts +270 -0
  45. package/src/modules/channel-mode-module.ts +260 -0
  46. package/src/modules/fleet-module.ts +1705 -0
  47. package/src/modules/fleet-types.ts +225 -0
  48. package/src/modules/lessons-module.ts +465 -0
  49. package/src/modules/mcpl-admin-module.ts +345 -0
  50. package/src/modules/retrieval-module.ts +327 -0
  51. package/src/modules/settings-module.ts +143 -0
  52. package/src/modules/subagent-module.ts +2074 -0
  53. package/src/modules/subscription-gc-module.ts +322 -0
  54. package/src/modules/time-module.ts +85 -0
  55. package/src/modules/tui-module.ts +76 -0
  56. package/src/modules/web-ui-curve-page.ts +249 -0
  57. package/src/modules/web-ui-module.ts +2595 -0
  58. package/src/recipe.ts +1003 -0
  59. package/src/session-manager.ts +278 -0
  60. package/src/state/agent-tree-reducer.ts +431 -0
  61. package/src/state/fleet-tree-aggregator.ts +195 -0
  62. package/src/strategies/frontdesk-strategy.ts +364 -0
  63. package/src/synesthete.ts +68 -0
  64. package/src/tui.ts +2200 -0
  65. package/src/types/bun-ffi.d.ts +6 -0
  66. package/src/web/protocol.ts +648 -0
  67. package/test/agent-name.test.ts +62 -0
  68. package/test/agent-tree-fleet-launch.test.ts +64 -0
  69. package/test/agent-tree-reducer-parity.test.ts +194 -0
  70. package/test/agent-tree-reducer.test.ts +443 -0
  71. package/test/agent-tree-rollup.test.ts +109 -0
  72. package/test/autobio-progress-snapshot.test.ts +40 -0
  73. package/test/claudeai-export-importer.test.ts +386 -0
  74. package/test/evacuator.test.ts +332 -0
  75. package/test/fleet-commands.test.ts +244 -0
  76. package/test/fleet-no-subfleets.test.ts +147 -0
  77. package/test/fleet-orchestration.test.ts +353 -0
  78. package/test/fleet-recipe.test.ts +124 -0
  79. package/test/fleet-route.test.ts +44 -0
  80. package/test/fleet-smoke.test.ts +479 -0
  81. package/test/fleet-subscribe-union-e2e.test.ts +123 -0
  82. package/test/fleet-subscribe-union.test.ts +85 -0
  83. package/test/fleet-tree-aggregator-e2e.test.ts +110 -0
  84. package/test/fleet-tree-aggregator.test.ts +215 -0
  85. package/test/frontdesk-strategy.test.ts +326 -0
  86. package/test/headless-describe.test.ts +182 -0
  87. package/test/headless-smoke.test.ts +190 -0
  88. package/test/logging-adapter.test.ts +87 -0
  89. package/test/mcpl-admin-module.test.ts +213 -0
  90. package/test/mcpl-agent-overlay.test.ts +126 -0
  91. package/test/mock-headless-child.ts +133 -0
  92. package/test/recipe-cache-ttl.test.ts +37 -0
  93. package/test/recipe-env.test.ts +157 -0
  94. package/test/recipe-path-resolution.test.ts +170 -0
  95. package/test/subagent-async-timeout.test.ts +381 -0
  96. package/test/subagent-fork-materialise.test.ts +241 -0
  97. package/test/subagent-peek-scoping.test.ts +180 -0
  98. package/test/subagent-peek-zombie.test.ts +148 -0
  99. package/test/subagent-reaper-in-flight.test.ts +229 -0
  100. package/test/subscription-gc-module.test.ts +136 -0
  101. package/test/warmup-handoff.test.ts +150 -0
  102. package/test/web-ui-bind.test.ts +51 -0
  103. package/test/web-ui-module.test.ts +246 -0
  104. package/test/web-ui-protocol.test.ts +0 -0
  105. package/tsconfig.json +14 -0
  106. package/web/bun.lock +357 -0
  107. package/web/index.html +12 -0
  108. package/web/package.json +24 -0
  109. package/web/src/App.tsx +1931 -0
  110. package/web/src/Context.tsx +158 -0
  111. package/web/src/ContextDocument.tsx +150 -0
  112. package/web/src/Files.tsx +271 -0
  113. package/web/src/Lessons.tsx +164 -0
  114. package/web/src/Mcpl.tsx +310 -0
  115. package/web/src/Stream.tsx +119 -0
  116. package/web/src/TreeSidebar.tsx +283 -0
  117. package/web/src/Usage.tsx +182 -0
  118. package/web/src/main.tsx +7 -0
  119. package/web/src/styles.css +26 -0
  120. package/web/src/tree.ts +268 -0
  121. package/web/src/wire.ts +120 -0
  122. package/web/tsconfig.json +21 -0
  123. package/web/vite.config.ts +32 -0
@@ -0,0 +1,1705 @@
1
+ /**
2
+ * FleetModule — orchestrates child connectome-host processes.
3
+ *
4
+ * Each child is spawned as a detached subprocess running `bun src/index.ts
5
+ * <recipe> --headless`, with its own DATA_DIR and Chronicle store. The
6
+ * parent connects to the child's Unix socket at {dataDir}/ipc.sock and
7
+ * exchanges JSONL envelopes (see `fleet-types.ts`, `HEADLESS-FLEET-PLAN.md`).
8
+ *
9
+ * Tools: launch / list / status / send / command / peek / kill / restart / relay / await.
10
+ *
11
+ * Naming note: this module's `launch` is deliberately NOT `spawn` — the
12
+ * agent framework's SubagentModule already owns `subagent--spawn` for an
13
+ * in-process ephemeral agent with a different identity and lifecycle.
14
+ * `fleet--launch` means "start a separate recipe-driven child process"
15
+ * and should not be confused with it.
16
+ * Features: autoStart on framework start, allowedRecipes enforcement,
17
+ * autoRestart with flap cap, Chronicle persistence of fleet state, and
18
+ * adopt-on-restart (reattach to living children after parent restart).
19
+ * Shutdown supports detach mode (leave children alive for next parent).
20
+ */
21
+
22
+ import type {
23
+ Module,
24
+ ModuleContext,
25
+ ProcessState,
26
+ ProcessEvent,
27
+ EventResponse,
28
+ ToolDefinition,
29
+ ToolCall,
30
+ ToolResult,
31
+ } from '@animalabs/agent-framework';
32
+ import { spawn as spawnProcess, type ChildProcess } from 'node:child_process';
33
+ import { connect as netConnect, type Socket } from 'node:net';
34
+ import { existsSync, mkdirSync, unlinkSync, openSync, closeSync, appendFileSync } from 'node:fs';
35
+ import { join, resolve, isAbsolute } from 'node:path';
36
+ import { type IncomingCommand, type WireEvent, matchesSubscription } from './fleet-types.js';
37
+ import { loadRecipe } from '../recipe.js';
38
+ import { REDUCER_REQUIRED_EVENTS } from '../state/agent-tree-reducer.js';
39
+
40
+ /**
41
+ * Cross-process telemetry the parent host needs from every child, on top of
42
+ * what the unified-tree reducer needs. `usage:updated` is the cumulative
43
+ * session-usage signal — the TUI status bar and the WebUI header roll it up
44
+ * across children, so children that narrow their subscription (e.g. the
45
+ * triumvirate recipe) shouldn't be allowed to silently turn it off. Kept
46
+ * separate from REDUCER_REQUIRED_EVENTS because it isn't reducer-required;
47
+ * the wire just needs it for host-level observability.
48
+ */
49
+ const HOST_FORCED_TELEMETRY_EVENTS: readonly string[] = ['usage:updated'];
50
+
51
+ /**
52
+ * Force the events the unified-tree reducer + host telemetry need into every
53
+ * subscription sent to a fleet child. Recipes can still narrow chatty events
54
+ * out of the wire stream; they just can't accidentally disable rendering by
55
+ * omitting `inference:tool_calls_yielded` / `tool:started`, or starve the
56
+ * host's session-usage roll-up by omitting `usage:updated`.
57
+ *
58
+ * If `'*'` is present we leave the subscription alone — everything already
59
+ * flows. Otherwise each required event is added unless it's already covered
60
+ * (literal match or prefix-glob like `tool:*`).
61
+ */
62
+ export function unionWithReducerRequired(subscription: readonly string[]): string[] {
63
+ if (subscription.includes('*')) return [...subscription];
64
+ const set = new Set(subscription);
65
+ for (const required of [...REDUCER_REQUIRED_EVENTS, ...HOST_FORCED_TELEMETRY_EVENTS]) {
66
+ if (!matchesSubscription(required, set)) set.add(required);
67
+ }
68
+ return [...set];
69
+ }
70
+
71
+ export type FleetEventCallback = (childName: string, event: WireEvent) => void;
72
+
73
+ interface FleetEventSubscription {
74
+ callback: FleetEventCallback;
75
+ /** If null, subscriber receives every event. Otherwise a subscription set (supports glob). */
76
+ filter: Set<string> | null;
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Public types
81
+ // ---------------------------------------------------------------------------
82
+
83
+ export interface FleetModuleConfig {
84
+ /** Default subscription sent to children at handshake (default: ['*']). */
85
+ defaultSubscription?: string[];
86
+ /** Max events to retain per child for peek (default: 500). */
87
+ bufferSize?: number;
88
+ /** How long to wait for child socket file to appear after spawn (default: 15s). */
89
+ socketWaitTimeoutMs?: number;
90
+ /** How long to wait for child to emit lifecycle:ready after socket connect (default: 10s). */
91
+ readyTimeoutMs?: number;
92
+ /** How long to wait after sending shutdown before SIGTERM escalation (default: 10s). */
93
+ gracefulShutdownMs?: number;
94
+ /** How long after SIGTERM before SIGKILL escalation (default: 5s). */
95
+ sigtermEscalationMs?: number;
96
+ /**
97
+ * Path to the connectome-host entry script that children execute.
98
+ * Default: process.argv[1] (re-runs the parent's entry script).
99
+ * Override for test harnesses or shipped distributions where the parent
100
+ * was launched via a wrapper that obscures the real entry path.
101
+ */
102
+ childIndexPath?: string;
103
+ /**
104
+ * Path to the runtime binary used to spawn children.
105
+ * Default: process.execPath (whatever bun the parent is running under).
106
+ */
107
+ childRuntimePath?: string;
108
+ /**
109
+ * Children to launch on start(). Each child spawns concurrently;
110
+ * start() returns immediately (spawns are fire-and-forget so a slow
111
+ * child doesn't block framework start).
112
+ */
113
+ autoStart?: AutoStartChild[];
114
+ /**
115
+ * Recipes the conductor may launch via fleet--launch, on top of whatever is
116
+ * already listed in autoStart (those are implicitly allowed). If BOTH
117
+ * autoStart and allowedRecipes are absent, the allowlist is disabled and
118
+ * any recipe is allowed (matches Phase 2/3 ad-hoc usage).
119
+ *
120
+ * Pattern syntax: literal exact match, trailing `"*"` for prefix match,
121
+ * or a bare `"*"` for "allow everything". Mid-string `*` is NOT a glob —
122
+ * the validateRecipe schema check rejects it to keep the pattern intent
123
+ * aligned with the matcher.
124
+ */
125
+ allowedRecipes?: string[];
126
+ }
127
+
128
+ export interface AutoStartChild {
129
+ name: string;
130
+ recipe: string;
131
+ dataDir?: string;
132
+ env?: Record<string, string>;
133
+ subscription?: string[];
134
+ /** Default true. Set false to register the child in the allowlist without launching it. */
135
+ autoStart?: boolean;
136
+ /** Phase 5 honours this; accepted now for forward-compat. */
137
+ autoRestart?: boolean;
138
+ }
139
+
140
+ interface LaunchInput {
141
+ name: string;
142
+ recipe: string;
143
+ dataDir?: string;
144
+ env?: Record<string, string>;
145
+ subscription?: string[];
146
+ /** Respawn on crash. Default false. */
147
+ autoRestart?: boolean;
148
+ }
149
+
150
+ type ChildStatus = 'starting' | 'ready' | 'exited' | 'crashed';
151
+
152
+ /**
153
+ * Subset of FleetChild that is serializable to Chronicle. Live handles
154
+ * (process, socket) and ephemeral state (event buffer, line buffer) are
155
+ * excluded. Stored under the module's state namespace and re-hydrated
156
+ * on module start() for adopt-on-restart.
157
+ */
158
+ interface PersistedChild {
159
+ name: string;
160
+ recipePath: string;
161
+ dataDir: string;
162
+ socketPath: string;
163
+ pid: number | null;
164
+ status: ChildStatus;
165
+ startedAt: number;
166
+ exitedAt: number | null;
167
+ lastEventAt: number | null;
168
+ exitCode: number | null;
169
+ exitReason: string | null;
170
+ subscription: string[];
171
+ autoRestart: boolean;
172
+ env: Record<string, string> | null;
173
+ }
174
+
175
+ interface PersistedFleetState {
176
+ children: Record<string, PersistedChild>;
177
+ }
178
+
179
+ interface FleetChild {
180
+ name: string;
181
+ recipePath: string;
182
+ dataDir: string;
183
+ socketPath: string;
184
+ pid: number | null;
185
+ process: ChildProcess | null;
186
+ socket: Socket | null;
187
+ status: ChildStatus;
188
+ startedAt: number;
189
+ exitedAt: number | null;
190
+ lastEventAt: number | null;
191
+ exitCode: number | null;
192
+ exitReason: string | null;
193
+ events: WireEvent[]; // ring buffer (up to bufferSize)
194
+ buffer: string; // socket line buffer
195
+ subscription: string[];
196
+ /** Whether autoRestart is enabled for this child. */
197
+ autoRestart: boolean;
198
+ /** True between kill request and process exit — suppresses autoRestart for intentional shutdowns. */
199
+ killRequested: boolean;
200
+ /** Timestamps of recent autoRestart attempts, for flap protection. */
201
+ restartAttempts: number[];
202
+ /** Env and optional envOverride persisted so autoRestart can respawn with the same config. */
203
+ env?: Record<string, string>;
204
+ /**
205
+ * Most recent speech from a non-tool-ending inference round, as reported
206
+ * by the child's synthetic `inference:speech` event. Empty until the
207
+ * child's primary agent has spoken at least once without ending in
208
+ * tool calls. Used by fleet--relay.
209
+ */
210
+ lastCompletedSpeech: string;
211
+ }
212
+
213
+ // ---------------------------------------------------------------------------
214
+ // Module
215
+ // ---------------------------------------------------------------------------
216
+
217
+ export class FleetModule implements Module {
218
+ readonly name = 'fleet';
219
+
220
+ private ctx: ModuleContext | null = null;
221
+ private children = new Map<string, FleetChild>();
222
+ private config: Required<Omit<FleetModuleConfig, 'autoStart' | 'allowedRecipes'>>;
223
+ private autoStartChildren: AutoStartChild[];
224
+ /** Set when stop() has been called; suppresses autoRestart during shutdown. */
225
+ private stopping = false;
226
+ /** When true, stop() leaves children alive so a later parent can adopt them. */
227
+ private detachMode = false;
228
+ /** Window in ms and cap used for autoRestart flap detection. */
229
+ private readonly restartFlapWindowMs = 60_000;
230
+ private readonly restartFlapCap = 3;
231
+ /**
232
+ * Effective allowlist entries. Only consulted when `allowlistEnabled` is
233
+ * true; when false, every recipe is allowed.
234
+ */
235
+ private allowlist: string[];
236
+ private allowlistEnabled: boolean;
237
+ /**
238
+ * Push subscribers keyed by child name; '*' receives every child's events.
239
+ * Used by the TUI peek view to render live streams without polling the
240
+ * rolling buffer. Each subscription carries an optional filter so
241
+ * different consumers (conductor context, TUI pane, peek view) can get
242
+ * different slices of the same wire stream.
243
+ */
244
+ private eventSubscribers = new Map<string, Set<FleetEventSubscription>>();
245
+ /** Installed by start(), removed by stop(); see killAllOnExitSync(). */
246
+ private exitHandler: (() => void) | null = null;
247
+
248
+ constructor(config: FleetModuleConfig = {}) {
249
+ this.config = {
250
+ defaultSubscription: config.defaultSubscription ?? ['*'],
251
+ bufferSize: config.bufferSize ?? 500,
252
+ socketWaitTimeoutMs: config.socketWaitTimeoutMs ?? 15_000,
253
+ readyTimeoutMs: config.readyTimeoutMs ?? 10_000,
254
+ gracefulShutdownMs: config.gracefulShutdownMs ?? 10_000,
255
+ sigtermEscalationMs: config.sigtermEscalationMs ?? 5_000,
256
+ childIndexPath: config.childIndexPath ?? process.argv[1] ?? '',
257
+ childRuntimePath: config.childRuntimePath ?? process.execPath,
258
+ };
259
+
260
+ this.autoStartChildren = config.autoStart ?? [];
261
+
262
+ const explicit = config.allowedRecipes;
263
+ const implicit = this.autoStartChildren.map((c) => c.recipe);
264
+ // Allowlist is active only when the user gave us something to work with —
265
+ // either an explicit list or a set of declared children. Otherwise we
266
+ // stay in Phase 2/3 "open" mode so ad-hoc instantiation isn't broken.
267
+ this.allowlistEnabled = explicit !== undefined || implicit.length > 0;
268
+ this.allowlist = [...(explicit ?? []), ...implicit];
269
+ }
270
+
271
+ /**
272
+ * Synchronous SIGKILL of every live child this fleet owns. Invoked from
273
+ * the 'exit' listener installed in start(); also safe to call directly.
274
+ *
275
+ * Coverage and limits:
276
+ * - Spawned children (proc handle present): killed via proc.kill.
277
+ * - Adopted children (proc null, pid populated): killed via
278
+ * process.kill(pid). Without this, adopt-on-restart cleanup is a no-op.
279
+ * - Detach mode: skipped — those children are *meant* to survive for
280
+ * the next parent to adopt.
281
+ * - Hard-crash / SIGKILL of the parent: 'exit' does not fire, so this
282
+ * cannot help. Detached children orphan to PID 1 in that path.
283
+ */
284
+ private killAllOnExitSync(): void {
285
+ if (this.detachMode) return;
286
+ for (const c of this.children.values()) {
287
+ const proc = c.process;
288
+ if (proc && proc.exitCode === null && proc.signalCode === null) {
289
+ try { proc.kill('SIGKILL'); } catch { /* noop */ }
290
+ } else if (!proc && c.pid !== null && (c.status === 'ready' || c.status === 'starting')) {
291
+ try { process.kill(c.pid, 'SIGKILL'); } catch { /* noop */ }
292
+ }
293
+ }
294
+ }
295
+
296
+ async start(ctx: ModuleContext): Promise<void> {
297
+ this.ctx = ctx;
298
+
299
+ // Defensive cleanup if the parent exits without stop() running: synchronously
300
+ // SIGKILL any live children. See killAllOnExitSync() for coverage/limits.
301
+ // Installed here (not in the constructor) so listeners don't leak and a
302
+ // stopped FleetModule can be GC'd; we remove it in stop().
303
+ if (!this.exitHandler) {
304
+ this.exitHandler = (): void => this.killAllOnExitSync();
305
+ process.on('exit', this.exitHandler);
306
+ }
307
+
308
+ // First, try to adopt any still-alive children from a prior parent run.
309
+ // Adoption is best-effort: for each persisted child we probe liveness
310
+ // (PID + socket connect) and, if responsive, reattach without respawn.
311
+ // Dead children get cleaned up and may be respawned below.
312
+ const adoptedNames = await this.adoptPersistedChildren();
313
+
314
+ // Kick off autoStart children concurrently; errors don't block framework
315
+ // boot. Skip any that were already adopted — re-spawning them would
316
+ // create a duplicate alongside the still-running instance.
317
+ for (const child of this.autoStartChildren) {
318
+ if (child.autoStart === false) continue;
319
+ if (adoptedNames.has(child.name)) continue;
320
+
321
+ const input: LaunchInput = { name: child.name, recipe: child.recipe };
322
+ if (child.dataDir !== undefined) input.dataDir = child.dataDir;
323
+ if (child.env !== undefined) input.env = child.env;
324
+ if (child.subscription !== undefined) input.subscription = child.subscription;
325
+ if (child.autoRestart !== undefined) input.autoRestart = child.autoRestart;
326
+
327
+ this.handleLaunch(input, { viaAutoStart: true })
328
+ .then((res) => {
329
+ if (!res.success) {
330
+ console.error(`[fleet] autoStart "${child.name}" failed: ${res.error}`);
331
+ }
332
+ })
333
+ .catch((err: unknown) => {
334
+ console.error(`[fleet] autoStart "${child.name}" threw: ${String(err)}`);
335
+ });
336
+ }
337
+ }
338
+
339
+ /**
340
+ * Persist the current children map to Chronicle (via ModuleContext).
341
+ * Called after every lifecycle transition so a parent restart can see
342
+ * the last known state. Ephemeral fields (process handle, socket,
343
+ * event buffer) are excluded.
344
+ */
345
+ private persistState(): void {
346
+ if (!this.ctx) return;
347
+ if (typeof this.ctx.setState !== 'function') return;
348
+ const persisted: Record<string, PersistedChild> = {};
349
+ for (const [name, c] of this.children) {
350
+ persisted[name] = {
351
+ name: c.name,
352
+ recipePath: c.recipePath,
353
+ dataDir: c.dataDir,
354
+ socketPath: c.socketPath,
355
+ pid: c.pid,
356
+ status: c.status,
357
+ startedAt: c.startedAt,
358
+ exitedAt: c.exitedAt,
359
+ lastEventAt: c.lastEventAt,
360
+ exitCode: c.exitCode,
361
+ exitReason: c.exitReason,
362
+ subscription: [...c.subscription],
363
+ autoRestart: c.autoRestart,
364
+ env: c.env ?? null,
365
+ };
366
+ }
367
+ this.ctx.setState<PersistedFleetState>({ children: persisted });
368
+ }
369
+
370
+ /**
371
+ * Probe persisted children for liveness and reattach to the live ones.
372
+ * Returns the set of names we successfully adopted so autoStart can
373
+ * skip them. For each dead orphan, we clean up the stale socket/pid
374
+ * files so fresh spawns don't trip over them.
375
+ */
376
+ private async adoptPersistedChildren(): Promise<Set<string>> {
377
+ const adopted = new Set<string>();
378
+ if (!this.ctx) return adopted;
379
+ if (typeof this.ctx.getState !== 'function') return adopted;
380
+
381
+ let state: PersistedFleetState | null;
382
+ try {
383
+ state = this.ctx.getState<PersistedFleetState>();
384
+ } catch {
385
+ return adopted;
386
+ }
387
+ if (!state?.children) return adopted;
388
+
389
+ for (const [name, p] of Object.entries(state.children)) {
390
+ // Only living-ish statuses are worth probing. Anything we previously
391
+ // marked exited/crashed stays in the record so status/list show it,
392
+ // but we don't try to adopt.
393
+ if (p.status !== 'ready' && p.status !== 'starting') {
394
+ // Copy the record back into our live map as historical context.
395
+ this.children.set(name, this.reconstructOrphan(p));
396
+ continue;
397
+ }
398
+
399
+ const alive = await this.probeLiveness(p);
400
+ if (!alive) {
401
+ this.cleanupStaleChildFiles(p);
402
+ const orphan = this.reconstructOrphan(p);
403
+ orphan.status = 'crashed';
404
+ orphan.exitReason = 'orphaned (parent restarted; child not alive)';
405
+ this.children.set(name, orphan);
406
+ continue;
407
+ }
408
+
409
+ // Attempt to reattach to its socket. If anything in this fails,
410
+ // fall back to treating it as dead.
411
+ try {
412
+ const reattached = await this.reattachToLivingChild(p);
413
+ this.children.set(name, reattached);
414
+ adopted.add(name);
415
+ console.error(`[fleet] adopted "${name}" (pid=${p.pid}, socket=${p.socketPath})`);
416
+ } catch (err) {
417
+ console.error(`[fleet] failed to adopt "${name}": ${String(err)}`);
418
+ this.cleanupStaleChildFiles(p);
419
+ const orphan = this.reconstructOrphan(p);
420
+ orphan.status = 'crashed';
421
+ orphan.exitReason = `adopt failed: ${err instanceof Error ? err.message : String(err)}`;
422
+ this.children.set(name, orphan);
423
+ }
424
+ }
425
+
426
+ this.persistState();
427
+ return adopted;
428
+ }
429
+
430
+ /**
431
+ * Build a live-map entry from a persisted record WITHOUT connecting to
432
+ * the child. Used for historical records (status = exited / crashed)
433
+ * and as a scaffold for the adopt path.
434
+ */
435
+ private reconstructOrphan(p: PersistedChild): FleetChild {
436
+ const child: FleetChild = {
437
+ name: p.name,
438
+ recipePath: p.recipePath,
439
+ dataDir: p.dataDir,
440
+ socketPath: p.socketPath,
441
+ pid: p.pid,
442
+ process: null,
443
+ socket: null,
444
+ status: p.status,
445
+ startedAt: p.startedAt,
446
+ exitedAt: p.exitedAt,
447
+ lastEventAt: p.lastEventAt,
448
+ exitCode: p.exitCode,
449
+ exitReason: p.exitReason,
450
+ events: [],
451
+ buffer: '',
452
+ subscription: [...p.subscription],
453
+ autoRestart: p.autoRestart,
454
+ killRequested: false,
455
+ restartAttempts: [],
456
+ lastCompletedSpeech: '', // not persisted; rebuilt on next inference:speech
457
+ };
458
+ if (p.env) child.env = p.env;
459
+ return child;
460
+ }
461
+
462
+ /**
463
+ * Check whether a persisted child appears to be alive.
464
+ * Criteria: PID is alive (process.kill(pid, 0) doesn't throw) AND the
465
+ * socket file exists on disk. A true probe of the socket happens in
466
+ * reattachToLivingChild; this is the cheap pre-check.
467
+ */
468
+ private async probeLiveness(p: PersistedChild): Promise<boolean> {
469
+ if (p.pid === null) return false;
470
+ try {
471
+ process.kill(p.pid, 0);
472
+ } catch {
473
+ return false;
474
+ }
475
+ if (!existsSync(p.socketPath)) return false;
476
+ return true;
477
+ }
478
+
479
+ /**
480
+ * Connect to the child's Unix socket and treat the adopted connection as
481
+ * if we just spawned the child. The child's headless runtime closes the
482
+ * previous client (if any) and re-emits lifecycle:ready for us.
483
+ *
484
+ * PID-reuse guard: the process.kill(pid,0) pre-check in probeLiveness
485
+ * only verifies *some* process owns the PID — on a stale socket after a
486
+ * reboot, that can be an unrelated process. We verify the first
487
+ * lifecycle:ready event's pid matches the persisted pid; if it doesn't,
488
+ * we've connected to a stranger and abort the adoption.
489
+ */
490
+ private async reattachToLivingChild(p: PersistedChild): Promise<FleetChild> {
491
+ const child = this.reconstructOrphan(p);
492
+ child.status = 'starting'; // transitions to 'ready' via the lifecycle event once reattached
493
+
494
+ // Capture the first lifecycle:ready's pid. We subscribe *before*
495
+ // connecting so we can't miss the event (the child emits it
496
+ // synchronously inside its connection handler).
497
+ let observedPid: number | null | undefined;
498
+ const unsubPidCheck = this.onChildEvent(child.name, (_, evt) => {
499
+ if (observedPid !== undefined) return;
500
+ if (evt.type === 'lifecycle' && (evt as { phase?: string }).phase === 'ready') {
501
+ const pid = (evt as { pid?: number | null }).pid;
502
+ observedPid = pid ?? null;
503
+ }
504
+ });
505
+
506
+ try {
507
+ await this.connectChildSocket(child);
508
+ try {
509
+ this.sendToChild(child, { type: 'subscribe', events: p.subscription });
510
+ } catch { /* the ready-wait below will fail if the subscribe couldn't land */ }
511
+ await this.waitForReady(child);
512
+
513
+ if (observedPid !== p.pid) {
514
+ throw new Error(
515
+ `PID mismatch on adopt: expected pid=${p.pid}, got pid=${observedPid ?? 'undefined'}. ` +
516
+ `Likely stale socket / PID reuse after reboot.`,
517
+ );
518
+ }
519
+ } catch (err) {
520
+ // Close the socket we might have opened so we don't leave a dangling
521
+ // connection to a stranger's server.
522
+ try { child.socket?.destroy(); } catch { /* noop */ }
523
+ child.socket = null;
524
+ throw err;
525
+ } finally {
526
+ unsubPidCheck();
527
+ }
528
+
529
+ return child;
530
+ }
531
+
532
+ /** Remove leftover PID / socket files for a dead child. */
533
+ private cleanupStaleChildFiles(p: PersistedChild): void {
534
+ try { if (existsSync(p.socketPath)) unlinkSync(p.socketPath); } catch { /* noop */ }
535
+ const pidPath = p.socketPath.replace(/ipc\.sock$/, 'headless.pid');
536
+ try { if (existsSync(pidPath)) unlinkSync(pidPath); } catch { /* noop */ }
537
+ }
538
+
539
+ /**
540
+ * Attempt to restart a crashed child. Tracks recent attempts per-child
541
+ * and refuses further restarts once the flap cap is exceeded within the
542
+ * flap window. Delay grows exponentially per attempt so a deterministic
543
+ * startup bug doesn't fire-hose the log before the flap cap kicks in.
544
+ */
545
+ private tryAutoRestart(child: FleetChild): void {
546
+ const now = Date.now();
547
+ child.restartAttempts = child.restartAttempts.filter((t) => now - t < this.restartFlapWindowMs);
548
+ if (child.restartAttempts.length >= this.restartFlapCap) {
549
+ console.error(
550
+ `[fleet] autoRestart disabled for "${child.name}" — ${this.restartFlapCap} crashes in ${this.restartFlapWindowMs / 1000}s`,
551
+ );
552
+ return;
553
+ }
554
+ child.restartAttempts.push(now);
555
+ const attempt = child.restartAttempts.length;
556
+
557
+ // Exponential backoff with small jitter to de-sync sibling crashes:
558
+ // attempt 1 -> ~1s, attempt 2 -> ~3s, attempt 3 -> ~10s.
559
+ const baseMs = attempt === 1 ? 1_000 : attempt === 2 ? 3_000 : 10_000;
560
+ const jitterMs = Math.floor(Math.random() * Math.min(500, baseMs / 4));
561
+ const delayMs = baseMs + jitterMs;
562
+
563
+ console.error(`[fleet] autoRestart "${child.name}" (attempt ${attempt}, in ${delayMs}ms)`);
564
+
565
+ const input: LaunchInput = {
566
+ name: child.name,
567
+ recipe: child.recipePath,
568
+ dataDir: child.dataDir,
569
+ subscription: [...child.subscription],
570
+ autoRestart: true,
571
+ };
572
+ if (child.env !== undefined) input.env = child.env;
573
+
574
+ // Drop the crashed record so handleLaunch can register a fresh one.
575
+ this.children.delete(child.name);
576
+
577
+ setTimeout(() => {
578
+ if (this.stopping) return;
579
+ this.handleLaunch(input, { viaAutoStart: true })
580
+ .then((res) => {
581
+ if (!res.success) {
582
+ console.error(`[fleet] autoRestart "${child.name}" failed: ${res.error}`);
583
+ }
584
+ })
585
+ .catch((err: unknown) => {
586
+ console.error(`[fleet] autoRestart "${child.name}" threw: ${String(err)}`);
587
+ });
588
+ }, delayMs);
589
+ }
590
+
591
+ async stop(): Promise<void> {
592
+ // Mark shutdown so the process 'exit' handlers don't trigger autoRestart.
593
+ this.stopping = true;
594
+
595
+ // Uninstall the 'exit' safety net first so we don't pin this instance in
596
+ // memory after a clean shutdown, and so MaxListenersExceededWarning doesn't
597
+ // accumulate across many start/stop cycles in the same process.
598
+ if (this.exitHandler) {
599
+ process.off('exit', this.exitHandler);
600
+ this.exitHandler = null;
601
+ }
602
+
603
+ if (this.detachMode) {
604
+ // Detach: close socket references only; leave child processes alive so
605
+ // a later parent can adopt them. Children's own headless runtimes
606
+ // keep running because they were spawned with detached: true.
607
+ for (const c of this.children.values()) {
608
+ try { c.socket?.destroy(); } catch { /* noop */ }
609
+ c.socket = null;
610
+ }
611
+ this.ctx = null;
612
+ return;
613
+ }
614
+
615
+ const tasks: Array<Promise<void>> = [];
616
+ for (const child of this.children.values()) {
617
+ tasks.push(this.killChild(child).catch(() => { /* swallow per-child errors */ }));
618
+ }
619
+ await Promise.all(tasks);
620
+ this.ctx = null;
621
+ }
622
+
623
+ /**
624
+ * Set detach mode: on parent shutdown, leave children running so the
625
+ * next parent invocation can adopt them. Normal shutdown (detachMode
626
+ * false) kills all children gracefully.
627
+ */
628
+ setDetachMode(on: boolean): void {
629
+ this.detachMode = on;
630
+ }
631
+
632
+ async onProcess(_event: ProcessEvent, _state: ProcessState): Promise<EventResponse> {
633
+ return {};
634
+ }
635
+
636
+ /** Read-only view of children, for tests / TUI integration. */
637
+ getChildren(): ReadonlyMap<string, Readonly<FleetChild>> {
638
+ return this.children;
639
+ }
640
+
641
+ /**
642
+ * Subscribe to wire events from a child (or '*' for all children).
643
+ * Returns an unsubscribe function.
644
+ *
645
+ * @param filter Optional subscription set; if provided, the callback only
646
+ * fires for events matching it (same glob semantics as the
647
+ * wire-level subscription: '*', 'tool:*', 'inference:completed').
648
+ * Omit to receive every event.
649
+ *
650
+ * Note that this filter narrows *further* than the wire-level subscription
651
+ * the FleetModule sent to the child — the child's subscription is the upper
652
+ * bound (can't subscribe locally to events the child doesn't send).
653
+ */
654
+ onChildEvent(name: string | '*', callback: FleetEventCallback, filter?: string[]): () => void {
655
+ let subs = this.eventSubscribers.get(name);
656
+ if (!subs) {
657
+ subs = new Set();
658
+ this.eventSubscribers.set(name, subs);
659
+ }
660
+ const sub: FleetEventSubscription = {
661
+ callback,
662
+ filter: filter && filter.length > 0 ? new Set(filter) : null,
663
+ };
664
+ subs.add(sub);
665
+ return (): void => {
666
+ const set = this.eventSubscribers.get(name);
667
+ if (!set) return;
668
+ set.delete(sub);
669
+ if (set.size === 0) this.eventSubscribers.delete(name);
670
+ };
671
+ }
672
+
673
+ // =========================================================================
674
+ // Tool definitions + dispatch
675
+ // =========================================================================
676
+
677
+ getTools(): ToolDefinition[] {
678
+ return [
679
+ {
680
+ name: 'launch',
681
+ description:
682
+ 'Launch a child connectome-host process running the given recipe in headless mode. ' +
683
+ 'Distinct from subagent--spawn (which creates an in-process ephemeral agent): this starts a ' +
684
+ 'separate OS process with its own Chronicle store, MCPL servers, and lifecycle. ' +
685
+ 'Returns once the child reports ready, or fails. ' +
686
+ 'Environment is inherited from the parent and cannot be overridden via this tool — ' +
687
+ 'that belongs in the recipe (modules.fleet.children[].env) so it sits on the trusted side of the boundary.',
688
+ inputSchema: {
689
+ type: 'object',
690
+ properties: {
691
+ name: { type: 'string', description: 'Unique short name for the child.' },
692
+ recipe: { type: 'string', description: 'Recipe path (relative to parent CWD or absolute) or http(s) URL.' },
693
+ dataDir: { type: 'string', description: 'Data directory override (default: ./data/<name>).' },
694
+ subscription: {
695
+ type: 'array',
696
+ items: { type: 'string' },
697
+ description: 'Event types to subscribe to (supports glob like "tool:*"). Default: all events.',
698
+ },
699
+ autoRestart: {
700
+ type: 'boolean',
701
+ description: 'Respawn on crash (non-zero exit, not signal). Default false.',
702
+ },
703
+ },
704
+ required: ['name', 'recipe'],
705
+ },
706
+ },
707
+ {
708
+ name: 'list',
709
+ description: 'List all known children with their current status.',
710
+ inputSchema: { type: 'object', properties: {} },
711
+ },
712
+ {
713
+ name: 'status',
714
+ description: 'Detailed status for one child (or all if name omitted).',
715
+ inputSchema: {
716
+ type: 'object',
717
+ properties: {
718
+ name: { type: 'string', description: 'Child name; omit for all.' },
719
+ },
720
+ },
721
+ },
722
+ {
723
+ name: 'send',
724
+ description: 'Send a user-style text message to a child. Triggers inference in the child.',
725
+ inputSchema: {
726
+ type: 'object',
727
+ properties: {
728
+ name: { type: 'string' },
729
+ content: { type: 'string' },
730
+ },
731
+ required: ['name', 'content'],
732
+ },
733
+ },
734
+ {
735
+ name: 'command',
736
+ description: 'Run a slash command in a child (e.g. "/status", "/help"). Output streams back as command-output events.',
737
+ inputSchema: {
738
+ type: 'object',
739
+ properties: {
740
+ name: { type: 'string' },
741
+ command: { type: 'string', description: 'Slash command including leading "/".' },
742
+ },
743
+ required: ['name', 'command'],
744
+ },
745
+ },
746
+ {
747
+ name: 'peek',
748
+ description: "Return the last N events from a child's rolling event buffer.",
749
+ inputSchema: {
750
+ type: 'object',
751
+ properties: {
752
+ name: { type: 'string' },
753
+ lines: { type: 'number', description: 'How many recent events (default: 50, capped at buffer size).' },
754
+ },
755
+ required: ['name'],
756
+ },
757
+ },
758
+ {
759
+ name: 'kill',
760
+ description: 'Stop a child gracefully (shutdown command), escalate to SIGTERM then SIGKILL if it does not exit.',
761
+ inputSchema: {
762
+ type: 'object',
763
+ properties: {
764
+ name: { type: 'string' },
765
+ },
766
+ required: ['name'],
767
+ },
768
+ },
769
+ {
770
+ name: 'restart',
771
+ description: 'Kill and respawn a child with the same recipe + dataDir + subscription it was originally launched with.',
772
+ inputSchema: {
773
+ type: 'object',
774
+ properties: {
775
+ name: { type: 'string' },
776
+ },
777
+ required: ['name'],
778
+ },
779
+ },
780
+ {
781
+ name: 'relay',
782
+ description:
783
+ "Forward the source child's most recent completed speech to the target child as a text message. " +
784
+ 'Speech is the text from an inference round that did not end in tool calls (the child\'s "final answer" from its last turn). ' +
785
+ 'Source-child status is NOT checked: as long as the source spoke at least once before exiting, ' +
786
+ 'its last speech can still be relayed (useful for "pass on the dead child\'s last findings"). ' +
787
+ 'Target child must be running. ' +
788
+ 'Requires the source child\'s subscription to include `inference:speech`.',
789
+ inputSchema: {
790
+ type: 'object',
791
+ properties: {
792
+ from: { type: 'string', description: 'Source child name — whose speech to forward.' },
793
+ to: { type: 'string', description: 'Target child name — recipient of the relayed message.' },
794
+ prefix: {
795
+ type: 'string',
796
+ description: 'Optional text prepended to the relayed content (e.g. "Backend says:").',
797
+ },
798
+ },
799
+ required: ['from', 'to'],
800
+ },
801
+ },
802
+ {
803
+ name: 'await',
804
+ description:
805
+ 'Block until the named children go idle (framework quiescent for >=500ms). ' +
806
+ 'Returns when all (or any, if requireAll:false) are idle, or on timeout. ' +
807
+ 'Fails fast if a waited-for child crashes or exits before going idle. ' +
808
+ 'Requires each child\'s subscription to include `lifecycle` events.',
809
+ inputSchema: {
810
+ type: 'object',
811
+ properties: {
812
+ names: {
813
+ type: 'array',
814
+ items: { type: 'string' },
815
+ description: 'Children to wait on.',
816
+ },
817
+ timeoutMs: {
818
+ type: 'number',
819
+ description: 'Give up after this many ms and return partial results. Default 300000 (5 min).',
820
+ },
821
+ requireAll: {
822
+ type: 'boolean',
823
+ description: 'Wait for every named child (true, default) or return as soon as any one goes idle (false).',
824
+ },
825
+ },
826
+ required: ['names'],
827
+ },
828
+ },
829
+ ];
830
+ }
831
+
832
+ async handleToolCall(call: ToolCall): Promise<ToolResult> {
833
+ try {
834
+ switch (call.name) {
835
+ case 'launch': return await this.handleLaunch(call.input as LaunchInput, { viaAutoStart: false });
836
+ case 'list': return this.handleList();
837
+ case 'status': return this.handleStatus(call.input as { name?: string });
838
+ case 'send': return this.handleSend(call.input as { name: string; content: string });
839
+ case 'command': return this.handleCommand(call.input as { name: string; command: string });
840
+ case 'peek': return this.handlePeek(call.input as { name: string; lines?: number });
841
+ case 'kill': return await this.handleKill(call.input as { name: string });
842
+ case 'restart': return await this.handleRestart(call.input as { name: string });
843
+ case 'relay': return this.handleRelay(call.input as { from: string; to: string; prefix?: string });
844
+ case 'await': return await this.handleAwait(call.input as { names: string[]; timeoutMs?: number; requireAll?: boolean });
845
+ default:
846
+ return { success: false, isError: true, error: `Unknown fleet tool: ${call.name}` };
847
+ }
848
+ } catch (err) {
849
+ return {
850
+ success: false,
851
+ isError: true,
852
+ error: err instanceof Error ? err.message : String(err),
853
+ };
854
+ }
855
+ }
856
+
857
+ // =========================================================================
858
+ // Tool handlers
859
+ // =========================================================================
860
+
861
+ private async handleLaunch(
862
+ input: LaunchInput,
863
+ opts: { viaAutoStart: boolean } = { viaAutoStart: false },
864
+ ): Promise<ToolResult> {
865
+ if (!input.name || typeof input.name !== 'string') {
866
+ return { success: false, isError: true, error: 'launch requires "name" string' };
867
+ }
868
+ if (!input.recipe || typeof input.recipe !== 'string') {
869
+ return { success: false, isError: true, error: 'launch requires "recipe" string' };
870
+ }
871
+
872
+ // allowedRecipes enforcement — skip for autoStart (implicitly trusted).
873
+ // Match against both the raw string (for explicit literal allowedRecipes
874
+ // like `"recipes/*"`) and the CWD-resolved absolute form (for implicit
875
+ // entries derived from `children[]`, which now carry absolute paths after
876
+ // recipe-load-time resolution).
877
+ if (!opts.viaAutoStart && this.allowlistEnabled) {
878
+ const resolvedInput = isUrlOrAbsolute(input.recipe)
879
+ ? input.recipe
880
+ : resolve(process.cwd(), input.recipe);
881
+ if (!this.matchesAllowlist(input.recipe, resolvedInput)) {
882
+ return {
883
+ success: false,
884
+ isError: true,
885
+ error:
886
+ `Recipe "${input.recipe}" is not in the allowlist. ` +
887
+ `Allowed: ${this.allowlist.join(', ') || '(none)'}. ` +
888
+ `Ask the user to approve it (add to modules.fleet.allowedRecipes in the parent recipe) and retry.`,
889
+ };
890
+ }
891
+ }
892
+
893
+ // env may only be set on the recipe-trusted path (autoStart). If an
894
+ // agent smuggles it through the agent-facing fleet--launch tool (the
895
+ // schema doesn't advertise it; this is belt-and-suspenders), strip it
896
+ // here before it can reach child_process.spawn(). Prevents LD_PRELOAD /
897
+ // NODE_OPTIONS / ANTHROPIC_BASE_URL and similar injection.
898
+ //
899
+ // Rebind to a local copy rather than mutating the caller's ToolCall.input
900
+ // object — the framework may not reuse it today, but defensively copy
901
+ // then strip is the safer pattern.
902
+ if (!opts.viaAutoStart && input.env !== undefined) {
903
+ input = { ...input, env: undefined };
904
+ }
905
+
906
+ const existing = this.children.get(input.name);
907
+ if (existing && (existing.status === 'starting' || existing.status === 'ready')) {
908
+ return {
909
+ success: false,
910
+ isError: true,
911
+ error: `Child '${input.name}' is already ${existing.status}`,
912
+ };
913
+ }
914
+ // Drop the old record if it had previously exited/crashed — re-spawn replaces.
915
+ if (existing) this.children.delete(input.name);
916
+
917
+ const recipePath = isUrlOrAbsolute(input.recipe)
918
+ ? input.recipe
919
+ : resolve(process.cwd(), input.recipe);
920
+
921
+ // No-subfleets invariant: a fleet child may not itself declare a fleet
922
+ // module. Enforced before subprocess spawn so the failure mode is a clean
923
+ // synchronous tool error rather than a child crashing mid-startup. See
924
+ // UNIFIED-TREE-PLAN.md §6 for the design rationale (depth-1 keeps the
925
+ // unified tree's cross-process visibility tractable).
926
+ //
927
+ // We only fail on a *confirmed* nested-fleet declaration. If the recipe
928
+ // can't be loaded for any other reason (file missing, unfetchable URL,
929
+ // malformed JSON, mock-test recipes), let the existing spawn path surface
930
+ // that failure naturally — pre-empting it here would mask test fixtures
931
+ // and unrelated misconfigurations.
932
+ try {
933
+ const childRecipe = await loadRecipe(recipePath);
934
+ const fleetField = childRecipe.modules?.fleet;
935
+ const declaresFleet = fleetField === true ||
936
+ (typeof fleetField === 'object' && fleetField !== null);
937
+ if (declaresFleet) {
938
+ return {
939
+ success: false,
940
+ isError: true,
941
+ error:
942
+ `fleet child recipe '${input.name}' (${recipePath}) declares its own 'fleet' module; ` +
943
+ `nested fleets are not supported. Remove the 'fleet' entry from that recipe's modules ` +
944
+ `to launch it as a child. (See UNIFIED-TREE-PLAN.md §6.)`,
945
+ };
946
+ }
947
+ } catch {
948
+ // Recipe couldn't be loaded — defer to the natural spawn-time failure.
949
+ }
950
+
951
+ const dataDir = input.dataDir
952
+ ? (isAbsolute(input.dataDir) ? input.dataDir : resolve(process.cwd(), input.dataDir))
953
+ : resolve(process.cwd(), 'data', input.name);
954
+
955
+ mkdirSync(dataDir, { recursive: true });
956
+
957
+ const socketPath = join(dataDir, 'ipc.sock');
958
+ const subscription = input.subscription ?? this.config.defaultSubscription;
959
+
960
+ const child: FleetChild = {
961
+ name: input.name,
962
+ recipePath,
963
+ dataDir,
964
+ socketPath,
965
+ pid: null,
966
+ process: null,
967
+ socket: null,
968
+ status: 'starting',
969
+ startedAt: Date.now(),
970
+ exitedAt: null,
971
+ lastEventAt: null,
972
+ exitCode: null,
973
+ exitReason: null,
974
+ events: [],
975
+ buffer: '',
976
+ subscription: [...subscription],
977
+ autoRestart: input.autoRestart ?? false,
978
+ killRequested: false,
979
+ restartAttempts: [],
980
+ lastCompletedSpeech: '',
981
+ };
982
+ if (input.env !== undefined) child.env = input.env;
983
+ this.children.set(input.name, child);
984
+
985
+ if (!this.config.childIndexPath) {
986
+ this.children.delete(input.name);
987
+ return {
988
+ success: false,
989
+ isError: true,
990
+ error: 'cannot determine connectome-host script path (configure FleetModule with childIndexPath)',
991
+ };
992
+ }
993
+
994
+ // Capture early-crash output on disk. If the child dies before
995
+ // headless.ts installs its runtime log redirect — missing bun, bad
996
+ // shebang, ANTHROPIC_API_KEY validation exit, recipe parse failure,
997
+ // import crash — this is the only place that output can land. After
998
+ // the runtime redirect runs, console output flows into headless.log
999
+ // instead; startup.log just holds the pre-redirect bootstrap slice.
1000
+ const startupLogPath = join(dataDir, 'startup.log');
1001
+ try {
1002
+ appendFileSync(startupLogPath, `\n--- launch ${new Date().toISOString()} recipe=${recipePath} pid=parent:${process.pid} ---\n`);
1003
+ } catch { /* best-effort; directory existed from mkdirSync above */ }
1004
+ let startupFd: number | null = null;
1005
+ try {
1006
+ startupFd = openSync(startupLogPath, 'a');
1007
+ } catch {
1008
+ startupFd = null; // fall back to stdio: 'ignore' if we can't open the file
1009
+ }
1010
+
1011
+ const proc = spawnProcess(
1012
+ this.config.childRuntimePath,
1013
+ [this.config.childIndexPath, recipePath, '--headless'],
1014
+ {
1015
+ detached: true,
1016
+ stdio: startupFd !== null ? ['ignore', startupFd, startupFd] : 'ignore',
1017
+ env: { ...process.env, ...input.env, DATA_DIR: dataDir },
1018
+ },
1019
+ );
1020
+ proc.unref();
1021
+
1022
+ // Parent doesn't need the fd once the child has inherited it.
1023
+ if (startupFd !== null) {
1024
+ try { closeSync(startupFd); } catch { /* noop */ }
1025
+ }
1026
+
1027
+ child.process = proc;
1028
+ child.pid = proc.pid ?? null;
1029
+
1030
+ proc.on('exit', (code, signal) => {
1031
+ child.exitedAt = Date.now();
1032
+ child.exitCode = code ?? null;
1033
+ child.exitReason = signal ?? (code === 0 ? 'clean' : `code=${code}`);
1034
+ child.status = code === 0 ? 'exited' : 'crashed';
1035
+ try { child.socket?.destroy(); } catch { /* noop */ }
1036
+ child.socket = null;
1037
+
1038
+ this.persistState();
1039
+
1040
+ // autoRestart: respawn on crash (non-zero non-null exit), skip if:
1041
+ // - we asked the child to stop (killRequested)
1042
+ // - parent framework is shutting down (this.stopping)
1043
+ // - exit was via external signal (code null + signal set)
1044
+ // - autoRestart is false or flap cap exceeded
1045
+ const crashed = code !== null && code !== 0;
1046
+ if (
1047
+ child.autoRestart &&
1048
+ crashed &&
1049
+ !child.killRequested &&
1050
+ !this.stopping
1051
+ ) {
1052
+ this.tryAutoRestart(child);
1053
+ }
1054
+ });
1055
+
1056
+ try {
1057
+ await this.waitForSocket(child);
1058
+ await this.connectChildSocket(child);
1059
+ } catch (err) {
1060
+ try { proc.kill('SIGKILL'); } catch { /* noop */ }
1061
+ child.status = 'crashed';
1062
+ child.exitReason = err instanceof Error ? err.message : String(err);
1063
+ return { success: false, isError: true, error: `launch failed: ${child.exitReason}` };
1064
+ }
1065
+
1066
+ // Set the subscription filter on the child immediately.
1067
+ try {
1068
+ this.sendToChild(child, { type: 'subscribe', events: subscription });
1069
+ } catch (err) {
1070
+ return { success: false, isError: true, error: `subscribe failed: ${(err as Error).message}` };
1071
+ }
1072
+
1073
+ try {
1074
+ await this.waitForReady(child);
1075
+ } catch (err) {
1076
+ return { success: false, isError: true, error: `child did not become ready: ${(err as Error).message}` };
1077
+ }
1078
+
1079
+ this.persistState();
1080
+
1081
+ return {
1082
+ success: true,
1083
+ data: {
1084
+ name: child.name,
1085
+ pid: child.pid,
1086
+ dataDir: child.dataDir,
1087
+ socketPath: child.socketPath,
1088
+ status: child.status,
1089
+ },
1090
+ };
1091
+ }
1092
+
1093
+ private handleList(): ToolResult {
1094
+ const list = [...this.children.values()].map((c) => ({
1095
+ name: c.name,
1096
+ status: c.status,
1097
+ pid: c.pid,
1098
+ startedAt: c.startedAt,
1099
+ exitedAt: c.exitedAt,
1100
+ lastEventAt: c.lastEventAt,
1101
+ eventCount: c.events.length,
1102
+ }));
1103
+ return { success: true, data: list };
1104
+ }
1105
+
1106
+ private handleStatus(input: { name?: string }): ToolResult {
1107
+ if (input.name) {
1108
+ const c = this.children.get(input.name);
1109
+ if (!c) return { success: false, isError: true, error: `Unknown child: ${input.name}` };
1110
+ return { success: true, data: this.statusOf(c) };
1111
+ }
1112
+ return { success: true, data: [...this.children.values()].map((c) => this.statusOf(c)) };
1113
+ }
1114
+
1115
+ private statusOf(c: FleetChild): Record<string, unknown> {
1116
+ return {
1117
+ name: c.name,
1118
+ recipePath: c.recipePath,
1119
+ dataDir: c.dataDir,
1120
+ socketPath: c.socketPath,
1121
+ pid: c.pid,
1122
+ status: c.status,
1123
+ startedAt: c.startedAt,
1124
+ exitedAt: c.exitedAt,
1125
+ lastEventAt: c.lastEventAt,
1126
+ exitCode: c.exitCode,
1127
+ exitReason: c.exitReason,
1128
+ eventCount: c.events.length,
1129
+ subscription: [...c.subscription],
1130
+ };
1131
+ }
1132
+
1133
+ private handleSend(input: { name: string; content: string }): ToolResult {
1134
+ const ok = this.requireRunning(input.name);
1135
+ if (!('child' in ok)) return ok;
1136
+ if (typeof input.content !== 'string') {
1137
+ return { success: false, isError: true, error: 'send requires "content" string' };
1138
+ }
1139
+ this.sendToChild(ok.child, { type: 'text', content: input.content });
1140
+ return { success: true, data: { name: input.name, sent: 'text' } };
1141
+ }
1142
+
1143
+ private handleCommand(input: { name: string; command: string }): ToolResult {
1144
+ const ok = this.requireRunning(input.name);
1145
+ if (!('child' in ok)) return ok;
1146
+ if (typeof input.command !== 'string') {
1147
+ return { success: false, isError: true, error: 'command requires "command" string' };
1148
+ }
1149
+ this.sendToChild(ok.child, { type: 'command', command: input.command });
1150
+ return { success: true, data: { name: input.name, sent: 'command', command: input.command } };
1151
+ }
1152
+
1153
+ private handlePeek(input: { name: string; lines?: number }): ToolResult {
1154
+ const c = this.children.get(input.name);
1155
+ if (!c) return { success: false, isError: true, error: `Unknown child: ${input.name}` };
1156
+ const requested = input.lines ?? 50;
1157
+ const n = Math.max(1, Math.min(requested, c.events.length || 1));
1158
+ const slice = c.events.length > 0 ? c.events.slice(-n) : [];
1159
+ return { success: true, data: { name: c.name, count: slice.length, events: slice } };
1160
+ }
1161
+
1162
+ private async handleKill(input: { name: string }): Promise<ToolResult> {
1163
+ const c = this.children.get(input.name);
1164
+ if (!c) return { success: false, isError: true, error: `Unknown child: ${input.name}` };
1165
+ if (c.status === 'exited' || c.status === 'crashed') {
1166
+ return { success: true, data: { name: c.name, status: c.status, note: 'already stopped' } };
1167
+ }
1168
+ await this.killChild(c);
1169
+ return {
1170
+ success: true,
1171
+ data: { name: c.name, status: c.status, exitCode: c.exitCode, exitReason: c.exitReason },
1172
+ };
1173
+ }
1174
+
1175
+ private async handleRestart(input: { name: string }): Promise<ToolResult> {
1176
+ const c = this.children.get(input.name);
1177
+ if (!c) return { success: false, isError: true, error: `Unknown child: ${input.name}` };
1178
+ const relaunch: LaunchInput = {
1179
+ name: c.name,
1180
+ recipe: c.recipePath,
1181
+ dataDir: c.dataDir,
1182
+ subscription: [...c.subscription],
1183
+ };
1184
+ if (c.status === 'starting' || c.status === 'ready') {
1185
+ await this.killChild(c);
1186
+ }
1187
+ this.children.delete(c.name);
1188
+ // Restart is implicitly allowed — we're using the exact recipe the child
1189
+ // was originally launched with (which already passed the allowlist check).
1190
+ return await this.handleLaunch(relaunch, { viaAutoStart: true });
1191
+ }
1192
+
1193
+ /**
1194
+ * Check whether a recipe string is allowed by the configured allowlist.
1195
+ * Prefix-match-only: literal exact match, bare `"*"` for any, or trailing
1196
+ * `"*"` for prefix match. Mid-string `*` is rejected at recipe
1197
+ * validation time so it can't silently fail here.
1198
+ *
1199
+ * Two forms of the input are tried against each entry: the raw string
1200
+ * (as the agent supplied it — matches explicit literal entries like
1201
+ * `"recipes/*"`) and a CWD-resolved absolute form (matches implicit
1202
+ * entries derived from `children[]`, which are absolute post-load).
1203
+ */
1204
+ private matchesAllowlist(recipe: string, resolved: string): boolean {
1205
+ for (const entry of this.allowlist) {
1206
+ if (entry === '*') return true;
1207
+ if (entry === recipe || entry === resolved) return true;
1208
+ if (entry.endsWith('*') && !entry.slice(0, -1).includes('*')) {
1209
+ const prefix = entry.slice(0, -1);
1210
+ if (recipe.startsWith(prefix) || resolved.startsWith(prefix)) return true;
1211
+ }
1212
+ }
1213
+ return false;
1214
+ }
1215
+
1216
+ /**
1217
+ * Relay the source child's last completed speech to the target child.
1218
+ *
1219
+ * Source-child status is intentionally NOT enforced: a crashed or exited
1220
+ * child still has its `lastCompletedSpeech` in memory (until the parent
1221
+ * itself shuts down) and there are real workflows where you want to pass
1222
+ * the dying child's last words to a survivor. The target, by contrast,
1223
+ * must be running — we can't deliver a message to a dead socket.
1224
+ */
1225
+ private handleRelay(input: { from: string; to: string; prefix?: string }): ToolResult {
1226
+ if (!input.from || !input.to) {
1227
+ return { success: false, isError: true, error: 'relay requires "from" and "to"' };
1228
+ }
1229
+ if (input.from === input.to) {
1230
+ return { success: false, isError: true, error: 'relay source and target must be different children' };
1231
+ }
1232
+ const fromChild = this.children.get(input.from);
1233
+ if (!fromChild) {
1234
+ return { success: false, isError: true, error: `Unknown source child: ${input.from}` };
1235
+ }
1236
+ const target = this.requireRunning(input.to);
1237
+ if (!('child' in target)) return target;
1238
+
1239
+ const speech = fromChild.lastCompletedSpeech;
1240
+ if (!speech) {
1241
+ return {
1242
+ success: false,
1243
+ isError: true,
1244
+ error:
1245
+ `No completed speech available from '${input.from}'. ` +
1246
+ `The child needs to have produced at least one non-tool-ending inference, ` +
1247
+ `AND its subscription must include 'inference:speech'.`,
1248
+ };
1249
+ }
1250
+
1251
+ const content = input.prefix ? `${input.prefix}\n\n${speech}` : speech;
1252
+ this.sendToChild(target.child, { type: 'text', content });
1253
+ return { success: true, data: { from: input.from, to: input.to, chars: content.length } };
1254
+ }
1255
+
1256
+ private async handleAwait(input: {
1257
+ names: string[];
1258
+ timeoutMs?: number;
1259
+ requireAll?: boolean;
1260
+ }): Promise<ToolResult> {
1261
+ if (!Array.isArray(input.names) || input.names.length === 0) {
1262
+ return { success: false, isError: true, error: 'await requires a non-empty "names" array' };
1263
+ }
1264
+ // Dedupe so requireAll doesn't double-count the same child and wait forever.
1265
+ const names = [...new Set(input.names)];
1266
+ for (const name of names) {
1267
+ if (!this.children.has(name)) {
1268
+ return { success: false, isError: true, error: `Unknown child: ${name}` };
1269
+ }
1270
+ }
1271
+
1272
+ // Pre-check terminal state — if any waited-for child is already crashed/exited,
1273
+ // there's no idle to wait for; short-circuit instead of paying the 200ms
1274
+ // crashPoll latency. Same wording as the in-flight crash error below so
1275
+ // callers get a consistent shape.
1276
+ for (const name of names) {
1277
+ const c = this.children.get(name)!;
1278
+ if (c.status === 'crashed' || c.status === 'exited') {
1279
+ return {
1280
+ success: false,
1281
+ isError: true,
1282
+ error:
1283
+ `Child '${name}' ${c.status} before reaching idle ` +
1284
+ `(exitCode=${c.exitCode ?? '?'}, reason=${c.exitReason ?? '?'}). ` +
1285
+ `Completed so far: (none).`,
1286
+ };
1287
+ }
1288
+ }
1289
+
1290
+ const requireAll = input.requireAll !== false; // default true
1291
+ const timeoutMs = input.timeoutMs ?? 300_000;
1292
+ const startedAt = Date.now();
1293
+
1294
+ // Children that are *already* idle when the call begins should count
1295
+ // as done — look at the most recent lifecycle event on each.
1296
+ const done = new Set<string>();
1297
+ for (const name of names) {
1298
+ if (this.isMostRecentLifecycleIdle(this.children.get(name)!)) done.add(name);
1299
+ }
1300
+
1301
+ const metCriterion = (): boolean => requireAll
1302
+ ? done.size === names.length
1303
+ : done.size >= 1;
1304
+
1305
+ if (metCriterion()) {
1306
+ return {
1307
+ success: true,
1308
+ data: { names: [...done], waitedMs: 0, completed: true },
1309
+ };
1310
+ }
1311
+
1312
+ return new Promise<ToolResult>((resolve) => {
1313
+ const unsubs: Array<() => void> = [];
1314
+ let finished = false;
1315
+
1316
+ const finish = (result: ToolResult): void => {
1317
+ if (finished) return;
1318
+ finished = true;
1319
+ for (const u of unsubs) u();
1320
+ clearTimeout(timeoutHandle);
1321
+ clearInterval(crashPoll);
1322
+ resolve(result);
1323
+ };
1324
+
1325
+ // Subscribe to each child; count lifecycle:idle events toward done.
1326
+ for (const name of names) {
1327
+ const unsub = this.onChildEvent(name, (childName, evt) => {
1328
+ if (evt.type !== 'lifecycle') return;
1329
+ if ((evt as { phase?: string }).phase !== 'idle') return;
1330
+ done.add(childName);
1331
+ if (metCriterion()) {
1332
+ finish({
1333
+ success: true,
1334
+ data: { names: [...done], waitedMs: Date.now() - startedAt, completed: true },
1335
+ });
1336
+ }
1337
+ });
1338
+ unsubs.push(unsub);
1339
+ }
1340
+
1341
+ // Poll for crash/exit — children leaving the running state before
1342
+ // reaching idle short-circuits the wait with an error.
1343
+ const crashPoll = setInterval(() => {
1344
+ for (const name of names) {
1345
+ if (done.has(name)) continue;
1346
+ const c = this.children.get(name);
1347
+ if (!c) continue;
1348
+ if (c.status === 'crashed' || c.status === 'exited') {
1349
+ finish({
1350
+ success: false,
1351
+ isError: true,
1352
+ error:
1353
+ `Child '${name}' ${c.status} before reaching idle ` +
1354
+ `(exitCode=${c.exitCode ?? '?'}, reason=${c.exitReason ?? '?'}). ` +
1355
+ `Completed so far: ${[...done].join(', ') || '(none)'}.`,
1356
+ });
1357
+ return;
1358
+ }
1359
+ }
1360
+ }, 200);
1361
+
1362
+ const timeoutHandle = setTimeout(() => {
1363
+ finish({
1364
+ success: false,
1365
+ isError: true,
1366
+ error:
1367
+ `await timed out after ${timeoutMs}ms. ` +
1368
+ `Idle: ${[...done].join(', ') || '(none)'}. ` +
1369
+ `Still pending: ${names.filter((n) => !done.has(n)).join(', ')}.`,
1370
+ });
1371
+ }, timeoutMs);
1372
+ });
1373
+ }
1374
+
1375
+ /** Walk a child's event history backwards to find the most recent lifecycle event. */
1376
+ private isMostRecentLifecycleIdle(child: FleetChild): boolean {
1377
+ for (let i = child.events.length - 1; i >= 0; i--) {
1378
+ const e = child.events[i]!;
1379
+ if (e.type === 'lifecycle') {
1380
+ return (e as { phase?: string }).phase === 'idle';
1381
+ }
1382
+ }
1383
+ return false;
1384
+ }
1385
+
1386
+ private requireRunning(name: string): { child: FleetChild } | ToolResult {
1387
+ const c = this.children.get(name);
1388
+ if (!c) return { success: false, isError: true, error: `Unknown child: ${name}` };
1389
+ if (c.status !== 'ready' && c.status !== 'starting') {
1390
+ return { success: false, isError: true, error: `Child '${name}' is ${c.status}, not running` };
1391
+ }
1392
+ if (!c.socket) {
1393
+ return { success: false, isError: true, error: `Child '${name}' has no active socket` };
1394
+ }
1395
+ return { child: c };
1396
+ }
1397
+
1398
+ // =========================================================================
1399
+ // Subprocess + socket plumbing
1400
+ // =========================================================================
1401
+
1402
+ private async waitForSocket(child: FleetChild): Promise<void> {
1403
+ const timeout = this.config.socketWaitTimeoutMs;
1404
+ const start = Date.now();
1405
+ while (Date.now() - start < timeout) {
1406
+ if (child.process && child.process.exitCode !== null) {
1407
+ throw new Error(`child exited (code=${child.process.exitCode}) before socket appeared`);
1408
+ }
1409
+ if (existsSync(child.socketPath)) return;
1410
+ await new Promise((r) => setTimeout(r, 50));
1411
+ }
1412
+ throw new Error(`socket did not appear at ${child.socketPath} within ${timeout}ms`);
1413
+ }
1414
+
1415
+ private async connectChildSocket(child: FleetChild): Promise<void> {
1416
+ return new Promise((resolveConn, rejectConn) => {
1417
+ const sock = netConnect(child.socketPath);
1418
+ const timer = setTimeout(() => {
1419
+ sock.destroy();
1420
+ rejectConn(new Error('socket connect timeout'));
1421
+ }, 3_000);
1422
+
1423
+ sock.once('connect', () => {
1424
+ clearTimeout(timer);
1425
+ child.socket = sock;
1426
+ sock.on('data', (chunk: Buffer) => this.handleChildData(child, chunk));
1427
+ sock.on('end', () => {
1428
+ if (child.socket === sock) child.socket = null;
1429
+ });
1430
+ sock.on('error', () => {
1431
+ // Errors get reflected in status via process exit; don't crash parent.
1432
+ });
1433
+ resolveConn();
1434
+ });
1435
+ sock.once('error', (err) => {
1436
+ clearTimeout(timer);
1437
+ rejectConn(err);
1438
+ });
1439
+ });
1440
+ }
1441
+
1442
+ private async waitForReady(child: FleetChild): Promise<void> {
1443
+ const timeout = this.config.readyTimeoutMs;
1444
+ const start = Date.now();
1445
+ while (Date.now() - start < timeout) {
1446
+ if (child.status === 'ready') return;
1447
+ if (child.status === 'crashed' || child.status === 'exited') {
1448
+ throw new Error(`child ${child.status} before ready`);
1449
+ }
1450
+ await new Promise((r) => setTimeout(r, 50));
1451
+ }
1452
+ throw new Error(`child did not report ready within ${timeout}ms`);
1453
+ }
1454
+
1455
+ private handleChildData(child: FleetChild, chunk: Buffer): void {
1456
+ child.buffer += chunk.toString('utf-8');
1457
+ let nl: number;
1458
+ while ((nl = child.buffer.indexOf('\n')) >= 0) {
1459
+ const line = child.buffer.slice(0, nl).trim();
1460
+ child.buffer = child.buffer.slice(nl + 1);
1461
+ if (!line) continue;
1462
+ let parsed: WireEvent;
1463
+ try {
1464
+ parsed = JSON.parse(line) as WireEvent;
1465
+ } catch {
1466
+ continue; // drop malformed lines silently
1467
+ }
1468
+ this.recordEvent(child, parsed);
1469
+ }
1470
+ }
1471
+
1472
+ private recordEvent(child: FleetChild, event: WireEvent): void {
1473
+ child.lastEventAt = Date.now();
1474
+ child.events.push(event);
1475
+ if (child.events.length > this.config.bufferSize) {
1476
+ child.events.splice(0, child.events.length - this.config.bufferSize);
1477
+ }
1478
+ let statusChanged = false;
1479
+ if (event.type === 'lifecycle') {
1480
+ const phase = (event as { phase?: string }).phase;
1481
+ if (phase === 'ready' && child.status !== 'ready') {
1482
+ child.status = 'ready';
1483
+ statusChanged = true;
1484
+ }
1485
+ // 'exiting' is informational; the real status transition happens on
1486
+ // the process 'exit' handler so we capture exit code + signal.
1487
+ // 'idle' is informational — fan-out lets fleet--await subscribers
1488
+ // resolve; no status field change.
1489
+ } else if (event.type === 'inference:speech') {
1490
+ const content = (event as { content?: string }).content;
1491
+ if (typeof content === 'string') {
1492
+ child.lastCompletedSpeech = content;
1493
+ }
1494
+ }
1495
+ this.fanOutEvent(child.name, event);
1496
+ if (statusChanged) this.persistState();
1497
+ }
1498
+
1499
+ private fanOutEvent(childName: string, event: WireEvent): void {
1500
+ const type = typeof event.type === 'string' ? event.type : '';
1501
+ for (const key of [childName, '*']) {
1502
+ const subs = this.eventSubscribers.get(key);
1503
+ if (!subs) continue;
1504
+ for (const sub of subs) {
1505
+ if (sub.filter && !matchesSubscription(type, sub.filter)) continue;
1506
+ try { sub.callback(childName, event); } catch { /* don't let one subscriber kill the loop */ }
1507
+ }
1508
+ }
1509
+ }
1510
+
1511
+ private sendToChild(child: FleetChild, cmd: IncomingCommand): void {
1512
+ if (!child.socket) {
1513
+ throw new Error(`Child '${child.name}' has no socket`);
1514
+ }
1515
+ // Single chokepoint for outgoing commands. We force the reducer-required
1516
+ // events into every subscribe so recipe authors (or agents calling the
1517
+ // subscribe tool) can't accidentally disable the unified-tree rendering
1518
+ // by omitting events like `inference:tool_calls_yielded` — the reducer
1519
+ // would drift into showing fleet children with empty subtrees and stale
1520
+ // 'ready' status. The wire is fatter by ~10 event types but they're tiny
1521
+ // relative to inference token streams.
1522
+ let outgoing = cmd;
1523
+ if (cmd.type === 'subscribe') {
1524
+ const requested = cmd.events;
1525
+ const augmented = unionWithReducerRequired(requested);
1526
+ // Log when we expanded the caller's intent so operators tailing logs
1527
+ // can see *why* a child is receiving events its recipe didn't list.
1528
+ // Silent rewriting of caller intent is its own postmortem-in-waiting.
1529
+ if (augmented.length !== requested.length) {
1530
+ const requestedSet = new Set(requested);
1531
+ const added = augmented.filter(e => !requestedSet.has(e));
1532
+ if (added.length > 0) {
1533
+ console.error(
1534
+ `[fleet] subscribe for "${child.name}": forced reducer-required events: ${added.join(', ')}`,
1535
+ );
1536
+ }
1537
+ }
1538
+ outgoing = { type: 'subscribe', events: augmented };
1539
+ }
1540
+ child.socket.write(JSON.stringify(outgoing) + '\n');
1541
+ }
1542
+
1543
+ /**
1544
+ * Request a state snapshot from a fleet child. The child responds with a
1545
+ * single `snapshot` event over its event stream (received via onChildEvent).
1546
+ * Used as a recovery verb at sync points — TUI cold start, child reconnect,
1547
+ * after restart. See UNIFIED-TREE-PLAN.md §3.
1548
+ *
1549
+ * Returns true if the request was sent, false if the child has no socket
1550
+ * (e.g. exited or not yet ready).
1551
+ */
1552
+ requestDescribe(childName: string, corrId?: string): boolean {
1553
+ const child = this.children.get(childName);
1554
+ if (!child || !child.socket) return false;
1555
+ try {
1556
+ this.sendToChild(child, { type: 'describe', corrId });
1557
+ return true;
1558
+ } catch {
1559
+ return false;
1560
+ }
1561
+ }
1562
+
1563
+ /** Request the child's lesson library. Response arrives as a
1564
+ * `lessons-snapshot` event on the child's event stream — subscribers
1565
+ * match on `corrId` to correlate. */
1566
+ requestLessons(childName: string, corrId?: string): boolean {
1567
+ const child = this.children.get(childName);
1568
+ if (!child || !child.socket) return false;
1569
+ try { this.sendToChild(child, { type: 'request-lessons', corrId }); return true; }
1570
+ catch { return false; }
1571
+ }
1572
+
1573
+ /** Request the child's workspace mount list. Response is a
1574
+ * `workspace-mounts-snapshot` event. */
1575
+ requestWorkspaceMounts(childName: string, corrId?: string): boolean {
1576
+ const child = this.children.get(childName);
1577
+ if (!child || !child.socket) return false;
1578
+ try { this.sendToChild(child, { type: 'request-workspace-mounts', corrId }); return true; }
1579
+ catch { return false; }
1580
+ }
1581
+
1582
+ /** Request a recursive listing of one mount in the child. Response is a
1583
+ * `workspace-tree-snapshot` event. */
1584
+ requestWorkspaceTree(childName: string, mount: string, corrId?: string): boolean {
1585
+ const child = this.children.get(childName);
1586
+ if (!child || !child.socket) return false;
1587
+ try { this.sendToChild(child, { type: 'request-workspace-tree', mount, corrId }); return true; }
1588
+ catch { return false; }
1589
+ }
1590
+
1591
+ /** Request a workspace file read from the child. Response is a
1592
+ * `workspace-file-snapshot` event (with `error` set on failure). */
1593
+ requestWorkspaceFile(childName: string, path: string, corrId?: string): boolean {
1594
+ const child = this.children.get(childName);
1595
+ if (!child || !child.socket) return false;
1596
+ try { this.sendToChild(child, { type: 'request-workspace-file', path, corrId }); return true; }
1597
+ catch { return false; }
1598
+ }
1599
+
1600
+ /** Cancel a specific in-process subagent inside a fleet child. Response
1601
+ * arrives as a `cancel-subagent-result` event on the child's event stream;
1602
+ * the WUI handler matches on corrId to route the result back to the
1603
+ * originating browser client. Returns false if the named child isn't
1604
+ * running. */
1605
+ cancelSubagentOnChild(childName: string, name: string, corrId?: string): boolean {
1606
+ const child = this.children.get(childName);
1607
+ if (!child || !child.socket) return false;
1608
+ try { this.sendToChild(child, { type: 'cancel-subagent', name, corrId }); return true; }
1609
+ catch { return false; }
1610
+ }
1611
+
1612
+ private async killChild(child: FleetChild): Promise<void> {
1613
+ if (child.status === 'exited' || child.status === 'crashed') return;
1614
+ const proc = child.process;
1615
+ const pid = child.pid;
1616
+ // Nothing to kill: never spawned and no adopted pid recorded.
1617
+ if (!proc && pid === null) return;
1618
+
1619
+ // Mark intent so the exit handler doesn't trigger autoRestart.
1620
+ child.killRequested = true;
1621
+
1622
+ if (child.socket) {
1623
+ try { this.sendToChild(child, { type: 'shutdown', graceful: true }); } catch { /* noop */ }
1624
+ }
1625
+
1626
+ // Adopted children have no ChildProcess handle; we can still send signals
1627
+ // via the recorded pid and poll liveness with signal 0.
1628
+ const waitExit = (ms: number): Promise<boolean> =>
1629
+ proc !== null ? this.waitForExit(proc, ms) : this.waitForExitByPid(pid!, ms);
1630
+ const sendSignal = (sig: NodeJS.Signals): void => {
1631
+ if (proc) { try { proc.kill(sig); } catch { /* noop */ } }
1632
+ else if (pid !== null) { try { process.kill(pid, sig); } catch { /* noop */ } }
1633
+ };
1634
+
1635
+ if (await waitExit(this.config.gracefulShutdownMs)) return;
1636
+ sendSignal('SIGTERM');
1637
+ if (await waitExit(this.config.sigtermEscalationMs)) return;
1638
+ sendSignal('SIGKILL');
1639
+ await waitExit(2_000);
1640
+ }
1641
+
1642
+ private waitForExit(proc: ChildProcess, timeoutMs: number): Promise<boolean> {
1643
+ if (proc.exitCode !== null) return Promise.resolve(true);
1644
+ return new Promise((res) => {
1645
+ const timer = setTimeout(() => {
1646
+ proc.off('exit', onExit);
1647
+ res(false);
1648
+ }, timeoutMs);
1649
+ const onExit = (): void => {
1650
+ clearTimeout(timer);
1651
+ res(true);
1652
+ };
1653
+ proc.once('exit', onExit);
1654
+ });
1655
+ }
1656
+
1657
+ /** Poll-based liveness for adopted children (no ChildProcess handle).
1658
+ * `kill(pid, 0)` throws ESRCH once the process is gone. */
1659
+ private waitForExitByPid(pid: number, timeoutMs: number): Promise<boolean> {
1660
+ const deadline = Date.now() + timeoutMs;
1661
+ return new Promise((res) => {
1662
+ const tick = (): void => {
1663
+ try {
1664
+ process.kill(pid, 0);
1665
+ if (Date.now() >= deadline) return res(false);
1666
+ setTimeout(tick, 100);
1667
+ } catch {
1668
+ res(true); // ESRCH (or EPERM rarely) — assume gone
1669
+ }
1670
+ };
1671
+ tick();
1672
+ });
1673
+ }
1674
+ }
1675
+
1676
+ // ---------------------------------------------------------------------------
1677
+ // Internal helpers
1678
+ // ---------------------------------------------------------------------------
1679
+
1680
+ function isUrlOrAbsolute(p: string): boolean {
1681
+ return p.startsWith('http://') || p.startsWith('https://') || isAbsolute(p);
1682
+ }
1683
+
1684
+ // ---------------------------------------------------------------------------
1685
+ // Shared formatter — used by both the TUI process view and the /fleet list
1686
+ // slash command so the two surfaces can't drift into different column sets.
1687
+ // ---------------------------------------------------------------------------
1688
+
1689
+ /** Shape a row renderer needs from a FleetChild. */
1690
+ export interface ChildRowView {
1691
+ name: string;
1692
+ status: ChildStatus;
1693
+ pid: number | null;
1694
+ startedAt: number;
1695
+ events: ReadonlyArray<unknown>;
1696
+ }
1697
+
1698
+ /** Format a single child as a single-line status row (no color codes). */
1699
+ export function formatChildRow(c: ChildRowView, now: number = Date.now()): string {
1700
+ const elapsed = Math.floor((now - c.startedAt) / 1000);
1701
+ const min = Math.floor(elapsed / 60);
1702
+ const sec = elapsed % 60;
1703
+ const timeStr = min > 0 ? `${min}m${sec}s` : `${sec}s`;
1704
+ return `${c.name.padEnd(16)} ${c.status.padEnd(10)} pid=${(c.pid ?? '-').toString().padEnd(7)} ${timeStr.padEnd(6)} events=${c.events.length}`;
1705
+ }