@pellux/goodvibes-tui 1.17.0 → 1.19.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 (104) hide show
  1. package/CHANGELOG.md +208 -0
  2. package/README.md +1 -1
  3. package/docs/foundation-artifacts/operator-contract.json +5025 -1636
  4. package/package.json +7 -7
  5. package/src/cli/endpoints.ts +50 -4
  6. package/src/cli/help.ts +29 -2
  7. package/src/cli/management-commands.ts +99 -23
  8. package/src/cli/management.ts +0 -2
  9. package/src/cli/service-posture.ts +5 -3
  10. package/src/cli/status.ts +27 -6
  11. package/src/cli/surface-command.ts +29 -15
  12. package/src/core/consolidation-receipt.ts +41 -0
  13. package/src/core/conversation-rendering.ts +14 -5
  14. package/src/core/memory-diagnostics-gateway.ts +99 -0
  15. package/src/core/memory-provenance.ts +67 -0
  16. package/src/core/memory-status.ts +118 -0
  17. package/src/core/pairing-devices.ts +49 -0
  18. package/src/core/pairing-handoff.ts +101 -0
  19. package/src/core/pairing-offers.ts +83 -0
  20. package/src/core/pairing-origin.ts +78 -0
  21. package/src/core/pairing-tailscale-gateway.ts +56 -0
  22. package/src/core/power-chip-source.ts +91 -0
  23. package/src/core/power-status.ts +95 -0
  24. package/src/core/stable-host.ts +171 -0
  25. package/src/core/stream-event-wiring.ts +21 -0
  26. package/src/core/turn-budget-outcome.ts +60 -0
  27. package/src/core/turn-cancellation.ts +39 -0
  28. package/src/core/turn-event-wiring.ts +6 -0
  29. package/src/core/voice-provision-gateway.ts +202 -0
  30. package/src/core/voice-provision-status.ts +197 -0
  31. package/src/daemon/cli.ts +127 -60
  32. package/src/input/command-registry.ts +21 -0
  33. package/src/input/commands/acp-runtime.ts +72 -0
  34. package/src/input/commands/devices-runtime.ts +109 -0
  35. package/src/input/commands/experience-runtime.ts +25 -4
  36. package/src/input/commands/git-runtime.ts +28 -4
  37. package/src/input/commands/graph-runtime.ts +45 -0
  38. package/src/input/commands/health-runtime.ts +15 -1
  39. package/src/input/commands/operator-rpc.ts +32 -11
  40. package/src/input/commands/power-runtime.ts +56 -0
  41. package/src/input/commands/qrcode-runtime.ts +14 -19
  42. package/src/input/commands/queue-runtime.ts +70 -0
  43. package/src/input/commands/workstream-attempts.ts +6 -1
  44. package/src/input/commands/workstream-runtime.ts +44 -25
  45. package/src/input/commands/worktree-runtime.ts +35 -8
  46. package/src/input/commands.ts +10 -0
  47. package/src/input/config-modal-types.ts +11 -0
  48. package/src/input/config-modal.ts +65 -2
  49. package/src/input/handler-shortcuts.ts +23 -0
  50. package/src/input/keybindings.ts +19 -1
  51. package/src/input/settings-modal-data.ts +31 -37
  52. package/src/input/settings-modal-types.ts +5 -2
  53. package/src/input/tts-settings-actions.ts +27 -8
  54. package/src/input/voice-local-settings.ts +52 -0
  55. package/src/main.ts +42 -42
  56. package/src/panels/builtin/operations.ts +54 -1
  57. package/src/panels/builtin/shared.ts +12 -0
  58. package/src/panels/builtin-modals.ts +49 -32
  59. package/src/panels/diff-panel.ts +34 -1
  60. package/src/panels/fleet-acts.ts +446 -0
  61. package/src/panels/fleet-gateway.ts +117 -0
  62. package/src/panels/fleet-observed-render.ts +128 -0
  63. package/src/panels/fleet-panel-format.ts +120 -4
  64. package/src/panels/fleet-panel-worktree-detail.ts +14 -0
  65. package/src/panels/fleet-panel.ts +60 -44
  66. package/src/panels/fleet-read-model.ts +34 -3
  67. package/src/panels/fleet-spawn.ts +266 -0
  68. package/src/panels/fleet-stop.ts +38 -2
  69. package/src/panels/fleet-tabs.ts +2 -2
  70. package/src/panels/memory-consolidation-gateway.ts +94 -0
  71. package/src/panels/modals/devices-modal.ts +161 -0
  72. package/src/panels/modals/memory-modal.ts +149 -5
  73. package/src/panels/modals/pairing-modal.ts +198 -68
  74. package/src/panels/modals/security-modal.ts +6 -0
  75. package/src/panels/workstream-graph-render.ts +123 -0
  76. package/src/renderer/composer-fragments.ts +69 -0
  77. package/src/renderer/settings-modal-helpers.ts +9 -0
  78. package/src/renderer/settings-modal.ts +4 -2
  79. package/src/renderer/shell-surface.ts +3 -0
  80. package/src/renderer/tool-call.ts +12 -6
  81. package/src/renderer/ui-factory.ts +32 -0
  82. package/src/runtime/bootstrap-command-context.ts +3 -1
  83. package/src/runtime/bootstrap-command-parts.ts +3 -1
  84. package/src/runtime/bootstrap-core.ts +6 -6
  85. package/src/runtime/bootstrap-shell.ts +7 -0
  86. package/src/runtime/code-index-services.ts +15 -1
  87. package/src/runtime/durability-services.ts +36 -1
  88. package/src/runtime/fleet-services.ts +41 -0
  89. package/src/runtime/idle-power-services.ts +85 -0
  90. package/src/runtime/index.ts +2 -0
  91. package/src/runtime/interaction-seams.ts +109 -0
  92. package/src/runtime/knowledge-services.ts +94 -0
  93. package/src/runtime/legacy-daemon-migration.ts +38 -6
  94. package/src/runtime/legacy-daemon-reconcile.ts +439 -0
  95. package/src/runtime/memory-governance-services.ts +79 -0
  96. package/src/runtime/notification-dispatch.ts +28 -0
  97. package/src/runtime/power-keepawake-remote.ts +75 -0
  98. package/src/runtime/process-lifecycle.ts +3 -1
  99. package/src/runtime/services.ts +105 -107
  100. package/src/runtime/ui-services.ts +3 -0
  101. package/src/runtime/voice-setup-services.ts +104 -0
  102. package/src/shell/blocking-input.ts +21 -141
  103. package/src/shell/recovery-input-helpers.ts +53 -166
  104. package/src/version.ts +11 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pellux/goodvibes-tui",
3
- "version": "1.17.0",
3
+ "version": "1.19.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.8.0",
108
- "@pellux/goodvibes-terminal-shell": "1.8.0",
107
+ "@pellux/goodvibes-sdk": "1.10.0",
108
+ "@pellux/goodvibes-terminal-shell": "1.10.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.17.0",
135
- "@pellux/goodvibes-tui-linux-arm64": "1.17.0",
136
- "@pellux/goodvibes-tui-darwin-x64": "1.17.0",
137
- "@pellux/goodvibes-tui-darwin-arm64": "1.17.0"
134
+ "@pellux/goodvibes-tui-linux-x64": "1.19.0",
135
+ "@pellux/goodvibes-tui-linux-arm64": "1.19.0",
136
+ "@pellux/goodvibes-tui-darwin-x64": "1.19.0",
137
+ "@pellux/goodvibes-tui-darwin-arm64": "1.19.0"
138
138
  },
139
139
  "trustedDependencies": [],
140
140
  "overrides": {
@@ -36,6 +36,30 @@ export interface RuntimeEndpointBinding {
36
36
  readonly configuredHost: string;
37
37
  readonly host: string;
38
38
  readonly port: number;
39
+ /**
40
+ * False when the stored hostMode is not one of the SDK's recognized modes
41
+ * ('local' | 'network' | 'custom'). The SDK's resolveHostBinding is a switch
42
+ * with NO default case: an unrecognized mode yields an undefined binding and
43
+ * the daemon throws in its constructor before ever binding. Displays must
44
+ * therefore never present the host/port below as a definite binding when
45
+ * this is false — the fallback values exist only so display code has
46
+ * something structured to show alongside the warning.
47
+ */
48
+ readonly recognized: boolean;
49
+ }
50
+
51
+ /**
52
+ * THE one display seam for endpoint bindings: every surface that renders a
53
+ * binding as text goes through this, so no surface can present the resolver's
54
+ * loopback fallback as a definite bind for a hostMode the SDK cannot handle
55
+ * (its bind resolver has no default case — the daemon throws before binding).
56
+ * The recognized case renders the familiar `<mode> <host>:<port>`.
57
+ */
58
+ export function formatRuntimeEndpointBinding(binding: RuntimeEndpointBinding): string {
59
+ if (binding.recognized) {
60
+ return `${binding.hostMode} ${binding.host}:${binding.port}`;
61
+ }
62
+ return `'${binding.hostMode}' — not a recognized host mode (expected local|network|custom); the daemon cannot bind this endpoint until it is corrected`;
39
63
  }
40
64
 
41
65
  export function hostModeForHostname(hostname: string): RuntimeHostMode {
@@ -52,12 +76,34 @@ export function resolveRuntimeEndpointBinding(
52
76
  const keys = RUNTIME_ENDPOINT_CONFIG_KEYS[endpoint];
53
77
  const hostMode = String(config.get(keys.hostMode) ?? 'local');
54
78
  const configuredHost = String(config.get(keys.host) ?? '127.0.0.1');
55
- const port = Number(config.get(keys.port) || RUNTIME_ENDPOINT_DEFAULT_PORTS[endpoint]);
79
+ // Port coercion is anchored PER ENDPOINT to what the SDK actually does with
80
+ // the stored value, so a display never disagrees with the machinery:
81
+ // - controlPlane / httpListener bind via resolveHostBinding, which is fed
82
+ // `Number(raw ?? default)` and applies `customPort || DEFAULT` — a
83
+ // stored 0 or non-numeric value collapses to the endpoint default.
84
+ // - web has NO resolveHostBinding consumer in the SDK at all; its closest
85
+ // machinery (resolveWebPort, which drives tailscale-serve, plus the
86
+ // surface registry/announcements) uses bare `Number(raw ?? default)`
87
+ // with no || collapse — 0 stays 0 and a non-numeric value stays NaN.
88
+ // Collapsing here would make the display assert a port the tailscale/
89
+ // announcement machinery is not actually using. (The SDK's web-port
90
+ // handling is inconsistent-by-absence — a proper resolveHostBinding-style
91
+ // web resolver is an SDK-side fix; until then this mirrors resolveWebPort.)
92
+ const rawPort = Number(config.get(keys.port) ?? RUNTIME_ENDPOINT_DEFAULT_PORTS[endpoint]);
93
+ const port = endpoint === 'web' ? rawPort : (rawPort || RUNTIME_ENDPOINT_DEFAULT_PORTS[endpoint]);
56
94
  if (hostMode === 'network') {
57
- return { hostMode, configuredHost, host: '0.0.0.0', port };
95
+ return { hostMode, configuredHost, host: '0.0.0.0', port, recognized: true };
58
96
  }
59
97
  if (hostMode === 'custom') {
60
- return { hostMode, configuredHost, host: configuredHost || '127.0.0.1', port };
98
+ return { hostMode, configuredHost, host: configuredHost || '127.0.0.1', port, recognized: true };
99
+ }
100
+ if (hostMode === 'local') {
101
+ return { hostMode, configuredHost, host: '127.0.0.1', port, recognized: true };
61
102
  }
62
- return { hostMode, configuredHost, host: '127.0.0.1', port };
103
+ // Unrecognized hostMode ('LAN', 'Network', '', a trimmed variant, …): the
104
+ // SDK bind path has NO default case for this — resolveHostBinding returns
105
+ // undefined and the daemon throws before binding. There IS no real binding
106
+ // to display; recognized:false tells callers to warn instead of asserting
107
+ // the loopback fallback below as fact.
108
+ return { hostMode, configuredHost, host: '127.0.0.1', port, recognized: false };
63
109
  }
package/src/cli/help.ts CHANGED
@@ -6,8 +6,13 @@ import { VERSION } from '../version.ts';
6
6
  function readJsonVersion(path: string): string | null {
7
7
  try {
8
8
  if (!existsSync(path)) return null;
9
- const parsed = JSON.parse(readFileSync(path, 'utf-8')) as { version?: unknown };
10
- return typeof parsed.version === 'string' ? parsed.version : null;
9
+ const parsed = JSON.parse(readFileSync(path, 'utf-8')) as { name?: unknown; version?: unknown };
10
+ // Only trust OUR package.json — a compiled single-file binary can resolve
11
+ // this path to a different package.json (a bundled dependency's) that
12
+ // reports a placeholder like "0.0.0". Fall through to the baked VERSION in
13
+ // that case rather than rendering a stray version in `--version`/banners.
14
+ if (parsed.name !== '@pellux/goodvibes-tui') return null;
15
+ return typeof parsed.version === 'string' && parsed.version.length > 0 ? parsed.version : null;
11
16
  } catch {
12
17
  return null;
13
18
  }
@@ -23,6 +28,28 @@ export function renderGoodVibesVersion(binary = 'goodvibes'): string {
23
28
  return `${binary} ${getPackageVersion()}`;
24
29
  }
25
30
 
31
+ /**
32
+ * Honest one-line startup identity for the daemon binary, emitted right as it
33
+ * begins serving — including on a bare (no-arg) systemd launch. It states the
34
+ * RESOLVED version (never a placeholder), the home/host/port it actually bound,
35
+ * and points at the real service-setup command. This replaces the field
36
+ * behavior where a bare launch showed a wrong "v0.0.0" banner and gave an
37
+ * operator nothing to act on. `version` is passed in (never read from the live
38
+ * build here) so callers/tests can pin a sentinel and never compare the live
39
+ * VERSION.
40
+ */
41
+ export function renderDaemonStartupBanner(
42
+ version: string,
43
+ binding: { readonly homeDir: string; readonly host: string; readonly port: number },
44
+ binary = 'goodvibes-daemon',
45
+ ): string {
46
+ return (
47
+ `${binary} ${version} starting — ` +
48
+ `home=${binding.homeDir} host=${binding.host} port=${binding.port} ` +
49
+ `(manage as a service: ${binary} install-service)`
50
+ );
51
+ }
52
+
26
53
  export function renderGoodVibesHelp(binary = 'goodvibes'): string {
27
54
  return [
28
55
  `Usage: ${binary} [OPTIONS] [PROMPT]`,
@@ -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';
10
- import { resolveRuntimeEndpointBinding } from './endpoints.ts';
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';
13
+ import { formatRuntimeEndpointBinding, 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) => {
@@ -309,8 +313,10 @@ export interface ControlPlaneStatusResult {
309
313
  readonly configuredHost: string;
310
314
  readonly host: string;
311
315
  readonly port: number;
316
+ readonly recognized: boolean;
312
317
  readonly posture: ReturnType<typeof classifyBindPosture>;
313
- readonly reachable: boolean;
318
+ /** undefined = NOT PROBED (unrecognized host mode) — a tri-state, never coerced to false. */
319
+ readonly reachable: boolean | undefined;
314
320
  readonly auth: ReturnType<typeof readAuthPaths>;
315
321
  readonly service: {
316
322
  readonly enabled: unknown;
@@ -323,7 +329,13 @@ export interface ControlPlaneStatusResult {
323
329
  export async function buildControlPlaneStatusResult(runtime: CliCommandRuntime): Promise<ControlPlaneStatusResult> {
324
330
  const binding = resolveRuntimeEndpointBinding(runtime.configManager, 'controlPlane');
325
331
  const enabled = runtime.configManager.get('controlPlane.enabled');
326
- const reachable = enabled === true ? await probeTcp(binding.host, binding.port) : false;
332
+ // Never TCP-probe the fallback endpoint of an unrecognized hostMode — a
333
+ // probe asserts a binding that does not exist. Not-probed is its own state
334
+ // (undefined), NEVER coerced to a definite false: a daemon launched with
335
+ // flag overrides can be serving healthily regardless of the stored mode.
336
+ const reachable = enabled === true
337
+ ? (binding.recognized ? await probeTcp(binding.host, binding.port) : undefined)
338
+ : false;
327
339
  const auth = readAuthPaths(runtime);
328
340
  const service = {
329
341
  enabled: runtime.configManager.get('service.enabled'),
@@ -331,7 +343,8 @@ export async function buildControlPlaneStatusResult(runtime: CliCommandRuntime):
331
343
  restartOnFailure: runtime.configManager.get('service.restartOnFailure'),
332
344
  };
333
345
  const issues: string[] = [];
334
- if (enabled === true && !reachable) issues.push(`Control plane is enabled but not reachable on ${binding.host}:${binding.port}.`);
346
+ if (!binding.recognized) issues.push(`controlPlane.hostMode '${binding.hostMode}' is not a recognized mode (local|network|custom) — the daemon cannot bind until it is corrected.`);
347
+ if (enabled === true && binding.recognized && !reachable) issues.push(`Control plane is enabled but not reachable on ${binding.host}:${binding.port}.`);
335
348
  if (enabled === true && service.enabled !== true) issues.push('Control plane is enabled but service mode is off.');
336
349
  if (enabled === true && service.autostart !== true) issues.push('Control plane is enabled but service autostart is off.');
337
350
  if (enabled === true && service.restartOnFailure !== true) issues.push('Control plane is enabled but service restart-on-failure is off.');
@@ -352,9 +365,9 @@ export function formatControlPlaneStatus(runtime: CliCommandRuntime, value: Cont
352
365
  return formatJsonOrText(runtime.cli)(value, [
353
366
  'GoodVibes control-plane status',
354
367
  ` enabled: ${yesNo(value.enabled)}`,
355
- ` bind: ${value.hostMode} ${value.host}:${value.port}`,
356
- ` bind posture: ${value.posture.label}`,
357
- ` reachable: ${yesNo(value.reachable)}`,
368
+ ` bind: ${formatRuntimeEndpointBinding(value)}`,
369
+ ` bind posture: ${value.recognized ? value.posture.label : 'unknown (unrecognized host mode)'}`,
370
+ ` reachable: ${value.reachable === undefined ? 'not probed (unrecognized host mode)' : yesNo(value.reachable)}`,
358
371
  ` service: enabled=${yesNo(value.service.enabled)} autostart=${yesNo(value.service.autostart)} restartOnFailure=${yesNo(value.service.restartOnFailure)}`,
359
372
  ` local auth users: ${value.auth.userStorePresent ? 'present' : 'missing'}`,
360
373
  ` bootstrap credential: ${value.auth.bootstrapCredentialPresent ? 'present' : 'missing'}`,
@@ -368,19 +381,80 @@ export async function renderControlPlaneStatus(runtime: CliCommandRuntime): Prom
368
381
  return formatControlPlaneStatus(runtime, await buildControlPlaneStatusResult(runtime));
369
382
  }
370
383
 
384
+ interface PairingHandoffCreateResult {
385
+ readonly token: { readonly id: string; readonly name: string; readonly token: string };
386
+ readonly offers: readonly { readonly kind: PairingHandoffOfferKind }[];
387
+ readonly fragment: string;
388
+ readonly deepLink?: string | undefined;
389
+ /** The honest TLS/capability posture of the origin the QR points at (SDK-computed). */
390
+ readonly posture?: import('@pellux/goodvibes-sdk/platform/pairing').OriginPosture | undefined;
391
+ }
392
+
371
393
  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',
394
+ return await withRuntimeServices(runtime, async (services) => {
395
+ // Freeze a stable web origin once (never clobbering a user-set value) so the
396
+ // printed link and the daemon's own handoff origin agree and survive a DHCP
397
+ // lease change where a stable name exists. The verb re-derives the same origin
398
+ // and returns its honest posture, so this call is a persistence side effect.
399
+ ensurePublicBaseUrl(runtime.configManager);
400
+ const name = readOptionValue(runtime.cli.commandArgs, 'name') ?? defaultPairingTokenName();
401
+ // Mint through the canonical hand-off verb: it mints a fresh per-device token
402
+ // and returns the exact `#pair=<token>` deep link the web app consumes. The
403
+ // daemon filters the requested offer set down to what it can actually satisfy.
404
+ const result = (await services.gatewayMethods.invoke('pairing.handoff.create', {
405
+ body: { name, offers: [...PAIRING_HANDOFF_OFFER_KINDS] },
406
+ context: { principalId: 'admin', principalKind: 'user', admin: true },
407
+ })) as PairingHandoffCreateResult;
408
+ const link = result.deepLink ?? result.fragment;
409
+ const qr = renderQrToString(generateQrMatrix(link));
410
+ const lines: string[] = [
411
+ 'Scan to pair a device — the QR opens the web app already signed in:',
412
+ '',
413
+ ` ${link}`,
414
+ ` Token name: ${result.token.name} (rename or revoke later in /settings → security → devices)`,
415
+ ];
416
+ if (result.offers.length > 0) {
417
+ lines.push('', 'Offers carried in this pairing (each declinable in the web app):', ...formatPairingOffers(result.offers.map((o) => o.kind)));
418
+ }
419
+ // The labeled browser-capability list and the one honest LAN line both render
420
+ // from the SDK posture the verb returned — never a locally-authored string.
421
+ const capabilities = formatPostureCapabilities(result.posture);
422
+ if (capabilities.length > 0) lines.push('', 'This device will get:', ...capabilities);
423
+ const notice = pairingPostureNotice(result.posture);
424
+ if (notice) lines.push('', notice);
425
+ // Tailscale detection is read-only here (the interactive serve lives in the
426
+ // pairing modal); when detected with an https MagicDNS URL, name the encrypted
427
+ // path. Absence stays quiet.
428
+ lines.push(...(await renderPairingTailscaleLines(services)));
429
+ lines.push('', qr);
430
+ return lines.join('\n');
380
431
  });
381
- const payload = encodeConnectionPayload(info);
382
- const qr = renderQrToString(generateQrMatrix(payload));
383
- return [formatConnectionBlock(info, payload), '', qr].join('\n');
432
+ }
433
+
434
+ interface TailscaleGetResult {
435
+ readonly available: boolean;
436
+ readonly httpsUrl?: string | undefined;
437
+ readonly detail: string;
438
+ }
439
+
440
+ /**
441
+ * Read-only tailscale detection for the `gv pair` block: when tailscale is
442
+ * available with a served https MagicDNS URL, name the encrypted path (the
443
+ * interactive one-action serve lives in the pairing modal). Quiet — an empty
444
+ * array — when tailscale is absent or the probe fails.
445
+ */
446
+ async function renderPairingTailscaleLines(services: RuntimeServices): Promise<string[]> {
447
+ try {
448
+ const ts = (await services.gatewayMethods.invoke('tailscale.get', {
449
+ body: {},
450
+ context: { principalId: 'admin', principalKind: 'user', admin: true },
451
+ })) as TailscaleGetResult;
452
+ if (!ts.available) return [];
453
+ if (ts.httpsUrl) return ['', `Encrypted access (Tailscale): ${ts.httpsUrl}`];
454
+ return ['', 'Tailscale detected — run it as a serve target for encrypted https access (see the pairing modal).'];
455
+ } catch {
456
+ return [];
457
+ }
384
458
  }
385
459
 
386
460
  export async function renderRemote(runtime: CliCommandRuntime, label: 'remote' | 'bridge'): Promise<string> {
@@ -408,9 +482,11 @@ export function renderWeb(runtime: CliCommandRuntime): string {
408
482
  const binding = resolveRuntimeEndpointBinding(runtime.configManager, 'web');
409
483
  const publicBaseUrl = String(runtime.configManager.get('web.publicBaseUrl') ?? '');
410
484
  const hasEndpointOverride = runtime.cli.flags.hostname !== undefined || runtime.cli.flags.port !== undefined;
485
+ // Prefer the stable-name resolution over the raw first-non-internal IPv4 so the
486
+ // printed URL survives a DHCP lease change where a stable name exists.
411
487
  const url = !hasEndpointOverride && publicBaseUrl
412
488
  ? publicBaseUrl
413
- : `http://${urlHostForBindHost(binding.host)}:${binding.port}`;
489
+ : `http://${stableUrlHostForBindHost(binding.host).host}:${binding.port}`;
414
490
  const value = {
415
491
  enabled: runtime.configManager.get('web.enabled'),
416
492
  ...binding,
@@ -419,7 +495,7 @@ export function renderWeb(runtime: CliCommandRuntime): string {
419
495
  return formatJsonOrText(runtime.cli)(value, [
420
496
  'GoodVibes web',
421
497
  ` enabled: ${yesNo(value.enabled)}`,
422
- ` bind: ${value.hostMode} ${value.host}:${value.port}`,
498
+ ` bind: ${formatRuntimeEndpointBinding(value)}`,
423
499
  ` url: ${value.url}`,
424
500
  ...(runtime.cli.flags.open ? [` open: ${openBrowser(value.url)}`] : []),
425
501
  ].join('\n'));
@@ -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';
@@ -7,7 +7,7 @@ import { PlatformServiceManager } from '@pellux/goodvibes-sdk/platform/daemon';
7
7
  import type { ManagedServiceStatus } from '@pellux/goodvibes-sdk/platform/daemon';
8
8
  import { resolveDaemonEnabled } from '@pellux/goodvibes-sdk/platform/config';
9
9
  import type { ConfigManager } from '../config/index.ts';
10
- import { resolveRuntimeEndpointBinding } from './endpoints.ts';
10
+ import { formatRuntimeEndpointBinding, resolveRuntimeEndpointBinding } from './endpoints.ts';
11
11
  import type { RuntimeEndpointBinding, RuntimeEndpointId } from './endpoints.ts';
12
12
  import { classifyBindPosture, isNetworkFacing } from './network-posture.ts';
13
13
  import { redactText } from './redaction.ts';
@@ -395,7 +395,9 @@ export async function buildCliServicePosture(
395
395
  binding,
396
396
  bindPosture: classifyBindPosture(binding),
397
397
  networkFacing: isNetworkFacing(enabled, binding),
398
- ...(options.probe && enabled ? { reachable: await probeTcp(binding.host, binding.port) } : {}),
398
+ // Never TCP-probe the fallback endpoint of an unrecognized hostMode —
399
+ // a probe asserts a binding that does not exist.
400
+ ...(options.probe && enabled && binding.recognized ? { reachable: await probeTcp(binding.host, binding.port) } : {}),
399
401
  };
400
402
  }));
401
403
 
@@ -485,7 +487,7 @@ export function formatCliServicePosture(posture: CliServicePosture, json = false
485
487
  '',
486
488
  'Endpoints:',
487
489
  ...posture.endpoints.map((endpoint) =>
488
- ` ${endpoint.label}: enabled=${yesNo(endpoint.enabled)} ${endpoint.binding.hostMode} ${endpoint.binding.host}:${endpoint.binding.port} posture=${endpoint.bindPosture.label}${endpoint.reachable === undefined ? '' : ` reachable=${yesNo(endpoint.reachable)}`}`,
490
+ ` ${endpoint.label}: enabled=${yesNo(endpoint.enabled)} ${formatRuntimeEndpointBinding(endpoint.binding)} posture=${endpoint.binding.recognized ? endpoint.bindPosture.label : 'unknown'}${endpoint.reachable === undefined ? '' : ` reachable=${yesNo(endpoint.reachable)}`}`,
489
491
  ),
490
492
  '',
491
493
  posture.issues.length === 0 ? 'Readiness: ready' : 'Readiness: needs attention',
package/src/cli/status.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
2
2
  import { resolveDaemonEnabled } from '@pellux/goodvibes-sdk/platform/config';
3
3
  import type { OnboardingCheckMarkersState } from '../runtime/onboarding/index.ts';
4
- import { resolveRuntimeEndpointBinding } from './endpoints.ts';
5
- import type { RuntimeEndpointId } from './endpoints.ts';
4
+ import { formatRuntimeEndpointBinding, resolveRuntimeEndpointBinding } from './endpoints.ts';
5
+ import type { RuntimeEndpointBinding, RuntimeEndpointId } from './endpoints.ts';
6
6
  import { classifyBindPosture, isNetworkFacing } from './network-posture.ts';
7
7
  import type { GoodVibesCliOutputFormat } from './types.ts';
8
8
  import type { CliServicePosture } from './service-posture.ts';
@@ -165,8 +165,8 @@ function secretPolicyLabel(policy: unknown): string {
165
165
  return String(policy ?? 'unknown');
166
166
  }
167
167
 
168
- function bindLine(label: string, enabled: unknown, binding: { readonly hostMode: string; readonly host: string; readonly port: number }): string {
169
- return ` ${label}: ${yesNo(enabled)} (${binding.hostMode} ${binding.host}:${binding.port})`;
168
+ function bindLine(label: string, enabled: unknown, binding: RuntimeEndpointBinding): string {
169
+ return ` ${label}: ${yesNo(enabled)} (${formatRuntimeEndpointBinding(binding)})`;
170
170
  }
171
171
 
172
172
  /** Describe registration coverage for the Workspace status section. */
@@ -300,8 +300,8 @@ export function buildCliExposureReport(options: CliStatusOptions): readonly CliE
300
300
  id: surface.id,
301
301
  label: surface.label,
302
302
  enabled,
303
- bind: `${binding.hostMode} ${binding.host}:${binding.port}`,
304
- reach: classifyBindPosture(binding).label,
303
+ bind: formatRuntimeEndpointBinding(binding),
304
+ reach: binding.recognized ? classifyBindPosture(binding).label : 'Unknown (unrecognized host mode)',
305
305
  networkFacing,
306
306
  authMode: surfaceAuthMode(networkFacing, options.auth),
307
307
  originAllowlist: surfaceOriginAllowlist(surface.id, config),
@@ -333,6 +333,27 @@ export function buildCliDoctorFindings(options: CliStatusOptions): readonly CliD
333
333
 
334
334
  const findings: CliDoctorFinding[] = [];
335
335
 
336
+ // An unrecognized hostMode has NO binding the SDK can produce (its bind
337
+ // resolver has no default case — the daemon throws before binding), so it
338
+ // is surfaced as a doctor finding on every endpoint that carries one,
339
+ // instead of the fallback loopback binding being presented as fact.
340
+ for (const [endpointId, endpointLabel, endpointBinding] of [
341
+ ['controlPlane', 'control plane', controlPlaneBinding],
342
+ ['httpListener', 'HTTP listener', httpListenerBinding],
343
+ ['web', 'web surface', webBinding],
344
+ ] as const) {
345
+ if (endpointBinding.recognized) continue;
346
+ findings.push({
347
+ id: `unrecognized-host-mode-${endpointId}`,
348
+ area: 'service',
349
+ severity: 'warning',
350
+ summary: `${endpointLabel} hostMode '${endpointBinding.hostMode}' is not a recognized mode.`,
351
+ cause: `${endpointId}.hostMode is set to '${endpointBinding.hostMode}', but only local|network|custom are recognized.`,
352
+ impact: 'The daemon cannot resolve a binding for this endpoint and will fail to start it; displays cannot show a real bind address.',
353
+ action: `Set ${endpointId}.hostMode to local, network, or custom.`,
354
+ });
355
+ }
356
+
336
357
  if (serverBackedEnabled && !serviceEnabled) {
337
358
  findings.push({
338
359
  id: 'service-disabled-for-server-surfaces',
@@ -6,7 +6,7 @@ import {
6
6
  resolveGoodVibesNtfyTopics,
7
7
  } from '@pellux/goodvibes-sdk/platform/integrations';
8
8
  import { enableFeatureFlags, getMissingSurfaceFeatureFlags, getServerSurfaceFeatureFlags, surfaceFeatureGateSettingsKeys } from '../runtime/surface-feature-flags.ts';
9
- import { resolveRuntimeEndpointBinding } from './endpoints.ts';
9
+ import { formatRuntimeEndpointBinding, resolveRuntimeEndpointBinding } from './endpoints.ts';
10
10
  import { classifyBindPosture, isNetworkFacing } from './network-posture.ts';
11
11
  import type { CliCommandRuntime } from './types.ts';
12
12
  import {
@@ -103,9 +103,9 @@ export async function handleSurfacesCommand(runtime: CliCommandRuntime): Promise
103
103
  const shouldProbeListener = includeProbe && (!target || targetExternalSurface);
104
104
  const [controlPlaneReachable, webReachable, listenerReachable] = includeProbe
105
105
  ? await Promise.all([
106
- shouldProbeControlPlane ? probeTcp(controlPlane.host, controlPlane.port) : Promise.resolve(undefined),
107
- shouldProbeWeb ? probeTcp(web.host, web.port) : Promise.resolve(undefined),
108
- shouldProbeListener ? probeTcp(httpListener.host, httpListener.port) : Promise.resolve(undefined),
106
+ shouldProbeControlPlane && controlPlane.recognized ? probeTcp(controlPlane.host, controlPlane.port) : Promise.resolve(undefined),
107
+ shouldProbeWeb && web.recognized ? probeTcp(web.host, web.port) : Promise.resolve(undefined),
108
+ shouldProbeListener && httpListener.recognized ? probeTcp(httpListener.host, httpListener.port) : Promise.resolve(undefined),
109
109
  ])
110
110
  : [undefined, undefined, undefined];
111
111
  const externalSurfaces = SURFACE_CONFIGS.map(([id, label, requiredKeys]) => {
@@ -129,13 +129,18 @@ export async function handleSurfacesCommand(runtime: CliCommandRuntime): Promise
129
129
  remoteTopic: String(config.get('surfaces.ntfy.remoteTopic' as ConfigKey) || GOODVIBES_NTFY_REMOTE_TOPIC),
130
130
  });
131
131
  const readinessIssues: string[] = [];
132
- if (shouldProbeControlPlane && config.get('controlPlane.enabled') === true && !controlPlaneReachable) {
132
+ for (const [endpointLabel, endpointBinding] of [['controlPlane', controlPlane], ['web', web], ['httpListener', httpListener]] as const) {
133
+ if (!endpointBinding.recognized) {
134
+ readinessIssues.push(`${endpointLabel}.hostMode '${endpointBinding.hostMode}' is not a recognized mode (local|network|custom) — the daemon cannot bind this endpoint until it is corrected.`);
135
+ }
136
+ }
137
+ if (shouldProbeControlPlane && controlPlane.recognized && config.get('controlPlane.enabled') === true && !controlPlaneReachable) {
133
138
  readinessIssues.push(`Control plane is enabled but not reachable on ${controlPlane.host}:${controlPlane.port}.`);
134
139
  }
135
- if (shouldProbeWeb && config.get('web.enabled') === true && !webReachable) {
140
+ if (shouldProbeWeb && web.recognized && config.get('web.enabled') === true && !webReachable) {
136
141
  readinessIssues.push(`Web surface is enabled but not reachable on ${web.host}:${web.port}.`);
137
142
  }
138
- if (shouldProbeListener && config.get('danger.httpListener') === true && !listenerReachable) {
143
+ if (shouldProbeListener && httpListener.recognized && config.get('danger.httpListener') === true && !listenerReachable) {
139
144
  readinessIssues.push(`HTTP listener is enabled but not reachable on ${httpListener.host}:${httpListener.port}.`);
140
145
  }
141
146
  for (const surface of filteredSurfaces) {
@@ -157,6 +162,7 @@ export async function handleSurfacesCommand(runtime: CliCommandRuntime): Promise
157
162
  configuredHost: controlPlane.configuredHost,
158
163
  host: controlPlane.host,
159
164
  port: controlPlane.port,
165
+ recognized: controlPlane.recognized,
160
166
  reachable: controlPlaneReachable,
161
167
  },
162
168
  web: {
@@ -165,6 +171,7 @@ export async function handleSurfacesCommand(runtime: CliCommandRuntime): Promise
165
171
  configuredHost: web.configuredHost,
166
172
  host: web.host,
167
173
  port: web.port,
174
+ recognized: web.recognized,
168
175
  reachable: webReachable,
169
176
  },
170
177
  httpListener: {
@@ -173,6 +180,7 @@ export async function handleSurfacesCommand(runtime: CliCommandRuntime): Promise
173
180
  configuredHost: httpListener.configuredHost,
174
181
  host: httpListener.host,
175
182
  port: httpListener.port,
183
+ recognized: httpListener.recognized,
176
184
  reachable: listenerReachable,
177
185
  },
178
186
  surfaces: filteredSurfaces,
@@ -180,9 +188,9 @@ export async function handleSurfacesCommand(runtime: CliCommandRuntime): Promise
180
188
  };
181
189
  const output = formatJsonOrText(runtime.cli)(value, [
182
190
  'GoodVibes surfaces',
183
- ` control-plane: ${yesNo(value.controlPlane.enabled)} (${value.controlPlane.hostMode} ${value.controlPlane.host}:${value.controlPlane.port})${includeProbe ? ` reachable=${yesNo(value.controlPlane.reachable)}` : ''}`,
184
- ` web: ${yesNo(value.web.enabled)} (${value.web.hostMode} ${value.web.host}:${value.web.port})${includeProbe ? ` reachable=${yesNo(value.web.reachable)}` : ''}`,
185
- ` http-listener: ${yesNo(value.httpListener.enabled)} (${value.httpListener.hostMode} ${value.httpListener.host}:${value.httpListener.port})${includeProbe ? ` reachable=${yesNo(value.httpListener.reachable)}` : ''}`,
191
+ ` control-plane: ${yesNo(value.controlPlane.enabled)} (${formatRuntimeEndpointBinding(value.controlPlane)})${includeProbe && value.controlPlane.reachable !== undefined ? ` reachable=${yesNo(value.controlPlane.reachable)}` : ''}`,
192
+ ` web: ${yesNo(value.web.enabled)} (${formatRuntimeEndpointBinding(value.web)})${includeProbe && value.web.reachable !== undefined ? ` reachable=${yesNo(value.web.reachable)}` : ''}`,
193
+ ` http-listener: ${yesNo(value.httpListener.enabled)} (${formatRuntimeEndpointBinding(value.httpListener)})${includeProbe && value.httpListener.reachable !== undefined ? ` reachable=${yesNo(value.httpListener.reachable)}` : ''}`,
186
194
  '',
187
195
  'External surfaces:',
188
196
  ...value.surfaces.map((surface) => ` ${surface.label.padEnd(16)} enabled=${yesNo(surface.enabled)} ready=${yesNo(surface.ready)}${surface.enabled && surface.missing.length > 0 ? ` missing=${surface.missing.join(',')}` : ''}${surface.enabled && surface.missingFeatureFlags.length > 0 ? ` settingsOff=${surfaceFeatureGateSettingsKeys(surface.missingFeatureFlags).join(',')}` : ''}`),
@@ -208,8 +216,10 @@ export interface ListenerTestResult {
208
216
  readonly configuredHost: string;
209
217
  readonly host: string;
210
218
  readonly port: number;
219
+ readonly recognized: boolean;
211
220
  readonly posture: ReturnType<typeof classifyBindPosture>;
212
- readonly reachable: boolean;
221
+ /** undefined = NOT PROBED (unrecognized host mode) — a tri-state, never coerced to false. */
222
+ readonly reachable: boolean | undefined;
213
223
  readonly service: {
214
224
  readonly enabled: unknown;
215
225
  readonly autostart: unknown;
@@ -231,7 +241,10 @@ export async function buildListenerTestResult(runtime: CliCommandRuntime): Promi
231
241
  const enabled = runtime.configManager.get('danger.httpListener');
232
242
  const binding = resolveRuntimeEndpointBinding(runtime.configManager, 'httpListener');
233
243
  const posture = classifyBindPosture(binding);
234
- const reachable = enabled === true ? await probeTcp(binding.host, binding.port) : false;
244
+ // Not-probed (unrecognized host mode) stays undefined — never a definite false.
245
+ const reachable = enabled === true
246
+ ? (binding.recognized ? await probeTcp(binding.host, binding.port) : undefined)
247
+ : false;
235
248
  const auth = readAuthPaths(runtime);
236
249
  const service = {
237
250
  enabled: runtime.configManager.get('service.enabled'),
@@ -252,6 +265,7 @@ export async function buildListenerTestResult(runtime: CliCommandRuntime): Promi
252
265
  };
253
266
  }).filter((surface) => surface.enabled === true);
254
267
  const issues: string[] = [];
268
+ if (!binding.recognized) issues.push(`httpListener.hostMode '${binding.hostMode}' is not a recognized mode (local|network|custom) — the daemon cannot bind until it is corrected.`);
255
269
  if (enabled !== true) issues.push('HTTP listener is disabled.');
256
270
  if (enabled === true && service.enabled !== true) issues.push('HTTP listener is enabled but service mode is off.');
257
271
  if (enabled === true && service.autostart !== true) issues.push('HTTP listener is enabled but service autostart is off.');
@@ -269,9 +283,9 @@ export function formatListenerTestResult(runtime: CliCommandRuntime, value: List
269
283
  return formatJsonOrText(runtime.cli)(value, [
270
284
  'GoodVibes listener test',
271
285
  ` enabled: ${yesNo(value.enabled)}`,
272
- ` endpoint: ${value.hostMode} ${value.host}:${value.port}`,
273
- ` bind posture: ${value.posture.label}`,
274
- ` reachable: ${yesNo(value.reachable)}`,
286
+ ` endpoint: ${formatRuntimeEndpointBinding(value)}`,
287
+ ` bind posture: ${value.recognized ? value.posture.label : 'unknown (unrecognized host mode)'}`,
288
+ ` reachable: ${value.reachable === undefined ? 'not probed (unrecognized host mode)' : yesNo(value.reachable)}`,
275
289
  ` service: enabled=${yesNo(value.service.enabled)} autostart=${yesNo(value.service.autostart)} restartOnFailure=${yesNo(value.service.restartOnFailure)}`,
276
290
  ` local auth users: ${value.auth.userStorePresent ? 'present' : 'missing'}`,
277
291
  ` bootstrap credential: ${value.auth.bootstrapCredentialPresent ? 'present' : 'missing'}`,
@@ -0,0 +1,41 @@
1
+ // ---------------------------------------------------------------------------
2
+ // consolidation-receipt.ts — the one-line notice for an idle-time memory
3
+ // consolidation run.
4
+ //
5
+ // The SDK's MemoryConsolidationScheduler runs at idle and produces a
6
+ // MemoryConsolidationRunReceipt (merged / archived / decayed / proposed). This
7
+ // formats it into the same one-line shape every other attach-time receipt
8
+ // uses, so a run that changed something surfaces on the next attach like a
9
+ // crash/update/migration receipt. A pure no-op run yields null — no noise.
10
+ //
11
+ // A run that PROPOSED something (a contradiction or cross-scope-duplicate
12
+ // awaiting human judgment — never auto-applied) names where to actually look:
13
+ // the Memory modal's Proposals tab (`/memory`, then the Proposals tab —
14
+ // memory-modal.ts's proposalsTab()), which lists each proposal's kind,
15
+ // reason, and affected record ids and jumps to them in the Review Queue.
16
+ // Without this pointer the receipt line was the only trace a proposal ever
17
+ // existed — a count with nowhere to go.
18
+ // ---------------------------------------------------------------------------
19
+
20
+ import type { MemoryConsolidationRunReceipt } from '@pellux/goodvibes-sdk/platform/state';
21
+
22
+ /**
23
+ * A one-line receipt for a consolidation run that changed something, or null
24
+ * when the run merged/archived/decayed/proposed nothing (a quiet idle pass —
25
+ * no notice, matching the check-in "stayed quiet" discipline).
26
+ */
27
+ export function formatConsolidationReceipt(receipt: MemoryConsolidationRunReceipt): string | null {
28
+ const merged = receipt.merged.length;
29
+ const archived = receipt.archived.length;
30
+ const decayed = receipt.decayed.length;
31
+ const proposed = receipt.proposed.length;
32
+ if (merged + archived + decayed + proposed === 0) return null;
33
+ const parts: string[] = [];
34
+ if (merged) parts.push(`${merged} merged`);
35
+ if (archived) parts.push(`${archived} archived`);
36
+ if (decayed) parts.push(`${decayed} decayed`);
37
+ if (proposed) parts.push(`${proposed} proposed`);
38
+ const base = `Memory consolidation: ${parts.join(', ')} (scanned ${receipt.scanned}).`;
39
+ if (proposed === 0) return base;
40
+ return `${base} Review the ${proposed} proposed change${proposed === 1 ? '' : 's'} with /memory (Proposals tab).`;
41
+ }