pi-crew 0.10.1 → 0.10.2

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 (64) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/NOTICE.md +21 -0
  3. package/README.md +44 -2
  4. package/agents/executor.md +1 -1
  5. package/agents/test-engineer.md +1 -1
  6. package/dist/index.mjs +2781 -949
  7. package/package.json +1 -1
  8. package/schema.json +492 -94
  9. package/src/config/config-validation.ts +7 -1
  10. package/src/config/defaults.ts +8 -1
  11. package/src/config/role-tools.ts +4 -2
  12. package/src/config/types.ts +19 -1
  13. package/src/extension/crew-cleanup.ts +13 -0
  14. package/src/extension/crew-vibes/footer.ts +19 -0
  15. package/src/extension/crew-vibes/index.ts +11 -1
  16. package/src/extension/register.ts +8 -0
  17. package/src/extension/registration/command-registration.ts +1 -0
  18. package/src/extension/registration/commands/run.ts +15 -1
  19. package/src/extension/registration/commands/shared.ts +8 -0
  20. package/src/extension/registration/foreground-run-controller.ts +10 -2
  21. package/src/extension/registration/lifecycle-handlers.ts +52 -2
  22. package/src/extension/registration/runtime-cleanup.ts +23 -5
  23. package/src/extension/registration/team-tool.ts +5 -1
  24. package/src/extension/registration/ui.ts +5 -4
  25. package/src/extension/team-tool/run-deadline.ts +20 -3
  26. package/src/extension/team-tool/run.ts +26 -2
  27. package/src/runtime/child-pi/child-pi-constants.ts +8 -0
  28. package/src/runtime/child-pi/child-pi-streams.ts +21 -1
  29. package/src/runtime/detached-run-results.ts +90 -0
  30. package/src/runtime/goal-workflow/adaptive-plan.ts +30 -3
  31. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +5 -1
  32. package/src/runtime/merge-gate.ts +25 -9
  33. package/src/runtime/model/model-fallback.ts +7 -3
  34. package/src/runtime/process-status.ts +16 -1
  35. package/src/runtime/recovery/crash-recovery.ts +16 -0
  36. package/src/runtime/run-tracker.ts +77 -10
  37. package/src/runtime/team-runner.ts +3 -2
  38. package/src/schema/config-schema.ts +1 -1
  39. package/src/state/stores/plan-store.ts +1 -1
  40. package/src/state/stores/state-store.ts +73 -3
  41. package/src/state/types.ts +2 -0
  42. package/src/ui/dock-footer.ts +49 -0
  43. package/src/ui/inline-panel/agent-pane.ts +375 -0
  44. package/src/ui/inline-panel/agent-transcript.ts +338 -0
  45. package/src/ui/inline-panel/agent-view-overlay.ts +225 -0
  46. package/src/ui/inline-panel/crew-editor.ts +192 -0
  47. package/src/ui/inline-panel/index.ts +290 -0
  48. package/src/ui/inline-panel/panel-rows.ts +37 -0
  49. package/src/ui/inline-panel/panel-selection.ts +157 -0
  50. package/src/ui/inline-panel/panel-store.ts +111 -0
  51. package/src/ui/inline-panel/view-session-store.ts +36 -0
  52. package/src/ui/pi-ui-compat.ts +9 -0
  53. package/src/ui/widget/index.ts +176 -22
  54. package/src/ui/widget/task-list.ts +198 -0
  55. package/src/ui/widget/widget-formatters.ts +240 -4
  56. package/src/ui/widget/widget-renderer.ts +234 -37
  57. package/src/ui/widget/widget-types.ts +11 -0
  58. package/src/utils/child-process-shield.ts +106 -0
  59. package/src/utils/redaction.ts +7 -0
  60. package/src/utils/safe-abort.ts +45 -0
  61. package/src/workflows/discover-workflows.ts +1 -0
  62. package/src/workflows/workflow-config.ts +7 -0
  63. package/workflows/default.workflow.md +36 -26
  64. package/workflows/strict-fast-fix.workflow.md +26 -0
@@ -4,19 +4,30 @@
4
4
  * Extracted from crew-widget.ts.
5
5
  */
6
6
 
7
+ import type { CrewAgentRecord } from "../../runtime/crew-agent-runtime.ts";
7
8
  import { listLiveAgents } from "../../runtime/live-session/live-agent-manager.ts";
8
9
  import { isPlanApprovalStatePending } from "../../runtime/plan-approval.ts";
9
10
  import { isFinishedRunStatus } from "../../runtime/process-status.ts";
11
+ import type { TeamRunManifest } from "../../state/types.ts";
10
12
  import { truncate } from "../../utils/visual.ts";
11
13
  import { Box, Text } from "../layout-primitives.ts";
12
14
  import { spinnerFrame } from "../spinner.ts";
13
15
  import { colorizeStatusGlyphs, iconForStatus } from "../status-colors.ts";
14
16
  import type { CrewTheme } from "../theme-adapter.ts";
15
- import { agentActivity, agentStats, notificationBadge } from "./widget-formatters.ts";
17
+ import {
18
+ agentActivity,
19
+ agentStats,
20
+ budgetedRow,
21
+ dockElapsed,
22
+ dockStatusIcon,
23
+ dockStatusLabel,
24
+ dockUsageText,
25
+ notificationBadge,
26
+ } from "./widget-formatters.ts";
16
27
  import { activeWidgetRuns, shortRunLabel } from "./widget-model.ts";
17
28
  import type { WidgetRun } from "./widget-types.ts";
18
29
 
19
- const MAX_AGENTS_DISPLAY = 3;
30
+ export const MAX_AGENTS_DISPLAY = 3;
20
31
  const FINISHED_LINGER_MAX_AGE = 1;
21
32
  /** Default terminal width when caller doesn't pass one explicitly. Keep <= 116
22
33
  * (the same default used elsewhere in pi-crew tool renderers) so we never paint
@@ -43,8 +54,192 @@ export function widgetHeader(runs: WidgetRun[], runningGlyph: string, maxLines =
43
54
  return `${runningGlyph} Crew agents${notificationBadge(notificationCount)} · ${parts.join(" · ")} · /team-dashboard`;
44
55
  }
45
56
 
57
+ // ── Agent ordering (shared with the inline panel) ──────────────────────
58
+
59
+ /**
60
+ * L-4: prioritize RUNNING > QUEUED > WAITING so the most relevant live workers
61
+ * are always shown first. Finished rows fill only the leftover budget and never
62
+ * steal a slot from an active agent.
63
+ */
64
+ const ACTIVE_PRIORITY: Record<string, number> = { running: 0, queued: 1, waiting: 2 };
65
+
66
+ function isActiveStatus(status: string): boolean {
67
+ return status === "running" || status === "queued" || status === "waiting";
68
+ }
69
+
70
+ /**
71
+ * The agent order the widget paints, split into its two sections.
72
+ *
73
+ * Exported because the inline panel navigates the same list: if the panel
74
+ * derived its own order, the cursor index would drift from the rendered rows.
75
+ * One function, one order.
76
+ */
77
+ export function orderWidgetAgents(entry: WidgetRun, now = Date.now()): { active: CrewAgentRecord[]; finished: CrewAgentRecord[] } {
78
+ const runDone = isFinishedRunStatus(entry.run.status);
79
+ const active = entry.agents.filter((agent) => isActiveStatus(agent.status));
80
+ const finished = entry.agents.filter((agent) => {
81
+ if (isActiveStatus(agent.status)) return false;
82
+ if (!agent.completedAt) return false;
83
+ // Mid-run, finished agents are the run's HISTORY: they must stay in
84
+ // the dock until the RUN itself is done, not age out after a minute
85
+ // while later phases are still working. The linger windows below only
86
+ // apply once the run reached a terminal status (and the run-level
87
+ // visibility grace then decides how much longer the dock shows at all).
88
+ if (!runDone) return true;
89
+ const maxAgeMs = (ERROR_STATUSES.has(agent.status) ? ERROR_LINGER_MAX_AGE : FINISHED_LINGER_MAX_AGE) * 60_000;
90
+ const age = now - new Date(agent.completedAt).getTime();
91
+ return Number.isFinite(age) && age < maxAgeMs;
92
+ });
93
+ return {
94
+ active: [...active].sort((a, b) => (ACTIVE_PRIORITY[a.status] ?? 9) - (ACTIVE_PRIORITY[b.status] ?? 9)),
95
+ finished,
96
+ };
97
+ }
98
+
46
99
  // ── Line builder ──────────────────────────────────────────────────────
47
100
 
101
+ /**
102
+ * Row layout for the per-agent lines.
103
+ *
104
+ * - `detailed` — the historical two-line tree (name row + `⊶ activity` row).
105
+ * - `compact` — one width-budgeted line per agent, so a wide terminal shows the
106
+ * full description instead of the same clip a narrow one gets.
107
+ */
108
+ export type WidgetRowStyle = "compact" | "detailed";
109
+
110
+ export interface WidgetRenderOptions {
111
+ rowStyle?: WidgetRowStyle;
112
+ /** Task id under the inline panel cursor, if any. */
113
+ selectedTaskId?: string;
114
+ /** Task id whose transcript pane is open, if any. */
115
+ viewedTaskId?: string;
116
+ /**
117
+ * True while the inline panel holds the cursor. Every agent is then listed
118
+ * (no MAX_AGENTS_DISPLAY cap) so keyboard navigation can reach all of them;
119
+ * the idle widget stays capped to keep the prompt area small.
120
+ */
121
+ focused?: boolean;
122
+ }
123
+
124
+ /** Short display form of a model id: `zai/glm-5.3` → `glm-5.3`. */
125
+ function shortModelLabel(agent: CrewAgentRecord, run: TeamRunManifest): string | undefined {
126
+ const model = agent.model ?? run.modelContext?.parentModel ?? run.modelContext?.override;
127
+ if (typeof model !== "string" || !model) return undefined;
128
+ return model.split("/").at(-1) ?? model;
129
+ }
130
+
131
+ /** One flat dock row (pi-subtask style) for an agent — active or finished. */
132
+ function compactAgentRow(
133
+ run: TeamRunManifest,
134
+ agent: CrewAgentRecord,
135
+ finished: boolean,
136
+ runs: readonly WidgetRun[],
137
+ options: WidgetRenderOptions,
138
+ width: number,
139
+ liveHandle: ReturnType<typeof listLiveAgents>[number] | undefined,
140
+ ): string {
141
+ const marker = options.selectedTaskId === agent.taskId ? "❯" : " ";
142
+ const dockGlyph = options.viewedTaskId === agent.taskId ? "⏺" : dockStatusIcon(agent.status);
143
+ const name = liveHandle?.agent ?? agent.agent;
144
+ const label = liveHandle?.description ?? agent.role ?? "";
145
+ // Task-first: the agent exists to run its task, so the row names the task
146
+ // right after the agent. With multiple runs, prefix each row with its run
147
+ // label so the flat dock still says which run an agent belongs to.
148
+ const runTag = runs.length > 1 ? `${shortRunLabel(run)} · ` : "";
149
+ const taskTag = agent.taskId ? ` · ${agent.taskId}` : "";
150
+ const roleTag = label && label !== agent.taskId && label !== name ? ` · ${label}` : "";
151
+ const nameText = runTag + name + taskTag + roleTag;
152
+ // pi-subtask activity: the worker's latest line while running, otherwise
153
+ // the status word.
154
+ const liveLine = liveHandle?.activity?.responseText
155
+ ?.split("\n")
156
+ .find((line) => line.trim())
157
+ ?.trim();
158
+ const activity =
159
+ !finished && liveHandle?.status === "running" && liveLine
160
+ ? liveLine.length > 60
161
+ ? `${liveLine.slice(0, 60)}…`
162
+ : liveLine
163
+ : finished
164
+ ? dockStatusLabel(agent.status)
165
+ : agentActivity(agent, liveHandle);
166
+ const usage = dockUsageText(agent, liveHandle, { viewed: options.viewedTaskId === agent.taskId });
167
+ const ageText = dockElapsed(agent.completedAt ?? agent.startedAt);
168
+ const model = shortModelLabel(agent, run);
169
+ // Stats tail: `· glm-5.3 · ↑1.2k ↓350 · 41s` — the model the worker is
170
+ // actually on first, then usage, then elapsed.
171
+ const suffix = `${model ? ` · ${model}` : ""}${usage ? ` · ${usage}` : ""}${ageText ? ` · ${ageText}` : ""}`;
172
+ return budgetedRow({ lead: `${marker} ${dockGlyph} `, name: nameText, activity, suffix }, width);
173
+ }
174
+
175
+ /**
176
+ * The compact dock: hint line, the `main` conversation row, then a
177
+ * MAX_AGENTS_DISPLAY-row SCROLL WINDOW over the flat agent list (exactly the
178
+ * order the inline panel navigates — `panelRowsFromRuns` parity). The window
179
+ * follows the panel selection bottom-pinned: moving the cursor past the
180
+ * window scrolls one row at a time and the ❯ marker is always painted; idle
181
+ * (no selection) shows the top of the list, which the shared ordering puts at
182
+ * the highest-priority live agents. Hidden rows surface as `… ↑N earlier` /
183
+ * `… +N more` indicators.
184
+ */
185
+ function compactDockLines(
186
+ runs: WidgetRun[],
187
+ options: WidgetRenderOptions,
188
+ width: number,
189
+ maxLines: number,
190
+ notificationCount: number,
191
+ runningGlyph: string,
192
+ ): string[] {
193
+ const now = Date.now();
194
+ const flat: Array<{
195
+ run: TeamRunManifest;
196
+ agent: CrewAgentRecord;
197
+ finished: boolean;
198
+ liveHandle: ReturnType<typeof listLiveAgents>[number] | undefined;
199
+ }> = [];
200
+ for (const entry of runs) {
201
+ const { active, finished } = orderWidgetAgents(entry, now);
202
+ const liveForRun = listLiveAgents().filter((a) => a.runId === entry.run.runId);
203
+ for (const agent of active) {
204
+ flat.push({ run: entry.run, agent, finished: false, liveHandle: liveForRun.find((h) => h.taskId === agent.taskId) });
205
+ }
206
+ for (const agent of finished) {
207
+ flat.push({ run: entry.run, agent, finished: true, liveHandle: liveForRun.find((h) => h.taskId === agent.taskId) });
208
+ }
209
+ }
210
+
211
+ // No agents at all: fall back to the legacy header so the space under the
212
+ // editor is never just blank.
213
+ if (flat.length === 0) return [widgetHeader(runs, runningGlyph, maxLines, notificationCount)];
214
+
215
+ const lines: string[] = [];
216
+ let hint: string;
217
+ if (options.viewedTaskId) {
218
+ const viewedName = flat.find((row) => row.agent.taskId === options.viewedTaskId)?.agent.agent ?? "agent";
219
+ hint = `viewing @${viewedName} — typing goes to the agent · ↓ switch · esc back`;
220
+ } else if (options.focused) {
221
+ hint = "enter to view · x to stop/cancel · esc back";
222
+ } else {
223
+ hint = `agents (${flat.length}) — ↓ to select`;
224
+ }
225
+ lines.push(truncate(hint, width));
226
+ // Filled ● = you're on the main conversation; hollow ◯ = an agent view is
227
+ // open (same convention as pi-subtask's main row).
228
+ const mainMarker = options.focused && !options.selectedTaskId ? "❯" : " ";
229
+ const mainIcon = options.viewedTaskId ? "◯" : "●";
230
+ lines.push(truncate(`${mainMarker} ${mainIcon} main`, width));
231
+
232
+ const selectedIndex = options.selectedTaskId ? flat.findIndex((row) => row.agent.taskId === options.selectedTaskId) : -1;
233
+ const windowStart = selectedIndex >= 0 ? Math.max(0, selectedIndex - MAX_AGENTS_DISPLAY + 1) : 0;
234
+ const windowEnd = Math.min(flat.length, windowStart + MAX_AGENTS_DISPLAY);
235
+ if (windowStart > 0) lines.push(truncate(` … ↑${windowStart} earlier (↑ to scroll)`, width));
236
+ for (const row of flat.slice(windowStart, windowEnd)) {
237
+ lines.push(compactAgentRow(row.run, row.agent, row.finished, runs, options, width, row.liveHandle));
238
+ }
239
+ if (windowEnd < flat.length) lines.push(truncate(` … +${flat.length - windowEnd} more (↓ to scroll)`, width));
240
+ return lines;
241
+ }
242
+
48
243
  export function buildWidgetLines(
49
244
  cwd: string,
50
245
  frame = 0,
@@ -52,7 +247,10 @@ export function buildWidgetLines(
52
247
  providedRuns?: WidgetRun[],
53
248
  notificationCount = 0,
54
249
  width = DEFAULT_WIDGET_WIDTH,
250
+ options: WidgetRenderOptions = {},
55
251
  ): string[] {
252
+ const rowStyle: WidgetRowStyle = options.rowStyle ?? "detailed";
253
+ const focused = options.focused === true;
56
254
  // Match the legacy `buildCrewWidgetLines` API: when no runs are supplied,
57
255
  // auto-fetch via activeWidgetRuns(cwd). Otherwise widgets calling with
58
256
  // only `(cwd, frame)` would render an empty line set (regression vs. the
@@ -61,18 +259,20 @@ export function buildWidgetLines(
61
259
  if (!runs.length) return [];
62
260
 
63
261
  const runningGlyph = spinnerFrame("widget-header");
262
+
263
+ // Compact = pi-subtask's dock: NO "Crew agents" header, NO tree — hint
264
+ // line, `main` row, then a 3-row scroll window over the flat agent list.
265
+ if (rowStyle === "compact") {
266
+ const lines = compactDockLines(runs, options, width, maxLines, notificationCount, runningGlyph);
267
+ return focused ? lines : lines.slice(0, maxLines);
268
+ }
269
+
64
270
  const lines: string[] = [widgetHeader(runs, runningGlyph, maxLines, notificationCount)];
65
271
 
66
- for (const { run, agents, snapshot } of runs) {
67
- const activeAgents = agents.filter((a) => a.status === "running" || a.status === "queued" || a.status === "waiting");
272
+ for (const entry of runs) {
273
+ const { run, agents } = entry;
68
274
  const now = Date.now();
69
- const finishedAgents = agents.filter((item) => {
70
- if (item.status === "running" || item.status === "queued" || item.status === "waiting") return false;
71
- if (!item.completedAt) return false;
72
- const maxAgeMs = (ERROR_STATUSES.has(item.status) ? ERROR_LINGER_MAX_AGE : FINISHED_LINGER_MAX_AGE) * 60_000;
73
- const age = now - new Date(item.completedAt).getTime();
74
- return Number.isFinite(age) && age < maxAgeMs;
75
- });
275
+ const { active: activeAgents, finished: finishedAgents } = orderWidgetAgents(entry, now);
76
276
  const completed = agents.filter((a) => a.status === "completed").length;
77
277
  // WP-3 (H4): while a run is parked awaiting plan approval, the spinner
78
278
  // glyph is replaced by a `⚠ plan:<last-8 runId>` badge. Plain-unicode ⚠
@@ -90,11 +290,6 @@ export function buildWidgetLines(
90
290
  // activity line) and is GUARANTEED stable across ticks:
91
291
  // - agents count — from `agents` array, always populated, never empty.
92
292
  // - run elapsed — from `run.createdAt`, always set on manifest.
93
- // Both come from sources with no race window — `agents` is read from
94
- // snapshot.agents OR agentsFor(run) (both always return same length
95
- // for a healthy run), and `run.createdAt` is immutable. The format
96
- // shape `"X/Y agents · Ns"` is therefore truly invariant: same number
97
- // of `·`-separated fields, same field meanings, every render tick.
98
293
  //
99
294
  // Bug 022 (timer-fix + label): for TERMINAL runs (failed/cancelled/
100
295
  // completed) the elapsed counter previously kept ticking up forever
@@ -112,56 +307,58 @@ export function buildWidgetLines(
112
307
 
113
308
  const liveForRun = listLiveAgents().filter((a) => a.runId === run.runId);
114
309
 
115
- // L-4: prioritize RUNNING > QUEUED > WAITING within the visible window so the
116
- // most relevant live workers are always shown. Finished rows fill only the
117
- // leftover budget and never steal slots from running agents.
118
- const ACTIVE_PRIORITY: Record<string, number> = {
119
- running: 0,
120
- queued: 1,
121
- waiting: 2,
122
- };
123
- const prioritizedActive = [...activeAgents].sort((a, b) => (ACTIVE_PRIORITY[a.status] ?? 9) - (ACTIVE_PRIORITY[b.status] ?? 9));
310
+ // Focused: list every agent so the keyboard cursor can reach it. Idle: keep
311
+ // the historical cap so the prompt area stays small.
312
+ const activeCap = focused ? activeAgents.length : MAX_AGENTS_DISPLAY;
124
313
  // Finished rows only appear in slots not used by active agents (max 2). When
125
314
  // there are >= MAX_AGENTS_DISPLAY live workers, finished rows are suppressed
126
315
  // entirely so they cannot push a live agent's activity line off-screen.
127
- const finishedSlots = Math.max(0, Math.min(2, MAX_AGENTS_DISPLAY - activeAgents.length));
316
+ const finishedSlots = focused ? finishedAgents.length : Math.max(0, Math.min(2, MAX_AGENTS_DISPLAY - activeAgents.length));
317
+
318
+ /** Cursor marker column, mirroring the inline panel's selection. */
319
+ const markerFor = (taskId: string): string => (options.selectedTaskId === taskId ? "❯" : " ");
128
320
 
129
- const visibleAgents = prioritizedActive.slice(0, MAX_AGENTS_DISPLAY);
321
+ const visibleAgents = activeAgents.slice(0, activeCap);
130
322
  for (const [index, agent] of visibleAgents.entries()) {
131
- const last = index === visibleAgents.length - 1 && activeAgents.length <= MAX_AGENTS_DISPLAY && finishedSlots === 0;
323
+ const last = index === visibleAgents.length - 1 && activeAgents.length <= activeCap && finishedSlots === 0;
132
324
  const branch = last ? "└─" : "├─";
133
- const agentGlyph = iconForStatus(agent.status, { runningGlyph });
134
325
  const liveHandle = liveForRun.find((h) => h.taskId === agent.taskId);
326
+ const legacyGlyph = options.viewedTaskId === agent.taskId ? "◉" : iconForStatus(agent.status, { runningGlyph });
135
327
  const stats = agentStats(agent, liveHandle);
136
328
  const name = liveHandle?.agent ?? agent.agent;
329
+ const activity = agentActivity(agent, liveHandle);
137
330
  const desc = truncate(liveHandle?.description ?? agent.role ?? "", TASK_DESC_MAX);
138
- const _activeMain = truncate(`│ ${branch} ${agentGlyph} ${name}${desc ? ` · ${desc}` : ` · ${agent.role}`}`, width);
331
+ const _activeMain = truncate(`│ ${branch} ${legacyGlyph} ${name}${desc ? ` · ${desc}` : ` · ${agent.role}`}`, width);
139
332
  lines.push(_activeMain);
140
- const _activity = truncate(`│ ⊶ ${agentActivity(agent, liveHandle)}${stats ? ` · ${stats}` : ""}`, width);
333
+ const _activity = truncate(`│ ⊶ ${activity}${stats ? ` · ${stats}` : ""}`, width);
141
334
  lines.push(_activity);
142
335
  }
143
336
 
144
- if (activeAgents.length > MAX_AGENTS_DISPLAY) {
145
- lines.push(truncate(`│ └─ … +${activeAgents.length - MAX_AGENTS_DISPLAY} more agents`, width));
337
+ if (activeAgents.length > activeCap) {
338
+ lines.push(truncate(`│ └─ … +${activeAgents.length - activeCap} more agents`, width));
146
339
  }
147
340
 
148
341
  for (const [index, agent] of finishedAgents.slice(0, finishedSlots).entries()) {
149
342
  const liveHandle = liveForRun.find((h) => h.taskId === agent.taskId);
150
343
  const name = liveHandle?.agent ?? agent.agent;
151
- const icon =
344
+ const legacyIcon =
152
345
  agent.status === "completed" ? "✓" : agent.status === "failed" ? "✗" : agent.status === "needs_attention" ? "⚠" : "▪";
153
346
  const stats = agentStats(agent, liveHandle);
154
347
  const desc = truncate(liveHandle?.description ?? agent.role ?? "", TASK_DESC_MAX);
155
348
  const isLastFinished = index === Math.min(finishedAgents.length, finishedSlots) - 1;
156
349
  const branch = isLastFinished ? "└─" : "├─";
157
- const _finished = truncate(`│ ${branch} ${icon} ${name} · ${desc}${stats ? ` · ${stats}` : ""}`, width);
350
+ const _finished = truncate(`│ ${branch} ${legacyIcon} ${name} · ${desc}${stats ? ` · ${stats}` : ""}`, width);
158
351
  lines.push(_finished);
159
352
  }
160
353
 
161
- if (lines.length >= maxLines) break;
354
+ // Focused (inline panel cursor active): the keyboard can reach every
355
+ // agent, so the renderer must list them ALL — clipping here would put
356
+ // the cursor marker (❯) on rows that are never painted. The idle
357
+ // widget keeps its historical cap to hold the prompt area small.
358
+ if (lines.length >= maxLines && !focused) break;
162
359
  }
163
360
 
164
- return lines.slice(0, maxLines);
361
+ return focused ? lines : lines.slice(0, maxLines);
165
362
  }
166
363
 
167
364
  // ── Colorization ──────────────────────────────────────────────────────
@@ -24,16 +24,27 @@ export interface CrewWidgetModel {
24
24
  /** P3 (#9): workspace/session ID for filtering runs by ownerSessionId
25
25
  * on every render, not just the first frame. */
26
26
  workspaceId?: string;
27
+ /** Per-agent row layout; defaults to the historical two-line tree. */
28
+ rowStyle?: import("./widget-renderer.ts").WidgetRowStyle;
27
29
  }
28
30
 
29
31
  export interface CrewWidgetState {
30
32
  frame: number;
31
33
  lastPlacement?: string;
32
34
  lastVisibility?: "hidden" | "visible";
35
+ /** Whether the aboveEditor task-list widget (pi-crew-tasks) is mounted. */
36
+ lastTasksVisibility?: "hidden" | "visible";
33
37
  lastKey?: string;
34
38
  lastMaxLines?: number;
35
39
  lastCwd?: string;
36
40
  legacyCleared?: boolean;
37
41
  model?: CrewWidgetModel;
38
42
  notificationCount?: number;
43
+ /**
44
+ * Non-serializable dock host used when `widgetPlacement` is `"bottom"`:
45
+ * the dock renders through the crew-vibes footer instead of a pi widget
46
+ * slot. Kept off `model` so serialization/persistence of the rest of the
47
+ * state stays unaffected.
48
+ */
49
+ footerDock?: { render(width: number): string[]; dispose(): void };
39
50
  }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * child-process-shield.ts — stop abort-driven child-process errors from
3
+ * killing the host pi process.
4
+ *
5
+ * THE CRASH
6
+ * ---------
7
+ * Node spawns a child with `options.signal` and the signal fires while that
8
+ * child is still tracked (before its 'exit' event has dispatched), Node runs:
9
+ *
10
+ * abortChildProcess(child, killSignal, reason)
11
+ * try { if (child.kill(killSignal)) {
12
+ * child.emit("error", new AbortError(...)) // node:child_process
13
+ * } } catch (err) { child.emit("error", err); }
14
+ *
15
+ * `child.emit("error", ...)` THROWS synchronously when the ChildProcess has
16
+ * no 'error' listener (EventEmitter 'error'-event semantics). That throw is
17
+ * captured by the AbortSignal dispatch machinery and re-thrown on the next
18
+ * tick (`process.nextTick(() => { throw err; })`, node:internal/event_target)
19
+ * — OUTSIDE any try/catch, so it surfaces as `uncaughtException` and pi dies
20
+ * (`pi exiting due to uncaughtException: AbortError: The operation was
21
+ * aborted`). This is exactly the crash seen on /crew-view session switches
22
+ * mid-foreground-run: the run's signal propagates into the vendored pi SDK's
23
+ * session machinery, an internal controller abort reaches a signal-spawned
24
+ * child with no error listener, and the terminal dies.
25
+ *
26
+ * IMPORTANT: because the rethrow is asynchronous (next tick), wrapping the
27
+ * AbortController.abort() call in try/catch (see safe-abort.ts) CANNOT stop
28
+ * it. The only reliable fix is to make sure the spawned child ALWAYS has an
29
+ * 'error' listener before an abort can fire, so child.emit("error", ...)
30
+ * dispatches instead of throwing.
31
+ *
32
+ * THE SHIELD
33
+ * ----------
34
+ * 1. `ChildProcess.prototype.kill` — every abort path in node runs through
35
+ * `child.kill()` FIRST (abortChildProcess calls it before emitting), so a
36
+ * no-op 'error' listener attached inside kill() protects every signal-
37
+ * spawned child IN THE PROCESS, regardless of when the spawning module
38
+ * captured its `spawn` reference (the vendored SDK may bind `spawn` at a
39
+ * point we cannot observe).
40
+ * 2. `child_process.spawn` wrapper — attaches the same no-op 'error' listener
41
+ * at spawn time when `options.signal` is present, so the listener exists
42
+ * before Node's pre-aborted-signal nextTick abort and before spawn-failure
43
+ * errors (ENOENT) are emitted.
44
+ *
45
+ * The no-op listener never swallows anything a consumer cares about: 'error'
46
+ * events still reach every other listener (multiple listeners run), and code
47
+ * that previously died on an unhandled 'error' event keeps its semantics for
48
+ * genuinely broken spawns — it just no longer takes down the whole pi process
49
+ * for the abort-after-exit case, which is a semantic no-op by definition (the
50
+ * child was already gone).
51
+ */
52
+ import type { ChildProcess } from "node:child_process";
53
+ import { createRequire } from "node:module";
54
+
55
+ const require = createRequire(import.meta.url);
56
+ const childProcessModule = require("node:child_process") as {
57
+ spawn: (file: string, args?: readonly string[] | undefined, options?: object) => ChildProcess;
58
+ ChildProcess: { prototype: { kill: (signal?: number | string) => boolean } };
59
+ };
60
+
61
+ const SHIELD_MARKER = Symbol.for("pi-crew.childProcessAbortShieldInstalled");
62
+
63
+ /** No-op 'error' listener: prevents the EventEmitter throw, never filters data. */
64
+ function shieldErrorListener(): void {
65
+ /* deliberately empty — see module docstring */
66
+ }
67
+
68
+ /**
69
+ * Install the child-process abort shield. Idempotent: subsequent calls are
70
+ * no-ops. MUST be called at extension registration, before any team run can
71
+ * start (the vendored SDK is imported lazily later, so any spawn it binds
72
+ * afterwards already sees the shielded spawn).
73
+ */
74
+ export function installChildProcessAbortShield(): void {
75
+ const proto = childProcessModule.ChildProcess.prototype as typeof childProcessModule.ChildProcess.prototype & {
76
+ [SHIELD_MARKER]?: boolean;
77
+ };
78
+ if (proto[SHIELD_MARKER]) return;
79
+
80
+ const originalKill = proto.kill;
81
+ proto.kill = function shieldedKill(this: ChildProcess, signal?: number | string): boolean {
82
+ // A signal-spawned child may be aborted synchronously by node's
83
+ // abortChildProcess right after this kill() returns; guarantee a
84
+ // listener is present so child.emit("error", AbortError) dispatches
85
+ // instead of throwing.
86
+ if (this.listenerCount("error") === 0) this.on("error", shieldErrorListener);
87
+ return originalKill.call(this, signal);
88
+ };
89
+
90
+ const moduleObject = childProcessModule as {
91
+ spawn: typeof childProcessModule.spawn;
92
+ };
93
+ const originalSpawn = moduleObject.spawn;
94
+ moduleObject.spawn = function shieldedSpawn(this: unknown, ...args: Parameters<typeof childProcessModule.spawn>) {
95
+ const child = originalSpawn.apply(this, args);
96
+ // Attach at spawn time: covers pre-aborted signals (node defers the
97
+ // abort to the next tick) and spawn-failure errors (ENOENT etc.)
98
+ // before any abort could fire.
99
+ if (child && (args[2] as { signal?: unknown } | undefined)?.signal !== undefined) {
100
+ child.on("error", shieldErrorListener);
101
+ }
102
+ return child;
103
+ };
104
+
105
+ proto[SHIELD_MARKER] = true;
106
+ }
@@ -34,7 +34,14 @@ export const PEM_PRIVATE_KEY_PATTERN = /-----BEGIN [A-Z ]+PRIVATE KEY-----[\s\S]
34
34
  // keyword-scan matches '_token' in 'prompt_tokens' (underscore + "token"),
35
35
  // falsely classifying usage counts as secrets. See performance/quality
36
36
  // assessment fix #5.
37
+ //
38
+ // `tokens` alone must stay too: pi-crew state records use
39
+ // `progress.tokens` / `status.tokens` as the per-task usage total; the
40
+ // camelCase fallback scan in isSecretKey() matches the bare keyword at
41
+ // start+end of the name and would otherwise rewrite `15` to the literal
42
+ // "***" on disk, which the widget then renders verbatim as `*** tok`.
37
43
  const TOKEN_COUNT_KEYS = new Set([
44
+ "tokens",
38
45
  "prompt_tokens",
39
46
  "completion_tokens",
40
47
  "total_tokens",
@@ -0,0 +1,45 @@
1
+ /**
2
+ * safe-abort.ts — abort helpers that cannot crash the host process.
3
+ *
4
+ * When a child process was spawned with `options.signal` and the signal fires
5
+ * AFTER that child already exited (fast workers, cleanup-after-completion,
6
+ * session switches mid-run), Node's child_process abort listener throws
7
+ * `AbortError: The operation was aborted` SYNCHRONOUSLY from
8
+ * `AbortController.abort()` (node:child_process abortChildProcess).
9
+ *
10
+ * In pi extension event handlers that throw propagates straight into pi's
11
+ * `uncaughtException` handling and kills the whole terminal session — the
12
+ * crash seen here: session_before_switch → stopSessionBoundSubagents → abort
13
+ * → AbortError → "pi exiting due to uncaughtException".
14
+ *
15
+ * Abort-after-exit is a semantic no-op (the work is already gone), so every
16
+ * controller abort that can be linked to a spawned child MUST go through
17
+ * these helpers: swallow the throw, log it, carry on.
18
+ */
19
+
20
+ import { logInternalError } from "./internal-error.ts";
21
+
22
+ /** Minimal controller shape — anything with an `abort()` method. */
23
+ export interface Abortable {
24
+ abort(): void;
25
+ }
26
+
27
+ /**
28
+ * Abort a controller, tolerating Node's abort-after-child-exit AbortError.
29
+ * Never throws. `scope` names the call site for the internal error log.
30
+ */
31
+ export function safeAbort(controller: Abortable | undefined, scope: string): void {
32
+ if (!controller) return;
33
+ try {
34
+ controller.abort();
35
+ } catch (error) {
36
+ logInternalError(`safe-abort.${scope}`, error);
37
+ }
38
+ }
39
+
40
+ /** Abort every controller in an iterable; a throwing controller does not
41
+ * prevent the remaining ones from being aborted. */
42
+ export function safeAbortAll(controllers: Iterable<Abortable> | undefined, scope: string): void {
43
+ if (!controllers) return;
44
+ for (const controller of controllers) safeAbort(controller, scope);
45
+ }
@@ -191,6 +191,7 @@ function parseWorkflowFile(filePath: string, source: ResourceSource): WorkflowCo
191
191
  topology: parseTopology(frontmatter.topology),
192
192
  coalesceMicroTasks: parseOptionalBoolean(frontmatter.coalesceMicroTasks),
193
193
  specStrict: parseOptionalBoolean(frontmatter.specStrict),
194
+ adaptive: parseOptionalBoolean(frontmatter.adaptive),
194
195
  steps,
195
196
  };
196
197
  } catch {
@@ -75,6 +75,13 @@ export interface WorkflowConfig {
75
75
  * Requires a verifier-role step (reject-start, §5) and fails the write-gate
76
76
  * on coverage gaps or machine-check failures (§4). */
77
77
  specStrict?: boolean;
78
+ /** Frontmatter `adaptive: true` — the workflow's `assess` step (planner role)
79
+ * emits an ADAPTIVE_PLAN_JSON block that the runtime parses and injects as
80
+ * concrete tasks, so the plan painted in the task list is derived from the
81
+ * goal, not from fixed phase templates. The `implementation` workflow is
82
+ * adaptive by name (backward compat); this flag extends the mechanism to
83
+ * any workflow. See src/runtime/goal-workflow/adaptive-plan.ts. */
84
+ adaptive?: boolean;
78
85
  }
79
86
 
80
87
  /** A dynamic workflow (runtime === "dynamic"). steps is empty — the script is the source of truth. */
@@ -1,31 +1,41 @@
1
1
  ---
2
2
  name: default
3
- description: Explore, plan, execute, and verify
4
- topology: sequential
3
+ description: Plan the goal into concrete tasks, execute them in parallel phases, verify
4
+ topology: complex-dag
5
+ adaptive: true
5
6
  ---
6
7
 
7
- ## explore
8
- role: explorer
9
-
10
- Explore the codebase for the goal: {goal}
11
-
12
- ## plan
8
+ ## assess
13
9
  role: planner
14
- dependsOn: explore
15
- output: plan.md
16
-
17
- Create a concise implementation plan for: {goal}
18
-
19
- ## execute
20
- role: executor
21
- dependsOn: plan
22
-
23
- Implement the plan for: {goal}
24
-
25
- ## verify
26
- role: verifier
27
- dependsOn: execute
28
- verify: true
29
-
30
- Verify completion for: {goal}
31
- Run FAST checks ONCE (cache output to .crew/cache/): `npm run test:critical && npx tsc --noEmit` (completes in <60s). Do NOT run `npm run test:unit` or `npm test` — too slow (642 files, >4 min). Cross-reference cached output with the changes. Do NOT re-run tests. Give PASS or FAIL with specific test evidence.
10
+ output: adaptive-plan.json
11
+
12
+ Plan the concrete task list for: {goal}
13
+
14
+ You are the orchestration planner. Inspect the repository enough to break the goal into specific, executable tasks; do not use a fixed template. Small tasks may need one executor plus one verifier; broader tasks may need parallel explorers, executors, and a verification phase.
15
+
16
+ Return a concise rationale, then include exactly one JSON block between these markers:
17
+
18
+ ADAPTIVE_PLAN_JSON_START
19
+ {
20
+ "phases": [
21
+ {
22
+ "name": "short-phase-name",
23
+ "tasks": [
24
+ {
25
+ "role": "explorer|executor|verifier",
26
+ "title": "short task title",
27
+ "task": "specific autonomous task prompt for this subagent"
28
+ }
29
+ ]
30
+ }
31
+ ]
32
+ }
33
+ ADAPTIVE_PLAN_JSON_END
34
+
35
+ Rules:
36
+ - **MAXIMIZE PARALLELISM**: Put independent tasks in the SAME phase so they run concurrently. Never create sequential phases when tasks are independent.
37
+ - Task titles name the concrete piece of work — they become the task list the user watches.
38
+ - Choose the smallest effective number of tasks per phase; a simple goal may have 1-2 phases with 1-2 tasks.
39
+ - End implementation plans with a verification phase (`verifier` role): run FAST targeted checks only (e.g. the affected test files), never the full suite.
40
+ - Tasks within the same phase run in parallel; phases run sequentially.
41
+ - Do not include more than 12 tasks per phase; split or summarize oversized plans instead.