@shardworks/claude-code-apparatus 0.1.274 → 0.1.276

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.
@@ -15,10 +15,10 @@
15
15
  *
16
16
  * The single-purpose primitives (stdin parsing, retrying HTTP, DLQ writes,
17
17
  * the SQLite trio, lifecycle reporters, stderr redirect) live in
18
- * `runtime.ts`. This file owns the orchestrator (`runBabysitter`), the
19
- * MCP/SSE proxy, and the script entry point. The previously-exported
20
- * primitives are re-exported below to preserve the package's public
21
- * surface.
18
+ * `runtime.ts`. The MCP/SSE proxy lives in `mcp-proxy.ts`. This file owns
19
+ * the orchestrator (`runBabysitter`) and the script entry point. The
20
+ * previously-exported primitives are re-exported below to preserve the
21
+ * package's public surface.
22
22
  *
23
23
  * See: docs/architecture/detached-sessions.md
24
24
  */
@@ -26,378 +26,266 @@ import { spawn } from 'node:child_process';
26
26
  import fs from 'node:fs';
27
27
  import os from 'node:os';
28
28
  import path from 'node:path';
29
- import http from 'node:http';
30
29
  import { fileURLToPath } from 'node:url';
31
- import { Server } from '@modelcontextprotocol/sdk/server/index.js';
32
- import { ListToolsRequestSchema, CallToolRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
33
- import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
34
30
  import { toolNameToRoute } from '@shardworks/tools-apparatus';
35
31
  import { processNdjsonBuffer, parseStreamJsonMessage, } from "./index.js";
36
- import { callGuildHttpApi, openTranscriptDb, readConfigFromStdin, redirectStderrToFile, reportRunning, reportResult, STDERR_DIAGNOSTIC_TAIL_LIMIT, writeToDlq, writeTranscript, } from "./runtime.js";
32
+ import { isSourcePath, openTranscriptDb, readConfigFromStdin, redirectStderrToFile, reportRunning, reportResult, STDERR_DIAGNOSTIC_TAIL_LIMIT, writeTranscript, callGuildHttpApi, } from "./runtime.js";
33
+ import { createProxyMcpHttpServer, } from "./mcp-proxy.js";
37
34
  // ── Re-exports (preserves the pre-extraction public surface) ────────────
38
35
  export { callGuildHttpApi, findRetryableCode, initTranscriptDb, openTranscriptDb, readConfigFromStdin, redirectStderrToFile, reportResult, reportRunning, resolveTerminalStatus, STDERR_DIAGNOSTIC_TAIL_LIMIT, writeToDlq, writeTranscript, } from "./runtime.js";
39
- // ── MCP proxy server ────────────────────────────────────────────────────
36
+ export { createProxyMcpHttpServer } from "./mcp-proxy.js";
37
+ const HEARTBEAT_INTERVAL_MS = 30_000;
38
+ const HEARTBEAT_TIMEOUT_MS = 10_000;
40
39
  /**
41
- * Create an MCP/SSE HTTP server that proxies tool calls to the guild.
42
- *
43
- * For each tool in the config, registers an MCP tool whose handler
44
- * forwards the call to the guild's Tool HTTP API via HTTP POST.
45
- *
46
- * Uses the low-level MCP Server class to register tools with raw
47
- * JSON Schema (the serialized params from the config).
40
+ * Init phase: open SQLite, start the MCP proxy, prepare session files,
41
+ * spawn the claude child, attach the stderr forwarder. Populates the
42
+ * resource handles on `ctx`. Throws if any step fails the orchestrator's
43
+ * finally block cleans up whatever was allocated.
48
44
  */
49
- export async function createProxyMcpHttpServer(tools, guildToolUrl, sessionId) {
50
- const server = new Server({ name: 'nexus-guild-proxy', version: '0.0.0' }, { capabilities: { tools: {} } });
51
- // ── MCP proxy diagnostics ──────────────────────────────────────────
52
- // Track connection state and tool call metrics for debugging SSE drops.
53
- let sseConnectedAt = null;
54
- let sseClosedAt = null;
55
- let toolCallCount = 0;
56
- // Register tools/list handler — advertises all tools with their JSON Schema.
57
- server.setRequestHandler(ListToolsRequestSchema, async () => ({
58
- tools: tools.map((t) => ({
59
- name: t.name,
60
- description: t.description,
61
- inputSchema: {
62
- type: 'object',
63
- ...t.params,
45
+ async function runInitPhase(ctx) {
46
+ const { config } = ctx;
47
+ // 1. Open SQLite
48
+ ctx.db = ctx.injectedDb ?? await openTranscriptDb(config.dbPath);
49
+ // 2. Start MCP proxy server
50
+ ctx.mcpHandle = await createProxyMcpHttpServer(config.tools, config.guildToolUrl, config.sessionId);
51
+ // 3. Prepare session files
52
+ ctx.tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nsg-babysitter-'));
53
+ const args = [...config.claudeArgs];
54
+ // Write mcp-config pointing to the babysitter's MCP proxy server
55
+ const mcpConfig = {
56
+ mcpServers: {
57
+ 'nexus-guild': {
58
+ type: 'sse',
59
+ url: ctx.mcpHandle.url,
64
60
  },
65
- })),
66
- }));
67
- // Build a name → HTTP method lookup so the proxy can route each call to
68
- // the correct verb (read tools are GET-only on the tool server; POSTing
69
- // to them 404s).
70
- const toolMethods = new Map();
71
- for (const t of tools) {
72
- toolMethods.set(t.name, t.method);
61
+ },
62
+ };
63
+ const mcpConfigPath = path.join(ctx.tmpDir, 'mcp-config.json');
64
+ fs.writeFileSync(mcpConfigPath, JSON.stringify(mcpConfig));
65
+ args.push('--mcp-config', mcpConfigPath, '--strict-mcp-config');
66
+ // Add autonomous mode flags
67
+ args.push('--print', '-', '--output-format', 'stream-json', '--verbose');
68
+ // 4. Spawn claude
69
+ const claudeProc = ctx.spawnFn('claude', args, {
70
+ cwd: config.cwd,
71
+ stdio: ['pipe', 'pipe', 'pipe'],
72
+ env: { ...process.env, ...config.env },
73
+ });
74
+ ctx.claudeProc = claudeProc;
75
+ // Pipe prompt to claude's stdin, then close
76
+ if (config.prompt) {
77
+ claudeProc.stdin.write(config.prompt);
73
78
  }
74
- // Register tools/call handler — proxies each call to the guild HTTP API.
75
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
76
- const toolName = request.params.name;
77
- const params = request.params.arguments ?? {};
78
- const route = toolNameToRoute(toolName);
79
- const url = `${guildToolUrl}${route}`;
80
- const method = toolMethods.get(toolName) ?? 'POST';
81
- toolCallCount++;
82
- const callNum = toolCallCount;
83
- const callStart = Date.now();
84
- try {
85
- const result = await callGuildHttpApi(url, sessionId, params, undefined, method);
86
- const elapsed = Date.now() - callStart;
87
- process.stderr.write(`[babysitter] mcp-proxy: ${toolName} → ${method} ${route} (${elapsed}ms, call #${callNum})\n`);
88
- const text = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
89
- return {
90
- content: [{ type: 'text', text }],
91
- };
92
- }
93
- catch (err) {
94
- const elapsed = Date.now() - callStart;
95
- const message = err instanceof Error ? err.message : String(err);
96
- process.stderr.write(`[babysitter] mcp-proxy: ${toolName} FAILED (${elapsed}ms, call #${callNum}): ${message}\n`);
97
- return {
98
- content: [{ type: 'text', text: `Error: ${message}` }],
99
- isError: true,
100
- };
101
- }
79
+ claudeProc.stdin.end();
80
+ // Forward claude's stderr bytes to the babysitter's redirected
81
+ // stderr log. No detection happens here — rate-limit signals are
82
+ // detected only on structured NDJSON messages inside
83
+ // parseStreamJsonMessage.
84
+ //
85
+ // Also maintain a rolling tail buffer (last
86
+ // STDERR_DIAGNOSTIC_TAIL_LIMIT chars) — used as the `stderrExcerpt`
87
+ // of the passive `terminationDiagnostic` attached when the session
88
+ // ends with `'failed'`. O(1) per chunk: append then slice the tail.
89
+ claudeProc.stderr?.on('data', (chunk) => {
90
+ process.stderr.write(chunk);
91
+ const text = chunk.toString('utf8');
92
+ ctx.stderrTail = (ctx.stderrTail + text).slice(-STDERR_DIAGNOSTIC_TAIL_LIMIT);
102
93
  });
103
- // Wrap in HTTP server with SSE transport (same pattern as mcp-server.ts).
104
- // Promise-gate: POST /message waits for the SSE transport to be fully connected,
105
- // eliminating the race where a POST arrives before GET /sse completes.
106
- let resolveTransport;
107
- let rejectTransport;
108
- const transportReady = new Promise((resolve, reject) => {
109
- resolveTransport = resolve;
110
- rejectTransport = reject;
94
+ }
95
+ /**
96
+ * Steady-state phase: fire the running report, install the SIGTERM
97
+ * handler, start the heartbeat schedule, consume stdout into the
98
+ * accumulator (with first-wins terminationTag), and await claude's
99
+ * exit. Returns the exit code and signal the terminal phase will report.
100
+ */
101
+ async function runSteadyStatePhase(ctx) {
102
+ const { config, claudeProc, db } = ctx;
103
+ if (!claudeProc) {
104
+ throw new Error('runSteadyStatePhase: claudeProc not initialized');
105
+ }
106
+ // 5. Report "running" status (don't await — fire and forget with retry)
107
+ const cancelHandle = { kind: 'local-pgid', pgid: process.pid };
108
+ const runningPromise = reportRunning(config, cancelHandle, ctx.retryTimeoutMs).catch((err) => {
109
+ process.stderr.write(`[babysitter] Failed to report running: ${err}\n`);
111
110
  });
112
- // Direct reference for close() — null until connected.
113
- let transport = null;
114
- // SSE keepalive timer — sends periodic comments to prevent idle timeouts.
115
- // SSE spec says lines starting with ':' are comments, ignored by clients.
116
- const SSE_KEEPALIVE_INTERVAL_MS = 30_000;
117
- let keepaliveTimer = null;
118
- const httpServer = http.createServer(async (req, res) => {
119
- try {
120
- if (req.method === 'GET' && req.url === '/sse') {
121
- const t = new SSEServerTransport('/message', res);
122
- try {
123
- await server.connect(t);
124
- transport = t;
125
- sseConnectedAt = Date.now();
126
- process.stderr.write(`[babysitter] mcp-proxy: SSE connection established\n`);
127
- // Start keepalive pings on the SSE response stream
128
- keepaliveTimer = setInterval(() => {
129
- try {
130
- res.write(':keepalive\n\n');
131
- }
132
- catch {
133
- // Stream already closed — timer will be cleared by close handler
134
- }
135
- }, SSE_KEEPALIVE_INTERVAL_MS);
136
- // Log when SSE connection closes (the key diagnostic for the drop)
137
- res.on('close', () => {
138
- const duration = sseConnectedAt ? Date.now() - sseConnectedAt : 0;
139
- sseClosedAt = Date.now();
140
- process.stderr.write(`[babysitter] mcp-proxy: SSE connection closed after ${duration}ms ` +
141
- `(${toolCallCount} tool calls proxied)\n`);
142
- if (keepaliveTimer) {
143
- clearInterval(keepaliveTimer);
144
- keepaliveTimer = null;
145
- }
146
- });
147
- resolveTransport(t);
148
- }
149
- catch (err) {
150
- rejectTransport(err instanceof Error ? err : new Error(String(err)));
151
- throw err;
152
- }
153
- }
154
- else if (req.method === 'POST' && req.url?.startsWith('/message')) {
155
- if (!transport) {
156
- process.stderr.write(`[babysitter] mcp-proxy: POST /message arrived before SSE transport ready — waiting\n`);
157
- }
158
- let t;
159
- try {
160
- t = await transportReady;
161
- }
162
- catch {
163
- res.writeHead(503).end('SSE transport failed to initialize');
164
- return;
165
- }
166
- // Detect and log the "SSE already dead" case before it hits the SDK
167
- if (sseClosedAt) {
168
- const ago = Date.now() - sseClosedAt;
169
- process.stderr.write(`[babysitter] mcp-proxy: POST /message on dead SSE connection ` +
170
- `(closed ${ago}ms ago, after ${toolCallCount} calls)\n`);
171
- }
172
- await t.handlePostMessage(req, res);
111
+ ctx.runningPromise = runningPromise;
112
+ // 5b. Heartbeat timer — sends liveness signal every 30s after ready report.
113
+ function scheduleHeartbeat() {
114
+ ctx.heartbeatTimer = setTimeout(async () => {
115
+ const route = toolNameToRoute('session-heartbeat');
116
+ const hbUrl = `${config.guildToolUrl}${route}`;
117
+ try {
118
+ await callGuildHttpApi(hbUrl, config.sessionId, { sessionId: config.sessionId }, HEARTBEAT_TIMEOUT_MS);
173
119
  }
174
- else {
175
- res.writeHead(404).end('Not found');
120
+ catch {
121
+ // Dropped — next heartbeat in 30s. Staleness threshold (90s) tolerates this.
176
122
  }
123
+ scheduleHeartbeat();
124
+ }, HEARTBEAT_INTERVAL_MS);
125
+ }
126
+ // Start heartbeat after running report completes
127
+ runningPromise.then(() => scheduleHeartbeat());
128
+ // 5c. SIGTERM handler — sets cancelled flag and propagates to claude.
129
+ const onSigterm = () => {
130
+ ctx.cancelledBySignal = true;
131
+ // Stop heartbeat timer
132
+ if (ctx.heartbeatTimer) {
133
+ clearTimeout(ctx.heartbeatTimer);
134
+ ctx.heartbeatTimer = null;
177
135
  }
178
- catch {
179
- if (!res.headersSent) {
180
- res.writeHead(500).end('Internal Server Error');
136
+ // Propagate SIGTERM to the claude process
137
+ if (ctx.claudeProc && ctx.claudeProc.pid && !ctx.claudeProc.killed) {
138
+ try {
139
+ ctx.claudeProc.kill('SIGTERM');
181
140
  }
141
+ catch { /* already dead */ }
142
+ }
143
+ // The normal claude exit path will run, check cancelledBySignal,
144
+ // and report status 'cancelled' instead of computing from exit code.
145
+ };
146
+ ctx.onSigterm = onSigterm;
147
+ process.on('SIGTERM', onSigterm);
148
+ // 6. Consume stdout, stream transcript. The accumulator is mutated in
149
+ // place by parseStreamJsonMessage; the first-wins terminationTag
150
+ // invariant relies on a single accumulator identity for the whole
151
+ // stream.
152
+ let buffer = '';
153
+ claudeProc.stdout.on('data', (chunk) => {
154
+ buffer += chunk.toString();
155
+ const prevLength = ctx.acc.transcript.length;
156
+ buffer = processNdjsonBuffer(buffer, (msg) => {
157
+ parseStreamJsonMessage(msg, ctx.acc);
158
+ });
159
+ // Write transcript to SQLite if new messages were added
160
+ if (ctx.acc.transcript.length > prevLength && db) {
161
+ writeTranscript(db, config.sessionId, ctx.acc.transcript);
182
162
  }
183
163
  });
184
- await new Promise((resolve) => {
185
- httpServer.listen(0, '127.0.0.1', resolve);
164
+ // 7. Wait for claude to exit
165
+ return await new Promise((resolve, reject) => {
166
+ claudeProc.on('error', (err) => {
167
+ reject(new Error(`Failed to spawn claude: ${err.message}`));
168
+ });
169
+ claudeProc.on('close', (code, signal) => {
170
+ resolve({ exitCode: code ?? 1, exitSignal: signal ?? undefined });
171
+ });
186
172
  });
187
- const addr = httpServer.address();
188
- if (!addr || typeof addr === 'string') {
189
- throw new Error('Failed to get MCP proxy server address');
173
+ }
174
+ /**
175
+ * Terminal phase: stop the heartbeat, remove the SIGTERM handler, await
176
+ * the running report, build the final StreamJsonResult, and submit it
177
+ * via `reportResult` (which handles both the normal and cancelled-by-
178
+ * signal paths via the StatusOverride contract).
179
+ */
180
+ async function runTerminalPhase(ctx, exit) {
181
+ // Stop heartbeat before terminal report
182
+ if (ctx.heartbeatTimer) {
183
+ clearTimeout(ctx.heartbeatTimer);
184
+ ctx.heartbeatTimer = null;
190
185
  }
191
- const url = `http://127.0.0.1:${addr.port}/sse`;
192
- process.stderr.write(`[babysitter] MCP proxy server listening on port ${addr.port}\n`);
193
- return {
194
- url,
195
- async close() {
196
- if (keepaliveTimer) {
197
- clearInterval(keepaliveTimer);
198
- keepaliveTimer = null;
199
- }
200
- if (transport) {
201
- await transport.close();
202
- }
203
- await new Promise((resolve, reject) => {
204
- httpServer.close((err) => (err ? reject(err) : resolve()));
205
- });
206
- },
186
+ // Clean up SIGTERM handler (happy path; the finally block in
187
+ // runBabysitter is a defensive no-op when the handler was already
188
+ // removed here).
189
+ if (ctx.onSigterm) {
190
+ process.removeListener('SIGTERM', ctx.onSigterm);
191
+ ctx.onSigterm = null;
192
+ }
193
+ // Ensure running report completed before recording result
194
+ if (ctx.runningPromise) {
195
+ await ctx.runningPromise;
196
+ }
197
+ // Build result
198
+ const result = {
199
+ exitCode: exit.exitCode,
200
+ transcript: ctx.acc.transcript,
201
+ costUsd: ctx.acc.costUsd,
202
+ tokenUsage: ctx.acc.tokenUsage,
203
+ providerSessionId: ctx.acc.providerSessionId,
204
+ signal: exit.exitSignal,
205
+ ...(ctx.acc.terminationTag ? { terminationTag: ctx.acc.terminationTag } : {}),
207
206
  };
207
+ // 8. Report result
208
+ await reportResult(ctx.config, result, ctx.acc.transcript, ctx.retryTimeoutMs, ctx.cancelledBySignal ? 'cancelled' : undefined, ctx.stderrTail);
208
209
  }
209
- // ── Main babysitter function ────────────────────────────────────────────
210
210
  /**
211
211
  * Run the session babysitter.
212
212
  *
213
- * This is the main orchestration function. It:
214
- * 1. Opens SQLite for transcript streaming
215
- * 2. Starts the MCP proxy server
216
- * 3. Prepares session files (tmpDir, system prompt, mcp-config)
217
- * 4. Spawns claude
218
- * 5. Reports "running" status
219
- * 6. Streams transcript to SQLite
220
- * 7. Reports result on exit
221
- * 8. Cleans up
213
+ * Three-phase orchestrator threading a {@link BabysitterRuntimeContext}:
214
+ * `runInitPhase` allocates resources, `runSteadyStatePhase` reports
215
+ * lifecycle events and consumes claude's NDJSON stream, `runTerminalPhase`
216
+ * builds and submits the final result. The shared try/catch/finally
217
+ * funnels all error and cleanup handling.
218
+ *
219
+ * The orchestrator-error path goes through `reportResult` via the
220
+ * `StatusOverride` contract (no hand-rolled session-record + DLQ
221
+ * cascade) `reportResult` is the single sink for both the normal
222
+ * `'failed'` path and the orchestrator-caught error path.
222
223
  */
223
224
  export async function runBabysitter(config, deps) {
224
- const spawnFn = deps?.spawnFn ?? spawn;
225
- const retryTimeoutMs = deps?.retryTimeoutMs;
226
- let db = null;
227
- let mcpHandle = null;
228
- let tmpDir = null;
229
- let claudeProc = null;
225
+ const ctx = {
226
+ config,
227
+ spawnFn: deps?.spawnFn ?? spawn,
228
+ retryTimeoutMs: deps?.retryTimeoutMs,
229
+ injectedDb: deps?.db,
230
+ db: null,
231
+ mcpHandle: null,
232
+ tmpDir: null,
233
+ claudeProc: null,
234
+ heartbeatTimer: null,
235
+ onSigterm: null,
236
+ cancelledBySignal: false,
237
+ runningPromise: null,
238
+ acc: { transcript: [] },
239
+ stderrTail: '',
240
+ };
230
241
  try {
231
- // 1. Open SQLite
232
- db = deps?.db ?? await openTranscriptDb(config.dbPath);
233
- // 2. Start MCP proxy server
234
- mcpHandle = await createProxyMcpHttpServer(config.tools, config.guildToolUrl, config.sessionId);
235
- // 3. Prepare session files
236
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nsg-babysitter-'));
237
- const args = [...config.claudeArgs];
238
- // Write system prompt if present in args (already handled by claudeArgs)
239
- // Write mcp-config pointing to the babysitter's MCP proxy server
240
- const mcpConfig = {
241
- mcpServers: {
242
- 'nexus-guild': {
243
- type: 'sse',
244
- url: mcpHandle.url,
245
- },
246
- },
247
- };
248
- const mcpConfigPath = path.join(tmpDir, 'mcp-config.json');
249
- fs.writeFileSync(mcpConfigPath, JSON.stringify(mcpConfig));
250
- args.push('--mcp-config', mcpConfigPath, '--strict-mcp-config');
251
- // Add autonomous mode flags
252
- args.push('--print', '-', '--output-format', 'stream-json', '--verbose');
253
- // 4. Spawn claude
254
- claudeProc = spawnFn('claude', args, {
255
- cwd: config.cwd,
256
- stdio: ['pipe', 'pipe', 'pipe'],
257
- env: { ...process.env, ...config.env },
258
- });
259
- // Pipe prompt to claude's stdin, then close
260
- if (config.prompt) {
261
- claudeProc.stdin.write(config.prompt);
262
- }
263
- claudeProc.stdin.end();
264
- // Forward claude's stderr bytes to the babysitter's redirected
265
- // stderr log. No detection happens here — rate-limit signals are
266
- // detected only on structured NDJSON messages inside
267
- // parseStreamJsonMessage.
268
- //
269
- // Also maintain a rolling tail buffer (last
270
- // STDERR_DIAGNOSTIC_TAIL_LIMIT chars) — used as the `stderrExcerpt`
271
- // of the passive `terminationDiagnostic` attached when the session
272
- // ends with `'failed'`. O(1) per chunk: append then slice the tail.
273
- let stderrTail = '';
274
- claudeProc.stderr?.on('data', (chunk) => {
275
- process.stderr.write(chunk);
276
- const text = chunk.toString('utf8');
277
- stderrTail = (stderrTail + text).slice(-STDERR_DIAGNOSTIC_TAIL_LIMIT);
278
- });
279
- // 5. Report "running" status (don't await — fire and forget with retry)
280
- const cancelHandle = { kind: 'local-pgid', pgid: process.pid };
281
- const runningPromise = reportRunning(config, cancelHandle, retryTimeoutMs).catch((err) => {
282
- process.stderr.write(`[babysitter] Failed to report running: ${err}\n`);
283
- });
284
- // 5b. Heartbeat timer — sends liveness signal every 30s after ready report.
285
- const HEARTBEAT_INTERVAL_MS = 30_000;
286
- const HEARTBEAT_TIMEOUT_MS = 10_000;
287
- let heartbeatTimer = null;
288
- function scheduleHeartbeat() {
289
- heartbeatTimer = setTimeout(async () => {
290
- const route = toolNameToRoute('session-heartbeat');
291
- const hbUrl = `${config.guildToolUrl}${route}`;
292
- try {
293
- await callGuildHttpApi(hbUrl, config.sessionId, { sessionId: config.sessionId }, HEARTBEAT_TIMEOUT_MS);
294
- }
295
- catch {
296
- // Dropped — next heartbeat in 30s. Staleness threshold (90s) tolerates this.
297
- }
298
- scheduleHeartbeat();
299
- }, HEARTBEAT_INTERVAL_MS);
300
- }
301
- // Start heartbeat after running report completes
302
- runningPromise.then(() => scheduleHeartbeat());
303
- // 5c. SIGTERM handler — sets cancelled flag and propagates to claude.
304
- let cancelledBySignal = false;
305
- const onSigterm = () => {
306
- cancelledBySignal = true;
307
- // Stop heartbeat timer
308
- if (heartbeatTimer) {
309
- clearTimeout(heartbeatTimer);
310
- heartbeatTimer = null;
311
- }
312
- // Propagate SIGTERM to the claude process
313
- if (claudeProc && claudeProc.pid && !claudeProc.killed) {
314
- try {
315
- claudeProc.kill('SIGTERM');
316
- }
317
- catch { /* already dead */ }
318
- }
319
- // The normal claude exit path will run, check cancelledBySignal,
320
- // and report status 'cancelled' instead of computing from exit code.
321
- };
322
- process.on('SIGTERM', onSigterm);
323
- // 6. Consume stdout, stream transcript
324
- const acc = { transcript: [] };
325
- let buffer = '';
326
- claudeProc.stdout.on('data', (chunk) => {
327
- buffer += chunk.toString();
328
- const prevLength = acc.transcript.length;
329
- buffer = processNdjsonBuffer(buffer, (msg) => {
330
- parseStreamJsonMessage(msg, acc);
331
- });
332
- // Write transcript to SQLite if new messages were added
333
- if (acc.transcript.length > prevLength && db) {
334
- writeTranscript(db, config.sessionId, acc.transcript);
335
- }
336
- });
337
- // 7. Wait for claude to exit
338
- const { code: exitCode, signal: exitSignal } = await new Promise((resolve, reject) => {
339
- claudeProc.on('error', (err) => {
340
- reject(new Error(`Failed to spawn claude: ${err.message}`));
341
- });
342
- claudeProc.on('close', (code, signal) => {
343
- resolve({ code: code ?? 1, signal: signal ?? undefined });
344
- });
345
- });
346
- // Stop heartbeat before terminal report
347
- if (heartbeatTimer) {
348
- clearTimeout(heartbeatTimer);
349
- heartbeatTimer = null;
350
- }
351
- // Clean up SIGTERM handler
352
- process.removeListener('SIGTERM', onSigterm);
353
- // Ensure running report completed before recording result
354
- await runningPromise;
355
- // Build result
356
- const result = {
357
- exitCode,
358
- transcript: acc.transcript,
359
- costUsd: acc.costUsd,
360
- tokenUsage: acc.tokenUsage,
361
- providerSessionId: acc.providerSessionId,
362
- signal: exitSignal,
363
- ...(acc.terminationTag ? { terminationTag: acc.terminationTag } : {}),
364
- };
365
- // 8. Report result
366
- await reportResult(config, result, acc.transcript, retryTimeoutMs, cancelledBySignal ? 'cancelled' : undefined, stderrTail);
242
+ await runInitPhase(ctx);
243
+ const exit = await runSteadyStatePhase(ctx);
244
+ await runTerminalPhase(ctx, exit);
367
245
  }
368
246
  catch (err) {
369
- // Top-level error: attempt to report failure
247
+ // Funnel the orchestrator-caught error through reportResult via the
248
+ // StatusOverride contract. reportResult writes to the DLQ when the
249
+ // guild HTTP API is unreachable, so this single call covers both
250
+ // the happy and degraded paths the legacy hand-rolled cascade
251
+ // covered.
370
252
  const message = err instanceof Error ? err.message : String(err);
253
+ const partial = {
254
+ exitCode: 1,
255
+ transcript: ctx.acc.transcript,
256
+ costUsd: ctx.acc.costUsd,
257
+ tokenUsage: ctx.acc.tokenUsage,
258
+ providerSessionId: ctx.acc.providerSessionId,
259
+ };
371
260
  try {
372
- const route = toolNameToRoute('session-record');
373
- const url = `${config.guildToolUrl}${route}`;
374
- await callGuildHttpApi(url, config.sessionId, {
375
- sessionId: config.sessionId,
376
- status: 'failed',
377
- exitCode: 1,
378
- error: message,
379
- }, retryTimeoutMs);
261
+ await reportResult(ctx.config, partial, ctx.acc.transcript, ctx.retryTimeoutMs, { kind: 'orchestrator-error', error: message }, ctx.stderrTail);
380
262
  }
381
263
  catch {
382
- writeToDlq(config.cwd, `${config.sessionId}.json`, {
383
- sessionId: config.sessionId,
384
- status: 'failed',
385
- exitCode: 1,
386
- error: message,
387
- });
264
+ // reportResult itself failed catastrophically — already DLQ'd
265
+ // internally on HTTP failure; swallow so we still rethrow the
266
+ // original error.
388
267
  }
389
268
  throw err;
390
269
  }
391
270
  finally {
392
271
  // 9. Cleanup
393
- process.removeAllListeners('SIGTERM');
394
- await mcpHandle?.close().catch(() => { });
395
- db?.close();
396
- if (tmpDir) {
397
- fs.rmSync(tmpDir, { recursive: true, force: true });
272
+ // Targeted SIGTERM-listener removal: the happy path already removes
273
+ // ctx.onSigterm in runTerminalPhase, so this is a defensive no-op
274
+ // when steady-state completed; on a partial-init path where steady-
275
+ // state never installed the listener, ctx.onSigterm is null and the
276
+ // call is skipped. Avoids removeAllListeners which would sweep
277
+ // unrelated listeners installed by hosts of this module.
278
+ if (ctx.onSigterm) {
279
+ process.removeListener('SIGTERM', ctx.onSigterm);
280
+ ctx.onSigterm = null;
281
+ }
282
+ await ctx.mcpHandle?.close().catch(() => { });
283
+ ctx.db?.close();
284
+ if (ctx.tmpDir) {
285
+ fs.rmSync(ctx.tmpDir, { recursive: true, force: true });
398
286
  }
399
- if (config.systemPromptTmpDir) {
400
- fs.rmSync(config.systemPromptTmpDir, { recursive: true, force: true });
287
+ if (ctx.config.systemPromptTmpDir) {
288
+ fs.rmSync(ctx.config.systemPromptTmpDir, { recursive: true, force: true });
401
289
  }
402
290
  }
403
291
  }
@@ -428,11 +316,17 @@ async function main() {
428
316
  }
429
317
  }
430
318
  }
431
- // Check if this module is the entry point
432
- const isEntryPoint = process.argv[1] &&
433
- (process.argv[1] === fileURLToPath(import.meta.url) ||
434
- path.basename(process.argv[1]) === 'babysitter.js' ||
435
- path.basename(process.argv[1]) === 'babysitter.ts');
319
+ // Check if this module is the entry point. The argv-vs-import.meta.url
320
+ // equality is the primary check; the basename comparison is a fallback for
321
+ // path-resolution differences (symlinks, realpath). The `isSourcePath`
322
+ // predicate selects the basename to expect — `babysitter.ts` in source
323
+ // mode, `babysitter.js` in compiled output — keeping the extension test in
324
+ // step with the other two source-mode branches in this package.
325
+ const argv1 = process.argv[1];
326
+ const expectedBasename = isSourcePath(import.meta.url) ? 'babysitter.ts' : 'babysitter.js';
327
+ const isEntryPoint = argv1 !== undefined &&
328
+ (argv1 === fileURLToPath(import.meta.url) ||
329
+ path.basename(argv1) === expectedBasename);
436
330
  if (isEntryPoint) {
437
331
  main();
438
332
  }