@neta-art/cohub-cli 10.5.0 → 10.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/dist/commands/runtime.js +21 -8
- package/dist/commands/spaces.js +3 -3
- package/dist/runtime/archive-store.d.ts +2 -2
- package/dist/runtime/archive-store.js +2 -2
- package/dist/runtime/diagnostics.d.ts +3 -3
- package/dist/runtime/harnesses/chained-projection.d.ts +40 -0
- package/dist/runtime/harnesses/chained-projection.js +95 -0
- package/dist/runtime/harnesses/claude.d.ts +8 -0
- package/dist/runtime/harnesses/claude.js +94 -0
- package/dist/runtime/harnesses/codex.d.ts +4 -0
- package/dist/runtime/harnesses/codex.js +229 -0
- package/dist/runtime/harnesses/index.d.ts +16 -0
- package/dist/runtime/harnesses/index.js +57 -0
- package/dist/runtime/harnesses/pi.d.ts +4 -0
- package/dist/runtime/harnesses/pi.js +247 -0
- package/dist/runtime/harnesses/process.d.ts +31 -0
- package/dist/runtime/harnesses/process.js +75 -0
- package/dist/runtime/harnesses/stream-json.d.ts +28 -0
- package/dist/runtime/harnesses/stream-json.js +291 -0
- package/dist/runtime/harnesses/types.d.ts +108 -0
- package/dist/runtime/harnesses/types.js +1 -0
- package/dist/runtime/harnesses/workbuddy.d.ts +15 -0
- package/dist/runtime/harnesses/workbuddy.js +99 -0
- package/dist/runtime/json-rpc.d.ts +7 -1
- package/dist/runtime/json-rpc.js +23 -2
- package/dist/runtime/launch.d.ts +6 -4
- package/dist/runtime/launch.js +21 -11
- package/dist/runtime/native/adapters.d.ts +11 -28
- package/dist/runtime/native/adapters.js +7 -75
- package/dist/runtime/native/archive.d.ts +9 -7
- package/dist/runtime/native/archive.js +99 -38
- package/dist/runtime/native/attach.d.ts +3 -5
- package/dist/runtime/native/attach.js +8 -2
- package/dist/runtime/native/daemon.d.ts +2 -2
- package/dist/runtime/native/daemon.js +5 -4
- package/dist/runtime/native/execution.d.ts +9 -30
- package/dist/runtime/native/execution.js +7 -325
- package/dist/runtime/native/identity.d.ts +4 -4
- package/dist/runtime/native/identity.js +4 -4
- package/dist/runtime/native/ingest.d.ts +2 -2
- package/dist/runtime/native/ingest.js +20 -25
- package/dist/runtime/native/sessions.d.ts +4 -4
- package/dist/runtime/native/sessions.js +12 -22
- package/dist/runtime/native/transcript.d.ts +3 -0
- package/dist/runtime/native/transcript.js +6 -5
- package/dist/runtime/native/translate.d.ts +2 -0
- package/dist/runtime/native/translate.js +34 -1
- package/dist/runtime/native/version.js +1 -1
- package/dist/runtime/presentation.js +2 -1
- package/dist/runtime/projection-store.js +2 -12
- package/dist/runtime/supervisor.d.ts +4 -4
- package/dist/runtime/supervisor.js +1 -1
- package/dist/runtime/turn-projection.d.ts +1 -1
- package/dist/runtime/turn-projection.js +2 -2
- package/package.json +2 -2
- package/dist/runtime/harness.d.ts +0 -26
- package/dist/runtime/harness.js +0 -142
package/dist/runtime/json-rpc.js
CHANGED
|
@@ -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);
|
package/dist/runtime/launch.d.ts
CHANGED
|
@@ -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[]):
|
|
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>;
|
package/dist/runtime/launch.js
CHANGED
|
@@ -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 "./
|
|
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(
|
|
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() ||
|
|
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
|
|
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,
|
|
151
|
+
harnesses = await installedHarnesses(root, executables);
|
|
148
152
|
if (!harnesses.length)
|
|
149
|
-
throw new Error(
|
|
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
|
-
|
|
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
|
|
208
|
-
const config = { spaceId, root, identity, harnesses, capabilities, executables
|
|
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
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
28
|
-
/**
|
|
29
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
/**
|
|
6
|
-
|
|
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
|
-
|
|
11
|
-
|
|
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 =
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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")
|
|
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(
|
|
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(
|
|
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
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
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),
|