@rol1n/pi-kit 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 +73 -2
- package/ask-u-questions/README.md +26 -0
- package/ask-u-questions/dialog-ui.ts +80 -0
- package/ask-u-questions/index.ts +92 -0
- package/ask-u-questions/multi-choice.ts +258 -0
- package/ask-u-questions/single-choice.ts +180 -0
- package/ask-u-questions/tool-renderers.ts +67 -0
- package/ask-u-questions/types.ts +192 -0
- package/continue-signal/README.md +20 -0
- package/continue-signal/index.ts +69 -0
- package/custom-providers/README.md +20 -0
- package/custom-providers/asr/handlers/dashscope.ts +154 -0
- package/custom-providers/asr/index.ts +37 -0
- package/custom-providers/asr/models.ts +18 -0
- package/custom-providers/asr/types.ts +20 -0
- package/custom-providers/chat/index.ts +33 -0
- package/custom-providers/chat/magpie.ts +258 -0
- package/custom-providers/chat/models.ts +111 -0
- package/custom-providers/classifier/index.ts +6 -0
- package/custom-providers/classifier/models.ts +1 -0
- package/custom-providers/image/handlers/dashscope.ts +240 -0
- package/custom-providers/image/handlers/openrouter.ts +185 -0
- package/custom-providers/image/index.ts +96 -0
- package/custom-providers/image/models.ts +71 -0
- package/custom-providers/image/types.ts +22 -0
- package/custom-providers/index.ts +64 -0
- package/custom-providers/types.ts +17 -0
- package/image-generate/README.md +25 -0
- package/image-generate/files.ts +49 -0
- package/image-generate/index.ts +220 -0
- package/image-generate/model-selector.ts +150 -0
- package/memory-log/README.md +54 -0
- package/memory-log/agent/consolidator/prompt.ts +47 -0
- package/memory-log/agent/consolidator/tools.ts +160 -0
- package/memory-log/agent/cost.ts +32 -0
- package/memory-log/agent/index.ts +42 -0
- package/memory-log/agent/observer/prompt.ts +73 -0
- package/memory-log/agent/observer/tool.ts +73 -0
- package/memory-log/commands/compact.ts +35 -0
- package/memory-log/commands/consolidate.ts +47 -0
- package/memory-log/commands/status.ts +50 -0
- package/memory-log/config.ts +253 -0
- package/memory-log/debug-log.ts +76 -0
- package/memory-log/hooks/compaction-hook.ts +161 -0
- package/memory-log/hooks/compaction-trigger.ts +160 -0
- package/memory-log/hooks/consolidator-trigger.ts +143 -0
- package/memory-log/hooks/observer-trigger.ts +176 -0
- package/memory-log/ids.ts +78 -0
- package/memory-log/index.ts +149 -0
- package/memory-log/ledger/fold.ts +77 -0
- package/memory-log/ledger/index.ts +7 -0
- package/memory-log/ledger/pool.ts +38 -0
- package/memory-log/ledger/progress.ts +181 -0
- package/memory-log/ledger/projection.ts +115 -0
- package/memory-log/ledger/render.ts +38 -0
- package/memory-log/ledger/serialize.ts +161 -0
- package/memory-log/ledger/types.ts +201 -0
- package/memory-log/memory/index-render.ts +45 -0
- package/memory-log/memory/paths.ts +120 -0
- package/memory-log/memory/session.ts +64 -0
- package/memory-log/runtime.ts +127 -0
- package/memory-log/spawn/launch.ts +133 -0
- package/memory-log/spawn/runs.ts +83 -0
- package/memory-log/tokens.ts +66 -0
- package/memory-log/ui/status-controller.ts +225 -0
- package/memory-log/ui/timeline.ts +103 -0
- package/model-vision/README.md +22 -0
- package/multi-harness/README.md +63 -0
- package/multi-harness/adapters.mjs +72 -0
- package/multi-harness/harness-done.mjs +14 -0
- package/multi-harness/index.ts +375 -0
- package/multi-harness/ipc.mjs +37 -0
- package/multi-harness/opencode-plugin.mjs +99 -0
- package/multi-harness/process-runner.mjs +115 -0
- package/multi-harness/turn-hook.mjs +29 -0
- package/multi-harness/worker-bridge.ts +52 -0
- package/oai-subquota/README.md +22 -0
- package/oai-subquota/index.ts +286 -0
- package/oai-subquota/snapshot.ts +293 -0
- package/oai-subquota/ui.ts +183 -0
- package/package.json +92 -4
- package/prompt-snipaste/README.md +27 -0
- package/prompt-snipaste/index.ts +143 -0
- package/prompt-snipaste/menu.ts +159 -0
- package/prompt-snipaste/prompts/ask-questions.md +8 -0
- package/prompt-snipaste/prompts/delegate-exploration.md +7 -0
- package/prompt-snipaste/prompts/diagnose-report.md +7 -0
- package/prompt-snipaste/prompts/frontend-tech-preset.md +18 -0
- package/prompt-snipaste/prompts/orchestrator-mode.md +7 -0
- package/prompt-snipaste/prompts/session-kickoff.md +7 -0
- package/prompt-snipaste/prompts/verify-not-assume.md +7 -0
- package/prompt-snipaste/renderers.ts +87 -0
- package/prompt-snipaste/snippets.ts +206 -0
- package/prompt-snipaste/types.ts +18 -0
- package/screenshot/README.md +14 -0
- package/srp-theme/README.md +18 -0
- package/srp-theme/footer.ts +193 -0
- package/srp-theme/index.ts +172 -0
- package/srp-theme/tps-meter.ts +441 -0
- package/subagent/README.md +35 -0
- package/subagent/bridge.ts +171 -0
- package/subagent/index.ts +590 -0
- package/subagent/mux.ts +621 -0
- package/subagent/session.ts +166 -0
- package/subagent/ui.ts +100 -0
- package/tui-asr/README.md +21 -0
- package/tui-asr/dictation.ts +272 -0
- package/tui-asr/index.ts +181 -0
- package/tui-asr/input.ts +87 -0
- package/tui-asr/model-selector.ts +163 -0
- package/tui-asr/status.ts +79 -0
- package/web-reach/README.md +19 -0
- package/web-reach/html.ts +83 -0
- package/web-reach/index.ts +82 -0
- package/web-reach/page.ts +214 -0
- package/web-reach/rsc.ts +161 -0
- package/web-reach/search.ts +85 -0
- package/web-reach/transport.ts +35 -0
- package/windows-wheel-fix/README.md +12 -0
- package/windows-wheel-fix/index.ts +44 -0
|
@@ -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 "../subagent/mux.ts";
|
|
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
|
+
}
|
|
@@ -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
|
+
});
|