@pellux/goodvibes-tui 1.26.0 → 1.27.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 (39) hide show
  1. package/README.md +1 -1
  2. package/docs/foundation-artifacts/operator-contract.json +3756 -1110
  3. package/package.json +8 -7
  4. package/scripts/postinstall.js +57 -1
  5. package/src/audio/activation-sound.ts +117 -0
  6. package/src/audio/capture.ts +112 -0
  7. package/src/audio/onnxruntime-assets.d.ts +24 -0
  8. package/src/audio/voice-capture-wiring.ts +119 -0
  9. package/src/audio/voice-input-session.ts +181 -0
  10. package/src/audio/wake-inference.ts +182 -0
  11. package/src/audio/wake-runtime.ts +341 -0
  12. package/src/cli/bundle-command.ts +4 -1
  13. package/src/cli/management-utils.ts +4 -1
  14. package/src/core/voice-capture-status.ts +53 -0
  15. package/src/core/voice-stt-gateway.ts +75 -0
  16. package/src/core/wake-provision-runner.ts +86 -0
  17. package/src/core/wake-provision-status.ts +170 -0
  18. package/src/daemon/cli.ts +38 -1
  19. package/src/daemon/provision-wake-model.ts +81 -0
  20. package/src/input/command-registry.ts +6 -0
  21. package/src/input/commands/experience-runtime.ts +37 -3
  22. package/src/input/handler-shortcuts.ts +8 -0
  23. package/src/input/keybindings.ts +10 -1
  24. package/src/input/onboarding/onboarding-feature-units.ts +5 -5
  25. package/src/input/settings-modal-types.ts +14 -1
  26. package/src/main.ts +12 -14
  27. package/src/renderer/process-indicator.ts +66 -0
  28. package/src/renderer/settings-modal-helpers.ts +4 -2
  29. package/src/renderer/shell-surface.ts +22 -3
  30. package/src/runtime/bootstrap-core.ts +7 -7
  31. package/src/runtime/bootstrap.ts +1 -1
  32. package/src/runtime/device-posture-composition.ts +110 -0
  33. package/src/runtime/disposal-wiring.ts +16 -0
  34. package/src/runtime/runtime-services-types.ts +17 -0
  35. package/src/runtime/services.ts +81 -4
  36. package/src/runtime/voice-setup-services.ts +50 -4
  37. package/src/shell/voice-capture-shell.ts +69 -0
  38. package/src/verification/verification-ledger.ts +59 -20
  39. package/src/version.ts +1 -1
@@ -231,7 +231,7 @@ export const FEATURE_ONBOARDING_SECTIONS: readonly FeatureSection[] = [
231
231
  {
232
232
  flagId: 'otel-remote-export',
233
233
  label: 'OTLP remote export',
234
- hint: 'Export spans over OTLP/gRPC to a collector. Enabling this also turns on the OpenTelemetry foundation.',
234
+ hint: 'Export spans over OTLP/HTTP JSON to a collector. Enabling this also turns on the OpenTelemetry foundation. Span export reads the OTEL_EXPORTER_OTLP_* environment variables; decision export reads telemetry.decisionOtlpEndpoint — this unit sets the latter.',
235
235
  requiresFlags: ['otel-foundation'],
236
236
  impliedConfig: [{ key: 'telemetry.decisionOtlpEnabled' as ConfigKey, value: true }],
237
237
  subOptions: [
@@ -239,10 +239,10 @@ export const FEATURE_ONBOARDING_SECTIONS: readonly FeatureSection[] = [
239
239
  key: 'endpoint',
240
240
  configKey: 'telemetry.decisionOtlpEndpoint' as ConfigKey,
241
241
  label: 'Collector endpoint',
242
- hint: 'OTLP collector URL (e.g. http://localhost:4317). Leave blank to set it later in /settings.',
242
+ hint: 'OTLP collector URL (e.g. http://localhost:4318). Leave blank to set it later in /settings.',
243
243
  valueType: 'text',
244
244
  defaultValue: '',
245
- placeholder: 'http://localhost:4317',
245
+ placeholder: 'http://localhost:4318',
246
246
  },
247
247
  ],
248
248
  },
@@ -326,7 +326,7 @@ export const FEATURE_ONBOARDING_SECTIONS: readonly FeatureSection[] = [
326
326
  stepId: 'features-devices',
327
327
  title: 'Paired devices and voice input',
328
328
  shortLabel: 'Devices',
329
- description: 'What a paired phone may be asked to do on your behalf, and whether GoodVibes listens for a spoken wake phrase. Every phone capability asks the person holding the phone before it runs; the wake word is off and states plainly that it is not wired up in this build.',
329
+ description: 'What a paired phone may be asked to do on your behalf, and whether GoodVibes listens for a spoken wake phrase. Every phone capability asks the person holding the phone before it runs; the wake word is off by default because holding a microphone open must be an explicit act, and turning it on does open one on this terminal.',
330
330
  units: [
331
331
  {
332
332
  flagId: 'paired-device-capabilities',
@@ -349,7 +349,7 @@ export const FEATURE_ONBOARDING_SECTIONS: readonly FeatureSection[] = [
349
349
  flagId: 'wake-word-detection',
350
350
  label: 'Wake-word detection',
351
351
  hint: 'Listen continuously on a capture device for the pinned "hey goodvibes" phrase and hand the utterance that follows to speech-to-text. Off by default because holding a microphone open must be an explicit act.',
352
- note: 'NOT AVAILABLE IN THIS BUILD — turning this on does nothing yet. The detector is complete, but no surface captures microphone audio or supplies it an inference runtime, so nothing is listening. Your choice is remembered and takes effect in the release that adds capture.',
352
+ note: 'On this terminal, turning this on starts a recorder (pw-record, parecord, arecord, ffmpeg or sox — whichever is installed), scores every frame with the pinned classifier, plays a short chime the moment a wake confirms, and shows a persistent "listening" row in the footer for as long as it runs. What follows a wake goes to speech-to-text and lands in the composer, or is sent straight away if you turn voice.wake.autoSubmit on. The model it scores with is already here — installing GoodVibes downloads and checksum-verifies it, and a running daemon retries at boot if the install could not reach the network — so turning this on normally needs no setup step. What it will NOT do on its own is download anything at the moment you turn it on: a host whose artifacts are missing or fail verification says which and names /voice wake setup, instead of pretending to listen. That same provisioning fetches the speech gate, so voice.wake.vadThreshold above 0 screens frames once it has run and refuses to start before it has, rather than scoring frames it claims to be screening. voice.wake.noiseSuppression: speex runs here too — the filter travels with the platform, so there is nothing to install for it.',
353
353
  },
354
354
  ],
355
355
  },
@@ -63,6 +63,14 @@ export type SettingsCategory =
63
63
  // and reachable only by hand-editing a settings file — see the push.* and
64
64
  // cluster.* comments in settings-modal-data.ts for the two times that happened.
65
65
  | 'profile'
66
+ // occasions.* — the proactive occasions/plans loop (docs/occasions.md §8):
67
+ // lead time, active hours, nudge channel and cadence, away-date adjustment,
68
+ // calendar mirroring, interview length, gift-history retention, and the
69
+ // sweep interval. Same mandatory-dual-membership rule as profile above, or
70
+ // all twelve keys are dropped from the workspace and reachable only by
71
+ // hand-editing a settings file — a third occurrence of the push.*/cluster.*
72
+ // class the profile comment already names two of.
73
+ | 'occasions'
66
74
  | 'danger';
67
75
 
68
76
  export type SettingsFocusPane = 'categories' | 'settings';
@@ -75,7 +83,12 @@ export const SETTINGS_CATEGORY_GROUPS: ReadonlyArray<{
75
83
  { label: 'AI Routing', categories: ['provider', 'pricing', 'subscriptions', 'helper', 'tools', 'tts', 'voice'] },
76
84
  { label: 'Service & Network', categories: ['service', 'daemon', 'network', 'controlPlane', 'httpListener', 'web', 'relay'] },
77
85
  { label: 'Surfaces & Cloud', categories: ['surfaces', 'device', 'conversationGate', 'integrations', 'connections', 'mcp', 'cloudflare'] },
78
- { label: 'Automation', categories: ['batch', 'automation', 'checkin', 'watchers', 'orchestration', 'planner', 'wrfc', 'payments'] },
86
+ // 'occasions' sits beside 'checkin': both are proactive background loops
87
+ // (a cadence, a judgment pass, a conditional channel delivery) rather than
88
+ // facts held about the owner — the data occasions/plans declare lives in
89
+ // the owner profile file itself (docs/occasions.md §3), but these SETTINGS
90
+ // rows tune the loop, exactly like checkin's.
91
+ { label: 'Automation', categories: ['batch', 'automation', 'checkin', 'occasions', 'watchers', 'orchestration', 'planner', 'wrfc', 'payments'] },
79
92
  // 'profile' sits beside 'memory' and 'learning': all three are what the
80
93
  // platform retains about the person using it, and this is the group a reader
81
94
  // looking for "what does it know about me" already scans — Interface is
package/src/main.ts CHANGED
@@ -55,6 +55,7 @@ import { applyInitialTuiCliState, reportFatalStartupError } from './cli/tui-star
55
55
  import { applyConfiguredHitlMode, applyRuntimeConfigValue, applyTuiRuntimeConfigDefaults } from './cli/config-overrides.ts';
56
56
  import { renderToolCallBlock } from './renderer/tool-call.ts';
57
57
  import { wireSpokenTurnRuntime } from './audio/spoken-turn-wiring.ts';
58
+ import { installVoiceCapture } from './shell/voice-capture-shell.ts';
58
59
  import { attachSpokenTurnModelRouting, createSpokenTurnInputOptions } from './audio/spoken-turn-model-routing.ts';
59
60
  import { allowTerminalWrite, installTuiTerminalOutputGuard } from './runtime/terminal-output-guard.ts';
60
61
  import { installProcessLifecycle } from './runtime/process-lifecycle.ts';
@@ -189,6 +190,8 @@ async function main() {
189
190
  const failoverState = createFailoverTurnState();
190
191
 
191
192
  const getPromptContentWidth = () => computePromptContentWidth(stdout.columns);
193
+ // Live-microphone footer row (push-to-talk or the wake detector); assigned once voice capture is wired below, null until then so pre-wiring frames size correctly.
194
+ let voiceCaptureStatus: () => import('./core/voice-capture-status.ts').VoiceCaptureIndicatorState | null = () => null;
192
195
 
193
196
  const getViewportHeight = (): number => {
194
197
  if (input.onboardingWizard.active) return stdout.rows || 24;
@@ -197,7 +200,7 @@ async function main() {
197
200
  const contextWindow = providerRegistry.getContextWindowForModel(currentModel);
198
201
  const rows = stdout.rows || 24;
199
202
  // Compact threshold must match buildShellFooter's `compact: height < 30` posture below, else estimateShellFooterHeight's cached-height fast path answers with the wrong mode.
200
- return rows - 2 - estimateShellFooterHeight(promptLines, contextWindow, rows < 30);
203
+ return rows - 2 - estimateShellFooterHeight(promptLines, contextWindow, rows < 30, voiceCaptureStatus());
201
204
  };
202
205
 
203
206
  const scroll = (delta: number) => {
@@ -333,8 +336,7 @@ async function main() {
333
336
  render();
334
337
  };
335
338
 
336
- commandContext.submitInput = submitInput;
337
- commandContext.submitSpokenInput = (text, content) => submitInput(text, content, { spokenOutput: true });
339
+ commandContext.submitInput = submitInput; commandContext.submitSpokenInput = (text, content) => submitInput(text, content, { spokenOutput: true });
338
340
  commandContext.stopSpokenOutput = () => spokenTurns.stop(); commandContext.pasteFromClipboard = () => input.handlePaste();
339
341
  // Read-only view of pending [TEXT: pN, M lines] fold markers, so /pastes
340
342
  // can preview a folded paste's actual content before the user submits it.
@@ -418,19 +420,19 @@ async function main() {
418
420
  },
419
421
  );
420
422
 
421
- orchestratorRefs.getViewportHeight = getViewportHeight;
422
- orchestratorRefs.scrollToEnd = scrollToEnd;
423
+ orchestratorRefs.getViewportHeight = getViewportHeight; orchestratorRefs.scrollToEnd = scrollToEnd;
423
424
 
424
425
  input.setCommandRegistry(commandRegistry, commandContext);
425
426
  commandContext.openComposerEditor = makeComposerEditorOpener({ buffer: input, stdin, stdout, writeGuard: allowTerminalWrite, repaint: () => { compositor.resetDiff(); render(); }, cwd: workingDir, env: process.env, notify: (m) => systemMessageRouter.high(m) });
426
427
  input.setConversationManager(conversation);
427
- input.setContentWidth(getPromptContentWidth());
428
- input.filePicker.setOnUpdate(() => render());
428
+ input.setContentWidth(getPromptContentWidth()); input.filePicker.setOnUpdate(() => render());
429
429
  // retirement: agentDetailModal/processModal setOnRefresh wiring removed —
430
430
  // those modals were deleted (Fleet subsumes the live process tree via F2).
431
431
 
432
432
  // Model picker callback is handled in bootstrap.ts — do not duplicate here.
433
433
  input.setHistory(inputHistory);
434
+ // ONE microphone path, shared by push-to-talk voice input (Alt+V) and wake-word detection; opens no device by itself (shell/voice-capture-shell.ts).
435
+ voiceCaptureStatus = installVoiceCapture({ configManager, shellPaths: ctx.services.shellPaths, homeDirectory, sessionId: ctx.runtime.sessionId, commandContext, unsubs, buffer: input, submitInput, notify: (m) => { systemMessageRouter.high(m); render(); }, render: () => render() });
434
436
 
435
437
  const toolCount = toolRegistry.list().length;
436
438
  conversation.splashOptions = {
@@ -511,7 +513,7 @@ async function main() {
511
513
  composerMode: composerState.modeLabel,
512
514
  composerStatus: composerState.statusLabel,
513
515
  composerFlags: composerState.flags,
514
- composerPendingRisk: composerState.pendingRisk, permissionMode: configManager.get('permissions.mode') as string,
516
+ composerPendingRisk: composerState.pendingRisk, permissionMode: configManager.get('permissions.mode') as string, voiceCapture: voiceCaptureStatus(),
515
517
  }).lines;
516
518
 
517
519
  const onboardingOwnsScreen = input.onboardingWizard.active;
@@ -735,9 +737,7 @@ async function main() {
735
737
  process.on('exit', exitListener);
736
738
 
737
739
  // --- Terminal setup ---
738
- stdin.setRawMode(true);
739
- stdin.resume();
740
- stdin.setEncoding('utf8');
740
+ stdin.setRawMode(true); stdin.resume(); stdin.setEncoding('utf8');
741
741
  allowTerminalWrite(() => stdout.write((cli.flags.noAltScreen ? '' : ALT_SCREEN_ENTER) + CLEAR_SCREEN + CURSOR_HIDE + MOUSE_ENABLE + KEYBOARD_EXT_ENABLE + PASTE_ENABLE + FOCUS_ENABLE));
742
742
  // forced dark/light applies before first paint; auto (TTY only) fires the
743
743
  // OSC 11 probe and repaints once if light wins. filterInput strips the reply from stdin.
@@ -771,9 +771,7 @@ async function main() {
771
771
 
772
772
  input.feed(data);
773
773
  });
774
- process.on('SIGINT', sigintHandler);
775
- process.on('unhandledRejection', unhandledRejectionHandler);
776
- stdout.on('resize', resizeHandler);
774
+ process.on('SIGINT', sigintHandler); process.on('unhandledRejection', unhandledRejectionHandler); stdout.on('resize', resizeHandler);
777
775
 
778
776
  // State restores happen ONLY when the user explicitly asks — a CLI flag
779
777
  // (--continue/--resume/--fork), a slash command (/session resume,
@@ -4,6 +4,7 @@ import { truncateDisplay } from '../utils/terminal-width.ts';
4
4
  import { GLYPHS, UI_TONES } from './ui-primitives.ts';
5
5
  import { activeUiTones } from './theme.ts';
6
6
  import { formatHints } from './hint-grammar.ts';
7
+ import { voiceCaptureRowVisible, type VoiceCaptureIndicatorState } from '../core/voice-capture-status.ts';
7
8
 
8
9
  /**
9
10
  * renderProcessIndicator — shows a one-line summary of active background
@@ -95,3 +96,68 @@ export function renderProcessIndicator(
95
96
  const hint = ` ${formatHints([{ key: 'Enter', verb: 'View' }])}`;
96
97
  return renderPlainStatus(`${label}${hint}`, { fg: activeUiTones().accent.brand, bold: true });
97
98
  }
99
+
100
+ /**
101
+ * Sentence each capture state renders as. Written out per state rather than
102
+ * assembled from fragments because these are the words that tell a user whether
103
+ * their microphone is open — "listening" and "recording" mean different things
104
+ * and a row that blurred them would be worse than no row.
105
+ */
106
+ const VOICE_CAPTURE_LABELS: Record<VoiceCaptureIndicatorState['kind'], string> = {
107
+ requesting: 'opening the microphone',
108
+ recording: 'recording — press the voice-input key again to stop',
109
+ transcribing: 'transcribing what you said',
110
+ 'wake-listening': 'listening for the wake phrase',
111
+ 'wake-capturing': 'wake heard — recording what follows',
112
+ 'wake-restarting': 'capture stream ended — restarting',
113
+ 'wake-latched': 'wake detection stopped',
114
+ };
115
+
116
+ /**
117
+ * renderVoiceCaptureIndicator — the persistent row shown while a microphone is
118
+ * open, below the input area beside the process indicator.
119
+ *
120
+ * It exists because a held-open capture device is otherwise invisible: wake
121
+ * detection runs for as long as the feature is on, and nothing else on screen
122
+ * would say so. `voice.wake.indicator` chooses between `statusline` (one dim
123
+ * row), `banner` (a highlighted row that is hard to miss) and `off`; a
124
+ * push-to-talk recording always renders, because the user pressed a key one
125
+ * moment ago and is waiting on it.
126
+ *
127
+ * Returns no lines when nothing is captured, or when the wake rows are turned
128
+ * off — the caller splices whatever comes back, so an empty array is "no row".
129
+ */
130
+ export function renderVoiceCaptureIndicator(
131
+ width: number,
132
+ state: VoiceCaptureIndicatorState | null,
133
+ ): Line[] {
134
+ if (!voiceCaptureRowVisible(state) || state === null) return [];
135
+ const tones = activeUiTones();
136
+ const marker = state.kind === 'wake-latched' ? GLYPHS.status.blocked : GLYPHS.status.active;
137
+ const device = state.deviceLabel !== null ? ` ${GLYPHS.navigation.pipeSeparator} ${state.deviceLabel}` : '';
138
+ const extra = state.detail !== undefined && state.detail.length > 0
139
+ ? ` ${GLYPHS.navigation.pipeSeparator} ${state.detail}`
140
+ : '';
141
+ const body = `${marker} Voice: ${VOICE_CAPTURE_LABELS[state.kind]}${device}${extra}`;
142
+ const isWakeRow = state.kind.startsWith('wake-');
143
+ const fg = state.kind === 'wake-latched' || state.kind === 'wake-restarting'
144
+ ? tones.chrome.warn
145
+ : tones.accent.control;
146
+
147
+ if (isWakeRow && state.indicator === 'banner') {
148
+ // The prominent variant: the row is filled to the terminal width on the
149
+ // footer background so it reads as a standing condition, not a passing note.
150
+ const line = UIFactory.stringToLine(' '.repeat(width), width, { fg: tones.chrome.faint });
151
+ const text = ` ${truncateDisplay(body, Math.max(0, width - 4), '…')} `;
152
+ for (let i = 0; i < text.length && 1 + i < width - 1; i++) {
153
+ const cell = line[1 + i]!;
154
+ cell.char = text[i]!;
155
+ cell.fg = fg;
156
+ cell.bg = tones.bg.footer;
157
+ cell.bold = true;
158
+ cell.dim = false;
159
+ }
160
+ return [line];
161
+ }
162
+ return [UIFactory.stringToLine(` ${truncateDisplay(body, Math.max(0, width - 4), '…')}`, width, { fg, bold: true })];
163
+ }
@@ -77,7 +77,7 @@ export const CATEGORY_INFO: Record<SettingsCategory, string> = {
77
77
  wrfc: 'Work-review-fix-cycle thresholds, retry limits, and automatic commit behavior.',
78
78
  helper: 'Helper model defaults used by helper subsystems when they do not use the main chat route.',
79
79
  tts: 'Text-to-speech provider, voice, and optional spoken-turn LLM overrides.',
80
- voice: 'Two independent voice capabilities. Local engines (voice.local.*): the STT and TTS engine, binary, and model-path for whisper.cpp/faster-whisper and Piper/Kokoro — all empty by default, configurable-not-configured, nothing auto-downloads, and an unconfigured machine reports honestly (never an error); once set, "Local engines" appears in the TTS provider picker beside elevenlabs, with no billing dimension. Wake-word detection (voice.wake.*, rendered as its own unit below): listening continuously for a spoken wake phrase and handing the utterance that follows to speech-to-text — off by default, because holding a microphone open is meant to be an explicit act. Its published recall figures are measured on synthesised speech only, so no human recording of the phrase is behind them.',
80
+ voice: 'Two independent voice capabilities. Local engines (voice.local.*): the STT and TTS engine, binary, and model-path for whisper.cpp/faster-whisper and Piper/Kokoro — all empty by default, configurable-not-configured, nothing auto-downloads, and an unconfigured machine reports honestly (never an error); once set, "Local engines" appears in the TTS provider picker beside elevenlabs, with no billing dimension. Wake-word detection (voice.wake.*, rendered as its own unit below): listening continuously for a spoken wake phrase and handing the utterance that follows to speech-to-text — off by default, because holding a microphone open is meant to be an explicit act. It is live on this terminal: enabling it starts a recorder subprocess (voice.wake.captureCommand), scores every frame with the pinned classifier through a WASM runtime, plays voice.wake.activationSound at a confirmed wake, shows a persistent footer row per voice.wake.indicator, and sends what follows to the same speech-to-text call — into the composer, or submitted outright when voice.wake.autoSubmit is on. The same capture path backs push-to-talk voice input (Alt+V by default), which always lands in the composer. voice.wake.noiseSuppression: speex runs here — the filter travels with the platform, so there is nothing to install for it — and voice.wake.vadThreshold above 0 screens frames with the pinned speech gate once provisioning has fetched it, refusing to start before that rather than scoring frames it claims to be screening. The model is already on this machine: installing goodvibes downloads and checksum-verifies it, and a running daemon retries at boot if the install could not reach the network — so switching this on normally needs no setup step. Switching it on never downloads anything itself; a host whose artifacts are missing or fail verification says which and names /voice wake setup rather than pretending to listen. Its published recall figures are measured on synthesised speech only, so no human recording of the phrase is behind them.',
81
81
  service: 'Background service posture: enabled state, autostart, restart behavior, service name, platform, and logs.',
82
82
  daemon: 'Local session daemon. It hosts the shared session broker and companion chat so a session started here is visible and steerable from other surfaces. On by default, bound to loopback (127.0.0.1) only.',
83
83
  controlPlane: 'Daemon control-plane settings for local admin/API access.',
@@ -86,6 +86,7 @@ export const CATEGORY_INFO: Record<SettingsCategory, string> = {
86
86
  batch: 'Batch execution settings, including local vs Cloudflare queue behavior.',
87
87
  automation: 'Scheduled and automated run settings, concurrency, timeout, catch-up, cooldown, and retention behavior.',
88
88
  checkin: 'Proactive check-in: on a cadence, a briefing is judged and you are contacted only when something warrants it. Off by default; every run — scheduled or manual — leaves a receipt (see /checkin) recording whether it stayed quiet, delivered a message, or was skipped.',
89
+ occasions: 'Proactive occasions and plans: dated, usually-recurring things that need an action (birthdays, anniversaries) and dated ranges that need only to be known about (a vacation). On by default. Lead time before a date, active hours nudges are allowed to arrive in, the nudge channel (empty makes it pull-only through /occasions), cadence while a window is open and the daily final stretch before it closes, whether an away plan moves a nudge earlier, whether a confirmed occasion mirrors to the calendar and whether a mirrored one suppresses its own nudge, how many questions the gift interview asks, how many years of gift history it keeps, and how often the sweep pass runs. The nudge itself never names the date — only how soon.',
89
90
  watchers: 'File/process watcher heartbeat, polling, and recovery-window behavior, plus the trigger family (watchers.triggers.*, rendered as its own unit): stream watchers that regex-filter and batch a long-lived command\'s output, model-free condition checks running a probe/extract/rule pipeline with no LLM in the loop, and one-shot on-exit triggers that fire exactly one payload when a launched command terminates. The trigger family is off by default — a trigger supervises real processes with nobody watching — and its rows tune the backoff ladder, the strike breaker, retention bounds, batching, and process caps.',
90
91
  runtime: 'Runtime guardrails such as companion chat limiter and event bus listener caps.',
91
92
  telemetry: 'Telemetry payload policy.',
@@ -94,7 +95,7 @@ export const CATEGORY_INFO: Record<SettingsCategory, string> = {
94
95
  mcp: 'MCP server trust and scope review. Trust changes can expose local files, tools, databases, browsers, or remote automation depending on the server.',
95
96
  sandbox: 'Isolation strategy for REPL, MCP, Windows, and QEMU-backed execution. Use these settings to separate risky tools from the host shell.',
96
97
  surfaces: 'External app surfaces such as Slack, Discord, ntfy, Home Assistant, Telegram, webhooks, chat bridges, and messaging providers.',
97
- device: 'Posture for using a PAIRED phone as a tool — its cameras, its screen, its location, its clipboard, and a small set of device effects. Two rows change behaviour today. device.capabilities.mode is the live switch: off stops every capability request reaching any paired device, ask-every-time asks on every single request and never consults a durable grant, and honor-grants (stock) asks the first time and every time after unless you chose "always allow" for that one capability on that one phone. device.nodes.maxPaired is enforced at the moment a device pairs: at the cap a NEW device is refused with a message naming the setting, the cap and how many are already paired, while a phone that is already in the list simply supersedes its own record and keeps working. Lowering the cap below the number already paired unpairs no one — existing devices keep working and only the next new pairing is refused; a device moving off the legacy shared token to its own is exempt so it cannot be stranded; and a nonsensical cap (zero, negative, not a number) is read as no cap at all, because a broken setting must never lock you out of your own daemon. The cap is re-read at every pairing, so a change applies without a restart. The remaining rows state the posture the capability service is built to honour — which capabilities may be granted durably, how long a phone has to answer, how exact a location it reports, whether the clipboard can be read, how long captures and grants live, and how often housekeeping sweeps. They are recorded and read back exactly as you set them, but no surface in THIS build maps them into a running capability session — the capability service currently runs on its own matching defaults. Your values are kept and take effect in the release that wires the session up.',
98
+ device: 'Posture for using a PAIRED phone as a tool — its cameras, its screen, its location, its clipboard, and a small set of device effects. Every row here changes behaviour, and every change applies to the next request without a restart. device.capabilities.mode is the master switch: off stops every capability request reaching any paired device (nothing is dispatched and nobody is asked), ask-every-time asks on every single request and never consults or offers a durable grant, and honor-grants (stock) asks the first time and every time after unless you chose "always allow" for that one capability on that one phone. allowAlwaysOffer decides which capabilities may offer that durable grant at all: every-capability (stock), standard-only (the elevated ones — front camera, screen capture, precise location, clipboard read — keep asking and an "always" answer is honoured for that one request only), or never. requestTimeoutSeconds is the deadline on three things at once: how long the phone has to answer, the deadline written into the request the phone receives, and how long the confirmation prompt itself stays open. device.location.precision gates the street-level fix (coarse-only refuses it and still serves approximate; ask-precise serves it but never remembers it), and device.clipboard.readMode gates reading the clipboard the same way — putting text ON the clipboard is a different capability and is unaffected. Captures from the camera and the screen are kept for capture.retentionHours and capped at capture.maxArtifacts, whichever bites first; housekeeping runs at every start and then every capture.sweepIntervalMinutes, deleting expired grants and captures and recording what it removed and why. A grant lasts grants.expiryDays and is never honoured past it, one phone holds at most grants.maxPerNode grants (the oldest are reaped past the cap), and the ledger of grants given, used, revoked and expired is kept for grants.auditRetentionDays. device.nodes.maxPaired is enforced at the moment a device pairs: at the cap a NEW device is refused with a message naming the setting, the cap and how many are already paired, while a phone that is already in the list simply supersedes its own record and keeps working. Lowering the cap below the number already paired unpairs no one — existing devices keep working and only the next new pairing is refused; a device moving off the legacy shared token to its own is exempt so it cannot be stranded. A nonsensical value anywhere here (zero, negative, not a number, an enum value that is not on the list) is read as the stock value rather than passed through, so a broken setting can neither lock you out of your own daemon nor widen the posture.',
98
99
  conversationGate: 'What a message arriving from one of those channels does. By default it gets a conversational answer, and work is proposed and waits for your agreement rather than starting on its own; you can instead confirm every run, or restore the old behavior where a message starts work immediately. Also how long a pending proposal stays answerable and how many can wait at once. This governs messages from channels only — what you type here always runs, and schedules and triggers were authorized when created.',
99
100
  cloudflare: 'Optional Cloudflare control plane, batch queue, Worker, Tunnel, Access, DNS, KV, Durable Objects, Secrets Store, and R2 settings.',
100
101
  release: 'Release-channel preference.',
@@ -141,6 +142,7 @@ export const CATEGORY_LABELS: Record<(typeof SETTINGS_CATEGORIES)[number], strin
141
142
  batch: 'Batch',
142
143
  automation: 'Automation',
143
144
  checkin: 'Check-in',
145
+ occasions: 'Occasions & Plans',
144
146
  watchers: 'Watchers',
145
147
  runtime: 'Runtime',
146
148
  telemetry: 'Telemetry',
@@ -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')
@@ -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
@@ -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. */