@esso0428/pi-subagents 0.17.31 → 0.17.32

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
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.17.32] - 2026-10-04
11
+
12
+ ### Added
13
+ - **Nested subagents with a navigable agents roster**: an agent whose frontmatter sets `allowed_subagents` gets an ownership-scoped `Agent`, an always-blocking `wait_for_nested_agent`, and a scoped `steer_subagent`. The allowlist is a hard boundary — unknown, disabled, and out-of-list types are rejected rather than fallen back to — and `maxSubagentDepth` fails closed, so an agent at the cap receives no nested tools at all. Result, resume, and steer are ownership-checked. Nested children consume no concurrency slot, because their parent already holds one. Unlike upstream, which hides nested children from every surface, the agents roster renders them as an indented subtree: `↑`/`↓` move across all levels and `Enter` opens any level's conversation viewer.
14
+
15
+ ### Fixed
16
+ - **Nested tools are no longer dropped from the active tool set**: the scoped nested `Agent` and `steer_subagent` deliberately share names with `EXCLUDED_TOOL_NAMES`, so the scope gate deleted them and `renarrow()` then dropped them from the active session while `beforeToolCall` rejected them. They are now re-admitted after the gate.
17
+ - **A nested child's durable transcript holds its whole conversation**: streaming never received the transcript path, and the child id was captured after `onSessionCreated` had already fired, so nothing was wired on the detached path. The conversation now persists, and attaching is idempotent so the opening entry is not rewritten over it.
18
+ - **A nested child survives cleanup, and the roster keeps its subtree after a reload**: the child had no `transcriptPath`, so `cleanup()` removed it outright; and checkpoints omitted `parentAgentId`/`depth`, so a reload rebuilt every agent as a root and flattened the tree. Both are now recorded, and an orphaned child keeps its indent and says its parent is gone.
19
+ - **Nested dispatch no longer hard-rejects types the caller can already spawn top-level**: the nested registry is built from config files alone, so types registered at runtime were missing from it. Config-derived entries still win; the global registry only fills genuine gaps.
20
+
10
21
  ## [0.17.31] - 2026-10-04
11
22
 
12
23
  ### Changed
package/ROADMAP.md CHANGED
@@ -1,87 +1,43 @@
1
- # v0.17.17 回退、v0.17.18 registry recovery 與 v0.17.19+ 恢復路線圖
1
+ # pi-subagents 路線圖
2
2
 
3
- 本文件是 v0.17.17 的回退邊界與後續恢復決策,不是 runtime 實作計畫,也不承諾任何日曆日期。v0.17.18 只因 npm 保留但未公開 v0.17.17 而重發相同 rollback runtime;v0.17.17 仍是 Git/文件里程碑,第一個未來功能恢復版本從 v0.17.19 開始。主要規格索引是 [`docs/post-0.17.6-feature-specs.md`](docs/post-0.17.6-feature-specs.md);各階段都必須以該索引及其保存的原始 implementation、測試與 commit 證據為準。
3
+ 只記錄**尚未完成**的工作。完成的項目直接移除,不保留完成記錄;功能的完整歷史以 [`CHANGELOG.md`](CHANGELOG.md) 為準。最終狀態是空檔案。
4
4
 
5
- ## 目標與不可變邊界
5
+ 上游 fork 為 `upstream` remote(`git show upstream/master:<path>`)。後續只做 feature port,不做 large upstream merge。
6
6
 
7
- - [x] 將 v0.17.17 定義為 intentional rollback:runtime/UI reset to v0.17.6 的 [`64bbbb0`](https://github.com/ESSO0428/pi-subagents/commit/64bbbb0),而不是把後續功能當成已恢復功能。
8
- - [x] 保留 v0.17.6 的 ConversationViewer interaction baseline:scrollbar rail、`[preview]`/`[Esc]`、`w` focused-tool preview、in-place read-only Tool Output preview、keyboard/wheel scroll、focus 與 layout 行為。
9
- - [x] 在回退 runtime/UI 的同時保留 resource-policy metadata、資源規則與文件;特別是目前的 resource-safe local verification 政策(Vitest、E2E、build 需要明確授權,以及既有 worker/resource 限制)不得因功能恢復而被撤回。
10
- - [x] 保存 post-v0.17.6 規格與證據索引;文件保存不代表 v0.17.17 已重新提供該功能。
11
- - [ ] 後續任何恢復都必須先通過單一功能的證據與人工 UI latency acceptance,才可考慮與其他已驗證功能組合。
7
+ ## 仍成立的恢復規則
12
8
 
13
- ## v0.17.17 回退內容
9
+ - 一次只做一個功能邊界;功能與 UI 同時交付,不留看不見的中間狀態
10
+ - 每項都要有對應的 archived spec 與上游 implementation/test/commit 證據
11
+ - UI 功能必須有人工操作紀錄(terminal 尺寸、操作步驟、畫面結果、可見延遲)。不要把「測試通過」單獨當成 latency acceptance
12
+ - v0.17.6 的 ConversationViewer baseline(scrollbar rail、`[preview]`/`[Esc]`、`w` preview、in-place read-only Tool Output、鍵盤與 wheel scroll)不可被改寫
13
+ - 資源安全政策不因功能恢復而撤回:Vitest / E2E / build 在此裝置需明確授權
14
14
 
15
- - [x] runtime 與 UI 以 `64bbbb0`(v0.17.6)為基準;不得用 upstream viewer、FleetView 或 overlay 覆蓋既有 v0.17.6 viewer contract。
16
- - [x] 保留資源政策的文件與 metadata,不把 resource-policy rollback 混入功能 rollback;恢復功能也不得移除 `AGENTS.md`、CHANGELOG、package/Vitest 設定中對資源安全的約束。
17
- - [x] 本次交付只保存 Markdown 路線圖與既有規格索引;不修改 runtime source、package metadata,不 publish,也不在本路線圖階段執行 Vitest、E2E 或 build。
18
- - [ ] 將所有 post-v0.17.6 功能視為「待證據恢復」,而非在 v0.17.17 中隱式保留或半恢復。
15
+ ## 交付 B:Workflow
19
16
 
20
- ## v0.17.17 暫時移除的功能與保存規格
17
+ 上游規模約 6,200 行:`src/workflow/**` 11 檔約 4,458 行(`runtime.ts` 1,219 + `worker-source.ts` 781 為骨幹)、`ui/workflow-*.ts` 3 檔約 1,778 行(`workflow-dialog.ts` 1,115 最大)。含 `node:vm` sandbox、worker thread、journal,是新的執行模型而非 UI,風險等級與 nested 不同,不與其他交付合併。
21
18
 
22
- 以下功能在 v0.17.17 runtime/UI 回到 v0.17.6 時暫時不提供;每項都保留可追溯的 archived spec。這些勾選項是恢復清單,不表示本次要在 runtime 中實作。
19
+ - API:`agent()` / `parallel()` / `pipeline()` / `phase()` / `log()` / `args()`
20
+ - `pipeline()` stage 之間無 barrier;`parallel()` 是 barrier
21
+ - `agent({ gate: "npm test" })` 以執行指令驗證 child;`agent({ resume: "<label>" })` 接續 child
22
+ - live card + `/agents → Workflows` 兩欄 inspector
23
+ - workflow 自己擁有的 agents 不列於任何 top-level surface,由 run 負責回報
24
+ - 若 `docs/workflows.md`(現有 30 KB)在交付時仍描述尚未實作的行為,需一併收斂
23
25
 
24
- - [ ] **Durable agent history、transcript 與重新開啟**:`.pi-subagents` JSONL、partial turns、GC 後的 read-only history 與安全 locator。保存於 [`docs/post-0.17.6-durable-history-spec.md`](docs/post-0.17.6-durable-history-spec.md)。
25
- - [ ] **Workflow、lifecycle events 與 cross-extension RPC**:ready/created/completed 等 lifecycle、RPC ownership/consume、deterministic Workflow host 與 journal。保存於 [`docs/post-0.17.6-workflow-rpc-lifecycle-spec.md`](docs/post-0.17.6-workflow-rpc-lifecycle-spec.md)。
26
- - [ ] **Nested agents**:child-safe tool context、parent ownership、depth cap、allowlist、foreground/background 與 recursive cleanup。保存於 [`docs/post-0.17.6-nested-agents-spec.md`](docs/post-0.17.6-nested-agents-spec.md)。
27
- - [ ] **Recovery、stop/abort 與 shutdown**:checkpoint、partial recovery、terminal record、abort/queue 喚醒、`session_shutdown` 順序與 bounded cleanup。保存於 [`docs/post-0.17.6-recovery-shutdown-spec.md`](docs/post-0.17.6-recovery-shutdown-spec.md)。
28
- - [ ] **Agents roster、history navigation 與 ConversationViewer 整合**:single AgentWidget roster、selector input ownership、history row、viewer reopen 與 nested row 語意。保存於 [`docs/post-0.17.6-agents-roster-viewer-spec.md`](docs/post-0.17.6-agents-roster-viewer-spec.md)。
29
- - [ ] **Render-cost 與 UI latency 修復**:ConversationTimeline bounded cache、render path 不讀檔、AgentWidget cache、單一 roster、短 terminal 的 editor budget 與 redraw 限制。保存於 [`docs/post-0.17.6-ui-latency-baseline-spec.md`](docs/post-0.17.6-ui-latency-baseline-spec.md)。
30
- - [x] **v0.17.6 baseline 本身不是待移除功能**:其 viewer rail/preview/read-only 行為是所有後續恢復的不可回退基線;完整索引仍見 [`docs/post-0.17.6-feature-specs.md`](docs/post-0.17.6-feature-specs.md)。
26
+ ### 引導機制(不可省略)
31
27
 
32
- ## 恢復證據規則
28
+ 上游不靠被動技能,靠**工具描述中的選擇準則**。`SubagentWorkflow` 的 `promptGuidelines` 明寫「何時用 Workflow、何時用 Agent」以及「優先 `pipeline` 而非 `parallel`」。沒有這段引導,模型不會主動選工具。任何功能恢復都必須同時交付引導文字。
33
29
 
34
- - [ ] 每次恢復只選一個功能邊界,先從主索引找到對應 archived spec,再核對其中的原始 implementation files、tests、commit/tag 與既有錯誤語意。
35
- - [ ] 先建立 v0.17.6 baseline evidence:確認 viewer rail、preview、focus、keyboard/wheel scroll 與 read-only history 的行為沒有被待恢復變更改寫。
36
- - [ ] 對資料與安全邊界先取證:project-relative transcript/checkpoint、path traversal 拒絕、ownership、allowlist、depth cap、lifecycle 順序都要有可重現的證據。
37
- - [ ] 對 UI 功能另外保存人工操作紀錄:terminal 尺寸、操作步驟、畫面結果、可見延遲與任何回歸;不要把「測試通過」單獨當成 latency acceptance。
38
- - [ ] 若證據不足、行為與 archived spec 不一致,或人工操作出現可見卡頓,該功能停留在待恢復狀態,不與其他功能合併。
39
- - [ ] 本文件本身不執行 Vitest、E2E 或 build;恢復工作是否需要這些檢查,依當時授權與 `AGENTS.md` 的資源政策處理。
30
+ ## pi-tui 版本分裂(需使用者決策)
40
31
 
41
- ## v0.17.19 的決策:只恢復 durable history 基礎層
32
+ 本 extension 宣告 peer `@earendil-works/pi-tui >=0.80.8`,但在 `~/.pi/agent/npm` 實際解析到 `0.74.2`,pi host 使用 `1.0.0`。後果是 `agent-widget.ts` 的 `focused instanceof Editor` 恆 false,roster `↑`/`↓` 在此環境 inert。
42
33
 
43
- - [ ] 將 v0.17.19 限定為第一個、可獨立驗證的 durable history/recovery data plane:先處理 transcript 格式、project-local path safety、checkpoint metadata 與 flush/attach seam。
44
- - [ ] 以 [`docs/post-0.17.6-durable-history-spec.md`](docs/post-0.17.6-durable-history-spec.md) 和 [`docs/post-0.17.6-recovery-shutdown-spec.md`](docs/post-0.17.6-recovery-shutdown-spec.md) 為證據邊界;不得順便恢復 nested agents、Workflow/RPC、FleetView 或整套 Agents UI。
45
- - [ ] 確保 `output_transcript: false` 不會關閉 `.pi-subagents` durable history,且 GC/session cleanup 不會刪除仍可讀的 terminal record;壞 locator、壞 JSON 或 traversal input 必須 fail safely。
46
- - [ ] 在接受 v0.17.19 前,人工確認既有 v0.17.6 viewer interaction 沒有 layout/focus/scroll regression;若此基礎層不改 UI,也仍要記錄 baseline 操作結果。
47
- - [ ] v0.17.19 不承諾任何日曆日期;版本成立條件是上述單一功能的證據完整,而不是時間到期或功能數量達標。
34
+ 阻擋者:`@nklisch/pi-mcp-adapter`(`^0.74.0`)與 `@aliou/pi-utils-ui`(`>=0.74.0 <1`)。inert 是刻意的安全失效——改用結構式探針會讓 widget 從其他擴充套件的對話框搶走方向鍵(曾發生,見 0.17.23/0.17.28)。根治需調整 host 端相依圖。
48
35
 
49
- ## v0.17.19 之後的證據驅動順序
36
+ ## 未來:`/workflow` 使用者指令
50
37
 
51
- 每一列都是獨立候選恢復項;「後續版本」只代表通過前一項 acceptance 後再決定的版本,不預設版本號或日期。
38
+ 讓使用者能主動要求 workflow,而非完全依賴模型自行判斷。設計前提未定:
52
39
 
53
- | 順序 | 單一恢復項 | 主要證據與邊界 |
54
- |---|---|---|
55
- | 1 | Durable history 的 history list、read-only reopen 與 reload | [`durable-history`](docs/post-0.17.6-durable-history-spec.md):先完成 history row/viewer wiring,再接各 spawn path;不得執行 tool 或改寫 transcript。 |
56
- | 2 | Recovery 的 restore、stop/abort 與 child shutdown | [`recovery-shutdown`](docs/post-0.17.6-recovery-shutdown-spec.md):先保留 checkpoint/flush,再加入 `restoreRecovered`、queue wake-up 與 `session_shutdown` ordering。 |
57
- | 3 | Single Agents roster 與 selector ownership | [`agents-roster-viewer`](docs/post-0.17.6-agents-roster-viewer-spec.md):先 cache durable rows/selection,再處理 selector input;不得重新引入已移除的 duplicate FleetView。 |
58
- | 4 | ConversationViewer 與 roster 的 latency/render bounds | [`ui-latency-baseline`](docs/post-0.17.6-ui-latency-baseline-spec.md):bounded cache、render 不讀檔、arrow 不重建 widget、單一 roster、短 terminal 保留 editor 空間。 |
59
- | 5 | Nested agent ownership 與 lifecycle | [`nested-agents`](docs/post-0.17.6-nested-agents-spec.md):先恢復 child-safe context、ownership/depth/allowlist,再接 manager spawn/abort/history;foreground/background 與 print-mode 另行取證。 |
60
- | 6 | Lifecycle events 與 cross-extension RPC | [`workflow-rpc-lifecycle`](docs/post-0.17.6-workflow-rpc-lifecycle-spec.md):先恢復 event/RPC protocol、scope 與 ownership/consume,再接非 top-level child 的 activity 顯示。 |
61
- | 7 | Workflow worker、journal、schema 與 UI | [`workflow-rpc-lifecycle`](docs/post-0.17.6-workflow-rpc-lifecycle-spec.md):最後接 Workflow host/runtime/UI;Workflow child 必須保留自己的 durable transcript,不得冒充 top-level roster。 |
62
-
63
- - [ ] 每完成一列,先獨立取證並完成人工 UI latency acceptance,再開始下一列。
64
- - [ ] 若某列需要前列的 seam,仍只接入必要依賴,不把後列功能預先帶入;依賴關係不能成為一次恢復多個 user-facing feature 的理由。
65
-
66
- ## 人工 UI latency acceptance 門檻
67
-
68
- - [ ] **Viewer baseline**:在短、一般與較長 terminal 高度下,檢查 transcript scrollbar rail、hidden content、sticky/focus/search、`[preview]`、`[Esc]`、`w`、鍵盤與 wheel scroll;Tool Output preview 必須仍是 parent-owned、read-only、in-place。
69
- - [ ] **History path**:從 running、completed、stopped、recovered record 開啟 viewer,關閉後回到原 selector;selected row/viewport 不應跳回第一列,GC 後仍能從 durable transcript 讀取。
70
- - [ ] **Roster path**:大量 agent activity、長 transcript、多行 activity 與短 terminal 下,只有一個 roster;selector 開啟時 background widget 不得搶 key,editor 必須保留最小空間。
71
- - [ ] **Render path**:人工觀察長歷史與頻繁事件時沒有隨歷史長度惡化的可見卡頓;同時以規格列出的 perf guards 驗證 render 不做 filesystem I/O,並記錄證據而非臆測數字門檻。
72
- - [ ] **失敗處理**:任何可見延遲、focus/layout 變化、重複 redraw、stale row 或 history 消失都代表該單一功能未接受;先隔離或回退該變更,不得進入組合 release。
73
-
74
- ## 0.17.19 與後續 release 的組合決策
75
-
76
- - [ ] **v0.17.19**:只接受 durable history/recovery 基礎層;不把 Agents UI、nested、RPC 或 Workflow 當作同一 release 的附帶恢復。
77
- - [ ] **後續版本**:只有已在獨立變更中完成 spec、targeted evidence 與人工 UI latency acceptance 的功能,才可在後續 release 與另一個已接受功能組合。
78
- - [ ] 組合前要重新執行人工回歸矩陣,特別是 viewer baseline、selector input ownership、長 transcript render 與 resource-policy metadata;單項通過不等於組合後通過。
79
- - [ ] 若組合回歸失敗,拆回最後一個已接受的功能邊界;不得為了湊版本內容而放寬 acceptance 或重新引入 duplicate UI。
80
- - [ ] 版本號只在證據與 release gate 都成立後決定;本路線圖不指定未來日期、不 publish、不替使用者執行 release 操作。
81
-
82
- ## 完成定義與範圍守則
83
-
84
- - [ ] 每個恢復項都有對應 archived spec、implementation/test/commit evidence、人工 UI latency 紀錄與明確的保留/回退決策。
85
- - [ ] 所有恢復都維持 `64bbbb0` 的 viewer baseline,並保留 resource-policy metadata、資源規則與文件。
86
- - [ ] 不以功能數量、日期或單次自動檢查取代逐項 acceptance;不可接受的功能留在文件化的待恢復狀態。
87
- - [x] 本次工作只新增本根目錄 `ROADMAP.md`;沒有 runtime edits、測試、build 或 publish。
40
+ - 與 `Agent` 工具自動產生的 workflow 如何劃分責任
41
+ - 已儲存腳本(上游 `examples/workflows/`)的存放位置與發現方式
42
+ - 是否與 `/agents → Workflows` inspector 合併,或作為其快捷入口
43
+ - 是否接受 inline 腳本,或只接受已儲存腳本
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esso0428/pi-subagents",
3
- "version": "0.17.31",
3
+ "version": "0.17.32",
4
4
  "description": "A pi extension that brings smart Claude Code-style autonomous sub-agents to pi, with npm:pi-subagents-style JSON agent overrides.",
5
5
  "author": "ESSO0428",
6
6
  "repository": {
@@ -0,0 +1,14 @@
1
+ /** Reject a wait when its caller aborts without aborting the underlying work. */
2
+ export function abortable<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
3
+ if (!signal) return promise;
4
+ if (signal.aborted) return Promise.reject(new DOMException("The operation was aborted", "AbortError"));
5
+ return new Promise<T>((resolve, reject) => {
6
+ const onAbort = () => reject(new DOMException("The operation was aborted", "AbortError"));
7
+ signal.addEventListener("abort", onAbort, { once: true });
8
+ const cleanup = () => signal.removeEventListener("abort", onAbort);
9
+ promise.then(
10
+ value => { cleanup(); resolve(value); },
11
+ error => { cleanup(); reject(error); },
12
+ );
13
+ });
14
+ }
@@ -20,6 +20,7 @@ import {
20
20
  writeAgentRecoveryCheckpoint,
21
21
  } from "./agent-recovery.js";
22
22
  import { resumeAgent, runAgent, type ToolActivity } from "./agent-runner.js";
23
+ import type { NestedSpawnOptions } from "./nested-tools.js";
23
24
  import type { AgentInvocation, AgentRecord, IsolationMode, SubagentType, ThinkingLevel } from "./types.js";
24
25
  import { addUsage } from "./usage.js";
25
26
  import { cleanupWorktree, createWorktree, pruneWorktrees, } from "./worktree.js";
@@ -157,7 +158,7 @@ interface SpawnArgs {
157
158
  options: SpawnOptions;
158
159
  }
159
160
 
160
- interface SpawnOptions {
161
+ export interface SpawnOptions extends Partial<Omit<NestedSpawnOptions, "description" | "signal" | "onAssistantUsage" | "onSessionCreated">> {
161
162
  description: string;
162
163
  model?: Model<any>;
163
164
  maxTurns?: number;
@@ -198,6 +199,8 @@ interface SpawnOptions {
198
199
  onTurnEnd?: (turnCount: number) => void;
199
200
  /** Called once per assistant message_end with that message's usage delta. */
200
201
  onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void;
202
+ /** Called when nested delegation is refused, for observability. */
203
+ onNestedIssue?: (issue: string) => void;
201
204
  /** Called when the session successfully compacts. */
202
205
  onCompaction?: (info: CompactionInfo) => void;
203
206
  /** Called synchronously after the record exists, before it is queued or started. */
@@ -277,6 +280,10 @@ export class AgentManager {
277
280
  ...(record.error !== undefined && { error: record.error }),
278
281
  ...(record.transcriptPath !== undefined && { transcriptPath: record.transcriptPath }),
279
282
  ...(record.invocation !== undefined && { invocation: cloneInvocation(record.invocation) }),
283
+ // Without these a reload rebuilds every agent as a root and the roster
284
+ // flattens, because nothing on disk records the nesting.
285
+ ...(record.parentAgentId !== undefined && { parentAgentId: record.parentAgentId }),
286
+ ...(record.depth !== undefined && { depth: record.depth }),
280
287
  };
281
288
  return checkpoint;
282
289
  }
@@ -379,6 +386,18 @@ export class AgentManager {
379
386
  isBackground: options.isBackground,
380
387
  invocation: options.invocation,
381
388
  waitGroupId: options.waitGroupId,
389
+ depth: options.depth ?? 1,
390
+ parentAgentId: options.parentAgentId,
391
+ parentDescription: options.parentAgentId ? this.agents.get(options.parentAgentId)?.description : undefined,
392
+ maxSubagentDepth: options.maxSubagentDepth,
393
+ rootSessionId: options.rootSessionId ?? ctx.sessionManager?.getSessionId?.(),
394
+ liveActivity: {
395
+ activeTools: new Map(),
396
+ responseText: "",
397
+ turnCount: 0,
398
+ maxTurns: options.maxTurns,
399
+ session: undefined,
400
+ },
382
401
  };
383
402
  this.agents.set(id, record);
384
403
  this.recoveryCwds.set(id, ctx.cwd);
@@ -397,7 +416,9 @@ export class AgentManager {
397
416
 
398
417
  const args: SpawnArgs = { pi, ctx, type, prompt, options };
399
418
 
400
- if (options.isBackground && !options.bypassQueue && this.runningBackground >= this.maxConcurrent) {
419
+ // Nested children never consume or wait for a top-level concurrency slot;
420
+ // queueing behind the parent would deadlock an inline nested spawn.
421
+ if (options.isBackground && !options.parentAgentId && !options.bypassQueue && this.runningBackground >= this.maxConcurrent) {
401
422
  // Queue it — will be started when a running agent completes
402
423
  this.queue.push({ id, args });
403
424
  return id;
@@ -453,7 +474,7 @@ export class AgentManager {
453
474
  record.status = "running";
454
475
  record.startedAt = Date.now();
455
476
  this.checkpoint(record);
456
- if (options.isBackground) {
477
+ if (options.isBackground && !options.parentAgentId) {
457
478
  this.runningBackground++;
458
479
  this.runningBackgroundIds.add(id);
459
480
  }
@@ -485,11 +506,27 @@ export class AgentManager {
485
506
  configCwd: customCwd !== undefined ? ctx.cwd : undefined,
486
507
  signal: record.abortController!.signal,
487
508
  onToolActivity: (activity) => {
509
+ const live = record.liveActivity;
510
+ if (live) {
511
+ if (activity.type === "start") {
512
+ live.activeTools.set(`${activity.toolName}:${Date.now()}:${live.activeTools.size}`, activity.toolName);
513
+ } else {
514
+ for (const [key, name] of live.activeTools) {
515
+ if (name === activity.toolName) { live.activeTools.delete(key); break; }
516
+ }
517
+ }
518
+ }
488
519
  if (activity.type === "end") record.toolUses++;
489
520
  options.onToolActivity?.(activity);
490
521
  },
491
- onTurnEnd: options.onTurnEnd,
492
- onTextDelta: options.onTextDelta,
522
+ onTurnEnd: (turnCount) => {
523
+ if (record.liveActivity) record.liveActivity.turnCount = turnCount;
524
+ options.onTurnEnd?.(turnCount);
525
+ },
526
+ onTextDelta: (delta, fullText) => {
527
+ if (record.liveActivity) record.liveActivity.responseText = fullText;
528
+ options.onTextDelta?.(delta, fullText);
529
+ },
493
530
  onAssistantUsage: (usage) => {
494
531
  addUsage(record.lifetimeUsage, usage);
495
532
  options.onAssistantUsage?.(usage);
@@ -499,8 +536,20 @@ export class AgentManager {
499
536
  this.onCompact?.(record, info);
500
537
  options.onCompaction?.(info);
501
538
  },
539
+ onNestedIssue: (issue) => {
540
+ record.nestedIssue = issue;
541
+ options.onNestedIssue?.(issue);
542
+ },
543
+ nestedRuntime: {
544
+ manager: this,
545
+ parentAgentId: id,
546
+ depth: record.depth ?? 1,
547
+ maxSubagentDepth: options.maxSubagentDepth,
548
+ },
549
+ nestedSession: options.parentAgentId !== undefined,
502
550
  onSessionCreated: (session) => {
503
551
  record.session = session;
552
+ if (record.liveActivity) record.liveActivity.session = session;
504
553
  const model = session.model;
505
554
  record.invocation = {
506
555
  ...(record.invocation ?? {}),
@@ -760,6 +809,8 @@ export class AgentManager {
760
809
  transcriptPath?: string;
761
810
  invocation?: AgentInvocation;
762
811
  compactionCount?: number;
812
+ parentAgentId?: string;
813
+ depth?: number;
763
814
  }): AgentRecord {
764
815
  return {
765
816
  id: record.id,
@@ -777,9 +828,36 @@ export class AgentManager {
777
828
  ? { ...record.lifetimeUsage }
778
829
  : { input: 0, output: 0, cacheWrite: 0 },
779
830
  compactionCount: record.compactionCount ?? 0,
831
+ parentAgentId: record.parentAgentId,
832
+ depth: record.depth,
780
833
  };
781
834
  }
782
835
 
836
+ /** Record a nested refusal so the widget and viewer retain the explanation. */
837
+ reportNestedIssue(parentAgentId: string, issue: string): void {
838
+ const record = this.agents.get(parentAgentId);
839
+ if (record) record.nestedIssue = issue;
840
+ }
841
+
842
+ /** Run one nested child inline without charging either concurrency pool. */
843
+ async spawnAndWait(
844
+ pi: ExtensionAPI,
845
+ ctx: ExtensionContext,
846
+ type: string,
847
+ prompt: string,
848
+ options: Omit<SpawnOptions, "isBackground">,
849
+ onSpawned?: (id: string) => void,
850
+ ): Promise<{ id: string; record: AgentRecord }> {
851
+ const id = this.spawn(pi, ctx, type, prompt, { ...options, isBackground: false, onSpawned });
852
+ const record = this.agents.get(id);
853
+ if (!record) throw new Error(`Nested agent "${id}" disappeared during startup.`);
854
+ if (record.promise) await record.promise;
855
+ return { id, record };
856
+ }
857
+
858
+ /** Current manager starts synchronously; retained for the nested tool contract. */
859
+ async awaitStartup(_id: string): Promise<void> {}
860
+
783
861
  abort(id: string): boolean {
784
862
  const record = this.agents.get(id);
785
863
  if (!record) return false;
@@ -32,6 +32,10 @@ export interface AgentRecoveryCheckpoint {
32
32
  compactionCount: number;
33
33
  transcriptPath?: string;
34
34
  invocation?: AgentInvocation;
35
+ /** Owning agent for a nested child. Survives reload so the roster keeps its tree. */
36
+ parentAgentId?: string;
37
+ /** Nesting depth of a nested child; absent on top-level agents. */
38
+ depth?: number;
35
39
  }
36
40
 
37
41
  function isSafeString(value: unknown, maxLength: number): value is string {
@@ -99,6 +103,9 @@ export function isAgentRecoveryCheckpoint(value: unknown): value is AgentRecover
99
103
  || !Number.isInteger(checkpoint.compactionCount)
100
104
  || (checkpoint.compactionCount as number) < 0) return false;
101
105
 
106
+ if (checkpoint.parentAgentId !== undefined && !isSafeString(checkpoint.parentAgentId, 256)) return false;
107
+ if (checkpoint.depth !== undefined && (!Number.isInteger(checkpoint.depth) || (checkpoint.depth as number) < 0)) return false;
108
+
102
109
  const status = checkpoint.status as AgentRecoveryStatus;
103
110
  if (!isActiveStatus(status) && !isTerminalStatus(status)) return false;
104
111
  if (checkpoint.completedAt !== undefined && !isFiniteTimestamp(checkpoint.completedAt)) return false;
@@ -18,10 +18,12 @@ import {
18
18
  SettingsManager,
19
19
  } from "@earendil-works/pi-coding-agent";
20
20
  import { BUILTIN_TOOL_NAMES, getAgentConfig, getConfig, getMemoryToolNames, getReadOnlyMemoryToolNames, getToolNamesForType } from "./agent-types.js";
21
+ import { runInChildSessionContext } from "./child-context.js";
21
22
  import { buildParentContext, extractText } from "./context.js";
22
23
  import { DEFAULT_AGENTS } from "./default-agents.js";
23
24
  import { detectEnv } from "./env.js";
24
25
  import { buildMemoryBlock, buildReadOnlyMemoryBlock } from "./memory.js";
26
+ import { createNestedSubagentTools, getMaxSubagentDepth, type NestedAgentManager } from "./nested-tools.js";
25
27
  import { buildAgentPrompt, type PromptExtras } from "./prompts.js";
26
28
  import { preloadSkills } from "./skill-loader.js";
27
29
  import type { SubagentType, ThinkingLevel } from "./types.js";
@@ -232,9 +234,18 @@ export function installExtensionToolScope(
232
234
  disallowedSet: Set<string> | undefined;
233
235
  extNames: Set<string>;
234
236
  narrowing: Map<string, Set<string>>;
237
+ /**
238
+ * Injected `customTools` that must survive the scope gate. They are deleted
239
+ * below along with `EXCLUDED_TOOL_NAMES` — the scoped nested `Agent` and
240
+ * `steer_subagent` deliberately share those names — so they have to be
241
+ * re-admitted or `renarrow` drops them from the active set and
242
+ * `beforeToolCall` rejects them. Already filtered against `disallowed_tools`
243
+ * by the caller, which is the only place that knows what may be taken back.
244
+ */
245
+ readmitToolNames: Set<string>;
235
246
  },
236
247
  ): void {
237
- const { loader, toolNames, disallowedSet, extNames, narrowing } = ctx;
248
+ const { loader, toolNames, disallowedSet, extNames, narrowing, readmitToolNames } = ctx;
238
249
 
239
250
  // The names allowed right now. Mirrors the `ext:` opt-in flip: when any `ext:`
240
251
  // selector is present, extension tools become an explicit allowlist — a loaded
@@ -256,6 +267,7 @@ export function installExtensionToolScope(
256
267
  }
257
268
  }
258
269
  for (const name of EXCLUDED_TOOL_NAMES) keep.delete(name);
270
+ for (const name of readmitToolNames) keep.add(name);
259
271
  return keep;
260
272
  };
261
273
 
@@ -394,6 +406,17 @@ export interface RunOptions {
394
406
  * pre-compaction context size estimate. Aborted compactions don't fire.
395
407
  */
396
408
  onCompaction?: (info: { reason: "manual" | "threshold" | "overflow"; tokensBefore: number }) => void;
409
+ /** Called when nested delegation is unavailable or refused. */
410
+ onNestedIssue?: (issue: string) => void;
411
+ /** Runtime bridge for ownership-scoped nested delegation. */
412
+ nestedRuntime?: {
413
+ manager: NestedAgentManager;
414
+ parentAgentId: string;
415
+ depth: number;
416
+ maxSubagentDepth?: number;
417
+ };
418
+ /** True only for an agent spawned by another agent, not a top-level owner. */
419
+ nestedSession?: boolean;
397
420
  }
398
421
 
399
422
  export interface RunResult {
@@ -647,7 +670,7 @@ export async function runAgent(
647
670
  systemPromptOverride: () => systemPrompt,
648
671
  appendSystemPromptOverride: () => [],
649
672
  });
650
- await loader.reload();
673
+ await runInChildSessionContext(() => loader.reload());
651
674
 
652
675
  // Plain entries in `tools:` are expected to be built-in names (extension tools
653
676
  // go through `ext:`), so an unknown name there is unambiguously a typo. Previously
@@ -730,6 +753,29 @@ export async function runAgent(
730
753
  ? new Set(agentConfig.disallowedTools)
731
754
  : undefined;
732
755
 
756
+ // Nested tools are built for this session and injected directly. Nested child
757
+ // sessions deliberately do not bind the pi-subagents extension again: doing
758
+ // so would register a second global manager and expose unscoped tools.
759
+ const effectiveMaxDepth = options.nestedRuntime?.maxSubagentDepth ?? getMaxSubagentDepth();
760
+ const nestedRuntime = options.nestedRuntime && options.nestedRuntime.depth < effectiveMaxDepth
761
+ ? options.nestedRuntime
762
+ : undefined;
763
+ if (options.nestedRuntime && options.nestedRuntime.depth >= effectiveMaxDepth && agentConfig?.allowedSubagents) {
764
+ options.onNestedIssue?.(`depth cap: nested delegation unavailable at depth ${options.nestedRuntime.depth} (max ${effectiveMaxDepth})`);
765
+ }
766
+ const nestedTools = agentConfig?.allowedSubagents && nestedRuntime && !options.isolated
767
+ ? createNestedSubagentTools({
768
+ manager: nestedRuntime.manager,
769
+ pi: options.pi,
770
+ parentAgentId: nestedRuntime.parentAgentId,
771
+ depth: nestedRuntime.depth,
772
+ maxSubagentDepth: effectiveMaxDepth,
773
+ allowedSubagents: agentConfig.allowedSubagents,
774
+ configCwd,
775
+ })
776
+ : [];
777
+ const nestedToolNames = new Set(nestedTools.map(tool => tool.name));
778
+
733
779
  // ─── Tool scoping ───────────────────────────────────────────────────────
734
780
  //
735
781
  // Some extensions register their tools ASYNCHRONOUSLY, long after the
@@ -762,11 +808,12 @@ export async function runAgent(
762
808
  let sessionTools: string[] | undefined;
763
809
  let sessionExcludeTools: string[] | undefined;
764
810
  if (noExtensions) {
765
- sessionTools = toolNames.filter(
766
- (t) => !EXCLUDED_TOOL_NAMES.includes(t) && !disallowedSet?.has(t),
767
- );
811
+ sessionTools = [
812
+ ...toolNames.filter((t) => !EXCLUDED_TOOL_NAMES.includes(t) && !disallowedSet?.has(t)),
813
+ ...[...nestedToolNames].filter(t => !disallowedSet?.has(t)),
814
+ ];
768
815
  } else {
769
- const denyTools = new Set<string>(EXCLUDED_TOOL_NAMES);
816
+ const denyTools = new Set<string>(EXCLUDED_TOOL_NAMES.filter(name => !nestedToolNames.has(name)));
770
817
  // Keep only the built-ins the agent asked for — deny the rest.
771
818
  for (const name of BUILTIN_TOOL_NAMES) {
772
819
  if (!builtinToolNameSet.has(name)) denyTools.add(name);
@@ -793,6 +840,7 @@ export async function runAgent(
793
840
  // modelRuntime, but ExtensionContext still exposes only the registry facade.
794
841
  // Pass both so the full supported Pi range retains the parent's providers.
795
842
  const parentModelRuntime = (ctx.modelRegistry as unknown as { runtime?: ModelRuntime | null }).runtime ?? undefined;
843
+ const customTools = [...nestedTools, ...(trackedWriteTool ? [trackedWriteTool as any] : [])];
796
844
  const sessionOpts: Parameters<typeof createAgentSession>[0] & {
797
845
  modelRegistry: ExtensionContext["modelRegistry"];
798
846
  modelRuntime?: ModelRuntime;
@@ -810,7 +858,7 @@ export async function runAgent(
810
858
  // fresh interactive app startup. Mark their extension lifecycle as a fork so
811
859
  // startup-only UI/theme extensions do not clear or rewrite the parent TUI.
812
860
  sessionStartEvent: { type: "session_start", reason: "fork" },
813
- ...(trackedWriteTool && { customTools: [trackedWriteTool as any] }),
861
+ ...(customTools.length > 0 && { customTools }),
814
862
  };
815
863
  if (sessionExcludeTools) {
816
864
  sessionOpts.excludeTools = sessionExcludeTools;
@@ -819,7 +867,8 @@ export async function runAgent(
819
867
  sessionOpts.thinkingLevel = thinkingLevel;
820
868
  }
821
869
 
822
- const { session } = await createAgentSession(sessionOpts);
870
+ const { session } = await runInChildSessionContext(() => createAgentSession(sessionOpts));
871
+
823
872
 
824
873
  const baseSessionName = agentConfig?.name ?? type;
825
874
  session.setSessionName(
@@ -830,14 +879,16 @@ export async function runAgent(
830
879
  // (e.g. loading credentials, setting up state). Tool gating already happened
831
880
  // at session construction via the `tools:` allowlist above — no separate
832
881
  // post-bind filter is needed. All ExtensionBindings fields are optional.
833
- await session.bindExtensions({
834
- onError: (err) => {
835
- options.onToolActivity?.({
836
- type: "end",
837
- toolName: `extension-error:${err.extensionPath}`,
838
- });
839
- },
840
- });
882
+ if (!options.nestedSession) {
883
+ await session.bindExtensions({
884
+ onError: (err) => {
885
+ options.onToolActivity?.({
886
+ type: "end",
887
+ toolName: `extension-error:${err.extensionPath}`,
888
+ });
889
+ },
890
+ });
891
+ }
841
892
 
842
893
  // With `allowedToolNames` unset, the registry is scoped by `excludeTools` but
843
894
  // the ACTIVE set still needs managing: pi activates only its four default
@@ -845,13 +896,14 @@ export async function runAgent(
845
896
  // (we can't deny the name of a tool that hasn't registered yet). Both are
846
897
  // handled below by re-deriving scope from the loader's live extension maps —
847
898
  // `registerTool` writes into those same maps, so late arrivals are judged too.
848
- if (!noExtensions) {
899
+ if (!noExtensions && !options.nestedSession) {
849
900
  installExtensionToolScope(session, {
850
901
  loader,
851
902
  toolNames,
852
903
  disallowedSet,
853
904
  extNames,
854
905
  narrowing,
906
+ readmitToolNames: new Set([...nestedToolNames].filter((name) => !disallowedSet?.has(name))),
855
907
  });
856
908
  }
857
909