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
@@ -18,13 +18,30 @@ import { spinnerBucket, spinnerFrame } from "../spinner.ts";
18
18
  import { runEventBus } from "../run-event-bus.ts";
19
19
  import { DEFAULT_UI } from "../../config/defaults.ts";
20
20
  import { activeWidgetRuns, statusSummary } from "./widget-model.ts";
21
- import { buildWidgetLines, colorWidgetLine, renderLines } from "./widget-renderer.ts";
21
+ import { buildWidgetLines, colorWidgetLine, renderLines, DEFAULT_WIDGET_WIDTH, TASK_DESC_MAX } from "./widget-renderer.ts";
22
22
  import type { CrewWidgetModel, CrewWidgetState, WidgetRun } from "./widget-types.ts";
23
23
 
24
24
  // Re-export types and helpers for backward compatibility
25
25
  export type { WidgetRun, CrewWidgetModel, CrewWidgetState } from "./widget-types.ts";
26
26
  export { activeWidgetRuns, statusSummary } from "./widget-model.ts";
27
- export { buildWidgetLines as buildCrewWidgetLines, widgetHeader } from "./widget-renderer.ts";
27
+ export { buildWidgetLines as buildCrewWidgetLines, widgetHeader, DEFAULT_WIDGET_WIDTH, TASK_DESC_MAX } from "./widget-renderer.ts";
28
+
29
+ /**
30
+ * Resolve the real render width for widget lines, in priority order:
31
+ * 1. explicit `width` argument (e.g. from caller that already knows terminal width)
32
+ * 2. `process.stdout.columns` (works in Node when stdout is a TTY)
33
+ * 3. `DEFAULT_WIDGET_WIDTH` (100) — last-resort fallback so we never paint
34
+ * a line wider than the smallest expected TUI.
35
+ *
36
+ * Callers SHOULD pass the width they already hold (e.g. `WidgetRender.render(width)`
37
+ * in this file already receives one). This helper exists for paths that don't.
38
+ */
39
+ export function getRenderWidth(width?: number): number {
40
+ if (Number.isFinite(width) && width! > 0) return Math.floor(width!);
41
+ const stdoutCols = (globalThis as { process?: { stdout?: { columns?: number } } }).process?.stdout?.columns;
42
+ if (Number.isFinite(stdoutCols) && stdoutCols! > 0) return Math.floor(stdoutCols!);
43
+ return DEFAULT_WIDGET_WIDTH;
44
+ }
28
45
  export { notificationBadge } from "./widget-formatters.ts";
29
46
 
30
47
  // ── Constants ─────────────────────────────────────────────────────────
@@ -57,7 +74,12 @@ class CrewWidgetComponent implements WidgetComponent {
57
74
  this.theme = asCrewTheme(themeLike);
58
75
  this.cachedTheme = this.theme;
59
76
  this.unsubscribeTheme = subscribeThemeChange(themeLike, () => this.invalidate());
60
- this.unsubscribeEventBus = runEventBus.onAny(() => this.invalidate());
77
+ this.unsubscribeEventBus = (() => {
78
+ const unsub1 = runEventBus.onChannel("run:state", () => this.invalidate());
79
+ const unsub2 = runEventBus.onChannel("worker:lifecycle", () => this.invalidate());
80
+ const unsub3 = runEventBus.onChannel("ui:invalidate", () => this.invalidate());
81
+ return () => { unsub1(); unsub2(); unsub3(); };
82
+ })();
61
83
  }
62
84
 
63
85
  private buildSignature(runs: WidgetRun[]): string {
@@ -103,7 +125,7 @@ class CrewWidgetComponent implements WidgetComponent {
103
125
  const runningGlyph = spinnerFrame("widget-header");
104
126
 
105
127
  if (this.cacheSignature !== signature || width !== this.cachedWidth || this.cachedTheme !== this.theme) {
106
- this.cachedBaseLines = buildWidgetLines(this.model.cwd, 0, this.model.maxLines, runs, this.model.notificationCount ?? 0).map((line, index) => {
128
+ this.cachedBaseLines = buildWidgetLines(this.model.cwd, 0, this.model.maxLines, runs, this.model.notificationCount ?? 0, width).map((line, index) => {
107
129
  if (index === 0 && line.length > 0) return `${runningGlyph}${line.slice(1)}`;
108
130
  return line;
109
131
  });
@@ -150,7 +172,7 @@ export function updateCrewWidget(
150
172
  }
151
173
 
152
174
  const runs = activeWidgetRuns(ctx.cwd, manifestCache, snapshotCache, preloadedManifests, workspaceId);
153
- const lines = buildWidgetLines(ctx.cwd, state.frame, maxLines, runs, state.notificationCount ?? 0);
175
+ const lines = buildWidgetLines(ctx.cwd, state.frame, maxLines, runs, state.notificationCount ?? 0, getRenderWidth());
154
176
  const placement = config?.widgetPlacement ?? DEFAULT_UI.widgetPlacement;
155
177
 
156
178
  ctx.ui.setStatus(STATUS_KEY, lines.length ? statusSummary(runs) : undefined);
@@ -11,12 +11,21 @@ import { Box, Text } from "../layout-primitives.ts";
11
11
  import { listLiveAgents } from "../../runtime/live-agent-manager.ts";
12
12
  import { computePhaseProgress, formatPhaseProgressLine } from "../../runtime/phase-progress.ts";
13
13
  import { spinnerFrame } from "../spinner.ts";
14
- import { agentActivity, agentStats, notificationBadge } from "./widget-formatters.ts";
15
- import { shortRunLabel } from "./widget-model.ts";
14
+ import { computeLiveDurationMs } from "../live-duration.ts";
15
+ import { getTaskUsage } from "../../runtime/usage-tracker.ts";
16
+ import { agentActivity, agentStats, elapsed, formatTokensCompact, notificationBadge } from "./widget-formatters.ts";
17
+ import { activeWidgetRuns, shortRunLabel } from "./widget-model.ts";
16
18
  import type { WidgetRun } from "./widget-types.ts";
17
19
 
18
20
  const MAX_AGENTS_DISPLAY = 3;
19
21
  const FINISHED_LINGER_MAX_AGE = 1;
22
+ /** Default terminal width when caller doesn't pass one explicitly. Keep <= 116
23
+ * (the same default used elsewhere in pi-crew tool renderers) so we never paint
24
+ * a line wider than the smallest expected TUI. Callers SHOULD pass the real
25
+ * width when known (via ctx.width || process.stdout.columns). */
26
+ export const DEFAULT_WIDGET_WIDTH = 100;
27
+ /** Cap per-component text so a single field cannot blow past width on its own. */
28
+ export const TASK_DESC_MAX = 60;
20
29
  const ERROR_LINGER_MAX_AGE = 2;
21
30
  const ERROR_STATUSES = new Set(["failed", "cancelled", "stopped", "needs_attention"]);
22
31
 
@@ -37,8 +46,12 @@ export function widgetHeader(runs: WidgetRun[], runningGlyph: string, maxLines =
37
46
 
38
47
  // ── Line builder ──────────────────────────────────────────────────────
39
48
 
40
- export function buildWidgetLines(cwd: string, frame = 0, maxLines = 8, providedRuns?: WidgetRun[], notificationCount = 0): string[] {
41
- const runs = providedRuns ?? [];
49
+ export function buildWidgetLines(cwd: string, frame = 0, maxLines = 8, providedRuns?: WidgetRun[], notificationCount = 0, width = DEFAULT_WIDGET_WIDTH): string[] {
50
+ // Match the legacy `buildCrewWidgetLines` API: when no runs are supplied,
51
+ // auto-fetch via activeWidgetRuns(cwd). Otherwise widgets calling with
52
+ // only `(cwd, frame)` would render an empty line set (regression vs. the
53
+ // pre-refactor implementation that called activeWidgetRuns here).
54
+ const runs = providedRuns ?? activeWidgetRuns(cwd);
42
55
  if (!runs.length) return [];
43
56
 
44
57
  const runningGlyph = spinnerFrame("widget-header");
@@ -56,9 +69,23 @@ export function buildWidgetLines(cwd: string, frame = 0, maxLines = 8, providedR
56
69
  });
57
70
  const completed = agents.filter((a) => a.status === "completed").length;
58
71
  const runGlyph = iconForStatus(run.status, { runningGlyph });
59
- const phaseLine = snapshot ? formatPhaseProgressLine(computePhaseProgress(snapshot.tasks)) : "";
60
- const progressPart = phaseLine || `${completed}/${agents.length} done`;
61
- lines.push(`├─ ${runGlyph} ${shortRunLabel(run)} · ${progressPart} · ${run.runId.slice(-8)}`);
72
+ // Run progress line. v1–v3 flickered on snapshot.tasks state, v4 was
73
+ // too minimal (`0/1 agents` only), v5 duplicated the worker activity
74
+ // line (tools/tokens/duration already shown one row below). v6 (this)
75
+ // shows only data that is RUN-level (not already in the per-agent
76
+ // activity line) and is GUARANTEED stable across ticks:
77
+ // - agents count — from `agents` array, always populated, never empty.
78
+ // - run elapsed — from `run.createdAt`, always set on manifest.
79
+ // Both come from sources with no race window — `agents` is read from
80
+ // snapshot.agents OR agentsFor(run) (both always return same length
81
+ // for a healthy run), and `run.createdAt` is immutable. The format
82
+ // shape `"X/Y agents · Ns"` is therefore truly invariant: same number
83
+ // of `·`-separated fields, same field meanings, every render tick.
84
+ const agentCountText = `${completed}/${agents.length} agents`;
85
+ const runElapsedMs = Math.max(0, Date.now() - new Date(run.createdAt).getTime());
86
+ const runElapsedText = `${Math.floor(runElapsedMs / 1000)}s`;
87
+ const progressPart = `${agentCountText} · ${runElapsedText}`;
88
+ lines.push(truncate(`├─ ${runGlyph} ${shortRunLabel(run)} · ${progressPart} · ${run.runId.slice(-8)}`, width));
62
89
 
63
90
  const liveForRun = listLiveAgents().filter((a) => a.runId === run.runId);
64
91
 
@@ -67,8 +94,9 @@ export function buildWidgetLines(cwd: string, frame = 0, maxLines = 8, providedR
67
94
  const name = liveHandle?.agent ?? agent.agent;
68
95
  const icon = agent.status === "completed" ? "✓" : agent.status === "failed" ? "✗" : agent.status === "needs_attention" ? "⚠" : "▪";
69
96
  const stats = agentStats(agent, liveHandle);
70
- const desc = liveHandle?.description ?? agent.role;
71
- lines.push(`│ ├─ ${icon} ${name} · ${desc}${stats ? ` · ${stats}` : ""}`);
97
+ const desc = truncate(liveHandle?.description ?? agent.role ?? "", TASK_DESC_MAX);
98
+ const _finished = truncate(`│ ├─ ${icon} ${name} · ${desc}${stats ? ` · ${stats}` : ""}`, width);
99
+ lines.push(_finished);
72
100
  }
73
101
 
74
102
  const visibleAgents = activeAgents.slice(0, MAX_AGENTS_DISPLAY);
@@ -79,13 +107,15 @@ export function buildWidgetLines(cwd: string, frame = 0, maxLines = 8, providedR
79
107
  const liveHandle = liveForRun.find((h) => h.taskId === agent.taskId);
80
108
  const stats = agentStats(agent, liveHandle);
81
109
  const name = liveHandle?.agent ?? agent.agent;
82
- const desc = liveHandle?.description ?? agent.role;
83
- lines.push(`│ ${branch} ${agentGlyph} ${name}${desc ? ` · ${desc}` : ` · ${agent.role}`}`);
84
- lines.push(`│ ⊶ ${agentActivity(agent, liveHandle)}${stats ? ` · ${stats}` : ""}`);
110
+ const desc = truncate(liveHandle?.description ?? agent.role ?? "", TASK_DESC_MAX);
111
+ const _activeMain = truncate(`│ ${branch} ${agentGlyph} ${name}${desc ? ` · ${desc}` : ` · ${agent.role}`}`, width);
112
+ lines.push(_activeMain);
113
+ const _activity = truncate(`│ ⊶ ${agentActivity(agent, liveHandle)}${stats ? ` · ${stats}` : ""}`, width);
114
+ lines.push(_activity);
85
115
  }
86
116
 
87
117
  if (activeAgents.length > MAX_AGENTS_DISPLAY) {
88
- lines.push(`│ └─ … +${activeAgents.length - MAX_AGENTS_DISPLAY} more agents`);
118
+ lines.push(truncate(`│ └─ … +${activeAgents.length - MAX_AGENTS_DISPLAY} more agents`, width));
89
119
  }
90
120
 
91
121
  if (lines.length >= maxLines) break;
@@ -97,34 +97,54 @@ export function isSecretKey(keyName: string): boolean {
97
97
  return false;
98
98
  }
99
99
 
100
- // Linear-time Authorization header redaction
100
+ // Boundary chars that may precede an "authorization:" or "Bearer " keyword.
101
+ // Includes '-' so prefixed headers (Proxy-Authorization, X-Authorization) and
102
+ // '\t' so tab-indented headers are recognized. See security review L5.
103
+ const AUTH_HEADER_BOUNDARY_CHARS = new Set([" ", ",", "{", "[", "\"", "\r", "\n", "-", "\t"]);
104
+ function isAuthHeaderBoundary(ch: string | undefined): boolean {
105
+ return ch !== undefined && AUTH_HEADER_BOUNDARY_CHARS.has(ch);
106
+ }
107
+
108
+ // Linear-time Authorization header redaction.
109
+ // L3 fix: scan ALL occurrences (previously first-only via indexOf, so a second
110
+ // "authorization:" on a later line leaked). L5 fix: boundary set includes '-'
111
+ // and '\t' so Proxy-Authorization / X-Authorization / tab-indented headers are
112
+ // redacted. Bearer values are left for redactBearerTokens.
101
113
  export function redactAuthHeader(line: string): string {
102
114
  const lower = line.toLowerCase();
103
- const authIdx = lower.indexOf("authorization:");
104
- if (authIdx === -1) return line;
105
-
106
- // Verify word boundary - must be at start of line or preceded by whitespace/comma/brace
107
- if (authIdx > 0) {
108
- const before = line[authIdx - 1];
109
- if (before !== ' ' && before !== ',' && before !== '{' && before !== '[' && before !== '"' && before !== '\r' && before !== '\n') {
110
- return line; // Not a word boundary
115
+ let result = "";
116
+ let i = 0; // emit cursor into the original `line`
117
+ let searchFrom = 0; // cursor for the next indexOf scan
118
+ for (;;) {
119
+ const authIdx = lower.indexOf("authorization:", searchFrom);
120
+ if (authIdx === -1) {
121
+ result += line.substring(i);
122
+ return result;
111
123
  }
112
- }
113
-
114
- // Check if this is followed by Bearer token (don't redact Bearer tokens separately)
115
- // Look for "Bearer" after "authorization:"
116
- const afterAuth = lower.substring(authIdx + 14).trimStart();
117
- if (!afterAuth.startsWith('bearer ')) {
118
- // No Bearer token, this is a regular Authorization header - redact it
119
- let end = authIdx + 14;
120
- while (end < line.length && line[end] !== "\r" && line[end] !== "\n") {
121
- end++;
124
+ // Emit the unchanged span up to this occurrence.
125
+ result += line.substring(i, authIdx);
126
+ const isBoundary = authIdx === 0 || isAuthHeaderBoundary(line[authIdx - 1]);
127
+ const afterAuth = lower.substring(authIdx + 14).trimStart();
128
+ if (isBoundary && !afterAuth.startsWith("bearer ")) {
129
+ // Regular Authorization header — blank the credential value (the rest
130
+ // of the line is the credential). Appending only a marker would leave
131
+ // the secret bytes visible; replace them with "***". Bearer values are
132
+ // left intact here for redactBearerTokens. See security review L3/L5.
133
+ let end = authIdx + 14;
134
+ while (end < line.length && line[end] !== "\r" && line[end] !== "\n") {
135
+ end++;
136
+ }
137
+ result += line.substring(authIdx, authIdx + 14) + " ***";
138
+ i = end;
139
+ searchFrom = end; // entire line consumed; resume after the line break
140
+ } else {
141
+ // Bearer token (handled by redactBearerTokens) OR not a boundary —
142
+ // keep the "authorization:" literal and continue scanning.
143
+ result += line.substring(authIdx, authIdx + 14);
144
+ i = authIdx + 14;
145
+ searchFrom = authIdx + 14;
122
146
  }
123
- return line.substring(0, end) + " ***" + (end < line.length ? line.substring(end) : "");
124
147
  }
125
-
126
- // It's a Bearer token format - don't redact here, let redactBearerTokens handle it
127
- return line;
128
148
  }
129
149
 
130
150
  // Linear-time Bearer token redaction
@@ -135,14 +155,12 @@ export function redactBearerTokens(line: string): string {
135
155
 
136
156
  while (i < line.length) {
137
157
  if (upper.startsWith("BEARER ", i)) {
138
- // Check word boundary: preceded by start, space, comma, brace, or newline
139
- if (i > 0) {
140
- const before = line[i - 1];
141
- if (before !== ' ' && before !== ',' && before !== '{' && before !== '[' && before !== '"' && before !== '\r' && before !== '\n') {
142
- result.push(line[i]);
143
- i++;
144
- continue;
145
- }
158
+ // Check word boundary: start-of-string or a boundary char. Includes '-'
159
+ // and '\t' (L5) so "Proxy-Authorization: Bearer ..." is redacted.
160
+ if (i > 0 && !isAuthHeaderBoundary(line[i - 1])) {
161
+ result.push(line[i]);
162
+ i++;
163
+ continue;
146
164
  }
147
165
 
148
166
  // Found "Bearer " - now find the token
@@ -208,6 +226,22 @@ export function redactSecretString(value: string): string {
208
226
  // i++ (advance 1 char) and re-scanned from i+1, so a long run was rescanned O(n)
209
227
  // times = O(n^2). The P1f ReDoS test (300KB no-dot input) surfaced this pre-existing
210
228
  // bug. Now advances past the whole run when it isn't a redactable secret -> O(n).
229
+ // FIX (BG2 follow-up): the inner-loop regex test /[a-zA-Z0-9_-]/.test(value[j])
230
+ // is O(1) per call but with measurable regex-engine overhead — for a 100KB
231
+ // underscore run that's 100K regex calls, well over the 200ms budget the
232
+ // P1f regression test allows. Replaced with a charCodeAt check (~5x faster
233
+ // in practice, O(1) per call with no regex allocation/eval cost).
234
+ function isKeyChar(c: string): boolean {
235
+ const code = c.charCodeAt(0);
236
+ return (
237
+ (code >= 48 && code <= 57) || // 0-9
238
+ (code >= 65 && code <= 90) || // A-Z
239
+ (code >= 97 && code <= 122) || // a-z
240
+ code === 95 || // _
241
+ code === 45 // -
242
+ );
243
+ }
244
+
211
245
  function redactInlineSecrets(value: string): string {
212
246
  const result: string[] = [];
213
247
  let i = 0;
@@ -215,7 +249,7 @@ function redactInlineSecrets(value: string): string {
215
249
  while (i < value.length) {
216
250
  // Collect a run of key characters (alphanumeric, underscore, hyphen).
217
251
  let j = i;
218
- while (j < value.length && /[a-zA-Z0-9_-]/.test(value[j])) {
252
+ while (j < value.length && isKeyChar(value[j])) {
219
253
  j++;
220
254
  }
221
255
  const keyLen = j - i;
@@ -29,6 +29,12 @@ const WIDE_RANGES: Array<[number, number]> = [
29
29
  [0x274C, 0x274C], [0x274E, 0x274E], [0x2753, 0x2755], [0x2757, 0x2757],
30
30
  [0x2763, 0x2764], [0x2795, 0x2797], [0x27A1, 0x27A1], [0x27B0, 0x27B0],
31
31
  [0x27BF, 0x27BF],
32
+ // Geometric Shapes / Misc Symbols-Arrows emoji that pi-tui upstream counts as
33
+ // width=2 (RGI emoji). Mismatch here caused the "Rendered line N exceeds
34
+ // terminal width (160 > 159)" TUI crash: pi-crew truncated to width 159 by
35
+ // its own (mismatched) measure, then Box padded to 159 chars, but pi-tui
36
+ // re-measured the padded line at 160 because ⬜ counts as 2 upstream.
37
+ [0x2B1B, 0x2B1C], // ⬛ BLACK LARGE SQUARE, ⬜ WHITE LARGE SQUARE
32
38
  [0x1F300, 0x1F9FF], // Misc Symbols, Emoticons, Transport, Map, Supplement
33
39
  [0x1FA00, 0x1FAFF], // Symbols Extended-A
34
40
  [0x1F000, 0x1F02F], // Mahjong, Dominos