@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 +137 -12
- package/package.json +3 -3
- package/src/agents.ts +17 -0
- package/src/config-ui.ts +271 -0
- package/src/context.ts +129 -0
- package/src/execution.ts +453 -0
- package/src/in-process-transport.ts +598 -0
- package/src/limits.ts +62 -0
- package/src/orchestration.ts +164 -0
- package/src/params.ts +59 -0
- package/src/persistence.ts +207 -0
- package/src/protocol.ts +76 -0
- package/src/registry.ts +759 -0
- package/src/render.ts +521 -0
- package/src/runner.ts +496 -0
- package/src/settings.ts +166 -0
- package/src/stateful-prompt.ts +43 -0
- package/src/stateful.ts +838 -0
- package/src/subagents.ts +36 -1682
- package/src/subprocess-transport.ts +50 -0
- package/src/transport.ts +30 -0
- package/src/workspace.ts +116 -0
package/README.md
CHANGED
|
@@ -2,21 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@narumitw/pi-subagents) [](https://pi.dev) [](./LICENSE)
|
|
4
4
|
|
|
5
|
-
`@narumitw/pi-subagents` is a native [Pi coding agent](https://pi.dev) extension
|
|
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
|
|
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
|
-
-
|
|
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
|
|
46
|
+
`pi-subagents` registers a primary batch tool plus the stateful lifecycle tools documented below:
|
|
42
47
|
|
|
43
|
-
- `subagent` — delegate
|
|
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
|
|
68
|
-
review
|
|
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
|
|
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
|
|
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
|
-
│
|
|
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.
|
|
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.
|
|
42
|
-
"@earendil-works/pi-agent-core": "0.80.
|
|
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[] = [
|
package/src/config-ui.ts
ADDED
|
@@ -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
|
+
}
|