@ccmsg/protocol 1.23.0 → 2.0.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/attributes.ts +12 -12
- package/src/common/hello.ts +38 -5
- package/src/control/agents.ts +20 -11
- package/src/control/peers.ts +124 -34
- package/src/control/session.ts +12 -5
- package/src/envelope.ts +1 -1
- package/src/errors.ts +10 -0
- package/src/fixtures/common.ts +5 -4
- package/src/fixtures/control.ts +1 -1
- package/src/fixtures/topics.ts +46 -8
- package/src/identifiers.ts +15 -0
package/package.json
CHANGED
package/src/attributes.ts
CHANGED
|
@@ -197,7 +197,7 @@ export const OP_ATTRIBUTES = {
|
|
|
197
197
|
roles: AGENT_AND_USER,
|
|
198
198
|
needs_hello: true,
|
|
199
199
|
locality: "any_instance",
|
|
200
|
-
errors: ["session_not_found"],
|
|
200
|
+
errors: ["session_not_found", "session_duplicated"],
|
|
201
201
|
},
|
|
202
202
|
"say.post": {
|
|
203
203
|
plane: "messaging",
|
|
@@ -218,7 +218,7 @@ export const OP_ATTRIBUTES = {
|
|
|
218
218
|
roles: AGENT_AND_USER,
|
|
219
219
|
needs_hello: true,
|
|
220
220
|
locality: "any_instance",
|
|
221
|
-
errors: ["rate_limited"],
|
|
221
|
+
errors: ["rate_limited", "session_duplicated"],
|
|
222
222
|
},
|
|
223
223
|
|
|
224
224
|
// --- control: session observation and operation (10) ---
|
|
@@ -227,7 +227,7 @@ export const OP_ATTRIBUTES = {
|
|
|
227
227
|
roles: USER_ONLY,
|
|
228
228
|
needs_hello: true,
|
|
229
229
|
locality: "owner_instance",
|
|
230
|
-
errors: ["session_not_found"],
|
|
230
|
+
errors: ["session_not_found", "ambiguous_run"],
|
|
231
231
|
},
|
|
232
232
|
"session.rename": {
|
|
233
233
|
plane: "control",
|
|
@@ -256,7 +256,7 @@ export const OP_ATTRIBUTES = {
|
|
|
256
256
|
roles: USER_ONLY,
|
|
257
257
|
needs_hello: true,
|
|
258
258
|
locality: "owner_instance",
|
|
259
|
-
errors: ["not_found"],
|
|
259
|
+
errors: ["not_found", "session_duplicated"],
|
|
260
260
|
},
|
|
261
261
|
"dump.presets.read": {
|
|
262
262
|
plane: "control",
|
|
@@ -304,7 +304,7 @@ export const OP_ATTRIBUTES = {
|
|
|
304
304
|
needs_hello: true,
|
|
305
305
|
locality: "owner_instance",
|
|
306
306
|
scope: "role",
|
|
307
|
-
errors: ["path_forbidden", "not_found"],
|
|
307
|
+
errors: ["path_forbidden", "not_found", "session_duplicated"],
|
|
308
308
|
},
|
|
309
309
|
"file.read": {
|
|
310
310
|
plane: "control",
|
|
@@ -312,49 +312,49 @@ export const OP_ATTRIBUTES = {
|
|
|
312
312
|
needs_hello: true,
|
|
313
313
|
locality: "owner_instance",
|
|
314
314
|
scope: "role",
|
|
315
|
-
errors: ["path_forbidden", "not_found"],
|
|
315
|
+
errors: ["path_forbidden", "not_found", "session_duplicated"],
|
|
316
316
|
},
|
|
317
317
|
"file.write": {
|
|
318
318
|
plane: "control",
|
|
319
319
|
roles: USER_ONLY,
|
|
320
320
|
needs_hello: true,
|
|
321
321
|
locality: "owner_instance",
|
|
322
|
-
errors: ["path_not_writable", "file_exists"],
|
|
322
|
+
errors: ["path_not_writable", "file_exists", "session_duplicated"],
|
|
323
323
|
},
|
|
324
324
|
"file.create": {
|
|
325
325
|
plane: "control",
|
|
326
326
|
roles: USER_ONLY,
|
|
327
327
|
needs_hello: true,
|
|
328
328
|
locality: "owner_instance",
|
|
329
|
-
errors: ["file_exists", "path_forbidden"],
|
|
329
|
+
errors: ["file_exists", "path_forbidden", "session_duplicated"],
|
|
330
330
|
},
|
|
331
331
|
"file.edit": {
|
|
332
332
|
plane: "control",
|
|
333
333
|
roles: USER_ONLY,
|
|
334
334
|
needs_hello: true,
|
|
335
335
|
locality: "owner_instance",
|
|
336
|
-
errors: ["file_conflict", "not_a_text_file"],
|
|
336
|
+
errors: ["file_conflict", "not_a_text_file", "session_duplicated"],
|
|
337
337
|
},
|
|
338
338
|
"file.delete": {
|
|
339
339
|
plane: "control",
|
|
340
340
|
roles: USER_ONLY,
|
|
341
341
|
needs_hello: true,
|
|
342
342
|
locality: "owner_instance",
|
|
343
|
-
errors: ["path_forbidden", "not_found"],
|
|
343
|
+
errors: ["path_forbidden", "not_found", "session_duplicated"],
|
|
344
344
|
},
|
|
345
345
|
"file.find": {
|
|
346
346
|
plane: "control",
|
|
347
347
|
roles: USER_ONLY,
|
|
348
348
|
needs_hello: true,
|
|
349
349
|
locality: "owner_instance",
|
|
350
|
-
errors: ["path_forbidden"],
|
|
350
|
+
errors: ["path_forbidden", "session_duplicated"],
|
|
351
351
|
},
|
|
352
352
|
"file.stat": {
|
|
353
353
|
plane: "control",
|
|
354
354
|
roles: USER_ONLY,
|
|
355
355
|
needs_hello: true,
|
|
356
356
|
locality: "owner_instance",
|
|
357
|
-
errors: [],
|
|
357
|
+
errors: ["session_duplicated"],
|
|
358
358
|
},
|
|
359
359
|
"dir.tree": {
|
|
360
360
|
plane: "control",
|
package/src/common/hello.ts
CHANGED
|
@@ -66,6 +66,16 @@ export const HelloSessionArgs = Type.Object({
|
|
|
66
66
|
...GREETING_FIELDS,
|
|
67
67
|
/** The session the connection speaks for. */
|
|
68
68
|
sid: Sid,
|
|
69
|
+
/** The harness process running this session. Where the greeting is carried by
|
|
70
|
+
* something standing in for the session — a hook, the CLI — it is the parent
|
|
71
|
+
* harness process's pid and not the helper's: what this names is the run, and
|
|
72
|
+
* the helper is not one.
|
|
73
|
+
*
|
|
74
|
+
* It is what ties this session to a process a launcher started before the
|
|
75
|
+
* harness had written anything of its own, and what lets two runs of one
|
|
76
|
+
* session be told apart. Optional: a session that cannot read it is greeted
|
|
77
|
+
* anyway, and the instance then knows the run by its connection alone. */
|
|
78
|
+
pid: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
69
79
|
/** What the session says about itself. All optional: a session states what it
|
|
70
80
|
* knows, and the instance derives or leaves unknown what it is not told. The
|
|
71
81
|
* instance repeats these on the `peers` topic, so they are the same fields
|
|
@@ -141,11 +151,11 @@ export const HelloResult = Type.Object({
|
|
|
141
151
|
version: Type.String(),
|
|
142
152
|
started_at: Timestamp,
|
|
143
153
|
/** Where a person opens the terminal a session runs in: the base URL of the
|
|
144
|
-
* gateway that fronts this instance's terminals. A
|
|
145
|
-
* itself in `terminal_id
|
|
146
|
-
*
|
|
147
|
-
* trailing slash, the path below it being the
|
|
148
|
-
* contract's.
|
|
154
|
+
* gateway that fronts this instance's terminals. A run's terminal names
|
|
155
|
+
* itself in `terminal_id`, and `terminalUrl` below composes the URL from the
|
|
156
|
+
* two — only for a handle under the `hyoui` scheme, this gateway serving no
|
|
157
|
+
* other. The base URL carries no trailing slash, the path below it being the
|
|
158
|
+
* gateway's spelling and not this contract's.
|
|
149
159
|
*
|
|
150
160
|
* Stated by the instance because only it knows which gateway stands in front
|
|
151
161
|
* of the machine its sessions run on; a client has no way to derive one from
|
|
@@ -164,6 +174,29 @@ export const HelloResult = Type.Object({
|
|
|
164
174
|
});
|
|
165
175
|
export type HelloResult = Static<typeof HelloResult>;
|
|
166
176
|
|
|
177
|
+
/** The terminal scheme the gateway in `terminal_gateway` serves. */
|
|
178
|
+
export const HYOUI_TERMINAL_SCHEME = "hyoui";
|
|
179
|
+
|
|
180
|
+
/** Where a person opens a run's terminal, or nothing.
|
|
181
|
+
*
|
|
182
|
+
* Nothing when the gateway is absent, and nothing when the handle is under
|
|
183
|
+
* another scheme: a handle names whose terminal it is, and composing this URL
|
|
184
|
+
* for one the gateway does not serve would hand a person a link to a page that
|
|
185
|
+
* is not there. A client holding such a handle opens it the way that system
|
|
186
|
+
* does, or leaves it unopened.
|
|
187
|
+
*
|
|
188
|
+
* Composed here rather than once per client, so the one place that knows both
|
|
189
|
+
* the scheme and the gateway's path is this contract. */
|
|
190
|
+
export function terminalUrl(
|
|
191
|
+
gateway: string | undefined,
|
|
192
|
+
terminalId: string | undefined,
|
|
193
|
+
): string | undefined {
|
|
194
|
+
if (gateway === undefined || terminalId === undefined) return undefined;
|
|
195
|
+
const prefix = `${HYOUI_TERMINAL_SCHEME}:`;
|
|
196
|
+
if (!terminalId.startsWith(prefix)) return undefined;
|
|
197
|
+
return `${gateway}/sessions/${terminalId.slice(prefix.length)}`;
|
|
198
|
+
}
|
|
199
|
+
|
|
167
200
|
export const HelloSessionRequest = request("hello.session", HelloSessionArgs);
|
|
168
201
|
export const HelloSessionResponse = response("hello.session", HelloResult);
|
|
169
202
|
|
package/src/control/agents.ts
CHANGED
|
@@ -1,19 +1,26 @@
|
|
|
1
1
|
import { type Static, Type } from "@sinclair/typebox";
|
|
2
2
|
import { topicFrame } from "../envelope.ts";
|
|
3
|
-
import { InstanceId, Sid, Timestamp } from "../identifiers.ts";
|
|
3
|
+
import { InstanceId, Sid, TerminalId, Timestamp } from "../identifiers.ts";
|
|
4
4
|
import { upstream } from "../upstream.ts";
|
|
5
5
|
|
|
6
|
-
/** One session as the harness itself reports it, noted
|
|
7
|
-
* it was found under.
|
|
6
|
+
/** One run of a session — one process — as the harness itself reports it, noted
|
|
7
|
+
* with which config home it was found under.
|
|
8
8
|
*
|
|
9
9
|
* This is the harness's view rather than the instance's: it covers sessions
|
|
10
10
|
* that never connected here, and it carries what only the process knows — its
|
|
11
11
|
* pid, its terminal, the title the session gave itself. The instance renames
|
|
12
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.
|
|
13
|
+
* which is why the status-like fields are open sets.
|
|
14
|
+
*
|
|
15
|
+
* A row is one process and is matched by `instance` and `pid`, never by `sid`:
|
|
16
|
+
* two processes may be running one session, and a launcher's process is here
|
|
17
|
+
* before it has a session at all. */
|
|
14
18
|
export const AgentInfo = Type.Object(
|
|
15
19
|
{
|
|
16
|
-
|
|
20
|
+
/** The session this process is running. Absent on a process a launcher
|
|
21
|
+
* started that the harness has not yet named a session for — it has a
|
|
22
|
+
* terminal and a start, and nothing to attach them to yet. */
|
|
23
|
+
sid: Type.Optional(Sid),
|
|
17
24
|
/** The instance that polled it, and whose host the pid belongs to. */
|
|
18
25
|
instance: InstanceId,
|
|
19
26
|
pid: Type.Integer({ minimum: 1 }),
|
|
@@ -39,7 +46,7 @@ export const AgentInfo = Type.Object(
|
|
|
39
46
|
* into. Absent when the process does not name one or its environment could
|
|
40
47
|
* not be read. Read from the running process rather than remembered from
|
|
41
48
|
* when it started, since resuming a session gives it a new process. */
|
|
42
|
-
terminal_id: Type.Optional(
|
|
49
|
+
terminal_id: Type.Optional(TerminalId),
|
|
43
50
|
/** Which namespace that terminal lives in. Absent means the process set
|
|
44
51
|
* none, which the multiplexer treats as its default — not the instance's
|
|
45
52
|
* own namespace, which can differ. Typing into the wrong namespace reports a
|
|
@@ -50,13 +57,15 @@ export const AgentInfo = Type.Object(
|
|
|
50
57
|
);
|
|
51
58
|
export type AgentInfo = Static<typeof AgentInfo>;
|
|
52
59
|
|
|
53
|
-
/** A row that is gone: the
|
|
54
|
-
*
|
|
55
|
-
*
|
|
60
|
+
/** A row that is gone: the process ended, or the instance that polled it
|
|
61
|
+
* stopped. Marked rather than absent, since a frame carries only what changed.
|
|
62
|
+
*
|
|
63
|
+
* It names the pid and not the session: the session may well still be there,
|
|
64
|
+
* with another process running it. */
|
|
56
65
|
export const AgentRemoved = Type.Object(
|
|
57
66
|
{
|
|
58
|
-
sid: Sid,
|
|
59
67
|
instance: InstanceId,
|
|
68
|
+
pid: Type.Integer({ minimum: 1 }),
|
|
60
69
|
removed: Type.Literal(true),
|
|
61
70
|
},
|
|
62
71
|
{ $id: "AgentRemoved" },
|
|
@@ -67,7 +76,7 @@ export const AgentElement = Type.Union([AgentInfo, AgentRemoved], { $id: "AgentE
|
|
|
67
76
|
export type AgentElement = Static<typeof AgentElement>;
|
|
68
77
|
|
|
69
78
|
/** The `agents` topic. Elements, like `peers`: the rows that changed since the
|
|
70
|
-
* last frame, matched by their `instance` and `
|
|
79
|
+
* last frame, matched by their `instance` and `pid`.
|
|
71
80
|
*
|
|
72
81
|
* The instance polls the harness only while somebody is listening here, so the
|
|
73
82
|
* list is as fresh as the subscription is old — and a poll that finds one
|
package/src/control/peers.ts
CHANGED
|
@@ -1,34 +1,60 @@
|
|
|
1
1
|
import { type Static, Type } from "@sinclair/typebox";
|
|
2
2
|
import { topicFrame } from "../envelope.ts";
|
|
3
|
-
import { InstanceId, Sid, Timestamp } from "../identifiers.ts";
|
|
3
|
+
import { InstanceId, Sid, TerminalId, Timestamp } from "../identifiers.ts";
|
|
4
4
|
import { SessionMetaFields } from "../session-meta.ts";
|
|
5
5
|
|
|
6
|
-
/** How
|
|
6
|
+
/** How recently the gateway must have seen inference for a session for that
|
|
7
|
+
* alone to say the session is alive. A session whose processes are all gone but
|
|
8
|
+
* whose inference is still running is alive: the request outlives the terminal
|
|
9
|
+
* it was typed in. */
|
|
10
|
+
export const GATEWAY_LIVE_WINDOW_MS = 5 * 60 * 1000;
|
|
11
|
+
|
|
12
|
+
/** One process running a session.
|
|
7
13
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
14
|
+
* A session and a run of it are two things: the session is the transcript and
|
|
15
|
+
* the folded state, and lives whether nothing or two processes are running it.
|
|
16
|
+
* This is the process — what a signal reaches, what a terminal shows, what a
|
|
17
|
+
* connection speaks over. */
|
|
18
|
+
export const SessionRun = Type.Object(
|
|
19
|
+
{
|
|
20
|
+
/** The harness process. Present when the harness's state file names one or
|
|
21
|
+
* a launcher started it; a run known only by its connection has none, and
|
|
22
|
+
* nothing in this contract can signal such a run. */
|
|
23
|
+
pid: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
24
|
+
/** When that process started, which is what tells a pid the OS has handed
|
|
25
|
+
* to something else from the run it was read for. Stated wherever `pid`
|
|
26
|
+
* is. */
|
|
27
|
+
started_at: Type.Optional(Timestamp),
|
|
28
|
+
terminal_id: Type.Optional(TerminalId),
|
|
29
|
+
/** Whether a connection of this run is open to the instance right now. */
|
|
30
|
+
connected: Type.Boolean(),
|
|
31
|
+
},
|
|
32
|
+
{ $id: "SessionRun" },
|
|
33
|
+
);
|
|
34
|
+
export type SessionRun = Static<typeof SessionRun>;
|
|
35
|
+
|
|
36
|
+
/** What the `session.status` fold for this session is worth.
|
|
12
37
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
|
|
38
|
+
* A client reads this before it reads the fold: the values below say whether
|
|
39
|
+
* there is a transcript at all, whether the instance has caught up with it, and
|
|
40
|
+
* whether anything it says can still be trusted. */
|
|
41
|
+
export const SessionStatusStanding = Type.Union(
|
|
16
42
|
[
|
|
17
|
-
/**
|
|
18
|
-
*
|
|
19
|
-
Type.Literal("
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
Type.Literal("
|
|
24
|
-
/**
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
Type.Literal("
|
|
43
|
+
/** No transcript, so there is nothing to fold. A session that has just
|
|
44
|
+
* started stands here until the harness writes its first record. */
|
|
45
|
+
Type.Literal("absent"),
|
|
46
|
+
/** The transcript is being read from the top; what the fold says so far is
|
|
47
|
+
* incomplete. */
|
|
48
|
+
Type.Literal("folding"),
|
|
49
|
+
Type.Literal("ready"),
|
|
50
|
+
/** Two or more runs are writing the same transcript, so the instance stops
|
|
51
|
+
* updating the fold and stops carrying the transcript's additions. What it
|
|
52
|
+
* states is the last value it could trust. */
|
|
53
|
+
Type.Literal("frozen"),
|
|
28
54
|
],
|
|
29
|
-
{ $id: "
|
|
55
|
+
{ $id: "SessionStatusStanding" },
|
|
30
56
|
);
|
|
31
|
-
export type
|
|
57
|
+
export type SessionStatusStanding = Static<typeof SessionStatusStanding>;
|
|
32
58
|
|
|
33
59
|
/** How long a lost session's row is kept after it was last seen, before the
|
|
34
60
|
* instance forgets it and the row leaves as a removal. The same window the inbox
|
|
@@ -68,9 +94,9 @@ export type StaleClientInfo = Static<typeof StaleClientInfo>;
|
|
|
68
94
|
* Connected and lost sessions are one kind of row rather than two lists: a
|
|
69
95
|
* session registering or going quiet moves it between the two, and a row that
|
|
70
96
|
* changed lists while keeping its identity is an update of that row. Which it
|
|
71
|
-
* is now is
|
|
72
|
-
* (`last_seen_at`, `stopped_at`, and what
|
|
73
|
-
* alongside the connection fields it kept. */
|
|
97
|
+
* is now is read off `runs` and `stopped_at` by `liveness` below, and the
|
|
98
|
+
* fields that only a lost session has (`last_seen_at`, `stopped_at`, and what
|
|
99
|
+
* it must resume as) are stated alongside the connection fields it kept. */
|
|
74
100
|
export const PeerInfo = Type.Object(
|
|
75
101
|
{
|
|
76
102
|
sid: Sid,
|
|
@@ -90,11 +116,19 @@ export const PeerInfo = Type.Object(
|
|
|
90
116
|
* notification's `sid_label` — so the material for those is on the row that
|
|
91
117
|
* every client already holds. */
|
|
92
118
|
title: Type.Optional(SessionMetaFields.title),
|
|
93
|
-
/**
|
|
94
|
-
* from one its instance has lost.
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
|
|
119
|
+
/** Every process the instance can see running this session, which is also
|
|
120
|
+
* what separates a connected row from one its instance has lost. Empty
|
|
121
|
+
* means none is running, which is a row a person can still resume; two or
|
|
122
|
+
* more mean the harness let the same session be resumed while it was
|
|
123
|
+
* running, and nothing here picks one of them.
|
|
124
|
+
*
|
|
125
|
+
* The observation itself rather than a count or a verdict: which process,
|
|
126
|
+
* where it can be opened, and whether it is connected are each what a
|
|
127
|
+
* person decides on, and a session role holds this row without ever seeing
|
|
128
|
+
* the `agents` topic. */
|
|
129
|
+
runs: Type.Array(SessionRun),
|
|
130
|
+
/** What this session's `session.status` fold is worth just now. */
|
|
131
|
+
session_status: SessionStatusStanding,
|
|
98
132
|
/** Set while a person has pinned this session. Absent means not pinned. */
|
|
99
133
|
pinned: Type.Optional(Type.Boolean()),
|
|
100
134
|
/** When this session first registered with the instance. Stable across its
|
|
@@ -112,9 +146,10 @@ export const PeerInfo = Type.Object(
|
|
|
112
146
|
/** When inference last ran for this session, as the gateway saw it.
|
|
113
147
|
*
|
|
114
148
|
* How busy a session is, carried as an attribute of the row rather than
|
|
115
|
-
* folded into
|
|
116
|
-
*
|
|
117
|
-
*
|
|
149
|
+
* folded into how it stands: a session is busy whether one process or none
|
|
150
|
+
* is running it, so the two answer different questions and collapsing them
|
|
151
|
+
* would lose one. It is also what says a session with no run left is still
|
|
152
|
+
* alive, the request outliving the process. It is an instant rather than a flag
|
|
118
153
|
* because there is no moment a request stops being in flight that anything
|
|
119
154
|
* observes — a client reads recency and decides its own threshold.
|
|
120
155
|
*
|
|
@@ -144,7 +179,9 @@ export const PeerInfo = Type.Object(
|
|
|
144
179
|
last_seen_at: Type.Optional(Timestamp),
|
|
145
180
|
/** When the session said it was stopping. Its presence is what makes a lost
|
|
146
181
|
* session a pause rather than a disappearance: one that goes without a word
|
|
147
|
-
* leaves nothing to stamp here.
|
|
182
|
+
* leaves nothing to stamp here. It says something only while `runs` is
|
|
183
|
+
* empty — a session that declared it was stopping and is running again has
|
|
184
|
+
* a stamp older than the run. */
|
|
148
185
|
stopped_at: Type.Optional(Timestamp),
|
|
149
186
|
/** What its last turn ran as, in the transcript's own spelling, read back
|
|
150
187
|
* from the transcript rather than copied from the connection: what a lost
|
|
@@ -177,6 +214,59 @@ export type PeerRemoved = Static<typeof PeerRemoved>;
|
|
|
177
214
|
export const PeerElement = Type.Union([PeerInfo, PeerRemoved], { $id: "PeerElement" });
|
|
178
215
|
export type PeerElement = Static<typeof PeerElement>;
|
|
179
216
|
|
|
217
|
+
/** Where a session stands as a thing that is or is not running.
|
|
218
|
+
*
|
|
219
|
+
* `duplicated` is not a degree of aliveness but the answer to a different
|
|
220
|
+
* question — how many processes — which is why it wins over the rest: a session
|
|
221
|
+
* two processes are writing is one nothing should be read from, however alive
|
|
222
|
+
* it looks. */
|
|
223
|
+
export type Liveness = "alive" | "duplicated" | "paused" | "disappeared";
|
|
224
|
+
|
|
225
|
+
/** How a session stands, read off the row.
|
|
226
|
+
*
|
|
227
|
+
* Derived here rather than stated on the wire, and here rather than once per
|
|
228
|
+
* side: an instance and a client that each wrote this arithmetic would show the
|
|
229
|
+
* same row two ways. */
|
|
230
|
+
export function liveness(
|
|
231
|
+
row: {
|
|
232
|
+
runs: readonly { connected: boolean }[];
|
|
233
|
+
stopped_at?: number;
|
|
234
|
+
gateway_active_at?: number;
|
|
235
|
+
},
|
|
236
|
+
now: number,
|
|
237
|
+
): Liveness {
|
|
238
|
+
if (row.runs.length >= 2) return "duplicated";
|
|
239
|
+
const running =
|
|
240
|
+
(row.runs.length > 0 && row.stopped_at === undefined) ||
|
|
241
|
+
(row.gateway_active_at !== undefined && now - row.gateway_active_at <= GATEWAY_LIVE_WINDOW_MS);
|
|
242
|
+
if (running) return "alive";
|
|
243
|
+
return row.stopped_at === undefined ? "disappeared" : "paused";
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Whether anything here can act on the session: some run of it is connected or
|
|
247
|
+
* names a terminal. A session alive with neither is one nothing can be handed
|
|
248
|
+
* to and nothing can be typed into. */
|
|
249
|
+
export function reachable(row: {
|
|
250
|
+
runs: readonly { connected: boolean; terminal_id?: string }[];
|
|
251
|
+
}): boolean {
|
|
252
|
+
return row.runs.some((run) => run.connected || run.terminal_id !== undefined);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Whether something is out that a person has to answer: a dialog the harness
|
|
256
|
+
* is holding open, or a turn that ended on an upstream error.
|
|
257
|
+
*
|
|
258
|
+
* Both materials belong to the `user` role — the `agents` row and the status
|
|
259
|
+
* fold — which is why this takes them rather than a `peers` row: a session role
|
|
260
|
+
* asking how another session stands has `runs` and `stopped_at` and no way to
|
|
261
|
+
* see this one. Either argument may be missing, which says only that its
|
|
262
|
+
* material was not there to read. */
|
|
263
|
+
export function waiting(
|
|
264
|
+
agentsRow: { waiting_for?: string } | undefined,
|
|
265
|
+
status: { api_error?: unknown } | undefined,
|
|
266
|
+
): boolean {
|
|
267
|
+
return agentsRow?.waiting_for !== undefined || status?.api_error !== undefined;
|
|
268
|
+
}
|
|
269
|
+
|
|
180
270
|
/** The `peers` topic.
|
|
181
271
|
*
|
|
182
272
|
* Elements: each frame carries the rows that changed, matched by their
|
package/src/control/session.ts
CHANGED
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
import { type Static, Type } from "@sinclair/typebox";
|
|
2
2
|
import { request, response } from "../envelope.ts";
|
|
3
|
-
import { InstanceId, Sid, Timestamp } from "../identifiers.ts";
|
|
3
|
+
import { InstanceId, Sid, TerminalId, Timestamp } from "../identifiers.ts";
|
|
4
4
|
import { DumpIds, SessionDumpFormat, TranscriptItemSelector, TranscriptItemType } from "./dump.ts";
|
|
5
5
|
|
|
6
6
|
/** Ends the OS process behind a session.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* A session with one run is named by its sid alone: the instance resolves it to
|
|
9
|
+
* a pid at the moment it signals, which is a better basis than anything a
|
|
10
|
+
* caller asserts. A session with two is not resolvable that way, and a kill
|
|
11
|
+
* that named only the sid is refused with `ambiguous_run` — the caller picks a
|
|
12
|
+
* run from `peers.runs` and names it below. */
|
|
11
13
|
export const SessionKillArgs = Type.Object({
|
|
12
14
|
sid: Sid,
|
|
15
|
+
/** Which run to end, where the session has more than one. The instance
|
|
16
|
+
* signals it only after finding it in the session's own runs, pid and start
|
|
17
|
+
* together, so a pid the caller got wrong ends nothing rather than ending
|
|
18
|
+
* whatever the OS has since given that number to. */
|
|
19
|
+
pid: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
13
20
|
/** Escalate to an unconditional kill. The instance never chooses this on its
|
|
14
21
|
* own, because it forfeits the session's chance to flush its transcript; a
|
|
15
22
|
* caller asks for it after watching a graceful attempt go unconfirmed. */
|
|
@@ -55,7 +62,7 @@ export type SessionRenameArgs = Static<typeof SessionRenameArgs>;
|
|
|
55
62
|
export const SessionRenameResult = Type.Object({
|
|
56
63
|
/** The terminal handle the keystrokes went to. A host-local handle, so it
|
|
57
64
|
* travels with the instance that owns it. */
|
|
58
|
-
terminal_id:
|
|
65
|
+
terminal_id: TerminalId,
|
|
59
66
|
instance: InstanceId,
|
|
60
67
|
/** The trimmed title actually typed, which is what a caller should report
|
|
61
68
|
* rather than the draft it sent. */
|
package/src/envelope.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { InstanceId, Role, Sid } from "./identifiers.ts";
|
|
|
6
6
|
* fields and whole new ops may be added; a removal or a change of meaning
|
|
7
7
|
* raises it. Peers announcing another generation are refused, on client
|
|
8
8
|
* connections and on mesh links alike. */
|
|
9
|
-
export const PROTOCOL_VERSION =
|
|
9
|
+
export const PROTOCOL_VERSION = 4;
|
|
10
10
|
|
|
11
11
|
/** The largest a single frame — one newline-delimited line, request, reply or
|
|
12
12
|
* topic frame alike — may be, in bytes.
|
package/src/errors.ts
CHANGED
|
@@ -46,6 +46,16 @@ export const ERROR_CODES = [
|
|
|
46
46
|
"session_not_found",
|
|
47
47
|
/** The path, transcript, or record named by the arguments does not exist. */
|
|
48
48
|
"not_found",
|
|
49
|
+
// --- a session two processes are running ---
|
|
50
|
+
/** Two or more processes are running the session the call names, so what it
|
|
51
|
+
* would act on cannot be settled and what the transcript says cannot be
|
|
52
|
+
* trusted. Nothing is held back for later: what was to be sent is still with
|
|
53
|
+
* the caller, and a person decides which run to end before anything here
|
|
54
|
+
* resumes. */
|
|
55
|
+
"session_duplicated",
|
|
56
|
+
/** The session has more than one run and the call named none. The caller
|
|
57
|
+
* picks one from `peers.runs` and asks again naming its pid. */
|
|
58
|
+
"ambiguous_run",
|
|
49
59
|
// --- file access ---
|
|
50
60
|
"path_forbidden",
|
|
51
61
|
"path_not_writable",
|
package/src/fixtures/common.ts
CHANGED
|
@@ -41,8 +41,9 @@ const { sid, instance, other_instance, endpoint, other_endpoint, request_id } =
|
|
|
41
41
|
export const HELLO_SESSION_REQUEST: Static<typeof HelloSessionRequest> = {
|
|
42
42
|
request_id,
|
|
43
43
|
op: "hello.session",
|
|
44
|
-
protocol_version:
|
|
44
|
+
protocol_version: 4,
|
|
45
45
|
sid,
|
|
46
|
+
pid: 4821,
|
|
46
47
|
client_version: "0.1.0",
|
|
47
48
|
repo: "ccmsg-protocol",
|
|
48
49
|
ws: "main",
|
|
@@ -59,7 +60,7 @@ export const HELLO_SESSION_REQUEST: Static<typeof HelloSessionRequest> = {
|
|
|
59
60
|
export const HELLO_USER_REQUEST: Static<typeof HelloUserRequest> = {
|
|
60
61
|
request_id,
|
|
61
62
|
op: "hello.user",
|
|
62
|
-
protocol_version:
|
|
63
|
+
protocol_version: 4,
|
|
63
64
|
client_version: "0.1.0",
|
|
64
65
|
};
|
|
65
66
|
|
|
@@ -68,7 +69,7 @@ export const HELLO_USER_REQUEST: Static<typeof HelloUserRequest> = {
|
|
|
68
69
|
export const HELLO_INSTANCE_REQUEST: Static<typeof HelloInstanceRequest> = {
|
|
69
70
|
request_id,
|
|
70
71
|
op: "hello.instance",
|
|
71
|
-
protocol_version:
|
|
72
|
+
protocol_version: 4,
|
|
72
73
|
mesh: {
|
|
73
74
|
ver: 1,
|
|
74
75
|
iss: other_endpoint,
|
|
@@ -83,7 +84,7 @@ export const HELLO_INSTANCE_REQUEST: Static<typeof HelloInstanceRequest> = {
|
|
|
83
84
|
export const HELLO_RESPONSE: Static<typeof HelloSessionResponse> = {
|
|
84
85
|
ok: true,
|
|
85
86
|
request_id,
|
|
86
|
-
protocol_version:
|
|
87
|
+
protocol_version: 4,
|
|
87
88
|
instance,
|
|
88
89
|
endpoint,
|
|
89
90
|
auth_expires_at: FIXTURE_NOW + 10_000_000,
|
package/src/fixtures/control.ts
CHANGED
|
@@ -105,7 +105,7 @@ export const SESSION_RENAME_REQUEST: Static<typeof SessionRenameRequest> = {
|
|
|
105
105
|
export const SESSION_RENAME_RESPONSE: Static<typeof SessionRenameResponse> = {
|
|
106
106
|
ok: true,
|
|
107
107
|
request_id,
|
|
108
|
-
terminal_id: "
|
|
108
|
+
terminal_id: "hyoui:%17",
|
|
109
109
|
instance,
|
|
110
110
|
title: "contract fixtures",
|
|
111
111
|
};
|
package/src/fixtures/topics.ts
CHANGED
|
@@ -74,7 +74,7 @@ const PEER = {
|
|
|
74
74
|
repo: "ccmsg-protocol",
|
|
75
75
|
ws: "main",
|
|
76
76
|
cwd: WORKSPACE,
|
|
77
|
-
protocol_version:
|
|
77
|
+
protocol_version: 4,
|
|
78
78
|
};
|
|
79
79
|
|
|
80
80
|
export const PEERS_FRAME: Static<typeof PeersFrame> = {
|
|
@@ -90,7 +90,15 @@ export const PEERS_FRAME: Static<typeof PeersFrame> = {
|
|
|
90
90
|
repo_root: "/repos/kawaz/ccmsg-protocol",
|
|
91
91
|
branch: "main",
|
|
92
92
|
title: "contract fixtures",
|
|
93
|
-
|
|
93
|
+
runs: [
|
|
94
|
+
{
|
|
95
|
+
pid: 4821,
|
|
96
|
+
started_at: FIXTURE_NOW - 600_000,
|
|
97
|
+
terminal_id: "hyoui:%17",
|
|
98
|
+
connected: true,
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
session_status: "ready",
|
|
94
102
|
pinned: true,
|
|
95
103
|
connected_at: FIXTURE_NOW - 600_000,
|
|
96
104
|
last_activity_at: FIXTURE_NOW,
|
|
@@ -102,7 +110,8 @@ export const PEERS_FRAME: Static<typeof PeersFrame> = {
|
|
|
102
110
|
{
|
|
103
111
|
...PEER,
|
|
104
112
|
sid: other_sid,
|
|
105
|
-
|
|
113
|
+
runs: [{ pid: 7314, started_at: FIXTURE_NOW - 120_000, connected: false }],
|
|
114
|
+
session_status: "folding",
|
|
106
115
|
stale_client: {
|
|
107
116
|
last_seen_at: FIXTURE_NOW - 300_000,
|
|
108
117
|
version: "0.0.9",
|
|
@@ -115,7 +124,8 @@ export const PEERS_FRAME: Static<typeof PeersFrame> = {
|
|
|
115
124
|
repo: "ccmsg",
|
|
116
125
|
ws: "daemon-v2",
|
|
117
126
|
cwd: "/repos/kawaz/ccmsg/daemon-v2",
|
|
118
|
-
|
|
127
|
+
runs: [],
|
|
128
|
+
session_status: "ready",
|
|
119
129
|
last_seen_at: FIXTURE_NOW - 1_200_000,
|
|
120
130
|
stopped_at: FIXTURE_NOW - 1_000_000,
|
|
121
131
|
model: "claude-opus-5",
|
|
@@ -125,14 +135,33 @@ export const PEERS_FRAME: Static<typeof PeersFrame> = {
|
|
|
125
135
|
},
|
|
126
136
|
};
|
|
127
137
|
|
|
128
|
-
/** A later frame: one row
|
|
138
|
+
/** A later frame: one row is being run by two processes at once, and one the
|
|
139
|
+
* instance forgot. */
|
|
129
140
|
export const PEERS_CHANGE_FRAME: Static<typeof PeersFrame> = {
|
|
130
141
|
ev: "topic",
|
|
131
142
|
topic: "peers",
|
|
132
143
|
instance,
|
|
133
144
|
data: {
|
|
134
145
|
peers: [
|
|
135
|
-
{
|
|
146
|
+
{
|
|
147
|
+
...PEER,
|
|
148
|
+
runs: [
|
|
149
|
+
{
|
|
150
|
+
pid: 4821,
|
|
151
|
+
started_at: FIXTURE_NOW - 600_000,
|
|
152
|
+
terminal_id: "hyoui:%17",
|
|
153
|
+
connected: true,
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
pid: 9022,
|
|
157
|
+
started_at: FIXTURE_NOW - 30_000,
|
|
158
|
+
terminal_id: "hyoui:%23",
|
|
159
|
+
connected: true,
|
|
160
|
+
},
|
|
161
|
+
],
|
|
162
|
+
session_status: "frozen",
|
|
163
|
+
gateway_active_at: FIXTURE_NOW + 1_000,
|
|
164
|
+
},
|
|
136
165
|
{ sid: "1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f", instance, removed: true },
|
|
137
166
|
],
|
|
138
167
|
},
|
|
@@ -169,9 +198,18 @@ export const AGENTS_FRAME: Static<typeof AgentsFrame> = {
|
|
|
169
198
|
status: "working",
|
|
170
199
|
state: "busy",
|
|
171
200
|
config_dir: "/config/claude-personal",
|
|
172
|
-
terminal_id: "
|
|
201
|
+
terminal_id: "hyoui:%17",
|
|
173
202
|
terminal_namespace: "personal",
|
|
174
203
|
},
|
|
204
|
+
{
|
|
205
|
+
instance,
|
|
206
|
+
pid: 9022,
|
|
207
|
+
cwd: WORKSPACE,
|
|
208
|
+
kind: "claude",
|
|
209
|
+
started_at: FIXTURE_NOW - 30_000,
|
|
210
|
+
config_dir: "/config/claude-personal",
|
|
211
|
+
terminal_id: "hyoui:%23",
|
|
212
|
+
},
|
|
175
213
|
],
|
|
176
214
|
polled_at: FIXTURE_NOW,
|
|
177
215
|
},
|
|
@@ -182,7 +220,7 @@ export const AGENTS_CHANGE_FRAME: Static<typeof AgentsFrame> = {
|
|
|
182
220
|
ev: "topic",
|
|
183
221
|
topic: "agents",
|
|
184
222
|
instance,
|
|
185
|
-
data: { agents: [{
|
|
223
|
+
data: { agents: [{ instance, pid: 7314, removed: true }], polled_at: FIXTURE_NOW + 5_000 },
|
|
186
224
|
};
|
|
187
225
|
|
|
188
226
|
export const SESSION_STATUS_FRAME: Static<typeof SessionStatusFrame> = {
|
package/src/identifiers.ts
CHANGED
|
@@ -81,6 +81,21 @@ export const Mid = Type.String({
|
|
|
81
81
|
});
|
|
82
82
|
export type Mid = Static<typeof Mid>;
|
|
83
83
|
|
|
84
|
+
/** A terminal a run lives in: `<scheme>:<id>`, where the scheme says whose
|
|
85
|
+
* handle the id is and the id is that system's own spelling.
|
|
86
|
+
*
|
|
87
|
+
* The scheme is what lets a client tell a handle it can open from one it
|
|
88
|
+
* cannot: `hyoui:<id>` is a terminal the gateway named in `terminal_gateway`
|
|
89
|
+
* serves, and `terminalUrl` composes the URL for it. A handle under any other
|
|
90
|
+
* scheme travels unchanged and is opened only by a client that knows that
|
|
91
|
+
* system — which is the point of naming the scheme rather than leaving a bare
|
|
92
|
+
* id every reader would have to guess the owner of. */
|
|
93
|
+
export const TerminalId = Type.String({
|
|
94
|
+
$id: "TerminalId",
|
|
95
|
+
pattern: "^[a-z][a-z0-9_-]*:[^\\s]+$",
|
|
96
|
+
});
|
|
97
|
+
export type TerminalId = Static<typeof TerminalId>;
|
|
98
|
+
|
|
84
99
|
/** Who a connection speaks as. Settled once by the greeting that opened it —
|
|
85
100
|
* `hello.session`, `hello.user` or `hello.instance`, the op being what says
|
|
86
101
|
* which — and fixed for the connection's life; the op attribute table's
|