@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pellux/goodvibes-tui",
3
- "version": "1.16.1",
3
+ "version": "1.18.0",
4
4
  "description": "Terminal-native GoodVibes product for coding, operations, automation, knowledge, channels, and daemon-backed control-plane workflows.",
5
5
  "type": "module",
6
6
  "main": "src/main.ts",
@@ -104,8 +104,8 @@
104
104
  "@anthropic-ai/vertex-sdk": "^0.16.0",
105
105
  "@ast-grep/napi": "^0.42.0",
106
106
  "@aws/bedrock-token-generator": "^1.1.0",
107
- "@pellux/goodvibes-sdk": "1.7.1",
108
- "@pellux/goodvibes-terminal-shell": "1.7.1",
107
+ "@pellux/goodvibes-sdk": "1.9.0",
108
+ "@pellux/goodvibes-terminal-shell": "1.9.0",
109
109
  "bash-language-server": "^5.6.0",
110
110
  "fuse.js": "^7.1.0",
111
111
  "graphql": "^16.13.2",
@@ -131,10 +131,10 @@
131
131
  "typescript": "^5.9.3"
132
132
  },
133
133
  "optionalDependencies": {
134
- "@pellux/goodvibes-tui-linux-x64": "1.16.1",
135
- "@pellux/goodvibes-tui-linux-arm64": "1.16.1",
136
- "@pellux/goodvibes-tui-darwin-x64": "1.16.1",
137
- "@pellux/goodvibes-tui-darwin-arm64": "1.16.1"
134
+ "@pellux/goodvibes-tui-linux-x64": "1.18.0",
135
+ "@pellux/goodvibes-tui-linux-arm64": "1.18.0",
136
+ "@pellux/goodvibes-tui-darwin-x64": "1.18.0",
137
+ "@pellux/goodvibes-tui-darwin-arm64": "1.18.0"
138
138
  },
139
139
  "trustedDependencies": [],
140
140
  "overrides": {
@@ -93,8 +93,8 @@ export const GLOBAL_FLAGS: readonly CompletionFlag[] = [
93
93
  takesValue: true,
94
94
  description: 'Override a config value (key=value)',
95
95
  },
96
- { name: '--enable', takesValue: true, description: 'Enable a feature flag for this launch' },
97
- { name: '--disable', takesValue: true, description: 'Disable a feature flag for this launch' },
96
+ { name: '--enable', takesValue: true, description: 'Enable a platform feature for this launch (writes its domain setting)' },
97
+ { name: '--disable', takesValue: true, description: 'Disable a platform feature for this launch (writes its domain setting)' },
98
98
  {
99
99
  name: '--prompt',
100
100
  short: '-p',
@@ -139,6 +139,7 @@ export const GLOBAL_FLAGS: readonly CompletionFlag[] = [
139
139
  { name: '--fork', takesValue: false, description: 'Fork session when supported' },
140
140
  { name: '--yes', short: '-y', takesValue: false, description: 'Auto-confirm prompts (non-interactive)' },
141
141
  { name: '--non-interactive', takesValue: false, description: 'Disable all interactive prompts (implies --yes)' },
142
+ { name: '--strict', takesValue: false, description: 'doctor: also fail on advisory findings, for CI' },
142
143
  ] as const;
143
144
 
144
145
  // ---------------------------------------------------------------------------
@@ -504,8 +505,8 @@ export function generateZsh(surface: CompletionSurface): string {
504
505
  lines.push(` '--working-dir[Alias for --cd]:dir:_files -/' \\`);
505
506
  lines.push(` '--daemon-home[Override daemon home]:dir:_files -/' \\`);
506
507
  lines.push(` '(-c --config)'{-c,--config}'[Override config value]:key=value:' \\`);
507
- lines.push(` '--enable[Enable feature flag]:feature:' \\`);
508
- lines.push(` '--disable[Disable feature flag]:feature:' \\`);
508
+ lines.push(` '--enable[Enable platform feature]:feature:' \\`);
509
+ lines.push(` '--disable[Disable platform feature]:feature:' \\`);
509
510
  lines.push(` '(-p --prompt)'{-p,--prompt}'[Run non-interactive prompt]:text:' \\`);
510
511
  lines.push(` '--print[Non-interactive run mode]' \\`);
511
512
  lines.push(` '(-o --output --output-format)'{-o,--output}'[Output format]:format:(text json stream-json)' \\`);
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
- import type { ConfigKey, ConfigManager, ConfigSetting, GoodVibesConfig, PersistedFlagState } from '../config/index.ts';
2
+ import type { ConfigKey, ConfigManager, ConfigSetting, GoodVibesConfig } from '../config/index.ts';
3
3
  import { CONFIG_SCHEMA, ConfigError } from '../config/index.ts';
4
+ import { featureEnablementWrite, getFeatureSetting } from '../runtime/feature-settings.ts';
4
5
  import type { GoodVibesCliCommand, GoodVibesCliFlags } from './types.ts';
5
6
  import { RUNTIME_ENDPOINT_CONFIG_KEYS, hostModeForHostname } from './endpoints.ts';
6
7
  import type { RuntimeEndpointId } from './endpoints.ts';
@@ -130,6 +131,32 @@ export function applyRuntimeConfigDefault(configManager: ConfigManager, key: Con
130
131
  applyRuntimeConfigValue(configManager, key, defaultValue);
131
132
  }
132
133
 
134
+ /**
135
+ * Every TUI-side config default applied at startup, in one place. Currently:
136
+ * show token speed ON (the SDK schema default is false). applyRuntimeConfigDefault
137
+ * reads the global + project settings files and only applies the default
138
+ * in-memory when the key is absent from both (e.g. a new install); an explicit
139
+ * user value is respected. No disk write.
140
+ */
141
+ export function applyTuiRuntimeConfigDefaults(configManager: ConfigManager): void {
142
+ applyRuntimeConfigDefault(configManager, 'display.showTokenSpeed', true);
143
+ }
144
+
145
+ /**
146
+ * Applies the persisted `behavior.hitlMode` config value to the live mode
147
+ * manager at startup, ignoring unset or unrecognized values. Extracted from
148
+ * main() verbatim (a plain move, no behavior change).
149
+ */
150
+ export function applyConfiguredHitlMode(
151
+ configManager: ConfigManager,
152
+ modeManager: { setHITLMode(mode: 'quiet' | 'balanced' | 'operator'): void },
153
+ ): void {
154
+ const hitlMode = configManager.get('behavior.hitlMode');
155
+ if (hitlMode === 'quiet' || hitlMode === 'balanced' || hitlMode === 'operator') {
156
+ modeManager.setHITLMode(hitlMode);
157
+ }
158
+ }
159
+
133
160
  export function applyRuntimeConfigValue(configManager: ConfigManager, key: ConfigKey, value: unknown): void {
134
161
  const setting = CONFIG_SCHEMA_BY_KEY.get(key);
135
162
  if (!setting) {
@@ -161,23 +188,36 @@ export function applyRuntimeConfigOverrides(
161
188
  return errors;
162
189
  }
163
190
 
191
+ /**
192
+ * Session-only feature overrides (--enable-feature / --disable-feature).
193
+ * Each feature is switched through its real domain settings key (e.g.
194
+ * sandbox.enabled, behavior.compactionStrategy) in the runtime config layer;
195
+ * features without an off position (constant capabilities on non-boolean
196
+ * keys) and unknown ids are reported as errors rather than silently ignored.
197
+ */
164
198
  export function applyRuntimeFeatureFlagOverrides(
165
199
  configManager: ConfigManager,
166
200
  options: {
167
201
  readonly enableFeatures: readonly string[];
168
202
  readonly disableFeatures: readonly string[];
169
203
  },
170
- ): void {
171
- if (options.enableFeatures.length === 0 && options.disableFeatures.length === 0) return;
204
+ ): readonly string[] {
205
+ if (options.enableFeatures.length === 0 && options.disableFeatures.length === 0) return [];
172
206
  const config = getRuntimeConfig(configManager);
173
- const flags = { ...config.featureFlags };
174
- for (const feature of options.enableFeatures) {
175
- flags[feature] = 'enabled' satisfies PersistedFlagState;
176
- }
177
- for (const feature of options.disableFeatures) {
178
- flags[feature] = 'disabled' satisfies PersistedFlagState;
179
- }
180
- config.featureFlags = flags;
207
+ const errors: string[] = [];
208
+ const apply = (feature: string, enabled: boolean, flagName: string): void => {
209
+ const write = featureEnablementWrite(feature, enabled);
210
+ if (!write) {
211
+ errors.push(getFeatureSetting(feature)
212
+ ? `${flagName} ${feature}: this capability has no ${enabled ? 'on' : 'off'} switch (its domain settings govern it directly).`
213
+ : `${flagName} ${feature}: unknown feature id.`);
214
+ return;
215
+ }
216
+ setNestedConfigValue(config, write.key, write.value);
217
+ };
218
+ for (const feature of options.enableFeatures) apply(feature, true, '--enable-feature');
219
+ for (const feature of options.disableFeatures) apply(feature, false, '--disable-feature');
220
+ return errors;
181
221
  }
182
222
 
183
223
  export function applyRuntimeEndpointFlagOverrides(
package/src/cli/doctor.ts CHANGED
@@ -1,8 +1,7 @@
1
1
  import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
2
2
  import { PermissionManager, createPermissionConfigReader } from '@pellux/goodvibes-sdk/platform/permissions';
3
3
  import type { PermissionCategory, PermissionCheckResult } from '@pellux/goodvibes-sdk/platform/permissions';
4
- import { PolicyRuntimeState, createFeatureFlagManager } from '@/runtime/index.ts';
5
- import type { FlagState } from '@/runtime/index.ts';
4
+ import { PolicyRuntimeState, createFeatureFlagManager, deriveFeatureStates } from '@/runtime/index.ts';
6
5
  import { getProviderIdFromModel } from '../config/provider-model.ts';
7
6
  import type { CliCommandOutput } from './types.ts';
8
7
  import type { GoodVibesCliOutputFormat } from './types.ts';
@@ -76,7 +75,7 @@ function resolveExplainTarget(args: readonly string[]): { tool: string; args: Re
76
75
  const LAYER_NAMES: readonly string[] = [
77
76
  'Auto-approve override (behavior.autoApprove / --no-worries-just-vibes)',
78
77
  'Session permission mode (permissions.mode)',
79
- 'Runtime policy engine (feature flag: permissions-policy-engine)',
78
+ 'Runtime policy engine (permissions.engine = policy-engine)',
80
79
  'Per-tool config rule (permissions.tools.<tool>)',
81
80
  'Default read auto-approve (prompt mode)',
82
81
  'Session approval cache (remembered [A] decisions)',
@@ -164,7 +163,7 @@ async function explain(options: DoctorSubcommandOptions): Promise<CliCommandOutp
164
163
  // Feature flags come from live config so the policy engine layer is walked
165
164
  // exactly when the running app would walk it.
166
165
  const featureFlags = createFeatureFlagManager();
167
- featureFlags.loadFromConfig({ flags: (config.getCategory('featureFlags') as Record<string, FlagState>) ?? {} });
166
+ featureFlags.loadFromConfig({ flags: deriveFeatureStates(config) });
168
167
  const policyEngineOn = featureFlags.isEnabled('permissions-policy-engine');
169
168
  const policyRuntimeState = new PolicyRuntimeState();
170
169
  const manager = new PermissionManager(
@@ -214,7 +213,7 @@ async function explain(options: DoctorSubcommandOptions): Promise<CliCommandOutp
214
213
  'Current settings:',
215
214
  ` mode : ${mode} (${permissionModeLabel(mode)})`,
216
215
  ` autoApprove : ${autoApprove ? 'yes (behavior.autoApprove)' : 'no'}`,
217
- ` policyEngine: ${policyEngineOn ? 'enabled' : 'disabled'} (feature flag permissions-policy-engine)`,
216
+ ` policyEngine: ${policyEngineOn ? 'enabled' : 'disabled'} (permissions.engine)`,
218
217
  '',
219
218
  'Analysis (from the platform analyzer):',
220
219
  ` risk : ${a.riskLevel.toUpperCase()} (${a.classification})`,
@@ -21,6 +21,7 @@ import {
21
21
  renderGoodVibesHelp,
22
22
  renderGoodVibesVersion,
23
23
  renderOnboardingCliStatus,
24
+ resolveDoctorExitCode,
24
25
  } from './index.ts';
25
26
  import { buildCliServicePosture, getGoodVibesPackageRoot, resolveGoodVibesDaemonExecutable } from './service-posture.ts';
26
27
  import { runInstallSelfCheck } from '../runtime/install-self-check.ts';
@@ -28,8 +29,7 @@ import { readPersistedWorkspaceTrust } from '../runtime/trust/workspace-trust.ts
28
29
  import { WorkspaceRegistrationManager } from '../runtime/trust/workspace-registration.ts';
29
30
  import type { CliWorkspaceStatus, CliSandboxStatus, CliRelayStatus } from './status.ts';
30
31
  import { detectSandboxAvailability, probeSandboxHost } from '@pellux/goodvibes-sdk/platform/tools/exec/sandbox';
31
- import { createFeatureFlagManager } from '@/runtime/index.ts';
32
- import type { FlagState } from '@/runtime/index.ts';
32
+ import { createFeatureFlagManager, deriveFeatureStates } from '@/runtime/index.ts';
33
33
  import { ensureGoodvibesGitignore } from './ensure-goodvibes-gitignore.ts';
34
34
 
35
35
  type ShellEntrypointOwnership = {
@@ -117,10 +117,14 @@ export async function prepareShellCliRuntime(
117
117
  console.error(overrideErrors.join('\n'));
118
118
  process.exit(2);
119
119
  }
120
- applyRuntimeFeatureFlagOverrides(configManager, {
120
+ const featureOverrideErrors = applyRuntimeFeatureFlagOverrides(configManager, {
121
121
  enableFeatures: cli.flags.enableFeatures,
122
122
  disableFeatures: cli.flags.disableFeatures,
123
123
  });
124
+ if (featureOverrideErrors.length > 0) {
125
+ console.error(featureOverrideErrors.join('\n'));
126
+ process.exit(2);
127
+ }
124
128
 
125
129
  if (cli.flags.provider !== undefined || cli.flags.model !== undefined) {
126
130
  const currentModel = configManager.get('provider.model');
@@ -181,9 +185,7 @@ export async function prepareShellCliRuntime(
181
185
  // Linux, a no-op elsewhere) is what makes "available" trustworthy.
182
186
  const sandboxAvailability = detectSandboxAvailability(probeSandboxHost());
183
187
  const sandboxFeatureFlags = createFeatureFlagManager();
184
- sandboxFeatureFlags.loadFromConfig({
185
- flags: (configManager.getCategory('featureFlags') as Record<string, FlagState>) ?? {},
186
- });
188
+ sandboxFeatureFlags.loadFromConfig({ flags: deriveFeatureStates(configManager) });
187
189
  const sandboxStatus: CliSandboxStatus = {
188
190
  configEnabled: Boolean(configManager.getCategory('sandbox').enabled),
189
191
  featureEnabled: sandboxFeatureFlags.isEnabled('exec-sandbox'),
@@ -192,7 +194,7 @@ export async function prepareShellCliRuntime(
192
194
  reason: sandboxAvailability.reason,
193
195
  networkIsolationGuaranteed: sandboxAvailability.networkIsolationGuaranteed,
194
196
  };
195
- // Reuses sandboxFeatureFlags (already loaded from the same featureFlags config category)
197
+ // Reuses sandboxFeatureFlags (already derived from the same domain settings keys)
196
198
  // for the relay-connect gate rather than constructing a second FeatureFlagManager.
197
199
  const relayCategory = configManager.getCategory('relay');
198
200
  const relayStatus: CliRelayStatus = {
@@ -232,7 +234,10 @@ export async function prepareShellCliRuntime(
232
234
  console.log(cli.command === 'onboarding'
233
235
  ? renderOnboardingCliStatus(statusOptions)
234
236
  : renderCliStatus(statusOptions));
235
- process.exit(cli.command === 'doctor' && snapshot.findings.length > 0 ? 1 : 0);
237
+ // Advisory findings are notes on an otherwise-usable install and must
238
+ // never make a healthy install report failure — only a must-fix finding
239
+ // exits non-zero; --strict (for CI) flips advisories to failures too.
240
+ process.exit(cli.command === 'doctor' ? resolveDoctorExitCode(snapshot.findings, cli.flags.strict) : 0);
236
241
  }
237
242
 
238
243
  const cliCommandResult = await handleGoodVibesCliCommand({
package/src/cli/help.ts CHANGED
@@ -68,8 +68,8 @@ export function renderGoodVibesHelp(binary = 'goodvibes'): string {
68
68
  ' --working-dir <dir> Alias for --cd',
69
69
  ' --daemon-home <dir> Override daemon home for daemon-backed commands',
70
70
  ' -c, --config <key=value> Override a config value for this launch',
71
- ' --enable <feature> Enable a feature flag for this launch',
72
- ' --disable <feature> Disable a feature flag for this launch',
71
+ ' --enable <feature> Enable a platform feature for this launch',
72
+ ' --disable <feature> Disable a platform feature for this launch',
73
73
  ' -p, --prompt <text> Run a non-interactive prompt',
74
74
  ' --print Alias for non-interactive run mode',
75
75
  ' -o, --output <format> text, json, or stream-json',
@@ -85,6 +85,7 @@ export function renderGoodVibesHelp(binary = 'goodvibes'): string {
85
85
  ' --fork [id] Fork session (current or specific id) when supported',
86
86
  ' -y, --yes Auto-confirm prompts (non-interactive)',
87
87
  ' --non-interactive Disable all interactive prompts (implies --yes)',
88
+ ' --strict doctor: also fail on advisory findings, for CI',
88
89
  ' -h, --help Print help',
89
90
  ' -v, --version Print version',
90
91
  '',
@@ -139,9 +140,9 @@ const COMMAND_HELP: Record<string, CommandHelp> = {
139
140
  examples: ['status', 'status --json'],
140
141
  },
141
142
  doctor: {
142
- usage: ['doctor', 'doctor --json', 'doctor explain <tool|command>', 'doctor routing', 'doctor hooks'],
143
- summary: 'Print status plus setup warnings, or explain a platform decision: explain (why a tool/command would be allowed/asked/denied under the current mode), routing (which model/provider serves which role), hooks (registered hooks, their source, and validation status).',
144
- examples: ['doctor', 'doctor explain "rm -rf build"', 'doctor explain write ./src/app.ts', 'doctor routing', 'doctor hooks --json'],
143
+ usage: ['doctor', 'doctor --json', 'doctor --strict', 'doctor explain <tool|command>', 'doctor routing', 'doctor hooks'],
144
+ summary: 'Print status plus setup warnings, or explain a platform decision: explain (why a tool/command would be allowed/asked/denied under the current mode), routing (which model/provider serves which role), hooks (registered hooks, their source, and validation status). Exits 0 when the install is usable (advisory findings render as notes); non-zero only for a must-fix finding. --strict also fails on advisory findings, for CI.',
145
+ examples: ['doctor', 'doctor --strict', 'doctor explain "rm -rf build"', 'doctor explain write ./src/app.ts', 'doctor routing', 'doctor hooks --json'],
145
146
  },
146
147
  providers: {
147
148
  usage: ['providers [list]', 'providers current', 'providers inspect <provider>', 'providers use <provider> [modelRegistryKey]'],
@@ -311,7 +312,7 @@ export function renderGoodVibesDaemonHelp(binary = 'goodvibes-daemon'): string {
311
312
  ' install-service Install + enable the daemon as a systemd user service (survives reboots)',
312
313
  ' uninstall-service Disable + remove the daemon systemd user service',
313
314
  ' service-status Show whether the daemon service is installed / enabled / active',
314
- ' migrate-service Guided takeover of a legacy goodvibes-daemon.service unit; prints a plan',
315
+ ' migrate-service Guided migration from an install-script goodvibes-daemon.service unit; prints a plan',
315
316
  ' unless run with -y/--yes (never auto-migrates)',
316
317
  '',
317
318
  'Options:',
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Launch-time self-update — clients land on the newest release at startup so
3
+ * an installed binary never drifts behind. At TUI launch (before any runtime
4
+ * bootstrap or terminal mode change) this runs a quick version check and, when
5
+ * a newer release exists, installs it through the SAME checksum-verified
6
+ * download/verify/swap path `/update apply` and scripts/install.sh use
7
+ * (src/input/commands/update-runtime.ts — there is deliberately no second
8
+ * updater), then asks the caller to restart onto the new binary.
9
+ *
10
+ * Honesty rules, in both directions:
11
+ * - the check gets a short timeout; when it cannot complete (offline, slow
12
+ * network) the CURRENT version starts with exactly one line saying the
13
+ * check was skipped — launch is never held hostage by the network.
14
+ * - a successful update restarts into the new binary and the restarted
15
+ * process prints a receipt naming both versions, so the swap is never
16
+ * silent.
17
+ * - every swap keeps the outgoing file at `<path>.previous`; rollback is
18
+ * one command (`/update rollback`).
19
+ *
20
+ * The feature is a real configurable setting (`update.autoUpdateAtLaunch` in
21
+ * settings.json, read via readUpdateSettings): default ON, off with an
22
+ * explicit false. Only binary installs self-update — package-manager and
23
+ * from-source runs are skipped silently because a swap there would fight the
24
+ * package manager (see detectInstallKind).
25
+ *
26
+ * Everything effectful is injectable; tests drive the decision logic with a
27
+ * stubbed fetch, a stubbed apply, and pinned fixture versions — never the
28
+ * live build VERSION and never the real network.
29
+ */
30
+ import { spawnSync } from 'node:child_process';
31
+ import { detectInstallKind, normalizeVersion, type UpdateFetchLike } from '../runtime/update-check.ts';
32
+ import { applyUpdate, checkForUpdate, type ApplyUpdateOptions } from '../input/commands/update-runtime.ts';
33
+ import { readUpdateSettings, type UpdateSettings } from '../config/tui-extension-settings.ts';
34
+ import type { ConfigManager } from '../config/index.ts';
35
+ import { VERSION } from '../version.ts';
36
+
37
+ /**
38
+ * Set on the restarted process by restartOntoUpdatedBinary, carrying the
39
+ * version that performed the update. Its presence means "an update already
40
+ * happened this launch": the restarted process prints the receipt and skips
41
+ * its own check, which also makes an update-that-didn't-change-the-version
42
+ * structurally unable to restart-loop.
43
+ */
44
+ export const LAUNCH_UPDATED_FROM_ENV = 'GOODVIBES_LAUNCH_UPDATED_FROM';
45
+
46
+ /** Default budget for the launch-time version check; user-tunable via update.launchCheckTimeoutMs. */
47
+ export const LAUNCH_UPDATE_CHECK_TIMEOUT_MS = 2500;
48
+
49
+ export type LaunchAutoUpdateOutcome =
50
+ | {
51
+ readonly action: 'continue';
52
+ readonly reason: 'just-updated' | 'disabled' | 'not-swappable-install' | 'already-current' | 'check-skipped' | 'update-failed';
53
+ }
54
+ | { readonly action: 'restart'; readonly latestTag: string };
55
+
56
+ export interface RunLaunchAutoUpdateOptions {
57
+ readonly fetchImpl: UpdateFetchLike;
58
+ readonly execPath: string;
59
+ readonly platform: NodeJS.Platform;
60
+ readonly arch: string;
61
+ readonly currentVersion: string;
62
+ readonly settings: UpdateSettings;
63
+ readonly env: NodeJS.ProcessEnv;
64
+ readonly print: (line: string) => void;
65
+ readonly configManager: { get(key: string): unknown };
66
+ /** Injectable so tests observe the install step instead of swapping real files. */
67
+ readonly apply?: (options: ApplyUpdateOptions) => Promise<void>;
68
+ readonly timeoutMs?: number;
69
+ }
70
+
71
+ /** Resolves to 'timeout' when `promise` does not settle within `ms`; the timer never keeps the process alive. */
72
+ async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T | 'timeout'> {
73
+ let timer: ReturnType<typeof setTimeout> | undefined;
74
+ try {
75
+ return await Promise.race([
76
+ promise,
77
+ new Promise<'timeout'>((resolve) => {
78
+ timer = setTimeout(() => resolve('timeout'), ms);
79
+ timer.unref?.();
80
+ }),
81
+ ]);
82
+ } finally {
83
+ clearTimeout(timer);
84
+ }
85
+ }
86
+
87
+ /**
88
+ * The launch-time decision: receipt-and-continue (restarted process), skip
89
+ * (disabled / unswappable / current / unreachable), install-and-restart, or
90
+ * fail-and-continue on the current version. Never throws.
91
+ */
92
+ export async function runLaunchAutoUpdate(options: RunLaunchAutoUpdateOptions): Promise<LaunchAutoUpdateOutcome> {
93
+ const updatedFrom = options.env[LAUNCH_UPDATED_FROM_ENV];
94
+ if (typeof updatedFrom === 'string' && updatedFrom.length > 0) {
95
+ // Consumed here so sessions spawned from inside this one never inherit it.
96
+ delete options.env[LAUNCH_UPDATED_FROM_ENV];
97
+ options.print(`auto-update: updated from v${normalizeVersion(updatedFrom)} to v${normalizeVersion(options.currentVersion)} at launch`);
98
+ return { action: 'continue', reason: 'just-updated' };
99
+ }
100
+
101
+ if (!(options.settings.autoUpdateAtLaunch ?? true)) {
102
+ return { action: 'continue', reason: 'disabled' };
103
+ }
104
+
105
+ if (detectInstallKind(options.execPath) !== 'binary') {
106
+ return { action: 'continue', reason: 'not-swappable-install' };
107
+ }
108
+
109
+ const timeoutMs = options.timeoutMs ?? options.settings.launchCheckTimeoutMs ?? LAUNCH_UPDATE_CHECK_TIMEOUT_MS;
110
+ let check: Awaited<ReturnType<typeof checkForUpdate>> | 'timeout';
111
+ try {
112
+ check = await withTimeout(checkForUpdate(options.fetchImpl, options.currentVersion), timeoutMs);
113
+ } catch {
114
+ check = 'timeout';
115
+ }
116
+ if (check === 'timeout') {
117
+ options.print('update check skipped: offline');
118
+ return { action: 'continue', reason: 'check-skipped' };
119
+ }
120
+ if (check.isCurrent) {
121
+ return { action: 'continue', reason: 'already-current' };
122
+ }
123
+
124
+ try {
125
+ const apply = options.apply ?? applyUpdate;
126
+ await apply({
127
+ fetchImpl: options.fetchImpl,
128
+ execPath: options.execPath,
129
+ platform: options.platform,
130
+ arch: options.arch,
131
+ currentVersion: options.currentVersion,
132
+ print: options.print,
133
+ configManager: options.configManager,
134
+ });
135
+ options.print(`auto-update: ${check.latestTag} installed — restarting onto the new version`);
136
+ return { action: 'restart', latestTag: check.latestTag };
137
+ } catch (error) {
138
+ options.print(
139
+ `auto-update failed: ${error instanceof Error ? error.message : String(error)} — starting the current version v${normalizeVersion(options.currentVersion)}`,
140
+ );
141
+ return { action: 'continue', reason: 'update-failed' };
142
+ }
143
+ }
144
+
145
+ export interface SelfUpdateAtLaunchParams {
146
+ readonly configManager: Pick<ConfigManager, 'get' | 'getRaw'>;
147
+ readonly stdout: { write(chunk: string): unknown };
148
+ }
149
+
150
+ /**
151
+ * The complete launch wiring for main(), with real host inputs: run the
152
+ * decision above; when an update was installed, restart onto the swapped
153
+ * binary and EXIT THIS PROCESS with the new instance's exit code (this call
154
+ * never returns in that case); otherwise return the honest lines that were
155
+ * printed (receipt / skipped check / failed update) so the caller can
156
+ * re-surface them through the system message router once it exists — the
157
+ * stdout copies written here are wiped by the TUI's alternate screen.
158
+ */
159
+ export async function selfUpdateAtLaunch(params: SelfUpdateAtLaunchParams): Promise<readonly string[]> {
160
+ const lines: string[] = [];
161
+ const outcome = await runLaunchAutoUpdate({
162
+ fetchImpl: fetch as UpdateFetchLike,
163
+ execPath: process.execPath,
164
+ platform: process.platform,
165
+ arch: process.arch,
166
+ currentVersion: VERSION,
167
+ settings: readUpdateSettings(params.configManager),
168
+ env: process.env,
169
+ configManager: params.configManager,
170
+ print: (line) => {
171
+ lines.push(line);
172
+ params.stdout.write(`${line}\n`);
173
+ },
174
+ });
175
+ if (outcome.action === 'restart') {
176
+ process.exit(
177
+ restartOntoUpdatedBinary({
178
+ execPath: process.execPath,
179
+ argv: process.argv.slice(2),
180
+ env: process.env,
181
+ fromVersion: VERSION,
182
+ }),
183
+ );
184
+ }
185
+ return lines;
186
+ }
187
+
188
+ export interface RestartOntoUpdatedBinaryOptions {
189
+ readonly execPath: string;
190
+ readonly argv: readonly string[];
191
+ readonly env: NodeJS.ProcessEnv;
192
+ /** The version that performed the update — the restarted process prints it in its receipt line. */
193
+ readonly fromVersion: string;
194
+ /** Injectable so tests observe the restart instead of spawning a process. */
195
+ readonly spawn?: (execPath: string, argv: readonly string[], env: NodeJS.ProcessEnv) => number | null;
196
+ }
197
+
198
+ /**
199
+ * Runs the (just-swapped) binary at execPath with the original CLI arguments
200
+ * and an inherited terminal, blocking until it exits, and returns its exit
201
+ * code for the caller to process.exit with. The receipt marker travels in the
202
+ * child's environment, never on the command line.
203
+ */
204
+ export function restartOntoUpdatedBinary(options: RestartOntoUpdatedBinaryOptions): number {
205
+ const spawn =
206
+ options.spawn ??
207
+ ((execPath: string, argv: readonly string[], env: NodeJS.ProcessEnv) =>
208
+ spawnSync(execPath, [...argv], { stdio: 'inherit', env }).status);
209
+ const status = spawn(options.execPath, options.argv, {
210
+ ...options.env,
211
+ [LAUNCH_UPDATED_FROM_ENV]: options.fromVersion,
212
+ });
213
+ return status ?? 0;
214
+ }
@@ -5,12 +5,16 @@ import { BUILTIN_SECRET_PROVIDER_SOURCES, describeSecretRef, isSecretRefInput, r
5
5
  import { getSubscriptionProviderConfig, listAvailableSubscriptionProviders } from '@pellux/goodvibes-sdk/platform/config';
6
6
  import { beginOpenAICodexLogin, exchangeOpenAICodexCode } from '@pellux/goodvibes-sdk/platform/config';
7
7
  import { inspectProviderAuth } from '@/runtime/index.ts';
8
- import { getOrCreateCompanionToken, buildCompanionConnectionInfo, encodeConnectionPayload, formatConnectionBlock } from '@pellux/goodvibes-sdk/platform/pairing';
9
- import { generateQrMatrix, renderQrToString } from '@pellux/goodvibes-sdk/platform/pairing';
8
+ import { generateQrMatrix, renderQrToString, PAIRING_HANDOFF_OFFER_KINDS, type PairingHandoffOfferKind } from '@pellux/goodvibes-sdk/platform/pairing';
9
+ import { ensurePublicBaseUrl } from '../core/pairing-origin.ts';
10
+ import { formatPairingOffers, formatPostureCapabilities, pairingPostureNotice } from '../core/pairing-offers.ts';
11
+ import { defaultPairingTokenName } from '../core/pairing-handoff.ts';
12
+ import { stableUrlHostForBindHost } from '../core/stable-host.ts';
10
13
  import { resolveRuntimeEndpointBinding } from './endpoints.ts';
11
14
  import { classifyBindPosture, isNetworkFacing } from './network-posture.ts';
12
15
  import type { CliCommandRuntime } from './types.ts';
13
- import { extractAuthorizationCode, formatJsonOrText, hasCommandFlag, openBrowser, probeTcp, readAuthPaths, runNonInteractiveAgent, urlHostForBindHost, withRuntimeServices, yesNo } from './management-utils.ts';
16
+ import type { RuntimeServices } from '../runtime/services.ts';
17
+ import { extractAuthorizationCode, formatJsonOrText, hasCommandFlag, openBrowser, probeTcp, readAuthPaths, readOptionValue, runNonInteractiveAgent, withRuntimeServices, yesNo } from './management-utils.ts';
14
18
 
15
19
  export async function renderSubscriptions(runtime: CliCommandRuntime): Promise<string> {
16
20
  return await withRuntimeServices(runtime, async (services) => {
@@ -368,19 +372,80 @@ export async function renderControlPlaneStatus(runtime: CliCommandRuntime): Prom
368
372
  return formatControlPlaneStatus(runtime, await buildControlPlaneStatusResult(runtime));
369
373
  }
370
374
 
375
+ interface PairingHandoffCreateResult {
376
+ readonly token: { readonly id: string; readonly name: string; readonly token: string };
377
+ readonly offers: readonly { readonly kind: PairingHandoffOfferKind }[];
378
+ readonly fragment: string;
379
+ readonly deepLink?: string | undefined;
380
+ /** The honest TLS/capability posture of the origin the QR points at (SDK-computed). */
381
+ readonly posture?: import('@pellux/goodvibes-sdk/platform/pairing').OriginPosture | undefined;
382
+ }
383
+
371
384
  export async function renderPairing(runtime: CliCommandRuntime): Promise<string> {
372
- const daemonHomeDir = join(runtime.homeDirectory, '.goodvibes', 'daemon');
373
- const tokenRecord = getOrCreateCompanionToken('tui', { daemonHomeDir });
374
- const binding = resolveRuntimeEndpointBinding(runtime.configManager, 'controlPlane');
375
- const daemonUrl = `http://${urlHostForBindHost(binding.host)}:${binding.port}`;
376
- const info = buildCompanionConnectionInfo({
377
- daemonUrl,
378
- token: tokenRecord.token,
379
- username: 'admin',
385
+ return await withRuntimeServices(runtime, async (services) => {
386
+ // Freeze a stable web origin once (never clobbering a user-set value) so the
387
+ // printed link and the daemon's own handoff origin agree and survive a DHCP
388
+ // lease change where a stable name exists. The verb re-derives the same origin
389
+ // and returns its honest posture, so this call is a persistence side effect.
390
+ ensurePublicBaseUrl(runtime.configManager);
391
+ const name = readOptionValue(runtime.cli.commandArgs, 'name') ?? defaultPairingTokenName();
392
+ // Mint through the canonical hand-off verb: it mints a fresh per-device token
393
+ // and returns the exact `#pair=<token>` deep link the web app consumes. The
394
+ // daemon filters the requested offer set down to what it can actually satisfy.
395
+ const result = (await services.gatewayMethods.invoke('pairing.handoff.create', {
396
+ body: { name, offers: [...PAIRING_HANDOFF_OFFER_KINDS] },
397
+ context: { principalId: 'admin', principalKind: 'user', admin: true },
398
+ })) as PairingHandoffCreateResult;
399
+ const link = result.deepLink ?? result.fragment;
400
+ const qr = renderQrToString(generateQrMatrix(link));
401
+ const lines: string[] = [
402
+ 'Scan to pair a device — the QR opens the web app already signed in:',
403
+ '',
404
+ ` ${link}`,
405
+ ` Token name: ${result.token.name} (rename or revoke later in /settings → security → devices)`,
406
+ ];
407
+ if (result.offers.length > 0) {
408
+ lines.push('', 'Offers carried in this pairing (each declinable in the web app):', ...formatPairingOffers(result.offers.map((o) => o.kind)));
409
+ }
410
+ // The labeled browser-capability list and the one honest LAN line both render
411
+ // from the SDK posture the verb returned — never a locally-authored string.
412
+ const capabilities = formatPostureCapabilities(result.posture);
413
+ if (capabilities.length > 0) lines.push('', 'This device will get:', ...capabilities);
414
+ const notice = pairingPostureNotice(result.posture);
415
+ if (notice) lines.push('', notice);
416
+ // Tailscale detection is read-only here (the interactive serve lives in the
417
+ // pairing modal); when detected with an https MagicDNS URL, name the encrypted
418
+ // path. Absence stays quiet.
419
+ lines.push(...(await renderPairingTailscaleLines(services)));
420
+ lines.push('', qr);
421
+ return lines.join('\n');
380
422
  });
381
- const payload = encodeConnectionPayload(info);
382
- const qr = renderQrToString(generateQrMatrix(payload));
383
- return [formatConnectionBlock(info, payload), '', qr].join('\n');
423
+ }
424
+
425
+ interface TailscaleGetResult {
426
+ readonly available: boolean;
427
+ readonly httpsUrl?: string | undefined;
428
+ readonly detail: string;
429
+ }
430
+
431
+ /**
432
+ * Read-only tailscale detection for the `gv pair` block: when tailscale is
433
+ * available with a served https MagicDNS URL, name the encrypted path (the
434
+ * interactive one-action serve lives in the pairing modal). Quiet — an empty
435
+ * array — when tailscale is absent or the probe fails.
436
+ */
437
+ async function renderPairingTailscaleLines(services: RuntimeServices): Promise<string[]> {
438
+ try {
439
+ const ts = (await services.gatewayMethods.invoke('tailscale.get', {
440
+ body: {},
441
+ context: { principalId: 'admin', principalKind: 'user', admin: true },
442
+ })) as TailscaleGetResult;
443
+ if (!ts.available) return [];
444
+ if (ts.httpsUrl) return ['', `Encrypted access (Tailscale): ${ts.httpsUrl}`];
445
+ return ['', 'Tailscale detected — run it as a serve target for encrypted https access (see the pairing modal).'];
446
+ } catch {
447
+ return [];
448
+ }
384
449
  }
385
450
 
386
451
  export async function renderRemote(runtime: CliCommandRuntime, label: 'remote' | 'bridge'): Promise<string> {
@@ -408,9 +473,11 @@ export function renderWeb(runtime: CliCommandRuntime): string {
408
473
  const binding = resolveRuntimeEndpointBinding(runtime.configManager, 'web');
409
474
  const publicBaseUrl = String(runtime.configManager.get('web.publicBaseUrl') ?? '');
410
475
  const hasEndpointOverride = runtime.cli.flags.hostname !== undefined || runtime.cli.flags.port !== undefined;
476
+ // Prefer the stable-name resolution over the raw first-non-internal IPv4 so the
477
+ // printed URL survives a DHCP lease change where a stable name exists.
411
478
  const url = !hasEndpointOverride && publicBaseUrl
412
479
  ? publicBaseUrl
413
- : `http://${urlHostForBindHost(binding.host)}:${binding.port}`;
480
+ : `http://${stableUrlHostForBindHost(binding.host).host}:${binding.port}`;
414
481
  const value = {
415
482
  enabled: runtime.configManager.get('web.enabled'),
416
483
  ...binding,
@@ -11,8 +11,6 @@ import { BUILTIN_SECRET_PROVIDER_SOURCES, describeSecretRef, isSecretRefInput, r
11
11
  import { getSubscriptionProviderConfig, listAvailableSubscriptionProviders } from '@pellux/goodvibes-sdk/platform/config';
12
12
  import { beginOpenAICodexLogin, exchangeOpenAICodexCode } from '@pellux/goodvibes-sdk/platform/config';
13
13
  import { inspectProviderAuth } from '@/runtime/index.ts';
14
- import { getOrCreateCompanionToken, buildCompanionConnectionInfo, encodeConnectionPayload, formatConnectionBlock } from '@pellux/goodvibes-sdk/platform/pairing';
15
- import { generateQrMatrix, renderQrToString } from '@pellux/goodvibes-sdk/platform/pairing';
16
14
  import type { CliCommandRuntime, GoodVibesCliParseResult } from './types.ts';
17
15
  import { formatProviderAuthRoute, summarizeProviderAuthRoutes } from './provider-auth-routes.ts';
18
16
  import { classifyProviderSetup } from './provider-classification.ts';