@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 +21 -0
- package/README.md +33 -0
- package/package.json +33 -0
- package/src/attributes.ts +353 -0
- package/src/common/hello.ts +73 -0
- package/src/common/ping.ts +37 -0
- package/src/common/shutdown.ts +13 -0
- package/src/common/topics.ts +99 -0
- package/src/control/agents.ts +64 -0
- package/src/control/files.ts +323 -0
- package/src/control/kv.ts +108 -0
- package/src/control/launcher.ts +100 -0
- package/src/control/llm.ts +494 -0
- package/src/control/peers.ts +127 -0
- package/src/control/sandbox.ts +58 -0
- package/src/control/session-errors.ts +27 -0
- package/src/control/session-status.ts +311 -0
- package/src/control/session.ts +251 -0
- package/src/control/transcript.ts +80 -0
- package/src/control/translate.ts +33 -0
- package/src/envelope.ts +117 -0
- package/src/errors.ts +63 -0
- package/src/identifiers.ts +62 -0
- package/src/index.ts +25 -0
- package/src/messaging/message.ts +92 -0
- package/src/messaging/notify.ts +35 -0
- package/src/messaging/say.ts +35 -0
- package/src/schemas.ts +195 -0
- package/src/upstream.ts +17 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type Static, Type } from "@sinclair/typebox";
|
|
2
|
+
import { request, response } from "../envelope.ts";
|
|
3
|
+
|
|
4
|
+
/** Translates a batch of texts on the instance's host.
|
|
5
|
+
*
|
|
6
|
+
* The reply comes back when the whole batch is done, which can be seconds. An
|
|
7
|
+
* empty batch is not a probe for whether translation is available — that is the
|
|
8
|
+
* capability's job, and asking the question twice is what the capability set
|
|
9
|
+
* was introduced to end. */
|
|
10
|
+
export const TranslateRunArgs = Type.Object({
|
|
11
|
+
texts: Type.Array(Type.String()),
|
|
12
|
+
});
|
|
13
|
+
export type TranslateRunArgs = Static<typeof TranslateRunArgs>;
|
|
14
|
+
|
|
15
|
+
/** One text's outcome. A batch succeeds as a whole while individual texts may
|
|
16
|
+
* not, so the failure lives per item rather than failing the op. */
|
|
17
|
+
export const TranslateResult = Type.Union(
|
|
18
|
+
[
|
|
19
|
+
Type.Object({ ok: Type.Literal(true), text: Type.String() }),
|
|
20
|
+
Type.Object({ ok: Type.Literal(false), error: Type.String() }),
|
|
21
|
+
],
|
|
22
|
+
{ $id: "TranslateResult" },
|
|
23
|
+
);
|
|
24
|
+
export type TranslateResult = Static<typeof TranslateResult>;
|
|
25
|
+
|
|
26
|
+
export const TranslateRunResult = Type.Object({
|
|
27
|
+
/** One per requested text, in the order they were sent. */
|
|
28
|
+
results: Type.Array(TranslateResult),
|
|
29
|
+
});
|
|
30
|
+
export type TranslateRunResult = Static<typeof TranslateRunResult>;
|
|
31
|
+
|
|
32
|
+
export const TranslateRunRequest = request("translate_run", TranslateRunArgs);
|
|
33
|
+
export const TranslateRunResponse = response("translate_run", TranslateRunResult);
|
package/src/envelope.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { type Static, type TSchema, Type } from "@sinclair/typebox";
|
|
2
|
+
import { ErrorBody } from "./errors.ts";
|
|
3
|
+
import { InstanceId } from "./identifiers.ts";
|
|
4
|
+
|
|
5
|
+
/** The generation of this wire contract. Within a generation, only optional
|
|
6
|
+
* fields and whole new ops may be added; a removal or a change of meaning
|
|
7
|
+
* raises it. Peers announcing another generation are refused, on client
|
|
8
|
+
* connections and on mesh links alike. */
|
|
9
|
+
export const PROTOCOL_VERSION = 2;
|
|
10
|
+
|
|
11
|
+
/** Fields a request carries in addition to its own arguments.
|
|
12
|
+
*
|
|
13
|
+
* `request_id` pairs a reply with its request so one connection can run its
|
|
14
|
+
* requests concurrently instead of in arrival order. Uniqueness only has to
|
|
15
|
+
* hold among one connection's in-flight requests.
|
|
16
|
+
*
|
|
17
|
+
* The three mesh fields are the whole of the mesh plane: an op forwarded to
|
|
18
|
+
* another instance is the same op in the same shape, wrapped in these. */
|
|
19
|
+
export const RequestEnvelope = Type.Object(
|
|
20
|
+
{
|
|
21
|
+
request_id: Type.String({ minLength: 1 }),
|
|
22
|
+
/** Set when the caller wants this op run by a named instance rather than
|
|
23
|
+
* by the one it is connected to. */
|
|
24
|
+
to_instance: Type.Optional(InstanceId),
|
|
25
|
+
/** Stamped by the forwarding instance so the destination knows where the
|
|
26
|
+
* reply goes back to. */
|
|
27
|
+
from_instance: Type.Optional(InstanceId),
|
|
28
|
+
/** Instances this request has already passed through, in order. A request
|
|
29
|
+
* that would revisit an instance is dropped rather than looped. */
|
|
30
|
+
hops: Type.Optional(Type.Array(InstanceId)),
|
|
31
|
+
},
|
|
32
|
+
{ $id: "RequestEnvelope" },
|
|
33
|
+
);
|
|
34
|
+
export type RequestEnvelope = Static<typeof RequestEnvelope>;
|
|
35
|
+
|
|
36
|
+
/** Compose an op's own arguments with the request envelope. */
|
|
37
|
+
export function request<T extends TSchema>(op: string, args: T) {
|
|
38
|
+
return Type.Intersect([Type.Object({ op: Type.Literal(op) }), args, RequestEnvelope], {
|
|
39
|
+
$id: `${op}:request`,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Compose an op's reply body with `ok: true` and the correlation id.
|
|
44
|
+
*
|
|
45
|
+
* `request_id` is required, unlike the generation that introduced it: a reply
|
|
46
|
+
* that cannot name its request settles no caller, and the only replies in that
|
|
47
|
+
* position are the reject-before-dispatch failures below. */
|
|
48
|
+
export function response<T extends TSchema>(op: string, body: T) {
|
|
49
|
+
return Type.Intersect(
|
|
50
|
+
[Type.Object({ ok: Type.Literal(true), request_id: Type.String({ minLength: 1 }) }), body],
|
|
51
|
+
{ $id: `${op}:response` },
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A failed reply. `request_id` is absent only where the request could not be
|
|
56
|
+
* identified at all (unparseable JSON, missing `op`, missing `request_id`). */
|
|
57
|
+
export const ErrorResponse = Type.Object(
|
|
58
|
+
{
|
|
59
|
+
ok: Type.Literal(false),
|
|
60
|
+
request_id: Type.Optional(Type.String({ minLength: 1 })),
|
|
61
|
+
error: ErrorBody,
|
|
62
|
+
},
|
|
63
|
+
{ $id: "ErrorResponse" },
|
|
64
|
+
);
|
|
65
|
+
export type ErrorResponse = Static<typeof ErrorResponse>;
|
|
66
|
+
|
|
67
|
+
/** A frame pushed on a topic the connection subscribed to.
|
|
68
|
+
*
|
|
69
|
+
* Snapshot and delta share one shape: the first frame after `topic_subscribe`
|
|
70
|
+
* carries `snapshot: true` and the whole current value, and later frames carry
|
|
71
|
+
* the same payload type as a change. `instance` names where the frame came
|
|
72
|
+
* from, which is what keeps whole-value topics from several instances out of
|
|
73
|
+
* each other's way. */
|
|
74
|
+
export function topicFrame<T extends TSchema>(topic: string, data: T) {
|
|
75
|
+
return Type.Intersect(
|
|
76
|
+
[
|
|
77
|
+
Type.Object({
|
|
78
|
+
ev: Type.Literal("topic"),
|
|
79
|
+
topic: Type.String({ minLength: 1 }),
|
|
80
|
+
snapshot: Type.Optional(Type.Literal(true)),
|
|
81
|
+
instance: InstanceId,
|
|
82
|
+
}),
|
|
83
|
+
Type.Object({ data }),
|
|
84
|
+
],
|
|
85
|
+
{ $id: `topic:${topic}` },
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The daemon is going down and will come back on the same socket. */
|
|
90
|
+
export const RestartingEvent = Type.Object(
|
|
91
|
+
{ ev: Type.Literal("restarting"), instance: InstanceId },
|
|
92
|
+
{ $id: "RestartingEvent" },
|
|
93
|
+
);
|
|
94
|
+
export type RestartingEvent = Static<typeof RestartingEvent>;
|
|
95
|
+
|
|
96
|
+
/** Another connection took over this session's subscription; this one will
|
|
97
|
+
* receive nothing further. About this connection alone, so it carries no
|
|
98
|
+
* originating instance. */
|
|
99
|
+
export const SubscribeSupersededEvent = Type.Object(
|
|
100
|
+
{ ev: Type.Literal("subscribe_superseded") },
|
|
101
|
+
{ $id: "SubscribeSupersededEvent" },
|
|
102
|
+
);
|
|
103
|
+
export type SubscribeSupersededEvent = Static<typeof SubscribeSupersededEvent>;
|
|
104
|
+
|
|
105
|
+
/** The instance's view of the host link changed. */
|
|
106
|
+
export const NetOnlineEvent = Type.Object(
|
|
107
|
+
{ ev: Type.Literal("net_online"), instance: InstanceId, online: Type.Boolean() },
|
|
108
|
+
{ $id: "NetOnlineEvent" },
|
|
109
|
+
);
|
|
110
|
+
export type NetOnlineEvent = Static<typeof NetOnlineEvent>;
|
|
111
|
+
|
|
112
|
+
/** Events about the connection itself rather than about a topic. */
|
|
113
|
+
export const ConnectionEvent = Type.Union(
|
|
114
|
+
[RestartingEvent, SubscribeSupersededEvent, NetOnlineEvent],
|
|
115
|
+
{ $id: "ConnectionEvent" },
|
|
116
|
+
);
|
|
117
|
+
export type ConnectionEvent = Static<typeof ConnectionEvent>;
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { type Static, Type } from "@sinclair/typebox";
|
|
2
|
+
|
|
3
|
+
/** Every code an error response may carry. The union is closed: a code outside
|
|
4
|
+
* this list is a contract violation, not an extension point. */
|
|
5
|
+
export const ERROR_CODES = [
|
|
6
|
+
// --- connection level (no single op owns these) ---
|
|
7
|
+
/** The request could not be dispatched at all: unparseable JSON, no `op`, no
|
|
8
|
+
* `request_id`, or a `hello` announcing another protocol generation. */
|
|
9
|
+
"bad_request",
|
|
10
|
+
/** The op name is not in the op attribute table. */
|
|
11
|
+
"unknown_op",
|
|
12
|
+
/** An op with `needs_hello` arrived before `hello` settled the identity. */
|
|
13
|
+
"hello_required",
|
|
14
|
+
// --- rule-derived (op attribute table §0) ---
|
|
15
|
+
/** The connection's role is outside the op's `roles`. Argument problems stay
|
|
16
|
+
* on `invalid_args` / `bad_request`. */
|
|
17
|
+
"forbidden",
|
|
18
|
+
/** The op's arguments failed the op's schema. */
|
|
19
|
+
"invalid_args",
|
|
20
|
+
/** The op declares a `capability` this instance does not have. */
|
|
21
|
+
"capability_unavailable",
|
|
22
|
+
/** An `instance-local` op could not be forwarded to the instance that owns
|
|
23
|
+
* the subject. */
|
|
24
|
+
"instance_unreachable",
|
|
25
|
+
// --- subscription ---
|
|
26
|
+
/** The topic name is not one this protocol generation defines. */
|
|
27
|
+
"topic_unknown",
|
|
28
|
+
// --- subject lookup ---
|
|
29
|
+
/** The `sid` names no session anywhere in the cluster. */
|
|
30
|
+
"session_not_found",
|
|
31
|
+
/** The path, transcript, or record named by the arguments does not exist. */
|
|
32
|
+
"not_found",
|
|
33
|
+
// --- file access ---
|
|
34
|
+
"path_forbidden",
|
|
35
|
+
"path_not_writable",
|
|
36
|
+
"file_exists",
|
|
37
|
+
/** The file changed between the read the edit was based on and the write. */
|
|
38
|
+
"file_conflict",
|
|
39
|
+
/** The on-disk content sniffed as binary, so a text edit would not be
|
|
40
|
+
* faithful to what the caller saw. */
|
|
41
|
+
"not_a_text_file",
|
|
42
|
+
// --- translate ---
|
|
43
|
+
/** The helper process is present but failed on this call. (Its absence is
|
|
44
|
+
* `capability_unavailable` instead.) */
|
|
45
|
+
"translate_helper_failed",
|
|
46
|
+
] as const;
|
|
47
|
+
|
|
48
|
+
export type ErrorCode = (typeof ERROR_CODES)[number];
|
|
49
|
+
|
|
50
|
+
export const ErrorCodeSchema = Type.Union(
|
|
51
|
+
ERROR_CODES.map((code) => Type.Literal(code)),
|
|
52
|
+
{ $id: "ErrorCode" },
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
export const ErrorBody = Type.Object(
|
|
56
|
+
{
|
|
57
|
+
code: ErrorCodeSchema,
|
|
58
|
+
/** Human-readable detail. Clients branch on `code`, never on this. */
|
|
59
|
+
msg: Type.String(),
|
|
60
|
+
},
|
|
61
|
+
{ $id: "ErrorBody" },
|
|
62
|
+
);
|
|
63
|
+
export type ErrorBody = Static<typeof ErrorBody>;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { type Static, Type } from "@sinclair/typebox";
|
|
2
|
+
|
|
3
|
+
/** A session id: the uuid Claude Code gives its own session. Globally unique,
|
|
4
|
+
* so it names a session across the whole cluster without an instance prefix. */
|
|
5
|
+
export const Sid = Type.String({
|
|
6
|
+
$id: "Sid",
|
|
7
|
+
pattern: "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
|
|
8
|
+
});
|
|
9
|
+
export type Sid = Static<typeof Sid>;
|
|
10
|
+
|
|
11
|
+
/** An instance id: the endpoint URL other instances dial, compared as a whole
|
|
12
|
+
* string including its path (mesh-peer-auth §4.2 — one origin may host several
|
|
13
|
+
* instances, so origin-level comparison would confuse them). The display name
|
|
14
|
+
* lives in config, never on the wire. */
|
|
15
|
+
export const InstanceId = Type.String({
|
|
16
|
+
$id: "InstanceId",
|
|
17
|
+
pattern: "^wss?://[^\\s?#]+$",
|
|
18
|
+
});
|
|
19
|
+
export type InstanceId = Static<typeof InstanceId>;
|
|
20
|
+
|
|
21
|
+
/** A delivery-frame id: `<instance>/<counter>`, numbered by the instance that
|
|
22
|
+
* issued the frame. It exists so `reply_to` can point at one frame; it is not
|
|
23
|
+
* a cursor and carries no ordering across instances. */
|
|
24
|
+
export const Mid = Type.String({
|
|
25
|
+
$id: "Mid",
|
|
26
|
+
pattern: "^wss?://[^\\s?#]+/\\d+$",
|
|
27
|
+
});
|
|
28
|
+
export type Mid = Static<typeof Mid>;
|
|
29
|
+
|
|
30
|
+
/** Who a connection speaks as. Set once by `hello` and fixed for the
|
|
31
|
+
* connection's life; the op attribute table's `roles` is checked against it. */
|
|
32
|
+
export const Role = Type.Union(
|
|
33
|
+
[Type.Literal("session"), Type.Literal("user"), Type.Literal("instance")],
|
|
34
|
+
{ $id: "Role" },
|
|
35
|
+
);
|
|
36
|
+
export type Role = Static<typeof Role>;
|
|
37
|
+
|
|
38
|
+
/** A capability name. `hello` returns the set this instance has, and an op
|
|
39
|
+
* whose `capability` is outside that set answers `capability_unavailable`. */
|
|
40
|
+
export const Capability = Type.Union(
|
|
41
|
+
[
|
|
42
|
+
Type.Literal("fork"),
|
|
43
|
+
Type.Literal("launcher"),
|
|
44
|
+
/** A gateway webhook source is configured, so request activity arrives to
|
|
45
|
+
* be pushed on the `llm_requests` topic. */
|
|
46
|
+
Type.Literal("llm_events"),
|
|
47
|
+
Type.Literal("llm_stats"),
|
|
48
|
+
Type.Literal("llm_status"),
|
|
49
|
+
Type.Literal("llm_usage"),
|
|
50
|
+
Type.Literal("sandbox"),
|
|
51
|
+
Type.Literal("terminal"),
|
|
52
|
+
Type.Literal("translate"),
|
|
53
|
+
],
|
|
54
|
+
{ $id: "Capability" },
|
|
55
|
+
);
|
|
56
|
+
export type Capability = Static<typeof Capability>;
|
|
57
|
+
|
|
58
|
+
/** A Unix-milliseconds timestamp. Every wire field naming a point in time is
|
|
59
|
+
* this type and ends in `_at`; durations carry their unit instead (`*_ms` /
|
|
60
|
+
* `*_secs`). */
|
|
61
|
+
export const Timestamp = Type.Integer({ $id: "Timestamp", minimum: 0 });
|
|
62
|
+
export type Timestamp = Static<typeof Timestamp>;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export * from "./attributes.ts";
|
|
2
|
+
export * from "./common/hello.ts";
|
|
3
|
+
export * from "./common/ping.ts";
|
|
4
|
+
export * from "./common/shutdown.ts";
|
|
5
|
+
export * from "./common/topics.ts";
|
|
6
|
+
export * from "./control/agents.ts";
|
|
7
|
+
export * from "./control/files.ts";
|
|
8
|
+
export * from "./control/kv.ts";
|
|
9
|
+
export * from "./control/launcher.ts";
|
|
10
|
+
export * from "./control/llm.ts";
|
|
11
|
+
export * from "./control/peers.ts";
|
|
12
|
+
export * from "./control/sandbox.ts";
|
|
13
|
+
export * from "./control/session-errors.ts";
|
|
14
|
+
export * from "./control/session-status.ts";
|
|
15
|
+
export * from "./control/session.ts";
|
|
16
|
+
export * from "./control/transcript.ts";
|
|
17
|
+
export * from "./control/translate.ts";
|
|
18
|
+
export * from "./envelope.ts";
|
|
19
|
+
export * from "./errors.ts";
|
|
20
|
+
export * from "./identifiers.ts";
|
|
21
|
+
export * from "./messaging/message.ts";
|
|
22
|
+
export * from "./messaging/notify.ts";
|
|
23
|
+
export * from "./messaging/say.ts";
|
|
24
|
+
export * from "./schemas.ts";
|
|
25
|
+
export * from "./upstream.ts";
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { type Static, Type } from "@sinclair/typebox";
|
|
2
|
+
import { request, response, topicFrame } from "../envelope.ts";
|
|
3
|
+
import { InstanceId, Mid, Sid, Timestamp } from "../identifiers.ts";
|
|
4
|
+
|
|
5
|
+
/** Why a message was not handed to its recipient right away.
|
|
6
|
+
*
|
|
7
|
+
* These are not errors: the op succeeded and the message is held in the
|
|
8
|
+
* recipient's inbox. They tell the sender what to do next — wait, resend to
|
|
9
|
+
* another session, or give up. The op itself fails only when `to` names no
|
|
10
|
+
* session anywhere in the cluster (`session_not_found`). */
|
|
11
|
+
export const UndeliveredReason = Type.Union(
|
|
12
|
+
[
|
|
13
|
+
/** Alive, but not yet listening. The daemon delivers when it starts. */
|
|
14
|
+
Type.Literal("preparing"),
|
|
15
|
+
Type.Literal("paused"),
|
|
16
|
+
/** The session is gone. */
|
|
17
|
+
Type.Literal("disappeared"),
|
|
18
|
+
/** The instance holding the session cannot be reached over the mesh. */
|
|
19
|
+
Type.Literal("instance_unreachable"),
|
|
20
|
+
/** The recipient declined it for now — too much arriving at once, a full
|
|
21
|
+
* queue, or a message it has already been handed. It stays in the inbox and
|
|
22
|
+
* is offered again, so the sender waits rather than resending. */
|
|
23
|
+
Type.Literal("throttled"),
|
|
24
|
+
/** The recipient's inbox is at its limit; the oldest message was dropped
|
|
25
|
+
* to make room for this one. */
|
|
26
|
+
Type.Literal("inbox_full"),
|
|
27
|
+
],
|
|
28
|
+
{ $id: "UndeliveredReason" },
|
|
29
|
+
);
|
|
30
|
+
export type UndeliveredReason = Static<typeof UndeliveredReason>;
|
|
31
|
+
|
|
32
|
+
/** A session the sender could send to instead, offered when the addressee is
|
|
33
|
+
* paused or gone: a session live now in the same repository. The workspace
|
|
34
|
+
* name is there because several worktrees of one repository qualify and the
|
|
35
|
+
* sender has to tell them apart. */
|
|
36
|
+
export const CandidateSession = Type.Object(
|
|
37
|
+
{
|
|
38
|
+
sid: Sid,
|
|
39
|
+
/** Workspace name, when the session runs in a named workspace. */
|
|
40
|
+
ws: Type.Optional(Type.String()),
|
|
41
|
+
instance: InstanceId,
|
|
42
|
+
},
|
|
43
|
+
{ $id: "CandidateSession" },
|
|
44
|
+
);
|
|
45
|
+
export type CandidateSession = Static<typeof CandidateSession>;
|
|
46
|
+
|
|
47
|
+
export const MessageSendArgs = Type.Object({
|
|
48
|
+
/** The recipient session. There is no room to address: a message goes to one
|
|
49
|
+
* session. */
|
|
50
|
+
to: Sid,
|
|
51
|
+
text: Type.String({ minLength: 1 }),
|
|
52
|
+
/** The `mid` of the frame this message answers, when it answers one. */
|
|
53
|
+
reply_to: Type.Optional(Mid),
|
|
54
|
+
});
|
|
55
|
+
export type MessageSendArgs = Static<typeof MessageSendArgs>;
|
|
56
|
+
|
|
57
|
+
export const MessageSendResult = Type.Object({
|
|
58
|
+
/** True when the recipient received it now; false when it went to the inbox
|
|
59
|
+
* to be delivered once the recipient can take it. */
|
|
60
|
+
delivered: Type.Boolean(),
|
|
61
|
+
/** Present when `delivered` is false. */
|
|
62
|
+
reason: Type.Optional(UndeliveredReason),
|
|
63
|
+
/** Present when the addressee is paused or gone. */
|
|
64
|
+
candidates: Type.Optional(Type.Array(CandidateSession)),
|
|
65
|
+
});
|
|
66
|
+
export type MessageSendResult = Static<typeof MessageSendResult>;
|
|
67
|
+
|
|
68
|
+
export const MessageSendRequest = request("message_send", MessageSendArgs);
|
|
69
|
+
export const MessageSendResponse = response("message_send", MessageSendResult);
|
|
70
|
+
|
|
71
|
+
/** A message as the recipient receives it, on topic `inbox`.
|
|
72
|
+
*
|
|
73
|
+
* To answer it, send to `from`. The route is the sender's id and nothing else,
|
|
74
|
+
* so no reply instructions travel on the wire: the wording a session sees
|
|
75
|
+
* belongs to whichever client renders it. */
|
|
76
|
+
export const InboxMessage = Type.Object(
|
|
77
|
+
{
|
|
78
|
+
mid: Mid,
|
|
79
|
+
from: Sid,
|
|
80
|
+
/** How the sender should be shown, resolved by the issuing instance. */
|
|
81
|
+
from_label: Type.String(),
|
|
82
|
+
text: Type.String(),
|
|
83
|
+
reply_to: Type.Optional(Mid),
|
|
84
|
+
sent_at: Timestamp,
|
|
85
|
+
},
|
|
86
|
+
{ $id: "InboxMessage" },
|
|
87
|
+
);
|
|
88
|
+
export type InboxMessage = Static<typeof InboxMessage>;
|
|
89
|
+
|
|
90
|
+
/** The `inbox` topic. Its snapshot is whatever is still undelivered for this
|
|
91
|
+
* session; each later frame is one newly arrived message. */
|
|
92
|
+
export const InboxFrame = topicFrame("inbox", Type.Array(InboxMessage));
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type Static, Type } from "@sinclair/typebox";
|
|
2
|
+
import { request, response, topicFrame } from "../envelope.ts";
|
|
3
|
+
import { Sid, Timestamp } from "../identifiers.ts";
|
|
4
|
+
|
|
5
|
+
/** A short line meant to reach a person watching, not the session's own turn.
|
|
6
|
+
* Delivery is best effort and unacknowledged; unlike `message_send`, nothing is
|
|
7
|
+
* held for later. */
|
|
8
|
+
export const NotifySendArgs = Type.Object({
|
|
9
|
+
/** The session the notification is about. Omit to mean the caller. */
|
|
10
|
+
sid: Type.Optional(Sid),
|
|
11
|
+
text: Type.String({ minLength: 1 }),
|
|
12
|
+
});
|
|
13
|
+
export type NotifySendArgs = Static<typeof NotifySendArgs>;
|
|
14
|
+
|
|
15
|
+
export const NotifySendResult = Type.Object({});
|
|
16
|
+
export type NotifySendResult = Static<typeof NotifySendResult>;
|
|
17
|
+
|
|
18
|
+
export const NotifySendRequest = request("notify_send", NotifySendArgs);
|
|
19
|
+
export const NotifySendResponse = response("notify_send", NotifySendResult);
|
|
20
|
+
|
|
21
|
+
export const Notification = Type.Object(
|
|
22
|
+
{
|
|
23
|
+
sid: Sid,
|
|
24
|
+
/** How the session should be shown, resolved by the issuing instance. */
|
|
25
|
+
sid_label: Type.String(),
|
|
26
|
+
text: Type.String(),
|
|
27
|
+
sent_at: Timestamp,
|
|
28
|
+
},
|
|
29
|
+
{ $id: "Notification" },
|
|
30
|
+
);
|
|
31
|
+
export type Notification = Static<typeof Notification>;
|
|
32
|
+
|
|
33
|
+
/** The `notify` topic. There is nothing to snapshot — a notification matters
|
|
34
|
+
* when it happens — so every frame is a new one. */
|
|
35
|
+
export const NotifyFrame = topicFrame("notify", Notification);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type Static, Type } from "@sinclair/typebox";
|
|
2
|
+
import { request, response } from "../envelope.ts";
|
|
3
|
+
import { Sid, Timestamp } from "../identifiers.ts";
|
|
4
|
+
|
|
5
|
+
/** A session speaking to whoever is watching, rather than to one recipient.
|
|
6
|
+
*
|
|
7
|
+
* What was said is already in the calling session's transcript, since the call
|
|
8
|
+
* itself is a tool use there. So this op only pushes "this session just spoke"
|
|
9
|
+
* to the watchers; the instance keeps no log of its own. */
|
|
10
|
+
export const SayPostArgs = Type.Object({
|
|
11
|
+
text: Type.String({ minLength: 1 }),
|
|
12
|
+
});
|
|
13
|
+
export type SayPostArgs = Static<typeof SayPostArgs>;
|
|
14
|
+
|
|
15
|
+
export const SayPostResult = Type.Object({
|
|
16
|
+
posted_at: Timestamp,
|
|
17
|
+
});
|
|
18
|
+
export type SayPostResult = Static<typeof SayPostResult>;
|
|
19
|
+
|
|
20
|
+
export const SayPostRequest = request("say_post", SayPostArgs);
|
|
21
|
+
export const SayPostResponse = response("say_post", SayPostResult);
|
|
22
|
+
|
|
23
|
+
/** Clears the unread mark a `say_post` raised. The mark is instance state that
|
|
24
|
+
* a restart may forget — nothing depends on it surviving. */
|
|
25
|
+
export const SayMarkReadArgs = Type.Object({
|
|
26
|
+
/** The session whose unread mark is cleared. Omit to clear every one. */
|
|
27
|
+
sid: Type.Optional(Sid),
|
|
28
|
+
});
|
|
29
|
+
export type SayMarkReadArgs = Static<typeof SayMarkReadArgs>;
|
|
30
|
+
|
|
31
|
+
export const SayMarkReadResult = Type.Object({});
|
|
32
|
+
export type SayMarkReadResult = Static<typeof SayMarkReadResult>;
|
|
33
|
+
|
|
34
|
+
export const SayMarkReadRequest = request("say_mark_read", SayMarkReadArgs);
|
|
35
|
+
export const SayMarkReadResponse = response("say_mark_read", SayMarkReadResult);
|
package/src/schemas.ts
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import type { TSchema } from "@sinclair/typebox";
|
|
2
|
+
import { TypeCompiler, type TypeCheck } from "@sinclair/typebox/compiler";
|
|
3
|
+
import type { OpName } from "./attributes.ts";
|
|
4
|
+
import { HelloRequest, HelloResponse } from "./common/hello.ts";
|
|
5
|
+
import { InstancePingRequest, InstancePingResponse } from "./common/ping.ts";
|
|
6
|
+
import { InstanceShutdownRequest, InstanceShutdownResponse } from "./common/shutdown.ts";
|
|
7
|
+
import {
|
|
8
|
+
TopicSubscribeRequest,
|
|
9
|
+
TopicSubscribeResponse,
|
|
10
|
+
TopicUnsubscribeRequest,
|
|
11
|
+
TopicUnsubscribeResponse,
|
|
12
|
+
} from "./common/topics.ts";
|
|
13
|
+
import { AgentsFrame } from "./control/agents.ts";
|
|
14
|
+
import {
|
|
15
|
+
DirListRequest,
|
|
16
|
+
DirListResponse,
|
|
17
|
+
DirTreeRequest,
|
|
18
|
+
DirTreeResponse,
|
|
19
|
+
FileCreateRequest,
|
|
20
|
+
FileCreateResponse,
|
|
21
|
+
FileDeleteRequest,
|
|
22
|
+
FileDeleteResponse,
|
|
23
|
+
FileEditRequest,
|
|
24
|
+
FileEditResponse,
|
|
25
|
+
FileFindRequest,
|
|
26
|
+
FileFindResponse,
|
|
27
|
+
FileReadRequest,
|
|
28
|
+
FileReadResponse,
|
|
29
|
+
FileStatBatchRequest,
|
|
30
|
+
FileStatBatchResponse,
|
|
31
|
+
FileWriteRequest,
|
|
32
|
+
FileWriteResponse,
|
|
33
|
+
} from "./control/files.ts";
|
|
34
|
+
import {
|
|
35
|
+
KvDeleteRequest,
|
|
36
|
+
KvDeleteResponse,
|
|
37
|
+
KvFrame,
|
|
38
|
+
KvReadRequest,
|
|
39
|
+
KvReadResponse,
|
|
40
|
+
KvWriteRequest,
|
|
41
|
+
KvWriteResponse,
|
|
42
|
+
} from "./control/kv.ts";
|
|
43
|
+
import {
|
|
44
|
+
LauncherConfigReadRequest,
|
|
45
|
+
LauncherConfigReadResponse,
|
|
46
|
+
LauncherRunRequest,
|
|
47
|
+
LauncherRunResponse,
|
|
48
|
+
} from "./control/launcher.ts";
|
|
49
|
+
import {
|
|
50
|
+
LlmRequestsFrame,
|
|
51
|
+
LlmStatsReadRequest,
|
|
52
|
+
LlmStatsReadResponse,
|
|
53
|
+
LlmStatusFrame,
|
|
54
|
+
LlmUsageReadRequest,
|
|
55
|
+
LlmUsageReadResponse,
|
|
56
|
+
} from "./control/llm.ts";
|
|
57
|
+
import { PeersFrame } from "./control/peers.ts";
|
|
58
|
+
import {
|
|
59
|
+
SandboxGrantRequest,
|
|
60
|
+
SandboxGrantResponse,
|
|
61
|
+
SandboxRevokeRequest,
|
|
62
|
+
SandboxRevokeResponse,
|
|
63
|
+
} from "./control/sandbox.ts";
|
|
64
|
+
import { SessionErrorsFrame } from "./control/session-errors.ts";
|
|
65
|
+
import { SessionStatusFrame } from "./control/session-status.ts";
|
|
66
|
+
import {
|
|
67
|
+
SessionDumpWriteRequest,
|
|
68
|
+
SessionDumpWriteResponse,
|
|
69
|
+
SessionEnvReadRequest,
|
|
70
|
+
SessionEnvReadResponse,
|
|
71
|
+
SessionForkOriginRequest,
|
|
72
|
+
SessionForkOriginResponse,
|
|
73
|
+
SessionKillRequest,
|
|
74
|
+
SessionKillResponse,
|
|
75
|
+
SessionLastLiveRemoveRequest,
|
|
76
|
+
SessionLastLiveRemoveResponse,
|
|
77
|
+
SessionRenameRequest,
|
|
78
|
+
SessionRenameResponse,
|
|
79
|
+
SessionSearchRequest,
|
|
80
|
+
SessionSearchResponse,
|
|
81
|
+
} from "./control/session.ts";
|
|
82
|
+
import {
|
|
83
|
+
TranscriptFrame,
|
|
84
|
+
TranscriptReadRequest,
|
|
85
|
+
TranscriptReadResponse,
|
|
86
|
+
} from "./control/transcript.ts";
|
|
87
|
+
import { TranslateRunRequest, TranslateRunResponse } from "./control/translate.ts";
|
|
88
|
+
import { InboxFrame, MessageSendRequest, MessageSendResponse } from "./messaging/message.ts";
|
|
89
|
+
import { NotifyFrame, NotifySendRequest, NotifySendResponse } from "./messaging/notify.ts";
|
|
90
|
+
import {
|
|
91
|
+
SayMarkReadRequest,
|
|
92
|
+
SayMarkReadResponse,
|
|
93
|
+
SayPostRequest,
|
|
94
|
+
SayPostResponse,
|
|
95
|
+
} from "./messaging/say.ts";
|
|
96
|
+
|
|
97
|
+
export interface OpSchemas {
|
|
98
|
+
readonly request: TSchema;
|
|
99
|
+
readonly response: TSchema;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The schema pair for every op.
|
|
103
|
+
*
|
|
104
|
+
* The op attribute table says who may call an op and where it runs; this says
|
|
105
|
+
* what it takes and what it answers. The two are checked against each other:
|
|
106
|
+
* an op in one and not the other is a hole in the contract, not a stage of it. */
|
|
107
|
+
export const OP_SCHEMAS: Record<OpName, OpSchemas> = {
|
|
108
|
+
hello: { request: HelloRequest, response: HelloResponse },
|
|
109
|
+
instance_ping: { request: InstancePingRequest, response: InstancePingResponse },
|
|
110
|
+
instance_shutdown: { request: InstanceShutdownRequest, response: InstanceShutdownResponse },
|
|
111
|
+
topic_subscribe: { request: TopicSubscribeRequest, response: TopicSubscribeResponse },
|
|
112
|
+
topic_unsubscribe: { request: TopicUnsubscribeRequest, response: TopicUnsubscribeResponse },
|
|
113
|
+
message_send: { request: MessageSendRequest, response: MessageSendResponse },
|
|
114
|
+
say_post: { request: SayPostRequest, response: SayPostResponse },
|
|
115
|
+
say_mark_read: { request: SayMarkReadRequest, response: SayMarkReadResponse },
|
|
116
|
+
notify_send: { request: NotifySendRequest, response: NotifySendResponse },
|
|
117
|
+
|
|
118
|
+
session_kill: { request: SessionKillRequest, response: SessionKillResponse },
|
|
119
|
+
session_rename: { request: SessionRenameRequest, response: SessionRenameResponse },
|
|
120
|
+
session_env_read: { request: SessionEnvReadRequest, response: SessionEnvReadResponse },
|
|
121
|
+
session_search: { request: SessionSearchRequest, response: SessionSearchResponse },
|
|
122
|
+
session_dump_write: { request: SessionDumpWriteRequest, response: SessionDumpWriteResponse },
|
|
123
|
+
transcript_read: { request: TranscriptReadRequest, response: TranscriptReadResponse },
|
|
124
|
+
session_fork_origin: { request: SessionForkOriginRequest, response: SessionForkOriginResponse },
|
|
125
|
+
session_last_live_remove: {
|
|
126
|
+
request: SessionLastLiveRemoveRequest,
|
|
127
|
+
response: SessionLastLiveRemoveResponse,
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
dir_list: { request: DirListRequest, response: DirListResponse },
|
|
131
|
+
file_read: { request: FileReadRequest, response: FileReadResponse },
|
|
132
|
+
file_write: { request: FileWriteRequest, response: FileWriteResponse },
|
|
133
|
+
file_create: { request: FileCreateRequest, response: FileCreateResponse },
|
|
134
|
+
file_edit: { request: FileEditRequest, response: FileEditResponse },
|
|
135
|
+
file_delete: { request: FileDeleteRequest, response: FileDeleteResponse },
|
|
136
|
+
file_find: { request: FileFindRequest, response: FileFindResponse },
|
|
137
|
+
file_stat_batch: { request: FileStatBatchRequest, response: FileStatBatchResponse },
|
|
138
|
+
dir_tree: { request: DirTreeRequest, response: DirTreeResponse },
|
|
139
|
+
|
|
140
|
+
launcher_config_read: {
|
|
141
|
+
request: LauncherConfigReadRequest,
|
|
142
|
+
response: LauncherConfigReadResponse,
|
|
143
|
+
},
|
|
144
|
+
launcher_run: { request: LauncherRunRequest, response: LauncherRunResponse },
|
|
145
|
+
sandbox_grant: { request: SandboxGrantRequest, response: SandboxGrantResponse },
|
|
146
|
+
sandbox_revoke: { request: SandboxRevokeRequest, response: SandboxRevokeResponse },
|
|
147
|
+
translate_run: { request: TranslateRunRequest, response: TranslateRunResponse },
|
|
148
|
+
llm_usage_read: { request: LlmUsageReadRequest, response: LlmUsageReadResponse },
|
|
149
|
+
llm_stats_read: { request: LlmStatsReadRequest, response: LlmStatsReadResponse },
|
|
150
|
+
|
|
151
|
+
kv_read: { request: KvReadRequest, response: KvReadResponse },
|
|
152
|
+
kv_write: { request: KvWriteRequest, response: KvWriteResponse },
|
|
153
|
+
kv_delete: { request: KvDeleteRequest, response: KvDeleteResponse },
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/** The frame schema for every topic.
|
|
157
|
+
*
|
|
158
|
+
* A topic's snapshot and its later frames share one schema, which is the point
|
|
159
|
+
* of the form: a subscriber has one way to read the current value and every
|
|
160
|
+
* change to it. */
|
|
161
|
+
export const TOPIC_SCHEMAS = {
|
|
162
|
+
inbox: InboxFrame,
|
|
163
|
+
notify: NotifyFrame,
|
|
164
|
+
peers: PeersFrame,
|
|
165
|
+
agents: AgentsFrame,
|
|
166
|
+
session_status: SessionStatusFrame,
|
|
167
|
+
transcript: TranscriptFrame,
|
|
168
|
+
session_errors: SessionErrorsFrame,
|
|
169
|
+
llm_requests: LlmRequestsFrame,
|
|
170
|
+
llm_status: LlmStatusFrame,
|
|
171
|
+
kv: KvFrame,
|
|
172
|
+
} as const;
|
|
173
|
+
|
|
174
|
+
const compiled = new WeakMap<TSchema, TypeCheck<TSchema>>();
|
|
175
|
+
|
|
176
|
+
/** A compiled validator for a schema, made once and reused. Validation is the
|
|
177
|
+
* contract's own job: both sides check against these rather than each writing
|
|
178
|
+
* their own field-by-field tests. */
|
|
179
|
+
export function validator<T extends TSchema>(schema: T): TypeCheck<T> {
|
|
180
|
+
const hit = compiled.get(schema);
|
|
181
|
+
if (hit) return hit as TypeCheck<T>;
|
|
182
|
+
const made = TypeCompiler.Compile(schema);
|
|
183
|
+
compiled.set(schema, made as TypeCheck<TSchema>);
|
|
184
|
+
return made;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function isValid<T extends TSchema>(schema: T, value: unknown): boolean {
|
|
188
|
+
return validator(schema).Check(value);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The reasons a value fails a schema, as `path: message` lines. Suitable for
|
|
192
|
+
* the `msg` of an `invalid_args` error. */
|
|
193
|
+
export function validationErrors<T extends TSchema>(schema: T, value: unknown): string[] {
|
|
194
|
+
return [...validator(schema).Errors(value)].map((e) => `${e.path}: ${e.message}`);
|
|
195
|
+
}
|
package/src/upstream.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Systems other than ccmsg that own the meaning of a type.
|
|
2
|
+
*
|
|
3
|
+
* `claude` is the Claude Code CLI (`claude agents --json`, the session state
|
|
4
|
+
* files, transcript rows); `llm-gateway` is the proxy the daemon asks about
|
|
5
|
+
* quota, spend and upstream health. */
|
|
6
|
+
export type UpstreamSource = "claude" | "llm-gateway";
|
|
7
|
+
|
|
8
|
+
/** Marks a type whose vocabulary belongs to `source`, not to ccmsg.
|
|
9
|
+
*
|
|
10
|
+
* The mark says where an open set's future values come from, so a reader knows
|
|
11
|
+
* an unfamiliar `status` string is the upstream's to add rather than a bug. It
|
|
12
|
+
* does not exempt the type from this contract's spelling: the daemon renames
|
|
13
|
+
* and re-units every field as it copies the upstream document in, so what
|
|
14
|
+
* travels here is snake_case with Unix-ms instants like everything else. */
|
|
15
|
+
export function upstream(source: UpstreamSource, note: string): { description: string } {
|
|
16
|
+
return { description: `upstream: ${source} — ${note}` };
|
|
17
|
+
}
|