@kelceyp/caw-server 1.0.290 → 1.1.0

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.
@@ -1,875 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * PTY Wrapper — spawns interactive claude via node-pty, streams transcript, emits result.
4
- *
5
- * Spawned by JobRunner as a normal child process. Internally uses node-pty to run
6
- * interactive `claude` (no -p flag), uses a Stop hook for completion detection via
7
- * a sentinel file, and tails Claude's transcript JSONL for live streaming.
8
- *
9
- * Args:
10
- * --session-id <uuid> Session ID (first run)
11
- * --resume <uuid> Resume session (continuation)
12
- * --model <model> Model override
13
- * --effort <level> Effort level override
14
- * --skip-permissions Enable --dangerously-skip-permissions
15
- *
16
- * Stdin: The prompt text (piped by JobRunner before spawn)
17
- * Stdout: JSONL: {type:"transcript",entry:{...}} per line, then {type:"result",...}
18
- * Exit: 0 on success (or SIGINT with valid sentinel), non-zero on failure
19
- */
20
-
21
- import * as pty from 'node-pty';
22
- import { mkdtempSync, readFileSync, existsSync, writeFileSync, rmSync, realpathSync } from 'fs';
23
- import { join } from 'path';
24
- import { tmpdir, homedir } from 'os';
25
- import { execSync } from 'child_process';
26
- import { readSentinelPayload, hasPendingWork } from './pty-deferred-teardown.mjs';
27
- import { emitResult as emitResultLine } from './pty-emit-result.mjs';
28
- import { TEARDOWN_REASONS } from './teardownReasons.mjs';
29
- import { ensureNonInteractiveSetup } from './pty-non-interactive-setup.mjs';
30
- import { CLAUDE_CONFIG_DIR_REFUSED_PREFIX } from './claude-config-dir-guard.mjs';
31
- import { createTranscriptTailer } from './transcript-tailer.mjs';
32
- import { createPromptRejectionDetector } from './prompt-rejection-detector.mjs';
33
-
34
- // --- Arg parsing ---
35
-
36
- const args = process.argv.slice(2);
37
- const getArg = (name) => {
38
- const idx = args.indexOf(name);
39
- return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
40
- };
41
- const hasFlag = (name) => args.includes(name);
42
-
43
- const sessionId = getArg('--session-id');
44
- const resumeId = getArg('--resume');
45
- const model = getArg('--model');
46
- const effortLevel = getArg('--effort');
47
- const skipPermissions = hasFlag('--skip-permissions');
48
-
49
- if (!sessionId && !resumeId) {
50
- process.stderr.write('Error: Either --session-id or --resume must be provided\n');
51
- process.exit(1);
52
- }
53
-
54
- const effectiveSessionId = sessionId || resumeId;
55
-
56
- // --- Logging (stderr only — stdout is reserved for streaming output) ---
57
-
58
- const log = (msg) => process.stderr.write(`[pty-wrapper] ${msg}\n`);
59
-
60
- // --- Read prompt from stdin ---
61
-
62
- const promptChunks = [];
63
- for await (const chunk of process.stdin) {
64
- promptChunks.push(chunk);
65
- }
66
- const prompt = Buffer.concat(promptChunks).toString('utf-8').trim();
67
-
68
- if (!prompt) {
69
- process.stderr.write('Error: No prompt provided on stdin\n');
70
- process.exit(1);
71
- }
72
-
73
- // --- Resolve claude binary ---
74
-
75
- let claudePath;
76
- try {
77
- claudePath = execSync('which claude', { encoding: 'utf-8' }).trim();
78
- if (!claudePath) throw new Error('empty result');
79
- } catch {
80
- process.stderr.write('Error: claude binary not found in PATH\n');
81
- process.exit(1);
82
- }
83
-
84
- // --- Pre-seed ~/.claude.json to avoid interactive dialogs ---
85
- // The function is defined in the sibling module
86
- // `./pty-non-interactive-setup.mjs` (statically imported above). Post-As1050
87
- // Commit B the direct-mode CAW server calls that function ONCE, before
88
- // spawning this wrapper, under a module-level mutex singleton in
89
- // `JobRunner.runDirectJob` (see `claude-session.js#prepareDirectHost`), and
90
- // signals suppression here via the `--skip-non-interactive-setup` CLI arg
91
- // emitted by `buildDirectCommand`. The Docker path does NOT emit that flag
92
- // (`claude-session.js#getDockerConfig`'s `commandParts` omits it by
93
- // design), so the in-container wrapper still runs the setup itself. This
94
- // wrapper's local call site preserves the same discriminating catch that
95
- // re-emits classified errors byte-for-byte with today's stderr + exit
96
- // contract and rethrows every other error. Two prefixes are classified:
97
- // `CLAUDE_JSON_UNREADABLE` (As1016) and `CLAUDE_CONFIG_DIR_REFUSED` (As1065,
98
- // the guard's refusal of the container's own `CLAUDE_CONFIG_DIR`). See As1016
99
- // for the fail-fast / atomic / idempotent invariants that module preserves.
100
- //
101
- // The invocation stays here (post log()/stdin-drain/claude-binary,
102
- // immediately before mkdtempSync) so a Docker-mode `process.exit(1)` on a
103
- // classified fail-fast cannot EPIPE the parent's stdin.write. The
104
- // direct-mode early-exit exposure the D16 ordering constraint was
105
- // originally motivated by is closed by two independent halves: As1050
106
- // Commit B moves the direct-mode call out of the wrapper entirely (the
107
- // `--skip-non-interactive-setup` gate below), and As1048 landed a
108
- // `stdin.on('error')` handler immediately before `stdin.write(promptToSend)`
109
- // inside `JobRunner.js#writePromptToChildStdin` (see `JobRunner.js:130`
110
- // registering the listener and `:141` performing the write) so any residual
111
- // Docker EPIPE surfaces as a caught error rather than an uncaught. The
112
- // Docker path retains the six exit sites, so this comment's ordering
113
- // constraint stands for Docker.
114
- //
115
- // Matched by shared/jobErrorPrefixes.js's `CLAUDE_JSON_UNREADABLE_PREFIX`
116
- // export AND by the extracted module's own inline copy at
117
- // `pty-non-interactive-setup.mjs`. Task 2 (Commit A) created the third
118
- // co-located literal (shared / wrapper / extracted); the extracted module
119
- // carries its own copy because `shared/` is not published from
120
- // `server/dist/`, and the wrapper keeps this local const so the
121
- // discriminating catch below can classify without importing the sibling's
122
- // export. Keep all three literals in sync per As1016 D8.
123
- //
124
- // The second classified prefix, `CLAUDE_CONFIG_DIR_REFUSED_PREFIX`, is NOT
125
- // carried as a literal here: it is imported above from
126
- // `./claude-config-dir-guard.mjs`, which is already in this wrapper's
127
- // closure via `pty-non-interactive-setup.mjs`. It has exactly two literals
128
- // (`shared/jobErrorPrefixes.js` and the guard; As1065 KD4).
129
- const CLAUDE_JSON_UNREADABLE_PREFIX = 'CLAUDE_JSON_UNREADABLE';
130
-
131
- // As1050 Commit B (C3) — the `--skip-non-interactive-setup` CLI arg is
132
- // emitted by `claude-session.js#buildDirectCommand` only for direct-mode
133
- // spawns; `getDockerConfig`'s `commandParts` omits it. `hasFlag` (not
134
- // `getArg`) is the correct helper: `getArg` is value-extracting and would
135
- // fail *open* on a valueless flag. Sibling idiom: `hasFlag('--skip-permissions')`.
136
- if (!hasFlag('--skip-non-interactive-setup')) {
137
- try {
138
- // As1050 AC 19 — forward `CLAUDE_CONFIG_DIR` from the wrapper's env so
139
- // the in-container Docker path (which still runs this local call —
140
- // `getDockerConfig`'s `commandParts` omits `--skip-non-interactive-setup`)
141
- // routes the seed to the same file the container-side `claude`
142
- // reads. Absent variable → falls back to `homedir()` inside the
143
- // sibling module, matching pre-AC-19 behaviour.
144
- ensureNonInteractiveSetup(process.cwd(), {
145
- home: homedir(),
146
- configDir: process.env.CLAUDE_CONFIG_DIR,
147
- log
148
- });
149
- } catch (err) {
150
- // Discriminating catch: only throws with one of the two classified
151
- // prefixes are re-emitted to stderr as a fail-fast (matching today's
152
- // process.stderr.write + process.exit(1) contract byte-for-byte; the
153
- // extracted module's `.message` omits the trailing `\n` that the wrapper
154
- // concatenates here):
155
- // - `CLAUDE_JSON_UNREADABLE` — the As1016 file-level and `HOME_UNSET`
156
- // fail-fast branches;
157
- // - `CLAUDE_CONFIG_DIR_REFUSED` — the As1065 guard refusing the
158
- // container's own `CLAUDE_CONFIG_DIR` (`ensureNonInteractiveSetup`
159
- // delegates its config-dir check to `assertClaudeConfigDirForwardable`).
160
- // All other errors are RETHROWN — the wrapper does NOT swallow them, so
161
- // today's uncaught-exception behaviour on unclassified failures is
162
- // preserved.
163
- if (err && typeof err.message === 'string' && (err.message.startsWith(CLAUDE_JSON_UNREADABLE_PREFIX) || err.message.startsWith(CLAUDE_CONFIG_DIR_REFUSED_PREFIX))) {
164
- process.stderr.write(err.message + '\n');
165
- process.exit(1);
166
- }
167
- throw err;
168
- }
169
- }
170
-
171
- // --- Temp directory for settings, hook script, and sentinel file ---
172
-
173
- const tempDir = mkdtempSync(join(tmpdir(), 'caw-pty-'));
174
- const sentinelPath = join(tempDir, 'result.json');
175
- const hookScriptPath = join(tempDir, 'stop-hook.sh');
176
- const settingsPath = join(tempDir, 'settings.json');
177
- const claudePidPath = join(tempDir, 'claude.pid');
178
- const wrapperPidPath = join(tempDir, 'wrapper.pid');
179
-
180
- // --- Write Stop hook script ---
181
- // The hook: (1) cats Stop hook JSON (on stdin) to sentinel file, then
182
- // (2) sends SIGUSR1 to the wrapper PID so it can check whether teardown is safe.
183
-
184
- const hookScript = [
185
- '#!/bin/bash',
186
- `cat > '${sentinelPath}'`,
187
- `kill -USR1 $(cat '${wrapperPidPath}') 2>/dev/null`,
188
- ''
189
- ].join('\n');
190
- writeFileSync(hookScriptPath, hookScript, { mode: 0o755 });
191
-
192
- // --- Write settings file with Stop hook config ---
193
-
194
- const hookEntry = {
195
- matcher: '',
196
- hooks: [{ type: 'command', command: hookScriptPath }]
197
- };
198
-
199
- const settings = {
200
- hooks: {
201
- Stop: [hookEntry],
202
- StopFailure: [hookEntry]
203
- }
204
- };
205
- writeFileSync(settingsPath, JSON.stringify(settings));
206
-
207
- // --- Deferred teardown state ---
208
-
209
- // 90 minutes — covers full ScheduleWakeup range (up to 3600s) with 30m headroom.
210
- // The env override (`CAW_PTY_WATCHDOG_TIMEOUT_MS`) exists only so integration
211
- // tests can exercise the watchdog force-kill path without waiting the full 90m.
212
- const WATCHDOG_TIMEOUT_MS = Number.parseInt(process.env.CAW_PTY_WATCHDOG_TIMEOUT_MS, 10) || (90 * 60 * 1000);
213
-
214
- // D11 test-only env overrides (default off). Precedent: CAW_PTY_WATCHDOG_TIMEOUT_MS above.
215
- // CAW_PTY_DISABLE_TRANSCRIPT_TAIL: skip all TranscriptTailer drains (the 1s
216
- // polling loop, the SIGUSR1 handler, and the final onExit tick), so
217
- // wrapper stdout carries no `transcript` lines and PromptRejectionDetector
218
- // never sees an entry via `consider()`. Used to prove downstream (Task 5)
219
- // fallback consumes `result` instead of the missing transcript stream.
220
- // CAW_PTY_SUPPRESS_STOP_FALLBACK: blank the emitted `result` field on the
221
- // success branch of pty-emit-result (only when hookData is non-null and
222
- // the success branch runs). The line declares the override as
223
- // `stopFallback: 'suppressed'` (As1072), so it is not mistaken for a real
224
- // empty-message turn; the error branch is never affected. Used to prove the
225
- // AC-5 gate downstream: `result` is still `''`, so with
226
- // CAW_PTY_DISABLE_TRANSCRIPT_TAIL the turn still reaches the loud empty-turn
227
- // failure.
228
- const DISABLE_TRANSCRIPT_TAIL = process.env.CAW_PTY_DISABLE_TRANSCRIPT_TAIL === '1';
229
- const SUPPRESS_STOP_FALLBACK = process.env.CAW_PTY_SUPPRESS_STOP_FALLBACK === '1';
230
-
231
- let watchdogTimer = null;
232
- let teardownInitiated = false;
233
- // Captured at the top of teardown() on both paths (clean-Stop passes 'stop',
234
- // watchdog callback passes 'watchdog'). Read by emitResult() on the success
235
- // branch and written to metadata.teardownReason so the downstream
236
- // stream-handler and classifier (Task 7) can distinguish the two paths.
237
- let teardownReason = null;
238
-
239
- // Duration capture: wall clock from `pty.spawn` to top of `teardown()`.
240
- // `endedAt` is set by whichever teardown path fires first (normal SIGUSR1
241
- // completion or watchdog force-kill — both routed through `teardown()`,
242
- // per design Decision D1). If the child exits before `teardown()` runs,
243
- // `endedAt` remains null and no `durationMs` is emitted (per Decision D3 —
244
- // service appends the pre-existing `'0'` duration sentinel).
245
- let startedAt = null;
246
- let endedAt = null;
247
-
248
- // Module-scope armed timestamp for the watchdog observability channel (As1012
249
- // Task 7). Written by `startOrResetWatchdog` on every call (initial arm and
250
- // each subsequent reset) and read by both `teardown()` and the setTimeout
251
- // expiry closure so `elapsedSinceArmMs` can be computed after the timer has
252
- // released. On a `reset` call the elapsed value is captured BEFORE `armedAt`
253
- // is overwritten so the emitted event carries the duration of the *previous*
254
- // armed interval (see item 4's ordering invariant).
255
- let armedAt = null;
256
-
257
- // Module-scope snapshot of the sentinel counts observed at the most recent
258
- // deferral, for the `WATCHDOG` observability channel (As1012 Task 13). Written
259
- // in the SIGUSR1 handler's defer branch BEFORE `startOrResetWatchdog()` so the
260
- // counts are in place if the watchdog subsequently fires; read from
261
- // `teardown()` when `reason === TEARDOWN_REASONS.WATCHDOG`. Lifted for the
262
- // same reason as `armedAt`: the SIGUSR1 handler's `bgCount` / `cronCount`
263
- // locals are block-scoped and out of scope inside the `setTimeout` closure
264
- // that eventually calls `teardown(TEARDOWN_REASONS.WATCHDOG)`. Overwritten on
265
- // every deferral, so it always holds the LAST deferral's counts — that is the
266
- // documented last-seen-snapshot semantic (design.md §Data on the wire).
267
- //
268
- // As1062 Task 9 extends the binding with a third property, `types` — a
269
- // `Map<label,count>` histogram derived from the SAME `background_tasks` array
270
- // the count was computed from. Normalisation collapses whitespace in friendly
271
- // labels (`"MCP task"` → `MCP_task`) so the emitted `pendingBgTypes` token
272
- // stays a single greppable word per label. Missing/empty types become
273
- // `unknown`. Layered onto the same object rather than a parallel binding so
274
- // the LAST-deferral-wins semantic covers types too.
275
- let lastDeferredCounts = null;
276
-
277
- // As1062 Task 9 — renders `lastDeferredCounts.types` (a `Map<label,count>`)
278
- // into the single-token form emitted as `pendingBgTypes` on the WATCHDOG
279
- // teardown line: sorted alphabetically by label for deterministic output,
280
- // comma-joined `label:count` pairs, whitespace-free by construction. Returns
281
- // the literal `'none'` on an empty histogram (crons-only deferral) so the
282
- // rendered line reads `pendingBgTypes=none` — distinct from the splitter's
283
- // `-` sentinel that flags non-watchdog reasons. Never returns `''` — the
284
- // wrapper-side empty-string guard the design calls for.
285
- const renderPendingBgTypes = (types) => {
286
- if (!types || types.size === 0) return 'none';
287
- const rendered = [...types.entries()]
288
- .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
289
- .map(([label, count]) => `${label}:${count}`)
290
- .join(',');
291
- return rendered || 'none';
292
- };
293
-
294
- // Forward declarations (assigned after ptyProcess is created)
295
- let ptyProcess = null;
296
-
297
- // --- Tagged event channel (As1012 Task 7) ---
298
- // Structured lifecycle events on stderr: `[caw:pty:event] <json>\n`. Consumed
299
- // downstream by JobRunner's `createStderrSplitter` (Task 6), which routes
300
- // matching lines through the operational tier (`caw:ops:pty:info`). Prose
301
- // stderr (every `log()` line above) is untouched — the splitter forwards
302
- // non-tagged bytes byte-identically to today's `stderrChunks` collector.
303
- // EPIPE is swallowed so a closed downstream pipe cannot crash the wrapper
304
- // (stderr is diagnostic; the tagged channel is best-effort).
305
- const EVENT_TAG = '[caw:pty:event] ';
306
- const emitEvent = (type, fields = {}) => {
307
- const payload = {
308
- type,
309
- wrapperPid: process.pid,
310
- claudePid: ptyProcess?.pid,
311
- agentSessionId: effectiveSessionId,
312
- ...fields
313
- };
314
- try {
315
- process.stderr.write(`${EVENT_TAG}${JSON.stringify(payload)}\n`);
316
- } catch (err) {
317
- if (err && err.code !== 'EPIPE') throw err;
318
- }
319
- };
320
-
321
- const startOrResetWatchdog = () => {
322
- const wasArmed = watchdogTimer !== null;
323
- if (wasArmed) clearTimeout(watchdogTimer);
324
- log(`Watchdog ${wasArmed ? 'reset' : 'armed'}: ${WATCHDOG_TIMEOUT_MS / 60000}m timeout`);
325
- // Ordering invariant (item 4): capture elapsedSinceArmMs BEFORE `armedAt`
326
- // is overwritten so the `reset` event carries the duration of the previous
327
- // armed interval. `armed` (first call) carries no elapsed value.
328
- if (wasArmed) {
329
- emitEvent('reset', { elapsedSinceArmMs: Date.now() - armedAt });
330
- } else {
331
- emitEvent('armed');
332
- }
333
- armedAt = Date.now();
334
- watchdogTimer = setTimeout(() => {
335
- log(`Watchdog expired: ${WATCHDOG_TIMEOUT_MS / 60000}m elapsed since last deferred Stop, force-killing claude`);
336
- emitEvent('expired', { elapsedSinceArmMs: Date.now() - armedAt });
337
- teardown(TEARDOWN_REASONS.WATCHDOG);
338
- }, WATCHDOG_TIMEOUT_MS);
339
- };
340
-
341
- const teardown = (reason = TEARDOWN_REASONS.STOP) => {
342
- if (teardownInitiated) return;
343
- teardownInitiated = true;
344
- endedAt = Date.now();
345
- teardownReason = reason;
346
- log(`Teardown (reason=${reason}): killing claude`);
347
- log(`Duration: ${startedAt !== null ? (endedAt - startedAt) : 'unknown'} ms (startedAt=${startedAt}, endedAt=${endedAt})`);
348
- // As1012 Task 13 — `pendingBgCount` / `pendingCronCount` ride only on the
349
- // `reason=watchdog` teardown, using the same conditional-spread idiom as
350
- // `elapsedSinceArmMs`. No null-guard is needed: `startOrResetWatchdog()`
351
- // has exactly one call site (the defer branch below), and that branch
352
- // always writes `lastDeferredCounts` BEFORE arming the timer — so a
353
- // `reason=watchdog` teardown is only reachable after at least one
354
- // deferral, and at least one of the two counts is guaranteed ≥ 1.
355
- emitEvent('teardown', {
356
- reason,
357
- ...(armedAt !== null ? { elapsedSinceArmMs: Date.now() - armedAt } : {}),
358
- ...(reason === TEARDOWN_REASONS.WATCHDOG && lastDeferredCounts !== null
359
- ? {
360
- pendingBgCount: lastDeferredCounts.bgCount,
361
- pendingCronCount: lastDeferredCounts.cronCount,
362
- pendingBgTypes: renderPendingBgTypes(lastDeferredCounts.types)
363
- }
364
- : {})
365
- });
366
- if (watchdogTimer) { clearTimeout(watchdogTimer); watchdogTimer = null; }
367
- stopPolling();
368
- if (ptyProcess) ptyProcess.kill('SIGINT');
369
- };
370
-
371
- // --- Register SIGUSR1 handler and write wrapper PID ---
372
- // Must happen BEFORE spawning claude so the handler is ready when the first Stop fires.
373
-
374
- process.on('SIGUSR1', () => {
375
- log('Received SIGUSR1 (stop signal)');
376
- drainTailerAndForward();
377
- const payload = readSentinelPayload(sentinelPath);
378
- if (!payload) {
379
- log(`Warning: failed to read sentinel, proceeding with teardown`);
380
- teardown(TEARDOWN_REASONS.SENTINEL_UNREADABLE);
381
- return;
382
- }
383
- if (payload.error) {
384
- log(`StopFailure received: error=${payload.error}`);
385
- teardown(TEARDOWN_REASONS.STOP_FAILURE);
386
- return;
387
- }
388
- const bgCount = Array.isArray(payload.background_tasks) ? payload.background_tasks.length : 0;
389
- const cronCount = Array.isArray(payload.session_crons) ? payload.session_crons.length : 0;
390
- log(`Sentinel: background_tasks=${bgCount}, session_crons=${cronCount}`);
391
- if (hasPendingWork(payload)) {
392
- log('Deferring teardown: pending background work');
393
- // As1012 Task 13 — capture BEFORE arming so the counts are already in
394
- // place if the timer subsequently fires. Members named to match the
395
- // locals above; the lift is a zero-translation copy.
396
- //
397
- // As1062 Task 9 — extend the same capture with a normalised `type`
398
- // histogram of `background_tasks` entries so the WATCHDOG line's
399
- // `pendingBgTypes=` token can carry a per-label breakdown. Normalisation
400
- // (`String(entry?.type ?? '').trim().replace(/\s+/g, '_') || 'unknown'`)
401
- // collapses whitespace in friendly labels (`"MCP task"` → `MCP_task`,
402
- // `"auto-mode scan"` → `auto-mode_scan`) so the emitted token stays
403
- // greppable as one word per label, and rescues missing/empty types to
404
- // `unknown` rather than dropping them silently.
405
- const types = new Map();
406
- if (Array.isArray(payload.background_tasks)) {
407
- for (const entry of payload.background_tasks) {
408
- const label = String(entry?.type ?? '').trim().replace(/\s+/g, '_') || 'unknown';
409
- types.set(label, (types.get(label) ?? 0) + 1);
410
- }
411
- }
412
- lastDeferredCounts = { bgCount, cronCount, types };
413
- startOrResetWatchdog();
414
- return;
415
- }
416
- teardown(TEARDOWN_REASONS.STOP);
417
- });
418
-
419
- writeFileSync(wrapperPidPath, String(process.pid));
420
-
421
- // --- Build claude args ---
422
-
423
- const claudeArgs = [];
424
- if (resumeId) {
425
- claudeArgs.push('--resume', resumeId);
426
- } else {
427
- claudeArgs.push('--session-id', sessionId);
428
- }
429
- if (skipPermissions) {
430
- claudeArgs.push('--dangerously-skip-permissions');
431
- }
432
- if (model) {
433
- claudeArgs.push('--model', model);
434
- }
435
- if (effortLevel) {
436
- claudeArgs.push('--effort', effortLevel);
437
- }
438
- claudeArgs.push('--settings', settingsPath);
439
- // The prompt is content. `--` bounds option parsing so a leading-dash
440
- // prompt ("- could you…") is not parsed as a CLI flag. This bound does
441
- // *not* cover subcommand dispatch, and the exposure is against the
442
- // trimmed prompt (pty-wrapper.mjs:63 trims before this point) — see
443
- // technical-debt-register.md for the recorded subcommand-shadowing debt.
444
- claudeArgs.push('--', prompt);
445
-
446
- log(`Session: ${effectiveSessionId}`);
447
- log(`Prompt length: ${prompt.length} chars`);
448
-
449
- // --- Spawn claude via PTY ---
450
-
451
- startedAt = Date.now();
452
- ptyProcess = pty.spawn(claudePath, claudeArgs, {
453
- name: 'xterm-256color',
454
- cols: 120,
455
- rows: 40,
456
- cwd: process.cwd(),
457
- env: { ...process.env, CLAUBBIT: '1', CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS: '0' }
458
- });
459
-
460
- log(`PID: ${ptyProcess.pid}`);
461
- writeFileSync(claudePidPath, String(ptyProcess.pid));
462
-
463
- // --- Auto-confirm --dangerously-skip-permissions dialog ---
464
- // Claude shows a "Yes/No" dialog when --dangerously-skip-permissions is used.
465
- // Detect it in PTY output and send down-arrow (select "Yes") + Enter.
466
-
467
- let ttyBuffer = '';
468
- let permissionConfirmed = false;
469
-
470
- // onData responsibilities after As1080 Increment A:
471
- //
472
- // (1) Auto-confirm the `--dangerously-skip-permissions` "Yes/No" dialog once
473
- // the rendered tty contains both "Yes" and "No". The wrapper sends a
474
- // down-arrow to select "Yes" followed by Enter, then latches
475
- // `permissionConfirmed` so subsequent chunks do not re-trigger.
476
- // (2) Keep `ttyBuffer` bounded — trimmed to the last 5000 chars once it
477
- // exceeds 10000. `ttyBuffer` itself is retained because the onExit
478
- // diagnostic-label branch reads a bounded tail of it for a single log
479
- // line at process exit.
480
- //
481
- // The onData handler no longer scans `ttyBuffer` for the literal
482
- // "Prompt is too long" trigger phrase. Rendered tty flattens authorship —
483
- // echoed job prompt, tool results, rendered CAW documents, agent prose, and
484
- // genuine API rejections all land in the same stream — so any text-based
485
- // predicate here fires on things the agent said and kills healthy sessions.
486
- // Prompt-rejection classification is now driven exclusively by the Stop-hook
487
- // sentinel's structured error payload consumed in `emitResult` and by
488
- // `claude-session.js` `classifyExit` Step 2 (`errorInfo.error === 'prompt_too_long'`).
489
-
490
- ptyProcess.onData((data) => {
491
- ttyBuffer += data;
492
- if (!permissionConfirmed && ttyBuffer.includes('Yes') && ttyBuffer.includes('No')) {
493
- log('Auto-confirming bypass permissions dialog');
494
- ptyProcess.write('\x1b[B'); // down arrow to select "Yes"
495
- setTimeout(() => ptyProcess.write('\r'), 200); // Enter
496
- permissionConfirmed = true;
497
- }
498
- // Keep buffer bounded
499
- if (ttyBuffer.length > 10000) ttyBuffer = ttyBuffer.slice(-5000);
500
- });
501
-
502
- // --- Transcript tailing (TranscriptTailer + PromptRejectionDetector wiring) ---
503
- // Task 6 (As1080 Increment B) replaced the inline tail-state block that lived
504
- // here with a two-module split. `transcript-tailer.mjs` owns cursor / (dev,ino)
505
- // / preBaselineUUIDs / processedUUIDs bookkeeping and torn-line + file-
506
- // replacement recovery; `prompt-rejection-detector.mjs` owns the conjunctive-
507
- // predicate evaluation on `isApiErrorMessage:true` transcript entries and the
508
- // one-shot `{"type":"error","error":"prompt_too_long", …}` stdout emission
509
- // that JobRunner.js:363 consumes. The wrapper is now a thin coordinator:
510
- // • establishes the tailer boundary pre-spawn (Case A fresh session →
511
- // cursor:0 / empty preBaselineUUIDs; Case B `--resume` with a readable
512
- // transcript file at the cwd-hash candidate → (dev,ino) + cursor:size
513
- // + regex-uuid pre-baseline walk with no size limit);
514
- // • forwards each `tick()` entry into `detector.consider(entry)` from the
515
- // poll callback (in file order, exactly once);
516
- // • notifies the detector at the tailer's `unusable` transition with
517
- // `cause ∈ {'readError','reopenFailed'}` — the venue matches the
518
- // transition (readError from initialiseBoundary Case B, reopenFailed
519
- // from a tick()-time reopenAndRewalkBoundary);
520
- // • at onExit, when `transcriptPath === null`, calls
521
- // `detector.noteTranscriptUnresolved()` exactly once before
522
- // `emitResult(hookData)`.
523
- //
524
- // The detector's live-accessor coupling reads `resultEmitted` and
525
- // `failureEmitted` via closure — NOT snapshots at construction — so a
526
- // concurrent Stop-hook `emitResult()` that flips the flags mid-`consider()`
527
- // is caught by the detector's re-check-before-write guard (Task 5 item 10;
528
- // unit-tested in `prompt-rejection-detector.test.js` case (e)).
529
- //
530
- // Path resolution stays a polling-time concern: no pre-spawn retry loop.
531
- // For fresh sessions where the cwd-hash guess is wrong, the D14 late-
532
- // adoption path at onExit constructs a fresh tailer against
533
- // `hookData.transcript_path` (cursor rule keyed off `resumeId`, mirroring
534
- // today's retired resume-baseline snapshot guard).
535
-
536
- let transcriptPath = null;
537
- let transcriptPathResolutionAttempts = 0;
538
- let transcriptPathMemoised = false;
539
-
540
- // Emission-state flags — declared here so the detector's live accessors
541
- // can close over them. `promptRejectionEmitted` distinguishes a detector-
542
- // initiated failure (branchTaken='prompt-rejection') from a Stop-hook
543
- // StopFailure (branchTaken='emitted-failure'); both paths set
544
- // `failureEmitted=true` so the exit branch `if (failureEmitted) { process.exit(1); }`
545
- // stays byte-identical to its pre-task state.
546
- let resultEmitted = false;
547
- let failureEmitted = false;
548
- let promptRejectionEmitted = false;
549
-
550
- // Compute the pre-spawn candidate transcript path via the cwd-hash
551
- // convention. Fresh sessions don't have the file yet at pre-spawn (claude
552
- // creates it after `pty.spawn`), so we do NOT require existsSync — the
553
- // tailer's `tick()` safely returns [] on ENOENT.
554
- const computeCandidateTranscriptPath = () => {
555
- const claudeProjectsDir = join(homedir(), '.claude', 'projects');
556
- const cwd = process.cwd();
557
- const candidates = [cwd];
558
- try {
559
- const rp = realpathSync(cwd);
560
- if (rp !== cwd) candidates.push(rp);
561
- } catch { /* ignore */ }
562
- // Prefer a candidate that already resolves on disk (relevant for
563
- // --resume where the transcript file exists at spawn time). Otherwise
564
- // fall back to the first (cwd-hash) candidate; D14 late-adoption at
565
- // onExit corrects if this guess was wrong.
566
- for (const dir of candidates) {
567
- const hash = dir.replace(/[/.]/g, '-');
568
- const p = join(claudeProjectsDir, hash, `${effectiveSessionId}.jsonl`);
569
- if (existsSync(p)) return p;
570
- }
571
- const hash = candidates[0].replace(/[/.]/g, '-');
572
- return join(claudeProjectsDir, hash, `${effectiveSessionId}.jsonl`);
573
- };
574
-
575
- const candidateTranscriptPath = computeCandidateTranscriptPath();
576
-
577
- // Tailer + detector construction. `boundaryUnusableNotified` mirrors the
578
- // tailer's one-shot invariant on the wrapper side so
579
- // `detector.noteBoundaryUnusable(cause)` fires exactly once per transition
580
- // with the cause matched to the transition venue. The detector's
581
- // defensive `cause=unknown` fallback (Task 5 wire-format contract) is
582
- // unreachable on this wired-up path.
583
- const tailer = createTranscriptTailer({ log });
584
- let boundaryUnusableNotified = false;
585
- const notifyBoundaryUnusable = (cause) => {
586
- if (boundaryUnusableNotified) return;
587
- boundaryUnusableNotified = true;
588
- detector.noteBoundaryUnusable(cause);
589
- };
590
-
591
- const detector = createPromptRejectionDetector({
592
- log,
593
- getEmissionState: () =>
594
- resultEmitted ? 'result'
595
- : failureEmitted ? 'failure'
596
- : 'clear',
597
- setEmissionState: (flag) => {
598
- if (flag === 'failure') {
599
- failureEmitted = true;
600
- promptRejectionEmitted = true;
601
- } else if (flag === 'result') {
602
- resultEmitted = true;
603
- }
604
- },
605
- tailer,
606
- });
607
-
608
- // Pre-spawn boundary establishment.
609
- if (resumeId) {
610
- // Case B (--resume): with a readable transcript file at the cwd-hash
611
- // candidate, snapshot (dev, ino) + cursor=size + pre-baseline walk.
612
- // Unresolvable path → defer to D14 (tailer stays uninitialised;
613
- // `tick()` short-circuits on `!boundaryEstablished`).
614
- if (existsSync(candidateTranscriptPath)) {
615
- tailer.initialiseBoundary(candidateTranscriptPath, { resume: true });
616
- if (tailer.unusable) {
617
- // Case B's only fail-closed cause is a read error.
618
- notifyBoundaryUnusable('readError');
619
- } else {
620
- transcriptPath = candidateTranscriptPath;
621
- transcriptPathMemoised = true;
622
- log(`Resume: initialised boundary against ${candidateTranscriptPath}`);
623
- }
624
- } else {
625
- log(`Resume: transcript not yet resolvable via cwd-hash (candidate=${candidateTranscriptPath}); deferring to D14 hook-path adoption`);
626
- }
627
- } else {
628
- // Case A (fresh session, no --resume): cursor=0, empty preBaselineUUIDs.
629
- // initialiseBoundary with resume:false does no I/O and cannot flip
630
- // `unusable`. Path may not exist yet; `tick()` returns [] on ENOENT.
631
- tailer.initialiseBoundary(candidateTranscriptPath, { resume: false });
632
- }
633
-
634
- // Poll-callback helper. Called from the 1s interval AND from the SIGUSR1
635
- // handler (Stop-hook fired) AND from onExit (final drain). Every entry
636
- // the tailer forwards from `tick()` reaches `detector.consider(entry)`
637
- // exactly once, in file order, and is also written to stdout as the
638
- // `{"type":"transcript",entry:…}` line JobRunner's stream handler consumes.
639
- // Preserves the load-bearing `Transcript found (attempt N):` log line
640
- // (asserted by the `pty-wrapper D14 memoised-branch preserves today`
641
- // integration test) the first time the tailer's file resolves on disk.
642
- const drainTailerAndForward = () => {
643
- if (DISABLE_TRANSCRIPT_TAIL) return;
644
- if (!tailer.boundaryEstablished) {
645
- // --resume + unresolvable pre-spawn path — waiting for D14 adoption.
646
- return;
647
- }
648
- if (!transcriptPathMemoised) {
649
- transcriptPathResolutionAttempts++;
650
- if (tailer.path && existsSync(tailer.path)) {
651
- transcriptPath = tailer.path;
652
- transcriptPathMemoised = true;
653
- log(`Transcript found (attempt ${transcriptPathResolutionAttempts}): ${transcriptPath}`);
654
- } else if (transcriptPathResolutionAttempts === 1 || transcriptPathResolutionAttempts % 10 === 0) {
655
- log(`Transcript not found (attempt ${transcriptPathResolutionAttempts}), cwd=${process.cwd()}, session=${effectiveSessionId}`);
656
- }
657
- }
658
- const preUnusable = tailer.unusable;
659
- let entries = [];
660
- try {
661
- entries = tailer.tick();
662
- } catch (err) {
663
- log(`tailer.tick() threw: ${err && err.message ? err.message : String(err)}`);
664
- return;
665
- }
666
- if (tailer.unusable && !preUnusable) {
667
- // Only reopenAndRewalkBoundary can flip `unusable` inside tick().
668
- notifyBoundaryUnusable('reopenFailed');
669
- }
670
- for (const entry of entries) {
671
- try {
672
- process.stdout.write(JSON.stringify({ type: 'transcript', entry }) + '\n');
673
- } catch (err) {
674
- if (err && err.code !== 'EPIPE') throw err;
675
- }
676
- detector.consider(entry);
677
- if (promptRejectionEmitted) {
678
- // Detector wrote the `{"type":"error","error":"prompt_too_long",...}`
679
- // line synchronously inside consider(). SIGTERM the pty so its
680
- // exit unwinds the wrapper's onExit path (which re-drains and
681
- // then no-ops emitResult under the emission guard).
682
- log(`prompt-rejection: matched — SIGTERM to pty (pid=${ptyProcess?.pid})`);
683
- if (ptyProcess) {
684
- try { ptyProcess.kill('SIGTERM'); } catch { /* pty already gone */ }
685
- }
686
- break;
687
- }
688
- }
689
- };
690
-
691
- // --- Sentinel / result emission ---
692
- // Called from onExit after polling stops. The Stop-hook sentinel is read once
693
- // at the onExit seam (hoisted out of this delegator) so the parsed payload
694
- // drives both the D14 hook-path adoption guard AND this emission. Null-
695
- // hookData path: the delegator no-ops, `resultEmitted` / `failureEmitted`
696
- // stay false unless the detector fired first, and `branchTaken` records
697
- // `no-emission` (or `prompt-rejection` if the detector fired) for the onExit
698
- // diagnostic line.
699
- //
700
- // The `if (resultEmitted || failureEmitted) return;` guard below is
701
- // load-bearing for the detector's live-accessor contract: it lets a
702
- // detector-initiated `failureEmitted=true` short-circuit any subsequent
703
- // Stop-hook `emitResult` so the wire carries exactly one emission.
704
-
705
- const emitResult = (hookData) => {
706
- if (resultEmitted || failureEmitted) return;
707
- if (!hookData) return;
708
-
709
- // durationMs: wall clock from pty.spawn to teardown(). Both the normal
710
- // SIGUSR1 path and the watchdog force-kill path route through teardown()
711
- // (Decision D1), so both report an honest real elapsed time. Only omitted
712
- // when startedAt/endedAt are unset — which never happens on the success
713
- // branch because teardown() runs before onExit calls emitResult().
714
- const { emitted } = emitResultLine({
715
- hookData,
716
- transcriptPath,
717
- effectiveSessionId,
718
- startedAt,
719
- endedAt,
720
- teardownReason,
721
- alreadyEmitted: resultEmitted || failureEmitted,
722
- suppressFallback: SUPPRESS_STOP_FALLBACK,
723
- log
724
- });
725
- if (emitted === 'result') resultEmitted = true;
726
- else if (emitted === 'error') failureEmitted = true;
727
- };
728
-
729
- // --- Transcript poll interval ---
730
-
731
- let transcriptPollInterval;
732
-
733
- const stopPolling = () => {
734
- if (transcriptPollInterval) { clearInterval(transcriptPollInterval); transcriptPollInterval = null; }
735
- };
736
-
737
- transcriptPollInterval = setInterval(() => {
738
- drainTailerAndForward();
739
- }, 1000);
740
-
741
- // --- Signal forwarding ---
742
- // Forward SIGTERM/SIGINT to the claude process inside the PTY.
743
- // onExit fires when ptyProcess exits, handling cleanup.
744
-
745
- process.on('SIGTERM', () => {
746
- log('Received SIGTERM, forwarding to claude');
747
- ptyProcess.kill('SIGTERM');
748
- });
749
-
750
- process.on('SIGINT', () => {
751
- log('Received SIGINT, forwarding to claude');
752
- ptyProcess.kill('SIGINT');
753
- });
754
-
755
- // --- Process exit handler ---
756
-
757
- let exited = false;
758
- ptyProcess.onExit(({ exitCode }) => {
759
- if (exited) return;
760
- exited = true;
761
-
762
- // Clear watchdog and stop polling (sentinelPath still exists on disk at this point)
763
- if (watchdogTimer) { clearTimeout(watchdogTimer); watchdogTimer = null; }
764
- stopPolling();
765
-
766
- // Hoisted Stop-hook sentinel read at the onExit → pre-emitResult seam.
767
- // The parsed payload (or null on ENOENT / JSON-parse failure) drives both
768
- // the D14 hook-path adoption guard below and the emitResult() delegator —
769
- // the extracted pty-emit-result.mjs no longer re-reads the sentinel from
770
- // disk. Null-hookData path: D14 guard is a no-op (`hookData?.transcript_path`
771
- // short-circuits), emitResult() no-ops on the null input, and the onExit
772
- // diagnostic below records `no-emission` (or `prompt-rejection` if the
773
- // detector fired before onExit).
774
- const hookData = readSentinelPayload(sentinelPath);
775
-
776
- // D14 (guarded hook-path adoption): when the 1s polling loop never
777
- // memoised a `transcriptPath` (cwd-hash convention produced nothing —
778
- // e.g., a client that writes elsewhere, an out-of-tree working directory
779
- // hashed differently, or a project-scoped transcript root), adopt the
780
- // Stop hook's `hookData.transcript_path` for the final tail. Gated on
781
- // `!transcriptPath` so every turn the hash convention worked for keeps
782
- // its pre-D14 behaviour byte-for-byte.
783
- //
784
- // Cursor rule keyed off `resumeId` (semantic mirror of today's retired
785
- // resume-baseline snapshot guard `if (!resumeId) return;`): a fresh
786
- // session adopts with cursor=0 so AC-1 / AC-2 entries still forward;
787
- // --resume adopts with cursor=size so 0 entries forward. Expressed via
788
- // a fresh `TranscriptTailer` instance because Task 4's
789
- // `initialiseBoundary` guard is one-way — the primary tailer's
790
- // `boundaryEstablished` flag has already latched.
791
- if (!DISABLE_TRANSCRIPT_TAIL && hookData?.transcript_path && !transcriptPath) {
792
- const adopted = hookData.transcript_path;
793
- if (existsSync(adopted)) {
794
- transcriptPath = adopted;
795
- log(`Final tail using hook-supplied transcript_path (hash resolution produced nothing, hook-supplied=${adopted})`);
796
- const d14Tailer = createTranscriptTailer({ log });
797
- d14Tailer.initialiseBoundary(adopted, { resume: !!resumeId });
798
- if (d14Tailer.unusable) {
799
- notifyBoundaryUnusable('readError');
800
- } else {
801
- let d14Entries = [];
802
- try {
803
- d14Entries = d14Tailer.tick();
804
- } catch (err) {
805
- log(`D14 tailer.tick() threw: ${err && err.message ? err.message : String(err)}`);
806
- }
807
- for (const entry of d14Entries) {
808
- try {
809
- process.stdout.write(JSON.stringify({ type: 'transcript', entry }) + '\n');
810
- } catch (err) {
811
- if (err && err.code !== 'EPIPE') throw err;
812
- }
813
- detector.consider(entry);
814
- if (promptRejectionEmitted) break;
815
- }
816
- }
817
- d14Tailer.close();
818
- }
819
- }
820
-
821
- // Final tick on the primary tailer captures anything the 1s poll's last
822
- // interval missed. drainTailerAndForward() feeds every forwarded entry
823
- // into detector.consider() before emitResult() runs (item 9).
824
- drainTailerAndForward();
825
-
826
- // Refusal emission when we truly never had a transcript to consider.
827
- // The wrapper is the sole site that owns the `transcript-unresolved`
828
- // refusal — the detector never observes `transcriptPath` itself
829
- // (item 10).
830
- if (transcriptPath === null) {
831
- detector.noteTranscriptUnresolved();
832
- }
833
-
834
- // Emit result or error from the pre-parsed sentinel payload (tempDir still
835
- // present — deleted below). If the detector fired before onExit
836
- // (`failureEmitted=true`), the guard at the top of emitResult short-
837
- // circuits so the wire carries exactly one emission.
838
- emitResult(hookData);
839
-
840
- // Clean up temp directory
841
- try { rmSync(tempDir, { recursive: true, force: true }); } catch {}
842
-
843
- // As1063 Task 1 item 9 / As1080 Task 6 item 17: closed diagnostic-label
844
- // set is exactly `emitted-result`, `emitted-failure`, `prompt-rejection`,
845
- // `no-emission`. `prompt-rejection` is the detector-initiated failure
846
- // branch (`promptRejectionEmitted=true`, which implies
847
- // `failureEmitted=true`). `emitted-failure` is the Stop-hook StopFailure
848
- // branch. `emitted-result` is the clean-Stop success branch.
849
- // `no-emission` is the terminal fall-through. Bounded per lifecycle event
850
- // by the `exited` guard at the top of onExit (no unbounded per-turn
851
- // accumulation). Written to stderr via log() so JobRunner's stderr
852
- // splitter captures it into the outputTail without affecting the
853
- // wrapper's stdout emission contract.
854
- const branchTaken = promptRejectionEmitted
855
- ? 'prompt-rejection'
856
- : resultEmitted
857
- ? 'emitted-result'
858
- : failureEmitted
859
- ? 'emitted-failure'
860
- : 'no-emission';
861
- // Cap the tty tail to 256 chars and escape newlines so the line stays on
862
- // one log entry regardless of what claude last wrote.
863
- const ttyTail = ttyBuffer.slice(-256).replace(/\n/g, '\\n').replace(/\r/g, '\\r');
864
- log(`onExit-branch: ${branchTaken} exitCode=${exitCode} teardownReason=${teardownReason ?? 'unset'} ttyTail="${ttyTail}"`);
865
-
866
- log(`Claude exited (code: ${exitCode})`);
867
-
868
- if (failureEmitted) {
869
- process.exit(1);
870
- }
871
- if (resultEmitted || exitCode === 0) {
872
- process.exit(0);
873
- }
874
- process.exit(exitCode ?? 1);
875
- });