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/CHANGELOG.md +30 -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 +23 -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 +54 -24
- package/jsonl-writer.ts +81 -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 +5 -3
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,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
|
-
|
|
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
|
|
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],
|
|
@@ -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
|
}
|
package/jsonl-writer.ts
ADDED
|
@@ -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 =
|
|
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.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
|
|
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
|
+
}
|