@ccmsg/protocol 1.9.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ccmsg/protocol",
3
- "version": "1.9.0",
3
+ "version": "1.11.0",
4
4
  "description": "Wire contract (schema + types + op attribute table) shared by the ccmsg daemon and web UI",
5
5
  "license": "MIT",
6
6
  "author": "kawaz",
package/src/attributes.ts CHANGED
@@ -196,7 +196,7 @@ export const OP_ATTRIBUTES = {
196
196
  errors: [],
197
197
  },
198
198
 
199
- // --- control: session observation and operation (8) ---
199
+ // --- control: session observation and operation (9) ---
200
200
  session_kill: {
201
201
  plane: "control",
202
202
  roles: USER_ONLY,
@@ -233,6 +233,13 @@ export const OP_ATTRIBUTES = {
233
233
  locality: "instance-local",
234
234
  errors: ["not_found"],
235
235
  },
236
+ dump_presets_read: {
237
+ plane: "control",
238
+ roles: AGENT_AND_USER,
239
+ needs_hello: true,
240
+ locality: "instance-local",
241
+ errors: [],
242
+ },
236
243
  transcript_read: {
237
244
  plane: "control",
238
245
  roles: AGENT_AND_USER,
@@ -129,6 +129,21 @@ export const HelloResult = Type.Object({
129
129
  /** The daemon build, for display. */
130
130
  version: Type.String(),
131
131
  started_at: Timestamp,
132
+ /** Where a person opens the terminal a session runs in: the base URL of the
133
+ * gateway that fronts this instance's terminals. A session's terminal names
134
+ * itself in `terminal_id` on the `agents` topic, and the gateway's URL for it
135
+ * is `<terminal_gateway>/sessions/<terminal_id>` — so the base URL carries no
136
+ * trailing slash, the path below it being the gateway's spelling and not this
137
+ * contract's.
138
+ *
139
+ * Stated by the instance because only it knows which gateway stands in front
140
+ * of the machine its sessions run on; a client has no way to derive one from
141
+ * the endpoint it reached. Absent where the instance cannot reach its
142
+ * sessions' terminals at all, which is the same condition that leaves the
143
+ * `terminal` capability out of the set above. */
144
+ terminal_gateway: Type.Optional(
145
+ Type.String({ pattern: "^https?://[^/?#\\s]+(/[^?#\\s]*[^/?#\\s])?$" }),
146
+ ),
132
147
  /** When this connection's authorization runs out, after which the instance
133
148
  * closes it. Present on a connection an access token opened; absent where
134
149
  * reaching the instance is itself the permission (the Unix socket) or where
@@ -0,0 +1,433 @@
1
+ import { type Static, type TSchema, Type } from "@sinclair/typebox";
2
+ import { request, response } from "../envelope.ts";
3
+ import { Timestamp } from "../identifiers.ts";
4
+
5
+ /** The vocabulary a transcript is read in: item types, the shape one item
6
+ * takes, the ledger of ids they carry, and the named selections a person dumps
7
+ * by.
8
+ *
9
+ * A transcript is a harness's own file format, appended to in whatever shape
10
+ * that harness settles on. What travels between us is not that file but the
11
+ * items it was read as, so the classifying — which line is which item — belongs
12
+ * to whoever reads the file, and only the names it classifies into are written
13
+ * down here. That split is what lets a harness change its file, or a second
14
+ * harness be read at all, without the contract moving.
15
+ *
16
+ * A type name is `:`-separated and read left to right, so a prefix names
17
+ * everything below it: `tool` is every tool, `message:user` is both directions
18
+ * of what a person and a session said. Three families stay open, because their
19
+ * last segment is a name someone else coins — a tool, an attachment kind, a
20
+ * hook event — and closing them would turn every newcomer into `unknown`. */
21
+
22
+ /** A type name as written on the wire and in a selection.
23
+ *
24
+ * Segments after the first carry the spelling of whatever named them, which is
25
+ * why they are not held to snake_case: `tool:Bash` and `hook:PreToolUse` are
26
+ * the harness's words, and rewriting them would leave the reader unable to
27
+ * match what it sees against what it ran. */
28
+ export const TranscriptItemType = Type.String({
29
+ pattern: "^[a-z]+(?::[A-Za-z0-9_.-]+)*$",
30
+ $id: "TranscriptItemType",
31
+ });
32
+ export type TranscriptItemType = Static<typeof TranscriptItemType>;
33
+
34
+ /** The type names that are fully spelled out here. The three open families
35
+ * (`tool:<Name>`, `system:attachment:<kind>`, `hook:<Event>`) are not in the
36
+ * list: their last segment is coined elsewhere, and a name absent from this
37
+ * list is a newcomer rather than an error. */
38
+ export const TRANSCRIPT_ITEM_TYPES = [
39
+ "message:user:in",
40
+ "message:user:out",
41
+ "message:sub:in",
42
+ "message:sub:out",
43
+ "message:session:in",
44
+ "message:session:out",
45
+ "thinking",
46
+ "notice:slash",
47
+ "notice:interrupt",
48
+ "system:compact",
49
+ "system:api-error",
50
+ "system:task",
51
+ "system:caveat",
52
+ "system:resume",
53
+ "system:unknown",
54
+ ] as const;
55
+ export type KnownTranscriptItemType = (typeof TRANSCRIPT_ITEM_TYPES)[number];
56
+
57
+ /** One element of a selection: a type name, a prefix of one, either of those
58
+ * negated with `-`, or `@name` standing for a preset expanded in place.
59
+ *
60
+ * Elements apply left to right, so an exclusion reaches whatever a prefix or an
61
+ * expansion before it brought in. */
62
+ export const TranscriptItemSelector = Type.String({
63
+ pattern: "^-?(?:@[A-Za-z0-9][A-Za-z0-9_-]*|[a-z]+(?::[A-Za-z0-9_.-]+)*)$",
64
+ $id: "TranscriptItemSelector",
65
+ });
66
+ export type TranscriptItemSelector = Static<typeof TranscriptItemSelector>;
67
+
68
+ /** What every item carries, whatever its type.
69
+ *
70
+ * `uuid` is the record's own id in the transcript, which is what makes an item
71
+ * addressable: a reader that wants more than the dump kept can go back to the
72
+ * file with it, and the links below point with it rather than with a position,
73
+ * since a selection or a range decides which items exist in a given dump. */
74
+ const BASE_FIELDS = {
75
+ uuid: Type.String({ minLength: 1 }),
76
+ /** The item's own instant. A call and its result each keep their own. */
77
+ at: Timestamp,
78
+ /** Which turn of the session the item fell in, when the reader could place
79
+ * it. Derived from the file and renumbered whenever it is read again, so it
80
+ * is an attribute to show and never a way to cut a range. */
81
+ turn: Type.Optional(Type.Integer({ minimum: 0 })),
82
+ };
83
+
84
+ /** A call and what came back are two items, not one.
85
+ *
86
+ * Some results arrive many turns later — an agent runs, a monitor waits — with
87
+ * other items in between, so folding them into a single item would make the
88
+ * reader decide which of the two instants the fold happens at. Two items each
89
+ * keep their own, linked by uuid, and folding is left to whoever draws them. A
90
+ * `use` without a `result_item` is a call that has not come back, including one
91
+ * whose result falls outside the range asked for. */
92
+ const USE_FIELDS = {
93
+ role: Type.Literal("use"),
94
+ /** The result's uuid, once there is one. */
95
+ result_item: Type.Optional(Type.String({ minLength: 1 })),
96
+ };
97
+
98
+ const RESULT_FIELDS = {
99
+ role: Type.Literal("result"),
100
+ /** The call this answers. Always known: a result exists because a call did. */
101
+ parent_item: Type.String({ minLength: 1 }),
102
+ };
103
+
104
+ function item(type: TSchema, fields: Record<string, TSchema> = {}): TSchema {
105
+ return Type.Object({ ...BASE_FIELDS, type, ...fields });
106
+ }
107
+
108
+ const Text = Type.String();
109
+
110
+ // --- message: who said what to whom, from the subject's side ---
111
+
112
+ /** The subject is a session by default, or one agent below it when the dump
113
+ * names an agent, and `in` / `out` are read from wherever the subject stands.
114
+ * A dump of an agent therefore reads `message:user:in` as the brief its parent
115
+ * gave it, on the same type names a dump of the session uses — which is what
116
+ * lets one preset be carried down a chain of agents. */
117
+ const MessageUserIn = item(Type.Literal("message:user:in"), { text: Text });
118
+ const MessageUserOut = item(Type.Literal("message:user:out"), { text: Text });
119
+
120
+ const MessageSubOut = item(Type.Literal("message:sub:out"), {
121
+ ...USE_FIELDS,
122
+ prompt: Text,
123
+ agent_id: Type.Optional(Type.String()),
124
+ /** The agent definition asked for, in the harness's spelling. */
125
+ subagent_type: Type.Optional(Type.String()),
126
+ name: Type.Optional(Type.String()),
127
+ description: Type.Optional(Type.String()),
128
+ });
129
+
130
+ const MessageSubIn = item(Type.Literal("message:sub:in"), {
131
+ ...RESULT_FIELDS,
132
+ text: Text,
133
+ agent_id: Type.Optional(Type.String()),
134
+ status: Type.Optional(Type.String()),
135
+ duration_ms: Type.Optional(Type.Integer({ minimum: 0 })),
136
+ });
137
+
138
+ const MessageSessionOut = item(Type.Literal("message:session:out"), {
139
+ text: Text,
140
+ /** The addressee as the subject wrote it: a sid, or a name that was resolved
141
+ * to one. Kept unresolved when that is all the transcript says. */
142
+ to: Type.Optional(Type.String()),
143
+ msg_id: Type.Optional(Type.String()),
144
+ reply_to: Type.Optional(Type.String()),
145
+ });
146
+
147
+ const MessageSessionIn = item(Type.Literal("message:session:in"), {
148
+ text: Text,
149
+ from: Type.Optional(Type.String()),
150
+ msg_id: Type.Optional(Type.String()),
151
+ });
152
+
153
+ const Thinking = item(Type.Literal("thinking"), { text: Text });
154
+
155
+ // --- notice: a person operated the harness ---
156
+
157
+ /** Kept apart from `system:*` because these explain a break in the
158
+ * conversation: someone typed a command or stopped a turn. A reader skimming
159
+ * for why the thread jumps needs them, and can skip what the harness injected
160
+ * for its own reasons. */
161
+ const NoticeSlash = item(Type.Literal("notice:slash"), {
162
+ command: Type.String(),
163
+ args: Type.Optional(Type.String()),
164
+ stdout: Type.Optional(Type.String()),
165
+ });
166
+ const NoticeInterrupt = item(Type.Literal("notice:interrupt"), {
167
+ text: Type.Optional(Text),
168
+ });
169
+
170
+ // --- system: the harness talking in someone else's voice ---
171
+
172
+ const SystemCompact = item(Type.Literal("system:compact"), { text: Text });
173
+ const SystemApiError = item(Type.Literal("system:api-error"), { text: Text });
174
+ const SystemTask = item(Type.Literal("system:task"), {
175
+ text: Text,
176
+ /** The background task or monitor the event came from. */
177
+ task_id: Type.Optional(Type.String()),
178
+ event: Type.Optional(Type.String()),
179
+ });
180
+ const SystemCaveat = item(Type.Literal("system:caveat"), { text: Text });
181
+ const SystemResume = item(Type.Literal("system:resume"), { text: Text });
182
+
183
+ /** `system:attachment:<kind>` — the kind is the harness's own word for what it
184
+ * attached, taken through unchanged so an attachment nobody has seen before
185
+ * still arrives under its own name instead of collapsing into `unknown`. */
186
+ const SystemAttachment = item(Type.String({ pattern: "^system:attachment:[A-Za-z0-9_.-]+$" }), {
187
+ attachment: Type.Record(Type.String(), Type.Unknown()),
188
+ });
189
+
190
+ /** What the reader could not place. It is still an item: a line that vanishes
191
+ * silently is the one failure a dump cannot be read around. */
192
+ const SystemUnknown = item(Type.Literal("system:unknown"), {
193
+ record: Type.Record(Type.String(), Type.Unknown()),
194
+ });
195
+
196
+ // --- hook: code the operator installed ---
197
+
198
+ /** `hook:<Event>` — the event alone, never the matcher. The name a hook runs
199
+ * under is `PreToolUse:Bash`, whose `:` would read as a level of the hierarchy
200
+ * and make `hook:PreToolUse` select nothing; the full name is a field instead,
201
+ * and prefix selection keeps meaning what it says. */
202
+ const Hook = item(Type.String({ pattern: "^hook:[A-Za-z0-9_.-]+$" }), {
203
+ /** The hook's full name, matcher included. */
204
+ hook_name: Type.String(),
205
+ outcome: Type.Union([
206
+ Type.Literal("additionalContext"),
207
+ Type.Literal("output"),
208
+ Type.Literal("block"),
209
+ ]),
210
+ content: Type.Optional(Text),
211
+ command: Type.Optional(Type.String()),
212
+ exit_code: Type.Optional(Type.Integer()),
213
+ stderr: Type.Optional(Type.String()),
214
+ duration_ms: Type.Optional(Type.Integer({ minimum: 0 })),
215
+ /** The call the hook fired around, for the events that have one. */
216
+ tool_use_id: Type.Optional(Type.String()),
217
+ });
218
+
219
+ // --- tool: `tool:<Name>`, one item for the call and one for the result ---
220
+
221
+ const ToolType = Type.String({ pattern: "^tool:[A-Za-z0-9_.-]+$" });
222
+
223
+ function toolUse(name: string, fields: Record<string, TSchema>): TSchema {
224
+ return item(Type.Literal(`tool:${name}`), {
225
+ ...USE_FIELDS,
226
+ tool_use_id: Type.String(),
227
+ ...fields,
228
+ });
229
+ }
230
+
231
+ function toolResult(name: string, fields: Record<string, TSchema>): TSchema {
232
+ return item(Type.Literal(`tool:${name}`), {
233
+ ...RESULT_FIELDS,
234
+ tool_use_id: Type.String(),
235
+ ...fields,
236
+ });
237
+ }
238
+
239
+ const OptText = Type.Optional(Text);
240
+ const OptCount = Type.Optional(Type.Integer({ minimum: 0 }));
241
+
242
+ /** A tool nobody wrote fields for still arrives, carrying what it was called
243
+ * with and what it answered. Fields are added to sharpen how a tool reads, not
244
+ * to decide whether it is kept. */
245
+ const ToolUseGeneric = item(ToolType, {
246
+ ...USE_FIELDS,
247
+ tool_use_id: Type.String(),
248
+ input: Type.Record(Type.String(), Type.Unknown()),
249
+ });
250
+ const ToolResultGeneric = item(ToolType, {
251
+ ...RESULT_FIELDS,
252
+ tool_use_id: Type.String(),
253
+ result: Type.Record(Type.String(), Type.Unknown()),
254
+ });
255
+
256
+ const TOOL_ITEMS: TSchema[] = [
257
+ /** No exit code: what the harness records of a shell call is its output and
258
+ * whether it was interrupted, so that is what a reader has to judge by. */
259
+ toolUse("Bash", { command: Type.String(), description: OptText }),
260
+ toolResult("Bash", {
261
+ stdout: OptText,
262
+ stderr: OptText,
263
+ interrupted: Type.Optional(Type.Boolean()),
264
+ }),
265
+ toolUse("Read", {
266
+ file_path: Type.String(),
267
+ offset: Type.Optional(Type.Integer({ minimum: 0 })),
268
+ limit: OptCount,
269
+ }),
270
+ toolResult("Read", { lines: OptCount, bytes: OptCount }),
271
+ toolUse("Write", { file_path: Type.String(), lines: OptCount }),
272
+ toolResult("Write", { ok: Type.Boolean() }),
273
+ /** The bodies are counted rather than carried: an edit's two sides are the
274
+ * file's content, and a dump that inlined them would be the file. */
275
+ toolUse("Edit", {
276
+ file_path: Type.String(),
277
+ old_lines: OptCount,
278
+ new_lines: OptCount,
279
+ }),
280
+ toolResult("Edit", { ok: Type.Boolean() }),
281
+ toolUse("Grep", { pattern: Type.String(), path: OptText }),
282
+ toolResult("Grep", { matches: OptCount }),
283
+ toolUse("Glob", { pattern: Type.String(), path: OptText }),
284
+ toolResult("Glob", { matches: OptCount }),
285
+ toolUse("WebFetch", { url: Type.String(), prompt: OptText }),
286
+ toolResult("WebFetch", { text: OptText }),
287
+ toolUse("WebSearch", { query: Type.String() }),
288
+ toolResult("WebSearch", { results: OptCount }),
289
+ /** The same exchange `message:sub:*` carries, seen from the calling side:
290
+ * this pair states that an agent was started and how it ended, and what it
291
+ * answered stays with the message. */
292
+ toolUse("Agent", {
293
+ prompt: Type.String(),
294
+ name: OptText,
295
+ subagent_type: OptText,
296
+ description: OptText,
297
+ }),
298
+ toolResult("Agent", { agent_id: OptText, status: OptText }),
299
+ toolUse("SendMessage", { to: Type.String(), summary: OptText }),
300
+ toolResult("SendMessage", { msg_id: OptText, routing: OptText }),
301
+ toolUse("Monitor", {
302
+ description: Type.String(),
303
+ command: OptText,
304
+ persistent: Type.Optional(Type.Boolean()),
305
+ timeout_ms: Type.Optional(Type.Integer({ minimum: 0 })),
306
+ }),
307
+ toolResult("Monitor", { task_id: OptText }),
308
+ toolUse("Skill", { skill: Type.String(), args: OptText }),
309
+ toolResult("Skill", {
310
+ agent_id: OptText,
311
+ background: Type.Optional(Type.Boolean()),
312
+ status: OptText,
313
+ }),
314
+ toolUse("TodoWrite", {
315
+ todos: Type.Array(Type.Object({ content: Type.String(), status: Type.String() })),
316
+ }),
317
+ toolResult("TodoWrite", { ok: Type.Boolean() }),
318
+ toolUse("TaskStop", { task_id: Type.String() }),
319
+ toolResult("TaskStop", { ok: Type.Boolean() }),
320
+ toolUse("CronCreate", { cron: Type.String(), prompt: OptText }),
321
+ toolResult("CronCreate", { cron_id: OptText }),
322
+ ];
323
+
324
+ /** One classified item.
325
+ *
326
+ * Items are finer than lines: one assistant record becomes the thinking, the
327
+ * text and each tool call it held. A reader that does not recognise a record
328
+ * still emits one — as a tool it has no fields for, an attachment under its own
329
+ * kind, or `system:unknown` — so nothing in the file goes missing without
330
+ * saying so. */
331
+ export const TranscriptItem = Type.Union(
332
+ [
333
+ MessageUserIn,
334
+ MessageUserOut,
335
+ MessageSubOut,
336
+ MessageSubIn,
337
+ MessageSessionOut,
338
+ MessageSessionIn,
339
+ Thinking,
340
+ NoticeSlash,
341
+ NoticeInterrupt,
342
+ SystemCompact,
343
+ SystemApiError,
344
+ SystemTask,
345
+ SystemCaveat,
346
+ SystemResume,
347
+ SystemAttachment,
348
+ SystemUnknown,
349
+ Hook,
350
+ ...TOOL_ITEMS,
351
+ ToolUseGeneric,
352
+ ToolResultGeneric,
353
+ ],
354
+ { $id: "TranscriptItem" },
355
+ );
356
+ export type TranscriptItem = Static<typeof TranscriptItem>;
357
+
358
+ /** What kind of thing an id names. An id says how to point at something, not
359
+ * what a line is, which is why these are gathered rather than made types of
360
+ * their own — the same agent would otherwise appear once as an item and again
361
+ * as an id. */
362
+ export const DumpIdKind = Type.Union(
363
+ [
364
+ Type.Literal("agent"),
365
+ Type.Literal("task"),
366
+ Type.Literal("tool_use"),
367
+ Type.Literal("msg"),
368
+ Type.Literal("sid"),
369
+ Type.Literal("cron"),
370
+ ],
371
+ { $id: "DumpIdKind" },
372
+ );
373
+ export type DumpIdKind = Static<typeof DumpIdKind>;
374
+
375
+ export const DumpIdEntry = Type.Object(
376
+ {
377
+ kind: DumpIdKind,
378
+ id: Type.String({ minLength: 1 }),
379
+ /** What the id was called where it appeared — an agent's name, a monitor's
380
+ * description, a peer's working directory. */
381
+ label: Type.Optional(Type.String()),
382
+ /** Where it stood when the dump was written, for the ids that end. */
383
+ status: Type.Optional(Type.String()),
384
+ duration_ms: Type.Optional(Type.Integer({ minimum: 0 })),
385
+ },
386
+ { $id: "DumpIdEntry" },
387
+ );
388
+ export type DumpIdEntry = Static<typeof DumpIdEntry>;
389
+
390
+ /** The ids the dumped items carried, gathered once.
391
+ *
392
+ * The ledger is what a reader descends by: an agent that did the thing worth
393
+ * copying is named here, and dumping it is the same request with that id as its
394
+ * subject. It is relative to the subject, so an agent's ledger lists the agents
395
+ * it started and not itself. */
396
+ export const DumpIds = Type.Array(DumpIdEntry, { $id: "DumpIds" });
397
+ export type DumpIds = Static<typeof DumpIds>;
398
+
399
+ /** A selection an operator named and can ask for by name.
400
+ *
401
+ * Presets are configured on the instance rather than fixed here, because what
402
+ * they name is an interest — "how the work was done", "what to hand over" —
403
+ * and an interest is not a property of the wire. A type name stays one to one
404
+ * with what a record is, and the groupings people actually reach for are made
405
+ * by naming a set of them. */
406
+ export const DumpPreset = Type.Object(
407
+ {
408
+ name: Type.String({ minLength: 1 }),
409
+ description: Type.Optional(Type.String()),
410
+ opts: Type.Object({ types: Type.Array(TranscriptItemSelector) }),
411
+ },
412
+ { $id: "DumpPreset" },
413
+ );
414
+ export type DumpPreset = Static<typeof DumpPreset>;
415
+
416
+ /** Reads the presets an instance is configured with.
417
+ *
418
+ * Nothing else states which names a dump may be asked for, so a client with no
419
+ * way to list them could only offer a free-text field and let the instance
420
+ * refuse. A preset that references another is answered as written; the
421
+ * expansion, and the refusal of a cycle or of a name that is not configured,
422
+ * happen where the config is validated. */
423
+ export const DumpPresetsReadArgs = Type.Object({});
424
+ export type DumpPresetsReadArgs = Static<typeof DumpPresetsReadArgs>;
425
+
426
+ export const DumpPresetsReadResult = Type.Object({
427
+ /** In configured order. */
428
+ presets: Type.Array(DumpPreset),
429
+ });
430
+ export type DumpPresetsReadResult = Static<typeof DumpPresetsReadResult>;
431
+
432
+ export const DumpPresetsReadRequest = request("dump_presets_read", DumpPresetsReadArgs);
433
+ export const DumpPresetsReadResponse = response("dump_presets_read", DumpPresetsReadResult);
@@ -1,6 +1,7 @@
1
1
  import { type Static, Type } from "@sinclair/typebox";
2
2
  import { request, response } from "../envelope.ts";
3
3
  import { InstanceId, Sid, Timestamp } from "../identifiers.ts";
4
+ import { DumpIds, TranscriptItemSelector, TranscriptItemType } from "./dump.ts";
4
5
 
5
6
  /** Ends the OS process behind a session.
6
7
  *
@@ -173,6 +174,13 @@ export const SessionSearchResponse = response("session_search", SessionSearchRes
173
174
  * destination is the instance's own data directory. */
174
175
  export const SessionDumpWriteArgs = Type.Object({
175
176
  sid: Sid,
177
+ /** Dump one agent below the session instead of the session itself, naming it
178
+ * as the transcript's own file does. The subject decides what every item
179
+ * type points at, so the same selection reads an agent's work the way it
180
+ * reads the session's. Kept beside `sid` rather than spelled into it: `sid`
181
+ * is validated as a sid, and a single joined string would be neither
182
+ * validated nor parsed anywhere but in the instance. */
183
+ agent_id: Type.Optional(Type.String()),
176
184
  /** Inclusive lower bound in time. */
177
185
  since_at: Type.Optional(Timestamp),
178
186
  /** Inclusive lower bound as a transcript record id, which cuts at that
@@ -181,9 +189,19 @@ export const SessionDumpWriteArgs = Type.Object({
181
189
  since_uuid: Type.Optional(Type.String()),
182
190
  until_at: Type.Optional(Timestamp),
183
191
  until_uuid: Type.Optional(Type.String()),
184
- /** Leave out the assistant's thinking blocks. */
192
+ /** Which item types to keep, applied left to right over the whole range.
193
+ * Absent keeps everything but the attachments. */
194
+ types: Type.Optional(Type.Array(TranscriptItemSelector)),
195
+ /** A preset configured on the instance, used as the ground `types` is then
196
+ * applied over. A name the instance does not have is refused rather than
197
+ * ignored, since a dump silently wider than asked for is the failure a
198
+ * selection exists to prevent. */
199
+ preset: Type.Optional(Type.String({ minLength: 1 })),
200
+ /** Leave out the assistant's thinking blocks. `["-thinking"]` says the same
201
+ * thing in the vocabulary the rest of the selection is written in. */
185
202
  no_thinking: Type.Optional(Type.Boolean()),
186
- /** Leave out the machinery of in-process agents. */
203
+ /** Leave out the machinery of in-process agents, which
204
+ * `["-message:sub", "-tool:Agent"]` also says. */
187
205
  no_agent: Type.Optional(Type.Boolean()),
188
206
  });
189
207
  export type SessionDumpWriteArgs = Static<typeof SessionDumpWriteArgs>;
@@ -192,7 +210,13 @@ export const SessionDumpWriteResult = Type.Object({
192
210
  /** Absolute path on the writing instance's host. */
193
211
  path: Type.String(),
194
212
  instance: InstanceId,
195
- entries: Type.Integer({ minimum: 0 }),
213
+ /** How many items of each type were written, keyed by type name. A single
214
+ * total leaves the caller unable to tell a dump that kept what it asked for
215
+ * from one whose selection matched almost nothing. */
216
+ entries: Type.Record(TranscriptItemType, Type.Integer({ minimum: 0 })),
217
+ /** The ids those items carried, so the next dump — of an agent named here —
218
+ * can be asked for without opening the file. */
219
+ ids: DumpIds,
196
220
  bytes: Type.Integer({ minimum: 0 }),
197
221
  });
198
222
  export type SessionDumpWriteResult = Static<typeof SessionDumpWriteResult>;
@@ -79,6 +79,7 @@ export const HELLO_RESPONSE: Static<typeof HelloResponse> = {
79
79
  { id: other_instance, endpoint: other_endpoint, host: "nuc", reachable: false },
80
80
  ],
81
81
  capabilities: ["fork", "launcher", "terminal"],
82
+ terminal_gateway: "https://mba.example.ts.net/hyoui",
82
83
  version: "0.1.0",
83
84
  started_at: FIXTURE_NOW - 3_600_000,
84
85
  };
@@ -45,6 +45,11 @@ import type {
45
45
  SandboxRevokeRequest,
46
46
  SandboxRevokeResponse,
47
47
  } from "../control/sandbox.ts";
48
+ import type {
49
+ DumpPresetsReadRequest,
50
+ DumpPresetsReadResponse,
51
+ TranscriptItem,
52
+ } from "../control/dump.ts";
48
53
  import type {
49
54
  SessionDumpWriteRequest,
50
55
  SessionDumpWriteResponse,
@@ -149,8 +154,10 @@ export const SESSION_DUMP_WRITE_REQUEST: Static<typeof SessionDumpWriteRequest>
149
154
  request_id,
150
155
  op: "session_dump_write",
151
156
  sid,
157
+ agent_id: "a471372f2",
152
158
  since_at: FIXTURE_NOW - 3_600_000,
153
- no_thinking: true,
159
+ preset: "howto",
160
+ types: ["@file", "-tool:Grep", "thinking"],
154
161
  };
155
162
 
156
163
  export const SESSION_DUMP_WRITE_RESPONSE: Static<typeof SessionDumpWriteResponse> = {
@@ -158,10 +165,104 @@ export const SESSION_DUMP_WRITE_RESPONSE: Static<typeof SessionDumpWriteResponse
158
165
  request_id,
159
166
  path: "/transcripts/6f1a2b3c.dump.md",
160
167
  instance,
161
- entries: 128,
168
+ entries: { thinking: 41, "tool:Bash": 62, "tool:Read": 25 },
169
+ ids: [
170
+ {
171
+ kind: "agent",
172
+ id: "a471372f2",
173
+ label: "dump-kinds-design",
174
+ status: "ok",
175
+ duration_ms: 252_000,
176
+ },
177
+ { kind: "task", id: "b6mmcr0ax", label: "just watch", status: "running" },
178
+ { kind: "sid", id: other_sid, label: "ccmsg-webui/main" },
179
+ ],
162
180
  bytes: 65_536,
163
181
  };
164
182
 
183
+ export const DUMP_PRESETS_READ_REQUEST: Static<typeof DumpPresetsReadRequest> = {
184
+ request_id,
185
+ op: "dump_presets_read",
186
+ };
187
+
188
+ export const DUMP_PRESETS_READ_RESPONSE: Static<typeof DumpPresetsReadResponse> = {
189
+ ok: true,
190
+ request_id,
191
+ presets: [
192
+ {
193
+ name: "file",
194
+ description: "reading, writing and searching, as one interest",
195
+ opts: { types: ["tool:Read", "tool:Write", "tool:Edit", "tool:Glob", "tool:Grep"] },
196
+ },
197
+ {
198
+ name: "howto",
199
+ description: "how the work was done: what was thought, run, read and written",
200
+ opts: { types: ["thinking", "message:user", "message:sub", "tool:Bash", "@file"] },
201
+ },
202
+ ],
203
+ };
204
+
205
+ /** One item of several types, as a reader emits them.
206
+ *
207
+ * A call and its result appear as the two items they are, linked both ways, so
208
+ * an implementation can check that it draws the pair without assuming they are
209
+ * adjacent. */
210
+ export const TRANSCRIPT_ITEMS: Static<typeof TranscriptItem>[] = [
211
+ {
212
+ uuid: "3f9a21c4",
213
+ type: "message:user:in",
214
+ at: FIXTURE_NOW - 3_600_000,
215
+ turn: 1,
216
+ text: "dump のアイテム型を整理して",
217
+ },
218
+ { uuid: "f10b6d43", type: "thinking", at: FIXTURE_NOW - 3_500_000, text: "台帳として分ける" },
219
+ {
220
+ uuid: "07c5e1b8",
221
+ type: "tool:Bash",
222
+ at: FIXTURE_NOW - 3_400_000,
223
+ role: "use",
224
+ tool_use_id: "toolu_01Ne9BDS",
225
+ result_item: "18d6f2c9",
226
+ command: "jq -r '.type' session.jsonl | sort | uniq -c",
227
+ description: "count the record types",
228
+ },
229
+ {
230
+ uuid: "18d6f2c9",
231
+ type: "tool:Bash",
232
+ at: FIXTURE_NOW - 3_399_000,
233
+ role: "result",
234
+ tool_use_id: "toolu_01Ne9BDS",
235
+ parent_item: "07c5e1b8",
236
+ stdout: "1174 assistant\n753 user\n",
237
+ interrupted: false,
238
+ },
239
+ {
240
+ uuid: "b7e41d09",
241
+ type: "message:sub:out",
242
+ at: FIXTURE_NOW - 3_300_000,
243
+ role: "use",
244
+ result_item: "c2d80f16",
245
+ prompt: "docs/design/dump-kinds.md を書き直す",
246
+ agent_id: "a471372f2",
247
+ subagent_type: "opus5-worker-high",
248
+ },
249
+ {
250
+ uuid: "92e6d4f5",
251
+ type: "hook:PreToolUse",
252
+ at: FIXTURE_NOW - 3_200_000,
253
+ hook_name: "PreToolUse:Bash",
254
+ outcome: "additionalContext",
255
+ content: "read コマンドを使うこと",
256
+ tool_use_id: "toolu_01Ne9BDS",
257
+ },
258
+ {
259
+ uuid: "81d5c3e4",
260
+ type: "system:attachment:queued_command",
261
+ at: FIXTURE_NOW - 3_100_000,
262
+ attachment: { type: "queued_command", command: "/pre-clear" },
263
+ },
264
+ ];
265
+
165
266
  export const TRANSCRIPT_READ_REQUEST: Static<typeof TranscriptReadRequest> = {
166
267
  request_id,
167
268
  op: "transcript_read",
@@ -92,6 +92,10 @@ export const OP_FIXTURES = {
92
92
  request: control.SESSION_DUMP_WRITE_REQUEST,
93
93
  response: control.SESSION_DUMP_WRITE_RESPONSE,
94
94
  },
95
+ dump_presets_read: {
96
+ request: control.DUMP_PRESETS_READ_REQUEST,
97
+ response: control.DUMP_PRESETS_READ_RESPONSE,
98
+ },
95
99
  transcript_read: {
96
100
  request: control.TRANSCRIPT_READ_REQUEST,
97
101
  response: control.TRANSCRIPT_READ_RESPONSE,
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ export * from "./common/ping.ts";
5
5
  export * from "./common/shutdown.ts";
6
6
  export * from "./common/topics.ts";
7
7
  export * from "./control/agents.ts";
8
+ export * from "./control/dump.ts";
8
9
  export * from "./control/files.ts";
9
10
  export * from "./control/kv.ts";
10
11
  export * from "./control/launcher.ts";
package/src/schemas.ts CHANGED
@@ -33,6 +33,7 @@ import {
33
33
  TopicUnsubscribeResponse,
34
34
  } from "./common/topics.ts";
35
35
  import { AgentsFrame } from "./control/agents.ts";
36
+ import { DumpPresetsReadRequest, DumpPresetsReadResponse } from "./control/dump.ts";
36
37
  import {
37
38
  DirListRequest,
38
39
  DirListResponse,
@@ -150,6 +151,7 @@ export const OP_SCHEMAS: Record<OpName, OpSchemas> = {
150
151
  session_env_read: { request: SessionEnvReadRequest, response: SessionEnvReadResponse },
151
152
  session_search: { request: SessionSearchRequest, response: SessionSearchResponse },
152
153
  session_dump_write: { request: SessionDumpWriteRequest, response: SessionDumpWriteResponse },
154
+ dump_presets_read: { request: DumpPresetsReadRequest, response: DumpPresetsReadResponse },
153
155
  transcript_read: { request: TranscriptReadRequest, response: TranscriptReadResponse },
154
156
  session_fork_origin: { request: SessionForkOriginRequest, response: SessionForkOriginResponse },
155
157
  session_last_live_remove: {