@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
@@ -0,0 +1,49 @@
1
+ /**
2
+ * pairing-devices.ts — shared rendering for the paired-device list.
3
+ *
4
+ * Both the /devices command family and the settings device-management modal
5
+ * render each per-device token the same way (name · created · last-seen · id),
6
+ * so the two surfaces never drift. Pure over its inputs and a `now` clock, so
7
+ * the formatting is testable without real time.
8
+ */
9
+ import type { PublicPairingToken } from '@pellux/goodvibes-sdk/platform/pairing';
10
+
11
+ /** A short, human-quotable id prefix used to target rename/revoke. */
12
+ export function shortTokenId(id: string): string {
13
+ return id.slice(0, 8);
14
+ }
15
+
16
+ /** Relative last-seen, or "never" when the device has not authenticated yet. */
17
+ export function formatLastSeen(ms: number | undefined, now: number = Date.now()): string {
18
+ if (ms === undefined) return 'never';
19
+ const delta = Math.max(0, now - ms);
20
+ if (delta < 60_000) return 'just now';
21
+ const mins = Math.floor(delta / 60_000);
22
+ if (mins < 60) return `${mins}m ago`;
23
+ const hours = Math.floor(mins / 60);
24
+ if (hours < 24) return `${hours}h ago`;
25
+ return `${Math.floor(hours / 24)}d ago`;
26
+ }
27
+
28
+ /** Creation date as YYYY-MM-DD. */
29
+ export function formatCreated(ms: number): string {
30
+ return new Date(ms).toISOString().slice(0, 10);
31
+ }
32
+
33
+ /** One device line: name · created · last-seen · short id. */
34
+ export function formatDeviceLine(token: PublicPairingToken, now: number = Date.now()): string {
35
+ return `${token.name} · created ${formatCreated(token.createdAt)} · last seen ${formatLastSeen(token.lastSeenAt, now)} · ${shortTokenId(token.id)}`;
36
+ }
37
+
38
+ /** Resolve a token by exact id or unambiguous id-prefix; returns a resolution result. */
39
+ export function resolveTokenByIdPrefix(
40
+ tokens: readonly PublicPairingToken[],
41
+ idOrPrefix: string,
42
+ ): { readonly ok: true; readonly token: PublicPairingToken } | { readonly ok: false; readonly reason: 'not-found' | 'ambiguous' } {
43
+ const exact = tokens.find((t) => t.id === idOrPrefix);
44
+ if (exact) return { ok: true, token: exact };
45
+ const matches = tokens.filter((t) => t.id.startsWith(idOrPrefix));
46
+ if (matches.length === 1) return { ok: true, token: matches[0]! };
47
+ if (matches.length === 0) return { ok: false, reason: 'not-found' };
48
+ return { ok: false, reason: 'ambiguous' };
49
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * pairing-handoff.ts — the one place a TUI pairing producer mints a link.
3
+ *
4
+ * Every producer (the `gv pair` CLI block, the pairing modal QR, the /qrcode
5
+ * flow) routes through mintPairingHandoff, which mints a fresh per-device token
6
+ * and encodes the SDK's canonical `#pair=<token>` deep link — the exact shape
7
+ * the web app consumes, byte-for-byte the same as the `pairing.handoff.create`
8
+ * gateway verb's handler (mint + buildPairingHandoffLink). No producer encodes a
9
+ * raw JSON connection blob any more: a camera scan of the QR opens the web app
10
+ * already carrying the one-time token, so the device lands signed in.
11
+ *
12
+ * The token rides in the URL fragment, never the query, so it is never sent to a
13
+ * server (no access-log / Referer exposure). The offer set rides alongside so a
14
+ * bundle-aware surface can present the notifications/relay/passkey steps.
15
+ */
16
+ import {
17
+ buildPairingHandoffFragment,
18
+ buildPairingHandoffLink,
19
+ describeOriginPosture,
20
+ type MintedPairingToken,
21
+ type OriginPosture,
22
+ type PairingHandoffOfferKind,
23
+ } from '@pellux/goodvibes-sdk/platform/pairing';
24
+
25
+ /** The token-minting surface a handoff needs — satisfied by the SDK's PairingTokenManager. */
26
+ export interface PairingTokenMinter {
27
+ mint(input: { readonly name: string }): MintedPairingToken;
28
+ }
29
+
30
+ export interface PairingHandoff {
31
+ readonly token: MintedPairingToken;
32
+ readonly offers: readonly PairingHandoffOfferKind[];
33
+ /** `#pair=<token>` (with an `offers=` key when offers are present). */
34
+ readonly fragment: string;
35
+ /** `<webOrigin>/#pair=<token>` — present only when a web origin is known. */
36
+ readonly deepLink?: string | undefined;
37
+ /**
38
+ * The honest TLS/capability posture of the web origin the deep link opens —
39
+ * the same field the `pairing.handoff.create` gateway verb carries (both
40
+ * computed by the SDK's describeOriginPosture). Present only when a web origin
41
+ * is known; a surface renders its one honest LAN line and its labeled
42
+ * capability list from here, never from a locally-authored string.
43
+ */
44
+ readonly posture?: OriginPosture | undefined;
45
+ }
46
+
47
+ export interface MintPairingHandoffInput {
48
+ readonly pairingTokens: PairingTokenMinter;
49
+ /** The device/token name shown to the user and stored on the token; editable later. */
50
+ readonly name: string;
51
+ readonly offers: readonly PairingHandoffOfferKind[];
52
+ /** The web-app origin the deep link points at; absent ⇒ fragment-only. */
53
+ readonly webOrigin?: string | undefined;
54
+ }
55
+
56
+ /**
57
+ * A default name for a freshly-minted pairing token when the user did not supply
58
+ * one. Names are shown and editable later in the device management surface, so a
59
+ * date-stamped default is enough to tell two devices apart at a glance.
60
+ */
61
+ export function defaultPairingTokenName(now: Date = new Date()): string {
62
+ const stamp = now.toISOString().slice(0, 16).replace('T', ' ');
63
+ return `paired device (${stamp})`;
64
+ }
65
+
66
+ export function mintPairingHandoff(input: MintPairingHandoffInput): PairingHandoff {
67
+ const token = input.pairingTokens.mint({ name: input.name });
68
+ const fragment = buildPairingHandoffFragment({ token: token.token, offers: input.offers });
69
+ const deepLink = input.webOrigin
70
+ ? buildPairingHandoffLink({ webOrigin: input.webOrigin, token: token.token, offers: input.offers })
71
+ : undefined;
72
+ // The posture is described from the SAME web origin the deep link opens — the
73
+ // identical computation the gateway verb performs — so a locally-minted handoff
74
+ // carries the same honest posture as one minted over the wire.
75
+ const posture = input.webOrigin ? describeOriginPosture(input.webOrigin) : undefined;
76
+ return { token, offers: input.offers, fragment, ...(deepLink ? { deepLink } : {}), ...(posture ? { posture } : {}) };
77
+ }
78
+
79
+ /** The link content a QR should encode: the full deep link when known, else the fragment. */
80
+ export function pairingQrContent(handoff: PairingHandoff): string {
81
+ return handoff.deepLink ?? handoff.fragment;
82
+ }
83
+
84
+ export interface PairingOfferAvailability {
85
+ /** Whether the rendezvous relay is configured ⇒ the relay offer is presentable. */
86
+ readonly relayEnabled: boolean;
87
+ /** Whether a step-up (passkey) ceremony is wired ⇒ the passkey offer is presentable. */
88
+ readonly stepUpAvailable: boolean;
89
+ }
90
+
91
+ /**
92
+ * The offers this daemon can satisfy right now, in canonical order. Notifications
93
+ * are always available (browser push needs no server-side prerequisite beyond
94
+ * the VAPID key the daemon mints on demand); relay and passkey are gated.
95
+ */
96
+ export function availablePairingOffers(availability: PairingOfferAvailability): PairingHandoffOfferKind[] {
97
+ const offers: PairingHandoffOfferKind[] = ['notifications'];
98
+ if (availability.relayEnabled) offers.push('relay');
99
+ if (availability.stepUpAvailable) offers.push('passkey');
100
+ return offers;
101
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * pairing-offers.ts — plain-language copy for the pairing offer set, the one
3
+ * honest LAN-posture line, and the labeled browser-capability list.
4
+ *
5
+ * Each offer names, in plain terms, what accepting it does and what declining it
6
+ * costs — no security jargon, just the concrete consequence. Every offer is
7
+ * independently declinable; a pairing without any accepted offer still pairs the
8
+ * device (it just signs in with none of the extras).
9
+ *
10
+ * The one honest LAN line and the per-capability availability both come from the
11
+ * SDK posture (describeOriginPosture / the `posture` field on a handoff), so
12
+ * every surface renders the SAME truth: the exact LAN_PLAIN_HTTP_NOTICE wording
13
+ * only when the posture carries it, and each browser-gated capability labeled
14
+ * with whether the paired device gets it (and why not, when it does not).
15
+ */
16
+ import type {
17
+ BrowserGatedCapability,
18
+ OriginPosture,
19
+ PairingHandoffOfferKind,
20
+ } from '@pellux/goodvibes-sdk/platform/pairing';
21
+
22
+ export interface PairingOfferCopy {
23
+ readonly title: string;
24
+ /** One plain-language line: what accepting does, and what declining leaves you with. */
25
+ readonly consequence: string;
26
+ }
27
+
28
+ export const PAIRING_OFFER_COPY: Record<PairingHandoffOfferKind, PairingOfferCopy> = {
29
+ notifications: {
30
+ title: 'Notifications',
31
+ consequence: 'This device gets a push when an agent needs your input or a run finishes. Decline and it pairs without push.',
32
+ },
33
+ relay: {
34
+ title: 'Relay',
35
+ consequence: 'Reach this daemon from off the LAN through the rendezvous relay, which sees only ciphertext and connection metadata. Decline to stay LAN-only.',
36
+ },
37
+ passkey: {
38
+ title: 'Passkey',
39
+ consequence: 'Register a passkey on this device to confirm sensitive actions with a tap. Decline and sensitive actions ask another way.',
40
+ },
41
+ };
42
+
43
+ /** The offer set as `label — consequence` lines, in the given order. */
44
+ export function formatPairingOffers(offers: readonly PairingHandoffOfferKind[]): string[] {
45
+ return offers.map((kind) => {
46
+ const copy = PAIRING_OFFER_COPY[kind];
47
+ return ` ${copy.title} — ${copy.consequence}`;
48
+ });
49
+ }
50
+
51
+ /**
52
+ * Plain-language label for each browser-gated capability the posture reports —
53
+ * what the paired device would actually get, named without jargon.
54
+ */
55
+ export const POSTURE_CAPABILITY_LABEL: Record<BrowserGatedCapability, string> = {
56
+ 'service-worker': 'Offline app (installable / background sync)',
57
+ push: 'Push notifications',
58
+ microphone: 'Voice input (microphone)',
59
+ };
60
+
61
+ /**
62
+ * The one honest LAN line to render, or null. This is the SDK posture's own
63
+ * `notice` field verbatim (LAN_PLAIN_HTTP_NOTICE) — present ONLY for the
64
+ * plain-http-on-LAN posture, absent (and so never a nag) everywhere else.
65
+ */
66
+ export function pairingPostureNotice(posture: OriginPosture | undefined): string | null {
67
+ return posture?.notice ?? null;
68
+ }
69
+
70
+ /**
71
+ * The labeled capability list from the posture: each browser-gated capability as
72
+ * a `Label — available` / `Label — <reason>` line, so the pairing surface lists
73
+ * what the paired device will get instead of hiding a dead button. Empty when no
74
+ * posture is known.
75
+ */
76
+ export function formatPostureCapabilities(posture: OriginPosture | undefined): string[] {
77
+ if (!posture) return [];
78
+ return posture.capabilities.map((cap) => {
79
+ const label = POSTURE_CAPABILITY_LABEL[cap.capability as BrowserGatedCapability] ?? cap.capability;
80
+ const status = cap.available ? 'available' : (cap.reason ?? 'unavailable');
81
+ return ` ${label} — ${status}`;
82
+ });
83
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * pairing-origin.ts — the web-app origin a pairing deep link points at, and the
3
+ * one-time write of web.publicBaseUrl from the stable-name resolution.
4
+ *
5
+ * The origin is the WEB endpoint's public URL (the surface the `#pair=<token>`
6
+ * link opens), not the control-plane daemon URL. A user-set web.publicBaseUrl is
7
+ * authoritative and is never re-derived or clobbered; only when it is empty do
8
+ * we fall back to `http://<stable-host>:<web-port>` using the same ladder the
9
+ * printed/QR link uses, so the link and the persisted origin always agree.
10
+ */
11
+ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
12
+ import { resolveRuntimeEndpointBinding } from '../cli/endpoints.ts';
13
+ import { stableUrlHostForBindHost, type ResolvedStableHost, type StableHostInputs } from './stable-host.ts';
14
+
15
+ export interface PairingWebOrigin {
16
+ readonly origin: string;
17
+ readonly resolvedHost: ResolvedStableHost;
18
+ /** True when the origin is http:// on a non-loopback host — the honest LAN-posture case. */
19
+ readonly httpOnLan: boolean;
20
+ /** True when the origin came verbatim from a user-set web.publicBaseUrl. */
21
+ readonly fromPublicBaseUrl: boolean;
22
+ }
23
+
24
+ function trimTrailingSlash(value: string): string {
25
+ return value.replace(/\/+$/, '');
26
+ }
27
+
28
+ /** http:// on anything other than loopback is served in the clear on the LAN. */
29
+ export function isHttpOnLan(origin: string): boolean {
30
+ if (!origin.startsWith('http://')) return false;
31
+ let host: string;
32
+ try {
33
+ host = new URL(origin).hostname;
34
+ } catch {
35
+ return false;
36
+ }
37
+ return host !== '127.0.0.1' && host !== 'localhost' && host !== '::1';
38
+ }
39
+
40
+ export function resolvePairingWebOrigin(
41
+ configManager: Pick<ConfigManager, 'get'>,
42
+ probe?: () => StableHostInputs,
43
+ ): PairingWebOrigin {
44
+ const publicBaseUrl = trimTrailingSlash(String(configManager.get('web.publicBaseUrl') ?? '').trim());
45
+ if (publicBaseUrl) {
46
+ return { origin: publicBaseUrl, resolvedHost: { host: hostnameOf(publicBaseUrl), kind: 'gateway-interface', stable: true }, httpOnLan: isHttpOnLan(publicBaseUrl), fromPublicBaseUrl: true };
47
+ }
48
+ const binding = resolveRuntimeEndpointBinding(configManager, 'web');
49
+ const resolvedHost = stableUrlHostForBindHost(binding.host, probe);
50
+ const origin = `http://${resolvedHost.host}:${binding.port}`;
51
+ return { origin, resolvedHost, httpOnLan: isHttpOnLan(origin), fromPublicBaseUrl: false };
52
+ }
53
+
54
+ function hostnameOf(origin: string): string {
55
+ try {
56
+ return new URL(origin).hostname;
57
+ } catch {
58
+ return origin;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Persist web.publicBaseUrl once, from the stable-name resolution, and only when
64
+ * it is empty AND the resolution produced a stable name (a DHCP-bound address is
65
+ * not worth freezing into config). Returns the resolved origin either way. Never
66
+ * overwrites a user-set value.
67
+ */
68
+ export function ensurePublicBaseUrl(
69
+ configManager: Pick<ConfigManager, 'get' | 'setDynamic'>,
70
+ probe?: () => StableHostInputs,
71
+ ): PairingWebOrigin {
72
+ const resolved = resolvePairingWebOrigin(configManager, probe);
73
+ if (!resolved.fromPublicBaseUrl && resolved.resolvedHost.stable) {
74
+ configManager.setDynamic('web.publicBaseUrl', resolved.origin);
75
+ return { ...resolved, fromPublicBaseUrl: true };
76
+ }
77
+ return resolved;
78
+ }
@@ -0,0 +1,56 @@
1
+ // ---------------------------------------------------------------------------
2
+ // pairing-tailscale-gateway.ts
3
+ //
4
+ // The two tailscale verbs the pairing surfaces drive — a read-only detection
5
+ // (tailscale.get) and the one-action serve (tailscale.serve.run) — over the
6
+ // same generic operator invoke path the fleet acts use (resolveOperatorRpc ->
7
+ // sdk.operator.invoke), reaching the SAME daemon the command layer does.
8
+ //
9
+ // Both thunks degrade QUIETLY: when no daemon is reachable, the probe resolves
10
+ // to null (the pairing surface renders no tailscale affordance at all) rather
11
+ // than throwing into a render path. Absence is never a nag.
12
+ // ---------------------------------------------------------------------------
13
+
14
+ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
15
+ import { resolveOperatorRpc } from '../input/commands/operator-rpc.ts';
16
+ import type {
17
+ PairingTailscaleServeReceipt,
18
+ PairingTailscaleStatus,
19
+ } from '../panels/modals/pairing-modal.ts';
20
+
21
+ export interface PairingTailscaleGatewayDeps {
22
+ readonly configManager: ConfigManager;
23
+ readonly homeDirectory: string | (() => string);
24
+ }
25
+
26
+ /**
27
+ * Probe tailscale (tailscale.get). Returns the honest status, or null when the
28
+ * daemon is unreachable / the call fails — the pairing surface then stays quiet.
29
+ */
30
+ export async function probePairingTailscale(
31
+ deps: PairingTailscaleGatewayDeps,
32
+ ): Promise<PairingTailscaleStatus | null> {
33
+ const rpc = resolveOperatorRpc(deps);
34
+ if (!rpc.available) return null;
35
+ try {
36
+ return (await rpc.sdk.operator.invoke('tailscale.get', {})) as PairingTailscaleStatus;
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Run tailscale.serve.run and return its receipt. Null when the daemon is
44
+ * unreachable; a failed serve returns a receipt with `ok:false` and an honest
45
+ * detail (never a throw the surface has to guess about).
46
+ */
47
+ export async function runPairingTailscaleServe(
48
+ deps: PairingTailscaleGatewayDeps,
49
+ ): Promise<PairingTailscaleServeReceipt | null> {
50
+ const rpc = resolveOperatorRpc(deps);
51
+ if (!rpc.available) return null;
52
+ const result = (await rpc.sdk.operator.invoke('tailscale.serve.run', {})) as {
53
+ readonly receipt: PairingTailscaleServeReceipt;
54
+ };
55
+ return result.receipt;
56
+ }
@@ -0,0 +1,91 @@
1
+ // ---------------------------------------------------------------------------
2
+ // power-chip-source.ts — the topology-aware source of truth behind the
3
+ // always-visible "sleep disabled" chip.
4
+ //
5
+ // Embedded topology: the in-process PowerManager IS the daemon's, so reading
6
+ // getState() per render is already the whole truth; the local runtime bus's
7
+ // OPS_POWER_STATE_CHANGED just triggers an immediate repaint so a
8
+ // webui-originated toggle (which lands on the same in-process manager) lights
9
+ // the chip without waiting for the next natural render.
10
+ //
11
+ // External/adopted topology: the local manager is NOT the daemon's, and the
12
+ // adopted-daemon wire is plain HTTP (no realtime event bridge), so the chip
13
+ // POLLS power.status.get on an unref'd interval and renders the DAEMON's
14
+ // state — a webui-originated toggle on the daemon lights the TUI chip within
15
+ // one poll, and a TUI toggle (forwarded over power.keepAwake.set by
16
+ // power-keepawake-remote.ts) lights the webui's the same way. While the
17
+ // daemon's state is unknown (fetch pending/unreachable) the local projection
18
+ // is served rather than a fabricated one.
19
+ // ---------------------------------------------------------------------------
20
+
21
+ import type { PowerState } from '@pellux/goodvibes-sdk/platform/power';
22
+ import { IDLE_POWER_STATE, powerSurfaceFromEvent, powerSurfaceFromState, type PowerStateChangedPayload, type PowerSurfaceState } from './power-status.ts';
23
+
24
+ export interface PowerChipSourceDeps {
25
+ /** The in-process manager (the whole truth in embedded topology). */
26
+ readonly powerManager: { getState(): PowerState };
27
+ /** Subscribe to the LOCAL bus's OPS_POWER_STATE_CHANGED envelopes; returns unsubscribe. */
28
+ readonly onPowerEvent: (cb: (payload: PowerStateChangedPayload) => void) => () => void;
29
+ /** True only in the external/adopted-daemon topology. */
30
+ readonly isExternalDaemon: () => boolean;
31
+ /** Fetch the DAEMON's power.status.get (null when unreachable). */
32
+ readonly fetchDaemonPowerState: () => Promise<PowerState | null>;
33
+ /** Repaint request when the chip's source state changes. */
34
+ readonly render: () => void;
35
+ /** External-topology poll cadence (default 5000ms; unref'd). */
36
+ readonly pollIntervalMs?: number | undefined;
37
+ readonly timers?: { setInterval: typeof setInterval; clearInterval: typeof clearInterval } | undefined;
38
+ }
39
+
40
+ export interface PowerChipSource {
41
+ /** The current chip projection — daemon-synced in external topology, local otherwise. */
42
+ get(): PowerSurfaceState;
43
+ /** Stop the poll + event subscription. */
44
+ stop(): void;
45
+ }
46
+
47
+ export function createPowerChipSource(deps: PowerChipSourceDeps): PowerChipSource {
48
+ const timers = deps.timers ?? { setInterval, clearInterval };
49
+ let daemonState: PowerSurfaceState | null = null;
50
+ let lastLocal: PowerSurfaceState = IDLE_POWER_STATE;
51
+ let polling = false;
52
+
53
+ // Local bus events: immediate repaint (embedded truth changes live).
54
+ const unsubscribe = deps.onPowerEvent((payload) => {
55
+ lastLocal = powerSurfaceFromEvent(payload);
56
+ deps.render();
57
+ });
58
+
59
+ const poll = async (): Promise<void> => {
60
+ if (!deps.isExternalDaemon()) {
61
+ if (daemonState !== null) { daemonState = null; deps.render(); }
62
+ return;
63
+ }
64
+ if (polling) return;
65
+ polling = true;
66
+ try {
67
+ const state = await deps.fetchDaemonPowerState();
68
+ const next = state ? powerSurfaceFromState(state) : null;
69
+ const changed = JSON.stringify(next) !== JSON.stringify(daemonState);
70
+ daemonState = next;
71
+ if (changed) deps.render();
72
+ } finally {
73
+ polling = false;
74
+ }
75
+ };
76
+ const interval = timers.setInterval(() => { void poll(); }, deps.pollIntervalMs ?? 5000);
77
+ (interval as { unref?: () => void }).unref?.();
78
+ void poll(); // initial sync so an adopted daemon's held keep-awake shows immediately
79
+
80
+ return {
81
+ get(): PowerSurfaceState {
82
+ if (deps.isExternalDaemon() && daemonState !== null) return daemonState;
83
+ // Local truth: prefer a live event projection when one arrived, else read the manager.
84
+ try { return powerSurfaceFromState(deps.powerManager.getState()); } catch { return lastLocal; }
85
+ },
86
+ stop(): void {
87
+ unsubscribe();
88
+ timers.clearInterval(interval);
89
+ },
90
+ };
91
+ }
@@ -0,0 +1,95 @@
1
+ // ---------------------------------------------------------------------------
2
+ // power-status.ts — the surface-facing view of host sleep ownership (power.*).
3
+ //
4
+ // The SDK's PowerManager owns the policy: a keep-awake toggle (daemon-held,
5
+ // persisted), automatic work-signal inhibition ("held because X"), and the
6
+ // honest lid-split note when idle-sleep can be blocked but lid-close suspend
7
+ // can't. This module is the read-only projection every surface renders from —
8
+ // the always-visible "sleep disabled" chip, and the ops/status detail lines —
9
+ // seeded from powerManager.getState() and kept live by OPS_POWER_STATE_CHANGED.
10
+ //
11
+ // Owner ruling: the chip IS the safety mechanism. No timers, no AC-only
12
+ // options — the keep-awake toggle is exactly one boolean.
13
+ // ---------------------------------------------------------------------------
14
+
15
+ import { LID_SWITCH_HONEST_SPLIT, type PowerState } from '@pellux/goodvibes-sdk/platform/power';
16
+
17
+ /** The always-visible chip text shown while keep-awake holds (danger-mode idiom). */
18
+ export const SLEEP_DISABLED_CHIP = 'sleep disabled';
19
+
20
+ /** The flattened power state a surface renders from. */
21
+ export interface PowerSurfaceState {
22
+ /** The owner keep-awake toggle — the "sleep disabled" chip source. */
23
+ readonly keepAwake: boolean;
24
+ /** True while any sleep inhibitor (work or keep-awake) is held. */
25
+ readonly inhibited: boolean;
26
+ /** Live reasons the WORK inhibitor is held ("held because X"). */
27
+ readonly workReasons: readonly string[];
28
+ /** The honest-split note when part of the requested coverage was refused (verbatim). */
29
+ readonly note: string | null;
30
+ }
31
+
32
+ /** The neutral state (no inhibition, nothing held) — the pre-event seed default. */
33
+ export const IDLE_POWER_STATE: PowerSurfaceState = {
34
+ keepAwake: false,
35
+ inhibited: false,
36
+ workReasons: [],
37
+ note: null,
38
+ };
39
+
40
+ /** Flatten the full PowerState (power.status.get) into the surface projection. */
41
+ export function powerSurfaceFromState(state: PowerState): PowerSurfaceState {
42
+ return {
43
+ keepAwake: state.keepAwake.enabled,
44
+ inhibited: state.keepAwake.held || state.work.held,
45
+ workReasons: state.work.reasons,
46
+ note: state.keepAwake.note,
47
+ };
48
+ }
49
+
50
+ /** The OPS_POWER_STATE_CHANGED payload shape the surface consumes. */
51
+ export interface PowerStateChangedPayload {
52
+ readonly inhibited: boolean;
53
+ readonly keepAwake: boolean;
54
+ readonly workReasons: readonly string[];
55
+ readonly note?: string | undefined;
56
+ }
57
+
58
+ /** Flatten an OPS_POWER_STATE_CHANGED event payload into the surface projection. */
59
+ export function powerSurfaceFromEvent(ev: PowerStateChangedPayload): PowerSurfaceState {
60
+ return {
61
+ keepAwake: ev.keepAwake,
62
+ inhibited: ev.inhibited,
63
+ workReasons: ev.workReasons,
64
+ note: ev.note ?? null,
65
+ };
66
+ }
67
+
68
+ /**
69
+ * The ops/status detail lines — the honest, plain-language account of sleep
70
+ * ownership. Order: the chip's meaning, then each "held because X" work reason,
71
+ * then the lid-split note VERBATIM when the SDK served it. Empty when nothing
72
+ * is held and no note applies.
73
+ */
74
+ export function powerStatusLines(state: PowerSurfaceState): string[] {
75
+ const lines: string[] = [];
76
+ if (state.keepAwake) {
77
+ lines.push(`${SLEEP_DISABLED_CHIP} — keep-awake is on (this host will not idle-sleep)`);
78
+ }
79
+ for (const reason of state.workReasons) {
80
+ lines.push(`held because ${reason}`);
81
+ }
82
+ if (state.note) {
83
+ // The SDK's honest lid-split line, rendered exactly as served.
84
+ lines.push(state.note);
85
+ }
86
+ if (lines.length === 0) {
87
+ lines.push('sleep is not being held; the host sleeps on its own schedule');
88
+ }
89
+ return lines;
90
+ }
91
+
92
+ /** True when the SDK served the honest lid-split note (idle blocked, lid-close is the OS's). */
93
+ export function hasLidSplitNote(state: PowerSurfaceState): boolean {
94
+ return state.note === LID_SWITCH_HONEST_SPLIT;
95
+ }