@kubitnodes/stellaris 0.4.1 → 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 (63) hide show
  1. package/dist/cli/index.js +603 -18
  2. package/dist/server/index.js +744 -60
  3. package/package.json +1 -1
  4. package/web/dist/assets/BackLink-DNHWkHmh.js +1 -0
  5. package/web/dist/assets/{Button-DxGzriWQ.js → Button-B6EN7Lh7.js} +1 -1
  6. package/web/dist/assets/ChannelForms-BrcWrkej.js +1 -0
  7. package/web/dist/assets/{ChannelView-DvtoxLi5.js → ChannelView-D7bT7d9q.js} +2 -2
  8. package/web/dist/assets/CitizenView-D6d1KnS6.js +2 -0
  9. package/web/dist/assets/{CitizensView-BOZHABbN.js → CitizensView-C55zOiYE.js} +1 -1
  10. package/web/dist/assets/{CliIcon-D3GddnP4.js → CliIcon-BRq1tnhu.js} +1 -1
  11. package/web/dist/assets/CronsView-BTGiqn10.js +1 -0
  12. package/web/dist/assets/{FileTree-DSFN4sd8.js → FileTree-BDhtTIzT.js} +1 -1
  13. package/web/dist/assets/{FileView-DyUYLXRC.js → FileView-BkZPGEbO.js} +2 -2
  14. package/web/dist/assets/{KnowledgeView-CMdXaTvS.js → KnowledgeView-BiTS-lyE.js} +1 -1
  15. package/web/dist/assets/{Markdown-BSeWHBBA.js → Markdown-DYk-gVwe.js} +5 -5
  16. package/web/dist/assets/{MessageItem-qIxS96uJ.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-BO_lR1cu.js +1 -0
  20. package/web/dist/assets/{Pane-pZUzNQIW.js → Pane-DFJdAhv9.js} +1 -1
  21. package/web/dist/assets/{Picker-CDf6os_x.js → Picker-CAEREZF9.js} +1 -1
  22. package/web/dist/assets/ProjectView-LU59V2vh.js +1 -0
  23. package/web/dist/assets/ProposalView-DHemTlkc.js +1 -0
  24. package/web/dist/assets/{ProposalsView-DBGpcAPO.js → ProposalsView-DBIOJAul.js} +1 -1
  25. package/web/dist/assets/{Quota-Brd4ddB4.js → Quota-D6_yyawu.js} +1 -1
  26. package/web/dist/assets/{RunnersView-45OXtalD.js → RunnersView-BNKgYuNw.js} +1 -1
  27. package/web/dist/assets/{SkillFileList-BUImALv9.js → SkillFileList-Ntyv8noL.js} +1 -1
  28. package/web/dist/assets/SkillView-Dez1fvqU.js +1 -0
  29. package/web/dist/assets/SocietyView-CbmbQXHi.js +1 -0
  30. package/web/dist/assets/{StickyList-D_ED_-LW.js → StickyList-Q0KEWBUl.js} +1 -1
  31. package/web/dist/assets/{SubjectThread-B5zvoRSd.js → SubjectThread-1PnxxtZc.js} +1 -1
  32. package/web/dist/assets/{TaskView-B5_QTI3p.js → TaskView-CcIlNHBh.js} +2 -2
  33. package/web/dist/assets/TasksView-BkTU43PD.js +1 -0
  34. package/web/dist/assets/{ThreadForms-CbFClyd0.js → ThreadForms-BlOEvY5E.js} +1 -1
  35. package/web/dist/assets/ThreadItem-D5N_Fw1K.js +1 -0
  36. package/web/dist/assets/{ThreadView-CHFs0zaS.js → ThreadView-BaYV77mQ.js} +1 -1
  37. package/web/dist/assets/{citizen-B8p56JRh.js → citizen-BRg9UIgD.js} +1 -1
  38. package/web/dist/assets/{compose-CV4o6TMb.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-BH0RZXfc.js → mentions-Ckktc7nR.js} +1 -1
  44. package/web/dist/assets/{tasks-D2Es55TQ.js → tasks-CFVtN9TK.js} +1 -1
  45. package/web/dist/assets/{useMatch-DeOpK57G.js → useMatch-BCaBBHc2.js} +1 -1
  46. package/web/dist/favicon.svg +89 -4
  47. package/web/dist/index.html +9 -9
  48. package/web/dist/assets/BackLink-fxPmFUg8.js +0 -1
  49. package/web/dist/assets/ChannelForms-C3m4IPnZ.js +0 -1
  50. package/web/dist/assets/CitizenView-Dq64VsZi.js +0 -2
  51. package/web/dist/assets/CronsView-CI947lZS.js +0 -1
  52. package/web/dist/assets/MetricsView-BhjdwkX1.js +0 -1
  53. package/web/dist/assets/NeedsYouView-7uZW9dY8.js +0 -1
  54. package/web/dist/assets/Overview-D6kiK8sE.js +0 -1
  55. package/web/dist/assets/ProjectView-B6OmxiT9.js +0 -1
  56. package/web/dist/assets/ProposalView-B1X8Jlo0.js +0 -1
  57. package/web/dist/assets/SkillView-B1knYZV8.js +0 -1
  58. package/web/dist/assets/SocietyView-F7bzrNv5.js +0 -1
  59. package/web/dist/assets/TasksView-DZLNqU2V.js +0 -1
  60. package/web/dist/assets/ThreadItem-kFn5M3Cx.js +0 -1
  61. package/web/dist/assets/format-BYF63sQU.js +0 -55
  62. package/web/dist/assets/governance-BjIg0eok.js +0 -1
  63. package/web/dist/assets/index-CyROMh25.js +0 -24
@@ -1101,6 +1101,12 @@ var IMAGE_MEDIA_TYPES = {
1101
1101
  gif: "image/gif",
1102
1102
  webp: "image/webp"
1103
1103
  };
1104
+ var IMAGE_KIND_NAMES = {
1105
+ png: "PNG",
1106
+ jpeg: "JPEG",
1107
+ gif: "GIF",
1108
+ webp: "WebP"
1109
+ };
1104
1110
  function startsWith(bytes, prefix, at = 0) {
1105
1111
  for (let index = 0; index < prefix.length; index += 1) {
1106
1112
  if (bytes[at + index] !== prefix[index]) {
@@ -1322,7 +1328,7 @@ var VERB_DESCRIPTIONS = {
1322
1328
  release_task: "Let go of the stage you hold so someone else can take it.",
1323
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.",
1324
1330
  get_task: "Read a task with its plan, its brief, and the messages of its thread.",
1325
- 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.",
1326
1332
  unsubscribe: "Unsubscribe from a channel.",
1327
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.",
1328
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.",
@@ -1343,7 +1349,7 @@ var VERB_DESCRIPTIONS = {
1343
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.",
1344
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.",
1345
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.",
1346
- upload_image: "Upload an image file, such as a screenshot or a chart, from the machine your turn runs on: a PNG, JPEG, GIF, or WebP of at most 8 MB, in your worktree, your home, or the system temp folder. Returns its id and the markdown that shows it in a post, `![what it shows](image:<id>)`; write what it shows as the alt text.",
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.",
1347
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."
1348
1354
  };
1349
1355
 
@@ -1565,7 +1571,11 @@ var RunnerHelloSchema = z9.object({
1565
1571
  gitIdentityMissing: z9.array(GitIdentityPartSchema).optional(),
1566
1572
  /** Turns the runner runs at once, or null for no limit. */
1567
1573
  slots: z9.number().int().positive().nullable(),
1568
- /** 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
+ */
1569
1579
  turns: z9.array(UlidSchema).default([])
1570
1580
  });
1571
1581
  var RunnerWelcomeSchema = z9.object({ name: NameSchema, version: z9.string() });
@@ -1728,9 +1738,9 @@ var TurnOutcomeSchema = z9.object({
1728
1738
  */
1729
1739
  resumeFailed: ResumeFailureSchema.optional(),
1730
1740
  /**
1731
- * Set when the model service turned the turn away for now, as when it was overloaded or failed
1732
- * on its side, which the scheduler answers by sending the same wake again within minutes. A
1733
- * 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.
1734
1744
  */
1735
1745
  transient: z9.boolean().optional(),
1736
1746
  /** The model the CLI reported, when it reported one. */
@@ -2149,13 +2159,18 @@ var TurnRecordSchema = z10.object({
2149
2159
  stoppedBy: NameSchema.optional(),
2150
2160
  /** Why the CLI could not resume the turn's session, for a turn that failed so. */
2151
2161
  resumeFailed: ResumeFailureSchema.optional(),
2152
- /** 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
+ */
2153
2167
  transient: z10.boolean().optional(),
2154
2168
  /** How many turns in a row, this one included, could not resume the session for a `retry`. */
2155
2169
  resumeRetries: z10.number().int().positive().optional(),
2156
2170
  /**
2157
2171
  * Whether the server dropped the conversation's session after this turn, which the next starts
2158
- * 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.
2159
2174
  */
2160
2175
  sessionDropped: z10.boolean().optional(),
2161
2176
  /** The crons whose fire started the turn, which record how it ended. */
@@ -2403,6 +2418,11 @@ var ServerConfigSchema = z13.object({
2403
2418
  dataDir: z13.string().min(1),
2404
2419
  host: z13.string().min(1),
2405
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(),
2406
2426
  /**
2407
2427
  * An address every runner's agents reach this server at, for example https://board.example, or
2408
2428
  * null for each runner to use the address it reaches the server at itself.
@@ -2454,10 +2474,11 @@ function githubSignIn(env) {
2454
2474
  return GithubSignInSchema.parse({ clientId, clientSecret, users, appleAppId });
2455
2475
  }
2456
2476
  function loadServerConfig(env) {
2457
- return ServerConfigSchema.parse({
2477
+ const config2 = ServerConfigSchema.parse({
2458
2478
  dataDir: env["STELLARIS_DATA_DIR"] ?? "./data",
2459
2479
  host: env["STELLARIS_HOST"] ?? "127.0.0.1",
2460
2480
  port: Number(env["STELLARIS_PORT"] ?? "4700"),
2481
+ metricsPort: env["STELLARIS_METRICS_PORT"]?.trim() ? Number(env["STELLARIS_METRICS_PORT"]) : null,
2461
2482
  publicUrl: env["STELLARIS_PUBLIC_URL"]?.replace(/\/+$/, "") ?? null,
2462
2483
  logLevel: env["STELLARIS_LOG_LEVEL"] ?? "info",
2463
2484
  concurrency: limit(env["STELLARIS_CONCURRENCY"], null),
@@ -2467,6 +2488,10 @@ function loadServerConfig(env) {
2467
2488
  github: githubSignIn(env),
2468
2489
  trustedProxies: Number(env["STELLARIS_TRUSTED_PROXIES"] ?? "0")
2469
2490
  });
2491
+ if (config2.metricsPort === config2.port) {
2492
+ throw new Error("STELLARIS_METRICS_PORT must differ from STELLARIS_PORT");
2493
+ }
2494
+ return config2;
2470
2495
  }
2471
2496
  var RunnerConfigSchema = z13.object({
2472
2497
  /** The board server, for example http://127.0.0.1:4700. */
@@ -2486,7 +2511,7 @@ var RunnerConfigSchema = z13.object({
2486
2511
  });
2487
2512
 
2488
2513
  // packages/shared/dist/version.js
2489
- var STELLARIS_VERSION = "0.4.1";
2514
+ var STELLARIS_VERSION = "0.4.2";
2490
2515
 
2491
2516
  // packages/shared/dist/mentions.js
2492
2517
  import { parse, postprocess, preprocess } from "micromark";
@@ -3181,6 +3206,451 @@ function filesOf(lines, status) {
3181
3206
  return [...files.values()];
3182
3207
  }
3183
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
+
3184
3654
  // packages/board-core/dist/moves.js
3185
3655
  import { mkdir as mkdir4, rm as rm2 } from "node:fs/promises";
3186
3656
  import path5 from "node:path";
@@ -3623,6 +4093,72 @@ function tokenUse(events, window, now) {
3623
4093
  };
3624
4094
  }
3625
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
+
3626
4162
  // packages/board-core/dist/mutex.js
3627
4163
  var Mutex = class {
3628
4164
  tail = Promise.resolve();
@@ -3982,6 +4518,7 @@ function hashToken(token) {
3982
4518
  // packages/board-core/dist/board.js
3983
4519
  var SYSTEM_ACTOR = { name: "board", role: USER_ROLE };
3984
4520
  var CLOSING_ATTEMPTS = 3;
4521
+ var CHANNEL_POST_WAIT_MS = 24 * 36e5;
3985
4522
  function ownTurn(actor, turn) {
3986
4523
  return turn.agent === actor.name && turn.scope === actor.scope && turn.thread === actor.thread && turn.channel === actor.channel && turn.place === actor.place;
3987
4524
  }
@@ -5296,10 +5833,13 @@ ${DASHBOARD_LINE}
5296
5833
  * when it mentions the user, so the creator can put a wait on the user in; a post that asks the
5297
5834
  * reader counts wherever it is. A closed thread counts for nothing: its conversation has ended,
5298
5835
  * and a turn there could only read its posts and be refused a reply, while a wake queued before
5299
- * it closed, as for a mention, still runs there.
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.
5300
5839
  */
5301
- async unreadByConversation(actor) {
5840
+ async unreadByConversation(actor, options = {}) {
5302
5841
  const { cursors, upto } = await this.digestSnapshot(actor.name);
5842
+ const waited = this.now().getTime() - (options.channelPostWaitMs ?? CHANNEL_POST_WAIT_MS);
5303
5843
  const counts = /* @__PURE__ */ new Map();
5304
5844
  const threadCounts = /* @__PURE__ */ new Map();
5305
5845
  const unscoped = { name: actor.name, role: actor.role };
@@ -5307,11 +5847,15 @@ ${DASHBOARD_LINE}
5307
5847
  if (news === true || message.step !== void 0 || message.author === SYSTEM_ACTOR.name || message.notice?.kind === "knowledge") {
5308
5848
  continue;
5309
5849
  }
5310
- const posted = parseChannelRef(message.channel).project ?? SOCIETY_SCOPE;
5850
+ const project = parseChannelRef(message.channel).project;
5851
+ const posted = project ?? SOCIETY_SCOPE;
5311
5852
  if (posted !== scope && scope !== SOCIETY_SCOPE) {
5312
5853
  continue;
5313
5854
  }
5314
5855
  const thread = threadOfPart(part);
5856
+ if (thread === void 0 && project !== null && asked !== true && Date.parse(message.ts) > waited) {
5857
+ continue;
5858
+ }
5315
5859
  if (thread !== void 0) {
5316
5860
  let counted = threadCounts.get(thread);
5317
5861
  if (counted === void 0) {
@@ -5735,9 +6279,9 @@ ${current.body}`);
5735
6279
  /**
5736
6280
  * Keeps an image a citizen's turn names by its path, read on the runner the turn runs on, which
5737
6281
  * refuses a file outside the turn's worktree, the citizen's home, and the system temp folder.
5738
- * Only the bytes of a PNG, JPEG, GIF, or WebP of at most `IMAGE_LIMIT_BYTES` are kept, checked
5739
- * here again whatever the runner said, under a new id outside the projection, so no runner
5740
- * 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.
5741
6285
  */
5742
6286
  async uploadImage(actor, input) {
5743
6287
  const args = VerbInputs.upload_image.parse(input);
@@ -5763,8 +6307,13 @@ ${current.body}`);
5763
6307
  }
5764
6308
  /**
5765
6309
  * Keeps `bytes` under a new id when they are a PNG, JPEG, GIF, or WebP of at most
5766
- * `IMAGE_LIMIT_BYTES`, and answers the markdown that shows them, described by `name`. `what`
5767
- * names the image in a refusal.
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.
5768
6317
  */
5769
6318
  async keepImage(actor, bytes, what, name) {
5770
6319
  const type = imageTypeOf(bytes);
@@ -5774,19 +6323,26 @@ ${current.body}`);
5774
6323
  if (bytes.byteLength > IMAGE_LIMIT_BYTES) {
5775
6324
  throw new BoardError("VALIDATION", `${what} is ${bytes.byteLength} bytes, over the ${IMAGE_LIMIT_BYTES} an image may be`);
5776
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
+ }
5777
6333
  return this.mutex.run(async () => {
5778
6334
  const id = this.newId();
5779
- await writeFileAtomic(this.paths.image(id), bytes);
6335
+ await writeFileAtomic(this.paths.image(id), kept);
5780
6336
  await this.events.append("image.uploaded", actor.name, {
5781
6337
  id,
5782
6338
  type,
5783
- size: bytes.byteLength
6339
+ size: kept.byteLength
5784
6340
  });
5785
6341
  const description = imageDescription(name);
5786
6342
  return {
5787
6343
  id,
5788
6344
  type,
5789
- size: bytes.byteLength,
6345
+ size: kept.byteLength,
5790
6346
  markdown: `![${description}](${IMAGE_SCHEME}${id})`
5791
6347
  };
5792
6348
  });
@@ -7087,6 +7643,39 @@ ${rationale}`}`);
7087
7643
  const events = await this.events.readSince(null, Number.MAX_SAFE_INTEGER);
7088
7644
  return tokenUse(events, window, this.now());
7089
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
+ }
7090
7679
  /** The board actions a finished turn took, from its transcript; null when it kept none. */
7091
7680
  async turnActions(agent, turnId) {
7092
7681
  const known = this.actionCounts.get(turnId);
@@ -7284,6 +7873,7 @@ ${rationale}`}`);
7284
7873
  trigger: parsed.trigger.kind,
7285
7874
  ...parsed.trigger.heartbeat === void 0 ? {} : { heartbeat: parsed.trigger.heartbeat },
7286
7875
  session: parsed.session,
7876
+ cli: parsed.cli,
7287
7877
  exitReason: parsed.exitReason,
7288
7878
  costUsd: parsed.costUsd,
7289
7879
  ...parsed.usage === null ? {} : { usage: parsed.usage },
@@ -9219,6 +9809,7 @@ var DEFAULT_TIMINGS = Object.freeze({
9219
9809
  debounceMs: 3e4,
9220
9810
  userDebounceMs: 5e3,
9221
9811
  heartbeatMs: 15 * 6e4,
9812
+ channelPostWaitMs: CHANNEL_POST_WAIT_MS,
9222
9813
  waitingStageMs: 10 * 6e4,
9223
9814
  leaseSweepMs: 6e4,
9224
9815
  opsIntervalMs: 5 * 6e4,
@@ -9238,6 +9829,7 @@ var SchedulerTimingsSchema = z17.object({
9238
9829
  debounceMs: z17.number().nonnegative(),
9239
9830
  userDebounceMs: z17.number().nonnegative(),
9240
9831
  heartbeatMs: z17.number().positive(),
9832
+ channelPostWaitMs: z17.number().nonnegative(),
9241
9833
  waitingStageMs: z17.number().positive(),
9242
9834
  leaseSweepMs: z17.number().positive(),
9243
9835
  opsIntervalMs: z17.number().positive(),
@@ -9306,10 +9898,14 @@ var StateSchema = z17.object({
9306
9898
  /** When each member last reflected, or was first seen; the cadence counts from here. */
9307
9899
  lastReflection: z17.record(z17.string(), z17.string()).default({}),
9308
9900
  /**
9309
- * The home session each society member last asked for an early reflection for, once per
9310
- * 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.
9311
9904
  */
9312
- 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
+ ])))
9313
9909
  });
9314
9910
  function messageOf(trigger) {
9315
9911
  return trigger.kind === "mention" || trigger.kind === "user_post" ? trigger.messageId : void 0;
@@ -9398,7 +9994,7 @@ var Scheduler = class {
9398
9994
  sweeping = /* @__PURE__ */ new Set();
9399
9995
  /** Crons whose fire's turn is running; a cron's next time passes while one is. */
9400
9996
  cronsInFlight = /* @__PURE__ */ new Set();
9401
- /** 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. */
9402
9998
  refused = /* @__PURE__ */ new Map();
9403
9999
  state = StateSchema.parse({
9404
10000
  cursor: null,
@@ -10258,7 +10854,9 @@ var Scheduler = class {
10258
10854
  continue;
10259
10855
  }
10260
10856
  this.state.lastHeartbeat[cadence] = iso(now);
10261
- unread ??= await this.board.unreadByConversation(actor);
10857
+ unread ??= await this.board.unreadByConversation(actor, {
10858
+ channelPostWaitMs: this.timings.channelPostWaitMs
10859
+ });
10262
10860
  held ??= await this.board.heldClaims(agent.name);
10263
10861
  pendingCrons ??= (await this.board.listCrons()).filter((cron) => cronStoppedAt(cron) === null);
10264
10862
  const crons = pendingCrons;
@@ -10399,18 +10997,20 @@ var Scheduler = class {
10399
10997
  }
10400
10998
  }
10401
10999
  /**
10402
- * A society role's home session that a turn left at `sessionCapTokens` or more, by its latest
10403
- * model call, asks for a reflection ahead of the cadence, which `checkReflections` queues once
10404
- * 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
10405
11004
  * (`TurnHost`), so its history goes only once a reflection saved what to keep. A session asks
10406
11005
  * once, so a reflection that fails leaves the next to the cadence, and none asks while a
10407
- * reflection is queued or running.
11006
+ * reflection is queued or running in its home.
10408
11007
  */
10409
11008
  async checkSessionSize(event) {
10410
11009
  const { payload } = event;
10411
11010
  const tokens = payload["contextTokens"];
10412
11011
  const session = stringOf(payload["session"]);
10413
- 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) {
10414
11014
  return;
10415
11015
  }
10416
11016
  const agent = await this.tryReadAgent(event.actor);
@@ -10418,24 +11018,33 @@ var Scheduler = class {
10418
11018
  return;
10419
11019
  }
10420
11020
  const charter = await this.board.readRole(agent.role).catch(() => null);
10421
- if (charter === null || !charter.societyScope || !charter.reflects) {
11021
+ if (charter === null || !charter.reflects || !this.isHome(agent, charter, project)) {
10422
11022
  return;
10423
11023
  }
10424
- if (this.state.earlyReflection[agent.name]?.session === session) {
11024
+ const home = sessionKey(agent.name, project);
11025
+ if (this.state.earlyReflection[home]?.session === session) {
10425
11026
  return;
10426
11027
  }
10427
- const ofAgent = (key) => parseSessionKey(key)?.agent === agent.name;
10428
- 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";
10429
11029
  if (reflecting) {
10430
11030
  return;
10431
11031
  }
10432
- this.state.earlyReflection[agent.name] = { session, due: true };
10433
- 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);
10434
11042
  }
10435
11043
  /**
10436
11044
  * Every `reflectionMs`, a member whose charter reflects takes a reflection turn in the scope of
10437
- * its latest working turn, unless it has not worked since its last reflection; a society member
10438
- * 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.
10439
11048
  */
10440
11049
  async checkReflections(now) {
10441
11050
  for (const agent of await this.board.listAgents()) {
@@ -10446,12 +11055,18 @@ var Scheduler = class {
10446
11055
  if (!charter.reflects) {
10447
11056
  continue;
10448
11057
  }
10449
- const early = this.state.earlyReflection[agent.name];
10450
- 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) {
10451
11060
  if (!this.busy(agent.name)) {
10452
- this.state.lastReflection[agent.name] = iso(now);
10453
- this.state.earlyReflection[agent.name] = { ...early, due: false };
10454
- 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
+ }
10455
11070
  }
10456
11071
  continue;
10457
11072
  }
@@ -10958,19 +11573,21 @@ var Scheduler = class {
10958
11573
  this.exclusive.set(key, "onboarding");
10959
11574
  } else if (dispatch.trigger.kind === "reflection") {
10960
11575
  this.exclusive.set(key, "reflection");
10961
- const early = this.state.earlyReflection[dispatch.agent];
11576
+ const early = this.state.earlyReflection[key];
10962
11577
  if (early?.due === true) {
10963
- this.state.earlyReflection[dispatch.agent] = { ...early, due: false };
11578
+ this.state.earlyReflection[key] = { ...early, due: false };
10964
11579
  }
10965
11580
  }
10966
11581
  }
10967
11582
  }
10968
11583
  /**
10969
- * A turn the model service turned away for now, overloaded or failing on its side, is sent again:
10970
- * the same wake in the same conversation, after the next delay of `transientRetryMs`, with every
10971
- * check a queued wake gets at dispatch. A wake already queued there, as a heartbeat's or a new
10972
- * post's, runs the conversation instead. Once the delays are spent the heartbeat takes over, and
10973
- * 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.
10974
11591
  */
10975
11592
  async retryRefused(key, item, record) {
10976
11593
  if (record.transient !== true || record.exitReason !== "error") {
@@ -10987,7 +11604,7 @@ var Scheduler = class {
10987
11604
  }
10988
11605
  const { steeredThrough: _steered, ...retry } = item;
10989
11606
  this.pending.set(key, { ...retry, readyAt: this.now().getTime() + delay });
10990
- 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");
10991
11608
  return;
10992
11609
  }
10993
11610
  if (refused !== this.timings.transientRetryMs.length + 1) {
@@ -10998,7 +11615,7 @@ var Scheduler = class {
10998
11615
  await this.board.publishSignal({
10999
11616
  kind: "turn_refused",
11000
11617
  key: `turn_refused:${key}`,
11001
- 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`,
11002
11619
  value: refused,
11003
11620
  threshold: refused,
11004
11621
  agent: dispatch.agent,
@@ -11099,6 +11716,7 @@ var TURN_CONTRACT = [
11099
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.",
11100
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.",
11101
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.",
11102
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.",
11103
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.",
11104
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.',
@@ -11652,7 +12270,7 @@ var TurnHost = class {
11652
12270
  const costSoFarUsd = !newSession && lastTurn?.session === recorded ? lastTurn.sessionCostUsd ?? lastTurn.costUsd : 0;
11653
12271
  const usageSoFar = !newSession && lastTurn?.session === recorded ? lastTurn.sessionUsage : void 0;
11654
12272
  const resumeRetries = !newSession && lastTurn?.session === recorded && lastTurn.resumeFailed === "retry" ? lastTurn.resumeRetries ?? 1 : 0;
11655
- 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);
11656
12274
  const afterReflection = lastTurn?.trigger.kind === "reflection" && lastTurn.sessionDropped === true;
11657
12275
  const digest = await this.board.readDigest({
11658
12276
  ...actor,
@@ -12088,8 +12706,11 @@ var TurnHost = class {
12088
12706
  }
12089
12707
  return { dropWorkspace: ended && (closing ? decided : !outcome.leftovers) };
12090
12708
  }
12091
- /** Ends a turn whose outcome will never come, as when its runner went away, as failed. */
12092
- 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 = {}) {
12093
12714
  const turn = this.open.get(turnId);
12094
12715
  if (turn === void 0) {
12095
12716
  return;
@@ -12104,7 +12725,8 @@ var TurnHost = class {
12104
12725
  endedAt: this.now().toISOString(),
12105
12726
  exitReason: "error",
12106
12727
  error: reason,
12107
- 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 } : {}
12108
12730
  };
12109
12731
  if (await this.recorded(turn, () => this.board.finishTurn(finished, turn.transcript))) {
12110
12732
  this.log.warn({ agent: turn.job.agent, project: turn.job.scope, reason }, "turn aborted");
@@ -12450,7 +13072,12 @@ var RunnerHub = class {
12450
13072
  this.log.info({ runner: name, version: hello.version, clis: hello.clis, slots: hello.slots }, "runner registered");
12451
13073
  return { name, version: this.version };
12452
13074
  }
12453
- /** 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
+ */
12454
13081
  async attach(name, send) {
12455
13082
  const seat = this.seats.get(name);
12456
13083
  if (seat === void 0) {
@@ -12462,6 +13089,13 @@ var RunnerHub = class {
12462
13089
  clearTimeout(seat.grace);
12463
13090
  seat.grace = null;
12464
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
+ }
12465
13099
  for (const { job } of seat.unacked.values()) {
12466
13100
  if (send({ type: "turn", job })) {
12467
13101
  this.expectAck(seat, job);
@@ -12745,7 +13379,7 @@ var RunnerHub = class {
12745
13379
  return opened.failed;
12746
13380
  }
12747
13381
  if (seat.send === null || !seat.send({ type: "turn", job: opened.job })) {
12748
- 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 });
12749
13383
  } else {
12750
13384
  this.expectAck(seat, opened.job);
12751
13385
  }
@@ -13235,6 +13869,19 @@ var RunnerHub = class {
13235
13869
  this.log.info({ runner: seat.name }, "runner is taking jobs again");
13236
13870
  }
13237
13871
  }
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");
13883
+ }
13884
+ }
13238
13885
  async detach(seat) {
13239
13886
  seat.send = null;
13240
13887
  this.runnerChanged(seat.name);
@@ -13255,7 +13902,8 @@ var RunnerHub = class {
13255
13902
  return;
13256
13903
  }
13257
13904
  for (const turnId of this.host.turnsOn(seat.name)) {
13258
- 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 });
13259
13907
  }
13260
13908
  }, this.graceMs);
13261
13909
  seat.grace.unref?.();
@@ -14803,6 +15451,34 @@ var ModelCatalog = class {
14803
15451
  }
14804
15452
  };
14805
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
+
14806
15482
  // apps/server/dist/quotas.js
14807
15483
  var MINUTE = 6e4;
14808
15484
  var QuotaReads = class {
@@ -14966,6 +15642,13 @@ var server = serveBoard({ fetch: app.fetch, port: config.port, hostname: config.
14966
15642
  trustedProxies: config.trustedProxies
14967
15643
  }, "board server listening");
14968
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
+ });
14969
15652
  await scheduler.start();
14970
15653
  log.info({
14971
15654
  concurrency: config.concurrency ?? "unlimited",
@@ -14977,6 +15660,7 @@ var shutdown = async (signal) => {
14977
15660
  log.info({ signal }, "shutting down; waiting for running turns to report");
14978
15661
  await scheduler.stop();
14979
15662
  await runners.close();
15663
+ metrics?.close();
14980
15664
  server.close();
14981
15665
  process.exit(0);
14982
15666
  };