@oldflag2333333/pi-subagent 0.1.4 → 0.1.6

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
@@ -77,7 +77,7 @@ Ask the reviewer profile to review src/auth for bugs and missing tests.
77
77
  | Finish with a specialist | Ask Pi to close the Sub, or close its Herdr tab |
78
78
  | Remove Sub sessions and communication files left by deleted Mains | `/clean-sub` |
79
79
 
80
- Within the same Main session, repeated `/sub:reviewer` commands reuse that profile's open Sub. If it is busy, your next task is queued. Finishing a task does not automatically close the tab.
80
+ Within the same Main session, repeated `/sub:reviewer` commands reuse that profile's open Sub. If it is busy, your next task is queued. Finishing a task does not automatically close the tab. Subs can report progress through `talk` without stopping; `endTurn: true` explicitly ends their current turn when finished or waiting for Main.
81
81
 
82
82
  The **Sub-agents** list shows only assistants created by the current Main: all **Open** Subs (including idle or unknown status) and **Resumable** persistent sessions confirmed closed with saved files still available. Other Main sessions and forks do not inherit them or gain access to resume them. Reloading or resuming the same Main preserves ownership.
83
83
 
@@ -164,21 +164,28 @@ These instructions apply only to your main Pi, not to its Subs. Keep shared proj
164
164
 
165
165
  ## Clean up deleted sessions
166
166
 
167
- Persistent Sub histories are grouped by their owning Main, using Pi's native `--session-dir`:
167
+ Persistent Subs use Pi's native session store alongside ordinary sessions, with IDs of the form `sub-<UUID>`:
168
168
 
169
169
  ```text
170
- ~/.pi/agent/subagent/sessions/<main-session-id>/*.jsonl
170
+ ~/.pi/agent/sessions/<encoded-cwd>/<timestamp>_sub-<UUID>.jsonl
171
171
  ```
172
172
 
173
- Run `/clean-sub` to immediately delete both the Sub session directory and runtime communication directory for any Main ID no longer found among saved Pi sessions. It checks Pi's default session store and the current session directory, always preserves the current Main, and creates no index. Unreadable session files or invalid headers stop cleanup instead of being treated as deleted.
173
+ Same-cwd Subs inherit Main's session directory (including `--session-dir` overrides); Subs launched in another cwd use Pi's normal storage for that cwd. Ephemeral Subs still use `--no-session`. Native `/resume` can discover saved Subs; `--continue` may therefore select a Sub if it is the most recent session.
174
174
 
175
- Close any orphaned Sub tabs before cleaning; the command only deletes files. Profiles and global settings are untouched. Older Sub files outside the managed directory still need manual deletion. If you moved Main files or use other custom session directories, make them available in the scanned locations before cleaning.
175
+ Ownership is stored in both directions: Main's `subagent-run` custom entries record Sub IDs and paths, and each Sub's `subagent-owner` custom entry records its own ID and Main's ID and file path. These entries do not enter model context. Reloading or resuming does not duplicate unchanged ownership metadata.
176
+
177
+ - `/clean-sub` permanently deletes orphaned Sub JSONL files across Pi's native session store and the current custom session directory, plus orphaned runtime communication directories. A Sub is orphaned when its recorded Main ID cannot be found. Recorded Main file paths also protect owners stored outside native discovery.
178
+ - On Pi exit (`session_shutdown` with reason `quit`), the same cleanup runs automatically, limited to Subs whose session-header cwd matches the current cwd. Switching sessions, forking, and reloading do not trigger cleanup. Main existence is still checked across projects to protect cross-cwd Subs.
179
+ - The current session/owning Main is always preserved, including unsaved sessions. Missing ownership metadata is preserved with a warning; unreadable files, invalid headers, or conflicting ownership abort cleanup before deletion. Shared session directories, ordinary sessions, profiles, and settings are never deleted. No separate index or legacy-storage migration is used.
180
+
181
+ Close orphaned Sub tabs before cleaning; cleanup deletes files, not running agents. Custom stores other than the current session directory are not scanned for Sub files; run `/clean-sub` from those stores when needed.
176
182
 
177
183
  ## Things to know
178
184
 
179
185
  Messages use custom inbox cards and Pi’s native run preparation, without extra user-role wake messages. See [messaging behavior](docs/messaging.md).
180
186
 
181
187
  - **Herdr is required for Subs.** There is no headless fallback. Up to four Subs can be open at once, and Subs cannot delegate further.
188
+ - **Subs cannot fork or clone.** `/fork` and `/clone` are cancelled in Sub tabs; Main sessions are unaffected.
182
189
  - **Profiles are not a sandbox.** Subs run as your OS user. A read-only role is not a filesystem permission boundary.
183
190
  - **MCP follows Pi's normal configuration.** Profiles select non-MCP tools; they do not isolate or filter MCP servers. Custom non-MCP tools must come from extensions already loaded in Main.
184
191
  - **Subs have separate conversations.** They receive their task, profile, and normal project context—not Main's chat history. Include the details they need in the task.
package/README.zh-CN.md CHANGED
@@ -77,7 +77,7 @@ mkdir -p ~/.pi/agent/subagent/profiles/reviewer
77
77
  | 结束使用 | 让 Pi 关闭子助手,或手动关闭其 Herdr 标签页 |
78
78
  | 清理已删除 Main 遗留的 Sub 会话和通信文件 | `/clean-sub` |
79
79
 
80
- 在同一个主会话(Main)中,多次调用 `/sub:reviewer` 会复用该角色已打开的子助手(Sub)。如果它正在工作,新任务会排队。任务完成后,标签页不会自动关闭。
80
+ 在同一个主会话(Main)中,多次调用 `/sub:reviewer` 会复用该角色已打开的子助手(Sub)。如果它正在工作,新任务会排队。任务完成后,标签页不会自动关闭。Sub 通过 `talk` 汇报进度后默认继续工作;只有完成任务或需要等待 Main 回复时,才用 `endTurn: true` 显式结束本轮。
81
81
 
82
82
  **Sub-agents(子助手)** 列表只显示当前 Main 创建的助手:**Open** 包含所有已打开的子助手,包括空闲或状态未知的;**Resumable** 包含已确认关闭、且保存文件仍存在的持久化会话。其他 Main 或分叉会话不会继承这些助手,也不能恢复它们。重载或恢复同一个 Main 则会保留关联。
83
83
 
@@ -164,21 +164,28 @@ reviewer/
164
164
 
165
165
  ## 清理已删除会话的遗留文件
166
166
 
167
- 持久化 Sub 使用 Pi 原生的 `--session-dir`,按所属 Main 分目录保存:
167
+ 持久化 Sub 与普通会话一起使用 Pi 原生会话目录,ID 格式为 `sub-<UUID>`:
168
168
 
169
169
  ```text
170
- ~/.pi/agent/subagent/sessions/<main-session-id>/*.jsonl
170
+ ~/.pi/agent/sessions/<工作目录编码>/<timestamp>_sub-<UUID>.jsonl
171
171
  ```
172
172
 
173
- 执行 `/clean-sub`,找不到对应 Main ID 时,立即删除它的 Sub 会话目录和运行时通信目录。命令检查 Pi 默认会话目录和当前会话目录,始终保留当前 Main,不新增索引。会话文件无法读取或文件头无效时会停止清理,不会将它们当作已删除。
173
+ 相同工作目录的 Sub 沿用 Main 的会话目录(包括 `--session-dir` 自定义目录);指定其他工作目录时使用该目录对应的 Pi 常规存储。临时 Sub 仍使用 `--no-session`。原生 `/resume` 可以发现持久化 Sub;因此 `--continue` 也可能选中最近活动的 Sub。
174
174
 
175
- 清理前请关闭遗留的 Sub 标签页,命令只删除文件。角色配置和全局设置不受影响;以前散落在专用目录之外的 Sub 文件仍需手动删除。如果移动过 Main 文件或使用其他自定义会话目录,请先确保这些会话位于扫描范围内,再执行清理。
175
+ 归属信息双向保存在 JSONL 中:Main 的 `subagent-run` 自定义条目记录 Sub ID 和文件路径;Sub 的 `subagent-owner` 自定义条目记录自身 ID、Main ID 和 Main 文件路径。这些条目不进入模型上下文,重载或恢复时不会重复写入未变化的归属信息。
176
+
177
+ - `/clean-sub` 扫描 Pi 全局原生会话库及当前自定义会话目录,永久删除找不到所属 Main ID 的孤儿 Sub JSONL 文件,同时清理孤儿运行时通信目录。也会检查记录的 Main 文件路径,保护保存在扫描范围之外的 Main。
178
+ - 退出 Pi 时(`session_shutdown` 的 `reason` 为 `quit`)自动执行清理,但只清理会话头中工作目录与当前目录相同的 Sub。切换会话、fork 和重载不触发清理;判断 Main 是否存在仍跨项目检查,避免误删跨目录委派的 Sub。
179
+ - 始终保护当前会话或所属 Main,包括尚未保存的会话。缺失归属元数据的 Sub 会保留并警告;无法读取文件、会话头无效或归属冲突时,在删除前中止清理。不删除共享会话目录、普通会话、角色配置或设置。不新增独立索引,也不迁移旧存储。
180
+
181
+ 清理前请关闭孤儿 Sub 标签页;清理只删除文件,不关闭运行中的助手。当前会话目录之外的其他自定义存储不会被扫描 Sub 文件,需要从相应存储运行 `/clean-sub`。
176
182
 
177
183
  ## 使用须知
178
184
 
179
185
  助手间消息使用自定义收件卡片,由 Pi 原生处理启动准备,不再添加用户角色唤醒消息。详见[消息机制](docs/messaging.md)。
180
186
 
181
187
  - **子助手依赖 Herdr。** 不支持无界面后台运行;最多同时打开四个子助手,子助手不能继续委派。
188
+ - **子助手不支持 fork 或 clone。** Sub 标签页中的 `/fork` 和 `/clone` 会被取消,不影响 Main 会话。
182
189
  - **角色配置不是沙箱。** 子助手以你的系统用户身份运行,配置“只读角色”不等于限制文件系统权限。
183
190
  - **MCP 沿用 Pi 的常规配置。** 角色只选择非 MCP 工具,不隔离或筛选 MCP 服务。自定义的非 MCP 工具必须来自主 Pi 已加载的扩展。
184
191
  - **子助手拥有独立对话。** 它会收到任务、角色配置和常规项目上下文,但不会收到主对话的聊天记录。请在任务中提供必要信息。
package/docs/messaging.md CHANGED
@@ -7,6 +7,15 @@ Pi Subagent requires **Pi 1.2.0 or newer**. Main ↔ Sub messages use `pi.sendMe
7
7
 
8
8
  There are no synthetic user-role wake messages, version fallbacks, or patches to Pi internals. Custom messages do not pass through user-input processing or execute peer text as slash commands or templates.
9
9
 
10
+ ## Sub talk and turn control
11
+
12
+ Sub's `talk` accepts an optional `endTurn` boolean, defaulting to `false`:
13
+
14
+ - Omitted or `false`: send the message and continue working. Use this for progress updates; no acknowledgement or "continue" reply from Main is required.
15
+ - `true`: send the message and end the current turn. Use this for completed work or a blocking question that requires Main's reply. Send a terminating `talk` on its own: Pi only ends the tool batch early when every tool result in it is terminating.
16
+
17
+ Ending a turn does not close the Sub or discard queued follow-ups. Main can still send further work through `talk`. Message text should make clear whether it reports progress, asks a blocking question, or delivers completed work; Main must not treat every message as task completion. Main's own `talk` does not end its turn.
18
+
10
19
  ## Delivery guarantees
11
20
 
12
21
  - `content` carries the peer message and routing instructions; `details` carries rendering and receipt metadata and is not sent to the model.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oldflag2333333/pi-subagent",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Reusable specialist profiles and independent sub-agents for Pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -4,7 +4,6 @@ import * as path from "node:path";
4
4
  import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
5
  import { subSessionName } from "../manual-context.js";
6
6
  import { subCapabilityArgs } from "../profiles/launch-args.js";
7
- import { subSessionDir } from "../sessions.js";
8
7
  import type { SubAgentStatus, SubLaunchSpec, SubSurfaceAdapter, SurfaceHandle } from "../types.js";
9
8
 
10
9
  function parseEnvelope(stdout: string): Record<string, unknown> {
@@ -41,9 +40,9 @@ function subArgs(spec: SubLaunchSpec): string[] {
41
40
  "--no-extensions",
42
41
  "-e", spec.entryPath,
43
42
  ...(fs.existsSync(herdrIntegration) ? ["-e", herdrIntegration] : []),
44
- ...(spec.profile.sessionPersistence === "persistent" || spec.resumeSessionId
45
- ? ["--session-dir", subSessionDir(spec.mainSessionId)] : ["--no-session"]),
46
- ...(spec.resumeSessionId ? ["--session", spec.resumeSessionFile ?? spec.resumeSessionId] : []),
43
+ ...(spec.profile.sessionPersistence === "persistent"
44
+ ? (spec.sessionDir ? ["--session-dir", spec.sessionDir] : []) : ["--no-session"]),
45
+ ...(spec.resumeSessionFile ? ["--session", spec.resumeSessionFile] : ["--session-id", spec.sessionId]),
47
46
  spec.projectTrusted ? "--approve" : "--no-approve",
48
47
  "--name", subSessionName(safeLabel(spec.title), spec.origin),
49
48
  ...subCapabilityArgs(spec.profile, spec.entryPath),
package/src/channel.ts CHANGED
@@ -100,6 +100,7 @@ export function readManifest(channelDir: string): DelegateManifest {
100
100
  const value = record(readJson(path.join(channelDir, "manifest.json")));
101
101
  if (!value || value.version !== 1 || typeof value.runId !== "string" || typeof value.mainSessionId !== "string"
102
102
  || typeof value.title !== "string" || typeof value.task !== "string" || typeof value.cwd !== "string"
103
+ || (value.mainSessionFile !== undefined && (typeof value.mainSessionFile !== "string" || !path.isAbsolute(value.mainSessionFile)))
103
104
  || (value.origin !== undefined && value.origin !== "manual")
104
105
  || !validProfile(value.profile) || typeof value.token !== "string" || typeof value.createdAt !== "number") {
105
106
  throw new Error("Invalid Pi Subagent channel manifest.");
@@ -1,8 +1,22 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
+ import { createInterface } from "node:readline";
3
4
  import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
- import { channelPath, runtimeRoot } from "../channel.js";
5
- import { subSessionDir, subSessionsRoot } from "../sessions.js";
5
+ import { channelPath, readManifest, runtimeRoot } from "../channel.js";
6
+ import { parseSubSessionOwner, SUB_OWNER_ENTRY, SUB_SESSION_ID, type SubSessionOwner } from "../sessions.js";
7
+
8
+ interface SavedSession {
9
+ file: string;
10
+ id: string;
11
+ cwd: string;
12
+ owner?: SubSessionOwner;
13
+ }
14
+
15
+ interface CleanupResult {
16
+ removed: number;
17
+ removedChannels: number;
18
+ errors: string[];
19
+ }
6
20
 
7
21
  function entries(directory: string): fs.Dirent[] {
8
22
  try { return fs.readdirSync(directory, { withFileTypes: true }); }
@@ -12,74 +26,137 @@ function entries(directory: string): fs.Dirent[] {
12
26
  }
13
27
  }
14
28
 
15
- /** Read only the header; never load transcripts or treat an unreadable session as deleted. */
16
- function sessionId(file: string): string {
17
- const fd = fs.openSync(file, "r");
29
+ function sessionDirectories(currentSessionDir: string): string[] {
30
+ const root = path.join(getAgentDir(), "sessions");
31
+ const directories = new Set([root, ...(currentSessionDir ? [path.resolve(currentSessionDir)] : [])]);
32
+ for (const entry of entries(root)) {
33
+ if (entry.isDirectory() || entry.isSymbolicLink()) directories.add(path.join(root, entry.name));
34
+ }
35
+ return [...directories];
36
+ }
37
+
38
+ /** Read headers only for Mains; stream Sub ownership without loading transcripts into memory. */
39
+ async function inspectSession(file: string, scopeCwd?: string, headerOnly = false): Promise<SavedSession | undefined> {
40
+ const input = fs.createReadStream(file, { encoding: "utf8" });
41
+ const lines = createInterface({ input, crlfDelay: Infinity });
42
+ let session: SavedSession | undefined;
18
43
  try {
19
- const buffer = Buffer.alloc(64 * 1024);
20
- const length = fs.readSync(fd, buffer, 0, buffer.length, 0);
21
- const text = buffer.subarray(0, length).toString("utf8");
22
- const header = JSON.parse(text.split("\n", 1)[0]!);
23
- if (header?.type !== "session" || typeof header.id !== "string" || !header.id) throw new Error("Invalid session header");
24
- return header.id;
44
+ for await (const line of lines) {
45
+ if (!session) {
46
+ const header = JSON.parse(line);
47
+ if (header?.type !== "session" || typeof header.id !== "string" || !header.id
48
+ || typeof header.cwd !== "string" || !path.isAbsolute(header.cwd)) throw new Error("Invalid session header");
49
+ session = { file, id: header.id, cwd: header.cwd };
50
+ if (headerOnly || !SUB_SESSION_ID.test(session.id) || (scopeCwd && path.resolve(session.cwd) !== scopeCwd)) break;
51
+ continue;
52
+ }
53
+ if (!line.includes(SUB_OWNER_ENTRY)) continue;
54
+ const entry = JSON.parse(line);
55
+ if (entry?.type !== "custom" || entry.customType !== SUB_OWNER_ENTRY) continue;
56
+ const owner = parseSubSessionOwner(entry.data);
57
+ if (owner.subSessionId !== session.id || owner.mainSessionId === session.id
58
+ || (owner.mainSessionFile !== undefined && !path.isAbsolute(owner.mainSessionFile))) throw new Error("Invalid Sub session ownership");
59
+ if (session.owner && session.owner.mainSessionId !== owner.mainSessionId) {
60
+ throw new Error("Conflicting Sub session owners");
61
+ }
62
+ session.owner = owner;
63
+ }
64
+ if (!session) throw new Error("Missing session header");
65
+ return session;
25
66
  } catch (error) {
67
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
26
68
  throw new Error(`Cannot inspect session ${file}: ${error instanceof Error ? error.message : String(error)}`);
27
- } finally { fs.closeSync(fd); }
69
+ } finally {
70
+ lines.close();
71
+ input.destroy();
72
+ }
28
73
  }
29
74
 
30
- export function cleanSubFiles(currentMainId: string, currentSessionDir: string): { removed: number; errors: string[] } {
31
- const stores = [
32
- { root: runtimeRoot(), ownerDirectory: (id: string) => path.basename(path.dirname(channelPath(id, "unused"))) },
33
- { root: subSessionsRoot(), ownerDirectory: (id: string) => path.basename(subSessionDir(id)) },
34
- ].map((store) => {
35
- const candidates = entries(store.root).filter((entry) => entry.isDirectory());
36
- if (candidates.length && fs.lstatSync(store.root).isSymbolicLink()) throw new Error(`Refusing to clean symlinked directory: ${store.root}`);
37
- return { ...store, candidates };
38
- });
39
- if (stores.every((store) => store.candidates.length === 0)) return { removed: 0, errors: [] };
40
-
41
- const sessionsRoot = path.join(getAgentDir(), "sessions");
42
- const directories = new Set([sessionsRoot]);
43
- if (currentSessionDir) directories.add(currentSessionDir);
44
- for (const entry of entries(sessionsRoot)) {
45
- const file = path.join(sessionsRoot, entry.name);
46
- if (entry.isDirectory() || (entry.isSymbolicLink() && fs.statSync(file).isDirectory())) directories.add(file);
75
+ /** Global discovery establishes Main liveness even when only one cwd is being cleaned. */
76
+ export async function cleanSubFiles(currentSessionId: string, currentSessionDir: string, cwd?: string): Promise<CleanupResult> {
77
+ const scopeCwd = cwd ? path.resolve(cwd) : undefined;
78
+ const live = new Set([currentSessionId]); // Includes unsaved/in-memory current sessions.
79
+ const sessions: SavedSession[] = [];
80
+ const errors: string[] = [];
81
+ const ownerFiles = new Map<string, string>();
82
+ for (const directory of sessionDirectories(currentSessionDir)) {
83
+ const files = entries(directory);
84
+ if (files.length && fs.lstatSync(directory).isSymbolicLink()) throw new Error(`Refusing to clean symlinked directory: ${directory}`);
85
+ for (const entry of files) {
86
+ if (!entry.name.endsWith(".jsonl")) continue;
87
+ const file = path.join(directory, entry.name);
88
+ // A linked Main still establishes liveness, but links are never deletion targets.
89
+ const session = await inspectSession(file, scopeCwd);
90
+ if (!session) continue;
91
+ live.add(session.id);
92
+ if (session.owner?.mainSessionFile) ownerFiles.set(session.owner.mainSessionFile, session.owner.mainSessionId);
93
+ if (entry.isFile() && SUB_SESSION_ID.test(session.id) && (!scopeCwd || path.resolve(session.cwd) === scopeCwd)) {
94
+ if (session.owner) sessions.push(session);
95
+ else errors.push(`Preserved Sub without owner metadata: ${file}`);
96
+ }
97
+ }
47
98
  }
48
- const live = new Set([currentMainId]);
49
- // Complete discovery before deleting anything. A read/parse error aborts cleanup.
50
- for (const directory of directories) {
51
- for (const entry of entries(directory)) {
52
- if (entry.name.endsWith(".jsonl")) live.add(sessionId(path.join(directory, entry.name)));
99
+
100
+ const channels: Array<{ directory: string; mainSessionId: string }> = [];
101
+ const runtime = runtimeRoot();
102
+ const owners = entries(runtime);
103
+ if (owners.length && fs.lstatSync(runtime).isSymbolicLink()) throw new Error(`Refusing to clean symlinked directory: ${runtime}`);
104
+ for (const owner of owners) {
105
+ if (!owner.isDirectory()) continue;
106
+ const ownerDir = path.join(runtime, owner.name);
107
+ for (const run of entries(ownerDir)) {
108
+ if (!run.isDirectory()) continue;
109
+ const directory = path.join(ownerDir, run.name);
110
+ const manifest = readManifest(directory);
111
+ if (path.resolve(channelPath(manifest.mainSessionId, manifest.runId)) !== path.resolve(directory)) throw new Error(`Invalid channel identity: ${directory}`);
112
+ if (scopeCwd && path.resolve(manifest.cwd) !== scopeCwd) continue;
113
+ if (manifest.mainSessionFile) ownerFiles.set(manifest.mainSessionFile, manifest.mainSessionId);
114
+ channels.push({ directory, mainSessionId: manifest.mainSessionId });
53
115
  }
54
116
  }
117
+ // Recorded paths also protect Mains using a custom store outside native discovery.
118
+ for (const [file, id] of ownerFiles) {
119
+ if (live.has(id)) continue;
120
+ const main = await inspectSession(file, undefined, true);
121
+ if (main?.id === id) live.add(id);
122
+ }
123
+
55
124
  let removed = 0;
56
- const errors: string[] = [];
57
- for (const store of stores) {
58
- const liveDirectories = new Set([...live].map(store.ownerDirectory));
59
- for (const entry of store.candidates) {
60
- if (liveDirectories.has(entry.name)) continue;
61
- const directory = path.join(store.root, entry.name);
62
- try {
63
- // Do not follow a directory replaced with a symlink during discovery.
64
- if (!fs.lstatSync(directory).isDirectory()) continue;
65
- fs.rmSync(directory, { recursive: true, force: true });
66
- removed++;
67
- } catch (error) {
68
- errors.push(`${directory}: ${error instanceof Error ? error.message : String(error)}`);
69
- }
125
+ let removedChannels = 0;
126
+ // Discovery must finish successfully before any destructive operation.
127
+ for (const session of sessions) {
128
+ if (session.id === currentSessionId || live.has(session.owner!.mainSessionId)) continue;
129
+ try {
130
+ if (!fs.lstatSync(session.file).isFile()) continue;
131
+ fs.unlinkSync(session.file);
132
+ removed++;
133
+ } catch (error) {
134
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") errors.push(`${session.file}: ${error instanceof Error ? error.message : String(error)}`);
135
+ }
136
+ }
137
+ for (const channel of channels) {
138
+ if (live.has(channel.mainSessionId)) continue;
139
+ try {
140
+ if (!fs.lstatSync(channel.directory).isDirectory()) continue;
141
+ fs.rmSync(channel.directory, { recursive: true, force: true });
142
+ removedChannels++;
143
+ try { fs.rmdirSync(path.dirname(channel.directory)); }
144
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOTEMPTY" && (error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
145
+ } catch (error) {
146
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") errors.push(`${channel.directory}: ${error instanceof Error ? error.message : String(error)}`);
70
147
  }
71
148
  }
72
- return { removed, errors };
149
+ return { removed, removedChannels, errors };
73
150
  }
74
151
 
75
- export function registerCleanSubCommand(pi: ExtensionAPI): void {
152
+ export function registerCleanSubCommand(pi: ExtensionAPI, owningMainId?: string): void {
76
153
  pi.registerCommand("clean-sub", {
77
- description: "Delete managed Sub sessions and communication directories whose Main session can no longer be found.",
154
+ description: "Delete orphaned Sub sessions across Pi's native session store and orphaned communication files.",
78
155
  handler: async (_args, ctx) => {
79
156
  try {
80
- const result = cleanSubFiles(ctx.sessionManager.getSessionId(), ctx.sessionManager.getSessionDir());
157
+ const result = await cleanSubFiles(owningMainId ?? ctx.sessionManager.getSessionId(), ctx.sessionManager.getSessionDir());
81
158
  ctx.ui.notify([
82
- `Removed ${result.removed} orphaned Sub data directories (sessions and communication).`,
159
+ `Removed ${result.removed} orphaned Sub sessions and ${result.removedChannels} communication directories.`,
83
160
  ...result.errors,
84
161
  ].join("\n"), result.errors.length ? "warning" : "info");
85
162
  } catch (error) {
@@ -87,4 +164,13 @@ export function registerCleanSubCommand(pi: ExtensionAPI): void {
87
164
  }
88
165
  },
89
166
  });
167
+ pi.on("session_shutdown", async (event, ctx) => {
168
+ if (event.reason !== "quit") return;
169
+ try {
170
+ const result = await cleanSubFiles(owningMainId ?? ctx.sessionManager.getSessionId(), ctx.sessionManager.getSessionDir(), ctx.cwd);
171
+ if (result.errors.length) ctx.ui.notify(`Sub cleanup: ${result.errors.join("\n")}`, "warning");
172
+ } catch (error) {
173
+ ctx.ui.notify(`Sub cleanup aborted: ${error instanceof Error ? error.message : String(error)}`, "warning");
174
+ }
175
+ });
90
176
  }
@@ -13,7 +13,9 @@ export function bindPromptSections(pi: ExtensionAPI, keys: readonly string[], ge
13
13
  }
14
14
 
15
15
  export const SUB_PROTOCOL = `## Pi Subagent Sub protocol
16
- You are an isolated Sub Pi working with a Main Pi. You have not received the Main's conversation and must not read Pi session files. Use talk whenever you need information from the Main or need to deliver work. Each talk sends one message to the Main and ends the current turn. Remain available after delivery. The Main alone decides whether to respond, request more work, or close this Sub session. Never close the session yourself.`;
16
+ You are an isolated Sub Pi working with a Main Pi. You have not received the Main's conversation and must not read Pi session files.
17
+ Use talk to report progress, ask for information, or deliver completed work. Make clear in the message whether it is a progress update, a blocking question, or completed work. By default, talk only sends the message. After a progress update, continue the assigned task without waiting for Main to acknowledge or say 'continue'.
18
+ Set endTurn=true only when the task is complete or you must wait for Main's reply before proceeding. Call terminating talk on its own, without other tools in the same batch. Ending a turn does not close the session; remain available for follow-ups. The Main alone decides whether to respond, request more work, or close this Sub session. Never close the session yourself.`;
17
19
 
18
20
  export function applySubPromptSections(sections: Record<string, string>, profile: ResolvedProfile): void {
19
21
  sections.subagent_sub_protocol = SUB_PROTOCOL;
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import * as fs from "node:fs";
3
+ import * as path from "node:path";
3
4
  import { fileURLToPath } from "node:url";
4
5
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
6
  import { AdapterRegistry } from "./adapters/index.js";
@@ -189,11 +190,16 @@ export class MainRunManager {
189
190
  }
190
191
  const runId = randomUUID();
191
192
  const mainSessionId = this.ctx.sessionManager.getSessionId();
193
+ const mainSessionFile = this.ctx.sessionManager.getSessionFile();
194
+ const subSessionId = resume?.sessionId ?? `sub-${randomUUID()}`;
192
195
  const projectTrusted = this.ctx.isProjectTrusted();
193
196
  const cwd = resume?.cwd ?? input.cwd;
197
+ const sessionDir = resume ? path.dirname(resume.sessionFile)
198
+ : path.resolve(cwd) === path.resolve(this.ctx.cwd) ? this.ctx.sessionManager.getSessionDir() : undefined;
194
199
  const channel = createChannel({
195
200
  runId,
196
201
  mainSessionId,
202
+ ...(mainSessionFile ? { mainSessionFile } : {}),
197
203
  title: input.title,
198
204
  task: input.task,
199
205
  cwd,
@@ -212,7 +218,8 @@ export class MainRunManager {
212
218
  channelDir: channel.channelDir,
213
219
  createdAt: Date.now(),
214
220
  updatedAt: Date.now(),
215
- ...(resume ? { subSessionId: resume.sessionId, subSessionFile: resume.sessionFile } : {}),
221
+ subSessionId,
222
+ ...(resume ? { subSessionFile: resume.sessionFile } : {}),
216
223
  };
217
224
  try {
218
225
  this.save(run);
@@ -226,7 +233,9 @@ export class MainRunManager {
226
233
  cwd,
227
234
  projectTrusted,
228
235
  ...(input.origin ? { origin: input.origin } : {}),
229
- ...(resume ? { resumeSessionId: resume.sessionId, resumeSessionFile: resume.sessionFile } : {}),
236
+ sessionId: subSessionId,
237
+ sessionDir,
238
+ ...(resume ? { resumeSessionFile: resume.sessionFile } : {}),
230
239
  profile: input.profile,
231
240
  channelDir: channel.channelDir,
232
241
  token: channel.token,
package/src/sessions.ts CHANGED
@@ -1,15 +1,43 @@
1
1
  import * as fs from "node:fs";
2
- import * as path from "node:path";
3
- import { getAgentDir } from "@earendil-works/pi-coding-agent";
2
+ import type { ExtensionAPI, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
4
3
  import type { RunSnapshot } from "./types.js";
5
4
 
6
- export function subSessionsRoot(): string {
7
- return path.join(getAgentDir(), "subagent", "sessions");
5
+ export const SUB_OWNER_ENTRY = "subagent-owner";
6
+ export const SUB_SESSION_ID = /^sub-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
7
+
8
+ export interface SubSessionOwner {
9
+ version: 1;
10
+ subSessionId: string;
11
+ mainSessionId: string;
12
+ mainSessionFile?: string;
13
+ }
14
+
15
+ export function parseSubSessionOwner(value: unknown): SubSessionOwner {
16
+ const owner = value as Partial<SubSessionOwner> | undefined;
17
+ if (!owner || owner.version !== 1 || typeof owner.subSessionId !== "string"
18
+ || typeof owner.mainSessionId !== "string" || !owner.mainSessionId
19
+ || (owner.mainSessionFile !== undefined && typeof owner.mainSessionFile !== "string")) {
20
+ throw new Error("Invalid Sub session owner metadata.");
21
+ }
22
+ return owner as SubSessionOwner;
8
23
  }
9
24
 
10
- export function subSessionDir(mainSessionId: string): string {
11
- if (!mainSessionId || mainSessionId === "." || mainSessionId === "..") throw new Error("Invalid Main session ID.");
12
- return path.join(subSessionsRoot(), encodeURIComponent(mainSessionId));
25
+ /** Ownership is session-wide, not branch-relative. Never silently reassign a Sub. */
26
+ export function recordSubSessionOwner(pi: ExtensionAPI, ctx: ExtensionContext, mainSessionId: string, mainSessionFile?: string): void {
27
+ const subSessionId = ctx.sessionManager.getSessionId();
28
+ const entries = ctx.sessionManager.getEntries().filter((entry): entry is SessionEntry & { type: "custom" } =>
29
+ entry.type === "custom" && entry.customType === SUB_OWNER_ENTRY);
30
+ let previous: SubSessionOwner | undefined;
31
+ for (const entry of entries) {
32
+ const owner = parseSubSessionOwner(entry.data);
33
+ if (owner.subSessionId !== subSessionId || owner.mainSessionId !== mainSessionId) {
34
+ throw new Error("Sub session owner does not match this Main.");
35
+ }
36
+ previous = owner;
37
+ }
38
+ if (!previous || previous.mainSessionFile !== mainSessionFile) {
39
+ pi.appendEntry(SUB_OWNER_ENTRY, { version: 1, subSessionId, mainSessionId, ...(mainSessionFile ? { mainSessionFile } : {}) } satisfies SubSessionOwner);
40
+ }
13
41
  }
14
42
 
15
43
  export interface ResumableSubSession {
package/src/tools/main.ts CHANGED
@@ -58,7 +58,7 @@ export function registerMainTools(pi: ExtensionAPI, manager: MainRunManager): vo
58
58
  "Use delegate for work assigned by MAIN.md or a separable task that benefits from an independent context; select an explicitly configured profile and provide a short informative title.",
59
59
  "After delegate launches, do not wait or repeatedly poll. Pi Subagent will wake the Main when the Sub uses talk.",
60
60
  "For Subs labeled user-invoked specialists, follow up only on the user's task and results. Do not assign unrelated work or substitute them for general consulting profiles. New tasks are initiated by the user through a profile command.",
61
- "Use talk to respond to an existing Sub, interrupt_sub to stop its current turn without closing it, and close_sub only after its delivery is accepted or the user asks to close it.",
61
+ "Use talk to respond to an existing Sub, interrupt_sub to stop its current turn without closing it, and close_sub only after its completed work is accepted or the user asks to close it.",
62
62
  ],
63
63
  parameters: Type.Object({
64
64
  title: Type.String({ description: "Short Herdr tab title, up to 48 displayed characters." }),
@@ -131,6 +131,7 @@ export function registerMainTools(pi: ExtensionAPI, manager: MainRunManager): vo
131
131
  label: "talk",
132
132
  description: "Send one message to an existing Sub Pi. Messages enter the Sub's native follow-up queue and are processed after its current work. Format non-trivial messages as readable Markdown with paragraph breaks and lists.",
133
133
  promptSnippet: "Send a message to an existing Sub Pi",
134
+ promptGuidelines: ["Sub talk messages may be progress updates while the Sub keeps working. Do not send routine 'continue' replies or close a Sub merely for reporting progress; respond with talk when input is needed, and use close_sub only after completed work is accepted or the user asks to close it."],
134
135
  executionMode: "sequential",
135
136
  parameters: Type.Object({
136
137
  runId: Type.String({ description: "Full Sub run ID or a unique prefix." }),
package/src/tools/sub.ts CHANGED
@@ -21,6 +21,8 @@ import { applyProfileTools } from "../profiles/tool-selection.js";
21
21
  import { MANUAL_SUB_GUIDANCE, subSessionName } from "../manual-context.js";
22
22
  import { talkView } from "../talk-render.js";
23
23
  import { ChannelMonitor } from "../channel-monitor.js";
24
+ import { recordSubSessionOwner } from "../sessions.js";
25
+ import { registerCleanSubCommand } from "../commands/clean-sub.js";
24
26
  import { bindInboxEvents } from "../inbox-state.js";
25
27
  import { deliverTalk, ProtocolErrors } from "../talk-delivery.js";
26
28
  import type { ActiveTurn, DelegateManifest } from "../types.js";
@@ -61,6 +63,12 @@ export function registerSub(pi: ExtensionAPI): void {
61
63
  );
62
64
  bindInboxEvents(pi, () => activeContext, () => monitor.wakeAll());
63
65
 
66
+ // Fork/clone would copy ownership metadata into a different session.
67
+ pi.on("session_before_fork", (_event, ctx) => {
68
+ ctx.ui.notify("Sub sessions do not support /fork or /clone.", "warning");
69
+ return { cancel: true };
70
+ });
71
+
64
72
  // Pi emits resource discovery after all session_start handlers, including
65
73
  // extensions that register the profile's tools dynamically during startup.
66
74
  pi.on("resources_discover", (_event, ctx) => {
@@ -77,6 +85,7 @@ export function registerSub(pi: ExtensionAPI): void {
77
85
 
78
86
  pi.on("session_start", (_event, ctx) => {
79
87
  activeContext = ctx;
88
+ recordSubSessionOwner(pi, ctx, loaded.manifest.mainSessionId, loaded.manifest.mainSessionFile);
80
89
  pi.setSessionName(subSessionName(loaded.manifest.title, loaded.manifest.origin));
81
90
  ctx.ui.setTitle(`[sub] ${loaded.manifest.title}`);
82
91
  ctx.ui.setStatus("subagent", `sub · ${loaded.manifest.profile.name}`);
@@ -174,12 +183,16 @@ export function registerSub(pi: ExtensionAPI): void {
174
183
  name: "talk",
175
184
  label: "talk",
176
185
  exposure: "model-only",
177
- description: "Send one message to the Main Pi and end the current turn. Use it to ask for information or deliver work. Format non-trivial messages as readable Markdown with paragraph breaks and lists.",
178
- promptSnippet: "Send a message to the Main Pi",
179
- promptGuidelines: ["Use talk whenever the Sub needs to communicate with the Main; the Main decides when to close the Sub session."],
186
+ description: "Send one message to the Main Pi. Continue working by default; set endTurn=true only when the task is complete or you must wait for Main's reply. Format non-trivial messages as readable Markdown with paragraph breaks and lists.",
187
+ promptSnippet: "Send a message to the Main Pi, optionally ending the current turn",
188
+ promptGuidelines: [
189
+ "Use talk for progress updates, questions, and completed work. After progress updates, continue the task without waiting for an acknowledgement.",
190
+ "Set talk endTurn=true only when the task is complete or you must wait for Main's reply. Call terminating talk on its own, without other tools in the same batch. The Main decides when to close the Sub session.",
191
+ ],
180
192
  executionMode: "sequential",
181
193
  parameters: Type.Object({
182
194
  message: Type.String({ description: "Message to the other Agent. For non-trivial content, use readable Markdown with paragraph breaks and lists." }),
195
+ endTurn: Type.Optional(Type.Boolean({ description: "End the current turn after sending. Defaults to false: continue working. Set true only when the task is complete or you must wait for Main's reply." })),
183
196
  }),
184
197
  renderCall(args, theme, context) {
185
198
  const message = typeof args.message === "string" ? args.message : "";
@@ -196,7 +209,7 @@ export function registerSub(pi: ExtensionAPI): void {
196
209
  return {
197
210
  content: [{ type: "text", text: "Message queued for the Main Pi." }],
198
211
  details: { messageId: message.id },
199
- terminate: true,
212
+ terminate: params.endTurn === true,
200
213
  };
201
214
  },
202
215
  renderResult(result, _options, theme, context) {
@@ -212,4 +225,6 @@ export function registerSub(pi: ExtensionAPI): void {
212
225
  writeSubClosed(loaded.channelDir, loaded.manifest, "The Sub session was closed manually.");
213
226
  }
214
227
  });
228
+ // Run after the Sub shutdown protocol, and preserve its still-active owning Main.
229
+ registerCleanSubCommand(pi, loaded.manifest.mainSessionId);
215
230
  }
package/src/types.ts CHANGED
@@ -4,6 +4,7 @@ export interface DelegateManifest {
4
4
  version: 1;
5
5
  runId: string;
6
6
  mainSessionId: string;
7
+ mainSessionFile?: string;
7
8
  title: string;
8
9
  task: string;
9
10
  cwd: string;
@@ -96,8 +97,9 @@ export interface SubLaunchSpec {
96
97
  cwd: string;
97
98
  projectTrusted: boolean;
98
99
  origin?: "manual";
99
- resumeSessionId?: string;
100
- /** Recorded path also supports sessions saved before per-Main directories. */
100
+ sessionId: string;
101
+ /** Same-cwd Subs inherit Main's session directory, including custom stores. */
102
+ sessionDir?: string;
101
103
  resumeSessionFile?: string;
102
104
  profile: ResolvedProfile;
103
105
  channelDir: string;