@neta-art/cohub-cli 10.7.0 → 10.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +4 -4
  2. package/dist/board-kernel.js +276 -214
  3. package/dist/commands/boards/examples.js +16 -16
  4. package/dist/commands/boards.js +1 -1
  5. package/dist/commands/runtime.js +3 -14
  6. package/dist/runtime/connection.js +161 -54
  7. package/dist/runtime/harnesses/chained-transcript.d.ts +24 -0
  8. package/dist/runtime/harnesses/chained-transcript.js +62 -0
  9. package/dist/runtime/harnesses/claude-transcript.d.ts +8 -0
  10. package/dist/runtime/harnesses/claude-transcript.js +138 -0
  11. package/dist/runtime/harnesses/claude.js +20 -2
  12. package/dist/runtime/harnesses/codex.js +1 -1
  13. package/dist/runtime/harnesses/cursor-transcript.d.ts +37 -0
  14. package/dist/runtime/harnesses/cursor-transcript.js +183 -0
  15. package/dist/runtime/harnesses/cursor.d.ts +24 -0
  16. package/dist/runtime/harnesses/cursor.js +620 -0
  17. package/dist/runtime/harnesses/index.d.ts +1 -3
  18. package/dist/runtime/harnesses/index.js +4 -10
  19. package/dist/runtime/harnesses/pi.js +1 -1
  20. package/dist/runtime/harnesses/stream-json.d.ts +5 -1
  21. package/dist/runtime/harnesses/stream-json.js +31 -9
  22. package/dist/runtime/harnesses/types.d.ts +10 -5
  23. package/dist/runtime/harnesses/workbuddy-transcript.d.ts +8 -0
  24. package/dist/runtime/harnesses/workbuddy-transcript.js +127 -0
  25. package/dist/runtime/harnesses/workbuddy.d.ts +1 -1
  26. package/dist/runtime/harnesses/workbuddy.js +31 -7
  27. package/dist/runtime/json-rpc.d.ts +2 -0
  28. package/dist/runtime/json-rpc.js +9 -0
  29. package/dist/runtime/native/attach.d.ts +1 -1
  30. package/dist/runtime/native/attach.js +1 -4
  31. package/dist/runtime/native/execution.js +14 -3
  32. package/dist/runtime/native/ingest.d.ts +24 -2
  33. package/dist/runtime/native/ingest.js +76 -10
  34. package/dist/runtime/native/sessions.js +6 -1
  35. package/dist/runtime/native/transcript.d.ts +23 -0
  36. package/dist/runtime/native/transcript.js +40 -5
  37. package/package.json +3 -3
@@ -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: 48, fontWeight: 700 } },
14
- note: { type: "text", position: { x: 80, y: 140 }, props: { text: "Ship the beta to ten design partners, then widen.", fontSize: 20, width: 420 } },
15
- goal: { type: "shape", position: { x: 600, y: 60 }, size: { width: 240, height: 140 }, style: { stroke: "green", fill: "green", fillOpacity: 0.12, radius: 16 }, props: { text: "Beta in March" } },
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: "Frames as slides; children move and clip with their frame.",
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: 1600, height: 900 }, props: { label: "Intro" } },
36
- "s1-title": { type: "text", parent: "s1", position: { x: 120, y: 120 }, props: { text: "Special relativity", fontSize: 96, fontWeight: 700 } },
37
- "s1-sub": { type: "text", parent: "s1", position: { x: 120, y: 260 }, props: { text: "Nothing outruns light", fontSize: 40 } },
38
- s2: { type: "frame", position: { x: 1800, y: 0 }, size: { width: 1600, height: 900 }, props: { label: "Time dilation" } },
39
- "s2-title": { type: "text", parent: "s2", position: { x: 120, y: 120 }, props: { text: "Moving clocks run slow", fontSize: 72, fontWeight: 700 } },
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: 1600, height: 900 }, props: { label: "Length contraction" } },
48
- title: { type: "text", parent: "s1", position: { x: 120, y: 100 }, props: { text: "Moving rulers get shorter", fontSize: 72, fontWeight: 700 } },
49
- ruler: { type: "shape", parent: "s1", position: { x: 200, y: 420 }, size: { width: 800, height: 60 }, style: { stroke: "blue", fill: "blue", fillOpacity: 0.15 }, props: { text: "1 m" } },
50
- caption: { type: "text", parent: "s1", position: { x: 200, y: 560 }, props: { text: "L = L₀ √(1 − v²/c²)", fontSize: 44, font: "serif", reveal: 1 } },
51
- motion: { type: "arrow", parent: "s1", style: { stroke: "rose", strokeWidth: 4 }, props: { start: { x: 200, y: 360 }, end: { x: 1000, y: 360 }, label: "v → c" } },
52
- glow: { type: "effect", parent: "s1", position: { x: 1050, y: 380 }, size: { width: 160, height: 160 }, style: { fill: "amber" }, props: { kind: "glow" } },
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: 640, height: 360 }, script: { src: "scripts/wave.js", params: { amplitude: 60 } } },
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
  },
@@ -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 viewport in pixels", "1600x900")
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")
@@ -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>", `${SYNCED_LABELS}; repeatable`, (value, previous) => [...previous, value], [])
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 ? parseSyncedHarnesses(options.harness) : config?.harnesses ?? SYNCED;
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 ? parseSyncedHarnesses(options.harness) : [], ...(options.session ? { nativeSessionId: options.session } : {}) };
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 = new Map();
116
- const seen = new Set();
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
- log("error", "runtime.frame_too_large", { bytes, maxBytes: RUNTIME_MAX_FRAME_BYTES }, { connectionId });
141
- throw new Error("Runtime frame exceeds transfer limit");
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 closed = new Promise((resolve) => {
210
- socket.addEventListener("close", (event) => {
211
- unauthorized = event.code === 4401;
212
- fatal = [4400, 4403].includes(event.code);
213
- if (fatal) {
214
- fatalCode = event.code;
215
- fatalReason = event.reason;
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
- conflict = event.code === 4409;
218
- disconnected.abort();
219
- log(options.signal.aborted ? "debug" : fatal || conflict ? "error" : "warn", fatal ? "runtime.websocket.rejected" : "runtime.websocket.closed", {
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
- nativePending.clear();
233
- options.onDisconnected?.();
234
- const executionMustStop = options.signal.aborted || fatal || conflict;
235
- for (const execution of active.values()) {
236
- log(executionMustStop ? "error" : "warn", executionMustStop ? "runtime.execution_transport_invalidated" : "runtime.execution_transport_detached", {
237
- code: event.code,
238
- reason: event.reason,
239
- }, executionContext(connectionId, execution));
240
- if (executionMustStop)
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
- resolve();
244
- }, { once: true });
245
- });
333
+ }
334
+ resolveClosed();
335
+ };
336
+ socket.addEventListener("close", (event) => handleClose(event), { once: true });
246
337
  socket.addEventListener("error", (event) => {
247
- const detail = event.error;
338
+ const detail = event;
248
339
  log("warn", "runtime.websocket.error", {
249
340
  type: event.type,
250
- error: serializeDiagnosticError(detail ?? new Error("WebSocket error")),
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: Date.now() - connectedAt,
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
- const saved = await options.executor.results.recover(identity);
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
- running[1].controller.abort();
413
- await running[1].promise;
414
- active.delete(running[0]);
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.delete(frame.requestId);
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
- const saved = await options.executor.results.recover(frame.input, frame.requestId);
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>;