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