@ccmsg/cli 0.8.1 → 0.9.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.
Files changed (46) hide show
  1. package/package.json +2 -2
  2. package/src/auth/auth.ts +14 -14
  3. package/src/auth/http.ts +6 -6
  4. package/src/auth/records.ts +1 -1
  5. package/src/auth/topic.ts +1 -1
  6. package/src/cli.ts +21 -24
  7. package/src/daemon/control.ts +2 -2
  8. package/src/daemon/registry.ts +7 -7
  9. package/src/daemon/snapshot.ts +12 -10
  10. package/src/daemon/supervise.ts +1 -1
  11. package/src/files/containment.ts +5 -5
  12. package/src/files/files.ts +12 -12
  13. package/src/files/sandbox.ts +0 -0
  14. package/src/greeting/meta.ts +5 -2
  15. package/src/instance/config.ts +21 -9
  16. package/src/instance/instance.ts +15 -13
  17. package/src/kv/store.ts +4 -3
  18. package/src/launcher/launcher.ts +3 -3
  19. package/src/mesh/mesh.ts +7 -8
  20. package/src/mesh/relay.ts +1 -1
  21. package/src/messaging/delivery.ts +4 -4
  22. package/src/messaging/direct.ts +2 -2
  23. package/src/messaging/handlers.ts +4 -4
  24. package/src/messaging/inbox.ts +1 -1
  25. package/src/messaging/notify.ts +11 -11
  26. package/src/sessions/dump.ts +1 -1
  27. package/src/sessions/handlers.ts +17 -17
  28. package/src/sessions/harness.ts +1 -1
  29. package/src/sessions/last-live.ts +1 -1
  30. package/src/sessions/registry.ts +60 -77
  31. package/src/sessions/search.ts +1 -1
  32. package/src/sessions/status.ts +7 -7
  33. package/src/topics/handlers.ts +2 -2
  34. package/src/topics/topics.ts +0 -0
  35. package/src/transcript/items/classify.ts +50 -39
  36. package/src/transcript/items/document.ts +2 -2
  37. package/src/transcript/items/ids.ts +0 -0
  38. package/src/transcript/items/render.ts +24 -24
  39. package/src/transcript/items/select.ts +5 -5
  40. package/src/transcript/transcripts.ts +3 -3
  41. package/src/translate/translate.ts +1 -1
  42. package/src/transport/driver.ts +13 -11
  43. package/src/upstream/gateway.ts +3 -3
  44. package/src/upstream/requests.ts +1 -1
  45. package/src/upstream/status.ts +1 -1
  46. package/src/version.ts +1 -1
@@ -3,8 +3,10 @@ import { basename, dirname, isAbsolute, join } from "node:path";
3
3
  import {
4
4
  type AgentInfo,
5
5
  type Capability,
6
- type HelloArgs,
6
+ type HelloInstanceArgs,
7
7
  type HelloResult,
8
+ type HelloSessionArgs,
9
+ type HelloUserArgs,
8
10
  type Endpoint,
9
11
  type AgentElement,
10
12
  type InstanceId,
@@ -115,7 +117,7 @@ export interface MeshSource {
115
117
  }
116
118
 
117
119
  /** The mesh claim a peer greets with, as the contract states it. */
118
- type MeshClaim = NonNullable<HelloArgs["mesh"]>;
120
+ type MeshClaim = HelloInstanceArgs["mesh"];
119
121
 
120
122
  /** The fold, as the sessions domain reads it: two values about one session,
121
123
  * asked for when a payload is built rather than copied here when they change
@@ -137,7 +139,7 @@ export interface GatewaySource {
137
139
  * are the same fields under the same names — nothing is renamed on the way
138
140
  * through, and nothing is invented for a field the session left unsaid. */
139
141
  type SessionMeta = Pick<
140
- HelloArgs,
142
+ HelloSessionArgs,
141
143
  "repo" | "ws" | "cwd" | "transcript_path" | "repo_root" | "branch" | "title" | "model" | "effort"
142
144
  >;
143
145
 
@@ -208,7 +210,7 @@ export class Sessions implements UpstreamResource {
208
210
  * Held here rather than written to `last_live`, because the declaration
209
211
  * arrives while the session is still connected and `last_live` holds what is
210
212
  * gone: the entry is written when the connection closes, and this is what
211
- * stamps it then (contract, `session_stopping`). A session that declares and
213
+ * stamps it then (contract, `session.stopping`). A session that declares and
212
214
  * then carries on stays connected and keeps its declaration, which is spent
213
215
  * whenever it does leave. */
214
216
  readonly #stopping = new Map<Sid, Timestamp>();
@@ -252,55 +254,62 @@ export class Sessions implements UpstreamResource {
252
254
  for (const sid of live.keys()) this.#lastLive.remove(sid);
253
255
  }
254
256
 
255
- /** `hello`, which is where a session becomes something this instance can
256
- * speak about, and where everything this instance knows about where that
257
- * session lives comes from.
257
+ /** `hello.session`, which is where a session becomes something this instance
258
+ * can speak about, and where everything this instance knows about where that
259
+ * session lives comes from. The greeting names its sid because the op it
260
+ * arrived under is the one whose schema asks for one. */
261
+ helloSession = (input: HandlerInput): HelloResult => {
262
+ const args = input.args as unknown as HelloSessionArgs;
263
+ this.#greetable(input, args.protocol_version);
264
+ this.register(args.sid, args);
265
+ input.conn.onClose(() => this.release(args.sid));
266
+ return this.#greeted(input);
267
+ };
268
+
269
+ /** `hello.user`. A person speaks for no session, so there is nothing to
270
+ * register: the greeting settles a role and answers what the instance is. */
271
+ helloUser = (input: HandlerInput): HelloResult => {
272
+ const args = input.args as unknown as HelloUserArgs;
273
+ this.#greetable(input, args.protocol_version);
274
+ return this.#greeted(input);
275
+ };
276
+
277
+ /** `hello.instance`. A peer's greeting is answered only once the connection
278
+ * has been proven to be the endpoint it names. The verification rejects when
279
+ * it is not, and the connection stays anonymous because nothing settles an
280
+ * identity but a reply (mesh-peer-auth §5, daemon-v2 §3.2 step 7). This is
281
+ * the one greeting that has to wait for something, which is why it is the one
282
+ * that answers with a promise. */
283
+ helloInstance = (input: HandlerInput): Promise<HelloResult> => {
284
+ const args = input.args as unknown as HelloInstanceArgs;
285
+ this.#greetable(input, args.protocol_version);
286
+ const mesh = this.deps.mesh;
287
+ if (mesh === undefined) {
288
+ throw new OpError(
289
+ "capability_unavailable",
290
+ "this instance has no mesh, so no peer connection can be proven",
291
+ );
292
+ }
293
+ return mesh.greet(input.conn, args.mesh).then(() => this.#greeted(input));
294
+ };
295
+
296
+ /** What each of the three greetings checks before it settles anything.
258
297
  *
259
- * What registers a session is the greeting naming a sid, not the role it
260
- * claims: the sid is the session it speaks for, and reading the role here
261
- * would put the contract's "a session names its sid" rule in a second place
262
- * (M1). */
263
- hello = (input: HandlerInput): HelloResult | Promise<HelloResult> => {
264
- const args = input.args as unknown as HelloArgs;
265
- // A role is set once and fixed for the connection's life (contract, `Role`),
266
- // so a second greeting is not a re-identification: it is a request to be
267
- // somebody else on a connection that already is somebody.
298
+ * A role is set once and fixed for the connection's life (contract, `Role`),
299
+ * so a second greeting is not a re-identification: it is a request to be
300
+ * somebody else on a connection that already is somebody. */
301
+ #greetable(input: HandlerInput, protocolVersion: number): void {
268
302
  if (input.conn.identity.state === "settled") {
269
303
  throw new OpError("bad_request", "a connection greets once, and this one already has");
270
304
  }
271
- if (args.protocol_version !== PROTOCOL_VERSION) {
305
+ if (protocolVersion !== PROTOCOL_VERSION) {
272
306
  throw new OpError("bad_request", `this instance speaks protocol ${PROTOCOL_VERSION}`);
273
307
  }
274
- if (args.role === "instance") {
275
- // A peer's greeting is answered only once the connection has been proven
276
- // to be the endpoint it names. The verification rejects when it is not,
277
- // and the connection stays anonymous because nothing settles an identity
278
- // but a reply (mesh-peer-auth §5, daemon-v2 §3.2 step 7). This is the one
279
- // greeting that has to wait for something, which is why it is the one
280
- // that answers with a promise.
281
- if (args.mesh === undefined) {
282
- throw new OpError("invalid_args", "an instance greets with its mesh claim");
283
- }
284
- const mesh = this.deps.mesh;
285
- if (mesh === undefined) {
286
- throw new OpError(
287
- "capability_unavailable",
288
- "this instance has no mesh, so no peer connection can be proven",
289
- );
290
- }
291
- return mesh.greet(input.conn, args.mesh).then(() => this.#greeted(args, input));
292
- }
293
- return this.#greeted(args, input);
294
- };
308
+ }
295
309
 
296
310
  /** What every greeting answers, once whatever had to be settled has been. */
297
- #greeted(args: HelloArgs, input: HandlerInput): HelloResult {
311
+ #greeted(input: HandlerInput): HelloResult {
298
312
  const expiresAt = this.deps.authExpiresAt?.(input.conn);
299
- const sid = requiredSid(args);
300
- if (sid !== undefined) {
301
- this.register(sid, args);
302
- input.conn.onClose(() => this.release(sid));
303
- }
304
313
  return {
305
314
  protocol_version: PROTOCOL_VERSION,
306
315
  instance: this.deps.self,
@@ -439,7 +448,7 @@ export class Sessions implements UpstreamResource {
439
448
  }
440
449
 
441
450
  /** Drop one entry from `last_live`, which is what
442
- * `session_last_live_remove` asks for. The removal touches that list alone:
451
+ * `session.forget` asks for. The removal touches that list alone:
443
452
  * the session stays resumable by every other route. */
444
453
  forget(sid: Sid): boolean {
445
454
  const removed = this.#lastLive.remove(sid);
@@ -453,7 +462,7 @@ export class Sessions implements UpstreamResource {
453
462
  this.changed();
454
463
  }
455
464
 
456
- /** `session_stopping`: a session saying it is about to go, which is what
465
+ /** `session.stopping`: a session saying it is about to go, which is what
457
466
  * makes it Paused rather than Disappeared once it is gone (§5.2).
458
467
  *
459
468
  * Nothing is recorded now and nothing is published: the session is still
@@ -600,7 +609,7 @@ export class Sessions implements UpstreamResource {
600
609
  * silence for a retraction would let each of them erase what the last one
601
610
  * knew, and the session would be described by whichever process spoke most
602
611
  * recently rather than by everything it has said. */
603
- private register(sid: Sid, args: HelloArgs): void {
612
+ private register(sid: Sid, args: HelloSessionArgs): void {
604
613
  const now = Date.now();
605
614
  const held = this.#connected.get(sid);
606
615
  const meta = {
@@ -652,7 +661,7 @@ export class Sessions implements UpstreamResource {
652
661
  for (const [sid, entry] of this.#live) {
653
662
  if (live.has(sid)) continue;
654
663
  // The declaration came first and the departure has now arrived, which is
655
- // the order the two are one event in (contract, `session_stopping`).
664
+ // the order the two are one event in (contract, `session.stopping`).
656
665
  const stoppedAt = this.#stopping.get(sid);
657
666
  this.#stopping.delete(sid);
658
667
  this.#lastLive.record({
@@ -769,11 +778,11 @@ export class Sessions implements UpstreamResource {
769
778
  * id, so what it reports is not by itself evidence about *this* instance's
770
779
  * sessions: a session id belonging to another config home would otherwise
771
780
  * classify as live here, put a row on this instance's `peers`, and make
772
- * `message_send` accept a message for a session that has no inbox here and
781
+ * `message.send` accept a message for a session that has no inbox here and
773
782
  * never will. So the reading is narrowed to the sids this instance knows —
774
783
  * one that has greeted us, still connected or remembered in `last_live`, or
775
784
  * one the harness's own `sessions/` names. The events themselves are not
776
- * dropped: `llm_requests` carries what the gateway saw whoever it was for,
785
+ * dropped: `llm.requests` carries what the gateway saw whoever it was for,
777
786
  * because that topic is a view of the gateway rather than of this instance's
778
787
  * sessions. */
779
788
  #gatewayActiveAt(sid: Sid, inHarness: boolean): Timestamp | undefined {
@@ -834,7 +843,7 @@ export class Sessions implements UpstreamResource {
834
843
  * that is simply absent from what `peers` says. */
835
844
  function metaOf(
836
845
  deps: Pick<SessionsDeps, "configHome" | "harness">,
837
- args: HelloArgs,
846
+ args: HelloSessionArgs,
838
847
  refused: (reason: string) => void,
839
848
  ): SessionMeta {
840
849
  const meta: Record<string, string> = {};
@@ -924,29 +933,3 @@ function resolveAsFarAsItGoes(path: string): string | undefined {
924
933
  }
925
934
  }
926
935
  }
927
-
928
- /** What each role must and must not say when it greets.
929
- *
930
- * The sid is what registers a session, so which role is entitled to name one is
931
- * decided here rather than left to whoever reads the field: a `user` naming a
932
- * sid would be a person registering as the session, and a `session` without one
933
- * is a session this instance cannot speak about. */
934
- function requiredSid(args: HelloArgs): Sid | undefined {
935
- switch (args.role) {
936
- case "session":
937
- if (args.sid === undefined) throw new OpError("invalid_args", "a session names its sid");
938
- return args.sid;
939
- case "user":
940
- if (args.sid !== undefined) {
941
- throw new OpError("invalid_args", "a sid is the greeting of a session, not of a person");
942
- }
943
- return undefined;
944
- case "instance":
945
- // A peer speaks for no session: what it is has already been settled by
946
- // the handshake, and a sid here would be it registering as one.
947
- if (args.sid !== undefined) {
948
- throw new OpError("invalid_args", "a sid is the greeting of a session, not of an instance");
949
- }
950
- return undefined;
951
- }
952
- }
@@ -20,7 +20,7 @@ import { readRecord, type TranscriptFile, type TranscriptFiles } from "../transc
20
20
  const SCAN_BUDGET_BYTES = 64 * 1024 * 1024;
21
21
  const HITS = 50;
22
22
  /** What one hit shows of what it matched. Enough to recognise the passage;
23
- * the transcript itself is one `transcript_read` away. */
23
+ * the transcript itself is one `transcript.read` away. */
24
24
  const MATCHES_PER_HIT = 5;
25
25
  const MATCH_CHARS = 400;
26
26
 
@@ -14,14 +14,14 @@ import { workspaceFolders } from "./workspace.ts";
14
14
  /** What the fold says stopped a session, read in one place.
15
15
  *
16
16
  * Three values rest on it: whether a live session is Waiting (§5.2), what
17
- * `session_errors` lists, and the `api_error` of `session_status:<sid>`. They
17
+ * `session.errors` lists, and the `api_error` of `session.status:<sid>`. They
18
18
  * ask this rather than each reading the fold's field, so the three cannot come
19
19
  * to different answers about the same session (§7.4, M5). */
20
20
  export function stoppedOn(facts: TranscriptFacts): SessionApiError | undefined {
21
21
  return facts.api_error;
22
22
  }
23
23
 
24
- /** The `session_status:<sid>` payload.
24
+ /** The `session.status:<sid>` payload.
25
25
  *
26
26
  * Almost every field is the fold's, stated as the fold left it: one pass over
27
27
  * the transcript settles the error, the task list, the files it named and what
@@ -61,7 +61,7 @@ export function sessionStatusOf(
61
61
  }
62
62
 
63
63
  /** Where a session works, as it greeted (§5.1). The same two values the file
64
- * surfaces are decided against, asked for here so that what `session_status`
64
+ * surfaces are decided against, asked for here so that what `session.status`
65
65
  * says and what a read is admitted by come from one answer. */
66
66
  export interface SessionWhere {
67
67
  readonly root?: string;
@@ -88,7 +88,7 @@ export interface SessionStatusDeps {
88
88
  /** The two topics the fold's error state feeds, and the tails they keep
89
89
  * running (§6.3).
90
90
  *
91
- * `session_errors` is one list for the instance and `session_status:<sid>` is
91
+ * `session.errors` is one list for the instance and `session.status:<sid>` is
92
92
  * one session, so what they hold differs: the first wants every session's fold
93
93
  * and the second wants one. Both wants are the same mechanism — a subscription
94
94
  * arrives, the tails it needs are held, and the last subscription to go
@@ -170,7 +170,7 @@ export class SessionStatus implements UpstreamResource {
170
170
  /** What a topic of this owner currently says, for a snapshot and for a
171
171
  * change alike — built here and nowhere else, so the two cannot drift. */
172
172
  private value(topic: string): unknown {
173
- if (topic === "session_errors") return this.errors();
173
+ if (topic === "session.errors") return this.errors();
174
174
  const sid = topicParam(topic);
175
175
  return sid === undefined
176
176
  ? undefined
@@ -196,11 +196,11 @@ export class SessionStatus implements UpstreamResource {
196
196
  const wanted = new Set<Sid>();
197
197
  // One list for the instance means every session's fold; the list is what
198
198
  // the subscriber asked for, and it cannot be built from a subset of it.
199
- if (this.#wanted.has("session_errors")) {
199
+ if (this.#wanted.has("session.errors")) {
200
200
  for (const sid of this.deps.sessions()) wanted.add(sid);
201
201
  }
202
202
  for (const topic of this.#wanted) {
203
- if (topic === "session_errors") continue;
203
+ if (topic === "session.errors") continue;
204
204
  const sid = topicParam(topic);
205
205
  if (sid !== undefined) wanted.add(sid);
206
206
  }
@@ -7,12 +7,12 @@ import type { SubscribeOutcome, Topics } from "./topics.ts";
7
7
  * it to `Topics`, and turns the outcome into the contract's own answer. */
8
8
  export function topicHandlers(topics: Topics) {
9
9
  return {
10
- topic_subscribe: (input: HandlerInput): TopicSubscribeResult => {
10
+ "topic.subscribe": (input: HandlerInput): TopicSubscribeResult => {
11
11
  const topic = topicOf(input);
12
12
  answer(topics.subscribe(input.conn, topic), topic);
13
13
  return { topic };
14
14
  },
15
- topic_unsubscribe: (input: HandlerInput): TopicSubscribeResult => {
15
+ "topic.unsubscribe": (input: HandlerInput): TopicSubscribeResult => {
16
16
  const topic = topicOf(input);
17
17
  answer(topics.unsubscribe(input.conn, topic), topic);
18
18
  return { topic };
Binary file
@@ -31,7 +31,7 @@ import { genericResult, resultFields, useFields } from "./tools.ts";
31
31
  *
32
32
  * Nothing is dropped for being unrecognised. A tool nobody wrote fields for
33
33
  * arrives with what it was called with, an attachment arrives under its own
34
- * kind, and a record that fits nothing arrives as `system:unknown`. The one
34
+ * kind, and a record that fits nothing arrives as `system.unknown`. The one
35
35
  * failure a dump cannot be read around is a line that vanished quietly. */
36
36
 
37
37
  /** Record types that are the interface and the session's own bookkeeping
@@ -112,8 +112,8 @@ export function classify(records: Iterable<Located>, subject: TranscriptSubject
112
112
  export class Classification {
113
113
  #items: Draft[] = [];
114
114
  /** The call each tool result belongs to, by the id the harness pairs them
115
- * with. Holds the `tool:*` item and, for an `Agent` call, the
116
- * `message:sub:out` beside it — the same exchange seen from the two sides
115
+ * with. Holds the `tool.*` item and, for an `Agent` call, the
116
+ * `message.sub.out` beside it — the same exchange seen from the two sides
117
117
  * the contract names it from. */
118
118
  readonly #calls = new Map<string, { tool: Draft; message?: Draft; name: string }>();
119
119
  #turn = 0;
@@ -198,7 +198,7 @@ export class Classification {
198
198
  if (type === "system") return this.#system(record, make);
199
199
  if (type === "assistant") return this.#assistant(record, make);
200
200
  if (type === "user") return this.#user(record, make);
201
- make("system:unknown", { record });
201
+ make("system.unknown", { record });
202
202
  }
203
203
 
204
204
  /** An attachment is either the operator's own code speaking or the harness
@@ -210,10 +210,10 @@ export class Classification {
210
210
  if (kind === "hook_additional_context" || kind === "hook_success") {
211
211
  const name = str(attachment["hookName"]) ?? "";
212
212
  // The event alone is the type. A hook runs under `PreToolUse:Bash`,
213
- // whose `:` would read as another level of the hierarchy and leave
214
- // `hook:PreToolUse` selecting nothing.
213
+ // whose matcher is no level of a hierarchy: carrying it would make
214
+ // `hook.PreToolUse` select nothing, so it is a field instead.
215
215
  const event = str(attachment["hookEvent"]) ?? name.split(":")[0] ?? "";
216
- make(`hook:${segment(event)}`, {
216
+ make(`hook.${segment(event)}`, {
217
217
  hook_name: name,
218
218
  outcome: kind === "hook_success" ? "output" : "additionalContext",
219
219
  ...optional("content", text(attachment["content"])),
@@ -225,7 +225,7 @@ export class Classification {
225
225
  });
226
226
  return;
227
227
  }
228
- make(`system:attachment:${segment(kind ?? "unknown")}`, { attachment });
228
+ make(`system.attachment.${segment(kind ?? "unknown")}`, { attachment });
229
229
  }
230
230
 
231
231
  /** The harness files a slash command's output as a line of its own, which
@@ -235,19 +235,19 @@ export class Classification {
235
235
  #system(record: Row, make: Make): void {
236
236
  if (str(record["subtype"]) === "local_command") {
237
237
  const content = str(record["content"]) ?? "";
238
- make("notice:slash", {
238
+ make("notice.slash", {
239
239
  command: this.#slash ?? "",
240
240
  ...optional("stdout", tagged(content, "local-command-stdout") ?? content),
241
241
  });
242
242
  return;
243
243
  }
244
- make("system:unknown", { record });
244
+ make("system.unknown", { record });
245
245
  }
246
246
 
247
247
  #assistant(record: Row, make: Make): void {
248
248
  const message = row(record["message"]) ?? {};
249
249
  if (record["isApiErrorMessage"] === true) {
250
- make("system:api-error", { text: text(message["content"]) ?? "" });
250
+ make("system.api.error", { text: text(message["content"]) ?? "" });
251
251
  return;
252
252
  }
253
253
  for (const block of list(message["content"])) {
@@ -265,7 +265,7 @@ export class Classification {
265
265
  // them is the answer it was started for, and the ones before are what
266
266
  // it hands back mid-flight. No call carries them, which is why
267
267
  // `parent:out` is prose as well as a call.
268
- const kind = this.#subject === "main" ? "message:user:out" : "message:parent:out";
268
+ const kind = this.#subject === "main" ? "message.user.out" : "message.parent.out";
269
269
  if (said !== undefined && said !== "") make(kind, { text: said });
270
270
  continue;
271
271
  }
@@ -286,7 +286,7 @@ export class Classification {
286
286
  const input = row(block["input"]) ?? {};
287
287
  const fields = useFields(name, input);
288
288
  const called = typedAs(name);
289
- const item = make(`tool:${segment(called)}`, {
289
+ const item = make(`tool.${segment(called)}`, {
290
290
  role: "use",
291
291
  tool_use_id: id,
292
292
  ...(called === name ? {} : { harness_name: name }),
@@ -301,14 +301,14 @@ export class Classification {
301
301
  const named = str(input["name"]) ?? str(input["team_name"]);
302
302
  message =
303
303
  named === undefined
304
- ? make("message:sub:out", {
304
+ ? make("message.sub.out", {
305
305
  role: "use",
306
306
  tool_use_id: id,
307
307
  prompt: str(input["prompt"]) ?? "",
308
308
  ...optional("subagent_type", str(input["subagent_type"])),
309
309
  ...optional("description", str(input["description"])),
310
310
  })
311
- : make("message:team:out", {
311
+ : make("message.team.out", {
312
312
  role: "use",
313
313
  tool_use_id: id,
314
314
  text: str(input["prompt"]) ?? "",
@@ -323,14 +323,14 @@ export class Classification {
323
323
  // the names a harness gives a lead, and any other name is somebody
324
324
  // standing alongside.
325
325
  if (addressed(to)) {
326
- make("message:session:out", { text: text(input["message"]) ?? "", to });
326
+ make("message.session.out", { text: text(input["message"]) ?? "", to });
327
327
  } else {
328
328
  // Writing to an agent is one direction of a correspondence, not a call
329
329
  // that returns: what it says back arrives as its own message whenever
330
330
  // it chooses to send one, under nothing that names this. So the
331
331
  // message says it is waiting for nothing, and a reader is not left
332
332
  // watching for an answer that has no way in.
333
- make(LEADS.has(to) ? "message:parent:out" : "message:team:out", {
333
+ make(LEADS.has(to) ? "message.parent.out" : "message.team.out", {
334
334
  role: "use",
335
335
  tool_use_id: id,
336
336
  text: text(input["message"]) ?? "",
@@ -340,7 +340,7 @@ export class Classification {
340
340
  });
341
341
  }
342
342
  } else if (name === "Bash" && isCcmsgSend(str(input["command"]))) {
343
- make("message:session:out", { text: str(input["command"]) ?? "" });
343
+ make("message.session.out", { text: str(input["command"]) ?? "" });
344
344
  }
345
345
  if (id === "") return;
346
346
  // A call is dropped from the pairing once it has been answered, so what is
@@ -388,7 +388,7 @@ export class Classification {
388
388
  // name guessed from what came back. What ties it to the call is the key
389
389
  // the harness paired them by, which a reader joins against the calls it
390
390
  // holds.
391
- make("tool:unknown", {
391
+ make("tool.unknown", {
392
392
  role: "result",
393
393
  parent_tool_use_id: id,
394
394
  result: genericResult(record["toolUseResult"]),
@@ -398,7 +398,7 @@ export class Classification {
398
398
  const failed = block["is_error"] === true;
399
399
  const answer = record["toolUseResult"];
400
400
  const fields = resultFields(call.name, answer, failed);
401
- const item = make(`tool:${segment(call.name)}`, {
401
+ const item = make(`tool.${segment(call.name)}`, {
402
402
  role: "result",
403
403
  parent_item: call.tool.id,
404
404
  parent_tool_use_id: id,
@@ -446,17 +446,17 @@ export class Classification {
446
446
  * so nothing the harness injected is mistaken for someone speaking. */
447
447
  #said(record: Row, said: string, make: Make): void {
448
448
  if (record["isCompactSummary"] === true) {
449
- make("system:compact", { text: said });
449
+ make("system.compact", { text: said });
450
450
  return;
451
451
  }
452
452
  if (said.startsWith("<local-command-caveat>")) {
453
- make("system:caveat", { text: said });
453
+ make("system.caveat", { text: said });
454
454
  return;
455
455
  }
456
456
  const command = tagged(said, "command-name");
457
457
  if (command !== undefined) {
458
458
  this.#slash = command;
459
- make("notice:slash", {
459
+ make("notice.slash", {
460
460
  command,
461
461
  ...optional("args", tagged(said, "command-args") ?? tagged(said, "command-message")),
462
462
  ...optional("stdout", tagged(said, "local-command-stdout")),
@@ -464,11 +464,11 @@ export class Classification {
464
464
  return;
465
465
  }
466
466
  if (said.startsWith("[Request interrupted")) {
467
- make("notice:interrupt", { text: said });
467
+ make("notice.interrupt", { text: said });
468
468
  return;
469
469
  }
470
470
  if (said.startsWith("Resume the paused workflow by calling: Workflow({")) {
471
- make("system:resume", { text: said });
471
+ make("system.resume", { text: said });
472
472
  return;
473
473
  }
474
474
  if (said.startsWith("<task-notification>")) {
@@ -483,14 +483,14 @@ export class Classification {
483
483
  // stands, being told what to do is not the same as being written to.
484
484
  if (record["parentUuid"] === null) {
485
485
  this.#turn += 1;
486
- make(this.#subject === "main" ? "message:user:in" : "message:parent:in", {
486
+ make(this.#subject === "main" ? "message.user.in" : "message.parent.in", {
487
487
  text: said,
488
488
  ...(this.#subject === "main" ? {} : envelope(said)),
489
489
  });
490
490
  return;
491
491
  }
492
492
  if (said.includes("<cross-session-message")) {
493
- make("message:session:in", {
493
+ make("message.session.in", {
494
494
  text: said,
495
495
  ...optional("from", attribute(said, "from")),
496
496
  ...optional("msg_id", attribute(said, "mid")),
@@ -503,20 +503,26 @@ export class Classification {
503
503
  // name is somebody standing alongside, and what they send is a message
504
504
  // of its own rather than the answer to anything.
505
505
  const from = attribute(said, "teammate_id");
506
- make(LEADS.has(from ?? "") ? "message:parent:in" : "message:team:in", {
506
+ make(LEADS.has(from ?? "") ? "message.parent.in" : "message.team.in", {
507
507
  text: said,
508
508
  ...envelope(said),
509
509
  });
510
510
  return;
511
511
  }
512
512
  if (record["isMeta"] === true) {
513
- make("system:unknown", { record });
513
+ make("system.unknown", { record });
514
514
  return;
515
515
  }
516
- // A turn begins where a person speaks, which is the only place a dump can
517
- // count turns from the harness numbers nothing.
516
+ // What is left is somebody writing to the subject in their own words, and
517
+ // who that can be follows from the standing. A session and a teammate both
518
+ // go on standing and a person can type straight at either, so there it is
519
+ // a person speaking; an errand is written to only by whoever started it,
520
+ // so there the bare words are the parent asking for more.
521
+ //
522
+ // A turn begins where the subject is addressed, which is the only place a
523
+ // dump can count turns from — the harness numbers nothing.
518
524
  this.#turn += 1;
519
- make("message:user:in", { text: said });
525
+ make(this.#subject === "sub" ? "message.parent.in" : "message.user.in", { text: said });
520
526
  }
521
527
 
522
528
  /** A background task reporting, or an agent handing back its answer.
@@ -548,7 +554,7 @@ export class Classification {
548
554
  this.#calls.delete(key);
549
555
  return;
550
556
  }
551
- make("system:task", {
557
+ make("system.task", {
552
558
  text: said,
553
559
  ...optional("task_id", tagged(said, "task-id")),
554
560
  ...optional("event", tagged(said, "event") ?? tagged(said, "summary")),
@@ -588,7 +594,7 @@ function envelope(said: string): Record<string, unknown> {
588
594
  * whatever asked for it: a teammate's run ending answers the call that started
589
595
  * it, an errand's answer is the errand's result. */
590
596
  function answers(asked: Draft): string {
591
- return asked.type === "message:team:out" ? "message:team:in" : "message:sub:in";
597
+ return asked.type === "message.team.out" ? "message.team.in" : "message.sub.in";
592
598
  }
593
599
 
594
600
  /** Whether a shell command is this session speaking to another one. */
@@ -597,12 +603,17 @@ function isCcmsgSend(command: string | undefined): boolean {
597
603
  return /\bccmsg\s+(post|reply)\b/.test(command);
598
604
  }
599
605
 
600
- /** One segment of a type name. The harness's own spellings pass through — they
601
- * are what a reader matches against what it ran — and a character the name
602
- * could not carry is replaced rather than the segment being refused, so a
603
- * newcomer still arrives under something close to its own name. */
606
+ /** The last segment of an open family's type name (`tool.<Name>`,
607
+ * `system.attachment.<kind>`, `hook.<Event>`). The harness's own spellings pass
608
+ * through they are what a reader matches against what it ran — and a
609
+ * character the name could not carry is replaced rather than the segment being
610
+ * refused, so a newcomer still arrives under something close to its own name.
611
+ *
612
+ * A `.` in the harness's name is written `_`: the reader splits a type on `.`
613
+ * to walk its hierarchy, and a name carrying one of its own would read as a
614
+ * level this contract never coined (contract, `TranscriptItemType`). */
604
615
  function segment(name: string): string {
605
- const cleaned = name.replace(/[^A-Za-z0-9_.-]/g, "-");
616
+ const cleaned = name.replace(/\./g, "_").replace(/[^A-Za-z0-9_-]/g, "-");
606
617
  return cleaned === "" ? "unknown" : cleaned;
607
618
  }
608
619
 
@@ -253,7 +253,7 @@ function pair(items: readonly Item[]): {
253
253
  * the call and an agent's to the message, and the harness's key alone would
254
254
  * not say which. */
255
255
  function joined(item: Item, key: string): string {
256
- return `${item.type.startsWith("message:") ? "message" : "tool"}\n${key}`;
256
+ return `${item.type.startsWith("message.") ? "message" : "tool"}\n${key}`;
257
257
  }
258
258
 
259
259
  /** Whether an item is the conversation half of starting an agent, as opposed
@@ -262,5 +262,5 @@ function joined(item: Item, key: string): string {
262
262
  * they ended up — a conversation split across the page is one nobody can
263
263
  * follow. */
264
264
  function spoken(type: string): boolean {
265
- return type.startsWith("message:sub") || type.startsWith("message:team");
265
+ return type.startsWith("message.sub") || type.startsWith("message.team");
266
266
  }
Binary file