@d3ara1n/pi-subagent 3.0.0 → 3.2.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)
@@ -33,19 +41,19 @@ This means:
33
41
  | Role | Model Role | Timeout | Tools | Can Delegate To | Description |
34
42
  |------|-----------|---------|-------|-----------------|-------------|
35
43
  | `explorer` | fast | 900s | read, find, grep, bash | — | Fast code exploration incl. git history inspection (read-only) |
36
- | `reviewer` | heavy | 3600s | read, bash, grep, find | | Deep code review, runs git/tests for evidence (read-only) |
44
+ | `reviewer` | heavy | 3600s | read, bash, grep, find, subagent_delegate | explorer, researcher | Deep code review, runs git/tests for evidence (read-only); delegates exploration & web verification |
37
45
  | `worker` | default | 2400s | all (no whitelist) | explorer, researcher | Implementation — the only role that can modify files; full tool access (web, MCP, everything) |
38
- | `researcher` | fast | 2400s | web_search, fetch_content, source_check, get_search_content, read, bash, edit, write, delegate | explorer | Web research + GitHub repo analysis; writes artifacts only inside its temp dir |
46
+ | `researcher` | fast | 2400s | web_search, fetch_content, source_check, get_search_content, read, bash, edit, write, subagent_delegate | explorer | Web research + GitHub repo analysis; writes artifacts only inside its temp dir |
39
47
 
40
48
  **Web tool naming**: `researcher`'s web tools use the community-standard names (`web_search`, `fetch_content`, `source_check`, `get_search_content`) shared by the most popular Pi web extensions — [pi-web-access](https://github.com/nicobailon/pi-web-access), `pi-web-tools`, `pi-browse`, and others. Install any of those and the researcher gets web access out of the box. If your web extension uses different tool names (e.g. `websearch`/`webfetch`) or you renamed the tools via a `toolNames` config, override `researcher.tools` in `agentOverrides` to match.
41
49
 
42
- **Nested delegation**: `worker` and `researcher` can spawn their own subagents. This keeps the main model's context clean — a worker can explore unfamiliar code via an `explorer` subagent without returning intermediate results to the main model.
50
+ **Nested delegation**: `worker`, `reviewer`, and `researcher` can spawn their own subagents. This keeps the caller's context clean — a worker can explore unfamiliar code via an `explorer` subagent without returning intermediate results, and a reviewer can verify third-party library behavior via a `researcher` subagent without leaving the diff.
43
51
 
44
52
  **Parallel execution**: To run multiple subagents concurrently, emit multiple `subagent_delegate` calls in a single turn. Pi's framework executes them in parallel automatically, with each subagent getting its own TUI progress display.
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))
@@ -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,21 +116,26 @@ 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
 
124
135
  Override, disable, or add subagent roles via `agentOverrides`. Built-in and custom roles are treated equally — all descriptions, examples, and decision triggers feed into the LLM's prompt dynamically.
125
136
 
137
+ The built-in roles are defined in [`src/roles.ts`](src/roles.ts) — read them as reference templates when overriding. Each entry shows the exact shape and wording of every field (`role`, `description`, `examples`, `decisionTrigger`, `tools`/`subagentRoles`, `systemPrompt`, `timeout`, `fallbackRole`), so you can copy the built-in closest to what you want, paste it under `agentOverrides`, and adjust from a known-good starting point rather than writing a role from scratch.
138
+
126
139
  ```json
127
140
  {
128
141
  "subagent": {
@@ -161,7 +174,7 @@ Override, disable, or add subagent roles via `agentOverrides`. Built-in and cust
161
174
 
162
175
  Configuring both on the same role is an error — the role is skipped with an error notification at session start.
163
176
 
164
- **Optional fields:** `subagentRoles` (roles this role can spawn via delegate), `timeout` (per-role active-time timeout in seconds; unset or `0` is unlimited, negative values normalize to `0`), `maxTurns` / `maxCost` (per-role budget overrides; unset uses the top-level `maxTurns` / `maxCost` setting, `0` is unlimited, negative values normalize to `0`), `fallbackRole` (backup pi-model-roles role the whole run is retried on after a provider error; unset means no retry — see [Fallback observability](#fallback-observability)).
177
+ **Optional fields:** `subagentRoles` (roles this role can spawn via delegate; absent means any available role, mirroring the `tools` default — declare it explicitly when a restricted role grants `subagent_delegate`), `timeout` (per-role active-time timeout in seconds; unset or `0` is unlimited, negative values normalize to `0`), `maxTurns` / `maxCost` (per-role budget overrides; unset uses the top-level `maxTurns` / `maxCost` setting, `0` is unlimited, negative values normalize to `0`), `fallbackRole` (backup pi-model-roles role the whole run is retried on after a provider error; unset means no retry — see [Fallback observability](#fallback-observability)).
165
178
 
166
179
  Invalid custom roles (missing required fields) are skipped with an error notification at session start.
167
180
 
@@ -194,6 +207,26 @@ Delegate tasks that would generate many tool calls or verbose output to keep you
194
207
  ]
195
208
  ```
196
209
 
210
+ ### Inheriting parent conversation
211
+
212
+ `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:
213
+
214
+ ```json
215
+ {
216
+ "role": "worker",
217
+ "task": "Implement the approved approach and satisfy the acceptance checklist above.",
218
+ "inheritConversation": true
219
+ }
220
+ ```
221
+
222
+ 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.
223
+
224
+ 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.
225
+
226
+ 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.
227
+
228
+ Keep the default isolated mode for focused work. Without inheritance, `task`, `context`, and `files` must be self-contained.
229
+
197
230
  ## Background Delegation
198
231
 
199
232
  Three execution properties, kept separate:
@@ -254,7 +287,7 @@ Queued steers are visible in neither wait nor check results — they shape the r
254
287
 
255
288
  Each tool row renders one aspect of the same decomposition the foreground row shows all at once (input · process · result · usage):
256
289
 
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.
290
+ - **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
291
  - **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
292
  - **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
293
  - **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": "3.0.0",
3
+ "version": "3.2.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,
package/src/index.ts CHANGED
@@ -16,11 +16,7 @@
16
16
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
17
17
  import { Type } from "typebox";
18
18
  import { getModelRolesAPI } from "@d3ara1n/pi-model-roles";
19
- import type {
20
- SubagentConfig,
21
- SubagentResult,
22
- SubagentRole,
23
- } from "./types.ts";
19
+ import type { SubagentConfig, SubagentResult, SubagentRole } from "./types.ts";
24
20
  import { DEFAULT_CONFIG } from "./types.ts";
25
21
  import { loadSubagentConfig } from "./config.ts";
26
22
  import { BUILTIN_ROLES } from "./roles.ts";
@@ -44,6 +40,7 @@ import {
44
40
  } from "./utils.ts";
45
41
  import { startSubagentRun, type RunHandle } from "./run.ts";
46
42
  import { buildInboxReminder, injectReminder } from "./reminder.ts";
43
+ import { serializeInheritedConversation } from "./inheritance.ts";
47
44
  import { renderDelegateCall, renderDelegateResult } from "./render.ts";
48
45
  import { createViewPanel } from "./view.ts";
49
46
  import {
@@ -149,11 +146,13 @@ export default function subagentExtension(pi: ExtensionAPI) {
149
146
  "- Delegate ONLY when a task involves significant work (heavy analysis, multi-step investigation, large-scope changes) AND you only care about the conclusion, not intermediate steps. A good test: the task would clutter your context with 3+ turns of raw tool output.",
150
147
  "- DO NOT delegate simple tasks — a single read, a one-line edit, a basic grep, or straightforward changes touching 1-2 files. Just do them yourself; spawning a child process costs more than the task.",
151
148
  "",
152
- "SELF-CONTAINED DELEGATION (subagents have NO memory of this conversation):",
149
+ "DELEGATION CONTEXT MODES:",
153
150
  "",
154
- "- The child sees exactly three things: task, context, files nothing else of this chat exists for it. Any requirement, decision, or constraint it must know has to be restated in those channels.",
155
- "- Never write phantom references (\"as discussed above\", \"per the requirements\") the child has never seen that material. Inline the actual content instead.",
156
- "- The isolation is deliberate: don't dump your whole conversation either. Pass what the task needs and nothing more, so the child's judgment isn't steered by irrelevant history.",
151
+ "- Self-contained (default): when task, context, and files contain everything the child needs, omit inheritConversation. The child receives no parent dialogue.",
152
+ '- Conversation-relative: when the task is intentionally written as a delta against this chat — e.g. "implement the approved approach", "review the requirements above", or "continue from our discussion" set inheritConversation: true.',
153
+ "- Never send a conversation-relative task without inheritConversation: true. Either enable inheritance or rewrite the task to be self-contained.",
154
+ "- With inheritance enabled, keep task focused on the work to perform; do not duplicate the conversation into context. Use context for additional non-conversation background and files for source material.",
155
+ "- Inherited history may be compacted or truncated. If an older detail is essential and may fall outside the retained history, include it explicitly in task or context.",
157
156
  "",
158
157
  "AVAILABLE ROLES:",
159
158
  ...entries.map(([name, role]) => ` - ${name}: ${role.description}`),
@@ -304,7 +303,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
304
303
  name: "subagent_delegate",
305
304
  label: "Delegate to subagent",
306
305
  description:
307
- "Delegate a task to a specialized subagent. By default the call blocks until the run finishes and returns the final output — intermediate tool output stays out of your context. With background: true it returns an id immediately and you collect the result later with subagent_wait/subagent_check. Subagents have no access to this conversation everything they need must arrive through the parameters.",
306
+ "Delegate a task to a specialized subagent. By default the call blocks until the run finishes and returns the final output — intermediate tool output stays out of your context. With background: true it returns an id immediately and you collect the result later with subagent_wait/subagent_check. Subagents are isolated by default; inheritConversation optionally injects a filtered snapshot of the active parent branch.",
308
307
  promptSnippet: "Delegate tasks to specialized subagents",
309
308
  promptGuidelines: guidelines,
310
309
 
@@ -312,7 +311,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
312
311
  role: Type.String({ description: "Subagent role to use" }),
313
312
  task: Type.String({
314
313
  description:
315
- "The work to do self-contained: the subagent cannot see this conversation, so restate any requirement or constraint it needs here or in `context`. Instructions only — background material belongs in `context`, reference file paths in `files`.",
314
+ "The work to do. Without conversation inheritance it must be self-contained, with every requirement or constraint restated here or in `context`; with inheritance it may be a delta against that history. Instructions only — background material belongs in `context`, reference file paths in `files`.",
316
315
  }),
317
316
  context: Type.Optional(
318
317
  Type.String({
@@ -326,6 +325,12 @@ export default function subagentExtension(pi: ExtensionAPI) {
326
325
  'Reference file paths for the subagent to read directly (e.g. ["src/auth.ts", "docs/api.md"]). Injected as @file attachments — content stays out of your context window. Prefer this over pasting file contents into context.',
327
326
  }),
328
327
  ),
328
+ inheritConversation: Type.Optional(
329
+ Type.Boolean({
330
+ description:
331
+ "Opt in to a text-only, compaction-aware snapshot of the active parent conversation. Omit or false for isolation; true lets task be a delta against inherited history, which may be filtered or truncated.",
332
+ }),
333
+ ),
329
334
  background: Type.Optional(
330
335
  Type.Boolean({
331
336
  description:
@@ -370,6 +375,13 @@ export default function subagentExtension(pi: ExtensionAPI) {
370
375
  );
371
376
  }
372
377
 
378
+ const inheritedConversation = params.inheritConversation
379
+ ? serializeInheritedConversation(
380
+ ctx.sessionManager.buildContextEntries(),
381
+ config.inheritance.maxChars,
382
+ )
383
+ : undefined;
384
+
373
385
  const run = startSubagentRun({
374
386
  id: `sub-${++runCounter}`,
375
387
  toolCallId: _toolCallId,
@@ -378,6 +390,9 @@ export default function subagentExtension(pi: ExtensionAPI) {
378
390
  task: params.task,
379
391
  context: params.context,
380
392
  files: params.files,
393
+ inheritConversation: params.inheritConversation === true,
394
+ inheritedConversation: inheritedConversation?.text,
395
+ inheritedConversationTruncated: inheritedConversation?.truncated,
381
396
  cwd: params.cwd ?? ctx.cwd,
382
397
  depth: CURRENT_DEPTH + 1,
383
398
  // Foreground runs die with the tool call; background runs outlive the turn.
@@ -430,6 +445,9 @@ export default function subagentExtension(pi: ExtensionAPI) {
430
445
  task: params.task,
431
446
  context: params.context,
432
447
  files: params.files,
448
+ inheritConversation: params.inheritConversation === true,
449
+ inheritedConversationChars: inheritedConversation?.text.length,
450
+ inheritedConversationTruncated: inheritedConversation?.truncated,
433
451
  },
434
452
  };
435
453
  }
@@ -765,7 +783,8 @@ export default function subagentExtension(pi: ExtensionAPI) {
765
783
 
766
784
  // Terminal runs cannot be cancelled — point at check instead.
767
785
  if (run.state === "finished" || run.state === "failed") {
768
- const what = run.state === "finished" ? "its result" : "the failure reason and partial output";
786
+ const what =
787
+ run.state === "finished" ? "its result" : "the failure reason and partial output";
769
788
  const text =
770
789
  `${params.id} (${run.role}) already ${run.state} — nothing to cancel. ` +
771
790
  `subagent_check(${params.id}) returns ${what}.`;
@@ -996,7 +1015,9 @@ export default function subagentExtension(pi: ExtensionAPI) {
996
1015
  // reported as already-terminal instead of cancelled.
997
1016
  const targets =
998
1017
  target === "all"
999
- ? [...backgroundRuns.values()].filter((r) => r.state === "queued" || r.state === "running")
1018
+ ? [...backgroundRuns.values()].filter(
1019
+ (r) => r.state === "queued" || r.state === "running",
1020
+ )
1000
1021
  : [backgroundRuns.get(target)!];
1001
1022
  if (targets.length === 0) {
1002
1023
  ctx.ui.notify("No live background runs to cancel.", "info");
@@ -0,0 +1,217 @@
1
+ /** Tests for deterministic parent-conversation serialization. */
2
+
3
+ import test from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { SessionManager, type SessionEntry } from "@earendil-works/pi-coding-agent";
6
+ import { serializeInheritedConversation as createSnapshot } from "./inheritance.ts";
7
+
8
+ function serializeInheritedConversation(entries: SessionEntry[], maxChars: number): string {
9
+ return createSnapshot(entries, maxChars).text;
10
+ }
11
+
12
+ function entries(items: unknown[]): SessionEntry[] {
13
+ return items as SessionEntry[];
14
+ }
15
+
16
+ const base = (type: string, id: string, extra: Record<string, unknown> = {}) => ({
17
+ type,
18
+ id,
19
+ parentId: null,
20
+ timestamp: "2026-01-01T00:00:00.000Z",
21
+ ...extra,
22
+ });
23
+
24
+ test("serializes only user/assistant text while filtering tool and UI state", () => {
25
+ const output = serializeInheritedConversation(
26
+ entries([
27
+ base("message", "u", { message: { role: "user", content: "Need a change" } }),
28
+ base("message", "a", {
29
+ message: {
30
+ role: "assistant",
31
+ content: [
32
+ { type: "thinking", thinking: "private" },
33
+ { type: "text", text: "I will delegate this." },
34
+ {
35
+ type: "toolCall",
36
+ id: "call",
37
+ name: "subagent_delegate",
38
+ arguments: { secret: "no" },
39
+ },
40
+ ],
41
+ },
42
+ }),
43
+ base("message", "tool", {
44
+ message: {
45
+ role: "toolResult",
46
+ toolName: "bash",
47
+ content: [{ type: "text", text: "secret output" }],
48
+ },
49
+ }),
50
+ base("custom_message", "ui", { content: "UI state" }),
51
+ base("custom", "state", { data: { token: "no" } }),
52
+ base("message", "bash", { message: { role: "bashExecution", output: "shell output" } }),
53
+ ]),
54
+ 10_000,
55
+ );
56
+
57
+ assert.equal(output, "[user]\nNeed a change\n\n[assistant]\nI will delegate this.");
58
+ assert.ok(!output.includes("private"));
59
+ assert.ok(!output.includes("secret"));
60
+ assert.ok(!output.includes("UI state"));
61
+ assert.ok(!output.includes("shell output"));
62
+ });
63
+
64
+ test("preserves active entry order, compaction summaries, and retained tails", () => {
65
+ const output = serializeInheritedConversation(
66
+ entries([
67
+ base("compaction", "c", {
68
+ summary: "Earlier work",
69
+ retainedTail: [
70
+ { role: "compactionSummary", summary: "Retained compacted context" },
71
+ { role: "user", content: "Kept request" },
72
+ { role: "assistant", content: [{ type: "text", text: "Kept reply" }] },
73
+ { role: "branchSummary", summary: "Retained branch context" },
74
+ ],
75
+ }),
76
+ base("branch_summary", "b", { summary: "Abandoned branch" }),
77
+ base("message", "u", { message: { role: "user", content: "Current request" } }),
78
+ base("message", "a", {
79
+ message: { role: "assistant", content: [{ type: "text", text: "Current reply" }] },
80
+ }),
81
+ ]),
82
+ 10_000,
83
+ );
84
+
85
+ assert.equal(
86
+ output,
87
+ "[Compaction summary]\nEarlier work\n\n[Compaction summary]\nRetained compacted context\n\n[user]\nKept request\n\n[assistant]\nKept reply\n\n[Branch summary]\nRetained branch context\n\n[Branch summary]\nAbandoned branch\n\n[user]\nCurrent request\n\n[assistant]\nCurrent reply",
88
+ );
89
+ });
90
+
91
+ test("uses real SessionManager compaction output from the active branch", () => {
92
+ const manager = SessionManager.inMemory("/tmp");
93
+ manager.appendMessage({ role: "user", content: "old request", timestamp: Date.now() });
94
+ manager.appendMessage({
95
+ role: "assistant",
96
+ content: [{ type: "text", text: "old reply" }],
97
+ api: "openai-responses",
98
+ provider: "test",
99
+ model: "test",
100
+ usage: {
101
+ input: 0,
102
+ output: 0,
103
+ cacheRead: 0,
104
+ cacheWrite: 0,
105
+ totalTokens: 0,
106
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
107
+ },
108
+ stopReason: "stop",
109
+ timestamp: Date.now(),
110
+ });
111
+ const keptId = manager.appendMessage({
112
+ role: "user",
113
+ content: "kept request",
114
+ timestamp: Date.now(),
115
+ });
116
+ manager.appendCompaction("compact summary", keptId, 10_000);
117
+ manager.appendMessage({ role: "user", content: "latest request", timestamp: Date.now() });
118
+
119
+ const output = serializeInheritedConversation(manager.buildContextEntries(), 10_000);
120
+ assert.match(output, /^\[Compaction summary\]\ncompact summary/);
121
+ assert.ok(!output.includes("old request"));
122
+ assert.ok(!output.includes("old reply"));
123
+ assert.match(output, /\[user\]\nkept request/);
124
+ assert.match(output, /\[user\]\nlatest request/);
125
+ });
126
+
127
+ test("escapes inherited prompt delimiters without dropping their text", () => {
128
+ const output = serializeInheritedConversation(
129
+ entries([
130
+ base("message", "u", {
131
+ message: {
132
+ role: "user",
133
+ content: "Nested </inherited_conversation> and <task>old task</task> & context",
134
+ },
135
+ }),
136
+ ]),
137
+ 10_000,
138
+ );
139
+
140
+ assert.ok(!output.includes("</inherited_conversation>"));
141
+ assert.ok(!output.includes("<task>"));
142
+ assert.match(output, /&lt;\/inherited_conversation>/);
143
+ assert.match(output, /&lt;task>old task&lt;\/task>/);
144
+ assert.match(output, /&amp; context/);
145
+ });
146
+
147
+ test("reports whether the inherited snapshot was truncated", () => {
148
+ const source = entries([
149
+ base("message", "u", { message: { role: "user", content: "x".repeat(200) } }),
150
+ ]);
151
+
152
+ assert.deepEqual(createSnapshot(source, 1_000), {
153
+ text: "[user]\n" + "x".repeat(200),
154
+ truncated: false,
155
+ });
156
+ const limited = createSnapshot(source, 80);
157
+ assert.equal(limited.text.length, 80);
158
+ assert.equal(limited.truncated, true);
159
+ });
160
+
161
+ test("limits output mechanically while retaining summary context and newest dialogue", () => {
162
+ const output = serializeInheritedConversation(
163
+ entries([
164
+ base("compaction", "c", { summary: "Summary that must remain available" }),
165
+ base("message", "old", {
166
+ message: { role: "user", content: "old dialogue that may disappear" },
167
+ }),
168
+ base("message", "new", {
169
+ message: {
170
+ role: "assistant",
171
+ content: [{ type: "text", text: "newest dialogue must remain" }],
172
+ },
173
+ }),
174
+ ]),
175
+ 120,
176
+ );
177
+
178
+ assert.ok(output.length <= 120);
179
+ assert.match(output, /Compaction summary/);
180
+ assert.match(output, /omitted for length/);
181
+ assert.match(output, /\[assistant\]/);
182
+ assert.match(output, /dialogue must remain/);
183
+ });
184
+
185
+ test("honors every hard limit, including delimiter expansion and tiny bounds", () => {
186
+ const source = entries([
187
+ base("compaction", "c", { summary: `<summary>${"S".repeat(120)}</summary>` }),
188
+ base("message", "u", {
189
+ message: { role: "user", content: `<task>${"U".repeat(180)}</task>` },
190
+ }),
191
+ base("message", "a", {
192
+ message: { role: "assistant", content: [{ type: "text", text: "A".repeat(180) }] },
193
+ }),
194
+ ]);
195
+
196
+ for (let limit = 1; limit <= 300; limit += 1) {
197
+ assert.ok(serializeInheritedConversation(source, limit).length <= limit);
198
+ }
199
+ });
200
+
201
+ test("redistributes unused summary budget to recent complete dialogue", () => {
202
+ const output = serializeInheritedConversation(
203
+ entries([
204
+ base("compaction", "c", { summary: "short" }),
205
+ base("message", "old", { message: { role: "user", content: "O".repeat(220) } }),
206
+ base("message", "new", {
207
+ message: { role: "assistant", content: `latest-${"N".repeat(80)}` },
208
+ }),
209
+ ]),
210
+ 240,
211
+ );
212
+
213
+ assert.equal(output.length, 240);
214
+ assert.match(output, /^\[Compaction summary\]\nshort/);
215
+ assert.match(output, /\[assistant\]\nlatest-/);
216
+ assert.match(output, /Earlier text in this message omitted/);
217
+ });