@neta-art/cohub-cli 7.0.1 → 7.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 +13 -1
- package/dist/commands/desktop.d.ts +9 -0
- package/dist/commands/desktop.js +13 -5
- package/dist/commands/run.d.ts +11 -0
- package/dist/commands/run.js +4 -4
- package/dist/commands/runtime.js +254 -43
- package/dist/commands/sandboxd-binary.d.ts +2 -0
- package/dist/commands/sandboxd-binary.js +37 -16
- package/dist/index.js +4 -2
- package/dist/runtime/archive-store.d.ts +2 -0
- package/dist/runtime/archive-store.js +7 -1
- package/dist/runtime/connection.d.ts +3 -0
- package/dist/runtime/connection.js +242 -36
- package/dist/runtime/diagnostics.d.ts +104 -0
- package/dist/runtime/diagnostics.js +382 -0
- package/dist/runtime/harness.d.ts +3 -2
- package/dist/runtime/harness.js +39 -8
- package/dist/runtime/json-rpc.d.ts +2 -0
- package/dist/runtime/json-rpc.js +12 -1
- package/dist/runtime/projection-store.d.ts +34 -0
- package/dist/runtime/projection-store.js +103 -0
- package/dist/runtime/session-store.d.ts +26 -4
- package/dist/runtime/session-store.js +238 -60
- package/dist/runtime/space-binding.d.ts +45 -0
- package/dist/runtime/space-binding.js +305 -0
- package/dist/runtime/turn-projection.d.ts +43 -0
- package/dist/runtime/turn-projection.js +127 -0
- package/dist/space.d.ts +11 -3
- package/dist/space.js +18 -6
- package/package.json +3 -2
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { chmod, mkdir, open, readFile, readdir, realpath, rename, rm, rmdir, stat, utimes, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
5
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
6
|
+
const CONFIG_DIR_NAME = ".config";
|
|
7
|
+
const CONFIG_FILE_NAME = "runtime-spaces.json";
|
|
8
|
+
const LOCK_STALE_MS = 5 * 60 * 1000;
|
|
9
|
+
const LOCK_HEARTBEAT_MS = 60 * 1000;
|
|
10
|
+
const LOCK_RETRY_MS = 100;
|
|
11
|
+
const LOCK_ATTEMPTS = 600;
|
|
12
|
+
const GLOBAL_LOCK_SUFFIX = ".lock";
|
|
13
|
+
export class RuntimeSpaceBindingsError extends Error {
|
|
14
|
+
name = "RuntimeSpaceBindingsError";
|
|
15
|
+
}
|
|
16
|
+
const missing = (error) => error?.code === "ENOENT";
|
|
17
|
+
const nonEmptyString = (value) => typeof value === "string" && value.trim().length > 0;
|
|
18
|
+
export function runtimeSpaceBindingsPath() {
|
|
19
|
+
return join(homedir(), CONFIG_DIR_NAME, "cohub", CONFIG_FILE_NAME);
|
|
20
|
+
}
|
|
21
|
+
export function normalizeRuntimeRoot(root) {
|
|
22
|
+
return resolve(root);
|
|
23
|
+
}
|
|
24
|
+
export async function canonicalRuntimeRoot(root) {
|
|
25
|
+
return realpath(normalizeRuntimeRoot(root));
|
|
26
|
+
}
|
|
27
|
+
function invalidBindings(path, detail) {
|
|
28
|
+
return new RuntimeSpaceBindingsError(`Runtime Space bindings are invalid${detail ? ` (${detail})` : ""}: ${path}`);
|
|
29
|
+
}
|
|
30
|
+
export function parseRuntimeSpaceBindings(raw, path = CONFIG_FILE_NAME) {
|
|
31
|
+
let value;
|
|
32
|
+
try {
|
|
33
|
+
value = JSON.parse(raw);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
throw invalidBindings(path, "invalid JSON");
|
|
37
|
+
}
|
|
38
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
39
|
+
throw invalidBindings(path);
|
|
40
|
+
}
|
|
41
|
+
const record = value;
|
|
42
|
+
if (record.version !== 1 || !Array.isArray(record.bindings)) {
|
|
43
|
+
throw invalidBindings(path, "unsupported format");
|
|
44
|
+
}
|
|
45
|
+
const seen = new Set();
|
|
46
|
+
const bindings = [];
|
|
47
|
+
for (const item of record.bindings) {
|
|
48
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
|
49
|
+
throw invalidBindings(path, "invalid binding");
|
|
50
|
+
}
|
|
51
|
+
const binding = item;
|
|
52
|
+
if (!nonEmptyString(binding.root) || !isAbsolute(binding.root)) {
|
|
53
|
+
throw invalidBindings(path, "binding root must be absolute");
|
|
54
|
+
}
|
|
55
|
+
if (!nonEmptyString(binding.key) || !nonEmptyString(binding.spaceId)) {
|
|
56
|
+
throw invalidBindings(path, "binding identity is incomplete");
|
|
57
|
+
}
|
|
58
|
+
const root = normalizeRuntimeRoot(binding.root);
|
|
59
|
+
const key = binding.key.trim();
|
|
60
|
+
const spaceId = binding.spaceId.trim();
|
|
61
|
+
const identity = `${key}\u0000${root}`;
|
|
62
|
+
if (seen.has(identity))
|
|
63
|
+
throw invalidBindings(path, "duplicate binding");
|
|
64
|
+
seen.add(identity);
|
|
65
|
+
bindings.push({ root, key, spaceId });
|
|
66
|
+
}
|
|
67
|
+
return { version: 1, bindings };
|
|
68
|
+
}
|
|
69
|
+
export async function readRuntimeSpaceBindings(path = runtimeSpaceBindingsPath()) {
|
|
70
|
+
try {
|
|
71
|
+
return parseRuntimeSpaceBindings(await readFile(path, "utf8"), path);
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
if (missing(error))
|
|
75
|
+
return { version: 1, bindings: [] };
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export function findRuntimeSpaceBinding(bindings, input) {
|
|
80
|
+
const entries = Array.isArray(bindings) ? bindings : bindings.bindings;
|
|
81
|
+
const root = normalizeRuntimeRoot(input.root);
|
|
82
|
+
const key = input.key.trim();
|
|
83
|
+
return entries.find((binding) => binding.root === root && binding.key === key) ?? null;
|
|
84
|
+
}
|
|
85
|
+
function upsertRuntimeSpaceBinding(file, binding) {
|
|
86
|
+
const root = normalizeRuntimeRoot(binding.root);
|
|
87
|
+
const key = binding.key.trim();
|
|
88
|
+
const spaceId = binding.spaceId.trim();
|
|
89
|
+
const nextBinding = { root, key, spaceId };
|
|
90
|
+
const index = file.bindings.findIndex((item) => item.root === root && item.key === key);
|
|
91
|
+
if (index < 0) {
|
|
92
|
+
return { file: { version: 1, bindings: [...file.bindings, nextBinding] }, changed: true };
|
|
93
|
+
}
|
|
94
|
+
const current = file.bindings[index];
|
|
95
|
+
if (current?.root === root && current.key === key && current.spaceId === spaceId) {
|
|
96
|
+
return { file, changed: false };
|
|
97
|
+
}
|
|
98
|
+
const bindings = [...file.bindings];
|
|
99
|
+
bindings[index] = nextBinding;
|
|
100
|
+
return { file: { version: 1, bindings }, changed: true };
|
|
101
|
+
}
|
|
102
|
+
async function writeRuntimeSpaceBindings(path, file) {
|
|
103
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
104
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
105
|
+
try {
|
|
106
|
+
const output = await open(temporary, "wx", 0o600);
|
|
107
|
+
try {
|
|
108
|
+
await output.writeFile(`${JSON.stringify(file, null, 2)}\n`, "utf8");
|
|
109
|
+
await output.sync();
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
await output.close();
|
|
113
|
+
}
|
|
114
|
+
await rename(temporary, path);
|
|
115
|
+
await chmod(path, 0o600);
|
|
116
|
+
if (process.platform !== "win32") {
|
|
117
|
+
const directory = await open(dirname(path), "r");
|
|
118
|
+
try {
|
|
119
|
+
await directory.sync();
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
await directory.close();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
await rm(temporary, { force: true });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const statIfPresent = async (path) => stat(path, { bigint: false }).catch((error) => {
|
|
131
|
+
if (missing(error))
|
|
132
|
+
return null;
|
|
133
|
+
throw error;
|
|
134
|
+
});
|
|
135
|
+
async function lockSnapshot(lockPath) {
|
|
136
|
+
let names;
|
|
137
|
+
try {
|
|
138
|
+
names = await readdir(lockPath);
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
// A malformed non-directory lock is not safe to reclaim automatically.
|
|
142
|
+
if (missing(error) || error.code === "ENOTDIR")
|
|
143
|
+
return null;
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
if (names.length !== 1 || !names[0])
|
|
147
|
+
return null;
|
|
148
|
+
const owner = names[0];
|
|
149
|
+
const info = await statIfPresent(join(lockPath, owner));
|
|
150
|
+
return info ? { owner, mtimeMs: info.mtimeMs } : null;
|
|
151
|
+
}
|
|
152
|
+
async function reclaimStaleLock(lockPath, snapshot) {
|
|
153
|
+
const ownerPath = join(lockPath, snapshot.owner);
|
|
154
|
+
const current = await statIfPresent(ownerPath);
|
|
155
|
+
if (!current || Date.now() - current.mtimeMs <= LOCK_STALE_MS)
|
|
156
|
+
return false;
|
|
157
|
+
// Remove only the owner observed during the stale check. If another process
|
|
158
|
+
// replaced the lock, its owner token is different and rmdir stays harmless.
|
|
159
|
+
await rm(ownerPath, { force: true });
|
|
160
|
+
try {
|
|
161
|
+
await rmdir(lockPath);
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
const code = error.code;
|
|
166
|
+
if (code === "ENOENT" || code === "ENOTEMPTY" || code === "EEXIST")
|
|
167
|
+
return false;
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
async function acquireLock(lockPath) {
|
|
172
|
+
await mkdir(dirname(lockPath), { recursive: true, mode: 0o700 });
|
|
173
|
+
for (let attempt = 0; attempt < LOCK_ATTEMPTS; attempt += 1) {
|
|
174
|
+
try {
|
|
175
|
+
await mkdir(lockPath, { mode: 0o700 });
|
|
176
|
+
const owner = randomUUID();
|
|
177
|
+
try {
|
|
178
|
+
// The owner token is the filename. Removing this exact file before
|
|
179
|
+
// rmdir makes an old process unable to remove a replacement lock.
|
|
180
|
+
await writeFile(join(lockPath, owner), "", { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
await rm(join(lockPath, owner), { force: true });
|
|
184
|
+
await rmdir(lockPath).catch((cleanupError) => {
|
|
185
|
+
const code = cleanupError.code;
|
|
186
|
+
if (code !== "ENOENT" && code !== "ENOTEMPTY" && code !== "EEXIST")
|
|
187
|
+
throw cleanupError;
|
|
188
|
+
});
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
return owner;
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
if (error.code !== "EEXIST")
|
|
195
|
+
throw error;
|
|
196
|
+
const snapshot = await lockSnapshot(lockPath);
|
|
197
|
+
if (snapshot && Date.now() - snapshot.mtimeMs > LOCK_STALE_MS) {
|
|
198
|
+
await reclaimStaleLock(lockPath, snapshot);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
await delay(LOCK_RETRY_MS);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
throw new RuntimeSpaceBindingsError("Timed out waiting for the Runtime Space bindings lock");
|
|
205
|
+
}
|
|
206
|
+
async function releaseLock(lockPath, owner) {
|
|
207
|
+
await rm(join(lockPath, owner), { force: true });
|
|
208
|
+
await rmdir(lockPath).catch((error) => {
|
|
209
|
+
const code = error.code;
|
|
210
|
+
if (code !== "ENOENT" && code !== "ENOTEMPTY" && code !== "EEXIST")
|
|
211
|
+
throw error;
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
async function refreshLock(lockPath, owner) {
|
|
215
|
+
const ownerPath = join(lockPath, owner);
|
|
216
|
+
const current = await stat(ownerPath).catch((error) => {
|
|
217
|
+
if (missing(error))
|
|
218
|
+
return null;
|
|
219
|
+
throw error;
|
|
220
|
+
});
|
|
221
|
+
if (!current)
|
|
222
|
+
return;
|
|
223
|
+
const now = new Date();
|
|
224
|
+
await utimes(ownerPath, now, now);
|
|
225
|
+
}
|
|
226
|
+
export async function withRuntimeSpaceBindingsLock(fn, options = {}) {
|
|
227
|
+
const path = options.path ?? runtimeSpaceBindingsPath();
|
|
228
|
+
const lockPath = options.lockPath ?? `${path}${GLOBAL_LOCK_SUFFIX}`;
|
|
229
|
+
const owner = await acquireLock(lockPath);
|
|
230
|
+
const heartbeat = setInterval(() => {
|
|
231
|
+
void refreshLock(lockPath, owner).catch(() => undefined);
|
|
232
|
+
}, LOCK_HEARTBEAT_MS);
|
|
233
|
+
heartbeat.unref?.();
|
|
234
|
+
try {
|
|
235
|
+
return await fn();
|
|
236
|
+
}
|
|
237
|
+
finally {
|
|
238
|
+
clearInterval(heartbeat);
|
|
239
|
+
await releaseLock(lockPath, owner);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
function bindingLockPath(path, root, key) {
|
|
243
|
+
const digest = createHash("sha256").update(`${key}\u0000${root}`).digest("hex");
|
|
244
|
+
return `${path}.${digest}.lock`;
|
|
245
|
+
}
|
|
246
|
+
async function persistRuntimeSpaceBinding(path, binding) {
|
|
247
|
+
await withRuntimeSpaceBindingsLock(async () => {
|
|
248
|
+
const file = await readRuntimeSpaceBindings(path);
|
|
249
|
+
const result = upsertRuntimeSpaceBinding(file, binding);
|
|
250
|
+
if (result.changed)
|
|
251
|
+
await writeRuntimeSpaceBindings(path, result.file);
|
|
252
|
+
}, { path, lockPath: `${path}${GLOBAL_LOCK_SUFFIX}` });
|
|
253
|
+
}
|
|
254
|
+
export async function getRuntimeSpaceBinding(root, key, path = runtimeSpaceBindingsPath()) {
|
|
255
|
+
if (!nonEmptyString(key))
|
|
256
|
+
return null;
|
|
257
|
+
const canonicalRoot = await canonicalRuntimeRoot(root);
|
|
258
|
+
const file = await readRuntimeSpaceBindings(path);
|
|
259
|
+
return findRuntimeSpaceBinding(file, { root: canonicalRoot, key: key.trim() });
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Resolve a local Runtime Space without silently creating duplicates.
|
|
263
|
+
* The per-binding lock covers creation; the shared file is locked only for
|
|
264
|
+
* short read-modify-write commits.
|
|
265
|
+
*/
|
|
266
|
+
export async function resolveRuntimeSpace(input) {
|
|
267
|
+
const root = await canonicalRuntimeRoot(input.root);
|
|
268
|
+
const explicitSpaceId = input.explicitSpaceId?.trim() || null;
|
|
269
|
+
const identityKey = input.identityKey?.trim() || null;
|
|
270
|
+
const path = input.path ?? runtimeSpaceBindingsPath();
|
|
271
|
+
// An explicit target remains usable even in an environment where there is no
|
|
272
|
+
// decodable identity to scope a persisted local binding to.
|
|
273
|
+
if (!identityKey) {
|
|
274
|
+
if (explicitSpaceId) {
|
|
275
|
+
await input.validateSpace?.(explicitSpaceId);
|
|
276
|
+
return { spaceId: explicitSpaceId, source: "explicit" };
|
|
277
|
+
}
|
|
278
|
+
throw new RuntimeSpaceBindingsError("Cannot remember a local Runtime Space without an authenticated identity");
|
|
279
|
+
}
|
|
280
|
+
const bindingKey = identityKey;
|
|
281
|
+
const lockPath = bindingLockPath(path, root, bindingKey);
|
|
282
|
+
return withRuntimeSpaceBindingsLock(async () => {
|
|
283
|
+
const file = await readRuntimeSpaceBindings(path);
|
|
284
|
+
if (explicitSpaceId) {
|
|
285
|
+
await input.validateSpace?.(explicitSpaceId);
|
|
286
|
+
await persistRuntimeSpaceBinding(path, { root, key: bindingKey, spaceId: explicitSpaceId });
|
|
287
|
+
return { spaceId: explicitSpaceId, source: "explicit" };
|
|
288
|
+
}
|
|
289
|
+
const existing = findRuntimeSpaceBinding(file, { root, key: bindingKey });
|
|
290
|
+
if (existing) {
|
|
291
|
+
await input.validateSpace?.(existing.spaceId);
|
|
292
|
+
return { spaceId: existing.spaceId, source: "binding" };
|
|
293
|
+
}
|
|
294
|
+
// Remote creation and the local binding commit are separate durability
|
|
295
|
+
// domains; a server-side idempotency key is needed to close this crash window.
|
|
296
|
+
const createdSpaceId = await input.createSpace();
|
|
297
|
+
if (!nonEmptyString(createdSpaceId)) {
|
|
298
|
+
throw new RuntimeSpaceBindingsError("Local Runtime Space creation returned no Space ID");
|
|
299
|
+
}
|
|
300
|
+
const spaceId = createdSpaceId.trim();
|
|
301
|
+
await input.validateSpace?.(spaceId);
|
|
302
|
+
await persistRuntimeSpaceBinding(path, { root, key: bindingKey, spaceId });
|
|
303
|
+
return { spaceId, source: "created" };
|
|
304
|
+
}, { path, lockPath });
|
|
305
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { type CanonicalProjectionTurn, type MessageToolCallsFile, type ProjectionInput, type ProjectionTarget, type SessionTurnRecord, type StoredIntermediateMessage, type TurnIntermediateMessagesFile } from "@neta-art/cohub";
|
|
2
|
+
export type SessionTurnProjectionClient = {
|
|
3
|
+
session(sessionId: string): {
|
|
4
|
+
turns: {
|
|
5
|
+
listPaginated(options?: {
|
|
6
|
+
cursor?: number;
|
|
7
|
+
limit?: number;
|
|
8
|
+
direction?: "older" | "newer";
|
|
9
|
+
}, request?: {
|
|
10
|
+
signal?: AbortSignal;
|
|
11
|
+
}): Promise<{
|
|
12
|
+
turns: SessionTurnRecord[];
|
|
13
|
+
hasMore: boolean;
|
|
14
|
+
nextCursor: number | undefined;
|
|
15
|
+
}>;
|
|
16
|
+
get(turnId: string, request?: {
|
|
17
|
+
signal?: AbortSignal;
|
|
18
|
+
}): Promise<{
|
|
19
|
+
turn: SessionTurnRecord;
|
|
20
|
+
}>;
|
|
21
|
+
intermediate: {
|
|
22
|
+
get(turnId: string, messagesObjectKey?: string | null, options?: {
|
|
23
|
+
signal?: AbortSignal;
|
|
24
|
+
}): Promise<TurnIntermediateMessagesFile | null>;
|
|
25
|
+
getToolCalls(turnId: string, message: StoredIntermediateMessage, options?: {
|
|
26
|
+
signal?: AbortSignal;
|
|
27
|
+
}): Promise<MessageToolCallsFile | null>;
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
export type ProjectionSourceTurn = CanonicalProjectionTurn;
|
|
33
|
+
export declare function hydrateSessionTurn(turn: SessionTurnRecord, client: ReturnType<SessionTurnProjectionClient["session"]>, signal?: AbortSignal): Promise<CanonicalProjectionTurn>;
|
|
34
|
+
export declare function listSessionProjectionTurns(source: SessionTurnProjectionClient, sessionId: string, options?: {
|
|
35
|
+
afterSequence?: number | null;
|
|
36
|
+
throughSequence?: number | null;
|
|
37
|
+
excludeTurnId?: string | null;
|
|
38
|
+
signal?: AbortSignal;
|
|
39
|
+
}): Promise<ProjectionSourceTurn[]>;
|
|
40
|
+
export declare function getSessionProjectionTurn(source: SessionTurnProjectionClient, sessionId: string, turnId: string, signal?: AbortSignal): Promise<CanonicalProjectionTurn>;
|
|
41
|
+
export declare function projectTurnBatch(input: Omit<ProjectionInput, "turns"> & {
|
|
42
|
+
turns: ProjectionSourceTurn[];
|
|
43
|
+
}, target: ProjectionTarget, includeHeader?: boolean): import("@neta-art/cohub").NativeProjection;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { projectNativeSession } from "@neta-art/cohub";
|
|
2
|
+
const record = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
3
|
+
const iso = (value) => value ?? new Date(0).toISOString();
|
|
4
|
+
const sourceSessionId = (turn) => turn.sourceSessionId ?? turn.sessionId;
|
|
5
|
+
const sourceTurnId = (turn) => turn.sourceTurnId ?? turn.id;
|
|
6
|
+
const userMessageId = (turn) => {
|
|
7
|
+
const value = record(turn.meta).userMessageId;
|
|
8
|
+
return typeof value === "string" && value ? value : `${turn.id}:user`;
|
|
9
|
+
};
|
|
10
|
+
function hydrateToolDetails(content, calls) {
|
|
11
|
+
const byId = new Map(calls.map((call) => [call.id, call]));
|
|
12
|
+
return content.map((block) => {
|
|
13
|
+
if (block.type === "tool_use") {
|
|
14
|
+
const call = byId.get(block.id);
|
|
15
|
+
return call ? { ...block, input: call.input } : block;
|
|
16
|
+
}
|
|
17
|
+
if (block.type === "tool_result") {
|
|
18
|
+
const call = byId.get(block.tool_use_id);
|
|
19
|
+
return call?.result ? { ...block, content: call.result.content ?? block.content, is_error: call.result.isError } : block;
|
|
20
|
+
}
|
|
21
|
+
return block;
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
async function hydrateIntermediateMessage(turn, message, client, signal) {
|
|
25
|
+
signal?.throwIfAborted();
|
|
26
|
+
const toolCalls = await client.turns.intermediate.getToolCalls(turn.id, message, { signal });
|
|
27
|
+
return {
|
|
28
|
+
id: message.id,
|
|
29
|
+
turnId: turn.id,
|
|
30
|
+
role: message.role,
|
|
31
|
+
content: hydrateToolDetails(message.content, toolCalls?.toolCalls ?? []),
|
|
32
|
+
provider: message.provider,
|
|
33
|
+
model: message.model,
|
|
34
|
+
usage: message.usage,
|
|
35
|
+
stopReason: message.stopReason,
|
|
36
|
+
errorMessage: message.errorMessage,
|
|
37
|
+
meta: message.meta,
|
|
38
|
+
sourceSessionId: message.sessionId,
|
|
39
|
+
sequence: message.sequence ?? 0,
|
|
40
|
+
createdAt: iso(message.createdAt),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
async function mapWithConcurrency(items, concurrency, mapper) {
|
|
44
|
+
const results = new Array(items.length);
|
|
45
|
+
let next = 0;
|
|
46
|
+
const worker = async () => {
|
|
47
|
+
for (;;) {
|
|
48
|
+
const index = next++;
|
|
49
|
+
if (index >= items.length)
|
|
50
|
+
return;
|
|
51
|
+
const item = items[index];
|
|
52
|
+
if (item === undefined)
|
|
53
|
+
return;
|
|
54
|
+
results[index] = await mapper(item);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker));
|
|
58
|
+
return results;
|
|
59
|
+
}
|
|
60
|
+
export async function hydrateSessionTurn(turn, client, signal) {
|
|
61
|
+
const sourceId = sourceSessionId(turn);
|
|
62
|
+
const messages = [];
|
|
63
|
+
if (turn.userContent.length) {
|
|
64
|
+
messages.push({
|
|
65
|
+
id: userMessageId(turn), turnId: turn.id, role: "user", content: turn.userContent,
|
|
66
|
+
meta: { userMessageId: userMessageId(turn), turnId: turn.id }, sourceSessionId: sourceId,
|
|
67
|
+
sequence: turn.sequence * 1_000_000, createdAt: iso(turn.createdAt),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
const objectKey = turn.intermediateIndex?.messagesObjectKey;
|
|
71
|
+
if (!objectKey && (turn.intermediateSummary?.messageCount ?? 0) > 0)
|
|
72
|
+
throw new Error(`Intermediate message index is unavailable for Turn ${turn.id}`);
|
|
73
|
+
if (objectKey) {
|
|
74
|
+
const archive = await client.turns.intermediate.get(turn.id, objectKey, { signal });
|
|
75
|
+
if (!archive)
|
|
76
|
+
throw new Error(`Intermediate messages are unavailable for Turn ${turn.id}`);
|
|
77
|
+
const intermediate = await mapWithConcurrency(archive.messages, 8, (message) => hydrateIntermediateMessage(turn, message, client, signal));
|
|
78
|
+
messages.push(...intermediate.map((message) => ({ ...message, sequence: turn.sequence * 1_000_000 + 100_000 + message.sequence })));
|
|
79
|
+
}
|
|
80
|
+
const hasFinalGenerationResult = messages.some((message) => message.role === "assistant" && message.meta?.messageKind === "generation_result" && ["completed", "failed"].includes(String(message.meta.generationStatus)));
|
|
81
|
+
if (turn.assistantContent?.length && !hasFinalGenerationResult) {
|
|
82
|
+
messages.push({
|
|
83
|
+
id: `${turn.id}:assistant`, turnId: turn.id, role: turn.intent === "compact" ? "system" : "assistant", content: turn.assistantContent,
|
|
84
|
+
provider: turn.provider, model: turn.model, usage: turn.finalUsage, stopReason: turn.stopReason, errorMessage: turn.errorMessage,
|
|
85
|
+
meta: { ...(turn.meta ?? {}), turnId: turn.id, createdAt: turn.createdAt }, sourceSessionId: sourceId,
|
|
86
|
+
sequence: turn.sequence * 1_000_000 + 900_000, createdAt: iso(turn.createdAt),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
messages.sort((left, right) => left.sequence - right.sequence || left.id.localeCompare(right.id));
|
|
90
|
+
return {
|
|
91
|
+
id: turn.id, sourceSessionId: sourceId, sourceTurnId: sourceTurnId(turn), sequence: turn.sequence,
|
|
92
|
+
status: turn.status, intent: turn.intent, provider: turn.provider, model: turn.model,
|
|
93
|
+
userContent: turn.userContent, assistantContent: turn.assistantContent, meta: turn.meta,
|
|
94
|
+
createdAt: iso(turn.createdAt), startedAt: turn.startedAt, completedAt: turn.completedAt, durationMs: turn.durationMs,
|
|
95
|
+
messages,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
export async function listSessionProjectionTurns(source, sessionId, options = {}) {
|
|
99
|
+
const client = source.session(sessionId);
|
|
100
|
+
const turns = [];
|
|
101
|
+
const throughSequence = options.throughSequence ?? null;
|
|
102
|
+
let cursor = options.afterSequence && options.afterSequence > 0 ? options.afterSequence : undefined;
|
|
103
|
+
for (;;) {
|
|
104
|
+
options.signal?.throwIfAborted();
|
|
105
|
+
const page = await client.turns.listPaginated({ cursor, limit: 100, direction: "newer" }, { signal: options.signal });
|
|
106
|
+
const visible = page.turns.filter((turn) => (throughSequence == null || turn.sequence <= throughSequence) && turn.id !== options.excludeTurnId && !["queued", "running", "abort_requested"].includes(turn.status));
|
|
107
|
+
turns.push(...visible);
|
|
108
|
+
if (throughSequence != null && page.turns.some((turn) => turn.sequence >= throughSequence))
|
|
109
|
+
break;
|
|
110
|
+
if (!page.hasMore)
|
|
111
|
+
break;
|
|
112
|
+
if (page.nextCursor === undefined || page.nextCursor === cursor)
|
|
113
|
+
throw new Error("Session Turn pagination did not advance");
|
|
114
|
+
cursor = page.nextCursor;
|
|
115
|
+
}
|
|
116
|
+
return mapWithConcurrency(turns, 4, (turn) => hydrateSessionTurn(turn, client, options.signal));
|
|
117
|
+
}
|
|
118
|
+
export async function getSessionProjectionTurn(source, sessionId, turnId, signal) {
|
|
119
|
+
const client = source.session(sessionId);
|
|
120
|
+
return hydrateSessionTurn((await client.turns.get(turnId, { signal })).turn, client, signal);
|
|
121
|
+
}
|
|
122
|
+
export function projectTurnBatch(input, target, includeHeader = true) {
|
|
123
|
+
const projection = projectNativeSession(input, target);
|
|
124
|
+
if (includeHeader)
|
|
125
|
+
return projection;
|
|
126
|
+
return { ...projection, records: projection.records.filter((record) => record.sourceTurnId !== null) };
|
|
127
|
+
}
|
package/dist/space.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ export declare function identityKeyFrom(input: {
|
|
|
10
10
|
idToken?: string | null;
|
|
11
11
|
accessToken?: string | null;
|
|
12
12
|
}): string | null;
|
|
13
|
+
export declare function currentIdentityKey(): string | null;
|
|
13
14
|
/** Exported for tests; production always uses `CACHE_PATH`. */
|
|
14
15
|
export declare function readDefaultSpaceCache(path: string, key: string, now?: number): string | null;
|
|
15
16
|
export declare function clearDefaultSpaceCache(): void;
|
|
@@ -25,8 +26,15 @@ export declare function resolveDefaultSpace(): Promise<string | null>;
|
|
|
25
26
|
/** Shared exit for commands that need a space but resolved none. */
|
|
26
27
|
export declare function missingSpaceError(): never;
|
|
27
28
|
/**
|
|
28
|
-
*
|
|
29
|
-
* user's
|
|
30
|
-
* failures go through the shared HTTP error handler.
|
|
29
|
+
* Resolve a Space from an optional explicit target, the current directory
|
|
30
|
+
* binding, and finally the user's Home Space.
|
|
31
31
|
*/
|
|
32
|
+
export declare function resolveBoundSpace(options?: {
|
|
33
|
+
cwd?: string;
|
|
34
|
+
bindingsPath?: string;
|
|
35
|
+
}): Promise<string | null>;
|
|
36
|
+
export declare function resolveSpaceTarget(target?: string | null, options?: {
|
|
37
|
+
cwd?: string;
|
|
38
|
+
bindingsPath?: string;
|
|
39
|
+
}): Promise<string>;
|
|
32
40
|
export declare function resolveSpace(program: Command): Promise<string>;
|
package/dist/space.js
CHANGED
|
@@ -4,6 +4,7 @@ import { join } from "node:path";
|
|
|
4
4
|
import { resolveCohubEnvironment } from "@neta-art/cohub";
|
|
5
5
|
import { readAuthSession } from "./auth.js";
|
|
6
6
|
import { createClient } from "./client.js";
|
|
7
|
+
import { getRuntimeSpaceBinding } from "./runtime/space-binding.js";
|
|
7
8
|
import { error, handleHttp } from "./output.js";
|
|
8
9
|
const CONFIG_DIR = join(homedir(), ".config", "cohub");
|
|
9
10
|
const CACHE_PATH = join(CONFIG_DIR, "default-space.json");
|
|
@@ -35,7 +36,7 @@ export function identityKeyFrom(input) {
|
|
|
35
36
|
const sub = jwtClaim(input.idToken, "sub") ?? jwtClaim(input.accessToken, "sub");
|
|
36
37
|
return sub ? `${input.env}:${sub}` : null;
|
|
37
38
|
}
|
|
38
|
-
function
|
|
39
|
+
export function currentIdentityKey() {
|
|
39
40
|
const session = readAuthSession();
|
|
40
41
|
return identityKeyFrom({
|
|
41
42
|
env: resolveCohubEnvironment(),
|
|
@@ -97,7 +98,7 @@ export function explicitSpace(program) {
|
|
|
97
98
|
*/
|
|
98
99
|
export function resolveDefaultSpace() {
|
|
99
100
|
defaultSpacePromise ??= (async () => {
|
|
100
|
-
const key =
|
|
101
|
+
const key = currentIdentityKey();
|
|
101
102
|
if (key) {
|
|
102
103
|
const cached = readDefaultSpaceCache(CACHE_PATH, key);
|
|
103
104
|
if (cached)
|
|
@@ -116,10 +117,21 @@ export function missingSpaceError() {
|
|
|
116
117
|
return error("No target space", "Add -s, --space <id> or set COHUB_SPACE_ID. Run `cohub auth login` to use your home space.");
|
|
117
118
|
}
|
|
118
119
|
/**
|
|
119
|
-
*
|
|
120
|
-
* user's
|
|
121
|
-
* failures go through the shared HTTP error handler.
|
|
120
|
+
* Resolve a Space from an optional explicit target, the current directory
|
|
121
|
+
* binding, and finally the user's Home Space.
|
|
122
122
|
*/
|
|
123
|
+
export async function resolveBoundSpace(options = {}) {
|
|
124
|
+
const bound = await getRuntimeSpaceBinding(options.cwd ?? process.cwd(), currentIdentityKey(), options.bindingsPath).catch(handleHttp);
|
|
125
|
+
return bound?.spaceId ?? null;
|
|
126
|
+
}
|
|
127
|
+
export async function resolveSpaceTarget(target, options = {}) {
|
|
128
|
+
const explicit = target?.trim() || process.env.COHUB_SPACE_ID?.trim() || null;
|
|
129
|
+
if (explicit)
|
|
130
|
+
return explicit;
|
|
131
|
+
return (await resolveBoundSpace(options))
|
|
132
|
+
?? (await resolveDefaultSpace().catch(handleHttp))
|
|
133
|
+
?? missingSpaceError();
|
|
134
|
+
}
|
|
123
135
|
export async function resolveSpace(program) {
|
|
124
|
-
return explicitSpace(program)
|
|
136
|
+
return resolveSpaceTarget(explicitSpace(program));
|
|
125
137
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@neta-art/cohub-cli",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.1.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.20.1"
|
|
26
26
|
},
|
|
27
27
|
"publishConfig": {
|
|
28
28
|
"access": "public"
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
},
|
|
39
39
|
"scripts": {
|
|
40
40
|
"build": "tsc -p tsconfig.build.json",
|
|
41
|
+
"verify:sandboxd": "node --import tsx scripts/verify-sandboxd-release.ts",
|
|
41
42
|
"test": "node ../../scripts/test/run.mjs 'tests/**/*.test.ts'",
|
|
42
43
|
"test:runtime": "node --import tsx --test tests/runtime-*.integration.ts",
|
|
43
44
|
"test:runtime:native": "node --import tsx tests/runtime-native.smoke.ts",
|