@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 +11 -0
- package/ROADMAP.md +28 -72
- package/package.json +1 -1
- package/src/abortable.ts +14 -0
- package/src/agent-manager.ts +83 -5
- package/src/agent-recovery.ts +7 -0
- package/src/agent-runner.ts +69 -17
- package/src/agent-types.ts +88 -15
- package/src/child-context.ts +12 -0
- package/src/custom-agents.ts +12 -0
- package/src/index.ts +38 -7
- package/src/model-scope.ts +35 -0
- package/src/nested-tools.ts +361 -0
- package/src/settings.ts +18 -0
- package/src/types.ts +22 -0
- package/src/ui/agent-widget.ts +148 -63
- package/src/ui/conversation-viewer.ts +7 -3
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
|
-
#
|
|
1
|
+
# pi-subagents 路線圖
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|
-
##
|
|
36
|
+
## 未來:`/workflow` 使用者指令
|
|
50
37
|
|
|
51
|
-
|
|
38
|
+
讓使用者能主動要求 workflow,而非完全依賴模型自行判斷。設計前提未定:
|
|
52
39
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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.
|
|
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": {
|
package/src/abortable.ts
ADDED
|
@@ -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
|
+
}
|
package/src/agent-manager.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
492
|
-
|
|
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;
|
package/src/agent-recovery.ts
CHANGED
|
@@ -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;
|
package/src/agent-runner.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
...(
|
|
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
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
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
|
|