@neta-art/cohub-cli 10.6.0 → 10.8.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 -5
- package/dist/board-kernel.js +207 -119
- package/dist/commands/boards/examples.js +16 -16
- package/dist/commands/boards.js +1 -1
- package/dist/commands/generations.js +23 -2
- package/dist/commands/runtime.js +3 -14
- package/dist/runtime/harnesses/chained-transcript.d.ts +24 -0
- package/dist/runtime/harnesses/chained-transcript.js +62 -0
- package/dist/runtime/harnesses/claude-transcript.d.ts +8 -0
- package/dist/runtime/harnesses/claude-transcript.js +138 -0
- package/dist/runtime/harnesses/claude.js +79 -2
- package/dist/runtime/harnesses/codex.js +1 -1
- package/dist/runtime/harnesses/cursor-transcript.d.ts +37 -0
- package/dist/runtime/harnesses/cursor-transcript.js +183 -0
- package/dist/runtime/harnesses/cursor.d.ts +24 -0
- package/dist/runtime/harnesses/cursor.js +620 -0
- package/dist/runtime/harnesses/index.d.ts +1 -3
- package/dist/runtime/harnesses/index.js +4 -10
- package/dist/runtime/harnesses/pi.js +1 -1
- package/dist/runtime/harnesses/stream-json.d.ts +7 -1
- package/dist/runtime/harnesses/stream-json.js +35 -12
- package/dist/runtime/harnesses/types.d.ts +10 -5
- package/dist/runtime/harnesses/workbuddy-transcript.d.ts +8 -0
- package/dist/runtime/harnesses/workbuddy-transcript.js +127 -0
- package/dist/runtime/harnesses/workbuddy.d.ts +1 -1
- package/dist/runtime/harnesses/workbuddy.js +31 -7
- package/dist/runtime/json-rpc.d.ts +2 -0
- package/dist/runtime/json-rpc.js +9 -0
- package/dist/runtime/native/attach.d.ts +1 -1
- package/dist/runtime/native/attach.js +1 -4
- package/dist/runtime/native/execution.js +14 -3
- package/dist/runtime/native/ingest.d.ts +14 -0
- package/dist/runtime/native/ingest.js +51 -5
- package/dist/runtime/native/sessions.js +6 -1
- package/dist/runtime/native/transcript.d.ts +19 -0
- package/dist/runtime/native/transcript.js +33 -2
- package/package.json +3 -3
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { RuntimeTurnInput } from "@neta-art/cohub";
|
|
1
|
+
import type { ContentBlock, RuntimeMessage, RuntimeTurnInput } from "@neta-art/cohub";
|
|
2
2
|
import type { Executor } from "../native/execution.js";
|
|
3
3
|
import type { HarnessModel, HarnessRun, HarnessTurn } from "./types.js";
|
|
4
4
|
/** Runs without `--model`, so the CLI's own configuration and account default apply. */
|
|
@@ -14,13 +14,19 @@ export type StreamJsonProfile = {
|
|
|
14
14
|
projectDirectory(cwd: string): string;
|
|
15
15
|
/** Thinking levels accepted by `--effort`. */
|
|
16
16
|
effortLevels: readonly string[];
|
|
17
|
+
/** The CLI keeps a stream-json prompt's `uuid` as its record id, so the file names the Cohub Turn. */
|
|
18
|
+
promptUuid: boolean;
|
|
17
19
|
};
|
|
18
20
|
/** The CLI's help, after checking it offers every flag a Turn uses. */
|
|
19
21
|
export declare function streamJsonHelp(profile: StreamJsonProfile, executable: string, cwd: string): Promise<string>;
|
|
20
22
|
export declare function streamJsonModel(profile: StreamJsonProfile, id: string, name: string, options: {
|
|
21
23
|
reasoning: boolean;
|
|
22
24
|
isDefault?: boolean;
|
|
25
|
+
provider?: string;
|
|
26
|
+
effortLevels?: readonly string[];
|
|
23
27
|
}): HarnessModel;
|
|
28
|
+
export declare function streamContent(value: unknown): ContentBlock[];
|
|
29
|
+
export declare function streamUsage(value: unknown, cost: unknown): RuntimeMessage["usage"];
|
|
24
30
|
/**
|
|
25
31
|
* One Turn per process. Unattended execution never grants an approval: workspace edits follow
|
|
26
32
|
* `acceptEdits` and every other tool follows the user's own permission rules.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { createReadStream } from "node:fs";
|
|
3
4
|
import { access, readdir } from "node:fs/promises";
|
|
4
5
|
import { join } from "node:path";
|
|
5
6
|
import { promisify } from "node:util";
|
|
@@ -29,10 +30,11 @@ export async function streamJsonHelp(profile, executable, cwd) {
|
|
|
29
30
|
return stdout;
|
|
30
31
|
}
|
|
31
32
|
export function streamJsonModel(profile, id, name, options) {
|
|
33
|
+
const effortLevels = options.effortLevels ?? profile.effortLevels;
|
|
32
34
|
return {
|
|
33
|
-
harness: profile.harness, provider: profile.provider, id, name,
|
|
35
|
+
harness: profile.harness, provider: options.provider ?? profile.provider, id, name,
|
|
34
36
|
...(options.isDefault ? { isDefault: true } : {}),
|
|
35
|
-
...(options.reasoning ? { reasoning: true, thinkingLevelMap: Object.fromEntries(THINKING_LEVELS.map((level) => [level,
|
|
37
|
+
...(options.reasoning ? { reasoning: true, thinkingLevelMap: Object.fromEntries(THINKING_LEVELS.map((level) => [level, effortLevels.includes(level) ? level : null])) } : {}),
|
|
36
38
|
};
|
|
37
39
|
}
|
|
38
40
|
/** A session's file is found by id; the project directory naming is only the fast path. */
|
|
@@ -57,7 +59,7 @@ async function findSession(profile, nativeSessionId, cwd) {
|
|
|
57
59
|
}
|
|
58
60
|
return null;
|
|
59
61
|
}
|
|
60
|
-
function streamContent(value) {
|
|
62
|
+
export function streamContent(value) {
|
|
61
63
|
return array(value).flatMap((entry) => {
|
|
62
64
|
const block = record(entry);
|
|
63
65
|
if (block.type === "text")
|
|
@@ -72,7 +74,7 @@ function streamContent(value) {
|
|
|
72
74
|
return [];
|
|
73
75
|
});
|
|
74
76
|
}
|
|
75
|
-
function streamUsage(value, cost) {
|
|
77
|
+
export function streamUsage(value, cost) {
|
|
76
78
|
const usage = record(value);
|
|
77
79
|
const count = (key) => typeof usage[key] === "number" ? usage[key] : 0;
|
|
78
80
|
const input = count("input_tokens"), output = count("output_tokens"), cacheRead = count("cache_read_input_tokens"), cacheWrite = count("cache_creation_input_tokens");
|
|
@@ -80,14 +82,32 @@ function streamUsage(value, cost) {
|
|
|
80
82
|
return undefined;
|
|
81
83
|
return { input, output, cacheRead, cacheWrite, totalTokens: input + output + cacheRead + cacheWrite, ...(typeof cost === "number" && cost > 0 ? { cost: { total: cost } } : {}) };
|
|
82
84
|
}
|
|
83
|
-
async function prompt(input) {
|
|
85
|
+
async function prompt(profile, input) {
|
|
84
86
|
const content = await Promise.all(input.messages.flatMap((message) => message.content).map(async (block) => {
|
|
85
87
|
if (block.type !== "image")
|
|
86
88
|
return { type: "text", text: block.type === "text" ? block.text : JSON.stringify(block) };
|
|
87
89
|
const image = await inlineImage(block);
|
|
88
90
|
return { type: "image", source: { type: "base64", media_type: image.mimeType, data: image.data } };
|
|
89
91
|
}));
|
|
90
|
-
return { type: "user", message: { role: "user", content } };
|
|
92
|
+
return { type: "user", ...(profile.promptUuid ? { uuid: input.turnId } : {}), message: { role: "user", content } };
|
|
93
|
+
}
|
|
94
|
+
/** Whether a session file already records this Turn's prompt (the CLI silently drops a repeated `uuid`). */
|
|
95
|
+
async function holdsPrompt(path, turnId, signal) {
|
|
96
|
+
const token = Buffer.from(`"uuid":"${turnId}"`);
|
|
97
|
+
let tail = Buffer.alloc(0);
|
|
98
|
+
try {
|
|
99
|
+
for await (const chunk of createReadStream(path, { signal })) {
|
|
100
|
+
const bytes = Buffer.concat([tail, chunk]);
|
|
101
|
+
if (bytes.includes(token))
|
|
102
|
+
return true;
|
|
103
|
+
tail = bytes.subarray(Math.max(0, bytes.length - token.length + 1));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
if (!missing(error))
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
return false;
|
|
91
111
|
}
|
|
92
112
|
/** Maps stream-json output onto Cohub's streaming protocol; sub-agent traffic stays in the native transcript. */
|
|
93
113
|
function streamJsonTranslator(emit, identity) {
|
|
@@ -189,12 +209,15 @@ function streamJsonTranslator(emit, identity) {
|
|
|
189
209
|
*/
|
|
190
210
|
export async function runStreamJson(profile, executor, turn) {
|
|
191
211
|
const { input, signal, context } = turn;
|
|
192
|
-
const
|
|
212
|
+
const prepare = (resumable) => executor.sessions.prepare({
|
|
193
213
|
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",
|
|
214
|
+
provider: input.provider, context: input.context, signal, resumable,
|
|
197
215
|
});
|
|
216
|
+
// Without a control plane, a terminal conversation is read-only: Cohub continues only files it wrote.
|
|
217
|
+
let prepared = await prepare((transcript) => transcript.path !== turn.diverged && transcript.origin === "cohub");
|
|
218
|
+
// A retried Turn whose prompt is already in the file would be dropped, so it continues from history.
|
|
219
|
+
if (profile.promptUuid && prepared.resume === "native" && await holdsPrompt(prepared.session.path, input.turnId, signal))
|
|
220
|
+
prepared = await prepare(() => false);
|
|
198
221
|
let session = prepared.session;
|
|
199
222
|
const effort = input.thinkingLevel && profile.effortLevels.includes(input.thinkingLevel) ? input.thinkingLevel : null;
|
|
200
223
|
const args = [
|
|
@@ -204,11 +227,11 @@ export async function runStreamJson(profile, executor, turn) {
|
|
|
204
227
|
...(input.model && input.model !== DEFAULT_MODEL ? ["--model", input.model] : []),
|
|
205
228
|
...(effort ? ["--effort", effort] : []),
|
|
206
229
|
];
|
|
207
|
-
const content = await prompt(input);
|
|
230
|
+
const content = await prompt(profile, input);
|
|
208
231
|
signal.throwIfAborted();
|
|
209
232
|
const host = new JsonRpcProcess(...harnessCommand(executor.executables[profile.harness] || profile.binary, args, executor.cwd), executor.cwd, "stream", turnEnvironment(input));
|
|
210
233
|
watchHost(host, executor.diagnostics, context);
|
|
211
|
-
const translator = streamJsonTranslator(turn.emit, { provider: profile.provider, model: input.model && input.model !== DEFAULT_MODEL ? input.model : null });
|
|
234
|
+
const translator = streamJsonTranslator(turn.emit, { provider: input.provider || profile.provider, model: input.model && input.model !== DEFAULT_MODEL ? input.model : null });
|
|
212
235
|
let message = { ordinal: 0, content: [] };
|
|
213
236
|
let begun = false;
|
|
214
237
|
try {
|
|
@@ -49,7 +49,8 @@ export type NativeSessionFormat = {
|
|
|
49
49
|
project(input: ProjectionInput): NativeProjection;
|
|
50
50
|
/** Rewrite the session identity of a full projection. */
|
|
51
51
|
rebind(records: ProjectionRecord[], nativeSessionId: string): ProjectionRecord[];
|
|
52
|
-
|
|
52
|
+
/** Absent when the native state is not one file Cohub can carry; history is then projected again. */
|
|
53
|
+
archive?: {
|
|
53
54
|
/** Working-copy path of a restored archive; `id` is fresh, `nativeSessionId` the archived one. */
|
|
54
55
|
target(input: {
|
|
55
56
|
id: string;
|
|
@@ -71,7 +72,7 @@ export type NativeArchiveImport = {
|
|
|
71
72
|
cwd: string;
|
|
72
73
|
signal?: AbortSignal;
|
|
73
74
|
};
|
|
74
|
-
/** Reading a harness's own transcripts
|
|
75
|
+
/** Reading a harness's own transcripts: native sync, import and receipt-less recovery. */
|
|
75
76
|
export type NativeTranscriptFormat = {
|
|
76
77
|
roots(): string[];
|
|
77
78
|
/** Whether a directory found under a root can hold transcripts of `cwd`. */
|
|
@@ -91,6 +92,11 @@ export type NativeTranscriptFormat = {
|
|
|
91
92
|
userFinalMarked: boolean;
|
|
92
93
|
/** Turn keys are unique across sessions (forks share their history's Turns). */
|
|
93
94
|
globalTurnKeys: boolean;
|
|
95
|
+
/**
|
|
96
|
+
* A Turn Cohub runs carries its Cohub Turn id in the file (`cloudTurnId`). Otherwise the ledger
|
|
97
|
+
* records which Turn Cohub is running, and a Cohub file's Turns up to its end are taken as the server's.
|
|
98
|
+
*/
|
|
99
|
+
marksCohubTurns: boolean;
|
|
94
100
|
/** Plain JSONL bytes for archival when the file itself is stored differently. */
|
|
95
101
|
archiveSource?(path: string, cacheDirectory: string): Promise<string>;
|
|
96
102
|
};
|
|
@@ -102,7 +108,6 @@ export type LocalHarnessAdapter = {
|
|
|
102
108
|
models(executable: string, cwd: string): Promise<HarnessModel[]>;
|
|
103
109
|
run(executor: Executor, turn: HarnessTurn): Promise<HarnessRun>;
|
|
104
110
|
session: NativeSessionFormat;
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
nativeSync: boolean;
|
|
111
|
+
/** Every harness reads its own transcripts, so terminal conversations sync to the Space. */
|
|
112
|
+
transcript: NativeTranscriptFormat;
|
|
108
113
|
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type NativeTranscript } from "../native/transcript.js";
|
|
2
|
+
export declare const WORKBUDDY_REPLY_ENDED: string[];
|
|
3
|
+
/**
|
|
4
|
+
* CodeBuddy stores Responses-style items linked by `parentId`: a user message starts a Turn, and each
|
|
5
|
+
* model step (`providerData.messageId`) is one message. Interactive sessions record each end
|
|
6
|
+
* (`turn-metrics`, outside the chain); `-p` sessions do not.
|
|
7
|
+
*/
|
|
8
|
+
export declare function readWorkbuddyTranscript(path: string, nativeSessionId: string, signal?: AbortSignal): Promise<NativeTranscript>;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { record } from "../json-rpc.js";
|
|
2
|
+
import { iso } from "../native/transcript.js";
|
|
3
|
+
import { chainBranch, extendTurn, readChainedLines, startTurn } from "./chained-transcript.js";
|
|
4
|
+
import { array, identifiable, text } from "./process.js";
|
|
5
|
+
import { streamUsage } from "./stream-json.js";
|
|
6
|
+
export const WORKBUDDY_REPLY_ENDED = ["stop", "aborted", "error"];
|
|
7
|
+
const INTERRUPTED = "Interrupted by user";
|
|
8
|
+
function items(value) {
|
|
9
|
+
return array(value).flatMap((entry) => {
|
|
10
|
+
const block = record(entry);
|
|
11
|
+
if (["input_text", "output_text", "text"].includes(text(block.type)))
|
|
12
|
+
return text(block.text) ? [{ type: "text", text: text(block.text) }] : [];
|
|
13
|
+
if (block.type === "reasoning_text")
|
|
14
|
+
return text(block.text) ? [{ type: "thinking", thinking: text(block.text) }] : [];
|
|
15
|
+
const image = text(block.image) || text(block.image_url);
|
|
16
|
+
if (block.type === "input_image" && image) {
|
|
17
|
+
const data = /^data:([^;]+);base64,(.*)$/s.exec(image);
|
|
18
|
+
return [{ type: "image", source: data ? { type: "base64", media_type: data[1] ?? "image/png", data: data[2] ?? "" } : { type: "url", url: image } }];
|
|
19
|
+
}
|
|
20
|
+
return [];
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
function toolOutput(value) {
|
|
24
|
+
if (typeof value === "string")
|
|
25
|
+
return value;
|
|
26
|
+
const output = record(value);
|
|
27
|
+
if (output.type === "text")
|
|
28
|
+
return text(output.text);
|
|
29
|
+
return Array.isArray(value) ? items(value) : JSON.stringify(value ?? null);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* CodeBuddy stores Responses-style items linked by `parentId`: a user message starts a Turn, and each
|
|
33
|
+
* model step (`providerData.messageId`) is one message. Interactive sessions record each end
|
|
34
|
+
* (`turn-metrics`, outside the chain); `-p` sessions do not.
|
|
35
|
+
*/
|
|
36
|
+
export async function readWorkbuddyTranscript(path, nativeSessionId, signal) {
|
|
37
|
+
const read = await readChainedLines(path, signal);
|
|
38
|
+
const linked = (value) => value.type === "message" || "parentId" in value;
|
|
39
|
+
const branch = chainBranch(read.lines, (value) => linked(value) ? text(value.id) : "", (value) => text(value.parentId));
|
|
40
|
+
const metricsAt = read.lines.reduce((latest, line) => line.value.type === "turn-metrics" ? line.startBytes : latest, -1);
|
|
41
|
+
const turns = [];
|
|
42
|
+
const owners = new Map();
|
|
43
|
+
const steps = new Map();
|
|
44
|
+
let cwd = "", cloudSessionId = "", completedAt = "";
|
|
45
|
+
let current = null;
|
|
46
|
+
const finish = () => {
|
|
47
|
+
if (!current)
|
|
48
|
+
return;
|
|
49
|
+
const last = current.messages.at(-1);
|
|
50
|
+
const status = !last || last.stopReason === "aborted" || last.stopReason === "tool_use" ? "interrupted" : last.stopReason === "error" ? "failed" : "completed";
|
|
51
|
+
current.result = { messages: current.messages, completedAt: completedAt || current.startedAt, status };
|
|
52
|
+
turns.push(current);
|
|
53
|
+
current = null;
|
|
54
|
+
};
|
|
55
|
+
for (const line of branch) {
|
|
56
|
+
const value = line.value;
|
|
57
|
+
cwd ||= text(value.cwd);
|
|
58
|
+
if (value.type === "message" && value.role === "user") {
|
|
59
|
+
finish();
|
|
60
|
+
const cohub = record(value.cohub);
|
|
61
|
+
cloudSessionId ||= text(cohub.sessionId);
|
|
62
|
+
current = startTurn(line, text(value.id), turns.at(-1)?.key ?? null, items(value.content), text(cohub.turnId));
|
|
63
|
+
completedAt = current.startedAt;
|
|
64
|
+
owners.clear();
|
|
65
|
+
steps.clear();
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (!current)
|
|
69
|
+
continue;
|
|
70
|
+
const provider = record(value.providerData);
|
|
71
|
+
const step = text(provider.messageId);
|
|
72
|
+
const message = () => {
|
|
73
|
+
let target = step ? steps.get(step) : undefined;
|
|
74
|
+
if (!target) {
|
|
75
|
+
target = { content: [], provider: null, model: text(provider.model) || null, stopReason: null, errorMessage: null };
|
|
76
|
+
current?.messages.push(target);
|
|
77
|
+
if (step)
|
|
78
|
+
steps.set(step, target);
|
|
79
|
+
}
|
|
80
|
+
return target;
|
|
81
|
+
};
|
|
82
|
+
if (value.type === "message" && value.role === "assistant") {
|
|
83
|
+
const target = message();
|
|
84
|
+
target.content.push(...items(value.content));
|
|
85
|
+
const usage = streamUsage(record(value.message).usage, undefined);
|
|
86
|
+
if (usage)
|
|
87
|
+
target.usage = usage;
|
|
88
|
+
const error = text(record(provider.error).message);
|
|
89
|
+
target.stopReason = value.status === "incomplete" ? error === INTERRUPTED ? "aborted" : "error" : "stop";
|
|
90
|
+
if (target.stopReason === "error")
|
|
91
|
+
target.errorMessage = error || "Turn failed";
|
|
92
|
+
if (target.stopReason === "aborted")
|
|
93
|
+
current.ended = true;
|
|
94
|
+
}
|
|
95
|
+
else if (value.type === "reasoning") {
|
|
96
|
+
message().content.push(...items(value.rawContent), ...items(value.content).filter((block) => block.type === "thinking"));
|
|
97
|
+
}
|
|
98
|
+
else if (value.type === "function_call" && identifiable(value.callId) && identifiable(value.name)) {
|
|
99
|
+
let input;
|
|
100
|
+
try {
|
|
101
|
+
input = record(JSON.parse(text(value.arguments)));
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
input = { raw: value.arguments };
|
|
105
|
+
}
|
|
106
|
+
const target = message();
|
|
107
|
+
target.content.push({ type: "tool_use", id: value.callId, name: value.name, input });
|
|
108
|
+
target.stopReason = "tool_use";
|
|
109
|
+
const usage = streamUsage(record(value.message).usage, undefined);
|
|
110
|
+
if (usage)
|
|
111
|
+
target.usage = usage;
|
|
112
|
+
owners.set(value.callId, target);
|
|
113
|
+
}
|
|
114
|
+
else if (value.type === "function_call_result") {
|
|
115
|
+
const id = text(value.callId);
|
|
116
|
+
owners.get(id)?.content.push({ type: "tool_result", tool_use_id: id, content: toolOutput(value.output) });
|
|
117
|
+
}
|
|
118
|
+
if (typeof value.timestamp === "number" || typeof value.timestamp === "string")
|
|
119
|
+
completedAt = iso(value.timestamp);
|
|
120
|
+
extendTurn(current, line, true);
|
|
121
|
+
}
|
|
122
|
+
const tail = current;
|
|
123
|
+
if (tail && metricsAt >= tail.contentEndBytes)
|
|
124
|
+
tail.ended = true;
|
|
125
|
+
finish();
|
|
126
|
+
return { nativeSessionId, cwd, ...(cloudSessionId ? { cloudSessionId } : {}), turns, prefixes: read.prefixes };
|
|
127
|
+
}
|
|
@@ -3,7 +3,7 @@ import type { LocalHarnessAdapter } from "./types.js";
|
|
|
3
3
|
export declare function workbuddyProjectName(cwd: string): string;
|
|
4
4
|
/** The CLI lists selectable models (built-in and `models.json`) in its `--model` help. */
|
|
5
5
|
export declare function workbuddyModels(help: string): {
|
|
6
|
-
harness: "claude" | "codex" | "pi" | "workbuddy";
|
|
6
|
+
harness: "claude" | "codex" | "cursor" | "pi" | "workbuddy";
|
|
7
7
|
provider: string;
|
|
8
8
|
id: string;
|
|
9
9
|
name: string;
|
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
|
-
import { join } from "node:path";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
3
|
import { copyNativeArchive } from "../native/archive.js";
|
|
4
4
|
import { projectChainedSession, projectionMeta, projectionToolId, rebindSessionId, toolResultText } from "./chained-projection.js";
|
|
5
|
+
import { chainedSessionHeader, projectDirectory as projectFolder, projectTranscript } from "./chained-transcript.js";
|
|
6
|
+
import { readWorkbuddyTranscript, WORKBUDDY_REPLY_ENDED } from "./workbuddy-transcript.js";
|
|
5
7
|
import { DEFAULT_MODEL, runStreamJson, streamJsonHelp, streamJsonModel } from "./stream-json.js";
|
|
6
8
|
/** WorkBuddy's CLI is CodeBuddy Code; both share its data root. */
|
|
7
9
|
const dataRoot = () => process.env.CODEBUDDY_CONFIG_DIR?.trim() || join(homedir(), ".codebuddy");
|
|
8
10
|
const MAX_NAME_BYTES = 255;
|
|
9
11
|
const TRUNCATED_NAME_BYTES = 180;
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const base = cwd.replace(/[/\\:]/g, "-").replace(/^-+/, "").replace(/-+$/, "").replace(/-+/g, "-");
|
|
13
|
-
if (Buffer.byteLength(base, "utf8") <= MAX_NAME_BYTES)
|
|
14
|
-
return base;
|
|
12
|
+
const projectBase = (cwd) => cwd.replace(/[/\\:]/g, "-").replace(/^-+/, "").replace(/-+$/, "").replace(/-+/g, "-");
|
|
13
|
+
function truncated(base) {
|
|
15
14
|
let prefix = "", bytes = 0;
|
|
16
15
|
for (const character of base) {
|
|
17
16
|
const size = Buffer.byteLength(character, "utf8");
|
|
@@ -20,12 +19,23 @@ export function workbuddyProjectName(cwd) {
|
|
|
20
19
|
bytes += size;
|
|
21
20
|
prefix += character;
|
|
22
21
|
}
|
|
22
|
+
return prefix;
|
|
23
|
+
}
|
|
24
|
+
/** Mirrors CodeBuddy's project naming, including its truncation hash for long paths. */
|
|
25
|
+
export function workbuddyProjectName(cwd) {
|
|
26
|
+
const base = projectBase(cwd);
|
|
27
|
+
if (Buffer.byteLength(base, "utf8") <= MAX_NAME_BYTES)
|
|
28
|
+
return base;
|
|
29
|
+
const prefix = truncated(base);
|
|
23
30
|
let hash = 5381;
|
|
24
31
|
for (const byte of Buffer.from(base, "utf8"))
|
|
25
32
|
hash = 33 * hash ^ byte;
|
|
26
33
|
return `${prefix}-${(hash >>> 0).toString(36)}`;
|
|
27
34
|
}
|
|
28
35
|
const projectDirectory = (cwd) => join(dataRoot(), "projects", workbuddyProjectName(cwd));
|
|
36
|
+
const projectsRoot = () => join(dataRoot(), "projects");
|
|
37
|
+
/** Every subdirectory's project name starts with this, hashed long names included. */
|
|
38
|
+
const projectPrefix = (cwd) => truncated(projectBase(cwd));
|
|
29
39
|
const profile = {
|
|
30
40
|
harness: "workbuddy",
|
|
31
41
|
label: "WorkBuddy",
|
|
@@ -34,6 +44,7 @@ const profile = {
|
|
|
34
44
|
dataRoot,
|
|
35
45
|
projectDirectory,
|
|
36
46
|
effortLevels: ["minimal", "low", "medium", "high", "xhigh", "max"],
|
|
47
|
+
promptUuid: false,
|
|
37
48
|
};
|
|
38
49
|
/** The CLI lists selectable models (built-in and `models.json`) in its `--model` help. */
|
|
39
50
|
export function workbuddyModels(help) {
|
|
@@ -95,5 +106,18 @@ export const workbuddyHarness = {
|
|
|
95
106
|
},
|
|
96
107
|
},
|
|
97
108
|
},
|
|
98
|
-
|
|
109
|
+
transcript: {
|
|
110
|
+
roots: () => [projectsRoot()],
|
|
111
|
+
acceptsDirectory: (path, root, cwd) => projectFolder(path, root, projectPrefix(cwd)),
|
|
112
|
+
acceptsTranscript: (path, cwd) => projectTranscript(path, projectsRoot(), projectPrefix(cwd)),
|
|
113
|
+
header: (path, signal) => chainedSessionHeader(path, "workbuddy", signal),
|
|
114
|
+
read: (path, signal) => readWorkbuddyTranscript(path, basename(path, ".jsonl"), signal),
|
|
115
|
+
boundaries: ['"role":"user"', '"role":"assistant"', '"type":"turn-metrics"'],
|
|
116
|
+
turnEndRecorded: false,
|
|
117
|
+
replyEnded: (turn) => WORKBUDDY_REPLY_ENDED.includes(turn.messages.at(-1)?.stopReason ?? ""),
|
|
118
|
+
userFinalMarked: false,
|
|
119
|
+
globalTurnKeys: false,
|
|
120
|
+
// CodeBuddy keeps no field of a stream-json prompt, so Cohub's Turns are tracked in the ledger.
|
|
121
|
+
marksCohubTurns: false,
|
|
122
|
+
},
|
|
99
123
|
};
|
|
@@ -41,6 +41,8 @@ export declare class JsonRpcProcess {
|
|
|
41
41
|
request(method: string, params?: JsonRecord, timeoutMs?: number): Promise<JsonRecord>;
|
|
42
42
|
/** Signal end of input; a stream-mode CLI flushes its transcript and exits. */
|
|
43
43
|
endInput(): void;
|
|
44
|
+
/** Ask a CLI that stops on SIGINT to end its current Turn. Windows has no SIGINT, so the process tree is stopped. */
|
|
45
|
+
interrupt(): void;
|
|
44
46
|
/** Whether the process exits within the timeout. */
|
|
45
47
|
exited(timeoutMs: number): Promise<boolean>;
|
|
46
48
|
onEvent(listener: (event: JsonRecord) => void): () => boolean;
|
package/dist/runtime/json-rpc.js
CHANGED
|
@@ -157,6 +157,15 @@ export class JsonRpcProcess {
|
|
|
157
157
|
this.inputEnded = true;
|
|
158
158
|
this.child.stdin.end();
|
|
159
159
|
}
|
|
160
|
+
/** Ask a CLI that stops on SIGINT to end its current Turn. Windows has no SIGINT, so the process tree is stopped. */
|
|
161
|
+
interrupt() {
|
|
162
|
+
if (this.failure)
|
|
163
|
+
return;
|
|
164
|
+
if (process.platform === "win32")
|
|
165
|
+
void this.close().catch(() => undefined);
|
|
166
|
+
else
|
|
167
|
+
this.child.kill("SIGINT");
|
|
168
|
+
}
|
|
160
169
|
/** Whether the process exits within the timeout. */
|
|
161
170
|
async exited(timeoutMs) {
|
|
162
171
|
let timer;
|
|
@@ -27,7 +27,7 @@ export declare function nativeSyncSatisfied(config: NativeConfig | null, input:
|
|
|
27
27
|
* Enable native sync after consent. Native history may contain secrets, so nothing is read before
|
|
28
28
|
* the user agrees; a failure here never blocks the Runtime, it only leaves sessions read-only.
|
|
29
29
|
*/
|
|
30
|
-
export declare function ensureNativeSync(
|
|
30
|
+
export declare function ensureNativeSync(input: Input, log?: (line: string) => void, ask?: Ask): Promise<NativeSyncReport>;
|
|
31
31
|
/**
|
|
32
32
|
* Before any harness starts, repair what earlier releases installed: an outdated Pi extension can keep
|
|
33
33
|
* Pi from starting, and legacy Codex hooks run a script that is gone. Both had consent back then.
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { createInterface } from "node:readline/promises";
|
|
2
2
|
import { LOCAL_HARNESS_SPECS } from "@neta-art/cohub";
|
|
3
3
|
import { serializeDiagnosticError } from "../diagnostics.js";
|
|
4
|
-
import { nativeSyncHarnesses } from "../harnesses/index.js";
|
|
5
4
|
import { CODEX_SHARED_MIN_VERSION, sharedServerAvailable, supportsSharedServer } from "./codex-link.js";
|
|
6
5
|
import { PI_MIN_VERSION } from "./pi-link.js";
|
|
7
6
|
import { atLeast, formatVersion, harnessVersion } from "./version.js";
|
|
@@ -25,9 +24,7 @@ async function defaultAsk(question) {
|
|
|
25
24
|
* Enable native sync after consent. Native history may contain secrets, so nothing is read before
|
|
26
25
|
* the user agrees; a failure here never blocks the Runtime, it only leaves sessions read-only.
|
|
27
26
|
*/
|
|
28
|
-
export async function ensureNativeSync(
|
|
29
|
-
// A harness without a native integration runs Cohub Turns only.
|
|
30
|
-
const input = { ...requested, harnesses: nativeSyncHarnesses(requested.harnesses) };
|
|
27
|
+
export async function ensureNativeSync(input, log = (line) => process.stderr.write(line), ask = defaultAsk) {
|
|
31
28
|
if (!input.harnesses.length)
|
|
32
29
|
return { enabled: false, harnesses: [], codexShared: false, importHistory: false, note: "unsupported" };
|
|
33
30
|
const stateRoot = runtimeStateRoot(input.spaceId);
|
|
@@ -7,13 +7,24 @@ export async function executeTurn(executor, input, emit, signal, requestId, cont
|
|
|
7
7
|
signal.throwIfAborted();
|
|
8
8
|
assertHarnessAccess(input.harness, input.accessMode);
|
|
9
9
|
const diverged = await executor.results.admit(input);
|
|
10
|
-
const
|
|
11
|
-
|
|
10
|
+
const started = async (session) => {
|
|
11
|
+
await executor.results.start(session, input, requestId);
|
|
12
|
+
executor.ingest.begin(session, input.sessionId, input.turnId);
|
|
13
|
+
};
|
|
14
|
+
const turn = { input, emit, signal, diverged, started, context };
|
|
15
|
+
let run;
|
|
16
|
+
try {
|
|
17
|
+
run = await harnessAdapter(input.harness).run(executor, turn);
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
executor.ingest.end(input.turnId);
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
12
23
|
let uncertainCleanup;
|
|
13
24
|
if (run.host)
|
|
14
25
|
uncertainCleanup = await closeHost(run.host, run.message.errorMessage);
|
|
15
26
|
const message = signal.aborted ? { ...run.message, stopReason: "aborted" } : run.message;
|
|
16
|
-
const archive = await executor.archives.stage({ sessionId: input.sessionId, harness: input.harness, nativeSessionId: run.session.nativeSessionId, path: run.session.path }, input.turnId).catch((error) => {
|
|
27
|
+
const archive = !harnessAdapter(input.harness).session.archive ? null : await executor.archives.stage({ sessionId: input.sessionId, harness: input.harness, nativeSessionId: run.session.nativeSessionId, path: run.session.path }, input.turnId).catch((error) => {
|
|
17
28
|
// A missing archive only costs cross-machine native resume; the file itself is intact.
|
|
18
29
|
executor.diagnostics?.log("warn", "archive.capture_failed", { error: serializeDiagnosticError(error) }, { ...context, component: "archive" });
|
|
19
30
|
return null;
|
|
@@ -21,6 +21,8 @@ export type TranscriptState = {
|
|
|
21
21
|
headTurnId: string | null;
|
|
22
22
|
origin: "cohub" | "native";
|
|
23
23
|
controllable?: boolean;
|
|
24
|
+
/** The Turn Cohub is running in this file, for formats that cannot mark it; kept across a crash. */
|
|
25
|
+
executingTurnId?: string | null;
|
|
24
26
|
};
|
|
25
27
|
export type ImportFilter = {
|
|
26
28
|
harnesses?: Harness[];
|
|
@@ -118,6 +120,8 @@ export declare class NativeIngest {
|
|
|
118
120
|
private readonly failures;
|
|
119
121
|
private readonly aliveAt;
|
|
120
122
|
private readonly runningPaths;
|
|
123
|
+
/** Files a live Cohub execution is writing in a format that cannot mark its Turn. */
|
|
124
|
+
private readonly executing;
|
|
121
125
|
private writing;
|
|
122
126
|
private readonly foreign;
|
|
123
127
|
private importQueue;
|
|
@@ -152,6 +156,7 @@ export declare class NativeIngest {
|
|
|
152
156
|
connect(transport: IngestTransport | null): void;
|
|
153
157
|
/**
|
|
154
158
|
* Record that Cohub created or chose this transcript for a Session, before any Turn of it runs.
|
|
159
|
+
* `through` says the server has the file's Turns up to `turnKey` (none when null) and `turnId` heads them.
|
|
155
160
|
*/
|
|
156
161
|
adopt(session: {
|
|
157
162
|
harness: Harness;
|
|
@@ -162,6 +167,15 @@ export declare class NativeIngest {
|
|
|
162
167
|
turnKey: string | null;
|
|
163
168
|
turnId: string | null;
|
|
164
169
|
}): void;
|
|
170
|
+
/** A Cohub Turn starts writing this file; a format that cannot mark it is not read until it ends. */
|
|
171
|
+
begin(session: {
|
|
172
|
+
harness: Harness;
|
|
173
|
+
path: string;
|
|
174
|
+
nativeSessionId: string;
|
|
175
|
+
cwd: string;
|
|
176
|
+
}, sessionId: string, turnId: string): void;
|
|
177
|
+
/** A Cohub Turn ended, whether or not it produced a result. */
|
|
178
|
+
end(turnId: string): void;
|
|
165
179
|
advance(path: string, turnId: string): void;
|
|
166
180
|
touch(path: string): void;
|
|
167
181
|
running(harness: Harness, nativeSessionId: string): TranscriptState | null;
|
|
@@ -3,7 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import { NATIVE_INGEST_MAX_TURNS, SETTLED_TURN_STATUSES } from "@neta-art/cohub";
|
|
4
4
|
import { acceptsDirectory, acceptsTranscript, readSessionHeader, readTranscript, transcriptRoots } from "./adapters.js";
|
|
5
5
|
import { nativeTurnId } from "./identity.js";
|
|
6
|
-
import { transcriptFormat } from "../harnesses/index.js";
|
|
6
|
+
import { harnessAdapter, transcriptFormat } from "../harnesses/index.js";
|
|
7
7
|
import { containsNativePath } from "./paths.js";
|
|
8
8
|
const missing = (error) => error?.code === "ENOENT";
|
|
9
9
|
/** Transcripts read in parallel per lane. The live lane never waits behind a backfill. */
|
|
@@ -107,6 +107,8 @@ export class NativeIngest {
|
|
|
107
107
|
failures = new Map();
|
|
108
108
|
aliveAt = new Map();
|
|
109
109
|
runningPaths = new Set();
|
|
110
|
+
/** Files a live Cohub execution is writing in a format that cannot mark its Turn. */
|
|
111
|
+
executing = new Set();
|
|
110
112
|
writing = Promise.resolve();
|
|
111
113
|
foreign = new Set();
|
|
112
114
|
importQueue = [];
|
|
@@ -194,18 +196,49 @@ export class NativeIngest {
|
|
|
194
196
|
}
|
|
195
197
|
/**
|
|
196
198
|
* Record that Cohub created or chose this transcript for a Session, before any Turn of it runs.
|
|
199
|
+
* `through` says the server has the file's Turns up to `turnKey` (none when null) and `turnId` heads them.
|
|
197
200
|
*/
|
|
198
201
|
adopt(session, sessionId, through) {
|
|
199
202
|
const state = this.states.get(session.path) ?? this.blank(session.path, session.nativeSessionId, session.cwd, session.harness, "user", { size: -1, mtimeMs: 0, ino: 0 });
|
|
200
203
|
Object.assign(state, { sessionId, nativeSessionId: session.nativeSessionId });
|
|
201
|
-
if (through
|
|
204
|
+
if (through)
|
|
202
205
|
Object.assign(state, { throughTurnKey: through.turnKey, throughTurnId: through.turnId, headTurnId: through.turnId });
|
|
203
206
|
if (!this.states.has(session.path))
|
|
204
207
|
state.origin = "cohub";
|
|
208
|
+
// The harness wrote elsewhere than expected; the execution follows the file.
|
|
209
|
+
for (const other of this.states.values()) {
|
|
210
|
+
if (other === state || other.harness !== session.harness || other.nativeSessionId !== session.nativeSessionId || !other.executingTurnId)
|
|
211
|
+
continue;
|
|
212
|
+
state.executingTurnId = other.executingTurnId;
|
|
213
|
+
other.executingTurnId = null;
|
|
214
|
+
if (this.executing.delete(other.path))
|
|
215
|
+
this.executing.add(session.path);
|
|
216
|
+
this.markDirty(other.path);
|
|
217
|
+
}
|
|
205
218
|
this.states.set(session.path, state);
|
|
206
219
|
this.markDirty(session.path);
|
|
207
220
|
}
|
|
221
|
+
/** A Cohub Turn starts writing this file; a format that cannot mark it is not read until it ends. */
|
|
222
|
+
begin(session, sessionId, turnId) {
|
|
223
|
+
if (transcriptFormat(session.harness).marksCohubTurns)
|
|
224
|
+
return;
|
|
225
|
+
this.adopt(session, sessionId);
|
|
226
|
+
const state = this.states.get(session.path);
|
|
227
|
+
state.executingTurnId = turnId;
|
|
228
|
+
this.executing.add(session.path);
|
|
229
|
+
this.markDirty(session.path);
|
|
230
|
+
}
|
|
231
|
+
/** A Cohub Turn ended, whether or not it produced a result. */
|
|
232
|
+
end(turnId) {
|
|
233
|
+
for (const state of this.states.values()) {
|
|
234
|
+
if (state.executingTurnId !== turnId)
|
|
235
|
+
continue;
|
|
236
|
+
this.executing.delete(state.path);
|
|
237
|
+
this.touch(state.path);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
208
240
|
advance(path, turnId) {
|
|
241
|
+
this.end(turnId);
|
|
209
242
|
const state = this.states.get(path);
|
|
210
243
|
if (!state)
|
|
211
244
|
return;
|
|
@@ -469,7 +502,7 @@ export class NativeIngest {
|
|
|
469
502
|
}
|
|
470
503
|
}
|
|
471
504
|
async read(path, lane, force) {
|
|
472
|
-
if (this.foreign.has(path))
|
|
505
|
+
if (this.foreign.has(path) || this.executing.has(path))
|
|
473
506
|
return;
|
|
474
507
|
const info = await stat(path).catch((error) => { if (missing(error))
|
|
475
508
|
return null; throw error; });
|
|
@@ -565,6 +598,8 @@ export class NativeIngest {
|
|
|
565
598
|
const certain = this.options.hooks?.settled?.(state, mtimeMs);
|
|
566
599
|
if (certain != null)
|
|
567
600
|
return certain;
|
|
601
|
+
if (tail.ended)
|
|
602
|
+
return true;
|
|
568
603
|
if (!format.replyEnded?.(tail))
|
|
569
604
|
return false;
|
|
570
605
|
const idleMs = Date.now() - mtimeMs;
|
|
@@ -598,9 +633,20 @@ export class NativeIngest {
|
|
|
598
633
|
const known = pending.length ? await this.known(transport, pending.flatMap((turn) => turn.cloudTurnId ? [turn.cloudTurnId, derived(turn)] : [derived(turn)])) : new Map();
|
|
599
634
|
const cohub = (turn) => turn.cloudTurnId && known.has(turn.cloudTurnId) ? turn.cloudTurnId : null;
|
|
600
635
|
const tail = turns.at(-1);
|
|
636
|
+
// A Cohub file whose latest Cohub Turn the file cannot name (its format marks none, or it predates
|
|
637
|
+
// the marker) holds only Turns the server already has, up to its current end.
|
|
638
|
+
const cohubHead = state.executingTurnId ?? state.headTurnId;
|
|
639
|
+
const unmarked = !transcriptFormat(state.harness).marksCohubTurns || state.size < 0;
|
|
640
|
+
if (state.origin === "cohub" && cohubHead && unmarked && state.throughTurnId !== cohubHead && !turns.some((turn) => turn.cloudTurnId === cohubHead)) {
|
|
641
|
+
const through = tail ? cohub(tail) ?? cohubHead : cohubHead;
|
|
642
|
+
Object.assign(state, { throughTurnKey: tail?.key ?? state.throughTurnKey, throughTurnId: through, headTurnId: through, executingTurnId: null });
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
state.executingTurnId = null;
|
|
601
646
|
const head = !tail ? null : pending.length ? cohub(tail) ?? derived(tail) : state.throughTurnId;
|
|
602
647
|
const candidates = [];
|
|
603
|
-
|
|
648
|
+
// A head adopted without a transcript key (Cohub's Turn left no record) still parents what follows.
|
|
649
|
+
let parentTurnId = from > 0 || !state.throughTurnKey ? state.throughTurnId : null;
|
|
604
650
|
for (const [offset, turn] of pending.entries()) {
|
|
605
651
|
const marked = cohub(turn);
|
|
606
652
|
if (marked) {
|
|
@@ -726,7 +772,7 @@ export class NativeIngest {
|
|
|
726
772
|
return Date.parse(turn.startedAt) < Date.parse(this.enabledAt);
|
|
727
773
|
}
|
|
728
774
|
async archive(state, turn, turnId) {
|
|
729
|
-
if (!state.sessionId)
|
|
775
|
+
if (!state.sessionId || !harnessAdapter(state.harness).session.archive)
|
|
730
776
|
return;
|
|
731
777
|
try {
|
|
732
778
|
const source = turn.path ?? state.path;
|