@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.
@@ -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
- ? { pendingBgCount: lastDeferredCounts.bgCount, pendingCronCount: lastDeferredCounts.cronCount }
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
- lastDeferredCounts = { bgCount, cronCount };
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
- : matchedPromptTooLong
845
- ? 'matched-prompt-too-long'
846
- : failureEmitted
847
- ? 'emitted-failure'
848
- : 'no-emission';
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');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kelceyp/caw-server",
3
- "version": "1.0.280",
3
+ "version": "1.0.281",
4
4
  "description": "CAW server - REST API, MCP server and core features",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",