@rol1n/multi-harness 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.
@@ -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
+ }
@@ -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
+ });
@@ -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
+ }