@bastani/atomic 0.9.41-alpha.3 → 0.9.42-alpha.1

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 (271) hide show
  1. package/CHANGELOG.md +122 -0
  2. package/dist/builtin/intercom/CHANGELOG.md +25 -0
  3. package/dist/builtin/intercom/README.md +7 -3
  4. package/dist/builtin/intercom/broker/broker-status.ts +48 -0
  5. package/dist/builtin/intercom/broker/broker.ts +90 -9
  6. package/dist/builtin/intercom/broker/client.ts +112 -12
  7. package/dist/builtin/intercom/broker/control.ts +225 -0
  8. package/dist/builtin/intercom/broker/multinode-router.ts +32 -39
  9. package/dist/builtin/intercom/broker/paths.ts +4 -0
  10. package/dist/builtin/intercom/broker/pending-send-registry.ts +27 -5
  11. package/dist/builtin/intercom/broker/shared-storage-setting.ts +60 -0
  12. package/dist/builtin/intercom/broker/spawn.ts +13 -4
  13. package/dist/builtin/intercom/broker/sql-lease-store.ts +23 -0
  14. package/dist/builtin/intercom/broker/watchdog.ts +105 -0
  15. package/dist/builtin/intercom/index.bundle.mjs +200 -22
  16. package/dist/builtin/intercom/package.json +1 -1
  17. package/dist/builtin/intercom/types.ts +34 -2
  18. package/dist/builtin/mcp/package.json +1 -1
  19. package/dist/builtin/subagents/CHANGELOG.md +14 -0
  20. package/dist/builtin/subagents/package.json +1 -1
  21. package/dist/builtin/subagents/src/extension/index.bundle.mjs +1 -0
  22. package/dist/builtin/web-access/package.json +1 -1
  23. package/dist/builtin/workflows/CHANGELOG.md +40 -0
  24. package/dist/builtin/workflows/README.md +10 -0
  25. package/dist/builtin/workflows/builtin/{chunk-txbc93t6.js → chunk-3s95f7ry.js} +1 -1
  26. package/dist/builtin/workflows/builtin/{chunk-n2gkzkty.js → chunk-edhf30ef.js} +1 -1
  27. package/dist/builtin/workflows/builtin/{chunk-8x61e0s7.js → chunk-frqxd4d1.js} +18 -9
  28. package/dist/builtin/workflows/builtin/goal.js +2 -2
  29. package/dist/builtin/workflows/builtin/index.js +3 -3
  30. package/dist/builtin/workflows/builtin/ralph.js +2 -2
  31. package/dist/builtin/workflows/package.json +6 -1
  32. package/dist/builtin/workflows/src/authoring.d.ts +3 -1
  33. package/dist/builtin/workflows/src/durable/journal/internal.d.ts +13 -0
  34. package/dist/builtin/workflows/src/durable/journal/resolution.d.ts +2 -0
  35. package/dist/builtin/workflows/src/durable/journal/sql-run-journal.d.ts +1 -0
  36. package/dist/builtin/workflows/src/extension/index.bundle.mjs +88554 -82820
  37. package/dist/builtin/workflows/src/extension/local-worker-entry.mjs +72635 -0
  38. package/dist/builtin/workflows/src/index.js +53824 -5907
  39. package/dist/builtin/workflows/src/local-worker-entry.mjs +72635 -0
  40. package/dist/builtin/workflows/src/shared/authoring-contract-stage.d.ts +7 -0
  41. package/dist/builtin/workflows/src/shared/authoring-contract-ui.d.ts +2 -0
  42. package/dist/builtin/workflows/src/workers/public-options.d.ts +31 -0
  43. package/dist/bun/app-host-modules.d.ts +9 -0
  44. package/dist/bun/app-host-modules.d.ts.map +1 -0
  45. package/dist/bun/app-host-modules.js +10 -0
  46. package/dist/bun/app-host-modules.js.map +1 -0
  47. package/dist/bun/cli.d.ts +3 -1
  48. package/dist/bun/cli.d.ts.map +1 -1
  49. package/dist/bun/cli.js +15 -1
  50. package/dist/bun/cli.js.map +1 -1
  51. package/dist/bun/internal-intercom-broker.d.ts +17 -0
  52. package/dist/bun/internal-intercom-broker.d.ts.map +1 -1
  53. package/dist/bun/internal-intercom-broker.js +29 -0
  54. package/dist/bun/internal-intercom-broker.js.map +1 -1
  55. package/dist/bun/internal-workflow-worker.d.ts +4 -0
  56. package/dist/bun/internal-workflow-worker.d.ts.map +1 -0
  57. package/dist/bun/internal-workflow-worker.js +37 -0
  58. package/dist/bun/internal-workflow-worker.js.map +1 -0
  59. package/dist/bun/split-loader.js +10 -2
  60. package/dist/bun/split-loader.js.map +1 -1
  61. package/dist/cli/args.d.ts.map +1 -1
  62. package/dist/cli/args.js +3 -1
  63. package/dist/cli/args.js.map +1 -1
  64. package/dist/cli/intercom-command.d.ts +15 -0
  65. package/dist/cli/intercom-command.d.ts.map +1 -0
  66. package/dist/cli/intercom-command.js +87 -0
  67. package/dist/cli/intercom-command.js.map +1 -0
  68. package/dist/cli/storage-command.d.ts +39 -0
  69. package/dist/cli/storage-command.d.ts.map +1 -0
  70. package/dist/cli/storage-command.js +367 -0
  71. package/dist/cli/storage-command.js.map +1 -0
  72. package/dist/core/agent-session-events.d.ts.map +1 -1
  73. package/dist/core/agent-session-events.js +5 -1
  74. package/dist/core/agent-session-events.js.map +1 -1
  75. package/dist/core/agent-session-methods.d.ts +1 -0
  76. package/dist/core/agent-session-methods.d.ts.map +1 -1
  77. package/dist/core/agent-session-methods.js.map +1 -1
  78. package/dist/core/agent-session-tasks.d.ts.map +1 -1
  79. package/dist/core/agent-session-tasks.js +1 -0
  80. package/dist/core/agent-session-tasks.js.map +1 -1
  81. package/dist/core/agent-session-types.d.ts +1 -0
  82. package/dist/core/agent-session-types.d.ts.map +1 -1
  83. package/dist/core/agent-session-types.js.map +1 -1
  84. package/dist/core/agent-session.d.ts +1 -0
  85. package/dist/core/agent-session.d.ts.map +1 -1
  86. package/dist/core/agent-session.js +2 -0
  87. package/dist/core/agent-session.js.map +1 -1
  88. package/dist/core/builtin-install-layout.d.ts +6 -0
  89. package/dist/core/builtin-install-layout.d.ts.map +1 -1
  90. package/dist/core/builtin-install-layout.js +9 -0
  91. package/dist/core/builtin-install-layout.js.map +1 -1
  92. package/dist/core/child-session-options.d.ts.map +1 -1
  93. package/dist/core/child-session-options.js +3 -0
  94. package/dist/core/child-session-options.js.map +1 -1
  95. package/dist/core/exec.js +2 -2
  96. package/dist/core/exec.js.map +1 -1
  97. package/dist/core/export-html/template.css +9 -0
  98. package/dist/core/export-html/template.js +165 -45
  99. package/dist/core/extensions/loader-host-modules.d.ts +6 -0
  100. package/dist/core/extensions/loader-host-modules.d.ts.map +1 -1
  101. package/dist/core/extensions/loader-host-modules.js +12 -1
  102. package/dist/core/extensions/loader-host-modules.js.map +1 -1
  103. package/dist/core/extensions/loader-virtual-modules.d.ts +6 -0
  104. package/dist/core/extensions/loader-virtual-modules.d.ts.map +1 -1
  105. package/dist/core/extensions/loader-virtual-modules.js +18 -0
  106. package/dist/core/extensions/loader-virtual-modules.js.map +1 -1
  107. package/dist/core/extensions/message-types.d.ts +4 -0
  108. package/dist/core/extensions/message-types.d.ts.map +1 -1
  109. package/dist/core/extensions/message-types.js.map +1 -1
  110. package/dist/core/fast-model-routing-transport.d.ts.map +1 -1
  111. package/dist/core/fast-model-routing-transport.js +5 -1
  112. package/dist/core/fast-model-routing-transport.js.map +1 -1
  113. package/dist/core/intercom-broker-control.d.ts +67 -0
  114. package/dist/core/intercom-broker-control.d.ts.map +1 -0
  115. package/dist/core/intercom-broker-control.js +17 -0
  116. package/dist/core/intercom-broker-control.js.map +1 -0
  117. package/dist/core/model-runtime-streaming.d.ts.map +1 -1
  118. package/dist/core/model-runtime-streaming.js +38 -1
  119. package/dist/core/model-runtime-streaming.js.map +1 -1
  120. package/dist/core/package-manager-git.d.ts +2 -2
  121. package/dist/core/package-manager-git.d.ts.map +1 -1
  122. package/dist/core/package-manager-git.js.map +1 -1
  123. package/dist/core/package-manager-manifest.d.ts.map +1 -1
  124. package/dist/core/package-manager-manifest.js +9 -1
  125. package/dist/core/package-manager-manifest.js.map +1 -1
  126. package/dist/core/package-manager-npm.d.ts +2 -2
  127. package/dist/core/package-manager-npm.d.ts.map +1 -1
  128. package/dist/core/package-manager-npm.js.map +1 -1
  129. package/dist/core/package-manager-paths.d.ts +7 -7
  130. package/dist/core/package-manager-paths.d.ts.map +1 -1
  131. package/dist/core/package-manager-paths.js.map +1 -1
  132. package/dist/core/package-manager-storage-adapters.d.ts +7 -0
  133. package/dist/core/package-manager-storage-adapters.d.ts.map +1 -0
  134. package/dist/core/package-manager-storage-adapters.js +76 -0
  135. package/dist/core/package-manager-storage-adapters.js.map +1 -0
  136. package/dist/core/package-manager-types.d.ts +4 -0
  137. package/dist/core/package-manager-types.d.ts.map +1 -1
  138. package/dist/core/package-manager-types.js.map +1 -1
  139. package/dist/core/provider-rate-limits.d.ts +23 -0
  140. package/dist/core/provider-rate-limits.d.ts.map +1 -0
  141. package/dist/core/provider-rate-limits.js +113 -0
  142. package/dist/core/provider-rate-limits.js.map +1 -0
  143. package/dist/core/provider-request-admission.d.ts +7 -0
  144. package/dist/core/provider-request-admission.d.ts.map +1 -0
  145. package/dist/core/provider-request-admission.js +91 -0
  146. package/dist/core/provider-request-admission.js.map +1 -0
  147. package/dist/core/resource-admission-scope.d.ts +4 -0
  148. package/dist/core/resource-admission-scope.d.ts.map +1 -0
  149. package/dist/core/resource-admission-scope.js +9 -0
  150. package/dist/core/resource-admission-scope.js.map +1 -0
  151. package/dist/core/sdk-types.d.ts +16 -0
  152. package/dist/core/sdk-types.d.ts.map +1 -1
  153. package/dist/core/sdk-types.js.map +1 -1
  154. package/dist/core/sdk.d.ts +2 -1
  155. package/dist/core/sdk.d.ts.map +1 -1
  156. package/dist/core/sdk.js +18 -2
  157. package/dist/core/sdk.js.map +1 -1
  158. package/dist/core/session-lifecycle-work.d.ts +1 -0
  159. package/dist/core/session-lifecycle-work.d.ts.map +1 -1
  160. package/dist/core/session-lifecycle-work.js +1 -0
  161. package/dist/core/session-lifecycle-work.js.map +1 -1
  162. package/dist/core/settings-manager-basic-accessors.d.ts +3 -0
  163. package/dist/core/settings-manager-basic-accessors.d.ts.map +1 -1
  164. package/dist/core/settings-manager-basic-accessors.js +6 -0
  165. package/dist/core/settings-manager-basic-accessors.js.map +1 -1
  166. package/dist/core/settings-schema.d.ts +22 -0
  167. package/dist/core/settings-schema.d.ts.map +1 -1
  168. package/dist/core/settings-schema.js +18 -1
  169. package/dist/core/settings-schema.js.map +1 -1
  170. package/dist/core/storage/index.d.ts +1 -0
  171. package/dist/core/storage/index.d.ts.map +1 -1
  172. package/dist/core/storage/index.js +1 -0
  173. package/dist/core/storage/index.js.map +1 -1
  174. package/dist/core/storage/node-sqlite.d.ts +2 -1
  175. package/dist/core/storage/node-sqlite.d.ts.map +1 -1
  176. package/dist/core/storage/node-sqlite.js +5 -4
  177. package/dist/core/storage/node-sqlite.js.map +1 -1
  178. package/dist/core/storage/provider.d.ts +6 -0
  179. package/dist/core/storage/provider.d.ts.map +1 -1
  180. package/dist/core/storage/provider.js +27 -7
  181. package/dist/core/storage/provider.js.map +1 -1
  182. package/dist/core/storage/sqlite-facade.d.ts +49 -0
  183. package/dist/core/storage/sqlite-facade.d.ts.map +1 -0
  184. package/dist/core/storage/sqlite-facade.js +180 -0
  185. package/dist/core/storage/sqlite-facade.js.map +1 -0
  186. package/dist/core/storage/storage-status.d.ts +81 -0
  187. package/dist/core/storage/storage-status.d.ts.map +1 -0
  188. package/dist/core/storage/storage-status.js +409 -0
  189. package/dist/core/storage/storage-status.js.map +1 -0
  190. package/dist/core/tasks/supervisor.d.ts +2 -1
  191. package/dist/core/tasks/supervisor.d.ts.map +1 -1
  192. package/dist/core/tasks/supervisor.js +105 -4
  193. package/dist/core/tasks/supervisor.js.map +1 -1
  194. package/dist/core/tools/bash-pty-native.d.ts.map +1 -1
  195. package/dist/core/tools/bash-pty-native.js +1 -1
  196. package/dist/core/tools/bash-pty-native.js.map +1 -1
  197. package/dist/index.d.ts +2 -1
  198. package/dist/index.d.ts.map +1 -1
  199. package/dist/index.js +2 -1
  200. package/dist/index.js.map +1 -1
  201. package/dist/main.d.ts.map +1 -1
  202. package/dist/main.js +28 -2
  203. package/dist/main.js.map +1 -1
  204. package/dist/modes/interactive/components/assistant-message.d.ts +3 -1
  205. package/dist/modes/interactive/components/assistant-message.d.ts.map +1 -1
  206. package/dist/modes/interactive/components/assistant-message.js +7 -3
  207. package/dist/modes/interactive/components/assistant-message.js.map +1 -1
  208. package/dist/modes/interactive/components/chat-message-renderer.d.ts +9 -1
  209. package/dist/modes/interactive/components/chat-message-renderer.d.ts.map +1 -1
  210. package/dist/modes/interactive/components/chat-message-renderer.js +16 -8
  211. package/dist/modes/interactive/components/chat-message-renderer.js.map +1 -1
  212. package/dist/modes/interactive/components/login-dialog.d.ts +5 -0
  213. package/dist/modes/interactive/components/login-dialog.d.ts.map +1 -1
  214. package/dist/modes/interactive/components/login-dialog.js +69 -2
  215. package/dist/modes/interactive/components/login-dialog.js.map +1 -1
  216. package/dist/modes/interactive/components/markdown-transform.d.ts +1 -1
  217. package/dist/modes/interactive/components/markdown-transform.d.ts.map +1 -1
  218. package/dist/modes/interactive/components/markdown-transform.js +2 -2
  219. package/dist/modes/interactive/components/markdown-transform.js.map +1 -1
  220. package/dist/modes/interactive/components/session-selector-search.d.ts.map +1 -1
  221. package/dist/modes/interactive/components/session-selector-search.js +80 -22
  222. package/dist/modes/interactive/components/session-selector-search.js.map +1 -1
  223. package/dist/modes/interactive/components/user-message.d.ts +3 -1
  224. package/dist/modes/interactive/components/user-message.d.ts.map +1 -1
  225. package/dist/modes/interactive/components/user-message.js +6 -2
  226. package/dist/modes/interactive/components/user-message.js.map +1 -1
  227. package/dist/modes/interactive/interactive-mode-deps.d.ts +1 -1
  228. package/dist/modes/interactive/interactive-mode-deps.d.ts.map +1 -1
  229. package/dist/modes/interactive/interactive-mode-deps.js.map +1 -1
  230. package/dist/modes/interactive/interactive-render-chat.js +6 -2
  231. package/dist/modes/interactive/interactive-render-chat.js.map +1 -1
  232. package/dist/modes/interactive-engine/engine-health.d.ts +8 -4
  233. package/dist/modes/interactive-engine/engine-health.d.ts.map +1 -1
  234. package/dist/modes/interactive-engine/engine-health.js +41 -6
  235. package/dist/modes/interactive-engine/engine-health.js.map +1 -1
  236. package/dist/modes/interactive-engine/isolated-runtime.d.ts.map +1 -1
  237. package/dist/modes/interactive-engine/isolated-runtime.js +19 -17
  238. package/dist/modes/interactive-engine/isolated-runtime.js.map +1 -1
  239. package/dist/modes/interactive-engine/remote-queue-pause.js +1 -1
  240. package/dist/modes/interactive-engine/remote-queue-pause.js.map +1 -1
  241. package/dist/modes/rpc/rpc-input-scheduler.d.ts +1 -0
  242. package/dist/modes/rpc/rpc-input-scheduler.d.ts.map +1 -1
  243. package/dist/modes/rpc/rpc-input-scheduler.js +4 -1
  244. package/dist/modes/rpc/rpc-input-scheduler.js.map +1 -1
  245. package/dist/modes/rpc/rpc-input.d.ts.map +1 -1
  246. package/dist/modes/rpc/rpc-input.js +29 -7
  247. package/dist/modes/rpc/rpc-input.js.map +1 -1
  248. package/dist/utils/shell.d.ts.map +1 -1
  249. package/dist/utils/shell.js +14 -0
  250. package/dist/utils/shell.js.map +1 -1
  251. package/docs/custom-provider/oauth.md +8 -1
  252. package/docs/extensions/api-reference.md +2 -0
  253. package/docs/intercom/operations.md +17 -8
  254. package/docs/intercom/reference.md +1 -1
  255. package/docs/keybindings.md +1 -1
  256. package/docs/packages/authoring.md +15 -0
  257. package/docs/packages.md +3 -2
  258. package/docs/providers.md +2 -0
  259. package/docs/reference/cli.md +17 -0
  260. package/docs/sdk.md +37 -0
  261. package/docs/settings.md +33 -2
  262. package/docs/storage.md +191 -219
  263. package/docs/usage.md +1 -1
  264. package/docs/workflows/api-reference.md +50 -0
  265. package/docs/workflows/operations.md +44 -1
  266. package/docs/workflows.md +25 -0
  267. package/examples/extensions/gondolin/package-lock.json +14 -24
  268. package/examples/extensions/gondolin/package.json +1 -1
  269. package/npm-shrinkwrap.json +35 -35
  270. package/package.json +4 -4
  271. package/schemas/settings.schema.json +87 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,128 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.9.42-alpha.1] - 2026-10-23
6
+
7
+ Sessions, workflow runs, and Intercom can now share one libSQL / Turso database across machines, and provider rate limits are respected across every request in a process.
8
+
9
+ - **Shared storage across machines:** install `@bastani/atomic-storage-libsql`, join a machine with `atomic storage join <url> --token-file <path>`, and check the result with `atomic storage status`. Atomic packages can ship storage adapters, and `createSqliteDatabase()` handles the facade rules when you write your own ([#3856](https://github.com/bastani-inc/atomic/issues/3856), [#3857](https://github.com/bastani-inc/atomic/issues/3857))
10
+ - **Intercom you can inspect and restart:** `atomic intercom status` and `atomic intercom restart` show and refresh the broker's storage selection and multi-node routing, and a broker that stops answering is replaced after about a minute instead of hanging every session ([#3857](https://github.com/bastani-inc/atomic/issues/3857), [#3862](https://github.com/bastani-inc/atomic/issues/3862))
11
+ - **Rate limits handled across requests:** `configureProviderRequestBudget()` sets process-wide request budgets, 429 cooldowns are shared between model requests, and workflow model chains wait for the earliest eligible account instead of exhausting it ([#3864](https://github.com/bastani-inc/atomic/issues/3864))
12
+ - **Admission limits and local workflow pools:** SDK hosts can share session and command limits with `resourceAdmission`, and workflows can run child subtrees in local worker processes ([#3869](https://github.com/bastani-inc/atomic/issues/3869))
13
+ - **More responsive sessions and workflows:** Ctrl+C and queue clearing act without waiting for a busy engine, workflow stages reload resources four at a time, and standalone `run()` gives stages Intercom without an interactive host ([#3861](https://github.com/bastani-inc/atomic/issues/3861), [#3863](https://github.com/bastani-inc/atomic/issues/3863), [#3865](https://github.com/bastani-inc/atomic/issues/3865))
14
+ - **Login, export, and search fixes:** device-code `/login` waits for Enter before opening the browser, HTML exports show billed costs and the session's own system prompt, and `/resume` search ranks name matches first ([pi #9282](https://github.com/earendil-works/pi/issues/9282), [pi #9980](https://github.com/earendil-works/pi/issues/9980), [pi #11261](https://github.com/earendil-works/pi/issues/11261), [pi #11220](https://github.com/earendil-works/pi/issues/11220))
15
+
16
+ ### Added
17
+
18
+ - Markdown transformers registered with `pi.registerMarkdownTransformer()` now receive `messageId` and `timestamp` in their context when a message is rendered from the saved session, so an extension can tie its output to a session entry. Both are `undefined` for live messages. See [registerMarkdownTransformer](docs/extensions/api-reference.md#pi-registermarkdowntransformer-transformer)
19
+ - Added `configureProviderRequestBudget(provider, { maxRequests, intervalMs })` for process-wide rolling request budgets shared across SDK model runtimes. Model requests also share provider cooldowns after 429 responses. Separate configured provider IDs remain independent ([#3864](https://github.com/bastani-inc/atomic/issues/3864)).
20
+ - Storage adapter modules can import `@bastani/atomic/storage` and `@bastani/atomic` without an installed or linked copy of Atomic. They get the running Atomic's own modules, as extensions do, so Atomic recognizes the storage errors they throw ([#3856](https://github.com/bastani-inc/atomic/issues/3856))
21
+ - `createSqliteDatabase()` in `@bastani/atomic/storage` turns a SQLite-compatible driver's calls into a `SqliteDatabase` that applies the facade rules for you: call-order queueing, transaction handle lifetime, NUL rejection, integer and blob normalization, and typed errors with credentials redacted. `redactSecrets()` builds the credential redactor. The libSQL/Turso example in [Storage](docs/storage.md#bring-your-own-driver) now uses it and is a complete adapter ([#3856](https://github.com/bastani-inc/atomic/issues/3856))
22
+ - Atomic packages can provide storage adapters with a `storageAdapters` manifest entry, and `storage.adapter` can name one by package (`<package>` or `<package>/<name>`), so `atomic install npm:<adapter-package>` installs the adapter together with its driver. See [Adapters from Atomic packages](docs/storage.md#adapters-from-atomic-packages) ([#3856](https://github.com/bastani-inc/atomic/issues/3856))
23
+ - `@bastani/atomic-storage-libsql` is a first-party libSQL / Turso storage adapter package. Install it with `atomic install npm:@bastani/atomic-storage-libsql` and select it with `"storage": { "adapter": "@bastani/atomic-storage-libsql" }` to share sessions, workflow runs, and Intercom routing across machines. See [Turso and libSQL](docs/storage.md#turso-and-libsql) ([#3857](https://github.com/bastani-inc/atomic/issues/3857))
24
+ - `atomic storage status` shows, for sessions, workflow runs, and Intercom, the selected storage and where the selection comes from (environment variable, project or global settings, or default), the outranked selections, whether the adapter loads, and whether the database answers a `SELECT 1`, without creating anything. It also shows whether the running Intercom broker uses the current selection, its multi-node routing state, and the brokers live in the shared database. `--json` prints the same report. See [Checking the selected storage](docs/storage.md#checking-the-selected-storage) ([#3856](https://github.com/bastani-inc/atomic/issues/3856), [#3857](https://github.com/bastani-inc/atomic/issues/3857))
25
+ - `atomic storage join <url> --token-file <path>` joins this machine to a shared libSQL / Turso database in one step: it checks the token and the connection, stores the trimmed token in the agent directory with mode 600, writes only the global `storage` setting, creates no tables, and lists the environment variables and settings that override the new selection. It refuses a URL that carries the token itself, so a credential never reaches settings or output. When `@bastani/atomic-storage-libsql` is not installed, it prints the install command. See [Joining a machine to a shared database](docs/storage.md#joining-a-machine-to-a-shared-database) ([#3857](https://github.com/bastani-inc/atomic/issues/3857))
26
+ - `atomic intercom restart` replaces the running Intercom broker so it reads a changed storage selection; connected sessions reconnect to the new broker. `atomic intercom status` shows the broker, whether it uses the current selection, and its multi-node routing state ([#3857](https://github.com/bastani-inc/atomic/issues/3857))
27
+ - Interactive and print sessions warn at startup when the shared `storage` selection names an adapter that the running Atomic cannot load, naming the adapter and the cause, instead of Intercom multi-node routing staying off silently ([#3857](https://github.com/bastani-inc/atomic/issues/3857))
28
+ - SDK hosts can share admission limits through `resourceAdmission` and `withResourceAdmission()`. Session and managed-command permits remain held until cleanup, including background commands. Delegated sessions inherit the same service and request immediate admission or rejection rather than waiting on a permit held by their parent. See [Shared resource admission](docs/sdk.md#shared-resource-admission) ([#3869](https://github.com/bastani-inc/atomic/issues/3869)).
29
+
30
+ ### Changed
31
+
32
+ - The standalone `atomic` binaries are now built with Bun 1.4.4, which includes upstream security fixes in the embedded runtime
33
+ - Device-code `/login` flows (Kimi Code, GitHub Copilot, Meta, and extension providers) no longer open the browser on their own. Press Enter to open the verification URL and copy the code, or log in manually. Providers can turn off either action with `openBrowserOnConfirm: false` or `copyCodeOnConfirm: false`. See [Provider OAuth](docs/custom-provider/oauth.md#oauth-support) ([pi #9282](https://github.com/earendil-works/pi/issues/9282))
34
+ - A `{ "file": ... }` storage secret now drops one trailing line ending (`\n` or `\r\n`), so a token saved with `turso db tokens create <db> > token` works without trimming it first. Other whitespace is still kept ([#3856](https://github.com/bastani-inc/atomic/issues/3856))
35
+
36
+ ### Fixed
37
+
38
+ - HTML session exports now show the recorded total cost of each response, such as OpenRouter's billed cost, instead of the sum of its catalog-estimated components. Sessions recorded before totals were stored still use the component sum ([pi #9980](https://github.com/earendil-works/pi/issues/9980))
39
+ - On macOS, cancelling a running bash command no longer lets the next command in a `a; b` sequence start while the shell is being killed: the process group is stopped before it is killed ([pi #11252](https://github.com/earendil-works/pi/issues/11252))
40
+ - HTML session exports, including `atomic --export`, now show the system prompt and available tools from the session's first system message, and later system messages as named prompt-section and tool updates. Sessions without system entries still show the prompt and tools from the agent state ([pi #11261](https://github.com/earendil-works/pi/issues/11261))
41
+ - HTML session exports of very long sessions no longer fail to render the session tree with a call-stack overflow ([pi #11225](https://github.com/earendil-works/pi/issues/11225))
42
+ - `pi.exec()` now reports why a command could not be started (for example `ENOENT` for a missing executable) in `stderr` instead of returning an empty `stderr` with exit code 1 ([pi #11085](https://github.com/earendil-works/pi/issues/11085))
43
+ - `/resume` search matches the session name, summary, id, messages and working directory separately. A name match ranks first, then text found in the id, summary, messages or directory, then characters scattered through the name, summary or directory. Matches deep in a long transcript are no longer ranked last, and scattered characters in session ids or message text no longer produce noise matches ([pi #11220](https://github.com/earendil-works/pi/issues/11220))
44
+ - Updated the bundled `ip-address` dependency to 10.7.4, which fixes a quadratic-time zone-identifier match in IPv6 address parsing that a single long string could use to stall the process ([GHSA-rxp7-6hxx-x8q5](https://github.com/advisories/GHSA-rxp7-6hxx-x8q5))
45
+ - Provider request-budget waits no longer consume OpenAI, Anthropic, Mistral, or explicit Codex SSE HTTP timeouts, and successive Anthropic federation-authenticated requests keep reusing their cached token. Cancelling a queued model request reports an aborted result without dispatching it ([#3864](https://github.com/bastani-inc/atomic/issues/3864)).
46
+ - Cancelling or clearing queued messages no longer waits for a busy interactive engine to finish draining its output before acting. Cancelled prompts and shell commands waiting to start do not run later when output resumes. If queue clearing or cancellation of chat, shell commands, compaction, or retries stalls or fails, Ctrl+C can still replace the engine even while it continues sending heartbeats, including controls sent while a replacement initializes; an earlier failed queue pause no longer prevents an abort from being sent ([#3865](https://github.com/bastani-inc/atomic/issues/3865))
47
+ - The standalone binary's Intercom broker now runs multi-node routing with a shared `storage` selection and loads storage adapters, as the npm installation does ([#3856](https://github.com/bastani-inc/atomic/issues/3856))
48
+ - `atomic storage join` keeps previous credentials intact when saving settings fails, rejects relative `file:` URLs that would select different databases from different directories, and rejects credentials embedded in `file:` URLs before printing or saving them. New joins use separate private token files, preserving existing sessions' credentials. A successful join no longer reports failure when malformed project settings prevent the final override report: it warns that storage was saved and asks you to fix the settings before running status ([#3857](https://github.com/bastani-inc/atomic/issues/3857))
49
+ - `atomic storage status` reports settings load failures, opens local SQLite files (including adapter `file:` URLs) read-only without creating missing databases or changing journal mode, and bounds connection cleanup even when a query stalls ([#3856](https://github.com/bastani-inc/atomic/issues/3856))
50
+ - `createSqliteDatabase()` preserves callback and rollback failures when transaction cleanup also fails ([#3856](https://github.com/bastani-inc/atomic/issues/3856))
51
+ - Package-name storage adapters now honor packages listed in legacy global settings, while explicit agent directories remain isolated ([#3857](https://github.com/bastani-inc/atomic/issues/3857))
52
+ - On macOS, pausing a managed command no longer causes it to be reported as completed and terminated ([#3869](https://github.com/bastani-inc/atomic/issues/3869)).
53
+
54
+ ## [0.9.41] - 2026-10-22
55
+
56
+ Workflows stay contained when they fail, classifiers read images, and usage totals say where their cost came from.
57
+
58
+ - **Workflow failures stay inside their run:** an unhandled `ctx` rejection, a stray `ctx.exit()`, or a branch that outlives a kill or quit now fails or ends only its own run instead of the whole Atomic process ([#3846](https://github.com/bastani-inc/atomic/issues/3846), [#3849](https://github.com/bastani-inc/atomic/pull/3849))
59
+ - **Image input for more classifiers:** `models.classify()` accepts `images` for Jev and Clef on Cloudflare Workers AI and OpenRouter, with corrected Workers AI pricing ([#3847](https://github.com/bastani-inc/atomic/pull/3847))
60
+ - **Provider-billed cost flag:** `usage.cost.totalFromProvider` tells you whether a usage total is the provider's billed cost or a catalog estimate ([#3852](https://github.com/bastani-inc/atomic/pull/3852))
61
+ - **Steady sessions and installs:** SDK and RPC `steer()`/`followUp()` start a turn on an idle session, extension OAuth providers can report rejected logins, and the installers skip downloading a release that is already installed ([#3801](https://github.com/bastani-inc/atomic/issues/3801), [#3851](https://github.com/bastani-inc/atomic/pull/3851))
62
+ - **Terminal and rendering fixes:** Rex terminal support, terminal modes restored on unexpected exit, Markdown tables after unclosed `$$` blocks, CJK emphasis, and `@` autocomplete with regex characters ([#3847](https://github.com/bastani-inc/atomic/pull/3847))
63
+
64
+ **Breaking change:** SDK `steer()` and `followUp()` and the RPC `steer` and `follow_up` commands now start a turn when the agent is idle and the session already has messages.
65
+
66
+ ### Breaking Changes
67
+
68
+ - SDK `steer()` and `followUp()`, and the RPC `steer` and `follow_up` commands, now start a turn when the agent is idle and the session already has messages. On a session with no messages yet, or after a run was aborted, the message still waits for the next prompt. If you queue a message on an idle session that has messages and then call `prompt()` (or send an RPC `prompt`) right away, that prompt now fails with "Agent is already processing" unless you pass `streamingBehavior` (`"steer"` or `"followUp"`) to queue it behind the running turn.
69
+
70
+ ### Added
71
+
72
+ - Added `usage.cost.totalFromProvider` to usage totals. Summed usage, including nested tool-call and compaction totals, reports `true` only when every part was a provider-reported billed cost; catalog-estimated and legacy sessions without the field report `false`.
73
+ - Codemode `models.classify()` now also accepts `images` for TypeSafe's Jev and Cloudflare's Clef models on `cloudflare-workers-ai` and OpenRouter, next to Jev on `typesafe`, and lists Clef Omni and Jev 1.14 on Workers AI. See [Use classifier models](docs/models.md#use-classifier-models). Provider-level `compat` in `models.json` now also applies to classifier models, with the System One fields `imageInput` and `sendImageOptInHeader` for proxies that serve another service's classifiers. Extension classifier models accept `compat` too. See [Models Configuration](docs/models-configuration.md#typesafe-system-one).
74
+ - Added support for the Rex terminal (`TERM_PROGRAM=rex`): OSC 8 hyperlinks, truecolor, and inline images. Links previously fell back to the `text (url)` format ([#11131](https://github.com/earendil-works/pi/issues/11131), [#11150](https://github.com/earendil-works/pi/pull/11150) by [@petrroll](https://github.com/petrroll), [#11183](https://github.com/earendil-works/pi/pull/11183) by [@nihaals](https://github.com/nihaals))
75
+ - `routeExecutionModel()` accepts an optional `inference` option (exported type `RoutingInferenceBounds`) for callers that must not wait indefinitely on routing inference: `deadline` is an `AbortSignal` whose abort ends routing as a complete inference failure, so the caller can degrade to `AutoRoutingInferenceError.currentModelRoute`, and `limit` runs each routing inference under the caller's concurrency limit. Without it, routing keeps having no built-in deadline.
76
+ - RPC `get_available_models` and `refresh_models` responses now list extension providers that accept an API key in `customAuthProviders`, and report extension providers' environment or configured-key authentication in `providerAuthStatuses`.
77
+ - Extension OAuth providers can throw `OAuthRefreshRejectedError` (exported from `@bastani/atomic`, with `isOAuthRefreshRejectedError`) from `refreshToken` when the provider rejected the stored login. The user then sees the same `run /login <provider>` guidance as for a built-in provider. See [Provider OAuth](docs/custom-provider/oauth.md#rejected-logins).
78
+ - Exported `noOpUIContext`, the default no-op extension UI context, from the SDK entry point ([#3801](https://github.com/bastani-inc/atomic/issues/3801))
79
+ - `session.workflows.waitForSettled(runId)` resolves once a workflow run's ending is recorded in the workflow journal and its lease is released, so an SDK host can wait for the journal to be final after a run reports `completed`, `failed` or `killed`. It resolves at once for a run the session is not executing, and rejects with `WorkflowRunStorageError` when the journal could not record the ending. Extensions that call `registerWorkflowRunControl` must implement `waitForSettled` ([#3811](https://github.com/bastani-inc/atomic/issues/3811))
80
+ - `createStructuredOutputTool()` and `generateStructuredOutput()` accept a `timeoutMs` option: the wall-clock limit in milliseconds for one whole structured decision, covering every repair attempt, transport retry, and model fallback together. Without it, the `structured_output` tool uses the session's `httpIdleTimeoutMs` setting and other calls use that setting's 10-minute default. The calling model cannot set it. Extensions can read the setting with `ctx.getHttpIdleTimeoutMs()` ([#3806](https://github.com/bastani-inc/atomic/issues/3806))
81
+ - The task-host `CancelCause` contract adds `pause` and `quit`, typed resumable stops for an agent that is paused and awaiting resume rather than ended. A workflow stage pausing now stops its agents with `pause` instead of `parent-handoff`; `parent-handoff` stays valid for fresh-start handoffs and for entries recorded by earlier versions.
82
+
83
+ ### Changed
84
+
85
+ - `install.sh` and `install.ps1` no longer re-download Atomic when the requested release (or the latest release, when none is requested) is already installed and healthy. They print `atomic <tag> is already installed; skipping download`, keep the `Installed to` line and PATH guidance, and download nothing; on Windows a rerun also restores a missing User PATH entry. A different version, or a missing or broken launcher, `current` pointer (including one that points at a versions directory outside the install root), shim, or payload (including a missing `app.js` or other startup file), installs and repairs as before. Pass `--force` (`-Force` on Windows) or set `ATOMIC_FORCE_INSTALL=1` to reinstall anyway.
86
+ - Reopening `/resume` is faster: the session list is kept between opens and reloads only the sessions that were created, renamed, deleted, or changed since the last open, including changes made by other Atomic processes sharing the store.
87
+ - A subagent stopped with `pause` or `quit`, or by its workflow stage pausing, now reaches the parent as `Subagent <agent> quit · paused, awaiting resume` (or `paused · awaiting resume`) with `Stop reason: quit` or `pause` and the exact `subagent({ action: "resume", id: "<child-id>" })` call, as a quit workflow run is reported, instead of a `parent-handoff` stop notice saying the execution has ended and must not be restarted. Its `/tasks` row shows **Paused · awaiting resume**. Children cancelled for good keep the ended notice.
88
+ - The What's New banner shown after an update now leads with the release's highlights summary and points to `/changelog` for the full list of changes. Releases without a summary still show their full notes ([#3804](https://github.com/bastani-inc/atomic/issues/3804))
89
+ - Provider attribution headers now come from pi-ai's client identity, still sent only while `enableInstallTelemetry` is on. Chat requests to providers send Atomic's `User-Agent` (`atomic/<version> (<platform>; <runtime>; <arch>)`), including Cloudflare requests, which sent `atomic` before, and OpenCode requests send `x-opencode-client` whenever attribution is enabled, not only with a session. Extensions' `before_provider_headers` handlers no longer see the OpenRouter, NVIDIA NIM, and OpenCode attribution headers or the default `User-Agent`.
90
+ - The release lookup that runs before Atomic downloads `fd` or `ripgrep` now sends Atomic's `User-Agent` (`atomic/<version> (<platform>; <runtime>; <arch>)`) instead of `atomic`.
91
+
92
+ ### Fixed
93
+
94
+ - Fixed Cloudflare Workers AI classifier pricing: Clef Flash costs $0.038 and Jev $0.042 per million input tokens instead of Jev being listed as free.
95
+ - Fixed the `grep` tool following ripgrep's `RIPGREP_CONFIG_PATH` whenever it searches with `rg`, such as with `literal: true`, where config options like `--ignore-case` silently changed what the tool returned. The tool now runs `rg --no-config` ([#11203](https://github.com/earendil-works/pi/issues/11203))
96
+ - Fixed `@` file autocomplete returning no results for queries containing regex metacharacters such as `[` or `(` ([#10972](https://github.com/earendil-works/pi/issues/10972))
97
+ - Fixed an unclosed `$$` display-math block swallowing or corrupting every Markdown table after it ([#11159](https://github.com/earendil-works/pi/issues/11159))
98
+ - Fixed terminal modes (mouse tracking, focus reporting, Kitty keyboard protocol, bracketed paste, alternate screen) staying enabled when the process exits without calling `stop()`. A process `exit` handler now restores them synchronously ([#11170](https://github.com/earendil-works/pi/issues/11170))
99
+ - Fixed Markdown bold and italic not rendering next to CJK punctuation ([#10730](https://github.com/earendil-works/pi/pull/10730) by [@theBucky](https://github.com/theBucky))
100
+ - A rejection that workflow code leaves unhandled no longer ends the whole Atomic process. This covers a rejected `ctx.tool`, `ctx.task`, `ctx.chain`, `ctx.parallel`, `ctx.workflow`, `ctx.ui` prompt (`input`, `confirm`, `select`, `editor` or `custom`) or stage method call (`prompt`, `complete`, `steer`, `sendUserMessage`, `setModel`, `compact` and the other promise-returning stage methods), including one that is started and never awaited (`void ctx.task(...)` used to leave the run `completed` with no sign of the failure), a `ctx.tool` call made after its run has stopped (`ctx.tool admission is closed for this run`), a string, number or `undefined` thrown from a `ctx.tool` callback, and an error the workflow file throws or receives from `fs` itself, for example in a rolling pool that keeps `branch.finally(...)` as a separate promise. It also covers `ctx.exit()` called inside such a branch, which ends the run as exited with the status and outputs it asked for (on Bun the process used to end right after the run reported `completed`), and a child workflow's own branch that uses `ctx` after its parent exited. Stopping a run no longer ends the process either: a branch that keeps running after `/workflow quit`, a kill, or an abort signal from the code that started the run, and then calls `ctx.stage`, `ctx.task`, `ctx.workflow` or `ctx.exit()`, or that was waiting on a `ctx.ui` prompt when the run was killed, could end it with `workflow killed` (a kill), the quit error (`/workflow quit`), an `AbortError` or `atomic-workflows: HIL aborted` right after the run reported `killed` or `paused`, and the run keeps that status. `ctx.exit()` called in such a branch while the run is paused ends the run as exited once the run resumes, and a kill of the paused run still ends it as `killed` (on Node it used to end as `failed` with `Workflow exit is waiting for explicit resume`, and on Bun the process ended). The rejection fails its own run when it surfaces before that run decides its final status, so other runs, stages, and the interactive engine keep going; a `ctx.task`, `ctx.chain`, `ctx.parallel`, `ctx.workflow`, `ctx.ui` or stage method rejection that the workflow awaits or catches reaches that code unchanged and does not fail the run. Once the run has ended, an error that a `ctx` call rejected with, what `ctx.exit()` threw and the reason the run was stopped with are ignored (the reason of a kill is the string `workflow killed`, which is ignored by value for the rest of the process, so a branch that wakes up long after the run ended is covered too), and so is an error thrown by the workflow file for the next 30 seconds, so the remaining branches of a failing pool cannot end the process; a later one ends it as before. On Node the owning run is identified exactly; on Bun a `ctx` call's rejection, what `ctx.exit()` threw and the reason the run was stopped with are matched by identity (a `ctx` call's string, number or `undefined` only within the event-loop turn in which the call rejected, and the string a kill uses by value), and an error thrown by the workflow file itself is matched through its stack to the run of the file that calls `workflow()`, but only when no other run of that file is running or has run at the same time (a child workflow defined in the same file does not count), so a primitive thrown outside a `ctx` call on Bun is still fatal, and an error that either of two overlapping runs of one file could have thrown ends the process as before instead of failing a run that may be healthy. This works the same in the bundled install as in a source checkout. A rejection that belongs to no workflow run, including one raised by Atomic's own code, ends the process as before, with its original error and stack, and `--unhandled-rejections=warn|none|warn-with-error-code` are honored ([#3846](https://github.com/bastani-inc/atomic/issues/3846))
101
+ - A workflow that omits `name` is now named after the file that defines it when Atomic runs from the bundled install, as it already was in a source checkout, instead of after the bundled extension file (`index.bundle`) ([#3846](https://github.com/bastani-inc/atomic/issues/3846))
102
+ - `/workflow status <runId>` no longer reports `durable checkpoint topology is malformed or incomplete` for a running or crashed run whose child `ctx.workflow` calls have started but not yet recorded a stage or tool of their own, such as a pool of concurrent children when the process was killed. The children now show as running, and a crashed run can be inspected and resumed. A run that fails while such children are still starting now cancels them first, so its status can be inspected as well ([#3846](https://github.com/bastani-inc/atomic/issues/3846))
103
+ - Fixed `structured_output` and `generateStructuredOutput` failing the whole request with `Structured output model is unavailable or not a chat or classifier model` when one explicit `model` or `fallbackModels` ID could not be resolved, for example a display name instead of a `provider/model` ID. The unresolvable ID is now skipped and recorded in `modelAttempts`, and the remaining candidates, including the current chat model, are still tried. The request fails with the original error only when no candidate resolves ([#3814](https://github.com/bastani-inc/atomic/issues/3814))
104
+ - Fixed concurrent extension `select`/`confirm`/`input`/`editor` dialogs in interactive mode: a second dialog replaced the first and the first promise never settled, and a dialog opened from an inline `custom()` component hid that component for good. Closing the visible dialog now shows the previous one again ([#11166](https://github.com/earendil-works/pi/issues/11166))
105
+ - Fixed pasting in the shell hanging after copying text in Atomic and suspending it with Ctrl+Z on Linux: the background `wl-copy`/`xclip`/`xsel` process that keeps serving the clipboard was suspended along with Atomic ([#11179](https://github.com/earendil-works/pi/issues/11179))
106
+ - Fixed codemode scripts failing immediately when both codemode workers are busy, for example with parallel codemode calls or in-process subagents. Scripts now wait up to 60 seconds for a free worker, and `ATOMIC_CODEMODE_MAX_WORKERS` (or `PI_CODEMODE_MAX_WORKERS`) raises the limit of two workers ([#11184](https://github.com/earendil-works/pi/issues/11184))
107
+ - Fixed `mcp.json` files that start with a UTF-8 BOM failing to parse, which skipped all their MCP servers and made `atomic mcp add`/`atomic mcp remove` fail ([#11191](https://github.com/earendil-works/pi/issues/11191))
108
+ - The default GitHub Copilot model is now GPT-6.1 Sol. The previous default, GPT-5.4, was deprecated by GitHub on 2026-10-19 and dropped from the model catalog, so a Copilot login had no usable default model. GPT-6.1 Sol is GitHub's suggested replacement.
109
+ - When extensions fail to start a session, the error now names each failing extension and its own error (`Extension startup failed: <extension path>: <error>`) instead of only `Extension startup failed`. The SDK exports the thrown `ExtensionStartupError`, whose `failures` lists the extension path, event, and message of each failure, together with `isExtensionStartupFailure()` and `isTransientExtensionStartupFailure()` ([#3807](https://github.com/bastani-inc/atomic/issues/3807)).
110
+ - Stored OAuth logins, such as `openai-1` or `anthropic-2`, no longer stop working with `OAuth refresh failed ... invalid_grant` under heavy load from many sessions, workflow stages, or subagents until you run `/login`. A session that is slow or briefly unresponsive while it refreshes a token keeps its claim on that refresh, so another session no longer reuses the replaced refresh token. A slow token refresh is no longer cut off at its timeout. Its result is saved when the response arrives, and other sessions wait for it instead of reusing the replaced token. A token refresh the provider completed is also saved when another session took over meanwhile. A session whose refresh is rejected uses a login that another session has just saved. A login the provider has revoked or expired still fails. The error now tells you to run `/login <provider>` for that provider and shows only the provider's error code and description, never tokens.
111
+ - Sessions waiting for another session's OAuth token refresh no longer fail with `Lock file is already being held` before that refresh finishes under heavy load. They now wait as long as a refresh can keep its claim, then use the rotated token. Cancelling the request still stops the wait.
112
+ - Kimi Code logins no longer break with `invalid_grant` when a token refresh takes longer than 30 seconds or loses its connection mid-request. A revoked or expired OpenAI, Kimi Code, xAI, Meta or GitHub Copilot login now also tells you to run `/login <provider>`, for example on OpenAI's `refresh_token_expired` or `refresh_token_reused` errors.
113
+ - A stored OAuth login no longer breaks with `invalid_grant` after Atomic exits while a token refresh is still running, for example a subagent or `atomic -p` run ending after its request timed out, quitting an interactive session, `SIGTERM`, or `atomic auth`. Atomic now waits up to three seconds on exit for the provider's response and saves the rotated token. An `atomic -p` run no longer stays alive waiting for a token refresh that never answers. If Atomic is killed or crashes during a token refresh, run `/login <provider>`.
114
+ - An extension or `models.json` provider whose `$VAR` API key is unset now reports the missing key the way a built-in provider does wherever its models are used, for example `No API key found for <provider>` with the `/login` hint during compaction and branch summaries. Previously compaction, branch summaries, workflow stages, and subagents failed with `Failed to resolve API key ... from environment variable`.
115
+ - Extension OAuth providers whose refresh is rejected now get the built-in `run /login <provider>` guidance, and a newer login another session saved is still used first, when `refreshToken` throws an error carrying a code the built-in providers treat as a rejected login (`invalid_grant`, `refresh_token_expired`, `refresh_token_reused`, or `refresh_token_invalidated`) in its `error` or `code` property or in an OAuth error response (flat or nested under `error.code`) in `body`, `data`, `response.data`, or `response.body`, or HTTP status 401 in `status`, `statusCode`, `response.status`, or `response.statusCode`. Previously these showed the raw error message, which could include the request's tokens.
116
+ - In interactive sessions, `/login` lists extension providers that take an API key and logs in to them, `/logout` offers their stored keys, and `/login` shows an extension provider configured through an environment variable as configured instead of `not configured`.
117
+ - `/resume` search no longer keeps a session's old text after the session is rewritten with the same message count, first message, header and last-modified time, for example when an earlier message's text is replaced. Search now finds the new text and not the removed text, including when another Atomic process made the change.
118
+ - `structured_output` and `generateStructuredOutput` rejections now say what was wrong instead of the fixed `Invalid structured output: response does not match the decision schema.`. A schema mismatch lists up to five failing paths with the expected shape, such as `/files: expected array, got string` and `/summary: required`, followed by `and N more` when there are others, and never echoes the rejected values. When the provider's tool arguments were not valid JSON, the error is `Invalid structured output: tool call arguments were not valid JSON.` with the kind of syntax error and, when known, its position, without quoting the arguments, instead of a schema mismatch on `{}`. The same detail is sent to the model in the repair request and is kept in `modelAttempts` when a candidate model is exhausted ([#3802](https://github.com/bastani-inc/atomic/issues/3802))
119
+ - Shell `wait` and `kill` results now read as one status line above the task's output instead of a raw JSON envelope with internal ids: `✓ completed · exit 0`, `✗ exited 1`, `● still running · showing output so far`, or `○ cancelled`. A truncated result keeps the full-output path as a dim line, and `(no output)` is dimmed. Waiting on or killing an unknown or foreign task id now says `No background task "<id>" in this session` and points to `/tasks`, instead of `UnknownTask: UnknownTask`. The agent receives the same facts as one compact sentence (state, exit code, truncation, full-output path), the structured observation or cancellation receipt stays in the result `details`, and the failures thrown by `wait` and `kill` keep their code in a `code` property ([#3828](https://github.com/bastani-inc/atomic/issues/3828))
120
+ - Fixed a structured decision having no total time limit: a `structured_output` call, a `generateStructuredOutput()` call, or one routing inference for `model: "auto"` could run four slow generations back to back, plus retries, past every configured timeout, and held a workflow stage for over an hour. The whole decision is now stopped after the `httpIdleTimeoutMs` setting (10 minutes by default; disabled means effectively unbounded) and fails with `Structured output decision exceeded its <n>ms deadline; no decision was accepted.`, without trying another repair or fallback model ([#3806](https://github.com/bastani-inc/atomic/issues/3806))
121
+ - The error for exhausted `structured_output` and `generateStructuredOutput` repairs, and the new deadline error, now end with how each attempt finished and how long it took, such as `Attempts: 1) stopReason=stop 25m02s; 2) stopReason=length 1m30s.`, so one slow generation reads differently from several failed ones. Each provider request, including a transport retry, is listed on its own ([#3806](https://github.com/bastani-inc/atomic/issues/3806))
122
+ - Messages typed while the agent was busy are no longer dropped or left waiting for the end of the run. A message typed while a prompt was still being prepared, while Atomic waited to retry a failed request, just after you sent a prompt (before the screen showed that the turn had started), or while extensions were still finishing the previous run used to appear nowhere and be sent only after the run ended, or be refused with "Agent is already processing" and lost. It now shows as queued straight away and joins the run at its next turn boundary, in the order you typed it. If the agent refuses a message, the text comes back to the editor, or stays in the queue, together with the error.
123
+ - Queued messages are no longer left undelivered when the agent finishes before they are queued. The rest of a queued batch, such as the messages held during compaction, and a `steer` or `follow_up` sent to an idle agent whose session already has messages now start a turn instead of waiting for your next prompt.
124
+ - Near-neutral backgrounds such as message and selection backgrounds no longer turn too bright or blue in 256-color terminals: they now use the nearest color-cube or grayscale entry. Tool status backgrounds keep their blue, green, or red tint, so pending, successful, and failed tool blocks stay distinguishable ([#2550](https://github.com/bastani-inc/atomic/issues/2550))
125
+ - Fixed resumed sessions and `/tree` navigation leaving extension tools inactive when the branch recorded its tools before they were registered, such as tools from extensions installed after the session started. Extension tools the branch never added or removed are now active unless they are registered with `defaultActive: false` ([#11062](https://github.com/earendil-works/pi/issues/11062), [#11075](https://github.com/earendil-works/pi/issues/11075))
126
+
5
127
  ## [0.9.41-alpha.3] - 2026-10-22
6
128
 
7
129
  Usage totals now say whether their cost was billed by the provider or estimated.
@@ -4,6 +4,31 @@ All notable changes to the `pi-intercom` extension will be documented in this fi
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.9.42-alpha.1] - 2026-10-23
8
+
9
+ ### Added
10
+
11
+ - `intercom({ action: "status" })` reports the broker's multi-node routing state, for example `Multi-node routing: on (adapter @bastani/atomic-storage-libsql, prefix none), 1 peer broker live (build-host)`, or why it is off or unavailable ([#3857](https://github.com/bastani-inc/atomic/issues/3857))
12
+ - The broker writes a positive line to `broker.log` when it starts: `Intercom multi-node routing is on (adapter <adapter>, prefix <prefix>)`, or `Intercom multi-node routing is off: <reason>`, also for an absent, in-memory, or domain-only selection, so a log without errors is no longer ambiguous ([#3856](https://github.com/bastani-inc/atomic/issues/3856), [#3857](https://github.com/bastani-inc/atomic/issues/3857))
13
+ - The broker answers status and shutdown requests from `atomic storage status`, `atomic intercom status`, and `atomic intercom restart`: its storage selection, sessions, multi-node state, and the other brokers live in the shared database. A restart lets connected sessions reconnect to a broker that reads the current storage selection, without closing them ([#3857](https://github.com/bastani-inc/atomic/issues/3857))
14
+
15
+ ### Fixed
16
+
17
+ - Fixed Intercom remaining stuck on a broker that accepts connections but stops answering. New brokers monitor their own request handling and terminate after about a minute of failed health checks so clients can start one replacement. Timed-out client requests also check connection health before entering reconnect recovery, without restarting a responsive broker. Identical in-flight sends keep sharing one result through the health check. A bounded previous broker log and the latest watchdog diagnosis survive replacement ([#3862](https://github.com/bastani-inc/atomic/issues/3862)).
18
+ - Restarting an older broker on Windows no longer mistakes a lingering named pipe for a restarted broker after its process has exited ([#3857](https://github.com/bastani-inc/atomic/issues/3857))
19
+
20
+ ## [0.9.41] - 2026-10-22
21
+
22
+ ### Changed
23
+
24
+ - Workflow route updates cost the Intercom broker far less when many runs are active. The broker now acknowledges each route registration itself instead of the session listing the previous version requested after every update, and each run's route connection retries on its own backoff, with limited concurrency, instead of all of them reconnecting at once. A workflow stage that starts while its run's route connection is waiting to retry waits for that retry rather than failing, and a retry that fails is retried by the stage like any other connection timeout. Mixed versions keep working: with an older broker the session listing is still used, and older sessions work with the new broker.
25
+
26
+ ### Fixed
27
+
28
+ - `intercom list` with a `group` that names a workflow invocation group you are not a member of no longer reports `No sessions or workflow stages in this group.` The group is visible only to its members, so the peek now says that and tells you to `intercom join` it to see its stages, instead of looking like a run whose stages had all disappeared.
29
+ - Fixed `Intercom event relay failed (atomic:workflow-pending-stage-route): Connection timeout` repeating for every route update when 50 or more workflows ran at once. A broker that is slow to answer a connection is now a recoverable condition that background relays retry quietly. A broker that stays unresponsive for a minute is reported once, and Intercom tool calls you make still report the failure.
30
+ - Fixed a single workflow run's route connection failing to connect and dropping the session's healthy Intercom connection, which re-created every run's connection at the same time.
31
+
7
32
  ## [0.9.41-alpha.1] - 2026-10-21
8
33
 
9
34
  ### Changed
@@ -389,7 +389,7 @@ When multiple live agent stages share a name, `ask` refuses the ambiguous target
389
389
 
390
390
  `contact_supervisor` decisions and interviews are deliberately exclusive per child: one supervisor wait may coexist with ordinary peer asks, but a second concurrent supervisor wait receives `Already waiting for a supervisor reply`. Claimed foreground parent handoffs remain first-claim-wins and do not allocate a waiter. Multiple children can contact the same parent independently because inbound asks and handoffs are keyed by child/message identity. Mutual peer asks are supported, but each side must process inbound work to reply; the per-waiter timeout remains the deadlock backstop.
391
391
 
392
- **`status`** — Shows connection status, session ID, every current membership, and the total visible-session count. A `group` filter remains a read-only peek.
392
+ **`status`** — Shows connection status, session ID, every current membership, and the total visible-session count. It also reports the broker's multi-node routing state on a `Multi-node routing:` line: `on (adapter <adapter>, prefix <prefix>)` with the number of other brokers live in the shared database, or why routing is off or unavailable; `details.multiNode` carries the same fields. A broker that predates status reporting shows `unknown`, and `atomic intercom restart` replaces it. A `group` filter remains a read-only peek.
393
393
 
394
394
  ## Keyboard Shortcuts
395
395
 
@@ -463,6 +463,8 @@ graph TB
463
463
 
464
464
  The broker is a standalone TypeScript process that manages session registration and message routing. It auto-spawns when the first session that invokes Intercom needs it and exits after 5 seconds when it last has no registered sessions, including brokers that never received a connection and sockets that close before register. Clients reconnect automatically if the broker disappears and later comes back. A failed reconnect schedules the next attempt on a bounded backoff (1s, 2s, 5s, 10s, then 30s) once it releases reconnect ownership, so a transient failure never leaves a live session with nothing owning recovery; a failed explicit tool or overlay connection still returns its error to the caller and leaves the retry behind. A reconnect that fails after the broker already accepted it closes that connection first, so a session never appears twice in `intercom list`.
465
465
 
466
+ A broker-owned watchdog checks the local request path independently of the broker's main thread. After about a minute without a valid response from its own broker instance, it terminates its own process; clients reconnect through the existing spawn lock. It never signals a PID read from `broker.pid`. A timed-out client request shares one connection-health check with other simultaneous timeouts on that connection, and invalidates a silent connection. A responsive broker is left running. Brokers already running from an older version gain the watchdog only after a restart; an OS-suspended process cannot run it.
467
+
466
468
  Each `send`, `ask`, or `reply` invocation retries typed recoverable disconnects internally up to three times, after delays of 1, 2, and 5 seconds. Retries keep the same message ID, exact arguments, attachment order and presence, and reply route. The model does not supply or receive a retry token; existing integrations must remove `retryToken`, which is now refused without sending. Every new invocation remains a fresh operation, even with identical text. Confirmation happens once, and neither retries nor renewed reply waits extend the original 11-minute operation deadline.
467
469
 
468
470
  Lazy initialization before delivery has a separate limit of three reconnect retries on the same schedule. If that phase stops, the tool reports `outcome: "not_sent"`. The initialization wrapper never re-executes a delivery that has already started.
@@ -494,7 +496,9 @@ Runtime files live under the active agent directory. Atomic defaults to `~/.atom
494
496
  - `delivered-messages.key` — random owner-only HMAC key paired with the authority database
495
497
  - `broker.spawn.lock` — Lock used to avoid duplicate auto-spawns. It records the spawner and its broker, and is held until the broker answers or the processes it names are gone, with a 60 second backstop. On Windows, where the hidden launcher does not expose the broker process, it ends with the spawner's 5 second readiness wait, and a later spawn that finds `broker.log` still held by a starting broker waits for that broker instead of starting another
496
498
  - `broker.sock.replace.lock` — Short-lived lock a starting broker holds while it re-checks and replaces a stale socket file, so two brokers never both replace it
497
- - `broker.log` — Broker stderr, truncated on every spawn and capped at 8 KiB by the broker itself
499
+ - `broker.log` — Broker stderr, capped at 8 KiB by the broker itself. It records whether multi-node routing is on (`Intercom multi-node routing is on (adapter <adapter>, prefix <prefix>)`) or off and why
500
+ - `broker.log.previous` — The bounded tail of the previous nonempty broker log, preserved on spawn
501
+ - `broker-watchdog.log` — The latest watchdog termination's time, PID, instance, and reason; kept across spawns
498
502
  - `config.json` — User configuration
499
503
 
500
504
  The broker is a detached subprocess and never inherits the host's extension-loader module aliases, so every module reachable from `broker/broker.ts` resolves to Node built-ins or Intercom's own files. Its stderr is captured to `broker.log` through an already-open file descriptor rather than a pipe, because a pipe to a process that outlives its parent breaks once the parent exits. Startup failures — both an early exit and a readiness timeout — quote the log path and a bounded tail of that output.
@@ -505,7 +509,7 @@ The broker caps the file from the inside, since nothing on the parent side can b
505
509
 
506
510
  **Local IPC instead of TCP.** Same-machine only by design. `pi-intercom` uses Unix sockets on macOS/Linux and a named pipe on Windows, which keeps setup simple and avoids port management.
507
511
 
508
- **Auto-spawn with file lock.** The broker starts on first connection and exits after 5 seconds idle. There is no daemon to manage. A spawn lock file, keyed by PID and timestamp, prevents duplicate brokers when multiple sessions start at once. A broker that starts while another broker already answers on the socket exits instead of taking the socket over; it replaces only a stale socket file. A broker whose socket path comes to point at another broker shuts down, and its sessions reconnect to the broker that owns the path.
512
+ **Auto-spawn with file lock.** The broker starts on first connection and exits after 5 seconds idle. There is no daemon to manage. A spawn lock file, keyed by PID and timestamp, prevents duplicate brokers when multiple sessions start at once. A broker that starts while another broker already answers on the socket exits instead of taking the socket over; it replaces only a stale socket file. A broker whose socket path comes to point at another broker shuts down, and its sessions reconnect to the broker that owns the path. `atomic intercom restart` asks the broker to shut down over its socket (a `shutdown` request, accepted before registration like `broker_status`), so its sessions reconnect to a broker that reads the current storage selection.
509
513
 
510
514
  **`ask` stays client-side.** The broker still routes plain messages; it does not have a special request/response mode for `ask`. The client waits for a matching reply before it triggers a new turn, then returns that reply as the tool result. Reply hints make that flow practical by showing the recipient the exact `send` call to use. Separately, `list` / `sessions` now carry a `requestId` so a delayed session-list reply cannot be mistaken for a newer one.
511
515
 
@@ -0,0 +1,48 @@
1
+ import type { BrokerMultiNodeStatus, BrokerStatusReport } from "../types.js";
2
+ import type { JsonWireValue } from "./framing.js";
3
+
4
+ type WireObject = { [key: string]: JsonWireValue };
5
+
6
+ function isObject(value: JsonWireValue | undefined): value is WireObject {
7
+ return typeof value === "object" && value !== null && !Array.isArray(value);
8
+ }
9
+
10
+ function isOptionalString(value: JsonWireValue | undefined): boolean {
11
+ return value === undefined || typeof value === "string";
12
+ }
13
+
14
+ /** Whether a `broker_status` frame's `report` has the shape this version reads. */
15
+ export function isBrokerStatusReport(value: JsonWireValue | undefined): value is WireObject & BrokerStatusReport {
16
+ if (!isObject(value) || typeof value.pid !== "number" || typeof value.sessions !== "number") return false;
17
+ const { storage, multiNode } = value;
18
+ if (
19
+ storage !== undefined &&
20
+ (!isObject(storage) ||
21
+ typeof storage.digest !== "string" ||
22
+ (storage.source !== "env" && storage.source !== "settings"))
23
+ )
24
+ return false;
25
+ return (
26
+ isObject(multiNode) &&
27
+ (multiNode.status === "off" ||
28
+ multiNode.status === "starting" ||
29
+ multiNode.status === "on" ||
30
+ multiNode.status === "unavailable") &&
31
+ isOptionalString(multiNode.reason) &&
32
+ isOptionalString(multiNode.adapter) &&
33
+ isOptionalString(multiNode.prefix) &&
34
+ (multiNode.peers === undefined ||
35
+ (Array.isArray(multiNode.peers) && multiNode.peers.every((peer) => typeof peer === "string")))
36
+ );
37
+ }
38
+
39
+ /** One line for a broker's multi-node routing state, e.g. `on (adapter x, prefix none), 1 peer broker live (host-b)`. */
40
+ export function describeMultiNodeStatus(multiNode: BrokerMultiNodeStatus): string {
41
+ const storage = multiNode.adapter === undefined ? "" : ` (adapter ${multiNode.adapter}, prefix ${multiNode.prefix ?? "none"})`;
42
+ if (multiNode.status === "off" || multiNode.status === "unavailable") {
43
+ return `${multiNode.status}${storage}${multiNode.reason === undefined ? "" : `: ${multiNode.reason}`}`;
44
+ }
45
+ if (multiNode.status === "starting" || multiNode.peers === undefined) return `${multiNode.status}${storage}`;
46
+ const count = `${multiNode.peers.length} peer broker${multiNode.peers.length === 1 ? "" : "s"} live`;
47
+ return `on${storage}, ${count}${multiNode.peers.length === 0 ? "" : ` (${[...new Set(multiNode.peers)].join(", ")})`}`;
48
+ }
@@ -17,17 +17,21 @@ import {
17
17
  } from "./socket-writes.js";
18
18
  import { isSocketAnswering } from "./socket-liveness.js";
19
19
  import { acquireSocketReplacementLock } from "./socket-replacement-lock.js";
20
+ import { startBrokerWatchdog } from "./watchdog.js";
20
21
  import {
21
22
  getBrokerDeliveredMessagesPath,
22
23
  getBrokerPidPath,
24
+ getBrokerWatchdogLogPath,
23
25
  getBrokerSocketPath,
24
26
  getBrokerSocketReplacementLockPath,
25
27
  getIntercomDirPath,
26
28
  } from "./paths.js";
29
+ import { describeSharedStorage, sharedStorageDigest, sharedStorageSettingSource } from "./shared-storage-setting.js";
27
30
  import type {
28
31
  SessionInfo,
29
32
  Message,
30
33
  BrokerMessage,
34
+ BrokerStatusReport,
31
35
  SupervisorRegistration,
32
36
  WorkflowStageRosterAnnouncement,
33
37
  WorkflowStageRosterEntry,
@@ -86,6 +90,8 @@ import {
86
90
 
87
91
  const INTERCOM_DIR = getIntercomDirPath();
88
92
  const SOCKET_PATH = getBrokerSocketPath();
93
+ const BROKER_IDLE_TIMEOUT_MS = 5_000;
94
+ const UNCLASSIFIED_CONNECTION_RECHECK_MS = 100;
89
95
  const PID_PATH = getBrokerPidPath();
90
96
  const SOCKET_REPLACEMENT_LOCK_PATH = getBrokerSocketReplacementLockPath();
91
97
 
@@ -94,6 +100,13 @@ const LISTEN_ATTEMPTS = 5;
94
100
  const LISTEN_RETRY_DELAY_MS = 100;
95
101
  const SOCKET_OWNERSHIP_CHECK_MS = 1_000;
96
102
  const MULTI_NODE_CLOSE_BOUND_MS = 2_000;
103
+ const NO_SHARED_STORAGE_REASON = "no shared storage selection (top-level `storage` in global settings, or ATOMIC_STORAGE)";
104
+ /** The off reasons `resolveBrokerStateSource` reports without an error, as the log and `broker_status` state them. */
105
+ const MULTI_NODE_OFF_REASONS: Readonly<Record<string, string>> = {
106
+ "no-storage": "the shared storage selection names no database",
107
+ memory: "the shared storage selection is in memory",
108
+ "domain-only-adapter": "the storage adapter has no openDatabase hook",
109
+ };
97
110
 
98
111
  function exitBecauseLiveBrokerOwnsSocket(): never {
99
112
  console.error(`Intercom broker not started: a live broker already answers on ${SOCKET_PATH}`);
@@ -302,7 +315,9 @@ function isWorkflowPossibleStageAnnouncements(
302
315
 
303
316
  class IntercomBroker {
304
317
  private sessions = new Map<string, ConnectedSession>();
318
+ private unclassifiedConnections = new Map<net.Socket, number>();
305
319
  private server: net.Server;
320
+ private readonly instanceId = randomUUID();
306
321
  private shutdownTimer: NodeJS.Timeout | null = null;
307
322
  private boundSocketInode: number | undefined;
308
323
  private lastConnectionAt = performance.now();
@@ -328,6 +343,8 @@ class IntercomBroker {
328
343
  this.sharedStorageSetting === undefined ? undefined : (request) => this.routeRemoteSend(request);
329
344
  private multiNode: MultiNodeRouter | undefined;
330
345
  private multiNodeStatus: "starting" | "on" | "off" | "unavailable" = "off";
346
+ /** Why multi-node routing is off or unavailable, for `broker_status` and the log. */
347
+ private multiNodeReason: string | undefined = NO_SHARED_STORAGE_REASON;
331
348
  private multiNodeFirstAttempt: Promise<void> = Promise.resolve();
332
349
  private shuttingDown = false;
333
350
 
@@ -340,6 +357,7 @@ class IntercomBroker {
340
357
  start(): void {
341
358
  this.listen(1);
342
359
  if (this.sharedStorageSetting !== undefined) this.startMultiNode(this.sharedStorageSetting);
360
+ else console.error(`Intercom multi-node routing is off: ${NO_SHARED_STORAGE_REASON}`);
343
361
  process.on("SIGTERM", () => this.shutdown());
344
362
  process.on("SIGINT", () => this.shutdown());
345
363
  }
@@ -356,6 +374,7 @@ class IntercomBroker {
356
374
  releaseReplacementLock();
357
375
  writeFileSync(PID_PATH, String(process.pid));
358
376
  console.log(`Intercom broker started (pid: ${process.pid})`);
377
+ startBrokerWatchdog(SOCKET_PATH, this.instanceId, getBrokerWatchdogLogPath());
359
378
  this.lastConnectionAt = performance.now();
360
379
  this.scheduleShutdownCheck();
361
380
  if (this.boundSocketInode !== undefined) this.watchSocketOwnership();
@@ -415,11 +434,18 @@ class IntercomBroker {
415
434
 
416
435
  private handleConnection(socket: net.Socket): void {
417
436
  let sessionId: string | null = null;
418
- this.lastConnectionAt = performance.now();
419
- this.restartShutdownCheck();
437
+ this.unclassifiedConnections.set(socket, performance.now());
438
+ const recordActivity = () => {
439
+ this.lastConnectionAt = performance.now();
440
+ this.restartShutdownCheck();
441
+ };
420
442
 
421
443
  const reader = createMessageReader((msg) => {
422
444
  if (socket.destroyed || socket.writableEnded) return;
445
+ if (this.unclassifiedConnections.delete(socket)) {
446
+ const health = typeof msg === "object" && msg !== null && !Array.isArray(msg) && msg.type === "health";
447
+ if (!health) recordActivity();
448
+ }
423
449
  this.handleMessage(socket, msg, sessionId, (id) => {
424
450
  sessionId = id;
425
451
  });
@@ -441,6 +467,7 @@ class IntercomBroker {
441
467
  // is lost. disconnectSession is idempotent, so 'end', 'error' and 'close'
442
468
  // may each fire without duplicating the departure broadcast.
443
469
  const retire = () => {
470
+ if (this.unclassifiedConnections.delete(socket)) recordActivity();
444
471
  const departing = sessionId;
445
472
  if (departing === null) return;
446
473
  sessionId = null;
@@ -464,17 +491,24 @@ class IntercomBroker {
464
491
  });
465
492
  }
466
493
 
467
- private scheduleShutdownCheck(): void {
494
+ private scheduleShutdownCheck(delayMs = BROKER_IDLE_TIMEOUT_MS): void {
468
495
  if (this.shutdownTimer) return;
469
496
 
470
497
  this.shutdownTimer = setTimeout(() => {
471
498
  this.shutdownTimer = null;
472
499
  if (this.sessions.size === 0) {
500
+ const now = performance.now();
501
+ const awaitingFirstFrame = [...this.unclassifiedConnections.values()]
502
+ .some((acceptedAt) => now - acceptedAt < BROKER_IDLE_TIMEOUT_MS);
503
+ if (awaitingFirstFrame) {
504
+ this.scheduleShutdownCheck(UNCLASSIFIED_CONNECTION_RECHECK_MS);
505
+ return;
506
+ }
473
507
  const idleMs = Math.round(performance.now() - this.lastConnectionAt);
474
508
  console.log(`No sessions connected for ${idleMs}ms since the last connection, shutting down`);
475
509
  this.shutdown();
476
510
  }
477
- }, 5000);
511
+ }, delayMs);
478
512
  }
479
513
 
480
514
  private restartShutdownCheck(): void {
@@ -1473,6 +1507,27 @@ class IntercomBroker {
1473
1507
 
1474
1508
  const clientMessage = msg;
1475
1509
 
1510
+ if (clientMessage.type === "health") {
1511
+ if (typeof clientMessage.requestId !== "string" || clientMessage.requestId.length > 128) {
1512
+ throw new Error("Invalid health request");
1513
+ }
1514
+ writeMessageIfOpen(socket, { type: "health", requestId: clientMessage.requestId, instanceId: this.instanceId });
1515
+ return;
1516
+ }
1517
+
1518
+ // Control requests need no registration: `atomic intercom restart` and `atomic storage status` send them.
1519
+ if (clientMessage.type === "broker_status" || clientMessage.type === "shutdown") {
1520
+ if (typeof clientMessage.requestId !== "string") throw new Error(`Invalid ${clientMessage.type} message`);
1521
+ const requestId = clientMessage.requestId;
1522
+ if (clientMessage.type === "broker_status") {
1523
+ void this.statusReport().then((report) => writeMessageIfOpen(socket, { type: "broker_status", requestId, report }));
1524
+ return;
1525
+ }
1526
+ console.error("Intercom broker shutting down on request, so its sessions reconnect to a new broker");
1527
+ writeMessageWithOutcome(socket, { type: "shutdown_ack", requestId }, () => this.shutdown());
1528
+ return;
1529
+ }
1530
+
1476
1531
  if (currentId === null && clientMessage.type !== "register") {
1477
1532
  throw new Error(`Received ${clientMessage.type} before register`);
1478
1533
  }
@@ -1567,6 +1622,7 @@ class IntercomBroker {
1567
1622
  ...(supervisorId ? { supervisorSessionId: supervisorId } : {}),
1568
1623
  ...(clientMessage.acknowledgesReceipt === true ? { acknowledgesReceipt: true as const } : {}),
1569
1624
  acknowledgesPendingStageRoutes: true as const,
1625
+ brokerStatus: true as const,
1570
1626
  });
1571
1627
  if (isAgentRecipient(info)) this.broadcastToMemberships({ type: "session_joined", session: info }, sessionGroups(info), id);
1572
1628
  // #3761: messages written to this recipient's previous connection that it never confirmed saving go out again.
@@ -2097,12 +2153,32 @@ class IntercomBroker {
2097
2153
  });
2098
2154
  }
2099
2155
 
2156
+ /** This broker's `broker_status` report: its storage selection's digest, multi-node state and live peers. */
2157
+ private async statusReport(): Promise<BrokerStatusReport> {
2158
+ const setting = this.sharedStorageSetting;
2159
+ const peers = this.multiNodeStatus === "on" ? await this.multiNode?.peerBrokers() : undefined;
2160
+ return {
2161
+ pid: process.pid,
2162
+ sessions: this.sessions.size,
2163
+ ...(setting === undefined
2164
+ ? {}
2165
+ : { storage: { digest: sharedStorageDigest(setting), source: sharedStorageSettingSource() } }),
2166
+ multiNode: {
2167
+ status: this.multiNodeStatus,
2168
+ ...(this.multiNodeReason === undefined ? {} : { reason: this.multiNodeReason }),
2169
+ ...(setting === undefined ? {} : describeSharedStorage(setting)),
2170
+ ...(peers === undefined ? {} : { peers }),
2171
+ },
2172
+ };
2173
+ }
2174
+
2100
2175
  /**
2101
2176
  * The shared state is loaded lazily, so a single-node broker never loads the host storage layer. A database that
2102
2177
  * cannot be opened is retried with exponential backoff; meanwhile local delivery continues.
2103
2178
  */
2104
- private startMultiNode(setting: Record<string, unknown>): void {
2179
+ private startMultiNode(setting: Record<string, JsonWireValue>): void {
2105
2180
  this.multiNodeStatus = "starting";
2181
+ this.multiNodeReason = undefined;
2106
2182
  let settleFirstAttempt: () => void = () => {};
2107
2183
  this.multiNodeFirstAttempt = new Promise<void>((resolve) => {
2108
2184
  settleFirstAttempt = resolve;
@@ -2112,7 +2188,8 @@ class IntercomBroker {
2112
2188
  settleFirstAttempt();
2113
2189
  };
2114
2190
  const report = (error: unknown): void => {
2115
- console.error(`Intercom multi-node routing is unavailable: ${error instanceof Error ? error.message : String(error)}`);
2191
+ this.multiNodeReason = error instanceof Error ? error.message : String(error);
2192
+ console.error(`Intercom multi-node routing is unavailable: ${this.multiNodeReason}`);
2116
2193
  };
2117
2194
  void (async () => {
2118
2195
  const startup = await resolveMultiNodeStartup(setting, dirname(INTERCOM_DIR), async () => {
@@ -2126,9 +2203,10 @@ class IntercomBroker {
2126
2203
  return opener;
2127
2204
  });
2128
2205
  if (startup.status === "off") {
2129
- // A standalone binary has no host storage module, and a selection may not resolve to a database: both
2130
- // leave this broker single-node, exactly as without a shared `storage`.
2131
- if (startup.error !== undefined) console.error(`Intercom multi-node routing is off: ${startup.reason}`);
2206
+ // A selection that does not resolve to a database, or an adapter that cannot load, leaves this broker
2207
+ // single-node, exactly as without a shared `storage`.
2208
+ this.multiNodeReason = startup.error === undefined ? (MULTI_NODE_OFF_REASONS[startup.reason] ?? startup.reason) : startup.reason;
2209
+ console.error(`Intercom multi-node routing is off: ${this.multiNodeReason}`);
2132
2210
  settle("off");
2133
2211
  return;
2134
2212
  }
@@ -2171,6 +2249,9 @@ class IntercomBroker {
2171
2249
  }
2172
2250
  }
2173
2251
  if (this.shuttingDown) await this.multiNode.close().catch(() => {});
2252
+ this.multiNodeReason = undefined;
2253
+ const { adapter, prefix } = describeSharedStorage(setting);
2254
+ console.error(`Intercom multi-node routing is on (adapter ${adapter}, prefix ${prefix ?? "none"})`);
2174
2255
  settle("on");
2175
2256
  return;
2176
2257
  } catch (error) {