pi-crew 0.1.6 → 0.1.8

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.
@@ -7,12 +7,14 @@ import type { AgentConfig } from "../agents/agent-config.ts";
7
7
  const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"];
8
8
  const PROMPT_RUNTIME_EXTENSION_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "prompt", "prompt-runtime.ts");
9
9
  const TASK_ARG_LIMIT = 8000;
10
+ const DEFAULT_MAX_CREW_DEPTH = 2;
10
11
 
11
12
  export interface BuildPiWorkerArgsInput {
12
13
  task: string;
13
14
  agent: AgentConfig;
14
15
  model?: string;
15
16
  sessionEnabled?: boolean;
17
+ maxDepth?: number;
16
18
  }
17
19
 
18
20
  export interface BuildPiWorkerArgsResult {
@@ -28,6 +30,25 @@ export function applyThinkingSuffix(model: string | undefined, thinking: string
28
30
  return `${model}:${thinking}`;
29
31
  }
30
32
 
33
+ export function currentCrewDepth(env: NodeJS.ProcessEnv = process.env): number {
34
+ const raw = env.PI_CREW_DEPTH ?? env.PI_TEAMS_DEPTH ?? "0";
35
+ const parsed = Number(raw);
36
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0;
37
+ }
38
+
39
+ export function resolveCrewMaxDepth(inputMaxDepth?: number, env: NodeJS.ProcessEnv = process.env): number {
40
+ const raw = env.PI_CREW_MAX_DEPTH ?? env.PI_TEAMS_MAX_DEPTH;
41
+ const envDepth = raw !== undefined ? Number(raw) : NaN;
42
+ if (Number.isInteger(envDepth) && envDepth >= 0) return envDepth;
43
+ return Number.isInteger(inputMaxDepth) && inputMaxDepth !== undefined && inputMaxDepth >= 0 ? inputMaxDepth : DEFAULT_MAX_CREW_DEPTH;
44
+ }
45
+
46
+ export function checkCrewDepth(inputMaxDepth?: number, env: NodeJS.ProcessEnv = process.env): { blocked: boolean; depth: number; maxDepth: number } {
47
+ const depth = currentCrewDepth(env);
48
+ const maxDepth = resolveCrewMaxDepth(inputMaxDepth, env);
49
+ return { depth, maxDepth, blocked: depth >= maxDepth };
50
+ }
51
+
31
52
  export function buildPiWorkerArgs(input: BuildPiWorkerArgsInput): BuildPiWorkerArgsResult {
32
53
  const args = ["--mode", "json", "-p"];
33
54
  if (input.sessionEnabled === false) args.push("--no-session");
@@ -61,11 +82,19 @@ export function buildPiWorkerArgs(input: BuildPiWorkerArgsInput): BuildPiWorkerA
61
82
  args.push(`Task: ${input.task}`);
62
83
  }
63
84
 
85
+ const parentDepth = currentCrewDepth();
86
+ const maxDepth = resolveCrewMaxDepth(input.maxDepth);
64
87
  return {
65
88
  args,
66
89
  env: {
90
+ PI_CREW_INHERIT_PROJECT_CONTEXT: input.agent.inheritProjectContext ? "1" : "0",
91
+ PI_CREW_INHERIT_SKILLS: input.agent.inheritSkills ? "1" : "0",
92
+ PI_CREW_DEPTH: String(parentDepth + 1),
93
+ PI_CREW_MAX_DEPTH: String(maxDepth),
67
94
  PI_TEAMS_INHERIT_PROJECT_CONTEXT: input.agent.inheritProjectContext ? "1" : "0",
68
95
  PI_TEAMS_INHERIT_SKILLS: input.agent.inheritSkills ? "1" : "0",
96
+ PI_TEAMS_DEPTH: String(parentDepth + 1),
97
+ PI_TEAMS_MAX_DEPTH: String(maxDepth),
69
98
  },
70
99
  tempDir,
71
100
  };
@@ -48,7 +48,7 @@ export async function isLiveSessionRuntimeAvailable(timeoutMs = 1500, env: NodeJ
48
48
 
49
49
  export async function resolveCrewRuntime(config: PiTeamsConfig, env: NodeJS.ProcessEnv = process.env): Promise<CrewRuntimeCapabilities> {
50
50
  const requestedMode = config.runtime?.mode ?? "auto";
51
- const executeWorkers = config.executeWorkers === true || env.PI_TEAMS_EXECUTE_WORKERS === "1";
51
+ const executeWorkers = config.executeWorkers === true || env.PI_CREW_EXECUTE_WORKERS === "1" || env.PI_TEAMS_EXECUTE_WORKERS === "1";
52
52
  if (requestedMode === "scaffold") return scaffoldCaps(requestedMode);
53
53
  if (requestedMode === "child-process") return childCaps(requestedMode, executeWorkers ? undefined : "child-process requested but executeWorkers is not enabled; caller should refuse or fall back explicitly.");
54
54
  if (requestedMode === "live-session" || (requestedMode === "auto" && config.runtime?.preferLiveSession === true)) {
@@ -0,0 +1,28 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+
4
+ export interface SidechainEntry {
5
+ isSidechain: true;
6
+ agentId: string;
7
+ type: string;
8
+ message: unknown;
9
+ timestamp: string;
10
+ cwd: string;
11
+ }
12
+
13
+ export function writeSidechainEntry(filePath: string, entry: Omit<SidechainEntry, "isSidechain" | "timestamp">): void {
14
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
15
+ fs.appendFileSync(filePath, `${JSON.stringify({ isSidechain: true, timestamp: new Date().toISOString(), ...entry })}\n`, "utf-8");
16
+ }
17
+
18
+ export function sidechainOutputPath(stateRoot: string, taskId: string): string {
19
+ return path.join(stateRoot, "agents", taskId, "sidechain.output.jsonl");
20
+ }
21
+
22
+ export function eventToSidechainType(event: unknown): string | undefined {
23
+ if (!event || typeof event !== "object" || Array.isArray(event)) return undefined;
24
+ const type = (event as { type?: unknown }).type;
25
+ if (type === "message_start" || type === "message_update" || type === "message_end") return "message";
26
+ if (type === "tool_execution_start" || type === "tool_execution_update" || type === "tool_execution_end") return "tool";
27
+ return typeof type === "string" ? type : undefined;
28
+ }
@@ -1,5 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import type { AgentConfig } from "../agents/agent-config.ts";
3
+ import type { CrewLimitsConfig, CrewRuntimeConfig } from "../config/config.ts";
3
4
  import type { ArtifactDescriptor, TeamRunManifest, TeamTaskState, UsageState } from "../state/types.ts";
4
5
  import { writeArtifact } from "../state/artifact-store.ts";
5
6
  import { appendEvent } from "../state/event-log.ts";
@@ -7,7 +8,7 @@ import { saveRunManifest, saveRunTasks } from "../state/state-store.ts";
7
8
  import { createTaskClaim } from "../state/task-claims.ts";
8
9
  import { createWorkerHeartbeat, touchWorkerHeartbeat } from "./worker-heartbeat.ts";
9
10
  import type { WorkflowStep } from "../workflows/workflow-config.ts";
10
- import { captureWorktreeDiff, prepareTaskWorkspace } from "../worktree/worktree-manager.ts";
11
+ import { captureWorktreeDiff, captureWorktreeDiffStat, prepareTaskWorkspace } from "../worktree/worktree-manager.ts";
11
12
  import { buildModelCandidates, formatModelAttemptNote, isRetryableModelFailure, type ModelAttemptSummary } from "./model-fallback.ts";
12
13
  import { parsePiJsonOutput, type ParsedPiJsonOutput } from "./pi-json-output.ts";
13
14
  import { runChildPi } from "./child-pi.ts";
@@ -18,7 +19,9 @@ import { permissionForRole } from "./role-permission.ts";
18
19
  import { collectDependencyOutputContext, renderDependencyOutputContext, writeTaskInputsArtifact, writeTaskSharedOutput } from "./task-output-context.ts";
19
20
  import { appendCrewAgentEvent, appendCrewAgentOutput, emptyCrewAgentProgress, recordFromTask, upsertCrewAgent } from "./crew-agent-records.ts";
20
21
  import { parseSessionUsage } from "./session-usage.ts";
21
- import type { CrewAgentProgress } from "./crew-agent-runtime.ts";
22
+ import type { CrewAgentProgress, CrewRuntimeKind } from "./crew-agent-runtime.ts";
23
+ import { buildMemoryBlock } from "./agent-memory.ts";
24
+ import { runLiveSessionTask } from "./live-session-runtime.ts";
22
25
 
23
26
  export interface TaskRunnerInput {
24
27
  manifest: TeamRunManifest;
@@ -28,6 +31,12 @@ export interface TaskRunnerInput {
28
31
  agent: AgentConfig;
29
32
  signal?: AbortSignal;
30
33
  executeWorkers: boolean;
34
+ runtimeKind?: CrewRuntimeKind;
35
+ runtimeConfig?: CrewRuntimeConfig;
36
+ parentContext?: string;
37
+ parentModel?: unknown;
38
+ modelRegistry?: unknown;
39
+ limits?: CrewLimitsConfig;
31
40
  dependencyContextText?: string;
32
41
  }
33
42
 
@@ -55,7 +64,8 @@ function coordinationBridgeInstructions(task: TeamTaskState): string {
55
64
  ].join("\n");
56
65
  }
57
66
 
58
- function renderTaskPrompt(manifest: TeamRunManifest, step: WorkflowStep, task: TeamTaskState): string {
67
+ function renderTaskPrompt(manifest: TeamRunManifest, step: WorkflowStep, task: TeamTaskState, agent?: AgentConfig): string {
68
+ const memoryBlock = agent?.memory ? buildMemoryBlock(agent.name, agent.memory, task.cwd, Boolean(agent.tools?.some((tool) => tool === "write" || tool === "edit"))) : "";
59
69
  return [
60
70
  "# pi-crew Worker Runtime Context",
61
71
  `Run ID: ${manifest.runId}`,
@@ -86,6 +96,7 @@ function renderTaskPrompt(manifest: TeamRunManifest, step: WorkflowStep, task: T
86
96
  task.taskPacket ? renderTaskPacket(task.taskPacket) : "",
87
97
  "",
88
98
  (inputDependencyContext(task) || ""),
99
+ memoryBlock,
89
100
  "Task:",
90
101
  step.task.replaceAll("{goal}", manifest.goal),
91
102
  ].join("\n");
@@ -190,6 +201,9 @@ function applyAgentProgressEvent(progress: CrewAgentProgress, event: unknown, st
190
201
  next.currentToolArgs = undefined;
191
202
  next.currentToolStartedAt = undefined;
192
203
  }
204
+ if ((obj?.type === "tool_execution_error" || obj?.type === "tool_execution_failed") && next.currentTool) {
205
+ next.failedTool = next.currentTool;
206
+ }
193
207
  const usage = eventUsage(event);
194
208
  if (usage) {
195
209
  next.tokens = (usage.input ?? 0) + (usage.output ?? 0);
@@ -222,12 +236,13 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
222
236
  ...(dependencyContextText ? { dependencyContextText } : {}),
223
237
  } as TeamTaskState;
224
238
  let tasks = updateTask(input.tasks, task);
239
+ const runtimeKind = input.runtimeKind ?? (input.executeWorkers ? "child-process" : "scaffold");
225
240
  saveRunTasks(manifest, tasks);
226
- upsertCrewAgent(manifest, recordFromTask(manifest, task, input.executeWorkers ? "child-process" : "scaffold"));
227
- appendEvent(manifest.eventsPath, { type: "task.started", runId: manifest.runId, taskId: task.id, data: { role: task.role, agent: task.agent, cwd: task.cwd, worktreePath: workspace.worktreePath, worktreeBranch: workspace.branch, worktreeReused: workspace.reused } });
241
+ upsertCrewAgent(manifest, recordFromTask(manifest, task, runtimeKind));
242
+ appendEvent(manifest.eventsPath, { type: "task.started", runId: manifest.runId, taskId: task.id, data: { role: task.role, agent: task.agent, runtime: runtimeKind, cwd: task.cwd, worktreePath: workspace.worktreePath, worktreeBranch: workspace.branch, worktreeReused: workspace.reused } });
228
243
  const permissionMode = permissionForRole(task.role);
229
244
 
230
- const prompt = renderTaskPrompt(manifest, input.step, task);
245
+ const prompt = renderTaskPrompt(manifest, input.step, task, input.agent);
231
246
  const promptArtifact = writeArtifact(manifest.artifactsRoot, {
232
247
  kind: "prompt",
233
248
  relativePath: `prompts/${task.id}.md`,
@@ -243,7 +258,7 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
243
258
  let modelAttempts: ModelAttemptSummary[] | undefined;
244
259
  let parsedOutput: ParsedPiJsonOutput | undefined;
245
260
 
246
- let startupEvidence = createStartupEvidence({ command: input.executeWorkers ? "pi" : "safe-scaffold", startedAt: new Date(task.startedAt ?? new Date().toISOString()), finishedAt: new Date(), promptSentAt: new Date(task.startedAt ?? new Date().toISOString()), promptAccepted: true, exitCode: 0 });
261
+ let startupEvidence = createStartupEvidence({ command: runtimeKind === "child-process" ? "pi" : runtimeKind === "live-session" ? "live-session" : "safe-scaffold", startedAt: new Date(task.startedAt ?? new Date().toISOString()), finishedAt: new Date(), promptSentAt: new Date(task.startedAt ?? new Date().toISOString()), promptAccepted: true, exitCode: 0 });
247
262
  const inputsArtifact = writeTaskInputsArtifact(manifest, task, dependencyContext);
248
263
  const coordinationArtifact = writeArtifact(manifest.artifactsRoot, {
249
264
  kind: "metadata",
@@ -251,7 +266,7 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
251
266
  content: `${coordinationBridgeInstructions(task)}\n`,
252
267
  producer: task.id,
253
268
  });
254
- if (input.executeWorkers) {
269
+ if (runtimeKind === "child-process") {
255
270
  const candidates = buildModelCandidates(input.step.model ?? input.agent.model, input.agent.fallbackModels, undefined);
256
271
  const attemptModels = candidates.length > 0 ? candidates : [input.step.model ?? input.agent.model];
257
272
  const logs: string[] = [];
@@ -269,6 +284,7 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
269
284
  model,
270
285
  signal: input.signal,
271
286
  transcriptPath,
287
+ maxDepth: input.limits?.maxTaskDepth,
272
288
  onStdoutLine: (line) => appendCrewAgentOutput(manifest, task.id, line),
273
289
  onJsonEvent: (event) => {
274
290
  appendCrewAgentEvent(manifest, task.id, event);
@@ -320,6 +336,55 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
320
336
  producer: task.id,
321
337
  });
322
338
  }
339
+ } else if (runtimeKind === "live-session") {
340
+ const transcriptPath = `${manifest.artifactsRoot}/transcripts/${task.id}.jsonl`;
341
+ const attemptStartedAt = new Date();
342
+ const liveResult = await runLiveSessionTask({
343
+ manifest,
344
+ task,
345
+ step: input.step,
346
+ agent: input.agent,
347
+ prompt,
348
+ signal: input.signal,
349
+ transcriptPath,
350
+ runtimeConfig: input.runtimeConfig,
351
+ parentContext: input.parentContext,
352
+ parentModel: input.parentModel,
353
+ modelRegistry: input.modelRegistry,
354
+ onOutput: (text) => appendCrewAgentOutput(manifest, task.id, text),
355
+ onEvent: (event) => {
356
+ appendCrewAgentEvent(manifest, task.id, event);
357
+ task = { ...task, agentProgress: applyAgentProgressEvent(task.agentProgress ?? emptyCrewAgentProgress(), event, task.startedAt) };
358
+ tasks = updateTask(tasks, task);
359
+ upsertCrewAgent(manifest, recordFromTask(manifest, task, "live-session"));
360
+ appendEvent(manifest.eventsPath, { type: "task.progress", runId: manifest.runId, taskId: task.id, data: { event } });
361
+ },
362
+ });
363
+ startupEvidence = createStartupEvidence({ command: "live-session", startedAt: attemptStartedAt, finishedAt: new Date(), promptSentAt: attemptStartedAt, promptAccepted: liveResult.exitCode === 0 && !liveResult.error, stderr: liveResult.stderr, error: liveResult.error, exitCode: liveResult.exitCode });
364
+ exitCode = liveResult.exitCode;
365
+ error = liveResult.error || (liveResult.exitCode && liveResult.exitCode !== 0 ? liveResult.stderr || `Live session exited with ${liveResult.exitCode}` : undefined);
366
+ parsedOutput = { finalText: liveResult.stdout, textEvents: liveResult.stdout ? [liveResult.stdout] : [], jsonEvents: liveResult.jsonEvents, usage: liveResult.usage };
367
+ if (liveResult.usage) task = { ...task, usage: liveResult.usage, agentProgress: applyUsageToProgress(task.agentProgress, liveResult.usage) };
368
+ resultArtifact = writeArtifact(manifest.artifactsRoot, {
369
+ kind: "result",
370
+ relativePath: `results/${task.id}.txt`,
371
+ content: liveResult.stdout || liveResult.stderr || "(no output)",
372
+ producer: task.id,
373
+ });
374
+ logArtifact = writeArtifact(manifest.artifactsRoot, {
375
+ kind: "log",
376
+ relativePath: `logs/${task.id}.log`,
377
+ content: [`runtime=live-session`, `finalExitCode=${exitCode ?? "null"}`, `jsonEvents=${liveResult.jsonEvents}`, liveResult.usage ? `usage=${JSON.stringify(liveResult.usage)}` : "", "", "STDOUT:", liveResult.stdout, "", "STDERR:", liveResult.stderr].join("\n"),
378
+ producer: task.id,
379
+ });
380
+ if (fs.existsSync(transcriptPath)) {
381
+ transcriptArtifact = writeArtifact(manifest.artifactsRoot, {
382
+ kind: "log",
383
+ relativePath: `transcripts/${task.id}.jsonl`,
384
+ content: fs.readFileSync(transcriptPath, "utf-8"),
385
+ producer: task.id,
386
+ });
387
+ }
323
388
  } else {
324
389
  resultArtifact = writeArtifact(manifest.artifactsRoot, {
325
390
  kind: "result",
@@ -340,6 +405,12 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
340
405
  content: captureWorktreeDiff(workspace.worktreePath),
341
406
  producer: task.id,
342
407
  }) : undefined;
408
+ const diffStatArtifact = workspace.worktreePath ? writeArtifact(manifest.artifactsRoot, {
409
+ kind: "metadata",
410
+ relativePath: `metadata/${task.id}.diff-stat.json`,
411
+ content: `${JSON.stringify({ ...captureWorktreeDiffStat(workspace.worktreePath), syntheticPaths: workspace.syntheticPaths ?? [], nodeModulesLinked: workspace.nodeModulesLinked ?? false }, null, 2)}\n`,
412
+ producer: task.id,
413
+ }) : undefined;
343
414
 
344
415
  task = {
345
416
  ...task,
@@ -349,9 +420,9 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
349
420
  modelAttempts,
350
421
  usage: parsedOutput?.usage,
351
422
  jsonEvents: parsedOutput?.jsonEvents,
352
- agentProgress: task.agentProgress,
423
+ agentProgress: error && task.agentProgress?.currentTool ? { ...task.agentProgress, failedTool: task.agentProgress.currentTool } : task.agentProgress,
353
424
  error,
354
- verification: createVerificationEvidence(taskPacket.verification, !error, error ? `Task failed: ${error}` : input.executeWorkers ? "Worker finished without reporting a verification failure." : "Safe scaffold mode; verification commands were not executed."),
425
+ verification: createVerificationEvidence(taskPacket.verification, !error, error ? `Task failed: ${error}` : runtimeKind === "scaffold" ? "Safe scaffold mode; verification commands were not executed." : `${runtimeKind} worker finished without reporting a verification failure.`),
355
426
  promptArtifact,
356
427
  resultArtifact,
357
428
  claim: undefined,
@@ -385,10 +456,10 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
385
456
  content: `${JSON.stringify({ role: task.role, permissionMode }, null, 2)}\n`,
386
457
  producer: task.id,
387
458
  });
388
- manifest = { ...manifest, updatedAt: new Date().toISOString(), artifacts: [...manifest.artifacts, promptArtifact, resultArtifact, inputsArtifact, coordinationArtifact, packetArtifact, verificationArtifact, startupArtifact, permissionArtifact, ...(sharedOutputArtifact ? [sharedOutputArtifact] : []), ...(logArtifact ? [logArtifact] : []), ...(transcriptArtifact ? [transcriptArtifact] : []), ...(diffArtifact ? [diffArtifact] : [])] };
459
+ manifest = { ...manifest, updatedAt: new Date().toISOString(), artifacts: [...manifest.artifacts, promptArtifact, resultArtifact, inputsArtifact, coordinationArtifact, packetArtifact, verificationArtifact, startupArtifact, permissionArtifact, ...(sharedOutputArtifact ? [sharedOutputArtifact] : []), ...(logArtifact ? [logArtifact] : []), ...(transcriptArtifact ? [transcriptArtifact] : []), ...(diffArtifact ? [diffArtifact] : []), ...(diffStatArtifact ? [diffStatArtifact] : [])] };
389
460
  saveRunManifest(manifest);
390
461
  saveRunTasks(manifest, tasks);
391
- upsertCrewAgent(manifest, recordFromTask(manifest, task, input.executeWorkers ? "child-process" : "scaffold"));
462
+ upsertCrewAgent(manifest, recordFromTask(manifest, task, runtimeKind));
392
463
  appendEvent(manifest.eventsPath, { type: error ? "task.failed" : "task.completed", runId: manifest.runId, taskId: task.id, message: error });
393
464
  return { manifest, tasks };
394
465
  }
@@ -27,6 +27,9 @@ export interface ExecuteTeamRunInput {
27
27
  limits?: CrewLimitsConfig;
28
28
  runtime?: CrewRuntimeCapabilities;
29
29
  runtimeConfig?: CrewRuntimeConfig;
30
+ parentContext?: string;
31
+ parentModel?: unknown;
32
+ modelRegistry?: unknown;
30
33
  signal?: AbortSignal;
31
34
  }
32
35
 
@@ -174,7 +177,7 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
174
177
  const results = await Promise.all(readyBatch.map((task) => {
175
178
  const step = findStep(input.workflow, task);
176
179
  const agent = findAgent(input.agents, task);
177
- return runTeamTask({ manifest, tasks, task, step, agent, signal: input.signal, executeWorkers: input.executeWorkers });
180
+ 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, limits: input.limits });
178
181
  }));
179
182
  manifest = { ...results.at(-1)!.manifest, artifacts: mergeArtifacts([manifest.artifacts, ...results.map((item) => item.manifest.artifacts)].flat()) };
180
183
  tasks = mergeTaskUpdates(tasks, results);
@@ -94,6 +94,25 @@ export function readEvents(eventsPath: string): TeamEvent[] {
94
94
  .map((line) => JSON.parse(line) as TeamEvent);
95
95
  }
96
96
 
97
+ export interface EventCursorOptions {
98
+ sinceSeq?: number;
99
+ limit?: number;
100
+ }
101
+
102
+ function positiveInteger(value: number | undefined): number | undefined {
103
+ return value !== undefined && Number.isInteger(value) && value >= 0 ? value : undefined;
104
+ }
105
+
106
+ export function readEventsCursor(eventsPath: string, options: EventCursorOptions = {}): { events: TeamEvent[]; nextSeq: number; total: number } {
107
+ const sinceSeq = positiveInteger(options.sinceSeq) ?? 0;
108
+ const limit = positiveInteger(options.limit);
109
+ const all = readEvents(eventsPath);
110
+ const filtered = all.filter((event) => (event.metadata?.seq ?? 0) > sinceSeq);
111
+ const events = limit !== undefined ? filtered.slice(0, limit) : filtered;
112
+ const returnedMaxSeq = events.reduce((max, event) => Math.max(max, event.metadata?.seq ?? 0), sinceSeq);
113
+ return { events, nextSeq: returnedMaxSeq, total: filtered.length };
114
+ }
115
+
97
116
  export function dedupeTerminalEvents(events: TeamEvent[]): TeamEvent[] {
98
117
  const seen = new Set<string>();
99
118
  const output: TeamEvent[] = [];
@@ -1,5 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import type { TeamRunManifest } from "../state/types.ts";
3
+ import { readCrewAgents } from "../runtime/crew-agent-records.ts";
4
+ import type { CrewAgentRecord } from "../runtime/crew-agent-runtime.ts";
3
5
 
4
6
  interface DashboardComponent {
5
7
  invalidate(): void;
@@ -7,17 +9,43 @@ interface DashboardComponent {
7
9
  handleInput(data: string): void;
8
10
  }
9
11
 
10
- export type RunDashboardAction = "status" | "summary" | "artifacts" | "api" | "reload";
12
+ export type RunDashboardAction = "status" | "summary" | "artifacts" | "api" | "events" | "agents" | "agent-events" | "agent-output" | "agent-transcript" | "reload";
11
13
  export interface RunDashboardSelection {
12
14
  runId: string;
13
15
  action: RunDashboardAction;
14
16
  }
15
17
 
18
+ const ANSI_PATTERN = /\u001b\[[0-?]*[ -/]*[@-~]/g;
19
+
20
+ function visibleLength(value: string): number {
21
+ return value.replace(ANSI_PATTERN, "").length;
22
+ }
23
+
16
24
  function truncate(value: string, width: number): string {
17
25
  if (width <= 0) return "";
18
- if (value.length <= width) return value;
26
+ if (visibleLength(value) <= width) return value;
19
27
  if (width <= 1) return "…";
20
- return `${value.slice(0, width - 1)}…`;
28
+ let output = "";
29
+ let visible = 0;
30
+ for (let index = 0; index < value.length;) {
31
+ const slice = value.slice(index);
32
+ const ansi = slice.match(/^\u001b\[[0-?]*[ -/]*[@-~]/);
33
+ if (ansi?.[0]) {
34
+ output += ansi[0];
35
+ index += ansi[0].length;
36
+ continue;
37
+ }
38
+ const char = value[index]!;
39
+ if (visible >= width - 1) break;
40
+ output += char;
41
+ visible += 1;
42
+ index += char.length;
43
+ }
44
+ return `${output}\u001b[0m…`;
45
+ }
46
+
47
+ function padVisible(value: string, width: number): string {
48
+ return `${value}${" ".repeat(Math.max(0, width - visibleLength(value)))}`;
21
49
  }
22
50
 
23
51
  function statusIcon(status: string): string {
@@ -40,6 +68,30 @@ function readProgressPreview(run: TeamRunManifest, maxLines = 5): string[] {
40
68
  }
41
69
  }
42
70
 
71
+ function agentPreviewLine(agent: CrewAgentRecord): string {
72
+ const stats = [
73
+ agent.progress?.activityState,
74
+ agent.progress?.currentTool ? `tool=${agent.progress.currentTool}` : undefined,
75
+ agent.toolUses !== undefined ? `${agent.toolUses} tools` : undefined,
76
+ agent.progress?.tokens !== undefined ? `${agent.progress.tokens} tok` : undefined,
77
+ agent.progress?.turns !== undefined ? `${agent.progress.turns} turns` : undefined,
78
+ agent.progress?.failedTool ? `failedTool=${agent.progress.failedTool}` : undefined,
79
+ ].filter((part): part is string => Boolean(part));
80
+ const recent = agent.progress?.recentOutput?.at(-1);
81
+ return `Agent: ${statusIcon(agent.status)} ${agent.taskId} ${agent.role}->${agent.agent}${stats.length ? ` · ${stats.join(" · ")}` : ""}${recent ? ` ⎿ ${recent}` : ""}`;
82
+ }
83
+
84
+ function readAgentPreview(run: TeamRunManifest, maxLines = 5): string[] {
85
+ try {
86
+ const agents = readCrewAgents(run);
87
+ if (!agents.length) return ["Agents: (none)"];
88
+ return ["Agents:", ...agents.slice(0, maxLines).map(agentPreviewLine), ...(agents.length > maxLines ? [`Agents: +${agents.length - maxLines} more`] : [])];
89
+ } catch (error) {
90
+ const message = error instanceof Error ? error.message : String(error);
91
+ return [`Agents: failed to read (${message})`];
92
+ }
93
+ }
94
+
43
95
  function countByStatus(runs: TeamRunManifest[]): string {
44
96
  const counts = new Map<string, number>();
45
97
  for (const run of runs) counts.set(run.status, (counts.get(run.status) ?? 0) + 1);
@@ -64,19 +116,19 @@ export class RunDashboard implements DashboardComponent {
64
116
  const borderWidth = Math.min(innerWidth, Math.max(0, width - 2));
65
117
  const lines = [
66
118
  `╭${"─".repeat(borderWidth)}╮`,
67
- `│ ${truncate("pi-crew dashboard", innerWidth - 1).padEnd(innerWidth - 1)}│`,
68
- `│ ${truncate("↑/↓/j/k select • r reload • p progress • s/u/a/i actions • q close", innerWidth - 1).padEnd(innerWidth - 1)}│`,
69
- `│ ${truncate(`Runs: ${this.runs.length} • ${countByStatus(this.runs)}`, innerWidth - 1).padEnd(innerWidth - 1)}│`,
119
+ `│ ${padVisible(truncate("pi-crew dashboard", innerWidth - 1), innerWidth - 1)}│`,
120
+ `│ ${padVisible(truncate("↑/↓/j/k select • r reload • p progress • s/u/a/i actions • d agents • e/v/o viewers • q close", innerWidth - 1), innerWidth - 1)}│`,
121
+ `│ ${padVisible(truncate(`Runs: ${this.runs.length} • ${countByStatus(this.runs)}`, innerWidth - 1), innerWidth - 1)}│`,
70
122
  `├${"─".repeat(borderWidth)}┤`,
71
123
  ];
72
124
  if (this.runs.length === 0) {
73
- lines.push(`│ ${truncate("No runs found.", innerWidth - 1).padEnd(innerWidth - 1)}│`);
125
+ lines.push(`│ ${padVisible(truncate("No runs found.", innerWidth - 1), innerWidth - 1)}│`);
74
126
  } else {
75
127
  for (let i = 0; i < Math.min(this.runs.length, 10); i++) {
76
128
  const run = this.runs[i]!;
77
129
  const marker = i === this.selected ? "›" : " ";
78
130
  const text = `${marker} ${statusIcon(run.status)} ${run.runId} ${run.status} ${run.team}/${run.workflow ?? "none"} ${run.goal}`;
79
- lines.push(`│ ${truncate(text, innerWidth - 1).padEnd(innerWidth - 1)}│`);
131
+ lines.push(`│ ${padVisible(truncate(text, innerWidth - 1), innerWidth - 1)}│`);
80
132
  }
81
133
  const selectedRun = this.runs[this.selected];
82
134
  if (selectedRun) {
@@ -90,8 +142,8 @@ export class RunDashboard implements DashboardComponent {
90
142
  selectedRun.async ? `Async: pid=${selectedRun.async.pid ?? "unknown"} log=${selectedRun.async.logPath}` : "Async: no",
91
143
  `Goal: ${selectedRun.goal}`,
92
144
  ];
93
- for (const detail of [...details, ...readProgressPreview(selectedRun, this.showFullProgress ? 20 : 5)]) {
94
- lines.push(`│ ${truncate(detail, innerWidth - 1).padEnd(innerWidth - 1)}│`);
145
+ for (const detail of [...details, ...readAgentPreview(selectedRun), ...readProgressPreview(selectedRun, this.showFullProgress ? 20 : 5)]) {
146
+ lines.push(`│ ${padVisible(truncate(detail, innerWidth - 1), innerWidth - 1)}│`);
95
147
  }
96
148
  }
97
149
  }
@@ -124,6 +176,26 @@ export class RunDashboard implements DashboardComponent {
124
176
  this.done(runId ? { runId, action: "api" } : undefined);
125
177
  return;
126
178
  }
179
+ if (data === "d") {
180
+ const runId = this.runs[this.selected]?.runId;
181
+ this.done(runId ? { runId, action: "agents" } : undefined);
182
+ return;
183
+ }
184
+ if (data === "e") {
185
+ const runId = this.runs[this.selected]?.runId;
186
+ this.done(runId ? { runId, action: "agent-events" } : undefined);
187
+ return;
188
+ }
189
+ if (data === "o") {
190
+ const runId = this.runs[this.selected]?.runId;
191
+ this.done(runId ? { runId, action: "agent-output" } : undefined);
192
+ return;
193
+ }
194
+ if (data === "v") {
195
+ const runId = this.runs[this.selected]?.runId;
196
+ this.done(runId ? { runId, action: "agent-transcript" } : undefined);
197
+ return;
198
+ }
127
199
  if (data === "r") {
128
200
  this.done({ runId: this.runs[this.selected]?.runId ?? "", action: "reload" });
129
201
  return;
@@ -0,0 +1,33 @@
1
+ interface TimerApi {
2
+ setTimeout(handler: () => void, delayMs: number): unknown;
3
+ clearTimeout(handle: unknown): void;
4
+ }
5
+
6
+ const defaultTimerApi: TimerApi = {
7
+ setTimeout: (handler, delayMs) => setTimeout(handler, delayMs),
8
+ clearTimeout: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
9
+ };
10
+
11
+ export interface FileCoalescer {
12
+ schedule(file: string, delayMs?: number): boolean;
13
+ clear(): void;
14
+ }
15
+
16
+ export function createFileCoalescer(handler: (file: string) => void, defaultDelayMs: number, timerApi: TimerApi = defaultTimerApi): FileCoalescer {
17
+ const pending = new Map<string, unknown>();
18
+ return {
19
+ schedule(file, delayMs = defaultDelayMs) {
20
+ if (pending.has(file)) return false;
21
+ const timer = timerApi.setTimeout(() => {
22
+ pending.delete(file);
23
+ handler(file);
24
+ }, delayMs);
25
+ pending.set(file, timer);
26
+ return true;
27
+ },
28
+ clear() {
29
+ for (const timer of pending.values()) timerApi.clearTimeout(timer);
30
+ pending.clear();
31
+ },
32
+ };
33
+ }
@@ -1,4 +1,4 @@
1
- import { execFileSync } from "node:child_process";
1
+ import { execFileSync, spawnSync } from "node:child_process";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import { loadConfig } from "../config/config.ts";
@@ -9,6 +9,15 @@ export interface PreparedTaskWorkspace {
9
9
  worktreePath?: string;
10
10
  branch?: string;
11
11
  reused?: boolean;
12
+ nodeModulesLinked?: boolean;
13
+ syntheticPaths?: string[];
14
+ }
15
+
16
+ export interface WorktreeDiffStat {
17
+ filesChanged: number;
18
+ insertions: number;
19
+ deletions: number;
20
+ diffStat: string;
12
21
  }
13
22
 
14
23
  function git(cwd: string, args: string[]): string {
@@ -30,6 +39,47 @@ export function assertCleanLeader(repoRoot: string): void {
30
39
  }
31
40
  }
32
41
 
42
+ function linkNodeModulesIfPresent(repoRoot: string, worktreePath: string): boolean {
43
+ const source = path.join(repoRoot, "node_modules");
44
+ const target = path.join(worktreePath, "node_modules");
45
+ if (!fs.existsSync(source) || fs.existsSync(target)) return false;
46
+ try {
47
+ fs.symlinkSync(source, target, process.platform === "win32" ? "junction" : "dir");
48
+ return true;
49
+ } catch {
50
+ return false;
51
+ }
52
+ }
53
+
54
+ function normalizeSyntheticPath(worktreePath: string, rawPath: string): string {
55
+ const resolved = path.resolve(worktreePath, rawPath);
56
+ const relative = path.relative(worktreePath, resolved);
57
+ if (!relative || relative === "." || relative.startsWith("..") || path.isAbsolute(relative)) throw new Error(`synthetic path escapes worktree: ${rawPath}`);
58
+ return path.normalize(relative);
59
+ }
60
+
61
+ function runSetupHook(manifest: TeamRunManifest, task: TeamTaskState, repoRoot: string, worktreePath: string, branch: string): string[] {
62
+ const cfg = loadConfig(manifest.cwd).config.worktree;
63
+ if (!cfg?.setupHook) return [];
64
+ const hookPath = path.isAbsolute(cfg.setupHook) ? cfg.setupHook : path.resolve(repoRoot, cfg.setupHook);
65
+ if (!fs.existsSync(hookPath) || fs.statSync(hookPath).isDirectory()) throw new Error(`worktree setup hook not found or not a file: ${hookPath}`);
66
+ const nodeHook = hookPath.endsWith(".js") || hookPath.endsWith(".cjs") || hookPath.endsWith(".mjs");
67
+ const result = spawnSync(nodeHook ? process.execPath : hookPath, nodeHook ? [hookPath] : [], {
68
+ cwd: worktreePath,
69
+ encoding: "utf-8",
70
+ input: JSON.stringify({ version: 1, repoRoot, worktreePath, agentCwd: worktreePath, branch, runId: manifest.runId, taskId: task.id, agent: task.agent }),
71
+ timeout: cfg.setupHookTimeoutMs ?? 30_000,
72
+ shell: false,
73
+ });
74
+ if (result.error) throw new Error(`worktree setup hook failed: ${result.error.message}`);
75
+ if (result.status !== 0) throw new Error(`worktree setup hook failed with exit code ${result.status}: ${result.stderr || result.stdout || "no output"}`);
76
+ const trimmed = result.stdout.trim();
77
+ if (!trimmed) return [];
78
+ const parsed = JSON.parse(trimmed) as { syntheticPaths?: unknown };
79
+ if (!Array.isArray(parsed.syntheticPaths)) return [];
80
+ return [...new Set(parsed.syntheticPaths.filter((entry): entry is string => typeof entry === "string").map((entry) => normalizeSyntheticPath(worktreePath, entry)))];
81
+ }
82
+
33
83
  export function prepareTaskWorkspace(manifest: TeamRunManifest, task: TeamTaskState): PreparedTaskWorkspace {
34
84
  if (manifest.workspaceMode !== "worktree") return { cwd: task.cwd };
35
85
  const repoRoot = findGitRoot(manifest.cwd);
@@ -47,7 +97,28 @@ export function prepareTaskWorkspace(manifest: TeamRunManifest, task: TeamTaskSt
47
97
  return { cwd: worktreePath, worktreePath, branch, reused: true };
48
98
  }
49
99
  git(repoRoot, ["worktree", "add", "-b", branch, worktreePath, "HEAD"]);
50
- return { cwd: worktreePath, worktreePath, branch, reused: false };
100
+ const syntheticPaths = runSetupHook(manifest, task, repoRoot, worktreePath, branch);
101
+ const nodeModulesLinked = loadedConfig.config.worktree?.linkNodeModules === true ? linkNodeModulesIfPresent(repoRoot, worktreePath) : false;
102
+ return { cwd: worktreePath, worktreePath, branch, reused: false, nodeModulesLinked, syntheticPaths };
103
+ }
104
+
105
+ export function captureWorktreeDiffStat(worktreePath: string): WorktreeDiffStat {
106
+ try {
107
+ const diffStat = git(worktreePath, ["diff", "--stat"]);
108
+ const numstat = git(worktreePath, ["diff", "--numstat"]);
109
+ let filesChanged = 0;
110
+ let insertions = 0;
111
+ let deletions = 0;
112
+ for (const line of numstat.split(/\r?\n/).filter(Boolean)) {
113
+ const [add, del] = line.split(/\s+/);
114
+ filesChanged += 1;
115
+ insertions += Number(add) || 0;
116
+ deletions += Number(del) || 0;
117
+ }
118
+ return { filesChanged, insertions, deletions, diffStat };
119
+ } catch {
120
+ return { filesChanged: 0, insertions: 0, deletions: 0, diffStat: "" };
121
+ }
51
122
  }
52
123
 
53
124
  export function captureWorktreeDiff(worktreePath: string): string {