@oldflag2333333/pi-subagent 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,67 @@
1
1
  {
2
2
  "name": "@oldflag2333333/pi-subagent",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Reusable specialist profiles and independent sub-agents for Pi",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": {
8
+ "name": "oldflag",
9
+ "email": "oldflag2333333@gmail.com",
10
+ "url": "https://github.com/oldflag2333333"
11
+ },
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/oldflag2333333/pi-subagent.git"
15
+ },
16
+ "homepage": "https://github.com/oldflag2333333/pi-subagent#readme",
17
+ "bugs": {
18
+ "url": "https://github.com/oldflag2333333/pi-subagent/issues"
19
+ },
20
+ "keywords": [
21
+ "pi-package",
22
+ "pi-extension",
23
+ "profiles",
24
+ "subagent",
25
+ "herdr"
26
+ ],
27
+ "files": [
28
+ "src",
29
+ "herdr-plugin",
30
+ "README.md",
31
+ "README.zh-CN.md",
32
+ "docs",
33
+ "LICENSE"
34
+ ],
35
+ "pi": {
36
+ "extensions": [
37
+ "./src/index.ts"
38
+ ]
39
+ },
40
+ "scripts": {
41
+ "typecheck": "tsc --noEmit",
42
+ "test": "node --import tsx --test test/*.test.ts",
43
+ "check": "npm run typecheck && npm test"
44
+ },
45
+ "peerDependencies": {
46
+ "@earendil-works/pi-agent-core": "*",
47
+ "@earendil-works/pi-ai": "*",
48
+ "@earendil-works/pi-coding-agent": ">=1.0.4",
49
+ "@earendil-works/pi-tui": "*",
50
+ "typebox": "*"
51
+ },
52
+ "devDependencies": {
53
+ "@earendil-works/pi-agent-core": "^1.0.4",
54
+ "@earendil-works/pi-ai": "^1.0.4",
55
+ "@earendil-works/pi-coding-agent": "^1.0.4",
56
+ "@earendil-works/pi-tui": "^1.0.4",
57
+ "@types/node": "^22.15.0",
58
+ "@types/semver": "^7.8.0",
59
+ "semver": "^7.8.5",
60
+ "tsx": "^4.20.0",
61
+ "typebox": "^1.0.72",
62
+ "typescript": "^5.8.3"
63
+ },
64
+ "engines": {
65
+ "node": ">=22.19.0"
66
+ }
67
+ }
@@ -0,0 +1,155 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ import { subSessionName } from "../manual-context.js";
5
+ import { subCapabilityArgs } from "../profiles/launch-args.js";
6
+ import type { SubAgentStatus, SubLaunchSpec, SubSurfaceAdapter, SurfaceHandle } from "../types.js";
7
+
8
+ function parseEnvelope(stdout: string): Record<string, unknown> {
9
+ const lines = stdout.trim().split(/\r?\n/).filter(Boolean).reverse();
10
+ for (const line of lines) {
11
+ try {
12
+ const parsed = JSON.parse(line) as Record<string, unknown>;
13
+ return parsed.result && typeof parsed.result === "object" ? parsed.result as Record<string, unknown> : parsed;
14
+ } catch {}
15
+ }
16
+ throw new Error(`Herdr returned no JSON response: ${stdout.slice(0, 300)}`);
17
+ }
18
+
19
+ function nestedString(value: unknown, ...keys: string[]): string | undefined {
20
+ let current: unknown = value;
21
+ for (const key of keys) {
22
+ if (!current || typeof current !== "object") return undefined;
23
+ current = (current as Record<string, unknown>)[key];
24
+ }
25
+ return typeof current === "string" ? current : undefined;
26
+ }
27
+
28
+ function safeLabel(value: string): string {
29
+ return value.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim().slice(0, 48) || "delegated task";
30
+ }
31
+
32
+ function agentName(runId: string): string {
33
+ return `sub-${runId.toLowerCase().replace(/[^a-z0-9_-]/g, "").slice(0, 20)}`;
34
+ }
35
+
36
+ function subArgs(spec: SubLaunchSpec): string[] {
37
+ const herdrIntegration = path.join(getAgentDir(), "extensions", "herdr-agent-state.ts");
38
+ return [
39
+ "--no-extensions",
40
+ "-e", spec.entryPath,
41
+ ...(fs.existsSync(herdrIntegration) ? ["-e", herdrIntegration] : []),
42
+ ...(spec.profile.sessionPersistence === "persistent" || spec.resumeSessionId ? [] : ["--no-session"]),
43
+ ...(spec.resumeSessionId ? ["--session", spec.resumeSessionId] : []),
44
+ spec.projectTrusted ? "--approve" : "--no-approve",
45
+ "--name", subSessionName(safeLabel(spec.title), spec.origin),
46
+ ...subCapabilityArgs(spec.profile, spec.entryPath),
47
+ ];
48
+ }
49
+
50
+ /** A launch failed and its tab could not be rolled back; keep it addressable. */
51
+ export class HerdrLaunchCleanupError extends Error {
52
+ constructor(readonly handle: SurfaceHandle, cause: unknown, cleanup: unknown) {
53
+ super(`Sub launch failed: ${cause instanceof Error ? cause.message : String(cause)}. Tab cleanup also failed: ${cleanup instanceof Error ? cleanup.message : String(cleanup)}.`, { cause });
54
+ }
55
+ }
56
+
57
+ export class HerdrTabAdapter implements SubSurfaceAdapter {
58
+ readonly id = "herdr" as const;
59
+ constructor(private readonly pi: ExtensionAPI) {}
60
+
61
+ async available(): Promise<boolean> {
62
+ if (process.env.HERDR_ENV !== "1" || !process.env.HERDR_WORKSPACE_ID) return false;
63
+ const result = await this.pi.exec("herdr", ["status", "server"], { timeout: 3000 });
64
+ return result.code === 0;
65
+ }
66
+
67
+ async launch(spec: SubLaunchSpec, signal?: AbortSignal): Promise<SurfaceHandle> {
68
+ const workspaceId = process.env.HERDR_WORKSPACE_ID;
69
+ if (!workspaceId) throw new Error("HERDR_WORKSPACE_ID is unavailable; Pi is not running in a Herdr workspace.");
70
+ signal?.throwIfAborted();
71
+ const label = `↳ pi · ${safeLabel(spec.title)}`;
72
+ const created = await this.pi.exec("herdr", [
73
+ "tab", "create",
74
+ "--workspace", workspaceId,
75
+ "--cwd", spec.cwd,
76
+ "--label", label,
77
+ "--env", "PI_SUBAGENT_ROLE=sub",
78
+ "--env", `PI_SUBAGENT_CHANNEL=${spec.channelDir}`,
79
+ "--env", `PI_SUBAGENT_TOKEN=${spec.token}`,
80
+ "--no-focus",
81
+ ], { timeout: 15_000, signal });
82
+ let payload: Record<string, unknown>;
83
+ try { payload = parseEnvelope(created.stdout); } catch (error) {
84
+ if (created.code !== 0 || created.killed) throw new Error(created.stderr || "Failed to create Herdr tab.");
85
+ throw error;
86
+ }
87
+ const tabId = nestedString(payload, "tab", "tab_id") ?? nestedString(payload, "tab", "id");
88
+ const paneId = nestedString(payload, "root_pane", "pane_id") ?? nestedString(payload, "pane", "pane_id");
89
+ if (!tabId) throw new Error("Herdr tab creation response did not contain a tab ID; automatic cleanup is unavailable.");
90
+ const handle: SurfaceHandle = { adapter: "herdr", tabId, ...(paneId ? { paneId } : {}) };
91
+ const step = async (args: string[], timeout: number, failure: string) => {
92
+ signal?.throwIfAborted();
93
+ const result = await this.pi.exec("herdr", args, { timeout, signal });
94
+ if (result.code !== 0 || result.killed) throw new Error(result.stderr || failure);
95
+ signal?.throwIfAborted();
96
+ };
97
+ try {
98
+ if (created.code !== 0 || created.killed) throw new Error(created.stderr || "Failed to create Herdr tab.");
99
+ if (!paneId) throw new Error("Herdr tab creation response did not contain a root pane ID.");
100
+ const name = agentName(spec.runId);
101
+ await step([
102
+ "agent", "start", name, "--kind", "pi", "--pane", paneId, "--timeout", "60000", "--", ...subArgs(spec),
103
+ ], 70_000, "Failed to start Sub Pi in Herdr tab.");
104
+ await step([
105
+ "pane", "report-metadata", paneId, "--source", "subagent",
106
+ "--token", "subagent_role=sub", "--token", `subagent_main_session=${spec.mainSessionId}`,
107
+ "--token", `subagent_run_id=${spec.runId}`, "--token", `subagent_profile=${spec.profile.name}`,
108
+ ], 10_000, "Failed to mark the Herdr pane as a Pi Subagent Sub.");
109
+ await step(["agent", "prompt", name, spec.task], 20_000, "Failed to submit the delegated task to the Herdr agent.");
110
+ return handle;
111
+ } catch (error) {
112
+ try {
113
+ // Rollback must not inherit the cancellation that caused the failure.
114
+ await this.close(handle);
115
+ } catch (cleanup) {
116
+ throw new HerdrLaunchCleanupError(handle, error, cleanup);
117
+ }
118
+ throw error;
119
+ }
120
+ }
121
+
122
+ async status(handle: SurfaceHandle | undefined): Promise<SubAgentStatus> {
123
+ if (!handle?.paneId) return "unknown";
124
+ try {
125
+ const response = await this.pi.exec("herdr", ["agent", "get", handle.paneId], { timeout: 3000 });
126
+ if (response.code !== 0) return "unknown";
127
+ const agent = parseEnvelope(response.stdout).agent;
128
+ if (!agent || typeof agent !== "object") return "unknown";
129
+ const info = agent as Record<string, unknown>;
130
+ if (info.pane_id !== handle.paneId || (handle.tabId && info.tab_id !== handle.tabId)) return "unknown";
131
+ return info.agent_status === "working" || info.agent_status === "blocked" || info.agent_status === "idle"
132
+ ? info.agent_status : "unknown";
133
+ } catch {
134
+ return "unknown";
135
+ }
136
+ }
137
+
138
+ /** Distinguish an explicitly missing tab from a disconnected or unhealthy Herdr. */
139
+ async exists(handle: SurfaceHandle | undefined): Promise<boolean | undefined> {
140
+ if (!handle?.tabId) return undefined;
141
+ try {
142
+ const response = await this.pi.exec("herdr", ["tab", "get", handle.tabId], { timeout: 3000 });
143
+ if (response.killed) return undefined;
144
+ const payload = parseEnvelope(response.stdout);
145
+ if (response.code !== 0) return nestedString(payload, "error", "code") === "tab_not_found" ? false : undefined;
146
+ return nestedString(payload, "tab", "tab_id") === handle.tabId ? true : undefined;
147
+ } catch { return undefined; }
148
+ }
149
+
150
+ async close(handle: SurfaceHandle): Promise<void> {
151
+ if (!handle.tabId) return;
152
+ const result = await this.pi.exec("herdr", ["tab", "close", handle.tabId], { timeout: 10_000 });
153
+ if (result.code !== 0 || result.killed) throw new Error(result.stderr || `Failed to close Herdr tab ${handle.tabId}.`);
154
+ }
155
+ }
@@ -0,0 +1,31 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { HerdrTabAdapter } from "./herdr.js";
3
+ import type { SubAgentStatus, SurfaceHandle } from "../types.js";
4
+
5
+ export class AdapterRegistry {
6
+ private readonly herdr: HerdrTabAdapter;
7
+
8
+ constructor(pi: ExtensionAPI) {
9
+ this.herdr = new HerdrTabAdapter(pi);
10
+ }
11
+
12
+ async resolve(): Promise<HerdrTabAdapter> {
13
+ if (!(await this.herdr.available())) {
14
+ throw new Error("Pi Subagent requires Herdr, but the Herdr adapter is unavailable.");
15
+ }
16
+ return this.herdr;
17
+ }
18
+
19
+ async status(handle: SurfaceHandle | undefined): Promise<SubAgentStatus> {
20
+ return this.herdr.status(handle);
21
+ }
22
+
23
+ async exists(handle: SurfaceHandle | undefined): Promise<boolean | undefined> {
24
+ return this.herdr.exists(handle);
25
+ }
26
+
27
+ async close(handle: SurfaceHandle | undefined): Promise<void> {
28
+ if (!handle) return;
29
+ await this.herdr.close(handle);
30
+ }
31
+ }
@@ -0,0 +1,127 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { TalkDirection } from "./channel.js";
4
+
5
+ export const CHANNEL_DEBOUNCE_MS = 20;
6
+ export const CHANNEL_RESCAN_MS = 5000;
7
+ export type WatchDirectory = (directory: string, listener: (event: fs.WatchEventType, filename: string | Buffer | null) => void) => fs.FSWatcher;
8
+ interface WatchedDirectory {
9
+ path: string;
10
+ accept: (name: string) => boolean;
11
+ watcher?: fs.FSWatcher;
12
+ error?: string;
13
+ }
14
+ interface WatchedChannel {
15
+ directory: string;
16
+ direction: TalkDirection;
17
+ directories: WatchedDirectory[];
18
+ }
19
+
20
+ /** Files notify; the same bounded rescan path handles missed/unsupported watches. */
21
+ export class ChannelMonitor {
22
+ private readonly channels = new Map<string, WatchedChannel>();
23
+ private readonly dirty = new Set<string>();
24
+ private active = false;
25
+ private debounce?: ReturnType<typeof setTimeout>;
26
+ private fallback?: ReturnType<typeof setInterval>;
27
+
28
+ constructor(
29
+ private readonly scan: (ids: string[]) => void,
30
+ private readonly onError: (id: string, error: unknown) => void,
31
+ private readonly onRecovered: (id: string) => void = () => {},
32
+ private readonly watch: WatchDirectory = (directory, listener) => fs.watch(directory, { persistent: false }, listener),
33
+ ) {}
34
+
35
+ start(): void {
36
+ if (this.active) return;
37
+ this.active = true;
38
+ for (const [id, channel] of this.channels) this.attach(id, channel);
39
+ this.fallback = setInterval(() => {
40
+ for (const [id, channel] of this.channels) this.attach(id, channel);
41
+ this.wakeAll();
42
+ }, CHANNEL_RESCAN_MS);
43
+ this.fallback.unref?.();
44
+ }
45
+
46
+ add(id: string, directory: string, direction: TalkDirection): void {
47
+ const existing = this.channels.get(id);
48
+ if (existing?.directory === directory && existing.direction === direction) return;
49
+ this.remove(id);
50
+ const controls = new Set(direction === "to-main" ? ["session.json", "closed.json"] : ["close.json", "interrupt.json"]);
51
+ const channel: WatchedChannel = {
52
+ directory, direction,
53
+ directories: [
54
+ { path: directory, accept: (name) => controls.has(name) },
55
+ { path: path.join(directory, direction), accept: (name) => name.endsWith(".json") },
56
+ ],
57
+ };
58
+ this.channels.set(id, channel);
59
+ if (this.active) this.attach(id, channel);
60
+ }
61
+
62
+ private attach(id: string, channel: WatchedChannel): void {
63
+ for (const slot of channel.directories) {
64
+ if (slot.watcher) continue;
65
+ const fail = (error: unknown) => {
66
+ if (!this.active || this.channels.get(id) !== channel) return;
67
+ const signature = error instanceof Error ? error.message : String(error);
68
+ if (slot.error !== signature) { slot.error = signature; this.onError(id, error); }
69
+ };
70
+ try {
71
+ const watcher = this.watch(slot.path, (_event, filename) => {
72
+ if (this.channels.get(id) !== channel) return;
73
+ if (filename == null || slot.accept(path.basename(filename.toString()))) this.wake(id);
74
+ });
75
+ slot.watcher = watcher;
76
+ watcher.on("error", (error) => {
77
+ if (slot.watcher !== watcher) return;
78
+ slot.watcher = undefined;
79
+ watcher.close();
80
+ fail(error);
81
+ this.wake(id);
82
+ });
83
+ watcher.on("close", () => {
84
+ if (slot.watcher !== watcher) return;
85
+ slot.watcher = undefined;
86
+ fail(new Error(`File watcher closed: ${slot.path}`));
87
+ });
88
+ if (slot.error) { slot.error = undefined; this.onRecovered(id); }
89
+ } catch (error) { fail(error); }
90
+ }
91
+ }
92
+
93
+ wake(id: string): void {
94
+ if (!this.active || !this.channels.has(id)) return;
95
+ this.dirty.add(id);
96
+ if (this.debounce) return;
97
+ this.debounce = setTimeout(() => {
98
+ this.debounce = undefined;
99
+ const ids = [...this.dirty];
100
+ this.dirty.clear();
101
+ if (!this.active || ids.length === 0) return;
102
+ try { this.scan(ids); } catch (error) { this.onError(ids.join(", "), error); }
103
+ }, CHANNEL_DEBOUNCE_MS);
104
+ this.debounce.unref?.();
105
+ }
106
+ wakeAll(): void {
107
+ for (const id of this.channels.keys()) this.wake(id);
108
+ }
109
+ remove(id: string): void {
110
+ const channel = this.channels.get(id);
111
+ this.channels.delete(id);
112
+ this.dirty.delete(id);
113
+ for (const slot of channel?.directories ?? []) {
114
+ const watcher = slot.watcher;
115
+ slot.watcher = undefined;
116
+ watcher?.close();
117
+ }
118
+ }
119
+ stop(): void {
120
+ this.active = false;
121
+ if (this.debounce) clearTimeout(this.debounce);
122
+ if (this.fallback) clearInterval(this.fallback);
123
+ this.debounce = undefined;
124
+ this.fallback = undefined;
125
+ for (const id of [...this.channels.keys()]) this.remove(id);
126
+ }
127
+ }
package/src/channel.ts ADDED
@@ -0,0 +1,286 @@
1
+ import { randomBytes, randomUUID } from "node:crypto";
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import type {
6
+ SubClosedMessage,
7
+ SubSessionInfo,
8
+ CloseMessage,
9
+ DelegateManifest,
10
+ ActiveTurn,
11
+ InterruptRequest,
12
+ TalkMessage,
13
+ } from "./types.js";
14
+
15
+ const MAX_MANIFEST_BYTES = 1024 * 1024;
16
+ const MAX_TALK_BYTES = 1024 * 1024;
17
+ const MAX_CONTROL_BYTES = 64 * 1024;
18
+
19
+ export const MESSAGE_TYPE = "subagent-message";
20
+
21
+ export type TalkDirection = "to-main" | "to-sub";
22
+
23
+ function safeSegment(value: string): string {
24
+ return value.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 96) || "unknown";
25
+ }
26
+
27
+ export function runtimeRoot(): string {
28
+ const base = process.env.XDG_RUNTIME_DIR || os.tmpdir();
29
+ const owner = typeof process.getuid === "function" ? String(process.getuid()) : safeSegment(os.userInfo().username);
30
+ return path.join(base, `pi-subagent-${owner}`);
31
+ }
32
+
33
+ export function channelPath(mainSessionId: string, runId: string): string {
34
+ return path.join(runtimeRoot(), safeSegment(mainSessionId), safeSegment(runId));
35
+ }
36
+
37
+ export function writeAtomicJson(file: string, value: unknown, maxBytes = MAX_CONTROL_BYTES): void {
38
+ const serialized = `${JSON.stringify(value, null, 2)}\n`;
39
+ if (Buffer.byteLength(serialized, "utf8") > maxBytes) throw new Error(`${path.basename(file)} exceeds ${maxBytes} bytes.`);
40
+ // createChannel owns directory creation. Late writes must not resurrect a closed channel.
41
+ const temporary = `${file}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
42
+ try {
43
+ fs.writeFileSync(temporary, serialized, { encoding: "utf8", mode: 0o600 });
44
+ fs.renameSync(temporary, file);
45
+ } finally {
46
+ fs.rmSync(temporary, { force: true });
47
+ }
48
+ }
49
+
50
+ function readJson(file: string): unknown | undefined {
51
+ try {
52
+ return JSON.parse(fs.readFileSync(file, "utf8")) as unknown;
53
+ } catch (error) {
54
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
55
+ throw error;
56
+ }
57
+ }
58
+
59
+ function record(value: unknown): Record<string, unknown> | undefined {
60
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
61
+ }
62
+
63
+ export function createChannel(input: Omit<DelegateManifest, "version" | "token" | "createdAt">): DelegateManifest & { channelDir: string } {
64
+ const token = randomBytes(32).toString("hex");
65
+ const channelDir = channelPath(input.mainSessionId, input.runId);
66
+ fs.mkdirSync(path.dirname(channelDir), { recursive: true, mode: 0o700 });
67
+ // Never replace an existing run\'s capability token or remove its channel.
68
+ fs.mkdirSync(channelDir, { mode: 0o700 });
69
+ try {
70
+ fs.mkdirSync(path.join(channelDir, "to-main"), { mode: 0o700 });
71
+ fs.mkdirSync(path.join(channelDir, "to-sub"), { mode: 0o700 });
72
+ const manifest: DelegateManifest = { version: 1, ...input, token, createdAt: Date.now() };
73
+ writeAtomicJson(path.join(channelDir, "manifest.json"), manifest, MAX_MANIFEST_BYTES);
74
+ return { ...manifest, channelDir };
75
+ } catch (error) {
76
+ try { removeChannel(channelDir); } catch (cleanup) {
77
+ throw new AggregateError([error, cleanup], `Failed to create and clean up Pi Subagent channel ${channelDir}.`);
78
+ }
79
+ throw error;
80
+ }
81
+ }
82
+
83
+ function validProfile(value: unknown): boolean {
84
+ const profile = record(value);
85
+ return Boolean(profile && profile.version === 1 && typeof profile.name === "string" && profile.name.length > 0
86
+ && (profile.source === "global" || profile.source === "project") && typeof profile.sourcePath === "string"
87
+ && Array.isArray(profile.tools) && profile.tools.length > 0 && profile.tools.every((tool) => typeof tool === "string" && tool.length > 0)
88
+ && (profile.skills === undefined || (Array.isArray(profile.skills) && profile.skills.every((skill) => typeof skill === "string")))
89
+ && Array.isArray(profile.resolvedSkills) && profile.resolvedSkills.every((skill) => typeof skill === "string")
90
+ && Array.isArray(profile.resolvedExtensions) && profile.resolvedExtensions.every((extension) => typeof extension === "string")
91
+ && (profile.description === undefined || typeof profile.description === "string")
92
+ && (profile.model === undefined || typeof profile.model === "string")
93
+ && (profile.thinkingLevel === undefined || typeof profile.thinkingLevel === "string")
94
+ && (profile.invocation === undefined || profile.invocation === "both" || profile.invocation === "manual")
95
+ && (profile.sessionPersistence === undefined || profile.sessionPersistence === "ephemeral" || profile.sessionPersistence === "persistent")
96
+ && (profile.instructions === undefined || typeof profile.instructions === "string"));
97
+ }
98
+
99
+ export function readManifest(channelDir: string): DelegateManifest {
100
+ const value = record(readJson(path.join(channelDir, "manifest.json")));
101
+ if (!value || value.version !== 1 || typeof value.runId !== "string" || typeof value.mainSessionId !== "string"
102
+ || typeof value.title !== "string" || typeof value.task !== "string" || typeof value.cwd !== "string"
103
+ || (value.origin !== undefined && value.origin !== "manual")
104
+ || !validProfile(value.profile) || typeof value.token !== "string" || typeof value.createdAt !== "number") {
105
+ throw new Error("Invalid Pi Subagent channel manifest.");
106
+ }
107
+ return value as unknown as DelegateManifest;
108
+ }
109
+
110
+ function writeTalk(channelDir: string, manifest: DelegateManifest, direction: TalkDirection, message: string): TalkMessage {
111
+ // Each direction has one writer. Persist the counter before the payload;
112
+ // gaps after a failed write are harmless, and reloads cannot reorder messages.
113
+ const counterPath = path.join(channelDir, `${direction}-sequence.json`);
114
+ const storedCounter = readJson(counterPath);
115
+ const previous = record(storedCounter);
116
+ if (storedCounter !== undefined && (!previous || previous.runId !== manifest.runId || previous.token !== manifest.token
117
+ || !Number.isSafeInteger(previous.sequence) || (previous.sequence as number) < 1)) {
118
+ throw new Error("Invalid Pi Subagent talk sequence counter.");
119
+ }
120
+ const sequence = ((previous?.sequence as number | undefined) ?? 0) + 1;
121
+ if (!Number.isSafeInteger(sequence)) throw new Error("Pi Subagent talk sequence exhausted.");
122
+ writeAtomicJson(counterPath, { runId: manifest.runId, token: manifest.token, sequence });
123
+ const talk: TalkMessage = {
124
+ version: 1,
125
+ id: randomUUID(),
126
+ sequence,
127
+ runId: manifest.runId,
128
+ token: manifest.token,
129
+ createdAt: Date.now(),
130
+ message,
131
+ };
132
+ writeAtomicJson(path.join(channelDir, direction, `${safeSegment(talk.id)}.json`), talk, MAX_TALK_BYTES);
133
+ return talk;
134
+ }
135
+
136
+ export function talkToMain(channelDir: string, manifest: DelegateManifest, message: string): TalkMessage {
137
+ return writeTalk(channelDir, manifest, "to-main", message);
138
+ }
139
+
140
+ export function talkToSub(channelDir: string, manifest: DelegateManifest, message: string): TalkMessage {
141
+ return writeTalk(channelDir, manifest, "to-sub", message);
142
+ }
143
+
144
+ function validTalk(value: unknown, manifest: DelegateManifest): value is TalkMessage {
145
+ const item = record(value);
146
+ return Boolean(item && item.version === 1 && item.runId === manifest.runId && item.token === manifest.token
147
+ && typeof item.id === "string" && Number.isSafeInteger(item.sequence) && (item.sequence as number) > 0
148
+ && typeof item.createdAt === "number" && typeof item.message === "string");
149
+ }
150
+
151
+ function listTalk(channelDir: string, manifest: DelegateManifest, direction: TalkDirection): TalkMessage[] {
152
+ let names: string[];
153
+ try {
154
+ names = fs.readdirSync(path.join(channelDir, direction));
155
+ } catch (error) {
156
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
157
+ throw error;
158
+ }
159
+ const messages: TalkMessage[] = [];
160
+ for (const name of names.filter((name) => name.endsWith(".json")).sort()) {
161
+ const value = readJson(path.join(channelDir, direction, name));
162
+ if (validTalk(value, manifest)) messages.push(value);
163
+ }
164
+ return messages.sort((left, right) => left.sequence - right.sequence);
165
+ }
166
+
167
+ export function listTalkToMain(channelDir: string, manifest: DelegateManifest): TalkMessage[] {
168
+ return listTalk(channelDir, manifest, "to-main");
169
+ }
170
+
171
+ export function listTalkToSub(channelDir: string, manifest: DelegateManifest): TalkMessage[] {
172
+ return listTalk(channelDir, manifest, "to-sub");
173
+ }
174
+
175
+ export function removeTalk(channelDir: string, direction: TalkDirection, id: string): void {
176
+ fs.rmSync(path.join(channelDir, direction, `${safeSegment(id)}.json`), { force: true });
177
+ }
178
+
179
+ export function writeSubSessionInfo(channelDir: string, manifest: DelegateManifest, input: {
180
+ sessionId: string;
181
+ sessionFile: string;
182
+ }): SubSessionInfo {
183
+ const info: SubSessionInfo = {
184
+ version: 1,
185
+ runId: manifest.runId,
186
+ token: manifest.token,
187
+ sessionId: input.sessionId,
188
+ sessionFile: input.sessionFile,
189
+ createdAt: Date.now(),
190
+ };
191
+ writeAtomicJson(path.join(channelDir, "session.json"), info);
192
+ return info;
193
+ }
194
+
195
+ export function readSubSessionInfo(channelDir: string, manifest: DelegateManifest): SubSessionInfo | undefined {
196
+ const value = record(readJson(path.join(channelDir, "session.json")));
197
+ if (!value || value.version !== 1 || value.runId !== manifest.runId || value.token !== manifest.token
198
+ || typeof value.sessionId !== "string" || typeof value.sessionFile !== "string" || typeof value.createdAt !== "number") return undefined;
199
+ return value as unknown as SubSessionInfo;
200
+ }
201
+
202
+ export function writeActiveTurn(channelDir: string, manifest: DelegateManifest): ActiveTurn {
203
+ const turn: ActiveTurn = {
204
+ version: 1,
205
+ runId: manifest.runId,
206
+ token: manifest.token,
207
+ turnId: randomUUID(),
208
+ createdAt: Date.now(),
209
+ };
210
+ writeAtomicJson(path.join(channelDir, "active-turn.json"), turn);
211
+ return turn;
212
+ }
213
+
214
+ export function readActiveTurn(channelDir: string, manifest: Pick<DelegateManifest, "runId" | "token">): ActiveTurn | undefined {
215
+ const value = record(readJson(path.join(channelDir, "active-turn.json")));
216
+ if (!value || value.version !== 1 || value.runId !== manifest.runId || value.token !== manifest.token
217
+ || typeof value.turnId !== "string" || typeof value.createdAt !== "number") return undefined;
218
+ return value as unknown as ActiveTurn;
219
+ }
220
+
221
+ export function clearActiveTurn(channelDir: string, turn: ActiveTurn): void {
222
+ // An older settle event must not erase a newer turn.
223
+ try {
224
+ if (readActiveTurn(channelDir, turn)?.turnId === turn.turnId) {
225
+ fs.rmSync(path.join(channelDir, "active-turn.json"), { force: true });
226
+ }
227
+ } catch {}
228
+ }
229
+
230
+ export function writeInterrupt(channelDir: string, manifest: DelegateManifest, turn: ActiveTurn): InterruptRequest {
231
+ const request: InterruptRequest = { ...turn, requestedAt: Date.now() };
232
+ writeAtomicJson(path.join(channelDir, "interrupt.json"), request);
233
+ return request;
234
+ }
235
+
236
+ export function readInterrupt(channelDir: string, manifest: DelegateManifest): InterruptRequest | undefined {
237
+ const value = record(readJson(path.join(channelDir, "interrupt.json")));
238
+ if (!value || value.version !== 1 || value.runId !== manifest.runId || value.token !== manifest.token
239
+ || typeof value.turnId !== "string" || typeof value.createdAt !== "number"
240
+ || typeof value.requestedAt !== "number") return undefined;
241
+ return value as unknown as InterruptRequest;
242
+ }
243
+
244
+ export function clearInterrupt(channelDir: string): void {
245
+ fs.rmSync(path.join(channelDir, "interrupt.json"), { force: true });
246
+ }
247
+
248
+ export function writeClose(channelDir: string, manifest: DelegateManifest, reason: string): void {
249
+ const message: CloseMessage = {
250
+ version: 1,
251
+ runId: manifest.runId,
252
+ token: manifest.token,
253
+ createdAt: Date.now(),
254
+ reason,
255
+ };
256
+ writeAtomicJson(path.join(channelDir, "close.json"), message);
257
+ }
258
+
259
+ export function readClose(channelDir: string, manifest: DelegateManifest): CloseMessage | undefined {
260
+ const value = record(readJson(path.join(channelDir, "close.json")));
261
+ if (!value || value.version !== 1 || value.runId !== manifest.runId || value.token !== manifest.token
262
+ || typeof value.createdAt !== "number" || typeof value.reason !== "string") return undefined;
263
+ return value as unknown as CloseMessage;
264
+ }
265
+
266
+ export function writeSubClosed(channelDir: string, manifest: DelegateManifest, reason: string): void {
267
+ const message: SubClosedMessage = {
268
+ version: 1,
269
+ runId: manifest.runId,
270
+ token: manifest.token,
271
+ createdAt: Date.now(),
272
+ reason,
273
+ };
274
+ writeAtomicJson(path.join(channelDir, "closed.json"), message);
275
+ }
276
+
277
+ export function readSubClosed(channelDir: string, manifest: DelegateManifest): SubClosedMessage | undefined {
278
+ const value = record(readJson(path.join(channelDir, "closed.json")));
279
+ if (!value || value.version !== 1 || value.runId !== manifest.runId || value.token !== manifest.token
280
+ || typeof value.createdAt !== "number" || typeof value.reason !== "string") return undefined;
281
+ return value as unknown as SubClosedMessage;
282
+ }
283
+
284
+ export function removeChannel(channelDir: string): void {
285
+ fs.rmSync(channelDir, { recursive: true, force: true });
286
+ }