@neta-art/cohub-cli 10.5.0 → 10.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -3
- package/dist/board-kernel.js +13 -10
- package/dist/commands/generations.js +23 -2
- package/dist/commands/runtime.js +21 -8
- package/dist/commands/spaces.js +3 -3
- package/dist/runtime/archive-store.d.ts +2 -2
- package/dist/runtime/archive-store.js +2 -2
- package/dist/runtime/diagnostics.d.ts +3 -3
- package/dist/runtime/harnesses/chained-projection.d.ts +40 -0
- package/dist/runtime/harnesses/chained-projection.js +95 -0
- package/dist/runtime/harnesses/claude.d.ts +8 -0
- package/dist/runtime/harnesses/claude.js +153 -0
- package/dist/runtime/harnesses/codex.d.ts +4 -0
- package/dist/runtime/harnesses/codex.js +229 -0
- package/dist/runtime/harnesses/index.d.ts +16 -0
- package/dist/runtime/harnesses/index.js +57 -0
- package/dist/runtime/harnesses/pi.d.ts +4 -0
- package/dist/runtime/harnesses/pi.js +247 -0
- package/dist/runtime/harnesses/process.d.ts +31 -0
- package/dist/runtime/harnesses/process.js +75 -0
- package/dist/runtime/harnesses/stream-json.d.ts +30 -0
- package/dist/runtime/harnesses/stream-json.js +292 -0
- package/dist/runtime/harnesses/types.d.ts +108 -0
- package/dist/runtime/harnesses/types.js +1 -0
- package/dist/runtime/harnesses/workbuddy.d.ts +15 -0
- package/dist/runtime/harnesses/workbuddy.js +99 -0
- package/dist/runtime/json-rpc.d.ts +7 -1
- package/dist/runtime/json-rpc.js +23 -2
- package/dist/runtime/launch.d.ts +6 -4
- package/dist/runtime/launch.js +21 -11
- package/dist/runtime/native/adapters.d.ts +11 -28
- package/dist/runtime/native/adapters.js +7 -75
- package/dist/runtime/native/archive.d.ts +9 -7
- package/dist/runtime/native/archive.js +99 -38
- package/dist/runtime/native/attach.d.ts +3 -5
- package/dist/runtime/native/attach.js +8 -2
- package/dist/runtime/native/daemon.d.ts +2 -2
- package/dist/runtime/native/daemon.js +5 -4
- package/dist/runtime/native/execution.d.ts +9 -30
- package/dist/runtime/native/execution.js +7 -325
- package/dist/runtime/native/identity.d.ts +4 -4
- package/dist/runtime/native/identity.js +4 -4
- package/dist/runtime/native/ingest.d.ts +2 -2
- package/dist/runtime/native/ingest.js +20 -25
- package/dist/runtime/native/sessions.d.ts +4 -4
- package/dist/runtime/native/sessions.js +12 -22
- package/dist/runtime/native/transcript.d.ts +3 -0
- package/dist/runtime/native/transcript.js +6 -5
- package/dist/runtime/native/translate.d.ts +2 -0
- package/dist/runtime/native/translate.js +34 -1
- package/dist/runtime/native/version.js +1 -1
- package/dist/runtime/presentation.js +2 -1
- package/dist/runtime/projection-store.js +2 -12
- package/dist/runtime/supervisor.d.ts +4 -4
- package/dist/runtime/supervisor.js +1 -1
- package/dist/runtime/turn-projection.d.ts +1 -1
- package/dist/runtime/turn-projection.js +2 -2
- package/package.json +2 -2
- package/dist/runtime/harness.d.ts +0 -26
- package/dist/runtime/harness.js +0 -142
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
import { projectNativeSession } from "@neta-art/cohub";
|
|
4
|
+
import { JsonRpcProcess, record } from "../json-rpc.js";
|
|
5
|
+
import { codexModelCatalog } from "../model-catalog.js";
|
|
6
|
+
import { describeError } from "../../network-error.js";
|
|
7
|
+
import { openPrivateServer } from "../native/codex-link.js";
|
|
8
|
+
import { ArchiveNotRestorableError, copyNativeArchive } from "../native/archive.js";
|
|
9
|
+
import { codexArchivedSessionsDirectory, codexRolloutBytes, codexRolloutIdOf, codexSessionsDirectory, ensurePlainCodexRollout, readFirstRecord, readNativeTranscript, } from "../native/transcript.js";
|
|
10
|
+
import { codexTranslator } from "../native/translate.js";
|
|
11
|
+
import { array, closeHost, harnessCommand, inlineImage, text, turnEnvironment, watchHost } from "./process.js";
|
|
12
|
+
/** A stop request that the harness does not honour within this long ends its private process. */
|
|
13
|
+
const ABORT_ESCALATION_MS = 5_000;
|
|
14
|
+
function codexSessionPath(nativeSessionId) {
|
|
15
|
+
const home = process.env.CODEX_HOME?.trim() || join(homedir(), ".codex");
|
|
16
|
+
const day = new Date().toISOString().replace(/\.\d{3}Z$/, "").replaceAll(":", "-");
|
|
17
|
+
return join(home, "sessions", day.slice(0, 4), day.slice(5, 7), day.slice(8, 10), `rollout-${day}-${nativeSessionId}.jsonl`);
|
|
18
|
+
}
|
|
19
|
+
async function initializeCodex(rpc) {
|
|
20
|
+
await rpc.request("initialize", { clientInfo: { name: "cohub", title: "Cohub", version: "1" }, capabilities: { experimentalApi: true } });
|
|
21
|
+
rpc.write({ method: "initialized", params: {} });
|
|
22
|
+
}
|
|
23
|
+
/** Codex's first line is the only place a thread's purpose is recorded. */
|
|
24
|
+
export function codexThreadKind(payload) {
|
|
25
|
+
const source = payload.source;
|
|
26
|
+
if (source && typeof source === "object")
|
|
27
|
+
return record(record(source).subagent).thread_spawn ? "spawned" : "internal";
|
|
28
|
+
return ["subagent", "guardian_review", "memory_consolidation"].includes(text(payload.thread_source)) ? "internal" : "user";
|
|
29
|
+
}
|
|
30
|
+
async function runCodex(executor, turn) {
|
|
31
|
+
const { input, signal, context } = turn;
|
|
32
|
+
const shared = await executor.codex.get();
|
|
33
|
+
const prepared = await executor.sessions.prepare({
|
|
34
|
+
sessionId: input.sessionId, turnId: input.turnId, harness: "codex", cwd: executor.cwd,
|
|
35
|
+
provider: input.provider, context: input.context, signal,
|
|
36
|
+
// Only the shared server coordinates with terminal clients; a private one never opens their files.
|
|
37
|
+
resumable: (transcript) => transcript.path !== turn.diverged && (transcript.origin === "cohub" || shared !== null),
|
|
38
|
+
});
|
|
39
|
+
let session = prepared.session;
|
|
40
|
+
let host = null;
|
|
41
|
+
let channel = shared;
|
|
42
|
+
let message = { ordinal: 0, content: [] };
|
|
43
|
+
let begun = false;
|
|
44
|
+
try {
|
|
45
|
+
if (!channel) {
|
|
46
|
+
host = new JsonRpcProcess(...harnessCommand(executor.executables.codex || "codex", ["app-server", "--listen", "stdio://"], executor.cwd), executor.cwd, "jsonrpc", turnEnvironment(input));
|
|
47
|
+
watchHost(host, executor.diagnostics, context);
|
|
48
|
+
channel = await openPrivateServer(host);
|
|
49
|
+
}
|
|
50
|
+
const codex = channel;
|
|
51
|
+
const threadOptions = {
|
|
52
|
+
cwd: executor.cwd,
|
|
53
|
+
...(input.model ? { model: input.model } : {}),
|
|
54
|
+
...(input.provider && input.provider !== "codex" ? { modelProvider: input.provider } : {}),
|
|
55
|
+
...(input.accessMode === "read_only" ? { sandbox: "read-only" } : {}),
|
|
56
|
+
// Tools of a shared server otherwise see the environment of whoever started that server.
|
|
57
|
+
config: { "shell_environment_policy.set.COHUB_SPACE_ID": input.spaceId, "shell_environment_policy.set.COHUB_SESSION_ID": input.sessionId },
|
|
58
|
+
};
|
|
59
|
+
const existing = { ...threadOptions, threadId: session.nativeSessionId, path: session.path, excludeTurns: true };
|
|
60
|
+
const opened = prepared.resume === "new" ? await codex.request("thread/start", threadOptions)
|
|
61
|
+
: prepared.resume === "restored" ? await codex.request("thread/fork", existing)
|
|
62
|
+
: await codex.request("thread/resume", existing);
|
|
63
|
+
const thread = record(opened.thread);
|
|
64
|
+
if (typeof thread.id !== "string" || typeof thread.path !== "string")
|
|
65
|
+
throw new Error("Codex did not provide a durable native thread");
|
|
66
|
+
const threadId = thread.id;
|
|
67
|
+
if (thread.path !== session.path || threadId !== session.nativeSessionId) {
|
|
68
|
+
session = { ...session, nativeSessionId: threadId, path: thread.path };
|
|
69
|
+
executor.ingest.adopt(session, input.sessionId);
|
|
70
|
+
}
|
|
71
|
+
const translator = codexTranslator(turn.emit, { provider: text(opened.modelProvider) || "codex", model: text(opened.model) || input.model || null });
|
|
72
|
+
const content = await codexPrompt(input);
|
|
73
|
+
signal.throwIfAborted();
|
|
74
|
+
await turn.started(session);
|
|
75
|
+
begun = true;
|
|
76
|
+
let nativeTurnId = null;
|
|
77
|
+
executor.codex.driving.add(threadId);
|
|
78
|
+
await new Promise((resolve, reject) => {
|
|
79
|
+
let escalation;
|
|
80
|
+
let done = false;
|
|
81
|
+
const finish = (error) => {
|
|
82
|
+
if (done)
|
|
83
|
+
return;
|
|
84
|
+
done = true;
|
|
85
|
+
off();
|
|
86
|
+
offClose();
|
|
87
|
+
signal.removeEventListener("abort", abort);
|
|
88
|
+
clearTimeout(escalation);
|
|
89
|
+
if (error)
|
|
90
|
+
reject(error);
|
|
91
|
+
else
|
|
92
|
+
resolve();
|
|
93
|
+
};
|
|
94
|
+
const interrupt = () => { if (nativeTurnId)
|
|
95
|
+
void codex.request("turn/interrupt", { threadId, turnId: nativeTurnId }).catch(() => undefined); };
|
|
96
|
+
const off = codex.onEvent((event) => {
|
|
97
|
+
const params = event.params;
|
|
98
|
+
if (event.id != null) {
|
|
99
|
+
// An unattended Cohub Turn never grants an escalation.
|
|
100
|
+
if (!codex.shared || params.threadId === threadId && (!nativeTurnId || params.turnId === nativeTurnId))
|
|
101
|
+
codex.refuse(event.id, "Approval requires an interactive local client");
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (params.threadId !== threadId)
|
|
105
|
+
return;
|
|
106
|
+
// A resumed thread replays its last usage; only this Turn's counts belong to it.
|
|
107
|
+
if (event.method === "thread/tokenUsage/updated" && params.turnId !== nativeTurnId)
|
|
108
|
+
return;
|
|
109
|
+
if (event.method === "turn/started" && !nativeTurnId) {
|
|
110
|
+
nativeTurnId = text(record(params.turn).id) || null;
|
|
111
|
+
if (signal.aborted)
|
|
112
|
+
interrupt();
|
|
113
|
+
}
|
|
114
|
+
if (event.method === "turn/completed" && nativeTurnId && text(record(params.turn).id) !== nativeTurnId)
|
|
115
|
+
return;
|
|
116
|
+
try {
|
|
117
|
+
if (translator.push(event))
|
|
118
|
+
finish();
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
finish(error);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
const offClose = codex.onClose((error) => finish(error));
|
|
125
|
+
const abort = () => {
|
|
126
|
+
interrupt();
|
|
127
|
+
if (host)
|
|
128
|
+
escalation = setTimeout(() => void host?.close().catch(() => undefined), ABORT_ESCALATION_MS);
|
|
129
|
+
};
|
|
130
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
131
|
+
codex.request("turn/start", { threadId, clientUserMessageId: input.turnId, input: content, ...(input.thinkingLevel ? { effort: input.thinkingLevel === "off" ? "none" : input.thinkingLevel } : {}) })
|
|
132
|
+
.then((result) => { nativeTurnId ??= text(record(result.turn).id) || null; if (signal.aborted)
|
|
133
|
+
interrupt(); })
|
|
134
|
+
.catch(finish);
|
|
135
|
+
}).finally(() => executor.codex.driving.delete(threadId));
|
|
136
|
+
message = translator.last();
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
if (!begun) {
|
|
140
|
+
if (host)
|
|
141
|
+
await closeHost(host, null).catch(() => undefined);
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
message = { ...message, stopReason: signal.aborted ? "aborted" : "error", errorMessage: signal.aborted ? null : describeError(error) };
|
|
145
|
+
}
|
|
146
|
+
return { session, resume: prepared.resume, message, host };
|
|
147
|
+
}
|
|
148
|
+
async function codexPrompt(input) {
|
|
149
|
+
return await Promise.all(input.messages.flatMap((message) => message.content).map(async (block) => {
|
|
150
|
+
if (block.type === "image") {
|
|
151
|
+
const image = await inlineImage(block);
|
|
152
|
+
return { type: "image", url: `data:${image.mimeType};base64,${image.data}` };
|
|
153
|
+
}
|
|
154
|
+
return { type: "text", text: block.type === "text" ? block.text : JSON.stringify(block), text_elements: [] };
|
|
155
|
+
}));
|
|
156
|
+
}
|
|
157
|
+
export const codexHarness = {
|
|
158
|
+
id: "codex",
|
|
159
|
+
binary: "codex",
|
|
160
|
+
async models(executable, cwd) {
|
|
161
|
+
const rpc = new JsonRpcProcess(...harnessCommand(executable, ["app-server", "--listen", "stdio://"], cwd), cwd, "jsonrpc");
|
|
162
|
+
try {
|
|
163
|
+
await initializeCodex(rpc);
|
|
164
|
+
const config = await rpc.request("config/read", { includeLayers: false, cwd });
|
|
165
|
+
const entries = [];
|
|
166
|
+
const cursors = new Set();
|
|
167
|
+
let cursor = null;
|
|
168
|
+
do {
|
|
169
|
+
const result = await rpc.request("model/list", { cursor, limit: 100 });
|
|
170
|
+
entries.push(...array(result.data));
|
|
171
|
+
cursor = typeof result.nextCursor === "string" ? result.nextCursor : null;
|
|
172
|
+
if (entries.length > 2000 || cursor && cursors.has(cursor))
|
|
173
|
+
throw new Error("Invalid model catalog pagination");
|
|
174
|
+
if (cursor)
|
|
175
|
+
cursors.add(cursor);
|
|
176
|
+
} while (cursor);
|
|
177
|
+
return codexModelCatalog(config, entries);
|
|
178
|
+
}
|
|
179
|
+
finally {
|
|
180
|
+
await rpc.close();
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
run: runCodex,
|
|
184
|
+
session: {
|
|
185
|
+
path: (nativeSessionId) => codexSessionPath(nativeSessionId),
|
|
186
|
+
startsWithoutFile: true,
|
|
187
|
+
project: (input) => projectNativeSession(input, "codex"),
|
|
188
|
+
rebind: (records, nativeSessionId) => records.map((entry) => entry.sourceTurnId === null && entry.record.type === "session_meta"
|
|
189
|
+
? { ...entry, record: { ...entry.record, payload: { ...record(entry.record.payload), id: nativeSessionId, session_id: nativeSessionId } } }
|
|
190
|
+
: entry),
|
|
191
|
+
archive: {
|
|
192
|
+
target: ({ id }) => codexSessionPath(id),
|
|
193
|
+
async import(input) {
|
|
194
|
+
const { checksum } = await copyNativeArchive({
|
|
195
|
+
...input,
|
|
196
|
+
header: (header) => {
|
|
197
|
+
const payload = record(header.payload);
|
|
198
|
+
if (header.type !== "session_meta" || payload.id !== input.nativeSessionId)
|
|
199
|
+
throw new Error("Codex archive identity mismatch");
|
|
200
|
+
// A leaf archive does not carry its ancestor rollout.
|
|
201
|
+
if (payload.history_base != null)
|
|
202
|
+
throw new ArchiveNotRestorableError("Codex archive references history it does not carry; rebuild from the Session instead");
|
|
203
|
+
return { ...header, payload: { ...payload, id: input.id, ...(payload.session_id != null ? { session_id: input.id } : {}), history_mode: "legacy", cwd: input.cwd } };
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
return { checksum, nativeSessionId: input.id };
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
transcript: {
|
|
211
|
+
roots: () => [codexSessionsDirectory(), codexArchivedSessionsDirectory()],
|
|
212
|
+
acceptsDirectory: () => true,
|
|
213
|
+
acceptsTranscript: (path) => codexRolloutIdOf(basename(path)) !== null,
|
|
214
|
+
async header(path, signal) {
|
|
215
|
+
const header = await readFirstRecord(codexRolloutBytes(path, null, signal));
|
|
216
|
+
const payload = record(header.payload);
|
|
217
|
+
if (header.type !== "session_meta" || !text(payload.id) || !text(payload.cwd))
|
|
218
|
+
return null;
|
|
219
|
+
return { harness: "codex", nativeSessionId: text(payload.id), cwd: text(payload.cwd), thread: codexThreadKind(payload) };
|
|
220
|
+
},
|
|
221
|
+
read: (path, signal) => readNativeTranscript(path, "codex", { signal }),
|
|
222
|
+
boundaries: ['"UserMessage"', '"user_message"', '"turn_complete"', '"task_complete"', '"turn_aborted"'],
|
|
223
|
+
turnEndRecorded: true,
|
|
224
|
+
userFinalMarked: true,
|
|
225
|
+
globalTurnKeys: true,
|
|
226
|
+
archiveSource: (path, cacheDirectory) => ensurePlainCodexRollout(path, cacheDirectory),
|
|
227
|
+
},
|
|
228
|
+
nativeSync: true,
|
|
229
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type LocalHarness, type RuntimeCapabilities } from "@neta-art/cohub";
|
|
2
|
+
import type { HarnessExecutables, LocalHarnessAdapter, NativeTranscriptFormat } from "./types.js";
|
|
3
|
+
export type { HarnessExecutables, HarnessModel, HarnessResult, LocalHarnessAdapter } from "./types.js";
|
|
4
|
+
export { harnessCommand, harnessExecutableCandidates } from "./process.js";
|
|
5
|
+
export declare const harnessAdapter: (harness: LocalHarness) => LocalHarnessAdapter;
|
|
6
|
+
export declare const harnessExecutable: (harness: LocalHarness, executables: HarnessExecutables) => string;
|
|
7
|
+
/** Harnesses whose terminal conversations can sync to the Space. */
|
|
8
|
+
export declare const nativeSyncHarnesses: (harnesses: readonly LocalHarness[]) => ("claude" | "codex" | "pi" | "workbuddy")[];
|
|
9
|
+
export declare function transcriptFormat(harness: LocalHarness): NativeTranscriptFormat;
|
|
10
|
+
/** Discover installed executables only; authentication/capability errors stay explicit. */
|
|
11
|
+
export declare function installedHarnesses(cwd: string, executables: HarnessExecutables, path?: string): Promise<LocalHarness[]>;
|
|
12
|
+
/**
|
|
13
|
+
* Explicitly requested harnesses must all work. With `onUnavailable`, a failing harness is reported
|
|
14
|
+
* and left out instead, so auto-discovery still starts with the ones that work.
|
|
15
|
+
*/
|
|
16
|
+
export declare function discoverHarnesses(harnesses: LocalHarness[], executables: HarnessExecutables, cwd: string, onUnavailable?: (harness: LocalHarness, error: unknown) => void): Promise<RuntimeCapabilities>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { access, stat } from "node:fs/promises";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { LOCAL_HARNESS_SPECS, LOCAL_HARNESSES } from "@neta-art/cohub";
|
|
4
|
+
import { harnessExecutableCandidates } from "./process.js";
|
|
5
|
+
import { piHarness } from "./pi.js";
|
|
6
|
+
import { codexHarness } from "./codex.js";
|
|
7
|
+
import { claudeHarness } from "./claude.js";
|
|
8
|
+
import { workbuddyHarness } from "./workbuddy.js";
|
|
9
|
+
export { harnessCommand, harnessExecutableCandidates } from "./process.js";
|
|
10
|
+
/** Every local harness is one adapter; the protocol registry lists their identities. Built lazily to tolerate import cycles. */
|
|
11
|
+
let registry = null;
|
|
12
|
+
const adapters = () => registry ??= { pi: piHarness, codex: codexHarness, claude: claudeHarness, workbuddy: workbuddyHarness };
|
|
13
|
+
export const harnessAdapter = (harness) => adapters()[harness];
|
|
14
|
+
export const harnessExecutable = (harness, executables) => executables[harness] || adapters()[harness].binary;
|
|
15
|
+
/** Harnesses whose terminal conversations can sync to the Space. */
|
|
16
|
+
export const nativeSyncHarnesses = (harnesses) => harnesses.filter((harness) => adapters()[harness].nativeSync);
|
|
17
|
+
export function transcriptFormat(harness) {
|
|
18
|
+
const format = adapters()[harness].transcript;
|
|
19
|
+
if (!format)
|
|
20
|
+
throw new Error(`${LOCAL_HARNESS_SPECS[harness].label} transcripts cannot be read`);
|
|
21
|
+
return format;
|
|
22
|
+
}
|
|
23
|
+
/** Discover installed executables only; authentication/capability errors stay explicit. */
|
|
24
|
+
export async function installedHarnesses(cwd, executables, path = process.env.PATH ?? "") {
|
|
25
|
+
const explicit = LOCAL_HARNESSES.filter((harness) => executables[harness]);
|
|
26
|
+
const found = await Promise.all((explicit.length ? explicit : LOCAL_HARNESSES).map(async (harness) => {
|
|
27
|
+
for (const candidate of harnessExecutableCandidates(harnessExecutable(harness, executables), cwd, path)) {
|
|
28
|
+
try {
|
|
29
|
+
await access(candidate, process.platform === "win32" ? constants.F_OK : constants.X_OK);
|
|
30
|
+
if ((await stat(candidate)).isFile())
|
|
31
|
+
return harness;
|
|
32
|
+
}
|
|
33
|
+
catch { /* Try the next PATH entry. */ }
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}));
|
|
37
|
+
return found.filter((harness) => harness !== null);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Explicitly requested harnesses must all work. With `onUnavailable`, a failing harness is reported
|
|
41
|
+
* and left out instead, so auto-discovery still starts with the ones that work.
|
|
42
|
+
*/
|
|
43
|
+
export async function discoverHarnesses(harnesses, executables, cwd, onUnavailable) {
|
|
44
|
+
const catalogs = await Promise.all(harnesses.map(async (harness) => {
|
|
45
|
+
try {
|
|
46
|
+
return { harness, models: await adapters()[harness].models(harnessExecutable(harness, executables), cwd) };
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (!onUnavailable)
|
|
50
|
+
throw error;
|
|
51
|
+
onUnavailable(harness, error);
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}));
|
|
55
|
+
const available = catalogs.filter((catalog) => catalog !== null);
|
|
56
|
+
return { harnesses: available.map((catalog) => catalog.harness), models: available.flatMap((catalog) => catalog.models) };
|
|
57
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { createReadStream } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
import { projectNativeSession } from "@neta-art/cohub";
|
|
5
|
+
import { JsonRpcProcess, record } from "../json-rpc.js";
|
|
6
|
+
import { piModelCatalog } from "../model-catalog.js";
|
|
7
|
+
import { describeError } from "../../network-error.js";
|
|
8
|
+
import { PI_DISCONNECTED } from "../native/pi-link.js";
|
|
9
|
+
import { copyNativeArchive } from "../native/archive.js";
|
|
10
|
+
import { readFirstRecord, readNativeTranscript } from "../native/transcript.js";
|
|
11
|
+
import { piTranslator } from "../native/translate.js";
|
|
12
|
+
import { array, closeHost, harnessCommand, inlineImage, text, turnEnvironment, watchHost } from "./process.js";
|
|
13
|
+
/** A stop request that the harness does not honour within this long ends its private process. */
|
|
14
|
+
const ABORT_ESCALATION_MS = 5_000;
|
|
15
|
+
/** Pause before asking a busy Pi again, so a Turn that is starting has reported itself. */
|
|
16
|
+
const PI_BUSY_RETRY_MS = 100;
|
|
17
|
+
/** Pi stop reasons that end a reply; anything else (a tool call) means more is coming. */
|
|
18
|
+
const PI_ENDED = ["stop", "length", "error", "aborted"];
|
|
19
|
+
const agentDirectory = () => process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), ".pi", "agent");
|
|
20
|
+
const sessionRoot = () => process.env.PI_CODING_AGENT_SESSION_DIR?.trim() || join(agentDirectory(), "sessions");
|
|
21
|
+
/** Pi's encoding of a project directory into its session folder name. */
|
|
22
|
+
export const piSessionDirectoryName = (cwd) => `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
|
|
23
|
+
/** Whether a Pi project folder may hold sessions of `cwd` or a subdirectory of it. */
|
|
24
|
+
function piProjectFolderMatches(folder, cwd) {
|
|
25
|
+
const project = piSessionDirectoryName(cwd).slice(0, -2);
|
|
26
|
+
const name = basename(folder);
|
|
27
|
+
return name === `${project}--` || name.startsWith(`${project}-`);
|
|
28
|
+
}
|
|
29
|
+
function piSessionPath(nativeSessionId, cwd) {
|
|
30
|
+
const directory = process.env.PI_CODING_AGENT_SESSION_DIR?.trim() || join(agentDirectory(), "sessions", piSessionDirectoryName(cwd));
|
|
31
|
+
return join(directory, `${new Date().toISOString().replace(/[:.]/g, "-")}_${nativeSessionId}.jsonl`);
|
|
32
|
+
}
|
|
33
|
+
async function runPi(executor, turn) {
|
|
34
|
+
const { input, signal, context } = turn;
|
|
35
|
+
// Cohub drives every Pi through its extension, which needs the control socket and a recent Pi.
|
|
36
|
+
if (executor.pi.unavailable)
|
|
37
|
+
throw new Error(executor.pi.unavailable);
|
|
38
|
+
const prepared = await executor.sessions.prepare({
|
|
39
|
+
sessionId: input.sessionId, turnId: input.turnId, harness: "pi", cwd: executor.cwd,
|
|
40
|
+
provider: input.provider, context: input.context, signal,
|
|
41
|
+
// A terminal Pi is continued only through its extension; without one it is read-only.
|
|
42
|
+
resumable: (transcript) => transcript.path !== turn.diverged && (transcript.origin === "cohub" || executor.pi.find(transcript.nativeSessionId) !== null),
|
|
43
|
+
});
|
|
44
|
+
let session = prepared.session;
|
|
45
|
+
let link = executor.pi.find(session.nativeSessionId);
|
|
46
|
+
let host = null;
|
|
47
|
+
let message = { ordinal: 0, content: [] };
|
|
48
|
+
let begun = false;
|
|
49
|
+
try {
|
|
50
|
+
if (!link) {
|
|
51
|
+
host = new JsonRpcProcess(...harnessCommand(executor.executables.pi || "pi", ["--mode", "rpc", "--session", session.path, "-e", executor.piExtension], executor.cwd), executor.cwd, "pi", turnEnvironment(input));
|
|
52
|
+
watchHost(host, executor.diagnostics, context);
|
|
53
|
+
const rpc = host;
|
|
54
|
+
// Nobody is at this Pi's terminal to answer an extension's question.
|
|
55
|
+
rpc.onEvent((event) => {
|
|
56
|
+
if (event.type === "extension_ui_request" && ["confirm", "select", "input", "editor"].includes(text(event.method)))
|
|
57
|
+
rpc.write({ type: "extension_ui_response", id: event.id, cancelled: true });
|
|
58
|
+
});
|
|
59
|
+
await configurePiHost(host, input);
|
|
60
|
+
link = await Promise.race([
|
|
61
|
+
executor.pi.waitFor({ sessionId: session.nativeSessionId, sessionFile: session.path }, signal),
|
|
62
|
+
new Promise((_, reject) => host?.onFailure(reject)),
|
|
63
|
+
]);
|
|
64
|
+
}
|
|
65
|
+
const piSession = link;
|
|
66
|
+
session = { ...session, nativeSessionId: piSession.sessionId };
|
|
67
|
+
// Never interleave with a Turn a terminal user is running; follow it instead.
|
|
68
|
+
await executor.pi.idle(piSession.sessionId, signal);
|
|
69
|
+
const content = await piPrompt(input);
|
|
70
|
+
signal.throwIfAborted();
|
|
71
|
+
await turn.started(session);
|
|
72
|
+
begun = true;
|
|
73
|
+
const translator = piTranslator(turn.emit);
|
|
74
|
+
const model = !host && input.model && input.provider ? { provider: input.provider, id: input.model } : null;
|
|
75
|
+
await new Promise((resolve, reject) => {
|
|
76
|
+
let escalation;
|
|
77
|
+
/** Events held until Pi takes the prompt; until then they belong to a terminal Turn. */
|
|
78
|
+
let held = [];
|
|
79
|
+
let done = false;
|
|
80
|
+
const finish = (error) => {
|
|
81
|
+
if (done)
|
|
82
|
+
return;
|
|
83
|
+
done = true;
|
|
84
|
+
off();
|
|
85
|
+
signal.removeEventListener("abort", abort);
|
|
86
|
+
clearTimeout(escalation);
|
|
87
|
+
if (error)
|
|
88
|
+
reject(error);
|
|
89
|
+
else
|
|
90
|
+
resolve();
|
|
91
|
+
};
|
|
92
|
+
const feed = (event) => {
|
|
93
|
+
try {
|
|
94
|
+
if (translator.push(event))
|
|
95
|
+
finish();
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
finish(error);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
const off = executor.pi.subscribe(piSession.sessionId, (event) => {
|
|
102
|
+
if (event.type === PI_DISCONNECTED) {
|
|
103
|
+
finish(new Error("Pi exited during the Turn"));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (held)
|
|
107
|
+
held.push(event);
|
|
108
|
+
else
|
|
109
|
+
feed(event);
|
|
110
|
+
});
|
|
111
|
+
const abort = () => {
|
|
112
|
+
// Not taken yet: the Pi is running someone else's Turn, which is not Cohub's to stop.
|
|
113
|
+
if (held) {
|
|
114
|
+
finish(signal.reason ?? new Error("Aborted"));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
void executor.pi.abort(piSession.sessionId).catch(() => undefined);
|
|
118
|
+
if (host)
|
|
119
|
+
escalation = setTimeout(() => void host?.close().catch(() => undefined), ABORT_ESCALATION_MS);
|
|
120
|
+
};
|
|
121
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
122
|
+
void (async () => {
|
|
123
|
+
for (;;) {
|
|
124
|
+
// Only what follows this request can be this Turn's.
|
|
125
|
+
held = [];
|
|
126
|
+
const { accepted } = await executor.pi.prompt(piSession.sessionId, { turnId: input.turnId, content, model, thinkingLevel: host ? null : input.thinkingLevel ?? null, env: turnEnvironment(input) });
|
|
127
|
+
if (accepted)
|
|
128
|
+
break;
|
|
129
|
+
// The terminal started a Turn first: wait for it to settle, then ask again.
|
|
130
|
+
await new Promise((resolve) => setTimeout(resolve, PI_BUSY_RETRY_MS));
|
|
131
|
+
await executor.pi.idle(piSession.sessionId, signal);
|
|
132
|
+
if (done)
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const events = held ?? [];
|
|
136
|
+
held = null;
|
|
137
|
+
if (done) {
|
|
138
|
+
void executor.pi.abort(piSession.sessionId).catch(() => undefined);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
for (const event of events)
|
|
142
|
+
feed(event);
|
|
143
|
+
if (signal.aborted)
|
|
144
|
+
abort();
|
|
145
|
+
})().catch(finish);
|
|
146
|
+
if (signal.aborted)
|
|
147
|
+
abort();
|
|
148
|
+
});
|
|
149
|
+
message = translator.last();
|
|
150
|
+
// The Pi keeps running for its terminal user; its tools no longer work for this Turn.
|
|
151
|
+
if (!host)
|
|
152
|
+
void executor.pi.context(piSession.sessionId, { COHUB_TURN_ID: null }).catch(() => undefined);
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
// Before the prompt, nothing ran and the error is the outcome.
|
|
156
|
+
if (!begun) {
|
|
157
|
+
if (host)
|
|
158
|
+
await closeHost(host, null).catch(() => undefined);
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
message = { ...message, stopReason: signal.aborted ? "aborted" : "error", errorMessage: signal.aborted ? null : describeError(error) };
|
|
162
|
+
}
|
|
163
|
+
return { session, resume: prepared.resume, message, host };
|
|
164
|
+
}
|
|
165
|
+
/** A Pi that Cohub starts is configured through its RPC before the extension takes the prompt. */
|
|
166
|
+
async function configurePiHost(host, input) {
|
|
167
|
+
if (input.model) {
|
|
168
|
+
let provider = input.provider;
|
|
169
|
+
if (!provider) {
|
|
170
|
+
const catalog = await host.request("get_available_models");
|
|
171
|
+
const matches = (Array.isArray(catalog.models) ? catalog.models : []).map(record).filter((model) => model.id === input.model);
|
|
172
|
+
if (matches.length !== 1)
|
|
173
|
+
throw new Error("Select a provider for this local model");
|
|
174
|
+
provider = text(matches[0]?.provider);
|
|
175
|
+
}
|
|
176
|
+
await host.request("set_model", { provider, modelId: input.model });
|
|
177
|
+
}
|
|
178
|
+
if (input.thinkingLevel)
|
|
179
|
+
await host.request("set_thinking_level", { level: input.thinkingLevel });
|
|
180
|
+
}
|
|
181
|
+
async function piPrompt(input) {
|
|
182
|
+
const blocks = input.messages.flatMap((message) => message.content);
|
|
183
|
+
return await Promise.all(blocks.map(async (block) => {
|
|
184
|
+
if (block.type === "image")
|
|
185
|
+
return await inlineImage(block);
|
|
186
|
+
return { type: "text", text: block.type === "text" ? block.text : JSON.stringify(block) };
|
|
187
|
+
}));
|
|
188
|
+
}
|
|
189
|
+
export const piHarness = {
|
|
190
|
+
id: "pi",
|
|
191
|
+
binary: "pi",
|
|
192
|
+
async models(executable, cwd) {
|
|
193
|
+
const rpc = new JsonRpcProcess(...harnessCommand(executable, ["--mode", "rpc", "--no-session"], cwd), cwd, "pi");
|
|
194
|
+
try {
|
|
195
|
+
const result = await rpc.request("get_available_models");
|
|
196
|
+
const state = await rpc.request("get_state");
|
|
197
|
+
return piModelCatalog(array(result.models), state);
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
await rpc.close();
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
run: runPi,
|
|
204
|
+
session: {
|
|
205
|
+
path: piSessionPath,
|
|
206
|
+
startsWithoutFile: false,
|
|
207
|
+
project: (input) => projectNativeSession(input, "pi"),
|
|
208
|
+
rebind: (records, nativeSessionId) => records.map((entry) => entry.sourceTurnId === null && entry.record.type === "session"
|
|
209
|
+
? { ...entry, record: { ...entry.record, id: nativeSessionId, affinity: { ...record(entry.record.affinity), threadId: nativeSessionId } } }
|
|
210
|
+
: entry),
|
|
211
|
+
archive: {
|
|
212
|
+
target: ({ id, cwd }) => piSessionPath(id, cwd),
|
|
213
|
+
async import(input) {
|
|
214
|
+
const { checksum } = await copyNativeArchive({
|
|
215
|
+
...input,
|
|
216
|
+
header: (header) => {
|
|
217
|
+
if (header.type !== "session" || header.id !== input.nativeSessionId)
|
|
218
|
+
throw new Error("Pi archive identity mismatch");
|
|
219
|
+
const next = { ...header, cwd: input.cwd };
|
|
220
|
+
delete next.parentSession;
|
|
221
|
+
return next;
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
return { checksum, nativeSessionId: input.nativeSessionId };
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
transcript: {
|
|
229
|
+
roots: () => [sessionRoot()],
|
|
230
|
+
acceptsDirectory: (path, root, cwd) => Boolean(process.env.PI_CODING_AGENT_SESSION_DIR?.trim()) || dirname(path) !== root || piProjectFolderMatches(path, cwd),
|
|
231
|
+
acceptsTranscript: (path, cwd) => path.endsWith(".jsonl") && (Boolean(process.env.PI_CODING_AGENT_SESSION_DIR?.trim()) || piProjectFolderMatches(dirname(path), cwd)),
|
|
232
|
+
async header(path, signal) {
|
|
233
|
+
const header = await readFirstRecord(createReadStream(path, { signal }));
|
|
234
|
+
if (header.type !== "session" || !text(header.id) || !text(header.cwd))
|
|
235
|
+
return null;
|
|
236
|
+
return { harness: "pi", nativeSessionId: text(header.id), cwd: text(header.cwd), thread: "user" };
|
|
237
|
+
},
|
|
238
|
+
// A Pi Turn's end is inferred by the caller, so the tail is parsed as settled here.
|
|
239
|
+
read: (path, signal) => readNativeTranscript(path, "pi", { settled: true, signal }),
|
|
240
|
+
boundaries: ['"role":"user"', ...PI_ENDED.map((reason) => `"stopReason":"${reason}"`)],
|
|
241
|
+
turnEndRecorded: false,
|
|
242
|
+
replyEnded: (turn) => PI_ENDED.includes(turn.messages.at(-1)?.stopReason ?? ""),
|
|
243
|
+
userFinalMarked: false,
|
|
244
|
+
globalTurnKeys: false,
|
|
245
|
+
},
|
|
246
|
+
nativeSync: true,
|
|
247
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ContentBlock, RuntimeTurnInput } from "@neta-art/cohub";
|
|
2
|
+
import { type JsonRpcProcess } from "../json-rpc.js";
|
|
3
|
+
import { type RuntimeDiagnosticContext, type RuntimeDiagnostics } from "../diagnostics.js";
|
|
4
|
+
import type { HarnessResult } from "./types.js";
|
|
5
|
+
export declare const text: (value: unknown) => string;
|
|
6
|
+
export declare const array: (value: unknown) => unknown[];
|
|
7
|
+
export declare const missing: (error: unknown) => boolean;
|
|
8
|
+
/** Aborted streams leave tool calls with an empty id or name. */
|
|
9
|
+
export declare const identifiable: (value: unknown) => value is string;
|
|
10
|
+
export declare function harnessExecutableCandidates(binary: string, cwd: string, path: string, platform?: NodeJS.Platform, pathExt?: string | undefined): string[];
|
|
11
|
+
/**
|
|
12
|
+
* Windows npm/pnpm installs expose harnesses as .cmd shims, which cannot be spawned
|
|
13
|
+
* without a shell. Run an .exe directly, or the shim's Node entry with this Node.
|
|
14
|
+
*/
|
|
15
|
+
export declare function harnessCommand(binary: string, args: string[], cwd: string): [string, string[]];
|
|
16
|
+
/** Variables a Turn's tools see, so a `cohub` call inside a tool knows where it runs. */
|
|
17
|
+
export declare const turnEnvironment: (input: RuntimeTurnInput) => {
|
|
18
|
+
COHUB_SPACE_ID: string;
|
|
19
|
+
COHUB_SESSION_ID: string;
|
|
20
|
+
COHUB_TURN_ID: string;
|
|
21
|
+
};
|
|
22
|
+
export declare function inlineImage(block: Extract<ContentBlock, {
|
|
23
|
+
type: "image";
|
|
24
|
+
}>): Promise<{
|
|
25
|
+
type: "image";
|
|
26
|
+
data: string;
|
|
27
|
+
mimeType: string;
|
|
28
|
+
}>;
|
|
29
|
+
export declare function watchHost(host: JsonRpcProcess, diagnostics: RuntimeDiagnostics | undefined, context: RuntimeDiagnosticContext | undefined): void;
|
|
30
|
+
/** Close a harness process Cohub started. An unconfirmable cleanup marks the result uncertain; it never masks it. */
|
|
31
|
+
export declare function closeHost(host: JsonRpcProcess, executionError: string | null | undefined): Promise<HarnessResult["uncertainCleanup"]>;
|