@rol1n/multi-harness 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/README.md CHANGED
@@ -1,3 +1,63 @@
1
- # Temporary Holding Version
1
+ # Multi Harness
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A small delegation runtime with one tool, `multi_harness`. `pi`, `codex`, `claude`, and `opencode` are equal CLI peers. The original `subagent` extension remains a separate, lean Pi-only tool.
4
+
5
+ The runtime accepts **one named worker and an existing cwd**. Those decisions belong to the caller. An independently installed `worktree-studio` skill can coordinate isolated implementations and their acceptance review.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ pi install npm:@rol1n/multi-harness
11
+ ```
12
+
13
+ Also included in `@rol1n/pi-kit`. Enable one copy of the extension.
14
+
15
+ ## Use
16
+
17
+ Load `multi-harness` through the aggregate package, or run from this repository:
18
+
19
+ ```sh
20
+ pi --no-extensions -e ./multi-harness/index.ts
21
+ ```
22
+
23
+ Example tool calls:
24
+
25
+ ```json
26
+ {"action":"spawn","name":"reviewer","harness":"claude","cwd":"/existing/project","task":"Review error handling without changing files. Report concrete findings with file paths."}
27
+ {"action":"spawn","name":"implementer","harness":"pi","cwd":"/existing/other-directory","task":"Implement the approved fix and run the specified checks.","model":"provider/model"}
28
+ {"action":"spawn","name":"opencode-reviewer","harness":"opencode","cwd":"/existing/project","task":"Review the changes and report evidence without editing files."}
29
+ {"action":"send","target":"reviewer","task":"Review the revised implementation in the same session."}
30
+ {"action":"status"}
31
+ {"action":"stop","target":"implementer"}
32
+ {"action":"close","target":"reviewer"}
33
+ ```
34
+
35
+ - `spawn` is the default action; `name`, `harness`, and `task` are required. `cwd` defaults to the parent directory and must already exist. Names are reserved for the session, including after closing.
36
+ - `model` is optional and passed to the selected CLI. Pi defaults to the parent model; external CLIs use their installed defaults. Only the selected executable is checked on demand.
37
+ - `spawn` and `send` return after dispatch. **Turn completion is pushed to the real parent Pi session via `pi.sendMessage`**, not by a background wrapper writing a log. Do not poll terminals or run waiting shell loops.
38
+ - With tmux/Zellij, each worker runs its **native interactive TUI**. Turn settlement leaves the application open and idle. `send` continues that same application; `close` exits idle TUIs. `stop` terminates active work and its runtime, preserving files.
39
+ - Without a supported mux, workers use silent child processes. `send` starts another invocation that resumes the same persisted CLI session. Set `PI_MULTI_HARNESS_BACKEND=process` to force this fallback. This is not a live TUI in headless mode.
40
+ - Pi follow-ups use a file inbox and native `pi.sendUserMessage`. OpenCode uses a local server plugin, its native SDK inbox, and the same visible TUI session (no key injection). Codex uses its reported thread ID with `codex queue`; when no ID is available, mux input uses bracketed paste. Claude uses idle-only bracketed paste. Avoid simultaneous human typing and coordinator dispatch; startup trust/permission dialogs may require manual attention. Zellij input transport still requires validation with your installed Zellij/terminal build.
41
+
42
+ ## Notifications and state
43
+
44
+ Artifacts live in `~/.pi/agent/multi-harness/<worker-id>/`: task prompts, CLI session data/IDs, console/event logs in process mode, completion records, and short TUI screen captures. Working files remain in the caller's `cwd`. No automatic Git report is generated.
45
+
46
+ `harness-done "summary"` is injected into the worker PATH; `studio-done` is a compatibility alias. It records a proposed summary, **not an early completion event**. Native Codex/Claude hooks, OpenCode's root-session idle events, Pi's `agent_settled`, or the headless process exit publish the authoritative completion. This avoids racing a still-running CLI or notifying twice. Every dispatch has a task ID; stale and duplicate completions are ignored.
47
+
48
+ The parent subscribes to directory `fs.watch` events, with no periodic terminal capture or completion polling. Screen capture happens once per completed turn. Watcher failures are surfaced rather than silently claiming delivery. A worker's final text is a claim: independently review its files and test results.
49
+
50
+ Default turn timeout: 15 minutes (`maxMinutes`: 1–120). Headless workers are stopped on timeout. A native TUI stays open and cannot receive a new dispatch until it settles or is stopped. Timeouts do not cap tokens or spending; missing usage/pricing is not zero cost.
51
+
52
+ Session entries record only worker artifact locations. On session restore/reload, the extension reattaches notification watchers for saved workers. A `controller.lock` prevents another session from controlling the same worker concurrently. After an unclean crash, verify the recorded owner PID is no longer running before manually removing that lock. Normal shutdown releases watchers/locks and stops active tasks; idle TUIs remain open and can be recovered by resuming the parent session.
53
+
54
+ ## Permissions and platform boundaries
55
+
56
+ - This tool dispatches directly; user confirmation for expensive or risky workflows belongs to the caller/skill. It is not a sandbox.
57
+ - Per the configured workflow, Codex runs with `--sandbox danger-full-access -a never`, and Claude with `--permission-mode bypassPermissions`. Workers have the invoking user's filesystem/network privileges. Headless Claude has a $0.50 CLI API budget; interactive mode does not impose that limit. Use only in trusted environments.
58
+ - OpenCode uses `--auto`: automatically approve requests not explicitly denied by its configuration. Existing deny rules remain effective. Native mode injects the dependency-free local `opencode-plugin.mjs` through a merged `OPENCODE_CONFIG_CONTENT`; it never edits project/global config files or installs packages. Existing content must be valid JSON. Headless mode uses `opencode run --format json` and resumes by the exact reported `--session` ID. The adapter targets the OpenCode 1.x CLI/plugin API (source-checked against v1.19.3); 2.x API compatibility is not assumed. Install and authenticate the CLI yourself. DeepSeek Harness (`dsh`) is not integrated.
59
+ - Pi's project-resource trust flags apply only to loading project resources, not filesystem access. A different cwd does not inherit the parent's project trust automatically.
60
+ - Windows requires PowerShell 7 (`pwsh`) for external CLI invocation. Encoded PowerShell commands preserve Unicode, multiline tasks, JSON, and quoted paths. CLI hooks and `harness-done.cmd`/`.ps1` wrappers use the same native Windows Node installation. A mux must be available for visible TUIs; otherwise process fallback applies.
61
+ - Existing worktrees, reports, and sessions created by the removed `worktree-studio` extension are left untouched. Its old `the_road_not_taken` tool and two-route manifests are not imported into this runtime. Synchronize platform resources/reload before using the new tool; old running workers should be finished before upgrading.
62
+
63
+ Validation scripts are kept outside the repository. Check extension loading from this repository with `pi --no-extensions -e ./multi-harness/index.ts --list-models '<filter>'`.
package/adapters.mjs ADDED
@@ -0,0 +1,72 @@
1
+ import { dirname, join } from "node:path";
2
+ import { fileURLToPath, pathToFileURL } from "node:url";
3
+
4
+ export const HARNESSES = Object.freeze(["pi", "codex", "claude", "opencode"]);
5
+ const extensionDir = dirname(fileURLToPath(import.meta.url));
6
+ export const quotePs = (value) => `'${value.replaceAll("'", "''")}'`;
7
+ const quoteSh = (value) => `'${value.replaceAll("'", "'\\''")}'`;
8
+
9
+ /** Avoid cmd/npm shim argument re-parsing and preserve Unicode and JSON on Windows. */
10
+ export function commandSpec(argv, platform = process.platform) {
11
+ if (platform !== "win32") return { command: argv[0], args: argv.slice(1) };
12
+ const script = `$OutputEncoding = [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); & ${argv.map(quotePs).join(" ")}; if ($null -eq $LASTEXITCODE) { exit 1 }; exit $LASTEXITCODE`;
13
+ return { command: "pwsh", args: ["-NoProfile", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")] };
14
+ }
15
+
16
+ /** Add a dependency-free local plugin for native OpenCode only; never rewrite user config. */
17
+ export function buildEnv(config, inherited = process.env) {
18
+ if (config.harness !== "opencode" || !config.interactive) return inherited;
19
+ let content;
20
+ try { content = inherited.OPENCODE_CONFIG_CONTENT ? JSON.parse(inherited.OPENCODE_CONFIG_CONTENT) : {}; }
21
+ catch { throw new Error("OPENCODE_CONFIG_CONTENT must be valid JSON to append the multi-harness plugin."); }
22
+ if (!content || typeof content !== "object" || Array.isArray(content) || (content.plugin !== undefined && !Array.isArray(content.plugin))) {
23
+ throw new Error("OpenCode config must be an object with an optional plugin array.");
24
+ }
25
+ const plugin = pathToFileURL(join(extensionDir, "opencode-plugin.mjs")).href;
26
+ return { ...inherited, PI_MULTI_HARNESS_CWD: config.cwd,
27
+ OPENCODE_CONFIG_CONTENT: JSON.stringify({ ...content, plugin: [...(content.plugin ?? []).filter((item) => item !== plugin), plugin] }) };
28
+ }
29
+
30
+ export function buildArgs(config) {
31
+ const { harness, cwd, prompt, sessionFile, model, trusted, artifactDir, interactive, resume, sessionId } = config;
32
+ const modelArgs = model ? ["--model", model] : [];
33
+ switch (harness) {
34
+ case "pi":
35
+ return ["pi", ...(interactive ? ["--tui-mode", "regular", "-e", join(extensionDir, "worker-bridge.ts")] : ["--print"]),
36
+ trusted ? "--approve" : "--no-approve", "--session", sessionFile, ...modelArgs, prompt];
37
+ case "codex": {
38
+ const base = ["codex", "--sandbox", "danger-full-access", "-a", "never"];
39
+ if (interactive) {
40
+ const hook = JSON.stringify([process.execPath, join(extensionDir, "turn-hook.mjs"), artifactDir, "codex"]);
41
+ return [...base, "--cd", cwd, ...modelArgs, "-c", `notify=${hook}`, prompt];
42
+ }
43
+ return [...base, "exec", ...(resume ? ["resume", sessionId] : ["--cd", cwd]), "--skip-git-repo-check", "--json", ...modelArgs, "-"];
44
+ }
45
+ case "claude": {
46
+ const base = ["claude", "--permission-mode", "bypassPermissions", ...modelArgs];
47
+ if (interactive) {
48
+ const hook = (kind) => {
49
+ const args = [process.execPath, join(extensionDir, "turn-hook.mjs"), artifactDir, kind];
50
+ // Claude command hooks use a shell, not necessarily the interactive pane's shell.
51
+ if (process.platform === "win32") {
52
+ const script = `& ${args.map(quotePs).join(" ")}; exit $LASTEXITCODE`;
53
+ return `pwsh -NoProfile -EncodedCommand ${Buffer.from(script, "utf16le").toString("base64")}`;
54
+ }
55
+ return args.map(quoteSh).join(" ");
56
+ };
57
+ const settings = { hooks: {
58
+ UserPromptSubmit: [{ hooks: [{ type: "command", command: hook("claude-start"), timeout: 10 }] }],
59
+ Stop: [{ hooks: [{ type: "command", command: hook("claude"), timeout: 10 }] }],
60
+ } };
61
+ return [...base, "--session-id", sessionId, "--settings", JSON.stringify(settings), prompt];
62
+ }
63
+ return [...base, "-p", "--verbose", "--output-format", "stream-json", "--max-budget-usd", "0.50",
64
+ ...(resume ? ["--resume", sessionId] : ["--session-id", sessionId])];
65
+ }
66
+ case "opencode":
67
+ return interactive
68
+ ? ["opencode", cwd, "--auto", ...modelArgs, "--prompt", prompt]
69
+ : ["opencode", "run", "--dir", cwd, "--format", "json", "--auto", ...modelArgs, ...(resume ? ["--session", sessionId] : [])];
70
+ default: throw new Error(`Unsupported harness: ${harness}`);
71
+ }
72
+ }
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env node
2
+ import { join } from "node:path";
3
+ import { readJson, writeJson } from "./ipc.mjs";
4
+
5
+ const directory = process.env.PI_MULTI_HARNESS_DIR;
6
+ if (!directory) throw new Error("harness-done must run inside a multi_harness worker.");
7
+ const request = readJson(join(directory, "request.json"));
8
+ if (!request) throw new Error("No task is assigned to this worker.");
9
+ writeJson(join(directory, "proposal.json"), {
10
+ id: request.id,
11
+ summary: process.argv.slice(2).join(" ").trim() || "Task finished; inspect the artifacts for verification.",
12
+ at: Date.now(),
13
+ });
14
+ console.log("Summary recorded. The coordinator will be notified when this turn settles; keep the TUI open.");
package/index.ts ADDED
@@ -0,0 +1,375 @@
1
+ import { spawn, execFileSync, type ChildProcess } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { closeSync, mkdirSync, openSync, statSync, unlinkSync, utimesSync, watch, writeFileSync, type FSWatcher } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { delimiter, dirname, join, resolve } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
+ import { Type } from "typebox";
9
+ import { createSurface, closeSurface, getMuxBackend, renderSubagentCommand, sendLongCommand, sendCommand, readScreenAsync, shellEscape, psEscape, zellijPaneId, type WorkerBackend } from "@rol1n/subagent/mux";
10
+ import { HARNESSES, commandSpec } from "./adapters.mjs";
11
+ import { readJson, writeJson } from "./ipc.mjs";
12
+
13
+ const extensionDir = dirname(fileURLToPath(import.meta.url));
14
+ const RUNNER = join(extensionDir, "process-runner.mjs");
15
+ type Harness = "pi" | "codex" | "claude" | "opencode";
16
+ type Status = "running" | "idle" | "timed_out" | "stopped" | "closed" | "exited" | "failed";
17
+ interface Worker {
18
+ id: string; name: string; harness: Harness; cwd: string; model?: string; trusted: boolean;
19
+ artifactDir: string; sessionFile: string; sessionId: string; backend: WorkerBackend;
20
+ surface?: string; panePid?: string; status: Status; taskId?: string; summary?: string;
21
+ watcher?: FSWatcher; timer?: ReturnType<typeof setTimeout>; child?: ChildProcess;
22
+ }
23
+ const Params = Type.Object({
24
+ action: Type.Optional(Type.Union([Type.Literal("spawn"), Type.Literal("send"), Type.Literal("status"), Type.Literal("stop"), Type.Literal("close")], { description: "Defaults to spawn. Send continues an idle worker; stop terminates active work; close exits an idle TUI. All artifacts remain." })),
25
+ name: Type.Optional(Type.String({ description: "Unique worker name for spawn (letters, digits, hyphen, underscore)." })),
26
+ target: Type.Optional(Type.String({ description: "Existing worker name for send/stop/close; optional for status (lists all)." })),
27
+ harness: Type.Optional(Type.Union([Type.Literal("pi"), Type.Literal("codex"), Type.Literal("claude"), Type.Literal("opencode")], { description: "Required for spawn. Equal CLI peers, not execution modes." })),
28
+ cwd: Type.Optional(Type.String({ description: "Existing working directory for spawn; defaults to the parent cwd. No directory or Git worktree is created." })),
29
+ task: Type.Optional(Type.String({ description: "Task for spawn/send; include scope, deliverables, and verification criteria." })),
30
+ model: Type.Optional(Type.String({ description: "Optional model accepted by the selected CLI. Pi defaults to the parent model; other CLIs use their own defaults." })),
31
+ maxMinutes: Type.Optional(Type.Number({ minimum: 1, maximum: 120, description: "Turn timeout in minutes (default 15), not a spending limit. Native TUIs remain open on timeout until stopped or settled." })),
32
+ });
33
+ const errorText = (cause: unknown) => cause instanceof Error ? cause.message : String(cause);
34
+ const busy = (worker: Worker) => worker.status === "running" || worker.status === "timed_out";
35
+
36
+ function record(worker: Worker) {
37
+ const { watcher, timer, child, ...data } = worker;
38
+ return data;
39
+ }
40
+ function save(worker: Worker) { writeJson(join(worker.artifactDir, "worker.json"), record(worker)); }
41
+ function backend(): WorkerBackend {
42
+ const preference = process.env.PI_MULTI_HARNESS_BACKEND;
43
+ if (preference && preference !== "process") throw new Error("PI_MULTI_HARNESS_BACKEND only accepts process.");
44
+ return preference === "process" ? "process" : getMuxBackend() ?? "process";
45
+ }
46
+ function inspectPane(worker: Worker) {
47
+ const runner = readJson(join(worker.artifactDir, "process.json"));
48
+ if (!runner?.pid) throw new Error(`No live runner identity for ${worker.name}.`);
49
+ process.kill(runner.pid, 0); // Fail before pasting if the runner has exited.
50
+ if (worker.backend !== "tmux" || !worker.surface) return;
51
+ const state = execFileSync("tmux", ["display-message", "-p", "-t", worker.surface, "#{pane_pid}:#{pane_dead}"], { encoding: "utf8", timeout: 2000 });
52
+ const [pid, dead] = state.trim().split(":");
53
+ if (pid !== worker.panePid || dead === "1") throw new Error(`The pane for ${worker.name} has exited or been replaced. Refusing to send input.`);
54
+ }
55
+ function setupBin(directory: string) {
56
+ const bin = join(directory, "bin");
57
+ mkdirSync(bin, { recursive: true, mode: 0o700 });
58
+ const done = join(extensionDir, "harness-done.mjs");
59
+ for (const name of ["harness-done", "studio-done"]) {
60
+ writeFileSync(join(bin, name), `#!/usr/bin/env sh\nexec ${shellEscape(process.execPath.replace(/\\/g, "/"))} ${shellEscape(done.replace(/\\/g, "/"))} "$@"\n`, { mode: 0o700 });
61
+ writeFileSync(join(bin, `${name}.ps1`), `\uFEFF& ${psEscape(process.execPath)} ${psEscape(done)} @args\r\nexit $LASTEXITCODE\r\n`);
62
+ writeFileSync(join(bin, `${name}.cmd`), `@"${process.execPath}" "${done}" %*\r\n`);
63
+ }
64
+ return bin;
65
+ }
66
+ function deliverInbox(worker: Worker, text: string): Promise<void> {
67
+ return new Promise((resolve, reject) => {
68
+ let finished = false;
69
+ const watcher = watch(worker.artifactDir, inspect);
70
+ const timer = setTimeout(() => finish(Object.assign(new Error("Worker has not acknowledged delivery. The task may still be accepted; it remains tracked as running. Wait for completion or stop it before retrying."), { deliveryUncertain: true })), 5000);
71
+ function finish(error?: Error) {
72
+ if (finished) return;
73
+ finished = true;
74
+ clearTimeout(timer);
75
+ watcher.close();
76
+ error ? reject(error) : resolve();
77
+ }
78
+ function inspect() {
79
+ try {
80
+ const ack = readJson(join(worker.artifactDir, "ack.json"));
81
+ if (ack?.id === worker.taskId) finish(ack.status === "ok" ? undefined : new Error(ack.error ?? "Worker rejected the task."));
82
+ } catch (cause) { finish(Object.assign(new Error(errorText(cause)), { deliveryUncertain: true })); }
83
+ }
84
+ watcher.on("error", (cause) => finish(Object.assign(cause, { deliveryUncertain: true })));
85
+ try {
86
+ const file = join(worker.artifactDir, "inbox.json");
87
+ writeJson(file, { id: worker.taskId, task: text });
88
+ // Some Bun directory watchers coalesce an atomic rename under its temporary filename.
89
+ // A final-file change event is an explicit wakeup, not a polling/re-send loop.
90
+ const now = new Date();
91
+ try { utimesSync(file, now, now); }
92
+ catch (cause) { if ((cause as NodeJS.ErrnoException).code !== "ENOENT") throw cause; } // Already consumed is fine.
93
+ inspect();
94
+ } catch (cause) { finish(Object.assign(new Error(errorText(cause)), { deliveryUncertain: true })); }
95
+ });
96
+ }
97
+
98
+ function prompt(task: string, id: string) {
99
+ return `[Harness task ${id}]\n${task}\n\nCoordinator protocol: keep the native TUI open after completing this task. Finish with a concise summary of artifacts, verification actually performed, and blockers. Optionally run harness-done "summary" (studio-done is an alias) to record your summary. The turn-settlement hook sends completion to the parent automatically; do not exit the application. Do not launch nested multi_harness workers.`;
100
+ }
101
+ function terminate(worker: Worker) {
102
+ if (worker.timer) clearTimeout(worker.timer);
103
+ worker.timer = undefined;
104
+ if (worker.backend === "process" && worker.child?.pid && worker.child.exitCode === null && worker.child.signalCode === null) {
105
+ if (process.platform === "win32") execFileSync("taskkill", ["/pid", String(worker.child.pid), "/T", "/F"], { stdio: "ignore" });
106
+ else {
107
+ try { process.kill(-worker.child.pid, "SIGTERM"); }
108
+ catch (cause) { if ((cause as NodeJS.ErrnoException).code !== "ESRCH") throw cause; }
109
+ const pid = worker.child.pid;
110
+ setTimeout(() => {
111
+ try { process.kill(-pid, "SIGKILL"); }
112
+ catch (cause) { if ((cause as NodeJS.ErrnoException).code !== "ESRCH") console.error(`Cannot terminate worker group ${pid}: ${errorText(cause)}`); }
113
+ }, 1000).unref();
114
+ }
115
+ } else if (worker.surface) {
116
+ closeSurface(worker.surface);
117
+ worker.surface = undefined;
118
+ }
119
+ }
120
+
121
+ export default function multiHarness(pi: ExtensionAPI) {
122
+ if (process.env.PI_MULTI_HARNESS_WORKER === "1") return;
123
+ const workers = new Map<string, Worker>();
124
+ const captures = new Set<Promise<void>>();
125
+ const controls = new Set<string>();
126
+ function release(worker: Worker) {
127
+ if (!controls.delete(worker.id)) return;
128
+ unlinkSync(join(worker.artifactDir, "controller.lock"));
129
+ }
130
+ let shuttingDown = false;
131
+ function notify(worker: Worker, status: string, summary: string) {
132
+ if (shuttingDown) return;
133
+ pi.sendMessage({ customType: "multi_harness_result", content: `[Harness "${worker.name}" (${worker.harness}) ${status}]\n${summary.slice(-8000)}\nArtifacts: ${worker.artifactDir}`, display: true, details: { ...record(worker), event: status } }, { triggerTurn: true, deliverAs: "steer" });
134
+ }
135
+ function sync(worker: Worker) {
136
+ const completion = readJson(join(worker.artifactDir, "completion.json"));
137
+ const processReady = worker.backend !== "process" || !worker.child || worker.child.exitCode !== null || worker.child.signalCode !== null;
138
+ if (processReady && busy(worker) && worker.taskId && completion?.id === worker.taskId) {
139
+ if (worker.timer) clearTimeout(worker.timer);
140
+ worker.timer = undefined;
141
+ worker.summary = completion.summary;
142
+ worker.status = worker.backend === "process" ? (completion.status === "completed" ? "idle" : "failed") : "idle";
143
+ save(worker);
144
+ notify(worker, completion.status, [completion.summary, completion.error].filter(Boolean).join("\n"));
145
+ if (worker.surface) {
146
+ const capture = readScreenAsync(worker.surface, 100).then((screen) => {
147
+ if (screen.trim()) writeFileSync(join(worker.artifactDir, `${completion.id}.screen.log`), screen, "utf8");
148
+ }).catch((cause) => console.error(`Could not capture ${worker.name}: ${errorText(cause)}`)).finally(() => captures.delete(capture));
149
+ captures.add(capture);
150
+ }
151
+ }
152
+ const ack = readJson(join(worker.artifactDir, "ack.json"));
153
+ if (busy(worker) && ack?.id === worker.taskId && ack?.status === "error") {
154
+ if (worker.timer) clearTimeout(worker.timer);
155
+ worker.timer = undefined;
156
+ worker.status = "idle";
157
+ save(worker);
158
+ notify(worker, "failed", `Worker rejected the task: ${ack.error}`);
159
+ }
160
+ const state = readJson(join(worker.artifactDir, "state.json"));
161
+ if (worker.backend !== "process" && state?.status === "exited" && !["closed", "stopped", "exited"].includes(worker.status)) {
162
+ worker.status = "exited";
163
+ if (worker.timer) clearTimeout(worker.timer);
164
+ worker.timer = undefined;
165
+ if (worker.surface) closeSurface(worker.surface);
166
+ worker.surface = undefined;
167
+ worker.watcher?.close(); worker.watcher = undefined;
168
+ release(worker);
169
+ save(worker);
170
+ notify(worker, "exited", "The native TUI exited. Its pane was closed; artifacts were preserved.");
171
+ }
172
+ }
173
+ function attach(worker: Worker) {
174
+ if (worker.watcher) return;
175
+ if (!controls.has(worker.id)) {
176
+ try {
177
+ const fd = openSync(join(worker.artifactDir, "controller.lock"), "wx", 0o600);
178
+ try { writeFileSync(fd, JSON.stringify({ pid: process.pid })); } finally { closeSync(fd); }
179
+ controls.add(worker.id);
180
+ } catch (cause) {
181
+ throw new Error(`Cannot take control of ${worker.name}: ${errorText(cause)}. A second session may own it. After a crash, verify the owner process is gone before manually removing controller.lock.`);
182
+ }
183
+ }
184
+ worker.watcher = watch(worker.artifactDir, (_event, name) => {
185
+ if (!["completion.json", "state.json", "ack.json"].includes(name?.toString() ?? "")) return;
186
+ try { sync(worker); }
187
+ catch (cause) { notify(worker, "IPC error", errorText(cause)); }
188
+ });
189
+ worker.watcher.on("error", (cause) => notify(worker, "watcher error", `Automatic completion delivery unavailable: ${cause.message}`));
190
+ sync(worker); // Subscribe before checking to avoid a fast-completion race.
191
+ }
192
+ function arm(worker: Worker, task: string, minutes: number) {
193
+ worker.taskId = randomUUID();
194
+ worker.status = "running";
195
+ writeJson(join(worker.artifactDir, "request.json"), { id: worker.taskId, task, at: Date.now() });
196
+ writeFileSync(join(worker.artifactDir, `${worker.taskId}.prompt.md`), prompt(task, worker.taskId), { mode: 0o600 });
197
+ worker.timer = setTimeout(() => {
198
+ if (!busy(worker)) return;
199
+ worker.status = "timed_out";
200
+ if (worker.backend === "process") { terminate(worker); worker.status = "stopped"; }
201
+ save(worker);
202
+ notify(worker, "timed_out", worker.backend === "process" ? "Process stopped after the time limit." : "No settlement received before the deadline. TUI remains open; inspect it or stop the worker. This is not a token/cost limit.");
203
+ }, minutes * 60_000);
204
+ save(worker);
205
+ return prompt(task, worker.taskId);
206
+ }
207
+ function launch(worker: Worker, text: string, resume = false) {
208
+ const metadata = readJson(join(worker.artifactDir, "metadata.json"));
209
+ if (resume && ["codex", "opencode"].includes(worker.harness) && !metadata?.sessionId) throw new Error(`${worker.harness} did not report a session ID; cannot resume a headless conversation.`);
210
+ const configFile = join(worker.artifactDir, "runner.json");
211
+ writeJson(configFile, { harness: worker.harness, cwd: worker.cwd, prompt: text, sessionFile: worker.sessionFile,
212
+ model: worker.model, trusted: worker.trusted, artifactDir: worker.artifactDir, interactive: worker.backend !== "process", resume,
213
+ sessionId: metadata?.sessionId ?? worker.sessionId });
214
+ const bin = setupBin(worker.artifactDir);
215
+ const env: Array<[string, string]> = [["PI_MULTI_HARNESS_WORKER", "1"], ["PI_SUBAGENT_ACTIVE", "1"], ["PI_MULTI_HARNESS_DIR", worker.artifactDir], ["PATH", `${bin}${delimiter}${process.env.PATH ?? ""}`]];
216
+ if (process.env.PI_CODING_AGENT_DIR) env.push(["PI_CODING_AGENT_DIR", process.env.PI_CODING_AGENT_DIR]);
217
+ if (worker.backend !== "process") {
218
+ worker.surface = createSurface(worker.name, worker.backend);
219
+ if (worker.backend === "tmux") worker.panePid = execFileSync("tmux", ["display-message", "-p", "-t", worker.surface, "#{pane_pid}"], { encoding: "utf8" }).trim();
220
+ const rendered = renderSubagentCommand({ cwd: worker.cwd, env, args: [process.execPath, RUNNER, configFile] });
221
+ if (process.platform === "win32") {
222
+ writeFileSync(join(worker.artifactDir, "launch.ps1"), `\uFEFF${rendered.script}`, "utf8");
223
+ sendCommand(worker.surface, `pwsh -NoProfile -EncodedCommand ${Buffer.from(rendered.script, "utf16le").toString("base64")}`);
224
+ } else sendLongCommand(worker.surface, rendered.script, { scriptPath: join(worker.artifactDir, `launch${rendered.scriptExt}`) });
225
+ } else {
226
+ const fd = openSync(join(worker.artifactDir, "console.log"), "a");
227
+ try {
228
+ const child = spawn(process.execPath, [RUNNER, configFile], { cwd: worker.cwd, env: { ...process.env, ...Object.fromEntries(env) }, detached: process.platform !== "win32", stdio: ["ignore", fd, fd], windowsHide: true });
229
+ worker.child = child;
230
+ child.once("error", (cause) => { if (!busy(worker)) return; terminate(worker); worker.status = "failed"; save(worker); notify(worker, "failed", cause.message); });
231
+ child.once("close", (code) => {
232
+ if (worker.child !== child) return;
233
+ try {
234
+ sync(worker);
235
+ if (busy(worker)) { if (worker.timer) clearTimeout(worker.timer); worker.status = "failed"; save(worker); notify(worker, "failed", `Runner exited (${code}) without completion. Inspect console.log.`); }
236
+ } catch (cause) { notify(worker, "IPC error", `Cannot collect exited runner: ${errorText(cause)}`); }
237
+ });
238
+ } finally { closeSync(fd); }
239
+ }
240
+ save(worker);
241
+ }
242
+ async function dispatch(worker: Worker, text: string) {
243
+ if (worker.backend === "process") {
244
+ if (worker.child && worker.child.exitCode === null) throw new Error("Previous process is still flushing its output; retry after it exits.");
245
+ launch(worker, text, true);
246
+ return;
247
+ }
248
+ inspectPane(worker);
249
+ const state = readJson(join(worker.artifactDir, "state.json"));
250
+ if (state?.status !== "idle") throw new Error("Native TUI is not idle; finish its current turn or permission dialog first.");
251
+ if (worker.harness === "pi" || worker.harness === "opencode") {
252
+ await deliverInbox(worker, text);
253
+ return;
254
+ }
255
+ if (worker.harness === "codex") {
256
+ const metadata = readJson(join(worker.artifactDir, "metadata.json"));
257
+ if (metadata?.sessionId) {
258
+ const spec = commandSpec(["codex", "queue", "--thread", metadata.sessionId, "--message", text]);
259
+ execFileSync(spec.command, spec.args, { cwd: worker.cwd, encoding: "utf8", timeout: 10000, windowsHide: true });
260
+ return;
261
+ }
262
+ }
263
+ // Idle-only bracketed paste preserves multiline tasks without interpreting them as keystrokes.
264
+ if (worker.backend === "tmux") {
265
+ const buffer = `harness-${worker.taskId}`;
266
+ const taskFile = join(worker.artifactDir, `${worker.taskId}.prompt.md`);
267
+ execFileSync("tmux", ["load-buffer", "-b", buffer, taskFile], { stdio: "ignore", timeout: 2000 });
268
+ execFileSync("tmux", ["paste-buffer", "-p", "-d", "-b", buffer, "-t", worker.surface!], { stdio: "ignore", timeout: 2000 });
269
+ execFileSync("tmux", ["send-keys", "-t", worker.surface!, "Enter"], { stdio: "ignore", timeout: 2000 });
270
+ } else {
271
+ execFileSync("zellij", ["action", "write-chars", "--pane-id", zellijPaneId(worker.surface!), `\x1b[200~${text}\x1b[201~`], { stdio: "ignore", timeout: 2000 });
272
+ execFileSync("zellij", ["action", "write", "--pane-id", zellijPaneId(worker.surface!), "13"], { stdio: "ignore", timeout: 2000 });
273
+ }
274
+ }
275
+ pi.registerTool({
276
+ name: "multi_harness", label: "Multi Harness",
277
+ description: "Delegate work to a named pi, codex, claude, or opencode CLI peer in any existing cwd. Spawn/send return immediately; turn completion is pushed to this parent session. Native mux TUIs stay open for follow-ups; no mux uses a silent process with session resume. No Git, worktree, comparison mode, model picker, or confirmation dialog is built in. External CLIs use their own billing; permission defaults are documented in the extension README. When the worktree-studio skill is installed, use it for isolated alternative implementations.",
278
+ promptSnippet: "Spawn named CLI peers with multi_harness, then wait for automatic completion messages; do not poll terminals or status loops.",
279
+ promptGuidelines: ["Specify scope, existing cwd, and verification criteria. Prevent overlapping file edits yourself. Worker conclusions are unverified; review artifacts before integrating. Use stop for active work and close for idle TUIs."],
280
+ parameters: Params,
281
+ async execute(_id, params, signal, _update, ctx) {
282
+ const action = params.action ?? "spawn";
283
+ const reply = (text: string, details: unknown = {}) => ({ content: [{ type: "text" as const, text }], details });
284
+ if (action === "status") {
285
+ const selected = params.target ? [workers.get(params.target)].filter(Boolean) as Worker[] : [...workers.values()];
286
+ return reply(selected.length ? selected.map((w) => `${w.name} (${w.harness}): ${w.status}; cwd=${w.cwd}; ${w.surface ?? "process"}; artifacts=${w.artifactDir}`).join("\n") : "No matching workers in this session.", { workers: selected.map(record) });
287
+ }
288
+ if ((action === "spawn" || action === "send") && params.task && /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(params.task)) throw new Error("Tasks may contain multiline text, but not terminal control characters.");
289
+ if (action === "spawn") {
290
+ if (!params.name || !/^[\w-]{1,64}$/.test(params.name)) throw new Error("Spawn requires a name of 1–64 letters, digits, hyphens or underscores.");
291
+ if (workers.has(params.name)) throw new Error(`Worker ${params.name} already exists. Use send or choose a new name.`);
292
+ if (!params.harness || !HARNESSES.includes(params.harness) || !params.task?.trim()) throw new Error("Spawn requires harness (pi/codex/claude/opencode) and task.");
293
+ const cwd = resolve(ctx.cwd, params.cwd ?? ".");
294
+ if (!statSync(cwd).isDirectory()) throw new Error(`Not a directory: ${cwd}`);
295
+ const check = commandSpec([params.harness, "--version"]);
296
+ execFileSync(check.command, check.args, { cwd, encoding: "utf8", timeout: 10000, windowsHide: true });
297
+ if (signal?.aborted) throw new Error("Spawn cancelled before launch.");
298
+ const id = randomUUID();
299
+ const artifactDir = join(homedir(), ".pi", "agent", "multi-harness", id);
300
+ mkdirSync(artifactDir, { recursive: true, mode: 0o700 });
301
+ const worker: Worker = { id, name: params.name, harness: params.harness, cwd, model: params.model ?? (params.harness === "pi" && ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined), trusted: cwd === resolve(ctx.cwd) && ctx.isProjectTrusted(), artifactDir, sessionFile: join(artifactDir, "session.jsonl"), sessionId: randomUUID(), backend: backend(), status: "running" };
302
+ workers.set(worker.name, worker); // Reserve the name before any asynchronous operation.
303
+ try { attach(worker); const text = arm(worker, params.task, params.maxMinutes ?? 15); launch(worker, text); }
304
+ catch (cause) { terminate(worker); worker.watcher?.close(); release(worker); worker.status = "failed"; save(worker); throw cause; }
305
+ pi.appendEntry("multi_harness_worker", { artifactDir });
306
+ return reply(`Started ${worker.name} (${worker.harness}, ${worker.backend}). Completion will be pushed automatically; do not poll. Artifacts: ${artifactDir}`, record(worker));
307
+ }
308
+ const worker = params.target ? workers.get(params.target) : undefined;
309
+ if (!worker) throw new Error("Specify target: an existing worker name.");
310
+ if (action === "send") {
311
+ if (worker.status !== "idle" || !params.task?.trim()) throw new Error("Send requires an idle worker and non-empty task. Stop active work first.");
312
+ if (signal?.aborted) throw new Error("Send cancelled before dispatch.");
313
+ const oldId = worker.taskId;
314
+ const previousRequest = readJson(join(worker.artifactDir, "request.json"));
315
+ try {
316
+ const text = arm(worker, params.task, params.maxMinutes ?? 15);
317
+ await dispatch(worker, text);
318
+ } catch (cause) {
319
+ if ((cause as { deliveryUncertain?: boolean }).deliveryUncertain) { save(worker); throw cause; }
320
+ if (worker.timer) clearTimeout(worker.timer);
321
+ worker.status = "idle"; worker.taskId = oldId;
322
+ if (previousRequest) writeJson(join(worker.artifactDir, "request.json"), previousRequest);
323
+ save(worker);
324
+ throw cause;
325
+ }
326
+ return reply(`Sent a new task to ${worker.name}. Completion will be pushed automatically.`, record(worker));
327
+ }
328
+ const nativeBusy = worker.backend !== "process" && readJson(join(worker.artifactDir, "state.json"))?.status === "running";
329
+ if (action === "close" && (busy(worker) || nativeBusy)) throw new Error("Worker has active work. Use stop before close.");
330
+ if (action === "stop" && !busy(worker) && !nativeBusy) return reply(`${worker.name} has no active work; use close to exit its idle TUI.`, record(worker));
331
+ if (action === "close") {
332
+ await Promise.all(captures);
333
+ if (shuttingDown || busy(worker) || (worker.backend !== "process" && readJson(join(worker.artifactDir, "state.json"))?.status === "running")) throw new Error("Worker state changed while closing; inspect status and stop active work first.");
334
+ }
335
+ terminate(worker);
336
+ worker.status = action === "stop" ? "stopped" : "closed";
337
+ worker.watcher?.close(); worker.watcher = undefined;
338
+ release(worker);
339
+ save(worker);
340
+ return reply(`${action === "stop" ? "Stopped" : "Closed"} ${worker.name}. Working files, sessions, and artifacts are preserved.`, record(worker));
341
+ },
342
+ });
343
+ pi.on("session_start", (_event, ctx) => {
344
+ shuttingDown = false;
345
+ for (const worker of workers.values()) {
346
+ if (busy(worker)) { terminate(worker); worker.status = "stopped"; save(worker); }
347
+ worker.watcher?.close();
348
+ release(worker);
349
+ }
350
+ workers.clear();
351
+ for (const entry of ctx.sessionManager.getBranch()) {
352
+ if (entry.type !== "custom" || entry.customType !== "multi_harness_worker") continue;
353
+ const saved = readJson(join((entry.data as { artifactDir: string }).artifactDir, "worker.json"));
354
+ if (!saved || workers.has(saved.name)) continue;
355
+ const worker = saved as Worker;
356
+ workers.set(worker.name, worker);
357
+ if (!["closed", "stopped", "exited", "failed"].includes(worker.status)) {
358
+ try { attach(worker); }
359
+ catch (cause) {
360
+ workers.delete(worker.name);
361
+ if (ctx.hasUI) ctx.ui.notify(errorText(cause), "warning");
362
+ else console.error(errorText(cause));
363
+ }
364
+ }
365
+ }
366
+ });
367
+ pi.on("session_shutdown", () => {
368
+ shuttingDown = true;
369
+ for (const worker of workers.values()) {
370
+ if (busy(worker)) { terminate(worker); worker.status = "stopped"; save(worker); }
371
+ worker.watcher?.close(); worker.watcher = undefined;
372
+ release(worker);
373
+ }
374
+ });
375
+ }
package/ipc.mjs ADDED
@@ -0,0 +1,37 @@
1
+ import { readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+
5
+ export function readJson(path) {
6
+ try { return JSON.parse(readFileSync(path, "utf8")); }
7
+ catch (error) {
8
+ if (error.code === "ENOENT") return undefined;
9
+ throw new Error(`Cannot read IPC file ${path}: ${error.message}`, { cause: error });
10
+ }
11
+ }
12
+
13
+ export function writeJson(path, value) {
14
+ const temporary = `${path}.${randomUUID()}.tmp`;
15
+ writeFileSync(temporary, JSON.stringify(value, null, 2), { encoding: "utf8", mode: 0o600 });
16
+ renameSync(temporary, path);
17
+ }
18
+
19
+ export function taskId(prompt) {
20
+ return typeof prompt === "string" ? prompt.match(/\[Harness task ([\w-]+)\]/)?.[1] : undefined;
21
+ }
22
+
23
+ /** Record a settled turn, not a model's early claim that it has finished. */
24
+ export function complete(directory, result) {
25
+ const request = readJson(join(directory, "request.json"));
26
+ if (!request || !result.id || request.id !== result.id) return;
27
+ const previous = readJson(join(directory, "completion.json"));
28
+ if (previous?.id === result.id) return;
29
+ const proposal = readJson(join(directory, "proposal.json"));
30
+ const value = {
31
+ ...result,
32
+ summary: result.summary || (proposal?.id === result.id ? proposal.summary : undefined) || "No final text captured; inspect the session or log.",
33
+ at: Date.now(),
34
+ };
35
+ writeJson(join(directory, "state.json"), { status: "idle", id: result.id, at: value.at });
36
+ writeJson(join(directory, "completion.json"), value);
37
+ }
@@ -0,0 +1,99 @@
1
+ import { existsSync, realpathSync, renameSync, unlinkSync, watch } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { complete, readJson, taskId, writeJson } from "./ipc.mjs";
4
+
5
+ const text = (parts) => (parts ?? []).filter((part) => part.type === "text").map((part) => part.text).join("\n");
6
+ const describe = (error) => error?.data?.message ?? error?.message ?? error?.name ?? String(error);
7
+
8
+ /** OpenCode 1.x server plugin: native SDK input and idle events, no terminal key injection. */
9
+ export default async function multiHarnessOpenCode({ client, directory }) {
10
+ const artifactDir = process.env.PI_MULTI_HARNESS_DIR;
11
+ if (process.env.PI_MULTI_HARNESS_WORKER !== "1" || !artifactDir) return {};
12
+ const assignedCwd = process.env.PI_MULTI_HARNESS_CWD;
13
+ if (assignedCwd && realpathSync(directory) !== realpathSync(assignedCwd)) return {};
14
+ let sessionId = readJson(join(artifactDir, "metadata.json"))?.sessionId;
15
+ let active;
16
+ let settling = false;
17
+ let receiving = false;
18
+ let disposed = false;
19
+ let model;
20
+ let agent;
21
+ const inbox = join(artifactDir, "inbox.json");
22
+ const state = (status, id) => writeJson(join(artifactDir, "state.json"), { status, id, at: Date.now() });
23
+
24
+ async function receive() {
25
+ if (disposed || receiving) return;
26
+ receiving = true;
27
+ let payload;
28
+ try {
29
+ const processing = `${inbox}.processing`;
30
+ renameSync(inbox, processing);
31
+ try { payload = readJson(processing); } finally { unlinkSync(processing); }
32
+ const request = readJson(join(artifactDir, "request.json"));
33
+ if (!sessionId || !payload?.id || payload.id !== request?.id || typeof payload.task !== "string" || taskId(payload.task) !== payload.id) {
34
+ throw new Error("OpenCode inbox requires a known session and the current task ID.");
35
+ }
36
+ const current = readJson(join(artifactDir, "state.json"));
37
+ if (current?.status !== "idle") throw new Error("OpenCode is not idle; do not dispatch during another turn or permission dialog.");
38
+ state("running", payload.id);
39
+ // The injected SDK is bound to this backend/directory, so the visible TUI observes this same session.
40
+ const result = await client.session.promptAsync({ path: { id: sessionId }, body: { parts: [{ type: "text", text: payload.task }], ...(model ? { model } : {}), ...(agent ? { agent } : {}) } });
41
+ if (result.error) throw new Error(`OpenCode rejected the prompt: ${describe(result.error)}`);
42
+ writeJson(join(artifactDir, "ack.json"), { id: payload.id, status: "ok" });
43
+ } catch (error) {
44
+ if (error.code !== "ENOENT") {
45
+ if (!active) state("idle");
46
+ writeJson(join(artifactDir, "ack.json"), { id: payload?.id ?? readJson(join(artifactDir, "request.json"))?.id, status: "error", error: describe(error) });
47
+ }
48
+ } finally { receiving = false; }
49
+ }
50
+
51
+ async function settled() {
52
+ if (settling || disposed) return;
53
+ if (!active) { state("idle"); return; }
54
+ settling = true;
55
+ const turn = active;
56
+ try {
57
+ const result = await client.session.messages({ path: { id: sessionId } });
58
+ if (result.error || !Array.isArray(result.data)) throw new Error(`Cannot read OpenCode messages: ${result.error ? describe(result.error) : "invalid SDK response"}`);
59
+ const messages = result.data;
60
+ const start = messages.findIndex((message) => message.info?.id === turn.messageId);
61
+ if (start === -1) throw new Error("Assigned user message was not found; refusing to report another turn's answer.");
62
+ // Compaction may add synthetic user continuations; use the last root-session answer after our message.
63
+ const last = messages.slice(start + 1).filter((message) => message.info?.role === "assistant" && (!message.info.sessionID || message.info.sessionID === sessionId)).at(-1);
64
+ const error = last?.info.error ?? (last?.info.finish === "stop" ? undefined : turn.error) ?? (!last ? new Error("No assistant response for the assigned task.") : undefined);
65
+ complete(artifactDir, { id: turn.id, sessionId, status: error ? "failed" : "completed", summary: text(last?.parts), ...(error ? { error: describe(error) } : {}) });
66
+ } catch (error) {
67
+ complete(artifactDir, { id: turn.id, sessionId, status: "failed", error: describe(error) });
68
+ } finally {
69
+ if (active === turn) active = undefined;
70
+ settling = false;
71
+ }
72
+ }
73
+
74
+ const watcher = watch(artifactDir, (_event, name) => { if (name?.toString() === "inbox.json" || existsSync(inbox)) void receive(); });
75
+ watcher.unref();
76
+ watcher.on("error", (error) => writeJson(join(artifactDir, "ack.json"), { id: readJson(join(artifactDir, "request.json"))?.id, status: "error", error: `OpenCode inbox watcher failed: ${error.message}` }));
77
+ void receive();
78
+ return {
79
+ "chat.message": async (input, output) => {
80
+ const id = taskId(text(output.parts));
81
+ const request = readJson(join(artifactDir, "request.json"));
82
+ if (sessionId && input.sessionID !== sessionId) return; // Ignore subagents and unrelated sessions.
83
+ if (id && id === request?.id) {
84
+ sessionId = input.sessionID;
85
+ active = { id, messageId: output.message.id ?? input.messageID };
86
+ writeJson(join(artifactDir, "metadata.json"), { harness: "opencode", sessionId });
87
+ } else if (!sessionId) return;
88
+ model = input.model;
89
+ agent = input.agent;
90
+ state("running", id);
91
+ },
92
+ event: async ({ event }) => {
93
+ if (!sessionId || event.properties?.sessionID !== sessionId) return;
94
+ if (event.type === "session.error" && active) active.error = event.properties.error;
95
+ if (event.type === "session.idle" || (event.type === "session.status" && event.properties.status?.type === "idle")) await settled();
96
+ },
97
+ dispose: async () => { disposed = true; watcher.close(); },
98
+ };
99
+ }
package/package.json CHANGED
@@ -1,6 +1,42 @@
1
1
  {
2
2
  "name": "@rol1n/multi-harness",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "type": "module",
5
+ "description": "multi-harness extension from pi-kit",
6
+ "keywords": [
7
+ "pi-package"
8
+ ],
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/RolinShmily/pi-kit.git",
12
+ "directory": "multi-harness"
13
+ },
14
+ "homepage": "https://github.com/RolinShmily/pi-kit#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/RolinShmily/pi-kit/issues"
17
+ },
18
+ "engines": {
19
+ "node": ">=22.19.0"
20
+ },
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "dependencies": {
25
+ "@rol1n/subagent": "^0.1.1"
26
+ },
27
+ "peerDependencies": {
28
+ "@earendil-works/pi-ai": "*",
29
+ "@earendil-works/pi-agent-core": "*",
30
+ "@earendil-works/pi-coding-agent": "*",
31
+ "@earendil-works/pi-tui": "*",
32
+ "typebox": "*"
33
+ },
34
+ "pi": {
35
+ "extensions": [
36
+ "./index.ts"
37
+ ],
38
+ "skills": [],
39
+ "prompts": [],
40
+ "themes": []
41
+ }
42
+ }
@@ -0,0 +1,115 @@
1
+ import { spawn } from "node:child_process";
2
+ import { appendFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { StringDecoder } from "node:string_decoder";
5
+ import { buildArgs, buildEnv, commandSpec } from "./adapters.mjs";
6
+ import { complete, readJson, writeJson } from "./ipc.mjs";
7
+
8
+ const config = readJson(process.argv[2]);
9
+ if (!config) throw new Error("Missing multi-harness runner configuration.");
10
+ const { artifactDir, interactive, harness } = config;
11
+ const request = readJson(join(artifactDir, "request.json"));
12
+ writeJson(join(artifactDir, "process.json"), { pid: process.pid, at: Date.now() });
13
+ let child;
14
+ try {
15
+ const spec = commandSpec(buildArgs(config));
16
+ child = spawn(spec.command, spec.args, {
17
+ cwd: config.cwd, env: buildEnv(config), windowsHide: !interactive,
18
+ stdio: interactive ? "inherit" : ["pipe", "pipe", "pipe"],
19
+ });
20
+ } catch (error) {
21
+ complete(artifactDir, { id: request.id, status: "failed", error: `Cannot launch ${harness}: ${error.message}` });
22
+ writeJson(join(artifactDir, "state.json"), { status: "exited", code: 1, at: Date.now() });
23
+ throw error;
24
+ }
25
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
26
+ process.on(signal, () => child.kill(signal === "SIGHUP" && process.platform === "win32" ? "SIGTERM" : signal));
27
+ }
28
+ let summary = "";
29
+ let failure;
30
+ let stderr = "";
31
+ let pending = "";
32
+ const decoder = new StringDecoder("utf8");
33
+ let metadata = readJson(join(artifactDir, "metadata.json")) ?? {};
34
+ const seenSteps = new Set();
35
+ const openCodeText = new Map();
36
+ let openCodeMessageId;
37
+ let openCodeSessionSeen = false;
38
+ if (harness === "opencode") { delete metadata.usage; delete metadata.costUSD; }
39
+ function textBlocks(content) {
40
+ return Array.isArray(content) ? content.filter((part) => part.type === "text").map((part) => part.text).join("\n") : "";
41
+ }
42
+ function onLine(line) {
43
+ if (harness === "pi") { summary = `${summary}${line}\n`.slice(-16000); return; }
44
+ let event;
45
+ try { event = JSON.parse(line); }
46
+ catch (error) {
47
+ if (error instanceof SyntaxError) return; // CLI diagnostics are preserved in console.log.
48
+ throw error;
49
+ }
50
+ if (harness === "codex") {
51
+ if (event.type === "thread.started") metadata.sessionId = event.thread_id ?? event["thread-id"];
52
+ if (event.item?.type === "agent_message") summary = event.item.text ?? summary;
53
+ if (event.type === "turn.completed") metadata.usage = event.usage;
54
+ if (["turn.failed", "error"].includes(event.type)) failure = event.message ?? event.error?.message ?? "Codex turn failed";
55
+ } else if (harness === "opencode") {
56
+ if (typeof event.sessionID === "string" && event.sessionID) {
57
+ openCodeSessionSeen = true;
58
+ if (config.resume && event.sessionID !== config.sessionId) failure = "OpenCode resumed a different session; refusing to treat it as the requested conversation.";
59
+ else metadata.sessionId = event.sessionID;
60
+ }
61
+ if (event.type === "text" && typeof event.part?.text === "string") {
62
+ if (event.part.messageID !== openCodeMessageId) { openCodeText.clear(); openCodeMessageId = event.part.messageID; }
63
+ openCodeText.set(event.part.id ?? "last", event.part.text);
64
+ summary = [...openCodeText.values()].join("\n");
65
+ }
66
+ if (event.type === "error") failure = event.error?.data?.message ?? event.error?.message ?? event.error?.name ?? "OpenCode turn failed";
67
+ if (event.type === "step_finish" && event.part && (!event.part.id || !seenSteps.has(event.part.id))) {
68
+ if (event.part.id) seenSteps.add(event.part.id);
69
+ if (event.part.tokens) {
70
+ metadata.usage ??= {};
71
+ for (const [key, value] of Object.entries({ input: event.part.tokens.input, output: event.part.tokens.output, reasoning: event.part.tokens.reasoning, cacheRead: event.part.tokens.cache?.read, cacheWrite: event.part.tokens.cache?.write })) {
72
+ if (typeof value === "number") metadata.usage[key] = (metadata.usage[key] ?? 0) + value;
73
+ }
74
+ }
75
+ if (typeof event.part.cost === "number") metadata.costUSD = (metadata.costUSD ?? 0) + event.part.cost;
76
+ }
77
+ } else {
78
+ if (event.type === "assistant") summary = textBlocks(event.message?.content) || summary;
79
+ if (event.session_id) metadata.sessionId = event.session_id;
80
+ if (event.type === "result") {
81
+ summary = event.result ?? summary;
82
+ metadata = { ...metadata, usage: event.usage, costUSD: event.total_cost_usd, model: Object.keys(event.modelUsage ?? {}).join(", ") };
83
+ if (event.is_error) failure = summary || "Claude turn failed";
84
+ }
85
+ }
86
+ }
87
+ if (!interactive) {
88
+ child.stdout.on("data", (chunk) => {
89
+ appendFileSync(join(artifactDir, "events.log"), chunk);
90
+ process.stdout.write(chunk);
91
+ pending += decoder.write(chunk);
92
+ let end;
93
+ while ((end = pending.indexOf("\n")) !== -1) { onLine(pending.slice(0, end)); pending = pending.slice(end + 1); }
94
+ });
95
+ child.stderr.on("data", (chunk) => { stderr = `${stderr}${chunk.toString("utf8")}`.slice(-4000); process.stderr.write(chunk); });
96
+ child.stdin.on("error", (cause) => { failure = `Prompt delivery failed: ${cause.message}`; });
97
+ // Pi receives its prompt as a CLI argument; the other CLIs receive exact UTF-8 stdin.
98
+ child.stdin.end(harness === "pi" ? undefined : config.prompt);
99
+ }
100
+ child.once("error", (cause) => { failure = `Cannot start ${harness}: ${cause.message}`; });
101
+ child.once("close", (code, signal) => {
102
+ if (!interactive) {
103
+ pending += decoder.end();
104
+ if (pending) onLine(pending);
105
+ if (harness === "opencode" && !openCodeSessionSeen) failure ??= "OpenCode exited without a JSON session ID; inspect events.log for CLI compatibility errors.";
106
+ writeJson(join(artifactDir, "metadata.json"), metadata);
107
+ complete(artifactDir, { id: request.id, status: code === 0 && !failure ? "completed" : "failed", summary: summary.trim(), error: failure ?? (code !== 0 ? stderr || `Process exit ${code ?? signal}` : undefined), sessionId: metadata.sessionId });
108
+ } else {
109
+ const current = readJson(join(artifactDir, "request.json"));
110
+ const previous = readJson(join(artifactDir, "completion.json"));
111
+ if (current && previous?.id !== current.id) complete(artifactDir, { id: current.id, status: "failed", error: failure ?? `TUI exited before settlement (${code ?? signal}).` });
112
+ }
113
+ writeJson(join(artifactDir, "state.json"), { status: "exited", code, signal, at: Date.now() });
114
+ process.exitCode = code ?? 1;
115
+ });
package/turn-hook.mjs ADDED
@@ -0,0 +1,29 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { complete, readJson, taskId, writeJson } from "./ipc.mjs";
4
+
5
+ const [directory, harness, raw] = process.argv.slice(2);
6
+ if (!directory || !["codex", "claude", "claude-start"].includes(harness)) throw new Error("Invalid multi-harness hook arguments.");
7
+ const event = JSON.parse(harness === "codex" ? raw : readFileSync(0, "utf8"));
8
+ const request = readJson(join(directory, "request.json"));
9
+ if (!request) process.exit(0);
10
+ if (harness === "claude-start") {
11
+ if (event.hook_event_name !== "UserPromptSubmit") process.exit(0);
12
+ writeJson(join(directory, "state.json"), { status: "running", id: taskId(event.prompt), at: Date.now() });
13
+ process.exit(0);
14
+ }
15
+ if (harness === "codex" && event.type !== "agent-turn-complete") process.exit(0);
16
+ if (harness === "claude" && event.hook_event_name !== "Stop") process.exit(0);
17
+ const sessionId = event["thread-id"] ?? event.thread_id ?? event.session_id;
18
+ const inputs = event["input-messages"] ?? event.input_messages;
19
+ const state = readJson(join(directory, "state.json"));
20
+ const id = harness === "codex"
21
+ ? (Array.isArray(inputs) ? inputs.map(taskId).filter(Boolean).at(-1) : request.id)
22
+ : state?.id;
23
+ if (sessionId) writeJson(join(directory, "metadata.json"), { sessionId, harness });
24
+ if (id !== request.id) {
25
+ // Manual turns must return to idle too, without completing a coordinator dispatch.
26
+ writeJson(join(directory, "state.json"), { status: "idle", at: Date.now() });
27
+ process.exit(0);
28
+ }
29
+ complete(directory, { id, status: "completed", sessionId, summary: event["last-assistant-message"] ?? event.last_assistant_message });
@@ -0,0 +1,52 @@
1
+ import { watch, renameSync, unlinkSync, type FSWatcher } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ import { complete, readJson, taskId, writeJson } from "./ipc.mjs";
5
+
6
+ /** Native Pi inbox and settlement events; never shuts down an idle TUI. */
7
+ export default function workerBridge(pi: ExtensionAPI) {
8
+ const directory = process.env.PI_MULTI_HARNESS_DIR;
9
+ if (process.env.PI_MULTI_HARNESS_WORKER !== "1" || !directory) return;
10
+ let watcher: FSWatcher | undefined;
11
+ let id: string | undefined;
12
+ let summary = "";
13
+ let error: string | undefined;
14
+ const inbox = join(directory, "inbox.json");
15
+
16
+ function receive() {
17
+ let payload: { id?: string; task?: string } | undefined;
18
+ const processing = `${inbox}.processing`;
19
+ try {
20
+ renameSync(inbox, processing);
21
+ payload = readJson(processing);
22
+ unlinkSync(processing);
23
+ if (!payload?.id || !payload.task) throw new Error("Inbox requires id and task.");
24
+ pi.sendUserMessage(payload.task, { deliverAs: "followUp", expandPromptTemplates: false });
25
+ writeJson(join(directory, "ack.json"), { id: payload.id, status: "ok" });
26
+ } catch (cause) {
27
+ if ((cause as NodeJS.ErrnoException).code === "ENOENT") return;
28
+ writeJson(join(directory, "ack.json"), { id: payload?.id, status: "error", error: String(cause) });
29
+ }
30
+ }
31
+ pi.on("session_start", () => {
32
+ watcher = watch(directory, (_event, name) => { if (name?.toString() === "inbox.json") receive(); });
33
+ watcher.on("error", (cause) => console.error(`Multi-harness inbox watcher failed: ${cause.message}`));
34
+ receive();
35
+ });
36
+ pi.on("before_agent_start", (event) => {
37
+ id = event.messageType === "user" ? taskId(event.prompt) : undefined;
38
+ summary = "";
39
+ error = undefined;
40
+ writeJson(join(directory, "state.json"), { status: "running", id, at: Date.now() });
41
+ });
42
+ pi.on("agent_end", (event) => {
43
+ const last = event.messages.filter((message) => message.role === "assistant").at(-1);
44
+ summary = last?.content?.filter((part) => part.type === "text").map((part) => part.text).join("\n") ?? "";
45
+ error = last?.stopReason === "error" ? last.errorMessage || "Model error" : undefined;
46
+ });
47
+ pi.on("agent_settled", (event) => {
48
+ if (id) complete(directory, { id, status: error || event.aborted ? "failed" : "completed", summary, error: error ?? (event.aborted ? "Turn aborted" : undefined) });
49
+ else writeJson(join(directory, "state.json"), { status: "idle", at: Date.now() });
50
+ });
51
+ pi.on("session_shutdown", () => { watcher?.close(); });
52
+ }