pi-crew 0.1.20 → 0.1.22

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.
@@ -0,0 +1,83 @@
1
+ # pi-crew runtime refactor source map
2
+
3
+ This document records the source projects used as the baseline for the pi-crew subagent/runtime refactor. The goal is to avoid ad-hoc fixes in critical process orchestration paths and instead align pi-crew with proven Pi extension patterns.
4
+
5
+ ## Source/pi-subagents
6
+
7
+ Primary source for child-process worker execution.
8
+
9
+ - `pi-spawn.ts`: robust Pi CLI resolution on Windows and package installs.
10
+ - `async-execution.ts`: detached async runner with `windowsHide: true` to avoid blank console windows.
11
+ - `subagent-runner.ts`: streaming child Pi process runner, output capture, result extraction.
12
+ - `post-exit-stdio-guard.ts`: guards for child processes that exit before stdio fully closes.
13
+ - `result-watcher.ts` and `async-job-tracker.ts`: durable async job/result observation patterns.
14
+ - `model-fallback.ts`: model fallback policy independent of hardcoded provider assumptions.
15
+ - `subagent-control.ts`, `run-status.ts`: status and control semantics.
16
+
17
+ pi-crew alignment:
18
+
19
+ - Background runner and child worker spawn options now explicitly set `windowsHide: true`.
20
+ - Parallel research no longer gates all shard workers behind a single discover worker.
21
+ - Further work should consolidate `child-pi.ts`, `async-runner.ts`, and `subagent-manager.ts` into a durable-first subagent runtime module.
22
+
23
+ ## Source/pi-subagents2
24
+
25
+ Primary source for higher-level agent management and UI patterns.
26
+
27
+ - `src/agent-manager.ts`: agent lifecycle registry boundaries.
28
+ - `src/agent-runner.ts`: invocation/run abstraction separate from UI registration.
29
+ - `src/model-resolver.ts`: cleaner model resolution responsibility.
30
+ - `src/output-file.ts`: output file abstraction.
31
+ - `src/ui/agent-widget.ts`, `src/ui/conversation-viewer.ts`: compact live status and transcript viewing.
32
+
33
+ pi-crew alignment:
34
+
35
+ - Keep `Agent`/`crew_agent` tools as thin adapters over a durable manager.
36
+ - Avoid storing essential run mapping in memory only.
37
+ - Keep UI active-only and file-backed.
38
+
39
+ ## Source/pi-mono
40
+
41
+ Primary source for Pi extension API/lifecycle constraints.
42
+
43
+ - `packages/coding-agent/src/core/extensions/types.ts`: extension context/tool contracts.
44
+ - `packages/coding-agent/src/core/extensions/runner.ts`: extension execution boundaries.
45
+ - `packages/coding-agent/src/core/model-registry.ts`: available model discovery.
46
+ - `packages/coding-agent/src/modes/interactive/interactive-mode.ts`: session lifecycle/UI behavior.
47
+
48
+ pi-crew alignment:
49
+
50
+ - Treat session-bound foreground workers differently from explicit async background workers.
51
+ - Do not assume hardcoded providers/models.
52
+ - Use Pi-native UI calls without modal auto-open by default.
53
+
54
+ ## Source/pi-powerbar, pi-plan, pi-diff-review, pi-extensions*
55
+
56
+ Sources for UI and small-extension patterns.
57
+
58
+ - `pi-powerbar/src/powerbar/*`: low-noise status segment publishing.
59
+ - `pi-plan/src/plan-action-ui.ts`: action-oriented UI without persistent heavy overlays.
60
+ - `pi-diff-review/src/*`: command/tool registration and review UX patterns.
61
+ - `pi-extensions2/files-widget/*`: file-backed UI composition and navigation.
62
+
63
+ pi-crew alignment:
64
+
65
+ - Keep persistent widget active-only.
66
+ - Prefer manual dashboard/transcript commands for history.
67
+ - Avoid expensive render scans and auto-opening focus-capturing overlays.
68
+
69
+ ## Current refactor checkpoints
70
+
71
+ - [x] Hide Windows console windows for background runner and child Pi workers.
72
+ - [x] Make parallel research shard workers start in parallel instead of depending on a single discover worker.
73
+ - [x] Keep direct-agent reconstruction gated by `workflow === "direct-agent"` only.
74
+ - [x] Persist subagent records and recover terminal results after restart.
75
+ - [x] Fail fast for unrecoverable persisted records without `runId` instead of hanging.
76
+ - [x] Persist direct-agent model override into task state for background/resume reconstruction.
77
+
78
+ ## Remaining larger subsystem work
79
+
80
+ - Consolidate subagent runtime into `src/subagents/*` or equivalent durable-first module.
81
+ - Move model routing transparency into persisted task/subagent records: requested model, selected model, fallback chain, fallback reason.
82
+ - Add real integration smoke scripts for Windows process visibility, async restart recovery, and multi-shard fanout.
83
+ - Add adaptive planner repair/retry for invalid JSON instead of immediate block when safe.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -19,7 +19,7 @@ import { DurableTextViewer, DurableTranscriptViewer } from "../ui/transcript-vie
19
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 [
@@ -338,9 +350,9 @@ export function registerPiTeams(pi: ExtensionAPI): void {
338
350
  agent: spawnOptions.type,
339
351
  goal: spawnOptions.prompt,
340
352
  model: spawnOptions.model,
341
- async: false,
353
+ async: spawnOptions.background,
342
354
  config: spawnOptions.maxTurns ? { runtime: { maxTurns: spawnOptions.maxTurns } } : undefined,
343
- }, spawnOptions.background ? { ...ctx, signal: childSignal, startForegroundRun: (run, runId) => startForegroundRun(ctx, run, runId), onRunStarted: (runId) => openLiveSidebar(ctx, runId) } : { ...ctx, signal: childSignal });
355
+ }, spawnOptions.background ? { ...ctx, signal: childSignal } : { ...ctx, signal: childSignal });
344
356
  const record = subagentManager.spawn(options, runner, options.background ? undefined : signal);
345
357
  if (options.background || record.status === "queued") {
346
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 });
@@ -360,18 +372,40 @@ export function registerPiTeams(pi: ExtensionAPI): void {
360
372
  wait: Type.Optional(Type.Boolean({ description: "Wait for completion before returning." })),
361
373
  verbose: Type.Optional(Type.Boolean({ description: "Include status metadata before output." })),
362
374
  }) as never,
363
- async execute(_id, params, _signal, _onUpdate, ctx) {
375
+ async execute(_id, params, signal, _onUpdate, ctx) {
364
376
  const p = params as { agent_id?: string; wait?: boolean; verbose?: boolean };
365
377
  if (!p.agent_id) return subagentToolResult("get_subagent_result requires agent_id.", {}, true);
366
- 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);
367
380
  if (!record) return subagentToolResult(`Agent not found: ${p.agent_id}`, {}, true);
368
- 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
+ }
369
386
  if (p.wait && (current.status === "running" || current.status === "queued")) {
370
387
  current.resultConsumed = true;
371
- 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
+ }
372
403
  }
373
404
  const output = readSubagentRunResult(ctx, current);
374
- 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
+ }
375
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");
376
410
  return subagentToolResult(text, { agentId: current.id, runId: current.runId, status: current.status }, current.status === "failed" || current.status === "error");
377
411
  },
@@ -382,9 +416,9 @@ export function registerPiTeams(pi: ExtensionAPI): void {
382
416
  label: "Steer Agent",
383
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.",
384
418
  parameters: Type.Object({ agent_id: Type.String(), message: Type.String() }) as never,
385
- async execute(_id, params) {
419
+ async execute(_id, params, _signal, _onUpdate, ctx) {
386
420
  const p = params as { agent_id?: string; message?: string };
387
- 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;
388
422
  if (!record) return subagentToolResult(`Agent not found: ${p.agent_id ?? ""}`, {}, true);
389
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 });
390
424
  },
@@ -5,7 +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
+ import type { WorkflowConfig } from "../workflows/workflow-config.ts";
9
9
  import { effectiveAutonomousConfig, loadConfig, updateAutonomousConfig, updateConfig, type PiTeamsAutonomousConfig, type PiTeamsConfig } from "../config/config.ts";
10
10
  import { projectPiRoot, userPiRoot } from "../utils/paths.ts";
11
11
  import type { TeamToolParamsValue } from "../schema/team-tool-schema.ts";
@@ -46,6 +46,8 @@ 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";
50
+ import { expandParallelResearchWorkflow } from "../runtime/parallel-research.ts";
49
51
 
50
52
  export interface TeamToolDetails {
51
53
  action: string;
@@ -171,48 +173,6 @@ function commandExists(command: string, args: string[]): { ok: boolean; detail:
171
173
  return { ok: false, detail: output.error?.message ?? firstOutputLine(output.stdout, output.stderr) };
172
174
  }
173
175
 
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
-
216
176
  function effectiveRunConfig(base: PiTeamsConfig, rawOverride: unknown): PiTeamsConfig {
217
177
  const patch = configPatchFromConfig(rawOverride);
218
178
  return {
@@ -513,9 +473,11 @@ export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext
513
473
  const loaded = loadRunManifestById(ctx.cwd, params.runId);
514
474
  if (!loaded) return result(`Run '${params.runId}' not found.`, { action: "resume", status: "error" }, true);
515
475
  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);
476
+ const agents = allAgents(discoverAgents(ctx.cwd));
477
+ const direct = directTeamAndWorkflowFromRun(loaded.manifest, loaded.tasks, agents);
478
+ const team = direct?.team ?? allTeams(discoverTeams(ctx.cwd)).find((candidate) => candidate.name === loaded.manifest.team);
517
479
  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);
480
+ const workflow = direct?.workflow ?? allWorkflows(discoverWorkflows(ctx.cwd)).find((candidate) => candidate.name === loaded.manifest.workflow);
519
481
  if (!workflow) return result(`Workflow '${loaded.manifest.workflow}' not found.`, { action: "resume", status: "error" }, true);
520
482
  return await withRunLock(loaded.manifest, async () => {
521
483
  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 +486,7 @@ export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext
524
486
  const loadedConfig = loadConfig(ctx.cwd);
525
487
  const runtime = await resolveCrewRuntime(loadedConfig.config);
526
488
  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 });
489
+ 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
490
  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
491
  });
530
492
  }
@@ -1,4 +1,4 @@
1
- import { spawn } from "node:child_process";
1
+ import { spawn, type SpawnOptions } from "node:child_process";
2
2
  import { createRequire } from "node:module";
3
3
  import * as fs from "node:fs";
4
4
  import * as path from "node:path";
@@ -36,6 +36,16 @@ export interface SpawnBackgroundTeamRunResult {
36
36
  logPath: string;
37
37
  }
38
38
 
39
+ export function buildBackgroundSpawnOptions(manifest: TeamRunManifest, logFd: number): SpawnOptions {
40
+ return {
41
+ cwd: manifest.cwd,
42
+ detached: true,
43
+ stdio: ["ignore", logFd, logFd],
44
+ env: { ...process.env },
45
+ windowsHide: true,
46
+ };
47
+ }
48
+
39
49
  export function spawnBackgroundTeamRun(manifest: TeamRunManifest): SpawnBackgroundTeamRunResult {
40
50
  const runnerPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "background-runner.ts");
41
51
  const logPath = path.join(manifest.stateRoot, "background.log");
@@ -43,12 +53,7 @@ export function spawnBackgroundTeamRun(manifest: TeamRunManifest): SpawnBackgrou
43
53
  const logFd = fs.openSync(logPath, "a");
44
54
  const command = getBackgroundRunnerCommand(runnerPath, manifest.cwd, manifest.runId);
45
55
  fs.appendFileSync(logPath, `[pi-crew] background loader=${command.loader}\n`, "utf-8");
46
- const child = spawn(process.execPath, command.args, {
47
- cwd: manifest.cwd,
48
- detached: true,
49
- stdio: ["ignore", logFd, logFd],
50
- env: { ...process.env },
51
- });
56
+ const child = spawn(process.execPath, command.args, buildBackgroundSpawnOptions(manifest, logFd));
52
57
  child.unref();
53
58
  fs.closeSync(logFd);
54
59
  return { pid: child.pid, logPath };
@@ -6,6 +6,8 @@ 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";
10
+ import { expandParallelResearchWorkflow } from "./parallel-research.ts";
9
11
 
10
12
  function argValue(name: string): string | undefined {
11
13
  const index = process.argv.indexOf(name);
@@ -24,14 +26,16 @@ async function main(): Promise<void> {
24
26
  appendEvent(manifest.eventsPath, { type: "async.started", runId: manifest.runId, data: { pid: process.pid } });
25
27
 
26
28
  try {
27
- const team = allTeams(discoverTeams(cwd)).find((candidate) => candidate.name === manifest.team);
28
- if (!team) throw new Error(`Team '${manifest.team}' not found.`);
29
- const workflow = allWorkflows(discoverWorkflows(cwd)).find((candidate) => candidate.name === manifest.workflow);
30
- if (!workflow) throw new Error(`Workflow '${manifest.workflow ?? ""}' not found.`);
31
29
  const agents = allAgents(discoverAgents(cwd));
30
+ const direct = directTeamAndWorkflowFromRun(manifest, tasks, agents);
31
+ const team = direct?.team ?? allTeams(discoverTeams(cwd)).find((candidate) => candidate.name === manifest.team);
32
+ if (!team) throw new Error(`Team '${manifest.team}' not found.`);
33
+ const baseWorkflow = direct?.workflow ?? allWorkflows(discoverWorkflows(cwd)).find((candidate) => candidate.name === manifest.workflow);
34
+ if (!baseWorkflow) throw new Error(`Workflow '${manifest.workflow ?? ""}' not found.`);
35
+ const workflow = expandParallelResearchWorkflow(baseWorkflow, cwd);
32
36
  const loadedConfig = loadConfig(cwd);
33
37
  const runtime = await resolveCrewRuntime(loadedConfig.config);
34
- const executeWorkers = runtime.kind === "child-process";
38
+ const executeWorkers = runtime.kind !== "scaffold";
35
39
  const result = await executeTeamRun({ manifest, tasks, team, workflow, agents, executeWorkers, limits: loadedConfig.config.limits, runtime, runtimeConfig: loadedConfig.config.runtime });
36
40
  manifest = result.manifest;
37
41
  tasks = result.tasks;
@@ -1,4 +1,4 @@
1
- import { spawn, type ChildProcess } from "node:child_process";
1
+ import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import type { AgentConfig } from "../agents/agent-config.ts";
@@ -66,6 +66,16 @@ export interface ChildPiRunResult {
66
66
  error?: string;
67
67
  }
68
68
 
69
+ export function buildChildPiSpawnOptions(cwd: string, env: NodeJS.ProcessEnv): SpawnOptions {
70
+ return {
71
+ cwd,
72
+ env,
73
+ stdio: ["ignore", "pipe", "pipe"],
74
+ detached: process.platform !== "win32",
75
+ windowsHide: true,
76
+ };
77
+ }
78
+
69
79
  function appendTranscript(input: ChildPiRunInput, line: string): void {
70
80
  if (!input.transcriptPath) return;
71
81
  fs.mkdirSync(path.dirname(input.transcriptPath), { recursive: true });
@@ -225,12 +235,7 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
225
235
  const spawnSpec = getPiSpawnCommand(built.args);
226
236
  try {
227
237
  return await new Promise<ChildPiRunResult>((resolve) => {
228
- const child = spawn(spawnSpec.command, spawnSpec.args, {
229
- cwd: input.cwd,
230
- env: { ...process.env, ...built.env },
231
- stdio: ["ignore", "pipe", "pipe"],
232
- detached: process.platform !== "win32",
233
- });
238
+ const child = spawn(spawnSpec.command, spawnSpec.args, buildChildPiSpawnOptions(input.cwd, { ...process.env, ...built.env }));
234
239
  if (child.pid) activeChildProcesses.set(child.pid, child);
235
240
  let stdout = "";
236
241
  let stderr = "";
@@ -15,7 +15,7 @@ export interface BatchConcurrencyDecision {
15
15
  }
16
16
 
17
17
  export function defaultWorkflowConcurrency(workflowName: string): number {
18
- if (workflowName === "parallel-research") return 4;
18
+ if (workflowName === "parallel-research") return 6;
19
19
  if (workflowName === "research") return 2;
20
20
  if (workflowName === "implementation" || workflowName === "review" || workflowName === "default") return 2;
21
21
  return 1;
@@ -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
+ }
@@ -0,0 +1,44 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { WorkflowConfig, WorkflowStep } from "../workflows/workflow-config.ts";
4
+
5
+ export function sourcePiProjects(cwd: string): string[] {
6
+ const sourceDir = path.join(cwd, "Source");
7
+ try {
8
+ return fs.readdirSync(sourceDir, { withFileTypes: true })
9
+ .filter((entry) => entry.isDirectory() && entry.name.startsWith("pi-"))
10
+ .map((entry) => `Source/${entry.name}`)
11
+ .sort();
12
+ } catch {
13
+ return [];
14
+ }
15
+ }
16
+
17
+ export function chunkProjects(projects: string[], target = 6): string[][] {
18
+ const chunks = Array.from({ length: Math.min(Math.max(1, target), Math.max(1, projects.length)) }, () => [] as string[]);
19
+ projects.forEach((project, index) => chunks[index % chunks.length]!.push(project));
20
+ return chunks.filter((chunk) => chunk.length > 0);
21
+ }
22
+
23
+ export function expandParallelResearchWorkflow(workflow: WorkflowConfig, cwd: string): WorkflowConfig {
24
+ if (workflow.name !== "parallel-research") return workflow;
25
+ const projects = sourcePiProjects(cwd);
26
+ if (projects.length === 0) return workflow;
27
+ const chunks = chunkProjects(projects, Math.min(8, Math.max(4, Math.ceil(projects.length / 3))));
28
+ const exploreSteps: WorkflowStep[] = chunks.map((paths, index) => ({
29
+ id: `explore-shard-${index + 1}`,
30
+ role: "explorer",
31
+ parallelGroup: "explore",
32
+ reads: paths,
33
+ 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"),
34
+ }));
35
+ return {
36
+ ...workflow,
37
+ steps: [
38
+ { id: "discover", role: "explorer", parallelGroup: "inventory", task: `Quickly inventory and validate ${projects.length} pi-* projects for: {goal}\n\nProjects:\n${projects.map((item) => `- ${item}`).join("\n")}\n\nDo not block shard work; summarize routing notes only.` },
39
+ ...exploreSteps,
40
+ { id: "synthesize", role: "analyst", dependsOn: exploreSteps.map((step) => step.id), task: "Synthesize all dynamic shard findings. Identify common patterns, gaps, and concrete recommendations. Use discover output if available, but prioritize completed shard outputs." },
41
+ { id: "write", role: "writer", dependsOn: ["synthesize"], output: "research-summary.md", task: "Write a concise final summary with evidence, risks, and actionable next steps." },
42
+ ],
43
+ };
44
+ }
@@ -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));
@@ -63,15 +63,25 @@ function mergeArtifacts(items: ArtifactDescriptor[]): ArtifactDescriptor[] {
63
63
  return [...byPath.values()];
64
64
  }
65
65
 
66
- function mergeTaskUpdates(base: TeamTaskState[], results: Array<{ tasks: TeamTaskState[] }>): TeamTaskState[] {
66
+ function isNonTerminalTaskStatus(status: TeamTaskState["status"]): boolean {
67
+ return status === "queued" || status === "running";
68
+ }
69
+
70
+ function shouldMergeTaskUpdate(current: TeamTaskState, updated: TeamTaskState): boolean {
71
+ // Parallel workers receive the same input snapshot. A later result may still
72
+ // contain stale queued/running copies of tasks that another worker already
73
+ // completed. Never let those stale snapshots regress durable task state.
74
+ if (!isNonTerminalTaskStatus(current.status) && isNonTerminalTaskStatus(updated.status)) return false;
75
+ return updated.status !== current.status || updated.finishedAt !== current.finishedAt || updated.startedAt !== current.startedAt || Boolean(updated.resultArtifact) || Boolean(updated.error) || Boolean(updated.modelAttempts?.length) || Boolean(updated.usage);
76
+ }
77
+
78
+ export function __test__mergeTaskUpdates(base: TeamTaskState[], results: Array<{ tasks: TeamTaskState[] }>): TeamTaskState[] {
67
79
  let merged = base;
68
80
  for (const result of results) {
69
81
  for (const updated of result.tasks) {
70
82
  const current = merged.find((task) => task.id === updated.id);
71
- if (!current) continue;
72
- if (updated.status !== current.status || updated.finishedAt || updated.startedAt || updated.resultArtifact || updated.error) {
73
- merged = merged.map((task) => task.id === updated.id ? updated : task);
74
- }
83
+ if (!current || !shouldMergeTaskUpdate(current, updated)) continue;
84
+ merged = merged.map((task) => task.id === updated.id ? updated : task);
75
85
  }
76
86
  }
77
87
  return refreshTaskGraphQueues(merged);
@@ -341,7 +351,7 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
341
351
  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 });
342
352
  }));
343
353
  manifest = { ...results.at(-1)!.manifest, artifacts: mergeArtifacts([manifest.artifacts, ...results.map((item) => item.manifest.artifacts)].flat()) };
344
- tasks = mergeTaskUpdates(tasks, results);
354
+ tasks = __test__mergeTaskUpdates(tasks, results);
345
355
  const injectedAfterBatch = injectAdaptivePlanIfReady({ manifest, tasks, workflow, team: input.team });
346
356
  if (injectedAfterBatch.missingPlan) {
347
357
  tasks = markBlocked(tasks, "Adaptive planner did not produce a valid subagent plan.");
@@ -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;
@@ -3,7 +3,7 @@ name: parallel-research
3
3
  description: Parallel research team for multi-project/source audits
4
4
  workspaceMode: single
5
5
  defaultWorkflow: parallel-research
6
- maxConcurrency: 4
6
+ maxConcurrency: 6
7
7
  triggers: đọc sâu, deep read, deep research, source audit, multiple projects, parallel research, pi-*
8
8
  category: research
9
9
  cost: cheap
@@ -35,4 +35,4 @@ Rules:
35
35
  - Use parallel tasks in the same phase only when their work is independent.
36
36
  - Later phases depend on all tasks in the previous phase.
37
37
  - Include verification/review tasks when implementation is requested.
38
- - Do not include more than 12 total subagents unless the user explicitly asks for a large crew.
38
+ - Do not include more than 12 total subagents; split or summarize oversized plans instead.
@@ -10,28 +10,24 @@ Discover the relevant files/projects for: {goal}. Return a shard plan with paths
10
10
 
11
11
  ## explore-core
12
12
  role: explorer
13
- dependsOn: discover
14
13
  parallelGroup: explore
15
14
 
16
15
  Explore the core/runtime shard from the discover output. Focus on architecture, package config, docs, and reusable patterns for: {goal}
17
16
 
18
17
  ## explore-ui
19
18
  role: explorer
20
- dependsOn: discover
21
19
  parallelGroup: explore
22
20
 
23
21
  Explore the UI/TUI/extension-interface shard from the discover output. Focus on widgets, overlays, commands, status bars, package config, docs, and reusable patterns for: {goal}
24
22
 
25
23
  ## explore-runtime
26
24
  role: explorer
27
- dependsOn: discover
28
25
  parallelGroup: explore
29
26
 
30
27
  Explore the worker/runtime/subagent/runtime-control shard from the discover output. Focus on process/session/runtime orchestration, event streams, logs, package config, docs, and reusable patterns for: {goal}
31
28
 
32
29
  ## explore-extensions
33
30
  role: explorer
34
- dependsOn: discover
35
31
  parallelGroup: explore
36
32
 
37
33
  Explore the extension bundle/small-package shard from the discover output. Focus on package config, extension registration, commands/tools, docs, and reusable patterns for: {goal}
@@ -40,7 +36,7 @@ Explore the extension bundle/small-package shard from the discover output. Focus
40
36
  role: analyst
41
37
  dependsOn: explore-core, explore-ui, explore-runtime, explore-extensions
42
38
 
43
- Synthesize all shard findings. Identify common patterns, gaps, and concrete recommendations.
39
+ Synthesize all shard findings. Use discover output if available, but do not require it. Identify common patterns, gaps, and concrete recommendations.
44
40
 
45
41
  ## write
46
42
  role: writer