@d3ara1n/pi-subagent 2.2.0 → 3.1.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
@@ -8,22 +8,30 @@ Provides a `subagent_delegate` tool that lets the main model offload tasks to sp
8
8
 
9
9
  **The main model is the decision maker; subagents are executors.**
10
10
 
11
- Your primary AI has the most complete context — it knows the full conversation history, project structure, and task at hand. Subagents are spawned with **clean, isolated contexts** to handle specific, well-defined tasks without polluting the main model's context window.
11
+ Your primary AI has the most complete context — it knows the full conversation history, project structure, and task at hand. Subagents are isolated by default to handle specific, well-defined tasks without polluting the main model's context window. A caller can explicitly opt into a filtered, text-only snapshot of the active parent branch when a task genuinely depends on prior dialogue.
12
12
 
13
13
  This means:
14
14
  - **Subagents don't plan** — the main model decides what needs to be done and provides a clear task description
15
15
  - **Subagents don't orchestrate the overall plan** — the main model decides what to do and examines each result to pick the next move; nested delegation (worker → explorer) only offloads self-contained exploration/research inside one task
16
- - **Subagents don't inherit history** — they don't need the full conversation; just a precise task description
16
+ - **Subagents are isolated by default** — give them a precise, self-contained task; use `inheritConversation` only when prior dialogue is necessary
17
17
  - **Multiple subagents can run in parallel** — emit multiple `subagent_delegate` calls in one turn; pi executes them concurrently
18
18
  - **Subagents can nest subagents** — a `worker` can delegate exploration to `explorer` without returning to the main model
19
19
 
20
20
  > This design currently focuses on single-task delegation rather than chain pipelines or context-forking — those patterns fit better when subagents act as advisors (planner, oracle) rather than executors.
21
21
 
22
+ ## Model Compatibility
23
+
24
+ Observations on how main models behave with this plugin, one family per subsection. Subagent role models are configured separately via [pi-model-roles](../pi-model-roles); the notes below concern the **main model** — the orchestrator that decides when to delegate.
25
+
26
+ ### GPT family
27
+
28
+ **Not recommended.** As of GPT 5.6, GPT models delegate pathologically: they abandon built-in tools (`read`, `edit`, `write`) and MCP entirely and route everything through subagents — `explorer` to read files, `worker` to modify them, `reviewer` to verify each change, then worker → reviewer → worker correction loops over and over. Every task a direct handful of tool calls would finish turns into a long delegate chain, wasting large amounts of time and tokens. Use a main model that treats direct tool calls as the default and delegation as the exception.
29
+
22
30
  ## How it works
23
31
 
24
32
  1. Main model calls the `subagent_delegate` tool with a role and task description
25
33
  2. The extension resolves the role to a model via pi-model-roles
26
- 3. Spawns an isolated pi child process in RPC mode (`--mode rpc`) with the configured model, tools, and system prompt agent events stream back over stdout while stdin carries the initial prompt and mid-run steering commands. Children are headless: interactive extension dialogs (`ctx.ui.select/confirm/input`) are answered automatically with `cancelled` (standard "user declined" semantics), so an extension that asks never hangs the run
34
+ 3. Spawns a pi child process in RPC mode (`--mode rpc`, always without a reused parent session) with the configured model, tools, and system prompt. It is isolated by default; `inheritConversation: true` adds a filtered parent-branch snapshot to its initial stdin prompt. Agent events stream back over stdout while stdin carries the initial prompt and mid-run steering commands. Children are headless: interactive extension dialogs (`ctx.ui.select/confirm/input`) are answered automatically with `cancelled` (standard "user declined" semantics), so an extension that asks never hangs the run
27
35
  4. **Real-time TUI progress** shows tool calls, turns, and elapsed time as the subagent runs
28
36
  5. After completion, an **AI-generated one-line summary** is produced for compact display
29
37
  6. Returns the result to the main model with usage statistics (turns, tokens, cost)
@@ -45,7 +53,7 @@ This means:
45
53
 
46
54
  ## TUI Display
47
55
 
48
- - **During execution**: the task's first line with a ⏳ (or ⏸ queued) indicator, a live stream of thinking blocks and tool calls (latest 5 collapsed, everything expanded), and a usage line (elapsed/budget time, turns, tokens, peak context, cost, model)
56
+ - **During execution**: the task's first line with a ⏳ (or ⏸ queued) indicator, a live stream of thinking blocks and tool calls (latest 5 collapsed, everything expanded), and a usage line (elapsed/budget time, turns, tokens, peak context, cost, model). Delegates using inherited conversation are marked in the tool-call title.
49
57
  - **Collapsed result**: the task's first line, then `✓` + the AI-generated summary (or the first line of the output), then the usage line — no activity replay
50
58
  - **Expanded result** (Ctrl+O): reference files, context size, the full task, the complete activity stream, the final output as rendered Markdown, and usage details
51
59
  - **Fallback trace**: when a provider error (429, quota, timeout, ...) kills a run and it is retried on the role's `fallbackRole`, a `⚠ fallback: first attempt <model> failed (<reason>)` line appears in both views — also while the retry is running (see [Fallback observability](#fallback-observability))
@@ -56,7 +64,7 @@ This means:
56
64
  |---------|-------------|
57
65
  | `/subagent:view` | Open the live view: a tabbed overlay with a per-run activity feed and a brief detail page (inputs, files, stats), plus modal steer input for the focused run |
58
66
  | `/subagent:doctor` | Diagnose pi invocation, model-role resolution, configuration, and role references |
59
- | `/subagent:status` | List background runs (active + collected) and their current state |
67
+ | `/subagent:status` | List background runs and their current state |
60
68
  | `/subagent:cancel <id\|all> [reason]` | Cancel a live background run (or every live run); the optional reason is recorded with the run |
61
69
 
62
70
  ### Live view (`/subagent:view`)
@@ -65,7 +73,7 @@ A centered overlay covering most of the screen. A tab row across the top lists e
65
73
 
66
74
  The **activity page** (default) is the run's live feed: a continuous, append-only list where each entry is static text with a state icon, and the only animated thing is the ellipsis on a running entry (`.` → `..` → `...`). Finishing freezes an entry in place — its position never changes, only the icon flips. Streamed assistant text grows in place as the run's last line and settles into plain terminal-colored text at the turn boundary. The feed is scrollable (`↑↓`, `PgUp/PgDn`, `Home`/`End`): the view pins to the end and auto-follows new entries; scrolling up unpins (a `⋮ N earlier` marker appears), and reaching the bottom again re-pins. Both foreground and background runs appear here; a foreground run stays listed while its delegate call blocks the main agent. A run leaves the view once its result is in the conversation — when the last one goes, the overlay shows a centered empty notice (with `Esc close` hinted) rather than shrinking away.
67
75
 
68
- The **brief page** shows the run's inputs and vitals at full width: the task and context verbatim (wrapped; head+tail elided beyond 20k chars), the reference file list annotated with `✓`/`·` for whether the child's tool calls actually touched each file, usage and time stats, the fallback trace, and a stderr tail on failures.
76
+ The **brief page** shows the run's inputs and vitals at full width: the task and context verbatim (wrapped; head+tail elided beyond 20k chars), inherited-conversation size and truncation status when enabled (never its text), the reference file list annotated with `✓`/`·` for whether the child's tool calls actually touched each file, usage and time stats, the fallback trace, and a stderr tail on failures.
69
77
 
70
78
  Steer input is modal so keys never conflict with typing: in browse mode `s` opens the editor, `Enter` queues the message into the focused run (only while it is running) and returns to browse, `Esc` cancels and clears. The message appears immediately in the feed as an `↩ steer:` entry and is delivered to the child after its current tool batch, before its next LLM call — the run keeps its progress. `Esc` in browse mode closes the overlay.
71
79
 
@@ -108,16 +116,19 @@ Edit `~/.pi/agent/settings.json`:
108
116
  "summary": {
109
117
  "role": "utility",
110
118
  "enabled": true
119
+ },
120
+ "inheritance": {
121
+ "maxChars": 50000
111
122
  }
112
123
  }
113
124
  }
114
125
  ```
115
126
 
116
- All fields are optional. Defaults: `maxConcurrency: 4`, `maxDepth: 3`, `maxTurns: 0` (unlimited), `maxCost: 0` (unlimited), `history.enabled: true`, `summary.role: "utility"`, `summary.enabled: true`.
127
+ All fields are optional. Defaults: `maxConcurrency: 4`, `maxDepth: 3`, `maxTurns: 0` (unlimited), `maxCost: 0` (unlimited), `history.enabled: true`, `summary.role: "utility"`, `summary.enabled: true`, and `inheritance.maxChars: 50000`.
117
128
 
118
129
  Timeouts are defined per role. Built-in defaults are `explorer: 900`, `reviewer: 3600`, `worker: 2400`, and `researcher: 2400` seconds. The timeout is active time — the clock pauses while the child is inside a nested `subagent_delegate` call, so delegate-capable roles need no extra headroom.
119
130
 
120
- All numeric limits accept `0` for unlimited: `maxConcurrency`, `maxDepth`, `maxTurns`, `maxCost`, and per-role `timeout`. Negative values are normalized to `0`; non-numeric or non-finite values fall back to their defaults. `maxConcurrency: 0` runs delegates without queuing, and `maxDepth: 0` permits unrestricted nesting.
131
+ `maxConcurrency`, `maxDepth`, `maxTurns`, `maxCost`, and per-role `timeout` accept `0` for unlimited. Negative values are normalized to `0`; non-numeric or non-finite values fall back to their defaults. `inheritance.maxChars` is different: it must be a positive finite integer, and zero, negative, invalid, or non-finite values use the default. `maxConcurrency: 0` runs delegates without queuing, and `maxDepth: 0` permits unrestricted nesting.
121
132
 
122
133
  ### Agent Overrides
123
134
 
@@ -194,6 +205,26 @@ Delegate tasks that would generate many tool calls or verbose output to keep you
194
205
  ]
195
206
  ```
196
207
 
208
+ ### Inheriting parent conversation
209
+
210
+ `inheritConversation` is opt-in and defaults to `false`. Use it when a delta task relies on the active parent dialogue and repeating that material would be impractical:
211
+
212
+ ```json
213
+ {
214
+ "role": "worker",
215
+ "task": "Implement the approved approach and satisfy the acceptance checklist above.",
216
+ "inheritConversation": true
217
+ }
218
+ ```
219
+
220
+ At delegate execution, pi-subagent snapshots `buildContextEntries()` for the active branch. It serializes only compaction summaries, branch summaries, and text blocks from user and assistant messages. Thinking, images, tool calls and arguments, tool results, custom UI/state messages, bash messages, and model metadata are excluded. Newer compaction entries with a `retainedTail` are supported; older `firstKeptEntryId` compactions use the separately returned kept entries.
221
+
222
+ The inherited body is mechanically limited by `inheritance.maxChars` (default 50,000). When it is too long, pi-subagent retains summary context and the newest dialogue, inserting an omission marker. No model call summarizes this input. The child receives it in an independent `<inherited_conversation>` block after `files` and before explicit `context` and `task`; the task remains authoritative. Inherited conversation can be incomplete, so the child must report missing material rather than guess.
223
+
224
+ The tool-call title marks inherited runs. Expanded delegate input and `/subagent:view`'s brief page show the delivered character count plus `truncated` when applicable, next to the other input metadata; an enabled snapshot with no eligible text is shown as empty. The inherited body is never rendered or written to subagent history. History records only the safe inheritance flag, character count, and truncation status.
225
+
226
+ Keep the default isolated mode for focused work. Without inheritance, `task`, `context`, and `files` must be self-contained.
227
+
197
228
  ## Background Delegation
198
229
 
199
230
  Three execution properties, kept separate:
@@ -229,15 +260,15 @@ Typical flow:
229
260
 
230
261
  Semantics worth knowing:
231
262
 
232
- - **Results are pull-only for the model.** A purple completion notice is shown to the user, but nothing delivers the result to the model or wakes it up. The notice is a pure notification in the same visual family as pi's `[compaction]` card — a `[subagent] id (role) outcome` header with the bare task preview beneath, each line truncated to the terminal width — and deliberately unlike the tool rows, so it never reads as model behavior; the result itself never appears in the notice, only in `subagent_check` (model) or `/subagent:status` (user). The model owns the collection point: `subagent_wait`, then `subagent_check` each run. The inbox reminder (below) lists unclaimed runs on every request, but it never pushes results.
263
+ - **Results are pull-only for the model.** A purple completion notice is shown to the user, but nothing delivers the result to the model or wakes it up. The notice is a pure notification in the same visual family as pi's `[compaction]` card — a `[subagent] id (role) outcome` header with the bare task preview beneath, each line truncated to the terminal width — and deliberately unlike the tool rows, so it never reads as model behavior; the result itself never appears in the notice, only in `subagent_check` (model) or `/subagent:status` (user). The model owns the collection point: `subagent_wait`, then `subagent_check` each run. The inbox reminder (below) lists runs not yet checked on the active branch on every request, but it never pushes results.
233
264
  - **Background runs survive turn cancellation** and are unaffected by a cancelled `subagent_wait` — cancelling the wait never cancels the runs; call `subagent_wait` or `subagent_check` again later.
234
- - **Read-once collection:** `subagent_check` on a terminal run returns the result and frees it the output now lives in the conversation history, and only a lightweight tombstone stays in the registry (`/subagent:status` lists it under "Collected"). Re-checking a collected id explains that its result is already in the history.
235
- - **Cancellation keeps the partial output.** `subagent_cancel(id, reason?)` kills the child (SIGTERM, escalating to SIGKILL) and settles the run as `cancelled` — its own stop reason in the same family as `timeout`/`budget_exceeded` (TUI warning styling ⏹, not the error-red ✗ of real failures) — with whatever it had produced. The `reason` becomes the error message verbatim, so whoever reads the partial output later via `subagent_check` — or the audit history — sees `cancelled — <reason>`; the source is distinguishable too (`user: ...` for `/subagent:cancel`, the model's own words for the tool, `session shutdown` for reaping). Cancelling does not collect: `subagent_check` still returns the partial output once, and `subagent_wait` reports the run as `cancelled (partial output kept)`.
236
- - **Inbox reminder:** every LLM call carries a `[background subagent runs]` system reminder listing the unclaimed runs (queued, running, and finished-but-unchecked alike, including cancelled ones — shown as `cancelled — <reason>`), injected at a cache-stable head position. Runs missing from the list were already collected — so a finished run the model forgot to check keeps surfacing until it does.
265
+ - **Idempotent check, session-tree delivery state:** `subagent_check` re-delivers the same terminal snapshot on every call runs stay in the registry for the whole session, so no result can ever be stranded by branch navigation or compaction. Whether a run still needs collecting is not tracked in the registry: it derives from the session tree itself. The session is append-only, so branching back past a check entry drops it from the active path — the inbox reminder re-arms and the model simply checks again (the id still resolves; the run is still there). Branching forward to the original branch restores the check entry and silences the reminder again.
266
+ - **Cancellation keeps the partial output.** `subagent_cancel(id, reason?)` kills the child (SIGTERM, escalating to SIGKILL) and settles the run as `cancelled` — its own stop reason in the same family as `timeout`/`budget_exceeded` (TUI warning styling ⏹, not the error-red ✗ of real failures) — with whatever it had produced. The `reason` becomes the error message verbatim, so whoever reads the partial output later via `subagent_check` — or the audit history — sees `cancelled — <reason>`; the source is distinguishable too (`user: ...` for `/subagent:cancel`, the model's own words for the tool, `session shutdown` for reaping). Cancelling does not remove the run: `subagent_check` still returns the partial output, and `subagent_wait` reports the run as `cancelled (partial output kept)`.
267
+ - **Inbox reminder:** every LLM call carries a `[background subagent runs]` system reminder listing the runs not yet checked on the active branch (queued, running, and finished-but-unchecked alike, including cancelled ones — shown as `cancelled — <reason>`), injected at a cache-stable head position. Runs missing from the list were already checked on this branch — so a finished run the model forgot to check keeps surfacing until it does. Branch navigation keeps this honest: the list derives from the session tree, not registry bookkeeping.
237
268
  - **`timeout_ms` is optional.** Without it, `subagent_wait` blocks until every run finishes; each run is still bounded by its own role timeout.
238
269
  - Background runs share the global `maxConcurrency` gate — extra runs show up as `queued` in wait/check views.
239
270
  - **Top-level only:** nested subagents cannot delegate in the background (a subagent process exits when its task finishes, which would orphan the run).
240
- - The run registry lives in the pi process: a `/reload` or restart orphans in-flight background runs (their ids stop resolving). `/subagent:status` lists every registered run (active + collected) and its current state.
271
+ - The run registry lives in the pi process: a `/reload` or restart orphans in-flight background runs (their ids stop resolving). `/subagent:status` lists every registered run and its current state.
241
272
 
242
273
  ### Steering a running subagent
243
274
 
@@ -254,7 +285,7 @@ Queued steers are visible in neither wait nor check results — they shape the r
254
285
 
255
286
  Each tool row renders one aspect of the same decomposition the foreground row shows all at once (input · process · result · usage):
256
287
 
257
- - **Background subagent_delegate row = input only.** Collapsed: `▶ sub-1 <task first line>`. Expanded: plus `@file` references, context size, and the full task text. Static — the run progresses invisibly until a subagent_wait/subagent_check row picks it up.
288
+ - **Background subagent_delegate row = input only.** Collapsed: `▶ sub-1 <task first line>`. Expanded: plus `@file` references, context size, inherited-conversation size/truncation metadata when enabled, and the full task text. Static — the run progresses invisibly until a subagent_wait/subagent_check row picks it up.
258
289
  - **subagent_wait row = process + usage.** The input line shows the id list (or `(all)`) plus the timeout ceiling (`≤30s`) when one was given. One block per watched run: status line (`⏸ queued / ⏳ running` + id + task preview; bare, icon-free once terminal), a live activity stream (collapsed keeps the latest 5 items with a leading ellipsis; expanded shows everything) and a ticking usage bar. Once a run finishes, its process stream is replaced by a **status-only** result line (`✓ finished` / `⏲ budget-exceeded with the reason` / `⏱ timed out` / `⏹ cancelled with the reason` / `✗ <reason>`) — the output itself never appears in a subagent_wait row; expanded keeps the full process stream instead. A timed-out wait freezes the view.
259
290
  - **subagent_check row = the result view.** Same block shape as subagent_wait's single-run view (no id — there is only one), but the result line shows `✓ <AI summary>` (or the budget/failure reason when the run stopped early) and the expanded view renders the **full output** — subagent_check is where the conclusion lives.
260
291
  - **subagent_cancel row = confirmation only.** Collapsed: `⏹ sub-1 (worker): cancelled after 1 turn (~29s)` (or `• sub-1 (worker) already finished — nothing to cancel` for a no-op). Expanded adds the reason and the pointer to `subagent_check` — the partial output **never renders here**; it stays in the registry until a check row fetches it (layer contract: delegate = input, wait = process, cancel = intervention, check = result).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d3ara1n/pi-subagent",
3
- "version": "2.2.0",
3
+ "version": "3.1.0",
4
4
  "type": "module",
5
5
  "description": "Role-based subagent orchestration for pi — delegates tasks to specialized pi child processes with configurable model roles",
6
6
  "main": "src/index.ts",
@@ -43,6 +43,19 @@ afterEach(() => {
43
43
  });
44
44
 
45
45
  describe("loadSubagentConfig", () => {
46
+ test("uses the inheritance default and accepts only positive finite integer maxChars", () => {
47
+ const { agentDir } = makeRoot();
48
+ writeSettings(agentDir, { subagent: { inheritance: { maxChars: 12 } } });
49
+ assert.equal(loadSubagentConfig().inheritance.maxChars, 12);
50
+
51
+ for (const maxChars of [0, -1, 0.5, 12.9, "500", false, null]) {
52
+ writeSettings(agentDir, { subagent: { inheritance: { maxChars } } });
53
+ assert.equal(loadSubagentConfig().inheritance.maxChars, DEFAULT_CONFIG.inheritance.maxChars);
54
+ }
55
+ writeSettingsText(agentDir, '{"subagent":{"inheritance":{"maxChars":1e999}}}');
56
+ assert.equal(loadSubagentConfig().inheritance.maxChars, DEFAULT_CONFIG.inheritance.maxChars);
57
+ });
58
+
46
59
  test("preserves zero limits and clamps negative numeric limits to unlimited", () => {
47
60
  const { agentDir } = makeRoot();
48
61
  writeSettings(agentDir, {
@@ -99,6 +112,7 @@ describe("loadSubagentConfig", () => {
99
112
  maxCost: 5,
100
113
  history: { enabled: false },
101
114
  summary: { enabled: false, role: "global-summary" },
115
+ inheritance: { maxChars: 1234 },
102
116
  agentOverrides: { global: { disabled: true } },
103
117
  },
104
118
  });
@@ -112,7 +126,11 @@ describe("loadSubagentConfig", () => {
112
126
  assert.equal(config.maxTurns, DEFAULT_CONFIG.maxTurns);
113
127
  assert.equal(config.maxCost, DEFAULT_CONFIG.maxCost);
114
128
  assert.deepEqual(config.history, DEFAULT_CONFIG.history);
115
- assert.deepEqual(config.summary, { enabled: DEFAULT_CONFIG.summary.enabled, role: "project-summary" });
129
+ assert.deepEqual(config.summary, {
130
+ enabled: DEFAULT_CONFIG.summary.enabled,
131
+ role: "project-summary",
132
+ });
133
+ assert.deepEqual(config.inheritance, DEFAULT_CONFIG.inheritance);
116
134
  assert.deepEqual(config.agentOverrides, {});
117
135
  });
118
136
  });
package/src/config.ts CHANGED
@@ -12,6 +12,12 @@ import type { SubagentConfig } from "./types.ts";
12
12
  import { DEFAULT_CONFIG } from "./types.ts";
13
13
  import { normalizeNonNegativeInteger, normalizeNonNegativeNumber } from "./utils.ts";
14
14
 
15
+ function normalizePositiveInteger(value: unknown, fallback: number): number {
16
+ return typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) && value > 0
17
+ ? value
18
+ : fallback;
19
+ }
20
+
15
21
  function readSettingsFile(filePath: string): any {
16
22
  try {
17
23
  const content = fs.readFileSync(filePath, "utf-8");
@@ -39,6 +45,7 @@ export function loadSubagentConfig(cwd?: string): SubagentConfig {
39
45
 
40
46
  const rawSummary = raw?.summary;
41
47
  const rawHistory = raw?.history;
48
+ const rawInheritance = raw?.inheritance;
42
49
  return {
43
50
  maxConcurrency: normalizeNonNegativeInteger(raw.maxConcurrency, DEFAULT_CONFIG.maxConcurrency),
44
51
  maxDepth: normalizeNonNegativeInteger(raw.maxDepth, DEFAULT_CONFIG.maxDepth),
@@ -51,6 +58,12 @@ export function loadSubagentConfig(cwd?: string): SubagentConfig {
51
58
  role: rawSummary?.role ?? DEFAULT_CONFIG.summary.role,
52
59
  enabled: rawSummary?.enabled ?? DEFAULT_CONFIG.summary.enabled,
53
60
  },
61
+ inheritance: {
62
+ maxChars: normalizePositiveInteger(
63
+ rawInheritance?.maxChars,
64
+ DEFAULT_CONFIG.inheritance.maxChars,
65
+ ),
66
+ },
54
67
  agentOverrides: raw.agentOverrides ?? {},
55
68
  };
56
69
  }
package/src/history.ts CHANGED
@@ -38,6 +38,9 @@ export function persistSubagentHistory(
38
38
  role,
39
39
  task,
40
40
  timestamp: Date.now(),
41
+ inheritConversation: r.inheritConversation === true,
42
+ inheritedConversationChars: r.inheritedConversationChars,
43
+ inheritedConversationTruncated: r.inheritedConversationTruncated,
41
44
  exitCode: r.exitCode,
42
45
  stopReason: r.stopReason,
43
46
  model: r.model,