@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
@@ -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 rows: ConfigModalRow[] = this.reviewRecords.map((record) => ({
166
- id: record.id,
167
- label: `${record.reviewState.padEnd(13)} ${String(record.confidence).padStart(3)}% ${record.summary}${record.staleReason ? ` (stale: ${record.staleReason})` : ''}${temporalSuffix(record)}`,
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 { encodeConnectionPayload, generateQrMatrix, renderQrToString } from '@pellux/goodvibes-sdk/platform/pairing';
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
- // QR Code → 'pairing' config-modal surface (group-B port). Connection
13
- // URL/token/username(/password) plus a scannable QR block — read/navigate only.
14
- // Token regeneration is a destructive mutation (it invalidates any live
15
- // companion session) that the panel gated behind an in-panel confirm; per
16
- // charter that confirm is never folded into a modal, so regenerate routes to a
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
- // the regenerate action now dispatches the real `/qrcode regenerate`
22
- // verb (which rotates the shared operator-token store) and, once it resolves,
23
- // RE-PULLS the lazy connection-info thunk so the modal shows the rotated token +
24
- // QR in place without being re-opened. `confirm: true` keeps the two-press
25
- // guard so a stray keystroke never rotates a live companion's token.
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
- readonly username: string;
32
- readonly password?: string;
33
- readonly version?: string;
34
- readonly surface?: string;
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 = 'Companion Pairing';
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: 'copyToken', label: 'copy token', enabledFor: () => Boolean(this.deps.copyToClipboard) },
63
- { key: 'r', id: 'regenerate', label: 'regenerate token', confirm: true },
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 { this.requestRender = requestRender; }
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: 'Companion Pairing', degraded: 'Companion pairing is unavailable — the daemon connection info could not be resolved for this session.', tabs: [{ id: 'pairing', label: 'Pairing', rows: [] }] };
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, username, password } = info;
146
+ const { url, token, tokenName, deepLink, offers, posture } = info;
81
147
  const displayToken = this.revealed ? token : SECRET_MASK;
82
- const displayPassword = password !== undefined ? (this.revealed ? password : SECRET_MASK) : undefined;
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 companion app to pair this session.', { dim: true }));
86
- rows.push(infoRow('url', `URL ${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('username', `Username ${username}`));
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', `Companions connected: ${connected}`, connected > 0 ? { fg: MODAL_TONES.good } : { dim: true }));
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 block — same payload shape as QrPanel.render(); the multi-line
96
- // preformatted string is split into one non-selectable row per block row.
97
- const payload = encodeConnectionPayload({
98
- url, token, username,
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: 'Companion Pairing',
108
- tabs: [{ id: 'pairing', label: 'Pairing', rows, hints: [this.revealed ? 'v hide token' : 'v reveal token'] }],
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 === 'copyToken') {
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.token);
118
- ctx.print('Token copied to clipboard.');
227
+ this.deps.copyToClipboard(info.deepLink);
228
+ ctx.print('Pairing link copied to clipboard.');
119
229
  return;
120
230
  }
121
- if (id === 'regenerate') {
122
- const dispatched = ctx.executeCommand?.('qrcode', ['regenerate']);
123
- if (!dispatched) {
124
- ctx.setStatus('Token rotation is unavailable in this runtime.');
125
- return;
126
- }
127
- ctx.setStatus('Rotating pairing token…');
128
- void dispatched
129
- .then((ok) => {
130
- if (ok === false) { ctx.setStatus('Token rotation did not complete.'); return; }
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 (no real
151
- * token/QR generation call — encodeConnectionPayload/generateQrMatrix are pure
152
- * functions of this literal) and a frozen control-plane snapshot.
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://192.168.1.50:3141', token: 'golden-token-0123456789abcdef', username: 'golden-user', password: 'golden-pass', version: '0.0.0', surface: 'tui',
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
  }