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.
- package/CHANGELOG.md +278 -0
- package/docs/fixes/v0.9.10/locks-fix-verify.md +3 -0
- package/docs/fixes/v0.9.10/smoke-test.md +12 -0
- package/package.json +1 -1
- package/src/extension/team-tool/doctor.ts +41 -18
- package/src/runtime/child-pi.ts +122 -22
- package/src/runtime/compact-pipeline.ts +56 -0
- package/src/runtime/compact-stages/ansi-strip-stage.ts +25 -0
- package/src/runtime/compact-stages/blank-collapse-stage.ts +31 -0
- package/src/runtime/compact-stages/deduplicate-stage.ts +34 -0
- package/src/runtime/compact-stages/head-snap-stage.ts +57 -0
- package/src/runtime/compact-stages/index.ts +13 -0
- package/src/runtime/compact-stages/tail-capture-stage.ts +72 -0
- package/src/runtime/compact-stages/truncation-stage.ts +71 -0
- package/src/runtime/handoff-manager.ts +10 -0
- package/src/runtime/important-line-classifier.ts +130 -0
- package/src/runtime/iteration-hooks.ts +7 -19
- package/src/runtime/live-session-runtime.ts +50 -1
- package/src/runtime/model-fallback.ts +29 -1
- package/src/runtime/role-permission.ts +2 -2
- package/src/runtime/stream-preview.ts +9 -2
- package/src/runtime/task-output-context.ts +161 -27
- package/src/runtime/task-runner.ts +76 -15
- package/src/state/locks.ts +16 -0
- package/src/state/state-store.ts +8 -2
- package/src/ui/live-run-sidebar.ts +6 -1
- package/src/ui/loaders.ts +24 -4
- package/src/ui/run-dashboard.ts +6 -1
- package/src/ui/run-event-bus.ts +1 -1
- package/src/ui/run-snapshot-cache.ts +50 -16
- package/src/ui/widget/index.ts +27 -5
- package/src/ui/widget/widget-renderer.ts +43 -13
- package/src/utils/redaction.ts +17 -1
- package/src/utils/visual.ts +6 -0
- package/src/ui/crew-widget.ts +0 -544
package/src/runtime/child-pi.ts
CHANGED
|
@@ -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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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(
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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))
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
/**
|