@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
@@ -1,22 +1,52 @@
1
- // Web push (VAPID) for paired devices: hand-offs from the terminal, questions, permissions, finished and
2
- // failed runs, and (conversation mode, nobody watching) what the agent said. Payloads carry only {kind,
3
- // sessionID, title, body, requestID?}: no tokens, no transcript beyond one spoken text, shortened. Each device picks the kinds it wants, may set quiet hours, and users mute sessions.
1
+ // Web push (VAPID) for paired devices, one toggle per notification kind (docs/notifications.md): hand-offs, questions,
2
+ // permissions, all done, failed / stopped / retrying runs, every turn, subagents, scripts, long tools, spoken replies
3
+ // and the activity digest. Payloads carry only {kind, sessionID, title, body, requestID?, test?}: no tokens, no
4
+ // transcript beyond one spoken text or a short reason, shortened. Each device picks the kinds it wants, may set quiet
5
+ // hours, and users mute sessions.
4
6
  import webpush from "web-push"
5
- import { type PushPrefs, type PushTarget, type QuietHours, type Store } from "./store"
7
+ import { MemoryKv } from "./backend/memory-kv"
8
+ import type { Kv } from "./backend/types"
9
+ import { type PushPrefs, type PushTarget, type QuietHours, type Store, type VapidKeys } from "./store"
6
10
 
7
11
  export { DEFAULT_PUSH_PREFS, type PushPrefs } from "./store"
8
12
 
9
- /** What happened, as the registry and the spoke socket report it. */
10
- export type PushKind = "handoff" | "blocked" | "finished" | "say"
11
- /** What a blocked or finished notification is about. */
12
- export type PushDetail = "permission" | "form" | "succeeded" | "failed"
13
- /** The precise notification kind; one per device toggle. */
13
+ /** The notification kind; one per device toggle. */
14
14
  export type NotificationKind = keyof PushPrefs
15
15
 
16
+ /** Short label of each kind, shown in the title after the session name (and in Settings). */
17
+ export const NOTIFICATION_LABELS: Readonly<Record<NotificationKind, string>> = Object.freeze({
18
+ handoff: "Hand-off",
19
+ question: "Question",
20
+ permission: "Needs permission",
21
+ done: "All done",
22
+ failed: "Failed",
23
+ turn: "Turn finished",
24
+ stopped: "Stopped",
25
+ retry: "Retrying",
26
+ subagentDone: "Subagent finished",
27
+ subagentFailed: "Subagent failed",
28
+ jobDone: "Script finished",
29
+ jobFailed: "Script failed",
30
+ longTool: "Long tool finished",
31
+ say: "Spoken reply",
32
+ activity: "Activity",
33
+ decision: "Decision",
34
+ message: "Message",
35
+ })
36
+
37
+ /** True for a known notification kind (API input). */
38
+ export const isNotificationKind = (value: unknown): value is NotificationKind => typeof value === "string" && Object.hasOwn(NOTIFICATION_LABELS, value)
39
+
16
40
  /** Extra facts for one notification. */
17
41
  export interface PushExtra {
18
42
  /** permission: the request ID, so the notification can answer it. */
19
43
  readonly requestID?: string
44
+ /** What the notification is about, for the title: a subagent's title, a job's outcome ("exited 1"), a duration. */
45
+ readonly subject?: string
46
+ /** Identity of what ended (subagent session, job, tool call): the cooldown is per session, kind and key. Not sent. */
47
+ readonly key?: string
48
+ /** decision: the Decision, so a tap opens its card. */
49
+ readonly decisionID?: string
20
50
  }
21
51
 
22
52
  export interface PushPayload {
@@ -25,18 +55,9 @@ export interface PushPayload {
25
55
  readonly title: string
26
56
  readonly body: string
27
57
  readonly requestID?: string
28
- }
29
-
30
- /** The device toggle that decides an alert. */
31
- export function pushKindFor(kind: PushKind, detail?: PushDetail): NotificationKind {
32
- switch (kind) {
33
- case "blocked":
34
- return detail === "form" ? "question" : "permission"
35
- case "finished":
36
- return detail === "failed" ? "failed" : "finished"
37
- default:
38
- return kind
39
- }
58
+ readonly decisionID?: string
59
+ /** A test notification shaped like `kind`. */
60
+ readonly test?: true
40
61
  }
41
62
 
42
63
  const minutes = (clock: string): number => Number(clock.slice(0, 2)) * 60 + Number(clock.slice(3, 5))
@@ -80,6 +101,7 @@ export const URGENT_TTL_SECONDS = 3600
80
101
  /** One test notification per device in this window. */
81
102
  export const TEST_COOLDOWN_MS = 10_000
82
103
  const SEND_TIMEOUT_MS = 10_000
104
+ const COOL_KEY = (key: string) => `push:cool:${key}`
83
105
  const MAX_NAME = 60
84
106
 
85
107
  const webPushSender: PushSender = (target, payload, options) =>
@@ -91,6 +113,51 @@ export interface PushOptions {
91
113
  readonly subject: string
92
114
  readonly sender?: PushSender
93
115
  readonly now?: () => number
116
+ /** Timers for delayed activity digests (tests). */
117
+ readonly timers?: { set(fn: () => void, ms: number): unknown; clear(handle: unknown): void }
118
+ /** Cooldowns (default: this process, on `now`; Valkey in a cluster, so pods share them). */
119
+ readonly kv?: Kv
120
+ }
121
+
122
+ /** One step of interest in a session tree, for activity updates. */
123
+ export interface ActivityStep {
124
+ /** tool: a short tool call (only counted); anything else is a line of the digest and sends it. */
125
+ readonly kind: "tool" | "long" | "subagent" | "job" | "idle"
126
+ readonly text: string
127
+ }
128
+
129
+ /** An activity step of a root session, as the registry reports it. */
130
+ export interface ActivityAlert {
131
+ readonly sessionID: string
132
+ readonly title: string
133
+ readonly step?: ActivityStep
134
+ readonly owner?: string
135
+ }
136
+
137
+ /** Lines of a digest kept (older ones become "+n more"). */
138
+ const DIGEST_LINES = 5
139
+
140
+ interface Digest {
141
+ title: string
142
+ tools: number
143
+ lines: string[]
144
+ last?: number
145
+ timer?: unknown
146
+ }
147
+
148
+ const realTimers = {
149
+ set: (fn: () => void, ms: number) => {
150
+ const handle = setTimeout(fn, ms)
151
+ ;(handle as { unref?: () => void }).unref?.()
152
+ return handle
153
+ },
154
+ clear: (handle: unknown) => clearTimeout(handle as ReturnType<typeof setTimeout>),
155
+ }
156
+
157
+ /** "3 tools · explore finished · bun run build exited 0". */
158
+ export function digestBody(tools: number, lines: readonly string[]): string {
159
+ const shown = lines.length > DIGEST_LINES ? [`+${lines.length - DIGEST_LINES + 1} more`, ...lines.slice(-(DIGEST_LINES - 1))] : [...lines]
160
+ return [...(tools ? [`${tools} tool${tools === 1 ? "" : "s"}`] : []), ...shown].join(" · ")
94
161
  }
95
162
 
96
163
  function clip(text: string, max: number): string {
@@ -100,48 +167,83 @@ function clip(text: string, max: number): string {
100
167
 
101
168
  const shorten = (title: string): string => clip(title, MAX_NAME) || "Session"
102
169
 
103
- export function pushPayload(
104
- kind: PushKind,
105
- sessionID: string,
106
- sessionTitle: string,
107
- detail?: PushDetail,
108
- text?: string,
109
- extra: PushExtra = {},
110
- ): PushPayload {
170
+ const MAX_SUBJECT = 40
171
+
172
+ /** The title after the session name: the kind's label, with its subject where it has one. */
173
+ function kindTitle(kind: NotificationKind, subject: string | undefined): string {
174
+ const about = subject ? clip(subject, MAX_SUBJECT) : ""
175
+ switch (kind) {
176
+ case "subagentDone":
177
+ return about ? `Subagent ${about} finished` : NOTIFICATION_LABELS.subagentDone
178
+ case "subagentFailed":
179
+ return about ? `Subagent ${about} failed` : NOTIFICATION_LABELS.subagentFailed
180
+ case "jobDone":
181
+ case "jobFailed":
182
+ return about ? `Script ${about}` : NOTIFICATION_LABELS[kind]
183
+ case "longTool":
184
+ return about ? `${NOTIFICATION_LABELS.longTool} (${about})` : NOTIFICATION_LABELS.longTool
185
+ default:
186
+ return NOTIFICATION_LABELS[kind]
187
+ }
188
+ }
189
+
190
+ const OPEN = "Tap to open the session."
191
+
192
+ export function pushPayload(kind: NotificationKind, sessionID: string, sessionTitle: string, text?: string, extra: PushExtra = {}): PushPayload {
111
193
  const name = shorten(sessionTitle)
112
- const precise = pushKindFor(kind, detail)
113
- switch (precise) {
194
+ const detail = clip(text ?? "", SAY_MAX_CHARS)
195
+ const title = `${name} · ${kindTitle(kind, extra.subject)}`
196
+ switch (kind) {
114
197
  case "say":
115
- return { kind: precise, sessionID, title: name, body: clip(text ?? "", SAY_MAX_CHARS) || "New reply. Tap to listen." }
198
+ // A spoken reply reads as the session talking.
199
+ return { kind, sessionID, title: name, body: detail || "New reply. Tap to listen." }
116
200
  case "handoff":
117
- return { kind: precise, sessionID, title: name, body: "Sent from the terminal. Tap to talk." }
201
+ return { kind, sessionID, title, body: "Sent from the terminal. Tap to talk." }
118
202
  case "question":
119
- return { kind: precise, sessionID, title: `${name} has a question`, body: "Tap to see it." }
203
+ return { kind, sessionID, title, body: "Tap to see it." }
204
+ // An agent wrote the question or message: never in the payload (the console shows it after sign-in).
205
+ case "decision":
206
+ return { kind, sessionID, title, body: "A decision waits for you. Tap to answer.", ...(extra.decisionID ? { decisionID: extra.decisionID } : {}) }
207
+ case "message":
208
+ return { kind, sessionID, title, body: "Tap to see it." }
120
209
  case "permission":
121
210
  // Never the request's summary (it may be a command line); the service worker fetches it from the hub.
122
- return {
123
- kind: precise,
124
- sessionID,
125
- title: `${name} needs permission`,
126
- body: "Tap to review and answer.",
127
- ...(extra.requestID ? { requestID: extra.requestID } : {}),
128
- }
129
- case "failed":
130
- return { kind: precise, sessionID, title: `${name} failed`, body: "Tap to open the session." }
131
- case "finished":
132
- return { kind: precise, sessionID, title: `${name} finished`, body: "Tap to open the session." }
211
+ return { kind, sessionID, title, body: "Tap to review and answer.", ...(extra.requestID ? { requestID: extra.requestID } : {}) }
212
+ case "done":
213
+ return { kind, sessionID, title, body: detail || "Nothing is running or waiting. Tap to open." }
214
+ case "activity":
215
+ return { kind, sessionID, title, body: detail || "New activity." }
216
+ default:
217
+ return { kind, sessionID, title, body: detail || OPEN }
133
218
  }
134
219
  }
135
220
 
221
+ /** What a test notification of each kind is about. */
222
+ const TEST_SUBJECT: Partial<Record<NotificationKind, string>> = { subagentDone: "explore", subagentFailed: "explore", jobDone: "exited 0", jobFailed: "exited 1", longTool: "45s" }
223
+
136
224
  /** web-push options per notification kind: urgent kinds wake a dozing phone and wait longer for it. */
137
225
  export function sendOptionsFor(kind: NotificationKind | "test"): { TTL: number; urgency: "high" | "normal" } {
138
226
  switch (kind) {
139
227
  case "question":
140
228
  case "permission":
141
229
  case "failed":
230
+ case "decision":
231
+ case "message":
142
232
  return { TTL: URGENT_TTL_SECONDS, urgency: "high" }
143
- case "finished":
233
+ case "done":
234
+ case "stopped":
235
+ case "subagentFailed":
236
+ case "jobFailed":
237
+ // Worth seeing later, not worth waking a dozing phone.
144
238
  return { TTL: URGENT_TTL_SECONDS, urgency: "normal" }
239
+ case "turn":
240
+ case "retry":
241
+ case "subagentDone":
242
+ case "jobDone":
243
+ case "longTool":
244
+ case "activity":
245
+ // Progress: stale soon, replaced by the next one.
246
+ return { TTL: PUSH_TTL_SECONDS, urgency: "normal" }
145
247
  default:
146
248
  return { TTL: PUSH_TTL_SECONDS, urgency: "high" }
147
249
  }
@@ -170,19 +272,96 @@ export class Push {
170
272
  private readonly subject: string
171
273
  private readonly sender: PushSender
172
274
  private readonly now: () => number
173
- /** `session kind` → when it was last sent and how long it cools down. */
174
- private readonly lastSent = new Map<string, { at: number; cooldown: number }>()
275
+ /** Cooldowns: `push:cool:<session kind>` / `push:cool:test <device>` → when it was sent, expiring with the cooldown. */
276
+ private readonly kv: Kv
277
+ /**
278
+ * `device session` → the activity digest being collected. Kept in this process: activity is reported by the
279
+ * pod holding the session's connection.
280
+ */
281
+ private readonly digests = new Map<string, Digest>()
282
+ private readonly timers: NonNullable<PushOptions["timers"]>
175
283
 
176
284
  constructor(options: PushOptions) {
177
285
  this.store = options.store
178
286
  this.subject = options.subject
179
287
  this.sender = options.sender ?? webPushSender
180
288
  this.now = options.now ?? Date.now
289
+ this.timers = options.timers ?? realTimers
290
+ this.kv = options.kv ?? new MemoryKv({ now: this.now })
291
+ }
292
+
293
+ /**
294
+ * Activity updates: collects a session's steps per device that wants them (pref `activity`, not muted, not in
295
+ * quiet hours) and sends one collapsing notification: at once when the device got none for this session within
296
+ * its interval, else when the interval ends, with everything that happened meanwhile. Short tools only count.
297
+ */
298
+ activity(alert: ActivityAlert): void {
299
+ const step = alert.step
300
+ if (!alert.owner || !step) return
301
+ const at = this.now()
302
+ for (const deviceID of this.activityDevices(alert.owner, alert.sessionID, at)) {
303
+ const key = `${deviceID}\n${alert.sessionID}`
304
+ let digest = this.digests.get(key)
305
+ if (!digest) this.digests.set(key, (digest = { title: alert.title, tools: 0, lines: [] }))
306
+ digest.title = alert.title
307
+ if (step.kind === "tool") {
308
+ digest.tools++
309
+ continue
310
+ }
311
+ if (digest.lines.at(-1) !== step.text) digest.lines.push(step.text)
312
+ if (digest.lines.length > 50) digest.lines.splice(0, digest.lines.length - 50)
313
+ if (digest.timer !== undefined) continue
314
+ const wait = digest.last === undefined ? 0 : digest.last + this.store.activityMinutes(deviceID) * 60_000 - at
315
+ if (wait <= 0) void this.flushDigest(deviceID, alert.sessionID, alert.owner)
316
+ else {
317
+ const owner = alert.owner
318
+ digest.timer = this.timers.set(() => void this.flushDigest(deviceID, alert.sessionID, owner), wait)
319
+ }
320
+ }
321
+ this.pruneDigests(at)
322
+ }
323
+
324
+ /** Devices of `owner` with a subscription that want activity updates for the session now. */
325
+ private activityDevices(owner: string, sessionID: string, at: number): string[] {
326
+ if (this.store.mutedSessions(owner).includes(sessionID)) return []
327
+ const mine = new Set(this.store.devices().filter((d) => d.ownerID === owner).map((d) => d.id))
328
+ const devices = new Set<string>()
329
+ for (const s of this.store.pushSubscriptions()) {
330
+ if (!mine.has(s.deviceID) || devices.has(s.deviceID) || !this.store.pushPrefs(s.deviceID).activity) continue
331
+ const quiet = this.store.quietHours(s.deviceID)
332
+ if (quiet && inQuietHours(quiet, at)) continue
333
+ devices.add(s.deviceID)
334
+ }
335
+ return [...devices]
336
+ }
337
+
338
+ private async flushDigest(deviceID: string, sessionID: string, owner: string): Promise<void> {
339
+ const key = `${deviceID}\n${sessionID}`
340
+ const digest = this.digests.get(key)
341
+ if (!digest) return
342
+ if (digest.timer !== undefined) this.timers.clear(digest.timer)
343
+ digest.timer = undefined
344
+ const body = digestBody(digest.tools, digest.lines)
345
+ digest.tools = 0
346
+ digest.lines = []
347
+ digest.last = this.now()
348
+ // Settings may have changed while it waited.
349
+ if (!body || !this.activityDevices(owner, sessionID, digest.last).includes(deviceID)) return
350
+ const targets = this.store.pushSubscriptions().filter((s) => s.deviceID === deviceID)
351
+ await this.send(targets, pushPayload("activity", sessionID, digest.title, body))
352
+ }
353
+
354
+ /** Forgets digests idle for an hour (nothing pending, interval long over). */
355
+ private pruneDigests(at: number): void {
356
+ if (this.digests.size < 1000) return
357
+ for (const [key, digest] of this.digests) {
358
+ if (digest.timer === undefined && !digest.lines.length && (digest.last ?? 0) < at - 3_600_000) this.digests.delete(key)
359
+ }
181
360
  }
182
361
 
183
362
  /** The VAPID public key (applicationServerKey); generated and persisted on first use. */
184
- publicKey(): string {
185
- return this.keys().publicKey
363
+ async publicKey(): Promise<string> {
364
+ return (await this.keys()).publicKey
186
365
  }
187
366
 
188
367
  /**
@@ -191,17 +370,10 @@ export class Push {
191
370
  * in its quiet hours (hand-offs, an explicit request from the terminal, go through both). Returns the
192
371
  * number of successful deliveries.
193
372
  */
194
- async notify(
195
- kind: PushKind,
196
- sessionID: string,
197
- title: string,
198
- detail?: PushDetail,
199
- body?: string,
200
- ownerID?: string,
201
- extra?: PushExtra,
202
- ): Promise<number> {
203
- const precise = pushKindFor(kind, detail)
204
- if (!this.cool(`${sessionID}\n${precise}`, precise === "say" ? SAY_COOLDOWN_MS : PUSH_COOLDOWN_MS)) return 0
373
+ async notify(kind: NotificationKind, sessionID: string, title: string, body?: string, ownerID?: string, extra?: PushExtra): Promise<number> {
374
+ const precise = kind
375
+ const key = `${sessionID}\n${precise}${extra?.key ? `\n${extra.key}` : ""}`
376
+ if (!(await this.cool(key, precise === "say" ? SAY_COOLDOWN_MS : PUSH_COOLDOWN_MS))) return 0
205
377
  const owners = new Map(this.store.devices().map((d) => [d.id, d.ownerID]))
206
378
  const at = this.now()
207
379
  const muted = new Map<string, boolean>()
@@ -219,21 +391,26 @@ export class Push {
219
391
  return !isMuted(owner) && !(quiet && inQuietHours(quiet, at))
220
392
  })
221
393
  if (targets.length === 0) return 0
222
- const payload = pushPayload(kind, sessionID, title, detail, body, extra)
394
+ const payload = pushPayload(kind, sessionID, title, body, extra)
223
395
  const { delivered } = await this.send(targets, payload)
224
396
  return delivered
225
397
  }
226
398
 
227
- /** Sends a test notification to `deviceID`'s own subscriptions, whatever its prefs; one per TEST_COOLDOWN_MS. */
228
- async test(deviceID: string): Promise<PushTestResult> {
399
+ /**
400
+ * Sends a test notification to `deviceID`'s own subscriptions, whatever its prefs; one per TEST_COOLDOWN_MS. With
401
+ * a kind, it looks like that kind (title, urgency) for a "Test session".
402
+ */
403
+ async test(deviceID: string, kind?: NotificationKind): Promise<PushTestResult> {
229
404
  const key = `test\n${deviceID}`
230
- const last = this.lastSent.get(key)
231
- if (last && this.now() - last.at < last.cooldown) {
232
- return { limited: true, retryAfter: Math.ceil((last.cooldown - (this.now() - last.at)) / 1000) }
405
+ if (!(await this.cool(key, TEST_COOLDOWN_MS))) {
406
+ const at = Number(await this.kv.get(COOL_KEY(key)))
407
+ const left = Number.isFinite(at) ? TEST_COOLDOWN_MS - (this.now() - at) : TEST_COOLDOWN_MS
408
+ return { limited: true, retryAfter: Math.max(1, Math.ceil(left / 1000)) }
233
409
  }
234
- this.cool(key, TEST_COOLDOWN_MS)
235
410
  const targets = this.store.pushSubscriptions().filter((s) => s.deviceID === deviceID)
236
- const payload: PushPayload = { kind: "test", sessionID: "", title: "Hub", body: "Test notification: notifications work on this device." }
411
+ const payload: PushPayload = kind
412
+ ? { ...pushPayload(kind, "", "Test session", `Test: this is how “${NOTIFICATION_LABELS[kind]}” looks on this device.`, { subject: TEST_SUBJECT[kind] }), test: true }
413
+ : { kind: "test", sessionID: "", title: "Hub", body: "Test notification: notifications work on this device." }
237
414
  const { delivered, failed } = await this.send(targets, payload)
238
415
  return { sent: delivered, failed }
239
416
  }
@@ -242,7 +419,7 @@ export class Push {
242
419
  targets: readonly { endpoint: string; keys: PushTarget["keys"] }[],
243
420
  payload: PushPayload,
244
421
  ): Promise<{ delivered: number; failed: Array<{ host: string; status?: number }> }> {
245
- const keys = this.keys()
422
+ const keys = await this.keys()
246
423
  const options: PushSendOptions = {
247
424
  ...sendOptionsFor(payload.kind),
248
425
  timeout: SEND_TIMEOUT_MS,
@@ -259,27 +436,24 @@ export class Push {
259
436
  const status = statusOf(result.reason)
260
437
  const endpoint = targets[index]!.endpoint
261
438
  failed.push({ host: hostOf(endpoint), ...(status !== undefined ? { status } : {}) })
262
- if (status === 404 || status === 410) this.store.dropPushSubscription(endpoint)
439
+ if (status === 404 || status === 410) {
440
+ void this.store.dropPushSubscription(endpoint).catch((cause: unknown) => console.error(`[hub] push subscription not dropped: ${(cause as Error)?.message ?? cause}`))
441
+ }
263
442
  else console.error(`[hub] push to ${hostOf(endpoint)} failed: ${status ?? (result.reason as Error)?.message}`)
264
443
  })
265
444
  return { delivered, failed }
266
445
  }
267
446
 
268
- /** True (and records the send) when (session, kind) is outside its cooldown. */
269
- private cool(key: string, cooldown: number): boolean {
270
- const t = this.now()
271
- for (const [k, sent] of this.lastSent) if (t - sent.at >= sent.cooldown) this.lastSent.delete(k)
272
- if (this.lastSent.has(key)) return false
273
- this.lastSent.set(key, { at: t, cooldown })
274
- return true
447
+ /** True (and records the send, SET NX PX: one pod wins) when `key` is outside its cooldown. */
448
+ private cool(key: string, cooldown: number): Promise<boolean> {
449
+ return this.kv.set(COOL_KEY(key), String(this.now()), { px: cooldown, nx: true })
275
450
  }
276
451
 
277
- private keys() {
452
+ /** The stored keys; the first call generates a pair (when pods race, every pod gets the pair stored first). */
453
+ private async keys(): Promise<VapidKeys> {
278
454
  const existing = this.store.vapid()
279
455
  if (existing) return existing
280
- const keys = webpush.generateVAPIDKeys()
281
- this.store.setVapid(keys)
282
- return keys
456
+ return this.store.setVapid(webpush.generateVAPIDKeys())
283
457
  }
284
458
  }
285
459