pi-crew 0.9.10 → 0.9.12

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.
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { CrewTheme } from "../theme-adapter.ts";
8
- import { iconForStatus } from "../status-colors.ts";
8
+ import { iconForStatus, colorizeStatusGlyphs } from "../status-colors.ts";
9
9
  import { truncate } from "../../utils/visual.ts";
10
10
  import { Box, Text } from "../layout-primitives.ts";
11
11
  import { listLiveAgents } from "../../runtime/live-agent-manager.ts";
@@ -89,19 +89,21 @@ export function buildWidgetLines(cwd: string, frame = 0, maxLines = 8, providedR
89
89
 
90
90
  const liveForRun = listLiveAgents().filter((a) => a.runId === run.runId);
91
91
 
92
- for (const agent of finishedAgents.slice(0, 2)) {
93
- const liveHandle = liveForRun.find((h) => h.taskId === agent.taskId);
94
- const name = liveHandle?.agent ?? agent.agent;
95
- const icon = agent.status === "completed" ? "✓" : agent.status === "failed" ? "✗" : agent.status === "needs_attention" ? "⚠" : "▪";
96
- const stats = agentStats(agent, liveHandle);
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);
100
- }
101
-
102
- const visibleAgents = activeAgents.slice(0, MAX_AGENTS_DISPLAY);
92
+ // L-4: prioritize RUNNING > QUEUED > WAITING within the visible window so the
93
+ // most relevant live workers are always shown. Finished rows fill only the
94
+ // leftover budget and never steal slots from running agents.
95
+ const ACTIVE_PRIORITY: Record<string, number> = { running: 0, queued: 1, waiting: 2 };
96
+ const prioritizedActive = [...activeAgents].sort(
97
+ (a, b) => (ACTIVE_PRIORITY[a.status] ?? 9) - (ACTIVE_PRIORITY[b.status] ?? 9),
98
+ );
99
+ // Finished rows only appear in slots not used by active agents (max 2). When
100
+ // there are >= MAX_AGENTS_DISPLAY live workers, finished rows are suppressed
101
+ // entirely so they cannot push a live agent's activity line off-screen.
102
+ const finishedSlots = Math.max(0, Math.min(2, MAX_AGENTS_DISPLAY - activeAgents.length));
103
+
104
+ const visibleAgents = prioritizedActive.slice(0, MAX_AGENTS_DISPLAY);
103
105
  for (const [index, agent] of visibleAgents.entries()) {
104
- const last = index === visibleAgents.length - 1 && activeAgents.length <= MAX_AGENTS_DISPLAY;
106
+ const last = index === visibleAgents.length - 1 && activeAgents.length <= MAX_AGENTS_DISPLAY && finishedSlots === 0;
105
107
  const branch = last ? "└─" : "├─";
106
108
  const agentGlyph = iconForStatus(agent.status, { runningGlyph });
107
109
  const liveHandle = liveForRun.find((h) => h.taskId === agent.taskId);
@@ -118,6 +120,18 @@ export function buildWidgetLines(cwd: string, frame = 0, maxLines = 8, providedR
118
120
  lines.push(truncate(`│ └─ … +${activeAgents.length - MAX_AGENTS_DISPLAY} more agents`, width));
119
121
  }
120
122
 
123
+ for (const [index, agent] of finishedAgents.slice(0, finishedSlots).entries()) {
124
+ const liveHandle = liveForRun.find((h) => h.taskId === agent.taskId);
125
+ const name = liveHandle?.agent ?? agent.agent;
126
+ const icon = agent.status === "completed" ? "✓" : agent.status === "failed" ? "✗" : agent.status === "needs_attention" ? "⚠" : "▪";
127
+ const stats = agentStats(agent, liveHandle);
128
+ const desc = truncate(liveHandle?.description ?? agent.role ?? "", TASK_DESC_MAX);
129
+ const isLastFinished = index === Math.min(finishedAgents.length, finishedSlots) - 1;
130
+ const branch = isLastFinished ? "└─" : "├─";
131
+ const _finished = truncate(`│ ${branch} ${icon} ${name} · ${desc}${stats ? ` · ${stats}` : ""}`, width);
132
+ lines.push(_finished);
133
+ }
134
+
121
135
  if (lines.length >= maxLines) break;
122
136
  }
123
137
 
@@ -126,25 +140,15 @@ export function buildWidgetLines(cwd: string, frame = 0, maxLines = 8, providedR
126
140
 
127
141
  // ── Colorization ──────────────────────────────────────────────────────
128
142
 
129
- function statusGlyphColor(icon: string): Parameters<CrewTheme["fg"]>[0] {
130
- const mapping: Record<string, Parameters<CrewTheme["fg"]>[0]> = {
131
- "✓": "success",
132
- "✗": "error",
133
- "■": "warning",
134
- "⏸": "warning",
135
- "◦": "dim",
136
- "·": "dim",
137
- "▶": "accent",
138
- };
139
- return mapping[icon] ?? "accent";
140
- }
141
-
142
143
  export function colorWidgetLine(line: string, index: number, theme: CrewTheme): string {
143
144
  let result = line;
144
145
  if (index === 0) {
145
146
  result = result.replace("Crew agents", theme.bold(theme.fg("accent", "Crew agents")));
146
147
  }
147
- result = result.replace(/[✓✗■⏸◦·▶]/g, (icon) => theme.fg(statusGlyphColor(icon), icon));
148
+ // Shared glyph colorizer covers ALL status glyphs — including ⏳ (waiting),
149
+ // ⚠ (needs_attention), and the braille spinner range ⠁-⣿ (running) — which the
150
+ // previous local statusGlyphColor map + regex omitted (F-1, V-3).
151
+ result = colorizeStatusGlyphs(result, theme);
148
152
  if (index === 0) {
149
153
  result = theme.fg("accent", result);
150
154
  }
@@ -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
@@ -39,8 +39,10 @@ const WIDE_RANGES: Array<[number, number]> = [
39
39
  [0x1FA00, 0x1FAFF], // Symbols Extended-A
40
40
  [0x1F000, 0x1F02F], // Mahjong, Dominos
41
41
  [0xFE00, 0xFE0F], // Variation Selectors (emoji presentation)
42
- [0x200D, 0x200D], // Zero Width Joiner (creates compound emoji)
43
42
  ];
43
+ // NOTE: U+200D (Zero Width Joiner, ZWJ) is intentionally NOT listed as wide.
44
+ // ZWJ has zero advance width by Unicode definition; miscounting it as 2 caused
45
+ // slight over-truncation/over-padding of compound-emoji goals (T-1).
44
46
 
45
47
  function isWideCodePoint(code: number): boolean {
46
48
  for (const [lo, hi] of WIDE_RANGES) {