@huynle/hub 0.7.1 → 0.7.3
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 +87 -0
- package/Dockerfile +3 -0
- package/README.md +148 -11
- package/package.json +3 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/main.ts +4 -4
- package/packages/claude-code/src/wrapper.ts +3 -2
- package/packages/cli/dist/hub-cli.js +19334 -7849
- package/packages/cli/package.json +11 -0
- package/packages/cli/src/acting.ts +70 -0
- package/packages/cli/src/args.ts +91 -0
- package/packages/cli/src/bootstrap/adapters/archive-stream.ts +276 -0
- package/packages/cli/src/bootstrap/adapters/claude/README.md +63 -0
- package/packages/cli/src/bootstrap/adapters/claude/adapter.ts +250 -0
- package/packages/cli/src/bootstrap/adapters/claude/index.ts +42 -0
- package/packages/cli/src/bootstrap/adapters/claude/node.ts +99 -0
- package/packages/cli/src/bootstrap/adapters/claude/openpgp.ts +221 -0
- package/packages/cli/src/bootstrap/adapters/claude/ports.ts +44 -0
- package/packages/cli/src/bootstrap/adapters/claude/source.ts +160 -0
- package/packages/cli/src/bootstrap/adapters/claude/verify.ts +165 -0
- package/packages/cli/src/bootstrap/adapters/codex/archive.ts +103 -0
- package/packages/cli/src/bootstrap/adapters/codex/index.ts +60 -0
- package/packages/cli/src/bootstrap/adapters/codex/plan.ts +129 -0
- package/packages/cli/src/bootstrap/adapters/codex/sigstore.ts +298 -0
- package/packages/cli/src/bootstrap/adapters/codex/source.ts +102 -0
- package/packages/cli/src/bootstrap/adapters/codex/verify.ts +298 -0
- package/packages/cli/src/bootstrap/adapters/inflate.ts +433 -0
- package/packages/cli/src/bootstrap/adapters/node-services/control.ts +392 -0
- package/packages/cli/src/bootstrap/adapters/node-services/index.ts +23 -0
- package/packages/cli/src/bootstrap/adapters/node-services/launch.ts +223 -0
- package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +222 -0
- package/packages/cli/src/bootstrap/adapters/node-services/render.ts +183 -0
- package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +197 -0
- package/packages/cli/src/bootstrap/adapters/opencode/README.md +204 -0
- package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +205 -0
- package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +91 -0
- package/packages/cli/src/bootstrap/adapters/opencode/config.ts +212 -0
- package/packages/cli/src/bootstrap/adapters/opencode/index.ts +81 -0
- package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +266 -0
- package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +208 -0
- package/packages/cli/src/bootstrap/adapters/readiness.ts +19 -0
- package/packages/cli/src/bootstrap/ansible/index.ts +2 -0
- package/packages/cli/src/bootstrap/ansible/render.ts +279 -0
- package/packages/cli/src/bootstrap/contracts/actions.ts +470 -0
- package/packages/cli/src/bootstrap/contracts/artifact.ts +323 -0
- package/packages/cli/src/bootstrap/contracts/canonical.ts +54 -0
- package/packages/cli/src/bootstrap/contracts/credentials.ts +108 -0
- package/packages/cli/src/bootstrap/contracts/discovery.ts +215 -0
- package/packages/cli/src/bootstrap/contracts/envelope.ts +143 -0
- package/packages/cli/src/bootstrap/contracts/exec.ts +74 -0
- package/packages/cli/src/bootstrap/contracts/harness.ts +202 -0
- package/packages/cli/src/bootstrap/contracts/index.ts +28 -0
- package/packages/cli/src/bootstrap/contracts/journal.ts +198 -0
- package/packages/cli/src/bootstrap/contracts/paths.ts +91 -0
- package/packages/cli/src/bootstrap/contracts/plan.ts +155 -0
- package/packages/cli/src/bootstrap/contracts/platform.ts +62 -0
- package/packages/cli/src/bootstrap/contracts/preview.ts +190 -0
- package/packages/cli/src/bootstrap/contracts/profile.ts +105 -0
- package/packages/cli/src/bootstrap/contracts/secrets.ts +52 -0
- package/packages/cli/src/bootstrap/contracts/validate.ts +212 -0
- package/packages/cli/src/bootstrap/discovery/discover.ts +146 -0
- package/packages/cli/src/bootstrap/discovery/executables.ts +134 -0
- package/packages/cli/src/bootstrap/discovery/host.ts +96 -0
- package/packages/cli/src/bootstrap/discovery/index.ts +6 -0
- package/packages/cli/src/bootstrap/discovery/node-probe.ts +97 -0
- package/packages/cli/src/bootstrap/discovery/probe.ts +41 -0
- package/packages/cli/src/bootstrap/discovery/util.ts +13 -0
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +226 -0
- package/packages/cli/src/bootstrap/enrollment/index.ts +7 -0
- package/packages/cli/src/bootstrap/enrollment/policy.ts +54 -0
- package/packages/cli/src/bootstrap/enrollment/result.ts +63 -0
- package/packages/cli/src/bootstrap/enrollment/status.ts +119 -0
- package/packages/cli/src/bootstrap/enrollment/store.ts +173 -0
- package/packages/cli/src/bootstrap/preview/index.ts +5 -0
- package/packages/cli/src/bootstrap/preview/links.ts +31 -0
- package/packages/cli/src/bootstrap/preview/plan.ts +242 -0
- package/packages/cli/src/bootstrap/preview/preview.ts +104 -0
- package/packages/cli/src/bootstrap/preview/render.ts +30 -0
- package/packages/cli/src/bootstrap/profiles/audit.ts +87 -0
- package/packages/cli/src/bootstrap/profiles/catalog.ts +49 -0
- package/packages/cli/src/bootstrap/profiles/desired.ts +96 -0
- package/packages/cli/src/bootstrap/profiles/drift.ts +286 -0
- package/packages/cli/src/bootstrap/profiles/history.ts +263 -0
- package/packages/cli/src/bootstrap/profiles/index.ts +11 -0
- package/packages/cli/src/bootstrap/profiles/upgrade.ts +276 -0
- package/packages/cli/src/bootstrap/profiles/versions.ts +50 -0
- package/packages/cli/src/bootstrap/release/README.md +115 -0
- package/packages/cli/src/bootstrap/release/assets.ts +39 -0
- package/packages/cli/src/bootstrap/release/cli.ts +332 -0
- package/packages/cli/src/bootstrap/release/harness-pins.json +94 -0
- package/packages/cli/src/bootstrap/release/manifest.ts +118 -0
- package/packages/cli/src/bootstrap/release/pins.ts +192 -0
- package/packages/cli/src/bootstrap/release/text-assets.d.ts +9 -0
- package/packages/cli/src/bootstrap/release/trust-root.json +13 -0
- package/packages/cli/src/bootstrap/release/trust-root.ts +39 -0
- package/packages/cli/src/bootstrap/release/verify.ts +88 -0
- package/packages/cli/src/bootstrap/transaction/builtin.ts +312 -0
- package/packages/cli/src/bootstrap/transaction/engine.ts +994 -0
- package/packages/cli/src/bootstrap/transaction/executor.ts +112 -0
- package/packages/cli/src/bootstrap/transaction/fs.ts +194 -0
- package/packages/cli/src/bootstrap/transaction/index.ts +12 -0
- package/packages/cli/src/bootstrap/transaction/json-merge.ts +403 -0
- package/packages/cli/src/bootstrap/transaction/lock.ts +132 -0
- package/packages/cli/src/bootstrap/transaction/resolve.ts +48 -0
- package/packages/cli/src/bootstrap/transaction/result.ts +60 -0
- package/packages/cli/src/bootstrap/transaction/state.ts +88 -0
- package/packages/cli/src/bootstrap/transaction/store.ts +189 -0
- package/packages/cli/src/bootstrap/transaction/stream.ts +254 -0
- package/packages/cli/src/cli.ts +814 -0
- package/packages/cli/src/credentials.ts +75 -0
- package/packages/cli/src/decisions.ts +106 -0
- package/packages/cli/src/format.ts +185 -0
- package/packages/cli/src/grants.ts +105 -0
- package/packages/cli/src/http.ts +86 -0
- package/packages/cli/src/launches.ts +157 -0
- package/packages/cli/src/login.ts +160 -0
- package/packages/cli/src/main.ts +63 -0
- package/packages/cli/src/mcp.ts +879 -0
- package/packages/cli/src/messaging.ts +108 -0
- package/packages/cli/src/node.ts +279 -0
- package/packages/cli/src/openshell.ts +166 -0
- package/packages/cli/src/setup/commands.ts +809 -0
- package/packages/cli/src/setup/contributors.ts +171 -0
- package/packages/cli/src/setup/dispatch.ts +74 -0
- package/packages/cli/src/setup/launch-driver.ts +109 -0
- package/packages/cli/src/setup/runtime.ts +456 -0
- package/packages/cli/src/setup/spoke-probe.ts +120 -0
- package/packages/cli/src/setup/state.ts +244 -0
- package/packages/cli/src/setup/user-bin.ts +39 -0
- package/packages/cli/src/spoke-services.ts +109 -0
- package/packages/cli/src/watch.ts +28 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/main.ts +3 -2
- package/packages/codex/src/spoke.ts +4 -1
- package/packages/codex/src/wrapper.ts +7 -3
- package/packages/node/package.json +1 -1
- package/packages/node/src/harnesses.ts +39 -4
- package/packages/node/src/run.ts +1 -1
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/harness-events.ts +2 -2
- package/packages/protocol/src/index.ts +70 -7
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/client.ts +55 -4
- package/packages/sdk/src/errors.ts +1 -0
- package/packages/sdk/src/sse.ts +4 -1
- package/packages/sdk/src/types.ts +59 -0
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/enroll.ts +26 -4
- package/src/central/agent/app-tokens.ts +27 -17
- package/src/central/agent/archive-inactive.ts +66 -0
- package/src/central/agent/directory-routes.ts +155 -12
- package/src/central/agent/directory.ts +1 -1
- package/src/central/agent/errors.ts +2 -0
- package/src/central/agent/event-cursor.ts +49 -0
- package/src/central/agent/event-writers.ts +181 -0
- package/src/central/agent/events-routes.ts +148 -0
- package/src/central/agent/events.ts +277 -0
- package/src/central/agent/names.ts +1 -1
- package/src/central/agent/read-routes.ts +32 -36
- package/src/central/agent/routes.ts +14 -11
- package/src/central/agent/watch.ts +65 -174
- package/src/central/agent/webhook-routes.ts +10 -2
- package/src/central/agent/webhooks.ts +49 -5
- package/src/central/auth.ts +127 -1
- package/src/central/backend/index.ts +3 -0
- package/src/central/backend/types.ts +2 -0
- package/src/central/http.ts +116 -32
- package/src/central/mcp-http.ts +116 -0
- package/src/central/push.ts +14 -1
- package/src/central/registry.ts +34 -5
- package/src/central/store.ts +60 -0
- package/src/config.ts +70 -7
- package/src/index.ts +90 -91
- package/src/spoke/launchers.ts +109 -3
- package/src/spoke/spoke.ts +8 -4
- package/src/spoke/timeline.ts +59 -3
- package/src/spoke/tracker.ts +59 -6
- package/src/status.ts +97 -6
- package/web/dist/apple-touch-icon.png +0 -0
- package/web/dist/assets/index-Dm8OmkaO.css +1 -0
- package/web/dist/assets/index-FUJbj_Du.js +20 -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 +5 -5
- package/web/dist/index.html +10 -4
- package/web/dist/manifest.webmanifest +2 -2
- package/web/dist/sw.js +4 -1
- package/web/dist/assets/index-D9zMbxSg.css +0 -1
- package/web/dist/assets/index-db1z4duA.js +0 -20
package/src/config.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Per-machine spoke configuration, shared by every OpenCode process on the machine and edited
|
|
2
2
|
// from the /hub dialog. Holds the machine token, so it is written with mode 0600.
|
|
3
|
-
import {
|
|
3
|
+
import { randomBytes } from "node:crypto"
|
|
4
|
+
import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeSync } from "node:fs"
|
|
4
5
|
import { homedir } from "node:os"
|
|
5
6
|
import path from "node:path"
|
|
6
7
|
import { hubEnv, type Env } from "./env"
|
|
@@ -142,13 +143,75 @@ export function readConfig(file: string, machineName: string): VoiceConfig {
|
|
|
142
143
|
}
|
|
143
144
|
}
|
|
144
145
|
|
|
145
|
-
/**
|
|
146
|
-
|
|
146
|
+
/** The keys VoiceConfig owns in config.json; every other key belongs to someone else and is preserved. */
|
|
147
|
+
const CONFIG_KEYS = ["server", "mode", "sessions", "machineName", "subagent", "autoStartLocalHub", "herdr", "list", "voiceModel", "workModel", "machineID", "token"] as const satisfies readonly (keyof VoiceConfig)[]
|
|
148
|
+
|
|
149
|
+
type RawConfig = Record<string, unknown>
|
|
150
|
+
|
|
151
|
+
const isObject = (v: unknown): v is RawConfig => typeof v === "object" && v !== null && !Array.isArray(v)
|
|
152
|
+
|
|
153
|
+
/** The raw JSON object in `file` (every key, known or not); undefined when the file does not exist. */
|
|
154
|
+
export function readRawConfig(file: string): RawConfig | undefined {
|
|
155
|
+
let text: string
|
|
156
|
+
try {
|
|
157
|
+
text = readFileSync(file, "utf8")
|
|
158
|
+
} catch (cause) {
|
|
159
|
+
if ((cause as NodeJS.ErrnoException).code === "ENOENT") return undefined
|
|
160
|
+
throw cause
|
|
161
|
+
}
|
|
162
|
+
let parsed: unknown
|
|
163
|
+
try {
|
|
164
|
+
parsed = JSON.parse(text)
|
|
165
|
+
} catch {
|
|
166
|
+
parsed = undefined
|
|
167
|
+
}
|
|
168
|
+
if (!isObject(parsed)) throw new Error(`${file} is not a JSON object; fix or remove it first`)
|
|
169
|
+
return parsed
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Writes `content` to `file` atomically (a private temp file in the same directory, fsync, rename), mode 0600. */
|
|
173
|
+
function writePrivate(file: string, content: string): void {
|
|
147
174
|
mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
|
|
148
|
-
const tmp = `${file}.${process.pid}.tmp`
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
175
|
+
const tmp = `${file}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`
|
|
176
|
+
let fd: number | undefined
|
|
177
|
+
try {
|
|
178
|
+
fd = openSync(tmp, "wx", 0o600)
|
|
179
|
+
writeSync(fd, content)
|
|
180
|
+
fsyncSync(fd)
|
|
181
|
+
closeSync(fd)
|
|
182
|
+
fd = undefined
|
|
183
|
+
chmodSync(tmp, 0o600)
|
|
184
|
+
renameSync(tmp, file)
|
|
185
|
+
} catch (cause) {
|
|
186
|
+
if (fd !== undefined) closeSync(fd)
|
|
187
|
+
rmSync(tmp, { force: true })
|
|
188
|
+
throw cause
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const serialize = (raw: RawConfig) => `${JSON.stringify(raw, null, 2)}\n`
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Lossless read-modify-write of config.json: `update` gets the raw object (every key, {} for a missing file) and
|
|
196
|
+
* returns the next one, written atomically with 0600. Refuses (throws, file untouched) when the file is not a JSON
|
|
197
|
+
* object, so a hand-edited config is never replaced.
|
|
198
|
+
*/
|
|
199
|
+
export function updateRawConfig(file: string, update: (raw: RawConfig) => RawConfig): void {
|
|
200
|
+
writePrivate(file, serialize(update(readRawConfig(file) ?? {})))
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Atomic write with 0600 permissions. Lossless: keys config.json holds that VoiceConfig does not know are kept; the
|
|
205
|
+
* known keys come from `config` (a known key it lacks is removed). A file that is not a JSON object is replaced.
|
|
206
|
+
*/
|
|
207
|
+
export function writeConfig(file: string, config: VoiceConfig): void {
|
|
208
|
+
let previous: RawConfig = {}
|
|
209
|
+
try {
|
|
210
|
+
previous = readRawConfig(file) ?? {}
|
|
211
|
+
} catch {}
|
|
212
|
+
const next: RawConfig = { ...previous }
|
|
213
|
+
for (const key of CONFIG_KEYS) delete next[key]
|
|
214
|
+
writePrivate(file, serialize({ ...next, ...config }))
|
|
152
215
|
}
|
|
153
216
|
|
|
154
217
|
/** Changing the server URL drops the machine credentials, which belong to the old server. */
|
package/src/index.ts
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
// console, phones and (optional) Voice speech live on the hub; this side only exposes sessions, forwards
|
|
3
3
|
// their events, and runs remote / voice turns and permission replies on request.
|
|
4
4
|
import { mkdirSync, readFileSync } from "node:fs"
|
|
5
|
-
import { hostname } from "node:os"
|
|
5
|
+
import { homedir, hostname } from "node:os"
|
|
6
6
|
import { fileURLToPath } from "node:url"
|
|
7
|
-
|
|
7
|
+
// Types only: @opencode/plugin's runtime entry loads its whole schema graph (~1 s cold) for an identity define().
|
|
8
|
+
import type { Plugin } from "@opencode/plugin"
|
|
8
9
|
import { DEFAULT_SUBAGENT, prepareConfigPath } from "./config"
|
|
9
10
|
import { HubRpc, VoiceRpc } from "./rpc"
|
|
10
11
|
import { jobFromInfo, type JobInfo } from "./protocol"
|
|
@@ -15,7 +16,7 @@ import { herdrRunner } from "./spoke/herdr"
|
|
|
15
16
|
import { launchSource } from "./spoke/launch-source"
|
|
16
17
|
import { HubRpcError, hubMsg, hubWho } from "./spoke/hub-rpc"
|
|
17
18
|
import { HUB_TOOL, HUB_TOOL_DESCRIPTION, HUB_TOOL_INPUT, runHubTool } from "./spoke/hub-tool"
|
|
18
|
-
import { cliEntry, installLaunchers } from "./spoke/launchers"
|
|
19
|
+
import { cliEntry, installLaunchers, linkUserBin, standaloneHubctl } from "./spoke/launchers"
|
|
19
20
|
import { listServiceSessions } from "./spoke/listing"
|
|
20
21
|
import { ensureLocalHub } from "./spoke/localhub"
|
|
21
22
|
import { nodeConfigPath, readWorkspaceRoots } from "./spoke/node-roots"
|
|
@@ -27,6 +28,18 @@ import { detectKind, Spoke, SpokeError, type FormAction, type SpokeHost } from "
|
|
|
27
28
|
|
|
28
29
|
type Context = Parameters<Parameters<typeof Plugin.define>[0]["setup"]>[0]
|
|
29
30
|
|
|
31
|
+
/** Plugin.define without importing it (it returns its argument). */
|
|
32
|
+
const definePlugin = (plugin: Plugin.Plugin): Plugin.Plugin => plugin
|
|
33
|
+
|
|
34
|
+
/** One registration with OpenCode; a failure is logged and never stops the others. */
|
|
35
|
+
async function attempt(what: string, register: () => Promise<unknown> | undefined): Promise<void> {
|
|
36
|
+
try {
|
|
37
|
+
await register()
|
|
38
|
+
} catch (cause) {
|
|
39
|
+
console.error(`[hub] could not ${what}`, cause)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
30
43
|
const VOICE_STYLE =
|
|
31
44
|
"This message came from a voice conversation and your reply will be read aloud. " +
|
|
32
45
|
"Answer in at most three short sentences of plain speech: no markdown, code blocks, tables, or lists. " +
|
|
@@ -199,25 +212,29 @@ async function registerShellEnv(ctx: Context, identity: (sessionID: string) => {
|
|
|
199
212
|
// The `hub`/`hubctl` launchers run the CLI with this runtime (OpenCode's binary as bun; src/spoke/launchers.ts).
|
|
200
213
|
const bun = typeof process.versions.bun === "string"
|
|
201
214
|
const entry = cliEntry(fileURLToPath(new URL("..", import.meta.url)), bun)
|
|
202
|
-
if (entry)
|
|
203
|
-
|
|
215
|
+
if (entry) {
|
|
216
|
+
installLaunchers({ binDir, execPath: process.execPath, bun, entry, standalone: standaloneHubctl(homedir()) })
|
|
217
|
+
// `hub` in the user's own terminals too (~/.local/bin/hub, never over anything of theirs).
|
|
218
|
+
const { hint } = linkUserBin({ home: homedir(), binDir, path: process.env.PATH })
|
|
219
|
+
if (hint) console.error(`[hub] ${hint}`)
|
|
220
|
+
} else console.error("[hub] no built hub CLI (packages/cli/dist/hub-cli.js): `hub` is not installed")
|
|
204
221
|
} catch (cause) {
|
|
205
222
|
console.error("[hub] could not install the hub CLI launchers", cause)
|
|
206
223
|
}
|
|
207
224
|
const injector = envInjector({ correlator: new ShellCorrelator(), binDir, identity })
|
|
208
225
|
const tool = (ctx as any).tool
|
|
209
226
|
const shell = (ctx as any).shell
|
|
210
|
-
|
|
227
|
+
await attempt("register the shell environment hooks", async () => {
|
|
211
228
|
if (!tool?.hook || !shell?.hook) throw new Error("this OpenCode has no tool/shell hooks")
|
|
212
|
-
await
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
}
|
|
229
|
+
await Promise.all([
|
|
230
|
+
tool.hook("execute.before", (event: any) => injector.toolBefore(event)),
|
|
231
|
+
tool.hook("execute.after", (event: any) => injector.toolAfter(event)),
|
|
232
|
+
shell.hook("create.before", (invocation: any) => injector.shellBefore(invocation)),
|
|
233
|
+
])
|
|
234
|
+
})
|
|
218
235
|
}
|
|
219
236
|
|
|
220
|
-
export default
|
|
237
|
+
export default definePlugin({
|
|
221
238
|
id: "hub",
|
|
222
239
|
async setup(ctx) {
|
|
223
240
|
// An OpenCode started from a session's shell must not act as that session (nor hand its token to its shells).
|
|
@@ -249,82 +266,69 @@ export default Plugin.define({
|
|
|
249
266
|
permissionLocation(ctx),
|
|
250
267
|
)
|
|
251
268
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
})
|
|
269
|
-
|
|
270
|
-
})
|
|
271
|
-
} catch (cause) {
|
|
272
|
-
console.error("[hub] could not register the context hook", cause)
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
try {
|
|
269
|
+
// Every registration is independent: they run together, so OpenCode's plugin activation (bounded at ~5 s on a
|
|
270
|
+
// cold start) waits for about one of them, not their sum.
|
|
271
|
+
const registrations: Promise<void>[] = [
|
|
272
|
+
attempt("register the voice agent", () => ctx.agent.transform((editor) => defineVoiceAgent(editor as any))),
|
|
273
|
+
attempt("register the context hook", () =>
|
|
274
|
+
ctx.session.hook("context", (event) => {
|
|
275
|
+
const spoke = membership.active()
|
|
276
|
+
if (spoke?.isVoiceTurn(event.sessionID)) event.system.push({ type: "text", text: VOICE_STYLE })
|
|
277
|
+
const parts = orchestratorParts({
|
|
278
|
+
agent: event.agent,
|
|
279
|
+
conversation: spoke?.inConversation(event.sessionID) ?? false,
|
|
280
|
+
subagent: spoke?.subagent() ?? DEFAULT_SUBAGENT,
|
|
281
|
+
workModel: spoke?.workModelFor(event.sessionID),
|
|
282
|
+
taskBoard: spoke?.taskBoard(event.sessionID),
|
|
283
|
+
})
|
|
284
|
+
for (const text of parts) event.system.push({ type: "text", text })
|
|
285
|
+
}),
|
|
286
|
+
),
|
|
276
287
|
// The voice agent stops one worker ("cancel the deploy one") without stopping itself or the others.
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
288
|
+
attempt("register the task stop tool", () =>
|
|
289
|
+
(ctx as any).tool?.transform?.((editor: any) =>
|
|
290
|
+
editor.add({
|
|
291
|
+
name: STOP_TOOL,
|
|
292
|
+
description: STOP_TOOL_DESCRIPTION,
|
|
293
|
+
input: {
|
|
294
|
+
type: "object",
|
|
295
|
+
properties: {
|
|
296
|
+
sessionID: { type: "string", description: "The worker's sessionID, from your task board or the subagent tool's result." },
|
|
297
|
+
all: { type: "boolean", description: "true: stop every worker of yours that is still running." },
|
|
298
|
+
},
|
|
299
|
+
additionalProperties: false,
|
|
286
300
|
},
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
)
|
|
298
|
-
} catch (cause) {
|
|
299
|
-
console.error("[hub] could not register the task stop tool", cause)
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
try {
|
|
301
|
+
options: { codemode: false },
|
|
302
|
+
execute: async (value: unknown, context: { sessionID: string }) => {
|
|
303
|
+
const spoke = membership.active()
|
|
304
|
+
if (!spoke) return { content: "The hub is not running in this OpenCode process; nothing was stopped." }
|
|
305
|
+
const { stopped, text } = await spoke.stopWorkers(context.sessionID, (value ?? {}) as Record<string, unknown>)
|
|
306
|
+
return { content: text, metadata: { stopped } }
|
|
307
|
+
},
|
|
308
|
+
}),
|
|
309
|
+
),
|
|
310
|
+
),
|
|
303
311
|
// Agents message and ask other sessions as themselves (the session's own hub token; src/spoke/hub-tool.ts).
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
try {
|
|
312
|
+
attempt("register the hub tool", () =>
|
|
313
|
+
(ctx as any).tool?.transform?.((editor: any) =>
|
|
314
|
+
editor.add({
|
|
315
|
+
name: HUB_TOOL,
|
|
316
|
+
description: HUB_TOOL_DESCRIPTION,
|
|
317
|
+
input: HUB_TOOL_INPUT,
|
|
318
|
+
options: { codemode: false },
|
|
319
|
+
execute: (value: unknown, context: { sessionID: string; signal?: AbortSignal }) =>
|
|
320
|
+
runHubTool(() => membership.active(), value, { sessionID: context.sessionID, ...(context.signal ? { signal: context.signal } : {}) }),
|
|
321
|
+
}),
|
|
322
|
+
),
|
|
323
|
+
),
|
|
319
324
|
// @name / @@role in a prompt get context from the hub as an attached text file; the text is never changed
|
|
320
325
|
// and the harness's own @ meanings win (src/spoke/prompt-refs.ts, design §2.4).
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
await registerShellEnv(ctx, (sessionID) => membership.active()?.shellIdentity(sessionID))
|
|
326
|
+
attempt("register the prompt hook", () => {
|
|
327
|
+
const references = new PromptReferences(() => membership.active())
|
|
328
|
+
return ctx.session.hook("prompt", (event) => references.onPrompt(event))
|
|
329
|
+
}),
|
|
330
|
+
registerShellEnv(ctx, (sessionID) => membership.active()?.shellIdentity(sessionID)),
|
|
331
|
+
]
|
|
328
332
|
|
|
329
333
|
const run = async <T>(rpc: { error: (...args: any[]) => any }, action: (spoke: Spoke) => T | Promise<T>) => {
|
|
330
334
|
const spoke = membership.active()
|
|
@@ -376,13 +380,8 @@ export default Plugin.define({
|
|
|
376
380
|
hubMsg: (value, rpc) => run(rpc, (s) => hubMsg(s, value)),
|
|
377
381
|
} satisfies Parameters<typeof ctx.rpc.register<typeof HubRpc>>[1]
|
|
378
382
|
// "hub", plus the opencode-voice id "voice" for clients built against the old name (see src/rpc.ts).
|
|
379
|
-
for (const definition of [HubRpc, VoiceRpc]) {
|
|
380
|
-
|
|
381
|
-
await ctx.rpc.register(definition, handlers)
|
|
382
|
-
} catch (cause) {
|
|
383
|
-
console.error(`[hub] could not register RPC methods (${definition.id})`, cause)
|
|
384
|
-
}
|
|
385
|
-
}
|
|
383
|
+
for (const definition of [HubRpc, VoiceRpc]) registrations.push(attempt(`register RPC methods (${definition.id})`, () => ctx.rpc.register(definition, handlers)))
|
|
384
|
+
await Promise.all(registrations)
|
|
386
385
|
|
|
387
386
|
return () => membership.leave()
|
|
388
387
|
},
|
package/src/spoke/launchers.ts
CHANGED
|
@@ -3,8 +3,19 @@
|
|
|
3
3
|
// bundle shipped in this package with the runtime running the plugin: OpenCode is a Bun-compiled binary that acts
|
|
4
4
|
// as bun under BUN_BE_BUN=1, so no `bun` (or node) needs to be on PATH. Without Bun the bundle runs on `node`.
|
|
5
5
|
// The CLI deletes BUN_BE_BUN from its own env at start, so nothing it starts inherits it.
|
|
6
|
-
|
|
6
|
+
//
|
|
7
|
+
// A standalone hubctl at user-bin:hubctl (~/.local/bin/hubctl, what `hubctl setup` installs) wins: the launchers exec
|
|
8
|
+
// it when it is there (checked at run time), so shells, services and MCP clients all run that one stable authority
|
|
9
|
+
// rather than the bundle in the plugin's install (cache) directory.
|
|
10
|
+
//
|
|
11
|
+
// For the user's own terminals (a plugin-only install, where `hubctl setup` never ran), linkUserBin links
|
|
12
|
+
// ~/.local/bin/hub to the `hub` launcher under the setup's user-bin rule (packages/cli/src/setup/user-bin.ts): only
|
|
13
|
+
// when nothing is there or it is hub's own link, and never when another `hub` is on PATH. ~/.local/bin/hubctl is left
|
|
14
|
+
// to `hubctl setup`: it is the standalone hubctl's install path (a link there would make that install refuse to write
|
|
15
|
+
// through it, and the launchers would exec themselves).
|
|
16
|
+
import { accessSync, chmodSync, closeSync, constants, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readlinkSync, readSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"
|
|
7
17
|
import path from "node:path"
|
|
18
|
+
import { userBinLinkDecision, type UserBinEntry } from "../../packages/cli/src/setup/user-bin.ts"
|
|
8
19
|
|
|
9
20
|
export const LAUNCHERS = ["hub", "hubctl"] as const
|
|
10
21
|
|
|
@@ -20,14 +31,22 @@ export interface LauncherOptions {
|
|
|
20
31
|
readonly bun: boolean
|
|
21
32
|
/** The CLI entry the runtime runs. */
|
|
22
33
|
readonly entry: string
|
|
34
|
+
/** The standalone hubctl to prefer when it exists (standaloneHubctl(home)). */
|
|
35
|
+
readonly standalone?: string
|
|
23
36
|
}
|
|
24
37
|
|
|
38
|
+
/** Where `hubctl setup` installs the standalone hubctl (contract user-bin:hubctl). */
|
|
39
|
+
export const standaloneHubctl = (home: string): string => path.join(home, ".local", "bin", "hubctl")
|
|
40
|
+
|
|
25
41
|
/** A word quoted for sh. */
|
|
26
42
|
const quote = (word: string) => `'${word.replaceAll("'", `'\\''`)}'`
|
|
27
43
|
|
|
28
|
-
|
|
44
|
+
const LAUNCHER_HEADER = "#!/bin/sh\n# hub CLI launcher"
|
|
45
|
+
|
|
46
|
+
export function launcherScript({ execPath, bun, entry, standalone }: LauncherOptions): string {
|
|
29
47
|
const command = bun ? `exec env BUN_BE_BUN=1 ${quote(execPath)} ${quote(entry)} "$@"` : `exec node ${quote(entry)} "$@"`
|
|
30
|
-
|
|
48
|
+
const prefer = standalone ? `if [ -x ${quote(standalone)} ]; then exec ${quote(standalone)} "$@"; fi\n` : ""
|
|
49
|
+
return `${LAUNCHER_HEADER}, written by the hub OpenCode plugin at start (edits are overwritten).\n${prefer}${command}\n`
|
|
31
50
|
}
|
|
32
51
|
|
|
33
52
|
/** The entry to run from the package at `root`: the bundle, else (Bun only) the TypeScript source. */
|
|
@@ -66,3 +85,90 @@ export function installLaunchers(options: LauncherOptions & { readonly binDir: s
|
|
|
66
85
|
}
|
|
67
86
|
return { written }
|
|
68
87
|
}
|
|
88
|
+
|
|
89
|
+
/** The user's own bin dir (contract root user-bin). */
|
|
90
|
+
export const userBinDir = (home: string): string => path.join(home, ".local", "bin")
|
|
91
|
+
|
|
92
|
+
function entryOf(file: string): UserBinEntry {
|
|
93
|
+
try {
|
|
94
|
+
const info = lstatSync(file)
|
|
95
|
+
return info.isSymbolicLink() ? "symlink" : info.isFile() ? "file" : info.isDirectory() ? "directory" : "other"
|
|
96
|
+
} catch {
|
|
97
|
+
return "missing"
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A file that starts like the launchers hub writes. */
|
|
102
|
+
function isLauncher(file: string): boolean {
|
|
103
|
+
let fd: number | undefined
|
|
104
|
+
try {
|
|
105
|
+
fd = openSync(file, "r")
|
|
106
|
+
const buffer = Buffer.alloc(LAUNCHER_HEADER.length)
|
|
107
|
+
return readSync(fd, buffer, 0, buffer.length, 0) === buffer.length && buffer.toString("utf8") === LAUNCHER_HEADER
|
|
108
|
+
} catch {
|
|
109
|
+
return false
|
|
110
|
+
} finally {
|
|
111
|
+
if (fd !== undefined) closeSync(fd)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The first executable `name` on `searchPath`, outside `skip`, as found (not followed). */
|
|
116
|
+
function onPath(name: string, searchPath: string, skip: string): string | undefined {
|
|
117
|
+
for (const dir of searchPath.split(path.delimiter)) {
|
|
118
|
+
if (!dir || !path.isAbsolute(dir) || path.resolve(dir) === path.resolve(skip)) continue
|
|
119
|
+
const file = path.join(dir, name)
|
|
120
|
+
try {
|
|
121
|
+
if (!statSync(file).isFile()) continue
|
|
122
|
+
accessSync(file, constants.X_OK)
|
|
123
|
+
return file
|
|
124
|
+
} catch {}
|
|
125
|
+
}
|
|
126
|
+
return undefined
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Links ~/.local/bin/hub to `binDir`/hub (see the header). Returns what it linked, and one hint when the user's
|
|
131
|
+
* terminal will not find it (~/.local/bin not on `path`, or another `hub` comes first). Small synchronous calls,
|
|
132
|
+
* nothing when converged (the plugin runs this at start).
|
|
133
|
+
*/
|
|
134
|
+
export function linkUserBin(options: { readonly home: string; readonly binDir: string; readonly path: string | undefined }): { linked: string[]; hint?: string } {
|
|
135
|
+
// Only the default hub bin dir (~/.local/state/hub/bin): a custom HUB_STATE_DIR (tests, containers, a throwaway
|
|
136
|
+
// state) must never leave ~/.local/bin pointing into it.
|
|
137
|
+
if (path.resolve(options.binDir) !== path.join(options.home, ".local", "state", "hub", "bin")) return { linked: [] }
|
|
138
|
+
const userBin = userBinDir(options.home)
|
|
139
|
+
const linkPath = path.join(userBin, "hub")
|
|
140
|
+
const target = path.join(options.binDir, "hub")
|
|
141
|
+
const entry = entryOf(linkPath)
|
|
142
|
+
let pointsTo: string | undefined
|
|
143
|
+
if (entry === "symlink") {
|
|
144
|
+
try {
|
|
145
|
+
pointsTo = path.resolve(userBin, readlinkSync(linkPath))
|
|
146
|
+
} catch {}
|
|
147
|
+
}
|
|
148
|
+
const searchPath = options.path ?? ""
|
|
149
|
+
const onUserPath = searchPath.split(path.delimiter).some((dir) => dir && path.resolve(dir) === path.resolve(userBin))
|
|
150
|
+
const pathHint = onUserPath ? undefined : `${userBin} is not on PATH: add it to use \`hub\` in your terminal (export PATH="$HOME/.local/bin:$PATH")`
|
|
151
|
+
// `hubctl setup` linked hub to the standalone hubctl: that is the better hub, keep it.
|
|
152
|
+
if (pointsTo === standaloneHubctl(options.home)) return { linked: [], ...(pathHint ? { hint: pathHint } : {}) }
|
|
153
|
+
const foundOnPath = onPath("hub", searchPath, options.binDir)
|
|
154
|
+
const decision = userBinLinkDecision({ entry, ...(pointsTo !== undefined ? { pointsTo } : {}), target, linkPath, hubOwned: isLauncher, ...(foundOnPath !== undefined ? { foundOnPath } : {}) })
|
|
155
|
+
if (decision === "foreign") return { linked: [] }
|
|
156
|
+
if (decision === "shadowed") return { linked: [], hint: `\`hub\` on your PATH is ${foundOnPath}, not hub's CLI; hub's is ${target}` }
|
|
157
|
+
if (decision === "in-place") return { linked: [], ...(pathHint ? { hint: pathHint } : {}) }
|
|
158
|
+
mkdirSync(userBin, { recursive: true, mode: 0o755 })
|
|
159
|
+
// A new link never replaces what may have appeared meanwhile (EEXIST); hub's own is re-pointed atomically.
|
|
160
|
+
if (decision === "create") {
|
|
161
|
+
symlinkSync(target, linkPath)
|
|
162
|
+
return { linked: ["hub"], ...(pathHint ? { hint: pathHint } : {}) }
|
|
163
|
+
}
|
|
164
|
+
const tmp = `${linkPath}.${process.pid}.tmp`
|
|
165
|
+
rmSync(tmp, { force: true })
|
|
166
|
+
symlinkSync(target, tmp)
|
|
167
|
+
try {
|
|
168
|
+
renameSync(tmp, linkPath)
|
|
169
|
+
} catch (cause) {
|
|
170
|
+
rmSync(tmp, { force: true })
|
|
171
|
+
throw cause
|
|
172
|
+
}
|
|
173
|
+
return { linked: ["hub"], ...(pathHint ? { hint: pathHint } : {}) }
|
|
174
|
+
}
|
package/src/spoke/spoke.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
type DeliverRequest,
|
|
16
16
|
type DeliverResult,
|
|
17
17
|
type DeliveryMode,
|
|
18
|
+
type CompactSessionStatus,
|
|
18
19
|
type JobInfo,
|
|
19
20
|
type HerdrWorkspace,
|
|
20
21
|
type FormAnswer,
|
|
@@ -1571,7 +1572,7 @@ export class Spoke {
|
|
|
1571
1572
|
try {
|
|
1572
1573
|
const snapshot = await activity.call(this.host, this.tracker.directories(SEED_DIRECTORIES))
|
|
1573
1574
|
const active = new Set(snapshot.active)
|
|
1574
|
-
const ids = new Set([...snapshot.active, ...snapshot.jobs.map((j) => j.sessionID), ...this.tracker.working()])
|
|
1575
|
+
const ids = new Set([...snapshot.active, ...snapshot.jobs.map((j) => j.sessionID), ...this.tracker.working(), ...this.tracker.failed()])
|
|
1575
1576
|
const now = Date.now()
|
|
1576
1577
|
for (const id of ids) {
|
|
1577
1578
|
if (active.has(id)) this.tracker.ensure(id, now)
|
|
@@ -1580,9 +1581,12 @@ export class Spoke {
|
|
|
1580
1581
|
.filter((j) => j.sessionID === id && j.status === "running")
|
|
1581
1582
|
.slice(0, SEED_JOBS_MAX)
|
|
1582
1583
|
.map(({ sessionID: _, ...job }) => job)
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
const
|
|
1584
|
+
// An active session waiting to retry is seeded with its retry (attempt, reason, next try); the last failure
|
|
1585
|
+
// (no run started since) goes along so a reconnected hub still shows it.
|
|
1586
|
+
const status: CompactSessionStatus = active.has(id) ? (this.tracker.retryOf(id) ?? { type: "busy" }) : { type: "idle" }
|
|
1587
|
+
const error = this.tracker.errorOf(id)
|
|
1588
|
+
this.tracker.seed(id, { status: status.type, jobs })
|
|
1589
|
+
const event = { type: "session.seed", data: { sessionID: id, status, jobs, ...(error ? { error } : {}) } }
|
|
1586
1590
|
if (this.connection?.state === "connected" && shouldForward(this.config, this.tracker, event)) this.connection.send({ t: "event", event })
|
|
1587
1591
|
}
|
|
1588
1592
|
void this.fetchInfo()
|
package/src/spoke/timeline.ts
CHANGED
|
@@ -14,7 +14,8 @@ export interface TimelineItem {
|
|
|
14
14
|
readonly partID?: string
|
|
15
15
|
readonly sessionID: string
|
|
16
16
|
/** "message": a hub message delivered to the session (TimelineMessageItem). */
|
|
17
|
-
|
|
17
|
+
/** "error": the turn failed; "retry": the provider call is being retried (until the message completes or fails). */
|
|
18
|
+
readonly kind: "user" | "assistant" | "reasoning" | "tool" | "message" | "error" | "retry"
|
|
18
19
|
readonly text?: string
|
|
19
20
|
readonly name?: string
|
|
20
21
|
readonly state?: "running" | "done" | "failed"
|
|
@@ -39,12 +40,20 @@ export interface TimelineItem {
|
|
|
39
40
|
readonly direction?: TimelineMessageItem["direction"]
|
|
40
41
|
/** Message rows: who sent it, how it was addressed, what it answers. */
|
|
41
42
|
readonly message?: TimelineMessageItem["message"]
|
|
43
|
+
/** Error and retry rows: the error's type (bounded) and HTTP status (100–599). */
|
|
44
|
+
readonly errorType?: string
|
|
45
|
+
readonly errorStatus?: number
|
|
46
|
+
/** Retry rows: the attempt number and when the next attempt starts (epoch ms). */
|
|
47
|
+
readonly attempt?: number
|
|
48
|
+
readonly next?: number
|
|
42
49
|
}
|
|
43
50
|
|
|
44
51
|
/** Parts of one message a position leaves room for (later parts share the last slot). */
|
|
45
52
|
const POS_PARTS = 1000
|
|
46
53
|
const SUBAGENT_TOOLS = new Set(["subagent", "task"])
|
|
47
54
|
const TITLE_MAX = 160
|
|
55
|
+
const ERROR_TEXT_MAX = 1000
|
|
56
|
+
const ERROR_TYPE_MAX = 64
|
|
48
57
|
|
|
49
58
|
const record = (value: unknown): Record<string, any> => value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, any> : {}
|
|
50
59
|
|
|
@@ -55,7 +64,7 @@ export function timelineItems(sessionID: string, messages: readonly unknown[]):
|
|
|
55
64
|
for (const raw of messages) {
|
|
56
65
|
const message = record(raw)
|
|
57
66
|
if (typeof message.id !== "string") continue
|
|
58
|
-
const at =
|
|
67
|
+
const at = millis(message.time?.created)
|
|
59
68
|
// Messages keep the session's order even when one was created earlier than the one before it (a queued prompt).
|
|
60
69
|
const start = Math.max(at === undefined ? -Infinity : at * POS_PARTS, previous + POS_PARTS)
|
|
61
70
|
const first = (previous = Number.isFinite(start) ? start : 0)
|
|
@@ -71,7 +80,12 @@ export function timelineItems(sessionID: string, messages: readonly unknown[]):
|
|
|
71
80
|
if (row) out.push(row)
|
|
72
81
|
continue
|
|
73
82
|
}
|
|
74
|
-
if (message.type !== "assistant"
|
|
83
|
+
if (message.type !== "assistant") continue
|
|
84
|
+
const status = statusRow(sessionID, message, at, pos(Array.isArray(message.content) ? message.content.length : 0))
|
|
85
|
+
if (!Array.isArray(message.content)) {
|
|
86
|
+
if (status) out.push(status)
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
75
89
|
let texts = 0
|
|
76
90
|
message.content.forEach((part: any, index: number) => {
|
|
77
91
|
if (!part || typeof part !== "object") return
|
|
@@ -98,10 +112,52 @@ export function timelineItems(sessionID: string, messages: readonly unknown[]):
|
|
|
98
112
|
})
|
|
99
113
|
}
|
|
100
114
|
})
|
|
115
|
+
if (status) out.push(status)
|
|
101
116
|
}
|
|
102
117
|
return out
|
|
103
118
|
}
|
|
104
119
|
|
|
120
|
+
/** Epoch milliseconds of an OpenCode timestamp: a number (ms) or an ISO date string. */
|
|
121
|
+
function millis(value: unknown): number | undefined {
|
|
122
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : undefined
|
|
123
|
+
if (typeof value !== "string") return undefined
|
|
124
|
+
const ms = Date.parse(value)
|
|
125
|
+
return Number.isNaN(ms) ? undefined : ms
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The bounded type and HTTP status of an OpenCode error, and its trimmed message (else `fallback`). */
|
|
129
|
+
function errorFields(raw: unknown, fallback: string): { text: string; errorType?: string; errorStatus?: number } {
|
|
130
|
+
const error = record(raw)
|
|
131
|
+
const message = typeof error.message === "string" ? error.message.trim().slice(0, ERROR_TEXT_MAX) : ""
|
|
132
|
+
const type = typeof error.type === "string" ? error.type.trim().slice(0, ERROR_TYPE_MAX) : ""
|
|
133
|
+
const status = error.status
|
|
134
|
+
return {
|
|
135
|
+
text: message || fallback,
|
|
136
|
+
...(type ? { errorType: type } : {}),
|
|
137
|
+
...(Number.isInteger(status) && status >= 100 && status <= 599 ? { errorStatus: status } : {}),
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The status row of an assistant message, after its parts: "error" when the turn failed, "retry" while the
|
|
143
|
+
* provider call is being retried (no error yet and not completed), else none.
|
|
144
|
+
*/
|
|
145
|
+
function statusRow(sessionID: string, message: Record<string, any>, at: number | undefined, pos: number): TimelineItem | undefined {
|
|
146
|
+
const base = { messageID: message.id as string, sessionID, ...(at === undefined ? {} : { at }), pos }
|
|
147
|
+
if (message.error != null) return { key: `${message.id}:error`, kind: "error", ...errorFields(message.error, "failed"), ...base }
|
|
148
|
+
if (message.retry == null || message.time?.completed != null) return undefined
|
|
149
|
+
const retry = record(message.retry)
|
|
150
|
+
const next = millis(retry.at)
|
|
151
|
+
return {
|
|
152
|
+
key: `${message.id}:retry`,
|
|
153
|
+
kind: "retry",
|
|
154
|
+
...errorFields(retry.error, "retrying"),
|
|
155
|
+
...(Number.isInteger(retry.attempt) && retry.attempt >= 0 ? { attempt: retry.attempt } : {}),
|
|
156
|
+
...(next === undefined ? {} : { next }),
|
|
157
|
+
...base,
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
105
161
|
/** A message the hub delivered (synthetic, with deliverMetadata): a "message" row; other synthetic messages: none. */
|
|
106
162
|
function hubMessageRow(sessionID: string, message: Record<string, any>, at: number | undefined, pos: number): TimelineMessageItem | undefined {
|
|
107
163
|
const meta = record(message.metadata)
|