@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
@@ -0,0 +1,1215 @@
1
+ // Central server ↔ spoke protocol, version 1. JSON text frames over a WebSocket at /spoke/ws.
2
+ // A spoke (a harness plugin; OpenCode first) connects outbound with `Authorization: Bearer <machineToken>`.
3
+ // Every frame is one object with a `t` discriminator. Unknown or malformed frames are rejected.
4
+ //
5
+ // Core and features: the core ops (CORE_OPS: turn, steer, interrupt, stop, permission, form) and the optional
6
+ // features a spoke announces in its hello (SPOKE_FEATURES); the hub uses only what a spoke serves (spokeFeatures).
7
+ // An OpenCode spoke (a hello without `harness` or with harness "opencode") serves every core op without announcing
8
+ // it; any other harness announces the core ops it serves, like any feature. A hello without `harness` comes from an
9
+ // OpenCode spoke that predates this split and also implies IMPLIED_OPENCODE_FEATURES. The hub announces its own features in the welcome (HUB_FEATURES), and a spoke
10
+ // sends a newer request only when the hub listed it. Neither side bumps PROTOCOL_VERSION for a new feature.
11
+ //
12
+ // Modules: harness-events.ts (the forwarded events a non-OpenCode spoke may send, with their shapes), launch.ts
13
+ // (launch vocabulary and workspace checks), node.ts (the `hub node` hello, frames and ops on the same socket),
14
+ // preset.ts (the harness preset schema), webhook.ts (webhook signature format), wire.ts (shared frame primitives).
15
+ // All are re-exported here.
16
+
17
+ import { featureList, HUB_URL_MAX_CHARS, ID, isByeFrame as isBye, isHubURL, isObj, isResFrame as isRes, isWelcomeFrame, MAX_FRAME, MAX_RETRY_AFTER_MS, nonEmpty, num, parseFrame as parse, str, token, UNSAFE_CHARS } from "./wire.ts"
18
+ import type { NodeMessage, NodeServerMessage } from "./node.ts"
19
+
20
+ export * from "./harness-events.ts"
21
+ export * from "./launch.ts"
22
+ export * from "./node.ts"
23
+ export * from "./preset.ts"
24
+ export * from "./webhook.ts"
25
+ export { HUB_URL_MAX_CHARS, isHubURL, MAX_FRAME, MAX_RETRY_AFTER_MS }
26
+
27
+ export const PROTOCOL_VERSION = 1
28
+
29
+ export type SessionState = "idle" | "working" | "blocked" | "error"
30
+ export type ProcessKind = "service" | "standalone"
31
+ export type SpokeMode = "always" | "session"
32
+ export type PermissionDecision = "once" | "always" | "reject"
33
+
34
+ /** An answer to an OpenCode form (question): field key → value, like OpenCode's FormAnswer. */
35
+ export type FormAnswer = Readonly<Record<string, string | number | boolean | readonly string[]>>
36
+
37
+ export const FORM_ANSWER_MAX_KEYS = 50
38
+ export const FORM_ANSWER_MAX_CHARS = 20_000
39
+ export const FORM_ANSWER_MAX_ITEMS = 100
40
+
41
+ /** Why an answer object is not acceptable, or undefined when it is. */
42
+ export function formAnswerProblem(v: unknown): string | undefined {
43
+ if (typeof v !== "object" || v === null || Array.isArray(v)) return "answer must be an object"
44
+ const entries = Object.entries(v)
45
+ if (entries.length > FORM_ANSWER_MAX_KEYS) return `answer has more than ${FORM_ANSWER_MAX_KEYS} fields`
46
+ for (const [key, value] of entries) {
47
+ if (!key || key.length > 300) return "answer keys must be 1–300 characters"
48
+ if (typeof value === "string" && value.length <= FORM_ANSWER_MAX_CHARS) continue
49
+ if (typeof value === "boolean" || (typeof value === "number" && Number.isFinite(value))) continue
50
+ if (
51
+ Array.isArray(value) &&
52
+ value.length <= FORM_ANSWER_MAX_ITEMS &&
53
+ value.every((item) => typeof item === "string" && item.length <= FORM_ANSWER_MAX_CHARS)
54
+ ) continue
55
+ return `answer.${key} must be text, a number, true/false or a list of texts`
56
+ }
57
+ return undefined
58
+ }
59
+
60
+ export interface ModelSelection {
61
+ readonly providerID: string
62
+ /** OpenCode's catalog id of the model (Model.Info `id`, what switchModel takes), not its upstream `modelID`. */
63
+ readonly modelID: string
64
+ readonly variant?: string
65
+ }
66
+
67
+ export interface SessionModelPrefs {
68
+ readonly voice?: ModelSelection
69
+ readonly worker?: ModelSelection
70
+ }
71
+
72
+ export interface AvailableModel {
73
+ readonly providerID: string
74
+ readonly modelID: string
75
+ readonly name: string
76
+ readonly enabled: boolean
77
+ readonly status: string
78
+ readonly variants: readonly string[]
79
+ }
80
+
81
+ /**
82
+ * A timeline page. Forward mode (`cursor`): oldest rows first, `cursor` for the next newer page. Newest-first
83
+ * mode (`order: "desc"` or `before`): the newest rows (before `before`) in chronological order, `older` for the
84
+ * page before them. Both cursors are opaque. Items are the spoke's rows (src/spoke/timeline.ts TimelineItem); newer
85
+ * spokes add optional `pos` (ordering position), `callID`, `childSessionID`, `title` and `ordinal`.
86
+ */
87
+ export interface TimelinePage {
88
+ readonly items: readonly unknown[]
89
+ readonly cursor?: string
90
+ readonly older?: string
91
+ }
92
+
93
+ /** Timeline row kinds. "message": a hub message (src/spoke/timeline.ts has the others). */
94
+ export const TIMELINE_KINDS = ["user", "assistant", "reasoning", "tool", "message"] as const
95
+ export type TimelineKind = (typeof TIMELINE_KINDS)[number]
96
+
97
+ /** A message row's direction from the session's point of view: delivered to it (in) or sent by it (out). */
98
+ export const MESSAGE_DIRECTIONS = ["in", "out"] as const
99
+ export type MessageDirection = (typeof MESSAGE_DIRECTIONS)[number]
100
+
101
+ /**
102
+ * A hub message as a timeline row. Inbound rows come from the spoke's timeline (a message the hub delivered,
103
+ * `messageID` the deliver messageID); outbound rows come from the hub's message store. `text` is the body,
104
+ * without the envelope.
105
+ */
106
+ export interface TimelineMessageItem {
107
+ readonly key: string
108
+ readonly messageID: string
109
+ readonly sessionID: string
110
+ readonly kind: "message"
111
+ readonly text: string
112
+ readonly at?: number
113
+ /** Ordering position, as for the other rows. */
114
+ readonly pos?: number
115
+ readonly direction: MessageDirection
116
+ readonly message: Pick<DeliverMeta, "kind" | "from" | "to" | "replyTo">
117
+ }
118
+
119
+ export interface SessionInfo {
120
+ readonly id: string
121
+ readonly title: string
122
+ readonly directory: string
123
+ readonly parentID?: string
124
+ /** ms epoch of last update */
125
+ readonly updated: number
126
+ /** True when this process currently owns the session: it viewed or executed it most recently. */
127
+ readonly owned: boolean
128
+ /**
129
+ * Whether voice is enabled for this session (always true in mode "always"). Sessions with
130
+ * exposed:false are listed for discovery only: no events are forwarded and no ops run on them.
131
+ */
132
+ readonly exposed?: boolean
133
+ /** Workspace (project) root the session belongs to; differs from directory for worktrees/subfolders. */
134
+ readonly project?: string
135
+ readonly projectName?: string
136
+ readonly archived?: boolean
137
+ /** Open in a terminal right now (a Herdr pane or an OpenCode TUI showing it). */
138
+ readonly open?: boolean
139
+ readonly terminal?: TerminalInfo
140
+ }
141
+
142
+ export interface TerminalInfo {
143
+ /** Where "open" was detected. */
144
+ readonly source: "herdr" | "tui"
145
+ /** Herdr workspace ID and label (e.g. "research"), when open in Herdr. */
146
+ readonly workspaceID?: string
147
+ readonly workspace?: string
148
+ /** Pane/terminal title, e.g. "OC | Fix the login redirect". */
149
+ readonly title?: string
150
+ /** Herdr's agent status for the pane. */
151
+ readonly status?: "idle" | "working" | "blocked" | "done" | "unknown"
152
+ }
153
+
154
+ /** A Herdr workspace on this machine (a target for new sessions). */
155
+ export interface HerdrWorkspace {
156
+ readonly id: string
157
+ readonly label: string
158
+ /** Folders of the panes in this workspace, most used first (lets the phone suggest a directory). */
159
+ readonly directories: readonly string[]
160
+ }
161
+
162
+ /** Machine-level terminal context, sent by one spoke per machine (the service spoke, else a standalone). */
163
+ export interface Terminals {
164
+ readonly t: "terminals"
165
+ readonly herdr?: { readonly workspaces: readonly HerdrWorkspace[] }
166
+ }
167
+
168
+ /** Subset of OpenCode events the spoke forwards (see FORWARDED_EVENTS; HARNESS_EVENTS for other harnesses). */
169
+ export interface ForwardedEvent {
170
+ readonly type: string
171
+ readonly data: Record<string, unknown>
172
+ }
173
+
174
+ export const FORWARDED_EVENTS: ReadonlySet<string> = new Set([
175
+ "session.created",
176
+ "session.renamed",
177
+ "session.deleted",
178
+ "session.viewed",
179
+ "session.execution.started",
180
+ "session.execution.succeeded",
181
+ "session.execution.failed",
182
+ "session.execution.interrupted",
183
+ "session.text.delta",
184
+ "session.text.ended",
185
+ // data: InboxDeliveredData. Newer spokes add `origin` (who put the prompt in the inbox); the type is unchanged,
186
+ // so older hubs keep accepting it.
187
+ "session.inbox.delivered",
188
+ "session.agent.selected",
189
+ // Activity feed. The spoke sends these in compact form (see compactActivity): tool inputs are
190
+ // reduced to a short summary and results to ok/error, never full file contents or outputs.
191
+ "session.tool.input.started",
192
+ "session.tool.called",
193
+ "session.tool.success",
194
+ "session.tool.failed",
195
+ "session.reasoning.started",
196
+ // Reasoning text streams to the phone's "Thinking" block (deltas capped at REASONING_DELTA_MAX); the
197
+ // ended event's full text is dropped.
198
+ "session.reasoning.delta",
199
+ "session.reasoning.ended",
200
+ "permission.asked",
201
+ "permission.replied",
202
+ "form.created",
203
+ "form.replied",
204
+ "form.cancelled",
205
+ // Running detection (compact form, see compactActivity): OpenCode's session status (busy/retry/idle) and the
206
+ // shells and terminals a session started, with the command (≤ JOB_COMMAND_MAX), status and exit code only.
207
+ "session.status",
208
+ "session.shell.started",
209
+ "session.shell.ended",
210
+ "shell.created",
211
+ "shell.exited",
212
+ "shell.deleted",
213
+ "pty.created",
214
+ "pty.updated",
215
+ "pty.exited",
216
+ "pty.deleted",
217
+ // Hub-defined: a session's status and running jobs as the spoke found them (startup, stream reconnect).
218
+ "session.seed",
219
+ ])
220
+
221
+ /** Who put a prompt into a session's inbox: the user (a typed or voice prompt) or the hub (a delivered message). */
222
+ export const INBOX_ORIGINS = ["user", "hub"] as const
223
+ export type InboxOrigin = (typeof INBOX_ORIGINS)[number]
224
+
225
+ /** `session.inbox.delivered` data as forwarded. `origin` is optional: older spokes do not send it. */
226
+ export interface InboxDeliveredData {
227
+ readonly sessionID: string
228
+ readonly inboxID: string
229
+ readonly origin?: InboxOrigin
230
+ }
231
+
232
+ /**
233
+ * OpenCode events the spoke forwards only in another (compact) form: a subagent tool's progress, which names the
234
+ * child session, goes out as a `session.tool.called` with `childSessionID` (a type every hub accepts).
235
+ */
236
+ export const COMPACTED_EVENTS: ReadonlySet<string> = new Set(["session.tool.progress"])
237
+
238
+ /** Longest job command forwarded. */
239
+ export const JOB_COMMAND_MAX = 200
240
+ /** Longest retry reason forwarded. */
241
+ export const RETRY_MESSAGE_MAX = 200
242
+ /** Most running jobs one seed event carries. */
243
+ export const SEED_JOBS_MAX = 20
244
+
245
+ export type JobKind = "shell" | "pty"
246
+ export type JobStatus = "running" | "exited" | "timeout" | "killed"
247
+
248
+ /** A shell or terminal a session started, as forwarded (no output, no environment). */
249
+ export interface JobInfo {
250
+ readonly id: string
251
+ readonly kind: JobKind
252
+ readonly command?: string
253
+ readonly status: JobStatus
254
+ readonly exit?: number
255
+ readonly background?: true
256
+ readonly started?: number
257
+ readonly ended?: number
258
+ }
259
+
260
+ /** OpenCode's session status, compact. */
261
+ export type CompactSessionStatus = { readonly type: "idle" | "busy" } | { readonly type: "retry"; readonly attempt?: number; readonly message?: string }
262
+
263
+ // ---------- spoke → server ----------
264
+
265
+ export interface Hello {
266
+ readonly t: "hello"
267
+ readonly v: number
268
+ readonly processID: string
269
+ readonly kind: ProcessKind
270
+ readonly directory: string
271
+ readonly pid: number
272
+ readonly mode: SpokeMode
273
+ /**
274
+ * OpenCode's version: required in an OpenCode hello (no `harness`, or harness "opencode"), optional for other
275
+ * harnesses. Hubs that predate `harness` require it from every spoke, so a non-OpenCode spoke that wants to reach
276
+ * them sends it anyway (its own version, or "").
277
+ */
278
+ readonly opencodeVersion?: string
279
+ readonly pluginVersion: string
280
+ /** Optional ops this spoke supports (see SPOKE_FEATURES); older spokes send none. */
281
+ readonly features?: readonly string[]
282
+ /** Harness id, e.g. "opencode" (HARNESS_OPENCODE). Absent in older OpenCode spokes; see helloHarness. */
283
+ readonly harness?: string
284
+ readonly harnessVersion?: string
285
+ /** How the spoke observes activity (EVIDENCE_LEVELS); a level this hub does not know counts as "unknown". */
286
+ readonly evidence?: string
287
+ /**
288
+ * The launch this spoke was started for (a harness the node runs in a sandbox, connecting with a launch-bound
289
+ * token). Absent for ordinary spokes; hubs that predate it ignore it.
290
+ */
291
+ readonly launchID?: string
292
+ }
293
+
294
+ export const HARNESS_OPENCODE = "opencode"
295
+
296
+ /**
297
+ * How a spoke observes a session's activity: native harness events, lifecycle hooks, a harness RPC, or
298
+ * sampling the terminal screen. A session whose evidence is "unknown" never counts as idle.
299
+ */
300
+ export const EVIDENCE_LEVELS = ["native", "hooks", "rpc", "screen"] as const
301
+ export type EvidenceLevel = (typeof EVIDENCE_LEVELS)[number]
302
+
303
+ /**
304
+ * Features an OpenCode spoke that predates `harness` serves without announcing them (every op it handled
305
+ * then except autoName, which was always announced). Do not add features here: new ones must be announced.
306
+ */
307
+ export const IMPLIED_OPENCODE_FEATURES = [
308
+ "context",
309
+ "rename",
310
+ "summary",
311
+ "diff",
312
+ "conversation",
313
+ "models",
314
+ "modelPrefs",
315
+ "timeline",
316
+ "timelinePart",
317
+ "timelineFile",
318
+ "expose",
319
+ "create",
320
+ ] as const
321
+
322
+ /**
323
+ * Features a spoke announces in its hello, so the hub can refuse an op an older spoke would ignore.
324
+ * "sessionTokens": the spoke stores the session tokens the hub sends (sessionTokens op) for its sessions' shells.
325
+ * "deliver": the spoke admits hub messages into its sessions (deliver op).
326
+ * "hubPrompt": it admits deliver kind "prompt" (an agent's prompt under a grant) with a provenance header it renders.
327
+ * "permissionMessage": it passes a permission reply's `message` (a deny reason) on to the harness.
328
+ * "actTokens": it keeps the act tokens the hub sends (actTokens op) apart from session tokens, never in any
329
+ * environment, for its own act verbs only (the OpenCode hub tool).
330
+ * "launch": its create op takes a launch's `launchID`, `agent` and `model` (see isLaunchCreate); a launch create's
331
+ * directory may be a new workspace under the machine's node workspace roots rather than one the spoke reported.
332
+ */
333
+ const OPTIONAL_SPOKE_FEATURES = [...IMPLIED_OPENCODE_FEATURES, "autoName", "sessionTokens", "deliver", "hubPrompt", "permissionMessage", "actTokens", "launch"] as const
334
+
335
+ /**
336
+ * The core ops. An OpenCode spoke serves them all without announcing them; a spoke of another harness announces
337
+ * each one it serves (a hooks spoke may serve "permission" but not "turn"), and the hub sends only those. Hubs that
338
+ * predate this ignore the announcement and send them anyway, so announcing them is additive.
339
+ */
340
+ export const CORE_OPS = ["turn", "steer", "interrupt", "stop", "permission", "form"] as const
341
+ export type CoreOp = (typeof CORE_OPS)[number]
342
+
343
+ /**
344
+ * Every feature a spoke may serve: the core ops, the optional features, and "turnEvents": a spoke of another
345
+ * harness reports every turn's end as the harness confirms it (session.execution.succeeded, failed or interrupted,
346
+ * never inferred), so the hub trusts its "rpc" evidence for idle (e.g. a Codex app-server spoke's turn/completed).
347
+ * Not in SPOKE_FEATURES: an OpenCode spoke's native evidence already counts.
348
+ */
349
+ export const KNOWN_SPOKE_FEATURES = [...CORE_OPS, ...OPTIONAL_SPOKE_FEATURES, "turnEvents"] as const
350
+ export type SpokeFeature = (typeof KNOWN_SPOKE_FEATURES)[number]
351
+ /**
352
+ * What this package's OpenCode spoke announces (without "deliver" and "hubPrompt" when its OpenCode has no synthetic
353
+ * messages). No core op: an OpenCode spoke implies them.
354
+ */
355
+ export const SPOKE_FEATURES: readonly SpokeFeature[] = OPTIONAL_SPOKE_FEATURES
356
+
357
+ /**
358
+ * Features the hub announces in its welcome. "harness": it reads hello `harness`, `harnessVersion` and `evidence`.
359
+ * "messaging": it routes messages between sessions (it sends deliver to spokes that announce that feature).
360
+ * "nodes": it accepts a node hello (role "node", see node.ts) and sends node ops; a node backs off from a hub
361
+ * without it. "launches": it launches sessions (it sends launch creates to spokes that announce "launch").
362
+ */
363
+ export const KNOWN_HUB_FEATURES = ["harness", "messaging", "nodes", "launches"] as const
364
+ export type HubFeature = (typeof KNOWN_HUB_FEATURES)[number]
365
+ /** What this hub announces ("launches": the /api/agent/launches routes). */
366
+ export const HUB_FEATURES: readonly HubFeature[] = ["harness", "messaging", "nodes", "launches"]
367
+
368
+ /** A spoke's harness, version and evidence level; a hello without `harness` is an OpenCode native spoke. */
369
+ export function helloHarness(hello: Hello): { readonly harness: string; readonly harnessVersion: string; readonly evidence: EvidenceLevel | "unknown" } {
370
+ if (hello.harness === undefined) return { harness: HARNESS_OPENCODE, harnessVersion: hello.opencodeVersion ?? "", evidence: "native" }
371
+ const evidence = (EVIDENCE_LEVELS as readonly string[]).includes(hello.evidence ?? "") ? (hello.evidence as EvidenceLevel) : "unknown"
372
+ return { harness: hello.harness, harnessVersion: hello.harnessVersion ?? "", evidence }
373
+ }
374
+
375
+ /** Whether a hello comes from an OpenCode spoke: no `harness` (a spoke that predates it) or harness "opencode". */
376
+ export const isOpenCodeHello = (hello: Hello): boolean => hello.harness === undefined || hello.harness === HARNESS_OPENCODE
377
+
378
+ /**
379
+ * The features a spoke serves: those it announced, plus CORE_OPS for an OpenCode hello and IMPLIED_OPENCODE_FEATURES
380
+ * for a hello without `harness`.
381
+ */
382
+ export function spokeFeatures(hello: Hello): ReadonlySet<string> {
383
+ return new Set([
384
+ ...(isOpenCodeHello(hello) ? CORE_OPS : []),
385
+ ...(hello.harness === undefined ? IMPLIED_OPENCODE_FEATURES : []),
386
+ ...(hello.features ?? []),
387
+ ])
388
+ }
389
+
390
+ export interface Sessions {
391
+ readonly t: "sessions"
392
+ readonly sessions: readonly SessionInfo[]
393
+ }
394
+
395
+ export interface Event {
396
+ readonly t: "event"
397
+ readonly event: ForwardedEvent
398
+ }
399
+
400
+ /** Requests the spoke makes of the server on the user's behalf (from the /hub dialog). */
401
+ export type SpokeRequest =
402
+ | { readonly op: "pairCode" }
403
+ | { readonly op: "handoff"; readonly sessionID: string }
404
+ /** A one-time sign-in link to a session's page, or to the home page without a session. */
405
+ /** `voice`: land on the session with Voice mode starting (`?voice=1`). */
406
+ | { readonly op: "openLink"; readonly sessionID?: string; readonly voice?: boolean }
407
+
408
+ // ---------- server → spoke ----------
409
+
410
+ export interface Welcome {
411
+ readonly t: "welcome"
412
+ readonly v: number
413
+ readonly machineID: string
414
+ readonly publicURL: string
415
+ /** The hub's features (see HUB_FEATURES); older hubs send none. */
416
+ readonly features?: readonly string[]
417
+ }
418
+
419
+ /** An image attached to a typed prompt: base64 bytes of a PNG, JPEG, WebP or GIF (see promptImagesProblem). */
420
+ export interface PromptImage {
421
+ readonly mime: string
422
+ readonly data: string
423
+ readonly name?: string
424
+ }
425
+
426
+ /** A hub-minted session token for one session (design §1.3); `exp` is its expiry in ms since the epoch. */
427
+ export interface SessionToken {
428
+ readonly sessionID: string
429
+ readonly token: string
430
+ readonly exp: number
431
+ }
432
+
433
+ /** Most tokens one sessionTokens request carries (see batchSessionTokens). */
434
+ export const SESSION_TOKENS_MAX = 500
435
+ /** Longest session token: printable ASCII without whitespace. */
436
+ export const SESSION_TOKEN_MAX_CHARS = 4096
437
+
438
+ /** Why a spoke did not store a session token: it does not report the session, or the token has expired. */
439
+ export type SessionTokenSkipReason = "unknown" | "expired"
440
+
441
+ // ---------- messaging (deliver) ----------
442
+
443
+ /**
444
+ * What a hub message is: a tell, an ask, its reply or decline, the identity primer, a Decision answer, or a prompt
445
+ * an agent sends under a grant (feature "hubPrompt"; `meta.grant` names the grant).
446
+ */
447
+ export const MESSAGE_KINDS = ["msg", "ask", "reply", "decline", "primer", "decision", "prompt"] as const
448
+ export type MessageKind = (typeof MESSAGE_KINDS)[number]
449
+
450
+ /** How OpenCode admits a message into a busy session: after the running turn (queue) or into it (steer). */
451
+ export const DELIVERY_MODES = ["queue", "steer"] as const
452
+ export type DeliveryMode = (typeof DELIVERY_MODES)[number]
453
+
454
+ /**
455
+ * How a spoke delivered a message: into the running turn (steer), as a new turn (turn), admitted without waking
456
+ * or behind the running turn (queued), or held by the spoke for later, e.g. while a form is open (retained).
457
+ */
458
+ export const DELIVERED = ["steer", "turn", "queued", "retained"] as const
459
+ export type Delivered = (typeof DELIVERED)[number]
460
+
461
+ /** Longest deliver text (the hub-rendered envelope included). */
462
+ export const DELIVER_TEXT_MAX = 16_000
463
+ /** Longest id or address in a deliver request. */
464
+ export const DELIVER_ID_MAX = 300
465
+
466
+ /** Who sent a message to whom, as the hub resolved them; the spoke never sets these. */
467
+ export interface DeliverMeta {
468
+ readonly kind: MessageKind
469
+ /** The authenticated sender's address, e.g. "@api-refactor". */
470
+ readonly from: string
471
+ /** The address the sender wrote, e.g. "@@reviewer". */
472
+ readonly to: string
473
+ /** The message a reply or decline answers (required for those kinds). */
474
+ readonly replyTo?: string
475
+ /** How to admit the message into a busy session; the spoke picks when absent. */
476
+ readonly delivery?: DeliveryMode
477
+ /** The grant a prompt acts under (required for kind "prompt", refused for the others). */
478
+ readonly grant?: string
479
+ }
480
+
481
+ export type DeliverRequest = Extract<ServerRequest, { op: "deliver" }>
482
+
483
+ export interface DeliverResult {
484
+ readonly delivered: Delivered
485
+ /** The harness's inbox entry for the message, when it reports one. */
486
+ readonly inboxID?: string
487
+ }
488
+
489
+ export type ServerRequest =
490
+ /** `text` may be empty when `images` are attached. */
491
+ | { readonly op: "turn"; readonly sessionID: string; readonly text: string; readonly voiceStyle: boolean; readonly images?: readonly PromptImage[] }
492
+ /**
493
+ * `message` (reject only, feature "permissionMessage"): why, passed to the harness (OpenCode's reply message, which
494
+ * the model sees as the rejection's feedback).
495
+ */
496
+ | { readonly op: "permission"; readonly sessionID: string; readonly requestID: string; readonly decision: PermissionDecision; readonly message?: string }
497
+ /** Answer (or cancel) a pending form (question) in the session that asked it. */
498
+ | { readonly op: "form"; readonly sessionID: string; readonly formID: string; readonly answer: FormAnswer }
499
+ | { readonly op: "form"; readonly sessionID: string; readonly formID: string; readonly cancel: true }
500
+ | { readonly op: "context"; readonly sessionID: string }
501
+ /** Conversation mode: deliver an utterance with delivery "steer" and return at once (replies arrive as events). */
502
+ | { readonly op: "steer"; readonly sessionID: string; readonly text: string; readonly images?: readonly PromptImage[] }
503
+ | { readonly op: "interrupt"; readonly sessionID: string }
504
+ /** Rename the session in OpenCode; the spoke trims the title and requires 1–200 characters. */
505
+ | { readonly op: "rename"; readonly sessionID: string; readonly title: string }
506
+ /**
507
+ * Have OpenCode regenerate the title with its title agent (session.update with an empty title). Only for
508
+ * spokes whose hello lists the "autoName" feature (older spokes drop unknown ops).
509
+ */
510
+ | { readonly op: "autoName"; readonly sessionID: string }
511
+ /** Interrupt the session's whole tree: running descendants deepest first, then the root. */
512
+ | { readonly op: "stop"; readonly sessionID: string }
513
+ /** Right-rail data: context usage, MCP servers, branch and working-tree change stats. */
514
+ | { readonly op: "summary"; readonly sessionID: string }
515
+ /** One changed file's patch in the session's directory. */
516
+ | { readonly op: "diff"; readonly sessionID: string; readonly file: string }
517
+ /** Switch the session to the voice orchestrator agent (on) or back to its previous agent (off). */
518
+ | { readonly op: "conversation"; readonly sessionID: string; readonly on: boolean }
519
+ | { readonly op: "models"; readonly sessionID: string }
520
+ | { readonly op: "modelPrefs"; readonly sessionID: string }
521
+ | { readonly op: "setModelPrefs"; readonly sessionID: string; readonly voice?: ModelSelection | null; readonly worker?: ModelSelection | null }
522
+ | { readonly op: "timeline"; readonly sessionID: string; readonly limit?: number; readonly cursor?: string; readonly order?: "asc" | "desc"; readonly before?: string }
523
+ | { readonly op: "timelinePart"; readonly sessionID: string; readonly messageID: string; readonly partID: string }
524
+ /** One image attached to a user message (its position in the message's files). */
525
+ | { readonly op: "timelineFile"; readonly sessionID: string; readonly messageID: string; readonly index: number }
526
+ /** Enable or disable voice for one session (adds/removes it from the machine config's session list). */
527
+ | { readonly op: "expose"; readonly sessionID: string; readonly on: boolean }
528
+ /**
529
+ * Create a session in a workspace directory the spoke already reported. With `herdrWorkspace`, also open
530
+ * it in a new tab of that Herdr workspace (`opencode --session <id>` in the directory).
531
+ * Launch fields (feature "launch", see isLaunchCreate): `launchID` the launch the session is for, `agent` and
532
+ * `model` the agent and model to start it with (the spoke refuses an agent or model its harness lacks).
533
+ */
534
+ | {
535
+ readonly op: "create"
536
+ readonly directory: string
537
+ readonly title?: string
538
+ readonly herdrWorkspace?: string
539
+ readonly launchID?: string
540
+ readonly agent?: string
541
+ readonly model?: ModelSelection
542
+ }
543
+ /**
544
+ * Session tokens for sessions the spoke reported (feature "sessionTokens"); each replaces the session's older
545
+ * one. `hubURL`: the URL the sessions' shells reach the hub at. No session is adopted or exposed by this op.
546
+ * Send through batchSessionTokens so every frame stays under MAX_FRAME.
547
+ */
548
+ | { readonly op: "sessionTokens"; readonly tokens: readonly SessionToken[]; readonly hubURL?: string }
549
+ /**
550
+ * Act tokens (typ "act") for sessions the spoke reported (feature "actTokens"): the credential for act verbs. A
551
+ * separate op so a spoke that only knows sessionTokens never takes one for a shell token. The spoke keeps them
552
+ * apart from session tokens and never puts them in any environment. Send through batchActTokens.
553
+ */
554
+ | { readonly op: "actTokens"; readonly tokens: readonly SessionToken[] }
555
+ /**
556
+ * Admit a hub message into a session (feature "deliver"). `text` is the envelope the hub rendered; `messageID`
557
+ * is the idempotency key (spokes apply a message once). `wake: false` admits it without starting a turn.
558
+ * Kind "prompt" (feature "hubPrompt"): `text` is the prompt's body; the spoke renders the provenance header from
559
+ * `meta.from` and `meta.grant` around it, so the session never sees it as the user's prompt.
560
+ */
561
+ | { readonly op: "deliver"; readonly sessionID: string; readonly messageID: string; readonly text: string; readonly wake: boolean; readonly meta: DeliverMeta }
562
+
563
+ /**
564
+ * The feature a spoke must serve (spokeFeatures) for the hub to send `op`: the op itself for a core op (CORE_OPS,
565
+ * implied by OpenCode spokes), else the optional feature that serves it.
566
+ */
567
+ export function featureForOp(op: ServerRequest["op"]): SpokeFeature {
568
+ switch (op) {
569
+ case "setModelPrefs":
570
+ return "modelPrefs"
571
+ default:
572
+ return op
573
+ }
574
+ }
575
+
576
+ /** Every feature a spoke must announce for the hub to send `req`: its op's (featureForOp) and its options'. */
577
+ export function requestFeatures(req: ServerRequest): SpokeFeature[] {
578
+ const out: SpokeFeature[] = [featureForOp(req.op)]
579
+ if (req.op === "deliver" && req.meta.kind === "prompt") out.push("hubPrompt")
580
+ if (req.op === "permission" && req.message !== undefined) out.push("permissionMessage")
581
+ if (isLaunchCreate(req)) out.push("launch")
582
+ return out
583
+ }
584
+
585
+ export type CreateRequest = Extract<ServerRequest, { op: "create" }>
586
+
587
+ /** Whether `req` is a create carrying launch fields (feature "launch"). */
588
+ export function isLaunchCreate(req: ServerRequest): req is CreateRequest {
589
+ return req.op === "create" && (req.launchID !== undefined || req.agent !== undefined || req.model !== undefined)
590
+ }
591
+
592
+ /** Longest agent name a launch create carries. */
593
+ export const AGENT_NAME_MAX = 100
594
+
595
+ /** Longest permission reply message (deny reason). */
596
+ export const PERMISSION_MESSAGE_MAX = 1000
597
+
598
+ export interface ContextUsage {
599
+ /** Tokens of the latest assistant message (input + output + reasoning + cache). */
600
+ readonly used: number
601
+ readonly limit?: number
602
+ readonly percent?: number
603
+ readonly model?: string
604
+ }
605
+
606
+ export interface McpStatus {
607
+ readonly name: string
608
+ readonly status: "connected" | "pending" | "disabled" | "failed" | "needs_auth" | "unknown"
609
+ readonly error?: string
610
+ }
611
+
612
+ export interface ChangedFile {
613
+ readonly file: string
614
+ readonly status: "added" | "deleted" | "modified"
615
+ readonly additions: number
616
+ readonly deletions: number
617
+ }
618
+
619
+ export interface SessionChanges {
620
+ /** The listed files: all of them, or (when `truncated`) the most-changed ones that fit the limits. */
621
+ readonly files: readonly ChangedFile[]
622
+ /** Line totals over every changed file, listed or not. */
623
+ readonly additions: number
624
+ readonly deletions: number
625
+ /** Number of changed files, listed or not. */
626
+ readonly totalFiles: number
627
+ /** True when `files` lists fewer than `totalFiles`. */
628
+ readonly truncated: boolean
629
+ }
630
+
631
+ export interface FilePatch extends ChangedFile {
632
+ readonly patch: string
633
+ readonly truncated: boolean
634
+ }
635
+
636
+ /** Right-rail data for a session; each part is absent when OpenCode could not provide it. */
637
+ export interface SessionSummary {
638
+ readonly directory: string
639
+ readonly branch?: string
640
+ readonly context?: ContextUsage
641
+ readonly mcp?: readonly McpStatus[]
642
+ readonly changes?: SessionChanges
643
+ }
644
+
645
+ export type TurnResult =
646
+ | { readonly outcome: "succeeded"; readonly reply: string }
647
+ | { readonly outcome: "failed" | "interrupted"; readonly message: string; readonly reply: string }
648
+ | { readonly outcome: "timeout"; readonly reply: string }
649
+
650
+ /** Result payloads, keyed by op. */
651
+ export interface ResultFor {
652
+ turn: TurnResult
653
+ /** `gone`: the request is not pending in this process any more (answered elsewhere, expired). */
654
+ permission: { readonly gone?: true }
655
+ /** `gone`: the form is not pending in this process any more (answered elsewhere, cancelled, expired). */
656
+ form: { readonly gone?: true }
657
+ context: { readonly messages: ReadonlyArray<{ readonly role: "user" | "assistant"; readonly text: string }> }
658
+ pairCode: { readonly code: string; readonly url: string; readonly expiresAt: number }
659
+ handoff: { readonly notified: number }
660
+ openLink: { readonly url: string }
661
+ steer: { readonly inboxID: string }
662
+ interrupt: Record<string, never>
663
+ rename: { readonly title: string }
664
+ /**
665
+ * `title`: the new title. `unchanged`: OpenCode kept the title (`title` is the current one). `pending`: still
666
+ * generating after the spoke's wait; the catalog updates when it lands.
667
+ */
668
+ autoName: { readonly title?: string; readonly unchanged?: true; readonly pending?: true }
669
+ stop: { readonly stopped: readonly string[] }
670
+ summary: SessionSummary
671
+ diff: { readonly patch?: FilePatch }
672
+ conversation: { readonly agent: string }
673
+ models: { readonly models: readonly AvailableModel[] }
674
+ modelPrefs: { readonly prefs: SessionModelPrefs; readonly defaults: SessionModelPrefs }
675
+ setModelPrefs: { readonly prefs: SessionModelPrefs; readonly defaults: SessionModelPrefs }
676
+ timeline: TimelinePage
677
+ timelinePart: { readonly detail: unknown }
678
+ /** No `file` when the message has no image at that index; `tooLarge` when its bytes would not fit in a frame. */
679
+ timelineFile: { readonly file?: PromptImage; readonly tooLarge?: boolean }
680
+ expose: { readonly exposed: boolean }
681
+ create: { readonly sessionID: string; readonly openedIn?: "herdr" }
682
+ /** `applied`: sessions whose token the spoke stored; `skipped`: the others, with the reason. */
683
+ sessionTokens: { readonly applied: readonly string[]; readonly skipped: ReadonlyArray<{ readonly sessionID: string; readonly reason: SessionTokenSkipReason }> }
684
+ /** As sessionTokens. */
685
+ actTokens: { readonly applied: readonly string[]; readonly skipped: ReadonlyArray<{ readonly sessionID: string; readonly reason: SessionTokenSkipReason }> }
686
+ deliver: DeliverResult
687
+ }
688
+
689
+ // ---------- both directions ----------
690
+
691
+ export interface Req<R = SpokeRequest | ServerRequest> {
692
+ readonly t: "req"
693
+ readonly id: string
694
+ readonly req: R
695
+ }
696
+
697
+ export interface Res {
698
+ readonly t: "res"
699
+ readonly id: string
700
+ readonly ok: boolean
701
+ readonly result?: unknown
702
+ readonly error?: string
703
+ }
704
+
705
+ export interface Bye {
706
+ readonly t: "bye"
707
+ readonly reason: string
708
+ /**
709
+ * How soon to reconnect, in ms (0–MAX_RETRY_AFTER_MS): sent with reason "draining" by a hub pod that is shutting
710
+ * down, so the peer reconnects (to another pod) after a jittered delay up to this instead of its long backoff.
711
+ * Optional and additive: older peers ignore it.
712
+ */
713
+ readonly retryAfterMs?: number
714
+ }
715
+
716
+ export type SpokeMessage = Hello | Sessions | Terminals | Event | Req<SpokeRequest> | Res | Bye
717
+ export type ServerMessage = Welcome | Req<ServerRequest> | Res | Bye
718
+
719
+ // ---------- validation ----------
720
+
721
+ const isModelSelection = (v: unknown): v is ModelSelection =>
722
+ 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)))
723
+
724
+ const SESSION_TOKEN_TEXT = new RegExp(`^[\\x21-\\x7e]{1,${SESSION_TOKEN_MAX_CHARS}}$`)
725
+
726
+ function isSessionToken(v: unknown): v is SessionToken {
727
+ return isObj(v) && nonEmpty(v.sessionID) && v.sessionID.length <= 300 && str(v.token) && SESSION_TOKEN_TEXT.test(v.token) && num(v.exp)
728
+ }
729
+
730
+
731
+ /** Characters batchSessionTokens leaves free in a frame for the `{"t":"req","id":…}` envelope around a request. */
732
+ export const SESSION_TOKENS_ENVELOPE_CHARS = 1024
733
+
734
+ export type SessionTokensRequest = Extract<ServerRequest, { op: "sessionTokens" }>
735
+ export type ActTokensRequest = Extract<ServerRequest, { op: "actTokens" }>
736
+
737
+ /**
738
+ * Splits tokens into sessionTokens requests, in order, of at most SESSION_TOKENS_MAX tokens whose JSON is at most
739
+ * `maxChars` characters (default: MAX_FRAME less SESSION_TOKENS_ENVELOPE_CHARS). Each request carries `hubURL`.
740
+ * Throws a RangeError when one token alone does not fit `maxChars`.
741
+ */
742
+ export function batchSessionTokens(tokens: readonly SessionToken[], options: { readonly hubURL?: string; readonly maxChars?: number } = {}): SessionTokensRequest[] {
743
+ return batchTokens(tokens, (batch) => ({ op: "sessionTokens", tokens: batch, ...(options.hubURL !== undefined ? { hubURL: options.hubURL } : {}) }), options.maxChars)
744
+ }
745
+
746
+ /** Splits act tokens into actTokens requests, as batchSessionTokens does (no hub URL). */
747
+ export function batchActTokens(tokens: readonly SessionToken[], options: { readonly maxChars?: number } = {}): ActTokensRequest[] {
748
+ return batchTokens(tokens, (batch) => ({ op: "actTokens", tokens: batch }), options.maxChars)
749
+ }
750
+
751
+ function batchTokens<R>(tokens: readonly SessionToken[], request: (batch: readonly SessionToken[]) => R, max?: number): R[] {
752
+ const maxChars = max ?? MAX_FRAME - SESSION_TOKENS_ENVELOPE_CHARS
753
+ const empty = JSON.stringify(request([])).length
754
+ const out: R[] = []
755
+ let batch: SessionToken[] = []
756
+ let size = empty
757
+ for (const { sessionID, token, exp } of tokens) {
758
+ const entry = { sessionID, token, exp }
759
+ const chars = JSON.stringify(entry).length
760
+ if (empty + chars > maxChars) throw new RangeError(`A session token for ${sessionID} does not fit in ${maxChars} characters`)
761
+ if (batch.length > 0 && (batch.length >= SESSION_TOKENS_MAX || size + 1 + chars > maxChars)) {
762
+ out.push(request(batch))
763
+ batch = []
764
+ size = empty
765
+ }
766
+ size += chars + (batch.length > 0 ? 1 : 0)
767
+ batch.push(entry)
768
+ }
769
+ if (batch.length > 0) out.push(request(batch))
770
+ return out
771
+ }
772
+
773
+ function isSession(v: unknown): v is SessionInfo {
774
+ return (
775
+ isObj(v) &&
776
+ nonEmpty(v.id) &&
777
+ str(v.title) &&
778
+ str(v.directory) &&
779
+ num(v.updated) &&
780
+ typeof v.owned === "boolean" &&
781
+ (v.parentID === undefined || str(v.parentID)) &&
782
+ (v.exposed === undefined || typeof v.exposed === "boolean") &&
783
+ (v.project === undefined || str(v.project)) &&
784
+ (v.projectName === undefined || str(v.projectName)) &&
785
+ (v.archived === undefined || typeof v.archived === "boolean") &&
786
+ (v.open === undefined || typeof v.open === "boolean") &&
787
+ (v.terminal === undefined || isTerminal(v.terminal))
788
+ )
789
+ }
790
+
791
+ const TERMINAL_STATUS = new Set(["idle", "working", "blocked", "done", "unknown"])
792
+
793
+ function isTerminal(v: unknown): v is TerminalInfo {
794
+ return (
795
+ isObj(v) &&
796
+ (v.source === "herdr" || v.source === "tui") &&
797
+ (v.workspaceID === undefined || str(v.workspaceID)) &&
798
+ (v.workspace === undefined || str(v.workspace)) &&
799
+ (v.title === undefined || str(v.title)) &&
800
+ (v.status === undefined || TERMINAL_STATUS.has(v.status as string))
801
+ )
802
+ }
803
+
804
+ function isHerdrInfo(v: unknown): boolean {
805
+ return (
806
+ isObj(v) &&
807
+ Array.isArray(v.workspaces) &&
808
+ v.workspaces.length <= 200 &&
809
+ v.workspaces.every((w) => isObj(w) && nonEmpty(w.id) && str(w.label) && Array.isArray(w.directories) && w.directories.every(str))
810
+ )
811
+ }
812
+
813
+ /** A permission reply message: 1–PERMISSION_MESSAGE_MAX characters, newlines and tabs but no other unsafe characters. */
814
+ const permissionMessage = (v: unknown): v is string => nonEmpty(v) && v.length <= PERMISSION_MESSAGE_MAX && !UNSAFE_CHARS.test(v.replace(/[\n\t]/g, ""))
815
+
816
+ /** A non-empty address of at most DELIVER_ID_MAX characters without characters that could fake a header. */
817
+ const address = (v: unknown): v is string => nonEmpty(v) && v.length <= DELIVER_ID_MAX && !UNSAFE_CHARS.test(v)
818
+ const deliverID = (v: unknown): v is string => typeof v === "string" && v.length <= DELIVER_ID_MAX && ID.test(v)
819
+ /** An agent name: 1–AGENT_NAME_MAX printable characters, not starting with "-" or a space. */
820
+ const agentName = (v: unknown): v is string => nonEmpty(v) && v.length <= AGENT_NAME_MAX && !UNSAFE_CHARS.test(v) && !/^[-\s]/.test(v)
821
+
822
+ /** Why `v` is not an acceptable deliver request, or undefined when it is. */
823
+ export function deliverProblem(v: unknown): string | undefined {
824
+ if (!isObj(v)) return "deliver must be an object"
825
+ if (!nonEmpty(v.sessionID) || v.sessionID.length > DELIVER_ID_MAX) return `sessionID must be 1–${DELIVER_ID_MAX} characters`
826
+ if (!deliverID(v.messageID)) return `messageID must be 1–${DELIVER_ID_MAX} id characters`
827
+ if (!nonEmpty(v.text) || v.text.length > DELIVER_TEXT_MAX) return `text must be 1–${DELIVER_TEXT_MAX} characters`
828
+ if (typeof v.wake !== "boolean") return "wake must be true or false"
829
+ const meta = v.meta
830
+ if (!isObj(meta)) return "meta must be an object"
831
+ if (!(MESSAGE_KINDS as readonly unknown[]).includes(meta.kind)) return `meta.kind must be one of ${MESSAGE_KINDS.join(", ")}`
832
+ if (!address(meta.from)) return `meta.from must be an address of 1–${DELIVER_ID_MAX} printable characters`
833
+ if (!address(meta.to)) return `meta.to must be an address of 1–${DELIVER_ID_MAX} printable characters`
834
+ if (meta.replyTo !== undefined && !deliverID(meta.replyTo)) return `meta.replyTo must be 1–${DELIVER_ID_MAX} id characters`
835
+ if ((meta.kind === "reply" || meta.kind === "decline") && meta.replyTo === undefined) return `meta.replyTo is required for a ${meta.kind}`
836
+ if (meta.delivery !== undefined && !(DELIVERY_MODES as readonly unknown[]).includes(meta.delivery)) return `meta.delivery must be one of ${DELIVERY_MODES.join(", ")}`
837
+ if (meta.kind === "prompt" && meta.grant === undefined) return "meta.grant is required for a prompt"
838
+ if (meta.kind !== "prompt" && meta.grant !== undefined) return "meta.grant is only for a prompt"
839
+ if (meta.grant !== undefined && !deliverID(meta.grant)) return `meta.grant must be 1–${DELIVER_ID_MAX} id characters`
840
+ return undefined
841
+ }
842
+
843
+ /** Whether `v` is a well-formed deliver result (what the hub accepts back from a spoke). */
844
+ export function isDeliverResult(v: unknown): v is DeliverResult {
845
+ return isObj(v) && (DELIVERED as readonly unknown[]).includes(v.delivered) && (v.inboxID === undefined || (nonEmpty(v.inboxID) && v.inboxID.length <= DELIVER_ID_MAX))
846
+ }
847
+
848
+ function isSpokeRequest(v: unknown): v is SpokeRequest {
849
+ if (!isObj(v)) return false
850
+ if (v.op === "pairCode") return true
851
+ if (v.op === "handoff") return nonEmpty(v.sessionID)
852
+ if (v.op === "openLink") return (v.sessionID === undefined || nonEmpty(v.sessionID)) && (v.voice === undefined || typeof v.voice === "boolean")
853
+ return false
854
+ }
855
+
856
+ // ---------- prompt images ----------
857
+
858
+ /** Image types a prompt may carry. */
859
+ export const PROMPT_IMAGE_MIMES: readonly string[] = ["image/png", "image/jpeg", "image/webp", "image/gif"]
860
+ export const MAX_PROMPT_IMAGES = 4
861
+ /** Largest decoded image accepted. */
862
+ export const MAX_PROMPT_IMAGE_BYTES = 512 * 1024
863
+ /** Largest total base64 length of a prompt's images, so they fit in one frame with the text. */
864
+ export const MAX_PROMPT_IMAGES_BASE64 = 750_000
865
+ export const MAX_PROMPT_IMAGE_NAME_CHARS = 200
866
+
867
+ const BASE64 = /^[A-Za-z0-9+/]+={0,2}$/
868
+
869
+ /** Leading bytes of each accepted type (WebP: "RIFF" ... "WEBP"). */
870
+ function sniffImage(bytes: Uint8Array): string | undefined {
871
+ const at = (offset: number, ...values: number[]) => values.every((value, i) => bytes[offset + i] === value)
872
+ if (at(0, 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)) return "image/png"
873
+ if (at(0, 0xff, 0xd8, 0xff)) return "image/jpeg"
874
+ if (at(0, 0x47, 0x49, 0x46, 0x38)) return "image/gif"
875
+ if (at(0, 0x52, 0x49, 0x46, 0x46) && at(8, 0x57, 0x45, 0x42, 0x50)) return "image/webp"
876
+ return undefined
877
+ }
878
+
879
+ /**
880
+ * Why `value` is not an acceptable list of prompt images, or undefined when it is: at most
881
+ * MAX_PROMPT_IMAGES, each a known image type whose bytes match it, canonical base64 (OpenCode refuses
882
+ * anything else) of at most MAX_PROMPT_IMAGE_BYTES, together at most MAX_PROMPT_IMAGES_BASE64 characters.
883
+ */
884
+ export function promptImagesProblem(value: unknown): string | undefined {
885
+ if (!Array.isArray(value)) return "images must be an array"
886
+ if (value.length > MAX_PROMPT_IMAGES) return `Attach at most ${MAX_PROMPT_IMAGES} images`
887
+ let total = 0
888
+ for (const [i, image] of value.entries()) {
889
+ const label = `Image ${i + 1}`
890
+ if (!isObj(image)) return `${label} must be {mime, data, name?}`
891
+ if (typeof image.mime !== "string" || !PROMPT_IMAGE_MIMES.includes(image.mime)) return `${label}: type must be one of ${PROMPT_IMAGE_MIMES.join(", ")}`
892
+ if (image.name !== undefined && (typeof image.name !== "string" || image.name.length > MAX_PROMPT_IMAGE_NAME_CHARS)) {
893
+ return `${label}: name must be a string of at most ${MAX_PROMPT_IMAGE_NAME_CHARS} characters`
894
+ }
895
+ const data = image.data
896
+ if (typeof data !== "string" || !data || data.length % 4 !== 0 || !BASE64.test(data)) return `${label}: data must be non-empty base64`
897
+ total += data.length
898
+ if (total > MAX_PROMPT_IMAGES_BASE64) return `Images together are larger than ${MAX_PROMPT_IMAGES_BASE64} base64 characters`
899
+ if ((data.length / 4) * 3 > MAX_PROMPT_IMAGE_BYTES + 2) return `${label} is larger than ${MAX_PROMPT_IMAGE_BYTES / 1024} KB`
900
+ const bytes = Buffer.from(data, "base64")
901
+ if (bytes.toString("base64") !== data) return `${label}: data must be canonical base64`
902
+ if (bytes.length > MAX_PROMPT_IMAGE_BYTES) return `${label} is larger than ${MAX_PROMPT_IMAGE_BYTES / 1024} KB`
903
+ if (sniffImage(bytes) !== image.mime) return `${label}: content does not match ${image.mime}`
904
+ }
905
+ return undefined
906
+ }
907
+
908
+ /** Text, plus optional images; the text may be empty only when images are attached. */
909
+ function promptBody(v: Record<string, unknown>): boolean {
910
+ if (!str(v.text)) return false
911
+ if (v.images === undefined) return v.text.length > 0
912
+ return Array.isArray(v.images) && v.images.length > 0 && promptImagesProblem(v.images) === undefined
913
+ }
914
+
915
+ function isServerRequest(v: unknown): v is ServerRequest {
916
+ if (!isObj(v)) return false
917
+ switch (v.op) {
918
+ case "turn":
919
+ return nonEmpty(v.sessionID) && promptBody(v) && typeof v.voiceStyle === "boolean"
920
+ case "permission":
921
+ return (
922
+ nonEmpty(v.sessionID) &&
923
+ nonEmpty(v.requestID) &&
924
+ ["once", "always", "reject"].includes(v.decision as string) &&
925
+ (v.message === undefined || (v.decision === "reject" && permissionMessage(v.message)))
926
+ )
927
+ case "form":
928
+ if (!nonEmpty(v.sessionID) || !nonEmpty(v.formID) || v.formID.length > 300) return false
929
+ return v.cancel === true ? v.answer === undefined : v.cancel === undefined && formAnswerProblem(v.answer) === undefined
930
+ case "context":
931
+ case "autoName":
932
+ case "interrupt":
933
+ case "stop":
934
+ case "summary":
935
+ case "models":
936
+ case "modelPrefs":
937
+ return nonEmpty(v.sessionID)
938
+ case "setModelPrefs":
939
+ return (
940
+ nonEmpty(v.sessionID) &&
941
+ (v.voice === undefined || v.voice === null || isModelSelection(v.voice)) &&
942
+ (v.worker === undefined || v.worker === null || isModelSelection(v.worker)) &&
943
+ (v.voice !== undefined || v.worker !== undefined)
944
+ )
945
+ case "timeline":
946
+ return (
947
+ nonEmpty(v.sessionID) &&
948
+ (v.limit === undefined || (Number.isInteger(v.limit) && Number(v.limit) >= 1 && Number(v.limit) <= 200)) &&
949
+ (v.cursor === undefined || (str(v.cursor) && v.cursor.length <= 4096)) &&
950
+ (v.order === undefined || v.order === "asc" || v.order === "desc") &&
951
+ (v.before === undefined || (nonEmpty(v.before) && v.before.length <= 4096)) &&
952
+ // Newest-first paging (order desc, before) does not mix with the forward cursor.
953
+ (v.cursor === undefined || (v.before === undefined && v.order !== "desc"))
954
+ )
955
+ case "timelinePart":
956
+ return nonEmpty(v.sessionID) && nonEmpty(v.messageID) && v.messageID.length <= 300 && nonEmpty(v.partID) && v.partID.length <= 300
957
+ case "steer":
958
+ return nonEmpty(v.sessionID) && promptBody(v)
959
+ case "timelineFile":
960
+ return nonEmpty(v.sessionID) && nonEmpty(v.messageID) && v.messageID.length <= 300 && Number.isInteger(v.index) && Number(v.index) >= 0 && Number(v.index) < 1000
961
+ case "diff":
962
+ return nonEmpty(v.sessionID) && nonEmpty(v.file) && v.file.length <= 4096
963
+ case "rename":
964
+ return nonEmpty(v.sessionID) && str(v.title) && v.title.length <= 1000
965
+ case "conversation":
966
+ case "expose":
967
+ return nonEmpty(v.sessionID) && typeof v.on === "boolean"
968
+ case "create":
969
+ return (
970
+ nonEmpty(v.directory) &&
971
+ v.directory.startsWith("/") &&
972
+ v.directory.length <= 4096 &&
973
+ (v.title === undefined || (str(v.title) && v.title.length <= 200)) &&
974
+ (v.herdrWorkspace === undefined || (nonEmpty(v.herdrWorkspace) && v.herdrWorkspace.length <= 100)) &&
975
+ (v.launchID === undefined || deliverID(v.launchID)) &&
976
+ (v.agent === undefined || agentName(v.agent)) &&
977
+ (v.model === undefined || isModelSelection(v.model))
978
+ )
979
+ case "sessionTokens":
980
+ return Array.isArray(v.tokens) && v.tokens.length <= SESSION_TOKENS_MAX && v.tokens.every(isSessionToken) && (v.hubURL === undefined || isHubURL(v.hubURL))
981
+ case "actTokens":
982
+ return Array.isArray(v.tokens) && v.tokens.length <= SESSION_TOKENS_MAX && v.tokens.every(isSessionToken) && !("hubURL" in v)
983
+ case "deliver":
984
+ return deliverProblem(v) === undefined
985
+ default:
986
+ return false
987
+ }
988
+ }
989
+
990
+
991
+ /** Parses a frame received by the server. Returns undefined for anything invalid. */
992
+ export function parseSpokeMessage(raw: string | Buffer): SpokeMessage | undefined {
993
+ const m = parse(raw)
994
+ if (!m) return undefined
995
+ switch (m.t) {
996
+ case "hello":
997
+ // A spoke hello has no role (v1) or role "spoke"; a node hello (role "node") is parsed by parseNodeMessage.
998
+ return (m.role === undefined || m.role === "spoke") &&
999
+ num(m.v) &&
1000
+ nonEmpty(m.processID) &&
1001
+ (m.kind === "service" || m.kind === "standalone") &&
1002
+ str(m.directory) &&
1003
+ num(m.pid) &&
1004
+ (m.mode === "always" || m.mode === "session") &&
1005
+ (str(m.opencodeVersion) || (m.opencodeVersion === undefined && m.harness !== undefined && m.harness !== HARNESS_OPENCODE)) &&
1006
+ str(m.pluginVersion) &&
1007
+ (m.features === undefined || featureList(m.features)) &&
1008
+ (m.harness === undefined || token(m.harness, 64)) &&
1009
+ (m.harnessVersion === undefined || (str(m.harnessVersion) && m.harnessVersion.length <= 100)) &&
1010
+ (m.evidence === undefined || token(m.evidence, 32)) &&
1011
+ (m.launchID === undefined || (str(m.launchID) && ID.test(m.launchID)))
1012
+ ? (m as unknown as Hello)
1013
+ : undefined
1014
+ case "sessions":
1015
+ return Array.isArray(m.sessions) && m.sessions.every(isSession) ? (m as unknown as Sessions) : undefined
1016
+ case "terminals":
1017
+ return m.herdr === undefined || isHerdrInfo(m.herdr) ? (m as unknown as Terminals) : undefined
1018
+ case "event":
1019
+ return isObj(m.event) && nonEmpty(m.event.type) && FORWARDED_EVENTS.has(m.event.type) && isObj(m.event.data)
1020
+ ? (m as unknown as Event)
1021
+ : undefined
1022
+ case "req":
1023
+ return nonEmpty(m.id) && isSpokeRequest(m.req) ? (m as unknown as Req<SpokeRequest>) : undefined
1024
+ case "res":
1025
+ return isRes(m) ? (m as unknown as Res) : undefined
1026
+ case "bye":
1027
+ return isBye(m) ? (m as unknown as Bye) : undefined
1028
+ default:
1029
+ return undefined
1030
+ }
1031
+ }
1032
+
1033
+ /** Parses a frame received by the spoke. Returns undefined for anything invalid. */
1034
+ export function parseServerMessage(raw: string | Buffer): ServerMessage | undefined {
1035
+ const m = parse(raw)
1036
+ if (!m) return undefined
1037
+ switch (m.t) {
1038
+ case "welcome":
1039
+ return isWelcomeFrame(m) ? (m as unknown as Welcome) : undefined
1040
+ case "req":
1041
+ return nonEmpty(m.id) && isServerRequest(m.req) ? (m as unknown as Req<ServerRequest>) : undefined
1042
+ case "res":
1043
+ return isRes(m) ? (m as unknown as Res) : undefined
1044
+ case "bye":
1045
+ return isBye(m) ? (m as unknown as Bye) : undefined
1046
+ default:
1047
+ return undefined
1048
+ }
1049
+ }
1050
+
1051
+ export const encode = (message: SpokeMessage | ServerMessage | NodeMessage | NodeServerMessage): string => JSON.stringify(message)
1052
+
1053
+ // ---------- HTTP enrollment (spoke ↔ server, before a machine token exists) ----------
1054
+
1055
+ /** POST /spoke/enroll body: { machineName } → EnrollStart */
1056
+ export interface EnrollStart {
1057
+ readonly userCode: string
1058
+ readonly deviceCode: string
1059
+ readonly approveURL: string
1060
+ readonly intervalMs: number
1061
+ readonly expiresAt: number
1062
+ /** A local hub approved this first machine already; the next poll returns the token. */
1063
+ readonly autoApproved?: boolean
1064
+ }
1065
+
1066
+ /** POST /spoke/enroll/poll body: { deviceCode } → EnrollPoll */
1067
+ export type EnrollPoll =
1068
+ | { readonly status: "pending" }
1069
+ | { readonly status: "denied" | "expired" }
1070
+ | { readonly status: "approved"; readonly machineID: string; readonly machineToken: string }
1071
+
1072
+ // ---------- activity feed ----------
1073
+
1074
+ const SUMMARY_KEYS = ["description", "command", "filePath", "path", "pattern", "url", "query", "agent", "prompt"] as const
1075
+ export const ACTIVITY_SUMMARY_MAX = 160
1076
+ /** Longest reasoning delta forwarded; longer deltas are cut (they are normally a few tokens). */
1077
+ export const REASONING_DELTA_MAX = 2000
1078
+
1079
+ /** Short human summary of a tool input, e.g. the shell command or file path. Never the full input. */
1080
+ export function summarizeToolInput(input: unknown): string {
1081
+ if (typeof input !== "object" || input === null) return ""
1082
+ const record = input as Record<string, unknown>
1083
+ for (const key of SUMMARY_KEYS) {
1084
+ const value = record[key]
1085
+ if (typeof value === "string" && value.trim()) {
1086
+ const line = value.trim().split("\n")[0]!
1087
+ return line.length > ACTIVITY_SUMMARY_MAX ? `${line.slice(0, ACTIVITY_SUMMARY_MAX - 1)}…` : line
1088
+ }
1089
+ }
1090
+ return ""
1091
+ }
1092
+
1093
+ /**
1094
+ * Reduces activity events to what the activity feed needs. Returns the event unchanged when it is
1095
+ * not an activity event, or undefined when it should be dropped.
1096
+ */
1097
+ export function compactActivity(event: ForwardedEvent, sessionOf?: (jobID: string) => string | undefined, now: number = Date.now()): ForwardedEvent | undefined {
1098
+ const d = event.data
1099
+ if (JOB_EVENTS.has(event.type) || event.type === "session.status") return compactJob(event, sessionOf, now)
1100
+ const base = { sessionID: d.sessionID, assistantMessageID: d.assistantMessageID, id: d.id }
1101
+ switch (event.type) {
1102
+ case "session.tool.input.started":
1103
+ return { type: event.type, data: { ...base, name: d.name } }
1104
+ case "session.tool.called":
1105
+ return { type: event.type, data: { ...base, summary: summarizeToolInput(d.input), background: (d.input as any)?.background === true } }
1106
+ case "session.tool.progress": {
1107
+ const childSessionID = childSessionOf(d.metadata)
1108
+ return childSessionID ? { type: "session.tool.called", data: { ...base, childSessionID } } : undefined
1109
+ }
1110
+ case "session.tool.success": {
1111
+ const childSessionID = childSessionOf(d.metadata)
1112
+ return { type: event.type, data: childSessionID ? { ...base, childSessionID } : base }
1113
+ }
1114
+ case "session.tool.failed": {
1115
+ const message = typeof (d.error as any)?.message === "string" ? String((d.error as any).message).slice(0, ACTIVITY_SUMMARY_MAX) : "failed"
1116
+ return { type: event.type, data: { ...base, error: message } }
1117
+ }
1118
+ case "session.reasoning.started":
1119
+ case "session.reasoning.ended":
1120
+ return { type: event.type, data: { sessionID: d.sessionID, assistantMessageID: d.assistantMessageID } }
1121
+ case "session.reasoning.delta":
1122
+ return {
1123
+ type: event.type,
1124
+ data: { sessionID: d.sessionID, assistantMessageID: d.assistantMessageID, delta: typeof d.delta === "string" ? d.delta.slice(0, REASONING_DELTA_MAX) : "" },
1125
+ }
1126
+ default:
1127
+ return event
1128
+ }
1129
+ }
1130
+
1131
+ /** The child session a subagent tool reports in its metadata (`{sessionID: "ses_…"}`). */
1132
+ function childSessionOf(metadata: unknown): string | undefined {
1133
+ const id = (metadata as { sessionID?: unknown } | undefined)?.sessionID
1134
+ return typeof id === "string" && /^ses_[A-Za-z0-9]+$/.test(id) ? id : undefined
1135
+ }
1136
+
1137
+ const JOB_EVENTS = new Set(["session.shell.started", "session.shell.ended", "shell.created", "shell.exited", "shell.deleted", "pty.created", "pty.updated", "pty.exited", "pty.deleted"])
1138
+ const JOB_STATUSES = new Set<string>(["running", "exited", "timeout", "killed"])
1139
+
1140
+ function clipText(value: unknown, max: number): string | undefined {
1141
+ if (typeof value !== "string") return undefined
1142
+ const line = value.replace(/\s+/g, " ").trim()
1143
+ if (!line) return undefined
1144
+ return line.length > max ? `${line.slice(0, max - 1)}…` : line
1145
+ }
1146
+
1147
+ const finite = (v: unknown): number | undefined => (typeof v === "number" && Number.isFinite(v) ? v : undefined)
1148
+
1149
+ /** OpenCode's SessionStatus reduced to its type and (retry) attempt and short reason. */
1150
+ export function compactStatus(raw: unknown): CompactSessionStatus | undefined {
1151
+ const status = raw as Record<string, unknown> | null
1152
+ if (!status || typeof status !== "object") return undefined
1153
+ if (status.type === "idle" || status.type === "busy") return { type: status.type }
1154
+ if (status.type !== "retry") return undefined
1155
+ const attempt = finite(status.attempt)
1156
+ const message = clipText(status.message, RETRY_MESSAGE_MAX)
1157
+ return { type: "retry", ...(attempt !== undefined ? { attempt } : {}), ...(message ? { message } : {}) }
1158
+ }
1159
+
1160
+ /** A ShellInfo or Pty (OpenCode) as a JobInfo. */
1161
+ export function jobFromInfo(raw: unknown, kind: JobKind): (JobInfo & { sessionID?: string }) | undefined {
1162
+ const info = raw as Record<string, any> | null
1163
+ if (!info || typeof info !== "object" || typeof info.id !== "string" || !info.id) return undefined
1164
+ const status: JobStatus = JOB_STATUSES.has(info.status) ? info.status : "running"
1165
+ const commandLine = kind === "pty" ? [info.command, ...(Array.isArray(info.args) ? info.args : [])].filter((x) => typeof x === "string" && x).join(" ") || info.title : info.command
1166
+ const command = clipText(commandLine, JOB_COMMAND_MAX)
1167
+ const exit = finite(kind === "pty" ? info.exitCode : info.exit)
1168
+ const sessionID = typeof info.sessionID === "string" ? info.sessionID : typeof info.metadata?.sessionID === "string" ? info.metadata.sessionID : undefined
1169
+ const started = finite(info.time?.started)
1170
+ const ended = finite(info.time?.completed)
1171
+ return {
1172
+ ...(sessionID ? { sessionID } : {}),
1173
+ id: info.id,
1174
+ kind,
1175
+ ...(command ? { command } : {}),
1176
+ status,
1177
+ ...(exit !== undefined ? { exit } : {}),
1178
+ ...(info.metadata?.background === true ? { background: true as const } : {}),
1179
+ ...(started !== undefined ? { started } : {}),
1180
+ ...(ended !== undefined ? { ended } : {}),
1181
+ }
1182
+ }
1183
+
1184
+ function compactJob(event: ForwardedEvent, sessionOf: ((jobID: string) => string | undefined) | undefined, now: number): ForwardedEvent | undefined {
1185
+ const d = event.data as Record<string, any>
1186
+ const type = event.type
1187
+ if (type === "session.status") {
1188
+ const status = compactStatus(d.status)
1189
+ return typeof d.sessionID === "string" && status ? { type, data: { sessionID: d.sessionID, status } } : undefined
1190
+ }
1191
+ const kind: JobKind = type.startsWith("pty.") ? "pty" : "shell"
1192
+ const session = (id: unknown, own?: unknown) => (typeof own === "string" && own ? own : typeof id === "string" ? sessionOf?.(id) : undefined)
1193
+ let data: Record<string, unknown> | undefined
1194
+ if (type === "shell.created" || type === "session.shell.started" || type === "pty.created" || type === "pty.updated") {
1195
+ const job = jobFromInfo(type === "session.shell.started" ? d.shell : d.info, kind)
1196
+ if (!job) return undefined
1197
+ const sessionID = session(job.id, type === "session.shell.started" ? d.sessionID : job.sessionID)
1198
+ const { started, ended: _ended, sessionID: _session, ...rest } = job
1199
+ data = sessionID ? { sessionID, ...rest, at: started ?? now } : undefined
1200
+ } else if (type === "session.shell.ended") {
1201
+ const job = jobFromInfo(d.shell, "shell")
1202
+ if (!job) return undefined
1203
+ const sessionID = session(job.id, d.sessionID)
1204
+ data = sessionID ? { sessionID, id: job.id, kind, status: job.status === "running" ? "exited" : job.status, ...(job.exit !== undefined ? { exit: job.exit } : {}), at: job.ended ?? now } : undefined
1205
+ } else if (type === "shell.exited" || type === "pty.exited") {
1206
+ const sessionID = session(d.id, d.sessionID)
1207
+ const exit = finite(kind === "pty" ? d.exitCode : d.exit)
1208
+ const status = kind === "pty" ? "exited" : JOB_STATUSES.has(d.status) && d.status !== "running" ? d.status : "exited"
1209
+ data = sessionID ? { sessionID, id: d.id, kind, status, ...(exit !== undefined ? { exit } : {}), at: now } : undefined
1210
+ } else {
1211
+ const sessionID = session(d.id, d.sessionID)
1212
+ data = sessionID && typeof d.id === "string" ? { sessionID, id: d.id, kind } : undefined
1213
+ }
1214
+ return data ? { type, data } : undefined
1215
+ }