@kubitnodes/stellaris 0.4.0 → 0.4.2

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 (59) hide show
  1. package/dist/cli/index.js +644 -36
  2. package/dist/server/index.js +901 -116
  3. package/package.json +1 -1
  4. package/web/dist/assets/{BackLink-BLiSCuKv.js → BackLink-DNHWkHmh.js} +1 -1
  5. package/web/dist/assets/{Button-D4-iu6aX.js → Button-B6EN7Lh7.js} +1 -1
  6. package/web/dist/assets/{ChannelForms-DjviQfVm.js → ChannelForms-BrcWrkej.js} +1 -1
  7. package/web/dist/assets/ChannelView-D7bT7d9q.js +2 -0
  8. package/web/dist/assets/CitizenView-D6d1KnS6.js +2 -0
  9. package/web/dist/assets/{CitizensView-DXD3qkq1.js → CitizensView-C55zOiYE.js} +1 -1
  10. package/web/dist/assets/{CliIcon-DL4ZwgZv.js → CliIcon-BRq1tnhu.js} +1 -1
  11. package/web/dist/assets/{CronsView-CX_G5D3h.js → CronsView-BTGiqn10.js} +1 -1
  12. package/web/dist/assets/{FileTree-BFa_KVI8.js → FileTree-BDhtTIzT.js} +1 -1
  13. package/web/dist/assets/{FileView-Cd9Ijarf.js → FileView-BkZPGEbO.js} +2 -2
  14. package/web/dist/assets/{KnowledgeView-D_a4pjGV.js → KnowledgeView-BiTS-lyE.js} +1 -1
  15. package/web/dist/assets/{Markdown-DPqvK--4.js → Markdown-DYk-gVwe.js} +4 -4
  16. package/web/dist/assets/{MessageItem-WBTzWwyj.js → MessageItem-WBBIzv48.js} +1 -1
  17. package/web/dist/assets/MetricsView-CYmnG6aQ.js +1 -0
  18. package/web/dist/assets/NeedsYouView-B-5olhZL.js +1 -0
  19. package/web/dist/assets/{Overview-BGUnf6K1.js → Overview-BO_lR1cu.js} +1 -1
  20. package/web/dist/assets/{Pane-C9TbEME4.js → Pane-DFJdAhv9.js} +1 -1
  21. package/web/dist/assets/{Picker-BK48MjLY.js → Picker-CAEREZF9.js} +1 -1
  22. package/web/dist/assets/{ProjectView-sL3aGAC6.js → ProjectView-LU59V2vh.js} +1 -1
  23. package/web/dist/assets/ProposalView-DHemTlkc.js +1 -0
  24. package/web/dist/assets/{ProposalsView-DTGx8NDn.js → ProposalsView-DBIOJAul.js} +1 -1
  25. package/web/dist/assets/{Quota-BkF7pa0u.js → Quota-D6_yyawu.js} +1 -1
  26. package/web/dist/assets/RunnersView-BNKgYuNw.js +1 -0
  27. package/web/dist/assets/{SkillFileList-BUpKUkaG.js → SkillFileList-Ntyv8noL.js} +1 -1
  28. package/web/dist/assets/SkillView-Dez1fvqU.js +1 -0
  29. package/web/dist/assets/{SocietyView-JSdVE50m.js → SocietyView-CbmbQXHi.js} +1 -1
  30. package/web/dist/assets/{StickyList-DafbJutb.js → StickyList-Q0KEWBUl.js} +1 -1
  31. package/web/dist/assets/{SubjectThread-Jyl5YCFL.js → SubjectThread-1PnxxtZc.js} +1 -1
  32. package/web/dist/assets/{TaskView-Jke2pVGT.js → TaskView-CcIlNHBh.js} +2 -2
  33. package/web/dist/assets/{TasksView-zJTIaqEA.js → TasksView-BkTU43PD.js} +1 -1
  34. package/web/dist/assets/{ThreadForms-DwUOktyP.js → ThreadForms-BlOEvY5E.js} +1 -1
  35. package/web/dist/assets/ThreadItem-D5N_Fw1K.js +1 -0
  36. package/web/dist/assets/{ThreadView-CdQpBb7M.js → ThreadView-BaYV77mQ.js} +1 -1
  37. package/web/dist/assets/{citizen-BOn5BBRL.js → citizen-BRg9UIgD.js} +1 -1
  38. package/web/dist/assets/{compose-D-X3BQgY.js → compose-BnOoMY1c.js} +1 -1
  39. package/web/dist/assets/format-hyYD8qd8.js +55 -0
  40. package/web/dist/assets/governance-D4hlG-xw.js +1 -0
  41. package/web/dist/assets/index-5CbC_NpF.js +24 -0
  42. package/web/dist/assets/{index-CA_njyMe.css → index-vxBI4BYp.css} +1 -1
  43. package/web/dist/assets/{mentions-S-r32HKM.js → mentions-Ckktc7nR.js} +1 -1
  44. package/web/dist/assets/{tasks-D_Fy7dtN.js → tasks-CFVtN9TK.js} +1 -1
  45. package/web/dist/assets/useMatch-BCaBBHc2.js +1 -0
  46. package/web/dist/favicon.svg +89 -4
  47. package/web/dist/index.html +9 -9
  48. package/web/dist/assets/ChannelView-B-0H7Ps8.js +0 -2
  49. package/web/dist/assets/CitizenView-gE5GgCBa.js +0 -2
  50. package/web/dist/assets/MetricsView-YUkYixqT.js +0 -1
  51. package/web/dist/assets/NeedsYouView-CE_B9bZQ.js +0 -1
  52. package/web/dist/assets/ProposalView-BhZR4MIR.js +0 -1
  53. package/web/dist/assets/RunnersView-Bl62pXNw.js +0 -1
  54. package/web/dist/assets/SkillView-BQJApSh7.js +0 -1
  55. package/web/dist/assets/ThreadItem-Blx8gSvO.js +0 -1
  56. package/web/dist/assets/format-Dhd2NUB3.js +0 -55
  57. package/web/dist/assets/governance-CWzi7hzX.js +0 -1
  58. package/web/dist/assets/index-iplooQqq.js +0 -24
  59. package/web/dist/assets/useMatch-DZXCHHaG.js +0 -1
@@ -398,6 +398,21 @@ function quotaLevel(quota, now) {
398
398
  const high = quota.windows.some((window) => windowCurrent(window, now) && window.usedPercent !== null && window.usedPercent >= QUOTA_WARNING_PERCENT);
399
399
  return quota.status === "warning" || high ? "warning" : "ok";
400
400
  }
401
+ var UNKNOWN_LIMIT_HOLD_MS = 30 * 6e4;
402
+ function stopsTurns(quota) {
403
+ return quota.status === "limited" && !(quota.source === "read" && quota.extraUsage?.enabled === true);
404
+ }
405
+ function limitHold(quota, now) {
406
+ if (!stopsTurns(quota)) {
407
+ return null;
408
+ }
409
+ const known = quota.limitedUntil !== null;
410
+ const until = quota.limitedUntil ?? new Date(Date.parse(quota.observedAt) + UNKNOWN_LIMIT_HOLD_MS).toISOString();
411
+ return Date.parse(until) > now.getTime() ? { until, known } : null;
412
+ }
413
+ function limitLapsed(quota, now) {
414
+ return stopsTurns(quota) && quota.limitedUntil === null && limitHold(quota, now) === null;
415
+ }
401
416
  var WINDOW_NAMES = {
402
417
  five_hour: "5h",
403
418
  seven_day: "7d",
@@ -751,12 +766,16 @@ var RunnerSchema = z5.object({
751
766
  function runnerPause(runner, cli) {
752
767
  return runner.paused ?? runner.pausedClis?.[cli];
753
768
  }
754
- function heldOn(runners2, runner, cli) {
769
+ function runnerLimitHold(runner, cli, now) {
770
+ const quota = runner.quotas?.[cli];
771
+ return quota === void 0 ? null : limitHold(quota, now);
772
+ }
773
+ function heldOn(runners2, runner, cli, now) {
755
774
  if (runner === void 0 || cli === null) {
756
775
  return false;
757
776
  }
758
777
  const record = runners2.find((each) => each.name === runner);
759
- return record !== void 0 && runnerPause(record, cli) !== void 0;
778
+ return record !== void 0 && (runnerPause(record, cli) !== void 0 || runnerLimitHold(record, cli, now) !== null);
760
779
  }
761
780
  var ProposalKindSchema = z5.enum([
762
781
  "role",
@@ -1082,6 +1101,12 @@ var IMAGE_MEDIA_TYPES = {
1082
1101
  gif: "image/gif",
1083
1102
  webp: "image/webp"
1084
1103
  };
1104
+ var IMAGE_KIND_NAMES = {
1105
+ png: "PNG",
1106
+ jpeg: "JPEG",
1107
+ gif: "GIF",
1108
+ webp: "WebP"
1109
+ };
1085
1110
  function startsWith(bytes, prefix, at = 0) {
1086
1111
  for (let index = 0; index < prefix.length; index += 1) {
1087
1112
  if (bytes[at + index] !== prefix[index]) {
@@ -1303,7 +1328,7 @@ var VERB_DESCRIPTIONS = {
1303
1328
  release_task: "Let go of the stage you hold so someone else can take it.",
1304
1329
  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.",
1305
1330
  get_task: "Read a task with its plan, its brief, and the messages of its thread.",
1306
- subscribe: "Subscribe to a channel. Subscriptions feed your digest; they never wake you.",
1331
+ subscribe: "Subscribe to a channel. Its posts feed your digest, read at your next turn there; in a project's channel, one that asks nothing of you wakes you only once it is a day old.",
1307
1332
  unsubscribe: "Unsubscribe from a channel.",
1308
1333
  propose: "Propose a member, role, channel, reallocation, retirement, skill, or a project's archive. The charter shape per kind is in your instructions. Opens the proposal's thread in governance with your rationale; approval provisions it.",
1309
1334
  approve: "Approve a proposal; the board then provisions it, posts the decision in the proposal's thread, and closes it. User and steward only, never on your own proposal.",
@@ -1324,7 +1349,7 @@ var VERB_DESCRIPTIONS = {
1324
1349
  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.",
1325
1350
  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.",
1326
1351
  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.",
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.",
1352
+ 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 once its metadata is left out, 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
1353
  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."
1329
1354
  };
1330
1355
 
@@ -1546,7 +1571,11 @@ var RunnerHelloSchema = z9.object({
1546
1571
  gitIdentityMissing: z9.array(GitIdentityPartSchema).optional(),
1547
1572
  /** Turns the runner runs at once, or null for no limit. */
1548
1573
  slots: z9.number().int().positive().nullable(),
1549
- /** Turns the runner is still running, when it registers again; the server fails the rest of its turns. */
1574
+ /**
1575
+ * Turns the runner is still running, when it registers again: the server fails the rest of its
1576
+ * turns, and has any of these it has ended stopped once the stream opens, when the runner lists
1577
+ * a CLI it can stop.
1578
+ */
1550
1579
  turns: z9.array(UlidSchema).default([])
1551
1580
  });
1552
1581
  var RunnerWelcomeSchema = z9.object({ name: NameSchema, version: z9.string() });
@@ -1709,9 +1738,9 @@ var TurnOutcomeSchema = z9.object({
1709
1738
  */
1710
1739
  resumeFailed: ResumeFailureSchema.optional(),
1711
1740
  /**
1712
- * Set when the model service turned the turn away for now, as when it was overloaded or failed
1713
- * on its side, which the scheduler answers by sending the same wake again within minutes. A
1714
- * runner from before never sends it.
1741
+ * Set when the turn failed for now: the model service turned it away or cut it off, or the runner
1742
+ * could not reach the board before the CLI started. The scheduler answers it by sending the same
1743
+ * wake again within minutes. A runner from before never sends it.
1715
1744
  */
1716
1745
  transient: z9.boolean().optional(),
1717
1746
  /** The model the CLI reported, when it reported one. */
@@ -2130,13 +2159,18 @@ var TurnRecordSchema = z10.object({
2130
2159
  stoppedBy: NameSchema.optional(),
2131
2160
  /** Why the CLI could not resume the turn's session, for a turn that failed so. */
2132
2161
  resumeFailed: ResumeFailureSchema.optional(),
2133
- /** Whether the model service turned the turn away for now: overloaded, or failing on its side. */
2162
+ /**
2163
+ * Whether the turn failed for now, which the same wake may get past in a minute: the model service
2164
+ * turned it away or cut it off, the runner could not reach the board before the CLI started, or
2165
+ * the job never reached the runner.
2166
+ */
2134
2167
  transient: z10.boolean().optional(),
2135
2168
  /** How many turns in a row, this one included, could not resume the session for a `retry`. */
2136
2169
  resumeRetries: z10.number().int().positive().optional(),
2137
2170
  /**
2138
2171
  * 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.
2172
+ * anew: one its CLI could not resume, or a society role's or a project member's home after a
2173
+ * reflection completed.
2140
2174
  */
2141
2175
  sessionDropped: z10.boolean().optional(),
2142
2176
  /** The crons whose fire started the turn, which record how it ended. */
@@ -2384,6 +2418,11 @@ var ServerConfigSchema = z13.object({
2384
2418
  dataDir: z13.string().min(1),
2385
2419
  host: z13.string().min(1),
2386
2420
  port: z13.number().int().positive().max(65535),
2421
+ /**
2422
+ * A second port, on the same host, that serves Prometheus's counters at `/metrics` and nothing
2423
+ * else, or null for none: the main port's `/metrics` is the interface's page.
2424
+ */
2425
+ metricsPort: z13.number().int().positive().max(65535).nullable(),
2387
2426
  /**
2388
2427
  * An address every runner's agents reach this server at, for example https://board.example, or
2389
2428
  * null for each runner to use the address it reaches the server at itself.
@@ -2435,10 +2474,11 @@ function githubSignIn(env) {
2435
2474
  return GithubSignInSchema.parse({ clientId, clientSecret, users, appleAppId });
2436
2475
  }
2437
2476
  function loadServerConfig(env) {
2438
- return ServerConfigSchema.parse({
2477
+ const config2 = ServerConfigSchema.parse({
2439
2478
  dataDir: env["STELLARIS_DATA_DIR"] ?? "./data",
2440
2479
  host: env["STELLARIS_HOST"] ?? "127.0.0.1",
2441
2480
  port: Number(env["STELLARIS_PORT"] ?? "4700"),
2481
+ metricsPort: env["STELLARIS_METRICS_PORT"]?.trim() ? Number(env["STELLARIS_METRICS_PORT"]) : null,
2442
2482
  publicUrl: env["STELLARIS_PUBLIC_URL"]?.replace(/\/+$/, "") ?? null,
2443
2483
  logLevel: env["STELLARIS_LOG_LEVEL"] ?? "info",
2444
2484
  concurrency: limit(env["STELLARIS_CONCURRENCY"], null),
@@ -2448,6 +2488,10 @@ function loadServerConfig(env) {
2448
2488
  github: githubSignIn(env),
2449
2489
  trustedProxies: Number(env["STELLARIS_TRUSTED_PROXIES"] ?? "0")
2450
2490
  });
2491
+ if (config2.metricsPort === config2.port) {
2492
+ throw new Error("STELLARIS_METRICS_PORT must differ from STELLARIS_PORT");
2493
+ }
2494
+ return config2;
2451
2495
  }
2452
2496
  var RunnerConfigSchema = z13.object({
2453
2497
  /** The board server, for example http://127.0.0.1:4700. */
@@ -2467,7 +2511,7 @@ var RunnerConfigSchema = z13.object({
2467
2511
  });
2468
2512
 
2469
2513
  // packages/shared/dist/version.js
2470
- var STELLARIS_VERSION = "0.4.0";
2514
+ var STELLARIS_VERSION = "0.4.2";
2471
2515
 
2472
2516
  // packages/shared/dist/mentions.js
2473
2517
  import { parse, postprocess, preprocess } from "micromark";
@@ -3162,6 +3206,451 @@ function filesOf(lines, status) {
3162
3206
  return [...files.values()];
3163
3207
  }
3164
3208
 
3209
+ // packages/shared/dist/image-metadata.js
3210
+ function withoutMetadata(bytes) {
3211
+ switch (imageTypeOf(bytes)) {
3212
+ case "jpeg":
3213
+ return jpegWithoutMetadata(bytes);
3214
+ case "png":
3215
+ return pngWithoutMetadata(bytes);
3216
+ case "gif":
3217
+ return gifWithoutMetadata(bytes);
3218
+ case "webp":
3219
+ return webpWithoutMetadata(bytes);
3220
+ default:
3221
+ return null;
3222
+ }
3223
+ }
3224
+ function exifOrientation(block) {
3225
+ const tiff = startsWith2(block, EXIF_HEADER) ? block.subarray(EXIF_HEADER.length) : block;
3226
+ let little;
3227
+ if (tiff[0] === 73 && tiff[1] === 73) {
3228
+ little = true;
3229
+ } else if (tiff[0] === 77 && tiff[1] === 77) {
3230
+ little = false;
3231
+ } else {
3232
+ return 1;
3233
+ }
3234
+ const number = (offset, size) => offset >= 0 && size <= tiff.length && offset <= tiff.length - size ? numberAt(tiff, offset, size, little) : null;
3235
+ const directory = number(4, 4);
3236
+ const count = directory === null ? null : number(directory, 2);
3237
+ if (number(2, 2) !== 42 || directory === null || count === null) {
3238
+ return 1;
3239
+ }
3240
+ for (let entry = directory + 2; entry < directory + 2 + count * 12; entry += 12) {
3241
+ const tag = number(entry, 2);
3242
+ if (tag === null) {
3243
+ return 1;
3244
+ }
3245
+ if (tag === 274) {
3246
+ const value = number(entry + 8, 2);
3247
+ if (number(entry + 2, 2) !== 3 || number(entry + 4, 4) !== 1 || value === null || value < 1 || value > 8) {
3248
+ return 1;
3249
+ }
3250
+ return value;
3251
+ }
3252
+ }
3253
+ return 1;
3254
+ }
3255
+ function orientationOnly(orientation) {
3256
+ const block = new Uint8Array([
3257
+ 77,
3258
+ 77,
3259
+ 0,
3260
+ 42,
3261
+ 0,
3262
+ 0,
3263
+ 0,
3264
+ 8,
3265
+ 0,
3266
+ 1,
3267
+ 1,
3268
+ 18,
3269
+ 0,
3270
+ 3,
3271
+ 0,
3272
+ 0,
3273
+ 0,
3274
+ 1,
3275
+ 0,
3276
+ 0,
3277
+ 0,
3278
+ 0,
3279
+ 0,
3280
+ 0,
3281
+ 0,
3282
+ 0
3283
+ ]);
3284
+ block[19] = orientation;
3285
+ return block;
3286
+ }
3287
+ function crc32(bytes) {
3288
+ let crc = 4294967295;
3289
+ for (const byte of bytes) {
3290
+ crc = (crcTable()[(crc ^ byte) & 255] ?? 0) ^ crc >>> 8;
3291
+ }
3292
+ return (crc ^ 4294967295) >>> 0;
3293
+ }
3294
+ var crcEntries;
3295
+ function crcTable() {
3296
+ if (crcEntries === void 0) {
3297
+ crcEntries = new Uint32Array(256);
3298
+ for (let index = 0; index < 256; index += 1) {
3299
+ let crc = index;
3300
+ for (let bit = 0; bit < 8; bit += 1) {
3301
+ crc = crc & 1 ? 3988292384 ^ crc >>> 1 : crc >>> 1;
3302
+ }
3303
+ crcEntries[index] = crc >>> 0;
3304
+ }
3305
+ }
3306
+ return crcEntries;
3307
+ }
3308
+ var ascii2 = (text) => new TextEncoder().encode(text);
3309
+ var EXIF_HEADER = Uint8Array.of(69, 120, 105, 102, 0, 0);
3310
+ function startsWith2(bytes, prefix, at = 0) {
3311
+ if (bytes.length - at < prefix.length) {
3312
+ return false;
3313
+ }
3314
+ for (let index = 0; index < prefix.length; index += 1) {
3315
+ if (bytes[at + index] !== prefix[index]) {
3316
+ return false;
3317
+ }
3318
+ }
3319
+ return true;
3320
+ }
3321
+ function typeAt(bytes, at) {
3322
+ return String.fromCharCode(bytes[at] ?? 0, bytes[at + 1] ?? 0, bytes[at + 2] ?? 0, bytes[at + 3] ?? 0);
3323
+ }
3324
+ function numberAt(bytes, at, size, little = false) {
3325
+ let value = 0;
3326
+ for (let index = 0; index < size; index += 1) {
3327
+ value = value * 256 + (bytes[at + (little ? size - 1 - index : index)] ?? 0);
3328
+ }
3329
+ return value;
3330
+ }
3331
+ function bigEndian(value, size) {
3332
+ return Array.from({ length: size }, (_, index) => value >>> (size - 1 - index) * 8 & 255);
3333
+ }
3334
+ function littleEndian(value, size) {
3335
+ return Array.from({ length: size }, (_, index) => value >>> index * 8 & 255);
3336
+ }
3337
+ var Kept = class {
3338
+ buffer;
3339
+ length = 0;
3340
+ constructor(capacity) {
3341
+ this.buffer = new Uint8Array(Math.max(capacity, 64));
3342
+ }
3343
+ add(piece) {
3344
+ if (this.length + piece.length > this.buffer.length) {
3345
+ const grown = new Uint8Array(Math.max(this.buffer.length * 2, this.length + piece.length));
3346
+ grown.set(this.buffer.subarray(0, this.length));
3347
+ this.buffer = grown;
3348
+ }
3349
+ this.buffer.set(piece, this.length);
3350
+ this.length += piece.length;
3351
+ }
3352
+ /** Writes `piece` over what was kept from `at` on, as a size once what it measures is kept. */
3353
+ set(at, piece) {
3354
+ this.buffer.set(piece, at);
3355
+ }
3356
+ get bytes() {
3357
+ return this.buffer.subarray(0, this.length);
3358
+ }
3359
+ };
3360
+ function jpegWithoutMetadata(bytes) {
3361
+ if (bytes[0] !== 255 || bytes[1] !== 216) {
3362
+ return null;
3363
+ }
3364
+ const kept = new Kept(bytes.length);
3365
+ kept.add([255, 216]);
3366
+ let at = 2;
3367
+ let orientationKept = false;
3368
+ for (; ; ) {
3369
+ if (at + 1 >= bytes.length || bytes[at] !== 255) {
3370
+ return null;
3371
+ }
3372
+ const marker = bytes[at + 1] ?? 0;
3373
+ if (marker === 255) {
3374
+ at += 1;
3375
+ continue;
3376
+ }
3377
+ if (marker === 0 || marker >= 2 && marker <= 191 || marker === 216 || marker === 217) {
3378
+ return null;
3379
+ }
3380
+ if (marker === 1 || marker >= 208 && marker <= 215) {
3381
+ kept.add([255, marker]);
3382
+ at += 2;
3383
+ continue;
3384
+ }
3385
+ if (at + 4 > bytes.length) {
3386
+ return null;
3387
+ }
3388
+ const length = numberAt(bytes, at + 2, 2);
3389
+ const end = at + 2 + length;
3390
+ if (length < 2 || end > bytes.length) {
3391
+ return null;
3392
+ }
3393
+ const payload = bytes.subarray(at + 4, end);
3394
+ if (marker === 224) {
3395
+ if (startsWith2(payload, JFIF) && payload.length >= 14) {
3396
+ kept.add([255, 224, 0, 16]);
3397
+ kept.add(payload.subarray(0, 12));
3398
+ kept.add([0, 0]);
3399
+ }
3400
+ } else if (marker === 225) {
3401
+ if (startsWith2(payload, EXIF_HEADER) && !orientationKept) {
3402
+ orientationKept = true;
3403
+ const orientation = exifOrientation(payload.subarray(EXIF_HEADER.length));
3404
+ if (orientation !== 1) {
3405
+ const exif = orientationOnly(orientation);
3406
+ kept.add([255, 225, ...bigEndian(EXIF_HEADER.length + exif.length + 2, 2)]);
3407
+ kept.add(EXIF_HEADER);
3408
+ kept.add(exif);
3409
+ }
3410
+ }
3411
+ } else if (marker === 226 && startsWith2(payload, ICC_PROFILE) || marker === 238 && startsWith2(payload, ADOBE) || // A frame, its tables, a scan, and their kin, JPEG-LS's frame and parameters among them.
3412
+ marker >= 192 && marker <= 207 && marker !== 200 || marker >= 218 && marker <= 223 || marker === 247 || marker === 248) {
3413
+ kept.add(bytes.subarray(at, end));
3414
+ }
3415
+ at = end;
3416
+ if (marker !== 218) {
3417
+ continue;
3418
+ }
3419
+ const scan = at;
3420
+ for (; ; ) {
3421
+ const next = bytes.indexOf(255, at);
3422
+ if (next === -1 || next + 1 >= bytes.length) {
3423
+ return null;
3424
+ }
3425
+ at = next;
3426
+ const after = bytes[at + 1] ?? 0;
3427
+ if (after !== 0 && after !== 255 && !(after >= 208 && after <= 215)) {
3428
+ break;
3429
+ }
3430
+ at += 1;
3431
+ }
3432
+ kept.add(bytes.subarray(scan, at));
3433
+ if (bytes[at + 1] === 217) {
3434
+ kept.add([255, 217]);
3435
+ return kept.bytes;
3436
+ }
3437
+ }
3438
+ }
3439
+ var JFIF = Uint8Array.of(74, 70, 73, 70, 0);
3440
+ var ICC_PROFILE = ascii2("ICC_PROFILE\0");
3441
+ var ADOBE = ascii2("Adobe");
3442
+ var PNG_DRAWING_CHUNKS = /* @__PURE__ */ new Set([
3443
+ "IHDR",
3444
+ "PLTE",
3445
+ "IDAT",
3446
+ "IEND",
3447
+ "tRNS",
3448
+ "cHRM",
3449
+ "gAMA",
3450
+ "iCCP",
3451
+ "sBIT",
3452
+ "sRGB",
3453
+ "cICP",
3454
+ "mDCV",
3455
+ "cLLI",
3456
+ "bKGD",
3457
+ "hIST",
3458
+ "pHYs",
3459
+ "sPLT",
3460
+ "acTL",
3461
+ "fcTL",
3462
+ "fdAT"
3463
+ ]);
3464
+ function pngWithoutMetadata(bytes) {
3465
+ const kept = new Kept(bytes.length);
3466
+ kept.add(bytes.subarray(0, 8));
3467
+ let at = 8;
3468
+ let orientationKept = false;
3469
+ while (at + 12 <= bytes.length) {
3470
+ const length = numberAt(bytes, at, 4);
3471
+ const type = typeAt(bytes, at + 4);
3472
+ if (length > bytes.length - at - 12) {
3473
+ return null;
3474
+ }
3475
+ const end = at + 12 + length;
3476
+ if (PNG_DRAWING_CHUNKS.has(type)) {
3477
+ kept.add(bytes.subarray(at, end));
3478
+ } else if (type === "eXIf" && !orientationKept) {
3479
+ orientationKept = true;
3480
+ const orientation = exifOrientation(bytes.subarray(at + 8, at + 8 + length));
3481
+ if (orientation !== 1) {
3482
+ const data = new Uint8Array([...ascii2("eXIf"), ...orientationOnly(orientation)]);
3483
+ kept.add(bigEndian(data.length - 4, 4));
3484
+ kept.add(data);
3485
+ kept.add(bigEndian(crc32(data), 4));
3486
+ }
3487
+ }
3488
+ if (type === "IEND") {
3489
+ return kept.bytes;
3490
+ }
3491
+ at = end;
3492
+ }
3493
+ return null;
3494
+ }
3495
+ var GIF_PLAYING_APPLICATIONS = [
3496
+ "NETSCAPE2.0",
3497
+ "ANIMEXTS1.0",
3498
+ "ICCRGBG1012"
3499
+ ].map(ascii2);
3500
+ function gifColourTable(packed) {
3501
+ return packed & 128 ? 3 << (packed & 7) + 1 : 0;
3502
+ }
3503
+ function gifWithoutMetadata(bytes) {
3504
+ const subBlocksEnd = (start) => {
3505
+ let at2 = start;
3506
+ while (at2 < bytes.length) {
3507
+ const size = bytes[at2] ?? 0;
3508
+ if (size === 0) {
3509
+ return at2 + 1;
3510
+ }
3511
+ at2 += 1 + size;
3512
+ }
3513
+ return null;
3514
+ };
3515
+ if (bytes.length < 13) {
3516
+ return null;
3517
+ }
3518
+ let at = 13 + gifColourTable(bytes[10] ?? 0);
3519
+ if (at > bytes.length) {
3520
+ return null;
3521
+ }
3522
+ const kept = new Kept(bytes.length);
3523
+ kept.add(bytes.subarray(0, at));
3524
+ while (at < bytes.length) {
3525
+ switch (bytes[at]) {
3526
+ case 44: {
3527
+ if (at + 10 > bytes.length) {
3528
+ return null;
3529
+ }
3530
+ const data = at + 10 + gifColourTable(bytes[at + 9] ?? 0);
3531
+ const end = data < bytes.length ? subBlocksEnd(data + 1) : null;
3532
+ if (end === null) {
3533
+ return null;
3534
+ }
3535
+ kept.add(bytes.subarray(at, end));
3536
+ at = end;
3537
+ break;
3538
+ }
3539
+ case 33: {
3540
+ const end = at + 2 < bytes.length ? subBlocksEnd(at + 2) : null;
3541
+ if (end === null) {
3542
+ return null;
3543
+ }
3544
+ const label = bytes[at + 1];
3545
+ const plays = label === 255 && bytes[at + 2] === 11 && end >= at + 14 && GIF_PLAYING_APPLICATIONS.some((name) => startsWith2(bytes, name, at + 3));
3546
+ if (label === 249 || label === 1 || plays) {
3547
+ kept.add(bytes.subarray(at, end));
3548
+ }
3549
+ at = end;
3550
+ break;
3551
+ }
3552
+ case 59:
3553
+ kept.add([59]);
3554
+ return kept.bytes;
3555
+ default:
3556
+ return null;
3557
+ }
3558
+ }
3559
+ return null;
3560
+ }
3561
+ var WEBP_DRAWING_CHUNKS = /* @__PURE__ */ new Set([
3562
+ "VP8X",
3563
+ "ICCP",
3564
+ "ANIM",
3565
+ "ANMF",
3566
+ "ALPH",
3567
+ "VP8 ",
3568
+ "VP8L"
3569
+ ]);
3570
+ function webpWithoutMetadata(bytes) {
3571
+ if (bytes.length < 12) {
3572
+ return null;
3573
+ }
3574
+ const end = 8 + numberAt(bytes, 4, 4, true);
3575
+ const chunks = end >= 12 && end <= bytes.length ? webpChunks(bytes.subarray(12, end)) : null;
3576
+ if (chunks === null || chunks.length === 0) {
3577
+ return null;
3578
+ }
3579
+ const kept = new Kept(chunks.length + 12);
3580
+ kept.add(ascii2("RIFF"));
3581
+ kept.add(littleEndian(chunks.length + 4, 4));
3582
+ kept.add(ascii2("WEBP"));
3583
+ kept.add(chunks);
3584
+ const image = kept.bytes;
3585
+ if (image.length > 20 && typeAt(image, 12) === "VP8X" && numberAt(image, 16, 4, true) > 0) {
3586
+ const exif = webpChunkTypes(image.subarray(12)).includes("EXIF");
3587
+ image[20] = (image[20] ?? 0) & ~12 | (exif ? 8 : 0);
3588
+ }
3589
+ return image;
3590
+ }
3591
+ function webpChunks(chunks) {
3592
+ const kept = new Kept(chunks.length);
3593
+ const frames = [];
3594
+ let end = chunks.length;
3595
+ let at = 0;
3596
+ let orientationKept = false;
3597
+ for (; ; ) {
3598
+ if (at >= end) {
3599
+ const frame = frames.pop();
3600
+ if (frame === void 0) {
3601
+ return kept.bytes;
3602
+ }
3603
+ kept.set(frame.size, littleEndian(kept.length - frame.size - 4, 4));
3604
+ at = frame.resume;
3605
+ end = frames.at(-1)?.end ?? chunks.length;
3606
+ continue;
3607
+ }
3608
+ if (end - at < 8) {
3609
+ return null;
3610
+ }
3611
+ const type = typeAt(chunks, at);
3612
+ const size = numberAt(chunks, at + 4, 4, true);
3613
+ if (size > end - at - 8) {
3614
+ return null;
3615
+ }
3616
+ const data = at + 8;
3617
+ at = Math.min(data + size + size % 2, end);
3618
+ if (type === "ANMF") {
3619
+ if (size < 16) {
3620
+ return null;
3621
+ }
3622
+ frames.push({ end: data + size, resume: at, size: kept.length + 4 });
3623
+ kept.add(chunks.subarray(data - 8, data + 16));
3624
+ at = data + 16;
3625
+ end = data + size;
3626
+ } else if (WEBP_DRAWING_CHUNKS.has(type)) {
3627
+ kept.add(chunks.subarray(data - 8, data + size));
3628
+ if (size % 2 === 1) {
3629
+ kept.add([0]);
3630
+ }
3631
+ } else if (type === "EXIF" && frames.length === 0 && !orientationKept) {
3632
+ orientationKept = true;
3633
+ const turned = exifOrientation(chunks.subarray(data, data + size));
3634
+ if (turned !== 1) {
3635
+ const exif = orientationOnly(turned);
3636
+ kept.add(ascii2(type));
3637
+ kept.add(littleEndian(exif.length, 4));
3638
+ kept.add(exif);
3639
+ }
3640
+ }
3641
+ }
3642
+ }
3643
+ function webpChunkTypes(chunks) {
3644
+ const types = [];
3645
+ let at = 0;
3646
+ while (chunks.length - at >= 8) {
3647
+ types.push(typeAt(chunks, at));
3648
+ const size = numberAt(chunks, at + 4, 4, true);
3649
+ at += 8 + size + size % 2;
3650
+ }
3651
+ return types;
3652
+ }
3653
+
3165
3654
  // packages/board-core/dist/moves.js
3166
3655
  import { mkdir as mkdir4, rm as rm2 } from "node:fs/promises";
3167
3656
  import path5 from "node:path";
@@ -3604,6 +4093,72 @@ function tokenUse(events, window, now) {
3604
4093
  };
3605
4094
  }
3606
4095
 
4096
+ // packages/board-core/dist/turn-counters.js
4097
+ var TOKEN_KINDS = ["input", "output", "cache_read", "cache_write"];
4098
+ var USAGE_FIELD = {
4099
+ input: "inputTokens",
4100
+ output: "outputTokens",
4101
+ cache_read: "cacheReadTokens",
4102
+ cache_write: "cacheWriteTokens"
4103
+ };
4104
+ var UNKNOWN_LABEL = "unknown";
4105
+ function byKey(entries) {
4106
+ return [...entries].toSorted(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([, series]) => series);
4107
+ }
4108
+ var TurnCounters = class {
4109
+ cliOf;
4110
+ tokens = /* @__PURE__ */ new Map();
4111
+ turns = /* @__PURE__ */ new Map();
4112
+ /**
4113
+ * `cliOf` names a citizen's CLI for an end logged before ends named theirs: a turn runs on the
4114
+ * CLI its citizen's record names, which nothing changes after the citizen is added.
4115
+ */
4116
+ constructor(cliOf) {
4117
+ this.cliOf = cliOf;
4118
+ }
4119
+ /** Counts a turn's end, `turn.completed` or `turn.failed`, and passes over any other event. */
4120
+ add(event) {
4121
+ if (event.type !== "turn.completed" && event.type !== "turn.failed") {
4122
+ return;
4123
+ }
4124
+ const turn = TurnHistoryPayloadSchema.safeParse(event.payload);
4125
+ if (!turn.success) {
4126
+ return;
4127
+ }
4128
+ const agent = event.actor;
4129
+ const outcome = event.type === "turn.failed" ? "failed" : "completed";
4130
+ const trigger = turn.data.trigger;
4131
+ const counted = JSON.stringify([agent, outcome, trigger]);
4132
+ this.turns.set(counted, {
4133
+ agent,
4134
+ outcome,
4135
+ trigger,
4136
+ turns: (this.turns.get(counted)?.turns ?? 0) + 1
4137
+ });
4138
+ const usage = turn.data.usage;
4139
+ if (usage === void 0) {
4140
+ return;
4141
+ }
4142
+ const named = CliKindSchema.safeParse(event.payload["cli"]);
4143
+ const cli = (named.success ? named.data : this.cliOf(agent)) ?? UNKNOWN_LABEL;
4144
+ const model = turn.data.model ?? UNKNOWN_LABEL;
4145
+ for (const kind of TOKEN_KINDS) {
4146
+ const key = JSON.stringify([agent, cli, model, kind]);
4147
+ this.tokens.set(key, {
4148
+ agent,
4149
+ cli,
4150
+ model,
4151
+ kind,
4152
+ tokens: (this.tokens.get(key)?.tokens ?? 0) + usage[USAGE_FIELD[kind]]
4153
+ });
4154
+ }
4155
+ }
4156
+ /** The counts so far, in an order that holds from one read to the next. */
4157
+ read() {
4158
+ return { tokens: byKey(this.tokens), turns: byKey(this.turns) };
4159
+ }
4160
+ };
4161
+
3607
4162
  // packages/board-core/dist/mutex.js
3608
4163
  var Mutex = class {
3609
4164
  tail = Promise.resolve();
@@ -3963,6 +4518,7 @@ function hashToken(token) {
3963
4518
  // packages/board-core/dist/board.js
3964
4519
  var SYSTEM_ACTOR = { name: "board", role: USER_ROLE };
3965
4520
  var CLOSING_ATTEMPTS = 3;
4521
+ var CHANNEL_POST_WAIT_MS = 24 * 36e5;
3966
4522
  function ownTurn(actor, turn) {
3967
4523
  return turn.agent === actor.name && turn.scope === actor.scope && turn.thread === actor.thread && turn.channel === actor.channel && turn.place === actor.place;
3968
4524
  }
@@ -4086,8 +4642,10 @@ function plain(value) {
4086
4642
  return "";
4087
4643
  return JSON.stringify(value);
4088
4644
  }
4089
- function sentence(summary) {
4090
- return summary.trim().replace(/\.+$/, "");
4645
+ var SENTENCE_END = /[.…?!]["'”’»)\]}*_]*$/u;
4646
+ function asSentence(text) {
4647
+ const trimmed = text.trim();
4648
+ return SENTENCE_END.test(trimmed) ? trimmed : `${trimmed}.`;
4091
4649
  }
4092
4650
  function skillSummary(data, body) {
4093
4651
  const description = data["description"];
@@ -5275,10 +5833,13 @@ ${DASHBOARD_LINE}
5275
5833
  * when it mentions the user, so the creator can put a wait on the user in; a post that asks the
5276
5834
  * reader counts wherever it is. A closed thread counts for nothing: its conversation has ended,
5277
5835
  * 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.
5836
+ * it closed, as for a mention, still runs there. A top-level post in a project's channel, general
5837
+ * or a workstream's, counts only for a reader it asks until it is `channelPostWaitMs` old, and
5838
+ * then for every reader who has not read it, so a turn there for anything else reads it sooner.
5279
5839
  */
5280
- async unreadByConversation(actor) {
5840
+ async unreadByConversation(actor, options = {}) {
5281
5841
  const { cursors, upto } = await this.digestSnapshot(actor.name);
5842
+ const waited = this.now().getTime() - (options.channelPostWaitMs ?? CHANNEL_POST_WAIT_MS);
5282
5843
  const counts = /* @__PURE__ */ new Map();
5283
5844
  const threadCounts = /* @__PURE__ */ new Map();
5284
5845
  const unscoped = { name: actor.name, role: actor.role };
@@ -5286,11 +5847,15 @@ ${DASHBOARD_LINE}
5286
5847
  if (news === true || message.step !== void 0 || message.author === SYSTEM_ACTOR.name || message.notice?.kind === "knowledge") {
5287
5848
  continue;
5288
5849
  }
5289
- const posted = parseChannelRef(message.channel).project ?? SOCIETY_SCOPE;
5850
+ const project = parseChannelRef(message.channel).project;
5851
+ const posted = project ?? SOCIETY_SCOPE;
5290
5852
  if (posted !== scope && scope !== SOCIETY_SCOPE) {
5291
5853
  continue;
5292
5854
  }
5293
5855
  const thread = threadOfPart(part);
5856
+ if (thread === void 0 && project !== null && asked !== true && Date.parse(message.ts) > waited) {
5857
+ continue;
5858
+ }
5294
5859
  if (thread !== void 0) {
5295
5860
  let counted = threadCounts.get(thread);
5296
5861
  if (counted === void 0) {
@@ -5671,7 +6236,7 @@ ${current.body}`);
5671
6236
  name: args.name,
5672
6237
  reason: args.reason
5673
6238
  });
5674
- await this.appendMessage(SYSTEM_ACTOR.name, "general", `Skill ${args.name} removed from the society by ${actor.name}: ${sentence(withoutMentions(args.reason))}. Every citizen's skills index stops listing it from its next turn.`);
6239
+ await this.appendMessage(SYSTEM_ACTOR.name, "general", `Skill ${args.name} removed from the society by ${actor.name}: ${asSentence(withoutMentions(args.reason))} Every citizen's skills index stops listing it from its next turn.`);
5675
6240
  return removed;
5676
6241
  });
5677
6242
  }
@@ -5714,9 +6279,9 @@ ${current.body}`);
5714
6279
  /**
5715
6280
  * Keeps an image a citizen's turn names by its path, read on the runner the turn runs on, which
5716
6281
  * refuses a file outside the turn's worktree, the citizen's home, and the system temp folder.
5717
- * Only the bytes of a PNG, JPEG, GIF, or WebP of at most `IMAGE_LIMIT_BYTES` are kept, checked
5718
- * here again whatever the runner said, under a new id outside the projection, so no runner
5719
- * mirrors them and no prompt carries them: a post shows the image by its `image:` address.
6282
+ * Only a PNG, JPEG, GIF, or WebP of at most `IMAGE_LIMIT_BYTES` is kept, checked here again
6283
+ * whatever the runner said, without its metadata, under a new id outside the projection, so no
6284
+ * runner mirrors it and no prompt carries it: a post shows the image by its `image:` address.
5720
6285
  */
5721
6286
  async uploadImage(actor, input) {
5722
6287
  const args = VerbInputs.upload_image.parse(input);
@@ -5742,8 +6307,13 @@ ${current.body}`);
5742
6307
  }
5743
6308
  /**
5744
6309
  * 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.
6310
+ * `IMAGE_LIMIT_BYTES` that reads to its end, without its metadata (`withoutMetadata`), and
6311
+ * answers the markdown that shows it, described by `name`. `what` names the image in a refusal.
6312
+ * The limit is for the bytes as they came, as the route's body cap measures them, and bounds
6313
+ * what is read; what is kept is checked against it too, since an orientation written smaller
6314
+ * than the board writes it grows by a few bytes. The runner, the web's composer, and the app
6315
+ * strip an image before they send it, and stripping it again changes nothing, but an older
6316
+ * runner, page, or app sends one as it was.
5747
6317
  */
5748
6318
  async keepImage(actor, bytes, what, name) {
5749
6319
  const type = imageTypeOf(bytes);
@@ -5753,19 +6323,26 @@ ${current.body}`);
5753
6323
  if (bytes.byteLength > IMAGE_LIMIT_BYTES) {
5754
6324
  throw new BoardError("VALIDATION", `${what} is ${bytes.byteLength} bytes, over the ${IMAGE_LIMIT_BYTES} an image may be`);
5755
6325
  }
6326
+ const kept = withoutMetadata(bytes);
6327
+ if (kept === null) {
6328
+ throw new BoardError("VALIDATION", `${what} does not read to its end as a ${IMAGE_KIND_NAMES[type]}, so its metadata cannot be told from its picture; save it again and send that copy`);
6329
+ }
6330
+ if (kept.byteLength > IMAGE_LIMIT_BYTES) {
6331
+ throw new BoardError("VALIDATION", `${what} is ${kept.byteLength} bytes without its metadata, over the ${IMAGE_LIMIT_BYTES} an image may be`);
6332
+ }
5756
6333
  return this.mutex.run(async () => {
5757
6334
  const id = this.newId();
5758
- await writeFileAtomic(this.paths.image(id), bytes);
6335
+ await writeFileAtomic(this.paths.image(id), kept);
5759
6336
  await this.events.append("image.uploaded", actor.name, {
5760
6337
  id,
5761
6338
  type,
5762
- size: bytes.byteLength
6339
+ size: kept.byteLength
5763
6340
  });
5764
6341
  const description = imageDescription(name);
5765
6342
  return {
5766
6343
  id,
5767
6344
  type,
5768
- size: bytes.byteLength,
6345
+ size: kept.byteLength,
5769
6346
  markdown: `![${description}](${IMAGE_SCHEME}${id})`
5770
6347
  };
5771
6348
  });
@@ -6741,7 +7318,7 @@ ${args.summary}`);
6741
7318
  });
6742
7319
  const thread = await this.openThreadUnlocked(actor.name, this.proposalThreadOpening(proposal));
6743
7320
  const rationale = args.rationale.trim();
6744
- await this.appendThreadMessage(actor.name, thread.id, `Proposal ${proposal.id}: ${describeCharter(proposal.kind, proposal.charter)}.${rationale.length === 0 ? "" : `
7321
+ await this.appendThreadMessage(actor.name, thread.id, `Proposal ${proposal.id}: ${asSentence(describeCharter(proposal.kind, proposal.charter))}${rationale.length === 0 ? "" : `
6745
7322
 
6746
7323
  ${rationale}`}`);
6747
7324
  return proposal;
@@ -7066,6 +7643,39 @@ ${rationale}`}`);
7066
7643
  const events = await this.events.readSince(null, Number.MAX_SAFE_INTEGER);
7067
7644
  return tokenUse(events, window, this.now());
7068
7645
  }
7646
+ /**
7647
+ * Every finished turn counted for Prometheus: seeded once from the whole event log, then moved
7648
+ * by each turn's end this process appends, so reading the counts never reads the log. The server
7649
+ * logs every turn's end itself; one another process appended would count from the next seed.
7650
+ */
7651
+ async countTurns() {
7652
+ const clis = /* @__PURE__ */ new Map();
7653
+ const counters = new TurnCounters((agent) => clis.get(agent) ?? null);
7654
+ let waiting = [];
7655
+ this.events.onAppend((event) => {
7656
+ if (waiting === null) {
7657
+ counters.add(event);
7658
+ } else {
7659
+ waiting.push(event);
7660
+ }
7661
+ });
7662
+ const events = await this.events.readSince(null, Number.MAX_SAFE_INTEGER);
7663
+ for (const agent of await this.listAgents()) {
7664
+ clis.set(agent.name, agent.cli);
7665
+ }
7666
+ const seen = /* @__PURE__ */ new Set();
7667
+ for (const event of events) {
7668
+ seen.add(event.id);
7669
+ counters.add(event);
7670
+ }
7671
+ for (const event of waiting) {
7672
+ if (!seen.has(event.id)) {
7673
+ counters.add(event);
7674
+ }
7675
+ }
7676
+ waiting = null;
7677
+ return counters;
7678
+ }
7069
7679
  /** The board actions a finished turn took, from its transcript; null when it kept none. */
7070
7680
  async turnActions(agent, turnId) {
7071
7681
  const known = this.actionCounts.get(turnId);
@@ -7263,6 +7873,7 @@ ${rationale}`}`);
7263
7873
  trigger: parsed.trigger.kind,
7264
7874
  ...parsed.trigger.heartbeat === void 0 ? {} : { heartbeat: parsed.trigger.heartbeat },
7265
7875
  session: parsed.session,
7876
+ cli: parsed.cli,
7266
7877
  exitReason: parsed.exitReason,
7267
7878
  costUsd: parsed.costUsd,
7268
7879
  ...parsed.usage === null ? {} : { usage: parsed.usage },
@@ -7445,7 +8056,7 @@ ${rationale}`}`);
7445
8056
  const ts = this.now().toISOString();
7446
8057
  const landed = current.onDone !== "none";
7447
8058
  if (landed) {
7448
- await this.postTaskNote(actor.name, current, input.ok ? `${sentence(input.detail)}.` : `Landing failed: ${sentence(input.detail)}. The task waits at its last stage for its participants to reshape the plan.`, input.ok ? { action: "landed", stage: current.stage, to: null } : { action: "reopened", stage: current.stage, to: current.stage });
8059
+ await this.postTaskNote(actor.name, current, input.ok ? asSentence(input.detail) : `Landing failed: ${asSentence(input.detail)} The task waits at its last stage for its participants to reshape the plan.`, input.ok ? { action: "landed", stage: current.stage, to: null } : { action: "reopened", stage: current.stage, to: current.stage });
7449
8060
  }
7450
8061
  if (input.ok) {
7451
8062
  const task2 = await this.writeEndedTask(actor.name, location.project, {
@@ -7465,7 +8076,7 @@ ${rationale}`}`);
7465
8076
  await this.refreshMember(name);
7466
8077
  }
7467
8078
  if (landed) {
7468
- await this.appendMessage(actor.name, channelRef(location.project, task2.channel), `Task ${task2.id} "${task2.title}" is done: ${sentence(input.detail)}.`);
8079
+ await this.appendMessage(actor.name, channelRef(location.project, task2.channel), `Task ${task2.id} "${task2.title}" is done: ${asSentence(input.detail)}`);
7469
8080
  }
7470
8081
  return task2;
7471
8082
  }
@@ -8176,7 +8787,7 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
8176
8787
  files: taken.files.length,
8177
8788
  ...meta
8178
8789
  });
8179
- await this.appendMessage(SYSTEM_ACTOR.name, "general", `Skill ${skill.name} ${replaced ? "updated in" : "promoted to"} the society: ${sentence(skill.summary)}. Every citizen's skills index now lists it.`);
8790
+ await this.appendMessage(SYSTEM_ACTOR.name, "general", `Skill ${skill.name} ${replaced ? "updated in" : "promoted to"} the society: ${asSentence(skill.summary)} Every citizen's skills index now lists it.`);
8180
8791
  return replaced;
8181
8792
  }
8182
8793
  /**
@@ -8423,7 +9034,7 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
8423
9034
  });
8424
9035
  const purpose = withoutMentions(input.purpose);
8425
9036
  await this.appendMessage(SYSTEM_ACTOR.name, ref, `Channel ${ref} opened: ${purpose}`);
8426
- await this.appendMessage(SYSTEM_ACTOR.name, channelRef(input.project, "general"), `Channel ${ref} opened by ${by}: ${purpose}. Subscribe to it to follow its posts; its tasks and mentions reach whoever they concern either way.`);
9037
+ await this.appendMessage(SYSTEM_ACTOR.name, channelRef(input.project, "general"), `Channel ${ref} opened by ${by}: ${asSentence(purpose)} Subscribe to it to follow its posts; its tasks and mentions reach whoever they concern either way.`);
8427
9038
  return ref;
8428
9039
  }
8429
9040
  /**
@@ -8846,17 +9457,18 @@ ${charter.purpose}
8846
9457
  return kept > lease ? new Date(kept).toISOString() : null;
8847
9458
  }
8848
9459
  /**
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.
9460
+ * The lease a holder keeps while the user's pause, or its CLI's limit, holds its turns in the
9461
+ * task's project (`heldOn`), when later than the one it has, else null. It is renewed for a full
9462
+ * lease once less than half is left, as for a pending cron, so it never lapses while the holder
9463
+ * cannot take a turn there, and after the resume or the reset the holder has at least half a
9464
+ * lease to come back to it.
8853
9465
  */
8854
9466
  async pausedLease(task, runners2, runner, now) {
8855
9467
  if (task.claimedBy === void 0 || task.leaseExpiresAt === void 0) {
8856
9468
  return null;
8857
9469
  }
8858
9470
  const holder = await this.readAgent(task.claimedBy).catch(() => null);
8859
- if (holder === null || !heldOn(runners2, runner, holder.cli)) {
9471
+ if (holder === null || !heldOn(runners2, runner, holder.cli, now)) {
8860
9472
  return null;
8861
9473
  }
8862
9474
  const lease = Date.parse(task.leaseExpiresAt);
@@ -8947,9 +9559,9 @@ ${charter.purpose}
8947
9559
  });
8948
9560
  }
8949
9561
  const verdict = outcome === "approved" ? "Approved" : "Rejected";
8950
- const why = reason === void 0 ? "" : ` Reason: ${reason}.`;
9562
+ const why = reason === void 0 ? "" : ` Reason: ${asSentence(reason)}`;
8951
9563
  const result = provision === void 0 ? "" : ` Provisioned: ${Object.entries(provision).map(([key, value]) => `${key} ${plain(value)}`).join(", ")}.`;
8952
- const post = `${verdict}: ${describeCharter(proposal.kind, proposal.charter)}.${why}${result}`;
9564
+ const post = `${verdict}: ${asSentence(describeCharter(proposal.kind, proposal.charter))}${why}${result}`;
8953
9565
  await this.appendThreadMessage(actor.name, thread.id, actor.name === SYSTEM_ACTOR.name ? withoutMentions(post) : post, { step: { action: outcome } });
8954
9566
  await this.endThreadOf(proposalId, actor.name, outcome);
8955
9567
  return decision;
@@ -9197,6 +9809,7 @@ var DEFAULT_TIMINGS = Object.freeze({
9197
9809
  debounceMs: 3e4,
9198
9810
  userDebounceMs: 5e3,
9199
9811
  heartbeatMs: 15 * 6e4,
9812
+ channelPostWaitMs: CHANNEL_POST_WAIT_MS,
9200
9813
  waitingStageMs: 10 * 6e4,
9201
9814
  leaseSweepMs: 6e4,
9202
9815
  opsIntervalMs: 5 * 6e4,
@@ -9216,6 +9829,7 @@ var SchedulerTimingsSchema = z17.object({
9216
9829
  debounceMs: z17.number().nonnegative(),
9217
9830
  userDebounceMs: z17.number().nonnegative(),
9218
9831
  heartbeatMs: z17.number().positive(),
9832
+ channelPostWaitMs: z17.number().nonnegative(),
9219
9833
  waitingStageMs: z17.number().positive(),
9220
9834
  leaseSweepMs: z17.number().positive(),
9221
9835
  opsIntervalMs: z17.number().positive(),
@@ -9284,10 +9898,14 @@ var StateSchema = z17.object({
9284
9898
  /** When each member last reflected, or was first seen; the cadence counts from here. */
9285
9899
  lastReflection: z17.record(z17.string(), z17.string()).default({}),
9286
9900
  /**
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.
9901
+ * The session each home last asked for an early reflection for, once per session, by the home's
9902
+ * `sessionKey`, and whether that reflection still waits to be queued. A key of a member's name
9903
+ * alone, kept before a project's homes asked, is its society home.
9289
9904
  */
9290
- earlyReflection: z17.record(z17.string(), z17.object({ session: z17.string(), due: z17.boolean() })).default({})
9905
+ earlyReflection: z17.record(z17.string(), z17.object({ session: z17.string(), due: z17.boolean() })).default({}).transform((early) => Object.fromEntries(Object.entries(early).map(([key, asked]) => [
9906
+ key.includes("/") ? key : sessionKey(key, SOCIETY_SCOPE),
9907
+ asked
9908
+ ])))
9291
9909
  });
9292
9910
  function messageOf(trigger) {
9293
9911
  return trigger.kind === "mention" || trigger.kind === "user_post" ? trigger.messageId : void 0;
@@ -9376,7 +9994,7 @@ var Scheduler = class {
9376
9994
  sweeping = /* @__PURE__ */ new Set();
9377
9995
  /** Crons whose fire's turn is running; a cron's next time passes while one is. */
9378
9996
  cronsInFlight = /* @__PURE__ */ new Set();
9379
- /** Turns in a row the model service turned away, by session key, which `retryRefused` counts. */
9997
+ /** Turns in a row that failed for now, by session key, which `retryRefused` counts. */
9380
9998
  refused = /* @__PURE__ */ new Map();
9381
9999
  state = StateSchema.parse({
9382
10000
  cursor: null,
@@ -10236,7 +10854,9 @@ var Scheduler = class {
10236
10854
  continue;
10237
10855
  }
10238
10856
  this.state.lastHeartbeat[cadence] = iso(now);
10239
- unread ??= await this.board.unreadByConversation(actor);
10857
+ unread ??= await this.board.unreadByConversation(actor, {
10858
+ channelPostWaitMs: this.timings.channelPostWaitMs
10859
+ });
10240
10860
  held ??= await this.board.heldClaims(agent.name);
10241
10861
  pendingCrons ??= (await this.board.listCrons()).filter((cron) => cronStoppedAt(cron) === null);
10242
10862
  const crons = pendingCrons;
@@ -10377,18 +10997,20 @@ var Scheduler = class {
10377
10997
  }
10378
10998
  }
10379
10999
  /**
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
11000
+ * A home session that a turn left at `sessionCapTokens` or more, by its latest model call, asks
11001
+ * for a reflection ahead of the cadence, which `checkReflections` queues in that home once none
11002
+ * of the member's turns is queued or running: a society role's home in the society scope, and
11003
+ * the home of a project's member whose role reflects. The reflection's end starts the home afresh
10383
11004
  * (`TurnHost`), so its history goes only once a reflection saved what to keep. A session asks
10384
11005
  * once, so a reflection that fails leaves the next to the cadence, and none asks while a
10385
- * reflection is queued or running.
11006
+ * reflection is queued or running in its home.
10386
11007
  */
10387
11008
  async checkSessionSize(event) {
10388
11009
  const { payload } = event;
10389
11010
  const tokens = payload["contextTokens"];
10390
11011
  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) {
11012
+ const project = stringOf(payload["project"]);
11013
+ if (typeof tokens !== "number" || tokens < this.timings.sessionCapTokens || session === null || project === null || payload["thread"] !== void 0 || payload["channel"] !== void 0 || payload["place"] !== void 0 || payload["trigger"] === "reflection" || payload["sessionDropped"] === true) {
10392
11014
  return;
10393
11015
  }
10394
11016
  const agent = await this.tryReadAgent(event.actor);
@@ -10396,24 +11018,33 @@ var Scheduler = class {
10396
11018
  return;
10397
11019
  }
10398
11020
  const charter = await this.board.readRole(agent.role).catch(() => null);
10399
- if (charter === null || !charter.societyScope || !charter.reflects) {
11021
+ if (charter === null || !charter.reflects || !this.isHome(agent, charter, project)) {
10400
11022
  return;
10401
11023
  }
10402
- if (this.state.earlyReflection[agent.name]?.session === session) {
11024
+ const home = sessionKey(agent.name, project);
11025
+ if (this.state.earlyReflection[home]?.session === session) {
10403
11026
  return;
10404
11027
  }
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");
11028
+ const reflecting = this.pending.get(home)?.dispatch.trigger.kind === "reflection" || this.exclusive.get(home) === "reflection";
10407
11029
  if (reflecting) {
10408
11030
  return;
10409
11031
  }
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");
11032
+ delete this.state.earlyReflection[home];
11033
+ this.state.earlyReflection[home] = { session, due: true };
11034
+ this.log.info({ agent: agent.name, project, contextTokens: tokens, cap: this.timings.sessionCapTokens }, "home session past the cap; a reflection is due");
11035
+ }
11036
+ /**
11037
+ * Whether a member's home in a scope starts afresh after a reflection, as `TurnHost` decides: a
11038
+ * society role's in the society scope, and its own in each project it belongs to.
11039
+ */
11040
+ isHome(agent, charter, scope) {
11041
+ return scope === SOCIETY_SCOPE ? charter.societyScope : agent.memberships.includes(scope);
10412
11042
  }
10413
11043
  /**
10414
11044
  * Every `reflectionMs`, a member whose charter reflects takes a reflection turn in the scope of
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.
11045
+ * its latest working turn, unless it has not worked since its last reflection; a member one of
11046
+ * whose homes passed the cap takes one in that home ahead of the cadence, which restarts it, one
11047
+ * home at a time, in the order they asked.
10417
11048
  */
10418
11049
  async checkReflections(now) {
10419
11050
  for (const agent of await this.board.listAgents()) {
@@ -10424,12 +11055,18 @@ var Scheduler = class {
10424
11055
  if (!charter.reflects) {
10425
11056
  continue;
10426
11057
  }
10427
- const early = this.state.earlyReflection[agent.name];
10428
- if (early?.due === true) {
11058
+ const due = Object.entries(this.state.earlyReflection).filter(([key, early]) => early.due && parseSessionKey(key)?.agent === agent.name);
11059
+ if (due.length > 0) {
10429
11060
  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);
11061
+ for (const [key, early] of due) {
11062
+ this.state.earlyReflection[key] = { ...early, due: false };
11063
+ const scope = parseSessionKey(key)?.scope;
11064
+ if (scope !== void 0 && this.isHome(agent, charter, scope)) {
11065
+ this.state.lastReflection[agent.name] = iso(now);
11066
+ this.enqueue(agent.name, scope, { kind: "reflection", reason: "home session past the cap" }, now);
11067
+ break;
11068
+ }
11069
+ }
10433
11070
  }
10434
11071
  continue;
10435
11072
  }
@@ -10501,7 +11138,7 @@ var Scheduler = class {
10501
11138
  }
10502
11139
  members ??= await this.board.projectMembers(project.slug);
10503
11140
  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))) {
11141
+ if (takers.length > 0 && takers.every((taker) => heldOn(runners2, project.runner, taker.cli, new Date(now)))) {
10505
11142
  this.state.waitingHeld[key] = iso(now);
10506
11143
  continue;
10507
11144
  }
@@ -10708,7 +11345,8 @@ var Scheduler = class {
10708
11345
  last = Math.max(last, Date.parse(ended));
10709
11346
  }
10710
11347
  }
10711
- const held = heldOn(runners2, agent.homeRunner, agent.cli) || projects.some((project) => agent.memberships.includes(project.slug) && heldOn(runners2, project.runner, agent.cli));
11348
+ const at = new Date(now);
11349
+ const held = heldOn(runners2, agent.homeRunner, agent.cli, at) || projects.some((project) => agent.memberships.includes(project.slug) && heldOn(runners2, project.runner, agent.cli, at));
10712
11350
  if (held) {
10713
11351
  this.state.idleHeld[agent.name] = iso(now);
10714
11352
  }
@@ -10935,19 +11573,21 @@ var Scheduler = class {
10935
11573
  this.exclusive.set(key, "onboarding");
10936
11574
  } else if (dispatch.trigger.kind === "reflection") {
10937
11575
  this.exclusive.set(key, "reflection");
10938
- const early = this.state.earlyReflection[dispatch.agent];
11576
+ const early = this.state.earlyReflection[key];
10939
11577
  if (early?.due === true) {
10940
- this.state.earlyReflection[dispatch.agent] = { ...early, due: false };
11578
+ this.state.earlyReflection[key] = { ...early, due: false };
10941
11579
  }
10942
11580
  }
10943
11581
  }
10944
11582
  }
10945
11583
  /**
10946
- * A turn the model service turned away for now, overloaded or failing on its side, is sent again:
10947
- * the same wake in the same conversation, after the next delay of `transientRetryMs`, with every
10948
- * check a queued wake gets at dispatch. A wake already queued there, as a heartbeat's or a new
10949
- * post's, runs the conversation instead. Once the delays are spent the heartbeat takes over, and
10950
- * a `turn_refused` signal, which wakes nobody, says so. A turn that ends any other way ends the run.
11584
+ * A turn that failed for now is sent again: the model service turned it away or cut it off, the
11585
+ * runner could not reach the board before its CLI started, or its job never reached the runner.
11586
+ * The same wake goes in the same conversation, after the next delay of `transientRetryMs`, with
11587
+ * every check a queued wake gets at dispatch. A wake already queued there, as a heartbeat's or a
11588
+ * new post's, runs the conversation instead. Once the delays are spent the heartbeat takes over,
11589
+ * and a `turn_refused` signal, which wakes nobody, says so. A turn that ends any other way ends
11590
+ * the run.
10951
11591
  */
10952
11592
  async retryRefused(key, item, record) {
10953
11593
  if (record.transient !== true || record.exitReason !== "error") {
@@ -10964,7 +11604,7 @@ var Scheduler = class {
10964
11604
  }
10965
11605
  const { steeredThrough: _steered, ...retry } = item;
10966
11606
  this.pending.set(key, { ...retry, readyAt: this.now().getTime() + delay });
10967
- this.log.info({ agent: dispatch.agent, project: dispatch.project, refused, delayMs: delay }, "the model service turned the turn away, sending its wake again");
11607
+ this.log.info({ agent: dispatch.agent, project: dispatch.project, refused, delayMs: delay }, "the turn failed for now, sending its wake again");
10968
11608
  return;
10969
11609
  }
10970
11610
  if (refused !== this.timings.transientRetryMs.length + 1) {
@@ -10975,7 +11615,7 @@ var Scheduler = class {
10975
11615
  await this.board.publishSignal({
10976
11616
  kind: "turn_refused",
10977
11617
  key: `turn_refused:${key}`,
10978
- summary: `${dispatch.agent}'s turns in ${where} were turned away by the model service ${refused} times in a row${record.model === null ? "" : ` on ${record.model}`}, the last with "${record.error ?? "no error"}"; its heartbeat takes over`,
11618
+ summary: `${dispatch.agent}'s turns in ${where} failed for now ${refused} times in a row${record.model === null ? "" : ` on ${record.model}`}, the last with "${record.error ?? "no error"}"; its heartbeat takes over`,
10979
11619
  value: refused,
10980
11620
  threshold: refused,
10981
11621
  agent: dispatch.agent,
@@ -11076,6 +11716,7 @@ var TURN_CONTRACT = [
11076
11716
  "- 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.",
11077
11717
  "- 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.",
11078
11718
  "- 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.",
11719
+ "- A post at the top of a project's channel, general or another, wakes only whoever it mentions; everyone else reads it at their next turn there, at the latest about a day later. So mention whoever must act on it or know it soon.",
11079
11720
  "- 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.",
11080
11721
  "- 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.",
11081
11722
  '- 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.',
@@ -11227,7 +11868,11 @@ function runnerLine(runner, projects, now) {
11227
11868
  ].join(", ");
11228
11869
  const pauses = runner.clis.flatMap((cli) => {
11229
11870
  const pause = runner.pausedClis?.[cli];
11230
- return pause === void 0 ? [] : [`${cli} paused by the user since ${pause.at}`];
11871
+ const hold = runnerLimitHold(runner, cli, now);
11872
+ return [
11873
+ ...pause === void 0 ? [] : [`${cli} paused by the user since ${pause.at}`],
11874
+ ...hold === null ? [] : [`${cli} held by its limit until ${hold.until}`]
11875
+ ];
11231
11876
  });
11232
11877
  const quotas = runner.clis.flatMap((cli) => {
11233
11878
  const quota = runner.quotas?.[cli];
@@ -11400,7 +12045,7 @@ function buildTurnPrompt(input) {
11400
12045
  }
11401
12046
  const machines = input.runners;
11402
12047
  if (machines !== void 0 && machines !== null) {
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.", "");
12048
+ lines.push("", "## Runners", "", "The machines turns run on. A project lives on the runner that takes its first turn, one that offers every capability the project and that turn's task require, and moves only when the user moves it. A task's required_capabilities must name capabilities exactly as a runner below offers them; a task needing one its project's runner lacks waits. A runner's quota is what each CLI's login there last reported of its plan, in percent of each window. A runner, or a CLI on it, paused by the user starts none of the turns it would run until the user resumes it, and a CLI held by its limit starts none of its turns there until the limit lifts, at the reset the CLI gave or, with none given, half an hour after the report: they wait, and the stages their citizens hold keep their leases.", "");
11404
12049
  if (machines.runners.length === 0) {
11405
12050
  lines.push("None registered yet.");
11406
12051
  }
@@ -11625,7 +12270,7 @@ var TurnHost = class {
11625
12270
  const costSoFarUsd = !newSession && lastTurn?.session === recorded ? lastTurn.sessionCostUsd ?? lastTurn.costUsd : 0;
11626
12271
  const usageSoFar = !newSession && lastTurn?.session === recorded ? lastTurn.sessionUsage : void 0;
11627
12272
  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;
12273
+ const afreshAfterReflection = agent.role !== USER_ROLE && part === void 0 && (societyScope ? charter.societyScope : charter.reflects);
11629
12274
  const afterReflection = lastTurn?.trigger.kind === "reflection" && lastTurn.sessionDropped === true;
11630
12275
  const digest = await this.board.readDigest({
11631
12276
  ...actor,
@@ -12061,8 +12706,11 @@ var TurnHost = class {
12061
12706
  }
12062
12707
  return { dropWorkspace: ended && (closing ? decided : !outcome.leftovers) };
12063
12708
  }
12064
- /** Ends a turn whose outcome will never come, as when its runner went away, as failed. */
12065
- async abortTurn(turnId, reason) {
12709
+ /**
12710
+ * Ends a turn whose outcome will never come, as when its runner went away, as failed; `transient`
12711
+ * when its job never reached the runner, so the same wake may pass in a minute.
12712
+ */
12713
+ async abortTurn(turnId, reason, options = {}) {
12066
12714
  const turn = this.open.get(turnId);
12067
12715
  if (turn === void 0) {
12068
12716
  return;
@@ -12077,7 +12725,8 @@ var TurnHost = class {
12077
12725
  endedAt: this.now().toISOString(),
12078
12726
  exitReason: "error",
12079
12727
  error: reason,
12080
- toolCalls: turn.transcript.filter((entry) => entry.event.type === "tool_call").length
12728
+ toolCalls: turn.transcript.filter((entry) => entry.event.type === "tool_call").length,
12729
+ ...options.transient === true ? { transient: true } : {}
12081
12730
  };
12082
12731
  if (await this.recorded(turn, () => this.board.finishTurn(finished, turn.transcript))) {
12083
12732
  this.log.warn({ agent: turn.job.agent, project: turn.job.scope, reason }, "turn aborted");
@@ -12297,6 +12946,9 @@ var TurnHost = class {
12297
12946
 
12298
12947
  // packages/turn-host/dist/hub.js
12299
12948
  import { randomUUID as randomUUID2 } from "node:crypto";
12949
+ function receiptOverdue(seat) {
12950
+ return [...seat.unacked.values()].some((each) => each.overdue);
12951
+ }
12300
12952
  var RunnerProtocolError = class extends Error {
12301
12953
  };
12302
12954
  var RunnerAwayError = class extends Error {
@@ -12341,6 +12993,13 @@ var RunnerHub = class {
12341
12993
  starting = /* @__PURE__ */ new Map();
12342
12994
  /** Moves started since the hub did, so a placement that a move overtook gives its turn back. */
12343
12995
  moveStarts = 0;
12996
+ /** When each runner was last asked to read a CLI's quota for a limit with no reset, by `runner/cli`. */
12997
+ limitReads = /* @__PURE__ */ new Map();
12998
+ /**
12999
+ * The turn let through once a limit with no reset had held its half hour, by `runner/cli`: the
13000
+ * report it tries, which `observedAt` names, and whether the turn has ended.
13001
+ */
13002
+ limitTries = /* @__PURE__ */ new Map();
12344
13003
  constructor(options) {
12345
13004
  this.board = options.board;
12346
13005
  this.host = options.host;
@@ -12377,9 +13036,9 @@ var RunnerHub = class {
12377
13036
  if (hello.protocol !== RUNNER_PROTOCOL) {
12378
13037
  throw new RunnerProtocolError(`runner protocol ${hello.protocol} is not ${RUNNER_PROTOCOL}; upgrade the runner or the server`);
12379
13038
  }
12380
- const seat = this.seats.get(name);
13039
+ let seat = this.seats.get(name);
12381
13040
  if (seat === void 0) {
12382
- this.seats.set(name, {
13041
+ seat = {
12383
13042
  name,
12384
13043
  hello,
12385
13044
  send: null,
@@ -12387,19 +13046,18 @@ var RunnerHub = class {
12387
13046
  warm: /* @__PURE__ */ new Set(),
12388
13047
  disconnectedAt: null,
12389
13048
  grace: null,
12390
- unacked: /* @__PURE__ */ new Map(),
12391
- unconfirmed: false
12392
- });
13049
+ unacked: /* @__PURE__ */ new Map()
13050
+ };
13051
+ this.seats.set(name, seat);
12393
13052
  } else {
12394
13053
  seat.hello = hello;
12395
13054
  seat.warm = /* @__PURE__ */ new Set();
12396
13055
  }
12397
13056
  const still = new Set(hello.turns);
12398
- const unacked = this.seats.get(name)?.unacked ?? /* @__PURE__ */ new Map();
12399
13057
  for (const turnId of this.host.turnsOn(name)) {
12400
13058
  if (still.has(turnId)) {
12401
- this.settle(unacked, turnId);
12402
- } else if (!unacked.has(turnId)) {
13059
+ this.settle(seat, turnId);
13060
+ } else if (!seat.unacked.has(turnId)) {
12403
13061
  await this.host.abortTurn(turnId, `runner ${name} restarted during the turn`);
12404
13062
  }
12405
13063
  }
@@ -12414,7 +13072,12 @@ var RunnerHub = class {
12414
13072
  this.log.info({ runner: name, version: hello.version, clis: hello.clis, slots: hello.slots }, "runner registered");
12415
13073
  return { name, version: this.version };
12416
13074
  }
12417
- /** A registered runner's stream opened. Returns what to call when it closes. */
13075
+ /**
13076
+ * A registered runner's stream opened. Returns what to call when it closes. A turn its hello
13077
+ * listed that the board has ended, as one whose runner came back after the grace period, is told
13078
+ * to stop before anything else goes down the stream, since its CLI still works there; a runner
13079
+ * that lists no CLI it can stop, as one from before stops, is sent none.
13080
+ */
12418
13081
  async attach(name, send) {
12419
13082
  const seat = this.seats.get(name);
12420
13083
  if (seat === void 0) {
@@ -12422,11 +13085,17 @@ var RunnerHub = class {
12422
13085
  }
12423
13086
  seat.send = send;
12424
13087
  seat.disconnectedAt = null;
12425
- seat.unconfirmed = false;
12426
13088
  if (seat.grace !== null) {
12427
13089
  clearTimeout(seat.grace);
12428
13090
  seat.grace = null;
12429
13091
  }
13092
+ if (seat.hello.stoppableClis.length > 0) {
13093
+ for (const turnId of seat.hello.turns) {
13094
+ if (this.host.runnerOf(turnId) !== name) {
13095
+ void this.stopEnded(seat, turnId);
13096
+ }
13097
+ }
13098
+ }
12430
13099
  for (const { job } of seat.unacked.values()) {
12431
13100
  if (send({ type: "turn", job })) {
12432
13101
  this.expectAck(seat, job);
@@ -12463,24 +13132,23 @@ var RunnerHub = class {
12463
13132
  }
12464
13133
  await Promise.allSettled(this.detaching);
12465
13134
  }
13135
+ /** A turn's steps, which show its job arrived as its receipt would. */
12466
13136
  async turnEvents(runner, turnId, entries) {
12467
13137
  this.assertOwns(runner, turnId);
13138
+ this.received(runner, turnId);
12468
13139
  await this.host.addEvents(turnId, entries);
12469
13140
  }
12470
13141
  /** A runner's word that a turn's job arrived, which also shows its stream delivers again. */
12471
13142
  received(runner, turnId) {
12472
13143
  const seat = this.seats.get(runner);
12473
- if (seat === void 0 || !seat.unacked.has(turnId)) {
12474
- return;
12475
- }
12476
- this.settle(seat.unacked, turnId);
12477
- if (seat.unconfirmed) {
12478
- seat.unconfirmed = false;
12479
- this.log.info({ runner }, "runner is taking jobs again");
13144
+ if (seat !== void 0) {
13145
+ this.settle(seat, turnId);
12480
13146
  }
12481
13147
  }
13148
+ /** A turn's outcome, which shows its job arrived as its receipt would. */
12482
13149
  async turnOutcome(runner, turnId, outcome) {
12483
13150
  this.assertOwns(runner, turnId);
13151
+ this.received(runner, turnId);
12484
13152
  const ack = await this.host.closeTurn(turnId, outcome);
12485
13153
  return ack ?? { dropWorkspace: false };
12486
13154
  }
@@ -12625,8 +13293,10 @@ var RunnerHub = class {
12625
13293
  }
12626
13294
  const cli = agent.cli;
12627
13295
  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);
13296
+ const now = this.now();
13297
+ const free = (seat) => seat.send !== null && !receiptOverdue(seat) && seat.hello.clis.includes(cli) && (seat.hello.slots === null || seat.inUse < seat.hello.slots) && !heldOn(runners2, seat.name, cli, now) && !this.tryRunning(runners2, seat.name, cli, now);
12629
13298
  let chosen;
13299
+ let waitsOn = agent.homeRunner;
12630
13300
  if (dispatch.project === SOCIETY_SCOPE) {
12631
13301
  chosen = await this.pinnedSeat(agent.name, agent.homeRunner, cli, free);
12632
13302
  } else {
@@ -12655,8 +13325,12 @@ var RunnerHub = class {
12655
13325
  }
12656
13326
  const seat = this.seats.get(home);
12657
13327
  chosen = seat !== void 0 && free(seat) ? seat : void 0;
13328
+ waitsOn = home;
12658
13329
  }
12659
13330
  if (chosen === void 0) {
13331
+ if (waitsOn !== void 0) {
13332
+ this.readAfterLimit(runners2, waitsOn, cli, now);
13333
+ }
12660
13334
  return null;
12661
13335
  }
12662
13336
  if (dispatch.project !== SOCIETY_SCOPE && (this.moving.has(dispatch.project) || this.moveStarts !== starts)) {
@@ -12666,7 +13340,17 @@ var RunnerHub = class {
12666
13340
  if (dispatch.project !== SOCIETY_SCOPE) {
12667
13341
  this.starting.set(dispatch.project, (this.starting.get(dispatch.project) ?? 0) + 1);
12668
13342
  }
12669
- return { runner: chosen.name };
13343
+ const runner = chosen.name;
13344
+ const lapsed = runners2.find((each) => each.name === runner)?.quotas?.[cli];
13345
+ const tried = this.limitTries.get(`${runner}/${cli}`);
13346
+ if (lapsed !== void 0 && limitLapsed(lapsed, now) && tried?.observedAt !== lapsed.observedAt) {
13347
+ this.limitTries.set(`${runner}/${cli}`, {
13348
+ observedAt: lapsed.observedAt,
13349
+ dispatch,
13350
+ ended: false
13351
+ });
13352
+ }
13353
+ return { runner };
12670
13354
  }
12671
13355
  async runTurn(dispatch, assignment) {
12672
13356
  if ("refused" in assignment) {
@@ -12695,7 +13379,7 @@ var RunnerHub = class {
12695
13379
  return opened.failed;
12696
13380
  }
12697
13381
  if (seat.send === null || !seat.send({ type: "turn", job: opened.job })) {
12698
- await this.host.abortTurn(opened.job.turnId, `runner ${seat.name} went away before the turn`);
13382
+ await this.host.abortTurn(opened.job.turnId, `runner ${seat.name} went away before the turn`, { transient: true });
12699
13383
  } else {
12700
13384
  this.expectAck(seat, opened.job);
12701
13385
  }
@@ -12703,10 +13387,15 @@ var RunnerHub = class {
12703
13387
  return await opened.ended;
12704
13388
  } finally {
12705
13389
  opening();
13390
+ for (const tried of this.limitTries.values()) {
13391
+ if (tried.dispatch === dispatch) {
13392
+ tried.ended = true;
13393
+ }
13394
+ }
12706
13395
  if (seat !== void 0) {
12707
13396
  seat.inUse = Math.max(0, seat.inUse - 1);
12708
13397
  if (turnId !== null) {
12709
- this.settle(seat.unacked, turnId);
13398
+ this.settle(seat, turnId);
12710
13399
  }
12711
13400
  }
12712
13401
  }
@@ -13096,6 +13785,40 @@ var RunnerHub = class {
13096
13785
  await this.board.recordQuota(runner, cli, quota);
13097
13786
  return quota;
13098
13787
  }
13788
+ /**
13789
+ * Whether a turn let through to try a limit with no reset, once its hold ran out, is still running
13790
+ * on the runner, so the CLI's other turns there wait for what it reports: a refusal records a newer
13791
+ * limit, which holds them another half hour, and a turn that ends without one lets them go.
13792
+ */
13793
+ tryRunning(runners2, name, cli, now) {
13794
+ const quota = runners2.find((each) => each.name === name)?.quotas?.[cli];
13795
+ const tried = this.limitTries.get(`${name}/${cli}`);
13796
+ return quota !== void 0 && tried !== void 0 && !tried.ended && tried.observedAt === quota.observedAt && limitLapsed(quota, now);
13797
+ }
13798
+ /**
13799
+ * Asks a runner to read a CLI's quota again while a limit it gave no reset for holds a turn
13800
+ * waiting for it there, at most once per `UNKNOWN_LIMIT_HOLD_MS` for each runner and CLI, so a
13801
+ * reset the read names becomes the hold and a clear read lifts it. Not while the user's pause
13802
+ * holds the CLI there too, nor on a runner from before quota reads; a read that fails changes
13803
+ * nothing, and the hold runs out as it would have.
13804
+ */
13805
+ readAfterLimit(runners2, name, cli, now) {
13806
+ const record = runners2.find((each) => each.name === name);
13807
+ const seat = this.seats.get(name);
13808
+ if (record === void 0 || seat === void 0 || seat.send === null || !seat.hello.quotaReads || !seat.hello.clis.includes(cli) || runnerPause(record, cli) !== void 0 || runnerLimitHold(record, cli, now)?.known !== false) {
13809
+ return;
13810
+ }
13811
+ const key = `${name}/${cli}`;
13812
+ const last = this.limitReads.get(key);
13813
+ if (last !== void 0 && now.getTime() - last < UNKNOWN_LIMIT_HOLD_MS) {
13814
+ return;
13815
+ }
13816
+ this.limitReads.set(key, now.getTime());
13817
+ this.log.info({ runner: name, cli }, "reading a quota limited until an unknown time");
13818
+ this.quota(name, cli).catch((error) => {
13819
+ this.log.warn({ runner: name, cli, error: String(error) }, "could not read a limited quota");
13820
+ });
13821
+ }
13099
13822
  /** Calls `listener` whenever a runner registers, connects, or goes away. */
13100
13823
  onRunnerChange(listener) {
13101
13824
  this.runnerListeners.add(listener);
@@ -13109,29 +13832,54 @@ var RunnerHub = class {
13109
13832
  /**
13110
13833
  * Waits for a runner to acknowledge a job. One that does not in time may be behind a stream that
13111
13834
  * looks open but delivers nothing, as when its machine's network stops passing traffic, so no
13112
- * more turns go to it until it acknowledges or connects again; the turns it has carry on, since
13113
- * they report by posts.
13835
+ * more turns go to it until the job is acknowledged, its turn ends, or the runner connects again;
13836
+ * the turns it has carry on, since they report by posts.
13114
13837
  */
13115
13838
  expectAck(seat, job) {
13116
13839
  const previous = seat.unacked.get(job.turnId);
13117
13840
  if (previous !== void 0) {
13118
13841
  clearTimeout(previous.timer);
13119
13842
  }
13120
- const timer = setTimeout(() => {
13121
- if (!seat.unacked.has(job.turnId) || seat.unconfirmed || seat.send === null) {
13122
- return;
13123
- }
13124
- seat.unconfirmed = true;
13125
- this.log.warn({ runner: seat.name, turnId: job.turnId, waitedMs: this.ackTimeoutMs }, "runner has not acknowledged a job; no more turns go to it until it does or reconnects");
13126
- }, this.ackTimeoutMs);
13127
- timer.unref?.();
13128
- seat.unacked.set(job.turnId, { job, timer });
13843
+ const entry = {
13844
+ job,
13845
+ timer: setTimeout(() => {
13846
+ if (seat.unacked.get(job.turnId) !== entry || seat.send === null) {
13847
+ return;
13848
+ }
13849
+ const wasHeld = receiptOverdue(seat);
13850
+ entry.overdue = true;
13851
+ if (!wasHeld) {
13852
+ this.log.warn({ runner: seat.name, turnId: job.turnId, waitedMs: this.ackTimeoutMs }, "runner has not acknowledged a job; no more turns go to it until it does, the turn ends, or it reconnects");
13853
+ }
13854
+ }, this.ackTimeoutMs),
13855
+ overdue: false
13856
+ };
13857
+ entry.timer.unref?.();
13858
+ seat.unacked.set(job.turnId, entry);
13859
+ }
13860
+ /** A job acknowledged, or its turn over: the hold it put on its runner, if any, goes with it. */
13861
+ settle(seat, turnId) {
13862
+ const entry = seat.unacked.get(turnId);
13863
+ if (entry === void 0) {
13864
+ return;
13865
+ }
13866
+ clearTimeout(entry.timer);
13867
+ seat.unacked.delete(turnId);
13868
+ if (entry.overdue && !receiptOverdue(seat)) {
13869
+ this.log.info({ runner: seat.name }, "runner is taking jobs again");
13870
+ }
13129
13871
  }
13130
- settle(unacked, turnId) {
13131
- const entry = unacked.get(turnId);
13132
- if (entry !== void 0) {
13133
- clearTimeout(entry.timer);
13134
- unacked.delete(turnId);
13872
+ /** Has a runner stop the CLI of a turn it still runs that the board has ended. */
13873
+ async stopEnded(seat, turnId) {
13874
+ try {
13875
+ const stopped = await this.request(seat, (request) => ({ type: "stop", request, turnId }), CONTROL_TIMEOUT_MS);
13876
+ if (stopped === true) {
13877
+ this.log.warn({ runner: seat.name, turnId }, "stopped a turn the board had ended, which its runner still ran");
13878
+ } else {
13879
+ this.log.info({ runner: seat.name, turnId }, "a turn the board had ended was no longer running on its runner");
13880
+ }
13881
+ } catch (error) {
13882
+ this.log.warn({ runner: seat.name, turnId, error: String(error) }, "could not stop a turn the board had ended");
13135
13883
  }
13136
13884
  }
13137
13885
  async detach(seat) {
@@ -13154,7 +13902,8 @@ var RunnerHub = class {
13154
13902
  return;
13155
13903
  }
13156
13904
  for (const turnId of this.host.turnsOn(seat.name)) {
13157
- void this.host.abortTurn(turnId, seat.unacked.has(turnId) ? `the turn never reached runner ${seat.name}, which went away` : `runner ${seat.name} went away during the turn`);
13905
+ const unsent = seat.unacked.has(turnId);
13906
+ void this.host.abortTurn(turnId, unsent ? `the turn never reached runner ${seat.name}, which went away` : `runner ${seat.name} went away during the turn`, { transient: unsent });
13158
13907
  }
13159
13908
  }, this.graceMs);
13160
13909
  seat.grace.unref?.();
@@ -14702,6 +15451,34 @@ var ModelCatalog = class {
14702
15451
  }
14703
15452
  };
14704
15453
 
15454
+ // apps/server/dist/prometheus.js
15455
+ import { Hono as Hono4 } from "hono";
15456
+ var PROMETHEUS_CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8";
15457
+ function quoted(value) {
15458
+ return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("\n", "\\n")}"`;
15459
+ }
15460
+ function sample(name, labels, value) {
15461
+ const pairs = Object.entries(labels).map(([label, text]) => `${label}=${quoted(text)}`);
15462
+ return `${name}{${pairs.join(",")}} ${value}`;
15463
+ }
15464
+ function prometheusText(counts) {
15465
+ return [
15466
+ "# HELP stellaris_turn_tokens_total Tokens finished turns used, by citizen, CLI, model, and kind: input, output, cache_read, or cache_write.",
15467
+ "# TYPE stellaris_turn_tokens_total counter",
15468
+ ...counts.tokens.map(({ agent, cli, model, kind, tokens }) => sample("stellaris_turn_tokens_total", { agent, cli, model, kind }, tokens)),
15469
+ "# HELP stellaris_turns_total Finished turns, by citizen, outcome (completed or failed), and the trigger that woke them.",
15470
+ "# TYPE stellaris_turns_total counter",
15471
+ ...counts.turns.map(({ agent, outcome, trigger, turns: turns2 }) => sample("stellaris_turns_total", { agent, outcome, trigger }, turns2)),
15472
+ ""
15473
+ ].join("\n");
15474
+ }
15475
+ function createMetricsApp(counters) {
15476
+ const app2 = new Hono4();
15477
+ app2.get("/metrics", (c) => c.body(prometheusText(counters.read()), 200, { "content-type": PROMETHEUS_CONTENT_TYPE }));
15478
+ app2.notFound((c) => c.text("not found", 404));
15479
+ return app2;
15480
+ }
15481
+
14705
15482
  // apps/server/dist/quotas.js
14706
15483
  var MINUTE = 6e4;
14707
15484
  var QuotaReads = class {
@@ -14865,6 +15642,13 @@ var server = serveBoard({ fetch: app.fetch, port: config.port, hostname: config.
14865
15642
  trustedProxies: config.trustedProxies
14866
15643
  }, "board server listening");
14867
15644
  });
15645
+ var metrics = config.metricsPort === null ? null : serveBoard({
15646
+ fetch: createMetricsApp(await board.countTurns()).fetch,
15647
+ port: config.metricsPort,
15648
+ hostname: config.host
15649
+ }, (info) => {
15650
+ log.info({ host: info.address, port: info.port }, "metrics listening");
15651
+ });
14868
15652
  await scheduler.start();
14869
15653
  log.info({
14870
15654
  concurrency: config.concurrency ?? "unlimited",
@@ -14876,6 +15660,7 @@ var shutdown = async (signal) => {
14876
15660
  log.info({ signal }, "shutting down; waiting for running turns to report");
14877
15661
  await scheduler.stop();
14878
15662
  await runners.close();
15663
+ metrics?.close();
14879
15664
  server.close();
14880
15665
  process.exit(0);
14881
15666
  };