@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.
- package/dist/main.js +542 -499
- 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 +396 -404
- 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
|
|
|
@@ -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
|
-
? {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
575
|
-
//
|
|
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
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
//
|
|
584
|
-
//
|
|
585
|
-
//
|
|
586
|
-
//
|
|
587
|
-
//
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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
|
-
|
|
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
|
|
620
|
-
try {
|
|
621
|
-
|
|
622
|
-
|
|
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
|
|
625
|
-
if (existsSync(
|
|
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
|
-
|
|
551
|
+
const hash = candidates[0].replace(/[/.]/g, '-');
|
|
552
|
+
return join(claudeProjectsDir, hash, `${effectiveSessionId}.jsonl`);
|
|
636
553
|
};
|
|
637
554
|
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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
|
-
|
|
668
|
-
|
|
669
|
-
|
|
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
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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(`
|
|
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.
|
|
716
|
-
//
|
|
717
|
-
//
|
|
718
|
-
//
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
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
|
-
|
|
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)
|
|
790
|
-
//
|
|
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
|
-
|
|
801
|
-
|
|
802
|
-
|
|
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
|
-
//
|
|
808
|
-
|
|
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
|
|
837
|
-
//
|
|
838
|
-
//
|
|
839
|
-
// (
|
|
840
|
-
//
|
|
841
|
-
//
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
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';
|