@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
@@ -0,0 +1,202 @@
1
+ // ---------------------------------------------------------------------------
2
+ // voice-provision-gateway.ts
3
+ //
4
+ // The daemon-backed verbs the /voice setup + /voice status surfaces read:
5
+ // voice.local.status (GET /api/voice/local/status) — the no-network installed
6
+ // snapshot — and voice.local.install (POST /api/voice/local/install) — the
7
+ // one-act managed install. Neither has a named facade on the in-process
8
+ // OperatorClient, so — exactly like the memory-consolidation gateway — they go
9
+ // over the generic operator invoke path (operator-rpc.ts's resolveOperatorRpc
10
+ // -> sdk.operator.invoke), reaching the SAME daemon the command layer does.
11
+ //
12
+ // A daemon that predates managed voice provisioning answers an honest 501 (verb
13
+ // cataloged but no handler) or a 404 (older daemon without the route) — both
14
+ // are "verb unavailable", NOT a fabricated empty/failed install (see
15
+ // classifyVoiceProvisionError). The interface is injectable so the command and
16
+ // its tests round-trip against a mocked daemon.
17
+ // ---------------------------------------------------------------------------
18
+
19
+ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
20
+ import { GoodVibesSdkError } from '@pellux/goodvibes-sdk';
21
+ import { resolveOperatorRpc, describeOperatorRpcError } from '../input/commands/operator-rpc.ts';
22
+ import type { VoiceRuntimeStatusResult, VoiceLocalInstallResult } from './voice-provision-status.ts';
23
+ import { voiceStatusLines, voiceInstallReceiptLines, voiceInstallComponentLine } from './voice-provision-status.ts';
24
+
25
+ /** The narrow async verb surface the /voice setup + /voice status surfaces drive. */
26
+ export interface VoiceProvisionGateway {
27
+ /** Read the no-network managed-runtime snapshot (voice.local.status). */
28
+ fetchStatus(): Promise<VoiceRuntimeStatusResult>;
29
+ /** Run the one-act managed install (voice.local.install); resolves to the receipt. */
30
+ runInstall(): Promise<VoiceLocalInstallResult>;
31
+ }
32
+
33
+ /**
34
+ * Why the gateway could not be built (daemon disabled / no control-plane URL),
35
+ * surfaced verbatim so the surface prints an honest "unavailable" line rather
36
+ * than guessing — mirrors MemoryConsolidationGatewayResolution.
37
+ */
38
+ export type VoiceProvisionGatewayResolution =
39
+ | { readonly available: true; readonly gateway: VoiceProvisionGateway }
40
+ | { readonly available: false; readonly reason: string };
41
+
42
+ export interface VoiceProvisionGatewayDeps {
43
+ readonly configManager: ConfigManager;
44
+ readonly homeDirectory: string | (() => string);
45
+ }
46
+
47
+ /**
48
+ * Build the live voice-provisioning gateway over the generic operator invoke
49
+ * path — the same daemon resolution the command layer uses. Returns an honest
50
+ * unavailable reason when no daemon is reachable, so the surface refuses
51
+ * cleanly instead of throwing mid-render.
52
+ */
53
+ export function createVoiceProvisionGateway(deps: VoiceProvisionGatewayDeps): VoiceProvisionGatewayResolution {
54
+ const rpc = resolveOperatorRpc({ configManager: deps.configManager, homeDirectory: deps.homeDirectory });
55
+ if (!rpc.available) return { available: false, reason: rpc.reason };
56
+ const { sdk } = rpc;
57
+ return {
58
+ available: true,
59
+ gateway: {
60
+ fetchStatus: () => sdk.operator.invoke('voice.local.status', {}),
61
+ runInstall: () => sdk.operator.invoke('voice.local.install', {}),
62
+ },
63
+ };
64
+ }
65
+
66
+ /** How a voice verb rejection should render. */
67
+ export type VoiceProvisionFetchFailure =
68
+ | { readonly kind: 'unavailable'; readonly reason: string }
69
+ | { readonly kind: 'error'; readonly message: string };
70
+
71
+ /**
72
+ * Classify a voice verb rejection: a 501 (a daemon that predates managed voice
73
+ * provisioning — cataloged but unhandled) or a 404 (an older daemon without the
74
+ * route) are both "verb unavailable", distinct from a generic request failure
75
+ * (network error, 401/403, 500). Reuses describeOperatorRpcError's wording so
76
+ * the call sites never diverge on how they describe the same daemon response.
77
+ */
78
+ export function classifyVoiceProvisionError(error: unknown): VoiceProvisionFetchFailure {
79
+ if (error instanceof GoodVibesSdkError && (error.status === 501 || error.status === 404)) {
80
+ return { kind: 'unavailable', reason: 'this daemon does not serve managed voice provisioning yet.' };
81
+ }
82
+ return { kind: 'error', message: describeOperatorRpcError(error) };
83
+ }
84
+
85
+ /** The single one-act setup announcement shown before the (possibly long) install runs. */
86
+ export const VOICE_SETUP_ANNOUNCEMENT = [
87
+ 'Local Voice Setup',
88
+ ' provisioning the managed local voice runtime (piper TTS + a default voice; whisper STT where a bundle is published)…',
89
+ ' downloads are checksum-verified and resumable — re-run /voice setup to retry any failed component.',
90
+ ].join('\n');
91
+
92
+ /**
93
+ * Render the /voice status or /voice setup RESULT block from an already-resolved
94
+ * gateway resolution — the testable core the command wraps. Kept separate from
95
+ * the live gateway construction so a wire test injects a fake resolution
96
+ * (available / unavailable / failing) with no HTTP, mirroring the Memory modal's
97
+ * injectable resolveConsolidationGateway seam. The caller prints
98
+ * VOICE_SETUP_ANNOUNCEMENT itself before awaiting a setup so the up-front notice
99
+ * shows during the (possibly long, multi-hundred-MB) install.
100
+ */
101
+ export async function renderVoiceProvision(
102
+ sub: 'status' | 'setup',
103
+ resolution: VoiceProvisionGatewayResolution,
104
+ ): Promise<string> {
105
+ if (!resolution.available) {
106
+ const label = sub === 'status' ? 'Local Voice Runtime' : 'Local Voice Setup';
107
+ return `${label} unavailable: ${resolution.reason}`;
108
+ }
109
+ if (sub === 'status') {
110
+ try {
111
+ const status = await resolution.gateway.fetchStatus();
112
+ return ['Local Voice Runtime', ...voiceStatusLines(status)].join('\n');
113
+ } catch (error) {
114
+ const failure = classifyVoiceProvisionError(error);
115
+ return failure.kind === 'unavailable'
116
+ ? `Local Voice Runtime\n ${failure.reason}`
117
+ : `Local Voice Runtime\n could not read status: ${failure.message}`;
118
+ }
119
+ }
120
+ // setup
121
+ try {
122
+ const receipt = await resolution.gateway.runInstall();
123
+ return ['Local Voice Setup — receipt', ...voiceInstallReceiptLines(receipt)].join('\n');
124
+ } catch (error) {
125
+ const failure = classifyVoiceProvisionError(error);
126
+ return failure.kind === 'unavailable'
127
+ ? `Local Voice Setup\n ${failure.reason}`
128
+ : `Local Voice Setup\n install failed: ${failure.message}`;
129
+ }
130
+ }
131
+
132
+ export interface VoiceSetupProgressOptions {
133
+ /** Sink for each printed block (the command passes ctx.print). */
134
+ readonly print: (block: string) => void;
135
+ /** Poll interval while the install is in flight (default 800ms). */
136
+ readonly pollMs?: number;
137
+ /** Injectable delay (tests pass a fake; the command uses setTimeout). */
138
+ readonly sleep?: (ms: number) => Promise<void>;
139
+ }
140
+
141
+ const realSleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
142
+
143
+ /**
144
+ * Run /voice setup with LIVE progress: kick off the (long, request/response)
145
+ * install and, while it is in flight, poll voice.local.status for its
146
+ * `installInProgress` snapshot, printing a per-component line each time a
147
+ * component's phase advances (download → verify → extract → done). When the
148
+ * install settles, print the final receipt (or an honest failure). The install
149
+ * verb streams nothing itself — this polling is exactly how the SDK intends
150
+ * surfaces to render real progress instead of busy→receipt.
151
+ *
152
+ * The caller prints VOICE_SETUP_ANNOUNCEMENT before calling this. gateway
153
+ * fetchStatus/runInstall and sleep are injectable so a wire test drives the
154
+ * whole flow with no real timers or HTTP.
155
+ */
156
+ export async function runVoiceSetupWithProgress(
157
+ resolution: VoiceProvisionGatewayResolution,
158
+ opts: VoiceSetupProgressOptions,
159
+ ): Promise<void> {
160
+ if (!resolution.available) {
161
+ opts.print(`Local Voice Setup unavailable: ${resolution.reason}`);
162
+ return;
163
+ }
164
+ const { gateway } = resolution;
165
+ const pollMs = opts.pollMs ?? 800;
166
+ const sleep = opts.sleep ?? realSleep;
167
+
168
+ let settled = false;
169
+ const installPromise = gateway.runInstall().then(
170
+ (receipt) => { settled = true; return { ok: true as const, receipt }; },
171
+ (error) => { settled = true; return { ok: false as const, error }; },
172
+ );
173
+
174
+ // Print a component line only when its phase changes, so a slow install does
175
+ // not spam identical lines each poll.
176
+ const lastPhase = new Map<string, string>();
177
+ while (!settled) {
178
+ await sleep(pollMs);
179
+ if (settled) break;
180
+ try {
181
+ const status = await gateway.fetchStatus();
182
+ const progress = status.installInProgress;
183
+ if (!progress) continue;
184
+ const changed = progress.components.filter((c) => lastPhase.get(c.component) !== c.phase);
185
+ for (const c of changed) lastPhase.set(c.component, c.phase);
186
+ if (changed.length > 0) opts.print(changed.map(voiceInstallComponentLine).join('\n'));
187
+ } catch {
188
+ // A transient poll failure never aborts the install; the receipt/failure
189
+ // block below is the authoritative outcome.
190
+ }
191
+ }
192
+
193
+ const result = await installPromise;
194
+ if (result.ok) {
195
+ opts.print(['Local Voice Setup — receipt', ...voiceInstallReceiptLines(result.receipt)].join('\n'));
196
+ } else {
197
+ const failure = classifyVoiceProvisionError(result.error);
198
+ opts.print(failure.kind === 'unavailable'
199
+ ? `Local Voice Setup\n ${failure.reason}`
200
+ : `Local Voice Setup\n install failed: ${failure.message}`);
201
+ }
202
+ }
@@ -0,0 +1,197 @@
1
+ // ---------------------------------------------------------------------------
2
+ // voice-provision-status.ts — the surface-facing projection of the managed
3
+ // local-voice runtime (voice.local.status / voice.local.install).
4
+ //
5
+ // The SDK owns the provisioning policy: a one-act install that fetches the
6
+ // piper TTS engine + a default voice (fully hosted) and, where a pinned bundle
7
+ // is hosted for the platform, the whisper STT engine + model — atomic,
8
+ // checksum-verified, resumable, and honest about every terminal state. This
9
+ // module is the read-only projection the /voice surfaces render from: sizes
10
+ // declared up front, a receipt of what was installed / skipped / failed, and
11
+ // the honest "not yet published for this platform" line when a whisper bundle
12
+ // is pinned but not yet hosted (its manifest url is unstamped).
13
+ //
14
+ // Pure formatting only (no I/O): the command layer fetches the typed verb
15
+ // responses over the operator invoke seam (voice-provision-gateway.ts) and
16
+ // hands them here, so these builders are unit-testable against fixture shapes.
17
+ // ---------------------------------------------------------------------------
18
+
19
+ import type { OperatorMethodOutput } from '@pellux/goodvibes-sdk';
20
+
21
+ /** voice.local.status output — the no-network installed/available snapshot. */
22
+ export type VoiceRuntimeStatusResult = OperatorMethodOutput<'voice.local.status'>;
23
+ /** voice.local.install receipt — the one-act install outcome. */
24
+ export type VoiceLocalInstallResult = OperatorMethodOutput<'voice.local.install'>;
25
+ /** The live per-component progress carried on voice.local.status WHILE an install runs. */
26
+ export type VoiceInstallProgress = NonNullable<VoiceRuntimeStatusResult['installInProgress']>;
27
+ /** One live install component's progress (latest phase wins). */
28
+ export type VoiceInstallComponentProgress = VoiceInstallProgress['components'][number];
29
+
30
+ /** Human byte size (mirrors the local formatBytes idiom used across the renderer). */
31
+ export function formatVoiceBytes(bytes: number | null | undefined): string {
32
+ if (bytes === null || bytes === undefined || !Number.isFinite(bytes) || bytes < 0) return 'unknown size';
33
+ if (bytes < 1024) return `${bytes} B`;
34
+ const units = ['KB', 'MB', 'GB'];
35
+ let value = bytes / 1024;
36
+ let unit = 0;
37
+ while (value >= 1024 && unit < units.length - 1) {
38
+ value /= 1024;
39
+ unit += 1;
40
+ }
41
+ return `${value >= 10 ? value.toFixed(0) : value.toFixed(1)} ${units[unit]}`;
42
+ }
43
+
44
+ /**
45
+ * True when a whisper STT bundle is PINNED for this platform (bytes + checksum
46
+ * fixed) but not yet hosted/sideloaded — the "supported but not yet published"
47
+ * state. The SDK signals it as supported === true with an unprovisioned state
48
+ * and an honest reason string; contrast an unsupported platform (supported ===
49
+ * false), which has no pin at all.
50
+ */
51
+ export function isSttPinnedButUnpublished(status: VoiceRuntimeStatusResult): boolean {
52
+ const stt = status.stt;
53
+ return stt.supported === true && stt.state !== 'provisioned' && !stt.binaryPresent && !stt.modelPresent
54
+ && typeof stt.reason === 'string' && stt.reason.length > 0;
55
+ }
56
+
57
+ /** Whether the managed runtime still has something to offer to provision. */
58
+ export function voiceRuntimeNeedsSetup(status: VoiceRuntimeStatusResult): boolean {
59
+ return status.state !== 'provisioned' || status.stt.state === 'not-provisioned' || status.stt.state === 'partial';
60
+ }
61
+
62
+ /**
63
+ * The /voice status lines — an honest, plain-language account of the managed
64
+ * local-voice runtime with sizes declared up front. Order: platform + overall
65
+ * state, then the TTS (piper) engine, then the STT (whisper) engine with its
66
+ * honest unsupported / not-yet-published wording, then the one-act offer size.
67
+ */
68
+ export function voiceStatusLines(status: VoiceRuntimeStatusResult): string[] {
69
+ const lines: string[] = [];
70
+ lines.push(` platform: ${status.platform ?? 'unrecognized (no managed voice build)'}`);
71
+ lines.push(` runtime: ${status.state}`);
72
+
73
+ // TTS — piper, fully hosted where a platform build exists.
74
+ const tts = status.tts;
75
+ if (tts.binaryPresent && tts.voicePresent) {
76
+ lines.push(` tts (${tts.engine}): installed`);
77
+ } else if (status.state === 'unsupported-platform') {
78
+ lines.push(` tts (${tts.engine}): unsupported on this platform`);
79
+ } else {
80
+ const have = [tts.binaryPresent ? 'engine' : null, tts.voicePresent ? 'voice' : null].filter(Boolean);
81
+ lines.push(` tts (${tts.engine}): not installed${have.length ? ` (have ${have.join(' + ')})` : ''}`);
82
+ }
83
+
84
+ // STT — whisper, hosted only where a pinned bundle is published. Render the
85
+ // REAL state honestly: a platform with no pinned build (supported === false,
86
+ // or state 'unsupported-platform' — every platform except linux-x64 today)
87
+ // reads "unsupported on this platform"; a platform whose bundle is pinned but
88
+ // not yet hosted (supported === true, unprovisioned, with a reason) reads
89
+ // "not yet published for this platform".
90
+ const stt = status.stt;
91
+ if (stt.binaryPresent && stt.modelPresent) {
92
+ lines.push(` stt (${stt.engine}): installed`);
93
+ } else if (!stt.supported || stt.state === 'unsupported-platform') {
94
+ lines.push(` stt (${stt.engine}): unsupported on this platform`);
95
+ if (stt.reason) lines.push(` ${stt.reason}`);
96
+ } else if (isSttPinnedButUnpublished(status)) {
97
+ lines.push(` stt (${stt.engine}): not yet published for this platform`);
98
+ if (stt.reason) lines.push(` ${stt.reason}`);
99
+ } else {
100
+ lines.push(` stt (${stt.engine}): not installed`);
101
+ if (stt.reason) lines.push(` ${stt.reason}`);
102
+ }
103
+
104
+ if (status.offerBytes !== null && status.offerBytes !== undefined && voiceRuntimeNeedsSetup(status)) {
105
+ lines.push(` setup download: ${formatVoiceBytes(status.offerBytes)} — run /voice setup to install`);
106
+ }
107
+ return lines;
108
+ }
109
+
110
+ /** A human phase label for a live install component. */
111
+ export function voiceProgressPhaseLabel(phase: string): string {
112
+ switch (phase) {
113
+ case 'skip': return 'already present';
114
+ case 'download': return 'downloading';
115
+ case 'verify': return 'verifying';
116
+ case 'extract': return 'extracting';
117
+ case 'done': return 'done';
118
+ case 'error': return 'failed';
119
+ default: return phase;
120
+ }
121
+ }
122
+
123
+ /** One live progress line for an install component (size shown as done/total where known). */
124
+ export function voiceInstallComponentLine(component: VoiceInstallComponentProgress): string {
125
+ let size = '';
126
+ if (component.bytesTotal !== undefined) {
127
+ const done = component.bytesDone !== undefined ? `${formatVoiceBytes(component.bytesDone)}/` : '';
128
+ size = ` (${done}${formatVoiceBytes(component.bytesTotal)})`;
129
+ }
130
+ const msg = component.message ? ` — ${component.message}` : '';
131
+ return ` ${component.component}: ${voiceProgressPhaseLabel(component.phase)}${size}${msg}`;
132
+ }
133
+
134
+ /** Render the live per-component install progress (voice.local.status installInProgress). */
135
+ export function voiceInstallProgressLines(progress: VoiceInstallProgress): string[] {
136
+ return progress.components.map(voiceInstallComponentLine);
137
+ }
138
+
139
+ /** The honest per-terminal-state one-liner for a TTS/STT engine outcome. */
140
+ export function voiceEngineOutcomeLine(engine: string, state: string, reason: string | undefined): string {
141
+ switch (state) {
142
+ case 'provisioned':
143
+ return ` ${engine}: installed`;
144
+ case 'download-failed':
145
+ return ` ${engine}: download failed${reason ? ` — ${reason}` : ''} (re-run /voice setup to retry; completed parts are kept)`;
146
+ case 'checksum-mismatch':
147
+ return ` ${engine}: checksum mismatch — the downloaded file did not match its pinned checksum and was discarded${reason ? ` (${reason})` : ''}`;
148
+ case 'bundle-unavailable':
149
+ return ` ${engine}: not yet published for this platform${reason ? ` — ${reason}` : ''}`;
150
+ case 'sideload-mismatch':
151
+ return ` ${engine}: a sideloaded bundle was present but did not match the pinned checksum${reason ? ` — ${reason}` : ''}`;
152
+ case 'unsupported-platform':
153
+ return ` ${engine}: unsupported on this platform${reason ? ` — ${reason}` : ''}`;
154
+ default:
155
+ return ` ${engine}: ${state}${reason ? ` — ${reason}` : ''}`;
156
+ }
157
+ }
158
+
159
+ /**
160
+ * The /voice setup receipt lines — the final one-act outcome. Renders the
161
+ * per-component breakdown (installed / skipped-as-present / failed with size),
162
+ * the TTS and STT terminal states with honest wording, and the config write
163
+ * receipt (which voice.local.* keys were configured vs skipped as user-set).
164
+ */
165
+ export function voiceInstallReceiptLines(result: VoiceLocalInstallResult): string[] {
166
+ const lines: string[] = [];
167
+ lines.push(` result: ${result.provisioned ? 'local voice provisioned' : 'not provisioned'}`);
168
+ lines.push(` platform: ${result.platform ?? 'unrecognized (no managed voice build)'}`);
169
+
170
+ lines.push(voiceEngineOutcomeLine(`tts (${result.tts.engine})`, result.tts.state, result.tts.reason));
171
+ lines.push(voiceEngineOutcomeLine(`stt (${result.stt.engine})`, result.stt.state, result.stt.reason));
172
+
173
+ if (result.components.length > 0) {
174
+ lines.push(' components:');
175
+ for (const component of result.components) {
176
+ const size = component.bytes !== undefined ? ` (${formatVoiceBytes(component.bytes)})` : '';
177
+ const err = component.error ? ` — ${component.error}` : '';
178
+ const state = component.state === 'skipped' ? 'already present' : component.state;
179
+ lines.push(` ${component.id}: ${state}${size}${err}`);
180
+ }
181
+ }
182
+
183
+ const set = result.configured.set;
184
+ const skipped = result.configured.skipped;
185
+ if (set.length > 0) {
186
+ lines.push(` configured ${set.length} key${set.length === 1 ? '' : 's'}:`);
187
+ for (const entry of set) lines.push(` ${entry.key} = ${entry.value}`);
188
+ }
189
+ if (skipped.length > 0) {
190
+ lines.push(` left ${skipped.length} key${skipped.length === 1 ? '' : 's'} untouched:`);
191
+ for (const entry of skipped) lines.push(` ${entry.key}: ${entry.reason}`);
192
+ }
193
+ if (set.length === 0 && skipped.length === 0) {
194
+ lines.push(' no config keys were written');
195
+ }
196
+ return lines;
197
+ }
package/src/daemon/cli.ts CHANGED
@@ -1,6 +1,5 @@
1
- import { homedir, networkInterfaces } from 'node:os';
1
+ import { homedir } from 'node:os';
2
2
  import { join } from 'node:path';
3
- import { readFileSync } from 'node:fs';
4
3
  import { ConfigManager, resolveDaemonEnabled } from '@pellux/goodvibes-sdk/platform/config';
5
4
  import { formatProviderModel, getModelIdFromProviderModel, getProviderIdFromModel } from '../config/provider-model.ts';
6
5
  import { RuntimeEventBus, GlobalNetworkTransportInstaller } from '@/runtime/index.ts';
@@ -8,17 +7,20 @@ import { bindFeatureSettingsBridge, createFeatureFlagManager, deriveFeatureState
8
7
  import { createRuntimeStore } from '../runtime/store/index.ts';
9
8
  import { createRuntimeServices } from '../runtime/services.ts';
10
9
  import { DaemonServer, HttpListener } from '@pellux/goodvibes-sdk/platform/daemon';
10
+ import { createHostPowerSeam } from '@pellux/goodvibes-sdk/platform/power';
11
11
  import { logger } from '@pellux/goodvibes-sdk/platform/utils';
12
12
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
13
13
  import {
14
14
  getOrCreateCompanionToken,
15
15
  pruneStaleOperatorTokens,
16
- buildCompanionConnectionInfo,
17
- encodeConnectionPayload,
18
- formatConnectionBlock,
16
+ buildPairingHandoffLink,
17
+ describeOriginPosture,
19
18
  generateQrMatrix,
20
19
  renderQrToString,
21
20
  } from '@pellux/goodvibes-sdk/platform/pairing';
21
+ import { ensurePublicBaseUrl } from '../core/pairing-origin.ts';
22
+ import { availablePairingOffers } from '../core/pairing-handoff.ts';
23
+ import { formatPairingOffers, formatPostureCapabilities, pairingPostureNotice } from '../core/pairing-offers.ts';
22
24
  import { workspaceOperatorTokenCandidates } from '../runtime/operator-token-cleanup.ts';
23
25
  import {
24
26
  scan,
@@ -27,6 +29,9 @@ import {
27
29
  } from '@pellux/goodvibes-sdk/platform/discovery';
28
30
  import { createSafeHostServeFactory } from './safe-serve.ts';
29
31
  import { isDaemonServiceSubcommand, resolveInstalledDaemonBinary, runDaemonServiceCli } from './service-commands.ts';
32
+ import { resolveConfiguredServiceName } from '../runtime/legacy-daemon-migration.ts';
33
+ import { reconcileRedundantLegacyUnit } from '../runtime/legacy-daemon-reconcile.ts';
34
+ import { resolveRuntimeEndpointBinding } from '../cli/endpoints.ts';
30
35
  import { resolveDaemonUpdateArtifact } from './lifecycle.ts';
31
36
  import { VERSION } from '../version.ts';
32
37
 
@@ -34,6 +39,7 @@ import {
34
39
  parseGoodVibesCli,
35
40
  renderGoodVibesDaemonHelp,
36
41
  renderGoodVibesVersion,
42
+ renderDaemonStartupBanner,
37
43
  applyRuntimeConfigOverrides,
38
44
  applyRuntimeConfigValue,
39
45
  applyRuntimeFeatureFlagOverrides,
@@ -51,36 +57,6 @@ type DaemonCliTokens = {
51
57
  readonly httpToken: string | undefined;
52
58
  };
53
59
 
54
- function getLocalNetworkIp(): string {
55
- try {
56
- const nets = networkInterfaces();
57
- for (const name of Object.keys(nets)) {
58
- for (const net of nets[name] ?? []) {
59
- if (net.family === 'IPv4' && !net.internal) {
60
- return net.address;
61
- }
62
- }
63
- }
64
- } catch {
65
- return 'localhost';
66
- }
67
- return 'localhost';
68
- }
69
-
70
- function readBootstrapPassword(credentialPath: string): string | undefined {
71
- try {
72
- const content = readFileSync(credentialPath, 'utf-8');
73
- for (const line of content.split('\n')) {
74
- if (line.startsWith('password=')) {
75
- return line.slice('password='.length).trim();
76
- }
77
- }
78
- } catch {
79
- // credential file may not exist yet
80
- }
81
- return undefined;
82
- }
83
-
84
60
  function resolveDaemonCliOwnership(): DaemonCliOwnership {
85
61
  return {
86
62
  workingDirectory: process.env['GOODVIBES_WORKING_DIR'] ?? process.cwd(),
@@ -169,15 +145,28 @@ async function main(): Promise<void> {
169
145
  // no runtime/services are constructed — and exit with the honest result code.
170
146
  const serviceSubcommand = cli.positionals[0];
171
147
  if (isDaemonServiceSubcommand(serviceSubcommand)) {
172
- const host = String(process.env.GOODVIBES_DAEMON_HOST ?? config.get('controlPlane.host') ?? '127.0.0.1');
173
- const port = Number(config.get('controlPlane.port'));
148
+ // The host/port baked into the unit's ExecStart (and displayed) come from
149
+ // the SAME hostMode-aware resolution the SDK bind path uses — never from
150
+ // the GOODVIBES_DAEMON_HOST env var, which nothing in the bind path reads
151
+ // (the --hostname flag already lands in config via
152
+ // applyRuntimeEndpointFlagOverrides above, so it is covered here).
153
+ const binding = resolveRuntimeEndpointBinding(config, 'controlPlane');
154
+ if (!binding.recognized) {
155
+ // The SDK bind path has no default case for an unrecognized hostMode —
156
+ // a daemon launched with this config throws before binding. Say so
157
+ // instead of presenting the fallback values as a real binding.
158
+ console.warn(
159
+ `[goodvibes-daemon] warning: controlPlane.hostMode '${binding.hostMode}' is not a recognized mode ` +
160
+ "(local|network|custom) — the daemon will fail to start until it is corrected.",
161
+ );
162
+ }
174
163
  const binaryPath = resolveInstalledDaemonBinary({ moduleUrl: import.meta.url });
175
164
  const result = await runDaemonServiceCli({
176
165
  subcommand: serviceSubcommand,
177
166
  binaryPath,
178
167
  homeDir: homeDirectory,
179
- host,
180
- port,
168
+ host: binding.host,
169
+ port: binding.port,
181
170
  // migrate-service only: never auto-migrate — requires the same explicit
182
171
  // consent as any other non-interactive destructive confirmation.
183
172
  confirmMigration: cliFlags.yes,
@@ -189,6 +178,28 @@ async function main(): Promise<void> {
189
178
  process.exit(result.exitCode);
190
179
  }
191
180
 
181
+ // Honest startup identity, printed for EVERY launch shape — including a bare
182
+ // (no-arg) systemd launch, and BEFORE any runtime construction so it still
183
+ // reaches the journal when a broken config makes the daemon throw during
184
+ // composition. It states the resolved version (never a placeholder) and the
185
+ // home/host/port the daemon will actually bind: the binding comes from the
186
+ // SAME hostMode-aware resolution the SDK bind path uses (resolveHostBinding:
187
+ // 'local' forces 127.0.0.1, 'network' forces 0.0.0.0, port 0/non-numeric
188
+ // falls back to the default) — never from controlPlane.host alone, and never
189
+ // from the GOODVIBES_DAEMON_HOST env var, which the bind path does not read.
190
+ const bannerBinding = resolveRuntimeEndpointBinding(config, 'controlPlane');
191
+ // eslint-disable-next-line no-console
192
+ console.log(renderDaemonStartupBanner(VERSION, { homeDir: homeDirectory, host: bannerBinding.host, port: bannerBinding.port }));
193
+ if (!bannerBinding.recognized) {
194
+ // An unrecognized hostMode has NO binding the SDK can produce (its
195
+ // resolver has no default case and the daemon will throw below, before
196
+ // serving). Warn here so the journaled crash is explained.
197
+ console.warn(
198
+ `[goodvibes-daemon] warning: controlPlane.hostMode '${bannerBinding.hostMode}' is not a recognized mode ` +
199
+ "(local|network|custom) — the daemon cannot bind until it is corrected; the host/port above are fallback values, not a real binding.",
200
+ );
201
+ }
202
+
192
203
  const runtimeBus = new RuntimeEventBus();
193
204
  const runtimeStore = createRuntimeStore();
194
205
  // Gate states derive from the domain settings keys; the bridge keeps live
@@ -204,6 +215,17 @@ async function main(): Promise<void> {
204
215
  getConversationTitle: () => 'goodvibes daemon',
205
216
  workingDir,
206
217
  homeDirectory,
218
+ // The standalone daemon observes externally-launched coding-agent sessions
219
+ // on the host read-only (fleet visibility + steer; never counted, never
220
+ // stopped). Daemon-side only — the interactive process reads this snapshot
221
+ // rather than double-detecting. Mirrors the SDK daemon cli.
222
+ observeExternalAgents: true,
223
+ // Opt into the REAL host power seam (Linux logind: systemd-inhibit children
224
+ // + the dbus-monitor sleep-edge watcher) so the standalone daemon holds live
225
+ // keep-awake/idle-inhibit. SDK 1.9.0's runtime-services factory defaults to
226
+ // the non-spawning unavailable seam; only daemon compositions opt in. Mirrors
227
+ // the SDK daemon cli's createHostPowerSeam() (sdk commit 3a5ea26d).
228
+ powerSeam: createHostPowerSeam(),
207
229
  });
208
230
 
209
231
  // Load persisted providers from disk so the provider registry is pre-populated
@@ -312,29 +334,74 @@ async function main(): Promise<void> {
312
334
  httpListener: config.get('danger.httpListener'),
313
335
  });
314
336
 
315
- // Print companion connection info + QR code to stdout.
316
- // Use the config-driven control plane port, not a hardcoded default.
317
- const daemonPort = config.get('controlPlane.port');
318
- const configuredDaemonHost = String(process.env.GOODVIBES_DAEMON_HOST ?? getLocalNetworkIp());
319
- const daemonHost = configuredDaemonHost === '0.0.0.0' || configuredDaemonHost === '::'
320
- ? getLocalNetworkIp()
321
- : configuredDaemonHost;
322
- const daemonUrl = `http://${daemonHost}:${daemonPort}`;
323
- const bootstrapPassword = readBootstrapPassword(userAuth.getBootstrapCredentialPath());
324
- const connectionInfo = buildCompanionConnectionInfo({
325
- daemonUrl,
326
- token: companionTokenRecord.token,
327
- password: bootstrapPassword,
328
- // Without this the SDK defaults the printed banner header to v0.0.0 —
329
- // the visible version must be this binary's real version.
330
- version: VERSION,
331
- surface: 'tui',
337
+ // Cheap unattended reconcile: if this (canonical) daemon unit is confirmed
338
+ // serving AND a redundant installer-managed goodvibes-daemon.service (the
339
+ // retired unit name) sits enabled-but-NOT-running beside it — the exact
340
+ // production-incident state — auto-disable and remove it, printing a
341
+ // receipt. A RUNNING legacy daemon, a hand-written unit, or an unanswered
342
+ // configured endpoint all refuse with a notice instead. Best-effort: never
343
+ // let this block or crash daemon boot (per-call systemctl timeouts plus one
344
+ // cumulative pass deadline). The unit search root is the LOGIN user's home
345
+ // (where systemd user units live), never the daemon data home
346
+ // (GOODVIBES_DAEMON_HOME); the tracked name honors service.serviceName. The
347
+ // endpoint requirement uses the CLIENT view of the config — a fresh read of
348
+ // settings.json with none of this process's runtime flag overrides — because
349
+ // that is what clients resolve when they look for the daemon.
350
+ try {
351
+ const clientViewConfig = new ConfigManager({ workingDir, homeDir: homeDirectory, surfaceRoot: 'tui' });
352
+ const clientEndpoint = resolveRuntimeEndpointBinding(clientViewConfig, 'controlPlane');
353
+ const reconcile = await reconcileRedundantLegacyUnit({
354
+ homeDir: homedir(),
355
+ trackedServiceName: resolveConfiguredServiceName(clientViewConfig),
356
+ configuredEndpoint: { host: clientEndpoint.host, port: clientEndpoint.port },
357
+ });
358
+ if (reconcile.action !== 'noop') {
359
+ for (const line of reconcile.lines) {
360
+ // eslint-disable-next-line no-console
361
+ console.log(line);
362
+ }
363
+ }
364
+ if (reconcile.reason !== 'no-legacy-unit') {
365
+ // Breadcrumb for EVERY outcome where a legacy unit file exists —
366
+ // including guard refusals — so a persisting two-unit state is never
367
+ // silent about why nothing was reconciled.
368
+ logger.info('daemon: legacy-unit reconcile', { action: reconcile.action, reason: reconcile.reason });
369
+ }
370
+ } catch (error) {
371
+ logger.warn('daemon: legacy-unit reconcile failed (non-fatal)', { error: summarizeError(error) });
372
+ }
373
+
374
+ // Print a device-pairing QR to stdout. The QR encodes the canonical
375
+ // `#pair=<token>` deep link the web app consumes — a camera scan opens it
376
+ // already signed in. No raw JSON connection blob is printed. This is also the
377
+ // one place web.publicBaseUrl is frozen from the stable-name resolution (never
378
+ // clobbering a user-set value), so the printed origin survives a DHCP change.
379
+ // The daemon's shared companion token rides as the pairing token; a device can
380
+ // later migrate to its own per-device token from /settings → security → devices.
381
+ const webOrigin = ensurePublicBaseUrl(config);
382
+ const offers = availablePairingOffers({
383
+ relayEnabled: config.get('relay.enabled') === true,
384
+ stepUpAvailable: true,
332
385
  });
333
- const payload = encodeConnectionPayload(connectionInfo);
334
- const qrMatrix = generateQrMatrix(payload);
335
- const qrString = renderQrToString(qrMatrix);
386
+ const deepLink = buildPairingHandoffLink({ webOrigin: webOrigin.origin, token: companionTokenRecord.token, offers });
387
+ const qrString = renderQrToString(generateQrMatrix(deepLink));
388
+ // The banner renders the SAME SDK posture the pairing verb carries: the labeled
389
+ // capability list, and the one honest LAN line only when the posture holds it.
390
+ const posture = describeOriginPosture(webOrigin.origin);
391
+ const capabilities = formatPostureCapabilities(posture);
392
+ const notice = pairingPostureNotice(posture);
393
+ const bannerLines = [
394
+ `GoodVibes daemon ${VERSION} — scan to pair a device (opens the web app signed in):`,
395
+ '',
396
+ ` ${webOrigin.origin}`,
397
+ '',
398
+ ...(offers.length > 0 ? ['Offers (each declinable in the web app):', ...formatPairingOffers(offers), ''] : []),
399
+ ...(capabilities.length > 0 ? ['This device will get:', ...capabilities, ''] : []),
400
+ ...(notice ? [notice, ''] : []),
401
+ qrString,
402
+ ];
336
403
  // eslint-disable-next-line no-console
337
- console.log(formatConnectionBlock(connectionInfo, qrString));
404
+ console.log(bannerLines.join('\n'));
338
405
  }
339
406
 
340
407
  void main().catch(async (error) => {