moonmill 0.1.0 → 0.2.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.
Files changed (2) hide show
  1. package/dist/main.js +2381 -362
  2. package/package.json +2 -2
package/dist/main.js CHANGED
@@ -1,18 +1,18 @@
1
1
  #!/usr/bin/env node
2
- import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
2
+ import { basename, dirname, isAbsolute, join, posix, relative, resolve } from "node:path";
3
3
  import { parseArgs, promisify } from "node:util";
4
4
  import { z } from "zod";
5
- import { chmod, lstat, mkdir, mkdtemp, open, readFile, readdir, readlink, realpath, rename, rm, rmdir, stat, symlink, utimes, writeFile } from "node:fs/promises";
5
+ import { appendFile, chmod, lstat, mkdir, mkdtemp, open, readFile, readdir, readlink, realpath, rename, rm, rmdir, stat, symlink, utimes, writeFile } from "node:fs/promises";
6
6
  import { arch, homedir, hostname, release, tmpdir, userInfo, version } from "node:os";
7
7
  import { createHash, randomUUID } from "node:crypto";
8
8
  import { execFile, spawn } from "node:child_process";
9
9
  import { RuntimeFailureError, SessionNotFoundError } from "@botiverse/oar/browser";
10
- import { promptAndWait, toolResultText, viewOf } from "@botiverse/oar/observe";
10
+ import { eventsOf, promptAndWait, toolResultText, viewOf } from "@botiverse/oar/observe";
11
11
  import { TomlError, parse } from "smol-toml";
12
12
  import { parse as parse$1, stringify } from "yaml";
13
+ import { setTimeout as setTimeout$1 } from "node:timers/promises";
13
14
  import { WebSocket } from "ws";
14
15
  import { once } from "node:events";
15
- import { setTimeout as setTimeout$1 } from "node:timers/promises";
16
16
  //#region ../protocol/src/activity.ts
17
17
  /** How live activity is paced and how long it lasts. */
18
18
  const LIVE_ACTIVITY = {
@@ -110,10 +110,123 @@ function filedId(what) {
110
110
  /** A git commit's id in full: 40 hexadecimal digits (SHA-1), or 64 (SHA-256). */
111
111
  const COMMIT_ID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
112
112
  //#endregion
113
+ //#region ../protocol/src/images.ts
114
+ /** How many images one document may carry, and how large each may be. */
115
+ const IMAGE_LIMITS = {
116
+ /** Images in one document. */
117
+ images: 20,
118
+ /** Bytes of one image. */
119
+ bytes: 10485760,
120
+ /** Characters of what one shows (`alt`). */
121
+ alt: 500
122
+ };
123
+ /** The image types a document may carry. */
124
+ const IMAGE_TYPES = [
125
+ "image/png",
126
+ "image/jpeg",
127
+ "image/gif",
128
+ "image/webp"
129
+ ];
130
+ const imageType = z.enum(IMAGE_TYPES);
131
+ /**
132
+ * An image's key in the image store, under its Owner's prefix: the SHA-256 of its bytes, hex (a
133
+ * Node computes it with `node:crypto`, a page with `crypto.subtle`).
134
+ */
135
+ const imageKey = z.string().regex(/^[0-9a-f]{64}$/, "Not an image's key");
136
+ /** One image a document names: its key in the image store, its type and what it shows. */
137
+ const documentImage = z.object({
138
+ key: imageKey,
139
+ mimeType: imageType,
140
+ /** What it shows, for someone who can't see it. */
141
+ alt: z.string().max(IMAGE_LIMITS.alt).optional()
142
+ });
143
+ /** A document's images, in the order it shows them. */
144
+ const documentImages = z.array(documentImage).max(IMAGE_LIMITS.images);
145
+ /**
146
+ * An image as a document names it without its bytes or a key: its type and what it shows. A
147
+ * conversation's images, which stay on their Node (a Help Request's question and reply), are named
148
+ * so, and a Workspace's file so describes the image in it.
149
+ */
150
+ const imageAbout = documentImage.pick({
151
+ mimeType: true,
152
+ alt: true
153
+ });
154
+ /**
155
+ * A short-lived address in the image store, signed for one image and one request: its URL, and the
156
+ * headers the request must carry as signed. A path (on a Platform keeping images on its own disk)
157
+ * is the Platform's: resolve it against the Platform's address. Send it nothing else, a Node's
158
+ * credential least of all.
159
+ */
160
+ const imageAddress = z.object({
161
+ url: z.string().min(1),
162
+ headers: z.record(z.string(), z.string()).optional()
163
+ });
164
+ /** One image someone means to put into the image store: its key, its type and how large it is. */
165
+ const imageToUpload = z.object({
166
+ key: imageKey,
167
+ mimeType: imageType,
168
+ bytes: z.int().positive().max(IMAGE_LIMITS.bytes)
169
+ });
170
+ /** The images of one document to put into the image store. */
171
+ const imagesToUpload = z.array(imageToUpload).min(1).max(IMAGE_LIMITS.images);
172
+ /**
173
+ * Where to put one image: the address to PUT its bytes to, with the headers it names, or null when
174
+ * the store keeps it already.
175
+ */
176
+ const imageUpload = z.object({
177
+ key: imageKey,
178
+ address: imageAddress.nullable()
179
+ });
180
+ /** One image sent with words, as the head names it: its type by its first bytes, and its size. */
181
+ const sentImage = z.object({
182
+ mimeType: imageType,
183
+ bytes: z.int().positive().max(IMAGE_LIMITS.bytes)
184
+ });
185
+ z.array(sentImage).max(IMAGE_LIMITS.images);
186
+ /** An image's type, from its first bytes; null for anything that isn't one of `IMAGE_TYPES`. */
187
+ function imageTypeOf(bytes) {
188
+ if (holds$1(bytes, 0, [
189
+ 137,
190
+ 80,
191
+ 78,
192
+ 71,
193
+ 13,
194
+ 10,
195
+ 26,
196
+ 10
197
+ ])) return "image/png";
198
+ if (holds$1(bytes, 0, [
199
+ 255,
200
+ 216,
201
+ 255
202
+ ])) return "image/jpeg";
203
+ if (holds$1(bytes, 0, "GIF8")) return "image/gif";
204
+ if (holds$1(bytes, 0, "RIFF") && holds$1(bytes, 8, "WEBP")) return "image/webp";
205
+ return null;
206
+ }
207
+ /** Whether `bytes` hold `expected` from `at`: these bytes, or this ASCII text's. */
208
+ function holds$1(bytes, at, expected) {
209
+ for (let index = 0; index < expected.length; index++) {
210
+ const byte = typeof expected === "string" ? expected.charCodeAt(index) : expected[index];
211
+ if (bytes[at + index] !== byte) return false;
212
+ }
213
+ return true;
214
+ }
215
+ //#endregion
113
216
  //#region ../protocol/src/runs.ts
114
217
  /** A Workpiece's brief: what the Owner asks for when they dispatch it. */
115
218
  const brief = z.string().trim().min(1).max(2e4);
116
- z.enum([
219
+ /**
220
+ * Where a Workstation Task is. `queued` until its Agent's Node starts it, `running` while the Agent
221
+ * works, `handed-off` once the Agent delivered its Hand-off, `being-checked` while the Inspector of
222
+ * the Belt it goes onto judges it against the Belt's criteria (ADR-0014), `accepted` once the
223
+ * Hand-off moved on (onto a Belt without criteria or at the end of the Pipeline at once, once it
224
+ * passed its check, or once the Owner accepted it anyway), `rejected` when it failed its check
225
+ * (until the Owner accepts it anyway, if they do). `failed` and `timed-out` end a Task that
226
+ * can't finish. `suspended` while its Agent waits for the Owner's reply to a Help Request, its
227
+ * Session kept on its Node to resume with the reply.
228
+ */
229
+ const WORKSTATION_TASK_STATUSES = [
117
230
  "queued",
118
231
  "running",
119
232
  "suspended",
@@ -123,7 +236,8 @@ z.enum([
123
236
  "accepted",
124
237
  "failed",
125
238
  "timed-out"
126
- ]);
239
+ ];
240
+ z.enum(WORKSTATION_TASK_STATUSES);
127
241
  /** How a Workstation Task ends without a Hand-off. */
128
242
  const TASK_FAILURE_STATUSES = ["failed", "timed-out"];
129
243
  /**
@@ -186,31 +300,15 @@ function shortOutcome(outcome) {
186
300
  }
187
301
  /**
188
302
  * What keeps a Hand-off a document rather than a carrier of materials (ADR-0008): some text, a few
189
- * small images, and references to everything else. Together they stay well inside one message of
190
- * the Node link.
303
+ * images (as many and as large as any document's: `IMAGE_LIMITS`, kept in the image store and named
304
+ * by key: images.ts), and references to everything else. Together they stay well inside one
305
+ * message of the Node link.
191
306
  */
192
307
  const HAND_OFF_LIMITS = {
193
308
  /** Characters of Markdown. */
194
309
  markdown: 1e5,
195
- images: 4,
196
- /** Bytes of one image, before base64. */
197
- imageBytes: 524288,
198
310
  references: 20
199
311
  };
200
- /** The image types a Hand-off may carry. */
201
- const HAND_OFF_IMAGE_TYPES = [
202
- "image/png",
203
- "image/jpeg",
204
- "image/gif",
205
- "image/webp"
206
- ];
207
- const handOffImage = z.object({
208
- mimeType: z.enum(HAND_OFF_IMAGE_TYPES),
209
- /** The image, base64. */
210
- data: z.base64().max(Math.ceil(HAND_OFF_LIMITS.imageBytes / 3) * 4),
211
- /** What it shows, for someone who can't see it. */
212
- alt: z.string().max(500).optional()
213
- });
214
312
  /** Material the Hand-off points at, where it lives: a label, and a URL or a pinned identifier. */
215
313
  const handOffReference = z.object({
216
314
  label: z.string().trim().min(1).max(200),
@@ -219,25 +317,35 @@ const handOffReference = z.object({
219
317
  /** The document an Agent delivers at the end of its Workstation Task (ADR-0008). */
220
318
  const handOff = z.object({
221
319
  markdown: z.string().trim().min(1).max(HAND_OFF_LIMITS.markdown),
222
- images: z.array(handOffImage).max(HAND_OFF_LIMITS.images),
320
+ images: documentImages,
223
321
  references: z.array(handOffReference).max(HAND_OFF_LIMITS.references)
224
322
  });
225
- z.enum([
323
+ /**
324
+ * Where a check of a Hand-off is (ADR-0014): `queued` until its Inspector's Node starts it,
325
+ * `running` while the Inspector judges, then ended: `passed`, `failed` (with annotations), or
326
+ * `no-judgement` when its Session ended without one (it crashed, timed out, lost its Node, or
327
+ * never called the check tool).
328
+ */
329
+ const CHECK_STATUSES = [
226
330
  "queued",
227
331
  "running",
228
332
  "passed",
229
333
  "failed",
230
334
  "no-judgement"
231
- ]);
335
+ ];
336
+ z.enum(CHECK_STATUSES);
232
337
  /** An Inspector's annotations: Markdown, bounded like a Hand-off's text. */
233
338
  const annotations = z.string().trim().min(1).max(HAND_OFF_LIMITS.markdown);
234
339
  /**
235
340
  * An Inspector's judgement of a Hand-off against its Belt's criteria: passed, or failed with
236
- * annotations saying which criteria weren't met and why.
341
+ * annotations saying which criteria weren't met and why, and the images that show what is wrong
342
+ * beside them (#184), named in the image store as a Hand-off's are (images.ts). A passing one
343
+ * carries neither. (A judgement from protocol 24 or before, in an outbox or the ledger, has none.)
237
344
  */
238
345
  const judgement = z.discriminatedUnion("outcome", [z.object({ outcome: z.literal("passed") }), z.object({
239
346
  outcome: z.literal("failed"),
240
- annotations
347
+ annotations,
348
+ images: documentImages.default([])
241
349
  })]);
242
350
  /**
243
351
  * Where a Workpiece holds on a paused line, and why (spec "Pausing a line"): each step that would
@@ -288,7 +396,17 @@ const WORKSPACE_FOLDERS = {
288
396
  /** An Inspector's Workspace's Hand-off under check. */
289
397
  underCheck: "hand-off",
290
398
  /** A Rework's Workspace's Hand-off of the attempt it follows. */
291
- previousAttempt: "previous-attempt"
399
+ previousAttempt: "previous-attempt",
400
+ /**
401
+ * A work Session's Help Requests (#185): the images each question asked with, copied as they were
402
+ * then, one folder per question.
403
+ */
404
+ helpRequest: "help-request",
405
+ /**
406
+ * A chat's Workspace (#187): the images each of the Owner's messages came with, one folder per
407
+ * turn.
408
+ */
409
+ chatTurns: "chat-turns"
292
410
  };
293
411
  /**
294
412
  * Every name at a Workspace's root that is the Node's own: its folders and the instructions files
@@ -450,6 +568,8 @@ z.object({
450
568
  outcome: sessionOutcome.nullable(),
451
569
  usage: tokenUsage.nullable()
452
570
  });
571
+ /** What a chat turn's prompt calls the images the Owner's message came with: "This message's…". */
572
+ const CHAT_MESSAGE_IMAGES = "this message";
453
573
  /**
454
574
  * What the Owner calls an Agent, a Factory, a Workshop, a Pipeline or a Workstation, and the name
455
575
  * they give themselves (the Owner account's, renamed in the Account panel).
@@ -461,7 +581,7 @@ const definitionName = z.string().trim().min(1).max(100);
461
581
  * at its back-left corner's from its Factory's, and a Pipeline at its Intake's from its Workshop's
462
582
  * back-left corner (ADR-0015). Nothing stores a size: each is as big as what it holds needs.
463
583
  */
464
- const spot = z.object({
584
+ const spot$1 = z.object({
465
585
  x: z.number(),
466
586
  z: z.number()
467
587
  });
@@ -510,6 +630,22 @@ const attemptBudget = z.number().int().min(1).max({
510
630
  human: 1
511
631
  }.max);
512
632
  /**
633
+ * How long an Agent Workstation's Task may be at work in one segment of its Session before it
634
+ * times out, in minutes (#174): counted from the Task's start, and afresh from each resume after
635
+ * the Owner's reply to a Help Request, a suspended Task never timing out. Set per Workstation by
636
+ * the Owner, within bounds: less than `min` would stop every Task at once, more than `max` leave a
637
+ * stuck one running for days unnoticed. A Human Workstation has none to set (the Owner's audit
638
+ * never times out): it holds the default, unused.
639
+ */
640
+ const TIME_LIMIT = {
641
+ /** What a new Agent Workstation gets, and what every one had before it could be set. */
642
+ default: 60,
643
+ min: 5,
644
+ /** 72 hours. */
645
+ max: 4320
646
+ };
647
+ const timeLimitMinutes = z.number().int().min(TIME_LIMIT.min).max(TIME_LIMIT.max);
648
+ /**
513
649
  * Whether an Agent can run on this Runtime: installed and signed in, as its Node last reported. A
514
650
  * sign-in the Node couldn't confirm counts as not signed in.
515
651
  */
@@ -1029,6 +1165,8 @@ const RECORD_LIMITS = {
1029
1165
  tool: 200,
1030
1166
  /** Characters of why a turn failed. */
1031
1167
  reason: 2e3,
1168
+ /** Images one prompt or tool call names: any past these go unnamed. */
1169
+ images: 50,
1032
1170
  /** Items in one part of the answer. */
1033
1171
  itemsPerPart: 200,
1034
1172
  /** Items in a whole record: what follows is left out, and the record says how many. */
@@ -1044,6 +1182,20 @@ function recordText(max) {
1044
1182
  omitted: z.number().int().nonnegative()
1045
1183
  });
1046
1184
  }
1185
+ /** The most images one segment of a record numbers: any past these go unnamed. */
1186
+ const MAX_SEGMENT_IMAGES = RECORD_LIMITS.items * RECORD_LIMITS.images;
1187
+ /**
1188
+ * An image a record names (#188): one a prompt carried, a file in the Session's Workspace, or one a
1189
+ * tool call returned, kept in the record itself. Named by where it is, never by a path: in segment
1190
+ * `segment`, the `index`th image (0, 1, 2…) of those its prompts carried and its tools returned, in
1191
+ * the order they came.
1192
+ */
1193
+ const recordImage = z.object({
1194
+ segment: z.number().int().positive(),
1195
+ index: z.number().int().nonnegative().max(MAX_SEGMENT_IMAGES - 1)
1196
+ });
1197
+ /** The images a prompt or a tool call names, if any: absent when none. */
1198
+ const recordImages = z.array(recordImage).min(1).max(RECORD_LIMITS.images).optional();
1047
1199
  /**
1048
1200
  * How a tool call stands in the record: it returned (`ok`) or failed, it was still `running`
1049
1201
  * when the record ends, or it `ended` without a result its Runtime reported (its turn or Session
@@ -1125,7 +1277,8 @@ const recordItem = z.discriminatedUnion("kind", [
1125
1277
  }),
1126
1278
  z.object({
1127
1279
  kind: z.literal("prompt"),
1128
- text: recordText(RECORD_LIMITS.prompt)
1280
+ text: recordText(RECORD_LIMITS.prompt),
1281
+ images: recordImages
1129
1282
  }),
1130
1283
  z.object({
1131
1284
  kind: z.literal("text"),
@@ -1141,7 +1294,8 @@ const recordItem = z.discriminatedUnion("kind", [
1141
1294
  tool: z.string().min(1).max(RECORD_LIMITS.tool),
1142
1295
  input: recordText(RECORD_LIMITS.toolInput).nullable(),
1143
1296
  result: z.enum(RECORD_TOOL_RESULTS),
1144
- output: recordText(RECORD_LIMITS.toolOutput).nullable()
1297
+ output: recordText(RECORD_LIMITS.toolOutput).nullable(),
1298
+ images: recordImages
1145
1299
  }),
1146
1300
  z.object({
1147
1301
  kind: z.literal("turn-ended"),
@@ -1175,30 +1329,49 @@ const verdictText = z.string().trim().min(1).max(2e4);
1175
1329
  */
1176
1330
  const helpQuestion = z.string().trim().min(1).max(2e4);
1177
1331
  /**
1332
+ * The images a Verdict's guidance or note carries beside its words (spec #172, stories 8 and 9),
1333
+ * each named by its key in the image store, where the Owner's page put it first (ADR-0025). They
1334
+ * travel with the Workpiece as the words do. Absent for a Verdict given without images, and for
1335
+ * every one given before Verdicts carried them.
1336
+ */
1337
+ const verdictImages = documentImages.optional();
1338
+ /**
1178
1339
  * The Verdicts the Owner rules with (spec stories 46, 48, 50): Approve (onward, with an optional
1179
1340
  * note), Send back (to an earlier Agent Workstation, with guidance), Rework with guidance (a fresh
1180
1341
  * round at the Ticket's Workstation), Accept anyway (the latest Hand-off moves on unchecked), Reply
1181
1342
  * & resume (a Help Request's answer), and Scrap (the Workpiece ends). Which ones a Ticket takes
1182
- * depends on its category (`VERDICTS`).
1343
+ * depends on its category (`VERDICTS`). A Send back's or a Rework's guidance, and an Approve's note,
1344
+ * may carry images beside their words (`imagesOf`), never instead of them: an Approve with images
1345
+ * has a note (the Platform's Verdict rule refuses one without).
1183
1346
  */
1184
1347
  const verdict = z.discriminatedUnion("kind", [
1185
1348
  z.object({
1186
1349
  kind: z.literal("approve"),
1187
- note: verdictText.optional()
1350
+ note: verdictText.optional(),
1351
+ images: verdictImages
1188
1352
  }),
1189
1353
  z.object({
1190
1354
  kind: z.literal("send-back"),
1191
1355
  workstationId: z.string().min(1),
1192
- guidance: verdictText
1356
+ guidance: verdictText,
1357
+ images: verdictImages
1193
1358
  }),
1194
1359
  z.object({
1195
1360
  kind: z.literal("rework"),
1196
- guidance: verdictText
1361
+ guidance: verdictText,
1362
+ images: verdictImages
1197
1363
  }),
1198
1364
  z.object({ kind: z.literal("accept-anyway") }),
1199
1365
  z.object({
1200
1366
  kind: z.literal("reply"),
1201
- reply: verdictText
1367
+ reply: verdictText,
1368
+ /**
1369
+ * The images the reply came with (#186), named without their bytes: they went to the Node of
1370
+ * the Task it resumes, into its Session's Workspace, and nowhere else (ADR-0025). The Agents
1371
+ * after it are told how many there were. Absent from a reply in words alone, and from every
1372
+ * reply given before images were.
1373
+ */
1374
+ images: z.array(imageAbout).min(1).max(IMAGE_LIMITS.images).optional()
1202
1375
  }),
1203
1376
  z.object({ kind: z.literal("scrap") })
1204
1377
  ]);
@@ -1212,6 +1385,16 @@ const VERDICT_NAMES = {
1212
1385
  scrap: "Scrap"
1213
1386
  };
1214
1387
  /**
1388
+ * Where a Verdict sends the Workpiece, as its stamp's ink shows it: `onward`, `back` for Rework,
1389
+ * or to its `end` (Scrap). The Desk's stamps, the courier robot's paper and a Showroom's moments
1390
+ * are inked so.
1391
+ */
1392
+ const VERDICT_INK_KINDS = [
1393
+ "onward",
1394
+ "back",
1395
+ "end"
1396
+ ];
1397
+ /**
1215
1398
  * What the Owner wrote into a Verdict (spec story 54): the guidance of a Send back or a Rework with
1216
1399
  * guidance, an Approve's note, a Help Request's reply. Null for a Verdict that carries none.
1217
1400
  */
@@ -1229,6 +1412,20 @@ function wordsOf(given) {
1229
1412
  }
1230
1413
  return words;
1231
1414
  }
1415
+ /**
1416
+ * The images the Owner put beside the words of a Verdict (spec #172): its guidance's, or its note's,
1417
+ * in the image store. None for a Verdict that carries none, nor for a reply, whose images went to
1418
+ * its Node alone and are named without a key (#186).
1419
+ */
1420
+ function imagesOf(given) {
1421
+ let images = [];
1422
+ switch (given.kind) {
1423
+ case "send-back":
1424
+ case "rework":
1425
+ case "approve": images = given.images ?? [];
1426
+ }
1427
+ return images;
1428
+ }
1232
1429
  /** A Ticket's title: what it is about, in a line. */
1233
1430
  const ticketTitle = z.string().min(1).max(200);
1234
1431
  /** How the Owner sees a Ticket's number: T-1, T-2… per Owner. */
@@ -1344,15 +1541,55 @@ const NODE_TASKS_PATH = "/api/nodes/tasks";
1344
1541
  function nodeHandOffPath(taskId, handOffId) {
1345
1542
  return `${NODE_TASKS_PATH}/${encodeURIComponent(taskId)}/hand-offs/${encodeURIComponent(handOffId)}`;
1346
1543
  }
1347
- /** Where the Node fetches image `index` (0, 1, 2…) of that Hand-off, as its bytes: GET. */
1348
- function nodeHandOffImagePath(taskId, handOffId, index) {
1349
- return `${nodeHandOffPath(taskId, handOffId)}/images/${index}`;
1350
- }
1351
- /** A Hand-off as the Node fetches it: its document, with each image's bytes fetched on its own. */
1544
+ /** A document's image as a Node fetches it: with an address in the image store to read it at. */
1545
+ const addressedImage = documentImage.extend({ address: imageAddress });
1546
+ /**
1547
+ * A Hand-off as the Node fetches it: its document, each image with an address signed for the Task
1548
+ * that fetched it, valid a while, to read its bytes from the image store at (never with the Node's
1549
+ * credential).
1550
+ */
1352
1551
  const fetchedHandOff = handOff.extend({
1353
1552
  id: z.string().min(1),
1354
- images: z.array(handOffImage.omit({ data: true })).max(HAND_OFF_LIMITS.images)
1553
+ images: z.array(addressedImage).max(IMAGE_LIMITS.images)
1554
+ });
1555
+ /**
1556
+ * Where the Node fetches addresses to read the images of the brief of the Workpiece the Task
1557
+ * `taskId` it holds (or a check of it) works on: GET, answered `fetchedBrief`.
1558
+ */
1559
+ function nodeBriefPath(taskId) {
1560
+ return `${NODE_TASKS_PATH}/${encodeURIComponent(taskId)}/brief`;
1561
+ }
1562
+ /**
1563
+ * A brief's images as the Node fetches them: each with an address signed for the Task that
1564
+ * fetched it, valid a while, to read its bytes from the image store at (never with the Node's
1565
+ * credential). Its words are in the message starting the Session.
1566
+ */
1567
+ const fetchedBrief = z.object({ images: z.array(addressedImage).max(IMAGE_LIMITS.images) });
1568
+ /**
1569
+ * Where the Node fetches the annotations of check `checkId`, which failed the attempt the Task
1570
+ * `taskId` it holds reworks (`previousAttempt`), with the images beside them (#184): GET.
1571
+ */
1572
+ function nodeAnnotationsPath(taskId, checkId) {
1573
+ return `${NODE_TASKS_PATH}/${encodeURIComponent(taskId)}/checks/${encodeURIComponent(checkId)}/annotations`;
1574
+ }
1575
+ /**
1576
+ * A failed check's annotations as the Node fetches them: their Markdown, and each image beside them
1577
+ * with an address signed for the Task that fetched it, as a Hand-off's are.
1578
+ */
1579
+ const fetchedAnnotations = z.object({
1580
+ annotations,
1581
+ images: z.array(addressedImage).max(IMAGE_LIMITS.images)
1355
1582
  });
1583
+ /**
1584
+ * Where a Node asks where to put the images a message of its will name (a Hand-off's, a failed
1585
+ * judgement's), before it sends it: POST `nodeImageUploads`, answered `nodeImageUploadAnswer`, or
1586
+ * a `fetchRefusal`. It puts each image at its address itself, then sends the message, which the
1587
+ * Platform takes only once each image it names is in the image store (images.ts).
1588
+ */
1589
+ const NODE_IMAGES_PATH = "/api/nodes/images";
1590
+ z.object({ images: imagesToUpload });
1591
+ /** Where to put each, in the order asked: null for one the image store keeps already. */
1592
+ const nodeImageUploadAnswer = z.object({ uploads: z.array(imageUpload) });
1356
1593
  /** What the Platform answers a fetch it refuses. */
1357
1594
  const fetchRefusal = z.object({ error: z.string() });
1358
1595
  const messageId = z.string().min(1).max(128);
@@ -1378,7 +1615,7 @@ const ackMessage = z.object({
1378
1615
  outcome: ackOutcome,
1379
1616
  reason: z.string().max(500).optional()
1380
1617
  });
1381
- /** How long the Platform waits for a Node's whole answer, by default and at most. */
1618
+ /** How long the Platform waits for each part of a Node's answer, by default and at most. */
1382
1619
  const NODE_REQUEST_TIMEOUT_MS = {
1383
1620
  default: 2e4,
1384
1621
  max: 12e4
@@ -1388,6 +1625,36 @@ const MAX_ANSWER_PARTS = RECORD_LIMITS.items;
1388
1625
  /** A Task's or a check's id as a Node files its Session under it: never a path. */
1389
1626
  const sessionFiledId = filedId("an id");
1390
1627
  /**
1628
+ * The most bytes of an image one part of an answer carries: half a MiB of base64, so that a slow
1629
+ * link still hears from the Node well within its heartbeat timeout while an image goes.
1630
+ */
1631
+ const IMAGE_PART_BYTES = 393216;
1632
+ /** One part of an image's bytes, base64: at most `IMAGE_PART_BYTES` of them. */
1633
+ const imagePartData = z.base64().min(1).max(IMAGE_PART_BYTES / 3 * 4);
1634
+ /**
1635
+ * What images the Platform hands a Node are for: a reply to the Help Request of Task `taskId`,
1636
+ * whose Session the Node keeps waiting for it (#186); or the Owner's message in turn `turn` of chat
1637
+ * `chatId` (#187).
1638
+ */
1639
+ const handedTo = z.discriminatedUnion("of", [z.object({
1640
+ of: z.literal("reply"),
1641
+ taskId: sessionFiledId
1642
+ }), z.object({
1643
+ of: z.literal("chat"),
1644
+ chatId,
1645
+ turn: chatTurn
1646
+ })]);
1647
+ /** One handing-over of images to a Node, by the id the Platform gives it: never a path. */
1648
+ const handingId = filedId("a handing-over's id");
1649
+ /**
1650
+ * The images a message names that the Platform handed its Node before it (`handed-image`): the
1651
+ * handing-over's id, and each image in the order handed, without its bytes.
1652
+ */
1653
+ const handedImages = z.object({
1654
+ handing: handingId,
1655
+ images: z.array(imageAbout).min(1).max(IMAGE_LIMITS.images)
1656
+ });
1657
+ /**
1391
1658
  * A Session the Node ran, by the ids it files its record under (`sessions/<Task id>/`, a check's
1392
1659
  * in `check-<check id>/` there, a chat's in `sessions/chats/<chat id>/`, a Millwright's in
1393
1660
  * `sessions/millwright/<its id>/`), never by a path.
@@ -1411,6 +1678,37 @@ const recordedSession = z.discriminatedUnion("role", [
1411
1678
  sessionId: millwrightSessionId
1412
1679
  })
1413
1680
  ]);
1681
+ /** Which image of a document a Node keeps it is: 0, 1, 2… */
1682
+ const keptIndex = z.number().int().nonnegative().max(IMAGE_LIMITS.images - 1);
1683
+ /**
1684
+ * An image a Node keeps (ADR-0025), by the ids the Node filed it under, never a path:
1685
+ * - image `index` (0, 1, 2…) of a Help Request's question (#185), filed under its Task's id and its
1686
+ * `help-request` message's id, in its Task's Workspace;
1687
+ * - image `index` of a Help Request's reply (#186), filed under its Task's id and the handing-over
1688
+ * that brought it (`handing`), beside the question in its Task's Workspace;
1689
+ * - an image a Session's record names (#188), by its Session and where it is in the record
1690
+ * (`recordImage`): one a prompt carried, in the Session's Workspace, or one a tool returned, in
1691
+ * the record itself.
1692
+ */
1693
+ const keptImage = z.discriminatedUnion("of", [
1694
+ z.object({
1695
+ of: z.literal("help-request"),
1696
+ taskId: sessionFiledId,
1697
+ question: filedId("a question's id"),
1698
+ index: keptIndex
1699
+ }),
1700
+ z.object({
1701
+ of: z.literal("help-reply"),
1702
+ taskId: sessionFiledId,
1703
+ handing: handingId,
1704
+ index: keptIndex
1705
+ }),
1706
+ z.object({
1707
+ of: z.literal("session-record"),
1708
+ session: recordedSession,
1709
+ ...recordImage.shape
1710
+ })
1711
+ ]);
1414
1712
  /** What the Platform may ask a Node. */
1415
1713
  const nodeAsk = z.discriminatedUnion("about", [
1416
1714
  z.object({
@@ -1425,6 +1723,20 @@ const nodeAsk = z.discriminatedUnion("about", [
1425
1723
  about: z.literal("own-setup"),
1426
1724
  runtime: runtimeId,
1427
1725
  of: z.enum(["agent", "millwright"]).default("agent")
1726
+ }),
1727
+ z.object({
1728
+ about: z.literal("image"),
1729
+ image: keptImage
1730
+ }),
1731
+ z.object({
1732
+ about: z.literal("handed-image"),
1733
+ to: handedTo,
1734
+ handing: handingId,
1735
+ index: keptIndex,
1736
+ at: z.number().int().nonnegative().max(IMAGE_LIMITS.bytes - 1),
1737
+ last: z.boolean(),
1738
+ mimeType: imageType,
1739
+ data: imagePartData
1428
1740
  })
1429
1741
  ]);
1430
1742
  /** One part of what a Node answers. */
@@ -1441,6 +1753,12 @@ const nodeAnswer = z.discriminatedUnion("kind", [
1441
1753
  kind: z.literal("own-setup"),
1442
1754
  setup: ownSetup
1443
1755
  }),
1756
+ z.object({
1757
+ kind: z.literal("image"),
1758
+ mimeType: imageType,
1759
+ data: imagePartData
1760
+ }),
1761
+ z.object({ kind: z.literal("handed-image") }),
1444
1762
  z.object({
1445
1763
  kind: z.literal("refused"),
1446
1764
  reason: z.string().min(1).max(500)
@@ -1448,11 +1766,15 @@ const nodeAnswer = z.discriminatedUnion("kind", [
1448
1766
  z.object({
1449
1767
  kind: z.literal("failed"),
1450
1768
  reason: z.string().min(1).max(2e3)
1769
+ }),
1770
+ z.object({
1771
+ kind: z.literal("gone"),
1772
+ reason: z.string().min(1).max(500)
1451
1773
  })
1452
1774
  ]);
1453
1775
  /**
1454
- * The Platform asks the Node `ask`, and waits `timeoutMs` for the whole answer, after which the Node
1455
- * needn't answer any more. Never kept, replayed or acknowledged.
1776
+ * The Platform asks the Node `ask`, and waits `timeoutMs` for each part of the answer, after which
1777
+ * the Node needn't answer any more. Never kept, replayed or acknowledged.
1456
1778
  */
1457
1779
  const requestMessage = z.object({
1458
1780
  kind: z.literal("request"),
@@ -1462,7 +1784,7 @@ const requestMessage = z.object({
1462
1784
  });
1463
1785
  /**
1464
1786
  * Part `part` (0, 1, 2…) of the Node's answer to the request with id `request`; `last` once it is
1465
- * the whole of it. A `refused` or `failed` part is always the last. Never kept, replayed or
1787
+ * the whole of it. A `refused`, `failed` or `gone` part is always the last. Never kept, replayed or
1466
1788
  * acknowledged.
1467
1789
  */
1468
1790
  const answerMessage = z.object({
@@ -1551,7 +1873,13 @@ const helpRequestMessage = z.object({
1551
1873
  kind: z.literal("help-request"),
1552
1874
  ...reliableEnvelope,
1553
1875
  taskId: taskId$1,
1554
- question: helpQuestion
1876
+ question: helpQuestion,
1877
+ /**
1878
+ * The images the Agent asked with (#185), in its order, named without their bytes: the Node
1879
+ * keeps copies of them in the Task's Workspace, filed under this message's `id`, which the
1880
+ * Platform asks for each by (`keptImage`).
1881
+ */
1882
+ images: z.array(imageAbout).max(IMAGE_LIMITS.images).default([])
1555
1883
  });
1556
1884
  /**
1557
1885
  * A Session of the Workstation Task ended (its work, or a check): how, and the tokens it used, or
@@ -1703,6 +2031,15 @@ const nodeMessage = z.discriminatedUnion("kind", [
1703
2031
  answerMessage,
1704
2032
  ackMessage
1705
2033
  ]);
2034
+ /**
2035
+ * The images in the image store a Node's message names (a Hand-off's, a failed judgement's), which
2036
+ * its Node puts there before it sends it, and which the Platform finds there before it takes it.
2037
+ */
2038
+ function imagesNamedBy(message) {
2039
+ if (message.kind === "hand-off") return message.handOff.images;
2040
+ if (message.kind === "judgement" && message.judgement.outcome === "failed") return message.judgement.images;
2041
+ return [];
2042
+ }
1706
2043
  /** The answer to `hello`: the Node is online. */
1707
2044
  const welcomeMessage = z.object({
1708
2045
  kind: z.literal("welcome"),
@@ -1749,12 +2086,16 @@ const taskStartFields = {
1749
2086
  skills: sessionBindings.default([])
1750
2087
  }),
1751
2088
  /**
1752
- * The Workpiece as it stands: its brief and the Hand-offs accepted so far, in Pipeline order, each
1753
- * named by its id and where it was made; the Node fetches them (`nodeHandOffPath`).
2089
+ * The Workpiece as it stands: its brief, with the images it carries beside its words, each named
2090
+ * by its key in the image store, which the Node reads through addresses it fetches for them
2091
+ * (`nodeBriefPath`), and the Hand-offs accepted so far, in Pipeline order, each named by its id
2092
+ * and where it was made; the Node fetches them (`nodeHandOffPath`). (A Session kept in an outbox
2093
+ * from protocol 24 or before gets no images.)
1754
2094
  */
1755
2095
  workpiece: z.object({
1756
2096
  id: z.string().min(1),
1757
2097
  brief,
2098
+ images: documentImages.default([]),
1758
2099
  handOffs: z.array(z.object({
1759
2100
  id: z.string().min(1),
1760
2101
  workstation: named$1,
@@ -1769,10 +2110,14 @@ const taskStartFields = {
1769
2110
  */
1770
2111
  repositories: workspaceRepositories.default([])
1771
2112
  };
1772
- /** A question an Agent asked the Owner mid-Task, and the Owner's reply, if they gave one. */
2113
+ /**
2114
+ * A question an Agent asked the Owner mid-Task, and the Owner's reply, if they gave one, with how
2115
+ * many images it came with (#186): they went only to the Session it resumed.
2116
+ */
1773
2117
  const helpExchange = z.object({
1774
2118
  question: helpQuestion,
1775
- reply: verdictText.nullable()
2119
+ reply: verdictText.nullable(),
2120
+ replyImages: z.number().int().nonnegative().max(IMAGE_LIMITS.images).default(0)
1776
2121
  });
1777
2122
  /**
1778
2123
  * The attempt a Rework follows at its Workstation (ADR-0003): who made it, how it ended, and why,
@@ -1788,8 +2133,13 @@ const previousAttempt = z.object({
1788
2133
  status: z.enum([...UNSUCCESSFUL_STATUSES, "accepted"]),
1789
2134
  /** The Hand-off it delivered, if any (fetched like the Workpiece's); it isn't on the Workpiece. */
1790
2135
  handOff: z.object({ id: z.string().min(1) }).nullable(),
1791
- /** The check its Hand-off failed (`rejected`): who judged it, and the annotations. */
2136
+ /**
2137
+ * The check its Hand-off failed (`rejected`): who judged it, and the annotations; the images
2138
+ * beside them, the Node fetches by its id (`nodeAnnotationsPath`). (A Session kept in an outbox
2139
+ * from protocol 24 or before names no check, and gets none.)
2140
+ */
1792
2141
  check: z.object({
2142
+ id: checkId$1.nullable().default(null),
1793
2143
  inspector: named$1,
1794
2144
  annotations
1795
2145
  }).nullable(),
@@ -1801,16 +2151,39 @@ const previousAttempt = z.object({
1801
2151
  /**
1802
2152
  * A Verdict the Owner gave on a Ticket of the Workpiece's Pipeline Run (spec story 37), which every
1803
2153
  * Agent working on the Workpiece after it is told of: the Ticket (T-n, raised at a Workstation),
1804
- * and the Verdict with what the Owner wrote into it.
2154
+ * and the Verdict with what the Owner wrote into it and the images beside those words, each named by
2155
+ * its key in the image store, which the Node reads through addresses it fetches for them
2156
+ * (`nodeVerdictsPath`).
1805
2157
  */
1806
2158
  const workpieceVerdict = z.object({
1807
2159
  ticket: z.object({
1808
2160
  number: z.number().int().positive(),
1809
2161
  workstation: named$1
1810
2162
  }),
1811
- verdict
2163
+ verdict,
2164
+ /**
2165
+ * Whether it routed the Workpiece to the Task this Session works on (#183): the reworking Task
2166
+ * after a Send back or a Rework with guidance, the next station's after an Approve. Its images
2167
+ * come inside that Task's first prompt, besides as files; every Agent after it gets them as files
2168
+ * only. (A Session kept in an outbox from protocol 24 or before is routed by none.)
2169
+ */
2170
+ routedHere: z.boolean().default(false)
1812
2171
  });
1813
2172
  /**
2173
+ * Where the Node fetches addresses to read the images of the Owner's Verdicts on the Workpiece the
2174
+ * Task `task` it holds works on (#183), which the message starting its work names by key: GET,
2175
+ * answered `fetchedVerdicts`.
2176
+ */
2177
+ function nodeVerdictsPath(task) {
2178
+ return `${NODE_TASKS_PATH}/${encodeURIComponent(task)}/verdicts`;
2179
+ }
2180
+ /**
2181
+ * The images of the Owner's latest Verdicts on a Workpiece (the `MAX_WORKPIECE_VERDICTS` a Session
2182
+ * is told of) as the Node fetches them: each with an address signed for the Task that fetched it,
2183
+ * as a brief's are. Their words are in the message starting the Session.
2184
+ */
2185
+ const fetchedVerdicts = z.object({ images: z.array(addressedImage).max(IMAGE_LIMITS.images * 20) });
2186
+ /**
1814
2187
  * Start a Session for a Workstation Task, in a fresh Workspace (ADR-0002): everything the Node
1815
2188
  * needs to run it. In its `work` role, `agent` works on the Workpiece at the Task's Workstation and
1816
2189
  * hands off: it is told which attempt this is, what went wrong with the attempt before it if it is
@@ -1832,7 +2205,9 @@ const taskStartMessage = z.discriminatedUnion("role", [z.object({
1832
2205
  previous: previousAttempt.nullable(),
1833
2206
  /**
1834
2207
  * The Owner's Verdicts on the Workpiece so far (the latest `MAX_WORKPIECE_VERDICTS`), in the
1835
- * order given: the guidance, notes and replies they carry reach every Agent after them.
2208
+ * order given: the guidance, notes and replies they carry reach every Agent after them, and so
2209
+ * do the images beside a guidance or a note, inside the first prompt of the Task one routed the
2210
+ * Workpiece to (`routedHere`), as files to every other.
1836
2211
  */
1837
2212
  verdicts: z.array(workpieceVerdict).max(20),
1838
2213
  /**
@@ -1879,14 +2254,17 @@ const taskCancelMessage = inRole({
1879
2254
  * Agent binds as the reply was sent (#120), which the Node lays out anew for the resumed segment (a
1880
2255
  * copy it lacks installed first). The Node reports the resumed segment's start (`task-started`), or
1881
2256
  * the Task's end if the Session can't be resumed. (A reply kept in an outbox from protocol 21 or
1882
- * before names no Skills: its segment gets those the Session had.)
2257
+ * before names no Skills: its segment gets those the Session had.) A reply with images (#186) names
2258
+ * them (`handed`): the Platform handed them to the Node before (`handed-image`), and the Node writes
2259
+ * them into the Session's Workspace, inside the prompt that resumes it.
1883
2260
  */
1884
2261
  const taskResumeMessage = z.object({
1885
2262
  kind: z.literal("task-resume"),
1886
2263
  ...reliableEnvelope,
1887
2264
  taskId: taskId$1,
1888
2265
  reply: verdictText,
1889
- skills: sessionBindings.optional()
2266
+ skills: sessionBindings.optional(),
2267
+ handed: handedImages.optional()
1890
2268
  });
1891
2269
  /**
1892
2270
  * Where an Agent's chat Workspace is made as for (its place, spec story 71), for its instructions:
@@ -1904,12 +2282,12 @@ const chatSayPlace = z.discriminatedUnion("kind", [z.object({
1904
2282
  to: named$1
1905
2283
  })]).nullable();
1906
2284
  /**
1907
- * The Owner said `message` in turn `turn` of a chat with `agent`, its next prompt. The first turn
1908
- * opens the chat's Session in a fresh Workspace with `repositories` checked out (made as for the
1909
- * Agent's `place`), given the Agent's instructions and nothing of any Workpiece, on its model, with
1910
- * no Factory MCP; each later turn resumes that Session in the same Workspace, with the instructions
1911
- * it started with, whatever `agent` says now. The Node reports the turn's start
1912
- * (`chat-turn-started`) and end (`chat-turn-ended`).
2285
+ * The Owner said `message` in turn `turn` of a chat with `agent`, its next prompt, with the images
2286
+ * it came with, if any (`handed`). The first turn opens the chat's Session in a fresh Workspace
2287
+ * with `repositories` checked out (made as for the Agent's `place`), given the Agent's instructions
2288
+ * and nothing of any Workpiece, on its model, with no Factory MCP; each later turn resumes that
2289
+ * Session in the same Workspace, with the instructions it started with, whatever `agent` says now.
2290
+ * The Node reports the turn's start (`chat-turn-started`) and end (`chat-turn-ended`).
1913
2291
  */
1914
2292
  const chatSayMessage = z.object({
1915
2293
  kind: z.literal("chat-say"),
@@ -1928,7 +2306,13 @@ const chatSayMessage = z.object({
1928
2306
  }),
1929
2307
  place: chatSayPlace,
1930
2308
  repositories: workspaceRepositories,
1931
- message: chatMessage
2309
+ message: chatMessage,
2310
+ /**
2311
+ * The images the message came with (#187), which the Platform handed the Node before
2312
+ * (`handed-image`, for this turn): the turn writes them into whatever Workspace it opens or
2313
+ * resumes, and its prompt carries them.
2314
+ */
2315
+ handed: handedImages.optional()
1932
2316
  });
1933
2317
  /**
1934
2318
  * The Node's Tasks and chats someone is watching now: relay their live activity (`session-events`,
@@ -2144,6 +2528,8 @@ const named = z.object({
2144
2528
  /** What every Workstation is besides who staffs it (`workstationSummary`). */
2145
2529
  const station = named.extend({
2146
2530
  attemptBudget,
2531
+ /** Its time limit in minutes (#174): the default in an event recorded before it could be set. */
2532
+ timeLimitMinutes: timeLimitMinutes.default(TIME_LIMIT.default),
2147
2533
  along,
2148
2534
  /** Its Workspace policy (#90): the repositories each fresh Workspace there gets checked out. */
2149
2535
  repositories: workspaceRepositories,
@@ -2157,8 +2543,8 @@ const emptyWorkstation = station.extend({
2157
2543
  /**
2158
2544
  * A Workstation: who staffs it, its Operator (an Agent, or the Owner at a Human Workstation, which
2159
2545
  * has no Agent), or no one yet (an empty Workstation, which has none either); how many attempts it
2160
- * gets at a Workpiece in a round (a Human one, one), where it stands along its line, its Workspace
2161
- * policy and its revision.
2546
+ * gets at a Workpiece in a round (a Human one, one) and its time limit (a Human one's unused),
2547
+ * where it stands along its line, its Workspace policy and its revision.
2162
2548
  */
2163
2549
  const workstationSummary = z.discriminatedUnion("operator", [
2164
2550
  station.extend({
@@ -2222,7 +2608,7 @@ const agentRestoredEvent = z.object({
2222
2608
  });
2223
2609
  /** A Factory, Workshop or Pipeline as it stands: where, in what holds it, and its revision. */
2224
2610
  const placed = named.extend({
2225
- at: spot,
2611
+ at: spot$1,
2226
2612
  revision
2227
2613
  });
2228
2614
  /**
@@ -2262,7 +2648,7 @@ const definitionMovedEvent = z.object({
2262
2648
  kind: z.literal("definition-moved"),
2263
2649
  definition: movableDefinition,
2264
2650
  id: z.string().min(1),
2265
- at: spot,
2651
+ at: spot$1,
2266
2652
  revision
2267
2653
  });
2268
2654
  /**
@@ -2460,7 +2846,9 @@ const checkGateRepositoriesSetEvent = z.object({
2460
2846
  * The Owner staffed Workstation `workstationId` of Pipeline `pipelineId` (#63): with `agent`
2461
2847
  * (`operator` "agent"), with themselves as its Human (`operator` "human", no Agent), or with no one
2462
2848
  * (`operator` null, no Agent), freeing whoever staffed it before. `attemptBudget` is how many
2463
- * attempts it gets now (a Human Workstation one), `revision` its revision now.
2849
+ * attempts it gets now (a Human Workstation one), `timeLimitMinutes` its time limit now (a Human
2850
+ * Workstation's the default, unused; the default in an event recorded before it could be set),
2851
+ * `revision` its revision now.
2464
2852
  */
2465
2853
  const workstationStaffedEvent = z.object({
2466
2854
  kind: z.literal("workstation-staffed"),
@@ -2469,6 +2857,7 @@ const workstationStaffedEvent = z.object({
2469
2857
  operator: operatorKind.nullable(),
2470
2858
  agent: named.nullable(),
2471
2859
  attemptBudget,
2860
+ timeLimitMinutes: timeLimitMinutes.default(TIME_LIMIT.default),
2472
2861
  revision
2473
2862
  });
2474
2863
  /**
@@ -2484,6 +2873,19 @@ const attemptBudgetChangedEvent = z.object({
2484
2873
  revision
2485
2874
  });
2486
2875
  /**
2876
+ * The Owner set how long a Task at Agent Workstation `workstationId` of Pipeline `pipelineId` may
2877
+ * be at work in one segment of its Session (#174), in minutes, from its next segment on: a segment
2878
+ * timed already keeps the limit it started with.
2879
+ */
2880
+ const timeLimitChangedEvent = z.object({
2881
+ kind: z.literal("time-limit-changed"),
2882
+ pipelineId: z.string().min(1),
2883
+ workstationId: z.string().min(1),
2884
+ timeLimitMinutes,
2885
+ /** The Workstation's revision now. */
2886
+ revision
2887
+ });
2888
+ /**
2487
2889
  * The Owner set the Workspace policy of Workstation `workstationId` of Pipeline `pipelineId`
2488
2890
  * (#90): each fresh Workspace there gets `repositories` checked out, in this order, each in its
2489
2891
  * folder, from its next Task on.
@@ -2508,9 +2910,15 @@ const runDispatchedEvent = z.object({
2508
2910
  runId,
2509
2911
  number: z.number().int().positive(),
2510
2912
  pipeline: named,
2913
+ /**
2914
+ * Its Workpiece: the brief, and the images it carries beside its words, each by its key in the
2915
+ * image store, never an address to read it at (they expire; the ledger is kept). (Dispatched with
2916
+ * protocol 24 or before, it has none.)
2917
+ */
2511
2918
  workpiece: z.object({
2512
2919
  id: z.string().min(1),
2513
- brief
2920
+ brief,
2921
+ images: documentImages.default([])
2514
2922
  }),
2515
2923
  scheduleId: z.string().min(1).nullable().default(null)
2516
2924
  });
@@ -2610,7 +3018,10 @@ const checkStartedEvent = z.object({
2610
3018
  taskId,
2611
3019
  checkId
2612
3020
  });
2613
- /** The Inspector judged the Hand-off: passed, or failed with annotations. */
3021
+ /**
3022
+ * The Inspector judged the Hand-off: passed, or failed with annotations and the images beside them,
3023
+ * each by its key in the image store (none in an event from before #184).
3024
+ */
2614
3025
  const checkJudgedEvent = z.object({
2615
3026
  kind: z.literal("check-judged"),
2616
3027
  runId,
@@ -2994,6 +3405,7 @@ const ledgerEvent = z.discriminatedUnion("kind", [
2994
3405
  checkGateRemovedEvent,
2995
3406
  checkGateRepositoriesSetEvent,
2996
3407
  attemptBudgetChangedEvent,
3408
+ timeLimitChangedEvent,
2997
3409
  workstationRepositoriesSetEvent,
2998
3410
  scheduleAddedEvent,
2999
3411
  scheduleEditedEvent,
@@ -3063,6 +3475,373 @@ const PASSWORD_LIMITS = {
3063
3475
  max: 128
3064
3476
  };
3065
3477
  `${PASSWORD_LIMITS.long}${PASSWORD_LIMITS.short}`;
3478
+ /**
3479
+ * A Showroom's title as the Owner writes it (spec story 31): words of their own, trimmed, never
3480
+ * filled in from their name (ADR-0021 rule 3). Empty until they write one; opening needs one.
3481
+ */
3482
+ const showroomTitle = z.string().trim().max({
3483
+ /** Characters of its title. */
3484
+ title: 80,
3485
+ /**
3486
+ * How many Visitors watch it at once (spec story 53), so that one link going viral can't slow
3487
+ * every Owner's factory: the next is told it's full (`showroomMessage`). A browser signed in as
3488
+ * its own Owner isn't counted, the hidden frame that takes its card's picture among them.
3489
+ */
3490
+ visitors: 200,
3491
+ /**
3492
+ * The least time between two views a Showroom's feed builds and sends (spec story 54): at most
3493
+ * twice a second, however busy the factory.
3494
+ */
3495
+ viewIntervalMs: 500,
3496
+ /**
3497
+ * How long after its Run ended a Workpiece is still in the view, done or scrapped: long enough
3498
+ * for the World to play it leaving the line, after the courier robot carried back the Verdict
3499
+ * that ended it (spec story 5).
3500
+ */
3501
+ justEndedMs: 6e4
3502
+ }.title);
3503
+ /**
3504
+ * The operating systems a Visitor may read on a Node's tower (spec story 49), by family only: the
3505
+ * name a Node gives for its system, its release and its machine's architecture stay with the Owner
3506
+ * (ADR-0021 rule 3).
3507
+ */
3508
+ const SHOWROOM_SYSTEMS = [
3509
+ "macos",
3510
+ "linux",
3511
+ "windows",
3512
+ "other"
3513
+ ];
3514
+ /** A place on the island or in what holds it, as the World draws it (ADR-0015). */
3515
+ const spot = z.strictObject({
3516
+ x: z.number(),
3517
+ z: z.number()
3518
+ });
3519
+ /** A thing by its id alone. */
3520
+ const ref = z.strictObject({ id: z.string() });
3521
+ /**
3522
+ * A Workstation on a chosen line.
3523
+ * - What: its id, its name, how far along its line it stands, and who staffs it: an Agent (by id),
3524
+ * the Owner (a Human), or no one yet.
3525
+ * - From: `workstation` (id, name, along, operator, agent_id).
3526
+ * - Why: the World draws it, labelled with its name, and its Operator's figure (rule 1). Never its
3527
+ * repositories (rule 2) nor its attempt budget, which nothing draws.
3528
+ */
3529
+ const showroomWorkstation = z.union([
3530
+ z.strictObject({
3531
+ id: z.string(),
3532
+ name: z.string(),
3533
+ along: z.number(),
3534
+ operator: z.literal("agent"),
3535
+ agent: ref
3536
+ }),
3537
+ z.strictObject({
3538
+ id: z.string(),
3539
+ name: z.string(),
3540
+ along: z.number(),
3541
+ operator: z.literal("human"),
3542
+ agent: z.null()
3543
+ }),
3544
+ z.strictObject({
3545
+ id: z.string(),
3546
+ name: z.string(),
3547
+ along: z.number(),
3548
+ operator: z.null(),
3549
+ agent: z.null()
3550
+ })
3551
+ ]);
3552
+ /**
3553
+ * A Belt on a chosen line.
3554
+ * - What: its id, the Workstations it joins, whether a Check gate stands on it, and its
3555
+ * Inspector's id.
3556
+ * - From: `belt` (id, from_workstation_id, to_workstation_id, gated, inspector_id).
3557
+ * - Why: the World draws the Belt, its gate and the Inspector's figure there (rule 1). Never its
3558
+ * criteria or repositories (rule 2): whether a gate stands is `gated`, never read off criteria.
3559
+ */
3560
+ const showroomBelt = z.strictObject({
3561
+ id: z.string(),
3562
+ fromWorkstationId: z.string(),
3563
+ toWorkstationId: z.string(),
3564
+ gated: z.boolean(),
3565
+ inspector: ref.nullable()
3566
+ });
3567
+ /**
3568
+ * One of a Pipeline's Intakes, in the order they were made: its Dispatch, or a Schedule standing
3569
+ * there, paused or not.
3570
+ * - What: its kind, and a Schedule's whether it is paused.
3571
+ * - From: `intake` (kind) and `schedule` (paused), those not removed (`retirement_id`).
3572
+ * - Why: a scheduled Intake's hopper is an hourglass, its sand running while a Schedule on a line
3573
+ * not paused runs (rule 1). Never a Schedule's cadence, timezone, brief or overlap (rule 2), nor
3574
+ * an Intake's id, a handle to release anything with (rule 4).
3575
+ */
3576
+ const showroomIntake = z.discriminatedUnion("kind", [z.strictObject({ kind: z.literal("dispatch") }), z.strictObject({
3577
+ kind: z.literal("schedule"),
3578
+ paused: z.boolean()
3579
+ })]);
3580
+ /**
3581
+ * A Pipeline in a chosen Workshop.
3582
+ * - What: its id, its name, where its Intake stands, whether the Owner paused it, its Intakes,
3583
+ * its Workstations and Belts.
3584
+ * - From: `pipeline` (id, name, at_x, at_z, paused).
3585
+ * - Why: the World draws the line where it stands, flagged while paused, and the breadcrumb names
3586
+ * it (rule 1).
3587
+ */
3588
+ const showroomPipeline = z.strictObject({
3589
+ id: z.string(),
3590
+ name: z.string(),
3591
+ at: spot,
3592
+ paused: z.boolean(),
3593
+ intakes: z.array(showroomIntake),
3594
+ workstations: z.array(showroomWorkstation),
3595
+ belts: z.array(showroomBelt)
3596
+ });
3597
+ /**
3598
+ * A Workshop in a chosen Factory.
3599
+ * - What: its id, its name, where it stands in its Factory, its Pipelines.
3600
+ * - From: `workshop` (id, name, at_x, at_z).
3601
+ * - Why: the World draws it with its plate (rule 1).
3602
+ */
3603
+ const showroomWorkshop = z.strictObject({
3604
+ id: z.string(),
3605
+ name: z.string(),
3606
+ at: spot,
3607
+ pipelines: z.array(showroomPipeline)
3608
+ });
3609
+ /**
3610
+ * A chosen Factory, oldest first.
3611
+ * - What: its id, its name, where it stands on the island, its Workshops.
3612
+ * - From: `factory` (id, name, at_x, at_z), only those the Owner chose (`showroom.factory_ids`)
3613
+ * and still standing.
3614
+ * - Why: the World draws it with its sign (rule 1); no other Factory exists for a Visitor (rule 5).
3615
+ */
3616
+ const showroomFactory = z.strictObject({
3617
+ id: z.string(),
3618
+ name: z.string(),
3619
+ at: spot,
3620
+ workshops: z.array(showroomWorkshop)
3621
+ });
3622
+ /**
3623
+ * An Agent with a place in a chosen Factory, in the order they were hired.
3624
+ * - What: its id, its name, its Runtime and its Node's id.
3625
+ * - From: `agent` (id, name, runtime, node_id), only the Agents staffing a Workstation or
3626
+ * inspecting a Belt on a chosen line.
3627
+ * - Why: the World draws its figure in its Runtime's silhouette, and hover words name it (rule 1);
3628
+ * its Node's id says which tower's status puts it to sleep. Never its instructions, model or
3629
+ * Skills (rule 2); no Agent without a place (rule 5).
3630
+ */
3631
+ const showroomAgent = z.strictObject({
3632
+ id: z.string(),
3633
+ name: z.string(),
3634
+ runtime: z.enum(RUNTIME_IDS),
3635
+ node: ref
3636
+ });
3637
+ /**
3638
+ * One of the Owner's Nodes that isn't revoked, in the order they joined.
3639
+ * - What: its id, whether it is online, and its system's family (`SHOWROOM_SYSTEMS`).
3640
+ * - From: `node` (id, status, os's platform), those with no revoked_at.
3641
+ * - Why: the World draws a tower for it in the home corner, its beacon lit while it is online, and
3642
+ * an Agent of an offline Node asleep (rule 1); its board shows its system in place of its name.
3643
+ * Never its name, its system's release or architecture, nor its Runtimes or their versions
3644
+ * (rule 3).
3645
+ */
3646
+ const showroomNode = z.strictObject({
3647
+ id: z.string(),
3648
+ status: z.enum(["online", "offline"]),
3649
+ system: z.enum(SHOWROOM_SYSTEMS)
3650
+ });
3651
+ /**
3652
+ * The island's home corner.
3653
+ * - What: where the Desk and the Node towers beside it stand.
3654
+ * - From: `island` (home_x, home_z), or where every new island has it.
3655
+ * - Why: the World draws the Desk and the towers there (rule 1); it is structure, read by no one
3656
+ * opening anything.
3657
+ */
3658
+ const showroomIsland = z.strictObject({ home: spot });
3659
+ /**
3660
+ * A Workstation Task of a Run in `runs`, in the order they were queued.
3661
+ * - What: its id, its status, when it was queued, its Agent's id (none at a Human Workstation),
3662
+ * its Workstation's id, and its Hand-off's checks' statuses in order (a check's status holds its
3663
+ * outcome: passed, failed, or no judgement).
3664
+ * - From: `workstation_task` (id, status, queued_at, agent_id, workstation_id) and `check`
3665
+ * (status).
3666
+ * - Why: where its Workpiece is, how it looks (warm once a Task was accepted, the "ok" mark once
3667
+ * a check passed or an Audit approved it) and what lights a Workstation or a Check gate are
3668
+ * drawn from them, and when it was queued lines Workpieces up at a place (rule 1). Never its
3669
+ * brief, Hand-off, failure, attempt or cause, nor a check's criteria, annotations or reason
3670
+ * (rule 2).
3671
+ */
3672
+ const showroomTask = z.strictObject({
3673
+ id: z.string(),
3674
+ status: z.enum(WORKSTATION_TASK_STATUSES),
3675
+ queuedAt: z.string(),
3676
+ agent: ref.nullable(),
3677
+ workstation: ref,
3678
+ checks: z.array(z.strictObject({ status: z.enum(CHECK_STATUSES) }))
3679
+ });
3680
+ /**
3681
+ * Where a Workpiece holds on its paused line (the protocol's `workpieceHold`), drawn there: at its
3682
+ * Intake; after Task `taskId`'s Hand-off, its check passed or its attempt; or, with a Verdict to
3683
+ * apply, at the Workstation of the fresh round it sends the Workpiece to, or past Task `taskId`'s.
3684
+ * Never the Ticket whose Verdict it is.
3685
+ */
3686
+ const showroomHold = z.union([
3687
+ z.strictObject({ at: z.literal("intake") }),
3688
+ z.strictObject({
3689
+ at: z.enum([
3690
+ "hand-off",
3691
+ "check-passed",
3692
+ "rework"
3693
+ ]),
3694
+ taskId: z.string()
3695
+ }),
3696
+ z.strictObject({
3697
+ at: z.literal("verdict"),
3698
+ taskId: z.string(),
3699
+ workstationId: z.string().nullable()
3700
+ })
3701
+ ]);
3702
+ /**
3703
+ * A Pipeline Run on a chosen line, in progress or just ended (done or scrapped within
3704
+ * `SHOWROOM_LIMITS.justEndedMs`), in the order they were dispatched: Workpieces that came at the
3705
+ * same moment line up in that order.
3706
+ * - What: its id, its status, its Pipeline's id, when it was dispatched, where its Workpiece holds
3707
+ * on its paused line (null while it doesn't, `showroomHold`), and its Tasks (`showroomTask`).
3708
+ * - From: `pipeline_run` (id, status, pipeline_id, dispatched_at, held; completed_at and
3709
+ * scrapped_at, for how long ago it ended).
3710
+ * - Why: its Workpiece rides the line, waits at Check gates, goes back for Rework and leaves it,
3711
+ * done into the bin or as Scrap (rule 1). Never its Workpiece's brief, its failure or its
3712
+ * number (rule 2); a Run that failed leaves the floor at once, as on the Owner's.
3713
+ */
3714
+ const showroomRun = z.strictObject({
3715
+ id: z.string(),
3716
+ status: z.enum([
3717
+ "in-progress",
3718
+ "completed",
3719
+ "scrapped"
3720
+ ]),
3721
+ pipeline: ref,
3722
+ dispatchedAt: z.string(),
3723
+ held: showroomHold.nullable(),
3724
+ tasks: z.array(showroomTask)
3725
+ });
3726
+ /**
3727
+ * An open Ticket raised in a chosen Factory, oldest first.
3728
+ * - What: its id, whether the courier robot carries it to the Desk (an Audit), its Task's and Run's
3729
+ * ids, and where it was raised: its Pipeline and Workstation.
3730
+ * - From: `ticket` (id, category, task_id, run_id, workstation_id; those open) and its Run's
3731
+ * `pipeline_id`.
3732
+ * - Why: it flies to the Desk from where it was raised, or the courier robot fetches it, and stacks
3733
+ * there, while its Workpiece waits for the Owner (rule 1). Never its number, title, reason,
3734
+ * question or Verdict, nor which kind it is beyond whether it is carried (rule 2); none raised
3735
+ * in a Factory not chosen (rule 5).
3736
+ */
3737
+ const showroomTicket = z.strictObject({
3738
+ id: z.string(),
3739
+ carried: z.boolean(),
3740
+ taskId: z.string(),
3741
+ run: ref,
3742
+ from: z.strictObject({
3743
+ pipeline: ref,
3744
+ workstation: ref
3745
+ })
3746
+ });
3747
+ /**
3748
+ * A moment the World marks once, as it arrives live: a Task just accepted (it plays just finished
3749
+ * at its Workstation), a check just judged (its gate plays the stamp of its outcome), or a Verdict
3750
+ * just given on an Audit the courier robot carried (the robot carries the paper back, inked with
3751
+ * where it sends the Workpiece: on, back for Rework, or to its end).
3752
+ * - What: its kind, and the Task's or the Ticket's id; a check's outcome; a Verdict's ink
3753
+ * (`VERDICT_INKS`).
3754
+ * - From: the Owner's ledger since the view before (`task-accepted`, `check-judged`,
3755
+ * `ticket-ruled`: their kind, Task, Ticket, outcome and Verdict's kind alone), of the Tasks of
3756
+ * `runs` and the carried Audits on their lines.
3757
+ * - Why: each plays once on the floor (rule 1). Never a check's annotations, nor what a Verdict
3758
+ * says, its kind beyond where it sends the Workpiece, or who gave it (rules 2 and 3).
3759
+ */
3760
+ const showroomMoment = z.discriminatedUnion("kind", [
3761
+ z.strictObject({
3762
+ kind: z.literal("task-accepted"),
3763
+ taskId: z.string()
3764
+ }),
3765
+ z.strictObject({
3766
+ kind: z.literal("check-judged"),
3767
+ taskId: z.string(),
3768
+ outcome: z.enum(["passed", "failed"])
3769
+ }),
3770
+ z.strictObject({
3771
+ kind: z.literal("verdict"),
3772
+ ticketId: z.string(),
3773
+ ink: z.enum(VERDICT_INK_KINDS)
3774
+ })
3775
+ ]);
3776
+ /**
3777
+ * What an Agent at work on a Task under way is doing, by the Task's id (a check's Inspector's,
3778
+ * while its Hand-off is checked).
3779
+ * - What: thinking or working; an idle one isn't listed.
3780
+ * - From: the pulses Nodes send the Platform (live activity, in memory), for the Tasks in `runs`.
3781
+ * - Why: the light of thinking and working is drawn (rule 1). Never the tool it uses, nor what it
3782
+ * says (rule 2); a chat's pulse never, since a chat is no work of the line (ADR-0021).
3783
+ */
3784
+ const showroomActivity = z.strictObject({ state: z.enum(["thinking", "working"]) });
3785
+ /**
3786
+ * The caption's figures (spec #146, "The caption's figures"), counting only the chosen Factories'
3787
+ * in-service lines, as the Owner signed the caption off in the gallery (#159).
3788
+ * Whether the factory runs now, and how many Workstations and Check gates are at work, are not
3789
+ * among them: the page reads both off what its World lights from the view (the World's
3790
+ * `atWork`), the one rule for the caption, the World and the card.
3791
+ * - What: how many Workpieces finished, and how many Workstation Tasks were queued as Rework, in
3792
+ * the last day (`SHOWROOM_FIGURES_SPAN_MS`); and when the World last lit work, as the records
3793
+ * show it: at a time, or never.
3794
+ * - From: `workstation_task` (cause, queued_at, started_at, usage_recorded_at), `check`
3795
+ * (started_at, ended_at), `hand_off` (created_at), `ticket` (raised_at, ruled_at,
3796
+ * withdrawn_at) and `pipeline_run` (status, completed_at), counted, never listed
3797
+ * (`packages/platform/src/showroom/figures.ts`).
3798
+ * - Why: they tell how much the factory drawn on the floor works, so that a dark island reads as
3799
+ * resting, not broken (rule 1: the lights of work, counted). Never which Workpiece, what it was
3800
+ * about or why a Rework was needed (rule 2); nothing of an unchosen Factory (rule 5).
3801
+ */
3802
+ const showroomFigures = z.strictObject({
3803
+ finished: z.number().int().nonnegative(),
3804
+ rework: z.number().int().nonnegative(),
3805
+ lastAtWork: z.discriminatedUnion("kind", [z.strictObject({
3806
+ kind: z.literal("ago"),
3807
+ at: z.iso.datetime()
3808
+ }), z.strictObject({ kind: z.literal("never") })])
3809
+ });
3810
+ /**
3811
+ * What a Visitor receives of a Showroom (ADR-0021): the whitelist. Each item says what it is,
3812
+ * where the Platform takes it from and why the rules allow it.
3813
+ */
3814
+ const showroomView = z.strictObject({
3815
+ /**
3816
+ * The Showroom's title. From `showroom.title`. Why: the Owner wrote it for Visitors to read, in
3817
+ * words of their own (rule 3: never filled in from their name).
3818
+ */
3819
+ title: showroomTitle,
3820
+ /** The caption's figures (`showroomFigures`). */
3821
+ figures: showroomFigures,
3822
+ factories: z.array(showroomFactory),
3823
+ agents: z.array(showroomAgent),
3824
+ nodes: z.array(showroomNode),
3825
+ island: showroomIsland,
3826
+ runs: z.array(showroomRun),
3827
+ tickets: z.array(showroomTicket),
3828
+ activity: z.record(z.string(), showroomActivity),
3829
+ /** The moments since the view before, oldest first (`showroomMoment`). */
3830
+ moments: z.array(showroomMoment)
3831
+ });
3832
+ z.discriminatedUnion("kind", [
3833
+ z.strictObject({
3834
+ kind: z.literal("view"),
3835
+ live: z.boolean(),
3836
+ view: showroomView
3837
+ }),
3838
+ z.strictObject({ kind: z.literal("closed") }),
3839
+ z.strictObject({
3840
+ kind: z.literal("full"),
3841
+ title: z.string(),
3842
+ figures: showroomFigures.nullable()
3843
+ })
3844
+ ]);
3066
3845
  [
3067
3846
  "password",
3068
3847
  "github",
@@ -3086,18 +3865,18 @@ function jsonIn(text) {
3086
3865
  }
3087
3866
  }
3088
3867
  /** A file being written, under the name it gets once it is whole. */
3089
- const PARTIAL = ".tmp";
3868
+ const PARTIAL$1 = ".tmp";
3090
3869
  /**
3091
- * Writes `text` to `path`, readable by this user only, in one step: aside first, then renamed over
3092
- * it, so a crash leaves the whole file (old or new) and never part of one.
3870
+ * Writes `text` (or bytes) to `path`, readable by this user only, in one step: aside first, then
3871
+ * renamed over it, so a crash leaves the whole file (old or new) and never part of one.
3093
3872
  */
3094
3873
  async function writeAtomically(path, text) {
3095
- await writeFile(`${path}${PARTIAL}`, text, { mode: 384 });
3096
- await rename(`${path}${PARTIAL}`, path);
3874
+ await writeFile(`${path}${PARTIAL$1}`, text, { mode: 384 });
3875
+ await rename(`${path}${PARTIAL$1}`, path);
3097
3876
  }
3098
3877
  /** Whether `file` was left half-written by a crash during `writeAtomically`. */
3099
3878
  function isPartial(file) {
3100
- return file.endsWith(PARTIAL);
3879
+ return file.endsWith(PARTIAL$1);
3101
3880
  }
3102
3881
  /** Items can hold anything; their files are named by a hash. */
3103
3882
  function fileOf(item) {
@@ -3130,10 +3909,12 @@ async function openFileSet(dir) {
3130
3909
  /**
3131
3910
  * Where a Node keeps its own files: its credential (`node.json`) and its outbox. The
3132
3911
  * `MOONMILL_NODE_HOME` environment variable moves it, for example to run two Nodes on one
3133
- * machine.
3912
+ * machine; a relative one is taken from the folder the daemon starts in, since the paths an
3913
+ * Agent's instructions give under it must be absolute.
3134
3914
  */
3135
3915
  function defaultStateDir(env = process.env) {
3136
- return env["MOONMILL_NODE_HOME"] ?? join(homedir(), ".moonmill", "node");
3916
+ const home = env["MOONMILL_NODE_HOME"];
3917
+ return home === void 0 ? join(homedir(), ".moonmill", "node") : resolve(home);
3137
3918
  }
3138
3919
  /** What a Node got when it joined: who it is, where its Platform is, and how to prove it. */
3139
3920
  const nodeCredential = z.object({
@@ -3587,6 +4368,8 @@ var UnreadableMessageError = class extends Error {
3587
4368
  name = "UnreadableMessageError";
3588
4369
  };
3589
4370
  const SUFFIX = ".json";
4371
+ /** The folder of the outbox the images its messages name are kept in. */
4372
+ const IMAGES = "images";
3590
4373
  /** The file the message at `place` in line is kept in: their names keep them in line. */
3591
4374
  function fileInLine(place) {
3592
4375
  return `${String(place).padStart(12, "0")}${SUFFIX}`;
@@ -3598,6 +4381,7 @@ function placeOf(file) {
3598
4381
  /** The kind a message this daemon can't read says it is, if it says. */
3599
4382
  const sayingItsKind = z.object({ kind: z.string() });
3600
4383
  async function openOutbox(dir) {
4384
+ const imagesDir = join(dir, IMAGES);
3601
4385
  await mkdir(dir, {
3602
4386
  recursive: true,
3603
4387
  mode: 448
@@ -3606,6 +4390,7 @@ async function openOutbox(dir) {
3606
4390
  const discarded = [];
3607
4391
  let next = 0;
3608
4392
  for (const file of (await readdir(dir)).toSorted()) {
4393
+ if (file === IMAGES) continue;
3609
4394
  if (!file.endsWith(SUFFIX)) {
3610
4395
  await rm(join(dir, file), { force: true });
3611
4396
  continue;
@@ -3629,26 +4414,56 @@ async function openOutbox(dir) {
3629
4414
  });
3630
4415
  next = Math.max(next, placeOf(file) + 1);
3631
4416
  }
4417
+ /** How many messages on their way in name each image, by its key. */
4418
+ const adding = /* @__PURE__ */ new Map();
4419
+ /** The keys of the images a kept message names, or one on its way in. */
4420
+ const named = () => /* @__PURE__ */ new Set([...adding.keys(), ...[...kept.values()].flatMap(({ message }) => imagesNamedBy(message).map(({ key }) => key))]);
4421
+ /** Deletes every image no kept message names: one acknowledged, discarded or half-written. */
4422
+ const sweepImages = async () => {
4423
+ const needed = named();
4424
+ for (const file of await readdir(imagesDir).catch(() => [])) if (!needed.has(file)) await rm(join(imagesDir, file), { force: true });
4425
+ };
4426
+ await sweepImages();
3632
4427
  return {
3633
4428
  discarded,
3634
- async add(message) {
4429
+ async add(message, images = []) {
3635
4430
  const readable = nodeMessage.safeParse(message);
3636
4431
  if (!readable.success) throw new UnreadableMessageError(`Not a message the Platform can read: ${z.prettifyError(readable.error)}`);
3637
- const file = fileInLine(next++);
3638
- await writeAtomically(join(dir, file), JSON.stringify(message));
3639
- kept.set(message.id, {
3640
- file,
3641
- message
3642
- });
4432
+ const given = new Map(images.map((image) => [image.key, image.bytes]));
4433
+ const keys = imagesNamedBy(message).map(({ key }) => key);
4434
+ for (const key of keys) if (!given.has(key)) throw new UnreadableMessageError(`The image ${key} it names isn't given with it`);
4435
+ for (const key of keys) adding.set(key, (adding.get(key) ?? 0) + 1);
4436
+ try {
4437
+ if (keys.length > 0) await mkdir(imagesDir, {
4438
+ recursive: true,
4439
+ mode: 448
4440
+ });
4441
+ for (const key of keys) await writeAtomically(join(imagesDir, imageKey.parse(key)), given.get(key) ?? "");
4442
+ const file = fileInLine(next++);
4443
+ await writeAtomically(join(dir, file), JSON.stringify(message));
4444
+ kept.set(message.id, {
4445
+ file,
4446
+ message
4447
+ });
4448
+ } finally {
4449
+ for (const key of keys) {
4450
+ const count = (adding.get(key) ?? 1) - 1;
4451
+ if (count > 0) adding.set(key, count);
4452
+ else adding.delete(key);
4453
+ }
4454
+ }
3643
4455
  },
3644
4456
  async remove(id) {
3645
4457
  const entry = kept.get(id);
3646
4458
  if (!entry) return null;
3647
4459
  kept.delete(id);
3648
4460
  await rm(join(dir, entry.file), { force: true });
4461
+ const needed = named();
4462
+ for (const { key } of imagesNamedBy(entry.message)) if (!needed.has(key)) await rm(join(imagesDir, key), { force: true });
3649
4463
  return entry.message;
3650
4464
  },
3651
- pending: () => [...kept.values()].map((entry) => entry.message)
4465
+ pending: () => [...kept.values()].map((entry) => entry.message),
4466
+ image: async (key) => new Uint8Array(await readFile(join(imagesDir, imageKey.parse(key))))
3652
4467
  };
3653
4468
  }
3654
4469
  //#endregion
@@ -3659,7 +4474,7 @@ async function openOutbox(dir) {
3659
4474
  * copy included): `from` is gone either way.
3660
4475
  */
3661
4476
  async function fileCopy(from, folder) {
3662
- if (!await isThere$1(folder)) {
4477
+ if (!await isThere$2(folder)) {
3663
4478
  await mkdir(dirname(folder), {
3664
4479
  recursive: true,
3665
4480
  mode: 448
@@ -3669,7 +4484,7 @@ async function fileCopy(from, folder) {
3669
4484
  await rename(from, folder);
3670
4485
  filed = true;
3671
4486
  } catch (error) {
3672
- if (!(error instanceof Error && "code" in error && await isThere$1(folder))) throw error;
4487
+ if (!(error instanceof Error && "code" in error && await isThere$2(folder))) throw error;
3673
4488
  }
3674
4489
  if (filed) {
3675
4490
  await sealCopy(folder);
@@ -3683,7 +4498,7 @@ async function fileCopy(from, folder) {
3683
4498
  * there to take, since a check's copy may go (dropped, or taken over just now) at any time.
3684
4499
  */
3685
4500
  async function takeCopyOver(from, folder) {
3686
- if (!await isThere$1(from)) return false;
4501
+ if (!await isThere$2(from)) return false;
3687
4502
  await mkdir(dirname(folder), {
3688
4503
  recursive: true,
3689
4504
  mode: 448
@@ -3723,7 +4538,7 @@ async function unseal(folder) {
3723
4538
  for (const name of await readdir(folder)) await unseal(join(folder, name));
3724
4539
  }
3725
4540
  /** Whether a copy stands at `folder`: a folder of its own, not a link to one. */
3726
- async function isThere$1(folder) {
4541
+ async function isThere$2(folder) {
3727
4542
  return (await lstat(folder).catch(() => null))?.isDirectory() ?? false;
3728
4543
  }
3729
4544
  /** The names in folder `dir`, sorted, none when it isn't there. */
@@ -4082,22 +4897,242 @@ async function osName(platform) {
4082
4897
  return platform === "win32" ? version() : null;
4083
4898
  }
4084
4899
  //#endregion
4085
- //#region src/skill-acceptance.ts
4086
- /** The file at the top of a Skill's folder that names and describes it. */
4087
- const SKILL_FILE = "SKILL.md";
4088
- /** How big a Skill's folder may be: its files' bytes added up, and how many files it holds. */
4089
- const SKILL_FOLDER_LIMITS = {
4090
- bytes: 52428800,
4091
- files: 5e3
4900
+ //#region src/workspace-images.ts
4901
+ /** The file extension of each image type. */
4902
+ const EXTENSIONS = {
4903
+ "image/png": "png",
4904
+ "image/jpeg": "jpg",
4905
+ "image/gif": "gif",
4906
+ "image/webp": "webp"
4092
4907
  };
4093
4908
  /**
4094
- * Accepts `folder` as a Skill, or says why not, in the Owner's words. A `.git` at its top (a
4095
- * clone's history, which differs on every machine) is deleted first: it isn't the Skill's.
4909
+ * Writes a document's `images` into `workspace` beside it, in order, reading image `index`'s bytes
4910
+ * with `bytesOf(index)` as it is written. Returns them as written, for what the Agent is told.
4096
4911
  */
4097
- async function acceptSkill(folder) {
4098
- await rm(join(folder, ".git"), {
4912
+ async function writeImages(workspace, { folder, stem }, images, bytesOf) {
4913
+ if (images.length > 0) await mkdir(join(workspace, folder), {
4099
4914
  recursive: true,
4100
- force: true
4915
+ mode: 448
4916
+ });
4917
+ const written = [];
4918
+ for (const [index, image] of images.entries()) {
4919
+ const file = `${stem}-${index + 1}.${EXTENSIONS[image.mimeType]}`;
4920
+ await writeFile(join(workspace, folder, file), await bytesOf(index), { mode: 384 });
4921
+ written.push({
4922
+ path: posix.join(folder, file),
4923
+ mimeType: image.mimeType,
4924
+ alt: image.alt
4925
+ });
4926
+ }
4927
+ return written;
4928
+ }
4929
+ /**
4930
+ * A document's written images as Markdown, one image link a line, each linked from `folder` of the
4931
+ * Workspace, where they are named: `![A chart](1-draft-1.png)` from `workpiece`, and
4932
+ * `![A chart](workpiece/1-draft-1.png)` from the root (`""`).
4933
+ */
4934
+ function imageLinks(images, folder) {
4935
+ return images.map((image, index) => imageLink(image, index, folder)).join("\n");
4936
+ }
4937
+ /**
4938
+ * One of a document's images as a Markdown image link from `folder` of the Workspace, image `index`
4939
+ * of the document: `![A chart](workpiece/1-draft-1.png)` from the root (`""`).
4940
+ */
4941
+ function imageLink({ path, alt }, index, folder) {
4942
+ return `![${alt ?? `Image ${index + 1}`}](${posix.relative(folder, path)})`;
4943
+ }
4944
+ /**
4945
+ * What lies beside a document with `count` images, as the instructions' line on it ends: ", with
4946
+ * its image beside it", ", with its 3 images beside it", or nothing.
4947
+ */
4948
+ function besideIt(count) {
4949
+ if (count === 0) return "";
4950
+ return `, with its ${count === 1 ? "image" : `${count} images`} beside it`;
4951
+ }
4952
+ /**
4953
+ * The image at `path` in `workspace` (from its root, or absolute), as it is now, read whole only
4954
+ * when it is within `IMAGE_LIMITS.bytes`: its size is what was read, never what a look before said,
4955
+ * so a file growing meanwhile is refused all the same.
4956
+ */
4957
+ async function readWorkspaceImage(workspace, path) {
4958
+ /** Whether the Workspace went, or only the file: what a missing file means. */
4959
+ const missing = async () => await isThere$1(workspace) ? { kind: "absent" } : { kind: "gone" };
4960
+ let file;
4961
+ try {
4962
+ file = await realpath(resolve(workspace, path));
4963
+ } catch (error) {
4964
+ if (!isMissing$1(error)) throw error;
4965
+ return missing();
4966
+ }
4967
+ const fromRoot = relative(await realpath(workspace), file);
4968
+ if (fromRoot.startsWith("..") || isAbsolute(fromRoot)) return { kind: "outside" };
4969
+ const bytes = await readAtMost(file, IMAGE_LIMITS.bytes + 1).catch((error) => {
4970
+ if (isMissing$1(error)) return null;
4971
+ throw error;
4972
+ });
4973
+ if (bytes === null) return missing();
4974
+ if (bytes.length > IMAGE_LIMITS.bytes) return { kind: "too-large" };
4975
+ const mimeType = imageTypeOf(bytes);
4976
+ return mimeType === null ? { kind: "not-an-image" } : {
4977
+ kind: "image",
4978
+ mimeType,
4979
+ bytes
4980
+ };
4981
+ }
4982
+ /** The first `most` bytes of file `path`, or all of it when it holds fewer. */
4983
+ async function readAtMost(path, most) {
4984
+ const handle = await open(path, "r");
4985
+ try {
4986
+ const bytes = Buffer.alloc(most);
4987
+ let read = 0;
4988
+ while (read < most) {
4989
+ const { bytesRead } = await handle.read(bytes, read, most - read, read);
4990
+ if (bytesRead === 0) break;
4991
+ read += bytesRead;
4992
+ }
4993
+ return bytes.subarray(0, read);
4994
+ } finally {
4995
+ await handle.close();
4996
+ }
4997
+ }
4998
+ /**
4999
+ * The image at `path` in `workspace`, for the Owner, as it is now (`readWorkspaceImage`); `gone`
5000
+ * once the Workspace is.
5001
+ */
5002
+ async function imageInWorkspace(workspace, path) {
5003
+ const read = await readWorkspaceImage(workspace, path);
5004
+ if (read.kind === "image" || read.kind === "gone") return read;
5005
+ return {
5006
+ kind: "missing",
5007
+ why: read.kind === "absent" ? "the image was deleted from its Workspace" : read.kind === "outside" ? "the image was moved out of its Workspace" : "the image in its Workspace was changed since"
5008
+ };
5009
+ }
5010
+ /** Whether `folder` is there. */
5011
+ async function isThere$1(folder) {
5012
+ try {
5013
+ return (await stat(folder)).isDirectory();
5014
+ } catch (error) {
5015
+ if (isMissing$1(error)) return false;
5016
+ throw error;
5017
+ }
5018
+ }
5019
+ /** Whether `error` says a file isn't there. */
5020
+ function isMissing$1(error) {
5021
+ return error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR");
5022
+ }
5023
+ //#endregion
5024
+ //#region src/prompt-images.ts
5025
+ const MiB = 1048576;
5026
+ /**
5027
+ * How much of the Owner's images each Runtime takes inside one prompt. `largest` is measured on
5028
+ * the real CLIs (`pnpm probe:runtimes --spend-tokens`, 2026-11-04, #182): Claude Code 2.1.310 (on
5029
+ * haiku), Codex 0.164.0 and Kimi Code 2.3.0 (on their own default models) each saw every size the
5030
+ * probe tries, up to 10 MiB, the most an image may be (`IMAGE_LIMITS.bytes`), Claude Code's past
5031
+ * the 5 MB of base64 per image Anthropic's API takes (docs/research/real-cli-probe.md says what that
5032
+ * shows). `count` and `together` are fixed bounds, not measured: 20 images, past which
5033
+ * Anthropic's API lowers the pixels it takes of each, and 15 MiB, so that a prompt's images stay
5034
+ * well within a request's 32 MB there.
5035
+ */
5036
+ const PROMPT_IMAGE_LIMITS = {
5037
+ "claude-code": {
5038
+ largest: 10 * MiB,
5039
+ count: 20,
5040
+ together: 15 * MiB
5041
+ },
5042
+ codex: {
5043
+ largest: 10 * MiB,
5044
+ count: 20,
5045
+ together: 15 * MiB
5046
+ },
5047
+ "kimi-code": {
5048
+ largest: 10 * MiB,
5049
+ count: 20,
5050
+ together: 15 * MiB
5051
+ }
5052
+ };
5053
+ /**
5054
+ * What `prompt` gives a Session of `runtime` in Workspace `cwd`: its words, then a paragraph per
5055
+ * group of images naming each, and the images that go inside it, those its Runtime takes
5056
+ * (`takesImages`) within `limits`, in order; none when the Runtime `refused` them in this prompt
5057
+ * already. Words alone go as they are.
5058
+ */
5059
+ async function deliveredPrompt(prompt, how) {
5060
+ if (typeof prompt === "string") return {
5061
+ input: prompt,
5062
+ images: []
5063
+ };
5064
+ const name = RUNTIME_NAMES[how.runtime];
5065
+ const { largest, count, together } = how.limits;
5066
+ const inside = [];
5067
+ let carried = 0;
5068
+ /** Why `image` can't go inside the prompt (`""` for a file it can't read), or null when it can. */
5069
+ const heldBack = async ({ path }) => {
5070
+ if (how.refused) return `as ${name} refused it inside the prompt`;
5071
+ if (!how.takesImages) return `as ${name} takes no images inside a prompt`;
5072
+ const bytes = await sizeOf$1(join(how.cwd, path));
5073
+ if (bytes === null) return "";
5074
+ if (bytes > largest) return `larger than ${name} takes inside a prompt (${sizeWords(largest)})`;
5075
+ if (inside.length >= count) return `past the ${count} images ${name} takes inside one prompt`;
5076
+ if (carried + bytes > together) return `past the ${sizeWords(together)} of images ${name} takes inside one prompt`;
5077
+ carried += bytes;
5078
+ return null;
5079
+ };
5080
+ const paragraphs = [prompt.text];
5081
+ for (const { of, images } of prompt.images) {
5082
+ if (images.length === 0) continue;
5083
+ const lines = [];
5084
+ for (const [index, image] of images.entries()) {
5085
+ const why = await heldBack(image);
5086
+ const link = imageLink(image, index, "");
5087
+ if (why === null) {
5088
+ inside.push({
5089
+ path: join(how.cwd, image.path),
5090
+ mediaType: image.mimeType
5091
+ });
5092
+ lines.push(`- ${link}: with this prompt`);
5093
+ } else lines.push(`- ${link}: a file only${why ? `, ${why}` : ""}: open it to see it`);
5094
+ }
5095
+ const whose = `${of.charAt(0).toUpperCase()}${of.slice(1)}'s`;
5096
+ const which = images.length === 1 ? "image, a file" : `${images.length} images, each a file`;
5097
+ paragraphs.push(`${whose} ${which} in this Workspace:\n\n${lines.join("\n")}`);
5098
+ }
5099
+ return {
5100
+ input: paragraphs.join("\n\n"),
5101
+ images: inside
5102
+ };
5103
+ }
5104
+ /** How many bytes the file at `path` holds, or null when it can't be read. */
5105
+ async function sizeOf$1(path) {
5106
+ try {
5107
+ const found = await stat(path);
5108
+ return found.isFile() ? found.size : null;
5109
+ } catch {
5110
+ return null;
5111
+ }
5112
+ }
5113
+ /** A size as the prompt says it: "3 MiB", "1.5 MiB", "200 KiB", "900 bytes". */
5114
+ function sizeWords(bytes) {
5115
+ if (bytes >= MiB) return `${Math.round(bytes / MiB * 100) / 100} MiB`;
5116
+ if (bytes >= 1024) return `${Math.round(bytes / 1024 * 100) / 100} KiB`;
5117
+ return `${bytes} bytes`;
5118
+ }
5119
+ //#endregion
5120
+ //#region src/skill-acceptance.ts
5121
+ /** The file at the top of a Skill's folder that names and describes it. */
5122
+ const SKILL_FILE = "SKILL.md";
5123
+ /** How big a Skill's folder may be: its files' bytes added up, and how many files it holds. */
5124
+ const SKILL_FOLDER_LIMITS = {
5125
+ bytes: 52428800,
5126
+ files: 5e3
5127
+ };
5128
+ /**
5129
+ * Accepts `folder` as a Skill, or says why not, in the Owner's words. A `.git` at its top (a
5130
+ * clone's history, which differs on every machine) is deleted first: it isn't the Skill's.
5131
+ */
5132
+ async function acceptSkill(folder) {
5133
+ await rm(join(folder, ".git"), {
5134
+ recursive: true,
5135
+ force: true
4101
5136
  });
4102
5137
  const walked = await entriesOf(folder);
4103
5138
  if (typeof walked === "string") return {
@@ -4218,8 +5253,15 @@ async function digestOf(entries) {
4218
5253
  //#region src/routing-skills.ts
4219
5254
  /** Where a repository's own Skills are looked for, in this order, each Skill a folder in them. */
4220
5255
  const REPOSITORY_SKILL_FOLDERS = [".claude/skills", ".agents/skills"];
5256
+ /** A repository's own instructions for agents, looked for at its root only, in this order. */
5257
+ const REPOSITORY_INSTRUCTION_FILES = ["AGENTS.md", "CLAUDE.md"];
4221
5258
  /** Where a Session's routing Skills are written in its Workspace, a folder each, before each launch. */
4222
5259
  const ROUTING_SKILLS_FOLDER = join(".moonmill", "routing");
5260
+ /**
5261
+ * The routing AGENTS.md, from the Workspace's root: in the routing folder beside the routing
5262
+ * Skills, listing the repositories' own instructions, written with them before each launch.
5263
+ */
5264
+ const ROUTING_AGENTS_FILE = join(ROUTING_SKILLS_FOLDER, "AGENTS.md");
4223
5265
  /** How much of a `SKILL.md` is read for its frontmatter, which comes at its top. */
4224
5266
  const FRONTMATTER_BYTES = 65536;
4225
5267
  /**
@@ -4235,8 +5277,10 @@ function routingSkillName(folder) {
4235
5277
  * its own, written anew into the Workspace's `ROUTING_SKILLS_FOLDER`. A routing Skill whose name
4236
5278
  * one of `skills` has already (as names compare, `skillNameKey`), or an earlier repository's, is
4237
5279
  * left out, each Skill it would have listed in `leftOut`, saying which has its name: the Agent's
4238
- * own Skill wins it. Undefined `repositories`: nothing is looked for, and `skills` are given as
4239
- * they are; otherwise what was laid out before is replaced, `skills` or not.
5280
+ * own Skill wins it. The routing AGENTS.md is written there too (`ROUTING_AGENTS_FILE`), when any
5281
+ * of `repositories` carries instructions of its own. Undefined `repositories`: nothing is looked
5282
+ * for, and `skills` are given as they are; otherwise what was laid out before is replaced, `skills`
5283
+ * or not.
4240
5284
  */
4241
5285
  async function withRoutingSkills(workspace, repositories, skills) {
4242
5286
  if (repositories === void 0) return {
@@ -4248,6 +5292,7 @@ async function withRoutingSkills(workspace, repositories, skills) {
4248
5292
  recursive: true,
4249
5293
  force: true
4250
5294
  });
5295
+ await writeRoutingAgents(workspace, repositories);
4251
5296
  const given = [...skills ?? []];
4252
5297
  const taken = new Map(given.map(({ name }) => [skillNameKey(name), null]));
4253
5298
  const leftOut = [];
@@ -4300,6 +5345,68 @@ async function withRoutingSkills(workspace, repositories, skills) {
4300
5345
  };
4301
5346
  }
4302
5347
  /**
5348
+ * Writes the routing AGENTS.md into `workspace` (`ROUTING_AGENTS_FILE`), listing each of
5349
+ * `repositories` (the folders of `workspace` they were checked out into) that carries instructions
5350
+ * of its own, in that order; nothing when none does.
5351
+ */
5352
+ async function writeRoutingAgents(workspace, repositories) {
5353
+ const routed = [];
5354
+ for (const folder of repositories) {
5355
+ const files = await instructionFilesOf(join(workspace, folder));
5356
+ if (files.length > 0) routed.push({
5357
+ folder,
5358
+ files
5359
+ });
5360
+ }
5361
+ if (routed.length === 0) return;
5362
+ await mkdir(join(workspace, ROUTING_SKILLS_FOLDER), {
5363
+ recursive: true,
5364
+ mode: 448
5365
+ });
5366
+ await writeFile(join(workspace, ROUTING_AGENTS_FILE), routingAgentsFile(routed), { mode: 384 });
5367
+ }
5368
+ /**
5369
+ * The instruction files at the root of the repository at `repository`, each by its path there
5370
+ * (`<repository>/AGENTS.md`): each of `REPOSITORY_INSTRUCTION_FILES` that is a file, through any
5371
+ * links, wherever they lead. One that is a link to another listed already (a `CLAUDE.md` leading
5372
+ * to the `AGENTS.md` beside it, say) is listed once. None for a folder that isn't there.
5373
+ */
5374
+ async function instructionFilesOf(repository) {
5375
+ if (!await isFolder(repository)) return [];
5376
+ const files = [];
5377
+ const seen = /* @__PURE__ */ new Set();
5378
+ for (const name of REPOSITORY_INSTRUCTION_FILES) {
5379
+ const file = join(repository, name);
5380
+ if (!await isFile(file)) continue;
5381
+ const real = await realpath(file);
5382
+ if (seen.has(real)) continue;
5383
+ seen.add(real);
5384
+ files.push(file);
5385
+ }
5386
+ return files;
5387
+ }
5388
+ /**
5389
+ * The routing AGENTS.md listing `routed`: each repository's folder, the absolute path of each of
5390
+ * its instruction files, which the Agent reads where it is so that what it refers to is found from
5391
+ * there, and to read them before working in that folder and follow them while working there.
5392
+ */
5393
+ function routingAgentsFile(routed) {
5394
+ return [
5395
+ "# The repositories' own instructions",
5396
+ "",
5397
+ "These repositories in your Workspace come with instructions of their own for agents working in them. Read each file where it is, at the path given: what it refers to is found from there.",
5398
+ "",
5399
+ ...routed.flatMap(({ folder, files }) => [
5400
+ `## \`${folder}/\``,
5401
+ "",
5402
+ `Before you work in \`${folder}/\`, read ${files.length === 1 ? "this file" : "these files"}, and follow ${files.length === 1 ? "it" : "them"} while you work there:`,
5403
+ "",
5404
+ ...files.map((file) => `- ${file}`),
5405
+ ""
5406
+ ])
5407
+ ].join("\n");
5408
+ }
5409
+ /**
4303
5410
  * The Skills the repository at `repository` carries: each folder of its `REPOSITORY_SKILL_FOLDERS`
4304
5411
  * holding a `SKILL.md` (a hidden one apart), in that order, by name within each. One reached twice
4305
5412
  * (`.agents/skills` a link to `.claude/skills`, say) is listed once; one the Runtimes wouldn't take,
@@ -4477,6 +5584,58 @@ function recordedSkillsFile(record) {
4477
5584
  * when the folder holds none (the Session was never recorded here).
4478
5585
  */
4479
5586
  async function readSessionRecord(folder) {
5587
+ const files = await segmentFiles(folder);
5588
+ if (files === null) return null;
5589
+ const items = [];
5590
+ for (const [index, file] of files.entries()) {
5591
+ const path = join(folder, file);
5592
+ const { items: segment } = segmentOf(index + 1, file, await readFile(path, "utf8"), await skillsNotedFor(path));
5593
+ items.push(...segment);
5594
+ }
5595
+ return withinLimit(items);
5596
+ }
5597
+ /**
5598
+ * One image the record of the Session filed in `folder` names (`image`): a prompt's from the
5599
+ * Session's Workspace, `gone` once the Workspace was swept, or a tool's from the record. Either
5600
+ * must be a png, jpeg, gif or webp within `IMAGE_LIMITS.bytes`.
5601
+ */
5602
+ async function recordImageOf(folder, image) {
5603
+ const none = {
5604
+ kind: "missing",
5605
+ why: "its record names no such image"
5606
+ };
5607
+ const file = (await segmentFiles(folder))?.[image.segment - 1];
5608
+ if (file === void 0) return none;
5609
+ const text = await readFile(join(folder, file), "utf8").catch((error) => {
5610
+ if (isMissing$1(error)) return null;
5611
+ throw error;
5612
+ });
5613
+ if (text === null) return none;
5614
+ const { images, workspace } = segmentOf(image.segment, file, text, NOTHING_NOTED);
5615
+ const source = images[image.index];
5616
+ if (source === void 0) return none;
5617
+ if (source.kind === "file") return workspace === null ? {
5618
+ kind: "missing",
5619
+ why: "its record doesn't say which Workspace the image was in"
5620
+ } : imageInWorkspace(workspace, source.path);
5621
+ const bytes = Buffer.from(source.data, "base64");
5622
+ if (bytes.length > IMAGE_LIMITS.bytes) return {
5623
+ kind: "missing",
5624
+ why: "the image is larger than the Owner is shown"
5625
+ };
5626
+ const mimeType = imageTypeOf(bytes);
5627
+ if (mimeType === null) return {
5628
+ kind: "missing",
5629
+ why: "the image isn't a PNG, JPEG, GIF or WebP"
5630
+ };
5631
+ return {
5632
+ kind: "image",
5633
+ mimeType,
5634
+ bytes
5635
+ };
5636
+ }
5637
+ /** The files of the Session's segments in `folder`, in the order they opened; null for none. */
5638
+ async function segmentFiles(folder) {
4480
5639
  let files;
4481
5640
  try {
4482
5641
  files = (await readdir(folder, { withFileTypes: true })).filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl")).map((entry) => entry.name).toSorted(byOpening);
@@ -4484,13 +5643,7 @@ async function readSessionRecord(folder) {
4484
5643
  if (error instanceof Error && "code" in error && error.code === "ENOENT") return null;
4485
5644
  throw error;
4486
5645
  }
4487
- if (files.length === 0) return null;
4488
- const items = [];
4489
- for (const [index, file] of files.entries()) {
4490
- const path = join(folder, file);
4491
- items.push(...segmentOf(index + 1, file, await readFile(path, "utf8"), await skillsNotedFor(path)));
4492
- }
4493
- return withinLimit(items);
5646
+ return files.length === 0 ? null : files;
4494
5647
  }
4495
5648
  /** Files in the order their segments opened: by the time their name starts with, then by name. */
4496
5649
  function byOpening(a, b) {
@@ -4512,6 +5665,11 @@ const skillsNoted = z.union([z.object({
4512
5665
  skills,
4513
5666
  leftOut: []
4514
5667
  }))]);
5668
+ /** Nothing noted beside a segment's log. */
5669
+ const NOTHING_NOTED = {
5670
+ skills: [],
5671
+ leftOut: []
5672
+ };
4515
5673
  /** What is noted beside segment `record`'s log: nothing where nothing is, or it can't be read. */
4516
5674
  async function skillsNotedFor(record) {
4517
5675
  const text = await readFile(recordedSkillsFile(record), "utf8").catch(() => null);
@@ -4542,6 +5700,20 @@ function segmentOf(number, file, text, noted) {
4542
5700
  const sessionId = typeof header?.sessionId === "string" ? header.sessionId : file.replace(/^\d+-|\.jsonl$/g, "");
4543
5701
  const startedAt = typeof header?.startedAt === "number" ? header.startedAt : openedAtOf(file);
4544
5702
  const view = viewOf(records, file);
5703
+ const endedAt = /* @__PURE__ */ new Map();
5704
+ for (const record of records) {
5705
+ if (record.agentPath.length > 0) continue;
5706
+ for (const event of eventsOf(record)) if (event.kind === "tool_call_ended") endedAt.set(event.callId, record.seq);
5707
+ }
5708
+ /** The items that name images, each with them and where in the log they came. */
5709
+ const arriving = [];
5710
+ const name = (item, sources, at) => {
5711
+ if (sources.length > 0) arriving.push({
5712
+ item,
5713
+ sources,
5714
+ at: at ?? Number.MAX_SAFE_INTEGER
5715
+ });
5716
+ };
4545
5717
  const items = [{
4546
5718
  kind: "segment",
4547
5719
  number,
@@ -4552,19 +5724,55 @@ function segmentOf(number, file, text, noted) {
4552
5724
  skills: noted.skills,
4553
5725
  skillsLeftOut: noted.leftOut
4554
5726
  }];
4555
- for (const message of view.messages) if (message.kind === "input") items.push({
4556
- kind: "prompt",
4557
- text: cut(message.input.input, RECORD_LIMITS.prompt)
4558
- });
4559
- else if (message.kind === "turn") {
4560
- for (const section of message.sections) if (section.agentPath.length === 0) items.push(...section.parts.flatMap(itemsOf));
5727
+ const workspace = typeof header?.cwd === "string" && header.cwd !== "" ? header.cwd : null;
5728
+ for (const message of view.messages) if (message.kind === "input") {
5729
+ const prompt = {
5730
+ kind: "prompt",
5731
+ text: cut(message.input.input, RECORD_LIMITS.prompt)
5732
+ };
5733
+ items.push(prompt);
5734
+ const carried = (message.input.images ?? []).map(({ path }) => path);
5735
+ const sent = workspace === null ? [] : chatImagesOf(message.input.input, workspace);
5736
+ name(prompt, (sent.length > 0 ? sent : carried).map((path) => ({
5737
+ kind: "file",
5738
+ path
5739
+ })), message.input.attempts.at(-1)?.request.seq);
5740
+ } else if (message.kind === "turn") {
5741
+ for (const section of message.sections) if (section.agentPath.length === 0) for (const part of section.parts) items.push(...itemsOf(part, (item, sources) => name(item, sources, part.kind === "tool" ? endedAt.get(part.callId) : void 0)));
4561
5742
  if (message.outcome) items.push({
4562
5743
  kind: "turn-ended",
4563
5744
  outcome: message.outcome.kind,
4564
5745
  reason: message.outcome.kind === "failed" ? cut(message.outcome.reason, RECORD_LIMITS.reason).text : null
4565
5746
  });
4566
5747
  }
4567
- return items;
5748
+ const images = [];
5749
+ for (const { item, sources } of arriving.toSorted((a, b) => a.at - b.at)) {
5750
+ const named = [];
5751
+ for (const source of sources) {
5752
+ const image = {
5753
+ segment: number,
5754
+ index: images.length
5755
+ };
5756
+ images.push(source);
5757
+ if (named.length < RECORD_LIMITS.images && recordImage.safeParse(image).success) named.push(image);
5758
+ }
5759
+ if (named.length > 0) item.images = named;
5760
+ }
5761
+ return {
5762
+ items,
5763
+ images,
5764
+ workspace
5765
+ };
5766
+ }
5767
+ /** A link a chat turn's prompt makes to an image of its message, in the turn's folder. */
5768
+ const CHAT_IMAGE_LINK = new RegExp(String.raw`\]\((${WORKSPACE_FOLDERS.chatTurns}/\d+/[^)\s/]+)\)`, "g");
5769
+ /**
5770
+ * The files of the images a chat turn's prompt `input` says its message came with, in order, each
5771
+ * where it is in `workspace`: every one, inside the prompt or a file only. None for another prompt.
5772
+ */
5773
+ function chatImagesOf(input, workspace) {
5774
+ const linked = [...input.matchAll(CHAT_IMAGE_LINK)].flatMap((match) => match[1] ?? []);
5775
+ return [...new Set(linked)].map((path) => join(workspace, path));
4568
5776
  }
4569
5777
  /** A line of a log, or null where it isn't one (the half-written last line of a daemon that died). */
4570
5778
  function lineOf(line) {
@@ -4576,8 +5784,11 @@ function lineOf(line) {
4576
5784
  return null;
4577
5785
  }
4578
5786
  }
4579
- /** The record items one part of a turn stands for. */
4580
- function itemsOf(part) {
5787
+ /**
5788
+ * The record items one part of a turn stands for, each with the images it returned handed to
5789
+ * `name`, which numbers them among its segment's.
5790
+ */
5791
+ function itemsOf(part, name) {
4581
5792
  switch (part.kind) {
4582
5793
  case "text": return [{
4583
5794
  kind: "text",
@@ -4589,14 +5800,19 @@ function itemsOf(part) {
4589
5800
  }];
4590
5801
  case "tool": {
4591
5802
  const output = toolResultText(part.content) ?? part.output;
4592
- return [{
5803
+ const tool = {
4593
5804
  kind: "tool",
4594
5805
  callId: cut(part.callId, 200).text || "call",
4595
5806
  tool: cut(part.tool, RECORD_LIMITS.tool).text || "tool",
4596
5807
  input: part.input === void 0 ? null : cut(part.input, RECORD_LIMITS.toolInput),
4597
5808
  result: part.result,
4598
5809
  output: output === void 0 ? null : cut(output, RECORD_LIMITS.toolOutput)
4599
- }];
5810
+ };
5811
+ name(tool, (part.content ?? []).flatMap((each) => each.type === "image" ? [{
5812
+ kind: "bytes",
5813
+ data: each.data
5814
+ }] : []));
5815
+ return [tool];
4600
5816
  }
4601
5817
  }
4602
5818
  return [];
@@ -4634,14 +5850,17 @@ const TEXT_HOLD_MS = 100;
4634
5850
  * The Runtime driver over oar, for the real agent CLIs this machine has (ADR-0009), with the
4635
5851
  * Owner's own setup in this machine's folders (`thisMachine`) unless told otherwise.
4636
5852
  */
4637
- function oarRuntimeDriver({ machine = thisMachine() } = {}) {
5853
+ function oarRuntimeDriver({ machine = thisMachine(), ...options } = {}) {
4638
5854
  return runtimeDriver(async (runtime) => {
4639
5855
  const { defaultRuntimes } = await import("@botiverse/oar");
4640
5856
  return defaultRuntimes.require(OAR_RUNTIME_IDS[runtime]);
4641
- }, { machine });
5857
+ }, {
5858
+ machine,
5859
+ ...options
5860
+ });
4642
5861
  }
4643
5862
  /** The Runtime driver over the oar Runtimes `lookup` finds, on `machine`. */
4644
- function runtimeDriver(lookup, { machine }) {
5863
+ function runtimeDriver(lookup, { machine, promptImageLimits = {}, retriesRefusedImages = true }) {
4645
5864
  return { async open({ runtime: id, cwd, instructions, mcpServers, skills: own, repositories, model, resume, recordIn, onProblem = () => {} }) {
4646
5865
  const runtime = await lookup(id);
4647
5866
  const installation = await runtime.installation?.();
@@ -4672,7 +5891,12 @@ function runtimeDriver(lookup, { machine }) {
4672
5891
  skills: recordedSkillsOf(skills),
4673
5892
  leftOut,
4674
5893
  onProblem
4675
- }), onProblem);
5894
+ }), onProblem, {
5895
+ runtime: id,
5896
+ cwd,
5897
+ limits: promptImageLimits[id] ?? PROMPT_IMAGE_LIMITS[id],
5898
+ retriesRefusedImages
5899
+ });
4676
5900
  } };
4677
5901
  }
4678
5902
  /**
@@ -4786,12 +6010,37 @@ async function newRecordFile(dir, sessionId) {
4786
6010
  }
4787
6011
  }
4788
6012
  }
4789
- function driverSession(session, recording, onProblem) {
6013
+ /**
6014
+ * Whether a prompt that carried images was refused in a way they may have caused: oar refused it
6015
+ * (an image it couldn't deliver, or the Runtime's own no), or the provider failed the turn as too
6016
+ * large or an invalid request. A prompt refused for being busy, or a Session that ended, isn't.
6017
+ */
6018
+ function refusedItsImages(run) {
6019
+ if (run.kind === "rejected") return run.code === "unsupported" || run.code === "runtime_refused" || run.code === "error";
6020
+ return run.kind === "ended" && run.outcome.kind === "failed" && (run.outcome.failure === "input_too_large" || run.outcome.failure === "invalid_request");
6021
+ }
6022
+ function driverSession(session, recording, onProblem, prompted) {
4790
6023
  return {
4791
6024
  id: session.id,
4792
6025
  recording: recording?.path ?? null,
4793
- async run(input, signal) {
4794
- const run = await promptAndWait(session, input, signal ? { signal } : {});
6026
+ async run(prompt, signal) {
6027
+ const { retriesRefusedImages, ...how } = prompted;
6028
+ const deliver = async (refused) => {
6029
+ const { input, images } = await deliveredPrompt(prompt, {
6030
+ ...how,
6031
+ takesImages: session.capabilities.images,
6032
+ refused
6033
+ });
6034
+ return {
6035
+ run: await promptAndWait(session, input, {
6036
+ ...signal ? { signal } : {},
6037
+ ...images.length > 0 ? { images } : {}
6038
+ }),
6039
+ carried: images.length > 0
6040
+ };
6041
+ };
6042
+ const first = await deliver(false);
6043
+ const { run } = retriesRefusedImages && first.carried && refusedItsImages(first.run) ? await deliver(true) : first;
4795
6044
  if (run.kind === "rejected") return {
4796
6045
  kind: "failed",
4797
6046
  failure: run.code,
@@ -4845,7 +6094,7 @@ function driverSession(session, recording, onProblem) {
4845
6094
  //#endregion
4846
6095
  //#region src/official-skills.ts
4847
6096
  /** The Version an official Skill is recorded at: this daemon's, by its protocol. */
4848
- const OFFICIAL_SKILLS_VERSION = `protocol 24`;
6097
+ const OFFICIAL_SKILLS_VERSION = `protocol 25`;
4849
6098
  /** What every official Skill's instructions end with: no credential, and how to say it failed. */
4850
6099
  const NEVER = `Never ask for a password or token, and never write one into a link, a command or a
4851
6100
  file. If the source can't be reached, the Version isn't there, or there's no \`SKILL.md\` where the
@@ -5198,7 +6447,7 @@ async function openMillwright(options) {
5198
6447
  const skills = await writeOfficialSkills(folders.skills);
5199
6448
  for (const binding of setup?.skills ?? []) {
5200
6449
  const folder = ownCopyFolder(stateDir, binding.skillId, binding.version.digest);
5201
- if (nameTakenBy(binding.name, skills.map(({ name }) => name)) !== null || !await isThere$1(folder)) continue;
6450
+ if (nameTakenBy(binding.name, skills.map(({ name }) => name)) !== null || !await isThere$2(folder)) continue;
5202
6451
  const { version, digest } = binding.version;
5203
6452
  skills.push({
5204
6453
  name: binding.name,
@@ -5468,12 +6717,12 @@ async function openMillwright(options) {
5468
6717
  for (const copy of filed) if (now.skills.some((each) => copy === ownCopyFolder(stateDir, each.skillId, each.version.digest))) filed.delete(copy);
5469
6718
  for (const binding of now.skills) {
5470
6719
  const folder = ownCopyFolder(stateDir, binding.skillId, binding.version.digest);
5471
- if (owning.has(folder) || await isThere$1(folder)) continue;
6720
+ if (owning.has(folder) || await isThere$2(folder)) continue;
5472
6721
  if (binding.fromCheck !== null && await adopt(binding, binding.fromCheck, folder)) continue;
5473
6722
  owning.add(folder);
5474
6723
  enqueue("own", async () => {
5475
6724
  try {
5476
- if (stopping || killed || !stillNamed(binding) || await isThere$1(folder)) return;
6725
+ if (stopping || killed || !stillNamed(binding) || await isThere$2(folder)) return;
5477
6726
  const { version, digest } = binding.version;
5478
6727
  const installed = await installInto({
5479
6728
  label: binding.skillId,
@@ -5913,9 +7162,9 @@ function createSkillCopies(options) {
5913
7162
  * the Millwright installs. Why it couldn't, in the Owner's words, or null once it is there.
5914
7163
  */
5915
7164
  const makeCopy = async (agentId, binding, folder, signal) => {
5916
- if (await isThere$1(folder)) return null;
7165
+ if (await isThere$2(folder)) return null;
5917
7166
  if (binding.fromCheck !== null && await adopt(agentId, binding.skillId, binding.fromCheck, folder)) return null;
5918
- if (await isThere$1(folder)) return null;
7167
+ if (await isThere$2(folder)) return null;
5919
7168
  const { version, digest } = binding.version;
5920
7169
  const installed = await millwright.install({
5921
7170
  label: binding.skillId,
@@ -6129,7 +7378,7 @@ function createSkillCopies(options) {
6129
7378
  for (const { agentId, skills } of agents) for (const binding of skills) {
6130
7379
  if (binding.fromCheck === null) continue;
6131
7380
  const folder = agentCopyFolder(stateDir, agentId, binding.skillId, binding.version.digest);
6132
- if (taking.has(folder) || installing.has(folder) || await isThere$1(folder)) continue;
7381
+ if (taking.has(folder) || installing.has(folder) || await isThere$2(folder)) continue;
6133
7382
  hold(folder);
6134
7383
  try {
6135
7384
  await adopt(agentId, binding.skillId, binding.fromCheck, folder);
@@ -6300,7 +7549,7 @@ const HAND_OFF_GUIDANCE = [
6300
7549
  `When the work is done, call the \`${HAND_OFF_TOOL}\` tool of the \`${FACTORY_MCP_NAME}\` MCP server exactly once, as your last action, then stop. Its arguments:`,
6301
7550
  "",
6302
7551
  "- `markdown`: your Hand-off, a document in Markdown: what you did, what you found, and what whoever reads it next needs to know.",
6303
- `- \`images\` (optional): up to ${HAND_OFF_LIMITS.images} image files in this Workspace (${HAND_OFF_IMAGE_TYPES.map((type) => type.replace("image/", "").toUpperCase()).join(", ")}; at most ${HAND_OFF_LIMITS.imageBytes / 1024} KiB each), as \`{ "path": "…", "alt": "…" }\`.`,
7552
+ `- \`images\` (optional): up to ${IMAGE_LIMITS.images} image files in this Workspace (${IMAGE_TYPES.map((type) => type.replace("image/", "").toUpperCase()).join(", ")}; at most ${IMAGE_LIMITS.bytes / 1024 / 1024} MiB each), as \`{ "path": "…", "alt": "…" }\`.`,
6304
7553
  `- \`references\` (optional): up to ${HAND_OFF_LIMITS.references} materials you produced, each \`{ "label": "…", "target": "…" }\` with a URL or a pinned identifier as the target, such as a pushed commit.`,
6305
7554
  "",
6306
7555
  "A Hand-off is a document, not a delivery of files: put materials (code, data, archives) where they belong, such as a pushed branch, and reference them. Nothing in this Workspace is kept for whoever comes next."
@@ -6310,7 +7559,7 @@ const HAND_OFF_GUIDANCE = [
6310
7559
  * the tool returns at once, and the reply continues the Session (ADR-0009).
6311
7560
  */
6312
7561
  const HELP_GUIDANCE = [
6313
- `If you can't go on without the Owner (something only they know or can decide), call the \`${HELP_TOOL}\` tool of the \`${FACTORY_MCP_NAME}\` MCP server with your \`question\`, in Markdown: what you need to know, and what you found that makes you ask.`,
7562
+ `If you can't go on without the Owner (something only they know or can decide), call the \`${HELP_TOOL}\` tool of the \`${FACTORY_MCP_NAME}\` MCP server with your \`question\`, in Markdown: what you need to know, and what you found that makes you ask. To show the Owner what you see, add \`images\`: up to ${IMAGE_LIMITS.images} image files in this Workspace, as \`{ "path": "…", "alt": "…" }\`; the Owner sees them as they are when you ask.`,
6314
7563
  "",
6315
7564
  "It returns at once. Then end your turn without handing off: the Owner's reply continues this Session where you left off, as your next prompt. Ask one question at a time, and only when you're stuck."
6316
7565
  ].join("\n");
@@ -6322,6 +7571,7 @@ const JUDGE_GUIDANCE = [
6322
7571
  "",
6323
7572
  "- `outcome`: `\"passed\"` when the Hand-off meets every criterion, `\"failed\"` when it misses any.",
6324
7573
  `- \`annotations\` (when it failed): Markdown saying which criteria the Hand-off doesn't meet and why, for whoever works on it next and for the Owner; at most ${HAND_OFF_LIMITS.markdown.toLocaleString("en")} characters.`,
7574
+ `- \`images\` (optional, when it failed): up to ${IMAGE_LIMITS.images} image files in this Workspace that show what is wrong, such as a screenshot (${IMAGE_TYPES.map((type) => type.replace("image/", "").toUpperCase()).join(", ")}; at most ${IMAGE_LIMITS.bytes / 1024 / 1024} MiB each), as \`{ "path": "…", "alt": "…" }\`; they go beside the annotations.`,
6325
7575
  "",
6326
7576
  "Judge the Hand-off as it is: don't redo the work or fix it, and change nothing it references. Reach the materials it references the way this machine can."
6327
7577
  ].join("\n");
@@ -6342,40 +7592,76 @@ const WORKPIECE_DIR = WORKSPACE_FOLDERS.workpiece;
6342
7592
  const UNDER_CHECK_DIR = WORKSPACE_FOLDERS.underCheck;
6343
7593
  /** The folder of a Rework's Workspace holding the Hand-off of the attempt it follows. */
6344
7594
  const PREVIOUS_ATTEMPT_DIR = WORKSPACE_FOLDERS.previousAttempt;
6345
- /** The file extension of each image type a Hand-off may carry. */
6346
- const IMAGE_EXTENSIONS = {
6347
- "image/png": "png",
6348
- "image/jpeg": "jpg",
6349
- "image/gif": "gif",
6350
- "image/webp": "webp"
7595
+ /** A Workpiece written as its brief alone, with no images: nothing in the Workspace. */
7596
+ const NOTHING_WRITTEN = {
7597
+ brief: null,
7598
+ handOffs: []
6351
7599
  };
6352
7600
  /**
6353
- * Writes the Workpiece as it stands into a Session's Workspace (spec stories 24 and 35): when
6354
- * earlier stations' Hand-offs were accepted, `workpiece/` holds its brief and each of those
6355
- * Hand-offs as a Markdown file, with the Hand-off's images beside it, all fetched from the Platform.
6356
- * Returns the files in Pipeline order, for the instructions to point at: none at the first station,
6357
- * whose Workpiece is its brief alone.
7601
+ * Writes the Workpiece as it stands into a Session's Workspace (spec stories 24 and 35, and #172's
7602
+ * 28-30): when its brief carries images or earlier stations' Hand-offs were accepted, `workpiece/`
7603
+ * holds its brief as a Markdown file with the brief's images beside it (`brief-1.png`…), read from
7604
+ * the image store, and each of those Hand-offs as a Markdown file, with the Hand-off's images beside
7605
+ * it, all fetched from the Platform. Nothing at the first station for a brief without images: its
7606
+ * Workpiece is its brief alone, in the instructions.
6358
7607
  */
6359
7608
  async function writeWorkpiece(workspace, task, documents) {
6360
- const { handOffs } = task.workpiece;
6361
- if (handOffs.length === 0) return [];
7609
+ const { handOffs, images } = task.workpiece;
7610
+ if (handOffs.length === 0 && images.length === 0) return NOTHING_WRITTEN;
6362
7611
  const dir = join(workspace, WORKPIECE_DIR);
6363
7612
  await mkdir(dir, { mode: 448 });
6364
- await writeFile(join(dir, "brief.md"), `${task.workpiece.brief}\n`, { mode: 384 });
6365
- const files = [{
7613
+ const briefImages = await writeBriefImages(workspace, task, documents);
7614
+ const text = [task.workpiece.brief, ...briefImages.length > 0 ? ["## Images", imageLinks(briefImages, WORKPIECE_DIR)] : []].join("\n\n");
7615
+ await writeFile(join(dir, "brief.md"), `${text}\n`, { mode: 384 });
7616
+ const brief = {
6366
7617
  path: `${WORKPIECE_DIR}/brief.md`,
6367
- description: "the brief"
6368
- }];
7618
+ description: `the brief${besideIt(briefImages.length)}`,
7619
+ images: briefImages
7620
+ };
7621
+ const files = [];
6369
7622
  for (const [index, made] of handOffs.entries()) {
6370
7623
  const document = await documents.handOff(task.task.id, made.id);
6371
- const stem = `${index + 1}-${slugOf(made.workstation.name)}`;
6372
- const images = await writeHandOff(dir, stem, document, (number) => documents.image(task.task.id, made.id, number));
7624
+ const beside = {
7625
+ folder: WORKPIECE_DIR,
7626
+ stem: `${index + 1}-${slugOf(made.workstation.name)}`
7627
+ };
7628
+ const written = await writeHandOff(workspace, beside, document, documents);
6373
7629
  files.push({
6374
- path: `${WORKPIECE_DIR}/${stem}.md`,
6375
- description: `the Hand-off of ${made.agent.name} at the Workstation "${made.workstation.name}"${beside(images)}`
7630
+ path: `${WORKPIECE_DIR}/${beside.stem}.md`,
7631
+ description: `the Hand-off of ${made.agent.name} at the Workstation "${made.workstation.name}"${besideIt(written)}`
6376
7632
  });
6377
7633
  }
6378
- return files;
7634
+ return {
7635
+ brief,
7636
+ handOffs: files
7637
+ };
7638
+ }
7639
+ /**
7640
+ * Writes the images of the brief into `workpiece/` beside `brief.md`, as the message starting the
7641
+ * Session names them, each read from the image store at an address fetched for the Task.
7642
+ */
7643
+ async function writeBriefImages(workspace, task, documents) {
7644
+ const { images } = task.workpiece;
7645
+ if (images.length === 0) return [];
7646
+ const fetched = await documents.brief(task.task.id);
7647
+ return writeImages(workspace, {
7648
+ folder: WORKPIECE_DIR,
7649
+ stem: "brief"
7650
+ }, images, fromStore(documents, images, fetched.images, "the brief's image"));
7651
+ }
7652
+ /**
7653
+ * How `writeImages` reads `named`'s bytes from the image store: each at the address `addressed`
7654
+ * gives the same key (a document fetched for its addresses), or each at its own (`addressed`
7655
+ * omitted: `named` carries them). Rejects naming `what` ("the brief's image") for one it has no
7656
+ * address for.
7657
+ */
7658
+ function fromStore(documents, named, addressed, what) {
7659
+ return (index) => {
7660
+ const image = named[index];
7661
+ const address = addressed === void 0 ? image?.address : addressed.find(({ key }) => key === image?.key)?.address;
7662
+ if (!address) return Promise.reject(/* @__PURE__ */ new Error(`${what} ${index + 1} isn't on the Platform`));
7663
+ return documents.image(address);
7664
+ };
6379
7665
  }
6380
7666
  /**
6381
7667
  * Writes the Hand-off under check into an Inspector's Workspace (spec story 24): `hand-off/` holds
@@ -6384,71 +7670,109 @@ async function writeWorkpiece(workspace, task, documents) {
6384
7670
  */
6385
7671
  async function writeHandOffUnderCheck(workspace, task, documents) {
6386
7672
  const { handOff } = task.check;
6387
- const dir = join(workspace, UNDER_CHECK_DIR);
6388
- await mkdir(dir, { mode: 448 });
6389
- const images = await writeHandOff(dir, "hand-off", await documents.handOff(task.task.id, handOff.id), (number) => documents.image(task.task.id, handOff.id, number));
7673
+ await mkdir(join(workspace, UNDER_CHECK_DIR), { mode: 448 });
7674
+ const document = await documents.handOff(task.task.id, handOff.id);
7675
+ const images = await writeHandOff(workspace, {
7676
+ folder: UNDER_CHECK_DIR,
7677
+ stem: "hand-off"
7678
+ }, document, documents);
6390
7679
  return {
6391
7680
  path: `${UNDER_CHECK_DIR}/hand-off.md`,
6392
- description: `the Hand-off of ${handOff.agent.name} at the Workstation "${task.workstation.name}"${beside(images)}`
7681
+ description: `the Hand-off of ${handOff.agent.name} at the Workstation "${task.workstation.name}"${besideIt(images)}`
6393
7682
  };
6394
7683
  }
6395
7684
  /**
6396
7685
  * Writes the Hand-off of the attempt a Rework follows into its Workspace (spec story 33), if that
6397
7686
  * attempt handed off: `previous-attempt/` holds it as a Markdown file, its references listed at its
6398
- * end, with its images beside it, all fetched from the Platform. Returns the file, for the
6399
- * instructions to point at (its description says only what lies beside it), or null.
7687
+ * end, with its images beside it, and the images beside the annotations of the check it failed
7688
+ * (`annotations-1.png`…, #184), all fetched from the Platform. Returns them, for the instructions
7689
+ * to point at, or null.
6400
7690
  */
6401
7691
  async function writePreviousAttempt(workspace, task, documents) {
6402
7692
  const made = task.previous?.handOff;
6403
7693
  if (!made) return null;
6404
- const dir = join(workspace, PREVIOUS_ATTEMPT_DIR);
6405
- await mkdir(dir, { mode: 448 });
6406
- const images = await writeHandOff(dir, "hand-off", await documents.handOff(task.task.id, made.id), (number) => documents.image(task.task.id, made.id, number));
7694
+ await mkdir(join(workspace, PREVIOUS_ATTEMPT_DIR), { mode: 448 });
7695
+ const document = await documents.handOff(task.task.id, made.id);
7696
+ const images = await writeHandOff(workspace, {
7697
+ folder: PREVIOUS_ATTEMPT_DIR,
7698
+ stem: "hand-off"
7699
+ }, document, documents);
7700
+ const failed = task.previous?.check?.id;
6407
7701
  return {
6408
7702
  path: `${PREVIOUS_ATTEMPT_DIR}/hand-off.md`,
6409
- description: beside(images)
7703
+ description: besideIt(images),
7704
+ annotationImages: failed ? await writeAnnotationImages(workspace, task, failed, documents) : []
6410
7705
  };
6411
7706
  }
6412
7707
  /**
6413
- * Writes `document` as `<stem>.md` in `dir`, its images beside it as `<stem>-1.png`… (fetched with
6414
- * `image`) and its references listed at its end. Returns how many images it has.
7708
+ * Writes the images beside the annotations of check `checkId`, which failed the attempt a Rework
7709
+ * follows, into `previous-attempt/` beside its Hand-off, as `annotations-1.png`…, fetched from the
7710
+ * Platform and read from the image store. Returns them as written.
6415
7711
  */
6416
- async function writeHandOff(dir, stem, document, image) {
6417
- const images = [];
6418
- for (const [number, each] of document.images.entries()) {
6419
- const file = `${stem}-${number + 1}.${IMAGE_EXTENSIONS[each.mimeType]}`;
6420
- await writeFile(join(dir, file), await image(number), { mode: 384 });
6421
- images.push(`![${each.alt ?? `Image ${number + 1}`}](${file})`);
7712
+ async function writeAnnotationImages(workspace, task, checkId, documents) {
7713
+ const { images } = await documents.annotations(task.task.id, checkId);
7714
+ return writeImages(workspace, {
7715
+ folder: PREVIOUS_ATTEMPT_DIR,
7716
+ stem: "annotations"
7717
+ }, images, fromStore(documents, images, void 0, "the annotations' image"));
7718
+ }
7719
+ /**
7720
+ * Writes the images of the Owner's Verdicts on the Workpiece (#183: a Send back's or a Rework's
7721
+ * guidance's, an Approve's note's) into `workpiece/`, each Verdict's named after its Ticket
7722
+ * (`T-3-verdict-1.png`…), read from the image store at addresses fetched for the Task. Returns them
7723
+ * as written, for the instructions to name under what the Owner decided and the first prompt to
7724
+ * carry those of the Verdict that routed the Workpiece to the Task.
7725
+ */
7726
+ async function writeVerdictImages(workspace, task, documents) {
7727
+ const named = task.verdicts.map(({ verdict }) => imagesOf(verdict));
7728
+ if (named.every((images) => images.length === 0)) return [];
7729
+ const fetched = await documents.verdicts(task.task.id);
7730
+ const written = [];
7731
+ for (const [index, { ticket }] of task.verdicts.entries()) {
7732
+ const images = named[index] ?? [];
7733
+ const label = ticketLabel(ticket);
7734
+ const beside = {
7735
+ folder: WORKPIECE_DIR,
7736
+ stem: `${label}-verdict`
7737
+ };
7738
+ written.push(await writeImages(workspace, beside, images, fromStore(documents, images, fetched.images, `the Verdict on ${label}'s image`)));
6422
7739
  }
7740
+ return written;
7741
+ }
7742
+ /**
7743
+ * Writes a Hand-off into `workspace` as the document `beside` names, `<stem>.md` in its folder, its
7744
+ * images beside it (`writeImages`, their bytes read from the image store at the addresses the
7745
+ * document names) and linked under its text, and its references listed at its end. Returns how
7746
+ * many images it has.
7747
+ */
7748
+ async function writeHandOff(workspace, beside, document, documents) {
7749
+ const images = await writeImages(workspace, beside, document.images, fromStore(documents, document.images, void 0, "the Hand-off's image"));
6423
7750
  const references = document.references.map(({ label, target }) => `- ${label}: ${target}`);
6424
7751
  const text = [
6425
7752
  document.markdown,
6426
- ...images.length > 0 ? ["## Images", images.join("\n")] : [],
7753
+ ...images.length > 0 ? ["## Images", imageLinks(images, beside.folder)] : [],
6427
7754
  ...references.length > 0 ? ["## References", references.join("\n")] : []
6428
7755
  ].join("\n\n");
6429
- await writeFile(join(dir, `${stem}.md`), `${text}\n`, { mode: 384 });
7756
+ await writeFile(join(workspace, beside.folder, `${beside.stem}.md`), `${text}\n`, { mode: 384 });
6430
7757
  return images.length;
6431
7758
  }
6432
- /** ", with its image beside it", for a Hand-off file with `images` images. */
6433
- function beside(images) {
6434
- if (images === 0) return "";
6435
- return `, with its ${images === 1 ? "image" : `${images} images`} beside it`;
6436
- }
6437
7759
  /** A Workstation's name as part of a file name: "Draft PR" → "draft-pr". */
6438
7760
  function slugOf(name) {
6439
7761
  return name.normalize("NFKD").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40).replace(/-+$/, "") || "hand-off";
6440
7762
  }
6441
7763
  /**
6442
- * The Agent's instructions for a Session, which its Runtime gets as the Runtime driver launches it
6443
- * (`launch.ts`), the same on each launch of the Session. For a Workstation Task's work: the Agent's
6444
- * own instructions, the brief, the `workpiece` files so far, if any, what went wrong with the
6445
- * attempt before it if it is a Rework or a round a Verdict started (pointing at its Hand-off,
6446
- * `previous`), what the Owner decided about the Workpiece so far, how its Hand-off is checked on
6447
- * the Belt out of its Workstation if it is, and how to hand off. For a check: the Inspector's
6448
- * instructions, the Belt's criteria, the Hand-off under check (`underCheck`), the brief and the
6449
- * Workpiece so far, and how to judge.
7764
+ * The Agent's instructions for a Session in `workspace`, which its Runtime gets as the Runtime
7765
+ * driver launches it (`launch.ts`), the same on each launch of the Session. For a Workstation
7766
+ * Task's work: the Agent's own instructions, the brief (naming each of its images where it lies in
7767
+ * the Workspace), the repositories checked out, if any, the `workpiece` files so far, if any, what
7768
+ * went wrong with the attempt before it if it is a Rework or a round a Verdict started (pointing at
7769
+ * its Hand-off, `previous`), what the Owner decided about the Workpiece so far, how its Hand-off is
7770
+ * checked on the Belt out of its Workstation if it is, and how to hand off. For a check: the
7771
+ * Inspector's instructions, the Belt's criteria, the Hand-off under check (`underCheck`), the brief
7772
+ * with its images, the repositories checked out, if any, and the Workpiece so far, and how to
7773
+ * judge.
6450
7774
  */
6451
- function instructionsFor(task, { workpiece = [], underCheck = null, previous = null } = {}) {
7775
+ function instructionsFor(task, workspace, { workpiece = NOTHING_WRITTEN, underCheck = null, previous = null, verdicts = [] } = {}) {
6452
7776
  const { agent, pipeline, workstation } = task;
6453
7777
  const instructions = agent.instructions.trim() || "You have none beyond the brief: work from the brief alone.";
6454
7778
  if (task.role === "check") {
@@ -6470,11 +7794,8 @@ function instructionsFor(task, { workpiece = [], underCheck = null, previous = n
6470
7794
  "",
6471
7795
  underCheck ? `It is \`${underCheck.path}\`: ${underCheck.description}. Its references, if any, are listed at its end.` : `It is ${check.handOff.agent.name}'s Hand-off.`,
6472
7796
  "",
6473
- "## The brief",
6474
- "",
6475
- task.workpiece.brief,
6476
- "",
6477
- ...checkedOut(task),
7797
+ ...theBrief(task, workpiece),
7798
+ ...checkedOut(task, workspace),
6478
7799
  ...soFar(workpiece, "the Hand-off under check builds on theirs"),
6479
7800
  "## How to judge",
6480
7801
  "",
@@ -6482,7 +7803,7 @@ function instructionsFor(task, { workpiece = [], underCheck = null, previous = n
6482
7803
  ""
6483
7804
  ].join("\n");
6484
7805
  }
6485
- const empty = workpiece.length === 0 && !previous && task.repositories.length === 0;
7806
+ const empty = workpiece.brief === null && !previous && task.repositories.length === 0 && verdicts.every((images) => images.length === 0);
6486
7807
  return [
6487
7808
  `# ${agent.name}: Workstation Task`,
6488
7809
  "",
@@ -6492,14 +7813,11 @@ function instructionsFor(task, { workpiece = [], underCheck = null, previous = n
6492
7813
  "",
6493
7814
  instructions,
6494
7815
  "",
6495
- "## The brief",
6496
- "",
6497
- task.workpiece.brief,
6498
- "",
6499
- ...checkedOut(task),
7816
+ ...theBrief(task, workpiece),
7817
+ ...checkedOut(task, workspace),
6500
7818
  ...soFar(workpiece, "your work carries theirs on"),
6501
7819
  ...lastTime(task, previous),
6502
- ...ownersVerdicts(task),
7820
+ ...ownersVerdicts(task, verdicts),
6503
7821
  ...checkedOnward(task),
6504
7822
  "## How to hand off",
6505
7823
  "",
@@ -6513,11 +7831,11 @@ function instructionsFor(task, { workpiece = [], underCheck = null, previous = n
6513
7831
  }
6514
7832
  /**
6515
7833
  * The instructions' section on what went wrong with the attempt this one follows (spec story 33):
6516
- * which attempt this is, why the last one didn't get through (the check's annotations, or why it
6517
- * failed), and where its Hand-off is, if it made one. A round a Verdict started follows the
6518
- * Workpiece's latest attempt at the Workstation, and says so; when the Owner sent the Workpiece
6519
- * back after that attempt got through (`accepted`), it says that instead. None for a round its
6520
- * arrival started.
7834
+ * which attempt this is, why the last one didn't get through (the check's annotations, with where
7835
+ * the images beside them are, or why it failed), and where its Hand-off is, if it made one. A round
7836
+ * a Verdict started follows the Workpiece's latest attempt at the Workstation, and says so; when the
7837
+ * Owner sent the Workpiece back after that attempt got through (`accepted`), it says that instead.
7838
+ * None for a round its arrival started.
6521
7839
  */
6522
7840
  function lastTime(task, handOff) {
6523
7841
  const { attempt, previous } = task;
@@ -6542,7 +7860,8 @@ function lastTime(task, handOff) {
6542
7860
  `${previous.check.inspector.name}'s annotations:`,
6543
7861
  "",
6544
7862
  previous.check.annotations,
6545
- ""
7863
+ "",
7864
+ ...annotatedWith(previous.check.inspector.name, handOff?.annotationImages ?? [])
6546
7865
  ] : [
6547
7866
  `Why ${what}:`,
6548
7867
  "",
@@ -6552,9 +7871,25 @@ function lastTime(task, handOff) {
6552
7871
  ...askedTheOwner(previous)
6553
7872
  ];
6554
7873
  }
6555
- /** The questions the previous attempt asked the Owner, with their replies (spec story 61). */
7874
+ /**
7875
+ * Where the images an Inspector put beside its annotations are (#184), named from the Workspace's
7876
+ * root under the annotations; nothing when there are none.
7877
+ */
7878
+ function annotatedWith(inspector, images) {
7879
+ if (images.length === 0) return [];
7880
+ return [
7881
+ `${inspector}'s ${images.length === 1 ? "image" : `${images.length} images`} with the annotations, beside that Hand-off:`,
7882
+ "",
7883
+ imageLinks(images, ""),
7884
+ ""
7885
+ ];
7886
+ }
7887
+ /**
7888
+ * The questions the previous attempt asked the Owner, with their replies (spec story 61), and how
7889
+ * many images each reply came with (#186).
7890
+ */
6556
7891
  function askedTheOwner(previous) {
6557
- return previous.help.flatMap(({ question, reply }, index) => [
7892
+ return previous.help.flatMap(({ question, reply, replyImages }, index) => [
6558
7893
  `### Attempt ${previous.attempt} asked the Owner${previous.help.length > 1 ? ` (question ${index + 1})` : ""}`,
6559
7894
  "",
6560
7895
  question,
@@ -6563,30 +7898,57 @@ function askedTheOwner(previous) {
6563
7898
  "The Owner replied:",
6564
7899
  "",
6565
7900
  reply,
6566
- ""
7901
+ "",
7902
+ ...replyImagesWent(replyImages)
6567
7903
  ]
6568
7904
  ]);
6569
7905
  }
6570
7906
  /**
7907
+ * What an Agent after a reply is told of the images it came with (#186, ADR-0025): how many, and
7908
+ * that they went only to the Session it resumed, never down the line. Nothing for a reply in words
7909
+ * alone.
7910
+ */
7911
+ function replyImagesWent(count) {
7912
+ if (count === 0) return [];
7913
+ return [`The reply came with ${count === 1 ? "an image" : `${count} images`}, which went only to the Session it resumed.`, ""];
7914
+ }
7915
+ /**
6571
7916
  * The instructions' section on what the Owner decided about the Workpiece so far (spec story 37):
6572
7917
  * each of their Verdicts on its Tickets, in the order given, with the guidance, note or reply it
6573
- * carries. None before the Owner gave one.
7918
+ * carries, and the images beside those words, each named where it is in the Workspace (`written`,
7919
+ * #183), or, for a reply, how many it came with, which went to the Session it resumed alone (#186).
7920
+ * None before the Owner gave one.
6574
7921
  */
6575
- function ownersVerdicts(task) {
7922
+ function ownersVerdicts(task, written) {
6576
7923
  if (task.verdicts.length === 0) return [];
6577
7924
  return [
6578
7925
  "## What the Owner decided",
6579
7926
  "",
6580
7927
  "The Owner ruled on this Workpiece's Tickets as it came down the line. Follow what they decided, in the order they decided it:",
6581
7928
  "",
6582
- ...task.verdicts.flatMap(({ ticket, verdict }) => [
7929
+ ...task.verdicts.flatMap(({ ticket, verdict }, index) => [
6583
7930
  `### ${ticketLabel(ticket)} at "${ticket.workstation.name}": ${VERDICT_NAMES[verdict.kind]}`,
6584
7931
  "",
6585
7932
  wordsOf(verdict) ?? withoutWords(verdict, ticket.workstation.name),
6586
- ""
7933
+ "",
7934
+ ...shownWith(written[index] ?? []),
7935
+ ...verdict.kind === "reply" ? replyImagesWent(verdict.images?.length ?? 0) : []
6587
7936
  ])
6588
7937
  ];
6589
7938
  }
7939
+ /**
7940
+ * Where the images beside a Verdict's words are in the Workspace, named from its root under the
7941
+ * words; nothing when there are none.
7942
+ */
7943
+ function shownWith(images) {
7944
+ if (images.length === 0) return [];
7945
+ return [
7946
+ images.length === 1 ? "Its image is a file in this Workspace:" : `Its ${images.length} images are files in this Workspace:`,
7947
+ "",
7948
+ imageLinks(images, ""),
7949
+ ""
7950
+ ];
7951
+ }
6590
7952
  /** What a Verdict that carries no words of the Owner's decided, said for the Agent. */
6591
7953
  function withoutWords(verdict, workstation) {
6592
7954
  let said = "";
@@ -6619,10 +7981,13 @@ function checkedOnward(task) {
6619
7981
  ];
6620
7982
  }
6621
7983
  /**
6622
- * The instructions' section on the repositories checked out into the Workspace (#90), each in its
6623
- * folder, in the order its policy names them; none when it names none.
7984
+ * The instructions' section on the repositories checked out into `workspace` (#90), each in its
7985
+ * folder, in the order its policy names them, and where the routing AGENTS.md lists their own
7986
+ * instructions if any of them carries some (#173, `routing-skills.ts`): it is written anew on each
7987
+ * launch, and the instructions are fixed when the Session starts, so they name it whenever there
7988
+ * are repositories, "if it is there". None when its policy names none.
6624
7989
  */
6625
- function checkedOut({ repositories }) {
7990
+ function checkedOut({ repositories }, workspace) {
6626
7991
  if (repositories.length === 0) return [];
6627
7992
  return [
6628
7993
  "## The repositories in your Workspace",
@@ -6630,15 +7995,17 @@ function checkedOut({ repositories }) {
6630
7995
  ...repositories.map(({ url, ref, folder }) => `- \`${folder}/\`: ${url} at ${refWords(ref)}`),
6631
7996
  "",
6632
7997
  "Each is a fresh checkout in a folder of its own, made with this machine's own git access, which you fetch and push with too. This folder itself is no repository.",
7998
+ "",
7999
+ `If \`${join(workspace, ROUTING_AGENTS_FILE)}\` is there, it lists these repositories' own instructions for agents: read it before you work in one of them.`,
6633
8000
  ""
6634
8001
  ];
6635
8002
  }
6636
8003
  /**
6637
- * A chat's instructions: who the Agent is and where it has its place, its instructions, the
6638
- * repositories checked out into the Workspace, and that the Owner is talking with it directly.
6639
- * Nothing of any Workpiece.
8004
+ * A chat's instructions, in its Workspace `workspace`: who the Agent is and where it has its place,
8005
+ * its instructions, the repositories checked out into the Workspace, and that the Owner is talking
8006
+ * with it directly. Nothing of any Workpiece.
6640
8007
  */
6641
- function chatInstructionsFor(say) {
8008
+ function chatInstructionsFor(say, workspace) {
6642
8009
  const { agent, place } = say;
6643
8010
  const where = place === null ? "You have no place on a line yet." : place.kind === "workstation" ? `You staff the Workstation "${place.workstation.name}" of the Pipeline "${place.pipeline.name}".` : `You inspect the Hand-offs on the Belt from the Workstation "${place.from.name}" to "${place.to.name}" of the Pipeline "${place.pipeline.name}".`;
6644
8011
  const empty = say.repositories.length === 0;
@@ -6651,42 +8018,126 @@ function chatInstructionsFor(say) {
6651
8018
  "",
6652
8019
  agent.instructions.trim() || "You have none: answer as yourself.",
6653
8020
  "",
6654
- ...checkedOut(say),
8021
+ ...checkedOut(say, workspace),
6655
8022
  "## This chat",
6656
8023
  "",
6657
8024
  "No Workpiece comes with it, and nothing you do here moves on along a line. Answer the Owner, look at what they ask about with this machine's own access, and say what you find.",
6658
8025
  ""
6659
8026
  ].join("\n");
6660
8027
  }
6661
- /** The instructions' section on the Workpiece so far, pointing at its files, if any. */
6662
- function soFar(workpiece, because) {
6663
- return workpiece.length === 0 ? [] : [
6664
- "## The Workpiece so far",
6665
- "",
6666
- `Earlier Workstations of this Pipeline worked on this Workpiece before you. Its brief and every Hand-off accepted so far are files in \`${WORKPIECE_DIR}/\`, in Pipeline order:`,
6667
- "",
6668
- ...workpiece.map((file, index) => `${index + 1}. \`${file.path}\`: ${file.description}.`),
6669
- "",
6670
- `Read them before you start: ${because}.`,
6671
- ""
6672
- ];
8028
+ /**
8029
+ * What the Agent is told in a turn of a chat: the Owner's message, as they wrote it; and the images
8030
+ * it came with (#187), inside this prompt where its Runtime takes them (`prompt-images.ts`), each a
8031
+ * file in the chat's Workspace beside the turn (`chat-turns/<turn>/message-1.png`…), or, when they
8032
+ * couldn't be kept, how many there were. What follows the Owner's words, the protocol's `chatSaid`
8033
+ * leaves out again, for the Owner reading the chat back.
8034
+ */
8035
+ function chatPromptFor(message, { written, lost } = {
8036
+ written: [],
8037
+ lost: 0
8038
+ }) {
8039
+ const which = lost === 1 ? "an image" : `${lost} images`;
8040
+ return {
8041
+ text: lost > 0 ? `${message}\n\nThis message came with ${which}, which couldn't be kept on this machine for you.` : message,
8042
+ images: [{
8043
+ of: CHAT_MESSAGE_IMAGES,
8044
+ images: written
8045
+ }]
8046
+ };
6673
8047
  }
6674
8048
  /**
6675
- * What the Agent is told when its Session resumes with the Owner's reply to its question: the reply,
6676
- * and to carry on.
8049
+ * The instructions' section on the brief: its words, and its images, each named where it is in the
8050
+ * Workspace, beside a copy of the brief.
6677
8051
  */
6678
- function replyPromptFor(task, reply) {
6679
- const { name: file } = INSTRUCTIONS_ARE[task.agent.runtime];
8052
+ function theBrief(task, { brief }) {
8053
+ const images = brief?.images ?? [];
6680
8054
  return [
8055
+ "## The brief",
8056
+ "",
8057
+ task.workpiece.brief,
8058
+ "",
8059
+ ...brief && images.length > 0 ? [
8060
+ `${images.length === 1 ? "Its image is a file" : `Its ${images.length} images are files`} in this Workspace, beside a copy of the brief, \`${brief.path}\`:`,
8061
+ "",
8062
+ imageLinks(images, ""),
8063
+ ""
8064
+ ] : []
8065
+ ];
8066
+ }
8067
+ /**
8068
+ * The instructions' section on the Workpiece so far, pointing at its files: once earlier stations'
8069
+ * Hand-offs were accepted.
8070
+ */
8071
+ function soFar({ brief, handOffs }, because) {
8072
+ const files = brief ? [brief, ...handOffs] : handOffs;
8073
+ return handOffs.length === 0 ? [] : [
8074
+ "## The Workpiece so far",
8075
+ "",
8076
+ `Earlier Workstations of this Pipeline worked on this Workpiece before you. Its brief and every Hand-off accepted so far are files in \`${WORKPIECE_DIR}/\`, in Pipeline order:`,
8077
+ "",
8078
+ ...files.map((file, index) => `${index + 1}. \`${file.path}\`: ${file.description}.`),
8079
+ "",
8080
+ `Read them before you start: ${because}.`,
8081
+ ""
8082
+ ];
8083
+ }
8084
+ /**
8085
+ * What the Agent is told when its Session resumes with the Owner's reply to its question: the
8086
+ * reply, and to carry on; and the images the reply came with (#186), inside this prompt where its
8087
+ * Runtime takes them (`prompt-images.ts`), each a file beside the question in its Workspace.
8088
+ */
8089
+ function replyPromptFor(task, reply, { written, lost } = {
8090
+ written: [],
8091
+ lost: 0
8092
+ }) {
8093
+ const { name: file } = INSTRUCTIONS_ARE[task.agent.runtime];
8094
+ const lines = [
6681
8095
  "The Owner replied to your question:",
6682
8096
  "",
6683
8097
  reply,
6684
8098
  "",
6685
8099
  `Carry on with the work where you left off, then hand off with the ${HAND_OFF_TOOL} tool of the ${FACTORY_MCP_NAME} MCP server, as ${file} explains.`
6686
- ].join("\n");
8100
+ ];
8101
+ if (lost > 0) {
8102
+ const which = lost === 1 ? "an image" : `${lost} images`;
8103
+ lines.push("", `The reply came with ${which}, which couldn't be kept on this machine for you.`);
8104
+ }
8105
+ return {
8106
+ text: lines.join("\n"),
8107
+ images: [{
8108
+ of: "the reply",
8109
+ images: written
8110
+ }]
8111
+ };
8112
+ }
8113
+ /**
8114
+ * What the Agent is told when its Session starts, a Task's or a Check's (the rest is in its
8115
+ * instructions), and the images that come with it (spec #172, stories 29 and 32), as `files` wrote
8116
+ * them into its Workspace, inside this first prompt where its Runtime takes them
8117
+ * (`prompt-images.ts`), and in no later one: for the Task a Verdict routed the Workpiece to
8118
+ * (`routedHere`), that Verdict's guidance's or note's (#183), then the brief's, so that a brief
8119
+ * with as many images as a prompt takes can't push the Verdict's out of it. An Agent after it has
8120
+ * that Verdict's images as files only.
8121
+ */
8122
+ function promptFor(task, { workpiece = NOTHING_WRITTEN, verdicts = [] } = {}) {
8123
+ const images = [];
8124
+ if (task.role === "work") {
8125
+ for (const [index, { verdict, routedHere }] of task.verdicts.entries()) if (routedHere) images.push({
8126
+ of: verdict.kind === "approve" ? "the Owner's note" : "the Owner's guidance",
8127
+ images: verdicts[index] ?? []
8128
+ });
8129
+ }
8130
+ images.push({
8131
+ of: "the brief",
8132
+ images: workpiece.brief?.images ?? []
8133
+ });
8134
+ return {
8135
+ text: firstWords(task),
8136
+ images
8137
+ };
6687
8138
  }
6688
- /** What the Agent is told when its Session starts; the rest is in its instructions. */
6689
- function promptFor(task) {
8139
+ /** The words of a Session's first prompt. */
8140
+ function firstWords(task) {
6690
8141
  const { where, name: file } = INSTRUCTIONS_ARE[task.agent.runtime];
6691
8142
  if (task.role === "check") return `Your instructions, the Belt's criteria and the Hand-off to check are ${where}. Judge the Hand-off against the criteria, then record your judgement with the ${JUDGE_TOOL} tool of the ${FACTORY_MCP_NAME} MCP server, as ${file} explains.`;
6692
8143
  return `Your instructions and this Workpiece's brief are ${where}${task.workpiece.handOffs.length === 0 ? "" : `, and it points you to what the Workstations before yours handed off`}.${!task.previous ? "" : task.previous.status === "accepted" ? ` This is attempt ${task.attempt.number} of ${task.attempt.of} of a fresh round: the Owner sent the Workpiece back here, as ${file} says.` : ` This is attempt ${task.attempt.number} of ${task.attempt.of}${task.cause === "verdict" ? " of a fresh round the Owner started" : ""}: ${file} says what went wrong with the last one.`}${task.verdicts.length === 0 ? "" : ` Follow what the Owner decided about it, as ${file} says.`} Do the work the brief asks for, then hand off with the ${HAND_OFF_TOOL} tool of the ${FACTORY_MCP_NAME} MCP server, as ${file} explains.`;
@@ -6773,6 +8224,14 @@ async function isThere(workspace) {
6773
8224
  return false;
6774
8225
  }
6775
8226
  }
8227
+ /** What the images `say`'s message came with were handed for: its turn. */
8228
+ function handedFor(say) {
8229
+ return {
8230
+ of: "chat",
8231
+ chatId: say.chatId,
8232
+ turn: say.turn
8233
+ };
8234
+ }
6776
8235
  /** What a turn whose Session never opened used. */
6777
8236
  const NONE = {
6778
8237
  input: 0,
@@ -6853,7 +8312,7 @@ function createChatRunner(options) {
6853
8312
  if (stop.aborted) return { failed: { kind: "aborted" } };
6854
8313
  const { workspace } = chat;
6855
8314
  const resume = chat.sessionId ?? void 0;
6856
- const instructions = (resume === void 0 ? null : chat.instructions) ?? chatInstructionsFor(say);
8315
+ const instructions = (resume === void 0 ? null : chat.instructions) ?? chatInstructionsFor(say, workspace);
6857
8316
  try {
6858
8317
  return {
6859
8318
  session: await options.driver.open({
@@ -6987,6 +8446,7 @@ function createChatRunner(options) {
6987
8446
  instructions
6988
8447
  });
6989
8448
  }
8449
+ const images = await imagesOf(say, workspace);
6990
8450
  await reportStarted(say, fresh);
6991
8451
  onEvent({
6992
8452
  type: "chat-turn-started",
@@ -6998,7 +8458,7 @@ function createChatRunner(options) {
6998
8458
  recording: opening.recording
6999
8459
  });
7000
8460
  return {
7001
- outcome: await opening.run(say.message, stop),
8461
+ outcome: await opening.run(chatPromptFor(say.message, images), stop),
7002
8462
  usage: opening.usage()
7003
8463
  };
7004
8464
  } finally {
@@ -7007,6 +8467,46 @@ function createChatRunner(options) {
7007
8467
  await opening.close();
7008
8468
  }
7009
8469
  };
8470
+ /**
8471
+ * The images `say`'s message came with (#187), written into the turn's Workspace beside the turn
8472
+ * (`chat-turns/<turn>/message-1.png`…), from where the Platform handed them; or how many of them
8473
+ * there were, when they couldn't be.
8474
+ */
8475
+ const imagesOf = async (say, workspace) => {
8476
+ if (say.handed === void 0) return {
8477
+ written: [],
8478
+ lost: 0
8479
+ };
8480
+ try {
8481
+ return {
8482
+ written: await options.handed.claim(handedFor(say), say.handed, workspace, {
8483
+ folder: posix.join(WORKSPACE_FOLDERS.chatTurns, String(say.turn)),
8484
+ stem: "message"
8485
+ }),
8486
+ lost: 0
8487
+ };
8488
+ } catch (error) {
8489
+ onEvent({
8490
+ type: "problem",
8491
+ message: `Couldn't write the images of turn ${say.turn} of chat ${say.chatId} into its Workspace: ${messageOf(error)}`
8492
+ });
8493
+ return {
8494
+ written: [],
8495
+ lost: say.handed.images.length
8496
+ };
8497
+ }
8498
+ };
8499
+ /** Forgets what was handed for `say`'s turn and those before, as the turn ends. */
8500
+ const forgetHanded = async (say) => {
8501
+ try {
8502
+ await options.handed.forget(handedFor(say));
8503
+ } catch (error) {
8504
+ onEvent({
8505
+ type: "problem",
8506
+ message: `Couldn't let go of the images handed for chat ${say.chatId}: ${messageOf(error)}`
8507
+ });
8508
+ }
8509
+ };
7010
8510
  /** Runs one turn of `chat`, from its start to the report of its end. */
7011
8511
  const turn = async (chat, say) => {
7012
8512
  const underWay = {
@@ -7055,6 +8555,7 @@ function createChatRunner(options) {
7055
8555
  usage: null
7056
8556
  };
7057
8557
  }
8558
+ if (!killed) await forgetHanded(say);
7058
8559
  if (!killed && !leaving) await reportEnded(underWay, answered.outcome, answered.usage);
7059
8560
  } catch (error) {
7060
8561
  onEvent({
@@ -7079,7 +8580,8 @@ function createChatRunner(options) {
7079
8580
  sessionId: null,
7080
8581
  instructions: null,
7081
8582
  opened: false,
7082
- turns: Promise.resolve()
8583
+ turns: Promise.resolve(),
8584
+ said: 0
7083
8585
  };
7084
8586
  recalled.turns = recall(recalled, chatId);
7085
8587
  chats.set(chatId, recalled);
@@ -7112,8 +8614,12 @@ function createChatRunner(options) {
7112
8614
  return {
7113
8615
  say(say) {
7114
8616
  const chat = known(say.chatId);
8617
+ chat.said = Math.max(chat.said, say.turn);
7115
8618
  chat.turns = chat.turns.then(() => turn(chat, say));
7116
8619
  },
8620
+ whyNoImages(chatId, number) {
8621
+ return number <= (chats.get(chatId)?.said ?? 0) ? `it took turn ${number} of this chat on already` : null;
8622
+ },
7117
8623
  async swept(workspace) {
7118
8624
  const chatId = chatOfWorkspace(workspace);
7119
8625
  if (chatId === null || killed) return;
@@ -7150,6 +8656,66 @@ function createChatRunner(options) {
7150
8656
  };
7151
8657
  }
7152
8658
  //#endregion
8659
+ //#region src/image-uploads.ts
8660
+ /**
8661
+ * The Platform, or the image store, refused to take the images, which asking again won't change:
8662
+ * the message naming them goes as it is, and the Platform refuses it, saying why.
8663
+ */
8664
+ var ImagesRefusedError = class extends Error {
8665
+ name = "ImagesRefusedError";
8666
+ };
8667
+ /** How long one request may take before it's given up: a 10 MiB image on a slow line. */
8668
+ const REQUEST_TIMEOUT_MS = 3e5;
8669
+ /** Puts images into the image store of the Platform the Node joined with `credential`. */
8670
+ function platformImageUploads(credential) {
8671
+ return async (images, signal) => {
8672
+ for (const { image, bytes: read } of images) {
8673
+ const bytes = await read();
8674
+ const asked = { images: [{
8675
+ key: image.key,
8676
+ mimeType: image.mimeType,
8677
+ bytes: bytes.length
8678
+ }] };
8679
+ const answered = await send(new URL(NODE_IMAGES_PATH, credential.platformUrl), signal, {
8680
+ method: "POST",
8681
+ headers: {
8682
+ authorization: `Bearer ${credential.credential}`,
8683
+ "content-type": "application/json"
8684
+ },
8685
+ body: JSON.stringify(asked)
8686
+ });
8687
+ const [upload] = nodeImageUploadAnswer.parse(await answered.json()).uploads;
8688
+ if (upload?.key !== image.key) throw new ImagesRefusedError(`The Platform named no place for image ${image.key}`);
8689
+ if (upload.address) await send(new URL(upload.address.url, credential.platformUrl), signal, {
8690
+ method: "PUT",
8691
+ headers: { ...upload.address.headers },
8692
+ body: bytes
8693
+ });
8694
+ }
8695
+ };
8696
+ }
8697
+ /**
8698
+ * Sends one request, resolving with its answer once it succeeded. Rejects with an
8699
+ * `ImagesRefusedError` for a refusal (a 4xx), and an error of its own for anything that may pass
8700
+ * (no answer, a 5xx).
8701
+ */
8702
+ async function send(url, signal, init) {
8703
+ let response;
8704
+ try {
8705
+ response = await fetch(url, {
8706
+ ...init,
8707
+ signal: AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)])
8708
+ });
8709
+ } catch (error) {
8710
+ throw new Error(`Couldn't reach ${url.origin}: ${messageOf(error)}`, { cause: error });
8711
+ }
8712
+ if (response.ok) return response;
8713
+ const refusal = fetchRefusal.safeParse(await response.json().catch(() => null));
8714
+ const reason = refusal.success ? refusal.data.error : `${url.origin} answered ${response.status}`;
8715
+ if (response.status >= 400 && response.status < 500 && ![408, 429].includes(response.status)) throw new ImagesRefusedError(reason);
8716
+ throw new Error(reason);
8717
+ }
8718
+ //#endregion
7153
8719
  //#region src/connector.ts
7154
8720
  function write(ws, message) {
7155
8721
  ws.send(JSON.stringify(message));
@@ -7166,7 +8732,9 @@ function backoff(attempt) {
7166
8732
  /**
7167
8733
  * Dials out to the Platform (ADR-0006) and keeps the connection up: says hello, sends heartbeats,
7168
8734
  * replays the outbox after each welcome, and reconnects with backoff whenever the connection drops
7169
- * or goes quiet for longer than the heartbeat timeout.
8735
+ * or goes quiet for longer than the heartbeat timeout. Its messages go one at a time, in the
8736
+ * outbox's order: one naming images waits until they are in the image store, put there from the
8737
+ * outbox (trying again with backoff while that fails), and every message after it waits behind it.
7170
8738
  */
7171
8739
  function connectToPlatform(options) {
7172
8740
  const { credential, outbox, inbox, onEvent } = options;
@@ -7182,6 +8750,9 @@ function connectToPlatform(options) {
7182
8750
  let heartbeatTimer;
7183
8751
  let livenessTimer;
7184
8752
  let sending = Promise.resolve();
8753
+ let written = /* @__PURE__ */ new Set();
8754
+ let delivering = Promise.resolve();
8755
+ const ending = /* @__PURE__ */ new WeakMap();
7185
8756
  let taking = Promise.resolve();
7186
8757
  /** Who waits to hear what the Platform made of each message, by message id. */
7187
8758
  const waitingForAck = /* @__PURE__ */ new Map();
@@ -7199,6 +8770,7 @@ function connectToPlatform(options) {
7199
8770
  const open = () => {
7200
8771
  const ws = new WebSocket(url, { headers: { authorization: `Bearer ${credential.credential}` } });
7201
8772
  socket = ws;
8773
+ ending.set(ws, new AbortController());
7202
8774
  let refused = false;
7203
8775
  expectWithin(ws, options.connectTimeoutMs);
7204
8776
  ws.on("unexpected-response", (_request, response) => {
@@ -7209,7 +8781,7 @@ function connectToPlatform(options) {
7209
8781
  write(ws, {
7210
8782
  kind: "hello",
7211
8783
  id: randomUUID(),
7212
- protocolVersion: 24,
8784
+ protocolVersion: 25,
7213
8785
  os: options.os
7214
8786
  });
7215
8787
  });
@@ -7217,6 +8789,7 @@ function connectToPlatform(options) {
7217
8789
  if (ws === socket) receive(ws, textOf$1(data));
7218
8790
  });
7219
8791
  ws.on("close", (code, reason) => {
8792
+ ending.get(ws)?.abort();
7220
8793
  if (ws === socket) closed(refused ? NODE_CLOSE_CODES.refused : code, reason.toString());
7221
8794
  });
7222
8795
  ws.on("error", () => {});
@@ -7244,7 +8817,8 @@ function connectToPlatform(options) {
7244
8817
  id: randomUUID()
7245
8818
  }), message.heartbeatIntervalMs);
7246
8819
  onEvent({ type: "connected" });
7247
- for (const pending of outbox.pending()) write(ws, pending);
8820
+ written = /* @__PURE__ */ new Set();
8821
+ deliver();
7248
8822
  return;
7249
8823
  case "ack":
7250
8824
  acknowledged(message);
@@ -7368,15 +8942,72 @@ function connectToPlatform(options) {
7368
8942
  });
7369
8943
  reconnectTimer = setTimeout(open, options.reconnectDelayMs(attempt++));
7370
8944
  };
7371
- const keepAndSend = async (message) => {
8945
+ /** Whether `ws` is the connection, welcomed and open, that the outbox's messages go on now. */
8946
+ const isUp = (ws) => ws !== null && ws === socket && welcomed && !stopping && ws.readyState === WebSocket.OPEN;
8947
+ /** Writes what the outbox holds that the connection open now hasn't been sent, in order. */
8948
+ const deliver = () => {
8949
+ const ws = socket;
8950
+ delivering = delivering.then(() => deliverOn(ws)).catch((error) => {
8951
+ onEvent({
8952
+ type: "problem",
8953
+ message: `Couldn't send the outbox: ${messageOf(error)}`
8954
+ });
8955
+ });
8956
+ };
8957
+ const deliverOn = async (ws) => {
8958
+ for (const message of outbox.pending()) {
8959
+ if (!isUp(ws)) return;
8960
+ if (written.has(message.id)) continue;
8961
+ if (imagesNamedBy(message).length > 0 && !await imagesPutFor(ws, message)) return;
8962
+ if (!isUp(ws)) return;
8963
+ written.add(message.id);
8964
+ write(ws, message);
8965
+ }
8966
+ };
8967
+ /**
8968
+ * Puts the images `message` names into the image store, trying again with backoff while that
8969
+ * fails and `ws` is up. Resolves with whether to send it now: false once `ws` went down, which
8970
+ * stops it. Images the image store refuses outright, or no longer kept here, can't be put there by
8971
+ * trying again: the message goes as it is, for the Platform to refuse with its reason.
8972
+ */
8973
+ const imagesPutFor = async (ws, message) => {
8974
+ const images = imagesNamedBy(message).map((image) => ({
8975
+ image,
8976
+ bytes: () => outbox.image(image.key)
8977
+ }));
8978
+ const signal = ending.get(ws)?.signal ?? AbortSignal.abort();
8979
+ for (let tries = 0;; tries++) {
8980
+ try {
8981
+ await options.uploadImages(images, signal);
8982
+ return true;
8983
+ } catch (error) {
8984
+ if (!isUp(ws)) return false;
8985
+ const lost = error instanceof Error && "code" in error && error.code === "ENOENT";
8986
+ if (error instanceof ImagesRefusedError || lost) {
8987
+ onEvent({
8988
+ type: "problem",
8989
+ message: `The images of a ${message.kind} couldn't be put into the image store: ${messageOf(error)}`
8990
+ });
8991
+ return true;
8992
+ }
8993
+ onEvent({
8994
+ type: "problem",
8995
+ message: `Couldn't put the images of a ${message.kind} into the image store yet: ${messageOf(error)}`
8996
+ });
8997
+ }
8998
+ await setTimeout$1(options.reconnectDelayMs(tries), null, { signal }).catch(() => null);
8999
+ if (!isUp(ws)) return false;
9000
+ }
9001
+ };
9002
+ const keepAndSend = async (message, images) => {
7372
9003
  if (killed) return;
7373
- await outbox.add(message);
7374
- if (welcomed && socket && !stopping) write(socket, message);
9004
+ await outbox.add(message, images);
9005
+ if (isUp(socket)) deliver();
7375
9006
  };
7376
9007
  open();
7377
9008
  return {
7378
- send(message) {
7379
- const sent = sending.then(() => keepAndSend(message));
9009
+ send(message, images) {
9010
+ const sent = sending.then(() => keepAndSend(message, images));
7380
9011
  sending = sent.catch(() => {});
7381
9012
  return sent;
7382
9013
  },
@@ -7426,11 +9057,11 @@ function connectToPlatform(options) {
7426
9057
  const FETCH_TIMEOUT_MS = 6e4;
7427
9058
  /** The Platform's documents, as the Node that joined with `credential` may read them. */
7428
9059
  function platformDocuments(credential) {
7429
- const get = async (path) => {
9060
+ const get = async (path, headers = { authorization: `Bearer ${credential.credential}` }) => {
7430
9061
  let response;
7431
9062
  try {
7432
9063
  response = await fetch(new URL(path, credential.platformUrl), {
7433
- headers: { authorization: `Bearer ${credential.credential}` },
9064
+ headers,
7434
9065
  signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
7435
9066
  });
7436
9067
  } catch (error) {
@@ -7444,7 +9075,10 @@ function platformDocuments(credential) {
7444
9075
  };
7445
9076
  return {
7446
9077
  handOff: async (taskId, handOffId) => fetchedHandOff.parse(await (await get(nodeHandOffPath(taskId, handOffId))).json()),
7447
- image: async (taskId, handOffId, index) => new Uint8Array(await (await get(nodeHandOffImagePath(taskId, handOffId, index))).arrayBuffer())
9078
+ brief: async (taskId) => fetchedBrief.parse(await (await get(nodeBriefPath(taskId))).json()),
9079
+ verdicts: async (taskId) => fetchedVerdicts.parse(await (await get(nodeVerdictsPath(taskId))).json()),
9080
+ annotations: async (taskId, checkId) => fetchedAnnotations.parse(await (await get(nodeAnnotationsPath(taskId, checkId))).json()),
9081
+ image: async (address) => new Uint8Array(await (await get(address.url, { ...address.headers })).arrayBuffer())
7448
9082
  };
7449
9083
  }
7450
9084
  //#endregion
@@ -7480,11 +9114,11 @@ async function thisInstallation() {
7480
9114
  * `reason`. Asks npm for its latest when that may help.
7481
9115
  */
7482
9116
  async function protocolMove(installed, platformVersion, reason) {
7483
- if (platformVersion === null || platformVersion === 24) return {
9117
+ if (platformVersion === null || platformVersion === 25) return {
7484
9118
  kind: "unreadable",
7485
9119
  reason
7486
9120
  };
7487
- if (platformVersion < 24) return {
9121
+ if (platformVersion < 25) return {
7488
9122
  kind: "wait-for-platform",
7489
9123
  platformVersion
7490
9124
  };
@@ -7589,6 +9223,119 @@ function quietNpm(env) {
7589
9223
  };
7590
9224
  }
7591
9225
  //#endregion
9226
+ //#region src/handed-images.ts
9227
+ /** An image being handed is written aside under this name until its last part came. */
9228
+ const PARTIAL = ".partial";
9229
+ /** The folder of what is handed for `to`, in the Node's own: by what it is for, never a path. */
9230
+ function folderFor(dir, to) {
9231
+ return to.of === "reply" ? join(dir, `reply-${to.taskId}`) : join(chatFolder(dir, to.chatId), String(to.turn));
9232
+ }
9233
+ /** The folder of what is handed for chat `chatId`'s turns, a folder per turn in it. */
9234
+ function chatFolder(dir, chatId) {
9235
+ return join(dir, `chat-${chatId}`);
9236
+ }
9237
+ /**
9238
+ * Opens the images handed to this Node, kept in `dir`: what was handed for replies to the daemon
9239
+ * before goes first.
9240
+ */
9241
+ async function openHandedImages(options) {
9242
+ const { dir } = options;
9243
+ await mkdir(dir, {
9244
+ recursive: true,
9245
+ mode: 448
9246
+ });
9247
+ for (const folder of await readdir(dir)) if (folder.startsWith("reply-")) await rm(join(dir, folder), {
9248
+ recursive: true,
9249
+ force: true
9250
+ });
9251
+ return {
9252
+ async take(part) {
9253
+ const why = options.accepts(part.to);
9254
+ if (why !== null) return {
9255
+ kind: "refused",
9256
+ why
9257
+ };
9258
+ const bytes = Buffer.from(part.data, "base64");
9259
+ if (part.at + bytes.length > IMAGE_LIMITS.bytes) return {
9260
+ kind: "refused",
9261
+ why: `image ${part.index + 1} is larger than ${IMAGE_LIMITS.bytes / 1024 / 1024} MiB`
9262
+ };
9263
+ const folder = join(folderFor(dir, part.to), part.handing);
9264
+ const file = join(folder, `${part.index}${PARTIAL}`);
9265
+ if (part.at === 0) {
9266
+ if (imageTypeOf(bytes) !== part.mimeType) return {
9267
+ kind: "refused",
9268
+ why: `image ${part.index + 1} isn't the ${part.mimeType} it says`
9269
+ };
9270
+ await mkdir(folder, {
9271
+ recursive: true,
9272
+ mode: 448
9273
+ });
9274
+ await writeFile(file, bytes, { mode: 384 });
9275
+ } else {
9276
+ if (await sizeOf(file) !== part.at) return {
9277
+ kind: "refused",
9278
+ why: `a part of image ${part.index + 1} came out of place`
9279
+ };
9280
+ await appendFile(file, bytes);
9281
+ }
9282
+ if (part.last) await rename(file, join(folder, String(part.index)));
9283
+ return { kind: "taken" };
9284
+ },
9285
+ async claim(to, { handing, images }, workspace, beside) {
9286
+ const folder = join(folderFor(dir, to), handing);
9287
+ for (const index of images.keys()) if (await sizeOf(join(folder, String(index))) < 0) throw new Error(`Image ${index + 1} wasn't handed to this Node whole`);
9288
+ const written = await writeImages(workspace, beside, images, (index) => readFile(join(folder, String(index))));
9289
+ await forget(dir, to);
9290
+ return written;
9291
+ },
9292
+ forget: (to) => forget(dir, to),
9293
+ async sweep(before) {
9294
+ for (const chat of await readdir(dir)) {
9295
+ if (!chat.startsWith("chat-")) continue;
9296
+ const folder = join(dir, chat);
9297
+ for (const turn of await readdir(folder)) if ((await stat(join(folder, turn))).mtimeMs < before) await rm(join(folder, turn), {
9298
+ recursive: true,
9299
+ force: true
9300
+ });
9301
+ if ((await readdir(folder)).length === 0 && (await stat(folder)).mtimeMs < before) await rm(folder, {
9302
+ recursive: true,
9303
+ force: true
9304
+ });
9305
+ }
9306
+ }
9307
+ };
9308
+ }
9309
+ /** Forgets what is handed in `dir` for `to`: a chat's for its turns up to `to`'s. */
9310
+ async function forget(dir, to) {
9311
+ if (to.of === "reply") {
9312
+ await rm(folderFor(dir, to), {
9313
+ recursive: true,
9314
+ force: true
9315
+ });
9316
+ return;
9317
+ }
9318
+ const folder = chatFolder(dir, to.chatId);
9319
+ let turns;
9320
+ try {
9321
+ turns = await readdir(folder);
9322
+ } catch {
9323
+ return;
9324
+ }
9325
+ for (const turn of turns) if (Number(turn) <= to.turn) await rm(join(folder, turn), {
9326
+ recursive: true,
9327
+ force: true
9328
+ });
9329
+ }
9330
+ /** How many bytes the file at `path` holds, or -1 when there is none. */
9331
+ async function sizeOf(path) {
9332
+ try {
9333
+ return (await stat(path)).size;
9334
+ } catch {
9335
+ return -1;
9336
+ }
9337
+ }
9338
+ //#endregion
7592
9339
  //#region src/own-setup.ts
7593
9340
  /** What else `runtime` reads on `machine`, for a Session in a Workspace in `workspacesDir`. */
7594
9341
  async function ownSetupOf(runtime, machine, workspacesDir) {
@@ -8019,6 +9766,86 @@ function answerOf(found) {
8019
9766
  };
8020
9767
  }
8021
9768
  //#endregion
9769
+ //#region src/question-images.ts
9770
+ /** Where a question's or a reply's images were written, as the Node files it. */
9771
+ const filedImages = z.object({
9772
+ /** The Workspace they are in. */
9773
+ workspace: z.string().min(1),
9774
+ /** Each image, in the question's order: its file from the Workspace's root, and its type. */
9775
+ images: z.array(z.object({
9776
+ path: z.string().min(1),
9777
+ mimeType: imageType
9778
+ }))
9779
+ });
9780
+ /** Where the Node files question `question` of Task `taskId` among its Sessions. */
9781
+ function filedAt(sessionsDir, taskId, question) {
9782
+ return join(sessionsDir, taskId, "help-requests", `${question}.json`);
9783
+ }
9784
+ /** Where the Node files the images of the reply to Task `taskId` that `handing` brought. */
9785
+ function replyFiledAt(sessionsDir, taskId, handing) {
9786
+ return join(sessionsDir, taskId, "help-replies", `${handing}.json`);
9787
+ }
9788
+ /** Files `images`, written into `workspace`, at `filed`. */
9789
+ async function fileImages(filed, workspace, images) {
9790
+ await mkdir(join(filed, ".."), {
9791
+ recursive: true,
9792
+ mode: 448
9793
+ });
9794
+ await writeAtomically(filed, JSON.stringify({
9795
+ workspace,
9796
+ images: images.map(({ path, mimeType }) => ({
9797
+ path,
9798
+ mimeType
9799
+ }))
9800
+ }));
9801
+ }
9802
+ /** Image `index` of those filed at `filed`, from their Workspace. */
9803
+ async function filedImage(filed, index) {
9804
+ const text = await readFile(filed, "utf8").catch((error) => {
9805
+ if (isMissing$1(error)) return null;
9806
+ throw error;
9807
+ });
9808
+ const parsed = text === null ? null : filedImages.safeParse(jsonIn(text));
9809
+ const named = parsed?.success ? parsed.data.images[index] : void 0;
9810
+ if (!parsed?.success || named === void 0) return {
9811
+ kind: "missing",
9812
+ why: "it keeps no such image"
9813
+ };
9814
+ return imageInWorkspace(parsed.data.workspace, named.path);
9815
+ }
9816
+ /**
9817
+ * Copies the images an Agent asked with into its Workspace, as they are now, and files where they
9818
+ * are under `asking`'s ids. Returns them as the `help-request` message names them: without their
9819
+ * bytes.
9820
+ */
9821
+ async function keepQuestionImages(asking, images) {
9822
+ const about = images.map(({ mimeType, alt }) => ({
9823
+ mimeType,
9824
+ ...alt === void 0 ? {} : { alt }
9825
+ }));
9826
+ if (images.length === 0) return about;
9827
+ const written = await writeImages(asking.workspace, {
9828
+ folder: posix.join(WORKSPACE_FOLDERS.helpRequest, String(asking.number)),
9829
+ stem: "question"
9830
+ }, about, async (index) => images[index]?.bytes ?? Buffer.alloc(0));
9831
+ await fileImages(filedAt(asking.sessionsDir, asking.taskId, asking.question), asking.workspace, about.map(({ mimeType }, index) => ({
9832
+ path: written[index]?.path ?? "",
9833
+ mimeType
9834
+ })));
9835
+ return about;
9836
+ }
9837
+ /** Files where the images a reply came with were written in its Task's Workspace (`written`). */
9838
+ async function fileReplyImages(replying, written) {
9839
+ await fileImages(replyFiledAt(replying.sessionsDir, replying.taskId, replying.handing), replying.workspace, written);
9840
+ }
9841
+ /**
9842
+ * One image of a Help Request's question or reply, from its Task's Workspace, by the ids it is
9843
+ * filed under.
9844
+ */
9845
+ async function helpRequestImage(sessionsDir, image) {
9846
+ return filedImage(image.of === "help-request" ? filedAt(sessionsDir, image.taskId, image.question) : replyFiledAt(sessionsDir, image.taskId, image.handing), image.index);
9847
+ }
9848
+ //#endregion
8022
9849
  //#region src/requests.ts
8023
9850
  /** The most bytes of JSON one part of an answer holds: well inside one message of the Node link. */
8024
9851
  const PART_BYTES = 1048576;
@@ -8027,10 +9854,11 @@ const PROBE_MARGIN_MS = 2e3;
8027
9854
  /** Answers each request the Platform sends, in the background. */
8028
9855
  function answerRequests(options) {
8029
9856
  const answer = async (request) => {
8030
- const giveUpAt = Date.now() + request.timeoutMs;
9857
+ let giveUpAt = Date.now() + request.timeoutMs;
8031
9858
  const parts = await answerTo(request);
8032
9859
  for (const [part, piece] of parts.entries()) {
8033
9860
  if (Date.now() > giveUpAt) return;
9861
+ giveUpAt = Date.now() + request.timeoutMs;
8034
9862
  if (!options.send({
8035
9863
  kind: "answer",
8036
9864
  id: randomUUID(),
@@ -8046,6 +9874,8 @@ function answerRequests(options) {
8046
9874
  const { ask } = request;
8047
9875
  if (ask.about === "session-record") return recordOf(ask.session);
8048
9876
  if (ask.about === "repository-probe") return probeOf(ask.repository, request.timeoutMs, options.signal);
9877
+ if (ask.about === "image") return imageOf(ask.image);
9878
+ if (ask.about === "handed-image") return handedPart(ask);
8049
9879
  return setupOf(ask.runtime, ask.of === "millwright" ? options.millwrightDir : options.workspacesDir);
8050
9880
  };
8051
9881
  /**
@@ -8065,6 +9895,53 @@ function answerRequests(options) {
8065
9895
  }];
8066
9896
  }
8067
9897
  };
9898
+ /** An image it keeps: its bytes, in parts, or why it can't send them. */
9899
+ const imageOf = async (image) => {
9900
+ let kept;
9901
+ try {
9902
+ kept = image.of === "session-record" ? await recordImageOf(recordFolder(options.sessionsDir, image.session), image) : await helpRequestImage(options.sessionsDir, image);
9903
+ } catch (error) {
9904
+ return [{
9905
+ kind: "failed",
9906
+ reason: `${options.name} couldn't read the image: ${messageOf(error)}`.slice(0, 2e3)
9907
+ }];
9908
+ }
9909
+ if (kept.kind === "gone") return [{
9910
+ kind: "gone",
9911
+ reason: `${options.name} cleaned up the Workspace it kept the image in, and the image went with it`.slice(0, 500)
9912
+ }];
9913
+ if (kept.kind === "missing") return [{
9914
+ kind: "refused",
9915
+ reason: `${options.name} can't send the image: ${kept.why}`.slice(0, 500)
9916
+ }];
9917
+ const { mimeType, bytes } = kept;
9918
+ const parts = [];
9919
+ for (let at = 0; at < bytes.length; at += IMAGE_PART_BYTES) {
9920
+ const data = bytes.subarray(at, at + IMAGE_PART_BYTES).toString("base64");
9921
+ parts.push({
9922
+ kind: "image",
9923
+ mimeType,
9924
+ data
9925
+ });
9926
+ }
9927
+ return parts;
9928
+ };
9929
+ /** A part of an image the Platform hands it: on disk once answered, or why it isn't. */
9930
+ const handedPart = async (part) => {
9931
+ try {
9932
+ const taken = await options.handed.take(part);
9933
+ if (taken.kind === "refused") return [{
9934
+ kind: "refused",
9935
+ reason: `${options.name} can't take the images: ${taken.why}`.slice(0, 500)
9936
+ }];
9937
+ return [{ kind: "handed-image" }];
9938
+ } catch (error) {
9939
+ return [{
9940
+ kind: "failed",
9941
+ reason: `${options.name} couldn't keep the images: ${messageOf(error)}`.slice(0, 2e3)
9942
+ }];
9943
+ }
9944
+ };
8068
9945
  /** `session`'s record: its items, in parts. */
8069
9946
  const recordOf = async (session) => {
8070
9947
  const folder = recordFolder(options.sessionsDir, session);
@@ -8131,24 +10008,32 @@ function partsOf(items) {
8131
10008
  }
8132
10009
  //#endregion
8133
10010
  //#region src/factory-mcp.ts
10011
+ /** The images an Agent names in a tool call, by their paths in its Workspace, `described` so. */
10012
+ function imagesInput(described) {
10013
+ return z.array(z.object({
10014
+ path: z.string().min(1).describe("An image file in your Workspace."),
10015
+ alt: z.string().max(IMAGE_LIMITS.alt).optional().describe("What the image shows.")
10016
+ })).max(IMAGE_LIMITS.images).optional().describe(described);
10017
+ }
8134
10018
  /** What the Agent passes to the hand-off tool. */
8135
10019
  const handOffInput = z.object({
8136
10020
  markdown: z.string().describe("Your Hand-off, a document in Markdown: what you did, what you found, and what whoever reads it next needs to know."),
8137
- images: z.array(z.object({
8138
- path: z.string().min(1).describe("An image file in your Workspace."),
8139
- alt: z.string().max(500).optional().describe("What the image shows.")
8140
- })).max(HAND_OFF_LIMITS.images).optional().describe("Small images that belong in the document, such as a screenshot."),
10021
+ images: imagesInput("Images that belong in the document, such as a screenshot."),
8141
10022
  references: z.array(handOffReference).max(HAND_OFF_LIMITS.references).optional().describe("Materials you produced, where they live: a label, and a URL or a pinned identifier (a pushed commit, say).")
8142
10023
  });
8143
10024
  /** What the Agent passes to the help tool. */
8144
- const helpInput = z.object({ question: z.string().describe("Your question for the Owner, in Markdown: what you need to know, and what you found that makes you ask.") });
10025
+ const helpInput = z.object({
10026
+ question: z.string().describe("Your question for the Owner, in Markdown: what you need to know, and what you found that makes you ask."),
10027
+ images: imagesInput("Images that show the Owner what you see, such as a screenshot: they see them as they are when you ask.")
10028
+ });
8145
10029
  /**
8146
10030
  * What the Inspector passes to the check tool: one flat object, as some CLIs want a tool's input
8147
10031
  * to be, which `judgementFrom` makes a judgement of.
8148
10032
  */
8149
10033
  const judgeInput = z.object({
8150
10034
  outcome: z.enum(["passed", "failed"]).describe("Whether the Hand-off meets every one of the Belt's criteria."),
8151
- annotations: z.string().optional().describe("When it failed: which criteria the Hand-off doesn't meet and why, in Markdown. Required then.")
10035
+ annotations: z.string().optional().describe("When it failed: which criteria the Hand-off doesn't meet and why, in Markdown. Required then."),
10036
+ images: imagesInput("When it failed: images that show what is wrong, such as a screenshot, beside the annotations.")
8152
10037
  });
8153
10038
  /** A tool's input schema, as JSON Schema. */
8154
10039
  function jsonSchemaOf(schema) {
@@ -8166,7 +10051,10 @@ async function serveFactoryMcp(options) {
8166
10051
  name: JUDGE_TOOL,
8167
10052
  description: `Records your judgement of the Hand-off under check. ${JUDGE_GUIDANCE}`,
8168
10053
  inputSchema: jsonSchemaOf(judgeInput),
8169
- handler: async (input) => options.judge(judgementFrom(input))
10054
+ handler: async (input) => {
10055
+ const { judgement: made, images } = await judgementFrom(options.workspace, input);
10056
+ return options.judge(made, images);
10057
+ }
8170
10058
  }]
8171
10059
  });
8172
10060
  return serveMcpTools({
@@ -8176,89 +10064,126 @@ async function serveFactoryMcp(options) {
8176
10064
  name: HAND_OFF_TOOL,
8177
10065
  description: `Delivers your finished work as a Hand-off. ${HAND_OFF_GUIDANCE}`,
8178
10066
  inputSchema: jsonSchemaOf(handOffInput),
8179
- handler: async (input) => options.handOff(await handOffFrom(options.workspace, input))
10067
+ handler: async (input) => {
10068
+ const { document, images } = await handOffFrom(options.workspace, input);
10069
+ return options.handOff(document, images);
10070
+ }
8180
10071
  }, {
8181
10072
  name: HELP_TOOL,
8182
10073
  description: `Asks the Owner a question when you're stuck. ${HELP_GUIDANCE}`,
8183
10074
  inputSchema: jsonSchemaOf(helpInput),
8184
- handler: async (input) => options.askForHelp(questionFrom(input))
10075
+ handler: async (input) => {
10076
+ const { question, images } = questionFrom(input);
10077
+ return options.askForHelp(question, await imagesIn(options.workspace, images, "question"));
10078
+ }
8185
10079
  }]
8186
10080
  });
8187
10081
  }
8188
- /** The question the Agent asked, or an error saying what's wrong with it, for the Agent. */
10082
+ /**
10083
+ * The question the Agent asked, and the images it named with it, or an error saying what's wrong
10084
+ * with it, for the Agent.
10085
+ */
8189
10086
  function questionFrom(input) {
8190
10087
  const asked = helpInput.safeParse(input);
8191
10088
  if (!asked.success) throw new Error(`That isn't a question for the Owner: ${z.prettifyError(asked.error)}`);
8192
10089
  const question = helpQuestion.safeParse(asked.data.question);
8193
10090
  if (!question.success) throw new Error(`That isn't a question for the Owner: ${z.prettifyError(question.error)}`);
8194
- return question.data;
10091
+ return {
10092
+ question: question.data,
10093
+ images: asked.data.images ?? []
10094
+ };
8195
10095
  }
8196
- /** The judgement the Inspector asked for, or an error saying what's wrong with it, for it. */
8197
- function judgementFrom(input) {
10096
+ /**
10097
+ * The judgement the Inspector asked for, with the images a failed one names read from its
10098
+ * Workspace, or an error saying what's wrong with it, for it. A passing judgement carries no
10099
+ * annotations or images: whatever it names is never read.
10100
+ */
10101
+ async function judgementFrom(workspace, input) {
8198
10102
  const asked = judgeInput.safeParse(input);
8199
10103
  if (!asked.success) throw new Error(`That isn't a judgement: ${z.prettifyError(asked.error)}`);
8200
10104
  const { outcome, annotations } = asked.data;
8201
10105
  if (outcome === "failed" && !annotations?.trim()) throw new Error("A Hand-off that failed needs annotations: say which criteria it doesn't meet and why.");
10106
+ const images = outcome === "failed" ? await imagesIn(workspace, asked.data.images ?? [], "annotations") : [];
8202
10107
  const made = judgement.safeParse(outcome === "failed" ? {
8203
10108
  outcome,
8204
- annotations
10109
+ annotations,
10110
+ images: images.map(documentImageOf)
8205
10111
  } : { outcome: "passed" });
8206
10112
  if (!made.success) throw new Error(`That isn't a judgement: ${z.prettifyError(made.error)}`);
8207
- return made.data;
10113
+ return {
10114
+ judgement: made.data,
10115
+ images
10116
+ };
8208
10117
  }
8209
- /** The Hand-off the Agent asked for, or an error saying what's wrong with it, for the Agent. */
10118
+ /**
10119
+ * The Hand-off the Agent asked for, with the images it names read from its Workspace, or an error
10120
+ * saying what's wrong with it, for the Agent.
10121
+ */
8210
10122
  async function handOffFrom(workspace, input) {
8211
10123
  const asked = handOffInput.safeParse(input);
8212
10124
  if (!asked.success) throw new Error(`That isn't a Hand-off: ${z.prettifyError(asked.error)}`);
8213
- const images = await Promise.all((asked.data.images ?? []).map((image) => imageFrom(workspace, image.path, image.alt)));
10125
+ const images = await imagesIn(workspace, asked.data.images ?? [], "hand-off");
8214
10126
  const made = handOff.safeParse({
8215
10127
  markdown: asked.data.markdown,
8216
- images,
10128
+ images: images.map(documentImageOf),
8217
10129
  references: asked.data.references ?? []
8218
10130
  });
8219
10131
  if (!made.success) throw new Error(`That isn't a Hand-off: ${z.prettifyError(made.error)}`);
8220
- return made.data;
10132
+ return {
10133
+ document: made.data,
10134
+ images
10135
+ };
8221
10136
  }
8222
- /** Reads an image for a Hand-off from the Workspace. */
8223
- async function imageFrom(workspace, path, alt) {
8224
- let file;
8225
- try {
8226
- file = await realpath(resolve(workspace, path));
8227
- } catch {
8228
- throw new Error(`There's no image at ${path} in your Workspace.`);
8229
- }
8230
- const fromRoot = relative(await realpath(workspace), file);
8231
- if (fromRoot.startsWith("..") || isAbsolute(fromRoot)) throw new Error(`${path} isn't in your Workspace: a Hand-off's images must be.`);
8232
- if ((await stat(file)).size > HAND_OFF_LIMITS.imageBytes) throw new Error(`${path} is larger than ${HAND_OFF_LIMITS.imageBytes / 1024} KiB. A Hand-off is a document: make the image smaller, or put it somewhere reachable and reference it.`);
8233
- const bytes = await readFile(file);
8234
- const mimeType = imageTypeOf(bytes);
8235
- if (!mimeType) throw new Error(`${path} isn't a PNG, JPEG, GIF or WebP image.`);
10137
+ /** How a document names an image the Agent named: its key in the image store, type and alt. */
10138
+ function documentImageOf({ key, mimeType, alt }) {
8236
10139
  return {
10140
+ key,
8237
10141
  mimeType,
8238
- data: bytes.toString("base64"),
8239
10142
  ...alt === void 0 ? {} : { alt }
8240
10143
  };
8241
10144
  }
8242
- /** An image's type, from its first bytes. */
8243
- function imageTypeOf(bytes) {
8244
- if (bytes.subarray(0, 8).equals(Buffer.from([
8245
- 137,
8246
- 80,
8247
- 78,
8248
- 71,
8249
- 13,
8250
- 10,
8251
- 26,
8252
- 10
8253
- ]))) return "image/png";
8254
- if (bytes.subarray(0, 3).equals(Buffer.from([
8255
- 255,
8256
- 216,
8257
- 255
8258
- ]))) return "image/jpeg";
8259
- if (bytes.subarray(0, 4).toString("latin1") === "GIF8") return "image/gif";
8260
- if (bytes.subarray(0, 4).toString("latin1") === "RIFF" && bytes.subarray(8, 12).toString("latin1") === "WEBP") return "image/webp";
8261
- return null;
10145
+ /** What a tool's images are for, in the words that refuse one. */
10146
+ const IMAGES_FOR = {
10147
+ "hand-off": {
10148
+ whose: "a Hand-off's",
10149
+ tooLarge: "A Hand-off is a document: make the image smaller, or put it somewhere reachable and reference it."
10150
+ },
10151
+ question: {
10152
+ whose: "a question's",
10153
+ tooLarge: "Make the image smaller: the Owner sees it as it is."
10154
+ },
10155
+ annotations: {
10156
+ whose: "annotations'",
10157
+ tooLarge: "Make the image smaller: whoever reworks the Hand-off sees it as it is."
10158
+ }
10159
+ };
10160
+ /**
10161
+ * Reads the images an Agent `named` in a tool call (its Hand-off, its question, or its annotations)
10162
+ * from its `workspace`, in order; rejects with why one can't be, in words for the Agent.
10163
+ */
10164
+ async function imagesIn(workspace, named, forWhat) {
10165
+ return Promise.all(named.map(({ path, alt }) => imageIn(workspace, path, alt, forWhat)));
10166
+ }
10167
+ /**
10168
+ * Reads one image the Agent named from its Workspace (`readWorkspaceImage`, the one way images are
10169
+ * read there), refusing one it can't take in words for the Agent.
10170
+ */
10171
+ async function imageIn(workspace, path, alt, forWhat) {
10172
+ const words = IMAGES_FOR[forWhat];
10173
+ const read = await readWorkspaceImage(workspace, path);
10174
+ if (read.kind === "image") {
10175
+ const { mimeType, bytes } = read;
10176
+ return {
10177
+ key: createHash("sha256").update(bytes).digest("hex"),
10178
+ mimeType,
10179
+ bytes,
10180
+ alt
10181
+ };
10182
+ }
10183
+ if (read.kind === "outside") throw new Error(`${path} isn't in your Workspace: ${words.whose} images must be.`);
10184
+ if (read.kind === "too-large") throw new Error(`${path} is larger than ${IMAGE_LIMITS.bytes / 1024 / 1024} MiB. ${words.tooLarge}`);
10185
+ if (read.kind === "not-an-image") throw new Error(`${path} isn't a PNG, JPEG, GIF or WebP image.`);
10186
+ throw new Error(`There's no image at ${path} in your Workspace.`);
8262
10187
  }
8263
10188
  //#endregion
8264
10189
  //#region src/tasks.ts
@@ -8359,7 +10284,9 @@ function added(a, b) {
8359
10284
  * through the Runtime driver. A Workstation Task's work hands off, or asks the Owner for help: its
8360
10285
  * turn ends, the Task is kept suspended with its Workspace and Session id, and the Owner's reply
8361
10286
  * resumes that Session there, in a new segment with the same instructions, a Factory MCP of its own
8362
- * and its Agent's Skills as the reply names them (#120), readied anew. A check of its Hand-off
10287
+ * and its Agent's Skills as the reply names them (#120), readied anew; the images the reply came
10288
+ * with (#186), which the Platform handed the Node before (handed-images.ts), are written into the
10289
+ * Workspace beside the question and come inside the prompt that resumes it. A check of its Hand-off
8363
10290
  * records the Inspector's judgement (ADR-0014). The Platform hears
8364
10291
  * through the connector's outbox that the Session started (each segment), the Hand-off, the
8365
10292
  * question or the judgement, and how the Session ended with the tokens it used, its segments'
@@ -8447,9 +10374,12 @@ function createTaskRunner(options) {
8447
10374
  }, options.handOffGraceMs);
8448
10375
  now.done.finally(() => clearTimeout(grace));
8449
10376
  };
8450
- /** Puts `message` in the outbox, keeping the call until it is there. */
8451
- const keep = async (message) => {
8452
- const kept = connector.send(message);
10377
+ /**
10378
+ * Puts `message` in the outbox, with the bytes of the images it names, keeping the call until it
10379
+ * is there.
10380
+ */
10381
+ const keep = async (message, images = []) => {
10382
+ const kept = connector.send(message, images);
8453
10383
  keeping.add(kept);
8454
10384
  try {
8455
10385
  await kept;
@@ -8458,13 +10388,16 @@ function createTaskRunner(options) {
8458
10388
  }
8459
10389
  };
8460
10390
  /**
8461
- * Relays what the Agent's tool call made (a Hand-off, or a judgement) and tells the Agent what
8462
- * became of it.
10391
+ * Relays what the Agent's tool call made (a Hand-off, or a judgement), with the images it names,
10392
+ * and tells the Agent what became of it.
8463
10393
  */
8464
- const relay = async (session, agent, message) => {
10394
+ const relay = async (session, agent, message, images = []) => {
8465
10395
  const acknowledged = connector.acknowledgement(message.id);
8466
10396
  stopAfterGrace(session, agent, message.kind === "hand-off" ? "handing off" : "judging", acknowledged);
8467
- await keep(message);
10397
+ await keep(message, images.map(({ key, bytes }) => ({
10398
+ key,
10399
+ bytes
10400
+ })));
8468
10401
  const waited = new AbortController();
8469
10402
  const ack = await Promise.race([acknowledged, setTimeout$1(options.handOffWaitMs, null, { signal: waited.signal }).catch(() => null)]);
8470
10403
  waited.abort();
@@ -8474,8 +10407,8 @@ function createTaskRunner(options) {
8474
10407
  if (ack.outcome === "refused") throw new Error(`The Platform didn't take this ${what}: ${ack.reason ?? "no reason given"}`);
8475
10408
  return `Your ${what} reached the Platform. Your ${done} is done: stop here.`;
8476
10409
  };
8477
- /** Relays the Agent's Hand-off. */
8478
- const handOff = (session, agent, document) => {
10410
+ /** Relays the Agent's Hand-off, with the images it names. */
10411
+ const handOff = (session, agent, document, images) => {
8479
10412
  const id = randomUUID();
8480
10413
  return relay(session, agent, {
8481
10414
  kind: "hand-off",
@@ -8483,10 +10416,10 @@ function createTaskRunner(options) {
8483
10416
  idempotencyKey: `hand-off:${session.taskId}:${id}`,
8484
10417
  taskId: session.taskId,
8485
10418
  handOff: document
8486
- });
10419
+ }, images);
8487
10420
  };
8488
- /** Relays the Inspector's judgement. */
8489
- const judge = (session, inspector, judgement) => {
10421
+ /** Relays the Inspector's judgement, with the images a failed one names. */
10422
+ const judge = (session, inspector, judgement, images) => {
8490
10423
  const id = randomUUID();
8491
10424
  return relay(session, inspector, {
8492
10425
  kind: "judgement",
@@ -8495,17 +10428,32 @@ function createTaskRunner(options) {
8495
10428
  taskId: session.taskId,
8496
10429
  checkId: session.checkId,
8497
10430
  judgement
8498
- });
10431
+ }, images);
8499
10432
  };
8500
10433
  /**
8501
10434
  * Sends the Owner the Agent's question (spec stories 56 and 57): once it is in the outbox, the
8502
10435
  * tool returns at once, telling the Agent to end its turn (ADR-0009). One question per segment:
8503
- * the reply to it starts the next.
10436
+ * the reply to it starts the next. The images it asks with are copied into its Workspace first,
10437
+ * as they are now (#185, question-images.ts), and its message names them without their bytes.
8504
10438
  */
8505
- const askForHelp = async (work, asking, question) => {
10439
+ const askForHelp = async (work, asking, question, images) => {
8506
10440
  if (asking.asked) throw new Error("You asked the Owner already: end your turn now. Their reply will continue this Session.");
8507
10441
  asking.asked = true;
8508
10442
  const id = randomUUID();
10443
+ const { taskId } = work.session;
10444
+ let kept;
10445
+ try {
10446
+ kept = await keepQuestionImages({
10447
+ sessionsDir: options.sessionsDir,
10448
+ taskId,
10449
+ question: id,
10450
+ number: work.segments,
10451
+ workspace: work.workspace
10452
+ }, images);
10453
+ } catch (error) {
10454
+ asking.asked = false;
10455
+ throw new Error(`Couldn't keep your images on this machine: ${messageOf(error)}`, { cause: error });
10456
+ }
8509
10457
  const acknowledged = connector.acknowledgement(id);
8510
10458
  stopAfterGrace(work.session, work.task.agent.name, "asking for help", acknowledged, () => {
8511
10459
  asking.stoppedAfter = true;
@@ -8514,9 +10462,10 @@ function createTaskRunner(options) {
8514
10462
  await keep({
8515
10463
  kind: "help-request",
8516
10464
  id,
8517
- idempotencyKey: `help-request:${work.session.taskId}:${id}`,
8518
- taskId: work.session.taskId,
8519
- question
10465
+ idempotencyKey: `help-request:${taskId}:${id}`,
10466
+ taskId,
10467
+ question,
10468
+ images: kept
8520
10469
  });
8521
10470
  } catch (error) {
8522
10471
  asking.asked = false;
@@ -8549,6 +10498,10 @@ function createTaskRunner(options) {
8549
10498
  const skills = skillsHeldBy.get(keyOf(session));
8550
10499
  skillsHeldBy.delete(keyOf(session));
8551
10500
  if (skills !== void 0 && !killed) await skills();
10501
+ if (session.role === "work" && !killed) await options.handed.forget({
10502
+ of: "reply",
10503
+ taskId: session.taskId
10504
+ });
8552
10505
  };
8553
10506
  /**
8554
10507
  * Opens a Session for `session` in `workspace` (or resumes the one `resume` names there) with the
@@ -8631,17 +10584,18 @@ function createTaskRunner(options) {
8631
10584
  const agent = task.agent.name;
8632
10585
  const factory = await serveFactoryMcp({
8633
10586
  role: "check",
8634
- judge: (judgement) => judge(session, agent, judgement)
10587
+ workspace,
10588
+ judge: (judgement, images) => judge(session, agent, judgement, images)
8635
10589
  });
8636
10590
  let segment;
8637
10591
  try {
8638
10592
  segment = await runSession(session, {
8639
10593
  task,
8640
10594
  workspace,
8641
- instructions: instructionsFor(task, files),
10595
+ instructions: instructionsFor(task, workspace, files),
8642
10596
  skills,
8643
10597
  factory,
8644
- prompt: promptFor(task),
10598
+ prompt: promptFor(task, files),
8645
10599
  startedKey: keyFor("task-started", session)
8646
10600
  }, stop);
8647
10601
  } finally {
@@ -8650,8 +10604,8 @@ function createTaskRunner(options) {
8650
10604
  await reportEnd(session, segment.outcome, segment.usage ?? null);
8651
10605
  };
8652
10606
  /**
8653
- * Runs the next segment of a Task's work: its first, with the Task's prompt, or one resuming its
8654
- * Session with the Owner's reply. Then, if its Agent asked the Owner for help and ended its turn,
10607
+ * Runs the next segment of a Task's work: its first, with the Task's prompt (the brief's images
10608
+ * inside it, #181), or one resuming its Session with the Owner's reply. Then, if its Agent asked the Owner for help and ended its turn,
8655
10609
  * the Task is suspended (or goes straight on, if the reply came meanwhile); otherwise its end is
8656
10610
  * reported, with the tokens of all its segments.
8657
10611
  */
@@ -8669,8 +10623,8 @@ function createTaskRunner(options) {
8669
10623
  const factory = await serveFactoryMcp({
8670
10624
  role: "work",
8671
10625
  workspace,
8672
- handOff: (document) => handOff(session, agent, document),
8673
- askForHelp: (question) => askForHelp(work, asking, question)
10626
+ handOff: (document, images) => handOff(session, agent, document, images),
10627
+ askForHelp: (question, images) => askForHelp(work, asking, question, images)
8674
10628
  });
8675
10629
  let segment;
8676
10630
  try {
@@ -8763,6 +10717,46 @@ function createTaskRunner(options) {
8763
10717
  if (before !== void 0 && !killed) await before();
8764
10718
  return true;
8765
10719
  };
10720
+ /**
10721
+ * The images `reply` came with (#186), written into its Task's Workspace beside the question it
10722
+ * answers (`help-request/<n>/reply-1.png`…, n the asking segment's number, as the question's own),
10723
+ * from where the Platform handed them; or how many of them there were, when they couldn't be.
10724
+ */
10725
+ const replyImagesOf = async (work, { handed }) => {
10726
+ if (handed === void 0) return {
10727
+ written: [],
10728
+ lost: 0
10729
+ };
10730
+ const { taskId } = work.session;
10731
+ try {
10732
+ const written = await options.handed.claim({
10733
+ of: "reply",
10734
+ taskId
10735
+ }, handed, work.workspace, {
10736
+ folder: posix.join(WORKSPACE_FOLDERS.helpRequest, String(work.segments)),
10737
+ stem: "reply"
10738
+ });
10739
+ await fileReplyImages({
10740
+ sessionsDir: options.sessionsDir,
10741
+ taskId,
10742
+ handing: handed.handing,
10743
+ workspace: work.workspace
10744
+ }, written);
10745
+ return {
10746
+ written,
10747
+ lost: 0
10748
+ };
10749
+ } catch (error) {
10750
+ onEvent({
10751
+ type: "problem",
10752
+ message: `Couldn't write the images of the reply to Task ${taskId} into its Workspace: ${messageOf(error)}`
10753
+ });
10754
+ return {
10755
+ written: [],
10756
+ lost: handed.images.length
10757
+ };
10758
+ }
10759
+ };
8766
10760
  /** Resumes a Task's work with the Owner's reply, in a new segment, in the background. */
8767
10761
  const goOn = (work, reply) => {
8768
10762
  if (stopping || killed) {
@@ -8784,7 +10778,10 @@ function createTaskRunner(options) {
8784
10778
  await options.underWay.remove(kept);
8785
10779
  }
8786
10780
  }
8787
- if (await readyForReply(work, reply, stop)) await workSegment(work, replyPromptFor(work.task, reply.reply), stop);
10781
+ if (await readyForReply(work, reply, stop)) {
10782
+ const images = await replyImagesOf(work, reply);
10783
+ await workSegment(work, replyPromptFor(work.task, reply.reply, images), stop);
10784
+ }
8788
10785
  }, () => work.usage ?? null);
8789
10786
  };
8790
10787
  /** Runs a Session the Platform sent, from its new Workspace on. */
@@ -8825,7 +10822,10 @@ function createTaskRunner(options) {
8825
10822
  try {
8826
10823
  files = {
8827
10824
  workpiece: await writeWorkpiece(workspace, task, options.documents),
8828
- ...task.role === "check" ? { underCheck: await writeHandOffUnderCheck(workspace, task, options.documents) } : { previous: await writePreviousAttempt(workspace, task, options.documents) }
10825
+ ...task.role === "check" ? { underCheck: await writeHandOffUnderCheck(workspace, task, options.documents) } : {
10826
+ previous: await writePreviousAttempt(workspace, task, options.documents),
10827
+ verdicts: await writeVerdictImages(workspace, task, options.documents)
10828
+ }
8829
10829
  };
8830
10830
  } catch (error) {
8831
10831
  await reportEnd(session, {
@@ -8851,7 +10851,7 @@ function createTaskRunner(options) {
8851
10851
  taskId
8852
10852
  },
8853
10853
  workspace,
8854
- instructions: instructionsFor(task, files),
10854
+ instructions: instructionsFor(task, workspace, files),
8855
10855
  skills: readied.skills,
8856
10856
  state: "running",
8857
10857
  sessionId: null,
@@ -8863,7 +10863,7 @@ function createTaskRunner(options) {
8863
10863
  saved: Promise.resolve()
8864
10864
  };
8865
10865
  works.set(taskId, work);
8866
- await workSegment(work, promptFor(task), stop);
10866
+ await workSegment(work, promptFor(task, files), stop);
8867
10867
  };
8868
10868
  /** Ends every Session still running, and waits for what is on its way into the outbox. */
8869
10869
  const stopAll = async () => {
@@ -8879,19 +10879,21 @@ function createTaskRunner(options) {
8879
10879
  if (running.has(keyOf(session)) || session.role === "work" && works.has(session.taskId)) return;
8880
10880
  inBackground(session, (stop) => run(task, stop), () => works.get(session.taskId)?.usage ?? null);
8881
10881
  },
8882
- resume({ taskId, reply, skills }) {
10882
+ resume({ taskId, reply, skills, handed }) {
8883
10883
  const work = works.get(taskId);
8884
10884
  if (work?.state === "suspended") {
8885
10885
  goOn(work, {
8886
10886
  reply,
8887
- skills
10887
+ skills,
10888
+ handed
8888
10889
  });
8889
10890
  return;
8890
10891
  }
8891
10892
  if (work) {
8892
10893
  work.reply = {
8893
10894
  reply,
8894
- skills
10895
+ skills,
10896
+ handed
8895
10897
  };
8896
10898
  return;
8897
10899
  }
@@ -8990,6 +10992,11 @@ function createTaskRunner(options) {
8990
10992
  ending(reportEnd(ref, { kind: "aborted" }, left.suspended.usage, left.item));
8991
10993
  }
8992
10994
  },
10995
+ whyNoReply(taskId) {
10996
+ if (works.has(taskId)) return null;
10997
+ const left = leftSuspended.get(taskId);
10998
+ return left ? `the node daemon restarted while ${left.suspended.agent} waited for your reply, and doesn't keep a waiting Session across a restart` : "it isn't keeping that Task's Session";
10999
+ },
8993
11000
  stop: stopAll,
8994
11001
  async leave() {
8995
11002
  leaving = true;
@@ -9200,6 +11207,10 @@ async function startNode(options) {
9200
11207
  message: `Discarded a message in the outbox that this daemon can't read (${kind === null ? file : `${file}, a ${kind}`}): the Platform never hears it.`
9201
11208
  });
9202
11209
  const inbox = await openInbox(join(options.stateDir, "inbox"));
11210
+ const handed = await openHandedImages({
11211
+ dir: join(options.stateDir, "handed-images"),
11212
+ accepts: (to) => to.of === "reply" ? runner.whyNoReply(to.taskId) : chats.whyNoImages(to.chatId, to.turn)
11213
+ });
9203
11214
  const underWay = await openTasksUnderWay(join(options.stateDir, "under-way"), (message) => onEvent({
9204
11215
  type: "problem",
9205
11216
  message
@@ -9222,6 +11233,7 @@ async function startNode(options) {
9222
11233
  credential,
9223
11234
  os: options.os ?? await describeMachine(),
9224
11235
  outbox,
11236
+ uploadImages: platformImageUploads(credential),
9225
11237
  inbox,
9226
11238
  onMessage: (message) => {
9227
11239
  switch (message.kind) {
@@ -9296,7 +11308,8 @@ async function startNode(options) {
9296
11308
  signal: stopRequests.signal,
9297
11309
  machine: options.machine,
9298
11310
  workspacesDir,
9299
- millwrightDir: millwrightAtWorkFolder(options.stateDir)
11311
+ millwrightDir: millwrightAtWorkFolder(options.stateDir),
11312
+ handed
9300
11313
  });
9301
11314
  const skills = createSkillCopies({
9302
11315
  stateDir: options.stateDir,
@@ -9310,7 +11323,10 @@ async function startNode(options) {
9310
11323
  ...options.clock === void 0 ? {} : { clock: options.clock },
9311
11324
  intervalMs: options.sweepIntervalMs ?? 864e5,
9312
11325
  onSwept: (workspace) => chats.swept(workspace),
9313
- sweepAlso: (before) => skills.sweep(before),
11326
+ sweepAlso: async (before) => {
11327
+ await skills.sweep(before);
11328
+ await handed.sweep(before);
11329
+ },
9314
11330
  onEvent
9315
11331
  });
9316
11332
  const runner = createTaskRunner({
@@ -9326,7 +11342,8 @@ async function startNode(options) {
9326
11342
  underWay,
9327
11343
  workspaces: sweep,
9328
11344
  skills,
9329
- checkoutTimeoutMs: options.checkoutTimeoutMs ?? 6e5
11345
+ checkoutTimeoutMs: options.checkoutTimeoutMs ?? 6e5,
11346
+ handed
9330
11347
  });
9331
11348
  const chats = createChatRunner({
9332
11349
  connector,
@@ -9339,6 +11356,7 @@ async function startNode(options) {
9339
11356
  workspaces: sweep,
9340
11357
  skills,
9341
11358
  checkoutTimeoutMs: options.checkoutTimeoutMs ?? 6e5,
11359
+ handed,
9342
11360
  onEvent
9343
11361
  });
9344
11362
  await runner.reportLeftUnderWay();
@@ -9749,7 +11767,7 @@ async function runCli(args, io, context = {}) {
9749
11767
  const stateDir = defaultStateDir(context.env);
9750
11768
  if (values.version && positionals.length === 0) {
9751
11769
  const version = installed.from === "npm" ? ` ${installed.version}` : "";
9752
- io.log(`${PROGRAM}${version} (protocol 24)`);
11770
+ io.log(`${PROGRAM}${version} (protocol 25)`);
9753
11771
  return 0;
9754
11772
  }
9755
11773
  if (verb === "join" && rest.length === 2) {
@@ -9850,7 +11868,8 @@ async function startCommand(io, started, context) {
9850
11868
  ...context.protocolRetryMs === void 0 ? {} : { protocolRetryMs: context.protocolRetryMs }
9851
11869
  });
9852
11870
  node.resolve(running);
9853
- context.signal?.addEventListener("abort", () => void stop(), { once: true });
11871
+ if (context.signal?.aborted) stop();
11872
+ else context.signal?.addEventListener("abort", () => void stop(), { once: true });
9854
11873
  await running.stopped;
9855
11874
  await stopping;
9856
11875
  if (successor && !context.signal?.aborted) return successor.npm.run(successor.version, started.args, {
@@ -9862,12 +11881,12 @@ async function startCommand(io, started, context) {
9862
11881
  /** What the CLI prints of a move the daemon makes once the Platform refused its protocol. */
9863
11882
  function describeMove(move) {
9864
11883
  if (move.kind === "unreadable") return {
9865
- text: `The Platform refused this daemon's protocol (24) without saying which it speaks: ${move.reason}`,
11884
+ text: `The Platform refused this daemon's protocol (25) without saying which it speaks: ${move.reason}`,
9866
11885
  problem: true
9867
11886
  };
9868
11887
  const { platformVersion } = move;
9869
- if (move.kind === "wait-for-platform") return { text: `The Platform speaks protocol ${platformVersion}, older than this daemon's 24. Waiting for the Platform to be updated; trying again every few minutes.` };
9870
- const newer = `The Platform speaks protocol ${platformVersion}, newer than this daemon's 24`;
11888
+ if (move.kind === "wait-for-platform") return { text: `The Platform speaks protocol ${platformVersion}, older than this daemon's 25. Waiting for the Platform to be updated; trying again every few minutes.` };
11889
+ const newer = `The Platform speaks protocol ${platformVersion}, newer than this daemon's 25`;
9871
11890
  if (move.kind === "update-checkout") return {
9872
11891
  text: `${newer}: update the checkout, then start the Node again.`,
9873
11892
  problem: true