@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.
- package/CHANGELOG.md +11 -0
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +714 -177
- package/docs/foundation-artifacts/peer-contract.json +8 -4
- package/package.json +9 -7
- package/src/cli/entrypoint.ts +6 -0
- package/src/cli/service-posture.ts +6 -2
- package/src/config/run-daemon-config-migration.ts +46 -0
- package/src/core/conversation-compaction-render.ts +88 -0
- package/src/core/conversation-line-cache.ts +120 -92
- package/src/core/conversation-rendering.ts +290 -233
- package/src/core/conversation-turn-structure.ts +464 -0
- package/src/core/conversation-types.ts +8 -9
- package/src/daemon/cli.ts +71 -5
- package/src/input/commands/local-runtime.ts +14 -14
- package/src/input/commands/operator-rpc.ts +15 -4
- package/src/input/daemon-owned-settings-descriptions.ts +53 -0
- package/src/input/handler-content-actions.ts +4 -2
- package/src/input/handler-onboarding-daemon-adopt.ts +10 -0
- package/src/input/onboarding/onboarding-feature-units.ts +38 -0
- package/src/input/onboarding/onboarding-wizard-types.ts +1 -0
- package/src/input/search.ts +1 -1
- package/src/input/settings-modal-data.ts +15 -85
- package/src/input/settings-modal-search.ts +99 -0
- package/src/input/settings-modal-types.ts +2 -1
- package/src/renderer/conversation-surface.ts +37 -11
- package/src/renderer/conversation-tree.ts +179 -0
- package/src/renderer/settings-modal-helpers.ts +5 -3
- package/src/renderer/settings-modal.ts +8 -0
- package/src/renderer/tool-call.ts +38 -5
- package/src/runtime/cloudflare-control-plane.ts +4 -1
- package/src/runtime/feature-settings.ts +7 -0
- package/src/runtime/index.ts +22 -1
- package/src/runtime/legacy-daemon-migration.ts +2 -0
- package/src/verification/verification-ledger.ts +115 -2
- package/src/version.ts +1 -1
- 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: '
|
|
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.
|
|
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
|
|
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
|
-
|
|
211
|
-
|
|
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
|
-
|
|
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;
|
package/src/runtime/index.ts
CHANGED
|
@@ -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
|
|
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.
|
|
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
|