@kubitnodes/stellaris 0.3.26 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/cli/index.js +752 -184
  2. package/dist/server/index.js +1528 -276
  3. package/package.json +2 -2
  4. package/web/dist/assets/{BackLink-UTiLF1J4.js → BackLink-BLiSCuKv.js} +1 -1
  5. package/web/dist/assets/{Button-Cp4cNRUX.js → Button-D4-iu6aX.js} +1 -1
  6. package/web/dist/assets/ChannelForms-DjviQfVm.js +1 -0
  7. package/web/dist/assets/{ChannelView-CY3oG9hZ.js → ChannelView-B-0H7Ps8.js} +2 -2
  8. package/web/dist/assets/CitizenView-gE5GgCBa.js +2 -0
  9. package/web/dist/assets/{CitizensView-WPQ6Jhl3.js → CitizensView-DXD3qkq1.js} +1 -1
  10. package/web/dist/assets/{CliIcon-C7bwChMP.js → CliIcon-DL4ZwgZv.js} +1 -1
  11. package/web/dist/assets/{CronsView-_pdgwX1I.js → CronsView-CX_G5D3h.js} +1 -1
  12. package/web/dist/assets/{FileTree-jyDUSu7g.js → FileTree-BFa_KVI8.js} +1 -1
  13. package/web/dist/assets/{FileView-DcQvlc8V.js → FileView-Cd9Ijarf.js} +2 -2
  14. package/web/dist/assets/KnowledgeView-D_a4pjGV.js +1 -0
  15. package/web/dist/assets/{Markdown-DT3V-EVe.js → Markdown-DPqvK--4.js} +4 -4
  16. package/web/dist/assets/{MessageItem-agtEugfG.js → MessageItem-WBTzWwyj.js} +1 -1
  17. package/web/dist/assets/MetricsView-YUkYixqT.js +1 -0
  18. package/web/dist/assets/NeedsYouView-CE_B9bZQ.js +1 -0
  19. package/web/dist/assets/Overview-BGUnf6K1.js +1 -0
  20. package/web/dist/assets/{Pane-BaM0TdE6.js → Pane-C9TbEME4.js} +1 -1
  21. package/web/dist/assets/{Picker-C2ig-0dT.js → Picker-BK48MjLY.js} +1 -1
  22. package/web/dist/assets/ProjectView-sL3aGAC6.js +1 -0
  23. package/web/dist/assets/ProposalView-BhZR4MIR.js +1 -0
  24. package/web/dist/assets/{ProposalsView-p4KjwV9p.js → ProposalsView-DTGx8NDn.js} +1 -1
  25. package/web/dist/assets/{Quota-CfFGIql9.js → Quota-BkF7pa0u.js} +1 -1
  26. package/web/dist/assets/RunnersView-Bl62pXNw.js +1 -0
  27. package/web/dist/assets/{SkillFileList-CgrCnuaC.js → SkillFileList-BUpKUkaG.js} +1 -1
  28. package/web/dist/assets/{SkillView-Di5Z9h2L.js → SkillView-BQJApSh7.js} +1 -1
  29. package/web/dist/assets/{SocietyView-BDiVxUC5.js → SocietyView-JSdVE50m.js} +1 -1
  30. package/web/dist/assets/{StickyList-BaV_KZ8x.js → StickyList-DafbJutb.js} +1 -1
  31. package/web/dist/assets/{SubjectThread-DD_ZCCnr.js → SubjectThread-Jyl5YCFL.js} +1 -1
  32. package/web/dist/assets/{TaskView-CKcs-MOC.js → TaskView-Jke2pVGT.js} +2 -2
  33. package/web/dist/assets/{TasksView-oZMjncOU.js → TasksView-zJTIaqEA.js} +1 -1
  34. package/web/dist/assets/{ThreadForms-CG5Y926b.js → ThreadForms-DwUOktyP.js} +1 -1
  35. package/web/dist/assets/{ThreadItem-D-n2arFA.js → ThreadItem-Blx8gSvO.js} +1 -1
  36. package/web/dist/assets/{ThreadView-CT9vd0P4.js → ThreadView-CdQpBb7M.js} +1 -1
  37. package/web/dist/assets/citizen-BOn5BBRL.js +1 -0
  38. package/web/dist/assets/compose-D-X3BQgY.js +1 -0
  39. package/web/dist/assets/format-Dhd2NUB3.js +55 -0
  40. package/web/dist/assets/{governance-C6ez5MLh.js → governance-CWzi7hzX.js} +1 -1
  41. package/web/dist/assets/index-CA_njyMe.css +2 -0
  42. package/web/dist/assets/index-iplooQqq.js +24 -0
  43. package/web/dist/assets/{mentions-BEL5U6ra.js → mentions-S-r32HKM.js} +1 -1
  44. package/web/dist/assets/{tasks-CAI8Eci7.js → tasks-D_Fy7dtN.js} +1 -1
  45. package/web/dist/assets/useMatch-DZXCHHaG.js +1 -0
  46. package/web/dist/index.html +9 -9
  47. package/web/dist/assets/ChannelForms-DD2B0tey.js +0 -1
  48. package/web/dist/assets/CitizenView-BNK5ro-6.js +0 -2
  49. package/web/dist/assets/KnowledgeView-DDl0HQPG.js +0 -1
  50. package/web/dist/assets/MetricsView-DiXyYeWP.js +0 -1
  51. package/web/dist/assets/NeedsYouView-CKmuYan_.js +0 -1
  52. package/web/dist/assets/Overview-CKcTqsMM.js +0 -1
  53. package/web/dist/assets/ProjectView-T2OHTh5j.js +0 -1
  54. package/web/dist/assets/ProposalView-B5yjTAcn.js +0 -1
  55. package/web/dist/assets/RunnersView-DdvwoOur.js +0 -1
  56. package/web/dist/assets/citizen-BiHfph2A.js +0 -1
  57. package/web/dist/assets/compose-DY1WL8zB.js +0 -1
  58. package/web/dist/assets/format-CENiwkJC.js +0 -55
  59. package/web/dist/assets/index-CPXALUV6.js +0 -23
  60. package/web/dist/assets/index-DIa8Dlav.css +0 -2
  61. package/web/dist/assets/useMatch-CINzyDWa.js +0 -1
@@ -2,12 +2,11 @@
2
2
 
3
3
  // apps/server/dist/index.js
4
4
  import { access as access2 } from "node:fs/promises";
5
- import path10 from "node:path";
6
- import { serve } from "@hono/node-server";
5
+ import path11 from "node:path";
7
6
 
8
7
  // packages/board-core/dist/board.js
9
- import { chmod as chmod2, cp, mkdir as mkdir4, readdir as readdir4, readFile as readFile4, rename as rename3, rm as rm2, stat as stat3, writeFile as writeFile4 } from "node:fs/promises";
10
- import path7 from "node:path";
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";
9
+ import path8 from "node:path";
11
10
  import { decodeTime, monotonicFactory } from "ulid";
12
11
  import { z as z16 } from "zod";
13
12
 
@@ -65,7 +64,8 @@ var VERB_NAMES = [
65
64
  "update_cron",
66
65
  "remove_cron",
67
66
  "remove_skill",
68
- "upload_image"
67
+ "upload_image",
68
+ "read_image"
69
69
  ];
70
70
  var VerbNameSchema = z2.enum(VERB_NAMES);
71
71
  var CharterTriggerSchema = z2.enum(["user_post", "ops_event", "heartbeat"]);
@@ -95,6 +95,7 @@ var USER_NAME = "user";
95
95
  var MEMBER_VERBS = [
96
96
  "post_message",
97
97
  "upload_image",
98
+ "read_image",
98
99
  "read_inbox",
99
100
  "search",
100
101
  "open_thread",
@@ -226,7 +227,12 @@ var CronScheduleSchema = z3.union([
226
227
  var CronRunSchema = z3.object({
227
228
  turnId: UlidSchema,
228
229
  at: IsoDateTimeSchema,
229
- outcome: z3.enum(["completed", "failed", "stopped"])
230
+ outcome: z3.enum(["completed", "failed", "stopped"]),
231
+ /**
232
+ * For a failed one, the first turn of the cron's citizen in its conversation that was shown the
233
+ * failure and completed, and when it ended.
234
+ */
235
+ caughtUp: z3.object({ turnId: UlidSchema, at: IsoDateTimeSchema }).optional()
230
236
  });
231
237
  var CronFrontmatterSchema = z3.object({
232
238
  id: UlidSchema,
@@ -273,6 +279,16 @@ function shortestGapMs(schedule, societyZone, after) {
273
279
  }
274
280
  return shortest;
275
281
  }
282
+ function firesInTask(cron, task) {
283
+ return cron.scope === task.project && cron.thread === task.id;
284
+ }
285
+ function cronFailure(cron) {
286
+ return cron.lastRun?.outcome === "failed" && cron.lastRun.caughtUp === void 0 ? cron.lastRun : null;
287
+ }
288
+ function cronStoppedAt(cron) {
289
+ const times = [cron.paused?.at, cron.ended?.at, cronFailure(cron)?.at].filter((at) => at !== void 0);
290
+ return times.reduce((first, at) => first === null || Date.parse(at) < Date.parse(first) ? at : first, null);
291
+ }
276
292
  function nextFire(cron, societyZone, floorMs = CRON_FLOOR_MS) {
277
293
  if (cron.ended !== void 0 || cron.paused !== void 0) {
278
294
  return null;
@@ -704,6 +720,7 @@ var MemberSchema = z5.object({
704
720
  });
705
721
  var RunnerOsSchema = z5.enum(["linux", "windows", "darwin"]);
706
722
  var GitIdentityPartSchema = z5.enum(["name", "email"]);
723
+ var RunnerPauseSchema = z5.object({ at: IsoDateTimeSchema, by: NameSchema });
707
724
  var RunnerSchema = z5.object({
708
725
  name: NameSchema,
709
726
  os: RunnerOsSchema,
@@ -722,8 +739,25 @@ var RunnerSchema = z5.object({
722
739
  * Each CLI's plan quota as its login on this runner last reported it, from a turn's stream or a
723
740
  * read; absent until one has.
724
741
  */
725
- quotas: z5.partialRecord(CliKindSchema, CliQuotaSchema).optional()
742
+ quotas: z5.partialRecord(CliKindSchema, CliQuotaSchema).optional(),
743
+ /** The user's pause of the whole runner: no turn starts on it until the user resumes it. */
744
+ paused: RunnerPauseSchema.optional(),
745
+ /**
746
+ * The user's pauses of single CLIs on the runner, each apart from the runner's own: no turn of
747
+ * that CLI starts on it until the user resumes it.
748
+ */
749
+ pausedClis: z5.partialRecord(CliKindSchema, RunnerPauseSchema).optional()
726
750
  });
751
+ function runnerPause(runner, cli) {
752
+ return runner.paused ?? runner.pausedClis?.[cli];
753
+ }
754
+ function heldOn(runners2, runner, cli) {
755
+ if (runner === void 0 || cli === null) {
756
+ return false;
757
+ }
758
+ const record = runners2.find((each) => each.name === runner);
759
+ return record !== void 0 && runnerPause(record, cli) !== void 0;
760
+ }
727
761
  var ProposalKindSchema = z5.enum([
728
762
  "role",
729
763
  "member",
@@ -1073,7 +1107,21 @@ function imageTypeOf(bytes) {
1073
1107
  return null;
1074
1108
  }
1075
1109
  var ImagePathSchema = z6.string().min(1).max(4096).refine((value) => !value.includes("\0"), { message: "a path with no NUL character" });
1110
+ var ImageNameSchema = z6.string().max(1024);
1111
+ function imageDescription(name) {
1112
+ return name.replaceAll(/[[\]\\\r\n`]/g, "");
1113
+ }
1076
1114
  var IMAGE_SCHEME = "image:";
1115
+ var ImageRefSchema = z6.string().regex(/^(?:image:)?[0-9A-HJKMNP-TV-Z]{26}$/, "expected an image's id or image:<id>");
1116
+ function imageIdOf(ref) {
1117
+ return ref.startsWith(IMAGE_SCHEME) ? ref.slice(IMAGE_SCHEME.length) : ref;
1118
+ }
1119
+ var ImageFileSchema = z6.object({
1120
+ id: UlidSchema,
1121
+ type: ImageTypeSchema,
1122
+ size: z6.number().int().positive(),
1123
+ path: ImagePathSchema
1124
+ });
1077
1125
  var UploadedImageSchema = z6.object({
1078
1126
  id: UlidSchema,
1079
1127
  type: ImageTypeSchema,
@@ -1238,6 +1286,10 @@ var VerbInputs = {
1238
1286
  upload_image: z7.object({
1239
1287
  /** The image file on the machine your turn runs on: absolute, or relative to your working directory. */
1240
1288
  path: ImagePathSchema
1289
+ }),
1290
+ read_image: z7.object({
1291
+ /** The image's id, or its `image:<id>` address as a post shows it. */
1292
+ image_id: ImageRefSchema
1241
1293
  })
1242
1294
  };
1243
1295
  var VERB_DESCRIPTIONS = {
@@ -1249,7 +1301,7 @@ var VERB_DESCRIPTIONS = {
1249
1301
  create_task: "Create a task in a project with its plan: stages of {name, role or agent, gate}. Without stages it gets one stage, work, that anyone in the project may take. File it in a channel of the project, such as a release's, or general by default; its thread opens there with it, under the task's id, for everything said about the work.",
1250
1302
  claim_task: "Hold the task's current stage. Claims are leases renewed by every turn that touches the task.",
1251
1303
  release_task: "Let go of the stage you hold so someone else can take it.",
1252
- update_task: "Move a task back to an earlier stage (the current stage's holder, the user, the steward, or the concierge; anyone else asks the concierge in the task's thread), abandon it, set the tasks it is blocked by (blocked_by: its stage wakes nobody until they have all ended), or link its pull request (pull_request: url, with the merge commit's merge_subject and merge_body), which a ghpr task needs before its last stage completes. A note is posted to the task's thread.",
1304
+ update_task: "Move a task back to an earlier stage (the current stage's holder, the user, the steward, or the concierge; anyone else asks the concierge in the task's thread, with a mention), abandon it, set the tasks it is blocked by (blocked_by: its stage wakes nobody until they have all ended), or link its pull request (pull_request: url, with the merge commit's merge_subject and merge_body), which a ghpr task needs before its last stage completes. A note is posted to the task's thread.",
1253
1305
  get_task: "Read a task with its plan, its brief, and the messages of its thread.",
1254
1306
  subscribe: "Subscribe to a channel. Subscriptions feed your digest; they never wake you.",
1255
1307
  unsubscribe: "Unsubscribe from a channel.",
@@ -1272,7 +1324,8 @@ var VERB_DESCRIPTIONS = {
1272
1324
  update_cron: "Pause or resume a cron, or change its title, note, or schedule; resuming or rescheduling starts it afresh from now. Yours, or anyone's for the user, the steward, and the concierge.",
1273
1325
  remove_cron: "End a cron for good, with a reason. Yours, or anyone's for the user, the steward, and the concierge. A cron also ends with its conversation, and a one-time cron once it fires.",
1274
1326
  remove_skill: "Remove a skill from the society's skills, with a reason, when it is wrong, superseded, or no longer used: no skills index lists it from the next turn, and a notice in general says so with the reason. Its proposer's own copy stays in its home. Steward and user only; the board keeps its text aside for the user to restore.",
1275
- upload_image: "Upload an image file, such as a screenshot or a chart, from the machine your turn runs on: a PNG, JPEG, GIF, or WebP of at most 8 MB, in your worktree, your home, or the system temp folder. Returns its id and the markdown that shows it in a post, `![what it shows](image:<id>)`; write what it shows as the alt text."
1327
+ upload_image: "Upload an image file, such as a screenshot or a chart, from the machine your turn runs on: a PNG, JPEG, GIF, or WebP of at most 8 MB, in your worktree, your home, or the system temp folder. Returns its id and the markdown that shows it in a post, `![what it shows](image:<id>)`; write what it shows as the alt text.",
1328
+ read_image: "Open an image the board keeps, as a post shows it, `![what it shows](image:<id>)`, by its id: the board writes it to a file of this turn's own on the machine it runs on, removed when the turn ends, and returns its path, type, and size. Open the path with your file tools to see the image."
1276
1329
  };
1277
1330
 
1278
1331
  // packages/shared/dist/events.js
@@ -1283,6 +1336,7 @@ var BOARD_EVENT_TYPES = [
1283
1336
  "project.configured",
1284
1337
  "project.archived",
1285
1338
  "project.placed",
1339
+ "project.moved",
1286
1340
  "agent.added",
1287
1341
  "agent.configured",
1288
1342
  "agent.placed",
@@ -1322,6 +1376,8 @@ var BOARD_EVENT_TYPES = [
1322
1376
  "user.token_rotated",
1323
1377
  "runner.changed",
1324
1378
  "runner.quota",
1379
+ "runner.paused",
1380
+ "runner.resumed",
1325
1381
  "ops.signal",
1326
1382
  "paused.changed",
1327
1383
  "wake.requested",
@@ -1471,6 +1527,16 @@ var RunnerHelloSchema = z9.object({
1471
1527
  quotaReads: z9.boolean().default(false),
1472
1528
  /** Whether the runner reads an image a turn running there uploads; one from before never does. */
1473
1529
  imageReads: z9.boolean().default(false),
1530
+ /**
1531
+ * Whether the runner writes an image the board sends for a turn running there to open, for
1532
+ * `read_image`; one from before never does.
1533
+ */
1534
+ imageWrites: z9.boolean().default(false),
1535
+ /**
1536
+ * Whether the runner carries a project's repository out to another runner and in from one, and
1537
+ * removes its copy once the project has moved; one from before never does.
1538
+ */
1539
+ projectMoves: z9.boolean().default(false),
1474
1540
  capabilities: z9.array(z9.string()).default([]),
1475
1541
  /**
1476
1542
  * What the runner's account lacks of the git identity citizens' own commits carry, checked
@@ -1580,8 +1646,19 @@ var TurnJobSchema = z9.object({
1580
1646
  session: z9.string().min(1).nullable(),
1581
1647
  /** The running total the session reported after its last turn, which a resumed session starts from. */
1582
1648
  costSoFarUsd: z9.number().nonnegative(),
1649
+ /**
1650
+ * The session's running usage after its last turn, which a resumed session's own usage is
1651
+ * measured from. Absent when the board holds none for the session, and from a server from before.
1652
+ */
1653
+ usageSoFar: UsageSchema.optional(),
1583
1654
  /** The rendered charter, norms, memory core, and skills index, with `PATH_TOKENS` for places. */
1584
1655
  instructions: z9.string(),
1656
+ /**
1657
+ * The hash of `instructions` without the first turn's section (`instructionsHash` in
1658
+ * `turn-host`): a warm process started on other instructions starts afresh, so a compaction in
1659
+ * it puts back the current ones. Absent from a server from before, when the text is compared.
1660
+ */
1661
+ instructionsHash: z9.string().optional(),
1585
1662
  prompt: z9.string(),
1586
1663
  mcp: z9.object({ url: z9.string().min(1), token: z9.string().min(1) }),
1587
1664
  limits: z9.object({
@@ -1608,6 +1685,17 @@ var TurnOutcomeSchema = z9.object({
1608
1685
  costUsd: z9.number().nonnegative(),
1609
1686
  /** The session's running total after this turn, for CLIs that report one. */
1610
1687
  sessionCostUsd: z9.number().nonnegative().optional(),
1688
+ /**
1689
+ * The session's running usage after this turn, for CLIs that report one, else the job's
1690
+ * `usageSoFar`. A runner from before never sends it.
1691
+ */
1692
+ sessionUsage: UsageSchema.optional(),
1693
+ /**
1694
+ * The whole input of the turn's latest model call in the session's own thread, cached or not:
1695
+ * how large the session had grown. Sub-agents' calls are left out. A turn that made no call,
1696
+ * and a runner from before, never send it.
1697
+ */
1698
+ contextTokens: z9.number().int().nonnegative().optional(),
1611
1699
  /**
1612
1700
  * The session the CLI used, which differs from the job's for CLIs that assign their own ids. A
1613
1701
  * turn whose CLI never started, because it failed or was stopped first, reports the job's own,
@@ -1755,6 +1843,13 @@ var TurnImageSchema = z9.discriminatedUnion("kind", [
1755
1843
  }),
1756
1844
  z9.object({ kind: z9.literal("refused"), why: ImageRefusalSchema, detail: z9.string() })
1757
1845
  ]);
1846
+ var TurnImageWriteSchema = z9.object({
1847
+ turnId: UlidSchema,
1848
+ id: UlidSchema,
1849
+ type: ImageTypeSchema,
1850
+ content: z9.string()
1851
+ });
1852
+ var TurnImageFileSchema = z9.object({ path: ImagePathSchema });
1758
1853
  var WorkspaceConversationSchema = z9.discriminatedUnion("kind", [
1759
1854
  z9.object({ kind: z9.literal("thread"), id: UlidSchema }),
1760
1855
  z9.object({ kind: z9.literal("channel"), scope: NameSchema, name: NameSchema }),
@@ -1776,6 +1871,49 @@ var HeldWorkspaceSchema = z9.object({
1776
1871
  });
1777
1872
  var HeldWorkspacesSchema = z9.object({ workspaces: z9.array(HeldWorkspaceSchema) });
1778
1873
  var SweepResultSchema = z9.object({ kept: z9.array(HeldWorkspaceSchema) });
1874
+ var MOVE_PACK_LIMIT_BYTES = 256 * 1024 * 1024;
1875
+ var MOVE_FLOOR_BYTES_PER_SECOND = 16 * 1024;
1876
+ function moveTransferMs(bytes) {
1877
+ return 10 * 6e4 + Math.ceil(bytes / MOVE_FLOOR_BYTES_PER_SECOND) * 1e3;
1878
+ }
1879
+ var RepoRefsSchema = z9.record(z9.string().regex(/^refs\/(heads|tags)\/\S+$/), z9.string().regex(/^[0-9a-f]{40}([0-9a-f]{24})?$/));
1880
+ var ProjectExportSchema = z9.object({ move: UlidSchema, repo: ProjectRepoSchema });
1881
+ var HeldWorkSchema = z9.object({
1882
+ agent: NameSchema,
1883
+ conversation: WorkspaceConversationSchema.nullable(),
1884
+ uncommitted: z9.number().int().nonnegative(),
1885
+ onNoBranch: z9.number().int().nonnegative()
1886
+ });
1887
+ var ProjectExportedSchema = z9.discriminatedUnion("ok", [
1888
+ z9.object({
1889
+ ok: z9.literal(true),
1890
+ refs: RepoRefsSchema,
1891
+ bytes: z9.number().int().nonnegative()
1892
+ }),
1893
+ z9.object({
1894
+ ok: z9.literal(false),
1895
+ held: z9.array(HeldWorkSchema),
1896
+ bytes: z9.number().int().nonnegative()
1897
+ })
1898
+ ]);
1899
+ var ProjectImportSchema = z9.object({
1900
+ move: UlidSchema,
1901
+ repo: ProjectRepoSchema,
1902
+ pullRequests: z9.boolean(),
1903
+ /** The packs' size the old runner measured, which the clone's clock is sized from; the cap if unsaid. */
1904
+ bytes: z9.number().int().nonnegative().optional()
1905
+ });
1906
+ var ProjectImportedSchema = z9.object({ refs: RepoRefsSchema });
1907
+ var ProjectReleaseSchema = z9.object({ slug: NameSchema });
1908
+ var ProjectReleasedSchema = z9.object({
1909
+ worktrees: z9.number().int().nonnegative(),
1910
+ clone: z9.boolean()
1911
+ });
1912
+ var ProjectMovedSchema = z9.object({
1913
+ project: ProjectSchema,
1914
+ carried: z9.number().int().nonnegative().nullable(),
1915
+ released: z9.object({ ok: z9.boolean(), detail: z9.string() }).nullable()
1916
+ });
1779
1917
  var TurnSteerSchema = z9.object({ id: z9.uuid(), text: z9.string().min(1) });
1780
1918
  var RunnerMessageSchema = z9.discriminatedUnion("type", [
1781
1919
  z9.object({ type: z9.literal("turn"), job: TurnJobSchema }),
@@ -1803,6 +1941,15 @@ var RunnerMessageSchema = z9.discriminatedUnion("type", [
1803
1941
  * whose hello says `imageReads`.
1804
1942
  */
1805
1943
  z9.object({ type: z9.literal("image"), request: z9.string().min(1), read: TurnImageReadSchema }),
1944
+ /**
1945
+ * Answered with a `TurnImageFile`, or null when the turn is not running there. Sent only to
1946
+ * runners whose hello says `imageWrites`.
1947
+ */
1948
+ z9.object({
1949
+ type: z9.literal("image_write"),
1950
+ request: z9.string().min(1),
1951
+ write: TurnImageWriteSchema
1952
+ }),
1806
1953
  /** Answered with whether the CLI accepted it; that it took it comes as a `steered` event. */
1807
1954
  z9.object({
1808
1955
  type: z9.literal("steer"),
@@ -1812,6 +1959,19 @@ var RunnerMessageSchema = z9.discriminatedUnion("type", [
1812
1959
  }),
1813
1960
  /** Answered with whether the turn was there to stop; it ends `stopped`. */
1814
1961
  z9.object({ type: z9.literal("stop"), request: z9.string().min(1), turnId: UlidSchema }),
1962
+ /**
1963
+ * Answered with a `ProjectExported`. Sent only to runners whose hello says `projectMoves`, as are
1964
+ * the import and the release.
1965
+ */
1966
+ z9.object({ type: z9.literal("export"), request: z9.string().min(1), export: ProjectExportSchema }),
1967
+ /** Answered with a `ProjectImported`. */
1968
+ z9.object({ type: z9.literal("import"), request: z9.string().min(1), import: ProjectImportSchema }),
1969
+ /** Answered with a `ProjectReleased`. */
1970
+ z9.object({
1971
+ type: z9.literal("release"),
1972
+ request: z9.string().min(1),
1973
+ release: ProjectReleaseSchema
1974
+ }),
1815
1975
  /**
1816
1976
  * A conversation ended: the runner removes every citizen's workspace for it but those a turn
1817
1977
  * still uses, and answers with a `SweepResult` naming those it kept for holding work on no branch.
@@ -1855,6 +2015,7 @@ var TriggerKindSchema = z10.enum([
1855
2015
  "closing",
1856
2016
  "cron"
1857
2017
  ]);
2018
+ var HeartbeatCauseSchema = z10.enum(["unread", "held", "waiting"]);
1858
2019
  var TriggerSchema = z10.object({
1859
2020
  kind: TriggerKindSchema,
1860
2021
  from: NameSchema.optional(),
@@ -1865,7 +2026,9 @@ var TriggerSchema = z10.object({
1865
2026
  /** The channel the waking message was posted in. */
1866
2027
  channel: ChannelRefSchema.optional(),
1867
2028
  /** The cron that came due, for a `cron` wake. */
1868
- cronId: UlidSchema.optional()
2029
+ cronId: UlidSchema.optional(),
2030
+ /** What a `heartbeat` woke for; absent on records from before the board kept it. */
2031
+ heartbeat: HeartbeatCauseSchema.optional()
1869
2032
  });
1870
2033
  var TurnThreadSchema = z10.object({ id: UlidSchema, task: z10.boolean() });
1871
2034
  var TurnDispatchSchema = z10.object({
@@ -1942,6 +2105,22 @@ var TurnRecordSchema = z10.object({
1942
2105
  * resumed session starts from it, so the next turn's own cost is measured from here.
1943
2106
  */
1944
2107
  sessionCostUsd: z10.number().nonnegative().optional(),
2108
+ /**
2109
+ * The running usage the CLI reported for the session after this turn, when the runner reports
2110
+ * one. A resumed session's next turn measures its own usage from here.
2111
+ */
2112
+ sessionUsage: UsageSchema.optional(),
2113
+ /**
2114
+ * The SHA-256 of the instructions the session holds after this turn, the first turn's section
2115
+ * left out (`instructionsHash` in `turn-host`): those it began with, or the latest a prompt
2116
+ * carried. Absent when the board does not know them, as for records from before it kept them.
2117
+ */
2118
+ sessionInstructionsHash: z10.string().optional(),
2119
+ /**
2120
+ * The whole input of the turn's latest model call in the session's own thread, as the runner
2121
+ * reported it: how large the session had grown. Absent for a turn that made no call.
2122
+ */
2123
+ contextTokens: z10.number().int().nonnegative().optional(),
1945
2124
  toolCalls: z10.number().int().nonnegative().default(0),
1946
2125
  /** The model the CLI reported for this turn, else the model configured for the agent, else null. */
1947
2126
  model: z10.string().nullable().default(null),
@@ -1955,10 +2134,19 @@ var TurnRecordSchema = z10.object({
1955
2134
  transient: z10.boolean().optional(),
1956
2135
  /** How many turns in a row, this one included, could not resume the session for a `retry`. */
1957
2136
  resumeRetries: z10.number().int().positive().optional(),
1958
- /** Whether the server dropped the conversation's session after this turn, which the next starts anew. */
2137
+ /**
2138
+ * Whether the server dropped the conversation's session after this turn, which the next starts
2139
+ * anew: one its CLI could not resume, or a society role's home after a reflection completed.
2140
+ */
1959
2141
  sessionDropped: z10.boolean().optional(),
1960
2142
  /** The crons whose fire started the turn, which record how it ended. */
1961
- crons: z10.array(UlidSchema).optional()
2143
+ crons: z10.array(UlidSchema).optional(),
2144
+ /**
2145
+ * The failed fires its prompt named: each of its citizen's crons in its conversation whose latest
2146
+ * fire's turn failed with nothing catching it up (`cronFailure`), but those it carries, with that
2147
+ * turn. Completing it catches each up.
2148
+ */
2149
+ cronFailures: z10.array(z10.object({ cron: UlidSchema, turnId: UlidSchema })).optional()
1962
2150
  });
1963
2151
  var RunningTurnSchema = z10.object({
1964
2152
  turnId: UlidSchema,
@@ -2000,6 +2188,8 @@ var TurnHistoryEntrySchema = z10.object({
2000
2188
  /** The project whose channels' conversation the turn was in, for a turn in the society scope. */
2001
2189
  place: NameSchema.optional(),
2002
2190
  trigger: z10.string(),
2191
+ /** What a `heartbeat` woke for; absent on turns logged before the board kept it. */
2192
+ heartbeat: HeartbeatCauseSchema.optional(),
2003
2193
  exitReason: z10.string().nullable(),
2004
2194
  costUsd: z10.number(),
2005
2195
  /** The tokens the turn used; absent for turns logged before turns recorded them. */
@@ -2073,7 +2263,7 @@ var METRICS_WINDOW_MS = {
2073
2263
  "7d": 7 * 24 * 36e5,
2074
2264
  all: null
2075
2265
  };
2076
- var READ_VERBS = ["read_inbox", "search", "get_task"];
2266
+ var READ_VERBS = ["read_inbox", "search", "get_task", "read_image"];
2077
2267
  function isBoardAction(tool) {
2078
2268
  const verb = /^mcp__board__(.+)$/.exec(tool)?.[1];
2079
2269
  return verb !== void 0 && !READ_VERBS.some((read2) => read2 === verb);
@@ -2087,12 +2277,19 @@ var MetricsSchema = z12.object({
2087
2277
  /**
2088
2278
  * Finished turns that changed nothing on the board. Reflections are left out, since they write
2089
2279
  * memory, not the board; turns without a transcript are counted apart, their actions unknown.
2280
+ * Heartbeats are split by what each woke for, and those recorded before the board kept it are a
2281
+ * row without one.
2090
2282
  */
2091
2283
  idle: z12.object({
2092
2284
  turns: CountSchema,
2093
2285
  idle: CountSchema,
2094
2286
  unknown: CountSchema,
2095
- byTrigger: z12.array(z12.object({ trigger: z12.string(), turns: CountSchema, idle: CountSchema })),
2287
+ byTrigger: z12.array(z12.object({
2288
+ trigger: z12.string(),
2289
+ heartbeat: HeartbeatCauseSchema.optional(),
2290
+ turns: CountSchema,
2291
+ idle: CountSchema
2292
+ })),
2096
2293
  byAgent: z12.array(z12.object({
2097
2294
  agent: NameSchema,
2098
2295
  role: NameSchema.nullable(),
@@ -2171,11 +2368,17 @@ var TokenUseSchema = z12.object({
2171
2368
  // packages/shared/dist/config.js
2172
2369
  import { z as z13 } from "zod";
2173
2370
  var LogLevelSchema = z13.enum(["trace", "debug", "info", "warn", "error", "fatal"]);
2371
+ var AppleAppIdSchema = z13.string().regex(/^[A-Z0-9]{10}\.[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*$/, "not <team id>.<bundle id>");
2174
2372
  var GithubSignInSchema = z13.object({
2175
2373
  clientId: z13.string().min(1),
2176
2374
  clientSecret: z13.string().min(1),
2177
2375
  /** Logins, lowercased; GitHub logins are case-insensitive. */
2178
- users: z13.array(z13.string().min(1)).min(1)
2376
+ users: z13.array(z13.string().min(1)).min(1),
2377
+ /**
2378
+ * The iOS app that may sign in, which Apple's association file names so that only it can take the
2379
+ * board's `https` landing; null when no app signs in.
2380
+ */
2381
+ appleAppId: AppleAppIdSchema.nullable()
2179
2382
  });
2180
2383
  var ServerConfigSchema = z13.object({
2181
2384
  dataDir: z13.string().min(1),
@@ -2216,13 +2419,20 @@ function githubSignIn(env) {
2216
2419
  const clientId = env["STELLARIS_GITHUB_CLIENT_ID"] ?? "";
2217
2420
  const clientSecret = env["STELLARIS_GITHUB_CLIENT_SECRET"] ?? "";
2218
2421
  const users = list(env["STELLARIS_GITHUB_USERS"]).map((login) => login.toLowerCase());
2422
+ const appleAppId = env["STELLARIS_APPLE_APP_ID"]?.trim() || null;
2423
+ if (appleAppId !== null && !AppleAppIdSchema.safeParse(appleAppId).success) {
2424
+ throw new Error(`STELLARIS_APPLE_APP_ID is <team id>.<bundle id>, not ${JSON.stringify(appleAppId)}`);
2425
+ }
2219
2426
  if (clientId === "" && clientSecret === "" && users.length === 0) {
2427
+ if (appleAppId !== null) {
2428
+ throw new Error("STELLARIS_APPLE_APP_ID needs GitHub sign-in");
2429
+ }
2220
2430
  return null;
2221
2431
  }
2222
2432
  if (clientId === "" || clientSecret === "" || users.length === 0) {
2223
2433
  throw new Error("GitHub sign-in needs STELLARIS_GITHUB_CLIENT_ID, STELLARIS_GITHUB_CLIENT_SECRET, and STELLARIS_GITHUB_USERS together");
2224
2434
  }
2225
- return GithubSignInSchema.parse({ clientId, clientSecret, users });
2435
+ return GithubSignInSchema.parse({ clientId, clientSecret, users, appleAppId });
2226
2436
  }
2227
2437
  function loadServerConfig(env) {
2228
2438
  return ServerConfigSchema.parse({
@@ -2257,7 +2467,7 @@ var RunnerConfigSchema = z13.object({
2257
2467
  });
2258
2468
 
2259
2469
  // packages/shared/dist/version.js
2260
- var STELLARIS_VERSION = "0.3.26";
2470
+ var STELLARIS_VERSION = "0.4.0";
2261
2471
 
2262
2472
  // packages/shared/dist/mentions.js
2263
2473
  import { parse, postprocess, preprocess } from "micromark";
@@ -2952,6 +3162,41 @@ function filesOf(lines, status) {
2952
3162
  return [...files.values()];
2953
3163
  }
2954
3164
 
3165
+ // packages/board-core/dist/moves.js
3166
+ import { mkdir as mkdir4, rm as rm2 } from "node:fs/promises";
3167
+ import path5 from "node:path";
3168
+ import { execa as execa2 } from "execa";
3169
+ var MoveRepos = class {
3170
+ root;
3171
+ constructor(root) {
3172
+ this.root = root;
3173
+ }
3174
+ /** Makes the move's empty repository, which checks every object a push brings, and answers its path. */
3175
+ async open(dir) {
3176
+ await mkdir4(this.root, { recursive: true });
3177
+ await git2(["init", "--quiet", "--bare", dir], this.root);
3178
+ await git2(["config", "receive.fsckObjects", "true"], dir);
3179
+ return dir;
3180
+ }
3181
+ async drop(dir) {
3182
+ await rm2(path5.resolve(this.root, dir), { recursive: true, force: true });
3183
+ }
3184
+ /** Removes every move's repository, as the server starts: a move never survives a restart. */
3185
+ async clear() {
3186
+ await rm2(this.root, { recursive: true, force: true });
3187
+ }
3188
+ };
3189
+ async function git2(args, cwd) {
3190
+ const result = await execa2("git", args, {
3191
+ cwd,
3192
+ reject: false,
3193
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }
3194
+ });
3195
+ if (result.exitCode !== 0) {
3196
+ throw new Error(`git ${args[0] ?? ""} failed: ${result.stderr.trim()}`);
3197
+ }
3198
+ }
3199
+
2955
3200
  // packages/board-core/dist/mentions.js
2956
3201
  import vm from "node:vm";
2957
3202
  var MENTION_PARSE_MS = 500;
@@ -2986,7 +3231,8 @@ var TurnPayload = z14.object({
2986
3231
  thread: z14.string().optional(),
2987
3232
  channel: z14.string().optional(),
2988
3233
  place: z14.string().optional(),
2989
- trigger: z14.string()
3234
+ trigger: z14.string(),
3235
+ heartbeat: HeartbeatCauseSchema.optional().catch(void 0)
2990
3236
  });
2991
3237
  var TaskPayload = z14.object({
2992
3238
  taskId: z14.string(),
@@ -3086,13 +3332,12 @@ function computeMetrics(input) {
3086
3332
  const nothing = actions === 0 ? 1 : 0;
3087
3333
  turns2 += 1;
3088
3334
  idle += nothing;
3089
- for (const [rows, key] of [
3090
- [turnRows, turn.data.trigger],
3091
- [agentRows, event.actor]
3092
- ]) {
3093
- const row = rows.get(key) ?? { turns: 0, idle: 0 };
3094
- rows.set(key, { turns: row.turns + 1, idle: row.idle + nothing });
3095
- }
3335
+ const { trigger, heartbeat } = turn.data;
3336
+ const key = heartbeat === void 0 ? trigger : `${trigger}\0${heartbeat}`;
3337
+ const row = turnRows.get(key) ?? { trigger, heartbeat, turns: 0, idle: 0 };
3338
+ turnRows.set(key, { ...row, turns: row.turns + 1, idle: row.idle + nothing });
3339
+ const own = agentRows.get(event.actor) ?? { turns: 0, idle: 0 };
3340
+ agentRows.set(event.actor, { turns: own.turns + 1, idle: own.idle + nothing });
3096
3341
  break;
3097
3342
  }
3098
3343
  case "turn.started": {
@@ -3248,7 +3493,10 @@ function computeMetrics(input) {
3248
3493
  turns: turns2,
3249
3494
  idle,
3250
3495
  unknown,
3251
- byTrigger: [...turnRows].map(([trigger, row]) => ({ trigger, ...row })).toSorted((a, b) => b.idle - a.idle || b.turns - a.turns),
3496
+ byTrigger: [...turnRows.values()].map(({ heartbeat, ...row }) => ({
3497
+ ...row,
3498
+ ...heartbeat === void 0 ? {} : { heartbeat }
3499
+ })).toSorted((a, b) => b.idle - a.idle || b.turns - a.turns),
3252
3500
  byAgent: [...agentRows].map(([agent, row]) => ({ agent, role: citizens.get(agent)?.role ?? null, ...row })).toSorted((a, b) => b.idle - a.idle || b.turns - a.turns)
3253
3501
  },
3254
3502
  sentBack: {
@@ -3308,6 +3556,8 @@ var TurnHistoryPayloadSchema = z15.object({
3308
3556
  channel: NameSchema.optional(),
3309
3557
  place: NameSchema.optional(),
3310
3558
  trigger: z15.string().default("manual"),
3559
+ // One a later board added reads as none, so the turn stays in the history.
3560
+ heartbeat: HeartbeatCauseSchema.optional().catch(void 0),
3311
3561
  exitReason: z15.string().nullable().default(null),
3312
3562
  costUsd: z15.number().default(0),
3313
3563
  usage: UsageSchema.optional(),
@@ -3365,121 +3615,121 @@ var Mutex = class {
3365
3615
  };
3366
3616
 
3367
3617
  // packages/board-core/dist/paths.js
3368
- import path5 from "node:path";
3618
+ import path6 from "node:path";
3369
3619
  var BoardPaths = class {
3370
3620
  dataDir;
3371
3621
  constructor(dataDir) {
3372
- this.dataDir = path5.resolve(dataDir);
3622
+ this.dataDir = path6.resolve(dataDir);
3373
3623
  }
3374
3624
  // Board projection, also the storage of record; only the core library writes it.
3375
3625
  get board() {
3376
- return path5.join(this.dataDir, "board");
3626
+ return path6.join(this.dataDir, "board");
3377
3627
  }
3378
3628
  get society() {
3379
- return path5.join(this.board, "society");
3629
+ return path6.join(this.board, "society");
3380
3630
  }
3381
3631
  societyFile() {
3382
- return path5.join(this.society, "society.md");
3632
+ return path6.join(this.society, "society.md");
3383
3633
  }
3384
3634
  societyChannels() {
3385
- return path5.join(this.society, "channels");
3635
+ return path6.join(this.society, "channels");
3386
3636
  }
3387
3637
  societyChannel(name) {
3388
- return path5.join(this.societyChannels(), name);
3638
+ return path6.join(this.societyChannels(), name);
3389
3639
  }
3390
3640
  /** Threads whose channel is a society channel; project channels' threads live under the project. */
3391
3641
  societyThreads() {
3392
- return path5.join(this.society, "threads");
3642
+ return path6.join(this.society, "threads");
3393
3643
  }
3394
3644
  societyKnowledge() {
3395
- return path5.join(this.society, "knowledge");
3645
+ return path6.join(this.society, "knowledge");
3396
3646
  }
3397
3647
  societyKnowledgeFile(topic) {
3398
- return path5.join(this.societyKnowledge(), `${topic}.md`);
3648
+ return path6.join(this.societyKnowledge(), `${topic}.md`);
3399
3649
  }
3400
3650
  /** Skills promoted to the society, one directory per skill holding SKILL.md. */
3401
3651
  societySkills() {
3402
- return path5.join(this.society, "skills");
3652
+ return path6.join(this.society, "skills");
3403
3653
  }
3404
3654
  societySkill(name) {
3405
- return path5.join(this.societySkills(), name);
3655
+ return path6.join(this.societySkills(), name);
3406
3656
  }
3407
3657
  societySkillFile(name) {
3408
- return path5.join(this.societySkill(name), "SKILL.md");
3658
+ return path6.join(this.societySkill(name), "SKILL.md");
3409
3659
  }
3410
3660
  roles() {
3411
- return path5.join(this.society, "roles");
3661
+ return path6.join(this.society, "roles");
3412
3662
  }
3413
3663
  role(name) {
3414
- return path5.join(this.roles(), `${name}.md`);
3664
+ return path6.join(this.roles(), `${name}.md`);
3415
3665
  }
3416
3666
  proposals() {
3417
- return path5.join(this.society, "proposals");
3667
+ return path6.join(this.society, "proposals");
3418
3668
  }
3419
3669
  proposal(id) {
3420
- return path5.join(this.proposals(), `${id}.md`);
3670
+ return path6.join(this.proposals(), `${id}.md`);
3421
3671
  }
3422
3672
  /** A skill proposal's folder, as the board took it from its proposer's home. */
3423
3673
  proposalFiles(id) {
3424
- return path5.join(this.proposals(), id);
3674
+ return path6.join(this.proposals(), id);
3425
3675
  }
3426
3676
  decisions() {
3427
- return path5.join(this.society, "decisions");
3677
+ return path6.join(this.society, "decisions");
3428
3678
  }
3429
3679
  decision(id) {
3430
- return path5.join(this.decisions(), `${id}.md`);
3680
+ return path6.join(this.decisions(), `${id}.md`);
3431
3681
  }
3432
3682
  runners() {
3433
- return path5.join(this.society, "runners");
3683
+ return path6.join(this.society, "runners");
3434
3684
  }
3435
3685
  runner(name) {
3436
- return path5.join(this.runners(), `${name}.md`);
3686
+ return path6.join(this.runners(), `${name}.md`);
3437
3687
  }
3438
3688
  members() {
3439
- return path5.join(this.society, "members");
3689
+ return path6.join(this.society, "members");
3440
3690
  }
3441
3691
  /** Crons, one file each with its note as the body; ended ones stay. */
3442
3692
  crons() {
3443
- return path5.join(this.society, "crons");
3693
+ return path6.join(this.society, "crons");
3444
3694
  }
3445
3695
  cron(id) {
3446
- return path5.join(this.crons(), `${id}.md`);
3696
+ return path6.join(this.crons(), `${id}.md`);
3447
3697
  }
3448
3698
  member(name) {
3449
- return path5.join(this.members(), `${name}.md`);
3699
+ return path6.join(this.members(), `${name}.md`);
3450
3700
  }
3451
3701
  projects() {
3452
- return path5.join(this.board, "projects");
3702
+ return path6.join(this.board, "projects");
3453
3703
  }
3454
3704
  project(slug) {
3455
- return path5.join(this.projects(), slug);
3705
+ return path6.join(this.projects(), slug);
3456
3706
  }
3457
3707
  projectFile(slug) {
3458
- return path5.join(this.project(slug), "project.md");
3708
+ return path6.join(this.project(slug), "project.md");
3459
3709
  }
3460
3710
  projectChannels(slug) {
3461
- return path5.join(this.project(slug), "channels");
3711
+ return path6.join(this.project(slug), "channels");
3462
3712
  }
3463
3713
  projectChannel(slug, name) {
3464
- return path5.join(this.projectChannels(slug), name);
3714
+ return path6.join(this.projectChannels(slug), name);
3465
3715
  }
3466
3716
  threads(slug) {
3467
- return path5.join(this.project(slug), "threads");
3717
+ return path6.join(this.project(slug), "threads");
3468
3718
  }
3469
3719
  tasks(slug) {
3470
- return path5.join(this.project(slug), "tasks");
3720
+ return path6.join(this.project(slug), "tasks");
3471
3721
  }
3472
3722
  task(slug, id) {
3473
- return path5.join(this.tasks(slug), `${id}.md`);
3723
+ return path6.join(this.tasks(slug), `${id}.md`);
3474
3724
  }
3475
3725
  projectKnowledge(slug) {
3476
- return path5.join(this.project(slug), "knowledge");
3726
+ return path6.join(this.project(slug), "knowledge");
3477
3727
  }
3478
3728
  projectKnowledgeFile(slug, topic) {
3479
- return path5.join(this.projectKnowledge(slug), `${topic}.md`);
3729
+ return path6.join(this.projectKnowledge(slug), `${topic}.md`);
3480
3730
  }
3481
3731
  dashboard(slug) {
3482
- return path5.join(this.project(slug), "dashboard.md");
3732
+ return path6.join(this.project(slug), "dashboard.md");
3483
3733
  }
3484
3734
  channelDir(ref) {
3485
3735
  const parsed = parseChannelRef(ref);
@@ -3492,57 +3742,57 @@ var BoardPaths = class {
3492
3742
  }
3493
3743
  /** A thread's record, `<id>.md`, beside the directory of its messages, `<id>/`. */
3494
3744
  threadFile(threads, id) {
3495
- return path5.join(threads, `${id}.md`);
3745
+ return path6.join(threads, `${id}.md`);
3496
3746
  }
3497
3747
  threadMessages(threads, id) {
3498
- return path5.join(threads, id);
3748
+ return path6.join(threads, id);
3499
3749
  }
3500
3750
  messageFile(dir, id, author) {
3501
- return path5.join(dir, `${id}-${author}.md`);
3751
+ return path6.join(dir, `${id}-${author}.md`);
3502
3752
  }
3503
3753
  // Agent homes: authored by the agent, source of truth on the board server.
3504
3754
  agents() {
3505
- return path5.join(this.dataDir, "agents");
3755
+ return path6.join(this.dataDir, "agents");
3506
3756
  }
3507
3757
  agent(name) {
3508
- return path5.join(this.agents(), name);
3758
+ return path6.join(this.agents(), name);
3509
3759
  }
3510
3760
  agentFile(name) {
3511
- return path5.join(this.agent(name), "agent.json");
3761
+ return path6.join(this.agent(name), "agent.json");
3512
3762
  }
3513
3763
  agentRole(name) {
3514
- return path5.join(this.agent(name), "role.md");
3764
+ return path6.join(this.agent(name), "role.md");
3515
3765
  }
3516
3766
  agentProfile(name) {
3517
- return path5.join(this.agent(name), "profile.md");
3767
+ return path6.join(this.agent(name), "profile.md");
3518
3768
  }
3519
3769
  agentMemory(name) {
3520
- return path5.join(this.agent(name), "memory");
3770
+ return path6.join(this.agent(name), "memory");
3521
3771
  }
3522
3772
  agentMemoryCore(name) {
3523
- return path5.join(this.agentMemory(name), "core.md");
3773
+ return path6.join(this.agentMemory(name), "core.md");
3524
3774
  }
3525
3775
  /** Transcripts of the agent's finished turns, one JSON line per step. */
3526
3776
  agentTurns(name) {
3527
- return path5.join(this.agent(name), "turns");
3777
+ return path6.join(this.agent(name), "turns");
3528
3778
  }
3529
3779
  agentTranscript(name, turnId) {
3530
- return path5.join(this.agentTurns(name), `${turnId}.jsonl`);
3780
+ return path6.join(this.agentTurns(name), `${turnId}.jsonl`);
3531
3781
  }
3532
3782
  agentSkills(name) {
3533
- return path5.join(this.agent(name), "skills");
3783
+ return path6.join(this.agent(name), "skills");
3534
3784
  }
3535
3785
  agentSkill(name, skill) {
3536
- return path5.join(this.agentSkills(name), skill);
3786
+ return path6.join(this.agentSkills(name), skill);
3537
3787
  }
3538
3788
  agentSkillFile(name, skill) {
3539
- return path5.join(this.agentSkill(name, skill), "SKILL.md");
3789
+ return path6.join(this.agentSkill(name, skill), "SKILL.md");
3540
3790
  }
3541
3791
  agentProjects(name) {
3542
- return path5.join(this.agent(name), "projects");
3792
+ return path6.join(this.agent(name), "projects");
3543
3793
  }
3544
3794
  agentProject(name, slug) {
3545
- return path5.join(this.agentProjects(name), slug);
3795
+ return path6.join(this.agentProjects(name), slug);
3546
3796
  }
3547
3797
  /**
3548
3798
  * A thread's, a channel's, or, in the society scope, a project's conversation's session and last
@@ -3550,46 +3800,56 @@ var BoardPaths = class {
3550
3800
  */
3551
3801
  agentConversation(name, slug, part) {
3552
3802
  if (part.startsWith("#")) {
3553
- return path5.join(this.agentProject(name, slug), "channels", part.slice(1));
3803
+ return path6.join(this.agentProject(name, slug), "channels", part.slice(1));
3554
3804
  }
3555
- return part.startsWith("~") ? path5.join(this.agentProject(name, slug), "places", part.slice(1)) : path5.join(this.agentProject(name, slug), "threads", part);
3805
+ return part.startsWith("~") ? path6.join(this.agentProject(name, slug), "places", part.slice(1)) : path6.join(this.agentProject(name, slug), "threads", part);
3556
3806
  }
3557
3807
  agentCursors(name) {
3558
- return path5.join(this.agent(name), "cursors.json");
3808
+ return path6.join(this.agent(name), "cursors.json");
3559
3809
  }
3560
3810
  events() {
3561
- return path5.join(this.dataDir, "events");
3811
+ return path6.join(this.dataDir, "events");
3562
3812
  }
3563
3813
  eventLog() {
3564
- return path5.join(this.events(), "log.jsonl");
3814
+ return path6.join(this.events(), "log.jsonl");
3565
3815
  }
3566
3816
  /** Images citizens uploaded, by id: outside the projection, which every runner mirrors. */
3567
3817
  images() {
3568
- return path5.join(this.dataDir, "images");
3818
+ return path6.join(this.dataDir, "images");
3569
3819
  }
3570
3820
  image(id) {
3571
- return path5.join(this.images(), id);
3821
+ return path6.join(this.images(), id);
3822
+ }
3823
+ /**
3824
+ * The repositories projects travel through between runners, one per move while it runs:
3825
+ * outside the projection, which every runner mirrors, and served only to the move's runners.
3826
+ */
3827
+ moves() {
3828
+ return path6.join(this.dataDir, "moves");
3829
+ }
3830
+ move(id) {
3831
+ return path6.join(this.moves(), `${id}.git`);
3572
3832
  }
3573
3833
  state() {
3574
- return path5.join(this.dataDir, "state");
3834
+ return path6.join(this.dataDir, "state");
3575
3835
  }
3576
3836
  pausedFile() {
3577
- return path5.join(this.state(), "paused.json");
3837
+ return path6.join(this.state(), "paused.json");
3578
3838
  }
3579
3839
  /** Removed knowledge topics, set aside outside the projection, by scope. */
3580
3840
  removedKnowledge(scope) {
3581
- return path5.join(this.state(), "removed-knowledge", scope);
3841
+ return path6.join(this.state(), "removed-knowledge", scope);
3582
3842
  }
3583
3843
  /** Skills removed from the society, set aside outside the projection, one directory per removal. */
3584
3844
  removedSkills() {
3585
- return path5.join(this.state(), "removed-skills");
3845
+ return path6.join(this.state(), "removed-skills");
3586
3846
  }
3587
3847
  };
3588
3848
 
3589
3849
  // packages/board-core/dist/skill-folder.js
3590
3850
  import { constants } from "node:fs";
3591
3851
  import { lstat, open as open2, readdir as readdir3, readlink, realpath } from "node:fs/promises";
3592
- import path6 from "node:path";
3852
+ import path7 from "node:path";
3593
3853
  var SKILL_TEXT_LIMIT_CHARS = 1e5;
3594
3854
  var SKILL_TEXT_LIMIT_BYTES = SKILL_TEXT_LIMIT_CHARS * 4;
3595
3855
  function skillFileText(bytes) {
@@ -3600,8 +3860,8 @@ function skillFileText(bytes) {
3600
3860
  };
3601
3861
  }
3602
3862
  function inside(root, file) {
3603
- const relative = path6.relative(root, file);
3604
- return relative !== ".." && !relative.startsWith(`..${path6.sep}`) && !path6.isAbsolute(relative);
3863
+ const relative = path7.relative(root, file);
3864
+ return relative !== ".." && !relative.startsWith(`..${path7.sep}`) && !path7.isAbsolute(relative);
3605
3865
  }
3606
3866
  async function handleInside(root, handle) {
3607
3867
  if (process.platform !== "linux") {
@@ -3611,7 +3871,7 @@ async function handleInside(root, handle) {
3611
3871
  return file !== null && inside(root, file);
3612
3872
  }
3613
3873
  async function walk(root, prefix, found) {
3614
- const entries = await readdir3(path6.join(root, ...prefix === "" ? [] : prefix.split("/")), {
3874
+ const entries = await readdir3(path7.join(root, ...prefix === "" ? [] : prefix.split("/")), {
3615
3875
  withFileTypes: true
3616
3876
  }).catch(() => []);
3617
3877
  for (const entry of entries.toSorted((a, b) => a.name < b.name ? -1 : 1)) {
@@ -3632,7 +3892,7 @@ async function walk(root, prefix, found) {
3632
3892
  async function readEntry(root, relative) {
3633
3893
  let real;
3634
3894
  try {
3635
- real = await realpath(path6.join(root, ...relative.split("/")));
3895
+ real = await realpath(path7.join(root, ...relative.split("/")));
3636
3896
  } catch {
3637
3897
  return null;
3638
3898
  }
@@ -3723,7 +3983,10 @@ var SignInsSchema = z16.object({
3723
3983
  expiresAt: IsoDateTimeSchema
3724
3984
  })).default([])
3725
3985
  });
3726
- var TurnStartPayloadSchema = z16.object({ turnId: UlidSchema });
3986
+ var TurnStartPayloadSchema = z16.object({
3987
+ turnId: UlidSchema,
3988
+ heartbeat: TurnHistoryPayloadSchema.shape.heartbeat
3989
+ });
3727
3990
  var CursorsSchema = z16.object({
3728
3991
  digest: z16.string().nullable(),
3729
3992
  scopes: z16.record(z16.string(), z16.string()).default({}),
@@ -3795,6 +4058,9 @@ function skillFolderOf(proposal) {
3795
4058
  function proposedFrom(origin, where) {
3796
4059
  return origin !== void 0 && origin.scope === where.scope && origin.thread === where.thread && origin.channel === where.channel && origin.place === where.place;
3797
4060
  }
4061
+ function latestLease(a, b) {
4062
+ return a === null ? b : b === null || a >= b ? a : b;
4063
+ }
3798
4064
  function megabytes(bytes) {
3799
4065
  return (bytes / (1024 * 1024)).toFixed(1).replace(/\.0$/, "");
3800
4066
  }
@@ -3934,6 +4200,7 @@ var Board = class _Board {
3934
4200
  /** The turns in flight, which the server hosting them supplies; none until it does. */
3935
4201
  turnsInFlight = () => [];
3936
4202
  imageSource = null;
4203
+ imageSink = null;
3937
4204
  newId = monotonicFactory();
3938
4205
  events;
3939
4206
  actionCounts = /* @__PURE__ */ new Map();
@@ -3948,9 +4215,11 @@ var Board = class _Board {
3948
4215
  signIns = /* @__PURE__ */ new Map();
3949
4216
  files = new FileTree();
3950
4217
  homes;
4218
+ moves;
3951
4219
  constructor(dataDir, options) {
3952
4220
  this.paths = new BoardPaths(dataDir);
3953
- this.homes = new HomeRepos(path7.join(this.paths.state(), "home-hooks"));
4221
+ this.homes = new HomeRepos(path8.join(this.paths.state(), "home-hooks"));
4222
+ this.moves = new MoveRepos(this.paths.moves());
3954
4223
  this.leaseMs = options.leaseMs ?? DEFAULT_LEASE_MS;
3955
4224
  this.now = options.now ?? (() => /* @__PURE__ */ new Date());
3956
4225
  this.events = new EventLog(this.paths.eventLog(), () => this.newId(), this.now);
@@ -3976,16 +4245,20 @@ var Board = class _Board {
3976
4245
  await board2.loadRunnerTokens();
3977
4246
  await board2.loadSignIns();
3978
4247
  await board2.homes.installHooks();
4248
+ await board2.moves.clear();
3979
4249
  return board2;
3980
4250
  }
3981
4251
  /**
3982
4252
  * Applies each one-time alignment an existing society has not had yet, and remembers it, so a
3983
4253
  * member that later chooses otherwise keeps its choice. A new alignment is a name and a method in
3984
- * this list; once every board has applied it, both are deleted. None is pending.
4254
+ * this list; once every board has applied it, both are deleted.
3985
4255
  */
3986
4256
  async applyAlignments() {
3987
- const alignments = [];
3988
- const file = path7.join(this.paths.state(), "alignments.json");
4257
+ const alignments = [
4258
+ // Whoever may show an image in a post may open one a post shows.
4259
+ { name: "image-reads-granted", apply: () => this.grantBeside("upload_image", "read_image") }
4260
+ ];
4261
+ const file = path8.join(this.paths.state(), "alignments.json");
3989
4262
  const { applied } = await exists(file) ? await readJson(file, AlignmentsSchema) : { applied: [] };
3990
4263
  const pending = alignments.filter(({ name }) => !applied.includes(name));
3991
4264
  if (pending.length === 0) {
@@ -3996,6 +4269,17 @@ var Board = class _Board {
3996
4269
  }
3997
4270
  await writeJson(file, { applied: [...applied, ...pending.map(({ name }) => name)] });
3998
4271
  }
4272
+ /**
4273
+ * Grants `verb` once to every charter granting `beside`, the work roles' charters in practice;
4274
+ * seed roles have it from their seed.
4275
+ */
4276
+ async grantBeside(beside, verb) {
4277
+ for (const charter of await this.listRoles()) {
4278
+ if (charter.verbs.includes(beside) && !charter.verbs.includes(verb)) {
4279
+ await this.writeRoleUnlocked(USER_NAME, { ...charter, verbs: [...charter.verbs, verb] }, { verbsAdded: [verb] });
4280
+ }
4281
+ }
4282
+ }
3999
4283
  /** Writes every missing seed charter and aligns the existing ones with their seed. */
4000
4284
  async ensureSeedRoles() {
4001
4285
  await ensureDir(this.paths.roles());
@@ -4193,7 +4477,7 @@ ${charter.purpose}
4193
4477
  });
4194
4478
  }
4195
4479
  signInsFile() {
4196
- return path7.join(this.paths.state(), "sign-ins.json");
4480
+ return path8.join(this.paths.state(), "sign-ins.json");
4197
4481
  }
4198
4482
  async readSignIns() {
4199
4483
  const file = this.signInsFile();
@@ -4290,7 +4574,7 @@ ${charter.purpose}
4290
4574
  const files = await listFiles(this.paths.tasks(project));
4291
4575
  const tasks = [];
4292
4576
  for (const file of files) {
4293
- const doc = await readMarkdown(path7.join(this.paths.tasks(project), file), TaskFrontmatterSchema);
4577
+ const doc = await readMarkdown(path8.join(this.paths.tasks(project), file), TaskFrontmatterSchema);
4294
4578
  tasks.push({ ...doc.data, body: doc.body });
4295
4579
  }
4296
4580
  return tasks;
@@ -4309,7 +4593,7 @@ ${charter.purpose}
4309
4593
  const files = await listFiles(this.paths.proposals());
4310
4594
  const proposals = [];
4311
4595
  for (const file of files) {
4312
- const doc = await readMarkdown(path7.join(this.paths.proposals(), file), ProposalFrontmatterSchema);
4596
+ const doc = await readMarkdown(path8.join(this.paths.proposals(), file), ProposalFrontmatterSchema);
4313
4597
  proposals.push({ ...doc.data, body: doc.body });
4314
4598
  }
4315
4599
  return proposals;
@@ -4336,7 +4620,7 @@ ${charter.purpose}
4336
4620
  async listMembers() {
4337
4621
  const members = [];
4338
4622
  for (const file of await listFiles(this.paths.members())) {
4339
- const doc = await readMarkdown(path7.join(this.paths.members(), file), MemberSchema);
4623
+ const doc = await readMarkdown(path8.join(this.paths.members(), file), MemberSchema);
4340
4624
  members.push({ ...doc.data, profile: doc.body });
4341
4625
  }
4342
4626
  return members;
@@ -4355,7 +4639,7 @@ ${charter.purpose}
4355
4639
  const dir = project === null ? this.paths.societyKnowledge() : this.paths.projectKnowledge(project);
4356
4640
  const topics = [];
4357
4641
  for (const file of await listFiles(dir)) {
4358
- topics.push(await this.knowledgeAt(path7.join(dir, file), project, file.replace(/\.md$/, "")));
4642
+ topics.push(await this.knowledgeAt(path8.join(dir, file), project, file.replace(/\.md$/, "")));
4359
4643
  }
4360
4644
  return topics;
4361
4645
  }
@@ -4385,7 +4669,7 @@ ${charter.purpose}
4385
4669
  const aside = this.paths.removedKnowledge(project ?? SOCIETY_SCOPE);
4386
4670
  let highest = 0;
4387
4671
  for (const file of (await listFiles(aside)).filter((name) => name.startsWith(`${topic}.`))) {
4388
- const copy = await this.knowledgeAt(path7.join(aside, file), project, topic);
4672
+ const copy = await this.knowledgeAt(path8.join(aside, file), project, topic);
4389
4673
  highest = Math.max(highest, copy.revision);
4390
4674
  }
4391
4675
  return highest;
@@ -4401,7 +4685,7 @@ ${charter.purpose}
4401
4685
  async listSkillsIn(dir, scope) {
4402
4686
  const skills = [];
4403
4687
  for (const entry of await listDirs(dir)) {
4404
- const file = path7.join(dir, entry, "SKILL.md");
4688
+ const file = path8.join(dir, entry, "SKILL.md");
4405
4689
  if (!await exists(file)) {
4406
4690
  continue;
4407
4691
  }
@@ -4491,7 +4775,7 @@ ${charter.purpose}
4491
4775
  if (!wanted.delete(file.slice(0, 26))) {
4492
4776
  continue;
4493
4777
  }
4494
- const { data } = await readMarkdown(path7.join(dir, file), MessageFrontmatterSchema);
4778
+ const { data } = await readMarkdown(path8.join(dir, file), MessageFrontmatterSchema);
4495
4779
  found.push(MessageRefSchema.parse(data));
4496
4780
  }
4497
4781
  if (wanted.size === 0) {
@@ -4523,7 +4807,7 @@ ${charter.purpose}
4523
4807
  if (!file.endsWith(".md")) {
4524
4808
  continue;
4525
4809
  }
4526
- const doc = await readMarkdown(path7.join(threads, file), ThreadFrontmatterSchema);
4810
+ const doc = await readMarkdown(path8.join(threads, file), ThreadFrontmatterSchema);
4527
4811
  if (doc.data.state !== "open") {
4528
4812
  continue;
4529
4813
  }
@@ -4550,7 +4834,7 @@ ${charter.purpose}
4550
4834
  const all = [];
4551
4835
  for (const threads of await this.threadScopes()) {
4552
4836
  for (const file of await listFiles(threads)) {
4553
- const doc = await readMarkdown(path7.join(threads, file), ThreadFrontmatterSchema);
4837
+ const doc = await readMarkdown(path8.join(threads, file), ThreadFrontmatterSchema);
4554
4838
  const messages = await listFiles(this.paths.threadMessages(threads, doc.data.id));
4555
4839
  const last = messages.at(-1);
4556
4840
  all.push({
@@ -4763,6 +5047,46 @@ ${DASHBOARD_LINE}
4763
5047
  return next;
4764
5048
  });
4765
5049
  }
5050
+ /**
5051
+ * Pauses or resumes a runner, the user's alone: the whole runner, or with `cli` that CLI on it
5052
+ * alone, each apart from the other. While either holds, no turn it covers starts there; their
5053
+ * wakes wait in the scheduler's queue, and a turn under way finishes. Pausing what is paused, or
5054
+ * resuming what is not, changes nothing and logs nothing.
5055
+ */
5056
+ async setRunnerPaused(actor, name, change) {
5057
+ this.assertUser(actor, "only the user may pause or resume a runner");
5058
+ const cli = change.cli === void 0 ? void 0 : CliKindSchema.parse(change.cli);
5059
+ return this.mutex.run(async () => {
5060
+ const file = this.paths.runner(name);
5061
+ if (!await exists(file)) {
5062
+ throw new BoardError("NOT_FOUND", `runner ${name} not found`);
5063
+ }
5064
+ const doc = await readMarkdown(file, RunnerSchema);
5065
+ const { paused, pausedClis, ...rest } = doc.data;
5066
+ if (cli !== void 0 && change.paused && !doc.data.clis.includes(cli)) {
5067
+ throw new BoardError("INVALID_STATE", `runner ${name} has no ${cli} to pause`);
5068
+ }
5069
+ const current = cli === void 0 ? paused : pausedClis?.[cli];
5070
+ if (current !== void 0 === change.paused) {
5071
+ return doc.data;
5072
+ }
5073
+ const pause = change.paused ? { at: this.now().toISOString(), by: actor.name } : void 0;
5074
+ const clis = cli === void 0 ? { ...pausedClis } : { ...pausedClis, [cli]: pause };
5075
+ const kept = Object.entries(clis).filter(([, each]) => each !== void 0);
5076
+ const whole = cli === void 0 ? pause : paused;
5077
+ const next = RunnerSchema.parse({
5078
+ ...rest,
5079
+ ...whole === void 0 ? {} : { paused: whole },
5080
+ ...kept.length === 0 ? {} : { pausedClis: Object.fromEntries(kept) }
5081
+ });
5082
+ await writeMarkdown(file, next, doc.body);
5083
+ await this.events.append(change.paused ? "runner.paused" : "runner.resumed", actor.name, {
5084
+ name,
5085
+ ...cli === void 0 ? {} : { cli }
5086
+ });
5087
+ return next;
5088
+ });
5089
+ }
4766
5090
  /**
4767
5091
  * Logs an operations signal as an `ops.signal` event. No channel carries it: the scheduler wakes
4768
5092
  * its readers from the event, the runner lists it in their prompts, and the interface's log shows
@@ -4797,8 +5121,9 @@ ${DASHBOARD_LINE}
4797
5121
  }
4798
5122
  if (event.type === "turn.started") {
4799
5123
  const start = TurnStartPayloadSchema.safeParse(event.payload);
4800
- if (start.success)
4801
- starts.set(start.data.turnId, event.ts);
5124
+ if (start.success) {
5125
+ starts.set(start.data.turnId, { ts: event.ts, heartbeat: start.data.heartbeat });
5126
+ }
4802
5127
  continue;
4803
5128
  }
4804
5129
  if (event.type !== "turn.completed" && event.type !== "turn.failed") {
@@ -4806,17 +5131,19 @@ ${DASHBOARD_LINE}
4806
5131
  }
4807
5132
  const parsed = TurnHistoryPayloadSchema.safeParse(event.payload);
4808
5133
  if (parsed.success) {
4809
- const { turnId } = parsed.data;
4810
- const startedAt = turnId === void 0 ? void 0 : starts.get(turnId);
4811
- if (turnId !== void 0) {
4812
- starts.delete(turnId);
5134
+ const { heartbeat: ended, ...turn } = parsed.data;
5135
+ const start = turn.turnId === void 0 ? void 0 : starts.get(turn.turnId);
5136
+ if (turn.turnId !== void 0) {
5137
+ starts.delete(turn.turnId);
4813
5138
  }
5139
+ const heartbeat = ended ?? start?.heartbeat;
4814
5140
  entries.push({
4815
5141
  id: event.id,
4816
5142
  ts: event.ts,
4817
5143
  outcome: event.type === "turn.failed" ? "failed" : "completed",
4818
- ...parsed.data,
4819
- ...startedAt === void 0 ? {} : { startedAt }
5144
+ ...turn,
5145
+ ...heartbeat === void 0 ? {} : { heartbeat },
5146
+ ...start === void 0 ? {} : { startedAt: start.ts }
4820
5147
  });
4821
5148
  }
4822
5149
  }
@@ -4846,14 +5173,31 @@ ${DASHBOARD_LINE}
4846
5173
  return society;
4847
5174
  });
4848
5175
  }
4849
- /** Releases every claim whose lease has expired. Called by the scheduler on its tick. */
5176
+ /**
5177
+ * Releases every claim whose lease has expired, but for a holder's that its own cron in the
5178
+ * task's thread keeps (`keptLease`). Called by the scheduler on its tick.
5179
+ */
4850
5180
  async expireLeases() {
4851
5181
  return this.mutex.run(async () => {
4852
5182
  const expired = [];
4853
5183
  const now = this.now();
5184
+ let crons;
5185
+ let runners2;
4854
5186
  for (const project of await listDirs(this.paths.projects())) {
5187
+ let place;
4855
5188
  for (const task of await this.listTasks(project)) {
4856
- if (task.status === "claimed" && this.leaseExpired(task, now)) {
5189
+ if (task.status !== "claimed") {
5190
+ continue;
5191
+ }
5192
+ crons ??= await this.listCrons();
5193
+ runners2 ??= await this.listRunners();
5194
+ place ??= await this.readProject(project);
5195
+ const kept = latestLease(this.keptLease(task, crons, now), await this.pausedLease(task, runners2, place.runner, now));
5196
+ if (kept !== null) {
5197
+ await this.writeTask(project, { ...task, leaseExpiresAt: kept });
5198
+ continue;
5199
+ }
5200
+ if (this.leaseExpired(task, now)) {
4857
5201
  const released = await this.writeTask(project, {
4858
5202
  ...task,
4859
5203
  status: "open",
@@ -4922,20 +5266,24 @@ ${DASHBOARD_LINE}
4922
5266
  * general channel, one of its other channels, one of its threads, or, in the society scope, a
4923
5267
  * project's channels. The heartbeat asks, so that it wakes a member where its unread
4924
5268
  * messages are and nowhere else. Society channels count only for a member woken in the society
4925
- * scope; a project member reads them at its next turn. Two kinds of post are in the digest but not
5269
+ * scope; a project member reads them at its next turn. Some posts are in the digest but not
4926
5270
  * counted, and are read when the member wakes for something else: a task step's, which wakes
4927
- * whoever it hands work to, and the board's own, operations signals and landing announcements,
4928
- * whose signals that call for action wake their readers directly. A closed thread counts for
4929
- * nothing: its conversation has ended, and a turn there could only read its posts and be refused
4930
- * a reply, while a wake queued before it closed, as for a mention, still runs there.
5271
+ * whoever it hands work to; the board's own, operations signals and landing announcements, whose
5272
+ * signals that call for action wake their readers directly; and a knowledge topic's notice, which
5273
+ * asks nothing of anyone. In a task's thread a post counts only for whoever the current stage is
5274
+ * for, its holder or, while nobody holds it, a member who may take it, and for the task's creator
5275
+ * when it mentions the user, so the creator can put a wait on the user in; a post that asks the
5276
+ * reader counts wherever it is. A closed thread counts for nothing: its conversation has ended,
5277
+ * and a turn there could only read its posts and be refused a reply, while a wake queued before
5278
+ * it closed, as for a mention, still runs there.
4931
5279
  */
4932
5280
  async unreadByConversation(actor) {
4933
5281
  const { cursors, upto } = await this.digestSnapshot(actor.name);
4934
5282
  const counts = /* @__PURE__ */ new Map();
4935
- const closed = /* @__PURE__ */ new Map();
5283
+ const threadCounts = /* @__PURE__ */ new Map();
4936
5284
  const unscoped = { name: actor.name, role: actor.role };
4937
- for (const { scope, part, message, news } of await this.digestSince(unscoped, cursors, upto)) {
4938
- if (news === true || message.step !== void 0 || message.author === SYSTEM_ACTOR.name) {
5285
+ for (const { scope, part, message, news, asked } of await this.digestSince(unscoped, cursors, upto)) {
5286
+ if (news === true || message.step !== void 0 || message.author === SYSTEM_ACTOR.name || message.notice?.kind === "knowledge") {
4939
5287
  continue;
4940
5288
  }
4941
5289
  const posted = parseChannelRef(message.channel).project ?? SOCIETY_SCOPE;
@@ -4944,12 +5292,13 @@ ${DASHBOARD_LINE}
4944
5292
  }
4945
5293
  const thread = threadOfPart(part);
4946
5294
  if (thread !== void 0) {
4947
- let ended = closed.get(thread);
4948
- if (ended === void 0) {
4949
- ended = (await this.tryFindThread(thread))?.thread.state === "closed";
4950
- closed.set(thread, ended);
5295
+ let counted = threadCounts.get(thread);
5296
+ if (counted === void 0) {
5297
+ counted = await this.threadCount(actor, thread);
5298
+ threadCounts.set(thread, counted);
4951
5299
  }
4952
- if (ended) {
5300
+ const forReader = counted === "every" || counted !== "none" && asked === true || counted === "asks-user" && message.mentions.includes(USER_NAME);
5301
+ if (!forReader) {
4953
5302
  continue;
4954
5303
  }
4955
5304
  }
@@ -5102,7 +5451,7 @@ ${DASHBOARD_LINE}
5102
5451
  include = participates;
5103
5452
  }
5104
5453
  if (include && message.author !== actor.name) {
5105
- collected.push({ scope: filedIn, part, message });
5454
+ collected.push({ scope: filedIn, part, message, ...asked ? { asked: true } : {} });
5106
5455
  }
5107
5456
  }
5108
5457
  return collected.toSorted(byId);
@@ -5145,7 +5494,7 @@ ${task.body}`;
5145
5494
  }
5146
5495
  }
5147
5496
  for (const file of await listFiles(this.paths.projectKnowledge(project))) {
5148
- const content = await readFile4(path7.join(this.paths.projectKnowledge(project), file), "utf8");
5497
+ const content = await readFile4(path8.join(this.paths.projectKnowledge(project), file), "utf8");
5149
5498
  if (content.toLowerCase().includes(needle)) {
5150
5499
  hits.push({
5151
5500
  kind: "knowledge",
@@ -5166,7 +5515,7 @@ ${task.body}`;
5166
5515
  shared.push({
5167
5516
  kind: "knowledge",
5168
5517
  ref: `society/knowledge/${file}`,
5169
- file: path7.join(this.paths.societyKnowledge(), file)
5518
+ file: path8.join(this.paths.societyKnowledge(), file)
5170
5519
  });
5171
5520
  }
5172
5521
  for (const skill of await this.listSocietySkills()) {
@@ -5179,7 +5528,7 @@ ${task.body}`;
5179
5528
  shared.push({
5180
5529
  kind: "memory",
5181
5530
  ref: `memory/${file}`,
5182
- file: path7.join(this.paths.agentMemory(actor.name), file)
5531
+ file: path8.join(this.paths.agentMemory(actor.name), file)
5183
5532
  });
5184
5533
  }
5185
5534
  for (const entry of shared) {
@@ -5268,8 +5617,8 @@ ${current.body}`);
5268
5617
  removedAt: this.now().toISOString()
5269
5618
  });
5270
5619
  const aside = this.paths.removedKnowledge(args.project ?? SOCIETY_SCOPE);
5271
- await mkdir4(aside, { recursive: true });
5272
- await rename3(file, path7.join(aside, `${args.topic}.${removed.removedAt.replaceAll(":", "-")}.md`));
5620
+ await mkdir5(aside, { recursive: true });
5621
+ await rename3(file, path8.join(aside, `${args.topic}.${removed.removedAt.replaceAll(":", "-")}.md`));
5273
5622
  await this.events.append("knowledge.removed", actor.name, {
5274
5623
  topic: args.topic,
5275
5624
  project: args.project
@@ -5316,8 +5665,8 @@ ${current.body}`);
5316
5665
  removedAt: this.now().toISOString()
5317
5666
  });
5318
5667
  const aside = this.paths.removedSkills();
5319
- await mkdir4(aside, { recursive: true });
5320
- await rename3(this.paths.societySkill(args.name), path7.join(aside, `${args.name}.${removed.removedAt.replaceAll(":", "-")}`));
5668
+ await mkdir5(aside, { recursive: true });
5669
+ await rename3(this.paths.societySkill(args.name), path8.join(aside, `${args.name}.${removed.removedAt.replaceAll(":", "-")}`));
5321
5670
  await this.events.append("skill.removed", actor.name, {
5322
5671
  name: args.name,
5323
5672
  reason: args.reason
@@ -5379,12 +5728,30 @@ ${current.body}`);
5379
5728
  throw new BoardError("NOT_FOUND", "this board has no runners to read an image from");
5380
5729
  }
5381
5730
  const bytes = await this.imageSource(actor, args.path);
5731
+ return this.keepImage(actor, bytes, args.path, path8.basename(args.path));
5732
+ }
5733
+ /**
5734
+ * Keeps an image the user posts from a composer, as its bytes, under the same checks as an image
5735
+ * a turn uploads. `name`, the file's, describes it in the markdown answered; without one the
5736
+ * description is empty, for the user to write.
5737
+ */
5738
+ async postImage(actor, bytes, name) {
5739
+ this.assertUser(actor, "only the user posts an image's bytes; a turn uploads one with upload_image");
5740
+ const given = name === void 0 ? "" : ImageNameSchema.parse(name);
5741
+ return this.keepImage(actor, bytes, given === "" ? "the image" : given, given);
5742
+ }
5743
+ /**
5744
+ * Keeps `bytes` under a new id when they are a PNG, JPEG, GIF, or WebP of at most
5745
+ * `IMAGE_LIMIT_BYTES`, and answers the markdown that shows them, described by `name`. `what`
5746
+ * names the image in a refusal.
5747
+ */
5748
+ async keepImage(actor, bytes, what, name) {
5382
5749
  const type = imageTypeOf(bytes);
5383
5750
  if (type === null) {
5384
- throw new BoardError("VALIDATION", `${args.path} is not a PNG, JPEG, GIF, or WebP image by its first bytes`);
5751
+ throw new BoardError("VALIDATION", `${what} is not a PNG, JPEG, GIF, or WebP image by its first bytes`);
5385
5752
  }
5386
5753
  if (bytes.byteLength > IMAGE_LIMIT_BYTES) {
5387
- throw new BoardError("VALIDATION", `${args.path} is ${bytes.byteLength} bytes, over the ${IMAGE_LIMIT_BYTES} an image may be`);
5754
+ throw new BoardError("VALIDATION", `${what} is ${bytes.byteLength} bytes, over the ${IMAGE_LIMIT_BYTES} an image may be`);
5388
5755
  }
5389
5756
  return this.mutex.run(async () => {
5390
5757
  const id = this.newId();
@@ -5394,15 +5761,37 @@ ${current.body}`);
5394
5761
  type,
5395
5762
  size: bytes.byteLength
5396
5763
  });
5397
- const name = path7.basename(args.path).replaceAll(/[[\]\\\r\n]/g, "");
5764
+ const description = imageDescription(name);
5398
5765
  return {
5399
5766
  id,
5400
5767
  type,
5401
5768
  size: bytes.byteLength,
5402
- markdown: `![${name}](${IMAGE_SCHEME}${id})`
5769
+ markdown: `![${description}](${IMAGE_SCHEME}${id})`
5403
5770
  };
5404
5771
  });
5405
5772
  }
5773
+ /**
5774
+ * Opens an image the board keeps for the calling turn, by its id or its `image:<id>` address:
5775
+ * the runner the turn runs on writes the bytes to a file in a folder of the turn's own in the
5776
+ * system temp folder, removed when the turn ends, and the board answers its path, for the
5777
+ * citizen to open with its own file tools, since a post reaches a turn as markdown and an image
5778
+ * in it as its address. Any image the board keeps may be opened: every post, brief, topic, and
5779
+ * dashboard that could show one is in the projection every turn reads.
5780
+ */
5781
+ async openImage(actor, input) {
5782
+ const args = VerbInputs.read_image.parse(input);
5783
+ await this.authorize(actor, "read_image");
5784
+ if (actor.scope === void 0) {
5785
+ throw new BoardError("INVALID_STATE", "read_image writes the image on the machine a turn runs on, so only a turn can call it");
5786
+ }
5787
+ if (this.imageSink === null) {
5788
+ throw new BoardError("NOT_FOUND", "this board has no runners to write an image to");
5789
+ }
5790
+ const id = imageIdOf(args.image_id);
5791
+ const { type, bytes } = await this.readImage(id);
5792
+ const file = await this.imageSink(actor, { id, type, bytes });
5793
+ return { id, type, size: bytes.byteLength, path: file };
5794
+ }
5406
5795
  /** An uploaded image's bytes and kind, which its first bytes tell. */
5407
5796
  async readImage(id) {
5408
5797
  const file = this.paths.image(UlidSchema.parse(id));
@@ -5420,7 +5809,7 @@ ${current.body}`);
5420
5809
  async listCrons() {
5421
5810
  const crons = [];
5422
5811
  for (const file of await listFiles(this.paths.crons())) {
5423
- crons.push(await this.readCronFile(path7.join(this.paths.crons(), file)));
5812
+ crons.push(await this.readCronFile(path8.join(this.paths.crons(), file)));
5424
5813
  }
5425
5814
  return crons;
5426
5815
  }
@@ -5748,6 +6137,13 @@ ${current.body}`);
5748
6137
  readImagesWith(source) {
5749
6138
  this.imageSource = source;
5750
6139
  }
6140
+ /**
6141
+ * Where `read_image` writes an image to: the runner the calling turn runs on, through the server
6142
+ * that hosts the runners. Without one, as in the admin CLI, nothing can be opened.
6143
+ */
6144
+ openImagesWith(sink) {
6145
+ this.imageSink = sink;
6146
+ }
5751
6147
  /**
5752
6148
  * Refuses to end conversations while another citizen's turn is in one of them: `cut` picks the
5753
6149
  * turns that would lose theirs. The caller's own turn is exempt, since ending a conversation
@@ -5767,7 +6163,7 @@ ${current.body}`);
5767
6163
  async openThreadIds(dir, keep) {
5768
6164
  const ids = /* @__PURE__ */ new Set();
5769
6165
  for (const file of await listFiles(dir)) {
5770
- const doc = await readMarkdown(path7.join(dir, file), ThreadFrontmatterSchema);
6166
+ const doc = await readMarkdown(path8.join(dir, file), ThreadFrontmatterSchema);
5771
6167
  if (doc.data.state === "open" && keep({ ...doc.data, body: doc.body })) {
5772
6168
  ids.add(doc.data.id);
5773
6169
  }
@@ -5775,12 +6171,12 @@ ${current.body}`);
5775
6171
  return ids;
5776
6172
  }
5777
6173
  /**
5778
- * Refuses to archive a project while a turn runs in it, in a citizen's conversation of its
5779
- * channels in the society scope, or in a thread its archive closes.
6174
+ * Refuses to archive or move a project while a turn runs in it, in a citizen's conversation of
6175
+ * its channels in the society scope, or in one of its open threads.
5780
6176
  */
5781
- async assertProjectQuiet(actor, slug) {
6177
+ async assertProjectQuiet(actor, slug, what = `project ${slug} cannot be archived`) {
5782
6178
  const threads = await this.openThreadIds(this.paths.threads(slug), () => true);
5783
- this.assertNoTurnCut(actor, `project ${slug} cannot be archived`, (turn) => turn.scope === slug || turn.place === slug || turn.thread !== void 0 && threads.has(turn.thread));
6179
+ this.assertNoTurnCut(actor, what, (turn) => turn.scope === slug || turn.place === slug || turn.thread !== void 0 && threads.has(turn.thread));
5784
6180
  }
5785
6181
  async closeThread(actor, input) {
5786
6182
  const args = VerbInputs.close_thread.parse(input);
@@ -6583,6 +6979,8 @@ ${rationale}`}`);
6583
6979
  return this.removeCron(actor, VerbInputs.remove_cron.parse(input));
6584
6980
  case "upload_image":
6585
6981
  return this.uploadImage(actor, VerbInputs.upload_image.parse(input));
6982
+ case "read_image":
6983
+ return this.openImage(actor, VerbInputs.read_image.parse(input));
6586
6984
  default:
6587
6985
  throw new BoardError("VALIDATION", `unknown verb ${String(verb)}`);
6588
6986
  }
@@ -6747,7 +7145,7 @@ ${rationale}`}`);
6747
7145
  return part === void 0 ? this.paths.agentProject(agent, scope) : this.paths.agentConversation(agent, scope, part);
6748
7146
  }
6749
7147
  async readSessions(agent, project, part) {
6750
- const file = path7.join(this.sessionDir(agent, project, part), "sessions.json");
7148
+ const file = path8.join(this.sessionDir(agent, project, part), "sessions.json");
6751
7149
  return await exists(file) ? readJson(file, SessionsFileSchema) : {};
6752
7150
  }
6753
7151
  /**
@@ -6758,7 +7156,7 @@ ${rationale}`}`);
6758
7156
  await this.mutex.run(async () => {
6759
7157
  const dir = this.sessionDir(agent, project, part);
6760
7158
  await ensureDir(dir);
6761
- const file = path7.join(dir, "sessions.json");
7159
+ const file = path8.join(dir, "sessions.json");
6762
7160
  const current = await exists(file) ? await readJson(file, SessionsFileSchema) : {};
6763
7161
  const kept = current.runner === void 0 || current.runner === runner ? current : {};
6764
7162
  await writeJson(file, { ...kept, [cli]: sessionId, runner });
@@ -6770,7 +7168,7 @@ ${rationale}`}`);
6770
7168
  */
6771
7169
  async dropSession(agent, project, cli, sessionId, part) {
6772
7170
  return this.mutex.run(async () => {
6773
- const file = path7.join(this.sessionDir(agent, project, part), "sessions.json");
7171
+ const file = path8.join(this.sessionDir(agent, project, part), "sessions.json");
6774
7172
  if (!await exists(file)) {
6775
7173
  return false;
6776
7174
  }
@@ -6785,7 +7183,7 @@ ${rationale}`}`);
6785
7183
  }
6786
7184
  /** The last turn of one conversation: the scope's home, a channel's, a project's, or a thread's. */
6787
7185
  async readLastTurn(agent, project, part) {
6788
- const file = path7.join(this.sessionDir(agent, project, part), "last-turn.json");
7186
+ const file = path8.join(this.sessionDir(agent, project, part), "last-turn.json");
6789
7187
  return await exists(file) ? readJson(file, TurnRecordSchema) : null;
6790
7188
  }
6791
7189
  /** The latest turn a citizen took in a scope, in any of its conversations there. */
@@ -6793,9 +7191,9 @@ ${rationale}`}`);
6793
7191
  let latest = await this.readLastTurn(agent, project);
6794
7192
  const place = this.paths.agentProject(agent, project);
6795
7193
  const parts = [
6796
- ...await listDirs(path7.join(place, "threads")),
6797
- ...(await listDirs(path7.join(place, "channels"))).map((name) => `#${name}`),
6798
- ...(await listDirs(path7.join(place, "places"))).map((name) => `~${name}`)
7194
+ ...await listDirs(path8.join(place, "threads")),
7195
+ ...(await listDirs(path8.join(place, "channels"))).map((name) => `#${name}`),
7196
+ ...(await listDirs(path8.join(place, "places"))).map((name) => `~${name}`)
6799
7197
  ];
6800
7198
  for (const part of parts) {
6801
7199
  const turn = await this.readLastTurn(agent, project, part);
@@ -6818,6 +7216,7 @@ ${rationale}`}`);
6818
7216
  ...parsed.channel === void 0 ? {} : { channel: parsed.channel },
6819
7217
  ...parsed.place === void 0 ? {} : { place: parsed.place },
6820
7218
  trigger: parsed.trigger.kind,
7219
+ ...parsed.trigger.heartbeat === void 0 ? {} : { heartbeat: parsed.trigger.heartbeat },
6821
7220
  session: parsed.session,
6822
7221
  runner: parsed.runner
6823
7222
  });
@@ -6840,6 +7239,11 @@ ${rationale}`}`);
6840
7239
  for (const id of parsed.crons ?? []) {
6841
7240
  await this.recordCronRun(id, parsed, failed);
6842
7241
  }
7242
+ if (parsed.exitReason === "completed" || parsed.exitReason === "blocked") {
7243
+ for (const shown of parsed.cronFailures ?? []) {
7244
+ await this.catchUpCron(shown, parsed);
7245
+ }
7246
+ }
6843
7247
  if (parsed.exitReason === "stopped") {
6844
7248
  await this.events.append("turn.stopped", parsed.stoppedBy ?? USER_NAME, {
6845
7249
  ...parsed.id === void 0 ? {} : { turnId: parsed.id },
@@ -6857,10 +7261,12 @@ ${rationale}`}`);
6857
7261
  ...parsed.channel === void 0 ? {} : { channel: parsed.channel },
6858
7262
  ...parsed.place === void 0 ? {} : { place: parsed.place },
6859
7263
  trigger: parsed.trigger.kind,
7264
+ ...parsed.trigger.heartbeat === void 0 ? {} : { heartbeat: parsed.trigger.heartbeat },
6860
7265
  session: parsed.session,
6861
7266
  exitReason: parsed.exitReason,
6862
7267
  costUsd: parsed.costUsd,
6863
7268
  ...parsed.usage === null ? {} : { usage: parsed.usage },
7269
+ ...parsed.contextTokens === void 0 ? {} : { contextTokens: parsed.contextTokens },
6864
7270
  toolCalls: parsed.toolCalls,
6865
7271
  model: parsed.model,
6866
7272
  summary: parsed.status?.summary ?? null,
@@ -6979,6 +7385,27 @@ ${rationale}`}`);
6979
7385
  failures: outcome === "failed" ? cron.failures + 1 : outcome === "completed" ? 0 : cron.failures
6980
7386
  });
6981
7387
  }
7388
+ /**
7389
+ * Marks a cron's failed fire caught up by a completed turn whose prompt named it, while that
7390
+ * failure is still its latest run. Its schedule and its run of failures stay as they are.
7391
+ */
7392
+ async catchUpCron(shown, record) {
7393
+ if (record.id === void 0 || !await exists(this.paths.cron(shown.cron))) {
7394
+ return;
7395
+ }
7396
+ const cron = await this.readCron(shown.cron);
7397
+ const failure = cronFailure(cron);
7398
+ if (failure === null || failure.turnId !== shown.turnId) {
7399
+ return;
7400
+ }
7401
+ await this.writeCron({
7402
+ ...cron,
7403
+ lastRun: {
7404
+ ...failure,
7405
+ caughtUp: { turnId: record.id, at: record.endedAt ?? this.now().toISOString() }
7406
+ }
7407
+ });
7408
+ }
6982
7409
  /**
6983
7410
  * Whether a conversation with a workspace of its own has ended: a thread closed, a task ended
6984
7411
  * (for one from before tasks had threads), a channel archived, or a project archived, which ends
@@ -7061,11 +7488,11 @@ ${rationale}`}`);
7061
7488
  }
7062
7489
  /** Small named state files under data/state, for cursors the scheduler must keep across restarts. */
7063
7490
  async readState(name, schema, fallback) {
7064
- const file = path7.join(this.paths.state(), `${name}.json`);
7491
+ const file = path8.join(this.paths.state(), `${name}.json`);
7065
7492
  return await exists(file) ? readJson(file, schema) : fallback;
7066
7493
  }
7067
7494
  async writeState(name, value) {
7068
- await this.mutex.run(() => writeJson(path7.join(this.paths.state(), `${name}.json`), value));
7495
+ await this.mutex.run(() => writeJson(path8.join(this.paths.state(), `${name}.json`), value));
7069
7496
  }
7070
7497
  /** The project's dashboard, empty at revision 0 when it has none. */
7071
7498
  async readDashboard(slug) {
@@ -7096,7 +7523,7 @@ ${rationale}`}`);
7096
7523
  async listRunners() {
7097
7524
  const runners2 = [];
7098
7525
  for (const file of await listFiles(this.paths.runners())) {
7099
- runners2.push((await readMarkdown(path7.join(this.paths.runners(), file), RunnerSchema)).data);
7526
+ runners2.push((await readMarkdown(path8.join(this.paths.runners(), file), RunnerSchema)).data);
7100
7527
  }
7101
7528
  return runners2;
7102
7529
  }
@@ -7148,7 +7575,7 @@ ${rationale}`}`);
7148
7575
  return this.runnerTokens.get(hashToken(token)) ?? null;
7149
7576
  }
7150
7577
  runnerTokensFile() {
7151
- return path7.join(this.paths.state(), "runners.json");
7578
+ return path8.join(this.paths.state(), "runners.json");
7152
7579
  }
7153
7580
  async readRunnerTokens() {
7154
7581
  const file = this.runnerTokensFile();
@@ -7162,7 +7589,8 @@ ${rationale}`}`);
7162
7589
  }
7163
7590
  /**
7164
7591
  * Places a project on the runner that will hold its repository, unless it already lives on one,
7165
- * and returns where it lives. Placement is once: every later turn of the project goes there.
7592
+ * and returns where it lives. Placement is once: every later turn of the project goes there,
7593
+ * until the user moves the project (`recordProjectMove`).
7166
7594
  */
7167
7595
  async placeProject(slug, runner) {
7168
7596
  return this.mutex.run(async () => {
@@ -7204,12 +7632,12 @@ ${rationale}`}`);
7204
7632
  }
7205
7633
  const relative = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
7206
7634
  if (entry.isDirectory()) {
7207
- await walk2(path7.join(dir, entry.name), relative);
7635
+ await walk2(path8.join(dir, entry.name), relative);
7208
7636
  continue;
7209
7637
  }
7210
7638
  const file = entry.isFile() ? conflictCopyOf(relative) : null;
7211
7639
  if (file !== null) {
7212
- const since = await this.homes.changedAt(home, relative) ?? (await stat3(path7.join(home, relative)).catch(() => null))?.mtime.toISOString();
7640
+ const since = await this.homes.changedAt(home, relative) ?? (await stat3(path8.join(home, relative)).catch(() => null))?.mtime.toISOString();
7213
7641
  if (since !== void 0) {
7214
7642
  found.push({ path: relative, file, since });
7215
7643
  }
@@ -7283,6 +7711,74 @@ ${rationale}`}`);
7283
7711
  return next;
7284
7712
  });
7285
7713
  }
7714
+ /**
7715
+ * The board's half of moving a project to another runner, which only the user does, checked
7716
+ * before anything travels and while the hub holds the project's turns: the project is active and
7717
+ * lives on a runner other than `to`, which the board knows; no task there is landing, since its
7718
+ * landing runs on the old runner; and no turn runs in the project. Tasks in play move with their
7719
+ * branches, so the answer lists them for the capabilities the new runner must offer.
7720
+ */
7721
+ async prepareProjectMove(actor, slug, to) {
7722
+ this.assertUser(actor, "only the user may move a project to another runner");
7723
+ const runner = NameSchema.parse(to);
7724
+ return this.mutex.run(async () => {
7725
+ const project = await this.readActiveProject(slug);
7726
+ const from = project.runner;
7727
+ if (from === void 0) {
7728
+ throw new BoardError("INVALID_STATE", `project ${slug} lives on no runner yet; its first turn places it`);
7729
+ }
7730
+ if (from === runner) {
7731
+ throw new BoardError("INVALID_STATE", `project ${slug} already lives on ${runner}`);
7732
+ }
7733
+ await this.readRunner(runner);
7734
+ const inPlay2 = (await this.listTasks(slug)).filter((task) => task.status === "open" || task.status === "claimed");
7735
+ const landing = inPlay2.filter((task) => task.completing);
7736
+ if (landing.length > 0) {
7737
+ throw new BoardError("INVALID_STATE", `project ${slug} cannot be moved while ${landing.length} task(s) land on ${from} (${landing.map((task) => task.id).join(", ")}); move it once they have landed`);
7738
+ }
7739
+ await this.assertProjectQuiet(actor, slug, `project ${slug} cannot be moved`);
7740
+ return { project: { ...project, runner: from }, inPlay: inPlay2 };
7741
+ });
7742
+ }
7743
+ /**
7744
+ * Records that a project moved, once its new runner holds every branch its old one had, or with
7745
+ * nothing carried when the user moved it while the old runner was away: every later turn of
7746
+ * the project goes to `to`. Refused when the project was archived or moved meanwhile.
7747
+ */
7748
+ async recordProjectMove(actor, move) {
7749
+ this.assertUser(actor, "only the user may move a project to another runner");
7750
+ return this.mutex.run(async () => {
7751
+ const current = await this.readActiveProject(move.slug);
7752
+ if (current.runner !== move.from) {
7753
+ throw new BoardError("INVALID_STATE", `project ${move.slug} no longer lives on ${move.from}, so this move no longer applies`);
7754
+ }
7755
+ await this.readRunner(move.to);
7756
+ const project = await this.updateProject(move.slug, (each) => ({ ...each, runner: move.to }));
7757
+ await this.events.append("project.moved", actor.name, {
7758
+ slug: move.slug,
7759
+ from: move.from,
7760
+ to: move.to,
7761
+ carried: move.carried
7762
+ });
7763
+ return project;
7764
+ });
7765
+ }
7766
+ /**
7767
+ * Makes the repository a move carries a project through, for the server's git route to serve,
7768
+ * and answers the move's id, which names it; `dropMoveRepo` removes it when the move ends.
7769
+ */
7770
+ async openMoveRepo() {
7771
+ const id = this.newId();
7772
+ await this.moves.open(this.paths.move(id));
7773
+ return id;
7774
+ }
7775
+ async dropMoveRepo(id) {
7776
+ await this.moves.drop(this.paths.move(UlidSchema.parse(id)));
7777
+ }
7778
+ /** Where the moves' repositories live, which the server's git route serves from. */
7779
+ get moveRepos() {
7780
+ return this.paths.moves();
7781
+ }
7286
7782
  /** The projection a runner mirrors for its agents to read, by relative path, with hashes. */
7287
7783
  async boardManifest() {
7288
7784
  return this.files.manifest(this.paths.board, () => true);
@@ -7293,7 +7789,7 @@ ${rationale}`}`);
7293
7789
  async writeTurnRecord(record) {
7294
7790
  const dir = this.sessionDir(record.agent, record.project, conversationPart(record));
7295
7791
  await ensureDir(dir);
7296
- await writeJson(path7.join(dir, "last-turn.json"), record);
7792
+ await writeJson(path8.join(dir, "last-turn.json"), record);
7297
7793
  }
7298
7794
  // ---------------------------------------------------------------------------------------------
7299
7795
  // Internals
@@ -7412,8 +7908,8 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
7412
7908
  async ensureAgentProject(name, slug) {
7413
7909
  const dir = this.paths.agentProject(name, slug);
7414
7910
  await ensureDir(dir);
7415
- if (!await exists(path7.join(dir, "sessions.json"))) {
7416
- await writeJson(path7.join(dir, "sessions.json"), {});
7911
+ if (!await exists(path8.join(dir, "sessions.json"))) {
7912
+ await writeJson(path8.join(dir, "sessions.json"), {});
7417
7913
  }
7418
7914
  }
7419
7915
  /**
@@ -7655,11 +8151,11 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
7655
8151
  const file = this.paths.societySkillFile(skill.name);
7656
8152
  const replaced = await exists(file);
7657
8153
  const taken = proposal.skillFiles;
7658
- const staged = path7.join(this.paths.proposalFiles(proposal.id), "SKILL.md");
8154
+ const staged = path8.join(this.paths.proposalFiles(proposal.id), "SKILL.md");
7659
8155
  if (taken === void 0 || !await exists(staged)) {
7660
8156
  throw new BoardError("INVALID_STATE", `proposal ${proposal.id} holds no skill files`);
7661
8157
  }
7662
- await rm2(dir, { recursive: true, force: true });
8158
+ await rm3(dir, { recursive: true, force: true });
7663
8159
  await cp(this.paths.proposalFiles(proposal.id), dir, { recursive: true });
7664
8160
  const { body } = await readLooseMarkdown(file);
7665
8161
  await writeMarkdown(file, {
@@ -7712,10 +8208,10 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
7712
8208
  return `the frontmatter of ${folder}/SKILL.md in ${proposal.proposedBy}'s home does not parse (${manual.error})`;
7713
8209
  }
7714
8210
  const dir = this.paths.proposalFiles(proposal.id);
7715
- await rm2(dir, { recursive: true, force: true });
8211
+ await rm3(dir, { recursive: true, force: true });
7716
8212
  for (const entry of taken.files) {
7717
- const target = path7.join(dir, ...entry.path.split("/"));
7718
- await ensureDir(path7.dirname(target));
8213
+ const target = path8.join(dir, ...entry.path.split("/"));
8214
+ await ensureDir(path8.dirname(target));
7719
8215
  await writeFile4(target, contents.get(entry.path) ?? Buffer.alloc(0));
7720
8216
  await chmod2(target, entry.executable ? 493 : 420);
7721
8217
  }
@@ -7806,7 +8302,7 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
7806
8302
  if (!isSafeRelativePath(entry.path)) {
7807
8303
  continue;
7808
8304
  }
7809
- const bytes = await readFile4(path7.join(dir, ...entry.path.split("/"))).catch(() => null);
8305
+ const bytes = await readFile4(path8.join(dir, ...entry.path.split("/"))).catch(() => null);
7810
8306
  files.push({
7811
8307
  ...entry,
7812
8308
  ...bytes === null ? { text: null, truncated: false } : skillFileText(bytes)
@@ -7955,7 +8451,7 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
7955
8451
  this.assertNoTurnCut(actor, `${ref} cannot be archived`, (turn) => turn.channel === channel && turn.scope === scope || turn.thread !== void 0 && closing.has(turn.thread));
7956
8452
  const threadsClosed = [];
7957
8453
  for (const file of await listFiles(threads)) {
7958
- const doc = await readMarkdown(path7.join(threads, file), ThreadFrontmatterSchema);
8454
+ const doc = await readMarkdown(path8.join(threads, file), ThreadFrontmatterSchema);
7959
8455
  if (doc.data.channel !== ref || doc.data.state !== "open") {
7960
8456
  continue;
7961
8457
  }
@@ -8087,7 +8583,7 @@ ${charter.purpose}
8087
8583
  const threads = this.paths.threads(slug);
8088
8584
  const threadsClosed = [];
8089
8585
  for (const file of await listFiles(threads)) {
8090
- const doc = await readMarkdown(path7.join(threads, file), ThreadFrontmatterSchema);
8586
+ const doc = await readMarkdown(path8.join(threads, file), ThreadFrontmatterSchema);
8091
8587
  if (doc.data.state !== "open") {
8092
8588
  continue;
8093
8589
  }
@@ -8162,10 +8658,10 @@ ${charter.purpose}
8162
8658
  ...project,
8163
8659
  members: project.members.filter((member) => member !== name)
8164
8660
  }));
8165
- const sessions = path7.join(this.paths.agentProject(name, slug), "sessions.json");
8661
+ const sessions = path8.join(this.paths.agentProject(name, slug), "sessions.json");
8166
8662
  if (await exists(sessions)) {
8167
8663
  const archived = `sessions.archived.${ts.replace(/[:.]/g, "-")}.json`;
8168
- await rename3(sessions, path7.join(this.paths.agentProject(name, slug), archived));
8664
+ await rename3(sessions, path8.join(this.paths.agentProject(name, slug), archived));
8169
8665
  }
8170
8666
  }
8171
8667
  this.tokenIndex.delete(current.tokenHash);
@@ -8279,7 +8775,7 @@ ${charter.purpose}
8279
8775
  }
8280
8776
  assertMayGate(actor, what) {
8281
8777
  if (!PLANNING_ROLES.includes(actor.role)) {
8282
- throw new BoardError("FORBIDDEN", `only the user, the steward, and the concierge may ${what}; ask one of them in the task's thread`);
8778
+ throw new BoardError("FORBIDDEN", `only the user, the steward, and the concierge may ${what}; ask one of them in the task's thread, with a mention`);
8283
8779
  }
8284
8780
  }
8285
8781
  /** The user holds anything; everyone else must be a project member the current stage admits. */
@@ -8321,6 +8817,51 @@ ${charter.purpose}
8321
8817
  leaseEnd(now) {
8322
8818
  return new Date(now.getTime() + this.leaseMs).toISOString();
8323
8819
  }
8820
+ /**
8821
+ * The lease a holder's own crons in the task's thread keep its claim to, when later than the one
8822
+ * it has, else null. While one of them is pending, the lease is renewed for a full lease once less
8823
+ * than half is left, so it never lapses between fires that may be hours apart; once every one is
8824
+ * paused, ended, or failed at its latest fire with nothing catching it up (`cronStoppedAt`), it
8825
+ * runs a full lease from when the last of them stopped.
8826
+ */
8827
+ keptLease(task, crons, now) {
8828
+ if (task.leaseExpiresAt === void 0) {
8829
+ return null;
8830
+ }
8831
+ const lease = Date.parse(task.leaseExpiresAt);
8832
+ let pending = false;
8833
+ let stopped = null;
8834
+ for (const cron of crons) {
8835
+ if (cron.agent !== task.claimedBy || !firesInTask(cron, task)) {
8836
+ continue;
8837
+ }
8838
+ const at = cronStoppedAt(cron);
8839
+ if (at === null) {
8840
+ pending = true;
8841
+ } else {
8842
+ stopped = Math.max(stopped ?? 0, Date.parse(at));
8843
+ }
8844
+ }
8845
+ const kept = pending ? lease - now.getTime() < this.leaseMs / 2 ? now.getTime() + this.leaseMs : lease : stopped === null ? lease : stopped + this.leaseMs;
8846
+ return kept > lease ? new Date(kept).toISOString() : null;
8847
+ }
8848
+ /**
8849
+ * The lease a holder keeps while the user's pause holds its turns in the task's project
8850
+ * (`heldOn`), when later than the one it has, else null. It is renewed for a full lease once less
8851
+ * than half is left, as for a pending cron, so it never lapses while the holder cannot take a
8852
+ * turn there, and after the resume the holder has at least half a lease to come back to it.
8853
+ */
8854
+ async pausedLease(task, runners2, runner, now) {
8855
+ if (task.claimedBy === void 0 || task.leaseExpiresAt === void 0) {
8856
+ return null;
8857
+ }
8858
+ const holder = await this.readAgent(task.claimedBy).catch(() => null);
8859
+ if (holder === null || !heldOn(runners2, runner, holder.cli)) {
8860
+ return null;
8861
+ }
8862
+ const lease = Date.parse(task.leaseExpiresAt);
8863
+ return lease - now.getTime() < this.leaseMs / 2 ? this.leaseEnd(now) : null;
8864
+ }
8324
8865
  leaseExpired(task, now) {
8325
8866
  return task.leaseExpiresAt !== void 0 && new Date(task.leaseExpiresAt).getTime() <= now.getTime();
8326
8867
  }
@@ -8433,11 +8974,7 @@ ${charter.purpose}
8433
8974
  if (task.claimedBy === actor.name || task.createdBy === actor.name || task.stages.some((stage) => stage.agent === actor.name || stage.holders.includes(actor.name))) {
8434
8975
  return true;
8435
8976
  }
8436
- if (task.status !== "open" || task.completing) {
8437
- return false;
8438
- }
8439
- const agent = await this.readAgent(actor.name);
8440
- return agent.memberships.includes(task.project) && mayHoldStage(actor, task);
8977
+ return await this.mayTakeStage(actor, task);
8441
8978
  }
8442
8979
  if (subject?.kind === "proposal") {
8443
8980
  const proposal = await this.readProposal(subject.id);
@@ -8451,6 +8988,34 @@ ${charter.purpose}
8451
8988
  }
8452
8989
  return false;
8453
8990
  }
8991
+ /** Whether a task's current stage waits for a holder and the member may take it. */
8992
+ async mayTakeStage(actor, task) {
8993
+ if (task.status !== "open" || task.completing) {
8994
+ return false;
8995
+ }
8996
+ const agent = await this.readAgent(actor.name);
8997
+ return agent.memberships.includes(task.project) && mayHoldStage(actor, task);
8998
+ }
8999
+ /**
9000
+ * Which of a thread's posts count toward a reader's heartbeat (see `unreadByConversation`). A
9001
+ * thread, or a task, the board cannot find counts as an open topic's would.
9002
+ */
9003
+ async threadCount(actor, id) {
9004
+ const found = await this.tryFindThread(id);
9005
+ if (found?.thread.state === "closed") {
9006
+ return "none";
9007
+ }
9008
+ const subject = found?.thread.subject;
9009
+ const task = subject?.kind === "task" ? await this.findTask(subject.id).then((location) => location.task, () => null) : null;
9010
+ if (task === null) {
9011
+ return "every";
9012
+ }
9013
+ const stageIsTheirs = task.status === "claimed" ? task.claimedBy === actor.name : await this.mayTakeStage(actor, task);
9014
+ if (stageIsTheirs) {
9015
+ return "every";
9016
+ }
9017
+ return task.createdBy === actor.name ? "asks-user" : "asked";
9018
+ }
8454
9019
  async threadScopes() {
8455
9020
  const projects = await listDirs(this.paths.projects());
8456
9021
  return [this.paths.societyThreads(), ...projects.map((slug) => this.paths.threads(slug))];
@@ -8506,7 +9071,7 @@ ${charter.purpose}
8506
9071
  if (since !== null && id <= since || upto !== void 0 && id > upto) {
8507
9072
  continue;
8508
9073
  }
8509
- const doc = await readMarkdown(path7.join(dir, file), MessageFrontmatterSchema);
9074
+ const doc = await readMarkdown(path8.join(dir, file), MessageFrontmatterSchema);
8510
9075
  messages.push({ ...doc.data, body: doc.body });
8511
9076
  }
8512
9077
  return messages;
@@ -8555,6 +9120,15 @@ ${charter.purpose}
8555
9120
  };
8556
9121
 
8557
9122
  // packages/scheduler/dist/wake.js
9123
+ function heartbeatCause(input) {
9124
+ if (input.digestSize > 0) {
9125
+ return "unread";
9126
+ }
9127
+ if (input.claimsHeld > 0) {
9128
+ return "held";
9129
+ }
9130
+ return input.waitingStages > 0 ? "waiting" : null;
9131
+ }
8558
9132
  function decideWake(input) {
8559
9133
  if (input.paused) {
8560
9134
  return { wake: false, reason: "society paused", priority: 0 };
@@ -8586,7 +9160,7 @@ function decideWake(input) {
8586
9160
  case "heartbeat":
8587
9161
  break;
8588
9162
  }
8589
- if (input.digestSize === 0 && input.claimsHeld === 0 && input.waitingStages === 0) {
9163
+ if (heartbeatCause(input) === null) {
8590
9164
  return { wake: false, reason: "nothing to read, hold, or take", priority: 0 };
8591
9165
  }
8592
9166
  return {
@@ -8634,6 +9208,7 @@ var DEFAULT_TIMINGS = Object.freeze({
8634
9208
  homeConflictMs: 24 * 36e5,
8635
9209
  scaleCooldownMs: 36e5,
8636
9210
  reflectionMs: 24 * 36e5,
9211
+ sessionCapTokens: 3e5,
8637
9212
  transientRetryMs: [6e4, 3 * 6e4, 10 * 6e4]
8638
9213
  });
8639
9214
  var SchedulerTimingsSchema = z17.object({
@@ -8652,6 +9227,7 @@ var SchedulerTimingsSchema = z17.object({
8652
9227
  homeConflictMs: z17.number().positive(),
8653
9228
  scaleCooldownMs: z17.number().positive(),
8654
9229
  reflectionMs: z17.number().positive(),
9230
+ sessionCapTokens: z17.number().int().positive(),
8655
9231
  transientRetryMs: z17.array(z17.number().positive())
8656
9232
  }).partial();
8657
9233
  function parseTimings(input) {
@@ -8679,6 +9255,18 @@ var StateSchema = z17.object({
8679
9255
  lastHeartbeat: z17.record(z17.string(), z17.string()),
8680
9256
  /** Waiting stages already signalled, as `task:stage`, so each is logged once while it waits. */
8681
9257
  waitingSeen: z17.record(z17.string(), z17.string()).default({}),
9258
+ /**
9259
+ * The last pass at which each waiting stage was held by the user's pause of its runner, as
9260
+ * `task:stage`; its wait counts from there, so a stage that waited through a pause is not
9261
+ * signalled the moment the pause ends.
9262
+ */
9263
+ waitingHeld: z17.record(z17.string(), z17.string()).default({}),
9264
+ /**
9265
+ * The last operations pass at which each citizen was held by the user's pause of a runner it
9266
+ * works on; its idle time counts from there, so a citizen held through a pause is not signalled
9267
+ * idle the moment the pause ends.
9268
+ */
9269
+ idleHeld: z17.record(z17.string(), z17.string()).default({}),
8682
9270
  /** Queued dispatches survive a restart; the cursor has already moved past the events that made them. */
8683
9271
  pending: z17.record(z17.string(), PendingSchema).default({}),
8684
9272
  opsReported: z17.record(z17.string(), z17.string()).default({}),
@@ -8694,7 +9282,12 @@ var StateSchema = z17.object({
8694
9282
  costSinceReport: z17.number().nonnegative().default(0),
8695
9283
  turnsSinceReport: z17.number().int().nonnegative().default(0),
8696
9284
  /** When each member last reflected, or was first seen; the cadence counts from here. */
8697
- lastReflection: z17.record(z17.string(), z17.string()).default({})
9285
+ lastReflection: z17.record(z17.string(), z17.string()).default({}),
9286
+ /**
9287
+ * The home session each society member last asked for an early reflection for, once per
9288
+ * session, and whether that reflection still waits to be queued.
9289
+ */
9290
+ earlyReflection: z17.record(z17.string(), z17.object({ session: z17.string(), due: z17.boolean() })).default({})
8698
9291
  });
8699
9292
  function messageOf(trigger) {
8700
9293
  return trigger.kind === "mention" || trigger.kind === "user_post" ? trigger.messageId : void 0;
@@ -9038,6 +9631,7 @@ var Scheduler = class {
9038
9631
  const cost = payload["costUsd"];
9039
9632
  this.state.costSinceReport += typeof cost === "number" ? cost : 0;
9040
9633
  this.state.turnsSinceReport += 1;
9634
+ await this.checkSessionSize(event);
9041
9635
  return;
9042
9636
  }
9043
9637
  case "wake.requested": {
@@ -9611,10 +10205,14 @@ var Scheduler = class {
9611
10205
  * A heartbeat per member and scope on the cadence, which then looks at every conversation of the
9612
10206
  * scope: the unread messages of its home, of each other channel, and of each thread, a stage the
9613
10207
  * member holds, and a stage of an unblocked task waiting that it may take, each in its task's
9614
- * thread. Each conversation with something wakes on its own, so a heartbeat never brings one
9615
- * task's business into another's turn.
10208
+ * thread. A stage counts for neither while the member has a cron pending in its task's thread
10209
+ * (`cronStoppedAt`: neither paused nor ended, and its latest fire's turn did not fail or was
10210
+ * caught up since), whose fires are its checks there. Each conversation with something wakes on
10211
+ * its own, so a heartbeat never brings one task's business into another's turn, and its trigger
10212
+ * says what it woke for (`heartbeatCause`).
9616
10213
  */
9617
10214
  async checkHeartbeats(now) {
10215
+ let pendingCrons;
9618
10216
  for (const agent of await this.board.listAgents()) {
9619
10217
  if (agent.status !== "active" || agent.cli === null) {
9620
10218
  continue;
@@ -9640,6 +10238,9 @@ var Scheduler = class {
9640
10238
  this.state.lastHeartbeat[cadence] = iso(now);
9641
10239
  unread ??= await this.board.unreadByConversation(actor);
9642
10240
  held ??= await this.board.heldClaims(agent.name);
10241
+ pendingCrons ??= (await this.board.listCrons()).filter((cron) => cronStoppedAt(cron) === null);
10242
+ const crons = pendingCrons;
10243
+ const leftToCron = (task) => crons.some((cron) => cron.agent === agent.name && firesInTask(cron, task));
9643
10244
  const reasons = /* @__PURE__ */ new Map();
9644
10245
  const reasonsIn = (beside) => {
9645
10246
  const key = conversationKey2(agent.name, project, beside);
@@ -9658,13 +10259,13 @@ var Scheduler = class {
9658
10259
  }
9659
10260
  }
9660
10261
  for (const task of held) {
9661
- if (task.project === project) {
10262
+ if (task.project === project && !leftToCron(task)) {
9662
10263
  reasonsIn({ thread: { id: task.id, task: true } }).claimsHeld += 1;
9663
10264
  }
9664
10265
  }
9665
10266
  if (project !== SOCIETY_SCOPE) {
9666
10267
  for (const task of await this.board.openTasks(project)) {
9667
- if (mayHoldStage(agent, task) && (await this.blockersInPlay(task.blockedBy)).length === 0) {
10268
+ if (mayHoldStage(agent, task) && !leftToCron(task) && (await this.blockersInPlay(task.blockedBy)).length === 0) {
9668
10269
  reasonsIn({ thread: { id: task.id, task: true } }).waiting += 1;
9669
10270
  }
9670
10271
  }
@@ -9673,7 +10274,16 @@ var Scheduler = class {
9673
10274
  if (this.pending.has(key) || this.running.has(key)) {
9674
10275
  continue;
9675
10276
  }
9676
- const trigger = TriggerSchema.parse({ kind: "heartbeat", reason: "heartbeat" });
10277
+ const cause = heartbeatCause({
10278
+ digestSize: reason.digestSize,
10279
+ claimsHeld: reason.claimsHeld,
10280
+ waitingStages: reason.waiting
10281
+ });
10282
+ const trigger = TriggerSchema.parse({
10283
+ kind: "heartbeat",
10284
+ reason: "heartbeat",
10285
+ ...cause === null ? {} : { heartbeat: cause }
10286
+ });
9677
10287
  const decision = decideWake({
9678
10288
  trigger,
9679
10289
  digestSize: reason.digestSize,
@@ -9766,9 +10376,44 @@ var Scheduler = class {
9766
10376
  item.dispatch = rest.length === 0 ? dispatch : { ...dispatch, crons: rest };
9767
10377
  }
9768
10378
  }
10379
+ /**
10380
+ * A society role's home session that a turn left at `sessionCapTokens` or more, by its latest
10381
+ * model call, asks for a reflection ahead of the cadence, which `checkReflections` queues once
10382
+ * none of the member's turns is queued or running; the reflection's end starts the home afresh
10383
+ * (`TurnHost`), so its history goes only once a reflection saved what to keep. A session asks
10384
+ * once, so a reflection that fails leaves the next to the cadence, and none asks while a
10385
+ * reflection is queued or running.
10386
+ */
10387
+ async checkSessionSize(event) {
10388
+ const { payload } = event;
10389
+ const tokens = payload["contextTokens"];
10390
+ const session = stringOf(payload["session"]);
10391
+ if (typeof tokens !== "number" || tokens < this.timings.sessionCapTokens || session === null || payload["project"] !== SOCIETY_SCOPE || payload["thread"] !== void 0 || payload["channel"] !== void 0 || payload["place"] !== void 0 || payload["trigger"] === "reflection" || payload["sessionDropped"] === true) {
10392
+ return;
10393
+ }
10394
+ const agent = await this.tryReadAgent(event.actor);
10395
+ if (agent === null || agent.status !== "active") {
10396
+ return;
10397
+ }
10398
+ const charter = await this.board.readRole(agent.role).catch(() => null);
10399
+ if (charter === null || !charter.societyScope || !charter.reflects) {
10400
+ return;
10401
+ }
10402
+ if (this.state.earlyReflection[agent.name]?.session === session) {
10403
+ return;
10404
+ }
10405
+ const ofAgent = (key) => parseSessionKey(key)?.agent === agent.name;
10406
+ const reflecting = [...this.pending].some(([key, item]) => ofAgent(key) && item.dispatch.trigger.kind === "reflection") || [...this.exclusive].some(([key, kind]) => ofAgent(key) && kind === "reflection");
10407
+ if (reflecting) {
10408
+ return;
10409
+ }
10410
+ this.state.earlyReflection[agent.name] = { session, due: true };
10411
+ this.log.info({ agent: agent.name, contextTokens: tokens, cap: this.timings.sessionCapTokens }, "home session past the cap; a reflection is due");
10412
+ }
9769
10413
  /**
9770
10414
  * Every `reflectionMs`, a member whose charter reflects takes a reflection turn in the scope of
9771
- * its latest working turn, unless it has not worked since its last reflection.
10415
+ * its latest working turn, unless it has not worked since its last reflection; a society member
10416
+ * whose home session passed the cap takes one in its home ahead of the cadence, which restarts it.
9772
10417
  */
9773
10418
  async checkReflections(now) {
9774
10419
  for (const agent of await this.board.listAgents()) {
@@ -9779,6 +10424,15 @@ var Scheduler = class {
9779
10424
  if (!charter.reflects) {
9780
10425
  continue;
9781
10426
  }
10427
+ const early = this.state.earlyReflection[agent.name];
10428
+ if (early?.due === true) {
10429
+ if (!this.busy(agent.name)) {
10430
+ this.state.lastReflection[agent.name] = iso(now);
10431
+ this.state.earlyReflection[agent.name] = { ...early, due: false };
10432
+ this.enqueue(agent.name, SOCIETY_SCOPE, { kind: "reflection", reason: "home session past the cap" }, now);
10433
+ }
10434
+ continue;
10435
+ }
9782
10436
  const last = this.state.lastReflection[agent.name];
9783
10437
  if (last === void 0) {
9784
10438
  this.state.lastReflection[agent.name] = iso(now);
@@ -9792,8 +10446,7 @@ var Scheduler = class {
9792
10446
  this.state.lastReflection[agent.name] = iso(now);
9793
10447
  continue;
9794
10448
  }
9795
- const busy = [...this.pending.keys(), ...this.running.keys()].some((key) => parseSessionKey(key)?.agent === agent.name);
9796
- if (busy) {
10449
+ if (this.busy(agent.name)) {
9797
10450
  continue;
9798
10451
  }
9799
10452
  this.state.lastReflection[agent.name] = iso(now);
@@ -9803,6 +10456,13 @@ var Scheduler = class {
9803
10456
  this.enqueue(agent.name, latest.scope, { kind: "reflection", reason: "scheduled reflection" }, now);
9804
10457
  }
9805
10458
  }
10459
+ /**
10460
+ * Whether a member has a turn queued or running anywhere, so a reflection waits for the next tick
10461
+ * rather than merge into a working turn or run beside turns writing the memory it consolidates.
10462
+ */
10463
+ busy(agent) {
10464
+ return [...this.pending.keys(), ...this.running.keys()].some((key) => parseSessionKey(key)?.agent === agent);
10465
+ }
9806
10466
  /** The member's most recent turn that was not itself a reflection, with the scope it ran in. */
9807
10467
  async latestWorkingTurn(agent) {
9808
10468
  let latest = null;
@@ -9826,7 +10486,9 @@ var Scheduler = class {
9826
10486
  */
9827
10487
  async checkWaitingStages(now) {
9828
10488
  const stillWaiting = /* @__PURE__ */ new Set();
10489
+ const runners2 = await this.board.listRunners();
9829
10490
  for (const project of await this.board.listProjects()) {
10491
+ let members;
9830
10492
  for (const task of await this.board.openTasks(project.slug)) {
9831
10493
  const stage = currentStage(task);
9832
10494
  if (stage === void 0) {
@@ -9837,7 +10499,14 @@ var Scheduler = class {
9837
10499
  if (this.state.waitingSeen[key] !== void 0) {
9838
10500
  continue;
9839
10501
  }
9840
- const age = now - Date.parse(task.stageSince);
10502
+ members ??= await this.board.projectMembers(project.slug);
10503
+ const takers = members.filter((member) => member.cli !== null && mayHoldStage(member, task));
10504
+ if (takers.length > 0 && takers.every((taker) => heldOn(runners2, project.runner, taker.cli))) {
10505
+ this.state.waitingHeld[key] = iso(now);
10506
+ continue;
10507
+ }
10508
+ const held = this.state.waitingHeld[key];
10509
+ const age = now - Math.max(Date.parse(task.stageSince), held === void 0 ? 0 : Date.parse(held));
9841
10510
  if (age < this.timings.waitingStageMs) {
9842
10511
  continue;
9843
10512
  }
@@ -9858,6 +10527,11 @@ var Scheduler = class {
9858
10527
  });
9859
10528
  }
9860
10529
  }
10530
+ for (const key of Object.keys(this.state.waitingHeld)) {
10531
+ if (!stillWaiting.has(key)) {
10532
+ delete this.state.waitingHeld[key];
10533
+ }
10534
+ }
9861
10535
  for (const key of Object.keys(this.state.waitingSeen)) {
9862
10536
  if (!stillWaiting.has(key)) {
9863
10537
  delete this.state.waitingSeen[key];
@@ -9923,7 +10597,8 @@ var Scheduler = class {
9923
10597
  const roles = (await this.board.listRoles()).filter((role) => role.name !== USER_ROLE);
9924
10598
  const runners2 = await this.board.listRunners();
9925
10599
  const anywhere = new Set(runners2.filter((runner) => runner.status === "connected").flatMap((runner) => runner.capabilities));
9926
- for (const project of await this.board.listProjects()) {
10600
+ const projects = await this.board.listProjects();
10601
+ for (const project of projects) {
9927
10602
  const slug = project.slug;
9928
10603
  const tasks = await this.board.listTasks(slug);
9929
10604
  const home = runners2.find((runner) => runner.name === project.runner);
@@ -10015,7 +10690,13 @@ var Scheduler = class {
10015
10690
  }
10016
10691
  }
10017
10692
  const crons = (await this.board.listCrons()).filter((cron) => cron.ended === void 0);
10018
- for (const agent of await this.board.listAgents()) {
10693
+ const agents = await this.board.listAgents();
10694
+ for (const name of Object.keys(this.state.idleHeld)) {
10695
+ if (!agents.some((agent) => agent.name === name && agent.status === "active")) {
10696
+ delete this.state.idleHeld[name];
10697
+ }
10698
+ }
10699
+ for (const agent of agents) {
10019
10700
  if (agent.status !== "active" || agent.cli === null) {
10020
10701
  continue;
10021
10702
  }
@@ -10027,7 +10708,12 @@ var Scheduler = class {
10027
10708
  last = Math.max(last, Date.parse(ended));
10028
10709
  }
10029
10710
  }
10030
- const idle = now - last;
10711
+ const held = heldOn(runners2, agent.homeRunner, agent.cli) || projects.some((project) => agent.memberships.includes(project.slug) && heldOn(runners2, project.runner, agent.cli));
10712
+ if (held) {
10713
+ this.state.idleHeld[agent.name] = iso(now);
10714
+ }
10715
+ const heldAt = this.state.idleHeld[agent.name];
10716
+ const idle = now - Math.max(last, heldAt === void 0 ? 0 : Date.parse(heldAt));
10031
10717
  if (idle >= this.timings.idleMemberMs) {
10032
10718
  signals.push({
10033
10719
  kind: "idle_member",
@@ -10249,6 +10935,10 @@ var Scheduler = class {
10249
10935
  this.exclusive.set(key, "onboarding");
10250
10936
  } else if (dispatch.trigger.kind === "reflection") {
10251
10937
  this.exclusive.set(key, "reflection");
10938
+ const early = this.state.earlyReflection[dispatch.agent];
10939
+ if (early?.due === true) {
10940
+ this.state.earlyReflection[dispatch.agent] = { ...early, due: false };
10941
+ }
10252
10942
  }
10253
10943
  }
10254
10944
  }
@@ -10376,20 +11066,21 @@ var Scheduler = class {
10376
11066
  import { randomUUID } from "node:crypto";
10377
11067
 
10378
11068
  // packages/turn-host/dist/render.js
11069
+ import { createHash as createHash3 } from "node:crypto";
10379
11070
  var TURN_CONTRACT = [
10380
11071
  "- Every turn belongs to one conversation, named at the top of your prompt: your home conversation in a project or the society, for its general channel and for work tied to no other channel and no thread; the conversation of one other channel of the place, a workstream such as a release; outside projects, the conversation of one project's channels, general and its workstreams together, for posts there that ask something of you while you are not a member; or the conversation of one thread you take part in, a task's, a proposal's, or a topic's. Each has its own session and its own turns, which may run while this one does. The prompt carries this conversation only: in a thread, its new messages, or the whole thread when the conversation is new, and in a task's thread the task and its stage; in a channel's, a project's, or at home, that channel's, that project's channels', or general's messages that concern you. Read anything else you need with get_task, search, and the board's files, and leave other channels', other threads', and other projects' work to their own turns.",
10381
- "- Act through the board tools (the `board` MCP server). Holding a stage is a lease; every turn that touches the task renews it.",
11072
+ "- Act through the board tools (the `board` MCP server). Holding a stage is a lease; every turn that touches the task renews it, and so does the board while a cron of yours in the task's thread is pending.",
10382
11073
  "- Work only inside your worktree. In a task's conversation it is a worktree of its own on the task's branch `task/<id>`: commit your work there. The branch is checked out in one worktree at a time and kept for the turns of whoever holds the task's stage, so a turn of anyone else there, or one that starts while another turn has the branch, works detached at its tip, which Your workspace then says; its commits go on the branch by a fast-forward when a turn in the task ends with the branch free, and the task's thread is told of commits that no longer build on its tip. After each turn the runner commits what you left on the branch and lets it go for the next holder. Your home worktree is on your own branch, `agent/<name>`, for work tied to no task; nothing lands from it by itself. In a proposal's, a topic's, or a channel's conversation it is a worktree of its own, detached at the project's default branch, which goes when the thread closes or the channel is archived, so work that should last goes on a task's branch. Before each turn the runner fetches the project's remote and brings what holds no work of yours up to the default branch: your own branch when it has no commits of its own, a task's branch nobody has worked on, and a detached worktree with nothing uncommitted; what it leaves where it is, and why, is under Your workspace in the prompt. It never merges or commits for you.",
10383
11074
  "- Posts that would wake this conversation may arrive while you work, delivered into the turn under a heading that says so and formatted like the digest. They are part of this turn: act on them now, after the step in hand, or not at all, as with the digest. The user may also stop a turn; the next turn is told.",
10384
11075
  "- Silence is allowed. If the digest needs no reply, post nothing.",
10385
- "- 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 holding a stage, 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. The society's crons are under society/crons/ in the board projection.",
11076
+ "- To come back to something later, set a cron with create_cron: a note on what to do then, and a five-field cron expression or one time with at. It wakes you in this conversation unless you name another, at most every fifteen minutes, and every fire is a paid turn, so end a cron with remove_cron once it has done its job. Never wait by sleeping or leaving a process running: a turn's board token ends with the turn, so nothing it leaves behind can act on the board. While you check your own stage's work on a schedule, such as a running job, CI, or a deploy, keep holding the stage: the cron sets when you check, and while it is pending in the task's thread, neither ended nor paused, with its latest fire's turn not failed or a turn of yours there completed since, the board keeps your lease and the heartbeat leaves the stage to it, waking you there only for a post you have not read. Release a stage only to hand it on, or once its task's blocked_by names the tasks it waits on: the stage then wakes nobody until the last of them ends. Any other wait on someone else, such as the user or an outside party, is a stage of its own, not a held stage. The society's crons are under society/crons/ in the board projection.",
10386
11077
  "- An @mention wakes the citizen named, and every wake costs a turn. Address someone with @ only when you need them to act; when you merely refer to citizens, write their names plainly. A name in a code span or a code block is no mention, so code keeps its @ as written. @user wakes nobody, since the user takes no turns; it is what puts a question before the user, so use it only when you need the user to answer or decide.",
10387
- "- 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.",
10388
- "- To show an image in a post, such as a screenshot or a chart, save it anywhere you can read it, such as a folder from `mktemp -d`, call upload_image with its path, and put the markdown it returns in the post, `![what it shows](image:<id>)`, with what the image shows as its alt text: the board keeps the image, so it shows in any conversation after the file is gone. It takes a PNG, JPEG, GIF, or WebP of at most 8 MB from your worktree, your home, or the system temp folder. An image committed on a task's branch, linked by its path in the task's worktree, still shows in place, and so does one at an https address. Never put an image or any other file into a message as base64 or a data: URL: the board does not show it, and every reader of the conversation gets it in full in their prompt.",
11078
+ "- A thread is a conversation off a channel that reaches only its participants and whoever is mentioned in it. Every task has one, opened with it under the task's id on the channel of its project it was filed in, general unless it was filed in another: talk about a task there, with post_message and its thread_id, and the notes you give advance_task and update_task are posted there as you. Its participants are the task's creator, its stage holders and assignees, whoever may take the stage that waits, and anyone who posted; it closes when the task ends. In a task's thread, only the current stage's holder, or while nobody holds it whoever may take it, wakes for your post, and a post that asks the user also wakes the task's creator; mention anyone else you need, including the concierge for a plan change. Every proposal has one too, in governance, opened with its pitch and closed by its decision. Open other threads with open_thread on a channel with a title, for any other topic; close_thread posts your summary to its channel.",
11079
+ "- To show an image in a post, such as a screenshot or a chart, save it anywhere you can read it, such as a folder from `mktemp -d`, call upload_image with its path, and put the markdown it returns in the post, `![what it shows](image:<id>)`, with what the image shows as its alt text: the board keeps the image, so it shows in any conversation after the file is gone. It takes a PNG, JPEG, GIF, or WebP of at most 8 MB from your worktree, your home, or the system temp folder. An image committed on a task's branch, linked by its path in the task's worktree, still shows in place, and so does one at an https address. Never put an image or any other file into a message as base64 or a data: URL: the board does not show it, and every reader of the conversation gets it in full in their prompt. To see an image a post, a brief, or a topic shows as `image:<id>`, call read_image with the id: it returns the path of a file of this turn's own that holds the image, removed when the turn ends, which you open with the tool that shows you an image, Read in Claude Code or view_image in Codex.",
10389
11080
  "- Route every lesson: about you, your craft, or the user, write it to memory/core.md in your home directory, and keep that file short by moving detail to memory/<topic>.md, your archive; how a codebase behaves (architecture, conventions, gotchas, how to build, test, and verify it) goes in its AGENTS.md or README, committed on a task's branch like any change to the code, or proposed in the task's thread or the project channel when it is outside the task at hand; a durable fact about a project as a place (runners, access, flow, where results live) goes through `write_knowledge` on that project; something the whole project should know now, post it to the project channel, and anything about one task, in the task's thread.",
10390
11081
  '- A procedure you have followed twice is a candidate for a skill, which it is when it makes a citizen better at a kind of work on any project: write it to skills/<name>/SKILL.md in your home, frontmatter with `name` and a one-line `description` and then the steps, and your skills index lists it from the next turn. Files the steps use, such as scripts/ and references/, go in the same folder, named in SKILL.md by paths relative to it. Propose it with kind "skill" when more than one citizen would use it and nothing in it is tied to one place; a skill only you use stays yours, however many projects you use it on.',
10391
11082
  "- To take a published skill for yourself, install it in a temporary folder and copy it into your home: in a folder from `mktemp -d`, `npx -y skills add <source> --skill <name> -a universal --copy -y` writes `.agents/skills/<name>/` and a lock file there, and you copy that skill folder to skills/<name>/ in your home, named in lowercase letters, digits, and hyphens. Never install one inside a worktree, where it would be committed into the project, or with -g, which writes to the runner's own home, shared by every citizen on it and listed in no skills index.",
10392
- "- Report memoryUpdated: true in the status object whenever you changed memory/core.md or a skill, so a warm session restarts with the new instructions.",
11083
+ "- Report memoryUpdated: true in the status object whenever you changed memory/core.md or a skill. A change to your instructions reaches each of your conversations at its next turn, whose prompt then carries them whole, replacing what that conversation had; a turn already running keeps what it started with.",
10393
11084
  "- Other turns of yours may be running at the same time and share your home. Change a file there with your edit tool, which refuses a file changed since you read it, and never rewrite one whole from an earlier read, with a shell redirect or a script, or you erase what another of your turns wrote.",
10394
11085
  "- To ask the user something, mention @user where the question belongs: the task's thread for a task, the proposal's thread for a proposal, and for anything else a thread you open on your scope's general channel, titled with the question. The user answers in the same thread, and the answer comes back to that thread's conversation. A proposal waiting on the user is already in front of them and needs no mention.",
10395
11086
  "- End every turn with the status object: summary, claims held, what is blocked, and whether the user must decide. If you report that the user must decide, mentioned @user nowhere this turn, and proposed nothing for the user to decide, the runner asks in this turn's thread when it has one, and otherwise opens a thread on your scope's general channel with your summary as the question."
@@ -10398,9 +11089,9 @@ var PLANNING = [
10398
11089
  "- A task is a plan of stages between open and done. Hold the current stage with claim_task and finish it with advance_task; the next stage becomes current, and past the last one the task is done.",
10399
11090
  "- Name stages by what gets done. Plan the next few steps rather than everything, and reshape with plan_task as the work teaches you.",
10400
11091
  "- Assign a stage to a role when anyone in it could do it, to a citizen only when it must be them, and to nobody when anyone in the project could. A citizen named on a stage must be a member of the task's project; add them first. A stage wakes everyone it is open to when it comes up, so the narrower the assignee, the fewer turns it costs.",
10401
- "- 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.",
10402
- "- A task that cannot start until other tasks finish names them with update_task blocked_by. While any of them is in play, its stage 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.",
10403
- "- 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.",
11092
+ "- Reshape rather than force: another round is an inserted stage, and a wait on something outside the society is a stage named for what it waits on. When a check finds work unfinished, the holder of the current stage sends the task back to an earlier stage with update_task, and so may the user, the steward, and the concierge; anyone else asks the concierge in the task's thread, with a mention.",
11093
+ "- A task that cannot start until other tasks finish names them with update_task blocked_by, and whoever holds its stage then releases it, since the heartbeat still wakes a holder. While any of them is in play, a stage nobody holds wakes nobody, neither when it comes up nor on a heartbeat; once the last has ended, done or abandoned, the stage wakes as if it had just come up, or wakes its holder. Nobody needs to clear the block or mention anyone when they end; change blocked_by only when the plan changes.",
11094
+ "- A gated stage is an independent check: nobody who held an earlier stage of the task may hold it, and only the user, the steward, and the concierge may add, remove, move, reassign, or ungate it. Ask them in the task's thread, with a mention, when a gate should change.",
10404
11095
  "- Commit your work on the task's branch before you advance, and say what you did in the advance note; it is the handover the next holder reads in the task's thread."
10405
11096
  ].join("\n");
10406
11097
  var GOVERNANCE = [
@@ -10459,6 +11150,10 @@ function renderInstructions(input) {
10459
11150
  return `${sections.join("\n")}
10460
11151
  `;
10461
11152
  }
11153
+ function instructionsHash(input) {
11154
+ const { onboarding: _first, ...kept } = input;
11155
+ return createHash3("sha256").update(renderInstructions(kept)).digest("hex");
11156
+ }
10462
11157
 
10463
11158
  // packages/turn-host/dist/prompt.js
10464
11159
  var REFLECTION_INTRO = "This turn is for your memory; take no new work, and answer unread messages only where a reply is needed. Read memory/core.md and your recent turn records, then:";
@@ -10467,9 +11162,11 @@ var REFLECTION_STEPS = [
10467
11162
  "- Consolidate memory/core.md: keep it to lessons that still hold, about sixty lines at most, and move detail worth keeping to memory/<topic>.md in your home, which the board's search covers for you alone.",
10468
11163
  '- Extract a skill: a procedure you have followed twice is a candidate, and one that makes a citizen better at a kind of work on any project goes to skills/<name>/SKILL.md, frontmatter with `name` and a one-line `description` and then the steps, with any script or reference it uses in the same folder. Propose it with kind "skill" when more than one citizen would use it and nothing in it is tied to one place; one only you use stays yours.',
10469
11164
  "- Refresh profile.md: one paragraph on what you do well and what to send your way; the roster the front desk routes with is built from it.",
10470
- "- Share: how a codebase behaves goes in its AGENTS.md or README through a task like any change to the code, so propose it in that task's thread or the project channel; a durable fact about this scope as a place (runners, access, flow, where results live) goes through write_knowledge; a norm the whole society should follow goes to the steward as a plain post in general.",
10471
- "- Report memoryUpdated: true in the status object when core.md or a skill changed."
11165
+ "- Share: how a codebase behaves goes in its AGENTS.md or README through a task like any change to the code, so propose it in that task's thread or the project channel; a durable fact about this scope as a place (runners, access, flow, where results live) goes through write_knowledge; a norm the whole society should follow goes to the steward as a plain post in general."
10472
11166
  ];
11167
+ var SAVE_WHAT_IS_OPEN = "- This conversation's session ends with this turn, and the next turn here starts a new one that knows only your home's files and the board: write anything still open into memory/core.md, or the archive it points to, before you end.";
11168
+ var REFLECTION_REPORT = "- Report memoryUpdated: true in the status object when core.md or a skill changed.";
11169
+ var INSTRUCTIONS_CHANGED = "Your instructions changed since this conversation last received them. Between the tags below are the whole of them now: they replace the instructions this conversation began with and any earlier text like this, so where the two differ, follow these, and a rule missing from them no longer applies.";
10473
11170
  var MAX_BODY_CHARS = 1500;
10474
11171
  var MAX_PROFILE_CHARS = 200;
10475
11172
  function clip(text) {
@@ -10524,12 +11221,19 @@ function quotaPhrase(cli, quota, now) {
10524
11221
  }
10525
11222
  function runnerLine(runner, projects, now) {
10526
11223
  const living = projects.filter((project) => project.runner === runner.name).map((project) => project.slug);
10527
- const state = runner.status === "connected" ? "connected" : `away${runner.lastSeen === void 0 ? "" : ` since ${runner.lastSeen}`}`;
11224
+ const state = [
11225
+ runner.status === "connected" ? "connected" : `away${runner.lastSeen === void 0 ? "" : ` since ${runner.lastSeen}`}`,
11226
+ ...runner.paused === void 0 ? [] : [`paused by the user since ${runner.paused.at}`]
11227
+ ].join(", ");
11228
+ const pauses = runner.clis.flatMap((cli) => {
11229
+ const pause = runner.pausedClis?.[cli];
11230
+ return pause === void 0 ? [] : [`${cli} paused by the user since ${pause.at}`];
11231
+ });
10528
11232
  const quotas = runner.clis.flatMap((cli) => {
10529
11233
  const quota = runner.quotas?.[cli];
10530
11234
  return quota === void 0 ? [] : [quotaPhrase(cli, quota, now)];
10531
11235
  });
10532
- return `- ${runner.name}: ${state}; ${runner.os}; CLIs ${runner.clis.length === 0 ? "none" : runner.clis.join(", ")}; offers ${runner.capabilities.length === 0 ? "nothing beyond its CLIs" : runner.capabilities.join(", ")}; ${living.length === 0 ? "no projects" : `projects ${living.join(", ")}`}${quotas.length === 0 ? "" : `; quota ${quotas.join("; ")}`}`;
11236
+ return `- ${runner.name}: ${state}; ${runner.os}; CLIs ${runner.clis.length === 0 ? "none" : runner.clis.join(", ")}; offers ${runner.capabilities.length === 0 ? "nothing beyond its CLIs" : runner.capabilities.join(", ")}; ${living.length === 0 ? "no projects" : `projects ${living.join(", ")}`}${pauses.map((pause) => `; ${pause}`).join("")}${quotas.length === 0 ? "" : `; quota ${quotas.join("; ")}`}`;
10533
11237
  }
10534
11238
  function profileLine(profile) {
10535
11239
  const line = profile.split("\n").map((entry) => entry.trim()).find((entry) => entry.length > 0 && !entry.startsWith("#"));
@@ -10566,7 +11270,7 @@ function messageLines(messages, threads) {
10566
11270
  function cronLines(cron, timeZone) {
10567
11271
  const previous = cron.lastRun === void 0 ? "" : ` The turn of its previous fire ${cron.lastRun.outcome === "stopped" ? "was stopped by the user" : cron.lastRun.outcome} at ${cron.lastRun.at}.`;
10568
11272
  const next = nextFire(cron, timeZone);
10569
- const state = isOneTime(cron.schedule) ? " It fired once, as set, and has ended." : cron.ended !== void 0 ? ` ${cron.ended.by} has ended it since it fired (${cron.ended.reason}), so do what it asks only if it still matters.` : next === null ? "" : ` Next it fires at ${next.toISOString()}.`;
11273
+ const state = isOneTime(cron.schedule) ? " It fired once, as set, and has ended." : cron.ended !== void 0 ? ` ${cron.ended.by} has ended it since it fired (${cron.ended.reason}), so do what it asks only if it still matters.` : cron.paused !== void 0 ? ` ${cron.paused.by} has paused it since it fired, so it fires no more until resumed.` : next === null ? "" : ` Next it fires at ${next.toISOString()}.`;
10570
11274
  return [
10571
11275
  `- "${cron.title}" (cron ${cron.id}), set by ${cron.createdBy} at ${cron.createdAt}, fires ${describeSchedule(cron.schedule, timeZone)}.${previous}${state}`,
10572
11276
  "",
@@ -10620,6 +11324,10 @@ function buildTurnPrompt(input) {
10620
11324
  if (dispatch.trigger.kind === "user_post") {
10621
11325
  lines.push("The user posted. Route it: answer where it was posted, in its thread when it came in one, or create what it needs: a task with a plan whose stages name who does them, a thread, or a project, adding citizens to a project first when they are not members. Stay silent when the user already addressed a citizen and nothing else is needed.");
10622
11326
  }
11327
+ const changed = input.changedInstructions ?? null;
11328
+ if (changed !== null) {
11329
+ lines.push("", "## Your instructions changed", "", INSTRUCTIONS_CHANGED, "", "<instructions>", changed.trimEnd(), "</instructions>");
11330
+ }
10623
11331
  const conversation = input.conversation ?? null;
10624
11332
  lines.push("", "## This conversation", "");
10625
11333
  if (conversation === null && dispatch.channel !== void 0) {
@@ -10654,7 +11362,7 @@ function buildTurnPrompt(input) {
10654
11362
  lines.push("", WORKSPACE_TOKEN);
10655
11363
  const conflicts = input.conflicts ?? [];
10656
11364
  if (dispatch.trigger.kind === "reflection") {
10657
- lines.push("", "## Reflection", "", REFLECTION_INTRO, ...conflicts.length > 0 ? [RECONCILE] : [], ...REFLECTION_STEPS);
11365
+ lines.push("", "## Reflection", "", REFLECTION_INTRO, ...conflicts.length > 0 ? [RECONCILE] : [], ...REFLECTION_STEPS, ...input.afreshAfterReflection === true ? [SAVE_WHAT_IS_OPEN] : [], REFLECTION_REPORT);
10658
11366
  }
10659
11367
  if (input.onboarding !== null) {
10660
11368
  lines.push("", "## First turn", "", renderOnboardingPreamble(input.onboarding));
@@ -10668,6 +11376,14 @@ function buildTurnPrompt(input) {
10668
11376
  "Its CLI could not resume this conversation's session, so the session was dropped and this turn starts a new one: nothing of your earlier turns here carries over but what is on the board and in your home."
10669
11377
  ] : [], "The worktree may hold uncommitted changes. Run git status before doing anything else.");
10670
11378
  }
11379
+ const failed = input.failedCrons ?? [];
11380
+ if (failed.length > 0) {
11381
+ const timeZone = input.clock?.timeZone ?? DEFAULT_TIME_ZONE;
11382
+ lines.push("", "## Cron fires that did not finish", "", failed.length === 1 ? "The turn of this cron's latest fire here failed, so what its note asks may not be done:" : "The turns of these crons' latest fires here failed, so what their notes ask may not be done:", "", ...failed.flatMap((cron) => cronLines(cron, timeZone)));
11383
+ }
11384
+ if (last?.trigger.kind === "reflection" && last.exitReason === "completed" && last.sessionDropped === true) {
11385
+ lines.push("", "## Your session started afresh", "", "Your last turn here was a reflection, and this conversation's session ended with it, so this turn starts a new one: what you kept is in memory/core.md and the archive it points to, and what your earlier turns did is on the board, in your posts and the threads and tasks you worked in, which search and the board's files reach.");
11386
+ }
10671
11387
  const society = input.societyView;
10672
11388
  if (society !== void 0 && society !== null) {
10673
11389
  lines.push("", "## The society", "", `Each ask is a conversation of its own, and others may be in a turn at the same time. Before proposing a citizen, a role, or a project, look at the proposals still waiting under ${PATH_TOKENS.board}/society/proposals/: another ask may have proposed it already.`, "", "### Projects", "");
@@ -10684,7 +11400,7 @@ function buildTurnPrompt(input) {
10684
11400
  }
10685
11401
  const machines = input.runners;
10686
11402
  if (machines !== void 0 && machines !== null) {
10687
- 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 never moves. A task's required_capabilities must name capabilities exactly as a runner below offers them; a task needing one its project's runner lacks waits. A runner's quota is what each CLI's login there last reported of its plan, in percent of each window; a CLI limited until a time fails its turns there until then.", "");
11403
+ lines.push("", "## Runners", "", "The machines turns run on. A project lives on the runner that takes its first turn, one that offers every capability the project and that turn's task require, and moves only when the user moves it. A task's required_capabilities must name capabilities exactly as a runner below offers them; a task needing one its project's runner lacks waits. A runner's quota is what each CLI's login there last reported of its plan, in percent of each window; a CLI limited until a time fails its turns there until then. A runner, or a CLI on it, paused by the user starts none of the turns it would run until the user resumes it: they wait, and the stages their citizens hold keep their leases.", "");
10688
11404
  if (machines.runners.length === 0) {
10689
11405
  lines.push("None registered yet.");
10690
11406
  }
@@ -10907,7 +11623,10 @@ var TurnHost = class {
10907
11623
  const newSession = recorded === void 0;
10908
11624
  const lastTurn = await this.board.readLastTurn(agent.name, dispatch.project, part);
10909
11625
  const costSoFarUsd = !newSession && lastTurn?.session === recorded ? lastTurn.sessionCostUsd ?? lastTurn.costUsd : 0;
11626
+ const usageSoFar = !newSession && lastTurn?.session === recorded ? lastTurn.sessionUsage : void 0;
10910
11627
  const resumeRetries = !newSession && lastTurn?.session === recorded && lastTurn.resumeFailed === "retry" ? lastTurn.resumeRetries ?? 1 : 0;
11628
+ const afreshAfterReflection = charter.societyScope && agent.role !== USER_ROLE && societyScope && part === void 0;
11629
+ const afterReflection = lastTurn?.trigger.kind === "reflection" && lastTurn.sessionDropped === true;
10911
11630
  const digest = await this.board.readDigest({
10912
11631
  ...actor,
10913
11632
  scope: dispatch.project,
@@ -10919,14 +11638,14 @@ var TurnHost = class {
10919
11638
  const messages = threadSoFar ?? digest.messages;
10920
11639
  const cursorAfter = threadSoFar?.at(-1)?.id ?? digest.cursor;
10921
11640
  const held = task === null ? [] : (await this.board.heldClaims(agent.name)).filter((each) => each.id === task.id);
10922
- const onboarding = dispatch.onboarding || newSession && part === void 0 ? {
11641
+ const onboarding = dispatch.onboarding || newSession && part === void 0 && !afterReflection ? {
10923
11642
  agentName: agent.name,
10924
11643
  roleSummary: charter.purpose,
10925
11644
  project: dispatch.project,
10926
11645
  worktree: PATH_TOKENS.worktree,
10927
11646
  otherChannels: (project?.channels ?? []).map((name) => channelRef(dispatch.project, name)).filter((ref) => !agent.subscriptions.includes(ref))
10928
11647
  } : null;
10929
- const instructions = renderInstructions({
11648
+ const rendered = {
10930
11649
  agentName: agent.name,
10931
11650
  roleCharter: await this.board.readAgentRoleBody(agent.name),
10932
11651
  memoryCore: await this.board.readMemoryCore(agent.name),
@@ -10944,7 +11663,11 @@ var TurnHost = class {
10944
11663
  }))
10945
11664
  ],
10946
11665
  ...onboarding === null ? {} : { onboarding }
10947
- });
11666
+ };
11667
+ const instructions = renderInstructions(rendered);
11668
+ const instructionsNow = instructionsHash(rendered);
11669
+ const instructionsHeld = !newSession && lastTurn?.session === recorded ? lastTurn.sessionInstructionsHash : void 0;
11670
+ const changedInstructions = !newSession && instructionsHeld !== instructionsNow;
10948
11671
  const societyView = charter.wakeTriggers.includes(USER_POST_TRIGGER2) ? {
10949
11672
  projects: (await this.board.listProjects()).filter((each) => each.archived === void 0),
10950
11673
  members: await this.board.listMembers()
@@ -10985,6 +11708,11 @@ var TurnHost = class {
10985
11708
  crons.push(cron);
10986
11709
  }
10987
11710
  }
11711
+ const failedCrons = (await this.board.listCrons()).filter((cron) => cron.agent === agent.name && cron.scope === dispatch.project && conversationPart(cron) === part && cronFailure(cron) !== null && !(dispatch.crons ?? []).includes(cron.id));
11712
+ const cronFailures = failedCrons.flatMap((cron) => {
11713
+ const failure = cronFailure(cron);
11714
+ return failure === null ? [] : [{ cron: cron.id, turnId: failure.turnId }];
11715
+ });
10988
11716
  const prompt = buildTurnPrompt({
10989
11717
  dispatch,
10990
11718
  clock: { now: this.now(), timeZone: society.timezone },
@@ -10997,7 +11725,10 @@ var TurnHost = class {
10997
11725
  waitingStages: waiting,
10998
11726
  project,
10999
11727
  lastTurn,
11728
+ failedCrons,
11729
+ ...changedInstructions ? { changedInstructions: instructions } : {},
11000
11730
  onboarding,
11731
+ afreshAfterReflection,
11001
11732
  societyView,
11002
11733
  runners: runners2,
11003
11734
  knowledge,
@@ -11010,7 +11741,10 @@ var TurnHost = class {
11010
11741
  const record = await this.board.beginTurn({
11011
11742
  ...base,
11012
11743
  session: recorded ?? null,
11013
- sessionCostUsd: costSoFarUsd
11744
+ sessionCostUsd: costSoFarUsd,
11745
+ ...usageSoFar === void 0 ? {} : { sessionUsage: usageSoFar },
11746
+ ...instructionsHeld === void 0 ? {} : { sessionInstructionsHash: instructionsHeld },
11747
+ ...cronFailures.length === 0 ? {} : { cronFailures }
11014
11748
  });
11015
11749
  if (record.id === void 0) {
11016
11750
  throw new Error("the board gave the turn no id");
@@ -11028,7 +11762,9 @@ var TurnHost = class {
11028
11762
  ...place === void 0 ? {} : { place },
11029
11763
  session: recorded ?? null,
11030
11764
  costSoFarUsd,
11765
+ ...usageSoFar === void 0 ? {} : { usageSoFar },
11031
11766
  instructions,
11767
+ instructionsHash: instructionsNow,
11032
11768
  prompt,
11033
11769
  mcp: { url: this.mcpUrl, token },
11034
11770
  limits: { timeoutMs: this.turnTimeoutMs, maxTurns: this.maxTurns },
@@ -11052,11 +11788,13 @@ var TurnHost = class {
11052
11788
  stopRequestedBy: null,
11053
11789
  held: held.map((each) => each.id),
11054
11790
  costSoFarUsd,
11791
+ instructionsNow,
11055
11792
  resumeRetries,
11056
11793
  configuredModel: agent.model ?? null,
11057
11794
  transcript: [],
11058
11795
  coldToken: residentKey === null ? token : null,
11059
11796
  residentKey: residentKey === null ? null : `${runner.name}|${residentKey}`,
11797
+ afreshAfterReflection,
11060
11798
  keepLeases,
11061
11799
  done,
11062
11800
  fail
@@ -11220,8 +11958,13 @@ var TurnHost = class {
11220
11958
  const part = conversationPart(job);
11221
11959
  const resumeRetries = outcome.resumeFailed === "retry" ? turn.resumeRetries + 1 : void 0;
11222
11960
  const dropSession = job.session !== null && (outcome.resumeFailed === "lost" || (resumeRetries ?? 0) >= RESUME_RETRIES);
11961
+ const reflected = turn.afreshAfterReflection && turn.record.trigger.kind === "reflection" && outcome.exitReason === "completed";
11962
+ const { sessionUsage: _started, sessionInstructionsHash: heldBefore, ...begun } = turn.record;
11963
+ const promptDelivered = outcome.exitReason === "completed" || outcome.exitReason === "blocked" || outcome.exitReason === "stopped";
11964
+ const instructionsTaken = outcome.exitReason === "stopped" ? outcome.contextTokens !== void 0 : promptDelivered;
11965
+ const instructionsHeld = job.session === null ? outcome.session === null ? void 0 : turn.instructionsNow : instructionsTaken ? turn.instructionsNow : heldBefore;
11223
11966
  const finished = {
11224
- ...turn.record,
11967
+ ...begun,
11225
11968
  session: outcome.session,
11226
11969
  endedAt: this.now().toISOString(),
11227
11970
  exitReason: outcome.exitReason,
@@ -11230,6 +11973,9 @@ var TurnHost = class {
11230
11973
  usage: outcome.usage,
11231
11974
  costUsd: outcome.costUsd,
11232
11975
  sessionCostUsd: outcome.sessionCostUsd ?? turn.costSoFarUsd,
11976
+ ...outcome.sessionUsage === void 0 ? {} : { sessionUsage: outcome.sessionUsage },
11977
+ ...instructionsHeld === void 0 ? {} : { sessionInstructionsHash: instructionsHeld },
11978
+ ...outcome.contextTokens === void 0 ? {} : { contextTokens: outcome.contextTokens },
11233
11979
  toolCalls: turn.transcript.filter((entry) => entry.event.type === "tool_call").length,
11234
11980
  model: outcome.model ?? turn.configuredModel,
11235
11981
  ...outcome.work === null ? {} : { work: outcome.work },
@@ -11237,7 +11983,7 @@ var TurnHost = class {
11237
11983
  ...outcome.resumeFailed === void 0 ? {} : { resumeFailed: outcome.resumeFailed },
11238
11984
  ...outcome.transient === true ? { transient: true } : {},
11239
11985
  ...resumeRetries === void 0 ? {} : { resumeRetries },
11240
- ...dropSession ? { sessionDropped: true } : {}
11986
+ ...dropSession || reflected ? { sessionDropped: true } : {}
11241
11987
  };
11242
11988
  const ended = await this.conversationEnded(job);
11243
11989
  const closing = turn.record.trigger.kind === "closing";
@@ -11255,10 +12001,15 @@ var TurnHost = class {
11255
12001
  resumeFailed: outcome.resumeFailed,
11256
12002
  resumeRetries
11257
12003
  }, "dropped a session its CLI could not resume");
12004
+ } else if (reflected) {
12005
+ if (job.session !== null) {
12006
+ await this.board.dropSession(job.agent, job.scope, job.cli, job.session, part);
12007
+ }
12008
+ this.log.info({ agent: job.agent, project: job.scope, session: outcome.session }, "dropped a home session after its reflection");
11258
12009
  } else if (outcome.session !== null && outcome.session !== job.session) {
11259
12010
  await this.board.writeSession(job.agent, job.scope, job.cli, outcome.session, turn.runner, part);
11260
12011
  }
11261
- if (outcome.exitReason === "completed" || outcome.exitReason === "blocked" || outcome.exitReason === "stopped") {
12012
+ if (promptDelivered) {
11262
12013
  await this.board.setDigestCursor(job.agent, job.scope, turn.delivered, part);
11263
12014
  await this.renewLeases(turn.actor, turn.held);
11264
12015
  if (outcome.status?.needsUserDecision === true) {
@@ -11550,6 +12301,8 @@ var RunnerProtocolError = class extends Error {
11550
12301
  };
11551
12302
  var RunnerAwayError = class extends Error {
11552
12303
  };
12304
+ var MoveSourceAwayError = class extends Error {
12305
+ };
11553
12306
  var SILENT2 = { info() {
11554
12307
  }, warn() {
11555
12308
  }, error() {
@@ -11582,6 +12335,12 @@ var RunnerHub = class {
11582
12335
  /** Disconnects being recorded, which `close` waits for. */
11583
12336
  detaching = /* @__PURE__ */ new Set();
11584
12337
  runnerListeners = /* @__PURE__ */ new Set();
12338
+ /** The project being moved, by slug: none of its turns is placed and nothing lands there until it ends. */
12339
+ moving = /* @__PURE__ */ new Map();
12340
+ /** Turns of each project placed on a runner and not yet opened, which the board cannot see yet. */
12341
+ starting = /* @__PURE__ */ new Map();
12342
+ /** Moves started since the hub did, so a placement that a move overtook gives its turn back. */
12343
+ moveStarts = 0;
11585
12344
  constructor(options) {
11586
12345
  this.board = options.board;
11587
12346
  this.host = options.host;
@@ -11853,6 +12612,7 @@ var RunnerHub = class {
11853
12612
  return accepted ? "sent" : "refused";
11854
12613
  }
11855
12614
  async assign(dispatch) {
12615
+ const starts = this.moveStarts;
11856
12616
  const agent = await this.board.readAgent(dispatch.agent).catch(() => null);
11857
12617
  if (agent === null) {
11858
12618
  return { refused: `no agent ${dispatch.agent}` };
@@ -11864,11 +12624,15 @@ var RunnerHub = class {
11864
12624
  return { refused: `${agent.name} has no CLI binding` };
11865
12625
  }
11866
12626
  const cli = agent.cli;
11867
- const free = (seat) => seat.send !== null && !seat.unconfirmed && seat.hello.clis.includes(cli) && (seat.hello.slots === null || seat.inUse < seat.hello.slots);
12627
+ const runners2 = await this.board.listRunners();
12628
+ const free = (seat) => seat.send !== null && !seat.unconfirmed && seat.hello.clis.includes(cli) && (seat.hello.slots === null || seat.inUse < seat.hello.slots) && !heldOn(runners2, seat.name, cli);
11868
12629
  let chosen;
11869
12630
  if (dispatch.project === SOCIETY_SCOPE) {
11870
12631
  chosen = await this.pinnedSeat(agent.name, agent.homeRunner, cli, free);
11871
12632
  } else {
12633
+ if (this.moving.has(dispatch.project)) {
12634
+ return null;
12635
+ }
11872
12636
  const project = await this.board.readProject(dispatch.project);
11873
12637
  let home = project.runner;
11874
12638
  if (home === void 0) {
@@ -11895,7 +12659,13 @@ var RunnerHub = class {
11895
12659
  if (chosen === void 0) {
11896
12660
  return null;
11897
12661
  }
12662
+ if (dispatch.project !== SOCIETY_SCOPE && (this.moving.has(dispatch.project) || this.moveStarts !== starts)) {
12663
+ return null;
12664
+ }
11898
12665
  chosen.inUse += 1;
12666
+ if (dispatch.project !== SOCIETY_SCOPE) {
12667
+ this.starting.set(dispatch.project, (this.starting.get(dispatch.project) ?? 0) + 1);
12668
+ }
11899
12669
  return { runner: chosen.name };
11900
12670
  }
11901
12671
  async runTurn(dispatch, assignment) {
@@ -11904,11 +12674,23 @@ var RunnerHub = class {
11904
12674
  }
11905
12675
  const seat = this.seats.get(assignment.runner);
11906
12676
  let turnId = null;
12677
+ let placed = dispatch.project !== SOCIETY_SCOPE;
12678
+ const opening = () => {
12679
+ if (placed) {
12680
+ placed = false;
12681
+ const left = (this.starting.get(dispatch.project) ?? 1) - 1;
12682
+ if (left > 0) {
12683
+ this.starting.set(dispatch.project, left);
12684
+ } else {
12685
+ this.starting.delete(dispatch.project);
12686
+ }
12687
+ }
12688
+ };
11907
12689
  try {
11908
12690
  if (seat === void 0) {
11909
12691
  return await this.host.refuseTurn(dispatch, assignment.runner, "the runner is gone");
11910
12692
  }
11911
- const opened = await this.host.openTurn(dispatch, this.seatView(seat));
12693
+ const opened = await this.host.openTurn(dispatch, this.seatView(seat)).finally(opening);
11912
12694
  if ("failed" in opened) {
11913
12695
  return opened.failed;
11914
12696
  }
@@ -11920,6 +12702,7 @@ var RunnerHub = class {
11920
12702
  turnId = opened.job.turnId;
11921
12703
  return await opened.ended;
11922
12704
  } finally {
12705
+ opening();
11923
12706
  if (seat !== void 0) {
11924
12707
  seat.inUse = Math.max(0, seat.inUse - 1);
11925
12708
  if (turnId !== null) {
@@ -11952,6 +12735,9 @@ var RunnerHub = class {
11952
12735
  });
11953
12736
  return "done";
11954
12737
  }
12738
+ if (this.moving.has(project)) {
12739
+ return "deferred";
12740
+ }
11955
12741
  let outcome = { ok: true, detail: "nothing to land, since the task left no branch" };
11956
12742
  if (record.runner !== void 0) {
11957
12743
  const seat = this.seats.get(record.runner);
@@ -12004,6 +12790,9 @@ var RunnerHub = class {
12004
12790
  if (task.status !== "abandoned" || task.onDone !== "ghpr" || pull === void 0 || pull.closed !== void 0) {
12005
12791
  return "done";
12006
12792
  }
12793
+ if (this.moving.has(project)) {
12794
+ return "deferred";
12795
+ }
12007
12796
  const record = await this.board.readProject(project);
12008
12797
  let outcome = {
12009
12798
  ok: false,
@@ -12036,16 +12825,172 @@ var RunnerHub = class {
12036
12825
  });
12037
12826
  return "done";
12038
12827
  }
12828
+ /**
12829
+ * Moves a project to another runner, which only the user does, carrying every branch and tag of
12830
+ * its repository: the project's turns, landings, and pull-request closes wait while it runs.
12831
+ * The old runner pushes to a repository on the server once no worktree of the project holds work
12832
+ * no branch does, the new runner clones it, and the board records the new runner only once that
12833
+ * runner holds every branch and tag at the commit the old one had; until then the project works
12834
+ * where it was. The server's repository goes as the move ends, and the old runner then removes
12835
+ * its copy. With the old runner away nothing can travel, so the move goes ahead only when the
12836
+ * user says `withoutBranches`, and the branches stay there.
12837
+ */
12838
+ async moveProject(actor, slug, to, options = {}) {
12839
+ const under = [...this.moving.keys()];
12840
+ if (under.length > 0) {
12841
+ throw new BoardError("INVALID_STATE", `a move of ${under.join(", ")} is under way; try again once it ends`);
12842
+ }
12843
+ const move = { id: null, from: null, to, phase: "checking" };
12844
+ this.moving.set(slug, move);
12845
+ this.moveStarts += 1;
12846
+ let source;
12847
+ let moved;
12848
+ try {
12849
+ if ((this.starting.get(slug) ?? 0) > 0) {
12850
+ throw new BoardError("INVALID_STATE", `a turn of ${slug} is starting; try again once it has started and ended`);
12851
+ }
12852
+ const { project, inPlay: inPlay2 } = await this.board.prepareProjectMove(actor, slug, to);
12853
+ const from = project.runner;
12854
+ move.from = from;
12855
+ const target = this.seats.get(to);
12856
+ if (target === void 0 || target.send === null) {
12857
+ throw new BoardError("INVALID_STATE", `runner ${to} is not connected`);
12858
+ }
12859
+ await this.assertCanHost(target, project, inPlay2);
12860
+ source = this.seats.get(from);
12861
+ if (source === void 0 || source.send === null) {
12862
+ if (options.withoutBranches !== true) {
12863
+ throw new MoveSourceAwayError(sourceAwayMessage(project, from, to, inPlay2));
12864
+ }
12865
+ move.phase = "recording";
12866
+ const recorded2 = await this.board.recordProjectMove(actor, {
12867
+ slug,
12868
+ from,
12869
+ to,
12870
+ carried: null
12871
+ });
12872
+ this.log.info({ project: slug, from, to }, "project moved without its branches");
12873
+ return { project: recorded2, carried: null, released: null };
12874
+ }
12875
+ if (options.withoutBranches === true) {
12876
+ throw new BoardError("INVALID_STATE", `runner ${from}, where ${slug} lives, is connected, so the move carries its branches`);
12877
+ }
12878
+ if (!source.hello.projectMoves) {
12879
+ throw new BoardError("INVALID_STATE", `runner ${from}, where ${slug} lives, cannot carry a project yet; upgrade it`);
12880
+ }
12881
+ const repo = projectRepo(project);
12882
+ move.id = await this.board.openMoveRepo();
12883
+ const id = move.id;
12884
+ move.phase = "export";
12885
+ const exported = ProjectExportedSchema.parse(await this.request(source, (request) => ({ type: "export", request, export: { move: id, repo } }), moveTransferMs(MOVE_PACK_LIMIT_BYTES) + this.requestTimeoutMs).catch((error) => {
12886
+ throw new BoardError("INVALID_STATE", `${from} could not send ${slug}: ${describe(error)}`);
12887
+ }));
12888
+ if (!exported.ok) {
12889
+ throw new BoardError("INVALID_STATE", exported.held.length === 0 ? `${slug}'s repository on ${from} is ${mebibytes(exported.bytes)}, more than a move carries (${mebibytes(MOVE_PACK_LIMIT_BYTES)})` : `${slug} cannot be moved while work no branch holds sits in its worktrees on ${from}: ${exported.held.map(heldLine).join("; ")}. Have each citizen commit it or clear it, then move again`);
12890
+ }
12891
+ move.phase = "import";
12892
+ const imported = ProjectImportedSchema.parse(await this.request(target, (request) => ({
12893
+ type: "import",
12894
+ request,
12895
+ import: {
12896
+ move: id,
12897
+ repo,
12898
+ pullRequests: project.onDone === "ghpr",
12899
+ bytes: exported.bytes
12900
+ }
12901
+ }), moveTransferMs(exported.bytes) + this.requestTimeoutMs).catch((error) => {
12902
+ throw new BoardError("INVALID_STATE", `${to} could not take ${slug}: ${describe(error)}`);
12903
+ }));
12904
+ move.phase = "recording";
12905
+ const differ = differentRefs(exported.refs, imported.refs);
12906
+ if (differ.length > 0) {
12907
+ await this.releaseOn(target, slug);
12908
+ throw new BoardError("INVALID_STATE", `${slug} arrived on ${to} unlike it left ${from} (${differ.slice(0, 5).join(", ")}${differ.length > 5 ? ", \u2026" : ""}); it stays on ${from}`);
12909
+ }
12910
+ const carried = Object.keys(exported.refs).length;
12911
+ const recorded = await this.board.recordProjectMove(actor, { slug, from, to, carried }).catch(async (error) => {
12912
+ await this.releaseOn(target, slug);
12913
+ throw error;
12914
+ });
12915
+ this.log.info({ project: slug, from, to, carried }, "project moved");
12916
+ moved = { project: recorded, carried };
12917
+ } finally {
12918
+ this.moving.delete(slug);
12919
+ if (move.id !== null) {
12920
+ const id = move.id;
12921
+ await this.board.dropMoveRepo(id).catch((error) => {
12922
+ this.log.warn({ move: id, error: String(error) }, "could not remove a move's repository");
12923
+ });
12924
+ }
12925
+ }
12926
+ return { ...moved, released: await this.releaseOn(source, slug) };
12927
+ }
12928
+ /**
12929
+ * Whether a runner may reach a move's repository on the server: the old runner to push while it
12930
+ * exports, the new one to fetch while it imports. `unknown` for a move that is not under way.
12931
+ */
12932
+ moveAccess(runner, id, push) {
12933
+ const move = [...this.moving.values()].find((each) => each.id !== null && each.id === id);
12934
+ if (move === void 0) {
12935
+ return "unknown";
12936
+ }
12937
+ const allowed = push ? move.phase === "export" && runner === move.from : move.phase === "import" && runner === move.to;
12938
+ return allowed ? "allowed" : "refused";
12939
+ }
12940
+ /** Refuses a runner that cannot take a project's turns: its members' CLIs, the capabilities, a git identity. */
12941
+ async assertCanHost(seat, project, inPlay2) {
12942
+ if (!seat.hello.projectMoves) {
12943
+ throw new BoardError("INVALID_STATE", `runner ${seat.name} cannot take a project yet; upgrade it`);
12944
+ }
12945
+ const lacking = [];
12946
+ for (const name of project.members) {
12947
+ const agent = await this.board.readAgent(name).catch(() => null);
12948
+ if (agent !== null && agent.status === "active" && agent.cli !== null && !seat.hello.clis.includes(agent.cli)) {
12949
+ lacking.push(`${agent.cli} for ${agent.name}`);
12950
+ }
12951
+ }
12952
+ const needs = /* @__PURE__ */ new Set([
12953
+ ...project.requiredCapabilities,
12954
+ ...inPlay2.flatMap((task) => task.requiredCapabilities)
12955
+ ]);
12956
+ const missing = [...needs].filter((each) => !seat.hello.capabilities.includes(each));
12957
+ if (missing.length > 0) {
12958
+ lacking.push(`the ${missing.length === 1 ? "capability" : "capabilities"} ${missing.join(", ")}`);
12959
+ }
12960
+ const identity = seat.hello.gitIdentityMissing ?? [];
12961
+ if (identity.length > 0) {
12962
+ lacking.push(`a git ${identity.join(" and ")} for citizens' commits`);
12963
+ }
12964
+ if (lacking.length > 0) {
12965
+ throw new BoardError("INVALID_STATE", `runner ${seat.name} cannot take ${project.slug}: it lacks ${lacking.join("; ")}`);
12966
+ }
12967
+ }
12968
+ /** Asks a runner to remove its copy of a project that lives elsewhere now; how that went, never a throw. */
12969
+ async releaseOn(seat, slug) {
12970
+ if (seat === void 0) {
12971
+ return null;
12972
+ }
12973
+ try {
12974
+ const released = ProjectReleasedSchema.parse(await this.request(seat, (request) => ({ type: "release", request, release: { slug } }), SWEEP_TIMEOUT_MS));
12975
+ return {
12976
+ ok: true,
12977
+ detail: `${seat.name} removed ${released.worktrees} worktree(s)${released.clone ? " and its clone" : ""}`
12978
+ };
12979
+ } catch (error) {
12980
+ this.log.warn({ runner: seat.name, project: slug, error: String(error) }, "could not release a project");
12981
+ return { ok: false, detail: `${seat.name} kept its copy of ${slug}: ${describe(error)}` };
12982
+ }
12983
+ }
12039
12984
  /**
12040
12985
  * One path on a branch of a project's repository, read on the runner the project lives on.
12041
12986
  * NOT_FOUND when the project has no runner yet or the branch or the path does not exist, and
12042
12987
  * `RunnerAwayError` while its runner is away.
12043
12988
  */
12044
- async readBranch(project, branch, path11) {
12989
+ async readBranch(project, branch, path12) {
12045
12990
  const { seat, repo } = await this.projectSeat(project, "branchReads", "read files");
12046
- const file = BranchFileSchema.nullable().parse(await this.request(seat, (request) => ({ type: "file", request, read: { repo, branch, path: path11 } }), FILE_TIMEOUT_MS));
12991
+ const file = BranchFileSchema.nullable().parse(await this.request(seat, (request) => ({ type: "file", request, read: { repo, branch, path: path12 } }), FILE_TIMEOUT_MS));
12047
12992
  if (file === null) {
12048
- throw new BoardError("NOT_FOUND", `${branch} has no ${path11 === "" ? "commit" : path11}`);
12993
+ throw new BoardError("NOT_FOUND", `${branch} has no ${path12 === "" ? "commit" : path12}`);
12049
12994
  }
12050
12995
  return file;
12051
12996
  }
@@ -12065,11 +13010,11 @@ var RunnerHub = class {
12065
13010
  * What a branch of a project's repository did to one file since it left the default branch, read
12066
13011
  * on the runner the project lives on, with the same refusals as `branchChanges`.
12067
13012
  */
12068
- async branchDiff(project, branch, path11) {
13013
+ async branchDiff(project, branch, path12) {
12069
13014
  const { seat, repo } = await this.projectSeat(project, "branchChanges", "list changes");
12070
- const diff = BranchFileDiffSchema.nullable().parse(await this.request(seat, (request) => ({ type: "diff", request, read: { repo, branch, path: path11 } }), FILE_TIMEOUT_MS));
13015
+ const diff = BranchFileDiffSchema.nullable().parse(await this.request(seat, (request) => ({ type: "diff", request, read: { repo, branch, path: path12 } }), FILE_TIMEOUT_MS));
12071
13016
  if (diff === null) {
12072
- throw new BoardError("NOT_FOUND", `${branch} did not change ${path11}`);
13017
+ throw new BoardError("NOT_FOUND", `${branch} did not change ${path12}`);
12073
13018
  }
12074
13019
  return diff;
12075
13020
  }
@@ -12080,18 +13025,8 @@ var RunnerHub = class {
12080
13025
  * back as a `BoardError` in the runner's words.
12081
13026
  */
12082
13027
  async readTurnImage(actor, file) {
12083
- 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);
12084
- if (turn === void 0) {
12085
- throw new BoardError("INVALID_STATE", `${actor.name} has no turn running in this conversation to read ${file} from`);
12086
- }
12087
- const seat = this.seats.get(turn.runner);
12088
- if (seat === void 0 || seat.send === null) {
12089
- throw new RunnerAwayError(`runner ${turn.runner}, where the turn runs, is not connected`);
12090
- }
12091
- if (!seat.hello.imageReads) {
12092
- throw new BoardError("INVALID_STATE", `runner ${seat.name}, where the turn runs, cannot read images yet; upgrade it`);
12093
- }
12094
- const image = TurnImageSchema.nullable().parse(await this.request(seat, (request) => ({ type: "image", request, read: { turnId: turn.turnId, path: file } }), FILE_TIMEOUT_MS));
13028
+ const { turnId, seat } = this.turnSeat(actor, "imageReads", `read ${file} from`, "read images");
13029
+ const image = TurnImageSchema.nullable().parse(await this.request(seat, (request) => ({ type: "image", request, read: { turnId, path: file } }), FILE_TIMEOUT_MS));
12095
13030
  if (image === null) {
12096
13031
  throw new BoardError("INVALID_STATE", `the turn is no longer running on runner ${seat.name}`);
12097
13032
  }
@@ -12100,6 +13035,28 @@ var RunnerHub = class {
12100
13035
  }
12101
13036
  return Buffer.from(image.content, "base64");
12102
13037
  }
13038
+ /**
13039
+ * Hands an image the board keeps to the runner a citizen's turn runs on, for `read_image`, which
13040
+ * writes it to a folder of that turn's own in the system temp folder, removed when the turn
13041
+ * ends, and answers the file's path there, with the same refusals as `readTurnImage`.
13042
+ */
13043
+ async writeTurnImage(actor, image) {
13044
+ const { turnId, seat } = this.turnSeat(actor, "imageWrites", "open an image in", "open images");
13045
+ const file = TurnImageFileSchema.nullable().parse(await this.request(seat, (request) => ({
13046
+ type: "image_write",
13047
+ request,
13048
+ write: {
13049
+ turnId,
13050
+ id: image.id,
13051
+ type: image.type,
13052
+ content: Buffer.from(image.bytes).toString("base64")
13053
+ }
13054
+ }), FILE_TIMEOUT_MS));
13055
+ if (file === null) {
13056
+ throw new BoardError("INVALID_STATE", `the turn is no longer running on runner ${seat.name}`);
13057
+ }
13058
+ return file.path;
13059
+ }
12103
13060
  /** The models a CLI offers, asked of a connected runner that has it; none when no runner has it. */
12104
13061
  /**
12105
13062
  * The connected runner a model list for `cli` comes from: the first of `prefer` that has the CLI,
@@ -12277,6 +13234,25 @@ var RunnerHub = class {
12277
13234
  repo: projectRepo(record)
12278
13235
  };
12279
13236
  }
13237
+ /**
13238
+ * The caller's turn in flight, found by its conversation, and the connected runner it runs on,
13239
+ * which must say `able` in its hello: an image's way to and from the machine a turn runs on.
13240
+ * `where` and `what` say what the caller asked for in a refusal.
13241
+ */
13242
+ turnSeat(actor, able, where, what) {
13243
+ const turn = this.host.runningTurns().find((each) => each.agent === actor.name && each.scope === actor.scope && each.thread === actor.thread && each.channel === actor.channel && each.place === actor.place);
13244
+ if (turn === void 0) {
13245
+ throw new BoardError("INVALID_STATE", `${actor.name} has no turn running in this conversation to ${where}`);
13246
+ }
13247
+ const seat = this.seats.get(turn.runner);
13248
+ if (seat === void 0 || seat.send === null) {
13249
+ throw new RunnerAwayError(`runner ${turn.runner}, where the turn runs, is not connected`);
13250
+ }
13251
+ if (!seat.hello[able]) {
13252
+ throw new BoardError("INVALID_STATE", `runner ${seat.name}, where the turn runs, cannot ${what} yet; upgrade it`);
13253
+ }
13254
+ return { turnId: turn.turnId, seat };
13255
+ }
12280
13256
  assertOwns(runner, turnId) {
12281
13257
  const owner = this.host.runnerOf(turnId);
12282
13258
  if (owner !== void 0 && owner !== runner) {
@@ -12306,6 +13282,32 @@ var RunnerHub = class {
12306
13282
  });
12307
13283
  }
12308
13284
  };
13285
+ function describe(error) {
13286
+ return error instanceof Error ? error.message : String(error);
13287
+ }
13288
+ function mebibytes(bytes) {
13289
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
13290
+ }
13291
+ function heldLine(held) {
13292
+ const where = held.conversation === null ? "its own worktree" : held.conversation.kind === "thread" ? `thread ${held.conversation.id}` : held.conversation.kind === "channel" ? `channel ${held.conversation.name}` : `the channels of ${held.conversation.project}`;
13293
+ const parts = [
13294
+ held.uncommitted > 0 ? `${held.uncommitted} uncommitted file(s)` : "",
13295
+ held.onNoBranch > 0 ? `${held.onNoBranch} commit(s) on no branch` : ""
13296
+ ].filter((part) => part !== "");
13297
+ return `${held.agent}, ${where}: ${parts.join(" and ")}`;
13298
+ }
13299
+ function differentRefs(left, right) {
13300
+ const names = /* @__PURE__ */ new Set([...Object.keys(left), ...Object.keys(right)]);
13301
+ return [...names].filter((name) => left[name] !== right[name]).toSorted();
13302
+ }
13303
+ function sourceAwayMessage(project, from, to, inPlay2) {
13304
+ const stays = [
13305
+ "its local branches and worktrees",
13306
+ project.repo === null ? `its whole history, since ${project.slug} has no remote (${to} would start an empty repository)` : "",
13307
+ inPlay2.length === 0 ? "" : `the work of ${inPlay2.length} task(s) in play (${inPlay2.map((task) => task.id).join(", ")})`
13308
+ ].filter((part) => part !== "");
13309
+ return `runner ${from}, where ${project.slug} lives, is not connected, so nothing can travel. Staying on ${from}: ${stays.join("; ")}. Move it without them to go ahead`;
13310
+ }
12309
13311
 
12310
13312
  // apps/server/dist/index.js
12311
13313
  import pino from "pino";
@@ -12381,7 +13383,9 @@ import { z as z19, ZodError } from "zod";
12381
13383
  // apps/server/dist/body-limit.js
12382
13384
  import { bodyLimit } from "hono/body-limit";
12383
13385
  var ENROLL_BODY_LIMIT_BYTES = 16 * 1024;
13386
+ var APP_TOKEN_BODY_LIMIT_BYTES = 16 * 1024;
12384
13387
  var API_BODY_LIMIT_BYTES = 1024 * 1024;
13388
+ var IMAGE_BODY_LIMIT_BYTES = IMAGE_LIMIT_BYTES;
12385
13389
  var RUNNER_BODY_LIMIT_BYTES = 8 * 1024 * 1024;
12386
13390
  var RUNNER_ANSWER_LIMIT_BYTES = 16 * 1024 * 1024;
12387
13391
  function limitBody(maxSize) {
@@ -12398,13 +13402,19 @@ function limitBody(maxSize) {
12398
13402
  }
12399
13403
 
12400
13404
  // apps/server/dist/github-auth.js
12401
- import { randomBytes as randomBytes2 } from "node:crypto";
13405
+ import { createHash as createHash4, randomBytes as randomBytes2, timingSafeEqual } from "node:crypto";
12402
13406
  import { Hono } from "hono";
12403
13407
  import { z as z18 } from "zod";
12404
13408
  var SIGN_IN_TTL_MS = 30 * 24 * 36e5;
12405
13409
  var STATE_TTL_MS = 10 * 6e4;
12406
13410
  var MAX_STATES = 1e3;
13411
+ var CODE_TTL_MS = 2 * 6e4;
12407
13412
  var TokenAnswerSchema = z18.object({ access_token: z18.string().min(1) });
13413
+ var CodeChallengeSchema = z18.string().regex(/^[A-Za-z0-9_-]{43}$/);
13414
+ var CodeExchangeSchema = z18.object({
13415
+ code: z18.string().min(1).max(64),
13416
+ code_verifier: z18.string().regex(/^[A-Za-z0-9._~-]{43,128}$/)
13417
+ });
12408
13418
  var GithubUserSchema = z18.object({
12409
13419
  login: z18.string().min(1),
12410
13420
  id: z18.number().int(),
@@ -12429,32 +13439,56 @@ function safeNext(next) {
12429
13439
  } catch {
12430
13440
  return "/";
12431
13441
  }
12432
- const path11 = `${url.pathname}${url.search}`;
12433
- return url.origin === BOARD_ORIGIN && /^\/(?![/\\])/.test(path11) ? path11 : "/";
13442
+ const path12 = `${url.pathname}${url.search}`;
13443
+ return url.origin === BOARD_ORIGIN && /^\/(?![/\\])/.test(path12) ? path12 : "/";
12434
13444
  }
12435
- var APP_LANDING = "stellaris://signin";
13445
+ var APP_LANDING = "/auth/app/signin";
12436
13446
  function land(c, next, fragment) {
13447
+ c.header("cache-control", "no-store");
12437
13448
  return c.redirect(`${next}#${fragment}`, 302);
12438
13449
  }
13450
+ function prune(map, now) {
13451
+ for (const [key, entry] of map) {
13452
+ if (entry.expiresAt <= now || map.size >= MAX_STATES) {
13453
+ map.delete(key);
13454
+ }
13455
+ }
13456
+ }
13457
+ function provesChallenge(verifier, challenge) {
13458
+ const answer = Buffer.from(createHash4("sha256").update(verifier).digest("base64url"));
13459
+ const expected = Buffer.from(challenge);
13460
+ return answer.length === expected.length && timingSafeEqual(answer, expected);
13461
+ }
12439
13462
  function githubAuthRoutes(options) {
12440
13463
  const { board: board2, github } = options;
12441
13464
  const fetchImpl = options.fetch ?? fetch;
12442
13465
  const now = options.now ?? Date.now;
12443
13466
  const states = /* @__PURE__ */ new Map();
13467
+ const codes = /* @__PURE__ */ new Map();
12444
13468
  const routes = new Hono();
12445
- routes.get("/config", (c) => c.json({ github: github !== null }));
13469
+ routes.get("/config", (c) => c.json({ github: github !== null, appLanding: (github?.appleAppId ?? null) !== null }));
13470
+ routes.get("/app/signin", (c) => c.text("not found", 404, { "cache-control": "no-store" }));
12446
13471
  if (github === null) {
12447
13472
  return routes;
12448
13473
  }
12449
- routes.get("/github", options.limit ?? ((_c, next) => next()), (c) => {
12450
- for (const [state2, pending] of states) {
12451
- if (pending.expiresAt <= now() || states.size >= MAX_STATES) {
12452
- states.delete(state2);
13474
+ const limit2 = options.limit ?? ((_c, next) => next());
13475
+ routes.get("/github", limit2, (c) => {
13476
+ const app2 = c.req.query("landing") === "app";
13477
+ let challenge = null;
13478
+ if (app2) {
13479
+ if (github.appleAppId === null) {
13480
+ return c.text("Signing in to the app is off on this board", 403);
13481
+ }
13482
+ const parsed = CodeChallengeSchema.safeParse(c.req.query("code_challenge"));
13483
+ if (!parsed.success || c.req.query("code_challenge_method") !== "S256") {
13484
+ return c.text("Signing in to the app needs a code_challenge with method S256", 400);
12453
13485
  }
13486
+ challenge = parsed.data;
12454
13487
  }
13488
+ prune(states, now());
12455
13489
  const state = randomBytes2(24).toString("base64url");
12456
- const next = c.req.query("landing") === "app" ? APP_LANDING : safeNext(c.req.query("next"));
12457
- states.set(state, { next, expiresAt: now() + STATE_TTL_MS });
13490
+ const next = app2 ? APP_LANDING : safeNext(c.req.query("next"));
13491
+ states.set(state, { next, challenge, expiresAt: now() + STATE_TTL_MS });
12458
13492
  const authorize = new URL("https://github.com/login/oauth/authorize");
12459
13493
  authorize.searchParams.set("client_id", github.clientId);
12460
13494
  authorize.searchParams.set("state", state);
@@ -12507,9 +13541,43 @@ function githubAuthRoutes(options) {
12507
13541
  options.log?.warn({ login }, "GitHub sign-in refused a login that is not on the list");
12508
13542
  return land(c, pending.next, "signin-error=not-allowed");
12509
13543
  }
13544
+ if (pending.challenge !== null) {
13545
+ prune(codes, now());
13546
+ const appCode = randomBytes2(24).toString("base64url");
13547
+ codes.set(appCode, {
13548
+ challenge: pending.challenge,
13549
+ login,
13550
+ avatar,
13551
+ expiresAt: now() + CODE_TTL_MS
13552
+ });
13553
+ return land(c, pending.next, `code=${appCode}`);
13554
+ }
12510
13555
  const token = await board2.signIn(login, SIGN_IN_TTL_MS, avatar);
12511
13556
  return land(c, pending.next, `session=${encodeURIComponent(token)}`);
12512
13557
  });
13558
+ if (github.appleAppId !== null) {
13559
+ routes.post("/app/token", limit2, limitBody(APP_TOKEN_BODY_LIMIT_BYTES), async (c) => {
13560
+ const body = CodeExchangeSchema.safeParse(await c.req.json().catch(() => null));
13561
+ if (!body.success) {
13562
+ return c.json({ error: "invalid_request" }, 400);
13563
+ }
13564
+ const pending = codes.get(body.data.code);
13565
+ codes.delete(body.data.code);
13566
+ if (pending === void 0 || pending.expiresAt <= now() || !provesChallenge(body.data.code_verifier, pending.challenge)) {
13567
+ return c.json({ error: "invalid_grant" }, 400);
13568
+ }
13569
+ const token = await board2.signIn(pending.login, SIGN_IN_TTL_MS, pending.avatar);
13570
+ return c.json({ session: token }, 200, { "cache-control": "no-store" });
13571
+ });
13572
+ }
13573
+ return routes;
13574
+ }
13575
+ function appleAppSiteAssociation(github) {
13576
+ const routes = new Hono();
13577
+ routes.get("/apple-app-site-association", (c) => {
13578
+ const appId = github?.appleAppId ?? null;
13579
+ return appId === null ? c.text("not found", 404) : c.json({ webcredentials: { apps: [appId] } });
13580
+ });
12513
13581
  return routes;
12514
13582
  }
12515
13583
 
@@ -12617,14 +13685,114 @@ function rateLimit(options) {
12617
13685
  }
12618
13686
 
12619
13687
  // apps/server/dist/runner-routes.js
12620
- import path8 from "node:path";
13688
+ import path9 from "node:path";
12621
13689
  import { Hono as Hono2 } from "hono";
12622
13690
  import { streamSSE } from "hono/streaming";
12623
13691
 
12624
13692
  // apps/server/dist/home-git.js
12625
- import { execa as execa2 } from "execa";
13693
+ import { once } from "node:events";
13694
+ import { execa as execa3 } from "execa";
13695
+
13696
+ // apps/server/dist/serve.js
13697
+ import { serve } from "@hono/node-server";
13698
+ var REQUEST_TIMEOUT_MS = 3e5;
13699
+ var HEADERS_TIMEOUT_MS = 6e4;
13700
+ var deadlines = /* @__PURE__ */ new WeakMap();
13701
+ function serveBoard(options, listening) {
13702
+ const timeoutMs = options.requestTimeoutMs ?? REQUEST_TIMEOUT_MS;
13703
+ return serve({
13704
+ fetch: (request, env) => {
13705
+ const { incoming, outgoing } = env;
13706
+ if (!incoming.complete) {
13707
+ const timer = setTimeout(() => {
13708
+ if (!incoming.complete) {
13709
+ incoming.socket.destroy();
13710
+ }
13711
+ }, timeoutMs);
13712
+ timer.unref();
13713
+ const lift = () => clearTimeout(timer);
13714
+ deadlines.set(request, lift);
13715
+ outgoing.once("close", lift);
13716
+ }
13717
+ return options.fetch(request, env);
13718
+ },
13719
+ port: options.port,
13720
+ hostname: options.hostname,
13721
+ serverOptions: {
13722
+ requestTimeout: 0,
13723
+ headersTimeout: options.headersTimeoutMs ?? HEADERS_TIMEOUT_MS,
13724
+ ...options.connectionsCheckingIntervalMs === void 0 ? {} : { connectionsCheckingInterval: options.connectionsCheckingIntervalMs }
13725
+ }
13726
+ }, listening);
13727
+ }
13728
+ function liftDeadline(request) {
13729
+ deadlines.get(request)?.();
13730
+ deadlines.delete(request);
13731
+ }
13732
+
13733
+ // apps/server/dist/home-git.js
12626
13734
  var SMART_PATHS = /* @__PURE__ */ new Set(["/info/refs", "/git-upload-pack", "/git-receive-pack"]);
12627
- var MAX_BYTES = 256 * 1024 * 1024;
13735
+ var MAX_BYTES = MOVE_PACK_LIMIT_BYTES;
13736
+ var IDLE_MS = 5 * 6e4;
13737
+ async function feed(body, stdin, limit2, idleMs) {
13738
+ if (body === null) {
13739
+ stdin.end();
13740
+ return "done";
13741
+ }
13742
+ const reader = body.getReader();
13743
+ const waits = new AbortController();
13744
+ let ended = null;
13745
+ const endedAs = () => ended;
13746
+ const end = (why) => {
13747
+ if (ended === null) {
13748
+ ended = why;
13749
+ waits.abort();
13750
+ if (why === "idle" || why === "cut") {
13751
+ reader.cancel().catch(() => void 0);
13752
+ } else {
13753
+ reader.releaseLock();
13754
+ }
13755
+ }
13756
+ return ended;
13757
+ };
13758
+ const timer = setTimeout(() => end("idle"), idleMs);
13759
+ const stopped = () => {
13760
+ end("stopped");
13761
+ };
13762
+ stdin.on("error", stopped);
13763
+ stdin.once("close", stopped);
13764
+ let total = 0;
13765
+ try {
13766
+ for (; ; ) {
13767
+ const next = await reader.read().catch(() => null);
13768
+ const early = endedAs();
13769
+ if (early !== null) {
13770
+ return early;
13771
+ }
13772
+ if (next === null) {
13773
+ return end("cut");
13774
+ }
13775
+ if (next.done) {
13776
+ stdin.end();
13777
+ return "done";
13778
+ }
13779
+ timer.refresh();
13780
+ total += next.value.byteLength;
13781
+ if (total > limit2) {
13782
+ return end("too large");
13783
+ }
13784
+ if (!stdin.write(next.value)) {
13785
+ const drained = await once(stdin, "drain", { signal: waits.signal }).then(() => true, () => false);
13786
+ if (!drained) {
13787
+ return end("stopped");
13788
+ }
13789
+ }
13790
+ }
13791
+ } finally {
13792
+ clearTimeout(timer);
13793
+ stdin.off("close", stopped);
13794
+ }
13795
+ }
12628
13796
  function parseCgi(output) {
12629
13797
  const crlf = output.indexOf("\r\n\r\n");
12630
13798
  const lf = output.indexOf("\n\n");
@@ -12652,12 +13820,13 @@ function parseCgi(output) {
12652
13820
  var HomeGit = class {
12653
13821
  pushes = /* @__PURE__ */ new Map();
12654
13822
  serve(input) {
13823
+ liftDeadline(input.request);
12655
13824
  if (input.rest !== "/git-receive-pack") {
12656
13825
  return serveHomeGit(input);
12657
13826
  }
12658
- const previous = this.pushes.get(input.agent) ?? Promise.resolve();
13827
+ const previous = this.pushes.get(input.repository) ?? Promise.resolve();
12659
13828
  const run = previous.then(() => serveHomeGit(input), () => serveHomeGit(input));
12660
- this.pushes.set(input.agent, run.catch(() => void 0));
13829
+ this.pushes.set(input.repository, run.catch(() => void 0));
12661
13830
  return run;
12662
13831
  }
12663
13832
  };
@@ -12666,10 +13835,13 @@ async function serveHomeGit(input) {
12666
13835
  return new Response("not a smart HTTP path", { status: 404 });
12667
13836
  }
12668
13837
  const { request } = input;
12669
- const body = Buffer.from(await request.arrayBuffer());
12670
- if (body.length > MAX_BYTES) {
12671
- return new Response("pack too large for a home", { status: 413 });
13838
+ const limit2 = input.limitBytes ?? MAX_BYTES;
13839
+ const length = request.headers.get("content-length");
13840
+ const declared = length !== null && /^\d+$/.test(length) ? length : null;
13841
+ if (declared !== null && Number(declared) > limit2) {
13842
+ return new Response("pack too large for the board to take", { status: 413 });
12672
13843
  }
13844
+ liftDeadline(request);
12673
13845
  const url = new URL(request.url);
12674
13846
  const env = {
12675
13847
  PATH: process.env["PATH"] ?? "/usr/bin:/bin",
@@ -12677,13 +13849,15 @@ async function serveHomeGit(input) {
12677
13849
  GIT_PROJECT_ROOT: input.root,
12678
13850
  GIT_HTTP_EXPORT_ALL: "1",
12679
13851
  REQUEST_METHOD: request.method,
12680
- PATH_INFO: `/${input.agent}${input.rest}`,
13852
+ PATH_INFO: `/${input.repository}${input.rest}`,
12681
13853
  QUERY_STRING: url.search.replace(/^\?/, ""),
12682
13854
  CONTENT_TYPE: request.headers.get("content-type") ?? "",
12683
- CONTENT_LENGTH: String(body.length),
12684
13855
  REMOTE_USER: input.runner,
12685
13856
  REMOTE_ADDR: "127.0.0.1"
12686
13857
  };
13858
+ if (declared !== null) {
13859
+ env["CONTENT_LENGTH"] = declared;
13860
+ }
12687
13861
  const encoding = request.headers.get("content-encoding");
12688
13862
  if (encoding !== null) {
12689
13863
  env["HTTP_CONTENT_ENCODING"] = encoding;
@@ -12692,14 +13866,25 @@ async function serveHomeGit(input) {
12692
13866
  if (protocol !== null) {
12693
13867
  env["HTTP_GIT_PROTOCOL"] = protocol;
12694
13868
  }
12695
- const result = await execa2("git", ["http-backend"], {
13869
+ const backend = execa3("git", ["http-backend"], {
12696
13870
  env,
12697
13871
  extendEnv: false,
12698
- input: body,
12699
13872
  encoding: "buffer",
13873
+ // The blank line that ends the headers ends the whole answer when the body is empty, as for
13874
+ // the 4-byte probe git sends before a pack too large for one post; stripped, it reads as none.
13875
+ stripFinalNewline: false,
12700
13876
  reject: false,
12701
13877
  maxBuffer: MAX_BYTES
12702
13878
  });
13879
+ const fed = await feed(request.body, backend.stdin, limit2, input.idleMs ?? IDLE_MS);
13880
+ if (fed !== "done" && fed !== "stopped") {
13881
+ backend.kill();
13882
+ await backend;
13883
+ return fed === "too large" ? new Response("pack too large for the board to take", { status: 413 }) : new Response(fed === "idle" ? "the request stopped arriving" : "the request was cut", {
13884
+ status: fed === "idle" ? 408 : 400
13885
+ });
13886
+ }
13887
+ const result = await backend;
12703
13888
  if (result.exitCode !== 0 && result.stdout.length === 0) {
12704
13889
  return new Response(`git http-backend failed: ${Buffer.from(result.stderr).toString("utf8")}`, {
12705
13890
  status: 500
@@ -12812,20 +13997,47 @@ function runnerRoutes(board2, hub, enrollments, enrollLimit) {
12812
13997
  const marker = `/homes/${agent}/git`;
12813
13998
  const at = c.req.path.indexOf(marker);
12814
13999
  return homeGit.serve({
12815
- root: path8.dirname(repository),
12816
- agent,
14000
+ root: path9.dirname(repository),
14001
+ repository: agent,
12817
14002
  rest: at === -1 ? "" : c.req.path.slice(at + marker.length),
12818
14003
  request: c.req.raw,
12819
14004
  runner: c.get("runner")
12820
14005
  });
12821
14006
  });
12822
14007
  routes.route("/homes/:agent", home);
14008
+ routes.all("/moves/:id/git/*", async (c) => {
14009
+ const id = UlidSchema.safeParse(c.req.param("id"));
14010
+ const marker = `/moves/${c.req.param("id")}/git`;
14011
+ const at = c.req.path.indexOf(marker);
14012
+ const rest = at === -1 ? "" : c.req.path.slice(at + marker.length);
14013
+ const service = rest === "/info/refs" ? c.req.query("service") ?? "" : rest.slice(1);
14014
+ if (service !== "git-upload-pack" && service !== "git-receive-pack") {
14015
+ return c.json({ error: "NOT_FOUND", message: "not a smart HTTP request" }, 404);
14016
+ }
14017
+ const access3 = id.success ? hub.moveAccess(c.get("runner"), id.data, service === "git-receive-pack") : "unknown";
14018
+ if (access3 === "unknown") {
14019
+ return c.json({ error: "NOT_FOUND", message: "no such move under way" }, 404);
14020
+ }
14021
+ if (access3 === "refused" || !id.success) {
14022
+ return c.json({
14023
+ error: "FORBIDDEN",
14024
+ message: `this runner may not ${service === "git-receive-pack" ? "push to" : "fetch from"} this move now`
14025
+ }, 403);
14026
+ }
14027
+ return homeGit.serve({
14028
+ root: board2.moveRepos,
14029
+ repository: `${id.data}.git`,
14030
+ rest,
14031
+ request: c.req.raw,
14032
+ runner: c.get("runner")
14033
+ });
14034
+ });
12823
14035
  return routes;
12824
14036
  }
12825
14037
 
12826
14038
  // apps/server/dist/static.js
12827
14039
  import { readFile as readFile5, stat as stat4 } from "node:fs/promises";
12828
- import path9 from "node:path";
14040
+ import path10 from "node:path";
12829
14041
  var CONTENT_TYPES = {
12830
14042
  ".html": "text/html; charset=utf-8",
12831
14043
  ".js": "text/javascript; charset=utf-8",
@@ -12853,11 +14065,11 @@ function within2(root, pathname) {
12853
14065
  } catch {
12854
14066
  return null;
12855
14067
  }
12856
- const candidate = path9.resolve(root, `.${decoded}`);
12857
- return candidate === root || candidate.startsWith(root + path9.sep) ? candidate : null;
14068
+ const candidate = path10.resolve(root, `.${decoded}`);
14069
+ return candidate === root || candidate.startsWith(root + path10.sep) ? candidate : null;
12858
14070
  }
12859
14071
  function spaHandler(staticDir) {
12860
- const root = path9.resolve(staticDir);
14072
+ const root = path10.resolve(staticDir);
12861
14073
  return async (c) => {
12862
14074
  const { pathname } = new URL(c.req.url);
12863
14075
  if (pathname.startsWith("/api/")) {
@@ -12868,14 +14080,14 @@ function spaHandler(staticDir) {
12868
14080
  if (file !== null && await isFile(file)) {
12869
14081
  return c.body(await readFile5(file), 200, {
12870
14082
  ...headers,
12871
- "content-type": CONTENT_TYPES[path9.extname(file)] ?? "application/octet-stream",
14083
+ "content-type": CONTENT_TYPES[path10.extname(file)] ?? "application/octet-stream",
12872
14084
  "cache-control": pathname.startsWith("/assets/") ? "public, max-age=31536000, immutable" : "no-cache"
12873
14085
  });
12874
14086
  }
12875
14087
  if (pathname.startsWith("/assets/")) {
12876
14088
  return c.text("not found", 404, headers);
12877
14089
  }
12878
- const index = path9.join(root, "index.html");
14090
+ const index = path10.join(root, "index.html");
12879
14091
  if (!await isFile(index)) {
12880
14092
  return c.text("The interface is not built: run pnpm build:web.", 404);
12881
14093
  }
@@ -12902,6 +14114,10 @@ var RetireBodySchema = z19.object({ reason: z19.string().min(1) });
12902
14114
  var RunnerBodySchema = z19.object({ name: NameSchema });
12903
14115
  var EnrollmentApprovalSchema = z19.object({ name: NameSchema });
12904
14116
  var HomeRunnerBodySchema = z19.object({ runner: NameSchema.nullable() });
14117
+ var ProjectRunnerBodySchema = z19.object({
14118
+ runner: NameSchema,
14119
+ withoutBranches: z19.boolean().optional()
14120
+ });
12905
14121
  var ModelBodySchema = z19.object({ model: ModelNameSchema.nullable() });
12906
14122
  var EffortBodySchema = z19.object({ effort: EffortSchema.nullable() });
12907
14123
  var HistoryLimitSchema = z19.coerce.number().int().min(1).max(500);
@@ -12935,6 +14151,9 @@ function createApp(deps) {
12935
14151
  if (error instanceof RunnerAwayError) {
12936
14152
  return c.json({ error: "RUNNER_AWAY", message: error.message }, 503);
12937
14153
  }
14154
+ if (error instanceof MoveSourceAwayError) {
14155
+ return c.json({ error: "SOURCE_AWAY", message: error.message }, 409);
14156
+ }
12938
14157
  return c.json({ error: "INTERNAL", message: error.message }, 500);
12939
14158
  });
12940
14159
  const authenticate = async (c, next) => {
@@ -12948,6 +14167,14 @@ function createApp(deps) {
12948
14167
  };
12949
14168
  const api = new Hono3();
12950
14169
  api.use("*", authenticate);
14170
+ api.post("/images", limitBody(IMAGE_BODY_LIMIT_BYTES), async (c) => {
14171
+ if (c.get("actor").role !== USER_ROLE) {
14172
+ throw new BoardError("FORBIDDEN", "only the user posts an image's bytes");
14173
+ }
14174
+ const name = ImageNameSchema.optional().parse(c.req.query("name"));
14175
+ const bytes = new Uint8Array(await c.req.arrayBuffer());
14176
+ return c.json(await board2.postImage(c.get("actor"), bytes, name));
14177
+ });
12951
14178
  api.on(["POST", "PUT", "PATCH", "DELETE"], "*", limitBody(API_BODY_LIMIT_BYTES));
12952
14179
  api.get("/me", (c) => {
12953
14180
  const signIn = board2.signInOf(bearer2(c) ?? "");
@@ -13000,6 +14227,21 @@ function createApp(deps) {
13000
14227
  }
13001
14228
  return c.json(await quotas.read(runner, cli));
13002
14229
  });
14230
+ for (const [verb, paused] of [
14231
+ ["pause", true],
14232
+ ["resume", false]
14233
+ ]) {
14234
+ for (const path12 of [`/runners/:name/${verb}`, `/runners/:name/${verb}/:cli`]) {
14235
+ api.post(path12, async (c) => {
14236
+ const name = NameSchema.parse(c.req.param("name"));
14237
+ const cli = c.req.param("cli");
14238
+ return c.json(await board2.setRunnerPaused(c.get("actor"), name, {
14239
+ paused,
14240
+ ...cli === void 0 ? {} : { cli: CliKindSchema.parse(cli) }
14241
+ }));
14242
+ });
14243
+ }
14244
+ }
13003
14245
  api.post("/runners", async (c) => {
13004
14246
  const body = RunnerBodySchema.parse(await c.req.json());
13005
14247
  return c.json(await board2.addRunner(c.get("actor"), body.name));
@@ -13109,6 +14351,13 @@ function createApp(deps) {
13109
14351
  return branchFile(c, project.slug, project.defaultBranch, file);
13110
14352
  };
13111
14353
  api.get("/projects/:slug/files", (c) => projectFile(c, ""));
14354
+ api.put("/projects/:slug/runner", async (c) => {
14355
+ const body = ProjectRunnerBodySchema.parse(await c.req.json());
14356
+ if (deps.runners === void 0) {
14357
+ throw new BoardError("NOT_FOUND", "this board has no runners to move a project between");
14358
+ }
14359
+ return c.json(await deps.runners.moveProject(c.get("actor"), NameSchema.parse(c.req.param("slug")), body.runner, { withoutBranches: body.withoutBranches }));
14360
+ });
13112
14361
  api.get("/projects/:slug/files/:path{.+}", (c) => projectFile(c, c.req.param("path")));
13113
14362
  api.get("/tasks/:id", async (c) => c.json((await board2.findTask(c.req.param("id"))).task));
13114
14363
  api.get("/tasks/:id/thread", async (c) => c.json(await board2.listThread(c.req.param("id"))));
@@ -13286,6 +14535,7 @@ function createApp(deps) {
13286
14535
  ...deps.signIn,
13287
14536
  limit: limited(SIGN_IN_LIMIT, "sign-ins")
13288
14537
  }));
14538
+ app2.route("/.well-known", appleAppSiteAssociation(deps.signIn?.github ?? null));
13289
14539
  if (deps.runners !== void 0) {
13290
14540
  app2.route("/runner", runnerRoutes(board2, deps.runners, deps.enrollments, limited(ENROLL_LIMIT, "enrollment requests")));
13291
14541
  }
@@ -13568,6 +14818,7 @@ var host = new TurnHost({
13568
14818
  var runners = new RunnerHub({ board, host, version: VERSION, log });
13569
14819
  board.watchTurns(() => host.inFlight());
13570
14820
  board.readImagesWith((actor, file) => runners.readTurnImage(actor, file));
14821
+ board.openImagesWith((actor, image) => runners.writeTurnImage(actor, image));
13571
14822
  var concurrency = config.concurrency ?? Number.POSITIVE_INFINITY;
13572
14823
  var timings = parseTimings(JSON.parse(process.env["STELLARIS_TIMINGS"] ?? "{}"));
13573
14824
  var scheduler = new Scheduler({ board, runner: runners, log, concurrency, timings });
@@ -13585,8 +14836,8 @@ var view = {
13585
14836
  return scheduler.activeSignals;
13586
14837
  }
13587
14838
  };
13588
- var webDir = process.env["STELLARIS_WEB_DIR"] ?? path10.resolve(import.meta.dirname, "../../web/dist");
13589
- var webBuilt = await access2(path10.join(webDir, "index.html")).then(() => true, () => false);
14839
+ var webDir = process.env["STELLARIS_WEB_DIR"] ?? path11.resolve(import.meta.dirname, "../../web/dist");
14840
+ var webBuilt = await access2(path11.join(webDir, "index.html")).then(() => true, () => false);
13590
14841
  var app = createApp({
13591
14842
  board,
13592
14843
  version: VERSION,
@@ -13601,7 +14852,7 @@ var app = createApp({
13601
14852
  trustedProxies: config.trustedProxies,
13602
14853
  log
13603
14854
  });
13604
- var server = serve({ fetch: app.fetch, port: config.port, hostname: config.host }, (info) => {
14855
+ var server = serveBoard({ fetch: app.fetch, port: config.port, hostname: config.host }, (info) => {
13605
14856
  log.info({
13606
14857
  host: info.address,
13607
14858
  port: info.port,
@@ -13610,6 +14861,7 @@ var server = serve({ fetch: app.fetch, port: config.port, hostname: config.host
13610
14861
  webDir,
13611
14862
  webBuilt,
13612
14863
  signIn: config.github === null ? "token" : `github (${config.github.users.join(", ")})`,
14864
+ appleAppId: config.github?.appleAppId ?? null,
13613
14865
  trustedProxies: config.trustedProxies
13614
14866
  }, "board server listening");
13615
14867
  });