pi-crew 0.9.9 → 0.9.10

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.
Files changed (35) hide show
  1. package/CHANGELOG.md +278 -0
  2. package/docs/fixes/v0.9.10/locks-fix-verify.md +3 -0
  3. package/docs/fixes/v0.9.10/smoke-test.md +12 -0
  4. package/package.json +1 -1
  5. package/src/extension/team-tool/doctor.ts +41 -18
  6. package/src/runtime/child-pi.ts +122 -22
  7. package/src/runtime/compact-pipeline.ts +56 -0
  8. package/src/runtime/compact-stages/ansi-strip-stage.ts +25 -0
  9. package/src/runtime/compact-stages/blank-collapse-stage.ts +31 -0
  10. package/src/runtime/compact-stages/deduplicate-stage.ts +34 -0
  11. package/src/runtime/compact-stages/head-snap-stage.ts +57 -0
  12. package/src/runtime/compact-stages/index.ts +13 -0
  13. package/src/runtime/compact-stages/tail-capture-stage.ts +72 -0
  14. package/src/runtime/compact-stages/truncation-stage.ts +71 -0
  15. package/src/runtime/handoff-manager.ts +10 -0
  16. package/src/runtime/important-line-classifier.ts +130 -0
  17. package/src/runtime/iteration-hooks.ts +7 -19
  18. package/src/runtime/live-session-runtime.ts +50 -1
  19. package/src/runtime/model-fallback.ts +29 -1
  20. package/src/runtime/role-permission.ts +2 -2
  21. package/src/runtime/stream-preview.ts +9 -2
  22. package/src/runtime/task-output-context.ts +161 -27
  23. package/src/runtime/task-runner.ts +76 -15
  24. package/src/state/locks.ts +16 -0
  25. package/src/state/state-store.ts +8 -2
  26. package/src/ui/live-run-sidebar.ts +6 -1
  27. package/src/ui/loaders.ts +24 -4
  28. package/src/ui/run-dashboard.ts +6 -1
  29. package/src/ui/run-event-bus.ts +1 -1
  30. package/src/ui/run-snapshot-cache.ts +50 -16
  31. package/src/ui/widget/index.ts +27 -5
  32. package/src/ui/widget/widget-renderer.ts +43 -13
  33. package/src/utils/redaction.ts +17 -1
  34. package/src/utils/visual.ts +6 -0
  35. package/src/ui/crew-widget.ts +0 -544
@@ -1,5 +1,6 @@
1
1
  import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process";
2
2
  import * as fs from "node:fs";
3
+ import * as os from "node:os";
3
4
  import * as path from "node:path";
4
5
  import { WINDOWS_ESSENTIAL_ENV_VARS } from "../utils/env-allowlist.ts";
5
6
  import type { AgentConfig } from "../agents/agent-config.ts";
@@ -9,7 +10,9 @@ import { getPiSpawnCommand } from "./pi-spawn.ts";
9
10
  import { DEFAULT_CHILD_PI } from "../config/defaults.ts";
10
11
  import { logInternalError } from "../utils/internal-error.ts";
11
12
  import { attachPostExitStdioGuard, trySignalChild } from "./post-exit-stdio-guard.ts";
12
- import { redactJsonLine } from "../utils/redaction.ts";
13
+ import { redactJsonLine, redactSecretString } from "../utils/redaction.ts";
14
+ import { applyCompactPipeline } from "./compact-pipeline.ts";
15
+ import { TruncationStage, TailCaptureStage } from "./compact-stages/index.ts";
13
16
  import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
14
17
  import { registerChildProcess, unregisterChildProcess } from "../extension/crew-cleanup.ts";
15
18
  import { classifyProcessCrash } from "./crash-classification.ts";
@@ -27,12 +30,33 @@ const MAX_COMPACT_CONTENT_CHARS = DEFAULT_CHILD_PI.maxCompactContentChars;
27
30
  const activeChildProcesses = new Map<number, ChildProcess>();
28
31
  const childHardKillTimers = new Map<number, NodeJS.Timeout>();
29
32
 
33
+ /**
34
+ * SEC-1: Extract a redacted stderr/stdout excerpt for embedding in lifecycle
35
+ * events and error messages. The in-memory stdout/stderr accumulators receive
36
+ * RAW worker output (only structurally compacted via compactChildPiEvent —
37
+ * NOT secret-redacted), so any slice embedded into a persisted event must be
38
+ * redacted here. Otherwise worker-emitted secrets (API keys, tokens returned
39
+ * from a tool call) leak through diagnostic logs that bypass artifact-store
40
+ * redaction.
41
+ *
42
+ * Extracted as a single helper (8 call sites were duplicating this) so the
43
+ * redaction boundary is unit-testable directly. The real spawn error/timeout
44
+ * paths are integration-level and NOT reachable via PI_TEAMS_MOCK_CHILD_PI
45
+ * (the mock returns before the lifecycle-event handlers run), so a behavior
46
+ * test must target this helper rather than the full runChildPi path.
47
+ */
48
+ export function redactStderrExcerpt(stderr: string, maxChars: number): string {
49
+ return redactSecretString(stderr.slice(-maxChars));
50
+ }
51
+
30
52
  function appendBoundedTail(current: string, chunk: string, maxBytes = MAX_CAPTURE_BYTES): string {
31
- const combined = current + chunk;
32
- if (Buffer.byteLength(combined, "utf-8") <= maxBytes) return combined;
33
- let tail = combined.slice(Math.max(0, combined.length - maxBytes));
34
- while (Buffer.byteLength(tail, "utf-8") > maxBytes) tail = tail.slice(1024);
35
- return `[pi-crew captured output truncated to last ${Math.round(maxBytes / 1024)} KiB]\n${tail}`;
53
+ // Sprint 5: refactored onto TailCaptureStage (P0-A stage-chain). The marker
54
+ // embeds the cap size in KiB so the caller sees how much was dropped. Stage
55
+ // construction per call is cheap (4 fields) and avoids caching concerns.
56
+ return new TailCaptureStage({
57
+ maxBytes,
58
+ marker: `[pi-crew captured output truncated to last ${Math.round(maxBytes / 1024)} KiB]`,
59
+ }).apply(current + chunk);
36
60
  }
37
61
 
38
62
  function clearHardKillTimer(pid: number | undefined): void {
@@ -379,32 +403,56 @@ function appendTranscript(input: ChildPiRunInput, line: string): void {
379
403
  }
380
404
  }
381
405
 
382
- function compactString(value: string, maxChars = MAX_COMPACT_CONTENT_CHARS): string {
406
+ export function compactString(
407
+ value: string,
408
+ maxChars = MAX_COMPACT_CONTENT_CHARS,
409
+ opts: { preserveImportant?: boolean } = {},
410
+ ): string {
383
411
  if (value.length <= maxChars) return value;
384
412
  // L4: head + tail instead of head-only. Keeps closing markdown structure
385
413
  // (code fences, headings, list tails) instead of dropping them — the old
386
414
  // head-only slice left unclosed ``` fences that downstream parsers and
387
415
  // output-validator.ts flagged as "output may be truncated". Head gets 75%
388
416
  // (opening structure + bulk of content); tail gets 25% (closing structure).
389
- const head = Math.floor(maxChars * 0.75);
390
- const tail = maxChars - head;
391
- return `${value.slice(0, head)}\n...[pi-crew compacted ${value.length - maxChars} chars, head+tail preserved]...\n${value.slice(-tail)}`;
417
+ // P0-A: compose the value through the stage-chain compression pipeline.
418
+ // The default pipeline is just [TruncationStage] (single-stage, equivalent
419
+ // to the pre-P0-A implementation) so plain text with no ANSI / no blank
420
+ // runs / no consecutive duplicates produces bit-identical output (L4
421
+ // regression safety). Callers that want noise stripping can opt into
422
+ // additional stages via the pipeline — but compactString's caller surface
423
+ // keeps the simple `(value, maxChars, opts)` signature.
424
+ // P0-B: the TruncationStage scans the middle slice for important diagnostic
425
+ // lines (error, file:line, HTTP 4xx/5xx, compiler codes) and preserves them
426
+ // within a 15% slack budget. The `preserveImportant` opt propagates here.
427
+ const result = applyCompactPipeline(value, [new TruncationStage(maxChars, { preserveImportant: opts.preserveImportant })]);
428
+ return result.text;
392
429
  }
393
430
 
394
- function compactValue(value: unknown): unknown {
431
+ export function compactValue(value: unknown): unknown {
395
432
  if (typeof value === "string") return compactString(value);
396
- if (Array.isArray(value)) return value.slice(0, 20).map(compactValue);
433
+ if (Array.isArray(value)) {
434
+ // BUG-4: silent .slice(0, 20) lost items 21-50 with no marker.
435
+ // Append a truncation marker when entries are dropped so downstream
436
+ // consumers know data was elided (consistent with compactString style).
437
+ if (value.length > 20) {
438
+ return [...value.slice(0, 20).map(compactValue), `[pi-crew truncated ${value.length - 20} entries]`];
439
+ }
440
+ return value.map(compactValue);
441
+ }
397
442
  const record = asRecord(value);
398
443
  if (!record) return value;
444
+ const entries = Object.entries(record);
399
445
  const compacted: Record<string, unknown> = {};
400
- for (const [key, entry] of Object.entries(record).slice(0, 20)) compacted[key] = compactValue(entry);
446
+ for (const [key, entry] of entries.slice(0, 20)) compacted[key] = compactValue(entry);
447
+ // BUG-4: mark elided object keys so consumers know data was dropped.
448
+ if (entries.length > 20) compacted["[truncated]"] = `${entries.length - 20} entries`;
401
449
  return compacted;
402
450
  }
403
451
 
404
452
  function compactContentPart(part: unknown): unknown | undefined {
405
453
  const record = asRecord(part);
406
454
  if (!record) return undefined;
407
- if (record.type === "text") return { type: "text", text: typeof record.text === "string" ? compactString(record.text, MAX_ASSISTANT_TEXT_CHARS) : "" };
455
+ if (record.type === "text") return { type: "text", text: typeof record.text === "string" ? compactString(record.text, MAX_ASSISTANT_TEXT_CHARS, { preserveImportant: false }) : "" };
408
456
  if (record.type === "toolCall") return { type: "toolCall", name: record.name, input: compactValue(typeof record.input === "string" ? compactString(record.input, MAX_TOOL_INPUT_CHARS) : record.input) };
409
457
  if (record.type === "toolResult") return { type: "toolResult", name: record.name, content: compactValue(typeof record.content === "string" ? compactString(record.content, MAX_TOOL_RESULT_CHARS) : record.content) };
410
458
  return undefined;
@@ -569,6 +617,55 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
569
617
  return { exitCode: 0, stdout, stderr: "" };
570
618
  }
571
619
  if (mock === "retryable-failure") return { exitCode: 1, stdout: "", stderr: "[MOCK] rate limit: mock failure" };
620
+ // E2E fallback-chain fixture: invocation #1 returns a SILENT retryable
621
+ // failure (exit code 0, no real assistant text, message_end carries a
622
+ // retryable-pattern errorMessage). Invocation #2+ delegates to the
623
+ // standard json-success shape. Counter lives in os.tmpdir() keyed by
624
+ // process.pid + mock name so concurrent test processes don't collide.
625
+ // The test cleans up the file in its finally block.
626
+ if (mock === "retryable-failure-then-success") {
627
+ const counterFile = path.join(os.tmpdir(), `pi-crew-mock-counter-${process.pid}-retryable-failure-then-success`);
628
+ let count = 0;
629
+ try {
630
+ const raw = fs.readFileSync(counterFile, "utf-8");
631
+ const parsed = Number.parseInt(raw.trim(), 10);
632
+ if (Number.isFinite(parsed) && parsed >= 0) count = parsed;
633
+ } catch {
634
+ // file missing or unreadable — first invocation in this process
635
+ }
636
+ count += 1;
637
+ try {
638
+ fs.writeFileSync(counterFile, String(count));
639
+ } catch (error) {
640
+ logInternalError("child-pi.mock-counter-write", error as Error, `file=${counterFile}`);
641
+ }
642
+ if (count === 1) {
643
+ // Silent retryable failure: exit 0, no real text, message_end
644
+ // carries errorMessage matching `/provider[_ ]?error/i` so that
645
+ // `detectRetryableModelFailureFromOutput` surfaces it as an error
646
+ // and `isRetryableModelFailure` routes the next attempt to the
647
+ // next candidate model. `stopReason:"error"` (NOT "stop") so
648
+ // `isFinalAssistantEvent` does NOT prematurely terminate the run.
649
+ const failureEvent = {
650
+ type: "message_end",
651
+ message: {
652
+ role: "assistant",
653
+ content: [],
654
+ errorMessage: "Provider error: api_error",
655
+ stopReason: "error",
656
+ },
657
+ };
658
+ const stdout = `${JSON.stringify(failureEvent)}\n`;
659
+ observeStdoutChunk(input, stdout);
660
+ return { exitCode: 0, stdout, stderr: "" };
661
+ }
662
+ // Subsequent invocations: delegate to json-success shape so the
663
+ // fallback chain's second attempt succeeds and the run completes.
664
+ const text = `[MOCK] JSON success for ${input.agent.name}`;
665
+ const stdout = `${JSON.stringify({ type: "message", message: { role: "assistant", content: [{ type: "text", text }] } })}\n${JSON.stringify({ type: "message_end", usage: { input: 10, output: 5, cost: 0.001, turns: 1 } })}\n`;
666
+ observeStdoutChunk(input, stdout);
667
+ return { exitCode: 0, stdout, stderr: "" };
668
+ }
572
669
  return { exitCode: 1, stdout: "", stderr: `[MOCK] failure: ${mock}` };
573
670
  }
574
671
  const built = buildPiWorkerArgs({ task: effectiveTask, agent: input.agent, model: input.model, sessionEnabled: true, maxDepth: input.maxDepth, skillPaths: input.skillPaths, role: input.role });
@@ -688,7 +785,9 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
688
785
  noResponseTimer = setTimeout(() => {
689
786
  responseTimeoutHit = true;
690
787
  // Capture stderr at timeout moment for debugging
691
- const timeoutStderr = stderr.slice(-1024); // Last 1KB of stderr
788
+ // SEC-1: redact secrets before embedding in lifecycle event so
789
+ // worker-emitted secrets (API keys etc.) don't bypass redaction.
790
+ const timeoutStderr = redactStderrExcerpt(stderr, 1024); // Last 1KB of stderr (redacted, SEC-1)
692
791
  input.onLifecycleEvent?.({ type: "response_timeout", pid: child.pid, error: `No output for ${responseTimeoutMs}ms`, ts: new Date().toISOString(), stderr: timeoutStderr || undefined });
693
792
  killProcessTree(child.pid, child);
694
793
  try {
@@ -904,16 +1003,17 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
904
1003
  });
905
1004
  child.on("error", (error) => {
906
1005
  // Reject pending operations with process error context
1006
+ // SEC-1: redact stderr secrets embedded in the error message + excerpt.
907
1007
  const processError = new Error(
908
- `Child Pi process error: ${error.message}. Stderr: ${stderr.slice(-500) || "(none)"}`,
1008
+ `Child Pi process error: ${error.message}. Stderr: ${redactStderrExcerpt(stderr, 500) || "(none)"}`,
909
1009
  );
910
1010
  rejectPendingOperations(processError);
911
1011
  try {
912
- input.onLifecycleEvent?.({ type: "spawn_error", pid: child.pid, error: processError.message, ts: new Date().toISOString(), stderrExcerpt: stderr.slice(-500) || undefined });
1012
+ input.onLifecycleEvent?.({ type: "spawn_error", pid: child.pid, error: processError.message, ts: new Date().toISOString(), stderrExcerpt: redactStderrExcerpt(stderr, 500) || undefined });
913
1013
  } catch (err) {
914
1014
  logInternalError("child-pi.on-lifecycle-event", err, `event=error, pid=${child.pid}`);
915
1015
  }
916
- settle({ exitCode: null, stdout, stderr, error: processError.message, exitStatus: { exitCode: null, cancelled: abortRequested, timedOut: responseTimeoutHit, killed: false, cleanupErrors, finalDrainMs, crashClass: classifyProcessCrash({ exitCode: null, cancelled: abortRequested, timedOut: responseTimeoutHit, spawnError: error, stderrSnippet: stderr ? stderr.slice(-1000) : undefined }).crashClass } });
1016
+ settle({ exitCode: null, stdout, stderr, error: processError.message, exitStatus: { exitCode: null, cancelled: abortRequested, timedOut: responseTimeoutHit, killed: false, cleanupErrors, finalDrainMs, crashClass: classifyProcessCrash({ exitCode: null, cancelled: abortRequested, timedOut: responseTimeoutHit, spawnError: error, stderrSnippet: stderr ? redactStderrExcerpt(stderr, 1000) : undefined }).crashClass } });
917
1017
  });
918
1018
  child.on("exit", (code, signal) => {
919
1019
  if (child.pid) {
@@ -932,7 +1032,7 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
932
1032
  const exitError = isUnexpectedExit
933
1033
  ? new Error(
934
1034
  `Child Pi process exited unexpectedly (code=${code ?? "null"} signal=${signal ?? "null"}). `
935
- + `Stderr: ${stderr.slice(-1000) || "(none)"}`,
1035
+ + `Stderr: ${redactStderrExcerpt(stderr, 1000) || "(none)"}`,
936
1036
  )
937
1037
  : null;
938
1038
  if (exitError) {
@@ -948,7 +1048,7 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
948
1048
  exitCode: code,
949
1049
  ts: new Date().toISOString(),
950
1050
  error: exitError?.message,
951
- stderrExcerpt: isUnexpectedExit ? stderr.slice(-1000) || undefined : undefined,
1051
+ stderrExcerpt: isUnexpectedExit ? redactStderrExcerpt(stderr, 1000) || undefined : undefined,
952
1052
  // Phase-0 diagnostic fields (kept optional — no type change required).
953
1053
  ...(signal ? { signal } : {}),
954
1054
  ...(finalDrainArmed || forcedFinalDrain
@@ -988,7 +1088,7 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
988
1088
  } catch (err) {
989
1089
  logInternalError("child-pi.on-lifecycle-event", err, `event=close, pid=${child.pid}`);
990
1090
  }
991
- const timeoutError = responseTimeoutHit && !stderr.trim() ? { error: `Child Pi produced no new output for ${responseTimeoutMs}ms; process was terminated as unresponsive.` } : responseTimeoutHit && stderr.trim() ? { error: `Child Pi timed out after ${responseTimeoutMs}ms with stderr: ${stderr.slice(-500)}` } : undefined;
1091
+ const timeoutError = responseTimeoutHit && !stderr.trim() ? { error: `Child Pi produced no new output for ${responseTimeoutMs}ms; process was terminated as unresponsive.` } : responseTimeoutHit && stderr.trim() ? { error: `Child Pi timed out after ${responseTimeoutMs}ms with stderr: ${redactStderrExcerpt(stderr, 500)}` } : undefined;
992
1092
  // M6 fix: log when forced final drain converts non-zero exit to 0.
993
1093
  // This is expected in normal operation (child finished cleanly but linger was killed),
994
1094
  // but the telemetry helps detect regressions where crashes are hidden.
@@ -1012,7 +1112,7 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
1012
1112
  timedOut: responseTimeoutHit,
1013
1113
  killed: hardKilled,
1014
1114
  spawnError: undefined,
1015
- stderrSnippet: stderr ? stderr.slice(-1000) : undefined,
1115
+ stderrSnippet: stderr ? redactStderrExcerpt(stderr, 1000) : undefined,
1016
1116
  });
1017
1117
  settle({ exitCode: finalExitCode, stdout, stderr, ...(timeoutError ? { error: timeoutError.error } : {}), ...(steerError ? { error: steerError } : {}), aborted: wasGraceAborted || wasParentAborted, steered: softLimitReached && !wasGraceAborted, exitStatus: { exitCode: finalExitCode, cancelled: abortRequested, timedOut: responseTimeoutHit, killed: hardKilled, cleanupErrors, finalDrainMs, crashClass: crashClassification.crashClass } });
1018
1118
  });
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Stage-chain compression pipeline (P0-A).
3
+ *
4
+ * Composable, monotonic-shrink-safe text compression. Each stage declares an
5
+ * `id` and an `apply(text): string` method. The pipeline runs stages in
6
+ * order, applying each stage's output ONLY if it is no longer than the
7
+ * stage's input. This is the safety property that prevents the family of
8
+ * bugs the old L4 caveman-shrink refactor surfaced (24/27 artifacts corrupted
9
+ * with null bytes because a regex-based shrink expanded its input in some
10
+ * cases — knowledge.md "L4 output-handling"). With the monotonic-shrink gate,
11
+ * a buggy stage implementation can NEVER cause output growth, and therefore
12
+ * cannot corrupt downstream structure.
13
+ *
14
+ * Ported from Hypa's `src/Hypa.Infrastructure/Compression/GenericOutputCompressor.cs`
15
+ * (stage loop with `if (next.Length <= text.Length)` gate).
16
+ */
17
+
18
+ export interface ICompactStage {
19
+ /** Stable identifier; surfaced in `PipelineResult.applied` for observability. */
20
+ readonly id: string;
21
+ /**
22
+ * Transform `text`. MUST be pure (no side effects, deterministic for a
23
+ * given input). MAY return the input unchanged when nothing to do — the
24
+ * pipeline will skip it via the monotonic-shrink gate regardless, but
25
+ * returning the same string keeps `applied` honest.
26
+ */
27
+ apply(text: string): string;
28
+ }
29
+
30
+ export interface PipelineResult {
31
+ text: string;
32
+ /** ids of stages whose output was accepted (shorter-or-equal than their input). */
33
+ applied: readonly string[];
34
+ }
35
+
36
+ /**
37
+ * Run `stages` in order. Each stage is applied only if its output is no
38
+ * longer than its current input. The pipeline NEVER expands text — if a
39
+ * stage would expand, it is silently skipped (its id is not added to
40
+ * `applied`).
41
+ */
42
+ export function applyCompactPipeline(text: string, stages: readonly ICompactStage[]): PipelineResult {
43
+ let current = text;
44
+ const applied: string[] = [];
45
+ for (const stage of stages) {
46
+ if (!stage || typeof stage.apply !== "function") continue; // defensive: skip malformed entries
47
+ const next = stage.apply(current);
48
+ if (typeof next !== "string") continue; // defensive: skip non-string output
49
+ if (next.length <= current.length) {
50
+ current = next;
51
+ applied.push(stage.id);
52
+ }
53
+ // else: stage attempted to expand input — silently drop (monotonic-shrink gate).
54
+ }
55
+ return { text: current, applied };
56
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * AnsiStripStage — strip ANSI CSI escape sequences.
3
+ *
4
+ * Matches the common CSI pattern: ESC `[` followed by parameter bytes
5
+ * (0-9 ; ?), intermediate bytes (space - /), and a final byte (@-~).
6
+ * Sufficient for the color/cursor codes emitted by npm, cargo, jest, etc.
7
+ * Does not attempt to handle OSC / DCS / private modes (rare in CLI output
8
+ * captured into artifacts; can be added later if real-world signal emerges).
9
+ *
10
+ * Idempotent (no ANSI in → no change; ANSI in → ANSI out).
11
+ */
12
+ import type { ICompactStage } from "../compact-pipeline.ts";
13
+
14
+ // CSI: ESC [ <params 0-9;> <intermediates space-/ > <final @-~>
15
+ const ANSI_CSI_PATTERN = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
16
+
17
+ export class AnsiStripStage implements ICompactStage {
18
+ readonly id = "ansi-strip";
19
+ apply(text: string): string {
20
+ if (text.indexOf("\x1b") === -1) return text; // fast path: no ESC at all
21
+ return text.replace(ANSI_CSI_PATTERN, "");
22
+ }
23
+ }
24
+
25
+ export const ANSI_STRIP_STAGE = new AnsiStripStage();
@@ -0,0 +1,31 @@
1
+ /**
2
+ * BlankCollapseStage — collapse runs of 3+ consecutive newlines to a single
3
+ * blank line (i.e., 2 newlines).
4
+ *
5
+ * Reduces whitespace noise in long command output (npm install, cargo build,
6
+ * jest, etc. frequently emit blocks of blank lines between sections). Does
7
+ * NOT touch 1 or 2 consecutive newlines — those are legitimate paragraph
8
+ * breaks in prose.
9
+ *
10
+ * Idempotent (already-collapsed input → unchanged).
11
+ */
12
+ import type { ICompactStage } from "../compact-pipeline.ts";
13
+
14
+ export class BlankCollapseStage implements ICompactStage {
15
+ readonly id = "blank-collapse";
16
+ // NOTE: deliberately NOT using parameter-property shorthand here because
17
+ // Node's --experimental-strip-types does not support it. Field + ctor
18
+ // assignment is the portable shape.
19
+ private readonly minConsecutive: number;
20
+ constructor(minConsecutive = 3) {
21
+ this.minConsecutive = minConsecutive;
22
+ }
23
+ apply(text: string): string {
24
+ if (this.minConsecutive < 2) return text;
25
+ // {minConsecutive,} matches minConsecutive or more; replace with "\n\n" (one blank line).
26
+ const pattern = new RegExp(`\\n{${this.minConsecutive},}`, "g");
27
+ return text.replace(pattern, "\n\n");
28
+ }
29
+ }
30
+
31
+ export const BLANK_COLLAPSE_STAGE = new BlankCollapseStage();
@@ -0,0 +1,34 @@
1
+ /**
2
+ * DeduplicateStage — collapse CONSECUTIVE duplicate lines into one.
3
+ *
4
+ * Useful for log output where the same line repeats (retry attempts, poll
5
+ * loops, etc.). Only collapses CONSECUTIVE duplicates — non-adjacent
6
+ * repetitions are kept (they may be legitimately repeated later). Does NOT
7
+ * touch whitespace-only differences.
8
+ *
9
+ * Idempotent.
10
+ *
11
+ * SAFETY: do NOT enable this stage on assistant prose. "I I I went to the
12
+ * store" would lose emphasis. compactString's default pipeline does NOT
13
+ * include this stage for that reason; it is opt-in only.
14
+ */
15
+ import type { ICompactStage } from "../compact-pipeline.ts";
16
+
17
+ export class DeduplicateStage implements ICompactStage {
18
+ readonly id = "deduplicate";
19
+ apply(text: string): string {
20
+ if (text.length === 0) return text;
21
+ const lines = text.split(/\r?\n/);
22
+ if (lines.length < 2) return text;
23
+ const out: string[] = [lines[0]!];
24
+ for (let i = 1; i < lines.length; i++) {
25
+ const cur = lines[i]!;
26
+ if (cur !== out[out.length - 1]) out.push(cur);
27
+ }
28
+ // Preserve original line ending style: if input used \r\n, restore that.
29
+ const sep = text.includes("\r\n") ? "\r\n" : "\n";
30
+ return out.join(sep);
31
+ }
32
+ }
33
+
34
+ export const DEDUPLICATE_STAGE = new DeduplicateStage();
@@ -0,0 +1,57 @@
1
+ /**
2
+ * HeadSnapStage — keep the first N bytes of the input, optionally snapping to
3
+ * the last newline within that region for clean line boundaries.
4
+ *
5
+ * Distinct from TruncationStage (head + important-middle + tail, P0-B / P0-A):
6
+ * this stage is pure head-only with optional newline-snap, used by the
7
+ * iteration-hooks hook-output capture where the goal is "first N bytes
8
+ * snapped to a clean line" rather than head + tail.
9
+ *
10
+ * Use case in pi-crew:
11
+ * - `iteration-hooks.ts` truncateToLimit — hook stdout capture capped at
12
+ * MAX_STDOUT_BYTES (8KB), snapped to the last newline in the head region
13
+ * so partial lines never appear in the captured preview.
14
+ *
15
+ * Byte cap (not char cap) to preserve the original memory budget semantic:
16
+ * the input is converted from Buffer to string once, then this stage ensures
17
+ * the output never exceeds the byte cap by walking back any partial UTF-8
18
+ * sequence at the cut boundary.
19
+ */
20
+ import type { ICompactStage } from "../compact-pipeline.ts";
21
+
22
+ export interface HeadSnapStageConfig {
23
+ /** Maximum output size in bytes. */
24
+ maxBytes: number;
25
+ /** When true, snap the cut to the last newline within the head region. */
26
+ snapToNewline?: boolean;
27
+ /** Optional explicit id; defaults to "head-snap". */
28
+ id?: string;
29
+ }
30
+
31
+ export class HeadSnapStage implements ICompactStage {
32
+ readonly id: string;
33
+ private readonly maxBytes: number;
34
+ private readonly snapToNewline: boolean;
35
+ constructor(config: HeadSnapStageConfig) {
36
+ if (!Number.isFinite(config.maxBytes) || config.maxBytes <= 0) {
37
+ throw new Error(`HeadSnapStage: maxBytes must be a positive finite number, got ${config.maxBytes}`);
38
+ }
39
+ this.maxBytes = config.maxBytes;
40
+ this.snapToNewline = config.snapToNewline !== false;
41
+ this.id = config.id ?? "head-snap";
42
+ }
43
+ apply(text: string): string {
44
+ if (Buffer.byteLength(text, "utf-8") <= this.maxBytes) return text;
45
+ // Approximate: slice by char count, then walk back any partial UTF-8
46
+ // sequence to keep byte-length <= maxBytes.
47
+ let slice = text.slice(0, this.maxBytes);
48
+ while (Buffer.byteLength(slice, "utf-8") > this.maxBytes) {
49
+ slice = slice.slice(0, slice.length - 1);
50
+ }
51
+ if (this.snapToNewline) {
52
+ const lastNewline = slice.lastIndexOf("\n");
53
+ if (lastNewline >= 0) return slice.slice(0, lastNewline);
54
+ }
55
+ return slice;
56
+ }
57
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Barrel re-exports for the compact-stages module.
3
+ *
4
+ * Callers import from `../../runtime/compact-stages/index.ts` (or just
5
+ * `../../runtime/compact-stages/`) rather than reaching into individual
6
+ * stage files, so internal refactors do not break the public surface.
7
+ */
8
+ export { AnsiStripStage, ANSI_STRIP_STAGE } from "./ansi-strip-stage.ts";
9
+ export { BlankCollapseStage, BLANK_COLLAPSE_STAGE } from "./blank-collapse-stage.ts";
10
+ export { DeduplicateStage, DEDUPLICATE_STAGE } from "./deduplicate-stage.ts";
11
+ export { TruncationStage, type TruncationMarkerConfig } from "./truncation-stage.ts";
12
+ export { HeadSnapStage, type HeadSnapStageConfig } from "./head-snap-stage.ts";
13
+ export { TailCaptureStage, TAIL_CAPTURE_STREAM_STAGE, type TailCaptureStageConfig } from "./tail-capture-stage.ts";
@@ -0,0 +1,72 @@
1
+ /**
2
+ * TailCaptureStage — keep the last N characters/bytes of the input, prepend
3
+ * an optional marker when truncation fires.
4
+ *
5
+ * Distinct from TruncationStage (head + important-middle + tail, P0-B / P0-A):
6
+ * this stage is pure tail-capture, used by streaming accumulators that need to
7
+ * keep the most recent N chars/bytes and drop the oldest. No important-line
8
+ * preservation, no head — just the tail + optional marker.
9
+ *
10
+ * Use cases in pi-crew:
11
+ * - `appendBoundedTail` (child-pi.ts) — stdout/stderr streaming accumulator
12
+ * with byte cap and a `[pi-crew captured output truncated to last X KiB]`
13
+ * marker.
14
+ * - `stream-preview.ts` textBuffer — incremental text buffer for the live UI
15
+ * preview, char cap, NO marker (the UI shows raw text without a prefix).
16
+ *
17
+ * Two cap modes:
18
+ * - `maxChars`: character-based cap (UTF-8 safe by definition).
19
+ * - `maxBytes`: byte-based cap (legacy, used when memory budget matters
20
+ * more than UTF-8 safety). The tail is snapped to the last byte that
21
+ * keeps the result ≤ maxBytes to avoid splitting a multi-byte sequence.
22
+ */
23
+ import type { ICompactStage } from "../compact-pipeline.ts";
24
+
25
+ export interface TailCaptureStageConfig {
26
+ /** Character cap (UTF-8 safe). Mutually exclusive with maxBytes. */
27
+ maxChars?: number;
28
+ /** Byte cap (legacy, used by streaming accumulators). Mutually exclusive with maxChars. */
29
+ maxBytes?: number;
30
+ /** Marker prepended (with a newline separator) when truncation fires. Empty string = no marker. */
31
+ marker?: string;
32
+ /** Optional explicit id; defaults to "tail-capture" (or "tail-capture-stream" if maxBytes mode). */
33
+ id?: string;
34
+ }
35
+
36
+ export class TailCaptureStage implements ICompactStage {
37
+ readonly id: string;
38
+ private readonly maxChars: number | undefined;
39
+ private readonly maxBytes: number | undefined;
40
+ private readonly marker: string;
41
+ constructor(config: TailCaptureStageConfig) {
42
+ const hasChars = typeof config.maxChars === "number";
43
+ const hasBytes = typeof config.maxBytes === "number";
44
+ if (hasChars === hasBytes) {
45
+ throw new Error(`TailCaptureStage requires exactly one of maxChars or maxBytes (got chars=${config.maxChars} bytes=${config.maxBytes})`);
46
+ }
47
+ if (hasChars && (config.maxChars as number) <= 0) throw new Error(`TailCaptureStage: maxChars must be > 0, got ${config.maxChars}`);
48
+ if (hasBytes && (config.maxBytes as number) <= 0) throw new Error(`TailCaptureStage: maxBytes must be > 0, got ${config.maxBytes}`);
49
+ this.maxChars = config.maxChars;
50
+ this.maxBytes = config.maxBytes;
51
+ this.marker = config.marker ?? "";
52
+ this.id = config.id ?? (hasBytes ? "tail-capture" : "tail-capture");
53
+ }
54
+ apply(text: string): string {
55
+ if (this.maxBytes !== undefined) {
56
+ // Byte cap mode — snap tail to a UTF-8 char boundary so the result
57
+ // never contains a partial multi-byte sequence.
58
+ if (Buffer.byteLength(text, "utf-8") <= this.maxBytes) return text;
59
+ let tail = text.slice(Math.max(0, text.length - this.maxBytes));
60
+ while (Buffer.byteLength(tail, "utf-8") > this.maxBytes) tail = tail.slice(1024);
61
+ return this.marker ? `${this.marker}\n${tail}` : tail;
62
+ }
63
+ // Char cap mode.
64
+ const max = this.maxChars as number;
65
+ if (text.length <= max) return text;
66
+ const tail = text.slice(text.length - max);
67
+ return this.marker ? `${this.marker}\n${tail}` : tail;
68
+ }
69
+ }
70
+
71
+ /** Singleton: char-cap tail capture with no marker (for `stream-preview.ts` textBuffer). */
72
+ export const TAIL_CAPTURE_STREAM_STAGE = new TailCaptureStage({ maxChars: 16_384, id: "tail-capture-stream" });
@@ -0,0 +1,71 @@
1
+ /**
2
+ * TruncationStage — head(75%) + important-middle + tail(25%) compression.
3
+ *
4
+ * Wraps the head/tail/important-line split (from P0-B's `important-line-classifier.ts`)
5
+ * as a pipeline stage so it composes with other stages (ANSI strip, blank
6
+ * collapse, etc.). When the input is at or below `maxChars`, returns the
7
+ * input unchanged (idempotent — the pipeline gate then marks this stage as
8
+ * a no-op).
9
+ *
10
+ * Marker wording is parameterized so the SAME stage serves both `compactString`
11
+ * ("compacted ... chars") and `readIfSmall` ("truncated ... chars") with
12
+ * their distinct separators. Defaults match `compactString`'s pre-P0-A output
13
+ * exactly so that callers that do not opt into additional stages get
14
+ * bit-identical output (L4 backward-compat safety).
15
+ */
16
+ import type { ICompactStage } from "../compact-pipeline.ts";
17
+ import { splitWithImportantLines } from "../important-line-classifier.ts";
18
+
19
+ export interface TruncationMarkerConfig {
20
+ /** "compacted" (compactString default) or "truncated" (readIfSmall default). */
21
+ verb: "compacted" | "truncated";
22
+ /** Unit reported in the marker. Both callers currently use "chars" post-Sprint 1. */
23
+ unit: "chars" | "bytes";
24
+ /** Newline(s) between `head` and the marker line. compactString uses "\n"; readIfSmall uses "\n\n". */
25
+ headSeparator: string;
26
+ /** Newline(s) between the marker (or joined important lines) and `tail`. Both callers use "\n". */
27
+ tailSeparator: string;
28
+ }
29
+
30
+ const DEFAULT_MARKER: TruncationMarkerConfig = {
31
+ verb: "compacted",
32
+ unit: "chars",
33
+ headSeparator: "\n",
34
+ tailSeparator: "\n",
35
+ };
36
+
37
+ export class TruncationStage implements ICompactStage {
38
+ readonly id = "truncation";
39
+ private readonly maxChars: number;
40
+ private readonly preserveImportant: boolean;
41
+ private readonly marker: TruncationMarkerConfig;
42
+ constructor(
43
+ maxChars: number,
44
+ opts: { preserveImportant?: boolean; marker?: Partial<TruncationMarkerConfig> } = {},
45
+ ) {
46
+ if (!Number.isFinite(maxChars) || maxChars <= 0) {
47
+ throw new Error(`TruncationStage: maxChars must be a positive finite number, got ${maxChars}`);
48
+ }
49
+ this.maxChars = maxChars;
50
+ this.preserveImportant = opts.preserveImportant !== false;
51
+ this.marker = { ...DEFAULT_MARKER, ...(opts.marker ?? {}) };
52
+ }
53
+ apply(text: string): string {
54
+ if (text.length <= this.maxChars) return text;
55
+ const { head, tail, importantLines, baseDropped } = splitWithImportantLines(text, this.maxChars, {
56
+ preserveImportant: this.preserveImportant,
57
+ });
58
+ let result: string;
59
+ if (importantLines.length === 0) {
60
+ result = `${head}${this.marker.headSeparator}...[pi-crew ${this.marker.verb} ${baseDropped} ${this.marker.unit}, head+tail preserved]...${this.marker.tailSeparator}${tail}`;
61
+ } else {
62
+ const joined = importantLines.join("\n");
63
+ const remaining = text.length - head.length - tail.length - joined.length;
64
+ result = `${head}${this.marker.headSeparator}...[pi-crew ${this.marker.verb} ${baseDropped} ${this.marker.unit}, head+tail + ${importantLines.length} important lines preserved, ${remaining} ${this.marker.unit} remaining dropped]...\n${joined}${this.marker.tailSeparator}${tail}`;
65
+ }
66
+ // Defense-in-depth: this stage's own monotonic-shrink invariant. The
67
+ // pipeline gate is a SECOND line of defense.
68
+ if (result.length >= text.length) return text;
69
+ return result;
70
+ }
71
+ }
@@ -202,6 +202,16 @@ export class HandoffManager {
202
202
  this.cleanupTimer = setInterval(() => {
203
203
  this.cleanupStaleHandoffs();
204
204
  }, this.options.cleanupIntervalMs);
205
+ // FIX (BG2 hang): without .unref(), the cleanup interval keeps the Node
206
+ // event loop alive forever — tests that create HandoffManager without
207
+ // calling dispose() (e.g. chain-runner.test.ts mock helper that does
208
+ // `return new HandoffManager()`) leak an interval per test, and the
209
+ // file-level test never completes because Node waits for all handles
210
+ // to close. .unref() lets the process exit when nothing else is pending
211
+ // — this is the standard Node.js pattern for background timers.
212
+ if (typeof this.cleanupTimer.unref === "function") {
213
+ this.cleanupTimer.unref();
214
+ }
205
215
  }
206
216
 
207
217
  /**