@ccmsg/cli 0.11.5 → 0.13.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.11.5",
3
+ "version": "0.13.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.22.0"
23
+ "@ccmsg/protocol": "1.23.0"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/bun": "^1.3.0",
package/src/auth/auth.ts CHANGED
@@ -488,7 +488,7 @@ export class Auth {
488
488
  * Only its issuer can say, because only the issuer holds the secret that
489
489
  * signed it — and the six digits are held beside that secret. So the digits
490
490
  * travel there unjudged: an instance that decided them itself would let
491
- * somebody spread guesses across the cluster without any of them counting
491
+ * somebody spread guesses across the mesh without any of them counting
492
492
  * against the URL (contract, `AuthResolveArgs`). Nothing is spent here.
493
493
  *
494
494
  * The claims come back from the issuer having been checked and consumed, and
@@ -127,7 +127,7 @@ export class AuthRecords {
127
127
  }
128
128
  }
129
129
 
130
- /** Write one record of this instance's own, and tell the cluster.
130
+ /** Write one record of this instance's own, and tell the mesh.
131
131
  *
132
132
  * A local write always displaces what the key holds. Last-write-wins settles
133
133
  * a disagreement between instances; this is not one — the writer is the
package/src/cli.ts CHANGED
@@ -91,9 +91,9 @@ function ownSid(): string | undefined {
91
91
  * of its own to write to. */
92
92
  export type Read = () => Promise<string>;
93
93
 
94
- /** The system speech binary. Absolute on purpose: a `say` shim earlier on PATH
95
- * is what delegates here, so resolving through PATH again would re-enter the
96
- * shim. */
94
+ /** The system speech binary. Absolute on purpose: what is on `PATH` under this
95
+ * name is whatever the host has arranged, and a notification is meant to be
96
+ * heard rather than routed. */
97
97
  const SYSTEM_SAY = "/usr/bin/say";
98
98
 
99
99
  /** What every command may be given, and what every command reads from.
@@ -137,8 +137,8 @@ interface Command {
137
137
  * `service status` take nothing, and printing their help instead of their
138
138
  * answer would make them unreachable. */
139
139
  readonly bare?: boolean;
140
- /** A command that takes its arguments over rather than parsing them, which
141
- * is what `say` is: its arguments belong to another program. */
140
+ /** A command that takes its arguments over rather than parsing them: they
141
+ * belong to another program. */
142
142
  readonly raw?: (args: readonly string[]) => Promise<number>;
143
143
  }
144
144
 
@@ -434,7 +434,7 @@ const ROOT: Command = {
434
434
  {
435
435
  name: "dump",
436
436
  summary: "セッション (か配下の worker 1 体) の transcript を型ごとの表示で書き出す",
437
- usage: "ccmsg dump <sid>[/agent-<id>] [--preset <名前>] [--types <選択>]",
437
+ usage: "ccmsg dump <sid>[/agent-<id>] [--preset <名前>] [--types <選択>] [--format <形式>]",
438
438
  options: [
439
439
  ["--preset <名前>", "instance が持つ選択 (ccmsg dump presets で一覧)"],
440
440
  ["--types <選択>", "型をカンマ区切りで。prefix 可、-で除外、@名前で preset 展開"],
@@ -444,6 +444,10 @@ const ROOT: Command = {
444
444
  ],
445
445
  ["--until <at|ago|uuid>", "上限。同上"],
446
446
  ["--max-chars <n>", "1 アイテムの本文をこの文字数で切る (既定は切らない)"],
447
+ [
448
+ "--format <形式>",
449
+ "instance に書かせる形式: items (型付き JSON) / records (元の jsonl) / text (markdown)",
450
+ ],
447
451
  ["--json", "markdown ではなく dump file の中身をそのまま出す"],
448
452
  ["--out <path>", "標準出力ではなくこの path に書く"],
449
453
  ],
@@ -530,17 +534,6 @@ const ROOT: Command = {
530
534
  env: sessionEnv(),
531
535
  run: (args) => hello(args),
532
536
  },
533
- {
534
- name: "say",
535
- summary: `${SYSTEM_SAY} で発声し、どのセッションが喋ったかを知らせる`,
536
- usage: "ccmsg say [say-options] [text...]",
537
- options: [["", `引数は ${SYSTEM_SAY} へそのまま渡す (単独の --help だけが例外)`]],
538
- env: [
539
- [SESSION_ENV.join(" / "), "喋ったセッションの名乗り"],
540
- ["CCMSG_SAY_BIN", `発声に使うバイナリ (既定は ${SYSTEM_SAY})`],
541
- ],
542
- raw: (args) => say(args),
543
- },
544
537
  ],
545
538
  };
546
539
 
@@ -1192,7 +1185,7 @@ function agents(args: readonly string[]): Promise<unknown> {
1192
1185
  });
1193
1186
  }
1194
1187
 
1195
- /** Read the current value of a cluster topic and answer with it.
1188
+ /** Read the current value of a mesh topic and answer with it.
1196
1189
  *
1197
1190
  * One entry per instance, carrying the topic's payload exactly as the contract
1198
1191
  * defines it: a whole value per instance is not something to merge into one
@@ -1232,7 +1225,11 @@ async function topic(
1232
1225
  *
1233
1226
  * `--json` hands over the file as it stands, for a reader that is a program. */
1234
1227
  async function dump(args: readonly string[]): Promise<unknown> {
1235
- const parsed = options(args, ["preset", "types", "since", "until", "out", "max-chars"], ["json"]);
1228
+ const parsed = options(
1229
+ args,
1230
+ ["preset", "types", "since", "until", "out", "max-chars", "format"],
1231
+ ["json"],
1232
+ );
1236
1233
  const subject = parsed.rest[0];
1237
1234
  if (subject === undefined) {
1238
1235
  throw new CommandError(
@@ -1245,6 +1242,11 @@ async function dump(args: readonly string[]): Promise<unknown> {
1245
1242
  ...dumpArgs(subject, parsed.named),
1246
1243
  })) as unknown as SessionDumpWriteResult;
1247
1244
  const body = readFileSync(written.path, "utf8");
1245
+ // A format the instance was asked for is a file it wrote for that purpose,
1246
+ // so it is handed over as it stands: rendering it again here would be this
1247
+ // command deciding what a caller already decided.
1248
+ const format = parsed.named.get("format");
1249
+ if (format !== undefined) return await handed(body, parsed.named.get("out"));
1248
1250
  // What the heading states is where the cut fell, not the words it was asked
1249
1251
  // for in: a dump read next week cannot work out what "10 minutes ago" was.
1250
1252
  const since = spelled(resolved("since", parsed.named.get("since")));
@@ -1280,6 +1282,16 @@ async function dump(args: readonly string[]): Promise<unknown> {
1280
1282
  * two apart so that a sid stays a validated sid, and the joined spelling is
1281
1283
  * the CLI's own convenience — it is how the file the agent's records live in
1282
1284
  * is named, which is what makes the two halves tellable apart by eye. */
1285
+ /** Write out what the instance wrote, wherever the caller wanted it. */
1286
+ async function handed(body: string, out: string | undefined): Promise<undefined> {
1287
+ if (out === undefined) {
1288
+ process.stdout.write(body.endsWith("\n") ? body : `${body}\n`);
1289
+ return undefined;
1290
+ }
1291
+ await Bun.write(out, body);
1292
+ return undefined;
1293
+ }
1294
+
1283
1295
  export function dumpArgs(
1284
1296
  subject: string,
1285
1297
  named: ReadonlyMap<string, string> = new Map(),
@@ -1289,8 +1301,13 @@ export function dumpArgs(
1289
1301
  const agent = at === -1 ? undefined : subject.slice(at + AGENT_MARK.length);
1290
1302
  const types = named.get("types");
1291
1303
  const preset = named.get("preset");
1304
+ const format = named.get("format");
1305
+ if (format !== undefined && format !== "items" && format !== "records" && format !== "text") {
1306
+ throw new CommandError("invalid_args", "--format は items / records / text のどれかです");
1307
+ }
1292
1308
  return {
1293
1309
  sid,
1310
+ ...(format === undefined ? {} : { format }),
1294
1311
  ...(agent === undefined || agent === "" ? {} : { agent_id: agent }),
1295
1312
  ...(preset === undefined ? {} : { preset }),
1296
1313
  ...(types === undefined
@@ -1421,20 +1438,65 @@ function reply(args: readonly string[]): Promise<unknown> {
1421
1438
 
1422
1439
  /** `ccmsg notify <text>`: a line for whoever is watching. Nothing is held and
1423
1440
  * nothing is acknowledged, so there is no outcome to report beyond the op
1424
- * having been accepted. */
1425
- function notify(args: readonly string[]): Promise<unknown> {
1426
- const parsed = options(args, ["sid", "about"]);
1441
+ * having been accepted.
1442
+ *
1443
+ * `--hook` is the same line, taken from what the harness's own notification
1444
+ * tool was given: a session that pushed a notification said something to the
1445
+ * person it works for, and this is that host's two ways of reaching them — a
1446
+ * line on the page they are watching, and the machine saying it aloud. Both
1447
+ * run whatever the tool itself decided to do with it. */
1448
+ export async function notify(args: readonly string[], read?: Read): Promise<unknown> {
1449
+ const parsed = options(args, ["sid", "about", "reply-to"], ["hook"]);
1450
+ if (parsed.flags.has("hook")) return await pushed(await hookEvent(read));
1427
1451
  const [text] = parsed.rest;
1428
1452
  if (text === undefined) {
1429
- throw new CommandError("invalid_args", "使い方: ccmsg notify <text> [--about <sid>]");
1453
+ throw new CommandError(
1454
+ "invalid_args",
1455
+ "使い方: ccmsg notify <text> [--about <sid>] [--reply-to <mid>] | --hook",
1456
+ );
1430
1457
  }
1431
1458
  const about = parsed.named.get("about");
1432
- return announce(parsed.named.get("sid"), {
1459
+ const answering = parsed.named.get("reply-to");
1460
+ return await announce(parsed.named.get("sid"), {
1433
1461
  text,
1434
1462
  ...(about === undefined ? {} : { sid: about }),
1463
+ ...(answering === undefined ? {} : { reply_to: answering as NotifySendArgs["reply_to"] }),
1435
1464
  });
1436
1465
  }
1437
1466
 
1467
+ /** What a pushed notification becomes here: said aloud, and written to the
1468
+ * page.
1469
+ *
1470
+ * Nothing to say is nothing to do — a hook fires on the tool whatever the tool
1471
+ * was given — and a failure on either half leaves the other alone: the speech
1472
+ * is what a person in the room hears and the line is what a person at the page
1473
+ * reads, and neither is worth losing because the other could not be had. */
1474
+ export async function pushed(
1475
+ event: { sid?: string; tool_message?: string },
1476
+ spawn: Spawn = spawnSpeech,
1477
+ ): Promise<unknown> {
1478
+ const text = event.tool_message?.trim();
1479
+ if (text === undefined || text === "") return { pushed: false };
1480
+ const spoken = spoke(text, spawn);
1481
+ let sent = false;
1482
+ try {
1483
+ await announce(event.sid, { text });
1484
+ sent = true;
1485
+ } catch {
1486
+ // No instance, or one that refused: the person in the room still hears it.
1487
+ }
1488
+ return { pushed: true, spoken: await spoken, sent };
1489
+ }
1490
+
1491
+ /** Say it aloud, and answer whether the machine could. */
1492
+ async function spoke(text: string, spawn: Spawn): Promise<boolean> {
1493
+ try {
1494
+ return (await spawn([process.env["CCMSG_SAY_BIN"] ?? SYSTEM_SAY, text]).exited) === 0;
1495
+ } catch {
1496
+ return false;
1497
+ }
1498
+ }
1499
+
1438
1500
  /** `ccmsg stopping`: this session is about to go.
1439
1501
  *
1440
1502
  * What it buys is the difference between Paused and Disappeared (DESIGN §4.3): the
@@ -1681,74 +1743,12 @@ async function exchange(
1681
1743
  }
1682
1744
  }
1683
1745
 
1684
- /** What one attempt to record a `say` may cost before the speech goes ahead
1685
- * without it. The record is a nicety — which session spoke — and the speech is
1686
- * what the caller asked for, so an instance that has wedged costs latency once
1687
- * rather than silence. */
1688
- const SAY_POST_MS = 1_500;
1689
-
1690
1746
  /** How a speech process is started. Named so a test can watch the arguments
1691
1747
  * without the machine making a sound. */
1692
1748
  export type Spawn = (command: string[]) => { exited: Promise<number> };
1693
1749
 
1694
1750
  const spawnSpeech: Spawn = (command) =>
1695
- Bun.spawn(command, { stdin: "inherit", stdout: "inherit", stderr: "inherit" });
1696
-
1697
- /** `ccmsg say [say-options] [text...]`: speak, and say who spoke.
1698
- *
1699
- * Every argument goes to the speech binary untouched, so its own flags work and
1700
- * a PATH shim delegating here changes nothing about what the caller gets. The
1701
- * one exception is a lone `--help`, which the binary does not define.
1702
- *
1703
- * No arguments is not an error: `echo hi | say` reads its text from stdin, and
1704
- * that is the form a shim exists to preserve. */
1705
- export async function say(args: readonly string[], spawn: Spawn = spawnSpeech): Promise<number> {
1706
- if (args.length === 1 && (args[0] === "--help" || args[0] === "-h")) {
1707
- process.stdout.write(help([ROOT, ROOT.children?.find((one) => one.name === "say") as Command]));
1708
- return 0;
1709
- }
1710
- await posted(args.join(" "));
1711
- const binary = process.env["CCMSG_SAY_BIN"] ?? SYSTEM_SAY;
1712
- return await spawn([binary, ...args]).exited;
1713
- }
1714
-
1715
- /** Tell the instance this session spoke, and say nothing if it cannot be told.
1716
- *
1717
- * Best effort on purpose: no instance, a refused greeting or a socket that goes
1718
- * away under us all leave the speech itself untouched, and a message about the
1719
- * record would be noise in front of the thing the caller wanted. Text the
1720
- * contract will not take — a bare `say` reading its text from stdin has none —
1721
- * is nothing to record either. */
1722
- async function posted(text: string): Promise<void> {
1723
- const sid = ownSid();
1724
- if (text === "" || sid === undefined || sid === "") return;
1725
- const conn = await connect(resolvePaths().socket);
1726
- if (conn === undefined) return;
1727
- let timer: ReturnType<typeof setTimeout> | undefined;
1728
- const record = async (): Promise<void> => {
1729
- const greeting = await conn.ask({
1730
- op: "hello.session",
1731
- sid,
1732
- protocol_version: PROTOCOL_VERSION,
1733
- ...statedMeta(),
1734
- });
1735
- if (greeting["ok"] === true) await conn.ask({ op: "say.post", text });
1736
- };
1737
- const budget = new Promise<void>((resolve) => {
1738
- timer = setTimeout(resolve, SAY_POST_MS);
1739
- });
1740
- try {
1741
- // Raced rather than cancelled: an exchange that never answers is one this
1742
- // waits out, and closing under it would leave a reply nobody resolves.
1743
- await Promise.race([record(), budget]);
1744
- } catch {
1745
- // The instance went away mid-exchange. The speech is what was asked for
1746
- // and it happens regardless.
1747
- } finally {
1748
- clearTimeout(timer);
1749
- conn.close();
1750
- }
1751
- }
1751
+ Bun.spawn(command, { stdin: "ignore", stdout: "ignore", stderr: "ignore" });
1752
1752
 
1753
1753
  /** Long options and what is left over.
1754
1754
  *
@@ -196,6 +196,7 @@ const STARTING_PRESETS = [
196
196
  "message.sub.in",
197
197
  "message.team.in",
198
198
  "notice",
199
+ "tool.PushNotification",
199
200
  "thinking",
200
201
  ],
201
202
  },
@@ -6,7 +6,7 @@ import { CommandError } from "./link.ts";
6
6
  *
7
7
  * The pair rather than the payload alone, because two instances state the same
8
8
  * topic name: a payload with the name of its author taken off could not be told
9
- * from the other's, and which instance said it is half of what a cluster read
9
+ * from the other's, and which instance said it is half of what a mesh read
10
10
  * answers. */
11
11
  export interface Snapshot {
12
12
  readonly instance: InstanceId;
@@ -31,7 +31,7 @@ interface Frame {
31
31
  readonly data?: unknown;
32
32
  }
33
33
 
34
- /** What a greeting answered, as the two fields a cluster read needs of it. */
34
+ /** What a greeting answered, as the two fields a mesh read needs of it. */
35
35
  interface Greeted {
36
36
  readonly instance?: unknown;
37
37
  readonly instances?: unknown;
@@ -92,7 +92,7 @@ export async function snapshots(
92
92
  return [...held].map(([instance, data]) => ({ instance, data }));
93
93
  }
94
94
 
95
- /** The instances a greeting named, as the set a read of a cluster topic waits
95
+ /** The instances a greeting named, as the set a read of a mesh topic waits
96
96
  * for.
97
97
  *
98
98
  * `all` is every instance the answering one can currently reach, itself
@@ -14,11 +14,11 @@ import { type DispatchResult, failure, OpError, reply } from "./result.ts";
14
14
  /** What dispatch needs from the instance around it. */
15
15
  export interface DispatchDeps {
16
16
  /** This instance's own id, compared with the destination of an
17
- * `instance-local` op to decide whether the op is ours to run. */
17
+ * `owner_instance` op to decide whether the op is ours to run. */
18
18
  readonly self: InstanceId;
19
19
  /** The capabilities this instance has, as `hello` reports them. */
20
20
  readonly capabilities: ReadonlySet<Capability>;
21
- /** The instance that owns the subject of an `instance-local` op, or
21
+ /** The instance that owns the subject of an `owner_instance` op, or
22
22
  * `undefined` when no other instance owns it and we answer ourselves.
23
23
  * The routing table behind this is the `peers` topic (DESIGN §7.3). */
24
24
  readonly resolveInstance: (op: OpName, frame: Record<string, unknown>) => InstanceId | undefined;
@@ -97,7 +97,7 @@ export async function dispatch(
97
97
  );
98
98
  }
99
99
 
100
- // 6. an instance-local op whose subject belongs elsewhere goes to mesh.
100
+ // 6. an owner_instance op whose subject belongs elsewhere goes to mesh.
101
101
  //
102
102
  // A request that has already been here is dropped before that: a cycle in
103
103
  // the routing would otherwise send it round the same instances until every
@@ -108,7 +108,7 @@ export async function dispatch(
108
108
  if (Array.isArray(hops) && hops.includes(deps.self)) {
109
109
  return failure(requestId, "instance_unreachable", `${op} came back to ${deps.self}`);
110
110
  }
111
- if (attrs.locality === "instance-local") {
111
+ if (attrs.locality === "owner_instance") {
112
112
  const asked = fields["to_instance"];
113
113
  const target = typeof asked === "string" ? asked : deps.resolveInstance(op, fields);
114
114
  if (target !== undefined && target !== deps.self) {
@@ -10,6 +10,11 @@ export interface HookEvent {
10
10
  readonly cwd?: string;
11
11
  readonly transcript_path?: string;
12
12
  readonly reason?: string;
13
+ /** Which tool the event is about, on the events that are about one. */
14
+ readonly tool_name?: string;
15
+ /** What that tool was asked to say. A tool event carries the whole of the
16
+ * tool's input; this is the one field of it a hook of ours reads. */
17
+ readonly tool_message?: string;
13
18
  }
14
19
 
15
20
  /** Read one such event, or nothing to go on.
@@ -30,11 +35,16 @@ export async function hookEvent(
30
35
  }
31
36
  if (typeof parsed !== "object" || parsed === null) return {};
32
37
  const event = parsed as Record<string, unknown>;
38
+ const input = event["tool_input"];
33
39
  return {
34
40
  ...text(event, "session_id", "sid"),
35
41
  ...text(event, "cwd", "cwd"),
36
42
  ...text(event, "transcript_path", "transcript_path"),
37
43
  ...text(event, "reason", "reason"),
44
+ ...text(event, "tool_name", "tool_name"),
45
+ ...(typeof input === "object" && input !== null
46
+ ? text(input as Record<string, unknown>, "message", "tool_message")
47
+ : {}),
38
48
  };
39
49
  }
40
50
 
@@ -423,7 +423,7 @@ export class Instance {
423
423
  : { terminal_gateway: config.upstream.terminal_gateway }),
424
424
  }),
425
425
  ]);
426
- // The mesh is the rest of the cluster as the topic mechanism sees it: what
426
+ // The mesh is the rest of the mesh as the topic mechanism sees it: what
427
427
  // the peers have stated, and where a local subscription has to travel to
428
428
  // (DESIGN §7.4). An instance without one has no other instance to hear from.
429
429
  this.#topics = new Topics(this.self, this.#capabilities, this.#mesh);
@@ -561,7 +561,7 @@ export class Instance {
561
561
  this.#delivery = new Delivery({
562
562
  self: this.self,
563
563
  sessions: this.#sessions,
564
- ...(this.#mesh === undefined ? {} : { cluster: this.#mesh }),
564
+ ...(this.#mesh === undefined ? {} : { mesh: this.#mesh }),
565
565
  inbox,
566
566
  direct: this.#direct,
567
567
  publish: (topic, data, instance, to) => this.#topics.publish(topic, data, instance, to),
@@ -604,7 +604,7 @@ export class Instance {
604
604
  });
605
605
  this.#topics.attach("kv", kv);
606
606
 
607
- // The credentials, tokens and removals the cluster shares (DR-0001 §2.6).
607
+ // The credentials, tokens and removals the mesh shares (DR-0001 §2.6).
608
608
  // Written down beside the store and for the same reason: none of it is
609
609
  // derived from anything else this instance holds (DESIGN §2.5).
610
610
  const records = new AuthRecords({
@@ -860,7 +860,7 @@ export class Instance {
860
860
 
861
861
  /** A link came up or went down. Told to every client when it changes what
862
862
  * the instance would answer about the host link, and to nobody when the set
863
- * of reachable peers moved without changing that — a five-peer cluster
863
+ * of reachable peers moved without changing that — a five-peer mesh
864
864
  * losing one is not this host going offline. */
865
865
  #linkMoved(): void {
866
866
  const network = this.network;
@@ -945,11 +945,11 @@ export class Instance {
945
945
  : await this.#mesh.forward(decided.to, decided.frame, stated ?? callerOfIdentity(identity));
946
946
  }
947
947
 
948
- /** Which instance owns the subject of an instance-local op.
948
+ /** Which instance owns the subject of an owner_instance op.
949
949
  *
950
950
  * The subject is the session an op names, and an op that names none is about
951
951
  * this instance and stays here. A session this instance holds is its own
952
- * whatever the cluster last said; one it does not hold is looked for in the
952
+ * whatever the mesh last said; one it does not hold is looked for in the
953
953
  * routing table the `peers` topic is (DESIGN §7.3). */
954
954
  #owner(fields: Record<string, unknown>): InstanceId | undefined {
955
955
  const sid = fields["sid"];
@@ -8,16 +8,16 @@ export interface MeshView {
8
8
  instances(): InstanceInfo[];
9
9
  }
10
10
 
11
- /** The cluster as one instance sees it, itself included.
11
+ /** The mesh as one instance sees it, itself included.
12
12
  *
13
- * An instance with no mesh is a cluster of one and says so: it is reached at
13
+ * An instance with no mesh is a mesh of one and says so: it is reached at
14
14
  * whatever it serves, which is a row without an endpoint when that is the unix
15
15
  * socket alone rather than no row at all (contract, `InstanceInfo`).
16
16
  *
17
17
  * The same answer feeds `hello` and the topic, from here rather than from two
18
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(
19
+ * mesh would be one instance stating two views of itself. */
20
+ export function meshView(
21
21
  self: InstanceId,
22
22
  endpoint: Endpoint | undefined,
23
23
  mesh: MeshView | undefined,
@@ -56,7 +56,7 @@ export class Instances implements UpstreamResource {
56
56
  ) {}
57
57
 
58
58
  view(): InstanceInfo[] {
59
- return clusterView(this.deps.self, this.deps.endpoint, this.deps.mesh);
59
+ return meshView(this.deps.self, this.deps.endpoint, this.deps.mesh);
60
60
  }
61
61
 
62
62
  /** State the view, which the mesh asks for whenever a link moves. A frame
package/src/mesh/mesh.ts CHANGED
@@ -18,7 +18,7 @@ import {
18
18
  type SettledIdentity,
19
19
  } from "../dispatch/index.ts";
20
20
  import type { TopicValue } from "../topics/index.ts";
21
- import { AUTH_TOPIC, isClusterTopic, Relay } from "./relay.ts";
21
+ import { AUTH_TOPIC, isMeshTopic, Relay } from "./relay.ts";
22
22
  import {
23
23
  ALLOWED_ALGS,
24
24
  EphemeralKey,
@@ -388,7 +388,7 @@ export class Mesh {
388
388
  * whether it can be reached right now (DESIGN §7.5).
389
389
  *
390
390
  * A peer no handshake has settled yet is listed without an id. The operator
391
- * configured that endpoint, so it is an entry of the cluster whether or not
391
+ * configured that endpoint, so it is an entry of the mesh whether or not
392
392
  * anything has answered there — and leaving it out would hide exactly the
393
393
  * peer whose link is down, which is the one a reader is looking for. */
394
394
  instances(): InstanceInfo[] {
@@ -408,7 +408,7 @@ export class Mesh {
408
408
 
409
409
  /** Whether any peer is currently out of reach.
410
410
  *
411
- * What separates "no instance in the cluster knows this session" from "an
411
+ * What separates "no instance in the mesh knows this session" from "an
412
412
  * instance that might know it cannot be asked" — the one distinction DESIGN §6.6
413
413
  * says rests on the mesh's connection state and on nothing else. */
414
414
  anyUnreachable(): boolean {
@@ -417,7 +417,7 @@ export class Mesh {
417
417
 
418
418
  /** Which instance should answer for a session this instance does not hold.
419
419
  *
420
- * A session the cluster has named belongs to the instance its `peers` row
420
+ * A session the mesh has named belongs to the instance its `peers` row
421
421
  * states. One nobody has named while a peer is out of reach is answered with
422
422
  * that peer: forwarding there fails and the caller is told
423
423
  * `instance_unreachable`, which is what DESIGN §6.6 asks for in place of deciding
@@ -532,7 +532,7 @@ export class Mesh {
532
532
  *
533
533
  * The caller the envelope names, on the link it arrived over. A request that
534
534
  * names none is dispatched as the link itself, whose role is `instance` —
535
- * which the attribute table already answers, since no instance-local op is
535
+ * which the attribute table already answers, since no owner_instance op is
536
536
  * open to an instance. */
537
537
  caller(conn: Requester, caller: CallerIdentity | undefined): Requester {
538
538
  const link = this.#linkOf.get(conn);
@@ -553,12 +553,12 @@ export class Mesh {
553
553
 
554
554
  /** The current value of a relayed topic, one entry per instance that has
555
555
  * stated one. Handed to a fresh local subscriber beside this instance's own
556
- * snapshot, so it opens on the cluster rather than on us. */
556
+ * snapshot, so it opens on the mesh rather than on us. */
557
557
  snapshot(topic: string): readonly TopicValue[] {
558
558
  return this.relay.snapshot(topic);
559
559
  }
560
560
 
561
- /** A local subscriber appeared on a cluster topic, or the last one left.
561
+ /** A local subscriber appeared on a mesh topic, or the last one left.
562
562
  *
563
563
  * The subscription travels: what a subscriber asks of this instance, this
564
564
  * instance asks of every peer, and the frames come back unchanged (DESIGN §7.4).
@@ -567,7 +567,7 @@ export class Mesh {
567
567
  // `auth.records` is never given up and never asked for on demand: every
568
568
  // instance holds the whole set whether or not anything local is watching
569
569
  // it, the way `peers` is also the routing table (DESIGN §7.4, DR-0001 §2.6).
570
- if (topic === AUTH_TOPIC || !isClusterTopic(topic)) return;
570
+ if (topic === AUTH_TOPIC || !isMeshTopic(topic)) return;
571
571
  if (wanted) {
572
572
  if (this.#demanded.has(topic)) return;
573
573
  this.#demanded.add(topic);
@@ -593,7 +593,7 @@ export class Mesh {
593
593
  topic,
594
594
  // The instance asks on behalf of whoever subscribed to it, and what they
595
595
  // have in common is that they are this deployment's people rather than
596
- // any one session: a cluster topic is the same value for all of them
596
+ // any one session: a mesh topic is the same value for all of them
597
597
  // (DESIGN §6.2), so there is nothing narrower to name.
598
598
  //
599
599
  // `auth.records` is the exception, and the one topic no person may hear:
@@ -1090,12 +1090,12 @@ export class Mesh {
1090
1090
  //
1091
1091
  // The far end has no other way to learn this instance is going: it would
1092
1092
  // keep the link, keep answering `reachable`, and keep routing
1093
- // `instance-local` ops here until its own heartbeat gave up minutes later,
1093
+ // `owner_instance` ops here until its own heartbeat gave up minutes later,
1094
1094
  // where the disconnection of DESIGN §7.5 is supposed to be immediate. Which side
1095
1095
  // dialled a link is decided by the glare rule from a comparison of
1096
1096
  // endpoint strings (mesh-peer-auth §8.1), so which of a peer's links this instance
1097
1097
  // accepted is not something either end chose — leaving those open makes a
1098
- // clean stop look like a silent one to whichever half of the cluster the
1098
+ // clean stop look like a silent one to whichever half of the mesh the
1099
1099
  // comparison put on this side.
1100
1100
  //
1101
1101
  // An accepted socket is transport's to release (DESIGN §8.5 step 5), and left to
package/src/mesh/relay.ts CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  } from "@ccmsg/protocol";
10
10
  import { Elements, type TopicValue } from "../topics/index.ts";
11
11
 
12
- /** The rows of sessions the whole cluster is seen through.
12
+ /** The rows of sessions the whole mesh is seen through.
13
13
  *
14
14
  * They are `element`-granular, and an element topic is relayable only when its
15
15
  * elements say whose they are: a row here names the instance that holds the
@@ -19,13 +19,13 @@ import { Elements, type TopicValue } from "../topics/index.ts";
19
19
  * such name — an `inbox` frame belongs to a session, not to an instance. */
20
20
  const ROW_TOPICS: readonly string[] = ["peers", "agents"];
21
21
 
22
- /** The topics a subscriber sees the whole cluster on.
22
+ /** The topics a subscriber sees the whole mesh on.
23
23
  *
24
24
  * A per-instance whole is relayable by construction (DESIGN §6.2): a frame replaces
25
25
  * its own instance's entries and leaves every other instance's alone, so
26
26
  * several instances can state the same topic name without colliding. The rows
27
27
  * above are relayable for the same reason read one element at a time. */
28
- export const CLUSTER_TOPICS: readonly string[] = [
28
+ export const MESH_TOPICS: readonly string[] = [
29
29
  ...PLAIN_TOPICS.filter((topic) => TOPIC_ATTRIBUTES[topic].granularity === "per_instance_whole"),
30
30
  ...ROW_TOPICS,
31
31
  ];
@@ -37,8 +37,8 @@ export const CLUSTER_TOPICS: readonly string[] = [
37
37
  * the set this instance holds rather than held here (DR-0001 §2.6). */
38
38
  export const AUTH_TOPIC = "auth.records";
39
39
 
40
- export function isClusterTopic(topic: string): boolean {
41
- return CLUSTER_TOPICS.includes(topic);
40
+ export function isMeshTopic(topic: string): boolean {
41
+ return MESH_TOPICS.includes(topic);
42
42
  }
43
43
 
44
44
  /** Whether what a frame of this topic carries is rows to be merged rather than
@@ -64,7 +64,7 @@ export interface RelayDeps {
64
64
  }
65
65
 
66
66
  /** What the peers said, held on this instance so that losing a peer does not
67
- * empty the cluster view (DESIGN §7.5).
67
+ * empty the mesh view (DESIGN §7.5).
68
68
  *
69
69
  * Two things live here and nowhere else: the last whole value each instance
70
70
  * stated per topic, and whether that instance can be reached right now. The
@@ -99,7 +99,7 @@ export class Relay {
99
99
  * unchanged — recomputing it would put the same judgement in two places
100
100
  * (DESIGN §7.4). */
101
101
  accept(instance: InstanceId, topic: string, data: unknown, snapshot = false): void {
102
- if (!isClusterTopic(topic)) return;
102
+ if (!isMeshTopic(topic)) return;
103
103
  this.#sweep();
104
104
  const held = this.#held.get(instance) ?? new Map<string, unknown>();
105
105
  this.#held.set(instance, held);
@@ -174,7 +174,7 @@ export class Relay {
174
174
  return values;
175
175
  }
176
176
 
177
- /** Which instance a session belongs to, read from the cluster values the
177
+ /** Which instance a session belongs to, read from the mesh values the
178
178
  * peers stated (DESIGN §7.3).
179
179
  *
180
180
  * `peers` names every session an instance holds, connected and lost alike,
@@ -2,6 +2,8 @@ import type {
2
2
  CallerIdentity,
3
3
  CandidateSession,
4
4
  InboxMessage,
5
+ InboxRemoved,
6
+ InboxRemovedReason,
5
7
  InstanceId,
6
8
  MessageSendArgs,
7
9
  MessageSendResult,
@@ -21,7 +23,12 @@ import {
21
23
  OpError,
22
24
  type Requester,
23
25
  } from "../dispatch/index.ts";
24
- import type { PublishOutcome, TopicValue, UpstreamResource } from "../topics/index.ts";
26
+ import {
27
+ PEOPLE,
28
+ type PublishOutcome,
29
+ type TopicValue,
30
+ type UpstreamResource,
31
+ } from "../topics/index.ts";
25
32
  import type { DirectRoute } from "./direct.ts";
26
33
  import type { Inbox } from "./inbox.ts";
27
34
 
@@ -38,15 +45,15 @@ export interface SessionLookup {
38
45
  peerRows(): PeerInfo[];
39
46
  }
40
47
 
41
- /** The rest of the cluster, for a message addressed outside this instance.
48
+ /** The rest of the mesh, for a message addressed outside this instance.
42
49
  *
43
- * `message.send` is a `cluster` op — any instance may be asked — but a message
50
+ * `message.send` is an `any_instance` op — any instance may be asked — but a message
44
51
  * reaches a session through the session's own connections, which are held by
45
52
  * the instance it greeted. So the op is answered here by carrying it there
46
- * (DESIGN §2.2 step 6 is about `instance-local` ops; this is the same forwarding for
53
+ * (DESIGN §2.2 step 6 is about `owner_instance` ops; this is the same forwarding for
47
54
  * the one op whose subject is elsewhere while its op is not). */
48
- export interface Cluster {
49
- /** Which instance holds this session, or nothing when the cluster has not
55
+ export interface MeshReach {
56
+ /** Which instance holds this session, or nothing when the mesh has not
50
57
  * named it. */
51
58
  ownerOf(sid: Sid): InstanceId | undefined;
52
59
  /** Whether an instance that might hold it cannot be asked right now. */
@@ -64,14 +71,19 @@ export interface DeliveryDeps {
64
71
  readonly sessions: SessionLookup;
65
72
  /** Absent on an instance with no mesh, where every session it can name is
66
73
  * its own. */
67
- readonly cluster?: Cluster;
74
+ readonly mesh?: MeshReach;
68
75
  readonly inbox: Inbox;
69
76
  /** Route (a). Off until it is confirmed against a running harness, which is
70
77
  * condition 0 of DESIGN §6.5 and is why this is handed in rather than built here. */
71
78
  readonly direct: DirectRoute;
72
79
  /** The one way a value reaches subscribers (DESIGN §6.1), narrowed to the session a
73
80
  * message is for. */
74
- readonly publish: (topic: string, data: unknown, instance: InstanceId, to: Sid) => PublishOutcome;
81
+ readonly publish: (
82
+ topic: string,
83
+ data: unknown,
84
+ instance: InstanceId,
85
+ to: Sid | typeof PEOPLE,
86
+ ) => PublishOutcome;
75
87
  /** How many of that session's connections are listening on `inbox`. */
76
88
  readonly listeners: (topic: string, to: Sid) => number;
77
89
  }
@@ -97,6 +109,14 @@ export class Delivery implements UpstreamResource {
97
109
 
98
110
  constructor(private readonly deps: DeliveryDeps) {
99
111
  this.#counter = deps.inbox.lastCounter(`${deps.self}/`);
112
+ // Every way out of the inbox is stated to the watchers, and the inbox is
113
+ // the one that knows about all of them — handed over, timed out, dropped
114
+ // for a newer message. Read from there rather than published beside each
115
+ // call that removes something, so a way out nobody thought of here is
116
+ // still a removal somebody watching sees (DESIGN §6.7).
117
+ deps.inbox.onRemoved((mid, reason) => {
118
+ this.#removed([mid], reason);
119
+ });
100
120
  }
101
121
 
102
122
  /** `message.send`. The op fails only for a sid nobody knows; every other
@@ -123,47 +143,87 @@ export class Delivery implements UpstreamResource {
123
143
  if (direct === "refused") {
124
144
  // Turned away for now, which is neither delivered nor undeliverable: it
125
145
  // waits in the inbox and is offered again (DESIGN §6.8).
126
- this.deps.inbox.hold(to, message);
146
+ this.#hold(to, message);
127
147
  return { delivered: false, reason: "throttled" };
128
148
  }
129
149
 
130
150
  if (this.deps.listeners(INBOX, to) > 0) {
131
151
  if (this.deps.publish(INBOX, [message], this.deps.self, to) === "ok") {
152
+ // Route (b) is the topic itself, so the message never waits anywhere:
153
+ // the watchers are told both halves at once — it arrived, and it is
154
+ // gone because the session has it — since a view built from frames
155
+ // alone would otherwise show it waiting for good.
156
+ this.#watchers(to, [message]);
157
+ this.#removed([message.mid], "delivered");
132
158
  return { delivered: true };
133
159
  }
134
160
  // The session is listening but is behind on what it has already been
135
161
  // offered, which is the same standing as route (a) turning the message
136
162
  // away: it waits in the inbox and is offered again (DESIGN §6.8).
137
- this.deps.inbox.hold(to, message);
163
+ this.#hold(to, message);
138
164
  return { delivered: false, reason: "throttled" };
139
165
  }
140
166
 
141
- const { evicted } = this.deps.inbox.hold(to, message);
167
+ const { evicted } = this.#hold(to, message);
142
168
  return this.#undelivered(to, evicted ? "inbox_full" : this.#reason(state));
143
169
  };
144
170
 
171
+ /** Hold a message the session could not take, and say so to the watchers.
172
+ *
173
+ * Stated before it is held, so that a message dropped to make room for it
174
+ * reads in the order the two happened: a removal of something the watcher
175
+ * has, rather than of something it is about to be told about. */
176
+ #hold(to: Sid, message: InboxMessage): { evicted: boolean } {
177
+ this.#watchers(to, [message]);
178
+ return this.deps.inbox.hold(to, message);
179
+ }
180
+
181
+ /** What is waiting, as somebody looking at it from outside reads it.
182
+ *
183
+ * A person holds every session's inbox in one subscription, so the rows they
184
+ * are answered with name their recipient; the session's own do not, since
185
+ * its subscription is already the recipient (contract, `InboxMessage.to`). */
186
+ #watchers(to: Sid, messages: readonly InboxMessage[]): void {
187
+ if (messages.length === 0) return;
188
+ this.deps.publish(
189
+ INBOX,
190
+ messages.map((message) => ({ ...message, to })),
191
+ this.deps.self,
192
+ PEOPLE,
193
+ );
194
+ }
195
+
196
+ /** A message has left an inbox, which only a watcher has anything left to do
197
+ * with: the session it was for either has it or never will, and neither is
198
+ * something to tell it on the topic it receives messages on. */
199
+ #removed(mids: readonly Mid[], reason: InboxRemovedReason): void {
200
+ if (mids.length === 0) return;
201
+ const gone: InboxRemoved[] = mids.map((mid) => ({ mid, removed: true, reason }));
202
+ this.deps.publish(INBOX, gone, this.deps.self, PEOPLE);
203
+ }
204
+
145
205
  /** A session this instance does not hold: carried to the instance that does,
146
- * or named as one the cluster cannot answer for right now.
206
+ * or named as one the mesh cannot answer for right now.
147
207
  *
148
- * Nothing when the cluster has no such session anywhere and every instance
208
+ * Nothing when the mesh has no such session anywhere and every instance
149
209
  * could be asked — which is the only case `session_not_found` covers (DESIGN §6.6).
150
210
  * While an instance is out of reach the sid may well be its, so the sender is
151
211
  * told the reason rather than that the session does not exist. The message is
152
212
  * not held here either: the inbox that would offer it again is the one on the
153
213
  * instance that owns the session (DESIGN §6.7). */
154
214
  async #elsewhere(to: Sid, input: HandlerInput): Promise<MessageSendResult | undefined> {
155
- const cluster = this.deps.cluster;
156
- if (cluster === undefined) return undefined;
157
- const owner = cluster.ownerOf(to);
215
+ const reach = this.deps.mesh;
216
+ if (reach === undefined) return undefined;
217
+ const owner = reach.ownerOf(to);
158
218
  if (owner === undefined || owner === this.deps.self) {
159
- return cluster.anyUnreachable()
219
+ return reach.anyUnreachable()
160
220
  ? { delivered: false, reason: "instance_unreachable" }
161
221
  : undefined;
162
222
  }
163
223
  // The sender, as the owning instance will run the op as: the identity the
164
224
  // connection greeted with, which is the same thing `message.send` reads to
165
225
  // decide who a message is from (DESIGN §6.5).
166
- const answer = await cluster.forward(owner, input.args, callerOf(input));
226
+ const answer = await reach.forward(owner, input.args, callerOf(input));
167
227
  if (answer.kind === "reply") {
168
228
  const { ok: _ok, request_id: _id, ...body } = answer.response;
169
229
  return body as unknown as MessageSendResult;
@@ -243,11 +303,23 @@ export class Delivery implements UpstreamResource {
243
303
  *
244
304
  * A message an offer over route (a) has claimed is left out: it is on its way
245
305
  * on the other route, and the session subscribing while that runs must not
246
- * make it two messages. */
306
+ * make it two messages.
307
+ *
308
+ * **A person's subscription is a view, not a delivery.** They are answered
309
+ * with everything waiting anywhere on this instance, each row naming its
310
+ * recipient, and the inbox is left exactly as it was — a person is not who
311
+ * any of it was addressed to, and a view that consumed what it looked at
312
+ * would deliver messages to nobody by being opened. Nothing is left out of
313
+ * it either: a message an offer has claimed is still waiting until that
314
+ * offer says otherwise, and that is what somebody watching wants to see. */
247
315
  snapshot(topic: string, conn: Requester): readonly TopicValue[] {
248
316
  const identity = conn.identity;
249
- const sid = identity.state === "settled" ? identity.sid : undefined;
250
- if (topic !== INBOX || sid === undefined) return [];
317
+ if (topic !== INBOX || identity.state !== "settled") return [];
318
+ const sid = identity.sid;
319
+ // A settled connection that names no session is somebody watching: the
320
+ // topic table lets nobody but a session and a person subscribe here, so
321
+ // the sid is the whole of the difference and no role is read for it (M1).
322
+ if (sid === undefined) return [{ instance: this.deps.self, data: this.#waiting() }];
251
323
  const claimed = this.#claimed.get(sid);
252
324
  const held = this.deps.inbox
253
325
  .undelivered(sid)
@@ -259,6 +331,16 @@ export class Delivery implements UpstreamResource {
259
331
  return [{ instance: this.deps.self, data: held }];
260
332
  }
261
333
 
334
+ /** Everything still undelivered on this instance, oldest first within each
335
+ * session, each row naming who it is for. */
336
+ #waiting(): InboxMessage[] {
337
+ const rows: InboxMessage[] = [];
338
+ for (const sid of this.deps.inbox.sids()) {
339
+ for (const message of this.deps.inbox.undelivered(sid)) rows.push({ ...message, to: sid });
340
+ }
341
+ return rows;
342
+ }
343
+
262
344
  /** The reason a message is waiting, named from the classification alone
263
345
  * (DESIGN §6.6). `preparing` is the live session with nowhere to put it: it is there,
264
346
  * route (a) did not carry it, and nothing of its is listening yet.
@@ -11,6 +11,7 @@ import {
11
11
  INBOX_MAX_PER_SID,
12
12
  INBOX_RETENTION_MS,
13
13
  type InboxMessage,
14
+ type InboxRemovedReason,
14
15
  type Sid,
15
16
  type Timestamp,
16
17
  } from "@ccmsg/protocol";
@@ -44,8 +45,21 @@ type Record_ =
44
45
  export class Inbox {
45
46
  readonly #held = new Map<Sid, InboxMessage[]>();
46
47
 
48
+ /** Told whenever a message leaves, and why. Every way out passes through
49
+ * here — handed over, timed out, dropped for a newer one — so whoever states
50
+ * removals on the topic has one place to hear about them rather than a
51
+ * reading of its own per way (DESIGN §6.7). Absent until somebody asks: a
52
+ * replay at startup reaches conclusions about a file, with nobody yet
53
+ * subscribed for them to be news to. */
54
+ #onRemoved?: (mid: string, reason: InboxRemovedReason) => void;
55
+
47
56
  constructor(private readonly file: string) {}
48
57
 
58
+ /** Hear about messages leaving. */
59
+ onRemoved(told: (mid: string, reason: InboxRemovedReason) => void): void {
60
+ this.#onRemoved = told;
61
+ }
62
+
49
63
  /** Replay the file, drop what has expired, and write back what is left.
50
64
  *
51
65
  * The rewrite is the only whole-file write, and it happens before anything
@@ -95,7 +109,10 @@ export class Inbox {
95
109
  this.#append({ v: "add", sid, message });
96
110
  if (held.length <= INBOX_MAX_PER_SID) return { evicted: false };
97
111
  const oldest = held.shift();
98
- if (oldest !== undefined) this.#append({ v: "dropped", sid, mid: oldest.mid });
112
+ if (oldest !== undefined) {
113
+ this.#append({ v: "dropped", sid, mid: oldest.mid });
114
+ this.#onRemoved?.(oldest.mid, "dropped");
115
+ }
99
116
  return { evicted: true };
100
117
  }
101
118
 
@@ -108,7 +125,10 @@ export class Inbox {
108
125
  const left = held.filter((message) => !gone.has(message.mid));
109
126
  if (left.length === 0) this.#held.delete(sid);
110
127
  else this.#held.set(sid, left);
111
- for (const mid of mids) this.#append({ v: "delivered", sid, mid });
128
+ for (const mid of mids) {
129
+ this.#append({ v: "delivered", sid, mid });
130
+ this.#onRemoved?.(mid, "delivered");
131
+ }
112
132
  }
113
133
 
114
134
  /** Every session something is waiting for. What reads it is the offer of
@@ -154,8 +174,12 @@ export class Inbox {
154
174
  if (only !== undefined && sid !== only) continue;
155
175
  const left = held.filter((message) => now - message.sent_at <= INBOX_RETENTION_MS);
156
176
  if (left.length === held.length) continue;
177
+ const gone = new Set(left.map((message) => message.mid));
157
178
  if (left.length === 0) this.#held.delete(sid);
158
179
  else this.#held.set(sid, left);
180
+ for (const message of held) {
181
+ if (!gone.has(message.mid)) this.#onRemoved?.(message.mid, "expired");
182
+ }
159
183
  }
160
184
  }
161
185
 
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  InstanceId,
3
+ Mid,
3
4
  Notification,
4
5
  NotifySendArgs,
5
6
  NotifySendResult,
@@ -48,7 +49,10 @@ export class Notify implements UpstreamResource {
48
49
  * caller otherwise, so a session notifying about itself says only the text. */
49
50
  send = (input: HandlerInput): NotifySendResult => {
50
51
  const args = input.args as unknown as NotifySendArgs;
51
- this.#announce(args.sid ?? this.#caller(input), args.text);
52
+ // What it answers travels with it: a notification is shown while the
53
+ // session's own account of the same answer is still being written, and the
54
+ // `mid` is what tells a reader holding both that they are one thing.
55
+ this.#announce(args.sid ?? this.#caller(input), args.text, Date.now(), args.reply_to);
52
56
  return {};
53
57
  };
54
58
 
@@ -92,11 +96,12 @@ export class Notify implements UpstreamResource {
92
96
  return [];
93
97
  }
94
98
 
95
- #announce(sid: Sid, text: string, now: Timestamp = Date.now()): Timestamp {
99
+ #announce(sid: Sid, text: string, now: Timestamp = Date.now(), reply_to?: Mid): Timestamp {
96
100
  const notification: Notification = {
97
101
  sid,
98
102
  sid_label: this.deps.label(sid),
99
103
  text,
104
+ ...(reply_to === undefined ? {} : { reply_to }),
100
105
  sent_at: now,
101
106
  };
102
107
  // A notification is an occurrence, so nothing folds it away and a watcher
@@ -9,7 +9,8 @@ import { DESCRIPTION, SKILL } from "./skill.ts";
9
9
  *
10
10
  * The plugin itself is thin on purpose. Messages reach a session through the
11
11
  * harness's own socket, so nothing here listens, polls or holds a connection —
12
- * the skill says how to speak, and the two hooks say hello and goodbye. */
12
+ * the skill says how to speak, and the hooks say hello, goodbye, and whatever
13
+ * the session pushed to the person it works for. */
13
14
 
14
15
  /** The plugin's name, the marketplace's name, and therefore the id Claude Code
15
16
  * knows it by. One word for all three: there is one plugin here and a
@@ -43,6 +44,19 @@ const HOOKS = {
43
44
  hooks: [{ type: "command", command: throughPath("hello --hook"), timeout: HOOK_TIMEOUT_S }],
44
45
  },
45
46
  ],
47
+ PostToolUse: [
48
+ {
49
+ // What a session tells the person it is working for goes out the way
50
+ // this host tells them anything: aloud, and as a line on the page they
51
+ // are watching. The harness's own tool decides whether to raise a
52
+ // notification of its own; this runs either way, because a session that
53
+ // said something meant to say it.
54
+ matcher: "PushNotification",
55
+ hooks: [
56
+ { type: "command", command: throughPath("notify --hook"), timeout: HOOK_TIMEOUT_S },
57
+ ],
58
+ },
59
+ ],
46
60
  SessionEnd: [
47
61
  {
48
62
  hooks: [
@@ -77,12 +77,15 @@ preset の一覧は \`ccmsg dump presets\`。
77
77
 
78
78
  \`\`\`
79
79
  ccmsg notify <text> 一行知らせる (保持されない、返事も来ない)
80
- ccmsg say <text> 声に出して知らせる
81
80
  \`\`\`
82
81
 
83
82
  手が空いた・判断を仰ぎたい・長い作業が終わった、を人に伝えるときに使う。
84
83
  セッション同士のやり取りには使わない。
85
84
 
85
+ 声で届けたいときは \`PushNotification\` ツールを使う。この環境では hook が受けて
86
+ 本文をそのまま音声と webui の通知に流すので、「離席中のときだけ」といった
87
+ 一般の遠慮は要らない。
88
+
86
89
  ## これから終わるとき
87
90
 
88
91
  \`\`\`
@@ -4,13 +4,17 @@ import type {
4
4
  DumpPreset,
5
5
  InstanceId,
6
6
  SessionDumpFile,
7
+ SessionDumpFormat,
7
8
  SessionDumpWriteArgs,
8
9
  SessionDumpWriteResult,
10
+ Timestamp,
9
11
  } from "@ccmsg/protocol";
10
12
  import { OpError } from "../dispatch/index.ts";
11
13
  import {
12
14
  bounded,
13
15
  classify,
16
+ document as render,
17
+ type Item,
14
18
  ledger,
15
19
  located,
16
20
  select,
@@ -81,6 +85,7 @@ export function dumpWrite(args: SessionDumpWriteArgs, deps: DumpDeps): SessionDu
81
85
  );
82
86
  const ids = ledger(items);
83
87
  const written_at = Date.now();
88
+ const format = args.format ?? "items";
84
89
  // The file repeats what it was asked for. A dump outlives the request that
85
90
  // made it and is read by whoever was handed the path, so it has to say on
86
91
  // its own what it is a dump of and what was left out — which is why the
@@ -93,12 +98,27 @@ export function dumpWrite(args: SessionDumpWriteArgs, deps: DumpDeps): SessionDu
93
98
  items,
94
99
  ids,
95
100
  };
101
+ const body =
102
+ format === "items"
103
+ ? `${JSON.stringify(document, undefined, 2)}\n`
104
+ : format === "records"
105
+ ? sourceLines(text, items)
106
+ : render(document, {
107
+ instance: deps.self,
108
+ ...(args.since_at === undefined ? {} : { since: moment(args.since_at) }),
109
+ ...(args.until_at === undefined ? {} : { until: moment(args.until_at) }),
110
+ ...(args.since_uuid === undefined ? {} : { since: args.since_uuid }),
111
+ ...(args.until_uuid === undefined ? {} : { until: args.until_uuid }),
112
+ });
96
113
  const dir = join(deps.stateDir, DUMPS);
97
114
  mkdirSync(dir, { recursive: true });
98
115
  const named = args.agent_id === undefined ? args.sid : `${args.sid}-agent-${args.agent_id}`;
99
- const path = join(dir, `${named}-${written_at}${DUMP_SUFFIX}`);
100
- const body = `${JSON.stringify(document, undefined, 2)}\n`;
116
+ const path = join(dir, `${named}-${written_at}${suffix(format)}`);
101
117
  writeFileSync(path, body);
118
+ // What is counted is the selection, whatever the file ended up holding: that
119
+ // is what the caller asked for and what it reads the answer against, and a
120
+ // count that moved with the rendering would answer a different question each
121
+ // time (contract, `SessionDumpWriteResult`).
102
122
  return { path, instance: deps.self, entries, ids, bytes: Buffer.byteLength(body) };
103
123
  }
104
124
 
@@ -116,3 +136,43 @@ function presetFor(
116
136
  if (found === undefined) throw new OpError("invalid_args", `no preset is configured as ${name}`);
117
137
  return found;
118
138
  }
139
+
140
+ /** The records the selected items were read from, as the file holds them.
141
+ *
142
+ * Taken out of the bytes by each item's own address rather than re-serialized,
143
+ * so what a tool reading the harness's format gets is the harness's own lines
144
+ * — nothing is added around them and nothing inside them is changed. Several
145
+ * items out of one record share that address, so the record is written once
146
+ * and the line count is not the item count (contract, `SessionDumpFormat`).
147
+ *
148
+ * One dump is one transcript — the session's, or one agent's when the request
149
+ * names one — so no line here has to say which file it came from. */
150
+ function sourceLines(text: string, items: readonly Item[]): string {
151
+ const bytes = Buffer.from(text, "utf8");
152
+ const seen = new Set<number>();
153
+ const lines: string[] = [];
154
+ for (const item of items) {
155
+ const at = item.source.offset;
156
+ if (seen.has(at)) continue;
157
+ seen.add(at);
158
+ lines.push(
159
+ bytes
160
+ .subarray(at, at + item.source.bytes)
161
+ .toString("utf8")
162
+ .trimEnd(),
163
+ );
164
+ }
165
+ return lines.length === 0 ? "" : `${lines.join("\n")}\n`;
166
+ }
167
+
168
+ /** What a file of this format is called. Whoever is handed the path opens it
169
+ * in whatever reads that kind of file, and a name saying `.json` for prose
170
+ * would send them to the wrong one. */
171
+ function suffix(format: SessionDumpFormat): string {
172
+ return format === "items" ? DUMP_SUFFIX : format === "records" ? ".jsonl" : ".md";
173
+ }
174
+
175
+ /** A bound as the heading states it. */
176
+ function moment(at: Timestamp): string {
177
+ return new Date(at).toISOString();
178
+ }
@@ -22,7 +22,7 @@ import {
22
22
  import { type HandlerInput, OpError, type Requester } from "../dispatch/index.ts";
23
23
  import { within } from "../files/index.ts";
24
24
  import { HARNESS, type Harness } from "../harness/index.ts";
25
- import { clusterView } from "../mesh/instances.ts";
25
+ import { meshView } from "../mesh/instances.ts";
26
26
  import type { TranscriptFacts } from "../transcript/index.ts";
27
27
  import { Elements, type TopicValue, type UpstreamResource } from "../topics/index.ts";
28
28
  import { classify, type SessionInputs } from "./classify.ts";
@@ -315,8 +315,8 @@ export class Sessions implements UpstreamResource {
315
315
  instance: this.deps.self,
316
316
  ...(this.deps.endpoint === undefined ? {} : { endpoint: this.deps.endpoint }),
317
317
  // The same view the `instances` topic carries, worked out in one place
318
- // so a greeting and a subscription cannot state two different clusters.
319
- instances: clusterView(this.deps.self, this.deps.endpoint, this.deps.mesh),
318
+ // so a greeting and a subscription cannot state two different meshs.
319
+ instances: meshView(this.deps.self, this.deps.endpoint, this.deps.mesh),
320
320
  capabilities: [...this.deps.capabilities],
321
321
  version: this.deps.version,
322
322
  started_at: this.deps.startedAt,
@@ -6,9 +6,9 @@ import type { Requester } from "../dispatch/index.ts";
6
6
  * list cannot see a change arrive sooner than the display draws it, so frames
7
7
  * closer together than a few display frames are spent on nothing, while a wait
8
8
  * long enough to be read as lag starts around a quarter of a second. Towards
9
- * the cluster: a relayed frame waits once per hop, so the delay a subscriber
9
+ * the mesh: a relayed frame waits once per hop, so the delay a subscriber
10
10
  * sees is this value times the hops between it and the instance that produced
11
- * the value — at 100 ms a two-hop cluster still answers inside the window a
11
+ * the value — at 100 ms a two-hop mesh still answers inside the window a
12
12
  * person reads as immediate, which a longer period would leave.
13
13
  *
14
14
  * It is not a poll. Nothing is looked at when the period elapses: the timer is
Binary file
@@ -107,6 +107,13 @@ const USE: Record<string, Reader> = {
107
107
  const cron = str(input["cron"]);
108
108
  return cron === undefined ? undefined : { cron, ...optional("prompt", str(input["prompt"])) };
109
109
  },
110
+ /** What a session told the person it works for, as `text`: the same field a
111
+ * message carries, because this is the same thing — words meant for a person
112
+ * to read, rather than a call whose arguments happen to include some. */
113
+ PushNotification: (input) => {
114
+ const text = str(input["message"]);
115
+ return text === undefined ? undefined : { text };
116
+ },
110
117
  };
111
118
 
112
119
  function pattern(input: Record<string, unknown>): Record<string, unknown> | undefined {