@pellux/goodvibes-tui 1.27.1 → 2.0.2
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 +127 -2
- package/README.md +7 -9
- package/bin/goodvibes +2 -2
- package/bin/launcher-support.js +8 -4
- package/docs/README.md +11 -2
- package/docs/foundation-artifacts/operator-contract.json +6403 -2347
- package/package.json +14 -25
- package/scripts/postinstall.js +35 -35
- package/src/audio/spoken-turn-model-routing.ts +1 -1
- package/src/cli/bundle-command.ts +5 -5
- package/src/cli/completions/generate.ts +1 -1
- package/src/cli/doctor.ts +3 -3
- package/src/cli/entrypoint.ts +12 -7
- package/src/cli/help.ts +4 -5
- package/src/cli/hooks-command.ts +1 -1
- package/src/cli/index.ts +1 -4
- package/src/cli/launch-auto-update.ts +59 -7
- package/src/cli/management-commands.ts +6 -6
- package/src/cli/management-utils.ts +5 -5
- package/src/cli/management.ts +7 -7
- package/src/cli/network-posture.ts +1 -1
- package/src/cli/package-verification.ts +7 -6
- package/src/cli/plugin-bundles-command.ts +1 -1
- package/src/cli/plugin-command.ts +2 -2
- package/src/cli/service-command.ts +2 -2
- package/src/cli/service-posture.ts +4 -4
- package/src/cli/status.ts +9 -8
- package/src/cli/surface-command.ts +4 -4
- package/src/cli/tui-startup.ts +3 -3
- package/src/cli-flags.ts +3 -3
- package/src/config/goodvibes-home-audit.ts +0 -2
- package/src/config/secret-config.ts +37 -5
- package/src/config/tui-extension-settings.ts +1 -1
- package/src/core/approval-alert.ts +2 -2
- package/src/core/budget-breach-notifier.ts +3 -3
- package/src/core/conversation-compaction-render.ts +1 -1
- package/src/core/conversation-line-cache.ts +1 -1
- package/src/core/conversation-render-context.ts +1 -1
- package/src/core/conversation-rendering.ts +2 -2
- package/src/core/conversation.ts +10 -10
- package/src/core/long-task-notifier.ts +2 -2
- package/src/core/memory-diagnostics-gateway.ts +1 -1
- package/src/core/scriptable-statusline.ts +1 -1
- package/src/core/session-auto-titler.ts +1 -1
- package/src/core/session-resume-core.ts +2 -2
- package/src/core/stream-event-wiring.ts +24 -44
- package/src/core/terminal-notifier.ts +3 -3
- package/src/core/turn-cancellation.ts +1 -1
- package/src/core/turn-event-wiring.ts +5 -5
- package/src/input/command-registry.ts +6 -6
- package/src/input/commands/channel-pairing.ts +1 -1
- package/src/input/commands/cluster-runtime.ts +1 -1
- package/src/input/commands/codebase-runtime.ts +1 -1
- package/src/input/commands/devices-runtime.ts +1 -1
- package/src/input/commands/hosted-runtime.ts +330 -0
- package/src/input/commands/local-setup-review.ts +1 -1
- package/src/input/commands/local-setup-transfer.ts +2 -2
- package/src/input/commands/local-setup.ts +3 -3
- package/src/input/commands/managed-runtime.ts +2 -2
- package/src/input/commands/operator-rpc.ts +24 -110
- package/src/input/commands/payment-card-intake.ts +1 -1
- package/src/input/commands/permissions-provenance.ts +2 -2
- package/src/input/commands/plugin-runtime.ts +1 -1
- package/src/input/commands/provider.ts +1 -1
- package/src/input/commands/relay-runtime.ts +18 -9
- package/src/input/commands/rewind-runtime.ts +1 -1
- package/src/input/commands/runtime-services.ts +1 -1
- package/src/input/commands/services-runtime.ts +1 -1
- package/src/input/commands/session-workflow.ts +1 -1
- package/src/input/commands/settings-sync-runtime.ts +2 -2
- package/src/input/commands/share-runtime.ts +1 -1
- package/src/input/commands/tasks-runtime.ts +145 -55
- package/src/input/commands/update-runtime.ts +31 -28
- package/src/input/commands/work-plan-runtime.ts +2 -2
- package/src/input/commands/workstream-attempts.ts +1 -1
- package/src/input/commands/workstream-runtime.ts +2 -2
- package/src/input/commands.ts +2 -0
- package/src/input/feature-unit-layout.ts +1 -1
- package/src/input/feed-context-factory.ts +3 -3
- package/src/input/handler-content-actions.ts +2 -2
- package/src/input/handler-feed-routes.ts +1 -1
- package/src/input/handler-feed.ts +3 -3
- package/src/input/handler-modal-token-routes.ts +1 -1
- package/src/input/handler-onboarding-daemon-adopt.ts +3 -2
- package/src/input/handler-onboarding.ts +1 -1
- package/src/input/handler-types.ts +2 -2
- package/src/input/handler-ui-state.ts +1 -1
- package/src/input/handler.ts +4 -4
- package/src/input/input-history.ts +1 -1
- package/src/input/onboarding/onboarding-feature-units.ts +2 -2
- package/src/input/onboarding/onboarding-runtime-status.ts +15 -1
- package/src/input/onboarding/onboarding-wizard-apply.ts +17 -8
- package/src/input/onboarding/onboarding-wizard-cloudflare-step.ts +5 -3
- package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +1 -1
- package/src/input/onboarding/onboarding-wizard-types.ts +1 -1
- package/src/input/payments-config.ts +6 -6
- package/src/input/search.ts +1 -1
- package/src/input/session-picker-modal.ts +46 -0
- package/src/input/settings-modal-activation.ts +1 -1
- package/src/input/settings-modal-behavior.ts +4 -4
- package/src/input/settings-modal-commit.ts +11 -0
- package/src/input/settings-modal-data.ts +9 -122
- package/src/input/settings-modal-mutations.ts +33 -0
- package/src/input/settings-modal-secrets.ts +34 -0
- package/src/input/settings-modal-subscriptions.ts +1 -1
- package/src/input/settings-modal-types.ts +15 -1
- package/src/input/settings-modal.ts +28 -3
- package/src/input/worktree-setup-config.ts +1 -1
- package/src/main.ts +11 -9
- package/src/panels/agent-inspector-shared.ts +2 -2
- package/src/panels/base-panel.ts +2 -2
- package/src/panels/builtin/operations.ts +6 -8
- package/src/panels/builtin/session.ts +17 -0
- package/src/panels/builtin/shared.ts +5 -5
- package/src/panels/builtin-modals.ts +3 -3
- package/src/panels/confirm-state.ts +1 -1
- package/src/panels/cost-tracker-panel.ts +4 -4
- package/src/panels/diff-panel.ts +2 -2
- package/src/panels/diff-review-panel.ts +2 -2
- package/src/panels/expandable-list-panel.ts +2 -2
- package/src/panels/fleet-acts.ts +1 -1
- package/src/panels/fleet-observed-render.ts +1 -1
- package/src/panels/fleet-panel-format.ts +1 -1
- package/src/panels/fleet-panel.ts +1 -1
- package/src/panels/fleet-spawn.ts +1 -1
- package/src/panels/fleet-steer.ts +1 -1
- package/src/panels/fleet-transcript.ts +1 -1
- package/src/panels/git-panel.ts +1 -1
- package/src/panels/hosted-session-feed.ts +352 -0
- package/src/panels/hosted-session-panel.ts +221 -0
- package/src/panels/local-auth-panel.ts +3 -3
- package/src/panels/modals/devices-modal.ts +1 -1
- package/src/panels/modals/local-auth-modal.ts +1 -1
- package/src/panels/modals/pairing-modal.ts +1 -1
- package/src/panels/modals/services-modal.ts +1 -1
- package/src/panels/modals/settings-sync-modal.ts +1 -1
- package/src/panels/modals/subscription-modal.ts +1 -1
- package/src/panels/modals/work-plan-modal.ts +1 -1
- package/src/panels/notifications-panel.ts +1 -1
- package/src/panels/panel-confirm-overlay.ts +1 -1
- package/src/panels/plugins-panel.ts +2 -2
- package/src/panels/polish-core.ts +2 -2
- package/src/panels/polish-tables.ts +1 -1
- package/src/panels/polish.ts +2 -2
- package/src/panels/scrollable-list-panel.ts +3 -3
- package/src/panels/skills-panel.ts +3 -3
- package/src/panels/token-budget-panel.ts +5 -5
- package/src/panels/types.ts +1 -2
- package/src/permissions/prompt.ts +1 -1
- package/src/renderer/autocomplete-overlay.ts +1 -1
- package/src/renderer/block-actions-overlay.ts +1 -1
- package/src/renderer/bookmark-modal.ts +1 -1
- package/src/renderer/buffer.ts +1 -1
- package/src/renderer/code-block.ts +1 -1
- package/src/renderer/composer-fragments.ts +1 -1
- package/src/renderer/compositor.ts +2 -2
- package/src/renderer/config-modal.ts +1 -1
- package/src/renderer/context-inspector.ts +1 -1
- package/src/renderer/conversation-layout.ts +2 -2
- package/src/renderer/conversation-overlays.ts +1 -1
- package/src/renderer/conversation-surface.ts +2 -2
- package/src/renderer/diff-view.ts +1 -1
- package/src/renderer/diff.ts +1 -1
- package/src/renderer/file-picker-overlay.ts +1 -1
- package/src/renderer/fleet-tab-strip.ts +1 -1
- package/src/renderer/fullscreen-primitives.ts +2 -2
- package/src/renderer/fullscreen-workspace.ts +1 -1
- package/src/renderer/header-line.ts +1 -1
- package/src/renderer/help-overlay.ts +1 -1
- package/src/renderer/history-search-overlay.ts +2 -2
- package/src/renderer/layout.ts +5 -8
- package/src/renderer/markdown-inline.ts +1 -1
- package/src/renderer/markdown-table.ts +1 -1
- package/src/renderer/markdown.ts +1 -1
- package/src/renderer/mcp-workspace.ts +1 -1
- package/src/renderer/modal-factory.ts +2 -2
- package/src/renderer/model-workspace.ts +2 -2
- package/src/renderer/onboarding/onboarding-wizard.ts +1 -1
- package/src/renderer/overlay-box.ts +1 -1
- package/src/renderer/panel-composite.ts +1 -1
- package/src/renderer/panel-workspace-bar.ts +1 -1
- package/src/renderer/process-indicator.ts +1 -1
- package/src/renderer/profile-picker-modal.ts +1 -1
- package/src/renderer/search-overlay.ts +2 -2
- package/src/renderer/selection-modal-overlay.ts +1 -1
- package/src/renderer/session-picker-modal.ts +124 -3
- package/src/renderer/settings-modal-helpers.ts +2 -0
- package/src/renderer/settings-modal.ts +2 -2
- package/src/renderer/shell-surface.ts +2 -2
- package/src/renderer/system-message.ts +1 -1
- package/src/renderer/tab-strip.ts +1 -1
- package/src/renderer/theme-mode-config.ts +21 -33
- package/src/renderer/theme.ts +11 -12
- package/src/renderer/thinking.ts +1 -1
- package/src/renderer/tool-call.ts +2 -2
- package/src/renderer/tool-result-expanded-lines.ts +1 -1
- package/src/renderer/turn-injection.ts +7 -8
- package/src/renderer/ui-factory.ts +3 -3
- package/src/runtime/bootstrap-command-context.ts +4 -4
- package/src/runtime/bootstrap-command-parts.ts +5 -5
- package/src/runtime/bootstrap-core.ts +30 -30
- package/src/runtime/bootstrap-shell.ts +4 -4
- package/src/runtime/bootstrap.ts +128 -157
- package/src/runtime/client/approval-updates.ts +82 -0
- package/src/runtime/client/build-floors.ts +167 -0
- package/src/runtime/client/daemon-attach-handshake.ts +115 -0
- package/src/runtime/client/fleet-union.ts +147 -0
- package/src/runtime/client/host-service-status.ts +71 -0
- package/src/runtime/client/hosted-exit.ts +86 -0
- package/src/runtime/client/hosted-roster.ts +110 -0
- package/src/runtime/client/hosted-session-stream.ts +178 -0
- package/src/runtime/client/hosted-sessions.ts +187 -0
- package/src/runtime/client/operator-endpoint.ts +213 -0
- package/src/runtime/client/spine-adoption.ts +99 -0
- package/src/runtime/cloudflare-control-plane.ts +1 -1
- package/src/runtime/context-accounting-source.ts +1 -1
- package/src/runtime/context.ts +2 -2
- package/src/runtime/conversation-rewind-port.ts +42 -14
- package/src/runtime/daemon-attach-notices.ts +55 -19
- package/src/runtime/diagnostics/panels/ops.ts +1 -1
- package/src/runtime/disposal-wiring.ts +4 -24
- package/src/runtime/index.ts +119 -105
- package/src/runtime/install-self-check-startup.ts +2 -2
- package/src/runtime/interaction-seams.ts +1 -1
- package/src/runtime/knowledge-services.ts +8 -2
- package/src/runtime/legacy-daemon-migration.ts +4 -3
- package/src/runtime/legacy-daemon-reconcile.ts +3 -2
- package/src/runtime/memory-spine-transport.ts +37 -267
- package/src/runtime/notification-dispatch.ts +2 -2
- package/src/runtime/onboarding/apply.ts +2 -2
- package/src/runtime/onboarding/derivation.ts +2 -2
- package/src/runtime/onboarding/markers.ts +2 -2
- package/src/runtime/onboarding/progress.ts +2 -2
- package/src/runtime/onboarding/snapshot.ts +1 -1
- package/src/runtime/onboarding/state.ts +2 -2
- package/src/runtime/onboarding/types.ts +2 -2
- package/src/runtime/orchestrator-core-services.ts +1 -1
- package/src/runtime/path-shadow-startup.ts +28 -221
- package/src/runtime/power-keepawake-remote.ts +3 -2
- package/src/runtime/process-lifecycle.ts +2 -2
- package/src/runtime/provider-fallback.ts +10 -66
- package/src/runtime/recovery-autosave.ts +1 -1
- package/src/runtime/recovery-offer-wiring.ts +39 -23
- package/src/runtime/recovery-prompt.ts +31 -17
- package/src/runtime/relay-reachability-bridge.ts +53 -42
- package/src/runtime/release-artifacts.ts +2 -1
- package/src/runtime/runtime-services-types.ts +82 -35
- package/src/runtime/services.ts +305 -300
- package/src/runtime/session-resume-liveness-confirm.ts +1 -1
- package/src/runtime/ui-services.ts +7 -6
- package/src/runtime/update-check.ts +23 -43
- package/src/runtime/workspace-checkpointing.ts +1 -1
- package/src/runtime/wrfc-persistence.ts +2 -2
- package/src/shell/ui-openers.ts +30 -7
- package/src/utils/clipboard.ts +1 -1
- package/src/verification/verification-ledger.ts +10 -8
- package/src/version.ts +1 -1
- package/bin/goodvibes-daemon +0 -90
- package/src/cli/config-overrides.ts +0 -267
- package/src/cli/endpoints.ts +0 -109
- package/src/cli/parser.ts +0 -395
- package/src/cli/redaction.ts +0 -170
- package/src/cli/types.ts +0 -84
- package/src/cluster/commands.ts +0 -382
- package/src/cluster/remote-daemon-target.ts +0 -225
- package/src/cluster/render.ts +0 -268
- package/src/config/atomic-write.ts +0 -70
- package/src/config/credential-availability.ts +0 -122
- package/src/config/goodvibes-home.ts +0 -149
- package/src/config/index.ts +0 -88
- package/src/config/provider-model.ts +0 -23
- package/src/config/read-versioned.ts +0 -228
- package/src/core/alert-gating.ts +0 -67
- package/src/core/consolidation-receipt.ts +0 -41
- package/src/core/focus-tracker.ts +0 -41
- package/src/core/history.ts +0 -45
- package/src/core/memory-status.ts +0 -118
- package/src/core/orchestrator.ts +0 -7
- package/src/core/pairing-devices.ts +0 -49
- package/src/core/pairing-handoff.ts +0 -101
- package/src/core/pairing-offers.ts +0 -83
- package/src/core/pairing-origin.ts +0 -78
- package/src/core/rewind-turn-anchors.ts +0 -311
- package/src/core/session-recovery.ts +0 -210
- package/src/core/stable-host.ts +0 -171
- package/src/core/transcript-journal.ts +0 -503
- package/src/daemon/cli.ts +0 -693
- package/src/daemon/fatal-boot-report.ts +0 -111
- package/src/daemon/handlers/context.ts +0 -29
- package/src/daemon/handlers/contracts.ts +0 -77
- package/src/daemon/handlers/credentials.ts +0 -139
- package/src/daemon/handlers/drafts/draft-store.ts +0 -427
- package/src/daemon/handlers/drafts/index.ts +0 -17
- package/src/daemon/handlers/drafts/register.ts +0 -331
- package/src/daemon/handlers/errors.ts +0 -18
- package/src/daemon/handlers/inbox/cursor-store.ts +0 -457
- package/src/daemon/handlers/inbox/index.ts +0 -284
- package/src/daemon/handlers/inbox/mapping.ts +0 -192
- package/src/daemon/handlers/inbox/poller.ts +0 -212
- package/src/daemon/handlers/inbox/provider-adapter.ts +0 -156
- package/src/daemon/handlers/inbox/providers/discord.ts +0 -252
- package/src/daemon/handlers/inbox/providers/email.ts +0 -152
- package/src/daemon/handlers/inbox/providers/imap-client.ts +0 -300
- package/src/daemon/handlers/inbox/providers/route-util.ts +0 -24
- package/src/daemon/handlers/inbox/providers/slack.ts +0 -263
- package/src/daemon/handlers/index.ts +0 -117
- package/src/daemon/handlers/register.ts +0 -168
- package/src/daemon/handlers/remote/backends/cloud-terminal.ts +0 -142
- package/src/daemon/handlers/remote/backends/docker.ts +0 -79
- package/src/daemon/handlers/remote/backends/index.ts +0 -40
- package/src/daemon/handlers/remote/backends/local-process.ts +0 -113
- package/src/daemon/handlers/remote/backends/process-runner.ts +0 -127
- package/src/daemon/handlers/remote/backends/ssh.ts +0 -125
- package/src/daemon/handlers/remote/backends/types.ts +0 -97
- package/src/daemon/handlers/remote/dispatcher.ts +0 -181
- package/src/daemon/handlers/remote/index.ts +0 -119
- package/src/daemon/handlers/remote/peer-registry.ts +0 -357
- package/src/daemon/handlers/remote/service.ts +0 -191
- package/src/daemon/handlers/routing/inbox-bridge.ts +0 -71
- package/src/daemon/handlers/routing/index.ts +0 -261
- package/src/daemon/handlers/routing/route-store.ts +0 -319
- package/src/daemon/handlers/routing/routing-resolver.ts +0 -75
- package/src/daemon/handlers/sqlite-store.ts +0 -278
- package/src/daemon/handlers/triage/index.ts +0 -57
- package/src/daemon/handlers/triage/integration.ts +0 -213
- package/src/daemon/handlers/triage/pipeline.ts +0 -274
- package/src/daemon/handlers/triage/scorer.ts +0 -287
- package/src/daemon/handlers/triage/tagger/discord.ts +0 -187
- package/src/daemon/handlers/triage/tagger/imap.ts +0 -384
- package/src/daemon/handlers/triage/tagger/index.ts +0 -184
- package/src/daemon/handlers/triage/tagger/shared.ts +0 -70
- package/src/daemon/handlers/triage/tagger/slack.ts +0 -69
- package/src/daemon/handlers/triage/types.ts +0 -50
- package/src/daemon/lifecycle.ts +0 -41
- package/src/daemon/provision-wake-model.ts +0 -81
- package/src/daemon/safe-serve.ts +0 -61
- package/src/daemon/send/channels.ts +0 -200
- package/src/daemon/send/command.ts +0 -332
- package/src/daemon/send/composition.ts +0 -99
- package/src/daemon/send/failure-text.ts +0 -93
- package/src/daemon/send/inert-text.ts +0 -225
- package/src/daemon/send/stdin.ts +0 -24
- package/src/daemon/service-commands.ts +0 -329
- package/src/export/cost-utils.ts +0 -319
- package/src/input/selection.ts +0 -135
- package/src/plugins/loader.ts +0 -15
- package/src/renderer/bottom-bar.ts +0 -58
- package/src/renderer/conversation-tree.ts +0 -228
- package/src/runtime/agent-graph-composition.ts +0 -78
- package/src/runtime/channel-composition.ts +0 -46
- package/src/runtime/cluster-composition.ts +0 -124
- package/src/runtime/cluster-group-composition.ts +0 -284
- package/src/runtime/code-index-services.ts +0 -167
- package/src/runtime/daemon-handler-composition.ts +0 -76
- package/src/runtime/device-posture-composition.ts +0 -110
- package/src/runtime/durability-housekeeping.ts +0 -183
- package/src/runtime/durability-services.ts +0 -155
- package/src/runtime/embedded-service-factories.ts +0 -74
- package/src/runtime/feature-settings.ts +0 -154
- package/src/runtime/fleet-needs-input-push.ts +0 -70
- package/src/runtime/fleet-services.ts +0 -41
- package/src/runtime/idle-power-services.ts +0 -85
- package/src/runtime/mail-composition.ts +0 -65
- package/src/runtime/memory-fold.ts +0 -55
- package/src/runtime/operator-token-cleanup.ts +0 -109
- package/src/runtime/path-shadow.ts +0 -418
- package/src/runtime/perf/panel-health-monitor.ts +0 -18
- package/src/runtime/provider-optimizer-wiring.ts +0 -55
- package/src/runtime/reachability-notice.ts +0 -100
- package/src/runtime/remote-execution-composition.ts +0 -62
- package/src/runtime/session-liveness-marker.ts +0 -257
- package/src/runtime/session-pointer-surface.ts +0 -22
- package/src/runtime/session-storage-services.ts +0 -41
- package/src/runtime/surface-feature-flags.ts +0 -96
- package/src/runtime/terminal-output-guard.ts +0 -235
- package/src/runtime/trust/workspace-registration.ts +0 -192
- package/src/runtime/trust/workspace-trust.ts +0 -229
- package/src/runtime/ui/index.ts +0 -39
- package/src/runtime/ui/model-picker/data-provider.ts +0 -182
- package/src/runtime/ui/model-picker/health-enrichment.ts +0 -231
- package/src/runtime/ui/model-picker/index.ts +0 -59
- package/src/runtime/ui/model-picker/types.ts +0 -153
- package/src/runtime/ui/provider-health/data-provider.ts +0 -244
- package/src/runtime/ui/provider-health/fallback-visualizer.ts +0 -71
- package/src/runtime/ui/provider-health/index.ts +0 -46
- package/src/runtime/ui/provider-health/types.ts +0 -146
- package/src/runtime/ui-events.ts +0 -1
- package/src/runtime/ui-read-model-helpers.ts +0 -1
- package/src/runtime/ui-read-models-observability-maintenance.ts +0 -1
- package/src/runtime/ui-read-models-observability-options.ts +0 -1
- package/src/runtime/ui-read-models-observability-remote.ts +0 -1
- package/src/runtime/ui-read-models-observability-security.ts +0 -1
- package/src/runtime/ui-read-models-observability-system.ts +0 -1
- package/src/runtime/ui-read-models-observability.ts +0 -1
- package/src/runtime/ui-service-queries.ts +0 -1
- package/src/runtime/voice-setup-services.ts +0 -98
- package/src/runtime/workstream-attempts-validation.ts +0 -77
- package/src/runtime/workstream-draft-edits.ts +0 -95
- package/src/runtime/workstream-draft-store.ts +0 -274
- package/src/runtime/workstream-draft-types.ts +0 -42
- package/src/runtime/workstream-services.ts +0 -373
- package/src/tools/index.ts +0 -1
- package/src/types/grid.ts +0 -48
- package/src/types/sql-js.d.ts +0 -15
- package/src/work-plans/work-plan-store.ts +0 -702
|
@@ -1,49 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,101 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,83 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,78 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,311 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* rewind-turn-anchors.ts — the session-scoped join key between the conversation
|
|
3
|
-
* and the workspace-checkpoint store, for message-anchored rewind.
|
|
4
|
-
*
|
|
5
|
-
* The unified rewind service (SDK platform/rewind) anchors a rewind to a
|
|
6
|
-
* `{ sessionId, turnId }`. Files-scope resolves that turnId against the
|
|
7
|
-
* workspace checkpoints the turn engine already stamps with the same turnId.
|
|
8
|
-
* Conversation-scope, however, needs to know HOW MANY conversation messages
|
|
9
|
-
* existed at that turn boundary — a mapping the SDK's checkpoint store does not
|
|
10
|
-
* carry (TUI turn-checkpoints are not stamped with a conversation-message
|
|
11
|
-
* count). This registry supplies exactly that mapping: at every TURN_COMPLETED
|
|
12
|
-
* the TUI records the turnId together with the live `conversation.getMessageCount()`,
|
|
13
|
-
* so a later rewind to that turnId can truncate the conversation to precisely
|
|
14
|
-
* the boundary the files checkpoint captured.
|
|
15
|
-
*
|
|
16
|
-
* Scope + lifetime: in-memory, keyed by sessionId. The registry is the live
|
|
17
|
-
* working copy; it is mirrored to a small per-session sidecar file next to the
|
|
18
|
-
* session's JSONL (`<sessionsDir>/<sessionId>.anchors.json`) at every turn, and
|
|
19
|
-
* `restoreTurnAnchors` reloads that sidecar on resume so message-anchored
|
|
20
|
-
* /rewind works identically before and after a resume. The sidecar's
|
|
21
|
-
* `messageCount` boundaries stay valid because a resume rehydrates the same
|
|
22
|
-
* message history the anchors were recorded against.
|
|
23
|
-
*/
|
|
24
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
25
|
-
import { join } from 'node:path';
|
|
26
|
-
import type { SessionSurface } from '@/runtime/index.ts';
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
/** One recorded turn boundary — the rewind coordinator's per-turn anchor. */
|
|
30
|
-
export interface TurnAnchor {
|
|
31
|
-
/** The turn engine's turn id, shared with the workspace checkpoint's `turnId`. */
|
|
32
|
-
readonly turnId: string;
|
|
33
|
-
/** A short human label (the truncated user prompt) for the recent-turns picker. */
|
|
34
|
-
readonly label: string;
|
|
35
|
-
/** `conversation.getMessageCount()` captured at this turn's completion — the conversation truncation boundary. */
|
|
36
|
-
readonly messageCount: number;
|
|
37
|
-
/** Wall-clock ms at capture, for ordering + age display. */
|
|
38
|
-
readonly at: number;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** Hard cap so a very long session cannot grow this registry without bound. */
|
|
42
|
-
const MAX_ANCHORS_PER_SESSION = 500;
|
|
43
|
-
|
|
44
|
-
const registry = new Map<string, TurnAnchor[]>();
|
|
45
|
-
|
|
46
|
-
/** Trim a user prompt to a single compact line for the picker. */
|
|
47
|
-
export function summarizeTurnLabel(text: string | null | undefined, max = 72): string {
|
|
48
|
-
const oneLine = (text ?? '').replace(/\s+/g, ' ').trim();
|
|
49
|
-
if (oneLine.length === 0) return '(no prompt text)';
|
|
50
|
-
return oneLine.length > max ? `${oneLine.slice(0, max - 1)}…` : oneLine;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Record a completed turn's anchor. Idempotent per turnId: a repeated turnId
|
|
55
|
-
* updates the existing entry in place (the checkpoint engine can re-snapshot a
|
|
56
|
-
* turn) rather than duplicating it.
|
|
57
|
-
*/
|
|
58
|
-
export function recordTurnAnchor(sessionId: string, anchor: TurnAnchor): void {
|
|
59
|
-
if (!sessionId || !anchor.turnId) return;
|
|
60
|
-
const list = registry.get(sessionId) ?? [];
|
|
61
|
-
const existingIndex = list.findIndex((a) => a.turnId === anchor.turnId);
|
|
62
|
-
if (existingIndex >= 0) {
|
|
63
|
-
list[existingIndex] = anchor;
|
|
64
|
-
} else {
|
|
65
|
-
list.push(anchor);
|
|
66
|
-
if (list.length > MAX_ANCHORS_PER_SESSION) list.splice(0, list.length - MAX_ANCHORS_PER_SESSION);
|
|
67
|
-
}
|
|
68
|
-
registry.set(sessionId, list);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** All recorded anchors for a session, oldest first (chronological). */
|
|
72
|
-
export function getTurnAnchors(sessionId: string): readonly TurnAnchor[] {
|
|
73
|
-
return registry.get(sessionId) ?? [];
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** Resolve an anchor by exact turnId, or null when this run never recorded it. */
|
|
77
|
-
export function resolveTurnAnchor(sessionId: string, turnId: string): TurnAnchor | null {
|
|
78
|
-
return registry.get(sessionId)?.find((a) => a.turnId === turnId) ?? null;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** Drop a session's anchors. Exposed for tests and session reset. */
|
|
82
|
-
export function clearTurnAnchors(sessionId: string): void {
|
|
83
|
-
registry.delete(sessionId);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// --- Cross-resume persistence -------------------------------------------------
|
|
87
|
-
//
|
|
88
|
-
// The TUI mirrors the in-memory registry to a sidecar next to the session file.
|
|
89
|
-
// The directory comes off the caller's SessionSurface (`surface.sessionsDir`),
|
|
90
|
-
// the same handle the session JSONL itself is written through — so the sidecar
|
|
91
|
-
// and its session can never end up in different directories.
|
|
92
|
-
const ANCHOR_SIDECAR_VERSION = 1;
|
|
93
|
-
|
|
94
|
-
/** Absolute path to a session's anchor sidecar, or null when inputs are unusable. */
|
|
95
|
-
const ANCHOR_SIDECAR_SUFFIX = '.anchors.json';
|
|
96
|
-
|
|
97
|
-
function anchorSidecarPath(sessionId: string, surface: SessionSurface): string | null {
|
|
98
|
-
if (!sessionId || !surface.sessionsDir) return null;
|
|
99
|
-
// A sessionId is a filename component; refuse anything with path separators so
|
|
100
|
-
// a malformed id can never escape the sessions directory.
|
|
101
|
-
if (/[\\/]/.test(sessionId)) return null;
|
|
102
|
-
return join(surface.sessionsDir, `${sessionId}${ANCHOR_SIDECAR_SUFFIX}`);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function isTurnAnchor(value: unknown): value is TurnAnchor {
|
|
106
|
-
if (typeof value !== 'object' || value === null) return false;
|
|
107
|
-
const a = value as Record<string, unknown>;
|
|
108
|
-
return (
|
|
109
|
-
typeof a.turnId === 'string' && a.turnId.length > 0 &&
|
|
110
|
-
typeof a.label === 'string' &&
|
|
111
|
-
typeof a.messageCount === 'number' && Number.isFinite(a.messageCount) &&
|
|
112
|
-
typeof a.at === 'number' && Number.isFinite(a.at)
|
|
113
|
-
);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Write the current in-memory anchors for a session to its sidecar so they
|
|
118
|
-
* survive a resume. Best-effort and atomic (temp file + rename): a failed or
|
|
119
|
-
* torn write must never break the turn that triggered it. Called after each
|
|
120
|
-
* `recordTurnAnchor` at TURN_COMPLETED.
|
|
121
|
-
*/
|
|
122
|
-
export function persistTurnAnchors(sessionId: string, surface: SessionSurface): void {
|
|
123
|
-
const path = anchorSidecarPath(sessionId, surface);
|
|
124
|
-
if (!path) return;
|
|
125
|
-
const list = registry.get(sessionId) ?? [];
|
|
126
|
-
if (list.length === 0) return; // nothing to mirror yet
|
|
127
|
-
try {
|
|
128
|
-
mkdirSync(surface.sessionsDir, { recursive: true });
|
|
129
|
-
const payload = JSON.stringify({ version: ANCHOR_SIDECAR_VERSION, sessionId, anchors: list });
|
|
130
|
-
const tmp = `${path}${ANCHOR_SIDECAR_TMP_MARKER}${process.pid}`;
|
|
131
|
-
writeFileSync(tmp, payload);
|
|
132
|
-
renameSync(tmp, path);
|
|
133
|
-
} catch {
|
|
134
|
-
/* best-effort; a rewind-anchor persist miss must never break the turn */
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* Reload a session's persisted anchors into the in-memory registry on resume.
|
|
140
|
-
* Returns the number of anchors restored (0 when no sidecar exists or it is
|
|
141
|
-
* unreadable). Idempotent via `recordTurnAnchor`'s per-turnId dedup, so a resume
|
|
142
|
-
* that later re-records the same turn keeps a single entry.
|
|
143
|
-
*/
|
|
144
|
-
export function restoreTurnAnchors(sessionId: string, surface: SessionSurface): number {
|
|
145
|
-
const path = anchorSidecarPath(sessionId, surface);
|
|
146
|
-
if (!path || !existsSync(path)) return 0;
|
|
147
|
-
try {
|
|
148
|
-
const parsed = JSON.parse(readFileSync(path, 'utf8')) as { anchors?: unknown };
|
|
149
|
-
if (!parsed || !Array.isArray(parsed.anchors)) return 0;
|
|
150
|
-
let restored = 0;
|
|
151
|
-
for (const candidate of parsed.anchors) {
|
|
152
|
-
if (isTurnAnchor(candidate)) {
|
|
153
|
-
recordTurnAnchor(sessionId, candidate);
|
|
154
|
-
restored++;
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
return restored;
|
|
158
|
-
} catch {
|
|
159
|
-
return 0;
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// ─── Reaping orphaned sidecars ────────────────────────────────────────────────
|
|
164
|
-
//
|
|
165
|
-
// A sidecar's OWNER is the session JSONL it sits beside. Deleting a session
|
|
166
|
-
// removes the JSONL but nothing ever removed the sidecar, so anchors for
|
|
167
|
-
// sessions that no longer exist accumulate in the sessions directory forever.
|
|
168
|
-
// The sweep below reclaims them, plus the `.tmp-<pid>` staging files a crash
|
|
169
|
-
// between write and rename leaves behind.
|
|
170
|
-
|
|
171
|
-
/** Infix for the atomic-write staging file, before the writing process's pid. */
|
|
172
|
-
const ANCHOR_SIDECAR_TMP_MARKER = '.tmp-';
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* How long a staging file is tolerated before it is treated as crash residue:
|
|
176
|
-
* 1 hour. `persistTurnAnchors` renames within microseconds of writing, so
|
|
177
|
-
* anything this old was interrupted, and the completed sidecar (if the write
|
|
178
|
-
* ever finished) is a separate file.
|
|
179
|
-
*/
|
|
180
|
-
export const ANCHOR_TMP_MAX_AGE_MS = 60 * 60 * 1000;
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* How settled a sidecar must be before this sweep will delete it: 1 hour.
|
|
184
|
-
*
|
|
185
|
-
* Another instance can be persisting anchors for a session this process knows
|
|
186
|
-
* nothing about, and the window between reading a sidecar's content and
|
|
187
|
-
* unlinking it is not atomic. Requiring the file to have been untouched for an
|
|
188
|
-
* hour means a sidecar that some other instance is actively rewriting is never
|
|
189
|
-
* a candidate, while genuine residue — whose writer is long gone — always is.
|
|
190
|
-
* The sweep repeats, so the delay costs nothing.
|
|
191
|
-
*/
|
|
192
|
-
export const ANCHOR_SIDECAR_SETTLE_MS = 60 * 60 * 1000;
|
|
193
|
-
|
|
194
|
-
export interface AnchorSidecarReapResult {
|
|
195
|
-
/** Sidecar and staging files examined this sweep. */
|
|
196
|
-
readonly scanned: number;
|
|
197
|
-
/** Files deleted this sweep. */
|
|
198
|
-
readonly reaped: number;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
export interface AnchorSidecarReapOptions {
|
|
202
|
-
/** The session this process is using right now; its sidecar is never reaped. */
|
|
203
|
-
readonly currentSessionId?: string | null;
|
|
204
|
-
readonly now?: () => number;
|
|
205
|
-
/** Override the staging-file age window (tests). */
|
|
206
|
-
readonly tmpMaxAgeMs?: number;
|
|
207
|
-
/** Override how long a sidecar must be untouched before it can be reaped (tests). */
|
|
208
|
-
readonly settleMs?: number;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/**
|
|
212
|
-
* Delete anchor sidecars whose owning session file is gone, sidecars that hold
|
|
213
|
-
* nothing readable, and abandoned staging files.
|
|
214
|
-
*
|
|
215
|
-
* A sidecar survives when `<sessionsDir>/<sessionId>.jsonl` still exists AND
|
|
216
|
-
* the sidecar itself parses into at least one usable anchor — content, not
|
|
217
|
-
* mere existence, because a sidecar truncated by a crash restores nothing and
|
|
218
|
-
* would otherwise sit there indefinitely looking like valid state. It also
|
|
219
|
-
* survives while it is still fresh (see `ANCHOR_SIDECAR_SETTLE_MS`), which
|
|
220
|
-
* keeps a sidecar another instance is mid-rewrite out of reach.
|
|
221
|
-
*
|
|
222
|
-
* The current session's sidecar is never touched, and an unreadable or absent
|
|
223
|
-
* sessions directory simply reclaims nothing. Idempotent and concurrency-safe:
|
|
224
|
-
* a file another sweeper unlinked first (ENOENT) counts as reaped.
|
|
225
|
-
*/
|
|
226
|
-
export function reapOrphanedAnchorSidecars(
|
|
227
|
-
surface: SessionSurface,
|
|
228
|
-
options: AnchorSidecarReapOptions = {},
|
|
229
|
-
): AnchorSidecarReapResult {
|
|
230
|
-
const dir = surface.sessionsDir;
|
|
231
|
-
if (!dir) return { scanned: 0, reaped: 0 };
|
|
232
|
-
|
|
233
|
-
let names: string[];
|
|
234
|
-
try {
|
|
235
|
-
names = readdirSync(dir).filter(
|
|
236
|
-
(n) => n.endsWith(ANCHOR_SIDECAR_SUFFIX) || n.includes(`${ANCHOR_SIDECAR_SUFFIX}${ANCHOR_SIDECAR_TMP_MARKER}`),
|
|
237
|
-
);
|
|
238
|
-
} catch {
|
|
239
|
-
return { scanned: 0, reaped: 0 };
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
const now = options.now?.() ?? Date.now();
|
|
243
|
-
const tmpMaxAgeMs = options.tmpMaxAgeMs ?? ANCHOR_TMP_MAX_AGE_MS;
|
|
244
|
-
const settleMs = options.settleMs ?? ANCHOR_SIDECAR_SETTLE_MS;
|
|
245
|
-
let reaped = 0;
|
|
246
|
-
|
|
247
|
-
for (const name of names) {
|
|
248
|
-
const path = join(dir, name);
|
|
249
|
-
|
|
250
|
-
if (!name.endsWith(ANCHOR_SIDECAR_SUFFIX)) {
|
|
251
|
-
// A `<sessionId>.anchors.json.tmp-<pid>` staging file. Age alone decides:
|
|
252
|
-
// a young one may belong to another instance's in-flight write.
|
|
253
|
-
let mtimeMs: number;
|
|
254
|
-
try {
|
|
255
|
-
mtimeMs = statSync(path).mtimeMs;
|
|
256
|
-
} catch {
|
|
257
|
-
continue;
|
|
258
|
-
}
|
|
259
|
-
if (now - mtimeMs > tmpMaxAgeMs && unlinkAnchorFile(path)) reaped++;
|
|
260
|
-
continue;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
const sessionId = name.slice(0, name.length - ANCHOR_SIDECAR_SUFFIX.length);
|
|
264
|
-
if (sessionId.length === 0) continue;
|
|
265
|
-
if (options.currentSessionId && sessionId === options.currentSessionId) continue;
|
|
266
|
-
|
|
267
|
-
// A sidecar written moments ago belongs to a writer that is still around —
|
|
268
|
-
// possibly another instance whose session this process cannot see.
|
|
269
|
-
let sidecarMtimeMs: number;
|
|
270
|
-
try {
|
|
271
|
-
sidecarMtimeMs = statSync(path).mtimeMs;
|
|
272
|
-
} catch {
|
|
273
|
-
continue;
|
|
274
|
-
}
|
|
275
|
-
if (now - sidecarMtimeMs <= settleMs) continue;
|
|
276
|
-
|
|
277
|
-
if (!existsSync(join(dir, `${sessionId}.jsonl`))) {
|
|
278
|
-
// The owning session is gone — this sidecar can never be used again.
|
|
279
|
-
if (unlinkAnchorFile(path)) reaped++;
|
|
280
|
-
continue;
|
|
281
|
-
}
|
|
282
|
-
if (!sidecarHoldsAnchors(path)) {
|
|
283
|
-
// The session survives but the sidecar is empty or torn: it restores
|
|
284
|
-
// nothing, so keeping it only hides the loss.
|
|
285
|
-
if (unlinkAnchorFile(path)) reaped++;
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
return { scanned: names.length, reaped };
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
/** Content check: does this sidecar parse into at least one usable anchor? */
|
|
293
|
-
function sidecarHoldsAnchors(path: string): boolean {
|
|
294
|
-
try {
|
|
295
|
-
const text = readFileSync(path, 'utf8');
|
|
296
|
-
if (text.trim().length === 0) return false;
|
|
297
|
-
const parsed = JSON.parse(text) as { anchors?: unknown };
|
|
298
|
-
return Array.isArray(parsed?.anchors) && parsed.anchors.some(isTurnAnchor);
|
|
299
|
-
} catch {
|
|
300
|
-
return false;
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
function unlinkAnchorFile(path: string): boolean {
|
|
305
|
-
try {
|
|
306
|
-
unlinkSync(path);
|
|
307
|
-
return true;
|
|
308
|
-
} catch (err) {
|
|
309
|
-
return (err as NodeJS.ErrnoException | undefined)?.code === 'ENOENT';
|
|
310
|
-
}
|
|
311
|
-
}
|