pi-crew 0.1.19 → 0.1.21

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.20
4
+
5
+ - Reworked the implementation workflow into an adaptive planner-led orchestration flow that decides the number, roles, and phases of subagents from the task instead of using a fixed fanout template.
6
+ - Added dynamic adaptive task injection, persisted adaptive task metadata, and resume reconstruction for planner-selected subagent steps.
7
+ - Block implementation runs when the planner does not produce a valid adaptive plan, including missing/unreadable planner artifacts and malformed/oversized plans.
8
+ - Added tests for adaptive plan parsing, dynamic batch fanout, invalid-plan blocking, writer-role support, and adaptive resume recovery.
9
+ - Hardened subagent/runtime fixes from post-0.1.19 review: env-isolated depth tests, foreground failure status updates, generic tool conflict aliases, and max_turns propagation.
10
+
3
11
  ## 0.1.19
4
12
 
5
13
  - 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.1.19",
3
+ "version": "0.1.21",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -16,10 +16,10 @@ import { registerPiCrewRpc, type PiCrewRpcHandle } from "./cross-extension-rpc.t
16
16
  import { stopCrewWidget, updateCrewWidget, type CrewWidgetState } from "../ui/crew-widget.ts";
17
17
  import { clearPiCrewPowerbar, registerPiCrewPowerbarSegments, updatePiCrewPowerbar } from "../ui/powerbar-publisher.ts";
18
18
  import { DurableTextViewer, DurableTranscriptViewer } from "../ui/transcript-viewer.ts";
19
- import { loadRunManifestById } from "../state/state-store.ts";
19
+ import { loadRunManifestById, updateRunStatus } from "../state/state-store.ts";
20
20
  import { readCrewAgents } from "../runtime/crew-agent-records.ts";
21
21
  import { terminateActiveChildPiProcesses } from "../runtime/child-pi.ts";
22
- import { SubagentManager, type SubagentRecord, type SubagentSpawnOptions } from "../runtime/subagent-manager.ts";
22
+ import { readPersistedSubagentRecord, savePersistedSubagentRecord, SubagentManager, type SubagentRecord, type SubagentSpawnOptions } from "../runtime/subagent-manager.ts";
23
23
 
24
24
  function parseRunArgs(args: string): TeamToolParamsValue {
25
25
  const tokens = args.match(/"[^"]*"|'[^']*'|\S+/g)?.map((token) => token.replace(/^['"]|['"]$/g, "")) ?? [];
@@ -109,6 +109,18 @@ function sendFollowUp(pi: ExtensionAPI, content: string): void {
109
109
  sender.call(pi, { customType: "pi-crew-subagent-notification", content, display: true }, { deliverAs: "followUp", triggerTurn: true });
110
110
  }
111
111
 
112
+ function refreshPersistedSubagentRecord(ctx: ExtensionContext | ExtensionCommandContext, record: SubagentRecord): SubagentRecord {
113
+ if (!record.runId) return record;
114
+ const loaded = loadRunManifestById(ctx.cwd, record.runId);
115
+ if (!loaded) return record;
116
+ if (loaded.manifest.status === "completed" || loaded.manifest.status === "failed" || loaded.manifest.status === "cancelled" || loaded.manifest.status === "blocked") {
117
+ const refreshed = { ...record, status: loaded.manifest.status === "completed" ? "completed" as const : loaded.manifest.status === "cancelled" ? "cancelled" as const : "failed" as const, error: loaded.manifest.status === "completed" ? undefined : loaded.manifest.summary, completedAt: record.completedAt ?? Date.now() };
118
+ savePersistedSubagentRecord(ctx.cwd, refreshed);
119
+ return refreshed;
120
+ }
121
+ return record;
122
+ }
123
+
112
124
  function formatSubagentRecord(record: SubagentRecord): string {
113
125
  const duration = record.completedAt ? `${Math.round((record.completedAt - record.startedAt) / 1000)}s` : "running";
114
126
  return [
@@ -215,6 +227,12 @@ export function registerPiTeams(pi: ExtensionAPI): void {
215
227
  void runner(controller.signal)
216
228
  .catch((error) => {
217
229
  const message = error instanceof Error ? error.message : String(error);
230
+ if (runId) {
231
+ try {
232
+ const loaded = loadRunManifestById(ctx.cwd, runId);
233
+ if (loaded && loaded.manifest.status !== "completed" && loaded.manifest.status !== "failed" && loaded.manifest.status !== "cancelled" && loaded.manifest.status !== "blocked") updateRunStatus(loaded.manifest, "failed", message);
234
+ } catch {}
235
+ }
218
236
  ctx.ui.notify(`pi-crew foreground run failed: ${message}`, "error");
219
237
  })
220
238
  .finally(() => {
@@ -332,8 +350,9 @@ export function registerPiTeams(pi: ExtensionAPI): void {
332
350
  agent: spawnOptions.type,
333
351
  goal: spawnOptions.prompt,
334
352
  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 });
353
+ async: spawnOptions.background,
354
+ config: spawnOptions.maxTurns ? { runtime: { maxTurns: spawnOptions.maxTurns } } : undefined,
355
+ }, spawnOptions.background ? { ...ctx, signal: childSignal } : { ...ctx, signal: childSignal });
337
356
  const record = subagentManager.spawn(options, runner, options.background ? undefined : signal);
338
357
  if (options.background || record.status === "queued") {
339
358
  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 });
@@ -353,18 +372,40 @@ export function registerPiTeams(pi: ExtensionAPI): void {
353
372
  wait: Type.Optional(Type.Boolean({ description: "Wait for completion before returning." })),
354
373
  verbose: Type.Optional(Type.Boolean({ description: "Include status metadata before output." })),
355
374
  }) as never,
356
- async execute(_id, params, _signal, _onUpdate, ctx) {
375
+ async execute(_id, params, signal, _onUpdate, ctx) {
357
376
  const p = params as { agent_id?: string; wait?: boolean; verbose?: boolean };
358
377
  if (!p.agent_id) return subagentToolResult("get_subagent_result requires agent_id.", {}, true);
359
- const record = subagentManager.getRecord(p.agent_id);
378
+ const inMemory = subagentManager.getRecord(p.agent_id);
379
+ const record = inMemory ?? readPersistedSubagentRecord(ctx.cwd, p.agent_id);
360
380
  if (!record) return subagentToolResult(`Agent not found: ${p.agent_id}`, {}, true);
361
- let current = record;
381
+ let current = refreshPersistedSubagentRecord(ctx, record);
382
+ if (!inMemory && !current.runId && (current.status === "running" || current.status === "queued")) {
383
+ current = { ...current, status: "error", error: "Subagent was interrupted before its durable run id was recorded; it cannot be recovered after restart.", completedAt: current.completedAt ?? Date.now() };
384
+ savePersistedSubagentRecord(ctx.cwd, current);
385
+ }
362
386
  if (p.wait && (current.status === "running" || current.status === "queued")) {
363
387
  current.resultConsumed = true;
364
- current = await subagentManager.waitForRecord(current.id) ?? current;
388
+ savePersistedSubagentRecord(ctx.cwd, current);
389
+ const waited = await subagentManager.waitForRecord(current.id);
390
+ if (waited) current = waited;
391
+ else {
392
+ while (current.status === "running" || current.status === "queued") {
393
+ if (signal?.aborted) {
394
+ current = { ...current, status: "error", error: "Waiting for subagent result was aborted.", completedAt: Date.now() };
395
+ savePersistedSubagentRecord(ctx.cwd, current);
396
+ break;
397
+ }
398
+ await new Promise((resolve) => setTimeout(resolve, 1000));
399
+ current = refreshPersistedSubagentRecord(ctx, current);
400
+ if (!current.runId) break;
401
+ }
402
+ }
365
403
  }
366
404
  const output = readSubagentRunResult(ctx, current);
367
- if (current.status !== "running" && current.status !== "queued") current.resultConsumed = true;
405
+ if (current.status !== "running" && current.status !== "queued") {
406
+ current.resultConsumed = true;
407
+ savePersistedSubagentRecord(ctx.cwd, current);
408
+ }
368
409
  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
410
  return subagentToolResult(text, { agentId: current.id, runId: current.runId, status: current.status }, current.status === "failed" || current.status === "error");
370
411
  },
@@ -375,9 +416,9 @@ export function registerPiTeams(pi: ExtensionAPI): void {
375
416
  label: "Steer Agent",
376
417
  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
418
  parameters: Type.Object({ agent_id: Type.String(), message: Type.String() }) as never,
378
- async execute(_id, params) {
419
+ async execute(_id, params, _signal, _onUpdate, ctx) {
379
420
  const p = params as { agent_id?: string; message?: string };
380
- const record = p.agent_id ? subagentManager.getRecord(p.agent_id) : undefined;
421
+ const record = p.agent_id ? subagentManager.getRecord(p.agent_id) ?? readPersistedSubagentRecord(ctx.cwd, p.agent_id) : undefined;
381
422
  if (!record) return subagentToolResult(`Agent not found: ${p.agent_id ?? ""}`, {}, true);
382
423
  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
424
  },
@@ -402,8 +443,9 @@ export function registerPiTeams(pi: ExtensionAPI): void {
402
443
  label: "Steer Crew Agent",
403
444
  description: "Send a steering note to a pi-crew subagent using the conflict-safe tool name.",
404
445
  };
405
- for (const extraTool of [agentTool, getSubagentResultTool, steerSubagentTool, crewAgentTool, crewAgentResultTool, crewAgentSteerTool]) {
406
- pi.registerTool(extraTool);
446
+ for (const extraTool of [crewAgentTool, crewAgentResultTool, crewAgentSteerTool]) pi.registerTool(extraTool);
447
+ for (const extraTool of [agentTool, getSubagentResultTool, steerSubagentTool]) {
448
+ try { pi.registerTool(extraTool); } catch {}
407
449
  }
408
450
 
409
451
  pi.registerCommand("teams", {
@@ -46,6 +46,7 @@ import { listLiveAgents, resumeLiveAgent, steerLiveAgent, stopLiveAgent } from "
46
46
  import { appendLiveAgentControlRequest } from "../runtime/live-agent-control.ts";
47
47
  import { liveControlRealtimeMessage, publishLiveControlRealtime } from "../runtime/live-control-realtime.ts";
48
48
  import { formatTaskGraphLines, waitingReason } from "../runtime/task-display.ts";
49
+ import { directTeamAndWorkflowFromRun } from "../runtime/direct-run.ts";
49
50
 
50
51
  export interface TeamToolDetails {
51
52
  action: string;
@@ -513,9 +514,11 @@ export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext
513
514
  const loaded = loadRunManifestById(ctx.cwd, params.runId);
514
515
  if (!loaded) return result(`Run '${params.runId}' not found.`, { action: "resume", status: "error" }, true);
515
516
  if (!loaded.manifest.workflow) return result(`Run '${params.runId}' has no workflow to resume.`, { action: "resume", status: "error" }, true);
516
- const team = allTeams(discoverTeams(ctx.cwd)).find((candidate) => candidate.name === loaded.manifest.team);
517
+ const agents = allAgents(discoverAgents(ctx.cwd));
518
+ const direct = directTeamAndWorkflowFromRun(loaded.manifest, loaded.tasks, agents);
519
+ const team = direct?.team ?? allTeams(discoverTeams(ctx.cwd)).find((candidate) => candidate.name === loaded.manifest.team);
517
520
  if (!team) return result(`Team '${loaded.manifest.team}' not found.`, { action: "resume", status: "error" }, true);
518
- const workflow = allWorkflows(discoverWorkflows(ctx.cwd)).find((candidate) => candidate.name === loaded.manifest.workflow);
521
+ const workflow = direct?.workflow ?? allWorkflows(discoverWorkflows(ctx.cwd)).find((candidate) => candidate.name === loaded.manifest.workflow);
519
522
  if (!workflow) return result(`Workflow '${loaded.manifest.workflow}' not found.`, { action: "resume", status: "error" }, true);
520
523
  return await withRunLock(loaded.manifest, async () => {
521
524
  const resetTasks = loaded.tasks.map((task) => task.status === "failed" || task.status === "cancelled" || task.status === "skipped" || task.status === "running" ? { ...task, status: "queued" as const, error: undefined, startedAt: undefined, finishedAt: undefined, claim: undefined } : task);
@@ -524,7 +527,7 @@ export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext
524
527
  const loadedConfig = loadConfig(ctx.cwd);
525
528
  const runtime = await resolveCrewRuntime(loadedConfig.config);
526
529
  const executeWorkers = runtime.kind !== "scaffold";
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 });
530
+ const executed = await executeTeamRun({ manifest: loaded.manifest, tasks: resetTasks, team, workflow, agents, 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 });
528
531
  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");
529
532
  });
530
533
  }
@@ -6,6 +6,7 @@ import { allWorkflows, discoverWorkflows } from "../workflows/discover-workflows
6
6
  import { loadConfig } from "../config/config.ts";
7
7
  import { executeTeamRun } from "./team-runner.ts";
8
8
  import { resolveCrewRuntime } from "./runtime-resolver.ts";
9
+ import { directTeamAndWorkflowFromRun } from "./direct-run.ts";
9
10
 
10
11
  function argValue(name: string): string | undefined {
11
12
  const index = process.argv.indexOf(name);
@@ -24,14 +25,15 @@ async function main(): Promise<void> {
24
25
  appendEvent(manifest.eventsPath, { type: "async.started", runId: manifest.runId, data: { pid: process.pid } });
25
26
 
26
27
  try {
27
- const team = allTeams(discoverTeams(cwd)).find((candidate) => candidate.name === manifest.team);
28
+ const agents = allAgents(discoverAgents(cwd));
29
+ const direct = directTeamAndWorkflowFromRun(manifest, tasks, agents);
30
+ const team = direct?.team ?? allTeams(discoverTeams(cwd)).find((candidate) => candidate.name === manifest.team);
28
31
  if (!team) throw new Error(`Team '${manifest.team}' not found.`);
29
- const workflow = allWorkflows(discoverWorkflows(cwd)).find((candidate) => candidate.name === manifest.workflow);
32
+ const workflow = direct?.workflow ?? allWorkflows(discoverWorkflows(cwd)).find((candidate) => candidate.name === manifest.workflow);
30
33
  if (!workflow) throw new Error(`Workflow '${manifest.workflow ?? ""}' not found.`);
31
- const agents = allAgents(discoverAgents(cwd));
32
34
  const loadedConfig = loadConfig(cwd);
33
35
  const runtime = await resolveCrewRuntime(loadedConfig.config);
34
- const executeWorkers = runtime.kind === "child-process";
36
+ const executeWorkers = runtime.kind !== "scaffold";
35
37
  const result = await executeTeamRun({ manifest, tasks, team, workflow, agents, executeWorkers, limits: loadedConfig.config.limits, runtime, runtimeConfig: loadedConfig.config.runtime });
36
38
  manifest = result.manifest;
37
39
  tasks = result.tasks;
@@ -210,8 +210,11 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
210
210
  observeStdoutChunk(input, stdout);
211
211
  return { exitCode: 0, stdout, stderr: "" };
212
212
  }
213
- if (mock === "json-success") {
214
- const stdout = `${JSON.stringify({ type: "message", message: { role: "assistant", content: [{ type: "text", text: `Mock JSON success for ${input.agent.name}` }] } })}\n${JSON.stringify({ type: "message_end", usage: { input: 10, output: 5, cost: 0.001, turns: 1 } })}\n`;
213
+ if (mock === "json-success" || mock === "adaptive-plan") {
214
+ const text = mock === "adaptive-plan" && input.task.includes("ADAPTIVE_PLAN_JSON_START")
215
+ ? `Adaptive mock plan\nADAPTIVE_PLAN_JSON_START\n${JSON.stringify({ phases: [{ name: "research", tasks: [{ role: "explorer", task: "Explore adaptive target" }, { role: "analyst", task: "Analyze adaptive target" }, { role: "planner", task: "Plan adaptive target" }] }, { name: "build", tasks: [{ role: "executor", task: "Implement adaptive target" }] }, { name: "check", tasks: [{ role: "reviewer", task: "Review adaptive target" }, { role: "test-engineer", task: "Test adaptive target" }, { role: "writer", task: "Summarize adaptive target" }] }] })}\nADAPTIVE_PLAN_JSON_END`
216
+ : `Mock JSON success for ${input.agent.name}`;
217
+ const stdout = `${JSON.stringify({ type: "message", message: { role: "assistant", content: [{ type: "text", text }] } })}\n${JSON.stringify({ type: "message_end", usage: { input: 10, output: 5, cost: 0.001, turns: 1 } })}\n`;
215
218
  observeStdoutChunk(input, stdout);
216
219
  return { exitCode: 0, stdout, stderr: "" };
217
220
  }
@@ -0,0 +1,35 @@
1
+ import type { AgentConfig } from "../agents/agent-config.ts";
2
+ import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
3
+ import type { TeamConfig } from "../teams/team-config.ts";
4
+ import type { WorkflowConfig } from "../workflows/workflow-config.ts";
5
+
6
+ export function isDirectRun(manifest: Pick<TeamRunManifest, "team" | "workflow">): boolean {
7
+ return manifest.workflow === "direct-agent";
8
+ }
9
+
10
+ export function directTeamAndWorkflowFromRun(manifest: TeamRunManifest, tasks: TeamTaskState[], agents: AgentConfig[]): { team: TeamConfig; workflow: WorkflowConfig } | undefined {
11
+ if (!isDirectRun(manifest)) return undefined;
12
+ const firstTask = tasks[0];
13
+ const agentName = firstTask?.agent ?? (manifest.team.replace(/^direct-/, "") || "executor");
14
+ const agent = agents.find((candidate) => candidate.name === agentName);
15
+ const role = firstTask?.role ?? "agent";
16
+ const stepId = firstTask?.stepId ?? "01_agent";
17
+ return {
18
+ team: {
19
+ name: manifest.team,
20
+ description: `Direct subagent run for ${agentName}`,
21
+ source: "builtin",
22
+ filePath: "<generated>",
23
+ roles: [{ name: role, agent: agentName, description: agent?.description }],
24
+ defaultWorkflow: "direct-agent",
25
+ workspaceMode: manifest.workspaceMode,
26
+ },
27
+ workflow: {
28
+ name: manifest.workflow ?? "direct-agent",
29
+ description: `Direct task for ${agentName}`,
30
+ source: "builtin",
31
+ filePath: "<generated>",
32
+ steps: [{ id: stepId, role, task: "{goal}", model: firstTask?.model }],
33
+ },
34
+ };
35
+ }
@@ -15,6 +15,7 @@ export interface BuildPiWorkerArgsInput {
15
15
  model?: string;
16
16
  sessionEnabled?: boolean;
17
17
  maxDepth?: number;
18
+ env?: NodeJS.ProcessEnv;
18
19
  }
19
20
 
20
21
  export interface BuildPiWorkerArgsResult {
@@ -82,8 +83,9 @@ export function buildPiWorkerArgs(input: BuildPiWorkerArgsInput): BuildPiWorkerA
82
83
  args.push(`Task: ${input.task}`);
83
84
  }
84
85
 
85
- const parentDepth = currentCrewDepth();
86
- const maxDepth = resolveCrewMaxDepth(input.maxDepth);
86
+ const env = input.env ?? process.env;
87
+ const parentDepth = currentCrewDepth(env);
88
+ const maxDepth = resolveCrewMaxDepth(input.maxDepth, env);
87
89
  return {
88
90
  args,
89
91
  env: {
@@ -1,5 +1,8 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
1
3
  import { loadRunManifestById } from "../state/state-store.ts";
2
4
  import type { PiTeamsToolResult } from "../extension/tool-result.ts";
5
+ import { projectPiRoot } from "../utils/paths.ts";
3
6
 
4
7
  export type SubagentStatus = "queued" | "running" | "completed" | "failed" | "cancelled" | "error" | "stopped";
5
8
 
@@ -42,6 +45,32 @@ interface QueuedSpawn {
42
45
 
43
46
  const TERMINAL_RUN_STATUS = new Set(["completed", "failed", "cancelled", "blocked"]);
44
47
 
48
+ function persistedSubagentPath(cwd: string, id: string): string {
49
+ return path.join(projectPiRoot(cwd), "teams", "state", "subagents", `${id}.json`);
50
+ }
51
+
52
+ function serializableRecord(record: SubagentRecord): SubagentRecord {
53
+ const { promise: _promise, ...rest } = record;
54
+ return rest;
55
+ }
56
+
57
+ export function savePersistedSubagentRecord(cwd: string, record: SubagentRecord): void {
58
+ try {
59
+ const filePath = persistedSubagentPath(cwd, record.id);
60
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
61
+ fs.writeFileSync(filePath, `${JSON.stringify(serializableRecord(record), null, 2)}\n`, "utf-8");
62
+ } catch {}
63
+ }
64
+
65
+ export function readPersistedSubagentRecord(cwd: string, id: string): SubagentRecord | undefined {
66
+ try {
67
+ const parsed = JSON.parse(fs.readFileSync(persistedSubagentPath(cwd, id), "utf-8"));
68
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as SubagentRecord : undefined;
69
+ } catch {
70
+ return undefined;
71
+ }
72
+ }
73
+
45
74
  function resultText(result: PiTeamsToolResult): string {
46
75
  return result.content?.map((item) => item.type === "text" ? item.text : "").filter(Boolean).join("\n") ?? "";
47
76
  }
@@ -78,6 +107,7 @@ export class SubagentManager {
78
107
  background: options.background,
79
108
  };
80
109
  this.records.set(record.id, record);
110
+ savePersistedSubagentRecord(options.cwd, record);
81
111
  if (record.status === "queued") {
82
112
  this.queue.push({ record, options, runner, signal });
83
113
  return record;
@@ -155,11 +185,13 @@ export class SubagentManager {
155
185
  if (options.background) this.runningBackground++;
156
186
  record.status = "running";
157
187
  record.startedAt = Date.now();
188
+ savePersistedSubagentRecord(options.cwd, record);
158
189
  record.promise = (async () => {
159
190
  try {
160
191
  const result = await runner(options, signal);
161
192
  record.runId = detailsRunId(result);
162
193
  record.result = resultText(result);
194
+ savePersistedSubagentRecord(options.cwd, record);
163
195
  if (result.isError) {
164
196
  record.status = "error";
165
197
  record.error = record.result;
@@ -173,6 +205,7 @@ export class SubagentManager {
173
205
  } finally {
174
206
  if (options.background) this.runningBackground = Math.max(0, this.runningBackground - 1);
175
207
  record.completedAt = record.completedAt ?? Date.now();
208
+ savePersistedSubagentRecord(options.cwd, record);
176
209
  this.onComplete?.(record);
177
210
  this.drainQueue();
178
211
  }
@@ -194,6 +227,7 @@ export class SubagentManager {
194
227
  record.status = loaded.manifest.status === "completed" ? "completed" : loaded.manifest.status === "cancelled" ? "cancelled" : "failed";
195
228
  record.error = record.status === "completed" ? undefined : loaded.manifest.summary;
196
229
  record.completedAt = Date.now();
230
+ savePersistedSubagentRecord(cwd, record);
197
231
  return;
198
232
  }
199
233
  await new Promise((resolve) => setTimeout(resolve, this.pollIntervalMs));
@@ -1,3 +1,4 @@
1
+ import * as fs from "node:fs";
1
2
  import type { AgentConfig } from "../agents/agent-config.ts";
2
3
  import type { CrewLimitsConfig, CrewRuntimeConfig } from "../config/config.ts";
3
4
  import type { CrewRuntimeCapabilities } from "./runtime-resolver.ts";
@@ -76,6 +77,129 @@ function mergeTaskUpdates(base: TeamTaskState[], results: Array<{ tasks: TeamTas
76
77
  return refreshTaskGraphQueues(merged);
77
78
  }
78
79
 
80
+ interface AdaptivePlanTask {
81
+ role: string;
82
+ title?: string;
83
+ task: string;
84
+ }
85
+
86
+ interface AdaptivePlanPhase {
87
+ name: string;
88
+ tasks: AdaptivePlanTask[];
89
+ }
90
+
91
+ interface AdaptivePlan {
92
+ phases: AdaptivePlanPhase[];
93
+ }
94
+
95
+ const MAX_ADAPTIVE_TASKS = 12;
96
+
97
+ function slug(value: string): string {
98
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32) || "task";
99
+ }
100
+
101
+ export function __test__parseAdaptivePlan(text: string, allowedRoles: string[]): AdaptivePlan | undefined {
102
+ const markerMatch = text.match(/ADAPTIVE_PLAN_JSON_START\s*([\s\S]*?)\s*ADAPTIVE_PLAN_JSON_END/);
103
+ const fencedMatch = markerMatch ? undefined : text.match(/```(?:json)?\s*([\s\S]*?)```/i);
104
+ const raw = markerMatch?.[1] ?? fencedMatch?.[1];
105
+ if (!raw) return undefined;
106
+ let parsed: unknown;
107
+ try { parsed = JSON.parse(raw); } catch { return undefined; }
108
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
109
+ const phasesRaw = Array.isArray((parsed as { phases?: unknown }).phases) ? (parsed as { phases: unknown[] }).phases : Array.isArray((parsed as { tasks?: unknown }).tasks) ? [{ name: "adaptive", tasks: (parsed as { tasks: unknown[] }).tasks }] : undefined;
110
+ if (!phasesRaw) return undefined;
111
+ const allowed = new Set(allowedRoles);
112
+ const phases: AdaptivePlanPhase[] = [];
113
+ let total = 0;
114
+ for (const [phaseIndex, phaseRaw] of phasesRaw.entries()) {
115
+ if (!phaseRaw || typeof phaseRaw !== "object" || Array.isArray(phaseRaw)) return undefined;
116
+ const phaseObj = phaseRaw as { name?: unknown; tasks?: unknown };
117
+ if (!Array.isArray(phaseObj.tasks) || phaseObj.tasks.length === 0) return undefined;
118
+ const tasks: AdaptivePlanTask[] = [];
119
+ for (const taskRaw of phaseObj.tasks) {
120
+ if (!taskRaw || typeof taskRaw !== "object" || Array.isArray(taskRaw)) return undefined;
121
+ const taskObj = taskRaw as { role?: unknown; title?: unknown; task?: unknown };
122
+ if (typeof taskObj.role !== "string" || !allowed.has(taskObj.role)) return undefined;
123
+ if (typeof taskObj.task !== "string" || !taskObj.task.trim()) return undefined;
124
+ if (total >= MAX_ADAPTIVE_TASKS) return undefined;
125
+ tasks.push({ role: taskObj.role, title: typeof taskObj.title === "string" ? taskObj.title : undefined, task: taskObj.task.trim() });
126
+ total++;
127
+ }
128
+ phases.push({ name: typeof phaseObj.name === "string" && phaseObj.name.trim() ? phaseObj.name.trim() : `phase-${phaseIndex + 1}`, tasks });
129
+ }
130
+ return phases.length ? { phases } : undefined;
131
+ }
132
+
133
+ function reconstructAdaptiveWorkflow(workflow: WorkflowConfig, tasks: TeamTaskState[]): WorkflowConfig {
134
+ const existing = new Set(workflow.steps.map((step) => step.id));
135
+ const steps: WorkflowStep[] = [];
136
+ for (const task of tasks) {
137
+ if (!task.stepId?.startsWith("adaptive-") || !task.adaptive?.task || existing.has(task.stepId)) continue;
138
+ steps.push({ id: task.stepId, role: task.role, dependsOn: task.graph?.dependencies ?? task.dependsOn, parallelGroup: `adaptive-${slug(task.adaptive.phase)}`, task: task.adaptive.task });
139
+ }
140
+ return steps.length ? { ...workflow, steps: [...workflow.steps, ...steps] } : workflow;
141
+ }
142
+
143
+ function injectAdaptivePlanIfReady(input: { manifest: TeamRunManifest; tasks: TeamTaskState[]; workflow: WorkflowConfig; team: TeamConfig }): { tasks: TeamTaskState[]; workflow: WorkflowConfig; injected: boolean; missingPlan: boolean } {
144
+ if (input.workflow.name !== "implementation") return { tasks: input.tasks, workflow: input.workflow, injected: false, missingPlan: false };
145
+ if (input.tasks.some((task) => task.stepId?.startsWith("adaptive-"))) return { tasks: input.tasks, workflow: reconstructAdaptiveWorkflow(input.workflow, input.tasks), injected: false, missingPlan: false };
146
+ const completedAssess = input.tasks.find((task) => task.stepId === "assess" && task.status === "completed");
147
+ if (!completedAssess) return { tasks: input.tasks, workflow: input.workflow, injected: false, missingPlan: false };
148
+ if (!completedAssess.resultArtifact?.path) {
149
+ appendEvent(input.manifest.eventsPath, { type: "adaptive.plan_missing", runId: input.manifest.runId, taskId: completedAssess.id, message: "Adaptive planner result artifact is missing." });
150
+ return { tasks: input.tasks, workflow: input.workflow, injected: false, missingPlan: true };
151
+ }
152
+ const assessTask = completedAssess;
153
+ const resultPath = completedAssess.resultArtifact.path;
154
+ let text = "";
155
+ try { text = fs.readFileSync(resultPath, "utf-8"); } catch {
156
+ appendEvent(input.manifest.eventsPath, { type: "adaptive.plan_missing", runId: input.manifest.runId, taskId: assessTask.id, message: "Adaptive planner result artifact could not be read." });
157
+ return { tasks: input.tasks, workflow: input.workflow, injected: false, missingPlan: true };
158
+ }
159
+ const plan = __test__parseAdaptivePlan(text, input.team.roles.map((role) => role.name));
160
+ if (!plan) {
161
+ appendEvent(input.manifest.eventsPath, { type: "adaptive.plan_missing", runId: input.manifest.runId, taskId: assessTask.id, message: "Adaptive planner did not produce a valid plan; no dynamic subagents were spawned." });
162
+ return { tasks: input.tasks, workflow: input.workflow, injected: false, missingPlan: true };
163
+ }
164
+ const steps: WorkflowStep[] = [];
165
+ const tasks: TeamTaskState[] = [];
166
+ let previousStepIds = ["assess"];
167
+ let counter = 0;
168
+ for (const [phaseIndex, phase] of plan.phases.entries()) {
169
+ const currentStepIds: string[] = [];
170
+ for (const [taskIndex, planned] of phase.tasks.entries()) {
171
+ counter++;
172
+ const stepId = `adaptive-${phaseIndex + 1}-${taskIndex + 1}-${slug(planned.role)}`;
173
+ const taskId = `adaptive-${String(counter).padStart(2, "0")}-${slug(planned.role)}`;
174
+ steps.push({ id: stepId, role: planned.role, dependsOn: previousStepIds, parallelGroup: `adaptive-${slug(phase.name)}`, task: planned.task });
175
+ tasks.push({
176
+ id: taskId,
177
+ runId: input.manifest.runId,
178
+ stepId,
179
+ role: planned.role,
180
+ agent: input.team.roles.find((role) => role.name === planned.role)?.agent ?? planned.role,
181
+ title: planned.title ?? stepId,
182
+ status: "queued",
183
+ dependsOn: previousStepIds,
184
+ cwd: input.manifest.cwd,
185
+ adaptive: { phase: phase.name, task: planned.task },
186
+ graph: { taskId, dependencies: previousStepIds, children: [], queue: "blocked" },
187
+ });
188
+ currentStepIds.push(stepId);
189
+ }
190
+ previousStepIds = currentStepIds;
191
+ }
192
+ const dependencyTaskIdByStep = new Map<string, string>([["assess", assessTask.id], ...tasks.map((task) => [task.stepId ?? task.id, task.id] as const)]);
193
+ const withGraph = tasks.map((task) => ({
194
+ ...task,
195
+ dependsOn: task.dependsOn.map((dep) => dependencyTaskIdByStep.get(dep) ?? dep),
196
+ graph: task.graph ? { ...task.graph, dependencies: task.dependsOn.map((dep) => dependencyTaskIdByStep.get(dep) ?? dep), queue: "blocked" as const } : task.graph,
197
+ }));
198
+ const allTasks = refreshTaskGraphQueues([...input.tasks, ...withGraph]);
199
+ appendEvent(input.manifest.eventsPath, { type: "adaptive.plan_injected", runId: input.manifest.runId, taskId: assessTask.id, message: `Injected ${withGraph.length} adaptive subagent task(s) across ${plan.phases.length} phase(s).`, data: { phases: plan.phases.map((phase) => ({ name: phase.name, count: phase.tasks.length, roles: phase.tasks.map((task) => task.role) })) } });
200
+ return { tasks: allTasks, workflow: { ...input.workflow, steps: [...input.workflow.steps, ...steps] }, injected: true, missingPlan: false };
201
+ }
202
+
79
203
  function formatTaskProgress(task: TeamTaskState): string {
80
204
  return `- ${task.id}: ${task.status} (${task.role} -> ${task.agent})${task.taskPacket ? ` scope=${task.taskPacket.scope}` : ""}${task.verification ? ` green=${task.verification.observedGreenLevel}/${task.verification.requiredGreenLevel}` : ""}${task.error ? ` - ${task.error}` : ""}`;
81
205
  }
@@ -144,8 +268,22 @@ function applyPolicy(manifest: TeamRunManifest, tasks: TeamTaskState[], limits?:
144
268
  }
145
269
 
146
270
  export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ manifest: TeamRunManifest; tasks: TeamTaskState[] }> {
271
+ let workflow = input.workflow;
147
272
  let manifest = updateRunStatus(input.manifest, "running", input.executeWorkers ? "Executing team workflow." : "Creating workflow prompts and placeholder results.");
148
273
  let tasks = refreshTaskGraphQueues(input.tasks);
274
+ const initialAdaptive = injectAdaptivePlanIfReady({ manifest, tasks, workflow, team: input.team });
275
+ if (initialAdaptive.missingPlan) {
276
+ tasks = markBlocked(tasks, "Adaptive planner did not produce a valid subagent plan.");
277
+ saveRunTasks(manifest, tasks);
278
+ manifest = updateRunStatus(manifest, "blocked", "Adaptive planner did not produce a valid subagent plan.");
279
+ return { manifest, tasks };
280
+ }
281
+ if (initialAdaptive.injected) {
282
+ tasks = initialAdaptive.tasks;
283
+ workflow = initialAdaptive.workflow;
284
+ } else {
285
+ workflow = initialAdaptive.workflow;
286
+ }
149
287
  manifest = writeProgress(manifest, tasks, "team-runner");
150
288
  saveRunManifest(manifest);
151
289
  const runtimeKind = input.runtime?.kind ?? (input.executeWorkers ? "child-process" : "scaffold");
@@ -168,9 +306,25 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
168
306
  return { manifest, tasks };
169
307
  }
170
308
 
309
+ const injectedPlan = injectAdaptivePlanIfReady({ manifest, tasks, workflow, team: input.team });
310
+ if (injectedPlan.missingPlan) {
311
+ tasks = markBlocked(tasks, "Adaptive planner did not produce a valid subagent plan.");
312
+ saveRunTasks(manifest, tasks);
313
+ saveCrewAgents(manifest, recordsForMaterializedTasks(manifest, tasks, runtimeKind));
314
+ manifest = updateRunStatus(manifest, "blocked", "Adaptive planner did not produce a valid subagent plan.");
315
+ return { manifest, tasks };
316
+ }
317
+ if (injectedPlan.injected) {
318
+ tasks = injectedPlan.tasks;
319
+ workflow = injectedPlan.workflow;
320
+ saveRunTasks(manifest, tasks);
321
+ saveCrewAgents(manifest, recordsForMaterializedTasks(manifest, tasks, runtimeKind));
322
+ } else {
323
+ workflow = injectedPlan.workflow;
324
+ }
171
325
  const snapshot = taskGraphSnapshot(tasks);
172
326
  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 });
327
+ const concurrency = resolveBatchConcurrency({ workflowName: workflow.name, teamMaxConcurrency: input.team.maxConcurrency, limitMaxConcurrentWorkers: input.limits?.maxConcurrentWorkers, readyCount: snapshot.ready.length, workspaceMode: manifest.workspaceMode, readyRoles });
174
328
  const readyBatch = getReadyTasks(tasks, concurrency.selectedCount);
175
329
  if (readyBatch.length === 0) {
176
330
  tasks = markBlocked(tasks, "No ready queued task; dependency graph may be invalid.");
@@ -182,12 +336,26 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
182
336
 
183
337
  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 } });
184
338
  const results = await Promise.all(readyBatch.map((task) => {
185
- const step = findStep(input.workflow, task);
339
+ const step = findStep(workflow, task);
186
340
  const agent = findAgent(input.agents, task);
187
341
  return runTeamTask({ manifest, tasks, task, step, agent, signal: input.signal, executeWorkers: input.executeWorkers, runtimeKind: input.runtime?.kind, runtimeConfig: input.runtimeConfig, parentContext: input.parentContext, parentModel: input.parentModel, modelRegistry: input.modelRegistry, modelOverride: input.modelOverride, limits: input.limits });
188
342
  }));
189
343
  manifest = { ...results.at(-1)!.manifest, artifacts: mergeArtifacts([manifest.artifacts, ...results.map((item) => item.manifest.artifacts)].flat()) };
190
344
  tasks = mergeTaskUpdates(tasks, results);
345
+ const injectedAfterBatch = injectAdaptivePlanIfReady({ manifest, tasks, workflow, team: input.team });
346
+ if (injectedAfterBatch.missingPlan) {
347
+ tasks = markBlocked(tasks, "Adaptive planner did not produce a valid subagent plan.");
348
+ saveRunTasks(manifest, tasks);
349
+ saveCrewAgents(manifest, recordsForMaterializedTasks(manifest, tasks, runtimeKind));
350
+ manifest = updateRunStatus(manifest, "blocked", "Adaptive planner did not produce a valid subagent plan.");
351
+ return { manifest, tasks };
352
+ }
353
+ if (injectedAfterBatch.injected) {
354
+ tasks = injectedAfterBatch.tasks;
355
+ workflow = injectedAfterBatch.workflow;
356
+ } else {
357
+ workflow = injectedAfterBatch.workflow;
358
+ }
191
359
  saveRunTasks(manifest, tasks);
192
360
  saveCrewAgents(manifest, recordsForMaterializedTasks(manifest, tasks, runtimeKind));
193
361
  const completedBatch = readyBatch.map((task) => tasks.find((item) => item.id === task.id) ?? task);
@@ -55,6 +55,7 @@ export function createTasksFromWorkflow(runId: string, workflow: WorkflowConfig,
55
55
  status: "queued",
56
56
  dependsOn: dependencies,
57
57
  cwd,
58
+ model: step.model,
58
59
  graph: {
59
60
  taskId: id,
60
61
  parentId: dependencies[0] ? stepToTaskId.get(dependencies[0]) : undefined,
@@ -142,6 +142,7 @@ export interface TeamTaskState {
142
142
  startedAt?: string;
143
143
  finishedAt?: string;
144
144
  exitCode?: number | null;
145
+ model?: string;
145
146
  modelAttempts?: ModelAttemptState[];
146
147
  usage?: UsageState;
147
148
  jsonEvents?: number;
@@ -152,6 +153,10 @@ export interface TeamTaskState {
152
153
  taskPacket?: TaskPacket;
153
154
  verification?: VerificationEvidence;
154
155
  graph?: TaskGraphNode;
156
+ adaptive?: {
157
+ phase: string;
158
+ task: string;
159
+ };
155
160
  policy?: {
156
161
  retryCount?: number;
157
162
  lastDecision?: PolicyDecision;
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: implementation
3
- description: Full implementation team with analysis, critique, execution, review, and verification
3
+ description: Full implementation team with parallel specialists, critique, execution, review, and verification
4
4
  defaultWorkflow: implementation
5
5
  workspaceMode: single
6
6
  maxConcurrency: 3
@@ -9,7 +9,10 @@ maxConcurrency: 3
9
9
  - explorer: agent=explorer map the codebase
10
10
  - analyst: agent=analyst clarify requirements and constraints
11
11
  - planner: agent=planner create execution plan
12
- - critic: agent=critic challenge the plan
12
+ - critic: agent=critic challenge and synthesize specialist findings
13
13
  - executor: agent=executor implement the plan
14
14
  - reviewer: agent=reviewer review the implementation
15
+ - security-reviewer: agent=security-reviewer review security and trust boundaries
16
+ - test-engineer: agent=test-engineer design and run verification
15
17
  - verifier: agent=verifier verify done
18
+ - writer: agent=writer summarize documentation or release notes when needed
@@ -1,47 +1,38 @@
1
1
  ---
2
2
  name: implementation
3
- description: Full implementation workflow with critique and review gates
3
+ description: Adaptive implementation workflow where a planner agent decides the subagent fanout
4
4
  ---
5
5
 
6
- ## explore
7
- role: explorer
8
-
9
- Map relevant files, APIs, and constraints for: {goal}
10
-
11
- ## analyze
12
- role: analyst
13
- dependsOn: explore
14
-
15
- Analyze requirements, ambiguities, risks, and acceptance criteria for: {goal}
16
-
17
- ## plan
6
+ ## assess
18
7
  role: planner
19
- dependsOn: analyze
20
- output: plan.md
21
-
22
- Create an execution plan for: {goal}
23
-
24
- ## critique
25
- role: critic
26
- dependsOn: plan
27
-
28
- Critique the plan and identify required improvements.
29
-
30
- ## execute
31
- role: executor
32
- dependsOn: critique
33
-
34
- Implement the improved plan for: {goal}
35
-
36
- ## review
37
- role: reviewer
38
- dependsOn: execute
39
-
40
- Review the implementation.
41
-
42
- ## verify
43
- role: verifier
44
- dependsOn: review
45
- verify: true
46
-
47
- Verify the final result.
8
+ output: adaptive-plan.json
9
+
10
+ Assess this task and decide how many subagents are actually needed for: {goal}
11
+
12
+ You are the orchestration planner. Inspect the repository enough to choose an efficient crew; do not use a fixed template. Small/simple tasks may need one executor plus one verifier. Risky or broad tasks may need parallel explorers, specialists, implementers, reviewers, security reviewers, or test engineers.
13
+
14
+ Return a concise rationale, then include exactly one JSON block between these markers:
15
+
16
+ ADAPTIVE_PLAN_JSON_START
17
+ {
18
+ "phases": [
19
+ {
20
+ "name": "short-phase-name",
21
+ "tasks": [
22
+ {
23
+ "role": "explorer|analyst|planner|critic|executor|reviewer|security-reviewer|test-engineer|verifier|writer",
24
+ "title": "short task title",
25
+ "task": "specific autonomous task prompt for this subagent"
26
+ }
27
+ ]
28
+ }
29
+ ]
30
+ }
31
+ ADAPTIVE_PLAN_JSON_END
32
+
33
+ Rules:
34
+ - Choose the smallest effective number of subagents.
35
+ - Use parallel tasks in the same phase only when their work is independent.
36
+ - Later phases depend on all tasks in the previous phase.
37
+ - Include verification/review tasks when implementation is requested.
38
+ - Do not include more than 12 total subagents; split or summarize oversized plans instead.