@ccmsg/cli 0.2.13 → 0.3.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/package.json +2 -2
- package/src/cli.ts +96 -29
- package/src/daemon/registry.ts +41 -14
- package/src/daemon/supervise.ts +2 -1
- package/src/harness/index.ts +96 -0
- package/src/instance/config.ts +36 -0
- package/src/instance/instance.ts +28 -5
- package/src/instance/paths.ts +32 -6
- package/src/messaging/direct.ts +116 -0
- package/src/plugin/claude.ts +1 -72
- package/src/plugin/codex.ts +332 -0
- package/src/plugin/index.ts +7 -5
- package/src/plugin/install.ts +41 -161
- package/src/plugin/receipt.ts +202 -0
- package/src/plugin/skill.ts +84 -0
- package/src/sessions/harness.ts +166 -31
- package/src/sessions/registry.ts +84 -53
- package/src/sessions/search.ts +4 -4
- package/src/transcript/files.ts +103 -22
- package/src/transcript/fold.ts +72 -1
package/src/sessions/registry.ts
CHANGED
|
@@ -19,16 +19,32 @@ import {
|
|
|
19
19
|
} from "@ccmsg/protocol";
|
|
20
20
|
import { type HandlerInput, OpError, type Requester } from "../dispatch/index.ts";
|
|
21
21
|
import { within } from "../files/index.ts";
|
|
22
|
+
import { HARNESS, type Harness } from "../harness/index.ts";
|
|
22
23
|
import type { TranscriptFacts } from "../transcript/index.ts";
|
|
23
24
|
import type { TopicValue, UpstreamResource } from "../topics/index.ts";
|
|
24
25
|
import { classify, type SessionInputs } from "./classify.ts";
|
|
25
|
-
import {
|
|
26
|
+
import { isWaiting, type OwnSessions, ownSessions } from "./harness.ts";
|
|
26
27
|
import { LastLiveStore, type StoredEntry } from "./last-live.ts";
|
|
27
28
|
import { stoppedOn } from "./status.ts";
|
|
28
29
|
import { TerminalCache, type TerminalReader } from "./terminals.ts";
|
|
29
30
|
|
|
31
|
+
/** What the harness says at one instant: the rows it reports, and which
|
|
32
|
+
* sessions it says are there (§3.8).
|
|
33
|
+
*
|
|
34
|
+
* Two readings of one moment, passed together so a caller answering several
|
|
35
|
+
* questions about that moment reads once. They are the same set for a harness
|
|
36
|
+
* that reports a row per session and differ for one that reports none, which
|
|
37
|
+
* is why the classification reads `present` and never the rows' keys. */
|
|
38
|
+
interface Own {
|
|
39
|
+
readonly rows: ReadonlyMap<Sid, AgentInfo>;
|
|
40
|
+
readonly present: ReadonlySet<Sid>;
|
|
41
|
+
}
|
|
42
|
+
|
|
30
43
|
/** What the sessions domain needs from the instance around it. */
|
|
31
44
|
export interface SessionsDeps {
|
|
45
|
+
/** Which harness this config home runs (§3.8). It decides what says a
|
|
46
|
+
* session is there and, through that, what `agents` can report. */
|
|
47
|
+
readonly harness: Harness;
|
|
32
48
|
readonly self: InstanceId;
|
|
33
49
|
/** Where this instance says it is reached, which `hello` states beside the
|
|
34
50
|
* id: the caller got here by some URL of its own — a proxy's, an alias — and
|
|
@@ -82,6 +98,12 @@ export interface SessionsDeps {
|
|
|
82
98
|
* domain cannot judge: a peer's, whose claim is settled by an exchange of its
|
|
83
99
|
* own rather than by anything a session says (§7.2). */
|
|
84
100
|
readonly mesh?: MeshSource;
|
|
101
|
+
/** Where a person opens the terminal a session runs in, which `hello` states
|
|
102
|
+
* as `terminal_gateway`. The same value that gates the `terminal` capability
|
|
103
|
+
* (`sessionCapabilities`), so a client told the capability is on is told
|
|
104
|
+
* where to reach it in the same greeting. Absent on an instance with no
|
|
105
|
+
* gateway configured. */
|
|
106
|
+
readonly terminalGateway?: string;
|
|
85
107
|
}
|
|
86
108
|
|
|
87
109
|
/** What `hello` needs of the mesh: verify the greeting of a peer, and say which
|
|
@@ -155,7 +177,7 @@ interface Connected {
|
|
|
155
177
|
* built, and never stored (M4). */
|
|
156
178
|
export class Sessions implements UpstreamResource {
|
|
157
179
|
readonly #connected = new Map<Sid, Connected>();
|
|
158
|
-
readonly #harness:
|
|
180
|
+
readonly #harness: OwnSessions;
|
|
159
181
|
readonly #terminals: TerminalCache | undefined;
|
|
160
182
|
readonly #lastLive: LastLiveStore;
|
|
161
183
|
/** Sessions seen live since the last recompute, kept so the moment one stops
|
|
@@ -191,8 +213,9 @@ export class Sessions implements UpstreamResource {
|
|
|
191
213
|
readonly #stopping = new Map<Sid, Timestamp>();
|
|
192
214
|
|
|
193
215
|
constructor(private readonly deps: SessionsDeps) {
|
|
194
|
-
this.#harness =
|
|
195
|
-
|
|
216
|
+
this.#harness = ownSessions(
|
|
217
|
+
deps.harness,
|
|
218
|
+
deps.configHome,
|
|
196
219
|
deps.self,
|
|
197
220
|
() => this.changed(),
|
|
198
221
|
deps.pollMs,
|
|
@@ -203,7 +226,7 @@ export class Sessions implements UpstreamResource {
|
|
|
203
226
|
deps.terminals === undefined
|
|
204
227
|
? undefined
|
|
205
228
|
: new TerminalCache(deps.terminals, () => this.changed());
|
|
206
|
-
this.#live = this.#liveNow(Date.now(), this.#
|
|
229
|
+
this.#live = this.#liveNow(Date.now(), this.#own());
|
|
207
230
|
}
|
|
208
231
|
|
|
209
232
|
/** `hello`, which is where a session becomes something this instance can
|
|
@@ -274,6 +297,9 @@ export class Sessions implements UpstreamResource {
|
|
|
274
297
|
capabilities: [...this.deps.capabilities],
|
|
275
298
|
version: this.deps.version,
|
|
276
299
|
started_at: this.deps.startedAt,
|
|
300
|
+
...(this.deps.terminalGateway === undefined
|
|
301
|
+
? {}
|
|
302
|
+
: { terminal_gateway: this.deps.terminalGateway }),
|
|
277
303
|
...(expiresAt === undefined ? {} : { auth_expires_at: expiresAt }),
|
|
278
304
|
};
|
|
279
305
|
}
|
|
@@ -283,18 +309,18 @@ export class Sessions implements UpstreamResource {
|
|
|
283
309
|
classify(
|
|
284
310
|
sid: Sid,
|
|
285
311
|
now: Timestamp = Date.now(),
|
|
286
|
-
|
|
312
|
+
own: Own = this.#own(),
|
|
287
313
|
): SessionState | undefined {
|
|
288
|
-
return classify(this.inputs(sid,
|
|
314
|
+
return classify(this.inputs(sid, own), now);
|
|
289
315
|
}
|
|
290
316
|
|
|
291
317
|
/** The harness's sessions as they are at this instant. One read serves one
|
|
292
318
|
* question, and a caller answering several about the same instant passes the
|
|
293
319
|
* result on rather than reading again. */
|
|
294
|
-
#
|
|
295
|
-
const rows = this.#harness.
|
|
320
|
+
#own(): Own {
|
|
321
|
+
const rows = this.#harness.rows();
|
|
296
322
|
const terminals = this.#terminals;
|
|
297
|
-
if (terminals === undefined) return rows;
|
|
323
|
+
if (terminals === undefined) return { rows, present: this.#harness.present() };
|
|
298
324
|
// What the scan found is what exists: a pid that has left it is one whose
|
|
299
325
|
// terminal is no longer anybody's, and one that has arrived is read once.
|
|
300
326
|
terminals.observe([...rows.values()].map((row) => row.pid));
|
|
@@ -314,7 +340,7 @@ export class Sessions implements UpstreamResource {
|
|
|
314
340
|
},
|
|
315
341
|
);
|
|
316
342
|
}
|
|
317
|
-
return named;
|
|
343
|
+
return { rows: named, present: this.#harness.present() };
|
|
318
344
|
}
|
|
319
345
|
|
|
320
346
|
/** Everything the classification of one session reads, exposed so the rule
|
|
@@ -327,21 +353,22 @@ export class Sessions implements UpstreamResource {
|
|
|
327
353
|
* "a session exists" mean "somebody is listening", which is how a live
|
|
328
354
|
* session becomes `session_not_found` to a sender and how a session that is
|
|
329
355
|
* still running is written into `last_live` as gone. */
|
|
330
|
-
inputs(sid: Sid,
|
|
331
|
-
const row = rows.get(sid);
|
|
356
|
+
inputs(sid: Sid, own: Own = this.#own()): SessionInputs {
|
|
357
|
+
const row = own.rows.get(sid);
|
|
358
|
+
const present = own.present.has(sid);
|
|
332
359
|
const stored = this.#lastLive.get(sid);
|
|
333
360
|
const facts = this.deps.transcript?.facts(sid);
|
|
334
|
-
const gatewayActiveAt = this.#gatewayActiveAt(sid,
|
|
361
|
+
const gatewayActiveAt = this.#gatewayActiveAt(sid, present);
|
|
335
362
|
return {
|
|
336
363
|
connected: this.#connected.has(sid),
|
|
337
364
|
...(gatewayActiveAt === undefined ? {} : { gateway_active_at: gatewayActiveAt }),
|
|
338
365
|
...(facts === undefined || stoppedOn(facts) === undefined ? {} : { api_error_stopped: true }),
|
|
339
|
-
...(
|
|
366
|
+
...(!present
|
|
340
367
|
? {}
|
|
341
368
|
: {
|
|
342
369
|
harness: {
|
|
343
|
-
waiting: isWaiting(row),
|
|
344
|
-
...(row
|
|
370
|
+
waiting: row !== undefined && isWaiting(row),
|
|
371
|
+
...(row?.terminal_id === undefined ? {} : { terminal_id: row.terminal_id }),
|
|
345
372
|
},
|
|
346
373
|
}),
|
|
347
374
|
...(stored === undefined ? {} : { last_live: { stopped_at: stored.stopped_at } }),
|
|
@@ -379,7 +406,7 @@ export class Sessions implements UpstreamResource {
|
|
|
379
406
|
* so a session that named neither is one no path is admitted for. */
|
|
380
407
|
where(sid: Sid): { root?: string; cwd?: string } {
|
|
381
408
|
const meta = this.#connected.get(sid)?.meta;
|
|
382
|
-
const cwd = meta?.cwd ?? this.#
|
|
409
|
+
const cwd = meta?.cwd ?? this.#own().rows.get(sid)?.cwd;
|
|
383
410
|
// The container when the session named one, the working directory
|
|
384
411
|
// otherwise — the same order `repo_root` is meant in (§4.2).
|
|
385
412
|
const root = meta?.repo_root ?? cwd;
|
|
@@ -394,7 +421,7 @@ export class Sessions implements UpstreamResource {
|
|
|
394
421
|
* through this: the watch runs only while somebody is subscribed (§6.3), and
|
|
395
422
|
* a pid from a poll that has not run is a number belonging to nobody. */
|
|
396
423
|
rowsNow(): ReadonlyMap<Sid, AgentInfo> {
|
|
397
|
-
return this.#
|
|
424
|
+
return this.#own().rows;
|
|
398
425
|
}
|
|
399
426
|
|
|
400
427
|
/** Drop one entry from `last_live`, which is what
|
|
@@ -445,8 +472,8 @@ export class Sessions implements UpstreamResource {
|
|
|
445
472
|
}
|
|
446
473
|
|
|
447
474
|
snapshot(topic: string): readonly TopicValue[] {
|
|
448
|
-
const
|
|
449
|
-
const data = topic === "agents" ? this.agents(
|
|
475
|
+
const own = this.#own();
|
|
476
|
+
const data = topic === "agents" ? this.agents(own) : this.peers(Date.now(), own);
|
|
450
477
|
return [{ instance: this.deps.self, data }];
|
|
451
478
|
}
|
|
452
479
|
|
|
@@ -473,14 +500,14 @@ export class Sessions implements UpstreamResource {
|
|
|
473
500
|
* from stating that nothing is reachable. */
|
|
474
501
|
peers(
|
|
475
502
|
now: Timestamp = Date.now(),
|
|
476
|
-
|
|
503
|
+
own: Own = this.#own(),
|
|
477
504
|
): { peers: PeerInfo[]; last_live: LastLiveSession[]; instances?: InstanceInfo[] } {
|
|
478
505
|
const instances = this.deps.mesh?.instances();
|
|
479
506
|
return {
|
|
480
|
-
peers: [...this.#connected.values()].map((session) => this.#peer(session, now,
|
|
507
|
+
peers: [...this.#connected.values()].map((session) => this.#peer(session, now, own)),
|
|
481
508
|
last_live: this.#lastLive.entries(now).map((entry) => ({
|
|
482
509
|
...entry,
|
|
483
|
-
state: this.classify(entry.sid, now,
|
|
510
|
+
state: this.classify(entry.sid, now, own) ?? "disappeared",
|
|
484
511
|
pinned: this.#pinned(entry.sid),
|
|
485
512
|
})),
|
|
486
513
|
...(instances === undefined ? {} : { instances }),
|
|
@@ -493,8 +520,8 @@ export class Sessions implements UpstreamResource {
|
|
|
493
520
|
* make every confirmation poll a value the list did not have before, so the
|
|
494
521
|
* one suppression every topic shares (M5) would let a five-second heartbeat
|
|
495
522
|
* through for a directory that had not changed. */
|
|
496
|
-
agents(
|
|
497
|
-
return { agents: [...rows.values()] };
|
|
523
|
+
agents(own: Own = this.#own()): { agents: AgentInfo[] } {
|
|
524
|
+
return { agents: [...own.rows.values()] };
|
|
498
525
|
}
|
|
499
526
|
|
|
500
527
|
/** Bind a session to this instance, and take what it says about itself. Its
|
|
@@ -515,7 +542,7 @@ export class Sessions implements UpstreamResource {
|
|
|
515
542
|
const held = this.#connected.get(sid);
|
|
516
543
|
const meta = {
|
|
517
544
|
...this.#stated.get(sid),
|
|
518
|
-
...metaOf(
|
|
545
|
+
...metaOf(this.deps, args, (refused) => {
|
|
519
546
|
this.deps.log?.("transcript_path not taken", { sid, path: args.transcript_path, refused });
|
|
520
547
|
}),
|
|
521
548
|
};
|
|
@@ -552,8 +579,8 @@ export class Sessions implements UpstreamResource {
|
|
|
552
579
|
* mechanism and is written once for every topic (M5) — a payload equal to
|
|
553
580
|
* the last one goes no further than that. */
|
|
554
581
|
private changed(now: Timestamp = Date.now()): void {
|
|
555
|
-
const
|
|
556
|
-
const live = this.#liveNow(now,
|
|
582
|
+
const own = this.#own();
|
|
583
|
+
const live = this.#liveNow(now, own);
|
|
557
584
|
for (const [sid, entry] of this.#live) {
|
|
558
585
|
if (live.has(sid)) continue;
|
|
559
586
|
// The declaration came first and the departure has now arrived, which is
|
|
@@ -571,23 +598,23 @@ export class Sessions implements UpstreamResource {
|
|
|
571
598
|
this.#stated.delete(sid);
|
|
572
599
|
}
|
|
573
600
|
this.#live = live;
|
|
574
|
-
this.deps.publish("peers", this.peers(now,
|
|
575
|
-
this.deps.publish("agents", this.agents(
|
|
601
|
+
this.deps.publish("peers", this.peers(now, own));
|
|
602
|
+
this.deps.publish("agents", this.agents(own));
|
|
576
603
|
this.deps.onChanged?.();
|
|
577
604
|
}
|
|
578
605
|
|
|
579
606
|
/** Every session live right now, in the form its `last_live` entry takes if
|
|
580
607
|
* it stops being live. */
|
|
581
|
-
#liveNow(now: Timestamp,
|
|
608
|
+
#liveNow(now: Timestamp, own: Own): Map<Sid, StoredEntry> {
|
|
582
609
|
const live = new Map<Sid, StoredEntry>();
|
|
583
|
-
for (const sid of this.#connected.keys()) live.set(sid, this.#entry(sid, now,
|
|
584
|
-
for (const sid of
|
|
610
|
+
for (const sid of this.#connected.keys()) live.set(sid, this.#entry(sid, now, own));
|
|
611
|
+
for (const sid of own.present) live.set(sid, this.#entry(sid, now, own));
|
|
585
612
|
return live;
|
|
586
613
|
}
|
|
587
614
|
|
|
588
|
-
#entry(sid: Sid, now: Timestamp,
|
|
615
|
+
#entry(sid: Sid, now: Timestamp, own: Own): StoredEntry {
|
|
589
616
|
const held = this.#connected.get(sid);
|
|
590
|
-
const row = rows.get(sid);
|
|
617
|
+
const row = own.rows.get(sid);
|
|
591
618
|
// What answered last, not what the session named when it greeted: the
|
|
592
619
|
// greeting is one instant and `/model` moves afterwards, so the fold is
|
|
593
620
|
// asked first and the greeting only fills in for a transcript that has
|
|
@@ -602,7 +629,7 @@ export class Sessions implements UpstreamResource {
|
|
|
602
629
|
// The harness knows a title for a session that stated none itself, so
|
|
603
630
|
// it goes first and what the session named overrides it.
|
|
604
631
|
...(row?.name === undefined ? {} : { title: row.name }),
|
|
605
|
-
...this.#where(sid,
|
|
632
|
+
...this.#where(sid, own),
|
|
606
633
|
...(model === undefined ? {} : { model }),
|
|
607
634
|
...(effort === undefined ? {} : { effort }),
|
|
608
635
|
...(held === undefined ? {} : { connected_at: held.connected_at }),
|
|
@@ -610,7 +637,7 @@ export class Sessions implements UpstreamResource {
|
|
|
610
637
|
};
|
|
611
638
|
}
|
|
612
639
|
|
|
613
|
-
#peer(session: Connected, now: Timestamp,
|
|
640
|
+
#peer(session: Connected, now: Timestamp, own: Own): PeerInfo {
|
|
614
641
|
// The two "last activity" values are different questions (§5.3): the one
|
|
615
642
|
// above moves on every request the session makes, this one only when a
|
|
616
643
|
// person speaks, and the fold is the only place that knows the second.
|
|
@@ -618,12 +645,12 @@ export class Sessions implements UpstreamResource {
|
|
|
618
645
|
// What the gateway last saw run for this session: an attribute of the row
|
|
619
646
|
// beside the classification, not folded into it (§5.1). Absent from an
|
|
620
647
|
// instance with no gateway, where nothing observes inference at all.
|
|
621
|
-
const gatewayActiveAt = this.#gatewayActiveAt(session.sid,
|
|
648
|
+
const gatewayActiveAt = this.#gatewayActiveAt(session.sid, own.present.has(session.sid));
|
|
622
649
|
return {
|
|
623
650
|
sid: session.sid,
|
|
624
651
|
instance: this.deps.self,
|
|
625
|
-
...this.#where(session.sid,
|
|
626
|
-
state: this.classify(session.sid, now,
|
|
652
|
+
...this.#where(session.sid, own),
|
|
653
|
+
state: this.classify(session.sid, now, own) ?? "live",
|
|
627
654
|
pinned: this.#pinned(session.sid),
|
|
628
655
|
connected_at: session.connected_at,
|
|
629
656
|
last_activity_at: session.last_activity_at,
|
|
@@ -675,10 +702,10 @@ export class Sessions implements UpstreamResource {
|
|
|
675
702
|
* until one does. */
|
|
676
703
|
#where(
|
|
677
704
|
sid: Sid,
|
|
678
|
-
|
|
705
|
+
own: Own,
|
|
679
706
|
): Pick<PeerInfo, "repo" | "ws" | "cwd" | "transcript_path" | "repo_root" | "branch" | "title"> {
|
|
680
707
|
const meta = this.#stated.get(sid) ?? {};
|
|
681
|
-
const cwd = meta.cwd ?? rows.get(sid)?.cwd ?? "";
|
|
708
|
+
const cwd = meta.cwd ?? own.rows.get(sid)?.cwd ?? "";
|
|
682
709
|
return {
|
|
683
710
|
repo: meta.repo ?? "",
|
|
684
711
|
ws: meta.ws ?? "",
|
|
@@ -697,7 +724,7 @@ export class Sessions implements UpstreamResource {
|
|
|
697
724
|
*
|
|
698
725
|
* `transcript_path` is the exception, because it is the one field that is not
|
|
699
726
|
* only displayed: it names a file this instance then reads and follows. What is
|
|
700
|
-
* taken is a path under this config home's
|
|
727
|
+
* taken is a path under this config home's transcript tree, resolved, and nothing
|
|
701
728
|
* else — a session naming a file elsewhere is a session that named nothing,
|
|
702
729
|
* which is what a session that stayed silent already is (M6). It is not an
|
|
703
730
|
* error: how a session describes itself is its own business, and the instance
|
|
@@ -705,8 +732,8 @@ export class Sessions implements UpstreamResource {
|
|
|
705
732
|
* not taken is told to `refused`, which is the operator's answer to a field
|
|
706
733
|
* that is simply absent from what `peers` says. */
|
|
707
734
|
function metaOf(
|
|
735
|
+
deps: Pick<SessionsDeps, "configHome" | "harness">,
|
|
708
736
|
args: HelloArgs,
|
|
709
|
-
configHome: string,
|
|
710
737
|
refused: (reason: string) => void,
|
|
711
738
|
): SessionMeta {
|
|
712
739
|
const meta: Record<string, string> = {};
|
|
@@ -714,7 +741,7 @@ function metaOf(
|
|
|
714
741
|
const value = args[field];
|
|
715
742
|
if (value === undefined) continue;
|
|
716
743
|
if (field === "transcript_path") {
|
|
717
|
-
const taken = ownTranscript(value,
|
|
744
|
+
const taken = ownTranscript(value, deps);
|
|
718
745
|
if (typeof taken === "string") meta[field] = taken;
|
|
719
746
|
else refused(taken.refused);
|
|
720
747
|
continue;
|
|
@@ -727,7 +754,7 @@ function metaOf(
|
|
|
727
754
|
/** A transcript path this instance will read, or nothing.
|
|
728
755
|
|
|
729
756
|
* The test is where the file would be, not whether it is there. M6 is a
|
|
730
|
-
* boundary on what this instance reads, and a path inside
|
|
757
|
+
* boundary on what this instance reads, and a path inside the tree stays
|
|
731
758
|
* inside it whether or not anything has been written there yet — a session
|
|
732
759
|
* greeting at its very start names a transcript the harness has created
|
|
733
760
|
* neither the file nor the directory for, and refusing it would mean the one
|
|
@@ -741,23 +768,27 @@ function metaOf(
|
|
|
741
768
|
* along it that leads out of the tree lands outside and is refused. What is
|
|
742
769
|
* already there must be a file: a directory by that name is not a transcript.
|
|
743
770
|
*
|
|
744
|
-
*
|
|
771
|
+
* The tree itself is settled the same way, so a config home whose first
|
|
745
772
|
* session has yet to write anything is a boundary all the same: the directory
|
|
746
773
|
* that is there is followed, the part that is not is taken as spelled, and the
|
|
747
774
|
* comparison is between two paths resolved by one rule. The config home is not
|
|
748
775
|
* treated that way — an instance answers for a home it is running out of, and
|
|
749
776
|
* one that is not there names no tree to be inside of. */
|
|
750
|
-
function ownTranscript(
|
|
777
|
+
function ownTranscript(
|
|
778
|
+
named: string,
|
|
779
|
+
deps: Pick<SessionsDeps, "configHome" | "harness">,
|
|
780
|
+
): string | Refused {
|
|
751
781
|
if (!isAbsolute(named)) return { refused: "not an absolute path" };
|
|
752
|
-
let
|
|
782
|
+
let tree: string | undefined;
|
|
753
783
|
try {
|
|
754
|
-
|
|
784
|
+
const home = realpathSync(deps.configHome);
|
|
785
|
+
tree = resolveAsFarAsItGoes(join(home, HARNESS[deps.harness].transcripts));
|
|
755
786
|
} catch {
|
|
756
787
|
return { refused: "the config home is not there" };
|
|
757
788
|
}
|
|
758
789
|
const settled = resolveAsFarAsItGoes(named);
|
|
759
|
-
if (
|
|
760
|
-
return { refused: "outside this config home's
|
|
790
|
+
if (tree === undefined || settled === undefined || !within(settled, tree)) {
|
|
791
|
+
return { refused: "outside this config home's transcript tree" };
|
|
761
792
|
}
|
|
762
793
|
const stat = statSync(settled, { throwIfNoEntry: false });
|
|
763
794
|
if (stat !== undefined && !stat.isFile()) return { refused: "not a file" };
|
package/src/sessions/search.ts
CHANGED
|
@@ -239,11 +239,11 @@ function says(text: string, clauses: readonly Clause[]): boolean {
|
|
|
239
239
|
|
|
240
240
|
/** Whether the project directory could be the working directory asked for.
|
|
241
241
|
*
|
|
242
|
-
*
|
|
243
|
-
* is lossy — separators, dots and underscores all become dashes — so this only
|
|
242
|
+
* A harness that files by working directory flattens it into one name, and the
|
|
243
|
+
* flattening is lossy — separators, dots and underscores all become dashes — so this only
|
|
244
244
|
* narrows what is opened. What decides a hit is the transcript's own `cwd`. */
|
|
245
|
-
function looksLike(project: string, words: readonly string[]): boolean {
|
|
246
|
-
if (words.length === 0) return true;
|
|
245
|
+
function looksLike(project: string | undefined, words: readonly string[]): boolean {
|
|
246
|
+
if (words.length === 0 || project === undefined) return true;
|
|
247
247
|
const flat = flatten(project);
|
|
248
248
|
return words.every((word) => flat.includes(flatten(word)));
|
|
249
249
|
}
|
package/src/transcript/files.ts
CHANGED
|
@@ -1,19 +1,59 @@
|
|
|
1
1
|
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
2
|
import { basename, dirname, join } from "node:path";
|
|
3
3
|
import type { Sid } from "@ccmsg/protocol";
|
|
4
|
+
import { type Harness, HARNESS } from "../harness/index.ts";
|
|
4
5
|
import { OpError } from "../dispatch/index.ts";
|
|
5
6
|
|
|
6
|
-
/** Where the harness keeps transcripts under a config home: one directory per
|
|
7
|
-
* working directory, one `<sid>.jsonl` in it. */
|
|
8
|
-
const PROJECTS = "projects";
|
|
9
7
|
const SUFFIX = ".jsonl";
|
|
10
8
|
|
|
11
|
-
/**
|
|
9
|
+
/** Where one harness keeps transcripts under its config home, and how a file
|
|
10
|
+
* there says which session it belongs to (§3.8).
|
|
11
|
+
*
|
|
12
|
+
* Two facts, because the two harnesses file the same thing differently. Claude
|
|
13
|
+
* Code keeps one directory per working directory and names the file after the
|
|
14
|
+
* session; Codex keeps one directory per date and names the file after the
|
|
15
|
+
* thread with the moment it started in front. The `depth` is how many
|
|
16
|
+
* directories stand between the root and a file, which is what the walk needs
|
|
17
|
+
* and what the naming does not say.
|
|
18
|
+
*
|
|
19
|
+
* A Codex rollout that was reverted carries a second id after the thread's own,
|
|
20
|
+
* separated by `_`: the thread is the same and the file is a new one, so the
|
|
21
|
+
* name still answers "which session" and that is what is read out of it. */
|
|
22
|
+
interface TranscriptLayout {
|
|
23
|
+
readonly depth: number;
|
|
24
|
+
/** The session a file belongs to, or nothing when the name is not one this
|
|
25
|
+
* harness writes. */
|
|
26
|
+
readonly sidOf: (name: string) => Sid | undefined;
|
|
27
|
+
/** What that session's file is called, where the name follows from the sid.
|
|
28
|
+
* Absent where it does not, which is what makes the walk the only way in. */
|
|
29
|
+
readonly nameOf?: (sid: Sid) => string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A session id as Claude Code names files by. Validated before it is joined
|
|
12
33
|
* to a path, so a sid is a name rather than a route: no separator and no dot
|
|
13
34
|
* can appear in it, which makes traversal unrepresentable rather than
|
|
14
35
|
* unlikely. */
|
|
15
36
|
const SID = /^[0-9a-fA-F-]{8,64}$/;
|
|
16
37
|
|
|
38
|
+
/** A Codex rollout, as its recorder writes the name: the moment it opened, the
|
|
39
|
+
* thread UUID, and the rollout's own id after it when the thread was reverted.
|
|
40
|
+
* The thread UUID is what a sid is here (measured against codex-cli 0.153.4). */
|
|
41
|
+
const ROLLOUT =
|
|
42
|
+
/^rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-([0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})(?:_[0-9a-fA-F-]{36})?\.jsonl$/;
|
|
43
|
+
|
|
44
|
+
const LAYOUTS: Record<Harness, TranscriptLayout> = {
|
|
45
|
+
claude: {
|
|
46
|
+
depth: 1,
|
|
47
|
+
sidOf: (name) => {
|
|
48
|
+
if (!name.endsWith(SUFFIX)) return undefined;
|
|
49
|
+
const sid = name.slice(0, -SUFFIX.length);
|
|
50
|
+
return SID.test(sid) ? sid : undefined;
|
|
51
|
+
},
|
|
52
|
+
nameOf: (sid) => `${sid}${SUFFIX}`,
|
|
53
|
+
},
|
|
54
|
+
codex: { depth: 3, sidOf: (name) => ROLLOUT.exec(name)?.[1] },
|
|
55
|
+
};
|
|
56
|
+
|
|
17
57
|
/** The agent id and run id shapes the harness writes under a session's own
|
|
18
58
|
* directory, and the name a teammate is addressed by. Each is validated on the
|
|
19
59
|
* same footing as a sid, for the same reason: all three name a file. */
|
|
@@ -30,6 +70,8 @@ const TEAMMATE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
|
30
70
|
* (M6) — nothing searches for another one. */
|
|
31
71
|
export interface TranscriptFilesDeps {
|
|
32
72
|
readonly configHome: string;
|
|
73
|
+
/** Which harness's tree is under it (§3.8). */
|
|
74
|
+
readonly harness: Harness;
|
|
33
75
|
/** Where a connected session said its transcript is (§5.1). A session that
|
|
34
76
|
* never greeted has none, and the walk below answers for it. */
|
|
35
77
|
readonly announced: (sid: Sid) => string | undefined;
|
|
@@ -42,10 +84,10 @@ export class TranscriptFiles {
|
|
|
42
84
|
*
|
|
43
85
|
* Two ways to the one file, in the order of what each is good for: what the
|
|
44
86
|
* session announced is exact and costs no search, and the walk finds the
|
|
45
|
-
* file by the identity it carries in its name
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
87
|
+
* file by the identity it carries in its name for a session that never
|
|
88
|
+
* greeted or is no longer running. Both stay inside this harness's own
|
|
89
|
+
* transcript tree — the announced path because it was taken only if it was
|
|
90
|
+
* inside it, the walk because that tree is what it walks (M6). */
|
|
49
91
|
path(sid: Sid): string | undefined {
|
|
50
92
|
const announced = this.deps.announced(sid);
|
|
51
93
|
if (announced !== undefined && isFile(announced)) return announced;
|
|
@@ -117,21 +159,23 @@ export class TranscriptFiles {
|
|
|
117
159
|
* the file and what a `stat` already said about it, so neither has to stat
|
|
118
160
|
* again to decide whether to open it. */
|
|
119
161
|
all(): TranscriptFile[] {
|
|
162
|
+
const layout = LAYOUTS[this.deps.harness];
|
|
120
163
|
const found: TranscriptFile[] = [];
|
|
121
|
-
const
|
|
122
|
-
for (const project of names(projects)) {
|
|
123
|
-
const dir = join(projects, project);
|
|
164
|
+
for (const dir of directories(this.#root(), layout.depth)) {
|
|
124
165
|
for (const entry of names(dir)) {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if (!SID.test(sid)) continue;
|
|
166
|
+
const sid = layout.sidOf(entry);
|
|
167
|
+
if (sid === undefined) continue;
|
|
128
168
|
const file = join(dir, entry);
|
|
129
169
|
const stat = statOf(file);
|
|
130
170
|
if (stat === undefined) continue;
|
|
131
171
|
found.push({
|
|
132
172
|
sid,
|
|
133
173
|
file,
|
|
134
|
-
|
|
174
|
+
// Only a layout that files by working directory has one to state,
|
|
175
|
+
// and the field is a prefilter: a tree that says nothing about where
|
|
176
|
+
// a session ran narrows nothing, and the transcript's own `cwd`
|
|
177
|
+
// decides as it already does.
|
|
178
|
+
...(this.deps.harness === "claude" ? { project: basename(dir) } : {}),
|
|
135
179
|
size: stat.size,
|
|
136
180
|
created_at: Math.round(stat.birthtimeMs || stat.ctimeMs),
|
|
137
181
|
updated_at: Math.round(stat.mtimeMs),
|
|
@@ -142,17 +186,52 @@ export class TranscriptFiles {
|
|
|
142
186
|
return found;
|
|
143
187
|
}
|
|
144
188
|
|
|
189
|
+
/** The root of this harness's transcript tree, which is the boundary every
|
|
190
|
+
* path below is inside of (M6). */
|
|
191
|
+
#root(): string {
|
|
192
|
+
return join(this.deps.configHome, HARNESS[this.deps.harness].transcripts);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The session's file, found by the identity its name carries.
|
|
196
|
+
*
|
|
197
|
+
* A layout whose name follows from the sid is joined rather than searched,
|
|
198
|
+
* which is one `stat` per directory instead of a listing; one whose name
|
|
199
|
+
* carries more than the sid is walked, because the rest of the name is
|
|
200
|
+
* exactly what this does not know. */
|
|
145
201
|
private find(sid: Sid): string | undefined {
|
|
146
|
-
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
if (
|
|
202
|
+
const layout = LAYOUTS[this.deps.harness];
|
|
203
|
+
const dirs = directories(this.#root(), layout.depth);
|
|
204
|
+
const nameOf = layout.nameOf;
|
|
205
|
+
if (nameOf !== undefined) {
|
|
206
|
+
if (!SID.test(sid)) return undefined;
|
|
207
|
+
for (const dir of dirs) {
|
|
208
|
+
const file = join(dir, nameOf(sid));
|
|
209
|
+
if (isFile(file)) return file;
|
|
210
|
+
}
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
for (const dir of dirs) {
|
|
214
|
+
for (const entry of names(dir)) {
|
|
215
|
+
if (layout.sidOf(entry) === sid && isFile(join(dir, entry))) return join(dir, entry);
|
|
216
|
+
}
|
|
151
217
|
}
|
|
152
218
|
return undefined;
|
|
153
219
|
}
|
|
154
220
|
}
|
|
155
221
|
|
|
222
|
+
/** Every directory transcripts sit in, at the depth the layout files them at.
|
|
223
|
+
*
|
|
224
|
+
* Names are read rather than dates computed: what is there is what the harness
|
|
225
|
+
* wrote, and a tree with a directory nobody expected is one whose files are
|
|
226
|
+
* still found. */
|
|
227
|
+
function directories(root: string, depth: number): string[] {
|
|
228
|
+
let level = [root];
|
|
229
|
+
for (let step = 0; step < depth; step += 1) {
|
|
230
|
+
level = level.flatMap((dir) => names(dir).map((entry) => join(dir, entry)));
|
|
231
|
+
}
|
|
232
|
+
return level;
|
|
233
|
+
}
|
|
234
|
+
|
|
156
235
|
export interface AgentNames {
|
|
157
236
|
readonly agent_id?: string;
|
|
158
237
|
readonly run_id?: string;
|
|
@@ -165,8 +244,10 @@ export interface TranscriptFile {
|
|
|
165
244
|
readonly file: string;
|
|
166
245
|
/** The project directory's name, which is the working directory flattened.
|
|
167
246
|
* A lossy spelling — separators and dots all become dashes — so it prefilters
|
|
168
|
-
* a search and never decides it.
|
|
169
|
-
|
|
247
|
+
* a search and never decides it. Absent where the harness files transcripts
|
|
248
|
+
* by something other than the working directory, which leaves nothing to
|
|
249
|
+
* prefilter on. */
|
|
250
|
+
readonly project?: string;
|
|
170
251
|
readonly size: number;
|
|
171
252
|
readonly created_at: number;
|
|
172
253
|
readonly updated_at: number;
|