@pellux/goodvibes-tui 1.21.1 → 1.22.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 (37) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +1 -1
  3. package/docs/foundation-artifacts/operator-contract.json +714 -177
  4. package/docs/foundation-artifacts/peer-contract.json +8 -4
  5. package/package.json +9 -7
  6. package/src/cli/entrypoint.ts +6 -0
  7. package/src/cli/service-posture.ts +6 -2
  8. package/src/config/run-daemon-config-migration.ts +46 -0
  9. package/src/core/conversation-compaction-render.ts +88 -0
  10. package/src/core/conversation-line-cache.ts +120 -92
  11. package/src/core/conversation-rendering.ts +290 -233
  12. package/src/core/conversation-turn-structure.ts +464 -0
  13. package/src/core/conversation-types.ts +8 -9
  14. package/src/daemon/cli.ts +71 -5
  15. package/src/input/commands/local-runtime.ts +14 -14
  16. package/src/input/commands/operator-rpc.ts +15 -4
  17. package/src/input/daemon-owned-settings-descriptions.ts +53 -0
  18. package/src/input/handler-content-actions.ts +4 -2
  19. package/src/input/handler-onboarding-daemon-adopt.ts +10 -0
  20. package/src/input/onboarding/onboarding-feature-units.ts +38 -0
  21. package/src/input/onboarding/onboarding-wizard-types.ts +1 -0
  22. package/src/input/search.ts +1 -1
  23. package/src/input/settings-modal-data.ts +15 -85
  24. package/src/input/settings-modal-search.ts +99 -0
  25. package/src/input/settings-modal-types.ts +2 -1
  26. package/src/renderer/conversation-surface.ts +37 -11
  27. package/src/renderer/conversation-tree.ts +179 -0
  28. package/src/renderer/settings-modal-helpers.ts +5 -3
  29. package/src/renderer/settings-modal.ts +8 -0
  30. package/src/renderer/tool-call.ts +38 -5
  31. package/src/runtime/cloudflare-control-plane.ts +4 -1
  32. package/src/runtime/feature-settings.ts +7 -0
  33. package/src/runtime/index.ts +22 -1
  34. package/src/runtime/legacy-daemon-migration.ts +2 -0
  35. package/src/verification/verification-ledger.ts +115 -2
  36. package/src/version.ts +1 -1
  37. package/src/core/conversation-tool-groups.ts +0 -152
@@ -0,0 +1,179 @@
1
+ /**
2
+ * conversation-tree.ts — column geometry for the transcript's branch tree.
3
+ *
4
+ * The transcript renders an assistant turn as a small tree: the turn header at
5
+ * depth 0, each tool call it issues as a depth-1 branch, and each call's result
6
+ * as a depth-2 branch under that call. This module owns the *only* arithmetic
7
+ * that decides where those branch glyphs and their content land, so every row
8
+ * type (event lines, tool-call rows, collapsed fragments) agrees on a single
9
+ * column grid instead of each re-deriving it.
10
+ *
11
+ * Geometry, relative to the existing flush layout (LAYOUT.LEFT_MARGIN = 4):
12
+ *
13
+ * depth 0 ` ● assistant …` marker col 3, content col 5
14
+ * depth 1 `✓ ├ config.get …` connector col 5, content col 7
15
+ * depth 2 `✓ │ └ ▸ 627 lines …` connector col 7, content col 9
16
+ *
17
+ * The connector sits where the parent's content began, and content starts two
18
+ * columns after it — the same marker→content relationship a depth-0 row has.
19
+ * Status lives in a fixed gutter at column 0, OUTSIDE the indent, so every ✓
20
+ * and ✗ in the transcript lines up in one column regardless of nesting depth.
21
+ *
22
+ * Every level steps by exactly TREE_STEP_COLS, and indent, gutter and content
23
+ * columns are functions of depth and width ALONE — never of sibling status.
24
+ * That is what makes a `└`→`├` connector flip cost exactly one cell: the
25
+ * column it occupies exists either way, so no text moves.
26
+ *
27
+ * Narrow terminals: indenting unconditionally would eat the content budget and
28
+ * silently truncate the informative end of a row — the failure class the
29
+ * markdown-table column bug came from. treeIndentCols() therefore clamps the
30
+ * indent to whatever still leaves TREE_MIN_CONTENT_COLS of content, and drops
31
+ * to 0 (flush, no branch glyph) when even one level does not fit. Losing the
32
+ * visual nesting on a 30-column terminal is acceptable; losing characters is
33
+ * not.
34
+ */
35
+
36
+ import { LAYOUT } from './layout.ts';
37
+ import type { Line } from '../types/grid.ts';
38
+ import { createStyledCell } from '../types/grid.ts';
39
+
40
+ /** Columns added per nesting level. Fixed — never content-dependent. */
41
+ const TREE_STEP_COLS = 2;
42
+
43
+ /**
44
+ * Width of the reserved status column that every row carries, at every depth.
45
+ *
46
+ * The column exists whether or not the row currently has a marker: a completed
47
+ * row shows ✓, a failed row its failure glyph, an in-flight or plain row a
48
+ * blank of the same width. Because the space is always reserved, a marker
49
+ * appearing or resolving changes only that cell's contents and can never shift
50
+ * the label beside it. Combined with fixed per-depth indentation this gives the
51
+ * whole tree one rule: columns are fixed, only their contents change.
52
+ */
53
+ const TREE_STATUS_COLS = 2;
54
+
55
+ /**
56
+ * Content columns a branch row is guaranteed to keep. Below this the indent is
57
+ * reduced (and finally dropped) rather than squeezing the row's text.
58
+ */
59
+ const TREE_MIN_CONTENT_COLS = 16;
60
+
61
+
62
+ /**
63
+ * Effective indent, in columns, for a branch at `depth` rendered at `width`.
64
+ *
65
+ * Returns 0 for depth <= 0 (a flush, non-branch row) and for widths too narrow
66
+ * to carry even one level while keeping TREE_MIN_CONTENT_COLS of content. The
67
+ * result is always a whole multiple of TREE_STEP_COLS, so branch glyphs across
68
+ * different depths stay on the same column grid.
69
+ */
70
+ export function treeIndentCols(depth: number, width: number): number {
71
+ if (depth <= 0) return 0;
72
+ // The status gutter is charged first and never given back — see TREE_GUTTER_COL.
73
+ const budget = width - LAYOUT.LEFT_MARGIN - LAYOUT.RIGHT_MARGIN - TREE_STATUS_COLS - TREE_MIN_CONTENT_COLS;
74
+ if (budget < TREE_STEP_COLS) return 0;
75
+ const affordableLevels = Math.floor(budget / TREE_STEP_COLS);
76
+ return Math.min(depth, affordableLevels) * TREE_STEP_COLS;
77
+ }
78
+
79
+ /**
80
+ * Write the status gutter for a row: `glyph` when it has one, an untouched
81
+ * blank of the same width when it does not. Success and failure differ by
82
+ * GLYPH, not only by colour, so the distinction survives a monochrome terminal
83
+ * and colour-blind viewers.
84
+ */
85
+ export function writeTreeStatusGutter(
86
+ line: Line,
87
+ glyph: string | null,
88
+ fg: string,
89
+ width: number,
90
+ ): void {
91
+ if (!glyph || TREE_GUTTER_COL >= width) return;
92
+ line[TREE_GUTTER_COL] = createStyledCell(glyph, { fg, bold: true });
93
+ }
94
+
95
+ /**
96
+ * Column the connector (`├`/`└`) occupies for a given effective indent, and
97
+ * the column an ancestor's `│` gutter occupies at that same indent.
98
+ */
99
+ export function treeBranchCol(indentCols: number): number {
100
+ return LAYOUT.LEFT_MARGIN - 1 + indentCols;
101
+ }
102
+
103
+ /**
104
+ * The status gutter: a fixed column at the far LEFT of every row, before any
105
+ * indentation, carrying ✓ / ✕ / blank.
106
+ *
107
+ * Deliberately outside the indent so it does not scale with depth. With the
108
+ * marker placed after the connector instead, status would sit at a different
109
+ * horizontal position on every row depending on nesting, so scanning for what
110
+ * completed means tracking a ragged edge — and that degrades precisely as
111
+ * trees get deeper. Here every marker in the transcript lines up in one
112
+ * column no matter how deep its row sits, for the cost of one fixed column.
113
+ *
114
+ * It is never clamped: narrow terminals clamp the INDENT (see treeIndentCols)
115
+ * and leave the gutter intact, because losing nesting depth is recoverable by
116
+ * widening the terminal while losing which rows succeeded is not.
117
+ */
118
+ const TREE_GUTTER_COL = 0;
119
+
120
+ /** Column where a branch row's text starts, immediately after its connector. */
121
+ export function treeContentCol(indentCols: number): number {
122
+ return treeBranchCol(indentCols) + 2;
123
+ }
124
+
125
+ /**
126
+ * Columns at which ancestor `│` gutters are drawn for a row at `depth`, given
127
+ * the set of ancestor depths whose subtrees are still open.
128
+ *
129
+ * Clamping can push two levels onto the same column at narrow widths; a gutter
130
+ * is emitted only when it lands strictly left of this row's own connector, so
131
+ * a clamped tree degrades by losing gutters rather than by overwriting the
132
+ * connector.
133
+ */
134
+ function treeGutterCols(
135
+ openAncestorDepths: readonly number[],
136
+ depth: number,
137
+ width: number,
138
+ ): number[] {
139
+ const ownCol = treeBranchCol(treeIndentCols(depth, width));
140
+ const cols: number[] = [];
141
+ for (const ancestorDepth of openAncestorDepths) {
142
+ const col = treeBranchCol(treeIndentCols(ancestorDepth, width));
143
+ if (col < ownCol) cols.push(col);
144
+ }
145
+ return cols;
146
+ }
147
+
148
+ /**
149
+ * Draw a branch row's connector and the `│` gutters of its still-open
150
+ * ancestors onto an already-rendered line, in place.
151
+ *
152
+ * Connectors are recomputed from live structure on every rebuild (see
153
+ * conversation-turn-structure.ts), never cached, so a row that stops being
154
+ * last flips `└`→`├` here with nothing stale to invalidate. Only this cell
155
+ * changes: indent, status gutter and content column are functions of depth and
156
+ * width alone, so a sibling arriving repaints one connector cell and moves no
157
+ * text.
158
+ */
159
+ export function drawBranchConnector(
160
+ line: Line,
161
+ depth: number,
162
+ connector: string | undefined,
163
+ openAncestorDepths: readonly number[],
164
+ width: number,
165
+ fg: string,
166
+ ): void {
167
+ if (depth <= 0 || !connector) return;
168
+ const indent = treeIndentCols(depth, width);
169
+ if (indent <= 0) return;
170
+ for (const gutterCol of treeGutterCols(openAncestorDepths, depth, width)) {
171
+ if (gutterCol >= 0 && gutterCol < width) {
172
+ line[gutterCol] = createStyledCell('\u2502', { fg, dim: true });
173
+ }
174
+ }
175
+ const col = treeBranchCol(indent);
176
+ if (col >= 0 && col < width) {
177
+ line[col] = createStyledCell(connector, { fg, dim: true });
178
+ }
179
+ }
@@ -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: 'Free, offline local voice 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.',
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.',
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,7 +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
- watchers: 'File/process watcher heartbeat, polling, and recovery-window behavior.',
89
+ 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
90
  runtime: 'Runtime guardrails such as companion chat limiter and event bus listener caps.',
91
91
  telemetry: 'Telemetry payload policy.',
92
92
  cache: 'Provider and model cache behavior, TTL, and hit-rate monitoring.',
@@ -94,6 +94,7 @@ export const CATEGORY_INFO: Record<SettingsCategory, string> = {
94
94
  mcp: 'MCP server trust and scope review. Trust changes can expose local files, tools, databases, browsers, or remote automation depending on the server.',
95
95
  sandbox: 'Isolation strategy for REPL, MCP, Windows, and QEMU-backed execution. Use these settings to separate risky tools from the host shell.',
96
96
  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.',
97
98
  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.',
98
99
  cloudflare: 'Optional Cloudflare control plane, batch queue, Worker, Tunnel, Access, DNS, KV, Durable Objects, Secrets Store, and R2 settings.',
99
100
  release: 'Release-channel preference.',
@@ -145,6 +146,7 @@ export const CATEGORY_LABELS: Record<(typeof SETTINGS_CATEGORIES)[number], strin
145
146
  mcp: 'MCP',
146
147
  sandbox: 'Sandbox',
147
148
  surfaces: 'Surfaces',
149
+ device: 'Paired Devices',
148
150
  conversationGate: 'Channel Message Handling',
149
151
  cloudflare: 'Cloudflare',
150
152
  release: 'Release',
@@ -200,7 +202,7 @@ const SETTING_LABELS: Partial<Record<string, string>> = {
200
202
  'controlPlane.hostMode': 'CP Host Mode',
201
203
  'controlPlane.host': 'CP Host',
202
204
  'controlPlane.port': 'CP Port',
203
- 'controlPlane.baseUrl': 'CP Base URL',
205
+ 'controlPlane.publicBaseUrl': 'CP Public Base URL',
204
206
  'controlPlane.streamMode': 'CP Stream Mode',
205
207
  'controlPlane.allowRemote': 'CP Allow Remote',
206
208
  'controlPlane.trustProxy': 'CP Trust Proxy',
@@ -236,6 +236,14 @@ function buildFlagContext(entry: SettingEntry | null): string[] {
236
236
  `Feature: ${feature.id} (${feature.domain} domain)`,
237
237
  `Setting: ${feature.enablement.key} = ${formatValue(entry)}`,
238
238
  `State: ${displayState}`,
239
+ // A capability the registry declares not operable reads as disabled no
240
+ // matter what its settings key says. Without this line the row shows a
241
+ // value of true beside a state of disabled and explains nothing — the
242
+ // written reason exists in the registry, so render it where the user meets
243
+ // the contradiction rather than leaving it to be discovered.
244
+ ...(feature.operable === false && feature.inoperableDetail
245
+ ? [`Not available in this build: ${feature.inoperableDetail}`]
246
+ : []),
239
247
  `Default: ${feature.defaultEnabled ? 'enabled' : 'disabled'}`,
240
248
  `Applies: ${feature.restartRequired ? 'on next launch (startup-gated)' : 'immediately'}`,
241
249
  ...(pendingRestart
@@ -6,10 +6,20 @@ import { stripDangerousAnsi } from './ansi-sanitize.ts';
6
6
  import { formatElapsed } from '../utils/format-elapsed.ts';
7
7
  import { GLYPHS } from './ui-primitives.ts';
8
8
  import { activeUiTones } from './theme.ts';
9
+ import { treeBranchCol, treeContentCol, writeTreeStatusGutter } from './conversation-tree.ts';
9
10
 
10
11
  const TOOL_NAME_MIN_WIDTH = 8;
11
12
  const TOOL_NAME_MAX_WIDTH = 20;
12
13
 
14
+ /** Tree placement for a tool-call row rendered as a branch of its turn. */
15
+ export interface ToolCallTreeOptions {
16
+ /** Effective indent in columns, already clamped by treeIndentCols(). */
17
+ readonly indentCols?: number;
18
+ readonly branchFg?: string;
19
+ /** Suppress the tool label because the turn header already carries it. */
20
+ readonly omitToolName?: boolean;
21
+ }
22
+
13
23
  function writeStyledText(
14
24
  line: Line,
15
25
  startCol: number,
@@ -44,9 +54,18 @@ function buildLeftSegments(
44
54
  keyArg: string,
45
55
  suffixText: string,
46
56
  leftBudget: number,
57
+ omitToolName = false,
47
58
  ): Array<{ text: string; fg: string; bold?: boolean; dim?: boolean }> {
48
59
  if (leftBudget <= 0) return [];
49
60
 
61
+ // When every call in the turn shares one label, the label rides on the turn
62
+ // header and each row leads with the argument that actually distinguishes it
63
+ // (`config.get`, `services.restart`). Falls back to showing the name when
64
+ // there is no key argument, so a row can never render empty.
65
+ if (omitToolName && keyArg) {
66
+ return buildLeftSegments(keyArg, '', suffixText, leftBudget, false);
67
+ }
68
+
50
69
  const t = activeUiTones();
51
70
  const segments: Array<{ text: string; fg: string; bold?: boolean; dim?: boolean }> = [];
52
71
  const suffixBudget = suffixText ? Math.min(Math.max(12, Math.floor(leftBudget * 0.3)), 20) : 0;
@@ -164,13 +183,19 @@ export function renderToolCallBlock(
164
183
  errorMsg?: string,
165
184
  frameIndex?: number,
166
185
  startedAtMs?: number,
186
+ tree?: ToolCallTreeOptions,
167
187
  ): Line[] {
168
188
  const line = createEmptyLine(width);
169
189
  const t = activeUiTones();
170
- const margin = LAYOUT.LEFT_MARGIN;
190
+ const indent = Math.max(0, tree?.indentCols ?? 0);
191
+ // In tree mode the status icon moves to the branch's content column, so the
192
+ // ✓ lines up with the content of every other row at the same depth and the
193
+ // branch glyph sits where the parent's content began.
194
+ const margin = indent > 0 ? treeContentCol(indent) : LAYOUT.LEFT_MARGIN;
171
195
  const rightMargin = LAYOUT.RIGHT_MARGIN;
172
196
  const contentEnd = width - rightMargin;
173
197
 
198
+
174
199
  // Status icon. 'pending' means the tool is still awaiting a decision
175
200
  // (e.g. an approval prompt) and has NOT run yet — it uses the hollow idle
176
201
  // glyph so a not-yet-run tool never shows the completed green ✓ (2c).
@@ -207,10 +232,18 @@ export function renderToolCallBlock(
207
232
  : contentEnd;
208
233
  let col: number = leftStart;
209
234
 
210
- if (col < leftEndExclusive) {
211
- line[col] = createStyledCell(icon, { fg: iconColor, bold: status === 'done' || status === 'error' || status === 'cancelled' });
235
+ // In tree mode the status glyph goes in the fixed left gutter (column 0),
236
+ // before the indent, so every marker in the transcript aligns in one column
237
+ // no matter how deep its row sits. The row's own content then starts at the
238
+ // branch's content column with no inline icon slot.
239
+ if (indent > 0) {
240
+ writeTreeStatusGutter(line, icon, iconColor, width);
241
+ } else {
242
+ if (col < leftEndExclusive) {
243
+ line[col] = createStyledCell(icon, { fg: iconColor, bold: status === 'done' || status === 'error' || status === 'cancelled' });
244
+ }
245
+ col += 2; // icon + space
212
246
  }
213
- col += 2; // icon + space
214
247
 
215
248
  // Tool name — extract short name for long MCP tool names, but keep a readable dynamic width.
216
249
  const rawName = toolCall.name.includes('__')
@@ -225,7 +258,7 @@ export function renderToolCallBlock(
225
258
  ? `(${stripDangerousAnsi(resultSummary)})`
226
259
  : '';
227
260
  const leftBudget = Math.max(0, leftEndExclusive - col);
228
- const leftSegments = buildLeftSegments(rawName, keyArg, suffixText, leftBudget);
261
+ const leftSegments = buildLeftSegments(rawName, keyArg, suffixText, leftBudget, tree?.omitToolName ?? false);
229
262
  for (const segment of leftSegments) {
230
263
  col = writeStyledText(line, col, leftEndExclusive, segment.text, {
231
264
  fg: segment.fg,
@@ -282,7 +282,10 @@ function hostForUrl(host: string): string {
282
282
  }
283
283
 
284
284
  export function resolveCloudflareDaemonBaseUrl(configManager: Pick<ConfigManager, 'get'>): string {
285
- const configuredBaseUrl = String(configManager.get('controlPlane.baseUrl' as never) ?? '').trim();
285
+ // An explicitly DECLARED external address (tunnel / proxy) wins; otherwise the
286
+ // URL is built from the bind below. The old `controlPlane.baseUrl` this read
287
+ // was a stored mirror of the bind with no writers, so it drifted from it.
288
+ const configuredBaseUrl = String(configManager.get('controlPlane.publicBaseUrl' as never) ?? '').trim();
286
289
  if (configuredBaseUrl) return configuredBaseUrl.replace(/\/+$/, '');
287
290
  const host = hostForUrl(connectHostForBindHost(String(configManager.get('controlPlane.host' as never) ?? '127.0.0.1')));
288
291
  const portValue = Number(configManager.get('controlPlane.port' as never) ?? 3421);
@@ -73,6 +73,13 @@ export function featuresForEnablementKey(key: string): readonly FeatureSetting[]
73
73
  */
74
74
  export function isFeatureValueEnabled(feature: FeatureSetting, value: unknown): boolean {
75
75
  const { key, kind, enabledValues } = feature.enablement;
76
+ // A capability declared not operable in this build never reads as on, whatever
77
+ // its settings key says. deriveFeatureState already enforces this for the
78
+ // boolean and enum kinds; stating it once here covers the constant kind too,
79
+ // so no surface can show "on" for something that is doing nothing. The user's
80
+ // value is untouched — it is kept for the release that wires the capability up
81
+ // — and `feature.inoperableDetail` is the written reason a surface renders.
82
+ if (feature.operable === false) return false;
76
83
  if (kind === 'constant') {
77
84
  const schema = getConfigSchemaSetting(key);
78
85
  if (schema?.type === 'boolean') return value === true;
@@ -230,6 +230,18 @@ export const isPluginTerminal = operations.isPluginTerminal;
230
230
  export const resolveCapabilityManifest = operations.resolveCapabilityManifest;
231
231
  export const validateManifestV2 = operations.validateManifestV2;
232
232
  export const validatePluginSignature = operations.validatePluginSignature;
233
+ // Instance-type companions for the operations class aliases (see note above the
234
+ // security block): the `export const` form re-exports the value only.
235
+ export type AcpTaskAdapter = InstanceType<typeof operations.AcpTaskAdapter>;
236
+ export type OpsIllegalActionError = InstanceType<typeof operations.OpsIllegalActionError>;
237
+ export type OpsTargetNotFoundError = InstanceType<typeof operations.OpsTargetNotFoundError>;
238
+ export type ToolContractVerifier = InstanceType<typeof operations.ToolContractVerifier>;
239
+ export type McpLifecycleManager = InstanceType<typeof operations.McpLifecycleManager>;
240
+ export type McpPermissionManager = InstanceType<typeof operations.McpPermissionManager>;
241
+ export type McpSchemaFreshnessTracker = InstanceType<typeof operations.McpSchemaFreshnessTracker>;
242
+ export type PluginLifecycleManager = InstanceType<typeof operations.PluginLifecycleManager>;
243
+ export type PluginQuarantineEngine = InstanceType<typeof operations.PluginQuarantineEngine>;
244
+ export type PluginTrustStore = InstanceType<typeof operations.PluginTrustStore>;
233
245
  export const LOW_QUALITY_THRESHOLD = operations.LOW_QUALITY_THRESHOLD;
234
246
  export const computeQualityScore = operations.computeQualityScore;
235
247
  export const createCompactionManager = operations.createCompactionManager;
@@ -487,6 +499,15 @@ export const verifyBundle = security.verifyBundle;
487
499
  export const MAX_INPUT_LENGTH = security.MAX_INPUT_LENGTH;
488
500
  export const MAX_TOKEN_COUNT = security.MAX_TOKEN_COUNT;
489
501
 
502
+ // Instance-type companions for the class aliases above. `export const X = ns.X`
503
+ // re-exports only the value, so `X` is unusable in type position (TS2749/TS2694).
504
+ // These declaration-merge the instance type back onto the same name.
505
+ export type DivergenceDashboard = InstanceType<typeof security.DivergenceDashboard>;
506
+ export type DivergenceGateError = InstanceType<typeof security.DivergenceGateError>;
507
+ export type LayeredPolicyEvaluator = InstanceType<typeof security.LayeredPolicyEvaluator>;
508
+ export type PermissionSimulator = InstanceType<typeof security.PermissionSimulator>;
509
+ export type PolicySignatureError = InstanceType<typeof security.PolicySignatureError>;
510
+
490
511
  export type AuthInspectionSnapshot = Security.AuthInspectionSnapshot;
491
512
  export type ProviderAuthInspection = Security.ProviderAuthInspection;
492
513
  export type DivergenceDashboardSnapshot = Security.DivergenceDashboardSnapshot;
@@ -513,7 +534,7 @@ export type BundleProvenance = Security.BundleProvenance;
513
534
  export type DecisionReason = Security.DecisionReason;
514
535
  export type DivergenceReport = Security.DivergenceReport;
515
536
  export type EnforceGateResult = Security.EnforceGateResult;
516
- export type SignedPolicyBundle = Security.SignedPolicyBundle;
537
+ export type SignedPolicyBundle<T = unknown> = Security.SignedPolicyBundle<T>;
517
538
 
518
539
  export interface InspectableDomain {
519
540
  readonly name: string;
@@ -49,6 +49,7 @@ import { fileURLToPath } from 'node:url';
49
49
  import { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
50
50
  import { PlatformServiceManager, type ManagedServiceStatus } from '@pellux/goodvibes-sdk/platform/daemon';
51
51
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
52
+ import { runDaemonConfigMigration } from '../config/run-daemon-config-migration.ts';
52
53
 
53
54
  /** Structurally derived from `PlatformServiceManager`'s own constructor — the
54
55
  * SDK's public `platform/daemon` entry point only re-exports the class and
@@ -113,6 +114,7 @@ export interface BuildManagedDaemonServiceManagerParams {
113
114
  */
114
115
  export function buildManagedDaemonServiceManager(params: BuildManagedDaemonServiceManagerParams): PlatformServiceManager {
115
116
  const workingDirectory = params.workingDirectory ?? params.homeDir;
117
+ if (!params.configManager) runDaemonConfigMigration(params.homeDir);
116
118
  const configManager = params.configManager ?? new ConfigManager({
117
119
  workingDir: workingDirectory,
118
120
  homeDir: params.homeDir,
@@ -79,6 +79,116 @@ export const FEATURE_KNOB_LOCAL_SETTINGS = [
79
79
  'runtime.toolBudget.maxCostUsd',
80
80
  ] as const;
81
81
 
82
+ /**
83
+ * Config keys added by the paired-device and trigger-family domains in SDK
84
+ * 1.14.0. Like FEATURE_KNOB_LOCAL_SETTINGS above, these arrived as new
85
+ * CONFIG_SCHEMA entries that raised the settings inventory `total` with no
86
+ * matching behavior coverage, dropping `localBehaviorPercent` below its floor.
87
+ *
88
+ * THIS CONSTANT IS NOT A DIAL. Each key below is counted because a test that
89
+ * exercises it was actually written:
90
+ * `src/test/verification/device-and-trigger-settings-persistence.test.ts` runs,
91
+ * for every key in this list, the same four-part persistence contract the
92
+ * feature-knob keys are counted for — schema default exposure, `set()` through
93
+ * the key's own validator to disk, reload into a fresh ConfigManager with
94
+ * read-back equality, and reset-to-default that also survives reload. That test
95
+ * additionally asserts this list is exactly the non-enablement key set of both
96
+ * domains and overlaps neither of the other counted sets, so nothing here is
97
+ * double-counted and nothing drifts in uncounted.
98
+ *
99
+ * PER-KEY EVIDENCE — what is verified, and how live the key is TODAY.
100
+ * "persistence" below means the four-part contract above; the second column
101
+ * records whether anything in this tree (TUI or the pinned SDK) reads the key,
102
+ * audited by tracing each key to its consumers.
103
+ *
104
+ * watchers.triggers.backoffLadderMs persistence · live (trigger supervisor retry ladder)
105
+ * watchers.triggers.breakerStrikes persistence · live (strike breaker)
106
+ * watchers.triggers.defaultCheckIntervalMs persistence · live (condition-check scheduler)
107
+ * watchers.triggers.probeTimeoutMs persistence · live (probe execution)
108
+ * watchers.triggers.maxConcurrentChecks persistence · live (check concurrency cap)
109
+ * watchers.triggers.observationRingSize persistence · live (observation ring)
110
+ * watchers.triggers.runHistoryLimit persistence · live (run history retention)
111
+ * watchers.triggers.runHistoryTtlHours persistence · live (run history sweep)
112
+ * watchers.triggers.eventLogLimit persistence · live (event log retention)
113
+ * watchers.triggers.eventLogTtlHours persistence · live (event log sweep)
114
+ * watchers.triggers.sweepIntervalMs persistence · live (housekeeping cadence)
115
+ * watchers.triggers.supervisionTickMs persistence · live (supervision loop tick)
116
+ * watchers.triggers.streamQueueLimit persistence · live (stream watcher queue)
117
+ * watchers.triggers.streamBatchLines persistence · live (stream batching)
118
+ * watchers.triggers.streamBatchIntervalMs persistence · live (stream batching)
119
+ * watchers.triggers.onExitMaxDurationMs persistence · live (on-exit trigger duration cap)
120
+ * watchers.triggers.onExitStdin persistence · live (on-exit stdin posture)
121
+ * watchers.triggers.outputTailBytes persistence · live (captured output tail)
122
+ * device.capabilities.allowAlwaysOffer persistence · settings surface only in this build
123
+ * device.capabilities.requestTimeoutSeconds persistence · settings surface only in this build
124
+ * device.location.precision persistence · settings surface only in this build
125
+ * device.clipboard.readMode persistence · settings surface only in this build
126
+ * device.capture.retentionHours persistence · settings surface only in this build
127
+ * device.capture.maxArtifacts persistence · settings surface only in this build
128
+ * device.capture.sweepIntervalMinutes persistence · settings surface only in this build
129
+ * device.grants.expiryDays persistence · settings surface only in this build
130
+ * device.grants.maxPerNode persistence · settings surface only in this build
131
+ * device.grants.auditRetentionDays persistence · settings surface only in this build
132
+ * device.nodes.maxPaired persistence · live (enforced at the pairing path)
133
+ *
134
+ * On `device.nodes.maxPaired`: the SDK enforces it where a device pairs —
135
+ * PairingTokenManager.mint refuses a NEW node at the cap with
136
+ * PairingLimitReachedError (code DEVICE_NODES_MAX_PAIRED, carrying the setting
137
+ * name, the cap and the live count), mapped to HTTP 409 by the pairing and
138
+ * pairing-handoff routes. An already-paired node supersedes its own record
139
+ * instead of being refused, migration mints off the legacy shared token are
140
+ * deliberately exempt, lowering the cap unpairs nobody, and a non-positive or
141
+ * non-finite value reads as no cap so a broken setting cannot lock the owner
142
+ * out. The cap is read per mint, so a change applies without a restart. What is
143
+ * counted HERE is still only the persistence contract this repo's test
144
+ * exercises — the enforcement itself is SDK-side and SDK-tested, and is recorded
145
+ * in this column so the "how live is it" audit stays accurate, not to claim
146
+ * coverage this repo did not write.
147
+ *
148
+ * On the other ten `device.*` rows: DeviceCapabilityService carries its own
149
+ * policy struct whose defaults match those keys one for one, and names them in
150
+ * its field comments and refusal messages, but no code path in either tree maps
151
+ * configuration into that struct. Persistence is therefore the whole of what is
152
+ * verified for them, and the settings-workspace description for the `device`
153
+ * category says exactly that to the user rather than implying live knobs.
154
+ *
155
+ * DELIBERATELY NOT COUNTED: the 24 non-enablement `voice.wake.*` keys from the
156
+ * same release. `wake-word-detection` is declared `notOperable` — no surface
157
+ * captures audio — so its rows stay in `total` and out of the numerator. That is
158
+ * the honest reading, and it is why this list raises coverage by 29 and not 53.
159
+ */
160
+ export const DEVICE_AND_TRIGGER_LOCAL_SETTINGS = [
161
+ 'watchers.triggers.backoffLadderMs',
162
+ 'watchers.triggers.breakerStrikes',
163
+ 'watchers.triggers.defaultCheckIntervalMs',
164
+ 'watchers.triggers.probeTimeoutMs',
165
+ 'watchers.triggers.maxConcurrentChecks',
166
+ 'watchers.triggers.observationRingSize',
167
+ 'watchers.triggers.runHistoryLimit',
168
+ 'watchers.triggers.runHistoryTtlHours',
169
+ 'watchers.triggers.eventLogLimit',
170
+ 'watchers.triggers.eventLogTtlHours',
171
+ 'watchers.triggers.sweepIntervalMs',
172
+ 'watchers.triggers.supervisionTickMs',
173
+ 'watchers.triggers.streamQueueLimit',
174
+ 'watchers.triggers.streamBatchLines',
175
+ 'watchers.triggers.streamBatchIntervalMs',
176
+ 'watchers.triggers.onExitMaxDurationMs',
177
+ 'watchers.triggers.onExitStdin',
178
+ 'watchers.triggers.outputTailBytes',
179
+ 'device.capabilities.allowAlwaysOffer',
180
+ 'device.capabilities.requestTimeoutSeconds',
181
+ 'device.location.precision',
182
+ 'device.clipboard.readMode',
183
+ 'device.capture.retentionHours',
184
+ 'device.capture.maxArtifacts',
185
+ 'device.capture.sweepIntervalMinutes',
186
+ 'device.grants.expiryDays',
187
+ 'device.grants.maxPerNode',
188
+ 'device.grants.auditRetentionDays',
189
+ 'device.nodes.maxPaired',
190
+ ] as const;
191
+
82
192
  /**
83
193
  * Every feature's enablement settings key whose on/off writes round-trip
84
194
  * through a REAL on-disk ConfigManager in
@@ -95,8 +205,11 @@ const ENABLEMENT_KEYS_BEHAVIOR_VERIFIED = new Set(
95
205
  .filter((key) => !(FEATURE_KNOB_LOCAL_SETTINGS as readonly string[]).includes(key)),
96
206
  ).size;
97
207
 
98
- /** Settings with real local behavior verification: the authored baseline plus the persistence-tested feature-knob and enablement keys. */
99
- const SETTINGS_BEHAVIOR_VERIFIED = SETTINGS_BEHAVIOR_BASELINE + FEATURE_KNOB_LOCAL_SETTINGS.length + ENABLEMENT_KEYS_BEHAVIOR_VERIFIED;
208
+ /** Settings with real local behavior verification: the authored baseline plus the persistence-tested feature-knob, device/trigger, and enablement keys. */
209
+ const SETTINGS_BEHAVIOR_VERIFIED = SETTINGS_BEHAVIOR_BASELINE
210
+ + FEATURE_KNOB_LOCAL_SETTINGS.length
211
+ + DEVICE_AND_TRIGGER_LOCAL_SETTINGS.length
212
+ + ENABLEMENT_KEYS_BEHAVIOR_VERIFIED;
100
213
 
101
214
  const EXTERNAL_SLASH_COMMANDS = new Set([
102
215
  'auth',
package/src/version.ts CHANGED
@@ -6,7 +6,7 @@ import { join } from 'node:path';
6
6
  // The prebuild script updates the fallback value before compilation.
7
7
  // Uses import.meta.dir (Bun) to locate package.json relative to this file,
8
8
  // which is correct regardless of the process working directory.
9
- let _version = '1.21.1';
9
+ let _version = '1.22.0';
10
10
  try {
11
11
  const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8'));
12
12
  // Only trust a version read from OUR OWN package.json. A Bun single-file