@pellux/goodvibes-tui 1.16.1 → 1.18.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 (257) hide show
  1. package/CHANGELOG.md +138 -9
  2. package/README.md +1 -1
  3. package/docs/foundation-artifacts/operator-contract.json +5490 -1806
  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/management-commands.ts +82 -15
  12. package/src/cli/management.ts +0 -2
  13. package/src/cli/parser.ts +5 -0
  14. package/src/cli/status.ts +13 -0
  15. package/src/cli/surface-command.ts +5 -5
  16. package/src/cli/tui-startup.ts +60 -97
  17. package/src/cli/types.ts +2 -0
  18. package/src/config/index.ts +1 -1
  19. package/src/config/tui-extension-settings.ts +33 -0
  20. package/src/core/composer-state.ts +1 -1
  21. package/src/core/consolidation-receipt.ts +41 -0
  22. package/src/core/conversation-line-cache.ts +3 -3
  23. package/src/core/conversation-rendering.ts +18 -9
  24. package/src/core/conversation.ts +2 -2
  25. package/src/core/long-task-notifier.ts +7 -7
  26. package/src/core/memory-provenance.ts +67 -0
  27. package/src/core/pairing-devices.ts +49 -0
  28. package/src/core/pairing-handoff.ts +101 -0
  29. package/src/core/pairing-offers.ts +83 -0
  30. package/src/core/pairing-origin.ts +78 -0
  31. package/src/core/pairing-tailscale-gateway.ts +56 -0
  32. package/src/core/power-chip-source.ts +91 -0
  33. package/src/core/power-status.ts +95 -0
  34. package/src/core/stable-host.ts +171 -0
  35. package/src/core/stream-event-wiring.ts +29 -6
  36. package/src/core/system-message-noise.ts +1 -1
  37. package/src/core/system-message-router.ts +6 -6
  38. package/src/core/turn-budget-outcome.ts +60 -0
  39. package/src/core/turn-cancellation.ts +40 -1
  40. package/src/core/turn-event-wiring.ts +14 -8
  41. package/src/daemon/cli.ts +64 -58
  42. package/src/daemon/lifecycle.ts +41 -0
  43. package/src/daemon/service-commands.ts +5 -5
  44. package/src/export/cost-utils.ts +128 -6
  45. package/src/input/autocomplete.ts +2 -2
  46. package/src/input/command-registry.ts +55 -9
  47. package/src/input/commands/acp-runtime.ts +72 -0
  48. package/src/input/commands/ci-runtime.ts +5 -1
  49. package/src/input/commands/codebase-runtime.ts +6 -6
  50. package/src/input/commands/cost-runtime.ts +1 -1
  51. package/src/input/commands/devices-runtime.ts +109 -0
  52. package/src/input/commands/git-runtime.ts +28 -4
  53. package/src/input/commands/graph-runtime.ts +45 -0
  54. package/src/input/commands/health-runtime.ts +2 -2
  55. package/src/input/commands/intelligence-runtime.ts +1 -1
  56. package/src/input/commands/local-auth-runtime.ts +1 -1
  57. package/src/input/commands/local-provider-runtime.ts +49 -10
  58. package/src/input/commands/local-runtime.ts +53 -5
  59. package/src/input/commands/memory.ts +2 -2
  60. package/src/input/commands/operator-panel-runtime.ts +5 -5
  61. package/src/input/commands/operator-rpc.ts +32 -11
  62. package/src/input/commands/permissions-provenance.ts +1 -1
  63. package/src/input/commands/permissions-runtime.ts +99 -8
  64. package/src/input/commands/planning-runtime.ts +3 -3
  65. package/src/input/commands/platform-sandbox-runtime.ts +1 -1
  66. package/src/input/commands/power-runtime.ts +56 -0
  67. package/src/input/commands/provider-accounts-runtime.ts +71 -6
  68. package/src/input/commands/provider.ts +7 -5
  69. package/src/input/commands/qrcode-runtime.ts +14 -19
  70. package/src/input/commands/queue-runtime.ts +70 -0
  71. package/src/input/commands/recall-files-git.ts +13 -51
  72. package/src/input/commands/recall-files.ts +8 -11
  73. package/src/input/commands/recall-review.ts +2 -2
  74. package/src/input/commands/relay-runtime.ts +1 -1
  75. package/src/input/commands/remote-runtime.ts +1 -1
  76. package/src/input/commands/runtime-services.ts +4 -4
  77. package/src/input/commands/schedule-runtime.ts +14 -7
  78. package/src/input/commands/services-runtime.ts +1 -1
  79. package/src/input/commands/session-workflow.ts +2 -2
  80. package/src/input/commands/settings-sync-runtime.ts +2 -2
  81. package/src/input/commands/share-runtime.ts +1 -1
  82. package/src/input/commands/shell-core.ts +46 -68
  83. package/src/input/commands/subscription-runtime.ts +1 -1
  84. package/src/input/commands/update-runtime.ts +153 -96
  85. package/src/input/commands/work-plan-runtime.ts +1 -1
  86. package/src/input/commands/workstream-attempts.ts +6 -1
  87. package/src/input/commands/workstream-runtime.ts +46 -27
  88. package/src/input/commands/worktree-runtime.ts +35 -8
  89. package/src/input/commands.ts +10 -2
  90. package/src/input/config-modal-types.ts +14 -3
  91. package/src/input/config-modal.ts +75 -13
  92. package/src/input/feature-unit-layout.ts +93 -82
  93. package/src/input/feed-context-factory.ts +2 -2
  94. package/src/input/handler-command-route.ts +2 -2
  95. package/src/input/handler-content-actions.ts +1 -1
  96. package/src/input/handler-feed-routes.ts +7 -7
  97. package/src/input/handler-feed.ts +4 -4
  98. package/src/input/handler-interactions.ts +3 -2
  99. package/src/input/handler-modal-routes.ts +12 -4
  100. package/src/input/handler-modal-token-routes.ts +1 -1
  101. package/src/input/handler-onboarding-daemon-adopt.ts +2 -2
  102. package/src/input/handler-onboarding.ts +3 -2
  103. package/src/input/handler-picker-routes.ts +26 -13
  104. package/src/input/handler-shortcuts.ts +29 -6
  105. package/src/input/handler.ts +2 -2
  106. package/src/input/keybindings.ts +21 -3
  107. package/src/input/onboarding/onboarding-feature-units.ts +34 -27
  108. package/src/input/onboarding/onboarding-provider-key-fields.ts +112 -0
  109. package/src/input/onboarding/onboarding-wizard-apply.ts +22 -21
  110. package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +17 -0
  111. package/src/input/onboarding/onboarding-wizard-network-adopt.ts +2 -2
  112. package/src/input/onboarding/onboarding-wizard-steps.ts +30 -42
  113. package/src/input/panel-integration-actions.ts +2 -2
  114. package/src/input/panel-mouse-geometry.ts +1 -1
  115. package/src/input/panel-paste-flood-guard.ts +1 -1
  116. package/src/input/provider-key-intake.ts +167 -0
  117. package/src/input/sandbox-exec-config.ts +2 -2
  118. package/src/input/selection-modal.ts +1 -1
  119. package/src/input/settings-modal-activation.ts +5 -0
  120. package/src/input/settings-modal-adjustment.ts +4 -31
  121. package/src/input/settings-modal-data.ts +52 -57
  122. package/src/input/settings-modal-mutations.ts +12 -68
  123. package/src/input/settings-modal-types.ts +27 -17
  124. package/src/input/settings-modal.ts +74 -55
  125. package/src/main.ts +90 -90
  126. package/src/panels/agent-inspector-shared.ts +4 -4
  127. package/src/panels/base-panel.ts +2 -2
  128. package/src/panels/builtin/agent.ts +4 -4
  129. package/src/panels/builtin/development.ts +4 -4
  130. package/src/panels/builtin/knowledge.ts +1 -1
  131. package/src/panels/builtin/operations.ts +83 -11
  132. package/src/panels/builtin/session.ts +5 -5
  133. package/src/panels/builtin/shared.ts +16 -4
  134. package/src/panels/builtin-modals.ts +59 -42
  135. package/src/panels/builtin-panels.ts +1 -1
  136. package/src/panels/cost-tracker-panel.ts +124 -7
  137. package/src/panels/diff-panel.ts +39 -6
  138. package/src/panels/eval-registry.ts +2 -2
  139. package/src/panels/fleet-acts.ts +446 -0
  140. package/src/panels/fleet-deep-link.ts +1 -1
  141. package/src/panels/fleet-gateway.ts +117 -0
  142. package/src/panels/fleet-observed-render.ts +128 -0
  143. package/src/panels/fleet-panel-format.ts +163 -11
  144. package/src/panels/fleet-panel-worktree-detail.ts +14 -0
  145. package/src/panels/fleet-panel.ts +73 -57
  146. package/src/panels/fleet-read-model.ts +84 -14
  147. package/src/panels/fleet-spawn.ts +266 -0
  148. package/src/panels/fleet-steer.ts +2 -2
  149. package/src/panels/fleet-stop.ts +41 -5
  150. package/src/panels/fleet-tabs.ts +15 -16
  151. package/src/panels/fleet-transcript.ts +5 -5
  152. package/src/panels/git-panel.ts +2 -2
  153. package/src/panels/index.ts +4 -1
  154. package/src/panels/local-auth-panel.ts +1 -1
  155. package/src/panels/memory-consolidation-gateway.ts +94 -0
  156. package/src/panels/modals/devices-modal.ts +161 -0
  157. package/src/panels/modals/hooks-modal.ts +1 -1
  158. package/src/panels/modals/keybindings-modal.ts +2 -2
  159. package/src/panels/modals/knowledge-modal.ts +1 -1
  160. package/src/panels/modals/marketplace-modal.ts +4 -4
  161. package/src/panels/modals/memory-modal.ts +153 -9
  162. package/src/panels/modals/modal-surface-helpers.ts +1 -1
  163. package/src/panels/modals/modal-theme.ts +1 -1
  164. package/src/panels/modals/pairing-modal.ts +198 -68
  165. package/src/panels/modals/planning-modal.ts +2 -2
  166. package/src/panels/modals/plugins-modal.ts +1 -1
  167. package/src/panels/modals/policy-modal.ts +1 -1
  168. package/src/panels/modals/security-modal.ts +7 -1
  169. package/src/panels/modals/skills-modal.ts +1 -1
  170. package/src/panels/modals/work-plan-modal.ts +3 -3
  171. package/src/panels/notifications-feed.ts +127 -0
  172. package/src/panels/notifications-panel.ts +160 -0
  173. package/src/panels/panel-manager.ts +9 -9
  174. package/src/panels/plugins-panel.ts +4 -4
  175. package/src/panels/project-planning-answer-actions.ts +1 -1
  176. package/src/panels/provider-health-routes.ts +2 -2
  177. package/src/panels/skills-panel.ts +4 -5
  178. package/src/panels/token-budget-panel.ts +11 -3
  179. package/src/panels/types.ts +5 -5
  180. package/src/panels/workstream-graph-render.ts +123 -0
  181. package/src/permissions/broker-approval-card.ts +201 -0
  182. package/src/permissions/hunk-selection.ts +28 -4
  183. package/src/permissions/prompt.ts +244 -29
  184. package/src/renderer/autocomplete-overlay.ts +107 -54
  185. package/src/renderer/code-block.ts +2 -2
  186. package/src/renderer/compaction-history-modal.ts +1 -1
  187. package/src/renderer/compaction-preview.ts +1 -1
  188. package/src/renderer/compaction-quality.ts +1 -1
  189. package/src/renderer/composer-fragments.ts +69 -0
  190. package/src/renderer/compositor.ts +2 -2
  191. package/src/renderer/config-modal.ts +1 -1
  192. package/src/renderer/conversation-overlays.ts +1 -1
  193. package/src/renderer/footer-tips.ts +3 -3
  194. package/src/renderer/hint-grammar.ts +1 -1
  195. package/src/renderer/modal-factory.ts +1 -1
  196. package/src/renderer/model-picker-overlay.ts +1 -1
  197. package/src/renderer/model-workspace.ts +7 -2
  198. package/src/renderer/panel-composite.ts +2 -2
  199. package/src/renderer/panel-workspace-bar.ts +1 -1
  200. package/src/renderer/process-indicator.ts +1 -1
  201. package/src/renderer/selection-modal-overlay.ts +1 -1
  202. package/src/renderer/session-picker-modal.ts +2 -2
  203. package/src/renderer/settings-modal-helpers.ts +23 -5
  204. package/src/renderer/settings-modal.ts +129 -39
  205. package/src/renderer/shell-surface.ts +6 -0
  206. package/src/renderer/terminal-bg-probe.ts +1 -1
  207. package/src/renderer/theme.ts +3 -3
  208. package/src/renderer/tool-call.ts +13 -7
  209. package/src/renderer/tool-result-summary.ts +1 -1
  210. package/src/renderer/turn-injection.ts +7 -6
  211. package/src/renderer/ui-factory.ts +38 -1
  212. package/src/runtime/bootstrap-command-context.ts +7 -2
  213. package/src/runtime/bootstrap-command-parts.ts +9 -5
  214. package/src/runtime/bootstrap-core.ts +41 -41
  215. package/src/runtime/bootstrap-hook-bridge.ts +1 -1
  216. package/src/runtime/bootstrap-shell.ts +13 -5
  217. package/src/runtime/bootstrap.ts +34 -32
  218. package/src/runtime/channel-composition.ts +46 -0
  219. package/src/runtime/code-index-services.ts +3 -4
  220. package/src/runtime/daemon-attach-notices.ts +120 -0
  221. package/src/runtime/durability-services.ts +90 -0
  222. package/src/runtime/embedded-service-factories.ts +40 -0
  223. package/src/runtime/feature-settings.ts +147 -0
  224. package/src/runtime/fleet-services.ts +41 -0
  225. package/src/runtime/idle-power-services.ts +85 -0
  226. package/src/runtime/index.ts +6 -21
  227. package/src/runtime/interaction-seams.ts +109 -0
  228. package/src/runtime/legacy-daemon-migration.ts +29 -19
  229. package/src/runtime/memory-fold.ts +1 -1
  230. package/src/runtime/notification-dispatch.ts +110 -0
  231. package/src/runtime/onboarding/apply.ts +3 -29
  232. package/src/runtime/onboarding/fast-path.ts +39 -6
  233. package/src/runtime/onboarding/provider-key-capture.ts +126 -0
  234. package/src/runtime/onboarding/snapshot.ts +0 -1
  235. package/src/runtime/onboarding/types.ts +9 -3
  236. package/src/runtime/power-keepawake-remote.ts +75 -0
  237. package/src/runtime/process-lifecycle.ts +3 -1
  238. package/src/runtime/provider-fallback.ts +55 -0
  239. package/src/runtime/provider-optimizer-wiring.ts +55 -0
  240. package/src/runtime/release-artifacts.ts +19 -103
  241. package/src/runtime/resume-notice.ts +1 -1
  242. package/src/runtime/services.ts +113 -113
  243. package/src/runtime/session-continuity-hints.ts +38 -0
  244. package/src/runtime/surface-feature-flags.ts +40 -9
  245. package/src/runtime/terminal-output-guard.ts +2 -2
  246. package/src/runtime/trust/workspace-registration.ts +10 -2
  247. package/src/runtime/trust/workspace-trust.ts +58 -31
  248. package/src/runtime/ui-services.ts +6 -1
  249. package/src/runtime/update-check.ts +15 -69
  250. package/src/runtime/workstream-services.ts +2 -2
  251. package/src/shell/blocking-input.ts +108 -132
  252. package/src/shell/recovery-input-helpers.ts +53 -166
  253. package/src/shell/ui-openers.ts +38 -6
  254. package/src/utils/format-duration.ts +1 -1
  255. package/src/verification/verification-ledger.ts +21 -4
  256. package/src/version.ts +1 -1
  257. package/src/input/commands/flags-runtime.ts +0 -328
package/src/daemon/cli.ts CHANGED
@@ -1,25 +1,26 @@
1
- import { homedir, networkInterfaces } from 'node:os';
1
+ import { homedir } from 'node:os';
2
2
  import { join } from 'node:path';
3
- import { readFileSync } from 'node:fs';
4
3
  import { ConfigManager, resolveDaemonEnabled } from '@pellux/goodvibes-sdk/platform/config';
5
4
  import { formatProviderModel, getModelIdFromProviderModel, getProviderIdFromModel } from '../config/provider-model.ts';
6
5
  import { RuntimeEventBus, GlobalNetworkTransportInstaller } from '@/runtime/index.ts';
7
- import { createFeatureFlagManager } from '@/runtime/index.ts';
8
- import type { FlagState } from '@/runtime/index.ts';
6
+ import { bindFeatureSettingsBridge, createFeatureFlagManager, deriveFeatureStates } from '@/runtime/index.ts';
9
7
  import { createRuntimeStore } from '../runtime/store/index.ts';
10
8
  import { createRuntimeServices } from '../runtime/services.ts';
11
9
  import { DaemonServer, HttpListener } from '@pellux/goodvibes-sdk/platform/daemon';
10
+ import { createHostPowerSeam } from '@pellux/goodvibes-sdk/platform/power';
12
11
  import { logger } from '@pellux/goodvibes-sdk/platform/utils';
13
12
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
14
13
  import {
15
14
  getOrCreateCompanionToken,
16
15
  pruneStaleOperatorTokens,
17
- buildCompanionConnectionInfo,
18
- encodeConnectionPayload,
19
- formatConnectionBlock,
16
+ buildPairingHandoffLink,
17
+ describeOriginPosture,
20
18
  generateQrMatrix,
21
19
  renderQrToString,
22
20
  } from '@pellux/goodvibes-sdk/platform/pairing';
21
+ import { ensurePublicBaseUrl } from '../core/pairing-origin.ts';
22
+ import { availablePairingOffers } from '../core/pairing-handoff.ts';
23
+ import { formatPairingOffers, formatPostureCapabilities, pairingPostureNotice } from '../core/pairing-offers.ts';
23
24
  import { workspaceOperatorTokenCandidates } from '../runtime/operator-token-cleanup.ts';
24
25
  import {
25
26
  scan,
@@ -28,6 +29,8 @@ import {
28
29
  } from '@pellux/goodvibes-sdk/platform/discovery';
29
30
  import { createSafeHostServeFactory } from './safe-serve.ts';
30
31
  import { isDaemonServiceSubcommand, resolveInstalledDaemonBinary, runDaemonServiceCli } from './service-commands.ts';
32
+ import { resolveDaemonUpdateArtifact } from './lifecycle.ts';
33
+ import { VERSION } from '../version.ts';
31
34
 
32
35
  import {
33
36
  parseGoodVibesCli,
@@ -50,36 +53,6 @@ type DaemonCliTokens = {
50
53
  readonly httpToken: string | undefined;
51
54
  };
52
55
 
53
- function getLocalNetworkIp(): string {
54
- try {
55
- const nets = networkInterfaces();
56
- for (const name of Object.keys(nets)) {
57
- for (const net of nets[name] ?? []) {
58
- if (net.family === 'IPv4' && !net.internal) {
59
- return net.address;
60
- }
61
- }
62
- }
63
- } catch {
64
- return 'localhost';
65
- }
66
- return 'localhost';
67
- }
68
-
69
- function readBootstrapPassword(credentialPath: string): string | undefined {
70
- try {
71
- const content = readFileSync(credentialPath, 'utf-8');
72
- for (const line of content.split('\n')) {
73
- if (line.startsWith('password=')) {
74
- return line.slice('password='.length).trim();
75
- }
76
- }
77
- } catch {
78
- // credential file may not exist yet
79
- }
80
- return undefined;
81
- }
82
-
83
56
  function resolveDaemonCliOwnership(): DaemonCliOwnership {
84
57
  return {
85
58
  workingDirectory: process.env['GOODVIBES_WORKING_DIR'] ?? process.cwd(),
@@ -190,10 +163,11 @@ async function main(): Promise<void> {
190
163
 
191
164
  const runtimeBus = new RuntimeEventBus();
192
165
  const runtimeStore = createRuntimeStore();
166
+ // Gate states derive from the domain settings keys; the bridge keeps live
167
+ // config changes flowing into the manager for the daemon's lifetime.
193
168
  const featureFlags = createFeatureFlagManager();
194
- featureFlags.loadFromConfig({
195
- flags: (config.getCategory('featureFlags') as Record<string, FlagState>) ?? {},
196
- });
169
+ featureFlags.loadFromConfig({ flags: deriveFeatureStates(config) });
170
+ bindFeatureSettingsBridge(config, featureFlags);
197
171
  const runtimeServices = createRuntimeServices({
198
172
  configManager: config,
199
173
  featureFlags,
@@ -202,6 +176,17 @@ async function main(): Promise<void> {
202
176
  getConversationTitle: () => 'goodvibes daemon',
203
177
  workingDir,
204
178
  homeDirectory,
179
+ // The standalone daemon observes externally-launched coding-agent sessions
180
+ // on the host read-only (fleet visibility + steer; never counted, never
181
+ // stopped). Daemon-side only — the interactive process reads this snapshot
182
+ // rather than double-detecting. Mirrors the SDK daemon cli.
183
+ observeExternalAgents: true,
184
+ // Opt into the REAL host power seam (Linux logind: systemd-inhibit children
185
+ // + the dbus-monitor sleep-edge watcher) so the standalone daemon holds live
186
+ // keep-awake/idle-inhibit. SDK 1.9.0's runtime-services factory defaults to
187
+ // the non-spawning unavailable seam; only daemon compositions opt in. Mirrors
188
+ // the SDK daemon cli's createHostPowerSeam() (sdk commit 3a5ea26d).
189
+ powerSeam: createHostPowerSeam(),
205
190
  });
206
191
 
207
192
  // Load persisted providers from disk so the provider registry is pre-populated
@@ -233,6 +218,11 @@ async function main(): Promise<void> {
233
218
  userAuth,
234
219
  runtimeServices,
235
220
  serveFactory: createSafeHostServeFactory('Standalone daemon'),
221
+ // Hand the facade this binary's real version + exec path so its self-update
222
+ // lifecycle compares the HOST's version (not the sdk package's). Resolves to
223
+ // undefined for a non-binary install, so a dev run stays host-managed and
224
+ // never swaps the interpreter. See lifecycle.ts.
225
+ updateArtifact: resolveDaemonUpdateArtifact({ version: VERSION }),
236
226
  });
237
227
  const listener = new HttpListener({
238
228
  hookDispatcher: runtimeServices.hookDispatcher,
@@ -268,6 +258,11 @@ async function main(): Promise<void> {
268
258
  config.get('danger.httpListener') ? listener.start() : Promise.resolve(),
269
259
  ]);
270
260
 
261
+ // The DaemonServer facade owns the hourly self-update loop now (fed this
262
+ // binary's version + exec path via updateArtifact above); it checks hourly,
263
+ // swaps only at an idle moment, keeps the outgoing binary at `<path>.previous`,
264
+ // and stops with the daemon. No separate host loop.
265
+
271
266
  const abortController = new AbortController();
272
267
 
273
268
  const shutdown = async (): Promise<void> => {
@@ -300,26 +295,37 @@ async function main(): Promise<void> {
300
295
  httpListener: config.get('danger.httpListener'),
301
296
  });
302
297
 
303
- // Print companion connection info + QR code to stdout.
304
- // Use the config-driven control plane port, not a hardcoded default.
305
- const daemonPort = config.get('controlPlane.port');
306
- const configuredDaemonHost = String(process.env.GOODVIBES_DAEMON_HOST ?? getLocalNetworkIp());
307
- const daemonHost = configuredDaemonHost === '0.0.0.0' || configuredDaemonHost === '::'
308
- ? getLocalNetworkIp()
309
- : configuredDaemonHost;
310
- const daemonUrl = `http://${daemonHost}:${daemonPort}`;
311
- const bootstrapPassword = readBootstrapPassword(userAuth.getBootstrapCredentialPath());
312
- const connectionInfo = buildCompanionConnectionInfo({
313
- daemonUrl,
314
- token: companionTokenRecord.token,
315
- password: bootstrapPassword,
316
- surface: 'tui',
298
+ // Print a device-pairing QR to stdout. The QR encodes the canonical
299
+ // `#pair=<token>` deep link the web app consumes — a camera scan opens it
300
+ // already signed in. No raw JSON connection blob is printed. This is also the
301
+ // one place web.publicBaseUrl is frozen from the stable-name resolution (never
302
+ // clobbering a user-set value), so the printed origin survives a DHCP change.
303
+ // The daemon's shared companion token rides as the pairing token; a device can
304
+ // later migrate to its own per-device token from /settings → security → devices.
305
+ const webOrigin = ensurePublicBaseUrl(config);
306
+ const offers = availablePairingOffers({
307
+ relayEnabled: config.get('relay.enabled') === true,
308
+ stepUpAvailable: true,
317
309
  });
318
- const payload = encodeConnectionPayload(connectionInfo);
319
- const qrMatrix = generateQrMatrix(payload);
320
- const qrString = renderQrToString(qrMatrix);
310
+ const deepLink = buildPairingHandoffLink({ webOrigin: webOrigin.origin, token: companionTokenRecord.token, offers });
311
+ const qrString = renderQrToString(generateQrMatrix(deepLink));
312
+ // The banner renders the SAME SDK posture the pairing verb carries: the labeled
313
+ // capability list, and the one honest LAN line only when the posture holds it.
314
+ const posture = describeOriginPosture(webOrigin.origin);
315
+ const capabilities = formatPostureCapabilities(posture);
316
+ const notice = pairingPostureNotice(posture);
317
+ const bannerLines = [
318
+ `GoodVibes daemon ${VERSION} — scan to pair a device (opens the web app signed in):`,
319
+ '',
320
+ ` ${webOrigin.origin}`,
321
+ '',
322
+ ...(offers.length > 0 ? ['Offers (each declinable in the web app):', ...formatPairingOffers(offers), ''] : []),
323
+ ...(capabilities.length > 0 ? ['This device will get:', ...capabilities, ''] : []),
324
+ ...(notice ? [notice, ''] : []),
325
+ qrString,
326
+ ];
321
327
  // eslint-disable-next-line no-console
322
- console.log(formatConnectionBlock(connectionInfo, qrString));
328
+ console.log(bannerLines.join('\n'));
323
329
  }
324
330
 
325
331
  void main().catch(async (error) => {
@@ -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,7 +81,28 @@ 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;
87
+ /**
88
+ * Cancel JUST the currently-running tool call (the live transcript row),
89
+ * leaving the turn to continue — the local-session equivalent of the
90
+ * sessions.toolCalls.cancel wire verb over the in-process orchestrator.
91
+ * Returns true when a running call was found and cancelled.
92
+ */
93
+ cancelToolCall?: () => boolean;
94
+ /** The still-undelivered mid-turn messages, in delivery order (the /queue editable list). */
95
+ listQueuedMessages?: () => ReadonlyArray<{ id: string; queuedAt: number; text: string }>;
96
+ /** Replace a still-queued message's text; false once delivered (immutable). */
97
+ editQueuedMessage?: (id: string, text: string) => boolean;
98
+ /** Remove a still-queued message before delivery; false once delivered. */
99
+ deleteQueuedMessage?: (id: string) => boolean;
100
+ /** Toggle the drill-in of the "used N memories" provenance chip (memory.showProvenance). */
101
+ toggleMemoryProvenance?: () => void;
102
+ /** The current host sleep-ownership projection (power.status.get, flattened) — chip, held-because, lid-split. */
103
+ getPowerState?: () => import('../core/power-status.ts').PowerSurfaceState;
104
+ /** Set the daemon-held keep-awake toggle (power.keepAwake.set), acquiring/releasing the inhibitor live; returns the new state. */
105
+ setKeepAwake?: (enabled: boolean) => Promise<import('../core/power-status.ts').PowerSurfaceState>;
85
106
  /** True while an LLM turn is actively streaming. Used to give Escape
86
107
  * cancel-turn precedence over a focused panel's own escape handling. */
87
108
  isGenerating?: () => boolean;
@@ -139,7 +160,7 @@ export interface CommandShellUiOpeners {
139
160
  ) => void;
140
161
  openSettingsModal?: (target?: string) => void;
141
162
  /**
142
- * Open a MIGRATE-TO-MODAL surface by name (W6.1 purge skeleton — WO-A/B
163
+ * Open a MIGRATE-TO-MODAL surface by name (purge skeleton — B
143
164
  * command runtimes call this instead of openCommandPanel once their panel
144
165
  * is converted to a ModalFactory config). Threaded from ui-openers.ts the
145
166
  * same way openSettingsModal is.
@@ -157,12 +178,21 @@ export interface CommandShellUiOpeners {
157
178
  */
158
179
  openCommandPalette?: () => void;
159
180
  /**
160
- * Open (and optionally focus) a panel. UX-C focus rule: the command path is
181
+ * Command name -> reference-category label, from the same single source of
182
+ * truth as the generated command reference (categorizeBuiltinCommands),
183
+ * memoized by the shell. Lets registry-driven surfaces built inside command
184
+ * handlers (/help) group by category without importing the aggregation
185
+ * module — a static import there would cycle (commands.ts registers those
186
+ * very handlers).
187
+ */
188
+ getCommandCategories?: () => ReadonlyMap<string, string>;
189
+ /**
190
+ * Open (and optionally focus) a panel. focus rule: the command path is
161
191
  * "the user is mid-command-flow" — opening a panel this way leaves keyboard
162
192
  * focus in the composer by default. Pass `{ focus: true }` for a caller that
163
193
  * genuinely wants to grab focus (chords use panelManager.focusPanels()
164
194
  * 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
195
+ * the intent is explicit rather than implicit here). `target` is a
166
196
  * fleet deep-link jump target forwarded to PanelManager.open.
167
197
  */
168
198
  showPanel?: (panelId: string, pane?: 'top' | 'bottom', target?: PanelDeepLinkTarget, opts?: { focus?: boolean }) => void;
@@ -207,7 +237,7 @@ export interface CommandSessionServices {
207
237
  * conversation's current message history. Call after a session resume
208
238
  * replays historical messages, before the next render, so the footer's
209
239
  * token counter reflects the resumed session's real usage instead of a
210
- * fresh Orchestrator's zeroed default (W0.9).
240
+ * fresh Orchestrator's zeroed default.
211
241
  */
212
242
  readonly hydrateSessionUsage?: () => void;
213
243
  /** The orchestration engine's command-facing facade — see runtime/workstream-services.ts. */
@@ -221,7 +251,7 @@ export interface CommandSessionServices {
221
251
  /**
222
252
  * The MAIN interactive session's per-turn passive-injection
223
253
  * honesty ring — `Orchestrator.getTurnInjections()`, the main-session
224
- * counterpart to `AgentRecord.turnInjections` (wo801). `/recall injections`
254
+ * counterpart to `AgentRecord.turnInjections`. `/recall injections`
225
255
  * with no agent id reads this. Optional so command contexts built without an
226
256
  * orchestrator (headless, tests) fall back to the honest empty state.
227
257
  */
@@ -262,7 +292,9 @@ export interface CommandWorkspaceServices
262
292
  export interface CommandPlatformConfigServices {
263
293
  readonly config: DeepReadonly<GoodVibesConfig>;
264
294
  readonly configManager: ConfigManager;
265
- /** Feature-flag runtime — backs the `/flags` command's listing and runtime toggling. */
295
+ /** Per-device pairing tokens — backs the /devices command family. */
296
+ readonly pairingTokens?: import('@pellux/goodvibes-sdk/platform/pairing').PairingTokenManager;
297
+ /** Capability gate runtime — feature states derive from domain settings keys; surfaces read live/pending state here. */
266
298
  readonly featureFlagManager?: FeatureFlagManager;
267
299
  readonly voiceProviderRegistry?: VoiceProviderRegistry;
268
300
  readonly voiceService?: VoiceService;
@@ -325,11 +357,18 @@ export interface CommandContext
325
357
  readonly mcpApi?: McpApi;
326
358
  readonly opsApi?: OpsApi;
327
359
  readonly transport?: DirectTransport;
360
+ /**
361
+ * Durable user-origin permission rules (the persistent form of remembered
362
+ * approvals). The same in-process store the permission evaluator reads, so
363
+ * /permissions rules lists and revokes rules that take effect live. Served
364
+ * by the daemon's permissions.rules.list/.delete verbs to other surfaces.
365
+ */
366
+ readonly userPermissionRuleStore?: import('@pellux/goodvibes-sdk/platform/permissions').UserPermissionRuleStore;
328
367
  };
329
368
  }
330
369
 
331
370
  /**
332
- * UX-C palette curation (item 4): the "common" first tier the slash-command
371
+ * palette curation (item 4): the "common" first tier the slash-command
333
372
  * autocomplete dropdown shows before the alphabetical rest when it opens with
334
373
  * no filter typed yet (bare '/') — the "132-command palette unranked"
335
374
  * evaluator finding. Typed filtering (any non-empty query) is completely
@@ -379,6 +418,13 @@ export class CommandRegistry {
379
418
  * statically by the alias lint test).
380
419
  */
381
420
  register(command: SlashCommand): void {
421
+ // Every registered command is rendered with its description in /help, the
422
+ // command palette, and the generated reference (docs/commands-reference.md).
423
+ // A blank description would ship an unexplained row in all three, so it
424
+ // fails fast here — the release gate red-tests this invariant.
425
+ if (!command.description || command.description.trim().length === 0) {
426
+ throw new Error(`Command registration for "${command.name}" has no description — every command must describe itself.`);
427
+ }
382
428
  const existingByName = this.commands.get(command.name) ?? this.aliasIndex.get(command.name);
383
429
  if (existingByName) {
384
430
  throw new Error(
@@ -447,7 +493,7 @@ export class CommandRegistry {
447
493
  }
448
494
 
449
495
  if (bestScore > 0 || q === '') {
450
- // UX-C: with no query yet, rank the curated common tier (score 2)
496
+ // with no query yet, rank the curated common tier (score 2)
451
497
  // ahead of everything else (score 1) — the tie-break below then sorts
452
498
  // each tier alphabetically, so the result is "common tier, then the
453
499
  // alphabetical rest" rather than one flat alphabetical list.