@ccmsg/protocol 0.1.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/LICENSE +21 -0
- package/README.md +33 -0
- package/package.json +33 -0
- package/src/attributes.ts +353 -0
- package/src/common/hello.ts +73 -0
- package/src/common/ping.ts +37 -0
- package/src/common/shutdown.ts +13 -0
- package/src/common/topics.ts +99 -0
- package/src/control/agents.ts +64 -0
- package/src/control/files.ts +323 -0
- package/src/control/kv.ts +108 -0
- package/src/control/launcher.ts +100 -0
- package/src/control/llm.ts +494 -0
- package/src/control/peers.ts +127 -0
- package/src/control/sandbox.ts +58 -0
- package/src/control/session-errors.ts +27 -0
- package/src/control/session-status.ts +311 -0
- package/src/control/session.ts +251 -0
- package/src/control/transcript.ts +80 -0
- package/src/control/translate.ts +33 -0
- package/src/envelope.ts +117 -0
- package/src/errors.ts +63 -0
- package/src/identifiers.ts +62 -0
- package/src/index.ts +25 -0
- package/src/messaging/message.ts +92 -0
- package/src/messaging/notify.ts +35 -0
- package/src/messaging/say.ts +35 -0
- package/src/schemas.ts +195 -0
- package/src/upstream.ts +17 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type Static, Type } from "@sinclair/typebox";
|
|
2
|
+
import { topicFrame } from "../envelope.ts";
|
|
3
|
+
import { InstanceId, Sid } from "../identifiers.ts";
|
|
4
|
+
import { SessionApiError } from "./session-status.ts";
|
|
5
|
+
|
|
6
|
+
/** Which session is stopped, and on what. */
|
|
7
|
+
export const SessionErrorEntry = Type.Intersect(
|
|
8
|
+
[Type.Object({ sid: Sid, instance: InstanceId }), SessionApiError],
|
|
9
|
+
{ $id: "SessionErrorEntry" },
|
|
10
|
+
);
|
|
11
|
+
export type SessionErrorEntry = Static<typeof SessionErrorEntry>;
|
|
12
|
+
|
|
13
|
+
/** The `session_errors` topic: every connected session currently stopped on a
|
|
14
|
+
* harness error.
|
|
15
|
+
*
|
|
16
|
+
* Not per session, unlike `session_status`. A client showing a list of sessions
|
|
17
|
+
* has to mark the stopped ones, and subscribing to a whole status fold for each
|
|
18
|
+
* visible session is the cost this exists to avoid: the instance folds the one
|
|
19
|
+
* error pattern over every connected session instead.
|
|
20
|
+
*
|
|
21
|
+
* Whole-value per instance. A session that recovers drops out of the list
|
|
22
|
+
* rather than appearing with an empty error, so a client that missed a frame
|
|
23
|
+
* still converges on the next one. */
|
|
24
|
+
export const SessionErrorsFrame = topicFrame(
|
|
25
|
+
"session_errors",
|
|
26
|
+
Type.Object({ errors: Type.Array(SessionErrorEntry) }),
|
|
27
|
+
);
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { type Static, Type } from "@sinclair/typebox";
|
|
2
|
+
import { topicFrame } from "../envelope.ts";
|
|
3
|
+
import { Sid, Timestamp } from "../identifiers.ts";
|
|
4
|
+
|
|
5
|
+
/** One task on the session's list. */
|
|
6
|
+
export const SessionTodo = Type.Object(
|
|
7
|
+
{
|
|
8
|
+
id: Type.String(),
|
|
9
|
+
subject: Type.String(),
|
|
10
|
+
/** Pending, in progress, done — an open set the harness may grow. */
|
|
11
|
+
status: Type.String(),
|
|
12
|
+
owner: Type.Optional(Type.String()),
|
|
13
|
+
/** Tasks this one waits on, and tasks that wait on it. Both are read out of
|
|
14
|
+
* the session's own transcript, and both are empty when nothing was
|
|
15
|
+
* declared. */
|
|
16
|
+
blocked_by: Type.Array(Type.String()),
|
|
17
|
+
blocks: Type.Array(Type.String()),
|
|
18
|
+
},
|
|
19
|
+
{ $id: "SessionTodo" },
|
|
20
|
+
);
|
|
21
|
+
export type SessionTodo = Static<typeof SessionTodo>;
|
|
22
|
+
|
|
23
|
+
/** One phase of a workflow, with how much of it is finished. */
|
|
24
|
+
export const WorkflowPhaseStatus = Type.Object(
|
|
25
|
+
{ title: Type.String(), done: Type.Integer({ minimum: 0 }), total: Type.Integer({ minimum: 0 }) },
|
|
26
|
+
{ $id: "WorkflowPhaseStatus" },
|
|
27
|
+
);
|
|
28
|
+
export type WorkflowPhaseStatus = Static<typeof WorkflowPhaseStatus>;
|
|
29
|
+
|
|
30
|
+
/** One agent belonging to a workflow.
|
|
31
|
+
*
|
|
32
|
+
* Nearly everything is optional because a finished agent and a running one are
|
|
33
|
+
* observed from different places: a finished one is described by the record the
|
|
34
|
+
* workflow wrote, a running one only by the fact that it started and has not
|
|
35
|
+
* reported back. */
|
|
36
|
+
export const WorkflowAgentStatus = Type.Object(
|
|
37
|
+
{
|
|
38
|
+
/** The handle a transcript read accepts for this agent. */
|
|
39
|
+
agent_id: Type.String(),
|
|
40
|
+
label: Type.Optional(Type.String()),
|
|
41
|
+
model: Type.Optional(Type.String()),
|
|
42
|
+
agent_type: Type.Optional(Type.String()),
|
|
43
|
+
/** How it is getting on. An open set: most values come from the workflow's
|
|
44
|
+
* own record, and "still running" is inferred from a start with no result. */
|
|
45
|
+
state: Type.String(),
|
|
46
|
+
tokens: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
47
|
+
tool_calls: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
48
|
+
phase_index: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
49
|
+
phase_title: Type.Optional(Type.String()),
|
|
50
|
+
last_tool: Type.Optional(Type.String()),
|
|
51
|
+
result_preview: Type.Optional(Type.String()),
|
|
52
|
+
error: Type.Optional(Type.String()),
|
|
53
|
+
started_at: Type.Optional(Timestamp),
|
|
54
|
+
duration_ms: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
55
|
+
},
|
|
56
|
+
{ $id: "WorkflowAgentStatus" },
|
|
57
|
+
);
|
|
58
|
+
export type WorkflowAgentStatus = Static<typeof WorkflowAgentStatus>;
|
|
59
|
+
|
|
60
|
+
export const SessionWorkflowStatus = Type.Object(
|
|
61
|
+
{
|
|
62
|
+
/** Correlates the workflow with the notification announcing its result. */
|
|
63
|
+
task_id: Type.String(),
|
|
64
|
+
name: Type.String(),
|
|
65
|
+
summary: Type.Optional(Type.String()),
|
|
66
|
+
/** Running, or one of the ways it can end. An open set. */
|
|
67
|
+
status: Type.String(),
|
|
68
|
+
started_at: Timestamp,
|
|
69
|
+
ended_at: Type.Optional(Timestamp),
|
|
70
|
+
/** Names this run, and is what a transcript read asks for to reach the
|
|
71
|
+
* agents belonging to it. */
|
|
72
|
+
run_id: Type.Optional(Type.String()),
|
|
73
|
+
/** The declared phases. Empty while the run is still going, since a
|
|
74
|
+
* workflow declares them in the record it writes when it finishes. */
|
|
75
|
+
phases: Type.Array(WorkflowPhaseStatus),
|
|
76
|
+
/** From that record once it exists, and from the run's journal before then.
|
|
77
|
+
* Empty when neither could be read. */
|
|
78
|
+
agents: Type.Array(WorkflowAgentStatus),
|
|
79
|
+
},
|
|
80
|
+
{ $id: "SessionWorkflowStatus" },
|
|
81
|
+
);
|
|
82
|
+
export type SessionWorkflowStatus = Static<typeof SessionWorkflowStatus>;
|
|
83
|
+
|
|
84
|
+
export const SessionBackgroundStatus = Type.Object(
|
|
85
|
+
{
|
|
86
|
+
task_id: Type.String(),
|
|
87
|
+
kind: Type.Union([Type.Literal("monitor"), Type.Literal("bash"), Type.Literal("agent")]),
|
|
88
|
+
description: Type.String(),
|
|
89
|
+
/** Running, or one of the ways it can end. An open set. */
|
|
90
|
+
status: Type.String(),
|
|
91
|
+
started_at: Timestamp,
|
|
92
|
+
ended_at: Type.Optional(Timestamp),
|
|
93
|
+
/** Which kind of agent was spawned. Only for `agent`. */
|
|
94
|
+
agent_type: Type.Optional(Type.String()),
|
|
95
|
+
},
|
|
96
|
+
{ $id: "SessionBackgroundStatus" },
|
|
97
|
+
);
|
|
98
|
+
export type SessionBackgroundStatus = Static<typeof SessionBackgroundStatus>;
|
|
99
|
+
|
|
100
|
+
/** How much context the session's own turns are carrying.
|
|
101
|
+
*
|
|
102
|
+
* The raw counts travel and the ceiling is left to the reader: what a model's
|
|
103
|
+
* limit is can be overridden where the session runs, and the transcript does
|
|
104
|
+
* not record that, so a limit computed here would be wrong exactly where it
|
|
105
|
+
* mattered. */
|
|
106
|
+
export const SessionContextUsage = Type.Object(
|
|
107
|
+
{
|
|
108
|
+
/** Everything the last turn had to read. */
|
|
109
|
+
tokens: Type.Integer({ minimum: 0 }),
|
|
110
|
+
/** As the transcript spells it. */
|
|
111
|
+
model: Type.String(),
|
|
112
|
+
/** Absent from transcripts written before the harness recorded it. */
|
|
113
|
+
effort: Type.Optional(Type.String()),
|
|
114
|
+
/** The turn this reading was taken from. */
|
|
115
|
+
observed_at: Timestamp,
|
|
116
|
+
},
|
|
117
|
+
{ $id: "SessionContextUsage" },
|
|
118
|
+
);
|
|
119
|
+
export type SessionContextUsage = Static<typeof SessionContextUsage>;
|
|
120
|
+
|
|
121
|
+
/** A teammate of the session, as its transcript shows it. The teammate's own
|
|
122
|
+
* sense of whether it is busy is not reachable, so the state is an estimate. */
|
|
123
|
+
export const SessionTeammate = Type.Object(
|
|
124
|
+
{
|
|
125
|
+
name: Type.String(),
|
|
126
|
+
/** Whether the spawn was seen to succeed. */
|
|
127
|
+
spawned: Type.Boolean(),
|
|
128
|
+
agent_type: Type.Optional(Type.String()),
|
|
129
|
+
color: Type.Optional(Type.String()),
|
|
130
|
+
spawned_at: Type.Optional(Timestamp),
|
|
131
|
+
last_sent_at: Type.Optional(Timestamp),
|
|
132
|
+
last_received_at: Type.Optional(Timestamp),
|
|
133
|
+
/** An open set, taken from the latest thing observed. */
|
|
134
|
+
state: Type.String(),
|
|
135
|
+
/** Fixed when it was spawned, in the raw spelling. */
|
|
136
|
+
model: Type.Optional(Type.String()),
|
|
137
|
+
},
|
|
138
|
+
{ $id: "SessionTeammate" },
|
|
139
|
+
);
|
|
140
|
+
export type SessionTeammate = Static<typeof SessionTeammate>;
|
|
141
|
+
|
|
142
|
+
/** One agent below the session.
|
|
143
|
+
*
|
|
144
|
+
* A node whose parent cannot be located — the parent's transcript has rotated,
|
|
145
|
+
* or was never seen — is surfaced at the top rather than dropped, so it stays
|
|
146
|
+
* reachable instead of quietly disappearing. */
|
|
147
|
+
export const AgentTreeNode = Type.Recursive(
|
|
148
|
+
(self) =>
|
|
149
|
+
Type.Object({
|
|
150
|
+
/** Stable across reads, and what a transcript read asks for. */
|
|
151
|
+
agent_id: Type.String(),
|
|
152
|
+
/** Present for a teammate: the name it is addressed by, which is its
|
|
153
|
+
* observable identity even though it resolves to the same transcript. */
|
|
154
|
+
teammate_name: Type.Optional(Type.String()),
|
|
155
|
+
/** The role it was spawned as. Absent only when its record is malformed. */
|
|
156
|
+
agent_type: Type.Optional(Type.String()),
|
|
157
|
+
/** The spawn's description, verbatim. */
|
|
158
|
+
description: Type.Optional(Type.String()),
|
|
159
|
+
color: Type.Optional(Type.String()),
|
|
160
|
+
model: Type.Optional(Type.String()),
|
|
161
|
+
team_name: Type.Optional(Type.String()),
|
|
162
|
+
/** How far below the session it sits; a direct child is zero. */
|
|
163
|
+
spawn_depth: Type.Integer({ minimum: 0 }),
|
|
164
|
+
/** Where the node came from: a long-lived teammate, a one-off spawn, or a
|
|
165
|
+
* member of a workflow run. */
|
|
166
|
+
kind: Type.Union([
|
|
167
|
+
Type.Literal("teammate"),
|
|
168
|
+
Type.Literal("subagent"),
|
|
169
|
+
Type.Literal("workflow_member"),
|
|
170
|
+
]),
|
|
171
|
+
/** The run it belongs to. Only for a workflow member. */
|
|
172
|
+
workflow_id: Type.Optional(Type.String()),
|
|
173
|
+
/** An estimate, and an open set. Direct children reuse what the session's
|
|
174
|
+
* own fold observed; deeper ones fall back to how recently their
|
|
175
|
+
* transcript was touched. */
|
|
176
|
+
state: Type.String(),
|
|
177
|
+
/** When its transcript was last written to. */
|
|
178
|
+
last_activity_at: Type.Optional(Timestamp),
|
|
179
|
+
children: Type.Array(self),
|
|
180
|
+
}),
|
|
181
|
+
{ $id: "AgentTreeNode" },
|
|
182
|
+
);
|
|
183
|
+
export type AgentTreeNode = Static<typeof AgentTreeNode>;
|
|
184
|
+
|
|
185
|
+
/** One phase of a workflow run, with the members assigned to it. */
|
|
186
|
+
export const AgentTreeWorkflowPhase = Type.Object(
|
|
187
|
+
{
|
|
188
|
+
/** Counting from one, as the workflow declares its phases. */
|
|
189
|
+
index: Type.Integer({ minimum: 1 }),
|
|
190
|
+
title: Type.String(),
|
|
191
|
+
done: Type.Integer({ minimum: 0 }),
|
|
192
|
+
total: Type.Integer({ minimum: 0 }),
|
|
193
|
+
members: Type.Array(AgentTreeNode),
|
|
194
|
+
},
|
|
195
|
+
{ $id: "AgentTreeWorkflowPhase" },
|
|
196
|
+
);
|
|
197
|
+
export type AgentTreeWorkflowPhase = Static<typeof AgentTreeWorkflowPhase>;
|
|
198
|
+
|
|
199
|
+
/** One workflow run's members, grouped by phase. */
|
|
200
|
+
export const AgentTreeWorkflowGroup = Type.Object(
|
|
201
|
+
{
|
|
202
|
+
workflow_id: Type.String(),
|
|
203
|
+
/** The whole run's progress, across every phase. */
|
|
204
|
+
done: Type.Integer({ minimum: 0 }),
|
|
205
|
+
total: Type.Integer({ minimum: 0 }),
|
|
206
|
+
/** Empty while the run has not declared its phases yet, in which case every
|
|
207
|
+
* member is unassigned. */
|
|
208
|
+
phases: Type.Array(AgentTreeWorkflowPhase),
|
|
209
|
+
/** Members no phase could be found for. Normally empty. */
|
|
210
|
+
unassigned: Type.Array(AgentTreeNode),
|
|
211
|
+
/** The newest activity anywhere in the run, for ordering runs against each
|
|
212
|
+
* other. */
|
|
213
|
+
last_activity_at: Type.Optional(Timestamp),
|
|
214
|
+
},
|
|
215
|
+
{ $id: "AgentTreeWorkflowGroup" },
|
|
216
|
+
);
|
|
217
|
+
export type AgentTreeWorkflowGroup = Static<typeof AgentTreeWorkflowGroup>;
|
|
218
|
+
|
|
219
|
+
/** Everything running below the session, in the three kinds it distinguishes.
|
|
220
|
+
* A kind with nothing in it is an empty list rather than an absent one. */
|
|
221
|
+
export const AgentTreeGroups = Type.Object(
|
|
222
|
+
{
|
|
223
|
+
/** Long-lived members of the session's team, all directly below it. */
|
|
224
|
+
teammates: Type.Array(AgentTreeNode),
|
|
225
|
+
/** One-off spawns, nested where one spawned another. */
|
|
226
|
+
agents: Type.Array(AgentTreeNode),
|
|
227
|
+
/** One group per workflow run. */
|
|
228
|
+
workflows: Type.Array(AgentTreeWorkflowGroup),
|
|
229
|
+
},
|
|
230
|
+
{ $id: "AgentTreeGroups" },
|
|
231
|
+
);
|
|
232
|
+
export type AgentTreeGroups = Static<typeof AgentTreeGroups>;
|
|
233
|
+
|
|
234
|
+
/** Which kind of transcript record named a file outside the session's root.
|
|
235
|
+
* Grouping is all this decides; both origins grant the same single-file read. */
|
|
236
|
+
export const ExternalFile = Type.Object(
|
|
237
|
+
{
|
|
238
|
+
path: Type.String(),
|
|
239
|
+
/** A file tool touched it, or it arrived as an attachment. A path named
|
|
240
|
+
* both ways is reported as whichever came first. */
|
|
241
|
+
origin: Type.Union([Type.Literal("tool"), Type.Literal("attachment")]),
|
|
242
|
+
},
|
|
243
|
+
{ $id: "ExternalFile" },
|
|
244
|
+
);
|
|
245
|
+
export type ExternalFile = Static<typeof ExternalFile>;
|
|
246
|
+
|
|
247
|
+
/** One folder the session's editor workspace names.
|
|
248
|
+
*
|
|
249
|
+
* The path is the allowlist key a `workspace` read is checked against; the name
|
|
250
|
+
* is for display, and falls back to the folder's own basename. Duplicates are
|
|
251
|
+
* removed, so a folder named twice appears once. */
|
|
252
|
+
export const WorkspaceFolder = Type.Object(
|
|
253
|
+
{
|
|
254
|
+
name: Type.String({ minLength: 1 }),
|
|
255
|
+
/** Absolute, fully resolved, and without a trailing separator. */
|
|
256
|
+
path: Type.String({ minLength: 1 }),
|
|
257
|
+
},
|
|
258
|
+
{ $id: "WorkspaceFolder" },
|
|
259
|
+
);
|
|
260
|
+
export type WorkspaceFolder = Static<typeof WorkspaceFolder>;
|
|
261
|
+
|
|
262
|
+
/** An error the harness wrote in the session's own voice, seen as the last
|
|
263
|
+
* thing its main context did.
|
|
264
|
+
*
|
|
265
|
+
* The harness phrases these as though the agent were speaking — that a prompt
|
|
266
|
+
* is too long, that a login is needed — but they are the harness reporting a
|
|
267
|
+
* turn that stopped, and the session sits idle until a person intervenes.
|
|
268
|
+
*
|
|
269
|
+
* Only the latest turn counts: a real turn after one of these clears it, so a
|
|
270
|
+
* session that hit a passing failure and carried on is not flagged. An error
|
|
271
|
+
* inside a subagent never sets it, since a failed subagent does not stop the
|
|
272
|
+
* session. */
|
|
273
|
+
export const SessionApiError = Type.Object(
|
|
274
|
+
{
|
|
275
|
+
/** The error as written, so a reader can see why the session stopped. May
|
|
276
|
+
* run to several lines. */
|
|
277
|
+
text: Type.String(),
|
|
278
|
+
occurred_at: Timestamp,
|
|
279
|
+
},
|
|
280
|
+
{ $id: "SessionApiError" },
|
|
281
|
+
);
|
|
282
|
+
export type SessionApiError = Static<typeof SessionApiError>;
|
|
283
|
+
|
|
284
|
+
/** Everything the instance can fold out of one session's transcript. */
|
|
285
|
+
export const SessionStatusSnapshot = Type.Object(
|
|
286
|
+
{
|
|
287
|
+
todos: Type.Array(SessionTodo),
|
|
288
|
+
workflows: Type.Array(SessionWorkflowStatus),
|
|
289
|
+
background: Type.Array(SessionBackgroundStatus),
|
|
290
|
+
teammates: Type.Array(SessionTeammate),
|
|
291
|
+
agent_tree: AgentTreeGroups,
|
|
292
|
+
/** Absolute paths outside the session's root that its transcript names.
|
|
293
|
+
* This is exactly the allowlist an `external` read is checked against — one
|
|
294
|
+
* list, so nothing can honour one origin and forget another. */
|
|
295
|
+
external_files: Type.Array(ExternalFile),
|
|
296
|
+
workspace_folders: Type.Array(WorkspaceFolder),
|
|
297
|
+
/** Absent when the transcript's last turn carried no reading. */
|
|
298
|
+
context: Type.Optional(SessionContextUsage),
|
|
299
|
+
/** Present only while the session is stopped on one. */
|
|
300
|
+
api_error: Type.Optional(SessionApiError),
|
|
301
|
+
},
|
|
302
|
+
{ $id: "SessionStatusSnapshot" },
|
|
303
|
+
);
|
|
304
|
+
export type SessionStatusSnapshot = Static<typeof SessionStatusSnapshot>;
|
|
305
|
+
|
|
306
|
+
/** The `session_status:<sid>` topic. Whole-value: the fold is recomputed and
|
|
307
|
+
* sent entire whenever something in the transcript changes it. */
|
|
308
|
+
export const SessionStatusFrame = topicFrame(
|
|
309
|
+
"session_status",
|
|
310
|
+
Type.Intersect([Type.Object({ sid: Sid }), SessionStatusSnapshot]),
|
|
311
|
+
);
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { type Static, Type } from "@sinclair/typebox";
|
|
2
|
+
import { request, response } from "../envelope.ts";
|
|
3
|
+
import { InstanceId, Sid, Timestamp } from "../identifiers.ts";
|
|
4
|
+
|
|
5
|
+
/** Ends the OS process behind a session.
|
|
6
|
+
*
|
|
7
|
+
* The request names a session, never a pid: the instance resolves sid to pid
|
|
8
|
+
* itself at the moment it signals, and a pid the caller asserted would be a
|
|
9
|
+
* weaker basis for killing something than that. */
|
|
10
|
+
export const SessionKillArgs = Type.Object({
|
|
11
|
+
sid: Sid,
|
|
12
|
+
/** Escalate to an unconditional kill. The instance never chooses this on its
|
|
13
|
+
* own, because it forfeits the session's chance to flush its transcript; a
|
|
14
|
+
* caller asks for it after watching a graceful attempt go unconfirmed. */
|
|
15
|
+
force: Type.Optional(Type.Boolean()),
|
|
16
|
+
});
|
|
17
|
+
export type SessionKillArgs = Static<typeof SessionKillArgs>;
|
|
18
|
+
|
|
19
|
+
export const SessionKillResult = Type.Object({
|
|
20
|
+
/** The process was seen to be gone before the grace period ran out. False is
|
|
21
|
+
* not a failure: the signal was delivered and the process was still there,
|
|
22
|
+
* which is what a caller needs to know to decide whether to force. */
|
|
23
|
+
terminated: Type.Boolean(),
|
|
24
|
+
});
|
|
25
|
+
export type SessionKillResult = Static<typeof SessionKillResult>;
|
|
26
|
+
|
|
27
|
+
export const SessionKillRequest = request("session_kill", SessionKillArgs);
|
|
28
|
+
export const SessionKillResponse = response("session_kill", SessionKillResult);
|
|
29
|
+
|
|
30
|
+
/** Retitles a running session by typing its own rename command into the
|
|
31
|
+
* terminal it lives in.
|
|
32
|
+
*
|
|
33
|
+
* Nothing can set a session's title from outside, so this drives the terminal
|
|
34
|
+
* multiplexer the session runs under. Success means the keystrokes reached the
|
|
35
|
+
* terminal, not that the session took them — the instance cannot see the TUI's
|
|
36
|
+
* reaction, and the title it settles on arrives later on the `agents` topic. A
|
|
37
|
+
* session whose terminal is unknown is refused rather than guessed at. */
|
|
38
|
+
export const SessionRenameArgs = Type.Object({
|
|
39
|
+
sid: Sid,
|
|
40
|
+
/** The new title. Surrounding whitespace is trimmed and control characters
|
|
41
|
+
* are refused: the value is typed, so a newline in it would submit a
|
|
42
|
+
* half-written command. */
|
|
43
|
+
title: Type.String({ minLength: 1 }),
|
|
44
|
+
});
|
|
45
|
+
export type SessionRenameArgs = Static<typeof SessionRenameArgs>;
|
|
46
|
+
|
|
47
|
+
export const SessionRenameResult = Type.Object({
|
|
48
|
+
/** The terminal handle the keystrokes went to. A host-local handle, so it
|
|
49
|
+
* travels with the instance that owns it. */
|
|
50
|
+
terminal_id: Type.String(),
|
|
51
|
+
instance: InstanceId,
|
|
52
|
+
/** The trimmed title actually typed, which is what a caller should report
|
|
53
|
+
* rather than the draft it sent. */
|
|
54
|
+
title: Type.String(),
|
|
55
|
+
});
|
|
56
|
+
export type SessionRenameResult = Static<typeof SessionRenameResult>;
|
|
57
|
+
|
|
58
|
+
export const SessionRenameRequest = request("session_rename", SessionRenameArgs);
|
|
59
|
+
export const SessionRenameResponse = response("session_rename", SessionRenameResult);
|
|
60
|
+
|
|
61
|
+
/** Reads the environment of a session's own process.
|
|
62
|
+
*
|
|
63
|
+
* The environment comes from the resolved pid, not from the connection the
|
|
64
|
+
* session speaks on: the helper that holds that connection carries a different
|
|
65
|
+
* environment than the session itself. */
|
|
66
|
+
export const SessionEnvReadArgs = Type.Object({ sid: Sid });
|
|
67
|
+
export type SessionEnvReadArgs = Static<typeof SessionEnvReadArgs>;
|
|
68
|
+
|
|
69
|
+
export const SessionEnvReadResult = Type.Object({
|
|
70
|
+
/** The pid actually read, after the same reuse check a kill applies. */
|
|
71
|
+
pid: Type.Integer({ minimum: 1 }),
|
|
72
|
+
instance: InstanceId,
|
|
73
|
+
/** Values verbatim, secrets included. Redaction is the caller's decision, so
|
|
74
|
+
* the contract does not make it for them. */
|
|
75
|
+
env: Type.Record(Type.String(), Type.String()),
|
|
76
|
+
});
|
|
77
|
+
export type SessionEnvReadResult = Static<typeof SessionEnvReadResult>;
|
|
78
|
+
|
|
79
|
+
export const SessionEnvReadRequest = request("session_env_read", SessionEnvReadArgs);
|
|
80
|
+
export const SessionEnvReadResponse = response("session_env_read", SessionEnvReadResult);
|
|
81
|
+
|
|
82
|
+
/** Searches the transcripts of sessions that have run on this instance,
|
|
83
|
+
* including ones long finished. */
|
|
84
|
+
export const SessionSearchArgs = Type.Object({
|
|
85
|
+
/** Newline-separated clauses, ORed. Within a clause, whitespace-separated
|
|
86
|
+
* terms are ANDed across the session's messages. */
|
|
87
|
+
query: Type.Optional(Type.String()),
|
|
88
|
+
case_sensitive: Type.Optional(Type.Boolean()),
|
|
89
|
+
/** Read each clause as a regular expression instead of as literal terms. */
|
|
90
|
+
regex: Type.Optional(Type.Boolean()),
|
|
91
|
+
/** Include what people said. Defaults to true. */
|
|
92
|
+
target_user: Type.Optional(Type.Boolean()),
|
|
93
|
+
/** Include what sessions said. Defaults to true. */
|
|
94
|
+
target_agent: Type.Optional(Type.Boolean()),
|
|
95
|
+
/** Space-separated words matched against the session's working directory. */
|
|
96
|
+
cwd: Type.Optional(Type.String()),
|
|
97
|
+
/** Substring of the session id. */
|
|
98
|
+
sid: Type.Optional(Type.String()),
|
|
99
|
+
/** Restricts the search to these config homes; ones the instance does not
|
|
100
|
+
* know are ignored. */
|
|
101
|
+
config_dirs: Type.Optional(Type.Array(Type.String())),
|
|
102
|
+
/** Only search transcripts touched within this window. */
|
|
103
|
+
modified_within_ms: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
104
|
+
});
|
|
105
|
+
export type SessionSearchArgs = Static<typeof SessionSearchArgs>;
|
|
106
|
+
|
|
107
|
+
export const SessionSearchMatch = Type.Object(
|
|
108
|
+
{
|
|
109
|
+
role: Type.Union([Type.Literal("user"), Type.Literal("agent")]),
|
|
110
|
+
text: Type.String(),
|
|
111
|
+
said_at: Type.Optional(Timestamp),
|
|
112
|
+
},
|
|
113
|
+
{ $id: "SessionSearchMatch" },
|
|
114
|
+
);
|
|
115
|
+
export type SessionSearchMatch = Static<typeof SessionSearchMatch>;
|
|
116
|
+
|
|
117
|
+
/** One session the search matched. Paths and the config home are host values,
|
|
118
|
+
* so the hit names the instance they belong to — a search answered by several
|
|
119
|
+
* instances would otherwise mix two hosts' paths in one list. */
|
|
120
|
+
export const SessionSearchHit = Type.Object(
|
|
121
|
+
{
|
|
122
|
+
sid: Sid,
|
|
123
|
+
instance: InstanceId,
|
|
124
|
+
config_dir: Type.String(),
|
|
125
|
+
/** Absolute path of the transcript, on the answering instance's host. */
|
|
126
|
+
file: Type.String(),
|
|
127
|
+
/** Absent when the transcript never established one. */
|
|
128
|
+
cwd: Type.Optional(Type.String()),
|
|
129
|
+
/** `owner/repo`, when the working directory follows the repo layout. */
|
|
130
|
+
repo: Type.Optional(Type.String()),
|
|
131
|
+
/** Workspace path within the repository. */
|
|
132
|
+
ws: Type.Optional(Type.String()),
|
|
133
|
+
/** The session's own title, when it set one early enough to be read here.
|
|
134
|
+
* Absent means none was found, never that the session is untitled. */
|
|
135
|
+
title: Type.Optional(Type.String()),
|
|
136
|
+
created_at: Timestamp,
|
|
137
|
+
updated_at: Timestamp,
|
|
138
|
+
size: Type.Integer({ minimum: 0 }),
|
|
139
|
+
matches: Type.Array(SessionSearchMatch),
|
|
140
|
+
/** What the session's last turn ran as, in the transcript's own spelling.
|
|
141
|
+
* Resuming it should default to these rather than to anything else, so
|
|
142
|
+
* they are reported unmapped. Independently absent. */
|
|
143
|
+
model: Type.Optional(Type.String()),
|
|
144
|
+
effort: Type.Optional(Type.String()),
|
|
145
|
+
},
|
|
146
|
+
{ $id: "SessionSearchHit" },
|
|
147
|
+
);
|
|
148
|
+
export type SessionSearchHit = Static<typeof SessionSearchHit>;
|
|
149
|
+
|
|
150
|
+
export const SessionSearchResult = Type.Object({
|
|
151
|
+
hits: Type.Array(SessionSearchHit),
|
|
152
|
+
/** The walk stopped at its budget, so the hits are not every match. */
|
|
153
|
+
truncated: Type.Boolean(),
|
|
154
|
+
});
|
|
155
|
+
export type SessionSearchResult = Static<typeof SessionSearchResult>;
|
|
156
|
+
|
|
157
|
+
export const SessionSearchRequest = request("session_search", SessionSearchArgs);
|
|
158
|
+
export const SessionSearchResponse = response("session_search", SessionSearchResult);
|
|
159
|
+
|
|
160
|
+
/** Writes a session's dump to a file on the instance's host and answers with
|
|
161
|
+
* its path.
|
|
162
|
+
*
|
|
163
|
+
* What it adds over reading the transcript is a durable artifact whose path can
|
|
164
|
+
* be handed to a successor session, instead of a payload that would travel out
|
|
165
|
+
* through a client and back in again. The caller never supplies a path — the
|
|
166
|
+
* destination is the instance's own data directory. */
|
|
167
|
+
export const SessionDumpWriteArgs = Type.Object({
|
|
168
|
+
sid: Sid,
|
|
169
|
+
/** Inclusive lower bound in time. */
|
|
170
|
+
since_at: Type.Optional(Timestamp),
|
|
171
|
+
/** Inclusive lower bound as a transcript record id, which cuts at that
|
|
172
|
+
* record's position rather than at its clock — records sharing an instant
|
|
173
|
+
* stay on their own side of the cut. Give one bound or the other, not both. */
|
|
174
|
+
since_uuid: Type.Optional(Type.String()),
|
|
175
|
+
until_at: Type.Optional(Timestamp),
|
|
176
|
+
until_uuid: Type.Optional(Type.String()),
|
|
177
|
+
/** Leave out the assistant's thinking blocks. */
|
|
178
|
+
no_thinking: Type.Optional(Type.Boolean()),
|
|
179
|
+
/** Leave out the machinery of in-process agents. */
|
|
180
|
+
no_agent: Type.Optional(Type.Boolean()),
|
|
181
|
+
});
|
|
182
|
+
export type SessionDumpWriteArgs = Static<typeof SessionDumpWriteArgs>;
|
|
183
|
+
|
|
184
|
+
export const SessionDumpWriteResult = Type.Object({
|
|
185
|
+
/** Absolute path on the writing instance's host. */
|
|
186
|
+
path: Type.String(),
|
|
187
|
+
instance: InstanceId,
|
|
188
|
+
entries: Type.Integer({ minimum: 0 }),
|
|
189
|
+
bytes: Type.Integer({ minimum: 0 }),
|
|
190
|
+
});
|
|
191
|
+
export type SessionDumpWriteResult = Static<typeof SessionDumpWriteResult>;
|
|
192
|
+
|
|
193
|
+
export const SessionDumpWriteRequest = request("session_dump_write", SessionDumpWriteArgs);
|
|
194
|
+
export const SessionDumpWriteResponse = response("session_dump_write", SessionDumpWriteResult);
|
|
195
|
+
|
|
196
|
+
/** Asks where a forked session stopped being a copy of its ancestor.
|
|
197
|
+
*
|
|
198
|
+
* Forking duplicates the ancestor's records keeping each record id, so nothing
|
|
199
|
+
* inside the file marks the seam; finding it means comparing against the
|
|
200
|
+
* sibling transcripts the instance can already enumerate. */
|
|
201
|
+
export const SessionForkOriginArgs = Type.Object({ sid: Sid });
|
|
202
|
+
export type SessionForkOriginArgs = Static<typeof SessionForkOriginArgs>;
|
|
203
|
+
|
|
204
|
+
export const ForkOrigin = Type.Object(
|
|
205
|
+
{
|
|
206
|
+
/** The session the copied records came from. */
|
|
207
|
+
sid: Sid,
|
|
208
|
+
/** Id of the last copied record; the seam sits just after it. */
|
|
209
|
+
boundary_uuid: Type.String(),
|
|
210
|
+
/** How many records were copied. */
|
|
211
|
+
copied: Type.Integer({ minimum: 0 }),
|
|
212
|
+
},
|
|
213
|
+
{ $id: "ForkOrigin" },
|
|
214
|
+
);
|
|
215
|
+
export type ForkOrigin = Static<typeof ForkOrigin>;
|
|
216
|
+
|
|
217
|
+
export const SessionForkOriginResult = Type.Object({
|
|
218
|
+
/** Absent both when the session is no fork and when it is one whose ancestor
|
|
219
|
+
* file is gone. Nothing left on disk tells those two apart, and neither has a
|
|
220
|
+
* seam to place. */
|
|
221
|
+
origin: Type.Optional(ForkOrigin),
|
|
222
|
+
});
|
|
223
|
+
export type SessionForkOriginResult = Static<typeof SessionForkOriginResult>;
|
|
224
|
+
|
|
225
|
+
export const SessionForkOriginRequest = request("session_fork_origin", SessionForkOriginArgs);
|
|
226
|
+
export const SessionForkOriginResponse = response("session_fork_origin", SessionForkOriginResult);
|
|
227
|
+
|
|
228
|
+
/** Drops one entry from the list of sessions that were running when the
|
|
229
|
+
* instance last saw them.
|
|
230
|
+
*
|
|
231
|
+
* The removal touches that list alone: the session stays resumable by every
|
|
232
|
+
* other route, and an instance that later sees it connected records it again.
|
|
233
|
+
* An unknown session is not an error — two clients pressing the same button is
|
|
234
|
+
* the ordinary case, and the caller's goal holds either way. */
|
|
235
|
+
export const SessionLastLiveRemoveArgs = Type.Object({ sid: Sid });
|
|
236
|
+
export type SessionLastLiveRemoveArgs = Static<typeof SessionLastLiveRemoveArgs>;
|
|
237
|
+
|
|
238
|
+
export const SessionLastLiveRemoveResult = Type.Object({
|
|
239
|
+
/** Whether the entry was there to remove. */
|
|
240
|
+
removed: Type.Boolean(),
|
|
241
|
+
});
|
|
242
|
+
export type SessionLastLiveRemoveResult = Static<typeof SessionLastLiveRemoveResult>;
|
|
243
|
+
|
|
244
|
+
export const SessionLastLiveRemoveRequest = request(
|
|
245
|
+
"session_last_live_remove",
|
|
246
|
+
SessionLastLiveRemoveArgs,
|
|
247
|
+
);
|
|
248
|
+
export const SessionLastLiveRemoveResponse = response(
|
|
249
|
+
"session_last_live_remove",
|
|
250
|
+
SessionLastLiveRemoveResult,
|
|
251
|
+
);
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { type Static, Type } from "@sinclair/typebox";
|
|
2
|
+
import { request, response, topicFrame } from "../envelope.ts";
|
|
3
|
+
import { Sid } from "../identifiers.ts";
|
|
4
|
+
|
|
5
|
+
/** Reads a slice of a session's transcript.
|
|
6
|
+
*
|
|
7
|
+
* The caller never supplies a path — the file is the one the session announced,
|
|
8
|
+
* or the one an id below it resolves to — so there is no traversal to defend
|
|
9
|
+
* against. Paging is by byte offset aligned to line boundaries, which is what
|
|
10
|
+
* lets a transcript of any size be read from its end backwards without ever
|
|
11
|
+
* scanning it whole or building an index: read the tail, then ask again for
|
|
12
|
+
* what began before the slice just read.
|
|
13
|
+
*
|
|
14
|
+
* The role decides how much is visible rather than whether the call is allowed:
|
|
15
|
+
* a session reads its own transcript, a person reads any. */
|
|
16
|
+
export const TranscriptReadArgs = Type.Object({
|
|
17
|
+
sid: Sid,
|
|
18
|
+
/** Read lines ending at or before this offset. Absent starts at the end. */
|
|
19
|
+
before: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
20
|
+
/** How much to return; the instance narrows this to its own limit. */
|
|
21
|
+
max_bytes: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
22
|
+
/** Read an agent's transcript instead of the session's own. Validated
|
|
23
|
+
* strictly, since it names a file. Not to be combined with `teammate`. */
|
|
24
|
+
agent_id: Type.Optional(Type.String()),
|
|
25
|
+
/** The workflow run that owns `agent_id`. Absent means the agent hangs
|
|
26
|
+
* directly below the session. Meaningless on its own. */
|
|
27
|
+
run_id: Type.Optional(Type.String()),
|
|
28
|
+
/** Read a teammate's transcript, found by the name it is addressed by — the
|
|
29
|
+
* name it carries in conversation cannot be used as a filename, so it is
|
|
30
|
+
* resolved rather than substituted. Not to be combined with `agent_id`. */
|
|
31
|
+
teammate: Type.Optional(Type.String()),
|
|
32
|
+
});
|
|
33
|
+
export type TranscriptReadArgs = Static<typeof TranscriptReadArgs>;
|
|
34
|
+
|
|
35
|
+
export const TranscriptReadResult = Type.Object({
|
|
36
|
+
sid: Sid,
|
|
37
|
+
/** Whole records as written, oldest first. The reader parses each. */
|
|
38
|
+
lines: Type.Array(Type.String()),
|
|
39
|
+
/** Offset of the first line returned. Pass it back as `before` to page
|
|
40
|
+
* further back; zero means the beginning is included. */
|
|
41
|
+
start: Type.Integer({ minimum: 0 }),
|
|
42
|
+
/** Offset just past the last line returned. */
|
|
43
|
+
end: Type.Integer({ minimum: 0 }),
|
|
44
|
+
/** The transcript's size now. It grows while the session runs, so this is
|
|
45
|
+
* also where a later read of what has since been appended starts. */
|
|
46
|
+
size: Type.Integer({ minimum: 0 }),
|
|
47
|
+
});
|
|
48
|
+
export type TranscriptReadResult = Static<typeof TranscriptReadResult>;
|
|
49
|
+
|
|
50
|
+
export const TranscriptReadRequest = request("transcript_read", TranscriptReadArgs);
|
|
51
|
+
export const TranscriptReadResponse = response("transcript_read", TranscriptReadResult);
|
|
52
|
+
|
|
53
|
+
/** The `transcript:<sid>` topic.
|
|
54
|
+
*
|
|
55
|
+
* The one topic whose frames are not a whole value: a transcript is appended
|
|
56
|
+
* to, and sending it entire on every line would be sending the whole file over
|
|
57
|
+
* and over. The snapshot therefore states only where the file currently ends,
|
|
58
|
+
* and each frame after it carries what was appended, with the offsets that
|
|
59
|
+
* place it. Those offsets are the same ones a read pages by, so a client can
|
|
60
|
+
* stitch what arrives live onto what it read without reading anything twice.
|
|
61
|
+
*
|
|
62
|
+
* Only complete lines are sent; a line still being written waits for its end. */
|
|
63
|
+
export const TranscriptFrame = topicFrame(
|
|
64
|
+
"transcript",
|
|
65
|
+
Type.Union([
|
|
66
|
+
Type.Object({
|
|
67
|
+
sid: Sid,
|
|
68
|
+
/** Where the transcript ends as the subscription begins. What follows
|
|
69
|
+
* starts here. */
|
|
70
|
+
size: Type.Integer({ minimum: 0 }),
|
|
71
|
+
}),
|
|
72
|
+
Type.Object({
|
|
73
|
+
sid: Sid,
|
|
74
|
+
lines: Type.Array(Type.String()),
|
|
75
|
+
start: Type.Integer({ minimum: 0 }),
|
|
76
|
+
end: Type.Integer({ minimum: 0 }),
|
|
77
|
+
size: Type.Integer({ minimum: 0 }),
|
|
78
|
+
}),
|
|
79
|
+
]),
|
|
80
|
+
);
|