pi-crew 0.1.17 → 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,23 @@
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
+
11
+ ## 0.1.18
12
+
13
+ - Added a built-in `parallel-research` team/workflow for map-reduce style source audits with dynamic `Source/pi-*` fanout and parallel explorer shards.
14
+ - Made the live right sidebar the default foreground UI: active foreground runs auto-open a top-right live sidebar when the terminal is wide enough.
15
+ - Added live sidebar sections for active agents, waiting tasks, completed agents, task graph, model, tool, and token/usage details.
16
+ - Stopped materializing queued dependency tasks as child-process agents; status now separates active agents, waiting tasks, and completed agents.
17
+ - Added workflow-aware default concurrency so research/parallel-research can use ready parallel work instead of always running one worker.
18
+ - Dropped user/system prompt messages from child event persistence to avoid prompt/context leakage in agent event logs.
19
+ - Tightened child event compaction with separate assistant/tool input/tool result caps and improved powerbar active/waiting/model/token summaries.
20
+
3
21
  ## 0.1.17
4
22
 
5
23
  - Fixed terminal/completed workers being incorrectly escalated as stale heartbeat blockers after all tasks completed.
package/README.md CHANGED
@@ -52,6 +52,7 @@ Current highlights:
52
52
  - run-level and task-level mailbox files with validation/repair support
53
53
  - `/team-manager` interactive helper
54
54
  - `/team-dashboard` custom TUI overlay with progress preview, action shortcuts, and reload
55
+ - `parallel-research` team/workflow for dynamic `Source/pi-*` fanout and parallel shard exploration
55
56
  - package polish: `schema.json`, TypeScript semantic check, strip-types import smoke, cross-platform CI workflow, dry-run package verification
56
57
 
57
58
  ## Install
@@ -171,7 +172,10 @@ Supported config:
171
172
  "widgetMaxLines": 8,
172
173
  "powerbar": true,
173
174
  "dashboardPlacement": "right",
174
- "dashboardWidth": 52,
175
+ "dashboardWidth": 56,
176
+ "dashboardLiveRefreshMs": 1000,
177
+ "autoOpenDashboard": false,
178
+ "autoOpenDashboardForForegroundRuns": true,
175
179
  "showModel": true,
176
180
  "showTokens": true,
177
181
  "showTools": true
@@ -186,7 +190,9 @@ Safety notes:
186
190
  UI notes:
187
191
 
188
192
  - `widgetPlacement`/`widgetMaxLines` keep the persistent active-run widget compact.
189
- - `dashboardPlacement: "right"` opens `/team-dashboard` as a right-side overlay panel instead of a centered modal.
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
+ - `autoOpenDashboard`/`autoOpenDashboardForForegroundRuns` control whether the live sidebar opens automatically.
195
+ - `dashboardLiveRefreshMs` controls the live sidebar refresh cadence.
190
196
  - `showModel`, `showTokens`, and `showTools` show worker model attempts, token usage, and tool activity in dashboard agent rows.
191
197
 
192
198
  Show config:
package/docs/usage.md CHANGED
@@ -34,7 +34,10 @@ Supported fields:
34
34
  "widgetMaxLines": 8,
35
35
  "powerbar": true,
36
36
  "dashboardPlacement": "right",
37
- "dashboardWidth": 52,
37
+ "dashboardWidth": 56,
38
+ "dashboardLiveRefreshMs": 1000,
39
+ "autoOpenDashboard": false,
40
+ "autoOpenDashboardForForegroundRuns": true,
38
41
  "showModel": true,
39
42
  "showTokens": true,
40
43
  "showTools": true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.1.17",
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
@@ -87,9 +87,12 @@
87
87
  "widgetPlacement": { "type": "string", "enum": ["aboveEditor", "belowEditor"] },
88
88
  "widgetMaxLines": { "type": "integer", "minimum": 1, "maximum": 50 },
89
89
  "powerbar": { "type": "boolean" },
90
- "dashboardPlacement": { "type": "string", "enum": ["center", "right"], "description": "Place /team-dashboard as a centered overlay or right-side panel." },
91
- "dashboardWidth": { "type": "integer", "minimum": 32, "maximum": 120 },
92
- "showModel": { "type": "boolean", "description": "Show worker model attempts in dashboard agent rows." },
90
+ "dashboardPlacement": { "type": "string", "enum": ["center", "right"], "default": "right", "description": "Place /team-dashboard as a centered overlay or right-side panel." },
91
+ "dashboardWidth": { "type": "integer", "minimum": 32, "maximum": 120, "default": 56 },
92
+ "dashboardLiveRefreshMs": { "type": "integer", "minimum": 250, "maximum": 60000, "default": 1000 },
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
+ "autoOpenDashboardForForegroundRuns": { "type": "boolean", "default": true },
95
+ "showModel": { "type": "boolean", "default": true, "description": "Show worker model attempts in dashboard agent rows." },
93
96
  "showTokens": { "type": "boolean", "description": "Show token usage in dashboard agent rows." },
94
97
  "showTools": { "type": "boolean", "description": "Show tool activity in dashboard agent rows." }
95
98
  }
@@ -53,6 +53,9 @@ export interface CrewUiConfig {
53
53
  powerbar?: boolean;
54
54
  dashboardPlacement?: "center" | "right";
55
55
  dashboardWidth?: number;
56
+ dashboardLiveRefreshMs?: number;
57
+ autoOpenDashboard?: boolean;
58
+ autoOpenDashboardForForegroundRuns?: boolean;
56
59
  showModel?: boolean;
57
60
  showTokens?: boolean;
58
61
  showTools?: boolean;
@@ -316,6 +319,9 @@ function parseUiConfig(value: unknown): CrewUiConfig | undefined {
316
319
  powerbar: typeof obj.powerbar === "boolean" ? obj.powerbar : undefined,
317
320
  dashboardPlacement: obj.dashboardPlacement === "center" || obj.dashboardPlacement === "right" ? obj.dashboardPlacement : undefined,
318
321
  dashboardWidth: parsePositiveInteger(obj.dashboardWidth, 120),
322
+ dashboardLiveRefreshMs: parsePositiveInteger(obj.dashboardLiveRefreshMs, 60_000),
323
+ autoOpenDashboard: typeof obj.autoOpenDashboard === "boolean" ? obj.autoOpenDashboard : undefined,
324
+ autoOpenDashboardForForegroundRuns: typeof obj.autoOpenDashboardForForegroundRuns === "boolean" ? obj.autoOpenDashboardForForegroundRuns : undefined,
319
325
  showModel: typeof obj.showModel === "boolean" ? obj.showModel : undefined,
320
326
  showTokens: typeof obj.showTokens === "boolean" ? obj.showTokens : undefined,
321
327
  showTools: typeof obj.showTools === "boolean" ? obj.showTools : undefined,
@@ -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";
@@ -9,6 +11,7 @@ import { handleTeamManagerCommand } from "./team-manager-command.ts";
9
11
  import { handleTeamTool, type TeamToolDetails } from "./team-tool.ts";
10
12
  import { listRecentRuns } from "./run-index.ts";
11
13
  import { RunDashboard, type RunDashboardSelection } from "../ui/run-dashboard.ts";
14
+ import { LiveRunSidebar } from "../ui/live-run-sidebar.ts";
12
15
  import { registerPiCrewRpc, type PiCrewRpcHandle } from "./cross-extension-rpc.ts";
13
16
  import { stopCrewWidget, updateCrewWidget, type CrewWidgetState } from "../ui/crew-widget.ts";
14
17
  import { clearPiCrewPowerbar, registerPiCrewPowerbarSegments, updatePiCrewPowerbar } from "../ui/powerbar-publisher.ts";
@@ -16,6 +19,7 @@ import { DurableTextViewer, DurableTranscriptViewer } from "../ui/transcript-vie
16
19
  import { loadRunManifestById } from "../state/state-store.ts";
17
20
  import { readCrewAgents } from "../runtime/crew-agent-records.ts";
18
21
  import { terminateActiveChildPiProcesses } from "../runtime/child-pi.ts";
22
+ import { SubagentManager, type SubagentRecord, type SubagentSpawnOptions } from "../runtime/subagent-manager.ts";
19
23
 
20
24
  function parseRunArgs(args: string): TeamToolParamsValue {
21
25
  const tokens = args.match(/"[^"]*"|'[^']*'|\S+/g)?.map((token) => token.replace(/^['"]|['"]$/g, "")) ?? [];
@@ -99,6 +103,55 @@ function setNestedConfig(config: Record<string, unknown>, key: string, value: un
99
103
  target[parts[parts.length - 1]!] = value;
100
104
  }
101
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
+
102
155
  export function registerPiTeams(pi: ExtensionAPI): void {
103
156
  const globalStore = globalThis as Record<string, unknown>;
104
157
  const runtimeCleanupStoreKey = "__piCrewRuntimeCleanup";
@@ -111,8 +164,51 @@ export function registerPiTeams(pi: ExtensionAPI): void {
111
164
  let rpcHandle: PiCrewRpcHandle | undefined;
112
165
  let cleanedUp = false;
113
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
+ });
114
173
  const foregroundControllers = new Set<AbortController>();
115
- const startForegroundRun = (ctx: ExtensionContext, runner: (signal?: AbortSignal) => Promise<void>): void => {
174
+ let liveSidebarRunId: string | undefined;
175
+ let liveSidebarTimer: ReturnType<typeof setInterval> | undefined;
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
+ };
188
+ const openLiveSidebar = (ctx: ExtensionContext, runId: string): void => {
189
+ const uiConfig = loadConfig(ctx.cwd).config.ui;
190
+ const autoOpen = uiConfig?.autoOpenDashboard === true;
191
+ const foregroundAutoOpen = uiConfig?.autoOpenDashboardForForegroundRuns !== false;
192
+ if (!ctx.hasUI || !autoOpen || !foregroundAutoOpen || (uiConfig?.dashboardPlacement ?? "right") !== "right") return;
193
+ if (liveSidebarRunId === runId) return;
194
+ if (liveSidebarTimer) clearInterval(liveSidebarTimer);
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" });
198
+ const width = Math.min(90, Math.max(40, uiConfig?.dashboardWidth ?? 56));
199
+ liveSidebarTimer = setInterval(() => requestRender(ctx), uiConfig?.dashboardLiveRefreshMs ?? 1000);
200
+ liveSidebarTimer.unref?.();
201
+ void ctx.ui.custom<undefined>((_tui, theme, _keybindings, done) => new LiveRunSidebar({ cwd: ctx.cwd, runId, done, theme, config: uiConfig }), {
202
+ overlay: true,
203
+ overlayOptions: { width, minWidth: 40, maxHeight: "100%", anchor: "top-right", offsetX: 0, offsetY: 0, margin: { top: 0, right: 0, bottom: 0, left: 0 }, visible: (termWidth: number) => termWidth >= 100 },
204
+ }).finally(() => {
205
+ if (liveSidebarRunId === runId) liveSidebarRunId = undefined;
206
+ if (liveSidebarTimer) clearInterval(liveSidebarTimer);
207
+ liveSidebarTimer = undefined;
208
+ updateCrewWidget(ctx, widgetState, loadConfig(ctx.cwd).config.ui);
209
+ });
210
+ };
211
+ const startForegroundRun = (ctx: ExtensionContext, runner: (signal?: AbortSignal) => Promise<void>, runId?: string): void => {
116
212
  const controller = new AbortController();
117
213
  foregroundControllers.add(controller);
118
214
  setImmediate(() => {
@@ -123,6 +219,12 @@ export function registerPiTeams(pi: ExtensionAPI): void {
123
219
  })
124
220
  .finally(() => {
125
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
+ }
126
228
  if (currentCtx) {
127
229
  const config = loadConfig(currentCtx.cwd).config.ui;
128
230
  updateCrewWidget(currentCtx, widgetState, config);
@@ -136,9 +238,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
136
238
  const cleanupRuntime = (): void => {
137
239
  if (cleanedUp) return;
138
240
  cleanedUp = true;
139
- for (const controller of foregroundControllers) controller.abort();
140
- foregroundControllers.clear();
141
- terminateActiveChildPiProcesses();
241
+ stopSessionBoundSubagents();
142
242
  stopAsyncRunNotifier(notifierState);
143
243
  stopCrewWidget(currentCtx, widgetState, currentCtx ? loadConfig(currentCtx.cwd).config.ui : undefined);
144
244
  clearPiCrewPowerbar(pi.events);
@@ -152,6 +252,8 @@ export function registerPiTeams(pi: ExtensionAPI): void {
152
252
  pi.on("session_start", (_event, ctx) => {
153
253
  cleanedUp = false;
154
254
  currentCtx = ctx;
255
+ if (widgetState.interval) clearInterval(widgetState.interval);
256
+ widgetState.interval = undefined;
155
257
  notifyActiveRuns(ctx);
156
258
  const loadedConfig = loadConfig(ctx.cwd);
157
259
  registerPiCrewPowerbarSegments(pi.events, loadedConfig.config.ui);
@@ -161,11 +263,19 @@ export function registerPiTeams(pi: ExtensionAPI): void {
161
263
  widgetState.interval = setInterval(() => {
162
264
  if (!currentCtx) return;
163
265
  const config = loadConfig(currentCtx.cwd).config.ui;
164
- 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
+ }
165
272
  updatePiCrewPowerbar(pi.events, currentCtx.cwd, config);
166
273
  }, 1000);
167
274
  widgetState.interval.unref?.();
168
275
  });
276
+ pi.on("session_before_switch", () => {
277
+ stopSessionBoundSubagents();
278
+ });
169
279
  pi.on("session_shutdown", () => {
170
280
  cleanupRuntime();
171
281
  });
@@ -182,7 +292,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
182
292
  const abort = (): void => controller.abort();
183
293
  signal?.addEventListener("abort", abort, { once: true });
184
294
  try {
185
- const output = await handleTeamTool(params as TeamToolParamsValue, { ...ctx, signal: controller.signal, startForegroundRun: (runner) => startForegroundRun(ctx, runner) });
295
+ const output = await handleTeamTool(params as TeamToolParamsValue, { ...ctx, signal: controller.signal, startForegroundRun: (runner, runId) => startForegroundRun(ctx, runner, runId), onRunStarted: (runId) => openLiveSidebar(ctx, runId) });
186
296
  const config = loadConfig(ctx.cwd).config.ui;
187
297
  updateCrewWidget(ctx, widgetState, config);
188
298
  updatePiCrewPowerbar(pi.events, ctx.cwd, config);
@@ -196,6 +306,106 @@ export function registerPiTeams(pi: ExtensionAPI): void {
196
306
 
197
307
  pi.registerTool(tool);
198
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
+
199
409
  pi.registerCommand("teams", {
200
410
  description: "List pi-crew teams, workflows, and agents",
201
411
  handler: async (_args: string, ctx: ExtensionCommandContext) => {
@@ -207,7 +417,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
207
417
  pi.registerCommand("team-run", {
208
418
  description: "Manually start a pi-crew run (agent may also use the team tool autonomously)",
209
419
  handler: async (args: string, ctx: ExtensionCommandContext) => {
210
- const result = await handleTeamTool(parseRunArgs(args), { ...ctx, startForegroundRun: (runner) => startForegroundRun(ctx as ExtensionContext, runner) });
420
+ const result = await handleTeamTool(parseRunArgs(args), { ...ctx, startForegroundRun: (runner, runId) => startForegroundRun(ctx as ExtensionContext, runner, runId), onRunStarted: (runId) => openLiveSidebar(ctx as ExtensionContext, runId) });
211
421
  await notifyCommandResult(ctx, commandText(result));
212
422
  },
213
423
  });
@@ -12,8 +12,8 @@ export interface RecommendedSubtask {
12
12
  }
13
13
 
14
14
  export interface TeamRecommendation {
15
- team: "default" | "implementation" | "review" | "fast-fix" | "research";
16
- workflow: "default" | "implementation" | "review" | "fast-fix" | "research";
15
+ team: string;
16
+ workflow: string;
17
17
  action: "plan" | "run";
18
18
  async: boolean;
19
19
  workspaceMode: "single" | "worktree";
@@ -23,7 +23,8 @@ export interface TeamRecommendation {
23
23
  }
24
24
 
25
25
  const REVIEW_TERMS = ["review", "audit", "security", "vulnerability", "diff", "pr", "pull request"];
26
- const RESEARCH_TERMS = ["research", "investigate", "compare", "analyze", "document", "docs", "explain", "architecture"];
26
+ const RESEARCH_TERMS = ["research", "investigate", "compare", "analyze", "document", "docs", "explain", "architecture", "đọc sâu", "source", "projects"];
27
+ const PARALLEL_RESEARCH_RE = /(?:đọc sâu|deep read|deep research|source audit|multiple projects|các project|pi-\*|source\/|@source)/i;
27
28
  const FAST_FIX_TERMS = ["quick fix", "fast-fix", "small bug", "typo", "one-line", "minor", "lint"];
28
29
  const IMPLEMENTATION_TERMS = ["implement", "refactor", "migrate", "feature", "tests", "test", "integration", "upgrade", "build", "create", "add"];
29
30
  const RISKY_TERMS = ["migration", "refactor", "large", "multiple", "parallel", "concurrent", "risky", "critical"];
@@ -122,6 +123,11 @@ export function recommendTeam(goal: string, config: PiTeamsAutonomousConfig = {}
122
123
  workflow = "review";
123
124
  confidence = "high";
124
125
  reasons.push(`Review/audit terms detected: ${reviewMatches.join(", ") || "explicit review intent"}.`);
126
+ } else if (PARALLEL_RESEARCH_RE.test(goal) || (researchMatches.length >= 2 && (normalized.includes("multiple") || normalized.includes("source") || normalized.includes("project") || normalized.includes("pi-")))) {
127
+ team = "parallel-research";
128
+ workflow = "parallel-research";
129
+ confidence = "high";
130
+ reasons.push("Deep/multi-source research detected; use parallel shard exploration.");
125
131
  } else if (intents.includes("research") || (researchMatches.length > 0 && implementationMatches.length === 0)) {
126
132
  team = "research";
127
133
  workflow = "research";
@@ -5,6 +5,7 @@ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
5
5
  import { allAgents, discoverAgents } from "../agents/discover-agents.ts";
6
6
  import { allTeams, discoverTeams } from "../teams/discover-teams.ts";
7
7
  import { allWorkflows, discoverWorkflows } from "../workflows/discover-workflows.ts";
8
+ import type { WorkflowConfig, WorkflowStep } from "../workflows/workflow-config.ts";
8
9
  import { effectiveAutonomousConfig, loadConfig, updateAutonomousConfig, updateConfig, type PiTeamsAutonomousConfig, type PiTeamsConfig } from "../config/config.ts";
9
10
  import { projectPiRoot, userPiRoot } from "../utils/paths.ts";
10
11
  import type { TeamToolParamsValue } from "../schema/team-tool-schema.ts";
@@ -44,6 +45,7 @@ import { readForegroundControlStatus, writeForegroundInterruptRequest } from "..
44
45
  import { listLiveAgents, resumeLiveAgent, steerLiveAgent, stopLiveAgent } from "../runtime/live-agent-manager.ts";
45
46
  import { appendLiveAgentControlRequest } from "../runtime/live-agent-control.ts";
46
47
  import { liveControlRealtimeMessage, publishLiveControlRealtime } from "../runtime/live-control-realtime.ts";
48
+ import { formatTaskGraphLines, waitingReason } from "../runtime/task-display.ts";
47
49
 
48
50
  export interface TeamToolDetails {
49
51
  action: string;
@@ -57,7 +59,8 @@ type TeamContext = Pick<ExtensionContext, "cwd"> & Partial<Pick<ExtensionContext
57
59
  sessionManager?: { getBranch?: () => unknown[] };
58
60
  events?: { emit?: (event: string, data: unknown) => void };
59
61
  signal?: AbortSignal;
60
- startForegroundRun?: (runner: (signal?: AbortSignal) => Promise<void>) => void;
62
+ startForegroundRun?: (runner: (signal?: AbortSignal) => Promise<void>, runId?: string) => void;
63
+ onRunStarted?: (runId: string) => void;
61
64
  };
62
65
 
63
66
  function result(text: string, details: TeamToolDetails, isError = false): PiTeamsToolResult {
@@ -168,6 +171,48 @@ function commandExists(command: string, args: string[]): { ok: boolean; detail:
168
171
  return { ok: false, detail: output.error?.message ?? firstOutputLine(output.stdout, output.stderr) };
169
172
  }
170
173
 
174
+ function sourcePiProjects(cwd: string): string[] {
175
+ const sourceDir = path.join(cwd, "Source");
176
+ try {
177
+ return fs.readdirSync(sourceDir, { withFileTypes: true })
178
+ .filter((entry) => entry.isDirectory() && entry.name.startsWith("pi-"))
179
+ .map((entry) => `Source/${entry.name}`)
180
+ .sort();
181
+ } catch {
182
+ return [];
183
+ }
184
+ }
185
+
186
+ function chunkProjects(projects: string[], target = 4): string[][] {
187
+ const chunks = Array.from({ length: Math.min(Math.max(1, target), Math.max(1, projects.length)) }, () => [] as string[]);
188
+ projects.forEach((project, index) => chunks[index % chunks.length]!.push(project));
189
+ return chunks.filter((chunk) => chunk.length > 0);
190
+ }
191
+
192
+ function expandParallelResearchWorkflow(workflow: WorkflowConfig, cwd: string): WorkflowConfig {
193
+ if (workflow.name !== "parallel-research") return workflow;
194
+ const projects = sourcePiProjects(cwd);
195
+ if (projects.length === 0) return workflow;
196
+ const chunks = chunkProjects(projects, Math.min(6, Math.max(4, Math.ceil(projects.length / 4))));
197
+ const exploreSteps: WorkflowStep[] = chunks.map((paths, index) => ({
198
+ id: `explore-shard-${index + 1}`,
199
+ role: "explorer",
200
+ dependsOn: ["discover"],
201
+ parallelGroup: "explore",
202
+ reads: paths,
203
+ task: [`Explore this dynamic shard for: {goal}`, "", "Paths:", ...paths.map((item) => `- ${item}`), "", "Focus on purpose, architecture, runtime/UI patterns, package config, docs, and lessons for pi-crew."].join("\n"),
204
+ }));
205
+ return {
206
+ ...workflow,
207
+ steps: [
208
+ { id: "discover", role: "explorer", task: `Discover and validate ${projects.length} pi-* projects for: {goal}\n\nProjects:\n${projects.map((item) => `- ${item}`).join("\n")}` },
209
+ ...exploreSteps,
210
+ { id: "synthesize", role: "analyst", dependsOn: exploreSteps.map((step) => step.id), task: "Synthesize all dynamic shard findings. Identify common patterns, gaps, and concrete recommendations." },
211
+ { id: "write", role: "writer", dependsOn: ["synthesize"], output: "research-summary.md", task: "Write a concise final summary with evidence, risks, and actionable next steps." },
212
+ ],
213
+ };
214
+ }
215
+
171
216
  function effectiveRunConfig(base: PiTeamsConfig, rawOverride: unknown): PiTeamsConfig {
172
217
  const patch = configPatchFromConfig(rawOverride);
173
218
  return {
@@ -255,12 +300,29 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
255
300
  const teams = allTeams(discoverTeams(ctx.cwd));
256
301
  const workflows = allWorkflows(discoverWorkflows(ctx.cwd));
257
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);
258
305
  const teamName = params.team ?? "default";
259
- 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);
260
315
  if (!team) return result(`Team '${teamName}' not found.`, { action: "run", status: "error" }, true);
261
- const workflowName = params.workflow ?? team.defaultWorkflow ?? "default";
262
- const workflow = workflows.find((item) => item.name === workflowName);
263
- if (!workflow) return result(`Workflow '${workflowName}' not found.`, { action: "run", status: "error" }, true);
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);
324
+ if (!baseWorkflow) return result(`Workflow '${workflowName}' not found.`, { action: "run", status: "error" }, true);
325
+ const workflow = directAgent ? baseWorkflow : expandParallelResearchWorkflow(baseWorkflow, ctx.cwd);
264
326
 
265
327
  const validationErrors = validateWorkflowForTeam(workflow, team);
266
328
  if (validationErrors.length > 0) {
@@ -306,12 +368,13 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
306
368
  }
307
369
 
308
370
  const runtime = await resolveCrewRuntime(effectiveRunConfig(loadedConfig.config, params.config));
309
- const executeWorkers = runtime.kind === "child-process";
371
+ const executeWorkers = runtime.kind !== "scaffold";
310
372
  const executedConfig = effectiveRunConfig(loadedConfig.config, params.config);
311
373
  if (executeWorkers && ctx.startForegroundRun) {
374
+ ctx.onRunStarted?.(updatedManifest.runId);
312
375
  ctx.startForegroundRun(async (signal) => {
313
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 });
314
- });
377
+ }, updatedManifest.runId);
315
378
  const text = [
316
379
  `Started foreground pi-crew run ${updatedManifest.runId}.`,
317
380
  `Team: ${team.name}`,
@@ -368,6 +431,10 @@ export function handleStatus(params: TeamToolParamsValue, ctx: TeamContext): PiT
368
431
  const crewAgents = readCrewAgents(manifest).map((agent) => applyAttentionState(manifest, agent, controlConfig));
369
432
  const artifactLines = manifest.artifacts.slice(-10).map((artifact) => `- ${artifact.kind}: ${artifact.path}${artifact.sizeBytes !== undefined ? ` (${artifact.sizeBytes} bytes)` : ""}`);
370
433
  const totalUsage = aggregateUsage(tasks);
434
+ const activeAgents = crewAgents.filter((agent) => agent.status === "running");
435
+ const completedAgents = crewAgents.filter((agent) => agent.status !== "running");
436
+ const waitingTasks = tasks.filter((task) => task.status === "queued");
437
+ const agentLine = (agent: typeof crewAgents[number]): string => `- ${agent.id} [${agent.status}] ${agent.role} -> ${agent.agent} runtime=${agent.runtime}${agent.model ? ` model=${agent.model}` : ""}${agent.usage ? ` usage=${formatUsage(agent.usage)}` : ""}${agent.progress?.activityState === "needs_attention" ? " needs_attention" : ""}${formatActivityAge(agent) ? ` activity=${formatActivityAge(agent)}` : ""}${agent.progress?.currentTool ? ` tool=${agent.progress.currentTool}` : ""}${agent.toolUses ? ` tools=${agent.toolUses}` : ""}${!agent.usage && agent.progress?.tokens ? ` tokens=${agent.progress.tokens}` : ""}${agent.progress?.turns ? ` turns=${agent.progress.turns}` : ""}${agent.jsonEvents !== undefined ? ` jsonEvents=${agent.jsonEvents}` : ""}${agent.statusPath ? ` status=${agent.statusPath}` : ""}${agent.error ? ` error=${agent.error}` : ""}`;
371
438
  const lines = [
372
439
  `Run: ${manifest.runId}`,
373
440
  `Team: ${manifest.team}`,
@@ -380,11 +447,17 @@ export function handleStatus(params: TeamToolParamsValue, ctx: TeamContext): PiT
380
447
  `State: ${manifest.stateRoot}`,
381
448
  `Artifacts: ${manifest.artifactsRoot}`,
382
449
  ...(asyncLivenessLine ? [asyncLivenessLine] : []),
450
+ "Task graph:",
451
+ ...formatTaskGraphLines(tasks),
383
452
  "Tasks:",
384
453
  ...(tasks.length ? tasks.map((task) => `- ${task.id} [${task.status}] ${task.role} -> ${task.agent}${task.taskPacket ? ` scope=${task.taskPacket.scope}` : ""}${task.verification ? ` green=${task.verification.observedGreenLevel}/${task.verification.requiredGreenLevel}` : ""}${task.modelAttempts?.length ? ` attempts=${task.modelAttempts.length}` : ""}${task.jsonEvents !== undefined ? ` jsonEvents=${task.jsonEvents}` : ""}${task.usage ? ` usage=${JSON.stringify(task.usage)}` : ""}${task.worktree ? ` worktree=${task.worktree.path}` : ""}${task.error ? ` error=${task.error}` : ""}`) : ["- (none)"]),
385
454
  `Task counts: ${[...counts.entries()].map(([status, count]) => `${status}=${count}`).join(", ") || "none"}`,
386
- "Agents:",
387
- ...(crewAgents.length ? crewAgents.map((agent) => `- ${agent.id} [${agent.status}] ${agent.role} -> ${agent.agent} runtime=${agent.runtime}${agent.model ? ` model=${agent.model}` : ""}${agent.usage ? ` usage=${formatUsage(agent.usage)}` : ""}${agent.progress?.activityState === "needs_attention" ? " needs_attention" : ""}${formatActivityAge(agent) ? ` activity=${formatActivityAge(agent)}` : ""}${agent.progress?.currentTool ? ` tool=${agent.progress.currentTool}` : ""}${agent.toolUses ? ` tools=${agent.toolUses}` : ""}${!agent.usage && agent.progress?.tokens ? ` tokens=${agent.progress.tokens}` : ""}${agent.progress?.turns ? ` turns=${agent.progress.turns}` : ""}${agent.jsonEvents !== undefined ? ` jsonEvents=${agent.jsonEvents}` : ""}${agent.statusPath ? ` status=${agent.statusPath}` : ""}${agent.error ? ` error=${agent.error}` : ""}`) : ["- (none)"]),
455
+ "Active agents:",
456
+ ...(activeAgents.length ? activeAgents.map(agentLine) : ["- (none)"]),
457
+ "Waiting tasks:",
458
+ ...(waitingTasks.length ? waitingTasks.map((task) => `- ${task.id} [queued] ${task.role} -> ${task.agent} ${waitingReason(task, tasks) ?? "waiting"}`) : ["- (none)"]),
459
+ "Completed agents:",
460
+ ...(completedAgents.length ? completedAgents.map(agentLine) : ["- (none)"]),
388
461
  "Policy decisions:",
389
462
  ...(manifest.policyDecisions?.length ? manifest.policyDecisions.map((item) => `- ${item.action} (${item.reason})${item.taskId ? ` ${item.taskId}` : ""}: ${item.message}`) : ["- (none)"]),
390
463
  `Total usage: ${formatUsage(totalUsage)}`,
@@ -450,7 +523,7 @@ export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext
450
523
  appendEvent(loaded.manifest.eventsPath, { type: "run.resume_requested", runId: loaded.manifest.runId });
451
524
  const loadedConfig = loadConfig(ctx.cwd);
452
525
  const runtime = await resolveCrewRuntime(loadedConfig.config);
453
- const executeWorkers = runtime.kind === "child-process";
526
+ const executeWorkers = runtime.kind !== "scaffold";
454
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 });
455
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");
456
529
  });