pi-subagents 0.22.0 → 0.23.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,35 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.23.1] - 2026-05-02
6
+
7
+ ### Added
8
+ - Persist async per-child session metadata and remember recent foreground child session metadata so `resume` can revive multi-child async runs and foreground children by index.
9
+
10
+ ### Fixed
11
+ - Keep foreground children alive when they call `contact_supervisor` for a blocking decision by treating it as intercom coordination during parent detach, matching the generic `intercom` handoff path.
12
+ - Pause foreground parallel and chain flows when a child detaches for intercom coordination instead of counting the child as a successful completed result and continuing the workflow, and suppress grouped completion receipts for detached chains.
13
+ - Tighten resume/revive safety by rejecting pending async children, detached foreground children that may still be live, ambiguous foreground/async id prefixes, and exact invalid resume matches that would otherwise be masked by a prefix match in the other namespace.
14
+ - Preserve child session metadata in stale-run repaired results and avoid advertising revive from top-level-only or missing child session files.
15
+ - Stop builtin `reviewer` runs from writing progress by default, clarify that review-only/no-edit instructions win over progress-writing or artifact-writing instructions, and suppress automatic progress injection for explicit no-edit tasks even when chain templates use `{task}`.
16
+ - Treat parsed provider errors as failed foreground and async subagent attempts even when the child process exits successfully, and baseline saved output files per fallback attempt.
17
+ - Preserve output-file read and inspect errors instead of silently overwriting or falling back when a changed saved-output path cannot be read.
18
+ - Show each active async widget row's lifecycle status (`running`, `complete`, `failed`, or `paused`) alongside activity and usage stats.
19
+ - Start new direct, slash, prompt-template, foreground, and async subagent launches in compact view while keeping `Ctrl+O` available for live detail.
20
+ - Label top-level async parallel completion notifications as parallel runs instead of leaking the internal chain-shaped runner plan.
21
+
22
+ ## [0.23.0] - 2026-05-02
23
+
24
+ ### Fixed
25
+ - Detect `pi-intercom` when installed through the documented `pi install npm:pi-intercom` package flow, instead of only checking the legacy local extension path.
26
+
27
+ ### Changed
28
+ - 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`.
29
+ - Retry foreground subagent fallback models when Pi reports a retryable provider error, such as 429/quota, even if the child process exits successfully.
30
+ - 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.
31
+ - Render async subagent widgets through an adaptive component so active parallel agent rows fit without Pi's fixed string-widget truncation marker.
32
+ - 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.
33
+
5
34
  ## [0.22.0] - 2026-05-02
6
35
 
7
36
  ### 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
 
@@ -225,7 +225,7 @@ Ask oracle to review this plan. If it sees a decision I need to make, have it as
225
225
 
226
226
  The child can use one dedicated coordination tool:
227
227
 
228
- - `contact_supervisor`: the child contacts the parent/supervisor session that delegated the task. Use `reason: "need_decision"` for blocking decisions or clarification, and `reason: "progress_update"` for short non-blocking updates when a discovery changes the plan.
228
+ - `contact_supervisor`: the child contacts the parent/supervisor session that delegated the task. Use `reason: "need_decision"` for blocking decisions or clarification, and `reason: "progress_update"` for short non-blocking updates when a discovery changes the plan. Do not ask for clarification when the only conflict is review-only/no-edit versus progress-writing or artifact-writing instructions; no-edit wins.
229
229
 
230
230
  Child-side routine completion handoffs are still not expected. With the intercom bridge active, parent-side `pi-subagents` sends grouped completion results through `pi-intercom`: one grouped message per foreground parent `subagent` run and one per completed async result file. Acknowledged foreground delivery returns a compact receipt with artifact/session paths; if unacknowledged, the normal full output is preserved. Grouped messages include child intercom targets and full child summaries.
231
231
 
@@ -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
 
@@ -778,11 +780,12 @@ Status and control actions:
778
780
  subagent({ action: "status" })
779
781
  subagent({ action: "status", id: "<run-id>" })
780
782
  subagent({ action: "interrupt", id: "<run-id>" })
781
- subagent({ action: "resume", id: "<async-run-id>", message: "follow-up question" })
783
+ subagent({ action: "resume", id: "<run-id>", message: "follow-up question" })
784
+ subagent({ action: "resume", id: "<run-id>", index: 1, message: "follow-up for child 2" })
782
785
  subagent({ action: "doctor" })
783
786
  ```
784
787
 
785
- `resume` sends the follow-up directly when the async child is still reachable over intercom. After completion, it starts a new async child from the stored single-child session file.
788
+ `resume` sends the follow-up directly when an async child is still reachable over intercom. After completion, it revives the child by starting a new async child from the stored child session file. Multi-child async runs and remembered foreground single, parallel, or chain runs can be revived by passing `index` to choose the child. Revive starts a new child process from the old session context; it does not restart the same OS process, and it requires the chosen child to have a persisted `.jsonl` session file.
786
789
 
787
790
  ## Worktree isolation
788
791
 
@@ -893,7 +896,7 @@ Fields:
893
896
  - `mode`: default `always`; use `fork-only` to inject only for forked runs, or `off` to disable the bridge.
894
897
  - `instructionFile`: optional Markdown template replacing the default bridge instructions. `{orchestratorTarget}` is interpolated. Relative paths resolve from `~/.pi/agent/extensions/subagent/`.
895
898
 
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.
899
+ 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
900
 
898
901
  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
902
 
@@ -7,7 +7,6 @@ systemPromptMode: replace
7
7
  inheritProjectContext: true
8
8
  inheritSkills: false
9
9
  defaultReads: plan.md, progress.md
10
- defaultProgress: true
11
10
  ---
12
11
 
13
12
  You are a disciplined review subagent. Your job is to inspect, evaluate, and report findings with evidence. You do not guess; you verify from the code, tests, docs, or requirements.
@@ -59,9 +58,10 @@ Review a PR or issue by understanding the context, then verifying:
59
58
  - Prefer small corrective edits over broad rewrites.
60
59
  - If everything looks good, say so plainly.
61
60
  - If you are asked to maintain progress, record what you checked and what you found.
61
+ - If review-only or no-edit instructions conflict with progress-writing instructions, review-only/no-edit wins. Do not write `progress.md`; mention the conflict in your final review only if it matters.
62
62
 
63
63
  ## Supervisor coordination
64
- If runtime bridge instructions identify a safe supervisor target and you are blocked or need a decision, use `contact_supervisor` with `reason: "need_decision"` and wait for the reply. Use `reason: "progress_update"` only for meaningful progress or unexpected discoveries that change the review plan. Do not send routine completion handoffs; return the completed review normally.
64
+ If runtime bridge instructions identify a safe supervisor target and you are blocked or need a decision, use `contact_supervisor` with `reason: "need_decision"` and wait for the reply. Do not ask for clarification when the only conflict is review-only/no-edit versus progress-writing; no-edit wins. Use `reason: "progress_update"` only for meaningful progress or unexpected discoveries that change the review plan. Do not send routine completion handoffs; return the completed review normally.
65
65
 
66
66
  Fall back to generic `intercom` only if `contact_supervisor` is unavailable and the runtime bridge instructions identify a safe target. If no safe target is discoverable, do not guess.
67
67
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-subagents",
3
- "version": "0.22.0",
3
+ "version": "0.23.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",
@@ -105,7 +105,7 @@ Use this at the start of non-trivial work. Launch `scout` for local context and
105
105
 
106
106
  ### Parallel cleanup technique
107
107
 
108
- Use this after implementation when the user wants cleanup review or when a final pass would reduce AI-slop. Launch two fresh-context `reviewer` tasks with `output: false`: one deslop pass and one verbosity pass. If the `deslop` or `verbosity-cleaner` skills are available, pass the relevant skill to that reviewer; otherwise inline the criteria. Both reviewers are review-only and should flag concrete issues with severity, file/line references, and smallest safe fixes. The parent decides what to apply and asks before making changes unless cleanup was already authorized.
108
+ Use this after implementation when the user wants cleanup review or when a final pass would reduce AI-slop. Launch two fresh-context `reviewer` tasks with `output: false` and `progress: false`: one deslop pass and one verbosity pass. If the `deslop` or `verbosity-cleaner` skills are available, pass the relevant skill to that reviewer; otherwise inline the criteria. Both reviewers are review-only and should flag concrete issues with severity, file/line references, and smallest safe fixes. Review-only/no-edit beats progress-writing or artifact-writing instructions. The parent decides what to apply and asks before making changes unless cleanup was already authorized.
109
109
 
110
110
  ## Builtin Agents
111
111
 
@@ -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({
@@ -289,6 +290,21 @@ const run = subagent({
289
290
 
290
291
  Inspect async runs with `subagent({ action: "status", id: "..." })`, `subagent({ action: "status" })` for active runs, or the `/subagents-status` slash command.
291
292
 
293
+ Use `resume` for follow-up work after a delegated run:
294
+
295
+ ```typescript
296
+ subagent({ action: "resume", id: "run-id", message: "Follow up on this point." })
297
+ subagent({ action: "resume", id: "run-id", index: 1, message: "Continue reviewer 2." })
298
+ ```
299
+
300
+ Resume behavior:
301
+ - If an async child is still running and reachable, `resume` sends the follow-up to that live child over intercom.
302
+ - If an async child has completed, `resume` revives it by starting a new async child from the persisted child session file.
303
+ - Multi-child async runs require `index` unless only one running child is selectable.
304
+ - Completed foreground single, parallel, and chain runs can also be revived by `index` while their run metadata remains in extension state.
305
+ - Revive starts a new child process from the old session context; it does not restart the same OS process.
306
+ - If the chosen child has no persisted `.jsonl` session file, resume fails and reports that directly.
307
+
292
308
  Use diagnostics when setup or child startup looks wrong:
293
309
 
294
310
  ```typescript
@@ -408,6 +424,8 @@ Use `contact_supervisor` with `reason: "need_decision"` when:
408
424
  - a child needs clarification instead of guessing
409
425
  - an approval, product, API, or scope choice is required before continuing safely
410
426
 
427
+ Do not use `contact_supervisor` just to resolve review-only/no-edit versus progress-writing or artifact-writing instructions. No-edit wins, and the child should return review findings without touching files.
428
+
411
429
  Use `contact_supervisor` with `reason: "progress_update"` when:
412
430
  - a child is explicitly asked for progress
413
431
  - a meaningful discovery changes the plan
@@ -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");
@@ -24,7 +24,7 @@ import { resolveCurrentSessionId } from "../shared/session-identity.ts";
24
24
  import { cleanupOldChainDirs } from "../shared/settings.ts";
25
25
  import { renderWidget, renderSubagentResult, stopResultAnimations, stopWidgetAnimation, syncResultAnimation } from "../tui/render.ts";
26
26
  import { SubagentParams } from "./schemas.ts";
27
- import { createSubagentExecutor } from "../runs/foreground/subagent-executor.ts";
27
+ import { createSubagentExecutor, type SubagentParamsLike } from "../runs/foreground/subagent-executor.ts";
28
28
  import { createAsyncJobTracker } from "../runs/background/async-job-tracker.ts";
29
29
  import { createResultWatcher } from "../runs/background/result-watcher.ts";
30
30
  import { registerSlashCommands } from "../slash/slash-commands.ts";
@@ -245,6 +245,7 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
245
245
  baseCwd: process.cwd(),
246
246
  currentSessionId: null,
247
247
  asyncJobs: new Map(),
248
+ foregroundRuns: new Map(),
248
249
  foregroundControls: new Map(),
249
250
  lastForegroundControlId: null,
250
251
  pendingForegroundControlNotices: new Map(),
@@ -336,11 +337,16 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
336
337
  return new SubagentControlNoticeComponent({ ...details, noticeText: formatSubagentControlNotice(details, content) }, theme);
337
338
  });
338
339
 
340
+ const executeSubagentCollapsed = (id: string, params: SubagentParamsLike, signal: AbortSignal, onUpdate: ((result: AgentToolResult<Details>) => void) | undefined, ctx: ExtensionContext) => {
341
+ if (ctx.hasUI) ctx.ui.setToolsExpanded(false);
342
+ return executor.execute(id, params, signal, onUpdate, ctx);
343
+ };
344
+
339
345
  const slashBridge = registerSlashSubagentBridge({
340
346
  events: pi.events,
341
347
  getContext: () => state.lastUiContext,
342
348
  execute: (id, params, signal, onUpdate, ctx) =>
343
- executor.execute(id, params, signal, onUpdate, ctx),
349
+ executeSubagentCollapsed(id, params, signal, onUpdate, ctx),
344
350
  });
345
351
 
346
352
  const promptTemplateBridge = registerPromptTemplateDelegationBridge({
@@ -348,7 +354,7 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
348
354
  getContext: () => state.lastUiContext,
349
355
  execute: async (requestId, request, signal, ctx, onUpdate) => {
350
356
  if (request.tasks && request.tasks.length > 0) {
351
- return executor.execute(
357
+ return executeSubagentCollapsed(
352
358
  requestId,
353
359
  {
354
360
  tasks: request.tasks,
@@ -363,7 +369,7 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
363
369
  ctx,
364
370
  );
365
371
  }
366
- return executor.execute(
372
+ return executeSubagentCollapsed(
367
373
  requestId,
368
374
  {
369
375
  agent: request.agent,
@@ -419,14 +425,14 @@ MANAGEMENT (use action field, omit agent/task/chain/tasks):
419
425
  CONTROL:
420
426
  • { action: "status", id: "..." } - inspect an async/background run by id or prefix
421
427
  • { action: "interrupt", id?: "..." } - soft-interrupt the current child turn and leave the run paused
422
- • { action: "resume", id: "...", message: "..." } - follow up with a live async child or revive a completed single-child async run from its session
428
+ • { action: "resume", id: "...", message: "...", index?: 0 } - follow up with a live async child or revive a completed async/foreground child from its session
423
429
 
424
430
  DIAGNOSTICS:
425
431
  • { action: "doctor" } - read-only report for runtime paths, discovery, sessions, and intercom`,
426
432
  parameters: SubagentParams,
427
433
 
428
434
  execute(id, params, signal, onUpdate, ctx) {
429
- return executor.execute(id, params, signal, onUpdate, ctx);
435
+ return executeSubagentCollapsed(id, params, signal, onUpdate, ctx);
430
436
  },
431
437
 
432
438
  renderCall(args, theme) {
@@ -116,7 +116,7 @@ export const SubagentParams = Type.Object({
116
116
  description: "Async run directory for action='status' or action='resume'."
117
117
  })),
118
118
  index: Type.Optional(Type.Integer({ minimum: 0, description: "Zero-based child index for actions that target a specific child." })),
119
- message: Type.Optional(Type.String({ description: "Follow-up message for action='resume'." })),
119
+ message: Type.Optional(Type.String({ description: "Follow-up message for action='resume'. Use index to choose a child from multi-child runs." })),
120
120
  // Chain identifier for management (can't reuse 'chain' — that's the execution array)
121
121
  chainName: Type.Optional(Type.String({
122
122
  description: "Chain name for get/update/delete management actions"
@@ -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";
@@ -23,6 +31,7 @@ const DEFAULT_INTERCOM_BRIDGE_TEMPLATE = `The inherited thread is reference-only
23
31
  Use contact_supervisor first. It resolves the supervisor session "{orchestratorTarget}" and run metadata automatically.
24
32
  - Need a decision, blocked, approval, or product/API/scope ambiguity: contact_supervisor({ reason: "need_decision", message: "<question>" })
25
33
  - After contact_supervisor with reason "need_decision", stay alive and continue only after the reply arrives. Do not finish your final response with a choose-one question.
34
+ - Do not ask for clarification when the only conflict is review-only/no-edit versus progress-writing or artifact-writing instructions. Review-only/no-edit wins; leave files unchanged and mention the conflict in your final result only if it matters.
26
35
  - Meaningful progress or unexpected discoveries that change the plan: contact_supervisor({ reason: "progress_update", message: "UPDATE: <summary>" })
27
36
  - Generic intercom is lower-level plumbing/fallback only: intercom({ action: "ask", to: "{orchestratorTarget}", message: "<question>" })
28
37
 
@@ -56,6 +65,9 @@ interface ResolveIntercomBridgeInput {
56
65
  extensionDir?: string;
57
66
  configPath?: string;
58
67
  settingsDir?: string;
68
+ cwd?: string;
69
+ agentDir?: string;
70
+ globalNpmRoot?: string | null;
59
71
  }
60
72
 
61
73
  export function resolveIntercomSessionTarget(sessionName: string | undefined, sessionId: string): string {
@@ -102,6 +114,121 @@ function intercomConfigStatus(configPath: string): { enabled: boolean; error?: u
102
114
  }
103
115
  }
104
116
 
117
+ function readJsonBestEffort(filePath: string): unknown {
118
+ try {
119
+ return JSON.parse(fs.readFileSync(filePath, "utf-8"));
120
+ } catch (error) {
121
+ const code = error && typeof error === "object" && "code" in error ? (error as NodeJS.ErrnoException).code : undefined;
122
+ if (code !== "ENOENT") console.warn(`Failed to read JSON from '${filePath}'.`, error);
123
+ return null;
124
+ }
125
+ }
126
+
127
+ function packageHasPiExtension(packageRoot: string): boolean {
128
+ if (!fs.existsSync(packageRoot)) return false;
129
+ const pkg = readJsonBestEffort(path.join(packageRoot, "package.json"));
130
+ if (pkg && typeof pkg === "object" && !Array.isArray(pkg)) {
131
+ const pi = (pkg as { pi?: unknown }).pi;
132
+ if (pi && typeof pi === "object" && !Array.isArray(pi)) {
133
+ const extensions = (pi as { extensions?: unknown }).extensions;
134
+ return Array.isArray(extensions) && extensions.some((entry) => typeof entry === "string" && entry.trim() !== "");
135
+ }
136
+ }
137
+ return fs.existsSync(path.join(packageRoot, "extensions"));
138
+ }
139
+
140
+ function isSafePackagePath(value: string): boolean {
141
+ return value.length > 0
142
+ && !path.isAbsolute(value)
143
+ && value.split(/[\\/]/).every((part) => part.length > 0 && part !== "." && part !== "..");
144
+ }
145
+
146
+ function parseNpmPackageName(source: string): string | undefined {
147
+ const spec = source.slice(4).trim();
148
+ if (!spec) return undefined;
149
+ const match = spec.match(/^(@?[^@]+(?:\/[^@]+)?)(?:@(.+))?$/);
150
+ const packageName = match?.[1] ?? spec;
151
+ return isSafePackagePath(packageName) ? packageName : undefined;
152
+ }
153
+
154
+ function packageEntrySource(entry: unknown): string | undefined {
155
+ if (typeof entry === "string") return entry;
156
+ if (entry && typeof entry === "object" && !Array.isArray(entry) && typeof (entry as { source?: unknown }).source === "string") {
157
+ return (entry as { source: string }).source;
158
+ }
159
+ return undefined;
160
+ }
161
+
162
+ function packageEntryAllowsExtensions(entry: unknown): boolean {
163
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return true;
164
+ const extensions = (entry as { extensions?: unknown }).extensions;
165
+ return !Array.isArray(extensions) || extensions.length > 0;
166
+ }
167
+
168
+ function findNearestProjectConfigDir(cwd: string): string | undefined {
169
+ let current = path.resolve(cwd);
170
+ while (true) {
171
+ const configDir = path.join(current, CONFIG_DIR);
172
+ if (fs.existsSync(path.join(configDir, "settings.json"))) return configDir;
173
+ const parent = path.dirname(current);
174
+ if (parent === current) return undefined;
175
+ current = parent;
176
+ }
177
+ }
178
+
179
+ let cachedGlobalNpmRoot: string | null | undefined;
180
+
181
+ function getGlobalNpmRoot(): string | null {
182
+ if (cachedGlobalNpmRoot !== undefined) return cachedGlobalNpmRoot;
183
+ try {
184
+ cachedGlobalNpmRoot = execSync("npm root -g", { encoding: "utf-8", timeout: 5000 }).trim();
185
+ return cachedGlobalNpmRoot;
186
+ } catch {
187
+ cachedGlobalNpmRoot = null;
188
+ return null;
189
+ }
190
+ }
191
+
192
+ function configuredPiIntercomPackageDir(input: ResolveIntercomBridgeInput, agentDir: string): string | undefined {
193
+ const cwd = path.resolve(input.cwd ?? process.cwd());
194
+ const projectConfigDir = findNearestProjectConfigDir(cwd);
195
+ const settingsFiles = [
196
+ ...(projectConfigDir ? [{ file: path.join(projectConfigDir, "settings.json"), configDir: projectConfigDir, scope: "project" as const }] : []),
197
+ { file: path.join(agentDir, "settings.json"), configDir: agentDir, scope: "user" as const },
198
+ ];
199
+ const globalNpmRoot = input.globalNpmRoot === undefined ? getGlobalNpmRoot() : input.globalNpmRoot;
200
+
201
+ for (const { file, configDir, scope } of settingsFiles) {
202
+ const settings = readJsonBestEffort(file);
203
+ if (!settings || typeof settings !== "object" || Array.isArray(settings)) continue;
204
+ const packages = (settings as { packages?: unknown }).packages;
205
+ if (!Array.isArray(packages)) continue;
206
+
207
+ for (const entry of packages) {
208
+ if (!packageEntryAllowsExtensions(entry)) continue;
209
+ const source = packageEntrySource(entry)?.trim();
210
+ if (!source?.startsWith("npm:")) continue;
211
+ const packageName = parseNpmPackageName(source);
212
+ if (packageName !== PI_INTERCOM_PACKAGE_NAME) continue;
213
+ const candidates = scope === "project"
214
+ ? [path.join(configDir, "npm", "node_modules", packageName)]
215
+ : [
216
+ ...(globalNpmRoot ? [path.join(globalNpmRoot, packageName)] : []),
217
+ path.join(agentDir, "npm", "node_modules", packageName),
218
+ ];
219
+ const packageRoot = candidates.find(packageHasPiExtension);
220
+ if (packageRoot) return path.resolve(packageRoot);
221
+ }
222
+ }
223
+ return undefined;
224
+ }
225
+
226
+ function resolveIntercomExtensionDir(input: ResolveIntercomBridgeInput, agentDir: string): string {
227
+ const legacyDir = path.resolve(input.extensionDir ?? defaultIntercomExtensionDir(agentDir));
228
+ if (fs.existsSync(legacyDir)) return legacyDir;
229
+ return configuredPiIntercomPackageDir(input, agentDir) ?? legacyDir;
230
+ }
231
+
105
232
  function extensionSandboxAllowsIntercom(extensions: string[] | undefined, extensionDir: string): boolean {
106
233
  if (extensions === undefined) return true;
107
234
 
@@ -145,9 +272,10 @@ ${instruction}`;
145
272
  export function diagnoseIntercomBridge(input: ResolveIntercomBridgeInput): IntercomBridgeDiagnostic {
146
273
  const config = resolveIntercomBridgeConfig(input.config);
147
274
  const mode = config.mode;
148
- const extensionDir = path.resolve(input.extensionDir ?? defaultIntercomExtensionDir());
275
+ const agentDir = path.resolve(input.agentDir ?? defaultAgentDir());
276
+ const extensionDir = resolveIntercomExtensionDir(input, agentDir);
149
277
  const orchestratorTarget = input.orchestratorTarget?.trim();
150
- const configPath = path.resolve(input.configPath ?? defaultIntercomConfigPath());
278
+ const configPath = path.resolve(input.configPath ?? defaultIntercomConfigPath(agentDir));
151
279
  const wantsIntercom = mode !== "off" && !(mode === "fork-only" && input.context !== "fork");
152
280
  const piIntercomAvailable = fs.existsSync(extensionDir);
153
281
  let configStatus: ReturnType<typeof intercomConfigStatus> | undefined;
@@ -183,9 +311,10 @@ export function diagnoseIntercomBridge(input: ResolveIntercomBridgeInput): Inter
183
311
  export function resolveIntercomBridge(input: ResolveIntercomBridgeInput): IntercomBridgeState {
184
312
  const config = resolveIntercomBridgeConfig(input.config);
185
313
  const mode = config.mode;
186
- const extensionDir = path.resolve(input.extensionDir ?? defaultIntercomExtensionDir());
314
+ const agentDir = path.resolve(input.agentDir ?? defaultAgentDir());
315
+ const extensionDir = resolveIntercomExtensionDir(input, agentDir);
187
316
  const orchestratorTarget = input.orchestratorTarget?.trim();
188
- const settingsDir = path.resolve(input.settingsDir ?? defaultSubagentConfigDir());
317
+ const settingsDir = path.resolve(input.settingsDir ?? defaultSubagentConfigDir(agentDir));
189
318
  const defaultInstruction = buildIntercomBridgeInstruction(
190
319
  orchestratorTarget || "{orchestratorTarget}",
191
320
  DEFAULT_INTERCOM_BRIDGE_TEMPLATE,
@@ -204,7 +333,7 @@ export function resolveIntercomBridge(input: ResolveIntercomBridgeInput): Interc
204
333
  return { active: false, mode, extensionDir, instruction: defaultInstruction };
205
334
  }
206
335
 
207
- const configPath = path.resolve(input.configPath ?? defaultIntercomConfigPath());
336
+ const configPath = path.resolve(input.configPath ?? defaultIntercomConfigPath(agentDir));
208
337
  const intercomStatus = intercomConfigStatus(configPath);
209
338
  if (intercomStatus.error) console.warn(`Failed to parse intercom config at '${configPath}'. Assuming enabled.`, intercomStatus.error);
210
339
  if (!intercomStatus.enabled) {
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import * as fs from "node:fs";
2
3
  import {
3
4
  type Details,
4
5
  type IntercomEventBus,
@@ -76,11 +77,15 @@ function asyncResumeGuidance(input: {
76
77
  asyncId?: string;
77
78
  }): string | undefined {
78
79
  if (input.source !== "async" || !input.asyncId) return undefined;
79
- if (input.children.length === 1 && typeof input.children[0]?.sessionPath === "string") {
80
+ const resumable = input.children.filter((child) => typeof child.sessionPath === "string" && fs.existsSync(child.sessionPath));
81
+ if (input.children.length === 1 && resumable.length === 1) {
80
82
  return `Revive: subagent({ action: "resume", id: "${input.asyncId}", message: "..." })`;
81
83
  }
82
- if (input.children.length > 1) return "Resume: unsupported for multi-child async runs until per-child session files are persisted.";
83
- return "Resume: unavailable; no single child session file was persisted.";
84
+ if (resumable.length > 0) {
85
+ const firstIndex = resumable[0]?.index ?? input.children.indexOf(resumable[0]!);
86
+ return `Revive child: subagent({ action: "resume", id: "${input.asyncId}", index: ${firstIndex}, message: "..." })`;
87
+ }
88
+ return "Resume: unavailable; no child session file was persisted.";
84
89
  }
85
90
 
86
91
  function formatSubagentResultIntercomMessage(input: {