@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,195 @@
1
+ // `hub preset <list | check | run>`: the preset spoke's command line.
2
+ // list the official presets
3
+ // check <name|file> validate a preset (presetProblem); exit 1 when it is not valid
4
+ // run <name|file> [options] [-- harness args…]
5
+ // starts the preset's command (an argv, never through a shell) in a new tmux pane and connects it to the hub
6
+ // as a preset spoke (evidence screen) until the pane ends or Ctrl-C/SIGTERM (which leaves the harness running).
7
+ // --socket <name> the private tmux server (`tmux -L <name>`, default hub-preset): the user's own tmux server
8
+ // is never touched, unless
9
+ // --current a new window in the tmux session this runs in ($TMUX), only when asked
10
+ // --cwd <dir> the harness's directory (default the current one)
11
+ // --resume <id> resume a native session (the preset's resumeFlag)
12
+ // --no-hub only start the pane and report it (no hub connection; no enrolment needed)
13
+ // The native session ID is reported as the spoke knows it: passed on the command line or resumed ("reported, not
14
+ // observed"), or read off the screen once it shows.
15
+ import path from "node:path"
16
+ import { readSpokeCredentials } from "../../node/src/config.ts"
17
+ import { configPath } from "../../../src/config.ts"
18
+ import pkg from "../package.json" with { type: "json" }
19
+ import { findPreset, OFFICIAL_PRESETS } from "./presets.ts"
20
+ import { PresetSpoke } from "./spoke.ts"
21
+ import { tmuxBackend, tmuxRunner, type TerminalBackend, type TmuxRunner } from "./tmux.ts"
22
+
23
+ export interface PresetIo {
24
+ readonly env: Readonly<Record<string, string | undefined>>
25
+ readonly home: string
26
+ /** The working directory (default process.cwd()). */
27
+ readonly cwd?: string
28
+ stdout(s: string): void
29
+ stderr(s: string): void
30
+ /** Ends `run` (main.ts aborts it on SIGINT/SIGTERM). */
31
+ readonly signal?: AbortSignal
32
+ /** Runs tmux (default the `tmux` on PATH). */
33
+ readonly tmux?: TmuxRunner
34
+ readonly newID?: () => string
35
+ /** How often the pane is sampled (default the spoke's). */
36
+ readonly sampleMs?: number
37
+ }
38
+
39
+ export const PRESET_USAGE = `Usage: hub preset list
40
+ hub preset check <name|file>
41
+ hub preset run <name|file> [--socket <name> | --current] [--cwd <dir>] [--resume <id>] [--no-hub] [-- harness args…]`
42
+
43
+ export const DEFAULT_SOCKET = "hub-preset"
44
+
45
+ interface RunArgs {
46
+ readonly ref: string
47
+ readonly socket?: string
48
+ readonly current: boolean
49
+ readonly cwd?: string
50
+ readonly resume?: string
51
+ readonly noHub: boolean
52
+ readonly args: string[]
53
+ }
54
+
55
+ function parseRun(argv: readonly string[]): RunArgs | { error: string } {
56
+ const dash = argv.indexOf("--")
57
+ const own = dash >= 0 ? argv.slice(0, dash) : [...argv]
58
+ const args = dash >= 0 ? argv.slice(dash + 1) : []
59
+ let ref: string | undefined
60
+ const values: Record<string, string> = {}
61
+ const flags = new Set<string>()
62
+ for (let i = 0; i < own.length; i++) {
63
+ const arg = own[i]!
64
+ if (arg === "--socket" || arg === "--cwd" || arg === "--resume") {
65
+ const value = own[++i]
66
+ if (value === undefined || value.startsWith("--")) return { error: `${arg} needs a value` }
67
+ values[arg] = value
68
+ } else if (arg === "--current" || arg === "--no-hub") flags.add(arg)
69
+ else if (arg.startsWith("-")) return { error: `unknown option ${arg} (harness arguments go after --)` }
70
+ else if (ref === undefined) ref = arg
71
+ else return { error: `unexpected ${JSON.stringify(arg)} (harness arguments go after --)` }
72
+ }
73
+ if (ref === undefined) return { error: "run needs a preset name or file" }
74
+ if (flags.has("--current") && values["--socket"] !== undefined) return { error: "--current and --socket exclude each other" }
75
+ return {
76
+ ref,
77
+ current: flags.has("--current"),
78
+ noHub: flags.has("--no-hub"),
79
+ args,
80
+ ...(values["--socket"] !== undefined ? { socket: values["--socket"] } : {}),
81
+ ...(values["--cwd"] !== undefined ? { cwd: values["--cwd"] } : {}),
82
+ ...(values["--resume"] !== undefined ? { resume: values["--resume"] } : {}),
83
+ }
84
+ }
85
+
86
+ const message = (e: unknown) => (e instanceof Error ? e.message : String(e))
87
+
88
+ async function run(argv: readonly string[], io: PresetIo): Promise<number> {
89
+ const parsed = parseRun(argv)
90
+ if ("error" in parsed) {
91
+ io.stderr(`hub preset: ${parsed.error}\n${PRESET_USAGE}\n`)
92
+ return 2
93
+ }
94
+ const cwd = io.cwd ?? process.cwd()
95
+ const found = findPreset(parsed.ref, { cwd })
96
+ if ("error" in found) {
97
+ io.stderr(`hub preset: ${found.error}\n`)
98
+ return 1
99
+ }
100
+ const preset = found.preset
101
+ if (parsed.resume !== undefined && !preset.resumeFlag) {
102
+ io.stderr(`hub preset: the ${preset.name} preset cannot resume a session (it has no resumeFlag)\n`)
103
+ return 1
104
+ }
105
+ let backend: TerminalBackend
106
+ try {
107
+ backend = tmuxBackend({
108
+ ...(parsed.current ? { current: true } : { socket: parsed.socket ?? DEFAULT_SOCKET }),
109
+ env: io.env,
110
+ run: io.tmux ?? tmuxRunner("tmux", io.env as NodeJS.ProcessEnv),
111
+ })
112
+ } catch (cause) {
113
+ io.stderr(`hub preset: ${message(cause)}\n`)
114
+ return 1
115
+ }
116
+ const credentials = parsed.noHub ? undefined : readSpokeCredentials(io.env, io.home)
117
+ if (!parsed.noHub && !credentials) {
118
+ io.stderr(`hub preset: this machine is not enrolled with a hub (no machine token in ${configPath(io.env, io.home)}); use --no-hub to only start the pane\n`)
119
+ return 1
120
+ }
121
+ const log = (line: string) => io.stderr(`[hub preset] ${line}\n`)
122
+ const spoke = new PresetSpoke({
123
+ preset,
124
+ backend,
125
+ server: credentials?.server ?? "http://127.0.0.1:1",
126
+ token: credentials?.token ?? "",
127
+ pluginVersion: pkg.version,
128
+ directory: cwd,
129
+ ...(io.newID ? { newID: io.newID } : {}),
130
+ ...(io.sampleMs !== undefined ? { sampleMs: io.sampleMs } : {}),
131
+ log,
132
+ onState: (state, error) => log(`hub connection ${state}${error ? `: ${error}` : ""}`),
133
+ })
134
+ if (credentials) spoke.start()
135
+ let launched
136
+ try {
137
+ launched = await spoke.launch({ cwd: parsed.cwd ? path.resolve(cwd, parsed.cwd) : cwd, args: parsed.args, ...(parsed.resume !== undefined ? { resume: parsed.resume } : {}) })
138
+ } catch (cause) {
139
+ spoke.stop()
140
+ io.stderr(`hub preset: ${message(cause)}\n`)
141
+ return 1
142
+ }
143
+ const where = parsed.current ? "the current tmux session" : `tmux session ${launched.pane.session} on socket ${parsed.socket ?? DEFAULT_SOCKET}`
144
+ const native = launched.nativeID
145
+ ? `${launched.nativeID.id} (${launched.nativeID.how})`
146
+ : preset.nativeID.source === "screen"
147
+ ? "not seen yet (read off the screen when it shows)"
148
+ : `none (the ${preset.name} preset has no session IDs)`
149
+ const lines = [
150
+ `started ${preset.name} in ${where}${parsed.current ? ` (window ${launched.pane.window})` : ""}, pane ${launched.pane.target}`,
151
+ credentials ? `session: ${launched.sessionID} (evidence screen: hub messages are pasted, never confirmed)` : "not connected to a hub (--no-hub): the pane runs on its own",
152
+ `native ID: ${native}`,
153
+ `attach: ${launched.attach}`,
154
+ ]
155
+ io.stdout(`${lines.join("\n")}\n`)
156
+ if (!credentials) return 0
157
+
158
+ const outcome = await new Promise<"ended" | "signal">((resolve) => {
159
+ spoke.onEnd((id) => id === launched.sessionID && resolve("ended"))
160
+ if (io.signal?.aborted) return resolve("signal")
161
+ io.signal?.addEventListener("abort", () => resolve("signal"), { once: true })
162
+ })
163
+ spoke.stop()
164
+ io.stdout(outcome === "ended" ? `${preset.name} ended (its pane is gone)\n` : `disconnected from the hub; ${preset.name} is still running in its pane (attach: ${launched.attach})\n`)
165
+ return 0
166
+ }
167
+
168
+ export async function presetMain(argv: readonly string[], io: PresetIo): Promise<number> {
169
+ const [command, ...rest] = argv
170
+ switch (command) {
171
+ case "list": {
172
+ const width = Math.max(...OFFICIAL_PRESETS.map((p) => p.name.length))
173
+ io.stdout(`${OFFICIAL_PRESETS.map((p) => `${p.name.padEnd(width)} ${p.description ?? ""}`.trimEnd()).join("\n")}\n`)
174
+ return 0
175
+ }
176
+ case "check": {
177
+ if (rest.length !== 1) {
178
+ io.stderr(`hub preset: check needs one preset name or file\n${PRESET_USAGE}\n`)
179
+ return 2
180
+ }
181
+ const found = findPreset(rest[0]!, { cwd: io.cwd ?? process.cwd() })
182
+ if ("error" in found) {
183
+ io.stderr(`hub preset: ${found.error}\n`)
184
+ return 1
185
+ }
186
+ io.stdout(`${found.preset.name}: a valid preset (${found.source === "official" ? "official" : found.source})\n`)
187
+ return 0
188
+ }
189
+ case "run":
190
+ return run(rest, io)
191
+ default:
192
+ io.stderr(`${PRESET_USAGE}\n`)
193
+ return 2
194
+ }
195
+ }
@@ -0,0 +1,87 @@
1
+ // Presets (design §1.2 preset tier): the official ones shipped in presets/ (data only, imported statically so the CLI
2
+ // bundle carries them), finding one by name or file, the argv a launch runs and the native ID read off the screen.
3
+ // A preset is validated with the protocol's presetProblem before anything uses it; nothing here runs a shell.
4
+ import { readFileSync } from "node:fs"
5
+ import path from "node:path"
6
+ import { presetProblem, type HarnessPreset } from "../../protocol/src/index.ts"
7
+ import aider from "../presets/aider.json" with { type: "json" }
8
+ import goose from "../presets/goose.json" with { type: "json" }
9
+
10
+ /** Longest native ID taken from a screen (UTF-8 bytes; spoke-sdk MAX_NATIVE_BYTES, so it maps to a hub ID). */
11
+ export const SCREEN_ID_MAX_BYTES = 98
12
+
13
+ function official(raw: unknown): HarnessPreset {
14
+ const problem = presetProblem(raw)
15
+ if (problem) throw new Error(`Official preset ${JSON.stringify((raw as { name?: unknown })?.name)} is invalid: ${problem}`)
16
+ return raw as HarnessPreset
17
+ }
18
+
19
+ export const OFFICIAL_PRESETS: readonly HarnessPreset[] = [official(aider), official(goose)]
20
+
21
+ export type FoundPreset = { readonly preset: HarnessPreset; readonly source: "official" | string } | { readonly error: string }
22
+
23
+ /** Whether `ref` names a file rather than an official preset. */
24
+ const isPath = (ref: string) => ref.includes("/") || ref.includes(path.sep) || ref.endsWith(".json")
25
+
26
+ /** An official preset by name, or a preset file (a ref with a slash or ending in .json, relative to cwd), validated. */
27
+ export function findPreset(ref: string, options: { readonly cwd: string }): FoundPreset {
28
+ if (!isPath(ref)) {
29
+ const preset = OFFICIAL_PRESETS.find((p) => p.name === ref)
30
+ if (preset) return { preset, source: "official" }
31
+ return { error: `no official preset ${JSON.stringify(ref)} (official: ${OFFICIAL_PRESETS.map((p) => p.name).join(", ")}; give a file path for your own)` }
32
+ }
33
+ const file = path.resolve(options.cwd, ref)
34
+ let text: string
35
+ try {
36
+ text = readFileSync(file, "utf8")
37
+ } catch (cause) {
38
+ return { error: `cannot read ${file}: ${(cause as NodeJS.ErrnoException).code ?? String(cause)}` }
39
+ }
40
+ let raw: unknown
41
+ try {
42
+ raw = JSON.parse(text)
43
+ } catch {
44
+ return { error: `${file} is not JSON` }
45
+ }
46
+ const problem = presetProblem(raw)
47
+ return problem ? { error: `${file} is not a valid preset: ${problem}` } : { preset: raw as HarnessPreset, source: file }
48
+ }
49
+
50
+ export interface LaunchArgvOptions {
51
+ /** Extra arguments for the harness (after the preset's own). */
52
+ readonly args?: readonly string[]
53
+ /** Resume this native session ID (needs the preset's resumeFlag). */
54
+ readonly resume?: string
55
+ /** A fresh native ID, for presets whose ID the hub passes on the command line (source argv). */
56
+ readonly newID: () => string
57
+ }
58
+
59
+ /** Control characters (a NUL would cut an argv element short; line breaks have no place in one). */
60
+ const CONTROL = /[\u0000-\u001f\u007f]/
61
+
62
+ /**
63
+ * The argv a launch runs (never through a shell) and the native session ID, when the hub knows it at launch: the one
64
+ * it passes (source argv) or resumes. A screen ID is only known once it shows (nativeIDFromScreen).
65
+ */
66
+ export function launchArgv(preset: HarnessPreset, options: LaunchArgvOptions): { argv: string[]; nativeID: string | undefined } {
67
+ const args = [...(options.args ?? [])]
68
+ for (const arg of args) if (CONTROL.test(arg)) throw new Error(`Argument ${JSON.stringify(arg)} contains control characters`)
69
+ if (options.resume !== undefined) {
70
+ if (!preset.resumeFlag) throw new Error(`The ${preset.name} preset cannot resume a session (it has no resumeFlag)`)
71
+ if (options.resume === "" || CONTROL.test(options.resume)) throw new Error("The session ID to resume must be printable text")
72
+ return { argv: [...preset.command, preset.resumeFlag, options.resume, ...args], nativeID: options.resume }
73
+ }
74
+ if (preset.nativeID.source === "argv") {
75
+ const id = options.newID()
76
+ return { argv: [...preset.command, preset.nativeID.flag, id, ...args], nativeID: id }
77
+ }
78
+ return { argv: [...preset.command, ...args], nativeID: undefined }
79
+ }
80
+
81
+ /** The native session ID on `screen` (the first capture group of the preset's pattern), for source screen only. */
82
+ export function nativeIDFromScreen(preset: HarnessPreset, screen: string): string | undefined {
83
+ if (preset.nativeID.source !== "screen") return undefined
84
+ const id = new RegExp(preset.nativeID.pattern).exec(screen)?.[1]
85
+ if (!id || Buffer.byteLength(id) > SCREEN_ID_MAX_BYTES || CONTROL.test(id)) return undefined
86
+ return id
87
+ }
@@ -0,0 +1,251 @@
1
+ // The preset spoke (design §1.2 preset tier, decision fx04e42c point 6): a harness without a spoke of its own,
2
+ // described by a data-only preset and driven through a terminal pane (TerminalBackend: tmux). Built on HarnessSpoke.
3
+ //
4
+ // It announces only what a pasted terminal can do, and claims no more than it sees (evidence "screen"):
5
+ // - turn: the text is pasted into the pane and submitted; the hub is answered at once with outcome "timeout" and no
6
+ // reply (the spoke cannot see the harness take the prompt nor finish it: unconfirmed, never "succeeded");
7
+ // - deliver: a hub message (wake) is pasted and submitted once per messageID and answered "queued"; without wake it
8
+ // is "retained" (nothing is typed into the user's input line) and pasted ahead of the session's next turn or
9
+ // message; a prompt (hubPrompt) goes in its provenance envelope;
10
+ // - interrupt and stop: Ctrl-C, only when the backend can send keys;
11
+ // - never permission, form or steer (a terminal paste can answer neither a dialog nor a running turn reliably);
12
+ // - never an event: no activity, and above all no idle, is inferred from the screen (the hub never treats screen
13
+ // evidence as idle anyway). The screen is only sampled for the harness's native session ID (source screen) and the
14
+ // pane's liveness: the session ends with its pane.
15
+ //
16
+ // Session IDs: each launch gets a fresh UUID, its hub ID is hubSessionID("pr", uuid). For a preset whose native ID is
17
+ // passed on the command line (source argv) that UUID is the native ID: reported, not observed (the spoke never sees
18
+ // the harness use it).
19
+ import { randomUUID } from "node:crypto"
20
+ import path from "node:path"
21
+ import type { HarnessPreset, SessionInfo } from "../../protocol/src/index.ts"
22
+ import { deliverText, HarnessSpoke, hubSessionID, type Admission, type DeliverRequest, type HarnessSpokeOptions, type OpHandlers } from "../../spoke-sdk/src/index.ts"
23
+ import { launchArgv, nativeIDFromScreen } from "./presets.ts"
24
+ import type { TerminalBackend, TerminalPane } from "./tmux.ts"
25
+
26
+ /** The hub session ID namespace of preset sessions (ses_pr…). */
27
+ export const PRESET_SESSION_NAMESPACE = "pr"
28
+ export const SAMPLE_MS = 2000
29
+ /** Lines of screen searched for a native ID. */
30
+ export const CAPTURE_LINES = 200
31
+
32
+ export interface NativeID {
33
+ readonly id: string
34
+ /** How the spoke knows it: "read off the screen", or "reported, not observed (…)" when the hub passed it. */
35
+ readonly how: string
36
+ }
37
+
38
+ export interface PresetSpokeOptions {
39
+ readonly preset: HarnessPreset
40
+ readonly backend: TerminalBackend
41
+ readonly server: string
42
+ /** The machine token (never logged). */
43
+ readonly token: string
44
+ readonly pluginVersion: string
45
+ /** The spoke's directory (its hello). */
46
+ readonly directory: string
47
+ /** The harness's version, when known (default ""). */
48
+ readonly harnessVersion?: string
49
+ readonly newID?: () => string
50
+ readonly now?: () => number
51
+ readonly sampleMs?: number
52
+ readonly log?: (line: string) => void
53
+ readonly socket?: HarnessSpokeOptions["socket"]
54
+ readonly backoff?: HarnessSpokeOptions["backoff"]
55
+ readonly onState?: HarnessSpokeOptions["onState"]
56
+ }
57
+
58
+ export interface LaunchOptions {
59
+ readonly cwd: string
60
+ readonly args?: readonly string[]
61
+ /** Resume this native session ID (the preset's resumeFlag). */
62
+ readonly resume?: string
63
+ readonly env?: Readonly<Record<string, string>>
64
+ }
65
+
66
+ export interface Launched {
67
+ readonly sessionID: string
68
+ readonly pane: TerminalPane
69
+ readonly nativeID?: NativeID
70
+ /** How to look at the pane. */
71
+ readonly attach: string
72
+ }
73
+
74
+ interface Session {
75
+ readonly id: string
76
+ readonly pane: TerminalPane
77
+ nativeID?: NativeID
78
+ /** Hub messages admitted without waking, pasted ahead of the next submit. */
79
+ readonly retained: Array<{ readonly messageID: string; readonly text: string }>
80
+ /** Pastes into one pane run one after another. */
81
+ chain: Promise<unknown>
82
+ }
83
+
84
+ const unsafeName = /[^A-Za-z0-9_-]/g
85
+
86
+ export class PresetSpoke {
87
+ readonly spoke: HarnessSpoke
88
+ readonly preset: HarnessPreset
89
+ readonly evidence = "screen" as const
90
+
91
+ readonly #backend: TerminalBackend
92
+ readonly #sessions = new Map<string, Session>()
93
+ readonly #newID: () => string
94
+ readonly #now: () => number
95
+ readonly #sampleMs: number
96
+ readonly #log: (line: string) => void
97
+ readonly #ended = new Set<(sessionID: string) => void>()
98
+ #timer: ReturnType<typeof setInterval> | undefined
99
+ #sampling = false
100
+
101
+ constructor(options: PresetSpokeOptions) {
102
+ this.preset = options.preset
103
+ this.#backend = options.backend
104
+ this.#newID = options.newID ?? randomUUID
105
+ this.#now = options.now ?? Date.now
106
+ this.#sampleMs = options.sampleMs ?? SAMPLE_MS
107
+ this.#log = options.log ?? (() => {})
108
+ const handlers: OpHandlers = {
109
+ turn: async (req) => {
110
+ if (req.images?.length) throw new Error(`The ${this.preset.name} preset spoke pastes text into a terminal: it cannot send images`)
111
+ await this.#submit(this.#session(req.sessionID), req.text)
112
+ return { outcome: "timeout", reply: "" }
113
+ },
114
+ ...(this.#backend.canSendKeys
115
+ ? {
116
+ interrupt: async (req: { sessionID: string }) => (await this.#interrupt(req.sessionID), {}),
117
+ stop: async (req: { sessionID: string }) => (await this.#interrupt(req.sessionID), { stopped: [req.sessionID] }),
118
+ }
119
+ : {}),
120
+ }
121
+ this.spoke = new HarnessSpoke({
122
+ server: options.server,
123
+ token: options.token,
124
+ harness: options.preset.name,
125
+ harnessVersion: options.harnessVersion ?? "",
126
+ evidence: "screen",
127
+ pluginVersion: options.pluginVersion,
128
+ directory: options.directory,
129
+ kind: "standalone",
130
+ mode: "always",
131
+ handlers,
132
+ deliver: (req) => this.#deliver(req),
133
+ features: ["hubPrompt"],
134
+ ...(options.socket ? { socket: options.socket } : {}),
135
+ ...(options.backoff ? { backoff: options.backoff } : {}),
136
+ ...(options.onState ? { onState: options.onState } : {}),
137
+ })
138
+ }
139
+
140
+ /** Connects to the hub and samples the panes now and then. */
141
+ start(): void {
142
+ this.spoke.start()
143
+ if (this.#timer || this.#sampleMs <= 0) return
144
+ this.#timer = setInterval(() => void this.sample(), this.#sampleMs)
145
+ this.#timer.unref?.()
146
+ }
147
+
148
+ stop(): void {
149
+ if (this.#timer) clearInterval(this.#timer)
150
+ this.#timer = undefined
151
+ this.spoke.stop()
152
+ }
153
+
154
+ /** Starts the preset's harness in a new pane and reports it as a session. */
155
+ async launch(options: LaunchOptions): Promise<Launched> {
156
+ const uuid = this.#newID()
157
+ const { argv, nativeID } = launchArgv(this.preset, { ...(options.args ? { args: options.args } : {}), ...(options.resume !== undefined ? { resume: options.resume } : {}), newID: () => uuid })
158
+ const name = `hub-${this.preset.name.replace(unsafeName, "_")}-${uuid.replace(unsafeName, "").slice(0, 8)}`
159
+ const pane = await this.#backend.launch({ argv, cwd: options.cwd, name, ...(options.env ? { env: options.env } : {}) })
160
+ const id = hubSessionID(PRESET_SESSION_NAMESPACE, uuid)
161
+ const known: NativeID | undefined =
162
+ nativeID === undefined
163
+ ? undefined
164
+ : options.resume !== undefined
165
+ ? { id: nativeID, how: `reported, not observed (resumed with ${this.preset.resumeFlag})` }
166
+ : { id: nativeID, how: `reported, not observed (passed with ${(this.preset.nativeID as { flag: string }).flag})` }
167
+ this.#sessions.set(id, { id, pane, ...(known ? { nativeID: known } : {}), retained: [], chain: Promise.resolve() })
168
+ const info: SessionInfo = { id, title: `${this.preset.name} · ${path.basename(options.cwd) || options.cwd}`, directory: options.cwd, updated: this.#now(), owned: true, open: true }
169
+ this.spoke.upsertSession(info)
170
+ return { sessionID: id, pane, ...(known ? { nativeID: known } : {}), attach: this.#backend.attachHint(pane) }
171
+ }
172
+
173
+ /** The session's native ID, when known. */
174
+ nativeID(sessionID: string): NativeID | undefined {
175
+ return this.#sessions.get(sessionID)?.nativeID
176
+ }
177
+
178
+ pane(sessionID: string): TerminalPane | undefined {
179
+ return this.#sessions.get(sessionID)?.pane
180
+ }
181
+
182
+ /** Calls `listener` with each session that ended (its pane is gone). */
183
+ onEnd(listener: (sessionID: string) => void): void {
184
+ this.#ended.add(listener)
185
+ }
186
+
187
+ /** One sampling round: ends sessions whose pane is gone, reads native IDs off the screen. Never sends an event. */
188
+ async sample(): Promise<void> {
189
+ if (this.#sampling) return
190
+ this.#sampling = true
191
+ try {
192
+ for (const session of [...this.#sessions.values()]) {
193
+ if (!(await this.#backend.alive(session.pane).catch(() => false))) {
194
+ this.#end(session.id)
195
+ continue
196
+ }
197
+ this.spoke.touch(session.id)
198
+ if (session.nativeID || this.preset.nativeID.source !== "screen") continue
199
+ const screen = await this.#backend.capture(session.pane, CAPTURE_LINES).catch(() => "")
200
+ const id = nativeIDFromScreen(this.preset, screen)
201
+ if (id) {
202
+ session.nativeID = { id, how: "read off the screen" }
203
+ this.#log(`session ${session.id}: native ID ${id} (read off the screen)`)
204
+ }
205
+ }
206
+ } finally {
207
+ this.#sampling = false
208
+ }
209
+ }
210
+
211
+ #end(sessionID: string): void {
212
+ if (!this.#sessions.delete(sessionID)) return
213
+ this.spoke.endSession(sessionID)
214
+ for (const listener of this.#ended) listener(sessionID)
215
+ }
216
+
217
+ #session(sessionID: string): Session {
218
+ const session = this.#sessions.get(sessionID)
219
+ if (!session) throw new Error(`Unknown session ${sessionID}`)
220
+ return session
221
+ }
222
+
223
+ /** Pastes the retained messages and `text`, then Enter; one paste at a time per pane. */
224
+ #submit(session: Session, text: string): Promise<void> {
225
+ const run = session.chain.then(async () => {
226
+ const ahead = session.retained.splice(0).map((m) => m.text)
227
+ await this.#backend.paste(session.pane, [...ahead, text].join("\n\n"), { submit: true })
228
+ })
229
+ session.chain = run.catch(() => {})
230
+ return run
231
+ }
232
+
233
+ async #deliver(req: DeliverRequest): Promise<Admission> {
234
+ const session = this.#session(req.sessionID)
235
+ const text = deliverText(req)
236
+ if (!req.wake) {
237
+ session.retained.push({ messageID: req.messageID, text })
238
+ return { result: { delivered: "retained" }, resumed: false }
239
+ }
240
+ // A retry that wakes a message first retained: it goes now, not again later.
241
+ const at = session.retained.findIndex((m) => m.messageID === req.messageID)
242
+ if (at >= 0) session.retained.splice(at, 1)
243
+ await this.#submit(session, text)
244
+ return { result: { delivered: "queued" }, resumed: true }
245
+ }
246
+
247
+ async #interrupt(sessionID: string): Promise<void> {
248
+ await this.#backend.interrupt(this.#session(sessionID).pane)
249
+ }
250
+ }
251
+