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.
- package/CHANGELOG.md +98 -0
- package/NOTICE.md +21 -0
- package/README.md +44 -2
- package/agents/executor.md +1 -1
- package/agents/test-engineer.md +1 -1
- package/dist/index.mjs +2781 -949
- package/package.json +1 -1
- package/schema.json +492 -94
- package/src/config/config-validation.ts +7 -1
- package/src/config/defaults.ts +8 -1
- package/src/config/role-tools.ts +4 -2
- package/src/config/types.ts +19 -1
- package/src/extension/crew-cleanup.ts +13 -0
- package/src/extension/crew-vibes/footer.ts +19 -0
- package/src/extension/crew-vibes/index.ts +11 -1
- package/src/extension/register.ts +8 -0
- package/src/extension/registration/command-registration.ts +1 -0
- package/src/extension/registration/commands/run.ts +15 -1
- package/src/extension/registration/commands/shared.ts +8 -0
- package/src/extension/registration/foreground-run-controller.ts +10 -2
- package/src/extension/registration/lifecycle-handlers.ts +52 -2
- package/src/extension/registration/runtime-cleanup.ts +23 -5
- package/src/extension/registration/team-tool.ts +5 -1
- package/src/extension/registration/ui.ts +5 -4
- package/src/extension/team-tool/run-deadline.ts +20 -3
- package/src/extension/team-tool/run.ts +26 -2
- package/src/runtime/child-pi/child-pi-constants.ts +8 -0
- package/src/runtime/child-pi/child-pi-streams.ts +21 -1
- package/src/runtime/detached-run-results.ts +90 -0
- package/src/runtime/goal-workflow/adaptive-plan.ts +30 -3
- package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +5 -1
- package/src/runtime/merge-gate.ts +25 -9
- package/src/runtime/model/model-fallback.ts +7 -3
- package/src/runtime/process-status.ts +16 -1
- package/src/runtime/recovery/crash-recovery.ts +16 -0
- package/src/runtime/run-tracker.ts +77 -10
- package/src/runtime/team-runner.ts +3 -2
- package/src/schema/config-schema.ts +1 -1
- package/src/state/stores/plan-store.ts +1 -1
- package/src/state/stores/state-store.ts +73 -3
- package/src/state/types.ts +2 -0
- package/src/ui/dock-footer.ts +49 -0
- package/src/ui/inline-panel/agent-pane.ts +375 -0
- package/src/ui/inline-panel/agent-transcript.ts +338 -0
- package/src/ui/inline-panel/agent-view-overlay.ts +225 -0
- package/src/ui/inline-panel/crew-editor.ts +192 -0
- package/src/ui/inline-panel/index.ts +290 -0
- package/src/ui/inline-panel/panel-rows.ts +37 -0
- package/src/ui/inline-panel/panel-selection.ts +157 -0
- package/src/ui/inline-panel/panel-store.ts +111 -0
- package/src/ui/inline-panel/view-session-store.ts +36 -0
- package/src/ui/pi-ui-compat.ts +9 -0
- package/src/ui/widget/index.ts +176 -22
- package/src/ui/widget/task-list.ts +198 -0
- package/src/ui/widget/widget-formatters.ts +240 -4
- package/src/ui/widget/widget-renderer.ts +234 -37
- package/src/ui/widget/widget-types.ts +11 -0
- package/src/utils/child-process-shield.ts +106 -0
- package/src/utils/redaction.ts +7 -0
- package/src/utils/safe-abort.ts +45 -0
- package/src/workflows/discover-workflows.ts +1 -0
- package/src/workflows/workflow-config.ts +7 -0
- package/workflows/default.workflow.md +36 -26
- 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 {
|
|
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
|
|
67
|
-
const
|
|
272
|
+
for (const entry of runs) {
|
|
273
|
+
const { run, agents } = entry;
|
|
68
274
|
const now = Date.now();
|
|
69
|
-
const finishedAgents =
|
|
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
|
-
//
|
|
116
|
-
//
|
|
117
|
-
|
|
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 =
|
|
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 <=
|
|
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} ${
|
|
331
|
+
const _activeMain = truncate(`│ ${branch} ${legacyGlyph} ${name}${desc ? ` · ${desc}` : ` · ${agent.role}`}`, width);
|
|
139
332
|
lines.push(_activeMain);
|
|
140
|
-
const _activity = truncate(`│ ⊶ ${
|
|
333
|
+
const _activity = truncate(`│ ⊶ ${activity}${stats ? ` · ${stats}` : ""}`, width);
|
|
141
334
|
lines.push(_activity);
|
|
142
335
|
}
|
|
143
336
|
|
|
144
|
-
if (activeAgents.length >
|
|
145
|
-
lines.push(truncate(`│ └─ … +${activeAgents.length -
|
|
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
|
|
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} ${
|
|
350
|
+
const _finished = truncate(`│ ${branch} ${legacyIcon} ${name} · ${desc}${stats ? ` · ${stats}` : ""}`, width);
|
|
158
351
|
lines.push(_finished);
|
|
159
352
|
}
|
|
160
353
|
|
|
161
|
-
|
|
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
|
+
}
|
package/src/utils/redaction.ts
CHANGED
|
@@ -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:
|
|
4
|
-
topology:
|
|
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
|
-
##
|
|
8
|
-
role: explorer
|
|
9
|
-
|
|
10
|
-
Explore the codebase for the goal: {goal}
|
|
11
|
-
|
|
12
|
-
## plan
|
|
8
|
+
## assess
|
|
13
9
|
role: planner
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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.
|