@kelceyp/caw-server 1.0.289 → 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,870 +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). Used to prove the AC-5 gate downstream.
223
- const DISABLE_TRANSCRIPT_TAIL = process.env.CAW_PTY_DISABLE_TRANSCRIPT_TAIL === '1';
224
- const SUPPRESS_STOP_FALLBACK = process.env.CAW_PTY_SUPPRESS_STOP_FALLBACK === '1';
225
-
226
- let watchdogTimer = null;
227
- let teardownInitiated = false;
228
- // Captured at the top of teardown() on both paths (clean-Stop passes 'stop',
229
- // watchdog callback passes 'watchdog'). Read by emitResult() on the success
230
- // branch and written to metadata.teardownReason so the downstream
231
- // stream-handler and classifier (Task 7) can distinguish the two paths.
232
- let teardownReason = null;
233
-
234
- // Duration capture: wall clock from `pty.spawn` to top of `teardown()`.
235
- // `endedAt` is set by whichever teardown path fires first (normal SIGUSR1
236
- // completion or watchdog force-kill — both routed through `teardown()`,
237
- // per design Decision D1). If the child exits before `teardown()` runs,
238
- // `endedAt` remains null and no `durationMs` is emitted (per Decision D3 —
239
- // service appends the pre-existing `'0'` duration sentinel).
240
- let startedAt = null;
241
- let endedAt = null;
242
-
243
- // Module-scope armed timestamp for the watchdog observability channel (As1012
244
- // Task 7). Written by `startOrResetWatchdog` on every call (initial arm and
245
- // each subsequent reset) and read by both `teardown()` and the setTimeout
246
- // expiry closure so `elapsedSinceArmMs` can be computed after the timer has
247
- // released. On a `reset` call the elapsed value is captured BEFORE `armedAt`
248
- // is overwritten so the emitted event carries the duration of the *previous*
249
- // armed interval (see item 4's ordering invariant).
250
- let armedAt = null;
251
-
252
- // Module-scope snapshot of the sentinel counts observed at the most recent
253
- // deferral, for the `WATCHDOG` observability channel (As1012 Task 13). Written
254
- // in the SIGUSR1 handler's defer branch BEFORE `startOrResetWatchdog()` so the
255
- // counts are in place if the watchdog subsequently fires; read from
256
- // `teardown()` when `reason === TEARDOWN_REASONS.WATCHDOG`. Lifted for the
257
- // same reason as `armedAt`: the SIGUSR1 handler's `bgCount` / `cronCount`
258
- // locals are block-scoped and out of scope inside the `setTimeout` closure
259
- // that eventually calls `teardown(TEARDOWN_REASONS.WATCHDOG)`. Overwritten on
260
- // every deferral, so it always holds the LAST deferral's counts — that is the
261
- // documented last-seen-snapshot semantic (design.md §Data on the wire).
262
- //
263
- // As1062 Task 9 extends the binding with a third property, `types` — a
264
- // `Map<label,count>` histogram derived from the SAME `background_tasks` array
265
- // the count was computed from. Normalisation collapses whitespace in friendly
266
- // labels (`"MCP task"` → `MCP_task`) so the emitted `pendingBgTypes` token
267
- // stays a single greppable word per label. Missing/empty types become
268
- // `unknown`. Layered onto the same object rather than a parallel binding so
269
- // the LAST-deferral-wins semantic covers types too.
270
- let lastDeferredCounts = null;
271
-
272
- // As1062 Task 9 — renders `lastDeferredCounts.types` (a `Map<label,count>`)
273
- // into the single-token form emitted as `pendingBgTypes` on the WATCHDOG
274
- // teardown line: sorted alphabetically by label for deterministic output,
275
- // comma-joined `label:count` pairs, whitespace-free by construction. Returns
276
- // the literal `'none'` on an empty histogram (crons-only deferral) so the
277
- // rendered line reads `pendingBgTypes=none` — distinct from the splitter's
278
- // `-` sentinel that flags non-watchdog reasons. Never returns `''` — the
279
- // wrapper-side empty-string guard the design calls for.
280
- const renderPendingBgTypes = (types) => {
281
- if (!types || types.size === 0) return 'none';
282
- const rendered = [...types.entries()]
283
- .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
284
- .map(([label, count]) => `${label}:${count}`)
285
- .join(',');
286
- return rendered || 'none';
287
- };
288
-
289
- // Forward declarations (assigned after ptyProcess is created)
290
- let ptyProcess = null;
291
-
292
- // --- Tagged event channel (As1012 Task 7) ---
293
- // Structured lifecycle events on stderr: `[caw:pty:event] <json>\n`. Consumed
294
- // downstream by JobRunner's `createStderrSplitter` (Task 6), which routes
295
- // matching lines through the operational tier (`caw:ops:pty:info`). Prose
296
- // stderr (every `log()` line above) is untouched — the splitter forwards
297
- // non-tagged bytes byte-identically to today's `stderrChunks` collector.
298
- // EPIPE is swallowed so a closed downstream pipe cannot crash the wrapper
299
- // (stderr is diagnostic; the tagged channel is best-effort).
300
- const EVENT_TAG = '[caw:pty:event] ';
301
- const emitEvent = (type, fields = {}) => {
302
- const payload = {
303
- type,
304
- wrapperPid: process.pid,
305
- claudePid: ptyProcess?.pid,
306
- agentSessionId: effectiveSessionId,
307
- ...fields
308
- };
309
- try {
310
- process.stderr.write(`${EVENT_TAG}${JSON.stringify(payload)}\n`);
311
- } catch (err) {
312
- if (err && err.code !== 'EPIPE') throw err;
313
- }
314
- };
315
-
316
- const startOrResetWatchdog = () => {
317
- const wasArmed = watchdogTimer !== null;
318
- if (wasArmed) clearTimeout(watchdogTimer);
319
- log(`Watchdog ${wasArmed ? 'reset' : 'armed'}: ${WATCHDOG_TIMEOUT_MS / 60000}m timeout`);
320
- // Ordering invariant (item 4): capture elapsedSinceArmMs BEFORE `armedAt`
321
- // is overwritten so the `reset` event carries the duration of the previous
322
- // armed interval. `armed` (first call) carries no elapsed value.
323
- if (wasArmed) {
324
- emitEvent('reset', { elapsedSinceArmMs: Date.now() - armedAt });
325
- } else {
326
- emitEvent('armed');
327
- }
328
- armedAt = Date.now();
329
- watchdogTimer = setTimeout(() => {
330
- log(`Watchdog expired: ${WATCHDOG_TIMEOUT_MS / 60000}m elapsed since last deferred Stop, force-killing claude`);
331
- emitEvent('expired', { elapsedSinceArmMs: Date.now() - armedAt });
332
- teardown(TEARDOWN_REASONS.WATCHDOG);
333
- }, WATCHDOG_TIMEOUT_MS);
334
- };
335
-
336
- const teardown = (reason = TEARDOWN_REASONS.STOP) => {
337
- if (teardownInitiated) return;
338
- teardownInitiated = true;
339
- endedAt = Date.now();
340
- teardownReason = reason;
341
- log(`Teardown (reason=${reason}): killing claude`);
342
- log(`Duration: ${startedAt !== null ? (endedAt - startedAt) : 'unknown'} ms (startedAt=${startedAt}, endedAt=${endedAt})`);
343
- // As1012 Task 13 — `pendingBgCount` / `pendingCronCount` ride only on the
344
- // `reason=watchdog` teardown, using the same conditional-spread idiom as
345
- // `elapsedSinceArmMs`. No null-guard is needed: `startOrResetWatchdog()`
346
- // has exactly one call site (the defer branch below), and that branch
347
- // always writes `lastDeferredCounts` BEFORE arming the timer — so a
348
- // `reason=watchdog` teardown is only reachable after at least one
349
- // deferral, and at least one of the two counts is guaranteed ≥ 1.
350
- emitEvent('teardown', {
351
- reason,
352
- ...(armedAt !== null ? { elapsedSinceArmMs: Date.now() - armedAt } : {}),
353
- ...(reason === TEARDOWN_REASONS.WATCHDOG && lastDeferredCounts !== null
354
- ? {
355
- pendingBgCount: lastDeferredCounts.bgCount,
356
- pendingCronCount: lastDeferredCounts.cronCount,
357
- pendingBgTypes: renderPendingBgTypes(lastDeferredCounts.types)
358
- }
359
- : {})
360
- });
361
- if (watchdogTimer) { clearTimeout(watchdogTimer); watchdogTimer = null; }
362
- stopPolling();
363
- if (ptyProcess) ptyProcess.kill('SIGINT');
364
- };
365
-
366
- // --- Register SIGUSR1 handler and write wrapper PID ---
367
- // Must happen BEFORE spawning claude so the handler is ready when the first Stop fires.
368
-
369
- process.on('SIGUSR1', () => {
370
- log('Received SIGUSR1 (stop signal)');
371
- drainTailerAndForward();
372
- const payload = readSentinelPayload(sentinelPath);
373
- if (!payload) {
374
- log(`Warning: failed to read sentinel, proceeding with teardown`);
375
- teardown(TEARDOWN_REASONS.SENTINEL_UNREADABLE);
376
- return;
377
- }
378
- if (payload.error) {
379
- log(`StopFailure received: error=${payload.error}`);
380
- teardown(TEARDOWN_REASONS.STOP_FAILURE);
381
- return;
382
- }
383
- const bgCount = Array.isArray(payload.background_tasks) ? payload.background_tasks.length : 0;
384
- const cronCount = Array.isArray(payload.session_crons) ? payload.session_crons.length : 0;
385
- log(`Sentinel: background_tasks=${bgCount}, session_crons=${cronCount}`);
386
- if (hasPendingWork(payload)) {
387
- log('Deferring teardown: pending background work');
388
- // As1012 Task 13 — capture BEFORE arming so the counts are already in
389
- // place if the timer subsequently fires. Members named to match the
390
- // locals above; the lift is a zero-translation copy.
391
- //
392
- // As1062 Task 9 — extend the same capture with a normalised `type`
393
- // histogram of `background_tasks` entries so the WATCHDOG line's
394
- // `pendingBgTypes=` token can carry a per-label breakdown. Normalisation
395
- // (`String(entry?.type ?? '').trim().replace(/\s+/g, '_') || 'unknown'`)
396
- // collapses whitespace in friendly labels (`"MCP task"` → `MCP_task`,
397
- // `"auto-mode scan"` → `auto-mode_scan`) so the emitted token stays
398
- // greppable as one word per label, and rescues missing/empty types to
399
- // `unknown` rather than dropping them silently.
400
- const types = new Map();
401
- if (Array.isArray(payload.background_tasks)) {
402
- for (const entry of payload.background_tasks) {
403
- const label = String(entry?.type ?? '').trim().replace(/\s+/g, '_') || 'unknown';
404
- types.set(label, (types.get(label) ?? 0) + 1);
405
- }
406
- }
407
- lastDeferredCounts = { bgCount, cronCount, types };
408
- startOrResetWatchdog();
409
- return;
410
- }
411
- teardown(TEARDOWN_REASONS.STOP);
412
- });
413
-
414
- writeFileSync(wrapperPidPath, String(process.pid));
415
-
416
- // --- Build claude args ---
417
-
418
- const claudeArgs = [];
419
- if (resumeId) {
420
- claudeArgs.push('--resume', resumeId);
421
- } else {
422
- claudeArgs.push('--session-id', sessionId);
423
- }
424
- if (skipPermissions) {
425
- claudeArgs.push('--dangerously-skip-permissions');
426
- }
427
- if (model) {
428
- claudeArgs.push('--model', model);
429
- }
430
- if (effortLevel) {
431
- claudeArgs.push('--effort', effortLevel);
432
- }
433
- claudeArgs.push('--settings', settingsPath);
434
- // The prompt is content. `--` bounds option parsing so a leading-dash
435
- // prompt ("- could you…") is not parsed as a CLI flag. This bound does
436
- // *not* cover subcommand dispatch, and the exposure is against the
437
- // trimmed prompt (pty-wrapper.mjs:63 trims before this point) — see
438
- // technical-debt-register.md for the recorded subcommand-shadowing debt.
439
- claudeArgs.push('--', prompt);
440
-
441
- log(`Session: ${effectiveSessionId}`);
442
- log(`Prompt length: ${prompt.length} chars`);
443
-
444
- // --- Spawn claude via PTY ---
445
-
446
- startedAt = Date.now();
447
- ptyProcess = pty.spawn(claudePath, claudeArgs, {
448
- name: 'xterm-256color',
449
- cols: 120,
450
- rows: 40,
451
- cwd: process.cwd(),
452
- env: { ...process.env, CLAUBBIT: '1', CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS: '0' }
453
- });
454
-
455
- log(`PID: ${ptyProcess.pid}`);
456
- writeFileSync(claudePidPath, String(ptyProcess.pid));
457
-
458
- // --- Auto-confirm --dangerously-skip-permissions dialog ---
459
- // Claude shows a "Yes/No" dialog when --dangerously-skip-permissions is used.
460
- // Detect it in PTY output and send down-arrow (select "Yes") + Enter.
461
-
462
- let ttyBuffer = '';
463
- let permissionConfirmed = false;
464
-
465
- // onData responsibilities after As1080 Increment A:
466
- //
467
- // (1) Auto-confirm the `--dangerously-skip-permissions` "Yes/No" dialog once
468
- // the rendered tty contains both "Yes" and "No". The wrapper sends a
469
- // down-arrow to select "Yes" followed by Enter, then latches
470
- // `permissionConfirmed` so subsequent chunks do not re-trigger.
471
- // (2) Keep `ttyBuffer` bounded — trimmed to the last 5000 chars once it
472
- // exceeds 10000. `ttyBuffer` itself is retained because the onExit
473
- // diagnostic-label branch reads a bounded tail of it for a single log
474
- // line at process exit.
475
- //
476
- // The onData handler no longer scans `ttyBuffer` for the literal
477
- // "Prompt is too long" trigger phrase. Rendered tty flattens authorship —
478
- // echoed job prompt, tool results, rendered CAW documents, agent prose, and
479
- // genuine API rejections all land in the same stream — so any text-based
480
- // predicate here fires on things the agent said and kills healthy sessions.
481
- // Prompt-rejection classification is now driven exclusively by the Stop-hook
482
- // sentinel's structured error payload consumed in `emitResult` and by
483
- // `claude-session.js` `classifyExit` Step 2 (`errorInfo.error === 'prompt_too_long'`).
484
-
485
- ptyProcess.onData((data) => {
486
- ttyBuffer += data;
487
- if (!permissionConfirmed && ttyBuffer.includes('Yes') && ttyBuffer.includes('No')) {
488
- log('Auto-confirming bypass permissions dialog');
489
- ptyProcess.write('\x1b[B'); // down arrow to select "Yes"
490
- setTimeout(() => ptyProcess.write('\r'), 200); // Enter
491
- permissionConfirmed = true;
492
- }
493
- // Keep buffer bounded
494
- if (ttyBuffer.length > 10000) ttyBuffer = ttyBuffer.slice(-5000);
495
- });
496
-
497
- // --- Transcript tailing (TranscriptTailer + PromptRejectionDetector wiring) ---
498
- // Task 6 (As1080 Increment B) replaced the inline tail-state block that lived
499
- // here with a two-module split. `transcript-tailer.mjs` owns cursor / (dev,ino)
500
- // / preBaselineUUIDs / processedUUIDs bookkeeping and torn-line + file-
501
- // replacement recovery; `prompt-rejection-detector.mjs` owns the conjunctive-
502
- // predicate evaluation on `isApiErrorMessage:true` transcript entries and the
503
- // one-shot `{"type":"error","error":"prompt_too_long", …}` stdout emission
504
- // that JobRunner.js:363 consumes. The wrapper is now a thin coordinator:
505
- // • establishes the tailer boundary pre-spawn (Case A fresh session →
506
- // cursor:0 / empty preBaselineUUIDs; Case B `--resume` with a readable
507
- // transcript file at the cwd-hash candidate → (dev,ino) + cursor:size
508
- // + regex-uuid pre-baseline walk with no size limit);
509
- // • forwards each `tick()` entry into `detector.consider(entry)` from the
510
- // poll callback (in file order, exactly once);
511
- // • notifies the detector at the tailer's `unusable` transition with
512
- // `cause ∈ {'readError','reopenFailed'}` — the venue matches the
513
- // transition (readError from initialiseBoundary Case B, reopenFailed
514
- // from a tick()-time reopenAndRewalkBoundary);
515
- // • at onExit, when `transcriptPath === null`, calls
516
- // `detector.noteTranscriptUnresolved()` exactly once before
517
- // `emitResult(hookData)`.
518
- //
519
- // The detector's live-accessor coupling reads `resultEmitted` and
520
- // `failureEmitted` via closure — NOT snapshots at construction — so a
521
- // concurrent Stop-hook `emitResult()` that flips the flags mid-`consider()`
522
- // is caught by the detector's re-check-before-write guard (Task 5 item 10;
523
- // unit-tested in `prompt-rejection-detector.test.js` case (e)).
524
- //
525
- // Path resolution stays a polling-time concern: no pre-spawn retry loop.
526
- // For fresh sessions where the cwd-hash guess is wrong, the D14 late-
527
- // adoption path at onExit constructs a fresh tailer against
528
- // `hookData.transcript_path` (cursor rule keyed off `resumeId`, mirroring
529
- // today's retired resume-baseline snapshot guard).
530
-
531
- let transcriptPath = null;
532
- let transcriptPathResolutionAttempts = 0;
533
- let transcriptPathMemoised = false;
534
-
535
- // Emission-state flags — declared here so the detector's live accessors
536
- // can close over them. `promptRejectionEmitted` distinguishes a detector-
537
- // initiated failure (branchTaken='prompt-rejection') from a Stop-hook
538
- // StopFailure (branchTaken='emitted-failure'); both paths set
539
- // `failureEmitted=true` so the exit branch `if (failureEmitted) { process.exit(1); }`
540
- // stays byte-identical to its pre-task state.
541
- let resultEmitted = false;
542
- let failureEmitted = false;
543
- let promptRejectionEmitted = false;
544
-
545
- // Compute the pre-spawn candidate transcript path via the cwd-hash
546
- // convention. Fresh sessions don't have the file yet at pre-spawn (claude
547
- // creates it after `pty.spawn`), so we do NOT require existsSync — the
548
- // tailer's `tick()` safely returns [] on ENOENT.
549
- const computeCandidateTranscriptPath = () => {
550
- const claudeProjectsDir = join(homedir(), '.claude', 'projects');
551
- const cwd = process.cwd();
552
- const candidates = [cwd];
553
- try {
554
- const rp = realpathSync(cwd);
555
- if (rp !== cwd) candidates.push(rp);
556
- } catch { /* ignore */ }
557
- // Prefer a candidate that already resolves on disk (relevant for
558
- // --resume where the transcript file exists at spawn time). Otherwise
559
- // fall back to the first (cwd-hash) candidate; D14 late-adoption at
560
- // onExit corrects if this guess was wrong.
561
- for (const dir of candidates) {
562
- const hash = dir.replace(/[/.]/g, '-');
563
- const p = join(claudeProjectsDir, hash, `${effectiveSessionId}.jsonl`);
564
- if (existsSync(p)) return p;
565
- }
566
- const hash = candidates[0].replace(/[/.]/g, '-');
567
- return join(claudeProjectsDir, hash, `${effectiveSessionId}.jsonl`);
568
- };
569
-
570
- const candidateTranscriptPath = computeCandidateTranscriptPath();
571
-
572
- // Tailer + detector construction. `boundaryUnusableNotified` mirrors the
573
- // tailer's one-shot invariant on the wrapper side so
574
- // `detector.noteBoundaryUnusable(cause)` fires exactly once per transition
575
- // with the cause matched to the transition venue. The detector's
576
- // defensive `cause=unknown` fallback (Task 5 wire-format contract) is
577
- // unreachable on this wired-up path.
578
- const tailer = createTranscriptTailer({ log });
579
- let boundaryUnusableNotified = false;
580
- const notifyBoundaryUnusable = (cause) => {
581
- if (boundaryUnusableNotified) return;
582
- boundaryUnusableNotified = true;
583
- detector.noteBoundaryUnusable(cause);
584
- };
585
-
586
- const detector = createPromptRejectionDetector({
587
- log,
588
- getEmissionState: () =>
589
- resultEmitted ? 'result'
590
- : failureEmitted ? 'failure'
591
- : 'clear',
592
- setEmissionState: (flag) => {
593
- if (flag === 'failure') {
594
- failureEmitted = true;
595
- promptRejectionEmitted = true;
596
- } else if (flag === 'result') {
597
- resultEmitted = true;
598
- }
599
- },
600
- tailer,
601
- });
602
-
603
- // Pre-spawn boundary establishment.
604
- if (resumeId) {
605
- // Case B (--resume): with a readable transcript file at the cwd-hash
606
- // candidate, snapshot (dev, ino) + cursor=size + pre-baseline walk.
607
- // Unresolvable path → defer to D14 (tailer stays uninitialised;
608
- // `tick()` short-circuits on `!boundaryEstablished`).
609
- if (existsSync(candidateTranscriptPath)) {
610
- tailer.initialiseBoundary(candidateTranscriptPath, { resume: true });
611
- if (tailer.unusable) {
612
- // Case B's only fail-closed cause is a read error.
613
- notifyBoundaryUnusable('readError');
614
- } else {
615
- transcriptPath = candidateTranscriptPath;
616
- transcriptPathMemoised = true;
617
- log(`Resume: initialised boundary against ${candidateTranscriptPath}`);
618
- }
619
- } else {
620
- log(`Resume: transcript not yet resolvable via cwd-hash (candidate=${candidateTranscriptPath}); deferring to D14 hook-path adoption`);
621
- }
622
- } else {
623
- // Case A (fresh session, no --resume): cursor=0, empty preBaselineUUIDs.
624
- // initialiseBoundary with resume:false does no I/O and cannot flip
625
- // `unusable`. Path may not exist yet; `tick()` returns [] on ENOENT.
626
- tailer.initialiseBoundary(candidateTranscriptPath, { resume: false });
627
- }
628
-
629
- // Poll-callback helper. Called from the 1s interval AND from the SIGUSR1
630
- // handler (Stop-hook fired) AND from onExit (final drain). Every entry
631
- // the tailer forwards from `tick()` reaches `detector.consider(entry)`
632
- // exactly once, in file order, and is also written to stdout as the
633
- // `{"type":"transcript",entry:…}` line JobRunner's stream handler consumes.
634
- // Preserves the load-bearing `Transcript found (attempt N):` log line
635
- // (asserted by the `pty-wrapper D14 memoised-branch preserves today`
636
- // integration test) the first time the tailer's file resolves on disk.
637
- const drainTailerAndForward = () => {
638
- if (DISABLE_TRANSCRIPT_TAIL) return;
639
- if (!tailer.boundaryEstablished) {
640
- // --resume + unresolvable pre-spawn path — waiting for D14 adoption.
641
- return;
642
- }
643
- if (!transcriptPathMemoised) {
644
- transcriptPathResolutionAttempts++;
645
- if (tailer.path && existsSync(tailer.path)) {
646
- transcriptPath = tailer.path;
647
- transcriptPathMemoised = true;
648
- log(`Transcript found (attempt ${transcriptPathResolutionAttempts}): ${transcriptPath}`);
649
- } else if (transcriptPathResolutionAttempts === 1 || transcriptPathResolutionAttempts % 10 === 0) {
650
- log(`Transcript not found (attempt ${transcriptPathResolutionAttempts}), cwd=${process.cwd()}, session=${effectiveSessionId}`);
651
- }
652
- }
653
- const preUnusable = tailer.unusable;
654
- let entries = [];
655
- try {
656
- entries = tailer.tick();
657
- } catch (err) {
658
- log(`tailer.tick() threw: ${err && err.message ? err.message : String(err)}`);
659
- return;
660
- }
661
- if (tailer.unusable && !preUnusable) {
662
- // Only reopenAndRewalkBoundary can flip `unusable` inside tick().
663
- notifyBoundaryUnusable('reopenFailed');
664
- }
665
- for (const entry of entries) {
666
- try {
667
- process.stdout.write(JSON.stringify({ type: 'transcript', entry }) + '\n');
668
- } catch (err) {
669
- if (err && err.code !== 'EPIPE') throw err;
670
- }
671
- detector.consider(entry);
672
- if (promptRejectionEmitted) {
673
- // Detector wrote the `{"type":"error","error":"prompt_too_long",...}`
674
- // line synchronously inside consider(). SIGTERM the pty so its
675
- // exit unwinds the wrapper's onExit path (which re-drains and
676
- // then no-ops emitResult under the emission guard).
677
- log(`prompt-rejection: matched — SIGTERM to pty (pid=${ptyProcess?.pid})`);
678
- if (ptyProcess) {
679
- try { ptyProcess.kill('SIGTERM'); } catch { /* pty already gone */ }
680
- }
681
- break;
682
- }
683
- }
684
- };
685
-
686
- // --- Sentinel / result emission ---
687
- // Called from onExit after polling stops. The Stop-hook sentinel is read once
688
- // at the onExit seam (hoisted out of this delegator) so the parsed payload
689
- // drives both the D14 hook-path adoption guard AND this emission. Null-
690
- // hookData path: the delegator no-ops, `resultEmitted` / `failureEmitted`
691
- // stay false unless the detector fired first, and `branchTaken` records
692
- // `no-emission` (or `prompt-rejection` if the detector fired) for the onExit
693
- // diagnostic line.
694
- //
695
- // The `if (resultEmitted || failureEmitted) return;` guard below is
696
- // load-bearing for the detector's live-accessor contract: it lets a
697
- // detector-initiated `failureEmitted=true` short-circuit any subsequent
698
- // Stop-hook `emitResult` so the wire carries exactly one emission.
699
-
700
- const emitResult = (hookData) => {
701
- if (resultEmitted || failureEmitted) return;
702
- if (!hookData) return;
703
-
704
- // durationMs: wall clock from pty.spawn to teardown(). Both the normal
705
- // SIGUSR1 path and the watchdog force-kill path route through teardown()
706
- // (Decision D1), so both report an honest real elapsed time. Only omitted
707
- // when startedAt/endedAt are unset — which never happens on the success
708
- // branch because teardown() runs before onExit calls emitResult().
709
- const { emitted } = emitResultLine({
710
- hookData,
711
- transcriptPath,
712
- effectiveSessionId,
713
- startedAt,
714
- endedAt,
715
- teardownReason,
716
- alreadyEmitted: resultEmitted || failureEmitted,
717
- suppressFallback: SUPPRESS_STOP_FALLBACK,
718
- log
719
- });
720
- if (emitted === 'result') resultEmitted = true;
721
- else if (emitted === 'error') failureEmitted = true;
722
- };
723
-
724
- // --- Transcript poll interval ---
725
-
726
- let transcriptPollInterval;
727
-
728
- const stopPolling = () => {
729
- if (transcriptPollInterval) { clearInterval(transcriptPollInterval); transcriptPollInterval = null; }
730
- };
731
-
732
- transcriptPollInterval = setInterval(() => {
733
- drainTailerAndForward();
734
- }, 1000);
735
-
736
- // --- Signal forwarding ---
737
- // Forward SIGTERM/SIGINT to the claude process inside the PTY.
738
- // onExit fires when ptyProcess exits, handling cleanup.
739
-
740
- process.on('SIGTERM', () => {
741
- log('Received SIGTERM, forwarding to claude');
742
- ptyProcess.kill('SIGTERM');
743
- });
744
-
745
- process.on('SIGINT', () => {
746
- log('Received SIGINT, forwarding to claude');
747
- ptyProcess.kill('SIGINT');
748
- });
749
-
750
- // --- Process exit handler ---
751
-
752
- let exited = false;
753
- ptyProcess.onExit(({ exitCode }) => {
754
- if (exited) return;
755
- exited = true;
756
-
757
- // Clear watchdog and stop polling (sentinelPath still exists on disk at this point)
758
- if (watchdogTimer) { clearTimeout(watchdogTimer); watchdogTimer = null; }
759
- stopPolling();
760
-
761
- // Hoisted Stop-hook sentinel read at the onExit → pre-emitResult seam.
762
- // The parsed payload (or null on ENOENT / JSON-parse failure) drives both
763
- // the D14 hook-path adoption guard below and the emitResult() delegator —
764
- // the extracted pty-emit-result.mjs no longer re-reads the sentinel from
765
- // disk. Null-hookData path: D14 guard is a no-op (`hookData?.transcript_path`
766
- // short-circuits), emitResult() no-ops on the null input, and the onExit
767
- // diagnostic below records `no-emission` (or `prompt-rejection` if the
768
- // detector fired before onExit).
769
- const hookData = readSentinelPayload(sentinelPath);
770
-
771
- // D14 (guarded hook-path adoption): when the 1s polling loop never
772
- // memoised a `transcriptPath` (cwd-hash convention produced nothing —
773
- // e.g., a client that writes elsewhere, an out-of-tree working directory
774
- // hashed differently, or a project-scoped transcript root), adopt the
775
- // Stop hook's `hookData.transcript_path` for the final tail. Gated on
776
- // `!transcriptPath` so every turn the hash convention worked for keeps
777
- // its pre-D14 behaviour byte-for-byte.
778
- //
779
- // Cursor rule keyed off `resumeId` (semantic mirror of today's retired
780
- // resume-baseline snapshot guard `if (!resumeId) return;`): a fresh
781
- // session adopts with cursor=0 so AC-1 / AC-2 entries still forward;
782
- // --resume adopts with cursor=size so 0 entries forward. Expressed via
783
- // a fresh `TranscriptTailer` instance because Task 4's
784
- // `initialiseBoundary` guard is one-way — the primary tailer's
785
- // `boundaryEstablished` flag has already latched.
786
- if (!DISABLE_TRANSCRIPT_TAIL && hookData?.transcript_path && !transcriptPath) {
787
- const adopted = hookData.transcript_path;
788
- if (existsSync(adopted)) {
789
- transcriptPath = adopted;
790
- log(`Final tail using hook-supplied transcript_path (hash resolution produced nothing, hook-supplied=${adopted})`);
791
- const d14Tailer = createTranscriptTailer({ log });
792
- d14Tailer.initialiseBoundary(adopted, { resume: !!resumeId });
793
- if (d14Tailer.unusable) {
794
- notifyBoundaryUnusable('readError');
795
- } else {
796
- let d14Entries = [];
797
- try {
798
- d14Entries = d14Tailer.tick();
799
- } catch (err) {
800
- log(`D14 tailer.tick() threw: ${err && err.message ? err.message : String(err)}`);
801
- }
802
- for (const entry of d14Entries) {
803
- try {
804
- process.stdout.write(JSON.stringify({ type: 'transcript', entry }) + '\n');
805
- } catch (err) {
806
- if (err && err.code !== 'EPIPE') throw err;
807
- }
808
- detector.consider(entry);
809
- if (promptRejectionEmitted) break;
810
- }
811
- }
812
- d14Tailer.close();
813
- }
814
- }
815
-
816
- // Final tick on the primary tailer captures anything the 1s poll's last
817
- // interval missed. drainTailerAndForward() feeds every forwarded entry
818
- // into detector.consider() before emitResult() runs (item 9).
819
- drainTailerAndForward();
820
-
821
- // Refusal emission when we truly never had a transcript to consider.
822
- // The wrapper is the sole site that owns the `transcript-unresolved`
823
- // refusal — the detector never observes `transcriptPath` itself
824
- // (item 10).
825
- if (transcriptPath === null) {
826
- detector.noteTranscriptUnresolved();
827
- }
828
-
829
- // Emit result or error from the pre-parsed sentinel payload (tempDir still
830
- // present — deleted below). If the detector fired before onExit
831
- // (`failureEmitted=true`), the guard at the top of emitResult short-
832
- // circuits so the wire carries exactly one emission.
833
- emitResult(hookData);
834
-
835
- // Clean up temp directory
836
- try { rmSync(tempDir, { recursive: true, force: true }); } catch {}
837
-
838
- // As1063 Task 1 item 9 / As1080 Task 6 item 17: closed diagnostic-label
839
- // set is exactly `emitted-result`, `emitted-failure`, `prompt-rejection`,
840
- // `no-emission`. `prompt-rejection` is the detector-initiated failure
841
- // branch (`promptRejectionEmitted=true`, which implies
842
- // `failureEmitted=true`). `emitted-failure` is the Stop-hook StopFailure
843
- // branch. `emitted-result` is the clean-Stop success branch.
844
- // `no-emission` is the terminal fall-through. Bounded per lifecycle event
845
- // by the `exited` guard at the top of onExit (no unbounded per-turn
846
- // accumulation). Written to stderr via log() so JobRunner's stderr
847
- // splitter captures it into the outputTail without affecting the
848
- // wrapper's stdout emission contract.
849
- const branchTaken = promptRejectionEmitted
850
- ? 'prompt-rejection'
851
- : resultEmitted
852
- ? 'emitted-result'
853
- : failureEmitted
854
- ? 'emitted-failure'
855
- : 'no-emission';
856
- // Cap the tty tail to 256 chars and escape newlines so the line stays on
857
- // one log entry regardless of what claude last wrote.
858
- const ttyTail = ttyBuffer.slice(-256).replace(/\n/g, '\\n').replace(/\r/g, '\\r');
859
- log(`onExit-branch: ${branchTaken} exitCode=${exitCode} teardownReason=${teardownReason ?? 'unset'} ttyTail="${ttyTail}"`);
860
-
861
- log(`Claude exited (code: ${exitCode})`);
862
-
863
- if (failureEmitted) {
864
- process.exit(1);
865
- }
866
- if (resultEmitted || exitCode === 0) {
867
- process.exit(0);
868
- }
869
- process.exit(exitCode ?? 1);
870
- });