@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.
- package/CHANGELOG.md +208 -0
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +5025 -1636
- package/package.json +7 -7
- package/src/cli/endpoints.ts +50 -4
- package/src/cli/help.ts +29 -2
- package/src/cli/management-commands.ts +99 -23
- package/src/cli/management.ts +0 -2
- package/src/cli/service-posture.ts +5 -3
- package/src/cli/status.ts +27 -6
- package/src/cli/surface-command.ts +29 -15
- package/src/core/consolidation-receipt.ts +41 -0
- package/src/core/conversation-rendering.ts +14 -5
- package/src/core/memory-diagnostics-gateway.ts +99 -0
- package/src/core/memory-provenance.ts +67 -0
- package/src/core/memory-status.ts +118 -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/core/voice-provision-gateway.ts +202 -0
- package/src/core/voice-provision-status.ts +197 -0
- package/src/daemon/cli.ts +127 -60
- 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/experience-runtime.ts +25 -4
- package/src/input/commands/git-runtime.ts +28 -4
- package/src/input/commands/graph-runtime.ts +45 -0
- package/src/input/commands/health-runtime.ts +15 -1
- 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/input/tts-settings-actions.ts +27 -8
- package/src/input/voice-local-settings.ts +52 -0
- 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/code-index-services.ts +15 -1
- 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/knowledge-services.ts +94 -0
- package/src/runtime/legacy-daemon-migration.ts +38 -6
- package/src/runtime/legacy-daemon-reconcile.ts +439 -0
- package/src/runtime/memory-governance-services.ts +79 -0
- package/src/runtime/notification-dispatch.ts +28 -0
- package/src/runtime/power-keepawake-remote.ts +75 -0
- package/src/runtime/process-lifecycle.ts +3 -1
- package/src/runtime/services.ts +105 -107
- package/src/runtime/ui-services.ts +3 -0
- package/src/runtime/voice-setup-services.ts +104 -0
- package/src/shell/blocking-input.ts +21 -141
- package/src/shell/recovery-input-helpers.ts +53 -166
- package/src/version.ts +11 -2
|
@@ -7,6 +7,8 @@ import type {
|
|
|
7
7
|
ConfigModalView,
|
|
8
8
|
} from '../../input/config-modal-types.ts';
|
|
9
9
|
import { memoryRecordTemporalStatus } from '@pellux/goodvibes-sdk/platform/state';
|
|
10
|
+
import type { MemoryConsolidationGatewayResolution, MemoryConsolidationProposal } from '../memory-consolidation-gateway.ts';
|
|
11
|
+
import { classifyConsolidationFetchError } from '../memory-consolidation-gateway.ts';
|
|
10
12
|
|
|
11
13
|
// ---------------------------------------------------------------------------
|
|
12
14
|
// Memory → config-modal surface (group-B port). Two tabs: 'All Records'
|
|
@@ -63,8 +65,23 @@ export interface MemoryModalDeps {
|
|
|
63
65
|
readonly indexUnavailableReason?: string | null | undefined;
|
|
64
66
|
}>;
|
|
65
67
|
};
|
|
68
|
+
/**
|
|
69
|
+
* Resolve the memory-consolidation-receipts gateway (memory-consolidation-gateway.ts),
|
|
70
|
+
* lazily — called fresh each time the Proposals tab (re)fetches, exactly like
|
|
71
|
+
* the Fleet panel's `resolveGateway` (fleet-gateway.ts / fleet-acts.ts). Absent
|
|
72
|
+
* in a session with no daemon-backed control plane wired: the Proposals tab
|
|
73
|
+
* then renders its "unavailable" honest state without ever attempting a fetch.
|
|
74
|
+
*/
|
|
75
|
+
readonly resolveConsolidationGateway?: () => MemoryConsolidationGatewayResolution;
|
|
66
76
|
}
|
|
67
77
|
|
|
78
|
+
/** The Proposals tab's fetch state — one honest rendering per state, never a blend. */
|
|
79
|
+
type ProposalsStatus =
|
|
80
|
+
| { readonly kind: 'loading' }
|
|
81
|
+
| { readonly kind: 'unavailable'; readonly reason: string }
|
|
82
|
+
| { readonly kind: 'error'; readonly message: string }
|
|
83
|
+
| { readonly kind: 'ready'; readonly proposals: readonly MemoryConsolidationProposal[] };
|
|
84
|
+
|
|
68
85
|
/**
|
|
69
86
|
* Client-side port of the SDK's MemoryStore.reviewQueue ranking
|
|
70
87
|
* (memory-store-helpers.ts reviewQueueScore/isReviewCandidate): all four
|
|
@@ -104,11 +121,14 @@ class MemoryModalSurface implements ConfigModalSurface {
|
|
|
104
121
|
private reviewRecords: MemoryRecordLike[] = [];
|
|
105
122
|
/** Honest note on the last read: a wire failure (client mode) or the index-unavailable fallback reason — never silently dropped. */
|
|
106
123
|
private loadNote: string | null = null;
|
|
124
|
+
/** The Proposals tab's fetch state — starts 'loading' whenever a gateway is wired (see refreshProposals). */
|
|
125
|
+
private proposalsStatus: ProposalsStatus = { kind: 'loading' };
|
|
107
126
|
private requestRender: (() => void) | null = null;
|
|
108
127
|
|
|
109
128
|
constructor(private readonly deps: MemoryModalDeps) {}
|
|
110
129
|
|
|
111
130
|
private readonly reviewGate = (row: ConfigModalRow | null, tabId: string): boolean => tabId === 'review' && row !== null;
|
|
131
|
+
private readonly proposalsGate = (row: ConfigModalRow | null, tabId: string): boolean => tabId === 'proposals' && row !== null;
|
|
112
132
|
|
|
113
133
|
readonly actions = [
|
|
114
134
|
{ key: 'enter', id: 'markReviewed', label: 'reviewed', enabledFor: this.reviewGate },
|
|
@@ -117,6 +137,13 @@ class MemoryModalSurface implements ConfigModalSurface {
|
|
|
117
137
|
{ key: 'f', id: 'markFresh', label: 'fresh', enabledFor: this.reviewGate },
|
|
118
138
|
{ key: 'd', id: 'remove', label: 'delete', enabledFor: (row: ConfigModalRow | null) => row !== null },
|
|
119
139
|
{ key: 'r', id: 'refresh', label: 'refresh' },
|
|
140
|
+
// Jump from a selected proposal to one of its affected records in the
|
|
141
|
+
// Review Queue tab. A DISTINCT key from 'enter' (not shared with
|
|
142
|
+
// markReviewed): ConfigModal.resolveAction matches the FIRST action bound
|
|
143
|
+
// to a key and returns null if THAT one's enabledFor fails, rather than
|
|
144
|
+
// trying the next action bound to the same key — so gating two different
|
|
145
|
+
// 'enter' actions by tabId alone would silently break one of them.
|
|
146
|
+
{ key: 'v', id: 'jumpToReviewQueue', label: 'view in review queue', enabledFor: this.proposalsGate },
|
|
120
147
|
];
|
|
121
148
|
|
|
122
149
|
onOpen(requestRender: () => void): void {
|
|
@@ -129,6 +156,10 @@ class MemoryModalSurface implements ConfigModalSurface {
|
|
|
129
156
|
}
|
|
130
157
|
|
|
131
158
|
private async refresh(): Promise<void> {
|
|
159
|
+
await Promise.all([this.refreshRecords(), this.refreshProposals()]);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private async refreshRecords(): Promise<void> {
|
|
132
163
|
if (!this.deps.memoryRegistry) { this.allRecords = []; this.reviewRecords = []; this.loadNote = null; return; }
|
|
133
164
|
try {
|
|
134
165
|
const result = await this.deps.memoryRegistry.honestSearch({ limit: 100 });
|
|
@@ -142,10 +173,50 @@ class MemoryModalSurface implements ConfigModalSurface {
|
|
|
142
173
|
this.requestRender?.();
|
|
143
174
|
}
|
|
144
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Lazy fetch of memory.consolidation.receipts (memory-consolidation-gateway.ts)
|
|
178
|
+
* — only attempted when this surface opens or refreshes, never polled. No
|
|
179
|
+
* gateway resolver wired at all (session has no daemon-backed control
|
|
180
|
+
* plane) renders the same honest "unavailable" state as a resolved-but-
|
|
181
|
+
* refused gateway (daemon disabled / no control-plane URL) or a 501/404
|
|
182
|
+
* from an older daemon with no consolidation scheduler — three different
|
|
183
|
+
* causes, one bucket, because from the operator's seat they mean the same
|
|
184
|
+
* thing: nothing to show, and no fault of theirs.
|
|
185
|
+
*/
|
|
186
|
+
private async refreshProposals(): Promise<void> {
|
|
187
|
+
if (!this.deps.resolveConsolidationGateway) {
|
|
188
|
+
this.proposalsStatus = { kind: 'unavailable', reason: 'No consolidation gateway wired for this session.' };
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
this.proposalsStatus = { kind: 'loading' };
|
|
192
|
+
this.requestRender?.();
|
|
193
|
+
const resolution = this.deps.resolveConsolidationGateway();
|
|
194
|
+
if (!resolution.available) {
|
|
195
|
+
this.proposalsStatus = { kind: 'unavailable', reason: resolution.reason };
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
const result = await resolution.gateway.fetchReceipts();
|
|
200
|
+
this.proposalsStatus = { kind: 'ready', proposals: result.pendingProposals };
|
|
201
|
+
} catch (error) {
|
|
202
|
+
this.proposalsStatus = classifyConsolidationFetchError(error);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
145
206
|
private recordFrom(id: string): MemoryRecordLike | undefined {
|
|
146
207
|
return this.allRecords.find((r) => r.id === id) ?? this.reviewRecords.find((r) => r.id === id);
|
|
147
208
|
}
|
|
148
209
|
|
|
210
|
+
/** First pending proposal that names a given record id, keyed for the Review Queue's reason correlation (item 3). */
|
|
211
|
+
private proposalsByRecordId(): Map<string, MemoryConsolidationProposal> {
|
|
212
|
+
const map = new Map<string, MemoryConsolidationProposal>();
|
|
213
|
+
if (this.proposalsStatus.kind !== 'ready') return map;
|
|
214
|
+
for (const proposal of this.proposalsStatus.proposals) {
|
|
215
|
+
for (const id of proposal.ids) if (!map.has(id)) map.set(id, proposal);
|
|
216
|
+
}
|
|
217
|
+
return map;
|
|
218
|
+
}
|
|
219
|
+
|
|
149
220
|
private allTab(): ConfigModalTab {
|
|
150
221
|
const rows: ConfigModalRow[] = this.allRecords.map((record) => ({
|
|
151
222
|
id: record.id,
|
|
@@ -162,10 +233,25 @@ class MemoryModalSurface implements ConfigModalSurface {
|
|
|
162
233
|
}
|
|
163
234
|
|
|
164
235
|
private reviewTab(): ConfigModalTab {
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
236
|
+
const proposalByRecord = this.proposalsByRecordId();
|
|
237
|
+
const rows: ConfigModalRow[] = this.reviewRecords.map((record) => {
|
|
238
|
+
// A contradiction proposal already carries its reason on the record
|
|
239
|
+
// itself (the daemon sets reviewState 'contradicted' + staleReason); a
|
|
240
|
+
// cross-scope-duplicate proposal marks the record 'fresh' with NO
|
|
241
|
+
// staleReason, so without this correlation those rows are bare and
|
|
242
|
+
// unexplained (item 3) — fall back to the fetched proposal's own
|
|
243
|
+
// reason only when the record didn't already carry one.
|
|
244
|
+
const proposal = record.staleReason ? undefined : proposalByRecord.get(record.id);
|
|
245
|
+
const reasonSuffix = record.staleReason
|
|
246
|
+
? ` (stale: ${record.staleReason})`
|
|
247
|
+
: proposal
|
|
248
|
+
? ` (${proposal.kind}: ${proposal.reason})`
|
|
249
|
+
: '';
|
|
250
|
+
return {
|
|
251
|
+
id: record.id,
|
|
252
|
+
label: `${record.reviewState.padEnd(13)} ${String(record.confidence).padStart(3)}% ${record.summary}${reasonSuffix}${temporalSuffix(record)}`,
|
|
253
|
+
};
|
|
254
|
+
});
|
|
169
255
|
return {
|
|
170
256
|
id: 'review',
|
|
171
257
|
label: 'Review Queue',
|
|
@@ -176,6 +262,40 @@ class MemoryModalSurface implements ConfigModalSurface {
|
|
|
176
262
|
};
|
|
177
263
|
}
|
|
178
264
|
|
|
265
|
+
/**
|
|
266
|
+
* The Proposals tab — WHAT the daemon's idle-time consolidation pass
|
|
267
|
+
* proposed (contradiction / cross-scope-duplicate / stale-delete) and is
|
|
268
|
+
* holding for human judgment, read lazily over memory.consolidation.receipts
|
|
269
|
+
* (memory-consolidation-gateway.ts). Three distinct honest states render as
|
|
270
|
+
* three distinct lines — 'loading' / 'unavailable' / 'error' never collapse
|
|
271
|
+
* into a shared "nothing to show" — plus the true empty state ('ready' with
|
|
272
|
+
* zero proposals) which is a fourth, equally honest line.
|
|
273
|
+
*/
|
|
274
|
+
private proposalsTab(): ConfigModalTab {
|
|
275
|
+
const status = this.proposalsStatus;
|
|
276
|
+
const header = [`pending proposals ${status.kind === 'ready' ? status.proposals.length : 0}`];
|
|
277
|
+
if (status.kind === 'loading') {
|
|
278
|
+
return { id: 'proposals', label: 'Proposals', header, rows: [], emptyText: 'Loading pending proposals…' };
|
|
279
|
+
}
|
|
280
|
+
if (status.kind === 'unavailable') {
|
|
281
|
+
return { id: 'proposals', label: 'Proposals', header, rows: [], emptyText: `Pending proposals unavailable: ${status.reason}` };
|
|
282
|
+
}
|
|
283
|
+
if (status.kind === 'error') {
|
|
284
|
+
return { id: 'proposals', label: 'Proposals', header, rows: [], emptyText: `Could not fetch pending proposals: ${status.message}` };
|
|
285
|
+
}
|
|
286
|
+
const rows: ConfigModalRow[] = status.proposals.map((proposal, index) => ({
|
|
287
|
+
id: `proposal:${index}`,
|
|
288
|
+
label: `[${proposal.kind}] ${proposal.reason} — ${proposal.ids.length} record${proposal.ids.length === 1 ? '' : 's'}: ${proposal.ids.join(', ')}`,
|
|
289
|
+
}));
|
|
290
|
+
return {
|
|
291
|
+
id: 'proposals',
|
|
292
|
+
label: 'Proposals',
|
|
293
|
+
header,
|
|
294
|
+
rows,
|
|
295
|
+
emptyText: 'No pending proposals — nothing awaiting judgment right now.',
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
179
299
|
buildView(): ConfigModalView {
|
|
180
300
|
if (!this.deps.memoryRegistry) {
|
|
181
301
|
return {
|
|
@@ -187,12 +307,13 @@ class MemoryModalSurface implements ConfigModalSurface {
|
|
|
187
307
|
return {
|
|
188
308
|
title: 'Memory',
|
|
189
309
|
...(this.loadNote ? { degraded: this.loadNote } : {}),
|
|
190
|
-
tabs: [this.allTab(), this.reviewTab()],
|
|
310
|
+
tabs: [this.allTab(), this.reviewTab(), this.proposalsTab()],
|
|
191
311
|
};
|
|
192
312
|
}
|
|
193
313
|
|
|
194
314
|
onAction(id: string, ctx: ConfigModalActionContext): void {
|
|
195
315
|
if (id === 'refresh') { void this.refresh(); ctx.setStatus('Reloading memory records...'); return; }
|
|
316
|
+
if (id === 'jumpToReviewQueue') { this.handleJumpToReviewQueue(ctx); return; }
|
|
196
317
|
const record = ctx.row ? this.recordFrom(ctx.row.id) : undefined;
|
|
197
318
|
if (!record) return;
|
|
198
319
|
const review = (state: string, confidence: number, reason?: string): void => {
|
|
@@ -209,6 +330,29 @@ class MemoryModalSurface implements ConfigModalSurface {
|
|
|
209
330
|
case 'remove': void ctx.executeCommand?.('recall', ['remove', record.id]); ctx.setStatus(`Dispatched /recall remove ${record.id}.`); break;
|
|
210
331
|
}
|
|
211
332
|
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* The Proposals tab's "jump": move to the Review Queue tab with one of the
|
|
336
|
+
* selected proposal's affected records selected — the same host mechanism
|
|
337
|
+
* (`ctx.jumpToRow`) any future in-surface jump would use. A proposal's
|
|
338
|
+
* `ids` are not guaranteed to all be inside the review queue's top-24
|
|
339
|
+
* window, so this picks the first id that IS present there rather than
|
|
340
|
+
* assuming the first id always is; when none are, it says so honestly
|
|
341
|
+
* instead of jumping nowhere and leaving the user to wonder why.
|
|
342
|
+
*/
|
|
343
|
+
private handleJumpToReviewQueue(ctx: ConfigModalActionContext): void {
|
|
344
|
+
if (this.proposalsStatus.kind !== 'ready' || !ctx.row) return;
|
|
345
|
+
const index = Number(ctx.row.id.slice('proposal:'.length));
|
|
346
|
+
const proposal = this.proposalsStatus.proposals[index];
|
|
347
|
+
if (!proposal) return;
|
|
348
|
+
const target = proposal.ids.find((id) => this.reviewRecords.some((r) => r.id === id));
|
|
349
|
+
if (!target) {
|
|
350
|
+
ctx.setStatus(`None of this proposal's ${proposal.ids.length} record(s) are in the current review-queue window.`);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
ctx.jumpToRow?.('review', target);
|
|
354
|
+
ctx.setStatus(`Jumped to ${target} in the Review Queue.`);
|
|
355
|
+
}
|
|
212
356
|
}
|
|
213
357
|
|
|
214
358
|
export function createMemoryModalSurface(deps: MemoryModalDeps): ConfigModalSurface {
|
|
@@ -6,42 +6,83 @@ import type {
|
|
|
6
6
|
ConfigModalSurface,
|
|
7
7
|
ConfigModalView,
|
|
8
8
|
} from '../../input/config-modal-types.ts';
|
|
9
|
-
import {
|
|
9
|
+
import { describeOriginPosture, generateQrMatrix, renderQrToString, type OriginPosture, type PairingHandoffOfferKind } from '@pellux/goodvibes-sdk/platform/pairing';
|
|
10
|
+
import { formatPairingOffers, formatPostureCapabilities, pairingPostureNotice } from '../../core/pairing-offers.ts';
|
|
10
11
|
|
|
11
12
|
// ---------------------------------------------------------------------------
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// command instead. `getConnectionInfo` is a lazy thunk (loaded once on first
|
|
18
|
-
// buildView, cached) so a missing daemon home degrades honestly rather than
|
|
19
|
-
// throwing at modal-registration time.
|
|
13
|
+
// Device Pairing config-modal surface. Shows the web-app origin, the per-device
|
|
14
|
+
// token's name, a masked token/deep-link pair, the offer set with plain-language
|
|
15
|
+
// consequences, and a scannable QR of the `#pair=<token>` deep link — a camera
|
|
16
|
+
// scan opens the web app already signed in. No raw JSON connection blob is ever
|
|
17
|
+
// encoded.
|
|
20
18
|
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
19
|
+
// `getConnectionInfo` is a lazy thunk (loaded once on first buildView, cached):
|
|
20
|
+
// each modal open mints its OWN named per-device token, so a missing pairing
|
|
21
|
+
// service degrades honestly to an "unavailable" state instead of throwing at
|
|
22
|
+
// registration time. "new device token" clears the cache and re-pulls to mint a
|
|
23
|
+
// fresh token + QR in place — non-destructive now, because a freshly minted
|
|
24
|
+
// token no longer rotates a shared secret out from under a live device; old
|
|
25
|
+
// tokens stay valid until explicitly revoked in the settings device surface.
|
|
26
26
|
// ---------------------------------------------------------------------------
|
|
27
27
|
|
|
28
28
|
export interface PairingModalConnectionInfo {
|
|
29
|
+
/** The web-app origin the deep link opens (e.g. http://workshop.local:3141). */
|
|
29
30
|
readonly url: string;
|
|
31
|
+
/** The one-time pairing secret (masked unless revealed). */
|
|
30
32
|
readonly token: string;
|
|
31
|
-
|
|
32
|
-
readonly
|
|
33
|
-
|
|
34
|
-
readonly
|
|
33
|
+
/** The per-device token's editable name. */
|
|
34
|
+
readonly tokenName: string;
|
|
35
|
+
/** The `#pair=<token>` deep link the QR encodes — opens the web app signed in. */
|
|
36
|
+
readonly deepLink: string;
|
|
37
|
+
/** The offer set this pairing carries (each declinable in the web app). */
|
|
38
|
+
readonly offers: readonly PairingHandoffOfferKind[];
|
|
39
|
+
/**
|
|
40
|
+
* The honest TLS/capability posture of the web origin the deep link opens
|
|
41
|
+
* (the SDK's describeOriginPosture / the handoff's `posture` field). The one
|
|
42
|
+
* honest LAN line and the labeled capability list both render from here — never
|
|
43
|
+
* from a locally-authored string. Absent when no web origin is known.
|
|
44
|
+
*/
|
|
45
|
+
readonly posture?: OriginPosture | undefined;
|
|
35
46
|
}
|
|
36
47
|
export interface PairingModalControlPlaneSnapshot { readonly activeClientIds: readonly string[]; }
|
|
37
48
|
export interface PairingModalReadModel<T> { getSnapshot(): T; }
|
|
38
49
|
|
|
50
|
+
/** tailscale.get shape — the daemon's honest tailscale detection (quiet when absent). */
|
|
51
|
+
export interface PairingTailscaleStatus {
|
|
52
|
+
readonly available: boolean;
|
|
53
|
+
readonly loggedIn: boolean;
|
|
54
|
+
readonly magicDnsName?: string | undefined;
|
|
55
|
+
readonly httpsUrl?: string | undefined;
|
|
56
|
+
readonly detail: string;
|
|
57
|
+
readonly lastServe?: PairingTailscaleServeReceipt | undefined;
|
|
58
|
+
}
|
|
59
|
+
/** tailscale.serve.run receipt — the one-action serve result rendered verbatim. */
|
|
60
|
+
export interface PairingTailscaleServeReceipt {
|
|
61
|
+
readonly at: number;
|
|
62
|
+
readonly command: string;
|
|
63
|
+
readonly ok: boolean;
|
|
64
|
+
readonly url?: string | undefined;
|
|
65
|
+
readonly detail: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
39
68
|
export interface PairingModalDeps {
|
|
40
69
|
/** Lazy connection-info provider — returns null when the daemon/companion
|
|
41
70
|
* token cannot be resolved (honest degraded state instead of a throw). */
|
|
42
71
|
readonly getConnectionInfo: () => PairingModalConnectionInfo | null;
|
|
43
72
|
readonly controlPlaneReadModel?: PairingModalReadModel<PairingModalControlPlaneSnapshot>;
|
|
44
73
|
readonly copyToClipboard?: (text: string) => void;
|
|
74
|
+
/**
|
|
75
|
+
* Probe tailscale (tailscale.get) once on open. Absent dep, a null result, or
|
|
76
|
+
* `available:false` all keep the tailscale affordance QUIET — never a nag.
|
|
77
|
+
* Present + available ⇒ the surface offers the one-action serve.
|
|
78
|
+
*/
|
|
79
|
+
readonly probeTailscale?: () => Promise<PairingTailscaleStatus | null>;
|
|
80
|
+
/**
|
|
81
|
+
* Run tailscale.serve.run (behind the standard confirm) and return its receipt.
|
|
82
|
+
* Wired only when probeTailscale is; the resulting https MagicDNS URL and the
|
|
83
|
+
* receipt render in place.
|
|
84
|
+
*/
|
|
85
|
+
readonly runTailscaleServe?: () => Promise<PairingTailscaleServeReceipt | null>;
|
|
45
86
|
}
|
|
46
87
|
|
|
47
88
|
// Fixed-width placeholder — deliberately NOT derived from the real secret's
|
|
@@ -50,20 +91,45 @@ const SECRET_MASK = '••••••••••••';
|
|
|
50
91
|
|
|
51
92
|
class PairingModalSurface implements ConfigModalSurface {
|
|
52
93
|
readonly name = 'pairing-modal';
|
|
53
|
-
readonly title = '
|
|
94
|
+
readonly title = 'Device Pairing';
|
|
54
95
|
private info: PairingModalConnectionInfo | null | undefined = undefined;
|
|
55
96
|
private revealed = false;
|
|
56
97
|
private requestRender: () => void = () => {};
|
|
98
|
+
/** tailscale.get result: undefined = not probed yet, null = probed/absent (stays quiet). */
|
|
99
|
+
private tailscale: PairingTailscaleStatus | null | undefined = undefined;
|
|
100
|
+
/** The receipt from a completed serve run, rendered verbatim under the affordance. */
|
|
101
|
+
private serveReceipt: PairingTailscaleServeReceipt | null = null;
|
|
102
|
+
/** True while a serve run is in flight (the action is single-shot). */
|
|
103
|
+
private serving = false;
|
|
57
104
|
|
|
58
105
|
constructor(private readonly deps: PairingModalDeps) {}
|
|
59
106
|
|
|
60
107
|
readonly actions = [
|
|
61
108
|
{ key: 'v', id: 'toggleReveal', label: 'reveal token' },
|
|
62
|
-
{ key: 'c', id: '
|
|
63
|
-
{ key: 'r', id: '
|
|
109
|
+
{ key: 'c', id: 'copyLink', label: 'copy link', enabledFor: () => Boolean(this.deps.copyToClipboard) },
|
|
110
|
+
{ key: 'r', id: 'newToken', label: 'new device token' },
|
|
111
|
+
// The one-action serve affordance — offered ONLY when tailscale is detected
|
|
112
|
+
// and not already serving https; a second 't' press (the standard confirm)
|
|
113
|
+
// runs tailscale.serve.run. Absence keeps this key unadvertised and inert.
|
|
114
|
+
{
|
|
115
|
+
key: 't',
|
|
116
|
+
id: 'tailscaleServe',
|
|
117
|
+
label: 'serve over tailscale (https)',
|
|
118
|
+
confirm: true,
|
|
119
|
+
enabledFor: () => this.tailscale?.available === true && !this.serving,
|
|
120
|
+
},
|
|
64
121
|
];
|
|
65
122
|
|
|
66
|
-
onOpen(requestRender: () => void): void {
|
|
123
|
+
onOpen(requestRender: () => void): void {
|
|
124
|
+
this.requestRender = requestRender;
|
|
125
|
+
// Probe tailscale once on open; the affordance stays quiet until (and unless)
|
|
126
|
+
// this resolves to an available status. Failures degrade to quiet absence.
|
|
127
|
+
if (this.deps.probeTailscale && this.tailscale === undefined) {
|
|
128
|
+
this.deps.probeTailscale()
|
|
129
|
+
.then((status) => { this.tailscale = status ?? null; this.requestRender(); })
|
|
130
|
+
.catch(() => { this.tailscale = null; this.requestRender(); });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
67
133
|
|
|
68
134
|
private ensureInfo(): PairingModalConnectionInfo | null {
|
|
69
135
|
if (this.info === undefined) {
|
|
@@ -75,71 +141,130 @@ class PairingModalSurface implements ConfigModalSurface {
|
|
|
75
141
|
buildView(): ConfigModalView {
|
|
76
142
|
const info = this.ensureInfo();
|
|
77
143
|
if (!info) {
|
|
78
|
-
return { title: '
|
|
144
|
+
return { title: 'Device Pairing', degraded: 'Device pairing is unavailable — the pairing token service could not be resolved for this session.', tabs: [{ id: 'pairing', label: 'Pairing', rows: [] }] };
|
|
79
145
|
}
|
|
80
|
-
const { url, token,
|
|
146
|
+
const { url, token, tokenName, deepLink, offers, posture } = info;
|
|
81
147
|
const displayToken = this.revealed ? token : SECRET_MASK;
|
|
82
|
-
|
|
148
|
+
// The deep link carries the one-time token, so it is masked with the token.
|
|
149
|
+
const displayLink = this.revealed ? deepLink : SECRET_MASK;
|
|
83
150
|
|
|
84
151
|
const rows: ConfigModalRow[] = [];
|
|
85
|
-
rows.push(infoRow('intro', 'Scan with the GoodVibes
|
|
86
|
-
rows.push(infoRow('url', `
|
|
152
|
+
rows.push(infoRow('intro', 'Scan with the GoodVibes web app to pair this device — it opens already signed in.', { dim: true }));
|
|
153
|
+
rows.push(infoRow('url', `Web app ${url}`));
|
|
154
|
+
rows.push(infoRow('name', `Device ${tokenName}`));
|
|
87
155
|
rows.push(infoRow('token', `Token ${displayToken}`, { fg: MODAL_TONES.reasoning }));
|
|
88
|
-
rows.push(infoRow('
|
|
89
|
-
if (displayPassword !== undefined) rows.push(infoRow('password', `Password ${displayPassword}`));
|
|
156
|
+
rows.push(infoRow('link', `Link ${displayLink}`, { dim: true }));
|
|
90
157
|
if (this.deps.controlPlaneReadModel) {
|
|
91
158
|
const connected = this.deps.controlPlaneReadModel.getSnapshot().activeClientIds.length;
|
|
92
|
-
rows.push(infoRow('companions', `
|
|
159
|
+
rows.push(infoRow('companions', `Devices connected: ${connected}`, connected > 0 ? { fg: MODAL_TONES.good } : { dim: true }));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// The offer set carried by this pairing — each named with its plain-language
|
|
163
|
+
// consequence, each declinable in the web app.
|
|
164
|
+
if (offers.length > 0) {
|
|
165
|
+
rows.push(infoRow('offers-h', 'Offers (each declinable when you pair):', { dim: true }));
|
|
166
|
+
formatPairingOffers(offers).forEach((line, i) => rows.push(infoRow(`offer:${i}`, line)));
|
|
167
|
+
}
|
|
168
|
+
// The labeled browser-capability list — what the paired device actually gets
|
|
169
|
+
// over this origin (and, for a gated one, why not). Rendered from the posture,
|
|
170
|
+
// never hidden behind a dead button.
|
|
171
|
+
const capabilities = formatPostureCapabilities(posture);
|
|
172
|
+
if (capabilities.length > 0) {
|
|
173
|
+
rows.push(infoRow('caps-h', 'This device will get:', { dim: true }));
|
|
174
|
+
capabilities.forEach((line, i) => rows.push(infoRow(`cap:${i}`, line)));
|
|
93
175
|
}
|
|
176
|
+
// The ONE honest LAN line — the SDK posture's own notice (LAN_PLAIN_HTTP_NOTICE),
|
|
177
|
+
// present only for the plain-http-on-LAN posture, never a nag elsewhere.
|
|
178
|
+
const notice = pairingPostureNotice(posture);
|
|
179
|
+
if (notice) rows.push(infoRow('posture', notice, { fg: MODAL_TONES.reasoning }));
|
|
180
|
+
// The tailscale serve affordance — quiet unless tailscale is detected.
|
|
181
|
+
this.appendTailscaleRows(rows);
|
|
94
182
|
|
|
95
|
-
// ASCII QR
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
...(password !== undefined ? { password } : {}),
|
|
100
|
-
version: info.version ?? '0.0.0',
|
|
101
|
-
surface: info.surface ?? 'tui',
|
|
102
|
-
});
|
|
103
|
-
const qr = renderQrToString(generateQrMatrix(payload));
|
|
183
|
+
// ASCII QR of the deep link — a camera scan opens the web app already
|
|
184
|
+
// carrying the one-time token. No raw JSON blob is ever encoded. The
|
|
185
|
+
// multi-line preformatted string is split into one non-selectable row per row.
|
|
186
|
+
const qr = renderQrToString(generateQrMatrix(deepLink));
|
|
104
187
|
qr.split('\n').forEach((line, i) => rows.push(infoRow(`qr:${i}`, line, { fg: MODAL_TONES.qrDark, bg: MODAL_TONES.qrLight })));
|
|
105
188
|
|
|
189
|
+
const hints = [this.revealed ? 'v hide token' : 'v reveal token'];
|
|
190
|
+
if (this.tailscale?.available && !this.serving) hints.push('t serve over tailscale');
|
|
106
191
|
return {
|
|
107
|
-
title: '
|
|
108
|
-
tabs: [{ id: 'pairing', label: 'Pairing', rows, hints
|
|
192
|
+
title: 'Device Pairing',
|
|
193
|
+
tabs: [{ id: 'pairing', label: 'Pairing', rows, hints }],
|
|
109
194
|
};
|
|
110
195
|
}
|
|
111
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Append the tailscale section — quiet when tailscale is undetected or
|
|
199
|
+
* unavailable. When detected, the surface names the one-action serve; once a
|
|
200
|
+
* serve has produced (or tailscale already reports) an https MagicDNS URL, that
|
|
201
|
+
* URL and the serve receipt render verbatim so the encrypted path is visible.
|
|
202
|
+
*/
|
|
203
|
+
private appendTailscaleRows(rows: ConfigModalRow[]): void {
|
|
204
|
+
const ts = this.tailscale;
|
|
205
|
+
if (!ts || !ts.available) return; // absence stays quiet
|
|
206
|
+
const httpsUrl = this.serveReceipt?.url ?? ts.httpsUrl;
|
|
207
|
+
if (httpsUrl) {
|
|
208
|
+
rows.push(infoRow('ts-h', 'Encrypted access (Tailscale):', { dim: true }));
|
|
209
|
+
rows.push(infoRow('ts-url', ` ${httpsUrl}`, { fg: MODAL_TONES.good }));
|
|
210
|
+
} else {
|
|
211
|
+
rows.push(infoRow('ts-h', 'Tailscale detected — press t to serve this app over https (MagicDNS).', { fg: MODAL_TONES.reasoning }));
|
|
212
|
+
}
|
|
213
|
+
const receipt = this.serveReceipt ?? ts.lastServe;
|
|
214
|
+
if (receipt) {
|
|
215
|
+
rows.push(infoRow('ts-receipt', ` ${receipt.ok ? '✓' : '✗'} ${receipt.detail}`, receipt.ok ? { dim: true } : { fg: MODAL_TONES.warn }));
|
|
216
|
+
} else if (this.serving) {
|
|
217
|
+
rows.push(infoRow('ts-serving', ' Serving over tailscale…', { dim: true }));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
112
221
|
onAction(id: string, ctx: ConfigModalActionContext): void {
|
|
113
222
|
const info = this.ensureInfo();
|
|
114
223
|
if (id === 'toggleReveal') { this.revealed = !this.revealed; this.requestRender(); return; }
|
|
115
|
-
if (id === '
|
|
224
|
+
if (id === 'tailscaleServe') { this.runTailscaleServe(ctx); return; }
|
|
225
|
+
if (id === 'copyLink') {
|
|
116
226
|
if (!this.deps.copyToClipboard || !info) { ctx.print('Clipboard not available.'); return; }
|
|
117
|
-
this.deps.copyToClipboard(info.
|
|
118
|
-
ctx.print('
|
|
227
|
+
this.deps.copyToClipboard(info.deepLink);
|
|
228
|
+
ctx.print('Pairing link copied to clipboard.');
|
|
119
229
|
return;
|
|
120
230
|
}
|
|
121
|
-
if (id === '
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
// Re-pull the lazy connection-info thunk: /qrcode regenerate rewrote
|
|
132
|
-
// the shared token store, so a fresh getConnectionInfo() reflects the
|
|
133
|
-
// rotated token + QR. ensureInfo caches on first build — clear it.
|
|
134
|
-
this.info = undefined;
|
|
135
|
-
this.revealed = false;
|
|
136
|
-
this.ensureInfo();
|
|
137
|
-
this.requestRender();
|
|
138
|
-
ctx.setStatus('Pairing token rotated — new token and QR loaded.');
|
|
139
|
-
})
|
|
140
|
-
.catch(() => { ctx.setStatus('Token rotation failed.'); });
|
|
231
|
+
if (id === 'newToken') {
|
|
232
|
+
// Each open mints its own named token; "new device token" just re-pulls the
|
|
233
|
+
// thunk to mint a fresh one + QR in place. Non-destructive: the prior token
|
|
234
|
+
// stays valid until it is revoked in /settings → security → devices.
|
|
235
|
+
this.info = undefined;
|
|
236
|
+
this.revealed = false;
|
|
237
|
+
this.ensureInfo();
|
|
238
|
+
this.requestRender();
|
|
239
|
+
ctx.setStatus('Minted a fresh device token and QR. Revoke old ones in /settings → security → devices.');
|
|
240
|
+
return;
|
|
141
241
|
}
|
|
142
242
|
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Run the one-action tailscale serve (tailscale.serve.run) after the standard
|
|
246
|
+
* confirm has fired. The resulting https MagicDNS URL and receipt render in
|
|
247
|
+
* place; a failed run keeps its honest receipt detail rather than a dead row.
|
|
248
|
+
*/
|
|
249
|
+
private runTailscaleServe(ctx: ConfigModalActionContext): void {
|
|
250
|
+
if (!this.deps.runTailscaleServe || this.serving) return;
|
|
251
|
+
this.serving = true;
|
|
252
|
+
this.requestRender();
|
|
253
|
+
this.deps.runTailscaleServe()
|
|
254
|
+
.then((receipt) => {
|
|
255
|
+
this.serving = false;
|
|
256
|
+
this.serveReceipt = receipt;
|
|
257
|
+
if (receipt?.ok && receipt.url) ctx.setStatus(`Serving over tailscale: ${receipt.url}`);
|
|
258
|
+
else if (receipt) ctx.setStatus(`Tailscale serve did not complete: ${receipt.detail}`);
|
|
259
|
+
this.requestRender();
|
|
260
|
+
})
|
|
261
|
+
.catch((err: unknown) => {
|
|
262
|
+
this.serving = false;
|
|
263
|
+
this.serveReceipt = { at: Date.now(), command: 'tailscale serve', ok: false, detail: err instanceof Error ? err.message : String(err) };
|
|
264
|
+
ctx.setStatus('Tailscale serve failed.');
|
|
265
|
+
this.requestRender();
|
|
266
|
+
});
|
|
267
|
+
}
|
|
143
268
|
}
|
|
144
269
|
|
|
145
270
|
export function createPairingModalSurface(deps: PairingModalDeps): ConfigModalSurface {
|
|
@@ -147,13 +272,18 @@ export function createPairingModalSurface(deps: PairingModalDeps): ConfigModalSu
|
|
|
147
272
|
}
|
|
148
273
|
|
|
149
274
|
/**
|
|
150
|
-
* Deterministic golden fixture: a frozen connectionInfo literal (
|
|
151
|
-
*
|
|
152
|
-
*
|
|
275
|
+
* Deterministic golden fixture: a frozen connectionInfo literal (the QR renders
|
|
276
|
+
* purely from its deepLink via generateQrMatrix) and a frozen control-plane
|
|
277
|
+
* snapshot.
|
|
153
278
|
*/
|
|
154
279
|
export function pairingModalGoldenSurface(): ConfigModalSurface {
|
|
155
280
|
const connectionInfo: PairingModalConnectionInfo = {
|
|
156
|
-
url: 'http://
|
|
281
|
+
url: 'http://workshop.local:3141',
|
|
282
|
+
token: 'golden-token-0123456789abcdef',
|
|
283
|
+
tokenName: 'golden device',
|
|
284
|
+
deepLink: 'http://workshop.local:3141/#pair=golden-token-0123456789abcdef&offers=notifications~relay~passkey',
|
|
285
|
+
offers: ['notifications', 'relay', 'passkey'],
|
|
286
|
+
posture: describeOriginPosture('http://workshop.local:3141'),
|
|
157
287
|
};
|
|
158
288
|
return createPairingModalSurface({
|
|
159
289
|
getConnectionInfo: () => connectionInfo,
|
|
@@ -74,6 +74,7 @@ class SecurityModalSurface implements ConfigModalSurface {
|
|
|
74
74
|
readonly actions = [
|
|
75
75
|
{ key: 'f', id: 'preflight', label: 'preflight' },
|
|
76
76
|
{ key: 'i', id: 'jumpToIncident', label: 'jump to incident', enabledFor: () => Boolean(this.deps.readModel.getSnapshot().latestIncident) },
|
|
77
|
+
{ key: 'd', id: 'manageDevices', label: 'manage devices' },
|
|
77
78
|
{ key: 'r', id: 'refresh', label: 'refresh audit' },
|
|
78
79
|
];
|
|
79
80
|
|
|
@@ -170,6 +171,11 @@ class SecurityModalSurface implements ConfigModalSurface {
|
|
|
170
171
|
if (id === 'jumpToIncident' && this.deps.readModel.getSnapshot().latestIncident) {
|
|
171
172
|
void ctx.executeCommand?.('panel', ['open', 'incident']);
|
|
172
173
|
ctx.setStatus('Opened the incident surface (fleet).');
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (id === 'manageDevices') {
|
|
177
|
+
ctx.openModal?.('devices-modal');
|
|
178
|
+
ctx.setStatus('Opened paired-device management.');
|
|
173
179
|
}
|
|
174
180
|
}
|
|
175
181
|
}
|