@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 +2 -2
- package/src/auth/auth.ts +1 -1
- package/src/auth/records.ts +1 -1
- package/src/cli.ts +87 -87
- package/src/daemon/registry.ts +1 -0
- package/src/daemon/snapshot.ts +3 -3
- package/src/dispatch/dispatch.ts +4 -4
- package/src/greeting/hook.ts +10 -0
- package/src/instance/instance.ts +6 -6
- package/src/mesh/instances.ts +5 -5
- package/src/mesh/mesh.ts +11 -11
- package/src/mesh/relay.ts +8 -8
- package/src/messaging/delivery.ts +103 -21
- package/src/messaging/inbox.ts +26 -2
- package/src/messaging/notify.ts +7 -2
- package/src/plugin/claude.ts +15 -1
- package/src/plugin/skill.ts +4 -1
- package/src/sessions/dump.ts +62 -2
- package/src/sessions/registry.ts +3 -3
- package/src/topics/egress.ts +2 -2
- package/src/topics/topics.ts +0 -0
- package/src/transcript/items/tools.ts +7 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ccmsg/cli",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
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
|
|
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
|
package/src/auth/records.ts
CHANGED
|
@@ -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
|
|
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:
|
|
95
|
-
* is
|
|
96
|
-
*
|
|
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
|
|
141
|
-
*
|
|
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
|
|
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(
|
|
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
|
-
|
|
1426
|
-
|
|
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(
|
|
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
|
-
|
|
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: "
|
|
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
|
*
|
package/src/daemon/registry.ts
CHANGED
package/src/daemon/snapshot.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
package/src/dispatch/dispatch.ts
CHANGED
|
@@ -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
|
-
* `
|
|
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 `
|
|
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
|
|
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 === "
|
|
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) {
|
package/src/greeting/hook.ts
CHANGED
|
@@ -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
|
|
package/src/instance/instance.ts
CHANGED
|
@@ -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
|
|
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 ? {} : {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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"];
|
package/src/mesh/instances.ts
CHANGED
|
@@ -8,16 +8,16 @@ export interface MeshView {
|
|
|
8
8
|
instances(): InstanceInfo[];
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
/** The
|
|
11
|
+
/** The mesh as one instance sees it, itself included.
|
|
12
12
|
*
|
|
13
|
-
* An instance with no mesh is a
|
|
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
|
-
*
|
|
20
|
-
export function
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 || !
|
|
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
|
|
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
|
-
// `
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
41
|
-
return
|
|
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
|
|
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 (!
|
|
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
|
|
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
|
|
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
|
|
48
|
+
/** The rest of the mesh, for a message addressed outside this instance.
|
|
42
49
|
*
|
|
43
|
-
* `message.send` is
|
|
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 `
|
|
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
|
|
49
|
-
/** Which instance holds this session, or nothing when the
|
|
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
|
|
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: (
|
|
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
|
|
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
|
|
163
|
+
this.#hold(to, message);
|
|
138
164
|
return { delivered: false, reason: "throttled" };
|
|
139
165
|
}
|
|
140
166
|
|
|
141
|
-
const { evicted } = this
|
|
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
|
|
206
|
+
* or named as one the mesh cannot answer for right now.
|
|
147
207
|
*
|
|
148
|
-
* Nothing when the
|
|
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
|
|
156
|
-
if (
|
|
157
|
-
const owner =
|
|
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
|
|
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
|
|
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
|
-
|
|
250
|
-
|
|
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.
|
package/src/messaging/inbox.ts
CHANGED
|
@@ -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)
|
|
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)
|
|
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
|
|
package/src/messaging/notify.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/plugin/claude.ts
CHANGED
|
@@ -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
|
|
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: [
|
package/src/plugin/skill.ts
CHANGED
|
@@ -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
|
\`\`\`
|
package/src/sessions/dump.ts
CHANGED
|
@@ -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}${
|
|
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
|
+
}
|
package/src/sessions/registry.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
319
|
-
instances:
|
|
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,
|
package/src/topics/egress.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
package/src/topics/topics.ts
CHANGED
|
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 {
|