@neta-art/cohub-cli 10.4.4 → 10.6.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.
Files changed (61) hide show
  1. package/README.md +18 -2
  2. package/dist/commands/runtime.js +21 -8
  3. package/dist/commands/space-wallet.d.ts +2 -0
  4. package/dist/commands/space-wallet.js +64 -0
  5. package/dist/commands/spaces.d.ts +5 -0
  6. package/dist/commands/spaces.js +55 -8
  7. package/dist/runtime/archive-store.d.ts +2 -2
  8. package/dist/runtime/archive-store.js +2 -2
  9. package/dist/runtime/diagnostics.d.ts +3 -3
  10. package/dist/runtime/harnesses/chained-projection.d.ts +40 -0
  11. package/dist/runtime/harnesses/chained-projection.js +95 -0
  12. package/dist/runtime/harnesses/claude.d.ts +8 -0
  13. package/dist/runtime/harnesses/claude.js +94 -0
  14. package/dist/runtime/harnesses/codex.d.ts +4 -0
  15. package/dist/runtime/harnesses/codex.js +229 -0
  16. package/dist/runtime/harnesses/index.d.ts +16 -0
  17. package/dist/runtime/harnesses/index.js +57 -0
  18. package/dist/runtime/harnesses/pi.d.ts +4 -0
  19. package/dist/runtime/harnesses/pi.js +247 -0
  20. package/dist/runtime/harnesses/process.d.ts +31 -0
  21. package/dist/runtime/harnesses/process.js +75 -0
  22. package/dist/runtime/harnesses/stream-json.d.ts +28 -0
  23. package/dist/runtime/harnesses/stream-json.js +291 -0
  24. package/dist/runtime/harnesses/types.d.ts +108 -0
  25. package/dist/runtime/harnesses/types.js +1 -0
  26. package/dist/runtime/harnesses/workbuddy.d.ts +15 -0
  27. package/dist/runtime/harnesses/workbuddy.js +99 -0
  28. package/dist/runtime/json-rpc.d.ts +7 -1
  29. package/dist/runtime/json-rpc.js +23 -2
  30. package/dist/runtime/launch.d.ts +6 -4
  31. package/dist/runtime/launch.js +21 -11
  32. package/dist/runtime/native/adapters.d.ts +11 -28
  33. package/dist/runtime/native/adapters.js +7 -75
  34. package/dist/runtime/native/archive.d.ts +9 -7
  35. package/dist/runtime/native/archive.js +99 -38
  36. package/dist/runtime/native/attach.d.ts +3 -5
  37. package/dist/runtime/native/attach.js +8 -2
  38. package/dist/runtime/native/daemon.d.ts +2 -2
  39. package/dist/runtime/native/daemon.js +5 -4
  40. package/dist/runtime/native/execution.d.ts +9 -30
  41. package/dist/runtime/native/execution.js +7 -325
  42. package/dist/runtime/native/identity.d.ts +4 -4
  43. package/dist/runtime/native/identity.js +4 -4
  44. package/dist/runtime/native/ingest.d.ts +2 -2
  45. package/dist/runtime/native/ingest.js +20 -25
  46. package/dist/runtime/native/sessions.d.ts +4 -4
  47. package/dist/runtime/native/sessions.js +12 -22
  48. package/dist/runtime/native/transcript.d.ts +3 -0
  49. package/dist/runtime/native/transcript.js +6 -5
  50. package/dist/runtime/native/translate.d.ts +2 -0
  51. package/dist/runtime/native/translate.js +34 -1
  52. package/dist/runtime/native/version.js +1 -1
  53. package/dist/runtime/presentation.js +2 -1
  54. package/dist/runtime/projection-store.js +2 -12
  55. package/dist/runtime/supervisor.d.ts +4 -4
  56. package/dist/runtime/supervisor.js +1 -1
  57. package/dist/runtime/turn-projection.d.ts +1 -1
  58. package/dist/runtime/turn-projection.js +2 -2
  59. package/package.json +2 -2
  60. package/dist/runtime/harness.d.ts +0 -26
  61. package/dist/runtime/harness.js +0 -142
@@ -0,0 +1,94 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { copyNativeArchive } from "../native/archive.js";
4
+ import { projectChainedSession, projectionMeta, projectionToolId, rebindSessionId } from "./chained-projection.js";
5
+ import { DEFAULT_MODEL, runStreamJson, streamJsonHelp, streamJsonModel } from "./stream-json.js";
6
+ const MAX_PROJECT_NAME = 200;
7
+ const dataRoot = () => process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), ".claude");
8
+ /**
9
+ * Claude Code names a project directory by replacing every non-alphanumeric UTF-16 unit; a longer
10
+ * name keeps its first 200 units and appends the base-36 Java string hash of the path.
11
+ */
12
+ export function claudeProjectName(cwd) {
13
+ const name = cwd.replace(/[^a-zA-Z0-9]/g, "-");
14
+ if (name.length <= MAX_PROJECT_NAME)
15
+ return name;
16
+ let hash = 0;
17
+ for (let index = 0; index < cwd.length; index++)
18
+ hash = (hash * 31 + cwd.charCodeAt(index)) | 0;
19
+ return `${name.slice(0, MAX_PROJECT_NAME)}-${Math.abs(hash).toString(36)}`;
20
+ }
21
+ export const claudeProjectDirectory = (cwd) => join(dataRoot(), "projects", claudeProjectName(cwd));
22
+ const profile = {
23
+ harness: "claude",
24
+ label: "Claude Code",
25
+ binary: "claude",
26
+ provider: "anthropic",
27
+ dataRoot,
28
+ projectDirectory: claudeProjectDirectory,
29
+ effortLevels: ["low", "medium", "high", "xhigh", "max"],
30
+ };
31
+ function anthropicBlock(block) {
32
+ if (block.type === "text")
33
+ return { type: "text", text: block.text };
34
+ if (block.type === "image" && block.source.type === "base64")
35
+ return { type: "image", source: { type: "base64", media_type: block.source.media_type, data: block.source.data } };
36
+ if (block.type === "tool_use")
37
+ return { type: "tool_use", id: projectionToolId(block.id), name: block.name.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 128) || "tool", input: block.input };
38
+ if (block.type === "tool_result") {
39
+ const content = typeof block.content === "string" ? block.content : block.content.map(anthropicBlock).filter((item) => item !== null);
40
+ return { type: "tool_result", tool_use_id: projectionToolId(block.tool_use_id), content, is_error: block.is_error ?? false };
41
+ }
42
+ return null;
43
+ }
44
+ /** Claude Code merges consecutive assistant records sharing a message id; each projected call gets its own. */
45
+ function withAssistantIds(projection) {
46
+ return { ...projection, records: projection.records.map((entry) => entry.record.type !== "assistant" ? entry : {
47
+ ...entry, record: { ...entry.record, message: { id: `msg_${String(entry.record.uuid).replaceAll("-", "")}`, ...entry.record.message } },
48
+ }) };
49
+ }
50
+ export const claudeHarness = {
51
+ id: "claude",
52
+ binary: profile.binary,
53
+ async models(executable, cwd) {
54
+ await streamJsonHelp(profile, executable, cwd);
55
+ // Aliases follow the signed-in account; Haiku takes no effort level.
56
+ return [
57
+ streamJsonModel(profile, DEFAULT_MODEL, "Default", { reasoning: true, isDefault: true }),
58
+ streamJsonModel(profile, "opus", "Claude Opus", { reasoning: true }),
59
+ streamJsonModel(profile, "sonnet", "Claude Sonnet", { reasoning: true }),
60
+ streamJsonModel(profile, "haiku", "Claude Haiku", { reasoning: false }),
61
+ ];
62
+ },
63
+ run: (executor, turn) => runStreamJson(profile, executor, turn),
64
+ session: {
65
+ path: (nativeSessionId, cwd) => join(claudeProjectDirectory(cwd), `${nativeSessionId}.jsonl`),
66
+ startsWithoutFile: true,
67
+ project: (input) => withAssistantIds(projectChainedSession(input, "claude", { id: "uuid", parent: "parentUuid" }, (segment) => {
68
+ const content = segment.blocks.map(anthropicBlock).filter((block) => block !== null);
69
+ return [{
70
+ isSidechain: false, userType: "external", cwd: input.cwd, sessionId: input.nativeSessionId, version: "cohub",
71
+ type: segment.role, timestamp: segment.message.createdAt || input.timestamp || new Date(0).toISOString(),
72
+ ...(segment.compactSummary ? { isCompactSummary: true } : {}),
73
+ message: segment.role === "user"
74
+ ? { role: "user", content }
75
+ : {
76
+ type: "message", role: "assistant", model: segment.message.model || "cohub", content,
77
+ stop_reason: segment.blocks.some((block) => block.type === "tool_use") ? "tool_use" : "end_turn", stop_sequence: null,
78
+ usage: { input_tokens: 0, output_tokens: 0 },
79
+ },
80
+ cohub: projectionMeta(input, segment.message),
81
+ }];
82
+ })),
83
+ rebind: rebindSessionId,
84
+ archive: {
85
+ // The copy takes a fresh id: the archived one may already exist here, and is never replaced.
86
+ target: ({ id, cwd }) => join(claudeProjectDirectory(cwd), `${id}.jsonl`),
87
+ async import(input) {
88
+ const { checksum } = await copyNativeArchive({ ...input, sessionId: input.id });
89
+ return { checksum, nativeSessionId: input.id };
90
+ },
91
+ },
92
+ },
93
+ nativeSync: false,
94
+ };
@@ -0,0 +1,4 @@
1
+ import type { LocalHarnessAdapter, ThreadKind } from "./types.js";
2
+ /** Codex's first line is the only place a thread's purpose is recorded. */
3
+ export declare function codexThreadKind(payload: Record<string, unknown>): ThreadKind;
4
+ export declare const codexHarness: LocalHarnessAdapter;
@@ -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,4 @@
1
+ import type { LocalHarnessAdapter } from "./types.js";
2
+ /** Pi's encoding of a project directory into its session folder name. */
3
+ export declare const piSessionDirectoryName: (cwd: string) => string;
4
+ export declare const piHarness: LocalHarnessAdapter;
@@ -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
+ };