@ccmsg/protocol 2.0.0 → 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 +1 -1
- package/src/common/topics.ts +4 -0
- package/src/control/agents.ts +5 -1
- package/src/control/peers.ts +13 -6
- package/src/control/terminals.ts +166 -0
- package/src/fixtures/index.ts +1 -0
- package/src/fixtures/topics.ts +42 -0
- package/src/index.ts +1 -0
- package/src/schemas.ts +2 -0
package/package.json
CHANGED
package/src/common/topics.ts
CHANGED
|
@@ -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.
|
package/src/control/agents.ts
CHANGED
|
@@ -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
|
package/src/control/peers.ts
CHANGED
|
@@ -40,8 +40,9 @@ export type SessionRun = Static<typeof SessionRun>;
|
|
|
40
40
|
* whether anything it says can still be trusted. */
|
|
41
41
|
export const SessionStatusStanding = Type.Union(
|
|
42
42
|
[
|
|
43
|
-
/**
|
|
44
|
-
* started stands here until the harness writes its first record
|
|
43
|
+
/** Nothing is folded: there is no transcript yet (a session that has just
|
|
44
|
+
* started stands here until the harness writes its first record), or this
|
|
45
|
+
* instance holds no fold of it because nothing has asked for one. */
|
|
45
46
|
Type.Literal("absent"),
|
|
46
47
|
/** The transcript is being read from the top; what the fold says so far is
|
|
47
48
|
* incomplete. */
|
|
@@ -236,10 +237,16 @@ export function liveness(
|
|
|
236
237
|
now: number,
|
|
237
238
|
): Liveness {
|
|
238
239
|
if (row.runs.length >= 2) return "duplicated";
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
240
|
+
if (row.runs.length === 1) return "alive";
|
|
241
|
+
// No run is seen, so the declaration and the gateway are all there is to go
|
|
242
|
+
// on: inference still running for it is a session that is alive somewhere
|
|
243
|
+
// this instance cannot see the process of.
|
|
244
|
+
if (
|
|
245
|
+
row.gateway_active_at !== undefined &&
|
|
246
|
+
now - row.gateway_active_at <= GATEWAY_LIVE_WINDOW_MS
|
|
247
|
+
) {
|
|
248
|
+
return "alive";
|
|
249
|
+
}
|
|
243
250
|
return row.stopped_at === undefined ? "disappeared" : "paused";
|
|
244
251
|
}
|
|
245
252
|
|
|
@@ -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
|
+
);
|
package/src/fixtures/index.ts
CHANGED
|
@@ -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,
|
package/src/fixtures/topics.ts
CHANGED
|
@@ -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,
|