@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.
- package/README.md +7 -16
- package/dist/commands/app-commerce.js +2 -2
- package/dist/commands/me.js +2 -2
- package/dist/commands/references.js +2 -2
- package/dist/commands/runtime.d.ts +12 -0
- package/dist/commands/runtime.js +163 -52
- package/dist/commands/sandboxd-binary.d.ts +1 -1
- package/dist/commands/sandboxd-binary.js +4 -3
- package/dist/commands/search.js +2 -2
- package/dist/commands/space-commerce.js +2 -2
- package/dist/output.d.ts +9 -2
- package/dist/output.js +28 -4
- package/dist/runtime/diagnostics.js +8 -1
- package/dist/runtime/launch.js +8 -2
- package/dist/runtime/native-attach.d.ts +28 -0
- package/dist/runtime/native-attach.js +47 -0
- package/dist/runtime/native-codex-hook.js +1 -1
- package/dist/runtime/native-ipc.d.ts +12 -1
- package/dist/runtime/native-ipc.js +21 -4
- package/dist/runtime/native-pi-extension.js +9 -9
- package/dist/runtime/native-sync-store.d.ts +6 -1
- package/dist/runtime/native-sync-store.js +5 -2
- package/dist/runtime/native-sync.d.ts +26 -1
- package/dist/runtime/native-sync.js +107 -14
- package/dist/runtime/native-transcript.d.ts +8 -0
- package/dist/runtime/native-transcript.js +34 -5
- package/dist/runtime/presentation.d.ts +10 -1
- package/dist/runtime/presentation.js +16 -0
- package/dist/runtime/supervisor.js +60 -9
- package/package.json +2 -2
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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<
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 >
|
|
27
|
-
throw new Error("Native record
|
|
28
|
-
if (offset + pendingBytes >
|
|
29
|
-
throw new Error("Native transcript exceeds the
|
|
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
|
+
}
|