@kubitnodes/stellaris 0.3.27 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/cli/index.js +415 -56
  2. package/dist/server/index.js +737 -112
  3. package/package.json +2 -2
  4. package/web/dist/assets/{BackLink-qSxOMeEG.js → BackLink-BLiSCuKv.js} +1 -1
  5. package/web/dist/assets/{Button-CxjWy598.js → Button-D4-iu6aX.js} +1 -1
  6. package/web/dist/assets/ChannelForms-DjviQfVm.js +1 -0
  7. package/web/dist/assets/{ChannelView-rqfk1Tyu.js → ChannelView-B-0H7Ps8.js} +2 -2
  8. package/web/dist/assets/CitizenView-gE5GgCBa.js +2 -0
  9. package/web/dist/assets/{CitizensView-Bgw6PRSo.js → CitizensView-DXD3qkq1.js} +1 -1
  10. package/web/dist/assets/{CliIcon-BB2NdLxN.js → CliIcon-DL4ZwgZv.js} +1 -1
  11. package/web/dist/assets/{CronsView-BrXrcfC6.js → CronsView-CX_G5D3h.js} +1 -1
  12. package/web/dist/assets/{FileTree-CHDmrOhY.js → FileTree-BFa_KVI8.js} +1 -1
  13. package/web/dist/assets/{FileView-DUMfKYRW.js → FileView-Cd9Ijarf.js} +2 -2
  14. package/web/dist/assets/KnowledgeView-D_a4pjGV.js +1 -0
  15. package/web/dist/assets/{Markdown-C7RbUEZE.js → Markdown-DPqvK--4.js} +4 -4
  16. package/web/dist/assets/{MessageItem-DgWh5pJF.js → MessageItem-WBTzWwyj.js} +1 -1
  17. package/web/dist/assets/MetricsView-YUkYixqT.js +1 -0
  18. package/web/dist/assets/NeedsYouView-CE_B9bZQ.js +1 -0
  19. package/web/dist/assets/Overview-BGUnf6K1.js +1 -0
  20. package/web/dist/assets/{Pane-BlIRwUtQ.js → Pane-C9TbEME4.js} +1 -1
  21. package/web/dist/assets/{Picker-BSOB2JM_.js → Picker-BK48MjLY.js} +1 -1
  22. package/web/dist/assets/{ProjectView-CVt28SoI.js → ProjectView-sL3aGAC6.js} +1 -1
  23. package/web/dist/assets/ProposalView-BhZR4MIR.js +1 -0
  24. package/web/dist/assets/{ProposalsView-C0r1ya8_.js → ProposalsView-DTGx8NDn.js} +1 -1
  25. package/web/dist/assets/{Quota-59QJZrPa.js → Quota-BkF7pa0u.js} +1 -1
  26. package/web/dist/assets/RunnersView-Bl62pXNw.js +1 -0
  27. package/web/dist/assets/{SkillFileList-Cs_f3RPo.js → SkillFileList-BUpKUkaG.js} +1 -1
  28. package/web/dist/assets/{SkillView-BvVx3LSL.js → SkillView-BQJApSh7.js} +1 -1
  29. package/web/dist/assets/{SocietyView-CJRrypjI.js → SocietyView-JSdVE50m.js} +1 -1
  30. package/web/dist/assets/{StickyList-BbGq82Jj.js → StickyList-DafbJutb.js} +1 -1
  31. package/web/dist/assets/{SubjectThread-DdkyhVqK.js → SubjectThread-Jyl5YCFL.js} +1 -1
  32. package/web/dist/assets/{TaskView-CyEWpCvS.js → TaskView-Jke2pVGT.js} +2 -2
  33. package/web/dist/assets/{TasksView-D10XP1uk.js → TasksView-zJTIaqEA.js} +1 -1
  34. package/web/dist/assets/{ThreadForms-D94Jj0ES.js → ThreadForms-DwUOktyP.js} +1 -1
  35. package/web/dist/assets/{ThreadItem-CYmdCPOf.js → ThreadItem-Blx8gSvO.js} +1 -1
  36. package/web/dist/assets/{ThreadView-D79G2zgF.js → ThreadView-CdQpBb7M.js} +1 -1
  37. package/web/dist/assets/citizen-BOn5BBRL.js +1 -0
  38. package/web/dist/assets/compose-D-X3BQgY.js +1 -0
  39. package/web/dist/assets/format-Dhd2NUB3.js +55 -0
  40. package/web/dist/assets/{governance-YialJVYK.js → governance-CWzi7hzX.js} +1 -1
  41. package/web/dist/assets/index-CA_njyMe.css +2 -0
  42. package/web/dist/assets/index-iplooQqq.js +24 -0
  43. package/web/dist/assets/{mentions-Buc5vs-j.js → mentions-S-r32HKM.js} +1 -1
  44. package/web/dist/assets/{tasks-DLTeaHys.js → tasks-D_Fy7dtN.js} +1 -1
  45. package/web/dist/assets/useMatch-DZXCHHaG.js +1 -0
  46. package/web/dist/index.html +9 -9
  47. package/web/dist/assets/ChannelForms-BVidWMId.js +0 -1
  48. package/web/dist/assets/CitizenView-xU99R8hS.js +0 -2
  49. package/web/dist/assets/KnowledgeView-Qog6c92V.js +0 -1
  50. package/web/dist/assets/MetricsView-DuOyISk1.js +0 -1
  51. package/web/dist/assets/NeedsYouView-CrmHXZAn.js +0 -1
  52. package/web/dist/assets/Overview-xatORJK6.js +0 -1
  53. package/web/dist/assets/ProposalView-DJ0aJm7J.js +0 -1
  54. package/web/dist/assets/RunnersView-CcAAfw6y.js +0 -1
  55. package/web/dist/assets/citizen-CgA4vJjy.js +0 -1
  56. package/web/dist/assets/compose-3ksQaHF6.js +0 -1
  57. package/web/dist/assets/format-DpQyO6r1.js +0 -55
  58. package/web/dist/assets/index-DIa8Dlav.css +0 -2
  59. package/web/dist/assets/index-DhuCp-VF.js +0 -23
  60. package/web/dist/assets/useMatch-DPEg8ohe.js +0 -1
package/dist/cli/index.js CHANGED
@@ -65,7 +65,8 @@ var VERB_NAMES = [
65
65
  "update_cron",
66
66
  "remove_cron",
67
67
  "remove_skill",
68
- "upload_image"
68
+ "upload_image",
69
+ "read_image"
69
70
  ];
70
71
  var VerbNameSchema = z2.enum(VERB_NAMES);
71
72
  var CharterTriggerSchema = z2.enum(["user_post", "ops_event", "heartbeat"]);
@@ -95,6 +96,7 @@ var USER_NAME = "user";
95
96
  var MEMBER_VERBS = [
96
97
  "post_message",
97
98
  "upload_image",
99
+ "read_image",
98
100
  "read_inbox",
99
101
  "search",
100
102
  "open_thread",
@@ -225,7 +227,12 @@ var CronScheduleSchema = z3.union([
225
227
  var CronRunSchema = z3.object({
226
228
  turnId: UlidSchema,
227
229
  at: IsoDateTimeSchema,
228
- 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()
229
236
  });
230
237
  var CronFrontmatterSchema = z3.object({
231
238
  id: UlidSchema,
@@ -272,6 +279,16 @@ function shortestGapMs(schedule, societyZone, after) {
272
279
  }
273
280
  return shortest;
274
281
  }
282
+ function firesInTask(cron2, task2) {
283
+ return cron2.scope === task2.project && cron2.thread === task2.id;
284
+ }
285
+ function cronFailure(cron2) {
286
+ return cron2.lastRun?.outcome === "failed" && cron2.lastRun.caughtUp === void 0 ? cron2.lastRun : null;
287
+ }
288
+ function cronStoppedAt(cron2) {
289
+ const times = [cron2.paused?.at, cron2.ended?.at, cronFailure(cron2)?.at].filter((at) => at !== void 0);
290
+ return times.reduce((first, at) => first === null || Date.parse(at) < Date.parse(first) ? at : first, null);
291
+ }
275
292
  function nextFire(cron2, societyZone, floorMs = CRON_FLOOR_MS) {
276
293
  if (cron2.ended !== void 0 || cron2.paused !== void 0) {
277
294
  return null;
@@ -651,6 +668,7 @@ var MemberSchema = z5.object({
651
668
  });
652
669
  var RunnerOsSchema = z5.enum(["linux", "windows", "darwin"]);
653
670
  var GitIdentityPartSchema = z5.enum(["name", "email"]);
671
+ var RunnerPauseSchema = z5.object({ at: IsoDateTimeSchema, by: NameSchema });
654
672
  var RunnerSchema = z5.object({
655
673
  name: NameSchema,
656
674
  os: RunnerOsSchema,
@@ -669,8 +687,25 @@ var RunnerSchema = z5.object({
669
687
  * Each CLI's plan quota as its login on this runner last reported it, from a turn's stream or a
670
688
  * read; absent until one has.
671
689
  */
672
- quotas: z5.partialRecord(CliKindSchema, CliQuotaSchema).optional()
690
+ quotas: z5.partialRecord(CliKindSchema, CliQuotaSchema).optional(),
691
+ /** The user's pause of the whole runner: no turn starts on it until the user resumes it. */
692
+ paused: RunnerPauseSchema.optional(),
693
+ /**
694
+ * The user's pauses of single CLIs on the runner, each apart from the runner's own: no turn of
695
+ * that CLI starts on it until the user resumes it.
696
+ */
697
+ pausedClis: z5.partialRecord(CliKindSchema, RunnerPauseSchema).optional()
673
698
  });
699
+ function runnerPause(runner2, cli) {
700
+ return runner2.paused ?? runner2.pausedClis?.[cli];
701
+ }
702
+ function heldOn(runners, runner2, cli) {
703
+ if (runner2 === void 0 || cli === null) {
704
+ return false;
705
+ }
706
+ const record = runners.find((each) => each.name === runner2);
707
+ return record !== void 0 && runnerPause(record, cli) !== void 0;
708
+ }
674
709
  var ProposalKindSchema = z5.enum([
675
710
  "role",
676
711
  "member",
@@ -1014,7 +1049,21 @@ function imageTypeOf(bytes) {
1014
1049
  return null;
1015
1050
  }
1016
1051
  var ImagePathSchema = z6.string().min(1).max(4096).refine((value) => !value.includes("\0"), { message: "a path with no NUL character" });
1052
+ var ImageNameSchema = z6.string().max(1024);
1053
+ function imageDescription(name) {
1054
+ return name.replaceAll(/[[\]\\\r\n`]/g, "");
1055
+ }
1017
1056
  var IMAGE_SCHEME = "image:";
1057
+ var ImageRefSchema = z6.string().regex(/^(?:image:)?[0-9A-HJKMNP-TV-Z]{26}$/, "expected an image's id or image:<id>");
1058
+ function imageIdOf(ref) {
1059
+ return ref.startsWith(IMAGE_SCHEME) ? ref.slice(IMAGE_SCHEME.length) : ref;
1060
+ }
1061
+ var ImageFileSchema = z6.object({
1062
+ id: UlidSchema,
1063
+ type: ImageTypeSchema,
1064
+ size: z6.number().int().positive(),
1065
+ path: ImagePathSchema
1066
+ });
1018
1067
  var UploadedImageSchema = z6.object({
1019
1068
  id: UlidSchema,
1020
1069
  type: ImageTypeSchema,
@@ -1179,6 +1228,10 @@ var VerbInputs = {
1179
1228
  upload_image: z7.object({
1180
1229
  /** The image file on the machine your turn runs on: absolute, or relative to your working directory. */
1181
1230
  path: ImagePathSchema
1231
+ }),
1232
+ read_image: z7.object({
1233
+ /** The image's id, or its `image:<id>` address as a post shows it. */
1234
+ image_id: ImageRefSchema
1182
1235
  })
1183
1236
  };
1184
1237
 
@@ -1230,6 +1283,8 @@ var BOARD_EVENT_TYPES = [
1230
1283
  "user.token_rotated",
1231
1284
  "runner.changed",
1232
1285
  "runner.quota",
1286
+ "runner.paused",
1287
+ "runner.resumed",
1233
1288
  "ops.signal",
1234
1289
  "paused.changed",
1235
1290
  "wake.requested",
@@ -1371,6 +1426,11 @@ var RunnerHelloSchema = z9.object({
1371
1426
  quotaReads: z9.boolean().default(false),
1372
1427
  /** Whether the runner reads an image a turn running there uploads; one from before never does. */
1373
1428
  imageReads: z9.boolean().default(false),
1429
+ /**
1430
+ * Whether the runner writes an image the board sends for a turn running there to open, for
1431
+ * `read_image`; one from before never does.
1432
+ */
1433
+ imageWrites: z9.boolean().default(false),
1374
1434
  /**
1375
1435
  * Whether the runner carries a project's repository out to another runner and in from one, and
1376
1436
  * removes its copy once the project has moved; one from before never does.
@@ -1484,6 +1544,12 @@ var TurnJobSchema = z9.object({
1484
1544
  usageSoFar: UsageSchema.optional(),
1485
1545
  /** The rendered charter, norms, memory core, and skills index, with `PATH_TOKENS` for places. */
1486
1546
  instructions: z9.string(),
1547
+ /**
1548
+ * The hash of `instructions` without the first turn's section (`instructionsHash` in
1549
+ * `turn-host`): a warm process started on other instructions starts afresh, so a compaction in
1550
+ * it puts back the current ones. Absent from a server from before, when the text is compared.
1551
+ */
1552
+ instructionsHash: z9.string().optional(),
1487
1553
  prompt: z9.string(),
1488
1554
  mcp: z9.object({ url: z9.string().min(1), token: z9.string().min(1) }),
1489
1555
  limits: z9.object({
@@ -1668,6 +1734,13 @@ var TurnImageSchema = z9.discriminatedUnion("kind", [
1668
1734
  }),
1669
1735
  z9.object({ kind: z9.literal("refused"), why: ImageRefusalSchema, detail: z9.string() })
1670
1736
  ]);
1737
+ var TurnImageWriteSchema = z9.object({
1738
+ turnId: UlidSchema,
1739
+ id: UlidSchema,
1740
+ type: ImageTypeSchema,
1741
+ content: z9.string()
1742
+ });
1743
+ var TurnImageFileSchema = z9.object({ path: ImagePathSchema });
1671
1744
  var WorkspaceConversationSchema = z9.discriminatedUnion("kind", [
1672
1745
  z9.object({ kind: z9.literal("thread"), id: UlidSchema }),
1673
1746
  z9.object({ kind: z9.literal("channel"), scope: NameSchema, name: NameSchema }),
@@ -1681,6 +1754,7 @@ var HeldWorkspaceSchema = z9.object({
1681
1754
  var HeldWorkspacesSchema = z9.object({ workspaces: z9.array(HeldWorkspaceSchema) });
1682
1755
  var SweepResultSchema = z9.object({ kept: z9.array(HeldWorkspaceSchema) });
1683
1756
  var MOVE_PACK_LIMIT_BYTES = 256 * 1024 * 1024;
1757
+ var MOVE_FLOOR_BYTES_PER_SECOND = 16 * 1024;
1684
1758
  var RepoRefsSchema = z9.record(z9.string().regex(/^refs\/(heads|tags)\/\S+$/), z9.string().regex(/^[0-9a-f]{40}([0-9a-f]{24})?$/));
1685
1759
  var ProjectExportSchema = z9.object({ move: UlidSchema, repo: ProjectRepoSchema });
1686
1760
  var HeldWorkSchema = z9.object({
@@ -1704,7 +1778,9 @@ var ProjectExportedSchema = z9.discriminatedUnion("ok", [
1704
1778
  var ProjectImportSchema = z9.object({
1705
1779
  move: UlidSchema,
1706
1780
  repo: ProjectRepoSchema,
1707
- pullRequests: z9.boolean()
1781
+ pullRequests: z9.boolean(),
1782
+ /** The packs' size the old runner measured, which the clone's clock is sized from; the cap if unsaid. */
1783
+ bytes: z9.number().int().nonnegative().optional()
1708
1784
  });
1709
1785
  var ProjectImportedSchema = z9.object({ refs: RepoRefsSchema });
1710
1786
  var ProjectReleaseSchema = z9.object({ slug: NameSchema });
@@ -1744,6 +1820,15 @@ var RunnerMessageSchema = z9.discriminatedUnion("type", [
1744
1820
  * whose hello says `imageReads`.
1745
1821
  */
1746
1822
  z9.object({ type: z9.literal("image"), request: z9.string().min(1), read: TurnImageReadSchema }),
1823
+ /**
1824
+ * Answered with a `TurnImageFile`, or null when the turn is not running there. Sent only to
1825
+ * runners whose hello says `imageWrites`.
1826
+ */
1827
+ z9.object({
1828
+ type: z9.literal("image_write"),
1829
+ request: z9.string().min(1),
1830
+ write: TurnImageWriteSchema
1831
+ }),
1747
1832
  /** Answered with whether the CLI accepted it; that it took it comes as a `steered` event. */
1748
1833
  z9.object({
1749
1834
  type: z9.literal("steer"),
@@ -1809,6 +1894,7 @@ var TriggerKindSchema = z10.enum([
1809
1894
  "closing",
1810
1895
  "cron"
1811
1896
  ]);
1897
+ var HeartbeatCauseSchema = z10.enum(["unread", "held", "waiting"]);
1812
1898
  var TriggerSchema = z10.object({
1813
1899
  kind: TriggerKindSchema,
1814
1900
  from: NameSchema.optional(),
@@ -1819,7 +1905,9 @@ var TriggerSchema = z10.object({
1819
1905
  /** The channel the waking message was posted in. */
1820
1906
  channel: ChannelRefSchema.optional(),
1821
1907
  /** The cron that came due, for a `cron` wake. */
1822
- cronId: UlidSchema.optional()
1908
+ cronId: UlidSchema.optional(),
1909
+ /** What a `heartbeat` woke for; absent on records from before the board kept it. */
1910
+ heartbeat: HeartbeatCauseSchema.optional()
1823
1911
  });
1824
1912
  var TurnThreadSchema = z10.object({ id: UlidSchema, task: z10.boolean() });
1825
1913
  var TurnDispatchSchema = z10.object({
@@ -1885,6 +1973,12 @@ var TurnRecordSchema = z10.object({
1885
1973
  * one. A resumed session's next turn measures its own usage from here.
1886
1974
  */
1887
1975
  sessionUsage: UsageSchema.optional(),
1976
+ /**
1977
+ * The SHA-256 of the instructions the session holds after this turn, the first turn's section
1978
+ * left out (`instructionsHash` in `turn-host`): those it began with, or the latest a prompt
1979
+ * carried. Absent when the board does not know them, as for records from before it kept them.
1980
+ */
1981
+ sessionInstructionsHash: z10.string().optional(),
1888
1982
  /**
1889
1983
  * The whole input of the turn's latest model call in the session's own thread, as the runner
1890
1984
  * reported it: how large the session had grown. Absent for a turn that made no call.
@@ -1909,7 +2003,13 @@ var TurnRecordSchema = z10.object({
1909
2003
  */
1910
2004
  sessionDropped: z10.boolean().optional(),
1911
2005
  /** The crons whose fire started the turn, which record how it ended. */
1912
- crons: z10.array(UlidSchema).optional()
2006
+ crons: z10.array(UlidSchema).optional(),
2007
+ /**
2008
+ * The failed fires its prompt named: each of its citizen's crons in its conversation whose latest
2009
+ * fire's turn failed with nothing catching it up (`cronFailure`), but those it carries, with that
2010
+ * turn. Completing it catches each up.
2011
+ */
2012
+ cronFailures: z10.array(z10.object({ cron: UlidSchema, turnId: UlidSchema })).optional()
1913
2013
  });
1914
2014
  var RunningTurnSchema = z10.object({
1915
2015
  turnId: UlidSchema,
@@ -1951,6 +2051,8 @@ var TurnHistoryEntrySchema = z10.object({
1951
2051
  /** The project whose channels' conversation the turn was in, for a turn in the society scope. */
1952
2052
  place: NameSchema.optional(),
1953
2053
  trigger: z10.string(),
2054
+ /** What a `heartbeat` woke for; absent on turns logged before the board kept it. */
2055
+ heartbeat: HeartbeatCauseSchema.optional(),
1954
2056
  exitReason: z10.string().nullable(),
1955
2057
  costUsd: z10.number(),
1956
2058
  /** The tokens the turn used; absent for turns logged before turns recorded them. */
@@ -2021,7 +2123,7 @@ var METRICS_WINDOW_MS = {
2021
2123
  "7d": 7 * 24 * 36e5,
2022
2124
  all: null
2023
2125
  };
2024
- var READ_VERBS = ["read_inbox", "search", "get_task"];
2126
+ var READ_VERBS = ["read_inbox", "search", "get_task", "read_image"];
2025
2127
  function isBoardAction(tool) {
2026
2128
  const verb = /^mcp__board__(.+)$/.exec(tool)?.[1];
2027
2129
  return verb !== void 0 && !READ_VERBS.some((read2) => read2 === verb);
@@ -2035,12 +2137,19 @@ var MetricsSchema = z12.object({
2035
2137
  /**
2036
2138
  * Finished turns that changed nothing on the board. Reflections are left out, since they write
2037
2139
  * memory, not the board; turns without a transcript are counted apart, their actions unknown.
2140
+ * Heartbeats are split by what each woke for, and those recorded before the board kept it are a
2141
+ * row without one.
2038
2142
  */
2039
2143
  idle: z12.object({
2040
2144
  turns: CountSchema,
2041
2145
  idle: CountSchema,
2042
2146
  unknown: CountSchema,
2043
- byTrigger: z12.array(z12.object({ trigger: z12.string(), turns: CountSchema, idle: CountSchema })),
2147
+ byTrigger: z12.array(z12.object({
2148
+ trigger: z12.string(),
2149
+ heartbeat: HeartbeatCauseSchema.optional(),
2150
+ turns: CountSchema,
2151
+ idle: CountSchema
2152
+ })),
2044
2153
  byAgent: z12.array(z12.object({
2045
2154
  agent: NameSchema,
2046
2155
  role: NameSchema.nullable(),
@@ -2175,7 +2284,7 @@ var RunnerConfigSchema = z13.object({
2175
2284
  });
2176
2285
 
2177
2286
  // packages/shared/dist/version.js
2178
- var STELLARIS_VERSION = "0.3.27";
2287
+ var STELLARIS_VERSION = "0.4.0";
2179
2288
 
2180
2289
  // packages/shared/dist/mentions.js
2181
2290
  import { parse, postprocess, preprocess } from "micromark";
@@ -2936,7 +3045,8 @@ var TurnPayload = z14.object({
2936
3045
  thread: z14.string().optional(),
2937
3046
  channel: z14.string().optional(),
2938
3047
  place: z14.string().optional(),
2939
- trigger: z14.string()
3048
+ trigger: z14.string(),
3049
+ heartbeat: HeartbeatCauseSchema.optional().catch(void 0)
2940
3050
  });
2941
3051
  var TaskPayload = z14.object({
2942
3052
  taskId: z14.string(),
@@ -3036,13 +3146,12 @@ function computeMetrics(input) {
3036
3146
  const nothing = actions === 0 ? 1 : 0;
3037
3147
  turns += 1;
3038
3148
  idle += nothing;
3039
- for (const [rows, key] of [
3040
- [turnRows, turn2.data.trigger],
3041
- [agentRows, event.actor]
3042
- ]) {
3043
- const row = rows.get(key) ?? { turns: 0, idle: 0 };
3044
- rows.set(key, { turns: row.turns + 1, idle: row.idle + nothing });
3045
- }
3149
+ const { trigger, heartbeat } = turn2.data;
3150
+ const key = heartbeat === void 0 ? trigger : `${trigger}\0${heartbeat}`;
3151
+ const row = turnRows.get(key) ?? { trigger, heartbeat, turns: 0, idle: 0 };
3152
+ turnRows.set(key, { ...row, turns: row.turns + 1, idle: row.idle + nothing });
3153
+ const own = agentRows.get(event.actor) ?? { turns: 0, idle: 0 };
3154
+ agentRows.set(event.actor, { turns: own.turns + 1, idle: own.idle + nothing });
3046
3155
  break;
3047
3156
  }
3048
3157
  case "turn.started": {
@@ -3198,7 +3307,10 @@ function computeMetrics(input) {
3198
3307
  turns,
3199
3308
  idle,
3200
3309
  unknown,
3201
- byTrigger: [...turnRows].map(([trigger, row]) => ({ trigger, ...row })).toSorted((a, b) => b.idle - a.idle || b.turns - a.turns),
3310
+ byTrigger: [...turnRows.values()].map(({ heartbeat, ...row }) => ({
3311
+ ...row,
3312
+ ...heartbeat === void 0 ? {} : { heartbeat }
3313
+ })).toSorted((a, b) => b.idle - a.idle || b.turns - a.turns),
3202
3314
  byAgent: [...agentRows].map(([agent2, row]) => ({ agent: agent2, role: citizens.get(agent2)?.role ?? null, ...row })).toSorted((a, b) => b.idle - a.idle || b.turns - a.turns)
3203
3315
  },
3204
3316
  sentBack: {
@@ -3258,6 +3370,8 @@ var TurnHistoryPayloadSchema = z15.object({
3258
3370
  channel: NameSchema.optional(),
3259
3371
  place: NameSchema.optional(),
3260
3372
  trigger: z15.string().default("manual"),
3373
+ // One a later board added reads as none, so the turn stays in the history.
3374
+ heartbeat: HeartbeatCauseSchema.optional().catch(void 0),
3261
3375
  exitReason: z15.string().nullable().default(null),
3262
3376
  costUsd: z15.number().default(0),
3263
3377
  usage: UsageSchema.optional(),
@@ -3683,7 +3797,10 @@ var SignInsSchema = z16.object({
3683
3797
  expiresAt: IsoDateTimeSchema
3684
3798
  })).default([])
3685
3799
  });
3686
- var TurnStartPayloadSchema = z16.object({ turnId: UlidSchema });
3800
+ var TurnStartPayloadSchema = z16.object({
3801
+ turnId: UlidSchema,
3802
+ heartbeat: TurnHistoryPayloadSchema.shape.heartbeat
3803
+ });
3687
3804
  var CursorsSchema = z16.object({
3688
3805
  digest: z16.string().nullable(),
3689
3806
  scopes: z16.record(z16.string(), z16.string()).default({}),
@@ -3755,6 +3872,9 @@ function skillFolderOf(proposal2) {
3755
3872
  function proposedFrom(origin, where) {
3756
3873
  return origin !== void 0 && origin.scope === where.scope && origin.thread === where.thread && origin.channel === where.channel && origin.place === where.place;
3757
3874
  }
3875
+ function latestLease(a, b) {
3876
+ return a === null ? b : b === null || a >= b ? a : b;
3877
+ }
3758
3878
  function megabytes(bytes) {
3759
3879
  return (bytes / (1024 * 1024)).toFixed(1).replace(/\.0$/, "");
3760
3880
  }
@@ -3894,6 +4014,7 @@ var Board = class _Board {
3894
4014
  /** The turns in flight, which the server hosting them supplies; none until it does. */
3895
4015
  turnsInFlight = () => [];
3896
4016
  imageSource = null;
4017
+ imageSink = null;
3897
4018
  newId = monotonicFactory();
3898
4019
  events;
3899
4020
  actionCounts = /* @__PURE__ */ new Map();
@@ -3944,10 +4065,13 @@ var Board = class _Board {
3944
4065
  /**
3945
4066
  * Applies each one-time alignment an existing society has not had yet, and remembers it, so a
3946
4067
  * member that later chooses otherwise keeps its choice. A new alignment is a name and a method in
3947
- * this list; once every board has applied it, both are deleted. None is pending.
4068
+ * this list; once every board has applied it, both are deleted.
3948
4069
  */
3949
4070
  async applyAlignments() {
3950
- const alignments = [];
4071
+ const alignments = [
4072
+ // Whoever may show an image in a post may open one a post shows.
4073
+ { name: "image-reads-granted", apply: () => this.grantBeside("upload_image", "read_image") }
4074
+ ];
3951
4075
  const file = path8.join(this.paths.state(), "alignments.json");
3952
4076
  const { applied } = await exists(file) ? await readJson(file, AlignmentsSchema) : { applied: [] };
3953
4077
  const pending = alignments.filter(({ name }) => !applied.includes(name));
@@ -3959,6 +4083,17 @@ var Board = class _Board {
3959
4083
  }
3960
4084
  await writeJson(file, { applied: [...applied, ...pending.map(({ name }) => name)] });
3961
4085
  }
4086
+ /**
4087
+ * Grants `verb` once to every charter granting `beside`, the work roles' charters in practice;
4088
+ * seed roles have it from their seed.
4089
+ */
4090
+ async grantBeside(beside, verb) {
4091
+ for (const charter of await this.listRoles()) {
4092
+ if (charter.verbs.includes(beside) && !charter.verbs.includes(verb)) {
4093
+ await this.writeRoleUnlocked(USER_NAME, { ...charter, verbs: [...charter.verbs, verb] }, { verbsAdded: [verb] });
4094
+ }
4095
+ }
4096
+ }
3962
4097
  /** Writes every missing seed charter and aligns the existing ones with their seed. */
3963
4098
  async ensureSeedRoles() {
3964
4099
  await ensureDir(this.paths.roles());
@@ -4726,6 +4861,46 @@ ${DASHBOARD_LINE}
4726
4861
  return next;
4727
4862
  });
4728
4863
  }
4864
+ /**
4865
+ * Pauses or resumes a runner, the user's alone: the whole runner, or with `cli` that CLI on it
4866
+ * alone, each apart from the other. While either holds, no turn it covers starts there; their
4867
+ * wakes wait in the scheduler's queue, and a turn under way finishes. Pausing what is paused, or
4868
+ * resuming what is not, changes nothing and logs nothing.
4869
+ */
4870
+ async setRunnerPaused(actor, name, change) {
4871
+ this.assertUser(actor, "only the user may pause or resume a runner");
4872
+ const cli = change.cli === void 0 ? void 0 : CliKindSchema.parse(change.cli);
4873
+ return this.mutex.run(async () => {
4874
+ const file = this.paths.runner(name);
4875
+ if (!await exists(file)) {
4876
+ throw new BoardError("NOT_FOUND", `runner ${name} not found`);
4877
+ }
4878
+ const doc = await readMarkdown(file, RunnerSchema);
4879
+ const { paused, pausedClis, ...rest } = doc.data;
4880
+ if (cli !== void 0 && change.paused && !doc.data.clis.includes(cli)) {
4881
+ throw new BoardError("INVALID_STATE", `runner ${name} has no ${cli} to pause`);
4882
+ }
4883
+ const current = cli === void 0 ? paused : pausedClis?.[cli];
4884
+ if (current !== void 0 === change.paused) {
4885
+ return doc.data;
4886
+ }
4887
+ const pause = change.paused ? { at: this.now().toISOString(), by: actor.name } : void 0;
4888
+ const clis = cli === void 0 ? { ...pausedClis } : { ...pausedClis, [cli]: pause };
4889
+ const kept = Object.entries(clis).filter(([, each]) => each !== void 0);
4890
+ const whole = cli === void 0 ? pause : paused;
4891
+ const next = RunnerSchema.parse({
4892
+ ...rest,
4893
+ ...whole === void 0 ? {} : { paused: whole },
4894
+ ...kept.length === 0 ? {} : { pausedClis: Object.fromEntries(kept) }
4895
+ });
4896
+ await writeMarkdown(file, next, doc.body);
4897
+ await this.events.append(change.paused ? "runner.paused" : "runner.resumed", actor.name, {
4898
+ name,
4899
+ ...cli === void 0 ? {} : { cli }
4900
+ });
4901
+ return next;
4902
+ });
4903
+ }
4729
4904
  /**
4730
4905
  * Logs an operations signal as an `ops.signal` event. No channel carries it: the scheduler wakes
4731
4906
  * its readers from the event, the runner lists it in their prompts, and the interface's log shows
@@ -4760,8 +4935,9 @@ ${DASHBOARD_LINE}
4760
4935
  }
4761
4936
  if (event.type === "turn.started") {
4762
4937
  const start = TurnStartPayloadSchema.safeParse(event.payload);
4763
- if (start.success)
4764
- starts.set(start.data.turnId, event.ts);
4938
+ if (start.success) {
4939
+ starts.set(start.data.turnId, { ts: event.ts, heartbeat: start.data.heartbeat });
4940
+ }
4765
4941
  continue;
4766
4942
  }
4767
4943
  if (event.type !== "turn.completed" && event.type !== "turn.failed") {
@@ -4769,17 +4945,19 @@ ${DASHBOARD_LINE}
4769
4945
  }
4770
4946
  const parsed = TurnHistoryPayloadSchema.safeParse(event.payload);
4771
4947
  if (parsed.success) {
4772
- const { turnId } = parsed.data;
4773
- const startedAt = turnId === void 0 ? void 0 : starts.get(turnId);
4774
- if (turnId !== void 0) {
4775
- starts.delete(turnId);
4948
+ const { heartbeat: ended, ...turn2 } = parsed.data;
4949
+ const start = turn2.turnId === void 0 ? void 0 : starts.get(turn2.turnId);
4950
+ if (turn2.turnId !== void 0) {
4951
+ starts.delete(turn2.turnId);
4776
4952
  }
4953
+ const heartbeat = ended ?? start?.heartbeat;
4777
4954
  entries.push({
4778
4955
  id: event.id,
4779
4956
  ts: event.ts,
4780
4957
  outcome: event.type === "turn.failed" ? "failed" : "completed",
4781
- ...parsed.data,
4782
- ...startedAt === void 0 ? {} : { startedAt }
4958
+ ...turn2,
4959
+ ...heartbeat === void 0 ? {} : { heartbeat },
4960
+ ...start === void 0 ? {} : { startedAt: start.ts }
4783
4961
  });
4784
4962
  }
4785
4963
  }
@@ -4809,14 +4987,31 @@ ${DASHBOARD_LINE}
4809
4987
  return society;
4810
4988
  });
4811
4989
  }
4812
- /** Releases every claim whose lease has expired. Called by the scheduler on its tick. */
4990
+ /**
4991
+ * Releases every claim whose lease has expired, but for a holder's that its own cron in the
4992
+ * task's thread keeps (`keptLease`). Called by the scheduler on its tick.
4993
+ */
4813
4994
  async expireLeases() {
4814
4995
  return this.mutex.run(async () => {
4815
4996
  const expired = [];
4816
4997
  const now = this.now();
4998
+ let crons;
4999
+ let runners;
4817
5000
  for (const project2 of await listDirs(this.paths.projects())) {
5001
+ let place;
4818
5002
  for (const task2 of await this.listTasks(project2)) {
4819
- if (task2.status === "claimed" && this.leaseExpired(task2, now)) {
5003
+ if (task2.status !== "claimed") {
5004
+ continue;
5005
+ }
5006
+ crons ??= await this.listCrons();
5007
+ runners ??= await this.listRunners();
5008
+ place ??= await this.readProject(project2);
5009
+ const kept = latestLease(this.keptLease(task2, crons, now), await this.pausedLease(task2, runners, place.runner, now));
5010
+ if (kept !== null) {
5011
+ await this.writeTask(project2, { ...task2, leaseExpiresAt: kept });
5012
+ continue;
5013
+ }
5014
+ if (this.leaseExpired(task2, now)) {
4820
5015
  const released = await this.writeTask(project2, {
4821
5016
  ...task2,
4822
5017
  status: "open",
@@ -4885,20 +5080,24 @@ ${DASHBOARD_LINE}
4885
5080
  * general channel, one of its other channels, one of its threads, or, in the society scope, a
4886
5081
  * project's channels. The heartbeat asks, so that it wakes a member where its unread
4887
5082
  * messages are and nowhere else. Society channels count only for a member woken in the society
4888
- * scope; a project member reads them at its next turn. Two kinds of post are in the digest but not
5083
+ * scope; a project member reads them at its next turn. Some posts are in the digest but not
4889
5084
  * counted, and are read when the member wakes for something else: a task step's, which wakes
4890
- * whoever it hands work to, and the board's own, operations signals and landing announcements,
4891
- * whose signals that call for action wake their readers directly. A closed thread counts for
4892
- * nothing: its conversation has ended, and a turn there could only read its posts and be refused
4893
- * a reply, while a wake queued before it closed, as for a mention, still runs there.
5085
+ * whoever it hands work to; the board's own, operations signals and landing announcements, whose
5086
+ * signals that call for action wake their readers directly; and a knowledge topic's notice, which
5087
+ * asks nothing of anyone. In a task's thread a post counts only for whoever the current stage is
5088
+ * for, its holder or, while nobody holds it, a member who may take it, and for the task's creator
5089
+ * when it mentions the user, so the creator can put a wait on the user in; a post that asks the
5090
+ * reader counts wherever it is. A closed thread counts for nothing: its conversation has ended,
5091
+ * and a turn there could only read its posts and be refused a reply, while a wake queued before
5092
+ * it closed, as for a mention, still runs there.
4894
5093
  */
4895
5094
  async unreadByConversation(actor) {
4896
5095
  const { cursors, upto } = await this.digestSnapshot(actor.name);
4897
5096
  const counts = /* @__PURE__ */ new Map();
4898
- const closed = /* @__PURE__ */ new Map();
5097
+ const threadCounts = /* @__PURE__ */ new Map();
4899
5098
  const unscoped = { name: actor.name, role: actor.role };
4900
- for (const { scope, part, message, news } of await this.digestSince(unscoped, cursors, upto)) {
4901
- if (news === true || message.step !== void 0 || message.author === SYSTEM_ACTOR.name) {
5099
+ for (const { scope, part, message, news, asked } of await this.digestSince(unscoped, cursors, upto)) {
5100
+ if (news === true || message.step !== void 0 || message.author === SYSTEM_ACTOR.name || message.notice?.kind === "knowledge") {
4902
5101
  continue;
4903
5102
  }
4904
5103
  const posted = parseChannelRef(message.channel).project ?? SOCIETY_SCOPE;
@@ -4907,12 +5106,13 @@ ${DASHBOARD_LINE}
4907
5106
  }
4908
5107
  const thread2 = threadOfPart(part);
4909
5108
  if (thread2 !== void 0) {
4910
- let ended = closed.get(thread2);
4911
- if (ended === void 0) {
4912
- ended = (await this.tryFindThread(thread2))?.thread.state === "closed";
4913
- closed.set(thread2, ended);
5109
+ let counted = threadCounts.get(thread2);
5110
+ if (counted === void 0) {
5111
+ counted = await this.threadCount(actor, thread2);
5112
+ threadCounts.set(thread2, counted);
4914
5113
  }
4915
- if (ended) {
5114
+ const forReader = counted === "every" || counted !== "none" && asked === true || counted === "asks-user" && message.mentions.includes(USER_NAME);
5115
+ if (!forReader) {
4916
5116
  continue;
4917
5117
  }
4918
5118
  }
@@ -5065,7 +5265,7 @@ ${DASHBOARD_LINE}
5065
5265
  include = participates;
5066
5266
  }
5067
5267
  if (include && message.author !== actor.name) {
5068
- collected.push({ scope: filedIn, part, message });
5268
+ collected.push({ scope: filedIn, part, message, ...asked ? { asked: true } : {} });
5069
5269
  }
5070
5270
  }
5071
5271
  return collected.toSorted(byId);
@@ -5342,12 +5542,30 @@ ${current.body}`);
5342
5542
  throw new BoardError("NOT_FOUND", "this board has no runners to read an image from");
5343
5543
  }
5344
5544
  const bytes = await this.imageSource(actor, args.path);
5545
+ return this.keepImage(actor, bytes, args.path, path8.basename(args.path));
5546
+ }
5547
+ /**
5548
+ * Keeps an image the user posts from a composer, as its bytes, under the same checks as an image
5549
+ * a turn uploads. `name`, the file's, describes it in the markdown answered; without one the
5550
+ * description is empty, for the user to write.
5551
+ */
5552
+ async postImage(actor, bytes, name) {
5553
+ this.assertUser(actor, "only the user posts an image's bytes; a turn uploads one with upload_image");
5554
+ const given = name === void 0 ? "" : ImageNameSchema.parse(name);
5555
+ return this.keepImage(actor, bytes, given === "" ? "the image" : given, given);
5556
+ }
5557
+ /**
5558
+ * Keeps `bytes` under a new id when they are a PNG, JPEG, GIF, or WebP of at most
5559
+ * `IMAGE_LIMIT_BYTES`, and answers the markdown that shows them, described by `name`. `what`
5560
+ * names the image in a refusal.
5561
+ */
5562
+ async keepImage(actor, bytes, what, name) {
5345
5563
  const type = imageTypeOf(bytes);
5346
5564
  if (type === null) {
5347
- throw new BoardError("VALIDATION", `${args.path} is not a PNG, JPEG, GIF, or WebP image by its first bytes`);
5565
+ throw new BoardError("VALIDATION", `${what} is not a PNG, JPEG, GIF, or WebP image by its first bytes`);
5348
5566
  }
5349
5567
  if (bytes.byteLength > IMAGE_LIMIT_BYTES) {
5350
- throw new BoardError("VALIDATION", `${args.path} is ${bytes.byteLength} bytes, over the ${IMAGE_LIMIT_BYTES} an image may be`);
5568
+ throw new BoardError("VALIDATION", `${what} is ${bytes.byteLength} bytes, over the ${IMAGE_LIMIT_BYTES} an image may be`);
5351
5569
  }
5352
5570
  return this.mutex.run(async () => {
5353
5571
  const id = this.newId();
@@ -5357,15 +5575,37 @@ ${current.body}`);
5357
5575
  type,
5358
5576
  size: bytes.byteLength
5359
5577
  });
5360
- const name = path8.basename(args.path).replaceAll(/[[\]\\\r\n]/g, "");
5578
+ const description = imageDescription(name);
5361
5579
  return {
5362
5580
  id,
5363
5581
  type,
5364
5582
  size: bytes.byteLength,
5365
- markdown: `![${name}](${IMAGE_SCHEME}${id})`
5583
+ markdown: `![${description}](${IMAGE_SCHEME}${id})`
5366
5584
  };
5367
5585
  });
5368
5586
  }
5587
+ /**
5588
+ * Opens an image the board keeps for the calling turn, by its id or its `image:<id>` address:
5589
+ * the runner the turn runs on writes the bytes to a file in a folder of the turn's own in the
5590
+ * system temp folder, removed when the turn ends, and the board answers its path, for the
5591
+ * citizen to open with its own file tools, since a post reaches a turn as markdown and an image
5592
+ * in it as its address. Any image the board keeps may be opened: every post, brief, topic, and
5593
+ * dashboard that could show one is in the projection every turn reads.
5594
+ */
5595
+ async openImage(actor, input) {
5596
+ const args = VerbInputs.read_image.parse(input);
5597
+ await this.authorize(actor, "read_image");
5598
+ if (actor.scope === void 0) {
5599
+ throw new BoardError("INVALID_STATE", "read_image writes the image on the machine a turn runs on, so only a turn can call it");
5600
+ }
5601
+ if (this.imageSink === null) {
5602
+ throw new BoardError("NOT_FOUND", "this board has no runners to write an image to");
5603
+ }
5604
+ const id = imageIdOf(args.image_id);
5605
+ const { type, bytes } = await this.readImage(id);
5606
+ const file = await this.imageSink(actor, { id, type, bytes });
5607
+ return { id, type, size: bytes.byteLength, path: file };
5608
+ }
5369
5609
  /** An uploaded image's bytes and kind, which its first bytes tell. */
5370
5610
  async readImage(id) {
5371
5611
  const file = this.paths.image(UlidSchema.parse(id));
@@ -5711,6 +5951,13 @@ ${current.body}`);
5711
5951
  readImagesWith(source) {
5712
5952
  this.imageSource = source;
5713
5953
  }
5954
+ /**
5955
+ * Where `read_image` writes an image to: the runner the calling turn runs on, through the server
5956
+ * that hosts the runners. Without one, as in the admin CLI, nothing can be opened.
5957
+ */
5958
+ openImagesWith(sink) {
5959
+ this.imageSink = sink;
5960
+ }
5714
5961
  /**
5715
5962
  * Refuses to end conversations while another citizen's turn is in one of them: `cut` picks the
5716
5963
  * turns that would lose theirs. The caller's own turn is exempt, since ending a conversation
@@ -6546,6 +6793,8 @@ ${rationale}`}`);
6546
6793
  return this.removeCron(actor, VerbInputs.remove_cron.parse(input));
6547
6794
  case "upload_image":
6548
6795
  return this.uploadImage(actor, VerbInputs.upload_image.parse(input));
6796
+ case "read_image":
6797
+ return this.openImage(actor, VerbInputs.read_image.parse(input));
6549
6798
  default:
6550
6799
  throw new BoardError("VALIDATION", `unknown verb ${String(verb)}`);
6551
6800
  }
@@ -6781,6 +7030,7 @@ ${rationale}`}`);
6781
7030
  ...parsed.channel === void 0 ? {} : { channel: parsed.channel },
6782
7031
  ...parsed.place === void 0 ? {} : { place: parsed.place },
6783
7032
  trigger: parsed.trigger.kind,
7033
+ ...parsed.trigger.heartbeat === void 0 ? {} : { heartbeat: parsed.trigger.heartbeat },
6784
7034
  session: parsed.session,
6785
7035
  runner: parsed.runner
6786
7036
  });
@@ -6803,6 +7053,11 @@ ${rationale}`}`);
6803
7053
  for (const id of parsed.crons ?? []) {
6804
7054
  await this.recordCronRun(id, parsed, failed);
6805
7055
  }
7056
+ if (parsed.exitReason === "completed" || parsed.exitReason === "blocked") {
7057
+ for (const shown of parsed.cronFailures ?? []) {
7058
+ await this.catchUpCron(shown, parsed);
7059
+ }
7060
+ }
6806
7061
  if (parsed.exitReason === "stopped") {
6807
7062
  await this.events.append("turn.stopped", parsed.stoppedBy ?? USER_NAME, {
6808
7063
  ...parsed.id === void 0 ? {} : { turnId: parsed.id },
@@ -6820,6 +7075,7 @@ ${rationale}`}`);
6820
7075
  ...parsed.channel === void 0 ? {} : { channel: parsed.channel },
6821
7076
  ...parsed.place === void 0 ? {} : { place: parsed.place },
6822
7077
  trigger: parsed.trigger.kind,
7078
+ ...parsed.trigger.heartbeat === void 0 ? {} : { heartbeat: parsed.trigger.heartbeat },
6823
7079
  session: parsed.session,
6824
7080
  exitReason: parsed.exitReason,
6825
7081
  costUsd: parsed.costUsd,
@@ -6943,6 +7199,27 @@ ${rationale}`}`);
6943
7199
  failures: outcome === "failed" ? cron2.failures + 1 : outcome === "completed" ? 0 : cron2.failures
6944
7200
  });
6945
7201
  }
7202
+ /**
7203
+ * Marks a cron's failed fire caught up by a completed turn whose prompt named it, while that
7204
+ * failure is still its latest run. Its schedule and its run of failures stay as they are.
7205
+ */
7206
+ async catchUpCron(shown, record) {
7207
+ if (record.id === void 0 || !await exists(this.paths.cron(shown.cron))) {
7208
+ return;
7209
+ }
7210
+ const cron2 = await this.readCron(shown.cron);
7211
+ const failure = cronFailure(cron2);
7212
+ if (failure === null || failure.turnId !== shown.turnId) {
7213
+ return;
7214
+ }
7215
+ await this.writeCron({
7216
+ ...cron2,
7217
+ lastRun: {
7218
+ ...failure,
7219
+ caughtUp: { turnId: record.id, at: record.endedAt ?? this.now().toISOString() }
7220
+ }
7221
+ });
7222
+ }
6946
7223
  /**
6947
7224
  * Whether a conversation with a workspace of its own has ended: a thread closed, a task ended
6948
7225
  * (for one from before tasks had threads), a channel archived, or a project archived, which ends
@@ -8312,7 +8589,7 @@ ${charter.purpose}
8312
8589
  }
8313
8590
  assertMayGate(actor, what) {
8314
8591
  if (!PLANNING_ROLES.includes(actor.role)) {
8315
- throw new BoardError("FORBIDDEN", `only the user, the steward, and the concierge may ${what}; ask one of them in the task's thread`);
8592
+ 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`);
8316
8593
  }
8317
8594
  }
8318
8595
  /** The user holds anything; everyone else must be a project member the current stage admits. */
@@ -8354,6 +8631,51 @@ ${charter.purpose}
8354
8631
  leaseEnd(now) {
8355
8632
  return new Date(now.getTime() + this.leaseMs).toISOString();
8356
8633
  }
8634
+ /**
8635
+ * The lease a holder's own crons in the task's thread keep its claim to, when later than the one
8636
+ * it has, else null. While one of them is pending, the lease is renewed for a full lease once less
8637
+ * than half is left, so it never lapses between fires that may be hours apart; once every one is
8638
+ * paused, ended, or failed at its latest fire with nothing catching it up (`cronStoppedAt`), it
8639
+ * runs a full lease from when the last of them stopped.
8640
+ */
8641
+ keptLease(task2, crons, now) {
8642
+ if (task2.leaseExpiresAt === void 0) {
8643
+ return null;
8644
+ }
8645
+ const lease = Date.parse(task2.leaseExpiresAt);
8646
+ let pending = false;
8647
+ let stopped = null;
8648
+ for (const cron2 of crons) {
8649
+ if (cron2.agent !== task2.claimedBy || !firesInTask(cron2, task2)) {
8650
+ continue;
8651
+ }
8652
+ const at = cronStoppedAt(cron2);
8653
+ if (at === null) {
8654
+ pending = true;
8655
+ } else {
8656
+ stopped = Math.max(stopped ?? 0, Date.parse(at));
8657
+ }
8658
+ }
8659
+ const kept = pending ? lease - now.getTime() < this.leaseMs / 2 ? now.getTime() + this.leaseMs : lease : stopped === null ? lease : stopped + this.leaseMs;
8660
+ return kept > lease ? new Date(kept).toISOString() : null;
8661
+ }
8662
+ /**
8663
+ * The lease a holder keeps while the user's pause holds its turns in the task's project
8664
+ * (`heldOn`), when later than the one it has, else null. It is renewed for a full lease once less
8665
+ * than half is left, as for a pending cron, so it never lapses while the holder cannot take a
8666
+ * turn there, and after the resume the holder has at least half a lease to come back to it.
8667
+ */
8668
+ async pausedLease(task2, runners, runner2, now) {
8669
+ if (task2.claimedBy === void 0 || task2.leaseExpiresAt === void 0) {
8670
+ return null;
8671
+ }
8672
+ const holder = await this.readAgent(task2.claimedBy).catch(() => null);
8673
+ if (holder === null || !heldOn(runners, runner2, holder.cli)) {
8674
+ return null;
8675
+ }
8676
+ const lease = Date.parse(task2.leaseExpiresAt);
8677
+ return lease - now.getTime() < this.leaseMs / 2 ? this.leaseEnd(now) : null;
8678
+ }
8357
8679
  leaseExpired(task2, now) {
8358
8680
  return task2.leaseExpiresAt !== void 0 && new Date(task2.leaseExpiresAt).getTime() <= now.getTime();
8359
8681
  }
@@ -8466,11 +8788,7 @@ ${charter.purpose}
8466
8788
  if (task2.claimedBy === actor.name || task2.createdBy === actor.name || task2.stages.some((stage) => stage.agent === actor.name || stage.holders.includes(actor.name))) {
8467
8789
  return true;
8468
8790
  }
8469
- if (task2.status !== "open" || task2.completing) {
8470
- return false;
8471
- }
8472
- const agent2 = await this.readAgent(actor.name);
8473
- return agent2.memberships.includes(task2.project) && mayHoldStage(actor, task2);
8791
+ return await this.mayTakeStage(actor, task2);
8474
8792
  }
8475
8793
  if (subject?.kind === "proposal") {
8476
8794
  const proposal2 = await this.readProposal(subject.id);
@@ -8484,6 +8802,34 @@ ${charter.purpose}
8484
8802
  }
8485
8803
  return false;
8486
8804
  }
8805
+ /** Whether a task's current stage waits for a holder and the member may take it. */
8806
+ async mayTakeStage(actor, task2) {
8807
+ if (task2.status !== "open" || task2.completing) {
8808
+ return false;
8809
+ }
8810
+ const agent2 = await this.readAgent(actor.name);
8811
+ return agent2.memberships.includes(task2.project) && mayHoldStage(actor, task2);
8812
+ }
8813
+ /**
8814
+ * Which of a thread's posts count toward a reader's heartbeat (see `unreadByConversation`). A
8815
+ * thread, or a task, the board cannot find counts as an open topic's would.
8816
+ */
8817
+ async threadCount(actor, id) {
8818
+ const found = await this.tryFindThread(id);
8819
+ if (found?.thread.state === "closed") {
8820
+ return "none";
8821
+ }
8822
+ const subject = found?.thread.subject;
8823
+ const task2 = subject?.kind === "task" ? await this.findTask(subject.id).then((location) => location.task, () => null) : null;
8824
+ if (task2 === null) {
8825
+ return "every";
8826
+ }
8827
+ const stageIsTheirs = task2.status === "claimed" ? task2.claimedBy === actor.name : await this.mayTakeStage(actor, task2);
8828
+ if (stageIsTheirs) {
8829
+ return "every";
8830
+ }
8831
+ return task2.createdBy === actor.name ? "asks-user" : "asked";
8832
+ }
8487
8833
  async threadScopes() {
8488
8834
  const projects = await listDirs(this.paths.projects());
8489
8835
  return [this.paths.societyThreads(), ...projects.map((slug) => this.paths.threads(slug))];
@@ -8694,8 +9040,21 @@ runner.command("add <name>").description("Register a runner and print its token
8694
9040
  runner.command("list").description("List runners and what they offer").action(async () => {
8695
9041
  const board = await open3();
8696
9042
  const runners = await board.listRunners();
8697
- print(runners, () => runners.length === 0 ? "No runners yet; add one with `runner add <name>`." : runners.map((each) => `${each.name} ${each.status} ${each.os}${each.version === void 0 ? "" : ` v${each.version}`} ${each.clis.join(",") || "no CLIs yet"}${each.capabilities.length === 0 ? "" : ` ${each.capabilities.join(",")}`}`).join("\n"));
9043
+ print(runners, () => runners.length === 0 ? "No runners yet; add one with `runner add <name>`." : runners.map((each) => `${each.name} ${each.status}${each.paused === void 0 ? "" : " paused"} ${each.os}${each.version === void 0 ? "" : ` v${each.version}`} ${each.clis.join(",") || "no CLIs yet"}${each.capabilities.length === 0 ? "" : ` ${each.capabilities.join(",")}`}${Object.keys(each.pausedClis ?? {}).map((cli) => ` ${cli} paused`).join("")}`).join("\n"));
8698
9044
  });
9045
+ for (const [verb, paused] of [
9046
+ ["pause", true],
9047
+ ["resume", false]
9048
+ ]) {
9049
+ runner.command(`${verb} <name> [cli]`).description(paused ? "Start no more turns on a runner, or with one of its CLIs, until resumed; turns in flight finish" : "Let a paused runner, or a paused CLI on it, take turns again").action(async (name, cli) => {
9050
+ const board = await open3();
9051
+ const updated = await board.setRunnerPaused(board.userActor(), name, {
9052
+ paused,
9053
+ ...cli === void 0 ? {} : { cli: CliKindSchema.parse(cli) }
9054
+ });
9055
+ print(updated, () => `${cli === void 0 ? name : `${cli} on ${name}`} ${verb}d`);
9056
+ });
9057
+ }
8699
9058
  var agent = program.command("agent").description("Manage agents");
8700
9059
  agent.command("add <name>").description("Add an agent and print its board token once").requiredOption("--role <role>", "role charter name").option("--cli <cli>", "claude or codex").option("--model <model>", "model to run the CLI with; the CLI's own default otherwise").option("--runner <name>", "runner preferred for its turns; any with its CLI otherwise").option("-p, --project <slug...>", "project memberships").action(async (name, opts) => {
8701
9060
  const board = await open3();