@ccmsg/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +23 -0
  3. package/package.json +32 -0
  4. package/src/cli.ts +1074 -0
  5. package/src/daemon/control.ts +88 -0
  6. package/src/daemon/index.ts +6 -0
  7. package/src/daemon/link.ts +93 -0
  8. package/src/daemon/log.ts +116 -0
  9. package/src/daemon/registry.ts +285 -0
  10. package/src/daemon/snapshot.ts +115 -0
  11. package/src/daemon/supervise.ts +446 -0
  12. package/src/dispatch/caller.ts +47 -0
  13. package/src/dispatch/dispatch.ts +128 -0
  14. package/src/dispatch/handler.ts +55 -0
  15. package/src/dispatch/identity.ts +22 -0
  16. package/src/dispatch/index.ts +5 -0
  17. package/src/dispatch/result.ts +58 -0
  18. package/src/files/containment.ts +263 -0
  19. package/src/files/files.ts +421 -0
  20. package/src/files/index.ts +14 -0
  21. package/src/files/sandbox.ts +0 -0
  22. package/src/greeting/hook.ts +48 -0
  23. package/src/greeting/index.ts +2 -0
  24. package/src/greeting/meta.ts +66 -0
  25. package/src/instance/config.ts +424 -0
  26. package/src/instance/handlers.ts +28 -0
  27. package/src/instance/identity.ts +44 -0
  28. package/src/instance/index.ts +8 -0
  29. package/src/instance/instance.ts +911 -0
  30. package/src/instance/lock.ts +108 -0
  31. package/src/instance/log.ts +30 -0
  32. package/src/instance/paths.ts +200 -0
  33. package/src/instance/socket.ts +62 -0
  34. package/src/kv/index.ts +2 -0
  35. package/src/kv/merge.ts +66 -0
  36. package/src/kv/store.ts +195 -0
  37. package/src/launcher/index.ts +4 -0
  38. package/src/launcher/launcher.ts +190 -0
  39. package/src/launcher/roots.ts +32 -0
  40. package/src/launcher/spawn.ts +81 -0
  41. package/src/launcher/tree.ts +80 -0
  42. package/src/mesh/index.ts +5 -0
  43. package/src/mesh/keys.ts +158 -0
  44. package/src/mesh/mesh.ts +1169 -0
  45. package/src/mesh/probe.ts +100 -0
  46. package/src/mesh/relay.ts +147 -0
  47. package/src/mesh/wire.ts +96 -0
  48. package/src/messaging/delivery.ts +375 -0
  49. package/src/messaging/direct.ts +433 -0
  50. package/src/messaging/handlers.ts +14 -0
  51. package/src/messaging/inbox.ts +191 -0
  52. package/src/messaging/index.ts +5 -0
  53. package/src/messaging/notify.ts +117 -0
  54. package/src/plugin/claude.ts +148 -0
  55. package/src/plugin/index.ts +13 -0
  56. package/src/plugin/install.ts +416 -0
  57. package/src/service/index.ts +1 -0
  58. package/src/service/service.ts +359 -0
  59. package/src/sessions/classify.ts +66 -0
  60. package/src/sessions/dump.ts +105 -0
  61. package/src/sessions/fork.ts +127 -0
  62. package/src/sessions/handlers.ts +158 -0
  63. package/src/sessions/harness.ts +167 -0
  64. package/src/sessions/index.ts +26 -0
  65. package/src/sessions/last-live.ts +111 -0
  66. package/src/sessions/processes.ts +413 -0
  67. package/src/sessions/registry.ts +785 -0
  68. package/src/sessions/search.ts +278 -0
  69. package/src/sessions/status.ts +209 -0
  70. package/src/sessions/terminals.ts +72 -0
  71. package/src/sessions/workspace.ts +140 -0
  72. package/src/topics/handlers.ts +42 -0
  73. package/src/topics/index.ts +2 -0
  74. package/src/topics/topics.ts +290 -0
  75. package/src/transcript/files.ts +201 -0
  76. package/src/transcript/fold.ts +833 -0
  77. package/src/transcript/index.ts +16 -0
  78. package/src/transcript/read.ts +82 -0
  79. package/src/transcript/tail.ts +195 -0
  80. package/src/transcript/transcripts.ts +162 -0
  81. package/src/translate/helper.ts +87 -0
  82. package/src/translate/index.ts +2 -0
  83. package/src/translate/translate.ts +127 -0
  84. package/src/transport/conn.ts +129 -0
  85. package/src/transport/dial.ts +65 -0
  86. package/src/transport/driver.ts +102 -0
  87. package/src/transport/entry.ts +39 -0
  88. package/src/transport/framing.ts +131 -0
  89. package/src/transport/index.ts +8 -0
  90. package/src/transport/listener.ts +39 -0
  91. package/src/transport/uds.ts +88 -0
  92. package/src/transport/ws.ts +170 -0
  93. package/src/upstream/events.ts +125 -0
  94. package/src/upstream/gateway.ts +275 -0
  95. package/src/upstream/index.ts +8 -0
  96. package/src/upstream/json.ts +81 -0
  97. package/src/upstream/requests.ts +234 -0
  98. package/src/upstream/stats.ts +99 -0
  99. package/src/upstream/status.ts +281 -0
  100. package/src/upstream/usage.ts +208 -0
  101. package/src/upstream/webhook.ts +141 -0
  102. package/src/version.ts +8 -0
@@ -0,0 +1,100 @@
1
+ import type { Endpoint } from "@ccmsg/protocol";
2
+ import { MESH_VER, randomId } from "./keys.ts";
3
+ import { probeEndpoint, type ProbeBody } from "./wire.ts";
4
+
5
+ /** How long a probe may take to come back.
6
+ *
7
+ * An endpoint that does not answer is either asleep or misconfigured, and
8
+ * waiting longer tells the two apart no better. It bounds startup rather than
9
+ * deciding correctness: only the probe this instance sends itself decides
10
+ * anything. */
11
+ export const PROBE_TIMEOUT_MS = 3_000;
12
+
13
+ /** The configured endpoints do not describe this instance.
14
+ *
15
+ * Its own class so startup can refuse the same way a broken config does (§8.3,
16
+ * DV-Q9): `self` is a setting, and one that does not reach this process is a
17
+ * setting that is wrong. */
18
+ export class SelfEndpointError extends Error {
19
+ constructor(msg: string) {
20
+ super(msg);
21
+ this.name = "SelfEndpointError";
22
+ }
23
+ }
24
+
25
+ /** What one round of probes found. */
26
+ export interface PeerReport {
27
+ /** The configured endpoints that turned out to be this instance — `self`,
28
+ * and any alias of it a peer list happens to name. They are not dialled: a
29
+ * link to ourselves is not a link. */
30
+ readonly ours: readonly Endpoint[];
31
+ /** The endpoints that did not answer. Recorded and not refused: a peer that
32
+ * is asleep is the normal state of this mesh (§7.1, DV-Q11). */
33
+ readonly unreachable: readonly Endpoint[];
34
+ }
35
+
36
+ /** The probes in flight, and the endpoint each was sent to.
37
+ *
38
+ * Since `self` is configured (DR-0001 §2.7), this no longer settles an identity:
39
+ * it checks the setting. The probe to `self` has to come back here, which is
40
+ * what catches a `self` that names somebody else, and the rest of the list is
41
+ * probed to record what can be reached before anything is dialled.
42
+ *
43
+ * The table is destroyed when the run finishes: what the exercise leaves behind
44
+ * is the report and nothing else (§7.3). */
45
+ export class PeerProbe {
46
+ #sent = new Map<string, Endpoint>();
47
+ readonly #matched = new Set<Endpoint>();
48
+
49
+ /** A probe arrived here. Answering is unconditional and holds no state: the
50
+ * comparison is the sender's, and this instance is the sender for exactly one
51
+ * of the probes it is currently answering (§5.1). */
52
+ accept(token: string): void {
53
+ const sentTo = this.#sent.get(token);
54
+ if (sentTo !== undefined) this.#matched.add(sentTo);
55
+ }
56
+
57
+ /** Ask `self` and every peer who answers there, and refuse to start if the
58
+ * endpoint this instance calls its own is somebody else's. */
59
+ async verify(self: Endpoint, peers: readonly Endpoint[]): Promise<PeerReport> {
60
+ const targets = [...new Set([self, ...peers])];
61
+ this.#sent = new Map(targets.map((target) => [randomId(), target]));
62
+ const unreachable: Endpoint[] = [];
63
+ await Promise.all(
64
+ [...this.#sent].map(async ([token, target]) => {
65
+ if (!(await this.#probe(target, token))) unreachable.push(target);
66
+ }),
67
+ );
68
+ const ours = [...this.#matched];
69
+ this.#sent = new Map();
70
+ this.#matched.clear();
71
+ if (!ours.includes(self)) {
72
+ throw new SelfEndpointError(
73
+ unreachable.includes(self)
74
+ ? `self is ${self}, which did not answer this instance's probe`
75
+ : `self is ${self}, which answers as another instance`,
76
+ );
77
+ }
78
+ return { ours, unreachable };
79
+ }
80
+
81
+ /** Whether the endpoint answered. What it answered does not matter: the
82
+ * comparison happens where the probe lands, not in its reply. */
83
+ async #probe(target: Endpoint, token: string): Promise<boolean> {
84
+ const body: ProbeBody = { ver: MESH_VER, token };
85
+ try {
86
+ const response = await fetch(probeEndpoint(target), {
87
+ method: "POST",
88
+ // Closed after the one round trip it is: a probe is sent once at
89
+ // startup, and a pooled connection kept open for it would outlive the
90
+ // exercise and hold the listener at the far end.
91
+ headers: { "content-type": "application/json", connection: "close" },
92
+ body: JSON.stringify(body),
93
+ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
94
+ });
95
+ return response.ok;
96
+ } catch {
97
+ return false;
98
+ }
99
+ }
100
+ }
@@ -0,0 +1,147 @@
1
+ import {
2
+ type InstanceId,
3
+ LAST_LIVE_RETENTION_MS,
4
+ type PeerInfo,
5
+ PLAIN_TOPICS,
6
+ type Sid,
7
+ TOPIC_ATTRIBUTES,
8
+ type Timestamp,
9
+ } from "@ccmsg/protocol";
10
+ import type { TopicValue } from "../topics/index.ts";
11
+
12
+ /** The topics a subscriber sees the whole cluster on.
13
+ *
14
+ * The per-instance whole is what makes a cluster view possible at all (§6.2):
15
+ * a frame replaces its own instance's entries and leaves every other
16
+ * instance's alone, so several instances can state the same topic name without
17
+ * colliding. A topic of any other granularity has no such rule and is not
18
+ * relayed — an `element` topic like `inbox` names one instance's topic while
19
+ * its value belongs to a session, and a frame of it carries no way to say
20
+ * whose it is. */
21
+ export const CLUSTER_TOPICS: readonly string[] = PLAIN_TOPICS.filter(
22
+ (topic) => TOPIC_ATTRIBUTES[topic].granularity === "per_instance_whole",
23
+ );
24
+
25
+ export function isClusterTopic(topic: string): boolean {
26
+ return CLUSTER_TOPICS.includes(topic);
27
+ }
28
+
29
+ export interface RelayDeps {
30
+ /** Hand a relayed frame to this instance's own subscribers, under the
31
+ * instance that produced it (§7.4). */
32
+ readonly publish: (topic: string, data: unknown, instance: InstanceId) => void;
33
+ /** The clock, so a test can move the retention window without waiting it
34
+ * out. */
35
+ readonly now?: () => Timestamp;
36
+ }
37
+
38
+ /** What the peers said, held on this instance so that losing a peer does not
39
+ * empty the cluster view (§7.5).
40
+ *
41
+ * Two things live here and nowhere else: the last whole value each instance
42
+ * stated per topic, and whether that instance can be reached right now. The
43
+ * suppression table of the topic mechanism is not the place for either — it
44
+ * is forgotten the moment a topic has no subscriber, while what a peer last
45
+ * said has to outlive both the subscription and the link.
46
+ *
47
+ * The retention window is read rather than swept: nothing here runs on a
48
+ * timer (M3), so a value past the window is dropped by the next read that
49
+ * would have returned it. */
50
+ export class Relay {
51
+ /** Per instance, the last whole value it stated per topic. */
52
+ readonly #held = new Map<InstanceId, Map<string, unknown>>();
53
+ /** The mark of §7.5: when this instance stopped being reachable. Absent
54
+ * while it is reachable. */
55
+ readonly #lostAt = new Map<InstanceId, Timestamp>();
56
+
57
+ constructor(private readonly deps: RelayDeps) {}
58
+
59
+ #now(): Timestamp {
60
+ return (this.deps.now ?? Date.now)();
61
+ }
62
+
63
+ /** A frame a peer pushed on a topic this instance relays.
64
+ *
65
+ * `instance` is the one that produced the value, which is not always the
66
+ * peer it arrived from: a mesh of three relays transitively, and the frame
67
+ * names its origin the whole way. Held under that origin, and passed on
68
+ * unchanged — recomputing it would put the same judgement in two places
69
+ * (§7.4). */
70
+ accept(instance: InstanceId, topic: string, data: unknown): void {
71
+ if (!isClusterTopic(topic)) return;
72
+ this.#sweep();
73
+ const held = this.#held.get(instance) ?? new Map<string, unknown>();
74
+ this.#held.set(instance, held);
75
+ held.set(topic, data);
76
+ this.deps.publish(topic, data, instance);
77
+ }
78
+
79
+ /** The link to this instance is gone. What it said is kept and marked,
80
+ * because dropping it would empty the view until the instance comes back
81
+ * and restates everything (§7.5). */
82
+ lost(instance: InstanceId): void {
83
+ if (!this.#lostAt.has(instance)) this.#lostAt.set(instance, this.#now());
84
+ this.#sweep();
85
+ }
86
+
87
+ /** The link is back. The mark goes, and what it said stands until the
88
+ * instance replaces it with the snapshot its subscriptions bring. */
89
+ restored(instance: InstanceId): void {
90
+ this.#lostAt.delete(instance);
91
+ }
92
+
93
+ /** Whether this instance is currently held as unreachable. */
94
+ unreachable(instance: InstanceId): boolean {
95
+ this.#sweep();
96
+ return this.#lostAt.has(instance);
97
+ }
98
+
99
+ /** The current value of a relayed topic, one entry per instance that has
100
+ * stated one. What a fresh local subscriber is handed beside this
101
+ * instance's own snapshot. */
102
+ snapshot(topic: string): readonly TopicValue[] {
103
+ this.#sweep();
104
+ const values: TopicValue[] = [];
105
+ for (const [instance, held] of this.#held) {
106
+ const data = held.get(topic);
107
+ if (data !== undefined) values.push({ instance, data });
108
+ }
109
+ return values;
110
+ }
111
+
112
+ /** Which instance a session belongs to, read from the `peers` values the
113
+ * cluster stated (§7.3).
114
+ *
115
+ * The row names its own instance rather than the one that relayed it, so a
116
+ * value that travelled through a third instance still points at the session's
117
+ * own. */
118
+ owner(sid: Sid): InstanceId | undefined {
119
+ this.#sweep();
120
+ for (const held of this.#held.values()) {
121
+ const value = held.get("peers") as { peers?: PeerInfo[] } | undefined;
122
+ const row = value?.peers?.find((peer) => peer.sid === sid);
123
+ if (row !== undefined) return row.instance;
124
+ }
125
+ return undefined;
126
+ }
127
+
128
+ /** Drop what an instance said once it has been gone for the retention
129
+ * window. The window is the contract's, shared with `last_live` and the
130
+ * inbox: past it, everything the value would point at is gone too
131
+ * (§7.5, DV-Q12). */
132
+ #sweep(): void {
133
+ const now = this.#now();
134
+ for (const [instance, since] of this.#lostAt) {
135
+ if (now - since <= LAST_LIVE_RETENTION_MS) continue;
136
+ this.#lostAt.delete(instance);
137
+ this.#held.delete(instance);
138
+ }
139
+ }
140
+
141
+ /** What is held right now, so a test can state that a disconnected
142
+ * instance's value is still there and that a swept one is not. */
143
+ get retained(): { instances: number; marked: number } {
144
+ this.#sweep();
145
+ return { instances: this.#held.size, marked: this.#lostAt.size };
146
+ }
147
+ }
@@ -0,0 +1,96 @@
1
+ import type { Endpoint } from "@ccmsg/protocol";
2
+ import type { MeshJwk } from "./keys.ts";
3
+
4
+ /** The paths an instance's endpoint URL stands in front of.
5
+ *
6
+ * An `Endpoint` is compared whole, path included (contract, `Endpoint`), so
7
+ * everything an instance serves hangs below it. That is also what keeps two
8
+ * instances sharing one origin apart: the key of `wss://h/a` is only ever
9
+ * fetched from below `/a`, so `wss://h/b` cannot answer for it and a proof made
10
+ * with b's key cannot pass as a's (mesh-peer-auth §6.3). The separation is the
11
+ * shape of the URLs rather than a rule written somewhere. */
12
+ const WS_PATH = "/ws";
13
+ const JWK_PATH = "/mesh/jwk/";
14
+ const PROBE_PATH = "/mesh/probe";
15
+
16
+ /** Where a peer's mesh link is dialled. */
17
+ export function wsEndpoint(endpoint: Endpoint): string {
18
+ return `${endpoint}${WS_PATH}`;
19
+ }
20
+
21
+ /** Where one connection's key is fetched, and the challenge for it left.
22
+ *
23
+ * `http` rather than `ws` because this is the second connection of §6, which
24
+ * carries one request and closes: the protocol asks that it be a connection of
25
+ * its own outside the one being authenticated, not that it be a WebSocket. */
26
+ export function jwkEndpoint(endpoint: Endpoint, kid: string): string {
27
+ return `${httpBase(endpoint)}${JWK_PATH}${encodeURIComponent(kid)}`;
28
+ }
29
+
30
+ export function probeEndpoint(endpoint: Endpoint): string {
31
+ return `${httpBase(endpoint)}${PROBE_PATH}`;
32
+ }
33
+
34
+ /** The `kid` a request names, or nothing when the path is not a key request. */
35
+ export function kidOfPath(pathname: string, self: Endpoint): string | undefined {
36
+ const prefix = `${new URL(self).pathname.replace(/\/$/, "")}${JWK_PATH}`;
37
+ if (!pathname.startsWith(prefix)) return undefined;
38
+ const kid = decodeURIComponent(pathname.slice(prefix.length));
39
+ return kid === "" ? undefined : kid;
40
+ }
41
+
42
+ export function isProbePath(pathname: string, self: Endpoint): boolean {
43
+ return pathname === `${new URL(self).pathname.replace(/\/$/, "")}${PROBE_PATH}`;
44
+ }
45
+
46
+ /** The same authority and path, reached over HTTP. A `ws` URL and the `http`
47
+ * one beside it are one server; the scheme differs and nothing else does. */
48
+ function httpBase(endpoint: Endpoint): string {
49
+ const url = new URL(endpoint);
50
+ url.protocol = url.protocol === "wss:" ? "https:" : "http:";
51
+ return url.href.replace(/\/$/, "");
52
+ }
53
+
54
+ /** The subprotocol a dialling instance offers.
55
+ *
56
+ * A peer is let through the handshake on this marker alone and proves who it is
57
+ * afterwards, where a claim can actually be checked: an unproven mesh
58
+ * connection is anonymous and can reach only the ops that need no identity at
59
+ * all. What the person\'s entry offers instead is a token bound to a passkey
60
+ * (DR-0001), which is theirs and not an instance\'s to hold. */
61
+ export const MESH_PROTOCOL = "ccmsg.mesh";
62
+
63
+ /** A frame on the mesh link that is not an op.
64
+ *
65
+ * The contract's op vocabulary holds no mesh ops — an op crosses instances by
66
+ * carrying the envelope's mesh fields (contract, `Plane`) — so the handshake's
67
+ * own traffic is spelled apart from it, under a key no request has. */
68
+ export type MeshFrame =
69
+ | { readonly mesh: "proof"; readonly jws: string }
70
+ | { readonly mesh: "ping" }
71
+ | { readonly mesh: "pong" };
72
+
73
+ export function meshFrameOf(frame: unknown): MeshFrame | undefined {
74
+ if (typeof frame !== "object" || frame === null) return undefined;
75
+ const kind = (frame as Record<string, unknown>)["mesh"];
76
+ if (kind === "ping" || kind === "pong") return { mesh: kind };
77
+ if (kind !== "proof") return undefined;
78
+ const jws = (frame as Record<string, unknown>)["jws"];
79
+ return typeof jws === "string" ? { mesh: "proof", jws } : undefined;
80
+ }
81
+
82
+ /** What a key request carries: the challenge the proof must sign (§6). */
83
+ export interface JwkRequest {
84
+ readonly ver: number;
85
+ readonly challenge: string;
86
+ }
87
+
88
+ export interface JwkResponse {
89
+ readonly jwk: MeshJwk;
90
+ }
91
+
92
+ /** What a self-identification probe carries (mesh-self-identification §5.1). */
93
+ export interface ProbeBody {
94
+ readonly ver: number;
95
+ readonly token: string;
96
+ }