@kelceyp/caw-server 1.0.280 → 1.0.281
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 +436 -436
- package/dist/pty-wrapper.mjs +125 -8
- package/package.json +1 -1
package/dist/pty-wrapper.mjs
CHANGED
|
@@ -406,8 +406,33 @@ let armedAt = null;
|
|
|
406
406
|
// that eventually calls `teardown(TEARDOWN_REASONS.WATCHDOG)`. Overwritten on
|
|
407
407
|
// every deferral, so it always holds the LAST deferral's counts — that is the
|
|
408
408
|
// documented last-seen-snapshot semantic (design.md §Data on the wire).
|
|
409
|
+
//
|
|
410
|
+
// As1062 Task 9 extends the binding with a third property, `types` — a
|
|
411
|
+
// `Map<label,count>` histogram derived from the SAME `background_tasks` array
|
|
412
|
+
// the count was computed from. Normalisation collapses whitespace in friendly
|
|
413
|
+
// labels (`"MCP task"` → `MCP_task`) so the emitted `pendingBgTypes` token
|
|
414
|
+
// stays a single greppable word per label. Missing/empty types become
|
|
415
|
+
// `unknown`. Layered onto the same object rather than a parallel binding so
|
|
416
|
+
// the LAST-deferral-wins semantic covers types too.
|
|
409
417
|
let lastDeferredCounts = null;
|
|
410
418
|
|
|
419
|
+
// As1062 Task 9 — renders `lastDeferredCounts.types` (a `Map<label,count>`)
|
|
420
|
+
// into the single-token form emitted as `pendingBgTypes` on the WATCHDOG
|
|
421
|
+
// teardown line: sorted alphabetically by label for deterministic output,
|
|
422
|
+
// comma-joined `label:count` pairs, whitespace-free by construction. Returns
|
|
423
|
+
// the literal `'none'` on an empty histogram (crons-only deferral) so the
|
|
424
|
+
// rendered line reads `pendingBgTypes=none` — distinct from the splitter's
|
|
425
|
+
// `-` sentinel that flags non-watchdog reasons. Never returns `''` — the
|
|
426
|
+
// wrapper-side empty-string guard the design calls for.
|
|
427
|
+
const renderPendingBgTypes = (types) => {
|
|
428
|
+
if (!types || types.size === 0) return 'none';
|
|
429
|
+
const rendered = [...types.entries()]
|
|
430
|
+
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
|
|
431
|
+
.map(([label, count]) => `${label}:${count}`)
|
|
432
|
+
.join(',');
|
|
433
|
+
return rendered || 'none';
|
|
434
|
+
};
|
|
435
|
+
|
|
411
436
|
// Forward declarations (assigned after ptyProcess is created)
|
|
412
437
|
let ptyProcess = null;
|
|
413
438
|
|
|
@@ -473,7 +498,11 @@ const teardown = (reason = TEARDOWN_REASONS.STOP) => {
|
|
|
473
498
|
reason,
|
|
474
499
|
...(armedAt !== null ? { elapsedSinceArmMs: Date.now() - armedAt } : {}),
|
|
475
500
|
...(reason === TEARDOWN_REASONS.WATCHDOG && lastDeferredCounts !== null
|
|
476
|
-
? {
|
|
501
|
+
? {
|
|
502
|
+
pendingBgCount: lastDeferredCounts.bgCount,
|
|
503
|
+
pendingCronCount: lastDeferredCounts.cronCount,
|
|
504
|
+
pendingBgTypes: renderPendingBgTypes(lastDeferredCounts.types)
|
|
505
|
+
}
|
|
477
506
|
: {})
|
|
478
507
|
});
|
|
479
508
|
if (watchdogTimer) { clearTimeout(watchdogTimer); watchdogTimer = null; }
|
|
@@ -506,7 +535,23 @@ process.on('SIGUSR1', () => {
|
|
|
506
535
|
// As1012 Task 13 — capture BEFORE arming so the counts are already in
|
|
507
536
|
// place if the timer subsequently fires. Members named to match the
|
|
508
537
|
// locals above; the lift is a zero-translation copy.
|
|
509
|
-
|
|
538
|
+
//
|
|
539
|
+
// As1062 Task 9 — extend the same capture with a normalised `type`
|
|
540
|
+
// histogram of `background_tasks` entries so the WATCHDOG line's
|
|
541
|
+
// `pendingBgTypes=` token can carry a per-label breakdown. Normalisation
|
|
542
|
+
// (`String(entry?.type ?? '').trim().replace(/\s+/g, '_') || 'unknown'`)
|
|
543
|
+
// collapses whitespace in friendly labels (`"MCP task"` → `MCP_task`,
|
|
544
|
+
// `"auto-mode scan"` → `auto-mode_scan`) so the emitted token stays
|
|
545
|
+
// greppable as one word per label, and rescues missing/empty types to
|
|
546
|
+
// `unknown` rather than dropping them silently.
|
|
547
|
+
const types = new Map();
|
|
548
|
+
if (Array.isArray(payload.background_tasks)) {
|
|
549
|
+
for (const entry of payload.background_tasks) {
|
|
550
|
+
const label = String(entry?.type ?? '').trim().replace(/\s+/g, '_') || 'unknown';
|
|
551
|
+
types.set(label, (types.get(label) ?? 0) + 1);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
lastDeferredCounts = { bgCount, cronCount, types };
|
|
510
555
|
startOrResetWatchdog();
|
|
511
556
|
return;
|
|
512
557
|
}
|
|
@@ -546,7 +591,7 @@ ptyProcess = pty.spawn(claudePath, claudeArgs, {
|
|
|
546
591
|
cols: 120,
|
|
547
592
|
rows: 40,
|
|
548
593
|
cwd: process.cwd(),
|
|
549
|
-
env: { ...process.env, CLAUBBIT: '1' }
|
|
594
|
+
env: { ...process.env, CLAUBBIT: '1', CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS: '0' }
|
|
550
595
|
});
|
|
551
596
|
|
|
552
597
|
log(`PID: ${ptyProcess.pid}`);
|
|
@@ -558,6 +603,61 @@ writeFileSync(claudePidPath, String(ptyProcess.pid));
|
|
|
558
603
|
|
|
559
604
|
let ttyBuffer = '';
|
|
560
605
|
let permissionConfirmed = false;
|
|
606
|
+
|
|
607
|
+
// As1063 Task 3 item 5: streaming prompt-too-long detection (mechanism (d) fix).
|
|
608
|
+
//
|
|
609
|
+
// Named mechanism from Task 1 evidence ([Aj20482 trace](Ad84181)): Claude Code
|
|
610
|
+
// CLI treats "Prompt is too long" from the Anthropic API as an assistant-message
|
|
611
|
+
// response — it writes the text to the transcript, prints it to the tty, and
|
|
612
|
+
// awaits the next user input rather than exiting. No Stop hook fires (this is
|
|
613
|
+
// not a normal completion). The wrapper's onExit-side detection at :822 depends
|
|
614
|
+
// on the child exiting, so it never runs. This streaming detection catches the
|
|
615
|
+
// phrase during onData and forces the pty child to exit via SIGTERM, which then
|
|
616
|
+
// drives the existing onExit path (emitResult / prompt-too-long fallback /
|
|
617
|
+
// process exit) unchanged.
|
|
618
|
+
//
|
|
619
|
+
// Debounce (500 ms of tty silence after the phrase is seen) reduces the
|
|
620
|
+
// false-positive risk of a legitimate Claude response that mentions the
|
|
621
|
+
// phrase mid-stream — a real response keeps writing (streaming JSON events,
|
|
622
|
+
// further text) and pushes the timer out; only an idle tty — the observed
|
|
623
|
+
// prompt-too-long signature — lets the timer fire. `seenPromptTooLong` is
|
|
624
|
+
// persistent because `ttyBuffer` is trimmed to the last 5000 chars on
|
|
625
|
+
// overflow, and we do not want to lose the signal to a later burst of output.
|
|
626
|
+
// Budget rationale: the observed prod trace (Aj20482) had no post-phrase tty
|
|
627
|
+
// output for 5+ minutes; 500 ms is comfortably above any streaming inter-chunk
|
|
628
|
+
// gap for a real turn and well below any human-perceptible terminal-transition
|
|
629
|
+
// latency erosion (AC5).
|
|
630
|
+
//
|
|
631
|
+
// `CAW_PTY_PROMPT_TOO_LONG_DEBOUNCE_MS` overrides the default (test-only
|
|
632
|
+
// configuration knob per Task 3 item 17 — fault-injection for the pre-fix /
|
|
633
|
+
// post-fix pairing regression test; production leaves the default in place).
|
|
634
|
+
const PROMPT_TOO_LONG_DEBOUNCE_MS = (() => {
|
|
635
|
+
const raw = process.env.CAW_PTY_PROMPT_TOO_LONG_DEBOUNCE_MS;
|
|
636
|
+
if (!raw) return 500;
|
|
637
|
+
const parsed = parseInt(raw, 10);
|
|
638
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 500;
|
|
639
|
+
})();
|
|
640
|
+
let seenPromptTooLong = false;
|
|
641
|
+
let streamingPromptTooLongEmitted = false;
|
|
642
|
+
let promptTooLongTimer = null;
|
|
643
|
+
const schedulePromptTooLongEmit = () => {
|
|
644
|
+
if (promptTooLongTimer) clearTimeout(promptTooLongTimer);
|
|
645
|
+
promptTooLongTimer = setTimeout(() => {
|
|
646
|
+
promptTooLongTimer = null;
|
|
647
|
+
if (resultEmitted || failureEmitted) return;
|
|
648
|
+
log('Prompt-too-long: detected in streaming path — emitting failure and killing pty');
|
|
649
|
+
process.stdout.write(JSON.stringify({
|
|
650
|
+
type: 'error',
|
|
651
|
+
error: 'prompt_too_long',
|
|
652
|
+
message: 'Prompt is too long'
|
|
653
|
+
}) + '\n');
|
|
654
|
+
failureEmitted = true;
|
|
655
|
+
streamingPromptTooLongEmitted = true;
|
|
656
|
+
// Kill the pty so onExit fires and the wrapper shuts down cleanly.
|
|
657
|
+
try { ptyProcess.kill('SIGTERM'); } catch { /* ignore */ }
|
|
658
|
+
}, PROMPT_TOO_LONG_DEBOUNCE_MS);
|
|
659
|
+
};
|
|
660
|
+
|
|
561
661
|
ptyProcess.onData((data) => {
|
|
562
662
|
ttyBuffer += data;
|
|
563
663
|
if (!permissionConfirmed && ttyBuffer.includes('Yes') && ttyBuffer.includes('No')) {
|
|
@@ -566,6 +666,16 @@ ptyProcess.onData((data) => {
|
|
|
566
666
|
setTimeout(() => ptyProcess.write('\r'), 200); // Enter
|
|
567
667
|
permissionConfirmed = true;
|
|
568
668
|
}
|
|
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
|
+
}
|
|
569
679
|
// Keep buffer bounded
|
|
570
680
|
if (ttyBuffer.length > 10000) ttyBuffer = ttyBuffer.slice(-5000);
|
|
571
681
|
});
|
|
@@ -779,6 +889,11 @@ ptyProcess.onExit(({ exitCode }) => {
|
|
|
779
889
|
|
|
780
890
|
// Clear watchdog and stop polling (sentinelPath still exists on disk at this point)
|
|
781
891
|
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; }
|
|
782
897
|
stopPolling();
|
|
783
898
|
|
|
784
899
|
// Hoisted Stop-hook sentinel read at the onExit → pre-emitResult seam.
|
|
@@ -841,11 +956,13 @@ ptyProcess.onExit(({ exitCode }) => {
|
|
|
841
956
|
// affecting the wrapper's stdout emission contract.
|
|
842
957
|
const branchTaken = resultEmitted
|
|
843
958
|
? 'emitted-result'
|
|
844
|
-
:
|
|
845
|
-
? '
|
|
846
|
-
:
|
|
847
|
-
? '
|
|
848
|
-
:
|
|
959
|
+
: streamingPromptTooLongEmitted
|
|
960
|
+
? 'streaming-prompt-too-long'
|
|
961
|
+
: matchedPromptTooLong
|
|
962
|
+
? 'matched-prompt-too-long'
|
|
963
|
+
: failureEmitted
|
|
964
|
+
? 'emitted-failure'
|
|
965
|
+
: 'no-emission';
|
|
849
966
|
// Cap the tty tail to 256 chars and escape newlines so the line stays on
|
|
850
967
|
// one log entry regardless of what claude last wrote.
|
|
851
968
|
const ttyTail = ttyBuffer.slice(-256).replace(/\n/g, '\\n').replace(/\r/g, '\\r');
|