@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.
- package/CHANGELOG.md +9 -0
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +3756 -1110
- package/package.json +8 -7
- package/scripts/postinstall.js +57 -1
- package/src/audio/activation-sound.ts +117 -0
- package/src/audio/capture.ts +112 -0
- package/src/audio/onnxruntime-assets.d.ts +24 -0
- package/src/audio/voice-capture-wiring.ts +119 -0
- package/src/audio/voice-input-session.ts +181 -0
- package/src/audio/wake-inference.ts +182 -0
- package/src/audio/wake-runtime.ts +341 -0
- package/src/cli/bundle-command.ts +4 -1
- package/src/cli/management-utils.ts +4 -1
- package/src/cli/tui-startup.ts +10 -1
- package/src/core/voice-capture-status.ts +53 -0
- package/src/core/voice-stt-gateway.ts +75 -0
- package/src/core/wake-provision-runner.ts +86 -0
- package/src/core/wake-provision-status.ts +170 -0
- package/src/daemon/cli.ts +117 -28
- package/src/daemon/fatal-boot-report.ts +111 -0
- package/src/daemon/provision-wake-model.ts +81 -0
- package/src/input/command-registry.ts +6 -0
- package/src/input/commands/experience-runtime.ts +37 -3
- package/src/input/handler-shortcuts.ts +8 -0
- package/src/input/keybindings.ts +10 -1
- package/src/input/onboarding/onboarding-feature-units.ts +5 -5
- package/src/input/settings-modal-types.ts +14 -1
- package/src/main.ts +12 -14
- package/src/renderer/process-indicator.ts +66 -0
- package/src/renderer/settings-modal-helpers.ts +4 -2
- package/src/renderer/shell-surface.ts +22 -3
- package/src/runtime/bootstrap-core.ts +7 -7
- package/src/runtime/bootstrap.ts +3 -3
- package/src/runtime/device-posture-composition.ts +110 -0
- package/src/runtime/disposal-wiring.ts +16 -0
- package/src/runtime/embedded-service-factories.ts +16 -0
- package/src/runtime/legacy-daemon-migration.ts +12 -1
- package/src/runtime/runtime-services-types.ts +17 -0
- package/src/runtime/services.ts +81 -4
- package/src/runtime/voice-setup-services.ts +50 -4
- package/src/shell/voice-capture-shell.ts +69 -0
- package/src/verification/verification-ledger.ts +59 -20
- package/src/version.ts +1 -1
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fatal-boot-report.ts — saying why, on a stream, before the process stops.
|
|
3
|
+
*
|
|
4
|
+
* ── Where this module comes from ──────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* This is a local mirror of the SDK's `platform/daemon/fatal-boot-report.ts`,
|
|
7
|
+
* kept identical in shape and in exported names (`writeFatalLine`,
|
|
8
|
+
* `writeExitingStdoutLine`, `reportFatalBootFailure`). It lives here only
|
|
9
|
+
* because the published `@pellux/goodvibes-sdk` this repository compiles
|
|
10
|
+
* against predates that export — importing it would not typecheck and CI
|
|
11
|
+
* builds against the published package regardless. Delete this file and switch
|
|
12
|
+
* every importer to the SDK's copy at the next re-pin — but check the SDK's
|
|
13
|
+
* `package.json` "exports" map first: as of the 1.21.0 re-pin (2026-07-30),
|
|
14
|
+
* the compiled `dist/platform/daemon/fatal-boot-report.js` still exists
|
|
15
|
+
* inside the published tarball and `cli.js` still imports it internally, but
|
|
16
|
+
* there is no `./platform/daemon/fatal-boot-report` (or any other) entry in
|
|
17
|
+
* "exports" that makes it importable from outside the package — confirmed by
|
|
18
|
+
* attempting `import { reportFatalBootFailure } from
|
|
19
|
+
* '@pellux/goodvibes-sdk/platform/daemon/fatal-boot-report'`, which Bun
|
|
20
|
+
* refuses to resolve. This file stays load-bearing until a future SDK release
|
|
21
|
+
* adds that subpath export.
|
|
22
|
+
*
|
|
23
|
+
* ── The failure this exists to close ──────────────────────────────────────
|
|
24
|
+
*
|
|
25
|
+
* The shipped daemon died mute. Measured, against the released 1.27.0 binary
|
|
26
|
+
* in an isolated home with an unparseable `.goodvibes/daemon/settings.json`:
|
|
27
|
+
* exit code 1, **zero bytes on stdout, zero bytes on stderr, and no activity
|
|
28
|
+
* log written at all**. It crash-looped 77 times overnight and the only signal
|
|
29
|
+
* an operator had was that everything had stopped.
|
|
30
|
+
*
|
|
31
|
+
* The mechanism was not buffering and not a bypassed handler. It was simpler:
|
|
32
|
+
* `daemon/cli.ts` reported the failure to the activity LOGGER and exited, and
|
|
33
|
+
* at that point in boot the logger had no destination — the daemon entrypoint
|
|
34
|
+
* never called `configureActivityLogger` at all — so the line went nowhere and
|
|
35
|
+
* no file descriptor was ever touched. A `logger.error` is not a disclosure.
|
|
36
|
+
* Only a write to a file descriptor is.
|
|
37
|
+
*
|
|
38
|
+
* ── Why `writeSync(2, …)` and not `process.stderr.write` ──────────────────
|
|
39
|
+
*
|
|
40
|
+
* Because the fatal path must not depend on anything a host can replace or
|
|
41
|
+
* defer. `process.stderr.write` is a property on a mutable global, and THIS
|
|
42
|
+
* repository is the one that replaces it: `src/runtime/terminal-output-guard.ts`
|
|
43
|
+
* intercepts terminal output to keep the rendered screen clean, and a replaced
|
|
44
|
+
* writer that records instead of printing turns a fatal error into silence. It
|
|
45
|
+
* is also a stream, so a write issued immediately before `process.exit()` can
|
|
46
|
+
* still be in flight when the process stops existing.
|
|
47
|
+
*
|
|
48
|
+
* `writeSync(fd, …)` is neither. It is a direct write to the descriptor the
|
|
49
|
+
* service journal is attached to, it has completed when it returns, and no
|
|
50
|
+
* amount of replacing `process.stderr` upstream can intercept it. That is the
|
|
51
|
+
* whole property this module provides, and it is why every early-exit site in
|
|
52
|
+
* the daemon boot path routes through here.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
import { writeSync } from 'node:fs';
|
|
56
|
+
import { flushActivityLogSync, logger, summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
57
|
+
|
|
58
|
+
/** stdout and stderr, as the file descriptors they actually are. */
|
|
59
|
+
const STDOUT_FD = 1;
|
|
60
|
+
const STDERR_FD = 2;
|
|
61
|
+
|
|
62
|
+
function writeLineToFd(fd: number, line: string): void {
|
|
63
|
+
try {
|
|
64
|
+
writeSync(fd, line.endsWith('\n') ? line : `${line}\n`);
|
|
65
|
+
} catch {
|
|
66
|
+
// A closed or unwritable descriptor must never turn a diagnostic into a
|
|
67
|
+
// second failure. There is nothing further to fall back to: this IS the
|
|
68
|
+
// fallback.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Write one line to stderr synchronously, immune to a replaced
|
|
74
|
+
* `process.stderr` and to exit-time truncation. Use this for anything that
|
|
75
|
+
* gates a process exit.
|
|
76
|
+
*/
|
|
77
|
+
export function writeFatalLine(line: string): void {
|
|
78
|
+
writeLineToFd(STDERR_FD, line);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The stdout twin, for output that must survive an exit that follows it — the
|
|
83
|
+
* help text, the version line, the service-subcommand receipt and the cluster
|
|
84
|
+
* output are all printed and then immediately exited on, which is the same
|
|
85
|
+
* race.
|
|
86
|
+
*/
|
|
87
|
+
export function writeExitingStdoutLine(line: string): void {
|
|
88
|
+
writeLineToFd(STDOUT_FD, line);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Report a fatal boot failure everywhere it can be found, then leave the exit
|
|
93
|
+
* to the caller.
|
|
94
|
+
*
|
|
95
|
+
* The stream write happens FIRST and synchronously. The activity log is
|
|
96
|
+
* attempted after, because it is the part that can fail — it needs a
|
|
97
|
+
* configured destination, a writable directory, and a flush — and the
|
|
98
|
+
* guarantee this function makes is the stream line, not the log line.
|
|
99
|
+
*/
|
|
100
|
+
export function reportFatalBootFailure(error: unknown, context = 'goodvibes daemon host'): void {
|
|
101
|
+
const summary = summarizeError(error);
|
|
102
|
+
writeFatalLine(`${context} failed: ${summary}`);
|
|
103
|
+
if (error instanceof Error && error.stack) writeFatalLine(error.stack);
|
|
104
|
+
try {
|
|
105
|
+
logger.error(`${context} failed`, { error: summary });
|
|
106
|
+
flushActivityLogSync();
|
|
107
|
+
} catch {
|
|
108
|
+
// The reason is already on stderr, which is the guarantee. A log that
|
|
109
|
+
// cannot be written must not escalate into a different failure.
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// provision-wake-model.ts — `goodvibes-daemon provision-wake-model`.
|
|
3
|
+
//
|
|
4
|
+
// WHAT THIS IS FOR
|
|
5
|
+
//
|
|
6
|
+
// The curl installer needs to put the wake-word model on the machine, and it must
|
|
7
|
+
// not hold the pinned URLs, byte counts or checksums to do it. Those live in ONE
|
|
8
|
+
// place — the SDK's wake-word manifest — and a shell script that copied them
|
|
9
|
+
// would be a second copy of a pin, drifting silently the first time the model is
|
|
10
|
+
// retrained. So the installer runs the binary it just installed and lets the SDK
|
|
11
|
+
// do what the SDK owns.
|
|
12
|
+
//
|
|
13
|
+
// IT EXITS 0 EVEN WHEN THE DOWNLOAD FAILS, ON PURPOSE
|
|
14
|
+
//
|
|
15
|
+
// The caller is an installer. A wake-word model is not a reason to fail installing
|
|
16
|
+
// a coding tool, and an installer that aborts half-way through is worse than one
|
|
17
|
+
// that finishes without a wake word. The outcome is printed either way — one plain
|
|
18
|
+
// line naming what happened and how to retry — and a running daemon retries at
|
|
19
|
+
// every boot. `--strict` is there for a caller that genuinely wants the exit code
|
|
20
|
+
// to carry the result (a test, a provisioning script that is checking); the
|
|
21
|
+
// installer does not pass it.
|
|
22
|
+
//
|
|
23
|
+
// It composes NOTHING. No runtime, no config manager, no gateway: this reads a
|
|
24
|
+
// home directory, derives the managed voice root the same way the running daemon
|
|
25
|
+
// does, and calls one SDK function. Composing a runtime here would start a second
|
|
26
|
+
// set of pollers on a machine whose daemon is probably already running.
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
provisionWakeWordModelsAtInstall,
|
|
31
|
+
resolveManagedVoiceRoot,
|
|
32
|
+
} from '@pellux/goodvibes-sdk/platform/voice';
|
|
33
|
+
|
|
34
|
+
export interface ProvisionWakeModelResult {
|
|
35
|
+
readonly exitCode: number;
|
|
36
|
+
readonly lines: readonly string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ProvisionWakeModelDeps {
|
|
40
|
+
/** The home directory whose `.goodvibes/voice` tree receives the artifacts. */
|
|
41
|
+
readonly homeDirectory: string;
|
|
42
|
+
readonly env?: Readonly<Record<string, string | undefined>> | undefined;
|
|
43
|
+
/** Injected in tests: the SDK policy, so nothing downloads. */
|
|
44
|
+
readonly provisionAtInstall?: typeof provisionWakeWordModelsAtInstall | undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Run the install-time provision and report it.
|
|
49
|
+
*
|
|
50
|
+
* `--strict` makes a degraded outcome exit 1. Without it, every outcome exits 0,
|
|
51
|
+
* because the caller is an installer and the alternative is an aborted install
|
|
52
|
+
* over an optional model.
|
|
53
|
+
*/
|
|
54
|
+
export async function runProvisionWakeModelCommand(
|
|
55
|
+
argv: readonly string[],
|
|
56
|
+
deps: ProvisionWakeModelDeps,
|
|
57
|
+
): Promise<ProvisionWakeModelResult> {
|
|
58
|
+
const strict = argv.includes('--strict');
|
|
59
|
+
const provision = deps.provisionAtInstall ?? provisionWakeWordModelsAtInstall;
|
|
60
|
+
let managedRoot: string;
|
|
61
|
+
try {
|
|
62
|
+
managedRoot = resolveManagedVoiceRoot(deps.homeDirectory);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
// A home directory this process cannot make sense of is still not a reason to
|
|
65
|
+
// fail an install, so it is reported on the same terms as a failed download.
|
|
66
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
67
|
+
return {
|
|
68
|
+
exitCode: strict ? 1 : 0,
|
|
69
|
+
lines: [`wake-word model: skipped — ${message}`],
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const outcome = await provision({
|
|
73
|
+
managedRoot,
|
|
74
|
+
recoveryHint: '/voice wake setup',
|
|
75
|
+
...(deps.env !== undefined ? { env: deps.env } : {}),
|
|
76
|
+
});
|
|
77
|
+
return {
|
|
78
|
+
exitCode: strict && outcome.state === 'degraded' ? 1 : 0,
|
|
79
|
+
lines: [`wake-word model: ${outcome.message}`],
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -108,6 +108,12 @@ export interface CommandUiActions {
|
|
|
108
108
|
deleteQueuedMessage?: (id: string) => boolean;
|
|
109
109
|
/** Toggle the drill-in of the "used N memories" provenance chip (memory.showProvenance). */
|
|
110
110
|
toggleMemoryProvenance?: () => void;
|
|
111
|
+
/**
|
|
112
|
+
* Start voice input, or stop the recording already running and transcribe it
|
|
113
|
+
* into the composer. Two discrete presses rather than a hold, because a
|
|
114
|
+
* terminal delivers key events and never key-release state.
|
|
115
|
+
*/
|
|
116
|
+
toggleVoiceInput?: () => void;
|
|
111
117
|
/** The current host sleep-ownership projection (power.status.get, flattened) — chip, held-because, lid-split. */
|
|
112
118
|
getPowerState?: () => import('../core/power-status.ts').PowerSurfaceState;
|
|
113
119
|
/** Set the daemon-held keep-awake toggle (power.keepAwake.set), acquiring/releasing the inhibitor live; returns the new state. */
|
|
@@ -3,6 +3,10 @@ import { dirname, resolve } from 'node:path';
|
|
|
3
3
|
import type { CommandRegistry } from '../command-registry.ts';
|
|
4
4
|
import { requirePanelManager, requireShellPaths } from './runtime-services.ts';
|
|
5
5
|
import { createVoiceProvisionGateway, renderVoiceProvision, runVoiceSetupWithProgress, VOICE_SETUP_ANNOUNCEMENT } from '../../core/voice-provision-gateway.ts';
|
|
6
|
+
import { resolveWakeRuntimeSettings } from '@pellux/goodvibes-sdk/platform/voice/wake/runtime';
|
|
7
|
+
import { terminalWakeCapabilities } from '../../core/wake-provision-status.ts';
|
|
8
|
+
import { printWakeStatus, runWakeProvision } from '../../core/wake-provision-runner.ts';
|
|
9
|
+
import { wakeProvisionStatus } from '@pellux/goodvibes-sdk/platform/voice';
|
|
6
10
|
|
|
7
11
|
interface VoiceBundle {
|
|
8
12
|
readonly version: 1;
|
|
@@ -223,11 +227,41 @@ export function registerExperienceRuntimeCommands(registry: CommandRegistry): vo
|
|
|
223
227
|
|
|
224
228
|
registry.register({
|
|
225
229
|
name: 'voice',
|
|
226
|
-
description: 'Review or toggle always-speak mode, provision the managed local voice runtime (setup/status), and package portable voice metadata',
|
|
227
|
-
usage: '[review|status|setup|enable|disable|bundle export <path>|bundle inspect <path>]',
|
|
230
|
+
description: 'Review or toggle always-speak mode, provision the managed local voice runtime and the wake-word models (setup/status), and package portable voice metadata',
|
|
231
|
+
usage: '[review|status|setup|enable|disable|wake status|wake setup|bundle export <path>|bundle inspect <path>]',
|
|
228
232
|
async handler(args, ctx) {
|
|
229
233
|
const shellPaths = requireShellPaths(ctx);
|
|
230
234
|
const sub = (args[0] ?? 'review').toLowerCase();
|
|
235
|
+
if (sub === 'wake') {
|
|
236
|
+
// The wake models are provisioned locally by the SDK (checksum-pinned
|
|
237
|
+
// download), not through a daemon verb, so this path needs no gateway.
|
|
238
|
+
// Installation already provisions them and a daemon retries at boot;
|
|
239
|
+
// `setup` here is the recovery act, and nothing downloads unless the user
|
|
240
|
+
// typed it — see wake-provision-runner.ts.
|
|
241
|
+
const wakeSub = (args[1] ?? 'status').toLowerCase();
|
|
242
|
+
const managedRoot = shellPaths.resolveUserPath('voice');
|
|
243
|
+
const runner = {
|
|
244
|
+
managedRoot,
|
|
245
|
+
settings: resolveWakeRuntimeSettings(
|
|
246
|
+
(key: string) => ctx.platform.configManager.get(key as Parameters<typeof ctx.platform.configManager.get>[0]),
|
|
247
|
+
'tui',
|
|
248
|
+
// Read from disk, so `/voice wake status` reports the speech gate as
|
|
249
|
+
// available exactly when its artifact is there and verified.
|
|
250
|
+
terminalWakeCapabilities(wakeProvisionStatus({ managedRoot })),
|
|
251
|
+
),
|
|
252
|
+
print: (block: string) => ctx.print(block),
|
|
253
|
+
};
|
|
254
|
+
if (wakeSub === 'setup') {
|
|
255
|
+
await runWakeProvision(runner);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (wakeSub === 'status') {
|
|
259
|
+
printWakeStatus(runner);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
ctx.print('Usage: /voice wake [status|setup]');
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
231
265
|
if (sub === 'status' || sub === 'setup') {
|
|
232
266
|
// The one-act managed install returns a final receipt (no per-phase
|
|
233
267
|
// streaming over the verb), so the announcement block prints first and
|
|
@@ -294,7 +328,7 @@ export function registerExperienceRuntimeCommands(registry: CommandRegistry): vo
|
|
|
294
328
|
return;
|
|
295
329
|
}
|
|
296
330
|
}
|
|
297
|
-
ctx.print('Usage: /voice [review|status|setup|enable|disable|bundle export <path>|bundle inspect <path>]');
|
|
331
|
+
ctx.print('Usage: /voice [review|status|setup|enable|disable|wake status|wake setup|bundle export <path>|bundle inspect <path>]');
|
|
298
332
|
},
|
|
299
333
|
});
|
|
300
334
|
}
|
|
@@ -132,6 +132,14 @@ export function handleGlobalShortcutToken(
|
|
|
132
132
|
state.commandContext?.toggleMemoryProvenance?.();
|
|
133
133
|
return true;
|
|
134
134
|
|
|
135
|
+
case 'voice-input':
|
|
136
|
+
// Start recording, or stop the recording already running. Routed globally so
|
|
137
|
+
// the key works whether focus is on the composer or the panel workspace —
|
|
138
|
+
// dictation is not a composer-only act. Still consumed when voice input is
|
|
139
|
+
// not wired, so a stray Alt+V never lands in the prompt as text.
|
|
140
|
+
state.commandContext?.toggleVoiceInput?.();
|
|
141
|
+
return true;
|
|
142
|
+
|
|
135
143
|
case 'toggle-keep-awake': {
|
|
136
144
|
// Flip the daemon-held keep-awake toggle; the always-visible "sleep
|
|
137
145
|
// disabled" chip is the confirmation. Fire-and-forget (the seam renders on
|
package/src/input/keybindings.ts
CHANGED
|
@@ -71,7 +71,8 @@ export type KeyAction =
|
|
|
71
71
|
| 'yank-pop'
|
|
72
72
|
| 'cancel-tool-call'
|
|
73
73
|
| 'toggle-keep-awake'
|
|
74
|
-
| 'toggle-memory-provenance'
|
|
74
|
+
| 'toggle-memory-provenance'
|
|
75
|
+
| 'voice-input';
|
|
75
76
|
|
|
76
77
|
/** Human-readable description for each action (used in /keybindings display). */
|
|
77
78
|
export const ACTION_DESCRIPTIONS: Record<KeyAction, string> = {
|
|
@@ -117,6 +118,10 @@ export const ACTION_DESCRIPTIONS: Record<KeyAction, string> = {
|
|
|
117
118
|
'cancel-tool-call': 'Cancel the running tool call (the turn continues)',
|
|
118
119
|
'toggle-keep-awake': 'Toggle keep-awake (the "sleep disabled" chip)',
|
|
119
120
|
'toggle-memory-provenance': 'List/hide the memories a turn used (provenance chip drill-in)',
|
|
121
|
+
// Named for what it actually does. A terminal delivers key EVENTS, not held-key
|
|
122
|
+
// state — there is no release event to watch — so this is two discrete presses,
|
|
123
|
+
// and the description says so instead of implying a hold that cannot exist.
|
|
124
|
+
'voice-input': 'Voice input: press to start recording, press again to stop and transcribe into the composer',
|
|
120
125
|
};
|
|
121
126
|
|
|
122
127
|
/** Default key bindings for all actions. */
|
|
@@ -207,6 +212,10 @@ export const DEFAULT_KEYBINDINGS: Record<KeyAction, KeyCombo[]> = {
|
|
|
207
212
|
// Alt+M: expand/collapse the "used N memories" provenance chip's drill-in.
|
|
208
213
|
// Alt+M is unused by any other default. A no-op when the chip is not showing.
|
|
209
214
|
'toggle-memory-provenance': [{ key: 'm', alt: true }],
|
|
215
|
+
// Alt+V: start/stop voice input. Alt+V is unused by any other default (Ctrl+V
|
|
216
|
+
// is paste and stays paste). Two presses rather than a hold: see the action's
|
|
217
|
+
// description — a terminal has no key-release event to hold against.
|
|
218
|
+
'voice-input': [{ key: 'v', alt: true }],
|
|
210
219
|
};
|
|
211
220
|
|
|
212
221
|
/** Resolved overrides type: each key can be a single combo or array. */
|
|
@@ -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/
|
|
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:
|
|
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:
|
|
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
|
|
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: '
|
|
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
|
-
|
|
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.
|
|
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',
|