@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,578 @@
1
+ // CodexSessions: the Codex spoke's view of the threads it is attached to (P7-T7a; spike v0hsdqhm). The spoke is one
2
+ // client of a Codex app-server among others (the TUI, the desktop app): it reports each thread it attached to as a
3
+ // hub session (ses_cx<thread uuid>), turns the app-server's notifications into the harness event subset, and serves
4
+ // the hub's ops through app-server methods.
5
+ //
6
+ // Events (protocol harness-events.ts):
7
+ // - thread/status/changed active|idle → session.status busy|idle; turn/started → session.execution.started;
8
+ // turn/completed → the reply (session.text.ended: the turn's last agent message), then succeeded, failed or
9
+ // interrupted, as Codex confirms it (the spoke announces turnEvents: the hub trusts its rpc evidence for idle).
10
+ // - item/started userMessage → session.inbox.delivered: origin "hub" for input the spoke sent for a hub message
11
+ // (matched by clientUserMessageId, which Codex echoes as the item's clientId), else "user" (typed in the TUI, or
12
+ // the hub's own turn/steer ops, which are the user's).
13
+ // - tool items (commands, file changes, MCP and dynamic tools, web searches) → session.tool.called/success/failed,
14
+ // compacted to an ID and a short summary.
15
+ // - thread/name/updated → session.renamed; thread/archived|deleted → session.deleted; closed → no longer reported.
16
+ //
17
+ // Permission: an approval request (item/commandExecution|fileChange/requestApproval) goes to every client of the
18
+ // thread and the first answer wins. The spoke asks the hub (permission.asked) and answers with the hub's decision;
19
+ // when another client answered first (serverRequest/resolved), the request is gone (permission.replied without a
20
+ // reply, and the hub's later answer gets {gone: true}). Other server requests (forms, MCP elicitations) are left to
21
+ // the other clients.
22
+ //
23
+ // Deliver (each messageID once: HarnessSpoke's DeliveryLedger, since Codex's queue does not deduplicate):
24
+ // wake false → thread/inject_items (into the context, no turn): "queued". Wake on a busy thread → turn/steer into
25
+ // the running turn ("steer"), or with delivery "queue" held until the turn ends and then started as the next turn
26
+ // ("queued"); on an idle thread → turn/start ("turn").
27
+ import { statSync } from "node:fs"
28
+ import path from "node:path"
29
+ import { randomBytes } from "node:crypto"
30
+ import {
31
+ HARNESS_ERROR_MAX,
32
+ HARNESS_SUMMARY_MAX,
33
+ HARNESS_TEXT_MAX,
34
+ HARNESS_TITLE_MAX,
35
+ type CreateRequest,
36
+ type DeliverRequest,
37
+ type HarnessEvent,
38
+ type PermissionDecision,
39
+ type PromptImage,
40
+ type ResultFor,
41
+ type ServerRequest,
42
+ type SessionInfo,
43
+ type TurnResult,
44
+ } from "../../protocol/src/index.ts"
45
+ import { deliverText, type Admission } from "../../spoke-sdk/src/index.ts"
46
+ import { codexSessionID, codexThreadID, plainID, requestKey } from "./ids.ts"
47
+ import { LOCAL_ERROR, RpcError, type RpcID } from "./jsonrpc.ts"
48
+
49
+ /** What CodexSessions needs of the hub connection (HarnessSpoke). */
50
+ export interface SpokePort {
51
+ upsertSession(info: SessionInfo): void
52
+ endSession(id: string): void
53
+ emit(event: HarnessEvent): boolean
54
+ }
55
+
56
+ /** One app-server connection (initialized), named so its request IDs stay apart from another's. */
57
+ export interface AppServerPort {
58
+ readonly name: string
59
+ request(method: string, params?: unknown): Promise<any>
60
+ respond(id: RpcID, result: unknown): void
61
+ }
62
+
63
+ /** A thread as the app-server describes it (Thread, from thread/start, thread/resume, thread/read). */
64
+ export interface ThreadDescription {
65
+ readonly id: string
66
+ readonly cwd?: string
67
+ readonly name?: string | null
68
+ readonly preview?: string | null
69
+ readonly status?: unknown
70
+ }
71
+
72
+ export interface SessionsOptions {
73
+ readonly spoke: SpokePort
74
+ /** The app-server the spoke starts its own threads on (create). */
75
+ readonly createServer: () => Promise<AppServerPort>
76
+ /** Extra thread/start parameters for created threads (e.g. approvalPolicy in tests). */
77
+ readonly threadDefaults?: Readonly<Record<string, unknown>>
78
+ /** How long the turn op waits for the turn to end (default 10 min). */
79
+ readonly turnTimeoutMs?: number
80
+ /** The project (workspace root) of a directory; default: the nearest directory with .git. */
81
+ readonly projectOf?: (cwd: string) => string | undefined
82
+ readonly now?: () => number
83
+ readonly log?: (line: string) => void
84
+ }
85
+
86
+ const DEFAULT_TURN_TIMEOUT_MS = 10 * 60_000
87
+ const DEFAULT_TITLE = "Codex session"
88
+ const TOOL_ITEMS = new Set(["commandExecution", "fileChange", "mcpToolCall", "dynamicToolCall", "webSearch"])
89
+ const APPROVALS: Record<string, "shell" | "edit"> = { "item/commandExecution/requestApproval": "shell", "item/fileChange/requestApproval": "edit" }
90
+ const RECENT_TURNS = 50
91
+
92
+ type Origin = "user" | "hub"
93
+ type TurnEnd = { readonly status: string; readonly error?: string }
94
+
95
+ interface TurnWaiter {
96
+ resolve(end: TurnEnd): void
97
+ }
98
+
99
+ interface Queued {
100
+ readonly inboxID: string
101
+ readonly text: string
102
+ }
103
+
104
+ interface Thread {
105
+ readonly id: string
106
+ readonly hubID: string
107
+ readonly conn: AppServerPort
108
+ readonly cwd: string
109
+ title: string
110
+ busy: boolean
111
+ turnID?: string
112
+ reply?: { readonly id: string; readonly text: string }
113
+ /** Tool items of the running turns (for approvals' resources and the tools' end). */
114
+ readonly items: Map<string, { readonly type: string; readonly paths: readonly string[] }>
115
+ /** clientUserMessageIds of input the spoke sent → whose it is. */
116
+ readonly inputs: Map<string, Origin>
117
+ /** Hub messages waiting for the running turn to end (delivery "queue"). */
118
+ readonly queue: Queued[]
119
+ /** Hub messages injected without waking (messageID → inboxID). */
120
+ readonly injected: Map<string, string>
121
+ readonly waiters: Map<string, TurnWaiter>
122
+ readonly ended: Map<string, TurnEnd>
123
+ }
124
+
125
+ interface Approval {
126
+ readonly conn: AppServerPort
127
+ readonly rpcID: RpcID
128
+ readonly threadID: string
129
+ readonly available?: readonly unknown[]
130
+ }
131
+
132
+ const isObj = (v: unknown): v is Record<string, any> => typeof v === "object" && v !== null && !Array.isArray(v)
133
+ const str = (v: unknown): string | undefined => (typeof v === "string" && v !== "" ? v : undefined)
134
+ const cut = (text: string, max: number) => (text.length > max ? `${text.slice(0, max - 1)}…` : text)
135
+ const inboxID = () => `inb_${randomBytes(9).toString("hex")}`
136
+ const textInput = (text: string) => ({ type: "text", text, text_elements: [] })
137
+
138
+ /** The nearest directory from `cwd` up that holds .git (a repository or worktree root). */
139
+ export function gitRoot(cwd: string): string | undefined {
140
+ for (let dir = path.resolve(cwd); ; dir = path.dirname(dir)) {
141
+ try {
142
+ statSync(path.join(dir, ".git"))
143
+ return dir
144
+ } catch {}
145
+ if (path.dirname(dir) === dir) return undefined
146
+ }
147
+ }
148
+
149
+ /** A command as the user typed it: Codex wraps it in `/bin/bash -lc '<command>'`. */
150
+ export function unwrapCommand(command: string): string {
151
+ const wrapped = /^(?:\S*\/)?(?:bash|zsh|sh)\s+-l?c\s+'([\s\S]*)'$/.exec(command.trim())
152
+ return wrapped ? wrapped[1]!.replace(/'\\''/g, "'") : command.trim()
153
+ }
154
+
155
+ /** A tool item's short summary (no raw input or output). */
156
+ export function toolSummary(item: Record<string, any>): string | undefined {
157
+ let summary: string | undefined
158
+ switch (item.type) {
159
+ case "commandExecution":
160
+ summary = typeof item.command === "string" ? unwrapCommand(item.command) : undefined
161
+ break
162
+ case "fileChange":
163
+ summary = changedPaths(item).join(", ") || undefined
164
+ break
165
+ case "mcpToolCall":
166
+ summary = [str(item.server), str(item.tool)].filter(Boolean).join("_") || undefined
167
+ break
168
+ case "dynamicToolCall":
169
+ summary = str(item.tool)
170
+ break
171
+ case "webSearch":
172
+ summary = str(item.query)
173
+ break
174
+ }
175
+ return summary === undefined ? undefined : cut(summary.replace(/\s+/g, " "), HARNESS_SUMMARY_MAX)
176
+ }
177
+
178
+ const changedPaths = (item: Record<string, any>): string[] =>
179
+ Array.isArray(item.changes) ? item.changes.flatMap((c: unknown) => (isObj(c) && typeof c.path === "string" ? [c.path] : [])) : []
180
+
181
+ /** The approval decision for the hub's decision, among those the request offers (all, when it lists none). */
182
+ export function approvalDecision(decision: PermissionDecision, available: readonly unknown[] | undefined): unknown {
183
+ const offers = (d: string) => !available || available.includes(d)
184
+ switch (decision) {
185
+ case "once":
186
+ return "accept"
187
+ case "always": {
188
+ if (offers("acceptForSession")) return "acceptForSession"
189
+ const amendment = available?.find((d) => isObj(d) && "acceptWithExecpolicyAmendment" in d)
190
+ return amendment ?? "accept"
191
+ }
192
+ case "reject":
193
+ return offers("decline") ? "decline" : "cancel"
194
+ }
195
+ }
196
+
197
+ export class CodexSessions {
198
+ readonly #spoke: SpokePort
199
+ readonly #createServer: SessionsOptions["createServer"]
200
+ readonly #threadDefaults: Readonly<Record<string, unknown>>
201
+ readonly #turnTimeoutMs: number
202
+ readonly #projectOf: (cwd: string) => string | undefined
203
+ readonly #now: () => number
204
+ readonly #log: (line: string) => void
205
+ /** By native thread ID. */
206
+ readonly #threads = new Map<string, Thread>()
207
+ /** By hub request ID (requestKey). */
208
+ readonly #approvals = new Map<string, Approval>()
209
+
210
+ constructor(options: SessionsOptions) {
211
+ this.#spoke = options.spoke
212
+ this.#createServer = options.createServer
213
+ this.#threadDefaults = options.threadDefaults ?? {}
214
+ this.#turnTimeoutMs = options.turnTimeoutMs ?? DEFAULT_TURN_TIMEOUT_MS
215
+ this.#projectOf = options.projectOf ?? gitRoot
216
+ this.#now = options.now ?? Date.now
217
+ this.#log = options.log ?? (() => {})
218
+ }
219
+
220
+ has(sessionID: string): boolean {
221
+ const id = codexThreadID(sessionID)
222
+ return id !== undefined && this.#threads.has(id)
223
+ }
224
+
225
+ /** Whether the spoke reports this thread (on any connection). */
226
+ hasThread(threadID: string): boolean {
227
+ return this.#threads.has(threadID.toLowerCase())
228
+ }
229
+
230
+ /** Reports a thread the spoke is subscribed to on `conn` (after thread/start or thread/resume). */
231
+ attach(conn: AppServerPort, thread: ThreadDescription, title?: string): string {
232
+ const id = thread.id.toLowerCase()
233
+ const known = this.#threads.get(id)
234
+ if (known) return known.hubID
235
+ const entry: Thread = {
236
+ id,
237
+ hubID: codexSessionID(id),
238
+ conn,
239
+ cwd: thread.cwd || "/",
240
+ title: cut((title ?? str(thread.name) ?? str(thread.preview)?.replace(/\s+/g, " ").trim()) || DEFAULT_TITLE, HARNESS_TITLE_MAX),
241
+ busy: false,
242
+ items: new Map(),
243
+ inputs: new Map(),
244
+ queue: [],
245
+ injected: new Map(),
246
+ waiters: new Map(),
247
+ ended: new Map(),
248
+ }
249
+ this.#threads.set(id, entry)
250
+ this.#report(entry)
251
+ this.#emit({ type: "session.created", data: { sessionID: entry.hubID, title: entry.title } })
252
+ if (isObj(thread.status) && thread.status.type === "active") this.#setBusy(entry, true)
253
+ return entry.hubID
254
+ }
255
+
256
+ /** The connection closed: its threads are no longer reported, its requests are gone. */
257
+ connectionClosed(conn: AppServerPort): void {
258
+ for (const thread of [...this.#threads.values()]) if (thread.conn === conn) this.#end(thread, false)
259
+ for (const [key, approval] of this.#approvals) if (approval.conn === conn) this.#approvals.delete(key)
260
+ }
261
+
262
+ notification(conn: AppServerPort, method: string, params: unknown): void {
263
+ if (!isObj(params) || typeof params.threadId !== "string") return
264
+ const thread = this.#threads.get(params.threadId.toLowerCase())
265
+ if (!thread || thread.conn !== conn) return
266
+ switch (method) {
267
+ case "thread/status/changed": {
268
+ const type = isObj(params.status) ? params.status.type : undefined
269
+ if (type === "active") this.#setBusy(thread, true)
270
+ else if (type === "idle" || type === "systemError") this.#setBusy(thread, false)
271
+ else if (type === "notLoaded") this.#end(thread, false)
272
+ return
273
+ }
274
+ case "turn/started": {
275
+ const turnID = isObj(params.turn) ? str(params.turn.id) : undefined
276
+ if (!turnID) return
277
+ thread.turnID = turnID
278
+ thread.reply = undefined
279
+ this.#emit({ type: "session.execution.started", data: { sessionID: thread.hubID, turnID: plainID(turnID) } })
280
+ return
281
+ }
282
+ case "turn/completed":
283
+ return this.#turnCompleted(thread, params.turn)
284
+ case "item/started":
285
+ return this.#itemStarted(thread, params)
286
+ case "item/completed":
287
+ return this.#itemCompleted(thread, params)
288
+ case "thread/name/updated": {
289
+ const name = str(params.threadName)?.trim()
290
+ if (!name) return
291
+ thread.title = cut(name, HARNESS_TITLE_MAX)
292
+ this.#report(thread)
293
+ this.#emit({ type: "session.renamed", data: { sessionID: thread.hubID, title: thread.title } })
294
+ return
295
+ }
296
+ case "thread/archived":
297
+ case "thread/deleted":
298
+ return this.#end(thread, true)
299
+ case "thread/closed":
300
+ return this.#end(thread, false)
301
+ case "serverRequest/resolved": {
302
+ const rpcID = params.requestId
303
+ if (typeof rpcID !== "number" && typeof rpcID !== "string") return
304
+ const key = requestKey(conn.name, rpcID)
305
+ if (!this.#approvals.delete(key)) return
306
+ this.#emit({ type: "permission.replied", data: { sessionID: thread.hubID, requestID: key } })
307
+ return
308
+ }
309
+ }
310
+ }
311
+
312
+ /** A request the app-server sent its clients; true when the spoke took it up (an approval for a known thread). */
313
+ serverRequest(conn: AppServerPort, rpcID: RpcID, method: string, params: unknown): boolean {
314
+ const action = APPROVALS[method]
315
+ if (!action || !isObj(params) || typeof params.threadId !== "string") return false
316
+ const thread = this.#threads.get(params.threadId.toLowerCase())
317
+ if (!thread || thread.conn !== conn) return false
318
+ const key = requestKey(conn.name, rpcID)
319
+ const itemID = str(params.itemId)
320
+ const item = itemID ? thread.items.get(itemID) : undefined
321
+ const resources =
322
+ action === "shell"
323
+ ? [typeof params.command === "string" ? params.command : ""].filter(Boolean)
324
+ : item?.paths.length
325
+ ? [...item.paths]
326
+ : [str(params.grantRoot) ?? thread.cwd]
327
+ this.#approvals.set(key, { conn, rpcID, threadID: thread.id, ...(Array.isArray(params.availableDecisions) ? { available: params.availableDecisions } : {}) })
328
+ const turnID = str(params.turnId) ?? thread.turnID
329
+ this.#emit({
330
+ type: "permission.asked",
331
+ data: {
332
+ sessionID: thread.hubID,
333
+ id: key,
334
+ action,
335
+ resources: resources.slice(0, 100).map((r) => cut(r, 4000)),
336
+ ...(itemID && turnID ? { source: { type: "tool" as const, messageID: plainID(turnID), id: plainID(itemID) } } : {}),
337
+ },
338
+ })
339
+ return true
340
+ }
341
+
342
+ // ---- ops ------------------------------------------------------------------------------------------------------
343
+
344
+ async permission(req: Extract<ServerRequest, { op: "permission" }>): Promise<ResultFor["permission"]> {
345
+ const approval = this.#approvals.get(req.requestID)
346
+ const thread = this.#thread(req.sessionID)
347
+ if (!approval || approval.threadID !== thread.id) return { gone: true }
348
+ this.#approvals.delete(req.requestID)
349
+ approval.conn.respond(approval.rpcID, { decision: approvalDecision(req.decision, approval.available) })
350
+ this.#emit({ type: "permission.replied", data: { sessionID: thread.hubID, requestID: req.requestID, reply: req.decision } })
351
+ return {}
352
+ }
353
+
354
+ async turn(req: Extract<ServerRequest, { op: "turn" }>): Promise<TurnResult> {
355
+ const thread = this.#thread(req.sessionID)
356
+ if (thread.turnID) throw new Error("The Codex session is busy (a turn is running); steer it instead")
357
+ const turnID = await this.#startTurn(thread, input(req.text, req.images), "user")
358
+ const end = await this.#waitTurn(thread, turnID)
359
+ const reply = thread.reply?.text ?? ""
360
+ if (!end) return { outcome: "timeout", reply }
361
+ if (end.status === "completed") return { outcome: "succeeded", reply }
362
+ return { outcome: end.status === "interrupted" ? "interrupted" : "failed", message: end.error ?? (end.status === "interrupted" ? "Interrupted" : "The turn failed"), reply }
363
+ }
364
+
365
+ async steer(req: Extract<ServerRequest, { op: "steer" }>): Promise<ResultFor["steer"]> {
366
+ const thread = this.#thread(req.sessionID)
367
+ const id = inboxID()
368
+ await this.#steerOrStart(thread, input(req.text, req.images), "user", id)
369
+ return { inboxID: id }
370
+ }
371
+
372
+ async interrupt(req: Extract<ServerRequest, { op: "interrupt" }>): Promise<ResultFor["interrupt"]> {
373
+ const thread = this.#thread(req.sessionID)
374
+ if (thread.turnID) await thread.conn.request("turn/interrupt", { threadId: thread.id, turnId: thread.turnID })
375
+ return {}
376
+ }
377
+
378
+ async stop(req: Extract<ServerRequest, { op: "stop" }>): Promise<ResultFor["stop"]> {
379
+ const thread = this.#thread(req.sessionID)
380
+ if (!thread.turnID) return { stopped: [] }
381
+ await thread.conn.request("turn/interrupt", { threadId: thread.id, turnId: thread.turnID })
382
+ return { stopped: [thread.hubID] }
383
+ }
384
+
385
+ async deliver(req: DeliverRequest): Promise<Admission> {
386
+ const thread = this.#thread(req.sessionID)
387
+ const injected = thread.injected.get(req.messageID)
388
+ const text = injected ? `[hub] Message ${req.messageID} was delivered to this session above; read it and act on it.` : deliverText(req)
389
+ if (!req.wake) {
390
+ const id = inboxID()
391
+ await thread.conn.request("thread/inject_items", { threadId: thread.id, items: [{ type: "message", role: "user", content: [{ type: "input_text", text }] }] })
392
+ thread.injected.set(req.messageID, id)
393
+ this.#emit({ type: "session.inbox.delivered", data: { sessionID: thread.hubID, inboxID: id, origin: "hub" } })
394
+ return { result: { delivered: "queued", inboxID: id }, resumed: false }
395
+ }
396
+ const id = inboxID()
397
+ if (thread.turnID && req.meta.delivery === "queue") {
398
+ thread.queue.push({ inboxID: id, text })
399
+ return { result: { delivered: "queued", inboxID: id }, resumed: true }
400
+ }
401
+ const how = await this.#steerOrStart(thread, [textInput(text)], "hub", id)
402
+ return { result: { delivered: how, inboxID: id }, resumed: true }
403
+ }
404
+
405
+ async create(req: CreateRequest): Promise<ResultFor["create"]> {
406
+ if (req.agent !== undefined) throw new Error(`Codex has no agents (asked for ${JSON.stringify(req.agent)})`)
407
+ if (!path.isAbsolute(req.directory)) throw new Error(`The directory must be an absolute path (got ${JSON.stringify(req.directory)})`)
408
+ let isDir = false
409
+ try {
410
+ isDir = statSync(req.directory).isDirectory()
411
+ } catch {}
412
+ if (!isDir) throw new Error(`${req.directory} does not exist or is not a directory`)
413
+ const conn = await this.#createServer()
414
+ const started = await conn.request("thread/start", { ...this.#threadDefaults, cwd: req.directory, ...(req.model ? { model: req.model.modelID } : {}) })
415
+ const thread = isObj(started) && isObj(started.thread) ? (started.thread as unknown as ThreadDescription) : undefined
416
+ if (!thread || typeof thread.id !== "string") throw new Error("Codex started no thread (thread/start answered without one)")
417
+ const title = req.title?.trim() ? cut(req.title.trim(), HARNESS_TITLE_MAX) : undefined
418
+ const sessionID = this.attach(conn, { ...thread, cwd: thread.cwd || req.directory }, title)
419
+ if (title) await conn.request("thread/name/set", { threadId: thread.id, name: title }).catch((cause) => this.#log(`thread/name/set: ${message(cause)}`))
420
+ return { sessionID }
421
+ }
422
+
423
+ // ---- internals ------------------------------------------------------------------------------------------------
424
+
425
+ #thread(sessionID: string): Thread {
426
+ const id = codexThreadID(sessionID)
427
+ const thread = id === undefined ? undefined : this.#threads.get(id)
428
+ if (!thread) throw new Error(`Unknown Codex session ${sessionID}`)
429
+ return thread
430
+ }
431
+
432
+ #emit(event: HarnessEvent): void {
433
+ this.#spoke.emit(event)
434
+ }
435
+
436
+ #report(thread: Thread): void {
437
+ const project = this.#projectOf(thread.cwd)
438
+ this.#spoke.upsertSession({
439
+ id: thread.hubID,
440
+ title: thread.title,
441
+ directory: thread.cwd,
442
+ updated: this.#now(),
443
+ owned: true,
444
+ ...(project ? { project, projectName: path.basename(project) } : {}),
445
+ })
446
+ }
447
+
448
+ #setBusy(thread: Thread, busy: boolean): void {
449
+ if (thread.busy === busy) return
450
+ thread.busy = busy
451
+ this.#emit({ type: "session.status", data: { sessionID: thread.hubID, status: { type: busy ? "busy" : "idle" } } })
452
+ }
453
+
454
+ #end(thread: Thread, deleted: boolean): void {
455
+ this.#threads.delete(thread.id)
456
+ for (const [key, approval] of this.#approvals) if (approval.threadID === thread.id) this.#approvals.delete(key)
457
+ for (const waiter of thread.waiters.values()) waiter.resolve({ status: "failed", error: "The Codex session ended" })
458
+ thread.waiters.clear()
459
+ if (deleted) this.#emit({ type: "session.deleted", data: { sessionID: thread.hubID } })
460
+ this.#spoke.endSession(thread.hubID)
461
+ }
462
+
463
+ async #startTurn(thread: Thread, items: unknown[], origin: Origin, id = inboxID()): Promise<string> {
464
+ thread.inputs.set(id, origin)
465
+ let started: unknown
466
+ try {
467
+ started = await thread.conn.request("turn/start", { threadId: thread.id, input: items, clientUserMessageId: id })
468
+ } catch (cause) {
469
+ thread.inputs.delete(id)
470
+ throw cause
471
+ }
472
+ const turnID = isObj(started) && isObj(started.turn) ? str(started.turn.id) : undefined
473
+ if (!turnID) throw new Error("Codex started no turn (turn/start answered without one)")
474
+ if (!thread.ended.has(turnID)) thread.turnID = turnID
475
+ return turnID
476
+ }
477
+
478
+ /** Into the running turn when there is one ("steer"), else a new turn ("turn"); a lost race starts a turn. */
479
+ async #steerOrStart(thread: Thread, items: unknown[], origin: Origin, id: string): Promise<"steer" | "turn"> {
480
+ if (thread.turnID) {
481
+ thread.inputs.set(id, origin)
482
+ try {
483
+ await thread.conn.request("turn/steer", { threadId: thread.id, expectedTurnId: thread.turnID, input: items, clientUserMessageId: id })
484
+ return "steer"
485
+ } catch (cause) {
486
+ thread.inputs.delete(id)
487
+ if (!(cause instanceof RpcError) || cause.code === LOCAL_ERROR) throw cause
488
+ this.#log(`turn/steer refused (${cause.message}); starting a turn`)
489
+ }
490
+ }
491
+ await this.#startTurn(thread, items, origin, id)
492
+ return "turn"
493
+ }
494
+
495
+ #waitTurn(thread: Thread, turnID: string): Promise<TurnEnd | undefined> {
496
+ const ended = thread.ended.get(turnID)
497
+ if (ended) return Promise.resolve(ended)
498
+ return new Promise((resolve) => {
499
+ const timer = setTimeout(() => {
500
+ thread.waiters.delete(turnID)
501
+ resolve(undefined)
502
+ }, this.#turnTimeoutMs)
503
+ thread.waiters.set(turnID, {
504
+ resolve: (end) => {
505
+ clearTimeout(timer)
506
+ resolve(end)
507
+ },
508
+ })
509
+ })
510
+ }
511
+
512
+ #turnCompleted(thread: Thread, turn: unknown): void {
513
+ if (!isObj(turn)) return
514
+ const turnID = str(turn.id)
515
+ if (!turnID) return
516
+ const status = typeof turn.status === "string" ? turn.status : "completed"
517
+ const error = isObj(turn.error) && typeof turn.error.message === "string" ? cut(turn.error.message, HARNESS_ERROR_MAX) : undefined
518
+ const end: TurnEnd = { status, ...(error !== undefined ? { error } : {}) }
519
+ if (thread.turnID === turnID) thread.turnID = undefined
520
+ thread.ended.set(turnID, end)
521
+ while (thread.ended.size > RECENT_TURNS) thread.ended.delete(thread.ended.keys().next().value!)
522
+ const sessionID = thread.hubID
523
+ if (thread.reply) this.#emit({ type: "session.text.ended", data: { sessionID, assistantMessageID: plainID(thread.reply.id), ordinal: 0, text: cut(thread.reply.text, HARNESS_TEXT_MAX) } })
524
+ const data = { sessionID, turnID: plainID(turnID) }
525
+ if (status === "interrupted") this.#emit({ type: "session.execution.interrupted", data })
526
+ else if (status === "failed") this.#emit({ type: "session.execution.failed", data: { ...data, ...(error !== undefined ? { error: { message: error } } : {}) } })
527
+ else this.#emit({ type: "session.execution.succeeded", data })
528
+ thread.items.clear()
529
+ const waiter = thread.waiters.get(turnID)
530
+ thread.waiters.delete(turnID)
531
+ waiter?.resolve(end)
532
+ const next = thread.queue.shift()
533
+ if (next) this.#startTurn(thread, [textInput(next.text)], "hub", next.inboxID).catch((cause) => this.#log(`queued hub message ${next.inboxID}: ${message(cause)}`))
534
+ }
535
+
536
+ #itemStarted(thread: Thread, params: Record<string, any>): void {
537
+ const item = params.item
538
+ if (!isObj(item) || typeof item.id !== "string") return
539
+ if (item.type === "userMessage") {
540
+ const clientID = str(item.clientId)
541
+ const origin = clientID ? thread.inputs.get(clientID) : undefined
542
+ if (clientID) thread.inputs.delete(clientID)
543
+ this.#emit({ type: "session.inbox.delivered", data: { sessionID: thread.hubID, inboxID: plainID(clientID && origin ? clientID : item.id), origin: origin ?? "user" } })
544
+ return
545
+ }
546
+ if (!TOOL_ITEMS.has(item.type)) return
547
+ thread.items.set(item.id, { type: item.type, paths: changedPaths(item) })
548
+ const summary = toolSummary(item)
549
+ const turnID = str(params.turnId)
550
+ this.#emit({
551
+ type: "session.tool.called",
552
+ data: { sessionID: thread.hubID, id: plainID(item.id), ...(turnID ? { assistantMessageID: plainID(turnID) } : {}), ...(summary !== undefined ? { summary } : {}) },
553
+ })
554
+ }
555
+
556
+ #itemCompleted(thread: Thread, params: Record<string, any>): void {
557
+ const item = params.item
558
+ if (!isObj(item) || typeof item.id !== "string") return
559
+ if (item.type === "agentMessage") {
560
+ if (typeof item.text === "string" && item.text !== "") thread.reply = { id: item.id, text: item.text }
561
+ return
562
+ }
563
+ if (!TOOL_ITEMS.has(item.type)) return
564
+ thread.items.delete(item.id)
565
+ const id = plainID(item.id)
566
+ if (item.status === "failed" || item.status === "declined") {
567
+ const error = item.status === "declined" ? "declined" : typeof item.exitCode === "number" ? `exit code ${item.exitCode}` : "failed"
568
+ this.#emit({ type: "session.tool.failed", data: { sessionID: thread.hubID, id, error } })
569
+ } else this.#emit({ type: "session.tool.success", data: { sessionID: thread.hubID, id } })
570
+ }
571
+ }
572
+
573
+ const message = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause))
574
+
575
+ /** The app-server input for a prompt: its text (when any) and its images as data URLs. */
576
+ function input(text: string, images: readonly PromptImage[] | undefined): unknown[] {
577
+ return [...(text ? [textInput(text)] : []), ...(images ?? []).map((image) => ({ type: "image", url: `data:${image.mime};base64,${image.data}` }))]
578
+ }