pi-crew 0.1.5 → 0.1.6

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.5",
3
+ "version": "0.1.6",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -68,6 +68,7 @@
68
68
  "@mariozechner/pi-tui": "*"
69
69
  },
70
70
  "dependencies": {
71
+ "jiti": "^2.6.1",
71
72
  "typebox": "^1.1.24"
72
73
  },
73
74
  "devDependencies": {
package/schema.json CHANGED
@@ -54,6 +54,30 @@
54
54
  "maxTasksPerRun": { "type": "integer", "minimum": 1 },
55
55
  "heartbeatStaleMs": { "type": "integer", "minimum": 1 }
56
56
  }
57
+ },
58
+ "runtime": {
59
+ "type": "object",
60
+ "additionalProperties": false,
61
+ "description": "Crew runtime selection and live-agent behavior knobs.",
62
+ "properties": {
63
+ "mode": { "type": "string", "enum": ["auto", "scaffold", "child-process", "live-session"] },
64
+ "preferLiveSession": { "type": "boolean" },
65
+ "allowChildProcessFallback": { "type": "boolean" },
66
+ "maxTurns": { "type": "integer", "minimum": 1 },
67
+ "graceTurns": { "type": "integer", "minimum": 1 },
68
+ "inheritContext": { "type": "boolean" },
69
+ "promptMode": { "type": "string", "enum": ["replace", "append"] },
70
+ "groupJoin": { "type": "string", "enum": ["off", "group", "smart"] }
71
+ }
72
+ },
73
+ "control": {
74
+ "type": "object",
75
+ "additionalProperties": false,
76
+ "description": "Agent control-plane settings for attention/stale activity detection.",
77
+ "properties": {
78
+ "enabled": { "type": "boolean" },
79
+ "needsAttentionAfterMs": { "type": "integer", "minimum": 1 }
80
+ }
57
81
  }
58
82
  }
59
83
  }
@@ -23,6 +23,24 @@ export interface CrewLimitsConfig {
23
23
  heartbeatStaleMs?: number;
24
24
  }
25
25
 
26
+ export type CrewRuntimeMode = "auto" | "scaffold" | "child-process" | "live-session";
27
+
28
+ export interface CrewRuntimeConfig {
29
+ mode?: CrewRuntimeMode;
30
+ preferLiveSession?: boolean;
31
+ allowChildProcessFallback?: boolean;
32
+ maxTurns?: number;
33
+ graceTurns?: number;
34
+ inheritContext?: boolean;
35
+ promptMode?: "replace" | "append";
36
+ groupJoin?: "off" | "group" | "smart";
37
+ }
38
+
39
+ export interface CrewControlConfig {
40
+ enabled?: boolean;
41
+ needsAttentionAfterMs?: number;
42
+ }
43
+
26
44
  export interface PiTeamsConfig {
27
45
  asyncByDefault?: boolean;
28
46
  executeWorkers?: boolean;
@@ -30,6 +48,8 @@ export interface PiTeamsConfig {
30
48
  requireCleanWorktreeLeader?: boolean;
31
49
  autonomous?: PiTeamsAutonomousConfig;
32
50
  limits?: CrewLimitsConfig;
51
+ runtime?: CrewRuntimeConfig;
52
+ control?: CrewControlConfig;
33
53
  }
34
54
 
35
55
  export interface LoadedPiTeamsConfig {
@@ -77,6 +97,18 @@ function mergeConfig(base: PiTeamsConfig, override: PiTeamsConfig): PiTeamsConfi
77
97
  ...withoutUndefined((override.limits ?? {}) as Record<string, unknown>),
78
98
  };
79
99
  }
100
+ if (base.runtime || override.runtime) {
101
+ merged.runtime = {
102
+ ...(base.runtime ?? {}),
103
+ ...withoutUndefined((override.runtime ?? {}) as Record<string, unknown>),
104
+ };
105
+ }
106
+ if (base.control || override.control) {
107
+ merged.control = {
108
+ ...(base.control ?? {}),
109
+ ...withoutUndefined((override.control ?? {}) as Record<string, unknown>),
110
+ };
111
+ }
80
112
  return merged;
81
113
  }
82
114
 
@@ -146,6 +178,36 @@ function parseLimitsConfig(value: unknown): CrewLimitsConfig | undefined {
146
178
  return Object.values(limits).some((entry) => entry !== undefined) ? limits : undefined;
147
179
  }
148
180
 
181
+ function parseRuntimeMode(value: unknown): CrewRuntimeMode | undefined {
182
+ return value === "auto" || value === "scaffold" || value === "child-process" || value === "live-session" ? value : undefined;
183
+ }
184
+
185
+ function parseRuntimeConfig(value: unknown): CrewRuntimeConfig | undefined {
186
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
187
+ const obj = value as Record<string, unknown>;
188
+ const runtime: CrewRuntimeConfig = {
189
+ mode: parseRuntimeMode(obj.mode),
190
+ preferLiveSession: typeof obj.preferLiveSession === "boolean" ? obj.preferLiveSession : undefined,
191
+ allowChildProcessFallback: typeof obj.allowChildProcessFallback === "boolean" ? obj.allowChildProcessFallback : undefined,
192
+ maxTurns: parsePositiveInteger(obj.maxTurns),
193
+ graceTurns: parsePositiveInteger(obj.graceTurns),
194
+ inheritContext: typeof obj.inheritContext === "boolean" ? obj.inheritContext : undefined,
195
+ promptMode: obj.promptMode === "replace" || obj.promptMode === "append" ? obj.promptMode : undefined,
196
+ groupJoin: obj.groupJoin === "off" || obj.groupJoin === "group" || obj.groupJoin === "smart" ? obj.groupJoin : undefined,
197
+ };
198
+ return Object.values(runtime).some((entry) => entry !== undefined) ? runtime : undefined;
199
+ }
200
+
201
+ function parseControlConfig(value: unknown): CrewControlConfig | undefined {
202
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
203
+ const obj = value as Record<string, unknown>;
204
+ const control: CrewControlConfig = {
205
+ enabled: typeof obj.enabled === "boolean" ? obj.enabled : undefined,
206
+ needsAttentionAfterMs: parsePositiveInteger(obj.needsAttentionAfterMs),
207
+ };
208
+ return Object.values(control).some((entry) => entry !== undefined) ? control : undefined;
209
+ }
210
+
149
211
  function parseConfig(raw: unknown): PiTeamsConfig {
150
212
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
151
213
  const obj = raw as Record<string, unknown>;
@@ -156,6 +218,8 @@ function parseConfig(raw: unknown): PiTeamsConfig {
156
218
  requireCleanWorktreeLeader: typeof obj.requireCleanWorktreeLeader === "boolean" ? obj.requireCleanWorktreeLeader : undefined,
157
219
  autonomous: parseAutonomousConfig(obj.autonomous),
158
220
  limits: parseLimitsConfig(obj.limits),
221
+ runtime: parseRuntimeConfig(obj.runtime),
222
+ control: parseControlConfig(obj.control),
159
223
  };
160
224
  }
161
225
 
@@ -35,6 +35,10 @@ 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";
39
+ import { resolveCrewRuntime } from "../runtime/runtime-resolver.ts";
40
+ import { probeLiveSessionRuntime } from "../runtime/live-session-runtime.ts";
41
+ import { applyAttentionState, formatActivityAge, resolveCrewControlConfig } from "../runtime/agent-control.ts";
38
42
 
39
43
  export interface TeamToolDetails {
40
44
  action: string;
@@ -255,8 +259,9 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
255
259
  return result(text, { action: "run", status: "ok", runId: updatedManifest.runId, artifactsRoot: updatedManifest.artifactsRoot });
256
260
  }
257
261
 
258
- const executeWorkers = loadedConfig.config.executeWorkers === true || process.env.PI_TEAMS_EXECUTE_WORKERS === "1";
259
- const executed = await executeTeamRun({ manifest: updatedManifest, tasks, team, workflow, agents, executeWorkers, limits: loadedConfig.config.limits });
262
+ const runtime = await resolveCrewRuntime(loadedConfig.config);
263
+ 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 });
260
265
  const text = [
261
266
  `Created pi-crew run ${executed.manifest.runId}.`,
262
267
  `Team: ${team.name}`,
@@ -266,9 +271,10 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
266
271
  `State: ${executed.manifest.stateRoot}`,
267
272
  `Artifacts: ${executed.manifest.artifactsRoot}`,
268
273
  "",
274
+ `Runtime: ${runtime.kind}${runtime.fallback ? ` (fallback from ${runtime.requestedMode})` : ""}${runtime.reason ? ` - ${runtime.reason}` : ""}`,
269
275
  executeWorkers
270
- ? "Child Pi worker execution was enabled with PI_TEAMS_EXECUTE_WORKERS=1."
271
- : "Safe scaffold mode: child Pi workers were not launched. Set PI_TEAMS_EXECUTE_WORKERS=1 to enable real worker execution.",
276
+ ? "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.",
272
278
  ].join("\n");
273
279
  return result(text, { action: "run", status: executed.manifest.status === "failed" ? "error" : "ok", runId: executed.manifest.runId, artifactsRoot: executed.manifest.artifactsRoot }, executed.manifest.status === "failed");
274
280
  }
@@ -291,6 +297,8 @@ export function handleStatus(params: TeamToolParamsValue, ctx: TeamContext): PiT
291
297
  const counts = new Map<string, number>();
292
298
  for (const task of tasks) counts.set(task.status, (counts.get(task.status) ?? 0) + 1);
293
299
  const events = readEvents(manifest.eventsPath).slice(-8);
300
+ const controlConfig = resolveCrewControlConfig(loadConfig(ctx.cwd).config);
301
+ const crewAgents = readCrewAgents(manifest).map((agent) => applyAttentionState(manifest, agent, controlConfig));
294
302
  const artifactLines = manifest.artifacts.slice(-10).map((artifact) => `- ${artifact.kind}: ${artifact.path}${artifact.sizeBytes !== undefined ? ` (${artifact.sizeBytes} bytes)` : ""}`);
295
303
  const totalUsage = aggregateUsage(tasks);
296
304
  const lines = [
@@ -308,6 +316,8 @@ export function handleStatus(params: TeamToolParamsValue, ctx: TeamContext): PiT
308
316
  "Tasks:",
309
317
  ...(tasks.length ? tasks.map((task) => `- ${task.id} [${task.status}] ${task.role} -> ${task.agent}${task.taskPacket ? ` scope=${task.taskPacket.scope}` : ""}${task.verification ? ` green=${task.verification.observedGreenLevel}/${task.verification.requiredGreenLevel}` : ""}${task.modelAttempts?.length ? ` attempts=${task.modelAttempts.length}` : ""}${task.jsonEvents !== undefined ? ` jsonEvents=${task.jsonEvents}` : ""}${task.usage ? ` usage=${JSON.stringify(task.usage)}` : ""}${task.worktree ? ` worktree=${task.worktree.path}` : ""}${task.error ? ` error=${task.error}` : ""}`) : ["- (none)"]),
310
318
  `Task counts: ${[...counts.entries()].map(([status, count]) => `${status}=${count}`).join(", ") || "none"}`,
319
+ "Agents:",
320
+ ...(crewAgents.length ? crewAgents.map((agent) => `- ${agent.id} [${agent.status}] ${agent.role} -> ${agent.agent} runtime=${agent.runtime}${agent.progress?.activityState === "needs_attention" ? " needs_attention" : ""}${formatActivityAge(agent) ? ` activity=${formatActivityAge(agent)}` : ""}${agent.progress?.currentTool ? ` tool=${agent.progress.currentTool}` : ""}${agent.toolUses ? ` tools=${agent.toolUses}` : ""}${agent.progress?.tokens ? ` tokens=${agent.progress.tokens}` : ""}${agent.progress?.turns ? ` turns=${agent.progress.turns}` : ""}${agent.jsonEvents !== undefined ? ` jsonEvents=${agent.jsonEvents}` : ""}${agent.statusPath ? ` status=${agent.statusPath}` : ""}${agent.error ? ` error=${agent.error}` : ""}`) : ["- (none)"]),
311
321
  "Policy decisions:",
312
322
  ...(manifest.policyDecisions?.length ? manifest.policyDecisions.map((item) => `- ${item.action} (${item.reason})${item.taskId ? ` ${item.taskId}` : ""}: ${item.message}`) : ["- (none)"]),
313
323
  `Total usage: ${formatUsage(totalUsage)}`,
@@ -370,8 +380,9 @@ export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext
370
380
  saveRunTasks(loaded.manifest, resetTasks);
371
381
  appendEvent(loaded.manifest.eventsPath, { type: "run.resume_requested", runId: loaded.manifest.runId });
372
382
  const loadedConfig = loadConfig(ctx.cwd);
373
- const executeWorkers = loadedConfig.config.executeWorkers === true || process.env.PI_TEAMS_EXECUTE_WORKERS === "1";
374
- const executed = await executeTeamRun({ manifest: loaded.manifest, tasks: resetTasks, team, workflow, agents: allAgents(discoverAgents(ctx.cwd)), executeWorkers, limits: loadedConfig.config.limits });
383
+ const runtime = await resolveCrewRuntime(loadedConfig.config);
384
+ 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 });
375
386
  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");
376
387
  });
377
388
  }
@@ -446,12 +457,17 @@ function autonomousPatchFromConfig(config: unknown): PiTeamsAutonomousConfig {
446
457
 
447
458
  function configPatchFromConfig(config: unknown): PiTeamsConfig {
448
459
  const cfg = configRecord(config);
460
+ const control = configRecord(cfg.control);
449
461
  return {
450
462
  asyncByDefault: typeof cfg.asyncByDefault === "boolean" ? cfg.asyncByDefault : undefined,
451
463
  executeWorkers: typeof cfg.executeWorkers === "boolean" ? cfg.executeWorkers : undefined,
452
464
  notifierIntervalMs: typeof cfg.notifierIntervalMs === "number" && Number.isFinite(cfg.notifierIntervalMs) ? cfg.notifierIntervalMs : undefined,
453
465
  requireCleanWorktreeLeader: typeof cfg.requireCleanWorktreeLeader === "boolean" ? cfg.requireCleanWorktreeLeader : undefined,
454
466
  autonomous: typeof cfg.autonomous === "object" && cfg.autonomous !== null && !Array.isArray(cfg.autonomous) ? autonomousPatchFromConfig(cfg.autonomous) : undefined,
467
+ control: Object.keys(control).length > 0 ? {
468
+ enabled: typeof control.enabled === "boolean" ? control.enabled : undefined,
469
+ needsAttentionAfterMs: typeof control.needsAttentionAfterMs === "number" && Number.isInteger(control.needsAttentionAfterMs) && control.needsAttentionAfterMs > 0 ? control.needsAttentionAfterMs : undefined,
470
+ } : undefined,
455
471
  };
456
472
  }
457
473
 
@@ -540,7 +556,7 @@ export function handleCleanup(params: TeamToolParamsValue, ctx: TeamContext): Pi
540
556
  return result(lines.join("\n"), { action: "cleanup", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
541
557
  }
542
558
 
543
- export function handleApi(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
559
+ export async function handleApi(params: TeamToolParamsValue, ctx: TeamContext): Promise<PiTeamsToolResult> {
544
560
  if (!params.runId) return result("API requires runId.", { action: "api", status: "error" }, true);
545
561
  const loaded = loadRunManifestById(ctx.cwd, params.runId);
546
562
  if (!loaded) return result(`Run '${params.runId}' not found.`, { action: "api", status: "error" }, true);
@@ -561,6 +577,61 @@ export function handleApi(params: TeamToolParamsValue, ctx: TeamContext): PiTeam
561
577
  if (operation === "read-events") {
562
578
  return result(JSON.stringify(readEvents(loaded.manifest.eventsPath), null, 2), { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
563
579
  }
580
+ if (operation === "runtime-capabilities") {
581
+ const loadedConfig = loadConfig(ctx.cwd);
582
+ return result(JSON.stringify(await resolveCrewRuntime(loadedConfig.config), null, 2), { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
583
+ }
584
+ if (operation === "probe-live-session") {
585
+ return result(JSON.stringify(await probeLiveSessionRuntime(), null, 2), { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
586
+ }
587
+ if (operation === "list-agents") {
588
+ return result(JSON.stringify(readCrewAgents(loaded.manifest), null, 2), { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
589
+ }
590
+ if (operation === "get-agent-result") {
591
+ const agentId = typeof cfg.agentId === "string" ? cfg.agentId : undefined;
592
+ const agent = readCrewAgents(loaded.manifest).find((item) => item.id === agentId || item.taskId === agentId);
593
+ if (!agent) return result("API get-agent-result requires config.agentId matching an agent id or task id.", { action: "api", status: "error", runId: loaded.manifest.runId }, true);
594
+ const task = loaded.tasks.find((item) => item.id === agent.taskId);
595
+ const text = task?.resultArtifact && fs.existsSync(task.resultArtifact.path) ? fs.readFileSync(task.resultArtifact.path, "utf-8") : JSON.stringify(agent, null, 2);
596
+ return result(text, { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
597
+ }
598
+ if (operation === "read-agent-status") {
599
+ const agentId = typeof cfg.agentId === "string" ? cfg.agentId : undefined;
600
+ const agent = agentId ? readCrewAgents(loaded.manifest).find((item) => item.id === agentId || item.taskId === agentId) : undefined;
601
+ const status = agent ? readCrewAgentStatus(loaded.manifest, agent.taskId) ?? agent : undefined;
602
+ if (!status) return result("API read-agent-status requires config.agentId matching an agent id or task id.", { action: "api", status: "error", runId: loaded.manifest.runId }, true);
603
+ return result(JSON.stringify(status, null, 2), { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
604
+ }
605
+ if (operation === "read-agent-events") {
606
+ 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 });
610
+ }
611
+ if (operation === "read-agent-transcript") {
612
+ 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);
615
+ const transcriptPath = agent.transcriptPath && fs.existsSync(agent.transcriptPath) ? agent.transcriptPath : agentOutputPath(loaded.manifest, agent.taskId);
616
+ const text = fs.existsSync(transcriptPath) ? fs.readFileSync(transcriptPath, "utf-8") : "";
617
+ return result(text || `(no transcript at ${transcriptPath})`, { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
618
+ }
619
+ if (operation === "nudge-agent") {
620
+ const agentId = typeof cfg.agentId === "string" ? cfg.agentId : undefined;
621
+ const agent = readCrewAgents(loaded.manifest).find((item) => item.id === agentId || item.taskId === agentId);
622
+ if (!agent) return result("API nudge-agent requires config.agentId matching an agent id or task id.", { action: "api", status: "error", runId: loaded.manifest.runId }, true);
623
+ const messageText = typeof cfg.message === "string" && cfg.message.trim() ? cfg.message.trim() : "Please report your current status, blocker, or smallest next step.";
624
+ const message = appendMailboxMessage(loaded.manifest, { direction: "inbox", from: "leader", to: agent.taskId, taskId: agent.taskId, body: messageText });
625
+ appendEvent(loaded.manifest.eventsPath, { type: "agent.nudged", runId: loaded.manifest.runId, taskId: agent.taskId, message: messageText, data: { agentId: agent.id, mailboxMessageId: message.id } });
626
+ return result(JSON.stringify({ agentId: agent.id, mailboxMessage: message }, null, 2), { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
627
+ }
628
+ 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);
634
+ }
564
635
  if (operation === "read-mailbox") {
565
636
  const direction = cfg.direction === "inbox" || cfg.direction === "outbox" ? cfg.direction as MailboxDirection : undefined;
566
637
  const taskId = typeof cfg.taskId === "string" ? cfg.taskId : undefined;
@@ -762,7 +833,7 @@ export async function handleTeamTool(params: TeamToolParamsValue, ctx: TeamConte
762
833
  }
763
834
  case "doctor": return handleDoctor(ctx, params);
764
835
  case "cleanup": return handleCleanup(params, ctx);
765
- case "api": return handleApi(params, ctx);
836
+ case "api": return await handleApi(params, ctx);
766
837
  case "events": return handleEvents(params, ctx);
767
838
  case "artifacts": return handleArtifacts(params, ctx);
768
839
  case "worktrees": return handleWorktrees(params, ctx);
@@ -0,0 +1,64 @@
1
+ import type { PiTeamsConfig } from "../config/config.ts";
2
+ import type { TeamRunManifest } from "../state/types.ts";
3
+ import { appendEvent } from "../state/event-log.ts";
4
+ import type { CrewAgentRecord } from "./crew-agent-runtime.ts";
5
+ import { upsertCrewAgent } from "./crew-agent-records.ts";
6
+
7
+ export interface CrewControlConfig {
8
+ enabled: boolean;
9
+ needsAttentionAfterMs: number;
10
+ }
11
+
12
+ const DEFAULT_NEEDS_ATTENTION_MS = 60_000;
13
+
14
+ function positiveInt(value: unknown): number | undefined {
15
+ return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
16
+ }
17
+
18
+ export function resolveCrewControlConfig(config: PiTeamsConfig | undefined): CrewControlConfig {
19
+ const raw = config as PiTeamsConfig & { control?: { enabled?: unknown; needsAttentionAfterMs?: unknown } } | undefined;
20
+ return {
21
+ enabled: raw?.control?.enabled === false ? false : true,
22
+ needsAttentionAfterMs: positiveInt(raw?.control?.needsAttentionAfterMs) ?? DEFAULT_NEEDS_ATTENTION_MS,
23
+ };
24
+ }
25
+
26
+ export function activityAgeMs(agent: CrewAgentRecord, now = Date.now()): number | undefined {
27
+ const timestamp = agent.progress?.lastActivityAt ?? agent.startedAt;
28
+ if (!timestamp) return undefined;
29
+ const ms = now - new Date(timestamp).getTime();
30
+ return Number.isFinite(ms) ? Math.max(0, ms) : undefined;
31
+ }
32
+
33
+ export function formatActivityAge(agent: CrewAgentRecord, now = Date.now()): string | undefined {
34
+ const age = activityAgeMs(agent, now);
35
+ if (age === undefined) return undefined;
36
+ if (age < 1000) return "active now";
37
+ const seconds = Math.floor(age / 1000);
38
+ if (seconds < 60) return agent.progress?.activityState === "needs_attention" ? `no activity for ${seconds}s` : `active ${seconds}s ago`;
39
+ const minutes = Math.floor(seconds / 60);
40
+ return agent.progress?.activityState === "needs_attention" ? `no activity for ${minutes}m` : `active ${minutes}m ago`;
41
+ }
42
+
43
+ export function applyAttentionState(manifest: TeamRunManifest, agent: CrewAgentRecord, config: CrewControlConfig, now = Date.now()): CrewAgentRecord {
44
+ if (!config.enabled || agent.status !== "running") return agent;
45
+ const age = activityAgeMs(agent, now);
46
+ if (age === undefined || age <= config.needsAttentionAfterMs) return agent;
47
+ if (agent.progress?.activityState === "needs_attention") return agent;
48
+ const updated: CrewAgentRecord = {
49
+ ...agent,
50
+ progress: {
51
+ ...(agent.progress ?? { recentTools: [], recentOutput: [], toolCount: agent.toolUses ?? 0 }),
52
+ activityState: "needs_attention",
53
+ },
54
+ };
55
+ upsertCrewAgent(manifest, updated);
56
+ appendEvent(manifest.eventsPath, {
57
+ type: "agent.needs_attention",
58
+ runId: manifest.runId,
59
+ taskId: agent.taskId,
60
+ message: `${agent.agent} needs attention (no observed activity for ${Math.floor(age / 1000)}s).`,
61
+ data: { agentId: agent.id, ageMs: age, needsAttentionAfterMs: config.needsAttentionAfterMs },
62
+ });
63
+ return updated;
64
+ }
@@ -1,9 +1,36 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { createRequire } from "node:module";
2
3
  import * as fs from "node:fs";
3
4
  import * as path from "node:path";
4
5
  import { fileURLToPath } from "node:url";
5
6
  import type { TeamRunManifest } from "../state/types.ts";
6
7
 
8
+ const require = createRequire(import.meta.url);
9
+
10
+ function resolveJitiCliPath(): string | undefined {
11
+ const candidates: Array<() => string> = [
12
+ () => path.join(path.dirname(require.resolve("jiti/package.json")), "lib/jiti-cli.mjs"),
13
+ () => path.join(path.dirname(require.resolve("@mariozechner/jiti/package.json")), "lib/jiti-cli.mjs"),
14
+ ];
15
+ for (const candidate of candidates) {
16
+ try {
17
+ const filePath = candidate();
18
+ if (fs.existsSync(filePath)) return filePath;
19
+ } catch {
20
+ // Try the next possible runtime dependency location.
21
+ }
22
+ }
23
+ return undefined;
24
+ }
25
+
26
+ export function getBackgroundRunnerCommand(runnerPath: string, cwd: string, runId: string): { args: string[]; loader: "jiti" | "strip-types" } {
27
+ const jitiCliPath = resolveJitiCliPath();
28
+ const runnerArgs = [runnerPath, "--cwd", cwd, "--run-id", runId];
29
+ return jitiCliPath
30
+ ? { args: [jitiCliPath, ...runnerArgs], loader: "jiti" }
31
+ : { args: ["--experimental-strip-types", ...runnerArgs], loader: "strip-types" };
32
+ }
33
+
7
34
  export interface SpawnBackgroundTeamRunResult {
8
35
  pid?: number;
9
36
  logPath: string;
@@ -14,7 +41,9 @@ export function spawnBackgroundTeamRun(manifest: TeamRunManifest): SpawnBackgrou
14
41
  const logPath = path.join(manifest.stateRoot, "background.log");
15
42
  fs.mkdirSync(manifest.stateRoot, { recursive: true });
16
43
  const logFd = fs.openSync(logPath, "a");
17
- const child = spawn(process.execPath, ["--experimental-strip-types", runnerPath, "--cwd", manifest.cwd, "--run-id", manifest.runId], {
44
+ const command = getBackgroundRunnerCommand(runnerPath, manifest.cwd, manifest.runId);
45
+ fs.appendFileSync(logPath, `[pi-crew] background loader=${command.loader}\n`, "utf-8");
46
+ const child = spawn(process.execPath, command.args, {
18
47
  cwd: manifest.cwd,
19
48
  detached: true,
20
49
  stdio: ["ignore", logFd, logFd],
@@ -5,6 +5,7 @@ import { loadRunManifestById, updateRunStatus } from "../state/state-store.ts";
5
5
  import { allWorkflows, discoverWorkflows } from "../workflows/discover-workflows.ts";
6
6
  import { loadConfig } from "../config/config.ts";
7
7
  import { executeTeamRun } from "./team-runner.ts";
8
+ import { resolveCrewRuntime } from "./runtime-resolver.ts";
8
9
 
9
10
  function argValue(name: string): string | undefined {
10
11
  const index = process.argv.indexOf(name);
@@ -29,8 +30,9 @@ async function main(): Promise<void> {
29
30
  if (!workflow) throw new Error(`Workflow '${manifest.workflow ?? ""}' not found.`);
30
31
  const agents = allAgents(discoverAgents(cwd));
31
32
  const loadedConfig = loadConfig(cwd);
32
- const executeWorkers = loadedConfig.config.executeWorkers === true || process.env.PI_TEAMS_EXECUTE_WORKERS === "1";
33
- const result = await executeTeamRun({ manifest, tasks, team, workflow, agents, executeWorkers, limits: loadedConfig.config.limits });
33
+ const runtime = await resolveCrewRuntime(loadedConfig.config);
34
+ const executeWorkers = runtime.kind === "child-process";
35
+ const result = await executeTeamRun({ manifest, tasks, team, workflow, agents, executeWorkers, limits: loadedConfig.config.limits, runtime, runtimeConfig: loadedConfig.config.runtime });
34
36
  manifest = result.manifest;
35
37
  tasks = result.tasks;
36
38
  appendEvent(manifest.eventsPath, { type: "async.completed", runId: manifest.runId, data: { status: manifest.status, tasks: tasks.length } });
@@ -1,14 +1,21 @@
1
1
  import { spawn } from "node:child_process";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
2
4
  import type { AgentConfig } from "../agents/agent-config.ts";
3
5
  import { buildPiWorkerArgs, cleanupTempDir } from "./pi-args.ts";
4
6
  import { getPiSpawnCommand } from "./pi-spawn.ts";
5
7
 
8
+ const POST_EXIT_STDIO_GUARD_MS = 3000;
9
+
6
10
  export interface ChildPiRunInput {
7
11
  cwd: string;
8
12
  task: string;
9
13
  agent: AgentConfig;
10
14
  model?: string;
11
15
  signal?: AbortSignal;
16
+ transcriptPath?: string;
17
+ onStdoutLine?: (line: string) => void;
18
+ onJsonEvent?: (event: unknown) => void;
12
19
  }
13
20
 
14
21
  export interface ChildPiRunResult {
@@ -18,11 +25,65 @@ export interface ChildPiRunResult {
18
25
  error?: string;
19
26
  }
20
27
 
28
+ function appendTranscript(input: ChildPiRunInput, line: string): void {
29
+ if (!input.transcriptPath) return;
30
+ fs.mkdirSync(path.dirname(input.transcriptPath), { recursive: true });
31
+ fs.appendFileSync(input.transcriptPath, `${line}\n`, "utf-8");
32
+ }
33
+
34
+ export class ChildPiLineObserver {
35
+ private buffer = "";
36
+ private readonly input: ChildPiRunInput;
37
+
38
+ constructor(input: ChildPiRunInput) {
39
+ this.input = input;
40
+ }
41
+
42
+ observe(text: string): void {
43
+ this.buffer += text;
44
+ const lines = this.buffer.split(/\r?\n/);
45
+ this.buffer = lines.pop() ?? "";
46
+ for (const line of lines) this.emitLine(line);
47
+ }
48
+
49
+ flush(): void {
50
+ if (!this.buffer) return;
51
+ const line = this.buffer;
52
+ this.buffer = "";
53
+ this.emitLine(line);
54
+ }
55
+
56
+ private emitLine(line: string): void {
57
+ if (!line.trim()) return;
58
+ appendTranscript(this.input, line);
59
+ this.input.onStdoutLine?.(line);
60
+ try {
61
+ this.input.onJsonEvent?.(JSON.parse(line));
62
+ } catch {
63
+ // Raw stdout is allowed.
64
+ }
65
+ }
66
+ }
67
+
68
+ function observeStdoutChunk(input: ChildPiRunInput, text: string): void {
69
+ const observer = new ChildPiLineObserver(input);
70
+ observer.observe(text);
71
+ observer.flush();
72
+ }
73
+
21
74
  export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResult> {
22
75
  const mock = process.env.PI_TEAMS_MOCK_CHILD_PI;
23
76
  if (mock) {
24
- if (mock === "success") return { exitCode: 0, stdout: `Mock child Pi success for ${input.agent.name}\n`, stderr: "" };
25
- if (mock === "json-success") return { exitCode: 0, stdout: `${JSON.stringify({ type: "message", message: { role: "assistant", content: [{ type: "text", text: `Mock JSON success for ${input.agent.name}` }] } })}\n${JSON.stringify({ type: "message_end", usage: { input: 10, output: 5, cost: 0.001, turns: 1 } })}\n`, stderr: "" };
77
+ if (mock === "success") {
78
+ const stdout = `Mock child Pi success for ${input.agent.name}\n`;
79
+ observeStdoutChunk(input, stdout);
80
+ return { exitCode: 0, stdout, stderr: "" };
81
+ }
82
+ if (mock === "json-success") {
83
+ const stdout = `${JSON.stringify({ type: "message", message: { role: "assistant", content: [{ type: "text", text: `Mock JSON success for ${input.agent.name}` }] } })}\n${JSON.stringify({ type: "message_end", usage: { input: 10, output: 5, cost: 0.001, turns: 1 } })}\n`;
84
+ observeStdoutChunk(input, stdout);
85
+ return { exitCode: 0, stdout, stderr: "" };
86
+ }
26
87
  if (mock === "retryable-failure") return { exitCode: 1, stdout: "", stderr: "rate limit: mock failure" };
27
88
  return { exitCode: 1, stdout: "", stderr: `mock failure: ${mock}` };
28
89
  }
@@ -38,10 +99,15 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
38
99
  let stdout = "";
39
100
  let stderr = "";
40
101
  let settled = false;
102
+ let postExitGuard: NodeJS.Timeout | undefined;
103
+ const lineObserver = new ChildPiLineObserver(input);
41
104
 
42
105
  const settle = (result: ChildPiRunResult): void => {
43
106
  if (settled) return;
44
107
  settled = true;
108
+ if (postExitGuard) clearTimeout(postExitGuard);
109
+ lineObserver.flush();
110
+ input.signal?.removeEventListener("abort", abort);
45
111
  cleanupTempDir(built.tempDir);
46
112
  resolve(result);
47
113
  };
@@ -56,7 +122,9 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
56
122
 
57
123
  input.signal?.addEventListener("abort", abort, { once: true });
58
124
  child.stdout?.on("data", (chunk: Buffer) => {
59
- stdout += chunk.toString("utf-8");
125
+ const text = chunk.toString("utf-8");
126
+ stdout += text;
127
+ lineObserver.observe(text);
60
128
  });
61
129
  child.stderr?.on("data", (chunk: Buffer) => {
62
130
  stderr += chunk.toString("utf-8");
@@ -64,8 +132,14 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
64
132
  child.on("error", (error) => {
65
133
  settle({ exitCode: null, stdout, stderr, error: error.message });
66
134
  });
135
+ child.on("exit", () => {
136
+ postExitGuard = setTimeout(() => {
137
+ child.stdout?.destroy();
138
+ child.stderr?.destroy();
139
+ }, POST_EXIT_STDIO_GUARD_MS);
140
+ postExitGuard.unref?.();
141
+ });
67
142
  child.on("close", (exitCode) => {
68
- input.signal?.removeEventListener("abort", abort);
69
143
  settle({ exitCode, stdout, stderr });
70
144
  });
71
145
  });