pi-subagents 0.22.0 → 0.23.0

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,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.23.0] - 2026-05-02
6
+
7
+ ### Fixed
8
+ - Detect `pi-intercom` when installed through the documented `pi install npm:pi-intercom` package flow, instead of only checking the legacy local extension path.
9
+
10
+ ### Changed
11
+ - Store and discover saved chain workflows from dedicated chain directories: user chains in `~/.pi/agent/chains/**/*.chain.md` and project chains in `.pi/chains/**/*.chain.md`.
12
+ - Retry foreground subagent fallback models when Pi reports a retryable provider error, such as 429/quota, even if the child process exits successfully.
13
+ - Align single-run async subagent widgets and `/subagents-status` rendering with foreground subagent result styling for parallel, chain, and grouped chain runs, including inline live detail when tool output expansion is enabled, while keeping multi-job async widgets compact.
14
+ - Render async subagent widgets through an adaptive component so active parallel agent rows fit without Pi's fixed string-widget truncation marker.
15
+ - Tell parent agents that async runs are detached and they should end the turn instead of running sleep/poll loops when no independent work remains.
16
+
5
17
  ## [0.22.0] - 2026-05-02
6
18
 
7
19
  ### Added
package/README.md CHANGED
@@ -211,7 +211,7 @@ The package includes reusable prompt templates for common workflows. You do not
211
211
  pi install npm:pi-intercom
212
212
  ```
213
213
 
214
- Most users do not call `intercom` directly. After `pi-intercom` is installed, `pi-subagents` can automatically give child agents a private coordination channel back to the parent session.
214
+ Most users do not call `intercom` directly. After `pi-intercom` is installed, `pi-subagents` can automatically give child agents a private coordination channel back to the parent session. The bridge recognizes the normal `pi install npm:pi-intercom` package install as well as legacy local extension checkouts.
215
215
 
216
216
  Use it for work where the child might need a decision instead of guessing:
217
217
 
@@ -332,6 +332,8 @@ You can combine them in either order:
332
332
  /run reviewer "review this diff" --bg --fork
333
333
  ```
334
334
 
335
+ Background runs are detached. If the parent agent has other independent work, it should keep working. If it has nothing useful to do until the background result arrives, it should end the turn instead of running sleep or status-polling loops. Pi will deliver the completion when the run finishes.
336
+
335
337
  The `oracle` and `worker` builtins are designed for an explicit decision loop. A typical pattern is to ask `oracle` for diagnosis and a recommended execution prompt, then only run `worker` after the main agent approves that direction.
336
338
 
337
339
  ## Clarify and launch UI
@@ -401,7 +403,7 @@ Agent locations, lowest to highest priority:
401
403
  | User | `~/.pi/agent/agents/**/*.md` |
402
404
  | Project | `.pi/agents/**/*.md` |
403
405
 
404
- Project discovery also reads legacy `.agents/**/*.md` files. Nested subdirectories are discovered recursively. `.chain.md` files are treated as chains, not agents. If both `.agents/` and `.pi/agents/` define the same parsed runtime agent name, `.pi/agents/` wins. Use `agentScope: "user" | "project" | "both"` to control discovery; `both` is the default and project definitions win runtime-name collisions.
406
+ Project discovery also reads legacy `.agents/**/*.md` files. Nested subdirectories are discovered recursively. `.chain.md` files do not define agents. If both `.agents/` and `.pi/agents/` define the same parsed runtime agent name, `.pi/agents/` wins. Use `agentScope: "user" | "project" | "both"` to control discovery; `both` is the default and project definitions win runtime-name collisions.
405
407
 
406
408
  Builtin agents load at the lowest priority, so a user or project agent with the same name overrides them. They do not pin a provider model; they inherit your current Pi default model unless you set `subagents.agentOverrides.<name>.model`. `oracle` is an advisory reviewer that critiques direction and proposes an execution prompt without editing files. `worker` is the implementation agent for normal tasks and approved oracle handoffs.
407
409
 
@@ -531,14 +533,14 @@ When `extensions` is present, it takes precedence over extension paths implied b
531
533
 
532
534
  ## Chain files
533
535
 
534
- Chains are reusable `.chain.md` workflows stored next to agent files.
536
+ Chains are reusable `.chain.md` workflows stored separately from agent files.
535
537
 
536
538
  | Scope | Path |
537
539
  |-------|------|
538
- | User | `~/.pi/agent/agents/**/*.chain.md` |
539
- | Project | `.pi/agents/**/*.chain.md` |
540
+ | User | `~/.pi/agent/chains/**/*.chain.md` |
541
+ | Project | `.pi/chains/**/*.chain.md` |
540
542
 
541
- Project discovery also reads legacy `.agents/**/*.chain.md` files. Nested subdirectories are discovered recursively. If both locations define the same parsed runtime chain name, `.pi/agents/` wins. Chains support the same optional `package` frontmatter as agents; `name: review-flow` plus `package: code-analysis` runs as `code-analysis.review-flow`.
543
+ Nested subdirectories are discovered recursively. If user and project scopes define the same parsed runtime chain name, the project chain wins. Chains support the same optional `package` frontmatter as agents; `name: review-flow` plus `package: code-analysis` runs as `code-analysis.review-flow`.
542
544
 
543
545
  Example:
544
546
 
@@ -893,7 +895,7 @@ Fields:
893
895
  - `mode`: default `always`; use `fork-only` to inject only for forked runs, or `off` to disable the bridge.
894
896
  - `instructionFile`: optional Markdown template replacing the default bridge instructions. `{orchestratorTarget}` is interpolated. Relative paths resolve from `~/.pi/agent/extensions/subagent/`.
895
897
 
896
- Bridge activation also requires `pi-intercom` to be installed and enabled, a targetable current session name or fallback alias, and `pi-intercom` in any explicit agent `extensions` allowlist.
898
+ Bridge activation also requires `pi-intercom` to be installed and enabled through `pi install npm:pi-intercom` or a legacy local extension checkout, a targetable current session name or fallback alias, and `pi-intercom` in any explicit agent `extensions` allowlist.
897
899
 
898
900
  The default injected guidance tells children to use `contact_supervisor` with `reason: "need_decision"` when blocked or needing a decision, `reason: "progress_update"` only for meaningful blocked/progress updates, generic `intercom` as fallback plumbing, and avoid routine completion handoffs.
899
901
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-subagents",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
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",
@@ -183,11 +183,10 @@ Agent files can live in:
183
183
  - legacy `.agents/**/*.md` — still read for compatibility, but `.pi/agents/` wins on conflicts
184
184
 
185
185
  Chains live in:
186
- - `~/.pi/agent/agents/**/*.chain.md`
187
- - `.pi/agents/**/*.chain.md`
188
- - legacy `.agents/**/*.chain.md`
186
+ - `~/.pi/agent/chains/**/*.chain.md` — user scope
187
+ - `.pi/chains/**/*.chain.md` — project scope
189
188
 
190
- Discovery is recursive. `.chain.md` files are chains, not agents. Agents and chains can set optional frontmatter `package: code-analysis`; `name: scout` plus `package: code-analysis` registers as runtime name `code-analysis.scout` while serialization keeps `name` and `package` separate.
189
+ Discovery is recursive. `.chain.md` files do not define agents. Agents and chains can set optional frontmatter `package: code-analysis`; `name: scout` plus `package: code-analysis` registers as runtime name `code-analysis.scout` while serialization keeps `name` and `package` separate.
191
190
 
192
191
  Precedence is by parsed runtime name:
193
192
  1. project scope
@@ -263,7 +262,9 @@ without forcing each step to rediscover everything.
263
262
 
264
263
  ### Async/background
265
264
 
266
- Use async mode whenever the parent agent should keep working while a child runs. A normal foreground `subagent(...)` call blocks the parent until the child completes; it is appropriate when the next parent step depends on the child result. If you say you will "ask a reviewer while I continue auditing" or otherwise run local work in parallel with a child, launch with `async: true`. Do not end your turn immediately after launching that async child if you promised to keep working; continue the local inspection or other independent work, then check the async run when its result is needed.
265
+ Use async mode whenever the parent agent should keep working while a child runs. A normal foreground `subagent(...)` call blocks the parent until the child completes; it is appropriate when the next parent step depends on the child result. If you say you will "ask a reviewer while I continue auditing" or otherwise run local work in parallel with a child, launch with `async: true`.
266
+
267
+ Do not end your turn immediately after launching an async child if you promised to keep working. Continue the local inspection or other independent work, then check the async run when its result is needed. If there is no independent work left and you would only be running `sleep` or status polling commands to wait, end your turn instead. Pi will deliver the async completion when it arrives.
267
268
 
268
269
  ```typescript
269
270
  subagent({
@@ -459,7 +459,9 @@ export function handleCreate(params: ManagementParams, ctx: ManagementContext):
459
459
  const scope = scopeRaw as ManagementScope;
460
460
  const isChain = hasKey(cfg, "steps");
461
461
  const d = discoverAgentsAll(ctx.cwd);
462
- const targetDir = scope === "user" ? d.userDir : d.projectDir ?? path.join(ctx.cwd, ".pi", "agents");
462
+ const targetDir = isChain
463
+ ? scope === "user" ? d.userChainDir : d.projectChainDir ?? path.join(ctx.cwd, ".pi", "chains")
464
+ : scope === "user" ? d.userDir : d.projectDir ?? path.join(ctx.cwd, ".pi", "agents");
463
465
  fs.mkdirSync(targetDir, { recursive: true });
464
466
  if (nameExistsInScope(ctx.cwd, scope, runtimeName)) return result(`Name '${runtimeName}' already exists in ${scope} scope. Use update instead.`, true);
465
467
  const targetPath = path.join(targetDir, isChain ? `${runtimeName}.chain.md` : `${runtimeName}.md`);
@@ -130,6 +130,10 @@ interface AgentDiscoveryResult {
130
130
  projectAgentsDir: string | null;
131
131
  }
132
132
 
133
+ export function getUserChainDir(): string {
134
+ return path.join(os.homedir(), ".pi", "agent", "chains");
135
+ }
136
+
133
137
  function splitToolList(rawTools: string[] | undefined): { tools?: string[]; mcpDirectTools?: string[] } {
134
138
  const mcpDirectTools: string[] = [];
135
139
  const tools: string[] = [];
@@ -705,6 +709,17 @@ function resolveNearestProjectAgentDirs(cwd: string): { readDirs: string[]; pref
705
709
  preferredDir,
706
710
  };
707
711
  }
712
+
713
+ function resolveNearestProjectChainDirs(cwd: string): { readDirs: string[]; preferredDir: string | null } {
714
+ const projectRoot = findNearestProjectRoot(cwd);
715
+ if (!projectRoot) return { readDirs: [], preferredDir: null };
716
+
717
+ const preferredDir = path.join(projectRoot, ".pi", "chains");
718
+ return {
719
+ readDirs: isDirectory(preferredDir) ? [preferredDir] : [],
720
+ preferredDir,
721
+ };
722
+ }
708
723
  const BUILTIN_AGENTS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "agents");
709
724
 
710
725
  export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryResult {
@@ -742,12 +757,16 @@ export function discoverAgentsAll(cwd: string): {
742
757
  chains: ChainConfig[];
743
758
  userDir: string;
744
759
  projectDir: string | null;
760
+ userChainDir: string;
761
+ projectChainDir: string | null;
745
762
  userSettingsPath: string;
746
763
  projectSettingsPath: string | null;
747
764
  } {
748
765
  const userDirOld = path.join(os.homedir(), ".pi", "agent", "agents");
749
766
  const userDirNew = path.join(os.homedir(), ".agents");
767
+ const userChainDir = getUserChainDir();
750
768
  const { readDirs: projectDirs, preferredDir: projectDir } = resolveNearestProjectAgentDirs(cwd);
769
+ const { readDirs: projectChainDirs, preferredDir: projectChainDir } = resolveNearestProjectChainDirs(cwd);
751
770
  const userSettingsPath = getUserAgentSettingsPath();
752
771
  const projectSettingsPath = getProjectAgentSettingsPath(cwd);
753
772
  const userSettings = readSubagentSettings(userSettingsPath);
@@ -773,18 +792,17 @@ export function discoverAgentsAll(cwd: string): {
773
792
  const project = Array.from(projectMap.values());
774
793
 
775
794
  const chainMap = new Map<string, ChainConfig>();
776
- for (const dir of projectDirs) {
795
+ for (const dir of projectChainDirs) {
777
796
  for (const chain of loadChainsFromDir(dir, "project")) {
778
797
  chainMap.set(chain.name, chain);
779
798
  }
780
799
  }
781
800
  const chains = [
782
- ...loadChainsFromDir(userDirOld, "user"),
783
- ...loadChainsFromDir(userDirNew, "user"),
801
+ ...loadChainsFromDir(userChainDir, "user"),
784
802
  ...Array.from(chainMap.values()),
785
803
  ];
786
804
 
787
805
  const userDir = fs.existsSync(userDirNew) ? userDirNew : userDirOld;
788
806
 
789
- return { builtin, user, project, chains, userDir, projectDir, userSettingsPath, projectSettingsPath };
807
+ return { builtin, user, project, chains, userDir, projectDir, userChainDir, projectChainDir, userSettingsPath, projectSettingsPath };
790
808
  }
@@ -192,6 +192,7 @@ export function buildDoctorReport(input: DoctorReportInput): string {
192
192
  config: input.config.intercomBridge,
193
193
  context: input.context,
194
194
  orchestratorTarget: input.orchestratorTarget,
195
+ cwd: input.cwd,
195
196
  }), input.context).join("\n")).split("\n"),
196
197
  ];
197
198
  return lines.join("\n");
@@ -1,19 +1,27 @@
1
+ import { execSync } from "node:child_process";
1
2
  import * as fs from "node:fs";
2
3
  import * as os from "node:os";
3
4
  import * as path from "node:path";
4
5
  import type { AgentConfig } from "../agents/agents.ts";
5
6
  import type { ExtensionConfig, IntercomBridgeConfig, IntercomBridgeMode } from "../shared/types.ts";
6
7
 
7
- function defaultIntercomExtensionDir(): string {
8
- return path.join(os.homedir(), ".pi", "agent", "extensions", "pi-intercom");
8
+ const PI_INTERCOM_PACKAGE_NAME = "pi-intercom";
9
+ const CONFIG_DIR = ".pi";
10
+
11
+ function defaultAgentDir(): string {
12
+ return path.join(os.homedir(), ".pi", "agent");
13
+ }
14
+
15
+ function defaultIntercomExtensionDir(agentDir = defaultAgentDir()): string {
16
+ return path.join(agentDir, "extensions", PI_INTERCOM_PACKAGE_NAME);
9
17
  }
10
18
 
11
- function defaultIntercomConfigPath(): string {
12
- return path.join(os.homedir(), ".pi", "agent", "intercom", "config.json");
19
+ function defaultIntercomConfigPath(agentDir = defaultAgentDir()): string {
20
+ return path.join(agentDir, "intercom", "config.json");
13
21
  }
14
22
 
15
- function defaultSubagentConfigDir(): string {
16
- return path.join(os.homedir(), ".pi", "agent", "extensions", "subagent");
23
+ function defaultSubagentConfigDir(agentDir = defaultAgentDir()): string {
24
+ return path.join(agentDir, "extensions", "subagent");
17
25
  }
18
26
 
19
27
  const DEFAULT_INTERCOM_TARGET_PREFIX = "subagent-chat";
@@ -56,6 +64,9 @@ interface ResolveIntercomBridgeInput {
56
64
  extensionDir?: string;
57
65
  configPath?: string;
58
66
  settingsDir?: string;
67
+ cwd?: string;
68
+ agentDir?: string;
69
+ globalNpmRoot?: string | null;
59
70
  }
60
71
 
61
72
  export function resolveIntercomSessionTarget(sessionName: string | undefined, sessionId: string): string {
@@ -102,6 +113,119 @@ function intercomConfigStatus(configPath: string): { enabled: boolean; error?: u
102
113
  }
103
114
  }
104
115
 
116
+ function readJsonBestEffort(filePath: string): unknown {
117
+ try {
118
+ return JSON.parse(fs.readFileSync(filePath, "utf-8"));
119
+ } catch {
120
+ return null;
121
+ }
122
+ }
123
+
124
+ function packageHasPiExtension(packageRoot: string): boolean {
125
+ if (!fs.existsSync(packageRoot)) return false;
126
+ const pkg = readJsonBestEffort(path.join(packageRoot, "package.json"));
127
+ if (pkg && typeof pkg === "object" && !Array.isArray(pkg)) {
128
+ const pi = (pkg as { pi?: unknown }).pi;
129
+ if (pi && typeof pi === "object" && !Array.isArray(pi)) {
130
+ const extensions = (pi as { extensions?: unknown }).extensions;
131
+ return Array.isArray(extensions) && extensions.some((entry) => typeof entry === "string" && entry.trim() !== "");
132
+ }
133
+ }
134
+ return fs.existsSync(path.join(packageRoot, "extensions"));
135
+ }
136
+
137
+ function isSafePackagePath(value: string): boolean {
138
+ return value.length > 0
139
+ && !path.isAbsolute(value)
140
+ && value.split(/[\\/]/).every((part) => part.length > 0 && part !== "." && part !== "..");
141
+ }
142
+
143
+ function parseNpmPackageName(source: string): string | undefined {
144
+ const spec = source.slice(4).trim();
145
+ if (!spec) return undefined;
146
+ const match = spec.match(/^(@?[^@]+(?:\/[^@]+)?)(?:@(.+))?$/);
147
+ const packageName = match?.[1] ?? spec;
148
+ return isSafePackagePath(packageName) ? packageName : undefined;
149
+ }
150
+
151
+ function packageEntrySource(entry: unknown): string | undefined {
152
+ if (typeof entry === "string") return entry;
153
+ if (entry && typeof entry === "object" && !Array.isArray(entry) && typeof (entry as { source?: unknown }).source === "string") {
154
+ return (entry as { source: string }).source;
155
+ }
156
+ return undefined;
157
+ }
158
+
159
+ function packageEntryAllowsExtensions(entry: unknown): boolean {
160
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return true;
161
+ const extensions = (entry as { extensions?: unknown }).extensions;
162
+ return !Array.isArray(extensions) || extensions.length > 0;
163
+ }
164
+
165
+ function findNearestProjectConfigDir(cwd: string): string | undefined {
166
+ let current = path.resolve(cwd);
167
+ while (true) {
168
+ const configDir = path.join(current, CONFIG_DIR);
169
+ if (fs.existsSync(path.join(configDir, "settings.json"))) return configDir;
170
+ const parent = path.dirname(current);
171
+ if (parent === current) return undefined;
172
+ current = parent;
173
+ }
174
+ }
175
+
176
+ let cachedGlobalNpmRoot: string | null | undefined;
177
+
178
+ function getGlobalNpmRoot(): string | null {
179
+ if (cachedGlobalNpmRoot !== undefined) return cachedGlobalNpmRoot;
180
+ try {
181
+ cachedGlobalNpmRoot = execSync("npm root -g", { encoding: "utf-8", timeout: 5000 }).trim();
182
+ return cachedGlobalNpmRoot;
183
+ } catch {
184
+ cachedGlobalNpmRoot = null;
185
+ return null;
186
+ }
187
+ }
188
+
189
+ function configuredPiIntercomPackageDir(input: ResolveIntercomBridgeInput, agentDir: string): string | undefined {
190
+ const cwd = path.resolve(input.cwd ?? process.cwd());
191
+ const projectConfigDir = findNearestProjectConfigDir(cwd);
192
+ const settingsFiles = [
193
+ ...(projectConfigDir ? [{ file: path.join(projectConfigDir, "settings.json"), configDir: projectConfigDir, scope: "project" as const }] : []),
194
+ { file: path.join(agentDir, "settings.json"), configDir: agentDir, scope: "user" as const },
195
+ ];
196
+ const globalNpmRoot = input.globalNpmRoot === undefined ? getGlobalNpmRoot() : input.globalNpmRoot;
197
+
198
+ for (const { file, configDir, scope } of settingsFiles) {
199
+ const settings = readJsonBestEffort(file);
200
+ if (!settings || typeof settings !== "object" || Array.isArray(settings)) continue;
201
+ const packages = (settings as { packages?: unknown }).packages;
202
+ if (!Array.isArray(packages)) continue;
203
+
204
+ for (const entry of packages) {
205
+ if (!packageEntryAllowsExtensions(entry)) continue;
206
+ const source = packageEntrySource(entry)?.trim();
207
+ if (!source?.startsWith("npm:")) continue;
208
+ const packageName = parseNpmPackageName(source);
209
+ if (packageName !== PI_INTERCOM_PACKAGE_NAME) continue;
210
+ const candidates = scope === "project"
211
+ ? [path.join(configDir, "npm", "node_modules", packageName)]
212
+ : [
213
+ ...(globalNpmRoot ? [path.join(globalNpmRoot, packageName)] : []),
214
+ path.join(agentDir, "npm", "node_modules", packageName),
215
+ ];
216
+ const packageRoot = candidates.find(packageHasPiExtension);
217
+ if (packageRoot) return path.resolve(packageRoot);
218
+ }
219
+ }
220
+ return undefined;
221
+ }
222
+
223
+ function resolveIntercomExtensionDir(input: ResolveIntercomBridgeInput, agentDir: string): string {
224
+ const legacyDir = path.resolve(input.extensionDir ?? defaultIntercomExtensionDir(agentDir));
225
+ if (fs.existsSync(legacyDir)) return legacyDir;
226
+ return configuredPiIntercomPackageDir(input, agentDir) ?? legacyDir;
227
+ }
228
+
105
229
  function extensionSandboxAllowsIntercom(extensions: string[] | undefined, extensionDir: string): boolean {
106
230
  if (extensions === undefined) return true;
107
231
 
@@ -145,9 +269,10 @@ ${instruction}`;
145
269
  export function diagnoseIntercomBridge(input: ResolveIntercomBridgeInput): IntercomBridgeDiagnostic {
146
270
  const config = resolveIntercomBridgeConfig(input.config);
147
271
  const mode = config.mode;
148
- const extensionDir = path.resolve(input.extensionDir ?? defaultIntercomExtensionDir());
272
+ const agentDir = path.resolve(input.agentDir ?? defaultAgentDir());
273
+ const extensionDir = resolveIntercomExtensionDir(input, agentDir);
149
274
  const orchestratorTarget = input.orchestratorTarget?.trim();
150
- const configPath = path.resolve(input.configPath ?? defaultIntercomConfigPath());
275
+ const configPath = path.resolve(input.configPath ?? defaultIntercomConfigPath(agentDir));
151
276
  const wantsIntercom = mode !== "off" && !(mode === "fork-only" && input.context !== "fork");
152
277
  const piIntercomAvailable = fs.existsSync(extensionDir);
153
278
  let configStatus: ReturnType<typeof intercomConfigStatus> | undefined;
@@ -183,9 +308,10 @@ export function diagnoseIntercomBridge(input: ResolveIntercomBridgeInput): Inter
183
308
  export function resolveIntercomBridge(input: ResolveIntercomBridgeInput): IntercomBridgeState {
184
309
  const config = resolveIntercomBridgeConfig(input.config);
185
310
  const mode = config.mode;
186
- const extensionDir = path.resolve(input.extensionDir ?? defaultIntercomExtensionDir());
311
+ const agentDir = path.resolve(input.agentDir ?? defaultAgentDir());
312
+ const extensionDir = resolveIntercomExtensionDir(input, agentDir);
187
313
  const orchestratorTarget = input.orchestratorTarget?.trim();
188
- const settingsDir = path.resolve(input.settingsDir ?? defaultSubagentConfigDir());
314
+ const settingsDir = path.resolve(input.settingsDir ?? defaultSubagentConfigDir(agentDir));
189
315
  const defaultInstruction = buildIntercomBridgeInstruction(
190
316
  orchestratorTarget || "{orchestratorTarget}",
191
317
  DEFAULT_INTERCOM_BRIDGE_TEMPLATE,
@@ -204,7 +330,7 @@ export function resolveIntercomBridge(input: ResolveIntercomBridgeInput): Interc
204
330
  return { active: false, mode, extensionDir, instruction: defaultInstruction };
205
331
  }
206
332
 
207
- const configPath = path.resolve(input.configPath ?? defaultIntercomConfigPath());
333
+ const configPath = path.resolve(input.configPath ?? defaultIntercomConfigPath(agentDir));
208
334
  const intercomStatus = intercomConfigStatus(configPath);
209
335
  if (intercomStatus.error) console.warn(`Failed to parse intercom config at '${configPath}'. Assuming enabled.`, intercomStatus.error);
210
336
  if (!intercomStatus.enabled) {
@@ -38,7 +38,7 @@ export type ManagerResult =
38
38
  | { action: "launch-chain"; chain: ChainConfig; task: string; skipClarify?: boolean; fork?: boolean; background?: boolean; worktree?: boolean }
39
39
  | undefined;
40
40
 
41
- export interface AgentData { builtin: AgentConfig[]; user: AgentConfig[]; project: AgentConfig[]; chains: ChainConfig[]; userDir: string; projectDir: string | null; userSettingsPath: string; projectSettingsPath: string | null; cwd: string; }
41
+ export interface AgentData { builtin: AgentConfig[]; user: AgentConfig[]; project: AgentConfig[]; chains: ChainConfig[]; userDir: string; projectDir: string | null; userChainDir: string; projectChainDir: string | null; userSettingsPath: string; projectSettingsPath: string | null; cwd: string; }
42
42
  type ManagerScreen = "list" | "detail" | "chain-detail" | "edit" | "edit-field" | "edit-prompt" | "task-input" | "confirm-delete" | "name-input" | "chain-edit" | "template-select" | "parallel-builder" | "override-scope";
43
43
  interface AgentEntry { id: string; kind: "agent"; config: AgentConfig; isNew: boolean; }
44
44
  interface ChainEntry { id: string; kind: "chain"; config: ChainConfig; }
@@ -253,7 +253,8 @@ export class AgentManagerComponent implements Component {
253
253
  }
254
254
 
255
255
  private enterNameInput(mode: NameInputState["mode"], sourceId?: string, template?: AgentTemplate): void {
256
- const allowProject = Boolean(this.agentData.projectDir); let initial = ""; let scope: "user" | "project" = "user";
256
+ const isChain = mode === "new-chain" || mode === "clone-chain";
257
+ const allowProject = Boolean(isChain ? this.agentData.projectChainDir : this.agentData.projectDir); let initial = ""; let scope: "user" | "project" = "user";
257
258
  if (mode === "clone-agent" && sourceId) { const entry = this.getAgentEntry(sourceId); if (entry) { initial = `${frontmatterNameForConfig(entry.config)}-copy`; scope = entry.config.source === "project" ? "project" : "user"; } }
258
259
  if (mode === "clone-chain" && sourceId) { const entry = this.getChainEntry(sourceId); if (entry) { initial = `${frontmatterNameForConfig(entry.config)}-copy`; scope = entry.config.source === "project" ? "project" : "user"; } }
259
260
  if (mode === "new-agent" && template && template.name !== "Blank") initial = slugTemplateName(template.name);
@@ -425,14 +426,14 @@ export class AgentManagerComponent implements Component {
425
426
 
426
427
  if (state.mode === "clone-chain" && state.sourceId) {
427
428
  const sourceEntry = this.getChainEntry(state.sourceId); if (!sourceEntry) { this.screen = "list"; this.tui.requestRender(); return; }
428
- const dir = state.scope === "project" ? this.agentData.projectDir : this.agentData.userDir;
429
- if (!dir) { state.error = "Project agents directory not found."; this.tui.requestRender(); return; }
429
+ const dir = state.scope === "project" ? this.agentData.projectChainDir : this.agentData.userChainDir;
430
+ if (!dir) { state.error = "Project chains directory not found."; this.tui.requestRender(); return; }
430
431
  const filePath = path.join(dir, `${name}.chain.md`); if (fs.existsSync(filePath) || this.runtimeNameExistsInScope("chain", state.scope, name)) { state.error = "A chain with that name already exists."; this.tui.requestRender(); return; }
431
432
  try { const cloned = cloneChainConfig({ ...sourceEntry.config, name, localName: name, packageName: undefined, source: state.scope, filePath }); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(filePath, serializeChain(cloned), "utf-8"); const added: ChainEntry = { id: `c${this.nextId++}`, kind: "chain", config: cloned }; this.chains.push(added); this.nameInputState = null; this.enterChainDetail(added); this.tui.requestRender(); return; }
432
433
  catch (err) { state.error = err instanceof Error ? err.message : "Failed to clone chain."; this.tui.requestRender(); return; }
433
434
  }
434
435
  if (state.mode === "new-chain") {
435
- const dir = state.scope === "project" ? this.agentData.projectDir : this.agentData.userDir;
436
+ const dir = state.scope === "project" ? this.agentData.projectChainDir : this.agentData.userChainDir;
436
437
  if (!dir) { state.error = "Directory not found."; this.tui.requestRender(); return; }
437
438
  const filePath = path.join(dir, `${name}.chain.md`); if (fs.existsSync(filePath) || this.runtimeNameExistsInScope("chain", state.scope, name)) { state.error = "A chain with that name already exists."; this.tui.requestRender(); return; }
438
439
  const config: ChainConfig = { name, localName: name, description: "Describe this chain", source: state.scope, filePath, steps: [{ agent: "agent-name", task: "{task}" }] };
@@ -116,6 +116,16 @@ interface AsyncExecutionResult {
116
116
  isError?: boolean;
117
117
  }
118
118
 
119
+ export function formatAsyncStartedMessage(headline: string): string {
120
+ return [
121
+ headline,
122
+ "",
123
+ "The async run is detached. Do not run sleep timers or polling loops just to wait for it.",
124
+ "If you have independent work, continue that work. If you have nothing else to do until the async result arrives, end your turn now; Pi will deliver the completion when the run finishes.",
125
+ "Use subagent({ action: \"status\", id: \"...\" }) when you need the current status/result, or to inspect a blocked/stale run. Do not poll just to wait.",
126
+ ].join("\n");
127
+ }
128
+
119
129
  /**
120
130
  * Check if jiti is available for async execution
121
131
  */
@@ -425,7 +435,7 @@ export function executeAsyncChain(
425
435
  .join(" -> ");
426
436
 
427
437
  return {
428
- content: [{ type: "text", text: `Async ${resultMode}: ${chainDesc} [${id}]` }],
438
+ content: [{ type: "text", text: formatAsyncStartedMessage(`Async ${resultMode}: ${chainDesc} [${id}]`) }],
429
439
  details: { mode: resultMode, results: [], asyncId: id, asyncDir },
430
440
  };
431
441
  }
@@ -557,7 +567,7 @@ export function executeAsyncSingle(
557
567
  }
558
568
 
559
569
  return {
560
- content: [{ type: "text", text: `Async: ${agent} [${id}]` }],
570
+ content: [{ type: "text", text: formatAsyncStartedMessage(`Async: ${agent} [${id}]`) }],
561
571
  details: { mode: "single", results: [], asyncId: id, asyncDir },
562
572
  };
563
573
  }
@@ -162,8 +162,8 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
162
162
  ? groups.find((group) => status.currentStep! >= group.start && status.currentStep! < group.start + group.count)
163
163
  : undefined;
164
164
  const visibleSteps = activeGroup
165
- ? status.steps.slice(activeGroup.start, activeGroup.start + activeGroup.count)
166
- : status.steps;
165
+ ? status.steps.slice(activeGroup.start, activeGroup.start + activeGroup.count).map((step, index) => ({ ...step, index: activeGroup.start + index }))
166
+ : status.steps.map((step, index) => ({ ...step, index }));
167
167
  job.activeParallelGroup = Boolean(activeGroup);
168
168
  job.agents = visibleSteps.map((step) => step.agent);
169
169
  job.steps = visibleSteps;
@@ -13,8 +13,11 @@ interface AsyncRunStepSummary {
13
13
  activityState?: ActivityState;
14
14
  lastActivityAt?: number;
15
15
  currentTool?: string;
16
+ currentToolArgs?: string;
16
17
  currentToolStartedAt?: number;
17
18
  currentPath?: string;
19
+ recentTools?: Array<{ tool: string; args: string; endMs: number }>;
20
+ recentOutput?: string[];
18
21
  turnCount?: number;
19
22
  toolCount?: number;
20
23
  durationMs?: number;
@@ -155,8 +158,11 @@ function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string
155
158
  ...(stepActivityState ? { activityState: stepActivityState } : {}),
156
159
  ...(stepLastActivityAt ? { lastActivityAt: stepLastActivityAt } : {}),
157
160
  ...(step.currentTool ? { currentTool: step.currentTool } : {}),
161
+ ...(step.currentToolArgs ? { currentToolArgs: step.currentToolArgs } : {}),
158
162
  ...(step.currentToolStartedAt ? { currentToolStartedAt: step.currentToolStartedAt } : {}),
159
163
  ...(step.currentPath ? { currentPath: step.currentPath } : {}),
164
+ ...(step.recentTools ? { recentTools: step.recentTools.map((tool) => ({ ...tool })) } : {}),
165
+ ...(step.recentOutput ? { recentOutput: [...step.recentOutput] } : {}),
160
166
  ...(step.turnCount !== undefined ? { turnCount: step.turnCount } : {}),
161
167
  ...(step.toolCount !== undefined ? { toolCount: step.toolCount } : {}),
162
168
  ...(step.durationMs !== undefined ? { durationMs: step.durationMs } : {}),
@@ -270,7 +276,7 @@ function formatParallelProgress(steps: Pick<AsyncRunStepSummary, "status">[], to
270
276
  const failed = steps.filter((step) => step.status === "failed").length;
271
277
  const paused = steps.filter((step) => step.status === "paused").length;
272
278
  const parts = [`${done}/${total} done`];
273
- if (showRunning) parts.unshift(running === 1 ? "1 agent running" : `${running} agents running`);
279
+ if (showRunning && running > 0) parts.unshift(running === 1 ? "1 agent running" : `${running} agents running`);
274
280
  if (failed > 0) parts.push(`${failed} failed`);
275
281
  if (paused > 0) parts.push(`${paused} paused`);
276
282
  return parts.join(" · ");
@@ -142,6 +142,25 @@ function tokenUsageFromAttempts(attempts: ModelAttempt[] | undefined): TokenUsag
142
142
  return total > 0 ? { input, output, total } : null;
143
143
  }
144
144
 
145
+ function appendRecentStepOutput(step: RunnerStatusStep, lines: string[]): void {
146
+ const nonEmpty = lines.filter((line) => line.trim());
147
+ if (nonEmpty.length === 0) return;
148
+ step.recentOutput ??= [];
149
+ step.recentOutput.push(...nonEmpty);
150
+ if (step.recentOutput.length > 50) {
151
+ step.recentOutput.splice(0, step.recentOutput.length - 50);
152
+ }
153
+ }
154
+
155
+ function resetStepLiveDetail(step: RunnerStatusStep): void {
156
+ step.currentTool = undefined;
157
+ step.currentToolArgs = undefined;
158
+ step.currentToolStartedAt = undefined;
159
+ step.currentPath = undefined;
160
+ step.recentTools = [];
161
+ step.recentOutput = [];
162
+ }
163
+
145
164
  interface ChildEventContext {
146
165
  eventsPath: string;
147
166
  runId: string;
@@ -900,6 +919,8 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
900
919
  skills: step.skills,
901
920
  model: step.model,
902
921
  attemptedModels: step.modelCandidates && step.modelCandidates.length > 0 ? step.modelCandidates : step.model ? [step.model] : undefined,
922
+ recentTools: [],
923
+ recentOutput: [],
903
924
  })),
904
925
  artifactsDir,
905
926
  sessionDir: config.sessionDir,
@@ -1002,13 +1023,19 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1002
1023
  const currentPath = resolveCurrentPath(event.toolName, event.args);
1003
1024
  step.toolCount = (step.toolCount ?? 0) + 1;
1004
1025
  step.currentTool = event.toolName;
1026
+ step.currentToolArgs = extractToolArgsPreview(event.args ?? {});
1005
1027
  step.currentToolStartedAt = now;
1006
1028
  step.currentPath = currentPath;
1007
1029
  pendingToolResults[flatIndex] = { tool: event.toolName, path: currentPath, mutates, startedAt: now };
1008
1030
  statusPayload.toolCount = (statusPayload.toolCount ?? 0) + 1;
1009
1031
  syncTopLevelCurrentTool();
1010
1032
  } else if (event.type === "tool_execution_end") {
1033
+ if (step.currentTool) {
1034
+ step.recentTools ??= [];
1035
+ step.recentTools.push({ tool: step.currentTool, args: step.currentToolArgs || "", endMs: now });
1036
+ }
1011
1037
  step.currentTool = undefined;
1038
+ step.currentToolArgs = undefined;
1012
1039
  step.currentToolStartedAt = undefined;
1013
1040
  step.currentPath = undefined;
1014
1041
  syncTopLevelCurrentTool();
@@ -1016,6 +1043,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1016
1043
  const toolSnapshot = pendingToolResults[flatIndex];
1017
1044
  pendingToolResults[flatIndex] = undefined;
1018
1045
  const resultText = extractTextFromContent(event.message.content);
1046
+ appendRecentStepOutput(step, resultText.split("\n").slice(-10));
1019
1047
  if (toolSnapshot?.mutates && didMutatingToolFail(resultText)) {
1020
1048
  const state = mutatingFailureStates[flatIndex]!;
1021
1049
  recordMutatingFailure(state, {
@@ -1051,6 +1079,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1051
1079
  resetMutatingFailureState(mutatingFailureStates[flatIndex]!);
1052
1080
  }
1053
1081
  } else if (event.type === "message_end" && event.message?.role === "assistant") {
1082
+ appendRecentStepOutput(step, extractTextFromContent(event.message.content).split("\n").slice(-10));
1054
1083
  step.turnCount = (step.turnCount ?? 0) + 1;
1055
1084
  const usage = event.message.usage;
1056
1085
  if (usage) {
@@ -1277,6 +1306,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1277
1306
  statusPayload.steps[fi].status = "running";
1278
1307
  statusPayload.steps[fi].error = undefined;
1279
1308
  statusPayload.steps[fi].activityState = undefined;
1309
+ resetStepLiveDetail(statusPayload.steps[fi]);
1280
1310
  statusPayload.steps[fi].startedAt = taskStartTime;
1281
1311
  statusPayload.steps[fi].endedAt = undefined;
1282
1312
  statusPayload.steps[fi].durationMs = undefined;
@@ -1420,6 +1450,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1420
1450
  statusPayload.steps[flatIndex].status = "running";
1421
1451
  statusPayload.steps[flatIndex].activityState = undefined;
1422
1452
  statusPayload.activityState = undefined;
1453
+ resetStepLiveDetail(statusPayload.steps[flatIndex]);
1423
1454
  statusPayload.steps[flatIndex].skills = seqStep.skills;
1424
1455
  statusPayload.steps[flatIndex].startedAt = stepStartTime;
1425
1456
  statusPayload.steps[flatIndex].lastActivityAt = stepStartTime;
@@ -9,9 +9,8 @@ import type { Theme } from "@mariozechner/pi-coding-agent";
9
9
  import type { Component, TUI } from "@mariozechner/pi-tui";
10
10
  import { matchesKey, visibleWidth, truncateToWidth } from "@mariozechner/pi-tui";
11
11
  import * as fs from "node:fs";
12
- import * as os from "node:os";
13
12
  import * as path from "node:path";
14
- import type { AgentConfig, ChainConfig, ChainStepConfig } from "../../agents/agents.ts";
13
+ import { getUserChainDir, type AgentConfig, type ChainConfig, type ChainStepConfig } from "../../agents/agents.ts";
15
14
  import type { ResolvedStepBehavior } from "../../shared/settings.ts";
16
15
  import type { TextEditorState } from "../../tui/text-editor.ts";
17
16
  import { createEditorState, ensureCursorVisible, getCursorDisplayPos, handleEditorInput, renderEditor, wrapText } from "../../tui/text-editor.ts";
@@ -317,7 +316,7 @@ export class ChainClarifyComponent implements Component {
317
316
  return;
318
317
  }
319
318
  try {
320
- const dir = path.join(os.homedir(), ".pi", "agent", "agents");
319
+ const dir = getUserChainDir();
321
320
  fs.mkdirSync(dir, { recursive: true });
322
321
  const filePath = path.join(dir, `${name}.chain.md`);
323
322
  const config = this.buildChainConfig(name);
@@ -811,15 +811,16 @@ export async function runSync(
811
811
  sumUsage(aggregateUsage, result.usage);
812
812
  totalToolCount += result.progressSummary?.toolCount ?? 0;
813
813
  totalDurationMs += result.progressSummary?.durationMs ?? 0;
814
+ const attemptSucceeded = result.exitCode === 0 && !result.error;
814
815
  const attempt: ModelAttempt = {
815
816
  model: candidate ?? result.model ?? agent.model ?? "default",
816
- success: result.exitCode === 0,
817
+ success: attemptSucceeded,
817
818
  exitCode: result.exitCode,
818
819
  error: result.error,
819
820
  usage: { ...result.usage },
820
821
  };
821
822
  modelAttempts.push(attempt);
822
- if (result.exitCode === 0) {
823
+ if (attemptSucceeded) {
823
824
  break;
824
825
  }
825
826
  if (!isRetryableModelFailure(result.error) || i === modelsToTry.length - 1) {
@@ -28,7 +28,7 @@ import {
28
28
  type StepOverrides,
29
29
  } from "../../shared/settings.ts";
30
30
  import { discoverAvailableSkills, normalizeSkillInput } from "../../agents/skills.ts";
31
- import { executeAsyncChain, executeAsyncSingle, isAsyncAvailable } from "../background/async-execution.ts";
31
+ import { executeAsyncChain, executeAsyncSingle, formatAsyncStartedMessage, isAsyncAvailable } from "../background/async-execution.ts";
32
32
  import { createForkContextResolver } from "../../shared/fork-context.ts";
33
33
  import { resolveCurrentSessionId } from "../../shared/session-identity.ts";
34
34
  import { applyIntercomBridgeToAgent, INTERCOM_BRIDGE_MARKER, resolveIntercomBridge, resolveIntercomSessionTarget, resolveSubagentIntercomTarget, type IntercomBridgeState } from "../../intercom/intercom-bridge.ts";
@@ -344,6 +344,7 @@ async function resumeAsyncRun(input: {
344
344
  config: input.deps.config.intercomBridge,
345
345
  context: input.params.context,
346
346
  orchestratorTarget: sessionName,
347
+ cwd: effectiveCwd,
347
348
  });
348
349
  const agents = intercomBridge.active
349
350
  ? discoveredAgents.map((agent) => applyIntercomBridgeToAgent(agent, intercomBridge))
@@ -396,9 +397,9 @@ async function resumeAsyncRun(input: {
396
397
  `Session: ${target.sessionFile}`,
397
398
  result.details.asyncDir ? `Async dir: ${result.details.asyncDir}` : undefined,
398
399
  revivedTarget ? `Intercom target: ${revivedTarget} (if registered)` : undefined,
399
- `Follow: subagent({ action: "status", id: "${revivedId}" })`,
400
+ `Status if needed: subagent({ action: "status", id: "${revivedId}" })`,
400
401
  ].filter((line): line is string => Boolean(line));
401
- return { content: [{ type: "text", text: lines.join("\n") }], details: result.details };
402
+ return { content: [{ type: "text", text: formatAsyncStartedMessage(lines.join("\n")) }], details: result.details };
402
403
  }
403
404
 
404
405
  function resultSummaryForIntercom(result: SingleResult): string {
@@ -1962,6 +1963,7 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
1962
1963
  config: deps.config.intercomBridge,
1963
1964
  context: effectiveParams.context,
1964
1965
  orchestratorTarget: sessionName,
1966
+ cwd: effectiveCwd,
1965
1967
  });
1966
1968
  const agents = intercomBridge.active
1967
1969
  ? discoveredAgents.map((agent) => applyIntercomBridgeToAgent(agent, intercomBridge))
@@ -299,8 +299,11 @@ export interface AsyncStatus {
299
299
  activityState?: ActivityState;
300
300
  lastActivityAt?: number;
301
301
  currentTool?: string;
302
+ currentToolArgs?: string;
302
303
  currentToolStartedAt?: number;
303
304
  currentPath?: string;
305
+ recentTools?: Array<{ tool: string; args: string; endMs: number }>;
306
+ recentOutput?: string[];
304
307
  turnCount?: number;
305
308
  toolCount?: number;
306
309
  startedAt?: number;
@@ -320,6 +323,10 @@ export interface AsyncStatus {
320
323
  sessionFile?: string;
321
324
  }
322
325
 
326
+ export type AsyncJobStep = NonNullable<AsyncStatus["steps"]>[number] & {
327
+ index?: number;
328
+ };
329
+
323
330
  export interface AsyncJobState {
324
331
  asyncId: string;
325
332
  asyncDir: string;
@@ -338,7 +345,7 @@ export interface AsyncJobState {
338
345
  currentStep?: number;
339
346
  chainStepCount?: number;
340
347
  parallelGroups?: AsyncParallelGroupStatus[];
341
- steps?: AsyncStatus["steps"];
348
+ steps?: AsyncJobStep[];
342
349
  stepsTotal?: number;
343
350
  runningSteps?: number;
344
351
  completedSteps?: number;
package/src/tui/render.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  * Rendering functions for subagent results
3
3
  */
4
4
 
5
+ import * as path from "node:path";
5
6
  import type { AgentToolResult } from "@mariozechner/pi-agent-core";
6
7
  import { getMarkdownTheme, type ExtensionContext } from "@mariozechner/pi-coding-agent";
7
8
  import { Container, Markdown, Spacer, Text, visibleWidth, type Component } from "@mariozechner/pi-tui";
@@ -14,6 +15,7 @@ import {
14
15
  } from "../shared/types.ts";
15
16
  import { formatTokens, formatUsage, formatDuration, formatToolCall, shortenPath } from "../shared/formatters.ts";
16
17
  import { getDisplayItems, getLastActivity, getSingleResultOutput } from "../shared/utils.ts";
18
+ import { flatToLogicalStepIndex } from "../runs/background/parallel-groups.ts";
17
19
 
18
20
  type Theme = ExtensionContext["ui"]["theme"];
19
21
 
@@ -264,10 +266,10 @@ function formatWidgetAgents(agents: string[]): string {
264
266
  }
265
267
 
266
268
  function widgetJobName(job: AsyncJobState): string {
267
- const agents = job.agents?.length ? formatWidgetAgents(job.agents) : undefined;
268
- if (job.mode === "parallel") return agents ? `parallel · ${agents}` : "parallel";
269
- if (job.activeParallelGroup) return agents ? `parallel group · ${agents}` : "parallel group";
270
- if (job.agents?.length) return job.agents.join(" → ");
269
+ if (job.mode === "parallel") return "parallel";
270
+ if (job.mode === "chain") return "chain";
271
+ if (job.mode === "single" && job.agents?.length === 1) return job.agents[0]!;
272
+ if (job.agents?.length) return formatWidgetAgents(job.agents);
271
273
  return job.mode ?? "subagent";
272
274
  }
273
275
 
@@ -293,6 +295,7 @@ function widgetActivity(job: AsyncJobState): string {
293
295
  if (activity && facts.length) return `${activity} · ${facts.join(" · ")}`;
294
296
  if (activity) return activity;
295
297
  if (facts.length) return facts.join(" · ");
298
+ if (job.status === "running") return "thinking…";
296
299
  if (job.status === "queued") return "queued…";
297
300
  if (job.status === "paused") return "Paused";
298
301
  if (job.status === "failed") return "Failed";
@@ -308,7 +311,7 @@ function widgetStatusGlyph(job: AsyncJobState, theme: Theme): string {
308
311
  }
309
312
 
310
313
  function widgetStepGlyph(status: string, theme: Theme): string {
311
- if (status === "running") return theme.fg("accent", "▶");
314
+ if (status === "running") return theme.fg("accent", spinnerFrame());
312
315
  if (status === "complete" || status === "completed") return theme.fg("success", "✓");
313
316
  if (status === "failed") return theme.fg("error", "✗");
314
317
  if (status === "paused") return theme.fg("warning", "■");
@@ -337,14 +340,62 @@ function widgetStepActivity(step: NonNullable<AsyncJobState["steps"]>[number]):
337
340
  return facts.join(" · ");
338
341
  }
339
342
 
343
+ function widgetAggregateStepStatus(steps: NonNullable<AsyncJobState["steps"]>): string {
344
+ if (steps.some((step) => step.status === "running")) return "running";
345
+ if (steps.some((step) => step.status === "failed")) return "failed";
346
+ if (steps.some((step) => step.status === "paused")) return "paused";
347
+ if (steps.length > 0 && steps.every((step) => step.status === "complete" || step.status === "completed")) return "complete";
348
+ return "pending";
349
+ }
350
+
351
+ function widgetParallelOutcome(steps: NonNullable<AsyncJobState["steps"]>, total: number): string {
352
+ const running = steps.filter((step) => step.status === "running").length;
353
+ const done = steps.filter((step) => step.status === "complete" || step.status === "completed").length;
354
+ const failed = steps.filter((step) => step.status === "failed").length;
355
+ const paused = steps.filter((step) => step.status === "paused").length;
356
+ const parts = [`${done}/${total} done`];
357
+ if (running > 0) parts.unshift(formatAgentRunningLabel(running));
358
+ if (failed > 0) parts.push(`${failed} failed`);
359
+ if (paused > 0) parts.push(`${paused} paused`);
360
+ return parts.join(" · ");
361
+ }
362
+
363
+ function widgetChainDetails(job: AsyncJobState, theme: Theme, expanded = false, width = getTermWidth()): string[] {
364
+ if (!job.steps?.length) return [];
365
+ const total = job.chainStepCount ?? job.steps.length;
366
+ const groups = job.parallelGroups ?? [];
367
+ const lines: string[] = [];
368
+ let flatIndex = 0;
369
+ for (let stepIndex = 0; stepIndex < total; stepIndex++) {
370
+ const group = groups.find((candidate) => candidate.stepIndex === stepIndex);
371
+ if (group) {
372
+ const steps = job.steps.slice(group.start, group.start + group.count);
373
+ const status = widgetAggregateStepStatus(steps);
374
+ lines.push(` ${widgetStepGlyph(status, theme)} Step ${stepIndex + 1}/${total}: ${themeBold(theme, "parallel group")} ${theme.fg("dim", "·")} ${theme.fg("dim", widgetParallelOutcome(steps, group.count))}`);
375
+ flatIndex = Math.max(flatIndex, group.start + group.count);
376
+ continue;
377
+ }
378
+ const step = job.steps[flatIndex];
379
+ if (!step) {
380
+ lines.push(` ${theme.fg("dim", `◦ Step ${stepIndex + 1}/${total}: pending`)}`);
381
+ continue;
382
+ }
383
+ lines.push(...foregroundStyleWidgetStepLines(job, theme, step, "Step", stepIndex + 1, total, expanded, width));
384
+ flatIndex++;
385
+ }
386
+ return lines;
387
+ }
388
+
340
389
  function widgetParallelAgentDetails(job: AsyncJobState, theme: Theme): string[] {
341
- if (!job.activeParallelGroup || !job.steps?.length) return [];
390
+ if (!job.steps?.length) return [];
342
391
  if (job.mode !== "parallel" && job.mode !== "chain") return [];
392
+ if (job.mode === "chain" && !job.activeParallelGroup && job.parallelGroups?.length) return widgetChainDetails(job, theme);
343
393
  const total = job.stepsTotal ?? job.steps.length;
344
394
  return job.steps.map((step, index) => {
345
395
  const marker = index === job.steps!.length - 1 ? "└" : "├";
346
396
  const activity = widgetStepActivity(step);
347
- return ` ${theme.fg("dim", `${marker} ${widgetStepGlyph(step.status, theme)} Agent ${index + 1}/${total}: ${step.agent} · ${widgetStepStatus(step.status, theme)}${activity ? ` · ${activity}` : ""}`)}`;
397
+ const itemTitle = job.mode === "parallel" || job.activeParallelGroup ? "Agent" : "Step";
398
+ return ` ${theme.fg("dim", `${marker} ${widgetStepGlyph(step.status, theme)} ${itemTitle} ${index + 1}/${total}: ${step.agent} · ${widgetStepStatus(step.status, theme)}${activity ? ` · ${activity}` : ""}`)}`;
348
399
  });
349
400
  }
350
401
 
@@ -513,7 +564,7 @@ function widgetStats(job: AsyncJobState, theme: Theme): string {
513
564
  const running = job.runningSteps ?? (job.status === "running" ? 1 : 0);
514
565
  const done = job.completedSteps ?? (job.status === "complete" ? stepsTotal : 0);
515
566
  if (job.mode === "parallel") {
516
- if (job.status === "running") parts.push(formatAgentRunningLabel(running));
567
+ if (job.status === "running" && running > 0) parts.push(formatAgentRunningLabel(running));
517
568
  if (stepsTotal > 0) parts.push(`${done}/${stepsTotal} done`);
518
569
  } else {
519
570
  const activeGroup = job.currentStep !== undefined
@@ -521,24 +572,157 @@ function widgetStats(job: AsyncJobState, theme: Theme): string {
521
572
  : job.parallelGroups?.find((group) => group.start === 0);
522
573
  const logicalStep = activeGroup?.stepIndex ?? job.currentStep ?? 0;
523
574
  const total = job.chainStepCount ?? stepsTotal;
524
- const groupProgress = job.status === "running"
525
- ? `${formatAgentRunningLabel(running)} · ${done}/${stepsTotal} done`
526
- : `${done}/${stepsTotal} done`;
527
- parts.push(`step ${logicalStep + 1}/${total} · parallel group: ${groupProgress}`);
575
+ const groupParts = [`${done}/${stepsTotal} done`];
576
+ if (job.status === "running" && running > 0) groupParts.unshift(formatAgentRunningLabel(running));
577
+ parts.push(`step ${logicalStep + 1}/${total} · parallel group: ${groupParts.join(" · ")}`);
528
578
  }
529
579
  } else if (job.currentStep !== undefined) {
530
- parts.push(`step ${job.currentStep + 1}/${stepsTotal}`);
580
+ if (job.mode === "chain" && job.parallelGroups?.length) {
581
+ const total = job.chainStepCount ?? stepsTotal;
582
+ parts.push(`step ${flatToLogicalStepIndex(job.currentStep, total, job.parallelGroups) + 1}/${total}`);
583
+ } else {
584
+ parts.push(`step ${job.currentStep + 1}/${stepsTotal}`);
585
+ }
531
586
  } else if (stepsTotal > 1) {
532
587
  parts.push(`steps ${stepsTotal}`);
533
588
  }
589
+ if (job.toolCount !== undefined) parts.push(formatToolUseStat(job.toolCount));
534
590
  if (job.totalTokens?.total) parts.push(formatTokenStat(job.totalTokens.total));
535
591
  const endTime = job.status === "complete" || job.status === "failed" || job.status === "paused" ? (job.updatedAt ?? Date.now()) : Date.now();
536
592
  if (job.startedAt) parts.push(formatDuration(Math.max(0, endTime - job.startedAt)));
537
593
  return statJoin(theme, parts);
538
594
  }
539
595
 
540
- export function buildWidgetLines(jobs: AsyncJobState[], theme: Theme, width = getTermWidth()): string[] {
596
+ function widgetStepStats(theme: Theme, step: NonNullable<AsyncJobState["steps"]>[number]): string {
597
+ return statJoin(theme, [
598
+ step.turnCount !== undefined ? `${step.turnCount} turns` : "",
599
+ step.toolCount !== undefined ? formatToolUseStat(step.toolCount) : "",
600
+ step.tokens?.total ? formatTokenStat(step.tokens.total) : "",
601
+ step.durationMs !== undefined ? formatDuration(step.durationMs) : "",
602
+ ]);
603
+ }
604
+
605
+ function widgetStepActivityLine(step: NonNullable<AsyncJobState["steps"]>[number], width: number, expanded: boolean): string {
606
+ const toolLine = formatCurrentToolLine(step, width, expanded);
607
+ if (toolLine) return toolLine;
608
+ const activity = formatActivityLabel(step.lastActivityAt, step.activityState);
609
+ if (activity) return activity;
610
+ if (step.status === "running") return "thinking…";
611
+ return "";
612
+ }
613
+
614
+ function widgetOutputPath(job: AsyncJobState, step: NonNullable<AsyncJobState["steps"]>[number]): string | undefined {
615
+ if (typeof step.index !== "number") return undefined;
616
+ return path.join(job.asyncDir, `output-${step.index}.log`);
617
+ }
618
+
619
+ function foregroundStyleWidgetStepLines(
620
+ job: AsyncJobState,
621
+ theme: Theme,
622
+ step: NonNullable<AsyncJobState["steps"]>[number],
623
+ itemTitle: "Agent" | "Step",
624
+ index: number,
625
+ total: number,
626
+ expanded: boolean,
627
+ width: number,
628
+ ): string[] {
629
+ const stats = widgetStepStats(theme, step);
630
+ const lines = [` ${widgetStepGlyph(step.status, theme)} ${itemTitle} ${index}/${total}: ${themeBold(theme, step.agent)}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`];
631
+ const activity = widgetStepActivityLine(step, width, expanded);
632
+ if (activity) lines.push(` ${theme.fg("dim", `⎿ ${activity}`)}`);
633
+ if (step.status === "running") {
634
+ if (!expanded) lines.push(` ${theme.fg("accent", "Press Ctrl+O for live detail")}`);
635
+ const output = widgetOutputPath(job, step);
636
+ if (output) lines.push(` ${theme.fg("dim", `output: ${shortenPath(output)}`)}`);
637
+ if (expanded) {
638
+ const liveStatus = buildLiveStatusLine(step);
639
+ if (liveStatus && liveStatus !== activity) lines.push(` ${theme.fg("accent", liveStatus)}`);
640
+ for (const tool of step.recentTools?.slice(-3) ?? []) {
641
+ const maxArgsLen = Math.max(40, width - 30);
642
+ const argsPreview = tool.args.length <= maxArgsLen ? tool.args : `${tool.args.slice(0, maxArgsLen)}...`;
643
+ lines.push(` ${theme.fg("dim", `${tool.tool}${argsPreview ? `: ${argsPreview}` : ""}`)}`);
644
+ }
645
+ for (const line of step.recentOutput?.slice(-5) ?? []) {
646
+ lines.push(` ${theme.fg("dim", line)}`);
647
+ }
648
+ }
649
+ }
650
+ return lines;
651
+ }
652
+
653
+ function foregroundStyleWidgetDetails(job: AsyncJobState, theme: Theme, expanded: boolean, width: number): string[] {
654
+ if (!job.steps?.length) return [` ${theme.fg("dim", `⎿ ${widgetActivity(job)}`)}`];
655
+ if (job.mode !== "parallel" && job.mode !== "chain") return [` ${theme.fg("dim", `⎿ ${widgetActivity(job)}`)}`];
656
+ if (job.mode === "chain" && !job.activeParallelGroup && job.parallelGroups?.length) return widgetChainDetails(job, theme, expanded, width);
657
+ const total = job.stepsTotal ?? job.steps.length;
658
+ const itemTitle = job.mode === "parallel" || job.activeParallelGroup ? "Agent" : "Step";
659
+ const lines: string[] = [];
660
+ for (const [index, step] of job.steps.entries()) {
661
+ lines.push(...foregroundStyleWidgetStepLines(job, theme, step, itemTitle, index + 1, total, expanded, width));
662
+ }
663
+ return lines;
664
+ }
665
+
666
+ function buildSingleWidgetLines(job: AsyncJobState, theme: Theme, width: number, expanded: boolean): string[] {
667
+ const stats = widgetStats(job, theme);
668
+ const count = job.mode === "chain" ? job.chainStepCount : job.stepsTotal ?? job.agents?.length ?? job.steps?.length;
669
+ const mode = widgetJobName(job);
670
+ const title = `async subagent ${mode}${count && count > 1 ? ` (${count})` : ""}`;
671
+ return [
672
+ `${theme.fg("toolTitle", themeBold(theme, title))} ${theme.fg("dim", "· background · /subagents-status")}`,
673
+ `${widgetStatusGlyph(job, theme)} ${themeBold(theme, mode)}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`,
674
+ ...foregroundStyleWidgetDetails(job, theme, expanded, width),
675
+ ].map((line) => truncLine(line, width));
676
+ }
677
+
678
+ function compactSingleWidgetLines(job: AsyncJobState, theme: Theme, width: number): string[] {
679
+ const fullLines = buildSingleWidgetLines(job, theme, width, false);
680
+ if (fullLines.length <= 10 || !job.steps?.length || (job.mode !== "parallel" && !job.activeParallelGroup)) return fullLines;
681
+
682
+ const total = job.stepsTotal ?? job.steps.length;
683
+ const itemTitle = job.mode === "parallel" || job.activeParallelGroup ? "Agent" : "Step";
684
+ const lines = fullLines.slice(0, 2);
685
+ for (const [index, step] of job.steps.entries()) {
686
+ const stepStats = widgetStepStats(theme, step);
687
+ const activity = widgetStepActivityLine(step, width, false);
688
+ const activitySuffix = activity ? ` ${theme.fg("dim", "·")} ${theme.fg("dim", activity)}` : "";
689
+ lines.push(` ${widgetStepGlyph(step.status, theme)} ${itemTitle} ${index + 1}/${total}: ${themeBold(theme, step.agent)}${stepStats ? ` ${theme.fg("dim", "·")} ${stepStats}` : ""}${activitySuffix}`);
690
+ }
691
+ if (job.steps.some((step) => step.status === "running")) lines.push(theme.fg("accent", " Press Ctrl+O for live detail · /subagents-status for output paths"));
692
+ return lines.map((line) => truncLine(line, width));
693
+ }
694
+
695
+ function fitWidgetLineBudget(lines: string[], theme: Theme, width: number, expanded: boolean): string[] {
696
+ const rows = process.stdout.rows || 30;
697
+ const budget = expanded
698
+ ? Math.max(12, Math.min(24, Math.floor(rows * 0.55)))
699
+ : Math.max(10, Math.min(14, Math.floor(rows * 0.35)));
700
+ if (lines.length <= budget) return lines;
701
+ const visibleLines = Math.max(1, budget - 1);
702
+ const hiddenCount = lines.length - visibleLines;
703
+ const hint = expanded
704
+ ? `… ${hiddenCount} live-detail lines hidden · /subagents-status for full detail`
705
+ : `… ${hiddenCount} lines hidden · Ctrl+O expands · /subagents-status for full detail`;
706
+ return [...lines.slice(0, visibleLines), truncLine(theme.fg("dim", hint), width)];
707
+ }
708
+
709
+ function buildWidgetComponent(jobs: AsyncJobState[], expanded: boolean): (_tui: unknown, theme: Theme) => Component {
710
+ return (_tui, theme) => {
711
+ const width = getTermWidth();
712
+ const lines = expanded
713
+ ? buildWidgetLines(jobs, theme, width, true)
714
+ : jobs.length === 1
715
+ ? compactSingleWidgetLines(jobs[0]!, theme, width)
716
+ : buildWidgetLines(jobs, theme, width, false);
717
+ const container = new Container();
718
+ for (const line of fitWidgetLineBudget(lines, theme, width, expanded)) container.addChild(new Text(line, 1, 0));
719
+ return container;
720
+ };
721
+ }
722
+
723
+ export function buildWidgetLines(jobs: AsyncJobState[], theme: Theme, width = getTermWidth(), expanded = false): string[] {
541
724
  if (jobs.length === 0) return [];
725
+ if (jobs.length === 1) return buildSingleWidgetLines(jobs[0]!, theme, width, expanded);
542
726
  const running = jobs.filter((job) => job.status === "running");
543
727
  const queued = jobs.filter((job) => job.status === "queued");
544
728
  const finished = jobs.filter((job) => job.status !== "running" && job.status !== "queued");
@@ -608,7 +792,7 @@ export function buildWidgetLines(jobs: AsyncJobState[], theme: Theme, width = ge
608
792
  function refreshAnimatedWidget(): void {
609
793
  try {
610
794
  if (!latestWidgetCtx?.hasUI || latestWidgetJobs.length === 0) return;
611
- latestWidgetCtx.ui.setWidget(WIDGET_KEY, buildWidgetLines(latestWidgetJobs, latestWidgetCtx.ui.theme));
795
+ latestWidgetCtx.ui.setWidget(WIDGET_KEY, buildWidgetComponent(latestWidgetJobs, latestWidgetCtx.ui.getToolsExpanded?.() ?? false));
612
796
  latestWidgetCtx.ui.requestRender?.();
613
797
  } catch (error) {
614
798
  if (!isStaleExtensionContextError(error)) throw error;
@@ -662,7 +846,7 @@ export function renderWidget(ctx: ExtensionContext, jobs: AsyncJobState[]): void
662
846
  latestWidgetCtx = ctx;
663
847
  latestWidgetJobs = [...jobs];
664
848
 
665
- ctx.ui.setWidget(WIDGET_KEY, buildWidgetLines(jobs, ctx.ui.theme));
849
+ ctx.ui.setWidget(WIDGET_KEY, buildWidgetComponent(jobs, ctx.ui.getToolsExpanded?.() ?? false));
666
850
  if (hasAnimatedWidgetJobs(jobs)) ensureWidgetAnimation();
667
851
  else stopWidgetAnimation();
668
852
  }
@@ -62,11 +62,20 @@ function stepGlyph(theme: Theme, status: string): string {
62
62
  return theme.fg("dim", "◦");
63
63
  }
64
64
 
65
+ function runGlyph(theme: Theme, status: AsyncRunSummary["state"]): string {
66
+ if (status === "running") return theme.fg("accent", "▶");
67
+ if (status === "queued") return theme.fg("dim", "◦");
68
+ if (status === "complete") return theme.fg("success", "✓");
69
+ if (status === "paused") return theme.fg("warning", "■");
70
+ return theme.fg("error", "✗");
71
+ }
72
+
65
73
  function runLabel(theme: Theme, run: AsyncRunSummary, selected: boolean): string {
66
74
  const prefix = selected ? theme.fg("accent", ">") : " ";
67
75
  const stepLabel = formatAsyncRunProgressLabel(run);
68
76
  const cwd = shortenPath(run.cwd ?? run.asyncDir);
69
- return `${prefix} ${run.id.slice(0, 8)} ${statusColor(theme, run.state)} | ${run.mode} | ${stepLabel} | ${cwd}`;
77
+ const mode = ((theme as { bold?: (value: string) => string }).bold?.(run.mode)) ?? run.mode;
78
+ return `${prefix} ${runGlyph(theme, run.state)} ${mode} · ${stepLabel} · ${run.id.slice(0, 8)} · ${cwd}`;
70
79
  }
71
80
 
72
81
  function selectedIndex(rows: StatusRow[], cursor: number): number {
@@ -332,7 +341,13 @@ export class SubagentsStatusComponent implements Component {
332
341
  if (run.sessionFile) {
333
342
  lines.push(row(`session: ${truncateToWidth(shortenPath(run.sessionFile), innerW - 9)}`, width, this.theme));
334
343
  }
335
- lines.push(...this.renderStepRows(run, width, innerW));
344
+ if (run.mode === "chain" && (run.chainStepCount !== undefined || run.parallelGroups?.length)) {
345
+ lines.push(...this.renderChainProgressRows(run, width, innerW));
346
+ } else if (run.mode === "parallel") {
347
+ lines.push(...this.renderAgentRows(run, width, innerW));
348
+ } else {
349
+ lines.push(...this.renderStepRows(run, width, innerW));
350
+ }
336
351
  return lines;
337
352
  }
338
353
 
@@ -438,7 +453,7 @@ export class SubagentsStatusComponent implements Component {
438
453
  : undefined;
439
454
 
440
455
  const body: string[] = [];
441
- body.push(...detailRows(`${run.id} | ${statusColor(this.theme, run.state)} | ${run.mode} | ${stepLabel} | ${duration}`, width, innerW, this.theme));
456
+ body.push(...detailRows(`${runGlyph(this.theme, run.state)} ${run.mode} · ${stepLabel} · ${statusColor(this.theme, run.state)} · ${duration}`, width, innerW, this.theme));
442
457
  if (activity) body.push(...detailRows(activity, width, innerW, this.theme));
443
458
  body.push(row("", width, this.theme));
444
459
  if (run.mode === "chain" && (run.chainStepCount !== undefined || run.parallelGroups?.length)) {