@ccmsg/protocol 1.22.1 → 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.
@@ -576,7 +576,28 @@ export type DumpIdEntry = Static<typeof DumpIdEntry>;
576
576
  export const DumpIds = Type.Array(DumpIdEntry, { $id: "DumpIds" });
577
577
  export type DumpIds = Static<typeof DumpIds>;
578
578
 
579
- /** The file a dump is written to.
579
+ /** How the selected items are written out.
580
+ *
581
+ * The selection is one thing and its rendering another: which items a dump is
582
+ * of follows from the range and the `types`, and a format decides only what the
583
+ * file then says about them. So all three are dumps of the same items, and the
584
+ * reply describes that selection whichever was asked for.
585
+ *
586
+ * `items` is this contract's own vocabulary, the typed items as
587
+ * `SessionDumpFile`. `records` writes the transcript records those items were
588
+ * read from, unchanged, one JSON document per line — for a tool that already
589
+ * reads the harness's file and wants the classifying alone, which is why
590
+ * nothing of ours is added around them. An item names its record, so several
591
+ * items out of one record are one record here and the line count is not the
592
+ * item count. `text` renders the items for a person to read. */
593
+ export const SessionDumpFormat = Type.Union(
594
+ [Type.Literal("items"), Type.Literal("records"), Type.Literal("text")],
595
+ { $id: "SessionDumpFormat" },
596
+ );
597
+ export type SessionDumpFormat = Static<typeof SessionDumpFormat>;
598
+
599
+ /** The file an `items` dump is written to. The other two formats are not this
600
+ * shape: `records` is the harness's own lines and `text` is prose.
580
601
  *
581
602
  * The reply to a dump names a path rather than carrying the items, so the file
582
603
  * is where they actually travel — which makes its shape as much a part of the
@@ -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";
4
- import { DumpIds, TranscriptItemSelector, TranscriptItemType } from "./dump.ts";
3
+ import { InstanceId, Sid, TerminalId, Timestamp } from "../identifiers.ts";
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. */
@@ -203,6 +210,8 @@ export const SessionDumpWriteArgs = Type.Object({
203
210
  /** Leave out the machinery of in-process agents, which
204
211
  * `["-message.sub", "-tool.Agent"]` also says. */
205
212
  no_agent: Type.Optional(Type.Boolean()),
213
+ /** What the file says about the items selected. Absent is `items`. */
214
+ format: Type.Optional(SessionDumpFormat),
206
215
  });
207
216
  export type SessionDumpWriteArgs = Static<typeof SessionDumpWriteArgs>;
208
217
 
@@ -210,12 +219,18 @@ export const SessionDumpWriteResult = Type.Object({
210
219
  /** Absolute path on the writing instance's host. */
211
220
  path: Type.String(),
212
221
  instance: InstanceId,
213
- /** How many items of each type were written, keyed by type name. A single
222
+ /** How many items of each type were selected, keyed by type name. A single
214
223
  * total leaves the caller unable to tell a dump that kept what it asked for
215
- * from one whose selection matched almost nothing. */
224
+ * from one whose selection matched almost nothing.
225
+ *
226
+ * It counts items and not what the file holds, whatever the `format`: the
227
+ * selection is what a caller asked for and what it reads this against, and a
228
+ * count that moved with the rendering would answer a different question each
229
+ * time. */
216
230
  entries: Type.Record(TranscriptItemType, Type.Integer({ minimum: 0 })),
217
231
  /** The ids those items carried, so the next dump — of an agent named here —
218
- * can be asked for without opening the file. */
232
+ * can be asked for without opening the file. Read off the selection like
233
+ * `entries`, whatever the `format`. */
219
234
  ids: DumpIds,
220
235
  bytes: Type.Integer({ minimum: 0 }),
221
236
  });
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.
@@ -62,7 +62,7 @@ export const RequestEnvelope = Type.Object(
62
62
  *
63
63
  * A forwarded request that names none is dispatched as the `instance` role
64
64
  * it arrived on, which the attribute table already answers: an
65
- * instance-local op called by an instance is `forbidden`. */
65
+ * an `owner_instance` op called by an instance is `forbidden`. */
66
66
  caller: Type.Optional(CallerIdentity),
67
67
  },
68
68
  { $id: "RequestEnvelope" },
package/src/errors.ts CHANGED
@@ -35,17 +35,27 @@ export const ERROR_CODES = [
35
35
  "invalid_args",
36
36
  /** The op declares a `capability` this instance does not have. */
37
37
  "capability_unavailable",
38
- /** An `instance-local` op could not be forwarded to the instance that owns
38
+ /** An `owner_instance` op could not be forwarded to the instance that owns
39
39
  * the subject. */
40
40
  "instance_unreachable",
41
41
  // --- subscription ---
42
42
  /** The topic name is not one this protocol generation defines. */
43
43
  "topic_unknown",
44
44
  // --- subject lookup ---
45
- /** The `sid` names no session anywhere in the cluster. */
45
+ /** The `sid` names no session anywhere in the mesh. */
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",
@@ -66,7 +76,7 @@ export const ERROR_CODES = [
66
76
  * and `msg` says no more than the instance's own log would want. */
67
77
  "auth_invalid",
68
78
  /** The instance that issued the challenge or registration, and alone can
69
- * spend it, is not one this cluster knows or could reach just now. The client
79
+ * spend it, is not one this mesh knows or could reach just now. The client
70
80
  * asks for a fresh one, which the instance it is talking to can issue. */
71
81
  "auth_unknown_issuer",
72
82
  // --- translate ---
@@ -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
  };
@@ -75,6 +75,7 @@ export const NOTIFY_SEND_REQUEST: Static<typeof NotifySendRequest> = {
75
75
  op: "notify.send",
76
76
  sid,
77
77
  text: "確認して",
78
+ reply_to: mid,
78
79
  };
79
80
 
80
81
  export const NOTIFY_SEND_RESPONSE: Static<typeof NotifySendResponse> = {
@@ -29,6 +29,7 @@ export const INBOX_FRAME: Static<typeof InboxFrame> = {
29
29
  from_label: "contract-fixtures",
30
30
  text: "fixture を export した",
31
31
  sent_at: FIXTURE_NOW,
32
+ to: sid,
32
33
  },
33
34
  {
34
35
  mid: `${instance}/1842`,
@@ -37,15 +38,34 @@ export const INBOX_FRAME: Static<typeof InboxFrame> = {
37
38
  text: "確認する",
38
39
  reply_to: mid,
39
40
  sent_at: FIXTURE_NOW + 1_000,
41
+ to: sid,
40
42
  },
41
43
  ],
42
44
  };
43
45
 
46
+ /** A later frame, where one message has been handed over and another was never
47
+ * taken. */
48
+ export const INBOX_REMOVED_FRAME: Static<typeof InboxFrame> = {
49
+ ev: "topic",
50
+ topic: "inbox",
51
+ instance,
52
+ data: [
53
+ { mid, removed: true, reason: "delivered" },
54
+ { mid: `${instance}/1842`, removed: true, reason: "expired" },
55
+ ],
56
+ };
57
+
44
58
  export const NOTIFY_FRAME: Static<typeof NotifyFrame> = {
45
59
  ev: "topic",
46
60
  topic: "notify",
47
61
  instance,
48
- data: { sid, sid_label: "contract-fixtures", text: "確認して", sent_at: FIXTURE_NOW },
62
+ data: {
63
+ sid,
64
+ sid_label: "contract-fixtures",
65
+ text: "確認して",
66
+ reply_to: mid,
67
+ sent_at: FIXTURE_NOW,
68
+ },
49
69
  };
50
70
 
51
71
  const PEER = {
@@ -54,7 +74,7 @@ const PEER = {
54
74
  repo: "ccmsg-protocol",
55
75
  ws: "main",
56
76
  cwd: WORKSPACE,
57
- protocol_version: 3,
77
+ protocol_version: 4,
58
78
  };
59
79
 
60
80
  export const PEERS_FRAME: Static<typeof PeersFrame> = {
@@ -70,7 +90,15 @@ export const PEERS_FRAME: Static<typeof PeersFrame> = {
70
90
  repo_root: "/repos/kawaz/ccmsg-protocol",
71
91
  branch: "main",
72
92
  title: "contract fixtures",
73
- 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",
74
102
  pinned: true,
75
103
  connected_at: FIXTURE_NOW - 600_000,
76
104
  last_activity_at: FIXTURE_NOW,
@@ -82,7 +110,8 @@ export const PEERS_FRAME: Static<typeof PeersFrame> = {
82
110
  {
83
111
  ...PEER,
84
112
  sid: other_sid,
85
- state: "live_unmanaged",
113
+ runs: [{ pid: 7314, started_at: FIXTURE_NOW - 120_000, connected: false }],
114
+ session_status: "folding",
86
115
  stale_client: {
87
116
  last_seen_at: FIXTURE_NOW - 300_000,
88
117
  version: "0.0.9",
@@ -95,7 +124,8 @@ export const PEERS_FRAME: Static<typeof PeersFrame> = {
95
124
  repo: "ccmsg",
96
125
  ws: "daemon-v2",
97
126
  cwd: "/repos/kawaz/ccmsg/daemon-v2",
98
- state: "paused",
127
+ runs: [],
128
+ session_status: "ready",
99
129
  last_seen_at: FIXTURE_NOW - 1_200_000,
100
130
  stopped_at: FIXTURE_NOW - 1_000_000,
101
131
  model: "claude-opus-5",
@@ -105,14 +135,33 @@ export const PEERS_FRAME: Static<typeof PeersFrame> = {
105
135
  },
106
136
  };
107
137
 
108
- /** 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. */
109
140
  export const PEERS_CHANGE_FRAME: Static<typeof PeersFrame> = {
110
141
  ev: "topic",
111
142
  topic: "peers",
112
143
  instance,
113
144
  data: {
114
145
  peers: [
115
- { ...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
+ },
116
165
  { sid: "1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f", instance, removed: true },
117
166
  ],
118
167
  },
@@ -149,9 +198,18 @@ export const AGENTS_FRAME: Static<typeof AgentsFrame> = {
149
198
  status: "working",
150
199
  state: "busy",
151
200
  config_dir: "/config/claude-personal",
152
- terminal_id: "%17",
201
+ terminal_id: "hyoui:%17",
153
202
  terminal_namespace: "personal",
154
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
+ },
155
213
  ],
156
214
  polled_at: FIXTURE_NOW,
157
215
  },
@@ -162,7 +220,7 @@ export const AGENTS_CHANGE_FRAME: Static<typeof AgentsFrame> = {
162
220
  ev: "topic",
163
221
  topic: "agents",
164
222
  instance,
165
- 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 },
166
224
  };
167
225
 
168
226
  export const SESSION_STATUS_FRAME: Static<typeof SessionStatusFrame> = {
@@ -1,7 +1,7 @@
1
1
  import { type Static, Type } from "@sinclair/typebox";
2
2
 
3
3
  /** A session id: the uuid Claude Code gives its own session. Globally unique,
4
- * so it names a session across the whole cluster without an instance prefix. */
4
+ * so it names a session across the whole mesh without an instance prefix. */
5
5
  export const Sid = Type.String({
6
6
  $id: "Sid",
7
7
  pattern: "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
@@ -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