@huynle/hub 0.6.1 → 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.
- package/CHANGELOG.md +800 -0
- package/Dockerfile +30 -0
- package/README.md +870 -66
- package/package.json +45 -2
- package/packages/claude-code/package.json +12 -0
- package/packages/claude-code/src/bridge.ts +79 -0
- package/packages/claude-code/src/daemon.ts +339 -0
- package/packages/claude-code/src/hook-input.ts +44 -0
- package/packages/claude-code/src/ids.ts +27 -0
- package/packages/claude-code/src/index.ts +18 -0
- package/packages/claude-code/src/launcher.ts +265 -0
- package/packages/claude-code/src/main.ts +127 -0
- package/packages/claude-code/src/mcp.ts +109 -0
- package/packages/claude-code/src/paths.ts +34 -0
- package/packages/claude-code/src/plugin.ts +99 -0
- package/packages/claude-code/src/references.ts +51 -0
- package/packages/claude-code/src/sessions.ts +600 -0
- package/packages/claude-code/src/tools.ts +66 -0
- package/packages/claude-code/src/wire.ts +71 -0
- package/packages/claude-code/src/wrapper.ts +155 -0
- package/packages/cli/dist/hub-cli.js +10162 -0
- package/packages/codex/package.json +12 -0
- package/packages/codex/src/app-server.ts +44 -0
- package/packages/codex/src/ids.ts +28 -0
- package/packages/codex/src/index.ts +11 -0
- package/packages/codex/src/jsonrpc.ts +168 -0
- package/packages/codex/src/main.ts +122 -0
- package/packages/codex/src/paths.ts +65 -0
- package/packages/codex/src/sessions.ts +578 -0
- package/packages/codex/src/spoke.ts +249 -0
- package/packages/codex/src/stdio.ts +101 -0
- package/packages/codex/src/websocket.ts +206 -0
- package/packages/codex/src/wrapper.ts +100 -0
- package/packages/node/package.json +12 -0
- package/packages/node/src/config.ts +184 -0
- package/packages/node/src/connection.ts +283 -0
- package/packages/node/src/daemon.ts +281 -0
- package/packages/node/src/git.ts +107 -0
- package/packages/node/src/harnesses.ts +60 -0
- package/packages/node/src/host.ts +319 -0
- package/packages/node/src/index.ts +17 -0
- package/packages/node/src/main.ts +11 -0
- package/packages/node/src/openshell.ts +73 -0
- package/packages/node/src/run.ts +106 -0
- package/packages/preset/package.json +12 -0
- package/packages/preset/presets/aider.json +8 -0
- package/packages/preset/presets/goose.json +8 -0
- package/packages/preset/src/index.ts +2 -0
- package/packages/preset/src/main.ts +195 -0
- package/packages/preset/src/presets.ts +87 -0
- package/packages/preset/src/spoke.ts +251 -0
- package/packages/preset/src/tmux.ts +160 -0
- package/packages/protocol/package.json +12 -0
- package/packages/protocol/src/harness-events.ts +207 -0
- package/packages/protocol/src/index.ts +1215 -0
- package/packages/protocol/src/launch.ts +172 -0
- package/packages/protocol/src/node.ts +409 -0
- package/packages/protocol/src/preset.ts +144 -0
- package/packages/protocol/src/webhook.ts +62 -0
- package/packages/protocol/src/wire.ts +54 -0
- package/packages/runtime-openshell/image/Dockerfile +36 -0
- package/packages/runtime-openshell/image/README.md +78 -0
- package/packages/runtime-openshell/image/build.ts +203 -0
- package/packages/runtime-openshell/image/entry.sh +163 -0
- package/packages/runtime-openshell/image/image.ts +119 -0
- package/packages/runtime-openshell/image/start.sh +24 -0
- package/packages/runtime-openshell/package.json +12 -0
- package/packages/runtime-openshell/src/cli.ts +584 -0
- package/packages/runtime-openshell/src/index.ts +6 -0
- package/packages/runtime-openshell/src/policy.ts +155 -0
- package/packages/runtime-openshell/src/profiles.ts +181 -0
- package/packages/runtime-openshell/src/runtime.ts +475 -0
- package/packages/runtime-sdk/package.json +12 -0
- package/packages/runtime-sdk/src/index.ts +87 -0
- package/packages/sdk/package.json +12 -0
- package/packages/sdk/src/address.ts +25 -0
- package/packages/sdk/src/client.ts +502 -0
- package/packages/sdk/src/envelope.ts +108 -0
- package/packages/sdk/src/errors.ts +106 -0
- package/packages/sdk/src/index.ts +10 -0
- package/packages/sdk/src/sse.ts +67 -0
- package/packages/sdk/src/types.ts +458 -0
- package/packages/sdk/src/webhook.ts +102 -0
- package/packages/spoke-sdk/package.json +12 -0
- package/packages/spoke-sdk/src/activity.ts +3 -0
- package/packages/spoke-sdk/src/connection.ts +295 -0
- package/packages/spoke-sdk/src/delivery.ts +119 -0
- package/packages/spoke-sdk/src/enroll.ts +79 -0
- package/packages/spoke-sdk/src/harness-hello.ts +79 -0
- package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
- package/packages/spoke-sdk/src/index.ts +12 -0
- package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
- package/packages/spoke-sdk/src/prompt-source.ts +8 -0
- package/packages/spoke-sdk/src/session-ids.ts +49 -0
- package/packages/spoke-sdk/src/session-tokens.ts +78 -0
- package/scripts/hub-claude-code.service +34 -0
- package/scripts/hub-codex-spoke.service +34 -0
- package/scripts/hub-node.service +35 -0
- package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
- package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
- package/scripts/io.github.huynle.hub-node.plist +52 -0
- package/src/central/actions.ts +233 -0
- package/src/central/agent/acting-routes.ts +133 -0
- package/src/central/agent/acting-switch.ts +93 -0
- package/src/central/agent/acting.ts +599 -0
- package/src/central/agent/actor.ts +193 -0
- package/src/central/agent/app-tokens.ts +142 -0
- package/src/central/agent/audit.ts +185 -0
- package/src/central/agent/decisions.ts +530 -0
- package/src/central/agent/delivery.ts +678 -0
- package/src/central/agent/directory-routes.ts +185 -0
- package/src/central/agent/directory.ts +473 -0
- package/src/central/agent/envelope.ts +51 -0
- package/src/central/agent/errors.ts +72 -0
- package/src/central/agent/escalation.ts +219 -0
- package/src/central/agent/grant-requests.ts +221 -0
- package/src/central/agent/grant-routes.ts +70 -0
- package/src/central/agent/grants.ts +235 -0
- package/src/central/agent/launch-engine.ts +1025 -0
- package/src/central/agent/launch-routes.ts +252 -0
- package/src/central/agent/launch-spec.ts +289 -0
- package/src/central/agent/launch-store.ts +264 -0
- package/src/central/agent/launch-tokens.ts +206 -0
- package/src/central/agent/message-limits.ts +123 -0
- package/src/central/agent/message-routes.ts +385 -0
- package/src/central/agent/messages.ts +442 -0
- package/src/central/agent/names.ts +86 -0
- package/src/central/agent/placement.ts +419 -0
- package/src/central/agent/policy.ts +378 -0
- package/src/central/agent/read-routes.ts +389 -0
- package/src/central/agent/routes.ts +278 -0
- package/src/central/agent/session-tokens.ts +227 -0
- package/src/central/agent/versioned.ts +26 -0
- package/src/central/agent/watch.ts +221 -0
- package/src/central/agent/webhook-routes.ts +78 -0
- package/src/central/agent/webhooks.ts +449 -0
- package/src/central/agent-data.ts +177 -0
- package/src/central/auth.ts +224 -121
- package/src/central/backend/file-state.ts +240 -0
- package/src/central/backend/index.ts +113 -0
- package/src/central/backend/memory-bus.ts +93 -0
- package/src/central/backend/memory-catalog.ts +90 -0
- package/src/central/backend/memory-counters.ts +102 -0
- package/src/central/backend/memory-events.ts +45 -0
- package/src/central/backend/memory-kv.ts +38 -0
- package/src/central/backend/memory-state.ts +77 -0
- package/src/central/backend/postgres-migrate.ts +100 -0
- package/src/central/backend/postgres-state.ts +197 -0
- package/src/central/backend/types.ts +244 -0
- package/src/central/backend/valkey-bus.ts +425 -0
- package/src/central/backend/valkey-catalog.ts +190 -0
- package/src/central/backend/valkey-client.ts +36 -0
- package/src/central/backend/valkey-counters.ts +38 -0
- package/src/central/backend/valkey-events.ts +168 -0
- package/src/central/backend/valkey-kv.ts +54 -0
- package/src/central/backend/valkey-locks.ts +92 -0
- package/src/central/cluster/directory.ts +141 -0
- package/src/central/cluster/feed.ts +84 -0
- package/src/central/cluster/leader.ts +232 -0
- package/src/central/cluster/live.ts +151 -0
- package/src/central/cluster/recommend.ts +59 -0
- package/src/central/cluster/rpc.ts +150 -0
- package/src/central/cluster/view.ts +232 -0
- package/src/central/config.ts +197 -3
- package/src/central/done.ts +96 -0
- package/src/central/events-log.ts +100 -0
- package/src/central/http.ts +1067 -171
- package/src/central/import.ts +318 -0
- package/src/central/main.ts +211 -12
- package/src/central/nodes.ts +314 -0
- package/src/central/oidc.ts +34 -14
- package/src/central/push-target.ts +118 -18
- package/src/central/push.ts +256 -82
- package/src/central/registry.ts +902 -92
- package/src/central/say-stream.ts +128 -0
- package/src/central/session-token-push.ts +233 -0
- package/src/central/speech/openai.ts +21 -14
- package/src/central/speech/openrouter.ts +27 -10
- package/src/central/speech/provider.ts +54 -0
- package/src/central/speech/service.ts +22 -7
- package/src/central/spoke-ws.ts +522 -44
- package/src/central/store-shared.ts +518 -0
- package/src/central/store.ts +572 -189
- package/src/central/token-keys.ts +165 -0
- package/src/config.ts +49 -1
- package/src/index.ts +165 -12
- package/src/protocol.ts +3 -650
- package/src/rpc.ts +27 -2
- package/src/spoke/client.ts +3 -272
- package/src/spoke/conversation.ts +16 -12
- package/src/spoke/deliver.ts +75 -0
- package/src/spoke/enroll.ts +2 -79
- package/src/spoke/env-inject.ts +160 -0
- package/src/spoke/hub-rpc.ts +115 -0
- package/src/spoke/hub-tool.ts +409 -0
- package/src/spoke/launch-source.ts +22 -0
- package/src/spoke/launchers.ts +68 -0
- package/src/spoke/locations.ts +3 -2
- package/src/spoke/node-roots.ts +61 -0
- package/src/spoke/prompt-refs.ts +116 -0
- package/src/spoke/session-tokens.ts +2 -0
- package/src/spoke/spoke.ts +465 -28
- package/src/spoke/taskboard.ts +243 -0
- package/src/spoke/timeline.ts +184 -6
- package/src/spoke/tracker.ts +79 -4
- package/src/status.ts +203 -10
- package/src/tui-commands.ts +13 -6
- package/src/tui-messaging.ts +93 -0
- package/src/tui.ts +15 -0
- package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
- package/web/dist/assets/index-D9zMbxSg.css +1 -0
- package/web/dist/assets/index-db1z4duA.js +20 -0
- package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
- package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
- package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
- package/web/dist/index.html +9 -2
- package/web/dist/sw.js +79 -15
- package/web/dist/assets/index-DClBvfLL.css +0 -1
- 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
|