@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.
- package/.env.example +20 -0
- package/.github/workflows/publish.yml +65 -0
- package/ARCHITECTURE.md +355 -0
- package/CHANGELOG.md +30 -0
- package/HEADLESS-FLEET-PLAN.md +330 -0
- package/README.md +189 -0
- package/UNIFIED-TREE-PLAN.md +242 -0
- package/bun.lock +288 -0
- package/docs/AGENT-MEMORY-GUIDE.md +214 -0
- package/docs/AGENT-ONBOARDING.md +541 -0
- package/docs/ATTENTION-AND-GATING.md +120 -0
- package/docs/DEPLOYMENTS.md +130 -0
- package/docs/DEV-ENVIRONMENT.md +215 -0
- package/docs/LIBRARY-PIPELINE.md +286 -0
- package/docs/LOCUS-ROUTING-DESIGN.md +115 -0
- package/docs/claudeai-evacuation.md +228 -0
- package/docs/debug-context-api.md +208 -0
- package/docs/webui-deployment.md +219 -0
- package/package.json +33 -0
- package/recipes/SETUP.md +308 -0
- package/recipes/TRIUMVIRATE-SETUP.md +467 -0
- package/recipes/claude-export-revive.json +19 -0
- package/recipes/clerk.json +157 -0
- package/recipes/knowledge-miner.json +174 -0
- package/recipes/knowledge-reviewer.json +76 -0
- package/recipes/mcpl-editor-test.json +38 -0
- package/recipes/prompts/transplant-addendum.md +17 -0
- package/recipes/triumvirate.json +63 -0
- package/recipes/webui-fleet-test.json +27 -0
- package/recipes/webui-test.json +16 -0
- package/recipes/zulip-miner.json +87 -0
- package/scripts/connectome-doctor +373 -0
- package/scripts/evacuator.ts +956 -0
- package/scripts/import-claudeai-export.ts +747 -0
- package/scripts/lib/line-reader.ts +53 -0
- package/scripts/test-historical-thinking.ts +148 -0
- package/scripts/warmup-session.ts +336 -0
- package/src/agent-name.ts +58 -0
- package/src/commands.ts +1074 -0
- package/src/headless.ts +528 -0
- package/src/index.ts +867 -0
- package/src/logging-adapter.ts +208 -0
- package/src/mcpl-config.ts +199 -0
- package/src/modules/activity-module.ts +270 -0
- package/src/modules/channel-mode-module.ts +260 -0
- package/src/modules/fleet-module.ts +1705 -0
- package/src/modules/fleet-types.ts +225 -0
- package/src/modules/lessons-module.ts +465 -0
- package/src/modules/mcpl-admin-module.ts +345 -0
- package/src/modules/retrieval-module.ts +327 -0
- package/src/modules/settings-module.ts +143 -0
- package/src/modules/subagent-module.ts +2074 -0
- package/src/modules/subscription-gc-module.ts +322 -0
- package/src/modules/time-module.ts +85 -0
- package/src/modules/tui-module.ts +76 -0
- package/src/modules/web-ui-curve-page.ts +249 -0
- package/src/modules/web-ui-module.ts +2595 -0
- package/src/recipe.ts +1003 -0
- package/src/session-manager.ts +278 -0
- package/src/state/agent-tree-reducer.ts +431 -0
- package/src/state/fleet-tree-aggregator.ts +195 -0
- package/src/strategies/frontdesk-strategy.ts +364 -0
- package/src/synesthete.ts +68 -0
- package/src/tui.ts +2200 -0
- package/src/types/bun-ffi.d.ts +6 -0
- package/src/web/protocol.ts +648 -0
- package/test/agent-name.test.ts +62 -0
- package/test/agent-tree-fleet-launch.test.ts +64 -0
- package/test/agent-tree-reducer-parity.test.ts +194 -0
- package/test/agent-tree-reducer.test.ts +443 -0
- package/test/agent-tree-rollup.test.ts +109 -0
- package/test/autobio-progress-snapshot.test.ts +40 -0
- package/test/claudeai-export-importer.test.ts +386 -0
- package/test/evacuator.test.ts +332 -0
- package/test/fleet-commands.test.ts +244 -0
- package/test/fleet-no-subfleets.test.ts +147 -0
- package/test/fleet-orchestration.test.ts +353 -0
- package/test/fleet-recipe.test.ts +124 -0
- package/test/fleet-route.test.ts +44 -0
- package/test/fleet-smoke.test.ts +479 -0
- package/test/fleet-subscribe-union-e2e.test.ts +123 -0
- package/test/fleet-subscribe-union.test.ts +85 -0
- package/test/fleet-tree-aggregator-e2e.test.ts +110 -0
- package/test/fleet-tree-aggregator.test.ts +215 -0
- package/test/frontdesk-strategy.test.ts +326 -0
- package/test/headless-describe.test.ts +182 -0
- package/test/headless-smoke.test.ts +190 -0
- package/test/logging-adapter.test.ts +87 -0
- package/test/mcpl-admin-module.test.ts +213 -0
- package/test/mcpl-agent-overlay.test.ts +126 -0
- package/test/mock-headless-child.ts +133 -0
- package/test/recipe-cache-ttl.test.ts +37 -0
- package/test/recipe-env.test.ts +157 -0
- package/test/recipe-path-resolution.test.ts +170 -0
- package/test/subagent-async-timeout.test.ts +381 -0
- package/test/subagent-fork-materialise.test.ts +241 -0
- package/test/subagent-peek-scoping.test.ts +180 -0
- package/test/subagent-peek-zombie.test.ts +148 -0
- package/test/subagent-reaper-in-flight.test.ts +229 -0
- package/test/subscription-gc-module.test.ts +136 -0
- package/test/warmup-handoff.test.ts +150 -0
- package/test/web-ui-bind.test.ts +51 -0
- package/test/web-ui-module.test.ts +246 -0
- package/test/web-ui-protocol.test.ts +0 -0
- package/tsconfig.json +14 -0
- package/web/bun.lock +357 -0
- package/web/index.html +12 -0
- package/web/package.json +24 -0
- package/web/src/App.tsx +1931 -0
- package/web/src/Context.tsx +158 -0
- package/web/src/ContextDocument.tsx +150 -0
- package/web/src/Files.tsx +271 -0
- package/web/src/Lessons.tsx +164 -0
- package/web/src/Mcpl.tsx +310 -0
- package/web/src/Stream.tsx +119 -0
- package/web/src/TreeSidebar.tsx +283 -0
- package/web/src/Usage.tsx +182 -0
- package/web/src/main.tsx +7 -0
- package/web/src/styles.css +26 -0
- package/web/src/tree.ts +268 -0
- package/web/src/wire.ts +120 -0
- package/web/tsconfig.json +21 -0
- package/web/vite.config.ts +32 -0
package/src/headless.ts
ADDED
|
@@ -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
|
+
}
|