pi-crew 0.1.18 → 0.1.19
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 +8 -0
- package/README.md +2 -2
- package/docs/usage.md +1 -1
- package/package.json +1 -1
- package/schema.json +1 -1
- package/src/extension/register.ts +195 -12
- package/src/extension/team-tool.ts +24 -8
- package/src/runtime/concurrency.ts +42 -0
- package/src/runtime/progress-event-coalescer.ts +43 -0
- package/src/runtime/subagent-manager.ts +202 -0
- package/src/runtime/task-runner.ts +14 -5
- package/src/runtime/team-runner.ts +6 -4
- package/src/ui/powerbar-publisher.ts +7 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.19
|
|
4
|
+
|
|
5
|
+
- Added Claude-style `Agent`, `get_subagent_result`, and `steer_subagent` tools backed by pi-crew's durable worker runtime, plus conflict-safe `crew_agent`, `crew_agent_result`, and `crew_agent_steer` aliases.
|
|
6
|
+
- Added a durable subagent manager with background queueing, completion notifications, result joins, session-bound cleanup, and direct single-agent runs via `team run agent=...`.
|
|
7
|
+
- Disabled risky auto-opening of the right sidebar by default, added foreground completion notifications, and reduced duplicate widget/sidebar UI.
|
|
8
|
+
- Added progress coalescing and workflow concurrency helpers to keep foreground sessions responsive during busy worker output.
|
|
9
|
+
- Fixed live-session runs being classified as scaffold when workers are enabled and hardened session switch/shutdown cleanup for foreground child processes.
|
|
10
|
+
|
|
3
11
|
## 0.1.18
|
|
4
12
|
|
|
5
13
|
- Added a built-in `parallel-research` team/workflow for map-reduce style source audits with dynamic `Source/pi-*` fanout and parallel explorer shards.
|
package/README.md
CHANGED
|
@@ -174,7 +174,7 @@ Supported config:
|
|
|
174
174
|
"dashboardPlacement": "right",
|
|
175
175
|
"dashboardWidth": 56,
|
|
176
176
|
"dashboardLiveRefreshMs": 1000,
|
|
177
|
-
"autoOpenDashboard":
|
|
177
|
+
"autoOpenDashboard": false,
|
|
178
178
|
"autoOpenDashboardForForegroundRuns": true,
|
|
179
179
|
"showModel": true,
|
|
180
180
|
"showTokens": true,
|
|
@@ -190,7 +190,7 @@ Safety notes:
|
|
|
190
190
|
UI notes:
|
|
191
191
|
|
|
192
192
|
- `widgetPlacement`/`widgetMaxLines` keep the persistent active-run widget compact.
|
|
193
|
-
- `dashboardPlacement: "right"` is the default
|
|
193
|
+
- `dashboardPlacement: "right"` is the default for `/team-dashboard`; automatic overlay opening is opt-in because Pi custom overlays can be modal/focus-capturing in some terminals.
|
|
194
194
|
- `autoOpenDashboard`/`autoOpenDashboardForForegroundRuns` control whether the live sidebar opens automatically.
|
|
195
195
|
- `dashboardLiveRefreshMs` controls the live sidebar refresh cadence.
|
|
196
196
|
- `showModel`, `showTokens`, and `showTools` show worker model attempts, token usage, and tool activity in dashboard agent rows.
|
package/docs/usage.md
CHANGED
|
@@ -36,7 +36,7 @@ Supported fields:
|
|
|
36
36
|
"dashboardPlacement": "right",
|
|
37
37
|
"dashboardWidth": 56,
|
|
38
38
|
"dashboardLiveRefreshMs": 1000,
|
|
39
|
-
"autoOpenDashboard":
|
|
39
|
+
"autoOpenDashboard": false,
|
|
40
40
|
"autoOpenDashboardForForegroundRuns": true,
|
|
41
41
|
"showModel": true,
|
|
42
42
|
"showTokens": true,
|
package/package.json
CHANGED
package/schema.json
CHANGED
|
@@ -90,7 +90,7 @@
|
|
|
90
90
|
"dashboardPlacement": { "type": "string", "enum": ["center", "right"], "default": "right", "description": "Place /team-dashboard as a centered overlay or right-side panel." },
|
|
91
91
|
"dashboardWidth": { "type": "integer", "minimum": 32, "maximum": 120, "default": 56 },
|
|
92
92
|
"dashboardLiveRefreshMs": { "type": "integer", "minimum": 250, "maximum": 60000, "default": 1000 },
|
|
93
|
-
"autoOpenDashboard": { "type": "boolean", "default":
|
|
93
|
+
"autoOpenDashboard": { "type": "boolean", "default": false, "description": "Opt in to automatically opening the live right sidebar for foreground runs when UI is available. Disabled by default because Pi overlays are modal in some terminals." },
|
|
94
94
|
"autoOpenDashboardForForegroundRuns": { "type": "boolean", "default": true },
|
|
95
95
|
"showModel": { "type": "boolean", "default": true, "description": "Show worker model attempts in dashboard agent rows." },
|
|
96
96
|
"showTokens": { "type": "boolean", "description": "Show token usage in dashboard agent rows." },
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
1
2
|
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, ToolDefinition } from "@mariozechner/pi-coding-agent";
|
|
3
|
+
import { Type } from "typebox";
|
|
2
4
|
import { loadConfig } from "../config/config.ts";
|
|
3
5
|
import { registerAutonomousPolicy } from "./autonomous-policy.ts";
|
|
4
6
|
import { TeamToolParams, type TeamToolParamsValue } from "../schema/team-tool-schema.ts";
|
|
@@ -17,6 +19,7 @@ import { DurableTextViewer, DurableTranscriptViewer } from "../ui/transcript-vie
|
|
|
17
19
|
import { loadRunManifestById } from "../state/state-store.ts";
|
|
18
20
|
import { readCrewAgents } from "../runtime/crew-agent-records.ts";
|
|
19
21
|
import { terminateActiveChildPiProcesses } from "../runtime/child-pi.ts";
|
|
22
|
+
import { SubagentManager, type SubagentRecord, type SubagentSpawnOptions } from "../runtime/subagent-manager.ts";
|
|
20
23
|
|
|
21
24
|
function parseRunArgs(args: string): TeamToolParamsValue {
|
|
22
25
|
const tokens = args.match(/"[^"]*"|'[^']*'|\S+/g)?.map((token) => token.replace(/^['"]|['"]$/g, "")) ?? [];
|
|
@@ -100,6 +103,55 @@ function setNestedConfig(config: Record<string, unknown>, key: string, value: un
|
|
|
100
103
|
target[parts[parts.length - 1]!] = value;
|
|
101
104
|
}
|
|
102
105
|
|
|
106
|
+
function sendFollowUp(pi: ExtensionAPI, content: string): void {
|
|
107
|
+
const sender = (pi as unknown as { sendMessage?: (message: unknown, options?: unknown) => void }).sendMessage;
|
|
108
|
+
if (typeof sender !== "function") return;
|
|
109
|
+
sender.call(pi, { customType: "pi-crew-subagent-notification", content, display: true }, { deliverAs: "followUp", triggerTurn: true });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function formatSubagentRecord(record: SubagentRecord): string {
|
|
113
|
+
const duration = record.completedAt ? `${Math.round((record.completedAt - record.startedAt) / 1000)}s` : "running";
|
|
114
|
+
return [
|
|
115
|
+
`Agent: ${record.id}`,
|
|
116
|
+
`Type: ${record.type}`,
|
|
117
|
+
`Status: ${record.status}`,
|
|
118
|
+
record.runId ? `Run: ${record.runId}` : undefined,
|
|
119
|
+
`Description: ${record.description}`,
|
|
120
|
+
record.model ? `Model: ${record.model}` : undefined,
|
|
121
|
+
`Duration: ${duration}`,
|
|
122
|
+
record.error ? `Error: ${record.error}` : undefined,
|
|
123
|
+
].filter((line): line is string => Boolean(line)).join("\n");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function readSubagentRunResult(ctx: ExtensionContext | ExtensionCommandContext, record: SubagentRecord): string | undefined {
|
|
127
|
+
if (!record.runId) return record.result;
|
|
128
|
+
const loaded = loadRunManifestById(ctx.cwd, record.runId);
|
|
129
|
+
const task = loaded?.tasks.find((item) => item.resultArtifact) ?? loaded?.tasks[0];
|
|
130
|
+
const path = task?.resultArtifact?.path;
|
|
131
|
+
if (!path) return undefined;
|
|
132
|
+
try {
|
|
133
|
+
return fs.readFileSync(path, "utf-8").trim();
|
|
134
|
+
} catch {
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function subagentToolResult(text: string, details: Record<string, unknown> = {}, isError = false) {
|
|
140
|
+
return { content: [{ type: "text" as const, text }], details, isError };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function __test__subagentSpawnParams(params: Record<string, unknown>, ctx: Pick<ExtensionContext, "cwd">): SubagentSpawnOptions {
|
|
144
|
+
return {
|
|
145
|
+
cwd: ctx.cwd,
|
|
146
|
+
type: typeof params.subagent_type === "string" && params.subagent_type.trim() ? params.subagent_type.trim() : "executor",
|
|
147
|
+
description: typeof params.description === "string" && params.description.trim() ? params.description.trim() : "pi-crew subagent",
|
|
148
|
+
prompt: typeof params.prompt === "string" ? params.prompt : "",
|
|
149
|
+
background: params.run_in_background === true,
|
|
150
|
+
model: typeof params.model === "string" && params.model.trim() ? params.model.trim() : undefined,
|
|
151
|
+
maxTurns: typeof params.max_turns === "number" && Number.isFinite(params.max_turns) ? params.max_turns : undefined,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
103
155
|
export function registerPiTeams(pi: ExtensionAPI): void {
|
|
104
156
|
const globalStore = globalThis as Record<string, unknown>;
|
|
105
157
|
const runtimeCleanupStoreKey = "__piCrewRuntimeCleanup";
|
|
@@ -112,18 +164,37 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
112
164
|
let rpcHandle: PiCrewRpcHandle | undefined;
|
|
113
165
|
let cleanedUp = false;
|
|
114
166
|
const widgetState: CrewWidgetState = { frame: 0 };
|
|
167
|
+
const subagentManager = new SubagentManager(4, (record) => {
|
|
168
|
+
if (!record.background || record.resultConsumed) return;
|
|
169
|
+
if (record.status === "completed" || record.status === "failed" || record.status === "cancelled" || record.status === "error") {
|
|
170
|
+
sendFollowUp(pi, [`pi-crew subagent ${record.id} ${record.status}.`, record.runId ? `Run: ${record.runId}` : undefined, `Use get_subagent_result with agent_id=${record.id} for output.`].filter((line): line is string => Boolean(line)).join("\n"));
|
|
171
|
+
}
|
|
172
|
+
});
|
|
115
173
|
const foregroundControllers = new Set<AbortController>();
|
|
116
174
|
let liveSidebarRunId: string | undefined;
|
|
117
175
|
let liveSidebarTimer: ReturnType<typeof setInterval> | undefined;
|
|
118
176
|
const requestRender = (ctx: ExtensionContext): void => (ctx.ui as { requestRender?: () => void }).requestRender?.();
|
|
177
|
+
const stopSessionBoundSubagents = (): void => {
|
|
178
|
+
for (const controller of foregroundControllers) controller.abort();
|
|
179
|
+
foregroundControllers.clear();
|
|
180
|
+
subagentManager.abortAll();
|
|
181
|
+
terminateActiveChildPiProcesses();
|
|
182
|
+
if (liveSidebarTimer) clearInterval(liveSidebarTimer);
|
|
183
|
+
liveSidebarTimer = undefined;
|
|
184
|
+
liveSidebarRunId = undefined;
|
|
185
|
+
if (currentCtx) stopCrewWidget(currentCtx, widgetState, loadConfig(currentCtx.cwd).config.ui);
|
|
186
|
+
clearPiCrewPowerbar(pi.events);
|
|
187
|
+
};
|
|
119
188
|
const openLiveSidebar = (ctx: ExtensionContext, runId: string): void => {
|
|
120
189
|
const uiConfig = loadConfig(ctx.cwd).config.ui;
|
|
121
|
-
const autoOpen = uiConfig?.autoOpenDashboard
|
|
122
|
-
const foregroundAutoOpen = uiConfig?.autoOpenDashboardForForegroundRuns
|
|
190
|
+
const autoOpen = uiConfig?.autoOpenDashboard === true;
|
|
191
|
+
const foregroundAutoOpen = uiConfig?.autoOpenDashboardForForegroundRuns !== false;
|
|
123
192
|
if (!ctx.hasUI || !autoOpen || !foregroundAutoOpen || (uiConfig?.dashboardPlacement ?? "right") !== "right") return;
|
|
124
193
|
if (liveSidebarRunId === runId) return;
|
|
125
194
|
if (liveSidebarTimer) clearInterval(liveSidebarTimer);
|
|
126
195
|
liveSidebarRunId = runId;
|
|
196
|
+
ctx.ui.setWidget("pi-crew", undefined, { placement: uiConfig?.widgetPlacement ?? "aboveEditor" });
|
|
197
|
+
ctx.ui.setWidget("pi-crew-active", undefined, { placement: uiConfig?.widgetPlacement ?? "aboveEditor" });
|
|
127
198
|
const width = Math.min(90, Math.max(40, uiConfig?.dashboardWidth ?? 56));
|
|
128
199
|
liveSidebarTimer = setInterval(() => requestRender(ctx), uiConfig?.dashboardLiveRefreshMs ?? 1000);
|
|
129
200
|
liveSidebarTimer.unref?.();
|
|
@@ -134,9 +205,10 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
134
205
|
if (liveSidebarRunId === runId) liveSidebarRunId = undefined;
|
|
135
206
|
if (liveSidebarTimer) clearInterval(liveSidebarTimer);
|
|
136
207
|
liveSidebarTimer = undefined;
|
|
208
|
+
updateCrewWidget(ctx, widgetState, loadConfig(ctx.cwd).config.ui);
|
|
137
209
|
});
|
|
138
210
|
};
|
|
139
|
-
const startForegroundRun = (ctx: ExtensionContext, runner: (signal?: AbortSignal) => Promise<void
|
|
211
|
+
const startForegroundRun = (ctx: ExtensionContext, runner: (signal?: AbortSignal) => Promise<void>, runId?: string): void => {
|
|
140
212
|
const controller = new AbortController();
|
|
141
213
|
foregroundControllers.add(controller);
|
|
142
214
|
setImmediate(() => {
|
|
@@ -147,6 +219,12 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
147
219
|
})
|
|
148
220
|
.finally(() => {
|
|
149
221
|
foregroundControllers.delete(controller);
|
|
222
|
+
if (runId) {
|
|
223
|
+
const loaded = loadRunManifestById(ctx.cwd, runId);
|
|
224
|
+
const status = loaded?.manifest.status ?? "finished";
|
|
225
|
+
const level = status === "failed" || status === "blocked" ? "error" : status === "cancelled" ? "warning" : "info";
|
|
226
|
+
ctx.ui.notify(`pi-crew run ${runId} ${status}. Use /team-summary ${runId} or /team-status ${runId}.`, level as "info" | "warning" | "error");
|
|
227
|
+
}
|
|
150
228
|
if (currentCtx) {
|
|
151
229
|
const config = loadConfig(currentCtx.cwd).config.ui;
|
|
152
230
|
updateCrewWidget(currentCtx, widgetState, config);
|
|
@@ -160,14 +238,9 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
160
238
|
const cleanupRuntime = (): void => {
|
|
161
239
|
if (cleanedUp) return;
|
|
162
240
|
cleanedUp = true;
|
|
163
|
-
|
|
164
|
-
foregroundControllers.clear();
|
|
165
|
-
terminateActiveChildPiProcesses();
|
|
241
|
+
stopSessionBoundSubagents();
|
|
166
242
|
stopAsyncRunNotifier(notifierState);
|
|
167
243
|
stopCrewWidget(currentCtx, widgetState, currentCtx ? loadConfig(currentCtx.cwd).config.ui : undefined);
|
|
168
|
-
if (liveSidebarTimer) clearInterval(liveSidebarTimer);
|
|
169
|
-
liveSidebarTimer = undefined;
|
|
170
|
-
liveSidebarRunId = undefined;
|
|
171
244
|
clearPiCrewPowerbar(pi.events);
|
|
172
245
|
rpcHandle?.unsubscribe();
|
|
173
246
|
rpcHandle = undefined;
|
|
@@ -179,6 +252,8 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
179
252
|
pi.on("session_start", (_event, ctx) => {
|
|
180
253
|
cleanedUp = false;
|
|
181
254
|
currentCtx = ctx;
|
|
255
|
+
if (widgetState.interval) clearInterval(widgetState.interval);
|
|
256
|
+
widgetState.interval = undefined;
|
|
182
257
|
notifyActiveRuns(ctx);
|
|
183
258
|
const loadedConfig = loadConfig(ctx.cwd);
|
|
184
259
|
registerPiCrewPowerbarSegments(pi.events, loadedConfig.config.ui);
|
|
@@ -188,11 +263,19 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
188
263
|
widgetState.interval = setInterval(() => {
|
|
189
264
|
if (!currentCtx) return;
|
|
190
265
|
const config = loadConfig(currentCtx.cwd).config.ui;
|
|
191
|
-
|
|
266
|
+
if (liveSidebarRunId) {
|
|
267
|
+
currentCtx.ui.setWidget("pi-crew", undefined, { placement: config?.widgetPlacement ?? "aboveEditor" });
|
|
268
|
+
currentCtx.ui.setWidget("pi-crew-active", undefined, { placement: config?.widgetPlacement ?? "aboveEditor" });
|
|
269
|
+
} else {
|
|
270
|
+
updateCrewWidget(currentCtx, widgetState, config);
|
|
271
|
+
}
|
|
192
272
|
updatePiCrewPowerbar(pi.events, currentCtx.cwd, config);
|
|
193
273
|
}, 1000);
|
|
194
274
|
widgetState.interval.unref?.();
|
|
195
275
|
});
|
|
276
|
+
pi.on("session_before_switch", () => {
|
|
277
|
+
stopSessionBoundSubagents();
|
|
278
|
+
});
|
|
196
279
|
pi.on("session_shutdown", () => {
|
|
197
280
|
cleanupRuntime();
|
|
198
281
|
});
|
|
@@ -209,7 +292,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
209
292
|
const abort = (): void => controller.abort();
|
|
210
293
|
signal?.addEventListener("abort", abort, { once: true });
|
|
211
294
|
try {
|
|
212
|
-
const output = await handleTeamTool(params as TeamToolParamsValue, { ...ctx, signal: controller.signal, startForegroundRun: (runner) => startForegroundRun(ctx, runner), onRunStarted: (runId) => openLiveSidebar(ctx, runId) });
|
|
295
|
+
const output = await handleTeamTool(params as TeamToolParamsValue, { ...ctx, signal: controller.signal, startForegroundRun: (runner, runId) => startForegroundRun(ctx, runner, runId), onRunStarted: (runId) => openLiveSidebar(ctx, runId) });
|
|
213
296
|
const config = loadConfig(ctx.cwd).config.ui;
|
|
214
297
|
updateCrewWidget(ctx, widgetState, config);
|
|
215
298
|
updatePiCrewPowerbar(pi.events, ctx.cwd, config);
|
|
@@ -223,6 +306,106 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
223
306
|
|
|
224
307
|
pi.registerTool(tool);
|
|
225
308
|
|
|
309
|
+
const agentTool: ToolDefinition = {
|
|
310
|
+
name: "Agent",
|
|
311
|
+
label: "Agent",
|
|
312
|
+
description: "Launch a real pi-crew subagent. Uses pi-crew's durable child-process runtime by default; set run_in_background=true for parallel/background work, then use get_subagent_result.",
|
|
313
|
+
promptSnippet: "Use Agent to delegate focused work to a real pi-crew subagent. Use run_in_background=true for parallel work and get_subagent_result to join results.",
|
|
314
|
+
promptGuidelines: [
|
|
315
|
+
"Use Agent for independent exploration, review, verification, or implementation subtasks instead of doing all work in the parent turn.",
|
|
316
|
+
"For parallel work, launch multiple Agent calls with run_in_background=true, then call get_subagent_result for each result.",
|
|
317
|
+
"Available pi-crew subagent types include explorer, planner, analyst, executor, reviewer, verifier, writer, security-reviewer, and test-engineer.",
|
|
318
|
+
],
|
|
319
|
+
parameters: Type.Object({
|
|
320
|
+
prompt: Type.String({ description: "The task for the subagent to perform." }),
|
|
321
|
+
description: Type.String({ description: "Short 3-5 word task description." }),
|
|
322
|
+
subagent_type: Type.String({ description: "pi-crew agent name, e.g. explorer, planner, executor, reviewer, verifier, writer, security-reviewer, test-engineer." }),
|
|
323
|
+
model: Type.Optional(Type.String({ description: "Optional model override. If omitted, pi-crew uses Pi-configured model fallback." })),
|
|
324
|
+
max_turns: Type.Optional(Type.Number({ description: "Reserved for live-session subagents; child-process runtime may ignore this." })),
|
|
325
|
+
run_in_background: Type.Optional(Type.Boolean({ description: "Run in background and return an agent ID immediately." })),
|
|
326
|
+
}) as never,
|
|
327
|
+
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
328
|
+
const options = __test__subagentSpawnParams(params as Record<string, unknown>, ctx);
|
|
329
|
+
if (!options.prompt.trim()) return subagentToolResult("Agent requires prompt.", {}, true);
|
|
330
|
+
const runner = async (spawnOptions: SubagentSpawnOptions, childSignal?: AbortSignal) => handleTeamTool({
|
|
331
|
+
action: "run",
|
|
332
|
+
agent: spawnOptions.type,
|
|
333
|
+
goal: spawnOptions.prompt,
|
|
334
|
+
model: spawnOptions.model,
|
|
335
|
+
async: false,
|
|
336
|
+
}, spawnOptions.background ? { ...ctx, signal: childSignal, startForegroundRun: (run, runId) => startForegroundRun(ctx, run, runId), onRunStarted: (runId) => openLiveSidebar(ctx, runId) } : { ...ctx, signal: childSignal });
|
|
337
|
+
const record = subagentManager.spawn(options, runner, options.background ? undefined : signal);
|
|
338
|
+
if (options.background || record.status === "queued") {
|
|
339
|
+
return subagentToolResult([`Agent ${record.status === "queued" ? "queued" : "started"}.`, `Agent ID: ${record.id}`, `Type: ${record.type}`, `Description: ${record.description}`, "Use get_subagent_result to retrieve output. Do not duplicate this agent's work."].join("\n"), { agentId: record.id, status: record.status });
|
|
340
|
+
}
|
|
341
|
+
await record.promise;
|
|
342
|
+
const output = readSubagentRunResult(ctx, record) ?? record.result ?? "No output.";
|
|
343
|
+
return subagentToolResult([`Agent ${record.id} ${record.status}.`, "", output].join("\n"), { agentId: record.id, runId: record.runId, status: record.status }, record.status === "failed" || record.status === "error");
|
|
344
|
+
},
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
const getSubagentResultTool: ToolDefinition = {
|
|
348
|
+
name: "get_subagent_result",
|
|
349
|
+
label: "Get Agent Result",
|
|
350
|
+
description: "Check status and retrieve results from a pi-crew background subagent.",
|
|
351
|
+
parameters: Type.Object({
|
|
352
|
+
agent_id: Type.String({ description: "Agent ID returned by Agent." }),
|
|
353
|
+
wait: Type.Optional(Type.Boolean({ description: "Wait for completion before returning." })),
|
|
354
|
+
verbose: Type.Optional(Type.Boolean({ description: "Include status metadata before output." })),
|
|
355
|
+
}) as never,
|
|
356
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
357
|
+
const p = params as { agent_id?: string; wait?: boolean; verbose?: boolean };
|
|
358
|
+
if (!p.agent_id) return subagentToolResult("get_subagent_result requires agent_id.", {}, true);
|
|
359
|
+
const record = subagentManager.getRecord(p.agent_id);
|
|
360
|
+
if (!record) return subagentToolResult(`Agent not found: ${p.agent_id}`, {}, true);
|
|
361
|
+
let current = record;
|
|
362
|
+
if (p.wait && (current.status === "running" || current.status === "queued")) {
|
|
363
|
+
current.resultConsumed = true;
|
|
364
|
+
current = await subagentManager.waitForRecord(current.id) ?? current;
|
|
365
|
+
}
|
|
366
|
+
const output = readSubagentRunResult(ctx, current);
|
|
367
|
+
if (current.status !== "running" && current.status !== "queued") current.resultConsumed = true;
|
|
368
|
+
const text = [p.verbose ? formatSubagentRecord(current) : undefined, output ? `${p.verbose ? "\n" : ""}${output}` : current.status === "running" || current.status === "queued" ? "Agent is still running. Use wait=true or check again later." : current.error ?? "No output."].filter((line): line is string => Boolean(line)).join("\n");
|
|
369
|
+
return subagentToolResult(text, { agentId: current.id, runId: current.runId, status: current.status }, current.status === "failed" || current.status === "error");
|
|
370
|
+
},
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
const steerSubagentTool: ToolDefinition = {
|
|
374
|
+
name: "steer_subagent",
|
|
375
|
+
label: "Steer Agent",
|
|
376
|
+
description: "Send a steering note to a running pi-crew subagent. Live-session steering is planned; child-process runs expose durable status and can be cancelled if needed.",
|
|
377
|
+
parameters: Type.Object({ agent_id: Type.String(), message: Type.String() }) as never,
|
|
378
|
+
async execute(_id, params) {
|
|
379
|
+
const p = params as { agent_id?: string; message?: string };
|
|
380
|
+
const record = p.agent_id ? subagentManager.getRecord(p.agent_id) : undefined;
|
|
381
|
+
if (!record) return subagentToolResult(`Agent not found: ${p.agent_id ?? ""}`, {}, true);
|
|
382
|
+
return subagentToolResult([`Steering request noted for ${record.id}.`, "Current default pi-crew backend is child-process, so mid-turn session.steer is not available yet.", record.runId ? `Use team cancel runId=${record.runId} if the agent must be interrupted.` : undefined].filter((line): line is string => Boolean(line)).join("\n"), { agentId: record.id, runId: record.runId, status: record.status });
|
|
383
|
+
},
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
const crewAgentTool: ToolDefinition = {
|
|
387
|
+
...agentTool,
|
|
388
|
+
name: "crew_agent",
|
|
389
|
+
label: "Crew Agent",
|
|
390
|
+
description: "Launch a real pi-crew subagent using a conflict-safe pi-crew-specific tool name.",
|
|
391
|
+
promptSnippet: "Use crew_agent when you need pi-crew subagents and another extension may own the generic Agent tool.",
|
|
392
|
+
};
|
|
393
|
+
const crewAgentResultTool: ToolDefinition = {
|
|
394
|
+
...getSubagentResultTool,
|
|
395
|
+
name: "crew_agent_result",
|
|
396
|
+
label: "Get Crew Agent Result",
|
|
397
|
+
description: "Check status and retrieve results from a pi-crew subagent using the conflict-safe tool name.",
|
|
398
|
+
};
|
|
399
|
+
const crewAgentSteerTool: ToolDefinition = {
|
|
400
|
+
...steerSubagentTool,
|
|
401
|
+
name: "crew_agent_steer",
|
|
402
|
+
label: "Steer Crew Agent",
|
|
403
|
+
description: "Send a steering note to a pi-crew subagent using the conflict-safe tool name.",
|
|
404
|
+
};
|
|
405
|
+
for (const extraTool of [agentTool, getSubagentResultTool, steerSubagentTool, crewAgentTool, crewAgentResultTool, crewAgentSteerTool]) {
|
|
406
|
+
pi.registerTool(extraTool);
|
|
407
|
+
}
|
|
408
|
+
|
|
226
409
|
pi.registerCommand("teams", {
|
|
227
410
|
description: "List pi-crew teams, workflows, and agents",
|
|
228
411
|
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
@@ -234,7 +417,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
234
417
|
pi.registerCommand("team-run", {
|
|
235
418
|
description: "Manually start a pi-crew run (agent may also use the team tool autonomously)",
|
|
236
419
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
237
|
-
const result = await handleTeamTool(parseRunArgs(args), { ...ctx, startForegroundRun: (runner) => startForegroundRun(ctx as ExtensionContext, runner), onRunStarted: (runId) => openLiveSidebar(ctx as ExtensionContext, runId) });
|
|
420
|
+
const result = await handleTeamTool(parseRunArgs(args), { ...ctx, startForegroundRun: (runner, runId) => startForegroundRun(ctx as ExtensionContext, runner, runId), onRunStarted: (runId) => openLiveSidebar(ctx as ExtensionContext, runId) });
|
|
238
421
|
await notifyCommandResult(ctx, commandText(result));
|
|
239
422
|
},
|
|
240
423
|
});
|
|
@@ -59,7 +59,7 @@ type TeamContext = Pick<ExtensionContext, "cwd"> & Partial<Pick<ExtensionContext
|
|
|
59
59
|
sessionManager?: { getBranch?: () => unknown[] };
|
|
60
60
|
events?: { emit?: (event: string, data: unknown) => void };
|
|
61
61
|
signal?: AbortSignal;
|
|
62
|
-
startForegroundRun?: (runner: (signal?: AbortSignal) => Promise<void
|
|
62
|
+
startForegroundRun?: (runner: (signal?: AbortSignal) => Promise<void>, runId?: string) => void;
|
|
63
63
|
onRunStarted?: (runId: string) => void;
|
|
64
64
|
};
|
|
65
65
|
|
|
@@ -300,13 +300,29 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
|
|
|
300
300
|
const teams = allTeams(discoverTeams(ctx.cwd));
|
|
301
301
|
const workflows = allWorkflows(discoverWorkflows(ctx.cwd));
|
|
302
302
|
const agents = allAgents(discoverAgents(ctx.cwd));
|
|
303
|
+
const directAgent = params.agent ? agents.find((item) => item.name === params.agent) : undefined;
|
|
304
|
+
if (params.agent && !directAgent) return result(`Agent '${params.agent}' not found.`, { action: "run", status: "error" }, true);
|
|
303
305
|
const teamName = params.team ?? "default";
|
|
304
|
-
const team =
|
|
306
|
+
const team = directAgent ? {
|
|
307
|
+
name: `direct-${directAgent.name}`,
|
|
308
|
+
description: `Direct subagent run for ${directAgent.name}`,
|
|
309
|
+
source: "builtin" as const,
|
|
310
|
+
filePath: "<generated>",
|
|
311
|
+
roles: [{ name: params.role ?? "agent", agent: directAgent.name, description: directAgent.description }],
|
|
312
|
+
defaultWorkflow: "direct-agent",
|
|
313
|
+
workspaceMode: params.workspaceMode,
|
|
314
|
+
} : teams.find((item) => item.name === teamName);
|
|
305
315
|
if (!team) return result(`Team '${teamName}' not found.`, { action: "run", status: "error" }, true);
|
|
306
|
-
const workflowName = params.workflow ?? team.defaultWorkflow ?? "default";
|
|
307
|
-
const baseWorkflow =
|
|
316
|
+
const workflowName = directAgent ? "direct-agent" : params.workflow ?? team.defaultWorkflow ?? "default";
|
|
317
|
+
const baseWorkflow = directAgent ? {
|
|
318
|
+
name: "direct-agent",
|
|
319
|
+
description: `Direct task for ${directAgent.name}`,
|
|
320
|
+
source: "builtin" as const,
|
|
321
|
+
filePath: "<generated>",
|
|
322
|
+
steps: [{ id: "01_agent", role: params.role ?? "agent", task: "{goal}", model: params.model }],
|
|
323
|
+
} : workflows.find((item) => item.name === workflowName);
|
|
308
324
|
if (!baseWorkflow) return result(`Workflow '${workflowName}' not found.`, { action: "run", status: "error" }, true);
|
|
309
|
-
const workflow = expandParallelResearchWorkflow(baseWorkflow, ctx.cwd);
|
|
325
|
+
const workflow = directAgent ? baseWorkflow : expandParallelResearchWorkflow(baseWorkflow, ctx.cwd);
|
|
310
326
|
|
|
311
327
|
const validationErrors = validateWorkflowForTeam(workflow, team);
|
|
312
328
|
if (validationErrors.length > 0) {
|
|
@@ -352,13 +368,13 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
|
|
|
352
368
|
}
|
|
353
369
|
|
|
354
370
|
const runtime = await resolveCrewRuntime(effectiveRunConfig(loadedConfig.config, params.config));
|
|
355
|
-
const executeWorkers = runtime.kind
|
|
371
|
+
const executeWorkers = runtime.kind !== "scaffold";
|
|
356
372
|
const executedConfig = effectiveRunConfig(loadedConfig.config, params.config);
|
|
357
373
|
if (executeWorkers && ctx.startForegroundRun) {
|
|
358
374
|
ctx.onRunStarted?.(updatedManifest.runId);
|
|
359
375
|
ctx.startForegroundRun(async (signal) => {
|
|
360
376
|
await executeTeamRun({ manifest: updatedManifest, tasks, team, workflow, agents, executeWorkers, limits: executedConfig.limits, runtime, runtimeConfig: executedConfig.runtime, parentContext: buildParentContext(ctx), parentModel: ctx.model, modelRegistry: ctx.modelRegistry, modelOverride: params.model, signal });
|
|
361
|
-
});
|
|
377
|
+
}, updatedManifest.runId);
|
|
362
378
|
const text = [
|
|
363
379
|
`Started foreground pi-crew run ${updatedManifest.runId}.`,
|
|
364
380
|
`Team: ${team.name}`,
|
|
@@ -507,7 +523,7 @@ export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext
|
|
|
507
523
|
appendEvent(loaded.manifest.eventsPath, { type: "run.resume_requested", runId: loaded.manifest.runId });
|
|
508
524
|
const loadedConfig = loadConfig(ctx.cwd);
|
|
509
525
|
const runtime = await resolveCrewRuntime(loadedConfig.config);
|
|
510
|
-
const executeWorkers = runtime.kind
|
|
526
|
+
const executeWorkers = runtime.kind !== "scaffold";
|
|
511
527
|
const executed = await executeTeamRun({ manifest: loaded.manifest, tasks: resetTasks, team, workflow, agents: allAgents(discoverAgents(ctx.cwd)), executeWorkers, limits: loadedConfig.config.limits, runtime, runtimeConfig: loadedConfig.config.runtime, parentContext: buildParentContext(ctx), parentModel: ctx.model, modelRegistry: ctx.modelRegistry, modelOverride: params.model, signal: ctx.signal });
|
|
512
528
|
return result([`Resumed run ${executed.manifest.runId}.`, `Status: ${executed.manifest.status}`, `Tasks: ${executed.tasks.length}`, `Artifacts: ${executed.manifest.artifactsRoot}`].join("\n"), { action: "resume", status: executed.manifest.status === "failed" ? "error" : "ok", runId: executed.manifest.runId, artifactsRoot: executed.manifest.artifactsRoot }, executed.manifest.status === "failed");
|
|
513
529
|
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export interface ResolveBatchConcurrencyInput {
|
|
2
|
+
workflowName: string;
|
|
3
|
+
teamMaxConcurrency?: number;
|
|
4
|
+
limitMaxConcurrentWorkers?: number;
|
|
5
|
+
readyCount: number;
|
|
6
|
+
workspaceMode?: "single" | "worktree";
|
|
7
|
+
readyRoles?: string[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface BatchConcurrencyDecision {
|
|
11
|
+
maxConcurrent: number;
|
|
12
|
+
selectedCount: number;
|
|
13
|
+
defaultConcurrency: number;
|
|
14
|
+
reason: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function defaultWorkflowConcurrency(workflowName: string): number {
|
|
18
|
+
if (workflowName === "parallel-research") return 4;
|
|
19
|
+
if (workflowName === "research") return 2;
|
|
20
|
+
if (workflowName === "implementation" || workflowName === "review" || workflowName === "default") return 2;
|
|
21
|
+
return 1;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function positiveInteger(value: number | undefined): number | undefined {
|
|
25
|
+
if (value === undefined || !Number.isFinite(value)) return undefined;
|
|
26
|
+
return Math.max(1, Math.trunc(value));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function resolveBatchConcurrency(input: ResolveBatchConcurrencyInput): BatchConcurrencyDecision {
|
|
30
|
+
const defaultConcurrency = defaultWorkflowConcurrency(input.workflowName);
|
|
31
|
+
const limitMax = positiveInteger(input.limitMaxConcurrentWorkers);
|
|
32
|
+
const teamMax = positiveInteger(input.teamMaxConcurrency);
|
|
33
|
+
const requested = limitMax ?? teamMax ?? defaultConcurrency;
|
|
34
|
+
const source = limitMax !== undefined ? "limit" : teamMax !== undefined ? "team" : "workflow";
|
|
35
|
+
const readyCount = Math.max(0, Math.trunc(input.readyCount));
|
|
36
|
+
return {
|
|
37
|
+
maxConcurrent: requested,
|
|
38
|
+
selectedCount: readyCount === 0 ? 0 : Math.min(readyCount, requested),
|
|
39
|
+
defaultConcurrency,
|
|
40
|
+
reason: `${source}:${requested};ready:${readyCount}`,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export interface ProgressEventSummary {
|
|
2
|
+
eventType: string;
|
|
3
|
+
currentTool?: string;
|
|
4
|
+
toolCount?: number;
|
|
5
|
+
tokens?: number;
|
|
6
|
+
turns?: number;
|
|
7
|
+
activityState?: string;
|
|
8
|
+
lastActivityAt?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ProgressEventCoalesceDecision {
|
|
12
|
+
shouldAppend: boolean;
|
|
13
|
+
reason: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ProgressEventCoalesceInput {
|
|
17
|
+
previous?: ProgressEventSummary;
|
|
18
|
+
next: ProgressEventSummary;
|
|
19
|
+
nowMs: number;
|
|
20
|
+
lastAppendMs?: number;
|
|
21
|
+
minIntervalMs: number;
|
|
22
|
+
force?: boolean;
|
|
23
|
+
tokenThreshold?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const DEFAULT_TOKEN_THRESHOLD = 256;
|
|
27
|
+
|
|
28
|
+
function numericIncrease(previous: number | undefined, next: number | undefined): number {
|
|
29
|
+
return next !== undefined && previous !== undefined ? next - previous : next !== undefined ? next : 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function shouldAppendProgressEventUpdate(input: ProgressEventCoalesceInput): ProgressEventCoalesceDecision {
|
|
33
|
+
if (input.force) return { shouldAppend: true, reason: "force" };
|
|
34
|
+
if (!input.previous) return { shouldAppend: true, reason: "first" };
|
|
35
|
+
if (input.previous.activityState !== input.next.activityState) return { shouldAppend: true, reason: "activity_changed" };
|
|
36
|
+
if (input.previous.currentTool !== input.next.currentTool) return { shouldAppend: true, reason: "tool_changed" };
|
|
37
|
+
if (numericIncrease(input.previous.toolCount, input.next.toolCount) > 0) return { shouldAppend: true, reason: "tool_count_increased" };
|
|
38
|
+
if (numericIncrease(input.previous.turns, input.next.turns) > 0) return { shouldAppend: true, reason: "turns_increased" };
|
|
39
|
+
const tokenIncrease = numericIncrease(input.previous.tokens, input.next.tokens);
|
|
40
|
+
if (tokenIncrease >= (input.tokenThreshold ?? DEFAULT_TOKEN_THRESHOLD)) return { shouldAppend: true, reason: "tokens_increased" };
|
|
41
|
+
if (input.lastAppendMs === undefined || input.nowMs - input.lastAppendMs >= input.minIntervalMs) return { shouldAppend: true, reason: "interval" };
|
|
42
|
+
return { shouldAppend: false, reason: "coalesced" };
|
|
43
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { loadRunManifestById } from "../state/state-store.ts";
|
|
2
|
+
import type { PiTeamsToolResult } from "../extension/tool-result.ts";
|
|
3
|
+
|
|
4
|
+
export type SubagentStatus = "queued" | "running" | "completed" | "failed" | "cancelled" | "error" | "stopped";
|
|
5
|
+
|
|
6
|
+
export interface SubagentSpawnOptions {
|
|
7
|
+
cwd: string;
|
|
8
|
+
type: string;
|
|
9
|
+
description: string;
|
|
10
|
+
prompt: string;
|
|
11
|
+
background: boolean;
|
|
12
|
+
model?: string;
|
|
13
|
+
maxTurns?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SubagentRecord {
|
|
17
|
+
id: string;
|
|
18
|
+
runId?: string;
|
|
19
|
+
type: string;
|
|
20
|
+
description: string;
|
|
21
|
+
prompt: string;
|
|
22
|
+
status: SubagentStatus;
|
|
23
|
+
startedAt: number;
|
|
24
|
+
completedAt?: number;
|
|
25
|
+
result?: string;
|
|
26
|
+
error?: string;
|
|
27
|
+
resultConsumed?: boolean;
|
|
28
|
+
model?: string;
|
|
29
|
+
background: boolean;
|
|
30
|
+
promise?: Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type SpawnRunner = (options: SubagentSpawnOptions, signal?: AbortSignal) => Promise<PiTeamsToolResult>;
|
|
34
|
+
type Notify = (record: SubagentRecord) => void;
|
|
35
|
+
|
|
36
|
+
interface QueuedSpawn {
|
|
37
|
+
record: SubagentRecord;
|
|
38
|
+
options: SubagentSpawnOptions;
|
|
39
|
+
runner: SpawnRunner;
|
|
40
|
+
signal?: AbortSignal;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const TERMINAL_RUN_STATUS = new Set(["completed", "failed", "cancelled", "blocked"]);
|
|
44
|
+
|
|
45
|
+
function resultText(result: PiTeamsToolResult): string {
|
|
46
|
+
return result.content?.map((item) => item.type === "text" ? item.text : "").filter(Boolean).join("\n") ?? "";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function detailsRunId(result: PiTeamsToolResult): string | undefined {
|
|
50
|
+
const details = result.details as { runId?: unknown } | undefined;
|
|
51
|
+
return typeof details?.runId === "string" ? details.runId : undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class SubagentManager {
|
|
55
|
+
private readonly records = new Map<string, SubagentRecord>();
|
|
56
|
+
private queue: QueuedSpawn[] = [];
|
|
57
|
+
private runningBackground = 0;
|
|
58
|
+
private counter = 0;
|
|
59
|
+
private maxConcurrent: number;
|
|
60
|
+
private readonly onComplete?: Notify;
|
|
61
|
+
private readonly pollIntervalMs: number;
|
|
62
|
+
|
|
63
|
+
constructor(maxConcurrent = 4, onComplete?: Notify, pollIntervalMs = 1000) {
|
|
64
|
+
this.maxConcurrent = maxConcurrent;
|
|
65
|
+
this.onComplete = onComplete;
|
|
66
|
+
this.pollIntervalMs = pollIntervalMs;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
spawn(options: SubagentSpawnOptions, runner: SpawnRunner, signal?: AbortSignal): SubagentRecord {
|
|
70
|
+
const record: SubagentRecord = {
|
|
71
|
+
id: `agent_${Date.now().toString(36)}_${(++this.counter).toString(36)}`,
|
|
72
|
+
type: options.type,
|
|
73
|
+
description: options.description,
|
|
74
|
+
prompt: options.prompt,
|
|
75
|
+
status: options.background && this.runningBackground >= this.maxConcurrent ? "queued" : "running",
|
|
76
|
+
startedAt: Date.now(),
|
|
77
|
+
model: options.model,
|
|
78
|
+
background: options.background,
|
|
79
|
+
};
|
|
80
|
+
this.records.set(record.id, record);
|
|
81
|
+
if (record.status === "queued") {
|
|
82
|
+
this.queue.push({ record, options, runner, signal });
|
|
83
|
+
return record;
|
|
84
|
+
}
|
|
85
|
+
this.start(record, options, runner, signal);
|
|
86
|
+
return record;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
getRecord(id: string): SubagentRecord | undefined {
|
|
90
|
+
return this.records.get(id);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
listAgents(): SubagentRecord[] {
|
|
94
|
+
return [...this.records.values()].sort((a, b) => b.startedAt - a.startedAt);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
abort(id: string): boolean {
|
|
98
|
+
const record = this.records.get(id);
|
|
99
|
+
if (!record) return false;
|
|
100
|
+
if (record.status === "queued") {
|
|
101
|
+
this.queue = this.queue.filter((entry) => entry.record.id !== id);
|
|
102
|
+
record.status = "stopped";
|
|
103
|
+
record.completedAt = Date.now();
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
if (record.status !== "running") return false;
|
|
107
|
+
record.status = "stopped";
|
|
108
|
+
record.completedAt = Date.now();
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
abortAll(): number {
|
|
113
|
+
let count = 0;
|
|
114
|
+
for (const entry of this.queue) {
|
|
115
|
+
entry.record.status = "stopped";
|
|
116
|
+
entry.record.completedAt = Date.now();
|
|
117
|
+
count++;
|
|
118
|
+
}
|
|
119
|
+
this.queue = [];
|
|
120
|
+
for (const record of this.records.values()) {
|
|
121
|
+
if (record.status === "running") {
|
|
122
|
+
record.status = "stopped";
|
|
123
|
+
record.completedAt = Date.now();
|
|
124
|
+
count++;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return count;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async waitForAll(): Promise<void> {
|
|
131
|
+
while (true) {
|
|
132
|
+
this.drainQueue();
|
|
133
|
+
const pending = this.listAgents().filter((record) => record.status === "running" || record.status === "queued").map((record) => record.promise).filter((promise): promise is Promise<void> => Boolean(promise));
|
|
134
|
+
if (!pending.length) break;
|
|
135
|
+
await Promise.allSettled(pending);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async waitForRecord(id: string): Promise<SubagentRecord | undefined> {
|
|
140
|
+
while (true) {
|
|
141
|
+
const record = this.records.get(id);
|
|
142
|
+
if (!record) return undefined;
|
|
143
|
+
if (record.status !== "running" && record.status !== "queued") return record;
|
|
144
|
+
if (record.promise) await record.promise;
|
|
145
|
+
else await new Promise((resolve) => setTimeout(resolve, 100));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
setMaxConcurrent(value: number): void {
|
|
150
|
+
this.maxConcurrent = Math.max(1, Math.floor(value));
|
|
151
|
+
this.drainQueue();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private start(record: SubagentRecord, options: SubagentSpawnOptions, runner: SpawnRunner, signal?: AbortSignal): void {
|
|
155
|
+
if (options.background) this.runningBackground++;
|
|
156
|
+
record.status = "running";
|
|
157
|
+
record.startedAt = Date.now();
|
|
158
|
+
record.promise = (async () => {
|
|
159
|
+
try {
|
|
160
|
+
const result = await runner(options, signal);
|
|
161
|
+
record.runId = detailsRunId(result);
|
|
162
|
+
record.result = resultText(result);
|
|
163
|
+
if (result.isError) {
|
|
164
|
+
record.status = "error";
|
|
165
|
+
record.error = record.result;
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (record.runId) await this.pollRunToTerminal(options.cwd, record);
|
|
169
|
+
else record.status = "completed";
|
|
170
|
+
} catch (error) {
|
|
171
|
+
record.status = "error";
|
|
172
|
+
record.error = error instanceof Error ? error.message : String(error);
|
|
173
|
+
} finally {
|
|
174
|
+
if (options.background) this.runningBackground = Math.max(0, this.runningBackground - 1);
|
|
175
|
+
record.completedAt = record.completedAt ?? Date.now();
|
|
176
|
+
this.onComplete?.(record);
|
|
177
|
+
this.drainQueue();
|
|
178
|
+
}
|
|
179
|
+
})();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private drainQueue(): void {
|
|
183
|
+
while (this.queue.length > 0 && this.runningBackground < this.maxConcurrent) {
|
|
184
|
+
const next = this.queue.shift();
|
|
185
|
+
if (!next || next.record.status !== "queued") continue;
|
|
186
|
+
this.start(next.record, next.options, next.runner, next.signal);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private async pollRunToTerminal(cwd: string, record: SubagentRecord): Promise<void> {
|
|
191
|
+
while (record.runId && record.status === "running") {
|
|
192
|
+
const loaded = loadRunManifestById(cwd, record.runId);
|
|
193
|
+
if (loaded && TERMINAL_RUN_STATUS.has(loaded.manifest.status)) {
|
|
194
|
+
record.status = loaded.manifest.status === "completed" ? "completed" : loaded.manifest.status === "cancelled" ? "cancelled" : "failed";
|
|
195
|
+
record.error = record.status === "completed" ? undefined : loaded.manifest.summary;
|
|
196
|
+
record.completedAt = Date.now();
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
await new Promise((resolve) => setTimeout(resolve, this.pollIntervalMs));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -22,6 +22,7 @@ import { parseSessionUsage } from "./session-usage.ts";
|
|
|
22
22
|
import type { CrewAgentProgress, CrewRuntimeKind } from "./crew-agent-runtime.ts";
|
|
23
23
|
import { buildMemoryBlock } from "./agent-memory.ts";
|
|
24
24
|
import { runLiveSessionTask } from "./live-session-runtime.ts";
|
|
25
|
+
import { shouldAppendProgressEventUpdate, type ProgressEventSummary } from "./progress-event-coalescer.ts";
|
|
25
26
|
|
|
26
27
|
export interface TaskRunnerInput {
|
|
27
28
|
manifest: TeamRunManifest;
|
|
@@ -201,7 +202,7 @@ function cleanResultText(text: string | undefined): string | undefined {
|
|
|
201
202
|
return trimmed;
|
|
202
203
|
}
|
|
203
204
|
|
|
204
|
-
function progressEventSummary(task: TeamTaskState, event: unknown):
|
|
205
|
+
function progressEventSummary(task: TeamTaskState, event: unknown): ProgressEventSummary {
|
|
205
206
|
const type = asRecord(event)?.type;
|
|
206
207
|
return {
|
|
207
208
|
eventType: typeof type === "string" ? type : "event",
|
|
@@ -306,14 +307,18 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
|
|
|
306
307
|
const transcriptPath = `${manifest.artifactsRoot}/transcripts/${task.id}.jsonl`;
|
|
307
308
|
let lastAgentRecordPersistedAt = 0;
|
|
308
309
|
let lastRunProgressPersistedAt = 0;
|
|
310
|
+
let lastRunProgressSummary: ProgressEventSummary | undefined;
|
|
309
311
|
const persistChildProgress = (event: unknown, force = false): void => {
|
|
310
312
|
const now = Date.now();
|
|
311
313
|
if (force || shouldFlushProgressEvent(event) || now - lastAgentRecordPersistedAt >= 500) {
|
|
312
314
|
upsertCrewAgent(manifest, recordFromTask(manifest, task, "child-process"));
|
|
313
315
|
lastAgentRecordPersistedAt = now;
|
|
314
316
|
}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
+
const summary = progressEventSummary(task, event);
|
|
318
|
+
const decision = shouldAppendProgressEventUpdate({ previous: lastRunProgressSummary, next: summary, nowMs: now, lastAppendMs: lastRunProgressPersistedAt || undefined, minIntervalMs: 1000, force });
|
|
319
|
+
if (decision.shouldAppend) {
|
|
320
|
+
appendEvent(manifest.eventsPath, { type: "task.progress", runId: manifest.runId, taskId: task.id, data: { ...summary, coalesceReason: decision.reason } });
|
|
321
|
+
lastRunProgressSummary = summary;
|
|
317
322
|
lastRunProgressPersistedAt = now;
|
|
318
323
|
}
|
|
319
324
|
};
|
|
@@ -389,14 +394,18 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
|
|
|
389
394
|
const transcriptPath = `${manifest.artifactsRoot}/transcripts/${task.id}.jsonl`;
|
|
390
395
|
let lastAgentRecordPersistedAt = 0;
|
|
391
396
|
let lastRunProgressPersistedAt = 0;
|
|
397
|
+
let lastRunProgressSummary: ProgressEventSummary | undefined;
|
|
392
398
|
const persistLiveProgress = (event: unknown, force = false): void => {
|
|
393
399
|
const now = Date.now();
|
|
394
400
|
if (force || shouldFlushProgressEvent(event) || now - lastAgentRecordPersistedAt >= 500) {
|
|
395
401
|
upsertCrewAgent(manifest, recordFromTask(manifest, task, "live-session"));
|
|
396
402
|
lastAgentRecordPersistedAt = now;
|
|
397
403
|
}
|
|
398
|
-
|
|
399
|
-
|
|
404
|
+
const summary = progressEventSummary(task, event);
|
|
405
|
+
const decision = shouldAppendProgressEventUpdate({ previous: lastRunProgressSummary, next: summary, nowMs: now, lastAppendMs: lastRunProgressPersistedAt || undefined, minIntervalMs: 1000, force });
|
|
406
|
+
if (decision.shouldAppend) {
|
|
407
|
+
appendEvent(manifest.eventsPath, { type: "task.progress", runId: manifest.runId, taskId: task.id, data: { ...summary, coalesceReason: decision.reason } });
|
|
408
|
+
lastRunProgressSummary = summary;
|
|
400
409
|
lastRunProgressPersistedAt = now;
|
|
401
410
|
}
|
|
402
411
|
};
|
|
@@ -17,6 +17,7 @@ import { saveCrewAgents } from "./crew-agent-records.ts";
|
|
|
17
17
|
import { recordsForMaterializedTasks } from "./task-display.ts";
|
|
18
18
|
import { deliverGroupJoin, resolveGroupJoinMode } from "./group-join.ts";
|
|
19
19
|
import { runTeamTask } from "./task-runner.ts";
|
|
20
|
+
import { resolveBatchConcurrency } from "./concurrency.ts";
|
|
20
21
|
|
|
21
22
|
export interface ExecuteTeamRunInput {
|
|
22
23
|
manifest: TeamRunManifest;
|
|
@@ -167,9 +168,10 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
|
|
|
167
168
|
return { manifest, tasks };
|
|
168
169
|
}
|
|
169
170
|
|
|
170
|
-
const
|
|
171
|
-
const
|
|
172
|
-
const
|
|
171
|
+
const snapshot = taskGraphSnapshot(tasks);
|
|
172
|
+
const readyRoles = snapshot.ready.map((taskId) => tasks.find((task) => task.id === taskId)?.role).filter((role): role is string => Boolean(role));
|
|
173
|
+
const concurrency = resolveBatchConcurrency({ workflowName: input.workflow.name, teamMaxConcurrency: input.team.maxConcurrency, limitMaxConcurrentWorkers: input.limits?.maxConcurrentWorkers, readyCount: snapshot.ready.length, workspaceMode: manifest.workspaceMode, readyRoles });
|
|
174
|
+
const readyBatch = getReadyTasks(tasks, concurrency.selectedCount);
|
|
173
175
|
if (readyBatch.length === 0) {
|
|
174
176
|
tasks = markBlocked(tasks, "No ready queued task; dependency graph may be invalid.");
|
|
175
177
|
saveRunTasks(manifest, tasks);
|
|
@@ -178,7 +180,7 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
|
|
|
178
180
|
return { manifest, tasks };
|
|
179
181
|
}
|
|
180
182
|
|
|
181
|
-
appendEvent(manifest.eventsPath, { type: "task.progress", runId: manifest.runId, message: `Starting ready batch with ${readyBatch.length} task(s).`, data: { taskIds: readyBatch.map((task) => task.id), maxConcurrent } });
|
|
183
|
+
appendEvent(manifest.eventsPath, { type: "task.progress", runId: manifest.runId, message: `Starting ready batch with ${readyBatch.length} task(s).`, data: { taskIds: readyBatch.map((task) => task.id), readyCount: snapshot.ready.length, blockedCount: snapshot.blocked.length, runningCount: snapshot.running.length, doneCount: snapshot.done.length, selectedCount: readyBatch.length, maxConcurrent: concurrency.maxConcurrent, defaultConcurrency: concurrency.defaultConcurrency, concurrencyReason: concurrency.reason } });
|
|
182
184
|
const results = await Promise.all(readyBatch.map((task) => {
|
|
183
185
|
const step = findStep(input.workflow, task);
|
|
184
186
|
const agent = findAgent(input.agents, task);
|
|
@@ -16,7 +16,7 @@ function readTasks(tasksPath: string): TeamTaskState[] {
|
|
|
16
16
|
try { const parsed = JSON.parse(fs.readFileSync(tasksPath, "utf-8")); return Array.isArray(parsed) ? parsed as TeamTaskState[] : []; } catch { return []; }
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
function compactTokens(total: number): string {
|
|
19
|
+
export function compactTokens(total: number): string {
|
|
20
20
|
return total >= 1000 ? `${Math.round(total / 1000)}k` : `${total}`;
|
|
21
21
|
}
|
|
22
22
|
|
|
@@ -42,23 +42,24 @@ export function updatePiCrewPowerbar(events: EventBus, cwd: string, config?: Cre
|
|
|
42
42
|
const tasks = active.flatMap((item) => readTasks(item.run.tasksPath));
|
|
43
43
|
const running = agents.filter((agent) => agent.status === "running").length;
|
|
44
44
|
const waiting = tasks.filter((task) => task.status === "queued").length;
|
|
45
|
-
const completed =
|
|
46
|
-
const total = Math.max(1,
|
|
45
|
+
const completed = tasks.filter((task) => task.status === "completed").length;
|
|
46
|
+
const total = Math.max(1, tasks.length || agents.length);
|
|
47
47
|
const usage = aggregateUsage(tasks);
|
|
48
48
|
const tokenTotal = usage ? (usage.input ?? 0) + (usage.output ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0) : 0;
|
|
49
|
-
const model = agents.find((agent) => agent.model)?.model?.split("/").at(-1);
|
|
49
|
+
const model = config?.showModel === false ? undefined : agents.find((agent) => agent.model)?.model?.split("/").at(-1);
|
|
50
|
+
const tokenText = config?.showTokens === false || !tokenTotal ? undefined : compactTokens(tokenTotal);
|
|
50
51
|
safeEmit(events, "powerbar:update", {
|
|
51
52
|
id: "pi-crew-active",
|
|
52
53
|
icon: "⚙",
|
|
53
54
|
text: `crew ${running}a/${waiting}w`,
|
|
54
|
-
suffix: [model,
|
|
55
|
+
suffix: [model, tokenText].filter(Boolean).join(" · ") || undefined,
|
|
55
56
|
color: running ? "accent" : "warning",
|
|
56
57
|
});
|
|
57
58
|
safeEmit(events, "powerbar:update", {
|
|
58
59
|
id: "pi-crew-progress",
|
|
59
60
|
text: active[0]?.run.team ?? "crew",
|
|
60
61
|
bar: Math.round((completed / total) * 100),
|
|
61
|
-
suffix: `${completed}/${total}${
|
|
62
|
+
suffix: `${completed}/${total}${tokenText ? ` · ${tokenText}` : ""}`,
|
|
62
63
|
color: completed === total ? "success" : "accent",
|
|
63
64
|
barSegments: 8,
|
|
64
65
|
});
|