@oldflag2333333/pi-subagent 0.1.4 → 0.1.5

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
@@ -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
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oldflag2333333/pi-subagent",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
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
  }
@@ -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/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}`);
@@ -212,4 +221,6 @@ export function registerSub(pi: ExtensionAPI): void {
212
221
  writeSubClosed(loaded.channelDir, loaded.manifest, "The Sub session was closed manually.");
213
222
  }
214
223
  });
224
+ // Run after the Sub shutdown protocol, and preserve its still-active owning Main.
225
+ registerCleanSubCommand(pi, loaded.manifest.mainSessionId);
215
226
  }
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;