pi-crew 0.1.7 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
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
+ }
@@ -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,7 @@ 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";
12
13
 
13
14
  function parseRunArgs(args: string): TeamToolParamsValue {
14
15
  const tokens = args.match(/"[^"]*"|'[^']*'|\S+/g)?.map((token) => token.replace(/^['"]|['"]$/g, "")) ?? [];
@@ -64,15 +65,22 @@ function setNestedConfig(config: Record<string, unknown>, key: string, value: un
64
65
 
65
66
  export function registerPiTeams(pi: ExtensionAPI): void {
66
67
  const notifierState: AsyncNotifierState = { seenFinishedRunIds: new Set() };
68
+ let currentCtx: ExtensionContext | undefined;
69
+ let rpcHandle: PiCrewRpcHandle | undefined;
67
70
  registerAutonomousPolicy(pi);
71
+ rpcHandle = registerPiCrewRpc((pi as unknown as { events?: Parameters<typeof registerPiCrewRpc>[0] }).events, () => currentCtx);
68
72
 
69
73
  pi.on("session_start", (_event, ctx) => {
74
+ currentCtx = ctx;
70
75
  notifyActiveRuns(ctx);
71
76
  const loadedConfig = loadConfig(ctx.cwd);
72
77
  startAsyncRunNotifier(ctx, notifierState, loadedConfig.config.notifierIntervalMs ?? 5000);
73
78
  });
74
79
  pi.on("session_shutdown", () => {
75
80
  stopAsyncRunNotifier(notifierState);
81
+ currentCtx = undefined;
82
+ rpcHandle?.unsubscribe();
83
+ rpcHandle = undefined;
76
84
  });
77
85
 
78
86
  const tool: ToolDefinition = {
@@ -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;
@@ -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
+ }