@huynle/hub 0.8.6 → 0.9.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 +139 -0
- package/README.md +69 -12
- package/package.json +5 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/cli/dist/hub-cli.js +278 -35
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/bootstrap/adapters/opencode/README.md +4 -2
- package/packages/cli/src/bootstrap/release/harness-pins.json +22 -0
- package/packages/cli/src/bootstrap/release/harness-pins.pending.json +1 -20
- package/packages/cli/src/cli.ts +23 -10
- package/packages/cli/src/http.ts +1 -0
- package/packages/cli/src/launches.ts +5 -1
- package/packages/cli/src/mcp.ts +28 -8
- package/packages/cli/src/messaging.ts +14 -0
- package/packages/cli/src/team-intake-mcp.ts +26 -2
- package/packages/codex/package.json +1 -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 +677 -0
- package/packages/protocol/src/artifacts.ts +43 -0
- package/packages/protocol/src/credentials.ts +38 -0
- package/packages/protocol/src/index.ts +56 -5
- package/packages/protocol/src/launch.ts +7 -2
- package/packages/protocol/src/markdown.ts +753 -0
- package/packages/protocol/src/media-allowlist.ts +240 -0
- package/packages/protocol/src/media.ts +120 -0
- package/packages/protocol/src/preview-bundle.ts +221 -0
- package/packages/protocol/src/voice-recording.ts +8 -0
- 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/client.ts +17 -3
- package/packages/sdk/src/errors.ts +1 -0
- package/packages/sdk/src/team-intake.ts +8 -0
- package/packages/sdk/src/types.ts +16 -2
- package/packages/spoke-sdk/package.json +1 -1
- package/src/central/agent/delivery.ts +35 -6
- package/src/central/agent/errors.ts +2 -0
- package/src/central/agent/events.ts +11 -2
- package/src/central/agent/launch-engine.ts +65 -4
- package/src/central/agent/launch-routes.ts +8 -0
- package/src/central/agent/launch-spec.ts +18 -1
- package/src/central/agent/launch-store.ts +2 -2
- package/src/central/agent/message-routes.ts +49 -11
- package/src/central/agent/messages.ts +17 -4
- package/src/central/agent/not-after.ts +28 -0
- package/src/central/agent/placement.ts +36 -4
- package/src/central/agent/routes.ts +3 -1
- package/src/central/agent/webhook-routes.ts +21 -9
- package/src/central/agent/webhooks.ts +27 -9
- package/src/central/artifacts/host.ts +3 -1
- package/src/central/artifacts/maintenance.ts +2 -1
- package/src/central/artifacts/preview-bundle.ts +81 -0
- package/src/central/artifacts/previews.ts +94 -0
- package/src/central/artifacts/routes.ts +12 -1
- package/src/central/artifacts/store.ts +198 -8
- package/src/central/config.ts +34 -0
- package/src/central/fetch-budget.ts +59 -0
- package/src/central/fetcher.ts +372 -0
- package/src/central/http.ts +277 -22
- package/src/central/media-remote.ts +248 -0
- package/src/central/media-timeline.ts +317 -0
- package/src/central/media-unfurl.ts +119 -0
- package/src/central/profiles/content.ts +3 -35
- package/src/central/recording/routes.ts +2 -2
- package/src/central/recording/schema.ts +3 -1
- package/src/central/registry.ts +449 -21
- package/src/central/spoke-ws.ts +17 -3
- package/src/central/store.ts +21 -0
- package/src/central/teams/intake-routes.ts +2 -1
- package/src/central/teams/intake.ts +200 -15
- package/src/central/unfurl-parse.ts +313 -0
- package/src/index.ts +68 -8
- package/src/speakable.ts +33 -1
- package/src/spoke/conversation.ts +7 -4
- package/src/spoke/deliver.ts +19 -0
- package/src/spoke/media-bytes.ts +171 -0
- package/src/spoke/media-policy.ts +394 -0
- package/src/spoke/media.ts +189 -0
- package/src/spoke/own-server.ts +30 -0
- package/src/spoke/preview-bundle.ts +538 -0
- package/src/spoke/spoke.ts +387 -17
- package/src/spoke/timeline.ts +59 -5
- package/src/spoke/voice-front.ts +335 -0
- package/web/dist/assets/24BXRY42-Zfr7jm_p.js +2 -0
- package/web/dist/assets/4FHPYW2L-C6Z67098.js +2 -0
- package/web/dist/assets/4LWW3ZNH-CUiYeygH.js +1 -0
- package/web/dist/assets/5THPTBWS-Ce_hS451.js +1 -0
- package/web/dist/assets/5XYWFMKW-CW-BCKiK.js +4 -0
- package/web/dist/assets/64PMEAB3-BjpCDGU9.js +8 -0
- package/web/dist/assets/6BEWHPQE-BpNa96Ze.js +2 -0
- package/web/dist/assets/6PIGVMFT-YXn9Hgnb.js +1 -0
- package/web/dist/assets/75JW6PN6-BFpDBuGN.js +2 -0
- package/web/dist/assets/7WLXJQOR-D6QVYUu9.js +1 -0
- package/web/dist/assets/7XWI72E6-BMuIspEy.js +2 -0
- package/web/dist/assets/A4UD2L5Z-BQzcD9dU.js +8 -0
- package/web/dist/assets/ArtifactPage-4GoBqSpU.js +1 -0
- package/web/dist/assets/ArtifactPane-Dx_0iWwH.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-CuHgARMk.js +6 -0
- package/web/dist/assets/BGW36YJW-CJMhya8S.js +4 -0
- package/web/dist/assets/CGIZI67J-DYDSLME8.js +2 -0
- package/web/dist/assets/DKTLEL5E-DciMwFJ1.js +1 -0
- package/web/dist/assets/DNNCRC2E-BIeKf5t-.js +5 -0
- package/web/dist/assets/Diagram-CZM_R7MG.js +1 -0
- package/web/dist/assets/Diagram-vdpmp3xu.css +1 -0
- package/web/dist/assets/E67EBMDD--AucIw59.js +5 -0
- package/web/dist/assets/EFYWJBJK-CVywe_D5.js +1 -0
- package/web/dist/assets/EXE5EH6O-DR1IHCTr.js +4 -0
- package/web/dist/assets/FTXIJQGB-BZVuiBn1.js +1 -0
- package/web/dist/assets/G3MDBVQH-BZmnMkFw.js +1 -0
- package/web/dist/assets/G6GPDDNC-DEz8F449.js +2 -0
- package/web/dist/assets/G76NGGFM-CrB2V34K.js +1 -0
- package/web/dist/assets/GH6G3TJC-D3RTYEH4.js +2 -0
- package/web/dist/assets/HPNUA7RO-B19NeNOJ.js +1 -0
- package/web/dist/assets/HQRGC5D7-Dmpzq6Rn.js +4 -0
- package/web/dist/assets/IX6Z2TVR-DphRHnek.js +5 -0
- package/web/dist/assets/J77NQRCJ-B7AGcxvw.js +3 -0
- package/web/dist/assets/JAD36DTZ-B9-dbsS0.js +5 -0
- package/web/dist/assets/LF3NJQAX-koL91UZ9.js +2 -0
- package/web/dist/assets/LRZCOFJZ-Cdjasfuf.js +6 -0
- package/web/dist/assets/Library-TwvrwqSk.js +2 -0
- package/web/dist/assets/Lightbox-C8KNKwOA.css +1 -0
- package/web/dist/assets/Lightbox-Du56MXGX.js +1 -0
- package/web/dist/assets/MediaImage-Ds_JUx3a.js +4 -0
- package/web/dist/assets/N5NF2FGY-OuapYTfr.js +2 -0
- package/web/dist/assets/N7OWWQHY-BNLd-Q_U.js +2 -0
- package/web/dist/assets/NT5JY5WP-1lJM-aun.js +1 -0
- package/web/dist/assets/OT7W3RNY-jDL3drzX.js +4 -0
- package/web/dist/assets/P67P66DB-CE-opRb8.js +3 -0
- package/web/dist/assets/PFLLLM7K-DDJFgzW3.js +2 -0
- package/web/dist/assets/PUAMC4MJ-CtBm-WuU.js +12 -0
- package/web/dist/assets/PVFCHRLY-CG5JXv60.js +2 -0
- package/web/dist/assets/ProfilesDestination-rKNDpLjR.js +1 -0
- package/web/dist/assets/QQW2P742-BbcEkpa4.js +1 -0
- package/web/dist/assets/RVARI57O-CgA1WoRv.js +1 -0
- package/web/dist/assets/S63OEDPG-CKlhpvl2.js +3 -0
- package/web/dist/assets/SG5TVYLW-BBtiwXPL.js +2 -0
- package/web/dist/assets/TeamsPage-DtJylLrt.js +1 -0
- package/web/dist/assets/UGRSUGA2-urZ_hx83.js +5 -0
- package/web/dist/assets/V44WA6U7-DYfX3OlM.js +1 -0
- package/web/dist/assets/V67O4WWV-xYxJ00QW.js +1 -0
- package/web/dist/assets/VBUCM3YD-JHGjuJC_.js +1 -0
- package/web/dist/assets/VINGRCPA-DZuJa3SH.js +7 -0
- package/web/dist/assets/WL6DNKX4-BHqripdA.js +2 -0
- package/web/dist/assets/WQ63CJYP-D6G5frDY.js +2 -0
- package/web/dist/assets/Watchtower-Bmgdyfjb.js +2 -0
- package/web/dist/assets/XOYAOB3S-L-KeA1EF.js +13 -0
- package/web/dist/assets/Y5LQLG3M-dr2-erb8.js +2 -0
- package/web/dist/assets/YIK6JTN6--1t-Ud8k.js +2 -0
- package/web/dist/assets/artifacts-DU9uRgs1.js +1 -0
- package/web/dist/assets/index-C3ci5UtY.css +1 -0
- package/web/dist/assets/index-D-xG9nrc.js +36 -0
- package/web/dist/assets/lazy-f3CuUNJZ.js +43 -0
- package/web/dist/assets/live-7q72Lyj2.js +5 -0
- package/web/dist/assets/mermaid.worker-WDVRAh32.js +1 -0
- package/web/dist/assets/pane-layout-0RrLHGDr.js +1 -0
- package/web/dist/assets/preload-helper-BLdHbfdN.js +1 -0
- package/web/dist/assets/{renderer2d-CYag1CnR.js → renderer2d-hfZxM6Xl.js} +1 -1
- package/web/dist/assets/{renderer3d-BRB6rgDJ.js → renderer3d-Km9vipTM.js} +1 -1
- package/web/dist/assets/{safe-area-fOy0AOuG.js → safe-area-BAbgBiFk.js} +1 -1
- package/web/dist/assets/{silero-worker-DhlIHyD_.js → silero-worker-e802lB5T.js} +1 -1
- package/web/dist/assets/smart-turn-v3.2-cpu-WpeLRbPA.onnx +0 -0
- package/web/dist/assets/smart-turn-worker-DaVJPdvJ.js +11 -0
- package/web/dist/assets/source-BWlvaODa.js +1 -0
- package/web/dist/assets/toast-Cuu2K0hx.js +1 -0
- package/web/dist/assets/ui-CFB4wQfF.js +3 -0
- package/web/dist/index.html +8 -6
- package/web/src/voice/turn/LICENSE +28 -0
- package/web/dist/assets/ArtifactPage-Dq7VW-bU.js +0 -1
- package/web/dist/assets/ArtifactPane-DKwbMTX9.js +0 -1
- package/web/dist/assets/ArtifactWorkbench-vrgIfncX.js +0 -6
- package/web/dist/assets/Library-D6-Go6Cu.js +0 -2
- package/web/dist/assets/ProfilesDestination-BOML2Dgf.js +0 -1
- package/web/dist/assets/TeamsPage-CNBTE8gB.js +0 -1
- package/web/dist/assets/Watchtower-Dor1Ubjy.js +0 -2
- package/web/dist/assets/hub-CrbItiwP.js +0 -13
- package/web/dist/assets/index-82Ce0IAf.css +0 -1
- package/web/dist/assets/index-D9SP3_0z.js +0 -22
- package/web/dist/assets/live-BNJgy0vg.js +0 -5
- package/web/dist/assets/pane-layout-EdR1kRrG.js +0 -1
- package/web/dist/assets/preload-helper-DqrJTq0t.js +0 -1
- package/web/dist/assets/source-Wbq35qGp.js +0 -1
- package/web/dist/assets/ui-j5FFk90B.js +0 -3
package/src/spoke/spoke.ts
CHANGED
|
@@ -36,9 +36,11 @@ import { extractReply } from "../reply"
|
|
|
36
36
|
import { copiesHubIdentity, forkIdentityNote, notAUserMessage, opError, REVERT_BUSY, REVERT_END_BUSY, revertPreview, revertSummary, userContent, userIndex, visibleMessages, withStagedAt } from "./fork-revert"
|
|
37
37
|
import { TurnTracker } from "../turns"
|
|
38
38
|
import { TimelineCache, boundedTimelinePart, newestPage, timelineFile, timelineItems } from "./timeline"
|
|
39
|
+
import { MediaCache, timelineMedia, type MediaMessages } from "./media"
|
|
40
|
+
import { BundleCache, previewBundle } from "./preview-bundle"
|
|
39
41
|
import { CodedError, SpokeConnection, type Backoff, type ConnectionState } from "./client"
|
|
40
|
-
import { PROMPT_SOURCE, VOICE_AGENT } from "./conversation"
|
|
41
|
-
import { DeliveryLedger, deferredWhileBusy, deliverMetadata, deliverText, harnessMessageID, InboxOrigins, planDelivery, QUIET_WHILE_BUSY, REVERT_STAGED } from "./deliver"
|
|
42
|
+
import { isHubPrompt, PROMPT_SOURCE, VOICE_AGENT } from "./conversation"
|
|
43
|
+
import { DeliveryLedger, deferredWhileBusy, deliverDescription, deliverMetadata, deliverText, harnessMessageID, InboxOrigins, planDelivery, QUIET_WHILE_BUSY, REVERT_STAGED } from "./deliver"
|
|
42
44
|
import { pollEnrollment, startEnrollment } from "./enroll"
|
|
43
45
|
import { HerdrWatcher, openInHerdr, type HerdrRunner } from "./herdr"
|
|
44
46
|
import type { LocalHubResult } from "./localhub"
|
|
@@ -51,6 +53,7 @@ import { CONCIERGE_AGENT } from "../../packages/protocol/src/concierge.ts"
|
|
|
51
53
|
import { changeStats, contextUsage, filePatch, mcpServers } from "./summary"
|
|
52
54
|
import { browserSessions, eventSessionID, isExposed, isReported, SessionTracker, shouldForward, type EventLike, type SessionDetails } from "./tracker"
|
|
53
55
|
import { renderTaskBoard, TaskBoard } from "./taskboard"
|
|
56
|
+
import { FRONT_TITLE_PREFIX, frontTitle, relayDelivery, trimText, VOICE_FRONT_AGENT, VoiceFronts } from "./voice-front"
|
|
54
57
|
import { watchConfig, type ConfigWatcher } from "./watch"
|
|
55
58
|
|
|
56
59
|
export type SpokeErrorName = "not_connected" | "not_enrolled" | "invalid" | "unreachable"
|
|
@@ -103,9 +106,10 @@ export interface SpokeHost {
|
|
|
103
106
|
/**
|
|
104
107
|
* Admits text as a synthetic user message (OpenCode's session.synthetic): the model sees only `text`; the
|
|
105
108
|
* prompt hook does not run. `id` (msg_…) makes it idempotent; `resume: false` admits without waking.
|
|
109
|
+
* `description`: the one-line notice the TUI shows for it (the model does not see it).
|
|
106
110
|
* Absent: this OpenCode cannot take hub messages (no "deliver" feature).
|
|
107
111
|
*/
|
|
108
|
-
synthetic?(input: { sessionID: string; id: string; text: string; metadata: Record<string, unknown>; delivery: DeliveryMode; resume: boolean }): Promise<{ id?: string }>
|
|
112
|
+
synthetic?(input: { sessionID: string; id: string; text: string; description?: string; metadata: Record<string, unknown>; delivery: DeliveryMode; resume: boolean }): Promise<{ id?: string }>
|
|
109
113
|
context(sessionID: string): Promise<ReadonlyArray<unknown>>
|
|
110
114
|
listModels?(): Promise<ReadonlyArray<unknown>>
|
|
111
115
|
getSession(sessionID: string): Promise<HostSession>
|
|
@@ -155,8 +159,10 @@ export interface SpokeHost {
|
|
|
155
159
|
*/
|
|
156
160
|
/** `signal` aborts when the spoke abandons the listing because it timed out (listingTimeoutMs): stop asking the server. */
|
|
157
161
|
listRecent?(options?: { days: number; max: number; include: readonly string[]; signal?: AbortSignal }): Promise<Array<{ id: string } & SessionDetails>>
|
|
158
|
-
/** Creates a session in a directory (service processes only). */
|
|
162
|
+
/** Creates a session in a directory (the create op: service processes only; voice fronts: any process). */
|
|
159
163
|
createSession?(input: { directory: string; title?: string }): Promise<{ id: string }>
|
|
164
|
+
/** Deletes a session (OpenCode's session.remove); a voice front when its conversation ends. */
|
|
165
|
+
removeSession?(sessionID: string): Promise<unknown>
|
|
160
166
|
/** MCP servers and their status for a directory (raw OpenCode McpServer list). */
|
|
161
167
|
mcpList?(directory: string): Promise<ReadonlyArray<unknown>>
|
|
162
168
|
/** Working-tree changes of a directory (raw OpenCode FileDiffInfo list); undefined when nothing can read that directory. */
|
|
@@ -181,6 +187,11 @@ export interface SpokeHost {
|
|
|
181
187
|
revertClear?(sessionID: string): Promise<unknown>
|
|
182
188
|
/** Makes a staged revert permanent (session.revert.commit). Absent with the two above: no "revert". */
|
|
183
189
|
revertCommit?(sessionID: string): Promise<unknown>
|
|
190
|
+
/**
|
|
191
|
+
* (S4 M2) An item of OpenCode's media store (`media:<sha256>` URIs of tool output), read from the own server;
|
|
192
|
+
* undefined when it has none or cannot be reached. Absent: media: items are not served.
|
|
193
|
+
*/
|
|
194
|
+
fetchMedia?(hash: string): Promise<{ bytes: Uint8Array; mime?: string } | undefined>
|
|
184
195
|
}
|
|
185
196
|
|
|
186
197
|
/** A session's pending forms and permission requests as OpenCode lists them (raw, see SpokeHost.pending). */
|
|
@@ -262,6 +273,8 @@ export interface SpokeOptions {
|
|
|
262
273
|
* spoke never enrols, starts a local hub or reads Herdr.
|
|
263
274
|
*/
|
|
264
275
|
readonly launch?: LaunchConfig
|
|
276
|
+
/** Voice-front wakes within this window are merged into one (default 1.5 s). */
|
|
277
|
+
readonly frontMergeMs?: number
|
|
265
278
|
}
|
|
266
279
|
|
|
267
280
|
export type EnrollmentState = "pending" | "approved" | "denied" | "expired" | "error"
|
|
@@ -362,6 +375,10 @@ export class Spoke {
|
|
|
362
375
|
private readonly turns = new TurnTracker()
|
|
363
376
|
/** Timeline rows of sessions being paged through (30 s, dropped on any event of the session). */
|
|
364
377
|
private readonly timelines = new TimelineCache()
|
|
378
|
+
/** (S4 M2) Decoded timeline images, so later chunks do not read the history again; dropped on any revert. */
|
|
379
|
+
private readonly media = new MediaCache()
|
|
380
|
+
/** (Session media §3 4b) Walked HTML preview bundles, for the chunks of their larger files. */
|
|
381
|
+
private readonly bundles = new BundleCache()
|
|
365
382
|
/** Per session: a counter of revert events (a staged event's late read must not undo a later one). */
|
|
366
383
|
private readonly revertSeq = new Map<string, number>()
|
|
367
384
|
private readonly voiceTurns = new Map<string, number>()
|
|
@@ -385,6 +402,30 @@ export class Spoke {
|
|
|
385
402
|
/** Session → callbacks waiting for its next session.renamed event (autoName). */
|
|
386
403
|
private readonly renameWaiters = new Map<string, Set<(title: string) => void>>()
|
|
387
404
|
private conversationChain: Promise<unknown> = Promise.resolve()
|
|
405
|
+
/** (feature "voiceFront") Main sessions ↔ their hidden voice-front sessions, and what each front knows of its main. */
|
|
406
|
+
readonly fronts: VoiceFronts
|
|
407
|
+
/** Fronts whose deletion failed: forgotten as fronts and never reported again. */
|
|
408
|
+
private readonly retiredFronts = new Set<string>()
|
|
409
|
+
/**
|
|
410
|
+
* Front creations under way, and the sessions they made that are not linked yet: OpenCode announces a new session
|
|
411
|
+
* before create() returns, so until linked, a "Voice: " session (or the made one) is left out of snapshots.
|
|
412
|
+
*/
|
|
413
|
+
private frontsCreating = 0
|
|
414
|
+
/**
|
|
415
|
+
* Main → its front that failed (an execution error): no longer linked or woken, still reported hidden, deleted on
|
|
416
|
+
* off. `frontFallbacks`: mains whose front failed or vanished; the hub talks to them directly, as voice turns.
|
|
417
|
+
*/
|
|
418
|
+
private readonly failedFronts = new Map<string, string>()
|
|
419
|
+
/**
|
|
420
|
+
* "Voice: " sessions this process has not linked, by what a check found (see checkVoiceTitled): "checking" and
|
|
421
|
+
* "hidden" are not reported, "user" is reported as usual, "front" (on the voice-front agent: another process's
|
|
422
|
+
* front, or one an earlier process left) is reported with agent voice-front and no voiceFrontOf: the hub decides
|
|
423
|
+
* whether it is stale and asks for its removal (removeFront). The spoke never deletes one on its own.
|
|
424
|
+
*/
|
|
425
|
+
private readonly voiceTitled = new Map<string, "checking" | "user" | "front" | "hidden">()
|
|
426
|
+
private readonly frontFallbacks = new Set<string>()
|
|
427
|
+
private readonly unlinkedFronts = new Set<string>()
|
|
428
|
+
private frontWakes = 0
|
|
388
429
|
private readonly abort = new AbortController()
|
|
389
430
|
private snapshotTimer?: ReturnType<typeof setTimeout>
|
|
390
431
|
/** Sessions the last snapshot sent listed (the hub keeps status only for sessions it was told about). */
|
|
@@ -446,6 +487,11 @@ export class Spoke {
|
|
|
446
487
|
) {
|
|
447
488
|
this.config = options.launch ? launchVoiceConfig(options.launch, options.machineName) : readConfig(options.configFile, options.machineName)
|
|
448
489
|
this.hub = { pending: false, nextAt: 0, cooldown: options.localHubRetryMs ?? 30_000 }
|
|
490
|
+
this.fronts = new VoiceFronts({
|
|
491
|
+
...(options.frontMergeMs !== undefined ? { mergeMs: options.frontMergeMs } : {}),
|
|
492
|
+
send: (front, text) => void this.wakeFront(front, text),
|
|
493
|
+
frontBusy: (front) => this.running.has(front) || this.tracker.isBusy(front),
|
|
494
|
+
})
|
|
449
495
|
this.contexts = new SessionContexts({
|
|
450
496
|
...(options.conciergeLockFile ? { lockFile: options.conciergeLockFile } : {}),
|
|
451
497
|
...(options.conciergeLockReadFiles?.length ? { readLockFiles: options.conciergeLockReadFiles } : {}),
|
|
@@ -500,16 +546,76 @@ export class Spoke {
|
|
|
500
546
|
this.connectionKey = ""
|
|
501
547
|
this.tokens.clear()
|
|
502
548
|
this.actTokens.clear()
|
|
549
|
+
this.fronts.stop()
|
|
503
550
|
}
|
|
504
551
|
|
|
552
|
+
/** A voice-styled turn: a hub /turn asked for it, or the session's voice front failed (the hub talks to it now). */
|
|
505
553
|
isVoiceTurn(sessionID: string): boolean {
|
|
506
|
-
return (this.voiceTurns.get(sessionID) ?? 0) > 0
|
|
554
|
+
return (this.voiceTurns.get(sessionID) ?? 0) > 0 || this.frontFallbacks.has(sessionID)
|
|
507
555
|
}
|
|
508
556
|
|
|
557
|
+
/** Conversation mode is on: the agent swap (or its context-hook fallback), or a voice front (also a failed one). */
|
|
509
558
|
inConversation(sessionID: string): boolean {
|
|
559
|
+
return this.conversations.has(sessionID) || this.fronts.frontOf(sessionID) !== undefined || this.frontFallbacks.has(sessionID)
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** The session itself is the voice orchestrator (today's agent swap): the context hook adds its parts. */
|
|
563
|
+
orchestrates(sessionID: string): boolean {
|
|
510
564
|
return this.conversations.has(sessionID)
|
|
511
565
|
}
|
|
512
566
|
|
|
567
|
+
/**
|
|
568
|
+
* voice_main_send: relays the user's request from a voice front to its main session, as the user's (hub-sourced)
|
|
569
|
+
* prompt. "auto": idle starts a turn, busy steers; "queue" waits behind main's running turn.
|
|
570
|
+
*/
|
|
571
|
+
async frontSend(callerID: string, input: { text?: unknown; delivery?: unknown }): Promise<{ text: string; inboxID?: string }> {
|
|
572
|
+
const main = this.fronts.mainOf(callerID)
|
|
573
|
+
if (!main) return { text: "This session is not a voice front; nothing was sent." }
|
|
574
|
+
const text = typeof input.text === "string" ? input.text.trim() : ""
|
|
575
|
+
if (!text) return { text: "Pass the request as text; nothing was sent." }
|
|
576
|
+
const busy = this.running.has(main) || this.tracker.isBusy(main)
|
|
577
|
+
const delivery = relayDelivery(input.delivery, busy)
|
|
578
|
+
const inbox = await this.host.prompt({ sessionID: main, text, delivery, metadata: { source: PROMPT_SOURCE, kind: "voice-front" } })
|
|
579
|
+
if (delivery === "queue") {
|
|
580
|
+
this.fronts.queued(main, inbox.id, text)
|
|
581
|
+
return { text: "Queued for the main session; it runs after its current turn.", inboxID: inbox.id }
|
|
582
|
+
}
|
|
583
|
+
return { text: busy ? "Steered into the main session's running turn." : "Sent; the main session started working on it.", inboxID: inbox.id }
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** voice_main_stop: interrupts the front's main session and everything running below it (as the stop op). */
|
|
587
|
+
async frontStop(callerID: string): Promise<{ text: string; stopped: string[] }> {
|
|
588
|
+
const main = this.fronts.mainOf(callerID)
|
|
589
|
+
if (!main) return { text: "This session is not a voice front; nothing was stopped.", stopped: [] }
|
|
590
|
+
this.fronts.stopRequested(main)
|
|
591
|
+
const { stopped } = await this.stopTree(main)
|
|
592
|
+
return { text: "Stopped the main session's work.", stopped: [...stopped] }
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** The voice front's per-turn note about its main session; undefined for any other session. */
|
|
596
|
+
frontContext(sessionID: string): string | undefined {
|
|
597
|
+
const main = this.fronts.mainOf(sessionID)
|
|
598
|
+
if (!main) return undefined
|
|
599
|
+
const title = this.tracker.snapshot(Date.now()).find((s) => s.id === main)?.title
|
|
600
|
+
const workers = this.taskBoard(main)
|
|
601
|
+
return this.fronts.context(sessionID, { ...(title ? { title } : {}), busy: this.running.has(main) || this.tracker.isBusy(main), ...(workers ? { workers } : {}) })
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** A hub update for a front (`<system-update>`): admitted as the hub's (never the user's), steered, waking it. */
|
|
605
|
+
private async wakeFront(front: string, text: string): Promise<void> {
|
|
606
|
+
const metadata = { source: PROMPT_SOURCE, kind: "voice-front-update" }
|
|
607
|
+
try {
|
|
608
|
+
const synthetic = this.host.synthetic?.bind(this.host)
|
|
609
|
+
if (synthetic) {
|
|
610
|
+
const id = harnessMessageID(`voice-front:${front}:${Date.now()}:${++this.frontWakes}`)
|
|
611
|
+
this.origins.hub(id)
|
|
612
|
+
await synthetic({ sessionID: front, id, text, metadata: { ...metadata, messageID: id }, delivery: "steer", resume: true })
|
|
613
|
+
} else await this.host.prompt({ sessionID: front, text, delivery: "steer", metadata })
|
|
614
|
+
} catch (cause) {
|
|
615
|
+
console.error("[hub] could not wake the voice front", errorText(cause))
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
513
619
|
/** The agent the voice orchestrator delegates to (per-machine config). */
|
|
514
620
|
/** The model subagents should work on for a session in conversation mode, as "provider/model[#variant]". */
|
|
515
621
|
workModelFor(sessionID: string): string | undefined {
|
|
@@ -606,6 +712,10 @@ export class Spoke {
|
|
|
606
712
|
...this.openSessions().keys(),
|
|
607
713
|
...this.tracker.active(),
|
|
608
714
|
...this.conversations.keys(),
|
|
715
|
+
...this.fronts.mainIDs(),
|
|
716
|
+
...this.fronts.mainIDs().map((id) => this.fronts.frontOf(id)!),
|
|
717
|
+
...this.frontFallbacks,
|
|
718
|
+
...this.failedFronts.values(),
|
|
609
719
|
...(this.config.mode === "session" ? this.config.sessions : []),
|
|
610
720
|
])
|
|
611
721
|
}
|
|
@@ -651,9 +761,9 @@ export class Spoke {
|
|
|
651
761
|
* orchestrator prompt. Off: restore the previous agent unless the user picked another meanwhile.
|
|
652
762
|
* Calls are serialized so a re-sent "on" cannot race an "off".
|
|
653
763
|
*/
|
|
654
|
-
setConversation(sessionID: string, on: boolean): Promise<ResultFor["conversation"]> {
|
|
764
|
+
setConversation(sessionID: string, on: boolean, front = false, frontID?: string): Promise<ResultFor["conversation"]> {
|
|
655
765
|
if (!isExposed(this.config, this.tracker, sessionID)) return Promise.reject(new SpokeError("invalid", NOT_SHARED))
|
|
656
|
-
const run = () => (on ? this.conversationOn(sessionID) : this.conversationOff(sessionID))
|
|
766
|
+
const run = () => (on ? this.conversationOn(sessionID, front, frontID) : this.conversationOff(sessionID))
|
|
657
767
|
const result = this.conversationChain.then(run, run)
|
|
658
768
|
this.conversationChain = result.catch(() => undefined)
|
|
659
769
|
return result
|
|
@@ -915,7 +1025,8 @@ export class Spoke {
|
|
|
915
1025
|
opencodeVersion: this.host.opencodeVersion,
|
|
916
1026
|
pluginVersion: this.options.pluginVersion,
|
|
917
1027
|
// Hub messages and prompts need OpenCode's synthetic messages.
|
|
918
|
-
// Fork and revert need OpenCode's own server (the plugin API has neither).
|
|
1028
|
+
// Fork and revert need OpenCode's own server (the plugin API has neither). "voiceFront" is always listed: a
|
|
1029
|
+
// process that cannot create the front answers the conversation op without `front` (today's agent swap).
|
|
919
1030
|
features: SPOKE_FEATURES.filter(
|
|
920
1031
|
(f) =>
|
|
921
1032
|
((f !== "deliver" && f !== "hubPrompt") || this.host.synthetic !== undefined) &&
|
|
@@ -1001,16 +1112,39 @@ export class Spoke {
|
|
|
1001
1112
|
}
|
|
1002
1113
|
}
|
|
1003
1114
|
|
|
1004
|
-
private async conversationOn(sessionID: string): Promise<ResultFor["conversation"]> {
|
|
1005
|
-
|
|
1006
|
-
|
|
1115
|
+
private async conversationOn(sessionID: string, front = false, frontID?: string): Promise<ResultFor["conversation"]> {
|
|
1116
|
+
let entry = this.conversations.get(sessionID)
|
|
1117
|
+
let session = await this.host.getSession(sessionID)
|
|
1007
1118
|
// (CON-04) lockAgent: conversation mode is recorded, but the session keeps its agent and model (the Concierge
|
|
1008
|
-
// never escapes its restricted agent, and the context hook adds no orchestrator parts).
|
|
1119
|
+
// never escapes its restricted agent, and the context hook adds no orchestrator parts). It never gets a front.
|
|
1009
1120
|
// A session on the concierge agent is locked too (a spoke that restarted before the hub re-sent the context).
|
|
1010
1121
|
if (this.contexts.locked(sessionID) || session.agent === CONCIERGE_AGENT) {
|
|
1011
1122
|
this.conversations.set(sessionID, {})
|
|
1012
1123
|
return { agent: session.agent ?? (await this.defaultAgent()) }
|
|
1013
1124
|
}
|
|
1125
|
+
// The front failed: the hub talks to main directly and resends on without front (a reconnect). Main keeps its
|
|
1126
|
+
// agent and model; its turns stay voice turns (frontFallbacks) until off.
|
|
1127
|
+
if (!front && this.frontFallbacks.has(sessionID)) return { agent: session.agent ?? (await this.defaultAgent()) }
|
|
1128
|
+
if (front) {
|
|
1129
|
+
const live = this.fronts.frontOf(sessionID)
|
|
1130
|
+
if (live && this.tracker.has(live)) return { agent: session.agent ?? (await this.defaultAgent()), front: { sessionID: live } }
|
|
1131
|
+
// A failed front is replaced (deleted first); the session stops being a fallback.
|
|
1132
|
+
await this.endFront(sessionID)
|
|
1133
|
+
// Moving from the agent swap to a front: main gets its own agent and model back first.
|
|
1134
|
+
if (entry) {
|
|
1135
|
+
await this.conversationOff(sessionID)
|
|
1136
|
+
entry = undefined
|
|
1137
|
+
session = await this.host.getSession(sessionID)
|
|
1138
|
+
}
|
|
1139
|
+
// The hub knows this conversation's front (a resend to a restarted process): re-linked if it still exists.
|
|
1140
|
+
const adopted = frontID ? await this.adoptFront(sessionID, frontID) : undefined
|
|
1141
|
+
if (adopted) return { agent: session.agent ?? (await this.defaultAgent()), front: { sessionID: adopted } }
|
|
1142
|
+
const created = await this.createFront(sessionID, session).catch((cause) => {
|
|
1143
|
+
console.error("[hub] could not start a voice front; using the voice agent", errorText(cause))
|
|
1144
|
+
return undefined
|
|
1145
|
+
})
|
|
1146
|
+
if (created) return { agent: session.agent ?? (await this.defaultAgent()), front: { sessionID: created } }
|
|
1147
|
+
}
|
|
1014
1148
|
this.rememberModel(sessionID, session.model)
|
|
1015
1149
|
const current = session.agent
|
|
1016
1150
|
const models = await this.voiceModelOn(sessionID, session, entry)
|
|
@@ -1044,7 +1178,156 @@ export class Spoke {
|
|
|
1044
1178
|
return { voiceModel, ...(previousModel ? { previousModel } : {}) }
|
|
1045
1179
|
}
|
|
1046
1180
|
|
|
1181
|
+
/**
|
|
1182
|
+
* Creates main's voice front: a root session in main's directory, titled after it, on the voice-front agent and
|
|
1183
|
+
* the voice model, reported with voiceFrontOf. Throws (after removing a half-made front) when it cannot.
|
|
1184
|
+
*/
|
|
1185
|
+
private async createFront(main: string, session: HostSession): Promise<string> {
|
|
1186
|
+
const create = this.host.createSession?.bind(this.host)
|
|
1187
|
+
if (!create) throw new Error("this OpenCode process cannot create sessions")
|
|
1188
|
+
if (!(await this.host.hasAgent(VOICE_FRONT_AGENT))) throw new Error(`no ${VOICE_FRONT_AGENT} agent`)
|
|
1189
|
+
const directory = this.tracker.directoryOf(main) ?? session.location?.directory ?? this.host.directory
|
|
1190
|
+
const title = frontTitle(session.title)
|
|
1191
|
+
this.frontsCreating++
|
|
1192
|
+
let id: string | undefined
|
|
1193
|
+
try {
|
|
1194
|
+
id = (await create({ directory, title })).id
|
|
1195
|
+
this.unlinkedFronts.add(id)
|
|
1196
|
+
try {
|
|
1197
|
+
// Agent first: switching the agent may reset the model.
|
|
1198
|
+
await this.host.switchAgent(id, VOICE_FRONT_AGENT)
|
|
1199
|
+
const voice = this.voiceModelFor(main)
|
|
1200
|
+
if (voice) await this.switchModel(id, voice)
|
|
1201
|
+
} catch (cause) {
|
|
1202
|
+
await this.host.removeSession?.(id).catch(() => undefined)
|
|
1203
|
+
throw cause
|
|
1204
|
+
}
|
|
1205
|
+
this.fronts.link(main, id, await this.lastReply(main))
|
|
1206
|
+
} finally {
|
|
1207
|
+
this.frontsCreating--
|
|
1208
|
+
if (id !== undefined) this.unlinkedFronts.delete(id)
|
|
1209
|
+
}
|
|
1210
|
+
const now = Date.now()
|
|
1211
|
+
const source = this.tracker.snapshot(now).find((s) => s.id === main)
|
|
1212
|
+
this.tracker.observe({ type: "session.created", data: { sessionID: id, title, location: { directory } } }, now)
|
|
1213
|
+
this.tracker.setInfo(id, { title, directory, updated: now, ...(source?.project ? { project: source.project } : {}), ...(source?.projectName ? { projectName: source.projectName } : {}) })
|
|
1214
|
+
this.sendSnapshot()
|
|
1215
|
+
return id
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
/** A session that is (or may be) a front still being created: never reported as an ordinary session. */
|
|
1219
|
+
private frontInMaking(s: { id: string; title?: string }): boolean {
|
|
1220
|
+
if (this.frontMainOf(s.id)) return false
|
|
1221
|
+
return this.unlinkedFronts.has(s.id) || (this.frontsCreating > 0 && (s.title ?? "").startsWith(FRONT_TITLE_PREFIX))
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
/**
|
|
1225
|
+
* A "Voice: " session this process did not link, as checked (checkVoiceTitled): "checking" or "hidden" are held
|
|
1226
|
+
* back from snapshots; "front" and "user" are reported.
|
|
1227
|
+
*/
|
|
1228
|
+
private unlinkedFront(s: { id: string; title?: string }): boolean {
|
|
1229
|
+
if (!(s.title ?? "").startsWith(FRONT_TITLE_PREFIX) || this.frontMainOf(s.id)) return false
|
|
1230
|
+
const state = this.voiceTitled.get(s.id)
|
|
1231
|
+
if (state === undefined) void this.checkVoiceTitled(s.id)
|
|
1232
|
+
return state === undefined || state === "checking" || state === "hidden"
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
/** Whether a session this process did not link is a voice front (checked as "front"); see checkVoiceTitled. */
|
|
1236
|
+
private unlinkedVoiceFront(sessionID: string): boolean {
|
|
1237
|
+
return !this.frontMainOf(sessionID) && this.voiceTitled.get(sessionID) === "front"
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
/**
|
|
1241
|
+
* Classifies an unlinked "Voice: " session. A front runs on the voice-front agent (OpenCode's session record names
|
|
1242
|
+
* it): "front". Another agent or the default: "user". If the record cannot be read, a session without any message
|
|
1243
|
+
* from a human (only the hub's prompts) is "hidden" (not reported, never deleted); one with a human's is "user".
|
|
1244
|
+
*/
|
|
1245
|
+
private async checkVoiceTitled(sessionID: string): Promise<void> {
|
|
1246
|
+
this.voiceTitled.set(sessionID, "checking")
|
|
1247
|
+
let state: "user" | "front" | "hidden"
|
|
1248
|
+
try {
|
|
1249
|
+
state = (await this.host.getSession(sessionID)).agent === VOICE_FRONT_AGENT ? "front" : "user"
|
|
1250
|
+
} catch {
|
|
1251
|
+
state = await this.host
|
|
1252
|
+
.context(sessionID)
|
|
1253
|
+
.then((messages) => (messages.some((m) => (m as { type?: unknown }).type === "user" && !isHubPrompt((m as { metadata?: unknown }).metadata)) ? "user" : "hidden") as "user" | "hidden")
|
|
1254
|
+
.catch(() => "user" as const)
|
|
1255
|
+
}
|
|
1256
|
+
// Linked meanwhile (a front this process is creating): checked again later if it is ever unlinked.
|
|
1257
|
+
if (this.frontMainOf(sessionID) || this.unlinkedFronts.has(sessionID) || this.frontsCreating > 0) return void this.voiceTitled.delete(sessionID)
|
|
1258
|
+
this.voiceTitled.set(sessionID, state)
|
|
1259
|
+
if (state !== "hidden") this.scheduleSnapshot()
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
/**
|
|
1263
|
+
* Re-links an existing front to main (the hub names it on a resend): only a session on the voice-front agent that
|
|
1264
|
+
* this process has not linked to another session. Undefined when it is gone or no front.
|
|
1265
|
+
*/
|
|
1266
|
+
private async adoptFront(main: string, frontID: string): Promise<string | undefined> {
|
|
1267
|
+
if (frontID === main || (this.frontMainOf(frontID) ?? main) !== main) return undefined
|
|
1268
|
+
const info = await this.host.getSession(frontID).catch(() => undefined)
|
|
1269
|
+
if (info?.agent !== VOICE_FRONT_AGENT || (this.frontMainOf(frontID) ?? main) !== main) return undefined
|
|
1270
|
+
this.fronts.link(main, frontID, await this.lastReply(main))
|
|
1271
|
+
this.voiceTitled.delete(frontID)
|
|
1272
|
+
const now = Date.now()
|
|
1273
|
+
if (!this.tracker.has(frontID)) this.tracker.observe({ type: "session.created", data: { sessionID: frontID, title: info.title, location: info.location } }, now)
|
|
1274
|
+
console.warn(`[hub] re-linked the voice front ${frontID} of ${main}`)
|
|
1275
|
+
this.sendSnapshot()
|
|
1276
|
+
return frontID
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
/** Main's latest assistant text, to seed its front's context. */
|
|
1280
|
+
private async lastReply(main: string): Promise<{ lastText?: string }> {
|
|
1281
|
+
const lastText = await this.host
|
|
1282
|
+
.context(main)
|
|
1283
|
+
.then((messages) => contextMessages(messages).filter((m) => m.role === "assistant").at(-1)?.text)
|
|
1284
|
+
.catch(() => undefined)
|
|
1285
|
+
return lastText ? { lastText: trimText(lastText) } : {}
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
/**
|
|
1289
|
+
* removeFront: the hub found a voice front no conversation uses. Deleted only when it is on the voice-front agent
|
|
1290
|
+
* and this process has not linked it (as a front, a failed front, or one being created).
|
|
1291
|
+
*/
|
|
1292
|
+
private async removeFront(sessionID: string): Promise<ResultFor["removeFront"]> {
|
|
1293
|
+
if (this.frontMainOf(sessionID) || this.unlinkedFronts.has(sessionID) || !this.host.removeSession) return { removed: false }
|
|
1294
|
+
const agent = await this.host.getSession(sessionID).then((s) => s.agent, () => undefined)
|
|
1295
|
+
if (agent !== VOICE_FRONT_AGENT || this.frontMainOf(sessionID) || this.unlinkedFronts.has(sessionID)) return { removed: false }
|
|
1296
|
+
console.warn(`[hub] deleting a stale voice front the hub found: ${sessionID}`)
|
|
1297
|
+
await this.host.interrupt(sessionID).catch(() => undefined)
|
|
1298
|
+
await this.host.removeSession(sessionID)
|
|
1299
|
+
return { removed: true }
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
/** The model a session's voice runs on: its voice selection, else the machine's voiceModel; undefined: default. */
|
|
1303
|
+
private voiceModelFor(sessionID: string): ModelRef | undefined {
|
|
1304
|
+
const selected = this.modelPrefs.get(sessionID)?.voice
|
|
1305
|
+
if (selected) return { providerID: selected.providerID, id: selected.modelID, ...(selected.variant ? { variant: selected.variant } : {}) }
|
|
1306
|
+
return this.config.voiceModel ? parseModelRef(this.config.voiceModel) : undefined
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
/**
|
|
1310
|
+
* Ends main's voice front: interrupted, then deleted. If it cannot be deleted it is forgotten as a front and never
|
|
1311
|
+
* reported again (OpenCode has no archive call). Main is untouched.
|
|
1312
|
+
*/
|
|
1313
|
+
private async endFront(main: string): Promise<void> {
|
|
1314
|
+
const front = this.fronts.unlink(main) ?? this.failedFronts.get(main)
|
|
1315
|
+
this.failedFronts.delete(main)
|
|
1316
|
+
this.frontFallbacks.delete(main)
|
|
1317
|
+
if (!front) return
|
|
1318
|
+
await this.host.interrupt(front).catch(() => undefined)
|
|
1319
|
+
try {
|
|
1320
|
+
if (!this.host.removeSession) throw new Error("this OpenCode process cannot delete sessions")
|
|
1321
|
+
await this.host.removeSession(front)
|
|
1322
|
+
} catch (cause) {
|
|
1323
|
+
console.error("[hub] could not delete a voice front; it is no longer reported", errorText(cause))
|
|
1324
|
+
this.retiredFronts.add(front)
|
|
1325
|
+
}
|
|
1326
|
+
if (this.tracker.observe({ type: "session.deleted", data: { sessionID: front } }, Date.now())) this.sendSnapshot()
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1047
1329
|
private async conversationOff(sessionID: string): Promise<ResultFor["conversation"]> {
|
|
1330
|
+
await this.endFront(sessionID)
|
|
1048
1331
|
const entry = this.conversations.get(sessionID)
|
|
1049
1332
|
this.conversations.delete(sessionID)
|
|
1050
1333
|
const session = await this.host.getSession(sessionID)
|
|
@@ -1145,6 +1428,27 @@ export class Spoke {
|
|
|
1145
1428
|
return { detail: boundedTimelinePart(await this.host.context(req.sessionID), req.messageID, req.partID) }
|
|
1146
1429
|
case "timelineFile":
|
|
1147
1430
|
return timelineFile(await this.host.context(req.sessionID), req.messageID, req.index) satisfies ResultFor["timelineFile"]
|
|
1431
|
+
case "timelineMedia":
|
|
1432
|
+
return (await timelineMedia({
|
|
1433
|
+
sessionID: req.sessionID,
|
|
1434
|
+
messageID: req.messageID,
|
|
1435
|
+
ref: req.ref,
|
|
1436
|
+
...(req.offset === undefined ? {} : { offset: req.offset }),
|
|
1437
|
+
cache: this.media,
|
|
1438
|
+
load: () => this.mediaMessages(req.sessionID),
|
|
1439
|
+
directory: this.directoryOf(req.sessionID),
|
|
1440
|
+
...(this.host.fetchMedia ? { fetchMedia: (hash: string) => this.host.fetchMedia!(hash) } : {}),
|
|
1441
|
+
})) satisfies ResultFor["timelineMedia"]
|
|
1442
|
+
case "previewBundle":
|
|
1443
|
+
return (await previewBundle({
|
|
1444
|
+
sessionID: req.sessionID,
|
|
1445
|
+
messageID: req.messageID,
|
|
1446
|
+
path: req.path,
|
|
1447
|
+
...(req.bundle === undefined ? {} : { bundle: req.bundle, file: req.file!, offset: req.offset! }),
|
|
1448
|
+
cache: this.bundles,
|
|
1449
|
+
load: () => this.mediaMessages(req.sessionID),
|
|
1450
|
+
directory: this.directoryOf(req.sessionID),
|
|
1451
|
+
})) satisfies ResultFor["previewBundle"]
|
|
1148
1452
|
case "steer": {
|
|
1149
1453
|
const voice = isExposed(this.config, this.tracker, req.sessionID)
|
|
1150
1454
|
if (voice) await this.applyVoiceModel(req.sessionID)
|
|
@@ -1162,6 +1466,8 @@ export class Spoke {
|
|
|
1162
1466
|
return {}
|
|
1163
1467
|
case "rename":
|
|
1164
1468
|
return this.rename(req.sessionID, req.title)
|
|
1469
|
+
case "removeFront":
|
|
1470
|
+
return this.removeFront(req.sessionID)
|
|
1165
1471
|
case "dismissError":
|
|
1166
1472
|
this.unheardFailures.delete(req.sessionID)
|
|
1167
1473
|
return { dismissed: this.tracker.dismissError(req.sessionID) } satisfies ResultFor["dismissError"]
|
|
@@ -1177,7 +1483,7 @@ export class Spoke {
|
|
|
1177
1483
|
return patch ? { patch } : {}
|
|
1178
1484
|
}
|
|
1179
1485
|
case "conversation":
|
|
1180
|
-
return this.setConversation(req.sessionID, req.on)
|
|
1486
|
+
return this.setConversation(req.sessionID, req.on, req.front === true, req.front === true ? req.frontID : undefined)
|
|
1181
1487
|
case "deliver":
|
|
1182
1488
|
return this.deliver(req)
|
|
1183
1489
|
case "sessionContext": {
|
|
@@ -1302,6 +1608,8 @@ export class Spoke {
|
|
|
1302
1608
|
const staged = await this.host.revertStage!({ sessionID, messageID, files })
|
|
1303
1609
|
const revert = withStagedAt(revertSummary(staged, messages) ?? revertSummary({ messageID }, messages)!, this.tracker.revertOf(sessionID), Date.now())
|
|
1304
1610
|
this.timelines.invalidate(sessionID)
|
|
1611
|
+
this.media.invalidate(sessionID)
|
|
1612
|
+
this.bundles.invalidate(sessionID)
|
|
1305
1613
|
// Sent at once even when OpenCode's staged event already set it (its snapshot may still be debounced).
|
|
1306
1614
|
this.tracker.setRevert(sessionID, revert)
|
|
1307
1615
|
this.sendSnapshot()
|
|
@@ -1311,6 +1619,8 @@ export class Spoke {
|
|
|
1311
1619
|
/** After a restore or commit: no revert is staged; reported at once. */
|
|
1312
1620
|
private revertDone(sessionID: string): Record<string, never> {
|
|
1313
1621
|
this.timelines.invalidate(sessionID)
|
|
1622
|
+
this.media.invalidate(sessionID)
|
|
1623
|
+
this.bundles.invalidate(sessionID)
|
|
1314
1624
|
this.tracker.setRevert(sessionID, undefined)
|
|
1315
1625
|
this.sendSnapshot()
|
|
1316
1626
|
return {}
|
|
@@ -1321,17 +1631,27 @@ export class Spoke {
|
|
|
1321
1631
|
* revert is read from OpenCode's session (so one staged before this process saw it counts) and reported.
|
|
1322
1632
|
*/
|
|
1323
1633
|
private async shownMessages(sessionID: string): Promise<readonly unknown[]> {
|
|
1634
|
+
return (await this.mediaMessages(sessionID)).shown
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
/**
|
|
1638
|
+
* The session's messages, those a timeline shows (shownMessages) and the message of a committed revert (it and
|
|
1639
|
+
* those after it are gone): what timelineMedia checks a message against.
|
|
1640
|
+
*/
|
|
1641
|
+
private async mediaMessages(sessionID: string): Promise<MediaMessages> {
|
|
1642
|
+
const committed = this.tracker.committedOf(sessionID)?.messageID
|
|
1324
1643
|
const [messages, session] = await Promise.all([
|
|
1325
1644
|
this.host.context(sessionID),
|
|
1326
1645
|
this.canRevert() ? this.host.getSession(sessionID).catch(() => undefined) : Promise.resolve(undefined),
|
|
1327
1646
|
])
|
|
1328
|
-
|
|
1647
|
+
const base = { all: messages, ...(committed ? { committed } : {}) }
|
|
1648
|
+
if (!this.canRevert()) return { ...base, shown: messages }
|
|
1329
1649
|
const raw = session ? (session as { revert?: unknown }).revert : undefined
|
|
1330
1650
|
const known = this.tracker.revertOf(sessionID)
|
|
1331
1651
|
const read = session ? revertSummary(raw, messages) : known
|
|
1332
1652
|
const revert = session && read ? withStagedAt(read, known, Date.now()) : read
|
|
1333
1653
|
if (session && this.tracker.setRevert(sessionID, revert)) this.scheduleSnapshot()
|
|
1334
|
-
return visibleMessages(messages, revert?.messageID)
|
|
1654
|
+
return { ...base, shown: visibleMessages(messages, revert?.messageID) }
|
|
1335
1655
|
}
|
|
1336
1656
|
|
|
1337
1657
|
/**
|
|
@@ -1354,6 +1674,8 @@ export class Spoke {
|
|
|
1354
1674
|
* late read only fills in the commit's window start.
|
|
1355
1675
|
*/
|
|
1356
1676
|
private async observeRevert(event: EventLike, sessionID: string): Promise<void> {
|
|
1677
|
+
this.media.invalidate(sessionID)
|
|
1678
|
+
this.bundles.invalidate(sessionID)
|
|
1357
1679
|
if (!this.tracker.has(sessionID)) return
|
|
1358
1680
|
const seq = (this.revertSeq.get(sessionID) ?? 0) + 1
|
|
1359
1681
|
this.revertSeq.set(sessionID, seq)
|
|
@@ -1398,7 +1720,7 @@ export class Spoke {
|
|
|
1398
1720
|
const id = harnessMessageID(req.messageID)
|
|
1399
1721
|
// Marked before sending: OpenCode may report it delivered before the call returns.
|
|
1400
1722
|
this.origins.hub(id)
|
|
1401
|
-
const admitted = await synthetic({ sessionID: req.sessionID, id, text: deliverText(req), metadata: deliverMetadata(req), delivery: plan.delivery, resume: plan.resume })
|
|
1723
|
+
const admitted = await synthetic({ sessionID: req.sessionID, id, text: deliverText(req), description: deliverDescription(req), metadata: deliverMetadata(req), delivery: plan.delivery, resume: plan.resume })
|
|
1402
1724
|
this.timelines.invalidate(req.sessionID)
|
|
1403
1725
|
return { result: { delivered: plan.delivered, inboxID: typeof admitted?.id === "string" && admitted.id ? admitted.id : id }, resumed: plan.resume }
|
|
1404
1726
|
})
|
|
@@ -1605,6 +1927,8 @@ export class Spoke {
|
|
|
1605
1927
|
private async applyVoiceModel(sessionID: string): Promise<void> {
|
|
1606
1928
|
const selected = this.modelPrefs.get(sessionID)?.voice
|
|
1607
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
|
|
1608
1932
|
if ((!selected && !override) || this.tracker.active().includes(this.tracker.rootOf(sessionID))) return
|
|
1609
1933
|
const current = await this.currentModel(sessionID)
|
|
1610
1934
|
const onOverride = override !== undefined && current !== undefined && sameModel(current, override.applied)
|
|
@@ -1798,6 +2122,8 @@ export class Spoke {
|
|
|
1798
2122
|
this.tokens.delete(sessionID)
|
|
1799
2123
|
this.actTokens.delete(sessionID)
|
|
1800
2124
|
this.profiles.forget(sessionID)
|
|
2125
|
+
this.media.invalidate(sessionID)
|
|
2126
|
+
this.bundles.invalidate(sessionID)
|
|
1801
2127
|
}
|
|
1802
2128
|
if (event.type === "form.replied" || event.type === "form.cancelled") {
|
|
1803
2129
|
const formID = (event.data as Record<string, unknown> | undefined)?.id
|
|
@@ -1813,6 +2139,7 @@ export class Spoke {
|
|
|
1813
2139
|
}
|
|
1814
2140
|
this.trackAsk(event)
|
|
1815
2141
|
if (this.tracker.observe(event, Date.now())) this.scheduleSnapshot()
|
|
2142
|
+
this.observeFronts(event, sessionID)
|
|
1816
2143
|
if (REVERT_EVENTS.has(event.type) && typeof sessionID === "string") void this.observeRevert(event, sessionID)
|
|
1817
2144
|
if (typeof sessionID === "string") this.profiles.settle(sessionID) // a turn boundary makes a staged profile effective
|
|
1818
2145
|
if (this.host.listRecent && LISTING_EVENTS.has(event.type) && !(event.data as any)?.parentID) {
|
|
@@ -1825,6 +2152,42 @@ export class Spoke {
|
|
|
1825
2152
|
}
|
|
1826
2153
|
}
|
|
1827
2154
|
|
|
2155
|
+
/**
|
|
2156
|
+
* Feeds the voice fronts. A deleted main ends its front. A front that fails (as the hub sees it: front_failed) or is
|
|
2157
|
+
* deleted is unlinked, never woken again, and its main falls back to direct voice turns until off.
|
|
2158
|
+
*/
|
|
2159
|
+
private observeFronts(event: EventLike, sessionID: unknown): void {
|
|
2160
|
+
this.fronts.observe(event, (id) => this.tracker.rootOf(id))
|
|
2161
|
+
if (typeof sessionID !== "string") return
|
|
2162
|
+
if (event.type === "session.execution.failed") {
|
|
2163
|
+
const main = this.fronts.mainOf(sessionID)
|
|
2164
|
+
if (!main) return
|
|
2165
|
+
this.fronts.unlink(main)
|
|
2166
|
+
this.failedFronts.set(main, sessionID)
|
|
2167
|
+
this.frontFallbacks.add(main)
|
|
2168
|
+
console.warn(`[hub] voice front of ${main} failed; the session talks directly until voice ends`)
|
|
2169
|
+
return
|
|
2170
|
+
}
|
|
2171
|
+
if (event.type !== "session.deleted") return
|
|
2172
|
+
if (this.fronts.frontOf(sessionID) || this.failedFronts.has(sessionID)) void this.endFront(sessionID)
|
|
2173
|
+
const main = this.fronts.mainOf(sessionID)
|
|
2174
|
+
if (main) {
|
|
2175
|
+
this.fronts.unlink(main)
|
|
2176
|
+
this.frontFallbacks.add(main)
|
|
2177
|
+
}
|
|
2178
|
+
for (const [mainID, front] of this.failedFronts) if (front === sessionID) this.failedFronts.delete(mainID)
|
|
2179
|
+
this.retiredFronts.delete(sessionID)
|
|
2180
|
+
this.voiceTitled.delete(sessionID)
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
/** The main session of a front, also of a failed one (still reported hidden until deleted). */
|
|
2184
|
+
private frontMainOf(sessionID: string): string | undefined {
|
|
2185
|
+
const main = this.fronts.mainOf(sessionID)
|
|
2186
|
+
if (main) return main
|
|
2187
|
+
for (const [mainID, front] of this.failedFronts) if (front === sessionID) return mainID
|
|
2188
|
+
return undefined
|
|
2189
|
+
}
|
|
2190
|
+
|
|
1828
2191
|
/** Sends an OpenCode event to the hub (compact), when connected and the hub may see its session. */
|
|
1829
2192
|
private forward(event: EventLike): void {
|
|
1830
2193
|
if (this.connection?.state !== "connected" || (!FORWARDED_EVENTS.has(event.type) && !COMPACTED_EVENTS.has(event.type))) return
|
|
@@ -2108,7 +2471,14 @@ export class Spoke {
|
|
|
2108
2471
|
if (this.snapshotTimer) clearTimeout(this.snapshotTimer)
|
|
2109
2472
|
this.snapshotTimer = undefined
|
|
2110
2473
|
const tui = this.presenceBook.open(Date.now())
|
|
2111
|
-
const sessions = browserSessions(this.config, this.tracker, Date.now(), { terminal: (id) => this.terminal(id, tui) })
|
|
2474
|
+
const sessions = browserSessions(this.config, this.tracker, Date.now(), { terminal: (id) => this.terminal(id, tui) })
|
|
2475
|
+
.filter((s) => !this.retiredFronts.has(s.id) && !this.frontInMaking(s) && !this.unlinkedFront(s))
|
|
2476
|
+
.map((s) => {
|
|
2477
|
+
// A voice front is reported with its main (the hub routes through it and never lists it), always exposed.
|
|
2478
|
+
const main = this.frontMainOf(s.id)
|
|
2479
|
+
const front = main ? { voiceFrontOf: main, exposed: true, agent: VOICE_FRONT_AGENT } : this.unlinkedVoiceFront(s.id) ? { agent: VOICE_FRONT_AGENT } : {}
|
|
2480
|
+
return { ...s, ...this.contexts.report(s.id), ...front }
|
|
2481
|
+
})
|
|
2112
2482
|
this.connection?.send({ t: "sessions", sessions })
|
|
2113
2483
|
this.announced = new Set(sessions.map((s) => s.id))
|
|
2114
2484
|
this.resendUnheardFailures()
|