@kelceyp/caw-server 1.0.280 → 1.0.282

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
 
@@ -406,8 +244,33 @@ let armedAt = null;
406
244
  // that eventually calls `teardown(TEARDOWN_REASONS.WATCHDOG)`. Overwritten on
407
245
  // every deferral, so it always holds the LAST deferral's counts — that is the
408
246
  // documented last-seen-snapshot semantic (design.md §Data on the wire).
247
+ //
248
+ // As1062 Task 9 extends the binding with a third property, `types` — a
249
+ // `Map<label,count>` histogram derived from the SAME `background_tasks` array
250
+ // the count was computed from. Normalisation collapses whitespace in friendly
251
+ // labels (`"MCP task"` → `MCP_task`) so the emitted `pendingBgTypes` token
252
+ // stays a single greppable word per label. Missing/empty types become
253
+ // `unknown`. Layered onto the same object rather than a parallel binding so
254
+ // the LAST-deferral-wins semantic covers types too.
409
255
  let lastDeferredCounts = null;
410
256
 
257
+ // As1062 Task 9 — renders `lastDeferredCounts.types` (a `Map<label,count>`)
258
+ // into the single-token form emitted as `pendingBgTypes` on the WATCHDOG
259
+ // teardown line: sorted alphabetically by label for deterministic output,
260
+ // comma-joined `label:count` pairs, whitespace-free by construction. Returns
261
+ // the literal `'none'` on an empty histogram (crons-only deferral) so the
262
+ // rendered line reads `pendingBgTypes=none` — distinct from the splitter's
263
+ // `-` sentinel that flags non-watchdog reasons. Never returns `''` — the
264
+ // wrapper-side empty-string guard the design calls for.
265
+ const renderPendingBgTypes = (types) => {
266
+ if (!types || types.size === 0) return 'none';
267
+ const rendered = [...types.entries()]
268
+ .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
269
+ .map(([label, count]) => `${label}:${count}`)
270
+ .join(',');
271
+ return rendered || 'none';
272
+ };
273
+
411
274
  // Forward declarations (assigned after ptyProcess is created)
412
275
  let ptyProcess = null;
413
276
 
@@ -473,7 +336,11 @@ const teardown = (reason = TEARDOWN_REASONS.STOP) => {
473
336
  reason,
474
337
  ...(armedAt !== null ? { elapsedSinceArmMs: Date.now() - armedAt } : {}),
475
338
  ...(reason === TEARDOWN_REASONS.WATCHDOG && lastDeferredCounts !== null
476
- ? { pendingBgCount: lastDeferredCounts.bgCount, pendingCronCount: lastDeferredCounts.cronCount }
339
+ ? {
340
+ pendingBgCount: lastDeferredCounts.bgCount,
341
+ pendingCronCount: lastDeferredCounts.cronCount,
342
+ pendingBgTypes: renderPendingBgTypes(lastDeferredCounts.types)
343
+ }
477
344
  : {})
478
345
  });
479
346
  if (watchdogTimer) { clearTimeout(watchdogTimer); watchdogTimer = null; }
@@ -486,7 +353,7 @@ const teardown = (reason = TEARDOWN_REASONS.STOP) => {
486
353
 
487
354
  process.on('SIGUSR1', () => {
488
355
  log('Received SIGUSR1 (stop signal)');
489
- tailTranscript();
356
+ drainTailerAndForward();
490
357
  const payload = readSentinelPayload(sentinelPath);
491
358
  if (!payload) {
492
359
  log(`Warning: failed to read sentinel, proceeding with teardown`);
@@ -506,7 +373,23 @@ process.on('SIGUSR1', () => {
506
373
  // As1012 Task 13 — capture BEFORE arming so the counts are already in
507
374
  // place if the timer subsequently fires. Members named to match the
508
375
  // locals above; the lift is a zero-translation copy.
509
- lastDeferredCounts = { bgCount, cronCount };
376
+ //
377
+ // As1062 Task 9 — extend the same capture with a normalised `type`
378
+ // histogram of `background_tasks` entries so the WATCHDOG line's
379
+ // `pendingBgTypes=` token can carry a per-label breakdown. Normalisation
380
+ // (`String(entry?.type ?? '').trim().replace(/\s+/g, '_') || 'unknown'`)
381
+ // collapses whitespace in friendly labels (`"MCP task"` → `MCP_task`,
382
+ // `"auto-mode scan"` → `auto-mode_scan`) so the emitted token stays
383
+ // greppable as one word per label, and rescues missing/empty types to
384
+ // `unknown` rather than dropping them silently.
385
+ const types = new Map();
386
+ if (Array.isArray(payload.background_tasks)) {
387
+ for (const entry of payload.background_tasks) {
388
+ const label = String(entry?.type ?? '').trim().replace(/\s+/g, '_') || 'unknown';
389
+ types.set(label, (types.get(label) ?? 0) + 1);
390
+ }
391
+ }
392
+ lastDeferredCounts = { bgCount, cronCount, types };
510
393
  startOrResetWatchdog();
511
394
  return;
512
395
  }
@@ -533,7 +416,12 @@ if (effortLevel) {
533
416
  claudeArgs.push('--effort', effortLevel);
534
417
  }
535
418
  claudeArgs.push('--settings', settingsPath);
536
- 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);
537
425
 
538
426
  log(`Session: ${effectiveSessionId}`);
539
427
  log(`Prompt length: ${prompt.length} chars`);
@@ -546,7 +434,7 @@ ptyProcess = pty.spawn(claudePath, claudeArgs, {
546
434
  cols: 120,
547
435
  rows: 40,
548
436
  cwd: process.cwd(),
549
- env: { ...process.env, CLAUBBIT: '1' }
437
+ env: { ...process.env, CLAUBBIT: '1', CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS: '0' }
550
438
  });
551
439
 
552
440
  log(`PID: ${ptyProcess.pid}`);
@@ -558,6 +446,27 @@ writeFileSync(claudePidPath, String(ptyProcess.pid));
558
446
 
559
447
  let ttyBuffer = '';
560
448
  let permissionConfirmed = false;
449
+
450
+ // onData responsibilities after As1080 Increment A:
451
+ //
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.
460
+ //
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'`).
469
+
561
470
  ptyProcess.onData((data) => {
562
471
  ttyBuffer += data;
563
472
  if (!permissionConfirmed && ttyBuffer.includes('Yes') && ttyBuffer.includes('No')) {
@@ -570,155 +479,208 @@ ptyProcess.onData((data) => {
570
479
  if (ttyBuffer.length > 10000) ttyBuffer = ttyBuffer.slice(-5000);
571
480
  });
572
481
 
573
- // --- Transcript tailing ---
574
- // Claude writes session transcript to ~/.claude/projects/<cwd-hash>/<session-id>.jsonl.
575
- // 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).
576
515
 
577
516
  let transcriptPath = null;
578
- let transcriptLinesRead = 0;
579
517
  let transcriptPathResolutionAttempts = 0;
580
- const processedEntryUUIDs = new Set();
581
- let lastReadEntryUUID = null;
582
-
583
- // Snapshot the pre-existing line count on --resume so only new content streams.
584
- // Shared by the cwd-hash-hit branch and the D14 hook-path adoption branch —
585
- // keeping the snapshot in one place avoids duplicating the readFileSync/count
586
- // logic and satisfies the checklist rule ("Do not duplicate the snapshot
587
- // logic into the D14 branch").
588
- const snapshotResumeBaseline = (path) => {
589
- if (!resumeId) return;
590
- try {
591
- const content = readFileSync(path, 'utf-8');
592
- transcriptLinesRead = content.trim().split('\n').filter(Boolean).length;
593
- log(`Resume: skipping ${transcriptLinesRead} existing transcript lines`);
594
- } catch { /* ignore */ }
595
- };
596
-
597
- // Resolve transcript path, handling macOS /tmp → /private/tmp symlinks.
598
- // Returns null if not yet found (retried on next poll).
599
- //
600
- // D14 (guarded hook-path adoption): callers may pass an `overridePath` — the
601
- // Stop hook's `hookData.transcript_path`. When supplied, the function bypasses
602
- // the cwd-hash candidate search and adopts the override path directly (after
603
- // an existsSync guard). The caller must already have gated on `!transcriptPath`
604
- // so this is only used when the polling loop never memoised a path — which
605
- // preserves today's behaviour on every turn the hash convention worked for.
606
- const resolveTranscriptPath = (overridePath = null) => {
607
- if (!overridePath && transcriptPath) return transcriptPath;
608
-
609
- if (overridePath) {
610
- if (!existsSync(overridePath)) return null;
611
- transcriptPath = overridePath;
612
- snapshotResumeBaseline(overridePath);
613
- return transcriptPath;
614
- }
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;
615
529
 
616
- 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 = () => {
617
535
  const claudeProjectsDir = join(homedir(), '.claude', 'projects');
618
536
  const cwd = process.cwd();
619
- const paths = new Set([cwd]);
620
- try { paths.add(realpathSync(cwd)); } catch { /* ignore */ }
621
-
622
- 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) {
623
547
  const hash = dir.replace(/[/.]/g, '-');
624
- const candidate = join(claudeProjectsDir, hash, `${effectiveSessionId}.jsonl`);
625
- if (existsSync(candidate)) {
626
- transcriptPath = candidate;
627
- log(`Transcript found (attempt ${transcriptPathResolutionAttempts}): ${transcriptPath}`);
628
- snapshotResumeBaseline(candidate);
629
- return transcriptPath;
630
- }
631
- }
632
- if (transcriptPathResolutionAttempts === 1 || transcriptPathResolutionAttempts % 10 === 0) {
633
- log(`Transcript not found (attempt ${transcriptPathResolutionAttempts}), cwd=${cwd}, session=${effectiveSessionId}`);
548
+ const p = join(claudeProjectsDir, hash, `${effectiveSessionId}.jsonl`);
549
+ if (existsSync(p)) return p;
634
550
  }
635
- return null;
551
+ const hash = candidates[0].replace(/[/.]/g, '-');
552
+ return join(claudeProjectsDir, hash, `${effectiveSessionId}.jsonl`);
636
553
  };
637
554
 
638
- // Read new transcript entries and forward to stdout. Returns count of new lines emitted.
639
- const tailTranscript = () => {
640
- // D11 test-only override: skip all tail work so wrapper stdout carries no
641
- // `transcript` lines. Both the 1s polling loop's calls and the final
642
- // onExit call short-circuit here — the fallback-consumption case downstream
643
- // (Task 5) needs a wire with no transcript stream at all.
644
- if (DISABLE_TRANSCRIPT_TAIL) return 0;
645
- const path = resolveTranscriptPath();
646
- if (!path) return 0;
647
-
648
- let newLines = 0;
649
- try {
650
- const content = readFileSync(path, 'utf-8');
651
- const lines = content.trim().split('\n').filter(Boolean);
652
- const fileLineCount = lines.length;
653
-
654
- if (fileLineCount < transcriptLinesRead) {
655
- log(`tailTranscript: FILE SHRINK — transcriptLinesRead=${transcriptLinesRead}, fileLines=${fileLineCount} (file replaced or truncated), resetting to 0`);
656
- transcriptLinesRead = 0;
657
- } else if (transcriptLinesRead > 0 && lastReadEntryUUID) {
658
- try {
659
- const checkEntry = JSON.parse(lines[transcriptLinesRead - 1]);
660
- if (checkEntry.uuid && checkEntry.uuid !== lastReadEntryUUID) {
661
- log(`tailTranscript: FILE REPLACED — UUID mismatch at position ${transcriptLinesRead - 1} (expected ${lastReadEntryUUID}, got ${checkEntry.uuid}), resetting to 0`);
662
- transcriptLinesRead = 0;
663
- }
664
- } catch { /* parse error during write — skip validation this poll */ }
665
- }
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
+ };
666
570
 
667
- const pending = fileLineCount - transcriptLinesRead;
668
- if (pending > 0) {
669
- 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;
670
583
  }
584
+ },
585
+ tailer,
586
+ });
671
587
 
672
- const typeCounts = {};
673
- let skipped = 0;
674
- for (let i = transcriptLinesRead; i < lines.length; i++) {
675
- try {
676
- const entry = JSON.parse(lines[i]);
677
- if (entry.type === 'progress' && entry.data?.type === 'hook_progress') {
678
- skipped++;
679
- continue;
680
- }
681
- if (entry.uuid && processedEntryUUIDs.has(entry.uuid)) {
682
- skipped++;
683
- continue;
684
- }
685
- process.stdout.write(JSON.stringify({ type: 'transcript', entry }) + '\n');
686
- if (entry.uuid) processedEntryUUIDs.add(entry.uuid);
687
- newLines++;
688
- const key = entry.type || 'unknown';
689
- typeCounts[key] = (typeCounts[key] || 0) + 1;
690
- } catch {
691
- skipped++;
692
- }
693
- }
694
- transcriptLinesRead = lines.length;
695
- if (lines.length > 0) {
696
- try {
697
- const lastEntry = JSON.parse(lines[lines.length - 1]);
698
- if (lastEntry.uuid) lastReadEntryUUID = lastEntry.uuid;
699
- } 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}`);
700
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
+ }
701
613
 
702
- if (newLines > 0 || skipped > 0) {
703
- const types = Object.entries(typeCounts).map(([k, v]) => `${k}=${v}`).join(', ');
704
- 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}`);
705
636
  }
637
+ }
638
+ const preUnusable = tailer.unusable;
639
+ let entries = [];
640
+ try {
641
+ entries = tailer.tick();
706
642
  } catch (err) {
707
- 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
+ }
708
668
  }
709
- return newLines;
710
669
  };
711
670
 
712
671
  // --- Sentinel / result emission ---
713
672
  // Called from onExit after polling stops. The Stop-hook sentinel is read once
714
673
  // at the onExit seam (hoisted out of this delegator) so the parsed payload
715
- // drives both the D14 hook-path adoption guard AND this emission. On the
716
- // null-hookData path (ENOENT / JSON-parse failure): resultEmitted /
717
- // failureEmitted stay false and the prompt-too-long branch below fires as
718
- // today.
719
-
720
- let resultEmitted = false;
721
- 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.
722
684
 
723
685
  const emitResult = (hookData) => {
724
686
  if (resultEmitted || failureEmitted) return;
@@ -753,7 +715,7 @@ const stopPolling = () => {
753
715
  };
754
716
 
755
717
  transcriptPollInterval = setInterval(() => {
756
- tailTranscript();
718
+ drainTailerAndForward();
757
719
  }, 1000);
758
720
 
759
721
  // --- Signal forwarding ---
@@ -786,8 +748,9 @@ ptyProcess.onExit(({ exitCode }) => {
786
748
  // the D14 hook-path adoption guard below and the emitResult() delegator —
787
749
  // the extracted pty-emit-result.mjs no longer re-reads the sentinel from
788
750
  // disk. Null-hookData path: D14 guard is a no-op (`hookData?.transcript_path`
789
- // short-circuits); emitResult() no-ops on the null input; prompt-too-long
790
- // 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).
791
754
  const hookData = readSentinelPayload(sentinelPath);
792
755
 
793
756
  // D14 (guarded hook-path adoption): when the 1s polling loop never
@@ -797,52 +760,81 @@ ptyProcess.onExit(({ exitCode }) => {
797
760
  // Stop hook's `hookData.transcript_path` for the final tail. Gated on
798
761
  // `!transcriptPath` so every turn the hash convention worked for keeps
799
762
  // its pre-D14 behaviour byte-for-byte.
800
- if (hookData?.transcript_path && !transcriptPath) {
801
- const adopted = resolveTranscriptPath(hookData.transcript_path);
802
- 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;
803
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();
804
798
  }
805
799
  }
806
800
 
807
- // Do a final transcript tail to capture any last entries
808
- 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
+ }
809
813
 
810
814
  // Emit result or error from the pre-parsed sentinel payload (tempDir still
811
- // 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.
812
818
  emitResult(hookData);
813
819
 
814
- // Prompt-too-long detection: no sentinel written, ttyBuffer has the error text.
815
- // teardown() never ran on this path (Stop hook did not fire), so `endedAt` is
816
- // unset and `durationMs` is deliberately omitted from the emission — the
817
- // service appends the pre-existing `'0'` duration sentinel (Decision D3).
818
- // As1063 Task 1 item 9: track whether this branch was the one that set
819
- // failureEmitted so the onExit-branch diagnostic below can distinguish
820
- // `matched-prompt-too-long` from `emitted-failure` (both set the flag).
821
- let matchedPromptTooLong = false;
822
- if (!resultEmitted && !failureEmitted && ttyBuffer.includes('Prompt is too long')) {
823
- log('Prompt-too-long: omitting durationMs (teardown() never ran, endedAt unset)');
824
- process.stdout.write(JSON.stringify({
825
- type: 'error',
826
- error: 'prompt_too_long',
827
- message: 'Prompt is too long'
828
- }) + '\n');
829
- failureEmitted = true;
830
- matchedPromptTooLong = true;
831
- }
832
-
833
820
  // Clean up temp directory
834
821
  try { rmSync(tempDir, { recursive: true, force: true }); } catch {}
835
822
 
836
- // As1063 Task 1 item 9: emit which onExit branch was taken along with
837
- // the wrapper exit reason (teardownReason) and a bounded ttyBuffer tail.
838
- // Bounded per lifecycle event by the `exited` guard at the top of onExit
839
- // (no unbounded per-turn accumulation). Written to stderr via log() so
840
- // JobRunner's stderr splitter captures it into the outputTail without
841
- // affecting the wrapper's stdout emission contract.
842
- const branchTaken = resultEmitted
843
- ? 'emitted-result'
844
- : matchedPromptTooLong
845
- ? 'matched-prompt-too-long'
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'
846
838
  : failureEmitted
847
839
  ? 'emitted-failure'
848
840
  : 'no-emission';