@huynle/hub 0.0.0-stage → 0.6.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 +269 -0
- package/Dockerfile +51 -0
- package/LICENSE +21 -0
- package/README.md +1029 -2
- package/bin/hub.ts +20 -0
- package/docker/hub-deps.ts +42 -0
- package/package.json +85 -4
- package/rpc.ts +2 -0
- package/scripts/hub-local.sh +28 -0
- package/scripts/io.github.huynle.hub.plist +42 -0
- package/server.ts +3 -0
- package/src/central/assets.ts +74 -0
- package/src/central/auth.ts +271 -0
- package/src/central/config.ts +310 -0
- package/src/central/http.ts +1247 -0
- package/src/central/main.ts +136 -0
- package/src/central/oidc.ts +295 -0
- package/src/central/push-target.ts +97 -0
- package/src/central/push.ts +285 -0
- package/src/central/registry.ts +1153 -0
- package/src/central/speech/models.ts +89 -0
- package/src/central/speech/openai.ts +66 -0
- package/src/central/speech/openrouter.ts +71 -0
- package/src/central/speech/provider.ts +74 -0
- package/src/central/speech/service.ts +168 -0
- package/src/central/speech/settings.ts +375 -0
- package/src/central/spoke-ws.ts +300 -0
- package/src/central/store.ts +653 -0
- package/src/config.ts +170 -0
- package/src/env.ts +63 -0
- package/src/index.ts +236 -0
- package/src/key.ts +17 -0
- package/src/loopback.ts +41 -0
- package/src/migrate.ts +105 -0
- package/src/openrouter.ts +3 -0
- package/src/options.ts +33 -0
- package/src/protocol.ts +650 -0
- package/src/reply.ts +27 -0
- package/src/rpc.ts +98 -0
- package/src/setup.ts +124 -0
- package/src/speakable.ts +35 -0
- package/src/spoke/client.ts +272 -0
- package/src/spoke/conversation.ts +93 -0
- package/src/spoke/enroll.ts +79 -0
- package/src/spoke/herdr.ts +346 -0
- package/src/spoke/listing.ts +108 -0
- package/src/spoke/localhub.ts +287 -0
- package/src/spoke/locations.ts +34 -0
- package/src/spoke/own-server.ts +32 -0
- package/src/spoke/presence.ts +45 -0
- package/src/spoke/singleton.ts +103 -0
- package/src/spoke/spoke.ts +1212 -0
- package/src/spoke/summary.ts +117 -0
- package/src/spoke/timeline.ts +93 -0
- package/src/spoke/tracker.ts +345 -0
- package/src/spoke/watch.ts +78 -0
- package/src/status.ts +232 -0
- package/src/tui-commands.ts +53 -0
- package/src/tui-forms.ts +77 -0
- package/src/tui-presence.ts +75 -0
- package/src/tui.ts +479 -0
- package/src/turns.ts +94 -0
- package/src/voice-command.ts +65 -0
- package/tui.ts +2 -0
- package/web/dist/apple-touch-icon.png +0 -0
- package/web/dist/assets/index-DClBvfLL.css +1 -0
- package/web/dist/assets/index-Djo_yF0a.js +12 -0
- package/web/dist/favicon.ico +0 -0
- package/web/dist/icon-192.png +0 -0
- package/web/dist/icon-512.png +0 -0
- package/web/dist/icon-maskable-512.png +0 -0
- package/web/dist/icon.svg +18 -0
- package/web/dist/index.html +25 -0
- package/web/dist/manifest.webmanifest +18 -0
- package/web/dist/sw.js +151 -0
- package/web/dist/vad-worklet.js +27 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// Auto-start of a local hub for a loopback hub URL. When nothing answers on the configured
|
|
2
|
+
// http://127.0.0.1:<port> (or localhost / [::1]), start `bun <this repo>/bin/hub.ts` as a detached
|
|
3
|
+
// background process bound to that address, with output appended to hub.log. Without a bun install it
|
|
4
|
+
// runs the hub with the current executable (OpenCode is a compiled Bun binary) and BUN_BE_BUN=1. A lockfile (hub.pid)
|
|
5
|
+
// makes sure only one process on the machine starts it. Never used for non-loopback servers.
|
|
6
|
+
import { spawn as nodeSpawn } from "node:child_process"
|
|
7
|
+
import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs"
|
|
8
|
+
import { homedir } from "node:os"
|
|
9
|
+
import path from "node:path"
|
|
10
|
+
import { fileURLToPath } from "node:url"
|
|
11
|
+
import { hubEnv } from "../env"
|
|
12
|
+
import { isLoopbackURL } from "../loopback"
|
|
13
|
+
import { APP_DIR, legacyPaths, migrateFirst, stateBase } from "../migrate"
|
|
14
|
+
|
|
15
|
+
export interface ChildLike {
|
|
16
|
+
readonly pid?: number
|
|
17
|
+
unref(): void
|
|
18
|
+
on(event: "exit", listener: (code: number | null, signal: string | null) => void): unknown
|
|
19
|
+
on(event: "error", listener: (error: Error) => void): unknown
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type SpawnLike = (command: string, args: readonly string[], options: object) => ChildLike
|
|
23
|
+
|
|
24
|
+
export interface LocalHubOptions {
|
|
25
|
+
/**
|
|
26
|
+
* Where hub.pid and hub.log live (default $HUB_STATE_DIR or ~/.local/state/hub, migrated from
|
|
27
|
+
* ~/.local/state/opencode-hub or opencode-voice first so the hub does not find a fresh, empty state directory).
|
|
28
|
+
*/
|
|
29
|
+
readonly stateDir?: string
|
|
30
|
+
/** Home directory for the default state directory (default os.homedir()). */
|
|
31
|
+
readonly home?: string
|
|
32
|
+
/** Where a one-time migration notice goes (default console). */
|
|
33
|
+
readonly log?: (message: string) => void
|
|
34
|
+
/** This plugin's repository (default: resolved from this file). */
|
|
35
|
+
readonly repoRoot?: string
|
|
36
|
+
/** bun executable; shorthand for `runtime: {command: bun, env: {}}`. */
|
|
37
|
+
readonly bun?: string
|
|
38
|
+
/** How to run bin/hub.ts (default: resolveHubRuntime for this process). */
|
|
39
|
+
readonly runtime?: HubRuntime
|
|
40
|
+
readonly spawn?: SpawnLike
|
|
41
|
+
readonly fetch?: typeof globalThis.fetch
|
|
42
|
+
readonly isAlive?: (pid: number) => boolean
|
|
43
|
+
readonly now?: () => number
|
|
44
|
+
readonly sleep?: (ms: number) => Promise<void>
|
|
45
|
+
/** How long to wait for the started hub to answer (default 15 s). */
|
|
46
|
+
readonly waitMs?: number
|
|
47
|
+
readonly env?: Readonly<Record<string, string | undefined>>
|
|
48
|
+
/** This process's pid (written to the lock while starting). */
|
|
49
|
+
readonly pid?: number
|
|
50
|
+
/** Whether the repository has the built web app (default: web/dist/index.html exists). */
|
|
51
|
+
readonly webBuilt?: (root: string) => boolean
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Why a local hub is not started from an install without web/dist (e.g. the plugin installed from GitHub). */
|
|
55
|
+
export const WEB_NOT_BUILT =
|
|
56
|
+
"this Hub install has no built web app (web/dist), so its pages would only show an error. " +
|
|
57
|
+
"Install the plugin from npm (`opencode plugin add @huynle/hub`) or use a release binary, " +
|
|
58
|
+
"or in a source checkout run `npm install && npm run build:web` first."
|
|
59
|
+
|
|
60
|
+
const hasWebBuild = (root: string): boolean => existsSync(path.join(root, "web", "dist", "index.html"))
|
|
61
|
+
|
|
62
|
+
export type LocalHubResult =
|
|
63
|
+
| { readonly status: "skipped"; readonly reason: string }
|
|
64
|
+
| { readonly status: "running" }
|
|
65
|
+
| { readonly status: "started"; readonly pid: number }
|
|
66
|
+
| { readonly status: "busy"; readonly pid: number }
|
|
67
|
+
| { readonly status: "failed"; readonly error: string }
|
|
68
|
+
|
|
69
|
+
interface Lock {
|
|
70
|
+
readonly pid: number
|
|
71
|
+
readonly port: number
|
|
72
|
+
readonly started: number
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A lock whose hub has not answered for this long is stale, even if its pid is alive (pid reuse). */
|
|
76
|
+
export const LOCK_STALE_MS = 60_000
|
|
77
|
+
const PROBE_TIMEOUT_MS = 1500
|
|
78
|
+
const POLL_MS = 250
|
|
79
|
+
|
|
80
|
+
export function repoRoot(): string {
|
|
81
|
+
return fileURLToPath(new URL("../..", import.meta.url)).replace(/\/+$/, "")
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function hubScript(root: string = repoRoot()): string {
|
|
85
|
+
return path.join(root, "bin", "hub.ts")
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function defaultStateDir(env: Readonly<Record<string, string | undefined>> = process.env, home: string = homedir()): string {
|
|
89
|
+
return hubEnv(env).HUB_STATE_DIR || path.join(stateBase(home), APP_DIR)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The runtime for this process: probes the real executable, PATH and home directory. */
|
|
93
|
+
export function currentHubRuntime(env: Readonly<Record<string, string | undefined>> = process.env): HubRuntime {
|
|
94
|
+
const isBun = typeof Bun !== "undefined"
|
|
95
|
+
return resolveHubRuntime({
|
|
96
|
+
execPath: process.execPath,
|
|
97
|
+
home: homedir(),
|
|
98
|
+
isBun,
|
|
99
|
+
which: (name) => (isBun ? Bun.which(name, env.PATH ? { PATH: env.PATH } : {}) : null),
|
|
100
|
+
exists: existsSync,
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function pidAlive(pid: number): boolean {
|
|
105
|
+
try {
|
|
106
|
+
process.kill(pid, 0)
|
|
107
|
+
return true
|
|
108
|
+
} catch (cause) {
|
|
109
|
+
return (cause as NodeJS.ErrnoException).code === "EPERM"
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The listen address and port the hub needs to serve `server`. */
|
|
114
|
+
function bindFor(url: URL): { host: string; port: number; probe: string } {
|
|
115
|
+
const name = url.hostname.replace(/^\[(.*)\]$/, "$1")
|
|
116
|
+
const host = name === "localhost" ? "127.0.0.1" : name
|
|
117
|
+
const port = Number(url.port || 80)
|
|
118
|
+
return { host, port, probe: `http://${host.includes(":") ? `[${host}]` : host}:${port}` }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Makes sure a hub answers on the loopback `server`, starting one if nothing does and no other process
|
|
123
|
+
* is already starting it. Resolves once the hub answers, or with why it did not.
|
|
124
|
+
*/
|
|
125
|
+
export async function ensureLocalHub(server: string, options: LocalHubOptions = {}): Promise<LocalHubResult> {
|
|
126
|
+
if (!isLoopbackURL(server)) return { status: "skipped", reason: "not a loopback server" }
|
|
127
|
+
const url = new URL(server)
|
|
128
|
+
if (url.protocol !== "http:") return { status: "skipped", reason: "a local hub serves http only" }
|
|
129
|
+
|
|
130
|
+
const env = options.env ?? process.env
|
|
131
|
+
const doFetch = options.fetch ?? globalThis.fetch
|
|
132
|
+
const now = options.now ?? Date.now
|
|
133
|
+
const sleep = options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)))
|
|
134
|
+
const alive = options.isAlive ?? pidAlive
|
|
135
|
+
const home = options.home ?? homedir()
|
|
136
|
+
const stateDir = options.stateDir ?? defaultStateDir(env, home)
|
|
137
|
+
const lockFile = path.join(stateDir, "hub.pid")
|
|
138
|
+
const logFile = path.join(stateDir, "hub.log")
|
|
139
|
+
const bind = bindFor(url)
|
|
140
|
+
|
|
141
|
+
const answers = async (origin: string) => {
|
|
142
|
+
try {
|
|
143
|
+
await doFetch(`${origin}/healthz`, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) })
|
|
144
|
+
return true // any HTTP answer: something listens there, so never start a second server
|
|
145
|
+
} catch {
|
|
146
|
+
return false
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (await answers(url.origin)) return { status: "running" }
|
|
151
|
+
const root = options.repoRoot ?? repoRoot()
|
|
152
|
+
if (!(options.webBuilt ?? hasWebBuild)(root)) return { status: "failed", error: WEB_NOT_BUILT }
|
|
153
|
+
|
|
154
|
+
if (options.stateDir === undefined && !hubEnv(env).HUB_STATE_DIR) {
|
|
155
|
+
migrateFirst(legacyPaths(stateBase(home)), stateDir, { skip: ["hub.pid"], ...(options.log ? { log: options.log } : {}) })
|
|
156
|
+
}
|
|
157
|
+
mkdirSync(stateDir, { recursive: true, mode: 0o700 })
|
|
158
|
+
|
|
159
|
+
// ---- the lock ----
|
|
160
|
+
const existing = readLock(lockFile)
|
|
161
|
+
if (existing === "unreadable") {
|
|
162
|
+
if (now() - statSync(lockFile).mtimeMs < LOCK_STALE_MS) return { status: "busy", pid: 0 }
|
|
163
|
+
removeLock(lockFile)
|
|
164
|
+
} else if (existing) {
|
|
165
|
+
if (alive(existing.pid)) {
|
|
166
|
+
if (existing.port !== bind.port && (await answers(`http://127.0.0.1:${existing.port}`))) {
|
|
167
|
+
return {
|
|
168
|
+
status: "failed",
|
|
169
|
+
error:
|
|
170
|
+
`A local hub (pid ${existing.pid}) already runs on port ${existing.port}. ` +
|
|
171
|
+
`Point /hub → Server at http://127.0.0.1:${existing.port}, or stop that hub first.`,
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (now() - existing.started < LOCK_STALE_MS) return { status: "busy", pid: existing.pid }
|
|
175
|
+
}
|
|
176
|
+
removeLock(lockFile)
|
|
177
|
+
}
|
|
178
|
+
const own = options.pid ?? process.pid
|
|
179
|
+
try {
|
|
180
|
+
writeFileSync(lockFile, JSON.stringify({ pid: own, port: bind.port, started: now() } satisfies Lock), { flag: "wx", mode: 0o600 })
|
|
181
|
+
} catch {
|
|
182
|
+
const holder = readLock(lockFile)
|
|
183
|
+
return { status: "busy", pid: holder && holder !== "unreadable" ? holder.pid : 0 }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ---- start ----
|
|
187
|
+
let child: ChildLike
|
|
188
|
+
let exited: string | undefined
|
|
189
|
+
let log: number | undefined
|
|
190
|
+
try {
|
|
191
|
+
log = openSync(logFile, "a", 0o600)
|
|
192
|
+
chmodSync(logFile, 0o600)
|
|
193
|
+
const childEnv: Record<string, string> = {}
|
|
194
|
+
for (const [key, value] of Object.entries(env)) {
|
|
195
|
+
// The hub needs none of OpenCode's own settings or credentials.
|
|
196
|
+
if (value !== undefined && !key.startsWith("OPENCODE_")) childEnv[key] = value
|
|
197
|
+
}
|
|
198
|
+
const runtime = options.runtime ?? (options.bun ? { command: options.bun, env: {} } : currentHubRuntime(env))
|
|
199
|
+
delete childEnv.BUN_BE_BUN // only the runtime decides whether its executable acts as bun
|
|
200
|
+
// The address this hub must serve; legacy names would only lose to these and warn.
|
|
201
|
+
for (const legacy of ["VOICE_HOST", "VOICE_PORT", "VOICE_PUBLIC_URL"]) delete childEnv[legacy]
|
|
202
|
+
Object.assign(childEnv, runtime.env, { HUB_HOST: bind.host, HUB_PORT: String(bind.port), HUB_PUBLIC_URL: url.origin })
|
|
203
|
+
child = (options.spawn ?? (nodeSpawn as unknown as SpawnLike))(runtime.command, [hubScript(root)], {
|
|
204
|
+
cwd: root,
|
|
205
|
+
detached: true,
|
|
206
|
+
stdio: ["ignore", log, log],
|
|
207
|
+
env: childEnv,
|
|
208
|
+
})
|
|
209
|
+
child.on("error", (error) => {
|
|
210
|
+
exited = error.message
|
|
211
|
+
})
|
|
212
|
+
child.on("exit", (code, signal) => {
|
|
213
|
+
exited = `exited with ${signal ?? `code ${code}`}`
|
|
214
|
+
})
|
|
215
|
+
child.unref()
|
|
216
|
+
} catch (cause) {
|
|
217
|
+
removeLock(lockFile)
|
|
218
|
+
return { status: "failed", error: `Could not start the local hub: ${cause instanceof Error ? cause.message : cause}` }
|
|
219
|
+
} finally {
|
|
220
|
+
if (log !== undefined) closeSync(log)
|
|
221
|
+
}
|
|
222
|
+
const pid = child.pid ?? own
|
|
223
|
+
writeFileSync(lockFile, JSON.stringify({ pid, port: bind.port, started: now() } satisfies Lock), { mode: 0o600 })
|
|
224
|
+
|
|
225
|
+
// ---- wait until it answers ----
|
|
226
|
+
const deadline = now() + (options.waitMs ?? 15_000)
|
|
227
|
+
for (;;) {
|
|
228
|
+
if (exited !== undefined) {
|
|
229
|
+
const lock = readLock(lockFile)
|
|
230
|
+
if (lock && lock !== "unreadable" && lock.pid === pid) removeLock(lockFile)
|
|
231
|
+
return { status: "failed", error: `The local hub ${exited}; see ${logFile}` }
|
|
232
|
+
}
|
|
233
|
+
if (await answers(bind.probe)) return { status: "started", pid }
|
|
234
|
+
if (now() >= deadline) {
|
|
235
|
+
return { status: "failed", error: `The local hub (pid ${pid}) did not answer on ${bind.probe} yet; see ${logFile}` }
|
|
236
|
+
}
|
|
237
|
+
await sleep(POLL_MS)
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function readLock(file: string): Lock | "unreadable" | undefined {
|
|
242
|
+
if (!existsSync(file)) return undefined
|
|
243
|
+
try {
|
|
244
|
+
const raw = JSON.parse(readFileSync(file, "utf8")) as Partial<Lock>
|
|
245
|
+
if (Number.isInteger(raw.pid) && Number.isInteger(raw.port) && typeof raw.started === "number") return raw as Lock
|
|
246
|
+
} catch {
|
|
247
|
+
// fall through
|
|
248
|
+
}
|
|
249
|
+
return "unreadable"
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function removeLock(file: string): void {
|
|
253
|
+
try {
|
|
254
|
+
unlinkSync(file)
|
|
255
|
+
} catch {
|
|
256
|
+
// already gone
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export interface HubRuntime {
|
|
261
|
+
readonly command: string
|
|
262
|
+
readonly env: Readonly<Record<string, string>>
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export interface RuntimeProbe {
|
|
266
|
+
readonly execPath: string
|
|
267
|
+
readonly home: string
|
|
268
|
+
readonly isBun: boolean
|
|
269
|
+
readonly which: (name: string) => string | null
|
|
270
|
+
readonly exists: (file: string) => boolean
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* How to run bin/hub.ts: this runtime when it is bun, else bun on PATH, else ~/.bun/bin/bun, else this
|
|
275
|
+
* executable with BUN_BE_BUN=1 when it is a Bun runtime (inside OpenCode, process.execPath is the
|
|
276
|
+
* compiled `opencode` binary, which then runs scripts as bun), else plain `bun`.
|
|
277
|
+
*/
|
|
278
|
+
export function resolveHubRuntime(probe: RuntimeProbe): HubRuntime {
|
|
279
|
+
const base = probe.execPath.split(/[\\/]/).pop() ?? ""
|
|
280
|
+
if (/^bun(\.exe)?$/i.test(base)) return { command: probe.execPath, env: {} }
|
|
281
|
+
const found = probe.which("bun")
|
|
282
|
+
if (found) return { command: found, env: {} }
|
|
283
|
+
const home = path.join(probe.home, ".bun", "bin", "bun")
|
|
284
|
+
if (probe.exists(home)) return { command: home, env: {} }
|
|
285
|
+
if (probe.isBun) return { command: probe.execPath, env: { BUN_BE_BUN: "1" } }
|
|
286
|
+
return { command: "bun", env: {} }
|
|
287
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// OpenCode keeps pending permission requests per location (the directory a session runs in), and the
|
|
2
|
+
// plugin's ctx.permission only sees its own location. One plugin instance per location joins the
|
|
3
|
+
// process (see singleton.ts), but only the first runs the spoke. So a reply has to go through the
|
|
4
|
+
// instance of the location that holds the request.
|
|
5
|
+
import type { PermissionDecision } from "../protocol"
|
|
6
|
+
|
|
7
|
+
/** ctx.permission of one plugin instance, with its location's directory. */
|
|
8
|
+
export interface PermissionLocation {
|
|
9
|
+
readonly directory: string
|
|
10
|
+
get(input: { sessionID: string; requestID: string }): Promise<unknown>
|
|
11
|
+
reply(input: { sessionID: string; requestID: string; decision: PermissionDecision }): Promise<unknown>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Replies to a pending permission through the location that holds it (the session's directory first).
|
|
16
|
+
* "gone" when no location has it pending (already answered, expired, or asked in another process).
|
|
17
|
+
*/
|
|
18
|
+
export async function replyPermission(
|
|
19
|
+
locations: readonly PermissionLocation[],
|
|
20
|
+
input: { sessionID: string; requestID: string; decision: PermissionDecision },
|
|
21
|
+
directory?: string,
|
|
22
|
+
): Promise<"replied" | "gone"> {
|
|
23
|
+
const ordered = [...locations.filter((l) => l.directory === directory), ...locations.filter((l) => l.directory !== directory)]
|
|
24
|
+
for (const location of ordered) {
|
|
25
|
+
try {
|
|
26
|
+
await location.get({ sessionID: input.sessionID, requestID: input.requestID })
|
|
27
|
+
} catch {
|
|
28
|
+
continue // not pending in this location
|
|
29
|
+
}
|
|
30
|
+
await location.reply(input)
|
|
31
|
+
return "replied"
|
|
32
|
+
}
|
|
33
|
+
return "gone"
|
|
34
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Reaching this process's own OpenCode server over HTTP, for what the plugin API does not offer (answering
|
|
2
|
+
// forms). The background service is found with Service.discover; a foreground `opencode serve` from its
|
|
3
|
+
// command line and password variable. A standalone server started by a TUI (`serve --stdio --port 0`)
|
|
4
|
+
// keeps its password to itself: its forms are relayed through the TUI instead (Spoke.formActions).
|
|
5
|
+
|
|
6
|
+
/** URL and Authorization header of a foreground `opencode serve` this process is, when knowable. */
|
|
7
|
+
export function foregroundEndpoint(
|
|
8
|
+
argv: readonly string[],
|
|
9
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
10
|
+
): { url: string; authorization: string } | undefined {
|
|
11
|
+
if (!argv.includes("serve") || argv.includes("--stdio") || argv.includes("--service")) return undefined
|
|
12
|
+
const option = (name: string) => {
|
|
13
|
+
const index = argv.indexOf(name)
|
|
14
|
+
if (index >= 0) return argv[index + 1]
|
|
15
|
+
return argv.find((a) => a.startsWith(`${name}=`))?.slice(name.length + 1)
|
|
16
|
+
}
|
|
17
|
+
const port = Number(option("--port"))
|
|
18
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) return undefined
|
|
19
|
+
const password = env.OPENCODE_PASSWORD || env.OPENCODE_SERVER_PASSWORD
|
|
20
|
+
if (!password) return undefined
|
|
21
|
+
const host = option("--hostname")
|
|
22
|
+
const reachable = !host || host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host.includes(":") ? `[${host}]` : host
|
|
23
|
+
return { url: `http://${reachable}:${port}`, authorization: `Basic ${btoa(`opencode:${password}`)}` }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** "gone" for OpenCode's form-not-found / already-settled errors; undefined for anything else. */
|
|
27
|
+
export function formOutcome(cause: unknown): "gone" | undefined {
|
|
28
|
+
const tag = (cause as { _tag?: unknown } | null)?._tag
|
|
29
|
+
if (tag === "FormNotFoundError" || tag === "FormAlreadySettledError") return "gone"
|
|
30
|
+
const message = cause instanceof Error ? cause.message : String(cause)
|
|
31
|
+
return /form (not found|already settled)/i.test(message) ? "gone" : undefined
|
|
32
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// TUI presence on the spoke: which sessions an OpenCode TUI shows (its route and open tabs), reported over
|
|
2
|
+
// RPC voice.presence on change and every 15 s. A session is open while some TUI reported it within the
|
|
3
|
+
// window. Each TUI's report replaces its previous one, so a closed tab stops counting at once.
|
|
4
|
+
export const TUI_PRESENCE_WINDOW_MS = 45_000
|
|
5
|
+
/** Sessions taken from one report, and TUIs remembered. */
|
|
6
|
+
const REPORT_MAX = 200
|
|
7
|
+
const REPORTERS_MAX = 100
|
|
8
|
+
const SESSION_ID = /^ses[A-Za-z0-9_-]{1,200}$/
|
|
9
|
+
|
|
10
|
+
export class PresenceBook {
|
|
11
|
+
/** reporter → its sessions and when it reported them. Anonymous reports keep a time per session. */
|
|
12
|
+
private readonly byReporter = new Map<string, { at: number; sessions: Set<string> }>()
|
|
13
|
+
private readonly anonymous = new Map<string, number>()
|
|
14
|
+
|
|
15
|
+
report(sessionIDs: readonly string[], now: number, reporter?: string): void {
|
|
16
|
+
const ids = [...new Set(sessionIDs.filter((id) => typeof id === "string" && SESSION_ID.test(id)))].slice(0, REPORT_MAX)
|
|
17
|
+
if (!reporter) {
|
|
18
|
+
for (const id of ids) this.anonymous.set(id, now)
|
|
19
|
+
this.prune(now)
|
|
20
|
+
return
|
|
21
|
+
}
|
|
22
|
+
this.byReporter.delete(reporter)
|
|
23
|
+
this.byReporter.set(reporter, { at: now, sessions: new Set(ids) })
|
|
24
|
+
this.prune(now)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Sessions some TUI reported within the window. */
|
|
28
|
+
open(now: number): Set<string> {
|
|
29
|
+
const out = new Set<string>()
|
|
30
|
+
for (const { at, sessions } of this.byReporter.values()) if (now - at < TUI_PRESENCE_WINDOW_MS) for (const id of sessions) out.add(id)
|
|
31
|
+
for (const [id, at] of this.anonymous) if (now - at < TUI_PRESENCE_WINDOW_MS) out.add(id)
|
|
32
|
+
return out
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
reporters(): number {
|
|
36
|
+
return this.byReporter.size
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private prune(now: number): void {
|
|
40
|
+
for (const [key, entry] of this.byReporter) if (now - entry.at >= TUI_PRESENCE_WINDOW_MS) this.byReporter.delete(key)
|
|
41
|
+
while (this.byReporter.size > REPORTERS_MAX) this.byReporter.delete(this.byReporter.keys().next().value!)
|
|
42
|
+
for (const [id, at] of this.anonymous) if (now - at >= TUI_PRESENCE_WINDOW_MS) this.anonymous.delete(id)
|
|
43
|
+
while (this.anonymous.size > REPORT_MAX) this.anonymous.delete(this.anonymous.keys().next().value!)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Exactly one active spoke per OpenCode process. Every plugin instance in the process (one per
|
|
2
|
+
// location, plus reloads) joins a registry on globalThis; the first one creates the spoke, the
|
|
3
|
+
// others stay passive and delegate to it. When the active instance is disposed, the next
|
|
4
|
+
// instance takes over with its own factory, so a reloaded module replaces stale code.
|
|
5
|
+
import { randomBytes } from "node:crypto"
|
|
6
|
+
|
|
7
|
+
export interface Startable {
|
|
8
|
+
start(): void
|
|
9
|
+
stop(): void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface Instance {
|
|
13
|
+
readonly id: symbol
|
|
14
|
+
readonly create: (processID: string) => Startable
|
|
15
|
+
/** Per-instance value (e.g. the instance's location-scoped API), see Membership.locals. */
|
|
16
|
+
readonly local?: unknown
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface Registry {
|
|
20
|
+
pid: number
|
|
21
|
+
processID: string
|
|
22
|
+
instances: Instance[]
|
|
23
|
+
spoke?: Startable
|
|
24
|
+
owner?: symbol
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface Membership<S> {
|
|
28
|
+
readonly processID: string
|
|
29
|
+
active(): S | undefined
|
|
30
|
+
isActive(): boolean
|
|
31
|
+
/** The local values of every instance in the process (the active one first). */
|
|
32
|
+
locals<L = unknown>(): L[]
|
|
33
|
+
leave(): void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Kept from opencode-voice so a process that loads both the old and the renamed package runs one spoke.
|
|
37
|
+
export const SPOKE_KEY = Symbol.for("opencode-voice.spoke")
|
|
38
|
+
|
|
39
|
+
function registry(key: symbol): Registry {
|
|
40
|
+
const store = globalThis as Record<symbol, Registry | undefined>
|
|
41
|
+
let reg = store[key]
|
|
42
|
+
if (!reg || reg.pid !== process.pid || !Array.isArray(reg.instances)) {
|
|
43
|
+
reg = { pid: process.pid, processID: `proc_${randomBytes(8).toString("hex")}`, instances: [] }
|
|
44
|
+
store[key] = reg
|
|
45
|
+
}
|
|
46
|
+
return reg
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function promote(reg: Registry): void {
|
|
50
|
+
for (const instance of reg.instances) {
|
|
51
|
+
try {
|
|
52
|
+
const spoke = instance.create(reg.processID)
|
|
53
|
+
spoke.start()
|
|
54
|
+
reg.spoke = spoke
|
|
55
|
+
reg.owner = instance.id
|
|
56
|
+
return
|
|
57
|
+
} catch (cause) {
|
|
58
|
+
console.error("[hub] could not start the hub spoke", cause)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
reg.spoke = undefined
|
|
62
|
+
reg.owner = undefined
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** `create` receives the stable per-process ID; `local` is this instance's value for Membership.locals. */
|
|
66
|
+
export function joinProcess<S extends Startable>(create: (processID: string) => S, key: symbol = SPOKE_KEY, local?: unknown): Membership<S> {
|
|
67
|
+
const reg = registry(key)
|
|
68
|
+
const id = Symbol("hub.instance")
|
|
69
|
+
reg.instances.push({ id, create, ...(local !== undefined ? { local } : {}) })
|
|
70
|
+
if (!reg.spoke) {
|
|
71
|
+
try {
|
|
72
|
+
const spoke = create(reg.processID)
|
|
73
|
+
spoke.start()
|
|
74
|
+
reg.spoke = spoke
|
|
75
|
+
reg.owner = id
|
|
76
|
+
} catch (cause) {
|
|
77
|
+
console.error("[hub] could not start the hub spoke", cause)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
processID: reg.processID,
|
|
82
|
+
active: () => registry(key).spoke as S | undefined,
|
|
83
|
+
isActive: () => registry(key).owner === id,
|
|
84
|
+
locals: <L>() => {
|
|
85
|
+
const current = registry(key)
|
|
86
|
+
const ordered = [...current.instances].sort((a, b) => Number(b.id === current.owner) - Number(a.id === current.owner))
|
|
87
|
+
return ordered.flatMap((i) => (i.local === undefined ? [] : [i.local as L]))
|
|
88
|
+
},
|
|
89
|
+
leave: () => {
|
|
90
|
+
const current = registry(key)
|
|
91
|
+
const index = current.instances.findIndex((i) => i.id === id)
|
|
92
|
+
if (index === -1) return
|
|
93
|
+
current.instances.splice(index, 1)
|
|
94
|
+
if (current.owner !== id) return
|
|
95
|
+
try {
|
|
96
|
+
current.spoke?.stop()
|
|
97
|
+
} catch (cause) {
|
|
98
|
+
console.error("[hub] spoke stop failed", cause)
|
|
99
|
+
}
|
|
100
|
+
promote(current)
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
}
|