pi-subagents 0.8.2 → 0.8.3

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,6 +19,7 @@ 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
25
  import { cleanupOldArtifacts, getArtifactsDir } from "./artifacts.js";
@@ -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";
@@ -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],
@@ -1135,6 +1149,7 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
1135
1149
  for (const timer of cleanupTimers.values()) clearTimeout(timer);
1136
1150
  cleanupTimers.clear();
1137
1151
  asyncJobs.clear();
1152
+ resultFileCoalescer.clear();
1138
1153
  if (ctx.hasUI) {
1139
1154
  lastUiContext = ctx;
1140
1155
  renderWidget(ctx, []);
@@ -1146,6 +1161,7 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
1146
1161
  for (const timer of cleanupTimers.values()) clearTimeout(timer);
1147
1162
  cleanupTimers.clear();
1148
1163
  asyncJobs.clear();
1164
+ resultFileCoalescer.clear();
1149
1165
  if (ctx.hasUI) {
1150
1166
  lastUiContext = ctx;
1151
1167
  renderWidget(ctx, []);
@@ -1157,6 +1173,7 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
1157
1173
  for (const timer of cleanupTimers.values()) clearTimeout(timer);
1158
1174
  cleanupTimers.clear();
1159
1175
  asyncJobs.clear();
1176
+ resultFileCoalescer.clear();
1160
1177
  if (ctx.hasUI) {
1161
1178
  lastUiContext = ctx;
1162
1179
  renderWidget(ctx, []);
@@ -1172,6 +1189,7 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
1172
1189
  }
1173
1190
  cleanupTimers.clear();
1174
1191
  asyncJobs.clear();
1192
+ resultFileCoalescer.clear();
1175
1193
  if (lastUiContext?.hasUI) {
1176
1194
  lastUiContext.ui.setWidget(WIDGET_KEY, undefined);
1177
1195
  }
@@ -0,0 +1,72 @@
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
+ export interface JsonlWriterDeps {
15
+ createWriteStream?: (filePath: string) => JsonlWriteStream;
16
+ }
17
+
18
+ export interface JsonlWriter {
19
+ writeLine(line: string): void;
20
+ close(): Promise<void>;
21
+ }
22
+
23
+ export function createJsonlWriter(
24
+ filePath: string | undefined,
25
+ source: DrainableSource,
26
+ deps: JsonlWriterDeps = {},
27
+ ): JsonlWriter {
28
+ if (!filePath) {
29
+ return {
30
+ writeLine() {},
31
+ async close() {},
32
+ };
33
+ }
34
+
35
+ const createWriteStream = deps.createWriteStream ?? ((targetPath: string) => fs.createWriteStream(targetPath, { flags: "a" }));
36
+ let stream: JsonlWriteStream | undefined;
37
+ try {
38
+ stream = createWriteStream(filePath);
39
+ } catch {
40
+ return {
41
+ writeLine() {},
42
+ async close() {},
43
+ };
44
+ }
45
+
46
+ let backpressured = false;
47
+ let closed = false;
48
+
49
+ return {
50
+ writeLine(line: string) {
51
+ if (!stream || closed || !line.trim()) return;
52
+ try {
53
+ const ok = stream.write(`${line}\n`);
54
+ if (!ok && !backpressured) {
55
+ backpressured = true;
56
+ source.pause();
57
+ stream.once("drain", () => {
58
+ backpressured = false;
59
+ if (!closed) source.resume();
60
+ });
61
+ }
62
+ } catch {}
63
+ },
64
+ async close() {
65
+ if (!stream || closed) return;
66
+ closed = true;
67
+ const current = stream;
68
+ stream = undefined;
69
+ await new Promise<void>((resolve) => current.end(() => resolve()));
70
+ },
71
+ };
72
+ }
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.3",
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
+ }
package/render.ts CHANGED
@@ -28,6 +28,21 @@ function computeWidgetHash(jobs: AsyncJobState[]): string {
28
28
  ).join("|");
29
29
  }
30
30
 
31
+ function extractOutputTarget(task: string): string | undefined {
32
+ const writeToMatch = task.match(/\[Write to:\s*([^\]\n]+)\]/i);
33
+ if (writeToMatch?.[1]?.trim()) return writeToMatch[1].trim();
34
+ const findingsMatch = task.match(/Write your findings to:\s*(\S+)/i);
35
+ if (findingsMatch?.[1]?.trim()) return findingsMatch[1].trim();
36
+ const outputMatch = task.match(/[Oo]utput(?:\s+to)?\s*:\s*(\S+)/i);
37
+ if (outputMatch?.[1]?.trim()) return outputMatch[1].trim();
38
+ return undefined;
39
+ }
40
+
41
+ function hasEmptyTextOutputWithoutOutputTarget(task: string, output: string): boolean {
42
+ if (output.trim()) return false;
43
+ return !extractOutputTarget(task);
44
+ }
45
+
31
46
  /**
32
47
  * Render the async jobs widget
33
48
  */
@@ -158,11 +173,18 @@ export function renderSubagentResult(
158
173
  const hasRunning = d.progress?.some((p) => p.status === "running")
159
174
  || d.results.some((r) => r.progress?.status === "running");
160
175
  const ok = d.results.filter((r) => r.progress?.status === "completed" || (r.exitCode === 0 && r.progress?.status !== "running")).length;
176
+ const hasEmptyWithoutTarget = d.results.some((r) =>
177
+ r.exitCode === 0
178
+ && r.progress?.status !== "running"
179
+ && hasEmptyTextOutputWithoutOutputTarget(r.task, getFinalOutput(r.messages)),
180
+ );
161
181
  const icon = hasRunning
162
182
  ? theme.fg("warning", "...")
163
- : ok === d.results.length
164
- ? theme.fg("success", "ok")
165
- : theme.fg("error", "X");
183
+ : hasEmptyWithoutTarget
184
+ ? theme.fg("warning", "")
185
+ : ok === d.results.length
186
+ ? theme.fg("success", "ok")
187
+ : theme.fg("error", "X");
166
188
 
167
189
  const totalSummary =
168
190
  d.progressSummary ||
@@ -205,14 +227,19 @@ export function renderSubagentResult(
205
227
  const result = d.results[i];
206
228
  const isFailed = result && result.exitCode !== 0 && result.progress?.status !== "running";
207
229
  const isComplete = result && result.exitCode === 0 && result.progress?.status !== "running";
230
+ const isEmptyWithoutTarget = Boolean(result)
231
+ && Boolean(isComplete)
232
+ && hasEmptyTextOutputWithoutOutputTarget(result.task, getFinalOutput(result.messages));
208
233
  const isCurrent = i === (d.currentStepIndex ?? d.results.length);
209
234
  const icon = isFailed
210
235
  ? theme.fg("error", "✗")
211
- : isComplete
212
- ? theme.fg("success", "")
213
- : isCurrent && hasRunning
214
- ? theme.fg("warning", "")
215
- : theme.fg("dim", "○");
236
+ : isEmptyWithoutTarget
237
+ ? theme.fg("warning", "")
238
+ : isComplete
239
+ ? theme.fg("success", "")
240
+ : isCurrent && hasRunning
241
+ ? theme.fg("warning", "●")
242
+ : theme.fg("dim", "○");
216
243
  return `${icon} ${agent}`;
217
244
  })
218
245
  .join(theme.fg("dim", " → "))
@@ -259,28 +286,27 @@ export function renderSubagentResult(
259
286
  const rProg = r.progress || progressFromArray || r.progressSummary;
260
287
  const rRunning = rProg?.status === "running";
261
288
 
262
- // Step header with status
289
+ const resultOutput = getFinalOutput(r.messages);
263
290
  const statusIcon = rRunning
264
291
  ? theme.fg("warning", "●")
265
- : r.exitCode === 0
266
- ? theme.fg("success", "")
267
- : theme.fg("error", "✗");
292
+ : r.exitCode !== 0
293
+ ? theme.fg("error", "")
294
+ : hasEmptyTextOutputWithoutOutputTarget(r.task, resultOutput)
295
+ ? theme.fg("warning", "⚠")
296
+ : theme.fg("success", "✓");
268
297
  const stats = rProg ? ` | ${rProg.toolCount} tools, ${formatDuration(rProg.durationMs)}` : "";
269
- // Show model if available (full provider/model format)
270
298
  const modelDisplay = r.model ? theme.fg("dim", ` (${r.model})`) : "";
271
299
  const stepHeader = rRunning
272
300
  ? `${statusIcon} Step ${i + 1}: ${theme.bold(theme.fg("warning", r.agent))}${modelDisplay}${stats}`
273
301
  : `${statusIcon} Step ${i + 1}: ${theme.bold(r.agent)}${modelDisplay}${stats}`;
274
302
  c.addChild(new Text(stepHeader, 0, 0));
275
303
 
276
- // Task (truncated)
277
304
  const taskPreview = r.task.slice(0, 120) + (r.task.length > 120 ? "..." : "");
278
305
  c.addChild(new Text(theme.fg("dim", ` task: ${taskPreview}`), 0, 0));
279
306
 
280
- // Output target (extract from task)
281
- const outputMatch = r.task.match(/[Oo]utput(?:\s+to)?\s+([^\s]+\.(?:md|txt|json))/);
282
- if (outputMatch) {
283
- c.addChild(new Text(theme.fg("dim", ` output: ${outputMatch[1]}`), 0, 0));
307
+ const outputTarget = extractOutputTarget(r.task);
308
+ if (outputTarget) {
309
+ c.addChild(new Text(theme.fg("dim", ` output: ${outputTarget}`), 0, 0));
284
310
  }
285
311
 
286
312
  if (r.skills?.length) {