@maplezzk/pi-interactive-subagents 3.11.0 → 3.12.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
@@ -92,7 +92,7 @@ Subagent panes are created without stealing keyboard focus (cmux, tmux). Launch
92
92
 
93
93
  ### Extensions
94
94
 
95
- **Subagents** — 4 main-session tools + 3 commands, plus 1 subagent-only tool:
95
+ **Subagents** — 4 main-session tools + 2 commands, plus 1 subagent-only tool:
96
96
 
97
97
  | Tool | Description |
98
98
  | -------------------- | ------------------------------------------------------------------------------------------- |
@@ -104,7 +104,6 @@ Subagent panes are created without stealing keyboard focus (cmux, tmux). Launch
104
104
  | Command | Description |
105
105
  | -------------------------- | ------------------------------------ |
106
106
  | `/plan` | Start a full planning workflow |
107
- | `/iterate` | Fork into a subagent for quick fixes |
108
107
  | `/subagent <agent> <task>` | Spawn a named agent directly |
109
108
 
110
109
  ### Bundled Agents
@@ -155,26 +154,24 @@ The widget tracks each Pi-backed sub-agent from a child-written runtime snapshot
155
154
 
156
155
  These labels are no longer derived from session-file growth. Session JSONL is still used for transcript, resume, lineage, and result extraction, but Pi-backed liveness now comes from a small activity snapshot written by the child extension. A fixed internal watchdog marks a run as `stalled` when valid snapshots never appear, stop being readable, or stop matching the current child; valid long-running `active` or `waiting` states do not become `stalled` just because time passes. When a run enters `stalled` or recovers from it, the parent agent receives a steer message so it can react. All other status transitions stay in the widget only.
157
156
 
158
- **Interactive subagents stay silent.** Long-running user-driven subagents (e.g. `planner`, or any `/iterate` fork) do not wake the parent session on `stalled`/`recovered` transitions — the user is working directly in the subagent's pane, and a steer message there would just burn an orchestrator turn on a no-op "still waiting" ping. The widget still updates normally, and child snapshots are still recorded/classified regardless of the `interactive` setting. By default, agents with `auto-exit: true` are treated as autonomous and get stall pings; agents without it are treated as interactive and stay quiet. Override per-agent with `interactive: true|false` in frontmatter, or per-spawn with `interactive: true|false` on the tool call.
157
+ **Interactive subagents stay silent.** Long-running user-driven subagents (e.g. `planner`) do not wake the parent session on `stalled`/`recovered` transitions — the user is working directly in the subagent's pane, and a steer message there would just burn an orchestrator turn on a no-op "still waiting" ping. The widget still updates normally, and child snapshots are still recorded/classified regardless of the `interactive` setting. By default, agents with `auto-exit: true` are treated as autonomous and get stall pings; agents without it are treated as interactive and stay quiet. Override per-agent with `interactive: true|false` in frontmatter, or per-spawn with `interactive: true|false` on the tool call.
159
158
 
160
159
  #### Configuration
161
160
 
162
- Subagent settings, including status display and the persisted Herdr mode, are controlled by `config.json` in the extension directory. Copy `config.json.example` to get started:
163
-
164
- ```bash
165
- cp config.json.example config.json
166
- ```
161
+ The persisted Herdr mode, child-spawning policy, and explicit child-extension list are read from the user extension config at `~/.pi/agent/extensions/pi-interactive-subagents/config.json` (respecting `PI_CODING_AGENT_DIR`). The status panel's `status.enabled` is read from the installed package's `config.json`, falling back to `config.json.example`. The package's `config.json.example` documents the available fields; add `allowSubagentSpawning` and `subagentExtensions` to the user config without overwriting its existing mux settings:
167
162
 
168
163
  ```json
169
164
  {
170
165
  "herdrMode": "split",
166
+ "allowSubagentSpawning": false,
167
+ "subagentExtensions": [],
171
168
  "status": {
172
169
  "enabled": true
173
170
  }
174
171
  }
175
172
  ```
176
173
 
177
- `herdrMode` accepts `split` (default, backward-compatible pane layout) or `tab` (one background tab per subagent). The `/config:subagent herdr split|tab` command updates this field.
174
+ `herdrMode` accepts `split` (default, backward-compatible pane layout) or `tab` (one background tab per subagent). The `/config:subagent herdr split|tab` command updates this field. `allowSubagentSpawning` is a global switch for whether child subagents may create or manage other subagents; it defaults to `false`. Set it to `true` to enable the lifecycle tools in child sessions when the corresponding extension is selected. `subagentExtensions` is an optional list of extension paths to load explicitly in child sessions; automatic project/global extension discovery is disabled. Paths may be absolute, start with `~/`, or be relative to `PI_CODING_AGENT_DIR`. `subagent-done.ts` is always loaded. Explicit `deny-tools` restrictions still apply.
178
175
 
179
176
  `config.json` is gitignored so local overrides don't get committed.
180
177
 
@@ -186,10 +183,7 @@ cp config.json.example config.json
186
183
  // Named agent with defaults from agent definition
187
184
  subagent({ name: "Scout", agent: "scout", task: "Analyze the codebase..." });
188
185
 
189
- // Force a full-context fork for this spawn
190
- subagent({ name: "Iterate", fork: true, task: "Fix the bug where..." });
191
-
192
- // Agent defaults can choose a different session-mode via frontmatter
186
+ // Child sessions start fresh; use session-mode: lineage-only when lineage metadata is useful
193
187
  subagent({ name: "Planner", agent: "planner", task: "Work through the design with me" });
194
188
 
195
189
  // Custom working directory
@@ -203,7 +197,6 @@ subagent({ name: "Designer", agent: "game-designer", cwd: "agents/game-designer"
203
197
  | `name` | string | required | Display name (shown in widget and pane title) |
204
198
  | `task` | string | required | Task prompt for the sub-agent |
205
199
  | `agent` | string | — | Load defaults from agent definition |
206
- | `fork` | boolean | `false` | Force the full-context fork mode for this spawn, overriding any agent `session-mode` frontmatter |
207
200
  | `interactive` | boolean | derived | Mark this spawn as interactive (don't wake the parent on stall/recovery). Defaults to the agent's `interactive` frontmatter, otherwise the inverse of `auto-exit`. |
208
201
  | `model` | string | — | Override agent's default model |
209
202
  | `systemPrompt` | string | — | Append to system prompt |
@@ -290,18 +283,6 @@ Tab/window titles update to show current phase:
290
283
 
291
284
  ---
292
285
 
293
- ## The `/iterate` Workflow
294
-
295
- For quick, focused work without polluting the main session's context.
296
-
297
- ```
298
- /iterate Fix the off-by-one error in the pagination logic
299
- ```
300
-
301
- This always forks the current session into a subagent with full conversation context. It does not inherit an agent default `session-mode`. Make the fix, verify it, and exit to return. The main session gets a summary of what was done.
302
-
303
- ---
304
-
305
286
  ## Custom Agents
306
287
 
307
288
  Place a `.md` file in `.pi/agents/` (project) or `~/.pi/agent/agents/` (global):
@@ -314,7 +295,6 @@ model: anthropic/claude-sonnet-4-6
314
295
  thinking: minimal
315
296
  tools: read, bash, edit, write
316
297
  session-mode: lineage-only
317
- spawning: false
318
298
  ---
319
299
 
320
300
  # My Agent
@@ -332,8 +312,8 @@ You are a specialized agent that does X...
332
312
  | `thinking` | string | Thinking level: `minimal`, `medium`, `high` |
333
313
  | `tools` | string | Comma-separated **native pi tools only**: `read`, `bash`, `edit`, `write`, `grep`, `find`, `ls` |
334
314
  | `skills` | string | Comma-separated skill names to auto-load |
335
- | `session-mode` | string | Default child-session mode: `standalone`, `lineage-only`, or `fork` |
336
- | `spawning` | boolean | Set `false` to deny all subagent-spawning tools |
315
+ | `session-mode` | string | Default child-session mode: `standalone` or `lineage-only` |
316
+ | `spawning` | boolean | Legacy field retained for compatibility. Use the global `allowSubagentSpawning` setting to control child-session subagent lifecycle tools. |
337
317
  | `deny-tools` | string | Comma-separated extension tool names to deny |
338
318
  | `auto-exit` | boolean | Auto-shutdown when the agent finishes its turn — no `subagent_done` call needed. If the user sends any input, auto-exit is permanently disabled and the user takes over the session. Recommended for autonomous agents (scout, worker); not for interactive ones (planner). Also determines the default value of `interactive` (see below). |
339
319
  | `interactive` | boolean | derived | Override whether stall/recovery transitions wake the parent session. Defaults to the inverse of `auto-exit`: autonomous agents (`auto-exit: true`) are non-interactive and get stall pings; agents without `auto-exit` are interactive and stay quiet. Explicit values take precedence. |
@@ -346,15 +326,12 @@ Discovery still resolves precedence before visibility filtering. If a project-lo
346
326
 
347
327
  ### `session-mode`
348
328
 
349
- Choose how a subagent session starts:
329
+ Choose how a child session starts:
350
330
 
351
331
  - `standalone` — default fresh session with no lineage link to the caller
352
332
  - `lineage-only` — fresh blank child session with `parentSession` linkage, but no copied turns from the caller
353
- - `fork` — linked child session seeded with the caller's prior conversation context
354
333
 
355
- `lineage-only` is useful when you want session discovery and fork lineage UX to show the relationship later, but you do **not** want the child to inherit the parent's turns.
356
-
357
- `fork: true` on the tool call always forces the `fork` mode for that specific spawn. `/iterate` uses this explicit override on purpose.
334
+ All child tasks are delivered through an artifact-backed initial message. There is no full-context fork mode.
358
335
 
359
336
  ```yaml
360
337
  ---
@@ -376,7 +353,7 @@ When set to `true`, the agent session shuts down automatically as soon as the ag
376
353
  **When to use:**
377
354
 
378
355
  - ✅ Autonomous agents (scout, worker, reviewer) that run to completion
379
- - ❌ Interactive agents (planner, iterate) where the user drives the session
356
+ - ❌ Interactive agents (planner) where the user drives the session
380
357
 
381
358
  ```yaml
382
359
  ---
@@ -389,7 +366,7 @@ auto-exit: true
389
366
 
390
367
  Controls whether status transitions (`stalled`, `recovered`) wake the parent session with a steer message.
391
368
 
392
- **Default:** the inverse of `auto-exit`. Autonomous agents (`auto-exit: true`) are non-interactive and ping the parent on stall/recovery; agents without `auto-exit` are interactive and stay quiet. Bare spawns with no agent defs (e.g. `/iterate` with `fork: true`) are treated as interactive.
369
+ **Default:** the inverse of `auto-exit`. Autonomous agents (`auto-exit: true`) are non-interactive and ping the parent on stall/recovery; agents without `auto-exit` are interactive and stay quiet. Bare spawns with no agent defs are treated as interactive.
393
370
 
394
371
  **Why it exists:** Interactive agents can run for minutes or hours while the user thinks, types, and reads in the subagent's pane. Child snapshots still update the widget, but stalled/recovered supervision messages rarely need to wake the parent for user-driven sessions. Skipping the steer keeps the parent quiet until the child actually finishes.
395
372
 
@@ -415,18 +392,11 @@ subagent({ name: "Scout", agent: "scout", interactive: true, task: "..." });
415
392
 
416
393
  ## Tool Access Control
417
394
 
418
- By default, every sub-agent can spawn further sub-agents. Control this with frontmatter:
419
-
420
- ### `spawning: false`
395
+ Child subagent sessions cannot create or manage other subagents unless the global `allowSubagentSpawning` setting is `true`. When it is `false` (the default), the lifecycle tools `subagent`, `subagent_interrupt`, `subagents_list`, and `subagent_resume` are not registered in child sessions. The child-only `subagent_done` tool remains available; `caller_ping` remains available for requesting help from the parent.
421
396
 
422
- Denies all subagent lifecycle tools (`subagent`, `subagent_interrupt`, `subagents_list`, `subagent_resume`):
397
+ ### `spawning` (legacy)
423
398
 
424
- ```yaml
425
- ---
426
- name: worker
427
- spawning: false
428
- ---
429
- ```
399
+ The global `allowSubagentSpawning` setting is authoritative for child sessions. Existing `spawning: false` fields remain accepted for compatibility but do not override the global setting.
430
400
 
431
401
  ### `deny-tools`
432
402
 
@@ -439,15 +409,9 @@ deny-tools: subagent
439
409
  ---
440
410
  ```
441
411
 
442
- ### Recommended Configuration
412
+ ### Global setting
443
413
 
444
- | Agent | `spawning` | Rationale |
445
- | ---------- | ----------- | -------------------------------------------- |
446
- | planner | _(default)_ | Legitimately spawns scouts for investigation |
447
- | worker | `false` | Should implement tasks, not delegate |
448
- | researcher | `false` | Should research, not spawn |
449
- | reviewer | `false` | Should review, not spawn |
450
- | scout | `false` | Should gather context, not spawn |
414
+ The global switch applies uniformly to planner, worker, reviewer, scout, and custom child sessions. Leave `allowSubagentSpawning` as `false` unless a child agent is explicitly expected to create or manage another subagent.
451
415
 
452
416
  ---
453
417
 
@@ -478,7 +442,6 @@ Set a default `cwd` in agent frontmatter:
478
442
  ---
479
443
  name: game-designer
480
444
  cwd: ./agents/game-designer
481
- spawning: false
482
445
  ---
483
446
  ```
484
447
 
package/README.zh-CN.md CHANGED
@@ -39,10 +39,9 @@ zellij --session pi # 然后运行 pi
39
39
 
40
40
  ## 主要能力
41
41
 
42
- - **4 个主会话工具 + 3 个命令**:`subagent`、`subagent_interrupt`、`subagents_list`、`subagent_resume`;命令 `/plan`、`/iterate`、`/subagent`
42
+ - **4 个主会话工具 + 2 个命令**:`subagent`、`subagent_interrupt`、`subagents_list`、`subagent_resume`;命令 `/plan`、`/subagent`
43
43
  - **内置 agent**:planner、scout、worker、reviewer、visual-tester
44
44
  - **`/plan` 工作流**:调研 → 规划 → 确认 → 执行 → 审查 的完整流水线
45
- - **`/iterate` 工作流**:fork 当前会话到子 agent 做快速修改,不污染主上下文
46
45
  - **caller_ping**:子 agent 向父 agent 求助的机制
47
46
  - **自定义 agent**:在 `.pi/agents/` 或 `~/.pi/agent/agents/` 放置 `.md` 定义文件
48
47
 
@@ -50,22 +49,20 @@ zellij --session pi # 然后运行 pi
50
49
 
51
50
  ## 配置
52
51
 
53
- 状态显示与持久化 Herdr 模式由扩展目录下的 `config.json` 控制。复制 `config.json.example` 开始:
54
-
55
- ```bash
56
- cp config.json.example config.json
57
- ```
52
+ 持久化的 Herdr 模式、子 agent 创建策略和子 agent 扩展列表读取自用户扩展配置 `~/.pi/agent/extensions/pi-interactive-subagents/config.json`(遵循 `PI_CODING_AGENT_DIR`)。状态面板的 `status.enabled` 读取已安装包目录的 `config.json`,不存在时回退到 `config.json.example`。`config.json.example` 仅用于说明字段;向用户配置添加 `allowSubagentSpawning` 和 `subagentExtensions` 时不要覆盖已有的 mux 设置:
58
53
 
59
54
  ```json
60
55
  {
61
56
  "herdrMode": "split",
57
+ "allowSubagentSpawning": false,
58
+ "subagentExtensions": [],
62
59
  "status": {
63
60
  "enabled": true
64
61
  }
65
62
  }
66
63
  ```
67
64
 
68
- `herdrMode` 支持 `split`(默认,兼容原有 pane 布局)和 `tab`(每个 subagent 独立后台 Tab)。`/config:subagent herdr split|tab` 会更新该字段。
65
+ `herdrMode` 支持 `split`(默认,兼容原有 pane 布局)和 `tab`(每个 subagent 独立后台 Tab)。`allowSubagentSpawning` 是全局开关,控制子 agent 是否可以创建或管理其他 subagent,默认值为 `false`;设置为 `true` 后,子 agent 才会获得这些生命周期工具。`subagentExtensions` 是可选扩展路径列表;子 agent 不再自动发现项目级和全局扩展,只加载列表中的扩展,`subagent-done.ts` 始终加载。路径可以是绝对路径、`~/` 路径,或相对于 `PI_CODING_AGENT_DIR` 的路径。`/config:subagent herdr split|tab` 会更新 Herdr 字段。
69
66
 
70
67
  ## 致谢
71
68
 
package/SKILL.md CHANGED
@@ -7,7 +7,7 @@ description: "配置与排查 interactive subagents 的终端复用器、Herdr s
7
7
 
8
8
  ## 诊断
9
9
 
10
- 读取实际 Pi agent 目录下的 `extensions/pi-interactive-subagents/config.json`:`mux` 保存后端偏好,`herdrMode` 保存 `split|tab`。状态面板的 `status.enabled` 由安装包根目录的 `config.json` 读取;不存在时读取同目录 `config.json.example`。状态行数当前固定为 4,不是配置项。
10
+ 读取实际 Pi agent 目录下的 `extensions/pi-interactive-subagents/config.json`:`mux` 保存后端偏好,`herdrMode` 保存 `split|tab`,`allowSubagentSpawning` 控制子 agent 是否可以创建/管理其他 subagent,`subagentExtensions` 指定子 agent 显式加载的扩展路径列表,默认不自动发现其他扩展。默认 `allowSubagentSpawning` 为 `false`,`subagent-done.ts` 始终加载。状态面板的 `status.enabled` 由安装包根目录的 `config.json` 读取;不存在时读取同目录 `config.json.example`。状态行数当前固定为 4,不是配置项。
11
11
 
12
12
  环境变量优先级:`PI_TERMINAL_MUX` > `PI_SUBAGENT_MUX` > 持久化 `mux` > `auto`;`PI_SUBAGENT_HERDR_MODE` > 持久化 `herdrMode` > `split`。
13
13
 
@@ -1,5 +1,7 @@
1
1
  {
2
2
  "herdrMode": "split",
3
+ "allowSubagentSpawning": false,
4
+ "subagentExtensions": [],
3
5
  "status": {
4
6
  "enabled": true
5
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maplezzk/pi-interactive-subagents",
3
- "version": "3.11.0",
3
+ "version": "3.12.0",
4
4
  "description": "Interactive async subagents for pi — spawn, orchestrate, and manage sub-agent sessions in multiplexer panes. Fork of HazAT/pi-interactive-subagents.",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -2,7 +2,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
2
2
  import { keyHint } from "@earendil-works/pi-coding-agent";
3
3
  import { Type, type Static } from "@sinclair/typebox";
4
4
  import { Box, Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
5
- import { dirname, join } from "node:path";
5
+ import { dirname, isAbsolute, join } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { createTranslator, loadCatalog } from "pi-extensions-i18n";
8
8
  import {
@@ -48,6 +48,8 @@ import {
48
48
  HERDR_SURFACE_MODES,
49
49
  loadHerdrModeConfig,
50
50
  loadMuxConfig,
51
+ loadSubagentExtensionsConfig,
52
+ loadSubagentSpawningConfig,
51
53
  saveHerdrMode,
52
54
  saveMuxPreference,
53
55
  SUBAGENT_MUX_BACKENDS,
@@ -56,6 +58,8 @@ import {
56
58
  } from "./mux-config.ts";
57
59
 
58
60
  import {
61
+ SUBAGENT_SESSION_MODE_LINEAGE_ONLY,
62
+ SUBAGENT_SESSION_MODE_STANDALONE,
59
63
  findLastAssistantMessage,
60
64
  getNewEntries,
61
65
  seedSubagentSessionFile,
@@ -168,12 +172,6 @@ const SubagentParams = Type.Object({
168
172
  "Working directory for the sub-agent. The agent starts in this folder and picks up its local .pi/ config, CLAUDE.md, skills, and extensions. Use for role-specific subfolders.",
169
173
  }),
170
174
  ),
171
- fork: Type.Optional(
172
- Type.Boolean({
173
- description:
174
- "Force the full-context fork mode for this spawn. The sub-agent inherits the current session conversation, overriding any agent frontmatter session-mode.",
175
- }),
176
- ),
177
175
  interactive: Type.Optional(
178
176
  Type.Boolean({
179
177
  description:
@@ -201,7 +199,10 @@ const SubagentParams = Type.Object({
201
199
  ),
202
200
  });
203
201
 
204
- type SubagentSessionMode = "standalone" | "lineage-only" | "fork";
202
+ type SubagentSessionMode =
203
+ | typeof SUBAGENT_SESSION_MODE_STANDALONE
204
+ | typeof SUBAGENT_SESSION_MODE_LINEAGE_ONLY;
205
+ const DEFAULT_SUBAGENT_SESSION_MODE: SubagentSessionMode = SUBAGENT_SESSION_MODE_STANDALONE;
205
206
 
206
207
  interface AgentDefaults {
207
208
  model?: string;
@@ -232,7 +233,7 @@ interface ListedAgentDefinition extends AgentDefinition {
232
233
  source: AgentSource;
233
234
  }
234
235
 
235
- /** Tools that are gated by `spawning: false` */
236
+ /** Tools that are unavailable inside child subagent sessions. */
236
237
  const SPAWNING_TOOLS = new Set([
237
238
  "subagent",
238
239
  "subagent_interrupt",
@@ -241,21 +242,19 @@ const SPAWNING_TOOLS = new Set([
241
242
  ]);
242
243
 
243
244
  /**
244
- * Resolve the effective set of denied tool names from agent defaults.
245
- * `spawning: false` expands to all SPAWNING_TOOLS.
246
- * `deny-tools` adds individual tool names on top.
245
+ * Resolve the effective set of denied tool names for a child subagent.
246
+ * The global switch controls whether every child session may create or manage
247
+ * other subagents; the legacy `spawning` field does not override it.
248
+ * `deny-tools` can add individual restrictions on top.
247
249
  */
248
- function resolveDenyTools(agentDefs: AgentDefaults | null): Set<string> {
249
- const denied = new Set<string>();
250
- if (!agentDefs) return denied;
251
-
252
- // spawning: false deny all spawning tools
253
- if (agentDefs.spawning === false) {
254
- for (const t of SPAWNING_TOOLS) denied.add(t);
255
- }
250
+ function resolveDenyTools(
251
+ agentDefs: AgentDefaults | null,
252
+ allowSubagentSpawning = false,
253
+ ): Set<string> {
254
+ const denied = allowSubagentSpawning ? new Set<string>() : new Set(SPAWNING_TOOLS);
256
255
 
257
256
  // deny-tools: explicit list
258
- if (agentDefs.denyTools) {
257
+ if (agentDefs?.denyTools) {
259
258
  for (const t of agentDefs.denyTools
260
259
  .split(",")
261
260
  .map((s) => s.trim())
@@ -276,6 +275,30 @@ function getBundledAgentsDir(): string {
276
275
  return join(SUBAGENTS_DIR, "../../agents");
277
276
  }
278
277
 
278
+ const HOME_PREFIX = "~/";
279
+
280
+ /** Resolve an extension path from the user config against the Pi agent directory. */
281
+ function resolveSubagentExtensionPath(extensionPath: string): string {
282
+ if (extensionPath === "~") return homedir();
283
+ if (extensionPath.startsWith(HOME_PREFIX)) return join(homedir(), extensionPath.slice(HOME_PREFIX.length));
284
+ return isAbsolute(extensionPath) ? extensionPath : join(getAgentConfigDir(), extensionPath);
285
+ }
286
+
287
+ /** Load and resolve explicitly configured child-session extension paths. */
288
+ function getConfiguredSubagentExtensions(): string[] {
289
+ return loadSubagentExtensionsConfig().extensions.map(resolveSubagentExtensionPath);
290
+ }
291
+
292
+ /** Build the no-discovery flag and explicit extensions for a child Pi command. */
293
+ function buildChildExtensionArgs(extensionPaths = getConfiguredSubagentExtensions()): string[] {
294
+ return [
295
+ NO_EXTENSIONS_FLAG,
296
+ EXTENSION_FLAG,
297
+ shellEscape(join(SUBAGENTS_DIR, SUBAGENT_DONE_EXTENSION_FILE)),
298
+ ...extensionPaths.flatMap((extensionPath) => [EXTENSION_FLAG, shellEscape(extensionPath)]),
299
+ ];
300
+ }
301
+
279
302
  function getFrontmatterValue(frontmatter: string, key: string): string | undefined {
280
303
  const match = frontmatter.match(new RegExp(`^${key}:\\s*(.+)$`, "m"));
281
304
  return match ? match[1].trim() : undefined;
@@ -285,8 +308,9 @@ function parseOptionalBoolean(value: string | undefined): boolean | undefined {
285
308
  return value != null ? value === "true" : undefined;
286
309
  }
287
310
 
311
+ /** Parse a supported session mode; invalid values return undefined. */
288
312
  function parseSessionMode(value: string | undefined): SubagentSessionMode | undefined {
289
- if (value === "standalone" || value === "lineage-only" || value === "fork") {
313
+ if (value === SUBAGENT_SESSION_MODE_STANDALONE || value === SUBAGENT_SESSION_MODE_LINEAGE_ONLY) {
290
314
  return value;
291
315
  }
292
316
  return undefined;
@@ -376,30 +400,20 @@ function getDefaultSessionDirFor(cwd: string, _agentDir: string): string {
376
400
  return sessionDir;
377
401
  }
378
402
 
379
- function resolveEffectiveSessionMode(
380
- params: Static<typeof SubagentParams>,
381
- agentDefs: AgentDefaults | null,
382
- ): SubagentSessionMode {
383
- if (params.fork) return "fork";
384
- return agentDefs?.sessionMode ?? "standalone";
403
+ /** Resolve the agent-configured session mode, defaulting to standalone. */
404
+ function resolveEffectiveSessionMode(agentDefs: AgentDefaults | null): SubagentSessionMode {
405
+ return agentDefs?.sessionMode ?? DEFAULT_SUBAGENT_SESSION_MODE;
385
406
  }
386
407
 
387
- function resolveLaunchBehavior(
388
- params: Static<typeof SubagentParams>,
389
- agentDefs: AgentDefaults | null,
390
- ): {
408
+ /** Resolve launch behavior for standalone and lineage-only child sessions. */
409
+ function resolveLaunchBehavior(agentDefs: AgentDefaults | null): {
391
410
  sessionMode: SubagentSessionMode;
392
- seededSessionMode: "lineage-only" | "fork" | null;
393
- inheritsConversationContext: boolean;
394
- taskDelivery: "direct" | "artifact";
411
+ seededSessionMode: typeof SUBAGENT_SESSION_MODE_LINEAGE_ONLY | null;
395
412
  } {
396
- const sessionMode = resolveEffectiveSessionMode(params, agentDefs);
397
- const inheritsConversationContext = sessionMode === "fork";
413
+ const sessionMode = resolveEffectiveSessionMode(agentDefs);
398
414
  return {
399
415
  sessionMode,
400
- seededSessionMode: sessionMode === "standalone" ? null : sessionMode,
401
- inheritsConversationContext,
402
- taskDelivery: inheritsConversationContext ? "direct" : "artifact",
416
+ seededSessionMode: sessionMode === DEFAULT_SUBAGENT_SESSION_MODE ? null : SUBAGENT_SESSION_MODE_LINEAGE_ONLY,
403
417
  };
404
418
  }
405
419
 
@@ -412,12 +426,11 @@ function resolveLaunchBehavior(
412
426
  * 3. Default: the inverse of `auto-exit`. Agents that auto-exit are
413
427
  * autonomous (scout, worker, reviewer) and the parent session should be
414
428
  * woken on stall/recovery transitions. Agents that don't auto-exit are
415
- * driven by the user in their own pane (planner, iterate/fork) and
416
- * stall pings are noise.
429
+ * driven by the user in their own pane (planner) and stall pings are
430
+ * noise.
417
431
  *
418
- * When no agent defs exist at all (bare `subagent({ name, task })` call,
419
- * typical for `/iterate` with `fork: true`), `autoExit` is undefined and the
420
- * subagent is treated as interactive — matching the intent of iterate.
432
+ * When no agent defs exist at all (bare `subagent({ name, task })` call),
433
+ * `autoExit` is undefined and the subagent is treated as interactive.
421
434
  */
422
435
  function resolveEffectiveInteractive(
423
436
  params: Static<typeof SubagentParams>,
@@ -706,6 +719,10 @@ function updateWidget() {
706
719
  * as standalone prompts in the child session.
707
720
  */
708
721
  const SUBAGENT_CONTROL_TOOLS = ["caller_ping", "subagent_done"] as const;
722
+ const FILE_TIMESTAMP_LENGTH = 19;
723
+ const NO_EXTENSIONS_FLAG = "--no-extensions";
724
+ const EXTENSION_FLAG = "--extension";
725
+ const SUBAGENT_DONE_EXTENSION_FILE = "subagent-done.ts";
709
726
 
710
727
  /**
711
728
  * Build the child --tools allowlist.
@@ -731,21 +748,19 @@ function buildSubagentToolAllowlist(effectiveTools?: string): string | null {
731
748
  return [...allow].join(",");
732
749
  }
733
750
 
734
- function buildPiPromptArgs(params: {
735
- effectiveSkills?: string;
736
- taskDelivery: "direct" | "artifact";
737
- taskArg: string;
738
- }): string[] {
751
+ /**
752
+ * Build positional prompt args for a child launch.
753
+ * Skills must be separate prompts so Pi expands each `/skill:` directive.
754
+ */
755
+ function buildPiPromptArgs(params: { effectiveSkills?: string; taskArg: string }): string[] {
739
756
  const skillPrompts = (params.effectiveSkills ?? "")
740
757
  .split(",")
741
758
  .map((s) => s.trim())
742
759
  .filter(Boolean)
743
760
  .map((skill) => `/skill:${skill}`);
744
761
 
745
- const needsSeparator = params.taskDelivery === "artifact" && skillPrompts.length > 0;
746
-
747
762
  return [
748
- ...(needsSeparator ? [""] : []),
763
+ ...(skillPrompts.length > 0 ? [""] : []),
749
764
  ...skillPrompts,
750
765
  params.taskArg,
751
766
  ];
@@ -882,19 +897,10 @@ function handleSubagentInterrupt(
882
897
  running.statusState = forceStatusAfterInterrupt(running.statusState, now);
883
898
  updateWidget();
884
899
 
885
- // After aborting the current generation, also signal done so the subagent
886
- // properly finishes (parent's pollForExit detects the .exit file and
887
- // returns the result).
888
- if (running.sessionFile) {
889
- const exitFile = `${running.sessionFile}.exit`;
890
- try {
891
- writeFileSync(exitFile, JSON.stringify({ type: "done" }));
892
- } catch (writeErr: any) {
893
- process.stderr.write(
894
- `[interrupt] .exit 写入失败 file=${exitFile} err=${writeErr?.message ?? String(writeErr)}\n`,
895
- );
896
- }
897
- }
900
+ // Escape only cancels the child's current turn. Do not write the `.exit`
901
+ // sidecar here: that file is the terminal completion signal consumed by
902
+ // pollForExit, and writing it would close the pane and remove this running
903
+ // entry instead of leaving the child alive for another turn.
898
904
 
899
905
  return {
900
906
  content: [{ type: "text" as const, text: `Interrupt requested for subagent "${running.name}".` }],
@@ -1124,6 +1130,7 @@ export const __test__ = {
1124
1130
  resolveLaunchBehavior,
1125
1131
  resolveEffectiveInteractive,
1126
1132
  buildSubagentToolAllowlist,
1133
+ buildChildExtensionArgs,
1127
1134
  buildPiPromptArgs,
1128
1135
  formatWidgetRightLabel,
1129
1136
  observeRunningSubagent,
@@ -1197,11 +1204,10 @@ async function launchSubagent(
1197
1204
  await new Promise<void>((resolve) => setTimeout(resolve, getShellReadyDelayMs()));
1198
1205
  }
1199
1206
 
1200
- const launchBehavior = resolveLaunchBehavior(params, agentDefs);
1207
+ const launchBehavior = resolveLaunchBehavior(agentDefs);
1201
1208
 
1202
1209
  if (launchBehavior.seededSessionMode) {
1203
1210
  seedSubagentSessionFile({
1204
- mode: launchBehavior.seededSessionMode,
1205
1211
  parentSessionFile: sessionFile,
1206
1212
  childSessionFile: subagentSessionFile,
1207
1213
  childCwd: targetCwdForSession,
@@ -1210,25 +1216,21 @@ async function launchSubagent(
1210
1216
 
1211
1217
  const activityFile = getSubagentActivityFile(artifactDir, id);
1212
1218
  mkdirSync(dirname(activityFile), { recursive: true });
1213
- const { inheritsConversationContext } = launchBehavior;
1214
1219
 
1215
- // Build the task message
1216
- // Only full-context fork mode inherits prior conversation state.
1217
- // Blank-session modes need the wrapper instructions and artifact-backed handoff.
1220
+ // Build the task message. All child sessions use artifact-backed delivery.
1218
1221
  const modeHint = agentDefs?.autoExit
1219
1222
  ? "Complete your task autonomously."
1220
1223
  : "Complete your task. When finished, call the subagent_done tool. The user can interact with you at any time.";
1221
1224
  const summaryInstruction = agentDefs?.autoExit
1222
1225
  ? "Your FINAL assistant message should summarize what you accomplished."
1223
1226
  : "Your FINAL assistant message (before calling subagent_done or before the user exits) should summarize what you accomplished.";
1224
- const denySet = resolveDenyTools(agentDefs);
1227
+ const { allowSubagentSpawning } = loadSubagentSpawningConfig();
1228
+ const denySet = resolveDenyTools(agentDefs, allowSubagentSpawning);
1225
1229
  const identity = agentDefs?.body ?? params.systemPrompt ?? null;
1226
1230
  const systemPromptMode = agentDefs?.systemPromptMode;
1227
1231
  const identityInSystemPrompt = systemPromptMode && identity;
1228
1232
  const roleBlock = identity && !identityInSystemPrompt ? `\n\n${identity}` : "";
1229
- const fullTask = inheritsConversationContext
1230
- ? params.task
1231
- : `${roleBlock}\n\n${modeHint}\n\n${params.task}\n\n${summaryInstruction}`;
1233
+ const fullTask = `${roleBlock}\n\n${modeHint}\n\n${params.task}\n\n${summaryInstruction}`;
1232
1234
  // ── Claude Code CLI path ──
1233
1235
  if (agentDefs?.cli === "claude") {
1234
1236
  const sentinelFile = `/tmp/pi-claude-${id}-done`;
@@ -1306,12 +1308,9 @@ async function launchSubagent(
1306
1308
  // ── Pi CLI path ──
1307
1309
 
1308
1310
  // Build pi command
1309
- const parts: string[] = ["pi"];
1311
+ const parts: string[] = ["pi", ...buildChildExtensionArgs()];
1310
1312
  parts.push("--session", shellEscape(subagentSessionFile));
1311
1313
 
1312
- const subagentDonePath = join(SUBAGENTS_DIR, "subagent-done.ts");
1313
- parts.push("-e", shellEscape(subagentDonePath));
1314
-
1315
1314
  if (effectiveModel) {
1316
1315
  const model = effectiveThinking ? `${effectiveModel}:${effectiveThinking}` : effectiveModel;
1317
1316
  parts.push("--model", shellEscape(model));
@@ -1322,7 +1321,7 @@ async function launchSubagent(
1322
1321
  // auto-detect file paths and read their contents.
1323
1322
  if (identityInSystemPrompt && identity) {
1324
1323
  const flag = systemPromptMode === "replace" ? "--system-prompt" : "--append-system-prompt";
1325
- const spTimestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
1324
+ const spTimestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, FILE_TIMESTAMP_LENGTH);
1326
1325
  const spSafeName = params.name
1327
1326
  .toLowerCase()
1328
1327
  .replace(/[^a-z0-9\s-]/g, "")
@@ -1372,33 +1371,22 @@ async function launchSubagent(
1372
1371
  }
1373
1372
  const envPrefix = envParts.join(" ") + " ";
1374
1373
 
1375
- // Pass task and skill prompts to the sub-agent.
1376
- // Only full-context fork mode gets a direct task argument because it already
1377
- // inherits the parent conversation. Blank-session modes use artifact-backed
1378
- // handoff so the wrapper instructions arrive as the initial user message.
1379
- let taskArg: string;
1380
- if (launchBehavior.taskDelivery === "direct") {
1381
- taskArg = fullTask;
1382
- } else {
1383
- const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
1384
- const safeName = params.name
1385
- .toLowerCase()
1386
- .replace(/[^a-z0-9\s-]/g, "") // strip everything except alphanumeric, spaces, hyphens
1387
- .replace(/\s+/g, "-") // spaces to hyphens
1388
- .replace(/-+/g, "-") // collapse multiple hyphens
1389
- .replace(/^-|-$/g, ""); // trim leading/trailing hyphens
1390
- const artifactName = `context/${safeName || "subagent"}-${timestamp}.md`;
1391
- const artifactPath = join(artifactDir, artifactName);
1392
- mkdirSync(dirname(artifactPath), { recursive: true });
1393
- writeFileSync(artifactPath, fullTask, "utf8");
1394
- taskArg = `@${artifactPath}`;
1395
- }
1396
-
1397
- for (const promptArg of buildPiPromptArgs({
1398
- effectiveSkills,
1399
- taskDelivery: launchBehavior.taskDelivery,
1400
- taskArg,
1401
- })) {
1374
+ // Pass task and skill prompts to the child. The task is written to an
1375
+ // artifact so wrapper instructions arrive as the initial user message.
1376
+ const artifactTimestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, FILE_TIMESTAMP_LENGTH);
1377
+ const safeName = params.name
1378
+ .toLowerCase()
1379
+ .replace(/[^a-z0-9\s-]/g, "") // strip everything except alphanumeric, spaces, hyphens
1380
+ .replace(/\s+/g, "-") // spaces to hyphens
1381
+ .replace(/-+/g, "-") // collapse multiple hyphens
1382
+ .replace(/^-|-$/g, ""); // trim leading/trailing hyphens
1383
+ const artifactName = `context/${safeName || "subagent"}-${artifactTimestamp}.md`;
1384
+ const artifactPath = join(artifactDir, artifactName);
1385
+ mkdirSync(dirname(artifactPath), { recursive: true });
1386
+ writeFileSync(artifactPath, fullTask, "utf8");
1387
+ const taskArg = `@${artifactPath}`;
1388
+
1389
+ for (const promptArg of buildPiPromptArgs({ effectiveSkills, taskArg })) {
1402
1390
  parts.push(shellEscape(promptArg));
1403
1391
  }
1404
1392
 
@@ -2051,11 +2039,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
2051
2039
  await new Promise<void>((resolve) => setTimeout(resolve, getShellReadyDelayMs()));
2052
2040
 
2053
2041
  // Build pi resume command
2054
- const parts = ["pi", "--session", shellEscape(params.sessionPath)];
2055
-
2056
- // Load subagent-done extension so the agent can self-terminate if needed
2057
- const subagentDonePath = join(SUBAGENTS_DIR, "subagent-done.ts");
2058
- parts.push("-e", shellEscape(subagentDonePath));
2042
+ const parts = ["pi", ...buildChildExtensionArgs(), "--session", shellEscape(params.sessionPath)];
2059
2043
 
2060
2044
  const sessionId = ctx.sessionManager.getSessionId();
2061
2045
  const artifactDir = getArtifactDir(ctx.sessionManager.getSessionDir(), sessionId);
@@ -2064,7 +2048,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
2064
2048
 
2065
2049
  let resumeMsgFile: string | undefined;
2066
2050
  if (params.message) {
2067
- const msgTimestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
2051
+ const msgTimestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, FILE_TIMESTAMP_LENGTH);
2068
2052
  resumeMsgFile = join(
2069
2053
  artifactDir,
2070
2054
  "subagent-resume",
@@ -2082,6 +2066,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
2082
2066
 
2083
2067
  // Build env prefix — propagate PI_CODING_AGENT_DIR for config isolation
2084
2068
  const resumeEnvParts: string[] = [];
2069
+ const { allowSubagentSpawning } = loadSubagentSpawningConfig();
2085
2070
  if (process.env.PI_CODING_AGENT_DIR) {
2086
2071
  resumeEnvParts.push(`PI_CODING_AGENT_DIR=${shellEscape(process.env.PI_CODING_AGENT_DIR)}`);
2087
2072
  }
@@ -2089,6 +2074,9 @@ export default function subagentsExtension(pi: ExtensionAPI) {
2089
2074
  resumeEnvParts.push(`PI_SUBAGENT_SESSION=${shellEscape(params.sessionPath)}`);
2090
2075
  resumeEnvParts.push(`PI_SUBAGENT_ID=${shellEscape(id)}`);
2091
2076
  resumeEnvParts.push(`PI_SUBAGENT_ACTIVITY_FILE=${shellEscape(activityFile)}`);
2077
+ resumeEnvParts.push(
2078
+ `PI_DENY_TOOLS=${shellEscape([...resolveDenyTools(null, allowSubagentSpawning)].join(","))}`,
2079
+ );
2092
2080
  if (autoExit) {
2093
2081
  resumeEnvParts.push(`PI_SUBAGENT_AUTO_EXIT=1`);
2094
2082
  }
@@ -2214,18 +2202,6 @@ export default function subagentsExtension(pi: ExtensionAPI) {
2214
2202
  },
2215
2203
  });
2216
2204
 
2217
- // /iterate command — fork the session into a subagent
2218
- pi.registerCommand("iterate", {
2219
- description: "Fork session into a subagent for focused work (bugfixes, iteration)",
2220
- handler: async (args, _ctx) => {
2221
- const task = args.trim() || "";
2222
- const toolCall = task
2223
- ? `Use subagent to fork a session. fork: true, name: "Iterate", task: ${JSON.stringify(task)}`
2224
- : `Use subagent to fork a session. fork: true, name: "Iterate", task: "The user wants to do some hands-on work. Help them with whatever they need."`;
2225
- pi.sendUserMessage(toolCall);
2226
- },
2227
- });
2228
-
2229
2205
  // /subagent command — spawn a subagent by name
2230
2206
  pi.registerCommand("subagent", {
2231
2207
  description: "Spawn a subagent: /subagent <agent> <task>",
@@ -20,10 +20,22 @@ export interface SubagentHerdrModeConfig {
20
20
  source: SubagentMuxConfigSource;
21
21
  }
22
22
 
23
+ export interface SubagentSpawningConfig {
24
+ allowSubagentSpawning: boolean;
25
+ source: SubagentMuxConfigSource;
26
+ }
27
+
28
+ export interface SubagentExtensionsConfig {
29
+ extensions: string[];
30
+ source: SubagentMuxConfigSource;
31
+ }
32
+
23
33
  const BACKENDS: readonly MuxBackend[] = ["muxy", "cmux", "tmux", "zellij", "wezterm", "herdr", "otty", "orca"];
24
34
  const CONFIG_FILE = "config.json";
25
35
  const HERDR_MODE_ENV = "PI_SUBAGENT_HERDR_MODE";
26
36
  const DEFAULT_HERDR_MODE: HerdrSurfaceMode = HERDR_SURFACE_MODE_SPLIT;
37
+ const DEFAULT_ALLOW_SUBAGENT_SPAWNING = false;
38
+ const DEFAULT_SUBAGENT_EXTENSIONS: readonly string[] = [];
27
39
 
28
40
  function isMuxBackend(value: unknown): value is MuxBackend {
29
41
  return typeof value === "string" && (BACKENDS as readonly string[]).includes(value);
@@ -111,6 +123,29 @@ export function loadHerdrModeConfig(path = muxConfigPath()): SubagentHerdrModeCo
111
123
  return { herdrMode: DEFAULT_HERDR_MODE, source: "default" };
112
124
  }
113
125
 
126
+ /** Read the global child-subagent spawning switch; disabled by default. */
127
+ export function loadSubagentSpawningConfig(path = muxConfigPath()): SubagentSpawningConfig {
128
+ const stored = readConfigObject(path).allowSubagentSpawning;
129
+ if (typeof stored === "boolean") {
130
+ return { allowSubagentSpawning: stored, source: "file" };
131
+ }
132
+
133
+ return { allowSubagentSpawning: DEFAULT_ALLOW_SUBAGENT_SPAWNING, source: "default" };
134
+ }
135
+
136
+ /** Read the explicit extension list for child sessions; empty by default. */
137
+ export function loadSubagentExtensionsConfig(path = muxConfigPath()): SubagentExtensionsConfig {
138
+ const stored = readConfigObject(path).subagentExtensions;
139
+ if (Array.isArray(stored) && stored.every((value) => typeof value === "string" && value.trim())) {
140
+ return {
141
+ extensions: stored.map((value) => value.trim()),
142
+ source: "file",
143
+ };
144
+ }
145
+
146
+ return { extensions: [...DEFAULT_SUBAGENT_EXTENSIONS], source: "default" };
147
+ }
148
+
114
149
  /** Apply persisted mux and Herdr mode settings when no explicit environment override exists. */
115
150
  export function applyPersistedMuxPreference(path = muxConfigPath()): void {
116
151
  if (!environmentPreference()) {
@@ -17,36 +17,10 @@ export interface MessageEntry extends SessionEntry {
17
17
  };
18
18
  }
19
19
 
20
- export type SeededSubagentSessionMode = "lineage-only" | "fork";
21
-
22
- function getForkContentLines(parentSessionFile: string): string[] {
23
- const raw = readFileSync(parentSessionFile, "utf8");
24
- const lines = raw.split("\n").filter((line) => line.trim());
25
-
26
- let truncateAt = lines.length;
27
- for (let i = lines.length - 1; i >= 0; i--) {
28
- try {
29
- const entry = JSON.parse(lines[i]);
30
- if (entry.type === "message" && entry.message?.role === "user") {
31
- truncateAt = i;
32
- break;
33
- }
34
- } catch {
35
- // ignore malformed lines
36
- }
37
- }
38
-
39
- return lines.slice(0, truncateAt).filter((line) => {
40
- try {
41
- return JSON.parse(line).type !== "session";
42
- } catch {
43
- return true;
44
- }
45
- });
46
- }
20
+ export const SUBAGENT_SESSION_MODE_STANDALONE = "standalone" as const;
21
+ export const SUBAGENT_SESSION_MODE_LINEAGE_ONLY = "lineage-only" as const;
47
22
 
48
23
  export function seedSubagentSessionFile(params: {
49
- mode: SeededSubagentSessionMode;
50
24
  parentSessionFile: string;
51
25
  childSessionFile: string;
52
26
  childCwd: string;
@@ -59,9 +33,7 @@ export function seedSubagentSessionFile(params: {
59
33
  cwd: params.childCwd,
60
34
  parentSession: params.parentSessionFile,
61
35
  };
62
- const contentLines =
63
- params.mode === "fork" ? getForkContentLines(params.parentSessionFile) : [];
64
- const lines = [JSON.stringify(header), ...contentLines];
36
+ const lines = [JSON.stringify(header)];
65
37
 
66
38
  mkdirSync(dirname(params.childSessionFile), { recursive: true });
67
39
  writeFileSync(params.childSessionFile, lines.join("\n") + "\n", "utf8");
@@ -1,22 +0,0 @@
1
- ---
2
- name: claude-code
3
- description: Self-driving Claude Code session for deep investigation, experimentation, and code exploration
4
- cli: claude
5
- auto-exit: true
6
- spawning: false
7
- deny-tools: claude
8
- ---
9
-
10
- # Claude Code
11
-
12
- You are a self-driving Claude Code session spawned by pi for hands-on investigation and experimentation.
13
-
14
- You have full autonomy: bash, file access, git clone, code editing, running tests, building projects — everything a developer can do in a terminal.
15
-
16
- ## Guidelines
17
-
18
- - Focus on the task given to you
19
- - Be thorough in your investigation
20
- - Report concrete findings with evidence (file paths, command output, test results)
21
- - If you get stuck, explain what you tried and what failed
22
- - Your final message should summarize what you accomplished and what you found