@pellux/goodvibes-tui 1.17.0 → 1.18.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 (84) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +1 -1
  3. package/docs/foundation-artifacts/operator-contract.json +4596 -1732
  4. package/package.json +7 -7
  5. package/src/cli/management-commands.ts +82 -15
  6. package/src/cli/management.ts +0 -2
  7. package/src/core/consolidation-receipt.ts +41 -0
  8. package/src/core/conversation-rendering.ts +14 -5
  9. package/src/core/memory-provenance.ts +67 -0
  10. package/src/core/pairing-devices.ts +49 -0
  11. package/src/core/pairing-handoff.ts +101 -0
  12. package/src/core/pairing-offers.ts +83 -0
  13. package/src/core/pairing-origin.ts +78 -0
  14. package/src/core/pairing-tailscale-gateway.ts +56 -0
  15. package/src/core/power-chip-source.ts +91 -0
  16. package/src/core/power-status.ts +95 -0
  17. package/src/core/stable-host.ts +171 -0
  18. package/src/core/stream-event-wiring.ts +21 -0
  19. package/src/core/turn-budget-outcome.ts +60 -0
  20. package/src/core/turn-cancellation.ts +39 -0
  21. package/src/core/turn-event-wiring.ts +6 -0
  22. package/src/daemon/cli.ts +47 -56
  23. package/src/input/command-registry.ts +21 -0
  24. package/src/input/commands/acp-runtime.ts +72 -0
  25. package/src/input/commands/devices-runtime.ts +109 -0
  26. package/src/input/commands/git-runtime.ts +28 -4
  27. package/src/input/commands/graph-runtime.ts +45 -0
  28. package/src/input/commands/operator-rpc.ts +32 -11
  29. package/src/input/commands/power-runtime.ts +56 -0
  30. package/src/input/commands/qrcode-runtime.ts +14 -19
  31. package/src/input/commands/queue-runtime.ts +70 -0
  32. package/src/input/commands/workstream-attempts.ts +6 -1
  33. package/src/input/commands/workstream-runtime.ts +44 -25
  34. package/src/input/commands/worktree-runtime.ts +35 -8
  35. package/src/input/commands.ts +10 -0
  36. package/src/input/config-modal-types.ts +11 -0
  37. package/src/input/config-modal.ts +65 -2
  38. package/src/input/handler-shortcuts.ts +23 -0
  39. package/src/input/keybindings.ts +19 -1
  40. package/src/input/settings-modal-data.ts +31 -37
  41. package/src/input/settings-modal-types.ts +5 -2
  42. package/src/main.ts +42 -42
  43. package/src/panels/builtin/operations.ts +54 -1
  44. package/src/panels/builtin/shared.ts +12 -0
  45. package/src/panels/builtin-modals.ts +49 -32
  46. package/src/panels/diff-panel.ts +34 -1
  47. package/src/panels/fleet-acts.ts +446 -0
  48. package/src/panels/fleet-gateway.ts +117 -0
  49. package/src/panels/fleet-observed-render.ts +128 -0
  50. package/src/panels/fleet-panel-format.ts +120 -4
  51. package/src/panels/fleet-panel-worktree-detail.ts +14 -0
  52. package/src/panels/fleet-panel.ts +60 -44
  53. package/src/panels/fleet-read-model.ts +34 -3
  54. package/src/panels/fleet-spawn.ts +266 -0
  55. package/src/panels/fleet-stop.ts +38 -2
  56. package/src/panels/fleet-tabs.ts +2 -2
  57. package/src/panels/memory-consolidation-gateway.ts +94 -0
  58. package/src/panels/modals/devices-modal.ts +161 -0
  59. package/src/panels/modals/memory-modal.ts +149 -5
  60. package/src/panels/modals/pairing-modal.ts +198 -68
  61. package/src/panels/modals/security-modal.ts +6 -0
  62. package/src/panels/workstream-graph-render.ts +123 -0
  63. package/src/renderer/composer-fragments.ts +69 -0
  64. package/src/renderer/settings-modal-helpers.ts +9 -0
  65. package/src/renderer/settings-modal.ts +4 -2
  66. package/src/renderer/shell-surface.ts +3 -0
  67. package/src/renderer/tool-call.ts +12 -6
  68. package/src/renderer/ui-factory.ts +32 -0
  69. package/src/runtime/bootstrap-command-context.ts +3 -1
  70. package/src/runtime/bootstrap-command-parts.ts +3 -1
  71. package/src/runtime/bootstrap-core.ts +6 -6
  72. package/src/runtime/bootstrap-shell.ts +7 -0
  73. package/src/runtime/durability-services.ts +36 -1
  74. package/src/runtime/fleet-services.ts +41 -0
  75. package/src/runtime/idle-power-services.ts +85 -0
  76. package/src/runtime/index.ts +2 -0
  77. package/src/runtime/interaction-seams.ts +109 -0
  78. package/src/runtime/power-keepawake-remote.ts +75 -0
  79. package/src/runtime/process-lifecycle.ts +3 -1
  80. package/src/runtime/services.ts +38 -37
  81. package/src/runtime/ui-services.ts +3 -0
  82. package/src/shell/blocking-input.ts +21 -141
  83. package/src/shell/recovery-input-helpers.ts +53 -166
  84. package/src/version.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pellux/goodvibes-tui",
3
- "version": "1.17.0",
3
+ "version": "1.18.0",
4
4
  "description": "Terminal-native GoodVibes product for coding, operations, automation, knowledge, channels, and daemon-backed control-plane workflows.",
5
5
  "type": "module",
6
6
  "main": "src/main.ts",
@@ -104,8 +104,8 @@
104
104
  "@anthropic-ai/vertex-sdk": "^0.16.0",
105
105
  "@ast-grep/napi": "^0.42.0",
106
106
  "@aws/bedrock-token-generator": "^1.1.0",
107
- "@pellux/goodvibes-sdk": "1.8.0",
108
- "@pellux/goodvibes-terminal-shell": "1.8.0",
107
+ "@pellux/goodvibes-sdk": "1.9.0",
108
+ "@pellux/goodvibes-terminal-shell": "1.9.0",
109
109
  "bash-language-server": "^5.6.0",
110
110
  "fuse.js": "^7.1.0",
111
111
  "graphql": "^16.13.2",
@@ -131,10 +131,10 @@
131
131
  "typescript": "^5.9.3"
132
132
  },
133
133
  "optionalDependencies": {
134
- "@pellux/goodvibes-tui-linux-x64": "1.17.0",
135
- "@pellux/goodvibes-tui-linux-arm64": "1.17.0",
136
- "@pellux/goodvibes-tui-darwin-x64": "1.17.0",
137
- "@pellux/goodvibes-tui-darwin-arm64": "1.17.0"
134
+ "@pellux/goodvibes-tui-linux-x64": "1.18.0",
135
+ "@pellux/goodvibes-tui-linux-arm64": "1.18.0",
136
+ "@pellux/goodvibes-tui-darwin-x64": "1.18.0",
137
+ "@pellux/goodvibes-tui-darwin-arm64": "1.18.0"
138
138
  },
139
139
  "trustedDependencies": [],
140
140
  "overrides": {
@@ -5,12 +5,16 @@ import { BUILTIN_SECRET_PROVIDER_SOURCES, describeSecretRef, isSecretRefInput, r
5
5
  import { getSubscriptionProviderConfig, listAvailableSubscriptionProviders } from '@pellux/goodvibes-sdk/platform/config';
6
6
  import { beginOpenAICodexLogin, exchangeOpenAICodexCode } from '@pellux/goodvibes-sdk/platform/config';
7
7
  import { inspectProviderAuth } from '@/runtime/index.ts';
8
- import { getOrCreateCompanionToken, buildCompanionConnectionInfo, encodeConnectionPayload, formatConnectionBlock } from '@pellux/goodvibes-sdk/platform/pairing';
9
- import { generateQrMatrix, renderQrToString } from '@pellux/goodvibes-sdk/platform/pairing';
8
+ import { generateQrMatrix, renderQrToString, PAIRING_HANDOFF_OFFER_KINDS, type PairingHandoffOfferKind } from '@pellux/goodvibes-sdk/platform/pairing';
9
+ import { ensurePublicBaseUrl } from '../core/pairing-origin.ts';
10
+ import { formatPairingOffers, formatPostureCapabilities, pairingPostureNotice } from '../core/pairing-offers.ts';
11
+ import { defaultPairingTokenName } from '../core/pairing-handoff.ts';
12
+ import { stableUrlHostForBindHost } from '../core/stable-host.ts';
10
13
  import { resolveRuntimeEndpointBinding } from './endpoints.ts';
11
14
  import { classifyBindPosture, isNetworkFacing } from './network-posture.ts';
12
15
  import type { CliCommandRuntime } from './types.ts';
13
- import { extractAuthorizationCode, formatJsonOrText, hasCommandFlag, openBrowser, probeTcp, readAuthPaths, runNonInteractiveAgent, urlHostForBindHost, withRuntimeServices, yesNo } from './management-utils.ts';
16
+ import type { RuntimeServices } from '../runtime/services.ts';
17
+ import { extractAuthorizationCode, formatJsonOrText, hasCommandFlag, openBrowser, probeTcp, readAuthPaths, readOptionValue, runNonInteractiveAgent, withRuntimeServices, yesNo } from './management-utils.ts';
14
18
 
15
19
  export async function renderSubscriptions(runtime: CliCommandRuntime): Promise<string> {
16
20
  return await withRuntimeServices(runtime, async (services) => {
@@ -368,19 +372,80 @@ export async function renderControlPlaneStatus(runtime: CliCommandRuntime): Prom
368
372
  return formatControlPlaneStatus(runtime, await buildControlPlaneStatusResult(runtime));
369
373
  }
370
374
 
375
+ interface PairingHandoffCreateResult {
376
+ readonly token: { readonly id: string; readonly name: string; readonly token: string };
377
+ readonly offers: readonly { readonly kind: PairingHandoffOfferKind }[];
378
+ readonly fragment: string;
379
+ readonly deepLink?: string | undefined;
380
+ /** The honest TLS/capability posture of the origin the QR points at (SDK-computed). */
381
+ readonly posture?: import('@pellux/goodvibes-sdk/platform/pairing').OriginPosture | undefined;
382
+ }
383
+
371
384
  export async function renderPairing(runtime: CliCommandRuntime): Promise<string> {
372
- const daemonHomeDir = join(runtime.homeDirectory, '.goodvibes', 'daemon');
373
- const tokenRecord = getOrCreateCompanionToken('tui', { daemonHomeDir });
374
- const binding = resolveRuntimeEndpointBinding(runtime.configManager, 'controlPlane');
375
- const daemonUrl = `http://${urlHostForBindHost(binding.host)}:${binding.port}`;
376
- const info = buildCompanionConnectionInfo({
377
- daemonUrl,
378
- token: tokenRecord.token,
379
- username: 'admin',
385
+ return await withRuntimeServices(runtime, async (services) => {
386
+ // Freeze a stable web origin once (never clobbering a user-set value) so the
387
+ // printed link and the daemon's own handoff origin agree and survive a DHCP
388
+ // lease change where a stable name exists. The verb re-derives the same origin
389
+ // and returns its honest posture, so this call is a persistence side effect.
390
+ ensurePublicBaseUrl(runtime.configManager);
391
+ const name = readOptionValue(runtime.cli.commandArgs, 'name') ?? defaultPairingTokenName();
392
+ // Mint through the canonical hand-off verb: it mints a fresh per-device token
393
+ // and returns the exact `#pair=<token>` deep link the web app consumes. The
394
+ // daemon filters the requested offer set down to what it can actually satisfy.
395
+ const result = (await services.gatewayMethods.invoke('pairing.handoff.create', {
396
+ body: { name, offers: [...PAIRING_HANDOFF_OFFER_KINDS] },
397
+ context: { principalId: 'admin', principalKind: 'user', admin: true },
398
+ })) as PairingHandoffCreateResult;
399
+ const link = result.deepLink ?? result.fragment;
400
+ const qr = renderQrToString(generateQrMatrix(link));
401
+ const lines: string[] = [
402
+ 'Scan to pair a device — the QR opens the web app already signed in:',
403
+ '',
404
+ ` ${link}`,
405
+ ` Token name: ${result.token.name} (rename or revoke later in /settings → security → devices)`,
406
+ ];
407
+ if (result.offers.length > 0) {
408
+ lines.push('', 'Offers carried in this pairing (each declinable in the web app):', ...formatPairingOffers(result.offers.map((o) => o.kind)));
409
+ }
410
+ // The labeled browser-capability list and the one honest LAN line both render
411
+ // from the SDK posture the verb returned — never a locally-authored string.
412
+ const capabilities = formatPostureCapabilities(result.posture);
413
+ if (capabilities.length > 0) lines.push('', 'This device will get:', ...capabilities);
414
+ const notice = pairingPostureNotice(result.posture);
415
+ if (notice) lines.push('', notice);
416
+ // Tailscale detection is read-only here (the interactive serve lives in the
417
+ // pairing modal); when detected with an https MagicDNS URL, name the encrypted
418
+ // path. Absence stays quiet.
419
+ lines.push(...(await renderPairingTailscaleLines(services)));
420
+ lines.push('', qr);
421
+ return lines.join('\n');
380
422
  });
381
- const payload = encodeConnectionPayload(info);
382
- const qr = renderQrToString(generateQrMatrix(payload));
383
- return [formatConnectionBlock(info, payload), '', qr].join('\n');
423
+ }
424
+
425
+ interface TailscaleGetResult {
426
+ readonly available: boolean;
427
+ readonly httpsUrl?: string | undefined;
428
+ readonly detail: string;
429
+ }
430
+
431
+ /**
432
+ * Read-only tailscale detection for the `gv pair` block: when tailscale is
433
+ * available with a served https MagicDNS URL, name the encrypted path (the
434
+ * interactive one-action serve lives in the pairing modal). Quiet — an empty
435
+ * array — when tailscale is absent or the probe fails.
436
+ */
437
+ async function renderPairingTailscaleLines(services: RuntimeServices): Promise<string[]> {
438
+ try {
439
+ const ts = (await services.gatewayMethods.invoke('tailscale.get', {
440
+ body: {},
441
+ context: { principalId: 'admin', principalKind: 'user', admin: true },
442
+ })) as TailscaleGetResult;
443
+ if (!ts.available) return [];
444
+ if (ts.httpsUrl) return ['', `Encrypted access (Tailscale): ${ts.httpsUrl}`];
445
+ return ['', 'Tailscale detected — run it as a serve target for encrypted https access (see the pairing modal).'];
446
+ } catch {
447
+ return [];
448
+ }
384
449
  }
385
450
 
386
451
  export async function renderRemote(runtime: CliCommandRuntime, label: 'remote' | 'bridge'): Promise<string> {
@@ -408,9 +473,11 @@ export function renderWeb(runtime: CliCommandRuntime): string {
408
473
  const binding = resolveRuntimeEndpointBinding(runtime.configManager, 'web');
409
474
  const publicBaseUrl = String(runtime.configManager.get('web.publicBaseUrl') ?? '');
410
475
  const hasEndpointOverride = runtime.cli.flags.hostname !== undefined || runtime.cli.flags.port !== undefined;
476
+ // Prefer the stable-name resolution over the raw first-non-internal IPv4 so the
477
+ // printed URL survives a DHCP lease change where a stable name exists.
411
478
  const url = !hasEndpointOverride && publicBaseUrl
412
479
  ? publicBaseUrl
413
- : `http://${urlHostForBindHost(binding.host)}:${binding.port}`;
480
+ : `http://${stableUrlHostForBindHost(binding.host).host}:${binding.port}`;
414
481
  const value = {
415
482
  enabled: runtime.configManager.get('web.enabled'),
416
483
  ...binding,
@@ -11,8 +11,6 @@ import { BUILTIN_SECRET_PROVIDER_SOURCES, describeSecretRef, isSecretRefInput, r
11
11
  import { getSubscriptionProviderConfig, listAvailableSubscriptionProviders } from '@pellux/goodvibes-sdk/platform/config';
12
12
  import { beginOpenAICodexLogin, exchangeOpenAICodexCode } from '@pellux/goodvibes-sdk/platform/config';
13
13
  import { inspectProviderAuth } from '@/runtime/index.ts';
14
- import { getOrCreateCompanionToken, buildCompanionConnectionInfo, encodeConnectionPayload, formatConnectionBlock } from '@pellux/goodvibes-sdk/platform/pairing';
15
- import { generateQrMatrix, renderQrToString } from '@pellux/goodvibes-sdk/platform/pairing';
16
14
  import type { CliCommandRuntime, GoodVibesCliParseResult } from './types.ts';
17
15
  import { formatProviderAuthRoute, summarizeProviderAuthRoutes } from './provider-auth-routes.ts';
18
16
  import { classifyProviderSetup } from './provider-classification.ts';
@@ -0,0 +1,41 @@
1
+ // ---------------------------------------------------------------------------
2
+ // consolidation-receipt.ts — the one-line notice for an idle-time memory
3
+ // consolidation run.
4
+ //
5
+ // The SDK's MemoryConsolidationScheduler runs at idle and produces a
6
+ // MemoryConsolidationRunReceipt (merged / archived / decayed / proposed). This
7
+ // formats it into the same one-line shape every other attach-time receipt
8
+ // uses, so a run that changed something surfaces on the next attach like a
9
+ // crash/update/migration receipt. A pure no-op run yields null — no noise.
10
+ //
11
+ // A run that PROPOSED something (a contradiction or cross-scope-duplicate
12
+ // awaiting human judgment — never auto-applied) names where to actually look:
13
+ // the Memory modal's Proposals tab (`/memory`, then the Proposals tab —
14
+ // memory-modal.ts's proposalsTab()), which lists each proposal's kind,
15
+ // reason, and affected record ids and jumps to them in the Review Queue.
16
+ // Without this pointer the receipt line was the only trace a proposal ever
17
+ // existed — a count with nowhere to go.
18
+ // ---------------------------------------------------------------------------
19
+
20
+ import type { MemoryConsolidationRunReceipt } from '@pellux/goodvibes-sdk/platform/state';
21
+
22
+ /**
23
+ * A one-line receipt for a consolidation run that changed something, or null
24
+ * when the run merged/archived/decayed/proposed nothing (a quiet idle pass —
25
+ * no notice, matching the check-in "stayed quiet" discipline).
26
+ */
27
+ export function formatConsolidationReceipt(receipt: MemoryConsolidationRunReceipt): string | null {
28
+ const merged = receipt.merged.length;
29
+ const archived = receipt.archived.length;
30
+ const decayed = receipt.decayed.length;
31
+ const proposed = receipt.proposed.length;
32
+ if (merged + archived + decayed + proposed === 0) return null;
33
+ const parts: string[] = [];
34
+ if (merged) parts.push(`${merged} merged`);
35
+ if (archived) parts.push(`${archived} archived`);
36
+ if (decayed) parts.push(`${decayed} decayed`);
37
+ if (proposed) parts.push(`${proposed} proposed`);
38
+ const base = `Memory consolidation: ${parts.join(', ')} (scanned ${receipt.scanned}).`;
39
+ if (proposed === 0) return base;
40
+ return `${base} Review the ${proposed} proposed change${proposed === 1 ? '' : 's'} with /memory (Proposals tab).`;
41
+ }
@@ -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,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,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
+ }