@ccmsg/cli 0.6.1 → 0.8.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ccmsg/cli",
3
- "version": "0.6.1",
3
+ "version": "0.8.0",
4
4
  "description": "The ccmsg daemon, CLI and agent plugins for one instance (= one config home)",
5
5
  "license": "MIT",
6
6
  "author": "kawaz",
@@ -20,7 +20,7 @@
20
20
  "test": "bun test"
21
21
  },
22
22
  "dependencies": {
23
- "@ccmsg/protocol": "1.16.0"
23
+ "@ccmsg/protocol": "1.20.0"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/bun": "^1.3.0",
@@ -115,12 +115,16 @@ const STARTING_PRESETS = [
115
115
  {
116
116
  name: "howto",
117
117
  description: "調査のノウハウだけ。何を考えて何を叩いて何を読み書きしたか",
118
- opts: { types: ["thinking", "message:user", "message:sub", "tool:Bash", "@file"] },
118
+ opts: {
119
+ types: ["thinking", "message:user", "message:parent", "message:sub", "tool:Bash", "@file"],
120
+ },
119
121
  },
120
122
  {
121
123
  name: "journal",
122
124
  description: "日記用。人との往復と worker の答え、思考は要点だけ",
123
- opts: { types: ["message:user", "message:sub:in", "thinking"] },
125
+ opts: {
126
+ types: ["message:user", "message:parent", "message:sub:in", "message:team:in", "thinking"],
127
+ },
124
128
  },
125
129
  {
126
130
  name: "handoff",
@@ -62,7 +62,7 @@ import {
62
62
  Transport,
63
63
  type UpgradeDecision,
64
64
  } from "../transport/index.ts";
65
- import { Mesh, MESH_PROTOCOL } from "../mesh/index.ts";
65
+ import { Instances, Mesh, MESH_PROTOCOL } from "../mesh/index.ts";
66
66
  import {
67
67
  Gateway,
68
68
  gatewayCapabilities,
@@ -317,6 +317,7 @@ export class Instance {
317
317
  readonly #transport = new Transport();
318
318
  readonly #topics: Topics;
319
319
  readonly #sessions: Sessions;
320
+ readonly #instances: Instances;
320
321
  readonly #status: SessionStatus;
321
322
  readonly #transcripts: Transcripts;
322
323
  readonly #gateway: Gateway;
@@ -401,10 +402,10 @@ export class Instance {
401
402
  const stated = (data as { records?: AuthRecord[] } | undefined)?.records;
402
403
  if (Array.isArray(stated)) this.#auth.merge(stated);
403
404
  },
404
- // What `peers` says about the instances is this instance's own view, so
405
- // it is restated when that view moves (§7.5).
405
+ // The mesh view is this instance's own, so the topic that carries it is
406
+ // restated when that view moves (§7.5).
406
407
  changed: () => {
407
- this.#sessions.refresh();
408
+ this.#instances.refresh();
408
409
  this.#linkMoved();
409
410
  },
410
411
  });
@@ -532,6 +533,15 @@ export class Instance {
532
533
  publish: (topic, data, instance) => this.#topics.publish(topic, data, instance),
533
534
  });
534
535
 
536
+ this.#instances = new Instances({
537
+ self: this.self,
538
+ ...(this.#mesh === undefined ? {} : { endpoint: this.#mesh.self, mesh: this.#mesh }),
539
+ publish: (topic, data) => {
540
+ this.#topics.publish(topic, data);
541
+ },
542
+ });
543
+
544
+ this.#topics.attach("instances", this.#instances);
535
545
  this.#topics.attach("peers", this.#sessions);
536
546
  this.#topics.attach("agents", this.#sessions);
537
547
  this.#topics.attach("inbox", this.#delivery);
package/src/mesh/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export * from "./instances.ts";
1
2
  export * from "./keys.ts";
2
3
  export * from "./mesh.ts";
3
4
  export * from "./probe.ts";
@@ -0,0 +1,76 @@
1
+ import { hostname } from "node:os";
2
+ import type { Endpoint, InstanceId, InstanceInfo } from "@ccmsg/protocol";
3
+ import type { TopicValue, UpstreamResource } from "../topics/index.ts";
4
+
5
+ /** What the `instances` topic reads: the mesh as this instance sees it, which
6
+ * is the same view `hello` answers with. */
7
+ export interface MeshView {
8
+ instances(): InstanceInfo[];
9
+ }
10
+
11
+ /** The cluster as one instance sees it, itself included.
12
+ *
13
+ * An instance with no mesh is a cluster of one and says so: it is reached at
14
+ * whatever it serves, which is a row without an endpoint when that is the unix
15
+ * socket alone rather than no row at all (contract, `InstanceInfo`).
16
+ *
17
+ * The same answer feeds `hello` and the topic, from here rather than from two
18
+ * places: a greeting and a subscription that disagreed about who is in the
19
+ * cluster would be one instance stating two views of itself. */
20
+ export function clusterView(
21
+ self: InstanceId,
22
+ endpoint: Endpoint | undefined,
23
+ mesh: MeshView | undefined,
24
+ ): InstanceInfo[] {
25
+ return (
26
+ mesh?.instances() ?? [
27
+ {
28
+ id: self,
29
+ ...(endpoint === undefined ? {} : { endpoint }),
30
+ host: hostname(),
31
+ reachable: true,
32
+ },
33
+ ]
34
+ );
35
+ }
36
+
37
+ /** The `instances` topic (§7.5).
38
+ *
39
+ * A link going down reaches a subscriber where it is already listening, rather
40
+ * than only on its next greeting. The value is whole per instance: `reachable`
41
+ * is one instance's reading of every link it has, taken together, and two
42
+ * instances may legitimately disagree about the same link — so a frame states
43
+ * one sender's view entire and leaves every other sender's alone.
44
+ *
45
+ * Nothing is started or stopped by a subscription. The view exists because the
46
+ * mesh exists, and reading it costs a walk over the configured peers (§6.3:
47
+ * what subscription drives is a watch, and there is none here). */
48
+ export class Instances implements UpstreamResource {
49
+ constructor(
50
+ private readonly deps: {
51
+ readonly self: InstanceId;
52
+ readonly endpoint?: Endpoint;
53
+ readonly mesh?: MeshView;
54
+ readonly publish: (topic: string, data: unknown) => void;
55
+ },
56
+ ) {}
57
+
58
+ view(): InstanceInfo[] {
59
+ return clusterView(this.deps.self, this.deps.endpoint, this.deps.mesh);
60
+ }
61
+
62
+ /** State the view, which the mesh asks for whenever a link moves. A frame
63
+ * equal to the last one goes no further than the suppression every topic
64
+ * shares (M5), so restating it costs nothing when nothing moved. */
65
+ refresh(): void {
66
+ this.deps.publish("instances", { instances: this.view() });
67
+ }
68
+
69
+ start(): void {}
70
+
71
+ stop(): void {}
72
+
73
+ snapshot(): readonly TopicValue[] {
74
+ return [{ instance: this.deps.self, data: { instances: this.view() } }];
75
+ }
76
+ }
package/src/mesh/mesh.ts CHANGED
@@ -763,7 +763,7 @@ export class Mesh {
763
763
  this.#host?.element(topic, instance as InstanceId, fields["data"]);
764
764
  return true;
765
765
  }
766
- this.relay.accept(instance as InstanceId, topic, fields["data"]);
766
+ this.relay.accept(instance as InstanceId, topic, fields["data"], fields["snapshot"] === true);
767
767
  return true;
768
768
  }
769
769
 
package/src/mesh/relay.ts CHANGED
@@ -1,40 +1,59 @@
1
+ import type { SessionRow } from "../topics/index.ts";
1
2
  import {
2
- type AgentInfo,
3
3
  type InstanceId,
4
4
  LAST_LIVE_RETENTION_MS,
5
- type LastLiveSession,
6
- type PeerInfo,
7
5
  PLAIN_TOPICS,
8
6
  type Sid,
9
7
  TOPIC_ATTRIBUTES,
10
8
  type Timestamp,
11
9
  } from "@ccmsg/protocol";
12
- import type { TopicValue } from "../topics/index.ts";
10
+ import { Elements, type TopicValue } from "../topics/index.ts";
11
+
12
+ /** The rows of sessions the whole cluster is seen through.
13
+ *
14
+ * They are `element`-granular, and an element topic is relayable only when its
15
+ * elements say whose they are: a row here names the instance that holds the
16
+ * session, so two instances' rows stand side by side under one topic name the
17
+ * way a whole value per instance does. `inbox` and `kv:<ns>` are elements of
18
+ * the same granularity and are not relayed, because their elements carry no
19
+ * such name — an `inbox` frame belongs to a session, not to an instance. */
20
+ const ROW_TOPICS: readonly string[] = ["peers", "agents"];
13
21
 
14
22
  /** The topics a subscriber sees the whole cluster on.
15
23
  *
16
- * The per-instance whole is what makes a cluster view possible at all (§6.2):
17
- * a frame replaces its own instance's entries and leaves every other
18
- * instance's alone, so several instances can state the same topic name without
19
- * colliding. A topic of any other granularity has no such rule and is not
20
- * relayed an `element` topic like `inbox` names one instance's topic while
21
- * its value belongs to a session, and a frame of it carries no way to say
22
- * whose it is. */
23
- export const CLUSTER_TOPICS: readonly string[] = PLAIN_TOPICS.filter(
24
- (topic) => TOPIC_ATTRIBUTES[topic].granularity === "per_instance_whole",
25
- );
24
+ * A per-instance whole is relayable by construction (§6.2): a frame replaces
25
+ * its own instance's entries and leaves every other instance's alone, so
26
+ * several instances can state the same topic name without colliding. The rows
27
+ * above are relayable for the same reason read one element at a time. */
28
+ export const CLUSTER_TOPICS: readonly string[] = [
29
+ ...PLAIN_TOPICS.filter((topic) => TOPIC_ATTRIBUTES[topic].granularity === "per_instance_whole"),
30
+ ...ROW_TOPICS,
31
+ ];
26
32
 
27
33
  /** The one topic the mesh carries that the relay does not.
28
34
  *
29
- * It is `element`-granular, so what travels is the entries that changed and the
30
- * receiver merges them by key; and it is the instances' own, so it is asked for
31
- * as the instance rather than on a person's behalf (DR-0001 §2.6). */
35
+ * It is `element`-granular and its elements are the instances' own, so it is
36
+ * asked for as the instance rather than on a person's behalf, and folded into
37
+ * the set this instance holds rather than held here (DR-0001 §2.6). */
32
38
  export const AUTH_TOPIC = "auth_records";
33
39
 
34
40
  export function isClusterTopic(topic: string): boolean {
35
41
  return CLUSTER_TOPICS.includes(topic);
36
42
  }
37
43
 
44
+ /** Whether what a frame of this topic carries is rows to be merged rather than
45
+ * a value to be replaced. */
46
+ function carriesRows(topic: string): boolean {
47
+ return ROW_TOPICS.includes(topic);
48
+ }
49
+
50
+ /** The rows of one frame, under the field each topic names them in. A frame
51
+ * that carries none is one there is nothing to merge from. */
52
+ function rowsOf(topic: string, data: unknown): readonly SessionRow[] {
53
+ const field = (data as Record<string, unknown> | undefined)?.[topic];
54
+ return Array.isArray(field) ? (field as SessionRow[]) : [];
55
+ }
56
+
38
57
  export interface RelayDeps {
39
58
  /** Hand a relayed frame to this instance's own subscribers, under the
40
59
  * instance that produced it (§7.4). */
@@ -70,19 +89,47 @@ export class Relay {
70
89
  }
71
90
 
72
91
  /** A frame a peer pushed on a topic this instance relays.
92
+ *
93
+ * `snapshot` marks the opening frame of a subscription, which carries the
94
+ * whole of what its instance holds rather than what changed.
73
95
  *
74
96
  * `instance` is the one that produced the value, which is not always the
75
97
  * peer it arrived from: a mesh of three relays transitively, and the frame
76
98
  * names its origin the whole way. Held under that origin, and passed on
77
99
  * unchanged — recomputing it would put the same judgement in two places
78
100
  * (§7.4). */
79
- accept(instance: InstanceId, topic: string, data: unknown): void {
101
+ accept(instance: InstanceId, topic: string, data: unknown, snapshot = false): void {
80
102
  if (!isClusterTopic(topic)) return;
81
103
  this.#sweep();
82
104
  const held = this.#held.get(instance) ?? new Map<string, unknown>();
83
105
  this.#held.set(instance, held);
84
- held.set(topic, data);
85
- this.deps.publish(topic, data, instance);
106
+ if (!carriesRows(topic)) {
107
+ held.set(topic, data);
108
+ this.deps.publish(topic, data, instance);
109
+ return;
110
+ }
111
+ // A frame of rows says what changed, so what is held is the rows merged
112
+ // and what travels on is the part of it that said something. The same
113
+ // comparison the mechanism makes of a whole value, made of one element
114
+ // (M5) — and a frame left with no rows is not passed on at all, which is
115
+ // what stops a peer's restatement from becoming a frame for every local
116
+ // subscriber.
117
+ // An opening frame is the whole of what its instance holds, so it is taken
118
+ // as the list restated rather than as changes folded in: a row the peer no
119
+ // longer has is gone from it and from nowhere else, and merging would leave
120
+ // it here forever. What comes of that is the same kind of answer either
121
+ // way — the rows that told this instance something, removals included.
122
+ const rows = this.#rows(held, topic);
123
+ const stated = rowsOf(topic, data);
124
+ const news = snapshot ? rows.diff(stated) : rows.merge(stated);
125
+ if (news.length > 0) this.deps.publish(topic, { [topic]: news }, instance);
126
+ }
127
+
128
+ /** The rows one instance has stated on a topic, made the first time it does. */
129
+ #rows(held: Map<string, unknown>, topic: string): Elements {
130
+ const rows = (held.get(topic) as Elements | undefined) ?? new Elements();
131
+ held.set(topic, rows);
132
+ return rows;
86
133
  }
87
134
 
88
135
  /** The link to this instance is gone. What it said is kept and marked,
@@ -113,7 +160,16 @@ export class Relay {
113
160
  const values: TopicValue[] = [];
114
161
  for (const [instance, held] of this.#held) {
115
162
  const data = held.get(topic);
116
- if (data !== undefined) values.push({ instance, data });
163
+ if (data === undefined) continue;
164
+ // A topic of rows is held merged, and the opening frame of a topic is
165
+ // its whole value — so what a fresh subscriber is handed is every row
166
+ // that instance has stated, in one frame of the same shape as the ones
167
+ // that follow it.
168
+ values.push(
169
+ carriesRows(topic)
170
+ ? { instance, data: { [topic]: (data as Elements).rows() } }
171
+ : { instance, data },
172
+ );
117
173
  }
118
174
  return values;
119
175
  }
@@ -121,29 +177,21 @@ export class Relay {
121
177
  /** Which instance a session belongs to, read from the cluster values the
122
178
  * peers stated (§7.3).
123
179
  *
124
- * `peers` names every session an instance currently holds connected or in
125
- * `last_live` — and is checked first. A session hello has not reached yet
126
- * has no row there but the harness may already know of it, so `agents` is
127
- * checked next; `last_live` is the last resort for one whose instance has
128
- * not stated `agents` at all. Every row names its own instance rather than
129
- * the one that relayed it, so a value that travelled through a third
130
- * instance still points at the session's own. */
180
+ * `peers` names every session an instance holds, connected and lost alike,
181
+ * and is checked first. A session whose greeting has not reached its
182
+ * instance yet has no row there while the harness may already know of it, so
183
+ * `agents` is checked next. Every row names its own instance rather than the
184
+ * one that relayed it, so a row that travelled through a third instance
185
+ * still points at the session's own. */
131
186
  owner(sid: Sid): InstanceId | undefined {
132
187
  this.#sweep();
133
- for (const held of this.#held.values()) {
134
- const value = held.get("peers") as { peers?: PeerInfo[] } | undefined;
135
- const row = value?.peers?.find((peer) => peer.sid === sid);
136
- if (row !== undefined) return row.instance;
137
- }
138
- for (const held of this.#held.values()) {
139
- const value = held.get("agents") as { agents?: AgentInfo[] } | undefined;
140
- const row = value?.agents?.find((agent) => agent.sid === sid);
141
- if (row !== undefined) return row.instance;
142
- }
143
- for (const held of this.#held.values()) {
144
- const value = held.get("peers") as { last_live?: LastLiveSession[] } | undefined;
145
- const row = value?.last_live?.find((session) => session.sid === sid);
146
- if (row !== undefined) return row.instance;
188
+ for (const topic of ROW_TOPICS) {
189
+ for (const held of this.#held.values()) {
190
+ const row = (held.get(topic) as Elements | undefined)
191
+ ?.rows()
192
+ .find((held) => held.sid === sid);
193
+ if (row !== undefined) return row.instance;
194
+ }
147
195
  }
148
196
  return undefined;
149
197
  }
@@ -3,7 +3,6 @@ import type {
3
3
  CandidateSession,
4
4
  InboxMessage,
5
5
  InstanceId,
6
- LastLiveSession,
7
6
  MessageSendArgs,
8
7
  MessageSendResult,
9
8
  Mid,
@@ -15,6 +14,7 @@ import type {
15
14
  UndeliveredReason,
16
15
  } from "@ccmsg/protocol";
17
16
  import { USER_SENDER } from "@ccmsg/protocol";
17
+ import { isLive } from "../sessions/classify.ts";
18
18
  import {
19
19
  type DispatchResult,
20
20
  type HandlerInput,
@@ -35,7 +35,7 @@ const INBOX = "inbox";
35
35
  * the reasons of §4.2 from growing a source per reason. */
36
36
  export interface SessionLookup {
37
37
  classify(sid: Sid): SessionState | undefined;
38
- peers(): { peers: PeerInfo[]; last_live: LastLiveSession[] };
38
+ peerRows(): PeerInfo[];
39
39
  }
40
40
 
41
41
  /** The rest of the cluster, for a message addressed outside this instance.
@@ -290,17 +290,27 @@ export class Delivery implements UpstreamResource {
290
290
  }
291
291
 
292
292
  /** Sessions live now in the repository the addressee belongs to (§4.2).
293
+ *
294
+ * The rows are one list of sessions, connected and lost alike, so which of
295
+ * them can be written to is the classification — asked of the domain, the
296
+ * same way the addressee's own reason was, rather than read off a field of
297
+ * the row (M1).
293
298
  *
294
299
  * The repository is `repo_root` as the session named it. A session that named
295
300
  * none is left out rather than matched on something derived from its `cwd`:
296
301
  * no primary source states that derivation, and the sessions domain does not
297
302
  * make one up either. */
298
303
  #candidates(to: Sid): CandidateSession[] {
299
- const { peers, last_live } = this.deps.sessions.peers();
300
- const root = [...peers, ...last_live].find((row) => row.sid === to)?.repo_root;
304
+ const rows = this.deps.sessions.peerRows();
305
+ const root = rows.find((row) => row.sid === to)?.repo_root;
301
306
  if (root === undefined) return [];
302
- return peers
303
- .filter((peer) => peer.sid !== to && peer.repo_root === root)
307
+ return rows
308
+ .filter(
309
+ (peer) =>
310
+ peer.sid !== to &&
311
+ peer.repo_root === root &&
312
+ isLive({ state: this.deps.sessions.classify(peer.sid) }),
313
+ )
304
314
  .map((peer) => ({
305
315
  sid: peer.sid,
306
316
  ...(peer.ws === "" ? {} : { ws: peer.ws }),
@@ -374,7 +384,7 @@ function callerOf(input: HandlerInput): CallerIdentity | undefined {
374
384
  * notification's subject are the same session seen from two ops, and a session
375
385
  * shown one way there and another way here would read as two. */
376
386
  export function sessionLabel(sessions: SessionLookup, sid: Sid): string {
377
- const peer = sessions.peers().peers.find((row) => row.sid === sid);
387
+ const peer = sessions.peerRows().find((row) => row.sid === sid);
378
388
  if (peer === undefined) return sid;
379
389
  const where = [peer.repo, peer.ws].filter((part) => part !== "").join("/");
380
390
  return where === "" ? sid : where;
@@ -64,3 +64,15 @@ export function classify(
64
64
  if (inputs.last_live === undefined) return undefined;
65
65
  return inputs.last_live.stopped_at === undefined ? "disappeared" : "paused";
66
66
  }
67
+
68
+ /** Whether a classification is one of the connected ones, which is what a row
69
+ * has to be for anything here to reach it.
70
+ *
71
+ * Read off the classification rather than restated: the two sections the
72
+ * vocabulary is divided into are the contract's own, and a second list of
73
+ * which words mean "still there" would be a second place for the rule to drift
74
+ * in (M1). A row that states none is one no section holds, which is not a
75
+ * session anything can be handed to. */
76
+ export function isLive(row: { readonly state?: SessionState }): boolean {
77
+ return row.state !== undefined && row.state !== "paused" && row.state !== "disappeared";
78
+ }
@@ -75,7 +75,10 @@ export function dumpWrite(args: SessionDumpWriteArgs, deps: DumpDeps): SessionDu
75
75
  },
76
76
  deps.presets,
77
77
  );
78
- const { items, entries } = select(within(classify(located(text)), args), keep);
78
+ const { items, entries } = select(
79
+ within(classify(located(text), deps.files.subjectOf(file)), args),
80
+ keep,
81
+ );
79
82
  const ids = ledger(items);
80
83
  const written_at = Date.now();
81
84
  // The file repeats what it was asked for. A dump outlives the request that
@@ -68,7 +68,7 @@ export function itemsRead(
68
68
  throw new OpError("not_found", `the transcript of ${args.sid} could not be read`);
69
69
  }
70
70
  const keep = selection(args.types === undefined ? {} : { types: args.types }, deps.presets);
71
- const { items } = select(within(classify(located(text)), args), keep);
71
+ const { items } = select(within(classify(located(text), deps.files.subjectOf(file)), args), keep);
72
72
  const page = paged(items, args.limit, backwards(args));
73
73
  return {
74
74
  items: page.items,
@@ -3,22 +3,39 @@ import { dirname, join } from "node:path";
3
3
  import {
4
4
  LAST_LIVE_RETENTION_MS,
5
5
  type InstanceId,
6
- type LastLiveSession,
6
+ type PeerInfo,
7
7
  type Sid,
8
8
  type Timestamp,
9
9
  } from "@ccmsg/protocol";
10
10
 
11
11
  export const LAST_LIVE_FILE = "last-live.json";
12
12
 
13
- /** What is stored per session: the contract's entry, minus the one field that
14
- * is derived rather than observed.
13
+ /** What is stored per session: the observations a lost session's row is built
14
+ * from, and nothing the row derives or a connection supplies.
15
15
  *
16
16
  * `state` is left out on purpose (M4). It follows from `stopped_at` and from
17
17
  * whether the session is live again, both of which are known when the list is
18
18
  * read, so storing it would be storing a conclusion that can go stale on disk.
19
19
  * `pinned` is left out because no pin is held anywhere yet; when one is, it
20
- * belongs to the session rather than to this list. */
21
- export type StoredEntry = Omit<LastLiveSession, "state" | "pinned">;
20
+ * belongs to the session rather than to this list. The connection fields go
21
+ * with the connection there is none of.
22
+ *
23
+ * `last_seen_at` is required here while the row states it optionally: a row
24
+ * this store holds is by definition one this instance has lost, and when it
25
+ * last saw it is what the retention window is measured from. */
26
+ export type StoredEntry = Omit<
27
+ PeerInfo,
28
+ | "state"
29
+ | "pinned"
30
+ | "last_activity_at"
31
+ | "last_user_input_at"
32
+ | "gateway_active_at"
33
+ | "send_message"
34
+ | "client_version"
35
+ | "protocol_version"
36
+ | "stale_client"
37
+ | "last_seen_at"
38
+ > & { last_seen_at: Timestamp };
22
39
 
23
40
  interface Document {
24
41
  version: number;
@@ -1,5 +1,4 @@
1
1
  import { realpathSync, statSync } from "node:fs";
2
- import { hostname } from "node:os";
3
2
  import { basename, dirname, isAbsolute, join } from "node:path";
4
3
  import {
5
4
  type AgentInfo,
@@ -7,9 +6,10 @@ import {
7
6
  type HelloArgs,
8
7
  type HelloResult,
9
8
  type Endpoint,
9
+ type AgentElement,
10
10
  type InstanceId,
11
11
  type InstanceInfo,
12
- type LastLiveSession,
12
+ type PeerElement,
13
13
  type PeerInfo,
14
14
  PROTOCOL_VERSION,
15
15
  type SessionState,
@@ -20,8 +20,9 @@ import {
20
20
  import { type HandlerInput, OpError, type Requester } from "../dispatch/index.ts";
21
21
  import { within } from "../files/index.ts";
22
22
  import { HARNESS, type Harness } from "../harness/index.ts";
23
+ import { clusterView } from "../mesh/instances.ts";
23
24
  import type { TranscriptFacts } from "../transcript/index.ts";
24
- import type { TopicValue, UpstreamResource } from "../topics/index.ts";
25
+ import { Elements, type TopicValue, type UpstreamResource } from "../topics/index.ts";
25
26
  import { classify, type SessionInputs } from "./classify.ts";
26
27
  import { isWaiting, type OwnSessions, ownSessions } from "./harness.ts";
27
28
  import { LastLiveStore, type StoredEntry } from "./last-live.ts";
@@ -211,6 +212,16 @@ export class Sessions implements UpstreamResource {
211
212
  * then carries on stays connected and keeps its declaration, which is spent
212
213
  * whenever it does leave. */
213
214
  readonly #stopping = new Map<Sid, Timestamp>();
215
+ /** What the last frame of each topic left every subscriber holding, kept
216
+ * whether or not anybody is subscribed.
217
+ *
218
+ * A frame carries a difference, and the difference is taken against what was
219
+ * sent — not against what the last listener happened to see. Which is also
220
+ * what makes it right for a subscriber that arrives after a spell of nobody
221
+ * listening: it is handed every row as a snapshot, and every frame after it
222
+ * says what changed since the last one went out. */
223
+ readonly #sentPeers = new Elements();
224
+ readonly #sentAgents = new Elements();
214
225
 
215
226
  constructor(private readonly deps: SessionsDeps) {
216
227
  this.#harness = ownSessions(
@@ -294,18 +305,9 @@ export class Sessions implements UpstreamResource {
294
305
  protocol_version: PROTOCOL_VERSION,
295
306
  instance: this.deps.self,
296
307
  ...(this.deps.endpoint === undefined ? {} : { endpoint: this.deps.endpoint }),
297
- // Without a mesh the cluster is this instance alone, and it says so:
298
- // an instance serving the unix socket alone has no URL to be dialed at,
299
- // which is a row without an endpoint rather than no row (contract,
300
- // `InstanceInfo`).
301
- instances: this.deps.mesh?.instances() ?? [
302
- {
303
- id: this.deps.self,
304
- ...(this.deps.endpoint === undefined ? {} : { endpoint: this.deps.endpoint }),
305
- host: hostname(),
306
- reachable: true,
307
- },
308
- ],
308
+ // The same view the `instances` topic carries, worked out in one place
309
+ // so a greeting and a subscription cannot state two different clusters.
310
+ instances: clusterView(this.deps.self, this.deps.endpoint, this.deps.mesh),
309
311
  capabilities: [...this.deps.capabilities],
310
312
  version: this.deps.version,
311
313
  started_at: this.deps.startedAt,
@@ -483,9 +485,21 @@ export class Sessions implements UpstreamResource {
483
485
  if (this.#wanted.size === 0) this.#harness.stop();
484
486
  }
485
487
 
488
+ /** What a fresh subscriber is handed: every row, connected and lost alike.
489
+ *
490
+ * A frame of either topic carries the rows that changed, so the opening one
491
+ * has to carry all of them — it is the only frame that states the rows a
492
+ * subscriber was not there for. Stating them is also what the difference
493
+ * after it is taken against: these rows are what the subscriber now holds,
494
+ * and they are the same rows every earlier subscriber was brought up to by
495
+ * the frames it has had. */
486
496
  snapshot(topic: string): readonly TopicValue[] {
497
+ const now = Date.now();
487
498
  const own = this.#own();
488
- const data = topic === "agents" ? this.agents(own) : this.peers(Date.now(), own);
499
+ const data =
500
+ topic === "agents"
501
+ ? { agents: this.#sentAgents.stated(this.agentRows(own)), polled_at: now }
502
+ : { peers: this.#sentPeers.stated(this.peerRows(now, own)) };
489
503
  return [{ instance: this.deps.self, data }];
490
504
  }
491
505
 
@@ -495,9 +509,11 @@ export class Sessions implements UpstreamResource {
495
509
  return this.#harness.running;
496
510
  }
497
511
 
498
- /** The `peers` payload: what is live now, and what was live when this
499
- * instance last saw it. Both travel together because coming back is exactly
500
- * what moves a session from the second list to the first.
512
+ /** Every row `peers` states: what is live now, and what was live when this
513
+ * instance last saw it. One kind of row rather than two lists, because
514
+ * coming back and going quiet are the same row changing its `state` — a
515
+ * client that held two lists would have to move an entry between them to
516
+ * follow one field.
501
517
  *
502
518
  * Live is not the same as connected (§5.2). A session the harness names is
503
519
  * live whether or not it ever greeted us, and it has to be on this list for
@@ -510,41 +526,27 @@ export class Sessions implements UpstreamResource {
510
526
  * — this instance is one that classifies, so it says so on every row rather
511
527
  * than on the rows it happens to have an answer for.
512
528
  *
513
- * `instances` is the same view `hello` answers with, restated here so that a
514
- * link going down reaches a subscriber on the topic it is already on rather
515
- * than only on its next greeting (§7.5). It is this instance's view: what a
516
- * peer relayed here carries the peer's own, and neither is folded into the
517
- * other. An instance with no mesh states none, which is a different thing
518
- * from stating that nothing is reachable. */
519
- peers(
520
- now: Timestamp = Date.now(),
521
- own: Own = this.#own(),
522
- ): { peers: PeerInfo[]; last_live: LastLiveSession[]; instances?: InstanceInfo[] } {
523
- const instances = this.deps.mesh?.instances();
524
- return {
525
- peers: [
526
- ...[...this.#connected.values()].map((session) => this.#peer(session, now, own)),
527
- ...[...own.present]
528
- .filter((sid) => !this.#connected.has(sid))
529
- .map((sid) => this.#unconnected(sid, now, own)),
530
- ],
531
- last_live: this.#lastLive.entries(now).map((entry) => ({
529
+ * A lost session is a row here rather than a list of its own: its entry in
530
+ * the store holds what was observed, and the two fields a row derives
531
+ * where it stands now, and whether it is pinned are worked out at read
532
+ * time (M4). */
533
+ peerRows(now: Timestamp = Date.now(), own: Own = this.#own()): PeerInfo[] {
534
+ return [
535
+ ...[...this.#connected.values()].map((session) => this.#peer(session, now, own)),
536
+ ...[...own.present]
537
+ .filter((sid) => !this.#connected.has(sid))
538
+ .map((sid) => this.#unconnected(sid, now, own)),
539
+ ...this.#lastLive.entries(now).map((entry) => ({
532
540
  ...entry,
533
541
  state: this.classify(entry.sid, now, own) ?? "disappeared",
534
542
  pinned: this.#pinned(entry.sid),
535
543
  })),
536
- ...(instances === undefined ? {} : { instances }),
537
- };
544
+ ];
538
545
  }
539
546
 
540
- /** The `agents` payload: the harness's own view, as it stated it.
541
- *
542
- * `polled_at` is left out. Stating when the read behind the list ran would
543
- * make every confirmation poll a value the list did not have before, so the
544
- * one suppression every topic shares (M5) would let a five-second heartbeat
545
- * through for a directory that had not changed. */
546
- agents(own: Own = this.#own()): { agents: AgentInfo[] } {
547
- return { agents: [...own.rows.values()] };
547
+ /** Every row `agents` states: the harness's own view, as it stated it. */
548
+ agentRows(own: Own = this.#own()): AgentInfo[] {
549
+ return [...own.rows.values()];
548
550
  }
549
551
 
550
552
  /** Bind a session to this instance, and take what it says about itself. Its
@@ -596,11 +598,16 @@ export class Sessions implements UpstreamResource {
596
598
  this.changed();
597
599
  }
598
600
 
599
- /** Recompute, record what stopped being live, and state both topics.
601
+ /** Recompute, record what stopped being live, and state what moved on both
602
+ * topics.
600
603
  *
601
- * Publishing is unconditional here because suppression belongs to the topic
602
- * mechanism and is written once for every topic (M5) a payload equal to
603
- * the last one goes no further than that. */
604
+ * Both carry the rows that changed, so what is published is the difference
605
+ * against what was last published rather than the whole list: a session
606
+ * whose inference just ran is one row, and restating every row to say so
607
+ * would send a list to report one field. A recompute that found nothing
608
+ * different publishes nothing — the suppression every topic shares (M5) is
609
+ * whole-value and cannot drop a frame of elements, so the diff is where a
610
+ * repeat stops here. */
604
611
  private changed(now: Timestamp = Date.now()): void {
605
612
  const own = this.#own();
606
613
  const live = this.#liveNow(now, own);
@@ -622,8 +629,10 @@ export class Sessions implements UpstreamResource {
622
629
  }
623
630
  this.#reclaim(live);
624
631
  this.#live = live;
625
- this.deps.publish("peers", this.peers(now, own));
626
- this.deps.publish("agents", this.agents(own));
632
+ const peers = this.#sentPeers.diff(this.peerRows(now, own)) as PeerElement[];
633
+ if (peers.length > 0) this.deps.publish("peers", { peers });
634
+ const agents = this.#sentAgents.diff(this.agentRows(own)) as AgentElement[];
635
+ if (agents.length > 0) this.deps.publish("agents", { agents, polled_at: now });
627
636
  this.deps.onChanged?.();
628
637
  }
629
638
 
Binary file
@@ -1,3 +1,4 @@
1
+ export * from "./elements.ts";
1
2
  export * from "./egress.ts";
2
3
  export * from "./handlers.ts";
3
4
  export * from "./topics.ts";
@@ -1,11 +1,17 @@
1
1
  import { readdirSync, readFileSync, statSync } from "node:fs";
2
2
  import { basename, dirname, join } from "node:path";
3
- import type { Sid } from "@ccmsg/protocol";
3
+ import type { Sid, TranscriptSubject } from "@ccmsg/protocol";
4
4
  import { type Harness, HARNESS } from "../harness/index.ts";
5
5
  import { OpError } from "../dispatch/index.ts";
6
6
 
7
7
  const SUFFIX = ".jsonl";
8
8
 
9
+ /** What the harness names an agent's transcript with, and what it calls the
10
+ * kind of task a teammate is. Both are its own words, read where they are
11
+ * written rather than mirrored anywhere. */
12
+ const AGENT_PREFIX = "agent-";
13
+ const TEAMMATE_TASK = "in_process_teammate";
14
+
9
15
  /** Where one harness keeps transcripts under its config home, and how a file
10
16
  * there says which session it belongs to (§3.8).
11
17
  *
@@ -126,6 +132,37 @@ export class TranscriptFiles {
126
132
  return this.teammate(under, name(names.teammate ?? "", TEAMMATE, "teammate"));
127
133
  }
128
134
 
135
+ /** Which standing a transcript was written from, which every item read out
136
+ * of it states (§3.6).
137
+ *
138
+ * The file itself does not say whether an agent was a teammate or an errand:
139
+ * both are marked as sidechains and both are briefed the same way. What says
140
+ * so is the harness's own note beside the file — the same note a teammate is
141
+ * found by name in — and `taskKind` on it is the harness stating which kind
142
+ * of task it started. An envelope in the opening brief looks like the same
143
+ * answer and is not one: it is text somebody wrote, and an errand handed a
144
+ * quoted message carries it too.
145
+ *
146
+ * A note that is missing or unreadable leaves the question unanswered, and
147
+ * the answer then is `sub`: an errand is the standing that claims the least —
148
+ * nothing goes on standing, nobody is addressed by name — so a teammate read
149
+ * as one loses a name it might have been drawn under, where the reverse would
150
+ * have a reader write back to something that is already gone. */
151
+ subjectOf(file: string): TranscriptSubject {
152
+ const name = basename(file);
153
+ if (!name.startsWith(AGENT_PREFIX) || !name.endsWith(SUFFIX)) return "main";
154
+ let note: unknown;
155
+ try {
156
+ note = JSON.parse(
157
+ readFileSync(join(dirname(file), `${name.slice(0, -SUFFIX.length)}.meta.json`), "utf8"),
158
+ );
159
+ } catch {
160
+ return "sub";
161
+ }
162
+ const kind = (note as { taskKind?: unknown } | null)?.taskKind;
163
+ return kind === TEAMMATE_TASK ? "team" : "sub";
164
+ }
165
+
129
166
  /** A teammate's transcript, found by the name it is addressed by.
130
167
  *
131
168
  * The name a teammate carries in conversation is not its filename, so the
@@ -1,3 +1,4 @@
1
+ import type { TranscriptSubject } from "@ccmsg/protocol";
1
2
  import type { Item } from "./item.ts";
2
3
  import {
3
4
  count,
@@ -84,6 +85,7 @@ const OUTSTANDING_CALLS = 4096;
84
85
  type Draft = Record<string, unknown> & {
85
86
  id: string;
86
87
  uuid: string;
88
+ subject: TranscriptSubject;
87
89
  source: { offset: number; bytes: number };
88
90
  type: string;
89
91
  at: number;
@@ -96,8 +98,8 @@ type Draft = Record<string, unknown> & {
96
98
  * apart the harness wrote them. Reading the whole file before any range is
97
99
  * applied is what makes `parent_item` answerable: a result inside the range
98
100
  * whose call fell before it still names the call. */
99
- export function classify(records: Iterable<Located>): Item[] {
100
- const state = new Classification();
101
+ export function classify(records: Iterable<Located>, subject: TranscriptSubject = "main"): Item[] {
102
+ const state = new Classification(subject);
101
103
  return state.readAll(records);
102
104
  }
103
105
 
@@ -117,6 +119,24 @@ export class Classification {
117
119
  #turn = 0;
118
120
  /** The last slash command invoked, which is what its output belongs to. */
119
121
  #slash: string | undefined;
122
+ /** Whose file this is, which every item states and which decides who is at
123
+ * the other end of a plain line. A session's own transcript has a person
124
+ * there; a file written for an agent has whoever started it, and calling that
125
+ * `user` would have a reader take a machine for a person.
126
+ *
127
+ * It is told rather than read out of the records, because what tells a
128
+ * teammate from an errand is not in the transcript at all: the harness states
129
+ * it beside the file, and whoever opened the file has already read that (§3.6).
130
+ *
131
+ * A record marked as a sidechain inside a file opened as a session's own says
132
+ * the file is an agent's after all, and the reading moves to `sub` — the
133
+ * standing that claims the least. It only ever narrows: a reading told which
134
+ * agent's file it has is not talked out of it by the records. */
135
+ #subject: TranscriptSubject;
136
+
137
+ constructor(subject: TranscriptSubject = "main") {
138
+ this.#subject = subject;
139
+ }
120
140
 
121
141
  /** The records of one chunk as the items they were read as, oldest first.
122
142
  *
@@ -147,6 +167,7 @@ export class Classification {
147
167
  read(record: Row, source: { offset: number; bytes: number }): void {
148
168
  const type = str(record["type"]);
149
169
  if (type === undefined || NOT_ITEMS.has(type)) return;
170
+ if (record["isSidechain"] === true && this.#subject === "main") this.#subject = "sub";
150
171
  // A record the harness wrote without an id of its own still happened, and
151
172
  // an item is pointed at by the record it came from — so where the record
152
173
  // stands in the file stands in for the id it lacks. The `@` says which of
@@ -162,6 +183,7 @@ export class Classification {
162
183
  const draft: Draft = {
163
184
  id: `${uuid}:${String(index)}`,
164
185
  uuid,
186
+ subject: this.#subject,
165
187
  source,
166
188
  type: kind,
167
189
  at,
@@ -239,7 +261,12 @@ export class Classification {
239
261
  }
240
262
  if (kind === "text") {
241
263
  const said = str(fields["text"])?.trim();
242
- if (said !== undefined && said !== "") make("message:user:out", { text: said });
264
+ // An agent's words are addressed to whoever started it the last of
265
+ // them is the answer it was started for, and the ones before are what
266
+ // it hands back mid-flight. No call carries them, which is why
267
+ // `parent:out` is prose as well as a call.
268
+ const kind = this.#subject === "main" ? "message:user:out" : "message:parent:out";
269
+ if (said !== undefined && said !== "") make(kind, { text: said });
243
270
  continue;
244
271
  }
245
272
  if (kind === "tool_use") this.#call(fields, make);
@@ -267,29 +294,51 @@ export class Classification {
267
294
  });
268
295
  let message: Draft | undefined;
269
296
  if (SPAWNS.has(name)) {
270
- message = make("message:sub:out", {
271
- role: "use",
272
- tool_use_id: id,
273
- prompt: str(input["prompt"]) ?? "",
274
- ...optional("subagent_type", str(input["subagent_type"])),
275
- ...optional("name", str(input["name"])),
276
- ...optional("description", str(input["description"])),
277
- });
297
+ // A name is what makes an agent a teammate: it stands under that name,
298
+ // can be written to again, and answers whenever it writes back. An agent
299
+ // started without one is an errand — it runs once, answers the call that
300
+ // started it, and is done.
301
+ const named = str(input["name"]) ?? str(input["team_name"]);
302
+ message =
303
+ named === undefined
304
+ ? make("message:sub:out", {
305
+ role: "use",
306
+ tool_use_id: id,
307
+ prompt: str(input["prompt"]) ?? "",
308
+ ...optional("subagent_type", str(input["subagent_type"])),
309
+ ...optional("description", str(input["description"])),
310
+ })
311
+ : make("message:team:out", {
312
+ role: "use",
313
+ tool_use_id: id,
314
+ text: str(input["prompt"]) ?? "",
315
+ harness_name: named,
316
+ ...optional("subagent_type", str(input["subagent_type"])),
317
+ ...optional("description", str(input["description"])),
318
+ });
278
319
  } else if (name === "SendMessage") {
279
320
  const to = str(input["to"]) ?? "";
280
- // A sid is the harness's own uuid; anything else is a name, and a name
281
- // is how an agent below this session is addressed.
282
- make(addressed(to) ? "message:session:out" : "message:sub:out", {
283
- ...(addressed(to) ? {} : { role: "use", tool_use_id: id }),
284
- ...(addressed(to)
285
- ? { text: text(input["message"]) ?? "", to }
286
- : // Writing to an agent is one direction of a correspondence, not a
287
- // call that returns: what the agent says back arrives as its own
288
- // message whenever it chooses to send one, under nothing that
289
- // names this. So the brief says it is waiting for nothing, and a
290
- // reader is not left watching for an answer that has no way in.
291
- { prompt: text(input["message"]) ?? "", name: to, one_way: true }),
292
- });
321
+ // A sid is the harness's own uuid, so a message addressed by one goes to
322
+ // another session. Everything else is a name: the one above answers to
323
+ // the names a harness gives a lead, and any other name is somebody
324
+ // standing alongside.
325
+ if (addressed(to)) {
326
+ make("message:session:out", { text: text(input["message"]) ?? "", to });
327
+ } else {
328
+ // Writing to an agent is one direction of a correspondence, not a call
329
+ // that returns: what it says back arrives as its own message whenever
330
+ // it chooses to send one, under nothing that names this. So the
331
+ // message says it is waiting for nothing, and a reader is not left
332
+ // watching for an answer that has no way in.
333
+ make(LEADS.has(to) ? "message:parent:out" : "message:team:out", {
334
+ role: "use",
335
+ tool_use_id: id,
336
+ text: text(input["message"]) ?? "",
337
+ harness_name: to,
338
+ ...optional("summary", str(input["summary"])),
339
+ one_way: true,
340
+ });
341
+ }
293
342
  } else if (name === "Bash" && isCcmsgSend(str(input["command"]))) {
294
343
  make("message:session:out", { text: str(input["command"]) ?? "" });
295
344
  }
@@ -375,7 +424,7 @@ export class Classification {
375
424
  // decides is whether an answer came back, not which tool was called.
376
425
  const said = answered(result["content"]);
377
426
  if (said === undefined) return;
378
- const reply = make("message:sub:in", {
427
+ const reply = make(answers(call.message), {
379
428
  role: "result",
380
429
  parent_item: call.message.id,
381
430
  parent_tool_use_id: id,
@@ -434,17 +483,32 @@ export class Classification {
434
483
  // stands, being told what to do is not the same as being written to.
435
484
  if (record["parentUuid"] === null) {
436
485
  this.#turn += 1;
437
- make("message:user:in", { text: said });
486
+ make(this.#subject === "main" ? "message:user:in" : "message:parent:in", {
487
+ text: said,
488
+ ...(this.#subject === "main" ? {} : envelope(said)),
489
+ });
438
490
  return;
439
491
  }
440
- if (said.includes("<cross-session-message") || said.includes("<teammate-message")) {
492
+ if (said.includes("<cross-session-message")) {
441
493
  make("message:session:in", {
442
494
  text: said,
443
- ...optional("from", attribute(said, "from") ?? attribute(said, "teammate_id")),
495
+ ...optional("from", attribute(said, "from")),
444
496
  ...optional("msg_id", attribute(said, "mid")),
445
497
  });
446
498
  return;
447
499
  }
500
+ if (said.includes("<teammate-message")) {
501
+ // Who wrote decides which it is. A lead is the one above, whatever the
502
+ // subject's own place in the team; anyone else writing under their own
503
+ // name is somebody standing alongside, and what they send is a message
504
+ // of its own rather than the answer to anything.
505
+ const from = attribute(said, "teammate_id");
506
+ make(LEADS.has(from ?? "") ? "message:parent:in" : "message:team:in", {
507
+ text: said,
508
+ ...envelope(said),
509
+ });
510
+ return;
511
+ }
448
512
  if (record["isMeta"] === true) {
449
513
  make("system:unknown", { record });
450
514
  return;
@@ -471,7 +535,7 @@ export class Classification {
471
535
  const call = this.#calls.get(key);
472
536
  if (answer !== undefined && call !== undefined) {
473
537
  const asked = call.message ?? call.tool;
474
- const item = make("message:sub:in", {
538
+ const item = make(answers(asked), {
475
539
  role: "result",
476
540
  parent_item: asked.id,
477
541
  parent_tool_use_id: key,
@@ -503,6 +567,30 @@ function addressed(to: string): boolean {
503
567
  return SID.test(to);
504
568
  }
505
569
 
570
+ /** The names a harness gives the one above. They are the harness's own words
571
+ * rather than a relation anyone chose, which is why they are matched here and
572
+ * not carried into the type: what the type says is that this is the parent,
573
+ * and the spelling stays on the item as the name it was addressed by. */
574
+ const LEADS = new Set(["main", "team-lead"]);
575
+
576
+ /** Who a message came from and under which id, as the envelope the harness
577
+ * wraps one in says it. The name is the harness's own spelling and not a sid,
578
+ * which is why it travels as `harness_name`: what the type already said is
579
+ * which party this was, and the name is only what that party was called. */
580
+ function envelope(said: string): Record<string, unknown> {
581
+ return {
582
+ ...optional("harness_name", attribute(said, "teammate_id")),
583
+ ...optional("msg_id", attribute(said, "mid")),
584
+ };
585
+ }
586
+
587
+ /** The type an agent's answer arrives under, which is the other half of
588
+ * whatever asked for it: a teammate's run ending answers the call that started
589
+ * it, an errand's answer is the errand's result. */
590
+ function answers(asked: Draft): string {
591
+ return asked.type === "message:team:out" ? "message:team:in" : "message:sub:in";
592
+ }
593
+
506
594
  /** Whether a shell command is this session speaking to another one. */
507
595
  function isCcmsgSend(command: string | undefined): boolean {
508
596
  if (command === undefined) return false;
@@ -83,7 +83,7 @@ function heading(file: SessionDumpFile, view: DumpView): string[] {
83
83
  function draw(item: Item, child: Item | undefined, view: DumpView, parent?: string): string[] {
84
84
  const own = fragment(item);
85
85
  const answer = child === undefined ? undefined : fragment(child);
86
- const nested = child !== undefined && child.type.startsWith("message:sub");
86
+ const nested = child !== undefined && spoken(child.type);
87
87
  const link = isResult(item)
88
88
  ? arrow("←", parent)
89
89
  : (arrow("→", fields(item)["result_item"]) ?? waiting(item));
@@ -240,7 +240,7 @@ function pair(items: readonly Item[]): {
240
240
  // A pair the reader would have to scroll between is left where each half
241
241
  // happened, unless it is an agent's: what an agent was asked and what it
242
242
  // answered are one exchange whatever fell between them.
243
- if (!item.type.startsWith("message:sub") && call !== at - 1) continue;
243
+ if (!spoken(item.type) && call !== at - 1) continue;
244
244
  child.set(call, at);
245
245
  folded.add(at);
246
246
  }
@@ -248,10 +248,19 @@ function pair(items: readonly Item[]): {
248
248
  }
249
249
 
250
250
  /** The key an exchange is joined on. One call the harness gave a key to is two
251
- * items where it started an agent — the call and the brief beside it — so the
252
- * side of the exchange goes into the key: a tool's answer belongs to the call
253
- * and an agent's to the brief, and the harness's key alone would not say
254
- * which. */
251
+ * items where it also addressed somebody — the call and the message beside it
252
+ * — so the side of the exchange goes into the key: a tool's answer belongs to
253
+ * the call and an agent's to the message, and the harness's key alone would
254
+ * not say which. */
255
255
  function joined(item: Item, key: string): string {
256
- return `${item.type.startsWith("message:sub") ? "sub" : "tool"}\n${key}`;
256
+ return `${item.type.startsWith("message:") ? "message" : "tool"}\n${key}`;
257
+ }
258
+
259
+ /** Whether an item is the conversation half of starting an agent, as opposed
260
+ * to the call's own half. What was asked and what came back is one exchange
261
+ * however many turns fell between them, so these are drawn together wherever
262
+ * they ended up — a conversation split across the page is one nobody can
263
+ * follow. */
264
+ function spoken(type: string): boolean {
265
+ return type.startsWith("message:sub") || type.startsWith("message:team");
257
266
  }
Binary file
@@ -88,6 +88,44 @@ const ITEMS: Record<string, Draw> = {
88
88
  body: lines(str(item, "text")),
89
89
  }),
90
90
 
91
+ // The one above and the ones alongside. A name is on the heading wherever
92
+ // the record gave one — an answer handed back as prose names nobody, and a
93
+ // heading that invented a name for it would say more than the file does.
94
+ "message:parent:in": (item) => ({
95
+ head: words(field(item, "harness_name", "from="), mid(item)),
96
+ body: lines(str(item, "text")),
97
+ }),
98
+
99
+ "message:parent:out": (item) => ({
100
+ head: words(field(item, "harness_name", "to="), str(item, "summary")),
101
+ body: lines(str(item, "text")),
102
+ }),
103
+
104
+ "message:team:out": (item) => ({
105
+ head: words(
106
+ field(item, "harness_name", "to="),
107
+ field(item, "agent_id", "agent="),
108
+ field(item, "subagent_type", "type="),
109
+ str(item, "description"),
110
+ str(item, "summary"),
111
+ ),
112
+ body: lines(str(item, "text")),
113
+ }),
114
+
115
+ // Both halves of a teammate's correspondence arrive under one type: a letter
116
+ // it wrote, which names who wrote it, and its run ending, which names how it
117
+ // ended. Each heading says whichever of those the item carried.
118
+ "message:team:in": (item) => ({
119
+ head: words(
120
+ field(item, "harness_name", "from="),
121
+ mid(item),
122
+ field(item, "agent_id", "agent="),
123
+ field(item, "status", "status="),
124
+ elapsed(num(item, "duration_ms")),
125
+ ),
126
+ body: lines(str(item, "text")),
127
+ }),
128
+
91
129
  "message:session:out": (item) => ({
92
130
  head: words(field(item, "to", "to="), field(item, "reply_to", "reply_to="), mid(item)),
93
131
  body: lines(str(item, "text")),
@@ -18,6 +18,14 @@ export interface LlmRequestsDeps {
18
18
  readonly onActivity?: () => void;
19
19
  }
20
20
 
21
+ /** Which of a session's gateway facts moved.
22
+ *
23
+ * `live` is the only one the sessions domain has to hear: it is the moment the
24
+ * classification of §5.1 can change, because the window either opened or
25
+ * closed. `clock` is the same session seen again inside a window that was
26
+ * already open — the value of an attribute, not a section anything is in. */
27
+ type GatewayMove = "live" | "clock" | "none";
28
+
21
29
  /** Series remembered at once. The prune below already holds this near the
22
30
  * number active in the last cache window; the cap is what bounds a gateway
23
31
  * whose clock runs ahead, whose events would otherwise never expire. */
@@ -69,7 +77,7 @@ export class LlmRequests implements UpstreamResource {
69
77
  * near-ordered in practice, but a redelivery can put an older one after a
70
78
  * newer, and a countdown must not walk backwards. */
71
79
  record(info: LlmRequestObservation): void {
72
- this.active(info.sid, info.received_at);
80
+ if (this.active(info.sid, info.received_at) === "live") this.deps.onActivity?.();
73
81
  const key = seriesKey(info.sid, info.prefix);
74
82
  const held = this.#series.get(key);
75
83
  if (held !== undefined && held.info.received_at >= info.received_at) return;
@@ -91,7 +99,7 @@ export class LlmRequests implements UpstreamResource {
91
99
  * the window belongs to the request that opened it — and only says the
92
100
  * session was still running inference at that instant. */
93
101
  note(sid: Sid, at: Timestamp): void {
94
- if (this.active(sid, at)) this.deps.onActivity?.();
102
+ if (this.active(sid, at) === "live") this.deps.onActivity?.();
95
103
  }
96
104
 
97
105
  /** When the gateway last saw inference for a session (§5.1). Undefined once
@@ -136,21 +144,32 @@ export class LlmRequests implements UpstreamResource {
136
144
 
137
145
  private publish(): void {
138
146
  this.deps.publish("llm_requests", this.entries());
139
- this.deps.onActivity?.();
140
147
  }
141
148
 
142
- /** Note the session was seen, and say whether that moved it forward. */
143
- private active(sid: Sid, at: Timestamp): boolean {
149
+ /** Note the session was seen, and say what that moved.
150
+ *
151
+ * A session already inside its window moves its clock and nothing else. The
152
+ * sessions domain is told about `live` alone, because telling it about every
153
+ * event would restate the whole of `peers` once per call a session makes:
154
+ * inference is observed several times a second and the row it lands on
155
+ * differs only in an attribute (§5.2). */
156
+ private active(sid: Sid, at: Timestamp): GatewayMove {
144
157
  const held = this.#activeAt.get(sid);
145
- if (held !== undefined && held >= at) return false;
158
+ if (held !== undefined && held >= at) return "none";
159
+ const wasLive = held !== undefined && at - held <= GATEWAY_LIVE_WINDOW_MS;
146
160
  this.#activeAt.set(sid, at);
147
161
  // Sessions the gateway has not seen for longer than the window go: what is
148
162
  // left is what any of this can still say something about.
149
- const floor = at - GATEWAY_LIVE_WINDOW_MS;
163
+ this.prune(at);
164
+ return wasLive ? "clock" : "live";
165
+ }
166
+
167
+ /** Drop the sessions whose window has closed. */
168
+ private prune(now: Timestamp): void {
169
+ const floor = now - GATEWAY_LIVE_WINDOW_MS;
150
170
  for (const [seen, when] of this.#activeAt) {
151
171
  if (when < floor) this.#activeAt.delete(seen);
152
172
  }
153
- return true;
154
173
  }
155
174
 
156
175
  private notePrefix(info: LlmRequestObservation): void {