@huynle/hub 0.6.0 → 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
@@ -2,10 +2,20 @@
2
2
  // keeps the WebSocket to the central server, forwards exposed events, and executes the server's
3
3
  // requests (voice turns, permission replies, context reads) through the plugin context.
4
4
  import path from "node:path"
5
- import { readConfig, setSession, withServer, writeConfig, type Mode, type VoiceConfig, explicitVariant, formatModelRef, parseModelRef, plainModelRef, type ModelRef } from "../config"
5
+ import { defaults, readConfig, setSession, withServer, writeConfig, type LaunchConfig, type Mode, type VoiceConfig, explicitVariant, formatModelRef, parseModelRef, plainModelRef, type ModelRef } from "../config"
6
6
  import { isLoopbackURL } from "../loopback"
7
7
  import {
8
8
  compactActivity,
9
+ COMPACTED_EVENTS,
10
+ FORWARDED_EVENTS,
11
+ HARNESS_OPENCODE,
12
+ isLaunchCreate,
13
+ SEED_JOBS_MAX,
14
+ SPOKE_FEATURES,
15
+ type DeliverRequest,
16
+ type DeliverResult,
17
+ type DeliveryMode,
18
+ type JobInfo,
9
19
  type HerdrWorkspace,
10
20
  type FormAnswer,
11
21
  type PermissionDecision,
@@ -13,6 +23,9 @@ import {
13
23
  type PromptImage,
14
24
  type ResultFor,
15
25
  type ServerRequest,
26
+ type SessionTokensRequest,
27
+ type ActTokensRequest,
28
+ type CreateRequest,
16
29
  type SessionModelPrefs,
17
30
  type SpokeRequest,
18
31
  type TerminalInfo,
@@ -20,15 +33,19 @@ import {
20
33
  } from "../protocol"
21
34
  import { extractReply } from "../reply"
22
35
  import { TurnTracker } from "../turns"
23
- import { boundedTimelinePart, timelineFile, timelineItems } from "./timeline"
36
+ import { TimelineCache, boundedTimelinePart, newestPage, timelineFile, timelineItems } from "./timeline"
24
37
  import { SpokeConnection, type Backoff, type ConnectionState } from "./client"
25
38
  import { PROMPT_SOURCE, VOICE_AGENT } from "./conversation"
39
+ import { DeliveryLedger, deliverMetadata, deliverText, harnessMessageID, InboxOrigins, planDelivery } from "./deliver"
26
40
  import { pollEnrollment, startEnrollment } from "./enroll"
27
41
  import { HerdrWatcher, openInHerdr, type HerdrRunner } from "./herdr"
28
42
  import type { LocalHubResult } from "./localhub"
43
+ import { launchDirectoryProblem } from "./node-roots"
29
44
  import { PresenceBook } from "./presence"
45
+ import { SessionTokenStore } from "./session-tokens"
30
46
  import { changeStats, contextUsage, filePatch, mcpServers } from "./summary"
31
47
  import { browserSessions, isExposed, isReported, SessionTracker, shouldForward, type EventLike, type SessionDetails } from "./tracker"
48
+ import { renderTaskBoard, TaskBoard } from "./taskboard"
32
49
  import { watchConfig, type ConfigWatcher } from "./watch"
33
50
 
34
51
  export type SpokeErrorName = "not_connected" | "not_enrolled" | "invalid" | "unreachable"
@@ -76,15 +93,23 @@ export interface SpokeHost {
76
93
  readonly directory: string
77
94
  readonly opencodeVersion: string
78
95
  events(signal: AbortSignal): AsyncIterable<EventLike>
79
- prompt(input: { sessionID: string; text: string; files?: readonly PromptFile[]; metadata: Record<string, unknown>; delivery?: "steer" }): Promise<{ id: string }>
96
+ /** `id`: a client message id (msg_…, OpenCode applies it once); `resume: false` admits without waking. */
97
+ prompt(input: { sessionID: string; text: string; files?: readonly PromptFile[]; metadata: Record<string, unknown>; delivery?: DeliveryMode; id?: string; resume?: boolean }): Promise<{ id: string }>
98
+ /**
99
+ * Admits text as a synthetic user message (OpenCode's session.synthetic): the model sees only `text`; the
100
+ * prompt hook does not run. `id` (msg_…) makes it idempotent; `resume: false` admits without waking.
101
+ * Absent: this OpenCode cannot take hub messages (no "deliver" feature).
102
+ */
103
+ synthetic?(input: { sessionID: string; id: string; text: string; metadata: Record<string, unknown>; delivery: DeliveryMode; resume: boolean }): Promise<{ id?: string }>
80
104
  context(sessionID: string): Promise<ReadonlyArray<unknown>>
81
105
  listModels?(): Promise<ReadonlyArray<unknown>>
82
106
  getSession(sessionID: string): Promise<HostSession>
83
107
  /**
84
108
  * Replies to a pending permission; "gone" when it is not pending here any more (answered, expired).
85
- * `directory` is the session's directory when known (permissions are pending per location).
109
+ * `directory` is the session's directory when known (permissions are pending per location). `message`: a
110
+ * reject's reason, which OpenCode gives the model as the rejection's feedback.
86
111
  */
87
- replyPermission(input: { sessionID: string; requestID: string; decision: PermissionDecision; directory?: string }): Promise<unknown>
112
+ replyPermission(input: { sessionID: string; requestID: string; decision: PermissionDecision; message?: string; directory?: string }): Promise<unknown>
88
113
  /**
89
114
  * Answers or cancels a pending form through OpenCode's API: "gone" when it is not pending any more,
90
115
  * "unavailable" when this process cannot reach its own server (then a TUI relays it, see formActions).
@@ -92,11 +117,25 @@ export interface SpokeHost {
92
117
  */
93
118
  replyForm?(input: FormAction): Promise<"replied" | "gone" | "unavailable">
94
119
  interrupt(sessionID: string): Promise<unknown>
120
+ /** Changes a session's settings in OpenCode (session.update); only the title is used. */
121
+ updateSession?(sessionID: string, input: { title?: string }): Promise<unknown>
122
+ /**
123
+ * Has OpenCode regenerate the session's title with its title agent (session.update with an empty title on
124
+ * this process's own server; the plugin API's update would set an empty title instead). Resolves when
125
+ * OpenCode is done ("done", whether or not the title changed); "unavailable" when the server cannot be reached.
126
+ */
127
+ generateTitle?(sessionID: string): Promise<"done" | "unavailable">
95
128
  switchAgent(sessionID: string, agent: string): Promise<unknown>
96
129
  switchModel(sessionID: string, model: ModelRef): Promise<unknown>
97
130
  hasAgent(id: string): Promise<boolean>
98
131
  /** The agent a session without an explicit selection runs with. */
99
132
  defaultAgent(): Promise<string | undefined>
133
+ /**
134
+ * What runs right now, from this process's own OpenCode server: sessions with a run in progress
135
+ * (session.active) and running shells in the given directories (shell.list). Used to show sessions that
136
+ * were already running when this process started, or while its event stream was down.
137
+ */
138
+ activity?(directories: readonly string[]): Promise<{ readonly active: readonly string[]; readonly jobs: ReadonlyArray<JobInfo & { readonly sessionID: string }> }>
100
139
  /**
101
140
  * Recent root sessions from the local service with their workspace (service processes only): those in
102
141
  * the history window (`days`, at most `max`), plus `include` (always listed: open, running, ...).
@@ -159,6 +198,21 @@ export interface SpokeOptions {
159
198
  readonly herdrSettleMs?: number
160
199
  /** How often expired TUI presence is checked (default 5 s). */
161
200
  readonly presenceCheckMs?: number
201
+ /** How long autoName waits for OpenCode's generated title before answering "pending" (default 20 s). */
202
+ readonly autoNameWaitMs?: number
203
+ /** After generation returns without a session.renamed event, how long to wait for it (default 1.5 s). */
204
+ readonly autoNameSettleMs?: number
205
+ /**
206
+ * The machine's node workspace roots (node.json, src/spoke/node-roots.ts), read on each launch create: a launch's
207
+ * directory must be under one. Absent: no roots, so launch creates are refused.
208
+ */
209
+ readonly workspaceRoots?: () => readonly string[]
210
+ /**
211
+ * Launch-bound mode (a spoke inside a launch's sandbox, src/config.ts LaunchConfig): connect to `hubURL` with the
212
+ * launch token and announce `launchID` in the hello; the machine config is never read, watched or written, and the
213
+ * spoke never enrols, starts a local hub or reads Herdr.
214
+ */
215
+ readonly launch?: LaunchConfig
162
216
  }
163
217
 
164
218
  export type EnrollmentState = "pending" | "approved" | "denied" | "expired" | "error"
@@ -215,11 +269,21 @@ const FALLBACK_AGENT = "build"
215
269
  const LOCAL_HUB_MAX_RETRY_MS = 15 * 60_000
216
270
  const ENROLL_RETRY_MS = 500
217
271
  const NOT_SHARED = "This session is not shared with voice"
272
+ const CANNOT_AUTO_NAME = "This OpenCode process cannot auto-name sessions (its server cannot be reached); use /rename in its terminal"
273
+ export const AUTO_NAME_WAIT_MS = 20_000
274
+ /** Longest session title the hub sets (OpenCode's own titles are shorter). */
275
+ export const MAX_TITLE_CHARS = 200
218
276
  const UNKNOWN_SESSION = "Unknown session"
219
277
  /** Events after which the service re-lists its sessions (other processes' sessions appear, change, go). */
220
278
  const LISTING_EVENTS = new Set(["session.created", "session.renamed", "session.deleted"])
221
279
 
222
280
  const errorText = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause))
281
+ const LAUNCH_BOUND = "This spoke is bound to a launch; its hub connection is fixed by the launch config"
282
+
283
+ /** The fixed config of a launch-bound spoke: always shared, no local hub, no Herdr. */
284
+ function launchVoiceConfig(launch: LaunchConfig, machineName: string): VoiceConfig {
285
+ return { ...defaults(machineName), server: launch.hubURL, mode: "always", token: launch.token, autoStartLocalHub: false, herdr: false }
286
+ }
223
287
 
224
288
  export class Spoke {
225
289
  private config: VoiceConfig
@@ -227,9 +291,24 @@ export class Spoke {
227
291
  private connection?: SpokeConnection
228
292
  private connectionKey = ""
229
293
  private readonly tracker = new SessionTracker()
294
+ /** Session tokens from the hub (sessionTokens op), for the sessions' shells (P1-T6). Never logged. */
295
+ private readonly tokens = new SessionTokenStore()
296
+ /**
297
+ * Act tokens from the hub (actTokens op): the credential for act verbs, kept apart from `tokens` so no shell
298
+ * ever gets one. Only actIdentity reads them (the OpenCode hub tool). Never logged, never in any environment.
299
+ */
300
+ private readonly actTokens = new SessionTokenStore()
301
+ /** The background workers each session started (the voice agent's task board). */
302
+ private readonly tasks = new TaskBoard()
230
303
  private readonly turns = new TurnTracker()
304
+ /** Timeline rows of sessions being paged through (30 s, dropped on any event of the session). */
305
+ private readonly timelines = new TimelineCache()
231
306
  private readonly voiceTurns = new Map<string, number>()
232
307
  private readonly running = new Set<string>()
308
+ /** Hub messages applied (by messageID), so a retry is not admitted again. */
309
+ private readonly deliveries = new DeliveryLedger()
310
+ /** Who put each inbox item into a session (forwarded with session.inbox.delivered). */
311
+ private readonly origins = new InboxOrigins()
233
312
  private readonly infoTried = new Map<string, number>()
234
313
  /** Sessions in conversation mode → the agent to restore when it ends (undefined: the default agent). */
235
314
  private readonly conversations = new Map<string, { previous?: string; previousModel?: ModelRef; voiceModel?: ModelRef }>()
@@ -237,6 +316,13 @@ export class Spoke {
237
316
  private readonly modelPrefs = new Map<string, SessionModelPrefs>()
238
317
  /** Per session with a Voice override applied: the model to restore when it is cleared, and the override. */
239
318
  private readonly voiceOverrides = new Map<string, { readonly previous?: ModelRef; readonly applied: ModelRef }>()
319
+ /**
320
+ * Session → its current model as last seen (getSession, our switchModel, OpenCode's session.model.selected),
321
+ * so an utterance needs no getSession round trip. Cleared when the event stream restarts (events may be lost).
322
+ */
323
+ private readonly sessionModels = new Map<string, { readonly model?: ModelRef }>()
324
+ /** Session → callbacks waiting for its next session.renamed event (autoName). */
325
+ private readonly renameWaiters = new Map<string, Set<(title: string) => void>>()
240
326
  private conversationChain: Promise<unknown> = Promise.resolve()
241
327
  private readonly abort = new AbortController()
242
328
  private snapshotTimer?: ReturnType<typeof setTimeout>
@@ -244,6 +330,8 @@ export class Spoke {
244
330
  private intervals: Array<ReturnType<typeof setInterval>> = []
245
331
  private enrollment?: { state: EnrollmentState; abort: AbortController }
246
332
  private started = false
333
+ /** A seedActivity call is in flight. */
334
+ private seeding = false
247
335
  /** Sessions shown by OpenCode TUIs (voice.presence). */
248
336
  private readonly presenceBook = new PresenceBook()
249
337
  /** Form replies waiting for a TUI to carry them out (when the plugin cannot reach its own server). */
@@ -275,19 +363,14 @@ export class Spoke {
275
363
  private readonly host: SpokeHost,
276
364
  private readonly options: SpokeOptions,
277
365
  ) {
278
- this.config = readConfig(options.configFile, options.machineName)
366
+ this.config = options.launch ? launchVoiceConfig(options.launch, options.machineName) : readConfig(options.configFile, options.machineName)
279
367
  this.hub = { pending: false, nextAt: 0, cooldown: options.localHubRetryMs ?? 30_000 }
280
368
  }
281
369
 
282
370
  start(): void {
283
371
  if (this.started) return
284
372
  this.started = true
285
- try {
286
- this.watcher = watchConfig(this.options.configFile, this.options.machineName, (c) => this.apply(c), { pollMs: this.options.pollMs })
287
- this.config = this.watcher.current()
288
- } catch (cause) {
289
- console.error("[hub] config watch failed", cause)
290
- }
373
+ if (!this.options.launch) this.watch()
291
374
  void this.listen()
292
375
  this.apply(this.config)
293
376
  this.every(this.options.snapshotIntervalMs ?? 30_000, () => this.sendSnapshot())
@@ -298,6 +381,15 @@ export class Spoke {
298
381
  }
299
382
  }
300
383
 
384
+ private watch(): void {
385
+ try {
386
+ this.watcher = watchConfig(this.options.configFile, this.options.machineName, (c) => this.apply(c), { pollMs: this.options.pollMs })
387
+ this.config = this.watcher.current()
388
+ } catch (cause) {
389
+ console.error("[hub] config watch failed", cause)
390
+ }
391
+ }
392
+
301
393
  stop(): void {
302
394
  this.started = false
303
395
  this.abort.abort()
@@ -315,6 +407,8 @@ export class Spoke {
315
407
  this.connection?.stop()
316
408
  this.connection = undefined
317
409
  this.connectionKey = ""
410
+ this.tokens.clear()
411
+ this.actTokens.clear()
318
412
  }
319
413
 
320
414
  isVoiceTurn(sessionID: string): boolean {
@@ -335,6 +429,46 @@ export class Spoke {
335
429
  return this.config.workModel ?? (entry.previousModel ? formatModelRef(entry.previousModel) : undefined)
336
430
  }
337
431
 
432
+ /** The rendered task board of a session's workers (for the voice agent's context); undefined without any. */
433
+ taskBoard(sessionID: string): string | undefined {
434
+ return renderTaskBoard(this.tasks.children(sessionID), Date.now())
435
+ }
436
+
437
+ /**
438
+ * hub_task_stop: interrupts one of `callerID`'s workers (a session below it), or all its running workers, and
439
+ * whatever runs below them, deepest first. Never the caller itself, nor a session outside its tree.
440
+ */
441
+ async stopWorkers(callerID: string, input: { sessionID?: unknown; all?: unknown }): Promise<{ stopped: string[]; text: string }> {
442
+ let targets: string[]
443
+ if (input.all === true) {
444
+ targets = this.tasks
445
+ .children(callerID)
446
+ .filter((t) => t.state === "working" || t.state === "blocked")
447
+ .map((t) => t.id)
448
+ if (!targets.length) return { stopped: [], text: "None of your workers is running." }
449
+ } else if (typeof input.sessionID === "string" && input.sessionID) {
450
+ const id = input.sessionID
451
+ if (id === callerID || !this.tracker.lineage(id).slice(1).includes(callerID)) {
452
+ return { stopped: [], text: `${id} is not one of your workers; use a sessionID from your task board.` }
453
+ }
454
+ targets = [id]
455
+ } else return { stopped: [], text: "Pass the worker's sessionID from your task board, or all: true for every worker." }
456
+ const stopped: string[] = []
457
+ for (const target of targets) {
458
+ for (const id of [...this.tracker.runningBelow(target), target]) {
459
+ if (id === callerID) continue
460
+ try {
461
+ await this.host.interrupt(id)
462
+ stopped.push(id)
463
+ } catch {
464
+ // already settled
465
+ }
466
+ }
467
+ }
468
+ const names = targets.map((id) => `"${this.tasks.get(id)?.title ?? id}"`)
469
+ return { stopped, text: `Stopped ${names.join(", ")}.` }
470
+ }
471
+
338
472
  subagent(): string {
339
473
  return this.config.subagent
340
474
  }
@@ -456,6 +590,42 @@ export class Spoke {
456
590
  })
457
591
  }
458
592
 
593
+ /** Whether the hub this spoke is connected to announced `feature` in its welcome (older hubs announce none). */
594
+ /** The session's current (unexpired) hub session token, for its shells (HUB_SESSION_TOKEN). */
595
+ sessionToken(sessionID: string): string | undefined {
596
+ return this.tokens.get(sessionID)?.token
597
+ }
598
+
599
+ /**
600
+ * What a session's shells get (src/spoke/env-inject.ts): its current token, with HUB_URL the server this spoke
601
+ * reaches the hub at (same machine as the shells), else the URL the hub sent. Undefined without a token.
602
+ */
603
+ shellIdentity(sessionID: string): { token: string; url?: string } | undefined {
604
+ const token = this.sessionToken(sessionID)
605
+ if (!token) return undefined
606
+ const url = this.config.server || this.tokens.hubURL
607
+ return url ? { token, url } : { token }
608
+ }
609
+
610
+ /**
611
+ * The session's act token (actTokens op) with the hub URL, for the OpenCode hub tool's act verbs only. Never
612
+ * hand it to a shell, an environment, a log or a tool's output. Undefined without an act token or a hub URL.
613
+ */
614
+ actIdentity(sessionID: string): { token: string; url: string } | undefined {
615
+ const token = this.actTokens.get(sessionID)?.token
616
+ const url = this.config.server || this.tokens.hubURL
617
+ return token && url ? { token, url } : undefined
618
+ }
619
+
620
+ /** The URL the hub told this spoke its sessions' shells reach it at (HUB_URL), if it sent one. */
621
+ get hubURL(): string | undefined {
622
+ return this.tokens.hubURL
623
+ }
624
+
625
+ hubSupports(feature: string): boolean {
626
+ return this.connection?.hubSupports(feature) === true
627
+ }
628
+
459
629
  status(sessionID?: string): SpokeStatus {
460
630
  const connection = this.connection?.state ?? "off"
461
631
  const lastError = this.connection?.lastError
@@ -491,6 +661,7 @@ export class Spoke {
491
661
 
492
662
  /** Starts device enrollment; polling continues in the background and stores the token on approval. */
493
663
  async enroll(): Promise<EnrollResult> {
664
+ if (this.options.launch) throw new SpokeError("invalid", LAUNCH_BOUND)
494
665
  const { server, machineName, autoStartLocalHub } = this.current()
495
666
  this.enrollment?.abort.abort()
496
667
  const abort = new AbortController()
@@ -566,6 +737,7 @@ export class Spoke {
566
737
  * with autoStartLocalHub off, and reconnects at once when it answers.
567
738
  */
568
739
  async startLocalHub(): Promise<{ status: "started" | "running" | "busy" }> {
740
+ if (this.options.launch) throw new SpokeError("invalid", LAUNCH_BOUND)
569
741
  const { server } = this.current()
570
742
  const starter = this.options.localHub
571
743
  if (!isLoopbackURL(server)) throw new SpokeError("invalid", `${server} is not a local hub`)
@@ -606,10 +778,12 @@ export class Spoke {
606
778
  }
607
779
 
608
780
  private current(): VoiceConfig {
781
+ if (this.options.launch) return this.config
609
782
  return this.watcher?.refresh() ?? readConfig(this.options.configFile, this.options.machineName)
610
783
  }
611
784
 
612
785
  private write(config: VoiceConfig): void {
786
+ if (this.options.launch) throw new SpokeError("invalid", LAUNCH_BOUND)
613
787
  writeConfig(this.options.configFile, config)
614
788
  if (this.watcher) this.watcher.refresh()
615
789
  else this.apply(readConfig(this.options.configFile, this.options.machineName))
@@ -625,6 +799,9 @@ export class Spoke {
625
799
  this.connection?.stop()
626
800
  this.connection = undefined
627
801
  this.connectionKey = key
802
+ // Tokens belong to the hub and machine they came from; the next connection's hub sends its own.
803
+ this.tokens.clear()
804
+ this.actTokens.clear()
628
805
  if (active) this.connect(config)
629
806
  }
630
807
  if (previous.sessions.join() !== config.sessions.join()) this.scheduleSnapshot()
@@ -643,13 +820,21 @@ export class Spoke {
643
820
  directory: this.host.directory,
644
821
  pid: this.options.pid,
645
822
  mode: this.config.mode === "session" ? "session" : "always",
823
+ // Hubs that predate `harness` require opencodeVersion; newer ones read harness, harnessVersion, evidence.
646
824
  opencodeVersion: this.host.opencodeVersion,
647
825
  pluginVersion: this.options.pluginVersion,
826
+ // Hub messages and prompts need OpenCode's synthetic messages.
827
+ features: SPOKE_FEATURES.filter((f) => (f !== "deliver" && f !== "hubPrompt") || this.host.synthetic !== undefined),
828
+ harness: HARNESS_OPENCODE,
829
+ harnessVersion: this.host.opencodeVersion,
830
+ evidence: "native",
831
+ ...(this.options.launch ? { launchID: this.options.launch.launchID } : {}),
648
832
  }),
649
833
  handle: (req) => this.handle(req),
650
834
  onWelcome: () => {
651
835
  this.sendSnapshot()
652
836
  this.sendTerminals(true)
837
+ void this.seedActivity()
653
838
  },
654
839
  onState: (state, error) => {
655
840
  if (state === "revoked") console.error("[hub] this machine was revoked by the hub")
@@ -671,7 +856,7 @@ export class Spoke {
671
856
 
672
857
  /** After a failed connection to a loopback server: maybe start the local hub (see SpokeOptions.localHub). */
673
858
  private maybeStartLocalHub(server: string): void {
674
- const starter = this.options.localHub
859
+ const starter = this.options.launch ? undefined : this.options.localHub
675
860
  if (!starter || !this.config.autoStartLocalHub || !isLoopbackURL(server)) return
676
861
  if (this.hub.pending || Date.now() < this.hub.nextAt) return
677
862
  this.hub.pending = true
@@ -719,6 +904,7 @@ export class Spoke {
719
904
  private async conversationOn(sessionID: string): Promise<ResultFor["conversation"]> {
720
905
  const entry = this.conversations.get(sessionID)
721
906
  const session = await this.host.getSession(sessionID)
907
+ this.rememberModel(sessionID, session.model)
722
908
  const current = session.agent
723
909
  const models = await this.voiceModelOn(sessionID, session, entry)
724
910
  if (!(await this.host.hasAgent(VOICE_AGENT))) {
@@ -747,7 +933,7 @@ export class Spoke {
747
933
  const current = session.model
748
934
  const onVoice = current !== undefined && sameModel(current, voiceModel)
749
935
  const previousModel = entry?.voiceModel ? entry.previousModel : onVoice || !current ? undefined : plainModelRef(current)
750
- if (!onVoice) await this.host.switchModel(sessionID, voiceModel)
936
+ if (!onVoice) await this.switchModel(sessionID, voiceModel)
751
937
  return { voiceModel, ...(previousModel ? { previousModel } : {}) }
752
938
  }
753
939
 
@@ -755,9 +941,10 @@ export class Spoke {
755
941
  const entry = this.conversations.get(sessionID)
756
942
  this.conversations.delete(sessionID)
757
943
  const session = await this.host.getSession(sessionID)
944
+ this.rememberModel(sessionID, session.model)
758
945
  // Restore the model only if the session is still on the voice model (the user may have picked another).
759
946
  if (entry?.voiceModel && entry.previousModel && session.model && sameModel(session.model, entry.voiceModel)) {
760
- await this.host.switchModel(sessionID, entry.previousModel)
947
+ await this.switchModel(sessionID, entry.previousModel)
761
948
  }
762
949
  const current = session.agent
763
950
  if (current !== VOICE_AGENT) return { agent: current ?? (await this.defaultAgent()) }
@@ -778,7 +965,9 @@ export class Spoke {
778
965
  private async handle(req: ServerRequest): Promise<unknown> {
779
966
  // Session browser ops are not gated by exposure: they are how a session gets exposed.
780
967
  if (req.op === "expose") return this.expose(req.sessionID, req.on)
781
- if (req.op === "create") return this.create(req.directory, req.title, req.herdrWorkspace)
968
+ if (req.op === "create") return this.create(req)
969
+ if (req.op === "sessionTokens") return this.storeTokens(this.tokens, req)
970
+ if (req.op === "actTokens") return this.storeTokens(this.actTokens, req)
782
971
  // Inspection and control need a session this process knows; Voice (conversation) is gated separately.
783
972
  if (!isReported(this.config, this.tracker, req.sessionID) && !(await this.adopt(req.sessionID))) throw new Error(UNKNOWN_SESSION)
784
973
  switch (req.op) {
@@ -786,7 +975,13 @@ export class Spoke {
786
975
  return this.turn(req.sessionID, req.text, req.voiceStyle, req.images)
787
976
  case "permission": {
788
977
  const directory = this.tracker.directoryOf(req.sessionID)
789
- const outcome = await this.host.replyPermission({ sessionID: req.sessionID, requestID: req.requestID, decision: req.decision, ...(directory ? { directory } : {}) })
978
+ const outcome = await this.host.replyPermission({
979
+ sessionID: req.sessionID,
980
+ requestID: req.requestID,
981
+ decision: req.decision,
982
+ ...(req.message !== undefined ? { message: req.message } : {}),
983
+ ...(directory ? { directory } : {}),
984
+ })
790
985
  return outcome === "gone" ? { gone: true } : {}
791
986
  }
792
987
  case "form":
@@ -827,7 +1022,10 @@ export class Spoke {
827
1022
  return this.modelPrefsResult(req.sessionID)
828
1023
  }
829
1024
  case "timeline": {
830
- const items = timelineItems(req.sessionID, await this.host.context(req.sessionID))
1025
+ // A first page reads OpenCode (it shows the newest state); following pages reuse those rows briefly.
1026
+ const first = req.cursor === undefined && req.before === undefined
1027
+ const items = await this.timelines.get(req.sessionID, async () => timelineItems(req.sessionID, await this.host.context(req.sessionID)), first)
1028
+ if (req.before !== undefined || req.order === "desc") return newestPage(items, req.limit ?? 50, req.before) satisfies ResultFor["timeline"]
831
1029
  const offset = req.cursor === undefined ? 0 : Number(req.cursor)
832
1030
  if (!Number.isInteger(offset) || offset < 0) throw new Error("Invalid timeline cursor")
833
1031
  const page = items.slice(offset, offset + (req.limit ?? 50))
@@ -853,6 +1051,10 @@ export class Spoke {
853
1051
  case "interrupt":
854
1052
  await this.host.interrupt(req.sessionID)
855
1053
  return {}
1054
+ case "rename":
1055
+ return this.rename(req.sessionID, req.title)
1056
+ case "autoName":
1057
+ return this.autoName(req.sessionID)
856
1058
  case "stop":
857
1059
  return this.stopTree(req.sessionID)
858
1060
  case "summary":
@@ -864,6 +1066,101 @@ export class Spoke {
864
1066
  }
865
1067
  case "conversation":
866
1068
  return this.setConversation(req.sessionID, req.on)
1069
+ case "deliver":
1070
+ return this.deliver(req)
1071
+ }
1072
+ }
1073
+
1074
+ /**
1075
+ * Admits a hub message as a synthetic message (plan in ./deliver). Never touches the session's voice
1076
+ * settings, model or agent, and takes no turn lock: OpenCode queues it for busy sessions and open forms.
1077
+ */
1078
+ private async deliver(req: DeliverRequest): Promise<DeliverResult> {
1079
+ const synthetic = this.host.synthetic?.bind(this.host)
1080
+ if (!synthetic) throw new Error("This OpenCode process cannot take hub messages")
1081
+ return this.deliveries.run(req.messageID, req.wake, async () => {
1082
+ const subagent = (this.tracker.parentOf(req.sessionID) ?? (await this.host.getSession(req.sessionID).catch(() => undefined))?.parentID) !== undefined
1083
+ const plan = planDelivery(req, {
1084
+ busy: this.running.has(req.sessionID) || this.tracker.isBusy(req.sessionID),
1085
+ formOpen: this.tracker.formOpenIn(req.sessionID),
1086
+ subagent,
1087
+ })
1088
+ const id = harnessMessageID(req.messageID)
1089
+ // Marked before sending: OpenCode may report it delivered before the call returns.
1090
+ this.origins.hub(id)
1091
+ const admitted = await synthetic({ sessionID: req.sessionID, id, text: deliverText(req), metadata: deliverMetadata(req), delivery: plan.delivery, resume: plan.resume })
1092
+ this.timelines.invalidate(req.sessionID)
1093
+ return { result: { delivered: plan.delivered, inboxID: typeof admitted?.id === "string" && admitted.id ? admitted.id : id }, resumed: plan.resume }
1094
+ })
1095
+ }
1096
+
1097
+ /**
1098
+ * Stores the hub's session tokens for sessions this process reports (the tracker knows them). Never adopts or
1099
+ * exposes a session: a token for any other session is skipped as "unknown", an expired one as "expired".
1100
+ */
1101
+ private storeTokens(store: SessionTokenStore, req: SessionTokensRequest | ActTokensRequest): ResultFor["sessionTokens"] {
1102
+ const applied: string[] = []
1103
+ const skipped: Array<ResultFor["sessionTokens"]["skipped"][number]> = []
1104
+ for (const { sessionID, token, exp } of req.tokens) {
1105
+ if (this.config.mode === "off" || !this.tracker.has(sessionID)) skipped.push({ sessionID, reason: "unknown" })
1106
+ else if (store.set(sessionID, token, exp)) applied.push(sessionID)
1107
+ else skipped.push({ sessionID, reason: "expired" })
1108
+ }
1109
+ if (req.op === "sessionTokens" && req.hubURL !== undefined && applied.length > 0) this.tokens.hubURL = req.hubURL
1110
+ store.prune()
1111
+ return { applied, skipped }
1112
+ }
1113
+
1114
+ /**
1115
+ * Renames a session in OpenCode and reports the new title at once (the snapshot goes out before the reply,
1116
+ * on the same socket), without waiting for OpenCode's session.renamed event.
1117
+ */
1118
+ private async rename(sessionID: string, raw: string): Promise<ResultFor["rename"]> {
1119
+ const title = raw.trim()
1120
+ if (!title) throw new Error("Title is empty")
1121
+ if (title.length > MAX_TITLE_CHARS) throw new Error(`Title is longer than ${MAX_TITLE_CHARS} characters`)
1122
+ if (!this.host.updateSession) throw new Error("This OpenCode process cannot rename sessions")
1123
+ await this.host.updateSession(sessionID, { title })
1124
+ if (this.tracker.observe({ type: "session.renamed", data: { sessionID, title } }, Date.now())) this.sendSnapshot()
1125
+ return { title }
1126
+ }
1127
+
1128
+ /**
1129
+ * Has OpenCode regenerate the title with its title agent, then reports the outcome: the new title (from
1130
+ * session.renamed, or read back when the event is late), `unchanged` (OpenCode kept it or produced nothing),
1131
+ * or `pending` when generation outlasts the wait (the catalog updates when the event arrives).
1132
+ */
1133
+ private async autoName(sessionID: string): Promise<ResultFor["autoName"]> {
1134
+ const generate = this.host.generateTitle?.bind(this.host)
1135
+ if (!generate) throw new Error(CANNOT_AUTO_NAME)
1136
+ const before = (await this.host.getSession(sessionID)).title
1137
+ let resolveRenamed!: (title: string) => void
1138
+ const renamed = new Promise<string>((resolve) => (resolveRenamed = resolve))
1139
+ const waiters = this.renameWaiters.get(sessionID) ?? new Set()
1140
+ waiters.add(resolveRenamed)
1141
+ this.renameWaiters.set(sessionID, waiters)
1142
+ const timers: Array<ReturnType<typeof setTimeout>> = []
1143
+ const after = <T>(ms: number, value: T) => new Promise<T>((resolve) => timers.push(setTimeout(() => resolve(value), ms)))
1144
+ try {
1145
+ const first = await Promise.race([
1146
+ renamed.then((title) => ({ title })),
1147
+ generate(sessionID).then((outcome) => ({ outcome })),
1148
+ after(this.options.autoNameWaitMs ?? AUTO_NAME_WAIT_MS, { pending: true as const }),
1149
+ ])
1150
+ if ("title" in first) return { title: first.title }
1151
+ if ("pending" in first) return { pending: true }
1152
+ if (first.outcome === "unavailable") throw new Error(CANNOT_AUTO_NAME)
1153
+ // Generation is done; its session.renamed event may still be on its way.
1154
+ const late = await Promise.race([renamed, after(this.options.autoNameSettleMs ?? 1500, undefined)])
1155
+ if (late !== undefined) return { title: late }
1156
+ const title = (await this.host.getSession(sessionID)).title
1157
+ if (title === before) return { unchanged: true, title }
1158
+ if (this.tracker.observe({ type: "session.renamed", data: { sessionID, title } }, Date.now())) this.sendSnapshot()
1159
+ return { title }
1160
+ } finally {
1161
+ for (const timer of timers) clearTimeout(timer)
1162
+ waiters.delete(resolveRenamed)
1163
+ if (waiters.size === 0) this.renameWaiters.delete(sessionID)
867
1164
  }
868
1165
  }
869
1166
 
@@ -999,11 +1296,11 @@ export class Spoke {
999
1296
  const selected = this.modelPrefs.get(sessionID)?.voice
1000
1297
  const override = this.voiceOverrides.get(sessionID)
1001
1298
  if ((!selected && !override) || this.tracker.active().includes(this.tracker.rootOf(sessionID))) return
1002
- const current = (await this.host.getSession(sessionID)).model
1299
+ const current = await this.currentModel(sessionID)
1003
1300
  const onOverride = override !== undefined && current !== undefined && sameModel(current, override.applied)
1004
1301
  if (!selected) {
1005
1302
  this.voiceOverrides.delete(sessionID)
1006
- if (onOverride && override.previous) await this.host.switchModel(sessionID, override.previous)
1303
+ if (onOverride && override.previous) await this.switchModel(sessionID, override.previous)
1007
1304
  return
1008
1305
  }
1009
1306
  const target = { providerID: selected.providerID, id: selected.modelID, ...(selected.variant ? { variant: selected.variant } : {}) }
@@ -1013,7 +1310,32 @@ export class Spoke {
1013
1310
  }
1014
1311
  const previous = onOverride ? override.previous : current ? plainModelRef(current) : undefined
1015
1312
  this.voiceOverrides.set(sessionID, { ...(previous ? { previous } : {}), applied: target })
1016
- await this.host.switchModel(sessionID, target)
1313
+ await this.switchModel(sessionID, target)
1314
+ }
1315
+
1316
+ /** The session's current model: remembered, else asked from OpenCode (and remembered). */
1317
+ private async currentModel(sessionID: string): Promise<ModelRef | undefined> {
1318
+ const known = this.sessionModels.get(sessionID)
1319
+ if (known) return known.model
1320
+ const { model } = await this.host.getSession(sessionID)
1321
+ this.rememberModel(sessionID, model)
1322
+ return model
1323
+ }
1324
+
1325
+ private rememberModel(sessionID: string, model: ModelRef | undefined): void {
1326
+ this.sessionModels.delete(sessionID)
1327
+ this.sessionModels.set(sessionID, model ? { model } : {})
1328
+ if (this.sessionModels.size > SESSION_MODELS_LIMIT) this.sessionModels.delete(this.sessionModels.keys().next().value!)
1329
+ }
1330
+
1331
+ private async switchModel(sessionID: string, model: ModelRef): Promise<void> {
1332
+ try {
1333
+ await this.host.switchModel(sessionID, model)
1334
+ this.rememberModel(sessionID, model)
1335
+ } catch (cause) {
1336
+ this.sessionModels.delete(sessionID) // unknown now: ask next time
1337
+ throw cause
1338
+ }
1017
1339
  }
1018
1340
 
1019
1341
  /** Enables or disables voice for a session this process reported (mode "session" only). */
@@ -1029,15 +1351,23 @@ export class Spoke {
1029
1351
  * of a Herdr pane. With `herdrWorkspace`, also opens it in a new tab of that Herdr workspace; if that
1030
1352
  * fails the session still exists and the result carries a warning instead of `openedIn`.
1031
1353
  */
1032
- private async create(directory: string, title?: string, herdrWorkspace?: string): Promise<ResultFor["create"] & { warning?: string }> {
1354
+ private async create(req: CreateRequest): Promise<ResultFor["create"] & { warning?: string }> {
1355
+ const { directory, title, herdrWorkspace } = req
1033
1356
  if (this.options.kind !== "service" || !this.host.createSession) {
1034
1357
  throw new Error("Only the background service creates sessions")
1035
1358
  }
1036
1359
  const target = path.resolve(directory)
1037
- const workspace =
1038
- this.workspaces().get(target) ?? (this.herdr?.hasDirectory(target) ? { project: target, projectName: path.basename(target) || target } : undefined)
1360
+ const launch = isLaunchCreate(req)
1361
+ const workspace = launch
1362
+ ? await this.launchWorkspace(req, target)
1363
+ : this.workspaces().get(target) ?? (this.herdr?.hasDirectory(target) ? { project: target, projectName: path.basename(target) || target } : undefined)
1039
1364
  if (!workspace) throw new Error("unknown workspace")
1040
1365
  const session = await this.host.createSession({ directory: target, ...(title ? { title } : {}) })
1366
+ if (launch) {
1367
+ // Agent first: switching the agent may reset the model.
1368
+ if (req.agent) await this.host.switchAgent(session.id, req.agent)
1369
+ if (req.model) await this.switchModel(session.id, { providerID: req.model.providerID, id: req.model.modelID, ...(req.model.variant ? { variant: req.model.variant } : {}) })
1370
+ }
1041
1371
  const now = Date.now()
1042
1372
  this.tracker.observe({ type: "session.created", data: { sessionID: session.id, title: title ?? "", location: { directory: target } } }, now)
1043
1373
  this.tracker.setInfo(session.id, { title: title ?? "", directory: target, updated: now, ...workspace })
@@ -1048,6 +1378,33 @@ export class Spoke {
1048
1378
  return warning ? { sessionID: session.id, warning } : { sessionID: session.id, openedIn: "herdr" }
1049
1379
  }
1050
1380
 
1381
+ /**
1382
+ * The workspace of a launch create (feature "launch"): its directory must exist under the node workspace roots
1383
+ * (a worktree or clone `hub node` prepared need not be one this process reported), and its agent and model must
1384
+ * be ones this OpenCode has. Checked before the session is created.
1385
+ */
1386
+ private async launchWorkspace(req: CreateRequest, target: string): Promise<{ project: string; projectName: string }> {
1387
+ const problem = launchDirectoryProblem(target, this.options.workspaceRoots?.() ?? [])
1388
+ if (problem) throw new Error(problem)
1389
+ if (req.agent && !(await this.host.hasAgent(req.agent))) throw new Error(`Unknown agent ${req.agent}`)
1390
+ if (req.model && !(await this.hasModel(req.model))) {
1391
+ throw new Error(`Unknown model ${req.model.providerID}/${req.model.modelID}${req.model.variant ? `#${req.model.variant}` : ""}`)
1392
+ }
1393
+ return this.workspaces().get(target) ?? { project: target, projectName: path.basename(target) || target }
1394
+ }
1395
+
1396
+ /** Whether OpenCode's model list has `model` (and its variant); true when this OpenCode cannot list models. */
1397
+ private async hasModel(model: NonNullable<CreateRequest["model"]>): Promise<boolean> {
1398
+ if (!this.host.listModels) return true
1399
+ return (await this.host.listModels()).some((value: any) => {
1400
+ const id = typeof value?.id === "string" && value.id ? value.id : value?.modelID
1401
+ if (value?.providerID !== model.providerID || id !== model.modelID) return false
1402
+ if (!model.variant) return true
1403
+ const variants = Array.isArray(value.variants) ? value.variants : []
1404
+ return variants.some((v: any) => (typeof v === "string" ? v : v?.id) === model.variant)
1405
+ })
1406
+ }
1407
+
1051
1408
  /** Opens a session in a new Herdr tab; returns a warning when that did not work. */
1052
1409
  private async openInHerdr(workspaceID: string, directory: string, sessionID: string, title?: string): Promise<string | undefined> {
1053
1410
  const herdr = this.herdr
@@ -1105,7 +1462,11 @@ export class Spoke {
1105
1462
 
1106
1463
  private async listen(): Promise<void> {
1107
1464
  while (!this.abort.signal.aborted) {
1465
+ // Model changes may have been missed while the stream was down: ask OpenCode again.
1466
+ this.sessionModels.clear()
1108
1467
  try {
1468
+ // Events may have been missed while the stream was down (or before it opened): ask what runs now.
1469
+ if (this.connection?.state === "connected") void this.seedActivity()
1109
1470
  for await (const event of this.host.events(this.abort.signal)) this.observe(event)
1110
1471
  } catch (cause) {
1111
1472
  if (this.abort.signal.aborted) return
@@ -1118,7 +1479,15 @@ export class Spoke {
1118
1479
 
1119
1480
  private observe(event: EventLike): void {
1120
1481
  try {
1482
+ const sessionID = (event.data as Record<string, unknown> | undefined)?.sessionID
1483
+ if (typeof sessionID === "string") this.timelines.invalidate(sessionID)
1121
1484
  this.turns.observe(event)
1485
+ this.observeModel(event)
1486
+ this.origins.observe(event)
1487
+ if (event.type === "session.deleted" && typeof sessionID === "string") {
1488
+ this.tokens.delete(sessionID)
1489
+ this.actTokens.delete(sessionID)
1490
+ }
1122
1491
  if (event.type === "form.replied" || event.type === "form.cancelled") {
1123
1492
  const formID = (event.data as Record<string, unknown> | undefined)?.id
1124
1493
  if (typeof formID === "string") {
@@ -1126,21 +1495,49 @@ export class Spoke {
1126
1495
  this.relay.settled.delete(formID)
1127
1496
  }
1128
1497
  }
1498
+ this.tasks.observe(event, Date.now())
1499
+ if (event.type === "session.renamed" && typeof sessionID === "string") {
1500
+ const title = (event.data as Record<string, unknown>).title
1501
+ if (typeof title === "string") for (const waiter of this.renameWaiters.get(sessionID) ?? []) waiter(title)
1502
+ }
1129
1503
  if (this.tracker.observe(event, Date.now())) this.scheduleSnapshot()
1130
1504
  if (this.options.kind === "service" && this.host.listRecent && LISTING_EVENTS.has(event.type) && !(event.data as any)?.parentID) {
1131
1505
  this.scheduleSeed()
1132
1506
  }
1133
1507
  void this.fetchInfo()
1134
- if (this.connection?.state === "connected" && shouldForward(this.config, this.tracker, event)) {
1135
- // Activity events go out compact: tool inputs become a short summary, outputs are dropped.
1136
- const compact = compactActivity({ type: event.type, data: (event.data ?? {}) as Record<string, unknown> })
1137
- if (compact) this.connection.send({ t: "event", event: compact })
1508
+ if (this.connection?.state === "connected" && (FORWARDED_EVENTS.has(event.type) || COMPACTED_EVENTS.has(event.type))) {
1509
+ // Activity events go out compact: tool inputs become a short summary, outputs are dropped; job exits
1510
+ // get their session from the job.
1511
+ const compact = this.withOrigin(compactActivity({ type: event.type, data: (event.data ?? {}) as Record<string, unknown> }, (id) => this.tracker.jobSession(id)))
1512
+ if (compact && shouldForward(this.config, this.tracker, compact)) this.connection.send({ t: "event", event: compact })
1138
1513
  }
1139
1514
  } catch (cause) {
1140
1515
  console.error("[hub] event handling failed", cause)
1141
1516
  }
1142
1517
  }
1143
1518
 
1519
+ /**
1520
+ * Adds who put the item in the inbox (user or hub) to session.inbox.delivered, for hubs that route messages
1521
+ * (they reset reply-chain hops on a user's turn); when unknown, the event goes out unchanged.
1522
+ */
1523
+ private withOrigin<E extends { type: string; data: Record<string, unknown> }>(event: E | undefined): E | undefined {
1524
+ if (event?.type !== "session.inbox.delivered" || typeof event.data.inboxID !== "string") return event
1525
+ const origin = this.origins.take(event.data.inboxID)
1526
+ return origin && this.hubSupports("messaging") ? { ...event, data: { ...event.data, origin } } : event
1527
+ }
1528
+
1529
+ /** Keeps the remembered session models current from OpenCode's events. */
1530
+ private observeModel(event: EventLike): void {
1531
+ const data = (event.data ?? {}) as { sessionID?: unknown; model?: unknown }
1532
+ if (typeof data.sessionID !== "string") return
1533
+ if (event.type === "session.deleted") this.sessionModels.delete(data.sessionID)
1534
+ else if (event.type === "session.model.selected") {
1535
+ const model = data.model as Partial<ModelRef> | undefined
1536
+ if (model && typeof model.providerID === "string" && typeof model.id === "string") this.rememberModel(data.sessionID, model as ModelRef)
1537
+ else this.sessionModels.delete(data.sessionID)
1538
+ }
1539
+ }
1540
+
1144
1541
  private async fetchInfo(): Promise<void> {
1145
1542
  const now = Date.now()
1146
1543
  for (const id of this.tracker.needsInfo()) {
@@ -1163,6 +1560,40 @@ export class Spoke {
1163
1560
  }
1164
1561
  }
1165
1562
 
1563
+ /**
1564
+ * Sends each running (or formerly running) session's current status and running jobs as a session.seed event,
1565
+ * from the host's snapshot. Sessions the tracker thought working that are not active any more are seeded idle.
1566
+ */
1567
+ private async seedActivity(): Promise<void> {
1568
+ const activity = this.host.activity
1569
+ if (!activity || this.seeding) return
1570
+ this.seeding = true
1571
+ try {
1572
+ const snapshot = await activity.call(this.host, this.tracker.directories(SEED_DIRECTORIES))
1573
+ const active = new Set(snapshot.active)
1574
+ const ids = new Set([...snapshot.active, ...snapshot.jobs.map((j) => j.sessionID), ...this.tracker.working()])
1575
+ const now = Date.now()
1576
+ for (const id of ids) {
1577
+ if (active.has(id)) this.tracker.ensure(id, now)
1578
+ if (!this.tracker.has(id)) continue
1579
+ const jobs = snapshot.jobs
1580
+ .filter((j) => j.sessionID === id && j.status === "running")
1581
+ .slice(0, SEED_JOBS_MAX)
1582
+ .map(({ sessionID: _, ...job }) => job)
1583
+ const status = active.has(id) ? "busy" : "idle"
1584
+ this.tracker.seed(id, { status, jobs })
1585
+ const event = { type: "session.seed", data: { sessionID: id, status: { type: status }, jobs } }
1586
+ if (this.connection?.state === "connected" && shouldForward(this.config, this.tracker, event)) this.connection.send({ t: "event", event })
1587
+ }
1588
+ void this.fetchInfo()
1589
+ this.scheduleSnapshot()
1590
+ } catch (cause) {
1591
+ console.error("[hub] could not read running sessions", errorText(cause))
1592
+ } finally {
1593
+ this.seeding = false
1594
+ }
1595
+ }
1596
+
1166
1597
  private async seed(): Promise<void> {
1167
1598
  try {
1168
1599
  const keep = this.keep()
@@ -1202,11 +1633,17 @@ export class Spoke {
1202
1633
  }
1203
1634
  }
1204
1635
 
1636
+ /** Directories whose shells are listed when seeding. */
1637
+ const SEED_DIRECTORIES = 20
1638
+
1205
1639
  /** Keeps the terminals frame within the protocol's limits. */
1206
1640
  function limitWorkspaces(workspaces: readonly HerdrWorkspace[]): HerdrWorkspace[] {
1207
1641
  return workspaces.slice(0, 200).map((w) => ({ id: w.id, label: w.label, directories: w.directories.slice(0, 20) }))
1208
1642
  }
1209
1643
 
1644
+ /** Sessions whose current model is remembered. */
1645
+ const SESSION_MODELS_LIMIT = 500
1646
+
1210
1647
  function sameModel(a: ModelRef, b: ModelRef): boolean {
1211
1648
  return a.providerID === b.providerID && a.id === b.id && (explicitVariant(a) ?? "") === (explicitVariant(b) ?? "")
1212
1649
  }