pi-subagents 0.18.0 → 0.18.1

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 CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.18.1] - 2026-04-25
6
+
7
+ ### Changed
8
+ - Restyled live subagent rendering, async widgets, and background completion notifications with compact Claude-style visual grammar while preserving existing observability paths.
9
+ - Parallel subagent result rendering now labels parallel workers as `Agent N` instead of `Step N`, while chain rendering keeps step terminology.
10
+
11
+ ### Fixed
12
+ - `/run` and single-agent tool calls now allow self-contained agents to run without a task string.
13
+ - The `subagent` tool description no longer advertises hardcoded builtin agent names and management list output now separates disabled builtins from executable agents.
14
+ - Flexible `subagent` tool schema fields now include explicit JSON Schema types so llama.cpp and local OpenAI-compatible providers accept them.
15
+ - Settings package sources now resolve explicit `git:` and `npm:` entries from project and user package caches.
16
+ - Slash-command subagent results are now export-friendly, including completed output and child session paths in visible export content.
17
+
5
18
  ## [0.18.0] - 2026-04-23
6
19
 
7
20
  ### Added
package/README.md CHANGED
@@ -222,7 +222,7 @@ Subagents only get direct MCP tools when `mcp:` items are explicitly listed. Eve
222
222
 
223
223
  | Command | Description |
224
224
  |---------|-------------|
225
- | `/run <agent> <task>` | Run a single agent with a task |
225
+ | `/run <agent> [task]` | Run a single agent; omit the task for self-contained agents |
226
226
  | `/chain agent1 "task1" -> agent2 "task2"` | Run agents in sequence with per-step tasks |
227
227
  | `/parallel agent1 "task1" -> agent2 "task2"` | Run agents in parallel with per-step tasks |
228
228
  | `/subagents-status` | Open the async status overlay for active and recent runs |
@@ -438,7 +438,7 @@ Chains can be created from the Agents Manager template picker ("Blank Chain"), o
438
438
 
439
439
  | Mode | Async Support | Notes |
440
440
  |------|---------------|-------|
441
- | Single | Yes | `{ agent, task }` - agents with `output` write to temp dir |
441
+ | Single | Yes | `{ agent, task? }` - omit `task` for self-contained agents; agents with `output` write to temp dir |
442
442
  | Chain | Yes | `{ chain: [{agent, task}...] }` with `{task}`, `{previous}`, `{chain_dir}` variables |
443
443
  | Parallel | Yes | `{ tasks: [{agent, task}...] }` - via TUI toggle or converted to chain for async |
444
444
 
@@ -370,9 +370,18 @@ export function formatChainDetail(chain: ChainConfig): string {
370
370
  export function handleList(params: ManagementParams, ctx: ManagementContext): AgentToolResult<Details> {
371
371
  const scope = normalizeListScope(params.agentScope) ?? "both";
372
372
  const d = discoverAgentsAll(ctx.cwd);
373
- const agents = allAgents(d).filter((a) => scope === "both" || a.source === "builtin" || a.source === scope).sort((a, b) => a.name.localeCompare(b.name));
373
+ const scopedAgents = allAgents(d).filter((a) => scope === "both" || a.source === "builtin" || a.source === scope).sort((a, b) => a.name.localeCompare(b.name));
374
+ const agents = scopedAgents.filter((a) => !a.disabled);
375
+ const disabledBuiltins = scopedAgents.filter((a) => a.source === "builtin" && a.disabled);
374
376
  const chains = d.chains.filter((c) => scope === "both" || c.source === scope).sort((a, b) => a.name.localeCompare(b.name));
375
- const lines = ["Agents:", ...(agents.length ? agents.map((a) => `- ${a.name} (${a.source}${a.disabled ? ", disabled" : ""}): ${a.description}`) : ["- (none)"]), "", "Chains:", ...(chains.length ? chains.map((c) => `- ${c.name} (${c.source}): ${c.description}`) : ["- (none)"])];
377
+ const lines = [
378
+ "Executable agents:",
379
+ ...(agents.length ? agents.map((a) => `- ${a.name} (${a.source}): ${a.description}`) : ["- (none)"]),
380
+ ...(disabledBuiltins.length ? ["", "Disabled builtins:", ...disabledBuiltins.map((a) => `- ${a.name} (${a.source}, disabled): ${a.description}`)] : []),
381
+ "",
382
+ "Chains:",
383
+ ...(chains.length ? chains.map((c) => `- ${c.name} (${c.source}): ${c.description}`) : ["- (none)"]),
384
+ ];
376
385
  return result(lines.join("\n"));
377
386
  }
378
387
 
@@ -84,7 +84,7 @@ export interface AsyncChainParams {
84
84
 
85
85
  export interface AsyncSingleParams {
86
86
  agent: string;
87
- task: string;
87
+ task?: string;
88
88
  agentConfig: AgentConfig;
89
89
  ctx: AsyncExecutionContext;
90
90
  cwd?: string;
@@ -372,7 +372,6 @@ export function executeAsyncSingle(
372
372
  ): AsyncExecutionResult {
373
373
  const {
374
374
  agent,
375
- task,
376
375
  agentConfig,
377
376
  ctx,
378
377
  cwd,
@@ -389,6 +388,7 @@ export function executeAsyncSingle(
389
388
  controlIntercomTarget,
390
389
  childIntercomTarget,
391
390
  } = params;
391
+ const task = params.task ?? "";
392
392
  const runnerCwd = resolveChildCwd(ctx.cwd, cwd);
393
393
  const skillNames = params.skills ?? agentConfig.skills ?? [];
394
394
  const availableModels = params.availableModels;
package/execution.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import { spawn } from "node:child_process";
6
+ import { existsSync } from "node:fs";
6
7
  import type { Message } from "@mariozechner/pi-ai";
7
8
  import type { AgentConfig } from "./agents.ts";
8
9
  import {
@@ -726,12 +727,11 @@ export async function runSync(
726
727
  if (truncationResult.truncated) result.truncation = truncationResult;
727
728
  }
728
729
 
729
- if (shareEnabled) {
730
- const sessionFile = options.sessionFile
731
- ?? (options.sessionDir ? findLatestSessionFile(options.sessionDir) : null);
732
- if (sessionFile) {
733
- result.sessionFile = sessionFile;
734
- }
730
+ if (options.sessionFile && (existsSync(options.sessionFile) || result.messages?.length)) {
731
+ result.sessionFile = options.sessionFile;
732
+ } else if (shareEnabled && options.sessionDir) {
733
+ const sessionFile = findLatestSessionFile(options.sessionDir);
734
+ if (sessionFile) result.sessionFile = sessionFile;
735
735
  }
736
736
 
737
737
  return result;
package/index.ts CHANGED
@@ -21,7 +21,7 @@ import { Box, Container, Spacer, Text, truncateToWidth, visibleWidth, wrapTextWi
21
21
  import { discoverAgents } from "./agents.ts";
22
22
  import { cleanupAllArtifactDirs, cleanupOldArtifacts, getArtifactsDir } from "./artifacts.ts";
23
23
  import { cleanupOldChainDirs } from "./settings.ts";
24
- import { renderWidget, renderSubagentResult } from "./render.ts";
24
+ import { renderWidget, renderSubagentResult, stopResultAnimations, stopWidgetAnimation, syncResultAnimation } from "./render.ts";
25
25
  import { SubagentParams } from "./schemas.ts";
26
26
  import { createSubagentExecutor } from "./subagent-executor.ts";
27
27
  import { createAsyncJobTracker } from "./async-job-tracker.ts";
@@ -32,7 +32,8 @@ import { registerPromptTemplateDelegationBridge } from "./prompt-template-bridge
32
32
  import { registerSlashSubagentBridge } from "./slash-bridge.ts";
33
33
  import { clearSlashSnapshots, getSlashRenderableSnapshot, resolveSlashMessageDetails, restoreSlashFinalSnapshots, type SlashMessageDetails } from "./slash-live-state.ts";
34
34
  import { inspectSubagentStatus } from "./run-status.ts";
35
- import registerSubagentNotify from "./notify.ts";
35
+ import registerSubagentNotify, { type SubagentNotifyDetails } from "./notify.ts";
36
+ import { formatDuration, shortenPath } from "./formatters.ts";
36
37
  import {
37
38
  type ControlEvent,
38
39
  type Details,
@@ -130,12 +131,15 @@ function createSlashResultComponent(
130
131
  details: SlashMessageDetails,
131
132
  options: { expanded: boolean },
132
133
  theme: ExtensionContext["ui"]["theme"],
134
+ requestRender: () => void,
133
135
  ): Container {
134
136
  const container = new Container();
137
+ const animationState: { subagentResultAnimationTimer?: ReturnType<typeof setInterval> } = {};
135
138
  let lastVersion = -1;
136
139
  container.render = (width: number): string[] => {
137
140
  const snapshot = getSlashRenderableSnapshot(details);
138
- if (snapshot.version !== lastVersion) {
141
+ syncResultAnimation(snapshot.result, { state: animationState, invalidate: requestRender });
142
+ if (snapshot.version !== lastVersion || isSlashResultRunning(snapshot.result)) {
139
143
  lastVersion = snapshot.version;
140
144
  rebuildSlashResultContainer(container, snapshot.result, options, theme);
141
145
  }
@@ -162,6 +166,38 @@ function formatSubagentControlNotice(details: SubagentControlMessageDetails, con
162
166
  return details.noticeText ?? content ?? formatControlNoticeMessage(details.event, controlNoticeTarget(details));
163
167
  }
164
168
 
169
+ function parseSubagentNotifyContent(content: string): SubagentNotifyDetails | undefined {
170
+ const lines = content.split("\n");
171
+ const header = lines[0] ?? "";
172
+ const match = header.match(/^Background task (completed|failed|paused): \*\*(.+?)\*\*(?:\s+(\([^)]*\)))?$/);
173
+ if (!match) return undefined;
174
+ const body = lines.slice(2);
175
+ let sessionIndex = -1;
176
+ for (let i = body.length - 1; i >= 1; i--) {
177
+ if (body[i - 1]?.trim() === "" && /^(Session|Session file|Session share error):\s+/.test(body[i]!)) {
178
+ sessionIndex = i;
179
+ break;
180
+ }
181
+ }
182
+ const sessionLine = sessionIndex >= 0 ? body[sessionIndex] : undefined;
183
+ const resultLines = sessionIndex >= 0 ? body.slice(0, sessionIndex) : body;
184
+ const resultPreview = resultLines.join("\n").trim() || "(no output)";
185
+ let sessionLabel: string | undefined;
186
+ let sessionValue: string | undefined;
187
+ if (sessionLine) {
188
+ const separator = sessionLine.indexOf(":");
189
+ sessionLabel = sessionLine.slice(0, separator).toLowerCase();
190
+ sessionValue = sessionLine.slice(separator + 1).trim();
191
+ }
192
+ return {
193
+ agent: match[2]!,
194
+ status: match[1] as SubagentNotifyDetails["status"],
195
+ ...(match[3] ? { taskInfo: match[3] } : {}),
196
+ resultPreview,
197
+ ...(sessionLabel && sessionValue ? { sessionLabel, sessionValue } : {}),
198
+ };
199
+ }
200
+
165
201
  class SubagentControlNoticeComponent implements Component {
166
202
  constructor(
167
203
  private readonly details: SubagentControlMessageDetails,
@@ -191,6 +227,17 @@ class SubagentControlNoticeComponent implements Component {
191
227
  }
192
228
 
193
229
  export default function registerSubagentExtension(pi: ExtensionAPI): void {
230
+ const globalStore = globalThis as Record<string, unknown>;
231
+ const runtimeCleanupStoreKey = "__piSubagentRuntimeCleanup";
232
+ const previousRuntimeCleanup = globalStore[runtimeCleanupStoreKey];
233
+ if (typeof previousRuntimeCleanup === "function") {
234
+ try {
235
+ previousRuntimeCleanup();
236
+ } catch {
237
+ // Best effort cleanup for stale timers from an older reload.
238
+ }
239
+ }
240
+
194
241
  ensureAccessibleDir(RESULTS_DIR);
195
242
  ensureAccessibleDir(ASYNC_DIR);
196
243
  cleanupOldChainDirs();
@@ -227,6 +274,16 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
227
274
  startResultWatcher();
228
275
  primeExistingResults();
229
276
 
277
+ const runtimeCleanup = () => {
278
+ stopWidgetAnimation();
279
+ stopResultAnimations();
280
+ if (state.poller) {
281
+ clearInterval(state.poller);
282
+ state.poller = null;
283
+ }
284
+ };
285
+ globalStore[runtimeCleanupStoreKey] = runtimeCleanup;
286
+
230
287
  const { ensurePoller, handleStarted, handleComplete, resetJobs } = createAsyncJobTracker(pi, state, ASYNC_DIR);
231
288
  const executor = createSubagentExecutor({
232
289
  pi,
@@ -242,7 +299,37 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
242
299
  pi.registerMessageRenderer<SlashMessageDetails>(SLASH_RESULT_TYPE, (message, options, theme) => {
243
300
  const details = resolveSlashMessageDetails(message.details);
244
301
  if (!details) return undefined;
245
- return createSlashResultComponent(details, options, theme);
302
+ return createSlashResultComponent(details, options, theme, () => state.lastUiContext?.ui.requestRender?.());
303
+ });
304
+
305
+ pi.registerMessageRenderer<SubagentNotifyDetails>("subagent-notify", (message, options, theme) => {
306
+ const content = typeof message.content === "string" ? message.content : "";
307
+ const details = (message.details as SubagentNotifyDetails | undefined) ?? parseSubagentNotifyContent(content);
308
+ if (!details) return new Text(content, 0, 0);
309
+ const icon = details.status === "completed"
310
+ ? theme.fg("success", "✓")
311
+ : details.status === "paused"
312
+ ? theme.fg("warning", "■")
313
+ : theme.fg("error", "✗");
314
+ const parts: string[] = [];
315
+ if (details.taskInfo) parts.push(details.taskInfo);
316
+ if (details.durationMs !== undefined) parts.push(formatDuration(details.durationMs));
317
+ let text = `${icon} ${theme.bold(details.agent)} ${theme.fg("dim", details.status)}`;
318
+ if (parts.length > 0) text += ` ${theme.fg("dim", "·")} ${parts.map((part) => theme.fg("dim", part)).join(` ${theme.fg("dim", "·")} `)}`;
319
+ const trimmedPreview = details.resultPreview.trim();
320
+ const previewLines = options.expanded
321
+ ? trimmedPreview.split("\n").filter((line) => line.trim())
322
+ : [trimmedPreview.split("\n", 1)[0] ?? ""].filter((line) => line.trim());
323
+ for (const line of previewLines.length > 0 ? previewLines : ["(no output)"]) {
324
+ text += `\n ${theme.fg("dim", `⎿ ${line}`)}`;
325
+ }
326
+ if (!options.expanded && trimmedPreview.includes("\n")) {
327
+ text += `\n ${theme.fg("dim", "Ctrl+O full notification")}`;
328
+ }
329
+ if (details.sessionLabel && details.sessionValue) {
330
+ text += `\n ${theme.fg("muted", `${details.sessionLabel}: ${shortenPath(details.sessionValue)}`)}`;
331
+ }
332
+ return new Text(text, 0, 0);
246
333
  });
247
334
 
248
335
  pi.registerMessageRenderer<SubagentControlMessageDetails>(SUBAGENT_CONTROL_MESSAGE_TYPE, (message, _options, theme) => {
@@ -311,8 +398,9 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
311
398
  description: `Delegate to subagents or manage agent definitions.
312
399
 
313
400
  EXECUTION (use exactly ONE mode):
314
- SINGLE: { agent, task } - one task
315
- CHAIN: { chain: [{agent:"scout"}, {parallel:[{agent:"worker",count:3}]}] } - sequential pipeline with optional parallel fan-out
401
+ Before executing, use { action: "list" } to inspect configured agents/chains. Only execute agents listed as executable/non-disabled.
402
+ SINGLE: { agent, task? } - one task; omit task for self-contained agents
403
+ • CHAIN: { chain: [{agent:"agent-a"}, {parallel:[{agent:"agent-b",count:3}]}] } - sequential pipeline with optional parallel fan-out
316
404
  • PARALLEL: { tasks: [{agent,task,count?}, ...], concurrency?: number, worktree?: true } - concurrent execution (worktree: isolate each task in a git worktree)
317
405
  • Optional context: { context: "fresh" | "fork" } (default: "fresh")
318
406
 
@@ -321,10 +409,10 @@ CHAIN TEMPLATE VARIABLES (use in task strings):
321
409
  • {previous} - Text response from the previous step (empty for first step)
322
410
  • {chain_dir} - Shared directory for chain files (e.g., <tmpdir>/pi-subagents-<scope>/chain-runs/abc123/)
323
411
 
324
- Example: { chain: [{agent:"scout", task:"Analyze {task}"}, {agent:"planner", task:"Plan based on {previous}"}] }
412
+ Example: { chain: [{agent:"agent-a", task:"Analyze {task}"}, {agent:"agent-b", task:"Plan based on {previous}"}] }
325
413
 
326
414
  MANAGEMENT (use action field, omit agent/task/chain/tasks):
327
- • { action: "list" } - discover agents/chains
415
+ • { action: "list" } - discover executable agents/chains and any disabled builtins
328
416
  • { action: "get", agent: "name" } - full detail
329
417
  • { action: "create", config: { name, systemPrompt, systemPromptMode, inheritProjectContext, inheritSkills, ... } }
330
418
  • { action: "update", agent: "name", config: { ... } } - merge
@@ -370,7 +458,8 @@ CONTROL:
370
458
  );
371
459
  },
372
460
 
373
- renderResult(result, options, theme) {
461
+ renderResult(result, options, theme, context) {
462
+ syncResultAnimation(result, context);
374
463
  return renderSubagentResult(result, options, theme);
375
464
  },
376
465
 
@@ -381,7 +470,6 @@ CONTROL:
381
470
 
382
471
  const eventUnsubscribeStoreKey = "__piSubagentEventUnsubscribes";
383
472
  const controlNoticeSeenStoreKey = "__piSubagentVisibleControlNotices";
384
- const globalStore = globalThis as Record<string, unknown>;
385
473
  const previousEventUnsubscribes = globalStore[eventUnsubscribeStoreKey];
386
474
  if (Array.isArray(previousEventUnsubscribes)) {
387
475
  for (const unsubscribe of previousEventUnsubscribes) {
@@ -480,6 +568,11 @@ CONTROL:
480
568
  slashBridge.dispose();
481
569
  promptTemplateBridge.cancelAll();
482
570
  promptTemplateBridge.dispose();
571
+ stopWidgetAnimation();
572
+ stopResultAnimations();
573
+ if (globalStore[runtimeCleanupStoreKey] === runtimeCleanup) {
574
+ delete globalStore[runtimeCleanupStoreKey];
575
+ }
483
576
  if (state.lastUiContext?.hasUI) {
484
577
  state.lastUiContext.ui.setWidget(WIDGET_KEY, undefined);
485
578
  }
package/notify.ts CHANGED
@@ -12,6 +12,16 @@ interface ChainStepResult {
12
12
  success: boolean;
13
13
  }
14
14
 
15
+ export interface SubagentNotifyDetails {
16
+ agent: string;
17
+ status: "completed" | "failed" | "paused";
18
+ taskInfo?: string;
19
+ resultPreview: string;
20
+ durationMs?: number;
21
+ sessionLabel?: string;
22
+ sessionValue?: string;
23
+ }
24
+
15
25
  interface SubagentResult {
16
26
  id: string | null;
17
27
  agent: string | null;
@@ -20,6 +30,7 @@ interface SubagentResult {
20
30
  exitCode?: number;
21
31
  state?: string;
22
32
  timestamp: number;
33
+ durationMs?: number;
23
34
  sessionFile?: string;
24
35
  shareUrl?: string;
25
36
  gistUrl?: string;
@@ -64,22 +75,21 @@ export default function registerSubagentNotify(pi: ExtensionAPI): void {
64
75
  ? ` (${result.taskIndex + 1}/${result.totalTasks})`
65
76
  : "";
66
77
 
67
- const extra: string[] = [];
68
- if (result.shareUrl) {
69
- extra.push(`Session: ${result.shareUrl}`);
70
- } else if (result.shareError) {
71
- extra.push(`Session share error: ${result.shareError}`);
72
- } else if (result.sessionFile) {
73
- extra.push(`Session file: ${result.sessionFile}`);
74
- }
78
+ const sessionLine = result.shareUrl
79
+ ? `Session: ${result.shareUrl}`
80
+ : result.shareError
81
+ ? `Session share error: ${result.shareError}`
82
+ : result.sessionFile
83
+ ? `Session file: ${result.sessionFile}`
84
+ : undefined;
75
85
 
76
86
  const displaySummary = summary.trim() ? summary : "(no output)";
77
87
  const content = [
78
88
  `Background task ${status}: **${agent}**${taskInfo}`,
79
89
  "",
80
90
  displaySummary,
81
- extra.length ? "" : undefined,
82
- extra.length ? extra.join("\n") : undefined,
91
+ sessionLine ? "" : undefined,
92
+ sessionLine,
83
93
  ]
84
94
  .filter((line) => line !== undefined)
85
95
  .join("\n");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-subagents",
3
- "version": "0.18.0",
3
+ "version": "0.18.1",
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/pi-args.ts CHANGED
@@ -43,6 +43,7 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
43
43
  const args = [...input.baseArgs];
44
44
 
45
45
  if (input.sessionFile) {
46
+ fs.mkdirSync(path.dirname(input.sessionFile), { recursive: true });
46
47
  args.push("--session", input.sessionFile);
47
48
  } else {
48
49
  if (!input.sessionEnabled) {
package/render.ts CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  WIDGET_KEY,
14
14
  } from "./types.ts";
15
15
  import { formatTokens, formatUsage, formatDuration, formatToolCall, shortenPath } from "./formatters.ts";
16
- import { getDisplayItems, getLastActivity, getOutputTail, getSingleResultOutput } from "./utils.ts";
16
+ import { getDisplayItems, getLastActivity, getSingleResultOutput } from "./utils.ts";
17
17
 
18
18
  type Theme = ExtensionContext["ui"]["theme"];
19
19
 
@@ -79,12 +79,49 @@ function truncLine(text: string, maxWidth: number): string {
79
79
  return result + activeStyles.join("") + "…";
80
80
  }
81
81
 
82
- let lastWidgetHash = "";
82
+ const SPINNER = ["", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
83
+ const WIDGET_ANIMATION_MS = 80;
83
84
 
84
- function computeWidgetHash(jobs: AsyncJobState[]): string {
85
- return jobs.slice(0, MAX_WIDGET_JOBS).map(job =>
86
- `${job.asyncId}:${job.status}:${job.activityState}:${job.currentStep}:${job.updatedAt}:${job.totalTokens?.total ?? 0}`
87
- ).join("|");
85
+ let widgetTimer: ReturnType<typeof setInterval> | undefined;
86
+ let latestWidgetCtx: ExtensionContext | undefined;
87
+ let latestWidgetJobs: AsyncJobState[] = [];
88
+
89
+ const resultAnimationTimers = new Map<ReturnType<typeof setInterval>, ResultAnimationContext["state"]>();
90
+ const outputActivityCache = new Map<string, { checkedAt: number; text: string }>();
91
+
92
+ export interface ResultAnimationContext {
93
+ state: { subagentResultAnimationTimer?: ReturnType<typeof setInterval> };
94
+ invalidate: () => void;
95
+ }
96
+
97
+ function spinnerFrame(): string {
98
+ return SPINNER[Math.floor(Date.now() / WIDGET_ANIMATION_MS) % SPINNER.length]!;
99
+ }
100
+
101
+ function resultIsRunning(result: AgentToolResult<Details>): boolean {
102
+ return result.details?.progress?.some((entry) => entry.status === "running")
103
+ || result.details?.results.some((entry) => entry.progress?.status === "running")
104
+ || false;
105
+ }
106
+
107
+ function stopResultAnimation(context: ResultAnimationContext): void {
108
+ const timer = context.state.subagentResultAnimationTimer;
109
+ if (!timer) return;
110
+ clearInterval(timer);
111
+ resultAnimationTimers.delete(timer);
112
+ context.state.subagentResultAnimationTimer = undefined;
113
+ }
114
+
115
+ export function syncResultAnimation(result: AgentToolResult<Details>, context: ResultAnimationContext): void {
116
+ if (!resultIsRunning(result)) {
117
+ stopResultAnimation(context);
118
+ return;
119
+ }
120
+ if (context.state.subagentResultAnimationTimer) return;
121
+ const timer = setInterval(() => context.invalidate(), WIDGET_ANIMATION_MS);
122
+ timer.unref?.();
123
+ context.state.subagentResultAnimationTimer = timer;
124
+ resultAnimationTimers.set(timer, context.state);
88
125
  }
89
126
 
90
127
  function extractOutputTarget(task: string): string | undefined {
@@ -146,67 +183,340 @@ function buildLiveStatusLine(progress: Pick<AgentProgress, "activityState" | "la
146
183
  return formatActivityLabel(progress.lastActivityAt, progress.activityState === "needs_attention");
147
184
  }
148
185
 
186
+ function themeBold(theme: Theme, text: string): string {
187
+ return ((theme as { bold?: (value: string) => string }).bold?.(text)) ?? text;
188
+ }
189
+
190
+ function statJoin(theme: Theme, parts: string[]): string {
191
+ return parts.filter(Boolean).map((part) => theme.fg("dim", part)).join(` ${theme.fg("dim", "·")} `);
192
+ }
193
+
194
+ function formatTokenStat(tokens: number): string {
195
+ return `${formatTokens(tokens)} token`;
196
+ }
197
+
198
+ function formatToolUseStat(count: number): string {
199
+ return `${count} tool use${count === 1 ? "" : "s"}`;
200
+ }
201
+
202
+ function formatProgressStats(theme: Theme, progress: Pick<AgentProgress, "toolCount" | "tokens" | "durationMs"> | undefined, includeDuration = true): string {
203
+ if (!progress) return "";
204
+ const parts: string[] = [];
205
+ if (progress.toolCount > 0) parts.push(formatToolUseStat(progress.toolCount));
206
+ if (progress.tokens > 0) parts.push(formatTokenStat(progress.tokens));
207
+ if (includeDuration && progress.durationMs > 0) parts.push(formatDuration(progress.durationMs));
208
+ return statJoin(theme, parts);
209
+ }
210
+
211
+ function firstOutputLine(text: string): string {
212
+ return text.split("\n").find((line) => line.trim())?.trim() ?? "";
213
+ }
214
+
215
+ function resultStatusLine(result: Details["results"][number], output: string): string {
216
+ if (result.detached) return result.detachedReason ? `Detached: ${result.detachedReason}` : "Detached";
217
+ if (result.interrupted) return "Paused";
218
+ if (result.exitCode !== 0) return `Error: ${result.error ?? (firstOutputLine(output) || `exit ${result.exitCode}`)}`;
219
+ if (hasEmptyTextOutputWithoutOutputTarget(result.task, output)) return "Done (no text output)";
220
+ return "Done";
221
+ }
222
+
223
+ function resultGlyph(result: Details["results"][number], output: string, theme: Theme, running = result.progress?.status === "running"): string {
224
+ if (running) return theme.fg("accent", spinnerFrame());
225
+ if (result.detached) return theme.fg("warning", "■");
226
+ if (result.interrupted) return theme.fg("warning", "■");
227
+ if (result.exitCode !== 0) return theme.fg("error", "✗");
228
+ if (hasEmptyTextOutputWithoutOutputTarget(result.task, output)) return theme.fg("warning", "✓");
229
+ return theme.fg("success", "✓");
230
+ }
231
+
232
+ function compactCurrentActivity(progress: AgentProgress): string {
233
+ return formatCurrentToolLine(progress, getTermWidth() - 4, false) ?? buildLiveStatusLine(progress) ?? "thinking…";
234
+ }
235
+
236
+ function hasAnimatedWidgetJobs(jobs: AsyncJobState[]): boolean {
237
+ return jobs.some((job) => job.status === "running");
238
+ }
239
+
240
+ function widgetJobName(job: AsyncJobState): string {
241
+ if (job.agents?.length) return job.agents.join(" → ");
242
+ return job.mode ?? "subagent";
243
+ }
244
+
245
+ function getCachedLastActivity(outputFile: string | undefined): string {
246
+ if (!outputFile) return "";
247
+ const now = Date.now();
248
+ const cached = outputActivityCache.get(outputFile);
249
+ if (cached && now - cached.checkedAt < 1000) return cached.text;
250
+ const text = getLastActivity(outputFile);
251
+ outputActivityCache.set(outputFile, { checkedAt: now, text });
252
+ return text;
253
+ }
254
+
255
+ function widgetActivity(job: AsyncJobState): string {
256
+ if (job.currentTool && job.currentToolStartedAt !== undefined) {
257
+ return `${job.currentTool} ${formatDuration(Math.max(0, Date.now() - job.currentToolStartedAt))}`;
258
+ }
259
+ const activity = formatActivityLabel(job.lastActivityAt, job.activityState === "needs_attention")
260
+ ?? (job.status === "running" ? getCachedLastActivity(job.outputFile) : "");
261
+ if (activity) return activity;
262
+ if (job.status === "queued") return "queued…";
263
+ if (job.status === "paused") return "Paused";
264
+ if (job.status === "failed") return "Failed";
265
+ return "Done";
266
+ }
267
+
268
+ function widgetStatusGlyph(job: AsyncJobState, theme: Theme): string {
269
+ if (job.status === "running") return theme.fg("accent", spinnerFrame());
270
+ if (job.status === "queued") return theme.fg("muted", "◦");
271
+ if (job.status === "complete") return theme.fg("success", "✓");
272
+ if (job.status === "paused") return theme.fg("warning", "■");
273
+ return theme.fg("error", "✗");
274
+ }
275
+
276
+ function widgetStats(job: AsyncJobState, theme: Theme): string {
277
+ const parts: string[] = [];
278
+ const stepsTotal = job.stepsTotal ?? (job.agents?.length ?? 1);
279
+ if (job.currentStep !== undefined) parts.push(`step ${job.currentStep + 1}/${stepsTotal}`);
280
+ else if (stepsTotal > 1) parts.push(`steps ${stepsTotal}`);
281
+ if (job.totalTokens?.total) parts.push(formatTokenStat(job.totalTokens.total));
282
+ const endTime = job.status === "complete" || job.status === "failed" || job.status === "paused" ? (job.updatedAt ?? Date.now()) : Date.now();
283
+ if (job.startedAt) parts.push(formatDuration(Math.max(0, endTime - job.startedAt)));
284
+ return statJoin(theme, parts);
285
+ }
286
+
287
+ export function buildWidgetLines(jobs: AsyncJobState[], theme: Theme, width = getTermWidth()): string[] {
288
+ if (jobs.length === 0) return [];
289
+ const running = jobs.filter((job) => job.status === "running");
290
+ const queued = jobs.filter((job) => job.status === "queued");
291
+ const finished = jobs.filter((job) => job.status !== "running" && job.status !== "queued");
292
+
293
+ const lines: string[] = [];
294
+ const hasActive = running.length > 0 || queued.length > 0;
295
+ lines.push(truncLine(`${theme.fg(hasActive ? "accent" : "dim", hasActive ? "●" : "○")} ${theme.fg(hasActive ? "accent" : "dim", "Agents")} ${theme.fg("dim", "· /subagents-status")}`, width));
296
+
297
+ const items: string[][] = [];
298
+ let hiddenRunning = 0;
299
+ let hiddenFinished = 0;
300
+ let queuedSummaryShown = false;
301
+ let slots = MAX_WIDGET_JOBS;
302
+
303
+ for (const job of running) {
304
+ if (slots <= 0) { hiddenRunning++; continue; }
305
+ const stats = widgetStats(job, theme);
306
+ items.push([
307
+ `${widgetStatusGlyph(job, theme)} ${themeBold(theme, widgetJobName(job))}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`,
308
+ ` ${theme.fg("dim", `⎿ ${widgetActivity(job)}`)}`,
309
+ ]);
310
+ slots--;
311
+ }
312
+
313
+ if (queued.length > 0 && slots > 0) {
314
+ items.push([`${theme.fg("muted", "◦")} ${theme.fg("dim", `${queued.length} queued`)}`]);
315
+ queuedSummaryShown = true;
316
+ slots--;
317
+ }
318
+
319
+ for (const job of finished) {
320
+ if (slots <= 0) { hiddenFinished++; continue; }
321
+ const stats = widgetStats(job, theme);
322
+ items.push([
323
+ `${widgetStatusGlyph(job, theme)} ${themeBold(theme, widgetJobName(job))}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`,
324
+ ` ${theme.fg("dim", `⎿ ${widgetActivity(job)}`)}`,
325
+ ]);
326
+ slots--;
327
+ }
328
+
329
+ const hiddenQueued = queued.length > 0 && !queuedSummaryShown ? queued.length : 0;
330
+ const hiddenTotal = hiddenRunning + hiddenFinished + hiddenQueued;
331
+ if (hiddenTotal > 0) {
332
+ const parts: string[] = [];
333
+ if (hiddenRunning > 0) parts.push(`${hiddenRunning} running`);
334
+ if (hiddenQueued > 0) parts.push(`${hiddenQueued} queued`);
335
+ if (hiddenFinished > 0) parts.push(`${hiddenFinished} finished`);
336
+ items.push([theme.fg("dim", `+${hiddenTotal} more (${parts.join(", ")})`)]);
337
+ }
338
+
339
+ for (let i = 0; i < items.length; i++) {
340
+ const item = items[i]!;
341
+ const last = i === items.length - 1;
342
+ const branch = last ? "└─" : "├─";
343
+ const continuation = last ? " " : "│ ";
344
+ lines.push(truncLine(`${theme.fg("dim", branch)} ${item[0]}`, width));
345
+ for (const detail of item.slice(1)) {
346
+ lines.push(truncLine(`${theme.fg("dim", continuation)} ${detail}`, width));
347
+ }
348
+ }
349
+
350
+ return lines;
351
+ }
352
+
353
+ function refreshAnimatedWidget(): void {
354
+ if (!latestWidgetCtx?.hasUI || latestWidgetJobs.length === 0) return;
355
+ latestWidgetCtx.ui.setWidget(WIDGET_KEY, buildWidgetLines(latestWidgetJobs, latestWidgetCtx.ui.theme));
356
+ latestWidgetCtx.ui.requestRender?.();
357
+ }
358
+
359
+ function ensureWidgetAnimation(): void {
360
+ if (widgetTimer) return;
361
+ widgetTimer = setInterval(() => {
362
+ if (!hasAnimatedWidgetJobs(latestWidgetJobs)) {
363
+ stopWidgetAnimation();
364
+ return;
365
+ }
366
+ refreshAnimatedWidget();
367
+ }, WIDGET_ANIMATION_MS);
368
+ widgetTimer.unref?.();
369
+ }
370
+
371
+ export function stopWidgetAnimation(): void {
372
+ if (widgetTimer) {
373
+ clearInterval(widgetTimer);
374
+ widgetTimer = undefined;
375
+ }
376
+ latestWidgetCtx = undefined;
377
+ latestWidgetJobs = [];
378
+ outputActivityCache.clear();
379
+ }
380
+
381
+ export function stopResultAnimations(): void {
382
+ for (const [timer, state] of resultAnimationTimers) {
383
+ clearInterval(timer);
384
+ state.subagentResultAnimationTimer = undefined;
385
+ }
386
+ resultAnimationTimers.clear();
387
+ }
388
+
149
389
  /**
150
390
  * Render the async jobs widget
151
391
  */
152
392
  export function renderWidget(ctx: ExtensionContext, jobs: AsyncJobState[]): void {
153
- if (!ctx.hasUI) return;
154
393
  if (jobs.length === 0) {
155
- if (lastWidgetHash !== "") {
156
- lastWidgetHash = "";
157
- ctx.ui.setWidget(WIDGET_KEY, undefined);
158
- }
394
+ stopWidgetAnimation();
395
+ if (ctx.hasUI) ctx.ui.setWidget(WIDGET_KEY, undefined);
159
396
  return;
160
397
  }
161
-
162
- const displayedJobs = jobs.slice(0, MAX_WIDGET_JOBS);
163
- const hasRunningJobs = displayedJobs.some(job => job.status === "running");
164
- const newHash = computeWidgetHash(jobs);
165
- if (!hasRunningJobs && newHash === lastWidgetHash) {
398
+ if (!ctx.hasUI) {
399
+ stopWidgetAnimation();
166
400
  return;
167
401
  }
168
- lastWidgetHash = newHash;
402
+ latestWidgetCtx = ctx;
403
+ latestWidgetJobs = [...jobs];
169
404
 
170
- const theme = ctx.ui.theme;
171
- const w = getTermWidth();
172
- const lines: string[] = [];
173
- lines.push(theme.fg("accent", "Async subagents"));
174
-
175
- for (const job of displayedJobs) {
176
- const id = job.asyncId.slice(0, 6);
177
- const status =
178
- job.status === "complete"
179
- ? theme.fg("success", "complete")
180
- : job.status === "failed"
181
- ? theme.fg("error", "failed")
182
- : job.status === "paused"
183
- ? theme.fg("warning", "paused")
184
- : theme.fg("warning", "running");
185
-
186
- const stepsTotal = job.stepsTotal ?? (job.agents?.length ?? 1);
187
- const stepIndex = job.currentStep !== undefined ? job.currentStep + 1 : undefined;
188
- const stepText = stepIndex !== undefined ? `step ${stepIndex}/${stepsTotal}` : `steps ${stepsTotal}`;
189
- const endTime = (job.status === "complete" || job.status === "failed") ? (job.updatedAt ?? Date.now()) : Date.now();
190
- const elapsed = job.startedAt ? formatDuration(endTime - job.startedAt) : "";
191
- const agentLabel = job.agents ? job.agents.join(" -> ") : (job.mode ?? "single");
192
-
193
- const tokenText = job.totalTokens ? ` | ${formatTokens(job.totalTokens.total)} tok` : "";
194
- const activityText = job.currentTool && job.currentToolStartedAt
195
- ? `tool ${job.currentTool} ${formatDuration(Math.max(0, Date.now() - job.currentToolStartedAt))}`
196
- : formatActivityLabel(job.lastActivityAt, job.activityState === "needs_attention") ?? (job.status === "running" ? getLastActivity(job.outputFile) : "");
197
- const activitySuffix = activityText ? ` | ${theme.fg("dim", activityText)}` : "";
198
-
199
- lines.push(truncLine(`- ${id} ${status} | ${agentLabel} | ${stepText}${elapsed ? ` | ${elapsed}` : ""}${tokenText}${activitySuffix}`, w));
200
-
201
- if ((job.status === "running" || job.status === "paused") && job.outputFile) {
202
- const tail = getOutputTail(job.outputFile, 3);
203
- for (const line of tail) {
204
- lines.push(truncLine(theme.fg("dim", ` > ${line}`), w));
205
- }
405
+ ctx.ui.setWidget(WIDGET_KEY, buildWidgetLines(jobs, ctx.ui.theme));
406
+ if (hasAnimatedWidgetJobs(jobs)) ensureWidgetAnimation();
407
+ else stopWidgetAnimation();
408
+ }
409
+
410
+ function renderSingleCompact(d: Details, r: Details["results"][number], theme: Theme): Component {
411
+ const output = r.truncation?.text || getSingleResultOutput(r);
412
+ const progress = r.progress || r.progressSummary;
413
+ const isRunning = r.progress?.status === "running";
414
+ const contextBadge = d.context === "fork" ? theme.fg("warning", " [fork]") : "";
415
+ const stats = statJoin(theme, [
416
+ r.usage?.turns ? `⟳${r.usage.turns}` : "",
417
+ formatProgressStats(theme, progress),
418
+ ]);
419
+ const c = new Container();
420
+ const width = getTermWidth() - 4;
421
+ c.addChild(new Text(truncLine(`${resultGlyph(r, output, theme, isRunning)} ${theme.fg("toolTitle", theme.bold(r.agent))}${contextBadge}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`, width), 0, 0));
422
+
423
+ if (isRunning && r.progress) {
424
+ const activity = compactCurrentActivity(r.progress);
425
+ c.addChild(new Text(truncLine(theme.fg("dim", ` ⎿ ${activity}`), width), 0, 0));
426
+ const liveStatus = buildLiveStatusLine(r.progress);
427
+ if (liveStatus && liveStatus !== activity) c.addChild(new Text(truncLine(theme.fg("dim", ` ${liveStatus}`), width), 0, 0));
428
+ c.addChild(new Text(truncLine(theme.fg("accent", " Press Ctrl+O for live detail"), width), 0, 0));
429
+ if (r.artifactPaths) c.addChild(new Text(truncLine(theme.fg("dim", ` output: ${shortenPath(r.artifactPaths.outputPath)}`), width), 0, 0));
430
+ return c;
431
+ }
432
+
433
+ c.addChild(new Text(truncLine(theme.fg("dim", ` ⎿ ${resultStatusLine(r, output)}`), width), 0, 0));
434
+ const preview = firstOutputLine(output);
435
+ if (preview && r.exitCode === 0 && !hasEmptyTextOutputWithoutOutputTarget(r.task, output)) {
436
+ c.addChild(new Text(truncLine(theme.fg("dim", ` ${preview}`), width), 0, 0));
437
+ }
438
+ if (r.sessionFile) c.addChild(new Text(truncLine(theme.fg("dim", ` session: ${shortenPath(r.sessionFile)}`), width), 0, 0));
439
+ if (r.artifactPaths) c.addChild(new Text(truncLine(theme.fg("dim", ` output: ${shortenPath(r.artifactPaths.outputPath)}`), width), 0, 0));
440
+ if (r.truncation?.artifactPath) c.addChild(new Text(truncLine(theme.fg("dim", ` full output: ${shortenPath(r.truncation.artifactPath)}`), width), 0, 0));
441
+ return c;
442
+ }
443
+
444
+ function renderMultiCompact(d: Details, theme: Theme): Component {
445
+ const hasRunning = d.progress?.some((p) => p.status === "running")
446
+ || d.results.some((r) => r.progress?.status === "running");
447
+ const ok = d.results.filter((r) =>
448
+ !r.interrupted
449
+ && !r.detached
450
+ && (r.progress?.status === "completed" || (r.exitCode === 0 && r.progress?.status !== "running" && r.progress?.status !== "pending"))
451
+ ).length;
452
+ const failed = d.results.some((r) => r.exitCode !== 0 && r.progress?.status !== "running");
453
+ const paused = d.results.some((r) => (r.interrupted || r.detached) && r.progress?.status !== "running");
454
+ let totalSummary = d.progressSummary;
455
+ if (!totalSummary) {
456
+ let sawProgress = false;
457
+ const summary = { toolCount: 0, tokens: 0, durationMs: 0 };
458
+ for (const r of d.results) {
459
+ const prog = r.progress || r.progressSummary;
460
+ if (!prog) continue;
461
+ sawProgress = true;
462
+ summary.toolCount += prog.toolCount;
463
+ summary.tokens += prog.tokens;
464
+ summary.durationMs = d.mode === "chain" ? summary.durationMs + prog.durationMs : Math.max(summary.durationMs, prog.durationMs);
206
465
  }
466
+ if (sawProgress) totalSummary = summary;
207
467
  }
468
+ const hasParallelInChain = d.chainAgents?.some((a) => a.startsWith("["));
469
+ const totalCount = hasParallelInChain ? d.results.length : (d.totalSteps ?? d.results.length);
470
+ const currentStep = d.currentStepIndex !== undefined ? d.currentStepIndex + 1 : Math.min(totalCount, ok + (hasRunning ? 1 : 0));
471
+ const itemLabel = d.mode === "parallel" ? "agent" : "step";
472
+ const itemTitle = d.mode === "parallel" ? "Agent" : "Step";
473
+ const stepInfo = hasRunning ? `${itemLabel} ${currentStep}/${totalCount}` : `${itemLabel} ${ok}/${totalCount}`;
474
+ const stats = statJoin(theme, [stepInfo, formatProgressStats(theme, totalSummary)]);
475
+ const glyph = hasRunning
476
+ ? theme.fg("accent", spinnerFrame())
477
+ : failed
478
+ ? theme.fg("error", "✗")
479
+ : paused
480
+ ? theme.fg("warning", "■")
481
+ : theme.fg("success", "✓");
482
+ const contextBadge = d.context === "fork" ? theme.fg("warning", " [fork]") : "";
483
+ const c = new Container();
484
+ const width = getTermWidth() - 4;
485
+ c.addChild(new Text(truncLine(`${glyph} ${theme.fg("toolTitle", theme.bold(d.mode))}${contextBadge}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`, width), 0, 0));
208
486
 
209
- ctx.ui.setWidget(WIDGET_KEY, lines);
487
+ const useResultsDirectly = hasParallelInChain || !d.chainAgents?.length;
488
+ const stepsToShow = useResultsDirectly ? d.results.length : d.chainAgents!.length;
489
+ for (let i = 0; i < stepsToShow; i++) {
490
+ const r = d.results[i];
491
+ const agentName = useResultsDirectly ? (r?.agent || `${itemLabel}-${i + 1}`) : (d.chainAgents![i] || r?.agent || `${itemLabel}-${i + 1}`);
492
+ if (!r) {
493
+ c.addChild(new Text(truncLine(theme.fg("dim", ` ◦ ${itemTitle} ${i + 1}: ${agentName} · pending`), width), 0, 0));
494
+ continue;
495
+ }
496
+ const output = getSingleResultOutput(r);
497
+ const progressFromArray = d.progress?.find((p) => p.index === i) || d.progress?.find((p) => p.agent === r.agent && p.status === "running");
498
+ const rProg = r.progress || progressFromArray || r.progressSummary;
499
+ const rRunning = rProg && "status" in rProg && rProg.status === "running";
500
+ const rPending = rProg && "status" in rProg && rProg.status === "pending";
501
+ const stepNumber = r.progress?.index !== undefined ? r.progress.index + 1 : progressFromArray?.index !== undefined ? progressFromArray.index + 1 : i + 1;
502
+ const stepStats = formatProgressStats(theme, rProg);
503
+ const glyph = rPending ? theme.fg("dim", "◦") : resultGlyph(r, output, theme, rRunning);
504
+ const pendingLabel = rPending ? ` ${theme.fg("dim", "· pending")}` : "";
505
+ const line = `${glyph} ${itemTitle} ${stepNumber}: ${themeBold(theme, agentName)}${stepStats ? ` ${theme.fg("dim", "·")} ${stepStats}` : ""}${pendingLabel}`;
506
+ c.addChild(new Text(truncLine(` ${line}`, width), 0, 0));
507
+ if (rRunning && rProg && "status" in rProg) {
508
+ const activity = compactCurrentActivity(rProg);
509
+ c.addChild(new Text(truncLine(theme.fg("dim", ` ⎿ ${activity}`), width), 0, 0));
510
+ c.addChild(new Text(truncLine(theme.fg("accent", " Press Ctrl+O for live detail"), width), 0, 0));
511
+ } else if (!rPending && (r.exitCode !== 0 || r.interrupted || r.detached || hasEmptyTextOutputWithoutOutputTarget(r.task, output))) {
512
+ c.addChild(new Text(truncLine(theme.fg(r.exitCode !== 0 ? "error" : "dim", ` ⎿ ${resultStatusLine(r, output)}`), width), 0, 0));
513
+ }
514
+ const outputTarget = extractOutputTarget(r.task);
515
+ if (outputTarget) c.addChild(new Text(truncLine(theme.fg("dim", ` output: ${outputTarget}`), width), 0, 0));
516
+ if (r.artifactPaths) c.addChild(new Text(truncLine(theme.fg("dim", ` output: ${shortenPath(r.artifactPaths.outputPath)}`), width), 0, 0));
517
+ }
518
+ if (d.artifacts) c.addChild(new Text(truncLine(theme.fg("dim", ` artifacts: ${shortenPath(d.artifacts.dir)}`), width), 0, 0));
519
+ return c;
210
520
  }
211
521
 
212
522
  /**
@@ -230,6 +540,7 @@ export function renderSubagentResult(
230
540
 
231
541
  if (d.mode === "single" && d.results.length === 1) {
232
542
  const r = d.results[0];
543
+ if (!expanded) return renderSingleCompact(d, r, theme);
233
544
  const isRunning = r.progress?.status === "running";
234
545
  const icon = isRunning
235
546
  ? theme.fg("warning", "running")
@@ -322,6 +633,8 @@ export function renderSubagentResult(
322
633
  return c;
323
634
  }
324
635
 
636
+ if (!expanded) return renderMultiCompact(d, theme);
637
+
325
638
  const hasRunning = d.progress?.some((p) => p.status === "running")
326
639
  || d.results.some((r) => r.progress?.status === "running");
327
640
  const ok = d.results.filter((r) => r.progress?.status === "completed" || (r.exitCode === 0 && r.progress?.status !== "running")).length;
@@ -367,6 +680,7 @@ export function renderSubagentResult(
367
680
  const totalCount = hasParallelInChain ? d.results.length : (d.totalSteps ?? d.results.length);
368
681
  const currentStep = d.currentStepIndex !== undefined ? d.currentStepIndex + 1 : ok + 1;
369
682
  const stepInfo = hasRunning ? ` ${currentStep}/${totalCount}` : ` ${ok}/${totalCount}`;
683
+ const itemTitle = d.mode === "parallel" ? "Agent" : "Step";
370
684
 
371
685
  const chainVis = d.chainAgents?.length && !hasParallelInChain
372
686
  ? d.chainAgents
@@ -418,7 +732,7 @@ export function renderSubagentResult(
418
732
  : (d.chainAgents![i] || r?.agent || `step-${i + 1}`);
419
733
 
420
734
  if (!r) {
421
- c.addChild(new Text(fit(theme.fg("dim", ` Step ${i + 1}: ${agentName}`)), 0, 0));
735
+ c.addChild(new Text(fit(theme.fg("dim", ` ${itemTitle} ${i + 1}: ${agentName}`)), 0, 0));
422
736
  c.addChild(new Text(theme.fg("dim", ` status: pending`), 0, 0));
423
737
  c.addChild(new Spacer(1));
424
738
  continue;
@@ -441,8 +755,8 @@ export function renderSubagentResult(
441
755
  const stats = rProg ? ` | ${rProg.toolCount} tools, ${formatDuration(rProg.durationMs)}` : "";
442
756
  const modelDisplay = r.model ? theme.fg("dim", ` (${r.model})`) : "";
443
757
  const stepHeader = rRunning
444
- ? `${statusIcon} Step ${stepNumber}: ${theme.bold(theme.fg("warning", r.agent))}${modelDisplay}${stats}`
445
- : `${statusIcon} Step ${stepNumber}: ${theme.bold(r.agent)}${modelDisplay}${stats}`;
758
+ ? `${statusIcon} ${itemTitle} ${stepNumber}: ${theme.bold(theme.fg("warning", r.agent))}${modelDisplay}${stats}`
759
+ : `${statusIcon} ${itemTitle} ${stepNumber}: ${theme.bold(r.agent)}${modelDisplay}${stats}`;
446
760
  const toolCallLines = getToolCallLines(r, expanded);
447
761
  c.addChild(new Text(fit(stepHeader), 0, 0));
448
762
 
package/schemas.ts CHANGED
@@ -4,8 +4,22 @@
4
4
 
5
5
  import { Type } from "typebox";
6
6
 
7
- // Note: Using Type.Any() for Google API compatibility (doesn't support anyOf)
8
- const SkillOverride = Type.Any({ description: "Skill name(s) to inject (comma-separated), array of strings, or boolean (false disables, true uses default)" });
7
+ const SkillOverride = Type.Unsafe({
8
+ type: ["string", "array", "boolean"],
9
+ items: { type: "string" },
10
+ description: "Skill name(s) to inject (comma-separated), array of strings, or boolean (false disables, true uses default)",
11
+ });
12
+
13
+ const OutputOverride = Type.Unsafe({
14
+ type: ["string", "boolean"],
15
+ description: "Output filename/path (string), or false to disable file output",
16
+ });
17
+
18
+ const ReadsOverride = Type.Unsafe({
19
+ type: ["array", "boolean"],
20
+ items: { type: "string" },
21
+ description: "Files to read before running (array of filenames), or false to disable",
22
+ });
9
23
 
10
24
  export const TaskItem = Type.Object({
11
25
  agent: Type.String(),
@@ -23,8 +37,8 @@ export const SequentialStepSchema = Type.Object({
23
37
  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."
24
38
  })),
25
39
  cwd: Type.Optional(Type.String()),
26
- output: Type.Optional(Type.Any({ description: "Output filename to write in {chain_dir} (string), or false to disable file output" })),
27
- reads: Type.Optional(Type.Any({ description: "Files to read from {chain_dir} before running (array of filenames), or false to disable" })),
40
+ output: Type.Optional(OutputOverride),
41
+ reads: Type.Optional(ReadsOverride),
28
42
  progress: Type.Optional(Type.Boolean({ description: "Enable progress.md tracking in {chain_dir}" })),
29
43
  skill: Type.Optional(SkillOverride),
30
44
  model: Type.Optional(Type.String({ description: "Override model for this step" })),
@@ -36,8 +50,8 @@ export const ParallelTaskSchema = Type.Object({
36
50
  task: Type.Optional(Type.String({ description: "Task template with {task}, {previous}, {chain_dir} variables. Defaults to {previous}." })),
37
51
  cwd: Type.Optional(Type.String()),
38
52
  count: Type.Optional(Type.Integer({ minimum: 1, description: "Repeat this parallel task N times with the same settings." })),
39
- output: Type.Optional(Type.Any({ description: "Output filename to write in {chain_dir} (string), or false to disable file output" })),
40
- reads: Type.Optional(Type.Any({ description: "Files to read from {chain_dir} before running (array of filenames), or false to disable" })),
53
+ output: Type.Optional(OutputOverride),
54
+ reads: Type.Optional(ReadsOverride),
41
55
  progress: Type.Optional(Type.Boolean({ description: "Enable progress.md tracking in {chain_dir}" })),
42
56
  skill: Type.Optional(SkillOverride),
43
57
  model: Type.Optional(Type.String({ description: "Override model for this task" })),
@@ -53,9 +67,25 @@ export const ParallelStepSchema = Type.Object({
53
67
  })),
54
68
  });
55
69
 
56
- // Chain item can be either sequential or parallel
57
- // Note: Using Type.Any() for Google API compatibility (doesn't support anyOf)
58
- export const ChainItem = Type.Any({ description: "Chain step: either {agent, task?, ...} for sequential or {parallel: [...]} for concurrent execution" });
70
+ // Flattened so providers that reject anyOf/oneOf can still accept either sequential or parallel steps.
71
+ export const ChainItem = Type.Object({
72
+ agent: Type.Optional(Type.String({ description: "Sequential step agent name" })),
73
+ task: Type.Optional(Type.String({
74
+ 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."
75
+ })),
76
+ cwd: Type.Optional(Type.String()),
77
+ output: Type.Optional(OutputOverride),
78
+ reads: Type.Optional(ReadsOverride),
79
+ progress: Type.Optional(Type.Boolean({ description: "Enable progress.md tracking in {chain_dir}" })),
80
+ skill: Type.Optional(SkillOverride),
81
+ model: Type.Optional(Type.String({ description: "Override model for this step" })),
82
+ parallel: Type.Optional(Type.Array(ParallelTaskSchema, { minItems: 1, description: "Tasks to run in parallel" })),
83
+ concurrency: Type.Optional(Type.Number({ description: "Max concurrent tasks (default: 4)" })),
84
+ failFast: Type.Optional(Type.Boolean({ description: "Stop on first failure (default: false)" })),
85
+ worktree: Type.Optional(Type.Boolean({
86
+ description: "Create isolated git worktrees for each parallel task."
87
+ })),
88
+ }, { description: "Chain step: use {agent, task?, ...} for sequential or {parallel: [...]} for concurrent execution" });
59
89
 
60
90
  export const ControlOverrides = Type.Object({
61
91
  enabled: Type.Optional(Type.Boolean({ description: "Enable/disable subagent control attention tracking for this run" })),
@@ -70,7 +100,7 @@ export const ControlOverrides = Type.Object({
70
100
 
71
101
  export const SubagentParams = Type.Object({
72
102
  agent: Type.Optional(Type.String({ description: "Agent name (SINGLE mode) or target for management get/update/delete" })),
73
- task: Type.Optional(Type.String({ description: "Task (SINGLE mode)" })),
103
+ task: Type.Optional(Type.String({ description: "Task (SINGLE mode, optional for self-contained agents)" })),
74
104
  // Management action (when present, tool operates in management mode)
75
105
  action: Type.Optional(Type.String({
76
106
  description: "Action: management ('list','get','create','update','delete') or control ('status','interrupt'). Omit for execution mode."
@@ -89,8 +119,10 @@ export const SubagentParams = Type.Object({
89
119
  description: "Chain name for get/update/delete management actions"
90
120
  })),
91
121
  // Agent/chain configuration for create/update (nested to avoid conflicts with execution fields)
92
- config: Type.Optional(Type.Any({
93
- description: "Agent or chain config for create/update. Agent: name, description, scope ('user'|'project', default 'user'), systemPrompt, systemPromptMode, inheritProjectContext, inheritSkills, model, tools (comma-separated), extensions (comma-separated), skills (comma-separated), thinking, output, reads, progress, maxSubagentDepth. Chain: name, description, scope, steps (array of {agent, task?, output?, reads?, model?, skills?, progress?}). Presence of 'steps' creates a chain instead of an agent."
122
+ config: Type.Optional(Type.Unsafe({
123
+ type: ["object", "string"],
124
+ additionalProperties: true,
125
+ description: "Agent or chain config for create/update. Agent: name, description, scope ('user'|'project', default 'user'), systemPrompt, systemPromptMode, inheritProjectContext, inheritSkills, model, tools (comma-separated), extensions (comma-separated), skills (comma-separated), thinking, output, reads, progress, maxSubagentDepth. Chain: name, description, scope, steps (array of {agent, task?, output?, reads?, model?, skills?, progress?}). Presence of 'steps' creates a chain instead of an agent. String values must be valid JSON."
94
126
  })),
95
127
  tasks: Type.Optional(Type.Array(TaskItem, { description: "PARALLEL mode: [{agent, task, count?}, ...]" })),
96
128
  concurrency: Type.Optional(Type.Integer({ minimum: 1, description: "Top-level PARALLEL mode only: max concurrent tasks. Defaults to config.parallel.concurrency or 4." })),
@@ -118,7 +150,10 @@ export const SubagentParams = Type.Object({
118
150
  clarify: Type.Optional(Type.Boolean({ description: "Show TUI to preview/edit before execution (default: true for chains, false for single/parallel). Implies sync mode." })),
119
151
  control: Type.Optional(ControlOverrides),
120
152
  // Solo agent overrides
121
- output: Type.Optional(Type.Any({ description: "Output file for single agent (string), or false to disable. Relative paths resolve against cwd." })),
153
+ output: Type.Optional(Type.Unsafe({
154
+ type: ["string", "boolean"],
155
+ description: "Output file for single agent (string), or false to disable. Relative paths resolve against cwd.",
156
+ })),
122
157
  skill: Type.Optional(SkillOverride),
123
158
  model: Type.Optional(Type.String({ description: "Override model for single agent (e.g. 'anthropic/claude-sonnet-4')" })),
124
159
  });
package/skills.ts CHANGED
@@ -210,9 +210,70 @@ function collectSettingsSkillPaths(cwd: string): SkillSearchPath[] {
210
210
  return results;
211
211
  }
212
212
 
213
+ function isSafePackagePath(value: string): boolean {
214
+ return value.length > 0
215
+ && !path.isAbsolute(value)
216
+ && value.split(/[\\/]/).every((part) => part.length > 0 && part !== "." && part !== "..");
217
+ }
218
+
219
+ function parseNpmPackageName(source: string): string | undefined {
220
+ const spec = source.slice(4).trim();
221
+ if (!spec) return undefined;
222
+ const match = spec.match(/^(@?[^@]+(?:\/[^@]+)?)(?:@(.+))?$/);
223
+ const packageName = match?.[1] ?? spec;
224
+ return isSafePackagePath(packageName) ? packageName : undefined;
225
+ }
226
+
227
+ function stripGitRef(repoPath: string): string {
228
+ const atIndex = repoPath.indexOf("@");
229
+ const hashIndex = repoPath.indexOf("#");
230
+ const refIndex = [atIndex, hashIndex].filter((index) => index >= 0).sort((a, b) => a - b)[0];
231
+ return refIndex === undefined ? repoPath : repoPath.slice(0, refIndex);
232
+ }
233
+
234
+ function parseGitPackagePath(source: string): { host: string; repoPath: string } | undefined {
235
+ const spec = source.slice(4).trim();
236
+ if (!spec) return undefined;
237
+
238
+ let host = "";
239
+ let repoPath = "";
240
+ const scpLike = spec.match(/^git@([^:]+):(.+)$/);
241
+ if (scpLike) {
242
+ host = scpLike[1] ?? "";
243
+ repoPath = scpLike[2] ?? "";
244
+ } else if (/^[a-z][a-z0-9+.-]*:\/\//i.test(spec)) {
245
+ try {
246
+ const url = new URL(spec);
247
+ host = url.hostname;
248
+ repoPath = url.pathname.replace(/^\/+/, "");
249
+ } catch {
250
+ return undefined;
251
+ }
252
+ } else {
253
+ const slashIndex = spec.indexOf("/");
254
+ if (slashIndex < 0) return undefined;
255
+ host = spec.slice(0, slashIndex);
256
+ repoPath = spec.slice(slashIndex + 1);
257
+ }
258
+
259
+ const normalizedPath = stripGitRef(repoPath).replace(/\.git$/, "").replace(/^\/+/, "");
260
+ if (!host || !isSafePackagePath(host) || !isSafePackagePath(normalizedPath) || normalizedPath.split(/[\\/]/).length < 2) {
261
+ return undefined;
262
+ }
263
+ return { host, repoPath: normalizedPath };
264
+ }
265
+
213
266
  function resolveSettingsPackageRoot(source: string, baseDir: string): string | undefined {
214
267
  const trimmed = source.trim();
215
268
  if (!trimmed) return undefined;
269
+ if (trimmed.startsWith("git:")) {
270
+ const parsed = parseGitPackagePath(trimmed);
271
+ return parsed ? path.join(baseDir, "git", parsed.host, parsed.repoPath) : undefined;
272
+ }
273
+ if (trimmed.startsWith("npm:")) {
274
+ const packageName = parseNpmPackageName(trimmed);
275
+ return packageName ? path.join(baseDir, "npm", "node_modules", packageName) : undefined;
276
+ }
216
277
  const normalized = trimmed.startsWith("file:") ? trimmed.slice(5) : trimmed;
217
278
  if (normalized === "~") return os.homedir();
218
279
  if (normalized.startsWith("~/")) return path.join(os.homedir(), normalized.slice(2));
package/slash-commands.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
2
4
  import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
3
5
  import { Key, matchesKey } from "@mariozechner/pi-tui";
4
6
  import { discoverAgents, discoverAgentsAll } from "./agents.ts";
@@ -20,6 +22,7 @@ import {
20
22
  SLASH_SUBAGENT_RESPONSE_EVENT,
21
23
  SLASH_SUBAGENT_STARTED_EVENT,
22
24
  SLASH_SUBAGENT_UPDATE_EVENT,
25
+ type SingleResult,
23
26
  type SubagentState,
24
27
  } from "./types.ts";
25
28
 
@@ -194,6 +197,46 @@ function extractSlashMessageText(content: string | Array<{ type?: string; text?:
194
197
  .join("\n");
195
198
  }
196
199
 
200
+ function formatExportPathList(paths: string[]): string {
201
+ return paths.map((file) => `- \`${file}\``).join("\n");
202
+ }
203
+
204
+ function collectResultPaths(results: SingleResult[], getPath: (result: SingleResult) => string | undefined): string[] {
205
+ return results
206
+ .map(getPath)
207
+ .filter((file): file is string => typeof file === "string" && file.length > 0);
208
+ }
209
+
210
+ function buildSlashExportText(response: SlashSubagentResponse): string {
211
+ const output = extractSlashMessageText(response.result.content) || response.errorText || "(no output)";
212
+ const results = response.result.details?.results ?? [];
213
+ const sessionFiles = collectResultPaths(results, (result) => result.sessionFile);
214
+ const savedOutputs = collectResultPaths(results, (result) => result.savedOutputPath);
215
+ const artifactOutputs = collectResultPaths(results, (result) => result.artifactPaths?.outputPath);
216
+ const sections = ["## Subagent result", output];
217
+ if (sessionFiles.length > 0) sections.push("## Child session exports", formatExportPathList(sessionFiles));
218
+ if (savedOutputs.length > 0) sections.push("## Saved outputs", formatExportPathList(savedOutputs));
219
+ if (artifactOutputs.length > 0) sections.push("## Artifact outputs", formatExportPathList(artifactOutputs));
220
+ return sections.join("\n\n");
221
+ }
222
+
223
+ function persistSlashSessionSnapshot(ctx: ExtensionContext): void {
224
+ try {
225
+ if (!ctx.sessionManager) return;
226
+ const sessionManager = ctx.sessionManager as typeof ctx.sessionManager & {
227
+ _rewriteFile?: () => void;
228
+ flushed?: boolean;
229
+ };
230
+ const sessionFile = sessionManager.getSessionFile();
231
+ if (!sessionFile || typeof sessionManager._rewriteFile !== "function") return;
232
+ fs.mkdirSync(path.dirname(sessionFile), { recursive: true });
233
+ sessionManager._rewriteFile();
234
+ sessionManager.flushed = true;
235
+ } catch (error) {
236
+ console.error("Failed to persist slash session snapshot for export:", error);
237
+ }
238
+ }
239
+
197
240
  async function runSlashSubagent(
198
241
  pi: ExtensionAPI,
199
242
  ctx: ExtensionContext,
@@ -208,17 +251,18 @@ async function runSlashSubagent(
208
251
  display: true,
209
252
  details: initialDetails,
210
253
  });
254
+ persistSlashSessionSnapshot(ctx);
211
255
 
212
256
  try {
213
257
  const response = await requestSlashRun(pi, ctx, requestId, params);
214
258
  const finalDetails = finalizeSlashResult(response);
215
- const text = extractSlashMessageText(response.result.content) || response.errorText || "(no output)";
216
259
  pi.sendMessage({
217
260
  customType: SLASH_RESULT_TYPE,
218
- content: text,
219
- display: false,
261
+ content: buildSlashExportText(response),
262
+ display: true,
220
263
  details: finalDetails,
221
264
  });
265
+ persistSlashSessionSnapshot(ctx);
222
266
  if (ctx.hasUI) {
223
267
  ctx.ui.setStatus("subagent-slash", undefined);
224
268
  }
@@ -227,13 +271,14 @@ async function runSlashSubagent(
227
271
  }
228
272
  } catch (error) {
229
273
  const message = error instanceof Error ? error.message : String(error);
230
- const failedDetails = failSlashResult(requestId, params, message === "Cancelled" ? "Cancelled" : message);
274
+ const failedDetails = failSlashResult(requestId, params, message);
231
275
  pi.sendMessage({
232
276
  customType: SLASH_RESULT_TYPE,
233
- content: message,
234
- display: false,
277
+ content: `## Subagent result\n\n${message}`,
278
+ display: true,
235
279
  details: failedDetails,
236
280
  });
281
+ persistSlashSessionSnapshot(ctx);
237
282
  if (ctx.hasUI) {
238
283
  ctx.ui.setStatus("subagent-slash", undefined);
239
284
  }
@@ -398,16 +443,15 @@ export function registerSlashCommands(
398
443
  });
399
444
 
400
445
  pi.registerCommand("run", {
401
- description: "Run a subagent directly: /run agent[output=file] task [--bg] [--fork]",
446
+ description: "Run a subagent directly: /run agent[output=file] [task] [--bg] [--fork]",
402
447
  getArgumentCompletions: makeAgentCompletions(state, false),
403
448
  handler: async (args, ctx) => {
404
449
  const { args: cleanedArgs, bg, fork } = extractExecutionFlags(args);
405
450
  const input = cleanedArgs.trim();
406
451
  const firstSpace = input.indexOf(" ");
407
- if (firstSpace === -1) { ctx.ui.notify("Usage: /run <agent> <task> [--bg] [--fork]", "error"); return; }
408
- const { name: agentName, config: inline } = parseAgentToken(input.slice(0, firstSpace));
409
- const task = input.slice(firstSpace + 1).trim();
410
- if (!task) { ctx.ui.notify("Usage: /run <agent> <task> [--bg] [--fork]", "error"); return; }
452
+ if (!input) { ctx.ui.notify("Usage: /run <agent> [task] [--bg] [--fork]", "error"); return; }
453
+ const { name: agentName, config: inline } = parseAgentToken(firstSpace === -1 ? input : input.slice(0, firstSpace));
454
+ const task = firstSpace === -1 ? "" : input.slice(firstSpace + 1).trim();
411
455
 
412
456
  const agents = discoverAgents(state.baseCwd, "both").agents;
413
457
  if (!agents.find((a) => a.name === agentName)) { ctx.ui.notify(`Unknown agent: ${agentName}`, "error"); return; }
@@ -278,11 +278,9 @@ export function restoreSlashFinalSnapshots(entries: unknown[]): void {
278
278
  liveSnapshots.clear();
279
279
  finalSnapshots.clear();
280
280
  for (const entry of entries) {
281
- const e = entry as { type?: string; message?: { role?: string; customType?: string; display?: boolean; details?: unknown } };
282
- if (e?.type !== "message") continue;
283
- const m = e.message;
284
- if (!m || m.role !== "custom" || m.customType !== SLASH_RESULT_TYPE || m.display !== false) continue;
285
- const details = resolveSlashMessageDetails(m.details);
281
+ const e = entry as { type?: string; customType?: string; details?: unknown };
282
+ if (e?.type !== "custom_message" || e.customType !== SLASH_RESULT_TYPE) continue;
283
+ const details = resolveSlashMessageDetails(e.details);
286
284
  if (!details) continue;
287
285
  finalSnapshots.set(details.requestId, { result: details.result, version: nextVersion() });
288
286
  }
@@ -326,7 +326,7 @@ function validateExecutionInput(
326
326
  function getRequestedModeLabel(params: SubagentParamsLike): Details["mode"] {
327
327
  if ((params.chain?.length ?? 0) > 0) return "chain";
328
328
  if ((params.tasks?.length ?? 0) > 0) return "parallel";
329
- if (params.agent && params.task) return "single";
329
+ if (params.agent) return "single";
330
330
  return "single";
331
331
  }
332
332
 
@@ -483,7 +483,7 @@ function runAsyncPath(data: ExecutionContextData, deps: ExecutorDeps): AgentTool
483
483
  } = data;
484
484
  const hasChain = (params.chain?.length ?? 0) > 0;
485
485
  const hasTasks = (params.tasks?.length ?? 0) > 0;
486
- const hasSingle = Boolean(params.agent && params.task);
486
+ const hasSingle = !hasChain && !hasTasks && Boolean(params.agent);
487
487
  if (!effectiveAsync) return null;
488
488
 
489
489
  if (hasChain && params.chain) {
@@ -614,7 +614,7 @@ function runAsyncPath(data: ExecutionContextData, deps: ExecutorDeps): AgentTool
614
614
  const modelOverride = resolveModelCandidate((params.model as string | undefined) ?? a.model, availableModels, currentProvider);
615
615
  return executeAsyncSingle(id, {
616
616
  agent: params.agent!,
617
- task: params.context === "fork" ? wrapForkTask(params.task!) : params.task!,
617
+ task: params.context === "fork" ? wrapForkTask(params.task ?? "") : (params.task ?? ""),
618
618
  agentConfig: a,
619
619
  ctx: asyncCtx,
620
620
  availableModels,
@@ -1211,7 +1211,7 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
1211
1211
  id: m.id,
1212
1212
  fullId: `${m.provider}/${m.id}`,
1213
1213
  }));
1214
- let task = params.task!;
1214
+ let task = params.task ?? "";
1215
1215
  let modelOverride: string | undefined = resolveModelCandidate(
1216
1216
  (params.model as string | undefined) ?? agentConfig.model,
1217
1217
  availableModels,
@@ -1548,7 +1548,7 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
1548
1548
  const shareEnabled = effectiveParams.share === true;
1549
1549
  const hasChain = (effectiveParams.chain?.length ?? 0) > 0;
1550
1550
  const hasTasks = (effectiveParams.tasks?.length ?? 0) > 0;
1551
- const hasSingle = Boolean(effectiveParams.agent && effectiveParams.task);
1551
+ const hasSingle = !hasChain && !hasTasks && Boolean(effectiveParams.agent);
1552
1552
  const allowClarifyTaskPrompt = hasChain
1553
1553
  && effectiveParams.clarify === true
1554
1554
  && ctx.hasUI
@@ -1602,6 +1602,8 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
1602
1602
  }
1603
1603
  const sessionDirForIndex = (idx?: number) =>
1604
1604
  path.join(sessionRoot, `run-${idx ?? 0}`);
1605
+ const childSessionFileForIndex = (idx?: number) =>
1606
+ sessionFileForIndex(idx) ?? path.join(sessionDirForIndex(idx), "session.jsonl");
1605
1607
 
1606
1608
  const onUpdateWithContext = onUpdate
1607
1609
  ? (r: AgentToolResult<Details>) => onUpdate(withForkContext(r, effectiveParams.context))
@@ -1618,7 +1620,7 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
1618
1620
  shareEnabled,
1619
1621
  sessionRoot,
1620
1622
  sessionDirForIndex,
1621
- sessionFileForIndex,
1623
+ sessionFileForIndex: childSessionFileForIndex,
1622
1624
  artifactConfig,
1623
1625
  artifactsDir,
1624
1626
  backgroundRequestedWhileClarifying,