@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
@@ -0,0 +1,243 @@
1
+ // The voice agent's task board: its background workers (child sessions) with their state, last activity and
2
+ // how long, built from the OpenCode events this process sees. The context hook adds the rendered board to every
3
+ // voice turn, so the agent can answer "what's running?" or "how's the deploy going?" without a tool call, and
4
+ // the stop tool (hub_task_stop) checks with it that a worker belongs to the session asking. Pure: time is
5
+ // passed in.
6
+ import { summarizeToolInput } from "../protocol"
7
+
8
+ /** Tasks shown on the board, and the longest line. */
9
+ export const TASK_BOARD_MAX = 8
10
+ export const TASK_LINE_MAX = 200
11
+ /** Tasks remembered in this process (finished ones are forgotten first). */
12
+ const TASK_LIMIT = 300
13
+ const TITLE_MAX = 60
14
+ const TEXT_MAX = 160
15
+ /** Tool call IDs remembered per task to name their activity. */
16
+ const TOOL_NAMES_MAX = 20
17
+
18
+ export type TaskState = "working" | "blocked" | "done" | "failed" | "stopped"
19
+
20
+ export interface Task {
21
+ readonly id: string
22
+ readonly parentID: string
23
+ readonly title: string
24
+ readonly state: TaskState
25
+ /** ms epoch the current run started. */
26
+ readonly started: number
27
+ /** ms epoch the run ended (done, failed, stopped). */
28
+ readonly ended?: number
29
+ /** Since when it waits for the user (blocked). */
30
+ readonly blockedAt?: number
31
+ /** The latest tool it ran ("shell · bun test"). */
32
+ readonly activity?: string
33
+ /** Its latest assistant text (the result once done). */
34
+ readonly result?: string
35
+ readonly error?: string
36
+ /** Why it is blocked ("needs permission: …", "has a question: …"). */
37
+ readonly blocker?: string
38
+ }
39
+
40
+ interface Entry {
41
+ id: string
42
+ parentID: string
43
+ title: string
44
+ running: boolean
45
+ outcome?: "done" | "failed" | "stopped"
46
+ started: number
47
+ ended?: number
48
+ activity?: string
49
+ result?: string
50
+ error?: string
51
+ /** Pending permission / form ID → its description, in order. */
52
+ blockers: Map<string, { text: string; at: number }>
53
+ tools: Map<string, string>
54
+ }
55
+
56
+ const record = (v: unknown): Record<string, any> => (typeof v === "object" && v !== null ? (v as Record<string, any>) : {})
57
+ const str = (v: unknown) => (typeof v === "string" && v ? v : undefined)
58
+ const oneLine = (text: string, max: number) => {
59
+ const flat = text.replace(/\s+/g, " ").trim()
60
+ return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
61
+ }
62
+
63
+ const LIVE = (e: Entry) => e.running || e.blockers.size > 0
64
+
65
+ export class TaskBoard {
66
+ private readonly entries = new Map<string, Entry>()
67
+ private readonly limit: number
68
+
69
+ constructor({ limit = TASK_LIMIT }: { limit?: number } = {}) {
70
+ this.limit = limit
71
+ }
72
+
73
+ observe(event: { type: string; data?: unknown }, now: number): void {
74
+ const data = record(event.data)
75
+ const sessionID = str(data.sessionID) ?? str(record(data.form).sessionID)
76
+ if (!sessionID) return
77
+ if (event.type === "session.created") {
78
+ const parentID = str(data.parentID)
79
+ if (!parentID) return
80
+ this.entries.delete(sessionID)
81
+ this.entries.set(sessionID, {
82
+ id: sessionID,
83
+ parentID,
84
+ title: oneLine(str(data.title) ?? str(data.agent) ?? "worker", TITLE_MAX),
85
+ running: false,
86
+ started: now,
87
+ blockers: new Map(),
88
+ tools: new Map(),
89
+ })
90
+ this.prune()
91
+ return
92
+ }
93
+ const entry = this.entries.get(sessionID)
94
+ if (!entry) return
95
+ switch (event.type) {
96
+ case "session.renamed":
97
+ if (str(data.title)) entry.title = oneLine(data.title, TITLE_MAX)
98
+ return
99
+ case "session.deleted":
100
+ this.entries.delete(sessionID)
101
+ return
102
+ case "session.execution.started":
103
+ if (!entry.running) entry.started = now
104
+ entry.running = true
105
+ entry.outcome = entry.ended = entry.error = undefined
106
+ return
107
+ case "session.execution.succeeded":
108
+ case "session.execution.failed":
109
+ case "session.execution.interrupted":
110
+ entry.running = false
111
+ entry.ended = now
112
+ entry.blockers.clear()
113
+ entry.outcome = event.type.endsWith("succeeded") ? "done" : event.type.endsWith("failed") ? "failed" : "stopped"
114
+ if (entry.outcome === "failed") entry.error = oneLine(str(record(data.error).message) ?? "failed", TEXT_MAX)
115
+ return
116
+ case "session.tool.input.started": {
117
+ const id = str(data.id)
118
+ const name = str(data.name)
119
+ if (!id || !name) return
120
+ entry.tools.set(id, name)
121
+ if (entry.tools.size > TOOL_NAMES_MAX) entry.tools.delete(entry.tools.keys().next().value!)
122
+ entry.activity = name
123
+ return
124
+ }
125
+ case "session.tool.called": {
126
+ const name = entry.tools.get(str(data.id) ?? "") ?? str(data.name) ?? "tool"
127
+ const summary = str(data.summary) ?? summarizeToolInput(data.input)
128
+ entry.activity = oneLine(summary ? `${name} · ${summary}` : name, TEXT_MAX)
129
+ return
130
+ }
131
+ case "session.text.ended":
132
+ if (str(data.text)?.trim()) entry.result = oneLine(data.text, TEXT_MAX)
133
+ return
134
+ case "permission.asked": {
135
+ const id = str(data.id)
136
+ if (!id) return
137
+ const what = [str(data.action), Array.isArray(data.resources) ? data.resources.filter((r: unknown) => typeof r === "string").join(", ") : ""].filter(Boolean).join(": ")
138
+ entry.blockers.set(`per ${id}`, { text: oneLine(what ? `needs permission: ${what}` : "needs permission", TEXT_MAX), at: now })
139
+ return
140
+ }
141
+ case "permission.replied":
142
+ entry.blockers.delete(`per ${str(data.requestID)}`)
143
+ return
144
+ case "form.created": {
145
+ const form = record(data.form)
146
+ const id = str(form.id)
147
+ if (!id) return
148
+ entry.blockers.set(`frm ${id}`, { text: oneLine(str(form.title) ? `has a question: ${form.title}` : "has a question", TEXT_MAX), at: now })
149
+ return
150
+ }
151
+ case "form.replied":
152
+ case "form.cancelled":
153
+ entry.blockers.delete(`frm ${str(data.id) ?? str(record(data.form).id)}`)
154
+ return
155
+ }
156
+ }
157
+
158
+ /** The workers a session started: live ones first (newest first), then finished ones (latest first). */
159
+ children(parentID: string): Task[] {
160
+ const mine = [...this.entries.values()].filter((e) => e.parentID === parentID)
161
+ const live = mine.filter(LIVE).sort((a, b) => b.started - a.started)
162
+ const finished = mine.filter((e) => !LIVE(e)).sort((a, b) => (b.ended ?? b.started) - (a.ended ?? a.started))
163
+ return [...live, ...finished].map(view)
164
+ }
165
+
166
+ get(id: string): Task | undefined {
167
+ const entry = this.entries.get(id)
168
+ return entry && view(entry)
169
+ }
170
+
171
+ /** The session that started a known worker. */
172
+ parentOf(id: string): string | undefined {
173
+ return this.entries.get(id)?.parentID
174
+ }
175
+
176
+ private prune(): void {
177
+ while (this.entries.size > this.limit) {
178
+ const finished = [...this.entries.values()].filter((e) => !LIVE(e)).sort((a, b) => (a.ended ?? a.started) - (b.ended ?? b.started))[0]
179
+ this.entries.delete(finished?.id ?? this.entries.keys().next().value!)
180
+ }
181
+ }
182
+ }
183
+
184
+ function view(entry: Entry): Task {
185
+ const blocker = [...entry.blockers.values()][0]
186
+ const state: TaskState = blocker ? "blocked" : entry.running ? "working" : (entry.outcome ?? "working")
187
+ return {
188
+ id: entry.id,
189
+ parentID: entry.parentID,
190
+ title: entry.title,
191
+ state,
192
+ started: entry.started,
193
+ ...(entry.ended !== undefined && !entry.running ? { ended: entry.ended } : {}),
194
+ ...(blocker ? { blocker: blocker.text, blockedAt: blocker.at } : {}),
195
+ ...(entry.activity ? { activity: entry.activity } : {}),
196
+ ...(entry.result ? { result: entry.result } : {}),
197
+ ...(entry.error ? { error: entry.error } : {}),
198
+ }
199
+ }
200
+
201
+ /** "45s", "2m 10s", "1h 5m". */
202
+ export function duration(ms: number): string {
203
+ const s = Math.max(0, Math.round(ms / 1000))
204
+ if (s < 60) return `${s}s`
205
+ const m = Math.floor(s / 60)
206
+ if (m < 60) return s % 60 ? `${m}m ${s % 60}s` : `${m}m`
207
+ return m % 60 ? `${Math.floor(m / 60)}h ${m % 60}m` : `${Math.floor(m / 60)}h`
208
+ }
209
+
210
+ function line(task: Task, now: number): string {
211
+ const head = `- "${task.title}" (sessionID ${task.id}): `
212
+ let rest: string
213
+ switch (task.state) {
214
+ case "blocked":
215
+ rest = `blocked ${duration(now - (task.blockedAt ?? task.started))}, ${task.blocker}`
216
+ break
217
+ case "working":
218
+ rest = `working ${duration(now - task.started)}${task.activity ? `, last: ${task.activity}` : ""}`
219
+ break
220
+ case "done":
221
+ rest = `done ${duration(now - (task.ended ?? now))} ago (took ${duration((task.ended ?? now) - task.started)})${task.result ? `: ${task.result}` : ""}`
222
+ break
223
+ case "failed":
224
+ rest = `failed ${duration(now - (task.ended ?? now))} ago${task.error ? `: ${task.error}` : ""}`
225
+ break
226
+ case "stopped":
227
+ rest = `stopped ${duration(now - (task.ended ?? now))} ago`
228
+ break
229
+ }
230
+ const text = head + rest
231
+ return text.length > TASK_LINE_MAX ? `${text.slice(0, TASK_LINE_MAX - 1)}…` : text
232
+ }
233
+
234
+ /** The board for the voice agent's system prompt; undefined when it started no workers. */
235
+ export function renderTaskBoard(tasks: readonly Task[], now: number, { max = TASK_BOARD_MAX }: { max?: number } = {}): string | undefined {
236
+ if (!tasks.length) return undefined
237
+ const shown = tasks.slice(0, max)
238
+ return [
239
+ "Task board: your background workers right now (live; answer questions about them from this, no tool call needed; use a sessionID to continue or stop that worker):",
240
+ ...shown.map((task) => line(task, now)),
241
+ ...(tasks.length > shown.length ? [`(${tasks.length - shown.length} older tasks not shown)`] : []),
242
+ ].join("\n")
243
+ }
@@ -1,4 +1,5 @@
1
- import { PROMPT_IMAGE_MIMES, type PromptImage } from "../protocol"
1
+ import { DELIVER_ID_MAX, MESSAGE_KINDS, PROMPT_IMAGE_MIMES, type MessageKind, type PromptImage, type TimelineMessageItem } from "../protocol"
2
+ import { PROMPT_SOURCE } from "./conversation"
2
3
 
3
4
  /** An attachment of a user message: its position in the message's files (for timelineFile), type and name. */
4
5
  export interface TimelineFile {
@@ -12,46 +13,223 @@ export interface TimelineItem {
12
13
  readonly messageID: string
13
14
  readonly partID?: string
14
15
  readonly sessionID: string
15
- readonly kind: "user" | "assistant" | "reasoning" | "tool"
16
+ /** "message": a hub message delivered to the session (TimelineMessageItem). */
17
+ readonly kind: "user" | "assistant" | "reasoning" | "tool" | "message"
16
18
  readonly text?: string
17
19
  readonly name?: string
18
20
  readonly state?: "running" | "done" | "failed"
19
21
  readonly at?: number
20
22
  readonly completed?: number
21
23
  readonly files?: readonly TimelineFile[]
24
+ /**
25
+ * Ordering position: rows sort by it (never by `at`, whose clocks differ between parts). It follows the order
26
+ * of the session's messages and of the parts within a message, and depends only on the message's own creation
27
+ * time and its predecessors' (so it stays the same while history pages or the context window move).
28
+ */
29
+ readonly pos?: number
30
+ /** Tool rows: the tool call ID (live activity items use it as their ID). */
31
+ readonly callID?: string
32
+ /** Subagent (subagent/task) tool rows: the child session it runs in. */
33
+ readonly childSessionID?: string
34
+ /** Subagent tool rows: the task's short description. */
35
+ readonly title?: string
36
+ /** Assistant text rows: which text part of the message this is (0, 1, …), as in live text deltas. */
37
+ readonly ordinal?: number
38
+ /** Message rows: always "in" here (the hub adds the session's sent messages). */
39
+ readonly direction?: TimelineMessageItem["direction"]
40
+ /** Message rows: who sent it, how it was addressed, what it answers. */
41
+ readonly message?: TimelineMessageItem["message"]
22
42
  }
23
43
 
44
+ /** Parts of one message a position leaves room for (later parts share the last slot). */
45
+ const POS_PARTS = 1000
46
+ const SUBAGENT_TOOLS = new Set(["subagent", "task"])
47
+ const TITLE_MAX = 160
48
+
24
49
  const record = (value: unknown): Record<string, any> => value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, any> : {}
25
50
 
26
51
  /** Projects OpenCode messages into stable, ordered, payload-light phone timeline rows. */
27
52
  export function timelineItems(sessionID: string, messages: readonly unknown[]): TimelineItem[] {
28
53
  const out: TimelineItem[] = []
54
+ let previous = -Infinity
29
55
  for (const raw of messages) {
30
56
  const message = record(raw)
31
57
  if (typeof message.id !== "string") continue
32
58
  const at = typeof message.time?.created === "number" ? message.time.created : undefined
59
+ // Messages keep the session's order even when one was created earlier than the one before it (a queued prompt).
60
+ const start = Math.max(at === undefined ? -Infinity : at * POS_PARTS, previous + POS_PARTS)
61
+ const first = (previous = Number.isFinite(start) ? start : 0)
62
+ const pos = (index: number) => first + Math.min(index, POS_PARTS - 1)
33
63
  if (message.type === "user") {
34
64
  const text = typeof message.text === "string" ? message.text : Array.isArray(message.content) ? message.content.filter((p: any) => p?.type === "text").map((p: any) => p.text).join("") : ""
35
65
  const files = userFiles(message.files)
36
- if (text || files.length) out.push({ key: `${message.id}:text`, messageID: message.id, sessionID, kind: "user", text, ...(at === undefined ? {} : { at }), ...(files.length ? { files } : {}) })
66
+ if (text || files.length) out.push({ key: `${message.id}:text`, messageID: message.id, sessionID, kind: "user", text, ...(at === undefined ? {} : { at }), ...(files.length ? { files } : {}), pos: pos(0) })
67
+ continue
68
+ }
69
+ if (message.type === "synthetic") {
70
+ const row = hubMessageRow(sessionID, message, at, pos(0))
71
+ if (row) out.push(row)
37
72
  continue
38
73
  }
39
74
  if (message.type !== "assistant" || !Array.isArray(message.content)) continue
75
+ let texts = 0
40
76
  message.content.forEach((part: any, index: number) => {
41
77
  if (!part || typeof part !== "object") return
42
78
  const partID = String(index)
43
- const base = { key: `${message.id}:${partID}`, messageID: message.id, partID, sessionID, ...(at === undefined ? {} : { at }) }
44
- if (part.type === "text" && typeof part.text === "string" && part.text) out.push({ ...base, kind: "assistant", text: part.text })
79
+ const base = { key: `${message.id}:${partID}`, messageID: message.id, partID, sessionID, ...(at === undefined ? {} : { at }), pos: pos(index) }
80
+ if (part.type === "text" && typeof part.text === "string") {
81
+ const ordinal = texts++
82
+ if (part.text) out.push({ ...base, kind: "assistant", text: part.text, ordinal })
83
+ }
45
84
  if (part.type === "reasoning") out.push({ ...base, kind: "reasoning", text: String(part.text ?? ""), state: part.time?.completed === undefined ? "running" : "done", ...(typeof part.time?.created === "number" ? { at: part.time.created } : {}), ...(typeof part.time?.completed === "number" ? { completed: part.time.completed } : {}) })
46
85
  if (part.type === "tool") {
47
86
  const status = part.state?.status
48
- out.push({ ...base, kind: "tool", name: String(part.name ?? "tool"), state: status === "error" ? "failed" : status === "completed" ? "done" : "running", ...(typeof part.time?.created === "number" ? { at: part.time.created } : {}), ...(typeof part.time?.completed === "number" ? { completed: part.time.completed } : {}) })
87
+ const name = String(part.name ?? "tool")
88
+ const spawn = SUBAGENT_TOOLS.has(name) ? subagentOf(part.state) : {}
89
+ out.push({
90
+ ...base,
91
+ kind: "tool",
92
+ name,
93
+ state: status === "error" ? "failed" : status === "completed" ? "done" : "running",
94
+ ...(typeof part.id === "string" && part.id ? { callID: part.id } : {}),
95
+ ...spawn,
96
+ ...(typeof part.time?.created === "number" ? { at: part.time.created } : {}),
97
+ ...(typeof part.time?.completed === "number" ? { completed: part.time.completed } : {}),
98
+ })
49
99
  }
50
100
  })
51
101
  }
52
102
  return out
53
103
  }
54
104
 
105
+ /** A message the hub delivered (synthetic, with deliverMetadata): a "message" row; other synthetic messages: none. */
106
+ function hubMessageRow(sessionID: string, message: Record<string, any>, at: number | undefined, pos: number): TimelineMessageItem | undefined {
107
+ const meta = record(message.metadata)
108
+ const id = meta.messageID
109
+ if (meta.source !== PROMPT_SOURCE || !isID(id) || !MESSAGE_KINDS.includes(meta.kind) || !isID(meta.from) || !isID(meta.to)) return undefined
110
+ const text = typeof message.text === "string" ? message.text : ""
111
+ return {
112
+ key: `${message.id}:message`,
113
+ messageID: id,
114
+ sessionID,
115
+ kind: "message",
116
+ text: meta.kind === "primer" ? text : messageBody(text, id),
117
+ ...(at === undefined ? {} : { at }),
118
+ pos,
119
+ direction: "in",
120
+ message: { kind: meta.kind as MessageKind, from: meta.from, to: meta.to, ...(isID(meta.replyTo) ? { replyTo: meta.replyTo } : {}) },
121
+ }
122
+ }
123
+
124
+ const isID = (value: unknown): value is string => typeof value === "string" && value.length > 0 && value.length <= DELIVER_ID_MAX
125
+
126
+ /** A line of the envelope body escaped by renderEnvelope (a leading backslash before a header-like line). */
127
+ const ESCAPED = /^(\s*)\\(\\*(?:\[hub\]|-{3,}|↩))/i
128
+
129
+ /**
130
+ * The body of a hub envelope (packages/sdk/src/envelope.ts renderEnvelope): the lines between its boundaries,
131
+ * unescaped. Text without boundaries (a reminder) shows its first line without the "[hub]" tag.
132
+ */
133
+ function messageBody(text: string, id: string): string {
134
+ const lines = text.split("\n")
135
+ const boundary = `----- ${id} -----`
136
+ const start = lines.indexOf(boundary)
137
+ const end = lines.lastIndexOf(boundary)
138
+ if (start < 0 || end <= start) return lines[0]!.replace(/^\[hub\]\s*/i, "")
139
+ return lines
140
+ .slice(start + 1, end)
141
+ .map((line) => line.replace(ESCAPED, "$1$2"))
142
+ .join("\n")
143
+ }
144
+
145
+ const CHILD_SESSION = /sessionID:?\s*"?(ses_[A-Za-z0-9]+)/
146
+
147
+ /** The child session (from the tool's metadata, else its output) and description of a subagent tool call. */
148
+ function subagentOf(rawState: unknown): { childSessionID?: string; title?: string } {
149
+ const state = record(rawState)
150
+ const description = typeof state.input?.description === "string" ? state.input.description.trim().split("\n")[0]!.slice(0, TITLE_MAX) : ""
151
+ let child = typeof state.metadata?.sessionID === "string" && state.metadata.sessionID.startsWith("ses_") ? (state.metadata.sessionID as string) : undefined
152
+ if (!child) {
153
+ const outputs = [state.output, state.error, ...(Array.isArray(state.content) ? state.content.map((c: any) => c?.text) : [])]
154
+ for (const text of outputs) {
155
+ const match = typeof text === "string" ? CHILD_SESSION.exec(text) : null
156
+ if (match) {
157
+ child = match[1]
158
+ break
159
+ }
160
+ }
161
+ }
162
+ return { ...(child ? { childSessionID: child } : {}), ...(description ? { title: description } : {}) }
163
+ }
164
+
165
+ /** A newest-first timeline page: rows in chronological order, and the cursor of the rows before them. */
166
+ export interface NewestPage {
167
+ readonly items: TimelineItem[]
168
+ /** Pass as `before` for the next older page; absent at the beginning of the conversation. */
169
+ readonly older?: string
170
+ }
171
+
172
+ /**
173
+ * The `limit` rows just before the row keyed `before` (the newest rows without it). The cursor is the key of
174
+ * the oldest row returned, so it stays valid while new messages arrive (an offset would shift). A page starts
175
+ * at a message boundary when the message fits in twice the limit; larger messages are split.
176
+ */
177
+ export function newestPage(items: readonly TimelineItem[], limit: number, before?: string): NewestPage {
178
+ const end = before === undefined ? items.length : items.findIndex((item) => item.key === before)
179
+ if (end < 0) throw new Error("Invalid timeline cursor")
180
+ let start = Math.max(0, end - limit)
181
+ const cap = Math.max(0, end - limit * 2)
182
+ while (start > cap && items[start - 1]!.messageID === items[start]!.messageID) start--
183
+ const page = items.slice(start, end)
184
+ return { items: page, ...(start > 0 && page.length ? { older: page[0]!.key } : {}) }
185
+ }
186
+
187
+ /**
188
+ * Short-lived per-session timeline rows, so paging through a history reads OpenCode's whole history once rather
189
+ * than once per page. Entries expire after `ttlMs` and are dropped when the session changes (invalidate).
190
+ */
191
+ export class TimelineCache {
192
+ private readonly entries = new Map<string, { items: TimelineItem[]; at: number }>()
193
+ /** Bumped on invalidation: a load started before it is not stored. */
194
+ private readonly generations = new Map<string, number>()
195
+ private readonly ttlMs: number
196
+ private readonly max: number
197
+ private readonly now: () => number
198
+
199
+ constructor(options: { ttlMs?: number; max?: number; now?: () => number } = {}) {
200
+ this.ttlMs = options.ttlMs ?? 30_000
201
+ this.max = options.max ?? 20
202
+ this.now = options.now ?? Date.now
203
+ }
204
+
205
+ get size(): number {
206
+ return this.entries.size
207
+ }
208
+
209
+ /** The cached rows of `sessionID`, else (or with `fresh`) loaded and cached. */
210
+ async get(sessionID: string, load: () => Promise<TimelineItem[]>, fresh = false): Promise<TimelineItem[]> {
211
+ const cached = this.entries.get(sessionID)
212
+ if (!fresh && cached && this.now() - cached.at < this.ttlMs) {
213
+ this.entries.delete(sessionID)
214
+ this.entries.set(sessionID, cached)
215
+ return cached.items
216
+ }
217
+ const generation = this.generations.get(sessionID) ?? 0
218
+ const items = await load()
219
+ if ((this.generations.get(sessionID) ?? 0) !== generation) return items
220
+ this.entries.delete(sessionID)
221
+ this.entries.set(sessionID, { items, at: this.now() })
222
+ while (this.entries.size > this.max) this.entries.delete(this.entries.keys().next().value!)
223
+ return items
224
+ }
225
+
226
+ invalidate(sessionID: string): void {
227
+ this.entries.delete(sessionID)
228
+ if (this.generations.size > 1000) this.generations.clear()
229
+ this.generations.set(sessionID, (this.generations.get(sessionID) ?? 0) + 1)
230
+ }
231
+ }
232
+
55
233
  /** Attachment metadata (never the bytes) of a user message's files. */
56
234
  function userFiles(raw: unknown): TimelineFile[] {
57
235
  if (!Array.isArray(raw)) return []
@@ -1,7 +1,7 @@
1
1
  // Sessions this OpenCode process knows about, built from the events it sees, plus which of them
2
2
  // are exposed to the central server (by mode). Pure: time is passed in.
3
3
  import type { Mode } from "../config"
4
- import { FORWARDED_EVENTS, type SessionInfo, type TerminalInfo } from "../protocol"
4
+ import { compactActivity, FORWARDED_EVENTS, type JobInfo, type SessionInfo, type TerminalInfo } from "../protocol"
5
5
 
6
6
  /** A session is "owned" by this process while it viewed or ran it within this window. */
7
7
  export const OWNED_WINDOW_MS = 30 * 60 * 1000
@@ -58,6 +58,9 @@ export function eventSessionID(event: EventLike): string | undefined {
58
58
  return undefined
59
59
  }
60
60
 
61
+ /** Shells and terminals remembered (for the session of their exit). */
62
+ const JOBS_KNOWN = 1000
63
+
61
64
  const RUN_END = new Set(["session.execution.succeeded", "session.execution.failed", "session.execution.interrupted"])
62
65
 
63
66
  export class SessionTracker {
@@ -66,6 +69,10 @@ export class SessionTracker {
66
69
  private readonly running = new Set<string>()
67
70
  /** Pending permission or form ID → its session. */
68
71
  private readonly waiting = new Map<string, string>()
72
+ /** Sessions OpenCode reports busy or retrying (session.status). */
73
+ private readonly busy = new Set<string>()
74
+ /** Shell or terminal ID → its session, while known (running or exited, until deleted). */
75
+ private readonly jobs = new Map<string, { sessionID: string; running: boolean }>()
69
76
 
70
77
  /** Applies an event seen in this process. Returns true when the snapshot may have changed. */
71
78
  observe(event: EventLike, now: number): boolean {
@@ -94,6 +101,8 @@ export class SessionTracker {
94
101
  }
95
102
  case "session.deleted":
96
103
  this.running.delete(id)
104
+ this.busy.delete(id)
105
+ for (const [jobID, job] of this.jobs) if (job.sessionID === id) this.jobs.delete(jobID)
97
106
  for (const [key, sessionID] of this.waiting) if (sessionID === id) this.waiting.delete(key)
98
107
  return this.entries.delete(id)
99
108
  default: {
@@ -178,18 +187,73 @@ export class SessionTracker {
178
187
  return true
179
188
  }
180
189
 
181
- /** Root sessions with a run in progress or a pending permission or form somewhere in their tree. */
190
+ /**
191
+ * Root sessions with a run in progress, a busy or retrying status, a running shell or terminal, or a pending
192
+ * permission or form somewhere in their tree.
193
+ */
182
194
  active(): string[] {
183
195
  const roots = new Set<string>()
184
- for (const id of [...this.running, ...this.waiting.values()]) roots.add(this.rootOf(id))
196
+ const jobs = [...this.jobs.values()].filter((j) => j.running).map((j) => j.sessionID)
197
+ for (const id of [...this.running, ...this.busy, ...jobs, ...this.waiting.values()]) roots.add(this.rootOf(id))
185
198
  return [...roots]
186
199
  }
187
200
 
201
+ /** Sessions OpenCode reported busy or retrying, or with a running shell or terminal (not their roots). */
202
+ working(): string[] {
203
+ const jobs = [...this.jobs.values()].filter((j) => j.running).map((j) => j.sessionID)
204
+ return [...new Set([...this.busy, ...jobs])]
205
+ }
206
+
207
+ /** Directories of known sessions (where to list shells), most recently updated first. */
208
+ directories(max: number): string[] {
209
+ const sorted = [...this.entries.values()].filter((e) => e.directory.startsWith("/")).sort((a, b) => b.updated - a.updated)
210
+ return [...new Set(sorted.map((e) => e.directory))].slice(0, max)
211
+ }
212
+
213
+ /** The session a shell or terminal belongs to, while known. */
214
+ jobSession(jobID: string): string | undefined {
215
+ return this.jobs.get(jobID)?.sessionID
216
+ }
217
+
218
+ /** Replaces a session's status and running jobs with a snapshot (fetched at startup or after a reconnect). */
219
+ seed(sessionID: string, snapshot: { readonly status: "idle" | "busy" | "retry"; readonly jobs: readonly JobInfo[] }): void {
220
+ if (snapshot.status === "idle") this.busy.delete(sessionID)
221
+ else this.busy.add(sessionID)
222
+ for (const [jobID, job] of this.jobs) if (job.sessionID === sessionID && job.running) this.jobs.delete(jobID)
223
+ for (const job of snapshot.jobs) this.setJob(job.id, sessionID, job.status === "running")
224
+ }
225
+
226
+ /** Session status and shells/terminals (compacted so the session of an exit is resolved). */
227
+ private trackJobs(event: EventLike): void {
228
+ if (event.type !== "session.status" && !event.type.startsWith("shell.") && !event.type.startsWith("pty.") && !event.type.startsWith("session.shell.")) return
229
+ const compact = compactActivity({ type: event.type, data: record(event.data) }, (id) => this.jobSession(id))
230
+ if (!compact) return
231
+ const data = compact.data as Record<string, any>
232
+ const sessionID = data.sessionID as string
233
+ if (compact.type === "session.status") {
234
+ if (data.status.type === "idle") this.busy.delete(sessionID)
235
+ else this.busy.add(sessionID)
236
+ } else if (compact.type.endsWith(".deleted")) this.jobs.delete(data.id)
237
+ else if (typeof data.id === "string" && typeof data.status === "string") this.setJob(data.id, sessionID, data.status === "running")
238
+ }
239
+
240
+ /** Remembers a job, keeping at most JOBS_KNOWN (oldest dropped). */
241
+ private setJob(id: string, sessionID: string, running: boolean): void {
242
+ this.jobs.delete(id)
243
+ this.jobs.set(id, { sessionID, running })
244
+ if (this.jobs.size > JOBS_KNOWN) this.jobs.delete(this.jobs.keys().next().value!)
245
+ }
246
+
188
247
  private trackActive(event: EventLike): void {
248
+ this.trackJobs(event)
189
249
  const data = record(event.data)
190
250
  const sessionID = eventSessionID(event)
191
251
  if (event.type === "session.execution.started" && sessionID) this.running.add(sessionID)
192
- else if (RUN_END.has(event.type) && sessionID) this.running.delete(sessionID)
252
+ else if (RUN_END.has(event.type) && sessionID) {
253
+ this.running.delete(sessionID)
254
+ // OpenCode v2 sends no idle status: a run's end ends a busy status (seeded or reported) too.
255
+ this.busy.delete(sessionID)
256
+ }
193
257
  else if (event.type === "permission.asked" && sessionID && typeof data.id === "string") this.waiting.set(`per ${data.id}`, sessionID)
194
258
  else if (event.type === "permission.replied" && typeof data.requestID === "string") this.waiting.delete(`per ${data.requestID}`)
195
259
  else if (event.type === "form.created" && sessionID && typeof record(data.form).id === "string") this.waiting.set(`frm ${record(data.form).id}`, sessionID)
@@ -198,6 +262,17 @@ export class SessionTracker {
198
262
  }
199
263
  }
200
264
 
265
+ /** The session itself has a run in progress or OpenCode reports it busy or retrying. */
266
+ isBusy(sessionID: string): boolean {
267
+ return this.running.has(sessionID) || this.busy.has(sessionID)
268
+ }
269
+
270
+ /** A form (question) is open in the session itself. */
271
+ formOpenIn(sessionID: string): boolean {
272
+ for (const [key, id] of this.waiting) if (id === sessionID && key.startsWith("frm ")) return true
273
+ return false
274
+ }
275
+
201
276
  /** A form (question) this process saw asked and not yet replied or cancelled. */
202
277
  formPending(formID: string): boolean {
203
278
  return this.waiting.has(`frm ${formID}`)