@narumitw/pi-subagents 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,21 +2,26 @@
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/@narumitw/pi-subagents)](https://www.npmjs.com/package/@narumitw/pi-subagents) [![Pi extension](https://img.shields.io/badge/Pi-extension-blue)](https://pi.dev) [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE)
4
4
 
5
- `@narumitw/pi-subagents` is a native [Pi coding agent](https://pi.dev) extension that adds a `subagent` tool for delegating work to specialized agents running in isolated Pi subprocesses.
5
+ `@narumitw/pi-subagents` is a native [Pi coding agent](https://pi.dev) extension for delegating work to specialized agents. The batch `subagent` tool keeps isolated Pi subprocesses, while addressable lifecycle tools can run reusable agents either as subprocess-backed logical sessions or as public-SDK in-process child sessions.
6
6
 
7
- Use it to split research, planning, implementation, and review work across focused workers while keeping each subprocess context, tools, and prompt boundary separate from the main conversation.
7
+ Use it to split independent research, planning, implementation, and review work across focused workers. Background delegation is intended for sidecar work the main agent can overlap with useful local work—not for handing off one critical-path task and waiting.
8
8
 
9
9
  ## ✨ Features
10
10
 
11
11
  - Registers a `subagent` tool for single-agent, parallel, fan-in, and chained delegation.
12
- - Runs workers as isolated `pi --mode json -p --no-session` subprocesses.
12
+ - Keeps batch workers isolated in `pi --mode json -p --no-session` subprocesses.
13
+ - Registers detached stateful lifecycle tools by default; spawn returns immediately and completion is delivered asynchronously without starting a new root turn.
14
+ - Supports an opt-in public-SDK `in-process` stateful transport with one reusable child `AgentSession` per `agentId`.
13
15
  - Supports built-in `scout`, `planner`, `reviewer`, and `worker` agents.
14
16
  - Loads custom user agents from `~/.pi/agent/agents/*.md`.
15
17
  - Optionally loads project agents from `.pi/agents/*.md` with confirmation.
16
18
  - Provides `/subagents:config` to persist per-agent tool allow-lists.
17
19
  - Supports per-task `cwd`, hard subprocess `timeoutMs`, `thinkingLevel`, abort propagation, and streaming progress.
20
+ - Bounds JSON lines, captured messages, stderr, final output, chain substitution, and fan-in context.
21
+ - Enforces a recursion-depth guard and deterministic process-group termination.
22
+ - Provides addressable stateful agents with follow-up, wait, list, interrupt, close, context selection, and persistence.
18
23
  - Publishes transient runtime status through Pi's generic extension status API while subagents are running.
19
- - Returns complete worker output in tool details and a concise result for the main agent.
24
+ - Returns complete bounded worker output in tool details and a concise result for the main agent.
20
25
 
21
26
  ## 📦 Install
22
27
 
@@ -38,9 +43,18 @@ pi -e ./extensions/pi-subagents
38
43
 
39
44
  ## 🛠️ Pi tool
40
45
 
41
- `pi-subagents` registers one tool:
46
+ `pi-subagents` registers a primary batch tool plus the stateful lifecycle tools documented below:
42
47
 
43
- - `subagent` — delegate work to one or more specialized agents.
48
+ - `subagent` — delegate blocking single, parallel, fan-in, or chained batch work.
49
+ - `subagent_spawn` and related lifecycle tools — start reusable detached work, return immediately, and receive bounded completion messages automatically.
50
+
51
+ Choose the API by lifecycle:
52
+
53
+ | Need | Use |
54
+ | --- | --- |
55
+ | One or more independent, one-shot results needed before the root responds | One blocking `subagent` call (`tasks` for parallel work) |
56
+ | Reusable history, follow-ups, mailboxes, or work that overlaps useful root work | `subagent_spawn` and lifecycle tools |
57
+ | One simple or critical-path action the root can perform directly | No subagent |
44
58
 
45
59
  Execution modes:
46
60
 
@@ -64,8 +78,12 @@ Count-selection guidance:
64
78
 
65
79
  - Use **no subagent** for simple answers, quick targeted edits, latency-sensitive one-step work, or
66
80
  tasks that need frequent user back-and-forth.
67
- - Use **one subagent** for isolated research, high-volume command output, planning, or independent
68
- review/verification after implementation.
81
+ - A single `subagent` call is blocking. Use one only when context isolation, high-volume output
82
+ isolation, or independent review is worth waiting for; otherwise the main agent should do the work.
83
+ - Do not delegate ordinary critical-path work merely to wait for it. Use detached `subagent_spawn`
84
+ only when parallel work, bounded context/output, independent review, a distinct model/tool profile,
85
+ or workspace isolation provides concrete value. Continue useful non-overlapping work when available;
86
+ otherwise call `subagent_wait` and synthesize the result before finishing.
69
87
  - Prefer **2–4 parallel read-only subagents** when a broad task naturally splits into independent
70
88
  branches that can each return a concise summary.
71
89
  - Exceed 4 tasks only when the branches are clearly distinct and worth the extra cost, while staying
@@ -128,6 +146,8 @@ Run one read-only reconnaissance agent:
128
146
  }
129
147
  ```
130
148
 
149
+ For genuinely random values, specify the range, duplicate policy, and a system randomness source instead of relying on model sampling, for example: `Use Python secrets to return 10 integers from 0 through 999; duplicates are allowed.`
150
+
131
151
  Run multiple agents in parallel with a shared thinking level and one per-task override:
132
152
 
133
153
  ```json
@@ -178,6 +198,92 @@ Run a chain where each step receives the previous output:
178
198
  }
179
199
  ```
180
200
 
201
+ ## 🔁 Stateful agents
202
+
203
+ Stateful lifecycle tools are available by default. `subagent_spawn` is detached: it schedules work, returns immediately with an opaque `agentId`, and later injects one bounded `pi-subagent-completion` message per settled turn. The message uses `deliverAs: "steer"` with `triggerTurn: false`, so an active root turn can consume it naturally.
204
+
205
+ Detached work has a root-coordination invariant: the main agent should continue useful non-overlapping work when available, or call `subagent_wait` when coordination is the only useful next action. If it yields while current delegated work is still live or awaiting synthesis, the extension schedules one idle-gated recovery continuation for that orchestration revision. A completion, close, interruption, new root turn, session replacement, or shutdown revises or clears that ephemeral state. Recovery is bounded and does not internally wait forever or persist across sessions.
206
+
207
+ A single detached agent is justified only by a concrete isolation or specialization benefit such as independent review, bounded context/output, a distinct model/tool profile, or workspace isolation. Simple work that the main agent can perform directly should not be delegated.
208
+
209
+ When `subagent_wait` starts while a turn is running, that wait consumes the turn's completion: the terminal output is returned by the tool and is not also injected as a duplicate asynchronous completion. Multiple active waiters may all receive the terminal result. A timed-out or aborted wait does not consume it, so later settlement still delivers the normal asynchronous completion. If a turn has already settled and queued its completion before a wait starts, the queued message cannot be retracted safely.
210
+
211
+ The default `subprocess` transport preserves compatibility: each turn starts a fresh isolated `pi --mode json -p --no-session` child and receives sanitized, bounded history. Set `transport` to `in-process` to retain one public Pi SDK `AgentSession` per stateful `agentId`, avoiding repeated process startup while preserving native child history in memory.
212
+
213
+ Configure the runtime in `~/.pi/agent/pi-subagents-config.json`, then reload Pi:
214
+
215
+ ```json
216
+ {
217
+ "stateful": {
218
+ "transport": "in-process",
219
+ "maxAgents": 16,
220
+ "maxActiveTurns": 4,
221
+ "maxDepth": 3,
222
+ "maxChildrenPerAgent": 8,
223
+ "maxMailboxMessages": 100,
224
+ "maxMailboxMessageBytes": 16384,
225
+ "idleTtlMs": 3600000,
226
+ "retentionDays": 30,
227
+ "maxStoredAgents": 50
228
+ }
229
+ }
230
+ ```
231
+
232
+ Set `"enabled": false` to remove all stateful lifecycle tools. Otherwise, the extension registers:
233
+
234
+ | Tool | Purpose |
235
+ | --- | --- |
236
+ | `subagent_spawn` | Start detached work, return an opaque `agentId` immediately, and deliver completion asynchronously. |
237
+ | `subagent_send` | Send follow-up work to a reusable agent; shared-workspace write conflicts are guarded unless explicitly overridden. |
238
+ | `subagent_message` | Queue a bounded mailbox message without starting a turn; sender IDs must be `root` or an agent in the same tree. |
239
+ | `subagent_messages` | Read and optionally acknowledge unread mailbox messages. |
240
+ | `subagent_wait` | Wait when coordination is the useful next action; timeout does not terminate the agent, and parent abort/user steering cancels only the wait. Already-terminal agents return immediately. |
241
+ | `subagent_list` | List retained agents and lifecycle states. |
242
+ | `subagent_interrupt` | Abort the current turn while retaining its identity and history. |
243
+ | `subagent_close` | Abort if necessary, close the agent, and remove it from retained persistence. |
244
+
245
+ Use `/subagents:agents list` to inspect the indented agent tree, lifecycle state, unread count, and available actions. Use `/subagents:agents clear` to close and delete all retained agents for the session. Active turns are FIFO-limited by `maxActiveTurns`; excess retained work remains in `starting` state until a slot is available. `maxAgents` separately bounds running, queued, and idle records. `parentId` creates a bounded child relationship; subtree interrupt and close operate child-first.
246
+
247
+ `subagent_spawn.context` accepts:
248
+
249
+ - `"none"` (default) — no parent conversation.
250
+ - `"all"` — bounded user/assistant text from the active branch.
251
+ - `"summary"` — a bounded earlier-context checkpoint plus recent messages verbatim.
252
+ - A positive number — the most recent N user turns and related assistant text.
253
+
254
+ Use `contextEntryIds` to select exact session entries. Supplying IDs without `context` implies `context: "all"`; an explicit `context: "none"` still disables parent context. Stable source IDs are retained so repeated follow-ups do not need to duplicate parent context.
255
+
256
+ Reasoning, tool results, custom transport messages, and non-text parts are excluded. Text inside `<private>...</private>` and lines containing `[subagent-private]` are omitted before context, mailbox content, or history is persisted.
257
+
258
+ Stateful execution uses a transport boundary:
259
+
260
+ - `subprocess` is the default compatibility and rollback path.
261
+ - `in-process` uses only public Pi SDK APIs: `createAgentSession()`, `SessionManager.inMemory()`, `DefaultResourceLoader`, and normal session lifecycle methods. It isolates conversation/tool selection, not memory or crashes; child failures share the parent Node.js process.
262
+ - Child resource loading sets `noExtensions: true`, preventing recursive `pi-subagents` loading and duplicate extension side effects while retaining normal context/skill resources and the selected agent prompt.
263
+ - Agent model, thinking level, and built-in tool allow-list overrides are applied when the child is created. Parent model/thinking changes are snapshotted for subsequently created children; an existing child keeps its own session configuration.
264
+ - Extension/custom tool names are rejected in-process with an actionable recommendation to use `subprocess`; permissions are never silently widened.
265
+ - Timeout, parent abort, close, expiry, and session shutdown abort/dispose owned child sessions. A child that does not settle after abort grace is discarded rather than reused.
266
+ - In-process startup failures do not silently retry through subprocesses, preventing duplicate side effects.
267
+
268
+ No private Pi imports, runtime casts, or `ExtensionAPI` monkey-patching are used. Approval policy, sandbox profile, provider-header hooks, extension state, global scheduling, and parent/child transcript switching are not inherited or provided by the in-process transport.
269
+
270
+ Write-capable agents share the workspace by default. Concurrent write-capable starts in the same cwd are rejected unless `allowConcurrentWrites` is explicitly set. Classification is intentionally conservative: an agent with `bash`, `write`, or `edit` is write-capable even when its task prompt says “read only,” because prompt wording is not a filesystem sandbox. For independent one-shot work, use batch parallel mode. Otherwise wait for or close the active agent, explicitly accept safe overlap with `allowConcurrentWrites`, or use an isolated worktree when repository isolation is actually needed.
271
+
272
+ Set `workspaceMode: "worktree"` to opt into a disposable detached Git worktree; this requires a clean repository and the worktree is removed on close or session shutdown. Isolated worktree agents are intentionally not restored after shutdown.
273
+
274
+ ## 📜 Compatibility and failure contract
275
+
276
+ Existing `subagent` requests remain unchanged:
277
+
278
+ | Mode | Ordering | Failure behavior |
279
+ | --- | --- | --- |
280
+ | Single | One result. | A failed/aborted/timed-out worker is marked as a tool error while preserving bounded details. |
281
+ | Chain | Input order. | Stops at the first failed step; completed steps remain in details. |
282
+ | Parallel | Input order, with at most four active children. | Collects all task results; partial worker failure is reported in summaries but does not discard successful results. |
283
+ | Parallel + aggregator | Source input order, then aggregator. | The aggregator runs with both successful outputs and failure descriptions; aggregator failure marks the tool result as an error. |
284
+
285
+ Timeout precedence remains: task/step/aggregator → call → agent setting → `PI_SUBAGENT_TIMEOUT_MS` → 600000 ms. Thinking precedence remains: task/step/aggregator → call → agent setting → child default. Project-agent resolution and confirmation behavior is unchanged.
286
+
181
287
  ## 🤖 Built-in agents
182
288
 
183
289
  Built-in agents are available without setup and can be overridden by user or project agents with the same name.
@@ -241,7 +347,15 @@ Set `thinkingLevel` to pass Pi's `--thinking <level>` to a subprocess. Supported
241
347
 
242
348
  Thinking-level precedence is: task/chain step/aggregator `thinkingLevel` → top-level `thinkingLevel` → agent default from config or frontmatter → Pi subprocess default. Omit `thinkingLevel` to preserve existing behavior. Pi still owns model capability clamping, so unsupported thinking levels are handled by the spawned Pi process.
243
349
 
244
- On timeout, the extension sends `SIGTERM`, escalates to `SIGKILL` after a short grace period, and returns any partial messages or stderr collected so far.
350
+ On timeout, the extension sends process-group `SIGTERM`, escalates to `SIGKILL` after a five-second grace period if the process has not actually closed, and returns partial bounded messages or stderr collected so far. Parent abort uses the same cleanup path and preserves a structured result.
351
+
352
+ The child event protocol limits each JSON line to 256 KiB. Captured output uses these defaults:
353
+
354
+ - final output and fan-in/chain context: 50 KiB;
355
+ - stderr: 16 KiB;
356
+ - captured messages: 200.
357
+
358
+ Truncated text includes a `truncated by pi-subagents` marker and details expose `truncated: true`. `PI_SUBAGENT_MAX_DEPTH` controls nested delegation depth and defaults to 1; child processes receive `PI_SUBAGENT_DEPTH` automatically.
245
359
 
246
360
  ## 📡 Runtime status
247
361
 
@@ -249,21 +363,32 @@ While the `subagent` tool is running, `pi-subagents` publishes compact activity
249
363
 
250
364
  ## 🔒 Safety notes
251
365
 
252
- Subagents are separate Pi processes and may use the tools allowed by their agent definition. Treat project-local agent prompts like executable project configuration: only enable them in trusted repositories.
366
+ Subagents have separate processes and context windows, but they are **not security sandboxes**. They run as the same OS user, share the host filesystem and network access, and may conflict if they edit the same files. Tool allow-lists reduce available Pi tools but do not reduce operating-system permissions.
367
+
368
+ The runner explicitly reports policy continuity in result details:
369
+
370
+ - inherited: process environment;
371
+ - overridden when selected: cwd, model, thinking level, and tool list;
372
+ - unsupported guarantees: parent approval policy, sandbox profile, and provider headers.
373
+
374
+ Treat project-local agent prompts like executable project configuration: only enable them in trusted repositories. Stateful project agents require Pi's project trust; interactive use also keeps confirmation enabled by default.
375
+
376
+ Stateful records are stored as versioned mode-0600 JSON under `~/.pi/agent/pi-subagents-state/` (or the configured Pi agent directory). Records contain sanitized logical history, never process IDs or credentials. Corrupt or unsupported state is quarantined, restored agents are always inert `idle` records, and no prior side effect is automatically resumed. Retention and count limits are configurable. Downgrading is safe: older extension versions ignore this separate state directory; use `/subagents:agents clear` before downgrade if the histories should be removed.
253
377
 
254
378
  ## 🗂️ Package layout
255
379
 
256
380
  ```txt
257
381
  extensions/pi-subagents/
258
382
  ├── src/
259
- └── subagents.ts
383
+ ├── subagents.ts # Pi entrypoint and tool schema
384
+ │ └── *.ts # Package-local discovery, execution, rendering, and config modules
260
385
  ├── README.md
261
386
  ├── LICENSE
262
387
  ├── tsconfig.json
263
388
  └── package.json
264
389
  ```
265
390
 
266
- The package exposes its Pi extension through `package.json`:
391
+ Only `subagents.ts` is a Pi entrypoint; the other source modules are internal. The package exposes its Pi extension through `package.json`:
267
392
 
268
393
  ```json
269
394
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-subagents",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Pi extension for delegating work to specialized isolated subagents.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -38,8 +38,8 @@
38
38
  "@earendil-works/pi-tui": "*"
39
39
  },
40
40
  "devDependencies": {
41
- "@biomejs/biome": "2.5.2",
42
- "@earendil-works/pi-agent-core": "0.80.3",
41
+ "@biomejs/biome": "2.5.3",
42
+ "@earendil-works/pi-agent-core": "0.80.6",
43
43
  "@earendil-works/pi-ai": "0.80.3",
44
44
  "@earendil-works/pi-coding-agent": "0.80.3",
45
45
  "@earendil-works/pi-tui": "0.80.3",
package/src/agents.ts CHANGED
@@ -38,8 +38,25 @@ export interface SubagentAgentConfig {
38
38
  timeoutMs?: number | null;
39
39
  }
40
40
 
41
+ export type SubagentTransportKind = "subprocess" | "in-process";
42
+
43
+ export interface SubagentRuntimeSettings {
44
+ enabled?: boolean;
45
+ transport?: SubagentTransportKind;
46
+ maxAgents?: number;
47
+ maxActiveTurns?: number;
48
+ maxDepth?: number;
49
+ maxChildrenPerAgent?: number;
50
+ maxMailboxMessages?: number;
51
+ maxMailboxMessageBytes?: number;
52
+ idleTtlMs?: number;
53
+ retentionDays?: number;
54
+ maxStoredAgents?: number;
55
+ }
56
+
41
57
  export interface SubagentSettings {
42
58
  agents?: Record<string, SubagentAgentConfig>;
59
+ stateful?: SubagentRuntimeSettings;
43
60
  }
44
61
 
45
62
  const BUILT_IN_AGENTS: AgentConfig[] = [
@@ -0,0 +1,271 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { DynamicBorder } from "@earendil-works/pi-coding-agent";
3
+ import {
4
+ Container,
5
+ Key,
6
+ matchesKey,
7
+ SelectList,
8
+ type SelectItem,
9
+ Spacer,
10
+ Text,
11
+ truncateToWidth,
12
+ } from "@earendil-works/pi-tui";
13
+ import { discoverAgents, type SubagentSettings } from "./agents.js";
14
+ import {
15
+ hasAnyAgentOverride,
16
+ hasOwn,
17
+ readSubagentSettings,
18
+ sameToolSet,
19
+ saveSubagentConfig,
20
+ uniqueToolNames,
21
+ } from "./settings.js";
22
+
23
+ class ToolToggleList {
24
+ private items: { name: string; selected: boolean }[];
25
+ private cursor = 0;
26
+ private cachedWidth?: number;
27
+ private cachedLines?: string[];
28
+ onDone?: (selected: string[]) => void;
29
+ onCancel?: () => void;
30
+
31
+ constructor(tools: string[], selected: Set<string>) {
32
+ this.items = tools.map((name) => ({ name, selected: selected.has(name) }));
33
+ }
34
+
35
+ private getSelectedNames(): string[] {
36
+ return this.items.filter((i) => i.selected).map((i) => i.name);
37
+ }
38
+
39
+ handleInput(data: string): void {
40
+ if (matchesKey(data, Key.escape)) {
41
+ this.onCancel?.();
42
+ return;
43
+ }
44
+ if (data === "s" || data === "S") {
45
+ this.onDone?.(this.getSelectedNames());
46
+ return;
47
+ }
48
+ if (this.items.length === 0) return;
49
+
50
+ if (matchesKey(data, Key.up) && this.cursor > 0) {
51
+ this.cursor--;
52
+ this.invalidate();
53
+ } else if (matchesKey(data, Key.down) && this.cursor < this.items.length - 1) {
54
+ this.cursor++;
55
+ this.invalidate();
56
+ } else if (matchesKey(data, Key.enter) || matchesKey(data, Key.space)) {
57
+ this.items[this.cursor].selected = !this.items[this.cursor].selected;
58
+ this.invalidate();
59
+ }
60
+ }
61
+
62
+ render(width: number): string[] {
63
+ if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
64
+ this.cachedWidth = width;
65
+ this.cachedLines = this.items.map((item, i) => {
66
+ const pointer = i === this.cursor ? ">" : " ";
67
+ const check = item.selected ? "✓" : "○";
68
+ return truncateToWidth(`${pointer} ${check} ${item.name}`, width);
69
+ });
70
+ return this.cachedLines;
71
+ }
72
+
73
+ invalidate(): void {
74
+ this.cachedWidth = undefined;
75
+ this.cachedLines = undefined;
76
+ }
77
+ }
78
+
79
+ export function registerSubagentConfigCommand(pi: ExtensionAPI) {
80
+ pi.registerCommand("subagents:config", {
81
+ description: "Configure which tools each subagent can use",
82
+ handler: async (_args, ctx) => {
83
+ if (!ctx.hasUI) {
84
+ return;
85
+ }
86
+
87
+ // Get current settings
88
+ const currentSettings = readSubagentSettings() ?? {};
89
+ const currentAgents = currentSettings.agents ?? {};
90
+
91
+ // Discover agents to show which ones are available
92
+ const discovery = discoverAgents(ctx.cwd, "user", currentSettings);
93
+ const agents = discovery.agents;
94
+
95
+ if (agents.length === 0) {
96
+ ctx.ui.notify("No agents found", "warning");
97
+ return;
98
+ }
99
+
100
+ // Loop: agent selection → tool toggle (Esc in tools returns here)
101
+ while (true) {
102
+ // Step 1: pick an agent to configure
103
+ const agentItems: SelectItem[] = agents.map((a) => {
104
+ const cfg = currentAgents[a.name];
105
+ const hasToolsOverride = cfg ? hasOwn(cfg, "tools") : false;
106
+ const toolSummary = hasToolsOverride
107
+ ? cfg?.tools && cfg.tools.length > 0
108
+ ? cfg.tools.join(", ")
109
+ : "none"
110
+ : "defaults";
111
+ return {
112
+ value: a.name,
113
+ label: a.name,
114
+ description: `${a.source} · tools: ${toolSummary}`,
115
+ };
116
+ });
117
+
118
+ const agentName = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
119
+ const container = new Container();
120
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
121
+ container.addChild(
122
+ new Text(theme.fg("accent", theme.bold("Subagent Tool Configuration")), 1, 0),
123
+ );
124
+ container.addChild(new Spacer(1));
125
+ container.addChild(
126
+ new Text(theme.fg("muted", "Select an agent to configure its allowed tools:"), 1, 0),
127
+ );
128
+ container.addChild(new Spacer(1));
129
+ const selectList = new SelectList(agentItems, Math.min(agentItems.length + 2, 15), {
130
+ selectedPrefix: (t: string) => theme.fg("accent", t),
131
+ selectedText: (t: string) => theme.fg("accent", t),
132
+ description: (t: string) => theme.fg("muted", t),
133
+ scrollInfo: (t: string) => theme.fg("dim", t),
134
+ noMatch: (t: string) => theme.fg("warning", t),
135
+ });
136
+ selectList.onSelect = (item) => done(item.value);
137
+ selectList.onCancel = () => done(null);
138
+ container.addChild(selectList);
139
+ container.addChild(
140
+ new Text(theme.fg("dim", "↑↓ navigate · enter select · esc cancel"), 1, 0),
141
+ );
142
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
143
+ return {
144
+ render: (w: number) => container.render(w),
145
+ invalidate: () => container.invalidate(),
146
+ handleInput: (data: string) => {
147
+ selectList.handleInput(data);
148
+ tui.requestRender();
149
+ },
150
+ };
151
+ });
152
+
153
+ if (!agentName) return;
154
+
155
+ const agent = agents.find((a) => a.name === agentName);
156
+ if (!agent) return;
157
+
158
+ // Step 2: toggle tools for the selected agent
159
+ // Discover without overrides to get original built-in/frontmatter defaults.
160
+ // The main discovery above applies saved overrides, so agent.tools is already
161
+ // overridden — using it for the reset-to-default comparison would match the
162
+ // override against itself and silently delete it on a no-op save.
163
+ const defaultDiscovery = discoverAgents(ctx.cwd, "user");
164
+ const defaultTools = defaultDiscovery.agents.find((a) => a.name === agentName)?.tools;
165
+ const currentAgentSettings = currentAgents[agentName];
166
+ const configuredTools =
167
+ currentAgentSettings && hasOwn(currentAgentSettings, "tools")
168
+ ? (currentAgentSettings.tools ?? [])
169
+ : undefined;
170
+
171
+ // Get all available tools from pi's registry
172
+ const allTools = uniqueToolNames(pi.getAllTools().map((t) => t.name)).sort((a, b) =>
173
+ a.localeCompare(b),
174
+ );
175
+ const currentTools = uniqueToolNames(configuredTools ?? defaultTools ?? allTools);
176
+ // Sort: currently selected tools first, then rest alphabetically. Preserve
177
+ // unavailable configured tools so saving does not silently drop them.
178
+ const currentSet = new Set(currentTools);
179
+ const selectedFirst = [...currentTools, ...allTools.filter((t) => !currentSet.has(t))];
180
+
181
+ const selectedTools = await ctx.ui.custom<string[] | null>((tui, theme, _kb, done) => {
182
+ const toggleList = new ToolToggleList(selectedFirst, currentSet);
183
+
184
+ const container = new Container();
185
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
186
+ container.addChild(
187
+ new Text(
188
+ theme.fg("accent", theme.bold(`${agentName} tools`)) +
189
+ theme.fg("muted", ` (${agent.source})`),
190
+ 1,
191
+ 0,
192
+ ),
193
+ );
194
+ container.addChild(new Spacer(1));
195
+ container.addChild(
196
+ new Text(theme.fg("muted", "Toggle tools with Enter/Space. S to save, Esc to cancel."), 1, 0),
197
+ );
198
+ container.addChild(new Spacer(1));
199
+
200
+ const listContainer = new Container();
201
+ listContainer.addChild({
202
+ render: (w: number) => toggleList.render(w),
203
+ invalidate: () => toggleList.invalidate(),
204
+ });
205
+ container.addChild(listContainer);
206
+
207
+ container.addChild(new Spacer(1));
208
+ container.addChild(
209
+ new Text(theme.fg("dim", "↑↓ navigate · enter/space toggle · S save · esc cancel"), 1, 0),
210
+ );
211
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
212
+
213
+ toggleList.onDone = (tools) => done(tools);
214
+ toggleList.onCancel = () => done(null);
215
+
216
+ return {
217
+ render: (w: number) => container.render(w),
218
+ invalidate: () => container.invalidate(),
219
+ handleInput: (data: string) => {
220
+ toggleList.handleInput(data);
221
+ tui.requestRender();
222
+ },
223
+ };
224
+ });
225
+
226
+ // null means user cancelled — loop back to agent selection
227
+ if (selectedTools === null) continue;
228
+
229
+ // Save to global settings
230
+ const updatedAgents = { ...currentAgents };
231
+ let restoredDefaults = false;
232
+
233
+ const isSameAsDefault =
234
+ defaultTools === undefined
235
+ ? sameToolSet(selectedTools, allTools)
236
+ : sameToolSet(selectedTools, defaultTools);
237
+
238
+ if (isSameAsDefault) {
239
+ // Tools match defaults — remove only the tools override.
240
+ // Keep other settings (model, timeoutMs) if present.
241
+ const existing = updatedAgents[agentName];
242
+ if (existing) {
243
+ const nextConfig = { ...existing };
244
+ delete nextConfig.tools;
245
+ if (hasAnyAgentOverride(nextConfig)) updatedAgents[agentName] = nextConfig;
246
+ else delete updatedAgents[agentName];
247
+ }
248
+ restoredDefaults = true;
249
+ } else {
250
+ updatedAgents[agentName] = {
251
+ ...updatedAgents[agentName],
252
+ tools: selectedTools,
253
+ };
254
+ }
255
+
256
+ const newSettings: SubagentSettings = {
257
+ ...currentSettings,
258
+ agents: Object.keys(updatedAgents).length > 0 ? updatedAgents : undefined,
259
+ };
260
+
261
+ saveSubagentConfig(newSettings);
262
+ const message = restoredDefaults
263
+ ? `${agentName}: defaults restored`
264
+ : `${agentName}: ${selectedTools.length} tool${selectedTools.length !== 1 ? "s" : ""} configured`;
265
+ ctx.ui.notify(message, "info");
266
+ // Saved — exit the loop
267
+ break;
268
+ }
269
+ },
270
+ });
271
+ }
package/src/context.ts ADDED
@@ -0,0 +1,129 @@
1
+ import { createHash } from "node:crypto";
2
+ import {
3
+ DEFAULT_MAX_CONTEXT_BYTES,
4
+ truncateUtf8,
5
+ truncateUtf8Tail,
6
+ } from "./limits.js";
7
+
8
+ export type ContextMode = "none" | "all" | "summary" | number;
9
+
10
+ export interface ContextSnapshot {
11
+ text: string;
12
+ turns: number;
13
+ truncated: boolean;
14
+ sourceIds: string[];
15
+ }
16
+
17
+ function textParts(content: unknown): string {
18
+ if (typeof content === "string") return content;
19
+ if (!Array.isArray(content)) return "";
20
+ return content
21
+ .filter((part): part is { type: "text"; text: string } =>
22
+ Boolean(
23
+ part &&
24
+ typeof part === "object" &&
25
+ (part as { type?: unknown }).type === "text" &&
26
+ typeof (part as { text?: unknown }).text === "string",
27
+ ),
28
+ )
29
+ .map((part) => part.text)
30
+ .join("\n");
31
+ }
32
+
33
+ export function redactPrivateText(text: string): string {
34
+ const tagPattern = /<\/?private>/gi;
35
+ let redacted = "";
36
+ let cursor = 0;
37
+ let depth = 0;
38
+ for (const match of text.matchAll(tagPattern)) {
39
+ const tag = match[0].toLowerCase();
40
+ const index = match.index ?? cursor;
41
+ if (depth === 0) redacted += text.slice(cursor, index);
42
+ if (tag === "<private>") {
43
+ if (depth === 0) redacted += "[private content omitted]";
44
+ depth++;
45
+ } else if (depth > 0) {
46
+ depth--;
47
+ } else {
48
+ redacted += match[0];
49
+ }
50
+ cursor = index + match[0].length;
51
+ }
52
+ if (depth === 0) redacted += text.slice(cursor);
53
+ return redacted
54
+ .split("\n")
55
+ .filter((line) => !line.includes("[subagent-private]"))
56
+ .join("\n");
57
+ }
58
+
59
+ export function buildContextSnapshot(
60
+ entries: readonly unknown[],
61
+ mode: ContextMode,
62
+ maxBytes = DEFAULT_MAX_CONTEXT_BYTES,
63
+ selectedSourceIds?: readonly string[],
64
+ ): ContextSnapshot {
65
+ if (mode === "none") return { text: "", turns: 0, truncated: false, sourceIds: [] };
66
+ const messages: Array<{ role: "user" | "assistant"; text: string; sourceId: string }> = [];
67
+ for (const entry of entries) {
68
+ if (!entry || typeof entry !== "object") continue;
69
+ const candidate = entry as {
70
+ id?: string;
71
+ type?: string;
72
+ role?: string;
73
+ content?: unknown;
74
+ message?: { role?: string; content?: unknown };
75
+ };
76
+ const message: { role?: string; content?: unknown } | undefined =
77
+ candidate.type === "message" ? candidate.message : candidate;
78
+ if (message?.role !== "user" && message?.role !== "assistant") continue;
79
+ const text = redactPrivateText(textParts(message.content));
80
+ if (text.trim()) {
81
+ const sourceId =
82
+ candidate.id ??
83
+ createHash("sha256").update(`${message.role}\0${text}`).digest("hex").slice(0, 16);
84
+ messages.push({ role: message.role, text, sourceId });
85
+ }
86
+ }
87
+ const seenSourceIds = new Set<string>();
88
+ const uniqueMessages = messages.filter((message) => {
89
+ if (seenSourceIds.has(message.sourceId)) return false;
90
+ seenSourceIds.add(message.sourceId);
91
+ return true;
92
+ });
93
+ const selectedSet = selectedSourceIds ? new Set(selectedSourceIds) : undefined;
94
+ const eligible = selectedSet
95
+ ? uniqueMessages.filter((message) => selectedSet.has(message.sourceId))
96
+ : uniqueMessages;
97
+ const turnLimit =
98
+ typeof mode === "number" ? Math.max(1, Math.floor(mode)) : Number.POSITIVE_INFINITY;
99
+ let userTurns = 0;
100
+ let start = eligible.length;
101
+ for (let index = eligible.length - 1; index >= 0; index--) {
102
+ if (eligible[index].role === "user") userTurns++;
103
+ if (userTurns > turnLimit) break;
104
+ start = index;
105
+ }
106
+ const selected = eligible.slice(start);
107
+ const raw =
108
+ mode === "summary" && selected.length > 4
109
+ ? [
110
+ `## Earlier context checkpoint\n${truncateUtf8(
111
+ selected
112
+ .slice(0, -4)
113
+ .map((message) => `${message.role}: ${message.text}`)
114
+ .join("\n"),
115
+ Math.floor(maxBytes / 3),
116
+ ).text}`,
117
+ ...selected
118
+ .slice(-4)
119
+ .map((message) => `## ${message.role}\n${message.text}`),
120
+ ].join("\n\n")
121
+ : selected.map((message) => `## ${message.role}\n${message.text}`).join("\n\n");
122
+ const bounded = mode === "summary" ? truncateUtf8Tail(raw, maxBytes) : truncateUtf8(raw, maxBytes);
123
+ return {
124
+ text: bounded.text,
125
+ turns: selected.filter((message) => message.role === "user").length,
126
+ truncated: bounded.truncated,
127
+ sourceIds: selected.map((message) => message.sourceId),
128
+ };
129
+ }