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.
@@ -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,15 @@ 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";
44
+ import { listLiveAgents, resumeLiveAgent, steerLiveAgent, stopLiveAgent } from "../runtime/live-agent-manager.ts";
45
+ import { appendLiveAgentControlRequest } from "../runtime/live-agent-control.ts";
46
+ import { liveControlRealtimeMessage, publishLiveControlRealtime } from "../runtime/live-control-realtime.ts";
42
47
 
43
48
  export interface TeamToolDetails {
44
49
  action: string;
@@ -47,7 +52,11 @@ export interface TeamToolDetails {
47
52
  artifactsRoot?: string;
48
53
  }
49
54
 
50
- type TeamContext = Pick<ExtensionContext, "cwd"> & Partial<Pick<ExtensionContext, "model">>;
55
+ type TeamContext = Pick<ExtensionContext, "cwd"> & Partial<Pick<ExtensionContext, "model">> & {
56
+ modelRegistry?: unknown;
57
+ sessionManager?: { getBranch?: () => unknown[] };
58
+ events?: { emit?: (event: string, data: unknown) => void };
59
+ };
51
60
 
52
61
  function result(text: string, details: TeamToolDetails, isError = false): PiTeamsToolResult {
53
62
  return toolResult(text, details, isError);
@@ -57,6 +66,29 @@ function formatScoped(name: string, source: string, description: string): string
57
66
  return `- ${name} (${source}): ${description}`;
58
67
  }
59
68
 
69
+ function extractTextContent(content: unknown): string {
70
+ if (typeof content === "string") return content;
71
+ if (!Array.isArray(content)) return "";
72
+ return content.map((part) => part && typeof part === "object" && !Array.isArray(part) && typeof (part as { text?: unknown }).text === "string" ? (part as { text: string }).text : "").filter(Boolean).join("\n");
73
+ }
74
+
75
+ function buildParentContext(ctx: TeamContext): string | undefined {
76
+ const branch = ctx.sessionManager?.getBranch?.();
77
+ if (!Array.isArray(branch) || branch.length === 0) return undefined;
78
+ const parts: string[] = [];
79
+ for (const entry of branch.slice(-20)) {
80
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
81
+ const record = entry as { type?: unknown; message?: unknown; summary?: unknown };
82
+ if (record.type === "compaction" && typeof record.summary === "string") parts.push(`[Summary]: ${record.summary}`);
83
+ const message = record.message && typeof record.message === "object" && !Array.isArray(record.message) ? record.message as { role?: unknown; content?: unknown } : undefined;
84
+ if (!message || (message.role !== "user" && message.role !== "assistant")) continue;
85
+ const text = extractTextContent(message.content).trim();
86
+ if (text) parts.push(`[${message.role === "user" ? "User" : "Assistant"}]: ${text}`);
87
+ }
88
+ if (!parts.length) return undefined;
89
+ return [`# Parent Conversation Context`, "The following context was inherited from the parent Pi session. Treat it as reference-only.", "", parts.join("\n\n")].join("\n");
90
+ }
91
+
60
92
  export function handleList(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
61
93
  const resource = params.resource;
62
94
  const blocks: string[] = [];
@@ -134,6 +166,18 @@ function commandExists(command: string, args: string[]): { ok: boolean; detail:
134
166
  return { ok: false, detail: output.error?.message ?? firstOutputLine(output.stdout, output.stderr) };
135
167
  }
136
168
 
169
+ function effectiveRunConfig(base: PiTeamsConfig, rawOverride: unknown): PiTeamsConfig {
170
+ const patch = configPatchFromConfig(rawOverride);
171
+ return {
172
+ ...base,
173
+ ...patch,
174
+ limits: patch.limits ? { ...(base.limits ?? {}), ...patch.limits } : base.limits,
175
+ runtime: patch.runtime ? { ...(base.runtime ?? {}), ...patch.runtime } : base.runtime,
176
+ control: patch.control ? { ...(base.control ?? {}), ...patch.control } : base.control,
177
+ worktree: patch.worktree ? { ...(base.worktree ?? {}), ...patch.worktree } : base.worktree,
178
+ };
179
+ }
180
+
137
181
  function piCommandExists(): { ok: boolean; detail: string } {
138
182
  const spec = getPiSpawnCommand(["--version"]);
139
183
  const output = spawnSync(spec.command, spec.args, { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] });
@@ -259,9 +303,10 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
259
303
  return result(text, { action: "run", status: "ok", runId: updatedManifest.runId, artifactsRoot: updatedManifest.artifactsRoot });
260
304
  }
261
305
 
262
- const runtime = await resolveCrewRuntime(loadedConfig.config);
306
+ const runtime = await resolveCrewRuntime(effectiveRunConfig(loadedConfig.config, params.config));
263
307
  const executeWorkers = runtime.kind === "child-process";
264
- const executed = await executeTeamRun({ manifest: updatedManifest, tasks, team, workflow, agents, executeWorkers, limits: loadedConfig.config.limits, runtime, runtimeConfig: loadedConfig.config.runtime });
308
+ const executedConfig = effectiveRunConfig(loadedConfig.config, params.config);
309
+ const executed = await executeTeamRun({ manifest: updatedManifest, tasks, team, workflow, agents, executeWorkers, limits: executedConfig.limits, runtime, runtimeConfig: executedConfig.runtime, parentContext: buildParentContext(ctx), parentModel: ctx.model, modelRegistry: ctx.modelRegistry });
265
310
  const text = [
266
311
  `Created pi-crew run ${executed.manifest.runId}.`,
267
312
  `Team: ${team.name}`,
@@ -272,9 +317,11 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
272
317
  `Artifacts: ${executed.manifest.artifactsRoot}`,
273
318
  "",
274
319
  `Runtime: ${runtime.kind}${runtime.fallback ? ` (fallback from ${runtime.requestedMode})` : ""}${runtime.reason ? ` - ${runtime.reason}` : ""}`,
275
- executeWorkers
320
+ runtime.kind === "child-process"
276
321
  ? "Child Pi worker execution was enabled."
277
- : "Safe scaffold mode: child Pi workers were not launched. Set PI_TEAMS_EXECUTE_WORKERS=1 or runtime.mode=child-process to enable real worker execution.",
322
+ : runtime.kind === "live-session"
323
+ ? "Experimental live-session worker execution was enabled."
324
+ : "Safe scaffold mode: child Pi workers were not launched. Set PI_CREW_EXECUTE_WORKERS=1, PI_TEAMS_EXECUTE_WORKERS=1, or runtime.mode=child-process to enable real worker execution.",
278
325
  ].join("\n");
279
326
  return result(text, { action: "run", status: executed.manifest.status === "failed" ? "error" : "ok", runId: executed.manifest.runId, artifactsRoot: executed.manifest.artifactsRoot }, executed.manifest.status === "failed");
280
327
  }
@@ -382,7 +429,7 @@ export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext
382
429
  const loadedConfig = loadConfig(ctx.cwd);
383
430
  const runtime = await resolveCrewRuntime(loadedConfig.config);
384
431
  const executeWorkers = runtime.kind === "child-process";
385
- 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 });
432
+ 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 });
386
433
  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");
387
434
  });
388
435
  }
@@ -458,12 +505,39 @@ function autonomousPatchFromConfig(config: unknown): PiTeamsAutonomousConfig {
458
505
  function configPatchFromConfig(config: unknown): PiTeamsConfig {
459
506
  const cfg = configRecord(config);
460
507
  const control = configRecord(cfg.control);
508
+ const runtime = configRecord(cfg.runtime);
509
+ const limits = configRecord(cfg.limits);
510
+ const worktree = configRecord(cfg.worktree);
461
511
  return {
462
512
  asyncByDefault: typeof cfg.asyncByDefault === "boolean" ? cfg.asyncByDefault : undefined,
463
513
  executeWorkers: typeof cfg.executeWorkers === "boolean" ? cfg.executeWorkers : undefined,
464
514
  notifierIntervalMs: typeof cfg.notifierIntervalMs === "number" && Number.isFinite(cfg.notifierIntervalMs) ? cfg.notifierIntervalMs : undefined,
465
515
  requireCleanWorktreeLeader: typeof cfg.requireCleanWorktreeLeader === "boolean" ? cfg.requireCleanWorktreeLeader : undefined,
466
516
  autonomous: typeof cfg.autonomous === "object" && cfg.autonomous !== null && !Array.isArray(cfg.autonomous) ? autonomousPatchFromConfig(cfg.autonomous) : undefined,
517
+ limits: Object.keys(limits).length > 0 ? {
518
+ maxConcurrentWorkers: typeof limits.maxConcurrentWorkers === "number" && Number.isInteger(limits.maxConcurrentWorkers) && limits.maxConcurrentWorkers > 0 ? limits.maxConcurrentWorkers : undefined,
519
+ maxTaskDepth: typeof limits.maxTaskDepth === "number" && Number.isInteger(limits.maxTaskDepth) && limits.maxTaskDepth > 0 ? limits.maxTaskDepth : undefined,
520
+ maxChildrenPerTask: typeof limits.maxChildrenPerTask === "number" && Number.isInteger(limits.maxChildrenPerTask) && limits.maxChildrenPerTask > 0 ? limits.maxChildrenPerTask : undefined,
521
+ maxRunMinutes: typeof limits.maxRunMinutes === "number" && Number.isInteger(limits.maxRunMinutes) && limits.maxRunMinutes > 0 ? limits.maxRunMinutes : undefined,
522
+ maxRetriesPerTask: typeof limits.maxRetriesPerTask === "number" && Number.isInteger(limits.maxRetriesPerTask) && limits.maxRetriesPerTask > 0 ? limits.maxRetriesPerTask : undefined,
523
+ maxTasksPerRun: typeof limits.maxTasksPerRun === "number" && Number.isInteger(limits.maxTasksPerRun) && limits.maxTasksPerRun > 0 ? limits.maxTasksPerRun : undefined,
524
+ heartbeatStaleMs: typeof limits.heartbeatStaleMs === "number" && Number.isInteger(limits.heartbeatStaleMs) && limits.heartbeatStaleMs > 0 ? limits.heartbeatStaleMs : undefined,
525
+ } : undefined,
526
+ runtime: Object.keys(runtime).length > 0 ? {
527
+ mode: runtime.mode === "auto" || runtime.mode === "scaffold" || runtime.mode === "child-process" || runtime.mode === "live-session" ? runtime.mode : undefined,
528
+ preferLiveSession: typeof runtime.preferLiveSession === "boolean" ? runtime.preferLiveSession : undefined,
529
+ allowChildProcessFallback: typeof runtime.allowChildProcessFallback === "boolean" ? runtime.allowChildProcessFallback : undefined,
530
+ maxTurns: typeof runtime.maxTurns === "number" && Number.isInteger(runtime.maxTurns) && runtime.maxTurns > 0 ? runtime.maxTurns : undefined,
531
+ graceTurns: typeof runtime.graceTurns === "number" && Number.isInteger(runtime.graceTurns) && runtime.graceTurns > 0 ? runtime.graceTurns : undefined,
532
+ inheritContext: typeof runtime.inheritContext === "boolean" ? runtime.inheritContext : undefined,
533
+ promptMode: runtime.promptMode === "replace" || runtime.promptMode === "append" ? runtime.promptMode : undefined,
534
+ groupJoin: runtime.groupJoin === "off" || runtime.groupJoin === "group" || runtime.groupJoin === "smart" ? runtime.groupJoin : undefined,
535
+ } : undefined,
536
+ worktree: Object.keys(worktree).length > 0 ? {
537
+ setupHook: typeof worktree.setupHook === "string" && worktree.setupHook.trim() ? worktree.setupHook.trim() : undefined,
538
+ setupHookTimeoutMs: typeof worktree.setupHookTimeoutMs === "number" && Number.isInteger(worktree.setupHookTimeoutMs) && worktree.setupHookTimeoutMs > 0 ? worktree.setupHookTimeoutMs : undefined,
539
+ linkNodeModules: typeof worktree.linkNodeModules === "boolean" ? worktree.linkNodeModules : undefined,
540
+ } : undefined,
467
541
  control: Object.keys(control).length > 0 ? {
468
542
  enabled: typeof control.enabled === "boolean" ? control.enabled : undefined,
469
543
  needsAttentionAfterMs: typeof control.needsAttentionAfterMs === "number" && Number.isInteger(control.needsAttentionAfterMs) && control.needsAttentionAfterMs > 0 ? control.needsAttentionAfterMs : undefined,
@@ -575,7 +649,12 @@ export async function handleApi(params: TeamToolParamsValue, ctx: TeamContext):
575
649
  return result(JSON.stringify(task, null, 2), { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
576
650
  }
577
651
  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 });
652
+ const sinceSeq = typeof cfg.sinceSeq === "number" ? cfg.sinceSeq : undefined;
653
+ const limit = typeof cfg.limit === "number" ? cfg.limit : undefined;
654
+ const payload = sinceSeq !== undefined || limit !== undefined
655
+ ? readEventsCursor(loaded.manifest.eventsPath, { sinceSeq, limit })
656
+ : { events: readEvents(loaded.manifest.eventsPath), nextSeq: undefined, total: undefined };
657
+ return result(JSON.stringify(payload, null, 2), { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
579
658
  }
580
659
  if (operation === "runtime-capabilities") {
581
660
  const loadedConfig = loadConfig(ctx.cwd);
@@ -604,18 +683,43 @@ export async function handleApi(params: TeamToolParamsValue, ctx: TeamContext):
604
683
  }
605
684
  if (operation === "read-agent-events") {
606
685
  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 });
686
+ const agents = readCrewAgents(loaded.manifest);
687
+ const agent = agentId ? agents.find((item) => item.id === agentId || item.taskId === agentId) : agents[0];
688
+ 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);
689
+ const sinceSeq = typeof cfg.sinceSeq === "number" ? cfg.sinceSeq : undefined;
690
+ const limit = typeof cfg.limit === "number" ? cfg.limit : undefined;
691
+ const payload = sinceSeq !== undefined || limit !== undefined
692
+ ? readCrewAgentEventsCursor(loaded.manifest, agent.taskId, { sinceSeq, limit })
693
+ : { path: agentEventsPath(loaded.manifest, agent.taskId), events: readCrewAgentEvents(loaded.manifest, agent.taskId) };
694
+ return result(JSON.stringify(payload, null, 2), { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
610
695
  }
611
696
  if (operation === "read-agent-transcript") {
612
697
  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);
698
+ const agents = readCrewAgents(loaded.manifest);
699
+ const agent = agentId ? agents.find((item) => item.id === agentId || item.taskId === agentId) : agents[0];
700
+ 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
701
  const transcriptPath = agent.transcriptPath && fs.existsSync(agent.transcriptPath) ? agent.transcriptPath : agentOutputPath(loaded.manifest, agent.taskId);
616
702
  const text = fs.existsSync(transcriptPath) ? fs.readFileSync(transcriptPath, "utf-8") : "";
617
703
  return result(text || `(no transcript at ${transcriptPath})`, { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
618
704
  }
705
+ if (operation === "read-agent-output") {
706
+ const agentId = typeof cfg.agentId === "string" ? cfg.agentId : undefined;
707
+ const agents = readCrewAgents(loaded.manifest);
708
+ const agent = agentId ? agents.find((item) => item.id === agentId || item.taskId === agentId) : agents[0];
709
+ 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);
710
+ const maxBytes = typeof cfg.maxBytes === "number" ? cfg.maxBytes : undefined;
711
+ return result(JSON.stringify(readAgentOutput(loaded.manifest, agent.taskId, maxBytes), null, 2), { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
712
+ }
713
+ if (operation === "agent-dashboard") {
714
+ return result(buildAgentDashboard(loaded.manifest).text, { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
715
+ }
716
+ if (operation === "foreground-status") {
717
+ return result(JSON.stringify(readForegroundControlStatus(loaded.manifest, loaded.tasks), null, 2), { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
718
+ }
719
+ if (operation === "foreground-interrupt") {
720
+ const reason = typeof cfg.reason === "string" && cfg.reason.trim() ? cfg.reason.trim() : undefined;
721
+ return result(JSON.stringify(writeForegroundInterruptRequest(loaded.manifest, reason), null, 2), { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
722
+ }
619
723
  if (operation === "nudge-agent") {
620
724
  const agentId = typeof cfg.agentId === "string" ? cfg.agentId : undefined;
621
725
  const agent = readCrewAgents(loaded.manifest).find((item) => item.id === agentId || item.taskId === agentId);
@@ -625,12 +729,34 @@ export async function handleApi(params: TeamToolParamsValue, ctx: TeamContext):
625
729
  appendEvent(loaded.manifest.eventsPath, { type: "agent.nudged", runId: loaded.manifest.runId, taskId: agent.taskId, message: messageText, data: { agentId: agent.id, mailboxMessageId: message.id } });
626
730
  return result(JSON.stringify({ agentId: agent.id, mailboxMessage: message }, null, 2), { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
627
731
  }
732
+ if (operation === "list-live-agents") {
733
+ return result(JSON.stringify(listLiveAgents().filter((agent) => agent.runId === loaded.manifest.runId), null, 2), { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
734
+ }
628
735
  if (operation === "steer-agent" || operation === "stop-agent" || operation === "resume-agent" || operation === "interrupt-agent") {
629
- const runtime = await resolveCrewRuntime(loadConfig(ctx.cwd).config);
630
- if (!runtime.steer && operation === "steer-agent") return result(`Runtime '${runtime.kind}' does not support live steering. Use nudge-agent for mailbox-based child-process coordination.`, { action: "api", status: "error", runId: loaded.manifest.runId }, true);
631
- if (!runtime.resume && operation === "resume-agent") return result(`Runtime '${runtime.kind}' does not support live resume.`, { action: "api", status: "error", runId: loaded.manifest.runId }, true);
632
- if (operation === "interrupt-agent" && runtime.kind !== "live-session") return result(`Runtime '${runtime.kind}' does not expose per-agent interrupt yet. Use nudge-agent or cancel the run.`, { action: "api", status: "error", runId: loaded.manifest.runId }, true);
633
- return result(`Operation '${operation}' is reserved for live-session runtime and is not active for this run yet.`, { action: "api", status: "error", runId: loaded.manifest.runId }, true);
736
+ const agentId = typeof cfg.agentId === "string" ? cfg.agentId : undefined;
737
+ if (!agentId) return result(`API ${operation} requires config.agentId.`, { action: "api", status: "error", runId: loaded.manifest.runId }, true);
738
+ const message = typeof cfg.message === "string" && cfg.message.trim() ? cfg.message.trim() : undefined;
739
+ const prompt = typeof cfg.prompt === "string" && cfg.prompt.trim() ? cfg.prompt.trim() : message;
740
+ try {
741
+ if (operation === "steer-agent") return result(JSON.stringify(await steerLiveAgent(agentId, message ?? "Please report current status and wrap up if possible."), null, 2), { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
742
+ if (operation === "resume-agent") {
743
+ if (!prompt) return result("API resume-agent requires config.prompt or config.message.", { action: "api", status: "error", runId: loaded.manifest.runId }, true);
744
+ return result(JSON.stringify(await resumeLiveAgent(agentId, prompt), null, 2), { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
745
+ }
746
+ return result(JSON.stringify(await stopLiveAgent(agentId), null, 2), { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
747
+ } catch (error) {
748
+ const agent = readCrewAgents(loaded.manifest).find((item) => item.id === agentId || item.taskId === agentId);
749
+ if (!agent) {
750
+ const err = error instanceof Error ? error.message : String(error);
751
+ return result(err, { action: "api", status: "error", runId: loaded.manifest.runId }, true);
752
+ }
753
+ if (operation === "resume-agent" && !prompt) return result("API resume-agent requires config.prompt or config.message.", { action: "api", status: "error", runId: loaded.manifest.runId }, true);
754
+ const request = appendLiveAgentControlRequest(loaded.manifest, { taskId: agent.taskId, agentId: agent.id, operation: operation === "resume-agent" ? "resume" : operation === "steer-agent" ? "steer" : "stop", message: operation === "resume-agent" ? prompt : message });
755
+ publishLiveControlRealtime(request);
756
+ ctx.events?.emit?.("pi-crew:live-control", liveControlRealtimeMessage(request));
757
+ appendEvent(loaded.manifest.eventsPath, { type: "agent.control.queued", runId: loaded.manifest.runId, taskId: agent.taskId, message: `Queued ${request.operation} control request for live agent.`, data: { request, realtime: true } });
758
+ return result(JSON.stringify({ queued: true, request }, null, 2), { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
759
+ }
634
760
  }
635
761
  if (operation === "read-mailbox") {
636
762
  const direction = cfg.direction === "inbox" || cfg.direction === "outbox" ? cfg.direction as MailboxDirection : undefined;
@@ -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,72 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+
5
+ export type AgentMemoryScope = "user" | "project" | "local";
6
+ const MAX_MEMORY_LINES = 200;
7
+
8
+ export function isUnsafeMemoryName(name: string): boolean {
9
+ return !name || name.length > 128 || !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name);
10
+ }
11
+
12
+ export function isSymlink(filePath: string): boolean {
13
+ try {
14
+ return fs.lstatSync(filePath).isSymbolicLink();
15
+ } catch {
16
+ return false;
17
+ }
18
+ }
19
+
20
+ export function safeReadMemoryFile(filePath: string): string | undefined {
21
+ if (!fs.existsSync(filePath) || isSymlink(filePath)) return undefined;
22
+ try {
23
+ return fs.readFileSync(filePath, "utf-8");
24
+ } catch {
25
+ return undefined;
26
+ }
27
+ }
28
+
29
+ export function resolveMemoryDir(agentName: string, scope: AgentMemoryScope, cwd: string): string {
30
+ if (isUnsafeMemoryName(agentName)) throw new Error(`Unsafe agent name for memory directory: ${agentName}`);
31
+ if (scope === "user") return path.join(os.homedir(), ".pi", "agent-memory", agentName);
32
+ if (scope === "project") return path.join(cwd, ".pi", "agent-memory", agentName);
33
+ return path.join(cwd, ".pi", "agent-memory-local", agentName);
34
+ }
35
+
36
+ export function ensureMemoryDir(memoryDir: string): void {
37
+ if (fs.existsSync(memoryDir)) {
38
+ if (isSymlink(memoryDir)) throw new Error(`Refusing to use symlinked memory directory: ${memoryDir}`);
39
+ return;
40
+ }
41
+ fs.mkdirSync(memoryDir, { recursive: true });
42
+ }
43
+
44
+ export function readMemoryIndex(memoryDir: string): string | undefined {
45
+ if (isSymlink(memoryDir)) return undefined;
46
+ const content = safeReadMemoryFile(path.join(memoryDir, "MEMORY.md"));
47
+ if (content === undefined) return undefined;
48
+ const lines = content.split(/\r?\n/);
49
+ return lines.length > MAX_MEMORY_LINES ? `${lines.slice(0, MAX_MEMORY_LINES).join("\n")}\n... (truncated at 200 lines)` : content;
50
+ }
51
+
52
+ export function buildMemoryBlock(agentName: string, scope: AgentMemoryScope, cwd: string, writable: boolean): string {
53
+ const memoryDir = resolveMemoryDir(agentName, scope, cwd);
54
+ if (writable) ensureMemoryDir(memoryDir);
55
+ const existing = readMemoryIndex(memoryDir);
56
+ const mode = writable ? "read-write" : "read-only";
57
+ return [
58
+ `# Agent Memory (${mode})`,
59
+ `Memory scope: ${scope}`,
60
+ `Memory directory: ${memoryDir}`,
61
+ writable ? "Use this persistent directory to maintain useful long-term notes for this agent." : "You may reference existing memory, but do not create or modify memory files.",
62
+ "",
63
+ existing ? `## Current MEMORY.md\n${existing}` : "No MEMORY.md exists yet.",
64
+ writable ? [
65
+ "",
66
+ "## Memory Instructions",
67
+ "- Keep MEMORY.md concise (under 200 lines); store details in separate linked files.",
68
+ "- Reject stale memories; update or remove outdated notes.",
69
+ "- Use safe relative filenames inside the memory directory only.",
70
+ ].join("\n") : "",
71
+ ].filter(Boolean).join("\n");
72
+ }
@@ -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 {