@neta-art/cohub-cli 10.7.0 → 10.8.1

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 +4 -4
  2. package/dist/board-kernel.js +276 -214
  3. package/dist/commands/boards/examples.js +16 -16
  4. package/dist/commands/boards.js +1 -1
  5. package/dist/commands/runtime.js +3 -14
  6. package/dist/runtime/connection.js +161 -54
  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 +20 -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 +5 -1
  21. package/dist/runtime/harnesses/stream-json.js +31 -9
  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 +24 -2
  33. package/dist/runtime/native/ingest.js +76 -10
  34. package/dist/runtime/native/sessions.js +6 -1
  35. package/dist/runtime/native/transcript.d.ts +23 -0
  36. package/dist/runtime/native/transcript.js +40 -5
  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,6 +14,8 @@ 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>;
@@ -23,6 +25,8 @@ export declare function streamJsonModel(profile: StreamJsonProfile, id: string,
23
25
  provider?: string;
24
26
  effortLevels?: readonly string[];
25
27
  }): HarnessModel;
28
+ export declare function streamContent(value: unknown): ContentBlock[];
29
+ export declare function streamUsage(value: unknown, cost: unknown): RuntimeMessage["usage"];
26
30
  /**
27
31
  * One Turn per process. Unattended execution never grants an approval: workspace edits follow
28
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";
@@ -58,7 +59,7 @@ async function findSession(profile, nativeSessionId, cwd) {
58
59
  }
59
60
  return null;
60
61
  }
61
- function streamContent(value) {
62
+ export function streamContent(value) {
62
63
  return array(value).flatMap((entry) => {
63
64
  const block = record(entry);
64
65
  if (block.type === "text")
@@ -73,7 +74,7 @@ function streamContent(value) {
73
74
  return [];
74
75
  });
75
76
  }
76
- function streamUsage(value, cost) {
77
+ export function streamUsage(value, cost) {
77
78
  const usage = record(value);
78
79
  const count = (key) => typeof usage[key] === "number" ? usage[key] : 0;
79
80
  const input = count("input_tokens"), output = count("output_tokens"), cacheRead = count("cache_read_input_tokens"), cacheWrite = count("cache_creation_input_tokens");
@@ -81,14 +82,32 @@ function streamUsage(value, cost) {
81
82
  return undefined;
82
83
  return { input, output, cacheRead, cacheWrite, totalTokens: input + output + cacheRead + cacheWrite, ...(typeof cost === "number" && cost > 0 ? { cost: { total: cost } } : {}) };
83
84
  }
84
- async function prompt(input) {
85
+ async function prompt(profile, input) {
85
86
  const content = await Promise.all(input.messages.flatMap((message) => message.content).map(async (block) => {
86
87
  if (block.type !== "image")
87
88
  return { type: "text", text: block.type === "text" ? block.text : JSON.stringify(block) };
88
89
  const image = await inlineImage(block);
89
90
  return { type: "image", source: { type: "base64", media_type: image.mimeType, data: image.data } };
90
91
  }));
91
- 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;
92
111
  }
93
112
  /** Maps stream-json output onto Cohub's streaming protocol; sub-agent traffic stays in the native transcript. */
94
113
  function streamJsonTranslator(emit, identity) {
@@ -190,12 +209,15 @@ function streamJsonTranslator(emit, identity) {
190
209
  */
191
210
  export async function runStreamJson(profile, executor, turn) {
192
211
  const { input, signal, context } = turn;
193
- const prepared = await executor.sessions.prepare({
212
+ const prepare = (resumable) => executor.sessions.prepare({
194
213
  sessionId: input.sessionId, turnId: input.turnId, harness: profile.harness, cwd: executor.cwd,
195
- provider: input.provider, context: input.context, signal,
196
- // Only a file Cohub wrote and advanced is continued; nothing else writes to it.
197
- resumable: (transcript) => transcript.path !== turn.diverged && transcript.origin === "cohub",
214
+ provider: input.provider, context: input.context, signal, resumable,
198
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);
199
221
  let session = prepared.session;
200
222
  const effort = input.thinkingLevel && profile.effortLevels.includes(input.thinkingLevel) ? input.thinkingLevel : null;
201
223
  const args = [
@@ -205,7 +227,7 @@ export async function runStreamJson(profile, executor, turn) {
205
227
  ...(input.model && input.model !== DEFAULT_MODEL ? ["--model", input.model] : []),
206
228
  ...(effort ? ["--effort", effort] : []),
207
229
  ];
208
- const content = await prompt(input);
230
+ const content = await prompt(profile, input);
209
231
  signal.throwIfAborted();
210
232
  const host = new JsonRpcProcess(...harnessCommand(executor.executables[profile.harness] || profile.binary, args, executor.cwd), executor.cwd, "stream", turnEnvironment(input));
211
233
  watchHost(host, executor.diagnostics, context);
@@ -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[];
@@ -116,8 +118,12 @@ export declare class NativeIngest {
116
118
  private readonly reads;
117
119
  private readonly timers;
118
120
  private readonly failures;
121
+ /** File versions that hit a size limit; they are not read again until the file changes. */
122
+ private readonly oversized;
119
123
  private readonly aliveAt;
120
124
  private readonly runningPaths;
125
+ /** Files a live Cohub execution is writing in a format that cannot mark its Turn. */
126
+ private readonly executing;
121
127
  private writing;
122
128
  private readonly foreign;
123
129
  private importQueue;
@@ -152,6 +158,7 @@ export declare class NativeIngest {
152
158
  connect(transport: IngestTransport | null): void;
153
159
  /**
154
160
  * Record that Cohub created or chose this transcript for a Session, before any Turn of it runs.
161
+ * `through` says the server has the file's Turns up to `turnKey` (none when null) and `turnId` heads them.
155
162
  */
156
163
  adopt(session: {
157
164
  harness: Harness;
@@ -162,6 +169,15 @@ export declare class NativeIngest {
162
169
  turnKey: string | null;
163
170
  turnId: string | null;
164
171
  }): void;
172
+ /** A Cohub Turn starts writing this file; a format that cannot mark it is not read until it ends. */
173
+ begin(session: {
174
+ harness: Harness;
175
+ path: string;
176
+ nativeSessionId: string;
177
+ cwd: string;
178
+ }, sessionId: string, turnId: string): void;
179
+ /** A Cohub Turn ended, whether or not it produced a result. */
180
+ end(turnId: string): void;
165
181
  advance(path: string, turnId: string): void;
166
182
  touch(path: string): void;
167
183
  running(harness: Harness, nativeSessionId: string): TranscriptState | null;
@@ -196,14 +212,19 @@ export declare class NativeIngest {
196
212
  };
197
213
  transcriptFor(sessionId: string, harness: Harness, headTurnId: string | null): TranscriptState | null;
198
214
  scan(): Promise<void>;
199
- plan(filter?: ImportFilter, signal?: AbortSignal): Promise<Array<NativeCandidate & {
215
+ /** `resume` is automatic resumption: it skips file versions that already hit a size limit, which only an explicit import retries. */
216
+ plan(filter?: ImportFilter, signal?: AbortSignal, options?: {
217
+ resume?: boolean;
218
+ }): Promise<Array<NativeCandidate & {
200
219
  nativeSessionId?: string;
201
220
  }>>;
202
221
  /**
203
222
  * Start or resume a backfill. What was imported is tracked, so resuming simply plans again; the
204
223
  * server recognizes any Turn it already has, so an interrupted batch is never duplicated.
205
224
  */
206
- startImport(filter?: ImportFilter, concurrency?: number): Promise<ImportJob>;
225
+ startImport(filter?: ImportFilter, concurrency?: number, options?: {
226
+ resume?: boolean;
227
+ }): Promise<ImportJob>;
207
228
  pauseImport(): ImportJob;
208
229
  watch(signal: AbortSignal): Promise<void>;
209
230
  private watchHarness;
@@ -217,6 +238,7 @@ export declare class NativeIngest {
217
238
  private report;
218
239
  private sync;
219
240
  private read;
241
+ private readVersion;
220
242
  private crossesBoundary;
221
243
  private blank;
222
244
  private harnessFor;