@huynle/hub 0.6.1 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +800 -0
- package/Dockerfile +30 -0
- package/README.md +870 -66
- package/package.json +45 -2
- package/packages/claude-code/package.json +12 -0
- package/packages/claude-code/src/bridge.ts +79 -0
- package/packages/claude-code/src/daemon.ts +339 -0
- package/packages/claude-code/src/hook-input.ts +44 -0
- package/packages/claude-code/src/ids.ts +27 -0
- package/packages/claude-code/src/index.ts +18 -0
- package/packages/claude-code/src/launcher.ts +265 -0
- package/packages/claude-code/src/main.ts +127 -0
- package/packages/claude-code/src/mcp.ts +109 -0
- package/packages/claude-code/src/paths.ts +34 -0
- package/packages/claude-code/src/plugin.ts +99 -0
- package/packages/claude-code/src/references.ts +51 -0
- package/packages/claude-code/src/sessions.ts +600 -0
- package/packages/claude-code/src/tools.ts +66 -0
- package/packages/claude-code/src/wire.ts +71 -0
- package/packages/claude-code/src/wrapper.ts +155 -0
- package/packages/cli/dist/hub-cli.js +10162 -0
- package/packages/codex/package.json +12 -0
- package/packages/codex/src/app-server.ts +44 -0
- package/packages/codex/src/ids.ts +28 -0
- package/packages/codex/src/index.ts +11 -0
- package/packages/codex/src/jsonrpc.ts +168 -0
- package/packages/codex/src/main.ts +122 -0
- package/packages/codex/src/paths.ts +65 -0
- package/packages/codex/src/sessions.ts +578 -0
- package/packages/codex/src/spoke.ts +249 -0
- package/packages/codex/src/stdio.ts +101 -0
- package/packages/codex/src/websocket.ts +206 -0
- package/packages/codex/src/wrapper.ts +100 -0
- package/packages/node/package.json +12 -0
- package/packages/node/src/config.ts +184 -0
- package/packages/node/src/connection.ts +283 -0
- package/packages/node/src/daemon.ts +281 -0
- package/packages/node/src/git.ts +107 -0
- package/packages/node/src/harnesses.ts +60 -0
- package/packages/node/src/host.ts +319 -0
- package/packages/node/src/index.ts +17 -0
- package/packages/node/src/main.ts +11 -0
- package/packages/node/src/openshell.ts +73 -0
- package/packages/node/src/run.ts +106 -0
- package/packages/preset/package.json +12 -0
- package/packages/preset/presets/aider.json +8 -0
- package/packages/preset/presets/goose.json +8 -0
- package/packages/preset/src/index.ts +2 -0
- package/packages/preset/src/main.ts +195 -0
- package/packages/preset/src/presets.ts +87 -0
- package/packages/preset/src/spoke.ts +251 -0
- package/packages/preset/src/tmux.ts +160 -0
- package/packages/protocol/package.json +12 -0
- package/packages/protocol/src/harness-events.ts +207 -0
- package/packages/protocol/src/index.ts +1215 -0
- package/packages/protocol/src/launch.ts +172 -0
- package/packages/protocol/src/node.ts +409 -0
- package/packages/protocol/src/preset.ts +144 -0
- package/packages/protocol/src/webhook.ts +62 -0
- package/packages/protocol/src/wire.ts +54 -0
- package/packages/runtime-openshell/image/Dockerfile +36 -0
- package/packages/runtime-openshell/image/README.md +78 -0
- package/packages/runtime-openshell/image/build.ts +203 -0
- package/packages/runtime-openshell/image/entry.sh +163 -0
- package/packages/runtime-openshell/image/image.ts +119 -0
- package/packages/runtime-openshell/image/start.sh +24 -0
- package/packages/runtime-openshell/package.json +12 -0
- package/packages/runtime-openshell/src/cli.ts +584 -0
- package/packages/runtime-openshell/src/index.ts +6 -0
- package/packages/runtime-openshell/src/policy.ts +155 -0
- package/packages/runtime-openshell/src/profiles.ts +181 -0
- package/packages/runtime-openshell/src/runtime.ts +475 -0
- package/packages/runtime-sdk/package.json +12 -0
- package/packages/runtime-sdk/src/index.ts +87 -0
- package/packages/sdk/package.json +12 -0
- package/packages/sdk/src/address.ts +25 -0
- package/packages/sdk/src/client.ts +502 -0
- package/packages/sdk/src/envelope.ts +108 -0
- package/packages/sdk/src/errors.ts +106 -0
- package/packages/sdk/src/index.ts +10 -0
- package/packages/sdk/src/sse.ts +67 -0
- package/packages/sdk/src/types.ts +458 -0
- package/packages/sdk/src/webhook.ts +102 -0
- package/packages/spoke-sdk/package.json +12 -0
- package/packages/spoke-sdk/src/activity.ts +3 -0
- package/packages/spoke-sdk/src/connection.ts +295 -0
- package/packages/spoke-sdk/src/delivery.ts +119 -0
- package/packages/spoke-sdk/src/enroll.ts +79 -0
- package/packages/spoke-sdk/src/harness-hello.ts +79 -0
- package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
- package/packages/spoke-sdk/src/index.ts +12 -0
- package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
- package/packages/spoke-sdk/src/prompt-source.ts +8 -0
- package/packages/spoke-sdk/src/session-ids.ts +49 -0
- package/packages/spoke-sdk/src/session-tokens.ts +78 -0
- package/scripts/hub-claude-code.service +34 -0
- package/scripts/hub-codex-spoke.service +34 -0
- package/scripts/hub-node.service +35 -0
- package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
- package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
- package/scripts/io.github.huynle.hub-node.plist +52 -0
- package/src/central/actions.ts +233 -0
- package/src/central/agent/acting-routes.ts +133 -0
- package/src/central/agent/acting-switch.ts +93 -0
- package/src/central/agent/acting.ts +599 -0
- package/src/central/agent/actor.ts +193 -0
- package/src/central/agent/app-tokens.ts +142 -0
- package/src/central/agent/audit.ts +185 -0
- package/src/central/agent/decisions.ts +530 -0
- package/src/central/agent/delivery.ts +678 -0
- package/src/central/agent/directory-routes.ts +185 -0
- package/src/central/agent/directory.ts +473 -0
- package/src/central/agent/envelope.ts +51 -0
- package/src/central/agent/errors.ts +72 -0
- package/src/central/agent/escalation.ts +219 -0
- package/src/central/agent/grant-requests.ts +221 -0
- package/src/central/agent/grant-routes.ts +70 -0
- package/src/central/agent/grants.ts +235 -0
- package/src/central/agent/launch-engine.ts +1025 -0
- package/src/central/agent/launch-routes.ts +252 -0
- package/src/central/agent/launch-spec.ts +289 -0
- package/src/central/agent/launch-store.ts +264 -0
- package/src/central/agent/launch-tokens.ts +206 -0
- package/src/central/agent/message-limits.ts +123 -0
- package/src/central/agent/message-routes.ts +385 -0
- package/src/central/agent/messages.ts +442 -0
- package/src/central/agent/names.ts +86 -0
- package/src/central/agent/placement.ts +419 -0
- package/src/central/agent/policy.ts +378 -0
- package/src/central/agent/read-routes.ts +389 -0
- package/src/central/agent/routes.ts +278 -0
- package/src/central/agent/session-tokens.ts +227 -0
- package/src/central/agent/versioned.ts +26 -0
- package/src/central/agent/watch.ts +221 -0
- package/src/central/agent/webhook-routes.ts +78 -0
- package/src/central/agent/webhooks.ts +449 -0
- package/src/central/agent-data.ts +177 -0
- package/src/central/auth.ts +224 -121
- package/src/central/backend/file-state.ts +240 -0
- package/src/central/backend/index.ts +113 -0
- package/src/central/backend/memory-bus.ts +93 -0
- package/src/central/backend/memory-catalog.ts +90 -0
- package/src/central/backend/memory-counters.ts +102 -0
- package/src/central/backend/memory-events.ts +45 -0
- package/src/central/backend/memory-kv.ts +38 -0
- package/src/central/backend/memory-state.ts +77 -0
- package/src/central/backend/postgres-migrate.ts +100 -0
- package/src/central/backend/postgres-state.ts +197 -0
- package/src/central/backend/types.ts +244 -0
- package/src/central/backend/valkey-bus.ts +425 -0
- package/src/central/backend/valkey-catalog.ts +190 -0
- package/src/central/backend/valkey-client.ts +36 -0
- package/src/central/backend/valkey-counters.ts +38 -0
- package/src/central/backend/valkey-events.ts +168 -0
- package/src/central/backend/valkey-kv.ts +54 -0
- package/src/central/backend/valkey-locks.ts +92 -0
- package/src/central/cluster/directory.ts +141 -0
- package/src/central/cluster/feed.ts +84 -0
- package/src/central/cluster/leader.ts +232 -0
- package/src/central/cluster/live.ts +151 -0
- package/src/central/cluster/recommend.ts +59 -0
- package/src/central/cluster/rpc.ts +150 -0
- package/src/central/cluster/view.ts +232 -0
- package/src/central/config.ts +197 -3
- package/src/central/done.ts +96 -0
- package/src/central/events-log.ts +100 -0
- package/src/central/http.ts +1067 -171
- package/src/central/import.ts +318 -0
- package/src/central/main.ts +211 -12
- package/src/central/nodes.ts +314 -0
- package/src/central/oidc.ts +34 -14
- package/src/central/push-target.ts +118 -18
- package/src/central/push.ts +256 -82
- package/src/central/registry.ts +902 -92
- package/src/central/say-stream.ts +128 -0
- package/src/central/session-token-push.ts +233 -0
- package/src/central/speech/openai.ts +21 -14
- package/src/central/speech/openrouter.ts +27 -10
- package/src/central/speech/provider.ts +54 -0
- package/src/central/speech/service.ts +22 -7
- package/src/central/spoke-ws.ts +522 -44
- package/src/central/store-shared.ts +518 -0
- package/src/central/store.ts +572 -189
- package/src/central/token-keys.ts +165 -0
- package/src/config.ts +49 -1
- package/src/index.ts +165 -12
- package/src/protocol.ts +3 -650
- package/src/rpc.ts +27 -2
- package/src/spoke/client.ts +3 -272
- package/src/spoke/conversation.ts +16 -12
- package/src/spoke/deliver.ts +75 -0
- package/src/spoke/enroll.ts +2 -79
- package/src/spoke/env-inject.ts +160 -0
- package/src/spoke/hub-rpc.ts +115 -0
- package/src/spoke/hub-tool.ts +409 -0
- package/src/spoke/launch-source.ts +22 -0
- package/src/spoke/launchers.ts +68 -0
- package/src/spoke/locations.ts +3 -2
- package/src/spoke/node-roots.ts +61 -0
- package/src/spoke/prompt-refs.ts +116 -0
- package/src/spoke/session-tokens.ts +2 -0
- package/src/spoke/spoke.ts +465 -28
- package/src/spoke/taskboard.ts +243 -0
- package/src/spoke/timeline.ts +184 -6
- package/src/spoke/tracker.ts +79 -4
- package/src/status.ts +203 -10
- package/src/tui-commands.ts +13 -6
- package/src/tui-messaging.ts +93 -0
- package/src/tui.ts +15 -0
- package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
- package/web/dist/assets/index-D9zMbxSg.css +1 -0
- package/web/dist/assets/index-db1z4duA.js +20 -0
- package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
- package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
- package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
- package/web/dist/index.html +9 -2
- package/web/dist/sw.js +79 -15
- package/web/dist/assets/index-DClBvfLL.css +0 -1
- package/web/dist/assets/index-Djo_yF0a.js +0 -12
package/src/protocol.ts
CHANGED
|
@@ -1,650 +1,3 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
export const PROTOCOL_VERSION = 1
|
|
6
|
-
|
|
7
|
-
export type SessionState = "idle" | "working" | "blocked" | "error"
|
|
8
|
-
export type ProcessKind = "service" | "standalone"
|
|
9
|
-
export type SpokeMode = "always" | "session"
|
|
10
|
-
export type PermissionDecision = "once" | "always" | "reject"
|
|
11
|
-
|
|
12
|
-
/** An answer to an OpenCode form (question): field key → value, like OpenCode's FormAnswer. */
|
|
13
|
-
export type FormAnswer = Readonly<Record<string, string | number | boolean | readonly string[]>>
|
|
14
|
-
|
|
15
|
-
export const FORM_ANSWER_MAX_KEYS = 50
|
|
16
|
-
export const FORM_ANSWER_MAX_CHARS = 20_000
|
|
17
|
-
export const FORM_ANSWER_MAX_ITEMS = 100
|
|
18
|
-
|
|
19
|
-
/** Why an answer object is not acceptable, or undefined when it is. */
|
|
20
|
-
export function formAnswerProblem(v: unknown): string | undefined {
|
|
21
|
-
if (typeof v !== "object" || v === null || Array.isArray(v)) return "answer must be an object"
|
|
22
|
-
const entries = Object.entries(v)
|
|
23
|
-
if (entries.length > FORM_ANSWER_MAX_KEYS) return `answer has more than ${FORM_ANSWER_MAX_KEYS} fields`
|
|
24
|
-
for (const [key, value] of entries) {
|
|
25
|
-
if (!key || key.length > 300) return "answer keys must be 1–300 characters"
|
|
26
|
-
if (typeof value === "string" && value.length <= FORM_ANSWER_MAX_CHARS) continue
|
|
27
|
-
if (typeof value === "boolean" || (typeof value === "number" && Number.isFinite(value))) continue
|
|
28
|
-
if (
|
|
29
|
-
Array.isArray(value) &&
|
|
30
|
-
value.length <= FORM_ANSWER_MAX_ITEMS &&
|
|
31
|
-
value.every((item) => typeof item === "string" && item.length <= FORM_ANSWER_MAX_CHARS)
|
|
32
|
-
) continue
|
|
33
|
-
return `answer.${key} must be text, a number, true/false or a list of texts`
|
|
34
|
-
}
|
|
35
|
-
return undefined
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export interface ModelSelection {
|
|
39
|
-
readonly providerID: string
|
|
40
|
-
/** OpenCode's catalog id of the model (Model.Info `id`, what switchModel takes), not its upstream `modelID`. */
|
|
41
|
-
readonly modelID: string
|
|
42
|
-
readonly variant?: string
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export interface SessionModelPrefs {
|
|
46
|
-
readonly voice?: ModelSelection
|
|
47
|
-
readonly worker?: ModelSelection
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export interface AvailableModel {
|
|
51
|
-
readonly providerID: string
|
|
52
|
-
readonly modelID: string
|
|
53
|
-
readonly name: string
|
|
54
|
-
readonly enabled: boolean
|
|
55
|
-
readonly status: string
|
|
56
|
-
readonly variants: readonly string[]
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export interface TimelinePage {
|
|
60
|
-
readonly items: readonly unknown[]
|
|
61
|
-
readonly cursor?: string
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export interface SessionInfo {
|
|
65
|
-
readonly id: string
|
|
66
|
-
readonly title: string
|
|
67
|
-
readonly directory: string
|
|
68
|
-
readonly parentID?: string
|
|
69
|
-
/** ms epoch of last update */
|
|
70
|
-
readonly updated: number
|
|
71
|
-
/** True when this process currently owns the session: it viewed or executed it most recently. */
|
|
72
|
-
readonly owned: boolean
|
|
73
|
-
/**
|
|
74
|
-
* Whether voice is enabled for this session (always true in mode "always"). Sessions with
|
|
75
|
-
* exposed:false are listed for discovery only: no events are forwarded and no ops run on them.
|
|
76
|
-
*/
|
|
77
|
-
readonly exposed?: boolean
|
|
78
|
-
/** Workspace (project) root the session belongs to; differs from directory for worktrees/subfolders. */
|
|
79
|
-
readonly project?: string
|
|
80
|
-
readonly projectName?: string
|
|
81
|
-
readonly archived?: boolean
|
|
82
|
-
/** Open in a terminal right now (a Herdr pane or an OpenCode TUI showing it). */
|
|
83
|
-
readonly open?: boolean
|
|
84
|
-
readonly terminal?: TerminalInfo
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
export interface TerminalInfo {
|
|
88
|
-
/** Where "open" was detected. */
|
|
89
|
-
readonly source: "herdr" | "tui"
|
|
90
|
-
/** Herdr workspace ID and label (e.g. "research"), when open in Herdr. */
|
|
91
|
-
readonly workspaceID?: string
|
|
92
|
-
readonly workspace?: string
|
|
93
|
-
/** Pane/terminal title, e.g. "OC | Fix the login redirect". */
|
|
94
|
-
readonly title?: string
|
|
95
|
-
/** Herdr's agent status for the pane. */
|
|
96
|
-
readonly status?: "idle" | "working" | "blocked" | "done" | "unknown"
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/** A Herdr workspace on this machine (a target for new sessions). */
|
|
100
|
-
export interface HerdrWorkspace {
|
|
101
|
-
readonly id: string
|
|
102
|
-
readonly label: string
|
|
103
|
-
/** Folders of the panes in this workspace, most used first (lets the phone suggest a directory). */
|
|
104
|
-
readonly directories: readonly string[]
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/** Machine-level terminal context, sent by one spoke per machine (the service spoke, else a standalone). */
|
|
108
|
-
export interface Terminals {
|
|
109
|
-
readonly t: "terminals"
|
|
110
|
-
readonly herdr?: { readonly workspaces: readonly HerdrWorkspace[] }
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/** Subset of OpenCode events the spoke forwards (see FORWARDED_EVENTS). */
|
|
114
|
-
export interface ForwardedEvent {
|
|
115
|
-
readonly type: string
|
|
116
|
-
readonly data: Record<string, unknown>
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
export const FORWARDED_EVENTS: ReadonlySet<string> = new Set([
|
|
120
|
-
"session.created",
|
|
121
|
-
"session.renamed",
|
|
122
|
-
"session.deleted",
|
|
123
|
-
"session.viewed",
|
|
124
|
-
"session.execution.started",
|
|
125
|
-
"session.execution.succeeded",
|
|
126
|
-
"session.execution.failed",
|
|
127
|
-
"session.execution.interrupted",
|
|
128
|
-
"session.text.delta",
|
|
129
|
-
"session.text.ended",
|
|
130
|
-
"session.inbox.delivered",
|
|
131
|
-
"session.agent.selected",
|
|
132
|
-
// Activity feed. The spoke sends these in compact form (see compactActivity): tool inputs are
|
|
133
|
-
// reduced to a short summary and results to ok/error, never full file contents or outputs.
|
|
134
|
-
"session.tool.input.started",
|
|
135
|
-
"session.tool.called",
|
|
136
|
-
"session.tool.success",
|
|
137
|
-
"session.tool.failed",
|
|
138
|
-
"session.reasoning.started",
|
|
139
|
-
// Reasoning text streams to the phone's "Thinking" block (deltas capped at REASONING_DELTA_MAX); the
|
|
140
|
-
// ended event's full text is dropped.
|
|
141
|
-
"session.reasoning.delta",
|
|
142
|
-
"session.reasoning.ended",
|
|
143
|
-
"permission.asked",
|
|
144
|
-
"permission.replied",
|
|
145
|
-
"form.created",
|
|
146
|
-
"form.replied",
|
|
147
|
-
"form.cancelled",
|
|
148
|
-
])
|
|
149
|
-
|
|
150
|
-
// ---------- spoke → server ----------
|
|
151
|
-
|
|
152
|
-
export interface Hello {
|
|
153
|
-
readonly t: "hello"
|
|
154
|
-
readonly v: number
|
|
155
|
-
readonly processID: string
|
|
156
|
-
readonly kind: ProcessKind
|
|
157
|
-
readonly directory: string
|
|
158
|
-
readonly pid: number
|
|
159
|
-
readonly mode: SpokeMode
|
|
160
|
-
readonly opencodeVersion: string
|
|
161
|
-
readonly pluginVersion: string
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
export interface Sessions {
|
|
165
|
-
readonly t: "sessions"
|
|
166
|
-
readonly sessions: readonly SessionInfo[]
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
export interface Event {
|
|
170
|
-
readonly t: "event"
|
|
171
|
-
readonly event: ForwardedEvent
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/** Requests the spoke makes of the server on the user's behalf (from the /hub dialog). */
|
|
175
|
-
export type SpokeRequest =
|
|
176
|
-
| { readonly op: "pairCode" }
|
|
177
|
-
| { readonly op: "handoff"; readonly sessionID: string }
|
|
178
|
-
/** A one-time sign-in link to a session's page, or to the home page without a session. */
|
|
179
|
-
/** `voice`: land on the session with Voice mode starting (`?voice=1`). */
|
|
180
|
-
| { readonly op: "openLink"; readonly sessionID?: string; readonly voice?: boolean }
|
|
181
|
-
|
|
182
|
-
// ---------- server → spoke ----------
|
|
183
|
-
|
|
184
|
-
export interface Welcome {
|
|
185
|
-
readonly t: "welcome"
|
|
186
|
-
readonly v: number
|
|
187
|
-
readonly machineID: string
|
|
188
|
-
readonly publicURL: string
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/** An image attached to a typed prompt: base64 bytes of a PNG, JPEG, WebP or GIF (see promptImagesProblem). */
|
|
192
|
-
export interface PromptImage {
|
|
193
|
-
readonly mime: string
|
|
194
|
-
readonly data: string
|
|
195
|
-
readonly name?: string
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
export type ServerRequest =
|
|
199
|
-
/** `text` may be empty when `images` are attached. */
|
|
200
|
-
| { readonly op: "turn"; readonly sessionID: string; readonly text: string; readonly voiceStyle: boolean; readonly images?: readonly PromptImage[] }
|
|
201
|
-
| { readonly op: "permission"; readonly sessionID: string; readonly requestID: string; readonly decision: PermissionDecision }
|
|
202
|
-
/** Answer (or cancel) a pending form (question) in the session that asked it. */
|
|
203
|
-
| { readonly op: "form"; readonly sessionID: string; readonly formID: string; readonly answer: FormAnswer }
|
|
204
|
-
| { readonly op: "form"; readonly sessionID: string; readonly formID: string; readonly cancel: true }
|
|
205
|
-
| { readonly op: "context"; readonly sessionID: string }
|
|
206
|
-
/** Conversation mode: deliver an utterance with delivery "steer" and return at once (replies arrive as events). */
|
|
207
|
-
| { readonly op: "steer"; readonly sessionID: string; readonly text: string; readonly images?: readonly PromptImage[] }
|
|
208
|
-
| { readonly op: "interrupt"; readonly sessionID: string }
|
|
209
|
-
/** Interrupt the session's whole tree: running descendants deepest first, then the root. */
|
|
210
|
-
| { readonly op: "stop"; readonly sessionID: string }
|
|
211
|
-
/** Right-rail data: context usage, MCP servers, branch and working-tree change stats. */
|
|
212
|
-
| { readonly op: "summary"; readonly sessionID: string }
|
|
213
|
-
/** One changed file's patch in the session's directory. */
|
|
214
|
-
| { readonly op: "diff"; readonly sessionID: string; readonly file: string }
|
|
215
|
-
/** Switch the session to the voice orchestrator agent (on) or back to its previous agent (off). */
|
|
216
|
-
| { readonly op: "conversation"; readonly sessionID: string; readonly on: boolean }
|
|
217
|
-
| { readonly op: "models"; readonly sessionID: string }
|
|
218
|
-
| { readonly op: "modelPrefs"; readonly sessionID: string }
|
|
219
|
-
| { readonly op: "setModelPrefs"; readonly sessionID: string; readonly voice?: ModelSelection | null; readonly worker?: ModelSelection | null }
|
|
220
|
-
| { readonly op: "timeline"; readonly sessionID: string; readonly limit?: number; readonly cursor?: string }
|
|
221
|
-
| { readonly op: "timelinePart"; readonly sessionID: string; readonly messageID: string; readonly partID: string }
|
|
222
|
-
/** One image attached to a user message (its position in the message's files). */
|
|
223
|
-
| { readonly op: "timelineFile"; readonly sessionID: string; readonly messageID: string; readonly index: number }
|
|
224
|
-
/** Enable or disable voice for one session (adds/removes it from the machine config's session list). */
|
|
225
|
-
| { readonly op: "expose"; readonly sessionID: string; readonly on: boolean }
|
|
226
|
-
/** Create a session in a workspace directory the spoke already reported. */
|
|
227
|
-
/**
|
|
228
|
-
* Create a session in a workspace directory the spoke already reported. With `herdrWorkspace`, also open
|
|
229
|
-
* it in a new tab of that Herdr workspace (`opencode --session <id>` in the directory).
|
|
230
|
-
*/
|
|
231
|
-
| { readonly op: "create"; readonly directory: string; readonly title?: string; readonly herdrWorkspace?: string }
|
|
232
|
-
|
|
233
|
-
export interface ContextUsage {
|
|
234
|
-
/** Tokens of the latest assistant message (input + output + reasoning + cache). */
|
|
235
|
-
readonly used: number
|
|
236
|
-
readonly limit?: number
|
|
237
|
-
readonly percent?: number
|
|
238
|
-
readonly model?: string
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
export interface McpStatus {
|
|
242
|
-
readonly name: string
|
|
243
|
-
readonly status: "connected" | "pending" | "disabled" | "failed" | "needs_auth" | "unknown"
|
|
244
|
-
readonly error?: string
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
export interface ChangedFile {
|
|
248
|
-
readonly file: string
|
|
249
|
-
readonly status: "added" | "deleted" | "modified"
|
|
250
|
-
readonly additions: number
|
|
251
|
-
readonly deletions: number
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
export interface SessionChanges {
|
|
255
|
-
/** The listed files: all of them, or (when `truncated`) the most-changed ones that fit the limits. */
|
|
256
|
-
readonly files: readonly ChangedFile[]
|
|
257
|
-
/** Line totals over every changed file, listed or not. */
|
|
258
|
-
readonly additions: number
|
|
259
|
-
readonly deletions: number
|
|
260
|
-
/** Number of changed files, listed or not. */
|
|
261
|
-
readonly totalFiles: number
|
|
262
|
-
/** True when `files` lists fewer than `totalFiles`. */
|
|
263
|
-
readonly truncated: boolean
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
export interface FilePatch extends ChangedFile {
|
|
267
|
-
readonly patch: string
|
|
268
|
-
readonly truncated: boolean
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
/** Right-rail data for a session; each part is absent when OpenCode could not provide it. */
|
|
272
|
-
export interface SessionSummary {
|
|
273
|
-
readonly directory: string
|
|
274
|
-
readonly branch?: string
|
|
275
|
-
readonly context?: ContextUsage
|
|
276
|
-
readonly mcp?: readonly McpStatus[]
|
|
277
|
-
readonly changes?: SessionChanges
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
export type TurnResult =
|
|
281
|
-
| { readonly outcome: "succeeded"; readonly reply: string }
|
|
282
|
-
| { readonly outcome: "failed" | "interrupted"; readonly message: string; readonly reply: string }
|
|
283
|
-
| { readonly outcome: "timeout"; readonly reply: string }
|
|
284
|
-
|
|
285
|
-
/** Result payloads, keyed by op. */
|
|
286
|
-
export interface ResultFor {
|
|
287
|
-
turn: TurnResult
|
|
288
|
-
/** `gone`: the request is not pending in this process any more (answered elsewhere, expired). */
|
|
289
|
-
permission: { readonly gone?: true }
|
|
290
|
-
/** `gone`: the form is not pending in this process any more (answered elsewhere, cancelled, expired). */
|
|
291
|
-
form: { readonly gone?: true }
|
|
292
|
-
context: { readonly messages: ReadonlyArray<{ readonly role: "user" | "assistant"; readonly text: string }> }
|
|
293
|
-
pairCode: { readonly code: string; readonly url: string; readonly expiresAt: number }
|
|
294
|
-
handoff: { readonly notified: number }
|
|
295
|
-
openLink: { readonly url: string }
|
|
296
|
-
steer: { readonly inboxID: string }
|
|
297
|
-
interrupt: Record<string, never>
|
|
298
|
-
stop: { readonly stopped: readonly string[] }
|
|
299
|
-
summary: SessionSummary
|
|
300
|
-
diff: { readonly patch?: FilePatch }
|
|
301
|
-
conversation: { readonly agent: string }
|
|
302
|
-
models: { readonly models: readonly AvailableModel[] }
|
|
303
|
-
modelPrefs: { readonly prefs: SessionModelPrefs; readonly defaults: SessionModelPrefs }
|
|
304
|
-
setModelPrefs: { readonly prefs: SessionModelPrefs; readonly defaults: SessionModelPrefs }
|
|
305
|
-
timeline: TimelinePage
|
|
306
|
-
timelinePart: { readonly detail: unknown }
|
|
307
|
-
/** No `file` when the message has no image at that index; `tooLarge` when its bytes would not fit in a frame. */
|
|
308
|
-
timelineFile: { readonly file?: PromptImage; readonly tooLarge?: boolean }
|
|
309
|
-
expose: { readonly exposed: boolean }
|
|
310
|
-
create: { readonly sessionID: string; readonly openedIn?: "herdr" }
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
// ---------- both directions ----------
|
|
314
|
-
|
|
315
|
-
export interface Req<R = SpokeRequest | ServerRequest> {
|
|
316
|
-
readonly t: "req"
|
|
317
|
-
readonly id: string
|
|
318
|
-
readonly req: R
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
export interface Res {
|
|
322
|
-
readonly t: "res"
|
|
323
|
-
readonly id: string
|
|
324
|
-
readonly ok: boolean
|
|
325
|
-
readonly result?: unknown
|
|
326
|
-
readonly error?: string
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
export interface Bye {
|
|
330
|
-
readonly t: "bye"
|
|
331
|
-
readonly reason: string
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
export type SpokeMessage = Hello | Sessions | Terminals | Event | Req<SpokeRequest> | Res | Bye
|
|
335
|
-
export type ServerMessage = Welcome | Req<ServerRequest> | Res | Bye
|
|
336
|
-
|
|
337
|
-
// ---------- validation ----------
|
|
338
|
-
|
|
339
|
-
const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
|
|
340
|
-
const str = (v: unknown): v is string => typeof v === "string"
|
|
341
|
-
const nonEmpty = (v: unknown): v is string => typeof v === "string" && v.length > 0
|
|
342
|
-
const num = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v)
|
|
343
|
-
const ID = /^[A-Za-z0-9_.:@/+~-]{1,300}$/
|
|
344
|
-
const isModelSelection = (v: unknown): v is ModelSelection =>
|
|
345
|
-
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)))
|
|
346
|
-
|
|
347
|
-
/** Largest frame (in characters) either side accepts; a larger one is a protocol error. */
|
|
348
|
-
export const MAX_FRAME = 1_000_000
|
|
349
|
-
|
|
350
|
-
function isSession(v: unknown): v is SessionInfo {
|
|
351
|
-
return (
|
|
352
|
-
isObj(v) &&
|
|
353
|
-
nonEmpty(v.id) &&
|
|
354
|
-
str(v.title) &&
|
|
355
|
-
str(v.directory) &&
|
|
356
|
-
num(v.updated) &&
|
|
357
|
-
typeof v.owned === "boolean" &&
|
|
358
|
-
(v.parentID === undefined || str(v.parentID)) &&
|
|
359
|
-
(v.exposed === undefined || typeof v.exposed === "boolean") &&
|
|
360
|
-
(v.project === undefined || str(v.project)) &&
|
|
361
|
-
(v.projectName === undefined || str(v.projectName)) &&
|
|
362
|
-
(v.archived === undefined || typeof v.archived === "boolean") &&
|
|
363
|
-
(v.open === undefined || typeof v.open === "boolean") &&
|
|
364
|
-
(v.terminal === undefined || isTerminal(v.terminal))
|
|
365
|
-
)
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
const TERMINAL_STATUS = new Set(["idle", "working", "blocked", "done", "unknown"])
|
|
369
|
-
|
|
370
|
-
function isTerminal(v: unknown): v is TerminalInfo {
|
|
371
|
-
return (
|
|
372
|
-
isObj(v) &&
|
|
373
|
-
(v.source === "herdr" || v.source === "tui") &&
|
|
374
|
-
(v.workspaceID === undefined || str(v.workspaceID)) &&
|
|
375
|
-
(v.workspace === undefined || str(v.workspace)) &&
|
|
376
|
-
(v.title === undefined || str(v.title)) &&
|
|
377
|
-
(v.status === undefined || TERMINAL_STATUS.has(v.status as string))
|
|
378
|
-
)
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
function isHerdrInfo(v: unknown): boolean {
|
|
382
|
-
return (
|
|
383
|
-
isObj(v) &&
|
|
384
|
-
Array.isArray(v.workspaces) &&
|
|
385
|
-
v.workspaces.length <= 200 &&
|
|
386
|
-
v.workspaces.every((w) => isObj(w) && nonEmpty(w.id) && str(w.label) && Array.isArray(w.directories) && w.directories.every(str))
|
|
387
|
-
)
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
function isSpokeRequest(v: unknown): v is SpokeRequest {
|
|
391
|
-
if (!isObj(v)) return false
|
|
392
|
-
if (v.op === "pairCode") return true
|
|
393
|
-
if (v.op === "handoff") return nonEmpty(v.sessionID)
|
|
394
|
-
if (v.op === "openLink") return (v.sessionID === undefined || nonEmpty(v.sessionID)) && (v.voice === undefined || typeof v.voice === "boolean")
|
|
395
|
-
return false
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
// ---------- prompt images ----------
|
|
399
|
-
|
|
400
|
-
/** Image types a prompt may carry. */
|
|
401
|
-
export const PROMPT_IMAGE_MIMES: readonly string[] = ["image/png", "image/jpeg", "image/webp", "image/gif"]
|
|
402
|
-
export const MAX_PROMPT_IMAGES = 4
|
|
403
|
-
/** Largest decoded image accepted. */
|
|
404
|
-
export const MAX_PROMPT_IMAGE_BYTES = 512 * 1024
|
|
405
|
-
/** Largest total base64 length of a prompt's images, so they fit in one frame with the text. */
|
|
406
|
-
export const MAX_PROMPT_IMAGES_BASE64 = 750_000
|
|
407
|
-
export const MAX_PROMPT_IMAGE_NAME_CHARS = 200
|
|
408
|
-
|
|
409
|
-
const BASE64 = /^[A-Za-z0-9+/]+={0,2}$/
|
|
410
|
-
|
|
411
|
-
/** Leading bytes of each accepted type (WebP: "RIFF" ... "WEBP"). */
|
|
412
|
-
function sniffImage(bytes: Uint8Array): string | undefined {
|
|
413
|
-
const at = (offset: number, ...values: number[]) => values.every((value, i) => bytes[offset + i] === value)
|
|
414
|
-
if (at(0, 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)) return "image/png"
|
|
415
|
-
if (at(0, 0xff, 0xd8, 0xff)) return "image/jpeg"
|
|
416
|
-
if (at(0, 0x47, 0x49, 0x46, 0x38)) return "image/gif"
|
|
417
|
-
if (at(0, 0x52, 0x49, 0x46, 0x46) && at(8, 0x57, 0x45, 0x42, 0x50)) return "image/webp"
|
|
418
|
-
return undefined
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
/**
|
|
422
|
-
* Why `value` is not an acceptable list of prompt images, or undefined when it is: at most
|
|
423
|
-
* MAX_PROMPT_IMAGES, each a known image type whose bytes match it, canonical base64 (OpenCode refuses
|
|
424
|
-
* anything else) of at most MAX_PROMPT_IMAGE_BYTES, together at most MAX_PROMPT_IMAGES_BASE64 characters.
|
|
425
|
-
*/
|
|
426
|
-
export function promptImagesProblem(value: unknown): string | undefined {
|
|
427
|
-
if (!Array.isArray(value)) return "images must be an array"
|
|
428
|
-
if (value.length > MAX_PROMPT_IMAGES) return `Attach at most ${MAX_PROMPT_IMAGES} images`
|
|
429
|
-
let total = 0
|
|
430
|
-
for (const [i, image] of value.entries()) {
|
|
431
|
-
const label = `Image ${i + 1}`
|
|
432
|
-
if (!isObj(image)) return `${label} must be {mime, data, name?}`
|
|
433
|
-
if (typeof image.mime !== "string" || !PROMPT_IMAGE_MIMES.includes(image.mime)) return `${label}: type must be one of ${PROMPT_IMAGE_MIMES.join(", ")}`
|
|
434
|
-
if (image.name !== undefined && (typeof image.name !== "string" || image.name.length > MAX_PROMPT_IMAGE_NAME_CHARS)) {
|
|
435
|
-
return `${label}: name must be a string of at most ${MAX_PROMPT_IMAGE_NAME_CHARS} characters`
|
|
436
|
-
}
|
|
437
|
-
const data = image.data
|
|
438
|
-
if (typeof data !== "string" || !data || data.length % 4 !== 0 || !BASE64.test(data)) return `${label}: data must be non-empty base64`
|
|
439
|
-
total += data.length
|
|
440
|
-
if (total > MAX_PROMPT_IMAGES_BASE64) return `Images together are larger than ${MAX_PROMPT_IMAGES_BASE64} base64 characters`
|
|
441
|
-
if ((data.length / 4) * 3 > MAX_PROMPT_IMAGE_BYTES + 2) return `${label} is larger than ${MAX_PROMPT_IMAGE_BYTES / 1024} KB`
|
|
442
|
-
const bytes = Buffer.from(data, "base64")
|
|
443
|
-
if (bytes.toString("base64") !== data) return `${label}: data must be canonical base64`
|
|
444
|
-
if (bytes.length > MAX_PROMPT_IMAGE_BYTES) return `${label} is larger than ${MAX_PROMPT_IMAGE_BYTES / 1024} KB`
|
|
445
|
-
if (sniffImage(bytes) !== image.mime) return `${label}: content does not match ${image.mime}`
|
|
446
|
-
}
|
|
447
|
-
return undefined
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
/** Text, plus optional images; the text may be empty only when images are attached. */
|
|
451
|
-
function promptBody(v: Record<string, unknown>): boolean {
|
|
452
|
-
if (!str(v.text)) return false
|
|
453
|
-
if (v.images === undefined) return v.text.length > 0
|
|
454
|
-
return Array.isArray(v.images) && v.images.length > 0 && promptImagesProblem(v.images) === undefined
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
function isServerRequest(v: unknown): v is ServerRequest {
|
|
458
|
-
if (!isObj(v)) return false
|
|
459
|
-
switch (v.op) {
|
|
460
|
-
case "turn":
|
|
461
|
-
return nonEmpty(v.sessionID) && promptBody(v) && typeof v.voiceStyle === "boolean"
|
|
462
|
-
case "permission":
|
|
463
|
-
return nonEmpty(v.sessionID) && nonEmpty(v.requestID) && ["once", "always", "reject"].includes(v.decision as string)
|
|
464
|
-
case "form":
|
|
465
|
-
if (!nonEmpty(v.sessionID) || !nonEmpty(v.formID) || v.formID.length > 300) return false
|
|
466
|
-
return v.cancel === true ? v.answer === undefined : v.cancel === undefined && formAnswerProblem(v.answer) === undefined
|
|
467
|
-
case "context":
|
|
468
|
-
case "interrupt":
|
|
469
|
-
case "stop":
|
|
470
|
-
case "summary":
|
|
471
|
-
case "models":
|
|
472
|
-
case "modelPrefs":
|
|
473
|
-
return nonEmpty(v.sessionID)
|
|
474
|
-
case "setModelPrefs":
|
|
475
|
-
return (
|
|
476
|
-
nonEmpty(v.sessionID) &&
|
|
477
|
-
(v.voice === undefined || v.voice === null || isModelSelection(v.voice)) &&
|
|
478
|
-
(v.worker === undefined || v.worker === null || isModelSelection(v.worker)) &&
|
|
479
|
-
(v.voice !== undefined || v.worker !== undefined)
|
|
480
|
-
)
|
|
481
|
-
case "timeline":
|
|
482
|
-
return (
|
|
483
|
-
nonEmpty(v.sessionID) &&
|
|
484
|
-
(v.limit === undefined || (Number.isInteger(v.limit) && Number(v.limit) >= 1 && Number(v.limit) <= 200)) &&
|
|
485
|
-
(v.cursor === undefined || (str(v.cursor) && v.cursor.length <= 4096))
|
|
486
|
-
)
|
|
487
|
-
case "timelinePart":
|
|
488
|
-
return nonEmpty(v.sessionID) && nonEmpty(v.messageID) && v.messageID.length <= 300 && nonEmpty(v.partID) && v.partID.length <= 300
|
|
489
|
-
case "steer":
|
|
490
|
-
return nonEmpty(v.sessionID) && promptBody(v)
|
|
491
|
-
case "timelineFile":
|
|
492
|
-
return nonEmpty(v.sessionID) && nonEmpty(v.messageID) && v.messageID.length <= 300 && Number.isInteger(v.index) && Number(v.index) >= 0 && Number(v.index) < 1000
|
|
493
|
-
case "diff":
|
|
494
|
-
return nonEmpty(v.sessionID) && nonEmpty(v.file) && v.file.length <= 4096
|
|
495
|
-
case "conversation":
|
|
496
|
-
case "expose":
|
|
497
|
-
return nonEmpty(v.sessionID) && typeof v.on === "boolean"
|
|
498
|
-
case "create":
|
|
499
|
-
return (
|
|
500
|
-
nonEmpty(v.directory) &&
|
|
501
|
-
v.directory.startsWith("/") &&
|
|
502
|
-
v.directory.length <= 4096 &&
|
|
503
|
-
(v.title === undefined || (str(v.title) && v.title.length <= 200)) &&
|
|
504
|
-
(v.herdrWorkspace === undefined || (nonEmpty(v.herdrWorkspace) && v.herdrWorkspace.length <= 100))
|
|
505
|
-
)
|
|
506
|
-
default:
|
|
507
|
-
return false
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
const isRes = (v: Record<string, unknown>) =>
|
|
512
|
-
nonEmpty(v.id) && typeof v.ok === "boolean" && (v.error === undefined || str(v.error))
|
|
513
|
-
const isBye = (v: Record<string, unknown>) => str(v.reason)
|
|
514
|
-
|
|
515
|
-
function parse(raw: string | Buffer): Record<string, unknown> | undefined {
|
|
516
|
-
const text = typeof raw === "string" ? raw : raw.toString("utf8")
|
|
517
|
-
if (text.length > MAX_FRAME) return undefined
|
|
518
|
-
try {
|
|
519
|
-
const value = JSON.parse(text)
|
|
520
|
-
return isObj(value) ? value : undefined
|
|
521
|
-
} catch {
|
|
522
|
-
return undefined
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
/** Parses a frame received by the server. Returns undefined for anything invalid. */
|
|
527
|
-
export function parseSpokeMessage(raw: string | Buffer): SpokeMessage | undefined {
|
|
528
|
-
const m = parse(raw)
|
|
529
|
-
if (!m) return undefined
|
|
530
|
-
switch (m.t) {
|
|
531
|
-
case "hello":
|
|
532
|
-
return num(m.v) &&
|
|
533
|
-
nonEmpty(m.processID) &&
|
|
534
|
-
(m.kind === "service" || m.kind === "standalone") &&
|
|
535
|
-
str(m.directory) &&
|
|
536
|
-
num(m.pid) &&
|
|
537
|
-
(m.mode === "always" || m.mode === "session") &&
|
|
538
|
-
str(m.opencodeVersion) &&
|
|
539
|
-
str(m.pluginVersion)
|
|
540
|
-
? (m as unknown as Hello)
|
|
541
|
-
: undefined
|
|
542
|
-
case "sessions":
|
|
543
|
-
return Array.isArray(m.sessions) && m.sessions.every(isSession) ? (m as unknown as Sessions) : undefined
|
|
544
|
-
case "terminals":
|
|
545
|
-
return m.herdr === undefined || isHerdrInfo(m.herdr) ? (m as unknown as Terminals) : undefined
|
|
546
|
-
case "event":
|
|
547
|
-
return isObj(m.event) && nonEmpty(m.event.type) && FORWARDED_EVENTS.has(m.event.type) && isObj(m.event.data)
|
|
548
|
-
? (m as unknown as Event)
|
|
549
|
-
: undefined
|
|
550
|
-
case "req":
|
|
551
|
-
return nonEmpty(m.id) && isSpokeRequest(m.req) ? (m as unknown as Req<SpokeRequest>) : undefined
|
|
552
|
-
case "res":
|
|
553
|
-
return isRes(m) ? (m as unknown as Res) : undefined
|
|
554
|
-
case "bye":
|
|
555
|
-
return isBye(m) ? (m as unknown as Bye) : undefined
|
|
556
|
-
default:
|
|
557
|
-
return undefined
|
|
558
|
-
}
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
/** Parses a frame received by the spoke. Returns undefined for anything invalid. */
|
|
562
|
-
export function parseServerMessage(raw: string | Buffer): ServerMessage | undefined {
|
|
563
|
-
const m = parse(raw)
|
|
564
|
-
if (!m) return undefined
|
|
565
|
-
switch (m.t) {
|
|
566
|
-
case "welcome":
|
|
567
|
-
return num(m.v) && nonEmpty(m.machineID) && str(m.publicURL) ? (m as unknown as Welcome) : undefined
|
|
568
|
-
case "req":
|
|
569
|
-
return nonEmpty(m.id) && isServerRequest(m.req) ? (m as unknown as Req<ServerRequest>) : undefined
|
|
570
|
-
case "res":
|
|
571
|
-
return isRes(m) ? (m as unknown as Res) : undefined
|
|
572
|
-
case "bye":
|
|
573
|
-
return isBye(m) ? (m as unknown as Bye) : undefined
|
|
574
|
-
default:
|
|
575
|
-
return undefined
|
|
576
|
-
}
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
export const encode = (message: SpokeMessage | ServerMessage): string => JSON.stringify(message)
|
|
580
|
-
|
|
581
|
-
// ---------- HTTP enrollment (spoke ↔ server, before a machine token exists) ----------
|
|
582
|
-
|
|
583
|
-
/** POST /spoke/enroll body: { machineName } → EnrollStart */
|
|
584
|
-
export interface EnrollStart {
|
|
585
|
-
readonly userCode: string
|
|
586
|
-
readonly deviceCode: string
|
|
587
|
-
readonly approveURL: string
|
|
588
|
-
readonly intervalMs: number
|
|
589
|
-
readonly expiresAt: number
|
|
590
|
-
/** A local hub approved this first machine already; the next poll returns the token. */
|
|
591
|
-
readonly autoApproved?: boolean
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
/** POST /spoke/enroll/poll body: { deviceCode } → EnrollPoll */
|
|
595
|
-
export type EnrollPoll =
|
|
596
|
-
| { readonly status: "pending" }
|
|
597
|
-
| { readonly status: "denied" | "expired" }
|
|
598
|
-
| { readonly status: "approved"; readonly machineID: string; readonly machineToken: string }
|
|
599
|
-
|
|
600
|
-
// ---------- activity feed ----------
|
|
601
|
-
|
|
602
|
-
const SUMMARY_KEYS = ["description", "command", "filePath", "path", "pattern", "url", "query", "agent", "prompt"] as const
|
|
603
|
-
export const ACTIVITY_SUMMARY_MAX = 160
|
|
604
|
-
/** Longest reasoning delta forwarded; longer deltas are cut (they are normally a few tokens). */
|
|
605
|
-
export const REASONING_DELTA_MAX = 2000
|
|
606
|
-
|
|
607
|
-
/** Short human summary of a tool input, e.g. the shell command or file path. Never the full input. */
|
|
608
|
-
export function summarizeToolInput(input: unknown): string {
|
|
609
|
-
if (typeof input !== "object" || input === null) return ""
|
|
610
|
-
const record = input as Record<string, unknown>
|
|
611
|
-
for (const key of SUMMARY_KEYS) {
|
|
612
|
-
const value = record[key]
|
|
613
|
-
if (typeof value === "string" && value.trim()) {
|
|
614
|
-
const line = value.trim().split("\n")[0]!
|
|
615
|
-
return line.length > ACTIVITY_SUMMARY_MAX ? `${line.slice(0, ACTIVITY_SUMMARY_MAX - 1)}…` : line
|
|
616
|
-
}
|
|
617
|
-
}
|
|
618
|
-
return ""
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
/**
|
|
622
|
-
* Reduces activity events to what the activity feed needs. Returns the event unchanged when it is
|
|
623
|
-
* not an activity event, or undefined when it should be dropped.
|
|
624
|
-
*/
|
|
625
|
-
export function compactActivity(event: ForwardedEvent): ForwardedEvent | undefined {
|
|
626
|
-
const d = event.data
|
|
627
|
-
const base = { sessionID: d.sessionID, assistantMessageID: d.assistantMessageID, id: d.id }
|
|
628
|
-
switch (event.type) {
|
|
629
|
-
case "session.tool.input.started":
|
|
630
|
-
return { type: event.type, data: { ...base, name: d.name } }
|
|
631
|
-
case "session.tool.called":
|
|
632
|
-
return { type: event.type, data: { ...base, summary: summarizeToolInput(d.input), background: (d.input as any)?.background === true } }
|
|
633
|
-
case "session.tool.success":
|
|
634
|
-
return { type: event.type, data: base }
|
|
635
|
-
case "session.tool.failed": {
|
|
636
|
-
const message = typeof (d.error as any)?.message === "string" ? String((d.error as any).message).slice(0, ACTIVITY_SUMMARY_MAX) : "failed"
|
|
637
|
-
return { type: event.type, data: { ...base, error: message } }
|
|
638
|
-
}
|
|
639
|
-
case "session.reasoning.started":
|
|
640
|
-
case "session.reasoning.ended":
|
|
641
|
-
return { type: event.type, data: { sessionID: d.sessionID, assistantMessageID: d.assistantMessageID } }
|
|
642
|
-
case "session.reasoning.delta":
|
|
643
|
-
return {
|
|
644
|
-
type: event.type,
|
|
645
|
-
data: { sessionID: d.sessionID, assistantMessageID: d.assistantMessageID, delta: typeof d.delta === "string" ? d.delta.slice(0, REASONING_DELTA_MAX) : "" },
|
|
646
|
-
}
|
|
647
|
-
default:
|
|
648
|
-
return event
|
|
649
|
-
}
|
|
650
|
-
}
|
|
1
|
+
// The spoke protocol lives in the private workspace package packages/protocol; imported by relative path so the
|
|
2
|
+
// published package, the Docker image and compiled binaries need no workspace link.
|
|
3
|
+
export * from "../packages/protocol/src/index.ts"
|