@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.
- package/README.md +4 -4
- package/dist/board-kernel.js +276 -214
- package/dist/commands/boards/examples.js +16 -16
- package/dist/commands/boards.js +1 -1
- package/dist/commands/runtime.js +3 -14
- package/dist/runtime/connection.js +161 -54
- package/dist/runtime/harnesses/chained-transcript.d.ts +24 -0
- package/dist/runtime/harnesses/chained-transcript.js +62 -0
- package/dist/runtime/harnesses/claude-transcript.d.ts +8 -0
- package/dist/runtime/harnesses/claude-transcript.js +138 -0
- package/dist/runtime/harnesses/claude.js +20 -2
- package/dist/runtime/harnesses/codex.js +1 -1
- package/dist/runtime/harnesses/cursor-transcript.d.ts +37 -0
- package/dist/runtime/harnesses/cursor-transcript.js +183 -0
- package/dist/runtime/harnesses/cursor.d.ts +24 -0
- package/dist/runtime/harnesses/cursor.js +620 -0
- package/dist/runtime/harnesses/index.d.ts +1 -3
- package/dist/runtime/harnesses/index.js +4 -10
- package/dist/runtime/harnesses/pi.js +1 -1
- package/dist/runtime/harnesses/stream-json.d.ts +5 -1
- package/dist/runtime/harnesses/stream-json.js +31 -9
- package/dist/runtime/harnesses/types.d.ts +10 -5
- package/dist/runtime/harnesses/workbuddy-transcript.d.ts +8 -0
- package/dist/runtime/harnesses/workbuddy-transcript.js +127 -0
- package/dist/runtime/harnesses/workbuddy.d.ts +1 -1
- package/dist/runtime/harnesses/workbuddy.js +31 -7
- package/dist/runtime/json-rpc.d.ts +2 -0
- package/dist/runtime/json-rpc.js +9 -0
- package/dist/runtime/native/attach.d.ts +1 -1
- package/dist/runtime/native/attach.js +1 -4
- package/dist/runtime/native/execution.js +14 -3
- package/dist/runtime/native/ingest.d.ts +24 -2
- package/dist/runtime/native/ingest.js +76 -10
- package/dist/runtime/native/sessions.js +6 -1
- package/dist/runtime/native/transcript.d.ts +23 -0
- package/dist/runtime/native/transcript.js +40 -5
- package/package.json +3 -3
|
@@ -10,9 +10,9 @@ export const BOARD_EXAMPLES = {
|
|
|
10
10
|
value: {
|
|
11
11
|
board: { title: "Launch plan" },
|
|
12
12
|
items: {
|
|
13
|
-
title: { type: "text", position: { x: 80, y: 60 }, props: { text: "Launch plan", fontSize:
|
|
14
|
-
note: { type: "text", position: { x: 80, y:
|
|
15
|
-
goal: { type: "shape", position: { x: 600, y: 60 }, size: { width:
|
|
13
|
+
title: { type: "text", position: { x: 80, y: 60 }, props: { text: "Launch plan", fontSize: 40, fontWeight: 700 } },
|
|
14
|
+
note: { type: "text", position: { x: 80, y: 128 }, props: { text: "Ship the beta to ten design partners, then widen.", width: 420 } },
|
|
15
|
+
goal: { type: "shape", position: { x: 600, y: 60 }, size: { width: 200, height: 140 }, style: { stroke: "green", fill: "green", fillOpacity: 0.12, radius: 16 }, props: { text: "Beta in March" } },
|
|
16
16
|
},
|
|
17
17
|
},
|
|
18
18
|
},
|
|
@@ -29,14 +29,14 @@ export const BOARD_EXAMPLES = {
|
|
|
29
29
|
},
|
|
30
30
|
},
|
|
31
31
|
slides: {
|
|
32
|
-
description: "
|
|
32
|
+
description: "960×540 frames as slides; children move and clip with their frame.",
|
|
33
33
|
value: {
|
|
34
34
|
items: {
|
|
35
|
-
s1: { type: "frame", position: { x: 0, y: 0 }, size: { width:
|
|
36
|
-
"s1-title": { type: "text", parent: "s1", position: { x:
|
|
37
|
-
"s1-sub": { type: "text", parent: "s1", position: { x:
|
|
38
|
-
s2: { type: "frame", position: { x:
|
|
39
|
-
"s2-title": { type: "text", parent: "s2", position: { x:
|
|
35
|
+
s1: { type: "frame", position: { x: 0, y: 0 }, size: { width: 960, height: 540 }, props: { label: "Intro" } },
|
|
36
|
+
"s1-title": { type: "text", parent: "s1", position: { x: 72, y: 72 }, props: { text: "Special relativity", fontSize: 56, fontWeight: 700 } },
|
|
37
|
+
"s1-sub": { type: "text", parent: "s1", position: { x: 72, y: 160 }, props: { text: "Nothing outruns light" } },
|
|
38
|
+
s2: { type: "frame", position: { x: 1080, y: 0 }, size: { width: 960, height: 540 }, props: { label: "Time dilation" } },
|
|
39
|
+
"s2-title": { type: "text", parent: "s2", position: { x: 72, y: 72 }, props: { text: "Moving clocks run slow", fontSize: 40, fontWeight: 700 } },
|
|
40
40
|
},
|
|
41
41
|
},
|
|
42
42
|
},
|
|
@@ -44,12 +44,12 @@ export const BOARD_EXAMPLES = {
|
|
|
44
44
|
description: "An animated lesson: entrances, a drawn arrow, typed text and camera moves with pause markers.",
|
|
45
45
|
value: {
|
|
46
46
|
items: {
|
|
47
|
-
s1: { type: "frame", size: { width:
|
|
48
|
-
title: { type: "text", parent: "s1", position: { x:
|
|
49
|
-
ruler: { type: "shape", parent: "s1", position: { x:
|
|
50
|
-
caption: { type: "text", parent: "s1", position: { x:
|
|
51
|
-
motion: { type: "arrow", parent: "s1", style: { stroke: "rose", strokeWidth:
|
|
52
|
-
glow: { type: "effect", parent: "s1", position: { x:
|
|
47
|
+
s1: { type: "frame", size: { width: 960, height: 540 }, props: { label: "Length contraction" } },
|
|
48
|
+
title: { type: "text", parent: "s1", position: { x: 72, y: 60 }, props: { text: "Moving rulers get shorter", fontSize: 40, fontWeight: 700 } },
|
|
49
|
+
ruler: { type: "shape", parent: "s1", position: { x: 120, y: 252 }, size: { width: 480, height: 40 }, style: { stroke: "blue", fill: "blue", fillOpacity: 0.15 }, props: { text: "1 m", fontSize: 16 } },
|
|
50
|
+
caption: { type: "text", parent: "s1", position: { x: 120, y: 336 }, props: { text: "L = L₀ √(1 − v²/c²)", font: "serif", reveal: 1 } },
|
|
51
|
+
motion: { type: "arrow", parent: "s1", style: { stroke: "rose", strokeWidth: 3 }, props: { start: { x: 120, y: 216 }, end: { x: 600, y: 216 }, label: "v → c" } },
|
|
52
|
+
glow: { type: "effect", parent: "s1", position: { x: 630, y: 224 }, size: { width: 96, height: 96 }, style: { fill: "amber" }, props: { kind: "glow" } },
|
|
53
53
|
},
|
|
54
54
|
animations: {
|
|
55
55
|
lecture: {
|
|
@@ -69,7 +69,7 @@ export const BOARD_EXAMPLES = {
|
|
|
69
69
|
description: "Custom Canvas content; use --module to print scripts/wave.js.",
|
|
70
70
|
value: {
|
|
71
71
|
items: {
|
|
72
|
-
wave: { type: "custom", position: { x: 0, y: 0 }, size: { width:
|
|
72
|
+
wave: { type: "custom", position: { x: 0, y: 0 }, size: { width: 480, height: 270 }, script: { src: "scripts/wave.js", params: { amplitude: 48 } } },
|
|
73
73
|
},
|
|
74
74
|
},
|
|
75
75
|
},
|
package/dist/commands/boards.js
CHANGED
|
@@ -151,7 +151,7 @@ function registerExportCommand(boards) {
|
|
|
151
151
|
.requiredOption("-o, --out <file>", "Output file; the extension picks the format. Sequences need %d, e.g. frames/%04d.png")
|
|
152
152
|
.option("--at <time>", "A moment, comma-separated moments, or start:end:step")
|
|
153
153
|
.option("--camera", "Follow the animation camera")
|
|
154
|
-
.option("--size <widthxheight>", "Camera
|
|
154
|
+
.option("--size <widthxheight>", "Camera output in pixels; frames keep their own size", "1600x900")
|
|
155
155
|
.option("--animation <id>", "Animation the --at times refer to")
|
|
156
156
|
.option("--frame <item-id>", "Export one frame as a page")
|
|
157
157
|
.option("--items <ids>", "Comma-separated item ids")
|
package/dist/commands/runtime.js
CHANGED
|
@@ -10,7 +10,6 @@ import { RuntimeArchiveStore } from "../runtime/archive-store.js";
|
|
|
10
10
|
import { readNativeConfig, runtimeStateRoot, writeNativeConfig } from "../runtime/native/config.js";
|
|
11
11
|
import { MAX_IMPORT_CONCURRENCY, DEFAULT_IMPORT_CONCURRENCY } from "../runtime/native/ingest.js";
|
|
12
12
|
import { installPiExtension, piExtensionState } from "../runtime/native/install.js";
|
|
13
|
-
import { nativeSyncHarnesses } from "../runtime/harnesses/index.js";
|
|
14
13
|
import { controlRuntimeInstance, requestRuntimeInstance, runtimeInstanceDirectory } from "../runtime/instance.js";
|
|
15
14
|
import { atLeastLevel, diagnosticLevels, formatDiagnostic, formatImportProgress, formatNativeSync, printRuntimeSummary } from "../runtime/presentation.js";
|
|
16
15
|
import { readRuntimeDiagnosticEvents, RuntimeDiagnosticReader, runtimeDiagnosticsDirectory, serializeDiagnosticError } from "../runtime/diagnostics.js";
|
|
@@ -19,16 +18,6 @@ const reportFailure = (cause) => {
|
|
|
19
18
|
process.stderr.write(`Runtime failed: ${serializeDiagnosticError(cause).message}\n`);
|
|
20
19
|
process.exitCode = 1;
|
|
21
20
|
};
|
|
22
|
-
const SYNCED = nativeSyncHarnesses(LOCAL_HARNESSES);
|
|
23
|
-
const SYNCED_LABELS = SYNCED.map((harness) => LOCAL_HARNESS_SPECS[harness].label).join(" or ");
|
|
24
|
-
/** Native sync and import cover only harnesses whose terminal conversations sync. */
|
|
25
|
-
function parseSyncedHarnesses(values) {
|
|
26
|
-
const harnesses = parseRuntimeHarnesses(values);
|
|
27
|
-
const unsynced = harnesses.filter((harness) => !SYNCED.includes(harness));
|
|
28
|
-
if (unsynced.length)
|
|
29
|
-
throw new Error(`${unsynced.map((harness) => LOCAL_HARNESS_SPECS[harness].label).join(", ")} conversations do not sync; native sync covers ${SYNCED_LABELS}`);
|
|
30
|
-
return harnesses;
|
|
31
|
-
}
|
|
32
21
|
const megabytes = (bytes) => `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
33
22
|
/** The bound Space of a directory, with its running Runtime; import and attach act through it. */
|
|
34
23
|
async function boundRuntime(program, dir, space) {
|
|
@@ -97,14 +86,14 @@ export function registerRuntime(program) {
|
|
|
97
86
|
runtime.command("detach")
|
|
98
87
|
.description("Pause native sync; keep all data")
|
|
99
88
|
.option("-s, --space <space>", "Target Space: ID, slug, or username/slug")
|
|
100
|
-
.option("--harness <name>", `${
|
|
89
|
+
.option("--harness <name>", `${LOCAL_HARNESSES.join(", ")}; repeatable`, (value, previous) => [...previous, value], [])
|
|
101
90
|
.option("--json", "JSON output")
|
|
102
91
|
.action(async (options) => {
|
|
103
92
|
try {
|
|
104
93
|
const { identity, spaceId, directory } = await boundRuntime(program, undefined, options.space);
|
|
105
94
|
const stateRoot = runtimeStateRoot(spaceId);
|
|
106
95
|
const config = await readNativeConfig(stateRoot, identity);
|
|
107
|
-
const paused = options.harness.length ?
|
|
96
|
+
const paused = options.harness.length ? parseRuntimeHarnesses(options.harness) : config?.harnesses ?? [...LOCAL_HARNESSES];
|
|
108
97
|
const harnesses = (config?.harnesses ?? []).filter((harness) => !paused.includes(harness));
|
|
109
98
|
if (config)
|
|
110
99
|
await writeNativeConfig(stateRoot, { ...config, harnesses, codexShared: harnesses.includes("codex") && config.codexShared });
|
|
@@ -144,7 +133,7 @@ export function registerRuntime(program) {
|
|
|
144
133
|
throw new Error(`Use a concurrency from 1 to ${MAX_IMPORT_CONCURRENCY}`);
|
|
145
134
|
const { spaceId, root, directory } = await boundRuntime(program, dir, options.space);
|
|
146
135
|
// The Runtime does the work, so an import survives this command: Ctrl-C only pauses it.
|
|
147
|
-
const filter = { harnesses: options.harness.length ?
|
|
136
|
+
const filter = { harnesses: options.harness.length ? parseRuntimeHarnesses(options.harness) : [], ...(options.session ? { nativeSessionId: options.session } : {}) };
|
|
148
137
|
const control = async (command, timeoutMs = 60_000) => {
|
|
149
138
|
const response = await controlRuntimeInstance(directory, "import", { ...filter, command, concurrency }, timeoutMs);
|
|
150
139
|
if (!response)
|
|
@@ -4,6 +4,7 @@ import { RUNTIME_MAX_FRAME_BYTES, RUNTIME_PROTOCOL_VERSION, runtimeCommandSchema
|
|
|
4
4
|
import { describeError } from "../network-error.js";
|
|
5
5
|
import { executeTurn } from "./native/execution.js";
|
|
6
6
|
import { ContextRequiredError } from "./native/results.js";
|
|
7
|
+
import { SizeLimitError } from "./native/transcript.js";
|
|
7
8
|
import { ProcessCleanupUncertainError } from "./process-group.js";
|
|
8
9
|
import { serializeDiagnosticError, } from "./diagnostics.js";
|
|
9
10
|
export async function serveRuntime(options) {
|
|
@@ -11,6 +12,11 @@ export async function serveRuntime(options) {
|
|
|
11
12
|
let backoff = 500;
|
|
12
13
|
let attempt = 0;
|
|
13
14
|
let conflictSince = null;
|
|
15
|
+
// Executions outlive a single transport so a dropped socket reconnects immediately
|
|
16
|
+
// instead of waiting for in-flight turns to finish.
|
|
17
|
+
const executions = new Map();
|
|
18
|
+
const seen = new Set();
|
|
19
|
+
const announce = { current: null };
|
|
14
20
|
const uploads = new AbortController();
|
|
15
21
|
const uploadSignal = AbortSignal.any([options.signal, uploads.signal]);
|
|
16
22
|
const log = (level, event, data, context) => options.diagnostics?.log(level, event, data, context);
|
|
@@ -33,12 +39,16 @@ export async function serveRuntime(options) {
|
|
|
33
39
|
while (!options.signal.aborted) {
|
|
34
40
|
attempt += 1;
|
|
35
41
|
let readyAt = 0;
|
|
42
|
+
const connectStartedAt = Date.now();
|
|
36
43
|
let outcome;
|
|
37
44
|
try {
|
|
38
45
|
outcome = await connect({
|
|
39
46
|
...options,
|
|
40
47
|
runtimeId,
|
|
41
48
|
attempt,
|
|
49
|
+
executions,
|
|
50
|
+
seen,
|
|
51
|
+
announce,
|
|
42
52
|
onReady: () => {
|
|
43
53
|
readyAt = Date.now();
|
|
44
54
|
conflictSince = null;
|
|
@@ -72,18 +82,40 @@ export async function serveRuntime(options) {
|
|
|
72
82
|
attempt: attempt || 1,
|
|
73
83
|
delayMs: backoff,
|
|
74
84
|
outcome: outcome.kind,
|
|
85
|
+
connectedMs: Date.now() - connectStartedAt,
|
|
86
|
+
readyMs: readyAt ? Date.now() - readyAt : null,
|
|
87
|
+
activeExecutions: executions.size,
|
|
75
88
|
});
|
|
76
89
|
await delay(backoff / 2 + Math.random() * backoff / 2, undefined, { signal: options.signal }).catch(() => undefined);
|
|
77
90
|
backoff = Math.min(30_000, backoff * 2);
|
|
78
91
|
}
|
|
79
92
|
}
|
|
80
93
|
finally {
|
|
94
|
+
for (const execution of executions.values())
|
|
95
|
+
execution.controller.abort();
|
|
96
|
+
await Promise.allSettled([...executions.values()].map((entry) => entry.promise));
|
|
81
97
|
clearInterval(timer);
|
|
82
98
|
uploads.abort();
|
|
83
99
|
await flush();
|
|
84
100
|
log("info", "runtime.stopped", { runtimeId });
|
|
85
101
|
}
|
|
86
102
|
}
|
|
103
|
+
function frameIdentity(frame) {
|
|
104
|
+
const value = frame;
|
|
105
|
+
const event = value?.event;
|
|
106
|
+
const pick = (input) => typeof input === "string" ? input : undefined;
|
|
107
|
+
return {
|
|
108
|
+
frameType: pick(value?.type),
|
|
109
|
+
requestId: pick(value?.requestId),
|
|
110
|
+
eventType: pick(event?.type),
|
|
111
|
+
sessionId: pick(event?.sessionId) ?? pick(event?.input?.sessionId),
|
|
112
|
+
turnId: pick(event?.turnId) ?? pick(event?.input?.turnId),
|
|
113
|
+
harness: pick(event?.input?.harness),
|
|
114
|
+
nativeSessionId: pick(event?.input?.nativeSessionId),
|
|
115
|
+
turnCount: Array.isArray(event?.input?.turns) ? event.input.turns.length : undefined,
|
|
116
|
+
turnIds: Array.isArray(event?.input?.turns) ? event.input.turns.slice(0, 10).map((turn) => pick(turn?.turnId)).filter(Boolean) : undefined,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
87
119
|
function executionContext(connectionId, input) {
|
|
88
120
|
return {
|
|
89
121
|
connectionId,
|
|
@@ -112,11 +144,13 @@ async function connect(options) {
|
|
|
112
144
|
const runtimeUrl = new URL(options.url);
|
|
113
145
|
runtimeUrl.searchParams.set("runtimeId", options.runtimeId);
|
|
114
146
|
const socket = new WebSocket(runtimeUrl.toString());
|
|
115
|
-
const active =
|
|
116
|
-
const seen =
|
|
147
|
+
const active = options.executions;
|
|
148
|
+
const seen = options.seen;
|
|
117
149
|
const disconnected = new AbortController();
|
|
118
150
|
const contexts = new Map();
|
|
119
151
|
let lastHeartbeat = Date.now();
|
|
152
|
+
let openedAt = 0;
|
|
153
|
+
let readyAt = 0;
|
|
120
154
|
let connectionId = null;
|
|
121
155
|
let fatal = false;
|
|
122
156
|
let fatalCode = 0;
|
|
@@ -137,8 +171,10 @@ async function connect(options) {
|
|
|
137
171
|
const data = JSON.stringify(frame);
|
|
138
172
|
const bytes = Buffer.byteLength(data);
|
|
139
173
|
if (bytes > RUNTIME_MAX_FRAME_BYTES) {
|
|
140
|
-
|
|
141
|
-
|
|
174
|
+
const identity = frameIdentity(frame);
|
|
175
|
+
const execution = identity.requestId ? active.get(identity.requestId) : undefined;
|
|
176
|
+
log("error", "runtime.frame_too_large", { bytes, maxBytes: RUNTIME_MAX_FRAME_BYTES, ...identity }, execution ? executionContext(connectionId, execution) : { connectionId });
|
|
177
|
+
throw new SizeLimitError("Runtime frame exceeds transfer limit");
|
|
142
178
|
}
|
|
143
179
|
socket.send(data);
|
|
144
180
|
};
|
|
@@ -173,8 +209,13 @@ async function connect(options) {
|
|
|
173
209
|
log("error", "runtime.heartbeat_timeout", {
|
|
174
210
|
ageMs: heartbeatAgeMs,
|
|
175
211
|
activeExecutions: active.size,
|
|
212
|
+
readyState: socket.readyState,
|
|
213
|
+
bufferedBytes: socket.bufferedAmount,
|
|
176
214
|
}, { connectionId });
|
|
177
215
|
closeTransport();
|
|
216
|
+
// A half-open TCP path may never deliver the close event; stop waiting so reconnect can start.
|
|
217
|
+
if (heartbeatAgeMs > 45_000)
|
|
218
|
+
handleClose({ code: 1006, reason: "Runtime heartbeat timed out", wasClean: false }, true);
|
|
178
219
|
}
|
|
179
220
|
else if (connectionId) {
|
|
180
221
|
try {
|
|
@@ -206,52 +247,108 @@ async function connect(options) {
|
|
|
206
247
|
});
|
|
207
248
|
}
|
|
208
249
|
}, 10_000);
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
250
|
+
const announceHere = (execution) => {
|
|
251
|
+
try {
|
|
252
|
+
send({ type: "runtime.recovery", executions: [execution] });
|
|
253
|
+
log("info", "runtime.detached_result_announced", undefined, executionContext(connectionId, execution));
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
// The next ready connection reports every pending receipt.
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
/** Replays a saved result, or answers explicitly that none is available; never leaves the server waiting. */
|
|
260
|
+
const replaySaved = async (requestId, input, context, executionRequestId) => {
|
|
261
|
+
let saved = null;
|
|
262
|
+
try {
|
|
263
|
+
saved = await options.executor.results.recover(input, executionRequestId);
|
|
264
|
+
}
|
|
265
|
+
catch (error) {
|
|
266
|
+
log("error", "runtime.recovery_failed", { error: serializeDiagnosticError(error) }, context);
|
|
267
|
+
}
|
|
268
|
+
if (saved)
|
|
269
|
+
for (const event of saved.events)
|
|
270
|
+
send({ type: "runtime.event", requestId, event });
|
|
271
|
+
else
|
|
272
|
+
send({ type: "runtime.event", requestId, event: { type: "turn.error", uncertain: true, message: "Result unavailable; files retained" } });
|
|
273
|
+
};
|
|
274
|
+
let closeHandled = false;
|
|
275
|
+
let resolveClosed = () => { };
|
|
276
|
+
const closed = new Promise((resolve) => { resolveClosed = resolve; });
|
|
277
|
+
const handleClose = (event, forced = false) => {
|
|
278
|
+
if (closeHandled)
|
|
279
|
+
return;
|
|
280
|
+
closeHandled = true;
|
|
281
|
+
unauthorized = event.code === 4401;
|
|
282
|
+
fatal = [4400, 4403].includes(event.code);
|
|
283
|
+
if (fatal) {
|
|
284
|
+
fatalCode = event.code;
|
|
285
|
+
fatalReason = event.reason;
|
|
286
|
+
}
|
|
287
|
+
conflict = event.code === 4409;
|
|
288
|
+
disconnected.abort();
|
|
289
|
+
const now = Date.now();
|
|
290
|
+
log(options.signal.aborted ? "debug" : fatal || conflict ? "error" : "warn", fatal ? "runtime.websocket.rejected" : "runtime.websocket.closed", {
|
|
291
|
+
code: event.code,
|
|
292
|
+
reason: event.reason,
|
|
293
|
+
wasClean: event.wasClean,
|
|
294
|
+
forced,
|
|
295
|
+
durationMs: now - connectedAt,
|
|
296
|
+
openMs: openedAt ? now - openedAt : null,
|
|
297
|
+
readyMs: readyAt ? now - readyAt : null,
|
|
298
|
+
lastInboundAgeMs: now - lastHeartbeat,
|
|
299
|
+
bufferedBytes: socket.bufferedAmount,
|
|
300
|
+
activeExecutions: active.size,
|
|
301
|
+
fatal,
|
|
302
|
+
conflict,
|
|
303
|
+
}, { connectionId });
|
|
304
|
+
nativeChannelReady = false;
|
|
305
|
+
for (const pending of nativePending.values()) {
|
|
306
|
+
clearTimeout(pending.timer);
|
|
307
|
+
pending.reject(new Error("Runtime native channel disconnected"));
|
|
308
|
+
}
|
|
309
|
+
nativePending.clear();
|
|
310
|
+
options.onDisconnected?.();
|
|
311
|
+
const executionMustStop = options.signal.aborted || fatal || conflict;
|
|
312
|
+
if (options.announce.current === announceHere)
|
|
313
|
+
options.announce.current = null;
|
|
314
|
+
for (const [requestId, execution] of active) {
|
|
315
|
+
if (executionMustStop) {
|
|
316
|
+
log("error", "runtime.execution_transport_invalidated", { code: event.code, reason: event.reason }, executionContext(connectionId, execution));
|
|
317
|
+
execution.controller.abort();
|
|
216
318
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
code: event.code,
|
|
221
|
-
reason: event.reason,
|
|
222
|
-
durationMs: Date.now() - connectedAt,
|
|
223
|
-
activeExecutions: active.size,
|
|
224
|
-
fatal,
|
|
225
|
-
conflict,
|
|
226
|
-
}, { connectionId });
|
|
227
|
-
nativeChannelReady = false;
|
|
228
|
-
for (const pending of nativePending.values()) {
|
|
229
|
-
clearTimeout(pending.timer);
|
|
230
|
-
pending.reject(new Error("Runtime native channel disconnected"));
|
|
319
|
+
else if (execution.recovery) {
|
|
320
|
+
// The server re-asks on the next connection; a stale waiter must not hold a slot or its request id.
|
|
321
|
+
execution.controller.abort();
|
|
231
322
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
execution.controller.abort();
|
|
323
|
+
else if (!execution.detached) {
|
|
324
|
+
execution.detached = true;
|
|
325
|
+
log("warn", "runtime.execution_transport_detached", { code: event.code, reason: event.reason }, executionContext(connectionId, execution));
|
|
326
|
+
// The server may have stopped asking by the time this finishes; a saved result is announced again.
|
|
327
|
+
void execution.promise.finally(() => {
|
|
328
|
+
if (!execution.result || active.get(requestId) !== execution)
|
|
329
|
+
return;
|
|
330
|
+
options.announce.current?.({ sessionId: execution.sessionId, turnId: execution.turnId, harness: execution.harness });
|
|
331
|
+
});
|
|
242
332
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
}
|
|
333
|
+
}
|
|
334
|
+
resolveClosed();
|
|
335
|
+
};
|
|
336
|
+
socket.addEventListener("close", (event) => handleClose(event), { once: true });
|
|
246
337
|
socket.addEventListener("error", (event) => {
|
|
247
|
-
const detail = event
|
|
338
|
+
const detail = event;
|
|
248
339
|
log("warn", "runtime.websocket.error", {
|
|
249
340
|
type: event.type,
|
|
250
|
-
|
|
341
|
+
message: typeof detail.message === "string" && detail.message ? detail.message : undefined,
|
|
342
|
+
readyState: socket.readyState,
|
|
343
|
+
opened: openedAt > 0,
|
|
344
|
+
ready: readyAt > 0,
|
|
345
|
+
lastInboundAgeMs: Date.now() - lastHeartbeat,
|
|
346
|
+
error: serializeDiagnosticError(detail.error ?? new Error("WebSocket error")),
|
|
251
347
|
}, { connectionId });
|
|
252
348
|
socket.close();
|
|
253
349
|
});
|
|
254
350
|
socket.addEventListener("open", () => {
|
|
351
|
+
openedAt = Date.now();
|
|
255
352
|
try {
|
|
256
353
|
log("info", "runtime.websocket.open", {
|
|
257
354
|
attempt: options.attempt,
|
|
@@ -289,14 +386,17 @@ async function connect(options) {
|
|
|
289
386
|
if (connectionId)
|
|
290
387
|
throw new Error("Runtime connection identity changed");
|
|
291
388
|
connectionId = frame.connectionId;
|
|
389
|
+
readyAt = Date.now();
|
|
292
390
|
clearTimeout(readyTimer);
|
|
293
391
|
log("info", "runtime.ready", {
|
|
294
392
|
connectionId,
|
|
295
|
-
durationMs:
|
|
393
|
+
durationMs: readyAt - connectedAt,
|
|
394
|
+
detachedExecutions: [...active.values()].filter((entry) => entry.detached).length,
|
|
296
395
|
}, { connectionId });
|
|
297
396
|
nativeChannelReady = true;
|
|
298
397
|
options.onNativeChannel?.(sendNative);
|
|
299
398
|
options.onReady();
|
|
399
|
+
options.announce.current = announceHere;
|
|
300
400
|
let recoveryBatch = 0;
|
|
301
401
|
for await (const executions of options.executor.results.pendingBatches()) {
|
|
302
402
|
recoveryBatch += 1;
|
|
@@ -391,10 +491,7 @@ async function connect(options) {
|
|
|
391
491
|
if (previous.sessionId !== identity.sessionId || previous.turnId !== identity.turnId || previous.harness !== identity.harness)
|
|
392
492
|
throw new Error("Runtime recovery identity changed");
|
|
393
493
|
await previous.promise;
|
|
394
|
-
|
|
395
|
-
if (saved)
|
|
396
|
-
for (const event of saved.events)
|
|
397
|
-
send({ type: "runtime.event", requestId: frame.requestId, event });
|
|
494
|
+
await replaySaved(frame.requestId, identity, context);
|
|
398
495
|
return;
|
|
399
496
|
}
|
|
400
497
|
const running = [...active].find(([, entry]) => entry.turnId === identity.turnId && entry.sessionId === identity.sessionId && entry.harness === identity.harness);
|
|
@@ -404,14 +501,26 @@ async function connect(options) {
|
|
|
404
501
|
traceContext: frame.traceContext,
|
|
405
502
|
controller: new AbortController(),
|
|
406
503
|
promise: Promise.resolve(),
|
|
504
|
+
recovery: true,
|
|
407
505
|
};
|
|
408
506
|
active.set(frame.requestId, recovery);
|
|
409
507
|
recovery.promise = (async () => {
|
|
410
508
|
try {
|
|
411
509
|
if (running) {
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
510
|
+
// A detached execution was not superseded by this host; wait for its result instead of discarding it.
|
|
511
|
+
if (running[1].detached) {
|
|
512
|
+
log("info", "runtime.recovery_waiting_detached", { runningRequestId: running[0] }, context);
|
|
513
|
+
// A recovery the server gave up on must not hold an execution slot until the Turn ends.
|
|
514
|
+
const stopped = recovery.controller.signal;
|
|
515
|
+
await Promise.race([running[1].promise, new Promise((resolve) => stopped.addEventListener("abort", () => resolve(), { once: true }))]);
|
|
516
|
+
stopped.throwIfAborted();
|
|
517
|
+
}
|
|
518
|
+
else {
|
|
519
|
+
running[1].controller.abort();
|
|
520
|
+
await running[1].promise;
|
|
521
|
+
}
|
|
522
|
+
if (active.get(running[0]) === running[1])
|
|
523
|
+
active.delete(running[0]);
|
|
415
524
|
}
|
|
416
525
|
const saved = await options.executor.results.recover(identity);
|
|
417
526
|
recovery.controller.signal.throwIfAborted();
|
|
@@ -433,7 +542,8 @@ async function connect(options) {
|
|
|
433
542
|
// The next connection can read the same result.
|
|
434
543
|
}
|
|
435
544
|
}
|
|
436
|
-
active.
|
|
545
|
+
if (active.get(frame.requestId) === recovery)
|
|
546
|
+
active.delete(frame.requestId);
|
|
437
547
|
}
|
|
438
548
|
})();
|
|
439
549
|
return;
|
|
@@ -449,11 +559,9 @@ async function connect(options) {
|
|
|
449
559
|
if (previous) {
|
|
450
560
|
if (previous.sessionId !== frame.input.sessionId || previous.turnId !== frame.input.turnId || previous.harness !== frame.input.harness)
|
|
451
561
|
throw new Error("Runtime execution identity changed");
|
|
562
|
+
log("info", "runtime.execution_resumed", { detached: previous.detached === true, settled: Boolean(previous.result) }, executionContext(connectionId, frame.input));
|
|
452
563
|
await previous.promise;
|
|
453
|
-
|
|
454
|
-
if (saved)
|
|
455
|
-
for (const event of saved.events)
|
|
456
|
-
send({ type: "runtime.event", requestId: frame.requestId, event });
|
|
564
|
+
await replaySaved(frame.requestId, frame.input, context, frame.requestId);
|
|
457
565
|
return;
|
|
458
566
|
}
|
|
459
567
|
const requestContext = async (executionSignal) => {
|
|
@@ -601,7 +709,6 @@ async function connect(options) {
|
|
|
601
709
|
shutdown();
|
|
602
710
|
try {
|
|
603
711
|
await closed;
|
|
604
|
-
await Promise.allSettled([...active.values()].map((entry) => entry.promise));
|
|
605
712
|
}
|
|
606
713
|
finally {
|
|
607
714
|
clearTimeout(readyTimer);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ContentBlock, LocalHarness } from "@neta-art/cohub";
|
|
2
|
+
import type { JsonRecord } from "../json-rpc.js";
|
|
3
|
+
import { type NativeTranscriptTurn, type TranscriptLine } from "../native/transcript.js";
|
|
4
|
+
import type { SessionHeader } from "./types.js";
|
|
5
|
+
/**
|
|
6
|
+
* `<data root>/projects/<project>/<session id>.jsonl`; sub-agent files live in deeper directories. A
|
|
7
|
+
* subdirectory's project name extends its parent's, so a prefix admits them; the header decides ownership.
|
|
8
|
+
*/
|
|
9
|
+
export declare function projectTranscript(path: string, root: string, project: string): boolean;
|
|
10
|
+
export declare function projectDirectory(path: string, root: string, project: string): boolean;
|
|
11
|
+
/** The session id is the file name; the first record carrying a `cwd` names the project. */
|
|
12
|
+
export declare function chainedSessionHeader(path: string, harness: LocalHarness, signal?: AbortSignal): Promise<SessionHeader | null>;
|
|
13
|
+
export declare function readChainedLines(path: string, signal?: AbortSignal): Promise<{
|
|
14
|
+
lines: TranscriptLine[];
|
|
15
|
+
prefixes: Map<number, string>;
|
|
16
|
+
}>;
|
|
17
|
+
/**
|
|
18
|
+
* The branch ending at the file's last linked record, oldest first. These formats only append, so a
|
|
19
|
+
* parent is the latest earlier record with that id: a rewound or repeated id never forms a cycle.
|
|
20
|
+
*/
|
|
21
|
+
export declare function chainBranch(lines: TranscriptLine[], id: (value: JsonRecord) => string, parent: (value: JsonRecord) => string): TranscriptLine[];
|
|
22
|
+
export declare function startTurn(line: TranscriptLine, key: string, parentKey: string | null, userContent: ContentBlock[], cloudTurnId: string): NativeTranscriptTurn;
|
|
23
|
+
/** Every branch record extends the Turn's bytes; only conversation records extend its content. */
|
|
24
|
+
export declare function extendTurn(turn: NativeTranscriptTurn, line: TranscriptLine, content: boolean): void;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { createReadStream } from "node:fs";
|
|
2
|
+
import { basename, dirname } from "node:path";
|
|
3
|
+
import { iso, readFirstMatchingRecord, readTranscriptLines } from "../native/transcript.js";
|
|
4
|
+
import { text } from "./process.js";
|
|
5
|
+
/**
|
|
6
|
+
* `<data root>/projects/<project>/<session id>.jsonl`; sub-agent files live in deeper directories. A
|
|
7
|
+
* subdirectory's project name extends its parent's, so a prefix admits them; the header decides ownership.
|
|
8
|
+
*/
|
|
9
|
+
export function projectTranscript(path, root, project) {
|
|
10
|
+
return path.endsWith(".jsonl") && projectDirectory(dirname(path), root, project);
|
|
11
|
+
}
|
|
12
|
+
export function projectDirectory(path, root, project) {
|
|
13
|
+
return dirname(path) === root && basename(path).startsWith(project);
|
|
14
|
+
}
|
|
15
|
+
/** The session id is the file name; the first record carrying a `cwd` names the project. */
|
|
16
|
+
export async function chainedSessionHeader(path, harness, signal) {
|
|
17
|
+
const value = await readFirstMatchingRecord(createReadStream(path, { signal }), (entry) => Boolean(text(entry.cwd)) && Boolean(text(entry.sessionId)));
|
|
18
|
+
return value ? { harness, nativeSessionId: basename(path, ".jsonl"), cwd: text(value.cwd), thread: "user" } : null;
|
|
19
|
+
}
|
|
20
|
+
export async function readChainedLines(path, signal) {
|
|
21
|
+
const read = await readTranscriptLines(createReadStream(path, { signal }));
|
|
22
|
+
if (!read.lines.length)
|
|
23
|
+
throw new Error("Native transcript is empty");
|
|
24
|
+
return read;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The branch ending at the file's last linked record, oldest first. These formats only append, so a
|
|
28
|
+
* parent is the latest earlier record with that id: a rewound or repeated id never forms a cycle.
|
|
29
|
+
*/
|
|
30
|
+
export function chainBranch(lines, id, parent) {
|
|
31
|
+
const latest = new Map();
|
|
32
|
+
const parents = [];
|
|
33
|
+
let leaf = -1;
|
|
34
|
+
lines.forEach((line, index) => {
|
|
35
|
+
const own = id(line.value);
|
|
36
|
+
parents[index] = -1;
|
|
37
|
+
if (!own)
|
|
38
|
+
return;
|
|
39
|
+
const before = latest.get(parent(line.value));
|
|
40
|
+
parents[index] = before ?? -1;
|
|
41
|
+
latest.set(own, index);
|
|
42
|
+
leaf = index;
|
|
43
|
+
});
|
|
44
|
+
const branch = [];
|
|
45
|
+
for (let index = leaf; index >= 0; index = parents[index] ?? -1)
|
|
46
|
+
branch.push(lines[index]);
|
|
47
|
+
return branch.reverse();
|
|
48
|
+
}
|
|
49
|
+
export function startTurn(line, key, parentKey, userContent, cloudTurnId) {
|
|
50
|
+
return {
|
|
51
|
+
key, parentKey, ...(cloudTurnId ? { cloudTurnId } : {}), userContent, messages: [], startedAt: iso(line.value.timestamp),
|
|
52
|
+
startBytes: line.startBytes, endBytes: line.endBytes, contentEndBytes: line.endBytes, boundaries: {}, sha256: line.sha256, result: null,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/** Every branch record extends the Turn's bytes; only conversation records extend its content. */
|
|
56
|
+
export function extendTurn(turn, line, content) {
|
|
57
|
+
turn.endBytes = line.endBytes;
|
|
58
|
+
turn.sha256 = line.sha256;
|
|
59
|
+
turn.boundaries[line.endBytes] = line.sha256;
|
|
60
|
+
if (content)
|
|
61
|
+
turn.contentEndBytes = line.endBytes;
|
|
62
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type NativeTranscript } from "../native/transcript.js";
|
|
2
|
+
export declare const CLAUDE_REPLY_ENDED: string[];
|
|
3
|
+
/**
|
|
4
|
+
* Claude Code appends one record per content block, linked by `parentUuid` (`logicalParentUuid`
|
|
5
|
+
* across a compaction). A Turn starts at a prompt; its tool results and an interruption share the
|
|
6
|
+
* prompt's `promptId`. Interactive sessions record each end (`turn_duration`), `-p` sessions do not.
|
|
7
|
+
*/
|
|
8
|
+
export declare function readClaudeTranscript(path: string, nativeSessionId: string, signal?: AbortSignal): Promise<NativeTranscript>;
|