@coreplane/switchboard 1.240.0 → 1.241.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/dist/assets/config/config.example.yaml +26 -7
  2. package/dist/assets/deploy/cloudflare/worker.ts +77 -18
  3. package/dist/assets/deploy/cloudflare-resident/worker.ts +1 -0
  4. package/dist/assets/deploy/secrets.manifest.json +6 -0
  5. package/dist/assets/package-lock.json +3 -3
  6. package/dist/assets/package.json +1 -1
  7. package/dist/assets/source.json +3 -3
  8. package/dist/assets/src/agents/registry.ts +27 -8
  9. package/dist/assets/src/core/authz/actor.ts +5 -0
  10. package/dist/assets/src/core/authz/grants.ts +20 -9
  11. package/dist/assets/src/core/coordinator/contract.ts +37 -0
  12. package/dist/assets/src/core/coordinator/driver.ts +17 -1
  13. package/dist/assets/src/core/coordinator/instancesRoute.ts +72 -0
  14. package/dist/assets/src/core/costs.ts +10 -5
  15. package/dist/assets/src/core/ship/coordinator.ts +22 -7
  16. package/dist/assets/src/core/trace/attrs.ts +1 -1
  17. package/dist/assets/src/execution/bindingPurge.ts +2 -2
  18. package/dist/assets/web/dist/.vite/manifest.json +422 -420
  19. package/dist/assets/web/dist/assets/AppShell-DEy5jRuS.js +1 -0
  20. package/dist/assets/web/dist/assets/{CostsPage-BD3w5j_S.js → CostsPage-BuHD-Bv7.js} +2 -2
  21. package/dist/assets/web/dist/assets/{DeliveryPage-BtvzKA1m.js → DeliveryPage-BGpPr5xr.js} +1 -1
  22. package/dist/assets/web/dist/assets/HomePage-dKBvWh-E.js +2 -0
  23. package/dist/assets/web/dist/assets/{NotFoundPage-DwOX1HaZ.js → NotFoundPage-DtmQM2gD.js} +1 -1
  24. package/dist/assets/web/dist/assets/PendingTurnRow-CrEAWx5b.js +1 -0
  25. package/dist/assets/web/dist/assets/{ResidentDetailPage-CUAOh5d9.js → ResidentDetailPage-C-48fxat.js} +1 -1
  26. package/dist/assets/web/dist/assets/{ResidentsIndexPage-B3KUlYy2.js → ResidentsIndexPage-CBBIjPOa.js} +1 -1
  27. package/dist/assets/web/dist/assets/RunFoldRow-B6vxjcBI.js +1 -0
  28. package/dist/assets/web/dist/assets/RunRoutePage-CQjd3Cz-.js +6 -0
  29. package/dist/assets/web/dist/assets/RunsIndexPage-Cy9G6D0p.js +1 -0
  30. package/dist/assets/web/dist/assets/{RunsTabs-BnkvCBrM.js → RunsTabs-CtNGNnhN.js} +1 -1
  31. package/dist/assets/web/dist/assets/{ScheduledPage-C1y9jgAk.js → ScheduledPage-Ci1Ygqnc.js} +1 -1
  32. package/dist/assets/web/dist/assets/SettingsPage-DxmJvAmT.js +1 -0
  33. package/dist/assets/web/dist/assets/{StatusDot-CiByEtoR.js → StatusDot-DPWE1JmO.js} +1 -1
  34. package/dist/assets/web/dist/assets/{Tooltip-DyRxk8A8.js → Tooltip-DKhSRH9t.js} +1 -1
  35. package/dist/assets/web/dist/assets/{UnitRoutePage-CnWyOizV.js → UnitRoutePage-DEN5bSQf.js} +1 -1
  36. package/dist/assets/web/dist/assets/{angular-html-DAQ_1T1v.js → angular-html-DgSK1qvr.js} +1 -1
  37. package/dist/assets/web/dist/assets/{angular-ts-D4kxRVp1.js → angular-ts-D3gNdiSG.js} +1 -1
  38. package/dist/assets/web/dist/assets/{apl-Dy07l64y.js → apl-CkHCYM8I.js} +1 -1
  39. package/dist/assets/web/dist/assets/{astro-DZIbTlc7.js → astro-DLm45axt.js} +1 -1
  40. package/dist/assets/web/dist/assets/{blade-CmSXWX3l.js → blade-BKa-VE-c.js} +1 -1
  41. package/dist/assets/web/dist/assets/{c-ggzVdJLK.js → c-C8NCNWai.js} +1 -1
  42. package/dist/assets/web/dist/assets/{chapel-Bo1wktsG.js → chapel-DqDQ7IKH.js} +1 -1
  43. package/dist/assets/web/dist/assets/{cobol-DJUmtKM2.js → cobol-j4vCD6AJ.js} +1 -1
  44. package/dist/assets/web/dist/assets/{coffee-BZWOVz4C.js → coffee-C6oDVH-0.js} +1 -1
  45. package/dist/assets/web/dist/assets/{cpp-Dtk39yMw.js → cpp-Bd3A1Rtc.js} +1 -1
  46. package/dist/assets/web/dist/assets/{crystal-CGf_-Zoj.js → crystal-CUOgOw0d.js} +1 -1
  47. package/dist/assets/web/dist/assets/{css-CX2fcL4u.js → css-RYljyv7G.js} +1 -1
  48. package/dist/assets/web/dist/assets/{dist-D8TaC961.js → dist-BnJXKHCY.js} +2 -2
  49. package/dist/assets/web/dist/assets/{edge-BYS8FtgQ.js → edge-BJhNdQDc.js} +1 -1
  50. package/dist/assets/web/dist/assets/{elixir-fhUpi3nO.js → elixir-g1AWOAfT.js} +1 -1
  51. package/dist/assets/web/dist/assets/{elm-DAQNiwsN.js → elm-CER5e4Dz.js} +1 -1
  52. package/dist/assets/web/dist/assets/{erb-CWzEys6U.js → erb-D5aOmMQf.js} +1 -1
  53. package/dist/assets/web/dist/assets/{git-rebase-9rY3p-Rw.js → git-rebase-D6rfV8jp.js} +1 -1
  54. package/dist/assets/web/dist/assets/{glimmer-js-tY7hy0nE.js → glimmer-js-BvDH3-mC.js} +1 -1
  55. package/dist/assets/web/dist/assets/{glimmer-ts-CTrLv5JF.js → glimmer-ts-DChtNs9V.js} +1 -1
  56. package/dist/assets/web/dist/assets/{glsl-BRJbiDfq.js → glsl-BHlsWrxf.js} +1 -1
  57. package/dist/assets/web/dist/assets/{graphql-B-MtVLh2.js → graphql-BsaBHb53.js} +1 -1
  58. package/dist/assets/web/dist/assets/{hack-BtIkOypQ.js → hack-0lbDosCd.js} +1 -1
  59. package/dist/assets/web/dist/assets/{haml-CoOH6gfa.js → haml-BKtco2vr.js} +1 -1
  60. package/dist/assets/web/dist/assets/{handlebars-DgrRu-GP.js → handlebars-BE_cj04z.js} +1 -1
  61. package/dist/assets/web/dist/assets/{html-DZaaI7ca.js → html-BNcr8EVd.js} +1 -1
  62. package/dist/assets/web/dist/assets/{html-derivative-DgHvT-qZ.js → html-derivative-BsJt2Kei.js} +1 -1
  63. package/dist/assets/web/dist/assets/{http-CvAd51yz.js → http-BR5P8ER_.js} +1 -1
  64. package/dist/assets/web/dist/assets/{hurl-CXGqAfzx.js → hurl-CtHalpWS.js} +1 -1
  65. package/dist/assets/web/dist/assets/{indexRow-Cse3JGb0.js → indexRow-BD1VT8o8.js} +1 -1
  66. package/dist/assets/web/dist/assets/{java-CIQStIXF.js → java-BXVdhN11.js} +1 -1
  67. package/dist/assets/web/dist/assets/{javascript-WbH8s7E3.js → javascript-CyoShgNQ.js} +1 -1
  68. package/dist/assets/web/dist/assets/{jinja-DzyWEVKC.js → jinja-BQYZzHex.js} +1 -1
  69. package/dist/assets/web/dist/assets/{jison-CrT-zCa-.js → jison-DqrUa1Eq.js} +1 -1
  70. package/dist/assets/web/dist/assets/{json-lHjpjXMQ.js → json-CQiTF9Jj.js} +1 -1
  71. package/dist/assets/web/dist/assets/{jsx-WTwYCHW6.js → jsx-Y16MkCYQ.js} +1 -1
  72. package/dist/assets/web/dist/assets/{julia-Bqpk9H7E.js → julia-zn16YONe.js} +1 -1
  73. package/dist/assets/web/dist/assets/{just-LdXk1r3m.js → just-DcVeN_MN.js} +1 -1
  74. package/dist/assets/web/dist/assets/{latex-h_y2A5KD.js → latex-BNNoF-WP.js} +1 -1
  75. package/dist/assets/web/dist/assets/{liquid-CY7bF2_p.js → liquid-DAi6qHzn.js} +1 -1
  76. package/dist/assets/web/dist/assets/{lua-oQ7XVZOY.js → lua-BswS0axw.js} +1 -1
  77. package/dist/assets/web/dist/assets/{main-CGO6fQ8k.js → main-B3is4sk6.js} +2 -2
  78. package/dist/assets/web/dist/assets/main-DP_zSemY.css +1 -0
  79. package/dist/assets/web/dist/assets/{marko-zv_qZ4QV.js → marko-C-4hkmQZ.js} +1 -1
  80. package/dist/assets/web/dist/assets/{mdc-Dl4kL_YK.js → mdc-0ZaHUkS7.js} +1 -1
  81. package/dist/assets/web/dist/assets/{nginx-CAHCZj4F.js → nginx-DBDg5tOR.js} +1 -1
  82. package/dist/assets/web/dist/assets/{nim-BOtmwCJH.js → nim-BVMz49Yw.js} +1 -1
  83. package/dist/assets/web/dist/assets/{org-DspQk3HQ.js → org-1hO_K6ya.js} +1 -1
  84. package/dist/assets/web/dist/assets/{perl-CAXmvVl0.js → perl-CVRQLvVk.js} +1 -1
  85. package/dist/assets/web/dist/assets/{php-CMUx4VyM.js → php-K9nCrB9n.js} +1 -1
  86. package/dist/assets/web/dist/assets/{pug-B55xVJTv.js → pug-CTxOcmO3.js} +1 -1
  87. package/dist/assets/web/dist/assets/{qml-DsNKCCRk.js → qml-CmCkfG5q.js} +1 -1
  88. package/dist/assets/web/dist/assets/{r-DbhP0VvO.js → r-pP47Xn1X.js} +1 -1
  89. package/dist/assets/web/dist/assets/{razor-BCpE6P-p.js → razor-BpW6r3tC.js} +1 -1
  90. package/dist/assets/web/dist/assets/{regexp-BllsjiN4.js → regexp-CINgdY4N.js} +1 -1
  91. package/dist/assets/web/dist/assets/{rst-CO5Rpq9V.js → rst-BZIUEq2Q.js} +1 -1
  92. package/dist/assets/web/dist/assets/{ruby-C2A4OgQq.js → ruby-nzAOxz6r.js} +1 -1
  93. package/dist/assets/web/dist/assets/{sas-Butzoyql.js → sas-QIS1bFth.js} +1 -1
  94. package/dist/assets/web/dist/assets/{scss-PEUwJBpf.js → scss-BuhBBVNt.js} +1 -1
  95. package/dist/assets/web/dist/assets/{shellscript-3Yhq0khE.js → shellscript-CQc1vXbk.js} +1 -1
  96. package/dist/assets/web/dist/assets/{shellsession-04Hkb7m3.js → shellsession-CoubCAUv.js} +1 -1
  97. package/dist/assets/web/dist/assets/{soy-BnId73cP.js → soy-CPRzlder.js} +1 -1
  98. package/dist/assets/web/dist/assets/{sql-BKwdc0ch.js → sql-W9krb8-9.js} +1 -1
  99. package/dist/assets/web/dist/assets/{sseReplay-DutHNghE.js → sseReplay-yji8a1wM.js} +1 -1
  100. package/dist/assets/web/dist/assets/{stata-BPPtNkay.js → stata-CuISJEC0.js} +1 -1
  101. package/dist/assets/web/dist/assets/{surrealql-CDPChcYP.js → surrealql-CSet7584.js} +1 -1
  102. package/dist/assets/web/dist/assets/{svelte-B15DIQ-z.js → svelte-ChXTSYwK.js} +1 -1
  103. package/dist/assets/web/dist/assets/{templ-D-rp_AiB.js → templ-Bm55v62k.js} +1 -1
  104. package/dist/assets/web/dist/assets/{tex-BnVDCD7p.js → tex-CICMX8Gj.js} +1 -1
  105. package/dist/assets/web/dist/assets/{ts-tags-cpujdyAR.js → ts-tags-CA1UzWyB.js} +1 -1
  106. package/dist/assets/web/dist/assets/{tsx-D1vO4L72.js → tsx-Dy04HNbv.js} +1 -1
  107. package/dist/assets/web/dist/assets/{twig-oQGnQGRc.js → twig-BBHsVnVD.js} +1 -1
  108. package/dist/assets/web/dist/assets/{typescript-CKSFpXiH.js → typescript-Dvc-wVBT.js} +1 -1
  109. package/dist/assets/web/dist/assets/{typst-DKHFo8BF.js → typst-Dvlqx3_q.js} +1 -1
  110. package/dist/assets/web/dist/assets/{vue-html-DpCp0_Sz.js → vue-html-D4YxT3An.js} +1 -1
  111. package/dist/assets/web/dist/assets/{vue-vine-y5d5wUb1.js → vue-vine-0YE7uQlH.js} +1 -1
  112. package/dist/assets/web/dist/assets/{vue-BstokpiS.js → vue-zWi1MNU2.js} +1 -1
  113. package/dist/assets/web/dist/assets/{xml-Br2hx5Ax.js → xml-gm-iksZB.js} +1 -1
  114. package/dist/assets/web/dist/assets/{xsl-Bc8Cobap.js → xsl-D_W5BcrY.js} +1 -1
  115. package/dist/assets/web/dist/assets/{yaml-LOITy_I1.js → yaml-CHmZ21wZ.js} +1 -1
  116. package/dist/cli.js +3411 -502
  117. package/package.json +1 -1
  118. package/dist/assets/web/dist/assets/AppShell-CM_fh-WW.js +0 -1
  119. package/dist/assets/web/dist/assets/HomePage-BU6aRCSe.js +0 -2
  120. package/dist/assets/web/dist/assets/RunFoldRow-Dulcbg25.js +0 -1
  121. package/dist/assets/web/dist/assets/RunRoutePage-DGoGrQZq.js +0 -6
  122. package/dist/assets/web/dist/assets/RunsIndexPage-37W7OZbC.js +0 -1
  123. package/dist/assets/web/dist/assets/SettingsPage-ggfB-Rqr.js +0 -1
  124. package/dist/assets/web/dist/assets/Slideover-CX-LEklF.js +0 -1
  125. package/dist/assets/web/dist/assets/main-O8UXRuEv.css +0 -1
@@ -327,13 +327,20 @@ workspaceDir: ./workspaces
327
327
  # # a finding at or above it sends the round into a fix step instead of merge-ready.
328
328
  # # Per channel/user: config set … --ship.addressSeverity; per run: severity:<level>.
329
329
 
330
- # `harness` is a retired key (docs/reference/specs/harness-pi.md item 1). It
331
- # moved the presets onto pi one at a time; every preset runs on pi now — the pi
332
- # coding agent started inside the run's own execution container (or as a child
333
- # of the bot for a preset without a workspace) with the run's model-proxy
334
- # bearer as its only key — and there is no other loop. A block written during
335
- # the series still loads when every value is `pi`; `native` names the deleted
336
- # loop and fails the load by name, as does an unknown preset. Remove the block.
330
+ # Which harness each preset's runs are driven by (docs/reference/specs/harness.md
331
+ # item 8): a mapping of preset to a harness's name. Two exist — `pi` (the pi
332
+ # coding agent, the default for every preset) and `opencode` (OpenCode's server,
333
+ # `opencode serve`) — each started inside the run's own execution container (or
334
+ # as a child of the bot for a preset without a workspace) with the run's
335
+ # model-proxy bearer as its only key, every tool call decided in the bot, every
336
+ # event on the run's record. A preset the block does not name runs on pi;
337
+ # nothing defaults to OpenCode. A run keeps the harness it started on, so
338
+ # changing a word moves the next run, never one in flight. Any other word
339
+ # (`native`, the deleted loop; `codex`) fails the load naming the two, as does
340
+ # an unknown preset. How-to: docs/how-to/put-a-preset-on-opencode.md.
341
+ # harness:
342
+ # coding: opencode # coding runs on OpenCode
343
+ # review: pi # the default, written out
337
344
 
338
345
  # What the harness writes into pi's per-run settings for every run on pi
339
346
  # (docs/reference/specs/harness-pi.md item 4): pi's compaction thresholds, in
@@ -349,6 +356,18 @@ workspaceDir: ./workspaces
349
356
  # reserveTokens: 150000
350
357
  # keepRecentTokens: 8000
351
358
 
359
+ # What the harness writes into OpenCode's per-run configuration for every run
360
+ # on OpenCode (docs/reference/specs/harness.md item 8): OpenCode's compaction
361
+ # thresholds, in tokens, under OpenCode's own names — `buffer`, the tokens kept
362
+ # free of the model's window before a compaction runs; `keepTokens`, how much
363
+ # of the newest turns it keeps. Unset, OpenCode's defaults stand and the file
364
+ # names no compaction. The OpenCode twin of the `pi` block above; positive
365
+ # integers only, any other shape or key fails the load by name.
366
+ # opencode:
367
+ # compaction:
368
+ # buffer: 20000
369
+ # keepTokens: 8000
370
+
352
371
  # The fan-out cap a spawning run meets (docs/reference/specs/agent-conductor.md).
353
372
  # `agent:conductor` starts child runs as the person who asked — each an
354
373
  # ordinary run in a thread of its own, under that person's permissions — and
@@ -17,8 +17,11 @@ import {
17
17
  createInstanceResponse,
18
18
  instanceStatusResponse,
19
19
  isInstanceNotFound,
20
+ parseInstanceEventPath,
20
21
  parseInstanceStatusPath,
21
22
  parseCreateInstanceRequest,
23
+ parseSendEventRequest,
24
+ sendEventResponse,
22
25
  parseSubjectAuthorization,
23
26
  type CreateInstanceOutcome,
24
27
  } from "../../src/core/coordinator/instancesRoute.ts";
@@ -87,6 +90,7 @@ export interface Env {
87
90
  DASHBOARD_TOKEN?: string; // dashboard auth `token` strategy: the bearer (the default env name; config may name another)
88
91
  SWITCHBOARD_INGRESS_TOKENS?: string; // enables HTTP /ingress + MCP /mcp (JSON token→identity map); the `cron` entry is what scheduled runs present; an entry whose `http:<subject>` actor holds `deploy:write` in the bot's config may POST /admin/restart
89
92
  BRAVE_SEARCH_API_KEY?: string; // web_search backend (Brave); web_fetch works without it
93
+ GITHUB_WEBHOOK_SECRET?: string; // check-run intake: signs POST /webhooks/github; absent, the intake answers 503 disabled
90
94
  CF_ANALYTICS_TOKEN?: string; // costs dash: Cloudflare API token, Account Analytics:Read only
91
95
  ANTHROPIC_ADMIN_KEY?: string; // costs dash (optional): Anthropic Admin API key for the LLM cost report
92
96
  MEMORY_TOKEN?: string; // durable memory + friction ledger + schedule firings + MCP registry: bearer for the state Worker
@@ -125,6 +129,7 @@ const FORWARDED_OPTIONAL = [
125
129
  "ANTHROPIC_ADMIN_KEY",
126
130
  "SWITCHBOARD_INGRESS_TOKENS",
127
131
  "BRAVE_SEARCH_API_KEY",
132
+ "GITHUB_WEBHOOK_SECRET",
128
133
  "MEMORY_TOKEN",
129
134
  "MCP_CREDENTIAL_KEY",
130
135
  "MCP_ACCESS_CLIENT_ID",
@@ -407,6 +412,57 @@ async function handleCoordinatorInstanceStatus(request: Request, env: Env, id: s
407
412
  return json(res.status, res.body);
408
413
  }
409
414
 
415
+ /** `POST /admin/coordinator/instances/<id>/events` — the shim's event relay
416
+ * (docs/reference/specs/http-ingress.md item 12): the bot's check-run intake
417
+ * holds no Workflow binding, so its `checks-settled-<head>` send crosses here
418
+ * and this Worker's own `SHIP_COORDINATOR` binding delivers it. The same door
419
+ * as the create and the status: the bearer in the map, the bot's `authorize`
420
+ * answer. The engine's `instance.not_found` is 404; any other refusal (an
421
+ * instance that already ended) is 502 by reason — the sender treats both as a
422
+ * failed send and the driver's bounded wait is the fallback. */
423
+ async function handleCoordinatorInstanceEvent(request: Request, env: Env, id: string): Promise<Response> {
424
+ const json = (status: number, body: Record<string, unknown>) =>
425
+ new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
426
+ if (request.method !== "POST")
427
+ return json(405, {
428
+ ok: false,
429
+ error: `method not allowed: ${request.method} — ${COORDINATOR_INSTANCES_PATH}/<id>/events answers POST only`,
430
+ });
431
+ const authorization = request.headers.get("authorization") ?? undefined;
432
+ const authn = authenticateIngressBearer(authorization, env.SWITCHBOARD_INGRESS_TOKENS, "coordinator");
433
+ if (!authn.ok) {
434
+ console.warn(`[coordinator] instance event ${authn.status} — ${authn.reason}`);
435
+ return json(authn.status, { ok: false, error: authn.reason });
436
+ }
437
+ let answer: Response;
438
+ try {
439
+ answer = await getContainer(env.SWITCHBOARD, INSTANCE).fetch(
440
+ new Request(`${INTERNAL}${COORDINATOR_AUTHORIZE_PATH}`, {
441
+ method: "POST",
442
+ headers: { authorization: authorization ?? "" },
443
+ }),
444
+ );
445
+ } catch (err) {
446
+ const reason = err instanceof Error ? err.message : String(err);
447
+ return json(503, { ok: false, error: `coordinator disabled: the bot could not be asked (${reason})` });
448
+ }
449
+ const auth = parseSubjectAuthorization(answer.status, await answer.text().catch(() => ""));
450
+ if (!auth.ok) return json(auth.status, { ok: false, error: auth.reason });
451
+ const parsed = parseSendEventRequest(await request.text().catch(() => ""));
452
+ if (!parsed.ok) return json(400, { ok: false, error: parsed.reason });
453
+ let outcome: Parameters<typeof sendEventResponse>[0];
454
+ try {
455
+ await (await env.SHIP_COORDINATOR.get(id)).sendEvent({ type: parsed.type, payload: parsed.payload });
456
+ outcome = { kind: "sent", id };
457
+ } catch (err) {
458
+ const reason = err instanceof Error ? err.message : String(err);
459
+ outcome = isInstanceNotFound(reason) ? { kind: "absent", id } : { kind: "failed", id, reason };
460
+ }
461
+ const res = sendEventResponse(outcome);
462
+ console.log(`[coordinator] ${auth.subject} → instance ${id} event ${parsed.type}: ${outcome.kind}`);
463
+ return json(res.status, res.body);
464
+ }
465
+
410
466
  /** Record a firing on the state Worker's ScheduleDO (the /runs Scheduled panel
411
467
  * reads it). Best-effort: a failure here is a log line — the run itself (if
412
468
  * any) already happened and is its own record. */
@@ -440,29 +496,32 @@ export default {
440
496
  // The routes the Worker answers itself — the restart, the coordinator's
441
497
  // instance creation and an instance's status, all over bindings only this
442
498
  // Worker holds; everything else is the container's.
443
- const statusId = parseInstanceStatusPath(pathname);
499
+ const eventsId = parseInstanceEventPath(pathname);
500
+ const statusId = eventsId === undefined ? parseInstanceStatusPath(pathname) : undefined;
444
501
  const res =
445
502
  pathname === "/admin/restart"
446
503
  ? await handleAdminRestart(forwarded, env)
447
504
  : pathname === COORDINATOR_INSTANCES_PATH
448
505
  ? await handleCoordinatorInstances(forwarded, env)
449
- : statusId !== undefined
450
- ? await handleCoordinatorInstanceStatus(forwarded, env, statusId)
451
- : pathname === COPY_PATH
452
- ? // The artifact copy (artifactsCopy.ts): the R2 binding and the Slack
453
- // token are this Worker's; the bot only asks, with its copy bearer.
454
- // `forwarded`, never `inbound`: `withTraceContext` rebuilt the request
455
- // with `new Request(inbound, …)`, which takes the body stream with it —
456
- // `inbound.text()` is empty afterwards and the route read "not JSON" live.
457
- await handleArtifactsCopy(forwarded, {
458
- bucket: env.ARTIFACTS,
459
- bucketName: env.ARTIFACTS_BUCKET_NAME,
460
- copyToken: env.ARTIFACTS_COPY_TOKEN,
461
- slackToken: env.SLACK_BOT_TOKEN,
462
- fetch: (input, init) => fetch(input, init),
463
- lengthPipe: (size) => new FixedLengthStream(size),
464
- })
465
- : withLength(await getContainer(env.SWITCHBOARD, INSTANCE).fetch(forwarded));
506
+ : eventsId !== undefined
507
+ ? await handleCoordinatorInstanceEvent(forwarded, env, eventsId)
508
+ : statusId !== undefined
509
+ ? await handleCoordinatorInstanceStatus(forwarded, env, statusId)
510
+ : pathname === COPY_PATH
511
+ ? // The artifact copy (artifactsCopy.ts): the R2 binding and the Slack
512
+ // token are this Worker's; the bot only asks, with its copy bearer.
513
+ // `forwarded`, never `inbound`: `withTraceContext` rebuilt the request
514
+ // with `new Request(inbound, …)`, which takes the body stream with it —
515
+ // `inbound.text()` is empty afterwards and the route read "not JSON" live.
516
+ await handleArtifactsCopy(forwarded, {
517
+ bucket: env.ARTIFACTS,
518
+ bucketName: env.ARTIFACTS_BUCKET_NAME,
519
+ copyToken: env.ARTIFACTS_COPY_TOKEN,
520
+ slackToken: env.SLACK_BOT_TOKEN,
521
+ fetch: (input, init) => fetch(input, init),
522
+ lengthPipe: (size) => new FixedLengthStream(size),
523
+ })
524
+ : withLength(await getContainer(env.SWITCHBOARD, INSTANCE).fetch(forwarded));
466
525
  root.end(res.status >= 500 ? "error" : "ok", { httpStatus: res.status });
467
526
  return res;
468
527
  } catch (err) {
@@ -7067,6 +7067,7 @@ export class ResidentDO extends Sandbox<Env> {
7067
7067
  LIFECYCLE_KEY,
7068
7068
  REFRESH_INSTANCE_KEY,
7069
7069
  RUNTIME_UNREACHABLE_KEY,
7070
+ AUTO_REBUILDS_KEY,
7070
7071
  ]);
7071
7072
  const facts = map.get(FACTS_KEY) as RepoFacts | undefined;
7072
7073
  const snap = map.get(SNAPSHOT_KEY) as SnapshotRecord | undefined;
@@ -40,6 +40,12 @@
40
40
  "optional": true,
41
41
  "note": "JSON bearer→identity map for POST /ingress and POST /mcp: `{ subject, channel?, email? }` per entry. Self-minted (openssl rand -hex 32 per entry). Optional: without it /ingress and /mcp are disabled and `deploy restart` has no bearer. The `cron` entry fires schedules; a `coordinator` entry (granted `coordinator:step`) is the ship coordinator's, rotated apart from cron. An `email` binds the token to a person: their runs, their grants untouched (authorization.md item 15)."
42
42
  },
43
+ {
44
+ "name": "GITHUB_WEBHOOK_SECRET",
45
+ "workers": ["bot"],
46
+ "optional": true,
47
+ "note": "GitHub webhook signing secret for POST /webhooks/github (the check-run intake). Set the same value on the repo's webhook. Optional: without it the intake answers 503 disabled and the merge wait falls back to its bounded timeout."
48
+ },
43
49
  {
44
50
  "name": "SANDBOX_TOKEN",
45
51
  "workers": ["bot", "sandbox"],
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.240.0",
3
+ "version": "1.241.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "switchboard",
9
- "version": "1.240.0",
9
+ "version": "1.241.0",
10
10
  "license": "Apache-2.0",
11
11
  "workspaces": [
12
12
  "web",
@@ -20445,7 +20445,7 @@
20445
20445
  },
20446
20446
  "packages/switchboard": {
20447
20447
  "name": "@coreplane/switchboard",
20448
- "version": "1.240.0",
20448
+ "version": "1.241.0",
20449
20449
  "license": "Apache-2.0",
20450
20450
  "dependencies": {
20451
20451
  "@earendil-works/pi-ai": "0.85.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.240.0",
3
+ "version": "1.241.0",
4
4
  "private": true,
5
5
  "description": "Mention it in Slack and an agent reviews the PR, ships the fix, or answers the question — on the model you choose, with its tools running where you decide.",
6
6
  "license": "Apache-2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.240.0",
3
- "commit": "dd69a6edcfaa523c998ff1059f47b8ef1675ebe9",
4
- "builtAt": "2026-09-17T00:22:24.645Z"
2
+ "version": "1.241.0",
3
+ "commit": "645b2a6d20ac39178acdb7c412a6a72f6013771c",
4
+ "builtAt": "2026-09-17T01:34:41.433Z"
5
5
  }
@@ -133,6 +133,23 @@ export interface AgentDef {
133
133
  // and rendered against the head sha at render time, so a repush is a
134
134
  // re-render by Switchboard — the agent only resubmits when the CONTENT (line
135
135
  // numbers included) changed.
136
+ /** The status-card rule every tool-running preset carries (docs/reference/specs/run-visibility.md
137
+ * item 2). One sentence in one place: the card shows the command running right
138
+ * now beside the checklist, so the checklist's markers must be facts — ✱ from
139
+ * the item's first command, ✓ only once its result has been read — or the two
140
+ * contradict each other on the card. `examples` are the preset's own outcome
141
+ * phrasings; the rule itself never varies by preset. */
142
+ export function statusCardRule(examples = '"Implement the fix", "Run the test suite"'): string {
143
+ return (
144
+ "Maintain the user-facing status card with the update_status tool. Post your plan as a checklist (○ pending) as soon as you have it, " +
145
+ "then keep it truthful at every moment: the markers are facts, not intentions. Mark an item ✱ when you issue the first command that does it, " +
146
+ "and ✓ only after you have read the result that proves it happened — never in the same turn as the command, never because you intend to run it next, " +
147
+ "never for a reporting or posting step you have not done. The order is: mark the item ✱, run its commands, read the result, then mark it ✓ and the next item ✱. " +
148
+ "The card shows the command running right now beside your checklist, so a ✓ item whose command is still running reads as a lie. " +
149
+ `Items are short outcomes (${examples}), never commands. This is the only progress the user sees while you work.`
150
+ );
151
+ }
152
+
136
153
  const PR_DESCRIPTION_TEMPLATE = `PR description — submit it with the submit_pr_description tool for EVERY PR (this is the default, not something to wait to be asked for). Switchboard renders the GitHub body from the object you submit, so never author PR-body markdown yourself. Before submitting, judge your title with the ${PR_TITLE_GUARD} gate — \`npm run check:pr-title -- "<title>"\` — and submit only a title it accepts; the same gate refuses the PR in CI. Content contract per field (each renders as its own section): prose is unwrapped — no hard line breaks inside a paragraph. Always hyperlink the triggering issue/request. Never fabricate validation — state exactly what you ran and the real result. Keep each field concise, not padded.
137
154
  EVERY PR includes one that already exists when you push — opened by a person, by dependabot, or by an earlier run. After EVERY push to such a PR: read its current title and body (\`github_issue_get\` with the PR number works for pull requests; \`gh pr view\` where gh exists), judge them against the change as it now stands at the pushed head, and submit the object that describes the PR as it is NOW — carry forward what the existing body says that is still true (a dependency bump's release notes belong in whatWhy), add what you changed, and anchor the Tour at the new head. Switchboard replaces the PR's title and body with your rendering. A description that describes an earlier state of its branch is a bug; "it is someone else's PR" is never a reason to leave it.
138
155
  - **title**: the PR title — one line naming the change, specific enough to pick out of a PR list.
@@ -237,7 +254,7 @@ ${SHOW_FILES}
237
254
 
238
255
  ${NOTEPAD}
239
256
 
240
- Maintain the user-facing status card with the update_status tool: right after you decide your plan, post it as a checklist (○ pending items), then update it whenever an item starts (✱) or finishes (✓). Items are short outcomes ("Clone repo and read the diff", "Run the test suite"), never commands. Mark an item ✓ only after it has actually happened — never pre-mark reporting/posting steps. This is the only progress the user sees while you work.
257
+ ${statusCardRule('"Clone repo and read the diff", "Run the test suite"')}
241
258
 
242
259
  If the request doesn't name a repository and you can't infer it, ask for it instead of guessing.
243
260
  Report outcomes faithfully: if tests fail or a step was skipped, say so plainly.
@@ -280,7 +297,7 @@ ${SHOW_FILES}
280
297
 
281
298
  ${NOTEPAD}
282
299
 
283
- Maintain the user-facing status card with the update_status tool: right after you decide your plan, post it as a checklist (○ pending items), then update it whenever an item starts (✱) or finishes (✓). Items are short outcomes ("Implement the fix", "Run the test suite"), never commands. Mark an item ✓ only after it has actually happened — never pre-mark reporting/posting steps. This is the only progress the user sees while you work.
300
+ ${statusCardRule()}
284
301
 
285
302
  Report outcomes faithfully: if tests fail or a step was skipped, say so plainly.
286
303
  ${FENCED_CONTENT_RULE}
@@ -319,7 +336,7 @@ ${SHOW_FILES}
319
336
 
320
337
  ${NOTEPAD}
321
338
 
322
- Maintain the user-facing status card with the update_status tool: right after you decide your plan, post it as a checklist (○ pending items), then update it whenever an item starts (✱) or finishes (✓). Items are short outcomes ("Implement the fix", "Run the test suite"), never commands. Mark an item ✓ only after it has actually happened — never pre-mark reporting/posting steps. This is the only progress the user sees while you work.
339
+ ${statusCardRule()}
323
340
 
324
341
  Report outcomes faithfully: if tests fail or a step was skipped, say so plainly.
325
342
  ${FENCED_CONTENT_RULE}
@@ -384,7 +401,7 @@ ${REVIEW_VERDICT_INSTRUCTION}
384
401
 
385
402
  ${NOTEPAD}
386
403
 
387
- Maintain the user-facing status card with the update_status tool: post your plan as a checklist (○ pending), update as items start (✱) and finish (✓ — only after they actually happened; never pre-mark reporting steps). Items are short outcomes, never commands.
404
+ ${statusCardRule('"Gather the diff and the files", "Analyze the change", "Post the verdict"')}
388
405
 
389
406
  ${FENCED_CONTENT_RULE}
390
407
  Your final message is posted to Slack. Lead with a one-line verdict, then the findings.`;
@@ -417,7 +434,7 @@ ${REVIEW_VERDICT_INSTRUCTION}
417
434
 
418
435
  ${NOTEPAD}
419
436
 
420
- Maintain the user-facing status card with the update_status tool: post your plan as a checklist (○ pending), update as items start (✱) and finish (✓ — only after they actually happened; never pre-mark reporting steps). Items are short outcomes, never commands.
437
+ ${statusCardRule('"Gather the diff and the files", "Analyze the change", "Post the verdict"')}
421
438
 
422
439
  ${FENCED_CONTENT_RULE}
423
440
  Your final message is posted to Slack. Lead with a one-line verdict, then the findings.`;
@@ -451,7 +468,7 @@ ${REVIEW_VERDICT_INSTRUCTION}
451
468
 
452
469
  ${NOTEPAD}
453
470
 
454
- Maintain the user-facing status card with the update_status tool: post your plan as a checklist (○ pending), update as items start (✱) and finish (✓ — only after they actually happened; never pre-mark reporting steps). Items are short outcomes, never commands.
471
+ ${statusCardRule('"Gather the diff and the files", "Analyze the change", "Post the verdict"')}
455
472
 
456
473
  ${FENCED_CONTENT_RULE}
457
474
  Your final message is posted to Slack. Lead with a one-line verdict, then the findings.`;
@@ -469,7 +486,7 @@ How to work:
469
486
  2. Prefer primary sources; corroborate a surprising claim with a second source.
470
487
  3. Answer concisely and cite the URLs (or repo paths) you used. If sources conflict or you couldn't verify something, say so plainly. If web search is unconfigured, use web_fetch / the GitHub tools on what you have and say search was unavailable.
471
488
 
472
- Maintain the user-facing status card with the update_status tool: post a short checklist (○ pending) after you plan, and update items as they start (✱) and finish (✓ — only once they actually happened).
489
+ ${statusCardRule('"Search the sources", "Write the answer"')}
473
490
 
474
491
  ${FENCED_CONTENT_RULE}
475
492
  Use Slack-friendly formatting (no markdown headers; *bold*, bullets, code blocks). Your final message is posted to Slack — lead with the answer, then supporting detail and sources.`;
@@ -485,6 +502,8 @@ Answer directly and concisely. Use Slack-friendly formatting (no markdown header
485
502
 
486
503
  Your tools work without a workspace: the GitHub tools — \`github_repos\` (the org repositories you can reach), \`github_tree\` / \`github_file\` / \`github_search_code\` (browse, read, search their code and docs, private repos included), \`github_issue_list\` / \`github_issue_get\` (read issues), \`github_issue_create\` / \`github_issue_update\` / \`github_issue_comment\` / \`github_issue_delete\` (act on issues) — and \`web_fetch\` (read a public URL). Use them: when the user names a repo loosely ("the switchboard app"), resolve it with github_repos (or the thread) rather than asking; when asked about one of our repos, read it before answering. Report exactly what a tool did (issue number + URL) — never claim an action you did not perform, and never fabricate file contents, URLs, or command output.
487
504
 
505
+ ${statusCardRule('"Read the issue and its thread", "Post the comment"')} A one-step answer needs no checklist; post one when the request has steps the person would wait on.
506
+
488
507
  You cannot run commands, clone repositories, edit code, or review pull requests, and you cannot search the web. Other Switchboard agents can: for code changes or PRs tell the user to re-send with \`agent:coding\`; for a PR review, \`agent:review\`; for a web-research question, \`agent:research\` (e.g. "\`agent:coding fix the failing login test in acme/api\`", "\`agent:research compare X and Y\`"). Delete an issue only when the user explicitly asked to delete it (closing is an update).`;
489
508
 
490
509
  // The explore agent (docs/reference/specs/agent-explore.md): a long, read-only
@@ -512,7 +531,7 @@ READ-ONLY: NEVER open a pull request, and never commit or push — no branch, no
512
531
 
513
532
  You cannot attach or post files: your whole answer is text. Never say a file is attached or below — name its path in the workspace and describe it (what it shows, its size) instead; a person who needs the file itself asks \`agent:coding\`, which can attach.
514
533
 
515
- Maintain the user-facing status card with the update_status tool: post your plan as a checklist (○ pending) once you have it, and update items as they start (✱) and finish (✓ — only after they actually happened). Items are short outcomes ("Clone and install", "Time the full suite"), never commands.
534
+ ${statusCardRule('"Clone and install", "Time the full suite"')}
516
535
 
517
536
  ${NOTEPAD}
518
537
 
@@ -100,6 +100,11 @@ const CHAT_SURFACES: Readonly<Record<string, ActorSurface>> = {
100
100
  mcp: "mcp",
101
101
  cli: "cli",
102
102
  schedule: "schedule",
103
+ // The web chat (record 0043): a browser session speaks as text, so its
104
+ // `access:<sub>` is a chat credential too — the browser actor, its baseline
105
+ // and its entry. A service token never reaches the chat (the dashboard gate
106
+ // serves it `/api/*` only), so `access:svc:` is no chat surface.
107
+ access: "access-browser",
103
108
  };
104
109
 
105
110
  /** A chat message's `userId` is already namespaced by its adapter (`slack:U…`,
@@ -62,10 +62,20 @@ export const CHAT_OPEN_ACTIONS: readonly string[] = [
62
62
  "mcp:write",
63
63
  ];
64
64
 
65
- /** What an Access browser session holds implicitly: every registered
66
- * group's read — never a write, never an exec. */
67
- export function browserReadActions(commandGroups: readonly string[]): Set<string> {
68
- return new Set(commandGroups.map((g) => `${g}:read`));
65
+ /** The two writes a browser session holds beside its reads (record 0043): the
66
+ * same personal writes every Slack user holds (`CHAT_OPEN_ACTIONS`), because
67
+ * the web chat makes a browser session a chat user — `memory remember` and
68
+ * `mcp add` for its own tier. The tier rows still decide the target: a shared
69
+ * tier needs the grant it always needed. */
70
+ export const BROWSER_WRITE_ACTIONS: readonly string[] = ["memory:write", "mcp:write"];
71
+
72
+ /** What an Access browser session holds implicitly: every registered group's
73
+ * read plus the two personal chat writes — never another write, never an exec.
74
+ * No groups known (a process that registered no commands) → nothing at all,
75
+ * fail-closed: the writes ride with the catalogue they belong to. */
76
+ export function browserActions(commandGroups: readonly string[]): Set<string> {
77
+ if (commandGroups.length === 0) return new Set();
78
+ return new Set([...commandGroups.map((g) => `${g}:read`), ...BROWSER_WRITE_ACTIONS]);
69
79
  }
70
80
 
71
81
  // ---- the native `grants` block ----------------------------------------------
@@ -243,8 +253,9 @@ export interface GrantsTable {
243
253
  surfaces: Map<string, Grants>;
244
254
  /** What every `slack:` user holds, listed or not: the open chat commands and `agent:run:<name>` for every unrestricted agent. */
245
255
  everyone: Grants;
246
- /** What every Access browser session (`access:<sub>`, never `access:svc:`) holds: each registered group's read. */
247
- browserReads: Grants;
256
+ /** What every Access browser session (`access:<sub>`, never `access:svc:`) holds:
257
+ * each registered group's read and the two personal chat writes. */
258
+ browser: Grants;
248
259
  restrict: Restriction;
249
260
  }
250
261
 
@@ -253,9 +264,9 @@ export interface GrantsTable {
253
264
  * `<group>:read`. Every other namespace (`schedule:`, `access:svc:`, `http:`,
254
265
  * `mcp:`) is a credential or a job that holds exactly what names it — an
255
266
  * unlisted one is `NO_GRANTS` (fail-closed). */
256
- export function namespaceBaseline(actorId: string, table: Pick<GrantsTable, "everyone" | "browserReads">): Grants {
267
+ export function namespaceBaseline(actorId: string, table: Pick<GrantsTable, "everyone" | "browser">): Grants {
257
268
  if (actorId.startsWith("slack:")) return table.everyone;
258
- if (actorId.startsWith("access:") && !actorId.startsWith("access:svc:")) return table.browserReads;
269
+ if (actorId.startsWith("access:") && !actorId.startsWith("access:svc:")) return table.browser;
259
270
  return NO_GRANTS;
260
271
  }
261
272
 
@@ -271,7 +282,7 @@ export function grantsTable(source: GrantsSource): GrantsTable {
271
282
  const openAgents = (source.agentNames ?? []).filter((a) => !restrict.agents.has(a)).map(agentRunAction);
272
283
  const baselines = {
273
284
  everyone: { ...NO_GRANTS, actions: new Set([...CHAT_OPEN_ACTIONS, ...openAgents]) },
274
- browserReads: { ...NO_GRANTS, actions: browserReadActions(source.commandGroups ?? []) },
285
+ browser: { ...NO_GRANTS, actions: browserActions(source.commandGroups ?? []) },
275
286
  };
276
287
  const grants = new Map<string, Grants>();
277
288
  const surfaces = new Map<string, Grants>();
@@ -80,6 +80,22 @@ export function runFinishedEventType(runId: string): string {
80
80
  return `${RUN_FINISHED_EVENT_PREFIX}${runId}`;
81
81
  }
82
82
 
83
+ /** The event the bot's GitHub check-run intake sends a merge-waiting parent:
84
+ * the type carries the head sha (hex — inside the platform's alphabet), so a
85
+ * driver waiting at that head matches its own event and any other head's is
86
+ * buffered harmlessly. */
87
+ export const CHECKS_SETTLED_EVENT_PREFIX = "checks-settled-";
88
+ export function checksSettledEventType(headSha: string): string {
89
+ return `${CHECKS_SETTLED_EVENT_PREFIX}${headSha}`;
90
+ }
91
+
92
+ /** What the checks-settled event carries — the head and a clock; the parent
93
+ * re-asks the merge door before it acts, so nothing more rides here. */
94
+ export interface ChecksSettledPayload {
95
+ headSha: string;
96
+ settledAt: number;
97
+ }
98
+
83
99
  /** What the event carries — ids, a status and a clock; the parent confirms
84
100
  * through `read-record` before it acts, so nothing more rides here. */
85
101
  export interface RunFinishedPayload {
@@ -310,6 +326,27 @@ export type RunFinishedSend =
310
326
  | { kind: "no-binding"; instance: string }
311
327
  | { kind: "failed"; instance: string; type: string; reason: string };
312
328
 
329
+ /** The one send per settled head (http-ingress.md item 12): best effort like
330
+ * `sendRunFinished` — a refusal is answered, never thrown, and the parent's
331
+ * bounded merge wait times out on its own. */
332
+ export async function sendChecksSettled(
333
+ workflow: WorkflowSender | undefined,
334
+ instance: string,
335
+ headSha: string,
336
+ settledAt: number,
337
+ ): Promise<RunFinishedSend> {
338
+ if (!workflow) return { kind: "no-binding", instance };
339
+ const type = checksSettledEventType(headSha);
340
+ const payload: ChecksSettledPayload = { headSha, settledAt };
341
+ try {
342
+ const handle = await workflow.get(instance);
343
+ await handle.sendEvent({ type, payload });
344
+ return { kind: "sent", instance, type };
345
+ } catch (err) {
346
+ return { kind: "failed", instance, type, reason: err instanceof Error ? err.message : String(err) };
347
+ }
348
+ }
349
+
313
350
  /** The one send per committed terminal record (run-history item 47). */
314
351
  export async function sendRunFinished(
315
352
  workflow: WorkflowSender | undefined,
@@ -57,7 +57,7 @@ import {
57
57
  type UnitPipelineInput,
58
58
  type UnitPipelineState,
59
59
  } from "../ship/coordinator.js";
60
- import { isCoordinatorUnit, runFinishedEventType, type CoordinatorUnit } from "./contract.js";
60
+ import { checksSettledEventType, isCoordinatorUnit, runFinishedEventType, type CoordinatorUnit } from "./contract.js";
61
61
 
62
62
  const MIN = 60_000;
63
63
 
@@ -429,6 +429,22 @@ async function perform(
429
429
  case "sleep":
430
430
  await step.sleep(action.step, action.ms);
431
431
  return { type: "sleep", step: action.step };
432
+ case "wait-checks": {
433
+ // The intake's checks-settled event at the approved head (http-ingress.md
434
+ // item 12), with the machine's bounded timeout as the fallback: either
435
+ // way the machine re-asks the merge door, which is the guard.
436
+ let outcome: "event" | "timeout";
437
+ try {
438
+ await step.waitForEvent(action.step, {
439
+ type: checksSettledEventType(action.headSha),
440
+ timeout: action.timeoutMs,
441
+ });
442
+ outcome = "event";
443
+ } catch {
444
+ outcome = "timeout";
445
+ }
446
+ return { type: "wait-checks", step: action.step, outcome };
447
+ }
432
448
  case "merge":
433
449
  return mergeReturn(
434
450
  action.step,
@@ -131,6 +131,78 @@ export function parseInstanceStatusPath(pathname: string): string | undefined {
131
131
  return INSTANCE_ID_PATTERN.test(id) ? id : undefined;
132
132
  }
133
133
 
134
+ /** `POST /admin/coordinator/instances/<id>/events` — the shim's event relay
135
+ * (docs/reference/specs/http-ingress.md item 12): the bot holds no Workflow
136
+ * binding, so the check-run intake sends `checks-settled-<head>` through this
137
+ * route and the shim's own `SHIP_COORDINATOR.get(id).sendEvent` delivers it. */
138
+ export const COORDINATOR_INSTANCE_EVENTS_SUFFIX = "/events";
139
+
140
+ /** The instance id an event path names, or undefined for any other path. */
141
+ export function parseInstanceEventPath(pathname: string): string | undefined {
142
+ if (!pathname.startsWith(COORDINATOR_INSTANCE_STATUS_PREFIX)) return undefined;
143
+ if (!pathname.endsWith(COORDINATOR_INSTANCE_EVENTS_SUFFIX)) return undefined;
144
+ const id = pathname.slice(
145
+ COORDINATOR_INSTANCE_STATUS_PREFIX.length,
146
+ pathname.length - COORDINATOR_INSTANCE_EVENTS_SUFFIX.length,
147
+ );
148
+ return INSTANCE_ID_PATTERN.test(id) ? id : undefined;
149
+ }
150
+
151
+ export type ParsedSendEvent = { ok: true; type: string; payload: unknown } | { ok: false; reason: string };
152
+
153
+ /** The Workflow event alphabet — what the engine's `sendEvent` accepts as a type. */
154
+ const EVENT_TYPE_PATTERN = /^[A-Za-z0-9_-]{1,100}$/;
155
+
156
+ /** The body: `{ type, payload? }` — the typed event the instance's `waitForEvent` waits on. */
157
+ export function parseSendEventRequest(text: string): ParsedSendEvent {
158
+ let parsed: unknown;
159
+ try {
160
+ parsed = JSON.parse(text);
161
+ } catch {
162
+ return { ok: false, reason: "body is not valid JSON" };
163
+ }
164
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
165
+ return { ok: false, reason: "body must be a JSON object" };
166
+ const b = parsed as Record<string, unknown>;
167
+ if (typeof b.type !== "string" || !EVENT_TYPE_PATTERN.test(b.type))
168
+ return { ok: false, reason: "`type` must be a Workflow event type: letters, digits, `_` and `-`, at most 100" };
169
+ return { ok: true, type: b.type, payload: b.payload };
170
+ }
171
+
172
+ /** How the send ended on the shim: delivered, no such instance (the engine's
173
+ * own `instance.not_found`), or the engine refusing by reason (an instance
174
+ * that already ended is a refusal, not an absence). */
175
+ export type SendEventOutcome =
176
+ { kind: "sent"; id: string } | { kind: "absent"; id: string } | { kind: "failed"; id: string; reason: string };
177
+
178
+ export function sendEventResponse(outcome: SendEventOutcome): { status: number; body: Record<string, unknown> } {
179
+ switch (outcome.kind) {
180
+ case "sent":
181
+ return { status: 200, body: { ok: true, id: outcome.id, sent: true } };
182
+ case "absent":
183
+ return { status: 404, body: { ok: false, error: "no_instance", id: outcome.id } };
184
+ case "failed":
185
+ return { status: 502, body: { ok: false, error: "send_failed", id: outcome.id, message: outcome.reason } };
186
+ }
187
+ }
188
+
189
+ /** The send answer as the bot reads it back: sent, absent, or unanswered by reason. */
190
+ export type SendEventAnswer = { kind: "sent" } | { kind: "absent" } | { kind: "unanswered"; reason: string };
191
+
192
+ export function readSendEventAnswer(status: number, text: string): SendEventAnswer {
193
+ let parsed: unknown;
194
+ try {
195
+ parsed = JSON.parse(text);
196
+ } catch {
197
+ parsed = undefined;
198
+ }
199
+ const body = typeof parsed === "object" && parsed !== null ? (parsed as Record<string, unknown>) : undefined;
200
+ if (status === 200 && body?.ok === true && body.sent === true) return { kind: "sent" };
201
+ if (status === 404 && body?.error === "no_instance") return { kind: "absent" };
202
+ const detail = typeof body?.error === "string" ? body.error : text.slice(0, 200);
203
+ return { kind: "unanswered", reason: `HTTP ${status} — ${detail}` };
204
+ }
205
+
134
206
  /** How the status read ended on the shim: the platform's status word, no such
135
207
  * instance, or the engine failing by reason. */
136
208
  export type InstanceStatusOutcome =
@@ -762,10 +762,15 @@ export function anthropicTokensCostUsd(modelId: string, t: AnthropicTokens): num
762
762
  // ---- range ----------------------------------------------------------------------------
763
763
 
764
764
  const DEFAULT_DAYS = 30;
765
- /** The widest range the page offers — and the window a costs snapshot is read for. */
766
- export const MAX_DAYS = 90;
767
-
768
- /** `?days=N` → a UTC date range ending today. Garbage → default; clamped 1..90. */
765
+ /** The widest range the page offers — and the window a costs snapshot is read for.
766
+ * Cloudflare's analytics on a Workers account answer no range wider than 4w4d
767
+ * (32 days) and hold no data older than that (`cannot request a time range
768
+ * wider than 4w4d`, `cannot request data older than 4w4d` — measured live), so
769
+ * a wider window is a refused read, not more history: 31 UTC days is the most
770
+ * a take can ask for. */
771
+ export const MAX_DAYS = 31;
772
+
773
+ /** `?days=N` → a UTC date range ending today. Garbage → default; clamped 1..31. */
769
774
  export function resolveRange(daysParam: string | null, now: Date = new Date(systemClock())): DateRange {
770
775
  const parsed = daysParam === null ? NaN : Number(daysParam);
771
776
  const days = Number.isInteger(parsed) ? Math.min(MAX_DAYS, Math.max(1, parsed)) : DEFAULT_DAYS;
@@ -1070,7 +1075,7 @@ export class AnthropicCostReportSource implements LlmCostSource {
1070
1075
  if (range.from >= endExclusive) return { rows, closedThrough };
1071
1076
  let page: string | null = null;
1072
1077
  for (let i = 0; ; i++) {
1073
- // Like the non-USD check: refuse rather than mis-sum. A ≤90-day range at
1078
+ // Like the non-USD check: refuse rather than mis-sum. A ≤31-day range at
1074
1079
  // limit=31 is at most 3 pages, so hitting the cap means the API changed.
1075
1080
  if (i >= MAX_COST_PAGES)
1076
1081
  throw new Error(