@kelceyp/caw-server 1.0.281 → 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.
- package/dist/main.js +509 -466
- package/dist/prompt-rejection-detector.mjs +158 -0
- package/dist/pty-emit-result.mjs +5 -2
- package/dist/pty-non-interactive-setup.mjs +307 -0
- package/dist/pty-wrapper.mjs +347 -472
- package/dist/public_html/main.js +374 -374
- package/dist/transcript-tailer.mjs +206 -0
- package/package.json +2 -2
package/dist/pty-wrapper.mjs
CHANGED
|
@@ -19,13 +19,16 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import * as pty from 'node-pty';
|
|
22
|
-
import { mkdtempSync, readFileSync, existsSync, writeFileSync, rmSync, realpathSync
|
|
23
|
-
import { join
|
|
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
|
-
//
|
|
82
|
-
//
|
|
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
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
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
|
|
114
|
-
// `
|
|
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
|
-
//
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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
|
|
363
|
-
//
|
|
364
|
-
// carries no `transcript` lines
|
|
365
|
-
//
|
|
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).
|
|
384
|
-
//
|
|
385
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
620
|
-
//
|
|
621
|
-
//
|
|
622
|
-
//
|
|
623
|
-
//
|
|
624
|
-
//
|
|
625
|
-
//
|
|
626
|
-
//
|
|
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
|
-
// `
|
|
632
|
-
//
|
|
633
|
-
//
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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
|
-
//
|
|
685
|
-
//
|
|
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
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
//
|
|
694
|
-
//
|
|
695
|
-
//
|
|
696
|
-
//
|
|
697
|
-
//
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
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
|
-
|
|
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
|
|
730
|
-
try {
|
|
731
|
-
|
|
732
|
-
|
|
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
|
|
735
|
-
if (existsSync(
|
|
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
|
-
|
|
551
|
+
const hash = candidates[0].replace(/[/.]/g, '-');
|
|
552
|
+
return join(claudeProjectsDir, hash, `${effectiveSessionId}.jsonl`);
|
|
746
553
|
};
|
|
747
554
|
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
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
|
-
|
|
778
|
-
|
|
779
|
-
|
|
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
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
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
|
-
|
|
813
|
-
|
|
814
|
-
|
|
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(`
|
|
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.
|
|
826
|
-
//
|
|
827
|
-
//
|
|
828
|
-
//
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
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
|
-
|
|
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)
|
|
905
|
-
//
|
|
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
|
-
|
|
916
|
-
|
|
917
|
-
|
|
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
|
-
//
|
|
923
|
-
|
|
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
|
|
952
|
-
//
|
|
953
|
-
//
|
|
954
|
-
// (
|
|
955
|
-
//
|
|
956
|
-
//
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
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');
|