@huynle/hub 0.9.1 → 0.10.0
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 +309 -0
- package/Dockerfile +5 -1
- package/README.md +153 -77
- package/package.json +7 -4
- package/packages/claude-code/package.json +1 -1
- package/packages/cli/dist/hub-cli.js +532 -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 +69 -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/webhooks.ts +5 -2
- 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 +88 -5
- 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 +517 -56
- package/src/central/human-activity.ts +453 -0
- package/src/central/llm/chat.ts +133 -0
- package/src/central/push-target.ts +11 -2
- 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 +209 -238
- 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-Bz992ZX6.js} +1 -1
- package/web/dist/assets/{ArtifactPane-CKOiAUNH.js → ArtifactPane-BJZNixJC.js} +1 -1
- package/web/dist/assets/ArtifactWorkbench-Dti3VP07.js +6 -0
- package/web/dist/assets/{Diagram-vdpmp3xu.css → Diagram-7y8kg3og.css} +1 -1
- package/web/dist/assets/Diagram-B4HdUxw0.js +1 -0
- package/web/dist/assets/{Library-CcjZXsfF.js → Library-CpzD2HDW.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-DlQeogea.js +1 -0
- package/web/dist/assets/{TeamsPage-CDyjrK-I.js → TeamsPage-4xdWCaI-.js} +1 -1
- package/web/dist/assets/{Watchtower-DYwoUWME.js → Watchtower-CpQdIRns.js} +2 -2
- package/web/dist/assets/{artifacts-DU9uRgs1.js → artifacts-But87Jfo.js} +1 -1
- package/web/dist/assets/index-BC0O1gfR.css +1 -0
- package/web/dist/assets/index-BS6lA6fN.js +33 -0
- package/web/dist/assets/{live-ycZ_aQZO.js → live-CVUFIdUX.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-IaJiloPi.js} +1 -1
- package/web/dist/assets/{renderer3d-C1a7eYGQ.js → renderer3d-ZiD1uKGW.js} +1 -1
- package/web/dist/assets/{safe-area-DHhPy61Q.js → safe-area-C7IAlkgY.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,
|
|
@@ -39,7 +40,7 @@ import { TimelineCache, boundedTimelinePart, newestPage, timelineFile, timelineI
|
|
|
39
40
|
import { MediaCache, timelineMedia, type MediaMessages } from "./media"
|
|
40
41
|
import { BundleCache, previewBundle } from "./preview-bundle"
|
|
41
42
|
import { CodedError, SpokeConnection, type Backoff, type ConnectionState } from "./client"
|
|
42
|
-
import { isHubPrompt,
|
|
43
|
+
import { isHubPrompt, LEGACY_VOICE_AGENT, PROMPT_SOURCE } from "./conversation"
|
|
43
44
|
import { DeliveryLedger, deferredWhileBusy, deliverDescription, deliverMetadata, deliverText, harnessMessageID, InboxOrigins, planDelivery, QUIET_WHILE_BUSY, REVERT_STAGED } from "./deliver"
|
|
44
45
|
import { pollEnrollment, startEnrollment } from "./enroll"
|
|
45
46
|
import { HerdrWatcher, openInHerdr, type HerdrRunner } from "./herdr"
|
|
@@ -50,10 +51,10 @@ import { OpenCodeProfiles } from "./profile-apply"
|
|
|
50
51
|
import { SessionTokenStore } from "./session-tokens"
|
|
51
52
|
import { SessionContexts } from "./session-context"
|
|
52
53
|
import { CONCIERGE_AGENT } from "../../packages/protocol/src/concierge.ts"
|
|
53
|
-
import { changeStats, contextUsage, filePatch, mcpServers } from "./summary"
|
|
54
|
+
import { changeStats, contextUsage, filePatch, mcpServers, sessionModel } from "./summary"
|
|
54
55
|
import { browserSessions, eventSessionID, isExposed, isReported, SessionTracker, shouldForward, type EventLike, type SessionDetails } from "./tracker"
|
|
55
56
|
import { renderTaskBoard, TaskBoard } from "./taskboard"
|
|
56
|
-
import { FRONT_TITLE_PREFIX, frontTitle, relayDelivery, trimText, VOICE_FRONT_AGENT, VoiceFronts } from "./voice-front"
|
|
57
|
+
import { FRONT_RELAY_TOOL, FRONT_TITLE_PREFIX, frontTitle, noThinkingOptions, relayDelivery, trimText, VOICE_FRONT_AGENT, VoiceFronts, type FrontMode, type FrontModelInfo } from "./voice-front"
|
|
57
58
|
import { watchConfig, type ConfigWatcher } from "./watch"
|
|
58
59
|
|
|
59
60
|
export type SpokeErrorName = "not_connected" | "not_enrolled" | "invalid" | "unreachable"
|
|
@@ -112,6 +113,8 @@ export interface SpokeHost {
|
|
|
112
113
|
synthetic?(input: { sessionID: string; id: string; text: string; description?: string; metadata: Record<string, unknown>; delivery: DeliveryMode; resume: boolean }): Promise<{ id?: string }>
|
|
113
114
|
context(sessionID: string): Promise<ReadonlyArray<unknown>>
|
|
114
115
|
listModels?(): Promise<ReadonlyArray<unknown>>
|
|
116
|
+
/** The default model at `directory` (OpenCode's model.default, a catalog entry with providerID and id); undefined: none. */
|
|
117
|
+
defaultModel?(directory: string): Promise<unknown>
|
|
115
118
|
getSession(sessionID: string): Promise<HostSession>
|
|
116
119
|
/**
|
|
117
120
|
* Replies to a pending permission; "gone" when it is not pending here any more (answered, expired).
|
|
@@ -161,6 +164,11 @@ export interface SpokeHost {
|
|
|
161
164
|
listRecent?(options?: { days: number; max: number; include: readonly string[]; signal?: AbortSignal }): Promise<Array<{ id: string } & SessionDetails>>
|
|
162
165
|
/** Creates a session in a directory (the create op: service processes only; voice fronts: any process). */
|
|
163
166
|
createSession?(input: { directory: string; title?: string }): Promise<{ id: string }>
|
|
167
|
+
/**
|
|
168
|
+
* (voice front) The IDs of a session's inbox items still waiting (OpenCode's session.inbox.list, own server only):
|
|
169
|
+
* relays no longer listed were delivered or cancelled while events were missed.
|
|
170
|
+
*/
|
|
171
|
+
inboxIDs?(sessionID: string): Promise<string[]>
|
|
164
172
|
/** Deletes a session (OpenCode's session.remove); a voice front when its conversation ends. */
|
|
165
173
|
removeSession?(sessionID: string): Promise<unknown>
|
|
166
174
|
/** MCP servers and their status for a directory (raw OpenCode McpServer list). */
|
|
@@ -370,7 +378,7 @@ export class Spoke {
|
|
|
370
378
|
* ever gets one. Only actIdentity reads them (the OpenCode hub tool). Never logged, never in any environment.
|
|
371
379
|
*/
|
|
372
380
|
private readonly actTokens = new SessionTokenStore()
|
|
373
|
-
/** The background workers each session started (the voice
|
|
381
|
+
/** The background workers each session started (the task board its voice front reads). */
|
|
374
382
|
private readonly tasks = new TaskBoard()
|
|
375
383
|
private readonly turns = new TurnTracker()
|
|
376
384
|
/** Timeline rows of sessions being paged through (30 s, dropped on any event of the session). */
|
|
@@ -388,17 +396,15 @@ export class Spoke {
|
|
|
388
396
|
/** Who put each inbox item into a session (forwarded with session.inbox.delivered). */
|
|
389
397
|
private readonly origins = new InboxOrigins()
|
|
390
398
|
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
399
|
/**
|
|
398
|
-
*
|
|
399
|
-
*
|
|
400
|
+
* Sessions in conversation mode without a voice front (an older hub, no front, or one that could not be made): the
|
|
401
|
+
* hub talks to them directly and their turns are voice turns. Their agent and model are never switched.
|
|
400
402
|
*/
|
|
401
|
-
private readonly
|
|
403
|
+
private readonly conversations = new Set<string>()
|
|
404
|
+
/** Sessions checked for the removed "voice" agent (a turn on a missing agent fails in OpenCode 2.2.3). */
|
|
405
|
+
private readonly agentChecked = new Set<string>()
|
|
406
|
+
/** Per-session selections supplied by the hub; the hub persists them per user. */
|
|
407
|
+
private readonly modelPrefs = new Map<string, SessionModelPrefs>()
|
|
402
408
|
/** Session → callbacks waiting for its next session.renamed event (autoName). */
|
|
403
409
|
private readonly renameWaiters = new Map<string, Set<(title: string) => void>>()
|
|
404
410
|
private conversationChain: Promise<unknown> = Promise.resolve()
|
|
@@ -424,6 +430,8 @@ export class Spoke {
|
|
|
424
430
|
*/
|
|
425
431
|
private readonly voiceTitled = new Map<string, "checking" | "user" | "front" | "hidden">()
|
|
426
432
|
private readonly frontFallbacks = new Set<string>()
|
|
433
|
+
/** (fix D) The catalog entry of each front model seen: provider package and variants. */
|
|
434
|
+
private readonly frontModelInfo = new Map<string, FrontModelInfo | undefined>()
|
|
427
435
|
private readonly unlinkedFronts = new Set<string>()
|
|
428
436
|
private frontWakes = 0
|
|
429
437
|
private readonly abort = new AbortController()
|
|
@@ -549,21 +557,19 @@ export class Spoke {
|
|
|
549
557
|
this.fronts.stop()
|
|
550
558
|
}
|
|
551
559
|
|
|
552
|
-
/**
|
|
560
|
+
/**
|
|
561
|
+
* A voice-styled turn: a hub /turn asked for it, or the hub talks to the session directly in conversation mode (no
|
|
562
|
+
* voice front: an older hub, none could be made, or it failed).
|
|
563
|
+
*/
|
|
553
564
|
isVoiceTurn(sessionID: string): boolean {
|
|
554
|
-
return (this.voiceTurns.get(sessionID) ?? 0) > 0 || this.frontFallbacks.has(sessionID)
|
|
565
|
+
return (this.voiceTurns.get(sessionID) ?? 0) > 0 || this.frontFallbacks.has(sessionID) || this.conversations.has(sessionID)
|
|
555
566
|
}
|
|
556
567
|
|
|
557
|
-
/** Conversation mode is on:
|
|
568
|
+
/** Conversation mode is on: with a voice front (also a failed one), or talking to the session directly. */
|
|
558
569
|
inConversation(sessionID: string): boolean {
|
|
559
570
|
return this.conversations.has(sessionID) || this.fronts.frontOf(sessionID) !== undefined || this.frontFallbacks.has(sessionID)
|
|
560
571
|
}
|
|
561
572
|
|
|
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
573
|
/**
|
|
568
574
|
* voice_main_send: relays the user's request from a voice front to its main session, as the user's (hub-sourced)
|
|
569
575
|
* prompt. "auto": idle starts a turn, busy steers; "queue" waits behind main's running turn.
|
|
@@ -576,11 +582,40 @@ export class Spoke {
|
|
|
576
582
|
const busy = this.running.has(main) || this.tracker.isBusy(main)
|
|
577
583
|
const delivery = relayDelivery(input.delivery, busy)
|
|
578
584
|
const inbox = await this.host.prompt({ sessionID: main, text, delivery, metadata: { source: PROMPT_SOURCE, kind: "voice-front" } })
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
585
|
+
this.fronts.relayed(main, inbox.id, text, delivery)
|
|
586
|
+
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."
|
|
587
|
+
return { text: `${done} ${HANDOFF_DONE}`, inboxID: inbox.id }
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* (P2) relay_to_session: a Concierge-mode front hands the user's request to one of the user's existing sessions,
|
|
592
|
+
* through the hub (relayToSession), which checks the front, the utterance and the target. Refused here, with no hub
|
|
593
|
+
* request, when the front's latest input is a hub wake (only the user's speech may relay) or the hub cannot relay.
|
|
594
|
+
* A refusal re-opens the front's turn (its next step may say why).
|
|
595
|
+
*/
|
|
596
|
+
async frontRelay(callerID: string, input: { session?: unknown; text?: unknown; previous?: unknown; confirm?: unknown }): Promise<{ text: string; sessionID?: string }> {
|
|
597
|
+
const refuse = (text: string) => (this.fronts.handoffFailed(callerID), { text })
|
|
598
|
+
if (this.fronts.mode(callerID) !== "concierge") return { text: "This session is not a Concierge front; nothing was sent." }
|
|
599
|
+
const session = typeof input.session === "string" ? input.session.trim() : ""
|
|
600
|
+
const text = typeof input.text === "string" ? input.text.trim() : ""
|
|
601
|
+
if (!session) return refuse("Name the session: its ID or @name from the board. Nothing was sent.")
|
|
602
|
+
if (!text) return refuse("Pass the request as text. Nothing was sent.")
|
|
603
|
+
if (!this.hubSupports("frontRelay")) return refuse("This hub cannot relay to other sessions: pass the request to the Concierge instead. Nothing was sent.")
|
|
604
|
+
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.`)
|
|
605
|
+
try {
|
|
606
|
+
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 } : {}) })
|
|
607
|
+
const name = result.title?.trim() || "the session"
|
|
608
|
+
const done = result.mode === "steer" ? `Steered into ${name}'s running turn.` : `Sent to ${name}; it started working on it.`
|
|
609
|
+
return { text: `${done} ${HANDOFF_DONE}`, sessionID: result.sessionID }
|
|
610
|
+
} catch (cause) {
|
|
611
|
+
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
612
|
}
|
|
583
|
-
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** (P2) The tools a front keeps this turn (none for any other session); the relay only when the hub relays. */
|
|
616
|
+
frontTools(sessionID: string): string[] {
|
|
617
|
+
const tools = this.fronts.tools(sessionID)
|
|
618
|
+
return this.hubSupports("frontRelay") ? tools : tools.filter((name) => name !== FRONT_RELAY_TOOL)
|
|
584
619
|
}
|
|
585
620
|
|
|
586
621
|
/** voice_main_stop: interrupts the front's main session and everything running below it (as the stop op). */
|
|
@@ -589,7 +624,7 @@ export class Spoke {
|
|
|
589
624
|
if (!main) return { text: "This session is not a voice front; nothing was stopped.", stopped: [] }
|
|
590
625
|
this.fronts.stopRequested(main)
|
|
591
626
|
const { stopped } = await this.stopTree(main)
|
|
592
|
-
return { text:
|
|
627
|
+
return { text: `Stopped the session's work. ${HANDOFF_DONE}`, stopped: [...stopped] }
|
|
593
628
|
}
|
|
594
629
|
|
|
595
630
|
/** The voice front's per-turn note about its main session; undefined for any other session. */
|
|
@@ -598,7 +633,37 @@ export class Spoke {
|
|
|
598
633
|
if (!main) return undefined
|
|
599
634
|
const title = this.tracker.snapshot(Date.now()).find((s) => s.id === main)?.title
|
|
600
635
|
const workers = this.taskBoard(main)
|
|
601
|
-
|
|
636
|
+
const board = this.fronts.mode(sessionID) === "concierge" ? this.contexts.frontBoard(main) : undefined
|
|
637
|
+
return this.fronts.context(sessionID, { ...(title ? { title } : {}), busy: this.running.has(main) || this.tracker.isBusy(main), ...(workers ? { workers } : {}), ...(board ? { board } : {}) })
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* (fix D) Request options that turn a front's model thinking off (src/spoke/voice-front.ts noThinkingOptions), from
|
|
642
|
+
* OpenCode's model catalog, cached per model. A catalog that cannot be read gives none.
|
|
643
|
+
*/
|
|
644
|
+
async frontModelOptions(model: { providerID: string; id: string }): Promise<Record<string, unknown>> {
|
|
645
|
+
const key = `${model.providerID}/${model.id}`
|
|
646
|
+
if (!this.frontModelInfo.has(key)) {
|
|
647
|
+
const listed = await Promise.resolve(this.host.listModels?.()).catch(() => undefined)
|
|
648
|
+
const info = (listed ?? []).find((m: any) => m?.providerID === model.providerID && m?.id === model.id) as FrontModelInfo | undefined
|
|
649
|
+
if (!listed) return {}
|
|
650
|
+
this.frontModelInfo.set(key, info)
|
|
651
|
+
if (this.frontModelInfo.size > 50) this.frontModelInfo.delete(this.frontModelInfo.keys().next().value!)
|
|
652
|
+
}
|
|
653
|
+
return noThinkingOptions(this.frontModelInfo.get(key))
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Checks the relays the fronts still wait for against their sessions' inboxes (after an event-stream gap): relays
|
|
658
|
+
* no longer listed are dropped. Without an inbox list, relays older than the hold cap count as resolved.
|
|
659
|
+
*/
|
|
660
|
+
async reconcileFronts(): Promise<void> {
|
|
661
|
+
const list = this.host.inboxIDs?.bind(this.host)
|
|
662
|
+
if (!list) return
|
|
663
|
+
for (const { main } of this.fronts.pendingRelays()) {
|
|
664
|
+
const ids = await list(main).catch(() => undefined)
|
|
665
|
+
if (ids) this.fronts.reconcile(main, new Set(ids))
|
|
666
|
+
}
|
|
602
667
|
}
|
|
603
668
|
|
|
604
669
|
/** A hub update for a front (`<system-update>`): admitted as the hub's (never the user's), steered, waking it. */
|
|
@@ -616,60 +681,11 @@ export class Spoke {
|
|
|
616
681
|
}
|
|
617
682
|
}
|
|
618
683
|
|
|
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. */
|
|
684
|
+
/** The rendered task board of a session's workers (for its voice front's context); undefined without any. */
|
|
630
685
|
taskBoard(sessionID: string): string | undefined {
|
|
631
686
|
return renderTaskBoard(this.tasks.children(sessionID), Date.now())
|
|
632
687
|
}
|
|
633
688
|
|
|
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
689
|
/**
|
|
674
690
|
* TUI presence (RPC voice.presence): the sessions a TUI shows right now (its route and open tabs).
|
|
675
691
|
* `reporter` identifies the TUI; its report replaces its previous one.
|
|
@@ -756,10 +772,10 @@ export class Spoke {
|
|
|
756
772
|
}
|
|
757
773
|
|
|
758
774
|
/**
|
|
759
|
-
* Turns conversation mode on or off for a session in this process. On
|
|
760
|
-
*
|
|
761
|
-
*
|
|
762
|
-
*
|
|
775
|
+
* Turns conversation mode on or off for a session in this process. On with `front`: a hidden voice front answers
|
|
776
|
+
* for the session. Without a front (an older hub, or none can be made) the hub talks to the session directly and
|
|
777
|
+
* its turns are voice turns. The session's agent and model are never switched. Calls are serialized so a re-sent
|
|
778
|
+
* "on" cannot race an "off".
|
|
763
779
|
*/
|
|
764
780
|
setConversation(sessionID: string, on: boolean, front = false, frontID?: string): Promise<ResultFor["conversation"]> {
|
|
765
781
|
if (!isExposed(this.config, this.tracker, sessionID)) return Promise.reject(new SpokeError("invalid", NOT_SHARED))
|
|
@@ -1113,76 +1129,80 @@ export class Spoke {
|
|
|
1113
1129
|
}
|
|
1114
1130
|
|
|
1115
1131
|
private async conversationOn(sessionID: string, front = false, frontID?: string): Promise<ResultFor["conversation"]> {
|
|
1116
|
-
|
|
1117
|
-
let session = await this.host.getSession(sessionID)
|
|
1132
|
+
const session = await this.host.getSession(sessionID)
|
|
1118
1133
|
// (CON-04) lockAgent: conversation mode is recorded, but the session keeps its agent and model (the Concierge
|
|
1119
|
-
// never escapes its restricted agent
|
|
1134
|
+
// never escapes its restricted agent). (P2) With `front`, a Concierge-mode front answers in front of it; without
|
|
1135
|
+
// one (an older hub, or none could be made) the Concierge answers itself, as before.
|
|
1120
1136
|
// A session on the concierge agent is locked too (a spoke that restarted before the hub re-sent the context).
|
|
1121
1137
|
if (this.contexts.locked(sessionID) || session.agent === CONCIERGE_AGENT) {
|
|
1122
|
-
this.
|
|
1123
|
-
|
|
1138
|
+
const agent = session.agent ?? (await this.defaultAgent())
|
|
1139
|
+
const made = front ? await this.frontOn(sessionID, session, frontID, "concierge") : undefined
|
|
1140
|
+
if (made) return { agent, front: { sessionID: made } }
|
|
1141
|
+
this.conversations.add(sessionID)
|
|
1142
|
+
return { agent }
|
|
1124
1143
|
}
|
|
1144
|
+
const agent = await this.leaveRemovedAgent(sessionID, session.agent)
|
|
1125
1145
|
// The front failed: the hub talks to main directly and resends on without front (a reconnect). Main keeps its
|
|
1126
1146
|
// 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
|
-
|
|
1147
|
+
if (!front && this.frontFallbacks.has(sessionID)) return { agent }
|
|
1148
|
+
const made = front ? await this.frontOn(sessionID, session, frontID, "session") : undefined
|
|
1149
|
+
if (made) return { agent, front: { sessionID: made } }
|
|
1150
|
+
// No front: main answers the hub itself, in the voice style. Its agent and model stay as they are.
|
|
1151
|
+
this.conversations.add(sessionID)
|
|
1152
|
+
return { agent }
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
/** The live front of `sessionID`, a re-linked one (`frontID`) or a new one; undefined when none can be made. */
|
|
1156
|
+
private async frontOn(sessionID: string, session: HostSession, frontID: string | undefined, mode: FrontMode): Promise<string | undefined> {
|
|
1157
|
+
const live = this.fronts.frontOf(sessionID)
|
|
1158
|
+
if (live && this.tracker.has(live)) return live
|
|
1159
|
+
// A failed front is replaced (deleted first); the session stops being a fallback.
|
|
1160
|
+
await this.endFront(sessionID)
|
|
1161
|
+
// The hub knows this conversation's front (a resend to a restarted process): re-linked if it still exists.
|
|
1162
|
+
const adopted = frontID ? await this.adoptFront(sessionID, frontID, mode) : undefined
|
|
1163
|
+
const created =
|
|
1164
|
+
adopted ??
|
|
1165
|
+
(await this.createFront(sessionID, session, mode).catch((cause) => {
|
|
1166
|
+
console.error("[hub] could not start a voice front; the hub talks to the session directly", errorText(cause))
|
|
1144
1167
|
return undefined
|
|
1145
|
-
})
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1168
|
+
}))
|
|
1169
|
+
if (created) this.conversations.delete(sessionID)
|
|
1170
|
+
return created
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/**
|
|
1174
|
+
* The session's agent. A session left on older plugins' "voice" agent (a crash mid-conversation) moves to
|
|
1175
|
+
* the default agent: OpenCode 2.2.3 does not fall back, it fails every turn on a missing agent. A "voice" agent the
|
|
1176
|
+
* user defined is theirs and kept.
|
|
1177
|
+
*/
|
|
1178
|
+
private async leaveRemovedAgent(sessionID: string, agent: string | undefined): Promise<string> {
|
|
1179
|
+
this.agentChecked.add(sessionID)
|
|
1180
|
+
if (this.agentChecked.size > AGENT_CHECKED_LIMIT) this.agentChecked.delete(this.agentChecked.values().next().value!)
|
|
1181
|
+
if (agent !== LEGACY_VOICE_AGENT) return agent ?? (await this.defaultAgent())
|
|
1182
|
+
if (await this.host.hasAgent(LEGACY_VOICE_AGENT).catch(() => true)) return agent
|
|
1183
|
+
const target = await this.defaultAgent()
|
|
1184
|
+
try {
|
|
1185
|
+
await this.host.switchAgent(sessionID, target)
|
|
1186
|
+
console.warn(`[hub] ${sessionID} was left on the removed voice agent; it now runs ${target}`)
|
|
1187
|
+
return target
|
|
1188
|
+
} catch (cause) {
|
|
1189
|
+
console.error(`[hub] could not move ${sessionID} off the removed voice agent`, errorText(cause))
|
|
1190
|
+
return agent
|
|
1154
1191
|
}
|
|
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 } : {}) }
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
/** Before the hub prompts a session: moved off the removed voice agent once per session (see leaveRemovedAgent). */
|
|
1195
|
+
private async checkAgent(sessionID: string): Promise<void> {
|
|
1196
|
+
if (this.agentChecked.has(sessionID)) return
|
|
1197
|
+
const session = await this.host.getSession(sessionID).catch(() => undefined)
|
|
1198
|
+
if (session) await this.leaveRemovedAgent(sessionID, session.agent)
|
|
1179
1199
|
}
|
|
1180
1200
|
|
|
1181
1201
|
/**
|
|
1182
1202
|
* Creates main's voice front: a root session in main's directory, titled after it, on the voice-front agent and
|
|
1183
1203
|
* the voice model, reported with voiceFrontOf. Throws (after removing a half-made front) when it cannot.
|
|
1184
1204
|
*/
|
|
1185
|
-
private async createFront(main: string, session: HostSession): Promise<string> {
|
|
1205
|
+
private async createFront(main: string, session: HostSession, mode: FrontMode = "session"): Promise<string> {
|
|
1186
1206
|
const create = this.host.createSession?.bind(this.host)
|
|
1187
1207
|
if (!create) throw new Error("this OpenCode process cannot create sessions")
|
|
1188
1208
|
if (!(await this.host.hasAgent(VOICE_FRONT_AGENT))) throw new Error(`no ${VOICE_FRONT_AGENT} agent`)
|
|
@@ -1202,7 +1222,7 @@ export class Spoke {
|
|
|
1202
1222
|
await this.host.removeSession?.(id).catch(() => undefined)
|
|
1203
1223
|
throw cause
|
|
1204
1224
|
}
|
|
1205
|
-
this.fronts.link(main, id, await this.lastReply(main))
|
|
1225
|
+
this.fronts.link(main, id, await this.lastReply(main), mode)
|
|
1206
1226
|
} finally {
|
|
1207
1227
|
this.frontsCreating--
|
|
1208
1228
|
if (id !== undefined) this.unlinkedFronts.delete(id)
|
|
@@ -1263,11 +1283,11 @@ export class Spoke {
|
|
|
1263
1283
|
* Re-links an existing front to main (the hub names it on a resend): only a session on the voice-front agent that
|
|
1264
1284
|
* this process has not linked to another session. Undefined when it is gone or no front.
|
|
1265
1285
|
*/
|
|
1266
|
-
private async adoptFront(main: string, frontID: string): Promise<string | undefined> {
|
|
1286
|
+
private async adoptFront(main: string, frontID: string, mode: FrontMode = "session"): Promise<string | undefined> {
|
|
1267
1287
|
if (frontID === main || (this.frontMainOf(frontID) ?? main) !== main) return undefined
|
|
1268
1288
|
const info = await this.host.getSession(frontID).catch(() => undefined)
|
|
1269
1289
|
if (info?.agent !== VOICE_FRONT_AGENT || (this.frontMainOf(frontID) ?? main) !== main) return undefined
|
|
1270
|
-
this.fronts.link(main, frontID, await this.lastReply(main))
|
|
1290
|
+
this.fronts.link(main, frontID, await this.lastReply(main), mode)
|
|
1271
1291
|
this.voiceTitled.delete(frontID)
|
|
1272
1292
|
const now = Date.now()
|
|
1273
1293
|
if (!this.tracker.has(frontID)) this.tracker.observe({ type: "session.created", data: { sessionID: frontID, title: info.title, location: info.location } }, now)
|
|
@@ -1328,20 +1348,10 @@ export class Spoke {
|
|
|
1328
1348
|
|
|
1329
1349
|
private async conversationOff(sessionID: string): Promise<ResultFor["conversation"]> {
|
|
1330
1350
|
await this.endFront(sessionID)
|
|
1331
|
-
const entry = this.conversations.get(sessionID)
|
|
1332
1351
|
this.conversations.delete(sessionID)
|
|
1333
1352
|
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 }
|
|
1353
|
+
if (this.contexts.locked(sessionID) || session.agent === CONCIERGE_AGENT) return { agent: session.agent ?? (await this.defaultAgent()) }
|
|
1354
|
+
return { agent: await this.leaveRemovedAgent(sessionID, session.agent) }
|
|
1345
1355
|
}
|
|
1346
1356
|
|
|
1347
1357
|
private async defaultAgent(): Promise<string> {
|
|
@@ -1401,15 +1411,12 @@ export class Spoke {
|
|
|
1401
1411
|
case "modelPrefs":
|
|
1402
1412
|
return this.modelPrefsResult(req.sessionID)
|
|
1403
1413
|
case "setModelPrefs": {
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
}
|
|
1410
|
-
if (next.voice || next.worker) this.modelPrefs.set(req.sessionID, next)
|
|
1414
|
+
// The Voice model is the voice front's. `worker` (older hubs' complex-worker model) is ignored.
|
|
1415
|
+
const next: { voice?: SessionModelPrefs["voice"] } = { ...this.modelPrefs.get(req.sessionID) }
|
|
1416
|
+
if ("voice" in req && req.voice === null) delete next.voice
|
|
1417
|
+
else if (req.voice) next.voice = req.voice
|
|
1418
|
+
if (next.voice) this.modelPrefs.set(req.sessionID, next)
|
|
1411
1419
|
else this.modelPrefs.delete(req.sessionID)
|
|
1412
|
-
if (!next.voice && this.voiceOverrides.has(req.sessionID)) await this.applyVoiceModel(req.sessionID)
|
|
1413
1420
|
return this.modelPrefsResult(req.sessionID)
|
|
1414
1421
|
}
|
|
1415
1422
|
case "timeline": {
|
|
@@ -1450,8 +1457,10 @@ export class Spoke {
|
|
|
1450
1457
|
directory: this.directoryOf(req.sessionID),
|
|
1451
1458
|
})) satisfies ResultFor["previewBundle"]
|
|
1452
1459
|
case "steer": {
|
|
1460
|
+
// (P2) The user's speech reaches a front only through this op (the hub's utterance route).
|
|
1461
|
+
if (this.fronts.isFront(req.sessionID)) this.fronts.heard(req.sessionID, "user")
|
|
1453
1462
|
const voice = isExposed(this.config, this.tracker, req.sessionID)
|
|
1454
|
-
|
|
1463
|
+
await this.checkAgent(req.sessionID)
|
|
1455
1464
|
const inbox = await this.host.prompt({
|
|
1456
1465
|
sessionID: req.sessionID,
|
|
1457
1466
|
text: req.text,
|
|
@@ -1468,6 +1477,8 @@ export class Spoke {
|
|
|
1468
1477
|
return this.rename(req.sessionID, req.title)
|
|
1469
1478
|
case "removeFront":
|
|
1470
1479
|
return this.removeFront(req.sessionID)
|
|
1480
|
+
case "frontUpdate":
|
|
1481
|
+
return { queued: this.fronts.update(req.sessionID, req.text, req.finished === true) } satisfies ResultFor["frontUpdate"]
|
|
1471
1482
|
case "dismissError":
|
|
1472
1483
|
this.unheardFailures.delete(req.sessionID)
|
|
1473
1484
|
return { dismissed: this.tracker.dismissError(req.sessionID) } satisfies ResultFor["dismissError"]
|
|
@@ -1862,7 +1873,7 @@ export class Spoke {
|
|
|
1862
1873
|
const ref = value ? parseModelRef(value) : undefined
|
|
1863
1874
|
return ref && { providerID: ref.providerID, modelID: ref.id, ...(ref.variant ? { variant: ref.variant } : {}) }
|
|
1864
1875
|
}
|
|
1865
|
-
return
|
|
1876
|
+
return convert(this.config.voiceModel) ? { voice: convert(this.config.voiceModel)! } : {}
|
|
1866
1877
|
}
|
|
1867
1878
|
|
|
1868
1879
|
private modelPrefsResult(sessionID: string): ResultFor["modelPrefs"] {
|
|
@@ -1885,18 +1896,24 @@ export class Spoke {
|
|
|
1885
1896
|
}
|
|
1886
1897
|
}
|
|
1887
1898
|
const host = this.host
|
|
1888
|
-
const [messages, models, mcp, diffs, branch] = await Promise.all([
|
|
1899
|
+
const [messages, models, mcp, diffs, branch, session] = await Promise.all([
|
|
1889
1900
|
optional(() => this.shownMessages(sessionID)),
|
|
1890
1901
|
optional(host.listModels && (() => host.listModels!())),
|
|
1891
1902
|
optional(host.mcpList && (() => host.mcpList!(directory))),
|
|
1892
1903
|
optional(host.vcsDiff && (() => host.vcsDiff!(directory))),
|
|
1893
1904
|
optional(host.vcsBranch && (() => host.vcsBranch!(directory))),
|
|
1905
|
+
optional(() => host.getSession(sessionID)),
|
|
1894
1906
|
])
|
|
1895
1907
|
const context = messages && contextUsage(messages, models ?? [])
|
|
1908
|
+
// The session's own model: chosen, else what its latest reply ran on; the default model only before any reply.
|
|
1909
|
+
const latest = sessionModel({ chosen: session?.model, ...(messages ? { messages } : {}), models: models ?? [] })
|
|
1910
|
+
const fallback = latest ? undefined : await optional(host.defaultModel && (() => host.defaultModel!(directory)))
|
|
1911
|
+
const model = latest ?? sessionModel({ fallback, models: models ?? [] })
|
|
1896
1912
|
return {
|
|
1897
1913
|
directory,
|
|
1898
1914
|
...(branch ? { branch } : {}),
|
|
1899
1915
|
...(context ? { context } : {}),
|
|
1916
|
+
...(model ? { model } : {}),
|
|
1900
1917
|
...(mcp ? { mcp: mcpServers(mcp) } : {}),
|
|
1901
1918
|
...(diffs ? { changes: changeStats(diffs) } : {}),
|
|
1902
1919
|
}
|
|
@@ -1919,57 +1936,8 @@ export class Spoke {
|
|
|
1919
1936
|
return { stopped: order }
|
|
1920
1937
|
}
|
|
1921
1938
|
|
|
1922
|
-
/** Applies a saved Voice override at the boundary before the next turn; never changes a running tree. */
|
|
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!)
|
|
1963
|
-
}
|
|
1964
|
-
|
|
1965
1939
|
private async switchModel(sessionID: string, model: ModelRef): Promise<void> {
|
|
1966
|
-
|
|
1967
|
-
await this.host.switchModel(sessionID, model)
|
|
1968
|
-
this.rememberModel(sessionID, model)
|
|
1969
|
-
} catch (cause) {
|
|
1970
|
-
this.sessionModels.delete(sessionID) // unknown now: ask next time
|
|
1971
|
-
throw cause
|
|
1972
|
-
}
|
|
1940
|
+
await this.host.switchModel(sessionID, model)
|
|
1973
1941
|
}
|
|
1974
1942
|
|
|
1975
1943
|
/** Enables or disables voice for a session this process reported (mode "session" only). */
|
|
@@ -2070,7 +2038,7 @@ export class Spoke {
|
|
|
2070
2038
|
|
|
2071
2039
|
private async turn(sessionID: string, text: string, voiceStyle: boolean, images?: readonly PromptImage[]): Promise<TurnResult> {
|
|
2072
2040
|
if (this.running.has(sessionID)) throw new Error("A turn is already running for this session")
|
|
2073
|
-
|
|
2041
|
+
await this.checkAgent(sessionID)
|
|
2074
2042
|
this.running.add(sessionID)
|
|
2075
2043
|
if (voiceStyle) this.voiceTurns.set(sessionID, (this.voiceTurns.get(sessionID) ?? 0) + 1)
|
|
2076
2044
|
try {
|
|
@@ -2096,11 +2064,11 @@ export class Spoke {
|
|
|
2096
2064
|
|
|
2097
2065
|
private async listen(): Promise<void> {
|
|
2098
2066
|
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
2067
|
try {
|
|
2102
2068
|
// Events may have been missed while the stream was down (or before it opened): ask what runs now.
|
|
2103
2069
|
if (this.connection?.state === "connected") void this.seedActivity()
|
|
2070
|
+
// (voice front) A relay delivered or cancelled in a gap must not hold the front's reports.
|
|
2071
|
+
void this.reconcileFronts()
|
|
2104
2072
|
for await (const event of this.host.events(this.abort.signal)) this.observe(event)
|
|
2105
2073
|
} catch (cause) {
|
|
2106
2074
|
if (this.abort.signal.aborted) return
|
|
@@ -2116,7 +2084,6 @@ export class Spoke {
|
|
|
2116
2084
|
const sessionID = (event.data as Record<string, unknown> | undefined)?.sessionID
|
|
2117
2085
|
if (typeof sessionID === "string") this.timelines.invalidate(sessionID)
|
|
2118
2086
|
this.turns.observe(event)
|
|
2119
|
-
this.observeModel(event)
|
|
2120
2087
|
this.origins.observe(event)
|
|
2121
2088
|
if (event.type === "session.deleted" && typeof sessionID === "string") {
|
|
2122
2089
|
this.tokens.delete(sessionID)
|
|
@@ -2140,13 +2107,15 @@ export class Spoke {
|
|
|
2140
2107
|
this.trackAsk(event)
|
|
2141
2108
|
if (this.tracker.observe(event, Date.now())) this.scheduleSnapshot()
|
|
2142
2109
|
this.observeFronts(event, sessionID)
|
|
2110
|
+
if (typeof sessionID === "string") this.observeAgent(event, sessionID)
|
|
2143
2111
|
if (REVERT_EVENTS.has(event.type) && typeof sessionID === "string") void this.observeRevert(event, sessionID)
|
|
2144
2112
|
if (typeof sessionID === "string") this.profiles.settle(sessionID) // a turn boundary makes a staged profile effective
|
|
2145
2113
|
if (this.host.listRecent && LISTING_EVENTS.has(event.type) && !(event.data as any)?.parentID) {
|
|
2146
2114
|
this.scheduleSeed()
|
|
2147
2115
|
}
|
|
2148
2116
|
void this.fetchInfo()
|
|
2149
|
-
|
|
2117
|
+
// (voice front) A front's text after its hand-off in the same run is never spoken: the turn ends there.
|
|
2118
|
+
if (!this.fronts.dropText(event)) this.forward(event)
|
|
2150
2119
|
} catch (cause) {
|
|
2151
2120
|
console.error("[hub] event handling failed", cause)
|
|
2152
2121
|
}
|
|
@@ -2180,6 +2149,18 @@ export class Spoke {
|
|
|
2180
2149
|
this.voiceTitled.delete(sessionID)
|
|
2181
2150
|
}
|
|
2182
2151
|
|
|
2152
|
+
/**
|
|
2153
|
+
* The removed "voice" agent: a session switched onto it is checked again before the hub's next prompt, and a turn
|
|
2154
|
+
* that failed on it (typed in the TUI) moves the session to the default agent, so the next one runs.
|
|
2155
|
+
*/
|
|
2156
|
+
private observeAgent(event: EventLike, sessionID: string): void {
|
|
2157
|
+
const data = event.data as Record<string, unknown> | undefined
|
|
2158
|
+
if (event.type === "session.agent.selected" && data?.agent === LEGACY_VOICE_AGENT) this.agentChecked.delete(sessionID)
|
|
2159
|
+
if (event.type !== "session.execution.failed") return
|
|
2160
|
+
const message = (data?.error as { message?: unknown } | undefined)?.message
|
|
2161
|
+
if (typeof message === "string" && message.includes(`Agent not found: "${LEGACY_VOICE_AGENT}"`)) void this.leaveRemovedAgent(sessionID, LEGACY_VOICE_AGENT)
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2183
2164
|
/** The main session of a front, also of a failed one (still reported hidden until deleted). */
|
|
2184
2165
|
private frontMainOf(sessionID: string): string | undefined {
|
|
2185
2166
|
const main = this.fronts.mainOf(sessionID)
|
|
@@ -2301,24 +2282,15 @@ export class Spoke {
|
|
|
2301
2282
|
|
|
2302
2283
|
/**
|
|
2303
2284
|
* 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)
|
|
2285
|
+
* (they reset reply-chain hops on a user's turn), and `carried` for a user prompt the hub carried (feature
|
|
2286
|
+
* inboxCarried); when unknown, the event goes out unchanged.
|
|
2305
2287
|
*/
|
|
2306
2288
|
private withOrigin<E extends { type: string; data: Record<string, unknown> }>(event: E | undefined): E | undefined {
|
|
2307
2289
|
if (event?.type !== "session.inbox.delivered" || typeof event.data.inboxID !== "string") return event
|
|
2290
|
+
const carried = this.origins.carried(event.data.inboxID)
|
|
2308
2291
|
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
|
-
}
|
|
2292
|
+
// (Session findability) carried: the hub carried this user prompt itself (it is not one typed in the terminal).
|
|
2293
|
+
return origin && this.hubSupports("messaging") ? { ...event, data: { ...event.data, origin, ...(carried ? { carried: true } : {}) } } : event
|
|
2322
2294
|
}
|
|
2323
2295
|
|
|
2324
2296
|
private async fetchInfo(): Promise<void> {
|
|
@@ -2545,9 +2517,8 @@ function limitWorkspaces(workspaces: readonly HerdrWorkspace[]): HerdrWorkspace[
|
|
|
2545
2517
|
return workspaces.slice(0, 200).map((w) => ({ id: w.id, label: w.label, directories: w.directories.slice(0, 20) }))
|
|
2546
2518
|
}
|
|
2547
2519
|
|
|
2548
|
-
/**
|
|
2549
|
-
const
|
|
2520
|
+
/** The end of a hand-off tool's result: the front's turn is over (anything it writes after is not spoken anyway). */
|
|
2521
|
+
const HANDOFF_DONE = "Your turn is over: write nothing more now. You will get a <system-update> when there is news."
|
|
2550
2522
|
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
}
|
|
2523
|
+
/** Sessions remembered as checked for the removed voice agent. */
|
|
2524
|
+
const AGENT_CHECKED_LIMIT = 500
|