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 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": true,
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; foreground runs auto-open a live top-right sidebar when the terminal is wide enough.
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": true,
39
+ "autoOpenDashboard": false,
40
40
  "autoOpenDashboardForForegroundRuns": true,
41
41
  "showModel": true,
42
42
  "showTokens": true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.1.18",
3
+ "version": "0.1.19",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
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": true, "description": "Automatically open the live right sidebar for foreground runs when UI is available." },
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 ?? true;
122
- const foregroundAutoOpen = uiConfig?.autoOpenDashboardForForegroundRuns ?? true;
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>): 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
- for (const controller of foregroundControllers) controller.abort();
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
- updateCrewWidget(currentCtx, widgetState, config);
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>) => 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 = teams.find((item) => item.name === teamName);
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 = workflows.find((item) => item.name === workflowName);
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 === "child-process";
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 === "child-process";
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): Record<string, 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
- if (force || shouldFlushProgressEvent(event) || now - lastRunProgressPersistedAt >= 1000) {
316
- appendEvent(manifest.eventsPath, { type: "task.progress", runId: manifest.runId, taskId: task.id, data: progressEventSummary(task, event) });
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
- if (force || shouldFlushProgressEvent(event) || now - lastRunProgressPersistedAt >= 1000) {
399
- appendEvent(manifest.eventsPath, { type: "task.progress", runId: manifest.runId, taskId: task.id, data: progressEventSummary(task, event) });
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 defaultConcurrency = input.workflow.name === "parallel-research" ? 4 : input.workflow.name === "research" ? 2 : input.workflow.name === "implementation" || input.workflow.name === "review" || input.workflow.name === "default" ? 2 : 1;
171
- const maxConcurrent = Math.max(1, input.limits?.maxConcurrentWorkers ?? input.team.maxConcurrency ?? defaultConcurrency);
172
- const readyBatch = getReadyTasks(tasks, maxConcurrent);
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 = agents.filter((agent) => agent.status === "completed").length;
46
- const total = Math.max(1, agents.length + waiting);
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, tokenTotal ? compactTokens(tokenTotal) : undefined].filter(Boolean).join(" · ") || undefined,
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}${tokenTotal ? ` · ${compactTokens(tokenTotal)}` : ""}`,
62
+ suffix: `${completed}/${total}${tokenText ? ` · ${tokenText}` : ""}`,
62
63
  color: completed === total ? "success" : "accent",
63
64
  barSegments: 8,
64
65
  });