@neta-art/cohub-cli 7.0.0 → 7.0.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 +5 -9
- package/dist/commands/apps.js +2 -2
- package/dist/commands/runtime.js +17 -17
- package/dist/runtime/archive-store.js +24 -24
- package/dist/runtime/connection.js +13 -10
- package/dist/runtime/native-archive.js +3 -3
- package/dist/runtime/process-group.js +1 -1
- package/dist/runtime/session-store.d.ts +2 -1
- package/dist/runtime/session-store.js +35 -8
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -79,26 +79,22 @@ cohub -s <spaceId> spaces prompt "message" --json
|
|
|
79
79
|
COHUB_SPACE_ID=<spaceId> cohub spaces prompt "message" --json
|
|
80
80
|
```
|
|
81
81
|
|
|
82
|
-
## Local Runtime
|
|
82
|
+
## Local Runtime
|
|
83
83
|
|
|
84
84
|
Connect one local workspace to a Space and select Pi or Codex per turn.
|
|
85
|
-
将一个本地工作区连接到 Space,每轮可选择 Pi 或 Codex。
|
|
86
85
|
|
|
87
86
|
```bash
|
|
88
87
|
cohub runtime up ./project --harness pi --harness codex
|
|
89
88
|
cohub runtime up ./project --space <spaceId> --harness codex
|
|
90
|
-
cohub -s <spaceId> spaces prompt "Continue
|
|
89
|
+
cohub -s <spaceId> spaces prompt "Continue" --harness codex
|
|
91
90
|
cohub runtime status --space <spaceId>
|
|
92
91
|
```
|
|
93
92
|
|
|
94
93
|
The Runtime uses WebSockets, native Pi RPC / Codex app-server, and existing cloud
|
|
95
94
|
streaming. Local executables and credentials are required. `sandbox up` has been
|
|
96
95
|
removed. Reconnection and result reconciliation are automatic; no recovery command is needed.
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
Runtime 使用 WebSocket、原生 Pi RPC / Codex app-server 和现有云端流式链路。
|
|
100
|
-
需要本机已安装并登录对应程序。Runtime 断线重连和结果核对自动完成,无需恢复命令。
|
|
101
|
-
仅在无法确定结果时,由 Space 页头提供异常确认。旧 `sandbox up` 命令已移除。
|
|
96
|
+
When a result genuinely cannot be determined, the affected Chat offers an explicit stop
|
|
97
|
+
confirmation. See [Runtime details](../../docs/local-runtime.md).
|
|
102
98
|
|
|
103
99
|
## Chats and prompts
|
|
104
100
|
|
|
@@ -378,7 +374,7 @@ legacy bare targets. A bare target checks the current Space for a file first, th
|
|
|
378
374
|
falls back to the same App references as `apps get`. Showing a preview is
|
|
379
375
|
idempotent: repeating it re-activates the same tab and refreshes any launch state
|
|
380
376
|
carried by the reference. With `--call`, the command waits for the App to announce readiness, invokes the method,
|
|
381
|
-
and waits for the App to complete the same UI command with `client.
|
|
377
|
+
and waits for the App to complete the same UI command with `client.desktop.reportResult()`.
|
|
382
378
|
|
|
383
379
|
App authors decide what is callable by registering handlers inside the App:
|
|
384
380
|
|
package/dist/commands/apps.js
CHANGED
|
@@ -508,7 +508,7 @@ export function registerApps(program) {
|
|
|
508
508
|
.option("--app-scope <scope>", "Scope granted directly to the app runtime (space.view, session.view, file.view, file.edit, taskrun.view, session.prompt.readonly, session.prompt.fullaccess, command.execute)", collectOption, [])
|
|
509
509
|
.option("--viewer-scope <scope>", "Deprecated: viewer grants are no longer gated by the app configuration", collectOption, [])
|
|
510
510
|
.option("--clear-app-scopes", "Clear app runtime scopes")
|
|
511
|
-
.option("--clear-viewer-scopes", "Deprecated: clear legacy scope metadata
|
|
511
|
+
.option("--clear-viewer-scopes", "Deprecated: clear legacy scope metadata")
|
|
512
512
|
.option("--meta <json>", "App metadata as a JSON object")
|
|
513
513
|
.option("--hide-cohub-bar", "Hide the Cohub footer bar on the public app page")
|
|
514
514
|
.option("--show-cohub-bar", "Show the Cohub footer bar on the public app page")
|
|
@@ -728,7 +728,7 @@ export function registerApps(program) {
|
|
|
728
728
|
.description("Grant an app scopes as the current user")
|
|
729
729
|
.requiredOption("--scope <scope>", "Scope to grant (repeatable)", collectOption, [])
|
|
730
730
|
.option("--space <spaceId>", "Target space; defaults to the app's own space")
|
|
731
|
-
.option("--extend", "Add scopes without replacing active grants
|
|
731
|
+
.option("--extend", "Add scopes without replacing active grants")
|
|
732
732
|
.option("--json", "Output as JSON")
|
|
733
733
|
.action(async (appRef, opts) => {
|
|
734
734
|
const client = createClient();
|
package/dist/commands/runtime.js
CHANGED
|
@@ -15,20 +15,20 @@ export const resolveLocalSpaceName = (root, name) => name?.trim() || basename(ro
|
|
|
15
15
|
export function parseRuntimeHarnesses(values) {
|
|
16
16
|
const names = values.flatMap((value) => value.split(",")).map((name) => name.trim()).filter(Boolean);
|
|
17
17
|
if (names.some((name) => !isLocalHarness(name)))
|
|
18
|
-
throw new Error("Harness must be pi or codex
|
|
18
|
+
throw new Error("Harness must be pi or codex");
|
|
19
19
|
return [...new Set(names.length ? names : ["pi"])];
|
|
20
20
|
}
|
|
21
21
|
export function registerRuntime(program) {
|
|
22
|
-
const runtime = program.command("runtime").description("Connect a local workspace
|
|
22
|
+
const runtime = program.command("runtime").description("Connect a local workspace");
|
|
23
23
|
runtime.command("up [dir]")
|
|
24
|
-
.description("Connect local Harnesses and files
|
|
25
|
-
.option("-s, --space <id>", "Target Space
|
|
26
|
-
.option("-n, --name <name>", "New Space name
|
|
27
|
-
.option("--harness <name>", "Pi or Codex; repeatable
|
|
28
|
-
.option("--pi <path>", "Pi executable
|
|
29
|
-
.option("--codex <path>", "Codex executable
|
|
30
|
-
.option("-y, --yes", "Accept local execution access
|
|
31
|
-
.option("--json", "JSON output
|
|
24
|
+
.description("Connect local Harnesses and files")
|
|
25
|
+
.option("-s, --space <id>", "Target Space")
|
|
26
|
+
.option("-n, --name <name>", "New Space name")
|
|
27
|
+
.option("--harness <name>", "Pi or Codex; repeatable", (value, previous) => [...previous, value], [])
|
|
28
|
+
.option("--pi <path>", "Pi executable")
|
|
29
|
+
.option("--codex <path>", "Codex executable")
|
|
30
|
+
.option("-y, --yes", "Accept local execution access")
|
|
31
|
+
.option("--json", "JSON output")
|
|
32
32
|
.action(async (dir, options) => {
|
|
33
33
|
const controller = new AbortController();
|
|
34
34
|
const stop = () => controller.abort();
|
|
@@ -37,14 +37,14 @@ export function registerRuntime(program) {
|
|
|
37
37
|
try {
|
|
38
38
|
const root = resolve(dir ?? process.cwd());
|
|
39
39
|
if (!(await stat(root)).isDirectory())
|
|
40
|
-
throw new Error("Workspace is not a directory
|
|
40
|
+
throw new Error("Workspace is not a directory");
|
|
41
41
|
const harnesses = parseRuntimeHarnesses(options.harness);
|
|
42
42
|
if (!options.yes) {
|
|
43
43
|
if (!process.stdin.isTTY)
|
|
44
|
-
throw new Error("Use --yes to authorize local execution
|
|
44
|
+
throw new Error("Use --yes to authorize local execution");
|
|
45
45
|
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
46
46
|
try {
|
|
47
|
-
const answer = await rl.question(`Connect ${root}? Space collaborators can run commands as your OS user, beyond this folder
|
|
47
|
+
const answer = await rl.question(`Connect ${root}? Space collaborators can run commands as your OS user, beyond this folder. [y/N] `);
|
|
48
48
|
if (!/^y(es)?$/i.test(answer.trim()))
|
|
49
49
|
return;
|
|
50
50
|
}
|
|
@@ -58,7 +58,7 @@ export function registerRuntime(program) {
|
|
|
58
58
|
const spaceId = requested || (await client.spaces.create({ name: resolveLocalSpaceName(root, options.name), config: { sandbox: { provider: "local" } } })).space.id;
|
|
59
59
|
const sandbox = (await client.space(spaceId).sandbox.get()).sandbox;
|
|
60
60
|
if (sandbox?.provider !== "local")
|
|
61
|
-
throw new Error("Space does not have a local Runtime
|
|
61
|
+
throw new Error("Space does not have a local Runtime");
|
|
62
62
|
const binary = await ensureSandboxdBinary();
|
|
63
63
|
const wsBase = resolveWebsocketUrl({ url: process.env.COHUB_WS_URL });
|
|
64
64
|
const url = new URL(wsBase);
|
|
@@ -83,7 +83,7 @@ export function registerRuntime(program) {
|
|
|
83
83
|
if (jsonRequested(options))
|
|
84
84
|
outJson({ spaceId, root, harnesses, url: webUrl });
|
|
85
85
|
else
|
|
86
|
-
console.error(`Runtime connected
|
|
86
|
+
console.error(`Runtime connected: ${webUrl}`);
|
|
87
87
|
},
|
|
88
88
|
});
|
|
89
89
|
}
|
|
@@ -99,14 +99,14 @@ export function registerRuntime(program) {
|
|
|
99
99
|
}
|
|
100
100
|
catch (cause) {
|
|
101
101
|
if (!controller.signal.aborted)
|
|
102
|
-
error("Runtime failed
|
|
102
|
+
error("Runtime failed", cause instanceof Error ? cause.message : String(cause));
|
|
103
103
|
}
|
|
104
104
|
finally {
|
|
105
105
|
process.removeListener("SIGINT", stop);
|
|
106
106
|
process.removeListener("SIGTERM", stop);
|
|
107
107
|
}
|
|
108
108
|
});
|
|
109
|
-
runtime.command("status").description("Runtime status
|
|
109
|
+
runtime.command("status").description("Runtime status").option("-s, --space <id>", "Target Space").action(async (options) => {
|
|
110
110
|
const spaceId = options.space?.trim() || await resolveSpace(program);
|
|
111
111
|
const { archives } = new RuntimeSessionStore(spaceId);
|
|
112
112
|
const [status, pendingLocalArchives, failedLocalArchives] = await Promise.all([
|
|
@@ -110,7 +110,7 @@ export class RuntimeArchiveStore {
|
|
|
110
110
|
const saved = await this.readIndex(this.version(turnId));
|
|
111
111
|
if (saved) {
|
|
112
112
|
if (saved.sessionId !== state.sessionId || saved.harness !== state.harness)
|
|
113
|
-
throw new Error("Archive identity mismatch
|
|
113
|
+
throw new Error("Archive identity mismatch");
|
|
114
114
|
const committed = await stat(join(this.root, "ready", `${turnId}.json`)).catch((error) => { if (missing(error))
|
|
115
115
|
return null; throw error; });
|
|
116
116
|
if (!committed)
|
|
@@ -125,7 +125,7 @@ export class RuntimeArchiveStore {
|
|
|
125
125
|
try {
|
|
126
126
|
const before = await file.stat();
|
|
127
127
|
if (!before.isFile() || !before.size)
|
|
128
|
-
throw new Error("Native archive is empty
|
|
128
|
+
throw new Error("Native archive is empty");
|
|
129
129
|
const buffer = Buffer.alloc(RUNTIME_ARCHIVE_SEGMENT_BYTES);
|
|
130
130
|
let offset = 0;
|
|
131
131
|
let digest = createHash("sha256");
|
|
@@ -135,7 +135,7 @@ export class RuntimeArchiveStore {
|
|
|
135
135
|
while (offset < previous.sizeBytes) {
|
|
136
136
|
const { bytesRead } = await file.read(buffer, 0, Math.min(buffer.length, previous.sizeBytes - offset), offset);
|
|
137
137
|
if (!bytesRead)
|
|
138
|
-
throw new Error("Native file changed during capture
|
|
138
|
+
throw new Error("Native file changed during capture");
|
|
139
139
|
digest.update(buffer.subarray(0, bytesRead));
|
|
140
140
|
offset += bytesRead;
|
|
141
141
|
}
|
|
@@ -151,7 +151,7 @@ export class RuntimeArchiveStore {
|
|
|
151
151
|
while (offset < before.size) {
|
|
152
152
|
const { bytesRead } = await file.read(buffer, 0, Math.min(buffer.length, before.size - offset), offset);
|
|
153
153
|
if (!bytesRead)
|
|
154
|
-
throw new Error("Native file changed during capture
|
|
154
|
+
throw new Error("Native file changed during capture");
|
|
155
155
|
const bytes = buffer.subarray(0, bytesRead);
|
|
156
156
|
digest.update(bytes);
|
|
157
157
|
const segment = { offset, sizeBytes: bytesRead, sha256: hash(bytes), md5: hash(bytes, "md5") };
|
|
@@ -161,7 +161,7 @@ export class RuntimeArchiveStore {
|
|
|
161
161
|
}
|
|
162
162
|
const after = await stat(state.path);
|
|
163
163
|
if (before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs)
|
|
164
|
-
throw new Error("Native file changed during capture
|
|
164
|
+
throw new Error("Native file changed during capture");
|
|
165
165
|
if (process.platform !== "win32") {
|
|
166
166
|
const directory = await open(join(this.root, "objects"), "r");
|
|
167
167
|
try {
|
|
@@ -219,7 +219,7 @@ export class RuntimeArchiveStore {
|
|
|
219
219
|
queue.push(index);
|
|
220
220
|
}
|
|
221
221
|
if (pending.size && !queue.length)
|
|
222
|
-
throw new Error("Cyclic archive outbox
|
|
222
|
+
throw new Error("Cyclic archive outbox");
|
|
223
223
|
for (let cursor = 0; cursor < queue.length; cursor++) {
|
|
224
224
|
signal.throwIfAborted();
|
|
225
225
|
const index = queue[cursor];
|
|
@@ -228,19 +228,19 @@ export class RuntimeArchiveStore {
|
|
|
228
228
|
try {
|
|
229
229
|
const { uploads } = await transport.prepareRuntimeArchive(index, { signal: AbortSignal.any([signal, AbortSignal.timeout(30_000)]) });
|
|
230
230
|
if (uploads.length > index.segments.length)
|
|
231
|
-
throw new Error("Upload plan mismatch
|
|
231
|
+
throw new Error("Upload plan mismatch");
|
|
232
232
|
const expected = new Set(index.segments.map((segment) => JSON.stringify(segment)));
|
|
233
233
|
for (const { segment, uploadUrl, headers } of uploads) {
|
|
234
234
|
if (!expected.has(JSON.stringify(segment)))
|
|
235
|
-
throw new Error("Upload segment mismatch
|
|
235
|
+
throw new Error("Upload segment mismatch");
|
|
236
236
|
signal.throwIfAborted();
|
|
237
237
|
const bytes = await readFile(this.blob(segment.sha256));
|
|
238
238
|
if (bytes.length !== segment.sizeBytes || hash(bytes) !== segment.sha256)
|
|
239
|
-
throw new Error("Local archive segment is corrupt
|
|
239
|
+
throw new Error("Local archive segment is corrupt");
|
|
240
240
|
const response = await (transport.fetchObject ?? fetch)(uploadUrl, { method: "PUT", headers, body: bytes, redirect: "error", signal: AbortSignal.any([signal, AbortSignal.timeout(60_000)]) });
|
|
241
241
|
// An immutable segment may already exist after a lost acknowledgement. Commit verifies it.
|
|
242
242
|
if (!response.ok && ![409, 412].includes(response.status))
|
|
243
|
-
throw new Error(`Archive upload failed
|
|
243
|
+
throw new Error(`Archive upload failed: ${response.status}`);
|
|
244
244
|
}
|
|
245
245
|
await transport.commitRuntimeArchive(index, { signal: AbortSignal.any([signal, AbortSignal.timeout(30_000)]) });
|
|
246
246
|
await atomicRuntimeJson(join(this.root, "ready", `${index.turnId}.json`), { turnId: index.turnId, sha256: index.sha256 });
|
|
@@ -249,13 +249,13 @@ export class RuntimeArchiveStore {
|
|
|
249
249
|
}
|
|
250
250
|
catch (error) {
|
|
251
251
|
if (!signal.aborted)
|
|
252
|
-
console.error("Archive pending; native segments retained
|
|
252
|
+
console.error("Archive pending; native segments retained:", error);
|
|
253
253
|
}
|
|
254
254
|
}
|
|
255
255
|
}
|
|
256
256
|
async restore(reference, target, signal) {
|
|
257
257
|
if (!this.transport)
|
|
258
|
-
throw new Error("Archive transport unavailable
|
|
258
|
+
throw new Error("Archive transport unavailable");
|
|
259
259
|
const timeout = (ms) => signal ? AbortSignal.any([signal, AbortSignal.timeout(ms)]) : AbortSignal.timeout(ms);
|
|
260
260
|
const pages = [];
|
|
261
261
|
const visited = new Set();
|
|
@@ -263,18 +263,18 @@ export class RuntimeArchiveStore {
|
|
|
263
263
|
while (turnId) {
|
|
264
264
|
signal?.throwIfAborted();
|
|
265
265
|
if (visited.has(turnId))
|
|
266
|
-
throw new Error("Cyclic archive
|
|
266
|
+
throw new Error("Cyclic archive");
|
|
267
267
|
visited.add(turnId);
|
|
268
268
|
const page = await this.transport.getRuntimeArchive(reference.sessionId, turnId, { signal: timeout(30_000) });
|
|
269
269
|
const index = harnessArchiveIndexSchema.parse(page.index);
|
|
270
270
|
if (index.turnId !== turnId || index.sessionId !== reference.sessionId || index.harness !== reference.harness)
|
|
271
|
-
throw new Error("Archive identity mismatch
|
|
271
|
+
throw new Error("Archive identity mismatch");
|
|
272
272
|
pages.push({ ...page, index });
|
|
273
273
|
turnId = index.parentTurnId;
|
|
274
274
|
}
|
|
275
275
|
const head = pages[0]?.index;
|
|
276
276
|
if (!head)
|
|
277
|
-
throw new Error("Archive missing
|
|
277
|
+
throw new Error("Archive missing");
|
|
278
278
|
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
|
279
279
|
const temporary = `${target}.${randomUUID()}.restoring`;
|
|
280
280
|
const file = await open(temporary, "wx", 0o600);
|
|
@@ -284,53 +284,53 @@ export class RuntimeArchiveStore {
|
|
|
284
284
|
for (const page of pages.reverse()) {
|
|
285
285
|
validateArchiveBoundary(page.index, parent);
|
|
286
286
|
if (page.segments.length !== page.index.segments.length)
|
|
287
|
-
throw new Error("Missing archive segments
|
|
287
|
+
throw new Error("Missing archive segments");
|
|
288
288
|
for (const [ordinal, expected] of page.index.segments.entries()) {
|
|
289
289
|
signal?.throwIfAborted();
|
|
290
290
|
const cached = await readFile(this.blob(expected.sha256)).catch((error) => { if (missing(error))
|
|
291
291
|
return null; throw error; });
|
|
292
292
|
if (cached) {
|
|
293
293
|
if (cached.length !== expected.sizeBytes || hash(cached) !== expected.sha256)
|
|
294
|
-
throw new Error("Cached archive segment is corrupt
|
|
294
|
+
throw new Error("Cached archive segment is corrupt");
|
|
295
295
|
digest.update(cached);
|
|
296
296
|
await file.writeFile(cached);
|
|
297
297
|
continue;
|
|
298
298
|
}
|
|
299
299
|
let link = page.segments[ordinal];
|
|
300
300
|
if (!link || JSON.stringify(link.segment) !== JSON.stringify(expected))
|
|
301
|
-
throw new Error("Archive segment identity mismatch
|
|
301
|
+
throw new Error("Archive segment identity mismatch");
|
|
302
302
|
let response = await (this.transport.fetchObject ?? fetch)(link.downloadUrl, { redirect: "error", signal: timeout(60_000) });
|
|
303
303
|
if ([401, 403].includes(response.status)) {
|
|
304
304
|
const refreshed = await this.transport.getRuntimeArchive(reference.sessionId, page.index.turnId, { signal: timeout(30_000) });
|
|
305
305
|
link = refreshed.segments[ordinal];
|
|
306
306
|
if (!link || JSON.stringify(link.segment) !== JSON.stringify(expected))
|
|
307
|
-
throw new Error("Archive segment missing
|
|
307
|
+
throw new Error("Archive segment missing");
|
|
308
308
|
response = await (this.transport.fetchObject ?? fetch)(link.downloadUrl, { redirect: "error", signal: timeout(60_000) });
|
|
309
309
|
}
|
|
310
310
|
if (!response.ok || !response.body)
|
|
311
|
-
throw new Error(`Archive download failed
|
|
311
|
+
throw new Error(`Archive download failed: ${response.status}`);
|
|
312
312
|
const segmentHash = createHash("sha256");
|
|
313
313
|
let size = 0;
|
|
314
314
|
const chunks = [];
|
|
315
315
|
for await (const chunk of response.body) {
|
|
316
316
|
size += chunk.length;
|
|
317
317
|
if (size > expected.sizeBytes)
|
|
318
|
-
throw new Error("Archive size mismatch
|
|
318
|
+
throw new Error("Archive size mismatch");
|
|
319
319
|
chunks.push(chunk);
|
|
320
320
|
segmentHash.update(chunk);
|
|
321
321
|
digest.update(chunk);
|
|
322
322
|
await file.writeFile(chunk);
|
|
323
323
|
}
|
|
324
324
|
if (size !== expected.sizeBytes || segmentHash.digest("hex") !== expected.sha256)
|
|
325
|
-
throw new Error("Archive checksum mismatch
|
|
325
|
+
throw new Error("Archive checksum mismatch");
|
|
326
326
|
await this.saveBlob(expected.sha256, Buffer.concat(chunks));
|
|
327
327
|
}
|
|
328
328
|
if (digest.copy().digest("hex") !== page.index.sha256)
|
|
329
|
-
throw new Error("Archive version checksum mismatch
|
|
329
|
+
throw new Error("Archive version checksum mismatch");
|
|
330
330
|
parent = page.index;
|
|
331
331
|
}
|
|
332
332
|
if ((await file.stat()).size !== head.sizeBytes)
|
|
333
|
-
throw new Error("Archive length mismatch
|
|
333
|
+
throw new Error("Archive length mismatch");
|
|
334
334
|
await file.sync();
|
|
335
335
|
await file.close();
|
|
336
336
|
await link(temporary, target);
|
|
@@ -10,7 +10,7 @@ export async function serveRuntime(options) {
|
|
|
10
10
|
const uploadSignal = AbortSignal.any([options.signal, uploads.signal]);
|
|
11
11
|
const flush = () => options.store.flushArchives(uploadSignal).catch((error) => {
|
|
12
12
|
if (!uploadSignal.aborted)
|
|
13
|
-
console.error("Archive pending
|
|
13
|
+
console.error("Archive pending:", error);
|
|
14
14
|
});
|
|
15
15
|
const timer = setInterval(() => { void flush(); }, 10_000);
|
|
16
16
|
void flush();
|
|
@@ -20,11 +20,11 @@ export async function serveRuntime(options) {
|
|
|
20
20
|
if (options.signal.aborted)
|
|
21
21
|
return;
|
|
22
22
|
if (outcome === "fatal")
|
|
23
|
-
throw new Error("Runtime connection rejected
|
|
23
|
+
throw new Error("Runtime connection rejected");
|
|
24
24
|
if (outcome === "conflict") {
|
|
25
25
|
conflictSince ??= Date.now();
|
|
26
26
|
if (Date.now() - conflictSince >= (options.leaseConflictTimeoutMs ?? 90_000))
|
|
27
|
-
throw new Error("Space is already connected to another Runtime
|
|
27
|
+
throw new Error("Space is already connected to another Runtime");
|
|
28
28
|
}
|
|
29
29
|
await delay(backoff, undefined, { signal: options.signal }).catch(() => undefined);
|
|
30
30
|
backoff = Math.min(10_000, backoff * 2);
|
|
@@ -37,7 +37,7 @@ export async function serveRuntime(options) {
|
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
39
|
async function connect(options) {
|
|
40
|
-
let
|
|
40
|
+
let currentToken = await options.token();
|
|
41
41
|
const socket = new WebSocket(options.url);
|
|
42
42
|
const active = new Map();
|
|
43
43
|
const seen = new Set();
|
|
@@ -69,9 +69,9 @@ async function connect(options) {
|
|
|
69
69
|
catch {
|
|
70
70
|
stop();
|
|
71
71
|
}
|
|
72
|
-
void options.token().then((next) => { if (next !==
|
|
72
|
+
void options.token().then((next) => { if (next !== currentToken) {
|
|
73
73
|
send({ type: "runtime.auth", token: next });
|
|
74
|
-
|
|
74
|
+
currentToken = next;
|
|
75
75
|
} }).catch(stop);
|
|
76
76
|
}
|
|
77
77
|
}, 10_000);
|
|
@@ -90,7 +90,7 @@ async function connect(options) {
|
|
|
90
90
|
socket.addEventListener("error", () => socket.close());
|
|
91
91
|
socket.addEventListener("open", () => {
|
|
92
92
|
try {
|
|
93
|
-
send({ type: "runtime.hello", version: RUNTIME_PROTOCOL_VERSION, spaceId: options.spaceId, token, capabilities: options.capabilities });
|
|
93
|
+
send({ type: "runtime.hello", version: RUNTIME_PROTOCOL_VERSION, spaceId: options.spaceId, token: currentToken, capabilities: options.capabilities });
|
|
94
94
|
}
|
|
95
95
|
catch {
|
|
96
96
|
stop();
|
|
@@ -108,6 +108,9 @@ async function connect(options) {
|
|
|
108
108
|
connectionId = frame.connectionId;
|
|
109
109
|
clearTimeout(readyTimer);
|
|
110
110
|
options.onReady();
|
|
111
|
+
for await (const executions of options.store.pendingExecutionBatches()) {
|
|
112
|
+
send({ type: "runtime.recovery", executions });
|
|
113
|
+
}
|
|
111
114
|
return;
|
|
112
115
|
}
|
|
113
116
|
if (!connectionId)
|
|
@@ -139,7 +142,7 @@ async function connect(options) {
|
|
|
139
142
|
catch (error) {
|
|
140
143
|
console.error("Runtime acknowledgement failed; result retained:", error);
|
|
141
144
|
active.delete(frame.requestId);
|
|
142
|
-
send({ type: "runtime.event", requestId: frame.requestId, event: { type: "turn.error", message: "Local acknowledgement failed; result retained
|
|
145
|
+
send({ type: "runtime.event", requestId: frame.requestId, event: { type: "turn.error", message: "Local acknowledgement failed; result retained" } });
|
|
143
146
|
return;
|
|
144
147
|
}
|
|
145
148
|
active.delete(frame.requestId);
|
|
@@ -184,7 +187,7 @@ async function connect(options) {
|
|
|
184
187
|
if (!recovery.controller.signal.aborted) {
|
|
185
188
|
console.error("Runtime recovery failed; original files retained:", error);
|
|
186
189
|
try {
|
|
187
|
-
send({ type: "runtime.event", requestId: frame.requestId, event: { type: "turn.error", uncertain: true, message: "Result unavailable; files retained
|
|
190
|
+
send({ type: "runtime.event", requestId: frame.requestId, event: { type: "turn.error", uncertain: true, message: "Result unavailable; files retained" } });
|
|
188
191
|
}
|
|
189
192
|
catch { /* The next connection can read the same result. */ }
|
|
190
193
|
}
|
|
@@ -240,7 +243,7 @@ async function connect(options) {
|
|
|
240
243
|
active.delete(requestId);
|
|
241
244
|
}
|
|
242
245
|
if ([...active.values()].some((entry) => entry.sessionId === frame.input.sessionId) || active.size >= 8) {
|
|
243
|
-
send({ type: "runtime.event", requestId: frame.requestId, event: { type: "turn.error", message: "Local Runtime is busy
|
|
246
|
+
send({ type: "runtime.event", requestId: frame.requestId, event: { type: "turn.error", message: "Local Runtime is busy" } });
|
|
244
247
|
return;
|
|
245
248
|
}
|
|
246
249
|
seen.add(frame.requestId);
|
|
@@ -28,13 +28,13 @@ export async function importNativeArchive(input) {
|
|
|
28
28
|
const header = JSON.parse(Buffer.concat(fragments).toString("utf8"));
|
|
29
29
|
if (input.harness === "pi") {
|
|
30
30
|
if (header?.type !== "session" || header.id !== input.nativeSessionId)
|
|
31
|
-
throw new Error("Pi archive identity mismatch
|
|
31
|
+
throw new Error("Pi archive identity mismatch");
|
|
32
32
|
header.cwd = input.cwd;
|
|
33
33
|
delete header.parentSession;
|
|
34
34
|
}
|
|
35
35
|
else {
|
|
36
36
|
if (header?.type !== "session_meta" || header.payload?.id !== input.nativeSessionId)
|
|
37
|
-
throw new Error("Codex archive identity mismatch
|
|
37
|
+
throw new Error("Codex archive identity mismatch");
|
|
38
38
|
header.payload.id = input.id;
|
|
39
39
|
if (header.payload.session_id != null)
|
|
40
40
|
header.payload.session_id = input.id;
|
|
@@ -56,7 +56,7 @@ export async function importNativeArchive(input) {
|
|
|
56
56
|
const prefix = newline < 0 ? bytes : bytes.subarray(0, newline);
|
|
57
57
|
headerBytes += prefix.length;
|
|
58
58
|
if (headerBytes > RUNTIME_MAX_FRAME_BYTES)
|
|
59
|
-
throw new Error("Native header is too large
|
|
59
|
+
throw new Error("Native header is too large");
|
|
60
60
|
fragments.push(prefix);
|
|
61
61
|
if (newline >= 0) {
|
|
62
62
|
await writeHeader();
|
|
@@ -63,6 +63,6 @@ export async function stopProcessGroup(pid) {
|
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
catch (cause) {
|
|
66
|
-
throw new ProcessCleanupUncertainError("Tool process cleanup could not be confirmed; execution remains unresolved
|
|
66
|
+
throw new ProcessCleanupUncertainError("Tool process cleanup could not be confirmed; execution remains unresolved", { cause });
|
|
67
67
|
}
|
|
68
68
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type RuntimeExecutionEvent, type HarnessArchive, type RuntimeTurnInput } from "@neta-art/cohub";
|
|
1
|
+
import { type RuntimeExecutionEvent, type HarnessArchive, type RuntimePendingExecution, type RuntimeTurnInput } from "@neta-art/cohub";
|
|
2
2
|
import { RuntimeArchiveStore, type ArchiveTransport } from "./archive-store.js";
|
|
3
3
|
import type { CodexTokenTotals } from "./codex-usage.js";
|
|
4
4
|
export declare class ContextRequiredError extends Error {
|
|
@@ -27,6 +27,7 @@ export declare class RuntimeSessionStore {
|
|
|
27
27
|
private archiveFlush;
|
|
28
28
|
constructor(spaceId: string, stateRoot?: string, transport?: ArchiveTransport);
|
|
29
29
|
private statePath;
|
|
30
|
+
pendingExecutionBatches(): AsyncGenerator<RuntimePendingExecution[]>;
|
|
30
31
|
flushArchives(signal: AbortSignal): Promise<void>;
|
|
31
32
|
private flushArchiveOutbox;
|
|
32
33
|
pendingTurnIds(sessionId: string): Promise<string[]>;
|
|
@@ -2,7 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
2
2
|
import { mkdir, open, readFile, readdir, rm } from "node:fs/promises";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
|
-
import { contextToPiMessages, selectRuntimeContextMessages, runtimeEventSchema } from "@neta-art/cohub";
|
|
5
|
+
import { contextToPiMessages, RUNTIME_RECOVERY_BATCH_SIZE, selectRuntimeContextMessages, runtimeEventSchema } from "@neta-art/cohub";
|
|
6
6
|
import { RuntimeArchiveStore, checksumNativeFile, atomicRuntimeJson as atomicJson } from "./archive-store.js";
|
|
7
7
|
import { importNativeArchive, readCodexArchiveTotals } from "./native-archive.js";
|
|
8
8
|
export class ContextRequiredError extends Error {
|
|
@@ -26,6 +26,33 @@ export class RuntimeSessionStore {
|
|
|
26
26
|
this.archives = new RuntimeArchiveStore(join(this.root, "archives"), transport);
|
|
27
27
|
}
|
|
28
28
|
statePath(input) { return join(this.root, input.harness, `${input.sessionId}.json`); }
|
|
29
|
+
async *pendingExecutionBatches() {
|
|
30
|
+
let batch = [];
|
|
31
|
+
for (const harness of ["pi", "codex"]) {
|
|
32
|
+
const directory = join(this.root, harness);
|
|
33
|
+
const names = await readdir(directory).catch((error) => { if (missing(error))
|
|
34
|
+
return []; throw error; });
|
|
35
|
+
for (const name of names) {
|
|
36
|
+
if (!name.endsWith(".json"))
|
|
37
|
+
continue;
|
|
38
|
+
try {
|
|
39
|
+
const state = JSON.parse(await readFile(join(directory, name), "utf8"));
|
|
40
|
+
if (state.version !== 1 || state.harness !== harness || !state.sessionId || !state.pendingTurnId)
|
|
41
|
+
continue;
|
|
42
|
+
batch.push({ sessionId: state.sessionId, turnId: state.pendingTurnId, harness });
|
|
43
|
+
if (batch.length >= RUNTIME_RECOVERY_BATCH_SIZE) {
|
|
44
|
+
yield batch;
|
|
45
|
+
batch = [];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
console.error(`Runtime session state unreadable: ${join(directory, name)}`, error);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (batch.length)
|
|
54
|
+
yield batch;
|
|
55
|
+
}
|
|
29
56
|
async flushArchives(signal) {
|
|
30
57
|
this.archiveFlush ??= this.flushArchiveOutbox(signal).finally(() => { this.archiveFlush = null; });
|
|
31
58
|
return this.archiveFlush;
|
|
@@ -46,20 +73,20 @@ export class RuntimeSessionStore {
|
|
|
46
73
|
state = JSON.parse(receipt);
|
|
47
74
|
}
|
|
48
75
|
catch {
|
|
49
|
-
throw new CaptureUnavailableError("Invalid capture receipt
|
|
76
|
+
throw new CaptureUnavailableError("Invalid capture receipt");
|
|
50
77
|
}
|
|
51
78
|
const turnId = state?.archivePendingTurnId;
|
|
52
79
|
if (typeof turnId !== "string" || typeof state?.path !== "string" || typeof state.resultChecksum !== "string") {
|
|
53
|
-
throw new CaptureUnavailableError("Invalid capture receipt
|
|
80
|
+
throw new CaptureUnavailableError("Invalid capture receipt");
|
|
54
81
|
}
|
|
55
82
|
if (!await this.archives.hasCapture(turnId)) {
|
|
56
83
|
const digest = await checksumNativeFile(state.path).catch((error) => {
|
|
57
84
|
if (missing(error))
|
|
58
|
-
throw new CaptureUnavailableError("Native session missing
|
|
85
|
+
throw new CaptureUnavailableError("Native session missing");
|
|
59
86
|
throw error;
|
|
60
87
|
});
|
|
61
88
|
if (digest !== state.resultChecksum)
|
|
62
|
-
throw new CaptureUnavailableError("Native session changed; original files retained
|
|
89
|
+
throw new CaptureUnavailableError("Native session changed; original files retained");
|
|
63
90
|
signal.throwIfAborted();
|
|
64
91
|
await this.archives.stage(state, turnId);
|
|
65
92
|
}
|
|
@@ -73,10 +100,10 @@ export class RuntimeSessionStore {
|
|
|
73
100
|
receipt, reason: error.message, failedAt: new Date().toISOString(),
|
|
74
101
|
});
|
|
75
102
|
await rm(join(captures, name), { force: true });
|
|
76
|
-
console.error("Archive capture unavailable; receipt retained
|
|
103
|
+
console.error("Archive capture unavailable; receipt retained:", error.message);
|
|
77
104
|
}
|
|
78
105
|
else
|
|
79
|
-
console.error("Archive capture pending
|
|
106
|
+
console.error("Archive capture pending:", error);
|
|
80
107
|
}
|
|
81
108
|
}
|
|
82
109
|
await this.archives.flush(signal);
|
|
@@ -182,7 +209,7 @@ export class RuntimeSessionStore {
|
|
|
182
209
|
}
|
|
183
210
|
catch (error) {
|
|
184
211
|
signal?.throwIfAborted();
|
|
185
|
-
console.error("Native archive unavailable; rebuilding from durable history
|
|
212
|
+
console.error("Native archive unavailable; rebuilding from durable history:", error);
|
|
186
213
|
}
|
|
187
214
|
}
|
|
188
215
|
if (input.context.complete === false)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@neta-art/cohub-cli",
|
|
3
|
-
"version": "7.0.
|
|
3
|
+
"version": "7.0.1",
|
|
4
4
|
"description": "CLI for Cohub — spaces, sessions, and agent collaboration.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"commander": "^15.0.0",
|
|
23
23
|
"pixi.js": "^8.20.1",
|
|
24
24
|
"sharp": "^0.35.4",
|
|
25
|
-
"@neta-art/cohub": "8.
|
|
25
|
+
"@neta-art/cohub": "8.19.0"
|
|
26
26
|
},
|
|
27
27
|
"publishConfig": {
|
|
28
28
|
"access": "public"
|