@ccmsg/protocol 2.0.1 → 2.1.1

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": "2.0.1",
3
+ "version": "2.1.1",
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",
@@ -9,6 +9,7 @@ export const PLAIN_TOPICS = [
9
9
  "peers",
10
10
  "instances",
11
11
  "agents",
12
+ "terminals",
12
13
  "session.errors",
13
14
  "llm.requests",
14
15
  "llm.status",
@@ -109,6 +110,9 @@ export const TOPIC_ATTRIBUTES = {
109
110
  // is apart from the rows of `peers`.
110
111
  instances: { roles: ["session", "user"], granularity: "per_instance_whole" },
111
112
  agents: { roles: ["user"], granularity: "element" },
113
+ // The host's terminals, which exist whether a session is in them or not, so
114
+ // they are a list of their own rather than a field of a session's row.
115
+ terminals: { roles: ["user"], granularity: "element" },
112
116
  // A set the instance derives whole, by folding one error pattern over its
113
117
  // sessions: it learns which sessions are stopped, not that one of them
114
118
  // changed, so each frame is that reading entire.
@@ -45,7 +45,11 @@ export const AgentInfo = Type.Object(
45
45
  /** The terminal the session runs in, which is the handle a rename types
46
46
  * into. Absent when the process does not name one or its environment could
47
47
  * not be read. Read from the running process rather than remembered from
48
- * when it started, since resuming a session gives it a new process. */
48
+ * when it started, since resuming a session gives it a new process.
49
+ *
50
+ * Where the instance states `terminals` as well, the match of the pids
51
+ * there is what says which terminal this run is in; this field is what an
52
+ * instance with no terminal manager has to go on. */
49
53
  terminal_id: Type.Optional(TerminalId),
50
54
  /** Which namespace that terminal lives in. Absent means the process set
51
55
  * none, which the multiplexer treats as its default — not the instance's
@@ -0,0 +1,166 @@
1
+ import { type Static, Type } from "@sinclair/typebox";
2
+ import { topicFrame } from "../envelope.ts";
3
+ import { InstanceId, TerminalId, Timestamp } from "../identifiers.ts";
4
+
5
+ /** One terminal on a host, as the terminal manager reports it.
6
+ *
7
+ * A terminal is not an attribute of a session: a person opens one with nothing
8
+ * running in it, and the terminal outlives the session that was running there.
9
+ * So it is a row of its own, matched by `instance` and `id`, and which session
10
+ * is in it is read off the pids rather than stated here.
11
+ *
12
+ * The words are a terminal's own — `id`, `state`, `pid` — rather than any one
13
+ * manager's, so another manager's terminals are rows of the same list. Which
14
+ * manager a row came from is the scheme of its `id`. */
15
+ export const TerminalInfo = Type.Object(
16
+ {
17
+ /** The instance that polled it, and whose host the pid belongs to. */
18
+ instance: InstanceId,
19
+ /** `<scheme>:<id>`, the scheme naming which terminal manager observed it. */
20
+ id: TerminalId,
21
+ /** What the terminal manager says the terminal is doing. An open set. */
22
+ state: Type.String(),
23
+ /** What is running in the terminal, as argv. */
24
+ command: Type.Array(Type.String()),
25
+ cwd: Type.Optional(Type.String()),
26
+ /** The main process inside the terminal. Absent where the manager reports
27
+ * none, which is also what leaves such a row out of every derivation
28
+ * below: nothing can be matched against a pid that is not there. */
29
+ pid: Type.Optional(Type.Integer({ minimum: 1 })),
30
+ started_at: Type.Optional(Timestamp),
31
+ },
32
+ { $id: "TerminalInfo" },
33
+ );
34
+ export type TerminalInfo = Static<typeof TerminalInfo>;
35
+
36
+ /** A row that is gone: the terminal was closed, or the instance that polled it
37
+ * stopped. Marked rather than absent, since a frame carries only what changed. */
38
+ export const TerminalRemoved = Type.Object(
39
+ {
40
+ instance: InstanceId,
41
+ id: TerminalId,
42
+ removed: Type.Literal(true),
43
+ },
44
+ { $id: "TerminalRemoved" },
45
+ );
46
+ export type TerminalRemoved = Static<typeof TerminalRemoved>;
47
+
48
+ export const TerminalElement = Type.Union([TerminalInfo, TerminalRemoved], {
49
+ $id: "TerminalElement",
50
+ });
51
+ export type TerminalElement = Static<typeof TerminalElement>;
52
+
53
+ /** The commands a harness is started as, by the name its binary is installed
54
+ * under. What `starting` below reads a terminal's `command` against, so that a
55
+ * shell a person opened is not read as a session on its way up.
56
+ *
57
+ * Names rather than paths: the same harness is installed under a dozen
58
+ * prefixes and run through as many wrappers, and none of that changes what it
59
+ * is. A harness outside this list is one nothing here claims to recognise — its
60
+ * terminal is unattached until the harness reports the run itself. */
61
+ export const HARNESS_COMMANDS = ["claude", "codex"] as const;
62
+
63
+ /** What a derivation below reads off a terminal: where it is and what is
64
+ * running in it. */
65
+ interface TerminalRow {
66
+ readonly instance: string;
67
+ readonly pid?: number;
68
+ readonly command?: readonly string[];
69
+ }
70
+
71
+ /** What it reads off an `agents` row: where the process is, and whose session
72
+ * it runs. */
73
+ interface AgentRow {
74
+ readonly instance: string;
75
+ readonly pid: number;
76
+ readonly sid?: string;
77
+ }
78
+
79
+ const held = (agents: readonly AgentRow[]): Set<string> =>
80
+ new Set(agents.map((agent) => `${agent.instance}/${agent.pid}`));
81
+
82
+ /** Whether a terminal is running a harness, read off the name its command was
83
+ * invoked under. */
84
+ const isHarness = (command: readonly string[] | undefined): boolean => {
85
+ const argv0 = command?.[0];
86
+ if (argv0 === undefined) return false;
87
+ const name = argv0.slice(argv0.lastIndexOf("/") + 1);
88
+ return (HARNESS_COMMANDS as readonly string[]).includes(name);
89
+ };
90
+
91
+ const key = (terminal: TerminalRow): string | undefined =>
92
+ terminal.pid === undefined ? undefined : `${terminal.instance}/${terminal.pid}`;
93
+
94
+ /** The terminals a session is running in: those whose process is a run of that
95
+ * session.
96
+ *
97
+ * The pid is what says so, not `agents.terminal_id`: the terminal list is the
98
+ * one that knows which terminals exist, and a run reaches it as the process
99
+ * inside one. Both lists are the `user` role's, and both are keyed by the host
100
+ * the pid belongs to, which is why a row is matched by `instance` and `pid`
101
+ * together.
102
+ *
103
+ * Derived here rather than by each side, for the reason `liveness` is: an
104
+ * instance and a client that each wrote this would show one host two ways. */
105
+ export function terminalsOf<T extends TerminalRow>(
106
+ sid: string,
107
+ agents: readonly AgentRow[],
108
+ terminals: readonly T[],
109
+ ): T[] {
110
+ const pids = held(agents.filter((agent) => agent.sid === sid));
111
+ return terminals.filter((terminal) => {
112
+ const at = key(terminal);
113
+ return at !== undefined && pids.has(at);
114
+ });
115
+ }
116
+
117
+ /** The terminals no run is in: the ones a person opened for themselves, and the
118
+ * ones a harness has just started in and not yet been seen as a run of.
119
+ *
120
+ * A terminal whose manager reports no pid is here too — nothing can be matched
121
+ * against it, so nothing can claim it. */
122
+ export function unattachedTerminals<T extends TerminalRow>(
123
+ agents: readonly AgentRow[],
124
+ terminals: readonly T[],
125
+ ): T[] {
126
+ const pids = held(agents);
127
+ return terminals.filter((terminal) => {
128
+ const at = key(terminal);
129
+ return at === undefined || !pids.has(at);
130
+ });
131
+ }
132
+
133
+ /** The terminals a harness is running in that the harness has not reported: one
134
+ * that has started and has neither written a state file nor greeted yet. This
135
+ * is where a run before its state file is said, rather than as an `agents` row
136
+ * without a `sid`.
137
+ *
138
+ * Narrower than `unattachedTerminals` in both ways it can be: a terminal with
139
+ * no process is one nothing can be starting in, and a terminal running
140
+ * something that is not a harness is a person's own and is not on its way to
141
+ * becoming a session. The `command` is what says which — a pid alone cannot
142
+ * tell a shell from a harness — so a row that states none is not here. */
143
+ export function starting<T extends TerminalRow>(
144
+ terminals: readonly T[],
145
+ agents: readonly AgentRow[],
146
+ ): T[] {
147
+ const pids = held(agents);
148
+ return terminals.filter((terminal) => {
149
+ const at = key(terminal);
150
+ return at !== undefined && !pids.has(at) && isHarness(terminal.command);
151
+ });
152
+ }
153
+
154
+ /** The `terminals` topic. Elements, like `agents`: the rows that changed since
155
+ * the last frame, matched by their `instance` and `id`.
156
+ *
157
+ * The `user` role's alone. A terminal is the host's, and a session has no
158
+ * reason to be told which terminals another session is being typed into. */
159
+ export const TerminalsFrame = topicFrame(
160
+ "terminals",
161
+ Type.Object({
162
+ terminals: Type.Array(TerminalElement),
163
+ /** When the poll behind these rows ran. Absent before the first one. */
164
+ polled_at: Type.Optional(Timestamp),
165
+ }),
166
+ );
@@ -179,6 +179,7 @@ export const TOPIC_FIXTURES = {
179
179
  peers: topics.PEERS_FRAME,
180
180
  instances: topics.INSTANCES_FRAME,
181
181
  agents: topics.AGENTS_FRAME,
182
+ terminals: topics.TERMINALS_FRAME,
182
183
  "session.status": topics.SESSION_STATUS_FRAME,
183
184
  transcript: topics.TRANSCRIPT_FRAME,
184
185
  "transcript.items": topics.TRANSCRIPT_ITEMS_FRAME,
@@ -7,6 +7,7 @@ import type { LlmRequestsFrame, LlmStatusFrame } from "../control/llm.ts";
7
7
  import type { PeersFrame } from "../control/peers.ts";
8
8
  import type { SessionErrorsFrame } from "../control/session-errors.ts";
9
9
  import type { SessionStatusFrame } from "../control/session-status.ts";
10
+ import type { TerminalsFrame } from "../control/terminals.ts";
10
11
  import type { TranscriptFrame, TranscriptItemsFrame } from "../control/transcript.ts";
11
12
  import { TRANSCRIPT_ITEMS } from "./control.ts";
12
13
  import type { InboxFrame } from "../messaging/message.ts";
@@ -223,6 +224,47 @@ export const AGENTS_CHANGE_FRAME: Static<typeof AgentsFrame> = {
223
224
  data: { agents: [{ instance, pid: 7314, removed: true }], polled_at: FIXTURE_NOW + 5_000 },
224
225
  };
225
226
 
227
+ export const TERMINALS_FRAME: Static<typeof TerminalsFrame> = {
228
+ ev: "topic",
229
+ topic: "terminals",
230
+ snapshot: true,
231
+ instance,
232
+ data: {
233
+ terminals: [
234
+ {
235
+ instance,
236
+ id: "hyoui:%17",
237
+ state: "running",
238
+ command: ["claude", "--continue"],
239
+ cwd: WORKSPACE,
240
+ pid: 4821,
241
+ started_at: FIXTURE_NOW - 600_000,
242
+ },
243
+ {
244
+ instance,
245
+ id: "hyoui:%31",
246
+ state: "running",
247
+ command: ["zsh", "-i"],
248
+ cwd: WORKSPACE,
249
+ pid: 5177,
250
+ started_at: FIXTURE_NOW - 120_000,
251
+ },
252
+ ],
253
+ polled_at: FIXTURE_NOW,
254
+ },
255
+ };
256
+
257
+ /** A later frame: one terminal was closed. */
258
+ export const TERMINALS_CHANGE_FRAME: Static<typeof TerminalsFrame> = {
259
+ ev: "topic",
260
+ topic: "terminals",
261
+ instance,
262
+ data: {
263
+ terminals: [{ instance, id: "hyoui:%31", removed: true }],
264
+ polled_at: FIXTURE_NOW + 5_000,
265
+ },
266
+ };
267
+
226
268
  export const SESSION_STATUS_FRAME: Static<typeof SessionStatusFrame> = {
227
269
  ev: "topic",
228
270
  topic: `session.status:${sid}`,
package/src/index.ts CHANGED
@@ -16,6 +16,7 @@ export * from "./control/sandbox.ts";
16
16
  export * from "./control/session-errors.ts";
17
17
  export * from "./control/session-status.ts";
18
18
  export * from "./control/session.ts";
19
+ export * from "./control/terminals.ts";
19
20
  export * from "./control/transcript.ts";
20
21
  export * from "./control/translate.ts";
21
22
  export * from "./envelope.ts";
package/src/schemas.ts CHANGED
@@ -94,6 +94,7 @@ import {
94
94
  } from "./control/sandbox.ts";
95
95
  import { SessionErrorsFrame } from "./control/session-errors.ts";
96
96
  import { SessionStatusFrame } from "./control/session-status.ts";
97
+ import { TerminalsFrame } from "./control/terminals.ts";
97
98
  import {
98
99
  SessionDumpWriteRequest,
99
100
  SessionDumpWriteResponse,
@@ -216,6 +217,7 @@ export const TOPIC_SCHEMAS = {
216
217
  peers: PeersFrame,
217
218
  instances: InstancesFrame,
218
219
  agents: AgentsFrame,
220
+ terminals: TerminalsFrame,
219
221
  "session.status": SessionStatusFrame,
220
222
  transcript: TranscriptFrame,
221
223
  "transcript.items": TranscriptItemsFrame,