@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,64 @@
|
|
|
1
|
+
import { type Static, Type } from "@sinclair/typebox";
|
|
2
|
+
import { topicFrame } from "../envelope.ts";
|
|
3
|
+
import { InstanceId, Sid, Timestamp } from "../identifiers.ts";
|
|
4
|
+
import { upstream } from "../upstream.ts";
|
|
5
|
+
|
|
6
|
+
/** One session as the harness itself reports it, noted with which config home
|
|
7
|
+
* it was found under.
|
|
8
|
+
*
|
|
9
|
+
* This is the harness's view rather than the instance's: it covers sessions
|
|
10
|
+
* that never connected here, and it carries what only the process knows — its
|
|
11
|
+
* pid, its terminal, the title the session gave itself. The instance renames
|
|
12
|
+
* the fields as it copies them in; the words inside them stay the harness's,
|
|
13
|
+
* which is why the status-like fields are open sets. */
|
|
14
|
+
export const AgentInfo = Type.Object(
|
|
15
|
+
{
|
|
16
|
+
sid: Sid,
|
|
17
|
+
/** The instance that polled it, and whose host the pid belongs to. */
|
|
18
|
+
instance: InstanceId,
|
|
19
|
+
pid: Type.Integer({ minimum: 1 }),
|
|
20
|
+
cwd: Type.String(),
|
|
21
|
+
/** Whether the session is one a person is sitting at or one running on its
|
|
22
|
+
* own. An open set. */
|
|
23
|
+
kind: Type.String(),
|
|
24
|
+
started_at: Timestamp,
|
|
25
|
+
/** The session's own title, when it has set one. */
|
|
26
|
+
name: Type.Optional(Type.String()),
|
|
27
|
+
/** What the session is doing. An open set. */
|
|
28
|
+
status: Type.Optional(Type.String()),
|
|
29
|
+
/** What it is waiting on, in the harness's words. */
|
|
30
|
+
waiting_for: Type.Optional(Type.String()),
|
|
31
|
+
/** How a background session ended up. An open set; absent for a session a
|
|
32
|
+
* person is sitting at. */
|
|
33
|
+
state: Type.Optional(Type.String()),
|
|
34
|
+
/** The short handle a background session is addressed by. */
|
|
35
|
+
background_id: Type.Optional(Type.String()),
|
|
36
|
+
/** The config home this row was polled from. */
|
|
37
|
+
config_dir: Type.String(),
|
|
38
|
+
/** The terminal the session runs in, which is the handle a rename types
|
|
39
|
+
* into. Absent when the process does not name one or its environment could
|
|
40
|
+
* not be read. Read from the running process rather than remembered from
|
|
41
|
+
* when it started, since resuming a session gives it a new process. */
|
|
42
|
+
terminal_id: Type.Optional(Type.String()),
|
|
43
|
+
/** Which namespace that terminal lives in. Absent means the process set
|
|
44
|
+
* none, which the multiplexer treats as its default — not the instance's
|
|
45
|
+
* own namespace, which can differ. Typing into the wrong namespace reports a
|
|
46
|
+
* live session as gone. */
|
|
47
|
+
terminal_namespace: Type.Optional(Type.String()),
|
|
48
|
+
},
|
|
49
|
+
{ $id: "AgentInfo", ...upstream("claude", "one row of the harness's session list") },
|
|
50
|
+
);
|
|
51
|
+
export type AgentInfo = Static<typeof AgentInfo>;
|
|
52
|
+
|
|
53
|
+
/** The `agents` topic. Whole-value per instance, like `peers`.
|
|
54
|
+
*
|
|
55
|
+
* The instance polls the harness only while somebody is listening here, so the
|
|
56
|
+
* list is as fresh as the subscription is old. */
|
|
57
|
+
export const AgentsFrame = topicFrame(
|
|
58
|
+
"agents",
|
|
59
|
+
Type.Object({
|
|
60
|
+
agents: Type.Array(AgentInfo),
|
|
61
|
+
/** When the poll behind this list ran. Absent before the first one. */
|
|
62
|
+
polled_at: Type.Optional(Timestamp),
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { type Static, Type } from "@sinclair/typebox";
|
|
2
|
+
import { request, response } from "../envelope.ts";
|
|
3
|
+
import { Sid, Timestamp } from "../identifiers.ts";
|
|
4
|
+
|
|
5
|
+
/** Which authorization surface a path is reached through, and with it the
|
|
6
|
+
* shape the path takes.
|
|
7
|
+
*
|
|
8
|
+
* - `contained` — inside the session's own root. The path is relative to it.
|
|
9
|
+
* - `workspace` — inside one of the workspace folders the session's editor
|
|
10
|
+
* configuration names. The path is absolute, and access is by directory
|
|
11
|
+
* prefix.
|
|
12
|
+
* - `external` — one file outside both, named by the session's transcript. The
|
|
13
|
+
* path is absolute and the grant is that exact file: there is no directory to
|
|
14
|
+
* descend, which is why listing and searching offer no `external`.
|
|
15
|
+
*
|
|
16
|
+
* The kind is an argument rather than three ops because the reply is the same
|
|
17
|
+
* document in all three cases and only the check in front of it differs. */
|
|
18
|
+
export const FileKind = Type.Union(
|
|
19
|
+
[Type.Literal("contained"), Type.Literal("workspace"), Type.Literal("external")],
|
|
20
|
+
{ $id: "FileKind" },
|
|
21
|
+
);
|
|
22
|
+
export type FileKind = Static<typeof FileKind>;
|
|
23
|
+
|
|
24
|
+
/** The kinds that name a directory to work within. */
|
|
25
|
+
export const DirKind = Type.Union([Type.Literal("contained"), Type.Literal("workspace")], {
|
|
26
|
+
$id: "DirKind",
|
|
27
|
+
});
|
|
28
|
+
export type DirKind = Static<typeof DirKind>;
|
|
29
|
+
|
|
30
|
+
/** Lists one directory.
|
|
31
|
+
*
|
|
32
|
+
* What a session may see differs from what a person may see — the role decides
|
|
33
|
+
* the visible range, not whether the call is allowed. */
|
|
34
|
+
export const DirListArgs = Type.Object({
|
|
35
|
+
/** The session whose roots are browsed. */
|
|
36
|
+
sid: Sid,
|
|
37
|
+
kind: DirKind,
|
|
38
|
+
/** Relative to the session root for `contained`, where an empty string is the
|
|
39
|
+
* root itself; absolute for `workspace`. */
|
|
40
|
+
path: Type.Optional(Type.String()),
|
|
41
|
+
});
|
|
42
|
+
export type DirListArgs = Static<typeof DirListArgs>;
|
|
43
|
+
|
|
44
|
+
/** One directory entry. Sockets, devices and the like collapse to `other`; a
|
|
45
|
+
* symlink is reported as itself, and one pointing out of the root is listed but
|
|
46
|
+
* refuses to resolve. */
|
|
47
|
+
export const DirEntry = Type.Object(
|
|
48
|
+
{
|
|
49
|
+
name: Type.String(),
|
|
50
|
+
type: Type.Union([
|
|
51
|
+
Type.Literal("file"),
|
|
52
|
+
Type.Literal("dir"),
|
|
53
|
+
Type.Literal("symlink"),
|
|
54
|
+
Type.Literal("other"),
|
|
55
|
+
]),
|
|
56
|
+
/** Bytes. Files only. */
|
|
57
|
+
size: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
58
|
+
/** Absent when the host would not state one. */
|
|
59
|
+
mtime_at: Type.Optional(Timestamp),
|
|
60
|
+
},
|
|
61
|
+
{ $id: "DirEntry" },
|
|
62
|
+
);
|
|
63
|
+
export type DirEntry = Static<typeof DirEntry>;
|
|
64
|
+
|
|
65
|
+
export const DirListResult = Type.Object({
|
|
66
|
+
sid: Sid,
|
|
67
|
+
/** The path as the instance normalized it, in the shape the kind implies. */
|
|
68
|
+
path: Type.String(),
|
|
69
|
+
entries: Type.Array(DirEntry),
|
|
70
|
+
});
|
|
71
|
+
export type DirListResult = Static<typeof DirListResult>;
|
|
72
|
+
|
|
73
|
+
export const DirListRequest = request("dir_list", DirListArgs);
|
|
74
|
+
export const DirListResponse = response("dir_list", DirListResult);
|
|
75
|
+
|
|
76
|
+
export const FileReadArgs = Type.Object({
|
|
77
|
+
sid: Sid,
|
|
78
|
+
kind: FileKind,
|
|
79
|
+
path: Type.String({ minLength: 1 }),
|
|
80
|
+
});
|
|
81
|
+
export type FileReadArgs = Static<typeof FileReadArgs>;
|
|
82
|
+
|
|
83
|
+
export const FileReadResult = Type.Object({
|
|
84
|
+
sid: Sid,
|
|
85
|
+
path: Type.String(),
|
|
86
|
+
/** Size on disk, which may exceed what `content` carries. */
|
|
87
|
+
size: Type.Integer({ minimum: 0 }),
|
|
88
|
+
/** The content was cut at the instance's read limit. */
|
|
89
|
+
truncated: Type.Boolean(),
|
|
90
|
+
/** The head of the file sniffed as binary, so no text is sent. */
|
|
91
|
+
binary: Type.Boolean(),
|
|
92
|
+
/** Empty when `binary`. */
|
|
93
|
+
content: Type.String(),
|
|
94
|
+
/** Also the token an edit passes back: an edit whose file has moved on since
|
|
95
|
+
* this read is refused instead of overwriting the newer copy. */
|
|
96
|
+
mtime_at: Timestamp,
|
|
97
|
+
});
|
|
98
|
+
export type FileReadResult = Static<typeof FileReadResult>;
|
|
99
|
+
|
|
100
|
+
export const FileReadRequest = request("file_read", FileReadArgs);
|
|
101
|
+
export const FileReadResponse = response("file_read", FileReadResult);
|
|
102
|
+
|
|
103
|
+
/** Writes a new file into the session's inbox directory.
|
|
104
|
+
*
|
|
105
|
+
* The one place a client may put a file without the user having opened it
|
|
106
|
+
* first, which is why it takes no `kind`: the destination is fixed and only the
|
|
107
|
+
* name within it is the caller's. `file_create` is the general form. */
|
|
108
|
+
export const FileWriteArgs = Type.Object({
|
|
109
|
+
sid: Sid,
|
|
110
|
+
/** Relative to the session's working directory. */
|
|
111
|
+
path: Type.String({ minLength: 1 }),
|
|
112
|
+
content: Type.String(),
|
|
113
|
+
});
|
|
114
|
+
export type FileWriteArgs = Static<typeof FileWriteArgs>;
|
|
115
|
+
|
|
116
|
+
export const FileWriteResult = Type.Object({
|
|
117
|
+
sid: Sid,
|
|
118
|
+
path: Type.String(),
|
|
119
|
+
});
|
|
120
|
+
export type FileWriteResult = Static<typeof FileWriteResult>;
|
|
121
|
+
|
|
122
|
+
export const FileWriteRequest = request("file_write", FileWriteArgs);
|
|
123
|
+
export const FileWriteResponse = response("file_write", FileWriteResult);
|
|
124
|
+
|
|
125
|
+
/** Creates a file that does not exist yet.
|
|
126
|
+
*
|
|
127
|
+
* The symmetric partner of `file_edit`: create versus overwrite. It never
|
|
128
|
+
* replaces an existing path, and it does not make parent directories. There is
|
|
129
|
+
* no `external` kind — that allowlist names single files, so it holds no
|
|
130
|
+
* directory to create in. */
|
|
131
|
+
export const FileCreateArgs = Type.Object({
|
|
132
|
+
sid: Sid,
|
|
133
|
+
kind: DirKind,
|
|
134
|
+
path: Type.String({ minLength: 1 }),
|
|
135
|
+
/** Usually empty: a client creates the file and lets the user fill it in. */
|
|
136
|
+
content: Type.String(),
|
|
137
|
+
});
|
|
138
|
+
export type FileCreateArgs = Static<typeof FileCreateArgs>;
|
|
139
|
+
|
|
140
|
+
export const FileCreateResult = Type.Object({
|
|
141
|
+
sid: Sid,
|
|
142
|
+
path: Type.String(),
|
|
143
|
+
});
|
|
144
|
+
export type FileCreateResult = Static<typeof FileCreateResult>;
|
|
145
|
+
|
|
146
|
+
export const FileCreateRequest = request("file_create", FileCreateArgs);
|
|
147
|
+
export const FileCreateResponse = response("file_create", FileCreateResult);
|
|
148
|
+
|
|
149
|
+
/** Overwrites an existing text file in place.
|
|
150
|
+
*
|
|
151
|
+
* It only ever overwrites — it does not create, delete or rename. The expected
|
|
152
|
+
* size and time come from the read the editor was populated from; a file that
|
|
153
|
+
* changed in between is refused rather than clobbered. A file whose current
|
|
154
|
+
* content sniffs as binary is refused too, so this can never turn a binary into
|
|
155
|
+
* text. */
|
|
156
|
+
export const FileEditArgs = Type.Object({
|
|
157
|
+
sid: Sid,
|
|
158
|
+
kind: FileKind,
|
|
159
|
+
path: Type.String({ minLength: 1 }),
|
|
160
|
+
content: Type.String(),
|
|
161
|
+
/** The modification time the editor read. */
|
|
162
|
+
expected_mtime_at: Timestamp,
|
|
163
|
+
/** Guards a change that happened to leave the modification time alone, which
|
|
164
|
+
* a coarse filesystem clock makes possible. */
|
|
165
|
+
expected_size: Type.Integer({ minimum: 0 }),
|
|
166
|
+
});
|
|
167
|
+
export type FileEditArgs = Static<typeof FileEditArgs>;
|
|
168
|
+
|
|
169
|
+
export const FileEditResult = Type.Object({
|
|
170
|
+
sid: Sid,
|
|
171
|
+
path: Type.String(),
|
|
172
|
+
size: Type.Integer({ minimum: 0 }),
|
|
173
|
+
/** The new lock token, so a second edit in the same sitting needs no re-read. */
|
|
174
|
+
mtime_at: Timestamp,
|
|
175
|
+
});
|
|
176
|
+
export type FileEditResult = Static<typeof FileEditResult>;
|
|
177
|
+
|
|
178
|
+
export const FileEditRequest = request("file_edit", FileEditArgs);
|
|
179
|
+
export const FileEditResponse = response("file_edit", FileEditResult);
|
|
180
|
+
|
|
181
|
+
/** Deletes one regular file. Never a directory, never a symlink, never
|
|
182
|
+
* recursive: this only unlinks files a person could see as a leaf. `external`
|
|
183
|
+
* is not offered — that allowlist exists so a transcript's files can be read,
|
|
184
|
+
* and deleting one on the strength of having observed it is another matter. */
|
|
185
|
+
export const FileDeleteArgs = Type.Object({
|
|
186
|
+
sid: Sid,
|
|
187
|
+
kind: DirKind,
|
|
188
|
+
path: Type.String({ minLength: 1 }),
|
|
189
|
+
});
|
|
190
|
+
export type FileDeleteArgs = Static<typeof FileDeleteArgs>;
|
|
191
|
+
|
|
192
|
+
export const FileDeleteResult = Type.Object({
|
|
193
|
+
sid: Sid,
|
|
194
|
+
path: Type.String(),
|
|
195
|
+
});
|
|
196
|
+
export type FileDeleteResult = Static<typeof FileDeleteResult>;
|
|
197
|
+
|
|
198
|
+
export const FileDeleteRequest = request("file_delete", FileDeleteArgs);
|
|
199
|
+
export const FileDeleteResponse = response("file_delete", FileDeleteResult);
|
|
200
|
+
|
|
201
|
+
/** Searches for files by name under one browsable root.
|
|
202
|
+
*
|
|
203
|
+
* Listing one directory at a time cannot answer "where is the file whose path
|
|
204
|
+
* contains this" without the asker having already opened every candidate
|
|
205
|
+
* directory, so the walk happens where the whole subtree is reachable at once. */
|
|
206
|
+
export const FileFindArgs = Type.Object({
|
|
207
|
+
sid: Sid,
|
|
208
|
+
kind: DirKind,
|
|
209
|
+
/** Where to search. Relative to the session root for `contained`, absolute
|
|
210
|
+
* for `workspace`; absent means the root itself. */
|
|
211
|
+
root: Type.Optional(Type.String()),
|
|
212
|
+
/** Whitespace-separated words, all of which must appear in a candidate's
|
|
213
|
+
* path, with a leading `-` excluding one. Matching ignores case. A query with
|
|
214
|
+
* nothing to include matches nothing rather than returning the whole tree, so
|
|
215
|
+
* a cleared search box costs no walk. */
|
|
216
|
+
query: Type.String(),
|
|
217
|
+
/** Skip what the repository's ignore rules hide, and do not descend into
|
|
218
|
+
* ignored directories. Defaults to true, because the unfiltered result is
|
|
219
|
+
* dominated by vendored trees — with a capped reply those hits do not merely
|
|
220
|
+
* add noise, they push the real answer out of it. */
|
|
221
|
+
respect_gitignore: Type.Optional(Type.Boolean()),
|
|
222
|
+
});
|
|
223
|
+
export type FileFindArgs = Static<typeof FileFindArgs>;
|
|
224
|
+
|
|
225
|
+
export const FileFindHit = Type.Object(
|
|
226
|
+
{
|
|
227
|
+
/** In the shape the kind implies, so it can be opened as it stands. */
|
|
228
|
+
path: Type.String(),
|
|
229
|
+
type: Type.Union([Type.Literal("file"), Type.Literal("dir"), Type.Literal("symlink")]),
|
|
230
|
+
},
|
|
231
|
+
{ $id: "FileFindHit" },
|
|
232
|
+
);
|
|
233
|
+
export type FileFindHit = Static<typeof FileFindHit>;
|
|
234
|
+
|
|
235
|
+
export const FileFindResult = Type.Object({
|
|
236
|
+
sid: Sid,
|
|
237
|
+
hits: Type.Array(FileFindHit),
|
|
238
|
+
/** The walk hit its result cap or its visit budget, so the hits are not the
|
|
239
|
+
* complete match set. Saying so is better than implying these are all. */
|
|
240
|
+
truncated: Type.Boolean(),
|
|
241
|
+
});
|
|
242
|
+
export type FileFindResult = Static<typeof FileFindResult>;
|
|
243
|
+
|
|
244
|
+
export const FileFindRequest = request("file_find", FileFindArgs);
|
|
245
|
+
export const FileFindResponse = response("file_find", FileFindResult);
|
|
246
|
+
|
|
247
|
+
/** Asks which of a batch of paths the instance is willing to serve as files.
|
|
248
|
+
*
|
|
249
|
+
* A client that has found path-shaped text in a message uses this to decide
|
|
250
|
+
* which of them to turn into links. Each path is tried against the three
|
|
251
|
+
* surfaces in turn and the first that admits it and finds a regular file wins. */
|
|
252
|
+
export const FileStatBatchArgs = Type.Object({
|
|
253
|
+
sid: Sid,
|
|
254
|
+
/** Absolute paths, resolved by the caller. */
|
|
255
|
+
paths: Type.Array(Type.String()),
|
|
256
|
+
});
|
|
257
|
+
export type FileStatBatchArgs = Static<typeof FileStatBatchArgs>;
|
|
258
|
+
|
|
259
|
+
export const FileStatEntry = Type.Object(
|
|
260
|
+
{
|
|
261
|
+
kind: FileKind,
|
|
262
|
+
/** In the shape that kind implies, ready to be opened. */
|
|
263
|
+
path: Type.String(),
|
|
264
|
+
},
|
|
265
|
+
{ $id: "FileStatEntry" },
|
|
266
|
+
);
|
|
267
|
+
export type FileStatEntry = Static<typeof FileStatEntry>;
|
|
268
|
+
|
|
269
|
+
export const FileStatBatchResult = Type.Object({
|
|
270
|
+
/** One slot per requested path, in the same order.
|
|
271
|
+
*
|
|
272
|
+
* Design rationale: an unresolved path is `null` rather than being left out,
|
|
273
|
+
* which is the one place this contract states an absence instead of omitting
|
|
274
|
+
* it. The slots are the caller's own list read back, and dropping the misses
|
|
275
|
+
* would break the correspondence that makes the reply usable at all. A miss
|
|
276
|
+
* is deliberately one value for every reason — outside the allowlist, not a
|
|
277
|
+
* regular file, or simply not there — so this cannot be used to ask whether a
|
|
278
|
+
* path the caller may not read exists. */
|
|
279
|
+
results: Type.Array(Type.Union([FileStatEntry, Type.Null()])),
|
|
280
|
+
});
|
|
281
|
+
export type FileStatBatchResult = Static<typeof FileStatBatchResult>;
|
|
282
|
+
|
|
283
|
+
export const FileStatBatchRequest = request("file_stat_batch", FileStatBatchArgs);
|
|
284
|
+
export const FileStatBatchResponse = response("file_stat_batch", FileStatBatchResult);
|
|
285
|
+
|
|
286
|
+
/** Reads the directory tree the launcher may start a session in.
|
|
287
|
+
*
|
|
288
|
+
* Directories only: this answers "where could a new session run", which is a
|
|
289
|
+
* different question from browsing a session's files, and it is bounded by the
|
|
290
|
+
* roots the launcher is configured with rather than by any session. */
|
|
291
|
+
export const DirTreeArgs = Type.Object({
|
|
292
|
+
/** Configured roots, or any directory below one — a client expands the tree
|
|
293
|
+
* lazily by asking for a descendant. */
|
|
294
|
+
roots: Type.Array(Type.String()),
|
|
295
|
+
/** How deep to walk. Absent uses the configured depth; a lazy expansion asks
|
|
296
|
+
* for one level. */
|
|
297
|
+
depth: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
298
|
+
/** Substring of the root-relative path. Matching nodes and their ancestors
|
|
299
|
+
* survive the filter. */
|
|
300
|
+
filter: Type.Optional(Type.String()),
|
|
301
|
+
});
|
|
302
|
+
export type DirTreeArgs = Static<typeof DirTreeArgs>;
|
|
303
|
+
|
|
304
|
+
/** One node of the tree. No `children` means the walk stopped at its depth
|
|
305
|
+
* here, and a client may ask for this path to go further; an empty array means
|
|
306
|
+
* the directory holds no subdirectories. */
|
|
307
|
+
export const DirTreeEntry = Type.Recursive(
|
|
308
|
+
(self) =>
|
|
309
|
+
Type.Object({
|
|
310
|
+
path: Type.String(),
|
|
311
|
+
children: Type.Optional(Type.Array(self)),
|
|
312
|
+
}),
|
|
313
|
+
{ $id: "DirTreeEntry" },
|
|
314
|
+
);
|
|
315
|
+
export type DirTreeEntry = Static<typeof DirTreeEntry>;
|
|
316
|
+
|
|
317
|
+
export const DirTreeResult = Type.Object({
|
|
318
|
+
entries: Type.Array(DirTreeEntry),
|
|
319
|
+
});
|
|
320
|
+
export type DirTreeResult = Static<typeof DirTreeResult>;
|
|
321
|
+
|
|
322
|
+
export const DirTreeRequest = request("dir_tree", DirTreeArgs);
|
|
323
|
+
export const DirTreeResponse = response("dir_tree", DirTreeResult);
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { type Static, Type } from "@sinclair/typebox";
|
|
2
|
+
import { NAMESPACE_PATTERN } from "../common/topics.ts";
|
|
3
|
+
import { request, response, topicFrame } from "../envelope.ts";
|
|
4
|
+
import { Timestamp } from "../identifiers.ts";
|
|
5
|
+
|
|
6
|
+
/** Which namespace a key lives in. Namespaces do not nest and carry no meaning
|
|
7
|
+
* here: they keep one user of this store from colliding with another. */
|
|
8
|
+
export const Namespace = Type.String({ $id: "Namespace", pattern: `^${NAMESPACE_PATTERN}$` });
|
|
9
|
+
export type Namespace = Static<typeof Namespace>;
|
|
10
|
+
|
|
11
|
+
/** What names a value within its namespace.
|
|
12
|
+
*
|
|
13
|
+
* Unlike a namespace, a key may hold text a person typed, so it is bounded and
|
|
14
|
+
* kept free of control characters rather than shaped into an identifier. Any
|
|
15
|
+
* structure within a key — a prefix naming which machine a value belongs to,
|
|
16
|
+
* say — is its user's convention, and this contract reads none of it. */
|
|
17
|
+
export const KvKey = Type.String({
|
|
18
|
+
$id: "KvKey",
|
|
19
|
+
minLength: 1,
|
|
20
|
+
maxLength: 256,
|
|
21
|
+
pattern: "^[^\\u0000-\\u001f]+$",
|
|
22
|
+
});
|
|
23
|
+
export type KvKey = Static<typeof KvKey>;
|
|
24
|
+
|
|
25
|
+
/** A stored value. Any JSON: what a value means belongs to whoever writes and
|
|
26
|
+
* reads it, and a store that understood its contents would have to be changed
|
|
27
|
+
* every time one of them did. */
|
|
28
|
+
export const KvValue = Type.Unknown({ $id: "KvValue" });
|
|
29
|
+
|
|
30
|
+
/** Reads one value.
|
|
31
|
+
*
|
|
32
|
+
* The whole of what this store promises is that a key is unique within its
|
|
33
|
+
* namespace. Instances mirror it between themselves, and when two of them hold
|
|
34
|
+
* different values for one key the later `updated_at` is the one that stands —
|
|
35
|
+
* so the value read here is the newest the answering instance knows of, not
|
|
36
|
+
* necessarily the newest anywhere. */
|
|
37
|
+
export const KvReadArgs = Type.Object({ ns: Namespace, key: KvKey });
|
|
38
|
+
export type KvReadArgs = Static<typeof KvReadArgs>;
|
|
39
|
+
|
|
40
|
+
export const KvReadResult = Type.Object({
|
|
41
|
+
value: KvValue,
|
|
42
|
+
/** When the value was written, which is also what settles a disagreement
|
|
43
|
+
* between two instances. */
|
|
44
|
+
updated_at: Timestamp,
|
|
45
|
+
});
|
|
46
|
+
export type KvReadResult = Static<typeof KvReadResult>;
|
|
47
|
+
|
|
48
|
+
export const KvReadRequest = request("kv_read", KvReadArgs);
|
|
49
|
+
export const KvReadResponse = response("kv_read", KvReadResult);
|
|
50
|
+
|
|
51
|
+
/** Writes one value, replacing whatever the key held. */
|
|
52
|
+
export const KvWriteArgs = Type.Object({
|
|
53
|
+
ns: Namespace,
|
|
54
|
+
key: KvKey,
|
|
55
|
+
value: KvValue,
|
|
56
|
+
/** When the value was written. A caller states it when the write it is
|
|
57
|
+
* reporting happened at some other time than this call — which is what lets a
|
|
58
|
+
* value written while an instance was unreachable arrive later without
|
|
59
|
+
* pretending to be newer than it is. Absent, the answering instance stamps it
|
|
60
|
+
* with the present. */
|
|
61
|
+
updated_at: Type.Optional(Timestamp),
|
|
62
|
+
});
|
|
63
|
+
export type KvWriteArgs = Static<typeof KvWriteArgs>;
|
|
64
|
+
|
|
65
|
+
export const KvWriteResult = Type.Object({
|
|
66
|
+
/** What the entry now carries, whether it was given or stamped. */
|
|
67
|
+
updated_at: Timestamp,
|
|
68
|
+
});
|
|
69
|
+
export type KvWriteResult = Static<typeof KvWriteResult>;
|
|
70
|
+
|
|
71
|
+
export const KvWriteRequest = request("kv_write", KvWriteArgs);
|
|
72
|
+
export const KvWriteResponse = response("kv_write", KvWriteResult);
|
|
73
|
+
|
|
74
|
+
/** Removes one key. A key that was not there is no error: the caller wanted the
|
|
75
|
+
* namespace to be without it, and it is. */
|
|
76
|
+
export const KvDeleteArgs = Type.Object({ ns: Namespace, key: KvKey });
|
|
77
|
+
export type KvDeleteArgs = Static<typeof KvDeleteArgs>;
|
|
78
|
+
|
|
79
|
+
export const KvDeleteResult = Type.Object({});
|
|
80
|
+
export type KvDeleteResult = Static<typeof KvDeleteResult>;
|
|
81
|
+
|
|
82
|
+
export const KvDeleteRequest = request("kv_delete", KvDeleteArgs);
|
|
83
|
+
export const KvDeleteResponse = response("kv_delete", KvDeleteResult);
|
|
84
|
+
|
|
85
|
+
/** One entry, in a snapshot or in the change that produced it. */
|
|
86
|
+
export const KvEntry = Type.Object(
|
|
87
|
+
{
|
|
88
|
+
key: KvKey,
|
|
89
|
+
/** Absent exactly when the entry is a removal, which is the only case with
|
|
90
|
+
* no value to state. */
|
|
91
|
+
value: Type.Optional(KvValue),
|
|
92
|
+
updated_at: Timestamp,
|
|
93
|
+
/** Marks a removal. Absent means the entry is there to be read, so a
|
|
94
|
+
* snapshot never carries it. */
|
|
95
|
+
deleted: Type.Optional(Type.Literal(true)),
|
|
96
|
+
},
|
|
97
|
+
{ $id: "KvEntry" },
|
|
98
|
+
);
|
|
99
|
+
export type KvEntry = Static<typeof KvEntry>;
|
|
100
|
+
|
|
101
|
+
/** The `kv:<ns>` topic, which is how a change one client makes reaches the
|
|
102
|
+
* others while they are looking at it.
|
|
103
|
+
*
|
|
104
|
+
* Snapshot and change are the same shape: the snapshot is every entry the
|
|
105
|
+
* namespace holds, and a later frame is the entries that changed. A removal
|
|
106
|
+
* travels as an entry marked deleted rather than as an absence, since an
|
|
107
|
+
* absence in a list of changes would say nothing. */
|
|
108
|
+
export const KvFrame = topicFrame("kv", Type.Object({ entries: Type.Array(KvEntry) }));
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { type Static, Type } from "@sinclair/typebox";
|
|
2
|
+
import { request, response } from "../envelope.ts";
|
|
3
|
+
|
|
4
|
+
/** Reads the launcher's configuration, as much of it as a form needs.
|
|
5
|
+
*
|
|
6
|
+
* Neither the directory tree nor the launch itself lets a client learn the
|
|
7
|
+
* roots it may pick from, or the recipes and their parameters — which are the
|
|
8
|
+
* form. This fills that gap, and its capability answer is also how a client
|
|
9
|
+
* decides whether to offer a launcher at all. */
|
|
10
|
+
export const LauncherConfigReadArgs = Type.Object({});
|
|
11
|
+
export type LauncherConfigReadArgs = Static<typeof LauncherConfigReadArgs>;
|
|
12
|
+
|
|
13
|
+
/** One value the recipe's command reads.
|
|
14
|
+
*
|
|
15
|
+
* The declaration is the only statement of which variables exist: the launcher
|
|
16
|
+
* defines exactly these and nothing else. A client renders one input per
|
|
17
|
+
* declaration, in order. */
|
|
18
|
+
export const LauncherParam = Type.Object(
|
|
19
|
+
{
|
|
20
|
+
/** A shell identifier, unique within a recipe. Some names are conventional
|
|
21
|
+
* enough to deserve a dedicated widget — a directory picker, a model
|
|
22
|
+
* chooser — and anything else is a plain text field. */
|
|
23
|
+
name: Type.String({ minLength: 1 }),
|
|
24
|
+
/** The form's initial value, and what resetting it restores. Empty is the
|
|
25
|
+
* ordinary "the user fills this in" case. */
|
|
26
|
+
default: Type.String(),
|
|
27
|
+
},
|
|
28
|
+
{ $id: "LauncherParam" },
|
|
29
|
+
);
|
|
30
|
+
export type LauncherParam = Static<typeof LauncherParam>;
|
|
31
|
+
|
|
32
|
+
/** One launch recipe as a client sees it: how the shell runs it is the
|
|
33
|
+
* instance's business and is not reported. */
|
|
34
|
+
export const LauncherTemplate = Type.Object(
|
|
35
|
+
{
|
|
36
|
+
name: Type.String({ minLength: 1 }),
|
|
37
|
+
/** A shell program whose vocabulary is the declared parameters. Nothing is
|
|
38
|
+
* substituted into it — the parameters reach it as shell variables — so it
|
|
39
|
+
* is shown verbatim, and a client may offer it for editing. */
|
|
40
|
+
command: Type.String(),
|
|
41
|
+
params: Type.Array(LauncherParam),
|
|
42
|
+
},
|
|
43
|
+
{ $id: "LauncherTemplate" },
|
|
44
|
+
);
|
|
45
|
+
export type LauncherTemplate = Static<typeof LauncherTemplate>;
|
|
46
|
+
|
|
47
|
+
export const LauncherConfigReadResult = Type.Object({
|
|
48
|
+
/** Directories a session may be started in, on the instance's host. A launch
|
|
49
|
+
* elsewhere is refused. */
|
|
50
|
+
root_dirs: Type.Array(Type.String()),
|
|
51
|
+
/** In configured order; the first is the default recipe. */
|
|
52
|
+
templates: Type.Array(LauncherTemplate),
|
|
53
|
+
});
|
|
54
|
+
export type LauncherConfigReadResult = Static<typeof LauncherConfigReadResult>;
|
|
55
|
+
|
|
56
|
+
export const LauncherConfigReadRequest = request("launcher_config_read", LauncherConfigReadArgs);
|
|
57
|
+
export const LauncherConfigReadResponse = response(
|
|
58
|
+
"launcher_config_read",
|
|
59
|
+
LauncherConfigReadResult,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
/** Starts a session.
|
|
63
|
+
*
|
|
64
|
+
* The reply comes back when the launched command has finished or been given up
|
|
65
|
+
* on, which can be seconds. That waits for nothing else: requests on one
|
|
66
|
+
* connection are answered as they complete, not in order. */
|
|
67
|
+
export const LauncherRunArgs = Type.Object({
|
|
68
|
+
/** Where to run, and what the command sees as its working directory. It is a
|
|
69
|
+
* field of its own rather than one of `params` because it is the one value
|
|
70
|
+
* the instance acts on itself: it is checked against the configured roots
|
|
71
|
+
* before anything is spawned. */
|
|
72
|
+
cwd: Type.String({ minLength: 1 }),
|
|
73
|
+
/** Values for the recipe's declared parameters, by name. An omitted one falls
|
|
74
|
+
* back to its default; a name the recipe does not declare is refused rather
|
|
75
|
+
* than dropped, because a client sending a value nothing will read has a bug
|
|
76
|
+
* worth surfacing. The values are opaque and are never spliced into shell
|
|
77
|
+
* text, so none of them can inject syntax. */
|
|
78
|
+
params: Type.Record(Type.String(), Type.String()),
|
|
79
|
+
/** Which recipe to launch. Absent takes the default one; a name that is not
|
|
80
|
+
* configured is refused rather than quietly replaced with something else. */
|
|
81
|
+
template: Type.Optional(Type.String()),
|
|
82
|
+
/** Replaces the recipe's command for this launch. A person editing the
|
|
83
|
+
* command is doing what they could do by typing it in a terminal, so it is
|
|
84
|
+
* allowed for them and gated behind the same role as the launch itself. */
|
|
85
|
+
command: Type.Optional(Type.String({ minLength: 1 })),
|
|
86
|
+
});
|
|
87
|
+
export type LauncherRunArgs = Static<typeof LauncherRunArgs>;
|
|
88
|
+
|
|
89
|
+
export const LauncherRunResult = Type.Object({
|
|
90
|
+
stdout: Type.String(),
|
|
91
|
+
stderr: Type.String(),
|
|
92
|
+
/** Absent when a signal ended the command, which leaves no code to state. */
|
|
93
|
+
exit_code: Type.Optional(Type.Integer()),
|
|
94
|
+
/** The command outlived its allowance and was stopped. */
|
|
95
|
+
timed_out: Type.Boolean(),
|
|
96
|
+
});
|
|
97
|
+
export type LauncherRunResult = Static<typeof LauncherRunResult>;
|
|
98
|
+
|
|
99
|
+
export const LauncherRunRequest = request("launcher_run", LauncherRunArgs);
|
|
100
|
+
export const LauncherRunResponse = response("launcher_run", LauncherRunResult);
|