@huynle/hub 0.6.1 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (355) hide show
  1. package/CHANGELOG.md +870 -0
  2. package/Dockerfile +33 -0
  3. package/README.md +1007 -66
  4. package/package.json +47 -2
  5. package/packages/claude-code/package.json +12 -0
  6. package/packages/claude-code/src/bridge.ts +79 -0
  7. package/packages/claude-code/src/daemon.ts +339 -0
  8. package/packages/claude-code/src/hook-input.ts +44 -0
  9. package/packages/claude-code/src/ids.ts +27 -0
  10. package/packages/claude-code/src/index.ts +18 -0
  11. package/packages/claude-code/src/launcher.ts +265 -0
  12. package/packages/claude-code/src/main.ts +127 -0
  13. package/packages/claude-code/src/mcp.ts +109 -0
  14. package/packages/claude-code/src/paths.ts +34 -0
  15. package/packages/claude-code/src/plugin.ts +99 -0
  16. package/packages/claude-code/src/references.ts +51 -0
  17. package/packages/claude-code/src/sessions.ts +600 -0
  18. package/packages/claude-code/src/tools.ts +66 -0
  19. package/packages/claude-code/src/wire.ts +71 -0
  20. package/packages/claude-code/src/wrapper.ts +156 -0
  21. package/packages/cli/dist/hub-cli.js +21647 -0
  22. package/packages/cli/package.json +11 -0
  23. package/packages/cli/src/acting.ts +70 -0
  24. package/packages/cli/src/args.ts +91 -0
  25. package/packages/cli/src/bootstrap/adapters/archive-stream.ts +276 -0
  26. package/packages/cli/src/bootstrap/adapters/claude/README.md +63 -0
  27. package/packages/cli/src/bootstrap/adapters/claude/adapter.ts +250 -0
  28. package/packages/cli/src/bootstrap/adapters/claude/index.ts +42 -0
  29. package/packages/cli/src/bootstrap/adapters/claude/node.ts +99 -0
  30. package/packages/cli/src/bootstrap/adapters/claude/openpgp.ts +221 -0
  31. package/packages/cli/src/bootstrap/adapters/claude/ports.ts +44 -0
  32. package/packages/cli/src/bootstrap/adapters/claude/source.ts +160 -0
  33. package/packages/cli/src/bootstrap/adapters/claude/verify.ts +165 -0
  34. package/packages/cli/src/bootstrap/adapters/codex/archive.ts +103 -0
  35. package/packages/cli/src/bootstrap/adapters/codex/index.ts +60 -0
  36. package/packages/cli/src/bootstrap/adapters/codex/plan.ts +129 -0
  37. package/packages/cli/src/bootstrap/adapters/codex/sigstore.ts +298 -0
  38. package/packages/cli/src/bootstrap/adapters/codex/source.ts +102 -0
  39. package/packages/cli/src/bootstrap/adapters/codex/verify.ts +298 -0
  40. package/packages/cli/src/bootstrap/adapters/inflate.ts +433 -0
  41. package/packages/cli/src/bootstrap/adapters/node-services/control.ts +392 -0
  42. package/packages/cli/src/bootstrap/adapters/node-services/index.ts +23 -0
  43. package/packages/cli/src/bootstrap/adapters/node-services/launch.ts +223 -0
  44. package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +222 -0
  45. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +183 -0
  46. package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +197 -0
  47. package/packages/cli/src/bootstrap/adapters/opencode/README.md +204 -0
  48. package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +205 -0
  49. package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +91 -0
  50. package/packages/cli/src/bootstrap/adapters/opencode/config.ts +212 -0
  51. package/packages/cli/src/bootstrap/adapters/opencode/index.ts +81 -0
  52. package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +266 -0
  53. package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +208 -0
  54. package/packages/cli/src/bootstrap/adapters/readiness.ts +19 -0
  55. package/packages/cli/src/bootstrap/ansible/index.ts +2 -0
  56. package/packages/cli/src/bootstrap/ansible/render.ts +279 -0
  57. package/packages/cli/src/bootstrap/contracts/actions.ts +470 -0
  58. package/packages/cli/src/bootstrap/contracts/artifact.ts +323 -0
  59. package/packages/cli/src/bootstrap/contracts/canonical.ts +54 -0
  60. package/packages/cli/src/bootstrap/contracts/credentials.ts +108 -0
  61. package/packages/cli/src/bootstrap/contracts/discovery.ts +215 -0
  62. package/packages/cli/src/bootstrap/contracts/envelope.ts +143 -0
  63. package/packages/cli/src/bootstrap/contracts/exec.ts +74 -0
  64. package/packages/cli/src/bootstrap/contracts/harness.ts +202 -0
  65. package/packages/cli/src/bootstrap/contracts/index.ts +28 -0
  66. package/packages/cli/src/bootstrap/contracts/journal.ts +198 -0
  67. package/packages/cli/src/bootstrap/contracts/paths.ts +91 -0
  68. package/packages/cli/src/bootstrap/contracts/plan.ts +155 -0
  69. package/packages/cli/src/bootstrap/contracts/platform.ts +62 -0
  70. package/packages/cli/src/bootstrap/contracts/preview.ts +190 -0
  71. package/packages/cli/src/bootstrap/contracts/profile.ts +105 -0
  72. package/packages/cli/src/bootstrap/contracts/secrets.ts +52 -0
  73. package/packages/cli/src/bootstrap/contracts/validate.ts +212 -0
  74. package/packages/cli/src/bootstrap/discovery/discover.ts +146 -0
  75. package/packages/cli/src/bootstrap/discovery/executables.ts +134 -0
  76. package/packages/cli/src/bootstrap/discovery/host.ts +96 -0
  77. package/packages/cli/src/bootstrap/discovery/index.ts +6 -0
  78. package/packages/cli/src/bootstrap/discovery/node-probe.ts +97 -0
  79. package/packages/cli/src/bootstrap/discovery/probe.ts +41 -0
  80. package/packages/cli/src/bootstrap/discovery/util.ts +13 -0
  81. package/packages/cli/src/bootstrap/enrollment/enroll.ts +226 -0
  82. package/packages/cli/src/bootstrap/enrollment/index.ts +7 -0
  83. package/packages/cli/src/bootstrap/enrollment/policy.ts +54 -0
  84. package/packages/cli/src/bootstrap/enrollment/result.ts +63 -0
  85. package/packages/cli/src/bootstrap/enrollment/status.ts +119 -0
  86. package/packages/cli/src/bootstrap/enrollment/store.ts +173 -0
  87. package/packages/cli/src/bootstrap/preview/index.ts +5 -0
  88. package/packages/cli/src/bootstrap/preview/links.ts +31 -0
  89. package/packages/cli/src/bootstrap/preview/plan.ts +242 -0
  90. package/packages/cli/src/bootstrap/preview/preview.ts +104 -0
  91. package/packages/cli/src/bootstrap/preview/render.ts +30 -0
  92. package/packages/cli/src/bootstrap/profiles/audit.ts +87 -0
  93. package/packages/cli/src/bootstrap/profiles/catalog.ts +49 -0
  94. package/packages/cli/src/bootstrap/profiles/desired.ts +96 -0
  95. package/packages/cli/src/bootstrap/profiles/drift.ts +286 -0
  96. package/packages/cli/src/bootstrap/profiles/history.ts +263 -0
  97. package/packages/cli/src/bootstrap/profiles/index.ts +11 -0
  98. package/packages/cli/src/bootstrap/profiles/upgrade.ts +276 -0
  99. package/packages/cli/src/bootstrap/profiles/versions.ts +50 -0
  100. package/packages/cli/src/bootstrap/release/README.md +115 -0
  101. package/packages/cli/src/bootstrap/release/assets.ts +39 -0
  102. package/packages/cli/src/bootstrap/release/cli.ts +332 -0
  103. package/packages/cli/src/bootstrap/release/harness-pins.json +94 -0
  104. package/packages/cli/src/bootstrap/release/manifest.ts +118 -0
  105. package/packages/cli/src/bootstrap/release/pins.ts +192 -0
  106. package/packages/cli/src/bootstrap/release/text-assets.d.ts +9 -0
  107. package/packages/cli/src/bootstrap/release/trust-root.json +13 -0
  108. package/packages/cli/src/bootstrap/release/trust-root.ts +39 -0
  109. package/packages/cli/src/bootstrap/release/verify.ts +88 -0
  110. package/packages/cli/src/bootstrap/transaction/builtin.ts +312 -0
  111. package/packages/cli/src/bootstrap/transaction/engine.ts +994 -0
  112. package/packages/cli/src/bootstrap/transaction/executor.ts +112 -0
  113. package/packages/cli/src/bootstrap/transaction/fs.ts +194 -0
  114. package/packages/cli/src/bootstrap/transaction/index.ts +12 -0
  115. package/packages/cli/src/bootstrap/transaction/json-merge.ts +403 -0
  116. package/packages/cli/src/bootstrap/transaction/lock.ts +132 -0
  117. package/packages/cli/src/bootstrap/transaction/resolve.ts +48 -0
  118. package/packages/cli/src/bootstrap/transaction/result.ts +60 -0
  119. package/packages/cli/src/bootstrap/transaction/state.ts +88 -0
  120. package/packages/cli/src/bootstrap/transaction/store.ts +189 -0
  121. package/packages/cli/src/bootstrap/transaction/stream.ts +254 -0
  122. package/packages/cli/src/cli.ts +814 -0
  123. package/packages/cli/src/credentials.ts +75 -0
  124. package/packages/cli/src/decisions.ts +106 -0
  125. package/packages/cli/src/format.ts +185 -0
  126. package/packages/cli/src/grants.ts +105 -0
  127. package/packages/cli/src/http.ts +86 -0
  128. package/packages/cli/src/launches.ts +157 -0
  129. package/packages/cli/src/login.ts +160 -0
  130. package/packages/cli/src/main.ts +63 -0
  131. package/packages/cli/src/mcp.ts +879 -0
  132. package/packages/cli/src/messaging.ts +108 -0
  133. package/packages/cli/src/node.ts +279 -0
  134. package/packages/cli/src/openshell.ts +166 -0
  135. package/packages/cli/src/setup/commands.ts +809 -0
  136. package/packages/cli/src/setup/contributors.ts +171 -0
  137. package/packages/cli/src/setup/dispatch.ts +74 -0
  138. package/packages/cli/src/setup/launch-driver.ts +109 -0
  139. package/packages/cli/src/setup/runtime.ts +456 -0
  140. package/packages/cli/src/setup/spoke-probe.ts +120 -0
  141. package/packages/cli/src/setup/state.ts +244 -0
  142. package/packages/cli/src/setup/user-bin.ts +39 -0
  143. package/packages/cli/src/spoke-services.ts +109 -0
  144. package/packages/cli/src/watch.ts +28 -0
  145. package/packages/codex/package.json +12 -0
  146. package/packages/codex/src/app-server.ts +44 -0
  147. package/packages/codex/src/ids.ts +28 -0
  148. package/packages/codex/src/index.ts +11 -0
  149. package/packages/codex/src/jsonrpc.ts +168 -0
  150. package/packages/codex/src/main.ts +123 -0
  151. package/packages/codex/src/paths.ts +65 -0
  152. package/packages/codex/src/sessions.ts +578 -0
  153. package/packages/codex/src/spoke.ts +252 -0
  154. package/packages/codex/src/stdio.ts +101 -0
  155. package/packages/codex/src/websocket.ts +206 -0
  156. package/packages/codex/src/wrapper.ts +104 -0
  157. package/packages/node/package.json +12 -0
  158. package/packages/node/src/config.ts +184 -0
  159. package/packages/node/src/connection.ts +283 -0
  160. package/packages/node/src/daemon.ts +281 -0
  161. package/packages/node/src/git.ts +107 -0
  162. package/packages/node/src/harnesses.ts +95 -0
  163. package/packages/node/src/host.ts +319 -0
  164. package/packages/node/src/index.ts +17 -0
  165. package/packages/node/src/main.ts +11 -0
  166. package/packages/node/src/openshell.ts +73 -0
  167. package/packages/node/src/run.ts +106 -0
  168. package/packages/preset/package.json +12 -0
  169. package/packages/preset/presets/aider.json +8 -0
  170. package/packages/preset/presets/goose.json +8 -0
  171. package/packages/preset/src/index.ts +2 -0
  172. package/packages/preset/src/main.ts +195 -0
  173. package/packages/preset/src/presets.ts +87 -0
  174. package/packages/preset/src/spoke.ts +251 -0
  175. package/packages/preset/src/tmux.ts +160 -0
  176. package/packages/protocol/package.json +12 -0
  177. package/packages/protocol/src/harness-events.ts +207 -0
  178. package/packages/protocol/src/index.ts +1215 -0
  179. package/packages/protocol/src/launch.ts +172 -0
  180. package/packages/protocol/src/node.ts +409 -0
  181. package/packages/protocol/src/preset.ts +144 -0
  182. package/packages/protocol/src/webhook.ts +62 -0
  183. package/packages/protocol/src/wire.ts +54 -0
  184. package/packages/runtime-openshell/image/Dockerfile +36 -0
  185. package/packages/runtime-openshell/image/README.md +78 -0
  186. package/packages/runtime-openshell/image/build.ts +203 -0
  187. package/packages/runtime-openshell/image/entry.sh +163 -0
  188. package/packages/runtime-openshell/image/image.ts +119 -0
  189. package/packages/runtime-openshell/image/start.sh +24 -0
  190. package/packages/runtime-openshell/package.json +12 -0
  191. package/packages/runtime-openshell/src/cli.ts +584 -0
  192. package/packages/runtime-openshell/src/index.ts +6 -0
  193. package/packages/runtime-openshell/src/policy.ts +155 -0
  194. package/packages/runtime-openshell/src/profiles.ts +181 -0
  195. package/packages/runtime-openshell/src/runtime.ts +475 -0
  196. package/packages/runtime-sdk/package.json +12 -0
  197. package/packages/runtime-sdk/src/index.ts +87 -0
  198. package/packages/sdk/package.json +12 -0
  199. package/packages/sdk/src/address.ts +25 -0
  200. package/packages/sdk/src/client.ts +553 -0
  201. package/packages/sdk/src/envelope.ts +108 -0
  202. package/packages/sdk/src/errors.ts +107 -0
  203. package/packages/sdk/src/index.ts +10 -0
  204. package/packages/sdk/src/sse.ts +70 -0
  205. package/packages/sdk/src/types.ts +517 -0
  206. package/packages/sdk/src/webhook.ts +102 -0
  207. package/packages/spoke-sdk/package.json +12 -0
  208. package/packages/spoke-sdk/src/activity.ts +3 -0
  209. package/packages/spoke-sdk/src/connection.ts +295 -0
  210. package/packages/spoke-sdk/src/delivery.ts +119 -0
  211. package/packages/spoke-sdk/src/enroll.ts +101 -0
  212. package/packages/spoke-sdk/src/harness-hello.ts +79 -0
  213. package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
  214. package/packages/spoke-sdk/src/index.ts +12 -0
  215. package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
  216. package/packages/spoke-sdk/src/prompt-source.ts +8 -0
  217. package/packages/spoke-sdk/src/session-ids.ts +49 -0
  218. package/packages/spoke-sdk/src/session-tokens.ts +78 -0
  219. package/scripts/hub-claude-code.service +34 -0
  220. package/scripts/hub-codex-spoke.service +34 -0
  221. package/scripts/hub-node.service +35 -0
  222. package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
  223. package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
  224. package/scripts/io.github.huynle.hub-node.plist +52 -0
  225. package/src/central/actions.ts +233 -0
  226. package/src/central/agent/acting-routes.ts +133 -0
  227. package/src/central/agent/acting-switch.ts +93 -0
  228. package/src/central/agent/acting.ts +599 -0
  229. package/src/central/agent/actor.ts +193 -0
  230. package/src/central/agent/app-tokens.ts +152 -0
  231. package/src/central/agent/archive-inactive.ts +66 -0
  232. package/src/central/agent/audit.ts +185 -0
  233. package/src/central/agent/decisions.ts +530 -0
  234. package/src/central/agent/delivery.ts +678 -0
  235. package/src/central/agent/directory-routes.ts +328 -0
  236. package/src/central/agent/directory.ts +473 -0
  237. package/src/central/agent/envelope.ts +51 -0
  238. package/src/central/agent/errors.ts +74 -0
  239. package/src/central/agent/escalation.ts +219 -0
  240. package/src/central/agent/event-cursor.ts +49 -0
  241. package/src/central/agent/event-writers.ts +181 -0
  242. package/src/central/agent/events-routes.ts +148 -0
  243. package/src/central/agent/events.ts +277 -0
  244. package/src/central/agent/grant-requests.ts +221 -0
  245. package/src/central/agent/grant-routes.ts +70 -0
  246. package/src/central/agent/grants.ts +235 -0
  247. package/src/central/agent/launch-engine.ts +1025 -0
  248. package/src/central/agent/launch-routes.ts +252 -0
  249. package/src/central/agent/launch-spec.ts +289 -0
  250. package/src/central/agent/launch-store.ts +264 -0
  251. package/src/central/agent/launch-tokens.ts +206 -0
  252. package/src/central/agent/message-limits.ts +123 -0
  253. package/src/central/agent/message-routes.ts +385 -0
  254. package/src/central/agent/messages.ts +442 -0
  255. package/src/central/agent/names.ts +86 -0
  256. package/src/central/agent/placement.ts +419 -0
  257. package/src/central/agent/policy.ts +378 -0
  258. package/src/central/agent/read-routes.ts +385 -0
  259. package/src/central/agent/routes.ts +281 -0
  260. package/src/central/agent/session-tokens.ts +227 -0
  261. package/src/central/agent/versioned.ts +26 -0
  262. package/src/central/agent/watch.ts +112 -0
  263. package/src/central/agent/webhook-routes.ts +86 -0
  264. package/src/central/agent/webhooks.ts +493 -0
  265. package/src/central/agent-data.ts +177 -0
  266. package/src/central/auth.ts +350 -121
  267. package/src/central/backend/file-state.ts +240 -0
  268. package/src/central/backend/index.ts +116 -0
  269. package/src/central/backend/memory-bus.ts +93 -0
  270. package/src/central/backend/memory-catalog.ts +90 -0
  271. package/src/central/backend/memory-counters.ts +102 -0
  272. package/src/central/backend/memory-events.ts +45 -0
  273. package/src/central/backend/memory-kv.ts +38 -0
  274. package/src/central/backend/memory-state.ts +77 -0
  275. package/src/central/backend/postgres-migrate.ts +100 -0
  276. package/src/central/backend/postgres-state.ts +197 -0
  277. package/src/central/backend/types.ts +246 -0
  278. package/src/central/backend/valkey-bus.ts +425 -0
  279. package/src/central/backend/valkey-catalog.ts +190 -0
  280. package/src/central/backend/valkey-client.ts +36 -0
  281. package/src/central/backend/valkey-counters.ts +38 -0
  282. package/src/central/backend/valkey-events.ts +168 -0
  283. package/src/central/backend/valkey-kv.ts +54 -0
  284. package/src/central/backend/valkey-locks.ts +92 -0
  285. package/src/central/cluster/directory.ts +141 -0
  286. package/src/central/cluster/feed.ts +84 -0
  287. package/src/central/cluster/leader.ts +232 -0
  288. package/src/central/cluster/live.ts +151 -0
  289. package/src/central/cluster/recommend.ts +59 -0
  290. package/src/central/cluster/rpc.ts +150 -0
  291. package/src/central/cluster/view.ts +232 -0
  292. package/src/central/config.ts +197 -3
  293. package/src/central/done.ts +96 -0
  294. package/src/central/events-log.ts +100 -0
  295. package/src/central/http.ts +1149 -170
  296. package/src/central/import.ts +318 -0
  297. package/src/central/main.ts +211 -12
  298. package/src/central/mcp-http.ts +116 -0
  299. package/src/central/nodes.ts +314 -0
  300. package/src/central/oidc.ts +34 -14
  301. package/src/central/push-target.ts +118 -18
  302. package/src/central/push.ts +256 -82
  303. package/src/central/registry.ts +902 -92
  304. package/src/central/say-stream.ts +128 -0
  305. package/src/central/session-token-push.ts +233 -0
  306. package/src/central/speech/openai.ts +21 -14
  307. package/src/central/speech/openrouter.ts +27 -10
  308. package/src/central/speech/provider.ts +54 -0
  309. package/src/central/speech/service.ts +22 -7
  310. package/src/central/spoke-ws.ts +522 -44
  311. package/src/central/store-shared.ts +518 -0
  312. package/src/central/store.ts +625 -189
  313. package/src/central/token-keys.ts +165 -0
  314. package/src/config.ts +118 -7
  315. package/src/index.ts +195 -43
  316. package/src/protocol.ts +3 -650
  317. package/src/rpc.ts +27 -2
  318. package/src/spoke/client.ts +3 -272
  319. package/src/spoke/conversation.ts +16 -12
  320. package/src/spoke/deliver.ts +75 -0
  321. package/src/spoke/enroll.ts +2 -79
  322. package/src/spoke/env-inject.ts +160 -0
  323. package/src/spoke/hub-rpc.ts +115 -0
  324. package/src/spoke/hub-tool.ts +409 -0
  325. package/src/spoke/launch-source.ts +22 -0
  326. package/src/spoke/launchers.ts +174 -0
  327. package/src/spoke/locations.ts +3 -2
  328. package/src/spoke/node-roots.ts +61 -0
  329. package/src/spoke/prompt-refs.ts +116 -0
  330. package/src/spoke/session-tokens.ts +2 -0
  331. package/src/spoke/spoke.ts +465 -28
  332. package/src/spoke/taskboard.ts +243 -0
  333. package/src/spoke/timeline.ts +184 -6
  334. package/src/spoke/tracker.ts +79 -4
  335. package/src/status.ts +203 -10
  336. package/src/tui-commands.ts +13 -6
  337. package/src/tui-messaging.ts +93 -0
  338. package/src/tui.ts +15 -0
  339. package/web/dist/apple-touch-icon.png +0 -0
  340. package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
  341. package/web/dist/assets/index-DWnMhXk9.js +20 -0
  342. package/web/dist/assets/index-DmUXEnw9.css +1 -0
  343. package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
  344. package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
  345. package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
  346. package/web/dist/favicon.ico +0 -0
  347. package/web/dist/icon-192.png +0 -0
  348. package/web/dist/icon-512.png +0 -0
  349. package/web/dist/icon-maskable-512.png +0 -0
  350. package/web/dist/icon.svg +5 -5
  351. package/web/dist/index.html +17 -4
  352. package/web/dist/manifest.webmanifest +2 -2
  353. package/web/dist/sw.js +79 -15
  354. package/web/dist/assets/index-DClBvfLL.css +0 -1
  355. package/web/dist/assets/index-Djo_yF0a.js +0 -12
@@ -5,7 +5,9 @@
5
5
  import { createHash, randomBytes, timingSafeEqual } from "node:crypto"
6
6
  import { accessSync, chmodSync, constants as fsConstants, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
7
7
  import path from "node:path"
8
+ import { type AgentData, mergeAgentData, normaliseAgentData, validEpoch } from "./agent-data"
8
9
  import type { StoredSpeech } from "./speech/settings"
10
+ import { pushKey, SharedRows, StoreTx, type SharedStoreOptions } from "./store-shared"
9
11
  import type { ModelSelection, SessionModelPrefs } from "../protocol"
10
12
 
11
13
  /** Owner of everything in pairing mode, and of records from before 0.3.0. */
@@ -41,6 +43,10 @@ export interface UserRecord {
41
43
  reads?: Record<string, number>
42
44
  /** Sessions whose notifications the user muted, oldest first. */
43
45
  mutedSessions?: string[]
46
+ /** Agent control plane data (names, roles, sessions, app tokens); see agent-data.ts. */
47
+ agent?: AgentData
48
+ /** Bumped to invalidate every session token minted for this user (absent = 0). */
49
+ tokenEpoch?: number
44
50
  }
45
51
 
46
52
  /** Workspace keys are the phone's group keys: `herdr:<workspaceID>` or a folder path. */
@@ -52,6 +58,9 @@ export interface ViewPrefs {
52
58
  }
53
59
 
54
60
  export const VIEW_PREF_KEYS = ["hiddenSessions", "hiddenWorkspaces", "pinnedSessions", "pinnedWorkspaces"] as const
61
+ /** Archived sessions accumulate for longer than the other view-preference lists. */
62
+ export const VIEW_PREF_MAX_ARCHIVED = 5000
63
+ export const VIEW_PREF_MAX_PINNED = 500
55
64
 
56
65
  export interface AuditEntry {
57
66
  at: number
@@ -70,9 +79,13 @@ export interface MachineRecord {
70
79
  lastSeen: number
71
80
  revoked?: boolean
72
81
  ownerID: string
82
+ /** Bumped to invalidate every session token minted for this machine (absent = 0). */
83
+ tokenEpoch?: number
73
84
  }
74
85
 
75
- /** Which notifications a device wants. */
86
+ /**
87
+ * Which notifications a device wants: one toggle per kind (see docs/notifications.md for what triggers each).
88
+ */
76
89
  export interface PushPrefs {
77
90
  /** A session sent to the phone from the terminal. */
78
91
  handoff: boolean
@@ -80,29 +93,90 @@ export interface PushPrefs {
80
93
  question: boolean
81
94
  /** A session waits for a permission. */
82
95
  permission: boolean
83
- /** A root session's run succeeded. */
84
- finished: boolean
85
- /** A root session's run failed. */
96
+ /** All done: a root session and its whole tree settled (nothing runs or waits), once per run. */
97
+ done: boolean
98
+ /** A root session's run failed (provider or tool error; not a user stop). */
86
99
  failed: boolean
100
+ /** Every root turn finished (0.7's "finished": noisy with background subagents). */
101
+ turn: boolean
102
+ /** A root run stopped without the user asking (shutdown, inactivity). */
103
+ stopped: boolean
104
+ /** The provider is retrying a root session's request (once per retry streak). */
105
+ retry: boolean
106
+ /** A subagent session finished. */
107
+ subagentDone: boolean
108
+ /** A subagent session failed. */
109
+ subagentFailed: boolean
110
+ /** A shell job or terminal exited 0. */
111
+ jobDone: boolean
112
+ /** A shell job or terminal exited non-zero, was killed or timed out. */
113
+ jobFailed: boolean
114
+ /** A tool call that ran at least 30 s ended (subagent calls excluded). */
115
+ longTool: boolean
87
116
  /** Conversation mode replies while no device is watching the session. */
88
117
  say: boolean
118
+ /** Activity updates: a collapsing digest per session (subagents, long tools, jobs ending, idle), at most one per interval. */
119
+ activity: boolean
120
+ /** A session (or app) asks you to decide something (`hub ask @me`): a Decision waits for your answer. */
121
+ decision: boolean
122
+ /** Agent messaging needs you: a message to @me, an ask left unanswered after every re-wake, or a message loop. */
123
+ message: boolean
89
124
  }
90
125
 
91
- export const PUSH_PREF_KEYS = ["handoff", "question", "permission", "finished", "failed", "say"] as const satisfies readonly (keyof PushPrefs)[]
92
-
93
- /** Defaults as in 0.5.0: hand-offs and anything blocking on, finished (which covered failures) off. */
126
+ export const PUSH_PREF_KEYS = [
127
+ "handoff",
128
+ "question",
129
+ "permission",
130
+ "done",
131
+ "failed",
132
+ "turn",
133
+ "stopped",
134
+ "retry",
135
+ "subagentDone",
136
+ "subagentFailed",
137
+ "jobDone",
138
+ "jobFailed",
139
+ "longTool",
140
+ "say",
141
+ "activity",
142
+ "decision",
143
+ "message",
144
+ ] as const satisfies readonly (keyof PushPrefs)[]
145
+
146
+ /** Defaults: anything that needs you, all done, failed, hand-offs and spoken replies; the finer kinds are opt-in. */
94
147
  export const DEFAULT_PUSH_PREFS: Readonly<PushPrefs> = Object.freeze({
95
148
  handoff: true,
96
149
  question: true,
97
150
  permission: true,
98
- finished: false,
99
- failed: false,
151
+ done: true,
152
+ failed: true,
153
+ turn: false,
154
+ stopped: false,
155
+ retry: false,
156
+ subagentDone: false,
157
+ subagentFailed: false,
158
+ jobDone: false,
159
+ jobFailed: false,
160
+ longTool: false,
100
161
  say: true,
162
+ // Opt-in per device (Settings → Notifications → Activity updates).
163
+ activity: false,
164
+ decision: true,
165
+ message: true,
101
166
  })
102
167
 
103
- /** 0.5.0's prefs: one toggle for questions and permissions, one for every finished run. */
168
+ /** Default and allowed range of a device's activity update interval, in minutes. */
169
+ export const ACTIVITY_INTERVAL_MINUTES = 5
170
+ export const ACTIVITY_INTERVAL_MIN = 1
171
+ export const ACTIVITY_INTERVAL_MAX = 60
172
+
173
+ /**
174
+ * Older prefs: 0.5.0's one toggle for questions and permissions (`blocked`), and up to 0.7 `finished` (every root
175
+ * turn, which fired while background subagents still ran); it now means all done (`done`).
176
+ */
104
177
  export interface LegacyPushPrefs {
105
178
  blocked?: boolean
179
+ finished?: boolean
106
180
  }
107
181
 
108
182
  /** A device's quiet hours: local "HH:MM" times in an IANA time zone; start > end spans midnight. */
@@ -137,7 +211,7 @@ export function quietHoursInput(value: unknown): QuietHours | undefined {
137
211
  /** Most sessions a user can mute (oldest are forgotten first). */
138
212
  export const MUTED_SESSIONS_LIMIT = 500
139
213
 
140
- /** Current prefs from a stored (possibly 0.5.0) record. */
214
+ /** Current prefs from a stored (possibly 0.5.0 or 0.7) record. */
141
215
  function normalizePushPrefs(stored: (Partial<PushPrefs> & LegacyPushPrefs) | undefined): PushPrefs {
142
216
  const out: PushPrefs = { ...DEFAULT_PUSH_PREFS }
143
217
  if (!stored) return out
@@ -147,10 +221,14 @@ function normalizePushPrefs(stored: (Partial<PushPrefs> & LegacyPushPrefs) | und
147
221
  // 0.5.0's "finished" covered failed runs too.
148
222
  if (typeof stored.finished === "boolean" && typeof stored.failed !== "boolean") out.failed = stored.finished
149
223
  }
224
+ if (typeof stored.finished === "boolean" && typeof stored.done !== "boolean") out.done = stored.finished
150
225
  for (const key of PUSH_PREF_KEYS) if (typeof stored[key] === "boolean") out[key] = stored[key]
151
226
  return out
152
227
  }
153
228
 
229
+ /** True when stored prefs use a legacy key (rewritten on open). */
230
+ const legacyPushPrefs = (stored: object | undefined): boolean => !!stored && ("blocked" in stored || "finished" in stored)
231
+
154
232
  export interface DeviceRecord {
155
233
  id: string
156
234
  name: string
@@ -159,7 +237,11 @@ export interface DeviceRecord {
159
237
  lastSeen: number
160
238
  pushPrefs?: Partial<PushPrefs> & LegacyPushPrefs
161
239
  quietHours?: QuietHours
240
+ /** Minutes between two activity updates of one session (ACTIVITY_INTERVAL_MINUTES when absent). */
241
+ activityMinutes?: number
162
242
  ownerID: string
243
+ /** Public key a native app registered at link time (PEM or base64), for later strong approvals. */
244
+ publicKey?: string
163
245
  }
164
246
 
165
247
  export interface VapidKeys {
@@ -189,6 +271,8 @@ export interface StateData {
189
271
  users: UserRecord[]
190
272
  /** Newest last, at most AUDIT_LIMIT. */
191
273
  audit: AuditEntry[]
274
+ /** The admin's hub-wide kill switch: every agent action is paused for every user (reading and messaging still work). */
275
+ agentsActingPaused?: true
192
276
  }
193
277
 
194
278
  export interface StoreOptions {
@@ -255,39 +339,113 @@ function findByToken<T extends { tokenHash: string }>(records: readonly T[], tok
255
339
  return found
256
340
  }
257
341
 
342
+ /** Shared persistence (cluster mode): see ./store-shared. */
343
+ export { HUB_ROWS, STORE_CACHE_TTL_MS, STORE_CHANGED_TOPIC, StoreConflictError, type SharedStoreOptions } from "./store-shared"
344
+
345
+ /** Records whose rows a security re-check reads again (Store.recheck). */
346
+ export interface RecheckRefs {
347
+ readonly users?: readonly (string | undefined)[]
348
+ readonly machines?: readonly (string | undefined)[]
349
+ readonly devices?: readonly (string | undefined)[]
350
+ }
351
+
352
+ /**
353
+ * The hub's records. Reads are synchronous (from memory); mutations are async. Two persistences:
354
+ * - file (Store.open): state.json, written synchronously inside every mutation (a mutation's change is applied
355
+ * and saved before its promise is returned), for the single-process hub;
356
+ * - shared (Store.shared): rows in a StateStore with a per-pod cache, for cluster mode (see ./store-shared).
357
+ * Token lookups and recheck() go to the database, so a revocation on another pod applies at once.
358
+ */
258
359
  export class Store {
259
360
  private constructor(
260
- private readonly file: string,
261
- private readonly state: StateData,
361
+ private readonly file: string | undefined,
362
+ private state: StateData,
262
363
  private readonly now: () => number,
364
+ private readonly shared?: SharedRows,
263
365
  ) {}
264
366
 
265
367
  static open(dir: string, now: () => number = Date.now, options: StoreOptions = {}): Store {
266
368
  prepareStateDir(dir, options)
267
369
  const file = path.join(dir, "state.json")
268
- let state: StateData = { machines: [], devices: [], push: [], users: [], audit: [] }
269
- if (existsSync(file)) {
270
- try {
271
- const raw = JSON.parse(readFileSync(file, "utf8")) as Partial<StateData>
272
- state = {
273
- machines: Array.isArray(raw.machines) ? raw.machines : [],
274
- devices: Array.isArray(raw.devices) ? raw.devices : [],
275
- ...(raw.vapid ? { vapid: raw.vapid } : {}),
276
- push: Array.isArray(raw.push) ? raw.push : [],
277
- ...(isRecord(raw.speech) ? { speech: raw.speech } : {}),
278
- users: Array.isArray(raw.users) ? raw.users : [],
279
- audit: Array.isArray(raw.audit) ? raw.audit : [],
280
- }
281
- } catch (cause) {
282
- throw new Error(`Cannot read ${file}: ${cause instanceof Error ? cause.message : cause}. Fix or move it away.`)
283
- }
370
+ let state = Store.read(dir, now, options)
371
+ if (!state) {
372
+ state = { machines: [], devices: [], push: [], users: [], audit: [] }
373
+ migrate(state, now(), options)
284
374
  }
285
- migrate(state, now(), options)
286
375
  const store = new Store(file, state, now)
287
376
  store.save()
288
377
  return store
289
378
  }
290
379
 
380
+ /**
381
+ * The records in <dir>/state.json, upgraded from older formats, without writing anything (`hub-server import`);
382
+ * undefined when there is no state.json.
383
+ */
384
+ static read(dir: string, now: () => number = Date.now, options: Pick<StoreOptions, "mode"> = {}): StateData | undefined {
385
+ const file = path.join(dir, "state.json")
386
+ if (!existsSync(file)) return undefined
387
+ let state: StateData
388
+ try {
389
+ const raw = JSON.parse(readFileSync(file, "utf8")) as Partial<StateData>
390
+ state = {
391
+ machines: Array.isArray(raw.machines) ? raw.machines : [],
392
+ devices: Array.isArray(raw.devices) ? raw.devices : [],
393
+ ...(raw.vapid ? { vapid: raw.vapid } : {}),
394
+ push: Array.isArray(raw.push) ? raw.push : [],
395
+ ...(isRecord(raw.speech) ? { speech: raw.speech } : {}),
396
+ users: Array.isArray(raw.users) ? raw.users : [],
397
+ audit: Array.isArray(raw.audit) ? raw.audit : [],
398
+ ...(raw.agentsActingPaused === true ? { agentsActingPaused: true as const } : {}),
399
+ }
400
+ } catch (cause) {
401
+ throw new Error(`Cannot read ${file}: ${cause instanceof Error ? cause.message : cause}. Fix or move it away.`)
402
+ }
403
+ migrate(state, now(), options)
404
+ return state
405
+ }
406
+
407
+ /**
408
+ * A Store on shared backends (cluster mode). Nothing is read until ready() (mutations and async lookups call
409
+ * it; the hub awaits it before serving). No data migration runs here: the schema and imports are
410
+ * `hub-server migrate` / `import`'s job.
411
+ */
412
+ static shared(options: SharedStoreOptions): Store {
413
+ let store: Store | undefined = undefined
414
+ const rows = new SharedRows(options, {
415
+ get: () => store!.state,
416
+ set: (state) => {
417
+ store!.state = state
418
+ },
419
+ })
420
+ store = new Store(undefined, SharedRows.emptyState(), options.now ?? Date.now, rows)
421
+ return store
422
+ }
423
+
424
+ /** Resolves once the records are loaded (at once for the file Store). */
425
+ ready(): Promise<void> {
426
+ return this.shared ? this.shared.ready() : Promise.resolve()
427
+ }
428
+
429
+ /** Stops listening for other pods' changes (shared); nothing to do for the file Store. */
430
+ async close(): Promise<void> {
431
+ await this.shared?.close()
432
+ }
433
+
434
+ /**
435
+ * Reads these records again from the database (shared Store; a no-op for the file Store), so the synchronous
436
+ * reads after it (revoked, epochs, disabled, owner) see other pods' latest writes.
437
+ */
438
+ async recheck(refs: RecheckRefs): Promise<void> {
439
+ const shared = this.shared
440
+ if (!shared) return
441
+ await shared.ready()
442
+ const all: Promise<void>[] = []
443
+ for (const collection of ["users", "machines", "devices"] as const) {
444
+ for (const key of new Set(refs[collection] ?? [])) if (key) all.push(shared.refresh({ collection, key }))
445
+ }
446
+ await Promise.all(all)
447
+ }
448
+
291
449
  data(): Readonly<StateData> {
292
450
  return this.state
293
451
  }
@@ -300,55 +458,92 @@ export class Store {
300
458
  return this.state.devices
301
459
  }
302
460
 
303
- addMachine(name: string, ownerID: string = LOCAL_USER): { machine: MachineRecord; token: string } {
461
+ addMachine(name: string, ownerID: string = LOCAL_USER): Promise<{ machine: MachineRecord; token: string }> {
304
462
  const token = newToken()
305
463
  const t = this.now()
306
464
  const machine: MachineRecord = { id: newID("mch"), name, tokenHash: hashToken(token), created: t, lastSeen: t, ownerID }
307
- this.state.machines.push(machine)
308
- this.save()
309
- return { machine, token }
465
+ return this.mutate((tx) => {
466
+ if (!this.state.machines.some((m) => m.id === machine.id)) this.state.machines.push(machine)
467
+ tx.touch("machines", machine.id)
468
+ tx.touch("machine-tokens", machine.tokenHash)
469
+ return { machine, token }
470
+ })
471
+ }
472
+
473
+ /** Non-revoked machine for a bearer token (the shared Store re-reads it from the database). */
474
+ async findMachine(token: string): Promise<MachineRecord | undefined> {
475
+ if (this.shared && token) await this.recheckToken("machine", token)
476
+ return this.cachedMachine(token)
310
477
  }
311
478
 
312
- /** Non-revoked machine for a bearer token. */
313
- findMachine(token: string): MachineRecord | undefined {
479
+ /** findMachine from memory only (the file Store; or a shared Store's cache, possibly stale). */
480
+ cachedMachine(token: string): MachineRecord | undefined {
314
481
  return findByToken(
315
482
  this.state.machines.filter((machine) => !machine.revoked),
316
483
  token,
317
484
  )
318
485
  }
319
486
 
320
- revokeMachine(id: string): boolean {
321
- const machine = this.state.machines.find((m) => m.id === id)
322
- if (!machine || machine.revoked) return false
323
- machine.revoked = true
324
- this.save()
325
- return true
487
+ revokeMachine(id: string): Promise<boolean> {
488
+ return this.mutate((tx) => {
489
+ const machine = this.state.machines.find((m) => m.id === id)
490
+ if (!machine || machine.revoked) return false
491
+ machine.revoked = true
492
+ tx.touch("machines", id)
493
+ return true
494
+ })
326
495
  }
327
496
 
328
- touchMachine(id: string): void {
329
- this.touch(this.state.machines.find((m) => m.id === id))
497
+ /** Records that the machine was seen (persisted at most once a minute; never rejects). */
498
+ touchMachine(id: string): Promise<void> {
499
+ return this.touch("machines", id, () => this.state.machines.find((m) => m.id === id))
330
500
  }
331
501
 
332
- addDevice(name: string, ownerID: string = LOCAL_USER): { device: DeviceRecord; token: string } {
502
+ addDevice(name: string, ownerID: string = LOCAL_USER, extra: { publicKey?: string } = {}): Promise<{ device: DeviceRecord; token: string }> {
333
503
  const token = newToken()
334
504
  const t = this.now()
335
505
  const device: DeviceRecord = { id: newID("dev"), name, tokenHash: hashToken(token), created: t, lastSeen: t, ownerID }
336
- this.state.devices.push(device)
337
- this.save()
338
- return { device, token }
506
+ if (extra.publicKey) device.publicKey = extra.publicKey
507
+ return this.mutate((tx) => {
508
+ if (!this.state.devices.some((d) => d.id === device.id)) this.state.devices.push(device)
509
+ tx.touch("devices", device.id)
510
+ tx.touch("device-tokens", device.tokenHash)
511
+ return { device, token }
512
+ })
513
+ }
514
+
515
+ /** The device for a bearer token (the shared Store re-reads it from the database). */
516
+ async findDevice(token: string): Promise<DeviceRecord | undefined> {
517
+ if (this.shared && token) await this.recheckToken("device", token)
518
+ return this.cachedDevice(token)
339
519
  }
340
520
 
341
- findDevice(token: string): DeviceRecord | undefined {
521
+ /** findDevice from memory only (the file Store; or a shared Store's cache, possibly stale). */
522
+ cachedDevice(token: string): DeviceRecord | undefined {
342
523
  return findByToken(this.state.devices, token)
343
524
  }
344
525
 
345
- removeDevice(id: string): boolean {
346
- const before = this.state.devices.length
347
- this.state.devices = this.state.devices.filter((d) => d.id !== id)
348
- if (this.state.devices.length === before) return false
349
- this.state.push = this.state.push.filter((p) => p.deviceID !== id)
350
- this.save()
351
- return true
526
+ /** Re-reads the record of a token: found in the cache by hash, else through its token-hash index row. */
527
+ private async recheckToken(kind: "machine" | "device", token: string): Promise<void> {
528
+ const shared = this.shared!
529
+ await shared.ready()
530
+ const hash = hashToken(token)
531
+ const records: readonly { id: string; tokenHash: string }[] = kind === "machine" ? this.state.machines : this.state.devices
532
+ const id = findByToken(records, token)?.id ?? (await shared.indexed(kind === "machine" ? "machine-tokens" : "device-tokens", hash))
533
+ if (id) await shared.refresh({ collection: kind === "machine" ? "machines" : "devices", key: id })
534
+ }
535
+
536
+ removeDevice(id: string): Promise<boolean> {
537
+ return this.mutate((tx) => {
538
+ const device = this.state.devices.find((d) => d.id === id)
539
+ if (!device) return false
540
+ this.state.devices = this.state.devices.filter((d) => d.id !== id)
541
+ tx.touch("devices", id)
542
+ tx.touch("device-tokens", device.tokenHash)
543
+ for (const p of this.state.push) if (p.deviceID === id) tx.touch("push", pushKey(p.endpoint))
544
+ this.state.push = this.state.push.filter((p) => p.deviceID !== id)
545
+ return true
546
+ })
352
547
  }
353
548
 
354
549
  // ---------- web push ----------
@@ -357,9 +552,17 @@ export class Store {
357
552
  return this.state.vapid
358
553
  }
359
554
 
360
- setVapid(keys: VapidKeys): void {
361
- this.state.vapid = { publicKey: keys.publicKey, privateKey: keys.privateKey }
362
- this.save()
555
+ /**
556
+ * Stores the hub's VAPID keys unless some are stored already (by another pod too: the first write wins), and
557
+ * returns the stored pair.
558
+ */
559
+ setVapid(keys: VapidKeys): Promise<VapidKeys> {
560
+ return this.mutate((tx) => {
561
+ if (this.state.vapid) return this.state.vapid
562
+ this.state.vapid = { publicKey: keys.publicKey, privateKey: keys.privateKey }
563
+ tx.touch("settings", "vapid")
564
+ return this.state.vapid
565
+ })
363
566
  }
364
567
 
365
568
  pushSubscriptions(): readonly PushSubscriptionRecord[] {
@@ -367,27 +570,29 @@ export class Store {
367
570
  }
368
571
 
369
572
  /** Adds or replaces (by endpoint) a subscription; an endpoint belongs to one device. */
370
- addPushSubscription(deviceID: string, target: PushTarget): PushSubscriptionRecord {
573
+ addPushSubscription(deviceID: string, target: PushTarget): Promise<PushSubscriptionRecord> {
371
574
  const record: PushSubscriptionRecord = {
372
575
  deviceID,
373
576
  endpoint: target.endpoint,
374
577
  keys: { p256dh: target.keys.p256dh, auth: target.keys.auth },
375
578
  created: this.now(),
376
579
  }
377
- const index = this.state.push.findIndex((p) => p.endpoint === target.endpoint)
378
- if (index >= 0) this.state.push[index] = record
379
- else this.state.push.push(record)
380
- this.save()
381
- return record
580
+ return this.mutate((tx) => {
581
+ const index = this.state.push.findIndex((p) => p.endpoint === target.endpoint)
582
+ if (index >= 0) this.state.push[index] = record
583
+ else this.state.push.push(record)
584
+ tx.touch("push", pushKey(record.endpoint))
585
+ return record
586
+ })
382
587
  }
383
588
 
384
589
  /** Removes a device's own subscription. */
385
- removePushSubscription(deviceID: string, endpoint: string): boolean {
590
+ removePushSubscription(deviceID: string, endpoint: string): Promise<boolean> {
386
591
  return this.dropPush((p) => p.deviceID === deviceID && p.endpoint === endpoint)
387
592
  }
388
593
 
389
594
  /** Removes a subscription the push service reported as gone. */
390
- dropPushSubscription(endpoint: string): boolean {
595
+ dropPushSubscription(endpoint: string): Promise<boolean> {
391
596
  return this.dropPush((p) => p.endpoint === endpoint)
392
597
  }
393
598
 
@@ -396,16 +601,35 @@ export class Store {
396
601
  return normalizePushPrefs(device?.pushPrefs)
397
602
  }
398
603
 
399
- /** Applies the boolean toggles given; a legacy `blocked` sets question and permission. */
400
- setPushPrefs(deviceID: string, prefs: Partial<PushPrefs> & LegacyPushPrefs): PushPrefs {
401
- const device = this.state.devices.find((d) => d.id === deviceID)
402
- if (!device) return { ...DEFAULT_PUSH_PREFS }
403
- const next = this.pushPrefs(deviceID)
404
- if (typeof prefs.blocked === "boolean") next.question = next.permission = prefs.blocked
405
- for (const key of PUSH_PREF_KEYS) if (typeof prefs[key] === "boolean") next[key] = prefs[key]
406
- device.pushPrefs = next
407
- this.save()
408
- return { ...next }
604
+ /** Applies the boolean toggles given; a legacy `blocked` sets question and permission, a legacy `finished` all done. */
605
+ setPushPrefs(deviceID: string, prefs: Partial<PushPrefs> & LegacyPushPrefs): Promise<PushPrefs> {
606
+ return this.mutate((tx) => {
607
+ const device = this.state.devices.find((d) => d.id === deviceID)
608
+ if (!device) return { ...DEFAULT_PUSH_PREFS }
609
+ const next = this.pushPrefs(deviceID)
610
+ if (typeof prefs.blocked === "boolean") next.question = next.permission = prefs.blocked
611
+ if (typeof prefs.finished === "boolean") next.done = prefs.finished
612
+ for (const key of PUSH_PREF_KEYS) if (typeof prefs[key] === "boolean") next[key] = prefs[key]
613
+ device.pushPrefs = next
614
+ tx.touch("devices", deviceID)
615
+ return { ...next }
616
+ })
617
+ }
618
+
619
+ /** Minutes between two activity updates of one session on this device. */
620
+ activityMinutes(deviceID: string): number {
621
+ return this.state.devices.find((d) => d.id === deviceID)?.activityMinutes ?? ACTIVITY_INTERVAL_MINUTES
622
+ }
623
+
624
+ /** Sets a device's activity update interval (clamped to 1–60 whole minutes). */
625
+ setActivityMinutes(deviceID: string, minutes: number): Promise<number> {
626
+ return this.mutate((tx) => {
627
+ const device = this.state.devices.find((d) => d.id === deviceID)
628
+ if (!device) return ACTIVITY_INTERVAL_MINUTES
629
+ device.activityMinutes = Math.min(ACTIVITY_INTERVAL_MAX, Math.max(ACTIVITY_INTERVAL_MIN, Math.round(minutes)))
630
+ tx.touch("devices", deviceID)
631
+ return device.activityMinutes
632
+ })
409
633
  }
410
634
 
411
635
  quietHours(deviceID: string): QuietHours | undefined {
@@ -414,36 +638,42 @@ export class Store {
414
638
  }
415
639
 
416
640
  /** Sets (validated by the caller) or clears (null) a device's quiet hours. */
417
- setQuietHours(deviceID: string, quiet: QuietHours | null): QuietHours | undefined {
418
- const device = this.state.devices.find((d) => d.id === deviceID)
419
- if (!device) return undefined
420
- if (quiet) device.quietHours = { start: quiet.start, end: quiet.end, timeZone: quiet.timeZone }
421
- else delete device.quietHours
422
- this.save()
423
- return this.quietHours(deviceID)
641
+ setQuietHours(deviceID: string, quiet: QuietHours | null): Promise<QuietHours | undefined> {
642
+ return this.mutate((tx) => {
643
+ const device = this.state.devices.find((d) => d.id === deviceID)
644
+ if (!device) return undefined
645
+ if (quiet) device.quietHours = { start: quiet.start, end: quiet.end, timeZone: quiet.timeZone }
646
+ else delete device.quietHours
647
+ tx.touch("devices", deviceID)
648
+ return this.quietHours(deviceID)
649
+ })
424
650
  }
425
651
 
426
652
  mutedSessions(userID: string): string[] {
427
653
  return [...(this.user(userID)?.mutedSessions ?? [])]
428
654
  }
429
655
 
430
- setSessionMuted(userID: string, sessionID: string, muted: boolean): string[] {
431
- const user = this.user(userID)
432
- if (!user) return []
433
- const list = (user.mutedSessions ?? []).filter((id) => id !== sessionID)
434
- if (muted) list.push(sessionID)
435
- while (list.length > MUTED_SESSIONS_LIMIT) list.shift()
436
- user.mutedSessions = list
437
- this.save()
438
- return [...list]
656
+ setSessionMuted(userID: string, sessionID: string, muted: boolean): Promise<string[]> {
657
+ return this.mutate((tx) => {
658
+ const user = this.user(userID)
659
+ if (!user) return []
660
+ const list = (user.mutedSessions ?? []).filter((id) => id !== sessionID)
661
+ if (muted) list.push(sessionID)
662
+ while (list.length > MUTED_SESSIONS_LIMIT) list.shift()
663
+ user.mutedSessions = list
664
+ tx.touch("users", userID)
665
+ return [...list]
666
+ })
439
667
  }
440
668
 
441
- private dropPush(match: (record: PushSubscriptionRecord) => boolean): boolean {
442
- const before = this.state.push.length
443
- this.state.push = this.state.push.filter((p) => !match(p))
444
- if (this.state.push.length === before) return false
445
- this.save()
446
- return true
669
+ private dropPush(match: (record: PushSubscriptionRecord) => boolean): Promise<boolean> {
670
+ return this.mutate((tx) => {
671
+ const gone = this.state.push.filter(match)
672
+ if (!gone.length) return false
673
+ this.state.push = this.state.push.filter((p) => !match(p))
674
+ for (const p of gone) tx.touch("push", pushKey(p.endpoint))
675
+ return true
676
+ })
447
677
  }
448
678
 
449
679
  // ---------- speech settings ----------
@@ -453,9 +683,11 @@ export class Store {
453
683
  return structuredClone(this.state.speech ?? {})
454
684
  }
455
685
 
456
- setSpeechSettings(settings: StoredSpeech): void {
457
- this.state.speech = structuredClone(settings)
458
- this.save()
686
+ setSpeechSettings(settings: StoredSpeech): Promise<void> {
687
+ return this.mutate((tx) => {
688
+ this.state.speech = structuredClone(settings)
689
+ tx.touch("settings", "speech")
690
+ })
459
691
  }
460
692
 
461
693
  // ---------- users ----------
@@ -477,56 +709,167 @@ export class Store {
477
709
  * Creates the user for a (verified, lowercased) email, or updates its name and lastSeen. Role
478
710
  * "admin" promotes an existing user (admins come from the config); "user" never demotes.
479
711
  */
480
- upsertUser(input: { email: string; name?: string; role: Role }): UserRecord {
712
+ upsertUser(input: { email: string; name?: string; role: Role }): Promise<UserRecord> {
481
713
  const email = input.email.trim().toLowerCase()
482
- const t = this.now()
483
- let user = this.userByEmail(email)
484
- if (!user) {
485
- user = { id: newUserID(), email, ...(input.name ? { name: input.name } : {}), role: input.role, created: t, lastSeen: t }
486
- this.state.users.push(user)
487
- } else {
488
- if (input.name) user.name = input.name
489
- if (input.role === "admin") user.role = "admin"
490
- user.lastSeen = t
491
- }
492
- this.save()
493
- return user
714
+ const id = newUserID()
715
+ return this.mutate((tx) => {
716
+ const t = this.now()
717
+ let user = this.userByEmail(email)
718
+ if (!user) {
719
+ user = { id, email, ...(input.name ? { name: input.name } : {}), role: input.role, created: t, lastSeen: t }
720
+ this.state.users.push(user)
721
+ tx.touch("user-emails", email)
722
+ } else {
723
+ if (input.name) user.name = input.name
724
+ if (input.role === "admin") user.role = "admin"
725
+ user.lastSeen = t
726
+ }
727
+ tx.touch("users", user.id)
728
+ return user
729
+ })
494
730
  }
495
731
 
496
- setUser(id: string, change: { role?: Role; disabled?: boolean }): UserRecord | undefined {
497
- const user = this.user(id)
498
- if (!user) return undefined
499
- if (change.role) user.role = change.role
500
- if (change.disabled === true) user.disabled = true
501
- else if (change.disabled === false) delete user.disabled
502
- this.save()
503
- return user
732
+ setUser(id: string, change: { role?: Role; disabled?: boolean }): Promise<UserRecord | undefined> {
733
+ return this.mutate((tx) => {
734
+ const user = this.user(id)
735
+ if (!user) return undefined
736
+ if (change.role) user.role = change.role
737
+ if (change.disabled === true) user.disabled = true
738
+ else if (change.disabled === false) delete user.disabled
739
+ tx.touch("users", id)
740
+ return user
741
+ })
504
742
  }
505
743
 
506
- touchUser(id: string): void {
507
- this.touch(this.user(id))
744
+ /** Records that the user was seen (persisted at most once a minute; never rejects). */
745
+ touchUser(id: string): Promise<void> {
746
+ return this.touch("users", id, () => this.user(id))
508
747
  }
509
748
 
510
749
  /**
511
750
  * First admin sign-in in oidc mode: every record owned by "local" moves to the admin and "local" is
512
751
  * disabled. False (nothing changed) once done.
513
752
  */
514
- migrateLocal(adminID: string): boolean {
515
- const local = this.user(LOCAL_USER)
516
- if (!local || local.disabled || adminID === LOCAL_USER) return false
517
- for (const record of [...this.state.machines, ...this.state.devices]) if (record.ownerID === LOCAL_USER) record.ownerID = adminID
518
- local.disabled = true
519
- this.audit({ actor: "system", action: "migrate.local", target: adminID })
520
- return true
753
+ migrateLocal(adminID: string): Promise<boolean> {
754
+ return this.mutate((tx) => {
755
+ const local = this.user(LOCAL_USER)
756
+ if (!local || local.disabled || adminID === LOCAL_USER) return false
757
+ for (const machine of this.state.machines) {
758
+ if (machine.ownerID !== LOCAL_USER) continue
759
+ machine.ownerID = adminID
760
+ machine.tokenEpoch = (machine.tokenEpoch ?? 0) + 1
761
+ tx.touch("machines", machine.id)
762
+ }
763
+ for (const device of this.state.devices) {
764
+ if (device.ownerID !== LOCAL_USER) continue
765
+ device.ownerID = adminID
766
+ tx.touch("devices", device.id)
767
+ }
768
+ const admin = this.user(adminID)
769
+ if (local.agent && admin) {
770
+ admin.agent = mergeAgentData(admin.agent ?? {}, local.agent)
771
+ tx.touch("users", adminID)
772
+ }
773
+ delete local.agent
774
+ local.tokenEpoch = (local.tokenEpoch ?? 0) + 1
775
+ local.disabled = true
776
+ tx.touch("users", LOCAL_USER)
777
+ this.appendAudit(tx, { actor: "system", action: "migrate.local", target: adminID })
778
+ return true
779
+ })
780
+ }
781
+
782
+ // ---------- agent data and token epochs ----------
783
+
784
+ /** A copy of the user's agent data ({} when none). */
785
+ agentData(userID: string): AgentData {
786
+ return structuredClone(this.user(userID)?.agent ?? {})
787
+ }
788
+
789
+ /**
790
+ * Changes a user's agent data: `change` edits a copy in place or returns a replacement; the result is
791
+ * normalised and saved only when it differs. Undefined for an unknown user. On a shared Store, `change` runs
792
+ * again on fresh data when another pod changed the user meanwhile (so it may run more than once).
793
+ */
794
+ updateAgentData(userID: string, change: (data: AgentData) => AgentData | void): Promise<AgentData | undefined> {
795
+ return this.mutate((tx) => {
796
+ const user = this.user(userID)
797
+ if (!user) return undefined
798
+ const draft = this.agentData(userID)
799
+ const next = normaliseAgentData(change(draft) ?? draft) ?? {}
800
+ if (JSON.stringify(next) !== JSON.stringify(user.agent ?? {})) {
801
+ if (Object.keys(next).length) user.agent = next
802
+ else delete user.agent
803
+ tx.touch("users", userID)
804
+ }
805
+ return this.agentData(userID)
806
+ })
807
+ }
808
+
809
+ userEpoch(userID: string): number {
810
+ return this.user(userID)?.tokenEpoch ?? 0
811
+ }
812
+
813
+ machineEpoch(machineID: string): number {
814
+ return this.state.machines.find((m) => m.id === machineID)?.tokenEpoch ?? 0
815
+ }
816
+
817
+ /** Invalidates the user's session tokens; returns the new epoch (undefined for an unknown user). */
818
+ bumpUserEpoch(userID: string): Promise<number | undefined> {
819
+ return this.mutate((tx) => this.bumpEpoch(tx, "users", userID, this.user(userID)))
820
+ }
821
+
822
+ /** Invalidates the machine's session tokens; returns the new epoch (undefined for an unknown machine). */
823
+ bumpMachineEpoch(machineID: string): Promise<number | undefined> {
824
+ return this.mutate((tx) => this.bumpEpoch(tx, "machines", machineID, this.state.machines.find((m) => m.id === machineID)))
825
+ }
826
+
827
+ private bumpEpoch(tx: StoreTx, collection: "users" | "machines", id: string, record: { tokenEpoch?: number } | undefined): number | undefined {
828
+ if (!record) return undefined
829
+ record.tokenEpoch = (record.tokenEpoch ?? 0) + 1
830
+ tx.touch(collection, id)
831
+ return record.tokenEpoch
832
+ }
833
+
834
+ // ---------- hub-wide agent acting switch ----------
835
+
836
+ /** True while the admin has paused agent actions hub-wide. */
837
+ actingPaused(): boolean {
838
+ return this.state.agentsActingPaused === true
839
+ }
840
+
841
+ /** Pauses or resumes agent actions hub-wide; true when that changed something (saved only then). */
842
+ setActingPaused(paused: boolean): Promise<boolean> {
843
+ return this.mutate((tx) => {
844
+ if (paused === this.actingPaused()) return false
845
+ if (paused) this.state.agentsActingPaused = true
846
+ else delete this.state.agentsActingPaused
847
+ tx.touch("settings", "agentsActingPaused")
848
+ return true
849
+ })
521
850
  }
522
851
 
523
852
  // ---------- audit ----------
524
853
 
525
854
  /** Appends an audit entry. Never pass tokens, codes, keys or session content. */
526
- audit(entry: Omit<AuditEntry, "at">): void {
527
- this.state.audit.push({ at: this.now(), ...entry })
528
- if (this.state.audit.length > AUDIT_LIMIT) this.state.audit.splice(0, this.state.audit.length - AUDIT_LIMIT)
529
- this.save()
855
+ audit(entry: Omit<AuditEntry, "at">): Promise<void> {
856
+ // Shared: first the entries other pods wrote (the cache may not have them yet), so this one sorts after them.
857
+ if (this.shared) return this.shared.syncAudit().then(() => this.mutate((tx) => this.appendAudit(tx, entry)))
858
+ return this.mutate((tx) => this.appendAudit(tx, entry))
859
+ }
860
+
861
+ private appendAudit(tx: StoreTx, entry: Omit<AuditEntry, "at">): void {
862
+ const record: AuditEntry = { at: this.now(), ...entry }
863
+ this.state.audit.push(record)
864
+ if (this.shared) tx.touch("audit", this.shared.keyAudit(record))
865
+ else tx.touch("audit", "")
866
+ if (this.state.audit.length > AUDIT_LIMIT) {
867
+ const dropped = this.state.audit.splice(0, this.state.audit.length - AUDIT_LIMIT)
868
+ for (const old of dropped) {
869
+ const key = this.shared?.auditKey(old)
870
+ if (key) tx.touch("audit", key)
871
+ }
872
+ }
530
873
  }
531
874
 
532
875
  /** Newest first, at most AUDIT_VIEW_LIMIT. */
@@ -540,11 +883,13 @@ export class Store {
540
883
  return structuredClone(this.user(id)?.speech ?? {})
541
884
  }
542
885
 
543
- setUserSpeech(id: string, settings: StoredSpeech): void {
544
- const user = this.user(id)
545
- if (!user) return
546
- user.speech = structuredClone(settings)
547
- this.save()
886
+ setUserSpeech(id: string, settings: StoredSpeech): Promise<void> {
887
+ return this.mutate((tx) => {
888
+ const user = this.user(id)
889
+ if (!user) return
890
+ user.speech = structuredClone(settings)
891
+ tx.touch("users", id)
892
+ })
548
893
  }
549
894
 
550
895
  // ---------- per-user view preferences ----------
@@ -557,14 +902,66 @@ export class Store {
557
902
  }
558
903
 
559
904
  /** Replaces the lists given (callers validate); returns the result. */
560
- setViewPrefs(id: string, change: Partial<ViewPrefs>): ViewPrefs {
561
- const user = this.user(id)
562
- if (!user) return this.viewPrefs(id)
563
- const next = this.viewPrefs(id)
564
- for (const key of VIEW_PREF_KEYS) if (change[key]) next[key] = [...change[key]]
565
- user.viewPrefs = next
566
- this.save()
567
- return this.viewPrefs(id)
905
+ setViewPrefs(id: string, change: Partial<ViewPrefs>): Promise<ViewPrefs> {
906
+ return this.mutate((tx) => {
907
+ const user = this.user(id)
908
+ if (!user) return this.viewPrefs(id)
909
+ const next = this.viewPrefs(id)
910
+ for (const key of VIEW_PREF_KEYS) if (change[key]) next[key] = [...change[key]]
911
+ user.viewPrefs = next
912
+ tx.touch("users", id)
913
+ return this.viewPrefs(id)
914
+ })
915
+ }
916
+
917
+ /** Changes one archived session atomically; undefined means the bounded archive list is full. */
918
+ setSessionArchived(id: string, sessionID: string, archived: boolean): Promise<{ changed: boolean } | undefined> {
919
+ return this.mutate((tx) => {
920
+ const user = this.user(id)
921
+ if (!user) return undefined
922
+ const next = this.viewPrefs(id)
923
+ const listed = next.hiddenSessions.includes(sessionID)
924
+ const changed = archived !== listed
925
+ if (!changed) return { changed }
926
+ if (archived && next.hiddenSessions.length >= VIEW_PREF_MAX_ARCHIVED) return undefined
927
+ next.hiddenSessions = archived ? [...next.hiddenSessions, sessionID] : next.hiddenSessions.filter((id) => id !== sessionID)
928
+ user.viewPrefs = next
929
+ tx.touch("users", id)
930
+ return { changed }
931
+ })
932
+ }
933
+
934
+ /** Changes one pinned session atomically while preserving every other preference list. */
935
+ setSessionPinned(id: string, sessionID: string, pinned: boolean): Promise<{ changed: boolean } | undefined> {
936
+ return this.mutate((tx) => {
937
+ const user = this.user(id)
938
+ if (!user) return undefined
939
+ const next = this.viewPrefs(id)
940
+ const listed = next.pinnedSessions.includes(sessionID)
941
+ const changed = pinned !== listed
942
+ if (!changed) return { changed }
943
+ if (pinned && next.pinnedSessions.length >= VIEW_PREF_MAX_PINNED) return undefined
944
+ next.pinnedSessions = pinned ? [...next.pinnedSessions, sessionID] : next.pinnedSessions.filter((id) => id !== sessionID)
945
+ user.viewPrefs = next
946
+ tx.touch("users", id)
947
+ return { changed }
948
+ })
949
+ }
950
+
951
+ /** Archives only while still unpinned, atomically against concurrent preference writes. */
952
+ archiveSessionIfUnpinned(id: string, sessionID: string): Promise<"changed" | "pinned" | "archived" | "full" | "not_found"> {
953
+ return this.mutate((tx) => {
954
+ const user = this.user(id)
955
+ if (!user) return "not_found"
956
+ const next = this.viewPrefs(id)
957
+ if (next.hiddenSessions.includes(sessionID)) return "archived"
958
+ if (next.pinnedSessions.includes(sessionID)) return "pinned"
959
+ if (next.hiddenSessions.length >= VIEW_PREF_MAX_ARCHIVED) return "full"
960
+ next.hiddenSessions = [...next.hiddenSessions, sessionID]
961
+ user.viewPrefs = next
962
+ tx.touch("users", id)
963
+ return "changed"
964
+ })
568
965
  }
569
966
 
570
967
  // ---------- per-user, per-session model preferences ----------
@@ -574,22 +971,24 @@ export class Store {
574
971
  return prefs ? structuredClone(prefs) : {}
575
972
  }
576
973
 
577
- setModelPrefs(id: string, sessionID: string, change: { voice?: ModelSelection | null; worker?: ModelSelection | null }): SessionModelPrefs {
578
- const user = this.user(id)
579
- if (!user) return {}
580
- const all = user.modelPrefs ?? (user.modelPrefs = {})
581
- const next: { voice?: ModelSelection; worker?: ModelSelection } = this.modelPrefs(id, sessionID)
582
- for (const key of ["voice", "worker"] as const) {
583
- if (!(key in change)) continue
584
- const value = change[key]
585
- if (value === null) delete next[key]
586
- else if (value !== undefined) next[key] = structuredClone(value)
587
- }
588
- delete all[sessionID]
589
- if (next.voice || next.worker) all[sessionID] = next
590
- while (Object.keys(all).length > MODEL_PREFS_LIMIT) delete all[Object.keys(all)[0]!]
591
- this.save()
592
- return this.modelPrefs(id, sessionID)
974
+ setModelPrefs(id: string, sessionID: string, change: { voice?: ModelSelection | null; worker?: ModelSelection | null }): Promise<SessionModelPrefs> {
975
+ return this.mutate((tx) => {
976
+ const user = this.user(id)
977
+ if (!user) return {}
978
+ const all = user.modelPrefs ?? (user.modelPrefs = {})
979
+ const next: { voice?: ModelSelection; worker?: ModelSelection } = this.modelPrefs(id, sessionID)
980
+ for (const key of ["voice", "worker"] as const) {
981
+ if (!(key in change)) continue
982
+ const value = change[key]
983
+ if (value === null) delete next[key]
984
+ else if (value !== undefined) next[key] = structuredClone(value)
985
+ }
986
+ delete all[sessionID]
987
+ if (next.voice || next.worker) all[sessionID] = next
988
+ while (Object.keys(all).length > MODEL_PREFS_LIMIT) delete all[Object.keys(all)[0]!]
989
+ tx.touch("users", id)
990
+ return this.modelPrefs(id, sessionID)
991
+ })
593
992
  }
594
993
 
595
994
  // ---------- per-user read marks (unread dots) ----------
@@ -599,40 +998,66 @@ export class Store {
599
998
  }
600
999
 
601
1000
  /** Marks a session read at `at`; a mark never moves back. Returns all marks. */
602
- markRead(id: string, sessionID: string, at: number): Record<string, number> {
603
- const user = this.user(id)
604
- if (!user) return {}
605
- const all = user.reads ?? (user.reads = {})
606
- const next = Math.max(all[sessionID] ?? 0, at)
607
- delete all[sessionID]
608
- all[sessionID] = next
609
- while (Object.keys(all).length > READS_LIMIT) delete all[Object.keys(all)[0]!]
610
- this.save()
611
- return this.reads(id)
1001
+ markRead(id: string, sessionID: string, at: number): Promise<Record<string, number>> {
1002
+ return this.mutate((tx) => {
1003
+ const user = this.user(id)
1004
+ if (!user) return {}
1005
+ const all = user.reads ?? (user.reads = {})
1006
+ const next = Math.max(all[sessionID] ?? 0, at)
1007
+ delete all[sessionID]
1008
+ all[sessionID] = next
1009
+ while (Object.keys(all).length > READS_LIMIT) delete all[Object.keys(all)[0]!]
1010
+ tx.touch("users", id)
1011
+ return this.reads(id)
1012
+ })
612
1013
  }
613
1014
 
614
- touchDevice(id: string): void {
615
- this.touch(this.state.devices.find((d) => d.id === id))
1015
+ /** Records that the device was seen (persisted at most once a minute; never rejects). */
1016
+ touchDevice(id: string): Promise<void> {
1017
+ return this.touch("devices", id, () => this.state.devices.find((d) => d.id === id))
616
1018
  }
617
1019
 
618
- private touch(record: { lastSeen: number } | undefined): void {
619
- if (!record) return
620
- const t = this.now()
621
- if (t - record.lastSeen < TOUCH_PERSIST_MS) return
622
- record.lastSeen = t
623
- this.save()
1020
+ private touch(collection: "users" | "machines" | "devices", id: string, find: () => { lastSeen: number } | undefined): Promise<void> {
1021
+ return this.mutate((tx) => {
1022
+ const record = find()
1023
+ if (!record) return
1024
+ const t = this.now()
1025
+ if (t - record.lastSeen < TOUCH_PERSIST_MS) return
1026
+ record.lastSeen = t
1027
+ tx.touch(collection, id)
1028
+ }).catch((cause: unknown) => console.warn(`[hub] lastSeen not saved: ${(cause as Error)?.message ?? cause}`))
1029
+ }
1030
+
1031
+ /**
1032
+ * Runs a change. File Store: the change and its save happen synchronously, before the promise is returned (a
1033
+ * failed save rejects it). Shared Store: see SharedRows.mutate.
1034
+ */
1035
+ private mutate<T>(fn: (tx: StoreTx) => T): Promise<T> {
1036
+ if (this.shared) return this.shared.mutate(fn)
1037
+ try {
1038
+ const tx = new StoreTx()
1039
+ const result = fn(tx)
1040
+ if (tx.dirty) this.save()
1041
+ return Promise.resolve(result)
1042
+ } catch (cause) {
1043
+ return Promise.reject(cause)
1044
+ }
624
1045
  }
625
1046
 
626
1047
  private save(): void {
627
- const tmp = `${this.file}.${process.pid}.tmp`
1048
+ const file = this.file!
1049
+ const tmp = `${file}.${process.pid}.tmp`
628
1050
  writeFileSync(tmp, `${JSON.stringify(this.state, null, 2)}\n`, { mode: 0o600 })
629
1051
  chmodSync(tmp, 0o600)
630
- renameSync(tmp, this.file)
1052
+ renameSync(tmp, file)
631
1053
  }
632
1054
  }
633
1055
 
634
- /** Idempotent upgrade of older states: the local user exists, every machine and device has an owner. */
635
- function migrate(state: StateData, t: number, options: StoreOptions): void {
1056
+ /**
1057
+ * Idempotent upgrade of older states: the local user exists, every machine and device has an owner, agent data
1058
+ * and token epochs are valid.
1059
+ */
1060
+ function migrate(state: StateData, t: number, options: Pick<StoreOptions, "mode">): void {
636
1061
  let local = state.users.find((u) => u.id === LOCAL_USER)
637
1062
  if (!local) {
638
1063
  local = { id: LOCAL_USER, role: "admin", created: t, lastSeen: t }
@@ -646,8 +1071,19 @@ function migrate(state: StateData, t: number, options: StoreOptions): void {
646
1071
  for (const record of [...state.machines, ...state.devices]) {
647
1072
  if (typeof record.ownerID !== "string" || !record.ownerID) record.ownerID = LOCAL_USER
648
1073
  }
649
- // 0.5.0 push prefs ({handoff, blocked, finished, say}) → the split toggles.
1074
+ // Agent data and token epochs are optional: invalid values are normalised or dropped (epochs read as 0).
1075
+ for (const user of state.users) {
1076
+ if ("agent" in user) {
1077
+ const agent = normaliseAgentData(user.agent)
1078
+ if (agent) user.agent = agent
1079
+ else delete user.agent
1080
+ }
1081
+ }
1082
+ for (const record of [...state.users, ...state.machines]) {
1083
+ if ("tokenEpoch" in record && !validEpoch(record.tokenEpoch)) delete record.tokenEpoch
1084
+ }
1085
+ // 0.5.0 ({handoff, blocked, finished, say}) and 0.7 (finished = every turn) push prefs → the current toggles.
650
1086
  for (const device of state.devices) {
651
- if (device.pushPrefs && "blocked" in device.pushPrefs) device.pushPrefs = normalizePushPrefs(device.pushPrefs)
1087
+ if (legacyPushPrefs(device.pushPrefs)) device.pushPrefs = normalizePushPrefs(device.pushPrefs)
652
1088
  }
653
1089
  }