@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.
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/attributes.ts +77 -52
- package/src/common/auth.ts +28 -28
- package/src/common/hello.ts +72 -34
- package/src/common/ping.ts +2 -2
- package/src/common/shutdown.ts +4 -4
- package/src/common/topics.ts +36 -21
- package/src/control/agents.ts +23 -4
- package/src/control/dump.ts +73 -66
- package/src/control/files.ts +24 -24
- package/src/control/instances.ts +21 -0
- package/src/control/kv.ts +6 -6
- package/src/control/launcher.ts +4 -4
- package/src/control/llm.ts +8 -8
- package/src/control/peers.ts +52 -68
- package/src/control/sandbox.ts +4 -4
- package/src/control/session-errors.ts +3 -3
- package/src/control/session-status.ts +2 -2
- package/src/control/session.ts +36 -35
- package/src/control/transcript.ts +6 -6
- package/src/control/translate.ts +2 -2
- package/src/envelope.ts +2 -2
- package/src/errors.ts +1 -1
- package/src/fixtures/common.ts +45 -32
- package/src/fixtures/control.ts +67 -67
- package/src/fixtures/index.ts +78 -63
- package/src/fixtures/messaging.ts +9 -9
- package/src/fixtures/topics.ts +40 -19
- package/src/identifiers.ts +8 -6
- package/src/index.ts +1 -0
- package/src/messaging/message.ts +3 -3
- package/src/messaging/notify.ts +3 -3
- package/src/messaging/say.ts +9 -9
- package/src/schemas.ts +81 -67
- package/src/session-meta.ts +2 -1
package/src/common/hello.ts
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
53
|
-
*
|
|
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
|
|
61
|
-
|
|
62
|
-
/** The
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
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
|
|
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 `
|
|
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
|
|
157
|
-
export const
|
|
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
|
+
}
|
package/src/common/ping.ts
CHANGED
|
@@ -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("
|
|
37
|
-
export const InstancePingResponse = response("
|
|
36
|
+
export const InstancePingRequest = request("instance.ping", InstancePingArgs);
|
|
37
|
+
export const InstancePingResponse = response("instance.ping", InstancePingResult);
|
package/src/common/shutdown.ts
CHANGED
|
@@ -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("
|
|
15
|
-
export const InstanceShutdownResponse = response("
|
|
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("
|
|
48
|
-
export const SessionStoppingResponse = response("
|
|
47
|
+
export const SessionStoppingRequest = request("session.stopping", SessionStoppingArgs);
|
|
48
|
+
export const SessionStoppingResponse = response("session.stopping", SessionStoppingResult);
|
package/src/common/topics.ts
CHANGED
|
@@ -7,15 +7,16 @@ export const PLAIN_TOPICS = [
|
|
|
7
7
|
"inbox",
|
|
8
8
|
"notify",
|
|
9
9
|
"peers",
|
|
10
|
+
"instances",
|
|
10
11
|
"agents",
|
|
11
|
-
"
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
"
|
|
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 = ["
|
|
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
|
|
43
|
-
`|(?:${SESSION_SCOPED_TOPICS
|
|
44
|
-
`|(?:${NAMESPACE_SCOPED_TOPICS
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 — `
|
|
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("
|
|
146
|
-
export const TopicSubscribeResponse = response("
|
|
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("
|
|
155
|
-
export const TopicUnsubscribeResponse = response("
|
|
169
|
+
export const TopicUnsubscribeRequest = request("topic.unsubscribe", TopicUnsubscribeArgs);
|
|
170
|
+
export const TopicUnsubscribeResponse = response("topic.unsubscribe", TopicUnsubscribeResult);
|
package/src/control/agents.ts
CHANGED
|
@@ -50,15 +50,34 @@ export const AgentInfo = Type.Object(
|
|
|
50
50
|
);
|
|
51
51
|
export type AgentInfo = Static<typeof AgentInfo>;
|
|
52
52
|
|
|
53
|
-
/**
|
|
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(
|
|
61
|
-
/** When the poll behind
|
|
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
|
);
|