@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.
- package/CHANGELOG.md +138 -9
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +5490 -1806
- package/package.json +7 -7
- package/src/cli/completions/generate.ts +5 -4
- package/src/cli/config-overrides.ts +51 -11
- package/src/cli/doctor.ts +4 -5
- package/src/cli/entrypoint.ts +13 -8
- package/src/cli/help.ts +7 -6
- package/src/cli/launch-auto-update.ts +214 -0
- package/src/cli/management-commands.ts +82 -15
- package/src/cli/management.ts +0 -2
- package/src/cli/parser.ts +5 -0
- package/src/cli/status.ts +13 -0
- package/src/cli/surface-command.ts +5 -5
- package/src/cli/tui-startup.ts +60 -97
- package/src/cli/types.ts +2 -0
- package/src/config/index.ts +1 -1
- package/src/config/tui-extension-settings.ts +33 -0
- package/src/core/composer-state.ts +1 -1
- package/src/core/consolidation-receipt.ts +41 -0
- package/src/core/conversation-line-cache.ts +3 -3
- package/src/core/conversation-rendering.ts +18 -9
- package/src/core/conversation.ts +2 -2
- package/src/core/long-task-notifier.ts +7 -7
- package/src/core/memory-provenance.ts +67 -0
- package/src/core/pairing-devices.ts +49 -0
- package/src/core/pairing-handoff.ts +101 -0
- package/src/core/pairing-offers.ts +83 -0
- package/src/core/pairing-origin.ts +78 -0
- package/src/core/pairing-tailscale-gateway.ts +56 -0
- package/src/core/power-chip-source.ts +91 -0
- package/src/core/power-status.ts +95 -0
- package/src/core/stable-host.ts +171 -0
- package/src/core/stream-event-wiring.ts +29 -6
- package/src/core/system-message-noise.ts +1 -1
- package/src/core/system-message-router.ts +6 -6
- package/src/core/turn-budget-outcome.ts +60 -0
- package/src/core/turn-cancellation.ts +40 -1
- package/src/core/turn-event-wiring.ts +14 -8
- package/src/daemon/cli.ts +64 -58
- package/src/daemon/lifecycle.ts +41 -0
- package/src/daemon/service-commands.ts +5 -5
- package/src/export/cost-utils.ts +128 -6
- package/src/input/autocomplete.ts +2 -2
- package/src/input/command-registry.ts +55 -9
- package/src/input/commands/acp-runtime.ts +72 -0
- package/src/input/commands/ci-runtime.ts +5 -1
- package/src/input/commands/codebase-runtime.ts +6 -6
- package/src/input/commands/cost-runtime.ts +1 -1
- package/src/input/commands/devices-runtime.ts +109 -0
- package/src/input/commands/git-runtime.ts +28 -4
- package/src/input/commands/graph-runtime.ts +45 -0
- package/src/input/commands/health-runtime.ts +2 -2
- package/src/input/commands/intelligence-runtime.ts +1 -1
- package/src/input/commands/local-auth-runtime.ts +1 -1
- package/src/input/commands/local-provider-runtime.ts +49 -10
- package/src/input/commands/local-runtime.ts +53 -5
- package/src/input/commands/memory.ts +2 -2
- package/src/input/commands/operator-panel-runtime.ts +5 -5
- package/src/input/commands/operator-rpc.ts +32 -11
- package/src/input/commands/permissions-provenance.ts +1 -1
- package/src/input/commands/permissions-runtime.ts +99 -8
- package/src/input/commands/planning-runtime.ts +3 -3
- package/src/input/commands/platform-sandbox-runtime.ts +1 -1
- package/src/input/commands/power-runtime.ts +56 -0
- package/src/input/commands/provider-accounts-runtime.ts +71 -6
- package/src/input/commands/provider.ts +7 -5
- package/src/input/commands/qrcode-runtime.ts +14 -19
- package/src/input/commands/queue-runtime.ts +70 -0
- package/src/input/commands/recall-files-git.ts +13 -51
- package/src/input/commands/recall-files.ts +8 -11
- package/src/input/commands/recall-review.ts +2 -2
- package/src/input/commands/relay-runtime.ts +1 -1
- package/src/input/commands/remote-runtime.ts +1 -1
- package/src/input/commands/runtime-services.ts +4 -4
- package/src/input/commands/schedule-runtime.ts +14 -7
- package/src/input/commands/services-runtime.ts +1 -1
- package/src/input/commands/session-workflow.ts +2 -2
- package/src/input/commands/settings-sync-runtime.ts +2 -2
- package/src/input/commands/share-runtime.ts +1 -1
- package/src/input/commands/shell-core.ts +46 -68
- package/src/input/commands/subscription-runtime.ts +1 -1
- package/src/input/commands/update-runtime.ts +153 -96
- package/src/input/commands/work-plan-runtime.ts +1 -1
- package/src/input/commands/workstream-attempts.ts +6 -1
- package/src/input/commands/workstream-runtime.ts +46 -27
- package/src/input/commands/worktree-runtime.ts +35 -8
- package/src/input/commands.ts +10 -2
- package/src/input/config-modal-types.ts +14 -3
- package/src/input/config-modal.ts +75 -13
- package/src/input/feature-unit-layout.ts +93 -82
- package/src/input/feed-context-factory.ts +2 -2
- package/src/input/handler-command-route.ts +2 -2
- package/src/input/handler-content-actions.ts +1 -1
- package/src/input/handler-feed-routes.ts +7 -7
- package/src/input/handler-feed.ts +4 -4
- package/src/input/handler-interactions.ts +3 -2
- package/src/input/handler-modal-routes.ts +12 -4
- package/src/input/handler-modal-token-routes.ts +1 -1
- package/src/input/handler-onboarding-daemon-adopt.ts +2 -2
- package/src/input/handler-onboarding.ts +3 -2
- package/src/input/handler-picker-routes.ts +26 -13
- package/src/input/handler-shortcuts.ts +29 -6
- package/src/input/handler.ts +2 -2
- package/src/input/keybindings.ts +21 -3
- package/src/input/onboarding/onboarding-feature-units.ts +34 -27
- package/src/input/onboarding/onboarding-provider-key-fields.ts +112 -0
- package/src/input/onboarding/onboarding-wizard-apply.ts +22 -21
- package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +17 -0
- package/src/input/onboarding/onboarding-wizard-network-adopt.ts +2 -2
- package/src/input/onboarding/onboarding-wizard-steps.ts +30 -42
- package/src/input/panel-integration-actions.ts +2 -2
- package/src/input/panel-mouse-geometry.ts +1 -1
- package/src/input/panel-paste-flood-guard.ts +1 -1
- package/src/input/provider-key-intake.ts +167 -0
- package/src/input/sandbox-exec-config.ts +2 -2
- package/src/input/selection-modal.ts +1 -1
- package/src/input/settings-modal-activation.ts +5 -0
- package/src/input/settings-modal-adjustment.ts +4 -31
- package/src/input/settings-modal-data.ts +52 -57
- package/src/input/settings-modal-mutations.ts +12 -68
- package/src/input/settings-modal-types.ts +27 -17
- package/src/input/settings-modal.ts +74 -55
- package/src/main.ts +90 -90
- package/src/panels/agent-inspector-shared.ts +4 -4
- package/src/panels/base-panel.ts +2 -2
- package/src/panels/builtin/agent.ts +4 -4
- package/src/panels/builtin/development.ts +4 -4
- package/src/panels/builtin/knowledge.ts +1 -1
- package/src/panels/builtin/operations.ts +83 -11
- package/src/panels/builtin/session.ts +5 -5
- package/src/panels/builtin/shared.ts +16 -4
- package/src/panels/builtin-modals.ts +59 -42
- package/src/panels/builtin-panels.ts +1 -1
- package/src/panels/cost-tracker-panel.ts +124 -7
- package/src/panels/diff-panel.ts +39 -6
- package/src/panels/eval-registry.ts +2 -2
- package/src/panels/fleet-acts.ts +446 -0
- package/src/panels/fleet-deep-link.ts +1 -1
- package/src/panels/fleet-gateway.ts +117 -0
- package/src/panels/fleet-observed-render.ts +128 -0
- package/src/panels/fleet-panel-format.ts +163 -11
- package/src/panels/fleet-panel-worktree-detail.ts +14 -0
- package/src/panels/fleet-panel.ts +73 -57
- package/src/panels/fleet-read-model.ts +84 -14
- package/src/panels/fleet-spawn.ts +266 -0
- package/src/panels/fleet-steer.ts +2 -2
- package/src/panels/fleet-stop.ts +41 -5
- package/src/panels/fleet-tabs.ts +15 -16
- package/src/panels/fleet-transcript.ts +5 -5
- package/src/panels/git-panel.ts +2 -2
- package/src/panels/index.ts +4 -1
- package/src/panels/local-auth-panel.ts +1 -1
- package/src/panels/memory-consolidation-gateway.ts +94 -0
- package/src/panels/modals/devices-modal.ts +161 -0
- package/src/panels/modals/hooks-modal.ts +1 -1
- package/src/panels/modals/keybindings-modal.ts +2 -2
- package/src/panels/modals/knowledge-modal.ts +1 -1
- package/src/panels/modals/marketplace-modal.ts +4 -4
- package/src/panels/modals/memory-modal.ts +153 -9
- package/src/panels/modals/modal-surface-helpers.ts +1 -1
- package/src/panels/modals/modal-theme.ts +1 -1
- package/src/panels/modals/pairing-modal.ts +198 -68
- package/src/panels/modals/planning-modal.ts +2 -2
- package/src/panels/modals/plugins-modal.ts +1 -1
- package/src/panels/modals/policy-modal.ts +1 -1
- package/src/panels/modals/security-modal.ts +7 -1
- package/src/panels/modals/skills-modal.ts +1 -1
- package/src/panels/modals/work-plan-modal.ts +3 -3
- package/src/panels/notifications-feed.ts +127 -0
- package/src/panels/notifications-panel.ts +160 -0
- package/src/panels/panel-manager.ts +9 -9
- package/src/panels/plugins-panel.ts +4 -4
- package/src/panels/project-planning-answer-actions.ts +1 -1
- package/src/panels/provider-health-routes.ts +2 -2
- package/src/panels/skills-panel.ts +4 -5
- package/src/panels/token-budget-panel.ts +11 -3
- package/src/panels/types.ts +5 -5
- package/src/panels/workstream-graph-render.ts +123 -0
- package/src/permissions/broker-approval-card.ts +201 -0
- package/src/permissions/hunk-selection.ts +28 -4
- package/src/permissions/prompt.ts +244 -29
- package/src/renderer/autocomplete-overlay.ts +107 -54
- package/src/renderer/code-block.ts +2 -2
- package/src/renderer/compaction-history-modal.ts +1 -1
- package/src/renderer/compaction-preview.ts +1 -1
- package/src/renderer/compaction-quality.ts +1 -1
- package/src/renderer/composer-fragments.ts +69 -0
- package/src/renderer/compositor.ts +2 -2
- package/src/renderer/config-modal.ts +1 -1
- package/src/renderer/conversation-overlays.ts +1 -1
- package/src/renderer/footer-tips.ts +3 -3
- package/src/renderer/hint-grammar.ts +1 -1
- package/src/renderer/modal-factory.ts +1 -1
- package/src/renderer/model-picker-overlay.ts +1 -1
- package/src/renderer/model-workspace.ts +7 -2
- package/src/renderer/panel-composite.ts +2 -2
- package/src/renderer/panel-workspace-bar.ts +1 -1
- package/src/renderer/process-indicator.ts +1 -1
- package/src/renderer/selection-modal-overlay.ts +1 -1
- package/src/renderer/session-picker-modal.ts +2 -2
- package/src/renderer/settings-modal-helpers.ts +23 -5
- package/src/renderer/settings-modal.ts +129 -39
- package/src/renderer/shell-surface.ts +6 -0
- package/src/renderer/terminal-bg-probe.ts +1 -1
- package/src/renderer/theme.ts +3 -3
- package/src/renderer/tool-call.ts +13 -7
- package/src/renderer/tool-result-summary.ts +1 -1
- package/src/renderer/turn-injection.ts +7 -6
- package/src/renderer/ui-factory.ts +38 -1
- package/src/runtime/bootstrap-command-context.ts +7 -2
- package/src/runtime/bootstrap-command-parts.ts +9 -5
- package/src/runtime/bootstrap-core.ts +41 -41
- package/src/runtime/bootstrap-hook-bridge.ts +1 -1
- package/src/runtime/bootstrap-shell.ts +13 -5
- package/src/runtime/bootstrap.ts +34 -32
- package/src/runtime/channel-composition.ts +46 -0
- package/src/runtime/code-index-services.ts +3 -4
- package/src/runtime/daemon-attach-notices.ts +120 -0
- package/src/runtime/durability-services.ts +90 -0
- package/src/runtime/embedded-service-factories.ts +40 -0
- package/src/runtime/feature-settings.ts +147 -0
- package/src/runtime/fleet-services.ts +41 -0
- package/src/runtime/idle-power-services.ts +85 -0
- package/src/runtime/index.ts +6 -21
- package/src/runtime/interaction-seams.ts +109 -0
- package/src/runtime/legacy-daemon-migration.ts +29 -19
- package/src/runtime/memory-fold.ts +1 -1
- package/src/runtime/notification-dispatch.ts +110 -0
- package/src/runtime/onboarding/apply.ts +3 -29
- package/src/runtime/onboarding/fast-path.ts +39 -6
- package/src/runtime/onboarding/provider-key-capture.ts +126 -0
- package/src/runtime/onboarding/snapshot.ts +0 -1
- package/src/runtime/onboarding/types.ts +9 -3
- package/src/runtime/power-keepawake-remote.ts +75 -0
- package/src/runtime/process-lifecycle.ts +3 -1
- package/src/runtime/provider-fallback.ts +55 -0
- package/src/runtime/provider-optimizer-wiring.ts +55 -0
- package/src/runtime/release-artifacts.ts +19 -103
- package/src/runtime/resume-notice.ts +1 -1
- package/src/runtime/services.ts +113 -113
- package/src/runtime/session-continuity-hints.ts +38 -0
- package/src/runtime/surface-feature-flags.ts +40 -9
- package/src/runtime/terminal-output-guard.ts +2 -2
- package/src/runtime/trust/workspace-registration.ts +10 -2
- package/src/runtime/trust/workspace-trust.ts +58 -31
- package/src/runtime/ui-services.ts +6 -1
- package/src/runtime/update-check.ts +15 -69
- package/src/runtime/workstream-services.ts +2 -2
- package/src/shell/blocking-input.ts +108 -132
- package/src/shell/recovery-input-helpers.ts +53 -166
- package/src/shell/ui-openers.ts +38 -6
- package/src/utils/format-duration.ts +1 -1
- package/src/verification/verification-ledger.ts +21 -4
- package/src/version.ts +1 -1
- 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
|
+
}
|