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.
@@ -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
- // Build task string
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
- // Add chain instructions (include previous summary only if not already in template)
239
- const behavior = parallelBehaviors[taskIndex]!;
240
- // For parallel, no single "first progress" - each manages independently
241
- taskStr += buildChainInstructions(behavior, chainDir, false, templateHasPrevious ? undefined : prev);
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: [...results, ...(p.details?.results || [])],
259
- progress: [...allProgress, ...(p.details?.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
- // Build task string (check if template has {previous} before replacement)
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
- // Add chain instructions (include previous summary only if not already in template)
362
- stepTask += buildChainInstructions(behavior, chainDir, isFirstProgress, templateHasPrevious ? undefined : prev);
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: [...results, ...(p.details?.results || [])],
381
- progress: [...allProgress, ...(p.details?.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
- if (agent.model) args.push("--model", agent.model);
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
- if (onUpdate)
148
- onUpdate({
149
- content: [{ type: "text", text: getFinalOutput(result.messages) || "(running...)" }],
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
- if (onUpdate)
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 = [...progress.recentOutput, ...lines].slice(-50);
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
- if (onUpdate)
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 = [...progress.recentOutput, ...toolLines].slice(-50);
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
- if (onUpdate)
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
- // Throttled periodic update for smoother progress display
238
- const now = Date.now();
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, {previous} passes output
143
- • PARALLEL: { tasks: [{agent,task}, ...] } - concurrent
144
- For "scout → planner" or multi-step flows, use chain (not multiple single calls).`,
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
- setTimeout(() => {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-subagents",
3
- "version": "0.3.1",
3
+ "version": "0.3.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",
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
- ctx.ui.setWidget(WIDGET_KEY, undefined);
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 jobs.slice(0, MAX_WIDGET_JOBS)) {
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({ description: "Task template. Use {task}, {previous}, {chain_dir}. Required for first step." })),
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: "Override output filename (string), or false for text-only" })),
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: "Override files to read from {chain_dir} (array), or false to disable" })),
27
- progress: Type.Optional(Type.Boolean({ description: "Override progress tracking" })),
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: "Override output filename (string), or false for text-only" })),
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: "Override files to read from {chain_dir} (array), or false to disable" })),
43
- progress: Type.Optional(Type.Boolean({ description: "Override progress tracking" })),
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: [{agent}, {agent, task:'{previous}'}] - sequential pipeline" })),
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()),