@ccmsg/protocol 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yoshiaki Kawazu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,33 @@
1
+ # ccmsg-protocol
2
+
3
+ > 🇯🇵 [README-ja.md](./README-ja.md)
4
+
5
+ The **contract of record** shared by the ccmsg daemon and its web UI. It holds the shape of
6
+ every op, event, error code and identifier as a schema, and both sides validate against it.
7
+
8
+ The contract is decided here and the daemon and web UI follow it. Neither side learns the
9
+ other's internals by any route that does not pass through it.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ bun add @ccmsg/protocol
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ ```ts
20
+ import { isValid, MessageSendRequest, OP_ATTRIBUTES, opErrors } from "@ccmsg/protocol";
21
+
22
+ isValid(MessageSendRequest, incoming); // validating the wire is the contract's job
23
+ OP_ATTRIBUTES.message_send.roles; // authorization reads the table, not a branch
24
+ opErrors("session_rename"); // the codes this op may answer with
25
+ ```
26
+
27
+ ## Documentation
28
+
29
+ - [DESIGN.md](./docs/DESIGN.md) — layers, planes, the op attribute table, and the conventions
30
+
31
+ ## License
32
+
33
+ MIT License, Yoshiaki Kawazu (@kawaz)
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@ccmsg/protocol",
3
+ "version": "0.1.0",
4
+ "description": "Wire contract (schema + types + op attribute table) shared by the ccmsg daemon and web UI",
5
+ "license": "MIT",
6
+ "author": "kawaz",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/kawaz/ccmsg-protocol.git"
10
+ },
11
+ "files": [
12
+ "src"
13
+ ],
14
+ "type": "module",
15
+ "main": "./src/index.ts",
16
+ "exports": {
17
+ ".": "./src/index.ts"
18
+ },
19
+ "scripts": {
20
+ "typecheck": "tsc --noEmit",
21
+ "test": "bun test"
22
+ },
23
+ "dependencies": {
24
+ "@sinclair/typebox": "^0.34.33"
25
+ },
26
+ "devDependencies": {
27
+ "@types/bun": "^1.3.0",
28
+ "oxfmt": "0.58.0",
29
+ "oxlint": "1.73.0",
30
+ "oxlint-tsgolint": "0.24.0",
31
+ "typescript": "7.0.2"
32
+ }
33
+ }
@@ -0,0 +1,353 @@
1
+ import type { ErrorCode } from "./errors.ts";
2
+ import type { Capability, Role } from "./identifiers.ts";
3
+
4
+ /** Which face of the contract an op belongs to. `common` is the transport
5
+ * level (connect / subscribe), which every face uses. `mesh` holds no ops: an
6
+ * op crosses instances by carrying the envelope's mesh fields, not by being a
7
+ * different op. */
8
+ export type Plane = "common" | "messaging" | "control" | "mesh";
9
+
10
+ /** `instance-local` ops answer for one instance's processes, paths and
11
+ * handles, so they are forwarded to the instance that owns the subject.
12
+ * `cluster` ops are answerable by whichever instance is asked. */
13
+ export type Locality = "instance-local" | "cluster";
14
+
15
+ export interface OpAttributes {
16
+ readonly plane: Plane;
17
+ /** Roles allowed to call the op. A role outside this set gets `forbidden`. */
18
+ readonly roles: readonly Role[];
19
+ /** Whether the op requires an identity settled by `hello`. */
20
+ readonly needs_hello: boolean;
21
+ /** The capability the op needs, when it needs one. */
22
+ readonly capability?: Capability;
23
+ readonly locality: Locality;
24
+ /** Present when the role changes what the reply may contain rather than
25
+ * whether the call is allowed. */
26
+ readonly scope?: "role";
27
+ /** Codes specific to this op. The codes that follow from the attributes
28
+ * above are added by `opErrors` instead of being repeated here. */
29
+ readonly errors: readonly ErrorCode[];
30
+ }
31
+
32
+ const ALL_ROLES = ["session", "user", "instance"] as const;
33
+ const AGENT_AND_USER = ["session", "user"] as const;
34
+ const USER_ONLY = ["user"] as const;
35
+ const SESSION_ONLY = ["session"] as const;
36
+
37
+ /** The whole op vocabulary, with the attributes that decide who may call each
38
+ * op, what it needs, and where it runs. This table is the single place those
39
+ * facts live: authorization, capability gating and forwarding all read it
40
+ * rather than each carrying their own copy. */
41
+ export const OP_ATTRIBUTES = {
42
+ // --- common: connect and subscribe (5) ---
43
+ // `hello` and `instance_ping` address the instance the caller reached, so
44
+ // there is nothing to forward and no unreachable instance to report — which
45
+ // is why they are `cluster` despite answering about one instance.
46
+ hello: {
47
+ plane: "common",
48
+ roles: ALL_ROLES,
49
+ needs_hello: false,
50
+ locality: "cluster",
51
+ errors: [],
52
+ },
53
+ instance_ping: {
54
+ plane: "common",
55
+ roles: ALL_ROLES,
56
+ needs_hello: false,
57
+ locality: "cluster",
58
+ errors: [],
59
+ },
60
+ instance_shutdown: {
61
+ plane: "common",
62
+ roles: USER_ONLY,
63
+ needs_hello: true,
64
+ locality: "instance-local",
65
+ errors: [],
66
+ },
67
+ topic_subscribe: {
68
+ plane: "common",
69
+ roles: AGENT_AND_USER,
70
+ needs_hello: true,
71
+ locality: "cluster",
72
+ errors: ["topic_unknown"],
73
+ },
74
+ topic_unsubscribe: {
75
+ plane: "common",
76
+ roles: AGENT_AND_USER,
77
+ needs_hello: true,
78
+ locality: "cluster",
79
+ errors: ["topic_unknown"],
80
+ },
81
+
82
+ // --- messaging: one-to-one delivery (4) ---
83
+ message_send: {
84
+ plane: "messaging",
85
+ roles: AGENT_AND_USER,
86
+ needs_hello: true,
87
+ locality: "cluster",
88
+ errors: ["session_not_found"],
89
+ },
90
+ say_post: {
91
+ plane: "messaging",
92
+ roles: SESSION_ONLY,
93
+ needs_hello: true,
94
+ locality: "cluster",
95
+ errors: [],
96
+ },
97
+ say_mark_read: {
98
+ plane: "messaging",
99
+ roles: USER_ONLY,
100
+ needs_hello: true,
101
+ locality: "cluster",
102
+ errors: [],
103
+ },
104
+ notify_send: {
105
+ plane: "messaging",
106
+ roles: AGENT_AND_USER,
107
+ needs_hello: true,
108
+ locality: "cluster",
109
+ errors: [],
110
+ },
111
+
112
+ // --- control: session observation and operation (8) ---
113
+ session_kill: {
114
+ plane: "control",
115
+ roles: USER_ONLY,
116
+ needs_hello: true,
117
+ locality: "instance-local",
118
+ errors: ["session_not_found"],
119
+ },
120
+ session_rename: {
121
+ plane: "control",
122
+ roles: USER_ONLY,
123
+ needs_hello: true,
124
+ capability: "terminal",
125
+ locality: "instance-local",
126
+ errors: ["session_not_found"],
127
+ },
128
+ session_env_read: {
129
+ plane: "control",
130
+ roles: USER_ONLY,
131
+ needs_hello: true,
132
+ locality: "instance-local",
133
+ errors: ["session_not_found"],
134
+ },
135
+ session_search: {
136
+ plane: "control",
137
+ roles: USER_ONLY,
138
+ needs_hello: true,
139
+ locality: "instance-local",
140
+ errors: [],
141
+ },
142
+ session_dump_write: {
143
+ plane: "control",
144
+ roles: USER_ONLY,
145
+ needs_hello: true,
146
+ locality: "instance-local",
147
+ errors: ["not_found"],
148
+ },
149
+ transcript_read: {
150
+ plane: "control",
151
+ roles: AGENT_AND_USER,
152
+ needs_hello: true,
153
+ locality: "instance-local",
154
+ scope: "role",
155
+ errors: ["not_found"],
156
+ },
157
+ session_fork_origin: {
158
+ plane: "control",
159
+ roles: USER_ONLY,
160
+ needs_hello: true,
161
+ capability: "fork",
162
+ locality: "instance-local",
163
+ errors: ["not_found"],
164
+ },
165
+ session_last_live_remove: {
166
+ plane: "control",
167
+ roles: USER_ONLY,
168
+ needs_hello: true,
169
+ locality: "instance-local",
170
+ errors: [],
171
+ },
172
+
173
+ // --- control: file access (9) ---
174
+ dir_list: {
175
+ plane: "control",
176
+ roles: AGENT_AND_USER,
177
+ needs_hello: true,
178
+ locality: "instance-local",
179
+ scope: "role",
180
+ errors: ["path_forbidden", "not_found"],
181
+ },
182
+ file_read: {
183
+ plane: "control",
184
+ roles: AGENT_AND_USER,
185
+ needs_hello: true,
186
+ locality: "instance-local",
187
+ scope: "role",
188
+ errors: ["path_forbidden", "not_found"],
189
+ },
190
+ file_write: {
191
+ plane: "control",
192
+ roles: USER_ONLY,
193
+ needs_hello: true,
194
+ locality: "instance-local",
195
+ errors: ["path_not_writable", "file_exists"],
196
+ },
197
+ file_create: {
198
+ plane: "control",
199
+ roles: USER_ONLY,
200
+ needs_hello: true,
201
+ locality: "instance-local",
202
+ errors: ["file_exists", "path_forbidden"],
203
+ },
204
+ file_edit: {
205
+ plane: "control",
206
+ roles: USER_ONLY,
207
+ needs_hello: true,
208
+ locality: "instance-local",
209
+ errors: ["file_conflict", "not_a_text_file"],
210
+ },
211
+ file_delete: {
212
+ plane: "control",
213
+ roles: USER_ONLY,
214
+ needs_hello: true,
215
+ locality: "instance-local",
216
+ errors: ["path_forbidden", "not_found"],
217
+ },
218
+ file_find: {
219
+ plane: "control",
220
+ roles: USER_ONLY,
221
+ needs_hello: true,
222
+ locality: "instance-local",
223
+ errors: ["path_forbidden"],
224
+ },
225
+ file_stat_batch: {
226
+ plane: "control",
227
+ roles: USER_ONLY,
228
+ needs_hello: true,
229
+ locality: "instance-local",
230
+ errors: [],
231
+ },
232
+ dir_tree: {
233
+ plane: "control",
234
+ roles: USER_ONLY,
235
+ needs_hello: true,
236
+ capability: "launcher",
237
+ locality: "instance-local",
238
+ errors: [],
239
+ },
240
+
241
+ // --- control: launcher / sandbox / translate / llm (7) ---
242
+ launcher_config_read: {
243
+ plane: "control",
244
+ roles: USER_ONLY,
245
+ needs_hello: true,
246
+ capability: "launcher",
247
+ locality: "instance-local",
248
+ errors: [],
249
+ },
250
+ launcher_run: {
251
+ plane: "control",
252
+ roles: USER_ONLY,
253
+ needs_hello: true,
254
+ capability: "launcher",
255
+ locality: "instance-local",
256
+ errors: [],
257
+ },
258
+ sandbox_grant: {
259
+ plane: "control",
260
+ roles: USER_ONLY,
261
+ needs_hello: true,
262
+ capability: "sandbox",
263
+ locality: "instance-local",
264
+ errors: ["path_forbidden"],
265
+ },
266
+ sandbox_revoke: {
267
+ plane: "control",
268
+ roles: USER_ONLY,
269
+ needs_hello: true,
270
+ capability: "sandbox",
271
+ locality: "instance-local",
272
+ errors: [],
273
+ },
274
+ translate_run: {
275
+ plane: "control",
276
+ roles: USER_ONLY,
277
+ needs_hello: true,
278
+ capability: "translate",
279
+ locality: "instance-local",
280
+ errors: ["translate_helper_failed"],
281
+ },
282
+ llm_usage_read: {
283
+ plane: "control",
284
+ roles: USER_ONLY,
285
+ needs_hello: true,
286
+ capability: "llm_usage",
287
+ locality: "instance-local",
288
+ errors: [],
289
+ },
290
+ llm_stats_read: {
291
+ plane: "control",
292
+ roles: USER_ONLY,
293
+ needs_hello: true,
294
+ capability: "llm_stats",
295
+ locality: "instance-local",
296
+ errors: [],
297
+ },
298
+
299
+ // --- control: the shared key-value store (3) ---
300
+ // The only control ops that are not instance-local: a value is held by every
301
+ // instance rather than by one, so whichever is asked can answer.
302
+ kv_read: {
303
+ plane: "control",
304
+ roles: USER_ONLY,
305
+ needs_hello: true,
306
+ locality: "cluster",
307
+ errors: ["not_found"],
308
+ },
309
+ kv_write: {
310
+ plane: "control",
311
+ roles: USER_ONLY,
312
+ needs_hello: true,
313
+ locality: "cluster",
314
+ errors: [],
315
+ },
316
+ kv_delete: {
317
+ plane: "control",
318
+ roles: USER_ONLY,
319
+ needs_hello: true,
320
+ locality: "cluster",
321
+ errors: [],
322
+ },
323
+ } as const satisfies Record<string, OpAttributes>;
324
+
325
+ export type OpName = keyof typeof OP_ATTRIBUTES;
326
+
327
+ export const OP_NAMES = Object.keys(OP_ATTRIBUTES) as OpName[];
328
+
329
+ export function opAttributes(op: OpName): OpAttributes {
330
+ return OP_ATTRIBUTES[op];
331
+ }
332
+
333
+ /** Every code the op may answer with: the ones it declares, plus the ones its
334
+ * attributes imply. Keeping the implied codes out of the table means adding a
335
+ * capability to an op cannot leave its error list stale. */
336
+ export function opErrors(op: OpName): ErrorCode[] {
337
+ const attrs: OpAttributes = OP_ATTRIBUTES[op];
338
+ const codes = new Set<ErrorCode>(["invalid_args", ...attrs.errors]);
339
+ if (attrs.needs_hello) codes.add("hello_required");
340
+ if (attrs.roles.length < ALL_ROLES.length) codes.add("forbidden");
341
+ if (attrs.capability !== undefined) codes.add("capability_unavailable");
342
+ if (attrs.locality === "instance-local") codes.add("instance_unreachable");
343
+ return [...codes];
344
+ }
345
+
346
+ /** Whether a connection speaking `role` may call `op`. */
347
+ export function isRoleAllowed(op: OpName, role: Role): boolean {
348
+ return (OP_ATTRIBUTES[op].roles as readonly Role[]).includes(role);
349
+ }
350
+
351
+ export function opsOfPlane(plane: Plane): OpName[] {
352
+ return OP_NAMES.filter((op) => OP_ATTRIBUTES[op].plane === plane);
353
+ }
@@ -0,0 +1,73 @@
1
+ import { type Static, Type } from "@sinclair/typebox";
2
+ import { request, response } from "../envelope.ts";
3
+ import { Capability, InstanceId, Role, Sid, Timestamp } from "../identifiers.ts";
4
+
5
+ /** The mesh handshake's opening claim, carried by a `role: "instance"` hello.
6
+ *
7
+ * It is not signed and proves nothing on its own: it names the peer and says
8
+ * where its one-off key can be fetched. The proof that binds this connection to
9
+ * `iss` follows on a separate exchange (mesh-peer-auth §5). */
10
+ export const MeshHello = Type.Object(
11
+ {
12
+ /** Generation of the mesh handshake format, apart from the protocol
13
+ * generation so the handshake can change without the wire changing. */
14
+ ver: Type.Integer({ minimum: 1 }),
15
+ /** The endpoint URL the connecting instance claims to be. */
16
+ iss: InstanceId,
17
+ /** The endpoint URL it believes it is connecting to. Compared whole
18
+ * against the receiver's own URL, which is what stops a signature made for
19
+ * one instance from being replayed at another on the same host. */
20
+ aud: InstanceId,
21
+ /** Names the ephemeral key the receiver is to fetch for this connection. */
22
+ kid: Type.String({ minLength: 16 }),
23
+ },
24
+ { $id: "MeshHello" },
25
+ );
26
+ export type MeshHello = Static<typeof MeshHello>;
27
+
28
+ export const HelloArgs = Type.Object({
29
+ role: Role,
30
+ /** The generation the caller speaks. A hello announcing another generation
31
+ * is refused with `bad_request`; there is no path that serves it anyway. */
32
+ protocol_version: Type.Integer({ minimum: 1 }),
33
+ /** Required for `role: "session"`: the session the connection speaks for. */
34
+ sid: Type.Optional(Sid),
35
+ /** Required for `role: "instance"`: the mesh handshake claim. */
36
+ mesh: Type.Optional(MeshHello),
37
+ /** The client build, for display in diagnostics. Nothing gates on it. */
38
+ client_version: Type.Optional(Type.String()),
39
+ });
40
+ export type HelloArgs = Static<typeof HelloArgs>;
41
+
42
+ /** One instance as seen from the instance answering `hello`. */
43
+ export const InstanceInfo = Type.Object(
44
+ {
45
+ id: InstanceId,
46
+ /** The host it runs on. An attribute of the instance, not its identity —
47
+ * one host may run several instances. */
48
+ host: Type.String({ minLength: 1 }),
49
+ /** Whether the answering instance can currently reach it. */
50
+ reachable: Type.Boolean(),
51
+ },
52
+ { $id: "InstanceInfo" },
53
+ );
54
+ export type InstanceInfo = Static<typeof InstanceInfo>;
55
+
56
+ export const HelloResult = Type.Object({
57
+ protocol_version: Type.Integer({ minimum: 1 }),
58
+ /** The instance answering. Every other id in the reply is relative to it. */
59
+ instance: InstanceId,
60
+ /** The instances this one knows of, itself included. */
61
+ instances: Type.Array(InstanceInfo),
62
+ /** What this instance can do. An op whose `capability` is absent here
63
+ * answers `capability_unavailable`, so a client can tell which ops are worth
64
+ * offering before it calls any of them. */
65
+ capabilities: Type.Array(Capability),
66
+ /** The daemon build, for display. */
67
+ version: Type.String(),
68
+ started_at: Timestamp,
69
+ });
70
+ export type HelloResult = Static<typeof HelloResult>;
71
+
72
+ export const HelloRequest = request("hello", HelloArgs);
73
+ export const HelloResponse = response("hello", HelloResult);
@@ -0,0 +1,37 @@
1
+ import { type Static, Type } from "@sinclair/typebox";
2
+ import { request, response } from "../envelope.ts";
3
+ import { InstanceId, Timestamp } from "../identifiers.ts";
4
+
5
+ export const InstancePingArgs = Type.Object({});
6
+ export type InstancePingArgs = Static<typeof InstancePingArgs>;
7
+
8
+ /** How the answering daemon process is running.
9
+ *
10
+ * This is about one process, which is why the op is instance-local: the health
11
+ * of the cluster is `hello`'s `instances[]`, not a ping fanned out. */
12
+ export const InstancePingResult = Type.Object({
13
+ instance: InstanceId,
14
+ version: Type.String(),
15
+ pid: Type.Integer({ minimum: 1 }),
16
+ started_at: Timestamp,
17
+ /** Connected clients right now. */
18
+ clients: Type.Integer({ minimum: 0 }),
19
+ /** The interpreter and entry script the daemon runs from. Which plugin cache
20
+ * the entry script sits in is what tells two same-host instances apart when
21
+ * one is running a stale build. */
22
+ exe: Type.Optional(Type.String()),
23
+ script: Type.Optional(Type.String()),
24
+ /** Bound HTTP/WS addresses as `host:port`; empty when HTTP is off. */
25
+ http: Type.Array(Type.String()),
26
+ /** The daemon's current view of the host link. */
27
+ network: Type.Union([
28
+ Type.Literal("off"),
29
+ Type.Literal("unknown"),
30
+ Type.Literal("online"),
31
+ Type.Literal("offline"),
32
+ ]),
33
+ });
34
+ export type InstancePingResult = Static<typeof InstancePingResult>;
35
+
36
+ export const InstancePingRequest = request("instance_ping", InstancePingArgs);
37
+ export const InstancePingResponse = response("instance_ping", InstancePingResult);
@@ -0,0 +1,13 @@
1
+ import { type Static, Type } from "@sinclair/typebox";
2
+ import { request, response } from "../envelope.ts";
3
+
4
+ export const InstanceShutdownArgs = Type.Object({});
5
+ export type InstanceShutdownArgs = Static<typeof InstanceShutdownArgs>;
6
+
7
+ /** Answered before the process goes down, so the caller learns the request was
8
+ * accepted rather than inferring it from the socket closing. */
9
+ export const InstanceShutdownResult = Type.Object({});
10
+ export type InstanceShutdownResult = Static<typeof InstanceShutdownResult>;
11
+
12
+ export const InstanceShutdownRequest = request("instance_shutdown", InstanceShutdownArgs);
13
+ export const InstanceShutdownResponse = response("instance_shutdown", InstanceShutdownResult);
@@ -0,0 +1,99 @@
1
+ import { type Static, Type } from "@sinclair/typebox";
2
+ import { request, response } from "../envelope.ts";
3
+ import type { Capability, Role } from "../identifiers.ts";
4
+
5
+ /** Topics naming one thing for the whole instance. */
6
+ export const PLAIN_TOPICS = [
7
+ "inbox",
8
+ "notify",
9
+ "peers",
10
+ "agents",
11
+ "session_errors",
12
+ "llm_requests",
13
+ "llm_status",
14
+ ] as const;
15
+
16
+ /** Topics naming one session, written `<topic>:<sid>`. */
17
+ export const SESSION_SCOPED_TOPICS = ["session_status", "transcript"] as const;
18
+
19
+ /** Topics naming one namespace, written `<topic>:<ns>`. The parameter is a name
20
+ * its users choose rather than an identifier this contract issues, so it is
21
+ * spelled apart from the session-scoped topics above. */
22
+ export const NAMESPACE_SCOPED_TOPICS = ["kv"] as const;
23
+
24
+ /** What a namespace may be called. Kept to an identifier because the name
25
+ * appears inside a topic name, where a separator or a space would make the two
26
+ * halves impossible to tell apart. */
27
+ export const NAMESPACE_PATTERN = "[a-z][a-z0-9_]{0,63}";
28
+
29
+ export type PlainTopic = (typeof PLAIN_TOPICS)[number];
30
+ export type SessionScopedTopic = (typeof SESSION_SCOPED_TOPICS)[number];
31
+ export type NamespaceScopedTopic = (typeof NAMESPACE_SCOPED_TOPICS)[number];
32
+ export type TopicName =
33
+ | PlainTopic
34
+ | `${SessionScopedTopic}:${string}`
35
+ | `${NamespaceScopedTopic}:${string}`;
36
+
37
+ export const Topic = Type.String({
38
+ $id: "Topic",
39
+ pattern: [
40
+ "^(?:",
41
+ PLAIN_TOPICS.join("|"),
42
+ `|(?:${SESSION_SCOPED_TOPICS.join("|")}):[0-9a-f-]{36}`,
43
+ `|(?:${NAMESPACE_SCOPED_TOPICS.join("|")}):${NAMESPACE_PATTERN}`,
44
+ ")$",
45
+ ].join(""),
46
+ });
47
+
48
+ export interface TopicAttributes {
49
+ readonly roles: readonly Role[];
50
+ readonly capability?: Capability;
51
+ }
52
+
53
+ /** Who may subscribe to what. The same question the op table answers for ops:
54
+ * a subscribe from a role outside the set answers `forbidden`, and one naming
55
+ * a capability the instance lacks answers `capability_unavailable`. */
56
+ export const TOPIC_ATTRIBUTES = {
57
+ inbox: { roles: ["session", "user"] },
58
+ notify: { roles: ["session", "user"] },
59
+ peers: { roles: ["session", "user"] },
60
+ agents: { roles: ["user"] },
61
+ session_errors: { roles: ["user"] },
62
+ llm_requests: { roles: ["user"], capability: "llm_events" },
63
+ llm_status: { roles: ["user"], capability: "llm_status" },
64
+ session_status: { roles: ["user"] },
65
+ transcript: { roles: ["user"] },
66
+ kv: { roles: ["user"] },
67
+ } as const satisfies Record<
68
+ PlainTopic | SessionScopedTopic | NamespaceScopedTopic,
69
+ TopicAttributes
70
+ >;
71
+
72
+ export type TopicKind = PlainTopic | SessionScopedTopic | NamespaceScopedTopic;
73
+
74
+ /** The part of a topic name before any `:` — the key into TOPIC_ATTRIBUTES. */
75
+ export function topicKind(topic: string): TopicKind | undefined {
76
+ const head = topic.split(":", 1)[0] as TopicKind;
77
+ return head in TOPIC_ATTRIBUTES ? head : undefined;
78
+ }
79
+
80
+ export const TopicSubscribeArgs = Type.Object({ topic: Topic });
81
+ export type TopicSubscribeArgs = Static<typeof TopicSubscribeArgs>;
82
+
83
+ /** The reply only acknowledges the subscription. The value itself arrives as
84
+ * the first frame, marked `snapshot: true`, so a subscriber has one code path
85
+ * for the current value and for every change after it. */
86
+ export const TopicSubscribeResult = Type.Object({ topic: Topic });
87
+ export type TopicSubscribeResult = Static<typeof TopicSubscribeResult>;
88
+
89
+ export const TopicSubscribeRequest = request("topic_subscribe", TopicSubscribeArgs);
90
+ export const TopicSubscribeResponse = response("topic_subscribe", TopicSubscribeResult);
91
+
92
+ export const TopicUnsubscribeArgs = Type.Object({ topic: Topic });
93
+ export type TopicUnsubscribeArgs = Static<typeof TopicUnsubscribeArgs>;
94
+
95
+ export const TopicUnsubscribeResult = Type.Object({ topic: Topic });
96
+ export type TopicUnsubscribeResult = Static<typeof TopicUnsubscribeResult>;
97
+
98
+ export const TopicUnsubscribeRequest = request("topic_unsubscribe", TopicUnsubscribeArgs);
99
+ export const TopicUnsubscribeResponse = response("topic_unsubscribe", TopicUnsubscribeResult);