@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,528 @@
1
+ /**
2
+ * Headless daemon runtime.
3
+ *
4
+ * IPC: Unix domain socket at {DATA_DIR}/ipc.sock (override with --socket-path).
5
+ * Wire format: JSON Lines, one envelope per line, both directions.
6
+ *
7
+ * Output: framework TraceEvents (filtered by client subscription) +
8
+ * lifecycle events ({type:"lifecycle",phase:"ready|idle|exiting"}).
9
+ * Input: {type:"subscribe",events:[...]} | {type:"text",content} |
10
+ * {type:"command",command:"/..."} | {type:"shutdown",graceful?}
11
+ *
12
+ * stdout + stderr are redirected to {DATA_DIR}/headless.log so the IPC
13
+ * channel is the only structured output path (parent uses the socket, not
14
+ * pipes). PID lives at {DATA_DIR}/headless.pid for parent-side liveness.
15
+ *
16
+ * One client at a time. Client disconnect does NOT exit the process —
17
+ * children stay up across parent restarts and accept the next connection.
18
+ *
19
+ * See HEADLESS-FLEET-PLAN.md (root) for the full protocol spec.
20
+ */
21
+
22
+ import { createServer, type Socket, type Server } from 'node:net';
23
+ import { createWriteStream, mkdirSync, existsSync, unlinkSync, writeFileSync, type WriteStream } from 'node:fs';
24
+ import { join, resolve } from 'node:path';
25
+ import type { AppContext } from './index.js';
26
+ import { type IncomingCommand, matchesSubscription } from './modules/fleet-types.js';
27
+ import { AgentTreeReducer } from './state/agent-tree-reducer.js';
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Local types
31
+ // ---------------------------------------------------------------------------
32
+
33
+ interface HeadlessOptions {
34
+ socketPath?: string;
35
+ exitWhenIdle?: boolean;
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Argv parsing
40
+ // ---------------------------------------------------------------------------
41
+
42
+ function parseHeadlessArgs(argv: string[]): HeadlessOptions {
43
+ const opts: HeadlessOptions = {};
44
+ for (let i = 0; i < argv.length; i++) {
45
+ if (argv[i] === '--socket-path' && i + 1 < argv.length) {
46
+ opts.socketPath = argv[i + 1];
47
+ i++;
48
+ } else if (argv[i] === '--exit-when-idle') {
49
+ opts.exitWhenIdle = true;
50
+ }
51
+ }
52
+ return opts;
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Main entry
57
+ // ---------------------------------------------------------------------------
58
+
59
+ export async function runHeadless(app: AppContext, argv: string[] = []): Promise<void> {
60
+ const opts = parseHeadlessArgs(argv);
61
+ const dataDir = resolve(process.env.DATA_DIR || './data');
62
+ const socketPath = opts.socketPath ?? join(dataDir, 'ipc.sock');
63
+ const logPath = join(dataDir, 'headless.log');
64
+ const pidPath = join(dataDir, 'headless.pid');
65
+
66
+ mkdirSync(dataDir, { recursive: true });
67
+
68
+ // -- Redirect stdout/stderr BEFORE anything else may log --
69
+ // The IPC socket is the only structured output channel; stdout/stderr
70
+ // become a debug log on disk. Done first so any later console.log from
71
+ // framework code lands in the file, not on a parent's pipe.
72
+ //
73
+ // Match the real process.stdout.write overload signature so libraries
74
+ // passing (chunk, cb) or (chunk, encoding, cb) for backpressure /
75
+ // flush confirmation don't have their callbacks silently swallowed.
76
+ const logStream: WriteStream = createWriteStream(logPath, { flags: 'a' });
77
+ type StdWrite = typeof process.stdout.write;
78
+ const makeRedirect = (): StdWrite => {
79
+ const redirect = function (
80
+ this: unknown,
81
+ chunk: string | Uint8Array,
82
+ encodingOrCb?: BufferEncoding | ((err?: Error | null) => void),
83
+ cb?: (err?: Error | null) => void,
84
+ ): boolean {
85
+ if (typeof encodingOrCb === 'function') {
86
+ return logStream.write(chunk, encodingOrCb);
87
+ }
88
+ if (encodingOrCb !== undefined) {
89
+ return logStream.write(chunk, encodingOrCb, cb);
90
+ }
91
+ return logStream.write(chunk);
92
+ } as StdWrite;
93
+ return redirect;
94
+ };
95
+ process.stdout.write = makeRedirect();
96
+ process.stderr.write = makeRedirect();
97
+
98
+ const log = (msg: string): void => {
99
+ logStream.write(`[${new Date().toISOString()}] ${msg}\n`);
100
+ };
101
+
102
+ log(`headless start pid=${process.pid} dataDir=${dataDir} socket=${socketPath}`);
103
+
104
+ // -- Stale socket cleanup --
105
+ // If a previous instance crashed without unlink, listen() would EADDRINUSE.
106
+ // The PID file would tell us if a previous instance is still alive, but
107
+ // for Phase 1 we assume single-tenancy per data dir and just remove.
108
+ if (existsSync(socketPath)) {
109
+ try {
110
+ unlinkSync(socketPath);
111
+ log('removed stale socket');
112
+ } catch (err) {
113
+ log(`stale-socket cleanup failed: ${String(err)}`);
114
+ }
115
+ }
116
+
117
+ // -- PID file (for Phase 5 liveness probing by parent) --
118
+ try {
119
+ writeFileSync(pidPath, String(process.pid));
120
+ } catch (err) {
121
+ log(`pid file write failed: ${String(err)}`);
122
+ }
123
+
124
+ // -- Connection state --
125
+ let currentClient: Socket | null = null;
126
+ // Default subscription: receive everything. Smoke-test friendly; parents
127
+ // are expected to send their own {type:"subscribe",events:[...]} on connect.
128
+ let subscription = new Set<string>(['*']);
129
+
130
+ // Events that bypass subscription filtering: protocol responses, not telemetry.
131
+ // Clients narrow their event stream for bandwidth, not protocol correctness —
132
+ // request/response pairs would be useless if the response got dropped.
133
+ const FILTER_EXEMPT = new Set<string>([
134
+ 'snapshot',
135
+ 'lessons-snapshot',
136
+ 'workspace-mounts-snapshot',
137
+ 'workspace-tree-snapshot',
138
+ 'workspace-file-snapshot',
139
+ ]);
140
+
141
+ function emit(event: Record<string, unknown>): void {
142
+ if (!currentClient) return;
143
+ const type = typeof event.type === 'string' ? event.type : '';
144
+ if (!FILTER_EXEMPT.has(type) && !matchesSubscription(type, subscription)) return;
145
+ try {
146
+ currentClient.write(JSON.stringify({ ...event, ts: Date.now() }) + '\n');
147
+ } catch (err) {
148
+ log(`emit failed for type=${type}: ${String(err)}`);
149
+ }
150
+ }
151
+
152
+ // -- Long-lived agent-tree reducer --
153
+ // Subscribed to framework traces from process startup; accumulates state for
154
+ // the lifetime of the child. Drives the 'describe' response. Same reducer
155
+ // shape runs in the parent for fleet children — see UNIFIED-TREE-PLAN.md §2.
156
+ const treeReducer = new AgentTreeReducer();
157
+ try {
158
+ treeReducer.seedFrameworkAgents(app.framework.getAllAgents().map(a => a.name));
159
+ } catch (err) {
160
+ log(`seed framework agents failed: ${String(err)}`);
161
+ }
162
+ const startedAt = Date.now();
163
+
164
+ // -- Wire framework trace events to socket and reducer --
165
+ app.framework.onTrace((traceEvent) => {
166
+ treeReducer.applyEvent(traceEvent);
167
+ emit(traceEvent as unknown as Record<string, unknown>);
168
+ });
169
+
170
+ // -- Command dispatch --
171
+ async function dispatchCommand(cmd: IncomingCommand): Promise<void> {
172
+ switch (cmd.type) {
173
+ case 'subscribe': {
174
+ if (!Array.isArray(cmd.events)) {
175
+ log('subscribe rejected: events must be array');
176
+ return;
177
+ }
178
+ subscription = new Set(cmd.events);
179
+ log(`subscription set: ${[...subscription].join(', ') || '(none)'}`);
180
+ return;
181
+ }
182
+ case 'text': {
183
+ if (typeof cmd.content !== 'string') {
184
+ log('text rejected: content must be string');
185
+ return;
186
+ }
187
+ app.framework.pushEvent({
188
+ type: 'external-message',
189
+ source: 'headless',
190
+ content: cmd.content,
191
+ metadata: {},
192
+ triggerInference: true,
193
+ });
194
+ return;
195
+ }
196
+ case 'command': {
197
+ if (typeof cmd.command !== 'string') {
198
+ log('command rejected: command must be string');
199
+ return;
200
+ }
201
+ const { handleCommand } = await import('./commands.js');
202
+ const result = handleCommand(cmd.command, app);
203
+ for (const line of result.lines) {
204
+ emit({ type: 'command-output', text: line.text, style: line.style ?? null });
205
+ }
206
+ if (result.switchToSessionId) {
207
+ await app.switchSession(result.switchToSessionId);
208
+ emit({ type: 'command-output', text: 'Session switched.', style: 'system' });
209
+ }
210
+ if (result.quit) {
211
+ await gracefulShutdown('command:/quit');
212
+ }
213
+ return;
214
+ }
215
+ case 'shutdown': {
216
+ await gracefulShutdown(cmd.graceful === false ? 'shutdown:immediate' : 'shutdown:graceful');
217
+ return;
218
+ }
219
+ case 'describe': {
220
+ // Recovery verb: parent requests a full state snapshot at sync points
221
+ // (cold start, reconnect, after restart). See UNIFIED-TREE-PLAN.md §1.
222
+ const snap = treeReducer.getSnapshot();
223
+ emit({
224
+ type: 'snapshot',
225
+ corrId: cmd.corrId,
226
+ asOfTs: snap.asOfTs,
227
+ child: {
228
+ name: app.recipe.name,
229
+ pid: process.pid,
230
+ recipe: app.recipe.name,
231
+ startedAt,
232
+ },
233
+ tree: {
234
+ nodes: snap.nodes as unknown as Array<Record<string, unknown>>,
235
+ callIdIndex: snap.callIdIndex,
236
+ },
237
+ });
238
+ return;
239
+ }
240
+ case 'request-lessons': {
241
+ const mod = app.framework.getAllModules().find((m) => m.name === 'lessons') as
242
+ | { getLessons(): Array<{ id: string; content: string; confidence: number; tags: string[]; deprecated: boolean; deprecationReason?: string; created?: number; updated?: number }> }
243
+ | undefined;
244
+ if (!mod) {
245
+ emit({ type: 'lessons-snapshot', corrId: cmd.corrId, loaded: false, lessons: [] });
246
+ return;
247
+ }
248
+ const lessons = mod.getLessons().map(l => ({
249
+ id: l.id,
250
+ content: l.content,
251
+ confidence: l.confidence,
252
+ tags: l.tags,
253
+ deprecated: l.deprecated,
254
+ ...(l.deprecationReason ? { deprecationReason: l.deprecationReason } : {}),
255
+ ...(typeof l.created === 'number' ? { created: l.created } : {}),
256
+ ...(typeof l.updated === 'number' ? { updated: l.updated } : {}),
257
+ }));
258
+ emit({ type: 'lessons-snapshot', corrId: cmd.corrId, loaded: true, lessons });
259
+ return;
260
+ }
261
+ case 'request-workspace-mounts': {
262
+ const mod = app.framework.getAllModules().find((m) => m.name === 'workspace') as
263
+ | { handleToolCall(call: { name: string; input: unknown; id?: string }): Promise<{ success: boolean; data?: unknown; error?: string }> }
264
+ | undefined;
265
+ if (!mod) {
266
+ emit({ type: 'workspace-mounts-snapshot', corrId: cmd.corrId, loaded: false, mounts: [] });
267
+ return;
268
+ }
269
+ try {
270
+ const result = await mod.handleToolCall({ name: 'ls', input: {}, id: `headless-ls-${Date.now()}` });
271
+ const data = (result.data ?? {}) as { mounts?: Array<{ name: string; path: string; mode: string }> };
272
+ emit({ type: 'workspace-mounts-snapshot', corrId: cmd.corrId, loaded: true, mounts: data.mounts ?? [] });
273
+ } catch {
274
+ emit({ type: 'workspace-mounts-snapshot', corrId: cmd.corrId, loaded: true, mounts: [] });
275
+ }
276
+ return;
277
+ }
278
+ case 'request-workspace-tree': {
279
+ const mod = app.framework.getAllModules().find((m) => m.name === 'workspace') as
280
+ | { handleToolCall(call: { name: string; input: unknown; id?: string }): Promise<{ success: boolean; data?: unknown; error?: string }> }
281
+ | undefined;
282
+ if (!mod) {
283
+ emit({ type: 'workspace-tree-snapshot', corrId: cmd.corrId, mount: cmd.mount, entries: [] });
284
+ return;
285
+ }
286
+ try {
287
+ const result = await mod.handleToolCall({
288
+ name: 'ls', input: { path: cmd.mount, recursive: true }, id: `headless-tree-${Date.now()}`,
289
+ });
290
+ const data = (result.data ?? {}) as { entries?: Array<{ path: string; size: number }> };
291
+ emit({ type: 'workspace-tree-snapshot', corrId: cmd.corrId, mount: cmd.mount, entries: data.entries ?? [] });
292
+ } catch {
293
+ emit({ type: 'workspace-tree-snapshot', corrId: cmd.corrId, mount: cmd.mount, entries: [] });
294
+ }
295
+ return;
296
+ }
297
+ case 'request-workspace-file': {
298
+ const mod = app.framework.getAllModules().find((m) => m.name === 'workspace') as
299
+ | { handleToolCall(call: { name: string; input: unknown; id?: string }): Promise<{ success: boolean; data?: unknown; error?: string }> }
300
+ | undefined;
301
+ if (!mod) {
302
+ emit({ type: 'workspace-file-snapshot', corrId: cmd.corrId, path: cmd.path, totalLines: 0, fromLine: 1, toLine: 0, content: '', truncated: false, error: 'workspace module not loaded' });
303
+ return;
304
+ }
305
+ const LIMIT = 5000;
306
+ try {
307
+ const result = await mod.handleToolCall({
308
+ name: 'read', input: { path: cmd.path, limit: LIMIT }, id: `headless-read-${Date.now()}`,
309
+ });
310
+ if (!result.success) {
311
+ emit({ type: 'workspace-file-snapshot', corrId: cmd.corrId, path: cmd.path, totalLines: 0, fromLine: 1, toLine: 0, content: '', truncated: false, error: result.error ?? 'read failed' });
312
+ return;
313
+ }
314
+ const data = (result.data ?? {}) as { path?: string; totalLines?: number; fromLine?: number; toLine?: number; content?: string };
315
+ const totalLines = data.totalLines ?? 0;
316
+ const toLine = data.toLine ?? totalLines;
317
+ emit({
318
+ type: 'workspace-file-snapshot',
319
+ corrId: cmd.corrId,
320
+ path: data.path ?? cmd.path,
321
+ totalLines,
322
+ fromLine: data.fromLine ?? 1,
323
+ toLine,
324
+ content: data.content ?? '',
325
+ truncated: toLine < totalLines,
326
+ });
327
+ } catch (err) {
328
+ emit({ type: 'workspace-file-snapshot', corrId: cmd.corrId, path: cmd.path, totalLines: 0, fromLine: 1, toLine: 0, content: '', truncated: false, error: err instanceof Error ? err.message : String(err) });
329
+ }
330
+ return;
331
+ }
332
+ case 'cancel-subagent': {
333
+ const mod = app.framework.getAllModules().find((m) => m.name === 'subagent') as
334
+ | { cancelSubagent(name: string): boolean }
335
+ | undefined;
336
+ if (!mod) {
337
+ emit({
338
+ type: 'cancel-subagent-result',
339
+ corrId: cmd.corrId,
340
+ name: cmd.name,
341
+ cancelled: false,
342
+ reason: 'subagent module not loaded',
343
+ });
344
+ return;
345
+ }
346
+ const ok = mod.cancelSubagent(cmd.name);
347
+ emit({
348
+ type: 'cancel-subagent-result',
349
+ corrId: cmd.corrId,
350
+ name: cmd.name,
351
+ cancelled: ok,
352
+ ...(ok ? {} : { reason: 'subagent not running' }),
353
+ });
354
+ return;
355
+ }
356
+ default: {
357
+ const t = (cmd as { type?: unknown }).type;
358
+ log(`unknown command type: ${String(t)}`);
359
+ }
360
+ }
361
+ }
362
+
363
+ // -- Socket server --
364
+ const server: Server = createServer((socket) => {
365
+ if (currentClient) {
366
+ log('new client connecting; closing previous client');
367
+ try { currentClient.end(); } catch { /* noop */ }
368
+ }
369
+ currentClient = socket;
370
+ log('client connected');
371
+
372
+ // Reset subscription to default on new connection so old filters
373
+ // don't carry across parents.
374
+ subscription = new Set<string>(['*']);
375
+
376
+ let buffer = '';
377
+ socket.on('data', (chunk) => {
378
+ buffer += chunk.toString('utf-8');
379
+ let nlIdx: number;
380
+ while ((nlIdx = buffer.indexOf('\n')) >= 0) {
381
+ const line = buffer.slice(0, nlIdx).trim();
382
+ buffer = buffer.slice(nlIdx + 1);
383
+ if (!line) continue;
384
+ let parsed: IncomingCommand;
385
+ try {
386
+ parsed = JSON.parse(line) as IncomingCommand;
387
+ } catch (err) {
388
+ log(`malformed JSON line dropped (${(err as Error).message}): ${line.slice(0, 200)}`);
389
+ continue;
390
+ }
391
+ // Fire-and-forget; errors logged inside dispatchCommand.
392
+ dispatchCommand(parsed).catch((err: unknown) => {
393
+ log(`dispatchCommand threw: ${String(err)}`);
394
+ });
395
+ }
396
+ });
397
+
398
+ socket.on('end', () => {
399
+ if (currentClient === socket) {
400
+ currentClient = null;
401
+ log('client disconnected; child stays up');
402
+ }
403
+ });
404
+
405
+ socket.on('error', (err) => {
406
+ log(`client socket error: ${String(err)}`);
407
+ });
408
+
409
+ // Send ready event as soon as the socket is live. The framework was
410
+ // already started before runHeadless was invoked, so 'ready' here means
411
+ // the IPC channel is open and the framework is accepting events.
412
+ emit({
413
+ type: 'lifecycle',
414
+ phase: 'ready',
415
+ pid: process.pid,
416
+ dataDir,
417
+ recipe: app.recipe.name,
418
+ });
419
+ });
420
+
421
+ server.on('error', (err) => {
422
+ log(`server error: ${String(err)}`);
423
+ });
424
+
425
+ await new Promise<void>((resolveListen, rejectListen) => {
426
+ server.listen(socketPath, () => {
427
+ log(`socket listening at ${socketPath}`);
428
+ resolveListen();
429
+ });
430
+ server.once('error', rejectListen);
431
+ });
432
+
433
+ // -- Shutdown --
434
+ let shuttingDown = false;
435
+ async function gracefulShutdown(reason: string): Promise<void> {
436
+ if (shuttingDown) return;
437
+ shuttingDown = true;
438
+ log(`shutdown: ${reason}`);
439
+ emit({ type: 'lifecycle', phase: 'exiting', reason });
440
+
441
+ // Give the exiting event a tick to flush onto the socket.
442
+ await new Promise((r) => setTimeout(r, 50));
443
+
444
+ try { await app.framework.stop(); } catch (err) { log(`framework.stop() failed: ${String(err)}`); }
445
+ try { server.close(); } catch (err) { log(`server.close() failed: ${String(err)}`); }
446
+ try { if (existsSync(socketPath)) unlinkSync(socketPath); } catch (err) { log(`socket unlink failed: ${String(err)}`); }
447
+ try { if (existsSync(pidPath)) unlinkSync(pidPath); } catch (err) { log(`pid unlink failed: ${String(err)}`); }
448
+ try { logStream.end(); } catch { /* noop */ }
449
+
450
+ // Small delay so logStream.end() can flush before we exit.
451
+ setTimeout(() => process.exit(0), 50);
452
+ }
453
+
454
+ process.on('SIGTERM', () => { void gracefulShutdown('SIGTERM'); });
455
+ process.on('SIGINT', () => { void gracefulShutdown('SIGINT'); });
456
+
457
+ // ------------------------------------------------------------------
458
+ // Synthetic wire events: lifecycle:idle (quiescence) + inference:speech
459
+ // ------------------------------------------------------------------
460
+ //
461
+ // `lifecycle:idle` fires on every work→quiescent transition so the parent
462
+ // can implement fleet--await (block until child finished its task).
463
+ // --exit-when-idle is a strict overlay: same detection, additionally
464
+ // shuts down on the transition.
465
+ //
466
+ // `inference:speech` fires once per "final" inference round — the round
467
+ // that completed without yielding tool calls. It carries the accumulated
468
+ // speech text, letting the parent implement fleet--relay without having
469
+ // to subscribe to the whole token stream.
470
+ {
471
+ const IDLE_CONFIRM_MS = 500;
472
+ const primaryAgentName = app.recipe.agent.name ?? 'agent';
473
+ let hadAtLeastOneInference = false;
474
+ let idleSince = 0;
475
+ let idleEmitted = false;
476
+
477
+ // Speech accumulator for the primary agent. Reset on inference:started
478
+ // and on inference:tool_calls_yielded (that round was a tool-use turn,
479
+ // not the final speech). Emitted on inference:completed if non-empty.
480
+ let currentSpeech = '';
481
+
482
+ app.framework.onTrace((event) => {
483
+ const t = (event as { type?: string }).type;
484
+ const agentName = (event as { agentName?: string }).agentName;
485
+
486
+ if (t === 'inference:started') {
487
+ hadAtLeastOneInference = true;
488
+ idleEmitted = false; // new activity — allow another idle emit when it ends
489
+ if (agentName === primaryAgentName) currentSpeech = '';
490
+ } else if (t === 'inference:tokens' && agentName === primaryAgentName) {
491
+ const content = (event as { content?: string }).content;
492
+ if (content) currentSpeech += content;
493
+ } else if (t === 'inference:tool_calls_yielded' && agentName === primaryAgentName) {
494
+ // Tool-call round: speech so far was thought preamble, not final.
495
+ currentSpeech = '';
496
+ } else if (t === 'inference:completed' && agentName === primaryAgentName) {
497
+ if (currentSpeech) {
498
+ emit({ type: 'inference:speech', agentName, content: currentSpeech });
499
+ }
500
+ currentSpeech = '';
501
+ }
502
+ });
503
+
504
+ const idlePoll = setInterval(() => {
505
+ if (shuttingDown) { clearInterval(idlePoll); return; }
506
+ const agents = app.framework.getAllAgents();
507
+ const allIdle = agents.every((a) => a.state.status === 'idle');
508
+ if (!hadAtLeastOneInference || !allIdle) {
509
+ idleSince = 0;
510
+ return;
511
+ }
512
+ if (idleSince === 0) { idleSince = Date.now(); return; }
513
+ if (Date.now() - idleSince >= IDLE_CONFIRM_MS && !idleEmitted) {
514
+ idleEmitted = true;
515
+ log('quiescent — emitting lifecycle:idle');
516
+ emit({ type: 'lifecycle', phase: 'idle' });
517
+ if (opts.exitWhenIdle) {
518
+ clearInterval(idlePoll);
519
+ void gracefulShutdown('exit-when-idle');
520
+ }
521
+ }
522
+ }, 100);
523
+ }
524
+
525
+ // Stay up indefinitely. Shutdown is the only thing that resolves us
526
+ // (via process.exit), so this Promise intentionally never fulfils.
527
+ await new Promise<void>(() => { /* park */ });
528
+ }