@neta-art/cohub-cli 10.5.0 → 10.7.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 (60) hide show
  1. package/README.md +8 -3
  2. package/dist/board-kernel.js +13 -10
  3. package/dist/commands/generations.js +23 -2
  4. package/dist/commands/runtime.js +21 -8
  5. package/dist/commands/spaces.js +3 -3
  6. package/dist/runtime/archive-store.d.ts +2 -2
  7. package/dist/runtime/archive-store.js +2 -2
  8. package/dist/runtime/diagnostics.d.ts +3 -3
  9. package/dist/runtime/harnesses/chained-projection.d.ts +40 -0
  10. package/dist/runtime/harnesses/chained-projection.js +95 -0
  11. package/dist/runtime/harnesses/claude.d.ts +8 -0
  12. package/dist/runtime/harnesses/claude.js +153 -0
  13. package/dist/runtime/harnesses/codex.d.ts +4 -0
  14. package/dist/runtime/harnesses/codex.js +229 -0
  15. package/dist/runtime/harnesses/index.d.ts +16 -0
  16. package/dist/runtime/harnesses/index.js +57 -0
  17. package/dist/runtime/harnesses/pi.d.ts +4 -0
  18. package/dist/runtime/harnesses/pi.js +247 -0
  19. package/dist/runtime/harnesses/process.d.ts +31 -0
  20. package/dist/runtime/harnesses/process.js +75 -0
  21. package/dist/runtime/harnesses/stream-json.d.ts +30 -0
  22. package/dist/runtime/harnesses/stream-json.js +292 -0
  23. package/dist/runtime/harnesses/types.d.ts +108 -0
  24. package/dist/runtime/harnesses/types.js +1 -0
  25. package/dist/runtime/harnesses/workbuddy.d.ts +15 -0
  26. package/dist/runtime/harnesses/workbuddy.js +99 -0
  27. package/dist/runtime/json-rpc.d.ts +7 -1
  28. package/dist/runtime/json-rpc.js +23 -2
  29. package/dist/runtime/launch.d.ts +6 -4
  30. package/dist/runtime/launch.js +21 -11
  31. package/dist/runtime/native/adapters.d.ts +11 -28
  32. package/dist/runtime/native/adapters.js +7 -75
  33. package/dist/runtime/native/archive.d.ts +9 -7
  34. package/dist/runtime/native/archive.js +99 -38
  35. package/dist/runtime/native/attach.d.ts +3 -5
  36. package/dist/runtime/native/attach.js +8 -2
  37. package/dist/runtime/native/daemon.d.ts +2 -2
  38. package/dist/runtime/native/daemon.js +5 -4
  39. package/dist/runtime/native/execution.d.ts +9 -30
  40. package/dist/runtime/native/execution.js +7 -325
  41. package/dist/runtime/native/identity.d.ts +4 -4
  42. package/dist/runtime/native/identity.js +4 -4
  43. package/dist/runtime/native/ingest.d.ts +2 -2
  44. package/dist/runtime/native/ingest.js +20 -25
  45. package/dist/runtime/native/sessions.d.ts +4 -4
  46. package/dist/runtime/native/sessions.js +12 -22
  47. package/dist/runtime/native/transcript.d.ts +3 -0
  48. package/dist/runtime/native/transcript.js +6 -5
  49. package/dist/runtime/native/translate.d.ts +2 -0
  50. package/dist/runtime/native/translate.js +34 -1
  51. package/dist/runtime/native/version.js +1 -1
  52. package/dist/runtime/presentation.js +2 -1
  53. package/dist/runtime/projection-store.js +2 -12
  54. package/dist/runtime/supervisor.d.ts +4 -4
  55. package/dist/runtime/supervisor.js +1 -1
  56. package/dist/runtime/turn-projection.d.ts +1 -1
  57. package/dist/runtime/turn-projection.js +2 -2
  58. package/package.json +2 -2
  59. package/dist/runtime/harness.d.ts +0 -26
  60. package/dist/runtime/harness.js +0 -142
@@ -65,6 +65,8 @@ export class JsonRpcProcess {
65
65
  stderr = "";
66
66
  closed;
67
67
  closing = null;
68
+ inputEnded = false;
69
+ /** `pi` and `jsonrpc` correlate request responses; `stream` delivers every line as an event. */
68
70
  constructor(binary, args, cwd, mode, context = {}) {
69
71
  this.mode = mode;
70
72
  this.child = spawn(binary, args, { cwd, env: { ...harnessEnvironment(), ...context }, stdio: "pipe", detached: process.platform !== "win32", windowsHide: true });
@@ -90,7 +92,7 @@ export class JsonRpcProcess {
90
92
  fail(value) {
91
93
  if (this.failure)
92
94
  return;
93
- this.failure = this.closing ? new RpcProcessClosedError() : value instanceof Error ? value : new Error(String(value));
95
+ this.failure = this.closing || this.inputEnded ? new RpcProcessClosedError() : value instanceof Error ? value : new Error(String(value));
94
96
  for (const pending of this.pending.values()) {
95
97
  clearTimeout(pending.timer);
96
98
  pending.reject(this.failure);
@@ -101,7 +103,7 @@ export class JsonRpcProcess {
101
103
  }
102
104
  receive(value) {
103
105
  const key = value.id == null ? null : String(value.id);
104
- const response = this.mode === "pi" ? value.type === "response" : "result" in value || "error" in value;
106
+ const response = this.mode === "pi" ? value.type === "response" : this.mode === "jsonrpc" && ("result" in value || "error" in value);
105
107
  if (key && response) {
106
108
  const pending = this.pending.get(key);
107
109
  if (!pending)
@@ -147,6 +149,25 @@ export class JsonRpcProcess {
147
149
  }
148
150
  });
149
151
  }
152
+ /** Signal end of input; a stream-mode CLI flushes its transcript and exits. */
153
+ endInput() {
154
+ if (this.failure)
155
+ return;
156
+ // The exit that follows is expected, not a failure.
157
+ this.inputEnded = true;
158
+ this.child.stdin.end();
159
+ }
160
+ /** Whether the process exits within the timeout. */
161
+ async exited(timeoutMs) {
162
+ let timer;
163
+ const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve(false), timeoutMs); });
164
+ try {
165
+ return await Promise.race([this.closed.then(() => true), timeout]);
166
+ }
167
+ finally {
168
+ clearTimeout(timer);
169
+ }
170
+ }
150
171
  onEvent(listener) { this.listeners.add(listener); return () => this.listeners.delete(listener); }
151
172
  onFailure(listener) {
152
173
  this.failureListeners.add(listener);
@@ -1,21 +1,23 @@
1
+ import { type LocalHarness } from "@neta-art/cohub";
2
+ import { type HarnessExecutables } from "./harnesses/index.js";
1
3
  import type { Command } from "commander";
2
4
  import { type RuntimeSummary } from "./presentation.js";
3
5
  import { type RuntimeLaunch } from "./supervisor.js";
4
- export type RuntimeUpOptions = {
6
+ export type RuntimeUpOptions = HarnessExecutables & {
5
7
  space?: string;
6
8
  new?: boolean;
7
9
  name?: string;
8
10
  harness: string[];
9
- pi?: string;
10
- codex?: string;
11
11
  display?: string | boolean;
12
12
  yes?: boolean;
13
13
  json?: boolean;
14
14
  detach?: boolean;
15
15
  verbose?: boolean;
16
16
  };
17
+ /** Executable overrides come from the per-harness `--<harness> <path>` options. */
18
+ export declare const runtimeExecutables: (options: HarnessExecutables) => HarnessExecutables;
17
19
  export declare const resolveLocalSpaceName: (root: string, name?: string) => string;
18
- export declare function parseRuntimeHarnesses(values: string[]): ("pi" | "codex")[];
20
+ export declare function parseRuntimeHarnesses(values: string[]): LocalHarness[];
19
21
  export declare function parseRuntimeDisplay(value: string | boolean | undefined, platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv): string | undefined;
20
22
  export declare function resolveRuntimeTarget(program: Command, target?: string): Promise<string>;
21
23
  export declare function startBackgroundRuntime(config: RuntimeLaunch): Promise<RuntimeSummary>;
@@ -4,21 +4,24 @@ import { stat } from "node:fs/promises";
4
4
  import { basename, join, resolve } from "node:path";
5
5
  import { promisify } from "node:util";
6
6
  import { createInterface } from "node:readline/promises";
7
- import { isLocalHarness } from "@neta-art/cohub";
8
- import { discoverHarnesses, installedHarnesses } from "./harness.js";
7
+ import { isLocalHarness, LOCAL_HARNESS_SPECS, LOCAL_HARNESSES } from "@neta-art/cohub";
8
+ import { discoverHarnesses, installedHarnesses } from "./harnesses/index.js";
9
9
  import { requireAccessToken } from "../auth.js";
10
10
  import { createClient } from "../client.js";
11
11
  import { currentIdentityKey, explicitSpace, resolveSpaceRef } from "../space.js";
12
12
  import { canonicalRuntimeRoot, getRuntimeSpaceBinding, resolveRuntimeSpace } from "./space-binding.js";
13
13
  import { controlRuntimeInstance, requestRuntimeInstance, runtimeInstanceDirectory } from "./instance.js";
14
14
  import { ensureNativeSync, repairIntegrations } from "./native/attach.js";
15
+ import { serializeDiagnosticError } from "./diagnostics.js";
15
16
  import { createDiagnosticConsole, printRuntimeSummary, runtimeWebUrl } from "./presentation.js";
16
17
  import { runRuntime } from "./supervisor.js";
18
+ /** Executable overrides come from the per-harness `--<harness> <path>` options. */
19
+ export const runtimeExecutables = (options) => Object.fromEntries(LOCAL_HARNESSES.flatMap((harness) => options[harness] ? [[harness, options[harness]]] : []));
17
20
  export const resolveLocalSpaceName = (root, name) => name?.trim() || basename(root) || "local-space";
18
21
  export function parseRuntimeHarnesses(values) {
19
22
  const names = values.flatMap((value) => value.split(",")).map((name) => name.trim()).filter(Boolean);
20
23
  if (names.some((name) => !isLocalHarness(name)))
21
- throw new Error("Harness must be pi or codex");
24
+ throw new Error(`Harness must be one of: ${LOCAL_HARNESSES.join(", ")}`);
22
25
  return [...new Set(names.length ? names : ["pi"])];
23
26
  }
24
27
  export function parseRuntimeDisplay(value, platform = process.platform, env = process.env) {
@@ -123,16 +126,17 @@ export async function runtimeUp(program, dir, options) {
123
126
  const binding = await getRuntimeSpaceBinding(root, identity);
124
127
  const existingId = requested || binding?.spaceId;
125
128
  let harnesses = parseRuntimeHarnesses(options.harness);
129
+ const executables = runtimeExecutables(options);
126
130
  let display = options.display === undefined ? undefined : parseRuntimeDisplay(options.display);
127
131
  if (existingId && !options.new) {
128
132
  const existing = await requestRuntimeInstance(runtimeInstanceDirectory(identity, existingId));
129
133
  if (existing) {
130
- if (existing.root !== root || options.harness.length && [...existing.harnesses].sort().join() !== [...harnesses].sort().join() || options.pi || options.codex || options.display !== undefined && display !== existing.display) {
134
+ if (existing.root !== root || options.harness.length && [...existing.harnesses].sort().join() !== [...harnesses].sort().join() || Object.keys(executables).length || options.display !== undefined && display !== existing.display) {
131
135
  throw new Error("Runtime is running with a different configuration. Use down first");
132
136
  }
133
137
  // `up` is the single idempotent entry: a reused instance adopts its running
134
138
  // Harnesses (unless explicitly overridden) and completes native sync setup.
135
- const native = await ensureNativeSync({ root, spaceId: existing.spaceId, identity, harnesses: options.harness.length ? harnesses : parseRuntimeHarnesses(existing.harnesses), yes: options.yes, executables: { pi: options.pi, codex: options.codex } });
139
+ const native = await ensureNativeSync({ root, spaceId: existing.spaceId, identity, harnesses: options.harness.length ? harnesses : parseRuntimeHarnesses(existing.harnesses), yes: options.yes, executables });
136
140
  // The running Runtime applies consent given now without restarting.
137
141
  const directory = runtimeInstanceDirectory(identity, existing.spaceId);
138
142
  await controlRuntimeInstance(directory, "reload").catch(() => undefined);
@@ -144,9 +148,9 @@ export async function runtimeUp(program, dir, options) {
144
148
  }
145
149
  display = parseRuntimeDisplay(options.display);
146
150
  if (!options.harness.length)
147
- harnesses = await installedHarnesses(root, options);
151
+ harnesses = await installedHarnesses(root, executables);
148
152
  if (!harnesses.length)
149
- throw new Error("Install and sign in to Pi or Codex, or pass --harness");
153
+ throw new Error(`Install and sign in to ${LOCAL_HARNESSES.map((harness) => LOCAL_HARNESS_SPECS[harness].label).join(", ")}, or pass --harness`);
150
154
  let createNew = Boolean(options.new);
151
155
  let name = resolveLocalSpaceName(root, options.name);
152
156
  if (!options.yes) {
@@ -190,8 +194,14 @@ export async function runtimeUp(program, dir, options) {
190
194
  throw new Error("Stop the existing Runtime before rebinding this directory");
191
195
  }
192
196
  await repairIntegrations(options.pi);
193
- // Fail local preflight before creating remote state; the worker reuses this catalog.
194
- const capabilities = await discoverHarnesses(harnesses, options, root);
197
+ // Fail local preflight before creating remote state; the worker reuses this catalog. Without
198
+ // `--harness`, a discovered harness that cannot start is skipped with a warning.
199
+ const capabilities = await discoverHarnesses(harnesses, executables, root, options.harness.length ? undefined : (harness, error) => {
200
+ process.stderr.write(`Skipping ${LOCAL_HARNESS_SPECS[harness].label}: ${serializeDiagnosticError(error).message.split("\n")[0]}\n`);
201
+ });
202
+ if (!capabilities.harnesses.length)
203
+ throw new Error("No installed harness could start; fix the warnings above or pass --harness");
204
+ harnesses = capabilities.harnesses;
195
205
  const client = createClient();
196
206
  const { spaceId, source } = await resolveRuntimeSpace({
197
207
  root, identityKey: identity, explicitSpaceId: requested,
@@ -204,8 +214,8 @@ export async function runtimeUp(program, dir, options) {
204
214
  },
205
215
  });
206
216
  // Consent (or --yes) precedes any user-level integration install; failure never blocks startup.
207
- const native = await ensureNativeSync({ root, spaceId, identity, harnesses, yes: options.yes, executables: { pi: options.pi, codex: options.codex } });
208
- const config = { spaceId, root, identity, harnesses, capabilities, executables: { pi: options.pi, codex: options.codex }, background: Boolean(options.detach), verbose: options.verbose, importHistory: native.importHistory, display };
217
+ const native = await ensureNativeSync({ root, spaceId, identity, harnesses, yes: options.yes, executables });
218
+ const config = { spaceId, root, identity, harnesses, capabilities, executables, background: Boolean(options.detach), verbose: options.verbose, importHistory: native.importHistory, display };
209
219
  const existing = await requestRuntimeInstance(runtimeInstanceDirectory(identity, spaceId));
210
220
  if (existing) {
211
221
  if (existing.root !== root)
@@ -1,32 +1,15 @@
1
- import { type NativeTranscript, type NativeTranscriptTurn } from "./transcript.js";
2
- export type { NativeTranscript, NativeTranscriptTurn };
3
- export type Harness = "pi" | "codex";
4
- /**
5
- * Why a Codex thread exists. Codex writes every thread it starts into one rollout tree: the user's
6
- * conversations, its own background work (guardian reviews, compaction, memory consolidation) and
7
- * agents spawned from a conversation.
8
- */
9
- export type ThreadKind = "user" | "internal" | "spawned";
10
- export type SessionHeader = {
11
- harness: Harness;
12
- nativeSessionId: string;
13
- cwd: string;
14
- thread: ThreadKind;
15
- };
1
+ import type { LocalHarness } from "@neta-art/cohub";
2
+ import type { SessionHeader, ThreadKind } from "../harnesses/types.js";
3
+ import type { NativeTranscript, NativeTranscriptTurn } from "./transcript.js";
4
+ export type { NativeTranscript, NativeTranscriptTurn, SessionHeader, ThreadKind };
5
+ export type Harness = LocalHarness;
16
6
  /** Directories holding this harness's transcripts; a transcript's header says which project it is. */
17
- export declare function transcriptRoots(harness: Harness): string[];
18
- /** Pi's encoding of a project directory into its session folder name. */
19
- export declare const piSessionDirectoryName: (cwd: string) => string;
7
+ export declare const transcriptRoots: (harness: Harness) => string[];
20
8
  /** Whether a directory found under a root can hold transcripts of `cwd`. */
21
- export declare function acceptsDirectory(path: string, root: string, harness: Harness, cwd: string): boolean;
9
+ export declare const acceptsDirectory: (path: string, root: string, harness: Harness, cwd: string) => boolean;
22
10
  /** Name-only check: can this path hold a transcript of this harness for `cwd`? */
23
- export declare function acceptsTranscript(path: string, harness: Harness, cwd: string): boolean;
24
- /** Codex's first line is the only place a thread's purpose is recorded. */
25
- export declare function codexThreadKind(payload: Record<string, unknown>): ThreadKind;
11
+ export declare const acceptsTranscript: (path: string, harness: Harness, cwd: string) => boolean;
26
12
  /** The first line of a transcript: identity, project and purpose. Nothing else is read. */
27
- export declare function readSessionHeader(path: string, harness: Harness, signal?: AbortSignal): Promise<SessionHeader | null>;
28
- /**
29
- * Parse a transcript into Turns. Codex records its own Turn boundary; a Pi Turn's end is inferred
30
- * by the caller, so Pi is parsed as settled here and the caller withdraws a tail still in flight.
31
- */
32
- export declare function readTranscript(path: string, harness: Harness, signal?: AbortSignal): Promise<NativeTranscript>;
13
+ export declare const readSessionHeader: (path: string, harness: Harness, signal?: AbortSignal) => Promise<SessionHeader | null>;
14
+ /** Parse a transcript into Turns. */
15
+ export declare const readTranscript: (path: string, harness: Harness, signal?: AbortSignal) => Promise<NativeTranscript>;
@@ -1,79 +1,11 @@
1
- import { createReadStream } from "node:fs";
2
- import { homedir } from "node:os";
3
- import { basename, dirname, join } from "node:path";
4
- import { codexArchivedSessionsDirectory, codexRolloutBytes, codexRolloutIdOf, codexSessionsDirectory, readNativeTranscript } from "./transcript.js";
5
- const text = (value) => typeof value === "string" ? value : "";
6
- const record = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
7
- const HEADER_MAX_BYTES = 1024 * 1024;
1
+ import { transcriptFormat } from "../harnesses/index.js";
8
2
  /** Directories holding this harness's transcripts; a transcript's header says which project it is. */
9
- export function transcriptRoots(harness) {
10
- if (harness === "codex")
11
- return [codexSessionsDirectory(), codexArchivedSessionsDirectory()];
12
- const custom = process.env.PI_CODING_AGENT_SESSION_DIR?.trim();
13
- if (custom)
14
- return [custom];
15
- return [join(process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), ".pi", "agent"), "sessions")];
16
- }
17
- /** Pi's encoding of a project directory into its session folder name. */
18
- export const piSessionDirectoryName = (cwd) => `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
19
- /** Whether a Pi project folder may hold sessions of `cwd` or a subdirectory of it. */
20
- function piProjectFolderMatches(folder, cwd) {
21
- const project = piSessionDirectoryName(cwd).slice(0, -2);
22
- const name = basename(folder);
23
- return name === `${project}--` || name.startsWith(`${project}-`);
24
- }
3
+ export const transcriptRoots = (harness) => transcriptFormat(harness).roots();
25
4
  /** Whether a directory found under a root can hold transcripts of `cwd`. */
26
- export function acceptsDirectory(path, root, harness, cwd) {
27
- if (harness === "codex" || process.env.PI_CODING_AGENT_SESSION_DIR?.trim() || dirname(path) !== root)
28
- return true;
29
- return piProjectFolderMatches(path, cwd);
30
- }
5
+ export const acceptsDirectory = (path, root, harness, cwd) => transcriptFormat(harness).acceptsDirectory(path, root, cwd);
31
6
  /** Name-only check: can this path hold a transcript of this harness for `cwd`? */
32
- export function acceptsTranscript(path, harness, cwd) {
33
- const name = basename(path);
34
- if (harness === "codex")
35
- return codexRolloutIdOf(name) !== null;
36
- return name.endsWith(".jsonl") && (Boolean(process.env.PI_CODING_AGENT_SESSION_DIR?.trim()) || piProjectFolderMatches(dirname(path), cwd));
37
- }
38
- async function firstRecord(bytes) {
39
- const chunks = [];
40
- let buffered = 0;
41
- for await (const chunk of bytes) {
42
- chunks.push(chunk);
43
- buffered += chunk.length;
44
- const newline = chunk.indexOf(10);
45
- if (newline >= 0)
46
- return record(JSON.parse(Buffer.concat(chunks, buffered).subarray(0, buffered - chunk.length + newline).toString("utf8")));
47
- if (buffered > HEADER_MAX_BYTES)
48
- throw new Error("Native session header is too large");
49
- }
50
- throw new Error("Native transcript is empty");
51
- }
52
- /** Codex's first line is the only place a thread's purpose is recorded. */
53
- export function codexThreadKind(payload) {
54
- const source = payload.source;
55
- if (source && typeof source === "object")
56
- return record(record(source).subagent).thread_spawn ? "spawned" : "internal";
57
- return ["subagent", "guardian_review", "memory_consolidation"].includes(text(payload.thread_source)) ? "internal" : "user";
58
- }
7
+ export const acceptsTranscript = (path, harness, cwd) => transcriptFormat(harness).acceptsTranscript(path, cwd);
59
8
  /** The first line of a transcript: identity, project and purpose. Nothing else is read. */
60
- export async function readSessionHeader(path, harness, signal) {
61
- if (harness === "pi") {
62
- const header = await firstRecord(createReadStream(path, { signal }));
63
- if (header.type !== "session" || !text(header.id) || !text(header.cwd))
64
- return null;
65
- return { harness, nativeSessionId: text(header.id), cwd: text(header.cwd), thread: "user" };
66
- }
67
- const header = await firstRecord(codexRolloutBytes(path, null, signal));
68
- const payload = record(header.payload);
69
- if (header.type !== "session_meta" || !text(payload.id) || !text(payload.cwd))
70
- return null;
71
- return { harness, nativeSessionId: text(payload.id), cwd: text(payload.cwd), thread: codexThreadKind(payload) };
72
- }
73
- /**
74
- * Parse a transcript into Turns. Codex records its own Turn boundary; a Pi Turn's end is inferred
75
- * by the caller, so Pi is parsed as settled here and the caller withdraws a tail still in flight.
76
- */
77
- export async function readTranscript(path, harness, signal) {
78
- return await readNativeTranscript(path, harness, { settled: harness === "pi", signal });
79
- }
9
+ export const readSessionHeader = async (path, harness, signal) => await transcriptFormat(harness).header(path, signal);
10
+ /** Parse a transcript into Turns. */
11
+ export const readTranscript = async (path, harness, signal) => await transcriptFormat(harness).read(path, signal);
@@ -1,17 +1,19 @@
1
+ import type { JsonRecord } from "../json-rpc.js";
1
2
  /** A structurally unrestorable archive (e.g. its history reference is not carried); never retried. */
2
3
  export declare class ArchiveNotRestorableError extends Error {
3
4
  constructor(message: string);
4
5
  }
5
- /** Change only the header of a working copy. Raw archive bytes remain untouched. */
6
- export declare function importNativeArchive(input: {
6
+ /**
7
+ * Create a working copy of raw archive bytes. The header line may be rewritten, or with `sessionId`
8
+ * the top-level session id of every line; the target is created exclusively, so an existing native
9
+ * file is never replaced.
10
+ */
11
+ export declare function copyNativeArchive(input: {
7
12
  source: string;
8
13
  target: string;
9
- harness: "pi" | "codex";
10
- nativeSessionId: string;
11
- id: string;
12
- cwd: string;
13
14
  signal?: AbortSignal;
15
+ header?: (header: JsonRecord) => JsonRecord;
16
+ sessionId?: string;
14
17
  }): Promise<{
15
18
  checksum: string;
16
- nativeSessionId: string;
17
19
  }>;
@@ -7,58 +7,119 @@ import { RUNTIME_MAX_FRAME_BYTES } from "@neta-art/cohub";
7
7
  export class ArchiveNotRestorableError extends Error {
8
8
  constructor(message) { super(message); this.name = "ArchiveNotRestorableError"; }
9
9
  }
10
- /** Change only the header of a working copy. Raw archive bytes remain untouched. */
11
- export async function importNativeArchive(input) {
10
+ const NEWLINE = Buffer.from("\n");
11
+ const SESSION_ID_KEY = Buffer.from('"sessionId"');
12
+ /** End of the JSON string starting at `start`, or -1 when the line ends inside it. */
13
+ function stringEnd(line, start) {
14
+ for (let index = start + 1; index < line.length; index++) {
15
+ if (line[index] === 0x5c)
16
+ index++;
17
+ else if (line[index] === 0x22)
18
+ return index;
19
+ }
20
+ return -1;
21
+ }
22
+ const skipSpace = (line, index) => {
23
+ while ([0x20, 0x09, 0x0d].includes(line[index] ?? -1))
24
+ index++;
25
+ return index;
26
+ };
27
+ /**
28
+ * Replace the top-level `sessionId` string of one JSON line; every other byte, including numbers a
29
+ * parser would round and lines that are not JSON, is kept as written. Structural JSON bytes are
30
+ * ASCII, which UTF-8 never uses inside a multi-byte character, so bytes are scanned directly.
31
+ */
32
+ function rebindLine(line, sessionId) {
33
+ let depth = 0;
34
+ for (let index = 0; index < line.length; index++) {
35
+ const byte = line[index];
36
+ if (byte === 0x22) {
37
+ const end = stringEnd(line, index);
38
+ if (end < 0)
39
+ return line;
40
+ const colon = skipSpace(line, end + 1);
41
+ if (depth === 1 && line[colon] === 0x3a && line.subarray(index, end + 1).equals(SESSION_ID_KEY)) {
42
+ const value = skipSpace(line, colon + 1);
43
+ const valueEnd = line[value] === 0x22 ? stringEnd(line, value) : -1;
44
+ if (valueEnd < 0)
45
+ return line;
46
+ return Buffer.concat([line.subarray(0, value), Buffer.from(JSON.stringify(sessionId)), line.subarray(valueEnd + 1)]);
47
+ }
48
+ index = end;
49
+ }
50
+ else if (byte === 0x7b || byte === 0x5b)
51
+ depth++;
52
+ else if (byte === 0x7d || byte === 0x5d)
53
+ depth--;
54
+ }
55
+ return line;
56
+ }
57
+ /**
58
+ * Create a working copy of raw archive bytes. The header line may be rewritten, or with `sessionId`
59
+ * the top-level session id of every line; the target is created exclusively, so an existing native
60
+ * file is never replaced.
61
+ */
62
+ export async function copyNativeArchive(input) {
12
63
  await mkdir(dirname(input.target), { recursive: true, mode: 0o700 });
13
64
  const temporary = `${input.target}.${randomUUID()}.importing`;
14
65
  const file = await open(temporary, "wx", 0o600);
15
66
  const digest = createHash("sha256");
16
- let headerReady = false, headerBytes = 0;
67
+ let headerReady = !input.header, headerBytes = 0;
17
68
  const fragments = [];
18
69
  const write = async (bytes) => { digest.update(bytes); await file.writeFile(bytes); };
19
70
  const writeHeader = async () => {
20
- const header = JSON.parse(Buffer.concat(fragments).toString("utf8"));
21
- if (input.harness === "pi") {
22
- if (header?.type !== "session" || header.id !== input.nativeSessionId)
23
- throw new Error("Pi archive identity mismatch");
24
- header.cwd = input.cwd;
25
- delete header.parentSession;
26
- }
27
- else {
28
- if (header?.type !== "session_meta" || header.payload?.id !== input.nativeSessionId)
29
- throw new Error("Codex archive identity mismatch");
30
- // A leaf archive does not carry its ancestor rollout.
31
- if (header.payload?.history_base != null)
32
- throw new ArchiveNotRestorableError("Codex archive references history it does not carry; rebuild from the Session instead");
33
- header.payload.id = input.id;
34
- if (header.payload.session_id != null)
35
- header.payload.session_id = input.id;
36
- header.payload.history_mode = "legacy";
37
- header.payload.cwd = input.cwd;
38
- }
71
+ const header = input.header?.(JSON.parse(Buffer.concat(fragments).toString("utf8")));
39
72
  await write(Buffer.from(`${JSON.stringify(header)}\n`));
40
73
  fragments.length = 0;
41
74
  headerReady = true;
42
75
  };
43
76
  try {
44
- for await (const bytes of createReadStream(input.source, { signal: input.signal })) {
45
- if (headerReady) {
46
- await write(bytes);
47
- continue;
77
+ if (input.sessionId) {
78
+ const sessionId = input.sessionId;
79
+ const line = [];
80
+ let lineBytes = 0;
81
+ const flush = async (newline) => {
82
+ const bytes = Buffer.concat(line);
83
+ line.length = 0;
84
+ lineBytes = 0;
85
+ await write(Buffer.concat([rebindLine(bytes, sessionId), newline ? NEWLINE : Buffer.alloc(0)]));
86
+ };
87
+ for await (const chunk of createReadStream(input.source, { signal: input.signal })) {
88
+ let bytes = chunk;
89
+ for (let newline = bytes.indexOf(10); newline >= 0; newline = bytes.indexOf(10)) {
90
+ line.push(bytes.subarray(0, newline));
91
+ await flush(true);
92
+ bytes = bytes.subarray(newline + 1);
93
+ }
94
+ lineBytes += bytes.length;
95
+ if (lineBytes > RUNTIME_MAX_FRAME_BYTES)
96
+ throw new Error("Native record is too large");
97
+ if (bytes.length)
98
+ line.push(bytes);
48
99
  }
49
- const newline = bytes.indexOf(10);
50
- const prefix = newline < 0 ? bytes : bytes.subarray(0, newline);
51
- headerBytes += prefix.length;
52
- if (headerBytes > RUNTIME_MAX_FRAME_BYTES)
53
- throw new Error("Native header is too large");
54
- fragments.push(prefix);
55
- if (newline >= 0) {
56
- await writeHeader();
57
- await write(bytes.subarray(newline + 1));
100
+ if (line.length)
101
+ await flush(false);
102
+ }
103
+ else {
104
+ for await (const bytes of createReadStream(input.source, { signal: input.signal })) {
105
+ if (headerReady) {
106
+ await write(bytes);
107
+ continue;
108
+ }
109
+ const newline = bytes.indexOf(10);
110
+ const prefix = newline < 0 ? bytes : bytes.subarray(0, newline);
111
+ headerBytes += prefix.length;
112
+ if (headerBytes > RUNTIME_MAX_FRAME_BYTES)
113
+ throw new Error("Native header is too large");
114
+ fragments.push(prefix);
115
+ if (newline >= 0) {
116
+ await writeHeader();
117
+ await write(bytes.subarray(newline + 1));
118
+ }
58
119
  }
120
+ if (!headerReady)
121
+ await writeHeader();
59
122
  }
60
- if (!headerReady)
61
- await writeHeader();
62
123
  input.signal?.throwIfAborted();
63
124
  await file.sync();
64
125
  await file.close();
@@ -72,7 +133,7 @@ export async function importNativeArchive(input) {
72
133
  await directory.close();
73
134
  }
74
135
  }
75
- return { checksum: digest.digest("hex"), nativeSessionId: input.harness === "pi" ? input.nativeSessionId : input.id };
136
+ return { checksum: digest.digest("hex") };
76
137
  }
77
138
  finally {
78
139
  await file.close();
@@ -1,3 +1,4 @@
1
+ import type { HarnessExecutables } from "../harnesses/types.js";
1
2
  import type { Harness } from "./adapters.js";
2
3
  import { type NativeConfig } from "./config.js";
3
4
  export type NativeSyncReport = {
@@ -13,10 +14,7 @@ type Input = {
13
14
  identity: string;
14
15
  harnesses: Harness[];
15
16
  yes?: boolean;
16
- executables?: {
17
- pi?: string;
18
- codex?: string;
19
- };
17
+ executables?: HarnessExecutables;
20
18
  };
21
19
  type Ask = (question: string) => Promise<boolean>;
22
20
  /** Whether this directory already syncs every requested harness. */
@@ -29,7 +27,7 @@ export declare function nativeSyncSatisfied(config: NativeConfig | null, input:
29
27
  * Enable native sync after consent. Native history may contain secrets, so nothing is read before
30
28
  * the user agrees; a failure here never blocks the Runtime, it only leaves sessions read-only.
31
29
  */
32
- export declare function ensureNativeSync(input: Input, log?: (line: string) => void, ask?: Ask): Promise<NativeSyncReport>;
30
+ export declare function ensureNativeSync(requested: Input, log?: (line: string) => void, ask?: Ask): Promise<NativeSyncReport>;
33
31
  /**
34
32
  * Before any harness starts, repair what earlier releases installed: an outdated Pi extension can keep
35
33
  * Pi from starting, and legacy Codex hooks run a script that is gone. Both had consent back then.
@@ -1,5 +1,7 @@
1
1
  import { createInterface } from "node:readline/promises";
2
+ import { LOCAL_HARNESS_SPECS } from "@neta-art/cohub";
2
3
  import { serializeDiagnosticError } from "../diagnostics.js";
4
+ import { nativeSyncHarnesses } from "../harnesses/index.js";
3
5
  import { CODEX_SHARED_MIN_VERSION, sharedServerAvailable, supportsSharedServer } from "./codex-link.js";
4
6
  import { PI_MIN_VERSION } from "./pi-link.js";
5
7
  import { atLeast, formatVersion, harnessVersion } from "./version.js";
@@ -23,7 +25,11 @@ async function defaultAsk(question) {
23
25
  * Enable native sync after consent. Native history may contain secrets, so nothing is read before
24
26
  * the user agrees; a failure here never blocks the Runtime, it only leaves sessions read-only.
25
27
  */
26
- export async function ensureNativeSync(input, log = (line) => process.stderr.write(line), ask = defaultAsk) {
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) };
31
+ if (!input.harnesses.length)
32
+ return { enabled: false, harnesses: [], codexShared: false, importHistory: false, note: "unsupported" };
27
33
  const stateRoot = runtimeStateRoot(input.spaceId);
28
34
  const previous = await readNativeConfig(stateRoot, input.identity).catch(() => null);
29
35
  if (previous && nativeSyncSatisfied(previous, input)) {
@@ -36,7 +42,7 @@ export async function ensureNativeSync(input, log = (line) => process.stderr.wri
36
42
  log("Native sync off · rerun interactively or with --yes to enable\n");
37
43
  return { enabled: false, harnesses: input.harnesses, codexShared: false, importHistory: false, note: "non-interactive" };
38
44
  }
39
- const names = input.harnesses.map((harness) => harness === "pi" ? "Pi" : "Codex").join(" and ");
45
+ const names = input.harnesses.map((harness) => LOCAL_HARNESS_SPECS[harness].label).join(" and ");
40
46
  if (!await confirm(`Sync ${names} chats in this folder to this Space so they continue on the web? Their history may contain secrets. [Y/n] `)) {
41
47
  return { enabled: false, harnesses: input.harnesses, codexShared: false, importHistory: false, note: "declined" };
42
48
  }
@@ -1,7 +1,7 @@
1
1
  import type { NativeRuntimeEvent } from "@neta-art/cohub";
2
2
  import type { RuntimeArchiveStore } from "../archive-store.js";
3
3
  import { type RuntimeDiagnostics } from "../diagnostics.js";
4
- import type { HarnessOptions } from "../harness.js";
4
+ import type { HarnessExecutables } from "../harnesses/types.js";
5
5
  import type { SessionTurnProjectionClient } from "../turn-projection.js";
6
6
  import { type Harness } from "./adapters.js";
7
7
  import { type NativeConfig } from "./config.js";
@@ -47,7 +47,7 @@ export declare class NativeRuntime {
47
47
  root: string;
48
48
  stateRoot: string;
49
49
  harnesses: Harness[];
50
- executables: HarnessOptions;
50
+ executables: HarnessExecutables;
51
51
  identity: string;
52
52
  config: NativeConfig | null;
53
53
  archives: RuntimeArchiveStore;
@@ -59,8 +59,8 @@ export class NativeRuntime {
59
59
  root: options.stateRoot,
60
60
  archives: options.archives,
61
61
  hooks: {
62
- piSettled: (nativeSessionId, mtimeMs) => {
63
- const link = this.pi.find(nativeSessionId);
62
+ settled: (state, mtimeMs) => {
63
+ const link = state.harness === "pi" ? this.pi.find(state.nativeSessionId) : null;
64
64
  if (!link)
65
65
  return null;
66
66
  if (!link.idle)
@@ -68,13 +68,14 @@ export class NativeRuntime {
68
68
  // Idle and nothing written since it settled: done. A newer write lets the file decide.
69
69
  return link.settledAt >= mtimeMs ? true : null;
70
70
  },
71
+ // Only Pi (extension) and Codex (shared app-server) have a control plane; other synced clients are read-only.
71
72
  controllable: (state) => state.harness === "pi"
72
73
  ? this.pi.find(state.nativeSessionId) !== null
73
- : this.codex.enabled && (this.active.has(state.nativeSessionId) || this.watched.has(state.nativeSessionId) || this.codex.driving.has(state.nativeSessionId)),
74
+ : state.harness === "codex" && this.codex.enabled && (this.active.has(state.nativeSessionId) || this.watched.has(state.nativeSessionId) || this.codex.driving.has(state.nativeSessionId)),
74
75
  abortTurn: (state) => {
75
76
  const stop = state.harness === "pi"
76
77
  ? this.pi.abort(state.nativeSessionId)
77
- : state.runningTurnKey ? this.codex.interrupt(state.nativeSessionId, state.runningTurnKey) : Promise.resolve(false);
78
+ : state.harness === "codex" && state.runningTurnKey ? this.codex.interrupt(state.nativeSessionId, state.runningTurnKey) : Promise.resolve(false);
78
79
  void stop.catch((error) => options.diagnostics.log("warn", "native.stop_failed", { error: serializeDiagnosticError(error) }, { harness: state.harness, sessionId: state.sessionId ?? undefined }));
79
80
  },
80
81
  onGrowth: (state) => this.onGrowth(state),