@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.
@@ -1,6 +1,6 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import { chmodSync, unlinkSync } from "node:fs";
3
- import { readdir, readFile } from "node:fs/promises";
2
+ import { unlinkSync } from "node:fs";
3
+ import { chmod, readdir, readFile } from "node:fs/promises";
4
4
  import { dirname, join } from "node:path";
5
5
  import { type InboxMessage, renderDirectDelivery, type Sid } from "@ccmsg/protocol";
6
6
  import { HARNESS, HARNESSES } from "../harness/index.ts";
@@ -39,7 +39,7 @@ export class DisabledDirectRoute implements DirectRoute {
39
39
 
40
40
  /** The `peerProtocol` generation this speaks. One value, because one is what
41
41
  * has been read off a running harness (2.1.263); any other generation is a
42
- * protocol nobody here has seen, which is condition 1 of DESIGN §6.5. */
42
+ * protocol nobody here has seen, which is condition 2 of DESIGN §6.5. */
43
43
  export const PEER_PROTOCOL = 1;
44
44
 
45
45
  /** How long one attempt has to reach the point where the harness holds our
@@ -52,7 +52,7 @@ export const PEER_PROTOCOL = 1;
52
52
  export const DIRECT_ACK_MS = 2_000;
53
53
 
54
54
  /** How long the status inbox is watched for word about this message before the
55
- * send is taken to have landed (DESIGN §6.5 condition 3).
55
+ * send is taken to have landed (DESIGN §6.5 condition 4).
56
56
  *
57
57
  * Provisional. What is known from the harness (2.1.263) is where the receipt
58
58
  * is raised, not how long it takes to arrive: the receiving session decides a
@@ -63,6 +63,16 @@ export const DIRECT_ACK_MS = 2_000;
63
63
  * answer. Nothing measured stands behind the number itself. */
64
64
  export const DIRECT_STATUS_MS = 250;
65
65
 
66
+ /** How long reading `sessions/` has to answer which file names this session
67
+ * (DESIGN §6.5 condition 1).
68
+ *
69
+ * The other two budgets cover what the route does after the target is found,
70
+ * and none of them covers the finding: a directory of small JSON files on the
71
+ * local disk answers in a moment, and a read that has not come back by now is
72
+ * one route (a) is better off not waiting for. Route (b) carries the message
73
+ * either way (DESIGN §6.5). */
74
+ export const DIRECT_SCAN_MS = 1_000;
75
+
66
76
  /** What the receiving session says about a message it did not simply take
67
77
  * (harness 2.1.263, `peer_message_status`).
68
78
  *
@@ -78,7 +88,7 @@ export const DIRECT_STATUS_MS = 250;
78
88
  const REFUSING = new Set(["refused", "denied", "dropped", "expired", "held"]);
79
89
 
80
90
  /** The socket this daemon offers so the receiving session can say what became
81
- * of a message (DESIGN §6.5 condition 3).
91
+ * of a message (DESIGN §6.5 condition 4).
82
92
  *
83
93
  * It lives in the directory the target's own socket is in, and not in this
84
94
  * instance's state directory, because the receiving harness vets the address it
@@ -105,7 +115,7 @@ class StatusInbox {
105
115
  * bound. Binding fails on a directory we cannot write, which costs the
106
116
  * route its status channel and nothing else: the message still goes, and
107
117
  * what the session says about it is simply not heard. */
108
- address(): string | undefined {
118
+ async address(): Promise<string | undefined> {
109
119
  if (this.#server !== undefined) return `uds:${this.#path}`;
110
120
  try {
111
121
  this.#server = Bun.listen({
@@ -125,7 +135,7 @@ class StatusInbox {
125
135
  // Same-uid by construction (A2 / A4), and stated rather than left to the
126
136
  // umask: what can be written here is what a session is told about.
127
137
  try {
128
- chmodSync(this.#path, 0o600);
138
+ await chmod(this.#path, 0o600);
129
139
  } catch {
130
140
  // The socket is bound and usable; a mode we could not set is not a
131
141
  // reason to give up the channel.
@@ -211,12 +221,14 @@ export interface SocketRouteOptions {
211
221
  readonly ackMs?: number;
212
222
  /** How long a receipt has to arrive before the message counts as taken. */
213
223
  readonly statusMs?: number;
224
+ /** How long reading `sessions/` has to name the target. */
225
+ readonly scanMs?: number;
214
226
  }
215
227
 
216
228
  /** Route (a) against the harness's messaging socket (DESIGN §6.5).
217
229
  *
218
230
  * The path is `sessions/<pid>.json` of this instance's own config home, which
219
- * is also the answer to condition 2: a key beside it that this uid can read is
231
+ * is also the answer to condition 3: a key beside it that this uid can read is
220
232
  * exactly the same-uid, same-config-home boundary the instance already stands
221
233
  * on (A2 / A4). Nothing here searches another config home, and a session this
222
234
  * instance cannot see a state file for is simply not reachable this way.
@@ -228,15 +240,30 @@ export class ClaudeCodeSocketRoute implements DirectRoute {
228
240
  readonly #sessionsDir: string;
229
241
  readonly #ackMs: number;
230
242
  readonly #statusMs: number;
243
+ readonly #scanMs: number;
231
244
  /** One status inbox per directory sessions' sockets live in. A host has one
232
245
  * such directory in practice; the map is what keeps that from being an
233
- * assumption. */
234
- readonly #inboxes = new Map<string, StatusInbox>();
246
+ * assumption.
247
+ *
248
+ * The binding rather than the bound socket, because binding waits: two sends
249
+ * to sessions of the same directory run side by side, and a map holding only
250
+ * finished inboxes would have both of them bind one, with the loser left
251
+ * listening on a socket nothing can close. */
252
+ readonly #inboxes = new Map<string, Promise<StatusInbox | undefined>>();
253
+
254
+ /** The channels that finished binding, so letting the route go takes their
255
+ * sockets down in the same turn rather than one after it. */
256
+ readonly #bound = new Set<StatusInbox>();
257
+
258
+ /** Whether the route has been let go. A binding that finishes after that has
259
+ * nobody to close it later, so it closes itself. */
260
+ #closed = false;
235
261
 
236
262
  constructor(options: SocketRouteOptions) {
237
263
  this.#sessionsDir = join(options.configHome, "sessions");
238
264
  this.#ackMs = options.ackMs ?? DIRECT_ACK_MS;
239
265
  this.#statusMs = options.statusMs ?? DIRECT_STATUS_MS;
266
+ this.#scanMs = options.scanMs ?? DIRECT_SCAN_MS;
240
267
  }
241
268
 
242
269
  /** One send, and what the session made of it.
@@ -253,8 +280,8 @@ export class ClaudeCodeSocketRoute implements DirectRoute {
253
280
  if (target === undefined) return "unavailable";
254
281
  const token = await this.#token(target.pid);
255
282
  if (token === undefined) return "unavailable";
256
- const inbox = this.#inbox(target.socketPath);
257
- const from = inbox?.address();
283
+ const inbox = await this.#inbox(target.socketPath);
284
+ const from = await inbox?.address();
258
285
  const watching = inbox === undefined ? undefined : inbox.status(message.mid, this.#statusMs);
259
286
  const written = await write(target.socketPath, frames(sid, token, message, from), this.#ackMs);
260
287
  if (written !== "delivered") return written;
@@ -263,35 +290,49 @@ export class ClaudeCodeSocketRoute implements DirectRoute {
263
290
  }
264
291
 
265
292
  close(): void {
266
- for (const inbox of this.#inboxes.values()) inbox.close();
293
+ // A binding still in flight closes itself when it lands, which is what
294
+ // `#closed` is read for; what is already bound goes now, so the sockets are
295
+ // gone by the time this returns.
296
+ this.#closed = true;
297
+ for (const inbox of this.#bound) inbox.close();
298
+ this.#bound.clear();
267
299
  this.#inboxes.clear();
268
300
  }
269
301
 
270
302
  /** The receipt channel for a target, bound beside its own socket. Absent
271
303
  * when nothing could be bound there, which leaves the route working and its
272
304
  * refusals unheard. */
273
- #inbox(socketPath: string): StatusInbox | undefined {
305
+ async #inbox(socketPath: string): Promise<StatusInbox | undefined> {
274
306
  const directory = dirname(socketPath);
275
307
  const held = this.#inboxes.get(directory);
276
- if (held !== undefined) return held;
277
- const inbox = new StatusInbox(directory);
278
- if (inbox.address() === undefined) return undefined;
279
- this.#inboxes.set(directory, inbox);
308
+ if (held !== undefined) return await held;
309
+ const opening = (async (): Promise<StatusInbox | undefined> => {
310
+ const inbox = new StatusInbox(directory);
311
+ if ((await inbox.address()) === undefined) return undefined;
312
+ if (this.#closed) {
313
+ inbox.close();
314
+ return undefined;
315
+ }
316
+ this.#bound.add(inbox);
317
+ return inbox;
318
+ })();
319
+ this.#inboxes.set(directory, opening);
320
+ const inbox = await opening;
321
+ // Nothing bound is not remembered: the directory may be writable by the
322
+ // time the next message goes that way, and a failure kept here would be
323
+ // the route without its receipts for the life of the instance.
324
+ if (inbox === undefined && this.#inboxes.get(directory) === opening) {
325
+ this.#inboxes.delete(directory);
326
+ }
280
327
  return inbox;
281
328
  }
282
329
 
283
330
  /** The state file naming this session, if it names a socket of a generation
284
- * we speak (DESIGN §6.5 conditions 1). */
331
+ * we speak (DESIGN §6.5 condition 2). */
285
332
  async #target(sid: Sid): Promise<HarnessTarget | undefined> {
286
- let names: string[];
287
- try {
288
- names = await readdir(this.#sessionsDir);
289
- } catch {
290
- return undefined;
291
- }
292
- for (const name of names) {
293
- if (!/^\d+\.json$/.test(name)) continue;
294
- const row = await readJson(join(this.#sessionsDir, name));
333
+ const rows = await this.#rows(/^\d+\.json$/);
334
+ if (rows === undefined) return undefined;
335
+ for (const row of rows) {
295
336
  if (row === undefined || row["sessionId"] !== sid) continue;
296
337
  const pid = row["pid"];
297
338
  const socketPath = row["messagingSocketPath"];
@@ -303,7 +344,7 @@ export class ClaudeCodeSocketRoute implements DirectRoute {
303
344
  return undefined;
304
345
  }
305
346
 
306
- /** The `peerToken` the harness wrote for this session (DESIGN §6.5 condition 2).
347
+ /** The `peerToken` the harness wrote for this session (DESIGN §6.5 condition 3).
307
348
  *
308
349
  * Found by the pid the key is named after rather than by rebuilding the rest
309
350
  * of the name: the digest in `<pid>.<digest>.key` is stated to be over the
@@ -311,20 +352,45 @@ export class ClaudeCodeSocketRoute implements DirectRoute {
311
352
  * against a running harness, and a name we cannot rebuild is still a name we
312
353
  * can recognise. */
313
354
  async #token(pid: number): Promise<string | undefined> {
314
- const key = new RegExp(`^${pid}\\.[0-9a-f]+\\.key$`);
355
+ const rows = await this.#rows(new RegExp(`^${pid}\\.[0-9a-f]+\\.key$`));
356
+ if (rows === undefined) return undefined;
357
+ for (const document of rows) {
358
+ const token = document?.["peerToken"];
359
+ if (typeof token === "string" && token !== "") return token;
360
+ }
361
+ return undefined;
362
+ }
363
+
364
+ /** The files of `sessions/` whose names this pattern admits, read at once and
365
+ * answered in the order the directory listed them.
366
+ *
367
+ * At once because the files are independent and what is being looked for is
368
+ * one of them: read in turn, the search costs the sum of every file and
369
+ * grows with the number of sessions on the host, and a `message.send` waits
370
+ * out the lot (DR-0015). The order is kept because it is what decides the
371
+ * answer — the first name that matches is the one the caller takes.
372
+ *
373
+ * Nothing when the directory cannot be read, and nothing when the reads have
374
+ * not finished inside `DIRECT_SCAN_MS`: both are route (a) not applying, and
375
+ * route (b) carries the message. */
376
+ async #rows(pattern: RegExp): Promise<(Record<string, unknown> | undefined)[] | undefined> {
315
377
  let names: string[];
316
378
  try {
317
379
  names = await readdir(this.#sessionsDir);
318
380
  } catch {
319
381
  return undefined;
320
382
  }
321
- for (const name of names) {
322
- if (!key.test(name)) continue;
323
- const document = await readJson(join(this.#sessionsDir, name));
324
- const token = document?.["peerToken"];
325
- if (typeof token === "string" && token !== "") return token;
383
+ const wanted = names.filter((name) => pattern.test(name));
384
+ const late = Promise.withResolvers<undefined>();
385
+ const deadline = setTimeout(() => late.resolve(undefined), this.#scanMs);
386
+ try {
387
+ return await Promise.race([
388
+ Promise.all(wanted.map((name) => readJson(join(this.#sessionsDir, name)))),
389
+ late.promise,
390
+ ]);
391
+ } finally {
392
+ clearTimeout(deadline);
326
393
  }
327
- return undefined;
328
394
  }
329
395
  }
330
396
 
@@ -477,7 +543,7 @@ function frames(sid: Sid, token: string, message: InboxMessage, from?: string):
477
543
  }
478
544
 
479
545
  /** Connect and write, and answer whether the harness holds our bytes (DESIGN §6.5
480
- * condition 3).
546
+ * condition 4).
481
547
  *
482
548
  * That is the whole of what this can decide. The connection carries nothing
483
549
  * back — a real send measured zero bytes on it — so waiting here for an answer
@@ -520,7 +586,7 @@ async function write(path: string, payload: string, ackMs: number): Promise<Dire
520
586
  });
521
587
  } catch {
522
588
  // No socket at the path, or nothing listening on it: the session ended and
523
- // took its socket with it, or never had one (DESIGN §6.5 condition 1).
589
+ // took its socket with it, or never had one (DESIGN §6.5 condition 2).
524
590
  return "unavailable";
525
591
  }
526
592
 
@@ -543,7 +609,7 @@ async function readJson(path: string): Promise<Record<string, unknown> | undefin
543
609
  return document as Record<string, unknown>;
544
610
  } catch {
545
611
  // Missing, unreadable by this uid, or half written — all of them are
546
- // "route (a) does not apply here" (DESIGN §6.5 conditions 1 and 2).
612
+ // "route (a) does not apply here" (DESIGN §6.5 conditions 2 and 3).
547
613
  return undefined;
548
614
  }
549
615
  }
@@ -1,16 +1,11 @@
1
- import {
2
- appendFileSync,
3
- mkdirSync,
4
- readFileSync,
5
- renameSync,
6
- unlinkSync,
7
- writeFileSync,
8
- } from "node:fs";
1
+ import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { appendFile, mkdir } from "node:fs/promises";
9
3
  import { dirname, join } from "node:path";
10
4
  import {
11
5
  INBOX_MAX_PER_SID,
12
6
  INBOX_RETENTION_MS,
13
7
  type InboxMessage,
8
+ type InboxRemovedReason,
14
9
  type Sid,
15
10
  type Timestamp,
16
11
  } from "@ccmsg/protocol";
@@ -44,8 +39,24 @@ type Record_ =
44
39
  export class Inbox {
45
40
  readonly #held = new Map<Sid, InboxMessage[]>();
46
41
 
42
+ /** The appends already asked for, as one chain. */
43
+ #written: Promise<void> = Promise.resolve();
44
+
45
+ /** Told whenever a message leaves, and why. Every way out passes through
46
+ * here — handed over, timed out, dropped for a newer one — so whoever states
47
+ * removals on the topic has one place to hear about them rather than a
48
+ * reading of its own per way (DESIGN §6.7). Absent until somebody asks: a
49
+ * replay at startup reaches conclusions about a file, with nobody yet
50
+ * subscribed for them to be news to. */
51
+ #onRemoved?: (mid: string, reason: InboxRemovedReason) => void;
52
+
47
53
  constructor(private readonly file: string) {}
48
54
 
55
+ /** Hear about messages leaving. */
56
+ onRemoved(told: (mid: string, reason: InboxRemovedReason) => void): void {
57
+ this.#onRemoved = told;
58
+ }
59
+
49
60
  /** Replay the file, drop what has expired, and write back what is left.
50
61
  *
51
62
  * The rewrite is the only whole-file write, and it happens before anything
@@ -87,28 +98,53 @@ export class Inbox {
87
98
  * Answers whether the oldest was dropped to make room, which is the whole of
88
99
  * `inbox_full`: the message is held either way, and what the sender is told
89
100
  * differs because something of theirs is now gone. */
90
- hold(sid: Sid, message: InboxMessage, now: Timestamp = Date.now()): { evicted: boolean } {
101
+ async hold(
102
+ sid: Sid,
103
+ message: InboxMessage,
104
+ now: Timestamp = Date.now(),
105
+ ): Promise<{ evicted: boolean }> {
91
106
  this.#expire(now, sid);
92
107
  const held = this.#held.get(sid) ?? [];
93
108
  this.#held.set(sid, held);
94
109
  held.push(message);
95
- this.#append({ v: "add", sid, message });
96
- if (held.length <= INBOX_MAX_PER_SID) return { evicted: false };
97
- const oldest = held.shift();
98
- if (oldest !== undefined) this.#append({ v: "dropped", sid, mid: oldest.mid });
110
+ // Awaited rather than left to land: what the sender is told is that the
111
+ // message is held, and it is not held until the line is on disk.
112
+ await this.#append({ v: "add", sid, message });
113
+ // What is over the limit is what the session holds now, not what it held
114
+ // when the line was written: a delivery or an expiry for this session
115
+ // leaves a different list in its place while the append is in flight, and
116
+ // deciding against the list from before would drop a message out of one
117
+ // nobody is holding and tell a watcher a delivered message was dropped.
118
+ const standing = this.#held.get(sid) ?? [];
119
+ if (standing.length <= INBOX_MAX_PER_SID) return { evicted: false };
120
+ const oldest = standing.shift();
121
+ if (oldest !== undefined) {
122
+ await this.#append({ v: "dropped", sid, mid: oldest.mid });
123
+ this.#onRemoved?.(oldest.mid, "dropped");
124
+ }
99
125
  return { evicted: true };
100
126
  }
101
127
 
102
128
  /** Note that messages reached their session, which is what takes them out of
103
129
  * the inbox (DESIGN §6.7). */
104
- delivered(sid: Sid, mids: readonly string[]): void {
130
+ async delivered(sid: Sid, mids: readonly string[]): Promise<void> {
105
131
  const held = this.#held.get(sid);
106
132
  if (held === undefined || mids.length === 0) return;
107
133
  const gone = new Set(mids);
108
134
  const left = held.filter((message) => !gone.has(message.mid));
109
135
  if (left.length === 0) this.#held.delete(sid);
110
136
  else this.#held.set(sid, left);
111
- for (const mid of mids) this.#append({ v: "delivered", sid, mid });
137
+ const written: Promise<void>[] = [];
138
+ for (const mid of mids) {
139
+ written.push(this.#append({ v: "delivered", sid, mid }));
140
+ this.#onRemoved?.(mid, "delivered");
141
+ }
142
+ await Promise.all(written);
143
+ }
144
+
145
+ /** Settle once every line asked for so far is on disk. */
146
+ async flush(): Promise<void> {
147
+ await this.#written;
112
148
  }
113
149
 
114
150
  /** Every session something is waiting for. What reads it is the offer of
@@ -154,14 +190,32 @@ export class Inbox {
154
190
  if (only !== undefined && sid !== only) continue;
155
191
  const left = held.filter((message) => now - message.sent_at <= INBOX_RETENTION_MS);
156
192
  if (left.length === held.length) continue;
193
+ const gone = new Set(left.map((message) => message.mid));
157
194
  if (left.length === 0) this.#held.delete(sid);
158
195
  else this.#held.set(sid, left);
196
+ for (const message of held) {
197
+ if (!gone.has(message.mid)) this.#onRemoved?.(message.mid, "expired");
198
+ }
159
199
  }
160
200
  }
161
201
 
162
- #append(record: Record_): void {
163
- mkdirSync(dirname(this.file), { recursive: true });
164
- appendFileSync(this.file, `${JSON.stringify(record)}\n`);
202
+ /** One line, behind the lines asked for before it.
203
+ *
204
+ * A message arriving is an ordinary event of a running instance, so the
205
+ * append does not hold the instance still while it lands (DR-0015). The
206
+ * chain is what keeps the file in the order the verbs happened: a delivery
207
+ * written before the add it answers would replay as a message nobody was
208
+ * ever holding. */
209
+ #append(record: Record_): Promise<void> {
210
+ const written = this.#written.then(async () => {
211
+ await mkdir(dirname(this.file), { recursive: true });
212
+ await appendFile(this.file, `${JSON.stringify(record)}\n`);
213
+ });
214
+ // The chain carries the order, not the outcome: a line that could not be
215
+ // written is answered to whoever asked for it, and the ones behind it still
216
+ // go.
217
+ this.#written = written.catch(() => {});
218
+ return written;
165
219
  }
166
220
 
167
221
  #compact(): void {
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  InstanceId,
3
+ Mid,
3
4
  Notification,
4
5
  NotifySendArgs,
5
6
  NotifySendResult,
@@ -48,7 +49,10 @@ export class Notify implements UpstreamResource {
48
49
  * caller otherwise, so a session notifying about itself says only the text. */
49
50
  send = (input: HandlerInput): NotifySendResult => {
50
51
  const args = input.args as unknown as NotifySendArgs;
51
- this.#announce(args.sid ?? this.#caller(input), args.text);
52
+ // What it answers travels with it: a notification is shown while the
53
+ // session's own account of the same answer is still being written, and the
54
+ // `mid` is what tells a reader holding both that they are one thing.
55
+ this.#announce(args.sid ?? this.#caller(input), args.text, Date.now(), args.reply_to);
52
56
  return {};
53
57
  };
54
58
 
@@ -92,11 +96,12 @@ export class Notify implements UpstreamResource {
92
96
  return [];
93
97
  }
94
98
 
95
- #announce(sid: Sid, text: string, now: Timestamp = Date.now()): Timestamp {
99
+ #announce(sid: Sid, text: string, now: Timestamp = Date.now(), reply_to?: Mid): Timestamp {
96
100
  const notification: Notification = {
97
101
  sid,
98
102
  sid_label: this.deps.label(sid),
99
103
  text,
104
+ ...(reply_to === undefined ? {} : { reply_to }),
100
105
  sent_at: now,
101
106
  };
102
107
  // A notification is an occurrence, so nothing folds it away and a watcher
@@ -1,23 +1,26 @@
1
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import type {
4
4
  DumpPreset,
5
5
  InstanceId,
6
6
  SessionDumpFile,
7
+ SessionDumpFormat,
7
8
  SessionDumpWriteArgs,
8
9
  SessionDumpWriteResult,
10
+ Timestamp,
9
11
  } from "@ccmsg/protocol";
10
12
  import { OpError } from "../dispatch/index.ts";
11
13
  import {
12
14
  bounded,
13
- classify,
15
+ document as render,
16
+ type Item,
14
17
  ledger,
15
- located,
16
18
  select,
17
19
  selection,
18
20
  within,
19
21
  } from "../transcript/items/index.ts";
20
22
  import type { TranscriptFiles } from "../transcript/index.ts";
23
+ import { classified } from "../transcript/scan.ts";
21
24
 
22
25
  /** Where dumps land: one directory under this instance's own state, named
23
26
  * after the config home it answers for like every other per-instance path
@@ -50,16 +53,19 @@ export interface DumpDeps {
50
53
  * `message.user.in` is the brief its parent gave it — so one selection carries
51
54
  * unchanged down a chain of agents, which is what makes the ledger's agent ids
52
55
  * a way to descend rather than just a list. */
53
- export function dumpWrite(args: SessionDumpWriteArgs, deps: DumpDeps): SessionDumpWriteResult {
56
+ export async function dumpWrite(
57
+ args: SessionDumpWriteArgs,
58
+ deps: DumpDeps,
59
+ ): Promise<SessionDumpWriteResult> {
54
60
  bounded(args);
55
61
  const preset = presetFor(args.preset, deps.presets);
56
- const file = deps.files.locate(
62
+ const file = await deps.files.locate(
57
63
  args.sid,
58
64
  args.agent_id === undefined ? {} : { agent_id: args.agent_id },
59
65
  );
60
66
  let text: string;
61
67
  try {
62
- text = readFileSync(file, "utf8");
68
+ text = await readFile(file, "utf8");
63
69
  } catch {
64
70
  throw new OpError("not_found", `the transcript of ${args.sid} could not be read`);
65
71
  }
@@ -76,11 +82,12 @@ export function dumpWrite(args: SessionDumpWriteArgs, deps: DumpDeps): SessionDu
76
82
  deps.presets,
77
83
  );
78
84
  const { items, entries } = select(
79
- within(classify(located(text), deps.files.subjectOf(file)), args),
85
+ within(await classified(text, await deps.files.subjectOf(file)), args),
80
86
  keep,
81
87
  );
82
88
  const ids = ledger(items);
83
89
  const written_at = Date.now();
90
+ const format = args.format ?? "items";
84
91
  // The file repeats what it was asked for. A dump outlives the request that
85
92
  // made it and is read by whoever was handed the path, so it has to say on
86
93
  // its own what it is a dump of and what was left out — which is why the
@@ -93,12 +100,27 @@ export function dumpWrite(args: SessionDumpWriteArgs, deps: DumpDeps): SessionDu
93
100
  items,
94
101
  ids,
95
102
  };
103
+ const body =
104
+ format === "items"
105
+ ? `${JSON.stringify(document, undefined, 2)}\n`
106
+ : format === "records"
107
+ ? sourceLines(text, items)
108
+ : render(document, {
109
+ instance: deps.self,
110
+ ...(args.since_at === undefined ? {} : { since: moment(args.since_at) }),
111
+ ...(args.until_at === undefined ? {} : { until: moment(args.until_at) }),
112
+ ...(args.since_uuid === undefined ? {} : { since: args.since_uuid }),
113
+ ...(args.until_uuid === undefined ? {} : { until: args.until_uuid }),
114
+ });
96
115
  const dir = join(deps.stateDir, DUMPS);
97
- mkdirSync(dir, { recursive: true });
116
+ await mkdir(dir, { recursive: true });
98
117
  const named = args.agent_id === undefined ? args.sid : `${args.sid}-agent-${args.agent_id}`;
99
- const path = join(dir, `${named}-${written_at}${DUMP_SUFFIX}`);
100
- const body = `${JSON.stringify(document, undefined, 2)}\n`;
101
- writeFileSync(path, body);
118
+ const path = join(dir, `${named}-${written_at}${suffix(format)}`);
119
+ await writeFile(path, body);
120
+ // What is counted is the selection, whatever the file ended up holding: that
121
+ // is what the caller asked for and what it reads the answer against, and a
122
+ // count that moved with the rendering would answer a different question each
123
+ // time (contract, `SessionDumpWriteResult`).
102
124
  return { path, instance: deps.self, entries, ids, bytes: Buffer.byteLength(body) };
103
125
  }
104
126
 
@@ -116,3 +138,43 @@ function presetFor(
116
138
  if (found === undefined) throw new OpError("invalid_args", `no preset is configured as ${name}`);
117
139
  return found;
118
140
  }
141
+
142
+ /** The records the selected items were read from, as the file holds them.
143
+ *
144
+ * Taken out of the bytes by each item's own address rather than re-serialized,
145
+ * so what a tool reading the harness's format gets is the harness's own lines
146
+ * — nothing is added around them and nothing inside them is changed. Several
147
+ * items out of one record share that address, so the record is written once
148
+ * and the line count is not the item count (contract, `SessionDumpFormat`).
149
+ *
150
+ * One dump is one transcript — the session's, or one agent's when the request
151
+ * names one — so no line here has to say which file it came from. */
152
+ function sourceLines(text: string, items: readonly Item[]): string {
153
+ const bytes = Buffer.from(text, "utf8");
154
+ const seen = new Set<number>();
155
+ const lines: string[] = [];
156
+ for (const item of items) {
157
+ const at = item.source.offset;
158
+ if (seen.has(at)) continue;
159
+ seen.add(at);
160
+ lines.push(
161
+ bytes
162
+ .subarray(at, at + item.source.bytes)
163
+ .toString("utf8")
164
+ .trimEnd(),
165
+ );
166
+ }
167
+ return lines.length === 0 ? "" : `${lines.join("\n")}\n`;
168
+ }
169
+
170
+ /** What a file of this format is called. Whoever is handed the path opens it
171
+ * in whatever reads that kind of file, and a name saying `.json` for prose
172
+ * would send them to the wrong one. */
173
+ function suffix(format: SessionDumpFormat): string {
174
+ return format === "items" ? DUMP_SUFFIX : format === "records" ? ".jsonl" : ".md";
175
+ }
176
+
177
+ /** A bound as the heading states it. */
178
+ function moment(at: Timestamp): string {
179
+ return new Date(at).toISOString();
180
+ }