@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.
- 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,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
|
+
|