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.
- package/CHANGELOG.md +330 -0
- package/docs/fixes/v0.9.10/locks-fix-verify.md +3 -0
- package/docs/fixes/v0.9.10/smoke-test.md +12 -0
- package/package.json +1 -1
- package/src/config/role-tools.ts +39 -6
- package/src/extension/team-tool/doctor.ts +41 -18
- package/src/runtime/async-runner.ts +70 -74
- package/src/runtime/background-runner.ts +13 -2
- package/src/runtime/child-pi.ts +122 -22
- package/src/runtime/compact-pipeline.ts +56 -0
- package/src/runtime/compact-stages/ansi-strip-stage.ts +25 -0
- package/src/runtime/compact-stages/blank-collapse-stage.ts +31 -0
- package/src/runtime/compact-stages/deduplicate-stage.ts +34 -0
- package/src/runtime/compact-stages/head-snap-stage.ts +57 -0
- package/src/runtime/compact-stages/index.ts +13 -0
- package/src/runtime/compact-stages/tail-capture-stage.ts +72 -0
- package/src/runtime/compact-stages/truncation-stage.ts +71 -0
- package/src/runtime/handoff-manager.ts +10 -0
- package/src/runtime/important-line-classifier.ts +130 -0
- package/src/runtime/iteration-hooks.ts +7 -19
- package/src/runtime/live-session-runtime.ts +50 -1
- package/src/runtime/model-fallback.ts +29 -1
- package/src/runtime/role-permission.ts +5 -21
- package/src/runtime/stream-preview.ts +9 -2
- package/src/runtime/task-output-context.ts +161 -27
- package/src/runtime/task-runner/prompt-builder.ts +1 -0
- package/src/runtime/task-runner.ts +76 -15
- package/src/state/artifact-store.ts +22 -2
- package/src/state/locks.ts +16 -0
- package/src/state/state-store.ts +8 -2
- package/src/ui/live-run-sidebar.ts +6 -1
- package/src/ui/loaders.ts +24 -4
- package/src/ui/run-dashboard.ts +6 -1
- package/src/ui/run-event-bus.ts +1 -1
- package/src/ui/run-snapshot-cache.ts +50 -16
- package/src/ui/widget/index.ts +27 -5
- package/src/ui/widget/widget-renderer.ts +43 -13
- package/src/utils/redaction.ts +66 -32
- package/src/utils/visual.ts +6 -0
- 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
|
-
|
|
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:
|
|
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
|
|
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) =>
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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 =
|
|
129
|
+
preview.textBuffer = TAIL_CAPTURE_STREAM_STAGE.apply(appended);
|
|
123
130
|
modified = true;
|
|
124
131
|
}
|
|
125
132
|
|