@huynle/hub 0.0.0-stage → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +269 -0
  2. package/Dockerfile +51 -0
  3. package/LICENSE +21 -0
  4. package/README.md +1029 -2
  5. package/bin/hub.ts +20 -0
  6. package/docker/hub-deps.ts +42 -0
  7. package/package.json +85 -4
  8. package/rpc.ts +2 -0
  9. package/scripts/hub-local.sh +28 -0
  10. package/scripts/io.github.huynle.hub.plist +42 -0
  11. package/server.ts +3 -0
  12. package/src/central/assets.ts +74 -0
  13. package/src/central/auth.ts +271 -0
  14. package/src/central/config.ts +310 -0
  15. package/src/central/http.ts +1247 -0
  16. package/src/central/main.ts +136 -0
  17. package/src/central/oidc.ts +295 -0
  18. package/src/central/push-target.ts +97 -0
  19. package/src/central/push.ts +285 -0
  20. package/src/central/registry.ts +1153 -0
  21. package/src/central/speech/models.ts +89 -0
  22. package/src/central/speech/openai.ts +66 -0
  23. package/src/central/speech/openrouter.ts +71 -0
  24. package/src/central/speech/provider.ts +74 -0
  25. package/src/central/speech/service.ts +168 -0
  26. package/src/central/speech/settings.ts +375 -0
  27. package/src/central/spoke-ws.ts +300 -0
  28. package/src/central/store.ts +653 -0
  29. package/src/config.ts +170 -0
  30. package/src/env.ts +63 -0
  31. package/src/index.ts +236 -0
  32. package/src/key.ts +17 -0
  33. package/src/loopback.ts +41 -0
  34. package/src/migrate.ts +105 -0
  35. package/src/openrouter.ts +3 -0
  36. package/src/options.ts +33 -0
  37. package/src/protocol.ts +650 -0
  38. package/src/reply.ts +27 -0
  39. package/src/rpc.ts +98 -0
  40. package/src/setup.ts +124 -0
  41. package/src/speakable.ts +35 -0
  42. package/src/spoke/client.ts +272 -0
  43. package/src/spoke/conversation.ts +93 -0
  44. package/src/spoke/enroll.ts +79 -0
  45. package/src/spoke/herdr.ts +346 -0
  46. package/src/spoke/listing.ts +108 -0
  47. package/src/spoke/localhub.ts +287 -0
  48. package/src/spoke/locations.ts +34 -0
  49. package/src/spoke/own-server.ts +32 -0
  50. package/src/spoke/presence.ts +45 -0
  51. package/src/spoke/singleton.ts +103 -0
  52. package/src/spoke/spoke.ts +1212 -0
  53. package/src/spoke/summary.ts +117 -0
  54. package/src/spoke/timeline.ts +93 -0
  55. package/src/spoke/tracker.ts +345 -0
  56. package/src/spoke/watch.ts +78 -0
  57. package/src/status.ts +232 -0
  58. package/src/tui-commands.ts +53 -0
  59. package/src/tui-forms.ts +77 -0
  60. package/src/tui-presence.ts +75 -0
  61. package/src/tui.ts +479 -0
  62. package/src/turns.ts +94 -0
  63. package/src/voice-command.ts +65 -0
  64. package/tui.ts +2 -0
  65. package/web/dist/apple-touch-icon.png +0 -0
  66. package/web/dist/assets/index-DClBvfLL.css +1 -0
  67. package/web/dist/assets/index-Djo_yF0a.js +12 -0
  68. package/web/dist/favicon.ico +0 -0
  69. package/web/dist/icon-192.png +0 -0
  70. package/web/dist/icon-512.png +0 -0
  71. package/web/dist/icon-maskable-512.png +0 -0
  72. package/web/dist/icon.svg +18 -0
  73. package/web/dist/index.html +25 -0
  74. package/web/dist/manifest.webmanifest +18 -0
  75. package/web/dist/sw.js +151 -0
  76. package/web/dist/vad-worklet.js +27 -0
@@ -0,0 +1,285 @@
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.
4
+ import webpush from "web-push"
5
+ import { type PushPrefs, type PushTarget, type QuietHours, type Store } from "./store"
6
+
7
+ export { DEFAULT_PUSH_PREFS, type PushPrefs } from "./store"
8
+
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. */
14
+ export type NotificationKind = keyof PushPrefs
15
+
16
+ /** Extra facts for one notification. */
17
+ export interface PushExtra {
18
+ /** permission: the request ID, so the notification can answer it. */
19
+ readonly requestID?: string
20
+ }
21
+
22
+ export interface PushPayload {
23
+ readonly kind: NotificationKind | "test"
24
+ readonly sessionID: string
25
+ readonly title: string
26
+ readonly body: string
27
+ 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
+ }
40
+ }
41
+
42
+ const minutes = (clock: string): number => Number(clock.slice(0, 2)) * 60 + Number(clock.slice(3, 5))
43
+
44
+ /** True when `at` falls in the quiet hours (local time of their zone); never for an empty or invalid range. */
45
+ export function inQuietHours(quiet: QuietHours, at: number): boolean {
46
+ let parts: Intl.DateTimeFormatPart[]
47
+ try {
48
+ parts = new Intl.DateTimeFormat("en-GB", { timeZone: quiet.timeZone, hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).formatToParts(at)
49
+ } catch {
50
+ return false
51
+ }
52
+ const hour = Number(parts.find((p) => p.type === "hour")?.value)
53
+ const minute = Number(parts.find((p) => p.type === "minute")?.value)
54
+ if (!Number.isFinite(hour) || !Number.isFinite(minute)) return false
55
+ const now = (hour % 24) * 60 + minute
56
+ const start = minutes(quiet.start)
57
+ const end = minutes(quiet.end)
58
+ if (start === end) return false
59
+ return start < end ? now >= start && now < end : now >= start || now < end
60
+ }
61
+
62
+ export interface PushSendOptions {
63
+ readonly TTL: number
64
+ readonly urgency: "high" | "normal"
65
+ readonly timeout: number
66
+ readonly vapidDetails: { readonly subject: string; readonly publicKey: string; readonly privateKey: string }
67
+ }
68
+
69
+ /** Delivers one message; rejects with an error carrying `statusCode` when the push service refuses it. */
70
+ export type PushSender = (target: PushTarget, payload: string, options: PushSendOptions) => Promise<unknown>
71
+
72
+ export const PUSH_COOLDOWN_MS = 30_000
73
+ /** Spoken replies are pushed more often than other kinds. */
74
+ export const SAY_COOLDOWN_MS = 10_000
75
+ /** Longest spoken text carried in a say notification body. */
76
+ export const SAY_MAX_CHARS = 180
77
+ export const PUSH_TTL_SECONDS = 300
78
+ /** Questions, permissions and run results stay deliverable for an hour (a phone in doze picks them up later). */
79
+ export const URGENT_TTL_SECONDS = 3600
80
+ /** One test notification per device in this window. */
81
+ export const TEST_COOLDOWN_MS = 10_000
82
+ const SEND_TIMEOUT_MS = 10_000
83
+ const MAX_NAME = 60
84
+
85
+ const webPushSender: PushSender = (target, payload, options) =>
86
+ webpush.sendNotification(target, payload, { ...options, vapidDetails: { ...options.vapidDetails } })
87
+
88
+ export interface PushOptions {
89
+ readonly store: Store
90
+ /** VAPID subject: a mailto: or https: URL. */
91
+ readonly subject: string
92
+ readonly sender?: PushSender
93
+ readonly now?: () => number
94
+ }
95
+
96
+ function clip(text: string, max: number): string {
97
+ const clean = text.replace(/\s+/g, " ").trim()
98
+ return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean
99
+ }
100
+
101
+ const shorten = (title: string): string => clip(title, MAX_NAME) || "Session"
102
+
103
+ export function pushPayload(
104
+ kind: PushKind,
105
+ sessionID: string,
106
+ sessionTitle: string,
107
+ detail?: PushDetail,
108
+ text?: string,
109
+ extra: PushExtra = {},
110
+ ): PushPayload {
111
+ const name = shorten(sessionTitle)
112
+ const precise = pushKindFor(kind, detail)
113
+ switch (precise) {
114
+ case "say":
115
+ return { kind: precise, sessionID, title: name, body: clip(text ?? "", SAY_MAX_CHARS) || "New reply. Tap to listen." }
116
+ case "handoff":
117
+ return { kind: precise, sessionID, title: name, body: "Sent from the terminal. Tap to talk." }
118
+ case "question":
119
+ return { kind: precise, sessionID, title: `${name} has a question`, body: "Tap to see it." }
120
+ case "permission":
121
+ // 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." }
133
+ }
134
+ }
135
+
136
+ /** web-push options per notification kind: urgent kinds wake a dozing phone and wait longer for it. */
137
+ export function sendOptionsFor(kind: NotificationKind | "test"): { TTL: number; urgency: "high" | "normal" } {
138
+ switch (kind) {
139
+ case "question":
140
+ case "permission":
141
+ case "failed":
142
+ return { TTL: URGENT_TTL_SECONDS, urgency: "high" }
143
+ case "finished":
144
+ return { TTL: URGENT_TTL_SECONDS, urgency: "normal" }
145
+ default:
146
+ return { TTL: PUSH_TTL_SECONDS, urgency: "high" }
147
+ }
148
+ }
149
+
150
+ /** Result of a test notification. */
151
+ export type PushTestResult =
152
+ | { readonly sent: number; readonly failed: ReadonlyArray<{ readonly host: string; readonly status?: number }> }
153
+ | { readonly limited: true; readonly retryAfter: number }
154
+
155
+ const hostOf = (endpoint: string): string => {
156
+ try {
157
+ return new URL(endpoint).host
158
+ } catch {
159
+ return "invalid endpoint"
160
+ }
161
+ }
162
+
163
+ const statusOf = (cause: unknown): number | undefined => {
164
+ const code = (cause as { statusCode?: unknown } | null)?.statusCode
165
+ return typeof code === "number" ? code : undefined
166
+ }
167
+
168
+ export class Push {
169
+ private readonly store: Store
170
+ private readonly subject: string
171
+ private readonly sender: PushSender
172
+ 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 }>()
175
+
176
+ constructor(options: PushOptions) {
177
+ this.store = options.store
178
+ this.subject = options.subject
179
+ this.sender = options.sender ?? webPushSender
180
+ this.now = options.now ?? Date.now
181
+ }
182
+
183
+ /** The VAPID public key (applicationServerKey); generated and persisted on first use. */
184
+ publicKey(): string {
185
+ return this.keys().publicKey
186
+ }
187
+
188
+ /**
189
+ * Notifies every subscribed device that wants this kind of notification — only devices of `ownerID`
190
+ * (the session's owner) when given — unless the session is muted by the device's user or the device is
191
+ * in its quiet hours (hand-offs, an explicit request from the terminal, go through both). Returns the
192
+ * number of successful deliveries.
193
+ */
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
205
+ const owners = new Map(this.store.devices().map((d) => [d.id, d.ownerID]))
206
+ const at = this.now()
207
+ const muted = new Map<string, boolean>()
208
+ const isMuted = (userID: string | undefined) => {
209
+ if (userID === undefined) return false
210
+ if (!muted.has(userID)) muted.set(userID, this.store.mutedSessions(userID).includes(sessionID))
211
+ return muted.get(userID)!
212
+ }
213
+ const targets = this.store.pushSubscriptions().filter((s) => {
214
+ const owner = owners.get(s.deviceID)
215
+ if (ownerID !== undefined && owner !== ownerID) return false
216
+ if (!this.store.pushPrefs(s.deviceID)[precise]) return false
217
+ if (precise === "handoff") return true
218
+ const quiet = this.store.quietHours(s.deviceID)
219
+ return !isMuted(owner) && !(quiet && inQuietHours(quiet, at))
220
+ })
221
+ if (targets.length === 0) return 0
222
+ const payload = pushPayload(kind, sessionID, title, detail, body, extra)
223
+ const { delivered } = await this.send(targets, payload)
224
+ return delivered
225
+ }
226
+
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> {
229
+ 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) }
233
+ }
234
+ this.cool(key, TEST_COOLDOWN_MS)
235
+ 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." }
237
+ const { delivered, failed } = await this.send(targets, payload)
238
+ return { sent: delivered, failed }
239
+ }
240
+
241
+ private async send(
242
+ targets: readonly { endpoint: string; keys: PushTarget["keys"] }[],
243
+ payload: PushPayload,
244
+ ): Promise<{ delivered: number; failed: Array<{ host: string; status?: number }> }> {
245
+ const keys = this.keys()
246
+ const options: PushSendOptions = {
247
+ ...sendOptionsFor(payload.kind),
248
+ timeout: SEND_TIMEOUT_MS,
249
+ vapidDetails: { subject: this.subject, publicKey: keys.publicKey, privateKey: keys.privateKey },
250
+ }
251
+ const raw = JSON.stringify(payload)
252
+ const results = await Promise.allSettled(
253
+ targets.map((target) => this.sender({ endpoint: target.endpoint, keys: target.keys }, raw, options)),
254
+ )
255
+ let delivered = 0
256
+ const failed: Array<{ host: string; status?: number }> = []
257
+ results.forEach((result, index) => {
258
+ if (result.status === "fulfilled") return void delivered++
259
+ const status = statusOf(result.reason)
260
+ const endpoint = targets[index]!.endpoint
261
+ failed.push({ host: hostOf(endpoint), ...(status !== undefined ? { status } : {}) })
262
+ if (status === 404 || status === 410) this.store.dropPushSubscription(endpoint)
263
+ else console.error(`[hub] push to ${hostOf(endpoint)} failed: ${status ?? (result.reason as Error)?.message}`)
264
+ })
265
+ return { delivered, failed }
266
+ }
267
+
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
275
+ }
276
+
277
+ private keys() {
278
+ const existing = this.store.vapid()
279
+ if (existing) return existing
280
+ const keys = webpush.generateVAPIDKeys()
281
+ this.store.setVapid(keys)
282
+ return keys
283
+ }
284
+ }
285
+