@huynle/hub 0.0.0-stage → 0.6.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 (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,650 @@
1
+ // Central server ↔ spoke protocol, version 1. JSON text frames over a WebSocket at /spoke/ws.
2
+ // The spoke (OpenCode plugin) connects outbound with `Authorization: Bearer <machineToken>`.
3
+ // Every frame is one object with a `t` discriminator. Unknown or malformed frames are rejected.
4
+
5
+ export const PROTOCOL_VERSION = 1
6
+
7
+ export type SessionState = "idle" | "working" | "blocked" | "error"
8
+ export type ProcessKind = "service" | "standalone"
9
+ export type SpokeMode = "always" | "session"
10
+ export type PermissionDecision = "once" | "always" | "reject"
11
+
12
+ /** An answer to an OpenCode form (question): field key → value, like OpenCode's FormAnswer. */
13
+ export type FormAnswer = Readonly<Record<string, string | number | boolean | readonly string[]>>
14
+
15
+ export const FORM_ANSWER_MAX_KEYS = 50
16
+ export const FORM_ANSWER_MAX_CHARS = 20_000
17
+ export const FORM_ANSWER_MAX_ITEMS = 100
18
+
19
+ /** Why an answer object is not acceptable, or undefined when it is. */
20
+ export function formAnswerProblem(v: unknown): string | undefined {
21
+ if (typeof v !== "object" || v === null || Array.isArray(v)) return "answer must be an object"
22
+ const entries = Object.entries(v)
23
+ if (entries.length > FORM_ANSWER_MAX_KEYS) return `answer has more than ${FORM_ANSWER_MAX_KEYS} fields`
24
+ for (const [key, value] of entries) {
25
+ if (!key || key.length > 300) return "answer keys must be 1–300 characters"
26
+ if (typeof value === "string" && value.length <= FORM_ANSWER_MAX_CHARS) continue
27
+ if (typeof value === "boolean" || (typeof value === "number" && Number.isFinite(value))) continue
28
+ if (
29
+ Array.isArray(value) &&
30
+ value.length <= FORM_ANSWER_MAX_ITEMS &&
31
+ value.every((item) => typeof item === "string" && item.length <= FORM_ANSWER_MAX_CHARS)
32
+ ) continue
33
+ return `answer.${key} must be text, a number, true/false or a list of texts`
34
+ }
35
+ return undefined
36
+ }
37
+
38
+ export interface ModelSelection {
39
+ readonly providerID: string
40
+ /** OpenCode's catalog id of the model (Model.Info `id`, what switchModel takes), not its upstream `modelID`. */
41
+ readonly modelID: string
42
+ readonly variant?: string
43
+ }
44
+
45
+ export interface SessionModelPrefs {
46
+ readonly voice?: ModelSelection
47
+ readonly worker?: ModelSelection
48
+ }
49
+
50
+ export interface AvailableModel {
51
+ readonly providerID: string
52
+ readonly modelID: string
53
+ readonly name: string
54
+ readonly enabled: boolean
55
+ readonly status: string
56
+ readonly variants: readonly string[]
57
+ }
58
+
59
+ export interface TimelinePage {
60
+ readonly items: readonly unknown[]
61
+ readonly cursor?: string
62
+ }
63
+
64
+ export interface SessionInfo {
65
+ readonly id: string
66
+ readonly title: string
67
+ readonly directory: string
68
+ readonly parentID?: string
69
+ /** ms epoch of last update */
70
+ readonly updated: number
71
+ /** True when this process currently owns the session: it viewed or executed it most recently. */
72
+ readonly owned: boolean
73
+ /**
74
+ * Whether voice is enabled for this session (always true in mode "always"). Sessions with
75
+ * exposed:false are listed for discovery only: no events are forwarded and no ops run on them.
76
+ */
77
+ readonly exposed?: boolean
78
+ /** Workspace (project) root the session belongs to; differs from directory for worktrees/subfolders. */
79
+ readonly project?: string
80
+ readonly projectName?: string
81
+ readonly archived?: boolean
82
+ /** Open in a terminal right now (a Herdr pane or an OpenCode TUI showing it). */
83
+ readonly open?: boolean
84
+ readonly terminal?: TerminalInfo
85
+ }
86
+
87
+ export interface TerminalInfo {
88
+ /** Where "open" was detected. */
89
+ readonly source: "herdr" | "tui"
90
+ /** Herdr workspace ID and label (e.g. "research"), when open in Herdr. */
91
+ readonly workspaceID?: string
92
+ readonly workspace?: string
93
+ /** Pane/terminal title, e.g. "OC | Fix the login redirect". */
94
+ readonly title?: string
95
+ /** Herdr's agent status for the pane. */
96
+ readonly status?: "idle" | "working" | "blocked" | "done" | "unknown"
97
+ }
98
+
99
+ /** A Herdr workspace on this machine (a target for new sessions). */
100
+ export interface HerdrWorkspace {
101
+ readonly id: string
102
+ readonly label: string
103
+ /** Folders of the panes in this workspace, most used first (lets the phone suggest a directory). */
104
+ readonly directories: readonly string[]
105
+ }
106
+
107
+ /** Machine-level terminal context, sent by one spoke per machine (the service spoke, else a standalone). */
108
+ export interface Terminals {
109
+ readonly t: "terminals"
110
+ readonly herdr?: { readonly workspaces: readonly HerdrWorkspace[] }
111
+ }
112
+
113
+ /** Subset of OpenCode events the spoke forwards (see FORWARDED_EVENTS). */
114
+ export interface ForwardedEvent {
115
+ readonly type: string
116
+ readonly data: Record<string, unknown>
117
+ }
118
+
119
+ export const FORWARDED_EVENTS: ReadonlySet<string> = new Set([
120
+ "session.created",
121
+ "session.renamed",
122
+ "session.deleted",
123
+ "session.viewed",
124
+ "session.execution.started",
125
+ "session.execution.succeeded",
126
+ "session.execution.failed",
127
+ "session.execution.interrupted",
128
+ "session.text.delta",
129
+ "session.text.ended",
130
+ "session.inbox.delivered",
131
+ "session.agent.selected",
132
+ // Activity feed. The spoke sends these in compact form (see compactActivity): tool inputs are
133
+ // reduced to a short summary and results to ok/error, never full file contents or outputs.
134
+ "session.tool.input.started",
135
+ "session.tool.called",
136
+ "session.tool.success",
137
+ "session.tool.failed",
138
+ "session.reasoning.started",
139
+ // Reasoning text streams to the phone's "Thinking" block (deltas capped at REASONING_DELTA_MAX); the
140
+ // ended event's full text is dropped.
141
+ "session.reasoning.delta",
142
+ "session.reasoning.ended",
143
+ "permission.asked",
144
+ "permission.replied",
145
+ "form.created",
146
+ "form.replied",
147
+ "form.cancelled",
148
+ ])
149
+
150
+ // ---------- spoke → server ----------
151
+
152
+ export interface Hello {
153
+ readonly t: "hello"
154
+ readonly v: number
155
+ readonly processID: string
156
+ readonly kind: ProcessKind
157
+ readonly directory: string
158
+ readonly pid: number
159
+ readonly mode: SpokeMode
160
+ readonly opencodeVersion: string
161
+ readonly pluginVersion: string
162
+ }
163
+
164
+ export interface Sessions {
165
+ readonly t: "sessions"
166
+ readonly sessions: readonly SessionInfo[]
167
+ }
168
+
169
+ export interface Event {
170
+ readonly t: "event"
171
+ readonly event: ForwardedEvent
172
+ }
173
+
174
+ /** Requests the spoke makes of the server on the user's behalf (from the /hub dialog). */
175
+ export type SpokeRequest =
176
+ | { readonly op: "pairCode" }
177
+ | { readonly op: "handoff"; readonly sessionID: string }
178
+ /** A one-time sign-in link to a session's page, or to the home page without a session. */
179
+ /** `voice`: land on the session with Voice mode starting (`?voice=1`). */
180
+ | { readonly op: "openLink"; readonly sessionID?: string; readonly voice?: boolean }
181
+
182
+ // ---------- server → spoke ----------
183
+
184
+ export interface Welcome {
185
+ readonly t: "welcome"
186
+ readonly v: number
187
+ readonly machineID: string
188
+ readonly publicURL: string
189
+ }
190
+
191
+ /** An image attached to a typed prompt: base64 bytes of a PNG, JPEG, WebP or GIF (see promptImagesProblem). */
192
+ export interface PromptImage {
193
+ readonly mime: string
194
+ readonly data: string
195
+ readonly name?: string
196
+ }
197
+
198
+ export type ServerRequest =
199
+ /** `text` may be empty when `images` are attached. */
200
+ | { readonly op: "turn"; readonly sessionID: string; readonly text: string; readonly voiceStyle: boolean; readonly images?: readonly PromptImage[] }
201
+ | { readonly op: "permission"; readonly sessionID: string; readonly requestID: string; readonly decision: PermissionDecision }
202
+ /** Answer (or cancel) a pending form (question) in the session that asked it. */
203
+ | { readonly op: "form"; readonly sessionID: string; readonly formID: string; readonly answer: FormAnswer }
204
+ | { readonly op: "form"; readonly sessionID: string; readonly formID: string; readonly cancel: true }
205
+ | { readonly op: "context"; readonly sessionID: string }
206
+ /** Conversation mode: deliver an utterance with delivery "steer" and return at once (replies arrive as events). */
207
+ | { readonly op: "steer"; readonly sessionID: string; readonly text: string; readonly images?: readonly PromptImage[] }
208
+ | { readonly op: "interrupt"; readonly sessionID: string }
209
+ /** Interrupt the session's whole tree: running descendants deepest first, then the root. */
210
+ | { readonly op: "stop"; readonly sessionID: string }
211
+ /** Right-rail data: context usage, MCP servers, branch and working-tree change stats. */
212
+ | { readonly op: "summary"; readonly sessionID: string }
213
+ /** One changed file's patch in the session's directory. */
214
+ | { readonly op: "diff"; readonly sessionID: string; readonly file: string }
215
+ /** Switch the session to the voice orchestrator agent (on) or back to its previous agent (off). */
216
+ | { readonly op: "conversation"; readonly sessionID: string; readonly on: boolean }
217
+ | { readonly op: "models"; readonly sessionID: string }
218
+ | { readonly op: "modelPrefs"; readonly sessionID: string }
219
+ | { readonly op: "setModelPrefs"; readonly sessionID: string; readonly voice?: ModelSelection | null; readonly worker?: ModelSelection | null }
220
+ | { readonly op: "timeline"; readonly sessionID: string; readonly limit?: number; readonly cursor?: string }
221
+ | { readonly op: "timelinePart"; readonly sessionID: string; readonly messageID: string; readonly partID: string }
222
+ /** One image attached to a user message (its position in the message's files). */
223
+ | { readonly op: "timelineFile"; readonly sessionID: string; readonly messageID: string; readonly index: number }
224
+ /** Enable or disable voice for one session (adds/removes it from the machine config's session list). */
225
+ | { readonly op: "expose"; readonly sessionID: string; readonly on: boolean }
226
+ /** Create a session in a workspace directory the spoke already reported. */
227
+ /**
228
+ * Create a session in a workspace directory the spoke already reported. With `herdrWorkspace`, also open
229
+ * it in a new tab of that Herdr workspace (`opencode --session <id>` in the directory).
230
+ */
231
+ | { readonly op: "create"; readonly directory: string; readonly title?: string; readonly herdrWorkspace?: string }
232
+
233
+ export interface ContextUsage {
234
+ /** Tokens of the latest assistant message (input + output + reasoning + cache). */
235
+ readonly used: number
236
+ readonly limit?: number
237
+ readonly percent?: number
238
+ readonly model?: string
239
+ }
240
+
241
+ export interface McpStatus {
242
+ readonly name: string
243
+ readonly status: "connected" | "pending" | "disabled" | "failed" | "needs_auth" | "unknown"
244
+ readonly error?: string
245
+ }
246
+
247
+ export interface ChangedFile {
248
+ readonly file: string
249
+ readonly status: "added" | "deleted" | "modified"
250
+ readonly additions: number
251
+ readonly deletions: number
252
+ }
253
+
254
+ export interface SessionChanges {
255
+ /** The listed files: all of them, or (when `truncated`) the most-changed ones that fit the limits. */
256
+ readonly files: readonly ChangedFile[]
257
+ /** Line totals over every changed file, listed or not. */
258
+ readonly additions: number
259
+ readonly deletions: number
260
+ /** Number of changed files, listed or not. */
261
+ readonly totalFiles: number
262
+ /** True when `files` lists fewer than `totalFiles`. */
263
+ readonly truncated: boolean
264
+ }
265
+
266
+ export interface FilePatch extends ChangedFile {
267
+ readonly patch: string
268
+ readonly truncated: boolean
269
+ }
270
+
271
+ /** Right-rail data for a session; each part is absent when OpenCode could not provide it. */
272
+ export interface SessionSummary {
273
+ readonly directory: string
274
+ readonly branch?: string
275
+ readonly context?: ContextUsage
276
+ readonly mcp?: readonly McpStatus[]
277
+ readonly changes?: SessionChanges
278
+ }
279
+
280
+ export type TurnResult =
281
+ | { readonly outcome: "succeeded"; readonly reply: string }
282
+ | { readonly outcome: "failed" | "interrupted"; readonly message: string; readonly reply: string }
283
+ | { readonly outcome: "timeout"; readonly reply: string }
284
+
285
+ /** Result payloads, keyed by op. */
286
+ export interface ResultFor {
287
+ turn: TurnResult
288
+ /** `gone`: the request is not pending in this process any more (answered elsewhere, expired). */
289
+ permission: { readonly gone?: true }
290
+ /** `gone`: the form is not pending in this process any more (answered elsewhere, cancelled, expired). */
291
+ form: { readonly gone?: true }
292
+ context: { readonly messages: ReadonlyArray<{ readonly role: "user" | "assistant"; readonly text: string }> }
293
+ pairCode: { readonly code: string; readonly url: string; readonly expiresAt: number }
294
+ handoff: { readonly notified: number }
295
+ openLink: { readonly url: string }
296
+ steer: { readonly inboxID: string }
297
+ interrupt: Record<string, never>
298
+ stop: { readonly stopped: readonly string[] }
299
+ summary: SessionSummary
300
+ diff: { readonly patch?: FilePatch }
301
+ conversation: { readonly agent: string }
302
+ models: { readonly models: readonly AvailableModel[] }
303
+ modelPrefs: { readonly prefs: SessionModelPrefs; readonly defaults: SessionModelPrefs }
304
+ setModelPrefs: { readonly prefs: SessionModelPrefs; readonly defaults: SessionModelPrefs }
305
+ timeline: TimelinePage
306
+ timelinePart: { readonly detail: unknown }
307
+ /** No `file` when the message has no image at that index; `tooLarge` when its bytes would not fit in a frame. */
308
+ timelineFile: { readonly file?: PromptImage; readonly tooLarge?: boolean }
309
+ expose: { readonly exposed: boolean }
310
+ create: { readonly sessionID: string; readonly openedIn?: "herdr" }
311
+ }
312
+
313
+ // ---------- both directions ----------
314
+
315
+ export interface Req<R = SpokeRequest | ServerRequest> {
316
+ readonly t: "req"
317
+ readonly id: string
318
+ readonly req: R
319
+ }
320
+
321
+ export interface Res {
322
+ readonly t: "res"
323
+ readonly id: string
324
+ readonly ok: boolean
325
+ readonly result?: unknown
326
+ readonly error?: string
327
+ }
328
+
329
+ export interface Bye {
330
+ readonly t: "bye"
331
+ readonly reason: string
332
+ }
333
+
334
+ export type SpokeMessage = Hello | Sessions | Terminals | Event | Req<SpokeRequest> | Res | Bye
335
+ export type ServerMessage = Welcome | Req<ServerRequest> | Res | Bye
336
+
337
+ // ---------- validation ----------
338
+
339
+ const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
340
+ const str = (v: unknown): v is string => typeof v === "string"
341
+ const nonEmpty = (v: unknown): v is string => typeof v === "string" && v.length > 0
342
+ const num = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v)
343
+ const ID = /^[A-Za-z0-9_.:@/+~-]{1,300}$/
344
+ const isModelSelection = (v: unknown): v is ModelSelection =>
345
+ isObj(v) && typeof v.providerID === "string" && ID.test(v.providerID) && typeof v.modelID === "string" && ID.test(v.modelID) && (v.variant === undefined || (typeof v.variant === "string" && ID.test(v.variant)))
346
+
347
+ /** Largest frame (in characters) either side accepts; a larger one is a protocol error. */
348
+ export const MAX_FRAME = 1_000_000
349
+
350
+ function isSession(v: unknown): v is SessionInfo {
351
+ return (
352
+ isObj(v) &&
353
+ nonEmpty(v.id) &&
354
+ str(v.title) &&
355
+ str(v.directory) &&
356
+ num(v.updated) &&
357
+ typeof v.owned === "boolean" &&
358
+ (v.parentID === undefined || str(v.parentID)) &&
359
+ (v.exposed === undefined || typeof v.exposed === "boolean") &&
360
+ (v.project === undefined || str(v.project)) &&
361
+ (v.projectName === undefined || str(v.projectName)) &&
362
+ (v.archived === undefined || typeof v.archived === "boolean") &&
363
+ (v.open === undefined || typeof v.open === "boolean") &&
364
+ (v.terminal === undefined || isTerminal(v.terminal))
365
+ )
366
+ }
367
+
368
+ const TERMINAL_STATUS = new Set(["idle", "working", "blocked", "done", "unknown"])
369
+
370
+ function isTerminal(v: unknown): v is TerminalInfo {
371
+ return (
372
+ isObj(v) &&
373
+ (v.source === "herdr" || v.source === "tui") &&
374
+ (v.workspaceID === undefined || str(v.workspaceID)) &&
375
+ (v.workspace === undefined || str(v.workspace)) &&
376
+ (v.title === undefined || str(v.title)) &&
377
+ (v.status === undefined || TERMINAL_STATUS.has(v.status as string))
378
+ )
379
+ }
380
+
381
+ function isHerdrInfo(v: unknown): boolean {
382
+ return (
383
+ isObj(v) &&
384
+ Array.isArray(v.workspaces) &&
385
+ v.workspaces.length <= 200 &&
386
+ v.workspaces.every((w) => isObj(w) && nonEmpty(w.id) && str(w.label) && Array.isArray(w.directories) && w.directories.every(str))
387
+ )
388
+ }
389
+
390
+ function isSpokeRequest(v: unknown): v is SpokeRequest {
391
+ if (!isObj(v)) return false
392
+ if (v.op === "pairCode") return true
393
+ if (v.op === "handoff") return nonEmpty(v.sessionID)
394
+ if (v.op === "openLink") return (v.sessionID === undefined || nonEmpty(v.sessionID)) && (v.voice === undefined || typeof v.voice === "boolean")
395
+ return false
396
+ }
397
+
398
+ // ---------- prompt images ----------
399
+
400
+ /** Image types a prompt may carry. */
401
+ export const PROMPT_IMAGE_MIMES: readonly string[] = ["image/png", "image/jpeg", "image/webp", "image/gif"]
402
+ export const MAX_PROMPT_IMAGES = 4
403
+ /** Largest decoded image accepted. */
404
+ export const MAX_PROMPT_IMAGE_BYTES = 512 * 1024
405
+ /** Largest total base64 length of a prompt's images, so they fit in one frame with the text. */
406
+ export const MAX_PROMPT_IMAGES_BASE64 = 750_000
407
+ export const MAX_PROMPT_IMAGE_NAME_CHARS = 200
408
+
409
+ const BASE64 = /^[A-Za-z0-9+/]+={0,2}$/
410
+
411
+ /** Leading bytes of each accepted type (WebP: "RIFF" ... "WEBP"). */
412
+ function sniffImage(bytes: Uint8Array): string | undefined {
413
+ const at = (offset: number, ...values: number[]) => values.every((value, i) => bytes[offset + i] === value)
414
+ if (at(0, 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)) return "image/png"
415
+ if (at(0, 0xff, 0xd8, 0xff)) return "image/jpeg"
416
+ if (at(0, 0x47, 0x49, 0x46, 0x38)) return "image/gif"
417
+ if (at(0, 0x52, 0x49, 0x46, 0x46) && at(8, 0x57, 0x45, 0x42, 0x50)) return "image/webp"
418
+ return undefined
419
+ }
420
+
421
+ /**
422
+ * Why `value` is not an acceptable list of prompt images, or undefined when it is: at most
423
+ * MAX_PROMPT_IMAGES, each a known image type whose bytes match it, canonical base64 (OpenCode refuses
424
+ * anything else) of at most MAX_PROMPT_IMAGE_BYTES, together at most MAX_PROMPT_IMAGES_BASE64 characters.
425
+ */
426
+ export function promptImagesProblem(value: unknown): string | undefined {
427
+ if (!Array.isArray(value)) return "images must be an array"
428
+ if (value.length > MAX_PROMPT_IMAGES) return `Attach at most ${MAX_PROMPT_IMAGES} images`
429
+ let total = 0
430
+ for (const [i, image] of value.entries()) {
431
+ const label = `Image ${i + 1}`
432
+ if (!isObj(image)) return `${label} must be {mime, data, name?}`
433
+ if (typeof image.mime !== "string" || !PROMPT_IMAGE_MIMES.includes(image.mime)) return `${label}: type must be one of ${PROMPT_IMAGE_MIMES.join(", ")}`
434
+ if (image.name !== undefined && (typeof image.name !== "string" || image.name.length > MAX_PROMPT_IMAGE_NAME_CHARS)) {
435
+ return `${label}: name must be a string of at most ${MAX_PROMPT_IMAGE_NAME_CHARS} characters`
436
+ }
437
+ const data = image.data
438
+ if (typeof data !== "string" || !data || data.length % 4 !== 0 || !BASE64.test(data)) return `${label}: data must be non-empty base64`
439
+ total += data.length
440
+ if (total > MAX_PROMPT_IMAGES_BASE64) return `Images together are larger than ${MAX_PROMPT_IMAGES_BASE64} base64 characters`
441
+ if ((data.length / 4) * 3 > MAX_PROMPT_IMAGE_BYTES + 2) return `${label} is larger than ${MAX_PROMPT_IMAGE_BYTES / 1024} KB`
442
+ const bytes = Buffer.from(data, "base64")
443
+ if (bytes.toString("base64") !== data) return `${label}: data must be canonical base64`
444
+ if (bytes.length > MAX_PROMPT_IMAGE_BYTES) return `${label} is larger than ${MAX_PROMPT_IMAGE_BYTES / 1024} KB`
445
+ if (sniffImage(bytes) !== image.mime) return `${label}: content does not match ${image.mime}`
446
+ }
447
+ return undefined
448
+ }
449
+
450
+ /** Text, plus optional images; the text may be empty only when images are attached. */
451
+ function promptBody(v: Record<string, unknown>): boolean {
452
+ if (!str(v.text)) return false
453
+ if (v.images === undefined) return v.text.length > 0
454
+ return Array.isArray(v.images) && v.images.length > 0 && promptImagesProblem(v.images) === undefined
455
+ }
456
+
457
+ function isServerRequest(v: unknown): v is ServerRequest {
458
+ if (!isObj(v)) return false
459
+ switch (v.op) {
460
+ case "turn":
461
+ return nonEmpty(v.sessionID) && promptBody(v) && typeof v.voiceStyle === "boolean"
462
+ case "permission":
463
+ return nonEmpty(v.sessionID) && nonEmpty(v.requestID) && ["once", "always", "reject"].includes(v.decision as string)
464
+ case "form":
465
+ if (!nonEmpty(v.sessionID) || !nonEmpty(v.formID) || v.formID.length > 300) return false
466
+ return v.cancel === true ? v.answer === undefined : v.cancel === undefined && formAnswerProblem(v.answer) === undefined
467
+ case "context":
468
+ case "interrupt":
469
+ case "stop":
470
+ case "summary":
471
+ case "models":
472
+ case "modelPrefs":
473
+ return nonEmpty(v.sessionID)
474
+ case "setModelPrefs":
475
+ return (
476
+ nonEmpty(v.sessionID) &&
477
+ (v.voice === undefined || v.voice === null || isModelSelection(v.voice)) &&
478
+ (v.worker === undefined || v.worker === null || isModelSelection(v.worker)) &&
479
+ (v.voice !== undefined || v.worker !== undefined)
480
+ )
481
+ case "timeline":
482
+ return (
483
+ nonEmpty(v.sessionID) &&
484
+ (v.limit === undefined || (Number.isInteger(v.limit) && Number(v.limit) >= 1 && Number(v.limit) <= 200)) &&
485
+ (v.cursor === undefined || (str(v.cursor) && v.cursor.length <= 4096))
486
+ )
487
+ case "timelinePart":
488
+ return nonEmpty(v.sessionID) && nonEmpty(v.messageID) && v.messageID.length <= 300 && nonEmpty(v.partID) && v.partID.length <= 300
489
+ case "steer":
490
+ return nonEmpty(v.sessionID) && promptBody(v)
491
+ case "timelineFile":
492
+ return nonEmpty(v.sessionID) && nonEmpty(v.messageID) && v.messageID.length <= 300 && Number.isInteger(v.index) && Number(v.index) >= 0 && Number(v.index) < 1000
493
+ case "diff":
494
+ return nonEmpty(v.sessionID) && nonEmpty(v.file) && v.file.length <= 4096
495
+ case "conversation":
496
+ case "expose":
497
+ return nonEmpty(v.sessionID) && typeof v.on === "boolean"
498
+ case "create":
499
+ return (
500
+ nonEmpty(v.directory) &&
501
+ v.directory.startsWith("/") &&
502
+ v.directory.length <= 4096 &&
503
+ (v.title === undefined || (str(v.title) && v.title.length <= 200)) &&
504
+ (v.herdrWorkspace === undefined || (nonEmpty(v.herdrWorkspace) && v.herdrWorkspace.length <= 100))
505
+ )
506
+ default:
507
+ return false
508
+ }
509
+ }
510
+
511
+ const isRes = (v: Record<string, unknown>) =>
512
+ nonEmpty(v.id) && typeof v.ok === "boolean" && (v.error === undefined || str(v.error))
513
+ const isBye = (v: Record<string, unknown>) => str(v.reason)
514
+
515
+ function parse(raw: string | Buffer): Record<string, unknown> | undefined {
516
+ const text = typeof raw === "string" ? raw : raw.toString("utf8")
517
+ if (text.length > MAX_FRAME) return undefined
518
+ try {
519
+ const value = JSON.parse(text)
520
+ return isObj(value) ? value : undefined
521
+ } catch {
522
+ return undefined
523
+ }
524
+ }
525
+
526
+ /** Parses a frame received by the server. Returns undefined for anything invalid. */
527
+ export function parseSpokeMessage(raw: string | Buffer): SpokeMessage | undefined {
528
+ const m = parse(raw)
529
+ if (!m) return undefined
530
+ switch (m.t) {
531
+ case "hello":
532
+ return num(m.v) &&
533
+ nonEmpty(m.processID) &&
534
+ (m.kind === "service" || m.kind === "standalone") &&
535
+ str(m.directory) &&
536
+ num(m.pid) &&
537
+ (m.mode === "always" || m.mode === "session") &&
538
+ str(m.opencodeVersion) &&
539
+ str(m.pluginVersion)
540
+ ? (m as unknown as Hello)
541
+ : undefined
542
+ case "sessions":
543
+ return Array.isArray(m.sessions) && m.sessions.every(isSession) ? (m as unknown as Sessions) : undefined
544
+ case "terminals":
545
+ return m.herdr === undefined || isHerdrInfo(m.herdr) ? (m as unknown as Terminals) : undefined
546
+ case "event":
547
+ return isObj(m.event) && nonEmpty(m.event.type) && FORWARDED_EVENTS.has(m.event.type) && isObj(m.event.data)
548
+ ? (m as unknown as Event)
549
+ : undefined
550
+ case "req":
551
+ return nonEmpty(m.id) && isSpokeRequest(m.req) ? (m as unknown as Req<SpokeRequest>) : undefined
552
+ case "res":
553
+ return isRes(m) ? (m as unknown as Res) : undefined
554
+ case "bye":
555
+ return isBye(m) ? (m as unknown as Bye) : undefined
556
+ default:
557
+ return undefined
558
+ }
559
+ }
560
+
561
+ /** Parses a frame received by the spoke. Returns undefined for anything invalid. */
562
+ export function parseServerMessage(raw: string | Buffer): ServerMessage | undefined {
563
+ const m = parse(raw)
564
+ if (!m) return undefined
565
+ switch (m.t) {
566
+ case "welcome":
567
+ return num(m.v) && nonEmpty(m.machineID) && str(m.publicURL) ? (m as unknown as Welcome) : undefined
568
+ case "req":
569
+ return nonEmpty(m.id) && isServerRequest(m.req) ? (m as unknown as Req<ServerRequest>) : undefined
570
+ case "res":
571
+ return isRes(m) ? (m as unknown as Res) : undefined
572
+ case "bye":
573
+ return isBye(m) ? (m as unknown as Bye) : undefined
574
+ default:
575
+ return undefined
576
+ }
577
+ }
578
+
579
+ export const encode = (message: SpokeMessage | ServerMessage): string => JSON.stringify(message)
580
+
581
+ // ---------- HTTP enrollment (spoke ↔ server, before a machine token exists) ----------
582
+
583
+ /** POST /spoke/enroll body: { machineName } → EnrollStart */
584
+ export interface EnrollStart {
585
+ readonly userCode: string
586
+ readonly deviceCode: string
587
+ readonly approveURL: string
588
+ readonly intervalMs: number
589
+ readonly expiresAt: number
590
+ /** A local hub approved this first machine already; the next poll returns the token. */
591
+ readonly autoApproved?: boolean
592
+ }
593
+
594
+ /** POST /spoke/enroll/poll body: { deviceCode } → EnrollPoll */
595
+ export type EnrollPoll =
596
+ | { readonly status: "pending" }
597
+ | { readonly status: "denied" | "expired" }
598
+ | { readonly status: "approved"; readonly machineID: string; readonly machineToken: string }
599
+
600
+ // ---------- activity feed ----------
601
+
602
+ const SUMMARY_KEYS = ["description", "command", "filePath", "path", "pattern", "url", "query", "agent", "prompt"] as const
603
+ export const ACTIVITY_SUMMARY_MAX = 160
604
+ /** Longest reasoning delta forwarded; longer deltas are cut (they are normally a few tokens). */
605
+ export const REASONING_DELTA_MAX = 2000
606
+
607
+ /** Short human summary of a tool input, e.g. the shell command or file path. Never the full input. */
608
+ export function summarizeToolInput(input: unknown): string {
609
+ if (typeof input !== "object" || input === null) return ""
610
+ const record = input as Record<string, unknown>
611
+ for (const key of SUMMARY_KEYS) {
612
+ const value = record[key]
613
+ if (typeof value === "string" && value.trim()) {
614
+ const line = value.trim().split("\n")[0]!
615
+ return line.length > ACTIVITY_SUMMARY_MAX ? `${line.slice(0, ACTIVITY_SUMMARY_MAX - 1)}…` : line
616
+ }
617
+ }
618
+ return ""
619
+ }
620
+
621
+ /**
622
+ * Reduces activity events to what the activity feed needs. Returns the event unchanged when it is
623
+ * not an activity event, or undefined when it should be dropped.
624
+ */
625
+ export function compactActivity(event: ForwardedEvent): ForwardedEvent | undefined {
626
+ const d = event.data
627
+ const base = { sessionID: d.sessionID, assistantMessageID: d.assistantMessageID, id: d.id }
628
+ switch (event.type) {
629
+ case "session.tool.input.started":
630
+ return { type: event.type, data: { ...base, name: d.name } }
631
+ case "session.tool.called":
632
+ return { type: event.type, data: { ...base, summary: summarizeToolInput(d.input), background: (d.input as any)?.background === true } }
633
+ case "session.tool.success":
634
+ return { type: event.type, data: base }
635
+ case "session.tool.failed": {
636
+ const message = typeof (d.error as any)?.message === "string" ? String((d.error as any).message).slice(0, ACTIVITY_SUMMARY_MAX) : "failed"
637
+ return { type: event.type, data: { ...base, error: message } }
638
+ }
639
+ case "session.reasoning.started":
640
+ case "session.reasoning.ended":
641
+ return { type: event.type, data: { sessionID: d.sessionID, assistantMessageID: d.assistantMessageID } }
642
+ case "session.reasoning.delta":
643
+ return {
644
+ type: event.type,
645
+ data: { sessionID: d.sessionID, assistantMessageID: d.assistantMessageID, delta: typeof d.delta === "string" ? d.delta.slice(0, REASONING_DELTA_MAX) : "" },
646
+ }
647
+ default:
648
+ return event
649
+ }
650
+ }
package/src/reply.ts ADDED
@@ -0,0 +1,27 @@
1
+ interface MessageLike {
2
+ readonly id: string
3
+ readonly type: string
4
+ readonly content?: ReadonlyArray<{ readonly type: string; readonly text?: string }> | null
5
+ }
6
+
7
+ const textOf = (message: MessageLike) =>
8
+ (message.content ?? [])
9
+ .filter((part) => part.type === "text" && part.text)
10
+ .map((part) => part.text)
11
+ .join("")
12
+ .trim()
13
+
14
+ /**
15
+ * The spoken answer to a prompt: the text of the last assistant message after the prompt's
16
+ * user message. Tool-only final messages fall back to the latest earlier assistant text.
17
+ */
18
+ export function extractReply(messages: ReadonlyArray<MessageLike>, userMessageID: string): string {
19
+ const start = messages.findIndex((message) => message.id === userMessageID)
20
+ if (start === -1) return ""
21
+ const assistants = messages.slice(start + 1).filter((message) => message.type === "assistant")
22
+ for (let index = assistants.length - 1; index >= 0; index--) {
23
+ const text = textOf(assistants[index]!)
24
+ if (text) return text
25
+ }
26
+ return ""
27
+ }