@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 +12 -5
- package/README.zh-CN.md +12 -5
- package/docs/messaging.md +9 -0
- package/package.json +1 -1
- package/src/adapters/herdr.ts +3 -4
- package/src/channel.ts +1 -0
- package/src/commands/clean-sub.ts +139 -53
- package/src/profiles/system-prompt.ts +3 -1
- package/src/run-manager.ts +11 -2
- package/src/sessions.ts +35 -7
- package/src/tools/main.ts +2 -1
- package/src/tools/sub.ts +19 -4
- package/src/types.ts +4 -2
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
|
|
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.
|
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
|
|
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
|
- **子助手拥有独立对话。** 它会收到任务、角色配置和常规项目上下文,但不会收到主对话的聊天记录。请在任务中提供必要信息。
|
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
package/src/adapters/herdr.ts
CHANGED
|
@@ -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"
|
|
45
|
-
? ["--session-dir",
|
|
46
|
-
...(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]),
|
|
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 {
|
|
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
|
}
|
|
@@ -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.
|
|
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;
|
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/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
|
|
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
|
|
178
|
-
promptSnippet: "Send a message to the Main Pi",
|
|
179
|
-
promptGuidelines: [
|
|
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
|
-
|
|
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;
|