pi-crew 0.9.9 → 0.9.11

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 (40) hide show
  1. package/CHANGELOG.md +330 -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/config/role-tools.ts +39 -6
  6. package/src/extension/team-tool/doctor.ts +41 -18
  7. package/src/runtime/async-runner.ts +70 -74
  8. package/src/runtime/background-runner.ts +13 -2
  9. package/src/runtime/child-pi.ts +122 -22
  10. package/src/runtime/compact-pipeline.ts +56 -0
  11. package/src/runtime/compact-stages/ansi-strip-stage.ts +25 -0
  12. package/src/runtime/compact-stages/blank-collapse-stage.ts +31 -0
  13. package/src/runtime/compact-stages/deduplicate-stage.ts +34 -0
  14. package/src/runtime/compact-stages/head-snap-stage.ts +57 -0
  15. package/src/runtime/compact-stages/index.ts +13 -0
  16. package/src/runtime/compact-stages/tail-capture-stage.ts +72 -0
  17. package/src/runtime/compact-stages/truncation-stage.ts +71 -0
  18. package/src/runtime/handoff-manager.ts +10 -0
  19. package/src/runtime/important-line-classifier.ts +130 -0
  20. package/src/runtime/iteration-hooks.ts +7 -19
  21. package/src/runtime/live-session-runtime.ts +50 -1
  22. package/src/runtime/model-fallback.ts +29 -1
  23. package/src/runtime/role-permission.ts +5 -21
  24. package/src/runtime/stream-preview.ts +9 -2
  25. package/src/runtime/task-output-context.ts +161 -27
  26. package/src/runtime/task-runner/prompt-builder.ts +1 -0
  27. package/src/runtime/task-runner.ts +76 -15
  28. package/src/state/artifact-store.ts +22 -2
  29. package/src/state/locks.ts +16 -0
  30. package/src/state/state-store.ts +8 -2
  31. package/src/ui/live-run-sidebar.ts +6 -1
  32. package/src/ui/loaders.ts +24 -4
  33. package/src/ui/run-dashboard.ts +6 -1
  34. package/src/ui/run-event-bus.ts +1 -1
  35. package/src/ui/run-snapshot-cache.ts +50 -16
  36. package/src/ui/widget/index.ts +27 -5
  37. package/src/ui/widget/widget-renderer.ts +43 -13
  38. package/src/utils/redaction.ts +66 -32
  39. package/src/utils/visual.ts +6 -0
  40. package/src/ui/crew-widget.ts +0 -544
@@ -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
  /**
@@ -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"
@@ -1,11 +1,10 @@
1
- import { isSensitivePath } from "./sensitive-paths.ts";
2
-
3
1
  export type RolePermissionMode = "read_only" | "workspace_write" | "danger_full_access" | "explicit_confirm";
4
2
 
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"]);
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
-
3
+ // Read-only roles: cannot mutate files/source. `verifier` is NOT here — it runs
4
+ // tests (bash + cache writes) so it is a WRITE role (F4). `planner` stays
5
+ // read-only to preserve the plan-approval gate boundary (F3).
6
+ const READ_ONLY_ROLES = new Set(["explorer", "reviewer", "security-reviewer", "analyst", "critic", "planner"]);
7
+ const WRITE_ROLES = new Set(["executor", "test-engineer", "writer", "verifier"]);
9
8
  export interface PermissionCheckResult {
10
9
  allowed: boolean;
11
10
  mode: RolePermissionMode;
@@ -18,21 +17,6 @@ export function permissionForRole(role: string): RolePermissionMode {
18
17
  return "workspace_write";
19
18
  }
20
19
 
21
- export function isReadOnlyCommand(command: string): boolean {
22
- const first = command.trim().split(/\s+/)[0]?.split(/[\\/]/).pop() ?? "";
23
- return READ_ONLY_COMMANDS.has(first) && !/\s(-i|--in-place)\b|\s>{1,2}\s|\brm\b|\bmv\b|\bcp\b|\b(?:npm|pnpm|yarn|bun)\s+(install|add|ci|remove)\b|\bgit\s+(commit|push|merge|rebase|reset|checkout|clean)\b/.test(command);
24
- }
25
-
26
- export function checkRolePermission(role: string, command: string, filePath?: string): PermissionCheckResult {
27
- const mode = permissionForRole(role);
28
- // Also block access to known sensitive paths even for read-only commands
29
- if (filePath && isSensitivePath(filePath)) {
30
- return { allowed: false, mode, reason: `Path '${filePath}' is sensitive (credentials, SSH keys, etc.) — access denied for all roles.` };
31
- }
32
- if (mode === "read_only" && !isReadOnlyCommand(command)) return { allowed: false, mode, reason: `Role '${role}' is read-only and command may modify state.` };
33
- return { allowed: true, mode };
34
- }
35
-
36
20
  export function currentCrewRole(env: NodeJS.ProcessEnv = process.env): string | undefined {
37
21
  return env.PI_CREW_ROLE?.trim() || env.PI_TEAMS_ROLE?.trim() || undefined;
38
22
  }
@@ -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