@pellux/goodvibes-tui 1.16.1 → 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 (257) hide show
  1. package/CHANGELOG.md +138 -9
  2. package/README.md +1 -1
  3. package/docs/foundation-artifacts/operator-contract.json +5490 -1806
  4. package/package.json +7 -7
  5. package/src/cli/completions/generate.ts +5 -4
  6. package/src/cli/config-overrides.ts +51 -11
  7. package/src/cli/doctor.ts +4 -5
  8. package/src/cli/entrypoint.ts +13 -8
  9. package/src/cli/help.ts +7 -6
  10. package/src/cli/launch-auto-update.ts +214 -0
  11. package/src/cli/management-commands.ts +82 -15
  12. package/src/cli/management.ts +0 -2
  13. package/src/cli/parser.ts +5 -0
  14. package/src/cli/status.ts +13 -0
  15. package/src/cli/surface-command.ts +5 -5
  16. package/src/cli/tui-startup.ts +60 -97
  17. package/src/cli/types.ts +2 -0
  18. package/src/config/index.ts +1 -1
  19. package/src/config/tui-extension-settings.ts +33 -0
  20. package/src/core/composer-state.ts +1 -1
  21. package/src/core/consolidation-receipt.ts +41 -0
  22. package/src/core/conversation-line-cache.ts +3 -3
  23. package/src/core/conversation-rendering.ts +18 -9
  24. package/src/core/conversation.ts +2 -2
  25. package/src/core/long-task-notifier.ts +7 -7
  26. package/src/core/memory-provenance.ts +67 -0
  27. package/src/core/pairing-devices.ts +49 -0
  28. package/src/core/pairing-handoff.ts +101 -0
  29. package/src/core/pairing-offers.ts +83 -0
  30. package/src/core/pairing-origin.ts +78 -0
  31. package/src/core/pairing-tailscale-gateway.ts +56 -0
  32. package/src/core/power-chip-source.ts +91 -0
  33. package/src/core/power-status.ts +95 -0
  34. package/src/core/stable-host.ts +171 -0
  35. package/src/core/stream-event-wiring.ts +29 -6
  36. package/src/core/system-message-noise.ts +1 -1
  37. package/src/core/system-message-router.ts +6 -6
  38. package/src/core/turn-budget-outcome.ts +60 -0
  39. package/src/core/turn-cancellation.ts +40 -1
  40. package/src/core/turn-event-wiring.ts +14 -8
  41. package/src/daemon/cli.ts +64 -58
  42. package/src/daemon/lifecycle.ts +41 -0
  43. package/src/daemon/service-commands.ts +5 -5
  44. package/src/export/cost-utils.ts +128 -6
  45. package/src/input/autocomplete.ts +2 -2
  46. package/src/input/command-registry.ts +55 -9
  47. package/src/input/commands/acp-runtime.ts +72 -0
  48. package/src/input/commands/ci-runtime.ts +5 -1
  49. package/src/input/commands/codebase-runtime.ts +6 -6
  50. package/src/input/commands/cost-runtime.ts +1 -1
  51. package/src/input/commands/devices-runtime.ts +109 -0
  52. package/src/input/commands/git-runtime.ts +28 -4
  53. package/src/input/commands/graph-runtime.ts +45 -0
  54. package/src/input/commands/health-runtime.ts +2 -2
  55. package/src/input/commands/intelligence-runtime.ts +1 -1
  56. package/src/input/commands/local-auth-runtime.ts +1 -1
  57. package/src/input/commands/local-provider-runtime.ts +49 -10
  58. package/src/input/commands/local-runtime.ts +53 -5
  59. package/src/input/commands/memory.ts +2 -2
  60. package/src/input/commands/operator-panel-runtime.ts +5 -5
  61. package/src/input/commands/operator-rpc.ts +32 -11
  62. package/src/input/commands/permissions-provenance.ts +1 -1
  63. package/src/input/commands/permissions-runtime.ts +99 -8
  64. package/src/input/commands/planning-runtime.ts +3 -3
  65. package/src/input/commands/platform-sandbox-runtime.ts +1 -1
  66. package/src/input/commands/power-runtime.ts +56 -0
  67. package/src/input/commands/provider-accounts-runtime.ts +71 -6
  68. package/src/input/commands/provider.ts +7 -5
  69. package/src/input/commands/qrcode-runtime.ts +14 -19
  70. package/src/input/commands/queue-runtime.ts +70 -0
  71. package/src/input/commands/recall-files-git.ts +13 -51
  72. package/src/input/commands/recall-files.ts +8 -11
  73. package/src/input/commands/recall-review.ts +2 -2
  74. package/src/input/commands/relay-runtime.ts +1 -1
  75. package/src/input/commands/remote-runtime.ts +1 -1
  76. package/src/input/commands/runtime-services.ts +4 -4
  77. package/src/input/commands/schedule-runtime.ts +14 -7
  78. package/src/input/commands/services-runtime.ts +1 -1
  79. package/src/input/commands/session-workflow.ts +2 -2
  80. package/src/input/commands/settings-sync-runtime.ts +2 -2
  81. package/src/input/commands/share-runtime.ts +1 -1
  82. package/src/input/commands/shell-core.ts +46 -68
  83. package/src/input/commands/subscription-runtime.ts +1 -1
  84. package/src/input/commands/update-runtime.ts +153 -96
  85. package/src/input/commands/work-plan-runtime.ts +1 -1
  86. package/src/input/commands/workstream-attempts.ts +6 -1
  87. package/src/input/commands/workstream-runtime.ts +46 -27
  88. package/src/input/commands/worktree-runtime.ts +35 -8
  89. package/src/input/commands.ts +10 -2
  90. package/src/input/config-modal-types.ts +14 -3
  91. package/src/input/config-modal.ts +75 -13
  92. package/src/input/feature-unit-layout.ts +93 -82
  93. package/src/input/feed-context-factory.ts +2 -2
  94. package/src/input/handler-command-route.ts +2 -2
  95. package/src/input/handler-content-actions.ts +1 -1
  96. package/src/input/handler-feed-routes.ts +7 -7
  97. package/src/input/handler-feed.ts +4 -4
  98. package/src/input/handler-interactions.ts +3 -2
  99. package/src/input/handler-modal-routes.ts +12 -4
  100. package/src/input/handler-modal-token-routes.ts +1 -1
  101. package/src/input/handler-onboarding-daemon-adopt.ts +2 -2
  102. package/src/input/handler-onboarding.ts +3 -2
  103. package/src/input/handler-picker-routes.ts +26 -13
  104. package/src/input/handler-shortcuts.ts +29 -6
  105. package/src/input/handler.ts +2 -2
  106. package/src/input/keybindings.ts +21 -3
  107. package/src/input/onboarding/onboarding-feature-units.ts +34 -27
  108. package/src/input/onboarding/onboarding-provider-key-fields.ts +112 -0
  109. package/src/input/onboarding/onboarding-wizard-apply.ts +22 -21
  110. package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +17 -0
  111. package/src/input/onboarding/onboarding-wizard-network-adopt.ts +2 -2
  112. package/src/input/onboarding/onboarding-wizard-steps.ts +30 -42
  113. package/src/input/panel-integration-actions.ts +2 -2
  114. package/src/input/panel-mouse-geometry.ts +1 -1
  115. package/src/input/panel-paste-flood-guard.ts +1 -1
  116. package/src/input/provider-key-intake.ts +167 -0
  117. package/src/input/sandbox-exec-config.ts +2 -2
  118. package/src/input/selection-modal.ts +1 -1
  119. package/src/input/settings-modal-activation.ts +5 -0
  120. package/src/input/settings-modal-adjustment.ts +4 -31
  121. package/src/input/settings-modal-data.ts +52 -57
  122. package/src/input/settings-modal-mutations.ts +12 -68
  123. package/src/input/settings-modal-types.ts +27 -17
  124. package/src/input/settings-modal.ts +74 -55
  125. package/src/main.ts +90 -90
  126. package/src/panels/agent-inspector-shared.ts +4 -4
  127. package/src/panels/base-panel.ts +2 -2
  128. package/src/panels/builtin/agent.ts +4 -4
  129. package/src/panels/builtin/development.ts +4 -4
  130. package/src/panels/builtin/knowledge.ts +1 -1
  131. package/src/panels/builtin/operations.ts +83 -11
  132. package/src/panels/builtin/session.ts +5 -5
  133. package/src/panels/builtin/shared.ts +16 -4
  134. package/src/panels/builtin-modals.ts +59 -42
  135. package/src/panels/builtin-panels.ts +1 -1
  136. package/src/panels/cost-tracker-panel.ts +124 -7
  137. package/src/panels/diff-panel.ts +39 -6
  138. package/src/panels/eval-registry.ts +2 -2
  139. package/src/panels/fleet-acts.ts +446 -0
  140. package/src/panels/fleet-deep-link.ts +1 -1
  141. package/src/panels/fleet-gateway.ts +117 -0
  142. package/src/panels/fleet-observed-render.ts +128 -0
  143. package/src/panels/fleet-panel-format.ts +163 -11
  144. package/src/panels/fleet-panel-worktree-detail.ts +14 -0
  145. package/src/panels/fleet-panel.ts +73 -57
  146. package/src/panels/fleet-read-model.ts +84 -14
  147. package/src/panels/fleet-spawn.ts +266 -0
  148. package/src/panels/fleet-steer.ts +2 -2
  149. package/src/panels/fleet-stop.ts +41 -5
  150. package/src/panels/fleet-tabs.ts +15 -16
  151. package/src/panels/fleet-transcript.ts +5 -5
  152. package/src/panels/git-panel.ts +2 -2
  153. package/src/panels/index.ts +4 -1
  154. package/src/panels/local-auth-panel.ts +1 -1
  155. package/src/panels/memory-consolidation-gateway.ts +94 -0
  156. package/src/panels/modals/devices-modal.ts +161 -0
  157. package/src/panels/modals/hooks-modal.ts +1 -1
  158. package/src/panels/modals/keybindings-modal.ts +2 -2
  159. package/src/panels/modals/knowledge-modal.ts +1 -1
  160. package/src/panels/modals/marketplace-modal.ts +4 -4
  161. package/src/panels/modals/memory-modal.ts +153 -9
  162. package/src/panels/modals/modal-surface-helpers.ts +1 -1
  163. package/src/panels/modals/modal-theme.ts +1 -1
  164. package/src/panels/modals/pairing-modal.ts +198 -68
  165. package/src/panels/modals/planning-modal.ts +2 -2
  166. package/src/panels/modals/plugins-modal.ts +1 -1
  167. package/src/panels/modals/policy-modal.ts +1 -1
  168. package/src/panels/modals/security-modal.ts +7 -1
  169. package/src/panels/modals/skills-modal.ts +1 -1
  170. package/src/panels/modals/work-plan-modal.ts +3 -3
  171. package/src/panels/notifications-feed.ts +127 -0
  172. package/src/panels/notifications-panel.ts +160 -0
  173. package/src/panels/panel-manager.ts +9 -9
  174. package/src/panels/plugins-panel.ts +4 -4
  175. package/src/panels/project-planning-answer-actions.ts +1 -1
  176. package/src/panels/provider-health-routes.ts +2 -2
  177. package/src/panels/skills-panel.ts +4 -5
  178. package/src/panels/token-budget-panel.ts +11 -3
  179. package/src/panels/types.ts +5 -5
  180. package/src/panels/workstream-graph-render.ts +123 -0
  181. package/src/permissions/broker-approval-card.ts +201 -0
  182. package/src/permissions/hunk-selection.ts +28 -4
  183. package/src/permissions/prompt.ts +244 -29
  184. package/src/renderer/autocomplete-overlay.ts +107 -54
  185. package/src/renderer/code-block.ts +2 -2
  186. package/src/renderer/compaction-history-modal.ts +1 -1
  187. package/src/renderer/compaction-preview.ts +1 -1
  188. package/src/renderer/compaction-quality.ts +1 -1
  189. package/src/renderer/composer-fragments.ts +69 -0
  190. package/src/renderer/compositor.ts +2 -2
  191. package/src/renderer/config-modal.ts +1 -1
  192. package/src/renderer/conversation-overlays.ts +1 -1
  193. package/src/renderer/footer-tips.ts +3 -3
  194. package/src/renderer/hint-grammar.ts +1 -1
  195. package/src/renderer/modal-factory.ts +1 -1
  196. package/src/renderer/model-picker-overlay.ts +1 -1
  197. package/src/renderer/model-workspace.ts +7 -2
  198. package/src/renderer/panel-composite.ts +2 -2
  199. package/src/renderer/panel-workspace-bar.ts +1 -1
  200. package/src/renderer/process-indicator.ts +1 -1
  201. package/src/renderer/selection-modal-overlay.ts +1 -1
  202. package/src/renderer/session-picker-modal.ts +2 -2
  203. package/src/renderer/settings-modal-helpers.ts +23 -5
  204. package/src/renderer/settings-modal.ts +129 -39
  205. package/src/renderer/shell-surface.ts +6 -0
  206. package/src/renderer/terminal-bg-probe.ts +1 -1
  207. package/src/renderer/theme.ts +3 -3
  208. package/src/renderer/tool-call.ts +13 -7
  209. package/src/renderer/tool-result-summary.ts +1 -1
  210. package/src/renderer/turn-injection.ts +7 -6
  211. package/src/renderer/ui-factory.ts +38 -1
  212. package/src/runtime/bootstrap-command-context.ts +7 -2
  213. package/src/runtime/bootstrap-command-parts.ts +9 -5
  214. package/src/runtime/bootstrap-core.ts +41 -41
  215. package/src/runtime/bootstrap-hook-bridge.ts +1 -1
  216. package/src/runtime/bootstrap-shell.ts +13 -5
  217. package/src/runtime/bootstrap.ts +34 -32
  218. package/src/runtime/channel-composition.ts +46 -0
  219. package/src/runtime/code-index-services.ts +3 -4
  220. package/src/runtime/daemon-attach-notices.ts +120 -0
  221. package/src/runtime/durability-services.ts +90 -0
  222. package/src/runtime/embedded-service-factories.ts +40 -0
  223. package/src/runtime/feature-settings.ts +147 -0
  224. package/src/runtime/fleet-services.ts +41 -0
  225. package/src/runtime/idle-power-services.ts +85 -0
  226. package/src/runtime/index.ts +6 -21
  227. package/src/runtime/interaction-seams.ts +109 -0
  228. package/src/runtime/legacy-daemon-migration.ts +29 -19
  229. package/src/runtime/memory-fold.ts +1 -1
  230. package/src/runtime/notification-dispatch.ts +110 -0
  231. package/src/runtime/onboarding/apply.ts +3 -29
  232. package/src/runtime/onboarding/fast-path.ts +39 -6
  233. package/src/runtime/onboarding/provider-key-capture.ts +126 -0
  234. package/src/runtime/onboarding/snapshot.ts +0 -1
  235. package/src/runtime/onboarding/types.ts +9 -3
  236. package/src/runtime/power-keepawake-remote.ts +75 -0
  237. package/src/runtime/process-lifecycle.ts +3 -1
  238. package/src/runtime/provider-fallback.ts +55 -0
  239. package/src/runtime/provider-optimizer-wiring.ts +55 -0
  240. package/src/runtime/release-artifacts.ts +19 -103
  241. package/src/runtime/resume-notice.ts +1 -1
  242. package/src/runtime/services.ts +113 -113
  243. package/src/runtime/session-continuity-hints.ts +38 -0
  244. package/src/runtime/surface-feature-flags.ts +40 -9
  245. package/src/runtime/terminal-output-guard.ts +2 -2
  246. package/src/runtime/trust/workspace-registration.ts +10 -2
  247. package/src/runtime/trust/workspace-trust.ts +58 -31
  248. package/src/runtime/ui-services.ts +6 -1
  249. package/src/runtime/update-check.ts +15 -69
  250. package/src/runtime/workstream-services.ts +2 -2
  251. package/src/shell/blocking-input.ts +108 -132
  252. package/src/shell/recovery-input-helpers.ts +53 -166
  253. package/src/shell/ui-openers.ts +38 -6
  254. package/src/utils/format-duration.ts +1 -1
  255. package/src/verification/verification-ledger.ts +21 -4
  256. package/src/version.ts +1 -1
  257. package/src/input/commands/flags-runtime.ts +0 -328
@@ -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 (W6.1 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
- // DEBT-3: 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,
@@ -18,12 +18,12 @@ import type {
18
18
  import { buildAnswerActions, isGenericRecommendation, type PlanningAnswerAction } from '../project-planning-answer-actions.ts';
19
19
 
20
20
  // ---------------------------------------------------------------------------
21
- // Project Planning → 'planning' config-modal surface (W6.1 group-B port). Shows
21
+ // Project Planning → 'planning' config-modal surface (group-B port). Shows
22
22
  // readiness/questions/decisions/task-graph/handoff — read-only except choosing
23
23
  // an answer to the current open question, approving execution, dismissing the
24
24
  // plan, or refreshing.
25
25
  //
26
- // DEBT-3 — the seams are now real:
26
+ // — the seams are now real:
27
27
  // - A CANNED answer to a real open question dispatches `/plan answer <id> <text>`
28
28
  // (records it; the open-question gap clears on the next refine).
29
29
  // - The CUSTOM free-form typed answer (and any answer to a synthetic readiness
@@ -13,7 +13,7 @@ import type {
13
13
  } from '@pellux/goodvibes-sdk/platform/plugins';
14
14
 
15
15
  // ---------------------------------------------------------------------------
16
- // Plugins → config-modal surface (W6.1 group-B port). Mirrors the retired
16
+ // Plugins → config-modal surface (group-B port). Mirrors the retired
17
17
  // PluginsPanel's read surface (list/capabilities/getTrustRecord/
18
18
  // getQuarantineRecord) plus `verify` (a pure read-only signature check).
19
19
  // enable/disable/liftQuarantine are DROPPED from the dep shape on purpose:
@@ -18,7 +18,7 @@ import type {
18
18
  } from '../../input/config-modal-types.ts';
19
19
 
20
20
  // ---------------------------------------------------------------------------
21
- // Policy → config-modal surface (W6.1 group-B port). Governance CONTENT view,
21
+ // Policy → config-modal surface (group-B port). Governance CONTENT view,
22
22
  // not a selectable list — current/candidate bundle state, diff, divergence
23
23
  // gate, history, permission audit, lint findings, preflight review, and
24
24
  // simulation samples render as non-selectable rows (the host windows/scrolls
@@ -12,7 +12,7 @@ import type {
12
12
  } from '../../input/config-modal-types.ts';
13
13
 
14
14
  // ---------------------------------------------------------------------------
15
- // Security → config-modal surface (W6.1 group-B port). Two tabs: 'Tokens' (the
15
+ // Security → config-modal surface (group-B port). Two tabs: 'Tokens' (the
16
16
  // token scope/rotation audit list under the governance summary) and
17
17
  // 'Governance' (policy preflight, MCP/plugin quarantine, latest incident, and
18
18
  // the MCP attack-path review). Read-model-backed, so refresh() is a no-op —
@@ -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
  }
@@ -8,7 +8,7 @@ import type { SkillOrigin, SkillRecord } from '../skills-panel.ts';
8
8
  import { listInstalledEcosystemEntries, type EcosystemCatalogPathOptions, type ShellPathService } from '@/runtime/index.ts';
9
9
 
10
10
  // ---------------------------------------------------------------------------
11
- // Skills → config-modal surface (W6.1 group-B port). Project-local and global
11
+ // Skills → config-modal surface (group-B port). Project-local and global
12
12
  // skill-pack discovery. Disk scans happen only in refresh() (never in
13
13
  // buildView), mirroring the panel's onActivate()-triggered load. Browse-only:
14
14
  // the panel never had a command-routable mutation verb (no /skills delete/remove
@@ -7,7 +7,7 @@ import type {
7
7
  import type { WorkPlanItemStatus } from '../../work-plans/work-plan-store.ts';
8
8
 
9
9
  // ---------------------------------------------------------------------------
10
- // Work Plan → config-modal surface (W6.1 group-B port). Lists the active
10
+ // Work Plan → config-modal surface (group-B port). Lists the active
11
11
  // persistent plan's checklist items with a progress summary + status counts.
12
12
  // All mutations (add/edit item, cycle/set status, remove, clear-completed)
13
13
  // route to the existing `/work-plan` command path (charter: no
@@ -65,8 +65,8 @@ class WorkPlanModalSurface implements ConfigModalSurface {
65
65
  private readonly hasRow = (row: ConfigModalRow | null): boolean => row !== null;
66
66
 
67
67
  /** 'i'/'w' jump to the selected item's linked agent/WRFC chain in Fleet
68
- * (DEBT-5 item 4 — restores the retired WorkPlanPanel's per-item deep-link,
69
- * lost when W6.1 migrated this surface to a modal without it). Gated on
68
+ * (item 4 — restores the retired WorkPlanPanel's per-item deep-link,
69
+ * lost when migrated this surface to a modal without it). Gated on
70
70
  * the link actually being present, same as the retired panel gated the key
71
71
  * on `item?.linked?.agentId`/`wrfcId` before ever reaching the jump. */
72
72
  private readonly hasAgentLink = (row: ConfigModalRow | null): boolean =>
@@ -0,0 +1,127 @@
1
+ /**
2
+ * PanelNotificationFeed — the read-model behind the `panel_only` notification
3
+ * target.
4
+ *
5
+ * The SDK's NotificationRouter (`@/runtime/index.ts` -> platform runtime `ui`
6
+ * barrel) can route a notification's `RoutingDecision.target` to
7
+ * `'panel_only'`, meaning: don't surface this in the conversation or the
8
+ * status bar, but it must still reach a real, visible place. Before this
9
+ * file, nothing in this repo consumed that target outside of tests — a
10
+ * panel-routed notification (including every burst- or batch-collapsed one)
11
+ * had nowhere to go. This feed is that place: a caller holding a routed
12
+ * `Notification` + `RoutingDecision` pair calls `record()`, and
13
+ * `notifications-panel.ts` renders whatever has accumulated.
14
+ *
15
+ * Collapsed groups (reasonCode `burst_collapsed` / `batch_window_collapsed`)
16
+ * are tracked by their `batchKey` and accumulate an honest running count
17
+ * instead of rendering one row per suppressed duplicate.
18
+ *
19
+ * Production wiring — something in the running app actually calling
20
+ * `record()` with live notifications — rides the SDK round that turns on
21
+ * adaptive notification suppression by default; this feed and its panel are
22
+ * the render target that work lands on, built ahead of it so the target is
23
+ * never missing when that switch flips.
24
+ */
25
+ import type { Notification, RoutingDecision } from '@/runtime/index.ts';
26
+
27
+ export interface PanelFeedEntry {
28
+ readonly key: string;
29
+ readonly domain: string;
30
+ readonly level: Notification['level'];
31
+ readonly title: string;
32
+ readonly body: string | undefined;
33
+ /** Unix ms of the most recent notification folded into this entry. */
34
+ readonly timestamp: number;
35
+ readonly reasonCode: RoutingDecision['reasonCode'];
36
+ /** How many notifications this entry represents. 1 for a standalone item; >1 for a collapsed group. Always the true count — never estimated. */
37
+ readonly collapsedCount: number;
38
+ }
39
+
40
+ const MAX_ENTRIES = 200;
41
+
42
+ /** Reason codes whose notifications fold into one running-count entry per batch key, rather than one entry per occurrence. */
43
+ const COLLAPSING_REASON_CODES: ReadonlySet<RoutingDecision['reasonCode']> = new Set([
44
+ 'burst_collapsed',
45
+ 'batch_window_collapsed',
46
+ ]);
47
+
48
+ export class PanelNotificationFeed {
49
+ private readonly entries = new Map<string, PanelFeedEntry>();
50
+ /** Insertion order of `entries` keys (oldest first), for bounded eviction. */
51
+ private order: string[] = [];
52
+ private readonly listeners = new Set<() => void>();
53
+
54
+ /**
55
+ * Record a routed notification. Only notifications actually targeted at
56
+ * `panel_only` are kept — a caller passing anything else is a mistake (the
57
+ * router sent it elsewhere), so it's dropped rather than shown in the
58
+ * wrong place.
59
+ */
60
+ record(notification: Notification, decision: RoutingDecision): void {
61
+ if (decision.target !== 'panel_only') return;
62
+
63
+ const collapsing = COLLAPSING_REASON_CODES.has(decision.reasonCode) && Boolean(decision.batchKey);
64
+ const key = collapsing ? `group:${decision.batchKey}` : `single:${notification.id}`;
65
+ const previousCount = collapsing ? (this.entries.get(key)?.collapsedCount ?? 0) : 0;
66
+
67
+ const entry: PanelFeedEntry = {
68
+ key,
69
+ domain: notification.domain,
70
+ level: notification.level,
71
+ title: notification.title,
72
+ body: notification.body,
73
+ timestamp: notification.timestamp,
74
+ reasonCode: decision.reasonCode,
75
+ collapsedCount: previousCount + 1,
76
+ };
77
+
78
+ if (!this.entries.has(key)) {
79
+ this.order.push(key);
80
+ if (this.order.length > MAX_ENTRIES) {
81
+ const evicted = this.order.shift();
82
+ if (evicted !== undefined) this.entries.delete(evicted);
83
+ }
84
+ }
85
+ this.entries.set(key, entry);
86
+ this.emitChange();
87
+ }
88
+
89
+ /** Every entry, most recently updated first. */
90
+ list(): readonly PanelFeedEntry[] {
91
+ const items: PanelFeedEntry[] = [];
92
+ for (const key of this.order) {
93
+ const entry = this.entries.get(key);
94
+ if (entry) items.push(entry);
95
+ }
96
+ return items.reverse();
97
+ }
98
+
99
+ clear(): void {
100
+ this.entries.clear();
101
+ this.order = [];
102
+ this.emitChange();
103
+ }
104
+
105
+ /** Subscribe to feed changes (e.g. to mark a panel dirty). Returns an unsubscribe function. */
106
+ subscribe(listener: () => void): () => void {
107
+ this.listeners.add(listener);
108
+ return () => this.listeners.delete(listener);
109
+ }
110
+
111
+ private emitChange(): void {
112
+ for (const listener of this.listeners) listener();
113
+ }
114
+ }
115
+
116
+ let sharedFeed: PanelNotificationFeed | null = null;
117
+
118
+ /**
119
+ * The process-wide `panel_only` feed. Lazily created so the running app has
120
+ * exactly one feed that every future caller and the NotificationsPanel agree
121
+ * on, while tests construct their own isolated `PanelNotificationFeed`
122
+ * instance instead of reaching for this one.
123
+ */
124
+ export function getSharedNotificationFeed(): PanelNotificationFeed {
125
+ if (!sharedFeed) sharedFeed = new PanelNotificationFeed();
126
+ return sharedFeed;
127
+ }