@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ccmsg/protocol",
3
- "version": "1.23.0",
3
+ "version": "2.0.0",
4
4
  "description": "Wire contract (schema + types + op attribute table) shared by the ccmsg daemon and web UI",
5
5
  "license": "MIT",
6
6
  "author": "kawaz",
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",
@@ -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 session's terminal names
145
- * itself in `terminal_id` on the `agents` topic, and the gateway's URL for it
146
- * is `<terminal_gateway>/sessions/<terminal_id>` so the base URL carries no
147
- * trailing slash, the path below it being the gateway's spelling and not this
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
+ * twoonly 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
 
@@ -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 with which config home
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
- sid: Sid,
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(Type.String()),
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 harness no longer reports this session, or the
54
- * instance that polled it stopped. Marked rather than absent, since a frame
55
- * carries only what changed. */
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 `sid`.
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
@@ -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 a session stands, as the instance holding it derives it.
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
- * The instance states the classification rather than the raw inputs it read,
9
- * so every client shows the same session the same way. The first three appear
10
- * on connected sessions, the last two on sessions the instance has lost; a
11
- * client that groups its list groups on this field alone.
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
- * Being pinned is not one of these: a person pins a session, and the mark
14
- * travels beside the classification rather than replacing it. */
15
- export const SessionState = Type.Union(
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
- /** Stopped at something a person has to answer: a dialog it opened, or a
18
- * turn that ended in an upstream error. */
19
- Type.Literal("waiting"),
20
- Type.Literal("live"),
21
- /** Alive, but reachable through neither a client connection nor a
22
- * terminal, so nothing here can act on it. */
23
- Type.Literal("live_unmanaged"),
24
- /** Gone, having said it was stopping. */
25
- Type.Literal("paused"),
26
- /** Gone without saying so. */
27
- Type.Literal("disappeared"),
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: "SessionState" },
55
+ { $id: "SessionStatusStanding" },
30
56
  );
31
- export type SessionState = Static<typeof SessionState>;
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 the `state` below, and the fields that only a lost session has
72
- * (`last_seen_at`, `stopped_at`, and what it must resume as) are stated
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
- /** How this session stands, which is also what separates a connected row
94
- * from one its instance has lost. Absent from an instance that states no
95
- * classification, and a client then shows the session without grouping it
96
- * rather than guessing one. */
97
- state: Type.Optional(SessionState),
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 `state`: a session is busy while it stands in any of the
116
- * connected classifications, so the two answer different questions and
117
- * collapsing them would lose one. It is an instant rather than a flag
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
@@ -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
- * The request names a session, never a pid: the instance resolves sid to pid
9
- * itself at the moment it signals, and a pid the caller asserted would be a
10
- * weaker basis for killing something than that. */
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: Type.String(),
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 = 3;
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",
@@ -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: 3,
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: 3,
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: 3,
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: 3,
87
+ protocol_version: 4,
87
88
  instance,
88
89
  endpoint,
89
90
  auth_expires_at: FIXTURE_NOW + 10_000_000,
@@ -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: "%17",
108
+ terminal_id: "hyoui:%17",
109
109
  instance,
110
110
  title: "contract fixtures",
111
111
  };
@@ -74,7 +74,7 @@ const PEER = {
74
74
  repo: "ccmsg-protocol",
75
75
  ws: "main",
76
76
  cwd: WORKSPACE,
77
- protocol_version: 3,
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
- state: "live",
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
- state: "live_unmanaged",
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
- state: "paused",
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 moved on, and one the instance forgot. */
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
- { ...PEER, state: "live", gateway_active_at: FIXTURE_NOW + 1_000 },
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: "%17",
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: [{ sid: other_sid, instance, removed: true }], polled_at: FIXTURE_NOW + 5_000 },
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> = {
@@ -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