pi-crew 0.1.7 → 0.1.9

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.7",
3
+ "version": "0.1.9",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -24,6 +24,7 @@ export interface AgentConfig {
24
24
  inheritProjectContext?: boolean;
25
25
  inheritSkills?: boolean;
26
26
  routing?: RoutingMetadata;
27
+ memory?: "user" | "project" | "local";
27
28
  disabled?: boolean;
28
29
  override?: { source: "config"; path: string };
29
30
  }
@@ -15,6 +15,10 @@ function parseCost(value: string | undefined): "free" | "cheap" | "expensive" |
15
15
  return value === "free" || value === "cheap" || value === "expensive" ? value : undefined;
16
16
  }
17
17
 
18
+ function parseMemory(value: string | undefined): "user" | "project" | "local" | undefined {
19
+ return value === "user" || value === "project" || value === "local" ? value : undefined;
20
+ }
21
+
18
22
  function parseAgentFile(filePath: string, source: ResourceSource): AgentConfig | undefined {
19
23
  try {
20
24
  const content = fs.readFileSync(filePath, "utf-8");
@@ -41,6 +45,7 @@ function parseAgentFile(filePath: string, source: ResourceSource): AgentConfig |
41
45
  systemPromptMode: frontmatter.systemPromptMode === "append" ? "append" : "replace",
42
46
  inheritProjectContext: frontmatter.inheritProjectContext === "true",
43
47
  inheritSkills: frontmatter.inheritSkills === "true",
48
+ memory: parseMemory(frontmatter.memory),
44
49
  disabled: frontmatter.disabled === "true" || frontmatter.enabled === "false",
45
50
  routing: triggers || useWhen || avoidWhen || cost || category ? { triggers, useWhen, avoidWhen, cost, category } : undefined,
46
51
  };
@@ -41,6 +41,12 @@ export interface CrewControlConfig {
41
41
  needsAttentionAfterMs?: number;
42
42
  }
43
43
 
44
+ export interface CrewWorktreeConfig {
45
+ setupHook?: string;
46
+ setupHookTimeoutMs?: number;
47
+ linkNodeModules?: boolean;
48
+ }
49
+
44
50
  export interface AgentOverrideConfig {
45
51
  disabled?: boolean;
46
52
  model?: string | false;
@@ -63,6 +69,7 @@ export interface PiTeamsConfig {
63
69
  limits?: CrewLimitsConfig;
64
70
  runtime?: CrewRuntimeConfig;
65
71
  control?: CrewControlConfig;
72
+ worktree?: CrewWorktreeConfig;
66
73
  agents?: CrewAgentsConfig;
67
74
  }
68
75
 
@@ -123,6 +130,12 @@ function mergeConfig(base: PiTeamsConfig, override: PiTeamsConfig): PiTeamsConfi
123
130
  ...withoutUndefined((override.control ?? {}) as Record<string, unknown>),
124
131
  };
125
132
  }
133
+ if (base.worktree || override.worktree) {
134
+ merged.worktree = {
135
+ ...(base.worktree ?? {}),
136
+ ...withoutUndefined((override.worktree ?? {}) as Record<string, unknown>),
137
+ };
138
+ }
126
139
  if (base.agents || override.agents) {
127
140
  merged.agents = {
128
141
  ...(base.agents ?? {}),
@@ -245,6 +258,17 @@ function parseControlConfig(value: unknown): CrewControlConfig | undefined {
245
258
  return Object.values(control).some((entry) => entry !== undefined) ? control : undefined;
246
259
  }
247
260
 
261
+ function parseWorktreeConfig(value: unknown): CrewWorktreeConfig | undefined {
262
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
263
+ const obj = value as Record<string, unknown>;
264
+ const worktree: CrewWorktreeConfig = {
265
+ setupHook: typeof obj.setupHook === "string" && obj.setupHook.trim() ? obj.setupHook.trim() : undefined,
266
+ setupHookTimeoutMs: parsePositiveInteger(obj.setupHookTimeoutMs, 300_000),
267
+ linkNodeModules: typeof obj.linkNodeModules === "boolean" ? obj.linkNodeModules : undefined,
268
+ };
269
+ return Object.values(worktree).some((entry) => entry !== undefined) ? worktree : undefined;
270
+ }
271
+
248
272
  function parseStringArrayOrFalse(value: unknown): string[] | false | undefined {
249
273
  if (value === false) return false;
250
274
  if (typeof value === "string") return value.split(",").map((entry) => entry.trim()).filter(Boolean);
@@ -294,6 +318,7 @@ function parseConfig(raw: unknown): PiTeamsConfig {
294
318
  limits: parseLimitsConfig(obj.limits),
295
319
  runtime: parseRuntimeConfig(obj.runtime),
296
320
  control: parseControlConfig(obj.control),
321
+ worktree: parseWorktreeConfig(obj.worktree),
297
322
  agents: parseAgentsConfig(obj.agents),
298
323
  };
299
324
  }
@@ -13,7 +13,9 @@ function isFinished(status: string): boolean {
13
13
  export function startAsyncRunNotifier(ctx: ExtensionContext, state: AsyncNotifierState, intervalMs = 5000): void {
14
14
  if (state.interval) clearInterval(state.interval);
15
15
  for (const run of listRuns(ctx.cwd)) {
16
- if (isFinished(run.status)) state.seenFinishedRunIds.add(run.runId);
16
+ // Treat all pre-existing runs as seen. This avoids noisy error toasts when
17
+ // an old active/stale run is later inspected and transitions to failed.
18
+ state.seenFinishedRunIds.add(run.runId);
17
19
  }
18
20
  state.interval = setInterval(() => {
19
21
  try {
@@ -0,0 +1,82 @@
1
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+ import type { TeamToolParamsValue } from "../schema/team-tool-schema.ts";
3
+ import { handleTeamTool } from "./team-tool.ts";
4
+ import { parseLiveControlRealtimeMessage, publishLiveControlRealtime } from "../runtime/live-control-realtime.ts";
5
+
6
+ export interface EventBusLike {
7
+ on(event: string, handler: (data: unknown) => void): (() => void) | void;
8
+ emit(event: string, data: unknown): void;
9
+ }
10
+
11
+ export type RpcReply<T = unknown> = { success: true; data?: T } | { success: false; error: string };
12
+ export const PI_CREW_RPC_VERSION = 1;
13
+
14
+ export interface PiCrewRpcHandle {
15
+ unsubscribe(): void;
16
+ }
17
+
18
+ function requestId(raw: unknown): string | undefined {
19
+ return raw && typeof raw === "object" && !Array.isArray(raw) && typeof (raw as { requestId?: unknown }).requestId === "string" ? (raw as { requestId: string }).requestId : undefined;
20
+ }
21
+
22
+ function reply(events: EventBusLike, channel: string, id: string | undefined, payload: RpcReply): void {
23
+ if (!id) return;
24
+ events.emit(`${channel}:reply:${id}`, payload);
25
+ }
26
+
27
+ function textOf(result: Awaited<ReturnType<typeof handleTeamTool>>): string {
28
+ return result.content?.map((item) => item.type === "text" ? item.text : "").join("\n") ?? "";
29
+ }
30
+
31
+ function on(events: EventBusLike, channel: string, handler: (raw: unknown) => void): () => void {
32
+ const unsub = events.on(channel, handler);
33
+ return typeof unsub === "function" ? unsub : () => {};
34
+ }
35
+
36
+ export function registerPiCrewRpc(events: EventBusLike | undefined, getCtx: () => ExtensionContext | undefined): PiCrewRpcHandle | undefined {
37
+ if (!events) return undefined;
38
+ const unsubs = [
39
+ on(events, "pi-crew:rpc:ping", (raw) => reply(events, "pi-crew:rpc:ping", requestId(raw), { success: true, data: { version: PI_CREW_RPC_VERSION } })),
40
+ on(events, "pi-crew:rpc:run", async (raw) => {
41
+ const id = requestId(raw);
42
+ try {
43
+ const ctx = getCtx();
44
+ if (!ctx) throw new Error("No active pi-crew session context.");
45
+ const params: TeamToolParamsValue = raw && typeof raw === "object" && !Array.isArray(raw) ? { ...(raw as object), action: "run" } as TeamToolParamsValue : { action: "run" };
46
+ const result = await handleTeamTool(params, ctx);
47
+ reply(events, "pi-crew:rpc:run", id, result.isError ? { success: false, error: textOf(result) } : { success: true, data: result.details });
48
+ } catch (error) {
49
+ reply(events, "pi-crew:rpc:run", id, { success: false, error: error instanceof Error ? error.message : String(error) });
50
+ }
51
+ }),
52
+ on(events, "pi-crew:rpc:status", async (raw) => {
53
+ const id = requestId(raw);
54
+ try {
55
+ const ctx = getCtx();
56
+ if (!ctx) throw new Error("No active pi-crew session context.");
57
+ const runId = raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as { runId?: string }).runId : undefined;
58
+ const result = await handleTeamTool({ action: "status", runId }, ctx);
59
+ reply(events, "pi-crew:rpc:status", id, result.isError ? { success: false, error: textOf(result) } : { success: true, data: { text: textOf(result), details: result.details } });
60
+ } catch (error) {
61
+ reply(events, "pi-crew:rpc:status", id, { success: false, error: error instanceof Error ? error.message : String(error) });
62
+ }
63
+ }),
64
+ on(events, "pi-crew:live-control", (raw) => {
65
+ const request = parseLiveControlRealtimeMessage(raw);
66
+ if (request) publishLiveControlRealtime(request);
67
+ }),
68
+ on(events, "pi-crew:rpc:live-control", async (raw) => {
69
+ const id = requestId(raw);
70
+ try {
71
+ const ctx = getCtx();
72
+ if (!ctx) throw new Error("No active pi-crew session context.");
73
+ const obj = raw && typeof raw === "object" && !Array.isArray(raw) ? raw as Record<string, unknown> : {};
74
+ const result = await handleTeamTool({ action: "api", runId: typeof obj.runId === "string" ? obj.runId : undefined, config: { operation: typeof obj.operation === "string" ? obj.operation : "steer-agent", agentId: obj.agentId, message: obj.message, prompt: obj.prompt } }, ctx);
75
+ reply(events, "pi-crew:rpc:live-control", id, result.isError ? { success: false, error: textOf(result) } : { success: true, data: { text: textOf(result), details: result.details } });
76
+ } catch (error) {
77
+ reply(events, "pi-crew:rpc:live-control", id, { success: false, error: error instanceof Error ? error.message : String(error) });
78
+ }
79
+ }),
80
+ ];
81
+ return { unsubscribe: () => unsubs.forEach((unsub) => unsub()) };
82
+ }
@@ -18,6 +18,8 @@ export function piTeamsHelp(): string {
18
18
  "- /team-worktrees <runId>",
19
19
  "- /team-api <runId> <operation> [taskId=<taskId>] [body=<message>]",
20
20
  "- /team-dashboard",
21
+ "- /team-transcript <runId> [taskId]",
22
+ "- /team-result <runId> [taskId]",
21
23
  "- /team-manager",
22
24
  "",
23
25
  "Maintenance:",
@@ -1,4 +1,4 @@
1
- import type { ExtensionAPI, ExtensionCommandContext, ToolDefinition } from "@mariozechner/pi-coding-agent";
1
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, ToolDefinition } from "@mariozechner/pi-coding-agent";
2
2
  import { loadConfig } from "../config/config.ts";
3
3
  import { registerAutonomousPolicy } from "./autonomous-policy.ts";
4
4
  import { TeamToolParams, type TeamToolParamsValue } from "../schema/team-tool-schema.ts";
@@ -9,6 +9,11 @@ import { handleTeamManagerCommand } from "./team-manager-command.ts";
9
9
  import { handleTeamTool, type TeamToolDetails } from "./team-tool.ts";
10
10
  import { listRuns } from "./run-index.ts";
11
11
  import { RunDashboard, type RunDashboardSelection } from "../ui/run-dashboard.ts";
12
+ import { registerPiCrewRpc, type PiCrewRpcHandle } from "./cross-extension-rpc.ts";
13
+ import { stopCrewWidget, updateCrewWidget, type CrewWidgetState } from "../ui/crew-widget.ts";
14
+ import { DurableTextViewer, DurableTranscriptViewer } from "../ui/transcript-viewer.ts";
15
+ import { loadRunManifestById } from "../state/state-store.ts";
16
+ import { readCrewAgents } from "../runtime/crew-agent-records.ts";
12
17
 
13
18
  function parseRunArgs(args: string): TeamToolParamsValue {
14
19
  const tokens = args.match(/"[^"]*"|'[^']*'|\S+/g)?.map((token) => token.replace(/^['"]|['"]$/g, "")) ?? [];
@@ -44,6 +49,36 @@ function parseScalar(raw: string): unknown {
44
49
  return raw;
45
50
  }
46
51
 
52
+ async function selectAgentTask(ctx: ExtensionCommandContext, runId: string | undefined, taskId?: string): Promise<{ runId: string; taskId?: string } | undefined> {
53
+ if (!runId) return undefined;
54
+ if (taskId) return { runId, taskId };
55
+ const loaded = loadRunManifestById(ctx.cwd, runId);
56
+ if (!loaded) return { runId };
57
+ const agents = readCrewAgents(loaded.manifest);
58
+ if (ctx.hasUI && agents.length > 1) {
59
+ const choice = await ctx.ui.select("Select pi-crew agent", agents.map((agent) => `${agent.taskId} ${agent.role}→${agent.agent} [${agent.status}]`));
60
+ return { runId, taskId: choice?.split(" ")[0] };
61
+ }
62
+ return { runId, taskId: agents[0]?.taskId };
63
+ }
64
+
65
+ async function openTranscriptViewer(ctx: ExtensionCommandContext, runId: string | undefined, taskId?: string): Promise<boolean> {
66
+ const selected = await selectAgentTask(ctx, runId, taskId);
67
+ if (!selected) return false;
68
+ // eslint-disable-next-line no-param-reassign
69
+ runId = selected.runId;
70
+ // eslint-disable-next-line no-param-reassign
71
+ taskId = selected.taskId;
72
+ if (!runId || !ctx.hasUI) return false;
73
+ const loaded = loadRunManifestById(ctx.cwd, runId);
74
+ if (!loaded) return false;
75
+ await ctx.ui.custom<undefined>((_tui, theme, _keybindings, done) => new DurableTranscriptViewer(loaded.manifest, theme, done, taskId), {
76
+ overlay: true,
77
+ overlayOptions: { width: "90%", maxHeight: "85%", anchor: "center" },
78
+ });
79
+ return true;
80
+ }
81
+
47
82
  function pushUnset(config: Record<string, unknown>, key: string): void {
48
83
  const current = Array.isArray(config.unset) ? config.unset : [];
49
84
  current.push(key);
@@ -64,15 +99,27 @@ function setNestedConfig(config: Record<string, unknown>, key: string, value: un
64
99
 
65
100
  export function registerPiTeams(pi: ExtensionAPI): void {
66
101
  const notifierState: AsyncNotifierState = { seenFinishedRunIds: new Set() };
102
+ let currentCtx: ExtensionContext | undefined;
103
+ let rpcHandle: PiCrewRpcHandle | undefined;
104
+ const widgetState: CrewWidgetState = { frame: 0 };
67
105
  registerAutonomousPolicy(pi);
106
+ rpcHandle = registerPiCrewRpc((pi as unknown as { events?: Parameters<typeof registerPiCrewRpc>[0] }).events, () => currentCtx);
68
107
 
69
108
  pi.on("session_start", (_event, ctx) => {
109
+ currentCtx = ctx;
70
110
  notifyActiveRuns(ctx);
71
111
  const loadedConfig = loadConfig(ctx.cwd);
72
112
  startAsyncRunNotifier(ctx, notifierState, loadedConfig.config.notifierIntervalMs ?? 5000);
113
+ updateCrewWidget(ctx, widgetState);
114
+ widgetState.interval = setInterval(() => { if (currentCtx) updateCrewWidget(currentCtx, widgetState); }, 1000);
115
+ widgetState.interval.unref?.();
73
116
  });
74
117
  pi.on("session_shutdown", () => {
75
118
  stopAsyncRunNotifier(notifierState);
119
+ stopCrewWidget(currentCtx, widgetState);
120
+ currentCtx = undefined;
121
+ rpcHandle?.unsubscribe();
122
+ rpcHandle = undefined;
76
123
  });
77
124
 
78
125
  const tool: ToolDefinition = {
@@ -82,7 +129,9 @@ export function registerPiTeams(pi: ExtensionAPI): void {
82
129
  promptSnippet: "Use the team tool proactively for coordinated multi-agent work. If unsure, call { action: 'recommend', goal } first, then run or plan with the suggested team/workflow.",
83
130
  parameters: TeamToolParams as never,
84
131
  async execute(_id, params, _signal, _onUpdate, ctx) {
85
- return await handleTeamTool(params as TeamToolParamsValue, ctx);
132
+ const output = await handleTeamTool(params as TeamToolParamsValue, ctx);
133
+ updateCrewWidget(ctx, widgetState);
134
+ return output;
86
135
  },
87
136
  };
88
137
 
@@ -242,6 +291,33 @@ export function registerPiTeams(pi: ExtensionAPI): void {
242
291
  handler: handleTeamManagerCommand,
243
292
  });
244
293
 
294
+ pi.registerCommand("team-result", {
295
+ description: "Open a pi-crew agent result viewer: <runId> [taskId]",
296
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
297
+ const [runId, rawTaskId] = args.trim().split(/\s+/).filter(Boolean);
298
+ const selected = await selectAgentTask(ctx, runId, rawTaskId);
299
+ const loaded = selected ? loadRunManifestById(ctx.cwd, selected.runId) : undefined;
300
+ if (ctx.hasUI && loaded) {
301
+ const agent = readCrewAgents(loaded.manifest).find((item) => item.taskId === selected?.taskId || item.id === selected?.taskId) ?? readCrewAgents(loaded.manifest)[0];
302
+ const text = agent?.resultArtifactPath ? commandText(await handleTeamTool({ action: "api", runId: selected!.runId, config: { operation: "read-agent-output", agentId: agent.taskId, maxBytes: 64_000 } }, ctx)) : "(no result)";
303
+ await ctx.ui.custom<undefined>((_tui, theme, _keybindings, done) => new DurableTextViewer("pi-crew result", `${selected!.runId}:${agent?.taskId ?? "unknown"}`, text.split(/\r?\n/), theme, done), { overlay: true, overlayOptions: { width: "90%", maxHeight: "85%", anchor: "center" } });
304
+ return;
305
+ }
306
+ const result = await handleTeamTool({ action: "api", runId, config: { operation: "read-agent-output", agentId: rawTaskId, maxBytes: 64_000 } }, ctx);
307
+ await notifyCommandResult(ctx, commandText(result));
308
+ },
309
+ });
310
+
311
+ pi.registerCommand("team-transcript", {
312
+ description: "Open a pi-crew transcript viewer: <runId> [taskId]",
313
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
314
+ const [runId, taskId] = args.trim().split(/\s+/).filter(Boolean);
315
+ if (await openTranscriptViewer(ctx, runId, taskId)) return;
316
+ const result = await handleTeamTool({ action: "api", runId, config: { operation: "read-agent-transcript", agentId: taskId } }, ctx);
317
+ await notifyCommandResult(ctx, commandText(result));
318
+ },
319
+ });
320
+
245
321
  pi.registerCommand("team-dashboard", {
246
322
  description: "Open a pi-crew run dashboard overlay",
247
323
  handler: async (_args: string, ctx: ExtensionCommandContext) => {
@@ -253,6 +329,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
253
329
  });
254
330
  if (!selection) return;
255
331
  if (selection.action === "reload") continue;
332
+ if (selection.action === "agent-transcript" && await openTranscriptViewer(ctx, selection.runId)) continue;
256
333
  const result = selection.action === "api"
257
334
  ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-manifest" } }, ctx)
258
335
  : selection.action === "agents"
@@ -0,0 +1,89 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { createFileCoalescer } from "../utils/file-coalescer.ts";
4
+
5
+ export interface ResultWatcherEvents {
6
+ emit(event: string, data: unknown): void;
7
+ }
8
+
9
+ export interface ResultWatcherHandle {
10
+ start(): void;
11
+ prime(): void;
12
+ stop(): void;
13
+ }
14
+
15
+ export interface ResultWatcherOptions {
16
+ eventName?: string;
17
+ completionTtlMs?: number;
18
+ }
19
+
20
+ function readJson(filePath: string): unknown | undefined {
21
+ try {
22
+ return JSON.parse(fs.readFileSync(filePath, "utf-8")) as unknown;
23
+ } catch {
24
+ return undefined;
25
+ }
26
+ }
27
+
28
+ function completionKey(payload: unknown, file: string): string {
29
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return `file:${file}`;
30
+ const obj = payload as Record<string, unknown>;
31
+ const id = [obj.runId, obj.sessionId, obj.id, obj.status].filter((entry): entry is string => typeof entry === "string" && entry.length > 0).join(":");
32
+ return id || `file:${file}`;
33
+ }
34
+
35
+ export function createResultWatcher(events: ResultWatcherEvents, resultsDir: string, eventNameOrOptions: string | ResultWatcherOptions = "pi-crew:run-result"): ResultWatcherHandle {
36
+ const options = typeof eventNameOrOptions === "string" ? { eventName: eventNameOrOptions } : eventNameOrOptions;
37
+ const eventName = options.eventName ?? "pi-crew:run-result";
38
+ const completionTtlMs = options.completionTtlMs ?? 5 * 60_000;
39
+ const seen = new Map<string, number>();
40
+ let watcher: fs.FSWatcher | undefined;
41
+ let restartTimer: ReturnType<typeof setTimeout> | undefined;
42
+ const coalescer = createFileCoalescer((file) => {
43
+ const filePath = path.join(resultsDir, file);
44
+ if (!file.endsWith(".json") || !fs.existsSync(filePath)) return;
45
+ const payload = readJson(filePath);
46
+ if (payload !== undefined) {
47
+ const now = Date.now();
48
+ for (const [key, expiresAt] of seen) if (expiresAt <= now) seen.delete(key);
49
+ const key = completionKey(payload, file);
50
+ if (!seen.has(key)) {
51
+ seen.set(key, now + completionTtlMs);
52
+ events.emit(eventName, payload);
53
+ }
54
+ }
55
+ try { fs.unlinkSync(filePath); } catch {}
56
+ }, 50);
57
+ const scheduleRestart = () => {
58
+ if (restartTimer) clearTimeout(restartTimer);
59
+ restartTimer = setTimeout(() => {
60
+ restartTimer = undefined;
61
+ try { handle.start(); } catch {}
62
+ }, 3000);
63
+ restartTimer.unref?.();
64
+ };
65
+ const handle: ResultWatcherHandle = {
66
+ start() {
67
+ fs.mkdirSync(resultsDir, { recursive: true });
68
+ watcher?.close();
69
+ watcher = fs.watch(resultsDir, (event, file) => {
70
+ if (event !== "rename" || !file) return;
71
+ coalescer.schedule(file.toString());
72
+ });
73
+ watcher.on("error", scheduleRestart);
74
+ watcher.unref?.();
75
+ },
76
+ prime() {
77
+ if (!fs.existsSync(resultsDir)) return;
78
+ for (const file of fs.readdirSync(resultsDir).filter((entry) => entry.endsWith(".json"))) coalescer.schedule(file, 0);
79
+ },
80
+ stop() {
81
+ watcher?.close();
82
+ watcher = undefined;
83
+ if (restartTimer) clearTimeout(restartTimer);
84
+ restartTimer = undefined;
85
+ coalescer.clear();
86
+ },
87
+ };
88
+ return handle;
89
+ }
@@ -41,6 +41,9 @@ import { probeLiveSessionRuntime } from "../runtime/live-session-runtime.ts";
41
41
  import { applyAttentionState, formatActivityAge, resolveCrewControlConfig } from "../runtime/agent-control.ts";
42
42
  import { buildAgentDashboard, readAgentOutput } from "../runtime/agent-observability.ts";
43
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";
44
47
 
45
48
  export interface TeamToolDetails {
46
49
  action: string;
@@ -49,7 +52,11 @@ export interface TeamToolDetails {
49
52
  artifactsRoot?: string;
50
53
  }
51
54
 
52
- 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
+ };
53
60
 
54
61
  function result(text: string, details: TeamToolDetails, isError = false): PiTeamsToolResult {
55
62
  return toolResult(text, details, isError);
@@ -59,6 +66,29 @@ function formatScoped(name: string, source: string, description: string): string
59
66
  return `- ${name} (${source}): ${description}`;
60
67
  }
61
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
+
62
92
  export function handleList(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
63
93
  const resource = params.resource;
64
94
  const blocks: string[] = [];
@@ -136,6 +166,18 @@ function commandExists(command: string, args: string[]): { ok: boolean; detail:
136
166
  return { ok: false, detail: output.error?.message ?? firstOutputLine(output.stdout, output.stderr) };
137
167
  }
138
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
+
139
181
  function piCommandExists(): { ok: boolean; detail: string } {
140
182
  const spec = getPiSpawnCommand(["--version"]);
141
183
  const output = spawnSync(spec.command, spec.args, { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] });
@@ -261,9 +303,10 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
261
303
  return result(text, { action: "run", status: "ok", runId: updatedManifest.runId, artifactsRoot: updatedManifest.artifactsRoot });
262
304
  }
263
305
 
264
- const runtime = await resolveCrewRuntime(loadedConfig.config);
306
+ const runtime = await resolveCrewRuntime(effectiveRunConfig(loadedConfig.config, params.config));
265
307
  const executeWorkers = runtime.kind === "child-process";
266
- 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 });
267
310
  const text = [
268
311
  `Created pi-crew run ${executed.manifest.runId}.`,
269
312
  `Team: ${team.name}`,
@@ -274,9 +317,11 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
274
317
  `Artifacts: ${executed.manifest.artifactsRoot}`,
275
318
  "",
276
319
  `Runtime: ${runtime.kind}${runtime.fallback ? ` (fallback from ${runtime.requestedMode})` : ""}${runtime.reason ? ` - ${runtime.reason}` : ""}`,
277
- executeWorkers
320
+ runtime.kind === "child-process"
278
321
  ? "Child Pi worker execution was enabled."
279
- : "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.",
280
325
  ].join("\n");
281
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");
282
327
  }
@@ -384,7 +429,7 @@ export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext
384
429
  const loadedConfig = loadConfig(ctx.cwd);
385
430
  const runtime = await resolveCrewRuntime(loadedConfig.config);
386
431
  const executeWorkers = runtime.kind === "child-process";
387
- 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 });
388
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");
389
434
  });
390
435
  }
@@ -460,12 +505,39 @@ function autonomousPatchFromConfig(config: unknown): PiTeamsAutonomousConfig {
460
505
  function configPatchFromConfig(config: unknown): PiTeamsConfig {
461
506
  const cfg = configRecord(config);
462
507
  const control = configRecord(cfg.control);
508
+ const runtime = configRecord(cfg.runtime);
509
+ const limits = configRecord(cfg.limits);
510
+ const worktree = configRecord(cfg.worktree);
463
511
  return {
464
512
  asyncByDefault: typeof cfg.asyncByDefault === "boolean" ? cfg.asyncByDefault : undefined,
465
513
  executeWorkers: typeof cfg.executeWorkers === "boolean" ? cfg.executeWorkers : undefined,
466
514
  notifierIntervalMs: typeof cfg.notifierIntervalMs === "number" && Number.isFinite(cfg.notifierIntervalMs) ? cfg.notifierIntervalMs : undefined,
467
515
  requireCleanWorktreeLeader: typeof cfg.requireCleanWorktreeLeader === "boolean" ? cfg.requireCleanWorktreeLeader : undefined,
468
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,
469
541
  control: Object.keys(control).length > 0 ? {
470
542
  enabled: typeof control.enabled === "boolean" ? control.enabled : undefined,
471
543
  needsAttentionAfterMs: typeof control.needsAttentionAfterMs === "number" && Number.isInteger(control.needsAttentionAfterMs) && control.needsAttentionAfterMs > 0 ? control.needsAttentionAfterMs : undefined,
@@ -657,12 +729,34 @@ export async function handleApi(params: TeamToolParamsValue, ctx: TeamContext):
657
729
  appendEvent(loaded.manifest.eventsPath, { type: "agent.nudged", runId: loaded.manifest.runId, taskId: agent.taskId, message: messageText, data: { agentId: agent.id, mailboxMessageId: message.id } });
658
730
  return result(JSON.stringify({ agentId: agent.id, mailboxMessage: message }, null, 2), { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
659
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
+ }
660
735
  if (operation === "steer-agent" || operation === "stop-agent" || operation === "resume-agent" || operation === "interrupt-agent") {
661
- const runtime = await resolveCrewRuntime(loadConfig(ctx.cwd).config);
662
- 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);
663
- 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);
664
- 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);
665
- 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
+ }
666
760
  }
667
761
  if (operation === "read-mailbox") {
668
762
  const direction = cfg.direction === "inbox" || cfg.direction === "outbox" ? cfg.direction as MailboxDirection : undefined;