@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.
- package/CHANGELOG.md +49 -0
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +4596 -1732
- package/package.json +7 -7
- package/src/cli/management-commands.ts +82 -15
- package/src/cli/management.ts +0 -2
- package/src/core/consolidation-receipt.ts +41 -0
- package/src/core/conversation-rendering.ts +14 -5
- package/src/core/memory-provenance.ts +67 -0
- package/src/core/pairing-devices.ts +49 -0
- package/src/core/pairing-handoff.ts +101 -0
- package/src/core/pairing-offers.ts +83 -0
- package/src/core/pairing-origin.ts +78 -0
- package/src/core/pairing-tailscale-gateway.ts +56 -0
- package/src/core/power-chip-source.ts +91 -0
- package/src/core/power-status.ts +95 -0
- package/src/core/stable-host.ts +171 -0
- package/src/core/stream-event-wiring.ts +21 -0
- package/src/core/turn-budget-outcome.ts +60 -0
- package/src/core/turn-cancellation.ts +39 -0
- package/src/core/turn-event-wiring.ts +6 -0
- package/src/daemon/cli.ts +47 -56
- package/src/input/command-registry.ts +21 -0
- package/src/input/commands/acp-runtime.ts +72 -0
- package/src/input/commands/devices-runtime.ts +109 -0
- package/src/input/commands/git-runtime.ts +28 -4
- package/src/input/commands/graph-runtime.ts +45 -0
- package/src/input/commands/operator-rpc.ts +32 -11
- package/src/input/commands/power-runtime.ts +56 -0
- package/src/input/commands/qrcode-runtime.ts +14 -19
- package/src/input/commands/queue-runtime.ts +70 -0
- package/src/input/commands/workstream-attempts.ts +6 -1
- package/src/input/commands/workstream-runtime.ts +44 -25
- package/src/input/commands/worktree-runtime.ts +35 -8
- package/src/input/commands.ts +10 -0
- package/src/input/config-modal-types.ts +11 -0
- package/src/input/config-modal.ts +65 -2
- package/src/input/handler-shortcuts.ts +23 -0
- package/src/input/keybindings.ts +19 -1
- package/src/input/settings-modal-data.ts +31 -37
- package/src/input/settings-modal-types.ts +5 -2
- package/src/main.ts +42 -42
- package/src/panels/builtin/operations.ts +54 -1
- package/src/panels/builtin/shared.ts +12 -0
- package/src/panels/builtin-modals.ts +49 -32
- package/src/panels/diff-panel.ts +34 -1
- package/src/panels/fleet-acts.ts +446 -0
- package/src/panels/fleet-gateway.ts +117 -0
- package/src/panels/fleet-observed-render.ts +128 -0
- package/src/panels/fleet-panel-format.ts +120 -4
- package/src/panels/fleet-panel-worktree-detail.ts +14 -0
- package/src/panels/fleet-panel.ts +60 -44
- package/src/panels/fleet-read-model.ts +34 -3
- package/src/panels/fleet-spawn.ts +266 -0
- package/src/panels/fleet-stop.ts +38 -2
- package/src/panels/fleet-tabs.ts +2 -2
- package/src/panels/memory-consolidation-gateway.ts +94 -0
- package/src/panels/modals/devices-modal.ts +161 -0
- package/src/panels/modals/memory-modal.ts +149 -5
- package/src/panels/modals/pairing-modal.ts +198 -68
- package/src/panels/modals/security-modal.ts +6 -0
- package/src/panels/workstream-graph-render.ts +123 -0
- package/src/renderer/composer-fragments.ts +69 -0
- package/src/renderer/settings-modal-helpers.ts +9 -0
- package/src/renderer/settings-modal.ts +4 -2
- package/src/renderer/shell-surface.ts +3 -0
- package/src/renderer/tool-call.ts +12 -6
- package/src/renderer/ui-factory.ts +32 -0
- package/src/runtime/bootstrap-command-context.ts +3 -1
- package/src/runtime/bootstrap-command-parts.ts +3 -1
- package/src/runtime/bootstrap-core.ts +6 -6
- package/src/runtime/bootstrap-shell.ts +7 -0
- package/src/runtime/durability-services.ts +36 -1
- package/src/runtime/fleet-services.ts +41 -0
- package/src/runtime/idle-power-services.ts +85 -0
- package/src/runtime/index.ts +2 -0
- package/src/runtime/interaction-seams.ts +109 -0
- package/src/runtime/power-keepawake-remote.ts +75 -0
- package/src/runtime/process-lifecycle.ts +3 -1
- package/src/runtime/services.ts +38 -37
- package/src/runtime/ui-services.ts +3 -0
- package/src/shell/blocking-input.ts +21 -141
- package/src/shell/recovery-input-helpers.ts +53 -166
- 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.
|
|
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.
|
|
108
|
-
"@pellux/goodvibes-terminal-shell": "1.
|
|
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.
|
|
135
|
-
"@pellux/goodvibes-tui-linux-arm64": "1.
|
|
136
|
-
"@pellux/goodvibes-tui-darwin-x64": "1.
|
|
137
|
-
"@pellux/goodvibes-tui-darwin-arm64": "1.
|
|
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 {
|
|
9
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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://${
|
|
480
|
+
: `http://${stableUrlHostForBindHost(binding.host).host}:${binding.port}`;
|
|
414
481
|
const value = {
|
|
415
482
|
enabled: runtime.configManager.get('web.enabled'),
|
|
416
483
|
...binding,
|
package/src/cli/management.ts
CHANGED
|
@@ -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
|
+
}
|