@kubitnodes/stellaris 0.3.27 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/cli/index.js +448 -66
  2. package/dist/server/index.js +882 -156
  3. package/package.json +2 -2
  4. package/web/dist/assets/BackLink-fxPmFUg8.js +1 -0
  5. package/web/dist/assets/Button-DxGzriWQ.js +1 -0
  6. package/web/dist/assets/ChannelForms-C3m4IPnZ.js +1 -0
  7. package/web/dist/assets/ChannelView-DvtoxLi5.js +2 -0
  8. package/web/dist/assets/CitizenView-Dq64VsZi.js +2 -0
  9. package/web/dist/assets/{CitizensView-Bgw6PRSo.js → CitizensView-BOZHABbN.js} +1 -1
  10. package/web/dist/assets/{CliIcon-BB2NdLxN.js → CliIcon-D3GddnP4.js} +1 -1
  11. package/web/dist/assets/CronsView-CI947lZS.js +1 -0
  12. package/web/dist/assets/{FileTree-CHDmrOhY.js → FileTree-DSFN4sd8.js} +1 -1
  13. package/web/dist/assets/{FileView-DUMfKYRW.js → FileView-DyUYLXRC.js} +2 -2
  14. package/web/dist/assets/KnowledgeView-CMdXaTvS.js +1 -0
  15. package/web/dist/assets/{Markdown-C7RbUEZE.js → Markdown-BSeWHBBA.js} +5 -5
  16. package/web/dist/assets/{MessageItem-DgWh5pJF.js → MessageItem-qIxS96uJ.js} +1 -1
  17. package/web/dist/assets/MetricsView-BhjdwkX1.js +1 -0
  18. package/web/dist/assets/{NeedsYouView-CrmHXZAn.js → NeedsYouView-7uZW9dY8.js} +1 -1
  19. package/web/dist/assets/{Overview-xatORJK6.js → Overview-D6kiK8sE.js} +1 -1
  20. package/web/dist/assets/{Pane-BlIRwUtQ.js → Pane-pZUzNQIW.js} +1 -1
  21. package/web/dist/assets/{Picker-BSOB2JM_.js → Picker-CDf6os_x.js} +1 -1
  22. package/web/dist/assets/{ProjectView-CVt28SoI.js → ProjectView-B6OmxiT9.js} +1 -1
  23. package/web/dist/assets/ProposalView-B1X8Jlo0.js +1 -0
  24. package/web/dist/assets/{ProposalsView-C0r1ya8_.js → ProposalsView-DBGpcAPO.js} +1 -1
  25. package/web/dist/assets/{Quota-59QJZrPa.js → Quota-Brd4ddB4.js} +1 -1
  26. package/web/dist/assets/RunnersView-45OXtalD.js +1 -0
  27. package/web/dist/assets/{SkillFileList-Cs_f3RPo.js → SkillFileList-BUImALv9.js} +1 -1
  28. package/web/dist/assets/{SkillView-BvVx3LSL.js → SkillView-B1knYZV8.js} +1 -1
  29. package/web/dist/assets/SocietyView-F7bzrNv5.js +1 -0
  30. package/web/dist/assets/{StickyList-BbGq82Jj.js → StickyList-D_ED_-LW.js} +1 -1
  31. package/web/dist/assets/{SubjectThread-DdkyhVqK.js → SubjectThread-B5zvoRSd.js} +1 -1
  32. package/web/dist/assets/{TaskView-CyEWpCvS.js → TaskView-B5_QTI3p.js} +2 -2
  33. package/web/dist/assets/{TasksView-D10XP1uk.js → TasksView-DZLNqU2V.js} +1 -1
  34. package/web/dist/assets/{ThreadForms-D94Jj0ES.js → ThreadForms-CbFClyd0.js} +1 -1
  35. package/web/dist/assets/{ThreadItem-CYmdCPOf.js → ThreadItem-kFn5M3Cx.js} +1 -1
  36. package/web/dist/assets/{ThreadView-D79G2zgF.js → ThreadView-CHFs0zaS.js} +1 -1
  37. package/web/dist/assets/citizen-B8p56JRh.js +1 -0
  38. package/web/dist/assets/compose-CV4o6TMb.js +1 -0
  39. package/web/dist/assets/format-BYF63sQU.js +55 -0
  40. package/web/dist/assets/{governance-YialJVYK.js → governance-BjIg0eok.js} +1 -1
  41. package/web/dist/assets/index-CA_njyMe.css +2 -0
  42. package/web/dist/assets/index-CyROMh25.js +24 -0
  43. package/web/dist/assets/{mentions-Buc5vs-j.js → mentions-BH0RZXfc.js} +1 -1
  44. package/web/dist/assets/{tasks-DLTeaHys.js → tasks-D2Es55TQ.js} +1 -1
  45. package/web/dist/assets/useMatch-DeOpK57G.js +1 -0
  46. package/web/dist/index.html +9 -9
  47. package/web/dist/assets/BackLink-qSxOMeEG.js +0 -1
  48. package/web/dist/assets/Button-CxjWy598.js +0 -1
  49. package/web/dist/assets/ChannelForms-BVidWMId.js +0 -1
  50. package/web/dist/assets/ChannelView-rqfk1Tyu.js +0 -2
  51. package/web/dist/assets/CitizenView-xU99R8hS.js +0 -2
  52. package/web/dist/assets/CronsView-BrXrcfC6.js +0 -1
  53. package/web/dist/assets/KnowledgeView-Qog6c92V.js +0 -1
  54. package/web/dist/assets/MetricsView-DuOyISk1.js +0 -1
  55. package/web/dist/assets/ProposalView-DJ0aJm7J.js +0 -1
  56. package/web/dist/assets/RunnersView-CcAAfw6y.js +0 -1
  57. package/web/dist/assets/SocietyView-CJRrypjI.js +0 -1
  58. package/web/dist/assets/citizen-CgA4vJjy.js +0 -1
  59. package/web/dist/assets/compose-3ksQaHF6.js +0 -1
  60. package/web/dist/assets/format-DpQyO6r1.js +0 -55
  61. package/web/dist/assets/index-DIa8Dlav.css +0 -2
  62. package/web/dist/assets/index-DhuCp-VF.js +0 -23
  63. 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;
@@ -382,6 +398,21 @@ function quotaLevel(quota, now) {
382
398
  const high = quota.windows.some((window) => windowCurrent(window, now) && window.usedPercent !== null && window.usedPercent >= QUOTA_WARNING_PERCENT);
383
399
  return quota.status === "warning" || high ? "warning" : "ok";
384
400
  }
401
+ var UNKNOWN_LIMIT_HOLD_MS = 30 * 6e4;
402
+ function stopsTurns(quota) {
403
+ return quota.status === "limited" && !(quota.source === "read" && quota.extraUsage?.enabled === true);
404
+ }
405
+ function limitHold(quota, now) {
406
+ if (!stopsTurns(quota)) {
407
+ return null;
408
+ }
409
+ const known = quota.limitedUntil !== null;
410
+ const until = quota.limitedUntil ?? new Date(Date.parse(quota.observedAt) + UNKNOWN_LIMIT_HOLD_MS).toISOString();
411
+ return Date.parse(until) > now.getTime() ? { until, known } : null;
412
+ }
413
+ function limitLapsed(quota, now) {
414
+ return stopsTurns(quota) && quota.limitedUntil === null && limitHold(quota, now) === null;
415
+ }
385
416
  var WINDOW_NAMES = {
386
417
  five_hour: "5h",
387
418
  seven_day: "7d",
@@ -704,6 +735,7 @@ var MemberSchema = z5.object({
704
735
  });
705
736
  var RunnerOsSchema = z5.enum(["linux", "windows", "darwin"]);
706
737
  var GitIdentityPartSchema = z5.enum(["name", "email"]);
738
+ var RunnerPauseSchema = z5.object({ at: IsoDateTimeSchema, by: NameSchema });
707
739
  var RunnerSchema = z5.object({
708
740
  name: NameSchema,
709
741
  os: RunnerOsSchema,
@@ -722,8 +754,29 @@ var RunnerSchema = z5.object({
722
754
  * Each CLI's plan quota as its login on this runner last reported it, from a turn's stream or a
723
755
  * read; absent until one has.
724
756
  */
725
- quotas: z5.partialRecord(CliKindSchema, CliQuotaSchema).optional()
757
+ quotas: z5.partialRecord(CliKindSchema, CliQuotaSchema).optional(),
758
+ /** The user's pause of the whole runner: no turn starts on it until the user resumes it. */
759
+ paused: RunnerPauseSchema.optional(),
760
+ /**
761
+ * The user's pauses of single CLIs on the runner, each apart from the runner's own: no turn of
762
+ * that CLI starts on it until the user resumes it.
763
+ */
764
+ pausedClis: z5.partialRecord(CliKindSchema, RunnerPauseSchema).optional()
726
765
  });
766
+ function runnerPause(runner, cli) {
767
+ return runner.paused ?? runner.pausedClis?.[cli];
768
+ }
769
+ function runnerLimitHold(runner, cli, now) {
770
+ const quota = runner.quotas?.[cli];
771
+ return quota === void 0 ? null : limitHold(quota, now);
772
+ }
773
+ function heldOn(runners2, runner, cli, now) {
774
+ if (runner === void 0 || cli === null) {
775
+ return false;
776
+ }
777
+ const record = runners2.find((each) => each.name === runner);
778
+ return record !== void 0 && (runnerPause(record, cli) !== void 0 || runnerLimitHold(record, cli, now) !== null);
779
+ }
727
780
  var ProposalKindSchema = z5.enum([
728
781
  "role",
729
782
  "member",
@@ -1073,7 +1126,21 @@ function imageTypeOf(bytes) {
1073
1126
  return null;
1074
1127
  }
1075
1128
  var ImagePathSchema = z6.string().min(1).max(4096).refine((value) => !value.includes("\0"), { message: "a path with no NUL character" });
1129
+ var ImageNameSchema = z6.string().max(1024);
1130
+ function imageDescription(name) {
1131
+ return name.replaceAll(/[[\]\\\r\n`]/g, "");
1132
+ }
1076
1133
  var IMAGE_SCHEME = "image:";
1134
+ var ImageRefSchema = z6.string().regex(/^(?:image:)?[0-9A-HJKMNP-TV-Z]{26}$/, "expected an image's id or image:<id>");
1135
+ function imageIdOf(ref) {
1136
+ return ref.startsWith(IMAGE_SCHEME) ? ref.slice(IMAGE_SCHEME.length) : ref;
1137
+ }
1138
+ var ImageFileSchema = z6.object({
1139
+ id: UlidSchema,
1140
+ type: ImageTypeSchema,
1141
+ size: z6.number().int().positive(),
1142
+ path: ImagePathSchema
1143
+ });
1077
1144
  var UploadedImageSchema = z6.object({
1078
1145
  id: UlidSchema,
1079
1146
  type: ImageTypeSchema,
@@ -1238,6 +1305,10 @@ var VerbInputs = {
1238
1305
  upload_image: z7.object({
1239
1306
  /** The image file on the machine your turn runs on: absolute, or relative to your working directory. */
1240
1307
  path: ImagePathSchema
1308
+ }),
1309
+ read_image: z7.object({
1310
+ /** The image's id, or its `image:<id>` address as a post shows it. */
1311
+ image_id: ImageRefSchema
1241
1312
  })
1242
1313
  };
1243
1314
  var VERB_DESCRIPTIONS = {
@@ -1249,7 +1320,7 @@ var VERB_DESCRIPTIONS = {
1249
1320
  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
1321
  claim_task: "Hold the task's current stage. Claims are leases renewed by every turn that touches the task.",
1251
1322
  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.",
1323
+ 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
1324
  get_task: "Read a task with its plan, its brief, and the messages of its thread.",
1254
1325
  subscribe: "Subscribe to a channel. Subscriptions feed your digest; they never wake you.",
1255
1326
  unsubscribe: "Unsubscribe from a channel.",
@@ -1272,7 +1343,8 @@ var VERB_DESCRIPTIONS = {
1272
1343
  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
1344
  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
1345
  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."
1346
+ 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.",
1347
+ 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
1348
  };
1277
1349
 
1278
1350
  // packages/shared/dist/events.js
@@ -1323,6 +1395,8 @@ var BOARD_EVENT_TYPES = [
1323
1395
  "user.token_rotated",
1324
1396
  "runner.changed",
1325
1397
  "runner.quota",
1398
+ "runner.paused",
1399
+ "runner.resumed",
1326
1400
  "ops.signal",
1327
1401
  "paused.changed",
1328
1402
  "wake.requested",
@@ -1472,6 +1546,11 @@ var RunnerHelloSchema = z9.object({
1472
1546
  quotaReads: z9.boolean().default(false),
1473
1547
  /** Whether the runner reads an image a turn running there uploads; one from before never does. */
1474
1548
  imageReads: z9.boolean().default(false),
1549
+ /**
1550
+ * Whether the runner writes an image the board sends for a turn running there to open, for
1551
+ * `read_image`; one from before never does.
1552
+ */
1553
+ imageWrites: z9.boolean().default(false),
1475
1554
  /**
1476
1555
  * Whether the runner carries a project's repository out to another runner and in from one, and
1477
1556
  * removes its copy once the project has moved; one from before never does.
@@ -1593,6 +1672,12 @@ var TurnJobSchema = z9.object({
1593
1672
  usageSoFar: UsageSchema.optional(),
1594
1673
  /** The rendered charter, norms, memory core, and skills index, with `PATH_TOKENS` for places. */
1595
1674
  instructions: z9.string(),
1675
+ /**
1676
+ * The hash of `instructions` without the first turn's section (`instructionsHash` in
1677
+ * `turn-host`): a warm process started on other instructions starts afresh, so a compaction in
1678
+ * it puts back the current ones. Absent from a server from before, when the text is compared.
1679
+ */
1680
+ instructionsHash: z9.string().optional(),
1596
1681
  prompt: z9.string(),
1597
1682
  mcp: z9.object({ url: z9.string().min(1), token: z9.string().min(1) }),
1598
1683
  limits: z9.object({
@@ -1777,6 +1862,13 @@ var TurnImageSchema = z9.discriminatedUnion("kind", [
1777
1862
  }),
1778
1863
  z9.object({ kind: z9.literal("refused"), why: ImageRefusalSchema, detail: z9.string() })
1779
1864
  ]);
1865
+ var TurnImageWriteSchema = z9.object({
1866
+ turnId: UlidSchema,
1867
+ id: UlidSchema,
1868
+ type: ImageTypeSchema,
1869
+ content: z9.string()
1870
+ });
1871
+ var TurnImageFileSchema = z9.object({ path: ImagePathSchema });
1780
1872
  var WorkspaceConversationSchema = z9.discriminatedUnion("kind", [
1781
1873
  z9.object({ kind: z9.literal("thread"), id: UlidSchema }),
1782
1874
  z9.object({ kind: z9.literal("channel"), scope: NameSchema, name: NameSchema }),
@@ -1799,6 +1891,10 @@ var HeldWorkspaceSchema = z9.object({
1799
1891
  var HeldWorkspacesSchema = z9.object({ workspaces: z9.array(HeldWorkspaceSchema) });
1800
1892
  var SweepResultSchema = z9.object({ kept: z9.array(HeldWorkspaceSchema) });
1801
1893
  var MOVE_PACK_LIMIT_BYTES = 256 * 1024 * 1024;
1894
+ var MOVE_FLOOR_BYTES_PER_SECOND = 16 * 1024;
1895
+ function moveTransferMs(bytes) {
1896
+ return 10 * 6e4 + Math.ceil(bytes / MOVE_FLOOR_BYTES_PER_SECOND) * 1e3;
1897
+ }
1802
1898
  var RepoRefsSchema = z9.record(z9.string().regex(/^refs\/(heads|tags)\/\S+$/), z9.string().regex(/^[0-9a-f]{40}([0-9a-f]{24})?$/));
1803
1899
  var ProjectExportSchema = z9.object({ move: UlidSchema, repo: ProjectRepoSchema });
1804
1900
  var HeldWorkSchema = z9.object({
@@ -1822,7 +1918,9 @@ var ProjectExportedSchema = z9.discriminatedUnion("ok", [
1822
1918
  var ProjectImportSchema = z9.object({
1823
1919
  move: UlidSchema,
1824
1920
  repo: ProjectRepoSchema,
1825
- pullRequests: z9.boolean()
1921
+ pullRequests: z9.boolean(),
1922
+ /** The packs' size the old runner measured, which the clone's clock is sized from; the cap if unsaid. */
1923
+ bytes: z9.number().int().nonnegative().optional()
1826
1924
  });
1827
1925
  var ProjectImportedSchema = z9.object({ refs: RepoRefsSchema });
1828
1926
  var ProjectReleaseSchema = z9.object({ slug: NameSchema });
@@ -1862,6 +1960,15 @@ var RunnerMessageSchema = z9.discriminatedUnion("type", [
1862
1960
  * whose hello says `imageReads`.
1863
1961
  */
1864
1962
  z9.object({ type: z9.literal("image"), request: z9.string().min(1), read: TurnImageReadSchema }),
1963
+ /**
1964
+ * Answered with a `TurnImageFile`, or null when the turn is not running there. Sent only to
1965
+ * runners whose hello says `imageWrites`.
1966
+ */
1967
+ z9.object({
1968
+ type: z9.literal("image_write"),
1969
+ request: z9.string().min(1),
1970
+ write: TurnImageWriteSchema
1971
+ }),
1865
1972
  /** Answered with whether the CLI accepted it; that it took it comes as a `steered` event. */
1866
1973
  z9.object({
1867
1974
  type: z9.literal("steer"),
@@ -1927,6 +2034,7 @@ var TriggerKindSchema = z10.enum([
1927
2034
  "closing",
1928
2035
  "cron"
1929
2036
  ]);
2037
+ var HeartbeatCauseSchema = z10.enum(["unread", "held", "waiting"]);
1930
2038
  var TriggerSchema = z10.object({
1931
2039
  kind: TriggerKindSchema,
1932
2040
  from: NameSchema.optional(),
@@ -1937,7 +2045,9 @@ var TriggerSchema = z10.object({
1937
2045
  /** The channel the waking message was posted in. */
1938
2046
  channel: ChannelRefSchema.optional(),
1939
2047
  /** The cron that came due, for a `cron` wake. */
1940
- cronId: UlidSchema.optional()
2048
+ cronId: UlidSchema.optional(),
2049
+ /** What a `heartbeat` woke for; absent on records from before the board kept it. */
2050
+ heartbeat: HeartbeatCauseSchema.optional()
1941
2051
  });
1942
2052
  var TurnThreadSchema = z10.object({ id: UlidSchema, task: z10.boolean() });
1943
2053
  var TurnDispatchSchema = z10.object({
@@ -2019,6 +2129,12 @@ var TurnRecordSchema = z10.object({
2019
2129
  * one. A resumed session's next turn measures its own usage from here.
2020
2130
  */
2021
2131
  sessionUsage: UsageSchema.optional(),
2132
+ /**
2133
+ * The SHA-256 of the instructions the session holds after this turn, the first turn's section
2134
+ * left out (`instructionsHash` in `turn-host`): those it began with, or the latest a prompt
2135
+ * carried. Absent when the board does not know them, as for records from before it kept them.
2136
+ */
2137
+ sessionInstructionsHash: z10.string().optional(),
2022
2138
  /**
2023
2139
  * The whole input of the turn's latest model call in the session's own thread, as the runner
2024
2140
  * reported it: how large the session had grown. Absent for a turn that made no call.
@@ -2043,7 +2159,13 @@ var TurnRecordSchema = z10.object({
2043
2159
  */
2044
2160
  sessionDropped: z10.boolean().optional(),
2045
2161
  /** The crons whose fire started the turn, which record how it ended. */
2046
- crons: z10.array(UlidSchema).optional()
2162
+ crons: z10.array(UlidSchema).optional(),
2163
+ /**
2164
+ * The failed fires its prompt named: each of its citizen's crons in its conversation whose latest
2165
+ * fire's turn failed with nothing catching it up (`cronFailure`), but those it carries, with that
2166
+ * turn. Completing it catches each up.
2167
+ */
2168
+ cronFailures: z10.array(z10.object({ cron: UlidSchema, turnId: UlidSchema })).optional()
2047
2169
  });
2048
2170
  var RunningTurnSchema = z10.object({
2049
2171
  turnId: UlidSchema,
@@ -2085,6 +2207,8 @@ var TurnHistoryEntrySchema = z10.object({
2085
2207
  /** The project whose channels' conversation the turn was in, for a turn in the society scope. */
2086
2208
  place: NameSchema.optional(),
2087
2209
  trigger: z10.string(),
2210
+ /** What a `heartbeat` woke for; absent on turns logged before the board kept it. */
2211
+ heartbeat: HeartbeatCauseSchema.optional(),
2088
2212
  exitReason: z10.string().nullable(),
2089
2213
  costUsd: z10.number(),
2090
2214
  /** The tokens the turn used; absent for turns logged before turns recorded them. */
@@ -2158,7 +2282,7 @@ var METRICS_WINDOW_MS = {
2158
2282
  "7d": 7 * 24 * 36e5,
2159
2283
  all: null
2160
2284
  };
2161
- var READ_VERBS = ["read_inbox", "search", "get_task"];
2285
+ var READ_VERBS = ["read_inbox", "search", "get_task", "read_image"];
2162
2286
  function isBoardAction(tool) {
2163
2287
  const verb = /^mcp__board__(.+)$/.exec(tool)?.[1];
2164
2288
  return verb !== void 0 && !READ_VERBS.some((read2) => read2 === verb);
@@ -2172,12 +2296,19 @@ var MetricsSchema = z12.object({
2172
2296
  /**
2173
2297
  * Finished turns that changed nothing on the board. Reflections are left out, since they write
2174
2298
  * memory, not the board; turns without a transcript are counted apart, their actions unknown.
2299
+ * Heartbeats are split by what each woke for, and those recorded before the board kept it are a
2300
+ * row without one.
2175
2301
  */
2176
2302
  idle: z12.object({
2177
2303
  turns: CountSchema,
2178
2304
  idle: CountSchema,
2179
2305
  unknown: CountSchema,
2180
- byTrigger: z12.array(z12.object({ trigger: z12.string(), turns: CountSchema, idle: CountSchema })),
2306
+ byTrigger: z12.array(z12.object({
2307
+ trigger: z12.string(),
2308
+ heartbeat: HeartbeatCauseSchema.optional(),
2309
+ turns: CountSchema,
2310
+ idle: CountSchema
2311
+ })),
2181
2312
  byAgent: z12.array(z12.object({
2182
2313
  agent: NameSchema,
2183
2314
  role: NameSchema.nullable(),
@@ -2355,7 +2486,7 @@ var RunnerConfigSchema = z13.object({
2355
2486
  });
2356
2487
 
2357
2488
  // packages/shared/dist/version.js
2358
- var STELLARIS_VERSION = "0.3.27";
2489
+ var STELLARIS_VERSION = "0.4.1";
2359
2490
 
2360
2491
  // packages/shared/dist/mentions.js
2361
2492
  import { parse, postprocess, preprocess } from "micromark";
@@ -3119,7 +3250,8 @@ var TurnPayload = z14.object({
3119
3250
  thread: z14.string().optional(),
3120
3251
  channel: z14.string().optional(),
3121
3252
  place: z14.string().optional(),
3122
- trigger: z14.string()
3253
+ trigger: z14.string(),
3254
+ heartbeat: HeartbeatCauseSchema.optional().catch(void 0)
3123
3255
  });
3124
3256
  var TaskPayload = z14.object({
3125
3257
  taskId: z14.string(),
@@ -3219,13 +3351,12 @@ function computeMetrics(input) {
3219
3351
  const nothing = actions === 0 ? 1 : 0;
3220
3352
  turns2 += 1;
3221
3353
  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
- }
3354
+ const { trigger, heartbeat } = turn.data;
3355
+ const key = heartbeat === void 0 ? trigger : `${trigger}\0${heartbeat}`;
3356
+ const row = turnRows.get(key) ?? { trigger, heartbeat, turns: 0, idle: 0 };
3357
+ turnRows.set(key, { ...row, turns: row.turns + 1, idle: row.idle + nothing });
3358
+ const own = agentRows.get(event.actor) ?? { turns: 0, idle: 0 };
3359
+ agentRows.set(event.actor, { turns: own.turns + 1, idle: own.idle + nothing });
3229
3360
  break;
3230
3361
  }
3231
3362
  case "turn.started": {
@@ -3381,7 +3512,10 @@ function computeMetrics(input) {
3381
3512
  turns: turns2,
3382
3513
  idle,
3383
3514
  unknown,
3384
- byTrigger: [...turnRows].map(([trigger, row]) => ({ trigger, ...row })).toSorted((a, b) => b.idle - a.idle || b.turns - a.turns),
3515
+ byTrigger: [...turnRows.values()].map(({ heartbeat, ...row }) => ({
3516
+ ...row,
3517
+ ...heartbeat === void 0 ? {} : { heartbeat }
3518
+ })).toSorted((a, b) => b.idle - a.idle || b.turns - a.turns),
3385
3519
  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
3520
  },
3387
3521
  sentBack: {
@@ -3441,6 +3575,8 @@ var TurnHistoryPayloadSchema = z15.object({
3441
3575
  channel: NameSchema.optional(),
3442
3576
  place: NameSchema.optional(),
3443
3577
  trigger: z15.string().default("manual"),
3578
+ // One a later board added reads as none, so the turn stays in the history.
3579
+ heartbeat: HeartbeatCauseSchema.optional().catch(void 0),
3444
3580
  exitReason: z15.string().nullable().default(null),
3445
3581
  costUsd: z15.number().default(0),
3446
3582
  usage: UsageSchema.optional(),
@@ -3866,7 +4002,10 @@ var SignInsSchema = z16.object({
3866
4002
  expiresAt: IsoDateTimeSchema
3867
4003
  })).default([])
3868
4004
  });
3869
- var TurnStartPayloadSchema = z16.object({ turnId: UlidSchema });
4005
+ var TurnStartPayloadSchema = z16.object({
4006
+ turnId: UlidSchema,
4007
+ heartbeat: TurnHistoryPayloadSchema.shape.heartbeat
4008
+ });
3870
4009
  var CursorsSchema = z16.object({
3871
4010
  digest: z16.string().nullable(),
3872
4011
  scopes: z16.record(z16.string(), z16.string()).default({}),
@@ -3938,6 +4077,9 @@ function skillFolderOf(proposal) {
3938
4077
  function proposedFrom(origin, where) {
3939
4078
  return origin !== void 0 && origin.scope === where.scope && origin.thread === where.thread && origin.channel === where.channel && origin.place === where.place;
3940
4079
  }
4080
+ function latestLease(a, b) {
4081
+ return a === null ? b : b === null || a >= b ? a : b;
4082
+ }
3941
4083
  function megabytes(bytes) {
3942
4084
  return (bytes / (1024 * 1024)).toFixed(1).replace(/\.0$/, "");
3943
4085
  }
@@ -3963,8 +4105,10 @@ function plain(value) {
3963
4105
  return "";
3964
4106
  return JSON.stringify(value);
3965
4107
  }
3966
- function sentence(summary) {
3967
- return summary.trim().replace(/\.+$/, "");
4108
+ var SENTENCE_END = /[.…?!]["'”’»)\]}*_]*$/u;
4109
+ function asSentence(text) {
4110
+ const trimmed = text.trim();
4111
+ return SENTENCE_END.test(trimmed) ? trimmed : `${trimmed}.`;
3968
4112
  }
3969
4113
  function skillSummary(data, body) {
3970
4114
  const description = data["description"];
@@ -4077,6 +4221,7 @@ var Board = class _Board {
4077
4221
  /** The turns in flight, which the server hosting them supplies; none until it does. */
4078
4222
  turnsInFlight = () => [];
4079
4223
  imageSource = null;
4224
+ imageSink = null;
4080
4225
  newId = monotonicFactory();
4081
4226
  events;
4082
4227
  actionCounts = /* @__PURE__ */ new Map();
@@ -4127,10 +4272,13 @@ var Board = class _Board {
4127
4272
  /**
4128
4273
  * Applies each one-time alignment an existing society has not had yet, and remembers it, so a
4129
4274
  * 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.
4275
+ * this list; once every board has applied it, both are deleted.
4131
4276
  */
4132
4277
  async applyAlignments() {
4133
- const alignments = [];
4278
+ const alignments = [
4279
+ // Whoever may show an image in a post may open one a post shows.
4280
+ { name: "image-reads-granted", apply: () => this.grantBeside("upload_image", "read_image") }
4281
+ ];
4134
4282
  const file = path8.join(this.paths.state(), "alignments.json");
4135
4283
  const { applied } = await exists(file) ? await readJson(file, AlignmentsSchema) : { applied: [] };
4136
4284
  const pending = alignments.filter(({ name }) => !applied.includes(name));
@@ -4142,6 +4290,17 @@ var Board = class _Board {
4142
4290
  }
4143
4291
  await writeJson(file, { applied: [...applied, ...pending.map(({ name }) => name)] });
4144
4292
  }
4293
+ /**
4294
+ * Grants `verb` once to every charter granting `beside`, the work roles' charters in practice;
4295
+ * seed roles have it from their seed.
4296
+ */
4297
+ async grantBeside(beside, verb) {
4298
+ for (const charter of await this.listRoles()) {
4299
+ if (charter.verbs.includes(beside) && !charter.verbs.includes(verb)) {
4300
+ await this.writeRoleUnlocked(USER_NAME, { ...charter, verbs: [...charter.verbs, verb] }, { verbsAdded: [verb] });
4301
+ }
4302
+ }
4303
+ }
4145
4304
  /** Writes every missing seed charter and aligns the existing ones with their seed. */
4146
4305
  async ensureSeedRoles() {
4147
4306
  await ensureDir(this.paths.roles());
@@ -4909,6 +5068,46 @@ ${DASHBOARD_LINE}
4909
5068
  return next;
4910
5069
  });
4911
5070
  }
5071
+ /**
5072
+ * Pauses or resumes a runner, the user's alone: the whole runner, or with `cli` that CLI on it
5073
+ * alone, each apart from the other. While either holds, no turn it covers starts there; their
5074
+ * wakes wait in the scheduler's queue, and a turn under way finishes. Pausing what is paused, or
5075
+ * resuming what is not, changes nothing and logs nothing.
5076
+ */
5077
+ async setRunnerPaused(actor, name, change) {
5078
+ this.assertUser(actor, "only the user may pause or resume a runner");
5079
+ const cli = change.cli === void 0 ? void 0 : CliKindSchema.parse(change.cli);
5080
+ return this.mutex.run(async () => {
5081
+ const file = this.paths.runner(name);
5082
+ if (!await exists(file)) {
5083
+ throw new BoardError("NOT_FOUND", `runner ${name} not found`);
5084
+ }
5085
+ const doc = await readMarkdown(file, RunnerSchema);
5086
+ const { paused, pausedClis, ...rest } = doc.data;
5087
+ if (cli !== void 0 && change.paused && !doc.data.clis.includes(cli)) {
5088
+ throw new BoardError("INVALID_STATE", `runner ${name} has no ${cli} to pause`);
5089
+ }
5090
+ const current = cli === void 0 ? paused : pausedClis?.[cli];
5091
+ if (current !== void 0 === change.paused) {
5092
+ return doc.data;
5093
+ }
5094
+ const pause = change.paused ? { at: this.now().toISOString(), by: actor.name } : void 0;
5095
+ const clis = cli === void 0 ? { ...pausedClis } : { ...pausedClis, [cli]: pause };
5096
+ const kept = Object.entries(clis).filter(([, each]) => each !== void 0);
5097
+ const whole = cli === void 0 ? pause : paused;
5098
+ const next = RunnerSchema.parse({
5099
+ ...rest,
5100
+ ...whole === void 0 ? {} : { paused: whole },
5101
+ ...kept.length === 0 ? {} : { pausedClis: Object.fromEntries(kept) }
5102
+ });
5103
+ await writeMarkdown(file, next, doc.body);
5104
+ await this.events.append(change.paused ? "runner.paused" : "runner.resumed", actor.name, {
5105
+ name,
5106
+ ...cli === void 0 ? {} : { cli }
5107
+ });
5108
+ return next;
5109
+ });
5110
+ }
4912
5111
  /**
4913
5112
  * Logs an operations signal as an `ops.signal` event. No channel carries it: the scheduler wakes
4914
5113
  * its readers from the event, the runner lists it in their prompts, and the interface's log shows
@@ -4943,8 +5142,9 @@ ${DASHBOARD_LINE}
4943
5142
  }
4944
5143
  if (event.type === "turn.started") {
4945
5144
  const start = TurnStartPayloadSchema.safeParse(event.payload);
4946
- if (start.success)
4947
- starts.set(start.data.turnId, event.ts);
5145
+ if (start.success) {
5146
+ starts.set(start.data.turnId, { ts: event.ts, heartbeat: start.data.heartbeat });
5147
+ }
4948
5148
  continue;
4949
5149
  }
4950
5150
  if (event.type !== "turn.completed" && event.type !== "turn.failed") {
@@ -4952,17 +5152,19 @@ ${DASHBOARD_LINE}
4952
5152
  }
4953
5153
  const parsed = TurnHistoryPayloadSchema.safeParse(event.payload);
4954
5154
  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);
5155
+ const { heartbeat: ended, ...turn } = parsed.data;
5156
+ const start = turn.turnId === void 0 ? void 0 : starts.get(turn.turnId);
5157
+ if (turn.turnId !== void 0) {
5158
+ starts.delete(turn.turnId);
4959
5159
  }
5160
+ const heartbeat = ended ?? start?.heartbeat;
4960
5161
  entries.push({
4961
5162
  id: event.id,
4962
5163
  ts: event.ts,
4963
5164
  outcome: event.type === "turn.failed" ? "failed" : "completed",
4964
- ...parsed.data,
4965
- ...startedAt === void 0 ? {} : { startedAt }
5165
+ ...turn,
5166
+ ...heartbeat === void 0 ? {} : { heartbeat },
5167
+ ...start === void 0 ? {} : { startedAt: start.ts }
4966
5168
  });
4967
5169
  }
4968
5170
  }
@@ -4992,14 +5194,31 @@ ${DASHBOARD_LINE}
4992
5194
  return society;
4993
5195
  });
4994
5196
  }
4995
- /** Releases every claim whose lease has expired. Called by the scheduler on its tick. */
5197
+ /**
5198
+ * Releases every claim whose lease has expired, but for a holder's that its own cron in the
5199
+ * task's thread keeps (`keptLease`). Called by the scheduler on its tick.
5200
+ */
4996
5201
  async expireLeases() {
4997
5202
  return this.mutex.run(async () => {
4998
5203
  const expired = [];
4999
5204
  const now = this.now();
5205
+ let crons;
5206
+ let runners2;
5000
5207
  for (const project of await listDirs(this.paths.projects())) {
5208
+ let place;
5001
5209
  for (const task of await this.listTasks(project)) {
5002
- if (task.status === "claimed" && this.leaseExpired(task, now)) {
5210
+ if (task.status !== "claimed") {
5211
+ continue;
5212
+ }
5213
+ crons ??= await this.listCrons();
5214
+ runners2 ??= await this.listRunners();
5215
+ place ??= await this.readProject(project);
5216
+ const kept = latestLease(this.keptLease(task, crons, now), await this.pausedLease(task, runners2, place.runner, now));
5217
+ if (kept !== null) {
5218
+ await this.writeTask(project, { ...task, leaseExpiresAt: kept });
5219
+ continue;
5220
+ }
5221
+ if (this.leaseExpired(task, now)) {
5003
5222
  const released = await this.writeTask(project, {
5004
5223
  ...task,
5005
5224
  status: "open",
@@ -5068,20 +5287,24 @@ ${DASHBOARD_LINE}
5068
5287
  * general channel, one of its other channels, one of its threads, or, in the society scope, a
5069
5288
  * project's channels. The heartbeat asks, so that it wakes a member where its unread
5070
5289
  * 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
5290
+ * scope; a project member reads them at its next turn. Some posts are in the digest but not
5072
5291
  * 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.
5292
+ * whoever it hands work to; the board's own, operations signals and landing announcements, whose
5293
+ * signals that call for action wake their readers directly; and a knowledge topic's notice, which
5294
+ * asks nothing of anyone. In a task's thread a post counts only for whoever the current stage is
5295
+ * for, its holder or, while nobody holds it, a member who may take it, and for the task's creator
5296
+ * when it mentions the user, so the creator can put a wait on the user in; a post that asks the
5297
+ * reader counts wherever it is. A closed thread counts for nothing: its conversation has ended,
5298
+ * and a turn there could only read its posts and be refused a reply, while a wake queued before
5299
+ * it closed, as for a mention, still runs there.
5077
5300
  */
5078
5301
  async unreadByConversation(actor) {
5079
5302
  const { cursors, upto } = await this.digestSnapshot(actor.name);
5080
5303
  const counts = /* @__PURE__ */ new Map();
5081
- const closed = /* @__PURE__ */ new Map();
5304
+ const threadCounts = /* @__PURE__ */ new Map();
5082
5305
  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) {
5306
+ for (const { scope, part, message, news, asked } of await this.digestSince(unscoped, cursors, upto)) {
5307
+ if (news === true || message.step !== void 0 || message.author === SYSTEM_ACTOR.name || message.notice?.kind === "knowledge") {
5085
5308
  continue;
5086
5309
  }
5087
5310
  const posted = parseChannelRef(message.channel).project ?? SOCIETY_SCOPE;
@@ -5090,12 +5313,13 @@ ${DASHBOARD_LINE}
5090
5313
  }
5091
5314
  const thread = threadOfPart(part);
5092
5315
  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);
5316
+ let counted = threadCounts.get(thread);
5317
+ if (counted === void 0) {
5318
+ counted = await this.threadCount(actor, thread);
5319
+ threadCounts.set(thread, counted);
5097
5320
  }
5098
- if (ended) {
5321
+ const forReader = counted === "every" || counted !== "none" && asked === true || counted === "asks-user" && message.mentions.includes(USER_NAME);
5322
+ if (!forReader) {
5099
5323
  continue;
5100
5324
  }
5101
5325
  }
@@ -5248,7 +5472,7 @@ ${DASHBOARD_LINE}
5248
5472
  include = participates;
5249
5473
  }
5250
5474
  if (include && message.author !== actor.name) {
5251
- collected.push({ scope: filedIn, part, message });
5475
+ collected.push({ scope: filedIn, part, message, ...asked ? { asked: true } : {} });
5252
5476
  }
5253
5477
  }
5254
5478
  return collected.toSorted(byId);
@@ -5468,7 +5692,7 @@ ${current.body}`);
5468
5692
  name: args.name,
5469
5693
  reason: args.reason
5470
5694
  });
5471
- await this.appendMessage(SYSTEM_ACTOR.name, "general", `Skill ${args.name} removed from the society by ${actor.name}: ${sentence(withoutMentions(args.reason))}. Every citizen's skills index stops listing it from its next turn.`);
5695
+ await this.appendMessage(SYSTEM_ACTOR.name, "general", `Skill ${args.name} removed from the society by ${actor.name}: ${asSentence(withoutMentions(args.reason))} Every citizen's skills index stops listing it from its next turn.`);
5472
5696
  return removed;
5473
5697
  });
5474
5698
  }
@@ -5525,12 +5749,30 @@ ${current.body}`);
5525
5749
  throw new BoardError("NOT_FOUND", "this board has no runners to read an image from");
5526
5750
  }
5527
5751
  const bytes = await this.imageSource(actor, args.path);
5752
+ return this.keepImage(actor, bytes, args.path, path8.basename(args.path));
5753
+ }
5754
+ /**
5755
+ * Keeps an image the user posts from a composer, as its bytes, under the same checks as an image
5756
+ * a turn uploads. `name`, the file's, describes it in the markdown answered; without one the
5757
+ * description is empty, for the user to write.
5758
+ */
5759
+ async postImage(actor, bytes, name) {
5760
+ this.assertUser(actor, "only the user posts an image's bytes; a turn uploads one with upload_image");
5761
+ const given = name === void 0 ? "" : ImageNameSchema.parse(name);
5762
+ return this.keepImage(actor, bytes, given === "" ? "the image" : given, given);
5763
+ }
5764
+ /**
5765
+ * Keeps `bytes` under a new id when they are a PNG, JPEG, GIF, or WebP of at most
5766
+ * `IMAGE_LIMIT_BYTES`, and answers the markdown that shows them, described by `name`. `what`
5767
+ * names the image in a refusal.
5768
+ */
5769
+ async keepImage(actor, bytes, what, name) {
5528
5770
  const type = imageTypeOf(bytes);
5529
5771
  if (type === null) {
5530
- throw new BoardError("VALIDATION", `${args.path} is not a PNG, JPEG, GIF, or WebP image by its first bytes`);
5772
+ throw new BoardError("VALIDATION", `${what} is not a PNG, JPEG, GIF, or WebP image by its first bytes`);
5531
5773
  }
5532
5774
  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`);
5775
+ throw new BoardError("VALIDATION", `${what} is ${bytes.byteLength} bytes, over the ${IMAGE_LIMIT_BYTES} an image may be`);
5534
5776
  }
5535
5777
  return this.mutex.run(async () => {
5536
5778
  const id = this.newId();
@@ -5540,15 +5782,37 @@ ${current.body}`);
5540
5782
  type,
5541
5783
  size: bytes.byteLength
5542
5784
  });
5543
- const name = path8.basename(args.path).replaceAll(/[[\]\\\r\n]/g, "");
5785
+ const description = imageDescription(name);
5544
5786
  return {
5545
5787
  id,
5546
5788
  type,
5547
5789
  size: bytes.byteLength,
5548
- markdown: `![${name}](${IMAGE_SCHEME}${id})`
5790
+ markdown: `![${description}](${IMAGE_SCHEME}${id})`
5549
5791
  };
5550
5792
  });
5551
5793
  }
5794
+ /**
5795
+ * Opens an image the board keeps for the calling turn, by its id or its `image:<id>` address:
5796
+ * the runner the turn runs on writes the bytes to a file in a folder of the turn's own in the
5797
+ * system temp folder, removed when the turn ends, and the board answers its path, for the
5798
+ * citizen to open with its own file tools, since a post reaches a turn as markdown and an image
5799
+ * in it as its address. Any image the board keeps may be opened: every post, brief, topic, and
5800
+ * dashboard that could show one is in the projection every turn reads.
5801
+ */
5802
+ async openImage(actor, input) {
5803
+ const args = VerbInputs.read_image.parse(input);
5804
+ await this.authorize(actor, "read_image");
5805
+ if (actor.scope === void 0) {
5806
+ throw new BoardError("INVALID_STATE", "read_image writes the image on the machine a turn runs on, so only a turn can call it");
5807
+ }
5808
+ if (this.imageSink === null) {
5809
+ throw new BoardError("NOT_FOUND", "this board has no runners to write an image to");
5810
+ }
5811
+ const id = imageIdOf(args.image_id);
5812
+ const { type, bytes } = await this.readImage(id);
5813
+ const file = await this.imageSink(actor, { id, type, bytes });
5814
+ return { id, type, size: bytes.byteLength, path: file };
5815
+ }
5552
5816
  /** An uploaded image's bytes and kind, which its first bytes tell. */
5553
5817
  async readImage(id) {
5554
5818
  const file = this.paths.image(UlidSchema.parse(id));
@@ -5894,6 +6158,13 @@ ${current.body}`);
5894
6158
  readImagesWith(source) {
5895
6159
  this.imageSource = source;
5896
6160
  }
6161
+ /**
6162
+ * Where `read_image` writes an image to: the runner the calling turn runs on, through the server
6163
+ * that hosts the runners. Without one, as in the admin CLI, nothing can be opened.
6164
+ */
6165
+ openImagesWith(sink) {
6166
+ this.imageSink = sink;
6167
+ }
5897
6168
  /**
5898
6169
  * Refuses to end conversations while another citizen's turn is in one of them: `cut` picks the
5899
6170
  * turns that would lose theirs. The caller's own turn is exempt, since ending a conversation
@@ -6491,7 +6762,7 @@ ${args.summary}`);
6491
6762
  });
6492
6763
  const thread = await this.openThreadUnlocked(actor.name, this.proposalThreadOpening(proposal));
6493
6764
  const rationale = args.rationale.trim();
6494
- await this.appendThreadMessage(actor.name, thread.id, `Proposal ${proposal.id}: ${describeCharter(proposal.kind, proposal.charter)}.${rationale.length === 0 ? "" : `
6765
+ await this.appendThreadMessage(actor.name, thread.id, `Proposal ${proposal.id}: ${asSentence(describeCharter(proposal.kind, proposal.charter))}${rationale.length === 0 ? "" : `
6495
6766
 
6496
6767
  ${rationale}`}`);
6497
6768
  return proposal;
@@ -6729,6 +7000,8 @@ ${rationale}`}`);
6729
7000
  return this.removeCron(actor, VerbInputs.remove_cron.parse(input));
6730
7001
  case "upload_image":
6731
7002
  return this.uploadImage(actor, VerbInputs.upload_image.parse(input));
7003
+ case "read_image":
7004
+ return this.openImage(actor, VerbInputs.read_image.parse(input));
6732
7005
  default:
6733
7006
  throw new BoardError("VALIDATION", `unknown verb ${String(verb)}`);
6734
7007
  }
@@ -6964,6 +7237,7 @@ ${rationale}`}`);
6964
7237
  ...parsed.channel === void 0 ? {} : { channel: parsed.channel },
6965
7238
  ...parsed.place === void 0 ? {} : { place: parsed.place },
6966
7239
  trigger: parsed.trigger.kind,
7240
+ ...parsed.trigger.heartbeat === void 0 ? {} : { heartbeat: parsed.trigger.heartbeat },
6967
7241
  session: parsed.session,
6968
7242
  runner: parsed.runner
6969
7243
  });
@@ -6986,6 +7260,11 @@ ${rationale}`}`);
6986
7260
  for (const id of parsed.crons ?? []) {
6987
7261
  await this.recordCronRun(id, parsed, failed);
6988
7262
  }
7263
+ if (parsed.exitReason === "completed" || parsed.exitReason === "blocked") {
7264
+ for (const shown of parsed.cronFailures ?? []) {
7265
+ await this.catchUpCron(shown, parsed);
7266
+ }
7267
+ }
6989
7268
  if (parsed.exitReason === "stopped") {
6990
7269
  await this.events.append("turn.stopped", parsed.stoppedBy ?? USER_NAME, {
6991
7270
  ...parsed.id === void 0 ? {} : { turnId: parsed.id },
@@ -7003,6 +7282,7 @@ ${rationale}`}`);
7003
7282
  ...parsed.channel === void 0 ? {} : { channel: parsed.channel },
7004
7283
  ...parsed.place === void 0 ? {} : { place: parsed.place },
7005
7284
  trigger: parsed.trigger.kind,
7285
+ ...parsed.trigger.heartbeat === void 0 ? {} : { heartbeat: parsed.trigger.heartbeat },
7006
7286
  session: parsed.session,
7007
7287
  exitReason: parsed.exitReason,
7008
7288
  costUsd: parsed.costUsd,
@@ -7126,6 +7406,27 @@ ${rationale}`}`);
7126
7406
  failures: outcome === "failed" ? cron.failures + 1 : outcome === "completed" ? 0 : cron.failures
7127
7407
  });
7128
7408
  }
7409
+ /**
7410
+ * Marks a cron's failed fire caught up by a completed turn whose prompt named it, while that
7411
+ * failure is still its latest run. Its schedule and its run of failures stay as they are.
7412
+ */
7413
+ async catchUpCron(shown, record) {
7414
+ if (record.id === void 0 || !await exists(this.paths.cron(shown.cron))) {
7415
+ return;
7416
+ }
7417
+ const cron = await this.readCron(shown.cron);
7418
+ const failure = cronFailure(cron);
7419
+ if (failure === null || failure.turnId !== shown.turnId) {
7420
+ return;
7421
+ }
7422
+ await this.writeCron({
7423
+ ...cron,
7424
+ lastRun: {
7425
+ ...failure,
7426
+ caughtUp: { turnId: record.id, at: record.endedAt ?? this.now().toISOString() }
7427
+ }
7428
+ });
7429
+ }
7129
7430
  /**
7130
7431
  * Whether a conversation with a workspace of its own has ended: a thread closed, a task ended
7131
7432
  * (for one from before tasks had threads), a channel archived, or a project archived, which ends
@@ -7165,7 +7466,7 @@ ${rationale}`}`);
7165
7466
  const ts = this.now().toISOString();
7166
7467
  const landed = current.onDone !== "none";
7167
7468
  if (landed) {
7168
- await this.postTaskNote(actor.name, current, input.ok ? `${sentence(input.detail)}.` : `Landing failed: ${sentence(input.detail)}. The task waits at its last stage for its participants to reshape the plan.`, input.ok ? { action: "landed", stage: current.stage, to: null } : { action: "reopened", stage: current.stage, to: current.stage });
7469
+ await this.postTaskNote(actor.name, current, input.ok ? asSentence(input.detail) : `Landing failed: ${asSentence(input.detail)} The task waits at its last stage for its participants to reshape the plan.`, input.ok ? { action: "landed", stage: current.stage, to: null } : { action: "reopened", stage: current.stage, to: current.stage });
7169
7470
  }
7170
7471
  if (input.ok) {
7171
7472
  const task2 = await this.writeEndedTask(actor.name, location.project, {
@@ -7185,7 +7486,7 @@ ${rationale}`}`);
7185
7486
  await this.refreshMember(name);
7186
7487
  }
7187
7488
  if (landed) {
7188
- await this.appendMessage(actor.name, channelRef(location.project, task2.channel), `Task ${task2.id} "${task2.title}" is done: ${sentence(input.detail)}.`);
7489
+ await this.appendMessage(actor.name, channelRef(location.project, task2.channel), `Task ${task2.id} "${task2.title}" is done: ${asSentence(input.detail)}`);
7189
7490
  }
7190
7491
  return task2;
7191
7492
  }
@@ -7896,7 +8197,7 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
7896
8197
  files: taken.files.length,
7897
8198
  ...meta
7898
8199
  });
7899
- await this.appendMessage(SYSTEM_ACTOR.name, "general", `Skill ${skill.name} ${replaced ? "updated in" : "promoted to"} the society: ${sentence(skill.summary)}. Every citizen's skills index now lists it.`);
8200
+ await this.appendMessage(SYSTEM_ACTOR.name, "general", `Skill ${skill.name} ${replaced ? "updated in" : "promoted to"} the society: ${asSentence(skill.summary)} Every citizen's skills index now lists it.`);
7900
8201
  return replaced;
7901
8202
  }
7902
8203
  /**
@@ -8143,7 +8444,7 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
8143
8444
  });
8144
8445
  const purpose = withoutMentions(input.purpose);
8145
8446
  await this.appendMessage(SYSTEM_ACTOR.name, ref, `Channel ${ref} opened: ${purpose}`);
8146
- await this.appendMessage(SYSTEM_ACTOR.name, channelRef(input.project, "general"), `Channel ${ref} opened by ${by}: ${purpose}. Subscribe to it to follow its posts; its tasks and mentions reach whoever they concern either way.`);
8447
+ await this.appendMessage(SYSTEM_ACTOR.name, channelRef(input.project, "general"), `Channel ${ref} opened by ${by}: ${asSentence(purpose)} Subscribe to it to follow its posts; its tasks and mentions reach whoever they concern either way.`);
8147
8448
  return ref;
8148
8449
  }
8149
8450
  /**
@@ -8495,7 +8796,7 @@ ${charter.purpose}
8495
8796
  }
8496
8797
  assertMayGate(actor, what) {
8497
8798
  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`);
8799
+ 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
8800
  }
8500
8801
  }
8501
8802
  /** The user holds anything; everyone else must be a project member the current stage admits. */
@@ -8537,6 +8838,52 @@ ${charter.purpose}
8537
8838
  leaseEnd(now) {
8538
8839
  return new Date(now.getTime() + this.leaseMs).toISOString();
8539
8840
  }
8841
+ /**
8842
+ * The lease a holder's own crons in the task's thread keep its claim to, when later than the one
8843
+ * it has, else null. While one of them is pending, the lease is renewed for a full lease once less
8844
+ * than half is left, so it never lapses between fires that may be hours apart; once every one is
8845
+ * paused, ended, or failed at its latest fire with nothing catching it up (`cronStoppedAt`), it
8846
+ * runs a full lease from when the last of them stopped.
8847
+ */
8848
+ keptLease(task, crons, now) {
8849
+ if (task.leaseExpiresAt === void 0) {
8850
+ return null;
8851
+ }
8852
+ const lease = Date.parse(task.leaseExpiresAt);
8853
+ let pending = false;
8854
+ let stopped = null;
8855
+ for (const cron of crons) {
8856
+ if (cron.agent !== task.claimedBy || !firesInTask(cron, task)) {
8857
+ continue;
8858
+ }
8859
+ const at = cronStoppedAt(cron);
8860
+ if (at === null) {
8861
+ pending = true;
8862
+ } else {
8863
+ stopped = Math.max(stopped ?? 0, Date.parse(at));
8864
+ }
8865
+ }
8866
+ const kept = pending ? lease - now.getTime() < this.leaseMs / 2 ? now.getTime() + this.leaseMs : lease : stopped === null ? lease : stopped + this.leaseMs;
8867
+ return kept > lease ? new Date(kept).toISOString() : null;
8868
+ }
8869
+ /**
8870
+ * The lease a holder keeps while the user's pause, or its CLI's limit, holds its turns in the
8871
+ * task's project (`heldOn`), when later than the one it has, else null. It is renewed for a full
8872
+ * lease once less than half is left, as for a pending cron, so it never lapses while the holder
8873
+ * cannot take a turn there, and after the resume or the reset the holder has at least half a
8874
+ * lease to come back to it.
8875
+ */
8876
+ async pausedLease(task, runners2, runner, now) {
8877
+ if (task.claimedBy === void 0 || task.leaseExpiresAt === void 0) {
8878
+ return null;
8879
+ }
8880
+ const holder = await this.readAgent(task.claimedBy).catch(() => null);
8881
+ if (holder === null || !heldOn(runners2, runner, holder.cli, now)) {
8882
+ return null;
8883
+ }
8884
+ const lease = Date.parse(task.leaseExpiresAt);
8885
+ return lease - now.getTime() < this.leaseMs / 2 ? this.leaseEnd(now) : null;
8886
+ }
8540
8887
  leaseExpired(task, now) {
8541
8888
  return task.leaseExpiresAt !== void 0 && new Date(task.leaseExpiresAt).getTime() <= now.getTime();
8542
8889
  }
@@ -8622,9 +8969,9 @@ ${charter.purpose}
8622
8969
  });
8623
8970
  }
8624
8971
  const verdict = outcome === "approved" ? "Approved" : "Rejected";
8625
- const why = reason === void 0 ? "" : ` Reason: ${reason}.`;
8972
+ const why = reason === void 0 ? "" : ` Reason: ${asSentence(reason)}`;
8626
8973
  const result = provision === void 0 ? "" : ` Provisioned: ${Object.entries(provision).map(([key, value]) => `${key} ${plain(value)}`).join(", ")}.`;
8627
- const post = `${verdict}: ${describeCharter(proposal.kind, proposal.charter)}.${why}${result}`;
8974
+ const post = `${verdict}: ${asSentence(describeCharter(proposal.kind, proposal.charter))}${why}${result}`;
8628
8975
  await this.appendThreadMessage(actor.name, thread.id, actor.name === SYSTEM_ACTOR.name ? withoutMentions(post) : post, { step: { action: outcome } });
8629
8976
  await this.endThreadOf(proposalId, actor.name, outcome);
8630
8977
  return decision;
@@ -8649,11 +8996,7 @@ ${charter.purpose}
8649
8996
  if (task.claimedBy === actor.name || task.createdBy === actor.name || task.stages.some((stage) => stage.agent === actor.name || stage.holders.includes(actor.name))) {
8650
8997
  return true;
8651
8998
  }
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);
8999
+ return await this.mayTakeStage(actor, task);
8657
9000
  }
8658
9001
  if (subject?.kind === "proposal") {
8659
9002
  const proposal = await this.readProposal(subject.id);
@@ -8667,6 +9010,34 @@ ${charter.purpose}
8667
9010
  }
8668
9011
  return false;
8669
9012
  }
9013
+ /** Whether a task's current stage waits for a holder and the member may take it. */
9014
+ async mayTakeStage(actor, task) {
9015
+ if (task.status !== "open" || task.completing) {
9016
+ return false;
9017
+ }
9018
+ const agent = await this.readAgent(actor.name);
9019
+ return agent.memberships.includes(task.project) && mayHoldStage(actor, task);
9020
+ }
9021
+ /**
9022
+ * Which of a thread's posts count toward a reader's heartbeat (see `unreadByConversation`). A
9023
+ * thread, or a task, the board cannot find counts as an open topic's would.
9024
+ */
9025
+ async threadCount(actor, id) {
9026
+ const found = await this.tryFindThread(id);
9027
+ if (found?.thread.state === "closed") {
9028
+ return "none";
9029
+ }
9030
+ const subject = found?.thread.subject;
9031
+ const task = subject?.kind === "task" ? await this.findTask(subject.id).then((location) => location.task, () => null) : null;
9032
+ if (task === null) {
9033
+ return "every";
9034
+ }
9035
+ const stageIsTheirs = task.status === "claimed" ? task.claimedBy === actor.name : await this.mayTakeStage(actor, task);
9036
+ if (stageIsTheirs) {
9037
+ return "every";
9038
+ }
9039
+ return task.createdBy === actor.name ? "asks-user" : "asked";
9040
+ }
8670
9041
  async threadScopes() {
8671
9042
  const projects = await listDirs(this.paths.projects());
8672
9043
  return [this.paths.societyThreads(), ...projects.map((slug) => this.paths.threads(slug))];
@@ -8771,6 +9142,15 @@ ${charter.purpose}
8771
9142
  };
8772
9143
 
8773
9144
  // packages/scheduler/dist/wake.js
9145
+ function heartbeatCause(input) {
9146
+ if (input.digestSize > 0) {
9147
+ return "unread";
9148
+ }
9149
+ if (input.claimsHeld > 0) {
9150
+ return "held";
9151
+ }
9152
+ return input.waitingStages > 0 ? "waiting" : null;
9153
+ }
8774
9154
  function decideWake(input) {
8775
9155
  if (input.paused) {
8776
9156
  return { wake: false, reason: "society paused", priority: 0 };
@@ -8802,7 +9182,7 @@ function decideWake(input) {
8802
9182
  case "heartbeat":
8803
9183
  break;
8804
9184
  }
8805
- if (input.digestSize === 0 && input.claimsHeld === 0 && input.waitingStages === 0) {
9185
+ if (heartbeatCause(input) === null) {
8806
9186
  return { wake: false, reason: "nothing to read, hold, or take", priority: 0 };
8807
9187
  }
8808
9188
  return {
@@ -8897,6 +9277,18 @@ var StateSchema = z17.object({
8897
9277
  lastHeartbeat: z17.record(z17.string(), z17.string()),
8898
9278
  /** Waiting stages already signalled, as `task:stage`, so each is logged once while it waits. */
8899
9279
  waitingSeen: z17.record(z17.string(), z17.string()).default({}),
9280
+ /**
9281
+ * The last pass at which each waiting stage was held by the user's pause of its runner, as
9282
+ * `task:stage`; its wait counts from there, so a stage that waited through a pause is not
9283
+ * signalled the moment the pause ends.
9284
+ */
9285
+ waitingHeld: z17.record(z17.string(), z17.string()).default({}),
9286
+ /**
9287
+ * The last operations pass at which each citizen was held by the user's pause of a runner it
9288
+ * works on; its idle time counts from there, so a citizen held through a pause is not signalled
9289
+ * idle the moment the pause ends.
9290
+ */
9291
+ idleHeld: z17.record(z17.string(), z17.string()).default({}),
8900
9292
  /** Queued dispatches survive a restart; the cursor has already moved past the events that made them. */
8901
9293
  pending: z17.record(z17.string(), PendingSchema).default({}),
8902
9294
  opsReported: z17.record(z17.string(), z17.string()).default({}),
@@ -9835,10 +10227,14 @@ var Scheduler = class {
9835
10227
  * A heartbeat per member and scope on the cadence, which then looks at every conversation of the
9836
10228
  * scope: the unread messages of its home, of each other channel, and of each thread, a stage the
9837
10229
  * 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.
10230
+ * thread. A stage counts for neither while the member has a cron pending in its task's thread
10231
+ * (`cronStoppedAt`: neither paused nor ended, and its latest fire's turn did not fail or was
10232
+ * caught up since), whose fires are its checks there. Each conversation with something wakes on
10233
+ * its own, so a heartbeat never brings one task's business into another's turn, and its trigger
10234
+ * says what it woke for (`heartbeatCause`).
9840
10235
  */
9841
10236
  async checkHeartbeats(now) {
10237
+ let pendingCrons;
9842
10238
  for (const agent of await this.board.listAgents()) {
9843
10239
  if (agent.status !== "active" || agent.cli === null) {
9844
10240
  continue;
@@ -9864,6 +10260,9 @@ var Scheduler = class {
9864
10260
  this.state.lastHeartbeat[cadence] = iso(now);
9865
10261
  unread ??= await this.board.unreadByConversation(actor);
9866
10262
  held ??= await this.board.heldClaims(agent.name);
10263
+ pendingCrons ??= (await this.board.listCrons()).filter((cron) => cronStoppedAt(cron) === null);
10264
+ const crons = pendingCrons;
10265
+ const leftToCron = (task) => crons.some((cron) => cron.agent === agent.name && firesInTask(cron, task));
9867
10266
  const reasons = /* @__PURE__ */ new Map();
9868
10267
  const reasonsIn = (beside) => {
9869
10268
  const key = conversationKey2(agent.name, project, beside);
@@ -9882,13 +10281,13 @@ var Scheduler = class {
9882
10281
  }
9883
10282
  }
9884
10283
  for (const task of held) {
9885
- if (task.project === project) {
10284
+ if (task.project === project && !leftToCron(task)) {
9886
10285
  reasonsIn({ thread: { id: task.id, task: true } }).claimsHeld += 1;
9887
10286
  }
9888
10287
  }
9889
10288
  if (project !== SOCIETY_SCOPE) {
9890
10289
  for (const task of await this.board.openTasks(project)) {
9891
- if (mayHoldStage(agent, task) && (await this.blockersInPlay(task.blockedBy)).length === 0) {
10290
+ if (mayHoldStage(agent, task) && !leftToCron(task) && (await this.blockersInPlay(task.blockedBy)).length === 0) {
9892
10291
  reasonsIn({ thread: { id: task.id, task: true } }).waiting += 1;
9893
10292
  }
9894
10293
  }
@@ -9897,7 +10296,16 @@ var Scheduler = class {
9897
10296
  if (this.pending.has(key) || this.running.has(key)) {
9898
10297
  continue;
9899
10298
  }
9900
- const trigger = TriggerSchema.parse({ kind: "heartbeat", reason: "heartbeat" });
10299
+ const cause = heartbeatCause({
10300
+ digestSize: reason.digestSize,
10301
+ claimsHeld: reason.claimsHeld,
10302
+ waitingStages: reason.waiting
10303
+ });
10304
+ const trigger = TriggerSchema.parse({
10305
+ kind: "heartbeat",
10306
+ reason: "heartbeat",
10307
+ ...cause === null ? {} : { heartbeat: cause }
10308
+ });
9901
10309
  const decision = decideWake({
9902
10310
  trigger,
9903
10311
  digestSize: reason.digestSize,
@@ -10100,7 +10508,9 @@ var Scheduler = class {
10100
10508
  */
10101
10509
  async checkWaitingStages(now) {
10102
10510
  const stillWaiting = /* @__PURE__ */ new Set();
10511
+ const runners2 = await this.board.listRunners();
10103
10512
  for (const project of await this.board.listProjects()) {
10513
+ let members;
10104
10514
  for (const task of await this.board.openTasks(project.slug)) {
10105
10515
  const stage = currentStage(task);
10106
10516
  if (stage === void 0) {
@@ -10111,7 +10521,14 @@ var Scheduler = class {
10111
10521
  if (this.state.waitingSeen[key] !== void 0) {
10112
10522
  continue;
10113
10523
  }
10114
- const age = now - Date.parse(task.stageSince);
10524
+ members ??= await this.board.projectMembers(project.slug);
10525
+ const takers = members.filter((member) => member.cli !== null && mayHoldStage(member, task));
10526
+ if (takers.length > 0 && takers.every((taker) => heldOn(runners2, project.runner, taker.cli, new Date(now)))) {
10527
+ this.state.waitingHeld[key] = iso(now);
10528
+ continue;
10529
+ }
10530
+ const held = this.state.waitingHeld[key];
10531
+ const age = now - Math.max(Date.parse(task.stageSince), held === void 0 ? 0 : Date.parse(held));
10115
10532
  if (age < this.timings.waitingStageMs) {
10116
10533
  continue;
10117
10534
  }
@@ -10132,6 +10549,11 @@ var Scheduler = class {
10132
10549
  });
10133
10550
  }
10134
10551
  }
10552
+ for (const key of Object.keys(this.state.waitingHeld)) {
10553
+ if (!stillWaiting.has(key)) {
10554
+ delete this.state.waitingHeld[key];
10555
+ }
10556
+ }
10135
10557
  for (const key of Object.keys(this.state.waitingSeen)) {
10136
10558
  if (!stillWaiting.has(key)) {
10137
10559
  delete this.state.waitingSeen[key];
@@ -10197,7 +10619,8 @@ var Scheduler = class {
10197
10619
  const roles = (await this.board.listRoles()).filter((role) => role.name !== USER_ROLE);
10198
10620
  const runners2 = await this.board.listRunners();
10199
10621
  const anywhere = new Set(runners2.filter((runner) => runner.status === "connected").flatMap((runner) => runner.capabilities));
10200
- for (const project of await this.board.listProjects()) {
10622
+ const projects = await this.board.listProjects();
10623
+ for (const project of projects) {
10201
10624
  const slug = project.slug;
10202
10625
  const tasks = await this.board.listTasks(slug);
10203
10626
  const home = runners2.find((runner) => runner.name === project.runner);
@@ -10289,7 +10712,13 @@ var Scheduler = class {
10289
10712
  }
10290
10713
  }
10291
10714
  const crons = (await this.board.listCrons()).filter((cron) => cron.ended === void 0);
10292
- for (const agent of await this.board.listAgents()) {
10715
+ const agents = await this.board.listAgents();
10716
+ for (const name of Object.keys(this.state.idleHeld)) {
10717
+ if (!agents.some((agent) => agent.name === name && agent.status === "active")) {
10718
+ delete this.state.idleHeld[name];
10719
+ }
10720
+ }
10721
+ for (const agent of agents) {
10293
10722
  if (agent.status !== "active" || agent.cli === null) {
10294
10723
  continue;
10295
10724
  }
@@ -10301,7 +10730,13 @@ var Scheduler = class {
10301
10730
  last = Math.max(last, Date.parse(ended));
10302
10731
  }
10303
10732
  }
10304
- const idle = now - last;
10733
+ const at = new Date(now);
10734
+ const held = heldOn(runners2, agent.homeRunner, agent.cli, at) || projects.some((project) => agent.memberships.includes(project.slug) && heldOn(runners2, project.runner, agent.cli, at));
10735
+ if (held) {
10736
+ this.state.idleHeld[agent.name] = iso(now);
10737
+ }
10738
+ const heldAt = this.state.idleHeld[agent.name];
10739
+ const idle = now - Math.max(last, heldAt === void 0 ? 0 : Date.parse(heldAt));
10305
10740
  if (idle >= this.timings.idleMemberMs) {
10306
10741
  signals.push({
10307
10742
  kind: "idle_member",
@@ -10654,20 +11089,21 @@ var Scheduler = class {
10654
11089
  import { randomUUID } from "node:crypto";
10655
11090
 
10656
11091
  // packages/turn-host/dist/render.js
11092
+ import { createHash as createHash3 } from "node:crypto";
10657
11093
  var TURN_CONTRACT = [
10658
11094
  "- 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.",
11095
+ "- 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
11096
  "- 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
11097
  "- 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
11098
  "- 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.",
11099
+ "- 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
11100
  "- 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.",
11101
+ "- 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.",
11102
+ "- 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
11103
  "- 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
11104
  '- 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
11105
  "- 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.",
11106
+ "- 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
11107
  "- 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
11108
  "- 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
11109
  "- 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 +11112,9 @@ var PLANNING = [
10676
11112
  "- 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
11113
  "- 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
11114
  "- 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.",
11115
+ "- 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
11116
  "- 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.",
11117
+ "- 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
11118
  "- 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
11119
  ].join("\n");
10684
11120
  var GOVERNANCE = [
@@ -10737,6 +11173,10 @@ function renderInstructions(input) {
10737
11173
  return `${sections.join("\n")}
10738
11174
  `;
10739
11175
  }
11176
+ function instructionsHash(input) {
11177
+ const { onboarding: _first, ...kept } = input;
11178
+ return createHash3("sha256").update(renderInstructions(kept)).digest("hex");
11179
+ }
10740
11180
 
10741
11181
  // packages/turn-host/dist/prompt.js
10742
11182
  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 +11189,7 @@ var REFLECTION_STEPS = [
10749
11189
  ];
10750
11190
  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
11191
  var REFLECTION_REPORT = "- Report memoryUpdated: true in the status object when core.md or a skill changed.";
11192
+ 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
11193
  var MAX_BODY_CHARS = 1500;
10753
11194
  var MAX_PROFILE_CHARS = 200;
10754
11195
  function clip(text) {
@@ -10803,12 +11244,23 @@ function quotaPhrase(cli, quota, now) {
10803
11244
  }
10804
11245
  function runnerLine(runner, projects, now) {
10805
11246
  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}`}`;
11247
+ const state = [
11248
+ runner.status === "connected" ? "connected" : `away${runner.lastSeen === void 0 ? "" : ` since ${runner.lastSeen}`}`,
11249
+ ...runner.paused === void 0 ? [] : [`paused by the user since ${runner.paused.at}`]
11250
+ ].join(", ");
11251
+ const pauses = runner.clis.flatMap((cli) => {
11252
+ const pause = runner.pausedClis?.[cli];
11253
+ const hold = runnerLimitHold(runner, cli, now);
11254
+ return [
11255
+ ...pause === void 0 ? [] : [`${cli} paused by the user since ${pause.at}`],
11256
+ ...hold === null ? [] : [`${cli} held by its limit until ${hold.until}`]
11257
+ ];
11258
+ });
10807
11259
  const quotas = runner.clis.flatMap((cli) => {
10808
11260
  const quota = runner.quotas?.[cli];
10809
11261
  return quota === void 0 ? [] : [quotaPhrase(cli, quota, now)];
10810
11262
  });
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("; ")}`}`;
11263
+ 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
11264
  }
10813
11265
  function profileLine(profile) {
10814
11266
  const line = profile.split("\n").map((entry) => entry.trim()).find((entry) => entry.length > 0 && !entry.startsWith("#"));
@@ -10845,7 +11297,7 @@ function messageLines(messages, threads) {
10845
11297
  function cronLines(cron, timeZone) {
10846
11298
  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
11299
  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()}.`;
11300
+ 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
11301
  return [
10850
11302
  `- "${cron.title}" (cron ${cron.id}), set by ${cron.createdBy} at ${cron.createdAt}, fires ${describeSchedule(cron.schedule, timeZone)}.${previous}${state}`,
10851
11303
  "",
@@ -10899,6 +11351,10 @@ function buildTurnPrompt(input) {
10899
11351
  if (dispatch.trigger.kind === "user_post") {
10900
11352
  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
11353
  }
11354
+ const changed = input.changedInstructions ?? null;
11355
+ if (changed !== null) {
11356
+ lines.push("", "## Your instructions changed", "", INSTRUCTIONS_CHANGED, "", "<instructions>", changed.trimEnd(), "</instructions>");
11357
+ }
10902
11358
  const conversation = input.conversation ?? null;
10903
11359
  lines.push("", "## This conversation", "");
10904
11360
  if (conversation === null && dispatch.channel !== void 0) {
@@ -10947,6 +11403,11 @@ function buildTurnPrompt(input) {
10947
11403
  "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
11404
  ] : [], "The worktree may hold uncommitted changes. Run git status before doing anything else.");
10949
11405
  }
11406
+ const failed = input.failedCrons ?? [];
11407
+ if (failed.length > 0) {
11408
+ const timeZone = input.clock?.timeZone ?? DEFAULT_TIME_ZONE;
11409
+ 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)));
11410
+ }
10950
11411
  if (last?.trigger.kind === "reflection" && last.exitReason === "completed" && last.sessionDropped === true) {
10951
11412
  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
11413
  }
@@ -10966,7 +11427,7 @@ function buildTurnPrompt(input) {
10966
11427
  }
10967
11428
  const machines = input.runners;
10968
11429
  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.", "");
11430
+ 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 runner, or a CLI on it, paused by the user starts none of the turns it would run until the user resumes it, and a CLI held by its limit starts none of its turns there until the limit lifts, at the reset the CLI gave or, with none given, half an hour after the report: they wait, and the stages their citizens hold keep their leases.", "");
10970
11431
  if (machines.runners.length === 0) {
10971
11432
  lines.push("None registered yet.");
10972
11433
  }
@@ -11211,7 +11672,7 @@ var TurnHost = class {
11211
11672
  worktree: PATH_TOKENS.worktree,
11212
11673
  otherChannels: (project?.channels ?? []).map((name) => channelRef(dispatch.project, name)).filter((ref) => !agent.subscriptions.includes(ref))
11213
11674
  } : null;
11214
- const instructions = renderInstructions({
11675
+ const rendered = {
11215
11676
  agentName: agent.name,
11216
11677
  roleCharter: await this.board.readAgentRoleBody(agent.name),
11217
11678
  memoryCore: await this.board.readMemoryCore(agent.name),
@@ -11229,7 +11690,11 @@ var TurnHost = class {
11229
11690
  }))
11230
11691
  ],
11231
11692
  ...onboarding === null ? {} : { onboarding }
11232
- });
11693
+ };
11694
+ const instructions = renderInstructions(rendered);
11695
+ const instructionsNow = instructionsHash(rendered);
11696
+ const instructionsHeld = !newSession && lastTurn?.session === recorded ? lastTurn.sessionInstructionsHash : void 0;
11697
+ const changedInstructions = !newSession && instructionsHeld !== instructionsNow;
11233
11698
  const societyView = charter.wakeTriggers.includes(USER_POST_TRIGGER2) ? {
11234
11699
  projects: (await this.board.listProjects()).filter((each) => each.archived === void 0),
11235
11700
  members: await this.board.listMembers()
@@ -11270,6 +11735,11 @@ var TurnHost = class {
11270
11735
  crons.push(cron);
11271
11736
  }
11272
11737
  }
11738
+ 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));
11739
+ const cronFailures = failedCrons.flatMap((cron) => {
11740
+ const failure = cronFailure(cron);
11741
+ return failure === null ? [] : [{ cron: cron.id, turnId: failure.turnId }];
11742
+ });
11273
11743
  const prompt = buildTurnPrompt({
11274
11744
  dispatch,
11275
11745
  clock: { now: this.now(), timeZone: society.timezone },
@@ -11282,6 +11752,8 @@ var TurnHost = class {
11282
11752
  waitingStages: waiting,
11283
11753
  project,
11284
11754
  lastTurn,
11755
+ failedCrons,
11756
+ ...changedInstructions ? { changedInstructions: instructions } : {},
11285
11757
  onboarding,
11286
11758
  afreshAfterReflection,
11287
11759
  societyView,
@@ -11297,7 +11769,9 @@ var TurnHost = class {
11297
11769
  ...base,
11298
11770
  session: recorded ?? null,
11299
11771
  sessionCostUsd: costSoFarUsd,
11300
- ...usageSoFar === void 0 ? {} : { sessionUsage: usageSoFar }
11772
+ ...usageSoFar === void 0 ? {} : { sessionUsage: usageSoFar },
11773
+ ...instructionsHeld === void 0 ? {} : { sessionInstructionsHash: instructionsHeld },
11774
+ ...cronFailures.length === 0 ? {} : { cronFailures }
11301
11775
  });
11302
11776
  if (record.id === void 0) {
11303
11777
  throw new Error("the board gave the turn no id");
@@ -11317,6 +11791,7 @@ var TurnHost = class {
11317
11791
  costSoFarUsd,
11318
11792
  ...usageSoFar === void 0 ? {} : { usageSoFar },
11319
11793
  instructions,
11794
+ instructionsHash: instructionsNow,
11320
11795
  prompt,
11321
11796
  mcp: { url: this.mcpUrl, token },
11322
11797
  limits: { timeoutMs: this.turnTimeoutMs, maxTurns: this.maxTurns },
@@ -11340,6 +11815,7 @@ var TurnHost = class {
11340
11815
  stopRequestedBy: null,
11341
11816
  held: held.map((each) => each.id),
11342
11817
  costSoFarUsd,
11818
+ instructionsNow,
11343
11819
  resumeRetries,
11344
11820
  configuredModel: agent.model ?? null,
11345
11821
  transcript: [],
@@ -11510,7 +11986,10 @@ var TurnHost = class {
11510
11986
  const resumeRetries = outcome.resumeFailed === "retry" ? turn.resumeRetries + 1 : void 0;
11511
11987
  const dropSession = job.session !== null && (outcome.resumeFailed === "lost" || (resumeRetries ?? 0) >= RESUME_RETRIES);
11512
11988
  const reflected = turn.afreshAfterReflection && turn.record.trigger.kind === "reflection" && outcome.exitReason === "completed";
11513
- const { sessionUsage: _started, ...begun } = turn.record;
11989
+ const { sessionUsage: _started, sessionInstructionsHash: heldBefore, ...begun } = turn.record;
11990
+ const promptDelivered = outcome.exitReason === "completed" || outcome.exitReason === "blocked" || outcome.exitReason === "stopped";
11991
+ const instructionsTaken = outcome.exitReason === "stopped" ? outcome.contextTokens !== void 0 : promptDelivered;
11992
+ const instructionsHeld = job.session === null ? outcome.session === null ? void 0 : turn.instructionsNow : instructionsTaken ? turn.instructionsNow : heldBefore;
11514
11993
  const finished = {
11515
11994
  ...begun,
11516
11995
  session: outcome.session,
@@ -11522,6 +12001,7 @@ var TurnHost = class {
11522
12001
  costUsd: outcome.costUsd,
11523
12002
  sessionCostUsd: outcome.sessionCostUsd ?? turn.costSoFarUsd,
11524
12003
  ...outcome.sessionUsage === void 0 ? {} : { sessionUsage: outcome.sessionUsage },
12004
+ ...instructionsHeld === void 0 ? {} : { sessionInstructionsHash: instructionsHeld },
11525
12005
  ...outcome.contextTokens === void 0 ? {} : { contextTokens: outcome.contextTokens },
11526
12006
  toolCalls: turn.transcript.filter((entry) => entry.event.type === "tool_call").length,
11527
12007
  model: outcome.model ?? turn.configuredModel,
@@ -11556,7 +12036,7 @@ var TurnHost = class {
11556
12036
  } else if (outcome.session !== null && outcome.session !== job.session) {
11557
12037
  await this.board.writeSession(job.agent, job.scope, job.cli, outcome.session, turn.runner, part);
11558
12038
  }
11559
- if (outcome.exitReason === "completed" || outcome.exitReason === "blocked" || outcome.exitReason === "stopped") {
12039
+ if (promptDelivered) {
11560
12040
  await this.board.setDigestCursor(job.agent, job.scope, turn.delivered, part);
11561
12041
  await this.renewLeases(turn.actor, turn.held);
11562
12042
  if (outcome.status?.needsUserDecision === true) {
@@ -11844,6 +12324,9 @@ var TurnHost = class {
11844
12324
 
11845
12325
  // packages/turn-host/dist/hub.js
11846
12326
  import { randomUUID as randomUUID2 } from "node:crypto";
12327
+ function receiptOverdue(seat) {
12328
+ return [...seat.unacked.values()].some((each) => each.overdue);
12329
+ }
11847
12330
  var RunnerProtocolError = class extends Error {
11848
12331
  };
11849
12332
  var RunnerAwayError = class extends Error {
@@ -11888,6 +12371,13 @@ var RunnerHub = class {
11888
12371
  starting = /* @__PURE__ */ new Map();
11889
12372
  /** Moves started since the hub did, so a placement that a move overtook gives its turn back. */
11890
12373
  moveStarts = 0;
12374
+ /** When each runner was last asked to read a CLI's quota for a limit with no reset, by `runner/cli`. */
12375
+ limitReads = /* @__PURE__ */ new Map();
12376
+ /**
12377
+ * The turn let through once a limit with no reset had held its half hour, by `runner/cli`: the
12378
+ * report it tries, which `observedAt` names, and whether the turn has ended.
12379
+ */
12380
+ limitTries = /* @__PURE__ */ new Map();
11891
12381
  constructor(options) {
11892
12382
  this.board = options.board;
11893
12383
  this.host = options.host;
@@ -11924,9 +12414,9 @@ var RunnerHub = class {
11924
12414
  if (hello.protocol !== RUNNER_PROTOCOL) {
11925
12415
  throw new RunnerProtocolError(`runner protocol ${hello.protocol} is not ${RUNNER_PROTOCOL}; upgrade the runner or the server`);
11926
12416
  }
11927
- const seat = this.seats.get(name);
12417
+ let seat = this.seats.get(name);
11928
12418
  if (seat === void 0) {
11929
- this.seats.set(name, {
12419
+ seat = {
11930
12420
  name,
11931
12421
  hello,
11932
12422
  send: null,
@@ -11934,19 +12424,18 @@ var RunnerHub = class {
11934
12424
  warm: /* @__PURE__ */ new Set(),
11935
12425
  disconnectedAt: null,
11936
12426
  grace: null,
11937
- unacked: /* @__PURE__ */ new Map(),
11938
- unconfirmed: false
11939
- });
12427
+ unacked: /* @__PURE__ */ new Map()
12428
+ };
12429
+ this.seats.set(name, seat);
11940
12430
  } else {
11941
12431
  seat.hello = hello;
11942
12432
  seat.warm = /* @__PURE__ */ new Set();
11943
12433
  }
11944
12434
  const still = new Set(hello.turns);
11945
- const unacked = this.seats.get(name)?.unacked ?? /* @__PURE__ */ new Map();
11946
12435
  for (const turnId of this.host.turnsOn(name)) {
11947
12436
  if (still.has(turnId)) {
11948
- this.settle(unacked, turnId);
11949
- } else if (!unacked.has(turnId)) {
12437
+ this.settle(seat, turnId);
12438
+ } else if (!seat.unacked.has(turnId)) {
11950
12439
  await this.host.abortTurn(turnId, `runner ${name} restarted during the turn`);
11951
12440
  }
11952
12441
  }
@@ -11969,7 +12458,6 @@ var RunnerHub = class {
11969
12458
  }
11970
12459
  seat.send = send;
11971
12460
  seat.disconnectedAt = null;
11972
- seat.unconfirmed = false;
11973
12461
  if (seat.grace !== null) {
11974
12462
  clearTimeout(seat.grace);
11975
12463
  seat.grace = null;
@@ -12010,24 +12498,23 @@ var RunnerHub = class {
12010
12498
  }
12011
12499
  await Promise.allSettled(this.detaching);
12012
12500
  }
12501
+ /** A turn's steps, which show its job arrived as its receipt would. */
12013
12502
  async turnEvents(runner, turnId, entries) {
12014
12503
  this.assertOwns(runner, turnId);
12504
+ this.received(runner, turnId);
12015
12505
  await this.host.addEvents(turnId, entries);
12016
12506
  }
12017
12507
  /** A runner's word that a turn's job arrived, which also shows its stream delivers again. */
12018
12508
  received(runner, turnId) {
12019
12509
  const seat = this.seats.get(runner);
12020
- if (seat === void 0 || !seat.unacked.has(turnId)) {
12021
- return;
12022
- }
12023
- this.settle(seat.unacked, turnId);
12024
- if (seat.unconfirmed) {
12025
- seat.unconfirmed = false;
12026
- this.log.info({ runner }, "runner is taking jobs again");
12510
+ if (seat !== void 0) {
12511
+ this.settle(seat, turnId);
12027
12512
  }
12028
12513
  }
12514
+ /** A turn's outcome, which shows its job arrived as its receipt would. */
12029
12515
  async turnOutcome(runner, turnId, outcome) {
12030
12516
  this.assertOwns(runner, turnId);
12517
+ this.received(runner, turnId);
12031
12518
  const ack = await this.host.closeTurn(turnId, outcome);
12032
12519
  return ack ?? { dropWorkspace: false };
12033
12520
  }
@@ -12171,8 +12658,11 @@ var RunnerHub = class {
12171
12658
  return { refused: `${agent.name} has no CLI binding` };
12172
12659
  }
12173
12660
  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);
12661
+ const runners2 = await this.board.listRunners();
12662
+ const now = this.now();
12663
+ const free = (seat) => seat.send !== null && !receiptOverdue(seat) && seat.hello.clis.includes(cli) && (seat.hello.slots === null || seat.inUse < seat.hello.slots) && !heldOn(runners2, seat.name, cli, now) && !this.tryRunning(runners2, seat.name, cli, now);
12175
12664
  let chosen;
12665
+ let waitsOn = agent.homeRunner;
12176
12666
  if (dispatch.project === SOCIETY_SCOPE) {
12177
12667
  chosen = await this.pinnedSeat(agent.name, agent.homeRunner, cli, free);
12178
12668
  } else {
@@ -12201,8 +12691,12 @@ var RunnerHub = class {
12201
12691
  }
12202
12692
  const seat = this.seats.get(home);
12203
12693
  chosen = seat !== void 0 && free(seat) ? seat : void 0;
12694
+ waitsOn = home;
12204
12695
  }
12205
12696
  if (chosen === void 0) {
12697
+ if (waitsOn !== void 0) {
12698
+ this.readAfterLimit(runners2, waitsOn, cli, now);
12699
+ }
12206
12700
  return null;
12207
12701
  }
12208
12702
  if (dispatch.project !== SOCIETY_SCOPE && (this.moving.has(dispatch.project) || this.moveStarts !== starts)) {
@@ -12212,7 +12706,17 @@ var RunnerHub = class {
12212
12706
  if (dispatch.project !== SOCIETY_SCOPE) {
12213
12707
  this.starting.set(dispatch.project, (this.starting.get(dispatch.project) ?? 0) + 1);
12214
12708
  }
12215
- return { runner: chosen.name };
12709
+ const runner = chosen.name;
12710
+ const lapsed = runners2.find((each) => each.name === runner)?.quotas?.[cli];
12711
+ const tried = this.limitTries.get(`${runner}/${cli}`);
12712
+ if (lapsed !== void 0 && limitLapsed(lapsed, now) && tried?.observedAt !== lapsed.observedAt) {
12713
+ this.limitTries.set(`${runner}/${cli}`, {
12714
+ observedAt: lapsed.observedAt,
12715
+ dispatch,
12716
+ ended: false
12717
+ });
12718
+ }
12719
+ return { runner };
12216
12720
  }
12217
12721
  async runTurn(dispatch, assignment) {
12218
12722
  if ("refused" in assignment) {
@@ -12249,10 +12753,15 @@ var RunnerHub = class {
12249
12753
  return await opened.ended;
12250
12754
  } finally {
12251
12755
  opening();
12756
+ for (const tried of this.limitTries.values()) {
12757
+ if (tried.dispatch === dispatch) {
12758
+ tried.ended = true;
12759
+ }
12760
+ }
12252
12761
  if (seat !== void 0) {
12253
12762
  seat.inUse = Math.max(0, seat.inUse - 1);
12254
12763
  if (turnId !== null) {
12255
- this.settle(seat.unacked, turnId);
12764
+ this.settle(seat, turnId);
12256
12765
  }
12257
12766
  }
12258
12767
  }
@@ -12428,11 +12937,7 @@ var RunnerHub = class {
12428
12937
  move.id = await this.board.openMoveRepo();
12429
12938
  const id = move.id;
12430
12939
  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) => {
12940
+ 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
12941
  throw new BoardError("INVALID_STATE", `${from} could not send ${slug}: ${describe(error)}`);
12437
12942
  }));
12438
12943
  if (!exported.ok) {
@@ -12442,8 +12947,13 @@ var RunnerHub = class {
12442
12947
  const imported = ProjectImportedSchema.parse(await this.request(target, (request) => ({
12443
12948
  type: "import",
12444
12949
  request,
12445
- import: { move: id, repo, pullRequests: project.onDone === "ghpr" }
12446
- })).catch((error) => {
12950
+ import: {
12951
+ move: id,
12952
+ repo,
12953
+ pullRequests: project.onDone === "ghpr",
12954
+ bytes: exported.bytes
12955
+ }
12956
+ }), moveTransferMs(exported.bytes) + this.requestTimeoutMs).catch((error) => {
12447
12957
  throw new BoardError("INVALID_STATE", `${to} could not take ${slug}: ${describe(error)}`);
12448
12958
  }));
12449
12959
  move.phase = "recording";
@@ -12570,18 +13080,8 @@ var RunnerHub = class {
12570
13080
  * back as a `BoardError` in the runner's words.
12571
13081
  */
12572
13082
  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));
13083
+ const { turnId, seat } = this.turnSeat(actor, "imageReads", `read ${file} from`, "read images");
13084
+ const image = TurnImageSchema.nullable().parse(await this.request(seat, (request) => ({ type: "image", request, read: { turnId, path: file } }), FILE_TIMEOUT_MS));
12585
13085
  if (image === null) {
12586
13086
  throw new BoardError("INVALID_STATE", `the turn is no longer running on runner ${seat.name}`);
12587
13087
  }
@@ -12590,6 +13090,28 @@ var RunnerHub = class {
12590
13090
  }
12591
13091
  return Buffer.from(image.content, "base64");
12592
13092
  }
13093
+ /**
13094
+ * Hands an image the board keeps to the runner a citizen's turn runs on, for `read_image`, which
13095
+ * writes it to a folder of that turn's own in the system temp folder, removed when the turn
13096
+ * ends, and answers the file's path there, with the same refusals as `readTurnImage`.
13097
+ */
13098
+ async writeTurnImage(actor, image) {
13099
+ const { turnId, seat } = this.turnSeat(actor, "imageWrites", "open an image in", "open images");
13100
+ const file = TurnImageFileSchema.nullable().parse(await this.request(seat, (request) => ({
13101
+ type: "image_write",
13102
+ request,
13103
+ write: {
13104
+ turnId,
13105
+ id: image.id,
13106
+ type: image.type,
13107
+ content: Buffer.from(image.bytes).toString("base64")
13108
+ }
13109
+ }), FILE_TIMEOUT_MS));
13110
+ if (file === null) {
13111
+ throw new BoardError("INVALID_STATE", `the turn is no longer running on runner ${seat.name}`);
13112
+ }
13113
+ return file.path;
13114
+ }
12593
13115
  /** The models a CLI offers, asked of a connected runner that has it; none when no runner has it. */
12594
13116
  /**
12595
13117
  * The connected runner a model list for `cli` comes from: the first of `prefer` that has the CLI,
@@ -12629,6 +13151,40 @@ var RunnerHub = class {
12629
13151
  await this.board.recordQuota(runner, cli, quota);
12630
13152
  return quota;
12631
13153
  }
13154
+ /**
13155
+ * Whether a turn let through to try a limit with no reset, once its hold ran out, is still running
13156
+ * on the runner, so the CLI's other turns there wait for what it reports: a refusal records a newer
13157
+ * limit, which holds them another half hour, and a turn that ends without one lets them go.
13158
+ */
13159
+ tryRunning(runners2, name, cli, now) {
13160
+ const quota = runners2.find((each) => each.name === name)?.quotas?.[cli];
13161
+ const tried = this.limitTries.get(`${name}/${cli}`);
13162
+ return quota !== void 0 && tried !== void 0 && !tried.ended && tried.observedAt === quota.observedAt && limitLapsed(quota, now);
13163
+ }
13164
+ /**
13165
+ * Asks a runner to read a CLI's quota again while a limit it gave no reset for holds a turn
13166
+ * waiting for it there, at most once per `UNKNOWN_LIMIT_HOLD_MS` for each runner and CLI, so a
13167
+ * reset the read names becomes the hold and a clear read lifts it. Not while the user's pause
13168
+ * holds the CLI there too, nor on a runner from before quota reads; a read that fails changes
13169
+ * nothing, and the hold runs out as it would have.
13170
+ */
13171
+ readAfterLimit(runners2, name, cli, now) {
13172
+ const record = runners2.find((each) => each.name === name);
13173
+ const seat = this.seats.get(name);
13174
+ if (record === void 0 || seat === void 0 || seat.send === null || !seat.hello.quotaReads || !seat.hello.clis.includes(cli) || runnerPause(record, cli) !== void 0 || runnerLimitHold(record, cli, now)?.known !== false) {
13175
+ return;
13176
+ }
13177
+ const key = `${name}/${cli}`;
13178
+ const last = this.limitReads.get(key);
13179
+ if (last !== void 0 && now.getTime() - last < UNKNOWN_LIMIT_HOLD_MS) {
13180
+ return;
13181
+ }
13182
+ this.limitReads.set(key, now.getTime());
13183
+ this.log.info({ runner: name, cli }, "reading a quota limited until an unknown time");
13184
+ this.quota(name, cli).catch((error) => {
13185
+ this.log.warn({ runner: name, cli, error: String(error) }, "could not read a limited quota");
13186
+ });
13187
+ }
12632
13188
  /** Calls `listener` whenever a runner registers, connects, or goes away. */
12633
13189
  onRunnerChange(listener) {
12634
13190
  this.runnerListeners.add(listener);
@@ -12642,29 +13198,41 @@ var RunnerHub = class {
12642
13198
  /**
12643
13199
  * Waits for a runner to acknowledge a job. One that does not in time may be behind a stream that
12644
13200
  * looks open but delivers nothing, as when its machine's network stops passing traffic, so no
12645
- * more turns go to it until it acknowledges or connects again; the turns it has carry on, since
12646
- * they report by posts.
13201
+ * more turns go to it until the job is acknowledged, its turn ends, or the runner connects again;
13202
+ * the turns it has carry on, since they report by posts.
12647
13203
  */
12648
13204
  expectAck(seat, job) {
12649
13205
  const previous = seat.unacked.get(job.turnId);
12650
13206
  if (previous !== void 0) {
12651
13207
  clearTimeout(previous.timer);
12652
13208
  }
12653
- const timer = setTimeout(() => {
12654
- if (!seat.unacked.has(job.turnId) || seat.unconfirmed || seat.send === null) {
12655
- return;
12656
- }
12657
- seat.unconfirmed = true;
12658
- this.log.warn({ runner: seat.name, turnId: job.turnId, waitedMs: this.ackTimeoutMs }, "runner has not acknowledged a job; no more turns go to it until it does or reconnects");
12659
- }, this.ackTimeoutMs);
12660
- timer.unref?.();
12661
- seat.unacked.set(job.turnId, { job, timer });
13209
+ const entry = {
13210
+ job,
13211
+ timer: setTimeout(() => {
13212
+ if (seat.unacked.get(job.turnId) !== entry || seat.send === null) {
13213
+ return;
13214
+ }
13215
+ const wasHeld = receiptOverdue(seat);
13216
+ entry.overdue = true;
13217
+ if (!wasHeld) {
13218
+ this.log.warn({ runner: seat.name, turnId: job.turnId, waitedMs: this.ackTimeoutMs }, "runner has not acknowledged a job; no more turns go to it until it does, the turn ends, or it reconnects");
13219
+ }
13220
+ }, this.ackTimeoutMs),
13221
+ overdue: false
13222
+ };
13223
+ entry.timer.unref?.();
13224
+ seat.unacked.set(job.turnId, entry);
12662
13225
  }
12663
- settle(unacked, turnId) {
12664
- const entry = unacked.get(turnId);
12665
- if (entry !== void 0) {
12666
- clearTimeout(entry.timer);
12667
- unacked.delete(turnId);
13226
+ /** A job acknowledged, or its turn over: the hold it put on its runner, if any, goes with it. */
13227
+ settle(seat, turnId) {
13228
+ const entry = seat.unacked.get(turnId);
13229
+ if (entry === void 0) {
13230
+ return;
13231
+ }
13232
+ clearTimeout(entry.timer);
13233
+ seat.unacked.delete(turnId);
13234
+ if (entry.overdue && !receiptOverdue(seat)) {
13235
+ this.log.info({ runner: seat.name }, "runner is taking jobs again");
12668
13236
  }
12669
13237
  }
12670
13238
  async detach(seat) {
@@ -12767,6 +13335,25 @@ var RunnerHub = class {
12767
13335
  repo: projectRepo(record)
12768
13336
  };
12769
13337
  }
13338
+ /**
13339
+ * The caller's turn in flight, found by its conversation, and the connected runner it runs on,
13340
+ * which must say `able` in its hello: an image's way to and from the machine a turn runs on.
13341
+ * `where` and `what` say what the caller asked for in a refusal.
13342
+ */
13343
+ turnSeat(actor, able, where, what) {
13344
+ 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);
13345
+ if (turn === void 0) {
13346
+ throw new BoardError("INVALID_STATE", `${actor.name} has no turn running in this conversation to ${where}`);
13347
+ }
13348
+ const seat = this.seats.get(turn.runner);
13349
+ if (seat === void 0 || seat.send === null) {
13350
+ throw new RunnerAwayError(`runner ${turn.runner}, where the turn runs, is not connected`);
13351
+ }
13352
+ if (!seat.hello[able]) {
13353
+ throw new BoardError("INVALID_STATE", `runner ${seat.name}, where the turn runs, cannot ${what} yet; upgrade it`);
13354
+ }
13355
+ return { turnId: turn.turnId, seat };
13356
+ }
12770
13357
  assertOwns(runner, turnId) {
12771
13358
  const owner = this.host.runnerOf(turnId);
12772
13359
  if (owner !== void 0 && owner !== runner) {
@@ -12899,6 +13486,7 @@ import { bodyLimit } from "hono/body-limit";
12899
13486
  var ENROLL_BODY_LIMIT_BYTES = 16 * 1024;
12900
13487
  var APP_TOKEN_BODY_LIMIT_BYTES = 16 * 1024;
12901
13488
  var API_BODY_LIMIT_BYTES = 1024 * 1024;
13489
+ var IMAGE_BODY_LIMIT_BYTES = IMAGE_LIMIT_BYTES;
12902
13490
  var RUNNER_BODY_LIMIT_BYTES = 8 * 1024 * 1024;
12903
13491
  var RUNNER_ANSWER_LIMIT_BYTES = 16 * 1024 * 1024;
12904
13492
  function limitBody(maxSize) {
@@ -12915,7 +13503,7 @@ function limitBody(maxSize) {
12915
13503
  }
12916
13504
 
12917
13505
  // apps/server/dist/github-auth.js
12918
- import { createHash as createHash3, randomBytes as randomBytes2, timingSafeEqual } from "node:crypto";
13506
+ import { createHash as createHash4, randomBytes as randomBytes2, timingSafeEqual } from "node:crypto";
12919
13507
  import { Hono } from "hono";
12920
13508
  import { z as z18 } from "zod";
12921
13509
  var SIGN_IN_TTL_MS = 30 * 24 * 36e5;
@@ -12968,7 +13556,7 @@ function prune(map, now) {
12968
13556
  }
12969
13557
  }
12970
13558
  function provesChallenge(verifier, challenge) {
12971
- const answer = Buffer.from(createHash3("sha256").update(verifier).digest("base64url"));
13559
+ const answer = Buffer.from(createHash4("sha256").update(verifier).digest("base64url"));
12972
13560
  const expected = Buffer.from(challenge);
12973
13561
  return answer.length === expected.length && timingSafeEqual(answer, expected);
12974
13562
  }
@@ -13203,9 +13791,109 @@ import { Hono as Hono2 } from "hono";
13203
13791
  import { streamSSE } from "hono/streaming";
13204
13792
 
13205
13793
  // apps/server/dist/home-git.js
13794
+ import { once } from "node:events";
13206
13795
  import { execa as execa3 } from "execa";
13796
+
13797
+ // apps/server/dist/serve.js
13798
+ import { serve } from "@hono/node-server";
13799
+ var REQUEST_TIMEOUT_MS = 3e5;
13800
+ var HEADERS_TIMEOUT_MS = 6e4;
13801
+ var deadlines = /* @__PURE__ */ new WeakMap();
13802
+ function serveBoard(options, listening) {
13803
+ const timeoutMs = options.requestTimeoutMs ?? REQUEST_TIMEOUT_MS;
13804
+ return serve({
13805
+ fetch: (request, env) => {
13806
+ const { incoming, outgoing } = env;
13807
+ if (!incoming.complete) {
13808
+ const timer = setTimeout(() => {
13809
+ if (!incoming.complete) {
13810
+ incoming.socket.destroy();
13811
+ }
13812
+ }, timeoutMs);
13813
+ timer.unref();
13814
+ const lift = () => clearTimeout(timer);
13815
+ deadlines.set(request, lift);
13816
+ outgoing.once("close", lift);
13817
+ }
13818
+ return options.fetch(request, env);
13819
+ },
13820
+ port: options.port,
13821
+ hostname: options.hostname,
13822
+ serverOptions: {
13823
+ requestTimeout: 0,
13824
+ headersTimeout: options.headersTimeoutMs ?? HEADERS_TIMEOUT_MS,
13825
+ ...options.connectionsCheckingIntervalMs === void 0 ? {} : { connectionsCheckingInterval: options.connectionsCheckingIntervalMs }
13826
+ }
13827
+ }, listening);
13828
+ }
13829
+ function liftDeadline(request) {
13830
+ deadlines.get(request)?.();
13831
+ deadlines.delete(request);
13832
+ }
13833
+
13834
+ // apps/server/dist/home-git.js
13207
13835
  var SMART_PATHS = /* @__PURE__ */ new Set(["/info/refs", "/git-upload-pack", "/git-receive-pack"]);
13208
13836
  var MAX_BYTES = MOVE_PACK_LIMIT_BYTES;
13837
+ var IDLE_MS = 5 * 6e4;
13838
+ async function feed(body, stdin, limit2, idleMs) {
13839
+ if (body === null) {
13840
+ stdin.end();
13841
+ return "done";
13842
+ }
13843
+ const reader = body.getReader();
13844
+ const waits = new AbortController();
13845
+ let ended = null;
13846
+ const endedAs = () => ended;
13847
+ const end = (why) => {
13848
+ if (ended === null) {
13849
+ ended = why;
13850
+ waits.abort();
13851
+ if (why === "idle" || why === "cut") {
13852
+ reader.cancel().catch(() => void 0);
13853
+ } else {
13854
+ reader.releaseLock();
13855
+ }
13856
+ }
13857
+ return ended;
13858
+ };
13859
+ const timer = setTimeout(() => end("idle"), idleMs);
13860
+ const stopped = () => {
13861
+ end("stopped");
13862
+ };
13863
+ stdin.on("error", stopped);
13864
+ stdin.once("close", stopped);
13865
+ let total = 0;
13866
+ try {
13867
+ for (; ; ) {
13868
+ const next = await reader.read().catch(() => null);
13869
+ const early = endedAs();
13870
+ if (early !== null) {
13871
+ return early;
13872
+ }
13873
+ if (next === null) {
13874
+ return end("cut");
13875
+ }
13876
+ if (next.done) {
13877
+ stdin.end();
13878
+ return "done";
13879
+ }
13880
+ timer.refresh();
13881
+ total += next.value.byteLength;
13882
+ if (total > limit2) {
13883
+ return end("too large");
13884
+ }
13885
+ if (!stdin.write(next.value)) {
13886
+ const drained = await once(stdin, "drain", { signal: waits.signal }).then(() => true, () => false);
13887
+ if (!drained) {
13888
+ return end("stopped");
13889
+ }
13890
+ }
13891
+ }
13892
+ } finally {
13893
+ clearTimeout(timer);
13894
+ stdin.off("close", stopped);
13895
+ }
13896
+ }
13209
13897
  function parseCgi(output) {
13210
13898
  const crlf = output.indexOf("\r\n\r\n");
13211
13899
  const lf = output.indexOf("\n\n");
@@ -13233,6 +13921,7 @@ function parseCgi(output) {
13233
13921
  var HomeGit = class {
13234
13922
  pushes = /* @__PURE__ */ new Map();
13235
13923
  serve(input) {
13924
+ liftDeadline(input.request);
13236
13925
  if (input.rest !== "/git-receive-pack") {
13237
13926
  return serveHomeGit(input);
13238
13927
  }
@@ -13247,10 +13936,13 @@ async function serveHomeGit(input) {
13247
13936
  return new Response("not a smart HTTP path", { status: 404 });
13248
13937
  }
13249
13938
  const { request } = input;
13250
- const body = Buffer.from(await request.arrayBuffer());
13251
- if (body.length > MAX_BYTES) {
13939
+ const limit2 = input.limitBytes ?? MAX_BYTES;
13940
+ const length = request.headers.get("content-length");
13941
+ const declared = length !== null && /^\d+$/.test(length) ? length : null;
13942
+ if (declared !== null && Number(declared) > limit2) {
13252
13943
  return new Response("pack too large for the board to take", { status: 413 });
13253
13944
  }
13945
+ liftDeadline(request);
13254
13946
  const url = new URL(request.url);
13255
13947
  const env = {
13256
13948
  PATH: process.env["PATH"] ?? "/usr/bin:/bin",
@@ -13261,10 +13953,12 @@ async function serveHomeGit(input) {
13261
13953
  PATH_INFO: `/${input.repository}${input.rest}`,
13262
13954
  QUERY_STRING: url.search.replace(/^\?/, ""),
13263
13955
  CONTENT_TYPE: request.headers.get("content-type") ?? "",
13264
- CONTENT_LENGTH: String(body.length),
13265
13956
  REMOTE_USER: input.runner,
13266
13957
  REMOTE_ADDR: "127.0.0.1"
13267
13958
  };
13959
+ if (declared !== null) {
13960
+ env["CONTENT_LENGTH"] = declared;
13961
+ }
13268
13962
  const encoding = request.headers.get("content-encoding");
13269
13963
  if (encoding !== null) {
13270
13964
  env["HTTP_CONTENT_ENCODING"] = encoding;
@@ -13273,10 +13967,9 @@ async function serveHomeGit(input) {
13273
13967
  if (protocol !== null) {
13274
13968
  env["HTTP_GIT_PROTOCOL"] = protocol;
13275
13969
  }
13276
- const result = await execa3("git", ["http-backend"], {
13970
+ const backend = execa3("git", ["http-backend"], {
13277
13971
  env,
13278
13972
  extendEnv: false,
13279
- input: body,
13280
13973
  encoding: "buffer",
13281
13974
  // The blank line that ends the headers ends the whole answer when the body is empty, as for
13282
13975
  // the 4-byte probe git sends before a pack too large for one post; stripped, it reads as none.
@@ -13284,6 +13977,15 @@ async function serveHomeGit(input) {
13284
13977
  reject: false,
13285
13978
  maxBuffer: MAX_BYTES
13286
13979
  });
13980
+ const fed = await feed(request.body, backend.stdin, limit2, input.idleMs ?? IDLE_MS);
13981
+ if (fed !== "done" && fed !== "stopped") {
13982
+ backend.kill();
13983
+ await backend;
13984
+ 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", {
13985
+ status: fed === "idle" ? 408 : 400
13986
+ });
13987
+ }
13988
+ const result = await backend;
13287
13989
  if (result.exitCode !== 0 && result.stdout.length === 0) {
13288
13990
  return new Response(`git http-backend failed: ${Buffer.from(result.stderr).toString("utf8")}`, {
13289
13991
  status: 500
@@ -13566,6 +14268,14 @@ function createApp(deps) {
13566
14268
  };
13567
14269
  const api = new Hono3();
13568
14270
  api.use("*", authenticate);
14271
+ api.post("/images", limitBody(IMAGE_BODY_LIMIT_BYTES), async (c) => {
14272
+ if (c.get("actor").role !== USER_ROLE) {
14273
+ throw new BoardError("FORBIDDEN", "only the user posts an image's bytes");
14274
+ }
14275
+ const name = ImageNameSchema.optional().parse(c.req.query("name"));
14276
+ const bytes = new Uint8Array(await c.req.arrayBuffer());
14277
+ return c.json(await board2.postImage(c.get("actor"), bytes, name));
14278
+ });
13569
14279
  api.on(["POST", "PUT", "PATCH", "DELETE"], "*", limitBody(API_BODY_LIMIT_BYTES));
13570
14280
  api.get("/me", (c) => {
13571
14281
  const signIn = board2.signInOf(bearer2(c) ?? "");
@@ -13618,6 +14328,21 @@ function createApp(deps) {
13618
14328
  }
13619
14329
  return c.json(await quotas.read(runner, cli));
13620
14330
  });
14331
+ for (const [verb, paused] of [
14332
+ ["pause", true],
14333
+ ["resume", false]
14334
+ ]) {
14335
+ for (const path12 of [`/runners/:name/${verb}`, `/runners/:name/${verb}/:cli`]) {
14336
+ api.post(path12, async (c) => {
14337
+ const name = NameSchema.parse(c.req.param("name"));
14338
+ const cli = c.req.param("cli");
14339
+ return c.json(await board2.setRunnerPaused(c.get("actor"), name, {
14340
+ paused,
14341
+ ...cli === void 0 ? {} : { cli: CliKindSchema.parse(cli) }
14342
+ }));
14343
+ });
14344
+ }
14345
+ }
13621
14346
  api.post("/runners", async (c) => {
13622
14347
  const body = RunnerBodySchema.parse(await c.req.json());
13623
14348
  return c.json(await board2.addRunner(c.get("actor"), body.name));
@@ -14194,6 +14919,7 @@ var host = new TurnHost({
14194
14919
  var runners = new RunnerHub({ board, host, version: VERSION, log });
14195
14920
  board.watchTurns(() => host.inFlight());
14196
14921
  board.readImagesWith((actor, file) => runners.readTurnImage(actor, file));
14922
+ board.openImagesWith((actor, image) => runners.writeTurnImage(actor, image));
14197
14923
  var concurrency = config.concurrency ?? Number.POSITIVE_INFINITY;
14198
14924
  var timings = parseTimings(JSON.parse(process.env["STELLARIS_TIMINGS"] ?? "{}"));
14199
14925
  var scheduler = new Scheduler({ board, runner: runners, log, concurrency, timings });
@@ -14227,7 +14953,7 @@ var app = createApp({
14227
14953
  trustedProxies: config.trustedProxies,
14228
14954
  log
14229
14955
  });
14230
- var server = serve({ fetch: app.fetch, port: config.port, hostname: config.host }, (info) => {
14956
+ var server = serveBoard({ fetch: app.fetch, port: config.port, hostname: config.host }, (info) => {
14231
14957
  log.info({
14232
14958
  host: info.address,
14233
14959
  port: info.port,