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
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Important-Line Classifier (P0-B) — scan middle slice of a truncated value
3
+ * for diagnostic lines worth preserving between head and tail.
4
+ *
5
+ * Ported from Hypa's `ImportantLineClassifier.cs` (5 regexes) and the
6
+ * middle-scanning portion of `Stages/TruncationStage.cs:24-46`, adapted to TS
7
+ * (no `[GeneratedRegex]` AOT) and to pi-crew's head(75%)/tail(25%) split.
8
+ *
9
+ * Design rationale:
10
+ * - Patterns are intentionally OVER-INCLUSIVE. False positives preserve
11
+ * harmless lines; false negatives drop critical diagnostics, which is
12
+ * unacceptable (the whole point of this module). Hypa uses the same
13
+ * over-inclusive design.
14
+ * - Patterns are evaluated against a WHOLE line, not against the raw
15
+ * truncated slice, so a match at a line boundary is reliable.
16
+ * - The `splitWithImportantLines` helper performs the head/tail split AND
17
+ * greedily picks whole important lines from the middle that fit inside
18
+ * `slackFactor * maxChars` (default 15% slack). Callers compose their own
19
+ * marker using the returned parts — keeping `compactString` (marker
20
+ * "compacted ... chars, head+tail preserved") and `readIfSmall` (marker
21
+ * "truncated ... bytes, head+tail preserved") backward-compatible when no
22
+ * important lines are present.
23
+ */
24
+
25
+ /** Diagnostic patterns. Anchored where safe to avoid matching noise. */
26
+ export const IMPORTANT_LINE_PATTERNS: readonly RegExp[] = [
27
+ // error keywords — NOTE: "warning" is intentionally excluded here; it has
28
+ // its own case-sensitive pattern below so that the common prose word
29
+ // "warning" does not over-match. (Hypa does the same split.)
30
+ /\b(error|failed|exception|fatal|panic)\b/i,
31
+ // file:line diagnostic — `child-pi.ts:383:`, `App.tsx:42:`
32
+ /\w+\.\w+:\d+:/,
33
+ // HTTP 4xx / 5xx — bounded so it does not match phone numbers etc.
34
+ /\b[45]\d{2}\b/,
35
+ // k8s / linter "Warning" event (case-sensitive so prose is not matched)
36
+ /\bWarning\b/,
37
+ // compiler / linter diagnostic id — `TS2304`, `CS0246`, `ES1234`
38
+ /\b[A-Z]{2,4}\d{3,5}\b/,
39
+ ];
40
+
41
+ /** True iff `line` matches at least one important-line pattern. */
42
+ export function isImportantLine(line: string): boolean {
43
+ if (!line) return false;
44
+ for (const pattern of IMPORTANT_LINE_PATTERNS) {
45
+ if (pattern.test(line)) return true;
46
+ }
47
+ return false;
48
+ }
49
+
50
+ /**
51
+ * Extract up to `maxLines` important lines from `text`. Lines are split on
52
+ * `\n` (also handles `\r\n`). Order preserved; duplicates kept (callers may
53
+ * want to see the same diagnostic twice if it appears twice — that often
54
+ * signals a recurring failure).
55
+ */
56
+ export function extractImportantLines(text: string, maxLines = 30): string[] {
57
+ if (!text || maxLines <= 0) return [];
58
+ const out: string[] = [];
59
+ for (const line of text.split(/\r?\n/)) {
60
+ if (out.length >= maxLines) break;
61
+ if (isImportantLine(line)) out.push(line);
62
+ }
63
+ return out;
64
+ }
65
+
66
+ export interface TruncationSplit {
67
+ /** The first 75% of the value (by char count), verbatim. */
68
+ head: string;
69
+ /** The last 25% of the value (by char count), verbatim. */
70
+ tail: string;
71
+ /**
72
+ * Important lines from the middle slice, greedily picked (whole lines) so
73
+ * the joined length fits inside `slackFactor * maxChars`. Empty when
74
+ * `preserveImportant` is false OR no important lines are present OR none
75
+ * fit the slack budget.
76
+ */
77
+ importantLines: string[];
78
+ /** `value.length - maxChars` — chars dropped if no important lines preserved. */
79
+ baseDropped: number;
80
+ }
81
+
82
+ export interface SplitOptions {
83
+ /** When false, important-line scanning is skipped (assistant-text mode). */
84
+ preserveImportant?: boolean;
85
+ /** Hard cap on candidate lines before slack-budget selection. Default 30. */
86
+ maxImportantLines?: number;
87
+ /** Fraction of `maxChars` available for important-line content. Default 0.15. */
88
+ slackFactor?: number;
89
+ }
90
+
91
+ /**
92
+ * Split `value` into head + important-middle + tail, returning the parts.
93
+ * The caller is responsible for composing the final result (marker + glue)
94
+ * because the marker wording differs between `compactString` and
95
+ * `readIfSmall`.
96
+ *
97
+ * When no important lines are picked, the returned `importantLines` is `[]`
98
+ * and the marker wording stays bit-identical to the pre-P0-B format.
99
+ */
100
+ export function splitWithImportantLines(value: string, maxChars: number, opts: SplitOptions = {}): TruncationSplit {
101
+ if (value.length <= maxChars) {
102
+ return { head: value, tail: "", importantLines: [], baseDropped: 0 };
103
+ }
104
+ const headLen = Math.floor(maxChars * 0.75);
105
+ const tailLen = maxChars - headLen;
106
+ const head = value.slice(0, headLen);
107
+ const tail = value.slice(value.length - tailLen);
108
+
109
+ if (opts.preserveImportant === false) {
110
+ return { head, tail, importantLines: [], baseDropped: value.length - maxChars };
111
+ }
112
+
113
+ const slackFactor = opts.slackFactor ?? 0.15;
114
+ const slackChars = Math.max(0, Math.floor(maxChars * slackFactor));
115
+ const maxCandidates = opts.maxImportantLines ?? 30;
116
+ const middle = value.slice(headLen, value.length - tailLen);
117
+ const candidates = extractImportantLines(middle, maxCandidates);
118
+
119
+ // Greedily pick whole lines that fit in the slack budget.
120
+ const chosen: string[] = [];
121
+ let used = 0;
122
+ for (const line of candidates) {
123
+ const addLen = (chosen.length > 0 ? 1 : 0) + line.length; // '\n' separator
124
+ if (used + addLen > slackChars) break;
125
+ chosen.push(line);
126
+ used += addLen;
127
+ }
128
+
129
+ return { head, tail, importantLines: chosen, baseDropped: value.length - maxChars };
130
+ }
@@ -8,6 +8,7 @@ import { spawn } from "node:child_process";
8
8
  import * as fs from "node:fs";
9
9
  import * as path from "node:path";
10
10
  import { WINDOWS_ESSENTIAL_ENV_VARS } from "../utils/env-allowlist.ts";
11
+ import { HeadSnapStage } from "./compact-stages/index.ts";
11
12
  import { resolveShellForScript } from "../utils/resolve-shell.ts";
12
13
  import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
13
14
  import { DENIED_METRIC_NAMES } from "./metric-parser.ts";
@@ -98,23 +99,6 @@ function notFiredResult(): HookResult {
98
99
  };
99
100
  }
100
101
 
101
- /**
102
- * Truncate a buffer to the given byte limit, snapping to the last newline
103
- * boundary for UTF-8 safety.
104
- */
105
- function truncateToLimit(buf: Buffer, limit: number): Buffer {
106
- if (buf.byteLength <= limit) return buf;
107
-
108
- const slice = buf.subarray(0, limit);
109
- // Find the last newline within the truncated region
110
- const lastNewline = slice.lastIndexOf("\n");
111
- if (lastNewline >= 0) {
112
- return slice.subarray(0, lastNewline);
113
- }
114
- // No newline found — return the full slice
115
- return slice;
116
- }
117
-
118
102
  /**
119
103
  * Check if a script path exists and is executable.
120
104
  */
@@ -196,13 +180,17 @@ export async function runIterationHook(
196
180
  const durationMs = Date.now() - startTime;
197
181
 
198
182
  const rawStdout = Buffer.concat(stdoutChunks);
199
- const truncatedStdout = truncateToLimit(rawStdout, MAX_STDOUT_BYTES);
183
+ // Sprint 5: refactored onto HeadSnapStage. Convert to UTF-8 string once,
184
+ // then apply the byte-cap stage with newline-snap so partial lines
185
+ // never appear in the captured preview. HeadSnapStage is byte-cap-safe
186
+ // (walks back partial UTF-8 sequences at the cut boundary).
187
+ const stdoutText = new HeadSnapStage({ maxBytes: MAX_STDOUT_BYTES }).apply(rawStdout.toString("utf-8"));
200
188
 
201
189
  const rawStderr = Buffer.concat(stderrChunks);
202
190
 
203
191
  resolve({
204
192
  fired: true,
205
- stdout: truncatedStdout.toString("utf-8"),
193
+ stdout: stdoutText,
206
194
  stderr: rawStderr.toString("utf-8"),
207
195
  exitCode: code,
208
196
  timedOut: killed,
@@ -241,6 +241,54 @@ function modelFromRegistry(modelRegistry: unknown, modelId: string | undefined):
241
241
  }
242
242
  }
243
243
 
244
+ /**
245
+ * Round 18: when agent declares `model: false`, the inherited `parentModel`
246
+ * (= `ctx.model` from Pi runtime, set via `team-tool.ts:541/655`) is the
247
+ * session's SAVED model. That saved model can be stale (e.g. a previous
248
+ * session used claude-sonnet-4-5 and saved it as session.model; the new
249
+ * session actually runs on minimax-M3 displayed in the footer). If the
250
+ * saved model has no auth in `modelRegistry`, the worker fails immediately
251
+ * with "No API key found" before reaching any fallback candidate.
252
+ *
253
+ * This helper prefers the saved model when it is in the auth-available
254
+ * registry; otherwise falls back to the first auth-available registry
255
+ * model (e.g. minimax/MiniMax-M3, zai/glm-5.2); otherwise returns the
256
+ * raw `parentModel` unchanged so the caller surfaces E008.
257
+ */
258
+ export function resolveParentModelFromRegistry(
259
+ modelRegistry: unknown,
260
+ rawParentModel: unknown,
261
+ ): string | undefined {
262
+ const raw = typeof rawParentModel === "string" ? rawParentModel.trim() : undefined;
263
+ if (raw) {
264
+ const candidate = raw.includes("/")
265
+ ? raw
266
+ : (() => {
267
+ const m = modelFromRegistry(modelRegistry, raw);
268
+ if (m && typeof m === "object" && "fullId" in m) {
269
+ return String((m as { fullId?: unknown }).fullId ?? raw);
270
+ }
271
+ return undefined;
272
+ })();
273
+ if (candidate && modelFromRegistry(modelRegistry, candidate)) return candidate;
274
+ }
275
+ const registry = modelRegistry as { getAvailable?: () => unknown[] } | undefined;
276
+ if (registry && typeof registry.getAvailable === "function") {
277
+ try {
278
+ const available = registry.getAvailable();
279
+ if (Array.isArray(available) && available.length > 0) {
280
+ const first = available[0] as { provider?: unknown; id?: unknown } | undefined;
281
+ if (first && typeof first.provider === "string" && typeof first.id === "string") {
282
+ return `${first.provider}/${first.id}`;
283
+ }
284
+ }
285
+ } catch {
286
+ // ignore — fall through to raw
287
+ }
288
+ }
289
+ return raw;
290
+ }
291
+
244
292
  /** Communication intensity by role (caveman-inspired token optimization) */
245
293
  const ROLE_INTENSITY: Record<string, "lite" | "full" | "ultra"> = {
246
294
  explorer: "ultra",
@@ -473,7 +521,8 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
473
521
  });
474
522
  await (resourceLoader as { reload?: () => Promise<void> }).reload?.();
475
523
  }
476
- const modelRouting = buildConfiguredModelRouting({ overrideModel: input.modelOverride, stepModel: input.step.model, teamRoleModel: input.teamRoleModel, agentModel: input.agent.model, fallbackModels: input.agent.fallbackModels, parentModel: input.parentModel, modelRegistry: input.modelRegistry, cwd: input.manifest.cwd, scopeModelsPatterns: await resolveScopeModelsPatterns(input.manifest.cwd) });
524
+ const effectiveParentModel = resolveParentModelFromRegistry(input.modelRegistry, input.parentModel);
525
+ const modelRouting = buildConfiguredModelRouting({ overrideModel: input.modelOverride, stepModel: input.step.model, teamRoleModel: input.teamRoleModel, agentModel: input.agent.model, fallbackModels: input.agent.fallbackModels, parentModel: effectiveParentModel, modelRegistry: input.modelRegistry, cwd: input.manifest.cwd, scopeModelsPatterns: await resolveScopeModelsPatterns(input.manifest.cwd) });
477
526
  const resolvedModel = modelFromRegistry(input.modelRegistry, modelRouting.candidates[0] ?? modelRouting.requested) ?? input.parentModel;
478
527
  // Phase 4: MCP proxy — will be determined after session creation
479
528
  // (we check parent's MCP tools and share connections when available)
@@ -209,6 +209,25 @@ const RETRYABLE_MODEL_FAILURE_PATTERNS = [
209
209
  /internal(?:_server)?[ _]error/i,
210
210
  /server error/i,
211
211
  /bad gateway/i,
212
+ //
213
+ // Broader retryable patterns (added 2026-06-25, FIX 2):
214
+ // - `/provider[_ ]?error/i`: OpenAI-compatible "Provider error" generic fault.
215
+ // - `/context[_ ]?length[_ ]?exceeded/i`: "context_length_exceeded" from
216
+ // OpenAI/Anthropic — when the configured model is the bottleneck, a
217
+ // different model in the fallback chain may have a larger window.
218
+ // - `/safety/i`: Anthropic safety blocks — typically retryable on a
219
+ // different model in the fallback chain.
220
+ // - `/is[_ ]?overloaded/i`: alias to the existing `/overloaded/i` pattern
221
+ // to catch phrasings like "upstream is overloaded".
222
+ // - `/\b408\b/`: HTTP 408 Request Timeout — transient, provider-side.
223
+ //
224
+ // Intentionally NOT added: `/bad_request/` — can mean bad input (e.g.
225
+ // invalid schema), which is non-retryable.
226
+ /provider[_ ]?error/i,
227
+ /context[_ ]?length[_ ]?exceeded/i,
228
+ /safety/i,
229
+ /is[_ ]?overloaded/i,
230
+ /\b408\b/,
212
231
  ];
213
232
 
214
233
  // These patterns indicate auth/key/billing issues that will never succeed on retry.
@@ -313,9 +332,18 @@ export function buildConfiguredModelRouting(input: {
313
332
  const rawModels = availableModels
314
333
  ? [input.overrideModel, input.stepModel, input.teamRoleModel, effectiveAgentModel, ...(input.fallbackModels ?? []), ...availableModels.map((model) => model.fullId)]
315
334
  : [input.overrideModel, input.stepModel, input.teamRoleModel, effectiveAgentModel, ...(input.fallbackModels ?? []), parentModel];
335
+ // Fix (Round 18): when an agent has `model: false` (frontmatter) the
336
+ // inherited `parentModel` (= session chính's model, e.g. minimax-M3) IS the
337
+ // desired primary. It must NOT be filtered out by isAvailableModel — which
338
+ // only knows about models from models.json / registry, NOT builtin Pi models.
339
+ // Pin the inherited parentModel at index 0 regardless of availability.
340
+ const parentModelRaw = effectiveAgentModel?.trim() || undefined;
316
341
  const configuredModels = rawModels
317
342
  .filter((model): model is string => Boolean(model?.trim()))
318
- .filter((model) => isAvailableModel(model.trim(), availableModels));
343
+ .filter((model, idx) => {
344
+ if (parentModelRaw && idx === 0 && model.trim() === parentModelRaw) return true;
345
+ return isAvailableModel(model.trim(), availableModels);
346
+ });
319
347
  const candidates = buildModelCandidates(configuredModels[0], configuredModels.slice(1), availableModels, preferredProvider);
320
348
  const reason = requested && candidates[0] && resolveModelCandidate(requested, availableModels, preferredProvider) !== candidates[0]
321
349
  ? "requested model unavailable; selected configured Pi fallback"
@@ -2,8 +2,8 @@ import { isSensitivePath } from "./sensitive-paths.ts";
2
2
 
3
3
  export type RolePermissionMode = "read_only" | "workspace_write" | "danger_full_access" | "explicit_confirm";
4
4
 
5
- const READ_ONLY_ROLES = new Set(["explorer", "reviewer", "security-reviewer", "verifier", "analyst", "critic", "planner", "writer"]);
6
- const WRITE_ROLES = new Set(["executor", "test-engineer"]);
5
+ const READ_ONLY_ROLES = new Set(["explorer", "reviewer", "security-reviewer", "verifier", "analyst", "critic", "planner"]);
6
+ const WRITE_ROLES = new Set(["executor", "test-engineer", "writer"]);
7
7
  const READ_ONLY_COMMANDS = new Set(["cat", "head", "tail", "less", "more", "wc", "ls", "find", "grep", "rg", "awk", "sed", "echo", "printf", "which", "where", "whoami", "pwd", "env", "printenv", "date", "df", "du", "uname", "file", "stat", "diff", "sort", "uniq", "tr", "cut", "paste", "test", "true", "false", "type", "readlink", "realpath", "basename", "dirname", "sha256sum", "md5sum", "xxd", "hexdump", "od", "strings", "tree", "jq", "git", "gh"]);
8
8
 
9
9
  export interface PermissionCheckResult {
@@ -3,6 +3,7 @@
3
3
  // Used by the UI layer to show partial results before task completion.
4
4
 
5
5
  import type { ParsedPiUsage } from "./pi-json-output.ts";
6
+ import { TAIL_CAPTURE_STREAM_STAGE } from "./compact-stages/index.ts";
6
7
 
7
8
  export interface ToolCallPreview {
8
9
  toolName: string;
@@ -111,7 +112,13 @@ export function feedJsonEvent(preview: StreamPreview, event: unknown): boolean {
111
112
  const text = extractTextFromContent(message?.content ?? obj.content);
112
113
  if (text) {
113
114
  const appended = preview.textBuffer.length > 0 ? preview.textBuffer + "\n" + text : text;
114
- preview.textBuffer = appended.length > MAX_TEXT_BUFFER ? appended.slice(appended.length - MAX_TEXT_BUFFER) : appended;
115
+ // Sprint 5: refactored onto the stage-chain. TAIL_CAPTURE_STREAM_STAGE
116
+ // is a 16_384-char tail-capture stage with no marker (the UI shows
117
+ // raw text without a prefix). It is bit-equivalent to the inline
118
+ // `appended.slice(appended.length - MAX_TEXT_BUFFER)` for inputs at
119
+ // or below the cap (returns verbatim) and equivalent for over-cap
120
+ // inputs (returns last MAX_TEXT_BUFFER chars).
121
+ preview.textBuffer = TAIL_CAPTURE_STREAM_STAGE.apply(appended);
115
122
  }
116
123
  modified = true;
117
124
  }
@@ -119,7 +126,7 @@ export function feedJsonEvent(preview: StreamPreview, event: unknown): boolean {
119
126
  // Detect direct text/final output
120
127
  if (typeof obj.text === "string" && obj.text.trim()) {
121
128
  const appended = preview.textBuffer.length > 0 ? preview.textBuffer + "\n" + obj.text : obj.text;
122
- preview.textBuffer = appended.length > MAX_TEXT_BUFFER ? appended.slice(appended.length - MAX_TEXT_BUFFER) : appended;
129
+ preview.textBuffer = TAIL_CAPTURE_STREAM_STAGE.apply(appended);
123
130
  modified = true;
124
131
  }
125
132
 
@@ -5,6 +5,8 @@ import { writeArtifact } from "../state/artifact-store.ts";
5
5
  import { resolveRealContainedPath } from "../utils/safe-paths.ts";
6
6
  import type { WorkflowStep } from "../workflows/workflow-config.ts";
7
7
  import { pruneToolOutputs, type ToolResultEntry, type FileEditEvent, DEFAULT_PRUNE_CONFIG } from "./tool-output-pruner.ts";
8
+ import { applyCompactPipeline } from "./compact-pipeline.ts";
9
+ import { ANSI_STRIP_STAGE, BLANK_COLLAPSE_STAGE, TruncationStage } from "./compact-stages/index.ts";
8
10
 
9
11
  export interface DependencyContextEntry {
10
12
  taskId: string;
@@ -19,7 +21,14 @@ export interface DependencyContextEntry {
19
21
 
20
22
  export interface DependencyOutputContext {
21
23
  dependencies: DependencyContextEntry[];
22
- sharedReads: Array<{ name: string; path: string; content: string }>;
24
+ /**
25
+ * Each shared artifact read, truncated for inline injection. When truncation
26
+ * is materially lossy (file size > 2× MAX_RESULT_INLINE_BYTES) the FULL
27
+ * content is also teed to `${artifactsRoot}/tee/${taskId}-${name}.full.txt`
28
+ * and the path is exposed via `fullOutputPath` so the downstream worker
29
+ * can `read` it back if it needs the dropped middle.
30
+ */
31
+ sharedReads: Array<{ name: string; path: string; content: string; fullOutputPath?: string }>;
23
32
  }
24
33
 
25
34
  function containedExists(filePath: string, baseDir?: string): boolean {
@@ -39,35 +48,127 @@ function containedExists(filePath: string, baseDir?: string): boolean {
39
48
  * (24K/40K/80K) which truncated the same artifact differently depending on
40
49
  * which code path read it.
41
50
  */
42
- const MAX_RESULT_INLINE_BYTES = 32_000;
51
+ export const MAX_RESULT_INLINE_BYTES = 32_000;
43
52
 
44
- function readIfSmall(filePath: string, baseDir?: string): string | undefined {
45
- const maxBytes = MAX_RESULT_INLINE_BYTES;
53
+ /**
54
+ * Read a file and return its content, truncating to a head+tail slice if it
55
+ * exceeds {@link MAX_RESULT_INLINE_BYTES} characters. Multi-byte UTF-8
56
+ * sequences are preserved by reading the full file as a UTF-8 string and
57
+ * slicing by character count (not raw bytes).
58
+ */
59
+ export interface TeeRecoveryOptions {
60
+ /** Absolute path to write the full (non-truncated) content to. */
61
+ fullOutputPath: string;
62
+ }
63
+
64
+ export interface ReadIfSmallTeeResult {
65
+ /** Truncated content (or full content when no truncation). */
66
+ content: string;
67
+ /** Set only when tee was actually written (file size > 2× threshold + write succeeded). */
68
+ fullOutputPath?: string;
69
+ }
70
+
71
+ /**
72
+ * Sanitize a taskId / artifactName into a flat tee filename. Any character
73
+ * outside [A-Za-z0-9._-] is replaced with underscore so the resulting path
74
+ * is always single-segment and cannot escape the tee directory.
75
+ */
76
+ function safeTeeName(taskId: string, artifactName: string): string {
77
+ const safe = (s: string): string => s.replace(/[^A-Za-z0-9._-]/g, "_");
78
+ return `${safe(taskId)}-${safe(artifactName)}.full.txt`;
79
+ }
80
+
81
+ /**
82
+ * Canonical tee path for a shared artifact read.
83
+ *
84
+ * Format: `${artifactsRoot}/tee/${taskId}-${artifactName}.full.txt`
85
+ *
86
+ * The downstream worker prompt includes this path so the worker can `read`
87
+ * the full content when it needs the dropped middle.
88
+ */
89
+ export function teePathForArtifact(artifactsRoot: string, taskId: string, artifactName: string): string {
90
+ return path.join(artifactsRoot, "tee", safeTeeName(taskId, artifactName));
91
+ }
92
+
93
+ /**
94
+ * Best-effort tee write. Returns true on success, false on any error (write
95
+ * failures are silent — tee is enhancement, never a hard dependency). The
96
+ * truncated inline content is still returned by the caller either way.
97
+ */
98
+ function writeTeeFile(fullOutputPath: string, content: string): boolean {
46
99
  try {
47
- const safePath = baseDir ? resolveRealContainedPath(baseDir, filePath) : filePath;
48
- const stat = fs.statSync(safePath);
49
- if (stat.size > maxBytes) {
50
- // L4: head + tail instead of head-only. Keeps closing markdown
51
- // structure (code fences, headings) instead of leaving them truncated.
52
- const head = Math.floor(maxBytes * 0.75);
53
- const tail = maxBytes - head;
54
- const headBuf = Buffer.alloc(head);
55
- const tailBuf = Buffer.alloc(tail);
56
- const fd = fs.openSync(safePath, "r");
57
- try {
58
- fs.readSync(fd, headBuf, 0, head, 0);
59
- fs.readSync(fd, tailBuf, 0, tail, stat.size - tail);
60
- } finally {
61
- fs.closeSync(fd);
100
+ fs.mkdirSync(path.dirname(fullOutputPath), { recursive: true });
101
+ fs.writeFileSync(fullOutputPath, content, "utf-8");
102
+ return true;
103
+ } catch {
104
+ return false;
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Read a file with optional tee-recovery (P1-A). Returns the truncated
110
+ * content AND (when tee was actually written) the absolute path to the full
111
+ * file. Returns undefined if the file cannot be read at all.
112
+ *
113
+ * Tee threshold: only when content.length > 2 * MAX_RESULT_INLINE_BYTES
114
+ * (the head+tail is materially lossy — small over-threshold files are not
115
+ * teed because the inline content is mostly intact and the worker can live
116
+ * with the 75/25 split). File content is read once and reused for both the
117
+ * pipeline (truncation) and the tee write (full file).
118
+ *
119
+ * Truncation behavior is unchanged from the P0-A pipeline: ANSI strip +
120
+ * blank collapse BEFORE truncation, important-line preservation (P0-B)
121
+ * inside TruncationStage, marker wording matches the pre-P1-A `readIfSmall`
122
+ * output exactly (L4 backward-compat).
123
+ */
124
+ export function readIfSmallWithTee(
125
+ filePath: string,
126
+ opts: { baseDir?: string; tee?: TeeRecoveryOptions } = {},
127
+ ): ReadIfSmallTeeResult | undefined {
128
+ const maxChars = MAX_RESULT_INLINE_BYTES;
129
+ try {
130
+ const safePath = opts.baseDir ? resolveRealContainedPath(opts.baseDir, filePath) : filePath;
131
+ const content = fs.readFileSync(safePath, "utf-8");
132
+ if (content.length > maxChars) {
133
+ let fullOutputPath: string | undefined;
134
+ // Tee only when truncation is materially lossy (>2× threshold).
135
+ if (opts.tee && content.length > maxChars * 2) {
136
+ if (writeTeeFile(opts.tee.fullOutputPath, content)) {
137
+ fullOutputPath = opts.tee.fullOutputPath;
138
+ }
62
139
  }
63
- return `${headBuf.toString("utf-8")}\n\n...[pi-crew truncated ${stat.size - maxBytes} bytes, head+tail preserved]...\n${tailBuf.toString("utf-8")}`;
140
+ const result = applyCompactPipeline(content, [
141
+ ANSI_STRIP_STAGE,
142
+ BLANK_COLLAPSE_STAGE,
143
+ new TruncationStage(maxChars, {
144
+ preserveImportant: true,
145
+ marker: { verb: "truncated", unit: "chars", headSeparator: "\n\n", tailSeparator: "\n" },
146
+ }),
147
+ ]);
148
+ return fullOutputPath ? { content: result.text, fullOutputPath } : { content: result.text };
64
149
  }
65
- return fs.readFileSync(safePath, "utf-8");
150
+ return { content };
66
151
  } catch {
67
152
  return undefined;
68
153
  }
69
154
  }
70
155
 
156
+ /**
157
+ * Read a file and return its content, truncating to a head+tail slice if it
158
+ * exceeds {@link MAX_RESULT_INLINE_BYTES} characters. Multi-byte UTF-8
159
+ * sequences are preserved by reading the full file as a UTF-8 string and
160
+ * slicing by character count (not raw bytes).
161
+ *
162
+ * Thin wrapper around {@link readIfSmallWithTee} for backward compatibility
163
+ * — callers that do not need tee-recovery metadata get just the content
164
+ * string. New tee-recovery call sites should use {@link readIfSmallWithTee}
165
+ * directly so they can include the full output path in the worker prompt.
166
+ */
167
+ export function readIfSmall(filePath: string, baseDir?: string): string | undefined {
168
+ const result = readIfSmallWithTee(filePath, { baseDir });
169
+ return result?.content;
170
+ }
171
+
71
172
  function safeSharedName(name: string): string {
72
173
  const normalized = name.replaceAll("\\", "/").replace(/^\.\/+/, "");
73
174
  if (!normalized || normalized.split("/").some((segment) => segment === "..") || path.isAbsolute(normalized)) throw new Error(`Invalid shared artifact name: ${name}`);
@@ -127,6 +228,7 @@ function aggregateUsage(task: TeamTaskState): DependencyContextEntry["usage"] {
127
228
  function pruneSharedReads(
128
229
  reads: Array<{ name: string; path: string; content: string }>,
129
230
  dependencies: DependencyContextEntry[],
231
+ artifactsRoot: string,
130
232
  ): Array<{ name: string; path: string; content: string }> {
131
233
  if (reads.length === 0) return reads;
132
234
  // Convert shared reads to tool result entries (ordered oldest → newest
@@ -140,15 +242,19 @@ function pruneSharedReads(
140
242
  // Collect file edit events from dependency artifacts produced to shared/.
141
243
  // A dependency that wrote a shared file after an earlier read invalidates
142
244
  // that read (the content is now stale relative to the latest version).
143
- const sharedRoot = path.resolve("shared");
245
+ // Artifact entries from listTaskArtifacts() are already relative to
246
+ // artifactsRoot (e.g. "shared/foo.md"), so resolve directly against
247
+ // artifactsRoot — NOT against a "shared" subdirectory (which would
248
+ // double-prefix to <artifactsRoot>/shared/shared/foo.md).
144
249
  const fileEdits: FileEditEvent[] = [];
145
250
  for (let depIndex = 0; depIndex < dependencies.length; depIndex++) {
146
251
  const dep = dependencies[depIndex]!;
147
252
  const produced = dep.artifactsProduced ?? [];
148
253
  for (const artifact of produced) {
149
254
  if (typeof artifact !== "string") continue;
150
- // Map artifact path to shared-relative and check against read targets.
151
- fileEdits.push({ target: path.resolve(sharedRoot, artifact), index: reads.length + depIndex });
255
+ // Map artifact path (relative to artifactsRoot) to absolute and
256
+ // check against read targets.
257
+ fileEdits.push({ target: path.resolve(artifactsRoot, artifact), index: reads.length + depIndex });
152
258
  }
153
259
  }
154
260
  const pruned = pruneToolOutputs(entries, DEFAULT_PRUNE_CONFIG);
@@ -175,13 +281,33 @@ export function collectDependencyOutputContext(manifest: TeamRunManifest, tasks:
175
281
  });
176
282
  const rawSharedReads = (step.reads === false ? [] : step.reads ?? []).map((name) => {
177
283
  const filePath = sharedPath(manifest, name);
178
- return { name, path: filePath, content: readIfSmall(filePath, path.resolve(manifest.artifactsRoot, "shared")) ?? "" };
284
+ // P1-A tee-recovery: when the shared artifact is large enough that the
285
+ // 75/25 head+tail split is materially lossy (>2× MAX_RESULT_INLINE_BYTES),
286
+ // tee the full content to ${artifactsRoot}/tee/${taskId}-${name}.full.txt
287
+ // and expose the path so the downstream worker can `read` the full file
288
+ // if it needs the dropped middle. The truncated content is still
289
+ // included inline; tee is an enhancement, not a hard dependency. Tee
290
+ // write is best-effort (writeTeeFile swallows I/O errors and the result
291
+ // simply omits fullOutputPath in that case).
292
+ const teePath = teePathForArtifact(manifest.artifactsRoot, task.id, name);
293
+ const teeResult = readIfSmallWithTee(filePath, {
294
+ baseDir: path.resolve(manifest.artifactsRoot, "shared"),
295
+ tee: { fullOutputPath: teePath },
296
+ });
297
+ if (teeResult === undefined) return { name, path: filePath, content: "" };
298
+ const entry: { name: string; path: string; content: string; fullOutputPath?: string } = {
299
+ name,
300
+ path: filePath,
301
+ content: teeResult.content,
302
+ };
303
+ if (teeResult.fullOutputPath) entry.fullOutputPath = teeResult.fullOutputPath;
304
+ return entry;
179
305
  }).filter((item) => item.content.trim().length > 0);
180
306
  // Apply staleness-aware pruning to shared reads: drops superseded reads
181
307
  // (same file re-read with different selectors) and replaces stale large
182
308
  // outputs with compact digest notices before injecting into the worker
183
309
  // prompt. OPT-IN: default config protects recent results.
184
- const sharedReads = pruneSharedReads(rawSharedReads, dependencies);
310
+ const sharedReads = pruneSharedReads(rawSharedReads, dependencies, manifest.artifactsRoot);
185
311
  return { dependencies, sharedReads };
186
312
  }
187
313
 
@@ -198,7 +324,15 @@ export function renderDependencyOutputContext(context: DependencyOutputContext):
198
324
  }
199
325
  if (context.sharedReads.length) {
200
326
  parts.push("# Shared Run Context Reads", "");
201
- for (const read of context.sharedReads) parts.push(`## shared/${read.name}`, `Path: ${read.path}`, "", read.content.trim(), "");
327
+ for (const read of context.sharedReads) {
328
+ parts.push(`## shared/${read.name}`, `Path: ${read.path}`);
329
+ // P1-A tee-recovery hint: when the file was materially truncated
330
+ // (>2× threshold) the full content was teed to fullOutputPath so the
331
+ // worker can read the dropped middle if needed. The path is inside
332
+ // artifactsRoot/tee/ and goes through the normal permission gate.
333
+ if (read.fullOutputPath) parts.push(`Full output (if you need the missing middle): ${read.fullOutputPath}`);
334
+ parts.push("", read.content.trim(), "");
335
+ }
202
336
  }
203
337
  return parts.join("\n").trim();
204
338
  }