@pellux/goodvibes-tui 1.26.0 → 1.27.1

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 (44) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +1 -1
  3. package/docs/foundation-artifacts/operator-contract.json +3756 -1110
  4. package/package.json +8 -7
  5. package/scripts/postinstall.js +57 -1
  6. package/src/audio/activation-sound.ts +117 -0
  7. package/src/audio/capture.ts +112 -0
  8. package/src/audio/onnxruntime-assets.d.ts +24 -0
  9. package/src/audio/voice-capture-wiring.ts +119 -0
  10. package/src/audio/voice-input-session.ts +181 -0
  11. package/src/audio/wake-inference.ts +182 -0
  12. package/src/audio/wake-runtime.ts +341 -0
  13. package/src/cli/bundle-command.ts +4 -1
  14. package/src/cli/management-utils.ts +4 -1
  15. package/src/cli/tui-startup.ts +10 -1
  16. package/src/core/voice-capture-status.ts +53 -0
  17. package/src/core/voice-stt-gateway.ts +75 -0
  18. package/src/core/wake-provision-runner.ts +86 -0
  19. package/src/core/wake-provision-status.ts +170 -0
  20. package/src/daemon/cli.ts +117 -28
  21. package/src/daemon/fatal-boot-report.ts +111 -0
  22. package/src/daemon/provision-wake-model.ts +81 -0
  23. package/src/input/command-registry.ts +6 -0
  24. package/src/input/commands/experience-runtime.ts +37 -3
  25. package/src/input/handler-shortcuts.ts +8 -0
  26. package/src/input/keybindings.ts +10 -1
  27. package/src/input/onboarding/onboarding-feature-units.ts +5 -5
  28. package/src/input/settings-modal-types.ts +14 -1
  29. package/src/main.ts +12 -14
  30. package/src/renderer/process-indicator.ts +66 -0
  31. package/src/renderer/settings-modal-helpers.ts +4 -2
  32. package/src/renderer/shell-surface.ts +22 -3
  33. package/src/runtime/bootstrap-core.ts +7 -7
  34. package/src/runtime/bootstrap.ts +3 -3
  35. package/src/runtime/device-posture-composition.ts +110 -0
  36. package/src/runtime/disposal-wiring.ts +16 -0
  37. package/src/runtime/embedded-service-factories.ts +16 -0
  38. package/src/runtime/legacy-daemon-migration.ts +12 -1
  39. package/src/runtime/runtime-services-types.ts +17 -0
  40. package/src/runtime/services.ts +81 -4
  41. package/src/runtime/voice-setup-services.ts +50 -4
  42. package/src/shell/voice-capture-shell.ts +69 -0
  43. package/src/verification/verification-ledger.ts +59 -20
  44. package/src/version.ts +1 -1
@@ -1,6 +1,7 @@
1
1
  import type { Line } from '../types/grid.ts';
2
- import { renderProcessIndicator } from './process-indicator.ts';
2
+ import { renderProcessIndicator, renderVoiceCaptureIndicator } from './process-indicator.ts';
3
3
  import { UIFactory } from './ui-factory.ts';
4
+ import { voiceCaptureRowVisible, type VoiceCaptureIndicatorState } from '../core/voice-capture-status.ts';
4
5
 
5
6
  export interface ShellFooterBuildOptions {
6
7
  readonly width: number;
@@ -84,6 +85,14 @@ export interface ShellFooterBuildOptions {
84
85
  readonly webSurfaceUrl?: string;
85
86
  /** True while power.keepAwake holds — renders the always-visible "sleep disabled" chip. */
86
87
  readonly powerKeepAwake?: boolean;
88
+ /**
89
+ * Live microphone state — a push-to-talk recording, or the wake detector for
90
+ * as long as it runs. Rendered as a persistent row beside the process
91
+ * indicator, because a capture device held open with nothing on screen saying
92
+ * so is the one state a voice feature must never be in. Null (or a wake state
93
+ * with `voice.wake.indicator: off`) renders nothing.
94
+ */
95
+ readonly voiceCapture?: VoiceCaptureIndicatorState | null;
87
96
  }
88
97
 
89
98
  export interface ShellFooterBuildResult {
@@ -98,6 +107,8 @@ export interface ShellFooterBuildResult {
98
107
  const FOOTER_BASE_ROWS = 5;
99
108
  const CONTEXT_PROGRESS_ROWS = 1;
100
109
  const PROCESS_INDICATOR_ROWS = 1;
110
+ /** The live-microphone row, when one is showing (see renderVoiceCaptureIndicator). */
111
+ const VOICE_CAPTURE_ROWS = 1;
101
112
  // Compact posture drops the context-info line (and never shows the context
102
113
  // bar or process indicator), leaving just: prompt-box top border, bottom
103
114
  // border, token-usage line, and the help/exit line.
@@ -151,6 +162,7 @@ export function estimateShellFooterHeight(
151
162
  promptLineCount: number,
152
163
  contextWindow?: number,
153
164
  compact = false,
165
+ voiceCapture: VoiceCaptureIndicatorState | null = null,
154
166
  ): number {
155
167
  if (lastRenderedFooterHeight !== null && lastRenderedFooterHeight.compact === compact) {
156
168
  return lastRenderedFooterHeight.height;
@@ -160,7 +172,11 @@ export function estimateShellFooterHeight(
160
172
  return COMPACT_FOOTER_BASE_ROWS + safePromptLines;
161
173
  }
162
174
  const progressRows = contextWindow && contextWindow > 0 ? CONTEXT_PROGRESS_ROWS : 0;
163
- return FOOTER_BASE_ROWS + safePromptLines + progressRows + PROCESS_INDICATOR_ROWS;
175
+ // Counted on the cold-start path too: a shell launched with the wake detector
176
+ // already listening renders that row in its very first frame, and a viewport
177
+ // sized one row too tall would draw the transcript's last line under it.
178
+ const voiceRows = voiceCaptureRowVisible(voiceCapture) ? VOICE_CAPTURE_ROWS : 0;
179
+ return FOOTER_BASE_ROWS + safePromptLines + progressRows + PROCESS_INDICATOR_ROWS + voiceRows;
164
180
  }
165
181
 
166
182
  export function buildShellFooter(
@@ -206,7 +222,10 @@ export function buildShellFooter(
206
222
  options.runningAgentProgress,
207
223
  );
208
224
  const inputBoxRows = Math.max(1, options.promptLineCount) + 2;
209
- lines.splice(inputBoxRows, 0, ...processIndicator);
225
+ // The voice row sits directly under the prompt box, ABOVE the process
226
+ // indicator: an open microphone is a live condition the user is acting inside,
227
+ // while the process indicator is a background summary.
228
+ lines.splice(inputBoxRows, 0, ...renderVoiceCaptureIndicator(options.width, options.voiceCapture ?? null), ...processIndicator);
210
229
  // Scriptable status line — dim informational line above the prompt. Unshifted
211
230
  // before the context hint so the context hint (if any) sits above it.
212
231
  if (options.scriptableStatusLine) {
@@ -20,11 +20,11 @@ import { createRuntimeStore, createDomainDispatch, type RuntimeStore } from './s
20
20
  import {
21
21
  type MutableRuntimeState, type SessionEvent, bindFeatureSettingsBridge, createFeatureFlagManager, deriveFeatureStates, RuntimeEventBus, ForensicsCollector,
22
22
  ForensicsRegistry, generateUserSessionId, loadBootstrapSystemPrompt, syncConfiguredServices,
23
- registerBootstrapHookBridge, registerBootstrapRuntimeEvents,
23
+ registerBootstrapHookBridge, registerBootstrapRuntimeEvents, configureRuntimeEventBusDefaults, runtimeEventBusOptionsFrom,
24
24
  } from '@/runtime/index.ts';
25
25
  import { readExecEnvScrubAllowlist } from '../input/exec-env-scrub-config.ts';
26
26
  import { createSandboxExecAsk, sandboxExecAskDepsFromRuntime } from '../permissions/sandbox-exec-gate.ts';
27
- import { createRuntimeServices, type RuntimeServices } from './services.ts';
27
+ import { createRuntimeServices, installDevicePosture, type RuntimeServices } from './services.ts';
28
28
  import { createHostPowerSeam } from '@pellux/goodvibes-sdk/platform/power';
29
29
  import { runBootMemoryFold } from './memory-fold.ts';
30
30
  import { wireCostPricing } from '../export/cost-utils.ts';
@@ -238,7 +238,7 @@ export async function initializeBootstrapCore(
238
238
  bindFeatureSettingsBridge(configManager, featureFlags);
239
239
 
240
240
  const userSessionId = `user-${generateUserSessionId()}`;
241
- const runtimeBus = new RuntimeEventBus();
241
+ configureRuntimeEventBusDefaults(runtimeEventBusOptionsFrom((key) => configManager.get(key))); const runtimeBus = new RuntimeEventBus(); // cap from runtime.eventBus.maxListeners before the first bus exists
242
242
  const store = createRuntimeStore();
243
243
  const domainDispatch = createDomainDispatch(store);
244
244
  let getConversationTitle = (): string | undefined => undefined;
@@ -249,9 +249,9 @@ export async function initializeBootstrapCore(
249
249
  runtimeStore: store,
250
250
  getConversationTitle: () => getConversationTitle(),
251
251
  workingDir, homeDirectory, ...(options.daemonHomeDirectory === undefined ? {} : { daemonHomeDirectory: options.daemonHomeDirectory }),
252
- // Embedded topology: the in-process runtime IS the daemon and holds its own OS
253
- // inhibitor, so opt into the real host seam (fork default is non-spawning). See services.ts.
254
- powerSeam: createHostPowerSeam(),
252
+ // Embedded topology: the in-process runtime IS the daemon, so it holds its own OS inhibitor (fork
253
+ // default is non-spawning) and owns the wake-model boot retry a standalone daemon would. See services.ts.
254
+ powerSeam: createHostPowerSeam(), provisionWakeModelsAtBoot: true,
255
255
  });
256
256
  await services.workspaceTrustManager.load(); // settle any already-persisted trust decision before any tool runs
257
257
  const providerRegistry = services.providerRegistry;
@@ -381,7 +381,7 @@ export async function initializeBootstrapCore(
381
381
  contextAccountingHolder: services.contextAccountingHolder,
382
382
  // First contained (sandboxed) command run announces "commands now run contained" once — recorded and surfaced now.
383
383
  onSandboxedRun: createSandboxContainmentNotice({ configManager, notify: (text) => conversation.log(`[Sandbox] ${text}`, { fg: '135' }) }),
384
- });
384
+ }); installDevicePosture(toolRegistry, services.devicePosture); // the `phone` tool is the only path that reaches the device capability service, so without this every `device.*` posture key governs nothing a session can observe; the housekeeping timer starts here (not at composition) so building a runtime in a test sweeps nothing
385
385
  // Note: installWrfcAgentToolGuard is called after routeOrBuffer is defined (further below) so the onTrace callback routes guard decisions through the pre-router buffer.
386
386
  services.agentOrchestrator.setDependencies({
387
387
  surfaceRoot: services.surface.surfaceRoot,
@@ -264,7 +264,7 @@ export async function bootstrapRuntime(
264
264
  }, ACP_TASK_SYNC_INTERVAL_MS);
265
265
  bootstrapUnsubs.push(() => clearInterval(acpTaskSyncInterval));
266
266
  orchestrator.registerDelegateTool(acpManager);
267
- const opsTaskManager = createTaskManager(store, runtimeBus, userSessionId);
267
+ const opsTaskManager = createTaskManager(store, runtimeBus, userSessionId, services.featureFlags); // featureFlags required: without it runtime.unifiedTasks configured nothing (permissive fallback); SDK default matches shipped behaviour, so this is a no-op for existing installs.
268
268
  // Operator interventions ride the control-plane gateway capability
269
269
  // (controlPlane.gateway, on by default; the old never-registered gate id shipped this dead).
270
270
  const opsControlPlane = services.featureFlags.isEnabled('control-plane-gateway')
@@ -482,9 +482,9 @@ export async function bootstrapRuntime(
482
482
  };
483
483
 
484
484
  // Embedded DaemonServer/HttpListener factories (incl. the facade auto-update
485
- // suppression) — see embedded-service-factories.ts.
485
+ // suppression), plus the daemon STATE directory — see embedded-service-factories.ts.
486
486
  const createExternalServiceFactories = (token: string): ExternalServiceFactories =>
487
- createEmbeddedServiceFactories(token, services.clusterCoordinator, services.clusterGroup.verbs);
487
+ createEmbeddedServiceFactories(token, services.clusterCoordinator, services.clusterGroup.verbs, join(services.homeDirectory, '.goodvibes', 'daemon'));
488
488
 
489
489
  let externalServices: ExternalServicesHandle = {
490
490
  daemonServer: null, httpListener: null,
@@ -0,0 +1,110 @@
1
+ /**
2
+ * device-posture-composition.ts — the paired-phone feature inside THIS daemon.
3
+ *
4
+ * A phone pairs with whichever daemon the person runs, and for this owner that
5
+ * daemon is hosted by the terminal app. Until this module existed the app had no
6
+ * device posture at all: `device.nodes.maxPaired` was enforced at the pairing
7
+ * path (SDK-side) and `device.capabilities.mode` was an onboarding toggle, while
8
+ * the other eleven `device.*` keys were recorded, read back, and governed
9
+ * nothing — the capability service they describe was never built here, so there
10
+ * was nothing for them to govern.
11
+ *
12
+ * The feature itself is platform-owned (`platform/devices`): the settings→policy
13
+ * mapping, the grants ledger, the capture store, the housekeeping sweeps, the
14
+ * confirmation flow, and the `phone` tool. This module supplies the three seams
15
+ * that are actually ours and nothing else:
16
+ *
17
+ * - the peer transport — the same DistributedRuntimeManager the remote surface
18
+ * pairs devices onto, so a phone paired here is a node here,
19
+ * - the approval path — the shared approval broker, so the confirmation
20
+ * appears wherever the person is looking (terminal, web app, companion),
21
+ * - the storage root and the live config manager.
22
+ *
23
+ * Constructing this starts nothing. `startHousekeeping()` is called from the
24
+ * bootstrap tail: grants and captures both outlive a restart, so a grant whose
25
+ * phone is gone, or a capture torn by a crash, is reaped BEFORE the first
26
+ * request of this run is served — and the periodic sweep after it is what keeps
27
+ * a long-running daemon from going days without one. A failed sweep is logged
28
+ * and the app still runs: housekeeping failing is a reason to say so, not a
29
+ * reason to refuse to start.
30
+ */
31
+ import { logger } from '@pellux/goodvibes-sdk/platform/utils';
32
+ import { createDevicePostureRuntime, registerDevicePhoneTool } from '@pellux/goodvibes-sdk/platform/devices';
33
+ import type {
34
+ DeviceApprovalBridge,
35
+ DevicePeerTransport,
36
+ DevicePhoneToolRegistry,
37
+ DevicePostureRuntime,
38
+ } from '@pellux/goodvibes-sdk/platform/devices';
39
+ import { registerDevicesGatewayMethods } from '@pellux/goodvibes-sdk/platform/control-plane';
40
+ import type { GatewayMethodCatalog } from '@pellux/goodvibes-sdk/platform/control-plane';
41
+ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
42
+
43
+ /** Who this surface records in the device audit trail. */
44
+ export const TUI_DEVICE_ACTOR = 'tui:phone-tool';
45
+
46
+ export interface DevicePostureCompositionOptions {
47
+ readonly configManager: ConfigManager;
48
+ /** The runtime devices pair onto; its listPeers/invokePeer are the transport. */
49
+ readonly distributedRuntime: DevicePeerTransport;
50
+ readonly approvals: DeviceApprovalBridge;
51
+ /** Surface-scoped directory the grants ledger, captures and disclosure live in. */
52
+ readonly stateDirectory: string;
53
+ /**
54
+ * Binding the catalog turns devices.nodes.list / devices.grants.* /
55
+ * devices.housekeeping.run from cataloged-but-unhandled into real handlers,
56
+ * which is what makes the grants surface work in the web app and the companion
57
+ * rather than only through this app's own tool.
58
+ */
59
+ readonly gatewayMethods?: GatewayMethodCatalog | undefined;
60
+ readonly getSessionId?: (() => string | undefined) | undefined;
61
+ }
62
+
63
+ export interface DevicePostureServices {
64
+ readonly devicePosture: DevicePostureRuntime;
65
+ }
66
+
67
+ /** Build the device posture runtime and bind the device verbs to it. */
68
+ export function createDevicePostureServices(options: DevicePostureCompositionOptions): DevicePostureServices {
69
+ const devicePosture = createDevicePostureRuntime({
70
+ transport: options.distributedRuntime,
71
+ approvals: options.approvals,
72
+ config: options.configManager,
73
+ stateDirectory: options.stateDirectory,
74
+ actor: TUI_DEVICE_ACTOR,
75
+ ...(options.getSessionId ? { getSessionId: options.getSessionId } : {}),
76
+ });
77
+ if (options.gatewayMethods) registerDevicesGatewayMethods(options.gatewayMethods, devicePosture);
78
+ return { devicePosture };
79
+ }
80
+
81
+ /**
82
+ * Everything a host with a tool registry has to do once it is up: register the
83
+ * `phone` tool — the only path that reaches the capability service, so without it
84
+ * the posture keys govern nothing a session can observe — and start housekeeping.
85
+ *
86
+ * One call, because these two belong to the same feature and a host that did the
87
+ * first and forgot the second would serve requests while never reaping a grant
88
+ * whose phone is gone.
89
+ */
90
+ export function installDevicePosture(
91
+ toolRegistry: DevicePhoneToolRegistry,
92
+ devicePosture: DevicePostureRuntime,
93
+ ): void {
94
+ registerDevicePhoneTool(toolRegistry, devicePosture);
95
+ startDeviceHousekeeping(devicePosture);
96
+ }
97
+
98
+ /**
99
+ * The recovery sweep plus the periodic timer. Separate from construction so
100
+ * composing a runtime in a test starts no timer and touches no disk, and
101
+ * separate from the call above so the standalone daemon — which registers no
102
+ * tools — still sweeps.
103
+ */
104
+ export function startDeviceHousekeeping(devicePosture: DevicePostureRuntime): void {
105
+ void devicePosture.startHousekeeping().catch((error: unknown) => {
106
+ logger.warn('Device housekeeping sweep failed at startup', {
107
+ error: error instanceof Error ? error.message : String(error),
108
+ });
109
+ });
110
+ }
@@ -38,6 +38,14 @@ export { createDisposalScope };
38
38
  export interface SurfaceRuntimePollerOwners extends Omit<RuntimePollerOwners, 'stopConfigWatch'> {
39
39
  /** Fork-only: the repeating crash-residue sweep (durability-services.ts). */
40
40
  readonly stopDurabilityHousekeeping: () => void;
41
+ /**
42
+ * Fork-only: the wake-word recovery sweep and a pending boot provision
43
+ * (voice-setup-services.ts). Started only when an entrypoint opted into boot
44
+ * provisioning, and a no-op otherwise — but it is on this list unconditionally,
45
+ * because "the graph did not start it this time" is not a reason for the
46
+ * teardown path to have no way to stop it.
47
+ */
48
+ readonly stopWakeHousekeeping: () => void;
41
49
  /**
42
50
  * Fork-only: the daemon handler surfaces (daemon-handler-composition.ts).
43
51
  *
@@ -47,6 +55,12 @@ export interface SurfaceRuntimePollerOwners extends Omit<RuntimePollerOwners, 's
47
55
  * exists, so if this surface does not stop them nothing does.
48
56
  */
49
57
  readonly daemonHandlers: { readonly unregister: () => void };
58
+ /**
59
+ * Fork-only: the paired-phone feature's housekeeping timer
60
+ * (device-posture-composition.ts). Started by whichever entry point boots the
61
+ * host, so the stop belongs on this list rather than in one shutdown path.
62
+ */
63
+ readonly devicePosture: { readonly stopHousekeeping: () => void };
50
64
  }
51
65
 
52
66
  /**
@@ -72,6 +86,8 @@ export function registerSurfaceRuntimePollers(
72
86
  ): void {
73
87
  registerRuntimePollers(registry, { ...services, stopConfigWatch: extras.stopConfigWatch });
74
88
  registry.add('durability housekeeping', services.stopDurabilityHousekeeping);
89
+ registry.add('device housekeeping', () => services.devicePosture.stopHousekeeping());
90
+ registry.add('wake-word housekeeping', services.stopWakeHousekeeping);
75
91
  // Registered LAST so it tears down FIRST (the scope unwinds in reverse), which
76
92
  // is the order daemon/cli.ts already used by hand: release the handler surfaces
77
93
  // — closing the inbox store and stopping its poll timers — before the pollers
@@ -27,9 +27,25 @@ export function createEmbeddedServiceFactories(
27
27
  sharedDaemonToken: string,
28
28
  clusterCoordinator: ClusterCoordinator,
29
29
  clusterGroupVerbs: ClusterGroupVerbSurface,
30
+ daemonStateDirectory: string,
30
31
  ): ExternalServiceFactories {
31
32
  return {
32
33
  sharedDaemonToken,
34
+ // `daemonRuntimeDir` names the daemon's own STATE directory — the one
35
+ // holding operator-tokens.json, auth-users.json and daemon-settings.json —
36
+ // which is `<home>/.goodvibes/daemon`, not the user home above it. Every
37
+ // reader in this repository resolves it that way (config/goodvibes-home.ts,
38
+ // cli/service-posture.ts, runtime/bootstrap.ts). Passing it explicitly
39
+ // through this typed field (rather than appending a duplicate
40
+ // `--daemon-home` CLI arg via `daemonLaunchArgs` and relying on
41
+ // last-wins flag parsing) also keeps a detached spawn correct when
42
+ // `GOODVIBES_HOME` overrides the tree root away from the OS home
43
+ // directory, which the SDK's own `daemonHomeDir` default does not account
44
+ // for. The SDK's
45
+ // fix (sdk commit d1336a2b) that put the state directory behind the FIRST
46
+ // `--daemon-home` argument shipped in 1.21.0 and made the old
47
+ // flag-duplication workaround redundant; this is its replacement.
48
+ daemonRuntimeDir: daemonStateDirectory,
33
49
  createDaemonServer: (bus, userAuth, runtimeServices) => {
34
50
  // No updateArtifact: the embedded daemon keeps updates host-managed (the
35
51
  // TUI client updates itself), so the facade runs no self-update loop.
@@ -135,7 +135,18 @@ export function buildManagedDaemonServiceManager(params: BuildManagedDaemonServi
135
135
  description: MANAGED_SERVICE_DESCRIPTION,
136
136
  workingDirectory,
137
137
  command: params.binaryPath,
138
- args: ['--daemon-home', params.homeDir],
138
+ // `--daemon-home` names the daemon's own STATE directory — the one holding
139
+ // operator-tokens.json, auth-users.json and daemon-settings.json — which is
140
+ // `<home>/.goodvibes/daemon`. This baked the USER HOME, so a serviced
141
+ // daemon filed its identity a level above where every reader in this
142
+ // repository looks: config/goodvibes-home.ts resolves the flag AS the state
143
+ // directory, cli/service-posture.ts already writes the state directory into
144
+ // GOODVIBES_DAEMON_HOME for the unit it installs, and runtime/bootstrap.ts
145
+ // reads the companion token from the state directory. On a normal machine
146
+ // the mismatch is invisible from the outside — the daemon simply mints a
147
+ // second operator-tokens.json in the home directory and the client keeps
148
+ // reading the empty one under .goodvibes/daemon.
149
+ args: ['--daemon-home', join(params.homeDir, '.goodvibes', 'daemon')],
139
150
  env: {},
140
151
  restartOnFailure: true,
141
152
  };
@@ -38,6 +38,7 @@ import type { createDurabilityServices } from './durability-services.ts';
38
38
  import type { MemorySpineClient } from '@pellux/goodvibes-sdk/platform/runtime/memory-spine';
39
39
  import type { WorkspaceCheckpointManager } from '@pellux/goodvibes-sdk/platform/workspace';
40
40
  import type { DomainDispatch, RuntimeStore } from './store/index.ts';
41
+ import type { DevicePostureRuntime } from '@pellux/goodvibes-sdk/platform/devices';
41
42
  import type { RuntimeEventBus, DistributedRuntimeManager, RemoteRunnerRegistry, RemoteSupervisor, IntegrationHelperService, IdempotencyStore, ComponentHealthMonitor, WorktreeRegistry, SandboxSessionRegistry, ShellPathService, FeatureFlagManager, PolicyRuntimeState, SessionSurface } from '@/runtime/index.ts';
42
43
  import type { VoiceProviderRegistry, VoiceService } from '@pellux/goodvibes-sdk/platform/voice';
43
44
  import type { CacheRegistry, PauseController, MemoryGovernor } from '@pellux/goodvibes-sdk/platform/runtime/memory';
@@ -91,6 +92,13 @@ export interface RuntimeServicesOptions {
91
92
  readonly powerSeam?: Parameters<typeof wireIdlePowerAndLiveTurn>[0]['powerSeam'];
92
93
  /** Live session id, read per crash-residue sweep so the running session is exempt — see durability-services.ts. */
93
94
  readonly currentSessionId?: (() => string | null) | undefined;
95
+ /**
96
+ * Wake-word boot provisioning opt-in. Same shape as `powerSeam`: the real
97
+ * entrypoints (daemon/cli.ts, bootstrap-core.ts) ask for it, the one-shot CLI
98
+ * commands do not, and a test composing this graph gets neither a network fetch
99
+ * nor an hourly sweep it did not ask for. See voice-setup-services.ts.
100
+ */
101
+ readonly provisionWakeModelsAtBoot?: boolean | undefined;
94
102
  }
95
103
 
96
104
  export interface RuntimeServices {
@@ -176,6 +184,13 @@ export interface RuntimeServices {
176
184
  readonly providerRegistry: ProviderRegistry;
177
185
  readonly toolLLM: ToolLLM;
178
186
  readonly distributedRuntime: DistributedRuntimeManager;
187
+ /**
188
+ * The paired-phone feature for this host: the grants ledger, the capture
189
+ * store, the housekeeping sweeps, and the capability service every `device.*`
190
+ * setting governs. Bound to the `devices.*` verbs at composition; the `phone`
191
+ * tool is registered on it in the bootstrap tail.
192
+ */
193
+ readonly devicePosture: DevicePostureRuntime;
179
194
  readonly daemonHandlers: DaemonHandlerSurfaces;
180
195
  /** Elects the one node on this network that consumes inbound messages; hand it to the DaemonServer so its consumers share this leadership instead of holding a second election. */
181
196
  readonly clusterCoordinator: ClusterCoordinator;
@@ -211,6 +226,8 @@ export interface RuntimeServices {
211
226
  readonly appendOnlyRetentionScheduler: ReturnType<typeof createDurabilityServices>['appendOnlyRetentionScheduler']; // periodic append-only sweep; unref'd timers, stop() on teardown
212
227
  /** Stops the recurring crash-residue sweep; idempotent, unref'd timer (hosts that tear a runtime down call it). */
213
228
  readonly stopDurabilityHousekeeping: () => void;
229
+ /** Stops the wake-word recovery sweep and a pending boot provision; a no-op unless `provisionWakeModelsAtBoot` was set. */
230
+ readonly stopWakeHousekeeping: () => void;
214
231
  readonly memoryConsolidationScheduler: MemoryConsolidationScheduler;
215
232
  readonly powerManager: PowerManager;
216
233
  /** The daemon's memory governor (default ON). Backs ops.memory.get and defends the daemon's footprint by tier. */
@@ -55,6 +55,10 @@ import { createWorkstreamServices } from './workstream-services.ts';
55
55
  import { wireFleetNeedsInputPush } from './fleet-needs-input-push.ts';
56
56
  import { codeIndexDbPath, createCodeIndexServices, createStoreRerooter, isCodeInjectionSettingEnabled } from './code-index-services.ts';
57
57
  import { createDaemonHandlerComposition } from './daemon-handler-composition.ts';
58
+ import { createDevicePostureServices } from './device-posture-composition.ts';
59
+ // Re-exported so the shell's bootstrap reaches the install through the same
60
+ // module it already imports the runtime graph from.
61
+ export { installDevicePosture } from './device-posture-composition.ts';
58
62
  import { createClusterServices, startClusterServices } from './cluster-group-composition.ts';
59
63
  import { WorkspaceTrustManager } from './trust/workspace-trust.ts';
60
64
  import { ensureConfiguredModelIsRoutable } from './provider-fallback.ts';
@@ -159,8 +163,24 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
159
163
  join(surface.sessionsDir, 'task-graph.json'),
160
164
  );
161
165
  const hookActivityTracker = new HookActivityTracker();
166
+ // featureFlags is REQUIRED here in practice, even though the SDK types it
167
+ // optional. isFeatureGateEnabled(null, ...) is permissive by design — a
168
+ // narrow embed with no manager wired gets the capability rather than a
169
+ // silent off — so omitting it did not disable the watcher framework when
170
+ // watchers.enabled is turned off; it made the setting configure nothing.
171
+ // Threading it preserves current effective behaviour rather than changing
172
+ // it: watchers.enabled defaults true, the watcher-framework flag's own
173
+ // defaultState is 'enabled', and the flag declares no notOperable record —
174
+ // so with nothing configured the gate reads exactly as before, and the
175
+ // difference is only that turning it OFF now turns it off. This composes
176
+ // with bootstrap-core.ts's own `if (configManager.get('watchers.enabled'))`
177
+ // check around the built-in polling watcher registration: that check reads
178
+ // the same key, so the two never disagree, and this fix only reaches the
179
+ // watcher registry's OTHER entry points (registerWatcher/startWatcher/etc.)
180
+ // that bootstrap-core.ts's narrower check does not cover.
162
181
  const watcherRegistry = new WatcherRegistry({
163
182
  storePath: shellPaths.resolveProjectPath('tui', 'watchers.json'),
183
+ featureFlags,
164
184
  });
165
185
  watcherRegistry.attachRuntime({
166
186
  runtimeStore: options.runtimeStore,
@@ -223,6 +243,15 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
223
243
  // modal): bootstrap.ts activates the wire transport when a compatible
224
244
  // external daemon is adopted, same signal as the session spine.
225
245
  const memorySpine = new MemorySpineClient({ local: createLocalMemoryAccess(memoryRegistry) });
246
+ // featureFlags is REQUIRED here in practice, even though the SDK types it
247
+ // optional. isFeatureGateEnabled(null, ...) is permissive by design, so
248
+ // omitting it did not disable delivery tracking when
249
+ // integrations.deliveryTracking is turned off — it made the setting
250
+ // configure nothing: deliverText/deliverJobRun kept running either way.
251
+ // Threading it preserves current effective behaviour rather than changing
252
+ // it: the config default is true, the delivery-engine flag's own
253
+ // defaultState is 'enabled', and the flag declares no notOperable record —
254
+ // so with nothing configured the gate reads exactly as before.
226
255
  const deliveryManager = new AutomationDeliveryManager({
227
256
  configManager,
228
257
  // This manager builds the delivery router the daemon actually replies
@@ -236,7 +265,20 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
236
265
  runtimeStore: options.runtimeStore,
237
266
  routeBindings,
238
267
  artifactStore,
268
+ featureFlags,
239
269
  });
270
+ // Same shape as deliveryManager above: automation.enabled defaults true,
271
+ // the automation-domain flag's own defaultState is 'enabled', so threading
272
+ // featureFlags here does not change what a default install does — it only
273
+ // makes turning automation.enabled off actually turn AutomationManager's own
274
+ // create/update/run/list surface off. This composes with bootstrap.ts's own
275
+ // `if (configManager.get('automation.enabled'))` gate around scheduling
276
+ // automationManager.start(): that check reads the same key, and
277
+ // AutomationManager.start() re-checks the same gate internally (it no-ops
278
+ // and stops rather than starting when disabled), so the two can never
279
+ // disagree — this fix only reaches the manager's OTHER entry points
280
+ // (createJob/updateJob/runNow/etc.) that the bootstrap.ts gate does not
281
+ // cover, the same way watchers.enabled and the watcher registry above do.
240
282
  const automationManager = new AutomationManager({
241
283
  configManager,
242
284
  defaultSurfaceKind: 'tui',
@@ -248,6 +290,7 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
248
290
  // Same live registry: a bare model id on an automation job resolves through
249
291
  // the shared resolver instead of a format-only rejection.
250
292
  providerRegistry,
293
+ featureFlags,
251
294
  spawnTask: (input) => {
252
295
  const record = agentManager.spawn({
253
296
  mode: 'spawn',
@@ -309,6 +352,16 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
309
352
  approvalBridge: approvalBroker,
310
353
  automationBridge: automationManager,
311
354
  });
355
+ // The paired-phone feature for this host, on the SAME runtime phones pair onto
356
+ // and the SAME approval broker every other confirmation rides. Every `device.*`
357
+ // setting is read live through this; see device-posture-composition.ts.
358
+ const { devicePosture } = createDevicePostureServices({
359
+ configManager,
360
+ distributedRuntime,
361
+ approvals: approvalBroker,
362
+ stateDirectory: shellPaths.resolveProjectPath('tui', 'devices'),
363
+ gatewayMethods,
364
+ });
312
365
 
313
366
  // Which machines on this network are "us", and which of them reads the
314
367
  // shared inbox. Both inert until startCluster() — no socket, no key material
@@ -334,7 +387,18 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
334
387
  = createRemoteExecutionServices({
335
388
  agentManager, workingDirectory, hookDispatcher, configManager, runtimeBus: options.runtimeBus,
336
389
  });
337
- const tokenAuditor = new ApiTokenAuditor({ managed: false });
390
+ // featureFlags is REQUIRED here in practice, even though the SDK types it
391
+ // optional. isFeatureGateEnabled(null, ...) is permissive by design, so
392
+ // omitting it did not disable managed blocking when
393
+ // security.tokenAudit.enabled is turned off. Threading it preserves current
394
+ // effective behaviour rather than changing it: managed is hardcoded false
395
+ // here (advisory reporting only; excess-scope/overdue tokens are reported,
396
+ // never blocked, regardless of this flag — see isBlocked()'s
397
+ // `this._config.managed && this._managedBlockingEnabled()` guard), the
398
+ // config default for security.tokenAudit.enabled is true, and the
399
+ // token-scope-rotation-audit flag's own defaultState is 'enabled' — so with
400
+ // nothing configured the gate reads exactly as before either way.
401
+ const tokenAuditor = new ApiTokenAuditor({ managed: false, featureFlags });
338
402
  const componentHealthMonitor = new ComponentHealthMonitor();
339
403
  const worktreeRegistry = new WorktreeRegistry(workingDirectory);
340
404
  const webhookNotifier = new WebhookNotifier();
@@ -386,7 +450,15 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
386
450
  runtimeBus: options.runtimeBus,
387
451
  observeExternalAgents: options.observeExternalAgents, providerRegistry, // observeExternalAgents is daemon-side only
388
452
  });
389
- const modeManager = new ModeManager(); const fileUndoManager = new FileUndoManager();
453
+ // featureFlags is REQUIRED here in practice, even though the SDK types it
454
+ // optional. isFeatureGateEnabled(null, ...) is permissive by design, so
455
+ // omitting it did not disable the HITL UX mode system when
456
+ // behavior.hitlMode is set to 'off' — setHITLMode/setDomainVerbosity kept
457
+ // accepting writes either way. Threading it preserves current effective
458
+ // behaviour rather than changing it: behavior.hitlMode defaults to
459
+ // 'balanced' (not 'off'), and the hitl-ux-modes flag's own defaultState is
460
+ // 'enabled' — so with nothing configured the gate reads exactly as before.
461
+ const modeManager = new ModeManager({ featureFlags }); const fileUndoManager = new FileUndoManager();
390
462
  const workspaceCheckpointManager = createWorkspaceCheckpointing({ workspaceRoot: workingDirectory, surface, runtimeBus: options.runtimeBus, configManager });
391
463
  // memory-consolidation honors governor backpressure: it ticks only when idle
392
464
  // AND the 'memory-consolidation' job is not paused AND expensive work is
@@ -414,7 +486,11 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
414
486
 
415
487
  // Managed local-voice provisioning (voice.local.status/install) — single-flight
416
488
  // one-act install + no-network status; see voice-setup-services.ts.
417
- const { voiceSetup } = wireVoiceSetup({ configManager, shellPaths, voiceProviders, admitExpensiveWork });
489
+ const { voiceSetup, stopWakeHousekeeping } = wireVoiceSetup({ configManager, shellPaths, voiceProviders, admitExpensiveWork,
490
+ // Boot provisioning of the wake-word model + its recovery sweep, opted into
491
+ // by the real entrypoints only (same treatment as powerSeam) so a one-shot
492
+ // CLI command and a test composing this graph fetch nothing and start no timer.
493
+ provisionWakeModelsAtBoot: options.provisionWakeModelsAtBoot === true });
418
494
 
419
495
  // Terminal-shell wrapper over the SDK registerGatewayVerbGroups (gateway-verbs.ts); checkin.*/fleet-needs-input/pairing.* register only when their deps are present. memoryGovernor lights up ops.memory.get; voiceSetup lights up voice.local.status/install.
420
496
  // calendar.*/email.* are platform-served; these two let it register (mail-composition.ts).
@@ -552,6 +628,7 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
552
628
  providerRegistry,
553
629
  toolLLM,
554
630
  distributedRuntime,
631
+ devicePosture,
555
632
  daemonHandlers,
556
633
  clusterCoordinator,
557
634
  clusterGroup,
@@ -577,7 +654,7 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
577
654
  workstreamCommands,
578
655
  codeIndexStore,
579
656
  codeIndexReindexScheduler,
580
- storeSnapshotScheduler, appendOnlyRetentionScheduler, stopDurabilityHousekeeping,
657
+ storeSnapshotScheduler, appendOnlyRetentionScheduler, stopDurabilityHousekeeping, stopWakeHousekeeping,
581
658
  memoryConsolidationScheduler,
582
659
  powerManager,
583
660
  memoryGovernor,
@@ -23,30 +23,76 @@ import {
23
23
  createVoiceSetupService,
24
24
  type VoiceSetupService,
25
25
  } from '@pellux/goodvibes-sdk/platform/runtime/voice-setup';
26
- import type { VoiceProviderRegistry } from '@pellux/goodvibes-sdk/platform/voice';
26
+ import {
27
+ startWakeBootProvisioning,
28
+ type VoiceProviderRegistry,
29
+ } from '@pellux/goodvibes-sdk/platform/voice';
27
30
  import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
31
+ import { logger } from '@pellux/goodvibes-sdk/platform/utils';
28
32
  import type { ShellPathService } from '@/runtime/index.ts';
29
33
 
30
34
  export type { VoiceSetupService };
31
35
 
36
+ /**
37
+ * The recovery act this surface names when a boot provision degrades. The SDK
38
+ * would otherwise name the control-plane verb, which is correct everywhere and
39
+ * useless to someone sitting in a terminal.
40
+ */
41
+ export const WAKE_RECOVERY_COMMAND = '/voice wake setup';
42
+
32
43
  export interface VoiceSetupServicesDeps {
33
44
  readonly configManager: ConfigManager;
34
45
  readonly shellPaths: Pick<ShellPathService, 'resolveUserPath'>;
35
46
  readonly voiceProviders: VoiceProviderRegistry;
36
47
  /** Critical-tier admission gate — a provision run allocates archive + model buffers. */
37
48
  readonly admitExpensiveWork: (label: string) => { allowed: boolean; reason?: string | undefined };
49
+ /**
50
+ * Opt into boot-time wake-model provisioning and the recovery sweeper.
51
+ *
52
+ * Off unless a real entrypoint asks for it, matching how this graph treats the
53
+ * host power seam: `daemon/cli.ts` and `runtime/bootstrap-core.ts` opt in, the
54
+ * one-shot CLI commands do not, and a test composing the graph gets neither a
55
+ * network fetch nor a repeating timer it did not ask for.
56
+ */
57
+ readonly provisionWakeModelsAtBoot?: boolean | undefined;
58
+ /** Injected in tests: the boot starter, so no sweeper and no fetch are real. */
59
+ readonly startBootProvisioning?: typeof startWakeBootProvisioning | undefined;
60
+ }
61
+
62
+ export interface VoiceSetupServices {
63
+ readonly voiceSetup: VoiceSetupService;
64
+ /**
65
+ * Stop the wake-word recovery sweeper and cancel a pending boot provision.
66
+ * A no-op when boot provisioning was not opted into. Registered on the
67
+ * disposal scope (disposal-wiring.ts) — an hourly timer nothing stops is a
68
+ * poller this surface leaked, which is exactly what that scope exists for.
69
+ */
70
+ readonly stopWakeHousekeeping: () => void;
38
71
  }
39
72
 
40
73
  /** Compose the managed-voice single-flight install + no-network status read. */
41
- export function wireVoiceSetup(deps: VoiceSetupServicesDeps): { voiceSetup: VoiceSetupService } {
74
+ export function wireVoiceSetup(deps: VoiceSetupServicesDeps): VoiceSetupServices {
42
75
  const { configManager, voiceProviders } = deps;
76
+ const managedVoiceRoot = deps.shellPaths.resolveUserPath('voice');
43
77
  const voiceSetup = createVoiceSetupService({
44
- managedVoiceRoot: deps.shellPaths.resolveUserPath('voice'),
78
+ managedVoiceRoot,
45
79
  getConfig: (key) => String(configManager.get(key as Parameters<typeof configManager.get>[0]) ?? ''),
46
80
  setConfig: (key, value) => configManager.setDynamic(key as Parameters<typeof configManager.setDynamic>[0], value),
47
81
  // A successful (re-)install clears any tripped local-engine breaker.
48
82
  resetLocalEngineFailureState: () => voiceProviders.get('local')?.resetEngineFailureState?.(),
49
83
  admitExpensiveWork: deps.admitExpensiveWork,
50
84
  });
51
- return { voiceSetup };
85
+ if (deps.provisionWakeModelsAtBoot !== true) {
86
+ return { voiceSetup, stopWakeHousekeeping: () => {} };
87
+ }
88
+ // Sweep the wake tree, then fetch whatever the install could not. The SDK owns
89
+ // the policy — never throws, one plain message, reaps before it retries — and
90
+ // routes the attempt through the setup service's single flight so a boot
91
+ // attempt and a user typing /voice wake setup are one download.
92
+ const boot = (deps.startBootProvisioning ?? startWakeBootProvisioning)({
93
+ managedRoot: managedVoiceRoot,
94
+ ensureProvisioned: () => voiceSetup.wakeEnsureProvisioned({ recoveryHint: WAKE_RECOVERY_COMMAND }),
95
+ announce: (message) => { logger.info(message); },
96
+ });
97
+ return { voiceSetup, stopWakeHousekeeping: () => boot.stop() };
52
98
  }