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/CHANGELOG.md +23 -1
- package/README.md +43 -18
- package/agent-management.ts +7 -0
- package/agent-manager-detail.ts +4 -0
- package/agent-manager-edit.ts +4 -2
- package/agent-scope.ts +6 -0
- package/agent-selection.ts +20 -0
- package/agent-serializer.ts +6 -0
- package/agents.ts +13 -12
- package/artifacts.ts +2 -1
- package/async-execution.ts +8 -18
- package/chain-execution.ts +15 -7
- package/completion-dedupe.ts +63 -0
- package/execution.ts +36 -13
- package/file-coalescer.ts +40 -0
- package/index.ts +40 -22
- package/jsonl-writer.ts +72 -0
- package/notify.ts +4 -13
- package/package.json +2 -2
- package/pi-spawn.ts +77 -0
- package/render.ts +44 -18
- package/schemas.ts +5 -4
- package/settings.ts +18 -2
- package/single-output.ts +55 -0
- package/skills.ts +221 -80
- package/subagent-runner.ts +32 -6
- package/types.ts +4 -2
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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.,
|
|
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
|
|
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)[] =
|
|
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
|
-
|
|
604
|
-
|
|
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
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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:
|
|
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
|
}
|
package/jsonl-writer.ts
ADDED
|
@@ -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 =
|
|
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 =
|
|
43
|
-
|
|
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.
|
|
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
|
|
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
|
-
:
|
|
164
|
-
? theme.fg("
|
|
165
|
-
:
|
|
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
|
-
:
|
|
212
|
-
? theme.fg("
|
|
213
|
-
:
|
|
214
|
-
? theme.fg("
|
|
215
|
-
:
|
|
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
|
-
|
|
289
|
+
const resultOutput = getFinalOutput(r.messages);
|
|
263
290
|
const statusIcon = rRunning
|
|
264
291
|
? theme.fg("warning", "●")
|
|
265
|
-
: r.exitCode
|
|
266
|
-
? theme.fg("
|
|
267
|
-
:
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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) {
|