@pellux/goodvibes-tui 1.17.0 → 1.19.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 (104) hide show
  1. package/CHANGELOG.md +208 -0
  2. package/README.md +1 -1
  3. package/docs/foundation-artifacts/operator-contract.json +5025 -1636
  4. package/package.json +7 -7
  5. package/src/cli/endpoints.ts +50 -4
  6. package/src/cli/help.ts +29 -2
  7. package/src/cli/management-commands.ts +99 -23
  8. package/src/cli/management.ts +0 -2
  9. package/src/cli/service-posture.ts +5 -3
  10. package/src/cli/status.ts +27 -6
  11. package/src/cli/surface-command.ts +29 -15
  12. package/src/core/consolidation-receipt.ts +41 -0
  13. package/src/core/conversation-rendering.ts +14 -5
  14. package/src/core/memory-diagnostics-gateway.ts +99 -0
  15. package/src/core/memory-provenance.ts +67 -0
  16. package/src/core/memory-status.ts +118 -0
  17. package/src/core/pairing-devices.ts +49 -0
  18. package/src/core/pairing-handoff.ts +101 -0
  19. package/src/core/pairing-offers.ts +83 -0
  20. package/src/core/pairing-origin.ts +78 -0
  21. package/src/core/pairing-tailscale-gateway.ts +56 -0
  22. package/src/core/power-chip-source.ts +91 -0
  23. package/src/core/power-status.ts +95 -0
  24. package/src/core/stable-host.ts +171 -0
  25. package/src/core/stream-event-wiring.ts +21 -0
  26. package/src/core/turn-budget-outcome.ts +60 -0
  27. package/src/core/turn-cancellation.ts +39 -0
  28. package/src/core/turn-event-wiring.ts +6 -0
  29. package/src/core/voice-provision-gateway.ts +202 -0
  30. package/src/core/voice-provision-status.ts +197 -0
  31. package/src/daemon/cli.ts +127 -60
  32. package/src/input/command-registry.ts +21 -0
  33. package/src/input/commands/acp-runtime.ts +72 -0
  34. package/src/input/commands/devices-runtime.ts +109 -0
  35. package/src/input/commands/experience-runtime.ts +25 -4
  36. package/src/input/commands/git-runtime.ts +28 -4
  37. package/src/input/commands/graph-runtime.ts +45 -0
  38. package/src/input/commands/health-runtime.ts +15 -1
  39. package/src/input/commands/operator-rpc.ts +32 -11
  40. package/src/input/commands/power-runtime.ts +56 -0
  41. package/src/input/commands/qrcode-runtime.ts +14 -19
  42. package/src/input/commands/queue-runtime.ts +70 -0
  43. package/src/input/commands/workstream-attempts.ts +6 -1
  44. package/src/input/commands/workstream-runtime.ts +44 -25
  45. package/src/input/commands/worktree-runtime.ts +35 -8
  46. package/src/input/commands.ts +10 -0
  47. package/src/input/config-modal-types.ts +11 -0
  48. package/src/input/config-modal.ts +65 -2
  49. package/src/input/handler-shortcuts.ts +23 -0
  50. package/src/input/keybindings.ts +19 -1
  51. package/src/input/settings-modal-data.ts +31 -37
  52. package/src/input/settings-modal-types.ts +5 -2
  53. package/src/input/tts-settings-actions.ts +27 -8
  54. package/src/input/voice-local-settings.ts +52 -0
  55. package/src/main.ts +42 -42
  56. package/src/panels/builtin/operations.ts +54 -1
  57. package/src/panels/builtin/shared.ts +12 -0
  58. package/src/panels/builtin-modals.ts +49 -32
  59. package/src/panels/diff-panel.ts +34 -1
  60. package/src/panels/fleet-acts.ts +446 -0
  61. package/src/panels/fleet-gateway.ts +117 -0
  62. package/src/panels/fleet-observed-render.ts +128 -0
  63. package/src/panels/fleet-panel-format.ts +120 -4
  64. package/src/panels/fleet-panel-worktree-detail.ts +14 -0
  65. package/src/panels/fleet-panel.ts +60 -44
  66. package/src/panels/fleet-read-model.ts +34 -3
  67. package/src/panels/fleet-spawn.ts +266 -0
  68. package/src/panels/fleet-stop.ts +38 -2
  69. package/src/panels/fleet-tabs.ts +2 -2
  70. package/src/panels/memory-consolidation-gateway.ts +94 -0
  71. package/src/panels/modals/devices-modal.ts +161 -0
  72. package/src/panels/modals/memory-modal.ts +149 -5
  73. package/src/panels/modals/pairing-modal.ts +198 -68
  74. package/src/panels/modals/security-modal.ts +6 -0
  75. package/src/panels/workstream-graph-render.ts +123 -0
  76. package/src/renderer/composer-fragments.ts +69 -0
  77. package/src/renderer/settings-modal-helpers.ts +9 -0
  78. package/src/renderer/settings-modal.ts +4 -2
  79. package/src/renderer/shell-surface.ts +3 -0
  80. package/src/renderer/tool-call.ts +12 -6
  81. package/src/renderer/ui-factory.ts +32 -0
  82. package/src/runtime/bootstrap-command-context.ts +3 -1
  83. package/src/runtime/bootstrap-command-parts.ts +3 -1
  84. package/src/runtime/bootstrap-core.ts +6 -6
  85. package/src/runtime/bootstrap-shell.ts +7 -0
  86. package/src/runtime/code-index-services.ts +15 -1
  87. package/src/runtime/durability-services.ts +36 -1
  88. package/src/runtime/fleet-services.ts +41 -0
  89. package/src/runtime/idle-power-services.ts +85 -0
  90. package/src/runtime/index.ts +2 -0
  91. package/src/runtime/interaction-seams.ts +109 -0
  92. package/src/runtime/knowledge-services.ts +94 -0
  93. package/src/runtime/legacy-daemon-migration.ts +38 -6
  94. package/src/runtime/legacy-daemon-reconcile.ts +439 -0
  95. package/src/runtime/memory-governance-services.ts +79 -0
  96. package/src/runtime/notification-dispatch.ts +28 -0
  97. package/src/runtime/power-keepawake-remote.ts +75 -0
  98. package/src/runtime/process-lifecycle.ts +3 -1
  99. package/src/runtime/services.ts +105 -107
  100. package/src/runtime/ui-services.ts +3 -0
  101. package/src/runtime/voice-setup-services.ts +104 -0
  102. package/src/shell/blocking-input.ts +21 -141
  103. package/src/shell/recovery-input-helpers.ts +53 -166
  104. package/src/version.ts +11 -2
@@ -1,7 +1,7 @@
1
1
  import { UIFactory } from '../renderer/ui-factory.ts';
2
2
  import { renderMarkdownTracked } from '../renderer/markdown.ts';
3
3
  import { renderDiffView } from '../renderer/diff-view.ts';
4
- import { activeTheme } from '../renderer/theme.ts';
4
+ import { activeTheme, activeUiTones } from '../renderer/theme.ts';
5
5
  import { renderToolCallBlock } from '../renderer/tool-call.ts';
6
6
  import { summarizeToolResult } from '../renderer/tool-result-summary.ts';
7
7
  import type { ToolCall } from '@pellux/goodvibes-sdk/platform/types';
@@ -291,11 +291,20 @@ export function renderConversationToolMessage(
291
291
  context.collapseState.set(collapseKey, isShort ? false : true);
292
292
  }
293
293
 
294
+ // A per-call user cancellation settles as a tool result whose content leads
295
+ // with "Error: cancelled by user" (the SDK's structured cancelled shape; any
296
+ // partial output the tool produced before it stopped follows on later lines).
297
+ // Render it structurally as a distinct "cancelled" block in the warn tone —
298
+ // not a generic tool result, and not a hard error — so the transcript reads
299
+ // the user's decision honestly while the partial output stays visible below.
300
+ const isCancelled = blockType === 'tool' && /^Error: cancelled by user\b/.test(contentLines[0] ?? '');
301
+ const warnTone = activeUiTones().chrome.warn;
302
+
294
303
  context.history.addLine(renderConversationEventLine(width, {
295
- marker: blockType === 'diff' ? GLYPHS.status.dualPane : GLYPHS.status.active,
296
- markerFg: blockType === 'diff' ? T.diffAccent : T.toolAccent,
297
- label: blockType === 'diff' ? 'diff' : 'tool result',
298
- labelFg: blockType === 'diff' ? T.diffAccent : T.toolAccent,
304
+ marker: isCancelled ? GLYPHS.status.blocked : (blockType === 'diff' ? GLYPHS.status.dualPane : GLYPHS.status.active),
305
+ markerFg: isCancelled ? warnTone : (blockType === 'diff' ? T.diffAccent : T.toolAccent),
306
+ label: isCancelled ? 'cancelled' : (blockType === 'diff' ? 'diff' : 'tool result'),
307
+ labelFg: isCancelled ? warnTone : (blockType === 'diff' ? T.diffAccent : T.toolAccent),
299
308
  detailFg: '244',
300
309
  }, [
301
310
  ...(message.toolName
@@ -0,0 +1,99 @@
1
+ // ---------------------------------------------------------------------------
2
+ // memory-diagnostics-gateway.ts
3
+ //
4
+ // The daemon-backed verb the /health memory (doctor) surface reads:
5
+ // ops.memory.get (GET /api/ops/memory) — the read-only MemoryGovernor snapshot.
6
+ // No named facade exists on the in-process OperatorClient, so — exactly like
7
+ // the memory-consolidation and voice-provisioning gateways — it goes over the
8
+ // generic operator invoke path (operator-rpc.ts's resolveOperatorRpc ->
9
+ // sdk.operator.invoke), reaching the SAME daemon the command layer does.
10
+ //
11
+ // A daemon that predates the memory governor answers an honest 501 (the verb is
12
+ // cataloged but has no handler when no live governor is composed) or a 404 (an
13
+ // older daemon without the route) — both mean "this daemon does not serve
14
+ // memory diagnostics", NOT a fabricated all-zero snapshot (see
15
+ // classifyMemoryDiagnosticsError). The interface is injectable so the command
16
+ // and its tests round-trip against a mocked daemon.
17
+ // ---------------------------------------------------------------------------
18
+
19
+ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
20
+ import { GoodVibesSdkError } from '@pellux/goodvibes-sdk';
21
+ import { resolveOperatorRpc, describeOperatorRpcError } from '../input/commands/operator-rpc.ts';
22
+ import { memoryStatusLines, type MemoryGovernorSnapshotResult } from './memory-status.ts';
23
+
24
+ /** The narrow async verb surface the /health memory (doctor) surface drives. */
25
+ export interface MemoryDiagnosticsGateway {
26
+ /** Read the MemoryGovernor snapshot (ops.memory.get). */
27
+ fetchSnapshot(): Promise<MemoryGovernorSnapshotResult>;
28
+ }
29
+
30
+ /**
31
+ * Why the gateway could not be built (daemon disabled / no control-plane URL),
32
+ * surfaced verbatim so the surface prints an honest "unavailable" line rather
33
+ * than guessing — mirrors the memory-consolidation / voice gateways.
34
+ */
35
+ export type MemoryDiagnosticsGatewayResolution =
36
+ | { readonly available: true; readonly gateway: MemoryDiagnosticsGateway }
37
+ | { readonly available: false; readonly reason: string };
38
+
39
+ export interface MemoryDiagnosticsGatewayDeps {
40
+ readonly configManager: ConfigManager;
41
+ readonly homeDirectory: string | (() => string);
42
+ }
43
+
44
+ /**
45
+ * Build the live memory-diagnostics gateway over the generic operator invoke
46
+ * path — the same daemon resolution the command layer uses. Returns an honest
47
+ * unavailable reason when no daemon is reachable.
48
+ */
49
+ export function createMemoryDiagnosticsGateway(deps: MemoryDiagnosticsGatewayDeps): MemoryDiagnosticsGatewayResolution {
50
+ const rpc = resolveOperatorRpc({ configManager: deps.configManager, homeDirectory: deps.homeDirectory });
51
+ if (!rpc.available) return { available: false, reason: rpc.reason };
52
+ const { sdk } = rpc;
53
+ return {
54
+ available: true,
55
+ gateway: { fetchSnapshot: () => sdk.operator.invoke('ops.memory.get', {}) },
56
+ };
57
+ }
58
+
59
+ /** How an ops.memory.get rejection should render. */
60
+ export type MemoryDiagnosticsFailure =
61
+ | { readonly kind: 'unavailable'; readonly reason: string }
62
+ | { readonly kind: 'error'; readonly message: string };
63
+
64
+ /** The honest "no memory diagnostics" reason for a daemon that predates the governor verb. */
65
+ export const MEMORY_DIAGNOSTICS_UNAVAILABLE = 'this daemon does not serve memory diagnostics.';
66
+
67
+ /**
68
+ * Classify an ops.memory.get rejection: a 501 (a daemon composed without a live
69
+ * governor — the verb is cataloged but unhandled) or a 404 (an older daemon
70
+ * without the route) are both "verb unavailable"; anything else (network,
71
+ * 401/403, 500) is a generic error.
72
+ */
73
+ export function classifyMemoryDiagnosticsError(error: unknown): MemoryDiagnosticsFailure {
74
+ if (error instanceof GoodVibesSdkError && (error.status === 501 || error.status === 404)) {
75
+ return { kind: 'unavailable', reason: MEMORY_DIAGNOSTICS_UNAVAILABLE };
76
+ }
77
+ return { kind: 'error', message: describeOperatorRpcError(error) };
78
+ }
79
+
80
+ /**
81
+ * Render the /health memory (doctor) block from an already-resolved gateway
82
+ * resolution — the testable core the command wraps. Kept separate from the live
83
+ * gateway construction so a wire test injects a fake resolution (available /
84
+ * unavailable / failing) with no HTTP.
85
+ */
86
+ export async function renderMemoryDiagnostics(resolution: MemoryDiagnosticsGatewayResolution): Promise<string> {
87
+ if (!resolution.available) {
88
+ return `Health Review: Memory\n memory diagnostics unavailable: ${resolution.reason}`;
89
+ }
90
+ try {
91
+ const snapshot = await resolution.gateway.fetchSnapshot();
92
+ return ['Health Review: Memory', ...memoryStatusLines(snapshot)].join('\n');
93
+ } catch (error) {
94
+ const failure = classifyMemoryDiagnosticsError(error);
95
+ return failure.kind === 'unavailable'
96
+ ? `Health Review: Memory\n ${failure.reason}`
97
+ : `Health Review: Memory\n could not read memory diagnostics: ${failure.message}`;
98
+ }
99
+ }
@@ -0,0 +1,67 @@
1
+ // ---------------------------------------------------------------------------
2
+ // memory-provenance.ts — the optional "used N memories" turn chip (owner-ruled
3
+ // 2026-07-13, default OFF).
4
+ //
5
+ // The SDK tags each turn payload with the memory-sourced injection ids it drew
6
+ // on: `metadata.memory.recordIds: string[]` (absent when none) — the SAME
7
+ // convention the webui's chip reads, so both surfaces name the same records.
8
+ // When the memory-provenance setting is ON, a turn that used memories shows a
9
+ // small chip naming how many, with a drill-in listing them. When OFF (default),
10
+ // NOTHING is rendered and the metadata is never even read.
11
+ // ---------------------------------------------------------------------------
12
+
13
+ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
14
+
15
+ /** TUI-local setting key: show the per-turn memory-provenance chip. Default OFF. */
16
+ export const MEMORY_SHOW_PROVENANCE_CONFIG_KEY = 'memory.showProvenance';
17
+ export const MEMORY_SHOW_PROVENANCE_DEFAULT = false;
18
+ export const MEMORY_SHOW_PROVENANCE_DESCRIPTION =
19
+ 'Show a "used N memories" chip on turns that drew on your standing memories, with a drill-in ' +
20
+ 'listing them (Alt+M to expand). Off by default; when off, nothing is rendered and no metadata is read.';
21
+
22
+ /**
23
+ * Read the memory-provenance toggle defensively. `memory.*` is a TUI-local
24
+ * section with no SDK DEFAULT_CONFIG entry, so ConfigManager.get() throws for
25
+ * it until something has been written — an unset key means the default (OFF),
26
+ * not an error (same pattern as memory.projection.dir).
27
+ */
28
+ export function readMemoryShowProvenance(configManager: Pick<ConfigManager, 'get'>): boolean {
29
+ try {
30
+ return configManager.get(MEMORY_SHOW_PROVENANCE_CONFIG_KEY as Parameters<ConfigManager['get']>[0]) === true;
31
+ } catch {
32
+ return MEMORY_SHOW_PROVENANCE_DEFAULT;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Extract the memory-sourced record ids a turn drew on, from the SDK's
38
+ * `metadata.memory.recordIds` convention. Structural (not a named import) so it
39
+ * reads the field whether or not the pinned SDK's TurnEvent type surfaces it
40
+ * yet, and yields an empty list for any turn without the field — never a guess,
41
+ * never a throw.
42
+ */
43
+ export function memoryRecordIdsFromTurn(turn: unknown): readonly string[] {
44
+ const metadata = (turn as { metadata?: unknown } | null | undefined)?.metadata;
45
+ const memory = (metadata as { memory?: unknown } | null | undefined)?.memory;
46
+ const recordIds = (memory as { recordIds?: unknown } | null | undefined)?.recordIds;
47
+ if (!Array.isArray(recordIds)) return [];
48
+ return recordIds.filter((id): id is string => typeof id === 'string');
49
+ }
50
+
51
+ /**
52
+ * Build the settings-modal synthetic entry for memory.showProvenance (TUI-local,
53
+ * default OFF). Read defensively — memory.* has no SDK DEFAULT_CONFIG entry, so
54
+ * an unset key means the OFF default, not an error.
55
+ */
56
+ export function buildMemoryProvenanceSyntheticEntry(configManager: Pick<ConfigManager, 'get'>): {
57
+ setting: { key: string; type: 'boolean'; default: boolean; description: string };
58
+ currentValue: boolean;
59
+ isDefault: boolean;
60
+ } {
61
+ const currentValue = readMemoryShowProvenance(configManager);
62
+ return {
63
+ setting: { key: MEMORY_SHOW_PROVENANCE_CONFIG_KEY, type: 'boolean', default: MEMORY_SHOW_PROVENANCE_DEFAULT, description: MEMORY_SHOW_PROVENANCE_DESCRIPTION },
64
+ currentValue,
65
+ isDefault: currentValue === MEMORY_SHOW_PROVENANCE_DEFAULT,
66
+ };
67
+ }
@@ -0,0 +1,118 @@
1
+ // ---------------------------------------------------------------------------
2
+ // memory-status.ts — the surface-facing projection of the daemon's memory
3
+ // governance (ops.memory.get + OPS_MEMORY_PRESSURE).
4
+ //
5
+ // The SDK's MemoryGovernor owns the policy: it samples RSS/heap against a
6
+ // budget, sheds memory by tier (trimming registered caches, pausing deferrable
7
+ // background jobs), refuses new expensive work at the critical tier, and trips
8
+ // on a genuine leak before the OS OOM-kills it. This module is the read-only
9
+ // projection the /health memory (doctor) surface renders from — the snapshot
10
+ // rows — and the attention line OPS_MEMORY_PRESSURE produces when the tier
11
+ // changes or the tripwire fires.
12
+ //
13
+ // Pure formatting only (no I/O): the command fetches the typed verb response
14
+ // over the operator invoke seam (memory-diagnostics-gateway.ts) and hands it
15
+ // here, so these builders are unit-testable against fixture shapes.
16
+ // ---------------------------------------------------------------------------
17
+
18
+ import type { OperatorMethodOutput } from '@pellux/goodvibes-sdk';
19
+ import type { OpsEvent } from '@/runtime/index.ts';
20
+
21
+ /** ops.memory.get output — the full governor snapshot. */
22
+ export type MemoryGovernorSnapshotResult = OperatorMethodOutput<'ops.memory.get'>;
23
+ /** The OPS_MEMORY_PRESSURE event payload (a tier change or a tripwire firing). */
24
+ export type MemoryPressurePayload = Extract<OpsEvent, { type: 'OPS_MEMORY_PRESSURE' }>;
25
+
26
+ /** Human MB (the governor already reports MB as numbers; round to 1 decimal). */
27
+ export function formatMb(mb: number | null | undefined): string {
28
+ if (mb === null || mb === undefined || !Number.isFinite(mb)) return 'unknown';
29
+ return `${mb >= 100 ? Math.round(mb) : Math.round(mb * 10) / 10} MB`;
30
+ }
31
+
32
+ /** Human byte size for a cache footprint estimate. */
33
+ export function formatMemoryBytes(bytes: number | null | undefined): string {
34
+ if (bytes === null || bytes === undefined || !Number.isFinite(bytes) || bytes < 0) return 'size n/a';
35
+ if (bytes < 1024) return `${bytes} B`;
36
+ const units = ['KB', 'MB', 'GB'];
37
+ let value = bytes / 1024;
38
+ let unit = 0;
39
+ while (value >= 1024 && unit < units.length - 1) {
40
+ value /= 1024;
41
+ unit += 1;
42
+ }
43
+ return `${value >= 10 ? value.toFixed(0) : value.toFixed(1)} ${units[unit]}`;
44
+ }
45
+
46
+ /** A plain-language note for the current tier (the governor's posture at this tier). */
47
+ export function memoryTierNote(tier: MemoryGovernorSnapshotResult['tier']): string {
48
+ switch (tier) {
49
+ case 'normal': return 'footprint is comfortably within budget';
50
+ case 'elevated': return 'trimming caches to a floor and running gc';
51
+ case 'high': return 'flushing caches and pausing deferrable background jobs';
52
+ case 'critical': return 'refusing new expensive work until the footprint recovers';
53
+ default: return '';
54
+ }
55
+ }
56
+
57
+ /**
58
+ * The /health memory (doctor) rows — the honest account of the daemon defending
59
+ * its footprint. Order: tier + posture, budget vs RSS, heap, tier thresholds,
60
+ * per-cache footprints the governor can shrink, which deferrable jobs are
61
+ * paused, and the leak-tripwire state.
62
+ */
63
+ export function memoryStatusLines(snapshot: MemoryGovernorSnapshotResult): string[] {
64
+ const lines: string[] = [];
65
+ lines.push(` tier: ${snapshot.tier} — ${memoryTierNote(snapshot.tier)}`);
66
+ if (snapshot.refusingExpensiveWork) {
67
+ lines.push(' expensive work: REFUSED (critical tier) — new ingestion / reindex / voice install is declined until RSS recovers');
68
+ }
69
+ lines.push(` budget: ${formatMb(snapshot.rssMb)} rss / ${formatMb(snapshot.budgetMb)} budget (${Math.round(snapshot.usedPct)}%)`);
70
+ const heap = snapshot.heapTotalMb !== undefined
71
+ ? `${formatMb(snapshot.heapUsedMb)} used / ${formatMb(snapshot.heapTotalMb)} total`
72
+ : formatMb(snapshot.heapUsedMb);
73
+ lines.push(` heap: ${heap}`);
74
+ lines.push(` tiers: elevated ${snapshot.thresholds.elevatedPct}% · high ${snapshot.thresholds.highPct}% · critical ${snapshot.thresholds.criticalPct}%`);
75
+
76
+ if (snapshot.caches.length === 0) {
77
+ lines.push(' caches: none registered');
78
+ } else {
79
+ lines.push(` caches: ${snapshot.caches.length}`);
80
+ for (const cache of snapshot.caches) {
81
+ const size = cache.estimatedBytes !== undefined ? `, ~${formatMemoryBytes(cache.estimatedBytes)}` : '';
82
+ lines.push(` ${cache.id} (${cache.name}): ${cache.entries} entries${size}`);
83
+ }
84
+ }
85
+
86
+ lines.push(snapshot.pausedJobs.length === 0
87
+ ? ' paused jobs: none'
88
+ : ` paused jobs: ${snapshot.pausedJobs.join(', ')}`);
89
+
90
+ if (snapshot.tripwire.armed) {
91
+ lines.push(` tripwire: ARMED — watching for a sustained >${snapshot.tripwire.rateMbPerSec} MB/s post-flush leak (held ${Math.round(snapshot.tripwire.sustainedSec)}s)`);
92
+ } else {
93
+ lines.push(' tripwire: disarmed (no post-flush leak growth under watch)');
94
+ }
95
+ return lines;
96
+ }
97
+
98
+ /**
99
+ * The attention line OPS_MEMORY_PRESSURE produces: a one-liner naming the new
100
+ * tier and the RSS/budget, plus a distinct escalation when the leak tripwire
101
+ * fires (the daemon is about to exit for a clean supervisor restart). Returned
102
+ * as a single string so the notice/attention surface can push it directly.
103
+ */
104
+ export function memoryPressureLine(event: MemoryPressurePayload): string {
105
+ const base = `memory pressure: ${event.previousTier} → ${event.tier} (${formatMb(event.rssMb)} rss / ${formatMb(event.budgetMb)} budget, ${Math.round(event.usedPct)}%)`;
106
+ if (event.tripwire) {
107
+ return `${base} — leak tripwire fired (${event.tripwire.rateMbPerSec} MB/s sustained ${Math.round(event.tripwire.sustainedSec)}s); the daemon will exit for a clean restart`;
108
+ }
109
+ if (event.note) return `${base} — ${event.note}`;
110
+ return base;
111
+ }
112
+
113
+ /** Severity for the notice feed: critical tier (or a tripwire) is critical; high is a warning. */
114
+ export function memoryPressureLevel(event: MemoryPressurePayload): 'info' | 'warning' | 'critical' {
115
+ if (event.tripwire || event.tier === 'critical') return 'critical';
116
+ if (event.tier === 'high') return 'warning';
117
+ return 'info';
118
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * pairing-devices.ts — shared rendering for the paired-device list.
3
+ *
4
+ * Both the /devices command family and the settings device-management modal
5
+ * render each per-device token the same way (name · created · last-seen · id),
6
+ * so the two surfaces never drift. Pure over its inputs and a `now` clock, so
7
+ * the formatting is testable without real time.
8
+ */
9
+ import type { PublicPairingToken } from '@pellux/goodvibes-sdk/platform/pairing';
10
+
11
+ /** A short, human-quotable id prefix used to target rename/revoke. */
12
+ export function shortTokenId(id: string): string {
13
+ return id.slice(0, 8);
14
+ }
15
+
16
+ /** Relative last-seen, or "never" when the device has not authenticated yet. */
17
+ export function formatLastSeen(ms: number | undefined, now: number = Date.now()): string {
18
+ if (ms === undefined) return 'never';
19
+ const delta = Math.max(0, now - ms);
20
+ if (delta < 60_000) return 'just now';
21
+ const mins = Math.floor(delta / 60_000);
22
+ if (mins < 60) return `${mins}m ago`;
23
+ const hours = Math.floor(mins / 60);
24
+ if (hours < 24) return `${hours}h ago`;
25
+ return `${Math.floor(hours / 24)}d ago`;
26
+ }
27
+
28
+ /** Creation date as YYYY-MM-DD. */
29
+ export function formatCreated(ms: number): string {
30
+ return new Date(ms).toISOString().slice(0, 10);
31
+ }
32
+
33
+ /** One device line: name · created · last-seen · short id. */
34
+ export function formatDeviceLine(token: PublicPairingToken, now: number = Date.now()): string {
35
+ return `${token.name} · created ${formatCreated(token.createdAt)} · last seen ${formatLastSeen(token.lastSeenAt, now)} · ${shortTokenId(token.id)}`;
36
+ }
37
+
38
+ /** Resolve a token by exact id or unambiguous id-prefix; returns a resolution result. */
39
+ export function resolveTokenByIdPrefix(
40
+ tokens: readonly PublicPairingToken[],
41
+ idOrPrefix: string,
42
+ ): { readonly ok: true; readonly token: PublicPairingToken } | { readonly ok: false; readonly reason: 'not-found' | 'ambiguous' } {
43
+ const exact = tokens.find((t) => t.id === idOrPrefix);
44
+ if (exact) return { ok: true, token: exact };
45
+ const matches = tokens.filter((t) => t.id.startsWith(idOrPrefix));
46
+ if (matches.length === 1) return { ok: true, token: matches[0]! };
47
+ if (matches.length === 0) return { ok: false, reason: 'not-found' };
48
+ return { ok: false, reason: 'ambiguous' };
49
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * pairing-handoff.ts — the one place a TUI pairing producer mints a link.
3
+ *
4
+ * Every producer (the `gv pair` CLI block, the pairing modal QR, the /qrcode
5
+ * flow) routes through mintPairingHandoff, which mints a fresh per-device token
6
+ * and encodes the SDK's canonical `#pair=<token>` deep link — the exact shape
7
+ * the web app consumes, byte-for-byte the same as the `pairing.handoff.create`
8
+ * gateway verb's handler (mint + buildPairingHandoffLink). No producer encodes a
9
+ * raw JSON connection blob any more: a camera scan of the QR opens the web app
10
+ * already carrying the one-time token, so the device lands signed in.
11
+ *
12
+ * The token rides in the URL fragment, never the query, so it is never sent to a
13
+ * server (no access-log / Referer exposure). The offer set rides alongside so a
14
+ * bundle-aware surface can present the notifications/relay/passkey steps.
15
+ */
16
+ import {
17
+ buildPairingHandoffFragment,
18
+ buildPairingHandoffLink,
19
+ describeOriginPosture,
20
+ type MintedPairingToken,
21
+ type OriginPosture,
22
+ type PairingHandoffOfferKind,
23
+ } from '@pellux/goodvibes-sdk/platform/pairing';
24
+
25
+ /** The token-minting surface a handoff needs — satisfied by the SDK's PairingTokenManager. */
26
+ export interface PairingTokenMinter {
27
+ mint(input: { readonly name: string }): MintedPairingToken;
28
+ }
29
+
30
+ export interface PairingHandoff {
31
+ readonly token: MintedPairingToken;
32
+ readonly offers: readonly PairingHandoffOfferKind[];
33
+ /** `#pair=<token>` (with an `offers=` key when offers are present). */
34
+ readonly fragment: string;
35
+ /** `<webOrigin>/#pair=<token>` — present only when a web origin is known. */
36
+ readonly deepLink?: string | undefined;
37
+ /**
38
+ * The honest TLS/capability posture of the web origin the deep link opens —
39
+ * the same field the `pairing.handoff.create` gateway verb carries (both
40
+ * computed by the SDK's describeOriginPosture). Present only when a web origin
41
+ * is known; a surface renders its one honest LAN line and its labeled
42
+ * capability list from here, never from a locally-authored string.
43
+ */
44
+ readonly posture?: OriginPosture | undefined;
45
+ }
46
+
47
+ export interface MintPairingHandoffInput {
48
+ readonly pairingTokens: PairingTokenMinter;
49
+ /** The device/token name shown to the user and stored on the token; editable later. */
50
+ readonly name: string;
51
+ readonly offers: readonly PairingHandoffOfferKind[];
52
+ /** The web-app origin the deep link points at; absent ⇒ fragment-only. */
53
+ readonly webOrigin?: string | undefined;
54
+ }
55
+
56
+ /**
57
+ * A default name for a freshly-minted pairing token when the user did not supply
58
+ * one. Names are shown and editable later in the device management surface, so a
59
+ * date-stamped default is enough to tell two devices apart at a glance.
60
+ */
61
+ export function defaultPairingTokenName(now: Date = new Date()): string {
62
+ const stamp = now.toISOString().slice(0, 16).replace('T', ' ');
63
+ return `paired device (${stamp})`;
64
+ }
65
+
66
+ export function mintPairingHandoff(input: MintPairingHandoffInput): PairingHandoff {
67
+ const token = input.pairingTokens.mint({ name: input.name });
68
+ const fragment = buildPairingHandoffFragment({ token: token.token, offers: input.offers });
69
+ const deepLink = input.webOrigin
70
+ ? buildPairingHandoffLink({ webOrigin: input.webOrigin, token: token.token, offers: input.offers })
71
+ : undefined;
72
+ // The posture is described from the SAME web origin the deep link opens — the
73
+ // identical computation the gateway verb performs — so a locally-minted handoff
74
+ // carries the same honest posture as one minted over the wire.
75
+ const posture = input.webOrigin ? describeOriginPosture(input.webOrigin) : undefined;
76
+ return { token, offers: input.offers, fragment, ...(deepLink ? { deepLink } : {}), ...(posture ? { posture } : {}) };
77
+ }
78
+
79
+ /** The link content a QR should encode: the full deep link when known, else the fragment. */
80
+ export function pairingQrContent(handoff: PairingHandoff): string {
81
+ return handoff.deepLink ?? handoff.fragment;
82
+ }
83
+
84
+ export interface PairingOfferAvailability {
85
+ /** Whether the rendezvous relay is configured ⇒ the relay offer is presentable. */
86
+ readonly relayEnabled: boolean;
87
+ /** Whether a step-up (passkey) ceremony is wired ⇒ the passkey offer is presentable. */
88
+ readonly stepUpAvailable: boolean;
89
+ }
90
+
91
+ /**
92
+ * The offers this daemon can satisfy right now, in canonical order. Notifications
93
+ * are always available (browser push needs no server-side prerequisite beyond
94
+ * the VAPID key the daemon mints on demand); relay and passkey are gated.
95
+ */
96
+ export function availablePairingOffers(availability: PairingOfferAvailability): PairingHandoffOfferKind[] {
97
+ const offers: PairingHandoffOfferKind[] = ['notifications'];
98
+ if (availability.relayEnabled) offers.push('relay');
99
+ if (availability.stepUpAvailable) offers.push('passkey');
100
+ return offers;
101
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * pairing-offers.ts — plain-language copy for the pairing offer set, the one
3
+ * honest LAN-posture line, and the labeled browser-capability list.
4
+ *
5
+ * Each offer names, in plain terms, what accepting it does and what declining it
6
+ * costs — no security jargon, just the concrete consequence. Every offer is
7
+ * independently declinable; a pairing without any accepted offer still pairs the
8
+ * device (it just signs in with none of the extras).
9
+ *
10
+ * The one honest LAN line and the per-capability availability both come from the
11
+ * SDK posture (describeOriginPosture / the `posture` field on a handoff), so
12
+ * every surface renders the SAME truth: the exact LAN_PLAIN_HTTP_NOTICE wording
13
+ * only when the posture carries it, and each browser-gated capability labeled
14
+ * with whether the paired device gets it (and why not, when it does not).
15
+ */
16
+ import type {
17
+ BrowserGatedCapability,
18
+ OriginPosture,
19
+ PairingHandoffOfferKind,
20
+ } from '@pellux/goodvibes-sdk/platform/pairing';
21
+
22
+ export interface PairingOfferCopy {
23
+ readonly title: string;
24
+ /** One plain-language line: what accepting does, and what declining leaves you with. */
25
+ readonly consequence: string;
26
+ }
27
+
28
+ export const PAIRING_OFFER_COPY: Record<PairingHandoffOfferKind, PairingOfferCopy> = {
29
+ notifications: {
30
+ title: 'Notifications',
31
+ consequence: 'This device gets a push when an agent needs your input or a run finishes. Decline and it pairs without push.',
32
+ },
33
+ relay: {
34
+ title: 'Relay',
35
+ consequence: 'Reach this daemon from off the LAN through the rendezvous relay, which sees only ciphertext and connection metadata. Decline to stay LAN-only.',
36
+ },
37
+ passkey: {
38
+ title: 'Passkey',
39
+ consequence: 'Register a passkey on this device to confirm sensitive actions with a tap. Decline and sensitive actions ask another way.',
40
+ },
41
+ };
42
+
43
+ /** The offer set as `label — consequence` lines, in the given order. */
44
+ export function formatPairingOffers(offers: readonly PairingHandoffOfferKind[]): string[] {
45
+ return offers.map((kind) => {
46
+ const copy = PAIRING_OFFER_COPY[kind];
47
+ return ` ${copy.title} — ${copy.consequence}`;
48
+ });
49
+ }
50
+
51
+ /**
52
+ * Plain-language label for each browser-gated capability the posture reports —
53
+ * what the paired device would actually get, named without jargon.
54
+ */
55
+ export const POSTURE_CAPABILITY_LABEL: Record<BrowserGatedCapability, string> = {
56
+ 'service-worker': 'Offline app (installable / background sync)',
57
+ push: 'Push notifications',
58
+ microphone: 'Voice input (microphone)',
59
+ };
60
+
61
+ /**
62
+ * The one honest LAN line to render, or null. This is the SDK posture's own
63
+ * `notice` field verbatim (LAN_PLAIN_HTTP_NOTICE) — present ONLY for the
64
+ * plain-http-on-LAN posture, absent (and so never a nag) everywhere else.
65
+ */
66
+ export function pairingPostureNotice(posture: OriginPosture | undefined): string | null {
67
+ return posture?.notice ?? null;
68
+ }
69
+
70
+ /**
71
+ * The labeled capability list from the posture: each browser-gated capability as
72
+ * a `Label — available` / `Label — <reason>` line, so the pairing surface lists
73
+ * what the paired device will get instead of hiding a dead button. Empty when no
74
+ * posture is known.
75
+ */
76
+ export function formatPostureCapabilities(posture: OriginPosture | undefined): string[] {
77
+ if (!posture) return [];
78
+ return posture.capabilities.map((cap) => {
79
+ const label = POSTURE_CAPABILITY_LABEL[cap.capability as BrowserGatedCapability] ?? cap.capability;
80
+ const status = cap.available ? 'available' : (cap.reason ?? 'unavailable');
81
+ return ` ${label} — ${status}`;
82
+ });
83
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * pairing-origin.ts — the web-app origin a pairing deep link points at, and the
3
+ * one-time write of web.publicBaseUrl from the stable-name resolution.
4
+ *
5
+ * The origin is the WEB endpoint's public URL (the surface the `#pair=<token>`
6
+ * link opens), not the control-plane daemon URL. A user-set web.publicBaseUrl is
7
+ * authoritative and is never re-derived or clobbered; only when it is empty do
8
+ * we fall back to `http://<stable-host>:<web-port>` using the same ladder the
9
+ * printed/QR link uses, so the link and the persisted origin always agree.
10
+ */
11
+ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
12
+ import { resolveRuntimeEndpointBinding } from '../cli/endpoints.ts';
13
+ import { stableUrlHostForBindHost, type ResolvedStableHost, type StableHostInputs } from './stable-host.ts';
14
+
15
+ export interface PairingWebOrigin {
16
+ readonly origin: string;
17
+ readonly resolvedHost: ResolvedStableHost;
18
+ /** True when the origin is http:// on a non-loopback host — the honest LAN-posture case. */
19
+ readonly httpOnLan: boolean;
20
+ /** True when the origin came verbatim from a user-set web.publicBaseUrl. */
21
+ readonly fromPublicBaseUrl: boolean;
22
+ }
23
+
24
+ function trimTrailingSlash(value: string): string {
25
+ return value.replace(/\/+$/, '');
26
+ }
27
+
28
+ /** http:// on anything other than loopback is served in the clear on the LAN. */
29
+ export function isHttpOnLan(origin: string): boolean {
30
+ if (!origin.startsWith('http://')) return false;
31
+ let host: string;
32
+ try {
33
+ host = new URL(origin).hostname;
34
+ } catch {
35
+ return false;
36
+ }
37
+ return host !== '127.0.0.1' && host !== 'localhost' && host !== '::1';
38
+ }
39
+
40
+ export function resolvePairingWebOrigin(
41
+ configManager: Pick<ConfigManager, 'get'>,
42
+ probe?: () => StableHostInputs,
43
+ ): PairingWebOrigin {
44
+ const publicBaseUrl = trimTrailingSlash(String(configManager.get('web.publicBaseUrl') ?? '').trim());
45
+ if (publicBaseUrl) {
46
+ return { origin: publicBaseUrl, resolvedHost: { host: hostnameOf(publicBaseUrl), kind: 'gateway-interface', stable: true }, httpOnLan: isHttpOnLan(publicBaseUrl), fromPublicBaseUrl: true };
47
+ }
48
+ const binding = resolveRuntimeEndpointBinding(configManager, 'web');
49
+ const resolvedHost = stableUrlHostForBindHost(binding.host, probe);
50
+ const origin = `http://${resolvedHost.host}:${binding.port}`;
51
+ return { origin, resolvedHost, httpOnLan: isHttpOnLan(origin), fromPublicBaseUrl: false };
52
+ }
53
+
54
+ function hostnameOf(origin: string): string {
55
+ try {
56
+ return new URL(origin).hostname;
57
+ } catch {
58
+ return origin;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Persist web.publicBaseUrl once, from the stable-name resolution, and only when
64
+ * it is empty AND the resolution produced a stable name (a DHCP-bound address is
65
+ * not worth freezing into config). Returns the resolved origin either way. Never
66
+ * overwrites a user-set value.
67
+ */
68
+ export function ensurePublicBaseUrl(
69
+ configManager: Pick<ConfigManager, 'get' | 'setDynamic'>,
70
+ probe?: () => StableHostInputs,
71
+ ): PairingWebOrigin {
72
+ const resolved = resolvePairingWebOrigin(configManager, probe);
73
+ if (!resolved.fromPublicBaseUrl && resolved.resolvedHost.stable) {
74
+ configManager.setDynamic('web.publicBaseUrl', resolved.origin);
75
+ return { ...resolved, fromPublicBaseUrl: true };
76
+ }
77
+ return resolved;
78
+ }