pi-subagents 0.8.2 → 0.8.4

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/execution.ts CHANGED
@@ -7,7 +7,6 @@ import * as fs from "node:fs";
7
7
  import type { Message } from "@mariozechner/pi-ai";
8
8
  import type { AgentConfig } from "./agents.js";
9
9
  import {
10
- appendJsonl,
11
10
  ensureArtifactsDir,
12
11
  getArtifactPaths,
13
12
  writeArtifact,
@@ -31,6 +30,8 @@ import {
31
30
  extractTextFromContent,
32
31
  } from "./utils.js";
33
32
  import { buildSkillInjection, resolveSkills } from "./skills.js";
33
+ import { getPiSpawnCommand } from "./pi-spawn.js";
34
+ import { createJsonlWriter } from "./jsonl-writer.js";
34
35
 
35
36
  const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"];
36
37
 
@@ -78,13 +79,16 @@ export async function runSync(
78
79
  }
79
80
  const effectiveModel = modelOverride ?? agent.model;
80
81
  const modelArg = applyThinkingSuffix(effectiveModel, agent.thinking);
81
- if (modelArg) args.push("--model", modelArg);
82
+ // Use --models (not --model) because pi CLI silently ignores --model
83
+ // without a companion --provider flag. --models resolves the provider
84
+ // automatically via resolveModelScope. See: #8
85
+ if (modelArg) args.push("--models", modelArg);
86
+ const toolExtensionPaths: string[] = [];
82
87
  if (agent.tools?.length) {
83
88
  const builtinTools: string[] = [];
84
- const extensionPaths: string[] = [];
85
89
  for (const tool of agent.tools) {
86
90
  if (tool.includes("/") || tool.endsWith(".ts") || tool.endsWith(".js")) {
87
- extensionPaths.push(tool);
91
+ toolExtensionPaths.push(tool);
88
92
  } else {
89
93
  builtinTools.push(tool);
90
94
  }
@@ -92,7 +96,14 @@ export async function runSync(
92
96
  if (builtinTools.length > 0) {
93
97
  args.push("--tools", builtinTools.join(","));
94
98
  }
95
- for (const extPath of extensionPaths) {
99
+ }
100
+ if (agent.extensions !== undefined) {
101
+ args.push("--no-extensions");
102
+ for (const extPath of agent.extensions) {
103
+ args.push("--extension", extPath);
104
+ }
105
+ } else {
106
+ for (const extPath of toolExtensionPaths) {
96
107
  args.push("--extension", extPath);
97
108
  }
98
109
  }
@@ -140,15 +151,18 @@ export async function runSync(
140
151
  result.progress = progress;
141
152
 
142
153
  const startTime = Date.now();
143
- const jsonlLines: string[] = [];
144
154
 
145
155
  let artifactPathsResult: ArtifactPaths | undefined;
156
+ let jsonlPath: string | undefined;
146
157
  if (artifactsDir && artifactConfig?.enabled !== false) {
147
158
  artifactPathsResult = getArtifactPaths(artifactsDir, runId, agentName, index);
148
159
  ensureArtifactsDir(artifactsDir);
149
160
  if (artifactConfig?.includeInput !== false) {
150
161
  writeArtifact(artifactPathsResult.inputPath, `# Task for ${agentName}\n\n${task}`);
151
162
  }
163
+ if (artifactConfig?.includeJsonl !== false) {
164
+ jsonlPath = artifactPathsResult.jsonlPath;
165
+ }
152
166
  }
153
167
 
154
168
  const spawnEnv = { ...process.env, ...getSubagentDepthEnv() };
@@ -159,8 +173,16 @@ export async function runSync(
159
173
  spawnEnv.MCP_DIRECT_TOOLS = "__none__";
160
174
  }
161
175
 
176
+ let closeJsonlWriter: (() => Promise<void>) | undefined;
162
177
  const exitCode = await new Promise<number>((resolve) => {
163
- const proc = spawn("pi", args, { cwd: cwd ?? runtimeCwd, env: spawnEnv, stdio: ["ignore", "pipe", "pipe"] });
178
+ const spawnSpec = getPiSpawnCommand(args);
179
+ const proc = spawn(spawnSpec.command, spawnSpec.args, {
180
+ cwd: cwd ?? runtimeCwd,
181
+ env: spawnEnv,
182
+ stdio: ["ignore", "pipe", "pipe"],
183
+ });
184
+ const jsonlWriter = createJsonlWriter(jsonlPath, proc.stdout);
185
+ closeJsonlWriter = () => jsonlWriter.close();
164
186
  let buf = "";
165
187
 
166
188
  // Throttled update mechanism - consolidates all updates
@@ -209,7 +231,7 @@ export async function runSync(
209
231
 
210
232
  const processLine = (line: string) => {
211
233
  if (!line.trim()) return;
212
- jsonlLines.push(line);
234
+ jsonlWriter.writeLine(line);
213
235
  try {
214
236
  const evt = JSON.parse(line) as { type?: string; message?: Message; toolName?: string; args?: unknown };
215
237
  const now = Date.now();
@@ -329,6 +351,12 @@ export async function runSync(
329
351
  }
330
352
  });
331
353
 
354
+ if (closeJsonlWriter) {
355
+ try {
356
+ await closeJsonlWriter();
357
+ } catch {}
358
+ }
359
+
332
360
  if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
333
361
  result.exitCode = exitCode;
334
362
 
@@ -365,11 +393,6 @@ export async function runSync(
365
393
  if (artifactConfig?.includeOutput !== false) {
366
394
  writeArtifact(artifactPathsResult.outputPath, fullOutput);
367
395
  }
368
- if (artifactConfig?.includeJsonl !== false) {
369
- for (const line of jsonlLines) {
370
- appendJsonl(artifactPathsResult.jsonlPath, line);
371
- }
372
- }
373
396
  if (artifactConfig?.includeMetadata !== false) {
374
397
  writeMetadata(artifactPathsResult.metadataPath, {
375
398
  runId,
@@ -0,0 +1,40 @@
1
+ interface TimerApi {
2
+ setTimeout(handler: () => void, delayMs: number): unknown;
3
+ clearTimeout(handle: unknown): void;
4
+ }
5
+
6
+ interface FileCoalescer {
7
+ schedule(file: string, delayMs?: number): boolean;
8
+ clear(): void;
9
+ }
10
+
11
+ const defaultTimerApi: TimerApi = {
12
+ setTimeout: (handler, delayMs) => setTimeout(handler, delayMs),
13
+ clearTimeout: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
14
+ };
15
+
16
+ export function createFileCoalescer(
17
+ handler: (file: string) => void,
18
+ defaultDelayMs: number,
19
+ timerApi: TimerApi = defaultTimerApi,
20
+ ): FileCoalescer {
21
+ const pending = new Map<string, unknown>();
22
+
23
+ return {
24
+ schedule(file: string, delayMs = defaultDelayMs): boolean {
25
+ if (pending.has(file)) return false;
26
+ const timer = timerApi.setTimeout(() => {
27
+ pending.delete(file);
28
+ handler(file);
29
+ }, delayMs);
30
+ pending.set(file, timer);
31
+ return true;
32
+ },
33
+ clear(): void {
34
+ for (const timer of pending.values()) {
35
+ timerApi.clearTimeout(timer);
36
+ }
37
+ pending.clear();
38
+ },
39
+ };
40
+ }
package/index.ts CHANGED
@@ -19,9 +19,10 @@ import * as path from "node:path";
19
19
  import { type ExtensionAPI, type ExtensionContext, type ToolDefinition } from "@mariozechner/pi-coding-agent";
20
20
  import { Text } from "@mariozechner/pi-tui";
21
21
  import { type AgentConfig, type AgentScope, discoverAgents, discoverAgentsAll } from "./agents.js";
22
+ import { resolveExecutionAgentScope } from "./agent-scope.js";
22
23
  import { cleanupOldChainDirs, getStepAgents, isParallelStep, resolveStepBehavior, type ChainStep, type SequentialStep } from "./settings.js";
23
24
  import { ChainClarifyComponent, type ChainClarifyResult, type ModelInfo } from "./chain-clarify.js";
24
- import { cleanupOldArtifacts, getArtifactsDir } from "./artifacts.js";
25
+ import { cleanupAllArtifactDirs, cleanupOldArtifacts, getArtifactsDir } from "./artifacts.js";
25
26
  import {
26
27
  type AgentProgress,
27
28
  type ArtifactConfig,
@@ -41,12 +42,15 @@ import {
41
42
  checkSubagentDepth,
42
43
  } from "./types.js";
43
44
  import { readStatus, findByPrefix, getFinalOutput, mapConcurrent } from "./utils.js";
45
+ import { buildCompletionKey, markSeenWithTtl } from "./completion-dedupe.js";
46
+ import { createFileCoalescer } from "./file-coalescer.js";
44
47
  import { runSync } from "./execution.js";
45
48
  import { renderWidget, renderSubagentResult } from "./render.js";
46
49
  import { SubagentParams, StatusParams } from "./schemas.js";
47
50
  import { executeChain } from "./chain-execution.js";
48
51
  import { isAsyncAvailable, executeAsyncChain, executeAsyncSingle } from "./async-execution.js";
49
52
  import { discoverAvailableSkills, normalizeSkillInput } from "./skills.js";
53
+ import { finalizeSingleOutput, injectSingleOutputInstruction, resolveSingleOutputPath } from "./single-output.js";
50
54
  import { AgentManagerComponent, type ManagerResult } from "./agent-manager.js";
51
55
  import { recordRun } from "./run-history.js";
52
56
  import { handleManagementAction } from "./agent-management.js";
@@ -74,7 +78,7 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
74
78
  const asyncByDefault = config.asyncByDefault === true;
75
79
 
76
80
  const tempArtifactsDir = getArtifactsDir(null);
77
- cleanupOldArtifacts(tempArtifactsDir, DEFAULT_ARTIFACT_CONFIG.cleanupDays);
81
+ cleanupAllArtifactDirs(DEFAULT_ARTIFACT_CONFIG.cleanupDays);
78
82
  let baseCwd = process.cwd();
79
83
  let currentSessionId: string | null = null;
80
84
  const asyncJobs = new Map<string, AsyncJobState>();
@@ -125,6 +129,8 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
125
129
  poller.unref?.();
126
130
  };
127
131
 
132
+ const completionSeen = new Map<string, number>();
133
+ const completionTtlMs = 10 * 60 * 1000;
128
134
  const handleResult = (file: string) => {
129
135
  const p = path.join(RESULTS_DIR, file);
130
136
  if (!fs.existsSync(p)) return;
@@ -132,19 +138,30 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
132
138
  const data = JSON.parse(fs.readFileSync(p, "utf-8"));
133
139
  if (data.sessionId && data.sessionId !== currentSessionId) return;
134
140
  if (!data.sessionId && data.cwd && data.cwd !== baseCwd) return;
141
+ const now = Date.now();
142
+ const completionKey = buildCompletionKey(data, `result:${file}`);
143
+ if (markSeenWithTtl(completionSeen, completionKey, now, completionTtlMs)) {
144
+ try {
145
+ fs.unlinkSync(p);
146
+ } catch {}
147
+ return;
148
+ }
135
149
  pi.events.emit("subagent:complete", data);
136
- pi.events.emit("subagent_enhanced:complete", data);
137
150
  fs.unlinkSync(p);
138
151
  } catch {}
139
152
  };
140
153
 
154
+ const resultFileCoalescer = createFileCoalescer(handleResult, 50);
141
155
  const watcher = fs.watch(RESULTS_DIR, (ev, file) => {
142
- if (ev === "rename" && file?.toString().endsWith(".json")) setTimeout(() => handleResult(file.toString()), 50);
156
+ if (ev !== "rename" || !file) return;
157
+ const fileName = file.toString();
158
+ if (!fileName.endsWith(".json")) return;
159
+ resultFileCoalescer.schedule(fileName);
143
160
  });
144
161
  watcher.unref?.();
145
162
  fs.readdirSync(RESULTS_DIR)
146
163
  .filter((f) => f.endsWith(".json"))
147
- .forEach(handleResult);
164
+ .forEach((file) => resultFileCoalescer.schedule(file, 0));
148
165
 
149
166
  const tool: ToolDefinition<typeof SubagentParams, Details> = {
150
167
  name: "subagent",
@@ -159,7 +176,7 @@ EXECUTION (use exactly ONE mode):
159
176
  CHAIN TEMPLATE VARIABLES (use in task strings):
160
177
  • {task} - The original task/request from the user
161
178
  • {previous} - Text response from the previous step (empty for first step)
162
- • {chain_dir} - Shared directory for chain files (e.g., /tmp/pi-chain-runs/abc123/)
179
+ • {chain_dir} - Shared directory for chain files (e.g., <tmpdir>/pi-chain-runs/abc123/)
163
180
 
164
181
  CHAIN DATA FLOW:
165
182
  1. Each step's text response automatically becomes {previous} for the next step
@@ -208,7 +225,7 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
208
225
  };
209
226
  }
210
227
 
211
- const scope: AgentScope = params.agentScope ?? "user";
228
+ const scope: AgentScope = resolveExecutionAgentScope(params.agentScope);
212
229
  currentSessionId = ctx.sessionManager.getSessionFile() ?? `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
213
230
  const agents = discoverAgents(ctx.cwd, scope).agents;
214
231
  const runId = randomUUID().slice(0, 8);
@@ -352,6 +369,8 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
352
369
  details: { mode: "single" as const, results: [] },
353
370
  };
354
371
  }
372
+ const rawOutput = params.output !== undefined ? params.output : a.output;
373
+ const effectiveOutput: string | false | undefined = rawOutput === true ? a.output : rawOutput;
355
374
  return executeAsyncSingle(id, {
356
375
  agent: params.agent!,
357
376
  task: params.task!,
@@ -369,6 +388,7 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
369
388
  if (normalized === undefined) return undefined;
370
389
  return normalized;
371
390
  })(),
391
+ output: effectiveOutput,
372
392
  });
373
393
  }
374
394
  }
@@ -425,7 +445,7 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
425
445
 
426
446
  // Mutable copies for TUI modifications
427
447
  let tasks = params.tasks.map(t => t.task);
428
- const modelOverrides: (string | undefined)[] = new Array(params.tasks.length).fill(undefined);
448
+ const modelOverrides: (string | undefined)[] = params.tasks.map(t => (t as { model?: string }).model);
429
449
  // Initialize skill overrides from task-level skill params (may be overridden by TUI)
430
450
  const skillOverrides: (string[] | false | undefined)[] = params.tasks.map(t =>
431
451
  normalizeSkillInput((t as { skill?: string | string[] | boolean }).skill)
@@ -598,17 +618,9 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
598
618
  if (override?.skills !== undefined) skillOverride = override.skills;
599
619
  }
600
620
 
601
- // Compute output path at runtime (uses effectiveOutput which may be TUI-modified)
602
621
  const cleanTask = task;
603
- let outputPath: string | undefined;
604
- if (typeof effectiveOutput === 'string' && effectiveOutput) {
605
- const outputDir = `/tmp/pi-${agentConfig.name}-${runId}`;
606
- fs.mkdirSync(outputDir, { recursive: true });
607
- outputPath = `${outputDir}/${effectiveOutput}`;
608
-
609
- // Inject output instruction into task
610
- task += `\n\n---\n**Output:** Write your findings to: ${outputPath}`;
611
- }
622
+ const outputPath = resolveSingleOutputPath(effectiveOutput, ctx.cwd, params.cwd);
623
+ task = injectSingleOutputInstruction(task, outputPath);
612
624
 
613
625
  const effectiveSkills = skillOverride === false
614
626
  ? []
@@ -634,11 +646,13 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
634
646
  if (r.progress) allProgress.push(r.progress);
635
647
  if (r.artifactPaths) allArtifactPaths.push(r.artifactPaths);
636
648
 
637
- // Get output and append file path if applicable
638
- let output = r.truncation?.text || getFinalOutput(r.messages);
639
- if (outputPath && r.exitCode === 0) {
640
- output += `\n\n📄 Output saved to: ${outputPath}`;
641
- }
649
+ const fullOutput = getFinalOutput(r.messages);
650
+ const finalizedOutput = finalizeSingleOutput({
651
+ fullOutput,
652
+ truncatedOutput: r.truncation?.text,
653
+ outputPath,
654
+ exitCode: r.exitCode,
655
+ });
642
656
 
643
657
  if (r.exitCode !== 0)
644
658
  return {
@@ -653,7 +667,7 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
653
667
  isError: true,
654
668
  };
655
669
  return {
656
- content: [{ type: "text", text: output || "(no output)" }],
670
+ content: [{ type: "text", text: finalizedOutput.displayOutput || "(no output)" }],
657
671
  details: {
658
672
  mode: "single",
659
673
  results: [r],
@@ -1129,12 +1143,23 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
1129
1143
  }
1130
1144
  });
1131
1145
 
1146
+ const cleanupSessionArtifacts = (ctx: ExtensionContext) => {
1147
+ try {
1148
+ const sessionFile = ctx.sessionManager.getSessionFile();
1149
+ if (sessionFile) {
1150
+ cleanupOldArtifacts(getArtifactsDir(sessionFile), DEFAULT_ARTIFACT_CONFIG.cleanupDays);
1151
+ }
1152
+ } catch {}
1153
+ };
1154
+
1132
1155
  pi.on("session_start", (_event, ctx) => {
1133
1156
  baseCwd = ctx.cwd;
1134
1157
  currentSessionId = ctx.sessionManager.getSessionFile() ?? `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1158
+ cleanupSessionArtifacts(ctx);
1135
1159
  for (const timer of cleanupTimers.values()) clearTimeout(timer);
1136
1160
  cleanupTimers.clear();
1137
1161
  asyncJobs.clear();
1162
+ resultFileCoalescer.clear();
1138
1163
  if (ctx.hasUI) {
1139
1164
  lastUiContext = ctx;
1140
1165
  renderWidget(ctx, []);
@@ -1143,9 +1168,11 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
1143
1168
  pi.on("session_switch", (_event, ctx) => {
1144
1169
  baseCwd = ctx.cwd;
1145
1170
  currentSessionId = ctx.sessionManager.getSessionFile() ?? `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1171
+ cleanupSessionArtifacts(ctx);
1146
1172
  for (const timer of cleanupTimers.values()) clearTimeout(timer);
1147
1173
  cleanupTimers.clear();
1148
1174
  asyncJobs.clear();
1175
+ resultFileCoalescer.clear();
1149
1176
  if (ctx.hasUI) {
1150
1177
  lastUiContext = ctx;
1151
1178
  renderWidget(ctx, []);
@@ -1154,9 +1181,11 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
1154
1181
  pi.on("session_branch", (_event, ctx) => {
1155
1182
  baseCwd = ctx.cwd;
1156
1183
  currentSessionId = ctx.sessionManager.getSessionFile() ?? `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1184
+ cleanupSessionArtifacts(ctx);
1157
1185
  for (const timer of cleanupTimers.values()) clearTimeout(timer);
1158
1186
  cleanupTimers.clear();
1159
1187
  asyncJobs.clear();
1188
+ resultFileCoalescer.clear();
1160
1189
  if (ctx.hasUI) {
1161
1190
  lastUiContext = ctx;
1162
1191
  renderWidget(ctx, []);
@@ -1172,6 +1201,7 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
1172
1201
  }
1173
1202
  cleanupTimers.clear();
1174
1203
  asyncJobs.clear();
1204
+ resultFileCoalescer.clear();
1175
1205
  if (lastUiContext?.hasUI) {
1176
1206
  lastUiContext.ui.setWidget(WIDGET_KEY, undefined);
1177
1207
  }
@@ -0,0 +1,81 @@
1
+ import * as fs from "node:fs";
2
+
3
+ export interface DrainableSource {
4
+ pause(): void;
5
+ resume(): void;
6
+ }
7
+
8
+ export interface JsonlWriteStream {
9
+ write(chunk: string): boolean;
10
+ once(event: "drain", listener: () => void): JsonlWriteStream;
11
+ end(callback?: () => void): void;
12
+ }
13
+
14
+ const DEFAULT_MAX_JSONL_BYTES = 50 * 1024 * 1024;
15
+
16
+ export interface JsonlWriterDeps {
17
+ createWriteStream?: (filePath: string) => JsonlWriteStream;
18
+ maxBytes?: number;
19
+ }
20
+
21
+ export interface JsonlWriter {
22
+ writeLine(line: string): void;
23
+ close(): Promise<void>;
24
+ }
25
+
26
+ export function createJsonlWriter(
27
+ filePath: string | undefined,
28
+ source: DrainableSource,
29
+ deps: JsonlWriterDeps = {},
30
+ ): JsonlWriter {
31
+ if (!filePath) {
32
+ return {
33
+ writeLine() {},
34
+ async close() {},
35
+ };
36
+ }
37
+
38
+ const createWriteStream = deps.createWriteStream ?? ((targetPath: string) => fs.createWriteStream(targetPath, { flags: "a" }));
39
+ let stream: JsonlWriteStream | undefined;
40
+ try {
41
+ stream = createWriteStream(filePath);
42
+ } catch {
43
+ return {
44
+ writeLine() {},
45
+ async close() {},
46
+ };
47
+ }
48
+
49
+ let backpressured = false;
50
+ let closed = false;
51
+ let bytesWritten = 0;
52
+ const maxBytes = deps.maxBytes ?? DEFAULT_MAX_JSONL_BYTES;
53
+
54
+ return {
55
+ writeLine(line: string) {
56
+ if (!stream || closed || !line.trim()) return;
57
+ const chunk = `${line}\n`;
58
+ const chunkBytes = Buffer.byteLength(chunk, "utf-8");
59
+ if (bytesWritten + chunkBytes > maxBytes) return;
60
+ try {
61
+ const ok = stream.write(chunk);
62
+ bytesWritten += chunkBytes;
63
+ if (!ok && !backpressured) {
64
+ backpressured = true;
65
+ source.pause();
66
+ stream.once("drain", () => {
67
+ backpressured = false;
68
+ if (!closed) source.resume();
69
+ });
70
+ }
71
+ } catch {}
72
+ },
73
+ async close() {
74
+ if (!stream || closed) return;
75
+ closed = true;
76
+ const current = stream;
77
+ stream = undefined;
78
+ await new Promise<void>((resolve) => current.end(() => resolve()));
79
+ },
80
+ };
81
+ }
package/notify.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
6
+ import { buildCompletionKey, getGlobalSeenMap, markSeenWithTtl } from "./completion-dedupe.js";
6
7
 
7
8
  interface ChainStepResult {
8
9
  agent: string;
@@ -27,22 +28,14 @@ interface SubagentResult {
27
28
  }
28
29
 
29
30
  export default function registerSubagentNotify(pi: ExtensionAPI): void {
30
- const seen = new Map<string, number>();
31
+ const seen = getGlobalSeenMap("__pi_subagents_notify_seen__");
31
32
  const ttlMs = 10 * 60 * 1000;
32
33
 
33
- const prune = (now: number) => {
34
- for (const [key, ts] of seen.entries()) {
35
- if (now - ts > ttlMs) seen.delete(key);
36
- }
37
- };
38
-
39
34
  const handleComplete = (data: unknown) => {
40
35
  const result = data as SubagentResult;
41
36
  const now = Date.now();
42
- const key = `${result.id ?? "no-id"}:${result.agent ?? "unknown"}:${result.timestamp ?? now}`;
43
- prune(now);
44
- if (seen.has(key)) return;
45
- seen.set(key, now);
37
+ const key = buildCompletionKey(result, "notify");
38
+ if (markSeenWithTtl(seen, key, now, ttlMs)) return;
46
39
 
47
40
  const agent = result.agent ?? "unknown";
48
41
  const status = result.success ? "completed" : "failed";
@@ -82,6 +75,4 @@ export default function registerSubagentNotify(pi: ExtensionAPI): void {
82
75
  };
83
76
 
84
77
  pi.events.on("subagent:complete", handleComplete);
85
- pi.events.on("subagent_enhanced:complete", handleComplete);
86
- pi.events.on("async_subagent:complete", handleComplete);
87
78
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-subagents",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
4
4
  "description": "Pi extension for delegating tasks to subagents with chains, parallel execution, and TUI clarification",
5
5
  "author": "Nico Bailon",
6
6
  "license": "MIT",
@@ -33,7 +33,7 @@
33
33
  "CHANGELOG.md"
34
34
  ],
35
35
  "scripts": {
36
- "test": "node --experimental-strip-types --test recursion-guard.test.ts"
36
+ "test": "node --experimental-strip-types --test *.test.ts"
37
37
  },
38
38
  "pi": {
39
39
  "extensions": [
package/pi-spawn.ts ADDED
@@ -0,0 +1,77 @@
1
+ import * as fs from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import * as path from "node:path";
4
+
5
+ const require = createRequire(import.meta.url);
6
+
7
+ export interface PiSpawnDeps {
8
+ platform?: NodeJS.Platform;
9
+ execPath?: string;
10
+ argv1?: string;
11
+ existsSync?: (filePath: string) => boolean;
12
+ readFileSync?: (filePath: string, encoding: "utf-8") => string;
13
+ resolvePackageJson?: () => string;
14
+ }
15
+
16
+ export interface PiSpawnCommand {
17
+ command: string;
18
+ args: string[];
19
+ }
20
+
21
+ function isRunnableNodeScript(filePath: string, existsSync: (filePath: string) => boolean): boolean {
22
+ if (!existsSync(filePath)) return false;
23
+ return /\.(?:mjs|cjs|js)$/i.test(filePath);
24
+ }
25
+
26
+ function normalizePath(filePath: string): string {
27
+ return path.isAbsolute(filePath) ? filePath : path.resolve(filePath);
28
+ }
29
+
30
+ export function resolveWindowsPiCliScript(deps: PiSpawnDeps = {}): string | undefined {
31
+ const existsSync = deps.existsSync ?? fs.existsSync;
32
+ const readFileSync = deps.readFileSync ?? ((filePath, encoding) => fs.readFileSync(filePath, encoding));
33
+ const argv1 = deps.argv1 ?? process.argv[1];
34
+
35
+ if (argv1) {
36
+ const argvPath = normalizePath(argv1);
37
+ if (isRunnableNodeScript(argvPath, existsSync)) {
38
+ return argvPath;
39
+ }
40
+ }
41
+
42
+ try {
43
+ const resolvePackageJson = deps.resolvePackageJson ?? (() => require.resolve("@mariozechner/pi-coding-agent/package.json"));
44
+ const packageJsonPath = resolvePackageJson();
45
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as {
46
+ bin?: string | Record<string, string>;
47
+ };
48
+ const binField = packageJson.bin;
49
+ const binPath = typeof binField === "string"
50
+ ? binField
51
+ : binField?.pi ?? Object.values(binField ?? {})[0];
52
+ if (!binPath) return undefined;
53
+ const candidate = normalizePath(path.resolve(path.dirname(packageJsonPath), binPath));
54
+ if (isRunnableNodeScript(candidate, existsSync)) {
55
+ return candidate;
56
+ }
57
+ } catch {
58
+ return undefined;
59
+ }
60
+
61
+ return undefined;
62
+ }
63
+
64
+ export function getPiSpawnCommand(args: string[], deps: PiSpawnDeps = {}): PiSpawnCommand {
65
+ const platform = deps.platform ?? process.platform;
66
+ if (platform === "win32") {
67
+ const piCliPath = resolveWindowsPiCliScript(deps);
68
+ if (piCliPath) {
69
+ return {
70
+ command: deps.execPath ?? process.execPath,
71
+ args: [piCliPath, ...args],
72
+ };
73
+ }
74
+ }
75
+
76
+ return { command: "pi", args };
77
+ }