@huynle/hub 0.9.2 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +320 -0
- package/Dockerfile +5 -1
- package/README.md +154 -77
- package/package.json +7 -4
- package/packages/claude-code/package.json +1 -1
- package/packages/cli/dist/hub-cli.js +534 -261
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/cli.ts +36 -1
- package/packages/cli/src/subscriptions.ts +49 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/sessions.ts +10 -6
- package/packages/codex/src/spoke.ts +2 -1
- package/packages/node/package.json +1 -1
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/schema/app-delivery/v1.schema.json +7 -5
- package/packages/protocol/schema/app-front/v1.schema.json +410 -0
- package/packages/protocol/src/app-front.ts +293 -0
- package/packages/protocol/src/concierge.ts +10 -1
- package/packages/protocol/src/context-cards.ts +59 -0
- package/packages/protocol/src/credentials.ts +39 -2
- package/packages/protocol/src/index.ts +81 -11
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/app-front.ts +524 -0
- package/packages/sdk/src/client.ts +37 -4
- package/packages/sdk/src/envelope.ts +9 -2
- package/packages/sdk/src/errors.ts +1 -0
- package/packages/sdk/src/index.ts +1 -0
- package/packages/sdk/src/types.ts +39 -1
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/delivery.ts +17 -1
- package/packages/spoke-sdk/src/harness-spoke.ts +4 -3
- package/packages/voice-client/README.md +146 -0
- package/packages/voice-client/src/vad/LICENSE +21 -0
- package/src/central/agent/acting-routes.ts +17 -3
- package/src/central/agent/acting.ts +12 -3
- package/src/central/agent/actor.ts +6 -1
- package/src/central/agent/app-decisions.ts +146 -0
- package/src/central/agent/app-tokens.ts +9 -3
- package/src/central/agent/audit.ts +3 -0
- package/src/central/agent/connect-routes.ts +64 -0
- package/src/central/agent/connect.ts +868 -0
- package/src/central/agent/conversation.ts +625 -0
- package/src/central/agent/decisions.ts +71 -6
- package/src/central/agent/delivery.ts +3 -0
- package/src/central/agent/directory.ts +5 -0
- package/src/central/agent/errors.ts +2 -0
- package/src/central/agent/grant-requests.ts +46 -9
- package/src/central/agent/grants.ts +13 -2
- package/src/central/agent/message-limits.ts +2 -2
- package/src/central/agent/message-routes.ts +13 -4
- package/src/central/agent/read-routes.ts +16 -2
- package/src/central/agent/routes.ts +108 -6
- package/src/central/agent/speech-routes.ts +316 -0
- package/src/central/agent/subscription-routes.ts +174 -0
- package/src/central/agent/subscriptions.ts +980 -0
- package/src/central/agent-data.ts +43 -1
- package/src/central/backend/memory-counters.ts +2 -2
- package/src/central/backend/types.ts +5 -2
- package/src/central/backend/valkey-counters.ts +5 -4
- package/src/central/client-address.ts +16 -0
- package/src/central/concierge/board-render.ts +52 -1
- package/src/central/concierge/board-routes.ts +25 -2
- package/src/central/concierge/board.ts +11 -1
- package/src/central/concierge/context.ts +29 -8
- package/src/central/concierge/grant-request.ts +216 -0
- package/src/central/concierge/prompt.ts +49 -2
- package/src/central/concierge/session-routes.ts +3 -0
- package/src/central/config.ts +64 -3
- package/src/central/context-cards/budget.ts +121 -0
- package/src/central/context-cards/changes.ts +29 -0
- package/src/central/context-cards/config.ts +145 -0
- package/src/central/context-cards/engine.ts +379 -0
- package/src/central/context-cards/inputs.ts +111 -0
- package/src/central/context-cards/pickup.ts +134 -0
- package/src/central/context-cards/registry.ts +89 -0
- package/src/central/context-cards/routes.ts +37 -0
- package/src/central/context-cards/service.ts +367 -0
- package/src/central/context-cards/store.ts +67 -0
- package/src/central/context-cards/types.ts +88 -0
- package/src/central/fetcher.ts +10 -2
- package/src/central/front-relays.ts +423 -0
- package/src/central/http.ts +547 -58
- package/src/central/human-activity.ts +453 -0
- package/src/central/llm/chat.ts +133 -0
- package/src/central/registry.ts +162 -9
- package/src/central/speech/service.ts +9 -0
- package/src/central/spoke-ws.ts +13 -0
- package/src/central/store.ts +9 -12
- package/src/config.ts +30 -13
- package/src/index.ts +45 -43
- package/src/rpc.ts +33 -4
- package/src/spoke/concierge-agent.ts +1 -1
- package/src/spoke/conversation.ts +6 -75
- package/src/spoke/deliver.ts +18 -8
- package/src/spoke/hub-rpc.ts +7 -2
- package/src/spoke/hub-tool-subscriptions.ts +82 -0
- package/src/spoke/hub-tool-title.ts +89 -0
- package/src/spoke/hub-tool.ts +56 -18
- package/src/spoke/session-context.ts +10 -2
- package/src/spoke/spoke.ts +227 -237
- package/src/spoke/summary.ts +36 -1
- package/src/spoke/taskboard.ts +5 -6
- package/src/spoke/voice-front.ts +323 -30
- package/src/spoke/who-teams.ts +51 -2
- package/src/tui-messaging.ts +24 -10
- package/src/tui.ts +6 -6
- package/web/dist/assets/{ArtifactPage-BKISN-Qj.js → ArtifactPage-CsPjam8D.js} +1 -1
- package/web/dist/assets/{ArtifactPane-CKOiAUNH.js → ArtifactPane-B9weO8L6.js} +1 -1
- package/web/dist/assets/ArtifactWorkbench-DzKhMs3l.js +6 -0
- package/web/dist/assets/{Diagram-vdpmp3xu.css → Diagram-7y8kg3og.css} +1 -1
- package/web/dist/assets/Diagram-CuRIffka.js +1 -0
- package/web/dist/assets/{Library-CcjZXsfF.js → Library-rv5DGtrm.js} +2 -2
- package/web/dist/assets/Lightbox-BlbivCkd.js +1 -0
- package/web/dist/assets/MediaImage-Ch3ryIdR.js +4 -0
- package/web/dist/assets/ProfilesDestination-DUuQ9jmd.js +1 -0
- package/web/dist/assets/{TeamsPage-CDyjrK-I.js → TeamsPage-D_17MkvN.js} +1 -1
- package/web/dist/assets/{Watchtower-DYwoUWME.js → Watchtower-DlrNOJ7X.js} +2 -2
- package/web/dist/assets/{artifacts-DU9uRgs1.js → artifacts-But87Jfo.js} +1 -1
- package/web/dist/assets/index-bX8B6NLo.js +33 -0
- package/web/dist/assets/index-rW6LKQj1.css +1 -0
- package/web/dist/assets/{live-ycZ_aQZO.js → live-BGLA-peO.js} +2 -2
- package/web/dist/assets/{pane-layout-0RrLHGDr.js → pane-layout-BW0RdX5a.js} +1 -1
- package/web/dist/assets/{renderer2d-BIwbgRoX.js → renderer2d-DyANQ1WS.js} +1 -1
- package/web/dist/assets/{renderer3d-C1a7eYGQ.js → renderer3d-vYYKFAJq.js} +1 -1
- package/web/dist/assets/{safe-area-DHhPy61Q.js → safe-area-B2Bk__FK.js} +1 -1
- package/web/dist/assets/{source-BWlvaODa.js → source-C-De8L3s.js} +1 -1
- package/web/dist/assets/toast-CXTwTvDw.js +9 -0
- package/web/dist/assets/vad-worklet-BfxJhrXy.js +27 -0
- package/web/dist/index.html +6 -6
- package/web/dist/assets/ArtifactWorkbench-d7R1o-rs.js +0 -6
- package/web/dist/assets/Diagram-azw2B_QH.js +0 -1
- package/web/dist/assets/Lightbox-Du56MXGX.js +0 -1
- package/web/dist/assets/MediaImage-Ds_JUx3a.js +0 -4
- package/web/dist/assets/ProfilesDestination-DdzbzmeK.js +0 -1
- package/web/dist/assets/index-C3ci5UtY.css +0 -1
- package/web/dist/assets/index-LNMZCqgE.js +0 -36
- package/web/dist/assets/toast-Cuu2K0hx.js +0 -1
- /package/{web/src/voice → packages/voice-client/src}/turn/LICENSE +0 -0
package/src/central/registry.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { BLOCKER_ID_MAX, StatusBoard, askBlocker, errorTypeLabel, permissionRequ
|
|
|
16
16
|
import { sanitizeLabel } from "./agent/envelope"
|
|
17
17
|
import { redactCredentials } from "../../packages/protocol/src/credentials.ts"
|
|
18
18
|
import { DoneWatch } from "./done"
|
|
19
|
+
import { FrontRelays, RelayRefused } from "./front-relays"
|
|
19
20
|
import { SentenceStream } from "./say-stream"
|
|
20
21
|
import type { ActivityStep, NotificationKind } from "./push"
|
|
21
22
|
|
|
@@ -53,7 +54,8 @@ export interface MachineRef {
|
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
/** A status tree entry as the hub reports it: working sessions carry when their current run started. */
|
|
56
|
-
|
|
57
|
+
/** `since`: a working entry's run start; `blockedSince`: when a blocked entry's oldest open blocker was first seen. */
|
|
58
|
+
export type LiveStatus = SessionStatus & { readonly since?: number; readonly blockedSince?: number }
|
|
57
59
|
|
|
58
60
|
/** Timer functions (injectable for tests). */
|
|
59
61
|
export interface Timers {
|
|
@@ -125,6 +127,13 @@ export interface ActivityItem {
|
|
|
125
127
|
export const ACTIVITY_LIMIT = 30
|
|
126
128
|
/** Root sessions whose activity is remembered. */
|
|
127
129
|
const ACTIVITY_ROOTS = 200
|
|
130
|
+
/**
|
|
131
|
+
* (Session findability) Harnesses whose spokes report every prompt the hub carried as origin "hub" (Claude Code: a
|
|
132
|
+
* UserPromptSubmit after the hub's hubPrompt), so each of their user items was typed in the terminal.
|
|
133
|
+
*/
|
|
134
|
+
const HUB_PROMPTS_ORIGIN_HUB: ReadonlySet<string> = new Set(["claude-code"])
|
|
135
|
+
/** Blocker first sightings kept (the oldest dropped first). */
|
|
136
|
+
const BLOCKERS_SEEN = 5000
|
|
128
137
|
|
|
129
138
|
/** A session counts as watched while a device reported it visible within this window. */
|
|
130
139
|
export const PRESENCE_WINDOW_MS = 45_000
|
|
@@ -450,6 +459,10 @@ export const FRONT_GONE_MS = 10_000
|
|
|
450
459
|
export type FrontAction =
|
|
451
460
|
| { readonly kind: "adopt"; readonly mainID: string; readonly connID: string; readonly frontID: string }
|
|
452
461
|
| { readonly kind: "remove"; readonly connID: string; readonly sessionID: string }
|
|
462
|
+
/** (P2) An update about a session the Concierge front relayed to: frontUpdate through the front's linking process. */
|
|
463
|
+
| { readonly kind: "update"; readonly connID: string; readonly mainID: string; readonly text: string; readonly finished?: boolean }
|
|
464
|
+
|
|
465
|
+
export { RELAY_REASON, RELAYS_PER_UTTERANCE, RelayRefused } from "./front-relays"
|
|
453
466
|
|
|
454
467
|
/** (voice front P1) The front in front of a main session: who created it, and whether a sessions frame listed it yet. */
|
|
455
468
|
interface Front {
|
|
@@ -493,6 +506,10 @@ const isFrontInfo = (s: SessionInfo) => s.voiceFrontOf !== undefined || s.agent
|
|
|
493
506
|
/** Older spokes reported exposed sessions only, without the field. */
|
|
494
507
|
const isOn = (s: SessionInfo) => s.exposed !== false
|
|
495
508
|
|
|
509
|
+
/** Recent terminal prompt inbox IDs remembered per root (replay dedupe), and roots remembered. */
|
|
510
|
+
const TERMINAL_PROMPT_IDS = 256
|
|
511
|
+
const TERMINAL_PROMPT_ROOTS = 2_000
|
|
512
|
+
|
|
496
513
|
export class Registry {
|
|
497
514
|
/** (REC-01) Voice recording taps; optional, best effort. */
|
|
498
515
|
recordingTap?: RecordingTap
|
|
@@ -516,6 +533,10 @@ export class Registry {
|
|
|
516
533
|
private readonly sessionsListeners = new Set<(connID: string) => void>()
|
|
517
534
|
/** Called when the user's own prompt reached a session's inbox (typed or spoken in the harness). */
|
|
518
535
|
private readonly humanTurnListeners = new Set<(sessionID: string, tenant?: string) => void>()
|
|
536
|
+
private readonly agentWorkListeners = new Set<(rootID: string, tenant: string, what: "turn" | "tool") => void>()
|
|
537
|
+
private readonly terminalPromptListeners = new Set<(rootID: string, tenant: string) => void>()
|
|
538
|
+
/** Root → its recently counted terminal prompt inbox IDs (an exact replay is counted once); bounded both ways. */
|
|
539
|
+
private readonly terminalPromptSeen = new Map<string, Set<string>>()
|
|
519
540
|
private readonly sessionDeletedListeners = new Set<(sessionID: string, tenant: string) => void>()
|
|
520
541
|
/** Pending form ID → its session and every connection that forwarded it, first first (like permissions). */
|
|
521
542
|
private readonly forms = new Map<string, PendingForm>()
|
|
@@ -567,6 +588,8 @@ export class Registry {
|
|
|
567
588
|
private readonly presence = new Map<string, { sessionID: string; at: number }>()
|
|
568
589
|
/** Session → start time of its current run (session.execution.started). */
|
|
569
590
|
private readonly since = new Map<string, number>()
|
|
591
|
+
/** (Session findability) Blocker ID → when this process first saw it (the "waiting 12m" of Needs you). */
|
|
592
|
+
private readonly blockerSeen = new Map<string, number>()
|
|
570
593
|
/** Session → streamed text waiting for its send window. */
|
|
571
594
|
private readonly streams = new Map<string, Stream>()
|
|
572
595
|
/** Machine → its latest terminals frame and the connection that sent it. */
|
|
@@ -592,6 +615,63 @@ export class Registry {
|
|
|
592
615
|
private readonly tenantOf: TenantOf = () => "",
|
|
593
616
|
) {
|
|
594
617
|
this.doneWatch = new DoneWatch(timers, (rootID) => this.settledTree(rootID), (rootID) => this.alert("done", rootID))
|
|
618
|
+
this.relays = new FrontRelays({ now, timers, send: (update) => this.relayUpdate(update) })
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** (voice front P2) The Concierge front's relays: utterance bindings and the sessions it relayed to. */
|
|
622
|
+
private readonly relays: FrontRelays
|
|
623
|
+
|
|
624
|
+
/** (P2) The hub steered the user's utterance (from `deviceID`) into the active front of `mainID`. */
|
|
625
|
+
frontUtterance(mainID: string, inboxID: string, deviceID?: string, text?: string): void {
|
|
626
|
+
const front = this.fronts.get(mainID)
|
|
627
|
+
if (front) this.relays.utterance(front.frontID, inboxID, deviceID, text)
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* (P2) A relay the front `frontID` asks for over `connID`: the front's main and the utterance's device, with one
|
|
632
|
+
* relay taken from that utterance (`release` gives it back). RelayRefused unless `connID` links the active front
|
|
633
|
+
* and the front's latest input is a recent utterance of the user's with relays left.
|
|
634
|
+
*/
|
|
635
|
+
takeRelay(connID: string, frontID: string): { readonly mainID: string } & ReturnType<FrontRelays["take"]> {
|
|
636
|
+
const mainID = this.frontMain.get(frontID)
|
|
637
|
+
const front = mainID === undefined ? undefined : this.fronts.get(mainID)
|
|
638
|
+
if (mainID === undefined || front?.frontID !== frontID) throw new RelayRefused("Not an active voice front")
|
|
639
|
+
if ((this.linker(front) ?? front.connID) !== connID) throw new RelayRefused("Only the front's own process relays for it")
|
|
640
|
+
return { mainID, ...this.relays.take(frontID) }
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** (P2) A relay reached `targetID`: its results go to the front of `mainID`. */
|
|
644
|
+
noteRelay(mainID: string, targetID: string, title: string, inboxID?: string): void {
|
|
645
|
+
this.relays.relayed(mainID, targetID, title, inboxID)
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** (P2) Sends an update about a relayed session to the front's linking process. */
|
|
649
|
+
private relayUpdate(update: { mainID: string; text: string; finished?: boolean }): void {
|
|
650
|
+
const front = this.fronts.get(update.mainID)
|
|
651
|
+
const connID = front ? (this.linker(front) ?? this.owner(front.frontID)) : undefined
|
|
652
|
+
if (connID === undefined) return
|
|
653
|
+
this.frontAction({ kind: "update", connID, mainID: update.mainID, text: update.text, ...(update.finished ? { finished: true } : {}) })
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* (P2) A session the Concierge front relayed to: its permission, question and failure are announced once as the
|
|
658
|
+
* Concierge's speech ("Hub API needs your permission to run a command."), and its root's events (from the process
|
|
659
|
+
* that owns it) feed the front's updates.
|
|
660
|
+
*/
|
|
661
|
+
private observeRelayed(connID: string, sessionID: string, type: string, data: Record<string, unknown>): void {
|
|
662
|
+
const rootID = this.rootOf(sessionID) ?? sessionID
|
|
663
|
+
if (rootID === sessionID && (this.owner(rootID) ?? connID) === connID) this.relays.observe(rootID, type, data)
|
|
664
|
+
const target = this.relays.target(rootID)
|
|
665
|
+
if (!target || !this.fronts.has(target.mainID)) return
|
|
666
|
+
if (type === "permission.asked" || type === "form.created") return this.announceOwnAsk(type, sessionID, data, target.mainID, target.title)
|
|
667
|
+
if (type !== "session.execution.failed") return
|
|
668
|
+
const at = this.announcedFailures.get(sessionID)
|
|
669
|
+
if (at !== undefined && this.now() - at <= FAILURE_DEDUPE_MS) return
|
|
670
|
+
remember(this.announcedFailures, sessionID, this.now())
|
|
671
|
+
const message = (data.error as { message?: unknown } | undefined)?.message
|
|
672
|
+
const reason = redactCredentials(typeof message === "string" ? message : "").text.replace(/\s+/g, " ").trim().replace(/[.!]+$/, "").slice(0, ANNOUNCE_REASON_MAX)
|
|
673
|
+
const who = sessionID === rootID ? target.title : `A worker of ${target.title}`
|
|
674
|
+
this.announce(target.mainID, `announce:failed:${sessionID}`, reason ? `${who} failed: ${reason}.` : `${who} failed.`, true)
|
|
595
675
|
}
|
|
596
676
|
|
|
597
677
|
/**
|
|
@@ -753,6 +833,27 @@ export class Registry {
|
|
|
753
833
|
return () => this.humanTurnListeners.delete(listener)
|
|
754
834
|
}
|
|
755
835
|
|
|
836
|
+
/**
|
|
837
|
+
* (Session findability) Listens to the agent's work: a turn started (session.execution.started) or a tool called
|
|
838
|
+
* (session.tool.called), on the root session of the tree (subagents count for their root).
|
|
839
|
+
*/
|
|
840
|
+
onAgentWork(listener: (rootID: string, tenant: string, what: "turn" | "tool") => void): () => void {
|
|
841
|
+
this.agentWorkListeners.add(listener)
|
|
842
|
+
return () => this.agentWorkListeners.delete(listener)
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* (Session findability) Listens to prompts the user typed in a session's terminal, on the root of the tree: a user
|
|
847
|
+
* inbox item the hub did not carry. Exact, never guessed: a spoke announcing inboxCarried (OpenCode, Codex) marks
|
|
848
|
+
* what the hub carried (`carried`); Claude Code reports the hub's prompts as origin "hub" (HUB_PROMPTS_ORIGIN_HUB).
|
|
849
|
+
* Any other spoke (an older OpenCode or Codex spoke) cannot tell them apart, so its user items are not reported
|
|
850
|
+
* here. Counted from the root's owner process only.
|
|
851
|
+
*/
|
|
852
|
+
onTerminalPrompt(listener: (rootID: string, tenant: string) => void): () => void {
|
|
853
|
+
this.terminalPromptListeners.add(listener)
|
|
854
|
+
return () => this.terminalPromptListeners.delete(listener)
|
|
855
|
+
}
|
|
856
|
+
|
|
756
857
|
/** Listens to sessions a spoke reports deleted (session.deleted, seen once by the pod holding the spoke). */
|
|
757
858
|
onSessionDeleted(listener: (sessionID: string, tenant: string) => void): () => void {
|
|
758
859
|
this.sessionDeletedListeners.add(listener)
|
|
@@ -901,6 +1002,7 @@ export class Registry {
|
|
|
901
1002
|
this.observeBoards(event, tenant)
|
|
902
1003
|
return
|
|
903
1004
|
}
|
|
1005
|
+
this.blockerFirstSeen(data.id)
|
|
904
1006
|
const request = permissionRequest(data)
|
|
905
1007
|
const blocker = askBlocker(event)
|
|
906
1008
|
this.permissions.set(data.id, { connIDs: [connID], sessionID, tenant, ...(request ? { request } : {}), ...(blocker ? { blocker } : {}) })
|
|
@@ -910,6 +1012,7 @@ export class Registry {
|
|
|
910
1012
|
if (event.type === "form.created" && sessionID) {
|
|
911
1013
|
const formID = (data.form as Record<string, unknown> | undefined)?.id
|
|
912
1014
|
if (typeof formID === "string") {
|
|
1015
|
+
this.blockerFirstSeen(formID)
|
|
913
1016
|
const known = this.forms.get(formID)
|
|
914
1017
|
if (known && !known.connIDs.includes(connID)) known.connIDs.push(connID)
|
|
915
1018
|
else if (!known) {
|
|
@@ -947,6 +1050,7 @@ export class Registry {
|
|
|
947
1050
|
if (sessionID) this.observeActivity(event, sessionID, data)
|
|
948
1051
|
if (sessionID) this.observeWorkerSpeech(event.type, sessionID, data)
|
|
949
1052
|
if (sessionID) this.observeWorkerBlockers(event.type, sessionID, data)
|
|
1053
|
+
if (sessionID) this.observeRelayed(connID, sessionID, event.type, data)
|
|
950
1054
|
|
|
951
1055
|
if (streamed && sessionID) {
|
|
952
1056
|
const rootID = this.rootOf(sessionID)
|
|
@@ -960,11 +1064,38 @@ export class Registry {
|
|
|
960
1064
|
const tenant = this.sessionTenant(sessionID)
|
|
961
1065
|
for (const listener of [...this.humanTurnListeners]) listener(sessionID, tenant)
|
|
962
1066
|
}
|
|
1067
|
+
if (event.type === "session.inbox.delivered" && data.origin === "user" && data.carried !== true && sessionID && tenant !== undefined && this.terminalPromptListeners.size) {
|
|
1068
|
+
const exact = conn.features.has("inboxCarried") || HUB_PROMPTS_ORIGIN_HUB.has(helloHarness(conn.hello).harness)
|
|
1069
|
+
const rootID = this.rootOf(sessionID) ?? sessionID
|
|
1070
|
+
const owner = this.owner(rootID)
|
|
1071
|
+
if (exact && (owner === undefined || owner === connID) && this.firstTerminalPrompt(rootID, data.inboxID)) for (const listener of [...this.terminalPromptListeners]) listener(rootID, tenant)
|
|
1072
|
+
}
|
|
1073
|
+
if ((event.type === "session.execution.started" || event.type === "session.tool.called") && sessionID && tenant !== undefined && this.agentWorkListeners.size) {
|
|
1074
|
+
const rootID = this.rootOf(sessionID) ?? sessionID
|
|
1075
|
+
// Several processes may forward the same events: counted from the root's owner only (any, when none is known).
|
|
1076
|
+
const owner = this.owner(rootID)
|
|
1077
|
+
if (owner === undefined || owner === connID) for (const listener of [...this.agentWorkListeners]) listener(rootID, tenant, event.type === "session.tool.called" ? "tool" : "turn")
|
|
1078
|
+
}
|
|
963
1079
|
if (event.type === "session.deleted" && sessionID) this.setConversation(sessionID, false)
|
|
1080
|
+
if (event.type === "session.deleted" && sessionID) this.terminalPromptSeen.delete(sessionID)
|
|
964
1081
|
if (event.type === "session.deleted" && sessionID) for (const listener of [...this.sessionDeletedListeners]) listener(sessionID, tenant)
|
|
965
1082
|
if (closedPermission) this.permissionClosed(...closedPermission)
|
|
966
1083
|
}
|
|
967
1084
|
|
|
1085
|
+
/** A terminal prompt not counted before for this root (by inboxID; one without an ID always counts). */
|
|
1086
|
+
private firstTerminalPrompt(rootID: string, inboxID: unknown): boolean {
|
|
1087
|
+
if (typeof inboxID !== "string" || !inboxID) return true
|
|
1088
|
+
let seen = this.terminalPromptSeen.get(rootID)
|
|
1089
|
+
if (seen?.has(inboxID)) return false
|
|
1090
|
+
if (seen) this.terminalPromptSeen.delete(rootID)
|
|
1091
|
+
else seen = new Set()
|
|
1092
|
+
this.terminalPromptSeen.set(rootID, seen)
|
|
1093
|
+
seen.add(inboxID)
|
|
1094
|
+
if (seen.size > TERMINAL_PROMPT_IDS) seen.delete(seen.values().next().value!)
|
|
1095
|
+
if (this.terminalPromptSeen.size > TERMINAL_PROMPT_ROOTS) this.terminalPromptSeen.delete(this.terminalPromptSeen.keys().next().value!)
|
|
1096
|
+
return true
|
|
1097
|
+
}
|
|
1098
|
+
|
|
968
1099
|
/** (REC-01) Hands a root session's event to the recording tap once: from the session's owner connection only. */
|
|
969
1100
|
private tap(connID: string, tenant: string | undefined, sessionID: string, type: string, data: Record<string, unknown>): void {
|
|
970
1101
|
const tap = this.recordingTap
|
|
@@ -1219,22 +1350,22 @@ export class Registry {
|
|
|
1219
1350
|
* (voice front P1) Main's own permission or question while its front is active, once per request: "The session
|
|
1220
1351
|
* needs your permission to run a command." / "The session has a question: Which branch?" (no command line, as push).
|
|
1221
1352
|
*/
|
|
1222
|
-
private announceOwnAsk(type: string, sessionID: string, data: Record<string, unknown
|
|
1353
|
+
private announceOwnAsk(type: string, sessionID: string, data: Record<string, unknown>, speakAs = sessionID, who = "The session"): void {
|
|
1223
1354
|
const blocker = askBlocker({ type, data })
|
|
1224
1355
|
if (!blocker) return
|
|
1225
1356
|
const key = `${type} ${blocker.id}`
|
|
1226
1357
|
if (this.announcedBlockers.has(key)) return
|
|
1227
1358
|
remember(this.announcedBlockers, key, true)
|
|
1228
|
-
if (blocker.kind === "permission") return this.announce(
|
|
1359
|
+
if (blocker.kind === "permission") return this.announce(speakAs, `announce:permission:${blocker.id}`, `${who} needs your permission to ${permissionPhrase(blocker.action)}.`, false)
|
|
1229
1360
|
// OpenCode's question tool titles its form "Questions": one question is read as itself (a field's description is
|
|
1230
1361
|
// the question, its title the short header), several are counted.
|
|
1231
1362
|
const fields = blocker.fields ?? []
|
|
1232
|
-
if (blocker.question && fields.length > 1) return this.announce(
|
|
1363
|
+
if (blocker.question && fields.length > 1) return this.announce(speakAs, `announce:form:${blocker.id}`, `${who} has ${fields.length} questions.`, false)
|
|
1233
1364
|
// The blocker may drop descriptions to stay small: the event's own field has it.
|
|
1234
1365
|
const raw = (data.form as { fields?: Array<{ description?: unknown }> } | undefined)?.fields?.[0]
|
|
1235
1366
|
const question = fields.length === 1 ? (typeof raw?.description === "string" && raw.description.trim() ? raw.description : fields[0]!.description || fields[0]!.title) : undefined
|
|
1236
1367
|
const title = spokenTitle(question || (blocker.question ? undefined : blocker.summary))
|
|
1237
|
-
this.announce(
|
|
1368
|
+
this.announce(speakAs, `announce:form:${blocker.id}`, title ? `${who} has a question: ${title}` : `${who} has a question.`, false)
|
|
1238
1369
|
}
|
|
1239
1370
|
|
|
1240
1371
|
/** Pairs a child session's latest assistant message with successful execution and speaks it once. */
|
|
@@ -1491,10 +1622,21 @@ export class Registry {
|
|
|
1491
1622
|
return this.active.has(sessionID) || this.remoteBusy.has(sessionID)
|
|
1492
1623
|
}
|
|
1493
1624
|
|
|
1494
|
-
/**
|
|
1625
|
+
/** When a blocker was first seen (now, the first time it is asked about). */
|
|
1626
|
+
private blockerFirstSeen(id: string): number {
|
|
1627
|
+
let at = this.blockerSeen.get(id)
|
|
1628
|
+
if (at === undefined) remember(this.blockerSeen, id, (at = this.now()), BLOCKERS_SEEN)
|
|
1629
|
+
return at
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
/**
|
|
1633
|
+
* Adds `since` (run start) to working entries, `blockedSince` (when the oldest open blocker was first seen) to
|
|
1634
|
+
* blocked ones, and agents' recommendations to permission blockers.
|
|
1635
|
+
*/
|
|
1495
1636
|
private live(sessions: SessionStatus[]): LiveStatus[] {
|
|
1496
1637
|
return sessions.map((s) => {
|
|
1497
1638
|
const since = s.state === "working" ? this.since.get(s.id) : undefined
|
|
1639
|
+
const blockedSince = s.state === "blocked" && s.blockers.length ? Math.min(...s.blockers.map((b) => this.blockerFirstSeen(b.id))) : undefined
|
|
1498
1640
|
const recommended = s.blockers.some((b) => b.kind === "permission" && this.permissions.get(b.id)?.recommendation)
|
|
1499
1641
|
const blockers = recommended
|
|
1500
1642
|
? s.blockers.map((b) => {
|
|
@@ -1502,8 +1644,8 @@ export class Registry {
|
|
|
1502
1644
|
return recommendation ? { ...b, recommendation } : b
|
|
1503
1645
|
})
|
|
1504
1646
|
: s.blockers
|
|
1505
|
-
const entry = recommended ? { ...s, blockers } : s
|
|
1506
|
-
return since === undefined ? entry : { ...entry, since }
|
|
1647
|
+
const entry: LiveStatus = recommended ? { ...s, blockers } : s
|
|
1648
|
+
return since === undefined && blockedSince === undefined ? entry : { ...entry, ...(since !== undefined ? { since } : {}), ...(blockedSince !== undefined ? { blockedSince } : {}) }
|
|
1507
1649
|
})
|
|
1508
1650
|
}
|
|
1509
1651
|
|
|
@@ -1909,6 +2051,12 @@ export class Registry {
|
|
|
1909
2051
|
this.emit({ type: "catalog" })
|
|
1910
2052
|
}
|
|
1911
2053
|
|
|
2054
|
+
/** (AF3) Conversation mode of a session now; `front` once a front was asked for (as its conversation messages). */
|
|
2055
|
+
conversationOf(sessionID: string): { on: boolean; front?: boolean } {
|
|
2056
|
+
const on = this.conversations.has(sessionID)
|
|
2057
|
+
return { on, ...(on && this.frontWanted.has(sessionID) ? { front: this.fronts.has(sessionID) } : {}) }
|
|
2058
|
+
}
|
|
2059
|
+
|
|
1912
2060
|
/** (voice front P1) The active front of a main session, if any. */
|
|
1913
2061
|
frontOf(sessionID: string): string | undefined {
|
|
1914
2062
|
return this.fronts.get(sessionID)?.frontID
|
|
@@ -2050,6 +2198,7 @@ export class Registry {
|
|
|
2050
2198
|
private unmapFront(mainID: string): void {
|
|
2051
2199
|
const front = this.fronts.get(mainID)
|
|
2052
2200
|
if (!front) return
|
|
2201
|
+
this.relays.forget(mainID, front.frontID)
|
|
2053
2202
|
this.fronts.delete(mainID)
|
|
2054
2203
|
if (this.frontMain.get(front.frontID) === mainID) this.frontMain.delete(front.frontID)
|
|
2055
2204
|
}
|
|
@@ -2132,7 +2281,11 @@ export class Registry {
|
|
|
2132
2281
|
case "session.deleted":
|
|
2133
2282
|
return this.frontFailed(mainID)
|
|
2134
2283
|
case "session.inbox.delivered":
|
|
2135
|
-
if (typeof data.inboxID === "string")
|
|
2284
|
+
if (typeof data.inboxID === "string") {
|
|
2285
|
+
this.delivered(data.inboxID)
|
|
2286
|
+
// (P2) The front's latest input: an utterance of the user's may relay, a wake never.
|
|
2287
|
+
this.relays.frontDelivered(frontID, data.inboxID)
|
|
2288
|
+
}
|
|
2136
2289
|
return
|
|
2137
2290
|
}
|
|
2138
2291
|
}
|
|
@@ -8,11 +8,13 @@ import {
|
|
|
8
8
|
lowerLayers,
|
|
9
9
|
resolveStt,
|
|
10
10
|
resolveTts,
|
|
11
|
+
speechReady,
|
|
11
12
|
speechView,
|
|
12
13
|
updateSpeech,
|
|
13
14
|
userSpeechView,
|
|
14
15
|
type Resolved,
|
|
15
16
|
type SpeechDefaults,
|
|
17
|
+
type SpeechReady,
|
|
16
18
|
type SpeechView,
|
|
17
19
|
type StoredSpeech,
|
|
18
20
|
type UserSpeechView,
|
|
@@ -146,6 +148,13 @@ export class SpeechService {
|
|
|
146
148
|
return openrouter ? withCurrent(parseModels(body), resolved.model) : speechModelList(kind, body, resolved.model)
|
|
147
149
|
}
|
|
148
150
|
|
|
151
|
+
/** Whether each direction can work at all for the scope (a user's settings over the default; see speechReady). */
|
|
152
|
+
ready(userID?: string): SpeechReady {
|
|
153
|
+
const settings = this.options.store.speechSettings()
|
|
154
|
+
const user = this.userSettings(userID)
|
|
155
|
+
return speechReady(resolveStt(settings, this.options.defaults, user), resolveTts(settings, this.options.defaults, user))
|
|
156
|
+
}
|
|
157
|
+
|
|
149
158
|
private userSettings(userID: string | undefined): StoredSpeech | undefined {
|
|
150
159
|
return userID === undefined ? undefined : (this.options.store.userSpeech?.(userID) ?? {})
|
|
151
160
|
}
|
package/src/central/spoke-ws.ts
CHANGED
|
@@ -234,6 +234,11 @@ export class SpokeHub {
|
|
|
234
234
|
/** Cluster mode: the directory heartbeat, RPC responder and close notices are set up. */
|
|
235
235
|
private readonly started: Promise<void>
|
|
236
236
|
readonly websocket: WebSocketHandler<SpokeSocketData>
|
|
237
|
+
/**
|
|
238
|
+
* (voice front P2) Serves relayToSession for `owner` (the machine's owner) from `connID`: the hub's checks and the
|
|
239
|
+
* prompt (set by the hub once its acting engine exists). Unset: refused.
|
|
240
|
+
*/
|
|
241
|
+
relayToSession?: (owner: string, connID: string, req: Extract<SpokeRequest, { op: "relayToSession" }>) => Promise<ResultFor["relayToSession"]>
|
|
237
242
|
|
|
238
243
|
constructor(private readonly options: SpokeHubOptions) {
|
|
239
244
|
const staleMs = options.staleMs ?? 60_000
|
|
@@ -731,6 +736,10 @@ export class SpokeHub {
|
|
|
731
736
|
const next = req.sessionID ? `/s/${encodeURIComponent(req.sessionID)}${req.voice ? "?voice=1" : ""}` : "/"
|
|
732
737
|
return { url: `${publicURL}/login?code=${code}&next=${next}` }
|
|
733
738
|
}
|
|
739
|
+
case "relayToSession":
|
|
740
|
+
// A sandboxed (launch-bound) harness never relays as the user.
|
|
741
|
+
if (ws.data.launch || !this.relayToSession) throw new Error("This hub does not relay for this process")
|
|
742
|
+
return this.relayToSession(owner, ws.data.connID, req)
|
|
734
743
|
}
|
|
735
744
|
}
|
|
736
745
|
|
|
@@ -739,6 +748,10 @@ export class SpokeHub {
|
|
|
739
748
|
const { registry } = this.options
|
|
740
749
|
const failed = (cause: unknown) => console.error("[hub] voice front request failed:", cause instanceof Error ? cause.message : cause)
|
|
741
750
|
if (action.kind === "remove") return void this.request(action.connID, { op: "removeFront", sessionID: action.sessionID }).catch(failed)
|
|
751
|
+
if (action.kind === "update") {
|
|
752
|
+
if (!registry.supports(action.connID, "conciergeFront")) return
|
|
753
|
+
return void this.request(action.connID, { op: "frontUpdate", sessionID: action.mainID, text: action.text, ...(action.finished ? { finished: true } : {}) }).catch(failed)
|
|
754
|
+
}
|
|
742
755
|
this.request(action.connID, { op: "conversation", sessionID: action.mainID, on: true, front: true, frontID: action.frontID }).then((result) => registry.frontAnswer(action.mainID, action.connID, result.front?.sessionID), failed)
|
|
743
756
|
}
|
|
744
757
|
|
package/src/central/store.ts
CHANGED
|
@@ -39,7 +39,7 @@ export interface UserRecord {
|
|
|
39
39
|
viewPrefs?: ViewPrefs
|
|
40
40
|
/** (RF2) Hosts and `*.suffix`es whose web images the console loads at once (normalised; absent = none). */
|
|
41
41
|
mediaAllowlist?: string[]
|
|
42
|
-
/** Session ID → per-session Voice
|
|
42
|
+
/** Session ID → per-session Voice model (the voice front's), oldest first. An older hub's worker entry is ignored. */
|
|
43
43
|
modelPrefs?: Record<string, SessionModelPrefs>
|
|
44
44
|
/** Session ID → when the user last read it (ms), least recently marked first. */
|
|
45
45
|
reads?: Record<string, number>
|
|
@@ -998,25 +998,22 @@ export class Store {
|
|
|
998
998
|
|
|
999
999
|
// ---------- per-user, per-session model preferences ----------
|
|
1000
1000
|
|
|
1001
|
+
/** A session's Voice model (the voice front's); an obsolete Worker model stored by older hubs is left out. */
|
|
1001
1002
|
modelPrefs(id: string, sessionID: string): SessionModelPrefs {
|
|
1002
|
-
const
|
|
1003
|
-
return
|
|
1003
|
+
const voice = this.user(id)?.modelPrefs?.[sessionID]?.voice
|
|
1004
|
+
return voice ? { voice: structuredClone(voice) } : {}
|
|
1004
1005
|
}
|
|
1005
1006
|
|
|
1006
|
-
setModelPrefs(id: string, sessionID: string, change: { voice?: ModelSelection | null
|
|
1007
|
+
setModelPrefs(id: string, sessionID: string, change: { voice?: ModelSelection | null }): Promise<SessionModelPrefs> {
|
|
1007
1008
|
return this.mutate((tx) => {
|
|
1008
1009
|
const user = this.user(id)
|
|
1009
1010
|
if (!user) return {}
|
|
1010
1011
|
const all = user.modelPrefs ?? (user.modelPrefs = {})
|
|
1011
|
-
const next: { voice?: ModelSelection
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
const value = change[key]
|
|
1015
|
-
if (value === null) delete next[key]
|
|
1016
|
-
else if (value !== undefined) next[key] = structuredClone(value)
|
|
1017
|
-
}
|
|
1012
|
+
const next: { voice?: ModelSelection } = this.modelPrefs(id, sessionID)
|
|
1013
|
+
if (change.voice === null) delete next.voice
|
|
1014
|
+
else if (change.voice !== undefined) next.voice = structuredClone(change.voice)
|
|
1018
1015
|
delete all[sessionID]
|
|
1019
|
-
if (next.voice
|
|
1016
|
+
if (next.voice) all[sessionID] = next
|
|
1020
1017
|
while (Object.keys(all).length > MODEL_PREFS_LIMIT) delete all[Object.keys(all)[0]!]
|
|
1021
1018
|
tx.touch("users", id)
|
|
1022
1019
|
return this.modelPrefs(id, sessionID)
|
package/src/config.ts
CHANGED
|
@@ -17,18 +17,17 @@ export interface VoiceConfig {
|
|
|
17
17
|
/** Session IDs exposed when mode is "session". */
|
|
18
18
|
readonly sessions: readonly string[]
|
|
19
19
|
readonly machineName: string
|
|
20
|
-
/** Agent the voice orchestrator delegates work to in conversation mode. */
|
|
21
|
-
readonly subagent: string
|
|
22
20
|
/** Start a local hub in the background when the server is loopback and nothing answers (default true). */
|
|
23
21
|
readonly autoStartLocalHub: boolean
|
|
24
22
|
/** Read open sessions and workspaces from the Herdr CLI (default true; the service process only). */
|
|
25
23
|
readonly herdr: boolean
|
|
26
24
|
/** History window of the session listing: root sessions from the last `days` days, at most `max`. */
|
|
27
25
|
readonly list: ListWindow
|
|
28
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* Fast model the voice front talks with in Voice mode ("provider/model[#variant]"); a session's Voice model in the
|
|
28
|
+
* console wins. Pick a fast paid model: free ones run out of quota (the front then falls back to the session).
|
|
29
|
+
*/
|
|
29
30
|
readonly voiceModel?: string
|
|
30
|
-
/** Model subagents work on; defaults to the session's model before conversation mode. */
|
|
31
|
-
readonly workModel?: string
|
|
32
31
|
readonly machineID?: string
|
|
33
32
|
readonly token?: string
|
|
34
33
|
}
|
|
@@ -42,8 +41,26 @@ export const DEFAULT_LIST: ListWindow = Object.freeze({ days: 30, max: 100 })
|
|
|
42
41
|
const LIST_LIMITS = { days: 365, max: 500 } as const
|
|
43
42
|
|
|
44
43
|
export const DEFAULT_SERVER = "http://127.0.0.1:4097"
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
/**
|
|
45
|
+
* Keys older plugins read and wrote for the removed "voice" agent: `subagent` (the agent it delegated to) and
|
|
46
|
+
* `workModel` (its workers' model). Read without error, ignored, never written; logged once per process.
|
|
47
|
+
*/
|
|
48
|
+
export const OBSOLETE_CONFIG_KEYS = ["subagent", "workModel"] as const
|
|
49
|
+
const OBSOLETE_WHY: Record<(typeof OBSOLETE_CONFIG_KEYS)[number], string> = {
|
|
50
|
+
subagent: "the voice agent that delegated to it is gone",
|
|
51
|
+
workModel: "the voice agent whose workers used it is gone",
|
|
52
|
+
}
|
|
53
|
+
const warnedKeys = new Set<string>()
|
|
54
|
+
|
|
55
|
+
/** Logs each obsolete key `raw` holds, once per `seen` (the process by default). */
|
|
56
|
+
export function warnObsoleteKeys(raw: unknown, log: (line: string) => void = console.warn, seen: Set<string> = warnedKeys): void {
|
|
57
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return
|
|
58
|
+
for (const key of OBSOLETE_CONFIG_KEYS) {
|
|
59
|
+
if (!(key in raw) || seen.has(key)) continue
|
|
60
|
+
seen.add(key)
|
|
61
|
+
log(`[hub] config.json: "${key}" is obsolete (${OBSOLETE_WHY[key]}: Voice mode always uses the voice front now); it is ignored and removed on the next save`)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
47
64
|
const MODEL_REF = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.:\/-]+(?:#[A-Za-z0-9_.-]+)?$/
|
|
48
65
|
|
|
49
66
|
export interface ModelRef {
|
|
@@ -98,7 +115,7 @@ export function prepareConfigPath(env: Env = process.env, home: string = homedir
|
|
|
98
115
|
}
|
|
99
116
|
|
|
100
117
|
export function defaults(machineName: string): VoiceConfig {
|
|
101
|
-
return { server: DEFAULT_SERVER, mode: "off", sessions: [], machineName,
|
|
118
|
+
return { server: DEFAULT_SERVER, mode: "off", sessions: [], machineName, autoStartLocalHub: true, herdr: true, list: { ...DEFAULT_LIST } }
|
|
102
119
|
}
|
|
103
120
|
|
|
104
121
|
/** Normalizes untrusted JSON into a config, falling back to defaults field by field. */
|
|
@@ -114,12 +131,10 @@ export function normalize(raw: unknown, machineName: string): VoiceConfig {
|
|
|
114
131
|
mode,
|
|
115
132
|
sessions,
|
|
116
133
|
machineName: typeof r.machineName === "string" && r.machineName ? r.machineName : machineName,
|
|
117
|
-
subagent: typeof r.subagent === "string" && AGENT_ID.test(r.subagent) ? r.subagent : base.subagent,
|
|
118
134
|
autoStartLocalHub: r.autoStartLocalHub !== false,
|
|
119
135
|
herdr: r.herdr !== false,
|
|
120
136
|
list: listWindow(r.list),
|
|
121
137
|
...(modelRef(r.voiceModel) ? { voiceModel: modelRef(r.voiceModel) } : {}),
|
|
122
|
-
...(modelRef(r.workModel) ? { workModel: modelRef(r.workModel) } : {}),
|
|
123
138
|
...(typeof r.machineID === "string" && r.machineID ? { machineID: r.machineID } : {}),
|
|
124
139
|
...(typeof r.token === "string" && r.token ? { token: r.token } : {}),
|
|
125
140
|
}
|
|
@@ -138,14 +153,16 @@ function listWindow(raw: unknown): ListWindow {
|
|
|
138
153
|
|
|
139
154
|
export function readConfig(file: string, machineName: string): VoiceConfig {
|
|
140
155
|
try {
|
|
141
|
-
|
|
156
|
+
const raw = JSON.parse(readFileSync(file, "utf8"))
|
|
157
|
+
warnObsoleteKeys(raw)
|
|
158
|
+
return normalize(raw, machineName)
|
|
142
159
|
} catch {
|
|
143
160
|
return defaults(machineName)
|
|
144
161
|
}
|
|
145
162
|
}
|
|
146
163
|
|
|
147
164
|
/** The keys VoiceConfig owns in config.json; every other key belongs to someone else and is preserved. */
|
|
148
|
-
const CONFIG_KEYS = ["server", "mode", "sessions", "machineName", "
|
|
165
|
+
const CONFIG_KEYS = ["server", "mode", "sessions", "machineName", "autoStartLocalHub", "herdr", "list", "voiceModel", "machineID", "token"] as const satisfies readonly (keyof VoiceConfig)[]
|
|
149
166
|
|
|
150
167
|
type RawConfig = Record<string, unknown>
|
|
151
168
|
|
|
@@ -215,7 +232,7 @@ export function writeConfig(file: string, config: VoiceConfig, options?: SecretF
|
|
|
215
232
|
previous = readRawConfig(file) ?? {}
|
|
216
233
|
} catch {}
|
|
217
234
|
const next: RawConfig = { ...previous }
|
|
218
|
-
for (const key of CONFIG_KEYS) delete next[key]
|
|
235
|
+
for (const key of [...CONFIG_KEYS, ...OBSOLETE_CONFIG_KEYS]) delete next[key]
|
|
219
236
|
writePrivate(file, serialize({ ...next, ...config }), options)
|
|
220
237
|
}
|
|
221
238
|
|