@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,160 @@
1
+ // The tmux terminal backend of the preset spoke: start a harness in a pane, paste text into it, send Ctrl-C, read
2
+ // its screen, tell whether it still runs. Every call is `tmux <args>` with an argv (node's execFile, no shell).
3
+ //
4
+ // Where: by default a private tmux server (`-L <socket>`, e.g. hub-preset), so the user's own tmux server is never
5
+ // touched; attach with `tmux -L <socket> attach`. Only when explicitly asked (`current`), a new window in the tmux
6
+ // session this process runs in ($TMUX).
7
+ //
8
+ // tmux runs a command given as one argument through the shell (`sh -c`) and one given as several arguments directly
9
+ // (execvp). A preset's argv has no shell syntax, but a lone argument could still hold glob or other characters, so a
10
+ // one-element argv must be a plain program name. tmux also splits its own command line at arguments ending in ";"
11
+ // (see tmuxArg).
12
+ import { execFile } from "node:child_process"
13
+ import { randomBytes } from "node:crypto"
14
+
15
+ export interface TmuxResult {
16
+ readonly code: number
17
+ readonly stdout: string
18
+ readonly stderr: string
19
+ }
20
+ /** Runs `tmux <args>` (argv, no shell) and resolves with its exit code and output; never rejects for a non-zero exit. */
21
+ export type TmuxRunner = (args: string[]) => Promise<TmuxResult>
22
+
23
+ export const TMUX_TIMEOUT_MS = 5000
24
+
25
+ export function tmuxRunner(bin = "tmux", env: NodeJS.ProcessEnv = process.env, timeoutMs = TMUX_TIMEOUT_MS): TmuxRunner {
26
+ return (args) =>
27
+ new Promise((resolve) => {
28
+ execFile(bin, args, { env, timeout: timeoutMs, encoding: "utf8", maxBuffer: 4 * 1024 * 1024 }, (error, stdout, stderr) => {
29
+ // A missing binary or a timeout has no numeric exit code: report 1 with the error's message.
30
+ const exit = (error as { code?: unknown } | null)?.code
31
+ const code = !error ? 0 : typeof exit === "number" ? exit : 1
32
+ resolve({ code, stdout: stdout ?? "", stderr: stderr || (error ? error.message : "") })
33
+ })
34
+ })
35
+ }
36
+
37
+ /** A pane the backend started: its tmux session, window and pane IDs (`%N`, the target of every later call). */
38
+ export interface TerminalPane {
39
+ readonly session: string
40
+ readonly window: string
41
+ readonly target: string
42
+ }
43
+
44
+ export interface LaunchRequest {
45
+ /** The program and its arguments; never run through a shell (see above). */
46
+ readonly argv: readonly string[]
47
+ readonly cwd: string
48
+ /** The tmux session (private socket) or window (current session) name. */
49
+ readonly name: string
50
+ /** Extra environment for the harness. */
51
+ readonly env?: Readonly<Record<string, string>>
52
+ }
53
+
54
+ export interface TerminalBackend {
55
+ readonly kind: "tmux"
56
+ /** Whether it can send keys (Ctrl-C): the spoke announces interrupt and stop only then. */
57
+ readonly canSendKeys: boolean
58
+ launch(req: LaunchRequest): Promise<TerminalPane>
59
+ /** Pastes `text` into the pane; `submit` presses Enter after it. */
60
+ paste(pane: TerminalPane, text: string, options: { readonly submit: boolean }): Promise<void>
61
+ interrupt(pane: TerminalPane): Promise<void>
62
+ /** The pane's last `lines` lines (wrapped lines joined). */
63
+ capture(pane: TerminalPane, lines: number): Promise<string>
64
+ alive(pane: TerminalPane): Promise<boolean>
65
+ kill(pane: TerminalPane): Promise<void>
66
+ /** The command a user runs to look at the pane. */
67
+ attachHint(pane: TerminalPane): string
68
+ }
69
+
70
+ export interface TmuxBackendOptions {
71
+ /** The private tmux socket name (`tmux -L <socket>`). Required unless `current`. */
72
+ readonly socket?: string
73
+ /** The tmux config file of the private server (e.g. /dev/null in tests); default tmux's own lookup. */
74
+ readonly config?: string
75
+ /** Use the tmux session this process runs in ($TMUX) instead of a private server. Only when explicitly asked. */
76
+ readonly current?: boolean
77
+ readonly env?: Readonly<Record<string, string | undefined>>
78
+ readonly run: TmuxRunner
79
+ /** Size of a new private session. */
80
+ readonly width?: number
81
+ readonly height?: number
82
+ }
83
+
84
+ const SOCKET = /^[A-Za-z0-9_.-]{1,64}$/
85
+ const PLAIN_PROGRAM = /^[A-Za-z0-9_.\/+:@%,-]+$/
86
+ /**
87
+ * The new pane as "<session> <window> <pane>". Spaces, not tabs: tmux before 3.3 prints a tab in -F output as "_".
88
+ * The window (@n) and pane (%n) IDs are read from the end, so a session name with spaces stays whole.
89
+ */
90
+ const FORMAT = "#{session_name} #{window_id} #{pane_id}"
91
+ const NEW_PANE = /^(.+) (@\d+) (%\d+)$/
92
+
93
+ /**
94
+ * tmux reads an argument ending in ";" as a command separator, and one ending in "\;" as that argument ending in ";".
95
+ * Every argument goes through this, so an argv element (or pasted text) ending in ";" reaches tmux as itself.
96
+ */
97
+ export const tmuxArg = (arg: string): string => (arg.endsWith(";") ? `${arg.slice(0, -1)}\\;` : arg)
98
+
99
+ const failure = (what: string, r: TmuxResult) => new Error(`tmux ${what} failed (exit ${r.code}): ${(r.stderr || r.stdout).trim().slice(0, 300)}`)
100
+
101
+ export function tmuxBackend(options: TmuxBackendOptions): TerminalBackend {
102
+ const current = options.current === true
103
+ if (current && !options.env?.TMUX) throw new Error("Not inside tmux ($TMUX is not set): cannot use the current tmux session")
104
+ if (!current && (typeof options.socket !== "string" || !SOCKET.test(options.socket))) throw new Error(`The tmux socket name must be 1–64 letters, digits, ".", "_" or "-" (got ${JSON.stringify(options.socket)})`)
105
+ const base = current ? [] : ["-L", options.socket!]
106
+ const tmux = (args: string[]) => options.run([...base, ...args.map(tmuxArg)])
107
+ const must = async (what: string, args: string[]) => {
108
+ const r = await tmux(args)
109
+ if (r.code !== 0) throw failure(what, r)
110
+ return r
111
+ }
112
+
113
+ return {
114
+ kind: "tmux",
115
+ canSendKeys: true,
116
+
117
+ async launch(req) {
118
+ if (req.argv.length === 0) throw new Error("Nothing to launch: the argv is empty")
119
+ if (req.argv.length === 1 && !PLAIN_PROGRAM.test(req.argv[0]!)) {
120
+ throw new Error(`tmux runs a lone command through the shell: ${JSON.stringify(req.argv[0])} must be a plain program name (letters, digits, / . _ + : @ % , -) or come with arguments`)
121
+ }
122
+ const env = Object.entries(req.env ?? {}).flatMap(([k, v]) => ["-e", `${k}=${v}`])
123
+ const where = current
124
+ ? ["new-window", "-d", "-P", "-F", FORMAT, "-n", req.name, "-c", req.cwd]
125
+ : [...(options.config ? ["-f", options.config] : []), "new-session", "-d", "-P", "-F", FORMAT, "-s", req.name, "-c", req.cwd, "-x", String(options.width ?? 200), "-y", String(options.height ?? 50)]
126
+ const r = await must(current ? "new-window" : "new-session", [...where, ...env, "--", ...req.argv])
127
+ const [, session, window, target] = NEW_PANE.exec(r.stdout.trim()) ?? []
128
+ if (!session || !window || !target) throw new Error(`tmux did not report the new pane (got ${JSON.stringify(r.stdout.trim().slice(0, 100))})`)
129
+ return { session, window, target }
130
+ },
131
+
132
+ async paste(pane, text, { submit }) {
133
+ const buffer = `hub-preset-${randomBytes(6).toString("hex")}`
134
+ await must("set-buffer", ["set-buffer", "-b", buffer, "--", text])
135
+ await must("paste-buffer", ["paste-buffer", "-d", "-p", "-b", buffer, "-t", pane.target])
136
+ if (submit) await must("send-keys", ["send-keys", "-t", pane.target, "Enter"])
137
+ },
138
+
139
+ async interrupt(pane) {
140
+ await must("send-keys", ["send-keys", "-t", pane.target, "C-c"])
141
+ },
142
+
143
+ async capture(pane, lines) {
144
+ return (await must("capture-pane", ["capture-pane", "-p", "-J", "-t", pane.target, "-S", `-${Math.max(1, Math.floor(lines))}`])).stdout
145
+ },
146
+
147
+ async alive(pane) {
148
+ const r = await tmux(["display-message", "-p", "-t", pane.target, "#{pane_dead}"])
149
+ return r.code === 0 && r.stdout.trim() === "0"
150
+ },
151
+
152
+ async kill(pane) {
153
+ await must("kill-pane", ["kill-pane", "-t", pane.target])
154
+ },
155
+
156
+ attachHint(pane) {
157
+ return current ? `tmux select-window -t ${pane.window}` : `tmux -L ${options.socket} attach -t ${pane.session}`
158
+ },
159
+ }
160
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "@huynle/hub-protocol",
3
+ "version": "0.7.1",
4
+ "private": true,
5
+ "description": "Hub ↔ spoke wire protocol (internal; shipped inside @huynle/hub, not published on its own).",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "exports": {
9
+ ".": "./src/index.ts",
10
+ "./package.json": "./package.json"
11
+ }
12
+ }
@@ -0,0 +1,207 @@
1
+ // The harness event subset: the forwarded events (FORWARDED_EVENTS, index.ts) a spoke of a harness other than
2
+ // OpenCode may send, with their data shapes and a validator. Every type is one hubs already accept, so a frame a
3
+ // non-OpenCode spoke sends passes older hubs' parsers too; nothing here changes what the hub parses. A hub (or the
4
+ // conformance suite) checks a non-OpenCode spoke's events with harnessEventProblem.
5
+ //
6
+ // Rules for all of them:
7
+ // - `sessionID` (and parentID, form.sessionID) follow the hub's ID rule, `ses_` + letters and digits: a spoke maps
8
+ // its harness's native IDs (a Claude Code UUID, a Codex thread ID) to hub IDs before sending (e.g. ses_cc<32 hex>).
9
+ // - Other IDs (tool calls, permission requests, forms, messages, turns) are plain IDs (wire.ts ID).
10
+ // - Extra data fields are allowed (a newer spoke may add some; hubs ignore what they do not read); the fields
11
+ // listed here must be valid.
12
+ // - Texts are bounded: no full tool inputs or outputs, only short summaries (as compactActivity does for OpenCode).
13
+ //
14
+ // Turn interruption: "session.execution.interrupted" ends a turn the user or the hub interrupted. A spoke that sees
15
+ // the interruption (Codex's Interrupt hook, an rpc turn/completed with status "interrupted") sends it at once. A
16
+ // harness that reports none (Claude Code fires no Stop hook on Esc) leaves the spoke to infer it later, from the
17
+ // transcript or the next prompt: it sends the event with `inferred: true`, so the hub knows the run's end time is
18
+ // approximate.
19
+ //
20
+ // Imports only from wire.ts: index.ts re-exports this module.
21
+
22
+ import { ID, isObj, nonEmpty, num } from "./wire.ts"
23
+
24
+ /** Event types a non-OpenCode spoke may send (all in FORWARDED_EVENTS). */
25
+ export const HARNESS_EVENTS = [
26
+ "session.created",
27
+ "session.renamed",
28
+ "session.deleted",
29
+ "session.execution.started",
30
+ "session.execution.succeeded",
31
+ "session.execution.failed",
32
+ "session.execution.interrupted",
33
+ "session.status",
34
+ "session.text.ended",
35
+ "session.inbox.delivered",
36
+ "session.tool.called",
37
+ "session.tool.success",
38
+ "session.tool.failed",
39
+ "permission.asked",
40
+ "permission.replied",
41
+ "form.created",
42
+ "form.replied",
43
+ "form.cancelled",
44
+ ] as const
45
+ export type HarnessEventType = (typeof HARNESS_EVENTS)[number]
46
+
47
+ /** Longest session title. */
48
+ export const HARNESS_TITLE_MAX = 200
49
+ /** Longest run error message. */
50
+ export const HARNESS_ERROR_MAX = 1000
51
+ /** Longest tool summary or tool error (as ACTIVITY_SUMMARY_MAX). */
52
+ export const HARNESS_SUMMARY_MAX = 160
53
+ /** Longest retry reason (as RETRY_MESSAGE_MAX). */
54
+ export const HARNESS_RETRY_MAX = 200
55
+ /** Longest final reply text (session.text.ended). */
56
+ export const HARNESS_TEXT_MAX = 16_000
57
+ /** Longest permission action, and the most resources (and save patterns) and the length of each. */
58
+ export const HARNESS_ACTION_MAX = 200
59
+ export const HARNESS_RESOURCES_MAX = 100
60
+ export const HARNESS_RESOURCE_MAX = 4000
61
+ /** Most fields a form carries (the hub keeps the ones it can render). */
62
+ export const HARNESS_FORM_FIELDS_MAX = 50
63
+
64
+ /** A hub session ID: `ses_` + 1–200 letters and digits. */
65
+ export const isHubSessionID = (v: unknown): v is string => typeof v === "string" && /^ses_[A-Za-z0-9]{1,200}$/.test(v)
66
+
67
+ type Turn = { readonly sessionID: string; readonly turnID?: string }
68
+
69
+ /** Each type's data, as a non-OpenCode spoke sends it. */
70
+ export interface HarnessEventData {
71
+ "session.created": { readonly sessionID: string; readonly parentID?: string; readonly title?: string }
72
+ "session.renamed": { readonly sessionID: string; readonly title: string }
73
+ "session.deleted": { readonly sessionID: string }
74
+ /** A turn started (the session is busy). `turnID`: the harness's turn, when it has one. */
75
+ "session.execution.started": Turn
76
+ "session.execution.succeeded": Turn
77
+ "session.execution.failed": Turn & { readonly error?: { readonly message: string } }
78
+ /** `inferred`: the harness reported no interruption; the spoke found it later (see the header). */
79
+ "session.execution.interrupted": Turn & { readonly inferred?: true }
80
+ "session.status": { readonly sessionID: string; readonly status: { readonly type: "idle" | "busy" } | { readonly type: "retry"; readonly attempt?: number; readonly message?: string } }
81
+ /** The turn's reply (e.g. Claude Code's Stop last_assistant_message); `ordinal` counts replies in the message. */
82
+ "session.text.ended": { readonly sessionID: string; readonly assistantMessageID: string; readonly ordinal: number; readonly text: string }
83
+ /** A prompt entered the session: typed by the user, or a message the hub delivered. */
84
+ "session.inbox.delivered": { readonly sessionID: string; readonly inboxID: string; readonly origin?: "user" | "hub" }
85
+ "session.tool.called": { readonly sessionID: string; readonly id: string; readonly assistantMessageID?: string; readonly summary?: string; readonly background?: boolean }
86
+ "session.tool.success": { readonly sessionID: string; readonly id: string }
87
+ "session.tool.failed": { readonly sessionID: string; readonly id: string; readonly error?: string }
88
+ /**
89
+ * The harness asks to act: `action` in the hub's vocabulary ("shell", "edit", "read", "<server>_<tool>"), what it
90
+ * applies to, and the patterns an "always" reply would save.
91
+ */
92
+ "permission.asked": {
93
+ readonly sessionID: string
94
+ readonly id: string
95
+ readonly action: string
96
+ readonly resources: readonly string[]
97
+ readonly save?: readonly string[]
98
+ readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
99
+ }
100
+ /** Answered (here, locally, or by a hook): the request is no longer pending. */
101
+ "permission.replied": { readonly sessionID: string; readonly requestID: string; readonly reply?: "once" | "always" | "reject" }
102
+ /** A form (question); `fields` as OpenCode's FormField (the hub keeps what it can render). */
103
+ "form.created": { readonly form: { readonly id: string; readonly sessionID: string; readonly title?: string; readonly fields: readonly unknown[]; readonly metadata?: Record<string, unknown> } }
104
+ "form.replied": { readonly sessionID: string; readonly id: string }
105
+ "form.cancelled": { readonly sessionID: string; readonly id: string }
106
+ }
107
+
108
+ export type HarnessEvent = { [K in HarnessEventType]: { readonly type: K; readonly data: HarnessEventData[K] } }[HarnessEventType]
109
+
110
+ const plainID = (v: unknown): v is string => typeof v === "string" && ID.test(v)
111
+ const text = (v: unknown, max: number): v is string => typeof v === "string" && v.length <= max
112
+ const optional = (v: unknown, check: (v: unknown) => boolean) => v === undefined || check(v)
113
+ const strings = (v: unknown, max: number, length: number) => Array.isArray(v) && v.length <= max && v.every((s) => text(s, length))
114
+
115
+ type Check = (d: Record<string, unknown>) => string | undefined
116
+
117
+ const session: Check = (d) => (isHubSessionID(d.sessionID) ? undefined : "data.sessionID must be a hub session ID (ses_ + letters and digits)")
118
+ const turn: Check = (d) => session(d) ?? (optional(d.turnID, plainID) ? undefined : "data.turnID must be an ID")
119
+ const both = (...checks: Check[]): Check => (d) => {
120
+ for (const check of checks) {
121
+ const problem = check(d)
122
+ if (problem) return problem
123
+ }
124
+ return undefined
125
+ }
126
+ const field = (name: string, check: (v: unknown) => boolean, what: string): Check => (d) => (check(d[name]) ? undefined : `data.${name} must be ${what}`)
127
+ const id = (name: string) => field(name, plainID, "an ID")
128
+ const STATUS_TYPES = new Set(["idle", "busy"])
129
+
130
+ const CHECKS: Record<HarnessEventType, Check> = {
131
+ "session.created": both(
132
+ session,
133
+ field("parentID", (v) => optional(v, isHubSessionID), "a hub session ID"),
134
+ field("title", (v) => optional(v, (t) => text(t, HARNESS_TITLE_MAX)), `at most ${HARNESS_TITLE_MAX} characters`),
135
+ ),
136
+ "session.renamed": both(session, field("title", (v) => nonEmpty(v) && v.length <= HARNESS_TITLE_MAX, `1–${HARNESS_TITLE_MAX} characters`)),
137
+ "session.deleted": session,
138
+ "session.execution.started": turn,
139
+ "session.execution.succeeded": turn,
140
+ "session.execution.failed": both(
141
+ turn,
142
+ field("error", (v) => optional(v, (e) => isObj(e) && text(e.message, HARNESS_ERROR_MAX)), `{message} of at most ${HARNESS_ERROR_MAX} characters`),
143
+ ),
144
+ "session.execution.interrupted": both(turn, field("inferred", (v) => v === undefined || v === true, "true when sent")),
145
+ "session.status": both(
146
+ session,
147
+ field(
148
+ "status",
149
+ (s) =>
150
+ isObj(s) &&
151
+ (STATUS_TYPES.has(s.type as string) ||
152
+ (s.type === "retry" && optional(s.attempt, num) && optional(s.message, (m) => text(m, HARNESS_RETRY_MAX)))),
153
+ "{type: idle | busy} or {type: retry, attempt?, message?}",
154
+ ),
155
+ ),
156
+ "session.text.ended": both(
157
+ session,
158
+ id("assistantMessageID"),
159
+ field("ordinal", (v) => Number.isInteger(v) && (v as number) >= 0, "a whole number ≥ 0"),
160
+ field("text", (v) => text(v, HARNESS_TEXT_MAX), `text of at most ${HARNESS_TEXT_MAX} characters`),
161
+ ),
162
+ "session.inbox.delivered": both(session, id("inboxID"), field("origin", (v) => v === undefined || v === "user" || v === "hub", "user or hub")),
163
+ "session.tool.called": both(
164
+ session,
165
+ id("id"),
166
+ field("assistantMessageID", (v) => optional(v, plainID), "an ID"),
167
+ field("summary", (v) => optional(v, (s) => text(s, HARNESS_SUMMARY_MAX)), `at most ${HARNESS_SUMMARY_MAX} characters`),
168
+ field("background", (v) => optional(v, (b) => typeof b === "boolean"), "true or false"),
169
+ ),
170
+ "session.tool.success": both(session, id("id")),
171
+ "session.tool.failed": both(session, id("id"), field("error", (v) => optional(v, (e) => text(e, HARNESS_SUMMARY_MAX)), `at most ${HARNESS_SUMMARY_MAX} characters`)),
172
+ "permission.asked": both(
173
+ session,
174
+ id("id"),
175
+ field("action", (v) => nonEmpty(v) && v.length <= HARNESS_ACTION_MAX, `1–${HARNESS_ACTION_MAX} characters`),
176
+ field("resources", (v) => strings(v, HARNESS_RESOURCES_MAX, HARNESS_RESOURCE_MAX), `a list of at most ${HARNESS_RESOURCES_MAX} texts`),
177
+ field("save", (v) => optional(v, (s) => strings(s, HARNESS_RESOURCES_MAX, HARNESS_RESOURCE_MAX)), `a list of at most ${HARNESS_RESOURCES_MAX} texts`),
178
+ field("source", (v) => optional(v, (s) => isObj(s) && s.type === "tool" && plainID(s.messageID) && plainID(s.id)), "{type: tool, messageID, id}"),
179
+ ),
180
+ "permission.replied": both(session, id("requestID"), field("reply", (v) => v === undefined || v === "once" || v === "always" || v === "reject", "once, always or reject")),
181
+ "form.created": field(
182
+ "form",
183
+ (f) =>
184
+ isObj(f) &&
185
+ plainID(f.id) &&
186
+ isHubSessionID(f.sessionID) &&
187
+ optional(f.title, (t) => text(t, HARNESS_TITLE_MAX)) &&
188
+ Array.isArray(f.fields) &&
189
+ f.fields.length <= HARNESS_FORM_FIELDS_MAX &&
190
+ f.fields.every(isObj) &&
191
+ optional(f.metadata, isObj),
192
+ `{id, sessionID, title?, fields (at most ${HARNESS_FORM_FIELDS_MAX} objects), metadata?}`,
193
+ ),
194
+ "form.replied": both(session, id("id")),
195
+ "form.cancelled": both(session, id("id")),
196
+ }
197
+
198
+ /** Why `event` is not an event a non-OpenCode spoke may send, or undefined when it is. */
199
+ export function harnessEventProblem(event: unknown): string | undefined {
200
+ if (!isObj(event)) return "event must be an object"
201
+ if (typeof event.type !== "string" || !Object.hasOwn(CHECKS, event.type)) return `event type must be one of ${HARNESS_EVENTS.join(", ")}`
202
+ if (!isObj(event.data)) return "event data must be an object"
203
+ return CHECKS[event.type as HarnessEventType](event.data)
204
+ }
205
+
206
+ /** Whether `event` is an event a non-OpenCode spoke may send (see harnessEventProblem). */
207
+ export const isHarnessEvent = (event: unknown): event is HarnessEvent => harnessEventProblem(event) === undefined