@neta-art/cohub-cli 10.5.0 → 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 (58) hide show
  1. package/README.md +4 -2
  2. package/dist/commands/runtime.js +21 -8
  3. package/dist/commands/spaces.js +3 -3
  4. package/dist/runtime/archive-store.d.ts +2 -2
  5. package/dist/runtime/archive-store.js +2 -2
  6. package/dist/runtime/diagnostics.d.ts +3 -3
  7. package/dist/runtime/harnesses/chained-projection.d.ts +40 -0
  8. package/dist/runtime/harnesses/chained-projection.js +95 -0
  9. package/dist/runtime/harnesses/claude.d.ts +8 -0
  10. package/dist/runtime/harnesses/claude.js +94 -0
  11. package/dist/runtime/harnesses/codex.d.ts +4 -0
  12. package/dist/runtime/harnesses/codex.js +229 -0
  13. package/dist/runtime/harnesses/index.d.ts +16 -0
  14. package/dist/runtime/harnesses/index.js +57 -0
  15. package/dist/runtime/harnesses/pi.d.ts +4 -0
  16. package/dist/runtime/harnesses/pi.js +247 -0
  17. package/dist/runtime/harnesses/process.d.ts +31 -0
  18. package/dist/runtime/harnesses/process.js +75 -0
  19. package/dist/runtime/harnesses/stream-json.d.ts +28 -0
  20. package/dist/runtime/harnesses/stream-json.js +291 -0
  21. package/dist/runtime/harnesses/types.d.ts +108 -0
  22. package/dist/runtime/harnesses/types.js +1 -0
  23. package/dist/runtime/harnesses/workbuddy.d.ts +15 -0
  24. package/dist/runtime/harnesses/workbuddy.js +99 -0
  25. package/dist/runtime/json-rpc.d.ts +7 -1
  26. package/dist/runtime/json-rpc.js +23 -2
  27. package/dist/runtime/launch.d.ts +6 -4
  28. package/dist/runtime/launch.js +21 -11
  29. package/dist/runtime/native/adapters.d.ts +11 -28
  30. package/dist/runtime/native/adapters.js +7 -75
  31. package/dist/runtime/native/archive.d.ts +9 -7
  32. package/dist/runtime/native/archive.js +99 -38
  33. package/dist/runtime/native/attach.d.ts +3 -5
  34. package/dist/runtime/native/attach.js +8 -2
  35. package/dist/runtime/native/daemon.d.ts +2 -2
  36. package/dist/runtime/native/daemon.js +5 -4
  37. package/dist/runtime/native/execution.d.ts +9 -30
  38. package/dist/runtime/native/execution.js +7 -325
  39. package/dist/runtime/native/identity.d.ts +4 -4
  40. package/dist/runtime/native/identity.js +4 -4
  41. package/dist/runtime/native/ingest.d.ts +2 -2
  42. package/dist/runtime/native/ingest.js +20 -25
  43. package/dist/runtime/native/sessions.d.ts +4 -4
  44. package/dist/runtime/native/sessions.js +12 -22
  45. package/dist/runtime/native/transcript.d.ts +3 -0
  46. package/dist/runtime/native/transcript.js +6 -5
  47. package/dist/runtime/native/translate.d.ts +2 -0
  48. package/dist/runtime/native/translate.js +34 -1
  49. package/dist/runtime/native/version.js +1 -1
  50. package/dist/runtime/presentation.js +2 -1
  51. package/dist/runtime/projection-store.js +2 -12
  52. package/dist/runtime/supervisor.d.ts +4 -4
  53. package/dist/runtime/supervisor.js +1 -1
  54. package/dist/runtime/turn-projection.d.ts +1 -1
  55. package/dist/runtime/turn-projection.js +2 -2
  56. package/package.json +2 -2
  57. package/dist/runtime/harness.d.ts +0 -26
  58. package/dist/runtime/harness.js +0 -142
@@ -0,0 +1,75 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import { delimiter, join, resolve, win32 } from "node:path";
3
+ import { RpcProcessClosedError } from "../json-rpc.js";
4
+ import { ProcessCleanupUncertainError } from "../process-group.js";
5
+ import { downloadPublicImage } from "../../safe-remote-image.js";
6
+ import { serializeDiagnosticError } from "../diagnostics.js";
7
+ export const text = (value) => typeof value === "string" ? value : "";
8
+ export const array = (value) => Array.isArray(value) ? value : [];
9
+ export const missing = (error) => error?.code === "ENOENT";
10
+ /** Aborted streams leave tool calls with an empty id or name. */
11
+ export const identifiable = (value) => typeof value === "string" && value.length > 0;
12
+ export function harnessExecutableCandidates(binary, cwd, path, platform = process.platform, pathExt = process.env.PATHEXT) {
13
+ const windows = platform === "win32";
14
+ const paths = windows ? win32 : { delimiter, join, resolve };
15
+ // Windows runs only PATHEXT names; npm/pnpm also write an extensionless sh script beside the .cmd shim.
16
+ const extensions = windows && !win32.extname(binary)
17
+ ? (pathExt || ".COM;.EXE;.BAT;.CMD").split(";").filter((ext) => /^\.[a-z0-9]+$/i.test(ext))
18
+ : [""];
19
+ const roots = binary.includes("/") || binary.includes("\\")
20
+ ? [paths.resolve(cwd, binary)]
21
+ : path.split(paths.delimiter).map((directory) => directory.replace(/^"(.*)"$/, "$1")).filter(Boolean).map((directory) => paths.resolve(cwd, directory, binary));
22
+ return roots.flatMap((root) => extensions.map((ext) => `${root}${ext}`));
23
+ }
24
+ /**
25
+ * Windows npm/pnpm installs expose harnesses as .cmd shims, which cannot be spawned
26
+ * without a shell. Run an .exe directly, or the shim's Node entry with this Node.
27
+ */
28
+ export function harnessCommand(binary, args, cwd) {
29
+ if (process.platform !== "win32")
30
+ return [binary, args];
31
+ const path = harnessExecutableCandidates(binary, cwd, process.env.PATH ?? "").find((candidate) => statSync(candidate, { throwIfNoEntry: false })?.isFile());
32
+ if (!path)
33
+ throw new Error(`${binary} was not found on PATH`);
34
+ const extension = win32.extname(path).toLowerCase();
35
+ if (extension === ".exe" || extension === ".com")
36
+ return [path, args];
37
+ if ([".js", ".mjs", ".cjs"].includes(extension))
38
+ return [process.execPath, [path, ...args]];
39
+ if (extension === ".cmd") {
40
+ // npm (cmd-shim): SET "_prog=node" … "%dp0%\entry" %*; pnpm (@zkochan/cmd-shim): node "%~dp0\entry" %*
41
+ const shim = readFileSync(path, "utf8");
42
+ const runsNode = /SET "_prog=node"/i.test(shim) || /^\s*node\s+"%~dp0\\/im.test(shim);
43
+ const entry = runsNode ? /"%~?dp0%?\\([^"]+)"\s+%\*/i.exec(shim)?.[1] : undefined;
44
+ if (entry)
45
+ return [process.execPath, [win32.join(win32.dirname(path), entry), ...args]];
46
+ }
47
+ throw new Error(`Cannot run ${path} without a shell; pass its executable option with an .exe or Node script`);
48
+ }
49
+ /** Variables a Turn's tools see, so a `cohub` call inside a tool knows where it runs. */
50
+ export const turnEnvironment = (input) => ({ COHUB_SPACE_ID: input.spaceId, COHUB_SESSION_ID: input.sessionId, COHUB_TURN_ID: input.turnId });
51
+ export async function inlineImage(block) {
52
+ if (block.source.type === "base64")
53
+ return { type: "image", data: block.source.data, mimeType: block.source.media_type };
54
+ const image = await downloadPublicImage(block.source.url);
55
+ return { type: "image", data: Buffer.from(image.bytes).toString("base64"), mimeType: image.mimeType };
56
+ }
57
+ export function watchHost(host, diagnostics, context) {
58
+ if (!diagnostics)
59
+ return;
60
+ host.onFailure((error) => {
61
+ if (!(error instanceof RpcProcessClosedError))
62
+ diagnostics.log("error", "harness.rpc_process_failed", { error: serializeDiagnosticError(error) }, { ...context, component: "harness" });
63
+ });
64
+ host.onTimeout((method, timeoutMs) => diagnostics.log("error", "harness.rpc_timeout", { method, timeoutMs }, { ...context, component: "harness" }));
65
+ }
66
+ /** Close a harness process Cohub started. An unconfirmable cleanup marks the result uncertain; it never masks it. */
67
+ export async function closeHost(host, executionError) {
68
+ return await host.close().then(() => undefined, (cleanupError) => {
69
+ const processGroupId = host.processGroupId;
70
+ if (!(cleanupError instanceof ProcessCleanupUncertainError) || processGroupId == null)
71
+ throw cleanupError;
72
+ const detail = executionError ? `; execution error: ${executionError}` : "";
73
+ return { processGroupId, error: `${cleanupError.message}${detail}` };
74
+ });
75
+ }
@@ -0,0 +1,28 @@
1
+ import type { RuntimeTurnInput } from "@neta-art/cohub";
2
+ import type { Executor } from "../native/execution.js";
3
+ import type { HarnessModel, HarnessRun, HarnessTurn } from "./types.js";
4
+ /** Runs without `--model`, so the CLI's own configuration and account default apply. */
5
+ export declare const DEFAULT_MODEL = "default";
6
+ /** A Claude Code–compatible CLI (`-p --input-format stream-json --output-format stream-json`). */
7
+ export type StreamJsonProfile = {
8
+ harness: RuntimeTurnInput["harness"];
9
+ label: string;
10
+ binary: string;
11
+ provider: string;
12
+ /** Root holding `projects/<project>/<session>.jsonl`. */
13
+ dataRoot(): string;
14
+ projectDirectory(cwd: string): string;
15
+ /** Thinking levels accepted by `--effort`. */
16
+ effortLevels: readonly string[];
17
+ };
18
+ /** The CLI's help, after checking it offers every flag a Turn uses. */
19
+ export declare function streamJsonHelp(profile: StreamJsonProfile, executable: string, cwd: string): Promise<string>;
20
+ export declare function streamJsonModel(profile: StreamJsonProfile, id: string, name: string, options: {
21
+ reasoning: boolean;
22
+ isDefault?: boolean;
23
+ }): HarnessModel;
24
+ /**
25
+ * One Turn per process. Unattended execution never grants an approval: workspace edits follow
26
+ * `acceptEdits` and every other tool follows the user's own permission rules.
27
+ */
28
+ export declare function runStreamJson(profile: StreamJsonProfile, executor: Executor, turn: HarnessTurn): Promise<HarnessRun>;
@@ -0,0 +1,291 @@
1
+ import { execFile } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { access, readdir } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { JsonRpcProcess, record } from "../json-rpc.js";
7
+ import { describeError } from "../../network-error.js";
8
+ import { array, closeHost, harnessCommand, identifiable, inlineImage, missing, text, turnEnvironment, watchHost } from "./process.js";
9
+ /** A stop request that the harness does not honour within this long ends its process. */
10
+ const ABORT_ESCALATION_MS = 5_000;
11
+ /** After input closes, the CLI flushes its transcript; it is reaped when it does not exit by then. */
12
+ const FLUSH_TIMEOUT_MS = 10_000;
13
+ /**
14
+ * Plan mode still honours the user's allow rules, so a read-only Turn also gets only these
15
+ * built-in tools and no configured MCP servers.
16
+ */
17
+ const READ_ONLY_TOOLS = "Read,Glob,Grep";
18
+ /** Flags every Turn relies on; an executable whose help lacks one is an older or unrelated CLI. */
19
+ const REQUIRED_FLAGS = ["--input-format", "--output-format", "--include-partial-messages", "--session-id", "--resume", "--permission-mode", "--tools", "--strict-mcp-config", "--model", "--effort"];
20
+ const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
21
+ /** Runs without `--model`, so the CLI's own configuration and account default apply. */
22
+ export const DEFAULT_MODEL = "default";
23
+ /** The CLI's help, after checking it offers every flag a Turn uses. */
24
+ export async function streamJsonHelp(profile, executable, cwd) {
25
+ const { stdout } = await promisify(execFile)(...harnessCommand(executable, ["--help"], cwd), { cwd, encoding: "utf8", timeout: 30_000, maxBuffer: 4 * 1024 * 1024, windowsHide: true });
26
+ const missingFlags = REQUIRED_FLAGS.filter((flag) => !new RegExp(`(^|[\\s,])${flag}(?=[\\s,=<]|$)`, "m").test(stdout));
27
+ if (missingFlags.length)
28
+ throw new Error(`${executable} is not a supported ${profile.label} CLI (missing ${missingFlags.join(", ")}); update ${profile.label}`);
29
+ return stdout;
30
+ }
31
+ export function streamJsonModel(profile, id, name, options) {
32
+ return {
33
+ harness: profile.harness, provider: profile.provider, id, name,
34
+ ...(options.isDefault ? { isDefault: true } : {}),
35
+ ...(options.reasoning ? { reasoning: true, thinkingLevelMap: Object.fromEntries(THINKING_LEVELS.map((level) => [level, profile.effortLevels.includes(level) ? level : null])) } : {}),
36
+ };
37
+ }
38
+ /** A session's file is found by id; the project directory naming is only the fast path. */
39
+ async function findSession(profile, nativeSessionId, cwd) {
40
+ const name = `${nativeSessionId}.jsonl`;
41
+ const exists = (path) => access(path).then(() => true, () => false);
42
+ const preferred = (() => { try {
43
+ return join(profile.projectDirectory(cwd), name);
44
+ }
45
+ catch {
46
+ return null;
47
+ } })();
48
+ if (preferred && await exists(preferred))
49
+ return preferred;
50
+ const projects = join(profile.dataRoot(), "projects");
51
+ const entries = await readdir(projects, { withFileTypes: true }).catch((error) => { if (missing(error))
52
+ return []; throw error; });
53
+ for (const entry of entries) {
54
+ const candidate = join(projects, entry.name, name);
55
+ if (entry.isDirectory() && await exists(candidate))
56
+ return candidate;
57
+ }
58
+ return null;
59
+ }
60
+ function streamContent(value) {
61
+ return array(value).flatMap((entry) => {
62
+ const block = record(entry);
63
+ if (block.type === "text")
64
+ return [{ type: "text", text: text(block.text) }];
65
+ if (block.type === "thinking")
66
+ return [{ type: "thinking", thinking: text(block.thinking), ...(text(block.signature) ? { signature: text(block.signature) } : {}) }];
67
+ if (block.type === "tool_use")
68
+ return identifiable(block.id) && identifiable(block.name) ? [{ type: "tool_use", id: block.id, name: block.name, input: record(block.input) }] : [];
69
+ const source = record(block.source);
70
+ if (block.type === "image" && source.type === "base64")
71
+ return [{ type: "image", source: { type: "base64", data: text(source.data), media_type: text(source.media_type) } }];
72
+ return [];
73
+ });
74
+ }
75
+ function streamUsage(value, cost) {
76
+ const usage = record(value);
77
+ const count = (key) => typeof usage[key] === "number" ? usage[key] : 0;
78
+ const input = count("input_tokens"), output = count("output_tokens"), cacheRead = count("cache_read_input_tokens"), cacheWrite = count("cache_creation_input_tokens");
79
+ if (!input && !output && !cacheRead && !cacheWrite)
80
+ return undefined;
81
+ return { input, output, cacheRead, cacheWrite, totalTokens: input + output + cacheRead + cacheWrite, ...(typeof cost === "number" && cost > 0 ? { cost: { total: cost } } : {}) };
82
+ }
83
+ async function prompt(input) {
84
+ const content = await Promise.all(input.messages.flatMap((message) => message.content).map(async (block) => {
85
+ if (block.type !== "image")
86
+ return { type: "text", text: block.type === "text" ? block.text : JSON.stringify(block) };
87
+ const image = await inlineImage(block);
88
+ return { type: "image", source: { type: "base64", media_type: image.mimeType, data: image.data } };
89
+ }));
90
+ return { type: "user", message: { role: "user", content } };
91
+ }
92
+ /** Maps stream-json output onto Cohub's streaming protocol; sub-agent traffic stays in the native transcript. */
93
+ function streamJsonTranslator(emit, identity) {
94
+ const messages = new Map();
95
+ const owners = new Map();
96
+ let current = null;
97
+ let streaming = null;
98
+ let model = identity.model;
99
+ let final = null;
100
+ const snapshot = (message) => ({
101
+ ordinal: message.ordinal, content: [...message.content, ...message.partial.values()], provider: identity.provider, model: message.model ?? model,
102
+ stopReason: message.content.some((block) => block.type === "tool_use") ? "tool_use" : "stop",
103
+ });
104
+ const begin = (id, messageModel) => {
105
+ const existing = messages.get(id);
106
+ if (existing)
107
+ return existing;
108
+ if (current)
109
+ emit({ type: "message.commit", message: snapshot(current) });
110
+ const message = { ordinal: messages.size, content: [], partial: new Map(), model: messageModel };
111
+ messages.set(id, message);
112
+ current = message;
113
+ emit({ type: "message.start", ordinal: message.ordinal });
114
+ return message;
115
+ };
116
+ const last = () => current ? snapshot(current) : { ordinal: 0, content: [], provider: identity.provider, model };
117
+ return {
118
+ /** The final `result`, or `null` while the Turn runs. */
119
+ result: () => final,
120
+ last,
121
+ push(event) {
122
+ if (event.parent_tool_use_id)
123
+ return false;
124
+ if (event.type === "system" && event.subtype === "init") {
125
+ model = text(event.model) || model;
126
+ return false;
127
+ }
128
+ if (event.type === "stream_event") {
129
+ const stream = record(event.event);
130
+ if (stream.type === "message_start")
131
+ streaming = begin(text(record(stream.message).id) || randomUUID(), text(record(stream.message).model) || null);
132
+ const delta = record(stream.delta);
133
+ if (streaming && stream.type === "content_block_delta" && (delta.type === "text_delta" || delta.type === "thinking_delta")) {
134
+ const index = Number(stream.index ?? 0), chunk = text(delta.text ?? delta.thinking);
135
+ const previous = streaming.partial.get(index);
136
+ streaming.partial.set(index, delta.type === "text_delta"
137
+ ? { type: "text", text: (previous?.type === "text" ? previous.text : "") + chunk }
138
+ : { type: "thinking", thinking: (previous?.type === "thinking" ? previous.thinking : "") + chunk });
139
+ emit({ type: "text.delta", ordinal: streaming.ordinal, index, kind: delta.type === "text_delta" ? "text" : "thinking", delta: chunk });
140
+ }
141
+ return false;
142
+ }
143
+ if (event.type === "assistant") {
144
+ const native = record(event.message);
145
+ const message = begin(text(native.id) || randomUUID(), text(native.model) || null);
146
+ // Complete blocks supersede what streamed so far.
147
+ message.partial.clear();
148
+ for (const block of streamContent(native.content)) {
149
+ const index = block.type === "tool_use" ? message.content.findIndex((existing) => existing.type === "tool_use" && existing.id === block.id) : -1;
150
+ if (index >= 0)
151
+ message.content[index] = block;
152
+ else
153
+ message.content.push(block);
154
+ if (block.type === "tool_use")
155
+ owners.set(block.id, message);
156
+ }
157
+ emit({ type: "content.replace", ordinal: message.ordinal, content: [...message.content] });
158
+ return false;
159
+ }
160
+ if (event.type === "user") {
161
+ for (const entry of array(record(event.message).content)) {
162
+ const block = record(entry);
163
+ const id = text(block.tool_use_id);
164
+ const owner = block.type === "tool_result" ? owners.get(id) : undefined;
165
+ if (!owner)
166
+ continue;
167
+ owner.content = owner.content.filter((existing) => existing.type !== "tool_result" || existing.tool_use_id !== id);
168
+ owner.content.push({ type: "tool_result", tool_use_id: id, content: typeof block.content === "string" ? block.content : streamContent(block.content), is_error: Boolean(block.is_error) });
169
+ emit({ type: "content.replace", ordinal: owner.ordinal, content: [...owner.content] });
170
+ }
171
+ return false;
172
+ }
173
+ if (event.type !== "result")
174
+ return false;
175
+ const failed = event.is_error === true || event.subtype !== "success";
176
+ final = {
177
+ ...last(),
178
+ stopReason: failed ? "error" : "stop",
179
+ errorMessage: failed ? text(event.result) || array(event.errors).map(text).join("\n") || text(event.subtype) || "Turn failed" : null,
180
+ usage: streamUsage(event.usage, event.total_cost_usd),
181
+ };
182
+ return true;
183
+ },
184
+ };
185
+ }
186
+ /**
187
+ * One Turn per process. Unattended execution never grants an approval: workspace edits follow
188
+ * `acceptEdits` and every other tool follows the user's own permission rules.
189
+ */
190
+ export async function runStreamJson(profile, executor, turn) {
191
+ const { input, signal, context } = turn;
192
+ const prepared = await executor.sessions.prepare({
193
+ sessionId: input.sessionId, turnId: input.turnId, harness: profile.harness, cwd: executor.cwd,
194
+ provider: input.provider, context: input.context, signal,
195
+ // Only a file Cohub wrote and advanced is continued; nothing else writes to it.
196
+ resumable: (transcript) => transcript.path !== turn.diverged && transcript.origin === "cohub",
197
+ });
198
+ let session = prepared.session;
199
+ const effort = input.thinkingLevel && profile.effortLevels.includes(input.thinkingLevel) ? input.thinkingLevel : null;
200
+ const args = [
201
+ "-p", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose", "--include-partial-messages",
202
+ ...(prepared.resume === "new" ? ["--session-id", session.nativeSessionId] : ["--resume", session.nativeSessionId]),
203
+ ...(input.accessMode === "read_only" ? ["--permission-mode", "plan", "--tools", READ_ONLY_TOOLS, "--strict-mcp-config"] : ["--permission-mode", "acceptEdits"]),
204
+ ...(input.model && input.model !== DEFAULT_MODEL ? ["--model", input.model] : []),
205
+ ...(effort ? ["--effort", effort] : []),
206
+ ];
207
+ const content = await prompt(input);
208
+ signal.throwIfAborted();
209
+ const host = new JsonRpcProcess(...harnessCommand(executor.executables[profile.harness] || profile.binary, args, executor.cwd), executor.cwd, "stream", turnEnvironment(input));
210
+ watchHost(host, executor.diagnostics, context);
211
+ const translator = streamJsonTranslator(turn.emit, { provider: profile.provider, model: input.model && input.model !== DEFAULT_MODEL ? input.model : null });
212
+ let message = { ordinal: 0, content: [] };
213
+ let begun = false;
214
+ try {
215
+ await turn.started(session);
216
+ begun = true;
217
+ await new Promise((resolve, reject) => {
218
+ let escalation;
219
+ let done = false;
220
+ const finish = (error) => {
221
+ if (done)
222
+ return;
223
+ done = true;
224
+ off();
225
+ offFailure();
226
+ signal.removeEventListener("abort", abort);
227
+ clearTimeout(escalation);
228
+ if (error)
229
+ reject(error);
230
+ else
231
+ resolve();
232
+ };
233
+ const offFailure = host.onFailure((error) => finish(error));
234
+ const off = host.onEvent((event) => {
235
+ // An unattended Cohub Turn never grants a permission request.
236
+ if (event.type === "control_request") {
237
+ try {
238
+ host.write({ type: "control_response", response: { subtype: "success", request_id: event.request_id, response: { behavior: "deny", message: "Approval requires an interactive local client" } } });
239
+ }
240
+ catch (error) {
241
+ finish(error);
242
+ }
243
+ return;
244
+ }
245
+ try {
246
+ if (translator.push(event))
247
+ finish();
248
+ }
249
+ catch (error) {
250
+ finish(error);
251
+ }
252
+ });
253
+ const abort = () => {
254
+ try {
255
+ host.write({ type: "control_request", request_id: randomUUID(), request: { subtype: "interrupt" } });
256
+ }
257
+ catch { /* Escalation closes the process. */ }
258
+ escalation = setTimeout(() => void host.close().catch(() => undefined), ABORT_ESCALATION_MS);
259
+ };
260
+ signal.addEventListener("abort", abort, { once: true });
261
+ try {
262
+ host.write(content);
263
+ }
264
+ catch (error) {
265
+ finish(error);
266
+ }
267
+ if (signal.aborted)
268
+ abort();
269
+ });
270
+ message = translator.result() ?? translator.last();
271
+ // A stopped Turn ends with an error result; the stop is not a failure to report.
272
+ if (signal.aborted)
273
+ message = { ...message, stopReason: "aborted", errorMessage: null };
274
+ }
275
+ catch (error) {
276
+ if (!begun) {
277
+ await closeHost(host, null).catch(() => undefined);
278
+ throw error;
279
+ }
280
+ message = { ...translator.last(), stopReason: signal.aborted ? "aborted" : "error", errorMessage: signal.aborted ? null : describeError(error) };
281
+ }
282
+ // Closing input lets the CLI flush its transcript before the executor reaps the process.
283
+ host.endInput();
284
+ await host.exited(FLUSH_TIMEOUT_MS);
285
+ const path = await findSession(profile, session.nativeSessionId, executor.cwd);
286
+ if (path && (path !== session.path || prepared.resume === "new")) {
287
+ session = { ...session, path };
288
+ executor.ingest.adopt(session, input.sessionId);
289
+ }
290
+ return { session, resume: prepared.resume, message, host };
291
+ }
@@ -0,0 +1,108 @@
1
+ import type { LocalHarness, NativeProjection, ProjectionInput, ProjectionRecord, RuntimeCapabilities, RuntimeExecutionEvent, RuntimeMessage, RuntimeTurnInput } from "@neta-art/cohub";
2
+ import type { JsonRpcProcess } from "../json-rpc.js";
3
+ import type { RuntimeDiagnosticContext } from "../diagnostics.js";
4
+ import type { Executor } from "../native/execution.js";
5
+ import type { NativeSession, ResumeKind } from "../native/sessions.js";
6
+ import type { NativeTranscript, NativeTranscriptTurn } from "../native/transcript.js";
7
+ export type HarnessExecutables = Partial<Record<LocalHarness, string>>;
8
+ export type HarnessModel = RuntimeCapabilities["models"][number];
9
+ export type HarnessResult = {
10
+ session: NativeSession;
11
+ event: Extract<RuntimeExecutionEvent, {
12
+ type: "turn.end";
13
+ }>;
14
+ uncertainCleanup?: {
15
+ processGroupId: number;
16
+ error: string;
17
+ };
18
+ };
19
+ /** One Cohub Turn handed to an adapter. `started` must be awaited right before the prompt is sent. */
20
+ export type HarnessTurn = {
21
+ input: RuntimeTurnInput;
22
+ emit: (event: RuntimeExecutionEvent) => void;
23
+ signal: AbortSignal;
24
+ /** A native file the server resolved as stopped; it is never continued. */
25
+ diverged: string | null;
26
+ started: (session: NativeSession) => Promise<void>;
27
+ context?: RuntimeDiagnosticContext;
28
+ };
29
+ /** `host` is a process Cohub started for this Turn; the executor closes it. */
30
+ export type HarnessRun = {
31
+ session: NativeSession;
32
+ resume: ResumeKind;
33
+ message: RuntimeMessage;
34
+ host: JsonRpcProcess | null;
35
+ };
36
+ export type ThreadKind = "user" | "internal" | "spawned";
37
+ export type SessionHeader = {
38
+ harness: LocalHarness;
39
+ nativeSessionId: string;
40
+ cwd: string;
41
+ thread: ThreadKind;
42
+ };
43
+ /** Native session files: where they live, how Cohub history is written into them, and how archives restore. */
44
+ export type NativeSessionFormat = {
45
+ /** File for a new native session of `cwd`. */
46
+ path(nativeSessionId: string, cwd: string): string;
47
+ /** An empty session is created by the harness itself instead of a projected file. */
48
+ startsWithoutFile: boolean;
49
+ project(input: ProjectionInput): NativeProjection;
50
+ /** Rewrite the session identity of a full projection. */
51
+ rebind(records: ProjectionRecord[], nativeSessionId: string): ProjectionRecord[];
52
+ archive: {
53
+ /** Working-copy path of a restored archive; `id` is fresh, `nativeSessionId` the archived one. */
54
+ target(input: {
55
+ id: string;
56
+ nativeSessionId: string;
57
+ cwd: string;
58
+ }): string;
59
+ /** Copy raw archive bytes to `target`, adjusting only the header; returns the copy's session id. */
60
+ import(input: NativeArchiveImport): Promise<{
61
+ checksum: string;
62
+ nativeSessionId: string;
63
+ }>;
64
+ };
65
+ };
66
+ export type NativeArchiveImport = {
67
+ source: string;
68
+ target: string;
69
+ nativeSessionId: string;
70
+ id: string;
71
+ cwd: string;
72
+ signal?: AbortSignal;
73
+ };
74
+ /** Reading a harness's own transcripts. Required for native sync and receipt-less recovery. */
75
+ export type NativeTranscriptFormat = {
76
+ roots(): string[];
77
+ /** Whether a directory found under a root can hold transcripts of `cwd`. */
78
+ acceptsDirectory(path: string, root: string, cwd: string): boolean;
79
+ /** Name-only check: can this path hold a transcript of `cwd`? */
80
+ acceptsTranscript(path: string, cwd: string): boolean;
81
+ header(path: string, signal?: AbortSignal): Promise<SessionHeader | null>;
82
+ /** Parse into Turns. Without `turnEndRecorded`, the tail is parsed as settled and withdrawn by the caller while in flight. */
83
+ read(path: string, signal?: AbortSignal): Promise<NativeTranscript>;
84
+ /** Record substrings that begin or end a Turn; new bytes without one need no parse. */
85
+ boundaries: string[];
86
+ /** The file records each Turn's end, so a Turn without a result is still running. */
87
+ turnEndRecorded: boolean;
88
+ /** Otherwise: whether the tail's last reply ended (a quiet period then settles it). */
89
+ replyEnded?(turn: NativeTranscriptTurn): boolean;
90
+ /** The prompt is recorded in steps and marked final (`userFinal`); a Turn is sent only once it is. */
91
+ userFinalMarked: boolean;
92
+ /** Turn keys are unique across sessions (forks share their history's Turns). */
93
+ globalTurnKeys: boolean;
94
+ /** Plain JSONL bytes for archival when the file itself is stored differently. */
95
+ archiveSource?(path: string, cacheDirectory: string): Promise<string>;
96
+ };
97
+ export type LocalHarnessAdapter = {
98
+ id: LocalHarness;
99
+ /** Executable looked up on PATH unless overridden. */
100
+ binary: string;
101
+ /** Fails when the harness cannot run. */
102
+ models(executable: string, cwd: string): Promise<HarnessModel[]>;
103
+ run(executor: Executor, turn: HarnessTurn): Promise<HarnessRun>;
104
+ session: NativeSessionFormat;
105
+ transcript?: NativeTranscriptFormat;
106
+ /** Terminal conversations can be synced to the Space (requires `transcript`). */
107
+ nativeSync: boolean;
108
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,15 @@
1
+ import type { LocalHarnessAdapter } from "./types.js";
2
+ /** Mirrors CodeBuddy's project naming, including its truncation hash for long paths. */
3
+ export declare function workbuddyProjectName(cwd: string): string;
4
+ /** The CLI lists selectable models (built-in and `models.json`) in its `--model` help. */
5
+ export declare function workbuddyModels(help: string): {
6
+ harness: "claude" | "codex" | "pi" | "workbuddy";
7
+ provider: string;
8
+ id: string;
9
+ name: string;
10
+ isDefault?: boolean | undefined;
11
+ reasoning?: boolean | undefined;
12
+ defaultThinkingLevel?: "high" | "low" | "max" | "medium" | "minimal" | "off" | "xhigh" | undefined;
13
+ thinkingLevelMap?: Partial<Record<"high" | "low" | "max" | "medium" | "minimal" | "off" | "xhigh", string | null>> | undefined;
14
+ }[];
15
+ export declare const workbuddyHarness: LocalHarnessAdapter;
@@ -0,0 +1,99 @@
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, toolResultText } from "./chained-projection.js";
5
+ import { DEFAULT_MODEL, runStreamJson, streamJsonHelp, streamJsonModel } from "./stream-json.js";
6
+ /** WorkBuddy's CLI is CodeBuddy Code; both share its data root. */
7
+ const dataRoot = () => process.env.CODEBUDDY_CONFIG_DIR?.trim() || join(homedir(), ".codebuddy");
8
+ const MAX_NAME_BYTES = 255;
9
+ const TRUNCATED_NAME_BYTES = 180;
10
+ /** Mirrors CodeBuddy's project naming, including its truncation hash for long paths. */
11
+ export function workbuddyProjectName(cwd) {
12
+ const base = cwd.replace(/[/\\:]/g, "-").replace(/^-+/, "").replace(/-+$/, "").replace(/-+/g, "-");
13
+ if (Buffer.byteLength(base, "utf8") <= MAX_NAME_BYTES)
14
+ return base;
15
+ let prefix = "", bytes = 0;
16
+ for (const character of base) {
17
+ const size = Buffer.byteLength(character, "utf8");
18
+ if (bytes + size > TRUNCATED_NAME_BYTES)
19
+ break;
20
+ bytes += size;
21
+ prefix += character;
22
+ }
23
+ let hash = 5381;
24
+ for (const byte of Buffer.from(base, "utf8"))
25
+ hash = 33 * hash ^ byte;
26
+ return `${prefix}-${(hash >>> 0).toString(36)}`;
27
+ }
28
+ const projectDirectory = (cwd) => join(dataRoot(), "projects", workbuddyProjectName(cwd));
29
+ const profile = {
30
+ harness: "workbuddy",
31
+ label: "WorkBuddy",
32
+ binary: "codebuddy",
33
+ provider: "codebuddy",
34
+ dataRoot,
35
+ projectDirectory,
36
+ effortLevels: ["minimal", "low", "medium", "high", "xhigh", "max"],
37
+ };
38
+ /** The CLI lists selectable models (built-in and `models.json`) in its `--model` help. */
39
+ export function workbuddyModels(help) {
40
+ const list = /--model <model>[\s\S]*?Currently supported:\s*\(([^)]*)\)/.exec(help)?.[1] ?? "";
41
+ const ids = [...new Set(list.split(",").map((id) => id.trim()).filter((id) => /^[\w.:/@-]{1,255}$/.test(id) && id !== DEFAULT_MODEL))];
42
+ if (!ids.length)
43
+ throw new Error("WorkBuddy lists no models; sign in to WorkBuddy or configure a model");
44
+ // The CLI ignores an effort level a model does not support.
45
+ return [streamJsonModel(profile, DEFAULT_MODEL, "Default", { reasoning: true, isDefault: true }), ...ids.map((id) => streamJsonModel(profile, id, id, { reasoning: true }))];
46
+ }
47
+ export const workbuddyHarness = {
48
+ id: "workbuddy",
49
+ binary: profile.binary,
50
+ models: async (executable, cwd) => workbuddyModels(await streamJsonHelp(profile, executable, cwd)),
51
+ run: (executor, turn) => runStreamJson(profile, executor, turn),
52
+ session: {
53
+ path: (nativeSessionId, cwd) => join(projectDirectory(cwd), `${nativeSessionId}.jsonl`),
54
+ startsWithoutFile: true,
55
+ // CodeBuddy stores Responses-style items: one record per message, call or result.
56
+ project: (input) => {
57
+ const names = new Map();
58
+ return projectChainedSession(input, "workbuddy", { id: "id", parent: "parentId" }, (segment) => {
59
+ const base = { timestamp: Date.parse(segment.message.createdAt) || 0, sessionId: input.nativeSessionId, cwd: input.cwd, cohub: projectionMeta(input, segment.message) };
60
+ if (segment.role === "user") {
61
+ if (segment.blocks.some((block) => block.type === "tool_result")) {
62
+ return segment.blocks.flatMap((block) => block.type === "tool_result"
63
+ ? [{ ...base, type: "function_call_result", callId: projectionToolId(block.tool_use_id), name: names.get(block.tool_use_id) ?? "tool", status: "completed", output: { type: "text", text: toolResultText(block) } }]
64
+ : []);
65
+ }
66
+ const content = segment.blocks.flatMap((block) => block.type === "text" ? [{ type: "input_text", text: block.text }]
67
+ : block.type === "image" && block.source.type === "base64" ? [{ type: "input_image", image: `data:${block.source.media_type};base64,${block.source.data}` }]
68
+ : []);
69
+ return content.length ? [{ ...base, type: "message", role: "user", content, providerData: { startsNewUserRequest: true } }] : [];
70
+ }
71
+ const records = [];
72
+ for (const block of segment.blocks) {
73
+ if (block.type === "text") {
74
+ const previous = records.at(-1);
75
+ if (previous?.type === "message")
76
+ previous.content.push({ type: "output_text", text: block.text });
77
+ else
78
+ records.push({ ...base, type: "message", role: "assistant", status: "completed", content: [{ type: "output_text", text: block.text }] });
79
+ }
80
+ else if (block.type === "tool_use") {
81
+ names.set(block.id, block.name);
82
+ records.push({ ...base, type: "function_call", callId: projectionToolId(block.id), name: block.name, arguments: JSON.stringify(block.input) });
83
+ }
84
+ }
85
+ return records;
86
+ });
87
+ },
88
+ rebind: rebindSessionId,
89
+ archive: {
90
+ // The copy takes a fresh id: the archived one may already exist here, and is never replaced.
91
+ target: ({ id, cwd }) => join(projectDirectory(cwd), `${id}.jsonl`),
92
+ async import(input) {
93
+ const { checksum } = await copyNativeArchive({ ...input, sessionId: input.id });
94
+ return { checksum, nativeSessionId: input.id };
95
+ },
96
+ },
97
+ },
98
+ nativeSync: false,
99
+ };
@@ -32,11 +32,17 @@ export declare class JsonRpcProcess {
32
32
  private stderr;
33
33
  private closed;
34
34
  private closing;
35
- constructor(binary: string, args: string[], cwd: string, mode: "pi" | "codex", context?: Record<string, string>);
35
+ private inputEnded;
36
+ /** `pi` and `jsonrpc` correlate request responses; `stream` delivers every line as an event. */
37
+ constructor(binary: string, args: string[], cwd: string, mode: "pi" | "jsonrpc" | "stream", context?: Record<string, string>);
36
38
  private fail;
37
39
  private receive;
38
40
  write(value: unknown): void;
39
41
  request(method: string, params?: JsonRecord, timeoutMs?: number): Promise<JsonRecord>;
42
+ /** Signal end of input; a stream-mode CLI flushes its transcript and exits. */
43
+ endInput(): void;
44
+ /** Whether the process exits within the timeout. */
45
+ exited(timeoutMs: number): Promise<boolean>;
40
46
  onEvent(listener: (event: JsonRecord) => void): () => boolean;
41
47
  onFailure(listener: (error: Error) => void): () => boolean;
42
48
  onTimeout(listener: (method: string, timeoutMs: number) => void): () => boolean;