pi-crew 0.9.8 → 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 (47) hide show
  1. package/CHANGELOG.md +311 -0
  2. package/README.md +2 -2
  3. package/docs/fixes/v0.9.10/locks-fix-verify.md +3 -0
  4. package/docs/fixes/v0.9.10/smoke-test.md +12 -0
  5. package/package.json +1 -1
  6. package/src/extension/register.ts +94 -21
  7. package/src/extension/registration/subagent-helpers.ts +1 -0
  8. package/src/extension/registration/subagent-tools.ts +9 -0
  9. package/src/extension/team-tool/doctor.ts +41 -18
  10. package/src/runtime/batch-barrier.ts +145 -0
  11. package/src/runtime/child-pi.ts +135 -22
  12. package/src/runtime/compact-pipeline.ts +56 -0
  13. package/src/runtime/compact-stages/ansi-strip-stage.ts +25 -0
  14. package/src/runtime/compact-stages/blank-collapse-stage.ts +31 -0
  15. package/src/runtime/compact-stages/deduplicate-stage.ts +34 -0
  16. package/src/runtime/compact-stages/head-snap-stage.ts +57 -0
  17. package/src/runtime/compact-stages/index.ts +13 -0
  18. package/src/runtime/compact-stages/tail-capture-stage.ts +72 -0
  19. package/src/runtime/compact-stages/truncation-stage.ts +71 -0
  20. package/src/runtime/crash-classification.ts +208 -0
  21. package/src/runtime/custom-tools/irc-tool.ts +47 -7
  22. package/src/runtime/handoff-manager.ts +10 -0
  23. package/src/runtime/important-line-classifier.ts +130 -0
  24. package/src/runtime/iteration-hooks.ts +7 -19
  25. package/src/runtime/live-agent-manager.ts +185 -0
  26. package/src/runtime/live-session-runtime.ts +50 -1
  27. package/src/runtime/model-fallback.ts +29 -1
  28. package/src/runtime/process-lifecycle.ts +481 -0
  29. package/src/runtime/role-permission.ts +2 -2
  30. package/src/runtime/stream-preview.ts +9 -2
  31. package/src/runtime/subagent-manager.ts +6 -0
  32. package/src/runtime/task-output-context.ts +209 -24
  33. package/src/runtime/task-runner.ts +76 -15
  34. package/src/runtime/tool-output-pruner.ts +334 -0
  35. package/src/state/locks.ts +16 -0
  36. package/src/state/state-store.ts +8 -2
  37. package/src/state/types.ts +5 -0
  38. package/src/ui/live-run-sidebar.ts +6 -1
  39. package/src/ui/loaders.ts +24 -4
  40. package/src/ui/run-dashboard.ts +6 -1
  41. package/src/ui/run-event-bus.ts +1 -1
  42. package/src/ui/run-snapshot-cache.ts +50 -16
  43. package/src/ui/widget/index.ts +27 -5
  44. package/src/ui/widget/widget-renderer.ts +43 -13
  45. package/src/utils/redaction.ts +17 -1
  46. package/src/utils/visual.ts +6 -0
  47. package/src/ui/crew-widget.ts +0 -544
@@ -28,27 +28,47 @@ function firstOutputLine(stdout: string | null | undefined, stderr: string | nul
28
28
  return output.split(/\r?\n/).find((line) => line.trim().length > 0)?.trim() ?? "available";
29
29
  }
30
30
 
31
+ // Round 29 optimization: memoize spawnSync probe results at module level.
32
+ // The probes (git --version, pi --version) are stable for the process
33
+ // lifetime, and spawnSync on a node script can cost 1-2s. Without the
34
+ // cache, each buildTeamDoctorReport() call would pay that cost, and a
35
+ // file with 12 tests would take 20s+ even with empty cwd. The cache is
36
+ // safe: a doctor check is informational, and a stale ok=true would
37
+ // self-correct on the next process restart.
38
+ const commandExistsCache = new Map<string, { ok: boolean; detail: string }>();
31
39
  function commandExists(command: string, args: string[]): { ok: boolean; detail: string } {
40
+ const cacheKey = `${command} ${args.join(" ")}`;
41
+ const cached = commandExistsCache.get(cacheKey);
42
+ if (cached) return cached;
43
+ let result: { ok: boolean; detail: string };
32
44
  try {
33
45
  const output = spawnSync(command, args, { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] });
34
46
  if (output.error) {
35
- return { ok: false, detail: output.error.message };
47
+ result = { ok: false, detail: output.error.message };
48
+ } else if (output.status !== 0) {
49
+ result = { ok: false, detail: firstOutputLine(output.stdout, output.stderr) || `status ${output.status}` };
50
+ } else {
51
+ result = { ok: true, detail: firstOutputLine(output.stdout, output.stderr) };
36
52
  }
37
- if (output.status !== 0) {
38
- return { ok: false, detail: firstOutputLine(output.stdout, output.stderr) || `status ${output.status}` };
39
- }
40
- return { ok: true, detail: firstOutputLine(output.stdout, output.stderr) };
41
53
  } catch (error) {
42
- return { ok: false, detail: error instanceof Error ? error.message : String(error) };
54
+ result = { ok: false, detail: error instanceof Error ? error.message : String(error) };
43
55
  }
56
+ commandExistsCache.set(cacheKey, result);
57
+ return result;
44
58
  }
45
59
 
60
+ let piCommandExistsCache: { ok: boolean; detail: string } | undefined;
46
61
  function piCommandExists(): { ok: boolean; detail: string } {
62
+ if (piCommandExistsCache) return piCommandExistsCache;
47
63
  const spec = getPiSpawnCommand(["--version"]);
48
64
  const output = commandExists(spec.command, spec.args);
49
- if (!output.ok) return output;
65
+ if (!output.ok) {
66
+ piCommandExistsCache = output;
67
+ return piCommandExistsCache;
68
+ }
50
69
  const executable = spec.command === "pi" ? "pi" : `${spec.command} ${spec.args[0] ?? ""}`.trim();
51
- return { ok: true, detail: `${output.detail} (${executable})` };
70
+ piCommandExistsCache = { ok: true, detail: `${output.detail} (${executable})` };
71
+ return piCommandExistsCache;
52
72
  }
53
73
 
54
74
  function checkWritableDir(dir: string): { ok: boolean; detail: string } {
@@ -119,12 +139,18 @@ export interface TeamDoctorReport {
119
139
  }
120
140
 
121
141
  export function buildTeamDoctorReport(input: TeamDoctorReportInput): TeamDoctorReport {
142
+ // Discover once — used in both Drift and Discovery sections. Walking the
143
+ // filesystem 3x (agents/teams/workflows) is the dominant cost of this
144
+ // function; calling it twice doubles the cost. Round 29 optimization.
145
+ const discoveredAgentsAll = allAgents(discoverAgents(input.cwd));
146
+ const discoveredTeamsAll = allTeams(discoverTeams(input.cwd));
147
+ const discoveredWorkflowsAll = allWorkflows(discoverWorkflows(input.cwd));
122
148
  // Compute drift once — reused in both Drift section and return value
123
149
  const driftResult = detectDrift(
124
150
  {
125
- agents: allAgents(discoverAgents(input.cwd)).map((a) => a.name),
126
- teams: allTeams(discoverTeams(input.cwd)).map((t) => t.name),
127
- workflows: allWorkflows(discoverWorkflows(input.cwd)).map((w) => w.name),
151
+ agents: discoveredAgentsAll.map((a) => a.name),
152
+ teams: discoveredTeamsAll.map((t) => t.name),
153
+ workflows: discoveredWorkflowsAll.map((w) => w.name),
128
154
  },
129
155
  loadConfig(input.cwd).config,
130
156
  );
@@ -153,14 +179,11 @@ export function buildTeamDoctorReport(input: TeamDoctorReportInput): TeamDoctorR
153
179
  ];
154
180
  }),
155
181
  section("Discovery", () => {
156
- const discoveredAgents = allAgents(discoverAgents(input.cwd));
157
- const discoveredTeams = allTeams(discoverTeams(input.cwd));
158
- const discoveredWorkflows = allWorkflows(discoverWorkflows(input.cwd));
159
- const agentModelHints = discoveredAgents.filter((agent) => agent.model || agent.fallbackModels?.length).length;
182
+ const agentModelHints = discoveredAgentsAll.filter((agent) => agent.model || agent.fallbackModels?.length).length;
160
183
  return [
161
- { label: "agents", ok: true, detail: `${discoveredAgents.length} discovered` },
162
- { label: "teams", ok: true, detail: `${discoveredTeams.length} discovered` },
163
- { label: "workflows", ok: true, detail: `${discoveredWorkflows.length} discovered` },
184
+ { label: "agents", ok: true, detail: `${discoveredAgentsAll.length} discovered` },
185
+ { label: "teams", ok: true, detail: `${discoveredTeamsAll.length} discovered` },
186
+ { label: "workflows", ok: true, detail: `${discoveredWorkflowsAll.length} discovered` },
164
187
  { label: "resource model hints", ok: true, detail: `${agentModelHints} agents declare model/fallback preferences` },
165
188
  ];
166
189
  }),
@@ -0,0 +1,145 @@
1
+ /**
2
+ * BatchBarrier — Rule 1 (no-wait batch grouping).
3
+ *
4
+ * When a leader launches several background subagents with the SAME `batchId`
5
+ * and does NOT join them immediately (`get_subagent_result(wait:true)`), the
6
+ * completion notifications are coalesced: instead of N individual
7
+ * "changed state" wake-ups, the leader receives ONE consolidated notification
8
+ * once ALL members of the batch have reached a terminal state.
9
+ *
10
+ * Semantics:
11
+ * - `register(batchId, agentId)` is called at spawn time (synchronous within a
12
+ * leader turn). All members of a batch are therefore known by the time the
13
+ * first completion fires (completion is observed via the 1000ms poll loop).
14
+ * - `markTerminal(batchId, agentId)` returns whether THIS completion made every
15
+ * registered member terminal ("allDone"). When allDone, the caller emits a
16
+ * single consolidated notification and calls `markNotified`.
17
+ * - If a member reaches terminal after the batch already notified (late spawn
18
+ * edge case), `markTerminal` returns allDone=false for the straggler path is
19
+ * NOT covered — but `alreadyNotified` lets the caller suppress stray
20
+ * individual notifications once the consolidated one fired.
21
+ *
22
+ * Thread-safety: single-threaded JS event loop. No locks needed.
23
+ */
24
+
25
+ export interface BatchMember {
26
+ id: string;
27
+ description?: string;
28
+ type?: string;
29
+ status: string;
30
+ }
31
+
32
+ export interface BatchSnapshot {
33
+ batchId: string;
34
+ members: BatchMember[];
35
+ terminal: BatchMember[];
36
+ /** true when every registered member has reached a terminal state. */
37
+ allDone: boolean;
38
+ /** true once the consolidated notification has been emitted. */
39
+ notified: boolean;
40
+ }
41
+
42
+ const TERMINAL_STATUSES = new Set([
43
+ "completed",
44
+ "failed",
45
+ "cancelled",
46
+ "error",
47
+ "stopped",
48
+ ]);
49
+
50
+ export function isTerminalStatus(status: string): boolean {
51
+ return TERMINAL_STATUSES.has(status);
52
+ }
53
+
54
+ export class BatchBarrier {
55
+ private readonly batches = new Map<
56
+ string,
57
+ {
58
+ members: Map<string, BatchMember>;
59
+ terminal: Map<string, BatchMember>;
60
+ notified: boolean;
61
+ }
62
+ >();
63
+
64
+ /** Register a member at spawn time. Idempotent per (batchId, agentId). */
65
+ register(batchId: string, agentId: string, meta?: { description?: string; type?: string }): void {
66
+ let batch = this.batches.get(batchId);
67
+ if (!batch) {
68
+ batch = { members: new Map(), terminal: new Map(), notified: false };
69
+ this.batches.set(batchId, batch);
70
+ }
71
+ if (!batch.members.has(agentId)) {
72
+ batch.members.set(agentId, {
73
+ id: agentId,
74
+ description: meta?.description,
75
+ type: meta?.type,
76
+ status: "running",
77
+ });
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Record that a member reached a terminal state. Returns the batch snapshot.
83
+ * `snapshot.allDone` is true iff every registered member is now terminal.
84
+ * If the batch was never seen (defensive edge case), the member is registered
85
+ * on-the-fly as a batch-of-one so its terminal state is not silently lost.
86
+ */
87
+ markTerminal(batchId: string, member: BatchMember): BatchSnapshot {
88
+ let batch = this.batches.get(batchId);
89
+ if (!batch) {
90
+ batch = { members: new Map(), terminal: new Map(), notified: false };
91
+ this.batches.set(batchId, batch);
92
+ }
93
+ // Ensure the member is known (auto-register for the defensive case).
94
+ if (!batch.members.has(member.id)) {
95
+ batch.members.set(member.id, { ...member, status: member.status });
96
+ }
97
+ if (isTerminalStatus(member.status)) {
98
+ batch.terminal.set(member.id, { ...member });
99
+ const existing = batch.members.get(member.id);
100
+ if (existing) batch.members.set(member.id, { ...existing, status: member.status });
101
+ }
102
+ const allDone =
103
+ batch.members.size > 0 &&
104
+ [...batch.members.keys()].every((id) => batch.terminal.has(id));
105
+ return {
106
+ batchId,
107
+ members: [...batch.members.values()],
108
+ terminal: [...batch.terminal.values()],
109
+ allDone,
110
+ notified: batch.notified,
111
+ };
112
+ }
113
+
114
+ /** Has the consolidated notification already been emitted for this batch? */
115
+ alreadyNotified(batchId: string): boolean {
116
+ return this.batches.get(batchId)?.notified ?? false;
117
+ }
118
+
119
+ /** Mark the consolidated notification as emitted. No-op if already set. */
120
+ markNotified(batchId: string): void {
121
+ const batch = this.batches.get(batchId);
122
+ if (batch) batch.notified = true;
123
+ }
124
+
125
+ /** Read-only snapshot (for tests / debugging). */
126
+ snapshot(batchId: string): BatchSnapshot | undefined {
127
+ const batch = this.batches.get(batchId);
128
+ if (!batch) return undefined;
129
+ return {
130
+ batchId,
131
+ members: [...batch.members.values()],
132
+ terminal: [...batch.terminal.values()],
133
+ allDone:
134
+ batch.members.size > 0 &&
135
+ [...batch.members.keys()].every((id) => batch.terminal.has(id)),
136
+ notified: batch.notified,
137
+ };
138
+ }
139
+
140
+ /** Drop a batch (used on cleanup / test reset). */
141
+ dispose(batchId?: string): void {
142
+ if (batchId === undefined) this.batches.clear();
143
+ else this.batches.delete(batchId);
144
+ }
145
+ }
@@ -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,9 +10,12 @@ 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";
18
+ import { classifyProcessCrash } from "./crash-classification.ts";
15
19
  import { resolveRealContainedPath } from "../utils/safe-paths.ts";
16
20
 
17
21
  const POST_EXIT_STDIO_GUARD_MS = DEFAULT_CHILD_PI.postExitStdioGuardMs;
@@ -26,12 +30,33 @@ const MAX_COMPACT_CONTENT_CHARS = DEFAULT_CHILD_PI.maxCompactContentChars;
26
30
  const activeChildProcesses = new Map<number, ChildProcess>();
27
31
  const childHardKillTimers = new Map<number, NodeJS.Timeout>();
28
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
+
29
52
  function appendBoundedTail(current: string, chunk: string, maxBytes = MAX_CAPTURE_BYTES): string {
30
- const combined = current + chunk;
31
- if (Buffer.byteLength(combined, "utf-8") <= maxBytes) return combined;
32
- let tail = combined.slice(Math.max(0, combined.length - maxBytes));
33
- while (Buffer.byteLength(tail, "utf-8") > maxBytes) tail = tail.slice(1024);
34
- 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);
35
60
  }
36
61
 
37
62
  function clearHardKillTimer(pid: number | undefined): void {
@@ -378,32 +403,56 @@ function appendTranscript(input: ChildPiRunInput, line: string): void {
378
403
  }
379
404
  }
380
405
 
381
- 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 {
382
411
  if (value.length <= maxChars) return value;
383
412
  // L4: head + tail instead of head-only. Keeps closing markdown structure
384
413
  // (code fences, headings, list tails) instead of dropping them — the old
385
414
  // head-only slice left unclosed ``` fences that downstream parsers and
386
415
  // output-validator.ts flagged as "output may be truncated". Head gets 75%
387
416
  // (opening structure + bulk of content); tail gets 25% (closing structure).
388
- const head = Math.floor(maxChars * 0.75);
389
- const tail = maxChars - head;
390
- 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;
391
429
  }
392
430
 
393
- function compactValue(value: unknown): unknown {
431
+ export function compactValue(value: unknown): unknown {
394
432
  if (typeof value === "string") return compactString(value);
395
- 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
+ }
396
442
  const record = asRecord(value);
397
443
  if (!record) return value;
444
+ const entries = Object.entries(record);
398
445
  const compacted: Record<string, unknown> = {};
399
- 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`;
400
449
  return compacted;
401
450
  }
402
451
 
403
452
  function compactContentPart(part: unknown): unknown | undefined {
404
453
  const record = asRecord(part);
405
454
  if (!record) return undefined;
406
- 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 }) : "" };
407
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) };
408
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) };
409
458
  return undefined;
@@ -568,6 +617,55 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
568
617
  return { exitCode: 0, stdout, stderr: "" };
569
618
  }
570
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
+ }
571
669
  return { exitCode: 1, stdout: "", stderr: `[MOCK] failure: ${mock}` };
572
670
  }
573
671
  const built = buildPiWorkerArgs({ task: effectiveTask, agent: input.agent, model: input.model, sessionEnabled: true, maxDepth: input.maxDepth, skillPaths: input.skillPaths, role: input.role });
@@ -687,7 +785,9 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
687
785
  noResponseTimer = setTimeout(() => {
688
786
  responseTimeoutHit = true;
689
787
  // Capture stderr at timeout moment for debugging
690
- 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)
691
791
  input.onLifecycleEvent?.({ type: "response_timeout", pid: child.pid, error: `No output for ${responseTimeoutMs}ms`, ts: new Date().toISOString(), stderr: timeoutStderr || undefined });
692
792
  killProcessTree(child.pid, child);
693
793
  try {
@@ -903,16 +1003,17 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
903
1003
  });
904
1004
  child.on("error", (error) => {
905
1005
  // Reject pending operations with process error context
1006
+ // SEC-1: redact stderr secrets embedded in the error message + excerpt.
906
1007
  const processError = new Error(
907
- `Child Pi process error: ${error.message}. Stderr: ${stderr.slice(-500) || "(none)"}`,
1008
+ `Child Pi process error: ${error.message}. Stderr: ${redactStderrExcerpt(stderr, 500) || "(none)"}`,
908
1009
  );
909
1010
  rejectPendingOperations(processError);
910
1011
  try {
911
- 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 });
912
1013
  } catch (err) {
913
1014
  logInternalError("child-pi.on-lifecycle-event", err, `event=error, pid=${child.pid}`);
914
1015
  }
915
- settle({ exitCode: null, stdout, stderr, error: processError.message });
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 } });
916
1017
  });
917
1018
  child.on("exit", (code, signal) => {
918
1019
  if (child.pid) {
@@ -931,7 +1032,7 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
931
1032
  const exitError = isUnexpectedExit
932
1033
  ? new Error(
933
1034
  `Child Pi process exited unexpectedly (code=${code ?? "null"} signal=${signal ?? "null"}). `
934
- + `Stderr: ${stderr.slice(-1000) || "(none)"}`,
1035
+ + `Stderr: ${redactStderrExcerpt(stderr, 1000) || "(none)"}`,
935
1036
  )
936
1037
  : null;
937
1038
  if (exitError) {
@@ -947,7 +1048,7 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
947
1048
  exitCode: code,
948
1049
  ts: new Date().toISOString(),
949
1050
  error: exitError?.message,
950
- stderrExcerpt: isUnexpectedExit ? stderr.slice(-1000) || undefined : undefined,
1051
+ stderrExcerpt: isUnexpectedExit ? redactStderrExcerpt(stderr, 1000) || undefined : undefined,
951
1052
  // Phase-0 diagnostic fields (kept optional — no type change required).
952
1053
  ...(signal ? { signal } : {}),
953
1054
  ...(finalDrainArmed || forcedFinalDrain
@@ -987,7 +1088,7 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
987
1088
  } catch (err) {
988
1089
  logInternalError("child-pi.on-lifecycle-event", err, `event=close, pid=${child.pid}`);
989
1090
  }
990
- 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;
991
1092
  // M6 fix: log when forced final drain converts non-zero exit to 0.
992
1093
  // This is expected in normal operation (child finished cleanly but linger was killed),
993
1094
  // but the telemetry helps detect regressions where crashes are hidden.
@@ -1001,7 +1102,19 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
1001
1102
  // is logged, not fatal). The steerError branch is retained for safety in
1002
1103
  // case a future change reintroduces a fatal steer path.
1003
1104
  const steerError = steerInjectionFailed ? "Steer injection failed due to stdin backpressure; process killed" : undefined;
1004
- 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 } });
1105
+ // P0 crash taxonomy: classify the exit so callers/dashboards can bucket
1106
+ // failure modes (timeout vs cancel vs native panic vs signal …).
1107
+ // The classifier is a pure function; this is the single integration point.
1108
+ const crashClassification = classifyProcessCrash({
1109
+ exitCode: finalExitCode,
1110
+ signal: child.signalCode ?? undefined,
1111
+ cancelled: abortRequested,
1112
+ timedOut: responseTimeoutHit,
1113
+ killed: hardKilled,
1114
+ spawnError: undefined,
1115
+ stderrSnippet: stderr ? redactStderrExcerpt(stderr, 1000) : undefined,
1116
+ });
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 } });
1005
1118
  });
1006
1119
  });
1007
1120
  } finally {
@@ -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();