@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.
Files changed (37) hide show
  1. package/README.md +8 -5
  2. package/dist/board-kernel.js +207 -119
  3. package/dist/commands/boards/examples.js +16 -16
  4. package/dist/commands/boards.js +1 -1
  5. package/dist/commands/generations.js +23 -2
  6. package/dist/commands/runtime.js +3 -14
  7. package/dist/runtime/harnesses/chained-transcript.d.ts +24 -0
  8. package/dist/runtime/harnesses/chained-transcript.js +62 -0
  9. package/dist/runtime/harnesses/claude-transcript.d.ts +8 -0
  10. package/dist/runtime/harnesses/claude-transcript.js +138 -0
  11. package/dist/runtime/harnesses/claude.js +79 -2
  12. package/dist/runtime/harnesses/codex.js +1 -1
  13. package/dist/runtime/harnesses/cursor-transcript.d.ts +37 -0
  14. package/dist/runtime/harnesses/cursor-transcript.js +183 -0
  15. package/dist/runtime/harnesses/cursor.d.ts +24 -0
  16. package/dist/runtime/harnesses/cursor.js +620 -0
  17. package/dist/runtime/harnesses/index.d.ts +1 -3
  18. package/dist/runtime/harnesses/index.js +4 -10
  19. package/dist/runtime/harnesses/pi.js +1 -1
  20. package/dist/runtime/harnesses/stream-json.d.ts +7 -1
  21. package/dist/runtime/harnesses/stream-json.js +35 -12
  22. package/dist/runtime/harnesses/types.d.ts +10 -5
  23. package/dist/runtime/harnesses/workbuddy-transcript.d.ts +8 -0
  24. package/dist/runtime/harnesses/workbuddy-transcript.js +127 -0
  25. package/dist/runtime/harnesses/workbuddy.d.ts +1 -1
  26. package/dist/runtime/harnesses/workbuddy.js +31 -7
  27. package/dist/runtime/json-rpc.d.ts +2 -0
  28. package/dist/runtime/json-rpc.js +9 -0
  29. package/dist/runtime/native/attach.d.ts +1 -1
  30. package/dist/runtime/native/attach.js +1 -4
  31. package/dist/runtime/native/execution.js +14 -3
  32. package/dist/runtime/native/ingest.d.ts +14 -0
  33. package/dist/runtime/native/ingest.js +51 -5
  34. package/dist/runtime/native/sessions.js +6 -1
  35. package/dist/runtime/native/transcript.d.ts +19 -0
  36. package/dist/runtime/native/transcript.js +33 -2
  37. package/package.json +3 -3
@@ -242,6 +242,6 @@ export const piHarness = {
242
242
  replyEnded: (turn) => PI_ENDED.includes(turn.messages.at(-1)?.stopReason ?? ""),
243
243
  userFinalMarked: false,
244
244
  globalTurnKeys: false,
245
+ marksCohubTurns: true,
245
246
  },
246
- nativeSync: true,
247
247
  };
@@ -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, profile.effortLevels.includes(level) ? level : null])) } : {}),
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 prepared = await executor.sessions.prepare({
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
- archive: {
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. Required for native sync and receipt-less recovery. */
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
- transcript?: NativeTranscriptFormat;
106
- /** Terminal conversations can be synced to the Space (requires `transcript`). */
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
- /** 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;
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
- nativeSync: false,
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;
@@ -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(requested: Input, log?: (line: string) => void, ask?: Ask): Promise<NativeSyncReport>;
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(requested, log = (line) => process.stderr.write(line), ask = defaultAsk) {
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 turn = { input, emit, signal, diverged, started: (session) => executor.results.start(session, input, requestId), context };
11
- const run = await harnessAdapter(input.harness).run(executor, turn);
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?.turnKey)
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
- let parentTurnId = from > 0 ? state.throughTurnId : null;
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;