@neta-art/cohub-cli 8.0.1 → 8.1.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.
@@ -0,0 +1,47 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import { installNativeSync } from "./native-install.js";
3
+ import { nativeRuntimeRoot, readNativeSyncConfig } from "./native-sync.js";
4
+ import { serializeDiagnosticError } from "./diagnostics.js";
5
+ /** Already enabled for this exact directory and every requested Harness: installation is idempotent. */
6
+ export function nativeSyncSatisfied(config, input) {
7
+ return Boolean(config && config.spaceId === input.spaceId && config.root === input.root && input.harnesses.every((harness) => config.harnesses.includes(harness)));
8
+ }
9
+ /** One explicit consent prompt, defaulting to yes; native history may contain secrets. */
10
+ export async function askNativeSyncConsent(harnesses, ask = defaultAsk) {
11
+ const answer = await ask(`Sync native ${harnesses.join(" / ")} chats in this folder and upload their history (may contain secrets) to this Space? [Y/n] `);
12
+ return /^(|y(es)?)$/i.test(answer.trim());
13
+ }
14
+ async function defaultAsk(question) {
15
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
16
+ try {
17
+ return await rl.question(question);
18
+ }
19
+ finally {
20
+ rl.close();
21
+ }
22
+ }
23
+ /** Enable native sync after consent; capability or write failures warn and never block the Runtime. */
24
+ export async function ensureNativeSync(input, log = (line) => process.stderr.write(line)) {
25
+ const config = await readNativeSyncConfig(nativeRuntimeRoot(input.spaceId), input.identity).catch(() => null);
26
+ if (nativeSyncSatisfied(config, input))
27
+ return { enabled: true, harnesses: config?.harnesses ?? [] };
28
+ if (!input.yes) {
29
+ // Non-interactive sessions cannot consent; they continue without sync instead of failing `up`.
30
+ if (!process.stdin.isTTY) {
31
+ log("Native sync off · rerun interactively or with --yes to enable\n");
32
+ return { enabled: false, harnesses: input.harnesses, note: "non-interactive" };
33
+ }
34
+ if (!await askNativeSyncConsent(input.harnesses))
35
+ return { enabled: false, harnesses: input.harnesses, note: "declined" };
36
+ }
37
+ try {
38
+ const result = await installNativeSync(input);
39
+ log(`Native sync enabled. Reload Pi or restart Codex and review its hook trust prompt.\n${result.configPath}`);
40
+ return { enabled: true, harnesses: result.harnesses };
41
+ }
42
+ catch (error) {
43
+ const message = serializeDiagnosticError(error).message;
44
+ log(`Native sync unavailable; Runtime continues without it — ${message}\n`);
45
+ return { enabled: false, harnesses: input.harnesses, note: message };
46
+ }
47
+ }
@@ -9,7 +9,7 @@ export async function runCodexNativeHook(payload) {
9
9
  if (typeof value.transcript_path !== "string" || !value.transcript_path)
10
10
  return;
11
11
  const result = await requestNativeDaemon({ harness: "codex", cwd: value.cwd, path: value.transcript_path, nativeSessionId: value.session_id });
12
- if (!result.ok)
12
+ if (!result.ok && !result.skipped)
13
13
  throw new Error(result.message);
14
14
  }
15
15
  async function main() {
@@ -7,14 +7,25 @@ type NativeIpcRequest = {
7
7
  nativeSessionId: string;
8
8
  settled?: boolean;
9
9
  leafId?: string | null;
10
+ sessionStartedAt?: string;
11
+ origin?: "local_import";
10
12
  };
11
- type NativeIpcResponse = {
13
+ export type NativeIpcResponse = {
12
14
  ok: true;
13
15
  pendingTurns: number;
14
16
  } | {
15
17
  ok: false;
18
+ skipped?: true;
16
19
  message: string;
17
20
  };
21
+ /**
22
+ * `store: null` means the request was valid but nothing was applicable yet (an unbound
23
+ * workspace, or a transcript a harness has not written). That is not a sync failure, so
24
+ * clients stay silent instead of warning about it.
25
+ */
26
+ export declare function nativeCaptureResponse(result: {
27
+ store: NativeSyncStore | null;
28
+ }): Promise<NativeIpcResponse>;
18
29
  export declare function nativeDaemonSocketFor(cwd: string): Promise<string | null>;
19
30
  export declare function requestNativeDaemon(input: Omit<NativeIpcRequest, "type">): Promise<NativeIpcResponse>;
20
31
  export declare function serveNativeDaemon(input: {
@@ -6,10 +6,22 @@ import { currentIdentityKey } from "../space.js";
6
6
  import { canonicalRuntimeRoot, getRuntimeSpaceBinding } from "./space-binding.js";
7
7
  const MAX_LINE_BYTES = 4 * 1024 * 1024;
8
8
  const socketPath = (runtimeRoot) => join(runtimeRoot, "native", "daemon.sock");
9
+ /**
10
+ * `store: null` means the request was valid but nothing was applicable yet (an unbound
11
+ * workspace, or a transcript a harness has not written). That is not a sync failure, so
12
+ * clients stay silent instead of warning about it.
13
+ */
14
+ export async function nativeCaptureResponse(result) {
15
+ if (!result.store)
16
+ return { ok: false, skipped: true, message: "No native capture is pending" };
17
+ return { ok: true, pendingTurns: (await result.store.status()).pendingTurns };
18
+ }
9
19
  const parse = (raw) => {
10
20
  const value = JSON.parse(raw);
11
21
  if (value?.type !== "native.capture" || !["pi", "codex"].includes(value.harness)
12
- || typeof value.cwd !== "string" || typeof value.path !== "string" || typeof value.nativeSessionId !== "string") {
22
+ || typeof value.cwd !== "string" || typeof value.path !== "string" || typeof value.nativeSessionId !== "string"
23
+ || value.origin !== undefined && value.origin !== "local_import"
24
+ || value.sessionStartedAt !== undefined && typeof value.sessionStartedAt !== "string") {
13
25
  throw new Error("Invalid native daemon request");
14
26
  }
15
27
  return value;
@@ -18,14 +30,19 @@ export async function nativeDaemonSocketFor(cwd) {
18
30
  const identity = currentIdentityKey();
19
31
  if (!identity)
20
32
  return null;
21
- const root = await canonicalRuntimeRoot(cwd);
33
+ // A vanished workspace is unbound, not an error; the terminal has nothing left to sync.
34
+ const root = await canonicalRuntimeRoot(cwd).catch((error) => { if (error.code === "ENOENT")
35
+ return null; throw error; });
36
+ if (!root)
37
+ return null;
22
38
  const binding = await getRuntimeSpaceBinding(root, identity);
23
39
  return binding ? socketPath(nativeRuntimeRoot(binding.spaceId)) : null;
24
40
  }
25
41
  export async function requestNativeDaemon(input) {
26
42
  const path = await nativeDaemonSocketFor(input.cwd);
43
+ // An unbound workspace is a normal state, not an error the terminal should surface.
27
44
  if (!path)
28
- return { ok: false, message: "Native Runtime is not bound" };
45
+ return { ok: false, skipped: true, message: "Native Runtime is not bound" };
29
46
  return new Promise((resolve, reject) => {
30
47
  const socket = createConnection(path);
31
48
  let buffer = "";
@@ -86,7 +103,7 @@ export async function serveNativeDaemon(input) {
86
103
  void (async () => {
87
104
  const request = parse(buffer.slice(0, newline));
88
105
  const result = await input.handle(request);
89
- respond({ ok: true, pendingTurns: result.store ? (await result.store.status()).pendingTurns : 0 });
106
+ respond(await nativeCaptureResponse(result));
90
107
  })().catch((error) => respond({ ok: false, message: error instanceof Error ? error.message : String(error) }));
91
108
  });
92
109
  });
@@ -1,3 +1,4 @@
1
+ import { existsSync } from "node:fs";
1
2
  import { requestNativeDaemon } from "./native-ipc.js";
2
3
  /** Thin native adapter. The Runtime Daemon owns Cohub auth, WS, retries and archives. */
3
4
  export default function cohubNativeExtension(pi) {
@@ -6,7 +7,6 @@ export default function cohubNativeExtension(pi) {
6
7
  let context = null;
7
8
  let captures = Promise.resolve();
8
9
  let lastError = "";
9
- let timer;
10
10
  const report = (error) => {
11
11
  const message = error instanceof Error ? error.message : String(error);
12
12
  if (message !== lastError)
@@ -19,9 +19,16 @@ export default function cohubNativeExtension(pi) {
19
19
  if (!path)
20
20
  return Promise.resolve();
21
21
  captures = captures.then(async () => {
22
+ // Pi persists the transcript lazily; a not-yet-created file is not a sync failure.
23
+ if (!existsSync(path))
24
+ return;
22
25
  const result = await requestNativeDaemon({ harness: "pi", cwd: ctx.cwd, path, nativeSessionId: ctx.sessionManager.getSessionId(), leafId: ctx.sessionManager.getLeafId(), settled });
23
- if (!result.ok)
26
+ // Nothing to sync yet (unbound workspace, or config not installed) is silence, not a warning.
27
+ if (!result.ok) {
28
+ if (result.skipped)
29
+ return;
24
30
  throw new Error(result.message);
31
+ }
25
32
  ctx.ui.setStatus("cohub", "Cohub");
26
33
  lastError = "";
27
34
  }).catch(report);
@@ -29,17 +36,10 @@ export default function cohubNativeExtension(pi) {
29
36
  };
30
37
  pi.on("session_start", async (_event, ctx) => {
31
38
  await capture(ctx, ctx.isIdle());
32
- if (timer)
33
- clearInterval(timer);
34
- timer = setInterval(() => { void capture(ctx, ctx.isIdle()); }, 5000);
35
- timer.unref();
36
39
  });
37
40
  pi.on("message_end", (_event, ctx) => capture(ctx));
38
41
  pi.on("agent_settled", (_event, ctx) => capture(ctx, true));
39
42
  pi.on("session_shutdown", async (_event, ctx) => {
40
- if (timer)
41
- clearInterval(timer);
42
- timer = undefined;
43
43
  await captures;
44
44
  ctx.ui.setStatus("cohub", undefined);
45
45
  context = null;
@@ -32,6 +32,8 @@ export type NativeTurnReceipt = {
32
32
  endBytes: number;
33
33
  contentEndBytes?: number;
34
34
  result: NativeTurnComplete | null;
35
+ sessionStartedAt?: string;
36
+ origin?: "local_import";
35
37
  progress?: NativeTurnProgress;
36
38
  };
37
39
  export type NativeSyncTransport = ArchiveTransport & {
@@ -81,7 +83,10 @@ export declare class NativeSyncStore {
81
83
  private cloudBindingPath;
82
84
  binding(): Promise<NativeBinding>;
83
85
  private initialize;
84
- capture(path: string, transcript: NativeTranscript): Promise<void>;
86
+ capture(path: string, transcript: NativeTranscript, context?: {
87
+ sessionStartedAt?: string;
88
+ origin?: "local_import";
89
+ }): Promise<void>;
85
90
  receipts(pendingOnly?: boolean): Promise<NativeTurnReceipt[]>;
86
91
  status(): Promise<{
87
92
  harness: "codex" | "pi";
@@ -112,7 +112,7 @@ export class NativeSyncStore {
112
112
  await atomicRuntimeJson(this.bindingPath(), binding);
113
113
  return binding;
114
114
  }
115
- async capture(path, transcript) {
115
+ async capture(path, transcript, context = {}) {
116
116
  if (transcript.nativeSessionId !== this.options.nativeSessionId)
117
117
  throw new Error("Native session identity mismatch");
118
118
  await withRuntimeSpaceBindingsLock(async () => {
@@ -156,6 +156,8 @@ export class NativeSyncStore {
156
156
  const result = turn.result ? nativeTurnCompleteSchema.parse(turn.result) : null;
157
157
  const receipt = { version: 1, turnId, key: turn.key, parentKey, parentCloudTurnId: parentKey ? null : parentCloudTurnId,
158
158
  userContent: turn.userContent, startedAt: turn.startedAt, endBytes: turn.endBytes, contentEndBytes: turn.contentEndBytes, result,
159
+ ...(context.sessionStartedAt ? { sessionStartedAt: context.sessionStartedAt } : {}),
160
+ ...(context.origin ? { origin: context.origin } : {}),
159
161
  ...(!result ? { progress: nativeTurnProgressSchema.parse({ revision: turn.endBytes, messages: turn.messages }) } : {}) };
160
162
  if (old && (JSON.stringify(old.userContent) !== JSON.stringify(receipt.userContent) || old.parentKey !== receipt.parentKey))
161
163
  throw new Error("Native Turn changed; original receipt retained");
@@ -238,7 +240,8 @@ export class NativeSyncStore {
238
240
  let request = await readJson(this.requestPath(receipt.turnId));
239
241
  if (!request) {
240
242
  request = nativeTurnStartSchema.parse({ turnId: receipt.turnId, sessionId: parent?.sessionId ?? binding.sessionId, parentTurnId: parent?.turnId ?? receipt.parentCloudTurnId,
241
- branchSessionId: nativeStableId(`${receipt.turnId}:branch`), harness: binding.harness, nativeSessionId: binding.nativeSessionId, userContent: receipt.userContent, startedAt: receipt.startedAt });
243
+ branchSessionId: nativeStableId(`${receipt.turnId}:branch`), harness: binding.harness, nativeSessionId: binding.nativeSessionId, userContent: receipt.userContent, startedAt: receipt.startedAt,
244
+ ...(receipt.sessionStartedAt ? { sessionStartedAt: receipt.sessionStartedAt } : {}), ...(receipt.origin ? { origin: receipt.origin } : {}) });
242
245
  await atomicRuntimeJson(this.requestPath(receipt.turnId), request);
243
246
  }
244
247
  let remote = await readJson(this.cloudBindingPath(receipt.turnId));
@@ -9,6 +9,29 @@ export type NativeSyncConfig = {
9
9
  };
10
10
  export declare const nativeRuntimeRoot: (spaceId: string) => string;
11
11
  export declare const nativeSyncConfigPath: (runtimeRoot: string, identity: string) => string;
12
+ export type NativeImportCandidate = {
13
+ harness: "pi" | "codex";
14
+ path: string;
15
+ nativeSessionId: string;
16
+ turnCount: number;
17
+ sessionStartedAt: string | undefined;
18
+ };
19
+ /** Find existing native transcripts for exactly one bound project. */
20
+ export declare function discoverNativeImportCandidates(root: string, harnesses: ("pi" | "codex")[], options?: {
21
+ signal?: AbortSignal;
22
+ onProgress?: (progress: {
23
+ harness: "pi" | "codex";
24
+ scanned: number;
25
+ candidates: number;
26
+ }) => void;
27
+ }): Promise<{
28
+ candidates: NativeImportCandidate[];
29
+ errors: {
30
+ harness: "pi" | "codex";
31
+ path: string;
32
+ message: string;
33
+ }[];
34
+ }>;
12
35
  export declare function nativeArchiveTransport(spaceId: string, identity: string): Pick<NativeSyncTransport, "prepareRuntimeArchive" | "commitRuntimeArchive" | "getRuntimeArchive">;
13
36
  export declare function readNativeSyncConfig(runtimeRoot: string, identity: string): Promise<NativeSyncConfig | null>;
14
37
  /** Local capture only. Neither Pi callbacks nor Codex hooks wait for Cohub's network. */
@@ -19,7 +42,9 @@ export declare function captureNativeSession(input: {
19
42
  nativeSessionId?: string;
20
43
  settled?: boolean;
21
44
  leafId?: string | null;
45
+ sessionStartedAt?: string;
46
+ origin?: "local_import";
22
47
  }): Promise<NativeSyncStore | null>;
23
48
  /** The existing Runtime supervisor retries receipts even after the original terminal has exited. */
24
49
  export declare function nativeWebSocketTransport(spaceId: string, identity: string, send: (event: NativeRuntimeEvent) => Promise<unknown>): NativeSyncTransport;
25
- export declare function flushNativeSessions(spaceId: string, identity: string, signal: AbortSignal, report: (error: unknown) => void, transportOverride?: NativeSyncTransport): Promise<void>;
50
+ export declare function flushNativeSessions(spaceId: string, identity: string, signal: AbortSignal, report: (error: unknown) => void, transportOverride?: NativeSyncTransport): Promise<boolean>;
@@ -1,14 +1,95 @@
1
- import { readFile } from "node:fs/promises";
1
+ import { readFile, readdir, stat } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { createClient } from "../client.js";
5
5
  import { currentIdentityKey } from "../space.js";
6
6
  import { canonicalRuntimeRoot, getRuntimeSpaceBinding } from "./space-binding.js";
7
- import { readNativeTranscript } from "./native-transcript.js";
7
+ import { readNativeTranscript, readNativeTranscriptHeader } from "./native-transcript.js";
8
8
  import { findRuntimeNativeSession } from "./session-store.js";
9
9
  import { listNativeSyncStores, nativeIdentityHash, NativeSyncStore } from "./native-sync-store.js";
10
10
  export const nativeRuntimeRoot = (spaceId) => join(homedir(), ".local", "state", "cohub", "runtime", spaceId);
11
11
  export const nativeSyncConfigPath = (runtimeRoot, identity) => join(runtimeRoot, "native", nativeIdentityHash(identity), "config.json");
12
+ const piSessionDirectory = (cwd) => {
13
+ const custom = process.env.PI_CODING_AGENT_SESSION_DIR?.trim();
14
+ if (custom)
15
+ return custom;
16
+ const agentDir = process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), ".pi", "agent");
17
+ const safePath = `--${cwd.replace(/^[/\\\\]/, "").replace(/[/\\\\:]/g, "-")}--`;
18
+ return join(agentDir, "sessions", safePath);
19
+ };
20
+ const codexSessionDirectory = () => join(process.env.CODEX_HOME?.trim() || join(homedir(), ".codex"), "sessions");
21
+ const LARGE_TRANSCRIPT_BYTES = 64 * 1024 * 1024;
22
+ async function nativeTranscriptPaths(root, harness, signal) {
23
+ const pending = [harness === "pi" ? piSessionDirectory(root) : codexSessionDirectory()];
24
+ const paths = [];
25
+ const errors = [];
26
+ while (pending.length) {
27
+ signal?.throwIfAborted();
28
+ const directory = pending.pop();
29
+ if (!directory)
30
+ continue;
31
+ const entries = await readdir(directory, { withFileTypes: true }).catch((error) => {
32
+ if (error.code === "ENOENT")
33
+ return [];
34
+ errors.push({ harness, path: directory, message: error instanceof Error ? error.message : String(error) });
35
+ return [];
36
+ });
37
+ for (const entry of entries) {
38
+ const path = join(directory, entry.name);
39
+ if (entry.isDirectory())
40
+ pending.push(path);
41
+ else if (entry.isFile() && entry.name.endsWith(".jsonl"))
42
+ paths.push(path);
43
+ }
44
+ }
45
+ return { paths, errors };
46
+ }
47
+ /** Find existing native transcripts for exactly one bound project. */
48
+ export async function discoverNativeImportCandidates(root, harnesses, options = {}) {
49
+ const candidates = [];
50
+ const errors = [];
51
+ for (const harness of harnesses) {
52
+ options.signal?.throwIfAborted();
53
+ const scanned = await nativeTranscriptPaths(root, harness, options.signal);
54
+ errors.push(...scanned.errors);
55
+ let start = 0;
56
+ while (start < scanned.paths.length) {
57
+ options.signal?.throwIfAborted();
58
+ const paths = scanned.paths.slice(start, start + 2);
59
+ const sizes = await Promise.all(paths.map((path) => stat(path).then((value) => value.size).catch(() => 0)));
60
+ const concurrency = sizes.some((size) => size > LARGE_TRANSCRIPT_BYTES) ? 1 : 2;
61
+ const batch = await Promise.all(paths.slice(0, concurrency).map(async (path) => {
62
+ try {
63
+ const header = await readNativeTranscriptHeader(path, harness, options.signal);
64
+ if (await canonicalRuntimeRoot(header.cwd) !== root)
65
+ return null;
66
+ const transcript = await readNativeTranscript(path, harness, { settled: true, signal: options.signal });
67
+ if (transcript.turns.length === 0)
68
+ return { error: { harness, path, message: "Transcript contains no importable Turns" } };
69
+ return { harness, path, nativeSessionId: transcript.nativeSessionId, turnCount: transcript.turns.length,
70
+ sessionStartedAt: transcript.turns.map((turn) => turn.startedAt).sort()[0] };
71
+ }
72
+ catch (error) {
73
+ return { error: { harness, path, message: error instanceof Error ? error.message : String(error) } };
74
+ }
75
+ }));
76
+ for (const result of batch) {
77
+ options.signal?.throwIfAborted();
78
+ if (!result)
79
+ continue;
80
+ if ("error" in result) {
81
+ if (result.error)
82
+ errors.push(result.error);
83
+ }
84
+ else
85
+ candidates.push(result);
86
+ options.onProgress?.({ harness, scanned: start + batch.length, candidates: candidates.length });
87
+ }
88
+ start += concurrency;
89
+ }
90
+ }
91
+ return { candidates, errors };
92
+ }
12
93
  export function nativeArchiveTransport(spaceId, identity) {
13
94
  const client = createClient().space(spaceId);
14
95
  const guard = async (task) => {
@@ -39,6 +120,10 @@ export async function readNativeSyncConfig(runtimeRoot, identity) {
39
120
  }
40
121
  }
41
122
  const nativeStores = new Map();
123
+ const missing = (error) => error.code === "ENOENT";
124
+ /** Canonical alias, but a missing target reports itself instead of failing capture. */
125
+ const canonicalPathOrNull = async (path) => canonicalRuntimeRoot(path).catch((error) => { if (missing(error))
126
+ return null; throw error; });
42
127
  /** Local capture only. Neither Pi callbacks nor Codex hooks wait for Cohub's network. */
43
128
  export async function captureNativeSession(input) {
44
129
  if (process.env.COHUB_TURN_ID || process.env.COHUB_EXECUTION_TOKEN)
@@ -46,7 +131,11 @@ export async function captureNativeSession(input) {
46
131
  const identity = currentIdentityKey();
47
132
  if (!identity)
48
133
  return null;
49
- const root = await canonicalRuntimeRoot(input.cwd);
134
+ // Pi and Codex create their transcripts lazily, so a pending capture for a file that does
135
+ // not exist yet (or a workspace that has since been removed) is not a sync failure.
136
+ const root = await canonicalPathOrNull(input.cwd);
137
+ if (!root)
138
+ return null;
50
139
  const space = await getRuntimeSpaceBinding(root, identity);
51
140
  if (!space)
52
141
  return null;
@@ -54,9 +143,11 @@ export async function captureNativeSession(input) {
54
143
  const config = await readNativeSyncConfig(runtimeRoot, identity);
55
144
  if (!config || config.root !== root || config.spaceId !== space.spaceId || !config.harnesses.includes(input.harness))
56
145
  return null;
57
- const path = await canonicalRuntimeRoot(input.path);
146
+ const path = await canonicalPathOrNull(input.path);
147
+ if (!path)
148
+ return null;
58
149
  const transcript = await readNativeTranscript(path, input.harness, input);
59
- if (await canonicalRuntimeRoot(transcript.cwd) !== root || input.nativeSessionId && transcript.nativeSessionId !== input.nativeSessionId)
150
+ if (await canonicalPathOrNull(transcript.cwd) !== root || input.nativeSessionId && transcript.nativeSessionId !== input.nativeSessionId)
60
151
  throw new Error("Native transcript belongs to another project or Session");
61
152
  const key = JSON.stringify([identity, space.spaceId, input.harness, transcript.nativeSessionId, path]);
62
153
  let store = nativeStores.get(key);
@@ -71,8 +162,7 @@ export async function captureNativeSession(input) {
71
162
  }
72
163
  if (!store) {
73
164
  const managed = await findRuntimeNativeSession(runtimeRoot, input.harness, transcript.nativeSessionId, path);
74
- const managedPath = managed ? await canonicalRuntimeRoot(managed.path).catch((error) => { if (error.code === "ENOENT")
75
- return null; throw error; }) : null;
165
+ const managedPath = managed ? await canonicalPathOrNull(managed.path) : null;
76
166
  if (candidates.length && managedPath !== path)
77
167
  throw new Error("Native path changed; original bindings retained");
78
168
  // Restored Pi working copies can share a native ID. Existing Cohub sidecars disambiguate them.
@@ -83,7 +173,7 @@ export async function captureNativeSession(input) {
83
173
  nativeStores.delete(nativeStores.keys().next().value ?? "");
84
174
  nativeStores.set(key, store);
85
175
  }
86
- await store.capture(path, transcript);
176
+ await store.capture(path, transcript, { sessionStartedAt: input.sessionStartedAt, origin: input.origin });
87
177
  return store;
88
178
  }
89
179
  /** The existing Runtime supervisor retries receipts even after the original terminal has exited. */
@@ -99,30 +189,33 @@ export function nativeWebSocketTransport(spaceId, identity, send) {
99
189
  }
100
190
  export async function flushNativeSessions(spaceId, identity, signal, report, transportOverride) {
101
191
  if (currentIdentityKey() !== identity)
102
- return;
192
+ return false;
103
193
  const runtimeRoot = nativeRuntimeRoot(spaceId);
104
194
  const config = await readNativeSyncConfig(runtimeRoot, identity);
105
195
  if (!config || config.spaceId !== spaceId)
106
- return;
196
+ return false;
107
197
  const transport = transportOverride;
108
198
  if (!transport)
109
- return;
199
+ return false;
110
200
  const stores = await listNativeSyncStores(runtimeRoot, spaceId, identity, transport);
201
+ let failed = false;
111
202
  for (const store of stores) {
112
203
  signal.throwIfAborted();
113
204
  const binding = await store.binding();
114
205
  if (!config.harnesses.includes(binding.harness))
115
206
  continue;
116
207
  try {
117
- // Codex hooks only push capture requests while its terminal lives; the Daemon keeps re-reading
118
- // the transcript between hooks so Stop-flushed Turns are picked up even if a hook is missed.
208
+ // Codex hooks trigger captures; re-reading here also closes gaps after reconnects or missed hooks.
119
209
  if (binding.harness === "codex")
120
210
  await store.capture(binding.path, await readNativeTranscript(binding.path, "codex"));
121
211
  await store.flush(AbortSignal.any([signal, AbortSignal.timeout(30_000)]));
122
212
  }
123
213
  catch (error) {
124
- if (!signal.aborted)
214
+ if (!signal.aborted) {
215
+ failed = true;
125
216
  report(error);
217
+ }
126
218
  }
127
219
  }
220
+ return failed;
128
221
  }
@@ -20,8 +20,16 @@ export type NativeTranscript = {
20
20
  turns: NativeTranscriptTurn[];
21
21
  prefixes: ReadonlyMap<number, string>;
22
22
  };
23
+ export declare const NATIVE_MAX_RECORD_BYTES: number;
24
+ export declare const NATIVE_MAX_TRANSCRIPT_BYTES: number;
25
+ /** Read only the session header before deciding whether a global Codex file belongs to this project. */
26
+ export declare function readNativeTranscriptHeader(path: string, harness: "pi" | "codex", signal?: AbortSignal): Promise<{
27
+ nativeSessionId: string;
28
+ cwd: string;
29
+ }>;
23
30
  /** Partial trailing records are retried, never parsed or acknowledged as complete. */
24
31
  export declare function readNativeTranscript(path: string, harness: "pi" | "codex", options?: {
25
32
  settled?: boolean;
26
33
  leafId?: string | null;
34
+ signal?: AbortSignal;
27
35
  }): Promise<NativeTranscript>;
@@ -11,6 +11,35 @@ const iso = (value) => {
11
11
  throw new Error("Invalid native timestamp");
12
12
  return date.toISOString();
13
13
  };
14
+ export const NATIVE_MAX_RECORD_BYTES = 256 * 1024 * 1024;
15
+ export const NATIVE_MAX_TRANSCRIPT_BYTES = 1024 * 1024 * 1024;
16
+ const NATIVE_HEADER_MAX_BYTES = 1024 * 1024;
17
+ /** Read only the session header before deciding whether a global Codex file belongs to this project. */
18
+ export async function readNativeTranscriptHeader(path, harness, signal) {
19
+ const stream = createReadStream(path, { signal });
20
+ let buffer = Buffer.alloc(0);
21
+ try {
22
+ for await (const chunk of stream) {
23
+ buffer = Buffer.concat([buffer, chunk]);
24
+ const newline = buffer.indexOf(10);
25
+ if (newline >= 0) {
26
+ const value = record(JSON.parse(buffer.subarray(0, newline).toString("utf8")));
27
+ const payload = record(value.payload);
28
+ const nativeSessionId = harness === "pi" ? text(value.id) : text(payload.id);
29
+ const cwd = harness === "pi" ? text(value.cwd) : text(payload.cwd);
30
+ if ((harness === "pi" && value.type !== "session") || (harness === "codex" && value.type !== "session_meta") || !nativeSessionId || !cwd)
31
+ throw new Error("Invalid native session header");
32
+ return { nativeSessionId, cwd };
33
+ }
34
+ if (buffer.length > NATIVE_HEADER_MAX_BYTES)
35
+ throw new Error("Native session header is too large");
36
+ }
37
+ throw new Error("Native transcript is empty");
38
+ }
39
+ finally {
40
+ stream.destroy();
41
+ }
42
+ }
14
43
  /** Partial trailing records are retried, never parsed or acknowledged as complete. */
15
44
  export async function readNativeTranscript(path, harness, options = {}) {
16
45
  const lines = [];
@@ -23,12 +52,12 @@ export async function readNativeTranscript(path, harness, options = {}) {
23
52
  fragments.push(bytes);
24
53
  pendingBytes += bytes.length;
25
54
  checksum.update(bytes);
26
- if (pendingBytes > 32 * 1024 * 1024)
27
- throw new Error("Native record is too large");
28
- if (offset + pendingBytes > 128 * 1024 * 1024)
29
- throw new Error("Native transcript exceeds the capture limit; original retained");
55
+ if (pendingBytes > NATIVE_MAX_RECORD_BYTES)
56
+ throw new Error("Native record exceeds the 256 MiB local parse limit; original retained");
57
+ if (offset + pendingBytes > NATIVE_MAX_TRANSCRIPT_BYTES)
58
+ throw new Error("Native transcript exceeds the 1 GiB local parse limit; original retained");
30
59
  };
31
- for await (const chunk of createReadStream(path)) {
60
+ for await (const chunk of createReadStream(path, { signal: options.signal })) {
32
61
  let start = 0;
33
62
  for (let end = chunk.indexOf(10); end >= 0; end = chunk.indexOf(10, start)) {
34
63
  append(chunk.subarray(start, end + 1));
@@ -1,3 +1,4 @@
1
+ import type { NativeSyncConfig } from "./native-sync.js";
1
2
  import type { RuntimeDiagnostic, RuntimeDiagnosticLevel } from "./diagnostics.js";
2
3
  export declare const diagnosticLevels: RuntimeDiagnosticLevel[];
3
4
  export declare const atLeastLevel: (level: RuntimeDiagnosticLevel, minimum: RuntimeDiagnosticLevel) => boolean;
@@ -16,6 +17,14 @@ export type RuntimeSummary = {
16
17
  workspaceConnected: boolean;
17
18
  diagnosticsPath: string;
18
19
  background: boolean;
19
- nativeSync?: boolean;
20
20
  };
21
21
  export declare function printRuntimeSummary(summary: RuntimeSummary, json?: boolean, reused?: boolean): void;
22
+ export type NativeSessionStatus = {
23
+ harness: "pi" | "codex";
24
+ nativeSessionId: string;
25
+ sessionId: string | null;
26
+ pendingTurns: number;
27
+ pendingArchives: number;
28
+ };
29
+ /** `runtime status` native sync block: installation state plus per-Harness pending work. */
30
+ export declare function formatNativeSync(config: NativeSyncConfig | null, sessions: NativeSessionStatus[], error?: string | null): string;
@@ -76,3 +76,19 @@ export function printRuntimeSummary(summary, json = false, reused = false) {
76
76
  process.stdout.write(" Ctrl+C to stop\n");
77
77
  process.stdout.write("\n");
78
78
  }
79
+ /** `runtime status` native sync block: installation state plus per-Harness pending work. */
80
+ export function formatNativeSync(config, sessions, error = null) {
81
+ if (error)
82
+ return `Native sync unknown — ${error} · fix or remove the config, then runtime up\n`;
83
+ if (!config?.harnesses.length)
84
+ return "Native sync off · run cohub runtime up to enable\n";
85
+ const lines = [`Native sync enabled · ${config.harnesses.join(", ")}`];
86
+ for (const session of sessions) {
87
+ lines.push(` ${session.harness.padEnd(5)} ${session.nativeSessionId.slice(0, 8)} ${session.pendingTurns} Turns · ${session.pendingArchives} archives pending`);
88
+ }
89
+ if (!sessions.length)
90
+ lines.push(" No native chats captured yet");
91
+ else if (sessions.every((session) => !session.pendingTurns && !session.pendingArchives))
92
+ lines.push(" Up to date");
93
+ return `${lines.join("\n")}\n`;
94
+ }