pi-crew 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.1.6",
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,4 +24,7 @@ export interface AgentConfig {
24
24
  inheritProjectContext?: boolean;
25
25
  inheritSkills?: boolean;
26
26
  routing?: RoutingMetadata;
27
+ memory?: "user" | "project" | "local";
28
+ disabled?: boolean;
29
+ override?: { source: "config"; path: string };
27
30
  }
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import type { AgentConfig, ResourceSource } from "./agent-config.ts";
4
+ import { loadConfig } from "../config/config.ts";
4
5
  import { parseCsv, parseFrontmatter } from "../utils/frontmatter.ts";
5
6
  import { packageRoot, projectPiRoot, userPiRoot } from "../utils/paths.ts";
6
7
 
@@ -14,6 +15,10 @@ function parseCost(value: string | undefined): "free" | "cheap" | "expensive" |
14
15
  return value === "free" || value === "cheap" || value === "expensive" ? value : undefined;
15
16
  }
16
17
 
18
+ function parseMemory(value: string | undefined): "user" | "project" | "local" | undefined {
19
+ return value === "user" || value === "project" || value === "local" ? value : undefined;
20
+ }
21
+
17
22
  function parseAgentFile(filePath: string, source: ResourceSource): AgentConfig | undefined {
18
23
  try {
19
24
  const content = fs.readFileSync(filePath, "utf-8");
@@ -40,6 +45,8 @@ function parseAgentFile(filePath: string, source: ResourceSource): AgentConfig |
40
45
  systemPromptMode: frontmatter.systemPromptMode === "append" ? "append" : "replace",
41
46
  inheritProjectContext: frontmatter.inheritProjectContext === "true",
42
47
  inheritSkills: frontmatter.inheritSkills === "true",
48
+ memory: parseMemory(frontmatter.memory),
49
+ disabled: frontmatter.disabled === "true" || frontmatter.enabled === "false",
43
50
  routing: triggers || useWhen || avoidWhen || cost || category ? { triggers, useWhen, avoidWhen, cost, category } : undefined,
44
51
  };
45
52
  } catch {
@@ -56,18 +63,40 @@ function readAgentDir(dir: string, source: ResourceSource): AgentConfig[] {
56
63
  .sort((a, b) => a.name.localeCompare(b.name));
57
64
  }
58
65
 
66
+ function applyAgentOverrides(agents: AgentConfig[], cwd: string): AgentConfig[] {
67
+ const loaded = loadConfig(cwd);
68
+ const config = loaded.config.agents;
69
+ const overrides = config?.overrides ?? {};
70
+ return agents
71
+ .filter((agent) => !(config?.disableBuiltins && agent.source === "builtin"))
72
+ .map((agent) => {
73
+ const overrideEntry = Object.entries(overrides).find(([name]) => name.toLowerCase() === agent.name.toLowerCase());
74
+ if (!overrideEntry) return agent;
75
+ const [, override] = overrideEntry;
76
+ return {
77
+ ...agent,
78
+ disabled: override.disabled ?? agent.disabled,
79
+ model: override.model === false ? undefined : override.model ?? agent.model,
80
+ fallbackModels: override.fallbackModels === false ? undefined : override.fallbackModels ?? agent.fallbackModels,
81
+ thinking: override.thinking === false ? undefined : override.thinking ?? agent.thinking,
82
+ tools: override.tools === false ? undefined : override.tools ?? agent.tools,
83
+ override: { source: "config", path: loaded.path },
84
+ };
85
+ });
86
+ }
87
+
59
88
  export function discoverAgents(cwd: string): AgentDiscoveryResult {
60
89
  return {
61
- builtin: readAgentDir(path.join(packageRoot(), "agents"), "builtin"),
62
- user: readAgentDir(path.join(userPiRoot(), "agents"), "user"),
63
- project: readAgentDir(path.join(projectPiRoot(cwd), "agents"), "project"),
90
+ builtin: applyAgentOverrides(readAgentDir(path.join(packageRoot(), "agents"), "builtin"), cwd),
91
+ user: applyAgentOverrides(readAgentDir(path.join(userPiRoot(), "agents"), "user"), cwd),
92
+ project: applyAgentOverrides(readAgentDir(path.join(projectPiRoot(cwd), "agents"), "project"), cwd),
64
93
  };
65
94
  }
66
95
 
67
96
  export function allAgents(discovery: AgentDiscoveryResult): AgentConfig[] {
68
97
  const byName = new Map<string, AgentConfig>();
69
98
  for (const agent of [...discovery.builtin, ...discovery.user, ...discovery.project]) {
70
- byName.set(agent.name, agent);
99
+ byName.set(agent.name.toLowerCase(), agent);
71
100
  }
72
- return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
101
+ return [...byName.values()].filter((agent) => !agent.disabled).sort((a, b) => a.name.localeCompare(b.name));
73
102
  }
@@ -41,6 +41,25 @@ 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
+
50
+ export interface AgentOverrideConfig {
51
+ disabled?: boolean;
52
+ model?: string | false;
53
+ fallbackModels?: string[] | false;
54
+ thinking?: string | false;
55
+ tools?: string[] | false;
56
+ }
57
+
58
+ export interface CrewAgentsConfig {
59
+ disableBuiltins?: boolean;
60
+ overrides?: Record<string, AgentOverrideConfig>;
61
+ }
62
+
44
63
  export interface PiTeamsConfig {
45
64
  asyncByDefault?: boolean;
46
65
  executeWorkers?: boolean;
@@ -50,6 +69,8 @@ export interface PiTeamsConfig {
50
69
  limits?: CrewLimitsConfig;
51
70
  runtime?: CrewRuntimeConfig;
52
71
  control?: CrewControlConfig;
72
+ worktree?: CrewWorktreeConfig;
73
+ agents?: CrewAgentsConfig;
53
74
  }
54
75
 
55
76
  export interface LoadedPiTeamsConfig {
@@ -109,6 +130,23 @@ function mergeConfig(base: PiTeamsConfig, override: PiTeamsConfig): PiTeamsConfi
109
130
  ...withoutUndefined((override.control ?? {}) as Record<string, unknown>),
110
131
  };
111
132
  }
133
+ if (base.worktree || override.worktree) {
134
+ merged.worktree = {
135
+ ...(base.worktree ?? {}),
136
+ ...withoutUndefined((override.worktree ?? {}) as Record<string, unknown>),
137
+ };
138
+ }
139
+ if (base.agents || override.agents) {
140
+ merged.agents = {
141
+ ...(base.agents ?? {}),
142
+ ...withoutUndefined((override.agents ?? {}) as Record<string, unknown>),
143
+ overrides: {
144
+ ...(base.agents?.overrides ?? {}),
145
+ ...(override.agents?.overrides ?? {}),
146
+ },
147
+ };
148
+ }
149
+ if (merged.agents?.overrides && Object.keys(merged.agents.overrides).length === 0) delete merged.agents.overrides;
112
150
  return merged;
113
151
  }
114
152
 
@@ -159,21 +197,33 @@ function parseAutonomousConfig(value: unknown): PiTeamsAutonomousConfig | undefi
159
197
  };
160
198
  }
161
199
 
162
- function parsePositiveInteger(value: unknown): number | undefined {
163
- return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
200
+ const LIMIT_CEILINGS = {
201
+ maxConcurrentWorkers: 1024,
202
+ maxTaskDepth: 100,
203
+ maxChildrenPerTask: 1000,
204
+ maxRunMinutes: 1440,
205
+ maxRetriesPerTask: 100,
206
+ maxTasksPerRun: 10_000,
207
+ heartbeatStaleMs: 24 * 60 * 60 * 1000,
208
+ runtimeMaxTurns: 10_000,
209
+ runtimeGraceTurns: 1_000,
210
+ } as const;
211
+
212
+ function parsePositiveInteger(value: unknown, max = Number.MAX_SAFE_INTEGER): number | undefined {
213
+ return typeof value === "number" && Number.isInteger(value) && value > 0 && value <= max ? value : undefined;
164
214
  }
165
215
 
166
216
  function parseLimitsConfig(value: unknown): CrewLimitsConfig | undefined {
167
217
  if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
168
218
  const obj = value as Record<string, unknown>;
169
219
  const limits: CrewLimitsConfig = {
170
- maxConcurrentWorkers: parsePositiveInteger(obj.maxConcurrentWorkers),
171
- maxTaskDepth: parsePositiveInteger(obj.maxTaskDepth),
172
- maxChildrenPerTask: parsePositiveInteger(obj.maxChildrenPerTask),
173
- maxRunMinutes: parsePositiveInteger(obj.maxRunMinutes),
174
- maxRetriesPerTask: parsePositiveInteger(obj.maxRetriesPerTask),
175
- maxTasksPerRun: parsePositiveInteger(obj.maxTasksPerRun),
176
- heartbeatStaleMs: parsePositiveInteger(obj.heartbeatStaleMs),
220
+ maxConcurrentWorkers: parsePositiveInteger(obj.maxConcurrentWorkers, LIMIT_CEILINGS.maxConcurrentWorkers),
221
+ maxTaskDepth: parsePositiveInteger(obj.maxTaskDepth, LIMIT_CEILINGS.maxTaskDepth),
222
+ maxChildrenPerTask: parsePositiveInteger(obj.maxChildrenPerTask, LIMIT_CEILINGS.maxChildrenPerTask),
223
+ maxRunMinutes: parsePositiveInteger(obj.maxRunMinutes, LIMIT_CEILINGS.maxRunMinutes),
224
+ maxRetriesPerTask: parsePositiveInteger(obj.maxRetriesPerTask, LIMIT_CEILINGS.maxRetriesPerTask),
225
+ maxTasksPerRun: parsePositiveInteger(obj.maxTasksPerRun, LIMIT_CEILINGS.maxTasksPerRun),
226
+ heartbeatStaleMs: parsePositiveInteger(obj.heartbeatStaleMs, LIMIT_CEILINGS.heartbeatStaleMs),
177
227
  };
178
228
  return Object.values(limits).some((entry) => entry !== undefined) ? limits : undefined;
179
229
  }
@@ -189,8 +239,8 @@ function parseRuntimeConfig(value: unknown): CrewRuntimeConfig | undefined {
189
239
  mode: parseRuntimeMode(obj.mode),
190
240
  preferLiveSession: typeof obj.preferLiveSession === "boolean" ? obj.preferLiveSession : undefined,
191
241
  allowChildProcessFallback: typeof obj.allowChildProcessFallback === "boolean" ? obj.allowChildProcessFallback : undefined,
192
- maxTurns: parsePositiveInteger(obj.maxTurns),
193
- graceTurns: parsePositiveInteger(obj.graceTurns),
242
+ maxTurns: parsePositiveInteger(obj.maxTurns, LIMIT_CEILINGS.runtimeMaxTurns),
243
+ graceTurns: parsePositiveInteger(obj.graceTurns, LIMIT_CEILINGS.runtimeGraceTurns),
194
244
  inheritContext: typeof obj.inheritContext === "boolean" ? obj.inheritContext : undefined,
195
245
  promptMode: obj.promptMode === "replace" || obj.promptMode === "append" ? obj.promptMode : undefined,
196
246
  groupJoin: obj.groupJoin === "off" || obj.groupJoin === "group" || obj.groupJoin === "smart" ? obj.groupJoin : undefined,
@@ -208,6 +258,54 @@ function parseControlConfig(value: unknown): CrewControlConfig | undefined {
208
258
  return Object.values(control).some((entry) => entry !== undefined) ? control : undefined;
209
259
  }
210
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
+
272
+ function parseStringArrayOrFalse(value: unknown): string[] | false | undefined {
273
+ if (value === false) return false;
274
+ if (typeof value === "string") return value.split(",").map((entry) => entry.trim()).filter(Boolean);
275
+ if (Array.isArray(value)) return value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0).map((entry) => entry.trim());
276
+ return undefined;
277
+ }
278
+
279
+ function parseAgentOverride(value: unknown): AgentOverrideConfig | undefined {
280
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
281
+ const obj = value as Record<string, unknown>;
282
+ const override: AgentOverrideConfig = {
283
+ disabled: typeof obj.disabled === "boolean" ? obj.disabled : undefined,
284
+ model: typeof obj.model === "string" || obj.model === false ? obj.model : undefined,
285
+ fallbackModels: parseStringArrayOrFalse(obj.fallbackModels),
286
+ thinking: typeof obj.thinking === "string" || obj.thinking === false ? obj.thinking : undefined,
287
+ tools: parseStringArrayOrFalse(obj.tools),
288
+ };
289
+ return Object.values(override).some((entry) => entry !== undefined) ? override : undefined;
290
+ }
291
+
292
+ function parseAgentsConfig(value: unknown): CrewAgentsConfig | undefined {
293
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
294
+ const obj = value as Record<string, unknown>;
295
+ const overrides: Record<string, AgentOverrideConfig> = {};
296
+ if (obj.overrides && typeof obj.overrides === "object" && !Array.isArray(obj.overrides)) {
297
+ for (const [name, rawOverride] of Object.entries(obj.overrides)) {
298
+ const parsed = parseAgentOverride(rawOverride);
299
+ if (parsed) overrides[name] = parsed;
300
+ }
301
+ }
302
+ const agents: CrewAgentsConfig = {
303
+ disableBuiltins: typeof obj.disableBuiltins === "boolean" ? obj.disableBuiltins : undefined,
304
+ overrides: Object.keys(overrides).length > 0 ? overrides : undefined,
305
+ };
306
+ return Object.values(agents).some((entry) => entry !== undefined) ? agents : undefined;
307
+ }
308
+
211
309
  function parseConfig(raw: unknown): PiTeamsConfig {
212
310
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
213
311
  const obj = raw as Record<string, unknown>;
@@ -220,6 +318,8 @@ function parseConfig(raw: unknown): PiTeamsConfig {
220
318
  limits: parseLimitsConfig(obj.limits),
221
319
  runtime: parseRuntimeConfig(obj.runtime),
222
320
  control: parseControlConfig(obj.control),
321
+ worktree: parseWorktreeConfig(obj.worktree),
322
+ agents: parseAgentsConfig(obj.agents),
223
323
  };
224
324
  }
225
325
 
@@ -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 = {
@@ -167,7 +175,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
167
175
  const config: Record<string, unknown> = { operation };
168
176
  for (const token of tokens.filter((item) => item.includes("="))) {
169
177
  const [key, ...rest] = token.split("=");
170
- if (key) config[key] = rest.join("=");
178
+ if (key) config[key] = parseScalar(rest.join("="));
171
179
  }
172
180
  const result = await handleTeamTool({ action: "api", runId, config }, ctx);
173
181
  await notifyCommandResult(ctx, commandText(result));
@@ -255,7 +263,15 @@ export function registerPiTeams(pi: ExtensionAPI): void {
255
263
  if (selection.action === "reload") continue;
256
264
  const result = selection.action === "api"
257
265
  ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-manifest" } }, ctx)
258
- : await handleTeamTool({ action: selection.action, runId: selection.runId }, ctx);
266
+ : selection.action === "agents"
267
+ ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "agent-dashboard" } }, ctx)
268
+ : selection.action === "agent-events"
269
+ ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-agent-events", limit: 50 } }, ctx)
270
+ : selection.action === "agent-output"
271
+ ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-agent-output", maxBytes: 32_000 } }, ctx)
272
+ : selection.action === "agent-transcript"
273
+ ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-agent-transcript" } }, ctx)
274
+ : await handleTeamTool({ action: selection.action, runId: selection.runId }, ctx);
259
275
  await notifyCommandResult(ctx, commandText(result));
260
276
  return;
261
277
  }
@@ -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
+ }