@neta-art/cohub-cli 10.5.0 → 10.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +8 -3
  2. package/dist/board-kernel.js +13 -10
  3. package/dist/commands/generations.js +23 -2
  4. package/dist/commands/runtime.js +21 -8
  5. package/dist/commands/spaces.js +3 -3
  6. package/dist/runtime/archive-store.d.ts +2 -2
  7. package/dist/runtime/archive-store.js +2 -2
  8. package/dist/runtime/diagnostics.d.ts +3 -3
  9. package/dist/runtime/harnesses/chained-projection.d.ts +40 -0
  10. package/dist/runtime/harnesses/chained-projection.js +95 -0
  11. package/dist/runtime/harnesses/claude.d.ts +8 -0
  12. package/dist/runtime/harnesses/claude.js +153 -0
  13. package/dist/runtime/harnesses/codex.d.ts +4 -0
  14. package/dist/runtime/harnesses/codex.js +229 -0
  15. package/dist/runtime/harnesses/index.d.ts +16 -0
  16. package/dist/runtime/harnesses/index.js +57 -0
  17. package/dist/runtime/harnesses/pi.d.ts +4 -0
  18. package/dist/runtime/harnesses/pi.js +247 -0
  19. package/dist/runtime/harnesses/process.d.ts +31 -0
  20. package/dist/runtime/harnesses/process.js +75 -0
  21. package/dist/runtime/harnesses/stream-json.d.ts +30 -0
  22. package/dist/runtime/harnesses/stream-json.js +292 -0
  23. package/dist/runtime/harnesses/types.d.ts +108 -0
  24. package/dist/runtime/harnesses/types.js +1 -0
  25. package/dist/runtime/harnesses/workbuddy.d.ts +15 -0
  26. package/dist/runtime/harnesses/workbuddy.js +99 -0
  27. package/dist/runtime/json-rpc.d.ts +7 -1
  28. package/dist/runtime/json-rpc.js +23 -2
  29. package/dist/runtime/launch.d.ts +6 -4
  30. package/dist/runtime/launch.js +21 -11
  31. package/dist/runtime/native/adapters.d.ts +11 -28
  32. package/dist/runtime/native/adapters.js +7 -75
  33. package/dist/runtime/native/archive.d.ts +9 -7
  34. package/dist/runtime/native/archive.js +99 -38
  35. package/dist/runtime/native/attach.d.ts +3 -5
  36. package/dist/runtime/native/attach.js +8 -2
  37. package/dist/runtime/native/daemon.d.ts +2 -2
  38. package/dist/runtime/native/daemon.js +5 -4
  39. package/dist/runtime/native/execution.d.ts +9 -30
  40. package/dist/runtime/native/execution.js +7 -325
  41. package/dist/runtime/native/identity.d.ts +4 -4
  42. package/dist/runtime/native/identity.js +4 -4
  43. package/dist/runtime/native/ingest.d.ts +2 -2
  44. package/dist/runtime/native/ingest.js +20 -25
  45. package/dist/runtime/native/sessions.d.ts +4 -4
  46. package/dist/runtime/native/sessions.js +12 -22
  47. package/dist/runtime/native/transcript.d.ts +3 -0
  48. package/dist/runtime/native/transcript.js +6 -5
  49. package/dist/runtime/native/translate.d.ts +2 -0
  50. package/dist/runtime/native/translate.js +34 -1
  51. package/dist/runtime/native/version.js +1 -1
  52. package/dist/runtime/presentation.js +2 -1
  53. package/dist/runtime/projection-store.js +2 -12
  54. package/dist/runtime/supervisor.d.ts +4 -4
  55. package/dist/runtime/supervisor.js +1 -1
  56. package/dist/runtime/turn-projection.d.ts +1 -1
  57. package/dist/runtime/turn-projection.js +2 -2
  58. package/package.json +2 -2
  59. package/dist/runtime/harness.d.ts +0 -26
  60. package/dist/runtime/harness.js +0 -142
package/README.md CHANGED
@@ -137,7 +137,9 @@ cohub -s home spaces files ls
137
137
 
138
138
  ## Local Runtime
139
139
 
140
- Connect one local workspace to a Space and select Pi or Codex per turn.
140
+ Connect one local workspace to a Space and select Pi, Codex, Claude Code or WorkBuddy per turn.
141
+ Claude Code (`claude`) and WorkBuddy (`codebuddy`) run Cohub Turns only; terminal sync covers Pi and Codex.
142
+ Without `--harness`, every installed harness is tried; one that cannot start is skipped with a warning.
141
143
 
142
144
  To keep using native terminals with ordinary Cohub Chats / Turns:
143
145
 
@@ -179,7 +181,7 @@ are never uploaded automatically. Use `runtime logs --json` to export a failure
179
181
  events carry the local `runtimeId` plus server `requestId` / `traceparent` when a turn has
180
182
  reached the Agent trace.
181
183
 
182
- The Runtime uses WebSockets, native Pi RPC / Codex app-server, and existing cloud
184
+ The Runtime uses WebSockets, native Pi RPC / Codex app-server / Claude Code stream-json, and existing cloud
183
185
  streaming. Local executables and credentials are required. `sandbox up` has been
184
186
  removed. Reconnection and result reconciliation are automatic; no recovery command is needed.
185
187
  When a result genuinely cannot be determined, the affected Chat offers an explicit stop
@@ -402,6 +404,8 @@ cohub generate "Lip-sync to this spoken take" \
402
404
  --image reference_image=https://example.com/portrait.png \
403
405
  --audio reference_audio=https://example.com/speech.mp3
404
406
 
407
+ cohub generate -m song2 --text "prompt=upbeat synthwave" --text "lyrics=[Verse] Neon lights" --param seed=42
408
+
405
409
  cohub generate "a calm lake" \
406
410
  --model <model> \
407
411
  --async
@@ -412,6 +416,7 @@ cohub tasks get <taskRunId> --json
412
416
  Supported inputs:
413
417
 
414
418
  ```bash
419
+ --text <role>=<text>
415
420
  --image <path-or-url>
416
421
  --image first_frame=<path-or-url>
417
422
  --image last_frame=<path-or-url>
@@ -422,7 +427,7 @@ Supported inputs:
422
427
  --audio reference_audio=<path-or-url>
423
428
  ```
424
429
 
425
- Role-qualified media values add `meta.role` to that content block. Repeat `--image reference_image=...` for multiple reference images. Seedance role-qualified media should use public URL inputs. Do not mix first/last frame roles with reference roles (`reference_image`, `reference_video`, `reference_audio`) in one request.
430
+ Role-qualified values add `meta.role` to that content block. Text roles come from the model's text inputs (`cohub models show <model>`); repeat `--text <role>=<text>` for each text block. Repeat `--image reference_image=...` for multiple reference images. Seedance role-qualified media should use public URL inputs. Do not mix first/last frame roles with reference roles (`reference_image`, `reference_video`, `reference_audio`) in one request.
426
431
 
427
432
  Pass generation parameters with `--param key=value` or `--parameters '<json>'`.
428
433
 
@@ -27490,9 +27490,14 @@ function fitTextToWidth(text, value, width) {
27490
27490
  }
27491
27491
  //#endregion
27492
27492
  //#region ../sdk/src/board/render/text-resolution.ts
27493
- const MAX_BOARD_RESOLUTION = 2;
27494
- function getBoardResolution() {
27495
- return Math.min(globalThis.devicePixelRatio || 1, MAX_BOARD_RESOLUTION);
27493
+ const MAX_BOARD_RESOLUTION = 3;
27494
+ const MAX_BOARD_PIXELS = 8e6;
27495
+ const MAX_TEXT_RESOLUTION = 6;
27496
+ function getBoardResolution(size, devicePixelRatio = globalThis.devicePixelRatio) {
27497
+ const density = Number.isFinite(devicePixelRatio) && devicePixelRatio > 0 ? devicePixelRatio : 1;
27498
+ const pixels = size ? size.width * size.height : 0;
27499
+ const budget = pixels > 0 ? Math.sqrt(MAX_BOARD_PIXELS / pixels) : MAX_BOARD_RESOLUTION;
27500
+ return Math.min(density, MAX_BOARD_RESOLUTION, budget);
27496
27501
  }
27497
27502
  /**
27498
27503
  * Zoom buckets for text re-rasterisation. Pixi Text is a bitmap: at high zoom it
@@ -27518,12 +27523,12 @@ function boardCardDetailed(zoom) {
27518
27523
  /** Effective text resolution for a given camera zoom. */
27519
27524
  function textResolutionForZoom(zoom) {
27520
27525
  const bucket = textZoomBucket(zoom);
27521
- return Math.min(getBoardResolution() * Math.max(1, bucket), 6);
27526
+ return Math.min(getBoardResolution() * Math.max(1, bucket), MAX_TEXT_RESOLUTION);
27522
27527
  }
27523
27528
  /** Update a Pixi text texture only when zoom crosses a resolution bucket. */
27524
27529
  function syncTextResolution(text, state, zoom) {
27525
27530
  const resolution = textResolutionForZoom(zoom);
27526
- if (resolution === state.resolution) return;
27531
+ if (resolution === state.resolution && resolution === text.resolution) return;
27527
27532
  text.resolution = resolution;
27528
27533
  state.resolution = resolution;
27529
27534
  }
@@ -28056,13 +28061,10 @@ function cardElevation(context, id) {
28056
28061
  }
28057
28062
  //#endregion
28058
28063
  //#region ../sdk/src/board/render/renderers/base-card-renderer.ts
28059
- const TEXT_OPTIONS = {
28060
- resolution: getBoardResolution(),
28061
- roundPixels: true
28062
- };
28063
28064
  function createLabel(text, style) {
28064
28065
  return new Text({
28065
- ...TEXT_OPTIONS,
28066
+ resolution: getBoardResolution(),
28067
+ roundPixels: true,
28066
28068
  text,
28067
28069
  style: {
28068
28070
  fontFamily: BOARD_FONT_STACK,
@@ -29495,6 +29497,7 @@ function sync$5(container, item, context) {
29495
29497
  parts.id = item.id;
29496
29498
  }
29497
29499
  positionShell(parts.root, item);
29500
+ syncTextResolution(parts.label, parts.label, context.zoom);
29498
29501
  const { width, height } = item.frame;
29499
29502
  const resolution = Math.min(4, Math.max(.25, context.zoom * getBoardResolution()));
29500
29503
  const texture = context.scripts?.frame(item, context.time, resolution) ?? null;
@@ -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({
@@ -1,4 +1,5 @@
1
1
  import { setTimeout as delay } from "node:timers/promises";
2
+ import { LOCAL_HARNESS_SPECS, LOCAL_HARNESSES } from "@neta-art/cohub";
2
3
  import { createClient } from "../client.js";
3
4
  import { json as outJson, jsonRequested } from "../output.js";
4
5
  import { currentIdentityKey } from "../space.js";
@@ -9,6 +10,7 @@ import { RuntimeArchiveStore } from "../runtime/archive-store.js";
9
10
  import { readNativeConfig, runtimeStateRoot, writeNativeConfig } from "../runtime/native/config.js";
10
11
  import { MAX_IMPORT_CONCURRENCY, DEFAULT_IMPORT_CONCURRENCY } from "../runtime/native/ingest.js";
11
12
  import { installPiExtension, piExtensionState } from "../runtime/native/install.js";
13
+ import { nativeSyncHarnesses } from "../runtime/harnesses/index.js";
12
14
  import { controlRuntimeInstance, requestRuntimeInstance, runtimeInstanceDirectory } from "../runtime/instance.js";
13
15
  import { atLeastLevel, diagnosticLevels, formatDiagnostic, formatImportProgress, formatNativeSync, printRuntimeSummary } from "../runtime/presentation.js";
14
16
  import { readRuntimeDiagnosticEvents, RuntimeDiagnosticReader, runtimeDiagnosticsDirectory, serializeDiagnosticError } from "../runtime/diagnostics.js";
@@ -17,6 +19,16 @@ const reportFailure = (cause) => {
17
19
  process.stderr.write(`Runtime failed: ${serializeDiagnosticError(cause).message}\n`);
18
20
  process.exitCode = 1;
19
21
  };
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
+ }
20
32
  const megabytes = (bytes) => `${(bytes / 1024 / 1024).toFixed(1)} MB`;
21
33
  /** The bound Space of a directory, with its running Runtime; import and attach act through it. */
22
34
  async function boundRuntime(program, dir, space) {
@@ -32,15 +44,16 @@ async function boundRuntime(program, dir, space) {
32
44
  }
33
45
  export function registerRuntime(program) {
34
46
  const runtime = program.command("runtime").description("Connect a local workspace");
35
- runtime.command("up [dir]")
47
+ const up = runtime.command("up [dir]")
36
48
  .description("Connect local Harnesses and files")
37
49
  .option("-s, --space <space>", "Target Space: ID, slug, or username/slug")
38
50
  .option("-n, --new", "Create a new Space")
39
51
  .option("--name <name>", "New Space name")
40
52
  .option("-d, --detach", "Run in the background")
41
- .option("--harness <name>", "Pi or Codex; repeatable", (value, previous) => [...previous, value], [])
42
- .option("--pi <path>", "Pi executable")
43
- .option("--codex <path>", "Codex executable")
53
+ .option("--harness <name>", `${LOCAL_HARNESSES.join(", ")}; repeatable`, (value, previous) => [...previous, value], []);
54
+ for (const harness of LOCAL_HARNESSES)
55
+ up.option(`--${harness} <path>`, `${LOCAL_HARNESS_SPECS[harness].label} executable`);
56
+ up
44
57
  .option("--display [screen]", "Share a screen (default: auto when available), or xvfb[:WIDTHxHEIGHT] for a virtual one")
45
58
  .option("--no-display", "Disable screen sharing and skip its confirmation")
46
59
  .option("-y, --yes", "Accept defaults and authorize local execution, native sync, and screen sharing")
@@ -63,7 +76,7 @@ export function registerRuntime(program) {
63
76
  try {
64
77
  const harnesses = parseRuntimeHarnesses(options.harness.length ? options.harness : ["pi"]);
65
78
  if (!harnesses.includes("pi"))
66
- throw new Error("Codex needs no extension; runtime up offers its shared app-server");
79
+ throw new Error("Only Pi uses an extension; Codex syncs through runtime up's shared app-server");
67
80
  const before = await piExtensionState();
68
81
  if (before === "foreign")
69
82
  throw new Error("A Pi extension named cohub exists and is not managed by Cohub; move it aside, then retry");
@@ -84,14 +97,14 @@ export function registerRuntime(program) {
84
97
  runtime.command("detach")
85
98
  .description("Pause native sync; keep all data")
86
99
  .option("-s, --space <space>", "Target Space: ID, slug, or username/slug")
87
- .option("--harness <name>", "Pi or Codex; repeatable", (value, previous) => [...previous, value], [])
100
+ .option("--harness <name>", `${SYNCED_LABELS}; repeatable`, (value, previous) => [...previous, value], [])
88
101
  .option("--json", "JSON output")
89
102
  .action(async (options) => {
90
103
  try {
91
104
  const { identity, spaceId, directory } = await boundRuntime(program, undefined, options.space);
92
105
  const stateRoot = runtimeStateRoot(spaceId);
93
106
  const config = await readNativeConfig(stateRoot, identity);
94
- const paused = parseRuntimeHarnesses(options.harness.length ? options.harness : config?.harnesses ?? ["pi", "codex"]);
107
+ const paused = options.harness.length ? parseSyncedHarnesses(options.harness) : config?.harnesses ?? SYNCED;
95
108
  const harnesses = (config?.harnesses ?? []).filter((harness) => !paused.includes(harness));
96
109
  if (config)
97
110
  await writeNativeConfig(stateRoot, { ...config, harnesses, codexShared: harnesses.includes("codex") && config.codexShared });
@@ -131,7 +144,7 @@ export function registerRuntime(program) {
131
144
  throw new Error(`Use a concurrency from 1 to ${MAX_IMPORT_CONCURRENCY}`);
132
145
  const { spaceId, root, directory } = await boundRuntime(program, dir, options.space);
133
146
  // The Runtime does the work, so an import survives this command: Ctrl-C only pauses it.
134
- const filter = { harnesses: options.harness.length ? parseRuntimeHarnesses(options.harness) : [], ...(options.session ? { nativeSessionId: options.session } : {}) };
147
+ const filter = { harnesses: options.harness.length ? parseSyncedHarnesses(options.harness) : [], ...(options.session ? { nativeSessionId: options.session } : {}) };
135
148
  const control = async (command, timeoutMs = 60_000) => {
136
149
  const response = await controlRuntimeInstance(directory, "import", { ...filter, command, concurrency }, timeoutMs);
137
150
  if (!response)
@@ -1,6 +1,6 @@
1
1
  import { readdir, stat } from "node:fs/promises";
2
2
  import { basename, dirname, relative, resolve, sep } from "node:path";
3
- import { readSentTurns, readSessionTurnOrigin, resolveCohubEnvironment } from "@neta-art/cohub";
3
+ import { harnessSchema, readSentTurns, readSessionTurnOrigin, resolveCohubEnvironment } from "@neta-art/cohub";
4
4
  import { uploadAvatarAsset, uploadChatImageAsset } from "../avatar.js";
5
5
  import { createClient } from "../client.js";
6
6
  import { collectOption, readPromptContent } from "../command-input.js";
@@ -344,7 +344,7 @@ export function registerPrompt(program) {
344
344
  .option("--title <title>", "Title for a newly created session or schedule")
345
345
  .option("-m, --model <model>", "Model name")
346
346
  .option("-p, --provider <provider>", "Provider name")
347
- .option("--harness <harness>", "Harness: cohub, pi, or codex", "cohub")
347
+ .option("--harness <harness>", `Harness: ${harnessSchema.options.join(", ")}`, "cohub")
348
348
  .option("--thinking-level <level>", "Thinking level: off|minimal|low|medium|high|xhigh|max")
349
349
  .option("--read-only", "Use read-only tools")
350
350
  .option("--steer", "Interrupt the current turn and run immediately")
@@ -703,7 +703,7 @@ export function registerSpaces(program) {
703
703
  .command("prompt [content...]", { hidden: true })
704
704
  .alias("send")
705
705
  .description("Send or schedule a prompt in the target space")
706
- .option("--harness <harness>", "Harness: cohub, pi, or codex", "cohub")
706
+ .option("--harness <harness>", `Harness: ${harnessSchema.options.join(", ")}`, "cohub")
707
707
  .option("--session <id>", "Target session ID")
708
708
  .option("--title <title>", "Title for a newly created session or schedule")
709
709
  .option("-m, --model <model>", "Model name")
@@ -1,4 +1,4 @@
1
- import { type HarnessArchive, type HarnessArchiveIndex, type RuntimeArchivePage, type RuntimeArchiveUpload } from "@neta-art/cohub";
1
+ import { type LocalHarness, type HarnessArchive, type HarnessArchiveIndex, type RuntimeArchivePage, type RuntimeArchiveUpload } from "@neta-art/cohub";
2
2
  export type ArchiveTransport = {
3
3
  fetchObject?: typeof fetch;
4
4
  prepareRuntimeArchive(index: HarnessArchiveIndex, options?: {
@@ -41,7 +41,7 @@ export declare class RuntimeArchiveStore {
41
41
  private readIndex;
42
42
  stage(state: {
43
43
  sessionId: string;
44
- harness: "pi" | "codex";
44
+ harness: LocalHarness;
45
45
  nativeSessionId: string;
46
46
  path: string;
47
47
  sizeBytes?: number;
@@ -2,7 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import { createReadStream } from "node:fs";
3
3
  import { mkdir, open, readFile, readdir, rename, rm, stat, link } from "node:fs/promises";
4
4
  import { dirname, join } from "node:path";
5
- import { RUNTIME_ARCHIVE_SEGMENT_BYTES, harnessArchiveIndexSchema, validateArchiveBoundary, } from "@neta-art/cohub";
5
+ import { LOCAL_HARNESS_SPECS, RUNTIME_ARCHIVE_SEGMENT_BYTES, harnessArchiveIndexSchema, validateArchiveBoundary, } from "@neta-art/cohub";
6
6
  const missing = (error) => error?.code === "ENOENT";
7
7
  /** Raised when local capture bytes fail integrity checks (checksum, truncation, boundary); retryable via cache rebuild. */
8
8
  export class ArchiveCaptureIntegrityError extends Error {
@@ -195,7 +195,7 @@ export class RuntimeArchiveStore {
195
195
  }
196
196
  }
197
197
  index = harnessArchiveIndexSchema.parse({ ...identity, version: 1, nativeSessionId: state.nativeSessionId,
198
- nativeFormat: state.harness === "pi" ? "pi.jsonl" : "codex.rollout", parentTurnId: parent?.turnId ?? null,
198
+ nativeFormat: LOCAL_HARNESS_SPECS[state.harness].nativeFormat, parentTurnId: parent?.turnId ?? null,
199
199
  sizeBytes, sha256: digest.digest("hex"), segments });
200
200
  validateArchiveBoundary(index, parent);
201
201
  if (state.expectedChecksum && index.sha256 !== state.expectedChecksum)
@@ -1,4 +1,4 @@
1
- import type { RuntimeTraceContext } from "@neta-art/cohub";
1
+ import type { LocalHarness, RuntimeTraceContext } from "@neta-art/cohub";
2
2
  export type RuntimeDiagnosticLevel = "debug" | "info" | "warn" | "error";
3
3
  export type RuntimeDiagnosticError = {
4
4
  name?: string;
@@ -27,7 +27,7 @@ export type RuntimeDiagnostic = {
27
27
  spaceId: string;
28
28
  sessionId?: string | null;
29
29
  turnId?: string | null;
30
- harness?: "pi" | "codex" | null;
30
+ harness?: LocalHarness | null;
31
31
  traceContext?: RuntimeTraceContext;
32
32
  data?: Record<string, unknown>;
33
33
  error?: RuntimeDiagnosticError;
@@ -39,7 +39,7 @@ export type RuntimeDiagnosticContext = {
39
39
  spaceId?: string;
40
40
  sessionId?: string | null;
41
41
  turnId?: string | null;
42
- harness?: "pi" | "codex" | null;
42
+ harness?: LocalHarness | null;
43
43
  traceContext?: RuntimeTraceContext;
44
44
  };
45
45
  export type RuntimeDiagnosticsOptions = {
@@ -0,0 +1,40 @@
1
+ import { type CanonicalProjectionMessage, type ContentBlock, type LocalHarness, type NativeProjection, type ProjectionInput, type ProjectionRecord } from "@neta-art/cohub";
2
+ type ToolResult = Extract<ContentBlock, {
3
+ type: "tool_result";
4
+ }>;
5
+ /** Deterministic, UUID-shaped record ids keep re-projection stable without local state. */
6
+ export declare function projectionUuid(key: string): string;
7
+ /** Provider APIs restrict tool ids; hashing keeps call/result pairs linked. */
8
+ export declare function projectionToolId(id: string): string;
9
+ export type ProjectedSegment = {
10
+ role: "user" | "assistant";
11
+ blocks: ContentBlock[];
12
+ message: CanonicalProjectionMessage;
13
+ compactSummary: boolean;
14
+ };
15
+ /**
16
+ * Shared walk for transcript formats whose records form a parent-linked chain. `write` maps one
17
+ * user/assistant segment to native records; ids and parent links are assigned here.
18
+ */
19
+ export declare function projectChainedSession(input: ProjectionInput, target: LocalHarness, fields: {
20
+ id: string;
21
+ parent: string;
22
+ }, write: (segment: ProjectedSegment) => Record<string, unknown>[]): NativeProjection;
23
+ export declare const projectionMeta: (input: ProjectionInput, message: CanonicalProjectionMessage) => {
24
+ messageId: string;
25
+ turnId: string;
26
+ schema: string;
27
+ spaceId: string;
28
+ sessionId: string;
29
+ };
30
+ export declare const toolResultText: (block: ToolResult) => string;
31
+ /** Session id is stored on every record of these formats. */
32
+ export declare const rebindSessionId: (records: ProjectionRecord[], nativeSessionId: string) => {
33
+ key: string;
34
+ sourceTurnId: string | null;
35
+ sourceMessageId: string | null;
36
+ record: {
37
+ sessionId: string;
38
+ };
39
+ }[];
40
+ export {};
@@ -0,0 +1,95 @@
1
+ import { createHash } from "node:crypto";
2
+ import { fingerprintProjectionTurns, isProjectionCompaction, isProjectionPendingGeneration, projectNativeMessageMeta, trimProjectionTurnsToCompaction, } from "@neta-art/cohub";
3
+ /** Deterministic, UUID-shaped record ids keep re-projection stable without local state. */
4
+ export function projectionUuid(key) {
5
+ const hex = createHash("sha256").update(key).digest("hex");
6
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-5${hex.slice(13, 16)}-${((Number.parseInt(hex[16] ?? "0", 16) & 3) | 8).toString(16)}${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
7
+ }
8
+ /** Provider APIs restrict tool ids; hashing keeps call/result pairs linked. */
9
+ export function projectionToolId(id) {
10
+ return /^[A-Za-z0-9_-]{1,64}$/.test(id) ? id : `call_${createHash("sha256").update(id).digest("hex").slice(0, 32)}`;
11
+ }
12
+ const nonEmpty = (block) => block.type !== "text" || block.text.length > 0;
13
+ /**
14
+ * Splits Cohub messages into alternating native records. A tool call is kept only with its result,
15
+ * because provider APIs reject unpaired calls; dropped content is reported as a projection warning.
16
+ */
17
+ function segments(message, warnings) {
18
+ const warn = (reason) => warnings.push({ sourceMessageId: message.id, sourceTurnId: message.turnId, reason });
19
+ if (message.content.some((block) => block.type === "image" && block.source.type === "url"))
20
+ warn("external image URL omitted from native projection");
21
+ const visible = message.content.filter((block) => nonEmpty(block) && !(block.type === "image" && block.source.type === "url"));
22
+ if (message.role === "user") {
23
+ const blocks = visible.filter((block) => block.type === "text" || block.type === "image");
24
+ return blocks.length ? [{ role: "user", blocks }] : [];
25
+ }
26
+ const raw = [];
27
+ for (const block of visible) {
28
+ const role = block.type === "tool_result" ? "user" : "assistant";
29
+ if (role === "assistant" && !["text", "tool_use"].includes(block.type))
30
+ continue;
31
+ const last = raw.at(-1);
32
+ if (last?.role === role)
33
+ last.blocks.push(block);
34
+ else
35
+ raw.push({ role, blocks: [block] });
36
+ }
37
+ const paired = [];
38
+ raw.forEach((segment, index) => {
39
+ if (segment.role === "assistant") {
40
+ const results = new Set(raw[index + 1]?.role === "user" ? raw[index + 1]?.blocks.flatMap((block) => block.type === "tool_result" ? [block.tool_use_id] : []) : []);
41
+ const blocks = segment.blocks.filter((block) => block.type !== "tool_use" || results.has(block.id));
42
+ if (blocks.length !== segment.blocks.length)
43
+ warn("tool call without a result omitted from native projection");
44
+ if (blocks.length)
45
+ paired.push({ role: "assistant", blocks });
46
+ }
47
+ else {
48
+ const calls = new Set(raw[index - 1]?.role === "assistant" ? raw[index - 1]?.blocks.flatMap((block) => block.type === "tool_use" ? [block.id] : []) : []);
49
+ const blocks = segment.blocks.filter((block) => block.type === "tool_result" && calls.has(block.tool_use_id));
50
+ if (blocks.length !== segment.blocks.length)
51
+ warn("tool result without a call omitted from native projection");
52
+ if (blocks.length)
53
+ paired.push({ role: "user", blocks });
54
+ }
55
+ });
56
+ return paired;
57
+ }
58
+ const compactionText = (message) => message.content.flatMap((block) => block.type === "system_note" ? [block.text] : []).join("\n");
59
+ /**
60
+ * Shared walk for transcript formats whose records form a parent-linked chain. `write` maps one
61
+ * user/assistant segment to native records; ids and parent links are assigned here.
62
+ */
63
+ export function projectChainedSession(input, target, fields, write) {
64
+ const warnings = [];
65
+ const records = [];
66
+ let parentId = null;
67
+ for (const turn of trimProjectionTurnsToCompaction(input.turns)) {
68
+ for (const message of turn.messages) {
69
+ if (isProjectionPendingGeneration(message))
70
+ continue;
71
+ const parts = isProjectionCompaction(message)
72
+ ? compactionText(message) ? [{ role: "user", blocks: [{ type: "text", text: compactionText(message) }], compactSummary: true }] : []
73
+ : message.role === "system" ? [] : segments(message, warnings).map((segment) => ({ ...segment, compactSummary: false }));
74
+ let ordinal = 0;
75
+ for (const part of parts) {
76
+ for (const native of write({ ...part, message })) {
77
+ const key = `${message.id}:${message.turnId}:${ordinal++}`;
78
+ const id = projectionUuid(`${input.sessionId}:${key}`);
79
+ records.push({ key, sourceTurnId: message.turnId, sourceMessageId: message.id, record: { [fields.parent]: parentId, [fields.id]: id, ...native } });
80
+ parentId = id;
81
+ }
82
+ }
83
+ }
84
+ }
85
+ const last = input.turns.at(-1);
86
+ return { target, records, cursor: { sourceSequence: last?.sequence ?? null, sourceTurnId: last?.sourceTurnId ?? null, sourceFingerprint: fingerprintProjectionTurns(input.turns) }, warnings };
87
+ }
88
+ export const projectionMeta = (input, message) => ({
89
+ schema: "cohub.projection.v2", spaceId: input.spaceId, sessionId: input.sessionId, ...projectNativeMessageMeta(message),
90
+ });
91
+ export const toolResultText = (block) => typeof block.content === "string"
92
+ ? block.content
93
+ : block.content.flatMap((item) => item.type === "text" ? [item.text] : []).join("\n");
94
+ /** Session id is stored on every record of these formats. */
95
+ export const rebindSessionId = (records, nativeSessionId) => records.map((entry) => ({ ...entry, record: { ...entry.record, sessionId: nativeSessionId } }));
@@ -0,0 +1,8 @@
1
+ import type { LocalHarnessAdapter } from "./types.js";
2
+ /**
3
+ * Claude Code names a project directory by replacing every non-alphanumeric UTF-16 unit; a longer
4
+ * name keeps its first 200 units and appends the base-36 Java string hash of the path.
5
+ */
6
+ export declare function claudeProjectName(cwd: string): string;
7
+ export declare const claudeProjectDirectory: (cwd: string) => string;
8
+ export declare const claudeHarness: LocalHarnessAdapter;
@@ -0,0 +1,153 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { JsonRpcProcess, record } from "../json-rpc.js";
5
+ import { copyNativeArchive } from "../native/archive.js";
6
+ import { projectChainedSession, projectionMeta, projectionToolId, rebindSessionId } from "./chained-projection.js";
7
+ import { array, harnessCommand, text } from "./process.js";
8
+ import { DEFAULT_MODEL, runStreamJson, streamJsonHelp, streamJsonModel } from "./stream-json.js";
9
+ const MAX_PROJECT_NAME = 200;
10
+ const INITIALIZE_TIMEOUT_MS = 30_000;
11
+ const dataRoot = () => process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), ".claude");
12
+ /**
13
+ * Claude Code names a project directory by replacing every non-alphanumeric UTF-16 unit; a longer
14
+ * name keeps its first 200 units and appends the base-36 Java string hash of the path.
15
+ */
16
+ export function claudeProjectName(cwd) {
17
+ const name = cwd.replace(/[^a-zA-Z0-9]/g, "-");
18
+ if (name.length <= MAX_PROJECT_NAME)
19
+ return name;
20
+ let hash = 0;
21
+ for (let index = 0; index < cwd.length; index++)
22
+ hash = (hash * 31 + cwd.charCodeAt(index)) | 0;
23
+ return `${name.slice(0, MAX_PROJECT_NAME)}-${Math.abs(hash).toString(36)}`;
24
+ }
25
+ export const claudeProjectDirectory = (cwd) => join(dataRoot(), "projects", claudeProjectName(cwd));
26
+ const profile = {
27
+ harness: "claude",
28
+ label: "Claude Code",
29
+ binary: "claude",
30
+ provider: "anthropic",
31
+ dataRoot,
32
+ projectDirectory: claudeProjectDirectory,
33
+ effortLevels: ["low", "medium", "high", "xhigh", "max"],
34
+ };
35
+ function anthropicBlock(block) {
36
+ if (block.type === "text")
37
+ return { type: "text", text: block.text };
38
+ if (block.type === "image" && block.source.type === "base64")
39
+ return { type: "image", source: { type: "base64", media_type: block.source.media_type, data: block.source.data } };
40
+ if (block.type === "tool_use")
41
+ return { type: "tool_use", id: projectionToolId(block.id), name: block.name.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 128) || "tool", input: block.input };
42
+ if (block.type === "tool_result") {
43
+ const content = typeof block.content === "string" ? block.content : block.content.map(anthropicBlock).filter((item) => item !== null);
44
+ return { type: "tool_result", tool_use_id: projectionToolId(block.tool_use_id), content, is_error: block.is_error ?? false };
45
+ }
46
+ return null;
47
+ }
48
+ /** Claude Code merges consecutive assistant records sharing a message id; each projected call gets its own. */
49
+ function withAssistantIds(projection) {
50
+ return { ...projection, records: projection.records.map((entry) => entry.record.type !== "assistant" ? entry : {
51
+ ...entry, record: { ...entry.record, message: { id: `msg_${String(entry.record.uuid).replaceAll("-", "")}`, ...entry.record.message } },
52
+ }) };
53
+ }
54
+ /** Claude Code's own `/model` list (what the SDK's `supportedModels()` reads), gateway-discovered models included. */
55
+ async function claudeInitialize(executable, cwd) {
56
+ const args = ["-p", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose", "--no-session-persistence", "--strict-mcp-config"];
57
+ const host = new JsonRpcProcess(...harnessCommand(executable, args, cwd), cwd, "stream");
58
+ const requestId = randomUUID();
59
+ let timer;
60
+ try {
61
+ return await new Promise((resolve, reject) => {
62
+ timer = setTimeout(() => reject(new Error("Claude Code initialize timed out")), INITIALIZE_TIMEOUT_MS);
63
+ host.onFailure(reject);
64
+ host.onEvent((event) => {
65
+ const response = record(event.response);
66
+ if (event.type !== "control_response" || response.request_id !== requestId)
67
+ return;
68
+ if (response.subtype === "success")
69
+ resolve(record(response.response));
70
+ else
71
+ reject(new Error(text(response.error) || "Claude Code initialize failed"));
72
+ });
73
+ host.write({ type: "control_request", request_id: requestId, request: { subtype: "initialize" } });
74
+ });
75
+ }
76
+ finally {
77
+ clearTimeout(timer);
78
+ await host.close();
79
+ }
80
+ }
81
+ /** Gateway models are served by `ANTHROPIC_BASE_URL`, so they carry its host as provider. */
82
+ function claudeModelCatalog(entries, baseUrl) {
83
+ let gateway = "gateway";
84
+ try {
85
+ if (baseUrl)
86
+ gateway = new URL(baseUrl).hostname.slice(0, 100) || gateway;
87
+ }
88
+ catch { /* Keep the generic name. */ }
89
+ const seen = new Set();
90
+ return entries.flatMap((value) => {
91
+ const model = record(value);
92
+ const id = text(model.value);
93
+ if (!id || id.length > 255 || seen.has(id))
94
+ return [];
95
+ seen.add(id);
96
+ const effortLevels = model.supportsEffort === true ? array(model.supportedEffortLevels).filter((level) => typeof level === "string") : [];
97
+ return [streamJsonModel(profile, id, (text(model.displayName) || id).slice(0, 255), {
98
+ reasoning: effortLevels.length > 0, effortLevels, isDefault: id === DEFAULT_MODEL,
99
+ provider: text(model.description).startsWith("From gateway") ? gateway : profile.provider,
100
+ })];
101
+ });
102
+ }
103
+ export const claudeHarness = {
104
+ id: "claude",
105
+ binary: profile.binary,
106
+ async models(executable, cwd) {
107
+ await streamJsonHelp(profile, executable, cwd);
108
+ try {
109
+ const models = claudeModelCatalog(array((await claudeInitialize(executable, cwd)).models), process.env.ANTHROPIC_BASE_URL);
110
+ if (models.some((model) => model.isDefault))
111
+ return models;
112
+ }
113
+ catch { /* A CLI without the initialize model list keeps the account aliases. */ }
114
+ // Aliases follow the signed-in account; Haiku takes no effort level.
115
+ return [
116
+ streamJsonModel(profile, DEFAULT_MODEL, "Default", { reasoning: true, isDefault: true }),
117
+ streamJsonModel(profile, "opus", "Claude Opus", { reasoning: true }),
118
+ streamJsonModel(profile, "sonnet", "Claude Sonnet", { reasoning: true }),
119
+ streamJsonModel(profile, "haiku", "Claude Haiku", { reasoning: false }),
120
+ ];
121
+ },
122
+ run: (executor, turn) => runStreamJson(profile, executor, turn),
123
+ session: {
124
+ path: (nativeSessionId, cwd) => join(claudeProjectDirectory(cwd), `${nativeSessionId}.jsonl`),
125
+ startsWithoutFile: true,
126
+ project: (input) => withAssistantIds(projectChainedSession(input, "claude", { id: "uuid", parent: "parentUuid" }, (segment) => {
127
+ const content = segment.blocks.map(anthropicBlock).filter((block) => block !== null);
128
+ return [{
129
+ isSidechain: false, userType: "external", cwd: input.cwd, sessionId: input.nativeSessionId, version: "cohub",
130
+ type: segment.role, timestamp: segment.message.createdAt || input.timestamp || new Date(0).toISOString(),
131
+ ...(segment.compactSummary ? { isCompactSummary: true } : {}),
132
+ message: segment.role === "user"
133
+ ? { role: "user", content }
134
+ : {
135
+ type: "message", role: "assistant", model: segment.message.model || "cohub", content,
136
+ stop_reason: segment.blocks.some((block) => block.type === "tool_use") ? "tool_use" : "end_turn", stop_sequence: null,
137
+ usage: { input_tokens: 0, output_tokens: 0 },
138
+ },
139
+ cohub: projectionMeta(input, segment.message),
140
+ }];
141
+ })),
142
+ rebind: rebindSessionId,
143
+ archive: {
144
+ // The copy takes a fresh id: the archived one may already exist here, and is never replaced.
145
+ target: ({ id, cwd }) => join(claudeProjectDirectory(cwd), `${id}.jsonl`),
146
+ async import(input) {
147
+ const { checksum } = await copyNativeArchive({ ...input, sessionId: input.id });
148
+ return { checksum, nativeSessionId: input.id };
149
+ },
150
+ },
151
+ },
152
+ nativeSync: false,
153
+ };
@@ -0,0 +1,4 @@
1
+ import type { LocalHarnessAdapter, ThreadKind } from "./types.js";
2
+ /** Codex's first line is the only place a thread's purpose is recorded. */
3
+ export declare function codexThreadKind(payload: Record<string, unknown>): ThreadKind;
4
+ export declare const codexHarness: LocalHarnessAdapter;