@shardworks/claude-code-apparatus 0.1.274 → 0.1.275
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/README.md +14 -5
- package/dist/babysitter.d.ts +16 -30
- package/dist/babysitter.d.ts.map +1 -1
- package/dist/babysitter.js +236 -342
- package/dist/babysitter.js.map +1 -1
- package/dist/detached.d.ts +9 -8
- package/dist/detached.d.ts.map +1 -1
- package/dist/detached.js +22 -21
- package/dist/detached.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +35 -16
- package/dist/index.js.map +1 -1
- package/dist/mcp-proxy.d.ts +44 -0
- package/dist/mcp-proxy.d.ts.map +1 -0
- package/dist/mcp-proxy.js +203 -0
- package/dist/mcp-proxy.js.map +1 -0
- package/dist/runtime.d.ts +98 -40
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +87 -18
- package/dist/runtime.js.map +1 -1
- package/package.json +5 -5
package/dist/babysitter.js
CHANGED
|
@@ -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`.
|
|
19
|
-
*
|
|
20
|
-
* primitives are re-exported below to preserve the
|
|
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 {
|
|
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
|
-
|
|
36
|
+
export { createProxyMcpHttpServer } from "./mcp-proxy.js";
|
|
37
|
+
const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
38
|
+
const HEARTBEAT_TIMEOUT_MS = 10_000;
|
|
40
39
|
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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
|
-
|
|
50
|
-
const
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
type: '
|
|
63
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
185
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
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
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
}
|