@ccmsg/protocol 1.19.0 → 1.21.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.
@@ -3,7 +3,7 @@ import { request, response } from "../envelope.ts";
3
3
  import { Capability, Endpoint, InstanceId, Role, Sid, Timestamp } from "../identifiers.ts";
4
4
  import { SessionMetaFields } from "../session-meta.ts";
5
5
 
6
- /** The mesh handshake's opening claim, carried by a `role: "instance"` hello.
6
+ /** The mesh handshake's opening claim, carried by `hello.instance`.
7
7
  *
8
8
  * It is not signed and proves nothing on its own: it names the peer and says
9
9
  * where its one-off key can be fetched. The proof that binds this connection to
@@ -36,42 +36,39 @@ export const MeshHello = Type.Object(
36
36
  );
37
37
  export type MeshHello = Static<typeof MeshHello>;
38
38
 
39
- /** The greeting that settles what a connection is.
40
- *
41
- * Which of the fields below are required is decided by `role`, which no single
42
- * object schema can state so the instance checks it, and a greeting that
43
- * breaks one of these is refused with `invalid_args`:
44
- *
45
- * - `role: "session"` carries `sid`.
46
- * - `role: "user"` carries no `sid`: a person speaks for no one session, and a
47
- * greeting that names one is refused rather than quietly ignored.
48
- * - `role: "instance"` carries `mesh`.
39
+ /** What every greeting carries, whichever of the three it is. */
40
+ const GREETING_FIELDS = {
41
+ /** The generation the caller speaks. A hello announcing another generation
42
+ * is refused with `bad_request`; there is no path that serves it anyway. */
43
+ protocol_version: Type.Integer({ minimum: 1 }),
44
+ /** The client build, for display in diagnostics. Nothing gates on it. */
45
+ client_version: Type.Optional(Type.String()),
46
+ };
47
+
48
+ /** The greeting that settles what a connection is. There is one op per role,
49
+ * so what a greeting must carry is the schema's to state rather than a check
50
+ * the instance runs against a `role` field: a session names its `sid`, an
51
+ * instance its mesh claim, and a person neither.
49
52
  *
50
- * A field that belongs to another role is as much a refusal as a missing one:
53
+ * A field that belongs to another role is as much a refusal as a missing one
51
54
  * `mesh` on a session greeting says the caller has confused which handshake it
52
- * is in, and accepting it would leave the connection settled as something
53
- * neither side meant.
55
+ * is in which is what the three ops keep apart: a greeting arrives under the
56
+ * name of the thing it settles, and there is no state in which the name and the
57
+ * fields disagree.
54
58
  *
55
59
  * A session's meta (`cwd`, `repo_root`, `transcript_path`, `title`, ...) is
56
60
  * taken field by field: a greeting that leaves a field out does not withdraw
57
61
  * it, and the instance keeps what it already knows for that `sid`. One session
58
62
  * reaches an instance as a run of short-lived processes (a session-start hook,
59
63
  * a `post`, a session-end hook), none of which knows every field. */
60
- export const HelloArgs = Type.Object({
61
- role: Role,
62
- /** The generation the caller speaks. A hello announcing another generation
63
- * is refused with `bad_request`; there is no path that serves it anyway. */
64
- protocol_version: Type.Integer({ minimum: 1 }),
65
- /** Required for `role: "session"`: the session the connection speaks for. */
66
- sid: Type.Optional(Sid),
67
- /** Required for `role: "instance"`: the mesh handshake claim. */
68
- mesh: Type.Optional(MeshHello),
69
- /** The client build, for display in diagnostics. Nothing gates on it. */
70
- client_version: Type.Optional(Type.String()),
71
- /** What a `role: "session"` connection says about itself. All optional: a
72
- * session states what it knows, and the instance derives or leaves unknown
73
- * what it is not told. The instance repeats these on the `peers` topic, so
74
- * they are the same fields under the same names there. */
64
+ export const HelloSessionArgs = Type.Object({
65
+ ...GREETING_FIELDS,
66
+ /** The session the connection speaks for. */
67
+ sid: Sid,
68
+ /** What the session says about itself. All optional: a session states what it
69
+ * knows, and the instance derives or leaves unknown what it is not told. The
70
+ * instance repeats these on the `peers` topic, so they are the same fields
71
+ * under the same names there. */
75
72
  repo: Type.Optional(SessionMetaFields.repo),
76
73
  ws: Type.Optional(SessionMetaFields.ws),
77
74
  cwd: Type.Optional(SessionMetaFields.cwd),
@@ -82,9 +79,22 @@ export const HelloArgs = Type.Object({
82
79
  model: Type.Optional(SessionMetaFields.model),
83
80
  effort: Type.Optional(SessionMetaFields.effort),
84
81
  });
85
- export type HelloArgs = Static<typeof HelloArgs>;
82
+ export type HelloSessionArgs = Static<typeof HelloSessionArgs>;
83
+
84
+ /** A person greeting. It names no session: a person speaks for none of them,
85
+ * and the sessions they may act on are decided by the op table rather than by
86
+ * anything settled here. */
87
+ export const HelloUserArgs = Type.Object(GREETING_FIELDS);
88
+ export type HelloUserArgs = Static<typeof HelloUserArgs>;
89
+
90
+ /** A peer instance greeting, which opens the mesh handshake. */
91
+ export const HelloInstanceArgs = Type.Object({
92
+ ...GREETING_FIELDS,
93
+ mesh: MeshHello,
94
+ });
95
+ export type HelloInstanceArgs = Static<typeof HelloInstanceArgs>;
86
96
 
87
- /** One instance as seen from the instance answering `hello`. */
97
+ /** One instance as seen from the instance answering a greeting. */
88
98
  export const InstanceInfo = Type.Object(
89
99
  {
90
100
  /** Absent until the handshake with it has settled: an endpoint an operator
@@ -148,10 +158,38 @@ export const HelloResult = Type.Object({
148
158
  * closes it. Present on a connection an access token opened; absent where
149
159
  * reaching the instance is itself the permission (the Unix socket) or where
150
160
  * the connection is a mesh link. The person's client renews before this
151
- * instant with `auth_refresh` rather than reconnecting. */
161
+ * instant with `auth.extend` rather than reconnecting. */
152
162
  auth_expires_at: Type.Optional(Timestamp),
153
163
  });
154
164
  export type HelloResult = Static<typeof HelloResult>;
155
165
 
156
- export const HelloRequest = request("hello", HelloArgs);
157
- export const HelloResponse = response("hello", HelloResult);
166
+ export const HelloSessionRequest = request("hello.session", HelloSessionArgs);
167
+ export const HelloSessionResponse = response("hello.session", HelloResult);
168
+
169
+ export const HelloUserRequest = request("hello.user", HelloUserArgs);
170
+ export const HelloUserResponse = response("hello.user", HelloResult);
171
+
172
+ export const HelloInstanceRequest = request("hello.instance", HelloInstanceArgs);
173
+ export const HelloInstanceResponse = response("hello.instance", HelloResult);
174
+
175
+ /** The role a greeting settles, read from the op that carried it.
176
+ *
177
+ * The role itself stays — the op table and the topic table are written against
178
+ * it, and a forwarded request names it in `caller` — and what a greeting no
179
+ * longer carries is a second place to say it. */
180
+ export const HELLO_OPS = {
181
+ "hello.session": "session",
182
+ "hello.user": "user",
183
+ "hello.instance": "instance",
184
+ } as const satisfies Record<string, Role>;
185
+
186
+ export type HelloOp = keyof typeof HELLO_OPS;
187
+
188
+ export function isHelloOp(op: string): op is HelloOp {
189
+ return op in HELLO_OPS;
190
+ }
191
+
192
+ /** The role a connection speaks once `op` has been answered. */
193
+ export function helloRole(op: HelloOp): Role {
194
+ return HELLO_OPS[op];
195
+ }
@@ -33,5 +33,5 @@ export const InstancePingResult = Type.Object({
33
33
  });
34
34
  export type InstancePingResult = Static<typeof InstancePingResult>;
35
35
 
36
- export const InstancePingRequest = request("instance_ping", InstancePingArgs);
37
- export const InstancePingResponse = response("instance_ping", InstancePingResult);
36
+ export const InstancePingRequest = request("instance.ping", InstancePingArgs);
37
+ export const InstancePingResponse = response("instance.ping", InstancePingResult);
@@ -11,8 +11,8 @@ export type InstanceShutdownArgs = Static<typeof InstanceShutdownArgs>;
11
11
  export const InstanceShutdownResult = Type.Object({});
12
12
  export type InstanceShutdownResult = Static<typeof InstanceShutdownResult>;
13
13
 
14
- export const InstanceShutdownRequest = request("instance_shutdown", InstanceShutdownArgs);
15
- export const InstanceShutdownResponse = response("instance_shutdown", InstanceShutdownResult);
14
+ export const InstanceShutdownRequest = request("instance.shutdown", InstanceShutdownArgs);
15
+ export const InstanceShutdownResponse = response("instance.shutdown", InstanceShutdownResult);
16
16
 
17
17
  /** A session saying it is about to go, so that its disconnection reads as a
18
18
  * pause rather than a loss.
@@ -44,5 +44,5 @@ export const SessionStoppingResult = Type.Object({
44
44
  });
45
45
  export type SessionStoppingResult = Static<typeof SessionStoppingResult>;
46
46
 
47
- export const SessionStoppingRequest = request("session_stopping", SessionStoppingArgs);
48
- export const SessionStoppingResponse = response("session_stopping", SessionStoppingResult);
47
+ export const SessionStoppingRequest = request("session.stopping", SessionStoppingArgs);
48
+ export const SessionStoppingResponse = response("session.stopping", SessionStoppingResult);
@@ -7,15 +7,16 @@ export const PLAIN_TOPICS = [
7
7
  "inbox",
8
8
  "notify",
9
9
  "peers",
10
+ "instances",
10
11
  "agents",
11
- "session_errors",
12
- "llm_requests",
13
- "llm_status",
14
- "auth_records",
12
+ "session.errors",
13
+ "llm.requests",
14
+ "llm.status",
15
+ "auth.records",
15
16
  ] as const;
16
17
 
17
18
  /** Topics naming one session, written `<topic>:<sid>`. */
18
- export const SESSION_SCOPED_TOPICS = ["session_status", "transcript", "transcript_items"] as const;
19
+ export const SESSION_SCOPED_TOPICS = ["session.status", "transcript", "transcript.items"] as const;
19
20
 
20
21
  /** Topics naming one namespace, written `<topic>:<ns>`. The parameter is a name
21
22
  * its users choose rather than an identifier this contract issues, so it is
@@ -35,13 +36,18 @@ export type TopicName =
35
36
  | `${SessionScopedTopic}:${string}`
36
37
  | `${NamespaceScopedTopic}:${string}`;
37
38
 
39
+ /** A topic name is a `.`-separated hierarchy, so its dots are literal here
40
+ * rather than the regexp's any-character. */
41
+ const alternation = (topics: readonly string[]): string =>
42
+ topics.map((topic) => topic.replace(/\./g, "\\.")).join("|");
43
+
38
44
  export const Topic = Type.String({
39
45
  $id: "Topic",
40
46
  pattern: [
41
47
  "^(?:",
42
- PLAIN_TOPICS.join("|"),
43
- `|(?:${SESSION_SCOPED_TOPICS.join("|")}):[0-9a-f-]{36}`,
44
- `|(?:${NAMESPACE_SCOPED_TOPICS.join("|")}):${NAMESPACE_PATTERN}`,
48
+ alternation(PLAIN_TOPICS),
49
+ `|(?:${alternation(SESSION_SCOPED_TOPICS)}):[0-9a-f-]{36}`,
50
+ `|(?:${alternation(NAMESPACE_SCOPED_TOPICS)}):${NAMESPACE_PATTERN}`,
45
51
  ")$",
46
52
  ].join(""),
47
53
  });
@@ -89,29 +95,38 @@ export interface TopicAttributes {
89
95
  export const TOPIC_ATTRIBUTES = {
90
96
  inbox: { roles: ["session", "user"], granularity: "element" },
91
97
  notify: { roles: ["session", "user"], granularity: "event" },
92
- peers: { roles: ["session", "user"], granularity: "per_instance_whole" },
93
- agents: { roles: ["user"], granularity: "per_instance_whole" },
94
- session_errors: { roles: ["user"], granularity: "per_instance_whole" },
95
- llm_requests: {
98
+ // A row here changes on its own — one session becomes busy while the rest
99
+ // stand still so a frame carries the rows that changed rather than every
100
+ // row the instance knows.
101
+ peers: { roles: ["session", "user"], granularity: "element" },
102
+ // One instance's whole reading of its links, taken together, which is why it
103
+ // is apart from the rows of `peers`.
104
+ instances: { roles: ["session", "user"], granularity: "per_instance_whole" },
105
+ agents: { roles: ["user"], granularity: "element" },
106
+ // A set the instance derives whole, by folding one error pattern over its
107
+ // sessions: it learns which sessions are stopped, not that one of them
108
+ // changed, so each frame is that reading entire.
109
+ "session.errors": { roles: ["user"], granularity: "per_instance_whole" },
110
+ "llm.requests": {
96
111
  roles: ["user"],
97
112
  capability: "llm_events",
98
113
  granularity: "per_instance_whole",
99
114
  },
100
- llm_status: { roles: ["user"], capability: "llm_status", granularity: "per_instance_whole" },
115
+ "llm.status": { roles: ["user"], capability: "llm_status", granularity: "per_instance_whole" },
101
116
  // One session lives on one instance, so its status has no other instance's
102
117
  // half to leave alone: the frame is simply the whole of it.
103
- session_status: { roles: ["user"], granularity: "whole" },
118
+ "session.status": { roles: ["user"], granularity: "whole" },
104
119
  transcript: { roles: ["user"], granularity: "append" },
105
120
  // The same appending, in items rather than in bytes. Both are offered because
106
121
  // they answer different needs: one draws the conversation, the other shows a
107
122
  // record as it was written.
108
- transcript_items: { roles: ["user"], granularity: "append" },
123
+ "transcript.items": { roles: ["user"], granularity: "append" },
109
124
  kv: { roles: ["user"], granularity: "element" },
110
125
  // The only topic no person may subscribe to: its elements are the secrets
111
126
  // that authenticate them. A relay carries it as the instance it is, not on a
112
127
  // caller's behalf, so there is no path by which a person's subscription
113
128
  // reaches it.
114
- auth_records: { roles: ["instance"], granularity: "element" },
129
+ "auth.records": { roles: ["instance"], granularity: "element" },
115
130
  } as const satisfies Record<
116
131
  PlainTopic | SessionScopedTopic | NamespaceScopedTopic,
117
132
  TopicAttributes
@@ -126,7 +141,7 @@ export function topicKind(topic: string): TopicKind | undefined {
126
141
  }
127
142
 
128
143
  /** How the frames of a topic name fold, taken from the name a subscriber
129
- * actually uses — `session_status:<sid>` rather than the kind behind it.
144
+ * actually uses — `session.status:<sid>` rather than the kind behind it.
130
145
  * `undefined` for a name this generation does not define. */
131
146
  export function topicGranularity(topic: string): TopicGranularity | undefined {
132
147
  const kind = topicKind(topic);
@@ -142,8 +157,8 @@ export type TopicSubscribeArgs = Static<typeof TopicSubscribeArgs>;
142
157
  export const TopicSubscribeResult = Type.Object({ topic: Topic });
143
158
  export type TopicSubscribeResult = Static<typeof TopicSubscribeResult>;
144
159
 
145
- export const TopicSubscribeRequest = request("topic_subscribe", TopicSubscribeArgs);
146
- export const TopicSubscribeResponse = response("topic_subscribe", TopicSubscribeResult);
160
+ export const TopicSubscribeRequest = request("topic.subscribe", TopicSubscribeArgs);
161
+ export const TopicSubscribeResponse = response("topic.subscribe", TopicSubscribeResult);
147
162
 
148
163
  export const TopicUnsubscribeArgs = Type.Object({ topic: Topic });
149
164
  export type TopicUnsubscribeArgs = Static<typeof TopicUnsubscribeArgs>;
@@ -151,5 +166,5 @@ export type TopicUnsubscribeArgs = Static<typeof TopicUnsubscribeArgs>;
151
166
  export const TopicUnsubscribeResult = Type.Object({ topic: Topic });
152
167
  export type TopicUnsubscribeResult = Static<typeof TopicUnsubscribeResult>;
153
168
 
154
- export const TopicUnsubscribeRequest = request("topic_unsubscribe", TopicUnsubscribeArgs);
155
- export const TopicUnsubscribeResponse = response("topic_unsubscribe", TopicUnsubscribeResult);
169
+ export const TopicUnsubscribeRequest = request("topic.unsubscribe", TopicUnsubscribeArgs);
170
+ export const TopicUnsubscribeResponse = response("topic.unsubscribe", TopicUnsubscribeResult);
@@ -50,15 +50,34 @@ export const AgentInfo = Type.Object(
50
50
  );
51
51
  export type AgentInfo = Static<typeof AgentInfo>;
52
52
 
53
- /** The `agents` topic. Whole-value per instance, like `peers`.
53
+ /** A row that is gone: the harness no longer reports this session, or the
54
+ * instance that polled it stopped. Marked rather than absent, since a frame
55
+ * carries only what changed. */
56
+ export const AgentRemoved = Type.Object(
57
+ {
58
+ sid: Sid,
59
+ instance: InstanceId,
60
+ removed: Type.Literal(true),
61
+ },
62
+ { $id: "AgentRemoved" },
63
+ );
64
+ export type AgentRemoved = Static<typeof AgentRemoved>;
65
+
66
+ export const AgentElement = Type.Union([AgentInfo, AgentRemoved], { $id: "AgentElement" });
67
+ export type AgentElement = Static<typeof AgentElement>;
68
+
69
+ /** The `agents` topic. Elements, like `peers`: the rows that changed since the
70
+ * last frame, matched by their `instance` and `sid`.
54
71
  *
55
72
  * The instance polls the harness only while somebody is listening here, so the
56
- * list is as fresh as the subscription is old. */
73
+ * list is as fresh as the subscription is old — and a poll that finds one
74
+ * session's status changed says that, rather than restating every row it read.
75
+ */
57
76
  export const AgentsFrame = topicFrame(
58
77
  "agents",
59
78
  Type.Object({
60
- agents: Type.Array(AgentInfo),
61
- /** When the poll behind this list ran. Absent before the first one. */
79
+ agents: Type.Array(AgentElement),
80
+ /** When the poll behind these rows ran. Absent before the first one. */
62
81
  polled_at: Type.Optional(Timestamp),
63
82
  }),
64
83
  );