@huynle/hub 0.9.2 → 0.10.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 +320 -0
- package/Dockerfile +5 -1
- package/README.md +154 -77
- package/package.json +7 -4
- package/packages/claude-code/package.json +1 -1
- package/packages/cli/dist/hub-cli.js +534 -261
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/cli.ts +36 -1
- package/packages/cli/src/subscriptions.ts +49 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/sessions.ts +10 -6
- package/packages/codex/src/spoke.ts +2 -1
- package/packages/node/package.json +1 -1
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/schema/app-delivery/v1.schema.json +7 -5
- package/packages/protocol/schema/app-front/v1.schema.json +410 -0
- package/packages/protocol/src/app-front.ts +293 -0
- package/packages/protocol/src/concierge.ts +10 -1
- package/packages/protocol/src/context-cards.ts +59 -0
- package/packages/protocol/src/credentials.ts +39 -2
- package/packages/protocol/src/index.ts +81 -11
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/app-front.ts +524 -0
- package/packages/sdk/src/client.ts +37 -4
- package/packages/sdk/src/envelope.ts +9 -2
- package/packages/sdk/src/errors.ts +1 -0
- package/packages/sdk/src/index.ts +1 -0
- package/packages/sdk/src/types.ts +39 -1
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/delivery.ts +17 -1
- package/packages/spoke-sdk/src/harness-spoke.ts +4 -3
- package/packages/voice-client/README.md +146 -0
- package/packages/voice-client/src/vad/LICENSE +21 -0
- package/src/central/agent/acting-routes.ts +17 -3
- package/src/central/agent/acting.ts +12 -3
- package/src/central/agent/actor.ts +6 -1
- package/src/central/agent/app-decisions.ts +146 -0
- package/src/central/agent/app-tokens.ts +9 -3
- package/src/central/agent/audit.ts +3 -0
- package/src/central/agent/connect-routes.ts +64 -0
- package/src/central/agent/connect.ts +868 -0
- package/src/central/agent/conversation.ts +625 -0
- package/src/central/agent/decisions.ts +71 -6
- package/src/central/agent/delivery.ts +3 -0
- package/src/central/agent/directory.ts +5 -0
- package/src/central/agent/errors.ts +2 -0
- package/src/central/agent/grant-requests.ts +46 -9
- package/src/central/agent/grants.ts +13 -2
- package/src/central/agent/message-limits.ts +2 -2
- package/src/central/agent/message-routes.ts +13 -4
- package/src/central/agent/read-routes.ts +16 -2
- package/src/central/agent/routes.ts +108 -6
- package/src/central/agent/speech-routes.ts +316 -0
- package/src/central/agent/subscription-routes.ts +174 -0
- package/src/central/agent/subscriptions.ts +980 -0
- package/src/central/agent-data.ts +43 -1
- package/src/central/backend/memory-counters.ts +2 -2
- package/src/central/backend/types.ts +5 -2
- package/src/central/backend/valkey-counters.ts +5 -4
- package/src/central/client-address.ts +16 -0
- package/src/central/concierge/board-render.ts +52 -1
- package/src/central/concierge/board-routes.ts +25 -2
- package/src/central/concierge/board.ts +11 -1
- package/src/central/concierge/context.ts +29 -8
- package/src/central/concierge/grant-request.ts +216 -0
- package/src/central/concierge/prompt.ts +49 -2
- package/src/central/concierge/session-routes.ts +3 -0
- package/src/central/config.ts +64 -3
- package/src/central/context-cards/budget.ts +121 -0
- package/src/central/context-cards/changes.ts +29 -0
- package/src/central/context-cards/config.ts +145 -0
- package/src/central/context-cards/engine.ts +379 -0
- package/src/central/context-cards/inputs.ts +111 -0
- package/src/central/context-cards/pickup.ts +134 -0
- package/src/central/context-cards/registry.ts +89 -0
- package/src/central/context-cards/routes.ts +37 -0
- package/src/central/context-cards/service.ts +367 -0
- package/src/central/context-cards/store.ts +67 -0
- package/src/central/context-cards/types.ts +88 -0
- package/src/central/fetcher.ts +10 -2
- package/src/central/front-relays.ts +423 -0
- package/src/central/http.ts +547 -58
- package/src/central/human-activity.ts +453 -0
- package/src/central/llm/chat.ts +133 -0
- package/src/central/registry.ts +162 -9
- package/src/central/speech/service.ts +9 -0
- package/src/central/spoke-ws.ts +13 -0
- package/src/central/store.ts +9 -12
- package/src/config.ts +30 -13
- package/src/index.ts +45 -43
- package/src/rpc.ts +33 -4
- package/src/spoke/concierge-agent.ts +1 -1
- package/src/spoke/conversation.ts +6 -75
- package/src/spoke/deliver.ts +18 -8
- package/src/spoke/hub-rpc.ts +7 -2
- package/src/spoke/hub-tool-subscriptions.ts +82 -0
- package/src/spoke/hub-tool-title.ts +89 -0
- package/src/spoke/hub-tool.ts +56 -18
- package/src/spoke/session-context.ts +10 -2
- package/src/spoke/spoke.ts +227 -237
- package/src/spoke/summary.ts +36 -1
- package/src/spoke/taskboard.ts +5 -6
- package/src/spoke/voice-front.ts +323 -30
- package/src/spoke/who-teams.ts +51 -2
- package/src/tui-messaging.ts +24 -10
- package/src/tui.ts +6 -6
- package/web/dist/assets/{ArtifactPage-BKISN-Qj.js → ArtifactPage-CsPjam8D.js} +1 -1
- package/web/dist/assets/{ArtifactPane-CKOiAUNH.js → ArtifactPane-B9weO8L6.js} +1 -1
- package/web/dist/assets/ArtifactWorkbench-DzKhMs3l.js +6 -0
- package/web/dist/assets/{Diagram-vdpmp3xu.css → Diagram-7y8kg3og.css} +1 -1
- package/web/dist/assets/Diagram-CuRIffka.js +1 -0
- package/web/dist/assets/{Library-CcjZXsfF.js → Library-rv5DGtrm.js} +2 -2
- package/web/dist/assets/Lightbox-BlbivCkd.js +1 -0
- package/web/dist/assets/MediaImage-Ch3ryIdR.js +4 -0
- package/web/dist/assets/ProfilesDestination-DUuQ9jmd.js +1 -0
- package/web/dist/assets/{TeamsPage-CDyjrK-I.js → TeamsPage-D_17MkvN.js} +1 -1
- package/web/dist/assets/{Watchtower-DYwoUWME.js → Watchtower-DlrNOJ7X.js} +2 -2
- package/web/dist/assets/{artifacts-DU9uRgs1.js → artifacts-But87Jfo.js} +1 -1
- package/web/dist/assets/index-bX8B6NLo.js +33 -0
- package/web/dist/assets/index-rW6LKQj1.css +1 -0
- package/web/dist/assets/{live-ycZ_aQZO.js → live-BGLA-peO.js} +2 -2
- package/web/dist/assets/{pane-layout-0RrLHGDr.js → pane-layout-BW0RdX5a.js} +1 -1
- package/web/dist/assets/{renderer2d-BIwbgRoX.js → renderer2d-DyANQ1WS.js} +1 -1
- package/web/dist/assets/{renderer3d-C1a7eYGQ.js → renderer3d-vYYKFAJq.js} +1 -1
- package/web/dist/assets/{safe-area-DHhPy61Q.js → safe-area-B2Bk__FK.js} +1 -1
- package/web/dist/assets/{source-BWlvaODa.js → source-C-De8L3s.js} +1 -1
- package/web/dist/assets/toast-CXTwTvDw.js +9 -0
- package/web/dist/assets/vad-worklet-BfxJhrXy.js +27 -0
- package/web/dist/index.html +6 -6
- package/web/dist/assets/ArtifactWorkbench-d7R1o-rs.js +0 -6
- package/web/dist/assets/Diagram-azw2B_QH.js +0 -1
- package/web/dist/assets/Lightbox-Du56MXGX.js +0 -1
- package/web/dist/assets/MediaImage-Ds_JUx3a.js +0 -4
- package/web/dist/assets/ProfilesDestination-DdzbzmeK.js +0 -1
- package/web/dist/assets/index-C3ci5UtY.css +0 -1
- package/web/dist/assets/index-LNMZCqgE.js +0 -36
- package/web/dist/assets/toast-Cuu2K0hx.js +0 -1
- /package/{web/src/voice → packages/voice-client/src}/turn/LICENSE +0 -0
package/src/spoke/spoke.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
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 { defaults, readConfig, setSession, withServer, writeConfig, type LaunchConfig, type Mode, type VoiceConfig,
|
|
5
|
+
import { defaults, readConfig, setSession, withServer, writeConfig, type LaunchConfig, type Mode, type VoiceConfig, parseModelRef, type ModelRef } from "../config"
|
|
6
6
|
import { isLoopbackURL } from "../loopback"
|
|
7
7
|
import {
|
|
8
8
|
compactActivity,
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
isLaunchCreate,
|
|
13
13
|
SEED_JOBS_MAX,
|
|
14
14
|
SPOKE_FEATURES,
|
|
15
|
+
RELAY_TEXT_MAX,
|
|
15
16
|
type DeliverRequest,
|
|
16
17
|
type DeliverResult,
|
|
17
18
|
type DeliveryMode,
|
|
@@ -27,6 +28,7 @@ import {
|
|
|
27
28
|
type SessionTokensRequest,
|
|
28
29
|
type ActTokensRequest,
|
|
29
30
|
type CreateRequest,
|
|
31
|
+
type ModelSelection,
|
|
30
32
|
type SessionModelPrefs,
|
|
31
33
|
type SpokeRequest,
|
|
32
34
|
type TerminalInfo,
|
|
@@ -39,7 +41,7 @@ import { TimelineCache, boundedTimelinePart, newestPage, timelineFile, timelineI
|
|
|
39
41
|
import { MediaCache, timelineMedia, type MediaMessages } from "./media"
|
|
40
42
|
import { BundleCache, previewBundle } from "./preview-bundle"
|
|
41
43
|
import { CodedError, SpokeConnection, type Backoff, type ConnectionState } from "./client"
|
|
42
|
-
import { isHubPrompt,
|
|
44
|
+
import { isHubPrompt, LEGACY_VOICE_AGENT, PROMPT_SOURCE } from "./conversation"
|
|
43
45
|
import { DeliveryLedger, deferredWhileBusy, deliverDescription, deliverMetadata, deliverText, harnessMessageID, InboxOrigins, planDelivery, QUIET_WHILE_BUSY, REVERT_STAGED } from "./deliver"
|
|
44
46
|
import { pollEnrollment, startEnrollment } from "./enroll"
|
|
45
47
|
import { HerdrWatcher, openInHerdr, type HerdrRunner } from "./herdr"
|
|
@@ -50,10 +52,10 @@ import { OpenCodeProfiles } from "./profile-apply"
|
|
|
50
52
|
import { SessionTokenStore } from "./session-tokens"
|
|
51
53
|
import { SessionContexts } from "./session-context"
|
|
52
54
|
import { CONCIERGE_AGENT } from "../../packages/protocol/src/concierge.ts"
|
|
53
|
-
import { changeStats, contextUsage, filePatch, mcpServers } from "./summary"
|
|
55
|
+
import { changeStats, contextUsage, filePatch, mcpServers, sessionModel } from "./summary"
|
|
54
56
|
import { browserSessions, eventSessionID, isExposed, isReported, SessionTracker, shouldForward, type EventLike, type SessionDetails } from "./tracker"
|
|
55
57
|
import { renderTaskBoard, TaskBoard } from "./taskboard"
|
|
56
|
-
import { FRONT_TITLE_PREFIX, frontTitle, relayDelivery, trimText, VOICE_FRONT_AGENT, VoiceFronts } from "./voice-front"
|
|
58
|
+
import { FRONT_RELAY_TOOL, FRONT_TITLE_PREFIX, frontTitle, noThinkingOptions, relayDelivery, trimText, VOICE_FRONT_AGENT, VoiceFronts, type FrontMode, type FrontModelInfo } from "./voice-front"
|
|
57
59
|
import { watchConfig, type ConfigWatcher } from "./watch"
|
|
58
60
|
|
|
59
61
|
export type SpokeErrorName = "not_connected" | "not_enrolled" | "invalid" | "unreachable"
|
|
@@ -112,6 +114,8 @@ export interface SpokeHost {
|
|
|
112
114
|
synthetic?(input: { sessionID: string; id: string; text: string; description?: string; metadata: Record<string, unknown>; delivery: DeliveryMode; resume: boolean }): Promise<{ id?: string }>
|
|
113
115
|
context(sessionID: string): Promise<ReadonlyArray<unknown>>
|
|
114
116
|
listModels?(): Promise<ReadonlyArray<unknown>>
|
|
117
|
+
/** The default model at `directory` (OpenCode's model.default, a catalog entry with providerID and id); undefined: none. */
|
|
118
|
+
defaultModel?(directory: string): Promise<unknown>
|
|
115
119
|
getSession(sessionID: string): Promise<HostSession>
|
|
116
120
|
/**
|
|
117
121
|
* Replies to a pending permission; "gone" when it is not pending here any more (answered, expired).
|
|
@@ -161,6 +165,11 @@ export interface SpokeHost {
|
|
|
161
165
|
listRecent?(options?: { days: number; max: number; include: readonly string[]; signal?: AbortSignal }): Promise<Array<{ id: string } & SessionDetails>>
|
|
162
166
|
/** Creates a session in a directory (the create op: service processes only; voice fronts: any process). */
|
|
163
167
|
createSession?(input: { directory: string; title?: string }): Promise<{ id: string }>
|
|
168
|
+
/**
|
|
169
|
+
* (voice front) The IDs of a session's inbox items still waiting (OpenCode's session.inbox.list, own server only):
|
|
170
|
+
* relays no longer listed were delivered or cancelled while events were missed.
|
|
171
|
+
*/
|
|
172
|
+
inboxIDs?(sessionID: string): Promise<string[]>
|
|
164
173
|
/** Deletes a session (OpenCode's session.remove); a voice front when its conversation ends. */
|
|
165
174
|
removeSession?(sessionID: string): Promise<unknown>
|
|
166
175
|
/** MCP servers and their status for a directory (raw OpenCode McpServer list). */
|
|
@@ -370,7 +379,7 @@ export class Spoke {
|
|
|
370
379
|
* ever gets one. Only actIdentity reads them (the OpenCode hub tool). Never logged, never in any environment.
|
|
371
380
|
*/
|
|
372
381
|
private readonly actTokens = new SessionTokenStore()
|
|
373
|
-
/** The background workers each session started (the voice
|
|
382
|
+
/** The background workers each session started (the task board its voice front reads). */
|
|
374
383
|
private readonly tasks = new TaskBoard()
|
|
375
384
|
private readonly turns = new TurnTracker()
|
|
376
385
|
/** Timeline rows of sessions being paged through (30 s, dropped on any event of the session). */
|
|
@@ -388,17 +397,15 @@ export class Spoke {
|
|
|
388
397
|
/** Who put each inbox item into a session (forwarded with session.inbox.delivered). */
|
|
389
398
|
private readonly origins = new InboxOrigins()
|
|
390
399
|
private readonly infoTried = new Map<string, number>()
|
|
391
|
-
/** Sessions in conversation mode → the agent to restore when it ends (undefined: the default agent). */
|
|
392
|
-
private readonly conversations = new Map<string, { previous?: string; previousModel?: ModelRef; voiceModel?: ModelRef }>()
|
|
393
|
-
/** Per-session selections supplied by the hub; the hub persists them per user. */
|
|
394
|
-
private readonly modelPrefs = new Map<string, SessionModelPrefs>()
|
|
395
|
-
/** Per session with a Voice override applied: the model to restore when it is cleared, and the override. */
|
|
396
|
-
private readonly voiceOverrides = new Map<string, { readonly previous?: ModelRef; readonly applied: ModelRef }>()
|
|
397
400
|
/**
|
|
398
|
-
*
|
|
399
|
-
*
|
|
401
|
+
* Sessions in conversation mode without a voice front (an older hub, no front, or one that could not be made): the
|
|
402
|
+
* hub talks to them directly and their turns are voice turns. Their agent and model are never switched.
|
|
400
403
|
*/
|
|
401
|
-
private readonly
|
|
404
|
+
private readonly conversations = new Set<string>()
|
|
405
|
+
/** Sessions checked for the removed "voice" agent (a turn on a missing agent fails in OpenCode 2.2.3). */
|
|
406
|
+
private readonly agentChecked = new Set<string>()
|
|
407
|
+
/** Per-session selections supplied by the hub; the hub persists them per user. */
|
|
408
|
+
private readonly modelPrefs = new Map<string, SessionModelPrefs>()
|
|
402
409
|
/** Session → callbacks waiting for its next session.renamed event (autoName). */
|
|
403
410
|
private readonly renameWaiters = new Map<string, Set<(title: string) => void>>()
|
|
404
411
|
private conversationChain: Promise<unknown> = Promise.resolve()
|
|
@@ -424,6 +431,8 @@ export class Spoke {
|
|
|
424
431
|
*/
|
|
425
432
|
private readonly voiceTitled = new Map<string, "checking" | "user" | "front" | "hidden">()
|
|
426
433
|
private readonly frontFallbacks = new Set<string>()
|
|
434
|
+
/** (fix D) The catalog entry of each front model seen: provider package and variants. */
|
|
435
|
+
private readonly frontModelInfo = new Map<string, FrontModelInfo | undefined>()
|
|
427
436
|
private readonly unlinkedFronts = new Set<string>()
|
|
428
437
|
private frontWakes = 0
|
|
429
438
|
private readonly abort = new AbortController()
|
|
@@ -549,21 +558,19 @@ export class Spoke {
|
|
|
549
558
|
this.fronts.stop()
|
|
550
559
|
}
|
|
551
560
|
|
|
552
|
-
/**
|
|
561
|
+
/**
|
|
562
|
+
* A voice-styled turn: a hub /turn asked for it, or the hub talks to the session directly in conversation mode (no
|
|
563
|
+
* voice front: an older hub, none could be made, or it failed).
|
|
564
|
+
*/
|
|
553
565
|
isVoiceTurn(sessionID: string): boolean {
|
|
554
|
-
return (this.voiceTurns.get(sessionID) ?? 0) > 0 || this.frontFallbacks.has(sessionID)
|
|
566
|
+
return (this.voiceTurns.get(sessionID) ?? 0) > 0 || this.frontFallbacks.has(sessionID) || this.conversations.has(sessionID)
|
|
555
567
|
}
|
|
556
568
|
|
|
557
|
-
/** Conversation mode is on:
|
|
569
|
+
/** Conversation mode is on: with a voice front (also a failed one), or talking to the session directly. */
|
|
558
570
|
inConversation(sessionID: string): boolean {
|
|
559
571
|
return this.conversations.has(sessionID) || this.fronts.frontOf(sessionID) !== undefined || this.frontFallbacks.has(sessionID)
|
|
560
572
|
}
|
|
561
573
|
|
|
562
|
-
/** The session itself is the voice orchestrator (today's agent swap): the context hook adds its parts. */
|
|
563
|
-
orchestrates(sessionID: string): boolean {
|
|
564
|
-
return this.conversations.has(sessionID)
|
|
565
|
-
}
|
|
566
|
-
|
|
567
574
|
/**
|
|
568
575
|
* voice_main_send: relays the user's request from a voice front to its main session, as the user's (hub-sourced)
|
|
569
576
|
* prompt. "auto": idle starts a turn, busy steers; "queue" waits behind main's running turn.
|
|
@@ -576,11 +583,40 @@ export class Spoke {
|
|
|
576
583
|
const busy = this.running.has(main) || this.tracker.isBusy(main)
|
|
577
584
|
const delivery = relayDelivery(input.delivery, busy)
|
|
578
585
|
const inbox = await this.host.prompt({ sessionID: main, text, delivery, metadata: { source: PROMPT_SOURCE, kind: "voice-front" } })
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
586
|
+
this.fronts.relayed(main, inbox.id, text, delivery)
|
|
587
|
+
const done = delivery === "queue" ? "Queued for the session; it runs after its current turn." : busy ? "Steered into the session's running turn." : "Sent; the session started working on it."
|
|
588
|
+
return { text: `${done} ${HANDOFF_DONE}`, inboxID: inbox.id }
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* (P2) relay_to_session: a Concierge-mode front hands the user's request to one of the user's existing sessions,
|
|
593
|
+
* through the hub (relayToSession), which checks the front, the utterance and the target. Refused here, with no hub
|
|
594
|
+
* request, when the front's latest input is a hub wake (only the user's speech may relay) or the hub cannot relay.
|
|
595
|
+
* A refusal re-opens the front's turn (its next step may say why).
|
|
596
|
+
*/
|
|
597
|
+
async frontRelay(callerID: string, input: { session?: unknown; text?: unknown; previous?: unknown; confirm?: unknown }): Promise<{ text: string; sessionID?: string }> {
|
|
598
|
+
const refuse = (text: string) => (this.fronts.handoffFailed(callerID), { text })
|
|
599
|
+
if (this.fronts.mode(callerID) !== "concierge") return { text: "This session is not a Concierge front; nothing was sent." }
|
|
600
|
+
const session = typeof input.session === "string" ? input.session.trim() : ""
|
|
601
|
+
const text = typeof input.text === "string" ? input.text.trim() : ""
|
|
602
|
+
if (!session) return refuse("Name the session: its ID or @name from the board. Nothing was sent.")
|
|
603
|
+
if (!text) return refuse("Pass the request as text. Nothing was sent.")
|
|
604
|
+
if (!this.hubSupports("frontRelay")) return refuse("This hub cannot relay to other sessions: pass the request to the Concierge instead. Nothing was sent.")
|
|
605
|
+
if (this.fronts.lastInput(callerID) !== "user") return refuse(`${FRONT_RELAY_TOOL} is only for what the user just said, never for an update. Nothing was sent.`)
|
|
606
|
+
try {
|
|
607
|
+
const result = await this.ask({ op: "relayToSession", frontID: callerID, session, text: text.slice(0, RELAY_TEXT_MAX), ...(input.previous === true ? { previous: true } : {}), ...(input.confirm === true ? { confirm: true } : {}) })
|
|
608
|
+
const name = result.title?.trim() || "the session"
|
|
609
|
+
const done = result.mode === "steer" ? `Steered into ${name}'s running turn.` : `Sent to ${name}; it started working on it.`
|
|
610
|
+
return { text: `${done} ${HANDOFF_DONE}`, sessionID: result.sessionID }
|
|
611
|
+
} catch (cause) {
|
|
612
|
+
return refuse(`Not sent: ${errorText(cause)}. Do what that says now (you may answer the user), or tell them in one short sentence why nothing was sent.`)
|
|
582
613
|
}
|
|
583
|
-
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** (P2) The tools a front keeps this turn (none for any other session); the relay only when the hub relays. */
|
|
617
|
+
frontTools(sessionID: string): string[] {
|
|
618
|
+
const tools = this.fronts.tools(sessionID)
|
|
619
|
+
return this.hubSupports("frontRelay") ? tools : tools.filter((name) => name !== FRONT_RELAY_TOOL)
|
|
584
620
|
}
|
|
585
621
|
|
|
586
622
|
/** voice_main_stop: interrupts the front's main session and everything running below it (as the stop op). */
|
|
@@ -589,7 +625,7 @@ export class Spoke {
|
|
|
589
625
|
if (!main) return { text: "This session is not a voice front; nothing was stopped.", stopped: [] }
|
|
590
626
|
this.fronts.stopRequested(main)
|
|
591
627
|
const { stopped } = await this.stopTree(main)
|
|
592
|
-
return { text:
|
|
628
|
+
return { text: `Stopped the session's work. ${HANDOFF_DONE}`, stopped: [...stopped] }
|
|
593
629
|
}
|
|
594
630
|
|
|
595
631
|
/** The voice front's per-turn note about its main session; undefined for any other session. */
|
|
@@ -598,7 +634,37 @@ export class Spoke {
|
|
|
598
634
|
if (!main) return undefined
|
|
599
635
|
const title = this.tracker.snapshot(Date.now()).find((s) => s.id === main)?.title
|
|
600
636
|
const workers = this.taskBoard(main)
|
|
601
|
-
|
|
637
|
+
const board = this.fronts.mode(sessionID) === "concierge" ? this.contexts.frontBoard(main) : undefined
|
|
638
|
+
return this.fronts.context(sessionID, { ...(title ? { title } : {}), busy: this.running.has(main) || this.tracker.isBusy(main), ...(workers ? { workers } : {}), ...(board ? { board } : {}) })
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* (fix D) Request options that turn a front's model thinking off (src/spoke/voice-front.ts noThinkingOptions), from
|
|
643
|
+
* OpenCode's model catalog, cached per model. A catalog that cannot be read gives none.
|
|
644
|
+
*/
|
|
645
|
+
async frontModelOptions(model: { providerID: string; id: string }): Promise<Record<string, unknown>> {
|
|
646
|
+
const key = `${model.providerID}/${model.id}`
|
|
647
|
+
if (!this.frontModelInfo.has(key)) {
|
|
648
|
+
const listed = await Promise.resolve(this.host.listModels?.()).catch(() => undefined)
|
|
649
|
+
const info = (listed ?? []).find((m: any) => m?.providerID === model.providerID && m?.id === model.id) as FrontModelInfo | undefined
|
|
650
|
+
if (!listed) return {}
|
|
651
|
+
this.frontModelInfo.set(key, info)
|
|
652
|
+
if (this.frontModelInfo.size > 50) this.frontModelInfo.delete(this.frontModelInfo.keys().next().value!)
|
|
653
|
+
}
|
|
654
|
+
return noThinkingOptions(this.frontModelInfo.get(key))
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Checks the relays the fronts still wait for against their sessions' inboxes (after an event-stream gap): relays
|
|
659
|
+
* no longer listed are dropped. Without an inbox list, relays older than the hold cap count as resolved.
|
|
660
|
+
*/
|
|
661
|
+
async reconcileFronts(): Promise<void> {
|
|
662
|
+
const list = this.host.inboxIDs?.bind(this.host)
|
|
663
|
+
if (!list) return
|
|
664
|
+
for (const { main } of this.fronts.pendingRelays()) {
|
|
665
|
+
const ids = await list(main).catch(() => undefined)
|
|
666
|
+
if (ids) this.fronts.reconcile(main, new Set(ids))
|
|
667
|
+
}
|
|
602
668
|
}
|
|
603
669
|
|
|
604
670
|
/** A hub update for a front (`<system-update>`): admitted as the hub's (never the user's), steered, waking it. */
|
|
@@ -616,60 +682,11 @@ export class Spoke {
|
|
|
616
682
|
}
|
|
617
683
|
}
|
|
618
684
|
|
|
619
|
-
/** The
|
|
620
|
-
/** The model subagents should work on for a session in conversation mode, as "provider/model[#variant]". */
|
|
621
|
-
workModelFor(sessionID: string): string | undefined {
|
|
622
|
-
const selected = this.modelPrefs.get(sessionID)?.worker
|
|
623
|
-
if (selected) return formatModelRef({ providerID: selected.providerID, id: selected.modelID, ...(selected.variant ? { variant: selected.variant } : {}) })
|
|
624
|
-
const entry = this.conversations.get(sessionID)
|
|
625
|
-
if (!entry?.voiceModel) return undefined
|
|
626
|
-
return this.config.workModel ?? (entry.previousModel ? formatModelRef(entry.previousModel) : undefined)
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
/** The rendered task board of a session's workers (for the voice agent's context); undefined without any. */
|
|
685
|
+
/** The rendered task board of a session's workers (for its voice front's context); undefined without any. */
|
|
630
686
|
taskBoard(sessionID: string): string | undefined {
|
|
631
687
|
return renderTaskBoard(this.tasks.children(sessionID), Date.now())
|
|
632
688
|
}
|
|
633
689
|
|
|
634
|
-
/**
|
|
635
|
-
* hub_task_stop: interrupts one of `callerID`'s workers (a session below it), or all its running workers, and
|
|
636
|
-
* whatever runs below them, deepest first. Never the caller itself, nor a session outside its tree.
|
|
637
|
-
*/
|
|
638
|
-
async stopWorkers(callerID: string, input: { sessionID?: unknown; all?: unknown }): Promise<{ stopped: string[]; text: string }> {
|
|
639
|
-
let targets: string[]
|
|
640
|
-
if (input.all === true) {
|
|
641
|
-
targets = this.tasks
|
|
642
|
-
.children(callerID)
|
|
643
|
-
.filter((t) => t.state === "working" || t.state === "blocked")
|
|
644
|
-
.map((t) => t.id)
|
|
645
|
-
if (!targets.length) return { stopped: [], text: "None of your workers is running." }
|
|
646
|
-
} else if (typeof input.sessionID === "string" && input.sessionID) {
|
|
647
|
-
const id = input.sessionID
|
|
648
|
-
if (id === callerID || !this.tracker.lineage(id).slice(1).includes(callerID)) {
|
|
649
|
-
return { stopped: [], text: `${id} is not one of your workers; use a sessionID from your task board.` }
|
|
650
|
-
}
|
|
651
|
-
targets = [id]
|
|
652
|
-
} else return { stopped: [], text: "Pass the worker's sessionID from your task board, or all: true for every worker." }
|
|
653
|
-
const stopped: string[] = []
|
|
654
|
-
for (const target of targets) {
|
|
655
|
-
for (const id of [...this.tracker.runningBelow(target), target]) {
|
|
656
|
-
if (id === callerID) continue
|
|
657
|
-
try {
|
|
658
|
-
await this.host.interrupt(id)
|
|
659
|
-
stopped.push(id)
|
|
660
|
-
} catch {
|
|
661
|
-
// already settled
|
|
662
|
-
}
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
const names = targets.map((id) => `"${this.tasks.get(id)?.title ?? id}"`)
|
|
666
|
-
return { stopped, text: `Stopped ${names.join(", ")}.` }
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
subagent(): string {
|
|
670
|
-
return this.config.subagent
|
|
671
|
-
}
|
|
672
|
-
|
|
673
690
|
/**
|
|
674
691
|
* TUI presence (RPC voice.presence): the sessions a TUI shows right now (its route and open tabs).
|
|
675
692
|
* `reporter` identifies the TUI; its report replaces its previous one.
|
|
@@ -756,10 +773,10 @@ export class Spoke {
|
|
|
756
773
|
}
|
|
757
774
|
|
|
758
775
|
/**
|
|
759
|
-
* Turns conversation mode on or off for a session in this process. On
|
|
760
|
-
*
|
|
761
|
-
*
|
|
762
|
-
*
|
|
776
|
+
* Turns conversation mode on or off for a session in this process. On with `front`: a hidden voice front answers
|
|
777
|
+
* for the session. Without a front (an older hub, or none can be made) the hub talks to the session directly and
|
|
778
|
+
* its turns are voice turns. The session's agent and model are never switched. Calls are serialized so a re-sent
|
|
779
|
+
* "on" cannot race an "off".
|
|
763
780
|
*/
|
|
764
781
|
setConversation(sessionID: string, on: boolean, front = false, frontID?: string): Promise<ResultFor["conversation"]> {
|
|
765
782
|
if (!isExposed(this.config, this.tracker, sessionID)) return Promise.reject(new SpokeError("invalid", NOT_SHARED))
|
|
@@ -1113,76 +1130,80 @@ export class Spoke {
|
|
|
1113
1130
|
}
|
|
1114
1131
|
|
|
1115
1132
|
private async conversationOn(sessionID: string, front = false, frontID?: string): Promise<ResultFor["conversation"]> {
|
|
1116
|
-
|
|
1117
|
-
let session = await this.host.getSession(sessionID)
|
|
1133
|
+
const session = await this.host.getSession(sessionID)
|
|
1118
1134
|
// (CON-04) lockAgent: conversation mode is recorded, but the session keeps its agent and model (the Concierge
|
|
1119
|
-
// never escapes its restricted agent
|
|
1135
|
+
// never escapes its restricted agent). (P2) With `front`, a Concierge-mode front answers in front of it; without
|
|
1136
|
+
// one (an older hub, or none could be made) the Concierge answers itself, as before.
|
|
1120
1137
|
// A session on the concierge agent is locked too (a spoke that restarted before the hub re-sent the context).
|
|
1121
1138
|
if (this.contexts.locked(sessionID) || session.agent === CONCIERGE_AGENT) {
|
|
1122
|
-
this.
|
|
1123
|
-
|
|
1139
|
+
const agent = session.agent ?? (await this.defaultAgent())
|
|
1140
|
+
const made = front ? await this.frontOn(sessionID, session, frontID, "concierge") : undefined
|
|
1141
|
+
if (made) return { agent, front: { sessionID: made } }
|
|
1142
|
+
this.conversations.add(sessionID)
|
|
1143
|
+
return { agent }
|
|
1124
1144
|
}
|
|
1145
|
+
const agent = await this.leaveRemovedAgent(sessionID, session.agent)
|
|
1125
1146
|
// The front failed: the hub talks to main directly and resends on without front (a reconnect). Main keeps its
|
|
1126
1147
|
// agent and model; its turns stay voice turns (frontFallbacks) until off.
|
|
1127
|
-
if (!front && this.frontFallbacks.has(sessionID)) return { agent
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1148
|
+
if (!front && this.frontFallbacks.has(sessionID)) return { agent }
|
|
1149
|
+
const made = front ? await this.frontOn(sessionID, session, frontID, "session") : undefined
|
|
1150
|
+
if (made) return { agent, front: { sessionID: made } }
|
|
1151
|
+
// No front: main answers the hub itself, in the voice style. Its agent and model stay as they are.
|
|
1152
|
+
this.conversations.add(sessionID)
|
|
1153
|
+
return { agent }
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
/** The live front of `sessionID`, a re-linked one (`frontID`) or a new one; undefined when none can be made. */
|
|
1157
|
+
private async frontOn(sessionID: string, session: HostSession, frontID: string | undefined, mode: FrontMode): Promise<string | undefined> {
|
|
1158
|
+
const live = this.fronts.frontOf(sessionID)
|
|
1159
|
+
if (live && this.tracker.has(live)) return live
|
|
1160
|
+
// A failed front is replaced (deleted first); the session stops being a fallback.
|
|
1161
|
+
await this.endFront(sessionID)
|
|
1162
|
+
// The hub knows this conversation's front (a resend to a restarted process): re-linked if it still exists.
|
|
1163
|
+
const adopted = frontID ? await this.adoptFront(sessionID, frontID, mode) : undefined
|
|
1164
|
+
const created =
|
|
1165
|
+
adopted ??
|
|
1166
|
+
(await this.createFront(sessionID, session, mode).catch((cause) => {
|
|
1167
|
+
console.error("[hub] could not start a voice front; the hub talks to the session directly", errorText(cause))
|
|
1144
1168
|
return undefined
|
|
1145
|
-
})
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1169
|
+
}))
|
|
1170
|
+
if (created) this.conversations.delete(sessionID)
|
|
1171
|
+
return created
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
/**
|
|
1175
|
+
* The session's agent. A session left on older plugins' "voice" agent (a crash mid-conversation) moves to
|
|
1176
|
+
* the default agent: OpenCode 2.2.3 does not fall back, it fails every turn on a missing agent. A "voice" agent the
|
|
1177
|
+
* user defined is theirs and kept.
|
|
1178
|
+
*/
|
|
1179
|
+
private async leaveRemovedAgent(sessionID: string, agent: string | undefined): Promise<string> {
|
|
1180
|
+
this.agentChecked.add(sessionID)
|
|
1181
|
+
if (this.agentChecked.size > AGENT_CHECKED_LIMIT) this.agentChecked.delete(this.agentChecked.values().next().value!)
|
|
1182
|
+
if (agent !== LEGACY_VOICE_AGENT) return agent ?? (await this.defaultAgent())
|
|
1183
|
+
if (await this.host.hasAgent(LEGACY_VOICE_AGENT).catch(() => true)) return agent
|
|
1184
|
+
const target = await this.defaultAgent()
|
|
1185
|
+
try {
|
|
1186
|
+
await this.host.switchAgent(sessionID, target)
|
|
1187
|
+
console.warn(`[hub] ${sessionID} was left on the removed voice agent; it now runs ${target}`)
|
|
1188
|
+
return target
|
|
1189
|
+
} catch (cause) {
|
|
1190
|
+
console.error(`[hub] could not move ${sessionID} off the removed voice agent`, errorText(cause))
|
|
1191
|
+
return agent
|
|
1154
1192
|
}
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
private async voiceModelOn(
|
|
1163
|
-
sessionID: string,
|
|
1164
|
-
session: HostSession,
|
|
1165
|
-
entry: { previousModel?: ModelRef; voiceModel?: ModelRef } | undefined,
|
|
1166
|
-
): Promise<{ previousModel?: ModelRef; voiceModel?: ModelRef }> {
|
|
1167
|
-
const selected = this.modelPrefs.get(sessionID)?.voice
|
|
1168
|
-
const voiceModel = selected
|
|
1169
|
-
? { providerID: selected.providerID, id: selected.modelID, ...(selected.variant ? { variant: selected.variant } : {}) }
|
|
1170
|
-
: this.config.voiceModel
|
|
1171
|
-
? parseModelRef(this.config.voiceModel)
|
|
1172
|
-
: undefined
|
|
1173
|
-
if (!voiceModel) return {}
|
|
1174
|
-
const current = session.model
|
|
1175
|
-
const onVoice = current !== undefined && sameModel(current, voiceModel)
|
|
1176
|
-
const previousModel = entry?.voiceModel ? entry.previousModel : onVoice || !current ? undefined : plainModelRef(current)
|
|
1177
|
-
if (!onVoice) await this.switchModel(sessionID, voiceModel)
|
|
1178
|
-
return { voiceModel, ...(previousModel ? { previousModel } : {}) }
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
/** Before the hub prompts a session: moved off the removed voice agent once per session (see leaveRemovedAgent). */
|
|
1196
|
+
private async checkAgent(sessionID: string): Promise<void> {
|
|
1197
|
+
if (this.agentChecked.has(sessionID)) return
|
|
1198
|
+
const session = await this.host.getSession(sessionID).catch(() => undefined)
|
|
1199
|
+
if (session) await this.leaveRemovedAgent(sessionID, session.agent)
|
|
1179
1200
|
}
|
|
1180
1201
|
|
|
1181
1202
|
/**
|
|
1182
1203
|
* Creates main's voice front: a root session in main's directory, titled after it, on the voice-front agent and
|
|
1183
1204
|
* the voice model, reported with voiceFrontOf. Throws (after removing a half-made front) when it cannot.
|
|
1184
1205
|
*/
|
|
1185
|
-
private async createFront(main: string, session: HostSession): Promise<string> {
|
|
1206
|
+
private async createFront(main: string, session: HostSession, mode: FrontMode = "session"): Promise<string> {
|
|
1186
1207
|
const create = this.host.createSession?.bind(this.host)
|
|
1187
1208
|
if (!create) throw new Error("this OpenCode process cannot create sessions")
|
|
1188
1209
|
if (!(await this.host.hasAgent(VOICE_FRONT_AGENT))) throw new Error(`no ${VOICE_FRONT_AGENT} agent`)
|
|
@@ -1202,7 +1223,7 @@ export class Spoke {
|
|
|
1202
1223
|
await this.host.removeSession?.(id).catch(() => undefined)
|
|
1203
1224
|
throw cause
|
|
1204
1225
|
}
|
|
1205
|
-
this.fronts.link(main, id, await this.lastReply(main))
|
|
1226
|
+
this.fronts.link(main, id, await this.lastReply(main), mode)
|
|
1206
1227
|
} finally {
|
|
1207
1228
|
this.frontsCreating--
|
|
1208
1229
|
if (id !== undefined) this.unlinkedFronts.delete(id)
|
|
@@ -1263,11 +1284,11 @@ export class Spoke {
|
|
|
1263
1284
|
* Re-links an existing front to main (the hub names it on a resend): only a session on the voice-front agent that
|
|
1264
1285
|
* this process has not linked to another session. Undefined when it is gone or no front.
|
|
1265
1286
|
*/
|
|
1266
|
-
private async adoptFront(main: string, frontID: string): Promise<string | undefined> {
|
|
1287
|
+
private async adoptFront(main: string, frontID: string, mode: FrontMode = "session"): Promise<string | undefined> {
|
|
1267
1288
|
if (frontID === main || (this.frontMainOf(frontID) ?? main) !== main) return undefined
|
|
1268
1289
|
const info = await this.host.getSession(frontID).catch(() => undefined)
|
|
1269
1290
|
if (info?.agent !== VOICE_FRONT_AGENT || (this.frontMainOf(frontID) ?? main) !== main) return undefined
|
|
1270
|
-
this.fronts.link(main, frontID, await this.lastReply(main))
|
|
1291
|
+
this.fronts.link(main, frontID, await this.lastReply(main), mode)
|
|
1271
1292
|
this.voiceTitled.delete(frontID)
|
|
1272
1293
|
const now = Date.now()
|
|
1273
1294
|
if (!this.tracker.has(frontID)) this.tracker.observe({ type: "session.created", data: { sessionID: frontID, title: info.title, location: info.location } }, now)
|
|
@@ -1328,20 +1349,10 @@ export class Spoke {
|
|
|
1328
1349
|
|
|
1329
1350
|
private async conversationOff(sessionID: string): Promise<ResultFor["conversation"]> {
|
|
1330
1351
|
await this.endFront(sessionID)
|
|
1331
|
-
const entry = this.conversations.get(sessionID)
|
|
1332
1352
|
this.conversations.delete(sessionID)
|
|
1333
1353
|
const session = await this.host.getSession(sessionID)
|
|
1334
|
-
this.
|
|
1335
|
-
|
|
1336
|
-
if (entry?.voiceModel && entry.previousModel && session.model && sameModel(session.model, entry.voiceModel)) {
|
|
1337
|
-
await this.switchModel(sessionID, entry.previousModel)
|
|
1338
|
-
}
|
|
1339
|
-
const current = session.agent
|
|
1340
|
-
if (current !== VOICE_AGENT) return { agent: current ?? (await this.defaultAgent()) }
|
|
1341
|
-
let target = entry?.previous ?? (await this.defaultAgent())
|
|
1342
|
-
if (target === VOICE_AGENT) target = FALLBACK_AGENT
|
|
1343
|
-
await this.host.switchAgent(sessionID, target)
|
|
1344
|
-
return { agent: target }
|
|
1354
|
+
if (this.contexts.locked(sessionID) || session.agent === CONCIERGE_AGENT) return { agent: session.agent ?? (await this.defaultAgent()) }
|
|
1355
|
+
return { agent: await this.leaveRemovedAgent(sessionID, session.agent) }
|
|
1345
1356
|
}
|
|
1346
1357
|
|
|
1347
1358
|
private async defaultAgent(): Promise<string> {
|
|
@@ -1401,17 +1412,16 @@ export class Spoke {
|
|
|
1401
1412
|
case "modelPrefs":
|
|
1402
1413
|
return this.modelPrefsResult(req.sessionID)
|
|
1403
1414
|
case "setModelPrefs": {
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
}
|
|
1410
|
-
if (next.voice || next.worker) this.modelPrefs.set(req.sessionID, next)
|
|
1415
|
+
// The Voice model is the voice front's. `worker` (older hubs' complex-worker model) is ignored.
|
|
1416
|
+
const next: { voice?: SessionModelPrefs["voice"] } = { ...this.modelPrefs.get(req.sessionID) }
|
|
1417
|
+
if ("voice" in req && req.voice === null) delete next.voice
|
|
1418
|
+
else if (req.voice) next.voice = req.voice
|
|
1419
|
+
if (next.voice) this.modelPrefs.set(req.sessionID, next)
|
|
1411
1420
|
else this.modelPrefs.delete(req.sessionID)
|
|
1412
|
-
if (!next.voice && this.voiceOverrides.has(req.sessionID)) await this.applyVoiceModel(req.sessionID)
|
|
1413
1421
|
return this.modelPrefsResult(req.sessionID)
|
|
1414
1422
|
}
|
|
1423
|
+
case "setModel":
|
|
1424
|
+
return this.setSessionModel(req.sessionID, req.model)
|
|
1415
1425
|
case "timeline": {
|
|
1416
1426
|
// A first page reads OpenCode (it shows the newest state); following pages reuse those rows briefly. A
|
|
1417
1427
|
// staged revert hides its message and everything after it (OpenCode keeps them until it is committed).
|
|
@@ -1450,8 +1460,10 @@ export class Spoke {
|
|
|
1450
1460
|
directory: this.directoryOf(req.sessionID),
|
|
1451
1461
|
})) satisfies ResultFor["previewBundle"]
|
|
1452
1462
|
case "steer": {
|
|
1463
|
+
// (P2) The user's speech reaches a front only through this op (the hub's utterance route).
|
|
1464
|
+
if (this.fronts.isFront(req.sessionID)) this.fronts.heard(req.sessionID, "user")
|
|
1453
1465
|
const voice = isExposed(this.config, this.tracker, req.sessionID)
|
|
1454
|
-
|
|
1466
|
+
await this.checkAgent(req.sessionID)
|
|
1455
1467
|
const inbox = await this.host.prompt({
|
|
1456
1468
|
sessionID: req.sessionID,
|
|
1457
1469
|
text: req.text,
|
|
@@ -1468,6 +1480,8 @@ export class Spoke {
|
|
|
1468
1480
|
return this.rename(req.sessionID, req.title)
|
|
1469
1481
|
case "removeFront":
|
|
1470
1482
|
return this.removeFront(req.sessionID)
|
|
1483
|
+
case "frontUpdate":
|
|
1484
|
+
return { queued: this.fronts.update(req.sessionID, req.text, req.finished === true) } satisfies ResultFor["frontUpdate"]
|
|
1471
1485
|
case "dismissError":
|
|
1472
1486
|
this.unheardFailures.delete(req.sessionID)
|
|
1473
1487
|
return { dismissed: this.tracker.dismissError(req.sessionID) } satisfies ResultFor["dismissError"]
|
|
@@ -1862,7 +1876,7 @@ export class Spoke {
|
|
|
1862
1876
|
const ref = value ? parseModelRef(value) : undefined
|
|
1863
1877
|
return ref && { providerID: ref.providerID, modelID: ref.id, ...(ref.variant ? { variant: ref.variant } : {}) }
|
|
1864
1878
|
}
|
|
1865
|
-
return
|
|
1879
|
+
return convert(this.config.voiceModel) ? { voice: convert(this.config.voiceModel)! } : {}
|
|
1866
1880
|
}
|
|
1867
1881
|
|
|
1868
1882
|
private modelPrefsResult(sessionID: string): ResultFor["modelPrefs"] {
|
|
@@ -1885,18 +1899,24 @@ export class Spoke {
|
|
|
1885
1899
|
}
|
|
1886
1900
|
}
|
|
1887
1901
|
const host = this.host
|
|
1888
|
-
const [messages, models, mcp, diffs, branch] = await Promise.all([
|
|
1902
|
+
const [messages, models, mcp, diffs, branch, session] = await Promise.all([
|
|
1889
1903
|
optional(() => this.shownMessages(sessionID)),
|
|
1890
1904
|
optional(host.listModels && (() => host.listModels!())),
|
|
1891
1905
|
optional(host.mcpList && (() => host.mcpList!(directory))),
|
|
1892
1906
|
optional(host.vcsDiff && (() => host.vcsDiff!(directory))),
|
|
1893
1907
|
optional(host.vcsBranch && (() => host.vcsBranch!(directory))),
|
|
1908
|
+
optional(() => host.getSession(sessionID)),
|
|
1894
1909
|
])
|
|
1895
1910
|
const context = messages && contextUsage(messages, models ?? [])
|
|
1911
|
+
// The session's own model: chosen, else what its latest reply ran on; the default model only before any reply.
|
|
1912
|
+
const latest = sessionModel({ chosen: session?.model, ...(messages ? { messages } : {}), models: models ?? [] })
|
|
1913
|
+
const fallback = latest ? undefined : await optional(host.defaultModel && (() => host.defaultModel!(directory)))
|
|
1914
|
+
const model = latest ?? sessionModel({ fallback, models: models ?? [] })
|
|
1896
1915
|
return {
|
|
1897
1916
|
directory,
|
|
1898
1917
|
...(branch ? { branch } : {}),
|
|
1899
1918
|
...(context ? { context } : {}),
|
|
1919
|
+
...(model ? { model } : {}),
|
|
1900
1920
|
...(mcp ? { mcp: mcpServers(mcp) } : {}),
|
|
1901
1921
|
...(diffs ? { changes: changeStats(diffs) } : {}),
|
|
1902
1922
|
}
|
|
@@ -1919,57 +1939,24 @@ export class Spoke {
|
|
|
1919
1939
|
return { stopped: order }
|
|
1920
1940
|
}
|
|
1921
1941
|
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
* A cleared override switches back to the model the session had before the first override, unless the
|
|
1925
|
-
* user picked another model meanwhile.
|
|
1926
|
-
*/
|
|
1927
|
-
private async applyVoiceModel(sessionID: string): Promise<void> {
|
|
1928
|
-
const selected = this.modelPrefs.get(sessionID)?.voice
|
|
1929
|
-
const override = this.voiceOverrides.get(sessionID)
|
|
1930
|
-
// A session with a voice front keeps its model: the voice selection is the front's.
|
|
1931
|
-
if (this.fronts.frontOf(sessionID)) return
|
|
1932
|
-
if ((!selected && !override) || this.tracker.active().includes(this.tracker.rootOf(sessionID))) return
|
|
1933
|
-
const current = await this.currentModel(sessionID)
|
|
1934
|
-
const onOverride = override !== undefined && current !== undefined && sameModel(current, override.applied)
|
|
1935
|
-
if (!selected) {
|
|
1936
|
-
this.voiceOverrides.delete(sessionID)
|
|
1937
|
-
if (onOverride && override.previous) await this.switchModel(sessionID, override.previous)
|
|
1938
|
-
return
|
|
1939
|
-
}
|
|
1940
|
-
const target = { providerID: selected.providerID, id: selected.modelID, ...(selected.variant ? { variant: selected.variant } : {}) }
|
|
1941
|
-
if (current && sameModel(current, target)) {
|
|
1942
|
-
if (override) this.voiceOverrides.set(sessionID, { ...override, applied: target })
|
|
1943
|
-
return
|
|
1944
|
-
}
|
|
1945
|
-
const previous = onOverride ? override.previous : current ? plainModelRef(current) : undefined
|
|
1946
|
-
this.voiceOverrides.set(sessionID, { ...(previous ? { previous } : {}), applied: target })
|
|
1947
|
-
await this.switchModel(sessionID, target)
|
|
1948
|
-
}
|
|
1949
|
-
|
|
1950
|
-
/** The session's current model: remembered, else asked from OpenCode (and remembered). */
|
|
1951
|
-
private async currentModel(sessionID: string): Promise<ModelRef | undefined> {
|
|
1952
|
-
const known = this.sessionModels.get(sessionID)
|
|
1953
|
-
if (known) return known.model
|
|
1954
|
-
const { model } = await this.host.getSession(sessionID)
|
|
1955
|
-
this.rememberModel(sessionID, model)
|
|
1956
|
-
return model
|
|
1957
|
-
}
|
|
1958
|
-
|
|
1959
|
-
private rememberModel(sessionID: string, model: ModelRef | undefined): void {
|
|
1960
|
-
this.sessionModels.delete(sessionID)
|
|
1961
|
-
this.sessionModels.set(sessionID, model ? { model } : {})
|
|
1962
|
-
if (this.sessionModels.size > SESSION_MODELS_LIMIT) this.sessionModels.delete(this.sessionModels.keys().next().value!)
|
|
1942
|
+
private async switchModel(sessionID: string, model: ModelRef): Promise<void> {
|
|
1943
|
+
await this.host.switchModel(sessionID, model)
|
|
1963
1944
|
}
|
|
1964
1945
|
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1946
|
+
/**
|
|
1947
|
+
* (feature "setModel") The session's own model, as the console picked it: OpenCode's session.switchModel stores it
|
|
1948
|
+
* on the session, so every later turn runs on it whoever prompts (the console, the TUI, a voice relay). Only a
|
|
1949
|
+
* model (and variant) of this OpenCode's catalog; no variant: the model's default.
|
|
1950
|
+
*/
|
|
1951
|
+
private async setSessionModel(sessionID: string, model: ModelSelection): Promise<ResultFor["setModel"]> {
|
|
1952
|
+
const catalog = (await this.host.listModels?.()) ?? []
|
|
1953
|
+
const known = catalog.find((value: any) => value?.providerID === model.providerID && (typeof value.id === "string" && value.id ? value.id : value.modelID) === model.modelID) as any
|
|
1954
|
+
const variants: string[] = Array.isArray(known?.variants) ? known.variants.flatMap((v: any) => (typeof v?.id === "string" ? [v.id] : [])) : []
|
|
1955
|
+
const variant = model.variant && model.variant !== "default" ? model.variant : undefined
|
|
1956
|
+
if (!known || (variant && !variants.includes(variant))) throw new Error(`Unknown model ${model.providerID}/${model.modelID}${variant ? `#${variant}` : ""} for this session`)
|
|
1957
|
+
const ref: ModelRef = { providerID: model.providerID, id: model.modelID, ...(variant ? { variant } : {}) }
|
|
1958
|
+
await this.switchModel(sessionID, ref)
|
|
1959
|
+
return { model: sessionModel({ chosen: ref, models: catalog })! }
|
|
1973
1960
|
}
|
|
1974
1961
|
|
|
1975
1962
|
/** Enables or disables voice for a session this process reported (mode "session" only). */
|
|
@@ -2070,7 +2057,7 @@ export class Spoke {
|
|
|
2070
2057
|
|
|
2071
2058
|
private async turn(sessionID: string, text: string, voiceStyle: boolean, images?: readonly PromptImage[]): Promise<TurnResult> {
|
|
2072
2059
|
if (this.running.has(sessionID)) throw new Error("A turn is already running for this session")
|
|
2073
|
-
|
|
2060
|
+
await this.checkAgent(sessionID)
|
|
2074
2061
|
this.running.add(sessionID)
|
|
2075
2062
|
if (voiceStyle) this.voiceTurns.set(sessionID, (this.voiceTurns.get(sessionID) ?? 0) + 1)
|
|
2076
2063
|
try {
|
|
@@ -2096,11 +2083,11 @@ export class Spoke {
|
|
|
2096
2083
|
|
|
2097
2084
|
private async listen(): Promise<void> {
|
|
2098
2085
|
while (!this.abort.signal.aborted) {
|
|
2099
|
-
// Model changes may have been missed while the stream was down: ask OpenCode again.
|
|
2100
|
-
this.sessionModels.clear()
|
|
2101
2086
|
try {
|
|
2102
2087
|
// Events may have been missed while the stream was down (or before it opened): ask what runs now.
|
|
2103
2088
|
if (this.connection?.state === "connected") void this.seedActivity()
|
|
2089
|
+
// (voice front) A relay delivered or cancelled in a gap must not hold the front's reports.
|
|
2090
|
+
void this.reconcileFronts()
|
|
2104
2091
|
for await (const event of this.host.events(this.abort.signal)) this.observe(event)
|
|
2105
2092
|
} catch (cause) {
|
|
2106
2093
|
if (this.abort.signal.aborted) return
|
|
@@ -2116,7 +2103,6 @@ export class Spoke {
|
|
|
2116
2103
|
const sessionID = (event.data as Record<string, unknown> | undefined)?.sessionID
|
|
2117
2104
|
if (typeof sessionID === "string") this.timelines.invalidate(sessionID)
|
|
2118
2105
|
this.turns.observe(event)
|
|
2119
|
-
this.observeModel(event)
|
|
2120
2106
|
this.origins.observe(event)
|
|
2121
2107
|
if (event.type === "session.deleted" && typeof sessionID === "string") {
|
|
2122
2108
|
this.tokens.delete(sessionID)
|
|
@@ -2140,13 +2126,15 @@ export class Spoke {
|
|
|
2140
2126
|
this.trackAsk(event)
|
|
2141
2127
|
if (this.tracker.observe(event, Date.now())) this.scheduleSnapshot()
|
|
2142
2128
|
this.observeFronts(event, sessionID)
|
|
2129
|
+
if (typeof sessionID === "string") this.observeAgent(event, sessionID)
|
|
2143
2130
|
if (REVERT_EVENTS.has(event.type) && typeof sessionID === "string") void this.observeRevert(event, sessionID)
|
|
2144
2131
|
if (typeof sessionID === "string") this.profiles.settle(sessionID) // a turn boundary makes a staged profile effective
|
|
2145
2132
|
if (this.host.listRecent && LISTING_EVENTS.has(event.type) && !(event.data as any)?.parentID) {
|
|
2146
2133
|
this.scheduleSeed()
|
|
2147
2134
|
}
|
|
2148
2135
|
void this.fetchInfo()
|
|
2149
|
-
|
|
2136
|
+
// (voice front) A front's text after its hand-off in the same run is never spoken: the turn ends there.
|
|
2137
|
+
if (!this.fronts.dropText(event)) this.forward(event)
|
|
2150
2138
|
} catch (cause) {
|
|
2151
2139
|
console.error("[hub] event handling failed", cause)
|
|
2152
2140
|
}
|
|
@@ -2180,6 +2168,18 @@ export class Spoke {
|
|
|
2180
2168
|
this.voiceTitled.delete(sessionID)
|
|
2181
2169
|
}
|
|
2182
2170
|
|
|
2171
|
+
/**
|
|
2172
|
+
* The removed "voice" agent: a session switched onto it is checked again before the hub's next prompt, and a turn
|
|
2173
|
+
* that failed on it (typed in the TUI) moves the session to the default agent, so the next one runs.
|
|
2174
|
+
*/
|
|
2175
|
+
private observeAgent(event: EventLike, sessionID: string): void {
|
|
2176
|
+
const data = event.data as Record<string, unknown> | undefined
|
|
2177
|
+
if (event.type === "session.agent.selected" && data?.agent === LEGACY_VOICE_AGENT) this.agentChecked.delete(sessionID)
|
|
2178
|
+
if (event.type !== "session.execution.failed") return
|
|
2179
|
+
const message = (data?.error as { message?: unknown } | undefined)?.message
|
|
2180
|
+
if (typeof message === "string" && message.includes(`Agent not found: "${LEGACY_VOICE_AGENT}"`)) void this.leaveRemovedAgent(sessionID, LEGACY_VOICE_AGENT)
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
2183
|
/** The main session of a front, also of a failed one (still reported hidden until deleted). */
|
|
2184
2184
|
private frontMainOf(sessionID: string): string | undefined {
|
|
2185
2185
|
const main = this.fronts.mainOf(sessionID)
|
|
@@ -2301,24 +2301,15 @@ export class Spoke {
|
|
|
2301
2301
|
|
|
2302
2302
|
/**
|
|
2303
2303
|
* Adds who put the item in the inbox (user or hub) to session.inbox.delivered, for hubs that route messages
|
|
2304
|
-
* (they reset reply-chain hops on a user's turn)
|
|
2304
|
+
* (they reset reply-chain hops on a user's turn), and `carried` for a user prompt the hub carried (feature
|
|
2305
|
+
* inboxCarried); when unknown, the event goes out unchanged.
|
|
2305
2306
|
*/
|
|
2306
2307
|
private withOrigin<E extends { type: string; data: Record<string, unknown> }>(event: E | undefined): E | undefined {
|
|
2307
2308
|
if (event?.type !== "session.inbox.delivered" || typeof event.data.inboxID !== "string") return event
|
|
2309
|
+
const carried = this.origins.carried(event.data.inboxID)
|
|
2308
2310
|
const origin = this.origins.take(event.data.inboxID)
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
/** Keeps the remembered session models current from OpenCode's events. */
|
|
2313
|
-
private observeModel(event: EventLike): void {
|
|
2314
|
-
const data = (event.data ?? {}) as { sessionID?: unknown; model?: unknown }
|
|
2315
|
-
if (typeof data.sessionID !== "string") return
|
|
2316
|
-
if (event.type === "session.deleted") this.sessionModels.delete(data.sessionID)
|
|
2317
|
-
else if (event.type === "session.model.selected") {
|
|
2318
|
-
const model = data.model as Partial<ModelRef> | undefined
|
|
2319
|
-
if (model && typeof model.providerID === "string" && typeof model.id === "string") this.rememberModel(data.sessionID, model as ModelRef)
|
|
2320
|
-
else this.sessionModels.delete(data.sessionID)
|
|
2321
|
-
}
|
|
2311
|
+
// (Session findability) carried: the hub carried this user prompt itself (it is not one typed in the terminal).
|
|
2312
|
+
return origin && this.hubSupports("messaging") ? { ...event, data: { ...event.data, origin, ...(carried ? { carried: true } : {}) } } : event
|
|
2322
2313
|
}
|
|
2323
2314
|
|
|
2324
2315
|
private async fetchInfo(): Promise<void> {
|
|
@@ -2545,9 +2536,8 @@ function limitWorkspaces(workspaces: readonly HerdrWorkspace[]): HerdrWorkspace[
|
|
|
2545
2536
|
return workspaces.slice(0, 200).map((w) => ({ id: w.id, label: w.label, directories: w.directories.slice(0, 20) }))
|
|
2546
2537
|
}
|
|
2547
2538
|
|
|
2548
|
-
/**
|
|
2549
|
-
const
|
|
2539
|
+
/** The end of a hand-off tool's result: the front's turn is over (anything it writes after is not spoken anyway). */
|
|
2540
|
+
const HANDOFF_DONE = "Your turn is over: write nothing more now. You will get a <system-update> when there is news."
|
|
2550
2541
|
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
}
|
|
2542
|
+
/** Sessions remembered as checked for the removed voice agent. */
|
|
2543
|
+
const AGENT_CHECKED_LIMIT = 500
|