pi-crew 0.1.6 → 0.1.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -24,4 +24,6 @@ export interface AgentConfig {
24
24
  inheritProjectContext?: boolean;
25
25
  inheritSkills?: boolean;
26
26
  routing?: RoutingMetadata;
27
+ disabled?: boolean;
28
+ override?: { source: "config"; path: string };
27
29
  }
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import type { AgentConfig, ResourceSource } from "./agent-config.ts";
4
+ import { loadConfig } from "../config/config.ts";
4
5
  import { parseCsv, parseFrontmatter } from "../utils/frontmatter.ts";
5
6
  import { packageRoot, projectPiRoot, userPiRoot } from "../utils/paths.ts";
6
7
 
@@ -40,6 +41,7 @@ function parseAgentFile(filePath: string, source: ResourceSource): AgentConfig |
40
41
  systemPromptMode: frontmatter.systemPromptMode === "append" ? "append" : "replace",
41
42
  inheritProjectContext: frontmatter.inheritProjectContext === "true",
42
43
  inheritSkills: frontmatter.inheritSkills === "true",
44
+ disabled: frontmatter.disabled === "true" || frontmatter.enabled === "false",
43
45
  routing: triggers || useWhen || avoidWhen || cost || category ? { triggers, useWhen, avoidWhen, cost, category } : undefined,
44
46
  };
45
47
  } catch {
@@ -56,18 +58,40 @@ function readAgentDir(dir: string, source: ResourceSource): AgentConfig[] {
56
58
  .sort((a, b) => a.name.localeCompare(b.name));
57
59
  }
58
60
 
61
+ function applyAgentOverrides(agents: AgentConfig[], cwd: string): AgentConfig[] {
62
+ const loaded = loadConfig(cwd);
63
+ const config = loaded.config.agents;
64
+ const overrides = config?.overrides ?? {};
65
+ return agents
66
+ .filter((agent) => !(config?.disableBuiltins && agent.source === "builtin"))
67
+ .map((agent) => {
68
+ const overrideEntry = Object.entries(overrides).find(([name]) => name.toLowerCase() === agent.name.toLowerCase());
69
+ if (!overrideEntry) return agent;
70
+ const [, override] = overrideEntry;
71
+ return {
72
+ ...agent,
73
+ disabled: override.disabled ?? agent.disabled,
74
+ model: override.model === false ? undefined : override.model ?? agent.model,
75
+ fallbackModels: override.fallbackModels === false ? undefined : override.fallbackModels ?? agent.fallbackModels,
76
+ thinking: override.thinking === false ? undefined : override.thinking ?? agent.thinking,
77
+ tools: override.tools === false ? undefined : override.tools ?? agent.tools,
78
+ override: { source: "config", path: loaded.path },
79
+ };
80
+ });
81
+ }
82
+
59
83
  export function discoverAgents(cwd: string): AgentDiscoveryResult {
60
84
  return {
61
- builtin: readAgentDir(path.join(packageRoot(), "agents"), "builtin"),
62
- user: readAgentDir(path.join(userPiRoot(), "agents"), "user"),
63
- project: readAgentDir(path.join(projectPiRoot(cwd), "agents"), "project"),
85
+ builtin: applyAgentOverrides(readAgentDir(path.join(packageRoot(), "agents"), "builtin"), cwd),
86
+ user: applyAgentOverrides(readAgentDir(path.join(userPiRoot(), "agents"), "user"), cwd),
87
+ project: applyAgentOverrides(readAgentDir(path.join(projectPiRoot(cwd), "agents"), "project"), cwd),
64
88
  };
65
89
  }
66
90
 
67
91
  export function allAgents(discovery: AgentDiscoveryResult): AgentConfig[] {
68
92
  const byName = new Map<string, AgentConfig>();
69
93
  for (const agent of [...discovery.builtin, ...discovery.user, ...discovery.project]) {
70
- byName.set(agent.name, agent);
94
+ byName.set(agent.name.toLowerCase(), agent);
71
95
  }
72
- return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
96
+ return [...byName.values()].filter((agent) => !agent.disabled).sort((a, b) => a.name.localeCompare(b.name));
73
97
  }
@@ -41,6 +41,19 @@ export interface CrewControlConfig {
41
41
  needsAttentionAfterMs?: number;
42
42
  }
43
43
 
44
+ export interface AgentOverrideConfig {
45
+ disabled?: boolean;
46
+ model?: string | false;
47
+ fallbackModels?: string[] | false;
48
+ thinking?: string | false;
49
+ tools?: string[] | false;
50
+ }
51
+
52
+ export interface CrewAgentsConfig {
53
+ disableBuiltins?: boolean;
54
+ overrides?: Record<string, AgentOverrideConfig>;
55
+ }
56
+
44
57
  export interface PiTeamsConfig {
45
58
  asyncByDefault?: boolean;
46
59
  executeWorkers?: boolean;
@@ -50,6 +63,7 @@ export interface PiTeamsConfig {
50
63
  limits?: CrewLimitsConfig;
51
64
  runtime?: CrewRuntimeConfig;
52
65
  control?: CrewControlConfig;
66
+ agents?: CrewAgentsConfig;
53
67
  }
54
68
 
55
69
  export interface LoadedPiTeamsConfig {
@@ -109,6 +123,17 @@ function mergeConfig(base: PiTeamsConfig, override: PiTeamsConfig): PiTeamsConfi
109
123
  ...withoutUndefined((override.control ?? {}) as Record<string, unknown>),
110
124
  };
111
125
  }
126
+ if (base.agents || override.agents) {
127
+ merged.agents = {
128
+ ...(base.agents ?? {}),
129
+ ...withoutUndefined((override.agents ?? {}) as Record<string, unknown>),
130
+ overrides: {
131
+ ...(base.agents?.overrides ?? {}),
132
+ ...(override.agents?.overrides ?? {}),
133
+ },
134
+ };
135
+ }
136
+ if (merged.agents?.overrides && Object.keys(merged.agents.overrides).length === 0) delete merged.agents.overrides;
112
137
  return merged;
113
138
  }
114
139
 
@@ -159,21 +184,33 @@ function parseAutonomousConfig(value: unknown): PiTeamsAutonomousConfig | undefi
159
184
  };
160
185
  }
161
186
 
162
- function parsePositiveInteger(value: unknown): number | undefined {
163
- return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
187
+ const LIMIT_CEILINGS = {
188
+ maxConcurrentWorkers: 1024,
189
+ maxTaskDepth: 100,
190
+ maxChildrenPerTask: 1000,
191
+ maxRunMinutes: 1440,
192
+ maxRetriesPerTask: 100,
193
+ maxTasksPerRun: 10_000,
194
+ heartbeatStaleMs: 24 * 60 * 60 * 1000,
195
+ runtimeMaxTurns: 10_000,
196
+ runtimeGraceTurns: 1_000,
197
+ } as const;
198
+
199
+ function parsePositiveInteger(value: unknown, max = Number.MAX_SAFE_INTEGER): number | undefined {
200
+ return typeof value === "number" && Number.isInteger(value) && value > 0 && value <= max ? value : undefined;
164
201
  }
165
202
 
166
203
  function parseLimitsConfig(value: unknown): CrewLimitsConfig | undefined {
167
204
  if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
168
205
  const obj = value as Record<string, unknown>;
169
206
  const limits: CrewLimitsConfig = {
170
- maxConcurrentWorkers: parsePositiveInteger(obj.maxConcurrentWorkers),
171
- maxTaskDepth: parsePositiveInteger(obj.maxTaskDepth),
172
- maxChildrenPerTask: parsePositiveInteger(obj.maxChildrenPerTask),
173
- maxRunMinutes: parsePositiveInteger(obj.maxRunMinutes),
174
- maxRetriesPerTask: parsePositiveInteger(obj.maxRetriesPerTask),
175
- maxTasksPerRun: parsePositiveInteger(obj.maxTasksPerRun),
176
- heartbeatStaleMs: parsePositiveInteger(obj.heartbeatStaleMs),
207
+ maxConcurrentWorkers: parsePositiveInteger(obj.maxConcurrentWorkers, LIMIT_CEILINGS.maxConcurrentWorkers),
208
+ maxTaskDepth: parsePositiveInteger(obj.maxTaskDepth, LIMIT_CEILINGS.maxTaskDepth),
209
+ maxChildrenPerTask: parsePositiveInteger(obj.maxChildrenPerTask, LIMIT_CEILINGS.maxChildrenPerTask),
210
+ maxRunMinutes: parsePositiveInteger(obj.maxRunMinutes, LIMIT_CEILINGS.maxRunMinutes),
211
+ maxRetriesPerTask: parsePositiveInteger(obj.maxRetriesPerTask, LIMIT_CEILINGS.maxRetriesPerTask),
212
+ maxTasksPerRun: parsePositiveInteger(obj.maxTasksPerRun, LIMIT_CEILINGS.maxTasksPerRun),
213
+ heartbeatStaleMs: parsePositiveInteger(obj.heartbeatStaleMs, LIMIT_CEILINGS.heartbeatStaleMs),
177
214
  };
178
215
  return Object.values(limits).some((entry) => entry !== undefined) ? limits : undefined;
179
216
  }
@@ -189,8 +226,8 @@ function parseRuntimeConfig(value: unknown): CrewRuntimeConfig | undefined {
189
226
  mode: parseRuntimeMode(obj.mode),
190
227
  preferLiveSession: typeof obj.preferLiveSession === "boolean" ? obj.preferLiveSession : undefined,
191
228
  allowChildProcessFallback: typeof obj.allowChildProcessFallback === "boolean" ? obj.allowChildProcessFallback : undefined,
192
- maxTurns: parsePositiveInteger(obj.maxTurns),
193
- graceTurns: parsePositiveInteger(obj.graceTurns),
229
+ maxTurns: parsePositiveInteger(obj.maxTurns, LIMIT_CEILINGS.runtimeMaxTurns),
230
+ graceTurns: parsePositiveInteger(obj.graceTurns, LIMIT_CEILINGS.runtimeGraceTurns),
194
231
  inheritContext: typeof obj.inheritContext === "boolean" ? obj.inheritContext : undefined,
195
232
  promptMode: obj.promptMode === "replace" || obj.promptMode === "append" ? obj.promptMode : undefined,
196
233
  groupJoin: obj.groupJoin === "off" || obj.groupJoin === "group" || obj.groupJoin === "smart" ? obj.groupJoin : undefined,
@@ -208,6 +245,43 @@ function parseControlConfig(value: unknown): CrewControlConfig | undefined {
208
245
  return Object.values(control).some((entry) => entry !== undefined) ? control : undefined;
209
246
  }
210
247
 
248
+ function parseStringArrayOrFalse(value: unknown): string[] | false | undefined {
249
+ if (value === false) return false;
250
+ if (typeof value === "string") return value.split(",").map((entry) => entry.trim()).filter(Boolean);
251
+ if (Array.isArray(value)) return value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0).map((entry) => entry.trim());
252
+ return undefined;
253
+ }
254
+
255
+ function parseAgentOverride(value: unknown): AgentOverrideConfig | undefined {
256
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
257
+ const obj = value as Record<string, unknown>;
258
+ const override: AgentOverrideConfig = {
259
+ disabled: typeof obj.disabled === "boolean" ? obj.disabled : undefined,
260
+ model: typeof obj.model === "string" || obj.model === false ? obj.model : undefined,
261
+ fallbackModels: parseStringArrayOrFalse(obj.fallbackModels),
262
+ thinking: typeof obj.thinking === "string" || obj.thinking === false ? obj.thinking : undefined,
263
+ tools: parseStringArrayOrFalse(obj.tools),
264
+ };
265
+ return Object.values(override).some((entry) => entry !== undefined) ? override : undefined;
266
+ }
267
+
268
+ function parseAgentsConfig(value: unknown): CrewAgentsConfig | undefined {
269
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
270
+ const obj = value as Record<string, unknown>;
271
+ const overrides: Record<string, AgentOverrideConfig> = {};
272
+ if (obj.overrides && typeof obj.overrides === "object" && !Array.isArray(obj.overrides)) {
273
+ for (const [name, rawOverride] of Object.entries(obj.overrides)) {
274
+ const parsed = parseAgentOverride(rawOverride);
275
+ if (parsed) overrides[name] = parsed;
276
+ }
277
+ }
278
+ const agents: CrewAgentsConfig = {
279
+ disableBuiltins: typeof obj.disableBuiltins === "boolean" ? obj.disableBuiltins : undefined,
280
+ overrides: Object.keys(overrides).length > 0 ? overrides : undefined,
281
+ };
282
+ return Object.values(agents).some((entry) => entry !== undefined) ? agents : undefined;
283
+ }
284
+
211
285
  function parseConfig(raw: unknown): PiTeamsConfig {
212
286
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
213
287
  const obj = raw as Record<string, unknown>;
@@ -220,6 +294,7 @@ function parseConfig(raw: unknown): PiTeamsConfig {
220
294
  limits: parseLimitsConfig(obj.limits),
221
295
  runtime: parseRuntimeConfig(obj.runtime),
222
296
  control: parseControlConfig(obj.control),
297
+ agents: parseAgentsConfig(obj.agents),
223
298
  };
224
299
  }
225
300
 
@@ -167,7 +167,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
167
167
  const config: Record<string, unknown> = { operation };
168
168
  for (const token of tokens.filter((item) => item.includes("="))) {
169
169
  const [key, ...rest] = token.split("=");
170
- if (key) config[key] = rest.join("=");
170
+ if (key) config[key] = parseScalar(rest.join("="));
171
171
  }
172
172
  const result = await handleTeamTool({ action: "api", runId, config }, ctx);
173
173
  await notifyCommandResult(ctx, commandText(result));
@@ -255,7 +255,15 @@ export function registerPiTeams(pi: ExtensionAPI): void {
255
255
  if (selection.action === "reload") continue;
256
256
  const result = selection.action === "api"
257
257
  ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-manifest" } }, ctx)
258
- : await handleTeamTool({ action: selection.action, runId: selection.runId }, ctx);
258
+ : selection.action === "agents"
259
+ ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "agent-dashboard" } }, ctx)
260
+ : selection.action === "agent-events"
261
+ ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-agent-events", limit: 50 } }, ctx)
262
+ : selection.action === "agent-output"
263
+ ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-agent-output", maxBytes: 32_000 } }, ctx)
264
+ : selection.action === "agent-transcript"
265
+ ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-agent-transcript" } }, ctx)
266
+ : await handleTeamTool({ action: selection.action, runId: selection.runId }, ctx);
259
267
  await notifyCommandResult(ctx, commandText(result));
260
268
  return;
261
269
  }
@@ -21,7 +21,7 @@ import { getPiSpawnCommand } from "../runtime/pi-spawn.ts";
21
21
  import { executeTeamRun } from "../runtime/team-runner.ts";
22
22
  import { spawnBackgroundTeamRun } from "../runtime/async-runner.ts";
23
23
  import { checkProcessLiveness, isActiveRunStatus } from "../runtime/process-status.ts";
24
- import { appendEvent, readEvents } from "../state/event-log.ts";
24
+ import { appendEvent, readEvents, readEventsCursor } from "../state/event-log.ts";
25
25
  import { cleanupRunWorktrees } from "../worktree/cleanup.ts";
26
26
  import { piTeamsHelp } from "./help.ts";
27
27
  import { initializeProject } from "./project-init.ts";
@@ -35,10 +35,12 @@ import { formatValidationReport, validateResources } from "./validate-resources.
35
35
  import { formatRecommendation, recommendTeam } from "./team-recommendation.ts";
36
36
  import { toolResult, type PiTeamsToolResult } from "./tool-result.ts";
37
37
  import { touchWorkerHeartbeat } from "../runtime/worker-heartbeat.ts";
38
- import { agentEventsPath, agentOutputPath, readCrewAgentEvents, readCrewAgentStatus, readCrewAgents } from "../runtime/crew-agent-records.ts";
38
+ import { agentEventsPath, agentOutputPath, readCrewAgentEvents, readCrewAgentEventsCursor, readCrewAgentStatus, readCrewAgents } from "../runtime/crew-agent-records.ts";
39
39
  import { resolveCrewRuntime } from "../runtime/runtime-resolver.ts";
40
40
  import { probeLiveSessionRuntime } from "../runtime/live-session-runtime.ts";
41
41
  import { applyAttentionState, formatActivityAge, resolveCrewControlConfig } from "../runtime/agent-control.ts";
42
+ import { buildAgentDashboard, readAgentOutput } from "../runtime/agent-observability.ts";
43
+ import { readForegroundControlStatus, writeForegroundInterruptRequest } from "../runtime/foreground-control.ts";
42
44
 
43
45
  export interface TeamToolDetails {
44
46
  action: string;
@@ -575,7 +577,12 @@ export async function handleApi(params: TeamToolParamsValue, ctx: TeamContext):
575
577
  return result(JSON.stringify(task, null, 2), { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
576
578
  }
577
579
  if (operation === "read-events") {
578
- return result(JSON.stringify(readEvents(loaded.manifest.eventsPath), null, 2), { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
580
+ const sinceSeq = typeof cfg.sinceSeq === "number" ? cfg.sinceSeq : undefined;
581
+ const limit = typeof cfg.limit === "number" ? cfg.limit : undefined;
582
+ const payload = sinceSeq !== undefined || limit !== undefined
583
+ ? readEventsCursor(loaded.manifest.eventsPath, { sinceSeq, limit })
584
+ : { events: readEvents(loaded.manifest.eventsPath), nextSeq: undefined, total: undefined };
585
+ return result(JSON.stringify(payload, null, 2), { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
579
586
  }
580
587
  if (operation === "runtime-capabilities") {
581
588
  const loadedConfig = loadConfig(ctx.cwd);
@@ -604,18 +611,43 @@ export async function handleApi(params: TeamToolParamsValue, ctx: TeamContext):
604
611
  }
605
612
  if (operation === "read-agent-events") {
606
613
  const agentId = typeof cfg.agentId === "string" ? cfg.agentId : undefined;
607
- const agent = readCrewAgents(loaded.manifest).find((item) => item.id === agentId || item.taskId === agentId);
608
- if (!agent) return result("API read-agent-events requires config.agentId matching an agent id or task id.", { action: "api", status: "error", runId: loaded.manifest.runId }, true);
609
- return result(JSON.stringify({ path: agentEventsPath(loaded.manifest, agent.taskId), events: readCrewAgentEvents(loaded.manifest, agent.taskId) }, null, 2), { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
614
+ const agents = readCrewAgents(loaded.manifest);
615
+ const agent = agentId ? agents.find((item) => item.id === agentId || item.taskId === agentId) : agents[0];
616
+ if (!agent) return result("API read-agent-events requires config.agentId matching an agent id or task id, or at least one agent in the run.", { action: "api", status: "error", runId: loaded.manifest.runId }, true);
617
+ const sinceSeq = typeof cfg.sinceSeq === "number" ? cfg.sinceSeq : undefined;
618
+ const limit = typeof cfg.limit === "number" ? cfg.limit : undefined;
619
+ const payload = sinceSeq !== undefined || limit !== undefined
620
+ ? readCrewAgentEventsCursor(loaded.manifest, agent.taskId, { sinceSeq, limit })
621
+ : { path: agentEventsPath(loaded.manifest, agent.taskId), events: readCrewAgentEvents(loaded.manifest, agent.taskId) };
622
+ return result(JSON.stringify(payload, null, 2), { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
610
623
  }
611
624
  if (operation === "read-agent-transcript") {
612
625
  const agentId = typeof cfg.agentId === "string" ? cfg.agentId : undefined;
613
- const agent = readCrewAgents(loaded.manifest).find((item) => item.id === agentId || item.taskId === agentId);
614
- if (!agent) return result("API read-agent-transcript requires config.agentId matching an agent id or task id.", { action: "api", status: "error", runId: loaded.manifest.runId }, true);
626
+ const agents = readCrewAgents(loaded.manifest);
627
+ const agent = agentId ? agents.find((item) => item.id === agentId || item.taskId === agentId) : agents[0];
628
+ if (!agent) return result("API read-agent-transcript requires config.agentId matching an agent id or task id, or at least one agent in the run.", { action: "api", status: "error", runId: loaded.manifest.runId }, true);
615
629
  const transcriptPath = agent.transcriptPath && fs.existsSync(agent.transcriptPath) ? agent.transcriptPath : agentOutputPath(loaded.manifest, agent.taskId);
616
630
  const text = fs.existsSync(transcriptPath) ? fs.readFileSync(transcriptPath, "utf-8") : "";
617
631
  return result(text || `(no transcript at ${transcriptPath})`, { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
618
632
  }
633
+ if (operation === "read-agent-output") {
634
+ const agentId = typeof cfg.agentId === "string" ? cfg.agentId : undefined;
635
+ const agents = readCrewAgents(loaded.manifest);
636
+ const agent = agentId ? agents.find((item) => item.id === agentId || item.taskId === agentId) : agents[0];
637
+ if (!agent) return result("API read-agent-output requires config.agentId matching an agent id or task id, or at least one agent in the run.", { action: "api", status: "error", runId: loaded.manifest.runId }, true);
638
+ const maxBytes = typeof cfg.maxBytes === "number" ? cfg.maxBytes : undefined;
639
+ return result(JSON.stringify(readAgentOutput(loaded.manifest, agent.taskId, maxBytes), null, 2), { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
640
+ }
641
+ if (operation === "agent-dashboard") {
642
+ return result(buildAgentDashboard(loaded.manifest).text, { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
643
+ }
644
+ if (operation === "foreground-status") {
645
+ return result(JSON.stringify(readForegroundControlStatus(loaded.manifest, loaded.tasks), null, 2), { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
646
+ }
647
+ if (operation === "foreground-interrupt") {
648
+ const reason = typeof cfg.reason === "string" && cfg.reason.trim() ? cfg.reason.trim() : undefined;
649
+ return result(JSON.stringify(writeForegroundInterruptRequest(loaded.manifest, reason), null, 2), { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
650
+ }
619
651
  if (operation === "nudge-agent") {
620
652
  const agentId = typeof cfg.agentId === "string" ? cfg.agentId : undefined;
621
653
  const agent = readCrewAgents(loaded.manifest).find((item) => item.id === agentId || item.taskId === agentId);
@@ -2,6 +2,8 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
2
 
3
3
  export const PI_TEAMS_INHERIT_PROJECT_CONTEXT_ENV = "PI_TEAMS_INHERIT_PROJECT_CONTEXT";
4
4
  export const PI_TEAMS_INHERIT_SKILLS_ENV = "PI_TEAMS_INHERIT_SKILLS";
5
+ export const PI_CREW_INHERIT_PROJECT_CONTEXT_ENV = "PI_CREW_INHERIT_PROJECT_CONTEXT";
6
+ export const PI_CREW_INHERIT_SKILLS_ENV = "PI_CREW_INHERIT_SKILLS";
5
7
 
6
8
  const PROJECT_CONTEXT_HEADER = "\n\n# Project Context\n\nProject-specific instructions and guidelines:\n\n";
7
9
  const SKILLS_HEADER = "\n\nThe following skills provide specialized instructions for specific tasks.";
@@ -13,6 +15,14 @@ function readBooleanEnv(name: string): boolean | undefined {
13
15
  return value !== "0";
14
16
  }
15
17
 
18
+ function readBooleanEnvAny(...names: string[]): boolean | undefined {
19
+ for (const name of names) {
20
+ const value = readBooleanEnv(name);
21
+ if (value !== undefined) return value;
22
+ }
23
+ return undefined;
24
+ }
25
+
16
26
  function findSectionEnd(prompt: string, startIndex: number, nextHeaders: string[]): number {
17
27
  let endIndex = prompt.length;
18
28
  for (const header of nextHeaders) {
@@ -45,8 +55,8 @@ export function rewriteTeamWorkerPrompt(prompt: string, options: { inheritProjec
45
55
 
46
56
  export default function registerPiTeamsPromptRuntime(pi: ExtensionAPI): void {
47
57
  pi.on("before_agent_start", (event) => {
48
- const inheritProjectContext = readBooleanEnv(PI_TEAMS_INHERIT_PROJECT_CONTEXT_ENV);
49
- const inheritSkills = readBooleanEnv(PI_TEAMS_INHERIT_SKILLS_ENV);
58
+ const inheritProjectContext = readBooleanEnvAny(PI_CREW_INHERIT_PROJECT_CONTEXT_ENV, PI_TEAMS_INHERIT_PROJECT_CONTEXT_ENV);
59
+ const inheritSkills = readBooleanEnvAny(PI_CREW_INHERIT_SKILLS_ENV, PI_TEAMS_INHERIT_SKILLS_ENV);
50
60
  if (inheritProjectContext === undefined && inheritSkills === undefined) return;
51
61
  const rewritten = rewriteTeamWorkerPrompt(event.systemPrompt, {
52
62
  inheritProjectContext: inheritProjectContext ?? true,
@@ -0,0 +1,88 @@
1
+ import * as fs from "node:fs";
2
+ import type { TeamRunManifest } from "../state/types.ts";
3
+ import { agentOutputPath, readCrewAgents } from "./crew-agent-records.ts";
4
+ import type { CrewAgentRecord } from "./crew-agent-runtime.ts";
5
+
6
+ export interface TextTailResult {
7
+ path: string;
8
+ text: string;
9
+ bytes: number;
10
+ truncated: boolean;
11
+ }
12
+
13
+ export function readTextTail(filePath: string, maxBytes = 64_000): TextTailResult {
14
+ if (!fs.existsSync(filePath)) return { path: filePath, text: "", bytes: 0, truncated: false };
15
+ const stat = fs.statSync(filePath);
16
+ const bytesToRead = Math.min(stat.size, Math.max(0, maxBytes));
17
+ const fd = fs.openSync(filePath, "r");
18
+ try {
19
+ const buffer = Buffer.alloc(bytesToRead);
20
+ fs.readSync(fd, buffer, 0, bytesToRead, stat.size - bytesToRead);
21
+ return { path: filePath, text: buffer.toString("utf-8"), bytes: stat.size, truncated: stat.size > bytesToRead };
22
+ } finally {
23
+ fs.closeSync(fd);
24
+ }
25
+ }
26
+
27
+ function activityText(agent: CrewAgentRecord): string {
28
+ const parts: string[] = [];
29
+ if (agent.progress?.activityState) parts.push(agent.progress.activityState);
30
+ if (agent.progress?.currentTool) parts.push(`tool=${agent.progress.currentTool}`);
31
+ if (agent.toolUses !== undefined) parts.push(`tools=${agent.toolUses}`);
32
+ if (agent.progress?.tokens !== undefined) parts.push(`tokens=${agent.progress.tokens}`);
33
+ if (agent.progress?.turns !== undefined) parts.push(`turns=${agent.progress.turns}`);
34
+ if (agent.progress?.durationMs !== undefined) parts.push(`durationMs=${agent.progress.durationMs}`);
35
+ if (agent.progress?.failedTool) parts.push(`failedTool=${agent.progress.failedTool}`);
36
+ if (agent.progress?.recentOutput?.length) parts.push(`last=${agent.progress.recentOutput.at(-1)}`);
37
+ return parts.join(" ") || "idle";
38
+ }
39
+
40
+ function statusGlyph(status: CrewAgentRecord["status"]): string {
41
+ if (status === "completed") return "✓";
42
+ if (status === "failed") return "✗";
43
+ if (status === "running") return "▶";
44
+ if (status === "cancelled" || status === "stopped") return "■";
45
+ return "·";
46
+ }
47
+
48
+ function outputWarning(agent: CrewAgentRecord): string {
49
+ if (agent.status !== "completed") return "";
50
+ if (!agent.outputPath || !fs.existsSync(agent.outputPath)) return " no-output";
51
+ try {
52
+ return fs.statSync(agent.outputPath).size === 0 ? " no-output" : "";
53
+ } catch {
54
+ return " no-output";
55
+ }
56
+ }
57
+
58
+ function agentLine(agent: CrewAgentRecord): string {
59
+ return `- ${statusGlyph(agent.status)} ${agent.taskId} [${agent.status}] ${agent.role}->${agent.agent} runtime=${agent.runtime} ${activityText(agent)}${outputWarning(agent)}${agent.error ? ` error=${agent.error}` : ""}`;
60
+ }
61
+
62
+ export function buildAgentDashboard(manifest: TeamRunManifest): { text: string; groups: Record<string, CrewAgentRecord[]> } {
63
+ const agents = readCrewAgents(manifest);
64
+ const groups: Record<string, CrewAgentRecord[]> = {
65
+ running: agents.filter((agent) => agent.status === "running"),
66
+ queued: agents.filter((agent) => agent.status === "queued"),
67
+ recent: agents.filter((agent) => agent.status !== "running" && agent.status !== "queued"),
68
+ };
69
+ const lines = [
70
+ `Crew agents for ${manifest.runId}`,
71
+ `Run status: ${manifest.status}`,
72
+ `Counts: running=${groups.running.length}, queued=${groups.queued.length}, recent=${groups.recent.length}`,
73
+ "",
74
+ "## Running",
75
+ ...(groups.running.length ? groups.running.map(agentLine) : ["- (none)"]),
76
+ "",
77
+ "## Queued",
78
+ ...(groups.queued.length ? groups.queued.map(agentLine) : ["- (none)"]),
79
+ "",
80
+ "## Recent",
81
+ ...(groups.recent.length ? groups.recent.slice(-10).map(agentLine) : ["- (none)"]),
82
+ ];
83
+ return { text: lines.join("\n"), groups };
84
+ }
85
+
86
+ export function readAgentOutput(manifest: TeamRunManifest, taskId: string, maxBytes?: number): TextTailResult {
87
+ return readTextTail(agentOutputPath(manifest, taskId), maxBytes);
88
+ }
@@ -2,10 +2,12 @@ import { spawn } 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";
5
- import { buildPiWorkerArgs, cleanupTempDir } from "./pi-args.ts";
5
+ import { buildPiWorkerArgs, checkCrewDepth, cleanupTempDir } from "./pi-args.ts";
6
6
  import { getPiSpawnCommand } from "./pi-spawn.ts";
7
7
 
8
8
  const POST_EXIT_STDIO_GUARD_MS = 3000;
9
+ const FINAL_DRAIN_MS = 5000;
10
+ const HARD_KILL_MS = 3000;
9
11
 
10
12
  export interface ChildPiRunInput {
11
13
  cwd: string;
@@ -16,6 +18,9 @@ export interface ChildPiRunInput {
16
18
  transcriptPath?: string;
17
19
  onStdoutLine?: (line: string) => void;
18
20
  onJsonEvent?: (event: unknown) => void;
21
+ maxDepth?: number;
22
+ finalDrainMs?: number;
23
+ hardKillMs?: number;
19
24
  }
20
25
 
21
26
  export interface ChildPiRunResult {
@@ -71,7 +76,25 @@ function observeStdoutChunk(input: ChildPiRunInput, text: string): void {
71
76
  observer.flush();
72
77
  }
73
78
 
79
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
80
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
81
+ }
82
+
83
+ function isFinalAssistantEvent(event: unknown): boolean {
84
+ const obj = asRecord(event);
85
+ if (!obj || obj.type !== "message_end") return false;
86
+ const message = asRecord(obj.message);
87
+ const role = message?.role;
88
+ if (role !== undefined && role !== "assistant") return false;
89
+ const stopReason = typeof message?.stopReason === "string" ? message.stopReason : typeof obj.stopReason === "string" ? obj.stopReason : undefined;
90
+ if (stopReason !== undefined && stopReason !== "stop") return false;
91
+ const content = Array.isArray(message?.content) ? message.content : [];
92
+ return !content.some((part) => asRecord(part)?.type === "toolCall");
93
+ }
94
+
74
95
  export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResult> {
96
+ const depth = checkCrewDepth(input.maxDepth);
97
+ if (depth.blocked) return { exitCode: 1, stdout: "", stderr: `pi-crew depth guard blocked child worker: depth ${depth.depth} >= max ${depth.maxDepth}` };
75
98
  const mock = process.env.PI_TEAMS_MOCK_CHILD_PI;
76
99
  if (mock) {
77
100
  if (mock === "success") {
@@ -87,7 +110,7 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
87
110
  if (mock === "retryable-failure") return { exitCode: 1, stdout: "", stderr: "rate limit: mock failure" };
88
111
  return { exitCode: 1, stdout: "", stderr: `mock failure: ${mock}` };
89
112
  }
90
- const built = buildPiWorkerArgs({ task: input.task, agent: input.agent, model: input.model, sessionEnabled: false });
113
+ const built = buildPiWorkerArgs({ task: input.task, agent: input.agent, model: input.model, sessionEnabled: false, maxDepth: input.maxDepth });
91
114
  const spawnSpec = getPiSpawnCommand(built.args);
92
115
  try {
93
116
  return await new Promise<ChildPiRunResult>((resolve) => {
@@ -99,13 +122,44 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
99
122
  let stdout = "";
100
123
  let stderr = "";
101
124
  let settled = false;
125
+ let childExited = false;
102
126
  let postExitGuard: NodeJS.Timeout | undefined;
103
- const lineObserver = new ChildPiLineObserver(input);
127
+ let finalDrainTimer: NodeJS.Timeout | undefined;
128
+ let hardKillTimer: NodeJS.Timeout | undefined;
129
+ const finalDrainMs = input.finalDrainMs ?? FINAL_DRAIN_MS;
130
+ const hardKillMs = input.hardKillMs ?? HARD_KILL_MS;
131
+ let forcedFinalDrain = false;
132
+ const lineObserver = new ChildPiLineObserver({
133
+ ...input,
134
+ onJsonEvent: (event) => {
135
+ input.onJsonEvent?.(event);
136
+ if (!isFinalAssistantEvent(event) || childExited || settled || finalDrainTimer) return;
137
+ finalDrainTimer = setTimeout(() => {
138
+ if (settled || childExited) return;
139
+ forcedFinalDrain = true;
140
+ try { child.kill(process.platform === "win32" ? undefined : "SIGTERM"); } catch {}
141
+ hardKillTimer = setTimeout(() => {
142
+ if (settled || childExited) return;
143
+ try { child.kill(process.platform === "win32" ? undefined : "SIGKILL"); } catch {}
144
+ }, hardKillMs);
145
+ hardKillTimer.unref?.();
146
+ }, finalDrainMs);
147
+ finalDrainTimer.unref?.();
148
+ },
149
+ });
150
+
151
+ const clearFinalDrainTimers = (): void => {
152
+ if (finalDrainTimer) clearTimeout(finalDrainTimer);
153
+ if (hardKillTimer) clearTimeout(hardKillTimer);
154
+ finalDrainTimer = undefined;
155
+ hardKillTimer = undefined;
156
+ };
104
157
 
105
158
  const settle = (result: ChildPiRunResult): void => {
106
159
  if (settled) return;
107
160
  settled = true;
108
161
  if (postExitGuard) clearTimeout(postExitGuard);
162
+ clearFinalDrainTimers();
109
163
  lineObserver.flush();
110
164
  input.signal?.removeEventListener("abort", abort);
111
165
  cleanupTempDir(built.tempDir);
@@ -133,6 +187,8 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
133
187
  settle({ exitCode: null, stdout, stderr, error: error.message });
134
188
  });
135
189
  child.on("exit", () => {
190
+ childExited = true;
191
+ clearFinalDrainTimers();
136
192
  postExitGuard = setTimeout(() => {
137
193
  child.stdout?.destroy();
138
194
  child.stderr?.destroy();
@@ -140,7 +196,7 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
140
196
  postExitGuard.unref?.();
141
197
  });
142
198
  child.on("close", (exitCode) => {
143
- settle({ exitCode, stdout, stderr });
199
+ settle({ exitCode, stdout, stderr, ...(forcedFinalDrain && !stderr.trim() ? { error: `Child Pi did not exit within ${finalDrainMs}ms after final assistant message; termination was requested.` } : {}) });
144
200
  });
145
201
  });
146
202
  } finally {
@@ -52,17 +52,55 @@ export function readCrewAgentStatus(manifest: TeamRunManifest, taskOrAgentId: st
52
52
  return readJsonFile<CrewAgentRecord>(agentStatusPath(manifest, taskId));
53
53
  }
54
54
 
55
+ function nextAgentEventSeq(filePath: string): number {
56
+ if (!fs.existsSync(filePath)) return 1;
57
+ let max = 0;
58
+ for (const line of fs.readFileSync(filePath, "utf-8").split(/\r?\n/)) {
59
+ if (!line.trim()) continue;
60
+ try {
61
+ const parsed = JSON.parse(line) as { seq?: unknown };
62
+ if (typeof parsed.seq === "number" && Number.isFinite(parsed.seq)) max = Math.max(max, parsed.seq);
63
+ else max += 1;
64
+ } catch {
65
+ max += 1;
66
+ }
67
+ }
68
+ return max + 1;
69
+ }
70
+
55
71
  export function appendCrewAgentEvent(manifest: TeamRunManifest, taskId: string, event: unknown): void {
56
72
  fs.mkdirSync(agentStateDir(manifest, taskId), { recursive: true });
57
- fs.appendFileSync(agentEventsPath(manifest, taskId), `${JSON.stringify({ time: new Date().toISOString(), event })}\n`, "utf-8");
73
+ const filePath = agentEventsPath(manifest, taskId);
74
+ fs.appendFileSync(filePath, `${JSON.stringify({ seq: nextAgentEventSeq(filePath), time: new Date().toISOString(), event })}\n`, "utf-8");
75
+ }
76
+
77
+ export interface CrewAgentEventCursorOptions {
78
+ sinceSeq?: number;
79
+ limit?: number;
58
80
  }
59
81
 
60
82
  export function readCrewAgentEvents(manifest: TeamRunManifest, taskId: string): unknown[] {
83
+ return readCrewAgentEventsCursor(manifest, taskId).events;
84
+ }
85
+
86
+ export function readCrewAgentEventsCursor(manifest: TeamRunManifest, taskId: string, options: CrewAgentEventCursorOptions = {}): { path: string; events: unknown[]; nextSeq: number; total: number } {
61
87
  const filePath = agentEventsPath(manifest, taskId);
62
- if (!fs.existsSync(filePath)) return [];
63
- return fs.readFileSync(filePath, "utf-8").split(/\r?\n/).filter(Boolean).map((line) => {
64
- try { return JSON.parse(line) as unknown; } catch { return { raw: line }; }
88
+ if (!fs.existsSync(filePath)) return { path: filePath, events: [], nextSeq: options.sinceSeq ?? 0, total: 0 };
89
+ const sinceSeq = typeof options.sinceSeq === "number" && Number.isInteger(options.sinceSeq) && options.sinceSeq >= 0 ? options.sinceSeq : 0;
90
+ const limit = typeof options.limit === "number" && Number.isInteger(options.limit) && options.limit >= 0 ? options.limit : undefined;
91
+ const parsed = fs.readFileSync(filePath, "utf-8").split(/\r?\n/).filter(Boolean).map((line, index) => {
92
+ try {
93
+ const event = JSON.parse(line) as Record<string, unknown>;
94
+ if (typeof event.seq !== "number") event.seq = index + 1;
95
+ return event;
96
+ } catch {
97
+ return { seq: index + 1, raw: line };
98
+ }
65
99
  });
100
+ const filtered = parsed.filter((event) => typeof event.seq === "number" && event.seq > sinceSeq);
101
+ const events = limit !== undefined ? filtered.slice(0, limit) : filtered;
102
+ const returnedMaxSeq = events.reduce((max, event) => typeof event.seq === "number" ? Math.max(max, event.seq) : max, sinceSeq);
103
+ return { path: filePath, events, nextSeq: returnedMaxSeq, total: filtered.length };
66
104
  }
67
105
 
68
106
  export function appendCrewAgentOutput(manifest: TeamRunManifest, taskId: string, text: string): void {
@@ -21,6 +21,7 @@ export interface CrewAgentProgress {
21
21
  durationMs?: number;
22
22
  lastActivityAt?: string;
23
23
  activityState?: "active" | "needs_attention" | "stale";
24
+ failedTool?: string;
24
25
  }
25
26
 
26
27
  export interface CrewAgentRecord {
@@ -0,0 +1,82 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { appendEvent } from "../state/event-log.ts";
4
+ import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
5
+ import { checkProcessLiveness, isActiveRunStatus } from "./process-status.ts";
6
+ import { readCrewAgents } from "./crew-agent-records.ts";
7
+
8
+ export type ForegroundControlRequestType = "interrupt" | "status";
9
+
10
+ export interface ForegroundControlStatus {
11
+ runId: string;
12
+ status: TeamRunManifest["status"];
13
+ active: boolean;
14
+ asyncPid?: number;
15
+ asyncAlive?: boolean;
16
+ runningTasks: string[];
17
+ runningAgents: string[];
18
+ controlPath: string;
19
+ lastRequest?: ForegroundControlRequest;
20
+ }
21
+
22
+ export interface ForegroundControlRequest {
23
+ id: string;
24
+ type: ForegroundControlRequestType;
25
+ createdAt: string;
26
+ reason: string;
27
+ acknowledged: boolean;
28
+ }
29
+
30
+ export function foregroundControlPath(manifest: TeamRunManifest): string {
31
+ return path.join(manifest.stateRoot, "foreground-control.json");
32
+ }
33
+
34
+ function readLastRequest(controlPath: string): ForegroundControlRequest | undefined {
35
+ if (!fs.existsSync(controlPath)) return undefined;
36
+ try {
37
+ const parsed = JSON.parse(fs.readFileSync(controlPath, "utf-8")) as { requests?: ForegroundControlRequest[] };
38
+ return parsed.requests?.at(-1);
39
+ } catch {
40
+ return undefined;
41
+ }
42
+ }
43
+
44
+ export function readForegroundControlStatus(manifest: TeamRunManifest, tasks: TeamTaskState[]): ForegroundControlStatus {
45
+ const controlPath = foregroundControlPath(manifest);
46
+ const asyncAlive = manifest.async?.pid !== undefined ? checkProcessLiveness(manifest.async.pid).alive : undefined;
47
+ return {
48
+ runId: manifest.runId,
49
+ status: manifest.status,
50
+ active: isActiveRunStatus(manifest.status),
51
+ asyncPid: manifest.async?.pid,
52
+ asyncAlive,
53
+ runningTasks: tasks.filter((task) => task.status === "running").map((task) => task.id),
54
+ runningAgents: readCrewAgents(manifest).filter((agent) => agent.status === "running").map((agent) => agent.id),
55
+ controlPath,
56
+ lastRequest: readLastRequest(controlPath),
57
+ };
58
+ }
59
+
60
+ export function writeForegroundInterruptRequest(manifest: TeamRunManifest, reason = "User requested foreground interrupt."): ForegroundControlRequest {
61
+ const controlPath = foregroundControlPath(manifest);
62
+ let requests: ForegroundControlRequest[] = [];
63
+ if (fs.existsSync(controlPath)) {
64
+ try {
65
+ const parsed = JSON.parse(fs.readFileSync(controlPath, "utf-8")) as { requests?: ForegroundControlRequest[] };
66
+ requests = Array.isArray(parsed.requests) ? parsed.requests : [];
67
+ } catch {
68
+ requests = [];
69
+ }
70
+ }
71
+ const request: ForegroundControlRequest = {
72
+ id: `fg_${Date.now().toString(36)}_${Math.random().toString(16).slice(2, 10)}`,
73
+ type: "interrupt",
74
+ createdAt: new Date().toISOString(),
75
+ reason,
76
+ acknowledged: false,
77
+ };
78
+ fs.mkdirSync(path.dirname(controlPath), { recursive: true });
79
+ fs.writeFileSync(controlPath, `${JSON.stringify({ requests: [...requests, request] }, null, 2)}\n`, "utf-8");
80
+ appendEvent(manifest.eventsPath, { type: "foreground.interrupt_requested", runId: manifest.runId, message: reason, data: { requestId: request.id, controlPath } });
81
+ return request;
82
+ }
@@ -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
  };
@@ -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 } 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";
@@ -28,6 +29,7 @@ export interface TaskRunnerInput {
28
29
  agent: AgentConfig;
29
30
  signal?: AbortSignal;
30
31
  executeWorkers: boolean;
32
+ limits?: CrewLimitsConfig;
31
33
  dependencyContextText?: string;
32
34
  }
33
35
 
@@ -190,6 +192,9 @@ function applyAgentProgressEvent(progress: CrewAgentProgress, event: unknown, st
190
192
  next.currentToolArgs = undefined;
191
193
  next.currentToolStartedAt = undefined;
192
194
  }
195
+ if ((obj?.type === "tool_execution_error" || obj?.type === "tool_execution_failed") && next.currentTool) {
196
+ next.failedTool = next.currentTool;
197
+ }
193
198
  const usage = eventUsage(event);
194
199
  if (usage) {
195
200
  next.tokens = (usage.input ?? 0) + (usage.output ?? 0);
@@ -269,6 +274,7 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
269
274
  model,
270
275
  signal: input.signal,
271
276
  transcriptPath,
277
+ maxDepth: input.limits?.maxTaskDepth,
272
278
  onStdoutLine: (line) => appendCrewAgentOutput(manifest, task.id, line),
273
279
  onJsonEvent: (event) => {
274
280
  appendCrewAgentEvent(manifest, task.id, event);
@@ -349,7 +355,7 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
349
355
  modelAttempts,
350
356
  usage: parsedOutput?.usage,
351
357
  jsonEvents: parsedOutput?.jsonEvents,
352
- agentProgress: task.agentProgress,
358
+ agentProgress: error && task.agentProgress?.currentTool ? { ...task.agentProgress, failedTool: task.agentProgress.currentTool } : task.agentProgress,
353
359
  error,
354
360
  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."),
355
361
  promptArtifact,
@@ -174,7 +174,7 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
174
174
  const results = await Promise.all(readyBatch.map((task) => {
175
175
  const step = findStep(input.workflow, task);
176
176
  const agent = findAgent(input.agents, task);
177
- return runTeamTask({ manifest, tasks, task, step, agent, signal: input.signal, executeWorkers: input.executeWorkers });
177
+ return runTeamTask({ manifest, tasks, task, step, agent, signal: input.signal, executeWorkers: input.executeWorkers, limits: input.limits });
178
178
  }));
179
179
  manifest = { ...results.at(-1)!.manifest, artifacts: mergeArtifacts([manifest.artifacts, ...results.map((item) => item.manifest.artifacts)].flat()) };
180
180
  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;