@neta-art/cohub-cli 10.6.0 → 10.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +8 -5
  2. package/dist/board-kernel.js +207 -119
  3. package/dist/commands/boards/examples.js +16 -16
  4. package/dist/commands/boards.js +1 -1
  5. package/dist/commands/generations.js +23 -2
  6. package/dist/commands/runtime.js +3 -14
  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 +79 -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 +7 -1
  21. package/dist/runtime/harnesses/stream-json.js +35 -12
  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 +14 -0
  33. package/dist/runtime/native/ingest.js +51 -5
  34. package/dist/runtime/native/sessions.js +6 -1
  35. package/dist/runtime/native/transcript.d.ts +19 -0
  36. package/dist/runtime/native/transcript.js +33 -2
  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")
@@ -32,6 +32,20 @@ function parseValue(value) {
32
32
  }
33
33
  return value;
34
34
  }
35
+ function parseTextInputs(values) {
36
+ return values.map((value) => {
37
+ const index = value.indexOf("=");
38
+ if (index < 0)
39
+ return error("Invalid --text", "Pass --text <role>=<text>.");
40
+ const role = value.slice(0, index).trim();
41
+ if (!role)
42
+ return error("Invalid --text", "Pass a role: --text <role>=<text>.");
43
+ const text = value.slice(index + 1);
44
+ if (!text)
45
+ return error("Invalid --text", 'Pass text after "=": --text <role>=<text>.');
46
+ return { role, text };
47
+ });
48
+ }
35
49
  function parseParams(param, parameters) {
36
50
  const result = parameters ? JSON.parse(parameters) : {};
37
51
  for (const item of param ?? []) {
@@ -260,8 +274,9 @@ export function registerGenerations(program) {
260
274
  program
261
275
  .command("generate")
262
276
  .description("Generate multimodal outputs")
263
- .argument("<prompt>", "Prompt text")
277
+ .argument("[prompt]", "Prompt text; optional when --text is given")
264
278
  .requiredOption("-m, --model <model>", "Multimodal model ID from `cohub models ls --model-type multimodal`")
279
+ .option("--text <role=text>", "Text input with a role from the model's text inputs (`cohub models show <model>`); repeatable, split at the first =, text sent unchanged", collect, [])
265
280
  .option("--image <path-or-url>", "Image input file path or URL; prefix with first_frame=, last_frame=, or reference_image= when needed; repeatable", collect, [])
266
281
  .option("--video <path-or-url>", "Video input file path or URL; prefix with reference_video= when needed; repeatable", collect, [])
267
282
  .option("--audio <path-or-url>", "Audio input file path or URL; prefix with reference_audio= when needed; repeatable", collect, [])
@@ -285,10 +300,15 @@ Examples:
285
300
  cohub -s <space-id> generate "Smooth transition" -m seedance-2-0-fast --image first_frame=https://example.com/first.png --image last_frame=https://example.com/last.png
286
301
  cohub -s <space-id> generate "Use these references" -m seedance-2-0-fast --image reference_image=https://example.com/a.png --image reference_image=https://example.com/b.png
287
302
  cohub -s <space-id> generate "Lip-sync to the reference take" -m seedance-2-0-fast --image reference_image=https://example.com/portrait.png --audio reference_audio=https://example.com/speech.mp3
303
+ cohub -s <space-id> generate -m song2 --text "prompt=upbeat synthwave" --text "lyrics=[Verse] Neon lights" --param seed=42
288
304
  cohub -s <space-id> generate "A calm lake" -m <model> --async
289
305
  `)
290
306
  .action(async (prompt, opts) => {
291
307
  try {
308
+ const texts = parseTextInputs(opts.text);
309
+ if (prompt === undefined && texts.length === 0) {
310
+ return error("Missing prompt", "Pass a prompt or at least one --text <role>=<text>.");
311
+ }
292
312
  const spaceId = await resolveSpace(program, { home: true });
293
313
  const sessionId = envValue("COHUB_SESSION_ID");
294
314
  const inputs = await Promise.all([
@@ -330,7 +350,8 @@ Examples:
330
350
  return asset.publicUrl;
331
351
  };
332
352
  const content = [
333
- { type: "text", text: prompt },
353
+ ...(prompt === undefined ? [] : [{ type: "text", text: prompt }]),
354
+ ...texts.map(({ role, text }) => ({ type: "text", text, meta: { role } })),
334
355
  ...await Promise.all(inputs.map((input) => mediaContent(input, upload))),
335
356
  ];
336
357
  const created = await client.generations.create({
@@ -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)
@@ -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>;
@@ -0,0 +1,138 @@
1
+ import { record } from "../json-rpc.js";
2
+ import { iso } from "../native/transcript.js";
3
+ import { chainBranch, extendTurn, readChainedLines, startTurn } from "./chained-transcript.js";
4
+ import { array, text } from "./process.js";
5
+ import { streamContent, streamUsage } from "./stream-json.js";
6
+ const COMMAND = /^<command-name>\s*([^<]*)<\/command-name>/;
7
+ const COMMAND_ARGS = /<command-args>([\s\S]*?)<\/command-args>/;
8
+ const BASH_INPUT = /^<bash-input>([\s\S]*?)<\/bash-input>/;
9
+ /** Output of a local command or `!` shell, recorded as a user message but never sent as a prompt. */
10
+ const LOCAL_OUTPUT = /^<(?:local-command-(?:stdout|stderr|caveat)|bash-(?:stdout|stderr))>/;
11
+ const INTERRUPTED = "[Request interrupted by user";
12
+ export const CLAUDE_REPLY_ENDED = ["end_turn", "stop_sequence", "max_tokens", "refusal", "aborted", "error"];
13
+ const nonEmpty = (block) => !(block.type === "text" && !block.text) && !(block.type === "thinking" && !block.thinking);
14
+ /** A local `/command` or `!shell` is shown as typed; one that never reached the model is no Turn. */
15
+ function prompt(value) {
16
+ const content = typeof value === "string" ? [{ type: "text", text: value }] : streamContent(value).filter((block) => block.type === "text" || block.type === "image");
17
+ const first = content.find((block) => block.type === "text")?.text.trimStart() ?? "";
18
+ if (LOCAL_OUTPUT.test(first) || first.startsWith(INTERRUPTED))
19
+ return null;
20
+ const command = COMMAND.exec(first);
21
+ if (command)
22
+ return { content: [{ type: "text", text: `${command[1]?.trim() ?? ""} ${COMMAND_ARGS.exec(first)?.[1]?.trim() ?? ""}`.trim() }], local: true };
23
+ const bash = BASH_INPUT.exec(first);
24
+ if (bash)
25
+ return { content: [{ type: "text", text: `!${bash[1] ?? ""}` }], local: true };
26
+ const visible = content.filter(nonEmpty);
27
+ return visible.length ? { content: visible, local: false } : null;
28
+ }
29
+ /**
30
+ * Claude Code appends one record per content block, linked by `parentUuid` (`logicalParentUuid`
31
+ * across a compaction). A Turn starts at a prompt; its tool results and an interruption share the
32
+ * prompt's `promptId`. Interactive sessions record each end (`turn_duration`), `-p` sessions do not.
33
+ */
34
+ export async function readClaudeTranscript(path, nativeSessionId, signal) {
35
+ const read = await readChainedLines(path, signal);
36
+ const branch = chainBranch(read.lines, (value) => value.isSidechain === true ? "" : text(value.uuid), (value) => text(value.parentUuid) || text(value.logicalParentUuid));
37
+ const turns = [];
38
+ const owners = new Map();
39
+ const messageIds = new Map();
40
+ let cwd = "", cloudSessionId = "";
41
+ let current = null;
42
+ let promptId = "", local = false, interrupted = false, completedAt = "";
43
+ const finish = () => {
44
+ if (!current)
45
+ return;
46
+ // A local command that never reached the model is not a conversation Turn.
47
+ if (local && !current.messages.length) {
48
+ current = null;
49
+ return;
50
+ }
51
+ const last = current.messages.at(-1);
52
+ const status = interrupted || !last || last.stopReason === "tool_use" ? "interrupted" : last.stopReason === "error" ? "failed" : "completed";
53
+ current.result = { messages: current.messages, completedAt: completedAt || current.startedAt, status };
54
+ turns.push(current);
55
+ current = null;
56
+ };
57
+ for (const line of branch) {
58
+ const value = line.value;
59
+ cwd ||= text(value.cwd);
60
+ if (value.type === "user") {
61
+ const message = record(value.message);
62
+ const results = array(message.content).map(record).filter((block) => block.type === "tool_result");
63
+ const opened = value.isMeta === true || value.isCompactSummary === true || results.length ? null : prompt(message.content);
64
+ if (opened && !(current && text(value.promptId) && text(value.promptId) === promptId)) {
65
+ finish();
66
+ const cohub = record(value.cohub);
67
+ cloudSessionId ||= text(cohub.sessionId);
68
+ // Cohub sends its Turn id as the prompt's record id; a projected record names its Turn.
69
+ current = startTurn(line, text(value.uuid), turns.at(-1)?.key ?? null, opened.content, text(cohub.turnId) || text(value.uuid));
70
+ promptId = text(value.promptId);
71
+ local = opened.local;
72
+ interrupted = false;
73
+ completedAt = current.startedAt;
74
+ owners.clear();
75
+ messageIds.clear();
76
+ continue;
77
+ }
78
+ if (!current)
79
+ continue;
80
+ for (const block of results) {
81
+ const id = text(block.tool_use_id);
82
+ const owner = owners.get(id);
83
+ if (!owner)
84
+ continue;
85
+ owner.content.push({ type: "tool_result", tool_use_id: id, content: typeof block.content === "string" ? block.content : streamContent(block.content), is_error: block.is_error === true });
86
+ }
87
+ if (array(message.content).some((block) => text(record(block).text).startsWith(INTERRUPTED)) || text(message.content).startsWith(INTERRUPTED)) {
88
+ interrupted = true;
89
+ current.ended = true;
90
+ }
91
+ completedAt = iso(value.timestamp);
92
+ extendTurn(current, line, true);
93
+ continue;
94
+ }
95
+ if (!current)
96
+ continue;
97
+ if (value.type === "assistant") {
98
+ const native = record(value.message);
99
+ const id = text(native.id);
100
+ let target = current.messages.at(-1);
101
+ if (!target || !id || messageIds.get(target) !== id) {
102
+ target = { content: [], provider: null, model: null, stopReason: null, errorMessage: null };
103
+ current.messages.push(target);
104
+ messageIds.set(target, id);
105
+ }
106
+ const content = streamContent(native.content).filter(nonEmpty);
107
+ for (const block of content) {
108
+ const index = block.type === "tool_use" ? target.content.findIndex((existing) => existing.type === "tool_use" && existing.id === block.id) : -1;
109
+ if (index >= 0)
110
+ target.content[index] = block;
111
+ else
112
+ target.content.push(block);
113
+ if (block.type === "tool_use")
114
+ owners.set(block.id, target);
115
+ }
116
+ const model = text(native.model);
117
+ if (model && model !== "<synthetic>")
118
+ target.model = model;
119
+ if (text(native.stop_reason))
120
+ target.stopReason = text(native.stop_reason);
121
+ const usage = streamUsage(native.usage, undefined);
122
+ if (usage)
123
+ target.usage = usage;
124
+ if (value.isApiErrorMessage === true) {
125
+ target.stopReason = "error";
126
+ target.errorMessage = content.flatMap((block) => block.type === "text" ? [block.text] : []).join("\n") || "API error";
127
+ }
128
+ completedAt = iso(value.timestamp);
129
+ extendTurn(current, line, true);
130
+ continue;
131
+ }
132
+ if (value.type === "system" && value.subtype === "turn_duration")
133
+ current.ended = true;
134
+ extendTurn(current, line, false);
135
+ }
136
+ finish();
137
+ return { nativeSessionId, cwd, ...(cloudSessionId ? { cloudSessionId } : {}), turns, prefixes: read.prefixes };
138
+ }
@@ -1,9 +1,15 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { homedir } from "node:os";
2
- import { join } from "node:path";
3
+ import { basename, join } from "node:path";
4
+ import { JsonRpcProcess, record } from "../json-rpc.js";
3
5
  import { copyNativeArchive } from "../native/archive.js";
4
6
  import { projectChainedSession, projectionMeta, projectionToolId, rebindSessionId } from "./chained-projection.js";
7
+ import { chainedSessionHeader, projectDirectory, projectTranscript } from "./chained-transcript.js";
8
+ import { CLAUDE_REPLY_ENDED, readClaudeTranscript } from "./claude-transcript.js";
9
+ import { array, harnessCommand, text } from "./process.js";
5
10
  import { DEFAULT_MODEL, runStreamJson, streamJsonHelp, streamJsonModel } from "./stream-json.js";
6
11
  const MAX_PROJECT_NAME = 200;
12
+ const INITIALIZE_TIMEOUT_MS = 30_000;
7
13
  const dataRoot = () => process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), ".claude");
8
14
  /**
9
15
  * Claude Code names a project directory by replacing every non-alphanumeric UTF-16 unit; a longer
@@ -19,6 +25,9 @@ export function claudeProjectName(cwd) {
19
25
  return `${name.slice(0, MAX_PROJECT_NAME)}-${Math.abs(hash).toString(36)}`;
20
26
  }
21
27
  export const claudeProjectDirectory = (cwd) => join(dataRoot(), "projects", claudeProjectName(cwd));
28
+ const projectsRoot = () => join(dataRoot(), "projects");
29
+ /** Every subdirectory's project name starts with this; a hashed long name keeps its first 200 units. */
30
+ const projectPrefix = (cwd) => cwd.replace(/[^a-zA-Z0-9]/g, "-").slice(0, MAX_PROJECT_NAME);
22
31
  const profile = {
23
32
  harness: "claude",
24
33
  label: "Claude Code",
@@ -27,6 +36,7 @@ const profile = {
27
36
  dataRoot,
28
37
  projectDirectory: claudeProjectDirectory,
29
38
  effortLevels: ["low", "medium", "high", "xhigh", "max"],
39
+ promptUuid: true,
30
40
  };
31
41
  function anthropicBlock(block) {
32
42
  if (block.type === "text")
@@ -47,11 +57,66 @@ function withAssistantIds(projection) {
47
57
  ...entry, record: { ...entry.record, message: { id: `msg_${String(entry.record.uuid).replaceAll("-", "")}`, ...entry.record.message } },
48
58
  }) };
49
59
  }
60
+ /** Claude Code's own `/model` list (what the SDK's `supportedModels()` reads), gateway-discovered models included. */
61
+ async function claudeInitialize(executable, cwd) {
62
+ const args = ["-p", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose", "--no-session-persistence", "--strict-mcp-config"];
63
+ const host = new JsonRpcProcess(...harnessCommand(executable, args, cwd), cwd, "stream");
64
+ const requestId = randomUUID();
65
+ let timer;
66
+ try {
67
+ return await new Promise((resolve, reject) => {
68
+ timer = setTimeout(() => reject(new Error("Claude Code initialize timed out")), INITIALIZE_TIMEOUT_MS);
69
+ host.onFailure(reject);
70
+ host.onEvent((event) => {
71
+ const response = record(event.response);
72
+ if (event.type !== "control_response" || response.request_id !== requestId)
73
+ return;
74
+ if (response.subtype === "success")
75
+ resolve(record(response.response));
76
+ else
77
+ reject(new Error(text(response.error) || "Claude Code initialize failed"));
78
+ });
79
+ host.write({ type: "control_request", request_id: requestId, request: { subtype: "initialize" } });
80
+ });
81
+ }
82
+ finally {
83
+ clearTimeout(timer);
84
+ await host.close();
85
+ }
86
+ }
87
+ /** Gateway models are served by `ANTHROPIC_BASE_URL`, so they carry its host as provider. */
88
+ function claudeModelCatalog(entries, baseUrl) {
89
+ let gateway = "gateway";
90
+ try {
91
+ if (baseUrl)
92
+ gateway = new URL(baseUrl).hostname.slice(0, 100) || gateway;
93
+ }
94
+ catch { /* Keep the generic name. */ }
95
+ const seen = new Set();
96
+ return entries.flatMap((value) => {
97
+ const model = record(value);
98
+ const id = text(model.value);
99
+ if (!id || id.length > 255 || seen.has(id))
100
+ return [];
101
+ seen.add(id);
102
+ const effortLevels = model.supportsEffort === true ? array(model.supportedEffortLevels).filter((level) => typeof level === "string") : [];
103
+ return [streamJsonModel(profile, id, (text(model.displayName) || id).slice(0, 255), {
104
+ reasoning: effortLevels.length > 0, effortLevels, isDefault: id === DEFAULT_MODEL,
105
+ provider: text(model.description).startsWith("From gateway") ? gateway : profile.provider,
106
+ })];
107
+ });
108
+ }
50
109
  export const claudeHarness = {
51
110
  id: "claude",
52
111
  binary: profile.binary,
53
112
  async models(executable, cwd) {
54
113
  await streamJsonHelp(profile, executable, cwd);
114
+ try {
115
+ const models = claudeModelCatalog(array((await claudeInitialize(executable, cwd)).models), process.env.ANTHROPIC_BASE_URL);
116
+ if (models.some((model) => model.isDefault))
117
+ return models;
118
+ }
119
+ catch { /* A CLI without the initialize model list keeps the account aliases. */ }
55
120
  // Aliases follow the signed-in account; Haiku takes no effort level.
56
121
  return [
57
122
  streamJsonModel(profile, DEFAULT_MODEL, "Default", { reasoning: true, isDefault: true }),
@@ -90,5 +155,17 @@ export const claudeHarness = {
90
155
  },
91
156
  },
92
157
  },
93
- nativeSync: false,
158
+ transcript: {
159
+ roots: () => [projectsRoot()],
160
+ acceptsDirectory: (path, root, cwd) => projectDirectory(path, root, projectPrefix(cwd)),
161
+ acceptsTranscript: (path, cwd) => projectTranscript(path, projectsRoot(), projectPrefix(cwd)),
162
+ header: (path, signal) => chainedSessionHeader(path, "claude", signal),
163
+ read: (path, signal) => readClaudeTranscript(path, basename(path, ".jsonl"), signal),
164
+ boundaries: ['"promptSource"', '"subtype":"turn_duration"', '"interruptedMessageId"', '"isApiErrorMessage":true', ...CLAUDE_REPLY_ENDED.map((reason) => `"stop_reason":"${reason}"`)],
165
+ turnEndRecorded: false,
166
+ replyEnded: (turn) => CLAUDE_REPLY_ENDED.includes(turn.messages.at(-1)?.stopReason ?? ""),
167
+ userFinalMarked: false,
168
+ globalTurnKeys: false,
169
+ marksCohubTurns: true,
170
+ },
94
171
  };
@@ -223,7 +223,7 @@ export const codexHarness = {
223
223
  turnEndRecorded: true,
224
224
  userFinalMarked: true,
225
225
  globalTurnKeys: true,
226
+ marksCohubTurns: true,
226
227
  archiveSource: (path, cacheDirectory) => ensurePlainCodexRollout(path, cacheDirectory),
227
228
  },
228
- nativeSync: true,
229
229
  };
@@ -0,0 +1,37 @@
1
+ import type { NativeTranscript } from "../native/transcript.js";
2
+ import type { SessionHeader } from "./types.js";
3
+ /** Chats (`store.db` and `meta.json`) live under the config directory, transcripts under the data directory. */
4
+ export declare const cursorConfigDirectory: () => string;
5
+ export declare const cursorProjectsRoot: () => string;
6
+ export declare const cursorChatsRoot: () => string;
7
+ /** Cursor's project name for a workspace path. */
8
+ export declare const cursorProjectName: (cwd: string) => string;
9
+ export declare const cursorTranscriptPath: (nativeSessionId: string, cwd: string) => string;
10
+ /** A chat's store directory is keyed by the MD5 of the directory the CLI ran in. */
11
+ export declare const cursorChatDirectory: (nativeSessionId: string, cwd: string) => string;
12
+ export declare function acceptsCursorDirectory(path: string, root: string, cwd: string): boolean;
13
+ export declare function acceptsCursorTranscript(path: string, cwd: string): boolean;
14
+ /**
15
+ * The directory a project name stands for, matched against real directories. On Windows the name
16
+ * starts with the drive letter and matching ignores case. Several directories with the same name
17
+ * (`a/b` and `a-b`) cannot be told apart, so none is chosen.
18
+ */
19
+ type Entry = {
20
+ name: string;
21
+ isDirectory(): boolean;
22
+ isSymbolicLink(): boolean;
23
+ };
24
+ export declare function projectDirectory(name: string, platform?: NodeJS.Platform, list?: (directory: string) => Promise<Entry[]>): Promise<string | null>;
25
+ /**
26
+ * The transcript does not name its project. A CLI chat's `meta.json` does; the IDE writes the same
27
+ * transcripts without one, so its project name is resolved to the directory it stands for.
28
+ */
29
+ export declare function cursorSessionHeader(path: string): Promise<SessionHeader | null>;
30
+ /**
31
+ * Cursor writes the model's messages without ids or times: a prompt (`<user_query>`, or a bare text
32
+ * that no other user line follows), one line per reply step with its tool calls (results are not
33
+ * recorded), and `turn_ended` after every Turn. A user line followed by another is context. A Turn
34
+ * is keyed by the file's checksum through its prompt, so a rewritten history never reuses a key.
35
+ */
36
+ export declare function readCursorTranscript(path: string, nativeSessionId: string, signal?: AbortSignal): Promise<NativeTranscript>;
37
+ export {};