@kubitnodes/stellaris 0.3.27 → 0.4.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 (60) hide show
  1. package/dist/cli/index.js +415 -56
  2. package/dist/server/index.js +737 -112
  3. package/package.json +2 -2
  4. package/web/dist/assets/{BackLink-qSxOMeEG.js → BackLink-BLiSCuKv.js} +1 -1
  5. package/web/dist/assets/{Button-CxjWy598.js → Button-D4-iu6aX.js} +1 -1
  6. package/web/dist/assets/ChannelForms-DjviQfVm.js +1 -0
  7. package/web/dist/assets/{ChannelView-rqfk1Tyu.js → ChannelView-B-0H7Ps8.js} +2 -2
  8. package/web/dist/assets/CitizenView-gE5GgCBa.js +2 -0
  9. package/web/dist/assets/{CitizensView-Bgw6PRSo.js → CitizensView-DXD3qkq1.js} +1 -1
  10. package/web/dist/assets/{CliIcon-BB2NdLxN.js → CliIcon-DL4ZwgZv.js} +1 -1
  11. package/web/dist/assets/{CronsView-BrXrcfC6.js → CronsView-CX_G5D3h.js} +1 -1
  12. package/web/dist/assets/{FileTree-CHDmrOhY.js → FileTree-BFa_KVI8.js} +1 -1
  13. package/web/dist/assets/{FileView-DUMfKYRW.js → FileView-Cd9Ijarf.js} +2 -2
  14. package/web/dist/assets/KnowledgeView-D_a4pjGV.js +1 -0
  15. package/web/dist/assets/{Markdown-C7RbUEZE.js → Markdown-DPqvK--4.js} +4 -4
  16. package/web/dist/assets/{MessageItem-DgWh5pJF.js → MessageItem-WBTzWwyj.js} +1 -1
  17. package/web/dist/assets/MetricsView-YUkYixqT.js +1 -0
  18. package/web/dist/assets/NeedsYouView-CE_B9bZQ.js +1 -0
  19. package/web/dist/assets/Overview-BGUnf6K1.js +1 -0
  20. package/web/dist/assets/{Pane-BlIRwUtQ.js → Pane-C9TbEME4.js} +1 -1
  21. package/web/dist/assets/{Picker-BSOB2JM_.js → Picker-BK48MjLY.js} +1 -1
  22. package/web/dist/assets/{ProjectView-CVt28SoI.js → ProjectView-sL3aGAC6.js} +1 -1
  23. package/web/dist/assets/ProposalView-BhZR4MIR.js +1 -0
  24. package/web/dist/assets/{ProposalsView-C0r1ya8_.js → ProposalsView-DTGx8NDn.js} +1 -1
  25. package/web/dist/assets/{Quota-59QJZrPa.js → Quota-BkF7pa0u.js} +1 -1
  26. package/web/dist/assets/RunnersView-Bl62pXNw.js +1 -0
  27. package/web/dist/assets/{SkillFileList-Cs_f3RPo.js → SkillFileList-BUpKUkaG.js} +1 -1
  28. package/web/dist/assets/{SkillView-BvVx3LSL.js → SkillView-BQJApSh7.js} +1 -1
  29. package/web/dist/assets/{SocietyView-CJRrypjI.js → SocietyView-JSdVE50m.js} +1 -1
  30. package/web/dist/assets/{StickyList-BbGq82Jj.js → StickyList-DafbJutb.js} +1 -1
  31. package/web/dist/assets/{SubjectThread-DdkyhVqK.js → SubjectThread-Jyl5YCFL.js} +1 -1
  32. package/web/dist/assets/{TaskView-CyEWpCvS.js → TaskView-Jke2pVGT.js} +2 -2
  33. package/web/dist/assets/{TasksView-D10XP1uk.js → TasksView-zJTIaqEA.js} +1 -1
  34. package/web/dist/assets/{ThreadForms-D94Jj0ES.js → ThreadForms-DwUOktyP.js} +1 -1
  35. package/web/dist/assets/{ThreadItem-CYmdCPOf.js → ThreadItem-Blx8gSvO.js} +1 -1
  36. package/web/dist/assets/{ThreadView-D79G2zgF.js → ThreadView-CdQpBb7M.js} +1 -1
  37. package/web/dist/assets/citizen-BOn5BBRL.js +1 -0
  38. package/web/dist/assets/compose-D-X3BQgY.js +1 -0
  39. package/web/dist/assets/format-Dhd2NUB3.js +55 -0
  40. package/web/dist/assets/{governance-YialJVYK.js → governance-CWzi7hzX.js} +1 -1
  41. package/web/dist/assets/index-CA_njyMe.css +2 -0
  42. package/web/dist/assets/index-iplooQqq.js +24 -0
  43. package/web/dist/assets/{mentions-Buc5vs-j.js → mentions-S-r32HKM.js} +1 -1
  44. package/web/dist/assets/{tasks-DLTeaHys.js → tasks-D_Fy7dtN.js} +1 -1
  45. package/web/dist/assets/useMatch-DZXCHHaG.js +1 -0
  46. package/web/dist/index.html +9 -9
  47. package/web/dist/assets/ChannelForms-BVidWMId.js +0 -1
  48. package/web/dist/assets/CitizenView-xU99R8hS.js +0 -2
  49. package/web/dist/assets/KnowledgeView-Qog6c92V.js +0 -1
  50. package/web/dist/assets/MetricsView-DuOyISk1.js +0 -1
  51. package/web/dist/assets/NeedsYouView-CrmHXZAn.js +0 -1
  52. package/web/dist/assets/Overview-xatORJK6.js +0 -1
  53. package/web/dist/assets/ProposalView-DJ0aJm7J.js +0 -1
  54. package/web/dist/assets/RunnersView-CcAAfw6y.js +0 -1
  55. package/web/dist/assets/citizen-CgA4vJjy.js +0 -1
  56. package/web/dist/assets/compose-3ksQaHF6.js +0 -1
  57. package/web/dist/assets/format-DpQyO6r1.js +0 -55
  58. package/web/dist/assets/index-DIa8Dlav.css +0 -2
  59. package/web/dist/assets/index-DhuCp-VF.js +0 -23
  60. package/web/dist/assets/useMatch-DPEg8ohe.js +0 -1
@@ -3,7 +3,6 @@
3
3
  // apps/server/dist/index.js
4
4
  import { access as access2 } from "node:fs/promises";
5
5
  import path11 from "node:path";
6
- import { serve } from "@hono/node-server";
7
6
 
8
7
  // packages/board-core/dist/board.js
9
8
  import { chmod as chmod2, cp, mkdir as mkdir5, readdir as readdir4, readFile as readFile4, rename as rename3, rm as rm3, stat as stat3, writeFile as writeFile4 } from "node:fs/promises";
@@ -65,7 +64,8 @@ var VERB_NAMES = [
65
64
  "update_cron",
66
65
  "remove_cron",
67
66
  "remove_skill",
68
- "upload_image"
67
+ "upload_image",
68
+ "read_image"
69
69
  ];
70
70
  var VerbNameSchema = z2.enum(VERB_NAMES);
71
71
  var CharterTriggerSchema = z2.enum(["user_post", "ops_event", "heartbeat"]);
@@ -95,6 +95,7 @@ var USER_NAME = "user";
95
95
  var MEMBER_VERBS = [
96
96
  "post_message",
97
97
  "upload_image",
98
+ "read_image",
98
99
  "read_inbox",
99
100
  "search",
100
101
  "open_thread",
@@ -226,7 +227,12 @@ var CronScheduleSchema = z3.union([
226
227
  var CronRunSchema = z3.object({
227
228
  turnId: UlidSchema,
228
229
  at: IsoDateTimeSchema,
229
- outcome: z3.enum(["completed", "failed", "stopped"])
230
+ outcome: z3.enum(["completed", "failed", "stopped"]),
231
+ /**
232
+ * For a failed one, the first turn of the cron's citizen in its conversation that was shown the
233
+ * failure and completed, and when it ended.
234
+ */
235
+ caughtUp: z3.object({ turnId: UlidSchema, at: IsoDateTimeSchema }).optional()
230
236
  });
231
237
  var CronFrontmatterSchema = z3.object({
232
238
  id: UlidSchema,
@@ -273,6 +279,16 @@ function shortestGapMs(schedule, societyZone, after) {
273
279
  }
274
280
  return shortest;
275
281
  }
282
+ function firesInTask(cron, task) {
283
+ return cron.scope === task.project && cron.thread === task.id;
284
+ }
285
+ function cronFailure(cron) {
286
+ return cron.lastRun?.outcome === "failed" && cron.lastRun.caughtUp === void 0 ? cron.lastRun : null;
287
+ }
288
+ function cronStoppedAt(cron) {
289
+ const times = [cron.paused?.at, cron.ended?.at, cronFailure(cron)?.at].filter((at) => at !== void 0);
290
+ return times.reduce((first, at) => first === null || Date.parse(at) < Date.parse(first) ? at : first, null);
291
+ }
276
292
  function nextFire(cron, societyZone, floorMs = CRON_FLOOR_MS) {
277
293
  if (cron.ended !== void 0 || cron.paused !== void 0) {
278
294
  return null;
@@ -704,6 +720,7 @@ var MemberSchema = z5.object({
704
720
  });
705
721
  var RunnerOsSchema = z5.enum(["linux", "windows", "darwin"]);
706
722
  var GitIdentityPartSchema = z5.enum(["name", "email"]);
723
+ var RunnerPauseSchema = z5.object({ at: IsoDateTimeSchema, by: NameSchema });
707
724
  var RunnerSchema = z5.object({
708
725
  name: NameSchema,
709
726
  os: RunnerOsSchema,
@@ -722,8 +739,25 @@ var RunnerSchema = z5.object({
722
739
  * Each CLI's plan quota as its login on this runner last reported it, from a turn's stream or a
723
740
  * read; absent until one has.
724
741
  */
725
- quotas: z5.partialRecord(CliKindSchema, CliQuotaSchema).optional()
742
+ quotas: z5.partialRecord(CliKindSchema, CliQuotaSchema).optional(),
743
+ /** The user's pause of the whole runner: no turn starts on it until the user resumes it. */
744
+ paused: RunnerPauseSchema.optional(),
745
+ /**
746
+ * The user's pauses of single CLIs on the runner, each apart from the runner's own: no turn of
747
+ * that CLI starts on it until the user resumes it.
748
+ */
749
+ pausedClis: z5.partialRecord(CliKindSchema, RunnerPauseSchema).optional()
726
750
  });
751
+ function runnerPause(runner, cli) {
752
+ return runner.paused ?? runner.pausedClis?.[cli];
753
+ }
754
+ function heldOn(runners2, runner, cli) {
755
+ if (runner === void 0 || cli === null) {
756
+ return false;
757
+ }
758
+ const record = runners2.find((each) => each.name === runner);
759
+ return record !== void 0 && runnerPause(record, cli) !== void 0;
760
+ }
727
761
  var ProposalKindSchema = z5.enum([
728
762
  "role",
729
763
  "member",
@@ -1073,7 +1107,21 @@ function imageTypeOf(bytes) {
1073
1107
  return null;
1074
1108
  }
1075
1109
  var ImagePathSchema = z6.string().min(1).max(4096).refine((value) => !value.includes("\0"), { message: "a path with no NUL character" });
1110
+ var ImageNameSchema = z6.string().max(1024);
1111
+ function imageDescription(name) {
1112
+ return name.replaceAll(/[[\]\\\r\n`]/g, "");
1113
+ }
1076
1114
  var IMAGE_SCHEME = "image:";
1115
+ var ImageRefSchema = z6.string().regex(/^(?:image:)?[0-9A-HJKMNP-TV-Z]{26}$/, "expected an image's id or image:<id>");
1116
+ function imageIdOf(ref) {
1117
+ return ref.startsWith(IMAGE_SCHEME) ? ref.slice(IMAGE_SCHEME.length) : ref;
1118
+ }
1119
+ var ImageFileSchema = z6.object({
1120
+ id: UlidSchema,
1121
+ type: ImageTypeSchema,
1122
+ size: z6.number().int().positive(),
1123
+ path: ImagePathSchema
1124
+ });
1077
1125
  var UploadedImageSchema = z6.object({
1078
1126
  id: UlidSchema,
1079
1127
  type: ImageTypeSchema,
@@ -1238,6 +1286,10 @@ var VerbInputs = {
1238
1286
  upload_image: z7.object({
1239
1287
  /** The image file on the machine your turn runs on: absolute, or relative to your working directory. */
1240
1288
  path: ImagePathSchema
1289
+ }),
1290
+ read_image: z7.object({
1291
+ /** The image's id, or its `image:<id>` address as a post shows it. */
1292
+ image_id: ImageRefSchema
1241
1293
  })
1242
1294
  };
1243
1295
  var VERB_DESCRIPTIONS = {
@@ -1249,7 +1301,7 @@ var VERB_DESCRIPTIONS = {
1249
1301
  create_task: "Create a task in a project with its plan: stages of {name, role or agent, gate}. Without stages it gets one stage, work, that anyone in the project may take. File it in a channel of the project, such as a release's, or general by default; its thread opens there with it, under the task's id, for everything said about the work.",
1250
1302
  claim_task: "Hold the task's current stage. Claims are leases renewed by every turn that touches the task.",
1251
1303
  release_task: "Let go of the stage you hold so someone else can take it.",
1252
- update_task: "Move a task back to an earlier stage (the current stage's holder, the user, the steward, or the concierge; anyone else asks the concierge in the task's thread), abandon it, set the tasks it is blocked by (blocked_by: its stage wakes nobody until they have all ended), or link its pull request (pull_request: url, with the merge commit's merge_subject and merge_body), which a ghpr task needs before its last stage completes. A note is posted to the task's thread.",
1304
+ update_task: "Move a task back to an earlier stage (the current stage's holder, the user, the steward, or the concierge; anyone else asks the concierge in the task's thread, with a mention), abandon it, set the tasks it is blocked by (blocked_by: its stage wakes nobody until they have all ended), or link its pull request (pull_request: url, with the merge commit's merge_subject and merge_body), which a ghpr task needs before its last stage completes. A note is posted to the task's thread.",
1253
1305
  get_task: "Read a task with its plan, its brief, and the messages of its thread.",
1254
1306
  subscribe: "Subscribe to a channel. Subscriptions feed your digest; they never wake you.",
1255
1307
  unsubscribe: "Unsubscribe from a channel.",
@@ -1272,7 +1324,8 @@ var VERB_DESCRIPTIONS = {
1272
1324
  update_cron: "Pause or resume a cron, or change its title, note, or schedule; resuming or rescheduling starts it afresh from now. Yours, or anyone's for the user, the steward, and the concierge.",
1273
1325
  remove_cron: "End a cron for good, with a reason. Yours, or anyone's for the user, the steward, and the concierge. A cron also ends with its conversation, and a one-time cron once it fires.",
1274
1326
  remove_skill: "Remove a skill from the society's skills, with a reason, when it is wrong, superseded, or no longer used: no skills index lists it from the next turn, and a notice in general says so with the reason. Its proposer's own copy stays in its home. Steward and user only; the board keeps its text aside for the user to restore.",
1275
- upload_image: "Upload an image file, such as a screenshot or a chart, from the machine your turn runs on: a PNG, JPEG, GIF, or WebP of at most 8 MB, in your worktree, your home, or the system temp folder. Returns its id and the markdown that shows it in a post, `![what it shows](image:<id>)`; write what it shows as the alt text."
1327
+ upload_image: "Upload an image file, such as a screenshot or a chart, from the machine your turn runs on: a PNG, JPEG, GIF, or WebP of at most 8 MB, in your worktree, your home, or the system temp folder. Returns its id and the markdown that shows it in a post, `![what it shows](image:<id>)`; write what it shows as the alt text.",
1328
+ read_image: "Open an image the board keeps, as a post shows it, `![what it shows](image:<id>)`, by its id: the board writes it to a file of this turn's own on the machine it runs on, removed when the turn ends, and returns its path, type, and size. Open the path with your file tools to see the image."
1276
1329
  };
1277
1330
 
1278
1331
  // packages/shared/dist/events.js
@@ -1323,6 +1376,8 @@ var BOARD_EVENT_TYPES = [
1323
1376
  "user.token_rotated",
1324
1377
  "runner.changed",
1325
1378
  "runner.quota",
1379
+ "runner.paused",
1380
+ "runner.resumed",
1326
1381
  "ops.signal",
1327
1382
  "paused.changed",
1328
1383
  "wake.requested",
@@ -1472,6 +1527,11 @@ var RunnerHelloSchema = z9.object({
1472
1527
  quotaReads: z9.boolean().default(false),
1473
1528
  /** Whether the runner reads an image a turn running there uploads; one from before never does. */
1474
1529
  imageReads: z9.boolean().default(false),
1530
+ /**
1531
+ * Whether the runner writes an image the board sends for a turn running there to open, for
1532
+ * `read_image`; one from before never does.
1533
+ */
1534
+ imageWrites: z9.boolean().default(false),
1475
1535
  /**
1476
1536
  * Whether the runner carries a project's repository out to another runner and in from one, and
1477
1537
  * removes its copy once the project has moved; one from before never does.
@@ -1593,6 +1653,12 @@ var TurnJobSchema = z9.object({
1593
1653
  usageSoFar: UsageSchema.optional(),
1594
1654
  /** The rendered charter, norms, memory core, and skills index, with `PATH_TOKENS` for places. */
1595
1655
  instructions: z9.string(),
1656
+ /**
1657
+ * The hash of `instructions` without the first turn's section (`instructionsHash` in
1658
+ * `turn-host`): a warm process started on other instructions starts afresh, so a compaction in
1659
+ * it puts back the current ones. Absent from a server from before, when the text is compared.
1660
+ */
1661
+ instructionsHash: z9.string().optional(),
1596
1662
  prompt: z9.string(),
1597
1663
  mcp: z9.object({ url: z9.string().min(1), token: z9.string().min(1) }),
1598
1664
  limits: z9.object({
@@ -1777,6 +1843,13 @@ var TurnImageSchema = z9.discriminatedUnion("kind", [
1777
1843
  }),
1778
1844
  z9.object({ kind: z9.literal("refused"), why: ImageRefusalSchema, detail: z9.string() })
1779
1845
  ]);
1846
+ var TurnImageWriteSchema = z9.object({
1847
+ turnId: UlidSchema,
1848
+ id: UlidSchema,
1849
+ type: ImageTypeSchema,
1850
+ content: z9.string()
1851
+ });
1852
+ var TurnImageFileSchema = z9.object({ path: ImagePathSchema });
1780
1853
  var WorkspaceConversationSchema = z9.discriminatedUnion("kind", [
1781
1854
  z9.object({ kind: z9.literal("thread"), id: UlidSchema }),
1782
1855
  z9.object({ kind: z9.literal("channel"), scope: NameSchema, name: NameSchema }),
@@ -1799,6 +1872,10 @@ var HeldWorkspaceSchema = z9.object({
1799
1872
  var HeldWorkspacesSchema = z9.object({ workspaces: z9.array(HeldWorkspaceSchema) });
1800
1873
  var SweepResultSchema = z9.object({ kept: z9.array(HeldWorkspaceSchema) });
1801
1874
  var MOVE_PACK_LIMIT_BYTES = 256 * 1024 * 1024;
1875
+ var MOVE_FLOOR_BYTES_PER_SECOND = 16 * 1024;
1876
+ function moveTransferMs(bytes) {
1877
+ return 10 * 6e4 + Math.ceil(bytes / MOVE_FLOOR_BYTES_PER_SECOND) * 1e3;
1878
+ }
1802
1879
  var RepoRefsSchema = z9.record(z9.string().regex(/^refs\/(heads|tags)\/\S+$/), z9.string().regex(/^[0-9a-f]{40}([0-9a-f]{24})?$/));
1803
1880
  var ProjectExportSchema = z9.object({ move: UlidSchema, repo: ProjectRepoSchema });
1804
1881
  var HeldWorkSchema = z9.object({
@@ -1822,7 +1899,9 @@ var ProjectExportedSchema = z9.discriminatedUnion("ok", [
1822
1899
  var ProjectImportSchema = z9.object({
1823
1900
  move: UlidSchema,
1824
1901
  repo: ProjectRepoSchema,
1825
- pullRequests: z9.boolean()
1902
+ pullRequests: z9.boolean(),
1903
+ /** The packs' size the old runner measured, which the clone's clock is sized from; the cap if unsaid. */
1904
+ bytes: z9.number().int().nonnegative().optional()
1826
1905
  });
1827
1906
  var ProjectImportedSchema = z9.object({ refs: RepoRefsSchema });
1828
1907
  var ProjectReleaseSchema = z9.object({ slug: NameSchema });
@@ -1862,6 +1941,15 @@ var RunnerMessageSchema = z9.discriminatedUnion("type", [
1862
1941
  * whose hello says `imageReads`.
1863
1942
  */
1864
1943
  z9.object({ type: z9.literal("image"), request: z9.string().min(1), read: TurnImageReadSchema }),
1944
+ /**
1945
+ * Answered with a `TurnImageFile`, or null when the turn is not running there. Sent only to
1946
+ * runners whose hello says `imageWrites`.
1947
+ */
1948
+ z9.object({
1949
+ type: z9.literal("image_write"),
1950
+ request: z9.string().min(1),
1951
+ write: TurnImageWriteSchema
1952
+ }),
1865
1953
  /** Answered with whether the CLI accepted it; that it took it comes as a `steered` event. */
1866
1954
  z9.object({
1867
1955
  type: z9.literal("steer"),
@@ -1927,6 +2015,7 @@ var TriggerKindSchema = z10.enum([
1927
2015
  "closing",
1928
2016
  "cron"
1929
2017
  ]);
2018
+ var HeartbeatCauseSchema = z10.enum(["unread", "held", "waiting"]);
1930
2019
  var TriggerSchema = z10.object({
1931
2020
  kind: TriggerKindSchema,
1932
2021
  from: NameSchema.optional(),
@@ -1937,7 +2026,9 @@ var TriggerSchema = z10.object({
1937
2026
  /** The channel the waking message was posted in. */
1938
2027
  channel: ChannelRefSchema.optional(),
1939
2028
  /** The cron that came due, for a `cron` wake. */
1940
- cronId: UlidSchema.optional()
2029
+ cronId: UlidSchema.optional(),
2030
+ /** What a `heartbeat` woke for; absent on records from before the board kept it. */
2031
+ heartbeat: HeartbeatCauseSchema.optional()
1941
2032
  });
1942
2033
  var TurnThreadSchema = z10.object({ id: UlidSchema, task: z10.boolean() });
1943
2034
  var TurnDispatchSchema = z10.object({
@@ -2019,6 +2110,12 @@ var TurnRecordSchema = z10.object({
2019
2110
  * one. A resumed session's next turn measures its own usage from here.
2020
2111
  */
2021
2112
  sessionUsage: UsageSchema.optional(),
2113
+ /**
2114
+ * The SHA-256 of the instructions the session holds after this turn, the first turn's section
2115
+ * left out (`instructionsHash` in `turn-host`): those it began with, or the latest a prompt
2116
+ * carried. Absent when the board does not know them, as for records from before it kept them.
2117
+ */
2118
+ sessionInstructionsHash: z10.string().optional(),
2022
2119
  /**
2023
2120
  * The whole input of the turn's latest model call in the session's own thread, as the runner
2024
2121
  * reported it: how large the session had grown. Absent for a turn that made no call.
@@ -2043,7 +2140,13 @@ var TurnRecordSchema = z10.object({
2043
2140
  */
2044
2141
  sessionDropped: z10.boolean().optional(),
2045
2142
  /** The crons whose fire started the turn, which record how it ended. */
2046
- crons: z10.array(UlidSchema).optional()
2143
+ crons: z10.array(UlidSchema).optional(),
2144
+ /**
2145
+ * The failed fires its prompt named: each of its citizen's crons in its conversation whose latest
2146
+ * fire's turn failed with nothing catching it up (`cronFailure`), but those it carries, with that
2147
+ * turn. Completing it catches each up.
2148
+ */
2149
+ cronFailures: z10.array(z10.object({ cron: UlidSchema, turnId: UlidSchema })).optional()
2047
2150
  });
2048
2151
  var RunningTurnSchema = z10.object({
2049
2152
  turnId: UlidSchema,
@@ -2085,6 +2188,8 @@ var TurnHistoryEntrySchema = z10.object({
2085
2188
  /** The project whose channels' conversation the turn was in, for a turn in the society scope. */
2086
2189
  place: NameSchema.optional(),
2087
2190
  trigger: z10.string(),
2191
+ /** What a `heartbeat` woke for; absent on turns logged before the board kept it. */
2192
+ heartbeat: HeartbeatCauseSchema.optional(),
2088
2193
  exitReason: z10.string().nullable(),
2089
2194
  costUsd: z10.number(),
2090
2195
  /** The tokens the turn used; absent for turns logged before turns recorded them. */
@@ -2158,7 +2263,7 @@ var METRICS_WINDOW_MS = {
2158
2263
  "7d": 7 * 24 * 36e5,
2159
2264
  all: null
2160
2265
  };
2161
- var READ_VERBS = ["read_inbox", "search", "get_task"];
2266
+ var READ_VERBS = ["read_inbox", "search", "get_task", "read_image"];
2162
2267
  function isBoardAction(tool) {
2163
2268
  const verb = /^mcp__board__(.+)$/.exec(tool)?.[1];
2164
2269
  return verb !== void 0 && !READ_VERBS.some((read2) => read2 === verb);
@@ -2172,12 +2277,19 @@ var MetricsSchema = z12.object({
2172
2277
  /**
2173
2278
  * Finished turns that changed nothing on the board. Reflections are left out, since they write
2174
2279
  * memory, not the board; turns without a transcript are counted apart, their actions unknown.
2280
+ * Heartbeats are split by what each woke for, and those recorded before the board kept it are a
2281
+ * row without one.
2175
2282
  */
2176
2283
  idle: z12.object({
2177
2284
  turns: CountSchema,
2178
2285
  idle: CountSchema,
2179
2286
  unknown: CountSchema,
2180
- byTrigger: z12.array(z12.object({ trigger: z12.string(), turns: CountSchema, idle: CountSchema })),
2287
+ byTrigger: z12.array(z12.object({
2288
+ trigger: z12.string(),
2289
+ heartbeat: HeartbeatCauseSchema.optional(),
2290
+ turns: CountSchema,
2291
+ idle: CountSchema
2292
+ })),
2181
2293
  byAgent: z12.array(z12.object({
2182
2294
  agent: NameSchema,
2183
2295
  role: NameSchema.nullable(),
@@ -2355,7 +2467,7 @@ var RunnerConfigSchema = z13.object({
2355
2467
  });
2356
2468
 
2357
2469
  // packages/shared/dist/version.js
2358
- var STELLARIS_VERSION = "0.3.27";
2470
+ var STELLARIS_VERSION = "0.4.0";
2359
2471
 
2360
2472
  // packages/shared/dist/mentions.js
2361
2473
  import { parse, postprocess, preprocess } from "micromark";
@@ -3119,7 +3231,8 @@ var TurnPayload = z14.object({
3119
3231
  thread: z14.string().optional(),
3120
3232
  channel: z14.string().optional(),
3121
3233
  place: z14.string().optional(),
3122
- trigger: z14.string()
3234
+ trigger: z14.string(),
3235
+ heartbeat: HeartbeatCauseSchema.optional().catch(void 0)
3123
3236
  });
3124
3237
  var TaskPayload = z14.object({
3125
3238
  taskId: z14.string(),
@@ -3219,13 +3332,12 @@ function computeMetrics(input) {
3219
3332
  const nothing = actions === 0 ? 1 : 0;
3220
3333
  turns2 += 1;
3221
3334
  idle += nothing;
3222
- for (const [rows, key] of [
3223
- [turnRows, turn.data.trigger],
3224
- [agentRows, event.actor]
3225
- ]) {
3226
- const row = rows.get(key) ?? { turns: 0, idle: 0 };
3227
- rows.set(key, { turns: row.turns + 1, idle: row.idle + nothing });
3228
- }
3335
+ const { trigger, heartbeat } = turn.data;
3336
+ const key = heartbeat === void 0 ? trigger : `${trigger}\0${heartbeat}`;
3337
+ const row = turnRows.get(key) ?? { trigger, heartbeat, turns: 0, idle: 0 };
3338
+ turnRows.set(key, { ...row, turns: row.turns + 1, idle: row.idle + nothing });
3339
+ const own = agentRows.get(event.actor) ?? { turns: 0, idle: 0 };
3340
+ agentRows.set(event.actor, { turns: own.turns + 1, idle: own.idle + nothing });
3229
3341
  break;
3230
3342
  }
3231
3343
  case "turn.started": {
@@ -3381,7 +3493,10 @@ function computeMetrics(input) {
3381
3493
  turns: turns2,
3382
3494
  idle,
3383
3495
  unknown,
3384
- byTrigger: [...turnRows].map(([trigger, row]) => ({ trigger, ...row })).toSorted((a, b) => b.idle - a.idle || b.turns - a.turns),
3496
+ byTrigger: [...turnRows.values()].map(({ heartbeat, ...row }) => ({
3497
+ ...row,
3498
+ ...heartbeat === void 0 ? {} : { heartbeat }
3499
+ })).toSorted((a, b) => b.idle - a.idle || b.turns - a.turns),
3385
3500
  byAgent: [...agentRows].map(([agent, row]) => ({ agent, role: citizens.get(agent)?.role ?? null, ...row })).toSorted((a, b) => b.idle - a.idle || b.turns - a.turns)
3386
3501
  },
3387
3502
  sentBack: {
@@ -3441,6 +3556,8 @@ var TurnHistoryPayloadSchema = z15.object({
3441
3556
  channel: NameSchema.optional(),
3442
3557
  place: NameSchema.optional(),
3443
3558
  trigger: z15.string().default("manual"),
3559
+ // One a later board added reads as none, so the turn stays in the history.
3560
+ heartbeat: HeartbeatCauseSchema.optional().catch(void 0),
3444
3561
  exitReason: z15.string().nullable().default(null),
3445
3562
  costUsd: z15.number().default(0),
3446
3563
  usage: UsageSchema.optional(),
@@ -3866,7 +3983,10 @@ var SignInsSchema = z16.object({
3866
3983
  expiresAt: IsoDateTimeSchema
3867
3984
  })).default([])
3868
3985
  });
3869
- var TurnStartPayloadSchema = z16.object({ turnId: UlidSchema });
3986
+ var TurnStartPayloadSchema = z16.object({
3987
+ turnId: UlidSchema,
3988
+ heartbeat: TurnHistoryPayloadSchema.shape.heartbeat
3989
+ });
3870
3990
  var CursorsSchema = z16.object({
3871
3991
  digest: z16.string().nullable(),
3872
3992
  scopes: z16.record(z16.string(), z16.string()).default({}),
@@ -3938,6 +4058,9 @@ function skillFolderOf(proposal) {
3938
4058
  function proposedFrom(origin, where) {
3939
4059
  return origin !== void 0 && origin.scope === where.scope && origin.thread === where.thread && origin.channel === where.channel && origin.place === where.place;
3940
4060
  }
4061
+ function latestLease(a, b) {
4062
+ return a === null ? b : b === null || a >= b ? a : b;
4063
+ }
3941
4064
  function megabytes(bytes) {
3942
4065
  return (bytes / (1024 * 1024)).toFixed(1).replace(/\.0$/, "");
3943
4066
  }
@@ -4077,6 +4200,7 @@ var Board = class _Board {
4077
4200
  /** The turns in flight, which the server hosting them supplies; none until it does. */
4078
4201
  turnsInFlight = () => [];
4079
4202
  imageSource = null;
4203
+ imageSink = null;
4080
4204
  newId = monotonicFactory();
4081
4205
  events;
4082
4206
  actionCounts = /* @__PURE__ */ new Map();
@@ -4127,10 +4251,13 @@ var Board = class _Board {
4127
4251
  /**
4128
4252
  * Applies each one-time alignment an existing society has not had yet, and remembers it, so a
4129
4253
  * member that later chooses otherwise keeps its choice. A new alignment is a name and a method in
4130
- * this list; once every board has applied it, both are deleted. None is pending.
4254
+ * this list; once every board has applied it, both are deleted.
4131
4255
  */
4132
4256
  async applyAlignments() {
4133
- const alignments = [];
4257
+ const alignments = [
4258
+ // Whoever may show an image in a post may open one a post shows.
4259
+ { name: "image-reads-granted", apply: () => this.grantBeside("upload_image", "read_image") }
4260
+ ];
4134
4261
  const file = path8.join(this.paths.state(), "alignments.json");
4135
4262
  const { applied } = await exists(file) ? await readJson(file, AlignmentsSchema) : { applied: [] };
4136
4263
  const pending = alignments.filter(({ name }) => !applied.includes(name));
@@ -4142,6 +4269,17 @@ var Board = class _Board {
4142
4269
  }
4143
4270
  await writeJson(file, { applied: [...applied, ...pending.map(({ name }) => name)] });
4144
4271
  }
4272
+ /**
4273
+ * Grants `verb` once to every charter granting `beside`, the work roles' charters in practice;
4274
+ * seed roles have it from their seed.
4275
+ */
4276
+ async grantBeside(beside, verb) {
4277
+ for (const charter of await this.listRoles()) {
4278
+ if (charter.verbs.includes(beside) && !charter.verbs.includes(verb)) {
4279
+ await this.writeRoleUnlocked(USER_NAME, { ...charter, verbs: [...charter.verbs, verb] }, { verbsAdded: [verb] });
4280
+ }
4281
+ }
4282
+ }
4145
4283
  /** Writes every missing seed charter and aligns the existing ones with their seed. */
4146
4284
  async ensureSeedRoles() {
4147
4285
  await ensureDir(this.paths.roles());
@@ -4909,6 +5047,46 @@ ${DASHBOARD_LINE}
4909
5047
  return next;
4910
5048
  });
4911
5049
  }
5050
+ /**
5051
+ * Pauses or resumes a runner, the user's alone: the whole runner, or with `cli` that CLI on it
5052
+ * alone, each apart from the other. While either holds, no turn it covers starts there; their
5053
+ * wakes wait in the scheduler's queue, and a turn under way finishes. Pausing what is paused, or
5054
+ * resuming what is not, changes nothing and logs nothing.
5055
+ */
5056
+ async setRunnerPaused(actor, name, change) {
5057
+ this.assertUser(actor, "only the user may pause or resume a runner");
5058
+ const cli = change.cli === void 0 ? void 0 : CliKindSchema.parse(change.cli);
5059
+ return this.mutex.run(async () => {
5060
+ const file = this.paths.runner(name);
5061
+ if (!await exists(file)) {
5062
+ throw new BoardError("NOT_FOUND", `runner ${name} not found`);
5063
+ }
5064
+ const doc = await readMarkdown(file, RunnerSchema);
5065
+ const { paused, pausedClis, ...rest } = doc.data;
5066
+ if (cli !== void 0 && change.paused && !doc.data.clis.includes(cli)) {
5067
+ throw new BoardError("INVALID_STATE", `runner ${name} has no ${cli} to pause`);
5068
+ }
5069
+ const current = cli === void 0 ? paused : pausedClis?.[cli];
5070
+ if (current !== void 0 === change.paused) {
5071
+ return doc.data;
5072
+ }
5073
+ const pause = change.paused ? { at: this.now().toISOString(), by: actor.name } : void 0;
5074
+ const clis = cli === void 0 ? { ...pausedClis } : { ...pausedClis, [cli]: pause };
5075
+ const kept = Object.entries(clis).filter(([, each]) => each !== void 0);
5076
+ const whole = cli === void 0 ? pause : paused;
5077
+ const next = RunnerSchema.parse({
5078
+ ...rest,
5079
+ ...whole === void 0 ? {} : { paused: whole },
5080
+ ...kept.length === 0 ? {} : { pausedClis: Object.fromEntries(kept) }
5081
+ });
5082
+ await writeMarkdown(file, next, doc.body);
5083
+ await this.events.append(change.paused ? "runner.paused" : "runner.resumed", actor.name, {
5084
+ name,
5085
+ ...cli === void 0 ? {} : { cli }
5086
+ });
5087
+ return next;
5088
+ });
5089
+ }
4912
5090
  /**
4913
5091
  * Logs an operations signal as an `ops.signal` event. No channel carries it: the scheduler wakes
4914
5092
  * its readers from the event, the runner lists it in their prompts, and the interface's log shows
@@ -4943,8 +5121,9 @@ ${DASHBOARD_LINE}
4943
5121
  }
4944
5122
  if (event.type === "turn.started") {
4945
5123
  const start = TurnStartPayloadSchema.safeParse(event.payload);
4946
- if (start.success)
4947
- starts.set(start.data.turnId, event.ts);
5124
+ if (start.success) {
5125
+ starts.set(start.data.turnId, { ts: event.ts, heartbeat: start.data.heartbeat });
5126
+ }
4948
5127
  continue;
4949
5128
  }
4950
5129
  if (event.type !== "turn.completed" && event.type !== "turn.failed") {
@@ -4952,17 +5131,19 @@ ${DASHBOARD_LINE}
4952
5131
  }
4953
5132
  const parsed = TurnHistoryPayloadSchema.safeParse(event.payload);
4954
5133
  if (parsed.success) {
4955
- const { turnId } = parsed.data;
4956
- const startedAt = turnId === void 0 ? void 0 : starts.get(turnId);
4957
- if (turnId !== void 0) {
4958
- starts.delete(turnId);
5134
+ const { heartbeat: ended, ...turn } = parsed.data;
5135
+ const start = turn.turnId === void 0 ? void 0 : starts.get(turn.turnId);
5136
+ if (turn.turnId !== void 0) {
5137
+ starts.delete(turn.turnId);
4959
5138
  }
5139
+ const heartbeat = ended ?? start?.heartbeat;
4960
5140
  entries.push({
4961
5141
  id: event.id,
4962
5142
  ts: event.ts,
4963
5143
  outcome: event.type === "turn.failed" ? "failed" : "completed",
4964
- ...parsed.data,
4965
- ...startedAt === void 0 ? {} : { startedAt }
5144
+ ...turn,
5145
+ ...heartbeat === void 0 ? {} : { heartbeat },
5146
+ ...start === void 0 ? {} : { startedAt: start.ts }
4966
5147
  });
4967
5148
  }
4968
5149
  }
@@ -4992,14 +5173,31 @@ ${DASHBOARD_LINE}
4992
5173
  return society;
4993
5174
  });
4994
5175
  }
4995
- /** Releases every claim whose lease has expired. Called by the scheduler on its tick. */
5176
+ /**
5177
+ * Releases every claim whose lease has expired, but for a holder's that its own cron in the
5178
+ * task's thread keeps (`keptLease`). Called by the scheduler on its tick.
5179
+ */
4996
5180
  async expireLeases() {
4997
5181
  return this.mutex.run(async () => {
4998
5182
  const expired = [];
4999
5183
  const now = this.now();
5184
+ let crons;
5185
+ let runners2;
5000
5186
  for (const project of await listDirs(this.paths.projects())) {
5187
+ let place;
5001
5188
  for (const task of await this.listTasks(project)) {
5002
- if (task.status === "claimed" && this.leaseExpired(task, now)) {
5189
+ if (task.status !== "claimed") {
5190
+ continue;
5191
+ }
5192
+ crons ??= await this.listCrons();
5193
+ runners2 ??= await this.listRunners();
5194
+ place ??= await this.readProject(project);
5195
+ const kept = latestLease(this.keptLease(task, crons, now), await this.pausedLease(task, runners2, place.runner, now));
5196
+ if (kept !== null) {
5197
+ await this.writeTask(project, { ...task, leaseExpiresAt: kept });
5198
+ continue;
5199
+ }
5200
+ if (this.leaseExpired(task, now)) {
5003
5201
  const released = await this.writeTask(project, {
5004
5202
  ...task,
5005
5203
  status: "open",
@@ -5068,20 +5266,24 @@ ${DASHBOARD_LINE}
5068
5266
  * general channel, one of its other channels, one of its threads, or, in the society scope, a
5069
5267
  * project's channels. The heartbeat asks, so that it wakes a member where its unread
5070
5268
  * messages are and nowhere else. Society channels count only for a member woken in the society
5071
- * scope; a project member reads them at its next turn. Two kinds of post are in the digest but not
5269
+ * scope; a project member reads them at its next turn. Some posts are in the digest but not
5072
5270
  * counted, and are read when the member wakes for something else: a task step's, which wakes
5073
- * whoever it hands work to, and the board's own, operations signals and landing announcements,
5074
- * whose signals that call for action wake their readers directly. A closed thread counts for
5075
- * nothing: its conversation has ended, and a turn there could only read its posts and be refused
5076
- * a reply, while a wake queued before it closed, as for a mention, still runs there.
5271
+ * whoever it hands work to; the board's own, operations signals and landing announcements, whose
5272
+ * signals that call for action wake their readers directly; and a knowledge topic's notice, which
5273
+ * asks nothing of anyone. In a task's thread a post counts only for whoever the current stage is
5274
+ * for, its holder or, while nobody holds it, a member who may take it, and for the task's creator
5275
+ * when it mentions the user, so the creator can put a wait on the user in; a post that asks the
5276
+ * reader counts wherever it is. A closed thread counts for nothing: its conversation has ended,
5277
+ * and a turn there could only read its posts and be refused a reply, while a wake queued before
5278
+ * it closed, as for a mention, still runs there.
5077
5279
  */
5078
5280
  async unreadByConversation(actor) {
5079
5281
  const { cursors, upto } = await this.digestSnapshot(actor.name);
5080
5282
  const counts = /* @__PURE__ */ new Map();
5081
- const closed = /* @__PURE__ */ new Map();
5283
+ const threadCounts = /* @__PURE__ */ new Map();
5082
5284
  const unscoped = { name: actor.name, role: actor.role };
5083
- for (const { scope, part, message, news } of await this.digestSince(unscoped, cursors, upto)) {
5084
- if (news === true || message.step !== void 0 || message.author === SYSTEM_ACTOR.name) {
5285
+ for (const { scope, part, message, news, asked } of await this.digestSince(unscoped, cursors, upto)) {
5286
+ if (news === true || message.step !== void 0 || message.author === SYSTEM_ACTOR.name || message.notice?.kind === "knowledge") {
5085
5287
  continue;
5086
5288
  }
5087
5289
  const posted = parseChannelRef(message.channel).project ?? SOCIETY_SCOPE;
@@ -5090,12 +5292,13 @@ ${DASHBOARD_LINE}
5090
5292
  }
5091
5293
  const thread = threadOfPart(part);
5092
5294
  if (thread !== void 0) {
5093
- let ended = closed.get(thread);
5094
- if (ended === void 0) {
5095
- ended = (await this.tryFindThread(thread))?.thread.state === "closed";
5096
- closed.set(thread, ended);
5295
+ let counted = threadCounts.get(thread);
5296
+ if (counted === void 0) {
5297
+ counted = await this.threadCount(actor, thread);
5298
+ threadCounts.set(thread, counted);
5097
5299
  }
5098
- if (ended) {
5300
+ const forReader = counted === "every" || counted !== "none" && asked === true || counted === "asks-user" && message.mentions.includes(USER_NAME);
5301
+ if (!forReader) {
5099
5302
  continue;
5100
5303
  }
5101
5304
  }
@@ -5248,7 +5451,7 @@ ${DASHBOARD_LINE}
5248
5451
  include = participates;
5249
5452
  }
5250
5453
  if (include && message.author !== actor.name) {
5251
- collected.push({ scope: filedIn, part, message });
5454
+ collected.push({ scope: filedIn, part, message, ...asked ? { asked: true } : {} });
5252
5455
  }
5253
5456
  }
5254
5457
  return collected.toSorted(byId);
@@ -5525,12 +5728,30 @@ ${current.body}`);
5525
5728
  throw new BoardError("NOT_FOUND", "this board has no runners to read an image from");
5526
5729
  }
5527
5730
  const bytes = await this.imageSource(actor, args.path);
5731
+ return this.keepImage(actor, bytes, args.path, path8.basename(args.path));
5732
+ }
5733
+ /**
5734
+ * Keeps an image the user posts from a composer, as its bytes, under the same checks as an image
5735
+ * a turn uploads. `name`, the file's, describes it in the markdown answered; without one the
5736
+ * description is empty, for the user to write.
5737
+ */
5738
+ async postImage(actor, bytes, name) {
5739
+ this.assertUser(actor, "only the user posts an image's bytes; a turn uploads one with upload_image");
5740
+ const given = name === void 0 ? "" : ImageNameSchema.parse(name);
5741
+ return this.keepImage(actor, bytes, given === "" ? "the image" : given, given);
5742
+ }
5743
+ /**
5744
+ * Keeps `bytes` under a new id when they are a PNG, JPEG, GIF, or WebP of at most
5745
+ * `IMAGE_LIMIT_BYTES`, and answers the markdown that shows them, described by `name`. `what`
5746
+ * names the image in a refusal.
5747
+ */
5748
+ async keepImage(actor, bytes, what, name) {
5528
5749
  const type = imageTypeOf(bytes);
5529
5750
  if (type === null) {
5530
- throw new BoardError("VALIDATION", `${args.path} is not a PNG, JPEG, GIF, or WebP image by its first bytes`);
5751
+ throw new BoardError("VALIDATION", `${what} is not a PNG, JPEG, GIF, or WebP image by its first bytes`);
5531
5752
  }
5532
5753
  if (bytes.byteLength > IMAGE_LIMIT_BYTES) {
5533
- throw new BoardError("VALIDATION", `${args.path} is ${bytes.byteLength} bytes, over the ${IMAGE_LIMIT_BYTES} an image may be`);
5754
+ throw new BoardError("VALIDATION", `${what} is ${bytes.byteLength} bytes, over the ${IMAGE_LIMIT_BYTES} an image may be`);
5534
5755
  }
5535
5756
  return this.mutex.run(async () => {
5536
5757
  const id = this.newId();
@@ -5540,15 +5761,37 @@ ${current.body}`);
5540
5761
  type,
5541
5762
  size: bytes.byteLength
5542
5763
  });
5543
- const name = path8.basename(args.path).replaceAll(/[[\]\\\r\n]/g, "");
5764
+ const description = imageDescription(name);
5544
5765
  return {
5545
5766
  id,
5546
5767
  type,
5547
5768
  size: bytes.byteLength,
5548
- markdown: `![${name}](${IMAGE_SCHEME}${id})`
5769
+ markdown: `![${description}](${IMAGE_SCHEME}${id})`
5549
5770
  };
5550
5771
  });
5551
5772
  }
5773
+ /**
5774
+ * Opens an image the board keeps for the calling turn, by its id or its `image:<id>` address:
5775
+ * the runner the turn runs on writes the bytes to a file in a folder of the turn's own in the
5776
+ * system temp folder, removed when the turn ends, and the board answers its path, for the
5777
+ * citizen to open with its own file tools, since a post reaches a turn as markdown and an image
5778
+ * in it as its address. Any image the board keeps may be opened: every post, brief, topic, and
5779
+ * dashboard that could show one is in the projection every turn reads.
5780
+ */
5781
+ async openImage(actor, input) {
5782
+ const args = VerbInputs.read_image.parse(input);
5783
+ await this.authorize(actor, "read_image");
5784
+ if (actor.scope === void 0) {
5785
+ throw new BoardError("INVALID_STATE", "read_image writes the image on the machine a turn runs on, so only a turn can call it");
5786
+ }
5787
+ if (this.imageSink === null) {
5788
+ throw new BoardError("NOT_FOUND", "this board has no runners to write an image to");
5789
+ }
5790
+ const id = imageIdOf(args.image_id);
5791
+ const { type, bytes } = await this.readImage(id);
5792
+ const file = await this.imageSink(actor, { id, type, bytes });
5793
+ return { id, type, size: bytes.byteLength, path: file };
5794
+ }
5552
5795
  /** An uploaded image's bytes and kind, which its first bytes tell. */
5553
5796
  async readImage(id) {
5554
5797
  const file = this.paths.image(UlidSchema.parse(id));
@@ -5894,6 +6137,13 @@ ${current.body}`);
5894
6137
  readImagesWith(source) {
5895
6138
  this.imageSource = source;
5896
6139
  }
6140
+ /**
6141
+ * Where `read_image` writes an image to: the runner the calling turn runs on, through the server
6142
+ * that hosts the runners. Without one, as in the admin CLI, nothing can be opened.
6143
+ */
6144
+ openImagesWith(sink) {
6145
+ this.imageSink = sink;
6146
+ }
5897
6147
  /**
5898
6148
  * Refuses to end conversations while another citizen's turn is in one of them: `cut` picks the
5899
6149
  * turns that would lose theirs. The caller's own turn is exempt, since ending a conversation
@@ -6729,6 +6979,8 @@ ${rationale}`}`);
6729
6979
  return this.removeCron(actor, VerbInputs.remove_cron.parse(input));
6730
6980
  case "upload_image":
6731
6981
  return this.uploadImage(actor, VerbInputs.upload_image.parse(input));
6982
+ case "read_image":
6983
+ return this.openImage(actor, VerbInputs.read_image.parse(input));
6732
6984
  default:
6733
6985
  throw new BoardError("VALIDATION", `unknown verb ${String(verb)}`);
6734
6986
  }
@@ -6964,6 +7216,7 @@ ${rationale}`}`);
6964
7216
  ...parsed.channel === void 0 ? {} : { channel: parsed.channel },
6965
7217
  ...parsed.place === void 0 ? {} : { place: parsed.place },
6966
7218
  trigger: parsed.trigger.kind,
7219
+ ...parsed.trigger.heartbeat === void 0 ? {} : { heartbeat: parsed.trigger.heartbeat },
6967
7220
  session: parsed.session,
6968
7221
  runner: parsed.runner
6969
7222
  });
@@ -6986,6 +7239,11 @@ ${rationale}`}`);
6986
7239
  for (const id of parsed.crons ?? []) {
6987
7240
  await this.recordCronRun(id, parsed, failed);
6988
7241
  }
7242
+ if (parsed.exitReason === "completed" || parsed.exitReason === "blocked") {
7243
+ for (const shown of parsed.cronFailures ?? []) {
7244
+ await this.catchUpCron(shown, parsed);
7245
+ }
7246
+ }
6989
7247
  if (parsed.exitReason === "stopped") {
6990
7248
  await this.events.append("turn.stopped", parsed.stoppedBy ?? USER_NAME, {
6991
7249
  ...parsed.id === void 0 ? {} : { turnId: parsed.id },
@@ -7003,6 +7261,7 @@ ${rationale}`}`);
7003
7261
  ...parsed.channel === void 0 ? {} : { channel: parsed.channel },
7004
7262
  ...parsed.place === void 0 ? {} : { place: parsed.place },
7005
7263
  trigger: parsed.trigger.kind,
7264
+ ...parsed.trigger.heartbeat === void 0 ? {} : { heartbeat: parsed.trigger.heartbeat },
7006
7265
  session: parsed.session,
7007
7266
  exitReason: parsed.exitReason,
7008
7267
  costUsd: parsed.costUsd,
@@ -7126,6 +7385,27 @@ ${rationale}`}`);
7126
7385
  failures: outcome === "failed" ? cron.failures + 1 : outcome === "completed" ? 0 : cron.failures
7127
7386
  });
7128
7387
  }
7388
+ /**
7389
+ * Marks a cron's failed fire caught up by a completed turn whose prompt named it, while that
7390
+ * failure is still its latest run. Its schedule and its run of failures stay as they are.
7391
+ */
7392
+ async catchUpCron(shown, record) {
7393
+ if (record.id === void 0 || !await exists(this.paths.cron(shown.cron))) {
7394
+ return;
7395
+ }
7396
+ const cron = await this.readCron(shown.cron);
7397
+ const failure = cronFailure(cron);
7398
+ if (failure === null || failure.turnId !== shown.turnId) {
7399
+ return;
7400
+ }
7401
+ await this.writeCron({
7402
+ ...cron,
7403
+ lastRun: {
7404
+ ...failure,
7405
+ caughtUp: { turnId: record.id, at: record.endedAt ?? this.now().toISOString() }
7406
+ }
7407
+ });
7408
+ }
7129
7409
  /**
7130
7410
  * Whether a conversation with a workspace of its own has ended: a thread closed, a task ended
7131
7411
  * (for one from before tasks had threads), a channel archived, or a project archived, which ends
@@ -8495,7 +8775,7 @@ ${charter.purpose}
8495
8775
  }
8496
8776
  assertMayGate(actor, what) {
8497
8777
  if (!PLANNING_ROLES.includes(actor.role)) {
8498
- throw new BoardError("FORBIDDEN", `only the user, the steward, and the concierge may ${what}; ask one of them in the task's thread`);
8778
+ throw new BoardError("FORBIDDEN", `only the user, the steward, and the concierge may ${what}; ask one of them in the task's thread, with a mention`);
8499
8779
  }
8500
8780
  }
8501
8781
  /** The user holds anything; everyone else must be a project member the current stage admits. */
@@ -8537,6 +8817,51 @@ ${charter.purpose}
8537
8817
  leaseEnd(now) {
8538
8818
  return new Date(now.getTime() + this.leaseMs).toISOString();
8539
8819
  }
8820
+ /**
8821
+ * The lease a holder's own crons in the task's thread keep its claim to, when later than the one
8822
+ * it has, else null. While one of them is pending, the lease is renewed for a full lease once less
8823
+ * than half is left, so it never lapses between fires that may be hours apart; once every one is
8824
+ * paused, ended, or failed at its latest fire with nothing catching it up (`cronStoppedAt`), it
8825
+ * runs a full lease from when the last of them stopped.
8826
+ */
8827
+ keptLease(task, crons, now) {
8828
+ if (task.leaseExpiresAt === void 0) {
8829
+ return null;
8830
+ }
8831
+ const lease = Date.parse(task.leaseExpiresAt);
8832
+ let pending = false;
8833
+ let stopped = null;
8834
+ for (const cron of crons) {
8835
+ if (cron.agent !== task.claimedBy || !firesInTask(cron, task)) {
8836
+ continue;
8837
+ }
8838
+ const at = cronStoppedAt(cron);
8839
+ if (at === null) {
8840
+ pending = true;
8841
+ } else {
8842
+ stopped = Math.max(stopped ?? 0, Date.parse(at));
8843
+ }
8844
+ }
8845
+ const kept = pending ? lease - now.getTime() < this.leaseMs / 2 ? now.getTime() + this.leaseMs : lease : stopped === null ? lease : stopped + this.leaseMs;
8846
+ return kept > lease ? new Date(kept).toISOString() : null;
8847
+ }
8848
+ /**
8849
+ * The lease a holder keeps while the user's pause holds its turns in the task's project
8850
+ * (`heldOn`), when later than the one it has, else null. It is renewed for a full lease once less
8851
+ * than half is left, as for a pending cron, so it never lapses while the holder cannot take a
8852
+ * turn there, and after the resume the holder has at least half a lease to come back to it.
8853
+ */
8854
+ async pausedLease(task, runners2, runner, now) {
8855
+ if (task.claimedBy === void 0 || task.leaseExpiresAt === void 0) {
8856
+ return null;
8857
+ }
8858
+ const holder = await this.readAgent(task.claimedBy).catch(() => null);
8859
+ if (holder === null || !heldOn(runners2, runner, holder.cli)) {
8860
+ return null;
8861
+ }
8862
+ const lease = Date.parse(task.leaseExpiresAt);
8863
+ return lease - now.getTime() < this.leaseMs / 2 ? this.leaseEnd(now) : null;
8864
+ }
8540
8865
  leaseExpired(task, now) {
8541
8866
  return task.leaseExpiresAt !== void 0 && new Date(task.leaseExpiresAt).getTime() <= now.getTime();
8542
8867
  }
@@ -8649,11 +8974,7 @@ ${charter.purpose}
8649
8974
  if (task.claimedBy === actor.name || task.createdBy === actor.name || task.stages.some((stage) => stage.agent === actor.name || stage.holders.includes(actor.name))) {
8650
8975
  return true;
8651
8976
  }
8652
- if (task.status !== "open" || task.completing) {
8653
- return false;
8654
- }
8655
- const agent = await this.readAgent(actor.name);
8656
- return agent.memberships.includes(task.project) && mayHoldStage(actor, task);
8977
+ return await this.mayTakeStage(actor, task);
8657
8978
  }
8658
8979
  if (subject?.kind === "proposal") {
8659
8980
  const proposal = await this.readProposal(subject.id);
@@ -8667,6 +8988,34 @@ ${charter.purpose}
8667
8988
  }
8668
8989
  return false;
8669
8990
  }
8991
+ /** Whether a task's current stage waits for a holder and the member may take it. */
8992
+ async mayTakeStage(actor, task) {
8993
+ if (task.status !== "open" || task.completing) {
8994
+ return false;
8995
+ }
8996
+ const agent = await this.readAgent(actor.name);
8997
+ return agent.memberships.includes(task.project) && mayHoldStage(actor, task);
8998
+ }
8999
+ /**
9000
+ * Which of a thread's posts count toward a reader's heartbeat (see `unreadByConversation`). A
9001
+ * thread, or a task, the board cannot find counts as an open topic's would.
9002
+ */
9003
+ async threadCount(actor, id) {
9004
+ const found = await this.tryFindThread(id);
9005
+ if (found?.thread.state === "closed") {
9006
+ return "none";
9007
+ }
9008
+ const subject = found?.thread.subject;
9009
+ const task = subject?.kind === "task" ? await this.findTask(subject.id).then((location) => location.task, () => null) : null;
9010
+ if (task === null) {
9011
+ return "every";
9012
+ }
9013
+ const stageIsTheirs = task.status === "claimed" ? task.claimedBy === actor.name : await this.mayTakeStage(actor, task);
9014
+ if (stageIsTheirs) {
9015
+ return "every";
9016
+ }
9017
+ return task.createdBy === actor.name ? "asks-user" : "asked";
9018
+ }
8670
9019
  async threadScopes() {
8671
9020
  const projects = await listDirs(this.paths.projects());
8672
9021
  return [this.paths.societyThreads(), ...projects.map((slug) => this.paths.threads(slug))];
@@ -8771,6 +9120,15 @@ ${charter.purpose}
8771
9120
  };
8772
9121
 
8773
9122
  // packages/scheduler/dist/wake.js
9123
+ function heartbeatCause(input) {
9124
+ if (input.digestSize > 0) {
9125
+ return "unread";
9126
+ }
9127
+ if (input.claimsHeld > 0) {
9128
+ return "held";
9129
+ }
9130
+ return input.waitingStages > 0 ? "waiting" : null;
9131
+ }
8774
9132
  function decideWake(input) {
8775
9133
  if (input.paused) {
8776
9134
  return { wake: false, reason: "society paused", priority: 0 };
@@ -8802,7 +9160,7 @@ function decideWake(input) {
8802
9160
  case "heartbeat":
8803
9161
  break;
8804
9162
  }
8805
- if (input.digestSize === 0 && input.claimsHeld === 0 && input.waitingStages === 0) {
9163
+ if (heartbeatCause(input) === null) {
8806
9164
  return { wake: false, reason: "nothing to read, hold, or take", priority: 0 };
8807
9165
  }
8808
9166
  return {
@@ -8897,6 +9255,18 @@ var StateSchema = z17.object({
8897
9255
  lastHeartbeat: z17.record(z17.string(), z17.string()),
8898
9256
  /** Waiting stages already signalled, as `task:stage`, so each is logged once while it waits. */
8899
9257
  waitingSeen: z17.record(z17.string(), z17.string()).default({}),
9258
+ /**
9259
+ * The last pass at which each waiting stage was held by the user's pause of its runner, as
9260
+ * `task:stage`; its wait counts from there, so a stage that waited through a pause is not
9261
+ * signalled the moment the pause ends.
9262
+ */
9263
+ waitingHeld: z17.record(z17.string(), z17.string()).default({}),
9264
+ /**
9265
+ * The last operations pass at which each citizen was held by the user's pause of a runner it
9266
+ * works on; its idle time counts from there, so a citizen held through a pause is not signalled
9267
+ * idle the moment the pause ends.
9268
+ */
9269
+ idleHeld: z17.record(z17.string(), z17.string()).default({}),
8900
9270
  /** Queued dispatches survive a restart; the cursor has already moved past the events that made them. */
8901
9271
  pending: z17.record(z17.string(), PendingSchema).default({}),
8902
9272
  opsReported: z17.record(z17.string(), z17.string()).default({}),
@@ -9835,10 +10205,14 @@ var Scheduler = class {
9835
10205
  * A heartbeat per member and scope on the cadence, which then looks at every conversation of the
9836
10206
  * scope: the unread messages of its home, of each other channel, and of each thread, a stage the
9837
10207
  * member holds, and a stage of an unblocked task waiting that it may take, each in its task's
9838
- * thread. Each conversation with something wakes on its own, so a heartbeat never brings one
9839
- * task's business into another's turn.
10208
+ * thread. A stage counts for neither while the member has a cron pending in its task's thread
10209
+ * (`cronStoppedAt`: neither paused nor ended, and its latest fire's turn did not fail or was
10210
+ * caught up since), whose fires are its checks there. Each conversation with something wakes on
10211
+ * its own, so a heartbeat never brings one task's business into another's turn, and its trigger
10212
+ * says what it woke for (`heartbeatCause`).
9840
10213
  */
9841
10214
  async checkHeartbeats(now) {
10215
+ let pendingCrons;
9842
10216
  for (const agent of await this.board.listAgents()) {
9843
10217
  if (agent.status !== "active" || agent.cli === null) {
9844
10218
  continue;
@@ -9864,6 +10238,9 @@ var Scheduler = class {
9864
10238
  this.state.lastHeartbeat[cadence] = iso(now);
9865
10239
  unread ??= await this.board.unreadByConversation(actor);
9866
10240
  held ??= await this.board.heldClaims(agent.name);
10241
+ pendingCrons ??= (await this.board.listCrons()).filter((cron) => cronStoppedAt(cron) === null);
10242
+ const crons = pendingCrons;
10243
+ const leftToCron = (task) => crons.some((cron) => cron.agent === agent.name && firesInTask(cron, task));
9867
10244
  const reasons = /* @__PURE__ */ new Map();
9868
10245
  const reasonsIn = (beside) => {
9869
10246
  const key = conversationKey2(agent.name, project, beside);
@@ -9882,13 +10259,13 @@ var Scheduler = class {
9882
10259
  }
9883
10260
  }
9884
10261
  for (const task of held) {
9885
- if (task.project === project) {
10262
+ if (task.project === project && !leftToCron(task)) {
9886
10263
  reasonsIn({ thread: { id: task.id, task: true } }).claimsHeld += 1;
9887
10264
  }
9888
10265
  }
9889
10266
  if (project !== SOCIETY_SCOPE) {
9890
10267
  for (const task of await this.board.openTasks(project)) {
9891
- if (mayHoldStage(agent, task) && (await this.blockersInPlay(task.blockedBy)).length === 0) {
10268
+ if (mayHoldStage(agent, task) && !leftToCron(task) && (await this.blockersInPlay(task.blockedBy)).length === 0) {
9892
10269
  reasonsIn({ thread: { id: task.id, task: true } }).waiting += 1;
9893
10270
  }
9894
10271
  }
@@ -9897,7 +10274,16 @@ var Scheduler = class {
9897
10274
  if (this.pending.has(key) || this.running.has(key)) {
9898
10275
  continue;
9899
10276
  }
9900
- const trigger = TriggerSchema.parse({ kind: "heartbeat", reason: "heartbeat" });
10277
+ const cause = heartbeatCause({
10278
+ digestSize: reason.digestSize,
10279
+ claimsHeld: reason.claimsHeld,
10280
+ waitingStages: reason.waiting
10281
+ });
10282
+ const trigger = TriggerSchema.parse({
10283
+ kind: "heartbeat",
10284
+ reason: "heartbeat",
10285
+ ...cause === null ? {} : { heartbeat: cause }
10286
+ });
9901
10287
  const decision = decideWake({
9902
10288
  trigger,
9903
10289
  digestSize: reason.digestSize,
@@ -10100,7 +10486,9 @@ var Scheduler = class {
10100
10486
  */
10101
10487
  async checkWaitingStages(now) {
10102
10488
  const stillWaiting = /* @__PURE__ */ new Set();
10489
+ const runners2 = await this.board.listRunners();
10103
10490
  for (const project of await this.board.listProjects()) {
10491
+ let members;
10104
10492
  for (const task of await this.board.openTasks(project.slug)) {
10105
10493
  const stage = currentStage(task);
10106
10494
  if (stage === void 0) {
@@ -10111,7 +10499,14 @@ var Scheduler = class {
10111
10499
  if (this.state.waitingSeen[key] !== void 0) {
10112
10500
  continue;
10113
10501
  }
10114
- const age = now - Date.parse(task.stageSince);
10502
+ members ??= await this.board.projectMembers(project.slug);
10503
+ const takers = members.filter((member) => member.cli !== null && mayHoldStage(member, task));
10504
+ if (takers.length > 0 && takers.every((taker) => heldOn(runners2, project.runner, taker.cli))) {
10505
+ this.state.waitingHeld[key] = iso(now);
10506
+ continue;
10507
+ }
10508
+ const held = this.state.waitingHeld[key];
10509
+ const age = now - Math.max(Date.parse(task.stageSince), held === void 0 ? 0 : Date.parse(held));
10115
10510
  if (age < this.timings.waitingStageMs) {
10116
10511
  continue;
10117
10512
  }
@@ -10132,6 +10527,11 @@ var Scheduler = class {
10132
10527
  });
10133
10528
  }
10134
10529
  }
10530
+ for (const key of Object.keys(this.state.waitingHeld)) {
10531
+ if (!stillWaiting.has(key)) {
10532
+ delete this.state.waitingHeld[key];
10533
+ }
10534
+ }
10135
10535
  for (const key of Object.keys(this.state.waitingSeen)) {
10136
10536
  if (!stillWaiting.has(key)) {
10137
10537
  delete this.state.waitingSeen[key];
@@ -10197,7 +10597,8 @@ var Scheduler = class {
10197
10597
  const roles = (await this.board.listRoles()).filter((role) => role.name !== USER_ROLE);
10198
10598
  const runners2 = await this.board.listRunners();
10199
10599
  const anywhere = new Set(runners2.filter((runner) => runner.status === "connected").flatMap((runner) => runner.capabilities));
10200
- for (const project of await this.board.listProjects()) {
10600
+ const projects = await this.board.listProjects();
10601
+ for (const project of projects) {
10201
10602
  const slug = project.slug;
10202
10603
  const tasks = await this.board.listTasks(slug);
10203
10604
  const home = runners2.find((runner) => runner.name === project.runner);
@@ -10289,7 +10690,13 @@ var Scheduler = class {
10289
10690
  }
10290
10691
  }
10291
10692
  const crons = (await this.board.listCrons()).filter((cron) => cron.ended === void 0);
10292
- for (const agent of await this.board.listAgents()) {
10693
+ const agents = await this.board.listAgents();
10694
+ for (const name of Object.keys(this.state.idleHeld)) {
10695
+ if (!agents.some((agent) => agent.name === name && agent.status === "active")) {
10696
+ delete this.state.idleHeld[name];
10697
+ }
10698
+ }
10699
+ for (const agent of agents) {
10293
10700
  if (agent.status !== "active" || agent.cli === null) {
10294
10701
  continue;
10295
10702
  }
@@ -10301,7 +10708,12 @@ var Scheduler = class {
10301
10708
  last = Math.max(last, Date.parse(ended));
10302
10709
  }
10303
10710
  }
10304
- const idle = now - last;
10711
+ const held = heldOn(runners2, agent.homeRunner, agent.cli) || projects.some((project) => agent.memberships.includes(project.slug) && heldOn(runners2, project.runner, agent.cli));
10712
+ if (held) {
10713
+ this.state.idleHeld[agent.name] = iso(now);
10714
+ }
10715
+ const heldAt = this.state.idleHeld[agent.name];
10716
+ const idle = now - Math.max(last, heldAt === void 0 ? 0 : Date.parse(heldAt));
10305
10717
  if (idle >= this.timings.idleMemberMs) {
10306
10718
  signals.push({
10307
10719
  kind: "idle_member",
@@ -10654,20 +11066,21 @@ var Scheduler = class {
10654
11066
  import { randomUUID } from "node:crypto";
10655
11067
 
10656
11068
  // packages/turn-host/dist/render.js
11069
+ import { createHash as createHash3 } from "node:crypto";
10657
11070
  var TURN_CONTRACT = [
10658
11071
  "- Every turn belongs to one conversation, named at the top of your prompt: your home conversation in a project or the society, for its general channel and for work tied to no other channel and no thread; the conversation of one other channel of the place, a workstream such as a release; outside projects, the conversation of one project's channels, general and its workstreams together, for posts there that ask something of you while you are not a member; or the conversation of one thread you take part in, a task's, a proposal's, or a topic's. Each has its own session and its own turns, which may run while this one does. The prompt carries this conversation only: in a thread, its new messages, or the whole thread when the conversation is new, and in a task's thread the task and its stage; in a channel's, a project's, or at home, that channel's, that project's channels', or general's messages that concern you. Read anything else you need with get_task, search, and the board's files, and leave other channels', other threads', and other projects' work to their own turns.",
10659
- "- Act through the board tools (the `board` MCP server). Holding a stage is a lease; every turn that touches the task renews it.",
11072
+ "- Act through the board tools (the `board` MCP server). Holding a stage is a lease; every turn that touches the task renews it, and so does the board while a cron of yours in the task's thread is pending.",
10660
11073
  "- Work only inside your worktree. In a task's conversation it is a worktree of its own on the task's branch `task/<id>`: commit your work there. The branch is checked out in one worktree at a time and kept for the turns of whoever holds the task's stage, so a turn of anyone else there, or one that starts while another turn has the branch, works detached at its tip, which Your workspace then says; its commits go on the branch by a fast-forward when a turn in the task ends with the branch free, and the task's thread is told of commits that no longer build on its tip. After each turn the runner commits what you left on the branch and lets it go for the next holder. Your home worktree is on your own branch, `agent/<name>`, for work tied to no task; nothing lands from it by itself. In a proposal's, a topic's, or a channel's conversation it is a worktree of its own, detached at the project's default branch, which goes when the thread closes or the channel is archived, so work that should last goes on a task's branch. Before each turn the runner fetches the project's remote and brings what holds no work of yours up to the default branch: your own branch when it has no commits of its own, a task's branch nobody has worked on, and a detached worktree with nothing uncommitted; what it leaves where it is, and why, is under Your workspace in the prompt. It never merges or commits for you.",
10661
11074
  "- Posts that would wake this conversation may arrive while you work, delivered into the turn under a heading that says so and formatted like the digest. They are part of this turn: act on them now, after the step in hand, or not at all, as with the digest. The user may also stop a turn; the next turn is told.",
10662
11075
  "- Silence is allowed. If the digest needs no reply, post nothing.",
10663
- "- To come back to something later, set a cron with create_cron: a note on what to do then, and a five-field cron expression or one time with at. It wakes you in this conversation unless you name another, at most every fifteen minutes, and every fire is a paid turn, so end a cron with remove_cron once it has done its job. Never wait by sleeping or leaving a process running: a turn's board token ends with the turn, so nothing it leaves behind can act on the board. While you check your own stage's work on a schedule, such as a running job, CI, or a deploy, keep holding the stage: the cron sets when you check, and your turns in the task's thread renew its lease. Release a stage only to hand it on, or once its task's blocked_by names the tasks it waits on: the stage then wakes nobody until the last of them ends. Any other wait on someone else, such as the user or an outside party, is a stage of its own, not a held stage. The society's crons are under society/crons/ in the board projection.",
11076
+ "- To come back to something later, set a cron with create_cron: a note on what to do then, and a five-field cron expression or one time with at. It wakes you in this conversation unless you name another, at most every fifteen minutes, and every fire is a paid turn, so end a cron with remove_cron once it has done its job. Never wait by sleeping or leaving a process running: a turn's board token ends with the turn, so nothing it leaves behind can act on the board. While you check your own stage's work on a schedule, such as a running job, CI, or a deploy, keep holding the stage: the cron sets when you check, and while it is pending in the task's thread, neither ended nor paused, with its latest fire's turn not failed or a turn of yours there completed since, the board keeps your lease and the heartbeat leaves the stage to it, waking you there only for a post you have not read. Release a stage only to hand it on, or once its task's blocked_by names the tasks it waits on: the stage then wakes nobody until the last of them ends. Any other wait on someone else, such as the user or an outside party, is a stage of its own, not a held stage. The society's crons are under society/crons/ in the board projection.",
10664
11077
  "- An @mention wakes the citizen named, and every wake costs a turn. Address someone with @ only when you need them to act; when you merely refer to citizens, write their names plainly. A name in a code span or a code block is no mention, so code keeps its @ as written. @user wakes nobody, since the user takes no turns; it is what puts a question before the user, so use it only when you need the user to answer or decide.",
10665
- "- A thread is a conversation off a channel that reaches only its participants and whoever is mentioned in it. Every task has one, opened with it under the task's id on the channel of its project it was filed in, general unless it was filed in another: talk about a task there, with post_message and its thread_id, and the notes you give advance_task and update_task are posted there as you. Its participants are the task's creator, its stage holders and assignees, whoever may take the stage that waits, and anyone who posted; it closes when the task ends. Every proposal has one too, in governance, opened with its pitch and closed by its decision. Open other threads with open_thread on a channel with a title, for any other topic; close_thread posts your summary to its channel.",
10666
- "- To show an image in a post, such as a screenshot or a chart, save it anywhere you can read it, such as a folder from `mktemp -d`, call upload_image with its path, and put the markdown it returns in the post, `![what it shows](image:<id>)`, with what the image shows as its alt text: the board keeps the image, so it shows in any conversation after the file is gone. It takes a PNG, JPEG, GIF, or WebP of at most 8 MB from your worktree, your home, or the system temp folder. An image committed on a task's branch, linked by its path in the task's worktree, still shows in place, and so does one at an https address. Never put an image or any other file into a message as base64 or a data: URL: the board does not show it, and every reader of the conversation gets it in full in their prompt.",
11078
+ "- A thread is a conversation off a channel that reaches only its participants and whoever is mentioned in it. Every task has one, opened with it under the task's id on the channel of its project it was filed in, general unless it was filed in another: talk about a task there, with post_message and its thread_id, and the notes you give advance_task and update_task are posted there as you. Its participants are the task's creator, its stage holders and assignees, whoever may take the stage that waits, and anyone who posted; it closes when the task ends. In a task's thread, only the current stage's holder, or while nobody holds it whoever may take it, wakes for your post, and a post that asks the user also wakes the task's creator; mention anyone else you need, including the concierge for a plan change. Every proposal has one too, in governance, opened with its pitch and closed by its decision. Open other threads with open_thread on a channel with a title, for any other topic; close_thread posts your summary to its channel.",
11079
+ "- To show an image in a post, such as a screenshot or a chart, save it anywhere you can read it, such as a folder from `mktemp -d`, call upload_image with its path, and put the markdown it returns in the post, `![what it shows](image:<id>)`, with what the image shows as its alt text: the board keeps the image, so it shows in any conversation after the file is gone. It takes a PNG, JPEG, GIF, or WebP of at most 8 MB from your worktree, your home, or the system temp folder. An image committed on a task's branch, linked by its path in the task's worktree, still shows in place, and so does one at an https address. Never put an image or any other file into a message as base64 or a data: URL: the board does not show it, and every reader of the conversation gets it in full in their prompt. To see an image a post, a brief, or a topic shows as `image:<id>`, call read_image with the id: it returns the path of a file of this turn's own that holds the image, removed when the turn ends, which you open with the tool that shows you an image, Read in Claude Code or view_image in Codex.",
10667
11080
  "- Route every lesson: about you, your craft, or the user, write it to memory/core.md in your home directory, and keep that file short by moving detail to memory/<topic>.md, your archive; how a codebase behaves (architecture, conventions, gotchas, how to build, test, and verify it) goes in its AGENTS.md or README, committed on a task's branch like any change to the code, or proposed in the task's thread or the project channel when it is outside the task at hand; a durable fact about a project as a place (runners, access, flow, where results live) goes through `write_knowledge` on that project; something the whole project should know now, post it to the project channel, and anything about one task, in the task's thread.",
10668
11081
  '- A procedure you have followed twice is a candidate for a skill, which it is when it makes a citizen better at a kind of work on any project: write it to skills/<name>/SKILL.md in your home, frontmatter with `name` and a one-line `description` and then the steps, and your skills index lists it from the next turn. Files the steps use, such as scripts/ and references/, go in the same folder, named in SKILL.md by paths relative to it. Propose it with kind "skill" when more than one citizen would use it and nothing in it is tied to one place; a skill only you use stays yours, however many projects you use it on.',
10669
11082
  "- To take a published skill for yourself, install it in a temporary folder and copy it into your home: in a folder from `mktemp -d`, `npx -y skills add <source> --skill <name> -a universal --copy -y` writes `.agents/skills/<name>/` and a lock file there, and you copy that skill folder to skills/<name>/ in your home, named in lowercase letters, digits, and hyphens. Never install one inside a worktree, where it would be committed into the project, or with -g, which writes to the runner's own home, shared by every citizen on it and listed in no skills index.",
10670
- "- Report memoryUpdated: true in the status object whenever you changed memory/core.md or a skill, so a warm session restarts with the new instructions.",
11083
+ "- Report memoryUpdated: true in the status object whenever you changed memory/core.md or a skill. A change to your instructions reaches each of your conversations at its next turn, whose prompt then carries them whole, replacing what that conversation had; a turn already running keeps what it started with.",
10671
11084
  "- Other turns of yours may be running at the same time and share your home. Change a file there with your edit tool, which refuses a file changed since you read it, and never rewrite one whole from an earlier read, with a shell redirect or a script, or you erase what another of your turns wrote.",
10672
11085
  "- To ask the user something, mention @user where the question belongs: the task's thread for a task, the proposal's thread for a proposal, and for anything else a thread you open on your scope's general channel, titled with the question. The user answers in the same thread, and the answer comes back to that thread's conversation. A proposal waiting on the user is already in front of them and needs no mention.",
10673
11086
  "- End every turn with the status object: summary, claims held, what is blocked, and whether the user must decide. If you report that the user must decide, mentioned @user nowhere this turn, and proposed nothing for the user to decide, the runner asks in this turn's thread when it has one, and otherwise opens a thread on your scope's general channel with your summary as the question."
@@ -10676,9 +11089,9 @@ var PLANNING = [
10676
11089
  "- A task is a plan of stages between open and done. Hold the current stage with claim_task and finish it with advance_task; the next stage becomes current, and past the last one the task is done.",
10677
11090
  "- Name stages by what gets done. Plan the next few steps rather than everything, and reshape with plan_task as the work teaches you.",
10678
11091
  "- Assign a stage to a role when anyone in it could do it, to a citizen only when it must be them, and to nobody when anyone in the project could. A citizen named on a stage must be a member of the task's project; add them first. A stage wakes everyone it is open to when it comes up, so the narrower the assignee, the fewer turns it costs.",
10679
- "- Reshape rather than force: another round is an inserted stage, and a wait on something outside the society is a stage named for what it waits on. When a check finds work unfinished, the holder of the current stage sends the task back to an earlier stage with update_task, and so may the user, the steward, and the concierge; anyone else asks the concierge in the task's thread.",
11092
+ "- Reshape rather than force: another round is an inserted stage, and a wait on something outside the society is a stage named for what it waits on. When a check finds work unfinished, the holder of the current stage sends the task back to an earlier stage with update_task, and so may the user, the steward, and the concierge; anyone else asks the concierge in the task's thread, with a mention.",
10680
11093
  "- A task that cannot start until other tasks finish names them with update_task blocked_by, and whoever holds its stage then releases it, since the heartbeat still wakes a holder. While any of them is in play, a stage nobody holds wakes nobody, neither when it comes up nor on a heartbeat; once the last has ended, done or abandoned, the stage wakes as if it had just come up, or wakes its holder. Nobody needs to clear the block or mention anyone when they end; change blocked_by only when the plan changes.",
10681
- "- A gated stage is an independent check: nobody who held an earlier stage of the task may hold it, and only the user, the steward, and the concierge may add, remove, move, reassign, or ungate it. Ask them in the task's thread when a gate should change.",
11094
+ "- A gated stage is an independent check: nobody who held an earlier stage of the task may hold it, and only the user, the steward, and the concierge may add, remove, move, reassign, or ungate it. Ask them in the task's thread, with a mention, when a gate should change.",
10682
11095
  "- Commit your work on the task's branch before you advance, and say what you did in the advance note; it is the handover the next holder reads in the task's thread."
10683
11096
  ].join("\n");
10684
11097
  var GOVERNANCE = [
@@ -10737,6 +11150,10 @@ function renderInstructions(input) {
10737
11150
  return `${sections.join("\n")}
10738
11151
  `;
10739
11152
  }
11153
+ function instructionsHash(input) {
11154
+ const { onboarding: _first, ...kept } = input;
11155
+ return createHash3("sha256").update(renderInstructions(kept)).digest("hex");
11156
+ }
10740
11157
 
10741
11158
  // packages/turn-host/dist/prompt.js
10742
11159
  var REFLECTION_INTRO = "This turn is for your memory; take no new work, and answer unread messages only where a reply is needed. Read memory/core.md and your recent turn records, then:";
@@ -10749,6 +11166,7 @@ var REFLECTION_STEPS = [
10749
11166
  ];
10750
11167
  var SAVE_WHAT_IS_OPEN = "- This conversation's session ends with this turn, and the next turn here starts a new one that knows only your home's files and the board: write anything still open into memory/core.md, or the archive it points to, before you end.";
10751
11168
  var REFLECTION_REPORT = "- Report memoryUpdated: true in the status object when core.md or a skill changed.";
11169
+ var INSTRUCTIONS_CHANGED = "Your instructions changed since this conversation last received them. Between the tags below are the whole of them now: they replace the instructions this conversation began with and any earlier text like this, so where the two differ, follow these, and a rule missing from them no longer applies.";
10752
11170
  var MAX_BODY_CHARS = 1500;
10753
11171
  var MAX_PROFILE_CHARS = 200;
10754
11172
  function clip(text) {
@@ -10803,12 +11221,19 @@ function quotaPhrase(cli, quota, now) {
10803
11221
  }
10804
11222
  function runnerLine(runner, projects, now) {
10805
11223
  const living = projects.filter((project) => project.runner === runner.name).map((project) => project.slug);
10806
- const state = runner.status === "connected" ? "connected" : `away${runner.lastSeen === void 0 ? "" : ` since ${runner.lastSeen}`}`;
11224
+ const state = [
11225
+ runner.status === "connected" ? "connected" : `away${runner.lastSeen === void 0 ? "" : ` since ${runner.lastSeen}`}`,
11226
+ ...runner.paused === void 0 ? [] : [`paused by the user since ${runner.paused.at}`]
11227
+ ].join(", ");
11228
+ const pauses = runner.clis.flatMap((cli) => {
11229
+ const pause = runner.pausedClis?.[cli];
11230
+ return pause === void 0 ? [] : [`${cli} paused by the user since ${pause.at}`];
11231
+ });
10807
11232
  const quotas = runner.clis.flatMap((cli) => {
10808
11233
  const quota = runner.quotas?.[cli];
10809
11234
  return quota === void 0 ? [] : [quotaPhrase(cli, quota, now)];
10810
11235
  });
10811
- return `- ${runner.name}: ${state}; ${runner.os}; CLIs ${runner.clis.length === 0 ? "none" : runner.clis.join(", ")}; offers ${runner.capabilities.length === 0 ? "nothing beyond its CLIs" : runner.capabilities.join(", ")}; ${living.length === 0 ? "no projects" : `projects ${living.join(", ")}`}${quotas.length === 0 ? "" : `; quota ${quotas.join("; ")}`}`;
11236
+ return `- ${runner.name}: ${state}; ${runner.os}; CLIs ${runner.clis.length === 0 ? "none" : runner.clis.join(", ")}; offers ${runner.capabilities.length === 0 ? "nothing beyond its CLIs" : runner.capabilities.join(", ")}; ${living.length === 0 ? "no projects" : `projects ${living.join(", ")}`}${pauses.map((pause) => `; ${pause}`).join("")}${quotas.length === 0 ? "" : `; quota ${quotas.join("; ")}`}`;
10812
11237
  }
10813
11238
  function profileLine(profile) {
10814
11239
  const line = profile.split("\n").map((entry) => entry.trim()).find((entry) => entry.length > 0 && !entry.startsWith("#"));
@@ -10845,7 +11270,7 @@ function messageLines(messages, threads) {
10845
11270
  function cronLines(cron, timeZone) {
10846
11271
  const previous = cron.lastRun === void 0 ? "" : ` The turn of its previous fire ${cron.lastRun.outcome === "stopped" ? "was stopped by the user" : cron.lastRun.outcome} at ${cron.lastRun.at}.`;
10847
11272
  const next = nextFire(cron, timeZone);
10848
- const state = isOneTime(cron.schedule) ? " It fired once, as set, and has ended." : cron.ended !== void 0 ? ` ${cron.ended.by} has ended it since it fired (${cron.ended.reason}), so do what it asks only if it still matters.` : next === null ? "" : ` Next it fires at ${next.toISOString()}.`;
11273
+ const state = isOneTime(cron.schedule) ? " It fired once, as set, and has ended." : cron.ended !== void 0 ? ` ${cron.ended.by} has ended it since it fired (${cron.ended.reason}), so do what it asks only if it still matters.` : cron.paused !== void 0 ? ` ${cron.paused.by} has paused it since it fired, so it fires no more until resumed.` : next === null ? "" : ` Next it fires at ${next.toISOString()}.`;
10849
11274
  return [
10850
11275
  `- "${cron.title}" (cron ${cron.id}), set by ${cron.createdBy} at ${cron.createdAt}, fires ${describeSchedule(cron.schedule, timeZone)}.${previous}${state}`,
10851
11276
  "",
@@ -10899,6 +11324,10 @@ function buildTurnPrompt(input) {
10899
11324
  if (dispatch.trigger.kind === "user_post") {
10900
11325
  lines.push("The user posted. Route it: answer where it was posted, in its thread when it came in one, or create what it needs: a task with a plan whose stages name who does them, a thread, or a project, adding citizens to a project first when they are not members. Stay silent when the user already addressed a citizen and nothing else is needed.");
10901
11326
  }
11327
+ const changed = input.changedInstructions ?? null;
11328
+ if (changed !== null) {
11329
+ lines.push("", "## Your instructions changed", "", INSTRUCTIONS_CHANGED, "", "<instructions>", changed.trimEnd(), "</instructions>");
11330
+ }
10902
11331
  const conversation = input.conversation ?? null;
10903
11332
  lines.push("", "## This conversation", "");
10904
11333
  if (conversation === null && dispatch.channel !== void 0) {
@@ -10947,6 +11376,11 @@ function buildTurnPrompt(input) {
10947
11376
  "Its CLI could not resume this conversation's session, so the session was dropped and this turn starts a new one: nothing of your earlier turns here carries over but what is on the board and in your home."
10948
11377
  ] : [], "The worktree may hold uncommitted changes. Run git status before doing anything else.");
10949
11378
  }
11379
+ const failed = input.failedCrons ?? [];
11380
+ if (failed.length > 0) {
11381
+ const timeZone = input.clock?.timeZone ?? DEFAULT_TIME_ZONE;
11382
+ lines.push("", "## Cron fires that did not finish", "", failed.length === 1 ? "The turn of this cron's latest fire here failed, so what its note asks may not be done:" : "The turns of these crons' latest fires here failed, so what their notes ask may not be done:", "", ...failed.flatMap((cron) => cronLines(cron, timeZone)));
11383
+ }
10950
11384
  if (last?.trigger.kind === "reflection" && last.exitReason === "completed" && last.sessionDropped === true) {
10951
11385
  lines.push("", "## Your session started afresh", "", "Your last turn here was a reflection, and this conversation's session ended with it, so this turn starts a new one: what you kept is in memory/core.md and the archive it points to, and what your earlier turns did is on the board, in your posts and the threads and tasks you worked in, which search and the board's files reach.");
10952
11386
  }
@@ -10966,7 +11400,7 @@ function buildTurnPrompt(input) {
10966
11400
  }
10967
11401
  const machines = input.runners;
10968
11402
  if (machines !== void 0 && machines !== null) {
10969
- lines.push("", "## Runners", "", "The machines turns run on. A project lives on the runner that takes its first turn, one that offers every capability the project and that turn's task require, and moves only when the user moves it. A task's required_capabilities must name capabilities exactly as a runner below offers them; a task needing one its project's runner lacks waits. A runner's quota is what each CLI's login there last reported of its plan, in percent of each window; a CLI limited until a time fails its turns there until then.", "");
11403
+ lines.push("", "## Runners", "", "The machines turns run on. A project lives on the runner that takes its first turn, one that offers every capability the project and that turn's task require, and moves only when the user moves it. A task's required_capabilities must name capabilities exactly as a runner below offers them; a task needing one its project's runner lacks waits. A runner's quota is what each CLI's login there last reported of its plan, in percent of each window; a CLI limited until a time fails its turns there until then. A runner, or a CLI on it, paused by the user starts none of the turns it would run until the user resumes it: they wait, and the stages their citizens hold keep their leases.", "");
10970
11404
  if (machines.runners.length === 0) {
10971
11405
  lines.push("None registered yet.");
10972
11406
  }
@@ -11211,7 +11645,7 @@ var TurnHost = class {
11211
11645
  worktree: PATH_TOKENS.worktree,
11212
11646
  otherChannels: (project?.channels ?? []).map((name) => channelRef(dispatch.project, name)).filter((ref) => !agent.subscriptions.includes(ref))
11213
11647
  } : null;
11214
- const instructions = renderInstructions({
11648
+ const rendered = {
11215
11649
  agentName: agent.name,
11216
11650
  roleCharter: await this.board.readAgentRoleBody(agent.name),
11217
11651
  memoryCore: await this.board.readMemoryCore(agent.name),
@@ -11229,7 +11663,11 @@ var TurnHost = class {
11229
11663
  }))
11230
11664
  ],
11231
11665
  ...onboarding === null ? {} : { onboarding }
11232
- });
11666
+ };
11667
+ const instructions = renderInstructions(rendered);
11668
+ const instructionsNow = instructionsHash(rendered);
11669
+ const instructionsHeld = !newSession && lastTurn?.session === recorded ? lastTurn.sessionInstructionsHash : void 0;
11670
+ const changedInstructions = !newSession && instructionsHeld !== instructionsNow;
11233
11671
  const societyView = charter.wakeTriggers.includes(USER_POST_TRIGGER2) ? {
11234
11672
  projects: (await this.board.listProjects()).filter((each) => each.archived === void 0),
11235
11673
  members: await this.board.listMembers()
@@ -11270,6 +11708,11 @@ var TurnHost = class {
11270
11708
  crons.push(cron);
11271
11709
  }
11272
11710
  }
11711
+ const failedCrons = (await this.board.listCrons()).filter((cron) => cron.agent === agent.name && cron.scope === dispatch.project && conversationPart(cron) === part && cronFailure(cron) !== null && !(dispatch.crons ?? []).includes(cron.id));
11712
+ const cronFailures = failedCrons.flatMap((cron) => {
11713
+ const failure = cronFailure(cron);
11714
+ return failure === null ? [] : [{ cron: cron.id, turnId: failure.turnId }];
11715
+ });
11273
11716
  const prompt = buildTurnPrompt({
11274
11717
  dispatch,
11275
11718
  clock: { now: this.now(), timeZone: society.timezone },
@@ -11282,6 +11725,8 @@ var TurnHost = class {
11282
11725
  waitingStages: waiting,
11283
11726
  project,
11284
11727
  lastTurn,
11728
+ failedCrons,
11729
+ ...changedInstructions ? { changedInstructions: instructions } : {},
11285
11730
  onboarding,
11286
11731
  afreshAfterReflection,
11287
11732
  societyView,
@@ -11297,7 +11742,9 @@ var TurnHost = class {
11297
11742
  ...base,
11298
11743
  session: recorded ?? null,
11299
11744
  sessionCostUsd: costSoFarUsd,
11300
- ...usageSoFar === void 0 ? {} : { sessionUsage: usageSoFar }
11745
+ ...usageSoFar === void 0 ? {} : { sessionUsage: usageSoFar },
11746
+ ...instructionsHeld === void 0 ? {} : { sessionInstructionsHash: instructionsHeld },
11747
+ ...cronFailures.length === 0 ? {} : { cronFailures }
11301
11748
  });
11302
11749
  if (record.id === void 0) {
11303
11750
  throw new Error("the board gave the turn no id");
@@ -11317,6 +11764,7 @@ var TurnHost = class {
11317
11764
  costSoFarUsd,
11318
11765
  ...usageSoFar === void 0 ? {} : { usageSoFar },
11319
11766
  instructions,
11767
+ instructionsHash: instructionsNow,
11320
11768
  prompt,
11321
11769
  mcp: { url: this.mcpUrl, token },
11322
11770
  limits: { timeoutMs: this.turnTimeoutMs, maxTurns: this.maxTurns },
@@ -11340,6 +11788,7 @@ var TurnHost = class {
11340
11788
  stopRequestedBy: null,
11341
11789
  held: held.map((each) => each.id),
11342
11790
  costSoFarUsd,
11791
+ instructionsNow,
11343
11792
  resumeRetries,
11344
11793
  configuredModel: agent.model ?? null,
11345
11794
  transcript: [],
@@ -11510,7 +11959,10 @@ var TurnHost = class {
11510
11959
  const resumeRetries = outcome.resumeFailed === "retry" ? turn.resumeRetries + 1 : void 0;
11511
11960
  const dropSession = job.session !== null && (outcome.resumeFailed === "lost" || (resumeRetries ?? 0) >= RESUME_RETRIES);
11512
11961
  const reflected = turn.afreshAfterReflection && turn.record.trigger.kind === "reflection" && outcome.exitReason === "completed";
11513
- const { sessionUsage: _started, ...begun } = turn.record;
11962
+ const { sessionUsage: _started, sessionInstructionsHash: heldBefore, ...begun } = turn.record;
11963
+ const promptDelivered = outcome.exitReason === "completed" || outcome.exitReason === "blocked" || outcome.exitReason === "stopped";
11964
+ const instructionsTaken = outcome.exitReason === "stopped" ? outcome.contextTokens !== void 0 : promptDelivered;
11965
+ const instructionsHeld = job.session === null ? outcome.session === null ? void 0 : turn.instructionsNow : instructionsTaken ? turn.instructionsNow : heldBefore;
11514
11966
  const finished = {
11515
11967
  ...begun,
11516
11968
  session: outcome.session,
@@ -11522,6 +11974,7 @@ var TurnHost = class {
11522
11974
  costUsd: outcome.costUsd,
11523
11975
  sessionCostUsd: outcome.sessionCostUsd ?? turn.costSoFarUsd,
11524
11976
  ...outcome.sessionUsage === void 0 ? {} : { sessionUsage: outcome.sessionUsage },
11977
+ ...instructionsHeld === void 0 ? {} : { sessionInstructionsHash: instructionsHeld },
11525
11978
  ...outcome.contextTokens === void 0 ? {} : { contextTokens: outcome.contextTokens },
11526
11979
  toolCalls: turn.transcript.filter((entry) => entry.event.type === "tool_call").length,
11527
11980
  model: outcome.model ?? turn.configuredModel,
@@ -11556,7 +12009,7 @@ var TurnHost = class {
11556
12009
  } else if (outcome.session !== null && outcome.session !== job.session) {
11557
12010
  await this.board.writeSession(job.agent, job.scope, job.cli, outcome.session, turn.runner, part);
11558
12011
  }
11559
- if (outcome.exitReason === "completed" || outcome.exitReason === "blocked" || outcome.exitReason === "stopped") {
12012
+ if (promptDelivered) {
11560
12013
  await this.board.setDigestCursor(job.agent, job.scope, turn.delivered, part);
11561
12014
  await this.renewLeases(turn.actor, turn.held);
11562
12015
  if (outcome.status?.needsUserDecision === true) {
@@ -12171,7 +12624,8 @@ var RunnerHub = class {
12171
12624
  return { refused: `${agent.name} has no CLI binding` };
12172
12625
  }
12173
12626
  const cli = agent.cli;
12174
- const free = (seat) => seat.send !== null && !seat.unconfirmed && seat.hello.clis.includes(cli) && (seat.hello.slots === null || seat.inUse < seat.hello.slots);
12627
+ const runners2 = await this.board.listRunners();
12628
+ const free = (seat) => seat.send !== null && !seat.unconfirmed && seat.hello.clis.includes(cli) && (seat.hello.slots === null || seat.inUse < seat.hello.slots) && !heldOn(runners2, seat.name, cli);
12175
12629
  let chosen;
12176
12630
  if (dispatch.project === SOCIETY_SCOPE) {
12177
12631
  chosen = await this.pinnedSeat(agent.name, agent.homeRunner, cli, free);
@@ -12428,11 +12882,7 @@ var RunnerHub = class {
12428
12882
  move.id = await this.board.openMoveRepo();
12429
12883
  const id = move.id;
12430
12884
  move.phase = "export";
12431
- const exported = ProjectExportedSchema.parse(await this.request(source, (request) => ({
12432
- type: "export",
12433
- request,
12434
- export: { move: id, repo }
12435
- })).catch((error) => {
12885
+ const exported = ProjectExportedSchema.parse(await this.request(source, (request) => ({ type: "export", request, export: { move: id, repo } }), moveTransferMs(MOVE_PACK_LIMIT_BYTES) + this.requestTimeoutMs).catch((error) => {
12436
12886
  throw new BoardError("INVALID_STATE", `${from} could not send ${slug}: ${describe(error)}`);
12437
12887
  }));
12438
12888
  if (!exported.ok) {
@@ -12442,8 +12892,13 @@ var RunnerHub = class {
12442
12892
  const imported = ProjectImportedSchema.parse(await this.request(target, (request) => ({
12443
12893
  type: "import",
12444
12894
  request,
12445
- import: { move: id, repo, pullRequests: project.onDone === "ghpr" }
12446
- })).catch((error) => {
12895
+ import: {
12896
+ move: id,
12897
+ repo,
12898
+ pullRequests: project.onDone === "ghpr",
12899
+ bytes: exported.bytes
12900
+ }
12901
+ }), moveTransferMs(exported.bytes) + this.requestTimeoutMs).catch((error) => {
12447
12902
  throw new BoardError("INVALID_STATE", `${to} could not take ${slug}: ${describe(error)}`);
12448
12903
  }));
12449
12904
  move.phase = "recording";
@@ -12570,18 +13025,8 @@ var RunnerHub = class {
12570
13025
  * back as a `BoardError` in the runner's words.
12571
13026
  */
12572
13027
  async readTurnImage(actor, file) {
12573
- const turn = this.host.runningTurns().find((each) => each.agent === actor.name && each.scope === actor.scope && each.thread === actor.thread && each.channel === actor.channel && each.place === actor.place);
12574
- if (turn === void 0) {
12575
- throw new BoardError("INVALID_STATE", `${actor.name} has no turn running in this conversation to read ${file} from`);
12576
- }
12577
- const seat = this.seats.get(turn.runner);
12578
- if (seat === void 0 || seat.send === null) {
12579
- throw new RunnerAwayError(`runner ${turn.runner}, where the turn runs, is not connected`);
12580
- }
12581
- if (!seat.hello.imageReads) {
12582
- throw new BoardError("INVALID_STATE", `runner ${seat.name}, where the turn runs, cannot read images yet; upgrade it`);
12583
- }
12584
- const image = TurnImageSchema.nullable().parse(await this.request(seat, (request) => ({ type: "image", request, read: { turnId: turn.turnId, path: file } }), FILE_TIMEOUT_MS));
13028
+ const { turnId, seat } = this.turnSeat(actor, "imageReads", `read ${file} from`, "read images");
13029
+ const image = TurnImageSchema.nullable().parse(await this.request(seat, (request) => ({ type: "image", request, read: { turnId, path: file } }), FILE_TIMEOUT_MS));
12585
13030
  if (image === null) {
12586
13031
  throw new BoardError("INVALID_STATE", `the turn is no longer running on runner ${seat.name}`);
12587
13032
  }
@@ -12590,6 +13035,28 @@ var RunnerHub = class {
12590
13035
  }
12591
13036
  return Buffer.from(image.content, "base64");
12592
13037
  }
13038
+ /**
13039
+ * Hands an image the board keeps to the runner a citizen's turn runs on, for `read_image`, which
13040
+ * writes it to a folder of that turn's own in the system temp folder, removed when the turn
13041
+ * ends, and answers the file's path there, with the same refusals as `readTurnImage`.
13042
+ */
13043
+ async writeTurnImage(actor, image) {
13044
+ const { turnId, seat } = this.turnSeat(actor, "imageWrites", "open an image in", "open images");
13045
+ const file = TurnImageFileSchema.nullable().parse(await this.request(seat, (request) => ({
13046
+ type: "image_write",
13047
+ request,
13048
+ write: {
13049
+ turnId,
13050
+ id: image.id,
13051
+ type: image.type,
13052
+ content: Buffer.from(image.bytes).toString("base64")
13053
+ }
13054
+ }), FILE_TIMEOUT_MS));
13055
+ if (file === null) {
13056
+ throw new BoardError("INVALID_STATE", `the turn is no longer running on runner ${seat.name}`);
13057
+ }
13058
+ return file.path;
13059
+ }
12593
13060
  /** The models a CLI offers, asked of a connected runner that has it; none when no runner has it. */
12594
13061
  /**
12595
13062
  * The connected runner a model list for `cli` comes from: the first of `prefer` that has the CLI,
@@ -12767,6 +13234,25 @@ var RunnerHub = class {
12767
13234
  repo: projectRepo(record)
12768
13235
  };
12769
13236
  }
13237
+ /**
13238
+ * The caller's turn in flight, found by its conversation, and the connected runner it runs on,
13239
+ * which must say `able` in its hello: an image's way to and from the machine a turn runs on.
13240
+ * `where` and `what` say what the caller asked for in a refusal.
13241
+ */
13242
+ turnSeat(actor, able, where, what) {
13243
+ const turn = this.host.runningTurns().find((each) => each.agent === actor.name && each.scope === actor.scope && each.thread === actor.thread && each.channel === actor.channel && each.place === actor.place);
13244
+ if (turn === void 0) {
13245
+ throw new BoardError("INVALID_STATE", `${actor.name} has no turn running in this conversation to ${where}`);
13246
+ }
13247
+ const seat = this.seats.get(turn.runner);
13248
+ if (seat === void 0 || seat.send === null) {
13249
+ throw new RunnerAwayError(`runner ${turn.runner}, where the turn runs, is not connected`);
13250
+ }
13251
+ if (!seat.hello[able]) {
13252
+ throw new BoardError("INVALID_STATE", `runner ${seat.name}, where the turn runs, cannot ${what} yet; upgrade it`);
13253
+ }
13254
+ return { turnId: turn.turnId, seat };
13255
+ }
12770
13256
  assertOwns(runner, turnId) {
12771
13257
  const owner = this.host.runnerOf(turnId);
12772
13258
  if (owner !== void 0 && owner !== runner) {
@@ -12899,6 +13385,7 @@ import { bodyLimit } from "hono/body-limit";
12899
13385
  var ENROLL_BODY_LIMIT_BYTES = 16 * 1024;
12900
13386
  var APP_TOKEN_BODY_LIMIT_BYTES = 16 * 1024;
12901
13387
  var API_BODY_LIMIT_BYTES = 1024 * 1024;
13388
+ var IMAGE_BODY_LIMIT_BYTES = IMAGE_LIMIT_BYTES;
12902
13389
  var RUNNER_BODY_LIMIT_BYTES = 8 * 1024 * 1024;
12903
13390
  var RUNNER_ANSWER_LIMIT_BYTES = 16 * 1024 * 1024;
12904
13391
  function limitBody(maxSize) {
@@ -12915,7 +13402,7 @@ function limitBody(maxSize) {
12915
13402
  }
12916
13403
 
12917
13404
  // apps/server/dist/github-auth.js
12918
- import { createHash as createHash3, randomBytes as randomBytes2, timingSafeEqual } from "node:crypto";
13405
+ import { createHash as createHash4, randomBytes as randomBytes2, timingSafeEqual } from "node:crypto";
12919
13406
  import { Hono } from "hono";
12920
13407
  import { z as z18 } from "zod";
12921
13408
  var SIGN_IN_TTL_MS = 30 * 24 * 36e5;
@@ -12968,7 +13455,7 @@ function prune(map, now) {
12968
13455
  }
12969
13456
  }
12970
13457
  function provesChallenge(verifier, challenge) {
12971
- const answer = Buffer.from(createHash3("sha256").update(verifier).digest("base64url"));
13458
+ const answer = Buffer.from(createHash4("sha256").update(verifier).digest("base64url"));
12972
13459
  const expected = Buffer.from(challenge);
12973
13460
  return answer.length === expected.length && timingSafeEqual(answer, expected);
12974
13461
  }
@@ -13203,9 +13690,109 @@ import { Hono as Hono2 } from "hono";
13203
13690
  import { streamSSE } from "hono/streaming";
13204
13691
 
13205
13692
  // apps/server/dist/home-git.js
13693
+ import { once } from "node:events";
13206
13694
  import { execa as execa3 } from "execa";
13695
+
13696
+ // apps/server/dist/serve.js
13697
+ import { serve } from "@hono/node-server";
13698
+ var REQUEST_TIMEOUT_MS = 3e5;
13699
+ var HEADERS_TIMEOUT_MS = 6e4;
13700
+ var deadlines = /* @__PURE__ */ new WeakMap();
13701
+ function serveBoard(options, listening) {
13702
+ const timeoutMs = options.requestTimeoutMs ?? REQUEST_TIMEOUT_MS;
13703
+ return serve({
13704
+ fetch: (request, env) => {
13705
+ const { incoming, outgoing } = env;
13706
+ if (!incoming.complete) {
13707
+ const timer = setTimeout(() => {
13708
+ if (!incoming.complete) {
13709
+ incoming.socket.destroy();
13710
+ }
13711
+ }, timeoutMs);
13712
+ timer.unref();
13713
+ const lift = () => clearTimeout(timer);
13714
+ deadlines.set(request, lift);
13715
+ outgoing.once("close", lift);
13716
+ }
13717
+ return options.fetch(request, env);
13718
+ },
13719
+ port: options.port,
13720
+ hostname: options.hostname,
13721
+ serverOptions: {
13722
+ requestTimeout: 0,
13723
+ headersTimeout: options.headersTimeoutMs ?? HEADERS_TIMEOUT_MS,
13724
+ ...options.connectionsCheckingIntervalMs === void 0 ? {} : { connectionsCheckingInterval: options.connectionsCheckingIntervalMs }
13725
+ }
13726
+ }, listening);
13727
+ }
13728
+ function liftDeadline(request) {
13729
+ deadlines.get(request)?.();
13730
+ deadlines.delete(request);
13731
+ }
13732
+
13733
+ // apps/server/dist/home-git.js
13207
13734
  var SMART_PATHS = /* @__PURE__ */ new Set(["/info/refs", "/git-upload-pack", "/git-receive-pack"]);
13208
13735
  var MAX_BYTES = MOVE_PACK_LIMIT_BYTES;
13736
+ var IDLE_MS = 5 * 6e4;
13737
+ async function feed(body, stdin, limit2, idleMs) {
13738
+ if (body === null) {
13739
+ stdin.end();
13740
+ return "done";
13741
+ }
13742
+ const reader = body.getReader();
13743
+ const waits = new AbortController();
13744
+ let ended = null;
13745
+ const endedAs = () => ended;
13746
+ const end = (why) => {
13747
+ if (ended === null) {
13748
+ ended = why;
13749
+ waits.abort();
13750
+ if (why === "idle" || why === "cut") {
13751
+ reader.cancel().catch(() => void 0);
13752
+ } else {
13753
+ reader.releaseLock();
13754
+ }
13755
+ }
13756
+ return ended;
13757
+ };
13758
+ const timer = setTimeout(() => end("idle"), idleMs);
13759
+ const stopped = () => {
13760
+ end("stopped");
13761
+ };
13762
+ stdin.on("error", stopped);
13763
+ stdin.once("close", stopped);
13764
+ let total = 0;
13765
+ try {
13766
+ for (; ; ) {
13767
+ const next = await reader.read().catch(() => null);
13768
+ const early = endedAs();
13769
+ if (early !== null) {
13770
+ return early;
13771
+ }
13772
+ if (next === null) {
13773
+ return end("cut");
13774
+ }
13775
+ if (next.done) {
13776
+ stdin.end();
13777
+ return "done";
13778
+ }
13779
+ timer.refresh();
13780
+ total += next.value.byteLength;
13781
+ if (total > limit2) {
13782
+ return end("too large");
13783
+ }
13784
+ if (!stdin.write(next.value)) {
13785
+ const drained = await once(stdin, "drain", { signal: waits.signal }).then(() => true, () => false);
13786
+ if (!drained) {
13787
+ return end("stopped");
13788
+ }
13789
+ }
13790
+ }
13791
+ } finally {
13792
+ clearTimeout(timer);
13793
+ stdin.off("close", stopped);
13794
+ }
13795
+ }
13209
13796
  function parseCgi(output) {
13210
13797
  const crlf = output.indexOf("\r\n\r\n");
13211
13798
  const lf = output.indexOf("\n\n");
@@ -13233,6 +13820,7 @@ function parseCgi(output) {
13233
13820
  var HomeGit = class {
13234
13821
  pushes = /* @__PURE__ */ new Map();
13235
13822
  serve(input) {
13823
+ liftDeadline(input.request);
13236
13824
  if (input.rest !== "/git-receive-pack") {
13237
13825
  return serveHomeGit(input);
13238
13826
  }
@@ -13247,10 +13835,13 @@ async function serveHomeGit(input) {
13247
13835
  return new Response("not a smart HTTP path", { status: 404 });
13248
13836
  }
13249
13837
  const { request } = input;
13250
- const body = Buffer.from(await request.arrayBuffer());
13251
- if (body.length > MAX_BYTES) {
13838
+ const limit2 = input.limitBytes ?? MAX_BYTES;
13839
+ const length = request.headers.get("content-length");
13840
+ const declared = length !== null && /^\d+$/.test(length) ? length : null;
13841
+ if (declared !== null && Number(declared) > limit2) {
13252
13842
  return new Response("pack too large for the board to take", { status: 413 });
13253
13843
  }
13844
+ liftDeadline(request);
13254
13845
  const url = new URL(request.url);
13255
13846
  const env = {
13256
13847
  PATH: process.env["PATH"] ?? "/usr/bin:/bin",
@@ -13261,10 +13852,12 @@ async function serveHomeGit(input) {
13261
13852
  PATH_INFO: `/${input.repository}${input.rest}`,
13262
13853
  QUERY_STRING: url.search.replace(/^\?/, ""),
13263
13854
  CONTENT_TYPE: request.headers.get("content-type") ?? "",
13264
- CONTENT_LENGTH: String(body.length),
13265
13855
  REMOTE_USER: input.runner,
13266
13856
  REMOTE_ADDR: "127.0.0.1"
13267
13857
  };
13858
+ if (declared !== null) {
13859
+ env["CONTENT_LENGTH"] = declared;
13860
+ }
13268
13861
  const encoding = request.headers.get("content-encoding");
13269
13862
  if (encoding !== null) {
13270
13863
  env["HTTP_CONTENT_ENCODING"] = encoding;
@@ -13273,10 +13866,9 @@ async function serveHomeGit(input) {
13273
13866
  if (protocol !== null) {
13274
13867
  env["HTTP_GIT_PROTOCOL"] = protocol;
13275
13868
  }
13276
- const result = await execa3("git", ["http-backend"], {
13869
+ const backend = execa3("git", ["http-backend"], {
13277
13870
  env,
13278
13871
  extendEnv: false,
13279
- input: body,
13280
13872
  encoding: "buffer",
13281
13873
  // The blank line that ends the headers ends the whole answer when the body is empty, as for
13282
13874
  // the 4-byte probe git sends before a pack too large for one post; stripped, it reads as none.
@@ -13284,6 +13876,15 @@ async function serveHomeGit(input) {
13284
13876
  reject: false,
13285
13877
  maxBuffer: MAX_BYTES
13286
13878
  });
13879
+ const fed = await feed(request.body, backend.stdin, limit2, input.idleMs ?? IDLE_MS);
13880
+ if (fed !== "done" && fed !== "stopped") {
13881
+ backend.kill();
13882
+ await backend;
13883
+ return fed === "too large" ? new Response("pack too large for the board to take", { status: 413 }) : new Response(fed === "idle" ? "the request stopped arriving" : "the request was cut", {
13884
+ status: fed === "idle" ? 408 : 400
13885
+ });
13886
+ }
13887
+ const result = await backend;
13287
13888
  if (result.exitCode !== 0 && result.stdout.length === 0) {
13288
13889
  return new Response(`git http-backend failed: ${Buffer.from(result.stderr).toString("utf8")}`, {
13289
13890
  status: 500
@@ -13566,6 +14167,14 @@ function createApp(deps) {
13566
14167
  };
13567
14168
  const api = new Hono3();
13568
14169
  api.use("*", authenticate);
14170
+ api.post("/images", limitBody(IMAGE_BODY_LIMIT_BYTES), async (c) => {
14171
+ if (c.get("actor").role !== USER_ROLE) {
14172
+ throw new BoardError("FORBIDDEN", "only the user posts an image's bytes");
14173
+ }
14174
+ const name = ImageNameSchema.optional().parse(c.req.query("name"));
14175
+ const bytes = new Uint8Array(await c.req.arrayBuffer());
14176
+ return c.json(await board2.postImage(c.get("actor"), bytes, name));
14177
+ });
13569
14178
  api.on(["POST", "PUT", "PATCH", "DELETE"], "*", limitBody(API_BODY_LIMIT_BYTES));
13570
14179
  api.get("/me", (c) => {
13571
14180
  const signIn = board2.signInOf(bearer2(c) ?? "");
@@ -13618,6 +14227,21 @@ function createApp(deps) {
13618
14227
  }
13619
14228
  return c.json(await quotas.read(runner, cli));
13620
14229
  });
14230
+ for (const [verb, paused] of [
14231
+ ["pause", true],
14232
+ ["resume", false]
14233
+ ]) {
14234
+ for (const path12 of [`/runners/:name/${verb}`, `/runners/:name/${verb}/:cli`]) {
14235
+ api.post(path12, async (c) => {
14236
+ const name = NameSchema.parse(c.req.param("name"));
14237
+ const cli = c.req.param("cli");
14238
+ return c.json(await board2.setRunnerPaused(c.get("actor"), name, {
14239
+ paused,
14240
+ ...cli === void 0 ? {} : { cli: CliKindSchema.parse(cli) }
14241
+ }));
14242
+ });
14243
+ }
14244
+ }
13621
14245
  api.post("/runners", async (c) => {
13622
14246
  const body = RunnerBodySchema.parse(await c.req.json());
13623
14247
  return c.json(await board2.addRunner(c.get("actor"), body.name));
@@ -14194,6 +14818,7 @@ var host = new TurnHost({
14194
14818
  var runners = new RunnerHub({ board, host, version: VERSION, log });
14195
14819
  board.watchTurns(() => host.inFlight());
14196
14820
  board.readImagesWith((actor, file) => runners.readTurnImage(actor, file));
14821
+ board.openImagesWith((actor, image) => runners.writeTurnImage(actor, image));
14197
14822
  var concurrency = config.concurrency ?? Number.POSITIVE_INFINITY;
14198
14823
  var timings = parseTimings(JSON.parse(process.env["STELLARIS_TIMINGS"] ?? "{}"));
14199
14824
  var scheduler = new Scheduler({ board, runner: runners, log, concurrency, timings });
@@ -14227,7 +14852,7 @@ var app = createApp({
14227
14852
  trustedProxies: config.trustedProxies,
14228
14853
  log
14229
14854
  });
14230
- var server = serve({ fetch: app.fetch, port: config.port, hostname: config.host }, (info) => {
14855
+ var server = serveBoard({ fetch: app.fetch, port: config.port, hostname: config.host }, (info) => {
14231
14856
  log.info({
14232
14857
  host: info.address,
14233
14858
  port: info.port,