@huynle/hub 0.8.7 → 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 +129 -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/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/central/registry.ts
CHANGED
|
@@ -14,6 +14,7 @@ import path from "node:path"
|
|
|
14
14
|
import { HARNESS_OPENCODE, helloHarness, spokeFeatures, type EvidenceLevel, type ForwardedEvent, type Hello, type HerdrWorkspace, type ProcessKind, type SessionInfo, type RevertCommitted, type SessionRevertInfo, type SpokeFeature, type SpokeMode, type TerminalInfo, type Terminals } from "../protocol"
|
|
15
15
|
import { BLOCKER_ID_MAX, StatusBoard, askBlocker, errorTypeLabel, permissionRequest, providerFailureNeedsUser, providerRetryNeedsUser, type Blocker, type BlockerRecommendation, type PermissionRequest, type SessionStatus } from "../status"
|
|
16
16
|
import { sanitizeLabel } from "./agent/envelope"
|
|
17
|
+
import { redactCredentials } from "../../packages/protocol/src/credentials.ts"
|
|
17
18
|
import { DoneWatch } from "./done"
|
|
18
19
|
import { SentenceStream } from "./say-stream"
|
|
19
20
|
import type { ActivityStep, NotificationKind } from "./push"
|
|
@@ -82,7 +83,11 @@ export type RegistryMessage =
|
|
|
82
83
|
| { readonly type: "delta"; readonly sessionID: string; readonly rootID: string; readonly messageID?: string; readonly ordinal?: number; readonly text: string }
|
|
83
84
|
/** Reasoning text streamed in a root or subagent session. */
|
|
84
85
|
| { readonly type: "reasoning"; readonly sessionID: string; readonly rootID: string; readonly messageID?: string; readonly delta: string }
|
|
85
|
-
|
|
86
|
+
/**
|
|
87
|
+
* Conversation mode changed. `front` (voice front P1): present once the voice front was asked for: whether a front
|
|
88
|
+
* session is in front of this one now; `reason` "front_failed": it failed and utterances go to the session itself.
|
|
89
|
+
*/
|
|
90
|
+
| { readonly type: "conversation"; readonly sessionID: string; readonly on: boolean; readonly front?: boolean; readonly reason?: "front_failed" }
|
|
86
91
|
/**
|
|
87
92
|
* Assistant text of a root session in conversation mode, to be spoken. Root text is spoken while it streams:
|
|
88
93
|
* one say per completed sentence, `seq` counting the sentences of the message's text part (`ordinal`) from 0.
|
|
@@ -431,6 +436,60 @@ const eventSessionID = (event: ForwardedEvent): string | undefined => {
|
|
|
431
436
|
return typeof id === "string" ? id : undefined
|
|
432
437
|
}
|
|
433
438
|
|
|
439
|
+
/** (voice front P1) A front session ID as a spoke answers it (bounded). */
|
|
440
|
+
const FRONT_ID_MAX = 300
|
|
441
|
+
/** (voice front P1) A voice-front session no conversation maps for this long is deleted (through its reporter). */
|
|
442
|
+
export const FRONT_STALE_MS = 60_000
|
|
443
|
+
/** (voice front P1) A mapped front that no process reports (and none links) for this long has failed. */
|
|
444
|
+
export const FRONT_GONE_MS = 10_000
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* (voice front P1) What the hub asks a spoke to do about fronts: re-link a conversation's known front through the
|
|
448
|
+
* process that owns main now (its linking process went), or delete a stale front (removeFront).
|
|
449
|
+
*/
|
|
450
|
+
export type FrontAction =
|
|
451
|
+
| { readonly kind: "adopt"; readonly mainID: string; readonly connID: string; readonly frontID: string }
|
|
452
|
+
| { readonly kind: "remove"; readonly connID: string; readonly sessionID: string }
|
|
453
|
+
|
|
454
|
+
/** (voice front P1) The front in front of a main session: who created it, and whether a sessions frame listed it yet. */
|
|
455
|
+
interface Front {
|
|
456
|
+
readonly frontID: string
|
|
457
|
+
/** The connection that answered the conversation op, then the last one that reported the front. */
|
|
458
|
+
connID: string
|
|
459
|
+
/** Reported in a sessions frame: from then on, a frame of `connID` without it means it is gone. */
|
|
460
|
+
seen: boolean
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** (voice front P1) What a permission lets the session do, as spoken: "run a command", "edit files", "use webfetch". */
|
|
464
|
+
const PERMISSION_PHRASES: Record<string, string> = {
|
|
465
|
+
shell: "run a command",
|
|
466
|
+
bash: "run a command",
|
|
467
|
+
edit: "edit files",
|
|
468
|
+
write: "edit files",
|
|
469
|
+
patch: "edit files",
|
|
470
|
+
read: "read files",
|
|
471
|
+
webfetch: "fetch a web page",
|
|
472
|
+
websearch: "search the web",
|
|
473
|
+
external_directory: "work outside its folder",
|
|
474
|
+
task: "start a worker",
|
|
475
|
+
subagent: "start a worker",
|
|
476
|
+
}
|
|
477
|
+
function permissionPhrase(action: string | undefined): string {
|
|
478
|
+
if (!action) return "go on"
|
|
479
|
+
return PERMISSION_PHRASES[action] ?? `use ${action.replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim().slice(0, 40)}`
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** A question's title as spoken: one line, short, ending with its punctuation (or a period). */
|
|
483
|
+
function spokenTitle(title: string | undefined): string {
|
|
484
|
+
const t = (title ?? "").replace(/\s+/g, " ").trim()
|
|
485
|
+
if (!t || t === "question") return ""
|
|
486
|
+
const short = t.length > ANNOUNCE_REASON_MAX ? `${t.slice(0, ANNOUNCE_REASON_MAX - 1).trimEnd()}…` : t
|
|
487
|
+
return /[.?!…]$/.test(short) ? short : `${short}.`
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** (voice front P1) A voice front as a spoke reports it: with voiceFrontOf, or on the voice-front agent (an unlinked one). */
|
|
491
|
+
const isFrontInfo = (s: SessionInfo) => s.voiceFrontOf !== undefined || s.agent === "voice-front"
|
|
492
|
+
|
|
434
493
|
/** Older spokes reported exposed sessions only, without the field. */
|
|
435
494
|
const isOn = (s: SessionInfo) => s.exposed !== false
|
|
436
495
|
|
|
@@ -468,6 +527,23 @@ export class Registry {
|
|
|
468
527
|
private readonly providerAlerted = new Set<string>()
|
|
469
528
|
/** Sessions in conversation mode → the connection last told so (re-sent when another one takes over). */
|
|
470
529
|
private readonly conversations = new Map<string, string | undefined>()
|
|
530
|
+
/** (voice front P1) Sessions whose conversation asked for a voice front (a reconnect resend asks again). */
|
|
531
|
+
private readonly frontWanted = new Set<string>()
|
|
532
|
+
/** (voice front P1) Main session → its active front; front → main (the front's events speak as main). */
|
|
533
|
+
private readonly fronts = new Map<string, Front>()
|
|
534
|
+
private readonly frontMain = new Map<string, string>()
|
|
535
|
+
/** (voice front P1) Fronts that failed: never mapped again (a later report or resend answer naming one is ignored). */
|
|
536
|
+
private readonly failedFronts = new Set<string>()
|
|
537
|
+
/**
|
|
538
|
+
* (voice front P1) Main sessions whose front failed: until the user turns the conversation off or on again, no
|
|
539
|
+
* front is asked for (reconnect resends) or mapped (sessions frames); the session talks directly.
|
|
540
|
+
*/
|
|
541
|
+
private readonly frontFellBack = new Set<string>()
|
|
542
|
+
/** (voice front P1) Unmapped voice-front sessions → when first reported so (removed after FRONT_STALE_MS). */
|
|
543
|
+
private readonly staleFronts = new Map<string, number>()
|
|
544
|
+
private readonly frontActionListeners = new Set<(action: FrontAction) => void>()
|
|
545
|
+
/** (voice front P1) Connection → front ops under way on it, and the sessions it reported when the first began. */
|
|
546
|
+
private readonly frontOps = new Map<string, { count: number; readonly known: ReadonlySet<string>; held: boolean }>()
|
|
471
547
|
/** `messageID ordinal` of texts already sent as say events (several processes forward the same text). */
|
|
472
548
|
private readonly spoken = new Map<string, true>()
|
|
473
549
|
/** `messageID ordinal` → a root text part being spoken sentence by sentence as it streams. */
|
|
@@ -582,12 +658,55 @@ export class Registry {
|
|
|
582
658
|
|
|
583
659
|
/** Sessions `conn` reports that belong to its tenant. */
|
|
584
660
|
private visible(conn: Conn): SessionInfo[] {
|
|
585
|
-
return [...conn.sessions.values()].filter((s) => this.reports(conn, s.id))
|
|
661
|
+
return [...conn.sessions.values()].filter((s) => !isFrontInfo(s) && !this.frontMain.has(s.id) && !this.heldFront(conn.id, s) && this.reports(conn, s.id))
|
|
586
662
|
}
|
|
587
663
|
|
|
588
|
-
/**
|
|
664
|
+
/**
|
|
665
|
+
* (voice front P1) While a front op runs on a connection, a "Voice: " session it newly reports may be the front
|
|
666
|
+
* before the spoke linked it (an older spoke snapshots OpenCode's session.created first): held back until it answers.
|
|
667
|
+
*/
|
|
668
|
+
private heldFront(connID: string, s: SessionInfo): boolean {
|
|
669
|
+
const op = this.frontOps.get(connID)
|
|
670
|
+
if (!op || op.known.has(s.id) || s.voiceFrontOf !== undefined || !(s.title ?? "").startsWith("Voice: ")) return false
|
|
671
|
+
op.held = true
|
|
672
|
+
return true
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/** (voice front P1) A conversation op asking `connID` for a front starts: see heldFront. */
|
|
676
|
+
frontOpStarted(connID: string): void {
|
|
677
|
+
const op = this.frontOps.get(connID)
|
|
678
|
+
if (op) op.count++
|
|
679
|
+
else this.frontOps.set(connID, { count: 1, known: new Set(this.conns.get(connID)?.sessions.keys() ?? []), held: false })
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/** (voice front P1) That op answered (or failed): what it held back is listed again, unless it is the front now. */
|
|
683
|
+
frontOpEnded(connID: string): void {
|
|
684
|
+
const op = this.frontOps.get(connID)
|
|
685
|
+
if (!op || --op.count > 0) return
|
|
686
|
+
this.frontOps.delete(connID)
|
|
687
|
+
if (op.held) this.emit({ type: "catalog" })
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* True when the tenant owns the session (reported by one of its processes, or a subagent of such a session). A
|
|
692
|
+
* hidden voice front belongs to nobody here: no route, list or agent reaches it.
|
|
693
|
+
*/
|
|
589
694
|
owns(tenant: string, sessionID: string): boolean {
|
|
590
|
-
return this.sessionTenant(sessionID) === tenant
|
|
695
|
+
return this.sessionTenant(sessionID) === tenant && !this.hidden(sessionID)
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* (voice front P1) A hidden voice-front session: one its tenant's processes report with `voiceFrontOf`, or the
|
|
700
|
+
* active front of a main session (before any sessions frame lists it). Never listed, boarded, pushed or named.
|
|
701
|
+
*/
|
|
702
|
+
hidden(sessionID: string): boolean {
|
|
703
|
+
if (this.frontMain.has(sessionID)) return true
|
|
704
|
+
for (const conn of this.all()) {
|
|
705
|
+
const s = conn.sessions.get(sessionID)
|
|
706
|
+
if (s && isFrontInfo(s) && this.tenant(conn) === this.sessionTenant(sessionID)) return true
|
|
707
|
+
if (s && this.heldFront(conn.id, s)) return true
|
|
708
|
+
}
|
|
709
|
+
return false
|
|
591
710
|
}
|
|
592
711
|
|
|
593
712
|
/** Whether the connection's process belongs to the tenant. */
|
|
@@ -681,10 +800,27 @@ export class Registry {
|
|
|
681
800
|
this.observeBoards({ type: "form.cancelled", data: { sessionID: origin.sessionID, id: formID } })
|
|
682
801
|
}
|
|
683
802
|
this.syncBoards()
|
|
803
|
+
this.frontLinkerGone(connID)
|
|
684
804
|
this.changed(this.tenant(conn))
|
|
685
805
|
this.emit({ type: "catalog" })
|
|
686
806
|
}
|
|
687
807
|
|
|
808
|
+
/**
|
|
809
|
+
* (voice front P1) The process that linked fronts went: each one's main owner now (another process on the same
|
|
810
|
+
* data) re-links the same front; with nobody to, or if it is gone, the conversation fails over after FRONT_GONE_MS.
|
|
811
|
+
*/
|
|
812
|
+
private frontLinkerGone(connID: string): void {
|
|
813
|
+
for (const [mainID, front] of this.fronts) {
|
|
814
|
+
if (front.connID !== connID || !this.conversations.has(mainID)) continue
|
|
815
|
+
const target = this.owner(mainID)
|
|
816
|
+
if (target !== undefined && this.frontWanted.has(mainID) && !this.frontFellBack.has(mainID) && this.supports(target, "voiceFront")) {
|
|
817
|
+
this.conversations.set(mainID, target)
|
|
818
|
+
this.frontAction({ kind: "adopt", mainID, connID: target, frontID: front.frontID })
|
|
819
|
+
}
|
|
820
|
+
this.watchFrontGone(mainID)
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
688
824
|
touch(connID: string): void {
|
|
689
825
|
const conn = this.conns.get(connID)
|
|
690
826
|
if (conn) conn.lastSeen = this.now()
|
|
@@ -702,6 +838,7 @@ export class Registry {
|
|
|
702
838
|
conn.sessionsKey = key
|
|
703
839
|
conn.sessions = new Map(sessions.map((s) => [s.id, s]))
|
|
704
840
|
this.syncBoards()
|
|
841
|
+
this.syncFronts(conn)
|
|
705
842
|
this.changed(this.tenant(conn))
|
|
706
843
|
this.emit({ type: "catalog" })
|
|
707
844
|
}
|
|
@@ -740,6 +877,8 @@ export class Registry {
|
|
|
740
877
|
const owner = this.sessionTenant(id)
|
|
741
878
|
if (owner !== undefined && owner !== tenant) return
|
|
742
879
|
}
|
|
880
|
+
// (voice front P1) A hidden front's events only speak, hear and record as its main session.
|
|
881
|
+
if (sessionID && this.hidden(sessionID)) return this.observeFront(connID, tenant, sessionID, event.type, data)
|
|
743
882
|
// A question or permission whose ID no card can carry is never kept (no card, no answer; unbounded memory).
|
|
744
883
|
if (oversizedBlockerID(event.type, data)) return
|
|
745
884
|
if (sessionID) conn.activity.set(sessionID, this.tick())
|
|
@@ -799,7 +938,8 @@ export class Registry {
|
|
|
799
938
|
}
|
|
800
939
|
|
|
801
940
|
if (sessionID) this.trackRun(event.type, sessionID)
|
|
802
|
-
|
|
941
|
+
// While a front is active, the recording follows the front (tapped as main): main's own turn is not tapped.
|
|
942
|
+
if (sessionID && this.recordingTap && TAPPED.has(event.type) && !this.fronts.has(sessionID)) this.tap(connID, tenant, sessionID, event.type, data)
|
|
803
943
|
const before = sessionID ? this.status(sessionID)?.find((s) => s.id === sessionID) : undefined
|
|
804
944
|
this.observeBoards(event, tenant)
|
|
805
945
|
if (sessionID) this.activitySteps(event.type, sessionID, data, before)
|
|
@@ -913,6 +1053,9 @@ export class Registry {
|
|
|
913
1053
|
const resend: string[] = []
|
|
914
1054
|
for (const [sessionID, told] of this.conversations) {
|
|
915
1055
|
if (told === connID || !this.reports(conn, sessionID) || this.owner(sessionID) !== connID) continue
|
|
1056
|
+
// (voice front P1) The process that links the front is still here: it keeps serving the conversation.
|
|
1057
|
+
const front = this.fronts.get(sessionID)
|
|
1058
|
+
if (front && this.linker(front) !== undefined && front.connID !== connID) continue
|
|
916
1059
|
this.conversations.set(sessionID, connID)
|
|
917
1060
|
resend.push(sessionID)
|
|
918
1061
|
}
|
|
@@ -928,8 +1071,8 @@ export class Registry {
|
|
|
928
1071
|
}
|
|
929
1072
|
|
|
930
1073
|
/** Root text streaming in conversation mode: each sentence is spoken as soon as it is complete. */
|
|
931
|
-
private speakDelta(connID: string, sessionID: string, data: Record<string, unknown
|
|
932
|
-
if (
|
|
1074
|
+
private speakDelta(connID: string, sessionID: string, data: Record<string, unknown>, fromFront = false): void {
|
|
1075
|
+
if (!this.speaks(sessionID, fromFront)) return
|
|
933
1076
|
const { assistantMessageID: messageID, ordinal, delta } = data
|
|
934
1077
|
if (typeof messageID !== "string" || typeof ordinal !== "number" || typeof delta !== "string") return
|
|
935
1078
|
const key = `${messageID} ${ordinal}`
|
|
@@ -945,10 +1088,9 @@ export class Registry {
|
|
|
945
1088
|
}
|
|
946
1089
|
|
|
947
1090
|
/** A root text part ended: speaks what the deltas did not (the ended text is authoritative), once. */
|
|
948
|
-
private say(sessionID: string, data: Record<string, unknown
|
|
949
|
-
if (this.
|
|
1091
|
+
private say(sessionID: string, data: Record<string, unknown>, fromFront = false): void {
|
|
1092
|
+
if (!this.speaks(sessionID, fromFront)) return
|
|
950
1093
|
const { assistantMessageID: messageID, ordinal, text } = data
|
|
951
|
-
if (!this.conversations.has(sessionID) || !this.boards.has(sessionID)) return
|
|
952
1094
|
if (typeof messageID !== "string" || typeof ordinal !== "number" || typeof text !== "string") return
|
|
953
1095
|
const key = `${messageID} ${ordinal}`
|
|
954
1096
|
if (this.spoken.has(key)) return
|
|
@@ -961,6 +1103,15 @@ export class Registry {
|
|
|
961
1103
|
if (text.trim() && !this.watched(sessionID)) this.alert("say", sessionID, { text })
|
|
962
1104
|
}
|
|
963
1105
|
|
|
1106
|
+
/**
|
|
1107
|
+
* Whether root text of `sessionID` is spoken: a root session in conversation mode. While a voice front is active,
|
|
1108
|
+
* only the front's text is (fromFront, spoken as main); main's own is not, the front reports it.
|
|
1109
|
+
*/
|
|
1110
|
+
private speaks(sessionID: string, fromFront: boolean): boolean {
|
|
1111
|
+
if (this.rootOf(sessionID) !== sessionID || !this.conversations.has(sessionID) || !this.boards.has(sessionID)) return false
|
|
1112
|
+
return fromFront === this.fronts.has(sessionID)
|
|
1113
|
+
}
|
|
1114
|
+
|
|
964
1115
|
private emitSentence(state: RootSpeech, messageID: string, ordinal: number, text: string): void {
|
|
965
1116
|
this.heardFrom(state.sessionID, text)
|
|
966
1117
|
const seq = state.seq++
|
|
@@ -1033,18 +1184,25 @@ export class Registry {
|
|
|
1033
1184
|
return rootID && rootID !== sessionID && this.conversations.has(rootID) && this.boards.has(rootID) ? rootID : undefined
|
|
1034
1185
|
}
|
|
1035
1186
|
|
|
1036
|
-
/**
|
|
1187
|
+
/**
|
|
1188
|
+
* A worker that fails, needs a permission or asks a question is announced once ("Deploy check failed: …"). While a
|
|
1189
|
+
* voice front is active, main's own failure, permission or question is too ("The session failed: …"): main is the
|
|
1190
|
+
* one doing the work then.
|
|
1191
|
+
*/
|
|
1037
1192
|
private observeWorkerBlockers(type: string, sessionID: string, data: Record<string, unknown>): void {
|
|
1038
1193
|
if (type !== "session.execution.failed" && type !== "permission.asked" && type !== "form.created") return
|
|
1039
|
-
const
|
|
1194
|
+
const own = this.fronts.has(sessionID) && this.speaks(sessionID, true)
|
|
1195
|
+
if (own && type !== "session.execution.failed") return this.announceOwnAsk(type, sessionID, data)
|
|
1196
|
+
const rootID = own ? sessionID : this.conversationRootOf(sessionID)
|
|
1040
1197
|
if (!rootID) return
|
|
1041
|
-
const title = this.workerTitle(rootID, sessionID)
|
|
1198
|
+
const title = own ? "The session" : this.workerTitle(rootID, sessionID)
|
|
1042
1199
|
if (type === "session.execution.failed") {
|
|
1043
1200
|
const at = this.announcedFailures.get(sessionID)
|
|
1044
1201
|
if (at !== undefined && this.now() - at <= FAILURE_DEDUPE_MS) return
|
|
1045
1202
|
remember(this.announcedFailures, sessionID, this.now())
|
|
1046
1203
|
const message = (data.error as { message?: unknown } | undefined)?.message
|
|
1047
|
-
|
|
1204
|
+
// Spoken (and pushed): never a credential the error text carries (the recordings' scrubber).
|
|
1205
|
+
const reason = redactCredentials(typeof message === "string" ? message : "").text.replace(/\s+/g, " ").trim().replace(/[.!]+$/, "").slice(0, ANNOUNCE_REASON_MAX)
|
|
1048
1206
|
return this.announce(rootID, `announce:failed:${sessionID}`, reason ? `${title} failed: ${reason}.` : `${title} failed.`, true)
|
|
1049
1207
|
}
|
|
1050
1208
|
const id = type === "permission.asked" ? data.id : (data.form as { id?: unknown } | undefined)?.id
|
|
@@ -1057,6 +1215,28 @@ export class Registry {
|
|
|
1057
1215
|
else this.announce(rootID, `announce:form:${id}`, `${title} has a question.`, false)
|
|
1058
1216
|
}
|
|
1059
1217
|
|
|
1218
|
+
/**
|
|
1219
|
+
* (voice front P1) Main's own permission or question while its front is active, once per request: "The session
|
|
1220
|
+
* needs your permission to run a command." / "The session has a question: Which branch?" (no command line, as push).
|
|
1221
|
+
*/
|
|
1222
|
+
private announceOwnAsk(type: string, sessionID: string, data: Record<string, unknown>): void {
|
|
1223
|
+
const blocker = askBlocker({ type, data })
|
|
1224
|
+
if (!blocker) return
|
|
1225
|
+
const key = `${type} ${blocker.id}`
|
|
1226
|
+
if (this.announcedBlockers.has(key)) return
|
|
1227
|
+
remember(this.announcedBlockers, key, true)
|
|
1228
|
+
if (blocker.kind === "permission") return this.announce(sessionID, `announce:permission:${blocker.id}`, `The session needs your permission to ${permissionPhrase(blocker.action)}.`, false)
|
|
1229
|
+
// OpenCode's question tool titles its form "Questions": one question is read as itself (a field's description is
|
|
1230
|
+
// the question, its title the short header), several are counted.
|
|
1231
|
+
const fields = blocker.fields ?? []
|
|
1232
|
+
if (blocker.question && fields.length > 1) return this.announce(sessionID, `announce:form:${blocker.id}`, `The session has ${fields.length} questions.`, false)
|
|
1233
|
+
// The blocker may drop descriptions to stay small: the event's own field has it.
|
|
1234
|
+
const raw = (data.form as { fields?: Array<{ description?: unknown }> } | undefined)?.fields?.[0]
|
|
1235
|
+
const question = fields.length === 1 ? (typeof raw?.description === "string" && raw.description.trim() ? raw.description : fields[0]!.description || fields[0]!.title) : undefined
|
|
1236
|
+
const title = spokenTitle(question || (blocker.question ? undefined : blocker.summary))
|
|
1237
|
+
this.announce(sessionID, `announce:form:${blocker.id}`, title ? `The session has a question: ${title}` : "The session has a question.", false)
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1060
1240
|
/** Pairs a child session's latest assistant message with successful execution and speaks it once. */
|
|
1061
1241
|
private observeWorkerSpeech(type: string, sessionID: string, data: Record<string, unknown>): void {
|
|
1062
1242
|
const now = this.now()
|
|
@@ -1116,6 +1296,8 @@ export class Registry {
|
|
|
1116
1296
|
*/
|
|
1117
1297
|
private flushWorkerSpeech(childID: string, state: WorkerSpeech): void {
|
|
1118
1298
|
if (!state.succeeded || !state.messageID || state.parts.size === 0 || !this.conversations.has(state.rootID)) return
|
|
1299
|
+
// (voice front P1) The front reports worker results itself.
|
|
1300
|
+
if (this.fronts.has(state.rootID)) return state.parts.clear()
|
|
1119
1301
|
const parts = [...state.parts].sort(([a], [b]) => a - b).filter(([ordinal]) => !this.spoken.has(`${state.messageID} ${ordinal}`))
|
|
1120
1302
|
state.parts.clear()
|
|
1121
1303
|
if (!parts.length) return
|
|
@@ -1378,6 +1560,7 @@ export class Registry {
|
|
|
1378
1560
|
|
|
1379
1561
|
/** The session as reported by any process plus its live status; undefined when nobody reports it. */
|
|
1380
1562
|
session(sessionID: string): SessionView | undefined {
|
|
1563
|
+
if (this.hidden(sessionID)) return undefined
|
|
1381
1564
|
let info: SessionInfo | undefined
|
|
1382
1565
|
/** Open from any reporting process; a Herdr pane wins over a TUI. */
|
|
1383
1566
|
let terminal: TerminalInfo | undefined
|
|
@@ -1599,7 +1782,8 @@ export class Registry {
|
|
|
1599
1782
|
if (type === "session.text.delta" || type === "session.reasoning.delta") return
|
|
1600
1783
|
const ended = type === "session.execution.succeeded" ? "succeeded" : type === "session.execution.failed" ? "failed" : type === "session.execution.interrupted" ? "interrupted" : undefined
|
|
1601
1784
|
const reason = (data.error as { message?: unknown } | undefined)?.message
|
|
1602
|
-
|
|
1785
|
+
// Pushed: never a credential the error text carries (the recordings' scrubber).
|
|
1786
|
+
const error = typeof reason === "string" && reason.trim() ? redactCredentials(reason).text : undefined
|
|
1603
1787
|
const errorType = (data.error as { type?: unknown } | undefined)?.type
|
|
1604
1788
|
// A provider error the user must act on replaces the root's "failed" (raised once per run).
|
|
1605
1789
|
const providerFailure = ended === "failed" && typeof errorType === "string" && providerFailureNeedsUser(errorType)
|
|
@@ -1695,20 +1879,264 @@ export class Registry {
|
|
|
1695
1879
|
return this.conversations.has(sessionID)
|
|
1696
1880
|
}
|
|
1697
1881
|
|
|
1698
|
-
/**
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1882
|
+
/**
|
|
1883
|
+
* Records conversation mode for a session; `connID` is the process that was just told. `front` (voice front P1):
|
|
1884
|
+
* undefined when no front was asked for (today's mode), else the front session the spoke answered (null: none, it
|
|
1885
|
+
* fell back). Its messages carry `front` once one was asked for.
|
|
1886
|
+
*/
|
|
1887
|
+
setConversation(sessionID: string, on: boolean, connID?: string, front?: string | null): void {
|
|
1888
|
+
const was = this.conversations.has(sessionID)
|
|
1889
|
+
const wanted = this.frontWanted.has(sessionID)
|
|
1890
|
+
const had = this.fronts.has(sessionID)
|
|
1891
|
+
this.frontFellBack.delete(sessionID)
|
|
1892
|
+
if (on) {
|
|
1893
|
+
this.conversations.set(sessionID, connID)
|
|
1894
|
+
if (front === undefined) this.frontWanted.delete(sessionID)
|
|
1895
|
+
else this.frontWanted.add(sessionID)
|
|
1896
|
+
if (front && connID !== undefined) this.mapFront(sessionID, front, connID)
|
|
1897
|
+
else this.unmapFront(sessionID)
|
|
1898
|
+
} else {
|
|
1703
1899
|
this.conversations.delete(sessionID)
|
|
1900
|
+
this.frontWanted.delete(sessionID)
|
|
1901
|
+
this.unmapFront(sessionID)
|
|
1704
1902
|
for (const [childID, state] of this.workerSpeech) if (state.rootID === sessionID) this.clearWorkerSpeech(childID)
|
|
1705
1903
|
this.dropRootSpeech(sessionID)
|
|
1706
1904
|
}
|
|
1905
|
+
const report = front !== undefined || wanted
|
|
1906
|
+
const changed = on !== was || (front !== undefined && !wanted) || (front === undefined && wanted) || had !== this.fronts.has(sessionID)
|
|
1707
1907
|
if (!changed) return
|
|
1708
|
-
this.emit({ type: "conversation", sessionID, on })
|
|
1908
|
+
this.emit({ type: "conversation", sessionID, on, ...(report ? { front: this.fronts.has(sessionID) } : {}) })
|
|
1709
1909
|
this.emit({ type: "catalog" })
|
|
1710
1910
|
}
|
|
1711
1911
|
|
|
1912
|
+
/** (voice front P1) The active front of a main session, if any. */
|
|
1913
|
+
frontOf(sessionID: string): string | undefined {
|
|
1914
|
+
return this.fronts.get(sessionID)?.frontID
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
/**
|
|
1918
|
+
* (voice front P1) Where the main session's utterances and interrupts go while its front is active: the front and
|
|
1919
|
+
* its owner process (the one that created it, while no sessions frame lists it yet).
|
|
1920
|
+
*/
|
|
1921
|
+
frontRoute(sessionID: string): { sessionID: string; connID: string } | undefined {
|
|
1922
|
+
const front = this.fronts.get(sessionID)
|
|
1923
|
+
if (!front) return undefined
|
|
1924
|
+
const connID = this.linker(front) ?? this.owner(front.frontID)
|
|
1925
|
+
return connID === undefined ? undefined : { sessionID: front.frontID, connID }
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
/** (voice front P1) The connected process that links the front (it relays and wakes it), if it is still here. */
|
|
1929
|
+
private linker(front: Front): string | undefined {
|
|
1930
|
+
return this.conns.has(front.connID) ? front.connID : undefined
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
/** (voice front P1) The front known for a conversation, named in a resend so a restarted spoke re-links it. */
|
|
1934
|
+
resendFrontID(sessionID: string): string | undefined {
|
|
1935
|
+
return this.fronts.get(sessionID)?.frontID
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
/** (voice front P1) The front failed while in use (e.g. a steer into it failed): the session talks directly. */
|
|
1939
|
+
frontLost(mainID: string): void {
|
|
1940
|
+
this.frontFailed(mainID)
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
/** (voice front P1) Listens to what the hub asks spokes to do about fronts (the SpokeHub sends it). */
|
|
1944
|
+
subscribeFrontActions(listener: (action: FrontAction) => void): () => void {
|
|
1945
|
+
this.frontActionListeners.add(listener)
|
|
1946
|
+
return () => this.frontActionListeners.delete(listener)
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
private frontAction(action: FrontAction): void {
|
|
1950
|
+
for (const listener of this.frontActionListeners) {
|
|
1951
|
+
try {
|
|
1952
|
+
listener(action)
|
|
1953
|
+
} catch (cause) {
|
|
1954
|
+
console.error(`[hub] front action failed: ${(cause as Error).message}`)
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
/** (voice front P1) Whether any connected process of the main's tenant reports the session. */
|
|
1960
|
+
private frontReported(frontID: string): boolean {
|
|
1961
|
+
return this.all().some((c) => c.sessions.has(frontID))
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
/** (voice front P1) After FRONT_GONE_MS: a mapped front nobody links or reports has failed (front_failed). */
|
|
1965
|
+
private watchFrontGone(mainID: string): void {
|
|
1966
|
+
const front = this.fronts.get(mainID)
|
|
1967
|
+
if (!front) return
|
|
1968
|
+
this.timers.set(() => {
|
|
1969
|
+
if (this.fronts.get(mainID) !== front || this.linker(front) || this.frontReported(front.frontID)) return
|
|
1970
|
+
this.frontFailed(mainID)
|
|
1971
|
+
}, FRONT_GONE_MS)
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
/**
|
|
1975
|
+
* (voice front P1) Voice-front sessions a frame reports that no conversation maps (another process's leftover, or
|
|
1976
|
+
* one from before a restart): after FRONT_STALE_MS still unmapped, its reporter is asked to delete it. A mapped front,
|
|
1977
|
+
* or one naming a main in conversation (its spoke links it), is never touched; user sessions are no fronts.
|
|
1978
|
+
*/
|
|
1979
|
+
private noteStaleFronts(conn: Conn): void {
|
|
1980
|
+
for (const s of conn.sessions.values()) {
|
|
1981
|
+
if (!this.unusedFront(s)) continue
|
|
1982
|
+
// A front that failed is never used again: no wait (its process forgot it, e.g. after a restart), also when it
|
|
1983
|
+
// was noted as unmapped before (a new front is reported before its conversation maps it).
|
|
1984
|
+
const failed = this.failedFronts.has(s.id)
|
|
1985
|
+
const due = failed ? this.now() - FRONT_STALE_MS : this.now()
|
|
1986
|
+
const since = this.staleFronts.get(s.id)
|
|
1987
|
+
if (since !== undefined && since <= due) continue
|
|
1988
|
+
this.staleFronts.set(s.id, due)
|
|
1989
|
+
this.timers.set(() => this.sweepStaleFront(s.id), failed ? 0 : FRONT_STALE_MS)
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
private unusedFront(s: SessionInfo): boolean {
|
|
1994
|
+
return isFrontInfo(s) && !this.frontMain.has(s.id) && !(s.voiceFrontOf !== undefined && this.conversations.has(s.voiceFrontOf))
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
private sweepStaleFront(sessionID: string): void {
|
|
1998
|
+
const since = this.staleFronts.get(sessionID)
|
|
1999
|
+
if (since === undefined) return
|
|
2000
|
+
const reporter = this.all().find((c) => {
|
|
2001
|
+
const s = c.sessions.get(sessionID)
|
|
2002
|
+
return s !== undefined && this.unusedFront(s) && this.supports(c.id, "voiceFront")
|
|
2003
|
+
})
|
|
2004
|
+
if (!reporter) return void this.staleFronts.delete(sessionID)
|
|
2005
|
+
const left = since + FRONT_STALE_MS - this.now()
|
|
2006
|
+
if (left > 0) return void this.timers.set(() => this.sweepStaleFront(sessionID), left)
|
|
2007
|
+
this.staleFronts.delete(sessionID)
|
|
2008
|
+
console.warn(`[hub] asking for a stale voice front to be deleted: ${sessionID}`)
|
|
2009
|
+
this.frontAction({ kind: "remove", connID: reporter.id, sessionID })
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
/** (voice front P1) Whether the session's conversation asked for a voice front. */
|
|
2013
|
+
wantsFront(sessionID: string): boolean {
|
|
2014
|
+
return this.frontWanted.has(sessionID)
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
/** (voice front P1) Whether a conversation resend to `connID` asks for the front again (it must serve "voiceFront"). */
|
|
2018
|
+
resendFront(sessionID: string, connID: string): boolean {
|
|
2019
|
+
return this.frontWanted.has(sessionID) && !this.frontFellBack.has(sessionID) && this.supports(connID, "voiceFront")
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
/**
|
|
2023
|
+
* (voice front P1) A resent conversation op's answer: the front it names (a restarted spoke may name a new one),
|
|
2024
|
+
* or none (an older spoke, or creating it failed): the session talks directly then.
|
|
2025
|
+
*/
|
|
2026
|
+
frontAnswer(sessionID: string, connID: string, frontID: unknown): void {
|
|
2027
|
+
if (!this.conversations.has(sessionID) || !this.frontWanted.has(sessionID) || this.frontFellBack.has(sessionID)) return
|
|
2028
|
+
const had = this.fronts.has(sessionID)
|
|
2029
|
+
if (typeof frontID === "string" && frontID) this.mapFront(sessionID, frontID, connID)
|
|
2030
|
+
else this.unmapFront(sessionID)
|
|
2031
|
+
if (had === this.fronts.has(sessionID)) return
|
|
2032
|
+
this.emit({ type: "conversation", sessionID, on: true, front: !had })
|
|
2033
|
+
this.emit({ type: "catalog" })
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
/** Maps main → front, unless the ID is unusable, failed before, or another tenant's session. */
|
|
2037
|
+
private mapFront(mainID: string, frontID: string, connID: string): void {
|
|
2038
|
+
const current = this.fronts.get(mainID)
|
|
2039
|
+
if (current?.frontID === frontID) return void (current.connID = connID)
|
|
2040
|
+
const tenant = this.sessionTenant(frontID)
|
|
2041
|
+
const usable = frontID.length <= FRONT_ID_MAX && frontID !== mainID && !this.failedFronts.has(frontID) && (tenant === undefined || tenant === this.sessionTenant(mainID))
|
|
2042
|
+
const owner = this.frontMain.get(frontID)
|
|
2043
|
+
if (!usable || (owner !== undefined && owner !== mainID)) return this.unmapFront(mainID)
|
|
2044
|
+
this.unmapFront(mainID)
|
|
2045
|
+
const seen = this.all().some((c) => c.sessions.get(frontID)?.voiceFrontOf === mainID)
|
|
2046
|
+
this.fronts.set(mainID, { frontID, connID, seen })
|
|
2047
|
+
this.frontMain.set(frontID, mainID)
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
private unmapFront(mainID: string): void {
|
|
2051
|
+
const front = this.fronts.get(mainID)
|
|
2052
|
+
if (!front) return
|
|
2053
|
+
this.fronts.delete(mainID)
|
|
2054
|
+
if (this.frontMain.get(front.frontID) === mainID) this.frontMain.delete(front.frontID)
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
/**
|
|
2058
|
+
* (voice front P1) A sessions frame: a front it lists for one of its tenant's main sessions (voiceFrontOf) becomes
|
|
2059
|
+
* that session's front when it has none that is still listed (this covers reconnects); a listed front this
|
|
2060
|
+
* process no longer lists is gone (the fallback).
|
|
2061
|
+
*/
|
|
2062
|
+
private syncFronts(conn: Conn): void {
|
|
2063
|
+
this.noteStaleFronts(conn)
|
|
2064
|
+
const tenant = this.tenant(conn)
|
|
2065
|
+
for (const s of conn.sessions.values()) {
|
|
2066
|
+
const mainID = s.voiceFrontOf
|
|
2067
|
+
if (typeof mainID !== "string" || mainID === s.id || this.failedFronts.has(s.id)) continue
|
|
2068
|
+
if (!this.conversations.has(mainID) || !this.frontWanted.has(mainID) || this.frontFellBack.has(mainID)) continue
|
|
2069
|
+
if (this.sessionTenant(s.id) !== tenant || this.sessionTenant(mainID) !== tenant) continue
|
|
2070
|
+
const current = this.fronts.get(mainID)
|
|
2071
|
+
if (current?.frontID === s.id) {
|
|
2072
|
+
current.seen = true
|
|
2073
|
+
current.connID = conn.id
|
|
2074
|
+
continue
|
|
2075
|
+
}
|
|
2076
|
+
// A front just created (not listed yet), or one another process still lists, stays.
|
|
2077
|
+
if (current && (!current.seen || this.all().some((c) => c.sessions.has(current.frontID)))) continue
|
|
2078
|
+
const had = current !== undefined
|
|
2079
|
+
this.mapFront(mainID, s.id, conn.id)
|
|
2080
|
+
const mapped = this.fronts.get(mainID)
|
|
2081
|
+
if (mapped) mapped.seen = true
|
|
2082
|
+
if (!had && mapped) {
|
|
2083
|
+
this.emit({ type: "conversation", sessionID: mainID, on: true, front: true })
|
|
2084
|
+
this.emit({ type: "catalog" })
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2087
|
+
for (const [mainID, front] of [...this.fronts]) {
|
|
2088
|
+
if (front.seen && front.connID === conn.id && !conn.sessions.has(front.frontID)) this.frontFailed(mainID)
|
|
2089
|
+
}
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
/** (voice front P1) The front failed or is gone while active: the session talks directly (SSE front_failed). */
|
|
2093
|
+
private frontFailed(mainID: string): void {
|
|
2094
|
+
const front = this.fronts.get(mainID)
|
|
2095
|
+
if (!front) return
|
|
2096
|
+
rememberID(this.failedFronts, front.frontID)
|
|
2097
|
+
this.frontFellBack.add(mainID)
|
|
2098
|
+
this.unmapFront(mainID)
|
|
2099
|
+
this.dropRootSpeech(mainID)
|
|
2100
|
+
console.warn(`[hub] voice front of ${mainID} failed: talking to the session directly`)
|
|
2101
|
+
if (!this.conversations.has(mainID)) return
|
|
2102
|
+
this.emit({ type: "conversation", sessionID: mainID, on: true, front: false, reason: "front_failed" })
|
|
2103
|
+
this.emit({ type: "catalog" })
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
/**
|
|
2107
|
+
* (voice front P1) An event of a hidden front: from the process that runs it only (several may forward it), its
|
|
2108
|
+
* text is spoken as main's say, its inbox deliveries are heard for main and its turn is recorded as main's; a
|
|
2109
|
+
* failed or deleted front falls back. Anything else of it (and of an unmapped front) is dropped.
|
|
2110
|
+
*/
|
|
2111
|
+
private observeFront(connID: string, tenant: string, frontID: string, type: string, data: Record<string, unknown>): void {
|
|
2112
|
+
const mainID = this.frontMain.get(frontID)
|
|
2113
|
+
const front = mainID === undefined ? undefined : this.fronts.get(mainID)
|
|
2114
|
+
if (mainID === undefined || front?.frontID !== frontID) return
|
|
2115
|
+
// From the linking process only (others forward the same events); a deletion from any of them.
|
|
2116
|
+
if (type !== "session.deleted" && (this.linker(front) ?? this.owner(frontID) ?? front.connID) !== connID) return
|
|
2117
|
+
if (this.recordingTap && TAPPED.has(type)) {
|
|
2118
|
+
try {
|
|
2119
|
+
if (this.recordingTap.wants(tenant, mainID, type)) this.recordingTap.event(tenant, mainID, type, data)
|
|
2120
|
+
} catch (cause) {
|
|
2121
|
+
console.error(`[hub] recording tap failed: ${(cause as Error).message}`)
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
switch (type) {
|
|
2125
|
+
case "session.text.delta":
|
|
2126
|
+
return this.speakDelta(connID, mainID, data, true)
|
|
2127
|
+
case "session.text.ended":
|
|
2128
|
+
return this.say(mainID, data, true)
|
|
2129
|
+
case "session.execution.interrupted":
|
|
2130
|
+
return this.dropRootSpeech(mainID)
|
|
2131
|
+
case "session.execution.failed":
|
|
2132
|
+
case "session.deleted":
|
|
2133
|
+
return this.frontFailed(mainID)
|
|
2134
|
+
case "session.inbox.delivered":
|
|
2135
|
+
if (typeof data.inboxID === "string") this.delivered(data.inboxID)
|
|
2136
|
+
return
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2139
|
+
|
|
1712
2140
|
/**
|
|
1713
2141
|
* Whether a connected process serves a feature (spokeFeatures): a core op (CORE_OPS: turn, steer, interrupt,
|
|
1714
2142
|
* stop, permission, form), which an OpenCode spoke implies and a spoke of another harness serves only when it
|