@neta-art/cohub-cli 8.1.0 → 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/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 +13 -5
- 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/native-codex-hook.js +1 -1
- package/dist/runtime/native-ipc.d.ts +10 -1
- package/dist/runtime/native-ipc.js +18 -3
- package/dist/runtime/native-pi-extension.js +9 -9
- package/dist/runtime/native-sync.d.ts +1 -1
- package/dist/runtime/native-sync.js +23 -11
- package/dist/runtime/supervisor.js +60 -8
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { resolveExecutionAppId, } from "@neta-art/cohub";
|
|
2
2
|
import { createClient } from "../client.js";
|
|
3
|
-
import { error, handleHttp, json as outJson, jsonRequested, ok, table } from "../output.js";
|
|
3
|
+
import { error, formatLocalDateTime, handleHttp, json as outJson, jsonRequested, ok, table } from "../output.js";
|
|
4
4
|
function collectOption(value, previous = []) {
|
|
5
5
|
return [...previous, value];
|
|
6
6
|
}
|
|
@@ -69,7 +69,7 @@ function printOrder(order) {
|
|
|
69
69
|
{ key: "status", label: "Status" },
|
|
70
70
|
{ key: "amount", label: "Amount" },
|
|
71
71
|
{ key: "paid", label: "Paid" },
|
|
72
|
-
{ key: "created", label: "Created" },
|
|
72
|
+
{ key: "created", label: "Created", format: formatLocalDateTime },
|
|
73
73
|
{ key: "paidAt", label: "Paid At" },
|
|
74
74
|
]);
|
|
75
75
|
}
|
package/dist/commands/me.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createClient } from "../client.js";
|
|
2
|
-
import { handleHttp, json as outJson, jsonRequested, table } from "../output.js";
|
|
2
|
+
import { formatLocalDateTime, handleHttp, json as outJson, jsonRequested, table } from "../output.js";
|
|
3
3
|
function parseInteger(value, name, min) {
|
|
4
4
|
const parsed = Number.parseInt(value, 10);
|
|
5
5
|
if (!Number.isSafeInteger(parsed) || parsed < min) {
|
|
@@ -70,7 +70,7 @@ export function registerMe(program) {
|
|
|
70
70
|
});
|
|
71
71
|
if (jsonRequested(opts))
|
|
72
72
|
return outJson(activity);
|
|
73
|
-
console.log(`\n ${activity.range.from} → ${activity.range.to}`);
|
|
73
|
+
console.log(`\n ${formatLocalDateTime(activity.range.from)} → ${formatLocalDateTime(activity.range.to)}`);
|
|
74
74
|
table([activity.summary], [
|
|
75
75
|
{ key: "totalTokens", label: "Tokens" },
|
|
76
76
|
{ key: "costTotal", label: "Cost ($)" },
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { isUuid, } from "@neta-art/cohub";
|
|
2
2
|
import { createClient } from "../client.js";
|
|
3
|
-
import { table, json as outJson, jsonRequested, error, handleHttp } from "../output.js";
|
|
3
|
+
import { formatLocalDateTime, table, json as outJson, jsonRequested, error, handleHttp } from "../output.js";
|
|
4
4
|
const RESOURCE_TYPES = new Set([
|
|
5
5
|
"turn",
|
|
6
6
|
"session",
|
|
@@ -104,7 +104,7 @@ Examples:
|
|
|
104
104
|
source: `${r.sourceType}:${r.sourceId.slice(0, 8)}`,
|
|
105
105
|
target: formatTarget(r.targetType, r.targetId),
|
|
106
106
|
count: r.count,
|
|
107
|
-
lastSeen: r.updatedAt.slice(0, 10),
|
|
107
|
+
lastSeen: formatLocalDateTime(r.updatedAt).slice(0, 10),
|
|
108
108
|
}));
|
|
109
109
|
table(rows, [
|
|
110
110
|
{ key: "kind", label: "Kind" },
|
|
@@ -1,3 +1,15 @@
|
|
|
1
1
|
import type { Command } from "commander";
|
|
2
|
+
import { type NativeIpcResponse } from "../runtime/native-ipc.js";
|
|
2
3
|
export { resolveLocalSpaceName, parseRuntimeHarnesses } from "../runtime/launch.js";
|
|
4
|
+
export declare function applyNativeImportResponse(result: {
|
|
5
|
+
imported: number;
|
|
6
|
+
failed: Array<{
|
|
7
|
+
path: string;
|
|
8
|
+
message: string;
|
|
9
|
+
}>;
|
|
10
|
+
skipped: Array<{
|
|
11
|
+
path: string;
|
|
12
|
+
message: string;
|
|
13
|
+
}>;
|
|
14
|
+
}, path: string, response: NativeIpcResponse): void;
|
|
3
15
|
export declare function registerRuntime(program: Command): void;
|
package/dist/commands/runtime.js
CHANGED
|
@@ -17,6 +17,16 @@ const reportFailure = (cause) => {
|
|
|
17
17
|
process.stderr.write(`Runtime failed: ${serializeDiagnosticError(cause).message}\n`);
|
|
18
18
|
process.exitCode = 1;
|
|
19
19
|
};
|
|
20
|
+
export function applyNativeImportResponse(result, path, response) {
|
|
21
|
+
if (!response.ok) {
|
|
22
|
+
if (response.skipped) {
|
|
23
|
+
result.skipped.push({ path, message: response.message });
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
throw new Error(response.message);
|
|
27
|
+
}
|
|
28
|
+
result.imported += 1;
|
|
29
|
+
}
|
|
20
30
|
export function registerRuntime(program) {
|
|
21
31
|
const runtime = program.command("runtime").description("Connect a local workspace");
|
|
22
32
|
runtime.command("up [dir]")
|
|
@@ -107,7 +117,7 @@ export function registerRuntime(program) {
|
|
|
107
117
|
const candidates = discovered.candidates.filter((candidate) => !options.session || candidate.nativeSessionId === options.session);
|
|
108
118
|
const result = {
|
|
109
119
|
spaceId, root, harnesses, candidates, errors: discovered.errors,
|
|
110
|
-
imported: 0, failed: [], dryRun: Boolean(options.dryRun),
|
|
120
|
+
imported: 0, failed: [], skipped: [], dryRun: Boolean(options.dryRun),
|
|
111
121
|
complete: discovered.errors.length === 0,
|
|
112
122
|
};
|
|
113
123
|
if (jsonRequested(options) && options.dryRun) {
|
|
@@ -163,9 +173,7 @@ export function registerRuntime(program) {
|
|
|
163
173
|
try {
|
|
164
174
|
const response = await requestNativeDaemon({ harness: candidate.harness, cwd: root, path: candidate.path, nativeSessionId: candidate.nativeSessionId,
|
|
165
175
|
sessionStartedAt: candidate.sessionStartedAt, origin: "local_import", settled: true });
|
|
166
|
-
|
|
167
|
-
throw new Error(response.message);
|
|
168
|
-
result.imported += 1;
|
|
176
|
+
applyNativeImportResponse(result, candidate.path, response);
|
|
169
177
|
}
|
|
170
178
|
catch (error) {
|
|
171
179
|
result.failed.push({ path: candidate.path, message: error instanceof Error ? error.message : String(error) });
|
|
@@ -176,7 +184,7 @@ export function registerRuntime(program) {
|
|
|
176
184
|
outJson(result);
|
|
177
185
|
else {
|
|
178
186
|
process.stdout.write(`Imported ${result.imported}/${candidates.length} native conversation${candidates.length === 1 ? "" : "s"}\n`);
|
|
179
|
-
for (const error of [...discovered.errors, ...result.failed])
|
|
187
|
+
for (const error of [...discovered.errors, ...result.skipped, ...result.failed])
|
|
180
188
|
process.stdout.write(`Skipped ${error.path}: ${error.message}\n`);
|
|
181
189
|
process.stdout.write("Uploads continue in the local Runtime background\n");
|
|
182
190
|
}
|
package/dist/commands/search.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { isUuid } from "@neta-art/cohub";
|
|
2
2
|
import { createClient } from "../client.js";
|
|
3
|
-
import { table, json as outJson, jsonRequested, error, handleHttp } from "../output.js";
|
|
3
|
+
import { formatLocalDateTime, table, json as outJson, jsonRequested, error, handleHttp } from "../output.js";
|
|
4
4
|
const DEFAULT_LIMIT = 20;
|
|
5
5
|
const MAX_TITLE_LENGTH = 72;
|
|
6
6
|
const MAX_CONTEXT_LENGTH = 42;
|
|
@@ -50,7 +50,7 @@ function rowsFor(items) {
|
|
|
50
50
|
title: truncate(item.title, MAX_TITLE_LENGTH),
|
|
51
51
|
context: truncate(contextFor(item), MAX_CONTEXT_LENGTH),
|
|
52
52
|
match: item.matchedField,
|
|
53
|
-
updated: item.updatedAt ? item.updatedAt.slice(0, 10) : "",
|
|
53
|
+
updated: item.updatedAt ? formatLocalDateTime(item.updatedAt).slice(0, 10) : "",
|
|
54
54
|
href: item.href,
|
|
55
55
|
}));
|
|
56
56
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createClient } from "../client.js";
|
|
2
|
-
import { error, handleHttp, json as outJson, jsonRequested, ok, table } from "../output.js";
|
|
2
|
+
import { error, formatLocalDateTime, handleHttp, json as outJson, jsonRequested, ok, table } from "../output.js";
|
|
3
3
|
import { resolveSpace } from "../space.js";
|
|
4
4
|
const PRODUCT_STATUSES = ["draft", "active"];
|
|
5
5
|
const PRODUCT_UPDATE_STATUSES = ["draft", "active", "archived"];
|
|
@@ -135,7 +135,7 @@ function printOrders(orders) {
|
|
|
135
135
|
{ key: "product", label: "Product" },
|
|
136
136
|
{ key: "status", label: "Status" },
|
|
137
137
|
{ key: "amount", label: "Amount" },
|
|
138
|
-
{ key: "created", label: "Created" },
|
|
138
|
+
{ key: "created", label: "Created", format: formatLocalDateTime },
|
|
139
139
|
]);
|
|
140
140
|
}
|
|
141
141
|
async function confirmDanger(opts, detail) {
|
package/dist/output.d.ts
CHANGED
|
@@ -10,11 +10,18 @@ export type Column = {
|
|
|
10
10
|
format?: (value: unknown, row: Row) => string;
|
|
11
11
|
};
|
|
12
12
|
/**
|
|
13
|
-
*
|
|
13
|
+
* Timestamp → local ISO-style date and time with the system's UTC offset.
|
|
14
|
+
*
|
|
15
|
+
* CLI tables are for people and follow the machine's local timezone. Structured
|
|
16
|
+
* output remains untouched so scripts continue to receive the server value.
|
|
17
|
+
*/
|
|
18
|
+
export declare function formatLocalDateTime(value: unknown): string;
|
|
19
|
+
/**
|
|
20
|
+
* Epoch milliseconds → local date and time.
|
|
14
21
|
*
|
|
15
22
|
* Absent values render empty rather than falling through `Number()` coercion:
|
|
16
23
|
* `null` and `""` both become 0 there, which would print a confident-looking
|
|
17
|
-
*
|
|
24
|
+
* Unix epoch for a timestamp the server never sent.
|
|
18
25
|
*/
|
|
19
26
|
export declare function formatEpochMs(value: unknown): string;
|
|
20
27
|
export declare function table(rows: Row[], columns: Column[]): void;
|
package/dist/output.js
CHANGED
|
@@ -1,11 +1,34 @@
|
|
|
1
1
|
import process from "node:process";
|
|
2
2
|
import { extractBillingPayload } from "@neta-art/cohub";
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* Timestamp → local ISO-style date and time with the system's UTC offset.
|
|
5
|
+
*
|
|
6
|
+
* CLI tables are for people and follow the machine's local timezone. Structured
|
|
7
|
+
* output remains untouched so scripts continue to receive the server value.
|
|
8
|
+
*/
|
|
9
|
+
export function formatLocalDateTime(value) {
|
|
10
|
+
const date = value instanceof Date
|
|
11
|
+
? value
|
|
12
|
+
: typeof value === "number"
|
|
13
|
+
? new Date(value)
|
|
14
|
+
: typeof value === "string" && value.trim() !== ""
|
|
15
|
+
? new Date(value)
|
|
16
|
+
: null;
|
|
17
|
+
if (!date || Number.isNaN(date.getTime()))
|
|
18
|
+
return "";
|
|
19
|
+
const pad = (part) => String(part).padStart(2, "0");
|
|
20
|
+
const offsetMinutes = -date.getTimezoneOffset();
|
|
21
|
+
const offsetSign = offsetMinutes >= 0 ? "+" : "-";
|
|
22
|
+
const absoluteOffset = Math.abs(offsetMinutes);
|
|
23
|
+
const offset = `${offsetSign}${pad(Math.floor(absoluteOffset / 60))}:${pad(absoluteOffset % 60)}`;
|
|
24
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())} ${offset}`;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Epoch milliseconds → local date and time.
|
|
5
28
|
*
|
|
6
29
|
* Absent values render empty rather than falling through `Number()` coercion:
|
|
7
30
|
* `null` and `""` both become 0 there, which would print a confident-looking
|
|
8
|
-
*
|
|
31
|
+
* Unix epoch for a timestamp the server never sent.
|
|
9
32
|
*/
|
|
10
33
|
export function formatEpochMs(value) {
|
|
11
34
|
const ms = typeof value === "number"
|
|
@@ -15,13 +38,14 @@ export function formatEpochMs(value) {
|
|
|
15
38
|
: Number.NaN;
|
|
16
39
|
if (!Number.isFinite(ms))
|
|
17
40
|
return "";
|
|
18
|
-
|
|
19
|
-
return Number.isNaN(date.getTime()) ? "" : date.toISOString();
|
|
41
|
+
return formatLocalDateTime(ms);
|
|
20
42
|
}
|
|
21
43
|
function cellText(column, row) {
|
|
22
44
|
const raw = row[column.key];
|
|
23
45
|
if (column.format)
|
|
24
46
|
return column.format(raw, row);
|
|
47
|
+
if (/(?:At|Timestamp)$/.test(column.key))
|
|
48
|
+
return formatLocalDateTime(raw);
|
|
25
49
|
const v = raw ?? "";
|
|
26
50
|
return typeof v === "object" ? JSON.stringify(v) : String(v);
|
|
27
51
|
}
|
|
@@ -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() {
|
|
@@ -10,13 +10,22 @@ type NativeIpcRequest = {
|
|
|
10
10
|
sessionStartedAt?: string;
|
|
11
11
|
origin?: "local_import";
|
|
12
12
|
};
|
|
13
|
-
type NativeIpcResponse = {
|
|
13
|
+
export type NativeIpcResponse = {
|
|
14
14
|
ok: true;
|
|
15
15
|
pendingTurns: number;
|
|
16
16
|
} | {
|
|
17
17
|
ok: false;
|
|
18
|
+
skipped?: true;
|
|
18
19
|
message: string;
|
|
19
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>;
|
|
20
29
|
export declare function nativeDaemonSocketFor(cwd: string): Promise<string | null>;
|
|
21
30
|
export declare function requestNativeDaemon(input: Omit<NativeIpcRequest, "type">): Promise<NativeIpcResponse>;
|
|
22
31
|
export declare function serveNativeDaemon(input: {
|
|
@@ -6,6 +6,16 @@ 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)
|
|
@@ -20,14 +30,19 @@ export async function nativeDaemonSocketFor(cwd) {
|
|
|
20
30
|
const identity = currentIdentityKey();
|
|
21
31
|
if (!identity)
|
|
22
32
|
return null;
|
|
23
|
-
|
|
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;
|
|
24
38
|
const binding = await getRuntimeSpaceBinding(root, identity);
|
|
25
39
|
return binding ? socketPath(nativeRuntimeRoot(binding.spaceId)) : null;
|
|
26
40
|
}
|
|
27
41
|
export async function requestNativeDaemon(input) {
|
|
28
42
|
const path = await nativeDaemonSocketFor(input.cwd);
|
|
43
|
+
// An unbound workspace is a normal state, not an error the terminal should surface.
|
|
29
44
|
if (!path)
|
|
30
|
-
return { ok: false, message: "Native Runtime is not bound" };
|
|
45
|
+
return { ok: false, skipped: true, message: "Native Runtime is not bound" };
|
|
31
46
|
return new Promise((resolve, reject) => {
|
|
32
47
|
const socket = createConnection(path);
|
|
33
48
|
let buffer = "";
|
|
@@ -88,7 +103,7 @@ export async function serveNativeDaemon(input) {
|
|
|
88
103
|
void (async () => {
|
|
89
104
|
const request = parse(buffer.slice(0, newline));
|
|
90
105
|
const result = await input.handle(request);
|
|
91
|
-
respond(
|
|
106
|
+
respond(await nativeCaptureResponse(result));
|
|
92
107
|
})().catch((error) => respond({ ok: false, message: error instanceof Error ? error.message : String(error) }));
|
|
93
108
|
});
|
|
94
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;
|
|
@@ -47,4 +47,4 @@ export declare function captureNativeSession(input: {
|
|
|
47
47
|
}): Promise<NativeSyncStore | null>;
|
|
48
48
|
/** The existing Runtime supervisor retries receipts even after the original terminal has exited. */
|
|
49
49
|
export declare function nativeWebSocketTransport(spaceId: string, identity: string, send: (event: NativeRuntimeEvent) => Promise<unknown>): NativeSyncTransport;
|
|
50
|
-
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>;
|
|
@@ -120,6 +120,10 @@ export async function readNativeSyncConfig(runtimeRoot, identity) {
|
|
|
120
120
|
}
|
|
121
121
|
}
|
|
122
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; });
|
|
123
127
|
/** Local capture only. Neither Pi callbacks nor Codex hooks wait for Cohub's network. */
|
|
124
128
|
export async function captureNativeSession(input) {
|
|
125
129
|
if (process.env.COHUB_TURN_ID || process.env.COHUB_EXECUTION_TOKEN)
|
|
@@ -127,7 +131,11 @@ export async function captureNativeSession(input) {
|
|
|
127
131
|
const identity = currentIdentityKey();
|
|
128
132
|
if (!identity)
|
|
129
133
|
return null;
|
|
130
|
-
|
|
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;
|
|
131
139
|
const space = await getRuntimeSpaceBinding(root, identity);
|
|
132
140
|
if (!space)
|
|
133
141
|
return null;
|
|
@@ -135,9 +143,11 @@ export async function captureNativeSession(input) {
|
|
|
135
143
|
const config = await readNativeSyncConfig(runtimeRoot, identity);
|
|
136
144
|
if (!config || config.root !== root || config.spaceId !== space.spaceId || !config.harnesses.includes(input.harness))
|
|
137
145
|
return null;
|
|
138
|
-
const path = await
|
|
146
|
+
const path = await canonicalPathOrNull(input.path);
|
|
147
|
+
if (!path)
|
|
148
|
+
return null;
|
|
139
149
|
const transcript = await readNativeTranscript(path, input.harness, input);
|
|
140
|
-
if (await
|
|
150
|
+
if (await canonicalPathOrNull(transcript.cwd) !== root || input.nativeSessionId && transcript.nativeSessionId !== input.nativeSessionId)
|
|
141
151
|
throw new Error("Native transcript belongs to another project or Session");
|
|
142
152
|
const key = JSON.stringify([identity, space.spaceId, input.harness, transcript.nativeSessionId, path]);
|
|
143
153
|
let store = nativeStores.get(key);
|
|
@@ -152,8 +162,7 @@ export async function captureNativeSession(input) {
|
|
|
152
162
|
}
|
|
153
163
|
if (!store) {
|
|
154
164
|
const managed = await findRuntimeNativeSession(runtimeRoot, input.harness, transcript.nativeSessionId, path);
|
|
155
|
-
const managedPath = managed ? await
|
|
156
|
-
return null; throw error; }) : null;
|
|
165
|
+
const managedPath = managed ? await canonicalPathOrNull(managed.path) : null;
|
|
157
166
|
if (candidates.length && managedPath !== path)
|
|
158
167
|
throw new Error("Native path changed; original bindings retained");
|
|
159
168
|
// Restored Pi working copies can share a native ID. Existing Cohub sidecars disambiguate them.
|
|
@@ -180,30 +189,33 @@ export function nativeWebSocketTransport(spaceId, identity, send) {
|
|
|
180
189
|
}
|
|
181
190
|
export async function flushNativeSessions(spaceId, identity, signal, report, transportOverride) {
|
|
182
191
|
if (currentIdentityKey() !== identity)
|
|
183
|
-
return;
|
|
192
|
+
return false;
|
|
184
193
|
const runtimeRoot = nativeRuntimeRoot(spaceId);
|
|
185
194
|
const config = await readNativeSyncConfig(runtimeRoot, identity);
|
|
186
195
|
if (!config || config.spaceId !== spaceId)
|
|
187
|
-
return;
|
|
196
|
+
return false;
|
|
188
197
|
const transport = transportOverride;
|
|
189
198
|
if (!transport)
|
|
190
|
-
return;
|
|
199
|
+
return false;
|
|
191
200
|
const stores = await listNativeSyncStores(runtimeRoot, spaceId, identity, transport);
|
|
201
|
+
let failed = false;
|
|
192
202
|
for (const store of stores) {
|
|
193
203
|
signal.throwIfAborted();
|
|
194
204
|
const binding = await store.binding();
|
|
195
205
|
if (!config.harnesses.includes(binding.harness))
|
|
196
206
|
continue;
|
|
197
207
|
try {
|
|
198
|
-
// Codex hooks
|
|
199
|
-
// 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.
|
|
200
209
|
if (binding.harness === "codex")
|
|
201
210
|
await store.capture(binding.path, await readNativeTranscript(binding.path, "codex"));
|
|
202
211
|
await store.flush(AbortSignal.any([signal, AbortSignal.timeout(30_000)]));
|
|
203
212
|
}
|
|
204
213
|
catch (error) {
|
|
205
|
-
if (!signal.aborted)
|
|
214
|
+
if (!signal.aborted) {
|
|
215
|
+
failed = true;
|
|
206
216
|
report(error);
|
|
217
|
+
}
|
|
207
218
|
}
|
|
208
219
|
}
|
|
220
|
+
return failed;
|
|
209
221
|
}
|
|
@@ -220,29 +220,81 @@ export async function runRuntime(config, onState, externalSignal, onDiagnostic)
|
|
|
220
220
|
}
|
|
221
221
|
};
|
|
222
222
|
bridgeTask = runBridge();
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
223
|
+
// Native reconciliation is event-driven: captures and channel reconnects wake it, while failures
|
|
224
|
+
// get bounded exponential retries so idle Runtimes do not scan every few seconds.
|
|
225
|
+
let nativeWakePending = true;
|
|
226
|
+
let nativeWake = null;
|
|
227
|
+
let nativeRetryTimer;
|
|
228
|
+
let nativeRetryDelay = 1000;
|
|
229
|
+
const wakeNativeSync = () => {
|
|
230
|
+
nativeWakePending = true;
|
|
231
|
+
if (nativeRetryTimer) {
|
|
232
|
+
clearTimeout(nativeRetryTimer);
|
|
233
|
+
nativeRetryTimer = undefined;
|
|
234
|
+
}
|
|
235
|
+
nativeWake?.();
|
|
236
|
+
nativeWake = null;
|
|
237
|
+
};
|
|
238
|
+
const waitForNativeWake = async () => {
|
|
239
|
+
if (nativeWakePending) {
|
|
240
|
+
nativeWakePending = false;
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
await new Promise((resolve) => {
|
|
244
|
+
const finish = () => {
|
|
245
|
+
signal.removeEventListener("abort", finish);
|
|
246
|
+
if (nativeWake === finish)
|
|
247
|
+
nativeWake = null;
|
|
248
|
+
resolve();
|
|
249
|
+
};
|
|
250
|
+
nativeWake = finish;
|
|
251
|
+
signal.addEventListener("abort", finish, { once: true });
|
|
252
|
+
if (signal.aborted)
|
|
253
|
+
finish();
|
|
254
|
+
});
|
|
255
|
+
nativeWakePending = false;
|
|
256
|
+
};
|
|
226
257
|
nativeSyncTask = (async () => {
|
|
227
258
|
const report = (error) => diagnostics.log("warn", "native.sync_pending", { error: serializeDiagnosticError(error) });
|
|
228
259
|
while (!signal.aborted) {
|
|
260
|
+
await waitForNativeWake();
|
|
261
|
+
if (signal.aborted)
|
|
262
|
+
break;
|
|
263
|
+
if (!nativeSend)
|
|
264
|
+
continue;
|
|
265
|
+
let failed = false;
|
|
229
266
|
try {
|
|
230
|
-
|
|
231
|
-
await flushNativeSessions(config.spaceId, config.identity, signal, report, nativeWebSocketTransport(config.spaceId, config.identity, nativeSend));
|
|
267
|
+
failed = await flushNativeSessions(config.spaceId, config.identity, signal, report, nativeWebSocketTransport(config.spaceId, config.identity, nativeSend));
|
|
232
268
|
}
|
|
233
269
|
catch (error) {
|
|
234
|
-
if (!signal.aborted)
|
|
270
|
+
if (!signal.aborted) {
|
|
235
271
|
report(error);
|
|
272
|
+
failed = true;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (failed && !signal.aborted) {
|
|
276
|
+
nativeRetryTimer = setTimeout(() => { nativeRetryTimer = undefined; wakeNativeSync(); }, nativeRetryDelay);
|
|
277
|
+
nativeRetryDelay = Math.min(30_000, nativeRetryDelay * 2);
|
|
278
|
+
}
|
|
279
|
+
else if (!failed) {
|
|
280
|
+
nativeRetryDelay = 1000;
|
|
236
281
|
}
|
|
237
|
-
await delay(5000, undefined, { signal }).catch(() => undefined);
|
|
238
282
|
}
|
|
283
|
+
if (nativeRetryTimer)
|
|
284
|
+
clearTimeout(nativeRetryTimer);
|
|
239
285
|
})();
|
|
286
|
+
closeNativeDaemon = await serveNativeDaemon({ runtimeRoot: store.root, handle: async (request) => {
|
|
287
|
+
const result = { store: await captureNativeSession(request) };
|
|
288
|
+
if (result.store)
|
|
289
|
+
wakeNativeSync();
|
|
290
|
+
return result;
|
|
291
|
+
} });
|
|
240
292
|
await serveRuntime({
|
|
241
293
|
spaceId: config.spaceId, cwd: config.root, url: url.toString(), capabilities,
|
|
242
294
|
harnesses: config.executables, runtimeId: status.runtimeId, diagnostics, token, signal, store,
|
|
243
295
|
onReady: () => update({ harnessConnected: true }),
|
|
244
296
|
onDisconnected: () => { nativeSend = null; update({ harnessConnected: false }); },
|
|
245
|
-
onNativeChannel: (send) => { nativeSend = send; },
|
|
297
|
+
onNativeChannel: (send) => { nativeSend = send; wakeNativeSync(); },
|
|
246
298
|
});
|
|
247
299
|
}
|
|
248
300
|
catch (error) {
|