@ccmsg/cli 0.12.0 → 0.14.0
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/package.json +2 -2
- package/src/auth/admin.ts +5 -2
- package/src/auth/auth.ts +17 -17
- package/src/auth/records.ts +64 -42
- package/src/cli.ts +38 -5
- package/src/daemon/snapshot.ts +3 -3
- package/src/daemon/supervise.ts +15 -7
- package/src/dispatch/dispatch.ts +4 -4
- package/src/files/containment.ts +42 -19
- package/src/files/files.ts +123 -104
- package/src/files/sandbox.ts +0 -0
- package/src/instance/instance.ts +43 -16
- package/src/instance/log.ts +29 -13
- package/src/kv/store.ts +104 -56
- package/src/launcher/launcher.ts +8 -5
- package/src/launcher/roots.ts +10 -7
- package/src/launcher/tree.ts +15 -12
- package/src/mesh/instances.ts +5 -5
- package/src/mesh/mesh.ts +11 -11
- package/src/mesh/relay.ts +8 -8
- package/src/messaging/delivery.ts +126 -27
- package/src/messaging/direct.ts +105 -39
- package/src/messaging/inbox.ts +72 -18
- package/src/messaging/notify.ts +7 -2
- package/src/sessions/dump.ts +73 -11
- package/src/sessions/fork.ts +21 -14
- package/src/sessions/handlers.ts +9 -7
- package/src/sessions/items.ts +10 -8
- package/src/sessions/last-live.ts +28 -5
- package/src/sessions/registry.ts +43 -26
- package/src/sessions/search.ts +16 -7
- package/src/sessions/status.ts +3 -3
- package/src/topics/egress.ts +2 -2
- package/src/topics/topics.ts +0 -0
- package/src/transcript/files.ts +65 -23
- package/src/transcript/read.ts +10 -10
- package/src/transcript/scan.ts +49 -0
package/src/transcript/files.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { readdirSync,
|
|
1
|
+
import { readdirSync, type Stats, statSync } from "node:fs";
|
|
2
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
2
3
|
import { basename, dirname, join } from "node:path";
|
|
3
4
|
import type { Sid, TranscriptSubject } from "@ccmsg/protocol";
|
|
4
5
|
import { type Harness, HARNESS } from "../harness/index.ts";
|
|
@@ -114,7 +115,7 @@ export class TranscriptFiles {
|
|
|
114
115
|
* `agent_id` and `teammate` are two ways of naming the same kind of file and
|
|
115
116
|
* cannot be combined — a request carrying both names two files and is a
|
|
116
117
|
* caller's mistake rather than a choice this makes for them. */
|
|
117
|
-
locate(sid: Sid, names: AgentNames = {}): string {
|
|
118
|
+
async locate(sid: Sid, names: AgentNames = {}): Promise<string> {
|
|
118
119
|
const file = this.session(sid);
|
|
119
120
|
if (names.agent_id !== undefined && names.teammate !== undefined) {
|
|
120
121
|
throw new OpError("invalid_args", "agent_id and teammate name two different transcripts");
|
|
@@ -127,9 +128,11 @@ export class TranscriptFiles {
|
|
|
127
128
|
}
|
|
128
129
|
const under = agentsDir(file, names.run_id);
|
|
129
130
|
if (names.agent_id !== undefined) {
|
|
130
|
-
return existing(
|
|
131
|
+
return await existing(
|
|
132
|
+
join(under, `agent-${name(names.agent_id, AGENT_ID, "agent_id")}${SUFFIX}`),
|
|
133
|
+
);
|
|
131
134
|
}
|
|
132
|
-
return this.teammate(under, name(names.teammate ?? "", TEAMMATE, "teammate"));
|
|
135
|
+
return await this.teammate(under, name(names.teammate ?? "", TEAMMATE, "teammate"));
|
|
133
136
|
}
|
|
134
137
|
|
|
135
138
|
/** Which standing a transcript was written from, which every item read out
|
|
@@ -148,13 +151,13 @@ export class TranscriptFiles {
|
|
|
148
151
|
* nothing goes on standing, nobody is addressed by name — so a teammate read
|
|
149
152
|
* as one loses a name it might have been drawn under, where the reverse would
|
|
150
153
|
* have a reader write back to something that is already gone. */
|
|
151
|
-
subjectOf(file: string): TranscriptSubject {
|
|
154
|
+
async subjectOf(file: string): Promise<TranscriptSubject> {
|
|
152
155
|
const name = basename(file);
|
|
153
156
|
if (!name.startsWith(AGENT_PREFIX) || !name.endsWith(SUFFIX)) return "main";
|
|
154
157
|
let note: unknown;
|
|
155
158
|
try {
|
|
156
159
|
note = JSON.parse(
|
|
157
|
-
|
|
160
|
+
await readFile(join(dirname(file), `${name.slice(0, -SUFFIX.length)}.meta.json`), "utf8"),
|
|
158
161
|
);
|
|
159
162
|
} catch {
|
|
160
163
|
return "sub";
|
|
@@ -168,10 +171,10 @@ export class TranscriptFiles {
|
|
|
168
171
|
* The name a teammate carries in conversation is not its filename, so the
|
|
169
172
|
* directory's own records are read for it rather than the name being
|
|
170
173
|
* substituted into a path. */
|
|
171
|
-
private teammate(under: string, wanted: string): string {
|
|
174
|
+
private async teammate(under: string, wanted: string): Promise<string> {
|
|
172
175
|
let names: string[];
|
|
173
176
|
try {
|
|
174
|
-
names =
|
|
177
|
+
names = await readdir(under);
|
|
175
178
|
} catch {
|
|
176
179
|
throw new OpError("not_found", `no agent has run under this session`);
|
|
177
180
|
}
|
|
@@ -179,13 +182,13 @@ export class TranscriptFiles {
|
|
|
179
182
|
if (!each.endsWith(".meta.json")) continue;
|
|
180
183
|
let document: unknown;
|
|
181
184
|
try {
|
|
182
|
-
document = JSON.parse(
|
|
185
|
+
document = JSON.parse(await readFile(join(under, each), "utf8"));
|
|
183
186
|
} catch {
|
|
184
187
|
continue;
|
|
185
188
|
}
|
|
186
189
|
const named = (document as { name?: unknown } | null)?.name;
|
|
187
190
|
if (named !== wanted) continue;
|
|
188
|
-
return existing(join(under, `${each.slice(0, -".meta.json".length)}${SUFFIX}`));
|
|
191
|
+
return await existing(join(under, `${each.slice(0, -".meta.json".length)}${SUFFIX}`));
|
|
189
192
|
}
|
|
190
193
|
throw new OpError("not_found", `no teammate of this session is addressed as ${wanted}`);
|
|
191
194
|
}
|
|
@@ -195,16 +198,16 @@ export class TranscriptFiles {
|
|
|
195
198
|
* The one enumeration a search and a fork sweep both start from. It states
|
|
196
199
|
* the file and what a `stat` already said about it, so neither has to stat
|
|
197
200
|
* again to decide whether to open it. */
|
|
198
|
-
all(): TranscriptFile[] {
|
|
201
|
+
async all(): Promise<TranscriptFile[]> {
|
|
199
202
|
const layout = LAYOUTS[this.deps.harness];
|
|
200
203
|
const found: TranscriptFile[] = [];
|
|
201
|
-
for (const dir of
|
|
202
|
-
for (const entry of
|
|
204
|
+
for (const dir of await walked(this.#root(), layout.depth)) {
|
|
205
|
+
for (const entry of await listed(dir)) {
|
|
203
206
|
const sid = layout.sidOf(entry);
|
|
204
207
|
if (sid === undefined) continue;
|
|
205
208
|
const file = join(dir, entry);
|
|
206
|
-
const
|
|
207
|
-
if (
|
|
209
|
+
const known = await stated(file);
|
|
210
|
+
if (known === undefined) continue;
|
|
208
211
|
found.push({
|
|
209
212
|
sid,
|
|
210
213
|
file,
|
|
@@ -213,9 +216,9 @@ export class TranscriptFiles {
|
|
|
213
216
|
// a session ran narrows nothing, and the transcript's own `cwd`
|
|
214
217
|
// decides as it already does.
|
|
215
218
|
...(this.deps.harness === "claude" ? { project: basename(dir) } : {}),
|
|
216
|
-
size:
|
|
217
|
-
created_at: Math.round(
|
|
218
|
-
updated_at: Math.round(
|
|
219
|
+
size: known.size,
|
|
220
|
+
created_at: Math.round(known.birthtimeMs || known.ctimeMs),
|
|
221
|
+
updated_at: Math.round(known.mtimeMs),
|
|
219
222
|
});
|
|
220
223
|
}
|
|
221
224
|
}
|
|
@@ -260,7 +263,27 @@ export class TranscriptFiles {
|
|
|
260
263
|
*
|
|
261
264
|
* Names are read rather than dates computed: what is there is what the harness
|
|
262
265
|
* wrote, and a tree with a directory nobody expected is one whose files are
|
|
263
|
-
* still found.
|
|
266
|
+
* still found.
|
|
267
|
+
*
|
|
268
|
+
* There are two of this walk, and of the two readings below it, because the
|
|
269
|
+
* two callers are not alike: `all()` runs from an op and reads the tree
|
|
270
|
+
* without holding the instance, while `path()` also runs from a subscription
|
|
271
|
+
* being opened, which states its value in the turn it is opened in and so
|
|
272
|
+
* cannot wait (DESIGN §6.2). The one that cannot wait is the one CT-Q8 and the
|
|
273
|
+
* issue `fold-from-head-with-versioned-cache` settle, and the two become one
|
|
274
|
+
* reading again when they do. */
|
|
275
|
+
async function walked(root: string, depth: number): Promise<string[]> {
|
|
276
|
+
let level = [root];
|
|
277
|
+
for (let step = 0; step < depth; step += 1) {
|
|
278
|
+
const below: string[] = [];
|
|
279
|
+
for (const dir of level) {
|
|
280
|
+
for (const entry of await listed(dir)) below.push(join(dir, entry));
|
|
281
|
+
}
|
|
282
|
+
level = below;
|
|
283
|
+
}
|
|
284
|
+
return level;
|
|
285
|
+
}
|
|
286
|
+
|
|
264
287
|
function directories(root: string, depth: number): string[] {
|
|
265
288
|
let level = [root];
|
|
266
289
|
for (let step = 0; step < depth; step += 1) {
|
|
@@ -305,11 +328,21 @@ function name(value: string, shape: RegExp, field: string): string {
|
|
|
305
328
|
return value;
|
|
306
329
|
}
|
|
307
330
|
|
|
308
|
-
function existing(file: string): string {
|
|
309
|
-
if (
|
|
331
|
+
async function existing(file: string): Promise<string> {
|
|
332
|
+
if ((await stated(file)) === undefined) {
|
|
333
|
+
throw new OpError("not_found", "no transcript is held for that agent");
|
|
334
|
+
}
|
|
310
335
|
return file;
|
|
311
336
|
}
|
|
312
337
|
|
|
338
|
+
async function listed(dir: string): Promise<string[]> {
|
|
339
|
+
try {
|
|
340
|
+
return await readdir(dir);
|
|
341
|
+
} catch {
|
|
342
|
+
return [];
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
313
346
|
function names(dir: string): string[] {
|
|
314
347
|
try {
|
|
315
348
|
return readdirSync(dir);
|
|
@@ -318,10 +351,19 @@ function names(dir: string): string[] {
|
|
|
318
351
|
}
|
|
319
352
|
}
|
|
320
353
|
|
|
354
|
+
async function stated(file: string): Promise<Stats | undefined> {
|
|
355
|
+
try {
|
|
356
|
+
const known = await stat(file);
|
|
357
|
+
return known.isFile() ? known : undefined;
|
|
358
|
+
} catch {
|
|
359
|
+
return undefined;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
321
363
|
function statOf(file: string) {
|
|
322
364
|
try {
|
|
323
|
-
const
|
|
324
|
-
return
|
|
365
|
+
const known = statSync(file);
|
|
366
|
+
return known.isFile() ? known : undefined;
|
|
325
367
|
} catch {
|
|
326
368
|
return undefined;
|
|
327
369
|
}
|
package/src/transcript/read.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { open, stat } from "node:fs/promises";
|
|
2
2
|
import type { TranscriptReadResult, Sid } from "@ccmsg/protocol";
|
|
3
3
|
import { OpError } from "../dispatch/index.ts";
|
|
4
4
|
|
|
@@ -20,15 +20,15 @@ export const READ_LIMIT = 512 * 1024;
|
|
|
20
20
|
*
|
|
21
21
|
* The offsets are the ones the `transcript` topic's frames carry, so what a
|
|
22
22
|
* client reads and what arrives live stitch together without overlap. */
|
|
23
|
-
export function readSlice(
|
|
23
|
+
export async function readSlice(
|
|
24
24
|
sid: Sid,
|
|
25
25
|
file: string,
|
|
26
26
|
before?: number,
|
|
27
27
|
maxBytes?: number,
|
|
28
|
-
): TranscriptReadResult {
|
|
28
|
+
): Promise<TranscriptReadResult> {
|
|
29
29
|
let size: number;
|
|
30
30
|
try {
|
|
31
|
-
size =
|
|
31
|
+
size = (await stat(file)).size;
|
|
32
32
|
} catch {
|
|
33
33
|
throw new OpError("not_found", `the transcript of ${sid} could not be read`);
|
|
34
34
|
}
|
|
@@ -45,7 +45,7 @@ export function readSlice(
|
|
|
45
45
|
// arithmetic puts them — inside a character as readily as before one — and
|
|
46
46
|
// decoding first would turn those bytes into replacement characters of a
|
|
47
47
|
// different length, moving every offset derived from them.
|
|
48
|
-
const bytes = slice(file, probe, until);
|
|
48
|
+
const bytes = await slice(file, probe, until);
|
|
49
49
|
// What of the read is whole records: everything up to the last newline. A
|
|
50
50
|
// record the writer has not finished ends the file without one.
|
|
51
51
|
const lastNewline = bytes.lastIndexOf(NEWLINE);
|
|
@@ -69,14 +69,14 @@ const NEWLINE = 0x0a;
|
|
|
69
69
|
|
|
70
70
|
/** The bytes in a range. A range that reads short — the file was truncated
|
|
71
71
|
* between the stat and the read — yields what was actually there. */
|
|
72
|
-
function slice(file: string, from: number, to: number): Buffer {
|
|
72
|
+
async function slice(file: string, from: number, to: number): Promise<Buffer> {
|
|
73
73
|
if (to <= from) return Buffer.alloc(0);
|
|
74
|
-
const handle =
|
|
74
|
+
const handle = await open(file, "r");
|
|
75
75
|
try {
|
|
76
76
|
const buffer = Buffer.alloc(to - from);
|
|
77
|
-
const
|
|
78
|
-
return buffer.subarray(0,
|
|
77
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, from);
|
|
78
|
+
return buffer.subarray(0, bytesRead);
|
|
79
79
|
} finally {
|
|
80
|
-
|
|
80
|
+
await handle.close();
|
|
81
81
|
}
|
|
82
82
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { TranscriptSubject } from "@ccmsg/protocol";
|
|
2
|
+
import { Classification, type Item } from "./items/index.ts";
|
|
3
|
+
import { positioned, span } from "./items/record.ts";
|
|
4
|
+
|
|
5
|
+
/** How many records one pass reads before it hands the loop back.
|
|
6
|
+
*
|
|
7
|
+
* A transcript is read whole by the ops that classify one, and reading it is
|
|
8
|
+
* CPU rather than IO: the file arrives in one `await` and every record in it is
|
|
9
|
+
* then parsed. Nothing else on the instance runs while that happens, so the
|
|
10
|
+
* pass is cut into turns — small enough that the wait an op elsewhere sees is
|
|
11
|
+
* one turn of parsing, large enough that the turns themselves cost nothing
|
|
12
|
+
* measurable against the parsing they carry. */
|
|
13
|
+
const RECORDS_PER_TURN = 2000;
|
|
14
|
+
|
|
15
|
+
/** Hand the event loop back, so that whatever else is waiting on it runs.
|
|
16
|
+
*
|
|
17
|
+
* The pause of a macrotask rather than of a microtask: a promise resolved with
|
|
18
|
+
* nothing to wait for is drained before the loop is reached at all, which would
|
|
19
|
+
* make the yield a shape in the code and nothing in the behaviour. */
|
|
20
|
+
export function breathe(): Promise<void> {
|
|
21
|
+
return new Promise((resume) => {
|
|
22
|
+
setImmediate(resume);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Whether this many records have gone by since the last pause. */
|
|
27
|
+
export function due(read: number): boolean {
|
|
28
|
+
return read > 0 && read % RECORDS_PER_TURN === 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A whole transcript as the items it was read into, yielding as it goes.
|
|
32
|
+
*
|
|
33
|
+
* The same reading `classify` does over the whole text at once, cut into turns:
|
|
34
|
+
* one `Classification` is fed chunk after chunk, so a call in one chunk is
|
|
35
|
+
* still known when its result arrives in a later one, and the items come back
|
|
36
|
+
* in the order the records are in. */
|
|
37
|
+
export async function classified(text: string, subject: TranscriptSubject): Promise<Item[]> {
|
|
38
|
+
const reading = new Classification(subject);
|
|
39
|
+
const lines = text.split("\n");
|
|
40
|
+
const items: Item[] = [];
|
|
41
|
+
let offset = 0;
|
|
42
|
+
for (let from = 0; from < lines.length; from += RECORDS_PER_TURN) {
|
|
43
|
+
const chunk = lines.slice(from, from + RECORDS_PER_TURN);
|
|
44
|
+
for (const item of reading.readAll(positioned(chunk, offset))) items.push(item);
|
|
45
|
+
for (const line of chunk) offset += span(line);
|
|
46
|
+
if (from + RECORDS_PER_TURN < lines.length) await breathe();
|
|
47
|
+
}
|
|
48
|
+
return items;
|
|
49
|
+
}
|