@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/spoke/spoke.ts
CHANGED
|
@@ -2,10 +2,20 @@
|
|
|
2
2
|
// keeps the WebSocket to the central server, forwards exposed events, and executes the server's
|
|
3
3
|
// requests (voice turns, permission replies, context reads) through the plugin context.
|
|
4
4
|
import path from "node:path"
|
|
5
|
-
import { readConfig, setSession, withServer, writeConfig, type Mode, type VoiceConfig, explicitVariant, formatModelRef, parseModelRef, plainModelRef, type ModelRef } from "../config"
|
|
5
|
+
import { defaults, readConfig, setSession, withServer, writeConfig, type LaunchConfig, type Mode, type VoiceConfig, explicitVariant, formatModelRef, parseModelRef, plainModelRef, type ModelRef } from "../config"
|
|
6
6
|
import { isLoopbackURL } from "../loopback"
|
|
7
7
|
import {
|
|
8
8
|
compactActivity,
|
|
9
|
+
COMPACTED_EVENTS,
|
|
10
|
+
FORWARDED_EVENTS,
|
|
11
|
+
HARNESS_OPENCODE,
|
|
12
|
+
isLaunchCreate,
|
|
13
|
+
SEED_JOBS_MAX,
|
|
14
|
+
SPOKE_FEATURES,
|
|
15
|
+
type DeliverRequest,
|
|
16
|
+
type DeliverResult,
|
|
17
|
+
type DeliveryMode,
|
|
18
|
+
type JobInfo,
|
|
9
19
|
type HerdrWorkspace,
|
|
10
20
|
type FormAnswer,
|
|
11
21
|
type PermissionDecision,
|
|
@@ -13,6 +23,9 @@ import {
|
|
|
13
23
|
type PromptImage,
|
|
14
24
|
type ResultFor,
|
|
15
25
|
type ServerRequest,
|
|
26
|
+
type SessionTokensRequest,
|
|
27
|
+
type ActTokensRequest,
|
|
28
|
+
type CreateRequest,
|
|
16
29
|
type SessionModelPrefs,
|
|
17
30
|
type SpokeRequest,
|
|
18
31
|
type TerminalInfo,
|
|
@@ -20,15 +33,19 @@ import {
|
|
|
20
33
|
} from "../protocol"
|
|
21
34
|
import { extractReply } from "../reply"
|
|
22
35
|
import { TurnTracker } from "../turns"
|
|
23
|
-
import { boundedTimelinePart, timelineFile, timelineItems } from "./timeline"
|
|
36
|
+
import { TimelineCache, boundedTimelinePart, newestPage, timelineFile, timelineItems } from "./timeline"
|
|
24
37
|
import { SpokeConnection, type Backoff, type ConnectionState } from "./client"
|
|
25
38
|
import { PROMPT_SOURCE, VOICE_AGENT } from "./conversation"
|
|
39
|
+
import { DeliveryLedger, deliverMetadata, deliverText, harnessMessageID, InboxOrigins, planDelivery } from "./deliver"
|
|
26
40
|
import { pollEnrollment, startEnrollment } from "./enroll"
|
|
27
41
|
import { HerdrWatcher, openInHerdr, type HerdrRunner } from "./herdr"
|
|
28
42
|
import type { LocalHubResult } from "./localhub"
|
|
43
|
+
import { launchDirectoryProblem } from "./node-roots"
|
|
29
44
|
import { PresenceBook } from "./presence"
|
|
45
|
+
import { SessionTokenStore } from "./session-tokens"
|
|
30
46
|
import { changeStats, contextUsage, filePatch, mcpServers } from "./summary"
|
|
31
47
|
import { browserSessions, isExposed, isReported, SessionTracker, shouldForward, type EventLike, type SessionDetails } from "./tracker"
|
|
48
|
+
import { renderTaskBoard, TaskBoard } from "./taskboard"
|
|
32
49
|
import { watchConfig, type ConfigWatcher } from "./watch"
|
|
33
50
|
|
|
34
51
|
export type SpokeErrorName = "not_connected" | "not_enrolled" | "invalid" | "unreachable"
|
|
@@ -76,15 +93,23 @@ export interface SpokeHost {
|
|
|
76
93
|
readonly directory: string
|
|
77
94
|
readonly opencodeVersion: string
|
|
78
95
|
events(signal: AbortSignal): AsyncIterable<EventLike>
|
|
79
|
-
|
|
96
|
+
/** `id`: a client message id (msg_…, OpenCode applies it once); `resume: false` admits without waking. */
|
|
97
|
+
prompt(input: { sessionID: string; text: string; files?: readonly PromptFile[]; metadata: Record<string, unknown>; delivery?: DeliveryMode; id?: string; resume?: boolean }): Promise<{ id: string }>
|
|
98
|
+
/**
|
|
99
|
+
* Admits text as a synthetic user message (OpenCode's session.synthetic): the model sees only `text`; the
|
|
100
|
+
* prompt hook does not run. `id` (msg_…) makes it idempotent; `resume: false` admits without waking.
|
|
101
|
+
* Absent: this OpenCode cannot take hub messages (no "deliver" feature).
|
|
102
|
+
*/
|
|
103
|
+
synthetic?(input: { sessionID: string; id: string; text: string; metadata: Record<string, unknown>; delivery: DeliveryMode; resume: boolean }): Promise<{ id?: string }>
|
|
80
104
|
context(sessionID: string): Promise<ReadonlyArray<unknown>>
|
|
81
105
|
listModels?(): Promise<ReadonlyArray<unknown>>
|
|
82
106
|
getSession(sessionID: string): Promise<HostSession>
|
|
83
107
|
/**
|
|
84
108
|
* Replies to a pending permission; "gone" when it is not pending here any more (answered, expired).
|
|
85
|
-
* `directory` is the session's directory when known (permissions are pending per location).
|
|
109
|
+
* `directory` is the session's directory when known (permissions are pending per location). `message`: a
|
|
110
|
+
* reject's reason, which OpenCode gives the model as the rejection's feedback.
|
|
86
111
|
*/
|
|
87
|
-
replyPermission(input: { sessionID: string; requestID: string; decision: PermissionDecision; directory?: string }): Promise<unknown>
|
|
112
|
+
replyPermission(input: { sessionID: string; requestID: string; decision: PermissionDecision; message?: string; directory?: string }): Promise<unknown>
|
|
88
113
|
/**
|
|
89
114
|
* Answers or cancels a pending form through OpenCode's API: "gone" when it is not pending any more,
|
|
90
115
|
* "unavailable" when this process cannot reach its own server (then a TUI relays it, see formActions).
|
|
@@ -92,11 +117,25 @@ export interface SpokeHost {
|
|
|
92
117
|
*/
|
|
93
118
|
replyForm?(input: FormAction): Promise<"replied" | "gone" | "unavailable">
|
|
94
119
|
interrupt(sessionID: string): Promise<unknown>
|
|
120
|
+
/** Changes a session's settings in OpenCode (session.update); only the title is used. */
|
|
121
|
+
updateSession?(sessionID: string, input: { title?: string }): Promise<unknown>
|
|
122
|
+
/**
|
|
123
|
+
* Has OpenCode regenerate the session's title with its title agent (session.update with an empty title on
|
|
124
|
+
* this process's own server; the plugin API's update would set an empty title instead). Resolves when
|
|
125
|
+
* OpenCode is done ("done", whether or not the title changed); "unavailable" when the server cannot be reached.
|
|
126
|
+
*/
|
|
127
|
+
generateTitle?(sessionID: string): Promise<"done" | "unavailable">
|
|
95
128
|
switchAgent(sessionID: string, agent: string): Promise<unknown>
|
|
96
129
|
switchModel(sessionID: string, model: ModelRef): Promise<unknown>
|
|
97
130
|
hasAgent(id: string): Promise<boolean>
|
|
98
131
|
/** The agent a session without an explicit selection runs with. */
|
|
99
132
|
defaultAgent(): Promise<string | undefined>
|
|
133
|
+
/**
|
|
134
|
+
* What runs right now, from this process's own OpenCode server: sessions with a run in progress
|
|
135
|
+
* (session.active) and running shells in the given directories (shell.list). Used to show sessions that
|
|
136
|
+
* were already running when this process started, or while its event stream was down.
|
|
137
|
+
*/
|
|
138
|
+
activity?(directories: readonly string[]): Promise<{ readonly active: readonly string[]; readonly jobs: ReadonlyArray<JobInfo & { readonly sessionID: string }> }>
|
|
100
139
|
/**
|
|
101
140
|
* Recent root sessions from the local service with their workspace (service processes only): those in
|
|
102
141
|
* the history window (`days`, at most `max`), plus `include` (always listed: open, running, ...).
|
|
@@ -159,6 +198,21 @@ export interface SpokeOptions {
|
|
|
159
198
|
readonly herdrSettleMs?: number
|
|
160
199
|
/** How often expired TUI presence is checked (default 5 s). */
|
|
161
200
|
readonly presenceCheckMs?: number
|
|
201
|
+
/** How long autoName waits for OpenCode's generated title before answering "pending" (default 20 s). */
|
|
202
|
+
readonly autoNameWaitMs?: number
|
|
203
|
+
/** After generation returns without a session.renamed event, how long to wait for it (default 1.5 s). */
|
|
204
|
+
readonly autoNameSettleMs?: number
|
|
205
|
+
/**
|
|
206
|
+
* The machine's node workspace roots (node.json, src/spoke/node-roots.ts), read on each launch create: a launch's
|
|
207
|
+
* directory must be under one. Absent: no roots, so launch creates are refused.
|
|
208
|
+
*/
|
|
209
|
+
readonly workspaceRoots?: () => readonly string[]
|
|
210
|
+
/**
|
|
211
|
+
* Launch-bound mode (a spoke inside a launch's sandbox, src/config.ts LaunchConfig): connect to `hubURL` with the
|
|
212
|
+
* launch token and announce `launchID` in the hello; the machine config is never read, watched or written, and the
|
|
213
|
+
* spoke never enrols, starts a local hub or reads Herdr.
|
|
214
|
+
*/
|
|
215
|
+
readonly launch?: LaunchConfig
|
|
162
216
|
}
|
|
163
217
|
|
|
164
218
|
export type EnrollmentState = "pending" | "approved" | "denied" | "expired" | "error"
|
|
@@ -215,11 +269,21 @@ const FALLBACK_AGENT = "build"
|
|
|
215
269
|
const LOCAL_HUB_MAX_RETRY_MS = 15 * 60_000
|
|
216
270
|
const ENROLL_RETRY_MS = 500
|
|
217
271
|
const NOT_SHARED = "This session is not shared with voice"
|
|
272
|
+
const CANNOT_AUTO_NAME = "This OpenCode process cannot auto-name sessions (its server cannot be reached); use /rename in its terminal"
|
|
273
|
+
export const AUTO_NAME_WAIT_MS = 20_000
|
|
274
|
+
/** Longest session title the hub sets (OpenCode's own titles are shorter). */
|
|
275
|
+
export const MAX_TITLE_CHARS = 200
|
|
218
276
|
const UNKNOWN_SESSION = "Unknown session"
|
|
219
277
|
/** Events after which the service re-lists its sessions (other processes' sessions appear, change, go). */
|
|
220
278
|
const LISTING_EVENTS = new Set(["session.created", "session.renamed", "session.deleted"])
|
|
221
279
|
|
|
222
280
|
const errorText = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause))
|
|
281
|
+
const LAUNCH_BOUND = "This spoke is bound to a launch; its hub connection is fixed by the launch config"
|
|
282
|
+
|
|
283
|
+
/** The fixed config of a launch-bound spoke: always shared, no local hub, no Herdr. */
|
|
284
|
+
function launchVoiceConfig(launch: LaunchConfig, machineName: string): VoiceConfig {
|
|
285
|
+
return { ...defaults(machineName), server: launch.hubURL, mode: "always", token: launch.token, autoStartLocalHub: false, herdr: false }
|
|
286
|
+
}
|
|
223
287
|
|
|
224
288
|
export class Spoke {
|
|
225
289
|
private config: VoiceConfig
|
|
@@ -227,9 +291,24 @@ export class Spoke {
|
|
|
227
291
|
private connection?: SpokeConnection
|
|
228
292
|
private connectionKey = ""
|
|
229
293
|
private readonly tracker = new SessionTracker()
|
|
294
|
+
/** Session tokens from the hub (sessionTokens op), for the sessions' shells (P1-T6). Never logged. */
|
|
295
|
+
private readonly tokens = new SessionTokenStore()
|
|
296
|
+
/**
|
|
297
|
+
* Act tokens from the hub (actTokens op): the credential for act verbs, kept apart from `tokens` so no shell
|
|
298
|
+
* ever gets one. Only actIdentity reads them (the OpenCode hub tool). Never logged, never in any environment.
|
|
299
|
+
*/
|
|
300
|
+
private readonly actTokens = new SessionTokenStore()
|
|
301
|
+
/** The background workers each session started (the voice agent's task board). */
|
|
302
|
+
private readonly tasks = new TaskBoard()
|
|
230
303
|
private readonly turns = new TurnTracker()
|
|
304
|
+
/** Timeline rows of sessions being paged through (30 s, dropped on any event of the session). */
|
|
305
|
+
private readonly timelines = new TimelineCache()
|
|
231
306
|
private readonly voiceTurns = new Map<string, number>()
|
|
232
307
|
private readonly running = new Set<string>()
|
|
308
|
+
/** Hub messages applied (by messageID), so a retry is not admitted again. */
|
|
309
|
+
private readonly deliveries = new DeliveryLedger()
|
|
310
|
+
/** Who put each inbox item into a session (forwarded with session.inbox.delivered). */
|
|
311
|
+
private readonly origins = new InboxOrigins()
|
|
233
312
|
private readonly infoTried = new Map<string, number>()
|
|
234
313
|
/** Sessions in conversation mode → the agent to restore when it ends (undefined: the default agent). */
|
|
235
314
|
private readonly conversations = new Map<string, { previous?: string; previousModel?: ModelRef; voiceModel?: ModelRef }>()
|
|
@@ -237,6 +316,13 @@ export class Spoke {
|
|
|
237
316
|
private readonly modelPrefs = new Map<string, SessionModelPrefs>()
|
|
238
317
|
/** Per session with a Voice override applied: the model to restore when it is cleared, and the override. */
|
|
239
318
|
private readonly voiceOverrides = new Map<string, { readonly previous?: ModelRef; readonly applied: ModelRef }>()
|
|
319
|
+
/**
|
|
320
|
+
* Session → its current model as last seen (getSession, our switchModel, OpenCode's session.model.selected),
|
|
321
|
+
* so an utterance needs no getSession round trip. Cleared when the event stream restarts (events may be lost).
|
|
322
|
+
*/
|
|
323
|
+
private readonly sessionModels = new Map<string, { readonly model?: ModelRef }>()
|
|
324
|
+
/** Session → callbacks waiting for its next session.renamed event (autoName). */
|
|
325
|
+
private readonly renameWaiters = new Map<string, Set<(title: string) => void>>()
|
|
240
326
|
private conversationChain: Promise<unknown> = Promise.resolve()
|
|
241
327
|
private readonly abort = new AbortController()
|
|
242
328
|
private snapshotTimer?: ReturnType<typeof setTimeout>
|
|
@@ -244,6 +330,8 @@ export class Spoke {
|
|
|
244
330
|
private intervals: Array<ReturnType<typeof setInterval>> = []
|
|
245
331
|
private enrollment?: { state: EnrollmentState; abort: AbortController }
|
|
246
332
|
private started = false
|
|
333
|
+
/** A seedActivity call is in flight. */
|
|
334
|
+
private seeding = false
|
|
247
335
|
/** Sessions shown by OpenCode TUIs (voice.presence). */
|
|
248
336
|
private readonly presenceBook = new PresenceBook()
|
|
249
337
|
/** Form replies waiting for a TUI to carry them out (when the plugin cannot reach its own server). */
|
|
@@ -275,19 +363,14 @@ export class Spoke {
|
|
|
275
363
|
private readonly host: SpokeHost,
|
|
276
364
|
private readonly options: SpokeOptions,
|
|
277
365
|
) {
|
|
278
|
-
this.config = readConfig(options.configFile, options.machineName)
|
|
366
|
+
this.config = options.launch ? launchVoiceConfig(options.launch, options.machineName) : readConfig(options.configFile, options.machineName)
|
|
279
367
|
this.hub = { pending: false, nextAt: 0, cooldown: options.localHubRetryMs ?? 30_000 }
|
|
280
368
|
}
|
|
281
369
|
|
|
282
370
|
start(): void {
|
|
283
371
|
if (this.started) return
|
|
284
372
|
this.started = true
|
|
285
|
-
|
|
286
|
-
this.watcher = watchConfig(this.options.configFile, this.options.machineName, (c) => this.apply(c), { pollMs: this.options.pollMs })
|
|
287
|
-
this.config = this.watcher.current()
|
|
288
|
-
} catch (cause) {
|
|
289
|
-
console.error("[hub] config watch failed", cause)
|
|
290
|
-
}
|
|
373
|
+
if (!this.options.launch) this.watch()
|
|
291
374
|
void this.listen()
|
|
292
375
|
this.apply(this.config)
|
|
293
376
|
this.every(this.options.snapshotIntervalMs ?? 30_000, () => this.sendSnapshot())
|
|
@@ -298,6 +381,15 @@ export class Spoke {
|
|
|
298
381
|
}
|
|
299
382
|
}
|
|
300
383
|
|
|
384
|
+
private watch(): void {
|
|
385
|
+
try {
|
|
386
|
+
this.watcher = watchConfig(this.options.configFile, this.options.machineName, (c) => this.apply(c), { pollMs: this.options.pollMs })
|
|
387
|
+
this.config = this.watcher.current()
|
|
388
|
+
} catch (cause) {
|
|
389
|
+
console.error("[hub] config watch failed", cause)
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
301
393
|
stop(): void {
|
|
302
394
|
this.started = false
|
|
303
395
|
this.abort.abort()
|
|
@@ -315,6 +407,8 @@ export class Spoke {
|
|
|
315
407
|
this.connection?.stop()
|
|
316
408
|
this.connection = undefined
|
|
317
409
|
this.connectionKey = ""
|
|
410
|
+
this.tokens.clear()
|
|
411
|
+
this.actTokens.clear()
|
|
318
412
|
}
|
|
319
413
|
|
|
320
414
|
isVoiceTurn(sessionID: string): boolean {
|
|
@@ -335,6 +429,46 @@ export class Spoke {
|
|
|
335
429
|
return this.config.workModel ?? (entry.previousModel ? formatModelRef(entry.previousModel) : undefined)
|
|
336
430
|
}
|
|
337
431
|
|
|
432
|
+
/** The rendered task board of a session's workers (for the voice agent's context); undefined without any. */
|
|
433
|
+
taskBoard(sessionID: string): string | undefined {
|
|
434
|
+
return renderTaskBoard(this.tasks.children(sessionID), Date.now())
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* hub_task_stop: interrupts one of `callerID`'s workers (a session below it), or all its running workers, and
|
|
439
|
+
* whatever runs below them, deepest first. Never the caller itself, nor a session outside its tree.
|
|
440
|
+
*/
|
|
441
|
+
async stopWorkers(callerID: string, input: { sessionID?: unknown; all?: unknown }): Promise<{ stopped: string[]; text: string }> {
|
|
442
|
+
let targets: string[]
|
|
443
|
+
if (input.all === true) {
|
|
444
|
+
targets = this.tasks
|
|
445
|
+
.children(callerID)
|
|
446
|
+
.filter((t) => t.state === "working" || t.state === "blocked")
|
|
447
|
+
.map((t) => t.id)
|
|
448
|
+
if (!targets.length) return { stopped: [], text: "None of your workers is running." }
|
|
449
|
+
} else if (typeof input.sessionID === "string" && input.sessionID) {
|
|
450
|
+
const id = input.sessionID
|
|
451
|
+
if (id === callerID || !this.tracker.lineage(id).slice(1).includes(callerID)) {
|
|
452
|
+
return { stopped: [], text: `${id} is not one of your workers; use a sessionID from your task board.` }
|
|
453
|
+
}
|
|
454
|
+
targets = [id]
|
|
455
|
+
} else return { stopped: [], text: "Pass the worker's sessionID from your task board, or all: true for every worker." }
|
|
456
|
+
const stopped: string[] = []
|
|
457
|
+
for (const target of targets) {
|
|
458
|
+
for (const id of [...this.tracker.runningBelow(target), target]) {
|
|
459
|
+
if (id === callerID) continue
|
|
460
|
+
try {
|
|
461
|
+
await this.host.interrupt(id)
|
|
462
|
+
stopped.push(id)
|
|
463
|
+
} catch {
|
|
464
|
+
// already settled
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
const names = targets.map((id) => `"${this.tasks.get(id)?.title ?? id}"`)
|
|
469
|
+
return { stopped, text: `Stopped ${names.join(", ")}.` }
|
|
470
|
+
}
|
|
471
|
+
|
|
338
472
|
subagent(): string {
|
|
339
473
|
return this.config.subagent
|
|
340
474
|
}
|
|
@@ -456,6 +590,42 @@ export class Spoke {
|
|
|
456
590
|
})
|
|
457
591
|
}
|
|
458
592
|
|
|
593
|
+
/** Whether the hub this spoke is connected to announced `feature` in its welcome (older hubs announce none). */
|
|
594
|
+
/** The session's current (unexpired) hub session token, for its shells (HUB_SESSION_TOKEN). */
|
|
595
|
+
sessionToken(sessionID: string): string | undefined {
|
|
596
|
+
return this.tokens.get(sessionID)?.token
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* What a session's shells get (src/spoke/env-inject.ts): its current token, with HUB_URL the server this spoke
|
|
601
|
+
* reaches the hub at (same machine as the shells), else the URL the hub sent. Undefined without a token.
|
|
602
|
+
*/
|
|
603
|
+
shellIdentity(sessionID: string): { token: string; url?: string } | undefined {
|
|
604
|
+
const token = this.sessionToken(sessionID)
|
|
605
|
+
if (!token) return undefined
|
|
606
|
+
const url = this.config.server || this.tokens.hubURL
|
|
607
|
+
return url ? { token, url } : { token }
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* The session's act token (actTokens op) with the hub URL, for the OpenCode hub tool's act verbs only. Never
|
|
612
|
+
* hand it to a shell, an environment, a log or a tool's output. Undefined without an act token or a hub URL.
|
|
613
|
+
*/
|
|
614
|
+
actIdentity(sessionID: string): { token: string; url: string } | undefined {
|
|
615
|
+
const token = this.actTokens.get(sessionID)?.token
|
|
616
|
+
const url = this.config.server || this.tokens.hubURL
|
|
617
|
+
return token && url ? { token, url } : undefined
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/** The URL the hub told this spoke its sessions' shells reach it at (HUB_URL), if it sent one. */
|
|
621
|
+
get hubURL(): string | undefined {
|
|
622
|
+
return this.tokens.hubURL
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
hubSupports(feature: string): boolean {
|
|
626
|
+
return this.connection?.hubSupports(feature) === true
|
|
627
|
+
}
|
|
628
|
+
|
|
459
629
|
status(sessionID?: string): SpokeStatus {
|
|
460
630
|
const connection = this.connection?.state ?? "off"
|
|
461
631
|
const lastError = this.connection?.lastError
|
|
@@ -491,6 +661,7 @@ export class Spoke {
|
|
|
491
661
|
|
|
492
662
|
/** Starts device enrollment; polling continues in the background and stores the token on approval. */
|
|
493
663
|
async enroll(): Promise<EnrollResult> {
|
|
664
|
+
if (this.options.launch) throw new SpokeError("invalid", LAUNCH_BOUND)
|
|
494
665
|
const { server, machineName, autoStartLocalHub } = this.current()
|
|
495
666
|
this.enrollment?.abort.abort()
|
|
496
667
|
const abort = new AbortController()
|
|
@@ -566,6 +737,7 @@ export class Spoke {
|
|
|
566
737
|
* with autoStartLocalHub off, and reconnects at once when it answers.
|
|
567
738
|
*/
|
|
568
739
|
async startLocalHub(): Promise<{ status: "started" | "running" | "busy" }> {
|
|
740
|
+
if (this.options.launch) throw new SpokeError("invalid", LAUNCH_BOUND)
|
|
569
741
|
const { server } = this.current()
|
|
570
742
|
const starter = this.options.localHub
|
|
571
743
|
if (!isLoopbackURL(server)) throw new SpokeError("invalid", `${server} is not a local hub`)
|
|
@@ -606,10 +778,12 @@ export class Spoke {
|
|
|
606
778
|
}
|
|
607
779
|
|
|
608
780
|
private current(): VoiceConfig {
|
|
781
|
+
if (this.options.launch) return this.config
|
|
609
782
|
return this.watcher?.refresh() ?? readConfig(this.options.configFile, this.options.machineName)
|
|
610
783
|
}
|
|
611
784
|
|
|
612
785
|
private write(config: VoiceConfig): void {
|
|
786
|
+
if (this.options.launch) throw new SpokeError("invalid", LAUNCH_BOUND)
|
|
613
787
|
writeConfig(this.options.configFile, config)
|
|
614
788
|
if (this.watcher) this.watcher.refresh()
|
|
615
789
|
else this.apply(readConfig(this.options.configFile, this.options.machineName))
|
|
@@ -625,6 +799,9 @@ export class Spoke {
|
|
|
625
799
|
this.connection?.stop()
|
|
626
800
|
this.connection = undefined
|
|
627
801
|
this.connectionKey = key
|
|
802
|
+
// Tokens belong to the hub and machine they came from; the next connection's hub sends its own.
|
|
803
|
+
this.tokens.clear()
|
|
804
|
+
this.actTokens.clear()
|
|
628
805
|
if (active) this.connect(config)
|
|
629
806
|
}
|
|
630
807
|
if (previous.sessions.join() !== config.sessions.join()) this.scheduleSnapshot()
|
|
@@ -643,13 +820,21 @@ export class Spoke {
|
|
|
643
820
|
directory: this.host.directory,
|
|
644
821
|
pid: this.options.pid,
|
|
645
822
|
mode: this.config.mode === "session" ? "session" : "always",
|
|
823
|
+
// Hubs that predate `harness` require opencodeVersion; newer ones read harness, harnessVersion, evidence.
|
|
646
824
|
opencodeVersion: this.host.opencodeVersion,
|
|
647
825
|
pluginVersion: this.options.pluginVersion,
|
|
826
|
+
// Hub messages and prompts need OpenCode's synthetic messages.
|
|
827
|
+
features: SPOKE_FEATURES.filter((f) => (f !== "deliver" && f !== "hubPrompt") || this.host.synthetic !== undefined),
|
|
828
|
+
harness: HARNESS_OPENCODE,
|
|
829
|
+
harnessVersion: this.host.opencodeVersion,
|
|
830
|
+
evidence: "native",
|
|
831
|
+
...(this.options.launch ? { launchID: this.options.launch.launchID } : {}),
|
|
648
832
|
}),
|
|
649
833
|
handle: (req) => this.handle(req),
|
|
650
834
|
onWelcome: () => {
|
|
651
835
|
this.sendSnapshot()
|
|
652
836
|
this.sendTerminals(true)
|
|
837
|
+
void this.seedActivity()
|
|
653
838
|
},
|
|
654
839
|
onState: (state, error) => {
|
|
655
840
|
if (state === "revoked") console.error("[hub] this machine was revoked by the hub")
|
|
@@ -671,7 +856,7 @@ export class Spoke {
|
|
|
671
856
|
|
|
672
857
|
/** After a failed connection to a loopback server: maybe start the local hub (see SpokeOptions.localHub). */
|
|
673
858
|
private maybeStartLocalHub(server: string): void {
|
|
674
|
-
const starter = this.options.localHub
|
|
859
|
+
const starter = this.options.launch ? undefined : this.options.localHub
|
|
675
860
|
if (!starter || !this.config.autoStartLocalHub || !isLoopbackURL(server)) return
|
|
676
861
|
if (this.hub.pending || Date.now() < this.hub.nextAt) return
|
|
677
862
|
this.hub.pending = true
|
|
@@ -719,6 +904,7 @@ export class Spoke {
|
|
|
719
904
|
private async conversationOn(sessionID: string): Promise<ResultFor["conversation"]> {
|
|
720
905
|
const entry = this.conversations.get(sessionID)
|
|
721
906
|
const session = await this.host.getSession(sessionID)
|
|
907
|
+
this.rememberModel(sessionID, session.model)
|
|
722
908
|
const current = session.agent
|
|
723
909
|
const models = await this.voiceModelOn(sessionID, session, entry)
|
|
724
910
|
if (!(await this.host.hasAgent(VOICE_AGENT))) {
|
|
@@ -747,7 +933,7 @@ export class Spoke {
|
|
|
747
933
|
const current = session.model
|
|
748
934
|
const onVoice = current !== undefined && sameModel(current, voiceModel)
|
|
749
935
|
const previousModel = entry?.voiceModel ? entry.previousModel : onVoice || !current ? undefined : plainModelRef(current)
|
|
750
|
-
if (!onVoice) await this.
|
|
936
|
+
if (!onVoice) await this.switchModel(sessionID, voiceModel)
|
|
751
937
|
return { voiceModel, ...(previousModel ? { previousModel } : {}) }
|
|
752
938
|
}
|
|
753
939
|
|
|
@@ -755,9 +941,10 @@ export class Spoke {
|
|
|
755
941
|
const entry = this.conversations.get(sessionID)
|
|
756
942
|
this.conversations.delete(sessionID)
|
|
757
943
|
const session = await this.host.getSession(sessionID)
|
|
944
|
+
this.rememberModel(sessionID, session.model)
|
|
758
945
|
// Restore the model only if the session is still on the voice model (the user may have picked another).
|
|
759
946
|
if (entry?.voiceModel && entry.previousModel && session.model && sameModel(session.model, entry.voiceModel)) {
|
|
760
|
-
await this.
|
|
947
|
+
await this.switchModel(sessionID, entry.previousModel)
|
|
761
948
|
}
|
|
762
949
|
const current = session.agent
|
|
763
950
|
if (current !== VOICE_AGENT) return { agent: current ?? (await this.defaultAgent()) }
|
|
@@ -778,7 +965,9 @@ export class Spoke {
|
|
|
778
965
|
private async handle(req: ServerRequest): Promise<unknown> {
|
|
779
966
|
// Session browser ops are not gated by exposure: they are how a session gets exposed.
|
|
780
967
|
if (req.op === "expose") return this.expose(req.sessionID, req.on)
|
|
781
|
-
if (req.op === "create") return this.create(req
|
|
968
|
+
if (req.op === "create") return this.create(req)
|
|
969
|
+
if (req.op === "sessionTokens") return this.storeTokens(this.tokens, req)
|
|
970
|
+
if (req.op === "actTokens") return this.storeTokens(this.actTokens, req)
|
|
782
971
|
// Inspection and control need a session this process knows; Voice (conversation) is gated separately.
|
|
783
972
|
if (!isReported(this.config, this.tracker, req.sessionID) && !(await this.adopt(req.sessionID))) throw new Error(UNKNOWN_SESSION)
|
|
784
973
|
switch (req.op) {
|
|
@@ -786,7 +975,13 @@ export class Spoke {
|
|
|
786
975
|
return this.turn(req.sessionID, req.text, req.voiceStyle, req.images)
|
|
787
976
|
case "permission": {
|
|
788
977
|
const directory = this.tracker.directoryOf(req.sessionID)
|
|
789
|
-
const outcome = await this.host.replyPermission({
|
|
978
|
+
const outcome = await this.host.replyPermission({
|
|
979
|
+
sessionID: req.sessionID,
|
|
980
|
+
requestID: req.requestID,
|
|
981
|
+
decision: req.decision,
|
|
982
|
+
...(req.message !== undefined ? { message: req.message } : {}),
|
|
983
|
+
...(directory ? { directory } : {}),
|
|
984
|
+
})
|
|
790
985
|
return outcome === "gone" ? { gone: true } : {}
|
|
791
986
|
}
|
|
792
987
|
case "form":
|
|
@@ -827,7 +1022,10 @@ export class Spoke {
|
|
|
827
1022
|
return this.modelPrefsResult(req.sessionID)
|
|
828
1023
|
}
|
|
829
1024
|
case "timeline": {
|
|
830
|
-
|
|
1025
|
+
// A first page reads OpenCode (it shows the newest state); following pages reuse those rows briefly.
|
|
1026
|
+
const first = req.cursor === undefined && req.before === undefined
|
|
1027
|
+
const items = await this.timelines.get(req.sessionID, async () => timelineItems(req.sessionID, await this.host.context(req.sessionID)), first)
|
|
1028
|
+
if (req.before !== undefined || req.order === "desc") return newestPage(items, req.limit ?? 50, req.before) satisfies ResultFor["timeline"]
|
|
831
1029
|
const offset = req.cursor === undefined ? 0 : Number(req.cursor)
|
|
832
1030
|
if (!Number.isInteger(offset) || offset < 0) throw new Error("Invalid timeline cursor")
|
|
833
1031
|
const page = items.slice(offset, offset + (req.limit ?? 50))
|
|
@@ -853,6 +1051,10 @@ export class Spoke {
|
|
|
853
1051
|
case "interrupt":
|
|
854
1052
|
await this.host.interrupt(req.sessionID)
|
|
855
1053
|
return {}
|
|
1054
|
+
case "rename":
|
|
1055
|
+
return this.rename(req.sessionID, req.title)
|
|
1056
|
+
case "autoName":
|
|
1057
|
+
return this.autoName(req.sessionID)
|
|
856
1058
|
case "stop":
|
|
857
1059
|
return this.stopTree(req.sessionID)
|
|
858
1060
|
case "summary":
|
|
@@ -864,6 +1066,101 @@ export class Spoke {
|
|
|
864
1066
|
}
|
|
865
1067
|
case "conversation":
|
|
866
1068
|
return this.setConversation(req.sessionID, req.on)
|
|
1069
|
+
case "deliver":
|
|
1070
|
+
return this.deliver(req)
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/**
|
|
1075
|
+
* Admits a hub message as a synthetic message (plan in ./deliver). Never touches the session's voice
|
|
1076
|
+
* settings, model or agent, and takes no turn lock: OpenCode queues it for busy sessions and open forms.
|
|
1077
|
+
*/
|
|
1078
|
+
private async deliver(req: DeliverRequest): Promise<DeliverResult> {
|
|
1079
|
+
const synthetic = this.host.synthetic?.bind(this.host)
|
|
1080
|
+
if (!synthetic) throw new Error("This OpenCode process cannot take hub messages")
|
|
1081
|
+
return this.deliveries.run(req.messageID, req.wake, async () => {
|
|
1082
|
+
const subagent = (this.tracker.parentOf(req.sessionID) ?? (await this.host.getSession(req.sessionID).catch(() => undefined))?.parentID) !== undefined
|
|
1083
|
+
const plan = planDelivery(req, {
|
|
1084
|
+
busy: this.running.has(req.sessionID) || this.tracker.isBusy(req.sessionID),
|
|
1085
|
+
formOpen: this.tracker.formOpenIn(req.sessionID),
|
|
1086
|
+
subagent,
|
|
1087
|
+
})
|
|
1088
|
+
const id = harnessMessageID(req.messageID)
|
|
1089
|
+
// Marked before sending: OpenCode may report it delivered before the call returns.
|
|
1090
|
+
this.origins.hub(id)
|
|
1091
|
+
const admitted = await synthetic({ sessionID: req.sessionID, id, text: deliverText(req), metadata: deliverMetadata(req), delivery: plan.delivery, resume: plan.resume })
|
|
1092
|
+
this.timelines.invalidate(req.sessionID)
|
|
1093
|
+
return { result: { delivered: plan.delivered, inboxID: typeof admitted?.id === "string" && admitted.id ? admitted.id : id }, resumed: plan.resume }
|
|
1094
|
+
})
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* Stores the hub's session tokens for sessions this process reports (the tracker knows them). Never adopts or
|
|
1099
|
+
* exposes a session: a token for any other session is skipped as "unknown", an expired one as "expired".
|
|
1100
|
+
*/
|
|
1101
|
+
private storeTokens(store: SessionTokenStore, req: SessionTokensRequest | ActTokensRequest): ResultFor["sessionTokens"] {
|
|
1102
|
+
const applied: string[] = []
|
|
1103
|
+
const skipped: Array<ResultFor["sessionTokens"]["skipped"][number]> = []
|
|
1104
|
+
for (const { sessionID, token, exp } of req.tokens) {
|
|
1105
|
+
if (this.config.mode === "off" || !this.tracker.has(sessionID)) skipped.push({ sessionID, reason: "unknown" })
|
|
1106
|
+
else if (store.set(sessionID, token, exp)) applied.push(sessionID)
|
|
1107
|
+
else skipped.push({ sessionID, reason: "expired" })
|
|
1108
|
+
}
|
|
1109
|
+
if (req.op === "sessionTokens" && req.hubURL !== undefined && applied.length > 0) this.tokens.hubURL = req.hubURL
|
|
1110
|
+
store.prune()
|
|
1111
|
+
return { applied, skipped }
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
/**
|
|
1115
|
+
* Renames a session in OpenCode and reports the new title at once (the snapshot goes out before the reply,
|
|
1116
|
+
* on the same socket), without waiting for OpenCode's session.renamed event.
|
|
1117
|
+
*/
|
|
1118
|
+
private async rename(sessionID: string, raw: string): Promise<ResultFor["rename"]> {
|
|
1119
|
+
const title = raw.trim()
|
|
1120
|
+
if (!title) throw new Error("Title is empty")
|
|
1121
|
+
if (title.length > MAX_TITLE_CHARS) throw new Error(`Title is longer than ${MAX_TITLE_CHARS} characters`)
|
|
1122
|
+
if (!this.host.updateSession) throw new Error("This OpenCode process cannot rename sessions")
|
|
1123
|
+
await this.host.updateSession(sessionID, { title })
|
|
1124
|
+
if (this.tracker.observe({ type: "session.renamed", data: { sessionID, title } }, Date.now())) this.sendSnapshot()
|
|
1125
|
+
return { title }
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
/**
|
|
1129
|
+
* Has OpenCode regenerate the title with its title agent, then reports the outcome: the new title (from
|
|
1130
|
+
* session.renamed, or read back when the event is late), `unchanged` (OpenCode kept it or produced nothing),
|
|
1131
|
+
* or `pending` when generation outlasts the wait (the catalog updates when the event arrives).
|
|
1132
|
+
*/
|
|
1133
|
+
private async autoName(sessionID: string): Promise<ResultFor["autoName"]> {
|
|
1134
|
+
const generate = this.host.generateTitle?.bind(this.host)
|
|
1135
|
+
if (!generate) throw new Error(CANNOT_AUTO_NAME)
|
|
1136
|
+
const before = (await this.host.getSession(sessionID)).title
|
|
1137
|
+
let resolveRenamed!: (title: string) => void
|
|
1138
|
+
const renamed = new Promise<string>((resolve) => (resolveRenamed = resolve))
|
|
1139
|
+
const waiters = this.renameWaiters.get(sessionID) ?? new Set()
|
|
1140
|
+
waiters.add(resolveRenamed)
|
|
1141
|
+
this.renameWaiters.set(sessionID, waiters)
|
|
1142
|
+
const timers: Array<ReturnType<typeof setTimeout>> = []
|
|
1143
|
+
const after = <T>(ms: number, value: T) => new Promise<T>((resolve) => timers.push(setTimeout(() => resolve(value), ms)))
|
|
1144
|
+
try {
|
|
1145
|
+
const first = await Promise.race([
|
|
1146
|
+
renamed.then((title) => ({ title })),
|
|
1147
|
+
generate(sessionID).then((outcome) => ({ outcome })),
|
|
1148
|
+
after(this.options.autoNameWaitMs ?? AUTO_NAME_WAIT_MS, { pending: true as const }),
|
|
1149
|
+
])
|
|
1150
|
+
if ("title" in first) return { title: first.title }
|
|
1151
|
+
if ("pending" in first) return { pending: true }
|
|
1152
|
+
if (first.outcome === "unavailable") throw new Error(CANNOT_AUTO_NAME)
|
|
1153
|
+
// Generation is done; its session.renamed event may still be on its way.
|
|
1154
|
+
const late = await Promise.race([renamed, after(this.options.autoNameSettleMs ?? 1500, undefined)])
|
|
1155
|
+
if (late !== undefined) return { title: late }
|
|
1156
|
+
const title = (await this.host.getSession(sessionID)).title
|
|
1157
|
+
if (title === before) return { unchanged: true, title }
|
|
1158
|
+
if (this.tracker.observe({ type: "session.renamed", data: { sessionID, title } }, Date.now())) this.sendSnapshot()
|
|
1159
|
+
return { title }
|
|
1160
|
+
} finally {
|
|
1161
|
+
for (const timer of timers) clearTimeout(timer)
|
|
1162
|
+
waiters.delete(resolveRenamed)
|
|
1163
|
+
if (waiters.size === 0) this.renameWaiters.delete(sessionID)
|
|
867
1164
|
}
|
|
868
1165
|
}
|
|
869
1166
|
|
|
@@ -999,11 +1296,11 @@ export class Spoke {
|
|
|
999
1296
|
const selected = this.modelPrefs.get(sessionID)?.voice
|
|
1000
1297
|
const override = this.voiceOverrides.get(sessionID)
|
|
1001
1298
|
if ((!selected && !override) || this.tracker.active().includes(this.tracker.rootOf(sessionID))) return
|
|
1002
|
-
const current =
|
|
1299
|
+
const current = await this.currentModel(sessionID)
|
|
1003
1300
|
const onOverride = override !== undefined && current !== undefined && sameModel(current, override.applied)
|
|
1004
1301
|
if (!selected) {
|
|
1005
1302
|
this.voiceOverrides.delete(sessionID)
|
|
1006
|
-
if (onOverride && override.previous) await this.
|
|
1303
|
+
if (onOverride && override.previous) await this.switchModel(sessionID, override.previous)
|
|
1007
1304
|
return
|
|
1008
1305
|
}
|
|
1009
1306
|
const target = { providerID: selected.providerID, id: selected.modelID, ...(selected.variant ? { variant: selected.variant } : {}) }
|
|
@@ -1013,7 +1310,32 @@ export class Spoke {
|
|
|
1013
1310
|
}
|
|
1014
1311
|
const previous = onOverride ? override.previous : current ? plainModelRef(current) : undefined
|
|
1015
1312
|
this.voiceOverrides.set(sessionID, { ...(previous ? { previous } : {}), applied: target })
|
|
1016
|
-
await this.
|
|
1313
|
+
await this.switchModel(sessionID, target)
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
/** The session's current model: remembered, else asked from OpenCode (and remembered). */
|
|
1317
|
+
private async currentModel(sessionID: string): Promise<ModelRef | undefined> {
|
|
1318
|
+
const known = this.sessionModels.get(sessionID)
|
|
1319
|
+
if (known) return known.model
|
|
1320
|
+
const { model } = await this.host.getSession(sessionID)
|
|
1321
|
+
this.rememberModel(sessionID, model)
|
|
1322
|
+
return model
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
private rememberModel(sessionID: string, model: ModelRef | undefined): void {
|
|
1326
|
+
this.sessionModels.delete(sessionID)
|
|
1327
|
+
this.sessionModels.set(sessionID, model ? { model } : {})
|
|
1328
|
+
if (this.sessionModels.size > SESSION_MODELS_LIMIT) this.sessionModels.delete(this.sessionModels.keys().next().value!)
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
private async switchModel(sessionID: string, model: ModelRef): Promise<void> {
|
|
1332
|
+
try {
|
|
1333
|
+
await this.host.switchModel(sessionID, model)
|
|
1334
|
+
this.rememberModel(sessionID, model)
|
|
1335
|
+
} catch (cause) {
|
|
1336
|
+
this.sessionModels.delete(sessionID) // unknown now: ask next time
|
|
1337
|
+
throw cause
|
|
1338
|
+
}
|
|
1017
1339
|
}
|
|
1018
1340
|
|
|
1019
1341
|
/** Enables or disables voice for a session this process reported (mode "session" only). */
|
|
@@ -1029,15 +1351,23 @@ export class Spoke {
|
|
|
1029
1351
|
* of a Herdr pane. With `herdrWorkspace`, also opens it in a new tab of that Herdr workspace; if that
|
|
1030
1352
|
* fails the session still exists and the result carries a warning instead of `openedIn`.
|
|
1031
1353
|
*/
|
|
1032
|
-
private async create(
|
|
1354
|
+
private async create(req: CreateRequest): Promise<ResultFor["create"] & { warning?: string }> {
|
|
1355
|
+
const { directory, title, herdrWorkspace } = req
|
|
1033
1356
|
if (this.options.kind !== "service" || !this.host.createSession) {
|
|
1034
1357
|
throw new Error("Only the background service creates sessions")
|
|
1035
1358
|
}
|
|
1036
1359
|
const target = path.resolve(directory)
|
|
1037
|
-
const
|
|
1038
|
-
|
|
1360
|
+
const launch = isLaunchCreate(req)
|
|
1361
|
+
const workspace = launch
|
|
1362
|
+
? await this.launchWorkspace(req, target)
|
|
1363
|
+
: this.workspaces().get(target) ?? (this.herdr?.hasDirectory(target) ? { project: target, projectName: path.basename(target) || target } : undefined)
|
|
1039
1364
|
if (!workspace) throw new Error("unknown workspace")
|
|
1040
1365
|
const session = await this.host.createSession({ directory: target, ...(title ? { title } : {}) })
|
|
1366
|
+
if (launch) {
|
|
1367
|
+
// Agent first: switching the agent may reset the model.
|
|
1368
|
+
if (req.agent) await this.host.switchAgent(session.id, req.agent)
|
|
1369
|
+
if (req.model) await this.switchModel(session.id, { providerID: req.model.providerID, id: req.model.modelID, ...(req.model.variant ? { variant: req.model.variant } : {}) })
|
|
1370
|
+
}
|
|
1041
1371
|
const now = Date.now()
|
|
1042
1372
|
this.tracker.observe({ type: "session.created", data: { sessionID: session.id, title: title ?? "", location: { directory: target } } }, now)
|
|
1043
1373
|
this.tracker.setInfo(session.id, { title: title ?? "", directory: target, updated: now, ...workspace })
|
|
@@ -1048,6 +1378,33 @@ export class Spoke {
|
|
|
1048
1378
|
return warning ? { sessionID: session.id, warning } : { sessionID: session.id, openedIn: "herdr" }
|
|
1049
1379
|
}
|
|
1050
1380
|
|
|
1381
|
+
/**
|
|
1382
|
+
* The workspace of a launch create (feature "launch"): its directory must exist under the node workspace roots
|
|
1383
|
+
* (a worktree or clone `hub node` prepared need not be one this process reported), and its agent and model must
|
|
1384
|
+
* be ones this OpenCode has. Checked before the session is created.
|
|
1385
|
+
*/
|
|
1386
|
+
private async launchWorkspace(req: CreateRequest, target: string): Promise<{ project: string; projectName: string }> {
|
|
1387
|
+
const problem = launchDirectoryProblem(target, this.options.workspaceRoots?.() ?? [])
|
|
1388
|
+
if (problem) throw new Error(problem)
|
|
1389
|
+
if (req.agent && !(await this.host.hasAgent(req.agent))) throw new Error(`Unknown agent ${req.agent}`)
|
|
1390
|
+
if (req.model && !(await this.hasModel(req.model))) {
|
|
1391
|
+
throw new Error(`Unknown model ${req.model.providerID}/${req.model.modelID}${req.model.variant ? `#${req.model.variant}` : ""}`)
|
|
1392
|
+
}
|
|
1393
|
+
return this.workspaces().get(target) ?? { project: target, projectName: path.basename(target) || target }
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
/** Whether OpenCode's model list has `model` (and its variant); true when this OpenCode cannot list models. */
|
|
1397
|
+
private async hasModel(model: NonNullable<CreateRequest["model"]>): Promise<boolean> {
|
|
1398
|
+
if (!this.host.listModels) return true
|
|
1399
|
+
return (await this.host.listModels()).some((value: any) => {
|
|
1400
|
+
const id = typeof value?.id === "string" && value.id ? value.id : value?.modelID
|
|
1401
|
+
if (value?.providerID !== model.providerID || id !== model.modelID) return false
|
|
1402
|
+
if (!model.variant) return true
|
|
1403
|
+
const variants = Array.isArray(value.variants) ? value.variants : []
|
|
1404
|
+
return variants.some((v: any) => (typeof v === "string" ? v : v?.id) === model.variant)
|
|
1405
|
+
})
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1051
1408
|
/** Opens a session in a new Herdr tab; returns a warning when that did not work. */
|
|
1052
1409
|
private async openInHerdr(workspaceID: string, directory: string, sessionID: string, title?: string): Promise<string | undefined> {
|
|
1053
1410
|
const herdr = this.herdr
|
|
@@ -1105,7 +1462,11 @@ export class Spoke {
|
|
|
1105
1462
|
|
|
1106
1463
|
private async listen(): Promise<void> {
|
|
1107
1464
|
while (!this.abort.signal.aborted) {
|
|
1465
|
+
// Model changes may have been missed while the stream was down: ask OpenCode again.
|
|
1466
|
+
this.sessionModels.clear()
|
|
1108
1467
|
try {
|
|
1468
|
+
// Events may have been missed while the stream was down (or before it opened): ask what runs now.
|
|
1469
|
+
if (this.connection?.state === "connected") void this.seedActivity()
|
|
1109
1470
|
for await (const event of this.host.events(this.abort.signal)) this.observe(event)
|
|
1110
1471
|
} catch (cause) {
|
|
1111
1472
|
if (this.abort.signal.aborted) return
|
|
@@ -1118,7 +1479,15 @@ export class Spoke {
|
|
|
1118
1479
|
|
|
1119
1480
|
private observe(event: EventLike): void {
|
|
1120
1481
|
try {
|
|
1482
|
+
const sessionID = (event.data as Record<string, unknown> | undefined)?.sessionID
|
|
1483
|
+
if (typeof sessionID === "string") this.timelines.invalidate(sessionID)
|
|
1121
1484
|
this.turns.observe(event)
|
|
1485
|
+
this.observeModel(event)
|
|
1486
|
+
this.origins.observe(event)
|
|
1487
|
+
if (event.type === "session.deleted" && typeof sessionID === "string") {
|
|
1488
|
+
this.tokens.delete(sessionID)
|
|
1489
|
+
this.actTokens.delete(sessionID)
|
|
1490
|
+
}
|
|
1122
1491
|
if (event.type === "form.replied" || event.type === "form.cancelled") {
|
|
1123
1492
|
const formID = (event.data as Record<string, unknown> | undefined)?.id
|
|
1124
1493
|
if (typeof formID === "string") {
|
|
@@ -1126,21 +1495,49 @@ export class Spoke {
|
|
|
1126
1495
|
this.relay.settled.delete(formID)
|
|
1127
1496
|
}
|
|
1128
1497
|
}
|
|
1498
|
+
this.tasks.observe(event, Date.now())
|
|
1499
|
+
if (event.type === "session.renamed" && typeof sessionID === "string") {
|
|
1500
|
+
const title = (event.data as Record<string, unknown>).title
|
|
1501
|
+
if (typeof title === "string") for (const waiter of this.renameWaiters.get(sessionID) ?? []) waiter(title)
|
|
1502
|
+
}
|
|
1129
1503
|
if (this.tracker.observe(event, Date.now())) this.scheduleSnapshot()
|
|
1130
1504
|
if (this.options.kind === "service" && this.host.listRecent && LISTING_EVENTS.has(event.type) && !(event.data as any)?.parentID) {
|
|
1131
1505
|
this.scheduleSeed()
|
|
1132
1506
|
}
|
|
1133
1507
|
void this.fetchInfo()
|
|
1134
|
-
if (this.connection?.state === "connected" &&
|
|
1135
|
-
// Activity events go out compact: tool inputs become a short summary, outputs are dropped
|
|
1136
|
-
|
|
1137
|
-
|
|
1508
|
+
if (this.connection?.state === "connected" && (FORWARDED_EVENTS.has(event.type) || COMPACTED_EVENTS.has(event.type))) {
|
|
1509
|
+
// Activity events go out compact: tool inputs become a short summary, outputs are dropped; job exits
|
|
1510
|
+
// get their session from the job.
|
|
1511
|
+
const compact = this.withOrigin(compactActivity({ type: event.type, data: (event.data ?? {}) as Record<string, unknown> }, (id) => this.tracker.jobSession(id)))
|
|
1512
|
+
if (compact && shouldForward(this.config, this.tracker, compact)) this.connection.send({ t: "event", event: compact })
|
|
1138
1513
|
}
|
|
1139
1514
|
} catch (cause) {
|
|
1140
1515
|
console.error("[hub] event handling failed", cause)
|
|
1141
1516
|
}
|
|
1142
1517
|
}
|
|
1143
1518
|
|
|
1519
|
+
/**
|
|
1520
|
+
* Adds who put the item in the inbox (user or hub) to session.inbox.delivered, for hubs that route messages
|
|
1521
|
+
* (they reset reply-chain hops on a user's turn); when unknown, the event goes out unchanged.
|
|
1522
|
+
*/
|
|
1523
|
+
private withOrigin<E extends { type: string; data: Record<string, unknown> }>(event: E | undefined): E | undefined {
|
|
1524
|
+
if (event?.type !== "session.inbox.delivered" || typeof event.data.inboxID !== "string") return event
|
|
1525
|
+
const origin = this.origins.take(event.data.inboxID)
|
|
1526
|
+
return origin && this.hubSupports("messaging") ? { ...event, data: { ...event.data, origin } } : event
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
/** Keeps the remembered session models current from OpenCode's events. */
|
|
1530
|
+
private observeModel(event: EventLike): void {
|
|
1531
|
+
const data = (event.data ?? {}) as { sessionID?: unknown; model?: unknown }
|
|
1532
|
+
if (typeof data.sessionID !== "string") return
|
|
1533
|
+
if (event.type === "session.deleted") this.sessionModels.delete(data.sessionID)
|
|
1534
|
+
else if (event.type === "session.model.selected") {
|
|
1535
|
+
const model = data.model as Partial<ModelRef> | undefined
|
|
1536
|
+
if (model && typeof model.providerID === "string" && typeof model.id === "string") this.rememberModel(data.sessionID, model as ModelRef)
|
|
1537
|
+
else this.sessionModels.delete(data.sessionID)
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1144
1541
|
private async fetchInfo(): Promise<void> {
|
|
1145
1542
|
const now = Date.now()
|
|
1146
1543
|
for (const id of this.tracker.needsInfo()) {
|
|
@@ -1163,6 +1560,40 @@ export class Spoke {
|
|
|
1163
1560
|
}
|
|
1164
1561
|
}
|
|
1165
1562
|
|
|
1563
|
+
/**
|
|
1564
|
+
* Sends each running (or formerly running) session's current status and running jobs as a session.seed event,
|
|
1565
|
+
* from the host's snapshot. Sessions the tracker thought working that are not active any more are seeded idle.
|
|
1566
|
+
*/
|
|
1567
|
+
private async seedActivity(): Promise<void> {
|
|
1568
|
+
const activity = this.host.activity
|
|
1569
|
+
if (!activity || this.seeding) return
|
|
1570
|
+
this.seeding = true
|
|
1571
|
+
try {
|
|
1572
|
+
const snapshot = await activity.call(this.host, this.tracker.directories(SEED_DIRECTORIES))
|
|
1573
|
+
const active = new Set(snapshot.active)
|
|
1574
|
+
const ids = new Set([...snapshot.active, ...snapshot.jobs.map((j) => j.sessionID), ...this.tracker.working()])
|
|
1575
|
+
const now = Date.now()
|
|
1576
|
+
for (const id of ids) {
|
|
1577
|
+
if (active.has(id)) this.tracker.ensure(id, now)
|
|
1578
|
+
if (!this.tracker.has(id)) continue
|
|
1579
|
+
const jobs = snapshot.jobs
|
|
1580
|
+
.filter((j) => j.sessionID === id && j.status === "running")
|
|
1581
|
+
.slice(0, SEED_JOBS_MAX)
|
|
1582
|
+
.map(({ sessionID: _, ...job }) => job)
|
|
1583
|
+
const status = active.has(id) ? "busy" : "idle"
|
|
1584
|
+
this.tracker.seed(id, { status, jobs })
|
|
1585
|
+
const event = { type: "session.seed", data: { sessionID: id, status: { type: status }, jobs } }
|
|
1586
|
+
if (this.connection?.state === "connected" && shouldForward(this.config, this.tracker, event)) this.connection.send({ t: "event", event })
|
|
1587
|
+
}
|
|
1588
|
+
void this.fetchInfo()
|
|
1589
|
+
this.scheduleSnapshot()
|
|
1590
|
+
} catch (cause) {
|
|
1591
|
+
console.error("[hub] could not read running sessions", errorText(cause))
|
|
1592
|
+
} finally {
|
|
1593
|
+
this.seeding = false
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1166
1597
|
private async seed(): Promise<void> {
|
|
1167
1598
|
try {
|
|
1168
1599
|
const keep = this.keep()
|
|
@@ -1202,11 +1633,17 @@ export class Spoke {
|
|
|
1202
1633
|
}
|
|
1203
1634
|
}
|
|
1204
1635
|
|
|
1636
|
+
/** Directories whose shells are listed when seeding. */
|
|
1637
|
+
const SEED_DIRECTORIES = 20
|
|
1638
|
+
|
|
1205
1639
|
/** Keeps the terminals frame within the protocol's limits. */
|
|
1206
1640
|
function limitWorkspaces(workspaces: readonly HerdrWorkspace[]): HerdrWorkspace[] {
|
|
1207
1641
|
return workspaces.slice(0, 200).map((w) => ({ id: w.id, label: w.label, directories: w.directories.slice(0, 20) }))
|
|
1208
1642
|
}
|
|
1209
1643
|
|
|
1644
|
+
/** Sessions whose current model is remembered. */
|
|
1645
|
+
const SESSION_MODELS_LIMIT = 500
|
|
1646
|
+
|
|
1210
1647
|
function sameModel(a: ModelRef, b: ModelRef): boolean {
|
|
1211
1648
|
return a.providerID === b.providerID && a.id === b.id && (explicitVariant(a) ?? "") === (explicitVariant(b) ?? "")
|
|
1212
1649
|
}
|