@kelceyp/caw-server 1.0.281 → 1.0.283

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.
@@ -19,13 +19,16 @@
19
19
  */
20
20
 
21
21
  import * as pty from 'node-pty';
22
- import { mkdtempSync, readFileSync, existsSync, writeFileSync, rmSync, realpathSync, statSync, chmodSync, renameSync, unlinkSync, lstatSync, readlinkSync, readdirSync } from 'fs';
23
- import { join, dirname, basename, resolve } from 'path';
22
+ import { mkdtempSync, readFileSync, existsSync, writeFileSync, rmSync, realpathSync } from 'fs';
23
+ import { join } from 'path';
24
24
  import { tmpdir, homedir } from 'os';
25
25
  import { execSync } from 'child_process';
26
26
  import { readSentinelPayload, hasPendingWork } from './pty-deferred-teardown.mjs';
27
27
  import { emitResult as emitResultLine } from './pty-emit-result.mjs';
28
28
  import { TEARDOWN_REASONS } from './teardownReasons.mjs';
29
+ import { ensureNonInteractiveSetup } from './pty-non-interactive-setup.mjs';
30
+ import { createTranscriptTailer } from './transcript-tailer.mjs';
31
+ import { createPromptRejectionDetector } from './prompt-rejection-detector.mjs';
29
32
 
30
33
  // --- Arg parsing ---
31
34
 
@@ -78,242 +81,77 @@ try {
78
81
  }
79
82
 
80
83
  // --- Pre-seed ~/.claude.json to avoid interactive dialogs ---
81
- // Sets hasCompletedOnboarding and trusts the working directory.
82
- // Uses realpathSync to handle macOS /tmp → /private/tmp symlinks.
84
+ // The function is defined in the sibling module
85
+ // `./pty-non-interactive-setup.mjs` (statically imported above). Post-As1050
86
+ // Commit B the direct-mode CAW server calls that function ONCE, before
87
+ // spawning this wrapper, under a module-level mutex singleton in
88
+ // `JobRunner.runDirectJob` (see `claude-session.js#prepareDirectHost`), and
89
+ // signals suppression here via the `--skip-non-interactive-setup` CLI arg
90
+ // emitted by `buildDirectCommand`. The Docker path does NOT emit that flag
91
+ // (`claude-session.js#getDockerConfig`'s `commandParts` omits it by
92
+ // design), so the in-container wrapper still runs the setup itself. This
93
+ // wrapper's local call site preserves the same discriminating catch that
94
+ // re-emits classified `CLAUDE_JSON_UNREADABLE` errors byte-for-byte with
95
+ // today's stderr + exit contract and rethrows every other error. See As1016
96
+ // for the fail-fast / atomic / idempotent invariants that module preserves.
83
97
  //
84
- // Fail-fast / atomic / idempotent write contract (As1016):
85
- // - Absent → seed with data = {} and mode 0o644, then atomic write.
86
- // - Present but stat/read/parse fails (with one-shot SyntaxError retry) →
87
- // print classified `CLAUDE_JSON_UNREADABLE (<code>): <path>. ...` to
88
- // stderr and exit 1. No `[pty-wrapper]` prefix. No write.
89
- // - Present and read succeeded → apply the guarded mutations; short-circuit
90
- // via the idempotent guard when both onboarding and every trust-set entry
91
- // are already recorded.
92
- // - Writes go via unique `<target>.tmp.<pid>.<counter>` in the SAME
93
- // directory (`renameSync` must stay on one filesystem), preserving the
94
- // target's existing mode (belt-and-braces `chmodSync` against umask),
95
- // cleaning up (`unlinkSync` + rethrow the original error) on any
96
- // temp-write / chmod / rename failure so the underlying cause is not
97
- // masked.
98
- // - A dangling symlink materialises the file at the link's own target;
99
- // `lstatSync` classifies the nominal path (single-hop only).
100
- // - At function top, sweeps `<basename>.tmp.*` siblings older than 60 s
101
- // from `<dirname>` so a crashed prior writer never accumulates cruft;
102
- // sweep failures are logged and ignored (never the caller's failure).
103
- //
104
- // Declared here (post log()/stdin-drain/claude-binary, immediately before
105
- // mkdtempSync) so an early `process.exit(1)` cannot EPIPE the parent's
106
- // stdin.write at JobRunner.js:747/:1145 (no 'error' listener there).
107
- // Relocating only the invocation and not the declaration would trip the
108
- // const binding's TDZ — `ReferenceError: Cannot access
109
- // 'ensureNonInteractiveSetup' before initialization` on every direct PTY
110
- // turn.
98
+ // The invocation stays here (post log()/stdin-drain/claude-binary,
99
+ // immediately before mkdtempSync) so a Docker-mode `process.exit(1)` on a
100
+ // classified fail-fast cannot EPIPE the parent's stdin.write. The
101
+ // direct-mode early-exit exposure the D16 ordering constraint was
102
+ // originally motivated by is closed by two independent halves: As1050
103
+ // Commit B moves the direct-mode call out of the wrapper entirely (the
104
+ // `--skip-non-interactive-setup` gate below), and As1048 landed a
105
+ // `stdin.on('error')` handler immediately before `stdin.write(promptToSend)`
106
+ // inside `JobRunner.js#writePromptToChildStdin` (see `JobRunner.js:130`
107
+ // registering the listener and `:141` performing the write) so any residual
108
+ // Docker EPIPE surfaces as a caught error rather than an uncaught. The
109
+ // Docker path retains the six exit sites, so this comment's ordering
110
+ // constraint stands for Docker.
111
111
  //
112
112
  // Matched by shared/jobErrorPrefixes.js's `CLAUDE_JSON_UNREADABLE_PREFIX`
113
- // export (not imported here — `shared/` is not published from
114
- // `server/dist/`; keep the two literals in sync).
113
+ // export AND by the extracted module's own inline copy at
114
+ // `pty-non-interactive-setup.mjs`. Task 2 (Commit A) created the third
115
+ // co-located literal (shared / wrapper / extracted); the extracted module
116
+ // carries its own copy because `shared/` is not published from
117
+ // `server/dist/`, and the wrapper keeps this local const so the
118
+ // discriminating catch below can classify without importing the sibling's
119
+ // export. Keep all three literals in sync per As1016 D8.
115
120
  const CLAUDE_JSON_UNREADABLE_PREFIX = 'CLAUDE_JSON_UNREADABLE';
116
121
 
117
- // Module-local counter so each write produces a unique temp path
118
- // (`<target>.tmp.<pid>.<counter>`, first temp is `.tmp.<pid>.1`).
119
- let claudeJsonTempCounter = 0;
120
-
121
- const ensureNonInteractiveSetup = (cwd) => {
122
- const nominal = join(homedir(), '.claude.json');
123
-
124
- // D15: classify the nominal path via lstatSync (not existsSync).
125
- // - not-a-symlink or absent → target = nominal
126
- // - live symlink → target = realpathSync(nominal)
127
- // - dangling symlink → target = resolve(dirname(nominal), readlinkSync(nominal))
128
- // Single-hop only. Dangling symlink materialises the file at the link's
129
- // OWN target rather than replacing the link with a regular file.
130
- let target = nominal;
122
+ // As1050 Commit B (C3) — the `--skip-non-interactive-setup` CLI arg is
123
+ // emitted by `claude-session.js#buildDirectCommand` only for direct-mode
124
+ // spawns; `getDockerConfig`'s `commandParts` omits it. `hasFlag` (not
125
+ // `getArg`) is the correct helper: `getArg` is value-extracting and would
126
+ // fail *open* on a valueless flag. Sibling idiom: `hasFlag('--skip-permissions')`.
127
+ if (!hasFlag('--skip-non-interactive-setup')) {
131
128
  try {
132
- const linkSt = lstatSync(nominal);
133
- if (linkSt.isSymbolicLink()) {
134
- try {
135
- target = realpathSync(nominal);
136
- } catch {
137
- const linkText = readlinkSync(nominal);
138
- target = resolve(dirname(nominal), linkText);
139
- }
140
- }
129
+ // As1050 AC 19 — forward `CLAUDE_CONFIG_DIR` from the wrapper's env so
130
+ // the in-container Docker path (which still runs this local call —
131
+ // `getDockerConfig`'s `commandParts` omits `--skip-non-interactive-setup`)
132
+ // routes the seed to the same file the container-side `claude`
133
+ // reads. Absent variable → falls back to `homedir()` inside the
134
+ // sibling module, matching pre-AC-19 behaviour.
135
+ ensureNonInteractiveSetup(process.cwd(), {
136
+ home: homedir(),
137
+ configDir: process.env.CLAUDE_CONFIG_DIR,
138
+ log
139
+ });
141
140
  } catch (err) {
142
- if (err.code !== 'ENOENT') {
143
- // A stat failure on the nominal path itself that is not "absent"
144
- // (EACCES on the parent dir, ELOOP, ENOTDIR, ...) is unclassifiable
145
- // — fail-fast rather than write blindly.
146
- process.stderr.write(`${CLAUDE_JSON_UNREADABLE_PREFIX} (${err.code}): ${nominal}. Restore from ~/.claude/backups/ or repair the file, then retry.\n`);
141
+ // Discriminating catch: only classified `CLAUDE_JSON_UNREADABLE`-prefixed
142
+ // throws are re-emitted to stderr as a fail-fast (matching today's
143
+ // process.stderr.write + process.exit(1) contract byte-for-byte; the
144
+ // extracted module's `.message` omits the trailing `\n` that the wrapper
145
+ // concatenates here). All other errors are RETHROWN — the wrapper does
146
+ // NOT swallow them, so today's uncaught-exception behaviour on
147
+ // non-`CLAUDE_JSON_UNREADABLE` failures is preserved.
148
+ if (err && typeof err.message === 'string' && err.message.startsWith(CLAUDE_JSON_UNREADABLE_PREFIX)) {
149
+ process.stderr.write(err.message + '\n');
147
150
  process.exit(1);
148
151
  }
149
- // ENOENT on the nominal path is the absent case; target stays nominal.
150
- }
151
-
152
- // D17: sweep stale <basename>.tmp.* siblings older than 60 s. Best-effort
153
- // cleanup; failures are logged and never surfaced (a broken sweep must
154
- // not fail the wrapper turn).
155
- try {
156
- const parentDir = dirname(target);
157
- const prefix = `${basename(target)}.tmp.`;
158
- const now = Date.now();
159
- for (const name of readdirSync(parentDir)) {
160
- if (!name.startsWith(prefix)) continue;
161
- const stalePath = join(parentDir, name);
162
- try {
163
- const staleSt = statSync(stalePath);
164
- if (now - staleSt.mtimeMs > 60_000) {
165
- unlinkSync(stalePath);
166
- }
167
- } catch (sweepErr) {
168
- log(`Warning: stale temp sweep of ${stalePath} failed: ${sweepErr.message}`);
169
- }
170
- }
171
- } catch (sweepErr) {
172
- log(`Warning: stale temp sweep of ${dirname(target)} failed: ${sweepErr.message}`);
173
- }
174
-
175
- // Classified read. `statSync(target)` classifies ENOENT as absent
176
- // (data = {}, existingMode = 0o644); any other stat error fails fast.
177
- // On SyntaxError from the initial JSON.parse a single re-stat +
178
- // re-readFileSync + re-JSON.parse retry runs with no sleep (reclassifies
179
- // to absent if the re-stat returns ENOENT). Other read failures fail
180
- // immediately with no retry.
181
- let data;
182
- let existingMode;
183
- let readSucceeded = false;
184
-
185
- let st;
186
- try {
187
- st = statSync(target);
188
- } catch (err) {
189
- if (err.code === 'ENOENT') {
190
- data = {};
191
- existingMode = 0o644;
192
- } else {
193
- process.stderr.write(`${CLAUDE_JSON_UNREADABLE_PREFIX} (${err.code}): ${target}. Restore from ~/.claude/backups/ or repair the file, then retry.\n`);
194
- process.exit(1);
195
- }
196
- }
197
-
198
- if (st) {
199
- existingMode = st.mode & 0o777;
200
- let raw;
201
- try {
202
- raw = readFileSync(target, 'utf-8');
203
- } catch (err) {
204
- process.stderr.write(`${CLAUDE_JSON_UNREADABLE_PREFIX} (${err.code}): ${target}. Restore from ~/.claude/backups/ or repair the file, then retry.\n`);
205
- process.exit(1);
206
- }
207
- try {
208
- data = JSON.parse(raw);
209
- readSucceeded = true;
210
- } catch {
211
- // D1: one-shot SyntaxError retry (no sleep). A concurrent
212
- // in-flight write from a peer can momentarily present a partial
213
- // JSON snapshot; a single re-stat + re-read gives it a chance to
214
- // land. Deliberately untested per D1 ("best-effort insurance for
215
- // the deploy, not a standing guarantee").
216
- let retrySt;
217
- try {
218
- retrySt = statSync(target);
219
- } catch (retryErr) {
220
- if (retryErr.code === 'ENOENT') {
221
- data = {};
222
- existingMode = 0o644;
223
- } else {
224
- process.stderr.write(`${CLAUDE_JSON_UNREADABLE_PREFIX} (${retryErr.code}): ${target}. Restore from ~/.claude/backups/ or repair the file, then retry.\n`);
225
- process.exit(1);
226
- }
227
- }
228
- if (retrySt) {
229
- existingMode = retrySt.mode & 0o777;
230
- let retryRaw;
231
- try {
232
- retryRaw = readFileSync(target, 'utf-8');
233
- } catch (retryErr) {
234
- process.stderr.write(`${CLAUDE_JSON_UNREADABLE_PREFIX} (${retryErr.code}): ${target}. Restore from ~/.claude/backups/ or repair the file, then retry.\n`);
235
- process.exit(1);
236
- }
237
- try {
238
- data = JSON.parse(retryRaw);
239
- readSucceeded = true;
240
- } catch (retryErr) {
241
- process.stderr.write(`${CLAUDE_JSON_UNREADABLE_PREFIX} (SyntaxError): ${target}. Restore from ~/.claude/backups/ or repair the file, then retry.\n`);
242
- process.exit(1);
243
- }
244
- }
245
- }
246
- }
247
-
248
- // Trust both the raw and real cwd paths (handles macOS /tmp symlink).
249
- // The inner try/ignore on realpathSync(cwd) is preserved — a stat failure
250
- // on cwd is a defensible reduction of the trust set, not the anti-pattern
251
- // rewritten out above.
252
- const paths = new Set([cwd]);
253
- try { paths.add(realpathSync(cwd)); } catch { /* ignore */ }
254
-
255
- // D3/D4 idempotent guard — predicated on a SUCCESSFUL read, never on the
256
- // shape of `data` alone.
257
- //
258
- // Closes: steady-state lost updates — the reproduced mechanism where two
259
- // concurrent turns race a read-modify-write when neither had any change
260
- // to make in the first place (e.g. `mcpServers` emptied for one project
261
- // with onboarding intact).
262
- // Does NOT close: concurrent first initialization. If N wrapper turns
263
- // start against a fresh cwd before any one has completed its write,
264
- // all N read the pre-setup state and all N write; only locking or
265
- // per-session config isolation can close that residual.
266
- // Note: `CLAUDE_CONFIG_DIR` isolation is empirically verified on Claude
267
- // Code 2.1.245 (relocates `.claude.json`, `projects/`, `sessions/`,
268
- // `backups/`) rather than a published contract; future upstream
269
- // changes are breaking evidence to re-verify.
270
- if (readSucceeded && data.hasCompletedOnboarding) {
271
- const alreadyTrusted = [...paths].every(
272
- (p) => data.projects?.[p]?.hasTrustDialogAccepted
273
- );
274
- if (alreadyTrusted) {
275
- return;
276
- }
277
- }
278
-
279
- // Guarded mutations — preserved exactly:
280
- // (i) onboarding is set ONCE; an already-onboarded seed's
281
- // `lastOnboardingVersion` is not re-stamped.
282
- // (ii) per-project trust is set ONCE; the spread preserves any
283
- // pre-existing keys (e.g. `mcpServers`) on the project's entry.
284
- if (!data.hasCompletedOnboarding) {
285
- data.hasCompletedOnboarding = true;
286
- data.lastOnboardingVersion = '9.9.9';
287
- }
288
- data.projects = data.projects || {};
289
- for (const p of paths) {
290
- if (!data.projects[p]?.hasTrustDialogAccepted) {
291
- data.projects[p] = {
292
- ...(data.projects[p] || {}),
293
- hasTrustDialogAccepted: true,
294
- projectOnboardingSeenCount: 10
295
- };
296
- }
297
- }
298
-
299
- // D5/D6/D7/D19: atomic mode-preserving temp+rename.
300
- // Unique temp name per (pid, counter). Same directory as `target` so
301
- // `renameSync` stays on one filesystem. Belt-and-braces `chmodSync`
302
- // against umask. On any error on write / chmod / rename, cleanup and
303
- // rethrow the ORIGINAL error so the underlying cause is not masked.
304
- const tmp = `${target}.tmp.${process.pid}.${++claudeJsonTempCounter}`;
305
- try {
306
- writeFileSync(tmp, JSON.stringify(data), { mode: existingMode });
307
- chmodSync(tmp, existingMode);
308
- renameSync(tmp, target);
309
- } catch (err) {
310
- try { unlinkSync(tmp); } catch { /* temp may not exist; ignore */ }
311
152
  throw err;
312
153
  }
313
- log(`Non-interactive setup complete for: ${cwd}`);
314
- };
315
-
316
- ensureNonInteractiveSetup(process.cwd());
154
+ }
317
155
 
318
156
  // --- Temp directory for settings, hook script, and sentinel file ---
319
157
 
@@ -359,10 +197,11 @@ writeFileSync(settingsPath, JSON.stringify(settings));
359
197
  const WATCHDOG_TIMEOUT_MS = Number.parseInt(process.env.CAW_PTY_WATCHDOG_TIMEOUT_MS, 10) || (90 * 60 * 1000);
360
198
 
361
199
  // D11 test-only env overrides (default off). Precedent: CAW_PTY_WATCHDOG_TIMEOUT_MS above.
362
- // CAW_PTY_DISABLE_TRANSCRIPT_TAIL: skip all tailTranscript() work (both the
363
- // 1s polling loop's calls and the final onExit call), so wrapper stdout
364
- // carries no `transcript` lines. Used to prove downstream (Task 5) fallback
365
- // consumes `result` instead of the missing transcript stream.
200
+ // CAW_PTY_DISABLE_TRANSCRIPT_TAIL: skip all TranscriptTailer drains (the 1s
201
+ // polling loop, the SIGUSR1 handler, and the final onExit tick), so
202
+ // wrapper stdout carries no `transcript` lines and PromptRejectionDetector
203
+ // never sees an entry via `consider()`. Used to prove downstream (Task 5)
204
+ // fallback consumes `result` instead of the missing transcript stream.
366
205
  // CAW_PTY_SUPPRESS_STOP_FALLBACK: blank the emitted `result` field on the
367
206
  // success branch of pty-emit-result (only when hookData is non-null and
368
207
  // the success branch runs). Used to prove the AC-5 gate downstream.
@@ -380,10 +219,9 @@ let teardownReason = null;
380
219
  // Duration capture: wall clock from `pty.spawn` to top of `teardown()`.
381
220
  // `endedAt` is set by whichever teardown path fires first (normal SIGUSR1
382
221
  // completion or watchdog force-kill — both routed through `teardown()`,
383
- // per design Decision D1). The prompt-too-long branch in `onExit` does NOT
384
- // go through `teardown()`, so `endedAt` remains null and no `durationMs`
385
- // is emitted (per Decision D3 — service appends the pre-existing `'0'`
386
- // duration sentinel).
222
+ // per design Decision D1). If the child exits before `teardown()` runs,
223
+ // `endedAt` remains null and no `durationMs` is emitted (per Decision D3 —
224
+ // service appends the pre-existing `'0'` duration sentinel).
387
225
  let startedAt = null;
388
226
  let endedAt = null;
389
227
 
@@ -515,7 +353,7 @@ const teardown = (reason = TEARDOWN_REASONS.STOP) => {
515
353
 
516
354
  process.on('SIGUSR1', () => {
517
355
  log('Received SIGUSR1 (stop signal)');
518
- tailTranscript();
356
+ drainTailerAndForward();
519
357
  const payload = readSentinelPayload(sentinelPath);
520
358
  if (!payload) {
521
359
  log(`Warning: failed to read sentinel, proceeding with teardown`);
@@ -578,7 +416,12 @@ if (effortLevel) {
578
416
  claudeArgs.push('--effort', effortLevel);
579
417
  }
580
418
  claudeArgs.push('--settings', settingsPath);
581
- claudeArgs.push(prompt);
419
+ // The prompt is content. `--` bounds option parsing so a leading-dash
420
+ // prompt ("- could you…") is not parsed as a CLI flag. This bound does
421
+ // *not* cover subcommand dispatch, and the exposure is against the
422
+ // trimmed prompt (pty-wrapper.mjs:63 trims before this point) — see
423
+ // technical-debt-register.md for the recorded subcommand-shadowing debt.
424
+ claudeArgs.push('--', prompt);
582
425
 
583
426
  log(`Session: ${effectiveSessionId}`);
584
427
  log(`Prompt length: ${prompt.length} chars`);
@@ -604,59 +447,25 @@ writeFileSync(claudePidPath, String(ptyProcess.pid));
604
447
  let ttyBuffer = '';
605
448
  let permissionConfirmed = false;
606
449
 
607
- // As1063 Task 3 item 5: streaming prompt-too-long detection (mechanism (d) fix).
608
- //
609
- // Named mechanism from Task 1 evidence ([Aj20482 trace](Ad84181)): Claude Code
610
- // CLI treats "Prompt is too long" from the Anthropic API as an assistant-message
611
- // response — it writes the text to the transcript, prints it to the tty, and
612
- // awaits the next user input rather than exiting. No Stop hook fires (this is
613
- // not a normal completion). The wrapper's onExit-side detection at :822 depends
614
- // on the child exiting, so it never runs. This streaming detection catches the
615
- // phrase during onData and forces the pty child to exit via SIGTERM, which then
616
- // drives the existing onExit path (emitResult / prompt-too-long fallback /
617
- // process exit) unchanged.
450
+ // onData responsibilities after As1080 Increment A:
618
451
  //
619
- // Debounce (500 ms of tty silence after the phrase is seen) reduces the
620
- // false-positive risk of a legitimate Claude response that mentions the
621
- // phrase mid-stream — a real response keeps writing (streaming JSON events,
622
- // further text) and pushes the timer out; only an idle tty — the observed
623
- // prompt-too-long signature — lets the timer fire. `seenPromptTooLong` is
624
- // persistent because `ttyBuffer` is trimmed to the last 5000 chars on
625
- // overflow, and we do not want to lose the signal to a later burst of output.
626
- // Budget rationale: the observed prod trace (Aj20482) had no post-phrase tty
627
- // output for 5+ minutes; 500 ms is comfortably above any streaming inter-chunk
628
- // gap for a real turn and well below any human-perceptible terminal-transition
629
- // latency erosion (AC5).
452
+ // (1) Auto-confirm the `--dangerously-skip-permissions` "Yes/No" dialog once
453
+ // the rendered tty contains both "Yes" and "No". The wrapper sends a
454
+ // down-arrow to select "Yes" followed by Enter, then latches
455
+ // `permissionConfirmed` so subsequent chunks do not re-trigger.
456
+ // (2) Keep `ttyBuffer` bounded — trimmed to the last 5000 chars once it
457
+ // exceeds 10000. `ttyBuffer` itself is retained because the onExit
458
+ // diagnostic-label branch reads a bounded tail of it for a single log
459
+ // line at process exit.
630
460
  //
631
- // `CAW_PTY_PROMPT_TOO_LONG_DEBOUNCE_MS` overrides the default (test-only
632
- // configuration knob per Task 3 item 17 — fault-injection for the pre-fix /
633
- // post-fix pairing regression test; production leaves the default in place).
634
- const PROMPT_TOO_LONG_DEBOUNCE_MS = (() => {
635
- const raw = process.env.CAW_PTY_PROMPT_TOO_LONG_DEBOUNCE_MS;
636
- if (!raw) return 500;
637
- const parsed = parseInt(raw, 10);
638
- return Number.isFinite(parsed) && parsed >= 0 ? parsed : 500;
639
- })();
640
- let seenPromptTooLong = false;
641
- let streamingPromptTooLongEmitted = false;
642
- let promptTooLongTimer = null;
643
- const schedulePromptTooLongEmit = () => {
644
- if (promptTooLongTimer) clearTimeout(promptTooLongTimer);
645
- promptTooLongTimer = setTimeout(() => {
646
- promptTooLongTimer = null;
647
- if (resultEmitted || failureEmitted) return;
648
- log('Prompt-too-long: detected in streaming path — emitting failure and killing pty');
649
- process.stdout.write(JSON.stringify({
650
- type: 'error',
651
- error: 'prompt_too_long',
652
- message: 'Prompt is too long'
653
- }) + '\n');
654
- failureEmitted = true;
655
- streamingPromptTooLongEmitted = true;
656
- // Kill the pty so onExit fires and the wrapper shuts down cleanly.
657
- try { ptyProcess.kill('SIGTERM'); } catch { /* ignore */ }
658
- }, PROMPT_TOO_LONG_DEBOUNCE_MS);
659
- };
461
+ // The onData handler no longer scans `ttyBuffer` for the literal
462
+ // "Prompt is too long" trigger phrase. Rendered tty flattens authorship —
463
+ // echoed job prompt, tool results, rendered CAW documents, agent prose, and
464
+ // genuine API rejections all land in the same stream — so any text-based
465
+ // predicate here fires on things the agent said and kills healthy sessions.
466
+ // Prompt-rejection classification is now driven exclusively by the Stop-hook
467
+ // sentinel's structured error payload consumed in `emitResult` and by
468
+ // `claude-session.js` `classifyExit` Step 2 (`errorInfo.error === 'prompt_too_long'`).
660
469
 
661
470
  ptyProcess.onData((data) => {
662
471
  ttyBuffer += data;
@@ -666,169 +475,212 @@ ptyProcess.onData((data) => {
666
475
  setTimeout(() => ptyProcess.write('\r'), 200); // Enter
667
476
  permissionConfirmed = true;
668
477
  }
669
- // As1063 Task 3: streaming prompt-too-long detection with debounce.
670
- // Latch `seenPromptTooLong` on first observation, then reset the debounce
671
- // on every subsequent onData chunk — the timer only fires after tty has
672
- // been idle for PROMPT_TOO_LONG_DEBOUNCE_MS.
673
- if (!seenPromptTooLong && ttyBuffer.includes('Prompt is too long')) {
674
- seenPromptTooLong = true;
675
- schedulePromptTooLongEmit();
676
- } else if (seenPromptTooLong && !resultEmitted && !failureEmitted) {
677
- schedulePromptTooLongEmit();
678
- }
679
478
  // Keep buffer bounded
680
479
  if (ttyBuffer.length > 10000) ttyBuffer = ttyBuffer.slice(-5000);
681
480
  });
682
481
 
683
- // --- Transcript tailing ---
684
- // Claude writes session transcript to ~/.claude/projects/<cwd-hash>/<session-id>.jsonl.
685
- // We poll it every 1 second and forward new entries to stdout as streaming events.
482
+ // --- Transcript tailing (TranscriptTailer + PromptRejectionDetector wiring) ---
483
+ // Task 6 (As1080 Increment B) replaced the inline tail-state block that lived
484
+ // here with a two-module split. `transcript-tailer.mjs` owns cursor / (dev,ino)
485
+ // / preBaselineUUIDs / processedUUIDs bookkeeping and torn-line + file-
486
+ // replacement recovery; `prompt-rejection-detector.mjs` owns the conjunctive-
487
+ // predicate evaluation on `isApiErrorMessage:true` transcript entries and the
488
+ // one-shot `{"type":"error","error":"prompt_too_long", …}` stdout emission
489
+ // that JobRunner.js:363 consumes. The wrapper is now a thin coordinator:
490
+ // • establishes the tailer boundary pre-spawn (Case A fresh session →
491
+ // cursor:0 / empty preBaselineUUIDs; Case B `--resume` with a readable
492
+ // transcript file at the cwd-hash candidate → (dev,ino) + cursor:size
493
+ // + regex-uuid pre-baseline walk with no size limit);
494
+ // • forwards each `tick()` entry into `detector.consider(entry)` from the
495
+ // poll callback (in file order, exactly once);
496
+ // • notifies the detector at the tailer's `unusable` transition with
497
+ // `cause ∈ {'readError','reopenFailed'}` — the venue matches the
498
+ // transition (readError from initialiseBoundary Case B, reopenFailed
499
+ // from a tick()-time reopenAndRewalkBoundary);
500
+ // • at onExit, when `transcriptPath === null`, calls
501
+ // `detector.noteTranscriptUnresolved()` exactly once before
502
+ // `emitResult(hookData)`.
503
+ //
504
+ // The detector's live-accessor coupling reads `resultEmitted` and
505
+ // `failureEmitted` via closure — NOT snapshots at construction — so a
506
+ // concurrent Stop-hook `emitResult()` that flips the flags mid-`consider()`
507
+ // is caught by the detector's re-check-before-write guard (Task 5 item 10;
508
+ // unit-tested in `prompt-rejection-detector.test.js` case (e)).
509
+ //
510
+ // Path resolution stays a polling-time concern: no pre-spawn retry loop.
511
+ // For fresh sessions where the cwd-hash guess is wrong, the D14 late-
512
+ // adoption path at onExit constructs a fresh tailer against
513
+ // `hookData.transcript_path` (cursor rule keyed off `resumeId`, mirroring
514
+ // today's retired resume-baseline snapshot guard).
686
515
 
687
516
  let transcriptPath = null;
688
- let transcriptLinesRead = 0;
689
517
  let transcriptPathResolutionAttempts = 0;
690
- const processedEntryUUIDs = new Set();
691
- let lastReadEntryUUID = null;
692
-
693
- // Snapshot the pre-existing line count on --resume so only new content streams.
694
- // Shared by the cwd-hash-hit branch and the D14 hook-path adoption branch —
695
- // keeping the snapshot in one place avoids duplicating the readFileSync/count
696
- // logic and satisfies the checklist rule ("Do not duplicate the snapshot
697
- // logic into the D14 branch").
698
- const snapshotResumeBaseline = (path) => {
699
- if (!resumeId) return;
700
- try {
701
- const content = readFileSync(path, 'utf-8');
702
- transcriptLinesRead = content.trim().split('\n').filter(Boolean).length;
703
- log(`Resume: skipping ${transcriptLinesRead} existing transcript lines`);
704
- } catch { /* ignore */ }
705
- };
706
-
707
- // Resolve transcript path, handling macOS /tmp → /private/tmp symlinks.
708
- // Returns null if not yet found (retried on next poll).
709
- //
710
- // D14 (guarded hook-path adoption): callers may pass an `overridePath` — the
711
- // Stop hook's `hookData.transcript_path`. When supplied, the function bypasses
712
- // the cwd-hash candidate search and adopts the override path directly (after
713
- // an existsSync guard). The caller must already have gated on `!transcriptPath`
714
- // so this is only used when the polling loop never memoised a path — which
715
- // preserves today's behaviour on every turn the hash convention worked for.
716
- const resolveTranscriptPath = (overridePath = null) => {
717
- if (!overridePath && transcriptPath) return transcriptPath;
718
-
719
- if (overridePath) {
720
- if (!existsSync(overridePath)) return null;
721
- transcriptPath = overridePath;
722
- snapshotResumeBaseline(overridePath);
723
- return transcriptPath;
724
- }
518
+ let transcriptPathMemoised = false;
519
+
520
+ // Emission-state flags — declared here so the detector's live accessors
521
+ // can close over them. `promptRejectionEmitted` distinguishes a detector-
522
+ // initiated failure (branchTaken='prompt-rejection') from a Stop-hook
523
+ // StopFailure (branchTaken='emitted-failure'); both paths set
524
+ // `failureEmitted=true` so the exit branch `if (failureEmitted) { process.exit(1); }`
525
+ // stays byte-identical to its pre-task state.
526
+ let resultEmitted = false;
527
+ let failureEmitted = false;
528
+ let promptRejectionEmitted = false;
725
529
 
726
- transcriptPathResolutionAttempts++;
530
+ // Compute the pre-spawn candidate transcript path via the cwd-hash
531
+ // convention. Fresh sessions don't have the file yet at pre-spawn (claude
532
+ // creates it after `pty.spawn`), so we do NOT require existsSync — the
533
+ // tailer's `tick()` safely returns [] on ENOENT.
534
+ const computeCandidateTranscriptPath = () => {
727
535
  const claudeProjectsDir = join(homedir(), '.claude', 'projects');
728
536
  const cwd = process.cwd();
729
- const paths = new Set([cwd]);
730
- try { paths.add(realpathSync(cwd)); } catch { /* ignore */ }
731
-
732
- for (const dir of paths) {
537
+ const candidates = [cwd];
538
+ try {
539
+ const rp = realpathSync(cwd);
540
+ if (rp !== cwd) candidates.push(rp);
541
+ } catch { /* ignore */ }
542
+ // Prefer a candidate that already resolves on disk (relevant for
543
+ // --resume where the transcript file exists at spawn time). Otherwise
544
+ // fall back to the first (cwd-hash) candidate; D14 late-adoption at
545
+ // onExit corrects if this guess was wrong.
546
+ for (const dir of candidates) {
733
547
  const hash = dir.replace(/[/.]/g, '-');
734
- const candidate = join(claudeProjectsDir, hash, `${effectiveSessionId}.jsonl`);
735
- if (existsSync(candidate)) {
736
- transcriptPath = candidate;
737
- log(`Transcript found (attempt ${transcriptPathResolutionAttempts}): ${transcriptPath}`);
738
- snapshotResumeBaseline(candidate);
739
- return transcriptPath;
740
- }
741
- }
742
- if (transcriptPathResolutionAttempts === 1 || transcriptPathResolutionAttempts % 10 === 0) {
743
- log(`Transcript not found (attempt ${transcriptPathResolutionAttempts}), cwd=${cwd}, session=${effectiveSessionId}`);
548
+ const p = join(claudeProjectsDir, hash, `${effectiveSessionId}.jsonl`);
549
+ if (existsSync(p)) return p;
744
550
  }
745
- return null;
551
+ const hash = candidates[0].replace(/[/.]/g, '-');
552
+ return join(claudeProjectsDir, hash, `${effectiveSessionId}.jsonl`);
746
553
  };
747
554
 
748
- // Read new transcript entries and forward to stdout. Returns count of new lines emitted.
749
- const tailTranscript = () => {
750
- // D11 test-only override: skip all tail work so wrapper stdout carries no
751
- // `transcript` lines. Both the 1s polling loop's calls and the final
752
- // onExit call short-circuit here — the fallback-consumption case downstream
753
- // (Task 5) needs a wire with no transcript stream at all.
754
- if (DISABLE_TRANSCRIPT_TAIL) return 0;
755
- const path = resolveTranscriptPath();
756
- if (!path) return 0;
757
-
758
- let newLines = 0;
759
- try {
760
- const content = readFileSync(path, 'utf-8');
761
- const lines = content.trim().split('\n').filter(Boolean);
762
- const fileLineCount = lines.length;
763
-
764
- if (fileLineCount < transcriptLinesRead) {
765
- log(`tailTranscript: FILE SHRINK — transcriptLinesRead=${transcriptLinesRead}, fileLines=${fileLineCount} (file replaced or truncated), resetting to 0`);
766
- transcriptLinesRead = 0;
767
- } else if (transcriptLinesRead > 0 && lastReadEntryUUID) {
768
- try {
769
- const checkEntry = JSON.parse(lines[transcriptLinesRead - 1]);
770
- if (checkEntry.uuid && checkEntry.uuid !== lastReadEntryUUID) {
771
- log(`tailTranscript: FILE REPLACED — UUID mismatch at position ${transcriptLinesRead - 1} (expected ${lastReadEntryUUID}, got ${checkEntry.uuid}), resetting to 0`);
772
- transcriptLinesRead = 0;
773
- }
774
- } catch { /* parse error during write — skip validation this poll */ }
775
- }
555
+ const candidateTranscriptPath = computeCandidateTranscriptPath();
556
+
557
+ // Tailer + detector construction. `boundaryUnusableNotified` mirrors the
558
+ // tailer's one-shot invariant on the wrapper side so
559
+ // `detector.noteBoundaryUnusable(cause)` fires exactly once per transition
560
+ // with the cause matched to the transition venue. The detector's
561
+ // defensive `cause=unknown` fallback (Task 5 wire-format contract) is
562
+ // unreachable on this wired-up path.
563
+ const tailer = createTranscriptTailer({ log });
564
+ let boundaryUnusableNotified = false;
565
+ const notifyBoundaryUnusable = (cause) => {
566
+ if (boundaryUnusableNotified) return;
567
+ boundaryUnusableNotified = true;
568
+ detector.noteBoundaryUnusable(cause);
569
+ };
776
570
 
777
- const pending = fileLineCount - transcriptLinesRead;
778
- if (pending > 0) {
779
- log(`tailTranscript: ${pending} new lines (fileLines=${fileLineCount}, read=${transcriptLinesRead})`);
571
+ const detector = createPromptRejectionDetector({
572
+ log,
573
+ getEmissionState: () =>
574
+ resultEmitted ? 'result'
575
+ : failureEmitted ? 'failure'
576
+ : 'clear',
577
+ setEmissionState: (flag) => {
578
+ if (flag === 'failure') {
579
+ failureEmitted = true;
580
+ promptRejectionEmitted = true;
581
+ } else if (flag === 'result') {
582
+ resultEmitted = true;
780
583
  }
584
+ },
585
+ tailer,
586
+ });
781
587
 
782
- const typeCounts = {};
783
- let skipped = 0;
784
- for (let i = transcriptLinesRead; i < lines.length; i++) {
785
- try {
786
- const entry = JSON.parse(lines[i]);
787
- if (entry.type === 'progress' && entry.data?.type === 'hook_progress') {
788
- skipped++;
789
- continue;
790
- }
791
- if (entry.uuid && processedEntryUUIDs.has(entry.uuid)) {
792
- skipped++;
793
- continue;
794
- }
795
- process.stdout.write(JSON.stringify({ type: 'transcript', entry }) + '\n');
796
- if (entry.uuid) processedEntryUUIDs.add(entry.uuid);
797
- newLines++;
798
- const key = entry.type || 'unknown';
799
- typeCounts[key] = (typeCounts[key] || 0) + 1;
800
- } catch {
801
- skipped++;
802
- }
803
- }
804
- transcriptLinesRead = lines.length;
805
- if (lines.length > 0) {
806
- try {
807
- const lastEntry = JSON.parse(lines[lines.length - 1]);
808
- if (lastEntry.uuid) lastReadEntryUUID = lastEntry.uuid;
809
- } catch { /* ignore */ }
588
+ // Pre-spawn boundary establishment.
589
+ if (resumeId) {
590
+ // Case B (--resume): with a readable transcript file at the cwd-hash
591
+ // candidate, snapshot (dev, ino) + cursor=size + pre-baseline walk.
592
+ // Unresolvable path → defer to D14 (tailer stays uninitialised;
593
+ // `tick()` short-circuits on `!boundaryEstablished`).
594
+ if (existsSync(candidateTranscriptPath)) {
595
+ tailer.initialiseBoundary(candidateTranscriptPath, { resume: true });
596
+ if (tailer.unusable) {
597
+ // Case B's only fail-closed cause is a read error.
598
+ notifyBoundaryUnusable('readError');
599
+ } else {
600
+ transcriptPath = candidateTranscriptPath;
601
+ transcriptPathMemoised = true;
602
+ log(`Resume: initialised boundary against ${candidateTranscriptPath}`);
810
603
  }
604
+ } else {
605
+ log(`Resume: transcript not yet resolvable via cwd-hash (candidate=${candidateTranscriptPath}); deferring to D14 hook-path adoption`);
606
+ }
607
+ } else {
608
+ // Case A (fresh session, no --resume): cursor=0, empty preBaselineUUIDs.
609
+ // initialiseBoundary with resume:false does no I/O and cannot flip
610
+ // `unusable`. Path may not exist yet; `tick()` returns [] on ENOENT.
611
+ tailer.initialiseBoundary(candidateTranscriptPath, { resume: false });
612
+ }
811
613
 
812
- if (newLines > 0 || skipped > 0) {
813
- const types = Object.entries(typeCounts).map(([k, v]) => `${k}=${v}`).join(', ');
814
- log(`tailTranscript: forwarded=${newLines}, skipped=${skipped}${types ? `, types: ${types}` : ''}`);
614
+ // Poll-callback helper. Called from the 1s interval AND from the SIGUSR1
615
+ // handler (Stop-hook fired) AND from onExit (final drain). Every entry
616
+ // the tailer forwards from `tick()` reaches `detector.consider(entry)`
617
+ // exactly once, in file order, and is also written to stdout as the
618
+ // `{"type":"transcript",entry:…}` line JobRunner's stream handler consumes.
619
+ // Preserves the load-bearing `Transcript found (attempt N):` log line
620
+ // (asserted by the `pty-wrapper D14 memoised-branch preserves today`
621
+ // integration test) the first time the tailer's file resolves on disk.
622
+ const drainTailerAndForward = () => {
623
+ if (DISABLE_TRANSCRIPT_TAIL) return;
624
+ if (!tailer.boundaryEstablished) {
625
+ // --resume + unresolvable pre-spawn path — waiting for D14 adoption.
626
+ return;
627
+ }
628
+ if (!transcriptPathMemoised) {
629
+ transcriptPathResolutionAttempts++;
630
+ if (tailer.path && existsSync(tailer.path)) {
631
+ transcriptPath = tailer.path;
632
+ transcriptPathMemoised = true;
633
+ log(`Transcript found (attempt ${transcriptPathResolutionAttempts}): ${transcriptPath}`);
634
+ } else if (transcriptPathResolutionAttempts === 1 || transcriptPathResolutionAttempts % 10 === 0) {
635
+ log(`Transcript not found (attempt ${transcriptPathResolutionAttempts}), cwd=${process.cwd()}, session=${effectiveSessionId}`);
815
636
  }
637
+ }
638
+ const preUnusable = tailer.unusable;
639
+ let entries = [];
640
+ try {
641
+ entries = tailer.tick();
816
642
  } catch (err) {
817
- log(`tailTranscript: read error: ${err.message}`);
643
+ log(`tailer.tick() threw: ${err && err.message ? err.message : String(err)}`);
644
+ return;
645
+ }
646
+ if (tailer.unusable && !preUnusable) {
647
+ // Only reopenAndRewalkBoundary can flip `unusable` inside tick().
648
+ notifyBoundaryUnusable('reopenFailed');
649
+ }
650
+ for (const entry of entries) {
651
+ try {
652
+ process.stdout.write(JSON.stringify({ type: 'transcript', entry }) + '\n');
653
+ } catch (err) {
654
+ if (err && err.code !== 'EPIPE') throw err;
655
+ }
656
+ detector.consider(entry);
657
+ if (promptRejectionEmitted) {
658
+ // Detector wrote the `{"type":"error","error":"prompt_too_long",...}`
659
+ // line synchronously inside consider(). SIGTERM the pty so its
660
+ // exit unwinds the wrapper's onExit path (which re-drains and
661
+ // then no-ops emitResult under the emission guard).
662
+ log(`prompt-rejection: matched — SIGTERM to pty (pid=${ptyProcess?.pid})`);
663
+ if (ptyProcess) {
664
+ try { ptyProcess.kill('SIGTERM'); } catch { /* pty already gone */ }
665
+ }
666
+ break;
667
+ }
818
668
  }
819
- return newLines;
820
669
  };
821
670
 
822
671
  // --- Sentinel / result emission ---
823
672
  // Called from onExit after polling stops. The Stop-hook sentinel is read once
824
673
  // at the onExit seam (hoisted out of this delegator) so the parsed payload
825
- // drives both the D14 hook-path adoption guard AND this emission. On the
826
- // null-hookData path (ENOENT / JSON-parse failure): resultEmitted /
827
- // failureEmitted stay false and the prompt-too-long branch below fires as
828
- // today.
829
-
830
- let resultEmitted = false;
831
- let failureEmitted = false;
674
+ // drives both the D14 hook-path adoption guard AND this emission. Null-
675
+ // hookData path: the delegator no-ops, `resultEmitted` / `failureEmitted`
676
+ // stay false unless the detector fired first, and `branchTaken` records
677
+ // `no-emission` (or `prompt-rejection` if the detector fired) for the onExit
678
+ // diagnostic line.
679
+ //
680
+ // The `if (resultEmitted || failureEmitted) return;` guard below is
681
+ // load-bearing for the detector's live-accessor contract: it lets a
682
+ // detector-initiated `failureEmitted=true` short-circuit any subsequent
683
+ // Stop-hook `emitResult` so the wire carries exactly one emission.
832
684
 
833
685
  const emitResult = (hookData) => {
834
686
  if (resultEmitted || failureEmitted) return;
@@ -863,7 +715,7 @@ const stopPolling = () => {
863
715
  };
864
716
 
865
717
  transcriptPollInterval = setInterval(() => {
866
- tailTranscript();
718
+ drainTailerAndForward();
867
719
  }, 1000);
868
720
 
869
721
  // --- Signal forwarding ---
@@ -889,11 +741,6 @@ ptyProcess.onExit(({ exitCode }) => {
889
741
 
890
742
  // Clear watchdog and stop polling (sentinelPath still exists on disk at this point)
891
743
  if (watchdogTimer) { clearTimeout(watchdogTimer); watchdogTimer = null; }
892
- // As1063 Task 3: cancel any pending streaming-prompt-too-long debounce
893
- // timer — the child has exited, so either the streaming detection already
894
- // fired (failureEmitted is true and its guard would no-op) or a normal
895
- // exit path is running (do not want a late stale emission).
896
- if (promptTooLongTimer) { clearTimeout(promptTooLongTimer); promptTooLongTimer = null; }
897
744
  stopPolling();
898
745
 
899
746
  // Hoisted Stop-hook sentinel read at the onExit → pre-emitResult seam.
@@ -901,8 +748,9 @@ ptyProcess.onExit(({ exitCode }) => {
901
748
  // the D14 hook-path adoption guard below and the emitResult() delegator —
902
749
  // the extracted pty-emit-result.mjs no longer re-reads the sentinel from
903
750
  // disk. Null-hookData path: D14 guard is a no-op (`hookData?.transcript_path`
904
- // short-circuits); emitResult() no-ops on the null input; prompt-too-long
905
- // branch below fires as today (resultEmitted/failureEmitted stay false).
751
+ // short-circuits), emitResult() no-ops on the null input, and the onExit
752
+ // diagnostic below records `no-emission` (or `prompt-rejection` if the
753
+ // detector fired before onExit).
906
754
  const hookData = readSentinelPayload(sentinelPath);
907
755
 
908
756
  // D14 (guarded hook-path adoption): when the 1s polling loop never
@@ -912,57 +760,84 @@ ptyProcess.onExit(({ exitCode }) => {
912
760
  // Stop hook's `hookData.transcript_path` for the final tail. Gated on
913
761
  // `!transcriptPath` so every turn the hash convention worked for keeps
914
762
  // its pre-D14 behaviour byte-for-byte.
915
- if (hookData?.transcript_path && !transcriptPath) {
916
- const adopted = resolveTranscriptPath(hookData.transcript_path);
917
- if (adopted) {
763
+ //
764
+ // Cursor rule keyed off `resumeId` (semantic mirror of today's retired
765
+ // resume-baseline snapshot guard `if (!resumeId) return;`): a fresh
766
+ // session adopts with cursor=0 so AC-1 / AC-2 entries still forward;
767
+ // --resume adopts with cursor=size so 0 entries forward. Expressed via
768
+ // a fresh `TranscriptTailer` instance because Task 4's
769
+ // `initialiseBoundary` guard is one-way — the primary tailer's
770
+ // `boundaryEstablished` flag has already latched.
771
+ if (!DISABLE_TRANSCRIPT_TAIL && hookData?.transcript_path && !transcriptPath) {
772
+ const adopted = hookData.transcript_path;
773
+ if (existsSync(adopted)) {
774
+ transcriptPath = adopted;
918
775
  log(`Final tail using hook-supplied transcript_path (hash resolution produced nothing, hook-supplied=${adopted})`);
776
+ const d14Tailer = createTranscriptTailer({ log });
777
+ d14Tailer.initialiseBoundary(adopted, { resume: !!resumeId });
778
+ if (d14Tailer.unusable) {
779
+ notifyBoundaryUnusable('readError');
780
+ } else {
781
+ let d14Entries = [];
782
+ try {
783
+ d14Entries = d14Tailer.tick();
784
+ } catch (err) {
785
+ log(`D14 tailer.tick() threw: ${err && err.message ? err.message : String(err)}`);
786
+ }
787
+ for (const entry of d14Entries) {
788
+ try {
789
+ process.stdout.write(JSON.stringify({ type: 'transcript', entry }) + '\n');
790
+ } catch (err) {
791
+ if (err && err.code !== 'EPIPE') throw err;
792
+ }
793
+ detector.consider(entry);
794
+ if (promptRejectionEmitted) break;
795
+ }
796
+ }
797
+ d14Tailer.close();
919
798
  }
920
799
  }
921
800
 
922
- // Do a final transcript tail to capture any last entries
923
- tailTranscript();
801
+ // Final tick on the primary tailer captures anything the 1s poll's last
802
+ // interval missed. drainTailerAndForward() feeds every forwarded entry
803
+ // into detector.consider() before emitResult() runs (item 9).
804
+ drainTailerAndForward();
805
+
806
+ // Refusal emission when we truly never had a transcript to consider.
807
+ // The wrapper is the sole site that owns the `transcript-unresolved`
808
+ // refusal — the detector never observes `transcriptPath` itself
809
+ // (item 10).
810
+ if (transcriptPath === null) {
811
+ detector.noteTranscriptUnresolved();
812
+ }
924
813
 
925
814
  // Emit result or error from the pre-parsed sentinel payload (tempDir still
926
- // present — deleted below)
815
+ // present — deleted below). If the detector fired before onExit
816
+ // (`failureEmitted=true`), the guard at the top of emitResult short-
817
+ // circuits so the wire carries exactly one emission.
927
818
  emitResult(hookData);
928
819
 
929
- // Prompt-too-long detection: no sentinel written, ttyBuffer has the error text.
930
- // teardown() never ran on this path (Stop hook did not fire), so `endedAt` is
931
- // unset and `durationMs` is deliberately omitted from the emission — the
932
- // service appends the pre-existing `'0'` duration sentinel (Decision D3).
933
- // As1063 Task 1 item 9: track whether this branch was the one that set
934
- // failureEmitted so the onExit-branch diagnostic below can distinguish
935
- // `matched-prompt-too-long` from `emitted-failure` (both set the flag).
936
- let matchedPromptTooLong = false;
937
- if (!resultEmitted && !failureEmitted && ttyBuffer.includes('Prompt is too long')) {
938
- log('Prompt-too-long: omitting durationMs (teardown() never ran, endedAt unset)');
939
- process.stdout.write(JSON.stringify({
940
- type: 'error',
941
- error: 'prompt_too_long',
942
- message: 'Prompt is too long'
943
- }) + '\n');
944
- failureEmitted = true;
945
- matchedPromptTooLong = true;
946
- }
947
-
948
820
  // Clean up temp directory
949
821
  try { rmSync(tempDir, { recursive: true, force: true }); } catch {}
950
822
 
951
- // As1063 Task 1 item 9: emit which onExit branch was taken along with
952
- // the wrapper exit reason (teardownReason) and a bounded ttyBuffer tail.
953
- // Bounded per lifecycle event by the `exited` guard at the top of onExit
954
- // (no unbounded per-turn accumulation). Written to stderr via log() so
955
- // JobRunner's stderr splitter captures it into the outputTail without
956
- // affecting the wrapper's stdout emission contract.
957
- const branchTaken = resultEmitted
958
- ? 'emitted-result'
959
- : streamingPromptTooLongEmitted
960
- ? 'streaming-prompt-too-long'
961
- : matchedPromptTooLong
962
- ? 'matched-prompt-too-long'
963
- : failureEmitted
964
- ? 'emitted-failure'
965
- : 'no-emission';
823
+ // As1063 Task 1 item 9 / As1080 Task 6 item 17: closed diagnostic-label
824
+ // set is exactly `emitted-result`, `emitted-failure`, `prompt-rejection`,
825
+ // `no-emission`. `prompt-rejection` is the detector-initiated failure
826
+ // branch (`promptRejectionEmitted=true`, which implies
827
+ // `failureEmitted=true`). `emitted-failure` is the Stop-hook StopFailure
828
+ // branch. `emitted-result` is the clean-Stop success branch.
829
+ // `no-emission` is the terminal fall-through. Bounded per lifecycle event
830
+ // by the `exited` guard at the top of onExit (no unbounded per-turn
831
+ // accumulation). Written to stderr via log() so JobRunner's stderr
832
+ // splitter captures it into the outputTail without affecting the
833
+ // wrapper's stdout emission contract.
834
+ const branchTaken = promptRejectionEmitted
835
+ ? 'prompt-rejection'
836
+ : resultEmitted
837
+ ? 'emitted-result'
838
+ : failureEmitted
839
+ ? 'emitted-failure'
840
+ : 'no-emission';
966
841
  // Cap the tty tail to 256 chars and escape newlines so the line stays on
967
842
  // one log entry regardless of what claude last wrote.
968
843
  const ttyTail = ttyBuffer.slice(-256).replace(/\n/g, '\\n').replace(/\r/g, '\\r');