@huynle/hub 0.6.1 → 0.7.2
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 +870 -0
- package/Dockerfile +33 -0
- package/README.md +1007 -66
- package/package.json +47 -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 +156 -0
- package/packages/cli/dist/hub-cli.js +21647 -0
- 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 +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 +123 -0
- package/packages/codex/src/paths.ts +65 -0
- package/packages/codex/src/sessions.ts +578 -0
- package/packages/codex/src/spoke.ts +252 -0
- package/packages/codex/src/stdio.ts +101 -0
- package/packages/codex/src/websocket.ts +206 -0
- package/packages/codex/src/wrapper.ts +104 -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 +95 -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 +553 -0
- package/packages/sdk/src/envelope.ts +108 -0
- package/packages/sdk/src/errors.ts +107 -0
- package/packages/sdk/src/index.ts +10 -0
- package/packages/sdk/src/sse.ts +70 -0
- package/packages/sdk/src/types.ts +517 -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 +101 -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 +152 -0
- package/src/central/agent/archive-inactive.ts +66 -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 +328 -0
- package/src/central/agent/directory.ts +473 -0
- package/src/central/agent/envelope.ts +51 -0
- package/src/central/agent/errors.ts +74 -0
- package/src/central/agent/escalation.ts +219 -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/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 +385 -0
- package/src/central/agent/routes.ts +281 -0
- package/src/central/agent/session-tokens.ts +227 -0
- package/src/central/agent/versioned.ts +26 -0
- package/src/central/agent/watch.ts +112 -0
- package/src/central/agent/webhook-routes.ts +86 -0
- package/src/central/agent/webhooks.ts +493 -0
- package/src/central/agent-data.ts +177 -0
- package/src/central/auth.ts +350 -121
- package/src/central/backend/file-state.ts +240 -0
- package/src/central/backend/index.ts +116 -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 +246 -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 +1149 -170
- package/src/central/import.ts +318 -0
- package/src/central/main.ts +211 -12
- package/src/central/mcp-http.ts +116 -0
- 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 +625 -189
- package/src/central/token-keys.ts +165 -0
- package/src/config.ts +118 -7
- package/src/index.ts +195 -43
- 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 +174 -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/apple-touch-icon.png +0 -0
- package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
- package/web/dist/assets/index-DWnMhXk9.js +20 -0
- package/web/dist/assets/index-DmUXEnw9.css +1 -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/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 +17 -4
- package/web/dist/manifest.webmanifest +2 -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,1215 @@
|
|
|
1
|
+
// Central server ↔ spoke protocol, version 1. JSON text frames over a WebSocket at /spoke/ws.
|
|
2
|
+
// A spoke (a harness plugin; OpenCode first) connects outbound with `Authorization: Bearer <machineToken>`.
|
|
3
|
+
// Every frame is one object with a `t` discriminator. Unknown or malformed frames are rejected.
|
|
4
|
+
//
|
|
5
|
+
// Core and features: the core ops (CORE_OPS: turn, steer, interrupt, stop, permission, form) and the optional
|
|
6
|
+
// features a spoke announces in its hello (SPOKE_FEATURES); the hub uses only what a spoke serves (spokeFeatures).
|
|
7
|
+
// An OpenCode spoke (a hello without `harness` or with harness "opencode") serves every core op without announcing
|
|
8
|
+
// it; any other harness announces the core ops it serves, like any feature. A hello without `harness` comes from an
|
|
9
|
+
// OpenCode spoke that predates this split and also implies IMPLIED_OPENCODE_FEATURES. The hub announces its own features in the welcome (HUB_FEATURES), and a spoke
|
|
10
|
+
// sends a newer request only when the hub listed it. Neither side bumps PROTOCOL_VERSION for a new feature.
|
|
11
|
+
//
|
|
12
|
+
// Modules: harness-events.ts (the forwarded events a non-OpenCode spoke may send, with their shapes), launch.ts
|
|
13
|
+
// (launch vocabulary and workspace checks), node.ts (the `hub node` hello, frames and ops on the same socket),
|
|
14
|
+
// preset.ts (the harness preset schema), webhook.ts (webhook signature format), wire.ts (shared frame primitives).
|
|
15
|
+
// All are re-exported here.
|
|
16
|
+
|
|
17
|
+
import { featureList, HUB_URL_MAX_CHARS, ID, isByeFrame as isBye, isHubURL, isObj, isResFrame as isRes, isWelcomeFrame, MAX_FRAME, MAX_RETRY_AFTER_MS, nonEmpty, num, parseFrame as parse, str, token, UNSAFE_CHARS } from "./wire.ts"
|
|
18
|
+
import type { NodeMessage, NodeServerMessage } from "./node.ts"
|
|
19
|
+
|
|
20
|
+
export * from "./harness-events.ts"
|
|
21
|
+
export * from "./launch.ts"
|
|
22
|
+
export * from "./node.ts"
|
|
23
|
+
export * from "./preset.ts"
|
|
24
|
+
export * from "./webhook.ts"
|
|
25
|
+
export { HUB_URL_MAX_CHARS, isHubURL, MAX_FRAME, MAX_RETRY_AFTER_MS }
|
|
26
|
+
|
|
27
|
+
export const PROTOCOL_VERSION = 1
|
|
28
|
+
|
|
29
|
+
export type SessionState = "idle" | "working" | "blocked" | "error"
|
|
30
|
+
export type ProcessKind = "service" | "standalone"
|
|
31
|
+
export type SpokeMode = "always" | "session"
|
|
32
|
+
export type PermissionDecision = "once" | "always" | "reject"
|
|
33
|
+
|
|
34
|
+
/** An answer to an OpenCode form (question): field key → value, like OpenCode's FormAnswer. */
|
|
35
|
+
export type FormAnswer = Readonly<Record<string, string | number | boolean | readonly string[]>>
|
|
36
|
+
|
|
37
|
+
export const FORM_ANSWER_MAX_KEYS = 50
|
|
38
|
+
export const FORM_ANSWER_MAX_CHARS = 20_000
|
|
39
|
+
export const FORM_ANSWER_MAX_ITEMS = 100
|
|
40
|
+
|
|
41
|
+
/** Why an answer object is not acceptable, or undefined when it is. */
|
|
42
|
+
export function formAnswerProblem(v: unknown): string | undefined {
|
|
43
|
+
if (typeof v !== "object" || v === null || Array.isArray(v)) return "answer must be an object"
|
|
44
|
+
const entries = Object.entries(v)
|
|
45
|
+
if (entries.length > FORM_ANSWER_MAX_KEYS) return `answer has more than ${FORM_ANSWER_MAX_KEYS} fields`
|
|
46
|
+
for (const [key, value] of entries) {
|
|
47
|
+
if (!key || key.length > 300) return "answer keys must be 1–300 characters"
|
|
48
|
+
if (typeof value === "string" && value.length <= FORM_ANSWER_MAX_CHARS) continue
|
|
49
|
+
if (typeof value === "boolean" || (typeof value === "number" && Number.isFinite(value))) continue
|
|
50
|
+
if (
|
|
51
|
+
Array.isArray(value) &&
|
|
52
|
+
value.length <= FORM_ANSWER_MAX_ITEMS &&
|
|
53
|
+
value.every((item) => typeof item === "string" && item.length <= FORM_ANSWER_MAX_CHARS)
|
|
54
|
+
) continue
|
|
55
|
+
return `answer.${key} must be text, a number, true/false or a list of texts`
|
|
56
|
+
}
|
|
57
|
+
return undefined
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface ModelSelection {
|
|
61
|
+
readonly providerID: string
|
|
62
|
+
/** OpenCode's catalog id of the model (Model.Info `id`, what switchModel takes), not its upstream `modelID`. */
|
|
63
|
+
readonly modelID: string
|
|
64
|
+
readonly variant?: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface SessionModelPrefs {
|
|
68
|
+
readonly voice?: ModelSelection
|
|
69
|
+
readonly worker?: ModelSelection
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface AvailableModel {
|
|
73
|
+
readonly providerID: string
|
|
74
|
+
readonly modelID: string
|
|
75
|
+
readonly name: string
|
|
76
|
+
readonly enabled: boolean
|
|
77
|
+
readonly status: string
|
|
78
|
+
readonly variants: readonly string[]
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A timeline page. Forward mode (`cursor`): oldest rows first, `cursor` for the next newer page. Newest-first
|
|
83
|
+
* mode (`order: "desc"` or `before`): the newest rows (before `before`) in chronological order, `older` for the
|
|
84
|
+
* page before them. Both cursors are opaque. Items are the spoke's rows (src/spoke/timeline.ts TimelineItem); newer
|
|
85
|
+
* spokes add optional `pos` (ordering position), `callID`, `childSessionID`, `title` and `ordinal`.
|
|
86
|
+
*/
|
|
87
|
+
export interface TimelinePage {
|
|
88
|
+
readonly items: readonly unknown[]
|
|
89
|
+
readonly cursor?: string
|
|
90
|
+
readonly older?: string
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Timeline row kinds. "message": a hub message (src/spoke/timeline.ts has the others). */
|
|
94
|
+
export const TIMELINE_KINDS = ["user", "assistant", "reasoning", "tool", "message"] as const
|
|
95
|
+
export type TimelineKind = (typeof TIMELINE_KINDS)[number]
|
|
96
|
+
|
|
97
|
+
/** A message row's direction from the session's point of view: delivered to it (in) or sent by it (out). */
|
|
98
|
+
export const MESSAGE_DIRECTIONS = ["in", "out"] as const
|
|
99
|
+
export type MessageDirection = (typeof MESSAGE_DIRECTIONS)[number]
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A hub message as a timeline row. Inbound rows come from the spoke's timeline (a message the hub delivered,
|
|
103
|
+
* `messageID` the deliver messageID); outbound rows come from the hub's message store. `text` is the body,
|
|
104
|
+
* without the envelope.
|
|
105
|
+
*/
|
|
106
|
+
export interface TimelineMessageItem {
|
|
107
|
+
readonly key: string
|
|
108
|
+
readonly messageID: string
|
|
109
|
+
readonly sessionID: string
|
|
110
|
+
readonly kind: "message"
|
|
111
|
+
readonly text: string
|
|
112
|
+
readonly at?: number
|
|
113
|
+
/** Ordering position, as for the other rows. */
|
|
114
|
+
readonly pos?: number
|
|
115
|
+
readonly direction: MessageDirection
|
|
116
|
+
readonly message: Pick<DeliverMeta, "kind" | "from" | "to" | "replyTo">
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface SessionInfo {
|
|
120
|
+
readonly id: string
|
|
121
|
+
readonly title: string
|
|
122
|
+
readonly directory: string
|
|
123
|
+
readonly parentID?: string
|
|
124
|
+
/** ms epoch of last update */
|
|
125
|
+
readonly updated: number
|
|
126
|
+
/** True when this process currently owns the session: it viewed or executed it most recently. */
|
|
127
|
+
readonly owned: boolean
|
|
128
|
+
/**
|
|
129
|
+
* Whether voice is enabled for this session (always true in mode "always"). Sessions with
|
|
130
|
+
* exposed:false are listed for discovery only: no events are forwarded and no ops run on them.
|
|
131
|
+
*/
|
|
132
|
+
readonly exposed?: boolean
|
|
133
|
+
/** Workspace (project) root the session belongs to; differs from directory for worktrees/subfolders. */
|
|
134
|
+
readonly project?: string
|
|
135
|
+
readonly projectName?: string
|
|
136
|
+
readonly archived?: boolean
|
|
137
|
+
/** Open in a terminal right now (a Herdr pane or an OpenCode TUI showing it). */
|
|
138
|
+
readonly open?: boolean
|
|
139
|
+
readonly terminal?: TerminalInfo
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface TerminalInfo {
|
|
143
|
+
/** Where "open" was detected. */
|
|
144
|
+
readonly source: "herdr" | "tui"
|
|
145
|
+
/** Herdr workspace ID and label (e.g. "research"), when open in Herdr. */
|
|
146
|
+
readonly workspaceID?: string
|
|
147
|
+
readonly workspace?: string
|
|
148
|
+
/** Pane/terminal title, e.g. "OC | Fix the login redirect". */
|
|
149
|
+
readonly title?: string
|
|
150
|
+
/** Herdr's agent status for the pane. */
|
|
151
|
+
readonly status?: "idle" | "working" | "blocked" | "done" | "unknown"
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** A Herdr workspace on this machine (a target for new sessions). */
|
|
155
|
+
export interface HerdrWorkspace {
|
|
156
|
+
readonly id: string
|
|
157
|
+
readonly label: string
|
|
158
|
+
/** Folders of the panes in this workspace, most used first (lets the phone suggest a directory). */
|
|
159
|
+
readonly directories: readonly string[]
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Machine-level terminal context, sent by one spoke per machine (the service spoke, else a standalone). */
|
|
163
|
+
export interface Terminals {
|
|
164
|
+
readonly t: "terminals"
|
|
165
|
+
readonly herdr?: { readonly workspaces: readonly HerdrWorkspace[] }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Subset of OpenCode events the spoke forwards (see FORWARDED_EVENTS; HARNESS_EVENTS for other harnesses). */
|
|
169
|
+
export interface ForwardedEvent {
|
|
170
|
+
readonly type: string
|
|
171
|
+
readonly data: Record<string, unknown>
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export const FORWARDED_EVENTS: ReadonlySet<string> = new Set([
|
|
175
|
+
"session.created",
|
|
176
|
+
"session.renamed",
|
|
177
|
+
"session.deleted",
|
|
178
|
+
"session.viewed",
|
|
179
|
+
"session.execution.started",
|
|
180
|
+
"session.execution.succeeded",
|
|
181
|
+
"session.execution.failed",
|
|
182
|
+
"session.execution.interrupted",
|
|
183
|
+
"session.text.delta",
|
|
184
|
+
"session.text.ended",
|
|
185
|
+
// data: InboxDeliveredData. Newer spokes add `origin` (who put the prompt in the inbox); the type is unchanged,
|
|
186
|
+
// so older hubs keep accepting it.
|
|
187
|
+
"session.inbox.delivered",
|
|
188
|
+
"session.agent.selected",
|
|
189
|
+
// Activity feed. The spoke sends these in compact form (see compactActivity): tool inputs are
|
|
190
|
+
// reduced to a short summary and results to ok/error, never full file contents or outputs.
|
|
191
|
+
"session.tool.input.started",
|
|
192
|
+
"session.tool.called",
|
|
193
|
+
"session.tool.success",
|
|
194
|
+
"session.tool.failed",
|
|
195
|
+
"session.reasoning.started",
|
|
196
|
+
// Reasoning text streams to the phone's "Thinking" block (deltas capped at REASONING_DELTA_MAX); the
|
|
197
|
+
// ended event's full text is dropped.
|
|
198
|
+
"session.reasoning.delta",
|
|
199
|
+
"session.reasoning.ended",
|
|
200
|
+
"permission.asked",
|
|
201
|
+
"permission.replied",
|
|
202
|
+
"form.created",
|
|
203
|
+
"form.replied",
|
|
204
|
+
"form.cancelled",
|
|
205
|
+
// Running detection (compact form, see compactActivity): OpenCode's session status (busy/retry/idle) and the
|
|
206
|
+
// shells and terminals a session started, with the command (≤ JOB_COMMAND_MAX), status and exit code only.
|
|
207
|
+
"session.status",
|
|
208
|
+
"session.shell.started",
|
|
209
|
+
"session.shell.ended",
|
|
210
|
+
"shell.created",
|
|
211
|
+
"shell.exited",
|
|
212
|
+
"shell.deleted",
|
|
213
|
+
"pty.created",
|
|
214
|
+
"pty.updated",
|
|
215
|
+
"pty.exited",
|
|
216
|
+
"pty.deleted",
|
|
217
|
+
// Hub-defined: a session's status and running jobs as the spoke found them (startup, stream reconnect).
|
|
218
|
+
"session.seed",
|
|
219
|
+
])
|
|
220
|
+
|
|
221
|
+
/** Who put a prompt into a session's inbox: the user (a typed or voice prompt) or the hub (a delivered message). */
|
|
222
|
+
export const INBOX_ORIGINS = ["user", "hub"] as const
|
|
223
|
+
export type InboxOrigin = (typeof INBOX_ORIGINS)[number]
|
|
224
|
+
|
|
225
|
+
/** `session.inbox.delivered` data as forwarded. `origin` is optional: older spokes do not send it. */
|
|
226
|
+
export interface InboxDeliveredData {
|
|
227
|
+
readonly sessionID: string
|
|
228
|
+
readonly inboxID: string
|
|
229
|
+
readonly origin?: InboxOrigin
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* OpenCode events the spoke forwards only in another (compact) form: a subagent tool's progress, which names the
|
|
234
|
+
* child session, goes out as a `session.tool.called` with `childSessionID` (a type every hub accepts).
|
|
235
|
+
*/
|
|
236
|
+
export const COMPACTED_EVENTS: ReadonlySet<string> = new Set(["session.tool.progress"])
|
|
237
|
+
|
|
238
|
+
/** Longest job command forwarded. */
|
|
239
|
+
export const JOB_COMMAND_MAX = 200
|
|
240
|
+
/** Longest retry reason forwarded. */
|
|
241
|
+
export const RETRY_MESSAGE_MAX = 200
|
|
242
|
+
/** Most running jobs one seed event carries. */
|
|
243
|
+
export const SEED_JOBS_MAX = 20
|
|
244
|
+
|
|
245
|
+
export type JobKind = "shell" | "pty"
|
|
246
|
+
export type JobStatus = "running" | "exited" | "timeout" | "killed"
|
|
247
|
+
|
|
248
|
+
/** A shell or terminal a session started, as forwarded (no output, no environment). */
|
|
249
|
+
export interface JobInfo {
|
|
250
|
+
readonly id: string
|
|
251
|
+
readonly kind: JobKind
|
|
252
|
+
readonly command?: string
|
|
253
|
+
readonly status: JobStatus
|
|
254
|
+
readonly exit?: number
|
|
255
|
+
readonly background?: true
|
|
256
|
+
readonly started?: number
|
|
257
|
+
readonly ended?: number
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** OpenCode's session status, compact. */
|
|
261
|
+
export type CompactSessionStatus = { readonly type: "idle" | "busy" } | { readonly type: "retry"; readonly attempt?: number; readonly message?: string }
|
|
262
|
+
|
|
263
|
+
// ---------- spoke → server ----------
|
|
264
|
+
|
|
265
|
+
export interface Hello {
|
|
266
|
+
readonly t: "hello"
|
|
267
|
+
readonly v: number
|
|
268
|
+
readonly processID: string
|
|
269
|
+
readonly kind: ProcessKind
|
|
270
|
+
readonly directory: string
|
|
271
|
+
readonly pid: number
|
|
272
|
+
readonly mode: SpokeMode
|
|
273
|
+
/**
|
|
274
|
+
* OpenCode's version: required in an OpenCode hello (no `harness`, or harness "opencode"), optional for other
|
|
275
|
+
* harnesses. Hubs that predate `harness` require it from every spoke, so a non-OpenCode spoke that wants to reach
|
|
276
|
+
* them sends it anyway (its own version, or "").
|
|
277
|
+
*/
|
|
278
|
+
readonly opencodeVersion?: string
|
|
279
|
+
readonly pluginVersion: string
|
|
280
|
+
/** Optional ops this spoke supports (see SPOKE_FEATURES); older spokes send none. */
|
|
281
|
+
readonly features?: readonly string[]
|
|
282
|
+
/** Harness id, e.g. "opencode" (HARNESS_OPENCODE). Absent in older OpenCode spokes; see helloHarness. */
|
|
283
|
+
readonly harness?: string
|
|
284
|
+
readonly harnessVersion?: string
|
|
285
|
+
/** How the spoke observes activity (EVIDENCE_LEVELS); a level this hub does not know counts as "unknown". */
|
|
286
|
+
readonly evidence?: string
|
|
287
|
+
/**
|
|
288
|
+
* The launch this spoke was started for (a harness the node runs in a sandbox, connecting with a launch-bound
|
|
289
|
+
* token). Absent for ordinary spokes; hubs that predate it ignore it.
|
|
290
|
+
*/
|
|
291
|
+
readonly launchID?: string
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export const HARNESS_OPENCODE = "opencode"
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* How a spoke observes a session's activity: native harness events, lifecycle hooks, a harness RPC, or
|
|
298
|
+
* sampling the terminal screen. A session whose evidence is "unknown" never counts as idle.
|
|
299
|
+
*/
|
|
300
|
+
export const EVIDENCE_LEVELS = ["native", "hooks", "rpc", "screen"] as const
|
|
301
|
+
export type EvidenceLevel = (typeof EVIDENCE_LEVELS)[number]
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Features an OpenCode spoke that predates `harness` serves without announcing them (every op it handled
|
|
305
|
+
* then except autoName, which was always announced). Do not add features here: new ones must be announced.
|
|
306
|
+
*/
|
|
307
|
+
export const IMPLIED_OPENCODE_FEATURES = [
|
|
308
|
+
"context",
|
|
309
|
+
"rename",
|
|
310
|
+
"summary",
|
|
311
|
+
"diff",
|
|
312
|
+
"conversation",
|
|
313
|
+
"models",
|
|
314
|
+
"modelPrefs",
|
|
315
|
+
"timeline",
|
|
316
|
+
"timelinePart",
|
|
317
|
+
"timelineFile",
|
|
318
|
+
"expose",
|
|
319
|
+
"create",
|
|
320
|
+
] as const
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Features a spoke announces in its hello, so the hub can refuse an op an older spoke would ignore.
|
|
324
|
+
* "sessionTokens": the spoke stores the session tokens the hub sends (sessionTokens op) for its sessions' shells.
|
|
325
|
+
* "deliver": the spoke admits hub messages into its sessions (deliver op).
|
|
326
|
+
* "hubPrompt": it admits deliver kind "prompt" (an agent's prompt under a grant) with a provenance header it renders.
|
|
327
|
+
* "permissionMessage": it passes a permission reply's `message` (a deny reason) on to the harness.
|
|
328
|
+
* "actTokens": it keeps the act tokens the hub sends (actTokens op) apart from session tokens, never in any
|
|
329
|
+
* environment, for its own act verbs only (the OpenCode hub tool).
|
|
330
|
+
* "launch": its create op takes a launch's `launchID`, `agent` and `model` (see isLaunchCreate); a launch create's
|
|
331
|
+
* directory may be a new workspace under the machine's node workspace roots rather than one the spoke reported.
|
|
332
|
+
*/
|
|
333
|
+
const OPTIONAL_SPOKE_FEATURES = [...IMPLIED_OPENCODE_FEATURES, "autoName", "sessionTokens", "deliver", "hubPrompt", "permissionMessage", "actTokens", "launch"] as const
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* The core ops. An OpenCode spoke serves them all without announcing them; a spoke of another harness announces
|
|
337
|
+
* each one it serves (a hooks spoke may serve "permission" but not "turn"), and the hub sends only those. Hubs that
|
|
338
|
+
* predate this ignore the announcement and send them anyway, so announcing them is additive.
|
|
339
|
+
*/
|
|
340
|
+
export const CORE_OPS = ["turn", "steer", "interrupt", "stop", "permission", "form"] as const
|
|
341
|
+
export type CoreOp = (typeof CORE_OPS)[number]
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Every feature a spoke may serve: the core ops, the optional features, and "turnEvents": a spoke of another
|
|
345
|
+
* harness reports every turn's end as the harness confirms it (session.execution.succeeded, failed or interrupted,
|
|
346
|
+
* never inferred), so the hub trusts its "rpc" evidence for idle (e.g. a Codex app-server spoke's turn/completed).
|
|
347
|
+
* Not in SPOKE_FEATURES: an OpenCode spoke's native evidence already counts.
|
|
348
|
+
*/
|
|
349
|
+
export const KNOWN_SPOKE_FEATURES = [...CORE_OPS, ...OPTIONAL_SPOKE_FEATURES, "turnEvents"] as const
|
|
350
|
+
export type SpokeFeature = (typeof KNOWN_SPOKE_FEATURES)[number]
|
|
351
|
+
/**
|
|
352
|
+
* What this package's OpenCode spoke announces (without "deliver" and "hubPrompt" when its OpenCode has no synthetic
|
|
353
|
+
* messages). No core op: an OpenCode spoke implies them.
|
|
354
|
+
*/
|
|
355
|
+
export const SPOKE_FEATURES: readonly SpokeFeature[] = OPTIONAL_SPOKE_FEATURES
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Features the hub announces in its welcome. "harness": it reads hello `harness`, `harnessVersion` and `evidence`.
|
|
359
|
+
* "messaging": it routes messages between sessions (it sends deliver to spokes that announce that feature).
|
|
360
|
+
* "nodes": it accepts a node hello (role "node", see node.ts) and sends node ops; a node backs off from a hub
|
|
361
|
+
* without it. "launches": it launches sessions (it sends launch creates to spokes that announce "launch").
|
|
362
|
+
*/
|
|
363
|
+
export const KNOWN_HUB_FEATURES = ["harness", "messaging", "nodes", "launches"] as const
|
|
364
|
+
export type HubFeature = (typeof KNOWN_HUB_FEATURES)[number]
|
|
365
|
+
/** What this hub announces ("launches": the /api/agent/launches routes). */
|
|
366
|
+
export const HUB_FEATURES: readonly HubFeature[] = ["harness", "messaging", "nodes", "launches"]
|
|
367
|
+
|
|
368
|
+
/** A spoke's harness, version and evidence level; a hello without `harness` is an OpenCode native spoke. */
|
|
369
|
+
export function helloHarness(hello: Hello): { readonly harness: string; readonly harnessVersion: string; readonly evidence: EvidenceLevel | "unknown" } {
|
|
370
|
+
if (hello.harness === undefined) return { harness: HARNESS_OPENCODE, harnessVersion: hello.opencodeVersion ?? "", evidence: "native" }
|
|
371
|
+
const evidence = (EVIDENCE_LEVELS as readonly string[]).includes(hello.evidence ?? "") ? (hello.evidence as EvidenceLevel) : "unknown"
|
|
372
|
+
return { harness: hello.harness, harnessVersion: hello.harnessVersion ?? "", evidence }
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Whether a hello comes from an OpenCode spoke: no `harness` (a spoke that predates it) or harness "opencode". */
|
|
376
|
+
export const isOpenCodeHello = (hello: Hello): boolean => hello.harness === undefined || hello.harness === HARNESS_OPENCODE
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* The features a spoke serves: those it announced, plus CORE_OPS for an OpenCode hello and IMPLIED_OPENCODE_FEATURES
|
|
380
|
+
* for a hello without `harness`.
|
|
381
|
+
*/
|
|
382
|
+
export function spokeFeatures(hello: Hello): ReadonlySet<string> {
|
|
383
|
+
return new Set([
|
|
384
|
+
...(isOpenCodeHello(hello) ? CORE_OPS : []),
|
|
385
|
+
...(hello.harness === undefined ? IMPLIED_OPENCODE_FEATURES : []),
|
|
386
|
+
...(hello.features ?? []),
|
|
387
|
+
])
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export interface Sessions {
|
|
391
|
+
readonly t: "sessions"
|
|
392
|
+
readonly sessions: readonly SessionInfo[]
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export interface Event {
|
|
396
|
+
readonly t: "event"
|
|
397
|
+
readonly event: ForwardedEvent
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Requests the spoke makes of the server on the user's behalf (from the /hub dialog). */
|
|
401
|
+
export type SpokeRequest =
|
|
402
|
+
| { readonly op: "pairCode" }
|
|
403
|
+
| { readonly op: "handoff"; readonly sessionID: string }
|
|
404
|
+
/** A one-time sign-in link to a session's page, or to the home page without a session. */
|
|
405
|
+
/** `voice`: land on the session with Voice mode starting (`?voice=1`). */
|
|
406
|
+
| { readonly op: "openLink"; readonly sessionID?: string; readonly voice?: boolean }
|
|
407
|
+
|
|
408
|
+
// ---------- server → spoke ----------
|
|
409
|
+
|
|
410
|
+
export interface Welcome {
|
|
411
|
+
readonly t: "welcome"
|
|
412
|
+
readonly v: number
|
|
413
|
+
readonly machineID: string
|
|
414
|
+
readonly publicURL: string
|
|
415
|
+
/** The hub's features (see HUB_FEATURES); older hubs send none. */
|
|
416
|
+
readonly features?: readonly string[]
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** An image attached to a typed prompt: base64 bytes of a PNG, JPEG, WebP or GIF (see promptImagesProblem). */
|
|
420
|
+
export interface PromptImage {
|
|
421
|
+
readonly mime: string
|
|
422
|
+
readonly data: string
|
|
423
|
+
readonly name?: string
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** A hub-minted session token for one session (design §1.3); `exp` is its expiry in ms since the epoch. */
|
|
427
|
+
export interface SessionToken {
|
|
428
|
+
readonly sessionID: string
|
|
429
|
+
readonly token: string
|
|
430
|
+
readonly exp: number
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** Most tokens one sessionTokens request carries (see batchSessionTokens). */
|
|
434
|
+
export const SESSION_TOKENS_MAX = 500
|
|
435
|
+
/** Longest session token: printable ASCII without whitespace. */
|
|
436
|
+
export const SESSION_TOKEN_MAX_CHARS = 4096
|
|
437
|
+
|
|
438
|
+
/** Why a spoke did not store a session token: it does not report the session, or the token has expired. */
|
|
439
|
+
export type SessionTokenSkipReason = "unknown" | "expired"
|
|
440
|
+
|
|
441
|
+
// ---------- messaging (deliver) ----------
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* What a hub message is: a tell, an ask, its reply or decline, the identity primer, a Decision answer, or a prompt
|
|
445
|
+
* an agent sends under a grant (feature "hubPrompt"; `meta.grant` names the grant).
|
|
446
|
+
*/
|
|
447
|
+
export const MESSAGE_KINDS = ["msg", "ask", "reply", "decline", "primer", "decision", "prompt"] as const
|
|
448
|
+
export type MessageKind = (typeof MESSAGE_KINDS)[number]
|
|
449
|
+
|
|
450
|
+
/** How OpenCode admits a message into a busy session: after the running turn (queue) or into it (steer). */
|
|
451
|
+
export const DELIVERY_MODES = ["queue", "steer"] as const
|
|
452
|
+
export type DeliveryMode = (typeof DELIVERY_MODES)[number]
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* How a spoke delivered a message: into the running turn (steer), as a new turn (turn), admitted without waking
|
|
456
|
+
* or behind the running turn (queued), or held by the spoke for later, e.g. while a form is open (retained).
|
|
457
|
+
*/
|
|
458
|
+
export const DELIVERED = ["steer", "turn", "queued", "retained"] as const
|
|
459
|
+
export type Delivered = (typeof DELIVERED)[number]
|
|
460
|
+
|
|
461
|
+
/** Longest deliver text (the hub-rendered envelope included). */
|
|
462
|
+
export const DELIVER_TEXT_MAX = 16_000
|
|
463
|
+
/** Longest id or address in a deliver request. */
|
|
464
|
+
export const DELIVER_ID_MAX = 300
|
|
465
|
+
|
|
466
|
+
/** Who sent a message to whom, as the hub resolved them; the spoke never sets these. */
|
|
467
|
+
export interface DeliverMeta {
|
|
468
|
+
readonly kind: MessageKind
|
|
469
|
+
/** The authenticated sender's address, e.g. "@api-refactor". */
|
|
470
|
+
readonly from: string
|
|
471
|
+
/** The address the sender wrote, e.g. "@@reviewer". */
|
|
472
|
+
readonly to: string
|
|
473
|
+
/** The message a reply or decline answers (required for those kinds). */
|
|
474
|
+
readonly replyTo?: string
|
|
475
|
+
/** How to admit the message into a busy session; the spoke picks when absent. */
|
|
476
|
+
readonly delivery?: DeliveryMode
|
|
477
|
+
/** The grant a prompt acts under (required for kind "prompt", refused for the others). */
|
|
478
|
+
readonly grant?: string
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
export type DeliverRequest = Extract<ServerRequest, { op: "deliver" }>
|
|
482
|
+
|
|
483
|
+
export interface DeliverResult {
|
|
484
|
+
readonly delivered: Delivered
|
|
485
|
+
/** The harness's inbox entry for the message, when it reports one. */
|
|
486
|
+
readonly inboxID?: string
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
export type ServerRequest =
|
|
490
|
+
/** `text` may be empty when `images` are attached. */
|
|
491
|
+
| { readonly op: "turn"; readonly sessionID: string; readonly text: string; readonly voiceStyle: boolean; readonly images?: readonly PromptImage[] }
|
|
492
|
+
/**
|
|
493
|
+
* `message` (reject only, feature "permissionMessage"): why, passed to the harness (OpenCode's reply message, which
|
|
494
|
+
* the model sees as the rejection's feedback).
|
|
495
|
+
*/
|
|
496
|
+
| { readonly op: "permission"; readonly sessionID: string; readonly requestID: string; readonly decision: PermissionDecision; readonly message?: string }
|
|
497
|
+
/** Answer (or cancel) a pending form (question) in the session that asked it. */
|
|
498
|
+
| { readonly op: "form"; readonly sessionID: string; readonly formID: string; readonly answer: FormAnswer }
|
|
499
|
+
| { readonly op: "form"; readonly sessionID: string; readonly formID: string; readonly cancel: true }
|
|
500
|
+
| { readonly op: "context"; readonly sessionID: string }
|
|
501
|
+
/** Conversation mode: deliver an utterance with delivery "steer" and return at once (replies arrive as events). */
|
|
502
|
+
| { readonly op: "steer"; readonly sessionID: string; readonly text: string; readonly images?: readonly PromptImage[] }
|
|
503
|
+
| { readonly op: "interrupt"; readonly sessionID: string }
|
|
504
|
+
/** Rename the session in OpenCode; the spoke trims the title and requires 1–200 characters. */
|
|
505
|
+
| { readonly op: "rename"; readonly sessionID: string; readonly title: string }
|
|
506
|
+
/**
|
|
507
|
+
* Have OpenCode regenerate the title with its title agent (session.update with an empty title). Only for
|
|
508
|
+
* spokes whose hello lists the "autoName" feature (older spokes drop unknown ops).
|
|
509
|
+
*/
|
|
510
|
+
| { readonly op: "autoName"; readonly sessionID: string }
|
|
511
|
+
/** Interrupt the session's whole tree: running descendants deepest first, then the root. */
|
|
512
|
+
| { readonly op: "stop"; readonly sessionID: string }
|
|
513
|
+
/** Right-rail data: context usage, MCP servers, branch and working-tree change stats. */
|
|
514
|
+
| { readonly op: "summary"; readonly sessionID: string }
|
|
515
|
+
/** One changed file's patch in the session's directory. */
|
|
516
|
+
| { readonly op: "diff"; readonly sessionID: string; readonly file: string }
|
|
517
|
+
/** Switch the session to the voice orchestrator agent (on) or back to its previous agent (off). */
|
|
518
|
+
| { readonly op: "conversation"; readonly sessionID: string; readonly on: boolean }
|
|
519
|
+
| { readonly op: "models"; readonly sessionID: string }
|
|
520
|
+
| { readonly op: "modelPrefs"; readonly sessionID: string }
|
|
521
|
+
| { readonly op: "setModelPrefs"; readonly sessionID: string; readonly voice?: ModelSelection | null; readonly worker?: ModelSelection | null }
|
|
522
|
+
| { readonly op: "timeline"; readonly sessionID: string; readonly limit?: number; readonly cursor?: string; readonly order?: "asc" | "desc"; readonly before?: string }
|
|
523
|
+
| { readonly op: "timelinePart"; readonly sessionID: string; readonly messageID: string; readonly partID: string }
|
|
524
|
+
/** One image attached to a user message (its position in the message's files). */
|
|
525
|
+
| { readonly op: "timelineFile"; readonly sessionID: string; readonly messageID: string; readonly index: number }
|
|
526
|
+
/** Enable or disable voice for one session (adds/removes it from the machine config's session list). */
|
|
527
|
+
| { readonly op: "expose"; readonly sessionID: string; readonly on: boolean }
|
|
528
|
+
/**
|
|
529
|
+
* Create a session in a workspace directory the spoke already reported. With `herdrWorkspace`, also open
|
|
530
|
+
* it in a new tab of that Herdr workspace (`opencode --session <id>` in the directory).
|
|
531
|
+
* Launch fields (feature "launch", see isLaunchCreate): `launchID` the launch the session is for, `agent` and
|
|
532
|
+
* `model` the agent and model to start it with (the spoke refuses an agent or model its harness lacks).
|
|
533
|
+
*/
|
|
534
|
+
| {
|
|
535
|
+
readonly op: "create"
|
|
536
|
+
readonly directory: string
|
|
537
|
+
readonly title?: string
|
|
538
|
+
readonly herdrWorkspace?: string
|
|
539
|
+
readonly launchID?: string
|
|
540
|
+
readonly agent?: string
|
|
541
|
+
readonly model?: ModelSelection
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Session tokens for sessions the spoke reported (feature "sessionTokens"); each replaces the session's older
|
|
545
|
+
* one. `hubURL`: the URL the sessions' shells reach the hub at. No session is adopted or exposed by this op.
|
|
546
|
+
* Send through batchSessionTokens so every frame stays under MAX_FRAME.
|
|
547
|
+
*/
|
|
548
|
+
| { readonly op: "sessionTokens"; readonly tokens: readonly SessionToken[]; readonly hubURL?: string }
|
|
549
|
+
/**
|
|
550
|
+
* Act tokens (typ "act") for sessions the spoke reported (feature "actTokens"): the credential for act verbs. A
|
|
551
|
+
* separate op so a spoke that only knows sessionTokens never takes one for a shell token. The spoke keeps them
|
|
552
|
+
* apart from session tokens and never puts them in any environment. Send through batchActTokens.
|
|
553
|
+
*/
|
|
554
|
+
| { readonly op: "actTokens"; readonly tokens: readonly SessionToken[] }
|
|
555
|
+
/**
|
|
556
|
+
* Admit a hub message into a session (feature "deliver"). `text` is the envelope the hub rendered; `messageID`
|
|
557
|
+
* is the idempotency key (spokes apply a message once). `wake: false` admits it without starting a turn.
|
|
558
|
+
* Kind "prompt" (feature "hubPrompt"): `text` is the prompt's body; the spoke renders the provenance header from
|
|
559
|
+
* `meta.from` and `meta.grant` around it, so the session never sees it as the user's prompt.
|
|
560
|
+
*/
|
|
561
|
+
| { readonly op: "deliver"; readonly sessionID: string; readonly messageID: string; readonly text: string; readonly wake: boolean; readonly meta: DeliverMeta }
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* The feature a spoke must serve (spokeFeatures) for the hub to send `op`: the op itself for a core op (CORE_OPS,
|
|
565
|
+
* implied by OpenCode spokes), else the optional feature that serves it.
|
|
566
|
+
*/
|
|
567
|
+
export function featureForOp(op: ServerRequest["op"]): SpokeFeature {
|
|
568
|
+
switch (op) {
|
|
569
|
+
case "setModelPrefs":
|
|
570
|
+
return "modelPrefs"
|
|
571
|
+
default:
|
|
572
|
+
return op
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** Every feature a spoke must announce for the hub to send `req`: its op's (featureForOp) and its options'. */
|
|
577
|
+
export function requestFeatures(req: ServerRequest): SpokeFeature[] {
|
|
578
|
+
const out: SpokeFeature[] = [featureForOp(req.op)]
|
|
579
|
+
if (req.op === "deliver" && req.meta.kind === "prompt") out.push("hubPrompt")
|
|
580
|
+
if (req.op === "permission" && req.message !== undefined) out.push("permissionMessage")
|
|
581
|
+
if (isLaunchCreate(req)) out.push("launch")
|
|
582
|
+
return out
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
export type CreateRequest = Extract<ServerRequest, { op: "create" }>
|
|
586
|
+
|
|
587
|
+
/** Whether `req` is a create carrying launch fields (feature "launch"). */
|
|
588
|
+
export function isLaunchCreate(req: ServerRequest): req is CreateRequest {
|
|
589
|
+
return req.op === "create" && (req.launchID !== undefined || req.agent !== undefined || req.model !== undefined)
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** Longest agent name a launch create carries. */
|
|
593
|
+
export const AGENT_NAME_MAX = 100
|
|
594
|
+
|
|
595
|
+
/** Longest permission reply message (deny reason). */
|
|
596
|
+
export const PERMISSION_MESSAGE_MAX = 1000
|
|
597
|
+
|
|
598
|
+
export interface ContextUsage {
|
|
599
|
+
/** Tokens of the latest assistant message (input + output + reasoning + cache). */
|
|
600
|
+
readonly used: number
|
|
601
|
+
readonly limit?: number
|
|
602
|
+
readonly percent?: number
|
|
603
|
+
readonly model?: string
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
export interface McpStatus {
|
|
607
|
+
readonly name: string
|
|
608
|
+
readonly status: "connected" | "pending" | "disabled" | "failed" | "needs_auth" | "unknown"
|
|
609
|
+
readonly error?: string
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
export interface ChangedFile {
|
|
613
|
+
readonly file: string
|
|
614
|
+
readonly status: "added" | "deleted" | "modified"
|
|
615
|
+
readonly additions: number
|
|
616
|
+
readonly deletions: number
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
export interface SessionChanges {
|
|
620
|
+
/** The listed files: all of them, or (when `truncated`) the most-changed ones that fit the limits. */
|
|
621
|
+
readonly files: readonly ChangedFile[]
|
|
622
|
+
/** Line totals over every changed file, listed or not. */
|
|
623
|
+
readonly additions: number
|
|
624
|
+
readonly deletions: number
|
|
625
|
+
/** Number of changed files, listed or not. */
|
|
626
|
+
readonly totalFiles: number
|
|
627
|
+
/** True when `files` lists fewer than `totalFiles`. */
|
|
628
|
+
readonly truncated: boolean
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
export interface FilePatch extends ChangedFile {
|
|
632
|
+
readonly patch: string
|
|
633
|
+
readonly truncated: boolean
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** Right-rail data for a session; each part is absent when OpenCode could not provide it. */
|
|
637
|
+
export interface SessionSummary {
|
|
638
|
+
readonly directory: string
|
|
639
|
+
readonly branch?: string
|
|
640
|
+
readonly context?: ContextUsage
|
|
641
|
+
readonly mcp?: readonly McpStatus[]
|
|
642
|
+
readonly changes?: SessionChanges
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
export type TurnResult =
|
|
646
|
+
| { readonly outcome: "succeeded"; readonly reply: string }
|
|
647
|
+
| { readonly outcome: "failed" | "interrupted"; readonly message: string; readonly reply: string }
|
|
648
|
+
| { readonly outcome: "timeout"; readonly reply: string }
|
|
649
|
+
|
|
650
|
+
/** Result payloads, keyed by op. */
|
|
651
|
+
export interface ResultFor {
|
|
652
|
+
turn: TurnResult
|
|
653
|
+
/** `gone`: the request is not pending in this process any more (answered elsewhere, expired). */
|
|
654
|
+
permission: { readonly gone?: true }
|
|
655
|
+
/** `gone`: the form is not pending in this process any more (answered elsewhere, cancelled, expired). */
|
|
656
|
+
form: { readonly gone?: true }
|
|
657
|
+
context: { readonly messages: ReadonlyArray<{ readonly role: "user" | "assistant"; readonly text: string }> }
|
|
658
|
+
pairCode: { readonly code: string; readonly url: string; readonly expiresAt: number }
|
|
659
|
+
handoff: { readonly notified: number }
|
|
660
|
+
openLink: { readonly url: string }
|
|
661
|
+
steer: { readonly inboxID: string }
|
|
662
|
+
interrupt: Record<string, never>
|
|
663
|
+
rename: { readonly title: string }
|
|
664
|
+
/**
|
|
665
|
+
* `title`: the new title. `unchanged`: OpenCode kept the title (`title` is the current one). `pending`: still
|
|
666
|
+
* generating after the spoke's wait; the catalog updates when it lands.
|
|
667
|
+
*/
|
|
668
|
+
autoName: { readonly title?: string; readonly unchanged?: true; readonly pending?: true }
|
|
669
|
+
stop: { readonly stopped: readonly string[] }
|
|
670
|
+
summary: SessionSummary
|
|
671
|
+
diff: { readonly patch?: FilePatch }
|
|
672
|
+
conversation: { readonly agent: string }
|
|
673
|
+
models: { readonly models: readonly AvailableModel[] }
|
|
674
|
+
modelPrefs: { readonly prefs: SessionModelPrefs; readonly defaults: SessionModelPrefs }
|
|
675
|
+
setModelPrefs: { readonly prefs: SessionModelPrefs; readonly defaults: SessionModelPrefs }
|
|
676
|
+
timeline: TimelinePage
|
|
677
|
+
timelinePart: { readonly detail: unknown }
|
|
678
|
+
/** No `file` when the message has no image at that index; `tooLarge` when its bytes would not fit in a frame. */
|
|
679
|
+
timelineFile: { readonly file?: PromptImage; readonly tooLarge?: boolean }
|
|
680
|
+
expose: { readonly exposed: boolean }
|
|
681
|
+
create: { readonly sessionID: string; readonly openedIn?: "herdr" }
|
|
682
|
+
/** `applied`: sessions whose token the spoke stored; `skipped`: the others, with the reason. */
|
|
683
|
+
sessionTokens: { readonly applied: readonly string[]; readonly skipped: ReadonlyArray<{ readonly sessionID: string; readonly reason: SessionTokenSkipReason }> }
|
|
684
|
+
/** As sessionTokens. */
|
|
685
|
+
actTokens: { readonly applied: readonly string[]; readonly skipped: ReadonlyArray<{ readonly sessionID: string; readonly reason: SessionTokenSkipReason }> }
|
|
686
|
+
deliver: DeliverResult
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// ---------- both directions ----------
|
|
690
|
+
|
|
691
|
+
export interface Req<R = SpokeRequest | ServerRequest> {
|
|
692
|
+
readonly t: "req"
|
|
693
|
+
readonly id: string
|
|
694
|
+
readonly req: R
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
export interface Res {
|
|
698
|
+
readonly t: "res"
|
|
699
|
+
readonly id: string
|
|
700
|
+
readonly ok: boolean
|
|
701
|
+
readonly result?: unknown
|
|
702
|
+
readonly error?: string
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
export interface Bye {
|
|
706
|
+
readonly t: "bye"
|
|
707
|
+
readonly reason: string
|
|
708
|
+
/**
|
|
709
|
+
* How soon to reconnect, in ms (0–MAX_RETRY_AFTER_MS): sent with reason "draining" by a hub pod that is shutting
|
|
710
|
+
* down, so the peer reconnects (to another pod) after a jittered delay up to this instead of its long backoff.
|
|
711
|
+
* Optional and additive: older peers ignore it.
|
|
712
|
+
*/
|
|
713
|
+
readonly retryAfterMs?: number
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
export type SpokeMessage = Hello | Sessions | Terminals | Event | Req<SpokeRequest> | Res | Bye
|
|
717
|
+
export type ServerMessage = Welcome | Req<ServerRequest> | Res | Bye
|
|
718
|
+
|
|
719
|
+
// ---------- validation ----------
|
|
720
|
+
|
|
721
|
+
const isModelSelection = (v: unknown): v is ModelSelection =>
|
|
722
|
+
isObj(v) && typeof v.providerID === "string" && ID.test(v.providerID) && typeof v.modelID === "string" && ID.test(v.modelID) && (v.variant === undefined || (typeof v.variant === "string" && ID.test(v.variant)))
|
|
723
|
+
|
|
724
|
+
const SESSION_TOKEN_TEXT = new RegExp(`^[\\x21-\\x7e]{1,${SESSION_TOKEN_MAX_CHARS}}$`)
|
|
725
|
+
|
|
726
|
+
function isSessionToken(v: unknown): v is SessionToken {
|
|
727
|
+
return isObj(v) && nonEmpty(v.sessionID) && v.sessionID.length <= 300 && str(v.token) && SESSION_TOKEN_TEXT.test(v.token) && num(v.exp)
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
/** Characters batchSessionTokens leaves free in a frame for the `{"t":"req","id":…}` envelope around a request. */
|
|
732
|
+
export const SESSION_TOKENS_ENVELOPE_CHARS = 1024
|
|
733
|
+
|
|
734
|
+
export type SessionTokensRequest = Extract<ServerRequest, { op: "sessionTokens" }>
|
|
735
|
+
export type ActTokensRequest = Extract<ServerRequest, { op: "actTokens" }>
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Splits tokens into sessionTokens requests, in order, of at most SESSION_TOKENS_MAX tokens whose JSON is at most
|
|
739
|
+
* `maxChars` characters (default: MAX_FRAME less SESSION_TOKENS_ENVELOPE_CHARS). Each request carries `hubURL`.
|
|
740
|
+
* Throws a RangeError when one token alone does not fit `maxChars`.
|
|
741
|
+
*/
|
|
742
|
+
export function batchSessionTokens(tokens: readonly SessionToken[], options: { readonly hubURL?: string; readonly maxChars?: number } = {}): SessionTokensRequest[] {
|
|
743
|
+
return batchTokens(tokens, (batch) => ({ op: "sessionTokens", tokens: batch, ...(options.hubURL !== undefined ? { hubURL: options.hubURL } : {}) }), options.maxChars)
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/** Splits act tokens into actTokens requests, as batchSessionTokens does (no hub URL). */
|
|
747
|
+
export function batchActTokens(tokens: readonly SessionToken[], options: { readonly maxChars?: number } = {}): ActTokensRequest[] {
|
|
748
|
+
return batchTokens(tokens, (batch) => ({ op: "actTokens", tokens: batch }), options.maxChars)
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function batchTokens<R>(tokens: readonly SessionToken[], request: (batch: readonly SessionToken[]) => R, max?: number): R[] {
|
|
752
|
+
const maxChars = max ?? MAX_FRAME - SESSION_TOKENS_ENVELOPE_CHARS
|
|
753
|
+
const empty = JSON.stringify(request([])).length
|
|
754
|
+
const out: R[] = []
|
|
755
|
+
let batch: SessionToken[] = []
|
|
756
|
+
let size = empty
|
|
757
|
+
for (const { sessionID, token, exp } of tokens) {
|
|
758
|
+
const entry = { sessionID, token, exp }
|
|
759
|
+
const chars = JSON.stringify(entry).length
|
|
760
|
+
if (empty + chars > maxChars) throw new RangeError(`A session token for ${sessionID} does not fit in ${maxChars} characters`)
|
|
761
|
+
if (batch.length > 0 && (batch.length >= SESSION_TOKENS_MAX || size + 1 + chars > maxChars)) {
|
|
762
|
+
out.push(request(batch))
|
|
763
|
+
batch = []
|
|
764
|
+
size = empty
|
|
765
|
+
}
|
|
766
|
+
size += chars + (batch.length > 0 ? 1 : 0)
|
|
767
|
+
batch.push(entry)
|
|
768
|
+
}
|
|
769
|
+
if (batch.length > 0) out.push(request(batch))
|
|
770
|
+
return out
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function isSession(v: unknown): v is SessionInfo {
|
|
774
|
+
return (
|
|
775
|
+
isObj(v) &&
|
|
776
|
+
nonEmpty(v.id) &&
|
|
777
|
+
str(v.title) &&
|
|
778
|
+
str(v.directory) &&
|
|
779
|
+
num(v.updated) &&
|
|
780
|
+
typeof v.owned === "boolean" &&
|
|
781
|
+
(v.parentID === undefined || str(v.parentID)) &&
|
|
782
|
+
(v.exposed === undefined || typeof v.exposed === "boolean") &&
|
|
783
|
+
(v.project === undefined || str(v.project)) &&
|
|
784
|
+
(v.projectName === undefined || str(v.projectName)) &&
|
|
785
|
+
(v.archived === undefined || typeof v.archived === "boolean") &&
|
|
786
|
+
(v.open === undefined || typeof v.open === "boolean") &&
|
|
787
|
+
(v.terminal === undefined || isTerminal(v.terminal))
|
|
788
|
+
)
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
const TERMINAL_STATUS = new Set(["idle", "working", "blocked", "done", "unknown"])
|
|
792
|
+
|
|
793
|
+
function isTerminal(v: unknown): v is TerminalInfo {
|
|
794
|
+
return (
|
|
795
|
+
isObj(v) &&
|
|
796
|
+
(v.source === "herdr" || v.source === "tui") &&
|
|
797
|
+
(v.workspaceID === undefined || str(v.workspaceID)) &&
|
|
798
|
+
(v.workspace === undefined || str(v.workspace)) &&
|
|
799
|
+
(v.title === undefined || str(v.title)) &&
|
|
800
|
+
(v.status === undefined || TERMINAL_STATUS.has(v.status as string))
|
|
801
|
+
)
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function isHerdrInfo(v: unknown): boolean {
|
|
805
|
+
return (
|
|
806
|
+
isObj(v) &&
|
|
807
|
+
Array.isArray(v.workspaces) &&
|
|
808
|
+
v.workspaces.length <= 200 &&
|
|
809
|
+
v.workspaces.every((w) => isObj(w) && nonEmpty(w.id) && str(w.label) && Array.isArray(w.directories) && w.directories.every(str))
|
|
810
|
+
)
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/** A permission reply message: 1–PERMISSION_MESSAGE_MAX characters, newlines and tabs but no other unsafe characters. */
|
|
814
|
+
const permissionMessage = (v: unknown): v is string => nonEmpty(v) && v.length <= PERMISSION_MESSAGE_MAX && !UNSAFE_CHARS.test(v.replace(/[\n\t]/g, ""))
|
|
815
|
+
|
|
816
|
+
/** A non-empty address of at most DELIVER_ID_MAX characters without characters that could fake a header. */
|
|
817
|
+
const address = (v: unknown): v is string => nonEmpty(v) && v.length <= DELIVER_ID_MAX && !UNSAFE_CHARS.test(v)
|
|
818
|
+
const deliverID = (v: unknown): v is string => typeof v === "string" && v.length <= DELIVER_ID_MAX && ID.test(v)
|
|
819
|
+
/** An agent name: 1–AGENT_NAME_MAX printable characters, not starting with "-" or a space. */
|
|
820
|
+
const agentName = (v: unknown): v is string => nonEmpty(v) && v.length <= AGENT_NAME_MAX && !UNSAFE_CHARS.test(v) && !/^[-\s]/.test(v)
|
|
821
|
+
|
|
822
|
+
/** Why `v` is not an acceptable deliver request, or undefined when it is. */
|
|
823
|
+
export function deliverProblem(v: unknown): string | undefined {
|
|
824
|
+
if (!isObj(v)) return "deliver must be an object"
|
|
825
|
+
if (!nonEmpty(v.sessionID) || v.sessionID.length > DELIVER_ID_MAX) return `sessionID must be 1–${DELIVER_ID_MAX} characters`
|
|
826
|
+
if (!deliverID(v.messageID)) return `messageID must be 1–${DELIVER_ID_MAX} id characters`
|
|
827
|
+
if (!nonEmpty(v.text) || v.text.length > DELIVER_TEXT_MAX) return `text must be 1–${DELIVER_TEXT_MAX} characters`
|
|
828
|
+
if (typeof v.wake !== "boolean") return "wake must be true or false"
|
|
829
|
+
const meta = v.meta
|
|
830
|
+
if (!isObj(meta)) return "meta must be an object"
|
|
831
|
+
if (!(MESSAGE_KINDS as readonly unknown[]).includes(meta.kind)) return `meta.kind must be one of ${MESSAGE_KINDS.join(", ")}`
|
|
832
|
+
if (!address(meta.from)) return `meta.from must be an address of 1–${DELIVER_ID_MAX} printable characters`
|
|
833
|
+
if (!address(meta.to)) return `meta.to must be an address of 1–${DELIVER_ID_MAX} printable characters`
|
|
834
|
+
if (meta.replyTo !== undefined && !deliverID(meta.replyTo)) return `meta.replyTo must be 1–${DELIVER_ID_MAX} id characters`
|
|
835
|
+
if ((meta.kind === "reply" || meta.kind === "decline") && meta.replyTo === undefined) return `meta.replyTo is required for a ${meta.kind}`
|
|
836
|
+
if (meta.delivery !== undefined && !(DELIVERY_MODES as readonly unknown[]).includes(meta.delivery)) return `meta.delivery must be one of ${DELIVERY_MODES.join(", ")}`
|
|
837
|
+
if (meta.kind === "prompt" && meta.grant === undefined) return "meta.grant is required for a prompt"
|
|
838
|
+
if (meta.kind !== "prompt" && meta.grant !== undefined) return "meta.grant is only for a prompt"
|
|
839
|
+
if (meta.grant !== undefined && !deliverID(meta.grant)) return `meta.grant must be 1–${DELIVER_ID_MAX} id characters`
|
|
840
|
+
return undefined
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/** Whether `v` is a well-formed deliver result (what the hub accepts back from a spoke). */
|
|
844
|
+
export function isDeliverResult(v: unknown): v is DeliverResult {
|
|
845
|
+
return isObj(v) && (DELIVERED as readonly unknown[]).includes(v.delivered) && (v.inboxID === undefined || (nonEmpty(v.inboxID) && v.inboxID.length <= DELIVER_ID_MAX))
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function isSpokeRequest(v: unknown): v is SpokeRequest {
|
|
849
|
+
if (!isObj(v)) return false
|
|
850
|
+
if (v.op === "pairCode") return true
|
|
851
|
+
if (v.op === "handoff") return nonEmpty(v.sessionID)
|
|
852
|
+
if (v.op === "openLink") return (v.sessionID === undefined || nonEmpty(v.sessionID)) && (v.voice === undefined || typeof v.voice === "boolean")
|
|
853
|
+
return false
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// ---------- prompt images ----------
|
|
857
|
+
|
|
858
|
+
/** Image types a prompt may carry. */
|
|
859
|
+
export const PROMPT_IMAGE_MIMES: readonly string[] = ["image/png", "image/jpeg", "image/webp", "image/gif"]
|
|
860
|
+
export const MAX_PROMPT_IMAGES = 4
|
|
861
|
+
/** Largest decoded image accepted. */
|
|
862
|
+
export const MAX_PROMPT_IMAGE_BYTES = 512 * 1024
|
|
863
|
+
/** Largest total base64 length of a prompt's images, so they fit in one frame with the text. */
|
|
864
|
+
export const MAX_PROMPT_IMAGES_BASE64 = 750_000
|
|
865
|
+
export const MAX_PROMPT_IMAGE_NAME_CHARS = 200
|
|
866
|
+
|
|
867
|
+
const BASE64 = /^[A-Za-z0-9+/]+={0,2}$/
|
|
868
|
+
|
|
869
|
+
/** Leading bytes of each accepted type (WebP: "RIFF" ... "WEBP"). */
|
|
870
|
+
function sniffImage(bytes: Uint8Array): string | undefined {
|
|
871
|
+
const at = (offset: number, ...values: number[]) => values.every((value, i) => bytes[offset + i] === value)
|
|
872
|
+
if (at(0, 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)) return "image/png"
|
|
873
|
+
if (at(0, 0xff, 0xd8, 0xff)) return "image/jpeg"
|
|
874
|
+
if (at(0, 0x47, 0x49, 0x46, 0x38)) return "image/gif"
|
|
875
|
+
if (at(0, 0x52, 0x49, 0x46, 0x46) && at(8, 0x57, 0x45, 0x42, 0x50)) return "image/webp"
|
|
876
|
+
return undefined
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* Why `value` is not an acceptable list of prompt images, or undefined when it is: at most
|
|
881
|
+
* MAX_PROMPT_IMAGES, each a known image type whose bytes match it, canonical base64 (OpenCode refuses
|
|
882
|
+
* anything else) of at most MAX_PROMPT_IMAGE_BYTES, together at most MAX_PROMPT_IMAGES_BASE64 characters.
|
|
883
|
+
*/
|
|
884
|
+
export function promptImagesProblem(value: unknown): string | undefined {
|
|
885
|
+
if (!Array.isArray(value)) return "images must be an array"
|
|
886
|
+
if (value.length > MAX_PROMPT_IMAGES) return `Attach at most ${MAX_PROMPT_IMAGES} images`
|
|
887
|
+
let total = 0
|
|
888
|
+
for (const [i, image] of value.entries()) {
|
|
889
|
+
const label = `Image ${i + 1}`
|
|
890
|
+
if (!isObj(image)) return `${label} must be {mime, data, name?}`
|
|
891
|
+
if (typeof image.mime !== "string" || !PROMPT_IMAGE_MIMES.includes(image.mime)) return `${label}: type must be one of ${PROMPT_IMAGE_MIMES.join(", ")}`
|
|
892
|
+
if (image.name !== undefined && (typeof image.name !== "string" || image.name.length > MAX_PROMPT_IMAGE_NAME_CHARS)) {
|
|
893
|
+
return `${label}: name must be a string of at most ${MAX_PROMPT_IMAGE_NAME_CHARS} characters`
|
|
894
|
+
}
|
|
895
|
+
const data = image.data
|
|
896
|
+
if (typeof data !== "string" || !data || data.length % 4 !== 0 || !BASE64.test(data)) return `${label}: data must be non-empty base64`
|
|
897
|
+
total += data.length
|
|
898
|
+
if (total > MAX_PROMPT_IMAGES_BASE64) return `Images together are larger than ${MAX_PROMPT_IMAGES_BASE64} base64 characters`
|
|
899
|
+
if ((data.length / 4) * 3 > MAX_PROMPT_IMAGE_BYTES + 2) return `${label} is larger than ${MAX_PROMPT_IMAGE_BYTES / 1024} KB`
|
|
900
|
+
const bytes = Buffer.from(data, "base64")
|
|
901
|
+
if (bytes.toString("base64") !== data) return `${label}: data must be canonical base64`
|
|
902
|
+
if (bytes.length > MAX_PROMPT_IMAGE_BYTES) return `${label} is larger than ${MAX_PROMPT_IMAGE_BYTES / 1024} KB`
|
|
903
|
+
if (sniffImage(bytes) !== image.mime) return `${label}: content does not match ${image.mime}`
|
|
904
|
+
}
|
|
905
|
+
return undefined
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/** Text, plus optional images; the text may be empty only when images are attached. */
|
|
909
|
+
function promptBody(v: Record<string, unknown>): boolean {
|
|
910
|
+
if (!str(v.text)) return false
|
|
911
|
+
if (v.images === undefined) return v.text.length > 0
|
|
912
|
+
return Array.isArray(v.images) && v.images.length > 0 && promptImagesProblem(v.images) === undefined
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function isServerRequest(v: unknown): v is ServerRequest {
|
|
916
|
+
if (!isObj(v)) return false
|
|
917
|
+
switch (v.op) {
|
|
918
|
+
case "turn":
|
|
919
|
+
return nonEmpty(v.sessionID) && promptBody(v) && typeof v.voiceStyle === "boolean"
|
|
920
|
+
case "permission":
|
|
921
|
+
return (
|
|
922
|
+
nonEmpty(v.sessionID) &&
|
|
923
|
+
nonEmpty(v.requestID) &&
|
|
924
|
+
["once", "always", "reject"].includes(v.decision as string) &&
|
|
925
|
+
(v.message === undefined || (v.decision === "reject" && permissionMessage(v.message)))
|
|
926
|
+
)
|
|
927
|
+
case "form":
|
|
928
|
+
if (!nonEmpty(v.sessionID) || !nonEmpty(v.formID) || v.formID.length > 300) return false
|
|
929
|
+
return v.cancel === true ? v.answer === undefined : v.cancel === undefined && formAnswerProblem(v.answer) === undefined
|
|
930
|
+
case "context":
|
|
931
|
+
case "autoName":
|
|
932
|
+
case "interrupt":
|
|
933
|
+
case "stop":
|
|
934
|
+
case "summary":
|
|
935
|
+
case "models":
|
|
936
|
+
case "modelPrefs":
|
|
937
|
+
return nonEmpty(v.sessionID)
|
|
938
|
+
case "setModelPrefs":
|
|
939
|
+
return (
|
|
940
|
+
nonEmpty(v.sessionID) &&
|
|
941
|
+
(v.voice === undefined || v.voice === null || isModelSelection(v.voice)) &&
|
|
942
|
+
(v.worker === undefined || v.worker === null || isModelSelection(v.worker)) &&
|
|
943
|
+
(v.voice !== undefined || v.worker !== undefined)
|
|
944
|
+
)
|
|
945
|
+
case "timeline":
|
|
946
|
+
return (
|
|
947
|
+
nonEmpty(v.sessionID) &&
|
|
948
|
+
(v.limit === undefined || (Number.isInteger(v.limit) && Number(v.limit) >= 1 && Number(v.limit) <= 200)) &&
|
|
949
|
+
(v.cursor === undefined || (str(v.cursor) && v.cursor.length <= 4096)) &&
|
|
950
|
+
(v.order === undefined || v.order === "asc" || v.order === "desc") &&
|
|
951
|
+
(v.before === undefined || (nonEmpty(v.before) && v.before.length <= 4096)) &&
|
|
952
|
+
// Newest-first paging (order desc, before) does not mix with the forward cursor.
|
|
953
|
+
(v.cursor === undefined || (v.before === undefined && v.order !== "desc"))
|
|
954
|
+
)
|
|
955
|
+
case "timelinePart":
|
|
956
|
+
return nonEmpty(v.sessionID) && nonEmpty(v.messageID) && v.messageID.length <= 300 && nonEmpty(v.partID) && v.partID.length <= 300
|
|
957
|
+
case "steer":
|
|
958
|
+
return nonEmpty(v.sessionID) && promptBody(v)
|
|
959
|
+
case "timelineFile":
|
|
960
|
+
return nonEmpty(v.sessionID) && nonEmpty(v.messageID) && v.messageID.length <= 300 && Number.isInteger(v.index) && Number(v.index) >= 0 && Number(v.index) < 1000
|
|
961
|
+
case "diff":
|
|
962
|
+
return nonEmpty(v.sessionID) && nonEmpty(v.file) && v.file.length <= 4096
|
|
963
|
+
case "rename":
|
|
964
|
+
return nonEmpty(v.sessionID) && str(v.title) && v.title.length <= 1000
|
|
965
|
+
case "conversation":
|
|
966
|
+
case "expose":
|
|
967
|
+
return nonEmpty(v.sessionID) && typeof v.on === "boolean"
|
|
968
|
+
case "create":
|
|
969
|
+
return (
|
|
970
|
+
nonEmpty(v.directory) &&
|
|
971
|
+
v.directory.startsWith("/") &&
|
|
972
|
+
v.directory.length <= 4096 &&
|
|
973
|
+
(v.title === undefined || (str(v.title) && v.title.length <= 200)) &&
|
|
974
|
+
(v.herdrWorkspace === undefined || (nonEmpty(v.herdrWorkspace) && v.herdrWorkspace.length <= 100)) &&
|
|
975
|
+
(v.launchID === undefined || deliverID(v.launchID)) &&
|
|
976
|
+
(v.agent === undefined || agentName(v.agent)) &&
|
|
977
|
+
(v.model === undefined || isModelSelection(v.model))
|
|
978
|
+
)
|
|
979
|
+
case "sessionTokens":
|
|
980
|
+
return Array.isArray(v.tokens) && v.tokens.length <= SESSION_TOKENS_MAX && v.tokens.every(isSessionToken) && (v.hubURL === undefined || isHubURL(v.hubURL))
|
|
981
|
+
case "actTokens":
|
|
982
|
+
return Array.isArray(v.tokens) && v.tokens.length <= SESSION_TOKENS_MAX && v.tokens.every(isSessionToken) && !("hubURL" in v)
|
|
983
|
+
case "deliver":
|
|
984
|
+
return deliverProblem(v) === undefined
|
|
985
|
+
default:
|
|
986
|
+
return false
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
/** Parses a frame received by the server. Returns undefined for anything invalid. */
|
|
992
|
+
export function parseSpokeMessage(raw: string | Buffer): SpokeMessage | undefined {
|
|
993
|
+
const m = parse(raw)
|
|
994
|
+
if (!m) return undefined
|
|
995
|
+
switch (m.t) {
|
|
996
|
+
case "hello":
|
|
997
|
+
// A spoke hello has no role (v1) or role "spoke"; a node hello (role "node") is parsed by parseNodeMessage.
|
|
998
|
+
return (m.role === undefined || m.role === "spoke") &&
|
|
999
|
+
num(m.v) &&
|
|
1000
|
+
nonEmpty(m.processID) &&
|
|
1001
|
+
(m.kind === "service" || m.kind === "standalone") &&
|
|
1002
|
+
str(m.directory) &&
|
|
1003
|
+
num(m.pid) &&
|
|
1004
|
+
(m.mode === "always" || m.mode === "session") &&
|
|
1005
|
+
(str(m.opencodeVersion) || (m.opencodeVersion === undefined && m.harness !== undefined && m.harness !== HARNESS_OPENCODE)) &&
|
|
1006
|
+
str(m.pluginVersion) &&
|
|
1007
|
+
(m.features === undefined || featureList(m.features)) &&
|
|
1008
|
+
(m.harness === undefined || token(m.harness, 64)) &&
|
|
1009
|
+
(m.harnessVersion === undefined || (str(m.harnessVersion) && m.harnessVersion.length <= 100)) &&
|
|
1010
|
+
(m.evidence === undefined || token(m.evidence, 32)) &&
|
|
1011
|
+
(m.launchID === undefined || (str(m.launchID) && ID.test(m.launchID)))
|
|
1012
|
+
? (m as unknown as Hello)
|
|
1013
|
+
: undefined
|
|
1014
|
+
case "sessions":
|
|
1015
|
+
return Array.isArray(m.sessions) && m.sessions.every(isSession) ? (m as unknown as Sessions) : undefined
|
|
1016
|
+
case "terminals":
|
|
1017
|
+
return m.herdr === undefined || isHerdrInfo(m.herdr) ? (m as unknown as Terminals) : undefined
|
|
1018
|
+
case "event":
|
|
1019
|
+
return isObj(m.event) && nonEmpty(m.event.type) && FORWARDED_EVENTS.has(m.event.type) && isObj(m.event.data)
|
|
1020
|
+
? (m as unknown as Event)
|
|
1021
|
+
: undefined
|
|
1022
|
+
case "req":
|
|
1023
|
+
return nonEmpty(m.id) && isSpokeRequest(m.req) ? (m as unknown as Req<SpokeRequest>) : undefined
|
|
1024
|
+
case "res":
|
|
1025
|
+
return isRes(m) ? (m as unknown as Res) : undefined
|
|
1026
|
+
case "bye":
|
|
1027
|
+
return isBye(m) ? (m as unknown as Bye) : undefined
|
|
1028
|
+
default:
|
|
1029
|
+
return undefined
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/** Parses a frame received by the spoke. Returns undefined for anything invalid. */
|
|
1034
|
+
export function parseServerMessage(raw: string | Buffer): ServerMessage | undefined {
|
|
1035
|
+
const m = parse(raw)
|
|
1036
|
+
if (!m) return undefined
|
|
1037
|
+
switch (m.t) {
|
|
1038
|
+
case "welcome":
|
|
1039
|
+
return isWelcomeFrame(m) ? (m as unknown as Welcome) : undefined
|
|
1040
|
+
case "req":
|
|
1041
|
+
return nonEmpty(m.id) && isServerRequest(m.req) ? (m as unknown as Req<ServerRequest>) : undefined
|
|
1042
|
+
case "res":
|
|
1043
|
+
return isRes(m) ? (m as unknown as Res) : undefined
|
|
1044
|
+
case "bye":
|
|
1045
|
+
return isBye(m) ? (m as unknown as Bye) : undefined
|
|
1046
|
+
default:
|
|
1047
|
+
return undefined
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
export const encode = (message: SpokeMessage | ServerMessage | NodeMessage | NodeServerMessage): string => JSON.stringify(message)
|
|
1052
|
+
|
|
1053
|
+
// ---------- HTTP enrollment (spoke ↔ server, before a machine token exists) ----------
|
|
1054
|
+
|
|
1055
|
+
/** POST /spoke/enroll body: { machineName } → EnrollStart */
|
|
1056
|
+
export interface EnrollStart {
|
|
1057
|
+
readonly userCode: string
|
|
1058
|
+
readonly deviceCode: string
|
|
1059
|
+
readonly approveURL: string
|
|
1060
|
+
readonly intervalMs: number
|
|
1061
|
+
readonly expiresAt: number
|
|
1062
|
+
/** A local hub approved this first machine already; the next poll returns the token. */
|
|
1063
|
+
readonly autoApproved?: boolean
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/** POST /spoke/enroll/poll body: { deviceCode } → EnrollPoll */
|
|
1067
|
+
export type EnrollPoll =
|
|
1068
|
+
| { readonly status: "pending" }
|
|
1069
|
+
| { readonly status: "denied" | "expired" }
|
|
1070
|
+
| { readonly status: "approved"; readonly machineID: string; readonly machineToken: string }
|
|
1071
|
+
|
|
1072
|
+
// ---------- activity feed ----------
|
|
1073
|
+
|
|
1074
|
+
const SUMMARY_KEYS = ["description", "command", "filePath", "path", "pattern", "url", "query", "agent", "prompt"] as const
|
|
1075
|
+
export const ACTIVITY_SUMMARY_MAX = 160
|
|
1076
|
+
/** Longest reasoning delta forwarded; longer deltas are cut (they are normally a few tokens). */
|
|
1077
|
+
export const REASONING_DELTA_MAX = 2000
|
|
1078
|
+
|
|
1079
|
+
/** Short human summary of a tool input, e.g. the shell command or file path. Never the full input. */
|
|
1080
|
+
export function summarizeToolInput(input: unknown): string {
|
|
1081
|
+
if (typeof input !== "object" || input === null) return ""
|
|
1082
|
+
const record = input as Record<string, unknown>
|
|
1083
|
+
for (const key of SUMMARY_KEYS) {
|
|
1084
|
+
const value = record[key]
|
|
1085
|
+
if (typeof value === "string" && value.trim()) {
|
|
1086
|
+
const line = value.trim().split("\n")[0]!
|
|
1087
|
+
return line.length > ACTIVITY_SUMMARY_MAX ? `${line.slice(0, ACTIVITY_SUMMARY_MAX - 1)}…` : line
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
return ""
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/**
|
|
1094
|
+
* Reduces activity events to what the activity feed needs. Returns the event unchanged when it is
|
|
1095
|
+
* not an activity event, or undefined when it should be dropped.
|
|
1096
|
+
*/
|
|
1097
|
+
export function compactActivity(event: ForwardedEvent, sessionOf?: (jobID: string) => string | undefined, now: number = Date.now()): ForwardedEvent | undefined {
|
|
1098
|
+
const d = event.data
|
|
1099
|
+
if (JOB_EVENTS.has(event.type) || event.type === "session.status") return compactJob(event, sessionOf, now)
|
|
1100
|
+
const base = { sessionID: d.sessionID, assistantMessageID: d.assistantMessageID, id: d.id }
|
|
1101
|
+
switch (event.type) {
|
|
1102
|
+
case "session.tool.input.started":
|
|
1103
|
+
return { type: event.type, data: { ...base, name: d.name } }
|
|
1104
|
+
case "session.tool.called":
|
|
1105
|
+
return { type: event.type, data: { ...base, summary: summarizeToolInput(d.input), background: (d.input as any)?.background === true } }
|
|
1106
|
+
case "session.tool.progress": {
|
|
1107
|
+
const childSessionID = childSessionOf(d.metadata)
|
|
1108
|
+
return childSessionID ? { type: "session.tool.called", data: { ...base, childSessionID } } : undefined
|
|
1109
|
+
}
|
|
1110
|
+
case "session.tool.success": {
|
|
1111
|
+
const childSessionID = childSessionOf(d.metadata)
|
|
1112
|
+
return { type: event.type, data: childSessionID ? { ...base, childSessionID } : base }
|
|
1113
|
+
}
|
|
1114
|
+
case "session.tool.failed": {
|
|
1115
|
+
const message = typeof (d.error as any)?.message === "string" ? String((d.error as any).message).slice(0, ACTIVITY_SUMMARY_MAX) : "failed"
|
|
1116
|
+
return { type: event.type, data: { ...base, error: message } }
|
|
1117
|
+
}
|
|
1118
|
+
case "session.reasoning.started":
|
|
1119
|
+
case "session.reasoning.ended":
|
|
1120
|
+
return { type: event.type, data: { sessionID: d.sessionID, assistantMessageID: d.assistantMessageID } }
|
|
1121
|
+
case "session.reasoning.delta":
|
|
1122
|
+
return {
|
|
1123
|
+
type: event.type,
|
|
1124
|
+
data: { sessionID: d.sessionID, assistantMessageID: d.assistantMessageID, delta: typeof d.delta === "string" ? d.delta.slice(0, REASONING_DELTA_MAX) : "" },
|
|
1125
|
+
}
|
|
1126
|
+
default:
|
|
1127
|
+
return event
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
/** The child session a subagent tool reports in its metadata (`{sessionID: "ses_…"}`). */
|
|
1132
|
+
function childSessionOf(metadata: unknown): string | undefined {
|
|
1133
|
+
const id = (metadata as { sessionID?: unknown } | undefined)?.sessionID
|
|
1134
|
+
return typeof id === "string" && /^ses_[A-Za-z0-9]+$/.test(id) ? id : undefined
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
const JOB_EVENTS = new Set(["session.shell.started", "session.shell.ended", "shell.created", "shell.exited", "shell.deleted", "pty.created", "pty.updated", "pty.exited", "pty.deleted"])
|
|
1138
|
+
const JOB_STATUSES = new Set<string>(["running", "exited", "timeout", "killed"])
|
|
1139
|
+
|
|
1140
|
+
function clipText(value: unknown, max: number): string | undefined {
|
|
1141
|
+
if (typeof value !== "string") return undefined
|
|
1142
|
+
const line = value.replace(/\s+/g, " ").trim()
|
|
1143
|
+
if (!line) return undefined
|
|
1144
|
+
return line.length > max ? `${line.slice(0, max - 1)}…` : line
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
const finite = (v: unknown): number | undefined => (typeof v === "number" && Number.isFinite(v) ? v : undefined)
|
|
1148
|
+
|
|
1149
|
+
/** OpenCode's SessionStatus reduced to its type and (retry) attempt and short reason. */
|
|
1150
|
+
export function compactStatus(raw: unknown): CompactSessionStatus | undefined {
|
|
1151
|
+
const status = raw as Record<string, unknown> | null
|
|
1152
|
+
if (!status || typeof status !== "object") return undefined
|
|
1153
|
+
if (status.type === "idle" || status.type === "busy") return { type: status.type }
|
|
1154
|
+
if (status.type !== "retry") return undefined
|
|
1155
|
+
const attempt = finite(status.attempt)
|
|
1156
|
+
const message = clipText(status.message, RETRY_MESSAGE_MAX)
|
|
1157
|
+
return { type: "retry", ...(attempt !== undefined ? { attempt } : {}), ...(message ? { message } : {}) }
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
/** A ShellInfo or Pty (OpenCode) as a JobInfo. */
|
|
1161
|
+
export function jobFromInfo(raw: unknown, kind: JobKind): (JobInfo & { sessionID?: string }) | undefined {
|
|
1162
|
+
const info = raw as Record<string, any> | null
|
|
1163
|
+
if (!info || typeof info !== "object" || typeof info.id !== "string" || !info.id) return undefined
|
|
1164
|
+
const status: JobStatus = JOB_STATUSES.has(info.status) ? info.status : "running"
|
|
1165
|
+
const commandLine = kind === "pty" ? [info.command, ...(Array.isArray(info.args) ? info.args : [])].filter((x) => typeof x === "string" && x).join(" ") || info.title : info.command
|
|
1166
|
+
const command = clipText(commandLine, JOB_COMMAND_MAX)
|
|
1167
|
+
const exit = finite(kind === "pty" ? info.exitCode : info.exit)
|
|
1168
|
+
const sessionID = typeof info.sessionID === "string" ? info.sessionID : typeof info.metadata?.sessionID === "string" ? info.metadata.sessionID : undefined
|
|
1169
|
+
const started = finite(info.time?.started)
|
|
1170
|
+
const ended = finite(info.time?.completed)
|
|
1171
|
+
return {
|
|
1172
|
+
...(sessionID ? { sessionID } : {}),
|
|
1173
|
+
id: info.id,
|
|
1174
|
+
kind,
|
|
1175
|
+
...(command ? { command } : {}),
|
|
1176
|
+
status,
|
|
1177
|
+
...(exit !== undefined ? { exit } : {}),
|
|
1178
|
+
...(info.metadata?.background === true ? { background: true as const } : {}),
|
|
1179
|
+
...(started !== undefined ? { started } : {}),
|
|
1180
|
+
...(ended !== undefined ? { ended } : {}),
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
function compactJob(event: ForwardedEvent, sessionOf: ((jobID: string) => string | undefined) | undefined, now: number): ForwardedEvent | undefined {
|
|
1185
|
+
const d = event.data as Record<string, any>
|
|
1186
|
+
const type = event.type
|
|
1187
|
+
if (type === "session.status") {
|
|
1188
|
+
const status = compactStatus(d.status)
|
|
1189
|
+
return typeof d.sessionID === "string" && status ? { type, data: { sessionID: d.sessionID, status } } : undefined
|
|
1190
|
+
}
|
|
1191
|
+
const kind: JobKind = type.startsWith("pty.") ? "pty" : "shell"
|
|
1192
|
+
const session = (id: unknown, own?: unknown) => (typeof own === "string" && own ? own : typeof id === "string" ? sessionOf?.(id) : undefined)
|
|
1193
|
+
let data: Record<string, unknown> | undefined
|
|
1194
|
+
if (type === "shell.created" || type === "session.shell.started" || type === "pty.created" || type === "pty.updated") {
|
|
1195
|
+
const job = jobFromInfo(type === "session.shell.started" ? d.shell : d.info, kind)
|
|
1196
|
+
if (!job) return undefined
|
|
1197
|
+
const sessionID = session(job.id, type === "session.shell.started" ? d.sessionID : job.sessionID)
|
|
1198
|
+
const { started, ended: _ended, sessionID: _session, ...rest } = job
|
|
1199
|
+
data = sessionID ? { sessionID, ...rest, at: started ?? now } : undefined
|
|
1200
|
+
} else if (type === "session.shell.ended") {
|
|
1201
|
+
const job = jobFromInfo(d.shell, "shell")
|
|
1202
|
+
if (!job) return undefined
|
|
1203
|
+
const sessionID = session(job.id, d.sessionID)
|
|
1204
|
+
data = sessionID ? { sessionID, id: job.id, kind, status: job.status === "running" ? "exited" : job.status, ...(job.exit !== undefined ? { exit: job.exit } : {}), at: job.ended ?? now } : undefined
|
|
1205
|
+
} else if (type === "shell.exited" || type === "pty.exited") {
|
|
1206
|
+
const sessionID = session(d.id, d.sessionID)
|
|
1207
|
+
const exit = finite(kind === "pty" ? d.exitCode : d.exit)
|
|
1208
|
+
const status = kind === "pty" ? "exited" : JOB_STATUSES.has(d.status) && d.status !== "running" ? d.status : "exited"
|
|
1209
|
+
data = sessionID ? { sessionID, id: d.id, kind, status, ...(exit !== undefined ? { exit } : {}), at: now } : undefined
|
|
1210
|
+
} else {
|
|
1211
|
+
const sessionID = session(d.id, d.sessionID)
|
|
1212
|
+
data = sessionID && typeof d.id === "string" ? { sessionID, id: d.id, kind } : undefined
|
|
1213
|
+
}
|
|
1214
|
+
return data ? { type, data } : undefined
|
|
1215
|
+
}
|