@oldflag2333333/pi-subagent 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +179 -2
- package/README.zh-CN.md +180 -0
- package/docs/messaging.md +28 -0
- package/docs/releasing.md +72 -0
- package/docs/releasing.zh-CN.md +72 -0
- package/docs/third-party-notices.md +25 -0
- package/herdr-plugin/README.md +34 -0
- package/herdr-plugin/action.ts +19 -0
- package/herdr-plugin/herdr-plugin.toml +27 -0
- package/herdr-plugin/index.mjs +108 -0
- package/package.json +65 -4
- package/src/adapters/herdr.ts +157 -0
- package/src/adapters/index.ts +31 -0
- package/src/channel-monitor.ts +127 -0
- package/src/channel.ts +286 -0
- package/src/commands/clean-sub.ts +90 -0
- package/src/commands/sub.ts +39 -0
- package/src/inbox-state.ts +114 -0
- package/src/index.ts +45 -0
- package/src/main-context.ts +91 -0
- package/src/manual-context.ts +9 -0
- package/src/parent-wake.ts +86 -0
- package/src/profiles/context.ts +27 -0
- package/src/profiles/launch-args.ts +21 -0
- package/src/profiles/loader.ts +325 -0
- package/src/profiles/mcp.ts +16 -0
- package/src/profiles/runtime.ts +102 -0
- package/src/profiles/system-prompt.ts +33 -0
- package/src/profiles/tool-selection.ts +21 -0
- package/src/profiles/tool-sources.ts +49 -0
- package/src/profiles/types.ts +37 -0
- package/src/run-manager.ts +442 -0
- package/src/sessions.ts +61 -0
- package/src/talk-delivery.ts +78 -0
- package/src/talk-message.ts +32 -0
- package/src/talk-render.ts +18 -0
- package/src/tools/main.ts +299 -0
- package/src/tools/sub.ts +215 -0
- package/src/types.ts +113 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
id = "subagent.agent-visibility"
|
|
2
|
+
name = "Pi Subagent Agent Visibility"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
min_herdr_version = "0.8.2"
|
|
5
|
+
description = "Hide or show Pi Subagent subs in Herdr's Agents panel"
|
|
6
|
+
platforms = ["linux", "macos"]
|
|
7
|
+
|
|
8
|
+
[[startup]]
|
|
9
|
+
command = ["pi", "--offline", "--no-extensions", "-e", "action.ts", "--subagent-visibility-action", "startup", "--list-models"]
|
|
10
|
+
|
|
11
|
+
[[actions]]
|
|
12
|
+
id = "toggle-subs"
|
|
13
|
+
title = "Toggle Pi Subagent subs"
|
|
14
|
+
contexts = ["global"]
|
|
15
|
+
command = ["pi", "--offline", "--no-extensions", "-e", "action.ts", "--subagent-visibility-action", "toggle", "--list-models"]
|
|
16
|
+
|
|
17
|
+
[[actions]]
|
|
18
|
+
id = "hide-subs"
|
|
19
|
+
title = "Hide Pi Subagent subs"
|
|
20
|
+
contexts = ["global"]
|
|
21
|
+
command = ["pi", "--offline", "--no-extensions", "-e", "action.ts", "--subagent-visibility-action", "hide", "--list-models"]
|
|
22
|
+
|
|
23
|
+
[[actions]]
|
|
24
|
+
id = "show-subs"
|
|
25
|
+
title = "Show Pi Subagent subs"
|
|
26
|
+
contexts = ["global"]
|
|
27
|
+
command = ["pi", "--offline", "--no-extensions", "-e", "action.ts", "--subagent-visibility-action", "show", "--list-models"]
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as net from "node:net";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
const pluginId = process.env.HERDR_PLUGIN_ID ?? "subagent.agent-visibility";
|
|
7
|
+
const source = `plugin:${pluginId}`;
|
|
8
|
+
const socketPath = process.env.HERDR_SOCKET_PATH;
|
|
9
|
+
const stateDir = process.env.HERDR_PLUGIN_STATE_DIR;
|
|
10
|
+
|
|
11
|
+
function statePath() {
|
|
12
|
+
if (!stateDir) throw new Error("HERDR_PLUGIN_STATE_DIR is missing.");
|
|
13
|
+
return path.join(stateDir, "visibility.json");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function readState() {
|
|
17
|
+
try {
|
|
18
|
+
const value = JSON.parse(fs.readFileSync(statePath(), "utf8"));
|
|
19
|
+
return { showSubs: value?.showSubs === true };
|
|
20
|
+
} catch (error) {
|
|
21
|
+
if (error?.code === "ENOENT") return { showSubs: false };
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function writeState(state) {
|
|
27
|
+
const file = statePath();
|
|
28
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
29
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
30
|
+
fs.writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
|
|
31
|
+
fs.renameSync(temporary, file);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function send(method, params) {
|
|
35
|
+
if (!socketPath) return Promise.reject(new Error("HERDR_SOCKET_PATH is missing."));
|
|
36
|
+
const endpoint = process.platform === "win32" ? `\\\\.\\pipe\\${socketPath}` : socketPath;
|
|
37
|
+
const request = { id: `subagent:${Date.now()}:${process.pid}`, method, params };
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
let buffer = "";
|
|
40
|
+
const socket = net.createConnection(endpoint);
|
|
41
|
+
const timeout = setTimeout(() => {
|
|
42
|
+
socket.destroy();
|
|
43
|
+
reject(new Error("Timed out waiting for Herdr."));
|
|
44
|
+
}, 5000);
|
|
45
|
+
const finish = (callback) => {
|
|
46
|
+
clearTimeout(timeout);
|
|
47
|
+
socket.destroy();
|
|
48
|
+
callback();
|
|
49
|
+
};
|
|
50
|
+
socket.on("error", (error) => finish(() => reject(error)));
|
|
51
|
+
socket.on("connect", () => socket.write(`${JSON.stringify(request)}\n`));
|
|
52
|
+
socket.on("data", (chunk) => {
|
|
53
|
+
buffer += chunk.toString("utf8");
|
|
54
|
+
const newline = buffer.indexOf("\n");
|
|
55
|
+
if (newline < 0) return;
|
|
56
|
+
try {
|
|
57
|
+
const response = JSON.parse(buffer.slice(0, newline));
|
|
58
|
+
if (response.error) throw new Error(response.error.message ?? JSON.stringify(response.error));
|
|
59
|
+
finish(() => resolve(response.result));
|
|
60
|
+
} catch (error) {
|
|
61
|
+
finish(() => reject(error));
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function apply(showSubs) {
|
|
68
|
+
if (showSubs) {
|
|
69
|
+
await send("agent.view.clear", { source });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
await send("agent.view.set", {
|
|
73
|
+
source,
|
|
74
|
+
label: "subs hidden",
|
|
75
|
+
filter: {
|
|
76
|
+
op: "not",
|
|
77
|
+
filter: {
|
|
78
|
+
op: "eq",
|
|
79
|
+
field: { token: "subagent_role" },
|
|
80
|
+
value: "sub",
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
sort: [],
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function run(action = "startup") {
|
|
88
|
+
const current = readState();
|
|
89
|
+
const next = action === "toggle"
|
|
90
|
+
? { showSubs: !current.showSubs }
|
|
91
|
+
: action === "show"
|
|
92
|
+
? { showSubs: true }
|
|
93
|
+
: action === "hide" || action === "startup"
|
|
94
|
+
? current
|
|
95
|
+
: undefined;
|
|
96
|
+
if (!next) throw new Error(`Unknown action '${action}'.`);
|
|
97
|
+
if (action === "hide") next.showSubs = false;
|
|
98
|
+
writeState(next);
|
|
99
|
+
await apply(next.showSubs);
|
|
100
|
+
console.log(next.showSubs ? "Pi Subagent subs are visible." : "Pi Subagent subs are hidden.");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
104
|
+
run(process.argv[2] ?? "startup").catch((error) => {
|
|
105
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
106
|
+
process.exitCode = 1;
|
|
107
|
+
});
|
|
108
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,67 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oldflag2333333/pi-subagent",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
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,157 @@
|
|
|
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 { subSessionDir } from "../sessions.js";
|
|
7
|
+
import type { SubAgentStatus, SubLaunchSpec, SubSurfaceAdapter, SurfaceHandle } from "../types.js";
|
|
8
|
+
|
|
9
|
+
function parseEnvelope(stdout: string): Record<string, unknown> {
|
|
10
|
+
const lines = stdout.trim().split(/\r?\n/).filter(Boolean).reverse();
|
|
11
|
+
for (const line of lines) {
|
|
12
|
+
try {
|
|
13
|
+
const parsed = JSON.parse(line) as Record<string, unknown>;
|
|
14
|
+
return parsed.result && typeof parsed.result === "object" ? parsed.result as Record<string, unknown> : parsed;
|
|
15
|
+
} catch {}
|
|
16
|
+
}
|
|
17
|
+
throw new Error(`Herdr returned no JSON response: ${stdout.slice(0, 300)}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function nestedString(value: unknown, ...keys: string[]): string | undefined {
|
|
21
|
+
let current: unknown = value;
|
|
22
|
+
for (const key of keys) {
|
|
23
|
+
if (!current || typeof current !== "object") return undefined;
|
|
24
|
+
current = (current as Record<string, unknown>)[key];
|
|
25
|
+
}
|
|
26
|
+
return typeof current === "string" ? current : undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function safeLabel(value: string): string {
|
|
30
|
+
return value.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim().slice(0, 48) || "delegated task";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function agentName(runId: string): string {
|
|
34
|
+
return `sub-${runId.toLowerCase().replace(/[^a-z0-9_-]/g, "").slice(0, 20)}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function subArgs(spec: SubLaunchSpec): string[] {
|
|
38
|
+
const herdrIntegration = path.join(getAgentDir(), "extensions", "herdr-agent-state.ts");
|
|
39
|
+
return [
|
|
40
|
+
"--no-extensions",
|
|
41
|
+
"-e", spec.entryPath,
|
|
42
|
+
...(fs.existsSync(herdrIntegration) ? ["-e", herdrIntegration] : []),
|
|
43
|
+
...(spec.profile.sessionPersistence === "persistent" || spec.resumeSessionId
|
|
44
|
+
? ["--session-dir", subSessionDir(spec.mainSessionId)] : ["--no-session"]),
|
|
45
|
+
...(spec.resumeSessionId ? ["--session", spec.resumeSessionFile ?? spec.resumeSessionId] : []),
|
|
46
|
+
spec.projectTrusted ? "--approve" : "--no-approve",
|
|
47
|
+
"--name", subSessionName(safeLabel(spec.title), spec.origin),
|
|
48
|
+
...subCapabilityArgs(spec.profile, spec.entryPath),
|
|
49
|
+
];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A launch failed and its tab could not be rolled back; keep it addressable. */
|
|
53
|
+
export class HerdrLaunchCleanupError extends Error {
|
|
54
|
+
constructor(readonly handle: SurfaceHandle, cause: unknown, cleanup: unknown) {
|
|
55
|
+
super(`Sub launch failed: ${cause instanceof Error ? cause.message : String(cause)}. Tab cleanup also failed: ${cleanup instanceof Error ? cleanup.message : String(cleanup)}.`, { cause });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export class HerdrTabAdapter implements SubSurfaceAdapter {
|
|
60
|
+
readonly id = "herdr" as const;
|
|
61
|
+
constructor(private readonly pi: ExtensionAPI) {}
|
|
62
|
+
|
|
63
|
+
async available(): Promise<boolean> {
|
|
64
|
+
if (process.env.HERDR_ENV !== "1" || !process.env.HERDR_WORKSPACE_ID) return false;
|
|
65
|
+
const result = await this.pi.exec("herdr", ["status", "server"], { timeout: 3000 });
|
|
66
|
+
return result.code === 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async launch(spec: SubLaunchSpec, signal?: AbortSignal): Promise<SurfaceHandle> {
|
|
70
|
+
const workspaceId = process.env.HERDR_WORKSPACE_ID;
|
|
71
|
+
if (!workspaceId) throw new Error("HERDR_WORKSPACE_ID is unavailable; Pi is not running in a Herdr workspace.");
|
|
72
|
+
signal?.throwIfAborted();
|
|
73
|
+
const label = `↳ pi · ${safeLabel(spec.title)}`;
|
|
74
|
+
const created = await this.pi.exec("herdr", [
|
|
75
|
+
"tab", "create",
|
|
76
|
+
"--workspace", workspaceId,
|
|
77
|
+
"--cwd", spec.cwd,
|
|
78
|
+
"--label", label,
|
|
79
|
+
"--env", "PI_SUBAGENT_ROLE=sub",
|
|
80
|
+
"--env", `PI_SUBAGENT_CHANNEL=${spec.channelDir}`,
|
|
81
|
+
"--env", `PI_SUBAGENT_TOKEN=${spec.token}`,
|
|
82
|
+
"--no-focus",
|
|
83
|
+
], { timeout: 15_000, signal });
|
|
84
|
+
let payload: Record<string, unknown>;
|
|
85
|
+
try { payload = parseEnvelope(created.stdout); } catch (error) {
|
|
86
|
+
if (created.code !== 0 || created.killed) throw new Error(created.stderr || "Failed to create Herdr tab.");
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
const tabId = nestedString(payload, "tab", "tab_id") ?? nestedString(payload, "tab", "id");
|
|
90
|
+
const paneId = nestedString(payload, "root_pane", "pane_id") ?? nestedString(payload, "pane", "pane_id");
|
|
91
|
+
if (!tabId) throw new Error("Herdr tab creation response did not contain a tab ID; automatic cleanup is unavailable.");
|
|
92
|
+
const handle: SurfaceHandle = { adapter: "herdr", tabId, ...(paneId ? { paneId } : {}) };
|
|
93
|
+
const step = async (args: string[], timeout: number, failure: string) => {
|
|
94
|
+
signal?.throwIfAborted();
|
|
95
|
+
const result = await this.pi.exec("herdr", args, { timeout, signal });
|
|
96
|
+
if (result.code !== 0 || result.killed) throw new Error(result.stderr || failure);
|
|
97
|
+
signal?.throwIfAborted();
|
|
98
|
+
};
|
|
99
|
+
try {
|
|
100
|
+
if (created.code !== 0 || created.killed) throw new Error(created.stderr || "Failed to create Herdr tab.");
|
|
101
|
+
if (!paneId) throw new Error("Herdr tab creation response did not contain a root pane ID.");
|
|
102
|
+
const name = agentName(spec.runId);
|
|
103
|
+
await step([
|
|
104
|
+
"agent", "start", name, "--kind", "pi", "--pane", paneId, "--timeout", "60000", "--", ...subArgs(spec),
|
|
105
|
+
], 70_000, "Failed to start Sub Pi in Herdr tab.");
|
|
106
|
+
await step([
|
|
107
|
+
"pane", "report-metadata", paneId, "--source", "subagent",
|
|
108
|
+
"--token", "subagent_role=sub", "--token", `subagent_main_session=${spec.mainSessionId}`,
|
|
109
|
+
"--token", `subagent_run_id=${spec.runId}`, "--token", `subagent_profile=${spec.profile.name}`,
|
|
110
|
+
], 10_000, "Failed to mark the Herdr pane as a Pi Subagent Sub.");
|
|
111
|
+
await step(["agent", "prompt", name, spec.task], 20_000, "Failed to submit the delegated task to the Herdr agent.");
|
|
112
|
+
return handle;
|
|
113
|
+
} catch (error) {
|
|
114
|
+
try {
|
|
115
|
+
// Rollback must not inherit the cancellation that caused the failure.
|
|
116
|
+
await this.close(handle);
|
|
117
|
+
} catch (cleanup) {
|
|
118
|
+
throw new HerdrLaunchCleanupError(handle, error, cleanup);
|
|
119
|
+
}
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async status(handle: SurfaceHandle | undefined): Promise<SubAgentStatus> {
|
|
125
|
+
if (!handle?.paneId) return "unknown";
|
|
126
|
+
try {
|
|
127
|
+
const response = await this.pi.exec("herdr", ["agent", "get", handle.paneId], { timeout: 3000 });
|
|
128
|
+
if (response.code !== 0) return "unknown";
|
|
129
|
+
const agent = parseEnvelope(response.stdout).agent;
|
|
130
|
+
if (!agent || typeof agent !== "object") return "unknown";
|
|
131
|
+
const info = agent as Record<string, unknown>;
|
|
132
|
+
if (info.pane_id !== handle.paneId || (handle.tabId && info.tab_id !== handle.tabId)) return "unknown";
|
|
133
|
+
return info.agent_status === "working" || info.agent_status === "blocked" || info.agent_status === "idle"
|
|
134
|
+
? info.agent_status : "unknown";
|
|
135
|
+
} catch {
|
|
136
|
+
return "unknown";
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Distinguish an explicitly missing tab from a disconnected or unhealthy Herdr. */
|
|
141
|
+
async exists(handle: SurfaceHandle | undefined): Promise<boolean | undefined> {
|
|
142
|
+
if (!handle?.tabId) return undefined;
|
|
143
|
+
try {
|
|
144
|
+
const response = await this.pi.exec("herdr", ["tab", "get", handle.tabId], { timeout: 3000 });
|
|
145
|
+
if (response.killed) return undefined;
|
|
146
|
+
const payload = parseEnvelope(response.stdout);
|
|
147
|
+
if (response.code !== 0) return nestedString(payload, "error", "code") === "tab_not_found" ? false : undefined;
|
|
148
|
+
return nestedString(payload, "tab", "tab_id") === handle.tabId ? true : undefined;
|
|
149
|
+
} catch { return undefined; }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async close(handle: SurfaceHandle): Promise<void> {
|
|
153
|
+
if (!handle.tabId) return;
|
|
154
|
+
const result = await this.pi.exec("herdr", ["tab", "close", handle.tabId], { timeout: 10_000 });
|
|
155
|
+
if (result.code !== 0 || result.killed) throw new Error(result.stderr || `Failed to close Herdr tab ${handle.tabId}.`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -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
|
+
}
|