@ccmsg/protocol 0.1.1 → 0.3.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 +1 -1
- package/src/common/hello.ts +14 -0
- package/src/control/peers.ts +67 -22
- package/src/index.ts +2 -0
- package/src/messaging/direct-delivery.ts +136 -0
- package/src/messaging/message.ts +14 -1
- package/src/session-meta.ts +33 -0
package/package.json
CHANGED
package/src/common/hello.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type Static, Type } from "@sinclair/typebox";
|
|
2
2
|
import { request, response } from "../envelope.ts";
|
|
3
3
|
import { Capability, InstanceId, Role, Sid, Timestamp } from "../identifiers.ts";
|
|
4
|
+
import { SessionMetaFields } from "../session-meta.ts";
|
|
4
5
|
|
|
5
6
|
/** The mesh handshake's opening claim, carried by a `role: "instance"` hello.
|
|
6
7
|
*
|
|
@@ -36,6 +37,19 @@ export const HelloArgs = Type.Object({
|
|
|
36
37
|
mesh: Type.Optional(MeshHello),
|
|
37
38
|
/** The client build, for display in diagnostics. Nothing gates on it. */
|
|
38
39
|
client_version: Type.Optional(Type.String()),
|
|
40
|
+
/** What a `role: "session"` connection says about itself. All optional: a
|
|
41
|
+
* session states what it knows, and the instance derives or leaves unknown
|
|
42
|
+
* what it is not told. The instance repeats these on the `peers` topic, so
|
|
43
|
+
* they are the same fields under the same names there. */
|
|
44
|
+
repo: Type.Optional(SessionMetaFields.repo),
|
|
45
|
+
ws: Type.Optional(SessionMetaFields.ws),
|
|
46
|
+
cwd: Type.Optional(SessionMetaFields.cwd),
|
|
47
|
+
transcript_path: Type.Optional(SessionMetaFields.transcript_path),
|
|
48
|
+
repo_root: Type.Optional(SessionMetaFields.repo_root),
|
|
49
|
+
branch: Type.Optional(SessionMetaFields.branch),
|
|
50
|
+
title: Type.Optional(SessionMetaFields.title),
|
|
51
|
+
model: Type.Optional(SessionMetaFields.model),
|
|
52
|
+
effort: Type.Optional(SessionMetaFields.effort),
|
|
39
53
|
});
|
|
40
54
|
export type HelloArgs = Static<typeof HelloArgs>;
|
|
41
55
|
|
package/src/control/peers.ts
CHANGED
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
import { type Static, Type } from "@sinclair/typebox";
|
|
2
2
|
import { topicFrame } from "../envelope.ts";
|
|
3
3
|
import { InstanceId, Sid, Timestamp } from "../identifiers.ts";
|
|
4
|
+
import { SessionMetaFields } from "../session-meta.ts";
|
|
5
|
+
|
|
6
|
+
/** How a session stands, as the instance holding it derives it.
|
|
7
|
+
*
|
|
8
|
+
* The instance states the classification rather than the raw inputs it read,
|
|
9
|
+
* so every client shows the same session the same way. The first three appear
|
|
10
|
+
* on connected sessions, the last two on sessions the instance has lost; a
|
|
11
|
+
* client that groups its list groups on this field alone.
|
|
12
|
+
*
|
|
13
|
+
* Being pinned is not one of these: a person pins a session, and the mark
|
|
14
|
+
* travels beside the classification rather than replacing it. */
|
|
15
|
+
export const SessionState = Type.Union(
|
|
16
|
+
[
|
|
17
|
+
/** Stopped at something a person has to answer: a dialog it opened, or a
|
|
18
|
+
* turn that ended in an upstream error. */
|
|
19
|
+
Type.Literal("waiting"),
|
|
20
|
+
Type.Literal("live"),
|
|
21
|
+
/** Alive, but reachable through neither a client connection nor a
|
|
22
|
+
* terminal, so nothing here can act on it. */
|
|
23
|
+
Type.Literal("live_unmanaged"),
|
|
24
|
+
/** Gone, having said it was stopping. */
|
|
25
|
+
Type.Literal("paused"),
|
|
26
|
+
/** Gone without saying so. */
|
|
27
|
+
Type.Literal("disappeared"),
|
|
28
|
+
],
|
|
29
|
+
{ $id: "SessionState" },
|
|
30
|
+
);
|
|
31
|
+
export type SessionState = Static<typeof SessionState>;
|
|
32
|
+
|
|
33
|
+
/** How long a session stays in `last_live` after it was last seen. The same
|
|
34
|
+
* window the inbox keeps undelivered messages for: what a person comes back to
|
|
35
|
+
* is one thing — the session and what was said to it — so the two cannot expire
|
|
36
|
+
* at different times. */
|
|
37
|
+
export const LAST_LIVE_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
4
38
|
|
|
5
39
|
/** A client of one session whose greeting was refused.
|
|
6
40
|
*
|
|
@@ -31,17 +65,20 @@ export const PeerInfo = Type.Object(
|
|
|
31
65
|
{
|
|
32
66
|
sid: Sid,
|
|
33
67
|
instance: InstanceId,
|
|
34
|
-
repo:
|
|
35
|
-
ws:
|
|
36
|
-
cwd:
|
|
37
|
-
/** Present when the session announced
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
*
|
|
43
|
-
|
|
44
|
-
|
|
68
|
+
repo: SessionMetaFields.repo,
|
|
69
|
+
ws: SessionMetaFields.ws,
|
|
70
|
+
cwd: SessionMetaFields.cwd,
|
|
71
|
+
/** Present when the session announced one the instance accepted. */
|
|
72
|
+
transcript_path: Type.Optional(SessionMetaFields.transcript_path),
|
|
73
|
+
repo_root: Type.Optional(SessionMetaFields.repo_root),
|
|
74
|
+
branch: Type.Optional(SessionMetaFields.branch),
|
|
75
|
+
/** How this session stands. One of `waiting`, `live` or `live_unmanaged`:
|
|
76
|
+
* a session in this list is connected, so it is by definition not gone.
|
|
77
|
+
* Absent from an instance that states no classification, and a client then
|
|
78
|
+
* shows the session without grouping it rather than guessing one. */
|
|
79
|
+
state: Type.Optional(SessionState),
|
|
80
|
+
/** Set while a person has pinned this session. Absent means not pinned. */
|
|
81
|
+
pinned: Type.Optional(Type.Boolean()),
|
|
45
82
|
/** When this session first registered with the instance. Stable across its
|
|
46
83
|
* reconnections, and reset when the instance restarts. */
|
|
47
84
|
connected_at: Type.Optional(Timestamp),
|
|
@@ -88,23 +125,31 @@ export const LastLiveSession = Type.Object(
|
|
|
88
125
|
{
|
|
89
126
|
sid: Sid,
|
|
90
127
|
instance: InstanceId,
|
|
91
|
-
repo:
|
|
92
|
-
ws:
|
|
93
|
-
cwd:
|
|
128
|
+
repo: SessionMetaFields.repo,
|
|
129
|
+
ws: SessionMetaFields.ws,
|
|
130
|
+
cwd: SessionMetaFields.cwd,
|
|
94
131
|
/** Also where the model and effort below were read from. */
|
|
95
|
-
transcript_path: Type.Optional(
|
|
96
|
-
repo_root: Type.Optional(
|
|
97
|
-
branch: Type.Optional(
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
132
|
+
transcript_path: Type.Optional(SessionMetaFields.transcript_path),
|
|
133
|
+
repo_root: Type.Optional(SessionMetaFields.repo_root),
|
|
134
|
+
branch: Type.Optional(SessionMetaFields.branch),
|
|
135
|
+
title: Type.Optional(SessionMetaFields.title),
|
|
136
|
+
/** How this session stands: `paused` or `disappeared`, which the
|
|
137
|
+
* `stopped_at` below is what separates. Absent from an instance that states
|
|
138
|
+
* no classification. */
|
|
139
|
+
state: Type.Optional(SessionState),
|
|
140
|
+
/** Set while a person has pinned this session. Absent means not pinned. */
|
|
141
|
+
pinned: Type.Optional(Type.Boolean()),
|
|
101
142
|
connected_at: Type.Optional(Timestamp),
|
|
102
143
|
/** The newest instant this session is known to have been alive. */
|
|
103
144
|
last_seen_at: Timestamp,
|
|
145
|
+
/** When the session said it was stopping. Its presence is what makes this a
|
|
146
|
+
* pause rather than a disappearance: a session that goes without a word
|
|
147
|
+
* leaves nothing to stamp here. */
|
|
148
|
+
stopped_at: Type.Optional(Timestamp),
|
|
104
149
|
/** What its last turn ran as, in the transcript's own spelling. A resume
|
|
105
150
|
* must not quietly switch the session to something else. */
|
|
106
|
-
model: Type.Optional(
|
|
107
|
-
effort: Type.Optional(
|
|
151
|
+
model: Type.Optional(SessionMetaFields.model),
|
|
152
|
+
effort: Type.Optional(SessionMetaFields.effort),
|
|
108
153
|
},
|
|
109
154
|
{ $id: "LastLiveSession" },
|
|
110
155
|
);
|
package/src/index.ts
CHANGED
|
@@ -18,8 +18,10 @@ export * from "./control/translate.ts";
|
|
|
18
18
|
export * from "./envelope.ts";
|
|
19
19
|
export * from "./errors.ts";
|
|
20
20
|
export * from "./identifiers.ts";
|
|
21
|
+
export * from "./messaging/direct-delivery.ts";
|
|
21
22
|
export * from "./messaging/message.ts";
|
|
22
23
|
export * from "./messaging/notify.ts";
|
|
23
24
|
export * from "./messaging/say.ts";
|
|
24
25
|
export * from "./schemas.ts";
|
|
26
|
+
export * from "./session-meta.ts";
|
|
25
27
|
export * from "./upstream.ts";
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type { InboxMessage } from "./message.ts";
|
|
2
|
+
import type { Mid, Sid } from "../identifiers.ts";
|
|
3
|
+
|
|
4
|
+
/** How an `InboxMessage` is worded when it is handed to a session through the
|
|
5
|
+
* harness's own messaging socket rather than over this protocol.
|
|
6
|
+
*
|
|
7
|
+
* On that route the recipient is the model, not a client: it reads one block of
|
|
8
|
+
* text and has no frame to look at. So `mid` and `from` have to be in the text
|
|
9
|
+
* or the recipient cannot answer — it would know a message arrived and not what
|
|
10
|
+
* to answer or how. That is what this module puts there, and nothing more: the
|
|
11
|
+
* wording is the delivery's, not the sender's, and `text` is carried through
|
|
12
|
+
* untouched.
|
|
13
|
+
*
|
|
14
|
+
* The shape is the harness's own sender convention, `<cross-session-message>`
|
|
15
|
+
* embedded in the message body. Sitting on it means the receiving harness reads
|
|
16
|
+
* the origin it already knows how to read, and a session that has seen a peer
|
|
17
|
+
* message sees the same envelope here. */
|
|
18
|
+
|
|
19
|
+
/** The element senders wrap a peer message in. */
|
|
20
|
+
export const DIRECT_DELIVERY_TAG = "cross-session-message";
|
|
21
|
+
|
|
22
|
+
/** What the recipient's harness is told to answer to.
|
|
23
|
+
*
|
|
24
|
+
* Not the sender's session and not a `uds:` path. Those are the addresses the
|
|
25
|
+
* harness itself dials, and dialing one that has gone ends the recipient's turn
|
|
26
|
+
* in failure; the sender here is a daemon that offers no such socket anyway.
|
|
27
|
+
* A name asks for no answer, so there is nothing to dangle — the way back is
|
|
28
|
+
* the reply line, which the recipient runs rather than the harness. */
|
|
29
|
+
export const DIRECT_DELIVERY_FROM = "ccmsg";
|
|
30
|
+
|
|
31
|
+
/** What the recipient is told the sender is doing. `prompting` is what a peer
|
|
32
|
+
* mid-turn sends as, which is what a relayed message is. */
|
|
33
|
+
export const DIRECT_DELIVERY_FROM_MODE = "prompting";
|
|
34
|
+
|
|
35
|
+
/** How the recipient answers: the one line of instruction in the body.
|
|
36
|
+
*
|
|
37
|
+
* A command and not a description of one, because the recipient acts on it
|
|
38
|
+
* directly. `reply` names the thing being done and takes the frame it answers,
|
|
39
|
+
* so nothing has to be looked up to use it. */
|
|
40
|
+
export function directDeliveryReplyLine(mid: Mid): string {
|
|
41
|
+
return `Reply with: ccmsg reply ${mid} <text>`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** An `InboxMessage` as it reaches a session through the messaging socket. */
|
|
45
|
+
export interface DirectDelivery {
|
|
46
|
+
mid: Mid;
|
|
47
|
+
from: Sid;
|
|
48
|
+
from_label: string;
|
|
49
|
+
reply_to?: Mid;
|
|
50
|
+
/** The sender's text, exactly as it was sent. */
|
|
51
|
+
text: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Attribute values are quoted, so a label the sender chose cannot be trusted
|
|
55
|
+
* to stay inside its quotes. The identifiers cannot contain any of these, but
|
|
56
|
+
* they go through the same escape so one rule covers every attribute. */
|
|
57
|
+
function escapeAttribute(value: string): string {
|
|
58
|
+
return value
|
|
59
|
+
.replaceAll("&", "&")
|
|
60
|
+
.replaceAll("<", "<")
|
|
61
|
+
.replaceAll(">", ">")
|
|
62
|
+
.replaceAll('"', """);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function unescapeAttribute(value: string): string {
|
|
66
|
+
return value
|
|
67
|
+
.replaceAll(""", '"')
|
|
68
|
+
.replaceAll(">", ">")
|
|
69
|
+
.replaceAll("<", "<")
|
|
70
|
+
.replaceAll("&", "&");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Word a message for the messaging socket.
|
|
74
|
+
*
|
|
75
|
+
* `from` / `from-name` / `from-mode` are the harness's attributes, so the
|
|
76
|
+
* receiving harness reads the origin it expects. The `ccmsg-` ones are this
|
|
77
|
+
* protocol's: the harness has no use for them and a reader that does not know
|
|
78
|
+
* them sees an envelope it can still read, while a recipient that wants to
|
|
79
|
+
* answer has the `mid` and the sid without parsing prose.
|
|
80
|
+
*
|
|
81
|
+
* The body is not escaped. What the model reads is these characters, so
|
|
82
|
+
* entity-escaping them would hand the recipient a corrupted message to answer;
|
|
83
|
+
* a `</cross-session-message>` inside the text is left where the sender put it
|
|
84
|
+
* and the closing tag is found from the end (see `parseDirectDelivery`).
|
|
85
|
+
* Refusing such a message instead would lose it for a substring. */
|
|
86
|
+
export function renderDirectDelivery(message: InboxMessage): string {
|
|
87
|
+
const attributes = [
|
|
88
|
+
`from="${DIRECT_DELIVERY_FROM}"`,
|
|
89
|
+
`from-name="${escapeAttribute(message.from_label)}"`,
|
|
90
|
+
`from-mode="${DIRECT_DELIVERY_FROM_MODE}"`,
|
|
91
|
+
`ccmsg-mid="${escapeAttribute(message.mid)}"`,
|
|
92
|
+
`ccmsg-from="${escapeAttribute(message.from)}"`,
|
|
93
|
+
];
|
|
94
|
+
if (message.reply_to !== undefined) {
|
|
95
|
+
attributes.push(`ccmsg-reply-to="${escapeAttribute(message.reply_to)}"`);
|
|
96
|
+
}
|
|
97
|
+
const body = `${message.text}\n\n${directDeliveryReplyLine(message.mid)}`;
|
|
98
|
+
return `<${DIRECT_DELIVERY_TAG} ${attributes.join(" ")}>\n${body}\n</${DIRECT_DELIVERY_TAG}>`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const OPENING = new RegExp(`^<${DIRECT_DELIVERY_TAG}((?:\\s+[a-z-]+="[^"]*")*)\\s*>\\n`);
|
|
102
|
+
const ATTRIBUTE = /([a-z-]+)="([^"]*)"/g;
|
|
103
|
+
|
|
104
|
+
/** Read back what `renderDirectDelivery` wrote.
|
|
105
|
+
*
|
|
106
|
+
* For the recipient side: a session or client holding the delivered text can
|
|
107
|
+
* recover the frame it answers without the sender's help. Returns `undefined`
|
|
108
|
+
* for anything that is not one of these envelopes — a plain message, or one
|
|
109
|
+
* missing the identity that makes it answerable.
|
|
110
|
+
*
|
|
111
|
+
* The closing tag is taken from the end so a body containing one round-trips,
|
|
112
|
+
* and the reply line this delivery added is removed, leaving the sender's own
|
|
113
|
+
* text. */
|
|
114
|
+
export function parseDirectDelivery(delivered: string): DirectDelivery | undefined {
|
|
115
|
+
const opening = OPENING.exec(delivered);
|
|
116
|
+
if (!opening) return undefined;
|
|
117
|
+
const closing = `\n</${DIRECT_DELIVERY_TAG}>`;
|
|
118
|
+
const end = delivered.lastIndexOf(closing);
|
|
119
|
+
if (end < opening[0].length - 1) return undefined;
|
|
120
|
+
|
|
121
|
+
const attributes = new Map<string, string>();
|
|
122
|
+
for (const [, key, value] of (opening[1] ?? "").matchAll(ATTRIBUTE)) {
|
|
123
|
+
if (key !== undefined && value !== undefined) attributes.set(key, unescapeAttribute(value));
|
|
124
|
+
}
|
|
125
|
+
const mid = attributes.get("ccmsg-mid");
|
|
126
|
+
const from = attributes.get("ccmsg-from");
|
|
127
|
+
const from_label = attributes.get("from-name");
|
|
128
|
+
if (mid === undefined || from === undefined || from_label === undefined) return undefined;
|
|
129
|
+
|
|
130
|
+
let text = delivered.slice(opening[0].length, end);
|
|
131
|
+
const suffix = `\n\n${directDeliveryReplyLine(mid)}`;
|
|
132
|
+
if (text.endsWith(suffix)) text = text.slice(0, -suffix.length);
|
|
133
|
+
|
|
134
|
+
const reply_to = attributes.get("ccmsg-reply-to");
|
|
135
|
+
return { mid, from, from_label, ...(reply_to !== undefined ? { reply_to } : {}), text };
|
|
136
|
+
}
|
package/src/messaging/message.ts
CHANGED
|
@@ -72,7 +72,8 @@ export const MessageSendResponse = response("message_send", MessageSendResult);
|
|
|
72
72
|
*
|
|
73
73
|
* To answer it, send to `from`. The route is the sender's id and nothing else,
|
|
74
74
|
* so no reply instructions travel on the wire: the wording a session sees
|
|
75
|
-
* belongs to
|
|
75
|
+
* belongs to whoever renders it — see `direct-delivery.ts` for the one route
|
|
76
|
+
* whose recipient reads text instead of this frame. */
|
|
76
77
|
export const InboxMessage = Type.Object(
|
|
77
78
|
{
|
|
78
79
|
mid: Mid,
|
|
@@ -87,6 +88,18 @@ export const InboxMessage = Type.Object(
|
|
|
87
88
|
);
|
|
88
89
|
export type InboxMessage = Static<typeof InboxMessage>;
|
|
89
90
|
|
|
91
|
+
/** How long an undelivered message is kept for its recipient. The same window
|
|
92
|
+
* a lost session stays listed for: a message outliving the session it was
|
|
93
|
+
* addressed to would be offered to no one, and a session outliving what was
|
|
94
|
+
* said to it would come back to an empty inbox. */
|
|
95
|
+
export const INBOX_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
96
|
+
|
|
97
|
+
/** How many undelivered messages one session's inbox holds. Beyond it the
|
|
98
|
+
* oldest is dropped for the newest and the sender is told `inbox_full`. Matched
|
|
99
|
+
* to what the harness itself will hold for a session, so a message the inbox
|
|
100
|
+
* accepts is one the recipient can still be handed. */
|
|
101
|
+
export const INBOX_MAX_PER_SID = 256;
|
|
102
|
+
|
|
90
103
|
/** The `inbox` topic. Its snapshot is whatever is still undelivered for this
|
|
91
104
|
* session; each later frame is one newly arrived message. */
|
|
92
105
|
export const InboxFrame = topicFrame("inbox", Type.Array(InboxMessage));
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox";
|
|
2
|
+
|
|
3
|
+
/** The fields that describe where a session lives and what it runs as.
|
|
4
|
+
*
|
|
5
|
+
* Stated once and referenced wherever a session is described: the session names
|
|
6
|
+
* them itself in `hello`, and the instance repeats them on the `peers` topic
|
|
7
|
+
* for connected and last-known sessions alike. Which of them are required
|
|
8
|
+
* differs by place — a greeting may leave any of them unsaid — so each place
|
|
9
|
+
* wraps what it needs in `Type.Optional`, but the name and the type of a field
|
|
10
|
+
* never differ between them. */
|
|
11
|
+
export const SessionMetaFields = {
|
|
12
|
+
/** The repository the session works in, as a display name. */
|
|
13
|
+
repo: Type.String(),
|
|
14
|
+
/** The workspace (worktree) name within that repository. */
|
|
15
|
+
ws: Type.String(),
|
|
16
|
+
/** The session's working directory. */
|
|
17
|
+
cwd: Type.String(),
|
|
18
|
+
/** The transcript file, which is what decides whether the session's
|
|
19
|
+
* transcript can be read at all. */
|
|
20
|
+
transcript_path: Type.String(),
|
|
21
|
+
/** The repository container. File browsing is rooted here rather than at the
|
|
22
|
+
* working directory, so sibling workspaces are reachable. An instance that is
|
|
23
|
+
* not told one derives it from `cwd`. */
|
|
24
|
+
repo_root: Type.String(),
|
|
25
|
+
/** The branch checked out in that workspace. */
|
|
26
|
+
branch: Type.String(),
|
|
27
|
+
/** The session's own title. Absent means not known, never untitled. */
|
|
28
|
+
title: Type.String(),
|
|
29
|
+
/** The model the session runs as, in its own spelling. */
|
|
30
|
+
model: Type.String(),
|
|
31
|
+
/** The reasoning effort the session runs at, in its own spelling. */
|
|
32
|
+
effort: Type.String(),
|
|
33
|
+
};
|