pi-subagents 0.3.1 → 0.3.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 +69 -0
- package/README.md +15 -2
- package/chain-clarify.ts +480 -19
- package/chain-execution.ts +109 -24
- package/execution.ts +72 -38
- package/index.ts +34 -4
- package/package.json +1 -1
- package/render.ts +31 -4
- package/schemas.ts +11 -9
- package/settings.ts +24 -20
- package/types.ts +3 -1
- package/utils.ts +51 -13
package/chain-execution.ts
CHANGED
|
@@ -7,7 +7,7 @@ import * as path from "node:path";
|
|
|
7
7
|
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
|
8
8
|
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
9
9
|
import type { AgentConfig } from "./agents.js";
|
|
10
|
-
import { ChainClarifyComponent, type ChainClarifyResult, type BehaviorOverride } from "./chain-clarify.js";
|
|
10
|
+
import { ChainClarifyComponent, type ChainClarifyResult, type BehaviorOverride, type ModelInfo } from "./chain-clarify.js";
|
|
11
11
|
import {
|
|
12
12
|
resolveChainTemplates,
|
|
13
13
|
createChainDir,
|
|
@@ -36,6 +36,28 @@ import {
|
|
|
36
36
|
MAX_CONCURRENCY,
|
|
37
37
|
} from "./types.js";
|
|
38
38
|
|
|
39
|
+
/** Resolve a model name to its full provider/model format */
|
|
40
|
+
function resolveModelFullId(modelName: string | undefined, availableModels: ModelInfo[]): string | undefined {
|
|
41
|
+
if (!modelName) return undefined;
|
|
42
|
+
// If already in provider/model format, return as-is
|
|
43
|
+
if (modelName.includes("/")) return modelName;
|
|
44
|
+
|
|
45
|
+
// Handle thinking level suffixes (e.g., "claude-sonnet-4-5:high")
|
|
46
|
+
// Strip the suffix for lookup, then add it back
|
|
47
|
+
const colonIdx = modelName.lastIndexOf(":");
|
|
48
|
+
const baseModel = colonIdx !== -1 ? modelName.substring(0, colonIdx) : modelName;
|
|
49
|
+
const thinkingSuffix = colonIdx !== -1 ? modelName.substring(colonIdx) : "";
|
|
50
|
+
|
|
51
|
+
// Look up base model in available models to find provider
|
|
52
|
+
const match = availableModels.find(m => m.id === baseModel);
|
|
53
|
+
if (match) {
|
|
54
|
+
return thinkingSuffix ? `${match.fullId}${thinkingSuffix}` : match.fullId;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Fallback: return as-is
|
|
58
|
+
return modelName;
|
|
59
|
+
}
|
|
60
|
+
|
|
39
61
|
export interface ChainExecutionParams {
|
|
40
62
|
chain: ChainStep[];
|
|
41
63
|
agents: AgentConfig[];
|
|
@@ -110,6 +132,13 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
|
|
|
110
132
|
// Behavior overrides from TUI (set if TUI is shown, undefined otherwise)
|
|
111
133
|
let tuiBehaviorOverrides: (BehaviorOverride | undefined)[] | undefined;
|
|
112
134
|
|
|
135
|
+
// Get available models for model resolution (used in TUI and execution)
|
|
136
|
+
const availableModels: ModelInfo[] = ctx.modelRegistry.getAvailable().map((m) => ({
|
|
137
|
+
provider: m.provider,
|
|
138
|
+
id: m.id,
|
|
139
|
+
fullId: `${m.provider}/${m.id}`,
|
|
140
|
+
}));
|
|
141
|
+
|
|
113
142
|
if (shouldClarify) {
|
|
114
143
|
// Sequential-only chain: use existing TUI
|
|
115
144
|
const seqSteps = chainSteps as SequentialStep[];
|
|
@@ -154,6 +183,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
|
|
|
154
183
|
originalTask,
|
|
155
184
|
chainDir,
|
|
156
185
|
resolvedBehaviors,
|
|
186
|
+
availableModels,
|
|
157
187
|
done,
|
|
158
188
|
),
|
|
159
189
|
{
|
|
@@ -227,18 +257,31 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
|
|
|
227
257
|
} as SingleResult;
|
|
228
258
|
}
|
|
229
259
|
|
|
230
|
-
//
|
|
260
|
+
// Resolve behavior for this parallel task
|
|
261
|
+
const behavior = parallelBehaviors[taskIndex]!;
|
|
262
|
+
|
|
263
|
+
// Build chain instructions (prefix goes BEFORE task, suffix goes AFTER)
|
|
231
264
|
const taskTemplate = parallelTemplates[taskIndex] ?? "{previous}";
|
|
232
265
|
const templateHasPrevious = taskTemplate.includes("{previous}");
|
|
266
|
+
const { prefix, suffix } = buildChainInstructions(
|
|
267
|
+
behavior,
|
|
268
|
+
chainDir,
|
|
269
|
+
false, // parallel tasks don't create progress (pre-created above)
|
|
270
|
+
templateHasPrevious ? undefined : prev
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
// Build task string with variable substitution
|
|
233
274
|
let taskStr = taskTemplate;
|
|
234
275
|
taskStr = taskStr.replace(/\{task\}/g, originalTask);
|
|
235
276
|
taskStr = taskStr.replace(/\{previous\}/g, prev);
|
|
236
277
|
taskStr = taskStr.replace(/\{chain_dir\}/g, chainDir);
|
|
237
278
|
|
|
238
|
-
//
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
279
|
+
// Assemble final task: prefix (READ/WRITE instructions) + task + suffix
|
|
280
|
+
taskStr = prefix + taskStr + suffix;
|
|
281
|
+
|
|
282
|
+
// Resolve model to full provider/model format for consistent display
|
|
283
|
+
const taskAgentConfig = agents.find((a) => a.name === task.agent);
|
|
284
|
+
const effectiveModel = resolveModelFullId(taskAgentConfig?.model, availableModels);
|
|
242
285
|
|
|
243
286
|
const r = await runSync(ctx.cwd, agents, task.agent, taskStr, {
|
|
244
287
|
cwd: task.cwd ?? cwd,
|
|
@@ -249,19 +292,24 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
|
|
|
249
292
|
share: shareEnabled,
|
|
250
293
|
artifactsDir: artifactConfig.enabled ? artifactsDir : undefined,
|
|
251
294
|
artifactConfig,
|
|
295
|
+
modelOverride: effectiveModel,
|
|
252
296
|
onUpdate: onUpdate
|
|
253
|
-
? (p) =>
|
|
297
|
+
? (p) => {
|
|
298
|
+
// Use concat instead of spread for better performance
|
|
299
|
+
const stepResults = p.details?.results || [];
|
|
300
|
+
const stepProgress = p.details?.progress || [];
|
|
254
301
|
onUpdate({
|
|
255
302
|
...p,
|
|
256
303
|
details: {
|
|
257
304
|
mode: "chain",
|
|
258
|
-
results:
|
|
259
|
-
progress:
|
|
305
|
+
results: results.concat(stepResults),
|
|
306
|
+
progress: allProgress.concat(stepProgress),
|
|
260
307
|
chainAgents,
|
|
261
308
|
totalSteps,
|
|
262
309
|
currentStepIndex: stepIndex,
|
|
263
310
|
},
|
|
264
|
-
})
|
|
311
|
+
});
|
|
312
|
+
}
|
|
265
313
|
: undefined,
|
|
266
314
|
});
|
|
267
315
|
|
|
@@ -336,14 +384,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
|
|
|
336
384
|
};
|
|
337
385
|
}
|
|
338
386
|
|
|
339
|
-
//
|
|
340
|
-
const templateHasPrevious = stepTemplate.includes("{previous}");
|
|
341
|
-
let stepTask = stepTemplate;
|
|
342
|
-
stepTask = stepTask.replace(/\{task\}/g, originalTask);
|
|
343
|
-
stepTask = stepTask.replace(/\{previous\}/g, prev);
|
|
344
|
-
stepTask = stepTask.replace(/\{chain_dir\}/g, chainDir);
|
|
345
|
-
|
|
346
|
-
// Resolve behavior (TUI overrides take precedence over step config)
|
|
387
|
+
// Resolve behavior first (TUI overrides take precedence over step config)
|
|
347
388
|
const tuiOverride = tuiBehaviorOverrides?.[stepIndex];
|
|
348
389
|
const stepOverride: StepOverrides = {
|
|
349
390
|
output: tuiOverride?.output !== undefined ? tuiOverride.output : seqStep.output,
|
|
@@ -358,8 +399,26 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
|
|
|
358
399
|
progressCreated = true;
|
|
359
400
|
}
|
|
360
401
|
|
|
361
|
-
//
|
|
362
|
-
|
|
402
|
+
// Build chain instructions (prefix goes BEFORE task, suffix goes AFTER)
|
|
403
|
+
const templateHasPrevious = stepTemplate.includes("{previous}");
|
|
404
|
+
const { prefix, suffix } = buildChainInstructions(
|
|
405
|
+
behavior,
|
|
406
|
+
chainDir,
|
|
407
|
+
isFirstProgress,
|
|
408
|
+
templateHasPrevious ? undefined : prev
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
// Build task string with variable substitution
|
|
412
|
+
let stepTask = stepTemplate;
|
|
413
|
+
stepTask = stepTask.replace(/\{task\}/g, originalTask);
|
|
414
|
+
stepTask = stepTask.replace(/\{previous\}/g, prev);
|
|
415
|
+
stepTask = stepTask.replace(/\{chain_dir\}/g, chainDir);
|
|
416
|
+
|
|
417
|
+
// Assemble final task: prefix (READ/WRITE instructions) + task + suffix (progress, previous summary)
|
|
418
|
+
stepTask = prefix + stepTask + suffix;
|
|
419
|
+
|
|
420
|
+
// Resolve model: TUI override (already full format) or agent's model resolved to full format
|
|
421
|
+
const effectiveModel = tuiOverride?.model ?? resolveModelFullId(agentConfig.model, availableModels);
|
|
363
422
|
|
|
364
423
|
// Run step
|
|
365
424
|
const r = await runSync(ctx.cwd, agents, seqStep.agent, stepTask, {
|
|
@@ -371,19 +430,24 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
|
|
|
371
430
|
share: shareEnabled,
|
|
372
431
|
artifactsDir: artifactConfig.enabled ? artifactsDir : undefined,
|
|
373
432
|
artifactConfig,
|
|
433
|
+
modelOverride: effectiveModel,
|
|
374
434
|
onUpdate: onUpdate
|
|
375
|
-
? (p) =>
|
|
435
|
+
? (p) => {
|
|
436
|
+
// Use concat instead of spread for better performance
|
|
437
|
+
const stepResults = p.details?.results || [];
|
|
438
|
+
const stepProgress = p.details?.progress || [];
|
|
376
439
|
onUpdate({
|
|
377
440
|
...p,
|
|
378
441
|
details: {
|
|
379
442
|
mode: "chain",
|
|
380
|
-
results:
|
|
381
|
-
progress:
|
|
443
|
+
results: results.concat(stepResults),
|
|
444
|
+
progress: allProgress.concat(stepProgress),
|
|
382
445
|
chainAgents,
|
|
383
446
|
totalSteps,
|
|
384
447
|
currentStepIndex: stepIndex,
|
|
385
448
|
},
|
|
386
|
-
})
|
|
449
|
+
});
|
|
450
|
+
}
|
|
387
451
|
: undefined,
|
|
388
452
|
});
|
|
389
453
|
|
|
@@ -392,6 +456,27 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
|
|
|
392
456
|
if (r.progress) allProgress.push(r.progress);
|
|
393
457
|
if (r.artifactPaths) allArtifactPaths.push(r.artifactPaths);
|
|
394
458
|
|
|
459
|
+
// Validate expected output file was created
|
|
460
|
+
if (behavior.output && r.exitCode === 0) {
|
|
461
|
+
try {
|
|
462
|
+
const expectedPath = behavior.output.startsWith("/")
|
|
463
|
+
? behavior.output
|
|
464
|
+
: path.join(chainDir, behavior.output);
|
|
465
|
+
if (!fs.existsSync(expectedPath)) {
|
|
466
|
+
// Look for similar files that might have been created instead
|
|
467
|
+
const dirFiles = fs.readdirSync(chainDir);
|
|
468
|
+
const mdFiles = dirFiles.filter(f => f.endsWith(".md") && f !== "progress.md");
|
|
469
|
+
const warning = mdFiles.length > 0
|
|
470
|
+
? `Agent wrote to different file(s): ${mdFiles.join(", ")} instead of ${behavior.output}`
|
|
471
|
+
: `Agent did not create expected output file: ${behavior.output}`;
|
|
472
|
+
// Add warning to result but don't fail
|
|
473
|
+
r.error = r.error ? `${r.error}\n⚠️ ${warning}` : `⚠️ ${warning}`;
|
|
474
|
+
}
|
|
475
|
+
} catch {
|
|
476
|
+
// Ignore validation errors - this is just a diagnostic
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
395
480
|
// On failure, leave chain_dir for debugging
|
|
396
481
|
if (r.exitCode !== 0) {
|
|
397
482
|
const summary = buildChainSummary(chainSteps, results, chainDir, "failed", {
|
package/execution.ts
CHANGED
|
@@ -43,7 +43,7 @@ export async function runSync(
|
|
|
43
43
|
task: string,
|
|
44
44
|
options: RunSyncOptions,
|
|
45
45
|
): Promise<SingleResult> {
|
|
46
|
-
const { cwd, signal, onUpdate, maxOutput, artifactsDir, artifactConfig, runId, index } = options;
|
|
46
|
+
const { cwd, signal, onUpdate, maxOutput, artifactsDir, artifactConfig, runId, index, modelOverride } = options;
|
|
47
47
|
const agent = agents.find((a) => a.name === agentName);
|
|
48
48
|
if (!agent) {
|
|
49
49
|
return {
|
|
@@ -68,7 +68,9 @@ export async function runSync(
|
|
|
68
68
|
} catch {}
|
|
69
69
|
args.push("--session-dir", options.sessionDir);
|
|
70
70
|
}
|
|
71
|
-
|
|
71
|
+
// Use model override if provided, otherwise use agent's default model
|
|
72
|
+
const effectiveModel = modelOverride ?? agent.model;
|
|
73
|
+
if (effectiveModel) args.push("--model", effectiveModel);
|
|
72
74
|
if (agent.tools?.length) {
|
|
73
75
|
const builtinTools: string[] = [];
|
|
74
76
|
const extensionPaths: string[] = [];
|
|
@@ -101,6 +103,7 @@ export async function runSync(
|
|
|
101
103
|
exitCode: 0,
|
|
102
104
|
messages: [],
|
|
103
105
|
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 },
|
|
106
|
+
model: effectiveModel, // Initialize with the model we're using
|
|
104
107
|
};
|
|
105
108
|
|
|
106
109
|
const progress: AgentProgress = {
|
|
@@ -132,6 +135,50 @@ export async function runSync(
|
|
|
132
135
|
const proc = spawn("pi", args, { cwd: cwd ?? runtimeCwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
133
136
|
let buf = "";
|
|
134
137
|
|
|
138
|
+
// Throttled update mechanism - consolidates all updates
|
|
139
|
+
let lastUpdateTime = 0;
|
|
140
|
+
let updatePending = false;
|
|
141
|
+
let pendingTimer: ReturnType<typeof setTimeout> | null = null;
|
|
142
|
+
let processClosed = false;
|
|
143
|
+
const UPDATE_THROTTLE_MS = 50; // Reduced from 75ms for faster responsiveness
|
|
144
|
+
|
|
145
|
+
const scheduleUpdate = () => {
|
|
146
|
+
if (!onUpdate || processClosed) return;
|
|
147
|
+
const now = Date.now();
|
|
148
|
+
const elapsed = now - lastUpdateTime;
|
|
149
|
+
|
|
150
|
+
if (elapsed >= UPDATE_THROTTLE_MS) {
|
|
151
|
+
// Enough time passed, update immediately
|
|
152
|
+
// Clear any pending timer to avoid double-updates
|
|
153
|
+
if (pendingTimer) {
|
|
154
|
+
clearTimeout(pendingTimer);
|
|
155
|
+
pendingTimer = null;
|
|
156
|
+
}
|
|
157
|
+
lastUpdateTime = now;
|
|
158
|
+
updatePending = false;
|
|
159
|
+
progress.durationMs = now - startTime;
|
|
160
|
+
onUpdate({
|
|
161
|
+
content: [{ type: "text", text: getFinalOutput(result.messages) || "(running...)" }],
|
|
162
|
+
details: { mode: "single", results: [result], progress: [progress] },
|
|
163
|
+
});
|
|
164
|
+
} else if (!updatePending) {
|
|
165
|
+
// Schedule update for later
|
|
166
|
+
updatePending = true;
|
|
167
|
+
pendingTimer = setTimeout(() => {
|
|
168
|
+
pendingTimer = null;
|
|
169
|
+
if (updatePending && !processClosed) {
|
|
170
|
+
updatePending = false;
|
|
171
|
+
lastUpdateTime = Date.now();
|
|
172
|
+
progress.durationMs = Date.now() - startTime;
|
|
173
|
+
onUpdate({
|
|
174
|
+
content: [{ type: "text", text: getFinalOutput(result.messages) || "(running...)" }],
|
|
175
|
+
details: { mode: "single", results: [result], progress: [progress] },
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}, UPDATE_THROTTLE_MS - elapsed);
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
135
182
|
const processLine = (line: string) => {
|
|
136
183
|
if (!line.trim()) return;
|
|
137
184
|
jsonlLines.push(line);
|
|
@@ -144,11 +191,9 @@ export async function runSync(
|
|
|
144
191
|
progress.toolCount++;
|
|
145
192
|
progress.currentTool = evt.toolName;
|
|
146
193
|
progress.currentToolArgs = extractToolArgsPreview((evt.args || {}) as Record<string, unknown>);
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
details: { mode: "single", results: [result], progress: [progress] },
|
|
151
|
-
});
|
|
194
|
+
// Tool start is important - update immediately by forcing throttle reset
|
|
195
|
+
lastUpdateTime = 0;
|
|
196
|
+
scheduleUpdate();
|
|
152
197
|
}
|
|
153
198
|
|
|
154
199
|
if (evt.type === "tool_execution_end") {
|
|
@@ -164,11 +209,7 @@ export async function runSync(
|
|
|
164
209
|
}
|
|
165
210
|
progress.currentTool = undefined;
|
|
166
211
|
progress.currentToolArgs = undefined;
|
|
167
|
-
|
|
168
|
-
onUpdate({
|
|
169
|
-
content: [{ type: "text", text: getFinalOutput(result.messages) || "(running...)" }],
|
|
170
|
-
details: { mode: "single", results: [result], progress: [progress] },
|
|
171
|
-
});
|
|
212
|
+
scheduleUpdate();
|
|
172
213
|
}
|
|
173
214
|
|
|
174
215
|
if (evt.type === "message_end" && evt.message) {
|
|
@@ -193,15 +234,14 @@ export async function runSync(
|
|
|
193
234
|
.split("\n")
|
|
194
235
|
.filter((l) => l.trim())
|
|
195
236
|
.slice(-10);
|
|
196
|
-
// Append to existing recentOutput (keep last 50 total)
|
|
197
|
-
progress.recentOutput
|
|
237
|
+
// Append to existing recentOutput (keep last 50 total) - mutate in place for efficiency
|
|
238
|
+
progress.recentOutput.push(...lines);
|
|
239
|
+
if (progress.recentOutput.length > 50) {
|
|
240
|
+
progress.recentOutput.splice(0, progress.recentOutput.length - 50);
|
|
241
|
+
}
|
|
198
242
|
}
|
|
199
243
|
}
|
|
200
|
-
|
|
201
|
-
onUpdate({
|
|
202
|
-
content: [{ type: "text", text: getFinalOutput(result.messages) || "(running...)" }],
|
|
203
|
-
details: { mode: "single", results: [result], progress: [progress] },
|
|
204
|
-
});
|
|
244
|
+
scheduleUpdate();
|
|
205
245
|
}
|
|
206
246
|
if (evt.type === "tool_result_end" && evt.message) {
|
|
207
247
|
result.messages.push(evt.message);
|
|
@@ -212,21 +252,18 @@ export async function runSync(
|
|
|
212
252
|
.split("\n")
|
|
213
253
|
.filter((l) => l.trim())
|
|
214
254
|
.slice(-10);
|
|
215
|
-
// Append to existing recentOutput (keep last 50 total)
|
|
216
|
-
progress.recentOutput
|
|
255
|
+
// Append to existing recentOutput (keep last 50 total) - mutate in place for efficiency
|
|
256
|
+
progress.recentOutput.push(...toolLines);
|
|
257
|
+
if (progress.recentOutput.length > 50) {
|
|
258
|
+
progress.recentOutput.splice(0, progress.recentOutput.length - 50);
|
|
259
|
+
}
|
|
217
260
|
}
|
|
218
|
-
|
|
219
|
-
onUpdate({
|
|
220
|
-
content: [{ type: "text", text: getFinalOutput(result.messages) || "(running...)" }],
|
|
221
|
-
details: { mode: "single", results: [result], progress: [progress] },
|
|
222
|
-
});
|
|
261
|
+
scheduleUpdate();
|
|
223
262
|
}
|
|
224
263
|
} catch {}
|
|
225
264
|
};
|
|
226
265
|
|
|
227
266
|
let stderrBuf = "";
|
|
228
|
-
let lastUpdateTime = 0;
|
|
229
|
-
const UPDATE_THROTTLE_MS = 75;
|
|
230
267
|
|
|
231
268
|
proc.stdout.on("data", (d) => {
|
|
232
269
|
buf += d.toString();
|
|
@@ -234,21 +271,18 @@ export async function runSync(
|
|
|
234
271
|
buf = lines.pop() || "";
|
|
235
272
|
lines.forEach(processLine);
|
|
236
273
|
|
|
237
|
-
//
|
|
238
|
-
|
|
239
|
-
if (onUpdate && now - lastUpdateTime > UPDATE_THROTTLE_MS) {
|
|
240
|
-
lastUpdateTime = now;
|
|
241
|
-
progress.durationMs = now - startTime;
|
|
242
|
-
onUpdate({
|
|
243
|
-
content: [{ type: "text", text: getFinalOutput(result.messages) || "(running...)" }],
|
|
244
|
-
details: { mode: "single", results: [result], progress: [progress] },
|
|
245
|
-
});
|
|
246
|
-
}
|
|
274
|
+
// Also schedule an update on data received (handles streaming output)
|
|
275
|
+
scheduleUpdate();
|
|
247
276
|
});
|
|
248
277
|
proc.stderr.on("data", (d) => {
|
|
249
278
|
stderrBuf += d.toString();
|
|
250
279
|
});
|
|
251
280
|
proc.on("close", (code) => {
|
|
281
|
+
processClosed = true;
|
|
282
|
+
if (pendingTimer) {
|
|
283
|
+
clearTimeout(pendingTimer);
|
|
284
|
+
pendingTimer = null;
|
|
285
|
+
}
|
|
252
286
|
if (buf.trim()) processLine(buf);
|
|
253
287
|
if (code !== 0 && stderrBuf.trim() && !result.error) {
|
|
254
288
|
result.error = stderrBuf.trim();
|
package/index.ts
CHANGED
|
@@ -73,6 +73,7 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
|
|
|
73
73
|
let baseCwd = process.cwd();
|
|
74
74
|
let currentSessionId: string | null = null;
|
|
75
75
|
const asyncJobs = new Map<string, AsyncJobState>();
|
|
76
|
+
const cleanupTimers = new Map<string, ReturnType<typeof setTimeout>>(); // Track cleanup timeouts
|
|
76
77
|
let lastUiContext: ExtensionContext | null = null;
|
|
77
78
|
let poller: NodeJS.Timeout | null = null;
|
|
78
79
|
|
|
@@ -88,6 +89,10 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
|
|
|
88
89
|
}
|
|
89
90
|
|
|
90
91
|
for (const job of asyncJobs.values()) {
|
|
92
|
+
// Skip status reads for finished jobs - they won't change
|
|
93
|
+
if (job.status === "complete" || job.status === "failed") {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
91
96
|
const status = readStatus(job.asyncDir);
|
|
92
97
|
if (status) {
|
|
93
98
|
job.status = status.state;
|
|
@@ -139,9 +144,20 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
|
|
|
139
144
|
label: "Subagent",
|
|
140
145
|
description: `Delegate to subagents. Use exactly ONE mode:
|
|
141
146
|
• SINGLE: { agent, task } - one task
|
|
142
|
-
• CHAIN: { chain: [{agent:"scout"}, {agent:"planner"}] } - sequential
|
|
143
|
-
• PARALLEL: { tasks: [{agent,task}, ...] } - concurrent
|
|
144
|
-
|
|
147
|
+
• CHAIN: { chain: [{agent:"scout"}, {agent:"planner"}] } - sequential pipeline
|
|
148
|
+
• PARALLEL: { tasks: [{agent,task}, ...] } - concurrent execution
|
|
149
|
+
|
|
150
|
+
CHAIN TEMPLATE VARIABLES (use in task strings):
|
|
151
|
+
• {task} - The original task/request from the user
|
|
152
|
+
• {previous} - Text response from the previous step (empty for first step)
|
|
153
|
+
• {chain_dir} - Shared directory for chain files (e.g., /tmp/pi-chain-runs/abc123/)
|
|
154
|
+
|
|
155
|
+
CHAIN DATA FLOW:
|
|
156
|
+
1. Each step's text response automatically becomes {previous} for the next step
|
|
157
|
+
2. Steps can also write files to {chain_dir} (via agent's "output" config)
|
|
158
|
+
3. Later steps can read those files (via agent's "reads" config)
|
|
159
|
+
|
|
160
|
+
Example: { chain: [{agent:"scout", task:"Analyze {task}"}, {agent:"planner", task:"Plan based on {previous}"}] }`,
|
|
145
161
|
parameters: SubagentParams,
|
|
146
162
|
|
|
147
163
|
async execute(_id, params, onUpdate, ctx, signal) {
|
|
@@ -589,10 +605,13 @@ For "scout → planner" or multi-step flows, use chain (not multiple single call
|
|
|
589
605
|
if (lastUiContext) {
|
|
590
606
|
renderWidget(lastUiContext, Array.from(asyncJobs.values()));
|
|
591
607
|
}
|
|
592
|
-
|
|
608
|
+
// Schedule cleanup after 10 seconds (track timer for cleanup on shutdown)
|
|
609
|
+
const timer = setTimeout(() => {
|
|
610
|
+
cleanupTimers.delete(asyncId);
|
|
593
611
|
asyncJobs.delete(asyncId);
|
|
594
612
|
if (lastUiContext) renderWidget(lastUiContext, Array.from(asyncJobs.values()));
|
|
595
613
|
}, 10000);
|
|
614
|
+
cleanupTimers.set(asyncId, timer);
|
|
596
615
|
});
|
|
597
616
|
|
|
598
617
|
pi.on("tool_result", (event, ctx) => {
|
|
@@ -608,6 +627,8 @@ For "scout → planner" or multi-step flows, use chain (not multiple single call
|
|
|
608
627
|
pi.on("session_start", (_event, ctx) => {
|
|
609
628
|
baseCwd = ctx.cwd;
|
|
610
629
|
currentSessionId = ctx.sessionManager.getSessionFile() ?? `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
630
|
+
for (const timer of cleanupTimers.values()) clearTimeout(timer);
|
|
631
|
+
cleanupTimers.clear();
|
|
611
632
|
asyncJobs.clear();
|
|
612
633
|
if (ctx.hasUI) {
|
|
613
634
|
lastUiContext = ctx;
|
|
@@ -617,6 +638,8 @@ For "scout → planner" or multi-step flows, use chain (not multiple single call
|
|
|
617
638
|
pi.on("session_switch", (_event, ctx) => {
|
|
618
639
|
baseCwd = ctx.cwd;
|
|
619
640
|
currentSessionId = ctx.sessionManager.getSessionFile() ?? `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
641
|
+
for (const timer of cleanupTimers.values()) clearTimeout(timer);
|
|
642
|
+
cleanupTimers.clear();
|
|
620
643
|
asyncJobs.clear();
|
|
621
644
|
if (ctx.hasUI) {
|
|
622
645
|
lastUiContext = ctx;
|
|
@@ -626,6 +649,8 @@ For "scout → planner" or multi-step flows, use chain (not multiple single call
|
|
|
626
649
|
pi.on("session_branch", (_event, ctx) => {
|
|
627
650
|
baseCwd = ctx.cwd;
|
|
628
651
|
currentSessionId = ctx.sessionManager.getSessionFile() ?? `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
652
|
+
for (const timer of cleanupTimers.values()) clearTimeout(timer);
|
|
653
|
+
cleanupTimers.clear();
|
|
629
654
|
asyncJobs.clear();
|
|
630
655
|
if (ctx.hasUI) {
|
|
631
656
|
lastUiContext = ctx;
|
|
@@ -636,6 +661,11 @@ For "scout → planner" or multi-step flows, use chain (not multiple single call
|
|
|
636
661
|
watcher.close();
|
|
637
662
|
if (poller) clearInterval(poller);
|
|
638
663
|
poller = null;
|
|
664
|
+
// Clear all pending cleanup timers
|
|
665
|
+
for (const timer of cleanupTimers.values()) {
|
|
666
|
+
clearTimeout(timer);
|
|
667
|
+
}
|
|
668
|
+
cleanupTimers.clear();
|
|
639
669
|
asyncJobs.clear();
|
|
640
670
|
if (lastUiContext?.hasUI) {
|
|
641
671
|
lastUiContext.ui.setWidget(WIDGET_KEY, undefined);
|
package/package.json
CHANGED
package/render.ts
CHANGED
|
@@ -16,21 +16,46 @@ import { getFinalOutput, getDisplayItems, getOutputTail, getLastActivity } from
|
|
|
16
16
|
|
|
17
17
|
type Theme = ExtensionContext["ui"]["theme"];
|
|
18
18
|
|
|
19
|
+
// Track last rendered widget state to avoid no-op re-renders
|
|
20
|
+
let lastWidgetHash = "";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Compute a simple hash of job states for change detection
|
|
24
|
+
*/
|
|
25
|
+
function computeWidgetHash(jobs: AsyncJobState[]): string {
|
|
26
|
+
return jobs.slice(0, MAX_WIDGET_JOBS).map(job =>
|
|
27
|
+
`${job.asyncId}:${job.status}:${job.currentStep}:${job.updatedAt}:${job.totalTokens?.total ?? 0}`
|
|
28
|
+
).join("|");
|
|
29
|
+
}
|
|
30
|
+
|
|
19
31
|
/**
|
|
20
32
|
* Render the async jobs widget
|
|
21
33
|
*/
|
|
22
34
|
export function renderWidget(ctx: ExtensionContext, jobs: AsyncJobState[]): void {
|
|
23
35
|
if (!ctx.hasUI) return;
|
|
24
36
|
if (jobs.length === 0) {
|
|
25
|
-
|
|
37
|
+
if (lastWidgetHash !== "") {
|
|
38
|
+
lastWidgetHash = "";
|
|
39
|
+
ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
40
|
+
}
|
|
26
41
|
return;
|
|
27
42
|
}
|
|
28
43
|
|
|
44
|
+
// Check if anything changed since last render
|
|
45
|
+
// Always re-render if any displayed job is running (output tail updates constantly)
|
|
46
|
+
const displayedJobs = jobs.slice(0, MAX_WIDGET_JOBS);
|
|
47
|
+
const hasRunningJobs = displayedJobs.some(job => job.status === "running");
|
|
48
|
+
const newHash = computeWidgetHash(jobs);
|
|
49
|
+
if (!hasRunningJobs && newHash === lastWidgetHash) {
|
|
50
|
+
return; // Skip re-render, nothing changed
|
|
51
|
+
}
|
|
52
|
+
lastWidgetHash = newHash;
|
|
53
|
+
|
|
29
54
|
const theme = ctx.ui.theme;
|
|
30
55
|
const lines: string[] = [];
|
|
31
56
|
lines.push(theme.fg("accent", "Async subagents"));
|
|
32
57
|
|
|
33
|
-
for (const job of
|
|
58
|
+
for (const job of displayedJobs) {
|
|
34
59
|
const id = job.asyncId.slice(0, 6);
|
|
35
60
|
const status =
|
|
36
61
|
job.status === "complete"
|
|
@@ -235,9 +260,11 @@ export function renderSubagentResult(
|
|
|
235
260
|
? theme.fg("success", "✓")
|
|
236
261
|
: theme.fg("error", "✗");
|
|
237
262
|
const stats = rProg ? ` | ${rProg.toolCount} tools, ${formatDuration(rProg.durationMs)}` : "";
|
|
263
|
+
// Show model if available (full provider/model format)
|
|
264
|
+
const modelDisplay = r.model ? theme.fg("dim", ` (${r.model})`) : "";
|
|
238
265
|
const stepHeader = rRunning
|
|
239
|
-
? `${statusIcon} Step ${i + 1}: ${theme.bold(theme.fg("warning", r.agent))}${stats}`
|
|
240
|
-
: `${statusIcon} Step ${i + 1}: ${theme.bold(r.agent)}${stats}`;
|
|
266
|
+
? `${statusIcon} Step ${i + 1}: ${theme.bold(theme.fg("warning", r.agent))}${modelDisplay}${stats}`
|
|
267
|
+
: `${statusIcon} Step ${i + 1}: ${theme.bold(r.agent)}${modelDisplay}${stats}`;
|
|
241
268
|
c.addChild(new Text(stepHeader, 0, 0));
|
|
242
269
|
|
|
243
270
|
// Task (truncated)
|
package/schemas.ts
CHANGED
|
@@ -13,34 +13,36 @@ export const TaskItem = Type.Object({
|
|
|
13
13
|
// Sequential chain step (single agent)
|
|
14
14
|
export const SequentialStepSchema = Type.Object({
|
|
15
15
|
agent: Type.String(),
|
|
16
|
-
task: Type.Optional(Type.String({
|
|
16
|
+
task: Type.Optional(Type.String({
|
|
17
|
+
description: "Task template with variables: {task}=original request, {previous}=prior step's text response, {chain_dir}=shared folder. Required for first step, defaults to '{previous}' for subsequent steps."
|
|
18
|
+
})),
|
|
17
19
|
cwd: Type.Optional(Type.String()),
|
|
18
20
|
// Chain behavior overrides
|
|
19
21
|
output: Type.Optional(Type.Union([
|
|
20
22
|
Type.String(),
|
|
21
23
|
Type.Boolean(),
|
|
22
|
-
], { description: "
|
|
24
|
+
], { description: "Output filename to write in {chain_dir} (string), or false to disable file output" })),
|
|
23
25
|
reads: Type.Optional(Type.Union([
|
|
24
26
|
Type.Array(Type.String()),
|
|
25
27
|
Type.Boolean(),
|
|
26
|
-
], { description: "
|
|
27
|
-
progress: Type.Optional(Type.Boolean({ description: "
|
|
28
|
+
], { description: "Files to read from {chain_dir} before running (array of filenames), or false to disable" })),
|
|
29
|
+
progress: Type.Optional(Type.Boolean({ description: "Enable progress.md tracking in {chain_dir}" })),
|
|
28
30
|
});
|
|
29
31
|
|
|
30
32
|
// Parallel task item (within a parallel step)
|
|
31
33
|
export const ParallelTaskSchema = Type.Object({
|
|
32
34
|
agent: Type.String(),
|
|
33
|
-
task: Type.Optional(Type.String({ description: "Task template. Defaults to {previous}." })),
|
|
35
|
+
task: Type.Optional(Type.String({ description: "Task template with {task}, {previous}, {chain_dir} variables. Defaults to {previous}." })),
|
|
34
36
|
cwd: Type.Optional(Type.String()),
|
|
35
37
|
output: Type.Optional(Type.Union([
|
|
36
38
|
Type.String(),
|
|
37
39
|
Type.Boolean(),
|
|
38
|
-
], { description: "
|
|
40
|
+
], { description: "Output filename to write in {chain_dir} (string), or false to disable file output" })),
|
|
39
41
|
reads: Type.Optional(Type.Union([
|
|
40
42
|
Type.Array(Type.String()),
|
|
41
43
|
Type.Boolean(),
|
|
42
|
-
], { description: "
|
|
43
|
-
progress: Type.Optional(Type.Boolean({ description: "
|
|
44
|
+
], { description: "Files to read from {chain_dir} before running (array of filenames), or false to disable" })),
|
|
45
|
+
progress: Type.Optional(Type.Boolean({ description: "Enable progress.md tracking in {chain_dir}" })),
|
|
44
46
|
});
|
|
45
47
|
|
|
46
48
|
// Parallel chain step (multiple agents running concurrently)
|
|
@@ -64,7 +66,7 @@ export const SubagentParams = Type.Object({
|
|
|
64
66
|
agent: Type.Optional(Type.String({ description: "Agent name (SINGLE mode)" })),
|
|
65
67
|
task: Type.Optional(Type.String({ description: "Task (SINGLE mode)" })),
|
|
66
68
|
tasks: Type.Optional(Type.Array(TaskItem, { description: "PARALLEL mode: [{agent, task}, ...]" })),
|
|
67
|
-
chain: Type.Optional(Type.Array(ChainItem, { description: "CHAIN mode:
|
|
69
|
+
chain: Type.Optional(Type.Array(ChainItem, { description: "CHAIN mode: sequential pipeline where each step's response becomes {previous} for the next. Use {task}, {previous}, {chain_dir} in task templates." })),
|
|
68
70
|
async: Type.Optional(Type.Boolean({ description: "Run in background (default: false, or per config)" })),
|
|
69
71
|
agentScope: Type.Optional(Type.String({ description: "Agent discovery scope: 'user', 'project', or 'both' (default: 'user')" })),
|
|
70
72
|
cwd: Type.Optional(Type.String()),
|