@pellux/goodvibes-tui 1.16.0 → 1.17.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.
Files changed (220) hide show
  1. package/CHANGELOG.md +95 -9
  2. package/README.md +1 -1
  3. package/docs/foundation-artifacts/operator-contract.json +1091 -271
  4. package/package.json +7 -7
  5. package/src/cli/completions/generate.ts +5 -4
  6. package/src/cli/config-overrides.ts +51 -11
  7. package/src/cli/doctor.ts +4 -5
  8. package/src/cli/entrypoint.ts +13 -8
  9. package/src/cli/help.ts +7 -6
  10. package/src/cli/launch-auto-update.ts +214 -0
  11. package/src/cli/parser.ts +5 -0
  12. package/src/cli/status.ts +13 -0
  13. package/src/cli/surface-command.ts +5 -5
  14. package/src/cli/tui-startup.ts +60 -97
  15. package/src/cli/types.ts +2 -0
  16. package/src/config/index.ts +1 -1
  17. package/src/config/tui-extension-settings.ts +33 -0
  18. package/src/core/composer-state.ts +1 -1
  19. package/src/core/conversation-line-cache.ts +3 -3
  20. package/src/core/conversation-rendering.ts +4 -4
  21. package/src/core/conversation.ts +2 -2
  22. package/src/core/long-task-notifier.ts +7 -7
  23. package/src/core/stream-event-wiring.ts +8 -6
  24. package/src/core/system-message-noise.ts +1 -1
  25. package/src/core/system-message-router.ts +6 -6
  26. package/src/core/turn-cancellation.ts +1 -1
  27. package/src/core/turn-event-wiring.ts +8 -8
  28. package/src/daemon/cli.ts +20 -5
  29. package/src/daemon/lifecycle.ts +41 -0
  30. package/src/daemon/service-commands.ts +5 -5
  31. package/src/export/cost-utils.ts +128 -6
  32. package/src/input/autocomplete.ts +2 -2
  33. package/src/input/command-registry.ts +34 -9
  34. package/src/input/commands/ci-runtime.ts +5 -1
  35. package/src/input/commands/codebase-runtime.ts +6 -6
  36. package/src/input/commands/cost-runtime.ts +1 -1
  37. package/src/input/commands/health-runtime.ts +2 -2
  38. package/src/input/commands/intelligence-runtime.ts +1 -1
  39. package/src/input/commands/local-auth-runtime.ts +1 -1
  40. package/src/input/commands/local-provider-runtime.ts +49 -10
  41. package/src/input/commands/local-runtime.ts +53 -5
  42. package/src/input/commands/memory.ts +2 -2
  43. package/src/input/commands/operator-panel-runtime.ts +5 -5
  44. package/src/input/commands/permissions-provenance.ts +1 -1
  45. package/src/input/commands/permissions-runtime.ts +99 -8
  46. package/src/input/commands/planning-runtime.ts +3 -3
  47. package/src/input/commands/platform-sandbox-runtime.ts +1 -1
  48. package/src/input/commands/provider-accounts-runtime.ts +71 -6
  49. package/src/input/commands/provider.ts +7 -5
  50. package/src/input/commands/recall-files-git.ts +13 -51
  51. package/src/input/commands/recall-files.ts +8 -11
  52. package/src/input/commands/recall-review.ts +2 -2
  53. package/src/input/commands/relay-runtime.ts +1 -1
  54. package/src/input/commands/remote-runtime.ts +1 -1
  55. package/src/input/commands/runtime-services.ts +4 -4
  56. package/src/input/commands/schedule-runtime.ts +14 -7
  57. package/src/input/commands/services-runtime.ts +1 -1
  58. package/src/input/commands/session-workflow.ts +2 -2
  59. package/src/input/commands/settings-sync-runtime.ts +2 -2
  60. package/src/input/commands/share-runtime.ts +1 -1
  61. package/src/input/commands/shell-core.ts +46 -68
  62. package/src/input/commands/subscription-runtime.ts +1 -1
  63. package/src/input/commands/update-runtime.ts +153 -96
  64. package/src/input/commands/work-plan-runtime.ts +1 -1
  65. package/src/input/commands/workstream-runtime.ts +2 -2
  66. package/src/input/commands.ts +0 -2
  67. package/src/input/config-modal-types.ts +3 -3
  68. package/src/input/config-modal.ts +10 -11
  69. package/src/input/feature-unit-layout.ts +93 -82
  70. package/src/input/feed-context-factory.ts +2 -2
  71. package/src/input/handler-command-route.ts +2 -2
  72. package/src/input/handler-content-actions.ts +1 -1
  73. package/src/input/handler-feed-routes.ts +7 -7
  74. package/src/input/handler-feed.ts +4 -4
  75. package/src/input/handler-interactions.ts +3 -2
  76. package/src/input/handler-modal-routes.ts +12 -4
  77. package/src/input/handler-modal-token-routes.ts +1 -1
  78. package/src/input/handler-onboarding-daemon-adopt.ts +2 -2
  79. package/src/input/handler-onboarding.ts +3 -2
  80. package/src/input/handler-picker-routes.ts +26 -13
  81. package/src/input/handler-shortcuts.ts +6 -6
  82. package/src/input/handler.ts +2 -2
  83. package/src/input/keybindings.ts +2 -2
  84. package/src/input/onboarding/onboarding-feature-units.ts +34 -27
  85. package/src/input/onboarding/onboarding-provider-key-fields.ts +112 -0
  86. package/src/input/onboarding/onboarding-wizard-apply.ts +22 -21
  87. package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +17 -0
  88. package/src/input/onboarding/onboarding-wizard-network-adopt.ts +2 -2
  89. package/src/input/onboarding/onboarding-wizard-steps.ts +30 -42
  90. package/src/input/panel-integration-actions.ts +2 -2
  91. package/src/input/panel-mouse-geometry.ts +1 -1
  92. package/src/input/panel-paste-flood-guard.ts +1 -1
  93. package/src/input/provider-key-intake.ts +167 -0
  94. package/src/input/sandbox-exec-config.ts +2 -2
  95. package/src/input/selection-modal.ts +1 -1
  96. package/src/input/settings-modal-activation.ts +5 -0
  97. package/src/input/settings-modal-adjustment.ts +4 -31
  98. package/src/input/settings-modal-data.ts +22 -21
  99. package/src/input/settings-modal-mutations.ts +12 -68
  100. package/src/input/settings-modal-types.ts +23 -16
  101. package/src/input/settings-modal.ts +74 -55
  102. package/src/main.ts +48 -48
  103. package/src/panels/agent-inspector-shared.ts +4 -4
  104. package/src/panels/base-panel.ts +2 -2
  105. package/src/panels/builtin/agent.ts +4 -4
  106. package/src/panels/builtin/development.ts +4 -4
  107. package/src/panels/builtin/knowledge.ts +1 -1
  108. package/src/panels/builtin/operations.ts +29 -10
  109. package/src/panels/builtin/session.ts +5 -5
  110. package/src/panels/builtin/shared.ts +4 -4
  111. package/src/panels/builtin-modals.ts +10 -10
  112. package/src/panels/builtin-panels.ts +1 -1
  113. package/src/panels/cost-tracker-panel.ts +124 -7
  114. package/src/panels/diff-panel.ts +5 -5
  115. package/src/panels/eval-registry.ts +2 -2
  116. package/src/panels/fleet-deep-link.ts +1 -1
  117. package/src/panels/fleet-panel-format.ts +44 -8
  118. package/src/panels/fleet-panel.ts +16 -16
  119. package/src/panels/fleet-read-model.ts +52 -13
  120. package/src/panels/fleet-steer.ts +2 -2
  121. package/src/panels/fleet-stop.ts +3 -3
  122. package/src/panels/fleet-tabs.ts +13 -14
  123. package/src/panels/fleet-transcript.ts +5 -5
  124. package/src/panels/git-panel.ts +2 -2
  125. package/src/panels/index.ts +4 -1
  126. package/src/panels/local-auth-panel.ts +1 -1
  127. package/src/panels/modals/hooks-modal.ts +1 -1
  128. package/src/panels/modals/keybindings-modal.ts +2 -2
  129. package/src/panels/modals/knowledge-modal.ts +1 -1
  130. package/src/panels/modals/marketplace-modal.ts +4 -4
  131. package/src/panels/modals/memory-modal.ts +4 -4
  132. package/src/panels/modals/modal-surface-helpers.ts +1 -1
  133. package/src/panels/modals/modal-theme.ts +1 -1
  134. package/src/panels/modals/pairing-modal.ts +2 -2
  135. package/src/panels/modals/planning-modal.ts +2 -2
  136. package/src/panels/modals/plugins-modal.ts +1 -1
  137. package/src/panels/modals/policy-modal.ts +1 -1
  138. package/src/panels/modals/security-modal.ts +1 -1
  139. package/src/panels/modals/skills-modal.ts +1 -1
  140. package/src/panels/modals/work-plan-modal.ts +3 -3
  141. package/src/panels/notifications-feed.ts +127 -0
  142. package/src/panels/notifications-panel.ts +160 -0
  143. package/src/panels/panel-manager.ts +9 -9
  144. package/src/panels/plugins-panel.ts +4 -4
  145. package/src/panels/project-planning-answer-actions.ts +1 -1
  146. package/src/panels/provider-health-routes.ts +2 -2
  147. package/src/panels/skills-panel.ts +4 -5
  148. package/src/panels/token-budget-panel.ts +11 -3
  149. package/src/panels/types.ts +5 -5
  150. package/src/permissions/broker-approval-card.ts +201 -0
  151. package/src/permissions/hunk-selection.ts +28 -4
  152. package/src/permissions/prompt.ts +244 -29
  153. package/src/renderer/autocomplete-overlay.ts +107 -54
  154. package/src/renderer/code-block.ts +2 -2
  155. package/src/renderer/compaction-history-modal.ts +1 -1
  156. package/src/renderer/compaction-preview.ts +1 -1
  157. package/src/renderer/compaction-quality.ts +1 -1
  158. package/src/renderer/compositor.ts +2 -2
  159. package/src/renderer/config-modal.ts +1 -1
  160. package/src/renderer/conversation-overlays.ts +1 -1
  161. package/src/renderer/footer-tips.ts +3 -3
  162. package/src/renderer/hint-grammar.ts +1 -1
  163. package/src/renderer/modal-factory.ts +1 -1
  164. package/src/renderer/model-picker-overlay.ts +1 -1
  165. package/src/renderer/model-workspace.ts +7 -2
  166. package/src/renderer/onboarding/onboarding-wizard.ts +22 -8
  167. package/src/renderer/panel-composite.ts +2 -2
  168. package/src/renderer/panel-workspace-bar.ts +1 -1
  169. package/src/renderer/process-indicator.ts +1 -1
  170. package/src/renderer/selection-modal-overlay.ts +102 -24
  171. package/src/renderer/session-picker-modal.ts +2 -2
  172. package/src/renderer/settings-modal-helpers.ts +14 -5
  173. package/src/renderer/settings-modal.ts +127 -39
  174. package/src/renderer/shell-surface.ts +3 -0
  175. package/src/renderer/terminal-bg-probe.ts +1 -1
  176. package/src/renderer/theme.ts +3 -3
  177. package/src/renderer/tool-call.ts +1 -1
  178. package/src/renderer/tool-result-summary.ts +1 -1
  179. package/src/renderer/turn-injection.ts +7 -6
  180. package/src/renderer/ui-factory.ts +6 -1
  181. package/src/runtime/bootstrap-command-context.ts +4 -1
  182. package/src/runtime/bootstrap-command-parts.ts +6 -4
  183. package/src/runtime/bootstrap-core.ts +35 -35
  184. package/src/runtime/bootstrap-hook-bridge.ts +1 -1
  185. package/src/runtime/bootstrap-shell.ts +6 -5
  186. package/src/runtime/bootstrap.ts +34 -32
  187. package/src/runtime/channel-composition.ts +46 -0
  188. package/src/runtime/code-index-services.ts +3 -4
  189. package/src/runtime/daemon-attach-notices.ts +120 -0
  190. package/src/runtime/durability-services.ts +55 -0
  191. package/src/runtime/embedded-service-factories.ts +40 -0
  192. package/src/runtime/feature-settings.ts +147 -0
  193. package/src/runtime/index.ts +4 -21
  194. package/src/runtime/legacy-daemon-migration.ts +29 -19
  195. package/src/runtime/memory-fold.ts +1 -1
  196. package/src/runtime/notification-dispatch.ts +110 -0
  197. package/src/runtime/onboarding/apply.ts +3 -29
  198. package/src/runtime/onboarding/fast-path.ts +39 -6
  199. package/src/runtime/onboarding/provider-key-capture.ts +126 -0
  200. package/src/runtime/onboarding/snapshot.ts +0 -1
  201. package/src/runtime/onboarding/types.ts +9 -3
  202. package/src/runtime/provider-fallback.ts +55 -0
  203. package/src/runtime/provider-optimizer-wiring.ts +55 -0
  204. package/src/runtime/release-artifacts.ts +19 -103
  205. package/src/runtime/resume-notice.ts +1 -1
  206. package/src/runtime/services.ts +81 -82
  207. package/src/runtime/session-continuity-hints.ts +38 -0
  208. package/src/runtime/surface-feature-flags.ts +40 -9
  209. package/src/runtime/terminal-output-guard.ts +2 -2
  210. package/src/runtime/trust/workspace-registration.ts +10 -2
  211. package/src/runtime/trust/workspace-trust.ts +58 -31
  212. package/src/runtime/ui-services.ts +3 -1
  213. package/src/runtime/update-check.ts +15 -69
  214. package/src/runtime/workstream-services.ts +2 -2
  215. package/src/shell/blocking-input.ts +102 -6
  216. package/src/shell/ui-openers.ts +38 -6
  217. package/src/utils/format-duration.ts +1 -1
  218. package/src/verification/verification-ledger.ts +21 -4
  219. package/src/version.ts +1 -1
  220. package/src/input/commands/flags-runtime.ts +0 -328
@@ -0,0 +1,41 @@
1
+ /**
2
+ * lifecycle.ts — resolve THIS binary's update-artifact identity for the SDK
3
+ * DaemonServer facade's auto-update lifecycle.
4
+ *
5
+ * The SDK facade now runs the entire self-update loop itself when handed a
6
+ * DaemonUpdateArtifact ({version, execPath}): it compares the HOST binary's
7
+ * version against the release tags (no longer the version-blind sdk-package
8
+ * comparison that used to restart-loop), swaps only at an idle moment, keeps
9
+ * the outgoing file at `<path>.previous`, and leaves a receipt in the store it
10
+ * serves on /status. When the artifact is ABSENT, updates are host-managed —
11
+ * the facade runs no loop (the safe embedded default).
12
+ *
13
+ * The one guard the facade does NOT apply is install-kind: it must never swap a
14
+ * dev `bun run daemon` interpreter or a bun-global package install. This helper
15
+ * is that guard — it hands the facade an artifact ONLY for a compiled binary
16
+ * install, so a dev run resolves to `undefined` (host-managed, no loop) and
17
+ * only a real self-contained binary self-updates.
18
+ */
19
+ import type { DaemonUpdateArtifact } from '@pellux/goodvibes-sdk/platform/daemon';
20
+ import { VERSION } from '../version.ts';
21
+ import { detectInstallKind } from '../runtime/update-check.ts';
22
+
23
+ export interface ResolveDaemonUpdateArtifactOptions {
24
+ /** The executable to identify; defaults to process.execPath. */
25
+ readonly execPath?: string;
26
+ /** Injectable so tests pin a fixture version — never the live build VERSION. */
27
+ readonly version?: string;
28
+ }
29
+
30
+ /**
31
+ * The update-artifact identity to hand the DaemonServer facade, or `undefined`
32
+ * for a non-binary install (dev/source or bun-global package) — in which case
33
+ * the facade keeps updates host-managed and runs no swap loop.
34
+ */
35
+ export function resolveDaemonUpdateArtifact(
36
+ options: ResolveDaemonUpdateArtifactOptions = {},
37
+ ): DaemonUpdateArtifact | undefined {
38
+ const execPath = options.execPath ?? process.execPath;
39
+ if (detectInstallKind(execPath) !== 'binary') return undefined;
40
+ return { version: options.version ?? VERSION, execPath };
41
+ }
@@ -290,7 +290,7 @@ export async function runDaemonServiceCli(input: DaemonServiceCliInput): Promise
290
290
  ok: false,
291
291
  exitCode: 1,
292
292
  lines: [
293
- `service install refused: a service is already installed under the legacy name ${LEGACY_SERVICE_UNIT_NAME}.service.`,
293
+ `service install refused: a service is already installed as ${LEGACY_SERVICE_UNIT_NAME}.service (the unit name the goodvibes install script manages).`,
294
294
  legacyUnitNote(legacy, resolvedName),
295
295
  `Installing this tool's ${resolvedName}.service alongside it risks two daemons competing for the same port.`,
296
296
  ],
@@ -313,10 +313,10 @@ export async function runDaemonServiceCli(input: DaemonServiceCliInput): Promise
313
313
  if (uninstalled.actionError) return failed('uninstall', uninstalled);
314
314
  const extra: string[] = [];
315
315
  if (stopped.actionError) extra.push(`(it may not have been running: ${stopped.actionError})`);
316
- // W3 Finding 4: this command only ever touches the TRACKED unit
317
- // (whatever name/path actually resolved — not necessarily the
318
- // SERVICE_NAME constant) above — say so explicitly when a legacy unit
319
- // also exists, so its continued presence is never a silent surprise.
316
+ // This command only ever touches the TRACKED unit (whatever name/path
317
+ // actually resolved — not necessarily the SERVICE_NAME constant) above —
318
+ // say so explicitly when an install-script unit also exists, so its
319
+ // continued presence is never a silent surprise.
320
320
  if (legacy.present) extra.push(legacyUnitNote(legacy, resolveManagedUnitName(uninstalled)));
321
321
  return ok('uninstall', uninstalled, extra);
322
322
  }
@@ -15,18 +15,30 @@
15
15
  // honest "unpriced" state instead of a silent $0.
16
16
  // ---------------------------------------------------------------------------
17
17
 
18
- import type { CatalogModel } from '@pellux/goodvibes-sdk/platform/providers';
18
+ import { computeUsageCostUsd, type CatalogModel, type ResolvedModelPricing } from '@pellux/goodvibes-sdk/platform/providers';
19
19
 
20
20
  export interface ModelPricing {
21
21
  input: number;
22
22
  output: number;
23
23
  }
24
24
 
25
+ /**
26
+ * Where a resolved price came from, for the render-side source distinction:
27
+ * 'user' = the manual pricing.modelPrices entry ("your price"), 'provider' =
28
+ * served by the provider API, 'catalog' = the live model catalog (dated),
29
+ * 'fallback' = this module's small built-in safety net.
30
+ */
31
+ export type PricingSourceKind = 'user' | 'provider' | 'catalog' | 'fallback';
32
+
25
33
  /** Result of a pricing lookup: the resolved (possibly zero) price, and whether it's real. */
26
34
  export interface PricingResult {
27
35
  pricing: ModelPricing;
28
- /** False when no source (catalog or static fallback) recognized the model — the zero pricing is a placeholder, not a real price. */
36
+ /** False when no source recognized the model — the zero pricing is a placeholder, not a real price. */
29
37
  priced: boolean;
38
+ /** Which source priced the model (present only when priced). */
39
+ source?: PricingSourceKind;
40
+ /** ISO date (YYYY-MM-DD) of the catalog/provider snapshot the price came from, when known. */
41
+ asOf?: string;
30
42
  }
31
43
 
32
44
  // Hand-maintained safety net, used only when the live catalog has no source
@@ -61,7 +73,7 @@ const STATIC_FALLBACK_PRICING: Record<string, ModelPricing> = {
61
73
 
62
74
  // Module-level injection point. Kept as a bare singleton (not threaded through
63
75
  // call sites) because the 9 existing consumers are pure functions with no
64
- // ProviderRegistry in scope; see WO-315 brief for the tradeoff. Tests that
76
+ // ProviderRegistry in scope; see brief for the tradeoff. Tests that
65
77
  // never call setPricingSource() fall through to the static-fallback path,
66
78
  // which keeps them isolated from catalog state by default.
67
79
  let pricingSource: (() => readonly CatalogModel[]) | null = null;
@@ -71,6 +83,34 @@ export function setPricingSource(source: (() => readonly CatalogModel[]) | null)
71
83
  pricingSource = source;
72
84
  }
73
85
 
86
+ // The ONE model pricing resolver (manual pricing.modelPrices -> registration
87
+ // -> provider-served -> catalog -> honest unknown), wired from
88
+ // bootstrap-core.ts as ProviderRegistry.resolveModelPricing once the runtime
89
+ // exists. When set, it is consulted FIRST so a manual price the user sets is
90
+ // visible to every cost surface immediately; the legacy catalog/static chain
91
+ // below remains the fallback for resolver-unknown models and for surfaces
92
+ // (tests, headless) that never wire a registry.
93
+ let modelPricingResolver: ((modelId: string) => ResolvedModelPricing) | null = null;
94
+
95
+ /** Wire (or clear, with null) the registry's resolveModelPricing as the primary pricing source. */
96
+ export function setModelPricingResolver(resolver: ((modelId: string) => ResolvedModelPricing) | null): void {
97
+ modelPricingResolver = resolver;
98
+ }
99
+
100
+ /**
101
+ * One-call bootstrap wiring: the live catalog as the legacy source AND the
102
+ * registry's ONE pricing resolver (manual pricing.modelPrices -> registration
103
+ * -> provider-served -> catalog -> honest unknown) as the primary, so every
104
+ * cost surface sees a manually-set price immediately and can name its source.
105
+ */
106
+ export function wireCostPricing(registry: {
107
+ getRawCatalogModels(): readonly CatalogModel[];
108
+ resolveModelPricing(modelRef: string): ResolvedModelPricing;
109
+ }): void {
110
+ setPricingSource(() => registry.getRawCatalogModels());
111
+ setModelPricingResolver((modelId) => registry.resolveModelPricing(modelId));
112
+ }
113
+
74
114
  function findInCatalog(modelId: string, models: readonly CatalogModel[]): ModelPricing | null {
75
115
  const exact = models.find((m) => m.id === modelId);
76
116
  if (exact) return exact.tier === 'free' ? { input: 0, output: 0 } : exact.pricing;
@@ -97,20 +137,60 @@ function findInStaticFallback(modelId: string): ModelPricing | null {
97
137
  * header for the full resolution order.
98
138
  */
99
139
  export function resolvePricing(modelId: string): PricingResult {
100
- if (modelId.endsWith(':free')) return { pricing: { input: 0, output: 0 }, priced: true };
140
+ if (modelId.endsWith(':free')) return { pricing: { input: 0, output: 0 }, priced: true, source: 'catalog' };
141
+
142
+ // The ONE resolver first: manual price -> registration -> provider-served
143
+ // -> catalog. A 'priced' answer carries its source (and snapshot date when
144
+ // known) so render surfaces can say "your price" vs "catalog price, as of
145
+ // <date>". Unknown/subscription answers fall through to the legacy chain
146
+ // so nothing regresses while the catalog is still loading.
147
+ if (modelPricingResolver) {
148
+ const resolved = modelPricingResolver(modelId);
149
+ if (resolved.status === 'priced') {
150
+ return {
151
+ pricing: { input: resolved.rates.inputPerMTok, output: resolved.rates.outputPerMTok },
152
+ priced: true,
153
+ source: resolved.source,
154
+ ...(resolved.asOf ? { asOf: resolved.asOf } : {}),
155
+ };
156
+ }
157
+ }
101
158
 
102
159
  const models = pricingSource?.() ?? [];
103
160
  const catalogHit = findInCatalog(modelId, models);
104
- if (catalogHit) return { pricing: catalogHit, priced: true };
161
+ if (catalogHit) return { pricing: catalogHit, priced: true, source: 'catalog' };
105
162
 
106
163
  const fallbackHit = findInStaticFallback(modelId);
107
- if (fallbackHit) return { pricing: fallbackHit, priced: true };
164
+ if (fallbackHit) return { pricing: fallbackHit, priced: true, source: 'fallback' };
108
165
 
109
166
  // Genuinely unknown — no source recognizes this model. Report honestly
110
167
  // rather than collapsing into the same zero a free model would return.
111
168
  return { pricing: { input: 0, output: 0 }, priced: false };
112
169
  }
113
170
 
171
+ /**
172
+ * The render-side source distinction for a priced model: "your price" for a
173
+ * manual pricing.modelPrices entry, "catalog price, as of <date>" (or
174
+ * provider-served/built-in equivalents) otherwise. Null when the model is
175
+ * unpriced — callers render the explicit "price unknown" marker instead.
176
+ */
177
+ export function describePricingSource(modelId: string): string | null {
178
+ const result = resolvePricing(modelId);
179
+ if (!result.priced) return null;
180
+ switch (result.source) {
181
+ case 'user':
182
+ return 'your price';
183
+ case 'provider':
184
+ return result.asOf ? `provider price, as of ${result.asOf}` : 'provider price';
185
+ case 'catalog':
186
+ return result.asOf ? `catalog price, as of ${result.asOf}` : 'catalog price';
187
+ case 'fallback':
188
+ return 'built-in price';
189
+ default:
190
+ return 'catalog price';
191
+ }
192
+ }
193
+
114
194
  /**
115
195
  * getPricing — resolve USD-per-1M-token pricing for a model ID.
116
196
  * Back-compat wrapper over resolvePricing(); returns zero for both genuinely
@@ -146,6 +226,20 @@ export function calcSessionCost(
146
226
  cacheWrite: number,
147
227
  modelId: string,
148
228
  ): number {
229
+ // Through the ONE resolver when wired: computeUsageCostUsd applies the
230
+ // source's explicit cache rates (or the published per-provider ratio)
231
+ // instead of billing cache traffic at the full input rate.
232
+ if (modelPricingResolver) {
233
+ const resolved = modelPricingResolver(modelId);
234
+ if (resolved.status === 'priced') {
235
+ return computeUsageCostUsd(resolved, {
236
+ inputTokens,
237
+ outputTokens,
238
+ cacheReadTokens: cacheRead,
239
+ cacheWriteTokens: cacheWrite,
240
+ }) ?? 0;
241
+ }
242
+ }
149
243
  const pricing = getPricing(modelId);
150
244
  // Cache reads/writes count toward billable input side (same convention as panel)
151
245
  const billableInput = inputTokens + cacheRead + cacheWrite;
@@ -195,3 +289,31 @@ export function readBudgetAlertUsd(configGet: (key: string) => unknown): number
195
289
  const parsed = typeof raw === 'number' ? raw : Number(raw);
196
290
  return Number.isFinite(parsed) && parsed >= 0 ? parsed : BUDGET_ALERT_USD_DEFAULT;
197
291
  }
292
+
293
+ /**
294
+ * The SDK settings key holding the user's manual per-model prices — the
295
+ * highest-precedence source in the ONE pricing resolver. Object map of
296
+ * `provider:model` -> { input, output, cacheRead?, cacheWrite? } in USD per
297
+ * 1M tokens.
298
+ */
299
+ export const MODEL_PRICES_CONFIG_KEY = 'pricing.modelPrices';
300
+
301
+ /**
302
+ * Persist a manual price for one `provider:model` key into
303
+ * pricing.modelPrices, preserving every other entry. The registry's resolver
304
+ * reads the key live per call, so the new price is visible to every cost
305
+ * surface on the next render — no restart.
306
+ */
307
+ export function writeManualModelPrice(
308
+ config: BudgetAlertConfigAccess,
309
+ modelKey: string,
310
+ price: { input: number; output: number },
311
+ ): void {
312
+ const current = config.get(MODEL_PRICES_CONFIG_KEY);
313
+ const map: Record<string, unknown> =
314
+ current && typeof current === 'object' && !Array.isArray(current)
315
+ ? { ...(current as Record<string, unknown>) }
316
+ : {};
317
+ map[modelKey] = { input: price.input, output: price.output };
318
+ config.set(MODEL_PRICES_CONFIG_KEY, map);
319
+ }
@@ -19,7 +19,7 @@ export interface AutocompleteState {
19
19
  results: AutocompleteResult[];
20
20
  selectedIndex: number;
21
21
  /**
22
- * UX-C (item 4): how many leading entries of `results` belong to the
22
+ * (item 4): how many leading entries of `results` belong to the
23
23
  * curated "common" tier — only meaningful when `query === ''` (0
24
24
  * otherwise, since a typed filter searches everything and the common/rest
25
25
  * split no longer applies). The renderer uses this to draw a separator
@@ -56,7 +56,7 @@ export class AutocompleteEngine {
56
56
  this.state.query = query;
57
57
  this.state.results = this.registry.fuzzyMatch(query);
58
58
  this.state.active = true;
59
- // UX-C: fuzzyMatch('') sorts the curated common tier first (score 2),
59
+ // fuzzyMatch('') sorts the curated common tier first (score 2),
60
60
  // then the alphabetical rest (score 1) — both tiers alphabetical within
61
61
  // themselves. Count the leading common run to tell the renderer where to
62
62
  // draw the separator. A non-empty query means every command was scored on
@@ -56,7 +56,7 @@ export interface CommandRuntimeState {
56
56
  * Cumulative count of direct terminal writes the output guard intercepted
57
57
  * this session — surfaced by /debug. Optional so the SDK's MutableRuntimeState
58
58
  * (which has no such field) stays assignable; the guard sets it lazily on the
59
- * shared runtime object. (UX-B item 1a.)
59
+ * shared runtime object. (item 1a.)
60
60
  */
61
61
  terminalWritesIntercepted?: number;
62
62
  }
@@ -81,6 +81,8 @@ export interface CommandUiActions {
81
81
  marker?: string;
82
82
  };
83
83
  executeCommand?: (name: string, args: string[]) => Promise<boolean>;
84
+ /** Arm the one-key jump to a spawned CI fix-session (the surface attaches on the jump key; the user never retypes an id). */
85
+ armFixSessionAttach?: (fixSessionId: string) => void;
84
86
  cancelGeneration?: () => void;
85
87
  /** True while an LLM turn is actively streaming. Used to give Escape
86
88
  * cancel-turn precedence over a focused panel's own escape handling. */
@@ -139,7 +141,7 @@ export interface CommandShellUiOpeners {
139
141
  ) => void;
140
142
  openSettingsModal?: (target?: string) => void;
141
143
  /**
142
- * Open a MIGRATE-TO-MODAL surface by name (W6.1 purge skeleton — WO-A/B
144
+ * Open a MIGRATE-TO-MODAL surface by name (purge skeleton — B
143
145
  * command runtimes call this instead of openCommandPanel once their panel
144
146
  * is converted to a ModalFactory config). Threaded from ui-openers.ts the
145
147
  * same way openSettingsModal is.
@@ -157,12 +159,21 @@ export interface CommandShellUiOpeners {
157
159
  */
158
160
  openCommandPalette?: () => void;
159
161
  /**
160
- * Open (and optionally focus) a panel. UX-C focus rule: the command path is
162
+ * Command name -> reference-category label, from the same single source of
163
+ * truth as the generated command reference (categorizeBuiltinCommands),
164
+ * memoized by the shell. Lets registry-driven surfaces built inside command
165
+ * handlers (/help) group by category without importing the aggregation
166
+ * module — a static import there would cycle (commands.ts registers those
167
+ * very handlers).
168
+ */
169
+ getCommandCategories?: () => ReadonlyMap<string, string>;
170
+ /**
171
+ * Open (and optionally focus) a panel. focus rule: the command path is
161
172
  * "the user is mid-command-flow" — opening a panel this way leaves keyboard
162
173
  * focus in the composer by default. Pass `{ focus: true }` for a caller that
163
174
  * genuinely wants to grab focus (chords use panelManager.focusPanels()
164
175
  * directly instead of this method, so no current call site needs it — but
165
- * the intent is explicit rather than implicit here). `target` (DEBT-5) is a
176
+ * the intent is explicit rather than implicit here). `target` is a
166
177
  * fleet deep-link jump target forwarded to PanelManager.open.
167
178
  */
168
179
  showPanel?: (panelId: string, pane?: 'top' | 'bottom', target?: PanelDeepLinkTarget, opts?: { focus?: boolean }) => void;
@@ -207,7 +218,7 @@ export interface CommandSessionServices {
207
218
  * conversation's current message history. Call after a session resume
208
219
  * replays historical messages, before the next render, so the footer's
209
220
  * token counter reflects the resumed session's real usage instead of a
210
- * fresh Orchestrator's zeroed default (W0.9).
221
+ * fresh Orchestrator's zeroed default.
211
222
  */
212
223
  readonly hydrateSessionUsage?: () => void;
213
224
  /** The orchestration engine's command-facing facade — see runtime/workstream-services.ts. */
@@ -221,7 +232,7 @@ export interface CommandSessionServices {
221
232
  /**
222
233
  * The MAIN interactive session's per-turn passive-injection
223
234
  * honesty ring — `Orchestrator.getTurnInjections()`, the main-session
224
- * counterpart to `AgentRecord.turnInjections` (wo801). `/recall injections`
235
+ * counterpart to `AgentRecord.turnInjections`. `/recall injections`
225
236
  * with no agent id reads this. Optional so command contexts built without an
226
237
  * orchestrator (headless, tests) fall back to the honest empty state.
227
238
  */
@@ -262,7 +273,7 @@ export interface CommandWorkspaceServices
262
273
  export interface CommandPlatformConfigServices {
263
274
  readonly config: DeepReadonly<GoodVibesConfig>;
264
275
  readonly configManager: ConfigManager;
265
- /** Feature-flag runtime — backs the `/flags` command's listing and runtime toggling. */
276
+ /** Capability gate runtime — feature states derive from domain settings keys; surfaces read live/pending state here. */
266
277
  readonly featureFlagManager?: FeatureFlagManager;
267
278
  readonly voiceProviderRegistry?: VoiceProviderRegistry;
268
279
  readonly voiceService?: VoiceService;
@@ -325,11 +336,18 @@ export interface CommandContext
325
336
  readonly mcpApi?: McpApi;
326
337
  readonly opsApi?: OpsApi;
327
338
  readonly transport?: DirectTransport;
339
+ /**
340
+ * Durable user-origin permission rules (the persistent form of remembered
341
+ * approvals). The same in-process store the permission evaluator reads, so
342
+ * /permissions rules lists and revokes rules that take effect live. Served
343
+ * by the daemon's permissions.rules.list/.delete verbs to other surfaces.
344
+ */
345
+ readonly userPermissionRuleStore?: import('@pellux/goodvibes-sdk/platform/permissions').UserPermissionRuleStore;
328
346
  };
329
347
  }
330
348
 
331
349
  /**
332
- * UX-C palette curation (item 4): the "common" first tier the slash-command
350
+ * palette curation (item 4): the "common" first tier the slash-command
333
351
  * autocomplete dropdown shows before the alphabetical rest when it opens with
334
352
  * no filter typed yet (bare '/') — the "132-command palette unranked"
335
353
  * evaluator finding. Typed filtering (any non-empty query) is completely
@@ -379,6 +397,13 @@ export class CommandRegistry {
379
397
  * statically by the alias lint test).
380
398
  */
381
399
  register(command: SlashCommand): void {
400
+ // Every registered command is rendered with its description in /help, the
401
+ // command palette, and the generated reference (docs/commands-reference.md).
402
+ // A blank description would ship an unexplained row in all three, so it
403
+ // fails fast here — the release gate red-tests this invariant.
404
+ if (!command.description || command.description.trim().length === 0) {
405
+ throw new Error(`Command registration for "${command.name}" has no description — every command must describe itself.`);
406
+ }
382
407
  const existingByName = this.commands.get(command.name) ?? this.aliasIndex.get(command.name);
383
408
  if (existingByName) {
384
409
  throw new Error(
@@ -447,7 +472,7 @@ export class CommandRegistry {
447
472
  }
448
473
 
449
474
  if (bestScore > 0 || q === '') {
450
- // UX-C: with no query yet, rank the curated common tier (score 2)
475
+ // with no query yet, rank the curated common tier (score 2)
451
476
  // ahead of everything else (score 1) — the tie-break below then sorts
452
477
  // each tier alphabetically, so the result is "common tier, then the
453
478
  // alphabetical rest" rather than one flat alphabetical list.
@@ -155,8 +155,12 @@ export function registerCiRuntimeCommands(registry: CommandRegistry): void {
155
155
  const result = await rpc.sdk.operator.invoke('ci.watches.run', { watchId: runId });
156
156
  const lines = [renderReport(result.report)];
157
157
  lines.push(`notified: ${result.notified ? 'yes' : 'no'}${result.notificationId ? ` (${result.notificationId})` : ''}`);
158
- lines.push(`fix-session triggered: ${result.fixSessionTriggered ? 'yes' : 'no'}${result.fixSessionId ? ` (${result.fixSessionId})` : ''}`);
158
+ lines.push(`fix-session triggered: ${result.fixSessionTriggered ? 'yes' : 'no'}`);
159
159
  ctx.print(lines.join('\n'));
160
+ // Route the spawned session through the shared one-key jump affordance
161
+ // instead of printing the id for the user to retype — same attach flow
162
+ // as the accepted fix-this card.
163
+ if (result.fixSessionId) ctx.armFixSessionAttach?.(result.fixSessionId);
160
164
  } catch (error) {
161
165
  ctx.print(`[ci watches run] ${describeOperatorRpcError(error)}`);
162
166
  }
@@ -3,7 +3,7 @@
3
3
  //
4
4
  // Explicit-query + transparency surface over the repo source-tree code index
5
5
  // (CodeIndexStore, @pellux/goodvibes-sdk/platform/state, landed on SDK main
6
- // as wo802/W5.3 Stage A) via the TUI's own wiring in
6
+ // with explicit-query build/status/search) via the TUI's own wiring in
7
7
  // src/runtime/code-index-services.ts, threaded through
8
8
  // CommandContext.session.codeIndexStore exactly like wrfcController/
9
9
  // workstreamEngine already are (bootstrap-command-context.ts).
@@ -14,8 +14,8 @@
14
14
  // node while it runs), status (counts, skips, degradation state, last
15
15
  // build), search <query> (explicit retrieval, results labeled
16
16
  // 'lexical'|'semantic' honestly — never implied as more precise than they
17
- // are). Stage B (now landed) adds passive auto-injection into coding turns and
18
- // tool-site incremental reindex; `status` surfaces both honestly (the
17
+ // are). Passive auto-injection into coding turns and tool-site incremental
18
+ // reindex followed later; `status` surfaces both honestly (the
19
19
  // auto-injection flag+setting gate, and the last reindex activity).
20
20
  // ---------------------------------------------------------------------------
21
21
 
@@ -35,7 +35,7 @@ function formatBytes(bytes: number): string {
35
35
  }
36
36
 
37
37
  /**
38
- * Honest one-line auto-injection state (Stage B): both the (default-off)
38
+ * Honest one-line auto-injection state: both the (default-off)
39
39
  * `agent-passive-code-injection` feature flag AND storage.codeIndexEnabled must
40
40
  * be on for passive code injection to run — state which is off, and why.
41
41
  */
@@ -49,7 +49,7 @@ function renderAutoInjectionState(flagEnabled: boolean, settingEnabled: boolean)
49
49
  return ` auto-injection: off (${reasons.join('; ')})`;
50
50
  }
51
51
 
52
- /** Honest last-reindex activity line (Stage B tool-site incremental reindex). */
52
+ /** Honest last-reindex activity line (tool-site incremental reindex). */
53
53
  function renderReindexActivity(activity: CodeIndexReindexActivity | null, now: number): string {
54
54
  if (!activity) return ' last reindex: none this session (writes/edits reindex touched files once the index is built)';
55
55
  const agoSec = Math.max(0, Math.round((now - activity.at) / 1000));
@@ -82,7 +82,7 @@ function renderCodeIndexStatus(stats: CodeIndexStats, configManager: Pick<Config
82
82
  lines.push(
83
83
  ` auto-build on startup: ${autoStart ? 'on' : 'off'} (storage.codeIndexEnabled, default off — /config to change)`,
84
84
  );
85
- // Stage B surfacing: passive code auto-injection state (flag + setting) and the last tool-site reindex.
85
+ // Passive code auto-injection state (flag + setting) and the last tool-site reindex.
86
86
  lines.push(renderAutoInjectionState(extras.flagEnabled, autoStart));
87
87
  lines.push(renderReindexActivity(extras.reindexActivity, extras.now ?? Date.now()));
88
88
  lines.push(
@@ -11,7 +11,7 @@ import {
11
11
  } from './cost-attribution-format.ts';
12
12
 
13
13
  /**
14
- * /cost budget <usd> — makes the CostTrackerPanel's budget alert real (WO-139).
14
+ * /cost budget <usd> — makes the CostTrackerPanel's budget alert real.
15
15
  * Opens the cost panel (creating it via the registered factory if not already
16
16
  * open) and sets its budget threshold directly, so the meter+alert already
17
17
  * built into CostTrackerPanel.render() (:266-290) actually fires. 0 disables
@@ -48,7 +48,7 @@ export function registerHealthRuntimeCommands(registry: CommandRegistry): void {
48
48
  const readModels = requireReadModels(ctx);
49
49
 
50
50
  if (sub === 'open' || sub === 'panel' || sub === 'provider') {
51
- ctx.openModal?.('providers-modal'); // W6.1: provider-health panel -> config modal
51
+ ctx.openModal?.('providers-modal'); // provider-health panel -> config modal
52
52
  return;
53
53
  }
54
54
 
@@ -477,7 +477,7 @@ export function registerHealthRuntimeCommands(registry: CommandRegistry): void {
477
477
 
478
478
  ctx.print([
479
479
  'Health Review',
480
- // DEBT-5 item 3: /health stays a cross-domain transcript report (it
480
+ // item 3: /health stays a cross-domain transcript report (it
481
481
  // genuinely spans settings/accounts/auth/sandbox/etc — no single
482
482
  // modal owns this data), but the providers domain DOES have one, so
483
483
  // point at it honestly. "/provider" (no domain) is a DIFFERENT
@@ -55,7 +55,7 @@ export function registerIntelligenceRuntimeCommands(registry: CommandRegistry):
55
55
  async handler(args, ctx) {
56
56
  const sub = (args[0] ?? 'review').toLowerCase();
57
57
  if (sub === 'panel' || sub === 'open') {
58
- // W6.1 (the purge): 'intelligence'/IntelligencePanel was
58
+ // (the purge): 'intelligence'/IntelligencePanel was
59
59
  // DELETE-disposition (no surviving human surface — its read model
60
60
  // stays and still backs every subcommand below). There is no alias
61
61
  // to resolve through, so this prints an honest notice and opens
@@ -10,7 +10,7 @@ export function handleLocalAuthCommand(args: string[], ctx: CommandContext): voi
10
10
  const sub = (args[0] ?? 'review').toLowerCase();
11
11
  const auth = requireLocalUserAuthManager(ctx);
12
12
  if (sub === 'panel' || sub === 'open') {
13
- // W6.1: browse view moved to local-auth-modal. The LocalAuthPanel itself is
13
+ // browse view moved to local-auth-modal. The LocalAuthPanel itself is
14
14
  // kept (masked password-entry host) but is no longer the /local-auth panel
15
15
  // destination — masked entry is reached via add-user/rotate-password below.
16
16
  ctx.openModal?.('local-auth-modal');
@@ -4,29 +4,35 @@ import { writeFile, unlink } from 'node:fs/promises';
4
4
  import type { CommandRegistry } from '../command-registry.ts';
5
5
  import { fetchModelContextWindows } from '@pellux/goodvibes-sdk/platform/discovery';
6
6
  import type { CustomProviderConfig } from '@pellux/goodvibes-sdk/platform/providers';
7
- import { requireProviderApi, requireShellPaths } from './runtime-services.ts';
7
+ import { requireProviderApi, requireSecretsManager, requireShellPaths } from './runtime-services.ts';
8
+ import { ensureProviderKeyThenSelect } from '../provider-key-intake.ts';
8
9
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
9
10
 
10
11
  function isValidProviderName(name: string): boolean {
11
12
  return /^[a-zA-Z0-9_-]+$/.test(name);
12
13
  }
13
14
 
15
+ /** Conventional secrets-store key a custom provider reads its API key from. */
16
+ function customProviderKeyName(providerName: string): string {
17
+ return `${providerName.replace(/[^A-Za-z0-9]/g, '_').toUpperCase()}_API_KEY`;
18
+ }
19
+
14
20
  export function registerLocalProviderRuntimeCommands(registry: CommandRegistry): void {
15
21
  registry.register({
16
22
  name: 'provider',
17
23
  aliases: ['p'],
18
24
  description: 'Switch provider or manage custom providers (add/remove)',
19
- usage: '[add <name> <baseURL> [apiKey] | remove <name> | <provider-name>]',
25
+ usage: '[add <name> <baseURL> | remove <name> | <provider-name>]',
20
26
  argsHint: '[add|remove|name]',
21
27
  async handler(args, ctx) {
22
28
  const shellPaths = requireShellPaths(ctx);
23
29
  if (args[0] === 'add') {
24
30
  const addArgs = args.slice(1);
25
31
  if (addArgs.length < 2) {
26
- ctx.print('Usage: /provider add <name> <baseURL> [apiKey]\nExample: /provider add my-server http://192.168.0.85:8001/v1');
32
+ ctx.print('Usage: /provider add <name> <baseURL>\nExample: /provider add my-server http://192.168.0.85:8001/v1\nAfter the provider is created you can enter its API key with the keystrokes masked (or skip it for keyless endpoints).');
27
33
  return;
28
34
  }
29
- const [name, baseURL, apiKey] = addArgs;
35
+ const [name, baseURL] = addArgs;
30
36
  if (!isValidProviderName(name)) {
31
37
  ctx.print('Error: Provider name must contain only letters, numbers, hyphens, and underscores.');
32
38
  return;
@@ -51,7 +57,6 @@ export function registerLocalProviderRuntimeCommands(registry: CommandRegistry):
51
57
  const controller = new AbortController();
52
58
  const timeoutId = setTimeout(() => controller.abort(), 5000);
53
59
  const headers: Record<string, string> = { 'Content-Type': 'application/json' };
54
- if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
55
60
  const res = await fetch(`${baseURL}/models`, { signal: controller.signal, headers });
56
61
  clearTimeout(timeoutId);
57
62
  if (res.ok) {
@@ -91,12 +96,16 @@ export function registerLocalProviderRuntimeCommands(registry: CommandRegistry):
91
96
  contextWindow: contextWindows[id] ?? 8192,
92
97
  capabilities: { toolCalling: true, codeEditing: true, reasoning: false, multimodal: false },
93
98
  }));
99
+ // The key is never embedded in the provider JSON. The config references
100
+ // it by name (apiKeyEnv) and the actual value lands in the secrets
101
+ // manager below, resolved live at request time.
102
+ const keyName = customProviderKeyName(name);
94
103
  const config: CustomProviderConfig = {
95
104
  name,
96
105
  displayName: name,
97
106
  type: 'openai-compat',
98
107
  baseURL,
99
- ...(apiKey ? { apiKey } : {}),
108
+ apiKeyEnv: keyName,
100
109
  models,
101
110
  };
102
111
  try {
@@ -107,6 +116,32 @@ export function registerLocalProviderRuntimeCommands(registry: CommandRegistry):
107
116
  return;
108
117
  }
109
118
  ctx.print(`Provider '${name}' added with ${models.length} model(s):\n${discoveredModelIds.length > 0 ? discoveredModelIds.map((id) => ` • ${id} (${(contextWindows[id] ?? 8192).toLocaleString()} ctx)`).join('\n') : ` • ${defaultModel} (starter entry)`}\nThe file watcher will auto-register it shortly.`);
119
+ // Capture the key with the keystrokes masked, straight into the secrets
120
+ // manager — never a cleartext argument, never written into the JSON.
121
+ if (ctx.beginConcealedInput) {
122
+ const secretsManager = requireSecretsManager(ctx);
123
+ ctx.print(`Enter the API key for provider '${name}' (input is masked; press Enter to store, Esc to skip if the endpoint needs no key).`);
124
+ ctx.beginConcealedInput({
125
+ label: name,
126
+ onSubmit: (value) => {
127
+ if (value.length === 0) {
128
+ ctx.print(`No key stored for '${name}' (endpoint used without a key).`);
129
+ return;
130
+ }
131
+ void (async () => {
132
+ try {
133
+ await secretsManager.set(keyName, value, { scope: 'user', medium: 'secure' });
134
+ ctx.print(`Stored the API key for '${name}' (value hidden). It resolves live for this provider — no restart needed.`);
135
+ } catch (error) {
136
+ ctx.print(`Could not store the key for '${name}': ${summarizeError(error)}`);
137
+ }
138
+ })();
139
+ },
140
+ onCancel: () => {
141
+ ctx.print(`No key stored for '${name}' (endpoint used without a key).`);
142
+ },
143
+ });
144
+ }
110
145
  return;
111
146
  }
112
147
 
@@ -157,10 +192,14 @@ export function registerLocalProviderRuntimeCommands(registry: CommandRegistry):
157
192
  }
158
193
  try {
159
194
  const selected = await providerApi.selectModel(match.registryKey);
160
- ctx.session.runtime.model = selected.registryKey;
161
- ctx.session.runtime.provider = selected.providerId;
162
- ctx.platform.configManager.set('provider.model', selected.registryKey);
163
- ctx.print(`Switched to provider: ${selected.providerId} (model: ${selected.modelId})`);
195
+ // Unconfigured provider selected → capture its key inline, then finish
196
+ // the switch so it lands ready to chat.
197
+ ensureProviderKeyThenSelect(ctx, selected.providerId, () => {
198
+ ctx.session.runtime.model = selected.registryKey;
199
+ ctx.session.runtime.provider = selected.providerId;
200
+ ctx.platform.configManager.set('provider.model', selected.registryKey);
201
+ ctx.print(`Switched to provider: ${selected.providerId} (model: ${selected.modelId})`);
202
+ });
164
203
  } catch (e) {
165
204
  ctx.print(`Error: ${summarizeError(e)}`);
166
205
  }