@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
@@ -2,8 +2,9 @@ import { appendFile, mkdir, open, readFile, readdir, rename, stat } from "node:f
2
2
  import { join } from "node:path";
3
3
  import { NATIVE_INGEST_MAX_TURNS, SETTLED_TURN_STATUSES } from "@neta-art/cohub";
4
4
  import { acceptsDirectory, acceptsTranscript, readSessionHeader, readTranscript, transcriptRoots } from "./adapters.js";
5
+ import { SizeLimitError } from "./transcript.js";
5
6
  import { nativeTurnId } from "./identity.js";
6
- import { transcriptFormat } from "../harnesses/index.js";
7
+ import { harnessAdapter, transcriptFormat } from "../harnesses/index.js";
7
8
  import { containsNativePath } from "./paths.js";
8
9
  const missing = (error) => error?.code === "ENOENT";
9
10
  /** Transcripts read in parallel per lane. The live lane never waits behind a backfill. */
@@ -27,6 +28,7 @@ const RETRY_MAX_MS = 5 * 60_000;
27
28
  */
28
29
  const INGEST_BATCH_TURNS = Math.min(50, NATIVE_INGEST_MAX_TURNS);
29
30
  const IMPORT_FAILURES_KEPT = 100;
31
+ const fileVersion = (info) => `${info.ino}:${info.size}:${info.mtimeMs}`;
30
32
  /** Headers are read this many at a time; each costs one small read. */
31
33
  const HEADER_CONCURRENCY = 16;
32
34
  /** Whether a session in `cwd` belongs to the directory bound at `root`. */
@@ -105,8 +107,12 @@ export class NativeIngest {
105
107
  reads = new Set();
106
108
  timers = new Map();
107
109
  failures = new Map();
110
+ /** File versions that hit a size limit; they are not read again until the file changes. */
111
+ oversized = new Map();
108
112
  aliveAt = new Map();
109
113
  runningPaths = new Set();
114
+ /** Files a live Cohub execution is writing in a format that cannot mark its Turn. */
115
+ executing = new Set();
110
116
  writing = Promise.resolve();
111
117
  foreign = new Set();
112
118
  importQueue = [];
@@ -194,18 +200,49 @@ export class NativeIngest {
194
200
  }
195
201
  /**
196
202
  * Record that Cohub created or chose this transcript for a Session, before any Turn of it runs.
203
+ * `through` says the server has the file's Turns up to `turnKey` (none when null) and `turnId` heads them.
197
204
  */
198
205
  adopt(session, sessionId, through) {
199
206
  const state = this.states.get(session.path) ?? this.blank(session.path, session.nativeSessionId, session.cwd, session.harness, "user", { size: -1, mtimeMs: 0, ino: 0 });
200
207
  Object.assign(state, { sessionId, nativeSessionId: session.nativeSessionId });
201
- if (through?.turnKey)
208
+ if (through)
202
209
  Object.assign(state, { throughTurnKey: through.turnKey, throughTurnId: through.turnId, headTurnId: through.turnId });
203
210
  if (!this.states.has(session.path))
204
211
  state.origin = "cohub";
212
+ // The harness wrote elsewhere than expected; the execution follows the file.
213
+ for (const other of this.states.values()) {
214
+ if (other === state || other.harness !== session.harness || other.nativeSessionId !== session.nativeSessionId || !other.executingTurnId)
215
+ continue;
216
+ state.executingTurnId = other.executingTurnId;
217
+ other.executingTurnId = null;
218
+ if (this.executing.delete(other.path))
219
+ this.executing.add(session.path);
220
+ this.markDirty(other.path);
221
+ }
205
222
  this.states.set(session.path, state);
206
223
  this.markDirty(session.path);
207
224
  }
225
+ /** A Cohub Turn starts writing this file; a format that cannot mark it is not read until it ends. */
226
+ begin(session, sessionId, turnId) {
227
+ if (transcriptFormat(session.harness).marksCohubTurns)
228
+ return;
229
+ this.adopt(session, sessionId);
230
+ const state = this.states.get(session.path);
231
+ state.executingTurnId = turnId;
232
+ this.executing.add(session.path);
233
+ this.markDirty(session.path);
234
+ }
235
+ /** A Cohub Turn ended, whether or not it produced a result. */
236
+ end(turnId) {
237
+ for (const state of this.states.values()) {
238
+ if (state.executingTurnId !== turnId)
239
+ continue;
240
+ this.executing.delete(state.path);
241
+ this.touch(state.path);
242
+ }
243
+ }
208
244
  advance(path, turnId) {
245
+ this.end(turnId);
209
246
  const state = this.states.get(path);
210
247
  if (!state)
211
248
  return;
@@ -287,21 +324,23 @@ export class NativeIngest {
287
324
  }
288
325
  this.drainLive();
289
326
  if (this.job.state === "running")
290
- await this.startImport(this.job.filter, this.job.concurrency);
327
+ await this.startImport(this.job.filter, this.job.concurrency, { resume: true });
291
328
  }
292
- async plan(filter = {}, signal) {
329
+ /** `resume` is automatic resumption: it skips file versions that already hit a size limit, which only an explicit import retries. */
330
+ async plan(filter = {}, signal, options = {}) {
293
331
  const harnesses = this.harnesses.filter((harness) => !filter.harnesses?.length || filter.harnesses.includes(harness));
294
332
  const candidates = await discoverTranscripts(this.options.cwd, harnesses, { headers: true, nested: this.nested, signal });
295
333
  // A transcript whose first read failed is still untracked (size -1), so a later import retries it.
296
334
  return candidates.filter((candidate) => (this.states.get(candidate.path)?.size ?? -1) < 0 && !this.live.has(candidate.path)
335
+ && !(options.resume && this.oversized.get(candidate.path) === fileVersion(candidate))
297
336
  && (!filter.nativeSessionId || candidate.nativeSessionId === filter.nativeSessionId));
298
337
  }
299
338
  /**
300
339
  * Start or resume a backfill. What was imported is tracked, so resuming simply plans again; the
301
340
  * server recognizes any Turn it already has, so an interrupted batch is never duplicated.
302
341
  */
303
- async startImport(filter = {}, concurrency = DEFAULT_IMPORT_CONCURRENCY) {
304
- const queue = await this.plan(filter);
342
+ async startImport(filter = {}, concurrency = DEFAULT_IMPORT_CONCURRENCY, options = {}) {
343
+ const queue = await this.plan(filter, undefined, options);
305
344
  const resuming = this.job.state === "running" || this.job.state === "paused";
306
345
  const same = resuming && JSON.stringify(this.job.filter) === JSON.stringify(filter);
307
346
  const base = same ? this.job : { ...this.job, done: 0, turns: 0, bytes: 0, failed: [], startedAt: new Date().toISOString() };
@@ -417,7 +456,7 @@ export class NativeIngest {
417
456
  }
418
457
  retryLater(path, error) {
419
458
  this.report(path, error);
420
- if (this.stopped || !this.transport)
459
+ if (this.stopped || !this.transport || error instanceof SizeLimitError)
421
460
  return;
422
461
  const attempts = (this.failures.get(path) ?? 0) + 1;
423
462
  if (attempts > RETRY_ATTEMPTS) {
@@ -469,12 +508,26 @@ export class NativeIngest {
469
508
  }
470
509
  }
471
510
  async read(path, lane, force) {
472
- if (this.foreign.has(path))
511
+ if (this.foreign.has(path) || this.executing.has(path))
473
512
  return;
474
513
  const info = await stat(path).catch((error) => { if (missing(error))
475
514
  return null; throw error; });
476
515
  if (!info?.isFile())
477
516
  return;
517
+ const version = fileVersion(info);
518
+ if (lane === "live" && this.oversized.get(path) === version)
519
+ return;
520
+ this.oversized.delete(path);
521
+ try {
522
+ await this.readVersion(path, lane, force, info);
523
+ }
524
+ catch (error) {
525
+ if (error instanceof SizeLimitError)
526
+ this.oversized.set(path, version);
527
+ throw error;
528
+ }
529
+ }
530
+ async readVersion(path, lane, force, info) {
478
531
  const previous = this.states.get(path);
479
532
  // Unchanged bytes can still hold a Turn whose end the control plane has only now reported.
480
533
  if (previous && this.unchanged(previous, info) && !(force && previous.runningTurnId))
@@ -565,6 +618,8 @@ export class NativeIngest {
565
618
  const certain = this.options.hooks?.settled?.(state, mtimeMs);
566
619
  if (certain != null)
567
620
  return certain;
621
+ if (tail.ended)
622
+ return true;
568
623
  if (!format.replyEnded?.(tail))
569
624
  return false;
570
625
  const idleMs = Date.now() - mtimeMs;
@@ -598,9 +653,20 @@ export class NativeIngest {
598
653
  const known = pending.length ? await this.known(transport, pending.flatMap((turn) => turn.cloudTurnId ? [turn.cloudTurnId, derived(turn)] : [derived(turn)])) : new Map();
599
654
  const cohub = (turn) => turn.cloudTurnId && known.has(turn.cloudTurnId) ? turn.cloudTurnId : null;
600
655
  const tail = turns.at(-1);
656
+ // A Cohub file whose latest Cohub Turn the file cannot name (its format marks none, or it predates
657
+ // the marker) holds only Turns the server already has, up to its current end.
658
+ const cohubHead = state.executingTurnId ?? state.headTurnId;
659
+ const unmarked = !transcriptFormat(state.harness).marksCohubTurns || state.size < 0;
660
+ if (state.origin === "cohub" && cohubHead && unmarked && state.throughTurnId !== cohubHead && !turns.some((turn) => turn.cloudTurnId === cohubHead)) {
661
+ const through = tail ? cohub(tail) ?? cohubHead : cohubHead;
662
+ Object.assign(state, { throughTurnKey: tail?.key ?? state.throughTurnKey, throughTurnId: through, headTurnId: through, executingTurnId: null });
663
+ return;
664
+ }
665
+ state.executingTurnId = null;
601
666
  const head = !tail ? null : pending.length ? cohub(tail) ?? derived(tail) : state.throughTurnId;
602
667
  const candidates = [];
603
- let parentTurnId = from > 0 ? state.throughTurnId : null;
668
+ // A head adopted without a transcript key (Cohub's Turn left no record) still parents what follows.
669
+ let parentTurnId = from > 0 || !state.throughTurnKey ? state.throughTurnId : null;
604
670
  for (const [offset, turn] of pending.entries()) {
605
671
  const marked = cohub(turn);
606
672
  if (marked) {
@@ -726,7 +792,7 @@ export class NativeIngest {
726
792
  return Date.parse(turn.startedAt) < Date.parse(this.enabledAt);
727
793
  }
728
794
  async archive(state, turn, turnId) {
729
- if (!state.sessionId)
795
+ if (!state.sessionId || !harnessAdapter(state.harness).session.archive)
730
796
  return;
731
797
  try {
732
798
  const source = turn.path ?? state.path;
@@ -31,7 +31,10 @@ export class NativeSessions {
31
31
  if (restored) {
32
32
  // A restored copy is history the server already has, even where it carries no markers.
33
33
  const through = (await readTranscript(restored.path, restored.harness).catch(() => null))?.turns.at(-1)?.key ?? null;
34
- this.options.ingest.adopt(restored, input.sessionId, { turnKey: through, turnId: input.context.throughTurnId });
34
+ if (through)
35
+ this.options.ingest.adopt(restored, input.sessionId, { turnKey: through, turnId: input.context.throughTurnId });
36
+ else
37
+ this.options.ingest.adopt(restored, input.sessionId);
35
38
  return { session: restored, resume: "restored" };
36
39
  }
37
40
  }
@@ -45,6 +48,8 @@ export class NativeSessions {
45
48
  const id = randomUUID();
46
49
  const raw = join(this.options.root, "native", "restored", `${id}.jsonl`);
47
50
  const format = harnessAdapter(input.harness).session.archive;
51
+ if (!format)
52
+ throw new ArchiveNotRestorableError(`${input.harness} archives are not restorable`);
48
53
  try {
49
54
  const restored = await this.options.archives.restore(archive, raw, signal);
50
55
  const path = format.target({ id, nativeSessionId: restored.nativeSessionId, cwd: input.cwd });
@@ -18,6 +18,8 @@ export type NativeTranscriptTurn = {
18
18
  boundaries: Record<number, string>;
19
19
  sha256: string;
20
20
  result: NativeTurnComplete | null;
21
+ /** The file itself recorded this Turn's end, so it settles without waiting for a quiet period. */
22
+ ended?: boolean;
21
23
  path?: string;
22
24
  rolloutId?: string;
23
25
  sequence?: number;
@@ -31,6 +33,18 @@ export type NativeTranscript = {
31
33
  lineagePaths?: string[];
32
34
  lineageRolloutIds?: string[];
33
35
  };
36
+ export type TranscriptLine = {
37
+ value: JsonRecord;
38
+ startBytes: number;
39
+ endBytes: number;
40
+ sha256: string;
41
+ };
42
+ type Line = TranscriptLine;
43
+ export declare const iso: (value: unknown) => string;
44
+ /** A size limit that the same bytes will hit again; retrying before the file changes is wasted work. */
45
+ export declare class SizeLimitError extends Error {
46
+ name: string;
47
+ }
34
48
  export declare const NATIVE_MAX_RECORD_BYTES: number;
35
49
  export declare const NATIVE_MAX_TRANSCRIPT_BYTES: number;
36
50
  export declare const codexSessionsDirectory: () => string;
@@ -50,6 +64,8 @@ export type NativeTranscriptHeader = {
50
64
  };
51
65
  /** Read exactly the first JSONL record of a stream without re-concatenating per chunk. */
52
66
  export declare function readFirstRecord(stream: AsyncIterable<Buffer>): Promise<JsonRecord>;
67
+ /** The first record a format without a header line identifies its session by; `null` when none is found early. */
68
+ export declare function readFirstMatchingRecord(stream: AsyncIterable<Buffer>, match: (value: JsonRecord) => boolean): Promise<JsonRecord | null>;
53
69
  /** Read only the session header before deciding whether a global Codex file belongs to this project. */
54
70
  export declare function readNativeTranscriptHeader(path: string, harness: "pi" | "codex", signal?: AbortSignal): Promise<NativeTranscriptHeader>;
55
71
  /** Raw JSONL bytes of a rollout, transparently decompressing `.zst` and honoring a lineage cutoff. */
@@ -58,9 +74,16 @@ export declare function codexRolloutBytes(path: string, endBytes: number | null,
58
74
  export declare function codexRolloutCachePath(source: string, cacheDirectory: string): string;
59
75
  /** Archives need plain JSONL bytes; decompress a cold `.zst` rollout into a cache once. */
60
76
  export declare function ensurePlainCodexRollout(path: string, cacheDirectory: string, signal?: AbortSignal, maxBytes?: number): Promise<string>;
77
+ type TranscriptLines = {
78
+ lines: Line[];
79
+ prefixes: Map<number, string>;
80
+ };
81
+ /** Newline framing with exact byte offsets and rolling checksums; a partial tail stays unparsed. */
82
+ export declare function readTranscriptLines(stream: AsyncIterable<Buffer>): Promise<TranscriptLines>;
61
83
  /** Partial trailing records are retried, never parsed or acknowledged as complete. */
62
84
  export declare function readNativeTranscript(path: string, harness: "pi" | "codex", options?: {
63
85
  settled?: boolean;
64
86
  leafId?: string | null;
65
87
  signal?: AbortSignal;
66
88
  }): Promise<NativeTranscript>;
89
+ export {};
@@ -12,16 +12,22 @@ import { codexTokenTotals, codexUsage } from "../codex-usage.js";
12
12
  import { record } from "../json-rpc.js";
13
13
  const text = (value) => typeof value === "string" ? value : "";
14
14
  const list = (value) => Array.isArray(value) ? value : [];
15
- const iso = (value) => {
15
+ export const iso = (value) => {
16
16
  const date = new Date(typeof value === "number" || typeof value === "string" ? value : 0);
17
17
  if (!Number.isFinite(date.getTime()))
18
18
  throw new Error("Invalid native timestamp");
19
19
  return date.toISOString();
20
20
  };
21
21
  const missing = (error) => error?.code === "ENOENT";
22
+ /** A size limit that the same bytes will hit again; retrying before the file changes is wasted work. */
23
+ export class SizeLimitError extends Error {
24
+ name = "SizeLimitError";
25
+ }
22
26
  export const NATIVE_MAX_RECORD_BYTES = 256 * 1024 * 1024;
23
27
  export const NATIVE_MAX_TRANSCRIPT_BYTES = 1024 * 1024 * 1024;
24
28
  const NATIVE_HEADER_MAX_BYTES = 1024 * 1024;
29
+ /** Formats without a header line are searched this far for the record naming the session's project. */
30
+ const NATIVE_HEADER_SCAN_MAX_BYTES = 16 * 1024 * 1024;
25
31
  const CODEX_HISTORY_MAX_DEPTH = 64;
26
32
  const CODEX_ROLLOUT_INDEX_TTL_MS = 2_000;
27
33
  const CODEX_ROLLOUT_NAME = /^rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-([0-9a-f-]{36})(?:_([0-9a-f-]{36}))?\.(?:jsonl|jsonl\.zst)$/;
@@ -64,6 +70,35 @@ export async function readFirstRecord(stream) {
64
70
  }
65
71
  throw new Error("Native transcript is empty");
66
72
  }
73
+ /** The first record a format without a header line identifies its session by; `null` when none is found early. */
74
+ export async function readFirstMatchingRecord(stream, match) {
75
+ let fragments = [], scanned = 0;
76
+ for await (const chunk of stream) {
77
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
78
+ let start = 0;
79
+ for (let end = bytes.indexOf(10, start); end >= 0; end = bytes.indexOf(10, start)) {
80
+ const line = Buffer.concat([...fragments, bytes.subarray(start, end)]);
81
+ fragments = [];
82
+ start = end + 1;
83
+ if (!line.length)
84
+ continue;
85
+ let value;
86
+ try {
87
+ value = record(JSON.parse(line.toString("utf8")));
88
+ }
89
+ catch {
90
+ return null;
91
+ }
92
+ if (match(value))
93
+ return value;
94
+ }
95
+ fragments.push(bytes.subarray(start));
96
+ scanned += bytes.length;
97
+ if (scanned > NATIVE_HEADER_SCAN_MAX_BYTES)
98
+ return null;
99
+ }
100
+ return null;
101
+ }
67
102
  async function readCodexHeader(path, signal) {
68
103
  const value = await readFirstRecord(codexRolloutBytes(path, null, signal));
69
104
  const payload = record(value.payload);
@@ -172,7 +207,7 @@ export async function ensurePlainCodexRollout(path, cacheDirectory, signal, maxB
172
207
  transform(chunk, _encoding, push) {
173
208
  written += chunk.length;
174
209
  if (written > maxBytes) {
175
- push(new Error("Decompressed rollout exceeds the local limit"));
210
+ push(new SizeLimitError("Decompressed rollout exceeds the local limit"));
176
211
  return;
177
212
  }
178
213
  push(null, chunk);
@@ -188,7 +223,7 @@ export async function ensurePlainCodexRollout(path, cacheDirectory, signal, maxB
188
223
  }
189
224
  }
190
225
  /** Newline framing with exact byte offsets and rolling checksums; a partial tail stays unparsed. */
191
- async function readTranscriptLines(stream) {
226
+ export async function readTranscriptLines(stream) {
192
227
  const lines = [];
193
228
  const prefixes = new Map();
194
229
  let fragments = [], pendingBytes = 0, offset = 0;
@@ -200,9 +235,9 @@ async function readTranscriptLines(stream) {
200
235
  pendingBytes += bytes.length;
201
236
  checksum.update(bytes);
202
237
  if (pendingBytes > NATIVE_MAX_RECORD_BYTES)
203
- throw new Error("Native record exceeds the 256 MiB local parse limit; original retained");
238
+ throw new SizeLimitError("Native record exceeds the 256 MiB local parse limit; original retained");
204
239
  if (offset + pendingBytes > NATIVE_MAX_TRANSCRIPT_BYTES)
205
- throw new Error("Native transcript exceeds the 1 GiB local parse limit; original retained");
240
+ throw new SizeLimitError("Native transcript exceeds the 1 GiB local parse limit; original retained");
206
241
  };
207
242
  for await (const chunk of stream) {
208
243
  let start = 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neta-art/cohub-cli",
3
- "version": "10.7.0",
3
+ "version": "10.8.1",
4
4
  "description": "CLI for Cohub — spaces, sessions, and agent collaboration.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -18,8 +18,8 @@
18
18
  "NOTICE"
19
19
  ],
20
20
  "dependencies": {
21
- "@neta-art/cohub": "11.5.1",
22
- "@neta-art/generation": "^0.1.39",
21
+ "@neta-art/cohub": "12.1.0",
22
+ "@neta-art/generation": "^0.1.40",
23
23
  "commander": "^15.0.0",
24
24
  "sharp": "^0.35.4",
25
25
  "ws": "^8.21.3"