@oldflag2333333/pi-subagent 0.1.3 → 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 +13 -4
- package/README.zh-CN.md +13 -4
- package/package.json +1 -1
- package/src/adapters/herdr.ts +14 -4
- package/src/channel.ts +1 -0
- package/src/commands/clean-sub.ts +139 -53
- package/src/run-manager.ts +11 -2
- package/src/sessions.ts +35 -7
- package/src/tools/sub.ts +11 -0
- package/src/types.ts +4 -2
package/README.md
CHANGED
|
@@ -164,26 +164,35 @@ 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
|
|
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/
|
|
170
|
+
~/.pi/agent/sessions/<encoded-cwd>/<timestamp>_sub-<UUID>.jsonl
|
|
171
171
|
```
|
|
172
172
|
|
|
173
|
-
|
|
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
|
-
|
|
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.
|
|
185
192
|
- **Reloading Main does not close Subs.** Close tabs you no longer need. Hard crashes may require manual cleanup.
|
|
186
193
|
|
|
194
|
+
Before a fresh or resumed Sub launch, the plugin submits a short shell probe and waits up to 60 seconds for it to execute before submitting the full Pi command. This avoids macOS truncating long commands (especially resume paths) and dropping Enter while the shell is still initializing. The wait is cancellable; launch failure attempts to close the new tab rather than sending Enter to a partial command. See [Herdr issue #4950](https://github.com/herdrdev/herdr/issues/4950).
|
|
195
|
+
|
|
187
196
|
If a profile is missing or fails to start, run `/profiles` first. Check its directory/name match, project trust, and any referenced tools, skills, or extensions. If Herdr is unavailable, confirm Pi is running inside a Herdr workspace.
|
|
188
197
|
|
|
189
198
|
To hide or show Pi Subagent Subs in Herdr's Agents panel, see the optional [Herdr companion plugin](herdr-plugin/README.md).
|
package/README.zh-CN.md
CHANGED
|
@@ -164,26 +164,35 @@ reviewer/
|
|
|
164
164
|
|
|
165
165
|
## 清理已删除会话的遗留文件
|
|
166
166
|
|
|
167
|
-
持久化 Sub
|
|
167
|
+
持久化 Sub 与普通会话一起使用 Pi 原生会话目录,ID 格式为 `sub-<UUID>`:
|
|
168
168
|
|
|
169
169
|
```text
|
|
170
|
-
~/.pi/agent/
|
|
170
|
+
~/.pi/agent/sessions/<工作目录编码>/<timestamp>_sub-<UUID>.jsonl
|
|
171
171
|
```
|
|
172
172
|
|
|
173
|
-
|
|
173
|
+
相同工作目录的 Sub 沿用 Main 的会话目录(包括 `--session-dir` 自定义目录);指定其他工作目录时使用该目录对应的 Pi 常规存储。临时 Sub 仍使用 `--no-session`。原生 `/resume` 可以发现持久化 Sub;因此 `--continue` 也可能选中最近活动的 Sub。
|
|
174
174
|
|
|
175
|
-
|
|
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
|
- **子助手拥有独立对话。** 它会收到任务、角色配置和常规项目上下文,但不会收到主对话的聊天记录。请在任务中提供必要信息。
|
|
185
192
|
- **重载主 Pi 不会关闭子助手。** 不再需要时请关闭标签页;异常崩溃后可能需要手动清理。
|
|
186
193
|
|
|
194
|
+
新建或恢复子助手时,插件先发送一条短 shell 探针,并等待最多 60 秒确认它已执行,再提交完整 Pi 命令。这样可避免 macOS 上 shell 仍在初始化时,长命令(尤其是包含会话路径的 resume)被截断、末尾 Enter 丢失的问题。探针等待支持取消;启动失败会尝试关闭新建的标签页,不会向半截命令补发 Enter。参见 [Herdr 已知问题 #4950](https://github.com/herdrdev/herdr/issues/4950)。
|
|
195
|
+
|
|
187
196
|
如果找不到角色或启动失败,先运行 `/profiles`,检查目录名与 `name` 是否一致、项目是否受信任,以及引用的工具、技能和扩展是否可用。如果提示 Herdr 不可用,请确认 Pi 正在 Herdr 工作区内运行。
|
|
188
197
|
|
|
189
198
|
如需在 Herdr 的 Agents 面板中隐藏或显示 Pi Subagent 子助手,可安装可选的 [Herdr 配套插件](herdr-plugin/README.md)(英文说明)。
|
package/package.json
CHANGED
package/src/adapters/herdr.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import * as fs from "node:fs";
|
|
2
3
|
import * as path from "node:path";
|
|
3
4
|
import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
5
|
import { subSessionName } from "../manual-context.js";
|
|
5
6
|
import { subCapabilityArgs } from "../profiles/launch-args.js";
|
|
6
|
-
import { subSessionDir } from "../sessions.js";
|
|
7
7
|
import type { SubAgentStatus, SubLaunchSpec, SubSurfaceAdapter, SurfaceHandle } from "../types.js";
|
|
8
8
|
|
|
9
9
|
function parseEnvelope(stdout: string): Record<string, unknown> {
|
|
@@ -40,9 +40,9 @@ function subArgs(spec: SubLaunchSpec): string[] {
|
|
|
40
40
|
"--no-extensions",
|
|
41
41
|
"-e", spec.entryPath,
|
|
42
42
|
...(fs.existsSync(herdrIntegration) ? ["-e", herdrIntegration] : []),
|
|
43
|
-
...(spec.profile.sessionPersistence === "persistent"
|
|
44
|
-
? ["--session-dir",
|
|
45
|
-
...(spec.
|
|
43
|
+
...(spec.profile.sessionPersistence === "persistent"
|
|
44
|
+
? (spec.sessionDir ? ["--session-dir", spec.sessionDir] : []) : ["--no-session"]),
|
|
45
|
+
...(spec.resumeSessionFile ? ["--session", spec.resumeSessionFile] : ["--session-id", spec.sessionId]),
|
|
46
46
|
spec.projectTrusted ? "--approve" : "--no-approve",
|
|
47
47
|
"--name", subSessionName(safeLabel(spec.title), spec.origin),
|
|
48
48
|
...subCapabilityArgs(spec.profile, spec.entryPath),
|
|
@@ -99,6 +99,16 @@ export class HerdrTabAdapter implements SubSurfaceAdapter {
|
|
|
99
99
|
try {
|
|
100
100
|
if (created.code !== 0 || created.killed) throw new Error(created.stderr || "Failed to create Herdr tab.");
|
|
101
101
|
if (!paneId) throw new Error("Herdr tab creation response did not contain a root pane ID.");
|
|
102
|
+
// A new pane's shell can still be initializing in canonical mode.
|
|
103
|
+
// On macOS a long launch line can lose its tail (including Enter).
|
|
104
|
+
// Execute a short probe first; match only its output, never the echoed
|
|
105
|
+
// command. Unwrapped rows keep a wrapped command from matching early.
|
|
106
|
+
const readyMarker = `pi-subagent-shell-ready-${randomUUID()}`;
|
|
107
|
+
await step(["pane", "run", paneId, `echo ${readyMarker}`], 10_000, "Failed to probe the Sub shell.");
|
|
108
|
+
await step([
|
|
109
|
+
"pane", "wait-output", paneId, "--regex", `^${readyMarker}$`,
|
|
110
|
+
"--source", "recent-unwrapped", "--timeout", "60000",
|
|
111
|
+
], 65_000, "The Sub shell did not become ready.");
|
|
102
112
|
const name = agentName(spec.runId);
|
|
103
113
|
await step([
|
|
104
114
|
"agent", "start", name, "--kind", "pi", "--pane", paneId, "--timeout", "60000", "--", ...subArgs(spec),
|
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 {
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
const
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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 {
|
|
69
|
+
} finally {
|
|
70
|
+
lines.close();
|
|
71
|
+
input.destroy();
|
|
72
|
+
}
|
|
28
73
|
}
|
|
29
74
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
]
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
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
|
|
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
|
}
|
package/src/run-manager.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
7
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
100
|
-
/**
|
|
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;
|