@gajae-code/coding-agent 0.11.1 → 0.11.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 +54 -0
- package/dist/types/cli/config-cli.d.ts +11 -1
- package/dist/types/cli/notify-cli.d.ts +1 -0
- package/dist/types/cli/session-picker.d.ts +2 -1
- package/dist/types/cli/setup-cli.d.ts +1 -1
- package/dist/types/commands/mcp-serve.d.ts +33 -0
- package/dist/types/commands/mcp.d.ts +1 -1
- package/dist/types/commands/notify.d.ts +0 -2
- package/dist/types/commands/sdk.d.ts +1 -1
- package/dist/types/config/atomic-yaml-patch.d.ts +18 -0
- package/dist/types/config/file-lock.d.ts +14 -4
- package/dist/types/config/keybindings.d.ts +42 -1
- package/dist/types/config/model-auth.d.ts +3 -0
- package/dist/types/config/model-bindings-applier.d.ts +13 -0
- package/dist/types/config/model-discovery-manager.d.ts +49 -0
- package/dist/types/config/model-equivalence.d.ts +20 -0
- package/dist/types/config/model-registry.d.ts +16 -15
- package/dist/types/config/model-resolver.d.ts +28 -18
- package/dist/types/config/models-config-schema.d.ts +11 -0
- package/dist/types/config/settings-schema.d.ts +56 -1
- package/dist/types/config/settings.d.ts +14 -11
- package/dist/types/coordinator-mcp/policy.d.ts +5 -0
- package/dist/types/coordinator-mcp/question-gate-codec.d.ts +129 -0
- package/dist/types/coordinator-mcp/question-state.d.ts +356 -0
- package/dist/types/coordinator-mcp/server.d.ts +4 -0
- package/dist/types/discovery/helpers.d.ts +0 -6
- package/dist/types/edit/index.d.ts +3 -0
- package/dist/types/eval/py/env.d.ts +9 -0
- package/dist/types/extensibility/extensions/loader.d.ts +2 -1
- package/dist/types/extensibility/extensions/runner.d.ts +2 -2
- package/dist/types/extensibility/extensions/types.d.ts +34 -3
- package/dist/types/extensibility/hooks/runner.d.ts +1 -1
- package/dist/types/extensibility/shared-events.d.ts +2 -0
- package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +10 -1
- package/dist/types/gjc-runtime/deep-interview-state.d.ts +44 -0
- package/dist/types/gjc-runtime/goal-mode-request.d.ts +11 -1
- package/dist/types/gjc-runtime/launch-worktree.d.ts +4 -2
- package/dist/types/gjc-runtime/linux-proc.d.ts +29 -0
- package/dist/types/gjc-runtime/session-resolution.d.ts +2 -2
- package/dist/types/gjc-runtime/state-migrations.d.ts +1 -1
- package/dist/types/gjc-runtime/state-writer.d.ts +2 -1
- package/dist/types/gjc-runtime/team-launch.d.ts +59 -0
- package/dist/types/gjc-runtime/team-notify.d.ts +45 -0
- package/dist/types/gjc-runtime/team-runtime.d.ts +29 -121
- package/dist/types/gjc-runtime/team-store.d.ts +160 -0
- package/dist/types/gjc-runtime/team-workers.d.ts +78 -0
- package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +8 -0
- package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +13 -0
- package/dist/types/gjc-runtime/ultragoal-evidence.d.ts +38 -0
- package/dist/types/gjc-runtime/ultragoal-guard.d.ts +7 -0
- package/dist/types/gjc-runtime/ultragoal-pipeline.d.ts +23 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +55 -33
- package/dist/types/gjc-runtime/workflow-cli-common.d.ts +8 -0
- package/dist/types/gjc-runtime/workflow-manifest.d.ts +3 -0
- package/dist/types/goals/continuation-timeout-guard.d.ts +22 -0
- package/dist/types/goals/runtime.d.ts +1 -0
- package/dist/types/goals/state.d.ts +8 -0
- package/dist/types/harness-control-plane/lifecycle-reconciliation.d.ts +17 -0
- package/dist/types/harness-control-plane/phase-rollup.d.ts +4 -7
- package/dist/types/harness-control-plane/receipt-ingest.d.ts +5 -0
- package/dist/types/harness-control-plane/receipt-spool.d.ts +4 -0
- package/dist/types/hindsight/state.d.ts +11 -4
- package/dist/types/lsp/lspmux.d.ts +5 -3
- package/dist/types/lsp/path-trust.d.ts +1 -0
- package/dist/types/main.d.ts +3 -2
- package/dist/types/memories/storage.d.ts +1 -0
- package/dist/types/modes/action-registry.d.ts +29 -0
- package/dist/types/modes/components/chrome.d.ts +14 -0
- package/dist/types/modes/components/command-palette.d.ts +27 -0
- package/dist/types/modes/components/custom-provider-wizard.d.ts +1 -0
- package/dist/types/modes/components/index.d.ts +2 -1
- package/dist/types/modes/components/pet-selector.d.ts +1 -1
- package/dist/types/modes/components/plan-preview-overlay.d.ts +43 -0
- package/dist/types/modes/components/queue-mode-selector.d.ts +1 -1
- package/dist/types/modes/components/queue-pane.d.ts +12 -0
- package/dist/types/modes/components/read-tool-group.d.ts +6 -0
- package/dist/types/modes/components/session-observer-overlay.d.ts +3 -4
- package/dist/types/modes/components/sessions-dashboard.d.ts +32 -0
- package/dist/types/modes/components/settings-selector.d.ts +1 -1
- package/dist/types/modes/components/show-images-selector.d.ts +1 -1
- package/dist/types/modes/components/status-line/types.d.ts +1 -1
- package/dist/types/modes/components/status-line.d.ts +1 -94
- package/dist/types/modes/components/tasks-pane.d.ts +15 -0
- package/dist/types/modes/components/theme-selector.d.ts +1 -1
- package/dist/types/modes/components/tool-execution.d.ts +15 -0
- package/dist/types/modes/components/tool-status-header.d.ts +111 -0
- package/dist/types/modes/components/transcript-viewer-overlay.d.ts +42 -0
- package/dist/types/modes/controllers/goal-mode-controller.d.ts +62 -0
- package/dist/types/modes/controllers/input-controller.d.ts +8 -1
- package/dist/types/modes/controllers/mode-gate.d.ts +8 -0
- package/dist/types/modes/controllers/plan-mode-controller.d.ts +65 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +15 -3
- package/dist/types/modes/controllers/stt-controller.d.ts +26 -0
- package/dist/types/modes/interactive-mode.d.ts +19 -17
- package/dist/types/modes/jobs-observer.d.ts +2 -0
- package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +4 -11
- package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +20 -2
- package/dist/types/modes/shared/agent-wire/workflow-gate-types.d.ts +31 -0
- package/dist/types/modes/tasks-aggregator.d.ts +39 -0
- package/dist/types/modes/transcript-item-registry.d.ts +60 -0
- package/dist/types/modes/types.d.ts +20 -13
- package/dist/types/modes/utils/ui-helpers.d.ts +2 -2
- package/dist/types/runtime-mcp/manager.d.ts +1 -1
- package/dist/types/sdk/broker/broker.d.ts +78 -12
- package/dist/types/sdk/broker/lifecycle-codec.d.ts +4 -0
- package/dist/types/sdk/broker/lifecycle-ledger.d.ts +15 -1
- package/dist/types/sdk/broker/lifecycle.d.ts +6 -2
- package/dist/types/sdk/broker/process-incarnation.d.ts +2 -0
- package/dist/types/sdk/broker/session-index.d.ts +0 -1
- package/dist/types/sdk/broker/state-version.d.ts +2 -0
- package/dist/types/sdk/bus/chat-effect-journal.d.ts +1 -0
- package/dist/types/sdk/bus/config-commands.d.ts +1 -1
- package/dist/types/sdk/bus/config.d.ts +11 -0
- package/dist/types/sdk/bus/daemon-paths.d.ts +1 -0
- package/dist/types/sdk/bus/html-format.d.ts +3 -2
- package/dist/types/sdk/bus/index.d.ts +27 -0
- package/dist/types/sdk/bus/lifecycle-control-runtime.d.ts +4 -0
- package/dist/types/sdk/bus/notification-service.d.ts +20 -0
- package/dist/types/sdk/bus/rate-limit-pool.d.ts +13 -2
- package/dist/types/sdk/bus/recent-activity.d.ts +21 -12
- package/dist/types/sdk/bus/rich-document.d.ts +7 -0
- package/dist/types/sdk/bus/rich-render.d.ts +18 -41
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +9 -4
- package/dist/types/sdk/bus/telegram-daemon.d.ts +46 -3
- package/dist/types/sdk/bus/threaded-inbound.d.ts +1 -1
- package/dist/types/sdk/bus/threaded-render.d.ts +13 -0
- package/dist/types/sdk/bus/topic-registry.d.ts +24 -2
- package/dist/types/sdk/host/control/operations.d.ts +1 -1
- package/dist/types/sdk/host/host.d.ts +2 -0
- package/dist/types/sdk/index.d.ts +1 -0
- package/dist/types/sdk/session-directory.d.ts +56 -0
- package/dist/types/session/agent-session.d.ts +82 -47
- package/dist/types/session/agent-storage.d.ts +7 -0
- package/dist/types/session/fallback-chain-controller.d.ts +10 -2
- package/dist/types/session/internal/managed-session-scope.d.ts +102 -0
- package/dist/types/session/internal/managed-session-storage.d.ts +38 -0
- package/dist/types/session/messages.d.ts +16 -0
- package/dist/types/session/session-manager-internal.d.ts +14 -0
- package/dist/types/session/session-manager.d.ts +82 -12
- package/dist/types/session/session-storage.d.ts +43 -3
- package/dist/types/session/volatile-context-pruning.d.ts +15 -0
- package/dist/types/skill-state/{deep-interview-mutation-guard.d.ts → workflow-mutation-guard.d.ts} +24 -8
- package/dist/types/skill-state/workflow-state-contract.d.ts +1 -3
- package/dist/types/slash-commands/builtin-registry.d.ts +4 -1
- package/dist/types/system-prompt.d.ts +2 -11
- package/dist/types/task/executor.d.ts +3 -1
- package/dist/types/thinking.d.ts +7 -0
- package/dist/types/tool-discovery/tool-index.d.ts +1 -50
- package/dist/types/tools/ask.d.ts +64 -2
- package/dist/types/tools/bash.d.ts +3 -0
- package/dist/types/tools/browser/dead-tab-recovery.d.ts +24 -0
- package/dist/types/tools/browser/tab-supervisor.d.ts +21 -0
- package/dist/types/tools/cron.d.ts +2 -0
- package/dist/types/tools/fetch.d.ts +1 -0
- package/dist/types/tools/index.d.ts +30 -15
- package/dist/types/tools/output-meta.d.ts +1 -0
- package/dist/types/tools/read.d.ts +3 -0
- package/dist/types/tools/search-tool-bm25.d.ts +3 -4
- package/dist/types/utils/event-bus.d.ts +1 -1
- package/dist/types/web/search/index.d.ts +3 -1
- package/package.json +21 -9
- package/scripts/build-sdk-package-smoke.ts +34 -1
- package/scripts/generate-hotkeys-docs.ts +74 -0
- package/scripts/generate-sdk-operation-inventory.ts +64 -20
- package/scripts/verify-gjc-sdk-canonicalization.ts +182 -2
- package/src/async/job-manager.ts +49 -6
- package/src/cli/config-cli.ts +75 -2
- package/src/cli/notify-cli.ts +92 -26
- package/src/cli/session-picker.ts +20 -3
- package/src/cli/setup-cli.ts +1 -1
- package/src/cli.ts +1 -1
- package/src/commands/config.ts +1 -1
- package/src/commands/harness.ts +34 -106
- package/src/commands/launch.ts +6 -4
- package/src/commands/mcp-serve.ts +85 -8
- package/src/commands/mcp.ts +2 -2
- package/src/commands/notify.ts +8 -4
- package/src/commands/sdk.ts +61 -40
- package/src/commands/ultragoal.ts +4 -1
- package/src/config/atomic-yaml-patch.ts +80 -13
- package/src/config/file-lock.ts +105 -43
- package/src/config/keybindings.ts +64 -20
- package/src/config/model-auth.ts +9 -0
- package/src/config/model-bindings-applier.ts +139 -0
- package/src/config/model-discovery-manager.ts +224 -0
- package/src/config/model-equivalence.ts +66 -0
- package/src/config/model-registry.ts +169 -302
- package/src/config/model-resolver.ts +361 -369
- package/src/config/models-config-schema.ts +5 -4
- package/src/config/settings-schema.ts +159 -2
- package/src/config/settings.ts +458 -482
- package/src/coordinator-mcp/policy.ts +76 -11
- package/src/coordinator-mcp/question-gate-codec.ts +274 -0
- package/src/coordinator-mcp/question-state.ts +778 -0
- package/src/coordinator-mcp/server.ts +1702 -238
- package/src/debug/index.ts +1 -1
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +13 -1
- package/src/defaults/gjc/skills/team/SKILL.md +16 -0
- package/src/discovery/helpers.ts +1 -50
- package/src/edit/index.ts +64 -0
- package/src/eval/py/env.ts +26 -0
- package/src/eval/py/kernel.ts +5 -3
- package/src/extensibility/extensions/loader.ts +9 -1
- package/src/extensibility/extensions/runner.ts +12 -3
- package/src/extensibility/extensions/types.ts +39 -1
- package/src/extensibility/gjc-plugins/installer.ts +16 -16
- package/src/extensibility/hooks/runner.ts +2 -2
- package/src/extensibility/runtime-skill-discovery.ts +33 -13
- package/src/extensibility/shared-events.ts +2 -0
- package/src/gjc-runtime/deep-interview-recorder.ts +63 -3
- package/src/gjc-runtime/deep-interview-runtime.ts +76 -27
- package/src/gjc-runtime/deep-interview-state.ts +317 -5
- package/src/gjc-runtime/goal-mode-request.ts +55 -8
- package/src/gjc-runtime/launch-tmux.ts +2 -5
- package/src/gjc-runtime/launch-worktree.ts +5 -5
- package/src/gjc-runtime/linux-proc.ts +71 -0
- package/src/gjc-runtime/ralplan-runtime.ts +74 -90
- package/src/gjc-runtime/session-resolution.ts +18 -3
- package/src/gjc-runtime/session-state-sidecar.ts +6 -107
- package/src/gjc-runtime/state-migrations.ts +1 -1
- package/src/gjc-runtime/state-runtime.ts +320 -249
- package/src/gjc-runtime/state-writer.ts +36 -40
- package/src/gjc-runtime/team-launch.ts +319 -0
- package/src/gjc-runtime/team-notify.ts +326 -0
- package/src/gjc-runtime/team-runtime.ts +1099 -1482
- package/src/gjc-runtime/team-store.ts +828 -0
- package/src/gjc-runtime/team-workers.ts +962 -0
- package/src/gjc-runtime/tmux-owner-isolation-cli.ts +2 -11
- package/src/gjc-runtime/tmux-owner-isolation.ts +14 -9
- package/src/gjc-runtime/tmux-sessions.ts +4 -21
- package/src/gjc-runtime/ultragoal-change-set.ts +172 -0
- package/src/gjc-runtime/ultragoal-evidence.ts +874 -0
- package/src/gjc-runtime/ultragoal-guard.ts +29 -5
- package/src/gjc-runtime/ultragoal-pipeline.ts +306 -0
- package/src/gjc-runtime/ultragoal-runtime.ts +84 -1311
- package/src/gjc-runtime/workflow-cli-common.ts +30 -0
- package/src/gjc-runtime/workflow-manifest.generated.json +58 -0
- package/src/gjc-runtime/workflow-manifest.ts +18 -0
- package/src/goals/continuation-timeout-guard.ts +79 -0
- package/src/goals/runtime.ts +7 -5
- package/src/goals/state.ts +18 -0
- package/src/harness-control-plane/lifecycle-reconciliation.ts +64 -0
- package/src/harness-control-plane/phase-rollup.ts +4 -7
- package/src/harness-control-plane/receipt-ingest.ts +5 -0
- package/src/harness-control-plane/receipt-spool.ts +51 -7
- package/src/harness-control-plane/session-lease.ts +12 -130
- package/src/hindsight/backend.ts +26 -27
- package/src/hindsight/state.ts +101 -32
- package/src/hooks/skill-state.ts +17 -11
- package/src/internal-urls/docs-index.generated.ts +21 -18
- package/src/lsp/client.ts +1 -1
- package/src/lsp/config.ts +145 -26
- package/src/lsp/index.ts +1 -1
- package/src/lsp/lspmux.ts +39 -10
- package/src/lsp/path-trust.ts +81 -0
- package/src/main.ts +28 -8
- package/src/memories/index.ts +1 -0
- package/src/memories/storage.ts +6 -2
- package/src/modes/action-registry.ts +165 -0
- package/src/modes/components/chrome.ts +40 -0
- package/src/modes/components/command-palette.ts +168 -0
- package/src/modes/components/custom-editor.ts +1 -1
- package/src/modes/components/custom-provider-wizard.ts +51 -14
- package/src/modes/components/index.ts +2 -1
- package/src/modes/components/pet-selector.ts +11 -17
- package/src/modes/components/plan-preview-overlay.ts +295 -0
- package/src/modes/components/queue-mode-selector.ts +10 -27
- package/src/modes/components/queue-pane.ts +79 -0
- package/src/modes/components/read-tool-group.ts +13 -0
- package/src/modes/components/session-observer-overlay.ts +207 -772
- package/src/modes/components/sessions-dashboard.ts +181 -0
- package/src/modes/components/settings-selector.ts +1 -1
- package/src/modes/components/show-images-selector.ts +10 -24
- package/src/modes/components/status-line/types.ts +1 -1
- package/src/modes/components/status-line.ts +1 -969
- package/src/modes/components/tasks-pane.ts +73 -0
- package/src/modes/components/theme-selector.ts +11 -31
- package/src/modes/components/tool-execution.ts +22 -0
- package/src/modes/components/tool-status-header.ts +1056 -0
- package/src/modes/components/transcript-viewer-overlay.ts +384 -0
- package/src/modes/controllers/btw-controller.ts +1 -0
- package/src/modes/controllers/event-controller.ts +3 -2
- package/src/modes/controllers/extension-ui-controller.ts +24 -1
- package/src/modes/controllers/goal-mode-controller.ts +474 -0
- package/src/modes/controllers/input-controller.ts +764 -229
- package/src/modes/controllers/mode-gate.ts +24 -0
- package/src/modes/controllers/plan-mode-controller.ts +517 -0
- package/src/modes/controllers/selector-controller.ts +225 -10
- package/src/modes/controllers/stt-controller.ts +125 -0
- package/src/modes/interactive-mode.ts +289 -1150
- package/src/modes/jobs-observer.ts +3 -0
- package/src/modes/runtime-init.ts +8 -0
- package/src/modes/shared/agent-wire/deep-interview-gate.ts +31 -106
- package/src/modes/shared/agent-wire/workflow-gate-broker.ts +71 -16
- package/src/modes/shared/agent-wire/workflow-gate-types.ts +177 -0
- package/src/modes/tasks-aggregator.ts +199 -0
- package/src/modes/transcript-item-registry.ts +229 -0
- package/src/modes/types.ts +45 -18
- package/src/modes/utils/ui-helpers.ts +17 -9
- package/src/prompts/agent-fragments/ralplan-persistence.md +6 -0
- package/src/prompts/agent-fragments/restricted-bash.md +1 -0
- package/src/prompts/agents/architect.md +2 -8
- package/src/prompts/agents/critic.md +2 -8
- package/src/prompts/agents/executor.md +3 -0
- package/src/prompts/agents/planner.md +4 -12
- package/src/prompts/system/custom-system-prompt.md +1 -23
- package/src/prompts/system/plan-mode-approved.md +8 -0
- package/src/prompts/system/system-prompt.md +17 -96
- package/src/prompts/tools/bash.md +4 -4
- package/src/prompts/tools/browser.md +15 -52
- package/src/prompts/tools/find.md +2 -2
- package/src/prompts/tools/hashline.md +13 -55
- package/src/prompts/tools/read.md +3 -6
- package/src/prompts/tools/search-tool-bm25.md +0 -2
- package/src/prompts/tools/search.md +2 -4
- package/src/prompts/tools/skill-discovery.md +1 -2
- package/src/prompts/tools/task.md +1 -1
- package/src/runtime-mcp/manager.ts +1 -1
- package/src/sdk/broker/broker.ts +206 -78
- package/src/sdk/broker/discovery.ts +1 -1
- package/src/sdk/broker/ensure.ts +3 -1
- package/src/sdk/broker/lifecycle-codec.ts +11 -0
- package/src/sdk/broker/lifecycle-ledger.ts +371 -74
- package/src/sdk/broker/lifecycle.ts +1399 -142
- package/src/sdk/broker/process-incarnation.ts +5 -13
- package/src/sdk/broker/session-index.ts +145 -56
- package/src/sdk/broker/state-version.ts +21 -0
- package/src/sdk/broker/transport.ts +1 -1
- package/src/sdk/bus/chat-daemon-cli.ts +2 -0
- package/src/sdk/bus/chat-daemon-control.ts +8 -28
- package/src/sdk/bus/chat-effect-journal.ts +10 -2
- package/src/sdk/bus/config-commands.ts +5 -4
- package/src/sdk/bus/config.ts +98 -0
- package/src/sdk/bus/conversation-store.ts +5 -29
- package/src/sdk/bus/daemon-paths.ts +2 -0
- package/src/sdk/bus/html-format.ts +20 -3
- package/src/sdk/bus/index.ts +778 -93
- package/src/sdk/bus/lifecycle-control-runtime.ts +91 -33
- package/src/sdk/bus/natives-augment.d.ts +26 -1
- package/src/sdk/bus/notification-service.ts +168 -20
- package/src/sdk/bus/rate-limit-pool.ts +58 -14
- package/src/sdk/bus/recent-activity.ts +188 -74
- package/src/sdk/bus/rich-document.ts +258 -0
- package/src/sdk/bus/rich-render.ts +62 -70
- package/src/sdk/bus/telegram-daemon-cli.ts +15 -59
- package/src/sdk/bus/telegram-daemon-contract.ts +9 -4
- package/src/sdk/bus/telegram-daemon.ts +1389 -237
- package/src/sdk/bus/threaded-inbound.ts +8 -3
- package/src/sdk/bus/threaded-render.ts +57 -1
- package/src/sdk/bus/topic-registry.ts +120 -13
- package/src/sdk/host/control/dispatch.ts +14 -2
- package/src/sdk/host/control/operations.ts +6 -1
- package/src/sdk/host/host.ts +31 -1
- package/src/sdk/host/query/handlers.ts +20 -22
- package/src/sdk/host/query/revision-store.ts +80 -57
- package/src/sdk/index.ts +1 -0
- package/src/sdk/protocol/operation-inventory.generated.json +943 -1907
- package/src/sdk/session-directory.ts +141 -0
- package/src/sdk/session.ts +458 -479
- package/src/session/agent-session.ts +1700 -583
- package/src/session/agent-storage.ts +18 -0
- package/src/session/cache-economics.ts +25 -8
- package/src/session/fallback-chain-controller.ts +53 -3
- package/src/session/internal/managed-session-scope.ts +2226 -0
- package/src/session/internal/managed-session-storage.ts +453 -0
- package/src/session/messages.ts +91 -2
- package/src/session/session-manager-internal.ts +33 -0
- package/src/session/session-manager.ts +1112 -333
- package/src/session/session-storage.ts +444 -39
- package/src/session/volatile-context-pruning.ts +78 -0
- package/src/setup/credential-auto-import.ts +2 -1
- package/src/setup/hermes/templates/operator-instructions.v1.md +1 -1
- package/src/setup/provider-onboarding.ts +36 -61
- package/src/setup/provider-presets.json +5 -0
- package/src/skill-state/active-state.ts +3 -13
- package/src/skill-state/{deep-interview-mutation-guard.ts → workflow-mutation-guard.ts} +47 -29
- package/src/skill-state/workflow-state-contract.ts +6 -41
- package/src/slash-commands/builtin-registry.ts +46 -11
- package/src/system-prompt.ts +37 -44
- package/src/task/agents.ts +19 -1
- package/src/task/executor.ts +90 -6
- package/src/task/index.ts +36 -21
- package/src/thinking.ts +21 -6
- package/src/tool-discovery/tool-index.ts +2 -134
- package/src/tools/ask.ts +157 -21
- package/src/tools/ast-edit.ts +1 -1
- package/src/tools/bash.ts +38 -0
- package/src/tools/browser/dead-tab-recovery.ts +78 -0
- package/src/tools/browser/tab-supervisor.ts +282 -36
- package/src/tools/computer.ts +10 -3
- package/src/tools/cron.ts +30 -16
- package/src/tools/eval.ts +1 -2
- package/src/tools/fetch.ts +11 -4
- package/src/tools/index.ts +76 -24
- package/src/tools/output-meta.ts +13 -5
- package/src/tools/read.ts +37 -1
- package/src/tools/resource-gc.ts +2 -2
- package/src/tools/search-tool-bm25.ts +11 -74
- package/src/utils/event-bus.ts +9 -4
- package/src/web/search/index.ts +3 -3
- package/dist/types/discovery/plugin-dir-roots.d.ts +0 -15
- package/dist/types/runtime-mcp/discoverable-tool-metadata.d.ts +0 -7
- package/src/discovery/plugin-dir-roots.ts +0 -28
- package/src/runtime-mcp/discoverable-tool-metadata.ts +0 -25
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
TextContent,
|
|
15
15
|
Usage,
|
|
16
16
|
} from "@gajae-code/ai";
|
|
17
|
+
import * as native from "@gajae-code/natives";
|
|
17
18
|
import { getTerminalId } from "@gajae-code/tui";
|
|
18
19
|
import {
|
|
19
20
|
getBlobsDir,
|
|
@@ -52,6 +53,16 @@ import {
|
|
|
52
53
|
resolveResidentImageDataUrlSync,
|
|
53
54
|
resolveTextBlobSync,
|
|
54
55
|
} from "./blob-store";
|
|
56
|
+
import {
|
|
57
|
+
canonicalizeTrustedPath,
|
|
58
|
+
deleteManagedSessionCandidate,
|
|
59
|
+
listManagedCandidates,
|
|
60
|
+
type ManagedMigrationPolicy,
|
|
61
|
+
openManagedCandidateForWrite,
|
|
62
|
+
prepareManagedSessionScopeForWriteSync,
|
|
63
|
+
resolveManagedScope,
|
|
64
|
+
} from "./internal/managed-session-scope";
|
|
65
|
+
|
|
55
66
|
import {
|
|
56
67
|
type BashExecutionMessage,
|
|
57
68
|
type CustomMessage,
|
|
@@ -64,6 +75,7 @@ import {
|
|
|
64
75
|
sanitizeRehydratedOpenAIResponsesAssistantMessage,
|
|
65
76
|
stripInternalDetailsFields,
|
|
66
77
|
} from "./messages";
|
|
78
|
+
import { type SessionManagerReadAccess, sessionManagerReadCapability } from "./session-manager-internal";
|
|
67
79
|
import type {
|
|
68
80
|
SessionStorage,
|
|
69
81
|
SessionStorageSnapshot,
|
|
@@ -75,7 +87,14 @@ import type {
|
|
|
75
87
|
} from "./session-storage";
|
|
76
88
|
import { FileSessionStorage, MemorySessionStorage } from "./session-storage";
|
|
77
89
|
|
|
78
|
-
export const CURRENT_SESSION_VERSION =
|
|
90
|
+
export const CURRENT_SESSION_VERSION = 4;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Version 4 adds append-only patch records. New writers emit v4 headers; v1–v3
|
|
94
|
+
* transcripts remain readable. Older builds do not apply these records, so they
|
|
95
|
+
* must not be used to edit a session after a v4 writer has updated it.
|
|
96
|
+
*/
|
|
97
|
+
|
|
79
98
|
function isUnderProjectGjc(cwd: string, targetPath: string): boolean {
|
|
80
99
|
const relative = path.relative(path.join(path.resolve(cwd), ".gjc"), path.resolve(targetPath));
|
|
81
100
|
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
@@ -187,6 +206,15 @@ export function getSessionMessageViewportAnchorId(message: AgentMessage): string
|
|
|
187
206
|
return sessionMessageViewportAnchorIds.get(message);
|
|
188
207
|
}
|
|
189
208
|
|
|
209
|
+
/** Returns registered viewport anchors for durable user messages in session order. */
|
|
210
|
+
export function getUserMessageViewportAnchorIds(messages: readonly AgentMessage[]): string[] {
|
|
211
|
+
return messages.flatMap(message => {
|
|
212
|
+
if (message.role !== "user" || message.synthetic) return [];
|
|
213
|
+
const anchorId = getSessionMessageViewportAnchorId(message);
|
|
214
|
+
return anchorId ? [anchorId] : [];
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
190
218
|
export function transferSessionMessageIdentity(source: AgentMessage[], target: AgentMessage[]): void {
|
|
191
219
|
if (source.length !== target.length) {
|
|
192
220
|
throw new Error(
|
|
@@ -365,7 +393,22 @@ export type SessionEntry =
|
|
|
365
393
|
| ModeChangeEntry
|
|
366
394
|
| ConfiguredModelChainEntry;
|
|
367
395
|
|
|
368
|
-
/**
|
|
396
|
+
/** Append-only replacement for mutable fields on the session header. */
|
|
397
|
+
export interface HeaderPatchRecord {
|
|
398
|
+
type: "header_patch";
|
|
399
|
+
patch: Partial<Pick<SessionHeader, "title" | "titleSource" | "cwd">>;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Append-only replacement for replay metadata on one existing session entry. */
|
|
403
|
+
export interface EntryPatchRecord {
|
|
404
|
+
type: "entry_patch";
|
|
405
|
+
entryId: string;
|
|
406
|
+
patch: Partial<Pick<SessionMessageEntry, "message">>;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export type SessionPatchRecord = HeaderPatchRecord | EntryPatchRecord;
|
|
410
|
+
|
|
411
|
+
/** Resolved file entries; patch records are applied by parseSessionEntries(). */
|
|
369
412
|
export type FileEntry = SessionHeader | SessionEntry;
|
|
370
413
|
|
|
371
414
|
export type DefaultModelSelectionStage = {
|
|
@@ -445,11 +488,32 @@ export interface ResumeTailTerminal {
|
|
|
445
488
|
|
|
446
489
|
export interface ResumeTailError {
|
|
447
490
|
kind: "error";
|
|
448
|
-
reason: "missing" | "malformed" | "unstable" | "read-failed";
|
|
491
|
+
reason: "missing" | "malformed" | "unstable" | "read-failed" | "legacy_migration_disabled";
|
|
449
492
|
}
|
|
450
493
|
|
|
451
|
-
export type
|
|
494
|
+
export type SessionDirectoryMigrationPolicy = ManagedMigrationPolicy;
|
|
495
|
+
|
|
496
|
+
export class SessionManagedStorageError extends Error {
|
|
497
|
+
readonly code = "managed_storage_unsupported";
|
|
498
|
+
|
|
499
|
+
constructor() {
|
|
500
|
+
super(
|
|
501
|
+
"Default managed session storage requires FileSessionStorage; provide an explicit session directory for custom storage.",
|
|
502
|
+
);
|
|
503
|
+
this.name = "SessionManagedStorageError";
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
export class SessionMigrationPolicyError extends Error {
|
|
508
|
+
readonly code = "legacy_migration_disabled";
|
|
509
|
+
|
|
510
|
+
constructor() {
|
|
511
|
+
super("Legacy session migration is disabled for this workspace.");
|
|
512
|
+
this.name = "SessionMigrationPolicyError";
|
|
513
|
+
}
|
|
514
|
+
}
|
|
452
515
|
|
|
516
|
+
export type ResumeTailInspection = ResumeTailResumable | ResumeTailTerminal | ResumeTailError;
|
|
453
517
|
export interface StrictSessionOpenSuccess {
|
|
454
518
|
kind: "opened";
|
|
455
519
|
manager: SessionManager;
|
|
@@ -488,6 +552,8 @@ export interface SessionInfo {
|
|
|
488
552
|
created: Date;
|
|
489
553
|
modified: Date;
|
|
490
554
|
messageCount: number;
|
|
555
|
+
/** True when messageCount was counted from only the bounded list prefix. */
|
|
556
|
+
messageCountIsEstimate?: boolean;
|
|
491
557
|
/** File size in bytes on disk; used for compact list rendering. */
|
|
492
558
|
size: number;
|
|
493
559
|
firstMessage: string;
|
|
@@ -544,30 +610,100 @@ export type SessionManagerCloseOutcome =
|
|
|
544
610
|
| { kind: "close_failed_retryable"; error: Error }
|
|
545
611
|
| { kind: "close_unknown"; error: Error };
|
|
546
612
|
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
|
554
|
-
|
|
|
555
|
-
|
|
|
556
|
-
|
|
|
557
|
-
|
|
|
558
|
-
|
|
|
559
|
-
|
|
|
560
|
-
|
|
|
561
|
-
|
|
562
|
-
|
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
613
|
+
/** Read-only session state made available to extensions and custom tools. */
|
|
614
|
+
/** Frozen read-only session facade made available to extensions and custom tools. */
|
|
615
|
+
export interface ReadonlySessionManager {
|
|
616
|
+
getCwd(): string;
|
|
617
|
+
getSessionDir(): string;
|
|
618
|
+
getSessionId(): string;
|
|
619
|
+
getSessionFile(): string | undefined;
|
|
620
|
+
getSessionName(): string | undefined;
|
|
621
|
+
getArtifactsDir(): string | null;
|
|
622
|
+
getArtifactPath(id: string): Promise<string | null>;
|
|
623
|
+
getLeafId(): string | null;
|
|
624
|
+
getLeafEntry(): SessionEntry | undefined;
|
|
625
|
+
getEntry(id: string): SessionEntry | undefined;
|
|
626
|
+
getLabel(id: string): string | undefined;
|
|
627
|
+
getBranch(fromId?: string): SessionEntry[];
|
|
628
|
+
getHeader(): SessionHeader | null;
|
|
629
|
+
getEntries(): SessionEntry[];
|
|
630
|
+
getTree(): SessionTreeNode[];
|
|
631
|
+
getUsageStatistics(): UsageStatistics;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function cloneAndFreezeSnapshot<T>(value: T): T {
|
|
635
|
+
const cloned = cloneJsonSemantic(value);
|
|
636
|
+
const freeze = (candidate: unknown, seen = new WeakSet<object>()): void => {
|
|
637
|
+
if (candidate === null || typeof candidate !== "object" || seen.has(candidate)) return;
|
|
638
|
+
seen.add(candidate);
|
|
639
|
+
for (const child of Object.values(candidate)) freeze(child, seen);
|
|
640
|
+
Object.freeze(candidate);
|
|
641
|
+
};
|
|
642
|
+
freeze(cloned);
|
|
643
|
+
return cloned;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/** Creates an immutable facade that never exposes SessionManager mutation authority. */
|
|
647
|
+
export function createReadonlySessionManager(manager: SessionManager): ReadonlySessionManager {
|
|
648
|
+
const facade = Object.freeze({
|
|
649
|
+
getCwd: () => manager.getCwd(),
|
|
650
|
+
getSessionDir: () => manager.getSessionDir(),
|
|
651
|
+
getSessionId: () => manager.getSessionId(),
|
|
652
|
+
getSessionFile: () => manager.getSessionFile(),
|
|
653
|
+
getSessionName: () => manager.getSessionName(),
|
|
654
|
+
getArtifactsDir: () => manager.getArtifactsDir(),
|
|
655
|
+
getArtifactPath: (id: string) => manager.getArtifactPath(id),
|
|
656
|
+
getLeafId: () => manager.getLeafId(),
|
|
657
|
+
getLeafEntry: () => {
|
|
658
|
+
const entry = manager.getLeafEntry();
|
|
659
|
+
return entry === undefined ? undefined : cloneAndFreezeSnapshot(entry);
|
|
660
|
+
},
|
|
661
|
+
getEntry: (id: string) => {
|
|
662
|
+
const entry = manager.getEntry(id);
|
|
663
|
+
return entry === undefined ? undefined : cloneAndFreezeSnapshot(entry);
|
|
664
|
+
},
|
|
665
|
+
getLabel: (id: string) => manager.getLabel(id),
|
|
666
|
+
getBranch: (fromId?: string) => cloneAndFreezeSnapshot(manager.getBranch(fromId)),
|
|
667
|
+
getHeader: () => {
|
|
668
|
+
const header = manager.getHeader();
|
|
669
|
+
return header === null ? null : cloneAndFreezeSnapshot(header);
|
|
670
|
+
},
|
|
671
|
+
getEntries: () => cloneAndFreezeSnapshot(manager.getEntries()),
|
|
672
|
+
getTree: () => cloneAndFreezeSnapshot(manager.getTree()),
|
|
673
|
+
getUsageStatistics: () => cloneAndFreezeSnapshot(manager.getUsageStatistics()),
|
|
674
|
+
});
|
|
675
|
+
readonlySessionArtifactCapabilities.set(facade, sessionArtifactCapability(manager)!);
|
|
676
|
+
return facade;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/** Internal artifact-writing capability. Read-only facades expose it only through a private weak-map lookup. */
|
|
680
|
+
export type SessionArtifactCapability = Readonly<
|
|
681
|
+
Pick<SessionManager, "allocateArtifactPath" | "saveArtifact" | "putBlob">
|
|
569
682
|
>;
|
|
570
683
|
|
|
684
|
+
const sessionArtifactCapabilities = new WeakMap<SessionManager, SessionArtifactCapability>();
|
|
685
|
+
const readonlySessionArtifactCapabilities = new WeakMap<ReadonlySessionManager, SessionArtifactCapability>();
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Returns the artifact capability for a concrete persistence owner or one of its
|
|
689
|
+
* immutable read-only facades. Structural lookalikes remain unauthorized.
|
|
690
|
+
*/
|
|
691
|
+
export function sessionArtifactCapability(value: unknown): SessionArtifactCapability | undefined {
|
|
692
|
+
if (value instanceof SessionManager) {
|
|
693
|
+
let capability = sessionArtifactCapabilities.get(value);
|
|
694
|
+
if (!capability) {
|
|
695
|
+
capability = Object.freeze({
|
|
696
|
+
allocateArtifactPath: value.allocateArtifactPath.bind(value),
|
|
697
|
+
saveArtifact: value.saveArtifact.bind(value),
|
|
698
|
+
putBlob: value.putBlob.bind(value),
|
|
699
|
+
});
|
|
700
|
+
sessionArtifactCapabilities.set(value, capability);
|
|
701
|
+
}
|
|
702
|
+
return capability;
|
|
703
|
+
}
|
|
704
|
+
if (value === null || typeof value !== "object") return undefined;
|
|
705
|
+
return readonlySessionArtifactCapabilities.get(value as ReadonlySessionManager);
|
|
706
|
+
}
|
|
571
707
|
function createSessionId(): string {
|
|
572
708
|
return Bun.randomUUIDv7();
|
|
573
709
|
}
|
|
@@ -643,18 +779,17 @@ function migrateV2ToV3(entries: FileEntry[]): void {
|
|
|
643
779
|
}
|
|
644
780
|
|
|
645
781
|
/**
|
|
646
|
-
* Run all necessary migrations to bring entries to current version.
|
|
647
|
-
*
|
|
782
|
+
* Run all necessary migrations to bring entries to the current semantic version.
|
|
783
|
+
* Version 4 adds trailing patch records, so v3 transcripts must be atomically upgraded before they can receive one.
|
|
648
784
|
*/
|
|
649
785
|
function migrateToCurrentVersion(entries: FileEntry[]): boolean {
|
|
650
786
|
const header = entries.find(e => e.type === "session") as SessionHeader | undefined;
|
|
651
787
|
const version = header?.version ?? 1;
|
|
652
|
-
|
|
653
788
|
if (version >= CURRENT_SESSION_VERSION) return false;
|
|
654
|
-
|
|
655
789
|
if (version < 2) migrateV1ToV2(entries);
|
|
656
790
|
if (version < 3) migrateV2ToV3(entries);
|
|
657
|
-
|
|
791
|
+
const migratedHeader = entries.find(entry => entry.type === "session") as SessionHeader | undefined;
|
|
792
|
+
if (migratedHeader) migratedHeader.version = CURRENT_SESSION_VERSION;
|
|
658
793
|
return true;
|
|
659
794
|
}
|
|
660
795
|
|
|
@@ -663,131 +798,75 @@ export function migrateSessionEntries(entries: FileEntry[]): void {
|
|
|
663
798
|
migrateToCurrentVersion(entries);
|
|
664
799
|
}
|
|
665
800
|
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
*/
|
|
672
|
-
function migrateSessionDirPath(oldPath: string, newPath: string): void {
|
|
673
|
-
// Session-dir lifecycle migration: moves/removes whole directories, not file content writes.
|
|
674
|
-
const existing = fs.statSync(newPath, { throwIfNoEntry: false });
|
|
675
|
-
if (existing?.isDirectory()) {
|
|
676
|
-
for (const file of fs.readdirSync(oldPath)) {
|
|
677
|
-
const src = path.join(oldPath, file);
|
|
678
|
-
const dst = path.join(newPath, file);
|
|
679
|
-
if (!fs.existsSync(dst)) {
|
|
680
|
-
fs.renameSync(src, dst);
|
|
681
|
-
}
|
|
682
|
-
}
|
|
683
|
-
fs.rmSync(oldPath, { recursive: true, force: true });
|
|
684
|
-
return;
|
|
685
|
-
}
|
|
686
|
-
if (existing) {
|
|
687
|
-
fs.rmSync(newPath, { recursive: true, force: true });
|
|
688
|
-
}
|
|
689
|
-
fs.renameSync(oldPath, newPath);
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
function encodeLegacyAbsoluteSessionDirName(cwd: string): string {
|
|
693
|
-
const resolvedCwd = path.resolve(cwd);
|
|
694
|
-
return `--${resolvedCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
function encodeRelativeSessionDirName(prefix: string, root: string, cwd: string): string {
|
|
698
|
-
const relative = path.relative(root, cwd).replace(/[/\\:]/g, "-");
|
|
699
|
-
return relative ? (prefix.endsWith("-") ? `${prefix}${relative}` : `${prefix}-${relative}`) : prefix;
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
function getDefaultSessionDirName(cwd: string): { encodedDirName: string; resolvedCwd: string } {
|
|
703
|
-
const resolvedCwd = path.resolve(cwd);
|
|
704
|
-
const canonicalCwd = resolveEquivalentPath(resolvedCwd);
|
|
705
|
-
const home = resolveEquivalentPath(os.homedir());
|
|
706
|
-
const tempRoot = resolveEquivalentPath(os.tmpdir());
|
|
707
|
-
const encodedDirName = pathIsWithin(home, canonicalCwd)
|
|
708
|
-
? encodeRelativeSessionDirName("-", home, canonicalCwd)
|
|
709
|
-
: pathIsWithin(tempRoot, canonicalCwd)
|
|
710
|
-
? encodeRelativeSessionDirName("-tmp", tempRoot, canonicalCwd)
|
|
711
|
-
: encodeLegacyAbsoluteSessionDirName(canonicalCwd);
|
|
712
|
-
return { encodedDirName, resolvedCwd };
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
/** Default and legacy directory candidates for maintenance-free resume discovery. */
|
|
716
|
-
function getReadOnlyDefaultSessionDirs(cwd: string, sessionsRoot: string = getSessionsDir()): string[] {
|
|
717
|
-
const { encodedDirName, resolvedCwd } = getDefaultSessionDirName(cwd);
|
|
718
|
-
const canonicalCwd = resolveEquivalentPath(resolvedCwd);
|
|
719
|
-
return [
|
|
720
|
-
path.join(sessionsRoot, encodedDirName),
|
|
721
|
-
path.join(sessionsRoot, encodeLegacyAbsoluteSessionDirName(canonicalCwd)),
|
|
722
|
-
path.join(sessionsRoot, encodeLegacyAbsoluteSessionDirName(resolvedCwd)),
|
|
723
|
-
].filter((dir, index, dirs) => dirs.indexOf(dir) === index);
|
|
801
|
+
function resolveManagedSessionRoot(sessionDir: string, cwd: string): string | undefined {
|
|
802
|
+
const sessionsRoot = path.dirname(sessionDir);
|
|
803
|
+
const resolved = resolveManagedScope({ cwd, agentDir: path.resolve(sessionsRoot, ".."), sessionsRoot });
|
|
804
|
+
if (resolved.kind === "error") return undefined;
|
|
805
|
+
return path.resolve(sessionDir) === path.resolve(resolved.scope.directoryPath) ? sessionsRoot : undefined;
|
|
724
806
|
}
|
|
725
807
|
|
|
726
|
-
/**
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
try {
|
|
741
|
-
entries = fs.readdirSync(sessionsRoot);
|
|
742
|
-
} catch {
|
|
743
|
-
return;
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
for (const entry of entries) {
|
|
747
|
-
let remainder: string;
|
|
748
|
-
if (entry === oldExact) {
|
|
749
|
-
remainder = "";
|
|
750
|
-
} else if (entry.startsWith(oldPrefix) && entry.endsWith("--")) {
|
|
751
|
-
remainder = entry.slice(oldPrefix.length, -2);
|
|
752
|
-
} else {
|
|
808
|
+
/** Exported for compaction.test.ts */
|
|
809
|
+
export function parseSessionEntries(content: string): FileEntry[] {
|
|
810
|
+
const records = parseJsonlLenient<FileEntry | SessionPatchRecord>(content);
|
|
811
|
+
const entries: FileEntry[] = [];
|
|
812
|
+
const entriesById = new Map<string, SessionEntry>();
|
|
813
|
+
let header: SessionHeader | undefined;
|
|
814
|
+
|
|
815
|
+
for (const record of records) {
|
|
816
|
+
// Patch records are defined only by v4 transcripts. Ignore them before a
|
|
817
|
+
// v4 header as well: a truncated or malicious prefix must not mutate a
|
|
818
|
+
// later header/entry when replay catches up.
|
|
819
|
+
if (record.type === "header_patch") {
|
|
820
|
+
if (header?.version === CURRENT_SESSION_VERSION && isHeaderPatchRecord(record))
|
|
821
|
+
applyHeaderPatch(header, record.patch);
|
|
753
822
|
continue;
|
|
754
823
|
}
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
migrateSessionDirPath(oldPath, newPath);
|
|
762
|
-
} catch {
|
|
763
|
-
// Best effort
|
|
824
|
+
if (record.type === "entry_patch") {
|
|
825
|
+
if (header?.version === CURRENT_SESSION_VERSION && isEntryPatchRecord(record)) {
|
|
826
|
+
const entry = entriesById.get(record.entryId);
|
|
827
|
+
if (entry?.type === "message" && record.patch.message) entry.message = record.patch.message;
|
|
828
|
+
}
|
|
829
|
+
continue;
|
|
764
830
|
}
|
|
831
|
+
entries.push(record);
|
|
832
|
+
if (record.type === "session") header ??= record;
|
|
833
|
+
else entriesById.set(record.id, record);
|
|
765
834
|
}
|
|
835
|
+
return entries;
|
|
766
836
|
}
|
|
767
837
|
|
|
768
|
-
function
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
838
|
+
function isHeaderPatchRecord(record: SessionPatchRecord): record is HeaderPatchRecord {
|
|
839
|
+
if (
|
|
840
|
+
record.type !== "header_patch" ||
|
|
841
|
+
!isRecord(record.patch) ||
|
|
842
|
+
!Object.keys(record).every(key => key === "type" || key === "patch")
|
|
843
|
+
)
|
|
844
|
+
return false;
|
|
845
|
+
const keys = Object.keys(record.patch);
|
|
846
|
+
if (!keys.every(key => key === "cwd" || key === "title" || key === "titleSource")) return false;
|
|
847
|
+
const { cwd, title, titleSource } = record.patch;
|
|
848
|
+
return (
|
|
849
|
+
(cwd === undefined || typeof cwd === "string") &&
|
|
850
|
+
(title === undefined || typeof title === "string") &&
|
|
851
|
+
(titleSource === undefined || titleSource === "auto" || titleSource === "user")
|
|
852
|
+
);
|
|
777
853
|
}
|
|
778
854
|
|
|
779
|
-
function
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
if (
|
|
783
|
-
return undefined;
|
|
784
|
-
}
|
|
785
|
-
return path.dirname(sessionDir);
|
|
855
|
+
function applyHeaderPatch(header: SessionHeader, patch: HeaderPatchRecord["patch"]): void {
|
|
856
|
+
if (patch.cwd !== undefined) header.cwd = patch.cwd;
|
|
857
|
+
if (patch.title !== undefined) header.title = patch.title;
|
|
858
|
+
if (patch.titleSource !== undefined) header.titleSource = patch.titleSource;
|
|
786
859
|
}
|
|
787
860
|
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
861
|
+
function isEntryPatchRecord(record: SessionPatchRecord): record is EntryPatchRecord {
|
|
862
|
+
return (
|
|
863
|
+
record.type === "entry_patch" &&
|
|
864
|
+
typeof record.entryId === "string" &&
|
|
865
|
+
isRecord(record.patch) &&
|
|
866
|
+
Object.keys(record).every(key => key === "type" || key === "entryId" || key === "patch") &&
|
|
867
|
+
Object.keys(record.patch).every(key => key === "message") &&
|
|
868
|
+
(record.patch.message === undefined || isRecord(record.patch.message))
|
|
869
|
+
);
|
|
791
870
|
}
|
|
792
871
|
|
|
793
872
|
function normalizeConfiguredModelChainEntry(entry: unknown): ConfiguredModelChain | undefined {
|
|
@@ -1126,22 +1205,30 @@ function cloneSessionContext(context: SessionContext): SessionContext {
|
|
|
1126
1205
|
};
|
|
1127
1206
|
}
|
|
1128
1207
|
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1208
|
+
function freezeInternalReadSnapshot<T>(value: T): T {
|
|
1209
|
+
if (process.env.NODE_ENV !== "test" && process.env.NODE_ENV !== "development") return value;
|
|
1210
|
+
const copy = cloneJsonSemantic(value);
|
|
1211
|
+
const freeze = (item: unknown): void => {
|
|
1212
|
+
if (!item || typeof item !== "object" || Object.isFrozen(item)) return;
|
|
1213
|
+
for (const child of Object.values(item as Record<string, unknown>)) freeze(child);
|
|
1214
|
+
Object.freeze(item);
|
|
1215
|
+
};
|
|
1216
|
+
freeze(copy);
|
|
1217
|
+
return copy;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
/** Resolve and prepare the default v2 session scope before any managed writer exists. */
|
|
1134
1221
|
function computeDefaultSessionDir(
|
|
1135
1222
|
cwd: string,
|
|
1136
1223
|
storage: SessionStorage,
|
|
1137
1224
|
sessionsRoot: string = getSessionsDir(),
|
|
1138
1225
|
): string {
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
return
|
|
1226
|
+
if (!(storage instanceof FileSessionStorage)) throw new SessionManagedStorageError();
|
|
1227
|
+
const resolved = resolveManagedScope({ cwd, agentDir: path.resolve(sessionsRoot, ".."), sessionsRoot });
|
|
1228
|
+
if (resolved.kind === "error") throw new Error(`Could not resolve managed session scope: ${resolved.message}`);
|
|
1229
|
+
const prepared = prepareManagedSessionScopeForWriteSync(resolved.scope);
|
|
1230
|
+
if (prepared.kind === "error") throw new Error(`Could not prepare managed session scope: ${prepared.message}`);
|
|
1231
|
+
return prepared.scope.directoryPath;
|
|
1145
1232
|
}
|
|
1146
1233
|
|
|
1147
1234
|
// =============================================================================
|
|
@@ -1213,9 +1300,39 @@ async function readTerminalBreadcrumb(cwd: string): Promise<string | null> {
|
|
|
1213
1300
|
return null;
|
|
1214
1301
|
}
|
|
1215
1302
|
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1303
|
+
const inspected = inspectResumeSessionFile(sessionFile, new FileSessionStorage());
|
|
1304
|
+
if ("kind" in inspected) {
|
|
1305
|
+
if (inspected.reason !== "missing") return null;
|
|
1306
|
+
const sessionsRoot = getSessionsDir();
|
|
1307
|
+
const resolved = resolveManagedScope({
|
|
1308
|
+
cwd: breadcrumbCwd,
|
|
1309
|
+
agentDir: path.resolve(sessionsRoot, ".."),
|
|
1310
|
+
sessionsRoot,
|
|
1311
|
+
});
|
|
1312
|
+
if (resolved.kind !== "resolved") return null;
|
|
1313
|
+
const listing = listManagedCandidates(resolved.scope);
|
|
1314
|
+
if (listing.kind !== "complete") return null;
|
|
1315
|
+
const migrated = listing.owned.filter(
|
|
1316
|
+
candidate => path.basename(candidate.path) === path.basename(sessionFile),
|
|
1317
|
+
);
|
|
1318
|
+
return migrated.length === 1 ? migrated[0]!.path : null;
|
|
1319
|
+
}
|
|
1320
|
+
const header = inspected.entries[0] as SessionHeader;
|
|
1321
|
+
if (resolveEquivalentPath(header.cwd) !== resolveEquivalentPath(breadcrumbCwd)) return null;
|
|
1322
|
+
const sessionsRoot = getSessionsDir();
|
|
1323
|
+
const resolved = resolveManagedScope({
|
|
1324
|
+
cwd: header.cwd,
|
|
1325
|
+
agentDir: path.resolve(sessionsRoot, ".."),
|
|
1326
|
+
sessionsRoot,
|
|
1327
|
+
});
|
|
1328
|
+
if (resolved.kind !== "resolved") return null;
|
|
1329
|
+
const listing = listManagedCandidates(resolved.scope);
|
|
1330
|
+
if (listing.kind !== "complete") return null;
|
|
1331
|
+
const exact = listing.owned.find(candidate => path.resolve(candidate.path) === path.resolve(sessionFile));
|
|
1332
|
+
if (exact) return exact.path;
|
|
1333
|
+
const byIdentity = listing.owned.find(candidate => candidate.sessionId === header.id);
|
|
1334
|
+
if (byIdentity) return byIdentity.path;
|
|
1335
|
+
return pathIsWithin(sessionsRoot, path.resolve(sessionFile)) ? null : path.resolve(sessionFile);
|
|
1219
1336
|
} catch (err) {
|
|
1220
1337
|
if (!isEnoent(err)) logger.debug("Terminal breadcrumb read failed", { err });
|
|
1221
1338
|
// Breadcrumb doesn't exist or is corrupt — fall through
|
|
@@ -1235,7 +1352,7 @@ export async function loadEntriesFromFile(
|
|
|
1235
1352
|
if (isEnoent(err)) return [];
|
|
1236
1353
|
throw err;
|
|
1237
1354
|
}
|
|
1238
|
-
const entries =
|
|
1355
|
+
const entries = parseSessionEntries(content);
|
|
1239
1356
|
|
|
1240
1357
|
// Validate session header
|
|
1241
1358
|
if (entries.length === 0) return entries;
|
|
@@ -1684,11 +1801,17 @@ async function getSortedSessions(sessionDir: string, storage: SessionStorage): P
|
|
|
1684
1801
|
await Promise.all(
|
|
1685
1802
|
files.map(async (path: string) => {
|
|
1686
1803
|
try {
|
|
1687
|
-
const
|
|
1804
|
+
const buffer = Buffer.allocUnsafe(SESSION_LIST_PREFIX_BYTES);
|
|
1805
|
+
const content = await readSessionListPrefix(path, storage, buffer);
|
|
1688
1806
|
const entries = parseJsonlLenient<Record<string, unknown>>(content);
|
|
1689
1807
|
if (entries.length === 0) return;
|
|
1690
1808
|
const header = entries[0] as Record<string, unknown>;
|
|
1691
1809
|
if (header.type !== "session" || typeof header.id !== "string") return;
|
|
1810
|
+
if (header.version === CURRENT_SESSION_VERSION) {
|
|
1811
|
+
for (const patch of await readSessionListTrailingPatches(path, storage, buffer)) {
|
|
1812
|
+
applySessionListHeaderPatch(header as unknown as SessionListHeader, patch);
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1692
1815
|
const mtime = storage.statSync(path).mtimeMs;
|
|
1693
1816
|
const firstPrompt = header.title ? undefined : extractFirstUserPrompt(entries);
|
|
1694
1817
|
sessions.push(new RecentSessionInfo(path, mtime, header, firstPrompt));
|
|
@@ -1725,21 +1848,223 @@ function formatTimeAgo(date: Date): string {
|
|
|
1725
1848
|
return date.toLocaleDateString();
|
|
1726
1849
|
}
|
|
1727
1850
|
|
|
1728
|
-
async function
|
|
1851
|
+
async function syncPathAndParent(filePath: string): Promise<void> {
|
|
1852
|
+
const file = await fs.promises.open(filePath, "r");
|
|
1853
|
+
try {
|
|
1854
|
+
await file.sync();
|
|
1855
|
+
} finally {
|
|
1856
|
+
await file.close();
|
|
1857
|
+
}
|
|
1858
|
+
if (process.platform !== "win32") {
|
|
1859
|
+
const parent = await fs.promises.open(path.dirname(filePath), "r");
|
|
1860
|
+
try {
|
|
1861
|
+
await parent.sync();
|
|
1862
|
+
} finally {
|
|
1863
|
+
await parent.close();
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
interface SessionMoveDirectoryHandle {
|
|
1869
|
+
sync(): Promise<void>;
|
|
1870
|
+
close(): Promise<void>;
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
export async function syncSessionMoveDirectory(
|
|
1874
|
+
directory: string,
|
|
1875
|
+
platform: NodeJS.Platform = process.platform,
|
|
1876
|
+
openDirectory: (directory: string) => Promise<SessionMoveDirectoryHandle> = value => fs.promises.open(value, "r"),
|
|
1877
|
+
): Promise<void> {
|
|
1878
|
+
if (platform === "win32") return;
|
|
1879
|
+
const handle = await openDirectory(directory);
|
|
1729
1880
|
try {
|
|
1730
|
-
await
|
|
1881
|
+
await handle.sync();
|
|
1882
|
+
} finally {
|
|
1883
|
+
await handle.close();
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
/** Durably flush every copied file and directory before an EXDEV source removal. */
|
|
1888
|
+
async function syncCopiedTree(treePath: string): Promise<void> {
|
|
1889
|
+
const stat = await fs.promises.lstat(treePath);
|
|
1890
|
+
if (stat.isSymbolicLink()) throw new Error("Refusing to fsync a copied symbolic link during a cross-device move");
|
|
1891
|
+
if (stat.isFile()) {
|
|
1892
|
+
const file = await fs.promises.open(treePath, "r");
|
|
1893
|
+
try {
|
|
1894
|
+
await file.sync();
|
|
1895
|
+
} finally {
|
|
1896
|
+
await file.close();
|
|
1897
|
+
}
|
|
1731
1898
|
return;
|
|
1732
|
-
} catch (error) {
|
|
1733
|
-
if (!hasFsCode(error, "EXDEV")) throw error;
|
|
1734
1899
|
}
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1900
|
+
if (!stat.isDirectory()) throw new Error("Copied cross-device move contains an unsupported filesystem entry");
|
|
1901
|
+
const directory = await fs.promises.opendir(treePath);
|
|
1902
|
+
try {
|
|
1903
|
+
for (;;) {
|
|
1904
|
+
const entry = await directory.read();
|
|
1905
|
+
if (entry === null) break;
|
|
1906
|
+
await syncCopiedTree(path.join(treePath, entry.name));
|
|
1907
|
+
}
|
|
1908
|
+
} finally {
|
|
1909
|
+
await directory.close();
|
|
1910
|
+
}
|
|
1911
|
+
if (process.platform !== "win32") {
|
|
1912
|
+
const handle = await fs.promises.open(treePath, "r");
|
|
1913
|
+
try {
|
|
1914
|
+
await handle.sync();
|
|
1915
|
+
} finally {
|
|
1916
|
+
await handle.close();
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
type CrossDeviceTreeIdentity = string;
|
|
1922
|
+
|
|
1923
|
+
/** Captures content plus topology; a later capture must match before source removal. */
|
|
1924
|
+
async function captureCrossDeviceTreeIdentity(treePath: string): Promise<CrossDeviceTreeIdentity> {
|
|
1925
|
+
const before = await fs.promises.lstat(treePath, { bigint: true });
|
|
1926
|
+
if (before.isSymbolicLink()) throw new Error("Refusing to move a symbolic link across devices");
|
|
1927
|
+
if (before.isFile()) {
|
|
1928
|
+
const bytes = await fs.promises.readFile(treePath);
|
|
1929
|
+
const after = await fs.promises.lstat(treePath, { bigint: true });
|
|
1930
|
+
if (
|
|
1931
|
+
before.dev !== after.dev ||
|
|
1932
|
+
before.ino !== after.ino ||
|
|
1933
|
+
before.size !== after.size ||
|
|
1934
|
+
before.mtimeNs !== after.mtimeNs
|
|
1935
|
+
)
|
|
1936
|
+
throw new Error("Cross-device move source changed while its identity was captured");
|
|
1937
|
+
return `file:${before.dev}:${before.ino}:${before.size}:${before.mtimeNs}:${crypto.createHash("sha256").update(bytes).digest("hex")}`;
|
|
1938
|
+
}
|
|
1939
|
+
if (!before.isDirectory()) throw new Error("Cross-device move contains an unsupported filesystem entry");
|
|
1940
|
+
const directory = await fs.promises.opendir(treePath);
|
|
1941
|
+
const entries: string[] = [];
|
|
1942
|
+
try {
|
|
1943
|
+
for (;;) {
|
|
1944
|
+
const entry = await directory.read();
|
|
1945
|
+
if (entry === null) break;
|
|
1946
|
+
entries.push(entry.name);
|
|
1947
|
+
}
|
|
1948
|
+
} finally {
|
|
1949
|
+
await directory.close();
|
|
1950
|
+
}
|
|
1951
|
+
entries.sort();
|
|
1952
|
+
const children = await Promise.all(
|
|
1953
|
+
entries.map(
|
|
1954
|
+
async name => `${JSON.stringify(name)}=${await captureCrossDeviceTreeIdentity(path.join(treePath, name))}`,
|
|
1955
|
+
),
|
|
1956
|
+
);
|
|
1957
|
+
const after = await fs.promises.lstat(treePath, { bigint: true });
|
|
1958
|
+
if (
|
|
1959
|
+
before.dev !== after.dev ||
|
|
1960
|
+
before.ino !== after.ino ||
|
|
1961
|
+
before.size !== after.size ||
|
|
1962
|
+
before.mtimeNs !== after.mtimeNs
|
|
1963
|
+
)
|
|
1964
|
+
throw new Error("Cross-device move source topology changed while its identity was captured");
|
|
1965
|
+
return `directory:${before.dev}:${before.ino}:${before.size}:${before.mtimeNs}:[${children.join(",")}]`;
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
async function removeCrossDeviceSourceIfUnchanged(source: string, expected: CrossDeviceTreeIdentity): Promise<void> {
|
|
1969
|
+
if ((await captureCrossDeviceTreeIdentity(source)) !== expected)
|
|
1970
|
+
throw new Error("Cross-device move source changed before removal");
|
|
1971
|
+
const stat = await fs.promises.lstat(source);
|
|
1972
|
+
if (stat.isDirectory()) await fs.promises.rm(source, { recursive: true, force: false });
|
|
1973
|
+
else await fs.promises.unlink(source);
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
/**
|
|
1977
|
+
* An EXDEV retirement must be driven by a native descriptor/handle-bound
|
|
1978
|
+
* snapshot-copy-verify transaction. Do not fall back to pathname copy/remove:
|
|
1979
|
+
* that can retire a replacement after a crash or concurrent rename.
|
|
1980
|
+
*/
|
|
1981
|
+
class CrossDeviceMoveUnsupportedError extends Error {
|
|
1982
|
+
constructor(source: string, destination: string, cause?: unknown) {
|
|
1983
|
+
super(
|
|
1984
|
+
`Cross-device session move is unavailable for ${source} -> ${destination}: native atomic detach/copy/verify support is required.`,
|
|
1985
|
+
{ cause },
|
|
1986
|
+
);
|
|
1987
|
+
this.name = "CrossDeviceMoveUnsupportedError";
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
class PathMoveCompletedWithSyncFailure extends Error {
|
|
1992
|
+
constructor(readonly cause: unknown) {
|
|
1993
|
+
super("Path move completed but final source-directory sync failed.", { cause });
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
async function movePathAcrossDevicesSafe(source: string, destination: string): Promise<void> {
|
|
1998
|
+
const sourceIdentity = await captureCrossDeviceTreeIdentity(source);
|
|
1999
|
+
const renamed = native.renameNoReplacePath(source, destination);
|
|
2000
|
+
if (renamed.ok) {
|
|
2001
|
+
try {
|
|
2002
|
+
if ((await captureCrossDeviceTreeIdentity(destination)) !== sourceIdentity)
|
|
2003
|
+
throw new Error("Atomic session rename did not preserve the captured source identity");
|
|
2004
|
+
await syncSessionMoveDirectory(path.dirname(destination));
|
|
2005
|
+
if (path.dirname(source) !== path.dirname(destination)) await syncSessionMoveDirectory(path.dirname(source));
|
|
2006
|
+
} catch (error) {
|
|
2007
|
+
throw new PathMoveCompletedWithSyncFailure(error);
|
|
2008
|
+
}
|
|
1739
2009
|
return;
|
|
1740
2010
|
}
|
|
1741
|
-
|
|
1742
|
-
|
|
2011
|
+
if (renamed.code === "quarantine_collision") {
|
|
2012
|
+
const error = new Error(`Session move destination already exists: ${destination}`) as NodeJS.ErrnoException;
|
|
2013
|
+
error.code = "EEXIST";
|
|
2014
|
+
throw error;
|
|
2015
|
+
}
|
|
2016
|
+
if (renamed.code !== "atomic_unavailable")
|
|
2017
|
+
throw new Error(`Atomic session rename failed: ${renamed.code ?? "unknown"}`);
|
|
2018
|
+
const sourceStat = await fs.promises.lstat(source);
|
|
2019
|
+
if (!sourceStat.isDirectory()) {
|
|
2020
|
+
let destinationCreated = false;
|
|
2021
|
+
let sourceRemoved = false;
|
|
2022
|
+
try {
|
|
2023
|
+
try {
|
|
2024
|
+
await fs.promises.link(source, destination);
|
|
2025
|
+
destinationCreated = true;
|
|
2026
|
+
} catch (error) {
|
|
2027
|
+
if (hasFsCode(error, "EXDEV")) throw new CrossDeviceMoveUnsupportedError(source, destination, error);
|
|
2028
|
+
if (
|
|
2029
|
+
!hasFsCode(error, "EPERM") &&
|
|
2030
|
+
!hasFsCode(error, "EACCES") &&
|
|
2031
|
+
!hasFsCode(error, "ENOTSUP") &&
|
|
2032
|
+
!hasFsCode(error, "EOPNOTSUPP")
|
|
2033
|
+
)
|
|
2034
|
+
throw error;
|
|
2035
|
+
await fs.promises.copyFile(source, destination, fs.constants.COPYFILE_EXCL);
|
|
2036
|
+
destinationCreated = true;
|
|
2037
|
+
}
|
|
2038
|
+
await syncPathAndParent(destination);
|
|
2039
|
+
await removeCrossDeviceSourceIfUnchanged(source, sourceIdentity);
|
|
2040
|
+
sourceRemoved = true;
|
|
2041
|
+
await syncSessionMoveDirectory(path.dirname(source));
|
|
2042
|
+
return;
|
|
2043
|
+
} catch (error) {
|
|
2044
|
+
if (!sourceRemoved && destinationCreated) await fs.promises.unlink(destination).catch(() => undefined);
|
|
2045
|
+
if (sourceRemoved && !(error instanceof PathMoveCompletedWithSyncFailure))
|
|
2046
|
+
throw new PathMoveCompletedWithSyncFailure(error);
|
|
2047
|
+
throw error;
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
const destinationParent = await fs.promises.lstat(path.dirname(destination));
|
|
2052
|
+
if (sourceStat.dev !== destinationParent.dev) throw new CrossDeviceMoveUnsupportedError(source, destination);
|
|
2053
|
+
|
|
2054
|
+
try {
|
|
2055
|
+
await fs.promises.cp(source, destination, { recursive: true, force: false, errorOnExist: true });
|
|
2056
|
+
await syncCopiedTree(destination);
|
|
2057
|
+
await removeCrossDeviceSourceIfUnchanged(source, sourceIdentity);
|
|
2058
|
+
try {
|
|
2059
|
+
await syncSessionMoveDirectory(path.dirname(source));
|
|
2060
|
+
} catch (error) {
|
|
2061
|
+
throw new PathMoveCompletedWithSyncFailure(error);
|
|
2062
|
+
}
|
|
2063
|
+
} catch (error) {
|
|
2064
|
+
if (!(error instanceof PathMoveCompletedWithSyncFailure))
|
|
2065
|
+
await fs.promises.rm(destination, { recursive: true, force: true }).catch(() => undefined);
|
|
2066
|
+
throw error;
|
|
2067
|
+
}
|
|
1743
2068
|
}
|
|
1744
2069
|
|
|
1745
2070
|
const MAX_PERSIST_CHARS = 500_000;
|
|
@@ -2091,19 +2416,12 @@ function cloneJsonSemantic<T>(value: T): T {
|
|
|
2091
2416
|
return cloned as T;
|
|
2092
2417
|
}
|
|
2093
2418
|
|
|
2094
|
-
function cloneAgentMessage<T extends AgentMessage>(message: T): T {
|
|
2095
|
-
const cloned = {
|
|
2096
|
-
...message,
|
|
2097
|
-
...("content" in message ? { content: cloneJsonSemantic(message.content) } : {}),
|
|
2098
|
-
...("providerPayload" in message ? { providerPayload: cloneJsonSemantic(message.providerPayload) } : {}),
|
|
2099
|
-
} as T;
|
|
2100
|
-
transferSessionMessageIdentity([message], [cloned]);
|
|
2101
|
-
return cloned;
|
|
2102
|
-
}
|
|
2103
|
-
|
|
2104
2419
|
function cloneSessionEntry(entry: SessionEntry): SessionEntry {
|
|
2105
|
-
|
|
2106
|
-
|
|
2420
|
+
const cloned = cloneJsonSemantic(entry);
|
|
2421
|
+
if (entry.type === "message" && cloned.type === "message") {
|
|
2422
|
+
transferSessionMessageIdentity([entry.message], [cloned.message]);
|
|
2423
|
+
}
|
|
2424
|
+
return cloned;
|
|
2107
2425
|
}
|
|
2108
2426
|
|
|
2109
2427
|
function materializeProviderVisibleEntrySync(entry: SessionEntry, stores: ResidentBlobStores): SessionEntry {
|
|
@@ -2725,7 +3043,7 @@ class NdjsonFileWriter {
|
|
|
2725
3043
|
}
|
|
2726
3044
|
|
|
2727
3045
|
/** Queue a write. Returns a promise so callers can await if needed. */
|
|
2728
|
-
write(entry: FileEntry): Promise<void> {
|
|
3046
|
+
write(entry: FileEntry | SessionPatchRecord): Promise<void> {
|
|
2729
3047
|
if (this.#closed || this.#closing) throw new Error("Writer closed");
|
|
2730
3048
|
if (this.#error) throw this.#error;
|
|
2731
3049
|
const line = `${JSON.stringify(entry)}\n`;
|
|
@@ -2741,7 +3059,7 @@ class NdjsonFileWriter {
|
|
|
2741
3059
|
* queue. Use only when no concurrent async write is in flight (the session-manager
|
|
2742
3060
|
* persist path enforces this via `#flushed`/`#needsFullRewriteOnNextPersist`).
|
|
2743
3061
|
*/
|
|
2744
|
-
writeSync(entry: FileEntry): void {
|
|
3062
|
+
writeSync(entry: FileEntry | SessionPatchRecord): void {
|
|
2745
3063
|
if (this.#closed || this.#closing) throw new Error("Writer closed");
|
|
2746
3064
|
if (this.#error) throw this.#error;
|
|
2747
3065
|
const line = `${JSON.stringify(entry)}\n`;
|
|
@@ -2936,6 +3254,14 @@ function extractTextFromContent(content: Message["content"]): string {
|
|
|
2936
3254
|
}
|
|
2937
3255
|
|
|
2938
3256
|
const SESSION_LIST_PREFIX_BYTES = 4096;
|
|
3257
|
+
const SESSION_LIST_TRAILING_PATCH_BYTES = 4096;
|
|
3258
|
+
// Hard cap on how many trailing bytes the listers may scan for header patches.
|
|
3259
|
+
// moveTo folds cwd patches into line 1 via a full atomic rewrite, so only a
|
|
3260
|
+
// recent title patch can live unfolded near EOF; anything buried deeper is
|
|
3261
|
+
// folded on the next natural full rewrite. Keeping this bounded preserves the
|
|
3262
|
+
// O(SESSION_LIST_PREFIX_BYTES) listing cost that SESSION_LIST_PREFIX_BYTES exists for.
|
|
3263
|
+
const SESSION_LIST_TRAILING_PATCH_SCAN_CAP = SESSION_LIST_TRAILING_PATCH_BYTES * 4;
|
|
3264
|
+
const SESSION_NAME_MAX_CHARS = 1_000;
|
|
2939
3265
|
const SESSION_LIST_PARALLEL_THRESHOLD = 64;
|
|
2940
3266
|
const SESSION_LIST_MAX_WORKERS = 16;
|
|
2941
3267
|
const sessionListPrefixDecoder = new TextDecoder("utf-8", { fatal: false });
|
|
@@ -2955,6 +3281,69 @@ async function readSessionListPrefix(file: string, storage: SessionStorage, buff
|
|
|
2955
3281
|
}
|
|
2956
3282
|
}
|
|
2957
3283
|
|
|
3284
|
+
async function readSessionListTrailingPatches(
|
|
3285
|
+
file: string,
|
|
3286
|
+
storage: SessionStorage,
|
|
3287
|
+
buffer: Buffer,
|
|
3288
|
+
): Promise<HeaderPatchRecord["patch"][]> {
|
|
3289
|
+
if (!(storage instanceof FileSessionStorage)) {
|
|
3290
|
+
const content = await storage.readText(file);
|
|
3291
|
+
const entries = parseSessionEntries(content);
|
|
3292
|
+
const header = entries[0] as SessionHeader | undefined;
|
|
3293
|
+
return header?.type === "session" ? [{ cwd: header.cwd, title: header.title }] : [];
|
|
3294
|
+
}
|
|
3295
|
+
|
|
3296
|
+
const size = storage.statSync(file).size;
|
|
3297
|
+
if (size <= SESSION_LIST_PREFIX_BYTES) return [];
|
|
3298
|
+
const latest: HeaderPatchRecord["patch"] = {};
|
|
3299
|
+
let position = size;
|
|
3300
|
+
const scanFloor = Math.max(0, size - SESSION_LIST_TRAILING_PATCH_SCAN_CAP);
|
|
3301
|
+
let trailingFragment = Buffer.alloc(0);
|
|
3302
|
+
const chunkSize = Math.min(buffer.byteLength, SESSION_LIST_TRAILING_PATCH_BYTES);
|
|
3303
|
+
const handle = await fs.promises.open(file, "r");
|
|
3304
|
+
try {
|
|
3305
|
+
while (position > scanFloor && (latest.cwd === undefined || latest.title === undefined)) {
|
|
3306
|
+
const start = Math.max(0, position - chunkSize);
|
|
3307
|
+
const length = position - start;
|
|
3308
|
+
const { bytesRead } = await handle.read(buffer, 0, length, start);
|
|
3309
|
+
const combined = Buffer.concat([buffer.subarray(0, bytesRead), trailingFragment]);
|
|
3310
|
+
let complete = combined;
|
|
3311
|
+
if (start > 0) {
|
|
3312
|
+
const firstNewline = combined.indexOf(0x0a);
|
|
3313
|
+
if (firstNewline === -1) {
|
|
3314
|
+
trailingFragment = combined;
|
|
3315
|
+
position = start;
|
|
3316
|
+
continue;
|
|
3317
|
+
}
|
|
3318
|
+
trailingFragment = combined.subarray(0, firstNewline);
|
|
3319
|
+
complete = combined.subarray(firstNewline + 1);
|
|
3320
|
+
}
|
|
3321
|
+
const lines = complete.toString("utf8").split("\n");
|
|
3322
|
+
for (let index = lines.length - 1; index >= 0; index--) {
|
|
3323
|
+
const line = lines[index];
|
|
3324
|
+
if (!line) continue;
|
|
3325
|
+
try {
|
|
3326
|
+
const record = JSON.parse(line) as SessionPatchRecord;
|
|
3327
|
+
if (!isHeaderPatchRecord(record)) continue;
|
|
3328
|
+
if (latest.cwd === undefined && typeof record.patch.cwd === "string") latest.cwd = record.patch.cwd;
|
|
3329
|
+
if (latest.title === undefined && typeof record.patch.title === "string")
|
|
3330
|
+
latest.title = record.patch.title;
|
|
3331
|
+
} catch {
|
|
3332
|
+
// Ignore malformed or partial records exactly as the canonical loader does.
|
|
3333
|
+
}
|
|
3334
|
+
}
|
|
3335
|
+
position = start;
|
|
3336
|
+
}
|
|
3337
|
+
return latest.cwd === undefined && latest.title === undefined ? [] : [latest];
|
|
3338
|
+
} finally {
|
|
3339
|
+
await handle.close();
|
|
3340
|
+
}
|
|
3341
|
+
}
|
|
3342
|
+
|
|
3343
|
+
function applySessionListHeaderPatch(header: SessionListHeader, patch: HeaderPatchRecord["patch"]): void {
|
|
3344
|
+
if (typeof patch.cwd === "string") header.cwd = patch.cwd;
|
|
3345
|
+
if (typeof patch.title === "string") header.title = patch.title;
|
|
3346
|
+
}
|
|
2958
3347
|
function decodeJsonStringFragment(value: string): string {
|
|
2959
3348
|
const safeValue = value.endsWith("\\") ? value.slice(0, -1) : value;
|
|
2960
3349
|
try {
|
|
@@ -3038,6 +3427,8 @@ function extractFirstUserMessageFromPrefix(content: string): string | undefined
|
|
|
3038
3427
|
interface SessionListHeader {
|
|
3039
3428
|
type: "session";
|
|
3040
3429
|
id: string;
|
|
3430
|
+
version?: number;
|
|
3431
|
+
|
|
3041
3432
|
cwd?: string;
|
|
3042
3433
|
title?: string;
|
|
3043
3434
|
parentSession?: string;
|
|
@@ -3053,6 +3444,7 @@ function parseSessionListHeader(
|
|
|
3053
3444
|
return {
|
|
3054
3445
|
type: "session",
|
|
3055
3446
|
id: parsedHeader.id,
|
|
3447
|
+
version: typeof parsedHeader.version === "number" ? parsedHeader.version : undefined,
|
|
3056
3448
|
cwd: typeof parsedHeader.cwd === "string" ? parsedHeader.cwd : undefined,
|
|
3057
3449
|
title: typeof parsedHeader.title === "string" ? parsedHeader.title : undefined,
|
|
3058
3450
|
parentSession: typeof parsedHeader.parentSession === "string" ? parsedHeader.parentSession : undefined,
|
|
@@ -3070,6 +3462,7 @@ function parseSessionListHeader(
|
|
|
3070
3462
|
return {
|
|
3071
3463
|
type: "session",
|
|
3072
3464
|
id,
|
|
3465
|
+
version: Number(extractStringProperty(firstLine, "version")) || undefined,
|
|
3073
3466
|
cwd: extractStringProperty(firstLine, "cwd"),
|
|
3074
3467
|
title: extractStringProperty(firstLine, "title"),
|
|
3075
3468
|
parentSession: extractStringProperty(firstLine, "parentSession"),
|
|
@@ -3093,9 +3486,14 @@ async function collectSessionFromFile(
|
|
|
3093
3486
|
): Promise<SessionInfo | undefined> {
|
|
3094
3487
|
try {
|
|
3095
3488
|
const content = await readSessionListPrefix(file, storage, buffer);
|
|
3096
|
-
const entries =
|
|
3489
|
+
const entries = parseSessionEntries(content).map(entry => entry as unknown as Record<string, unknown>);
|
|
3097
3490
|
const header = parseSessionListHeader(content, entries);
|
|
3098
3491
|
if (!header) return undefined;
|
|
3492
|
+
if (header.version === CURRENT_SESSION_VERSION) {
|
|
3493
|
+
for (const patch of await readSessionListTrailingPatches(file, storage, buffer)) {
|
|
3494
|
+
applySessionListHeaderPatch(header, patch);
|
|
3495
|
+
}
|
|
3496
|
+
}
|
|
3099
3497
|
|
|
3100
3498
|
let parsedMessageCount = 0;
|
|
3101
3499
|
let firstMessage = "";
|
|
@@ -3138,6 +3536,7 @@ async function collectSessionFromFile(
|
|
|
3138
3536
|
created: new Date(header.timestamp ?? ""),
|
|
3139
3537
|
modified: stats.mtime,
|
|
3140
3538
|
messageCount,
|
|
3539
|
+
messageCountIsEstimate: stats.size > SESSION_LIST_PREFIX_BYTES,
|
|
3141
3540
|
size: stats.size,
|
|
3142
3541
|
firstMessage: firstMessage || "(no messages)",
|
|
3143
3542
|
allMessagesText: allMessages.length > 0 ? allMessages.join(" ") : firstMessage,
|
|
@@ -3271,6 +3670,7 @@ export class SessionManager {
|
|
|
3271
3670
|
#persistChain: Promise<void> = Promise.resolve();
|
|
3272
3671
|
#persistError: Error | undefined;
|
|
3273
3672
|
#persistErrorReported = false;
|
|
3673
|
+
|
|
3274
3674
|
#artifactManager: ArtifactManager | null = null;
|
|
3275
3675
|
#artifactManagerSessionFile: string | null = null;
|
|
3276
3676
|
// When set, take precedence over the lazily-derived per-session manager.
|
|
@@ -3293,7 +3693,7 @@ export class SessionManager {
|
|
|
3293
3693
|
#replayMetadataRevision = 0;
|
|
3294
3694
|
#materializedEntriesRevision = -1;
|
|
3295
3695
|
#materializedEntriesCache: SessionEntry[] | undefined;
|
|
3296
|
-
#sessionContextCache:
|
|
3696
|
+
#sessionContextCache: SessionContext | undefined;
|
|
3297
3697
|
#sessionContextEntryRevision = -1;
|
|
3298
3698
|
#sessionContextLeafRevision = -1;
|
|
3299
3699
|
#sessionContextReplayMetadataRevision = -1;
|
|
@@ -3307,6 +3707,11 @@ export class SessionManager {
|
|
|
3307
3707
|
#getEntriesMaterializerCallCount = 0;
|
|
3308
3708
|
#materializedEntriesCachePopulateCount = 0;
|
|
3309
3709
|
#pathOnlyContextBuildCount = 0;
|
|
3710
|
+
#internalReadAccess: SessionManagerReadAccess = {
|
|
3711
|
+
getEntries: () => freezeInternalReadSnapshot(this.#getMaterializedEntriesInternal()),
|
|
3712
|
+
getSessionContext: () => this.#getSessionContextForRead(),
|
|
3713
|
+
getTree: () => this.#getTree(freezeInternalReadSnapshot(this.#getMaterializedEntriesInternal())),
|
|
3714
|
+
};
|
|
3310
3715
|
|
|
3311
3716
|
private constructor(
|
|
3312
3717
|
private cwd: string,
|
|
@@ -3318,6 +3723,13 @@ export class SessionManager {
|
|
|
3318
3723
|
this.#residentImageBlobStore = this.#blobStore;
|
|
3319
3724
|
if (persist && sessionDir) {
|
|
3320
3725
|
this.storage.ensureDirSync(sessionDir);
|
|
3726
|
+
// Canonicalize the trusted session directory (single choke point for every
|
|
3727
|
+
// creation path: create/open/moveTo/fork/SDK) so benign ancestor symlinks
|
|
3728
|
+
// (e.g. macOS `/var -> /private/var`, a symlinked `$HOME`) are resolved to a
|
|
3729
|
+
// symlink-free root before the strict owner-only and reparse guards run.
|
|
3730
|
+
if (this.storage instanceof FileSessionStorage) {
|
|
3731
|
+
this.sessionDir = canonicalizeTrustedPath(sessionDir);
|
|
3732
|
+
}
|
|
3321
3733
|
}
|
|
3322
3734
|
// Note: call _initSession() or _initSessionFile() after construction
|
|
3323
3735
|
}
|
|
@@ -3481,19 +3893,19 @@ export class SessionManager {
|
|
|
3481
3893
|
}
|
|
3482
3894
|
|
|
3483
3895
|
async #hydrateExistingSession(sessionFile: string, entries: FileEntry[], migrationApplied: boolean): Promise<void> {
|
|
3484
|
-
const
|
|
3485
|
-
|
|
3896
|
+
const ownedEntries = structuredClone(entries) as FileEntry[];
|
|
3897
|
+
const header = ownedEntries[0] as SessionHeader;
|
|
3898
|
+
this.#sessionFile = this.storage instanceof FileSessionStorage ? path.resolve(sessionFile) : sessionFile;
|
|
3486
3899
|
this.#sessionId = header.id;
|
|
3487
3900
|
this.#sessionName = header.title;
|
|
3488
3901
|
this.#titleSource = header.titleSource;
|
|
3489
3902
|
this.#needsFullRewriteOnNextPersist = migrationApplied;
|
|
3490
|
-
await resolveBlobRefsInEntries(
|
|
3491
|
-
this.#fileEntries =
|
|
3903
|
+
await resolveBlobRefsInEntries(ownedEntries, this.#blobStore);
|
|
3904
|
+
this.#fileEntries = ownedEntries;
|
|
3492
3905
|
this.#resetResidentTextBlobStore();
|
|
3493
3906
|
this.#fileEntries = this.#fileEntries.map(entry =>
|
|
3494
3907
|
prepareEntryForResidentSync(entry, this.#residentBlobStores()),
|
|
3495
3908
|
);
|
|
3496
|
-
this.sanitizeLoadedOpenAIResponsesReplayMetadata();
|
|
3497
3909
|
this.#buildIndex();
|
|
3498
3910
|
this.#bumpAllRevisions();
|
|
3499
3911
|
this.#flushed = true;
|
|
@@ -3511,7 +3923,7 @@ export class SessionManager {
|
|
|
3511
3923
|
await this.#closePersistWriter();
|
|
3512
3924
|
this.#persistError = undefined;
|
|
3513
3925
|
this.#persistErrorReported = false;
|
|
3514
|
-
this.#sessionFile = path.resolve(sessionFile);
|
|
3926
|
+
this.#sessionFile = this.storage instanceof FileSessionStorage ? path.resolve(sessionFile) : sessionFile;
|
|
3515
3927
|
writeTerminalBreadcrumb(this.cwd, this.#sessionFile);
|
|
3516
3928
|
this.#fileEntries = await loadEntriesFromFile(this.#sessionFile, this.storage);
|
|
3517
3929
|
if (this.#fileEntries.length > 0) {
|
|
@@ -3527,12 +3939,12 @@ export class SessionManager {
|
|
|
3527
3939
|
this.#fileEntries = this.#fileEntries.map(entry =>
|
|
3528
3940
|
prepareEntryForResidentSync(entry, this.#residentBlobStores()),
|
|
3529
3941
|
);
|
|
3530
|
-
this.sanitizeLoadedOpenAIResponsesReplayMetadata();
|
|
3531
3942
|
|
|
3532
3943
|
this.#buildIndex();
|
|
3533
3944
|
this.#bumpAllRevisions();
|
|
3534
3945
|
this.#flushed = true;
|
|
3535
3946
|
this.#ensuredOnDisk = true;
|
|
3947
|
+
await this.#sanitizeLoadedOpenAIResponsesReplayMetadataAndPersist();
|
|
3536
3948
|
} else {
|
|
3537
3949
|
const explicitPath = this.#sessionFile;
|
|
3538
3950
|
this.#newSessionSync();
|
|
@@ -3554,11 +3966,35 @@ export class SessionManager {
|
|
|
3554
3966
|
return sessionFile;
|
|
3555
3967
|
}
|
|
3556
3968
|
|
|
3557
|
-
/**
|
|
3969
|
+
/** Tombstone and exact-delete managed transcripts, detaching the active transcript first. */
|
|
3558
3970
|
async dropSession(sessionPath: string): Promise<void> {
|
|
3971
|
+
const requestedPath = path.resolve(sessionPath);
|
|
3972
|
+
if (this.#sessionFile && path.resolve(this.#sessionFile) === requestedPath) {
|
|
3973
|
+
await this.newSession();
|
|
3974
|
+
}
|
|
3559
3975
|
await this.#closePersistWriter();
|
|
3976
|
+
const managedRoot = resolveManagedSessionRoot(this.sessionDir, this.cwd);
|
|
3560
3977
|
try {
|
|
3561
|
-
|
|
3978
|
+
if (managedRoot) {
|
|
3979
|
+
if (!pathIsWithin(managedRoot, requestedPath))
|
|
3980
|
+
throw new Error("Managed session deletion is limited to this manager's configured session root.");
|
|
3981
|
+
const resolved = resolveManagedScope({
|
|
3982
|
+
cwd: this.cwd,
|
|
3983
|
+
agentDir: path.resolve(managedRoot, ".."),
|
|
3984
|
+
sessionsRoot: managedRoot,
|
|
3985
|
+
});
|
|
3986
|
+
if (resolved.kind !== "resolved")
|
|
3987
|
+
throw new Error("Managed session storage could not be resolved for this manager.");
|
|
3988
|
+
const listing = listManagedCandidates(resolved.scope);
|
|
3989
|
+
if (listing.kind !== "complete")
|
|
3990
|
+
throw new Error("Managed session storage could not be verified; refusing deletion.");
|
|
3991
|
+
const candidate = listing.owned.find(candidate => path.resolve(candidate.path) === requestedPath);
|
|
3992
|
+
if (!candidate) throw new Error("Managed session deletion requires exact logical authorization.");
|
|
3993
|
+
const deleted = await deleteManagedSessionCandidate(resolved.scope, candidate);
|
|
3994
|
+
if (deleted.kind === "error") throw new Error(`Could not delete managed session: ${deleted.message}`);
|
|
3995
|
+
} else {
|
|
3996
|
+
await this.storage.deleteSessionWithArtifacts(sessionPath);
|
|
3997
|
+
}
|
|
3562
3998
|
} catch (err) {
|
|
3563
3999
|
if (isEnoent(err)) return;
|
|
3564
4000
|
throw err;
|
|
@@ -3628,11 +4064,19 @@ export class SessionManager {
|
|
|
3628
4064
|
async moveTo(newCwd: string): Promise<void> {
|
|
3629
4065
|
const resolvedCwd = path.resolve(newCwd);
|
|
3630
4066
|
if (resolvedCwd === this.cwd) return;
|
|
3631
|
-
|
|
3632
|
-
const
|
|
3633
|
-
const
|
|
3634
|
-
|
|
3635
|
-
|
|
4067
|
+
const previousCwd = this.cwd;
|
|
4068
|
+
const previousSessionDir = this.sessionDir;
|
|
4069
|
+
const previousSessionFile = this.#sessionFile;
|
|
4070
|
+
const previousArtifactDir = previousSessionFile?.slice(0, -6);
|
|
4071
|
+
|
|
4072
|
+
const managedSessionsRoot =
|
|
4073
|
+
this.storage instanceof FileSessionStorage ? resolveManagedSessionRoot(this.sessionDir, this.cwd) : undefined;
|
|
4074
|
+
const newSessionDir =
|
|
4075
|
+
this.storage instanceof FileSessionStorage
|
|
4076
|
+
? managedSessionsRoot
|
|
4077
|
+
? computeDefaultSessionDir(resolvedCwd, this.storage, managedSessionsRoot)
|
|
4078
|
+
: computeDefaultSessionDir(resolvedCwd, this.storage)
|
|
4079
|
+
: this.sessionDir;
|
|
3636
4080
|
let hadSessionFile = false;
|
|
3637
4081
|
|
|
3638
4082
|
if (this.persist && this.#sessionFile) {
|
|
@@ -3674,15 +4118,25 @@ export class SessionManager {
|
|
|
3674
4118
|
try {
|
|
3675
4119
|
// Guard: session file may not exist yet (no assistant messages persisted)
|
|
3676
4120
|
if (hadSessionFile) {
|
|
3677
|
-
|
|
3678
|
-
|
|
4121
|
+
try {
|
|
4122
|
+
await movePathAcrossDevicesSafe(oldSessionFile, newSessionFile);
|
|
4123
|
+
movedSessionFile = true;
|
|
4124
|
+
} catch (error) {
|
|
4125
|
+
if (error instanceof PathMoveCompletedWithSyncFailure) movedSessionFile = true;
|
|
4126
|
+
throw error;
|
|
4127
|
+
}
|
|
3679
4128
|
}
|
|
3680
4129
|
|
|
3681
4130
|
try {
|
|
3682
4131
|
const stat = await fs.promises.stat(oldArtifactDir);
|
|
3683
4132
|
if (stat.isDirectory()) {
|
|
3684
|
-
|
|
3685
|
-
|
|
4133
|
+
try {
|
|
4134
|
+
await movePathAcrossDevicesSafe(oldArtifactDir, newArtifactDir);
|
|
4135
|
+
movedArtifactDir = true;
|
|
4136
|
+
} catch (error) {
|
|
4137
|
+
if (error instanceof PathMoveCompletedWithSyncFailure) movedArtifactDir = true;
|
|
4138
|
+
throw error;
|
|
4139
|
+
}
|
|
3686
4140
|
}
|
|
3687
4141
|
} catch (err) {
|
|
3688
4142
|
if (!isEnoent(err)) throw err;
|
|
@@ -3690,7 +4144,7 @@ export class SessionManager {
|
|
|
3690
4144
|
} catch (err) {
|
|
3691
4145
|
if (movedArtifactDir) {
|
|
3692
4146
|
try {
|
|
3693
|
-
await
|
|
4147
|
+
await movePathAcrossDevicesSafe(newArtifactDir, oldArtifactDir);
|
|
3694
4148
|
} catch (rollbackErr) {
|
|
3695
4149
|
restoreResidentStateAndThrow(
|
|
3696
4150
|
new Error(
|
|
@@ -3701,7 +4155,7 @@ export class SessionManager {
|
|
|
3701
4155
|
}
|
|
3702
4156
|
if (movedSessionFile) {
|
|
3703
4157
|
try {
|
|
3704
|
-
await
|
|
4158
|
+
await movePathAcrossDevicesSafe(newSessionFile, oldSessionFile);
|
|
3705
4159
|
} catch (rollbackErr) {
|
|
3706
4160
|
restoreResidentStateAndThrow(
|
|
3707
4161
|
new Error(
|
|
@@ -3719,27 +4173,63 @@ export class SessionManager {
|
|
|
3719
4173
|
this.#bumpAllRevisions();
|
|
3720
4174
|
}
|
|
3721
4175
|
|
|
3722
|
-
// Update cwd and sessionDir after the move succeeds.
|
|
4176
|
+
// Update cwd and sessionDir after the physical move succeeds, but roll the move back if metadata persistence fails.
|
|
3723
4177
|
this.cwd = resolvedCwd;
|
|
3724
4178
|
this.sessionDir = newSessionDir;
|
|
3725
4179
|
|
|
3726
|
-
// Update the session header in fileEntries
|
|
3727
|
-
const header = this.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
|
|
3728
|
-
if (header) {
|
|
3729
|
-
header.cwd = resolvedCwd;
|
|
3730
|
-
this.#headerExportRevision++;
|
|
3731
|
-
}
|
|
3732
|
-
|
|
3733
|
-
// Rewrite the session file at its new location with updated header.
|
|
3734
|
-
// hadSessionFile: file existed before move → must rewrite to update cwd
|
|
3735
|
-
// hasAssistant: assistant messages in memory but file missing → recreate from memory
|
|
3736
|
-
// Neither true → fresh session, never written → preserve lazy-persist
|
|
3737
4180
|
const hasAssistant = this.#fileEntries.some(e => e.type === "message" && e.message.role === "assistant");
|
|
3738
|
-
|
|
3739
|
-
|
|
4181
|
+
try {
|
|
4182
|
+
if (this.persist && this.#sessionFile && hadSessionFile) {
|
|
4183
|
+
await this.#appendHeaderPatch({ cwd: resolvedCwd });
|
|
4184
|
+
await this.#rewriteFile();
|
|
4185
|
+
} else if (this.persist && this.#sessionFile && hasAssistant) {
|
|
4186
|
+
await this.#appendHeaderPatch({ cwd: resolvedCwd });
|
|
4187
|
+
await this.#rewriteFile();
|
|
4188
|
+
} else {
|
|
4189
|
+
await this.#appendHeaderPatch({ cwd: resolvedCwd });
|
|
4190
|
+
}
|
|
4191
|
+
} catch (error) {
|
|
4192
|
+
const failedSessionFile = this.#sessionFile;
|
|
4193
|
+
const failedArtifactDir = failedSessionFile?.slice(0, -6);
|
|
4194
|
+
try {
|
|
4195
|
+
await this.#closePersistWriter().catch(() => {});
|
|
4196
|
+
this.#persistChain = Promise.resolve();
|
|
4197
|
+
this.#persistError = undefined;
|
|
4198
|
+
this.#persistErrorReported = false;
|
|
4199
|
+
if (
|
|
4200
|
+
this.storage instanceof FileSessionStorage &&
|
|
4201
|
+
failedArtifactDir &&
|
|
4202
|
+
previousArtifactDir &&
|
|
4203
|
+
failedArtifactDir !== previousArtifactDir &&
|
|
4204
|
+
fs.existsSync(failedArtifactDir)
|
|
4205
|
+
) {
|
|
4206
|
+
await movePathAcrossDevicesSafe(failedArtifactDir, previousArtifactDir);
|
|
4207
|
+
}
|
|
4208
|
+
if (
|
|
4209
|
+
this.storage instanceof FileSessionStorage &&
|
|
4210
|
+
failedSessionFile &&
|
|
4211
|
+
previousSessionFile &&
|
|
4212
|
+
failedSessionFile !== previousSessionFile &&
|
|
4213
|
+
fs.existsSync(failedSessionFile)
|
|
4214
|
+
) {
|
|
4215
|
+
await movePathAcrossDevicesSafe(failedSessionFile, previousSessionFile);
|
|
4216
|
+
}
|
|
4217
|
+
this.#sessionFile = previousSessionFile;
|
|
4218
|
+
this.cwd = previousCwd;
|
|
4219
|
+
this.sessionDir = previousSessionDir;
|
|
4220
|
+
const header = this.#fileEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
|
|
4221
|
+
if (header) applyHeaderPatch(header, { cwd: previousCwd });
|
|
4222
|
+
this.#headerExportRevision++;
|
|
4223
|
+
if (previousSessionFile && this.storage.existsSync(previousSessionFile)) await this.#rewriteFile();
|
|
4224
|
+
} catch (rollbackError) {
|
|
4225
|
+
throw new Error(`Failed to rollback session move: ${toError(rollbackError).message}`, {
|
|
4226
|
+
cause: toError(error),
|
|
4227
|
+
});
|
|
4228
|
+
}
|
|
4229
|
+
throw error;
|
|
3740
4230
|
}
|
|
3741
4231
|
|
|
3742
|
-
// Update terminal breadcrumb
|
|
4232
|
+
// Update terminal breadcrumb only after the durable cwd transition succeeds.
|
|
3743
4233
|
if (this.#sessionFile) {
|
|
3744
4234
|
writeTerminalBreadcrumb(resolvedCwd, this.#sessionFile);
|
|
3745
4235
|
}
|
|
@@ -3777,6 +4267,9 @@ export class SessionManager {
|
|
|
3777
4267
|
this.#usageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, premiumRequests: 0, cost: 0 };
|
|
3778
4268
|
this.#inMemoryArtifacts = null;
|
|
3779
4269
|
this.#inMemoryArtifactCounter = 0;
|
|
4270
|
+
this.#artifactManager = null;
|
|
4271
|
+
this.#artifactManagerSessionFile = null;
|
|
4272
|
+
this.#adoptedArtifactManager = null;
|
|
3780
4273
|
|
|
3781
4274
|
if (this.persist) {
|
|
3782
4275
|
const fileTimestamp = timestamp.replace(/[:.]/g, "-");
|
|
@@ -4104,19 +4597,23 @@ export class SessionManager {
|
|
|
4104
4597
|
}
|
|
4105
4598
|
}
|
|
4106
4599
|
|
|
4107
|
-
async #
|
|
4600
|
+
async #rewriteFileContents(): Promise<void> {
|
|
4108
4601
|
if (!this.persist || !this.#sessionFile) return;
|
|
4602
|
+
await this.#closePersistWriterInternal();
|
|
4603
|
+
const entries = await Promise.all(
|
|
4604
|
+
materializeResidentEntriesForPersistenceSync(this.#fileEntries, this.#residentBlobStores()).map(entry =>
|
|
4605
|
+
prepareEntryForPersistence(entry, this.#blobStore),
|
|
4606
|
+
),
|
|
4607
|
+
);
|
|
4608
|
+
await this.#writeEntriesAtomically(entries);
|
|
4609
|
+
this.#needsFullRewriteOnNextPersist = false;
|
|
4610
|
+
this.#flushed = true;
|
|
4611
|
+
this.#ensuredOnDisk = true;
|
|
4612
|
+
}
|
|
4613
|
+
|
|
4614
|
+
async #rewriteFile(): Promise<void> {
|
|
4109
4615
|
await this.#queuePersistTask(async () => {
|
|
4110
|
-
await this.#
|
|
4111
|
-
const entries = await Promise.all(
|
|
4112
|
-
materializeResidentEntriesForPersistenceSync(this.#fileEntries, this.#residentBlobStores()).map(entry =>
|
|
4113
|
-
prepareEntryForPersistence(entry, this.#blobStore),
|
|
4114
|
-
),
|
|
4115
|
-
);
|
|
4116
|
-
await this.#writeEntriesAtomically(entries);
|
|
4117
|
-
this.#needsFullRewriteOnNextPersist = false;
|
|
4118
|
-
this.#flushed = true;
|
|
4119
|
-
this.#ensuredOnDisk = true;
|
|
4616
|
+
await this.#rewriteFileContents();
|
|
4120
4617
|
});
|
|
4121
4618
|
}
|
|
4122
4619
|
|
|
@@ -4520,7 +5017,8 @@ export class SessionManager {
|
|
|
4520
5017
|
return name
|
|
4521
5018
|
.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
|
|
4522
5019
|
.replace(/ +/g, " ")
|
|
4523
|
-
.trim()
|
|
5020
|
+
.trim()
|
|
5021
|
+
.slice(0, SESSION_NAME_MAX_CHARS);
|
|
4524
5022
|
}
|
|
4525
5023
|
|
|
4526
5024
|
/**
|
|
@@ -4537,23 +5035,40 @@ export class SessionManager {
|
|
|
4537
5035
|
|
|
4538
5036
|
this.#sessionName = sanitized;
|
|
4539
5037
|
this.#titleSource = source;
|
|
5038
|
+
await this.#appendHeaderPatch({ title: sanitized, titleSource: source });
|
|
5039
|
+
return true;
|
|
5040
|
+
}
|
|
4540
5041
|
|
|
4541
|
-
|
|
4542
|
-
const header = this.#fileEntries.find(
|
|
4543
|
-
if (header)
|
|
4544
|
-
|
|
4545
|
-
header.titleSource = source;
|
|
4546
|
-
}
|
|
5042
|
+
async #appendHeaderPatch(patch: HeaderPatchRecord["patch"]): Promise<void> {
|
|
5043
|
+
const header = this.#fileEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
|
|
5044
|
+
if (!header) return;
|
|
5045
|
+
applyHeaderPatch(header, patch);
|
|
4547
5046
|
this.#headerExportRevision++;
|
|
4548
|
-
|
|
4549
|
-
// Update the session file header with the title (if already flushed)
|
|
4550
|
-
const sessionFile = this.#sessionFile;
|
|
4551
|
-
if (this.persist && sessionFile && this.storage.existsSync(sessionFile)) {
|
|
4552
|
-
await this.#rewriteFile();
|
|
4553
|
-
}
|
|
4554
|
-
return true;
|
|
5047
|
+
await this.#persistPatch({ type: "header_patch", patch });
|
|
4555
5048
|
}
|
|
4556
5049
|
|
|
5050
|
+
async #persistPatch(record: SessionPatchRecord): Promise<void> {
|
|
5051
|
+
if (!this.persist || !this.#sessionFile || !this.storage.existsSync(this.#sessionFile)) return;
|
|
5052
|
+
await this.#queuePersistTask(async () => {
|
|
5053
|
+
const header = this.#fileEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
|
|
5054
|
+
if (this.#needsFullRewriteOnNextPersist || !this.#flushed || (header?.version ?? 1) < CURRENT_SESSION_VERSION)
|
|
5055
|
+
return this.#rewriteFileContents();
|
|
5056
|
+
const writer = this.#ensurePersistWriter();
|
|
5057
|
+
if (!writer) return this.#rewriteFileContents();
|
|
5058
|
+
const persistedRecord =
|
|
5059
|
+
record.type === "entry_patch"
|
|
5060
|
+
? (prepareEntryForPersistenceSync(
|
|
5061
|
+
materializeResidentEntryForPersistenceSync(
|
|
5062
|
+
record as unknown as FileEntry,
|
|
5063
|
+
this.#residentBlobStores(),
|
|
5064
|
+
new Map(),
|
|
5065
|
+
),
|
|
5066
|
+
this.#blobStore,
|
|
5067
|
+
) as unknown as SessionPatchRecord)
|
|
5068
|
+
: record;
|
|
5069
|
+
await writer.write(persistedRecord);
|
|
5070
|
+
});
|
|
5071
|
+
}
|
|
4557
5072
|
_persist(entry: SessionEntry): void {
|
|
4558
5073
|
if (!this.persist || !this.#sessionFile) return;
|
|
4559
5074
|
if (this.#persistError) throw this.#persistError;
|
|
@@ -4566,6 +5081,7 @@ export class SessionManager {
|
|
|
4566
5081
|
if (!hasAssistant) {
|
|
4567
5082
|
// Mark as not flushed so when assistant arrives, all entries get written.
|
|
4568
5083
|
this.#flushed = false;
|
|
5084
|
+
|
|
4569
5085
|
this.#ensuredOnDisk = false;
|
|
4570
5086
|
return;
|
|
4571
5087
|
}
|
|
@@ -4859,6 +5375,22 @@ export class SessionManager {
|
|
|
4859
5375
|
this.#replayMetadataRevision++;
|
|
4860
5376
|
}
|
|
4861
5377
|
|
|
5378
|
+
/** Write mutated custom-message entries back into the canonical entry store by id. */
|
|
5379
|
+
applyCustomMessageEntryUpdates(entries: readonly CustomMessageEntry[]): void {
|
|
5380
|
+
for (const updated of entries) {
|
|
5381
|
+
const canonical = this.#byId.get(updated.id);
|
|
5382
|
+
if (canonical?.type !== "custom_message") continue;
|
|
5383
|
+
canonical.content = updated.content;
|
|
5384
|
+
canonical.details = updated.details;
|
|
5385
|
+
// Pruning replaces content permanently; retaining a cold-spill marker would
|
|
5386
|
+
// rehydrate the superseded payload on the next materialization.
|
|
5387
|
+
canonical.evictedContent = undefined;
|
|
5388
|
+
}
|
|
5389
|
+
this.#needsFullRewriteOnNextPersist = true;
|
|
5390
|
+
this.#bumpEntryRevision();
|
|
5391
|
+
this.#replayMetadataRevision++;
|
|
5392
|
+
}
|
|
5393
|
+
|
|
4862
5394
|
/**
|
|
4863
5395
|
* Rewrite the session file after in-place entry updates.
|
|
4864
5396
|
* Use sparingly (e.g., pruning old tool outputs).
|
|
@@ -4978,7 +5510,9 @@ export class SessionManager {
|
|
|
4978
5510
|
getLeafEntry(): SessionEntry | undefined {
|
|
4979
5511
|
if (!this.#leafId) return undefined;
|
|
4980
5512
|
const entry = this.#byId.get(this.#leafId);
|
|
4981
|
-
return entry
|
|
5513
|
+
return entry
|
|
5514
|
+
? cloneSessionEntry(materializeResidentEntryForReadSync(entry, this.#residentBlobStores(), new Map()))
|
|
5515
|
+
: undefined;
|
|
4982
5516
|
}
|
|
4983
5517
|
|
|
4984
5518
|
getResidentImageBytes(): number {
|
|
@@ -5163,10 +5697,12 @@ export class SessionManager {
|
|
|
5163
5697
|
getEntryForFidelity(id: string): SessionEntry | undefined {
|
|
5164
5698
|
const entry = this.#byId.get(id);
|
|
5165
5699
|
return entry
|
|
5166
|
-
?
|
|
5167
|
-
|
|
5168
|
-
|
|
5169
|
-
|
|
5700
|
+
? cloneSessionEntry(
|
|
5701
|
+
rehydrateColdSpillEntry(
|
|
5702
|
+
materializeResidentEntryForReadSync(entry, this.#residentBlobStores(), new Map()),
|
|
5703
|
+
this.#blobStore,
|
|
5704
|
+
this.#residentBlobStoresForColdRehydrate(),
|
|
5705
|
+
),
|
|
5170
5706
|
)
|
|
5171
5707
|
: undefined;
|
|
5172
5708
|
}
|
|
@@ -5180,10 +5716,12 @@ export class SessionManager {
|
|
|
5180
5716
|
if (visited.has(current.id)) break;
|
|
5181
5717
|
visited.add(current.id);
|
|
5182
5718
|
path.push(
|
|
5183
|
-
|
|
5184
|
-
|
|
5185
|
-
|
|
5186
|
-
|
|
5719
|
+
cloneSessionEntry(
|
|
5720
|
+
rehydrateColdSpillEntry(
|
|
5721
|
+
materializeResidentEntryForReadSync(current, this.#residentBlobStores(), cache),
|
|
5722
|
+
this.#blobStore,
|
|
5723
|
+
this.#residentBlobStoresForColdRehydrate(),
|
|
5724
|
+
),
|
|
5187
5725
|
),
|
|
5188
5726
|
);
|
|
5189
5727
|
current = current.parentId ? this.#byId.get(current.parentId) : undefined;
|
|
@@ -5220,10 +5758,12 @@ export class SessionManager {
|
|
|
5220
5758
|
return this.#fileEntries
|
|
5221
5759
|
.filter((entry): entry is SessionEntry => entry.type !== "session")
|
|
5222
5760
|
.map(entry =>
|
|
5223
|
-
|
|
5224
|
-
|
|
5225
|
-
|
|
5226
|
-
|
|
5761
|
+
cloneSessionEntry(
|
|
5762
|
+
rehydrateColdSpillEntry(
|
|
5763
|
+
materializeResidentEntryForReadSync(entry, this.#residentBlobStores(), cache),
|
|
5764
|
+
this.#blobStore,
|
|
5765
|
+
this.#residentBlobStoresForColdRehydrate(),
|
|
5766
|
+
),
|
|
5227
5767
|
),
|
|
5228
5768
|
);
|
|
5229
5769
|
}
|
|
@@ -5232,7 +5772,9 @@ export class SessionManager {
|
|
|
5232
5772
|
this.#publicMaterializerCallCount++;
|
|
5233
5773
|
this.#getEntryMaterializerCallCount++;
|
|
5234
5774
|
const entry = this.#byId.get(id);
|
|
5235
|
-
return entry
|
|
5775
|
+
return entry
|
|
5776
|
+
? cloneSessionEntry(materializeResidentEntryForReadSync(entry, this.#residentBlobStores(), new Map()))
|
|
5777
|
+
: undefined;
|
|
5236
5778
|
}
|
|
5237
5779
|
|
|
5238
5780
|
/**
|
|
@@ -5243,7 +5785,9 @@ export class SessionManager {
|
|
|
5243
5785
|
const children: SessionEntry[] = [];
|
|
5244
5786
|
for (const entry of this.#byId.values()) {
|
|
5245
5787
|
if (entry.parentId === parentId) {
|
|
5246
|
-
children.push(
|
|
5788
|
+
children.push(
|
|
5789
|
+
cloneSessionEntry(materializeResidentEntryForReadSync(entry, this.#residentBlobStores(), cache)),
|
|
5790
|
+
);
|
|
5247
5791
|
}
|
|
5248
5792
|
}
|
|
5249
5793
|
return children;
|
|
@@ -5298,7 +5842,7 @@ export class SessionManager {
|
|
|
5298
5842
|
while (current) {
|
|
5299
5843
|
if (visited.has(current.id)) break;
|
|
5300
5844
|
visited.add(current.id);
|
|
5301
|
-
path.push(materializeResidentEntryForReadSync(current, this.#residentBlobStores(), cache));
|
|
5845
|
+
path.push(cloneSessionEntry(materializeResidentEntryForReadSync(current, this.#residentBlobStores(), cache)));
|
|
5302
5846
|
current = current.parentId ? this.#byId.get(current.parentId) : undefined;
|
|
5303
5847
|
}
|
|
5304
5848
|
path.reverse();
|
|
@@ -5309,15 +5853,26 @@ export class SessionManager {
|
|
|
5309
5853
|
* Build the session context (what gets sent to the LLM).
|
|
5310
5854
|
* Uses tree traversal from current leaf.
|
|
5311
5855
|
*/
|
|
5856
|
+
/**
|
|
5857
|
+
* Return a defensive context snapshot for public consumers.
|
|
5858
|
+
*/
|
|
5312
5859
|
buildSessionContext(): SessionContext {
|
|
5313
|
-
|
|
5860
|
+
return cloneSessionContext(this.#getSessionContextForRead());
|
|
5861
|
+
}
|
|
5862
|
+
|
|
5863
|
+
/**
|
|
5864
|
+
* Return the revision-keyed context cache for internal read-only consumers.
|
|
5865
|
+
* The strong reference avoids GC-driven rebuilds during a session.
|
|
5866
|
+
*/
|
|
5867
|
+
#getSessionContextForRead(): Readonly<SessionContext> {
|
|
5868
|
+
const cached = this.#sessionContextCache;
|
|
5314
5869
|
if (
|
|
5315
5870
|
cached &&
|
|
5316
5871
|
this.#sessionContextEntryRevision === this.#entryRevision &&
|
|
5317
5872
|
this.#sessionContextLeafRevision === this.#leafRevision &&
|
|
5318
5873
|
this.#sessionContextReplayMetadataRevision === this.#replayMetadataRevision
|
|
5319
5874
|
) {
|
|
5320
|
-
return
|
|
5875
|
+
return cached;
|
|
5321
5876
|
}
|
|
5322
5877
|
this.#pathOnlyContextBuildCount++;
|
|
5323
5878
|
const context = buildSessionContext(
|
|
@@ -5326,11 +5881,12 @@ export class SessionManager {
|
|
|
5326
5881
|
undefined,
|
|
5327
5882
|
this.#sessionId,
|
|
5328
5883
|
);
|
|
5329
|
-
this.#sessionContextCache =
|
|
5884
|
+
this.#sessionContextCache = freezeInternalReadSnapshot(context);
|
|
5885
|
+
transferSessionMessageIdentity(context.messages, this.#sessionContextCache.messages);
|
|
5330
5886
|
this.#sessionContextEntryRevision = this.#entryRevision;
|
|
5331
5887
|
this.#sessionContextLeafRevision = this.#leafRevision;
|
|
5332
5888
|
this.#sessionContextReplayMetadataRevision = this.#replayMetadataRevision;
|
|
5333
|
-
return
|
|
5889
|
+
return this.#sessionContextCache;
|
|
5334
5890
|
}
|
|
5335
5891
|
|
|
5336
5892
|
#getActivePathEntriesForProviderContext(fromId?: string | null): SessionEntry[] {
|
|
@@ -5385,28 +5941,33 @@ export class SessionManager {
|
|
|
5385
5941
|
const marker = entry.type === "message" || entry.type === "custom_message" ? entry.evictedContent : undefined;
|
|
5386
5942
|
return marker ? Object.keys(marker.payloads ?? {}).length : 0;
|
|
5387
5943
|
}
|
|
5388
|
-
/** Strip stale OpenAI Responses assistant replay metadata from loaded in-memory entries. */
|
|
5944
|
+
/** Strip stale OpenAI Responses assistant replay metadata from loaded in-memory entries without persisting it. */
|
|
5389
5945
|
sanitizeLoadedOpenAIResponsesReplayMetadata(): boolean {
|
|
5390
|
-
|
|
5391
|
-
|
|
5392
|
-
if (entry.type !== "message" || entry.message.role !== "assistant") {
|
|
5393
|
-
continue;
|
|
5394
|
-
}
|
|
5946
|
+
return this.#sanitizeLoadedOpenAIResponsesReplayMetadata().length > 0;
|
|
5947
|
+
}
|
|
5395
5948
|
|
|
5396
|
-
|
|
5397
|
-
|
|
5398
|
-
|
|
5399
|
-
|
|
5949
|
+
async #sanitizeLoadedOpenAIResponsesReplayMetadataAndPersist(): Promise<boolean> {
|
|
5950
|
+
const patches = this.#sanitizeLoadedOpenAIResponsesReplayMetadata();
|
|
5951
|
+
for (const patch of patches) {
|
|
5952
|
+
await this.#persistPatch(patch);
|
|
5953
|
+
}
|
|
5954
|
+
return patches.length > 0;
|
|
5955
|
+
}
|
|
5400
5956
|
|
|
5957
|
+
#sanitizeLoadedOpenAIResponsesReplayMetadata(): EntryPatchRecord[] {
|
|
5958
|
+
const patches: EntryPatchRecord[] = [];
|
|
5959
|
+
for (const entry of this.#fileEntries) {
|
|
5960
|
+
if (entry.type !== "message" || entry.message.role !== "assistant") continue;
|
|
5961
|
+
const sanitizedMessage = sanitizeRehydratedOpenAIResponsesAssistantMessage(entry.message);
|
|
5962
|
+
if (sanitizedMessage === entry.message) continue;
|
|
5401
5963
|
entry.message = sanitizedMessage;
|
|
5402
|
-
|
|
5964
|
+
patches.push({ type: "entry_patch", entryId: entry.id, patch: { message: sanitizedMessage } });
|
|
5403
5965
|
}
|
|
5404
|
-
if (
|
|
5966
|
+
if (patches.length > 0) {
|
|
5405
5967
|
this.#bumpEntryRevision();
|
|
5406
5968
|
this.#replayMetadataRevision++;
|
|
5407
5969
|
}
|
|
5408
|
-
|
|
5409
|
-
return didSanitize;
|
|
5970
|
+
return patches;
|
|
5410
5971
|
}
|
|
5411
5972
|
|
|
5412
5973
|
/**
|
|
@@ -5418,9 +5979,7 @@ export class SessionManager {
|
|
|
5418
5979
|
}
|
|
5419
5980
|
|
|
5420
5981
|
/**
|
|
5421
|
-
*
|
|
5422
|
-
* The session is append-only: use appendXXX() to add entries, branch() to
|
|
5423
|
-
* change the leaf pointer. Entries cannot be modified or deleted.
|
|
5982
|
+
* Internal materialized entry cache. Public getters clone this snapshot before returning it.
|
|
5424
5983
|
*/
|
|
5425
5984
|
#getMaterializedEntriesInternal(): SessionEntry[] {
|
|
5426
5985
|
if (this.#materializedEntriesRevision === this.#entryRevision && this.#materializedEntriesCache) {
|
|
@@ -5446,12 +6005,14 @@ export class SessionManager {
|
|
|
5446
6005
|
}
|
|
5447
6006
|
|
|
5448
6007
|
/**
|
|
5449
|
-
* Get the session as a tree structure. Returns
|
|
6008
|
+
* Get the session as a tree structure. Returns defensive copies of all entries.
|
|
5450
6009
|
* A well-formed session has exactly one root (first entry with parentId === null).
|
|
5451
6010
|
* Orphaned entries (broken parent chain) are also returned as roots.
|
|
5452
6011
|
*/
|
|
5453
6012
|
getTree(): SessionTreeNode[] {
|
|
5454
|
-
|
|
6013
|
+
return this.#getTree(this.getEntries());
|
|
6014
|
+
}
|
|
6015
|
+
#getTree(entries: readonly SessionEntry[]): SessionTreeNode[] {
|
|
5455
6016
|
const nodeMap = new Map<string, SessionTreeNode>();
|
|
5456
6017
|
const roots: SessionTreeNode[] = [];
|
|
5457
6018
|
|
|
@@ -5523,7 +6084,9 @@ export class SessionManager {
|
|
|
5523
6084
|
|
|
5524
6085
|
return roots;
|
|
5525
6086
|
}
|
|
5526
|
-
|
|
6087
|
+
[sessionManagerReadCapability](): SessionManagerReadAccess {
|
|
6088
|
+
return this.#internalReadAccess;
|
|
6089
|
+
}
|
|
5527
6090
|
// =========================================================================
|
|
5528
6091
|
// Branching
|
|
5529
6092
|
// =========================================================================
|
|
@@ -5700,9 +6263,16 @@ export class SessionManager {
|
|
|
5700
6263
|
|
|
5701
6264
|
/** Resolve the default session directory without creating or migrating storage. */
|
|
5702
6265
|
static getDefaultSessionDirReadOnly(cwd: string, agentDir?: string): string {
|
|
5703
|
-
const
|
|
5704
|
-
|
|
6266
|
+
const sessionsRoot = getSessionsDir(agentDir);
|
|
6267
|
+
const resolved = resolveManagedScope({
|
|
6268
|
+
cwd,
|
|
6269
|
+
agentDir: agentDir ?? path.resolve(sessionsRoot, ".."),
|
|
6270
|
+
sessionsRoot,
|
|
6271
|
+
});
|
|
6272
|
+
if (resolved.kind === "error") throw new Error(`Could not resolve managed session scope: ${resolved.message}`);
|
|
6273
|
+
return resolved.scope.directoryPath;
|
|
5705
6274
|
}
|
|
6275
|
+
|
|
5706
6276
|
/**
|
|
5707
6277
|
* Create a new session.
|
|
5708
6278
|
* @param cwd Working directory (stored in session header)
|
|
@@ -5715,6 +6285,59 @@ export class SessionManager {
|
|
|
5715
6285
|
return manager;
|
|
5716
6286
|
}
|
|
5717
6287
|
|
|
6288
|
+
/** Resolve a default-managed candidate through binding validation and copy-retain migration before mutation. */
|
|
6289
|
+
static async prepareManagedCandidateForWrite(
|
|
6290
|
+
filePath: string,
|
|
6291
|
+
migrationPolicy: SessionDirectoryMigrationPolicy,
|
|
6292
|
+
): Promise<string> {
|
|
6293
|
+
const storage = new FileSessionStorage();
|
|
6294
|
+
const inspected = inspectResumeSessionFile(filePath, storage);
|
|
6295
|
+
if ("kind" in inspected) {
|
|
6296
|
+
if (inspected.reason === "missing") return filePath;
|
|
6297
|
+
throw new Error(`Could not inspect managed session: ${inspected.reason}`);
|
|
6298
|
+
}
|
|
6299
|
+
const header = inspected.entries.find(entry => entry.type === "session") as SessionHeader | undefined;
|
|
6300
|
+
if (!header?.cwd) return filePath;
|
|
6301
|
+
const sessionsRoot = getSessionsDir();
|
|
6302
|
+
const resolvedPath = path.resolve(filePath);
|
|
6303
|
+
const relativeToManagedRoot = path.relative(sessionsRoot, resolvedPath);
|
|
6304
|
+
const isManagedPath =
|
|
6305
|
+
relativeToManagedRoot === "" ||
|
|
6306
|
+
(!relativeToManagedRoot.startsWith("..") && !path.isAbsolute(relativeToManagedRoot));
|
|
6307
|
+
|
|
6308
|
+
if (!isManagedPath) return filePath;
|
|
6309
|
+
const resolved = resolveManagedScope({
|
|
6310
|
+
cwd: header.cwd,
|
|
6311
|
+
agentDir: path.resolve(sessionsRoot, ".."),
|
|
6312
|
+
sessionsRoot,
|
|
6313
|
+
});
|
|
6314
|
+
if (resolved.kind === "error") throw new Error(`Could not resolve managed session scope: ${resolved.message}`);
|
|
6315
|
+
|
|
6316
|
+
const listing = listManagedCandidates(resolved.scope);
|
|
6317
|
+
if (listing.kind !== "complete") throw new Error(`Managed session scan failed: ${listing.message}`);
|
|
6318
|
+
|
|
6319
|
+
const candidate = listing.owned.find(item => path.resolve(item.path) === resolvedPath);
|
|
6320
|
+
if (!candidate) throw new Error("Session is inside managed storage but is not an authorized managed candidate.");
|
|
6321
|
+
const revalidated = inspectResumeSessionFile(filePath, storage);
|
|
6322
|
+
if (
|
|
6323
|
+
"kind" in revalidated ||
|
|
6324
|
+
!sameResumeIdentity(inspected.identity, revalidated.identity) ||
|
|
6325
|
+
candidate.identity.dev !== inspected.identity.dev ||
|
|
6326
|
+
candidate.identity.ino !== inspected.identity.ino ||
|
|
6327
|
+
candidate.identity.size !== inspected.identity.size ||
|
|
6328
|
+
candidate.identity.mtimeNs !== inspected.identity.mtimeNs ||
|
|
6329
|
+
candidate.identity.mtimeMs !== inspected.identity.mtimeMs ||
|
|
6330
|
+
candidate.identity.sha256 !== inspected.identity.sha256
|
|
6331
|
+
)
|
|
6332
|
+
throw new Error("Managed session changed before migration authority was adopted.");
|
|
6333
|
+
const opened = await openManagedCandidateForWrite(resolved.scope, candidate, migrationPolicy);
|
|
6334
|
+
if (opened.kind === "error") {
|
|
6335
|
+
if (opened.code === "legacy_migration_disabled") throw new SessionMigrationPolicyError();
|
|
6336
|
+
throw new Error(`Could not open managed session: ${opened.message}`);
|
|
6337
|
+
}
|
|
6338
|
+
return opened.path;
|
|
6339
|
+
}
|
|
6340
|
+
|
|
5718
6341
|
/**
|
|
5719
6342
|
* Fork a session into the current project directory.
|
|
5720
6343
|
* Copies history from another session file while creating a new session file in the current sessionDir.
|
|
@@ -5724,10 +6347,15 @@ export class SessionManager {
|
|
|
5724
6347
|
cwd: string,
|
|
5725
6348
|
sessionDir?: string,
|
|
5726
6349
|
storage: SessionStorage = new FileSessionStorage(),
|
|
6350
|
+
migrationPolicy: SessionDirectoryMigrationPolicy = "copy-retain",
|
|
5727
6351
|
): Promise<SessionManager> {
|
|
6352
|
+
const managedSourcePath =
|
|
6353
|
+
!sessionDir && storage instanceof FileSessionStorage
|
|
6354
|
+
? await SessionManager.prepareManagedCandidateForWrite(sourcePath, migrationPolicy)
|
|
6355
|
+
: sourcePath;
|
|
5728
6356
|
const dir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage);
|
|
5729
6357
|
const manager = new SessionManager(cwd, dir, true, storage);
|
|
5730
|
-
const forkEntries = structuredClone(await loadEntriesFromFile(
|
|
6358
|
+
const forkEntries = structuredClone(await loadEntriesFromFile(managedSourcePath, storage)) as FileEntry[];
|
|
5731
6359
|
migrateToCurrentVersion(forkEntries);
|
|
5732
6360
|
await resolveBlobRefsInEntries(forkEntries, manager.#blobStore);
|
|
5733
6361
|
manager.#fileEntries = forkEntries;
|
|
@@ -5762,16 +6390,48 @@ export class SessionManager {
|
|
|
5762
6390
|
filePath: string,
|
|
5763
6391
|
sessionDir?: string,
|
|
5764
6392
|
storage: SessionStorage = new FileSessionStorage(),
|
|
6393
|
+
migrationPolicy: SessionDirectoryMigrationPolicy = "copy-retain",
|
|
5765
6394
|
): Promise<SessionManager> {
|
|
5766
|
-
//
|
|
5767
|
-
|
|
5768
|
-
|
|
5769
|
-
|
|
5770
|
-
|
|
5771
|
-
|
|
5772
|
-
|
|
5773
|
-
|
|
5774
|
-
|
|
6395
|
+
// Canonicalize benign ancestor symlinks in the caller-supplied path (e.g.
|
|
6396
|
+
// macOS `/var -> /private/var`, a symlinked `$HOME`/project) so explicit
|
|
6397
|
+
// session files land under a symlink-free trusted root that the strict
|
|
6398
|
+
// owner-only and reparse guards accept.
|
|
6399
|
+
if (storage instanceof FileSessionStorage) filePath = canonicalizeTrustedPath(filePath);
|
|
6400
|
+
if (sessionDir || !(storage instanceof FileSessionStorage)) {
|
|
6401
|
+
const entries = await loadEntriesFromFile(filePath, storage);
|
|
6402
|
+
const header = entries.find(entry => entry.type === "session") as SessionHeader | undefined;
|
|
6403
|
+
const manager = new SessionManager(
|
|
6404
|
+
header?.cwd ?? getProjectDir(),
|
|
6405
|
+
sessionDir ??
|
|
6406
|
+
(storage instanceof FileSessionStorage ? path.resolve(filePath, "..") : path.dirname(filePath)),
|
|
6407
|
+
true,
|
|
6408
|
+
storage,
|
|
6409
|
+
);
|
|
6410
|
+
await manager.#initSessionFile(filePath);
|
|
6411
|
+
return manager;
|
|
6412
|
+
}
|
|
6413
|
+
|
|
6414
|
+
const managedPath =
|
|
6415
|
+
!sessionDir && storage instanceof FileSessionStorage
|
|
6416
|
+
? await SessionManager.prepareManagedCandidateForWrite(filePath, migrationPolicy)
|
|
6417
|
+
: filePath;
|
|
6418
|
+
const inspected = inspectResumeSessionFile(managedPath, storage);
|
|
6419
|
+
if ("kind" in inspected) {
|
|
6420
|
+
if (inspected.reason === "missing" && path.resolve(managedPath) === path.resolve(filePath)) {
|
|
6421
|
+
const manager = new SessionManager(getProjectDir(), path.resolve(filePath, ".."), true, storage);
|
|
6422
|
+
await manager.#initSessionFile(filePath);
|
|
6423
|
+
return manager;
|
|
6424
|
+
}
|
|
6425
|
+
throw new Error(`Could not open session: ${inspected.reason}`);
|
|
6426
|
+
}
|
|
6427
|
+
const opened = await SessionManager.openExistingStrict(
|
|
6428
|
+
inspected.identity,
|
|
6429
|
+
sessionDir ?? path.resolve(managedPath, ".."),
|
|
6430
|
+
storage,
|
|
6431
|
+
migrationPolicy,
|
|
6432
|
+
);
|
|
6433
|
+
if (opened.kind === "error") throw new Error(`Could not open session: ${opened.reason}`);
|
|
6434
|
+
return opened.manager;
|
|
5775
6435
|
}
|
|
5776
6436
|
|
|
5777
6437
|
/**
|
|
@@ -5782,16 +6442,60 @@ export class SessionManager {
|
|
|
5782
6442
|
sessionDir?: string,
|
|
5783
6443
|
storage: SessionStorage = new FileSessionStorage(),
|
|
5784
6444
|
): Promise<SessionInfo[]> {
|
|
5785
|
-
|
|
6445
|
+
if (!sessionDir && !(storage instanceof FileSessionStorage)) return [];
|
|
6446
|
+
if (sessionDir && !resolveManagedSessionRoot(sessionDir, cwd)) {
|
|
6447
|
+
try {
|
|
6448
|
+
return await collectSessionsFromFiles(storage.listFilesSync(sessionDir, "*.jsonl"), storage);
|
|
6449
|
+
} catch {
|
|
6450
|
+
return [];
|
|
6451
|
+
}
|
|
6452
|
+
}
|
|
6453
|
+
const sessionsRoot = sessionDir
|
|
6454
|
+
? (resolveManagedSessionRoot(sessionDir, cwd) ?? getSessionsDir())
|
|
6455
|
+
: getSessionsDir();
|
|
6456
|
+
const resolved = resolveManagedScope({ cwd, agentDir: path.resolve(sessionsRoot, ".."), sessionsRoot });
|
|
6457
|
+
if (resolved.kind === "error") return [];
|
|
6458
|
+
if (storage instanceof FileSessionStorage) {
|
|
6459
|
+
const listing = listManagedCandidates(resolved.scope);
|
|
6460
|
+
if (listing.kind === "error") return [];
|
|
6461
|
+
return await collectSessionsFromFiles(
|
|
6462
|
+
listing.owned.map(candidate => candidate.path),
|
|
6463
|
+
storage,
|
|
6464
|
+
);
|
|
6465
|
+
}
|
|
6466
|
+
const legacyDirectory = `--${resolved.scope.canonicalCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
|
|
6467
|
+
const files = new Set<string>();
|
|
5786
6468
|
try {
|
|
5787
|
-
const
|
|
5788
|
-
|
|
5789
|
-
for (const filePath of storage.listFilesSync(dir, "*.jsonl")) files.add(filePath);
|
|
6469
|
+
for (const directory of [resolved.scope.directoryPath, path.join(sessionsRoot, legacyDirectory)]) {
|
|
6470
|
+
for (const file of storage.listFilesSync(directory, "*.jsonl")) files.add(file);
|
|
5790
6471
|
}
|
|
5791
|
-
return await collectSessionsFromFiles([...files], storage);
|
|
5792
6472
|
} catch {
|
|
5793
6473
|
return [];
|
|
5794
6474
|
}
|
|
6475
|
+
return (await collectSessionsFromFiles([...files], storage)).filter(
|
|
6476
|
+
session => resolveEquivalentPath(session.cwd) === resolved.scope.canonicalCwd,
|
|
6477
|
+
);
|
|
6478
|
+
}
|
|
6479
|
+
|
|
6480
|
+
/** Tombstone and exact-delete a default-managed candidate. */
|
|
6481
|
+
static async deleteManagedCandidate(sessionPath: string): Promise<void> {
|
|
6482
|
+
const storage = new FileSessionStorage();
|
|
6483
|
+
const entries = await loadEntriesFromFile(sessionPath, storage);
|
|
6484
|
+
const header = entries.find(entry => entry.type === "session") as SessionHeader | undefined;
|
|
6485
|
+
if (!header?.cwd) throw new Error("Session has no valid managed workspace header.");
|
|
6486
|
+
const sessionsRoot = path.resolve(sessionPath, "../..");
|
|
6487
|
+
const resolved = resolveManagedScope({
|
|
6488
|
+
cwd: header.cwd,
|
|
6489
|
+
agentDir: path.resolve(sessionsRoot, ".."),
|
|
6490
|
+
sessionsRoot,
|
|
6491
|
+
});
|
|
6492
|
+
if (resolved.kind === "error") throw new Error(`Could not resolve managed session scope: ${resolved.message}`);
|
|
6493
|
+
const listing = listManagedCandidates(resolved.scope);
|
|
6494
|
+
if (listing.kind !== "complete") throw new Error("Managed session scan did not grant deletion authority.");
|
|
6495
|
+
const candidate = listing.owned.find(item => path.resolve(item.path) === path.resolve(sessionPath));
|
|
6496
|
+
if (!candidate) throw new Error("Session is not an authorized managed candidate.");
|
|
6497
|
+
const deleted = await deleteManagedSessionCandidate(resolved.scope, candidate);
|
|
6498
|
+
if (deleted.kind === "error") throw new Error(`Could not delete managed session: ${deleted.message}`);
|
|
5795
6499
|
}
|
|
5796
6500
|
|
|
5797
6501
|
/** Capture exact source content for a strict fork without granting write ownership. */
|
|
@@ -5821,6 +6525,7 @@ export class SessionManager {
|
|
|
5821
6525
|
snapshot: CapturedSessionTranscriptSnapshot,
|
|
5822
6526
|
cwd: string,
|
|
5823
6527
|
sessionDir?: string,
|
|
6528
|
+
_migrationPolicy: SessionDirectoryMigrationPolicy = "copy-retain",
|
|
5824
6529
|
): Promise<StrictSessionForkResult> {
|
|
5825
6530
|
if (crypto.createHash("sha256").update(snapshot.content).digest("hex") !== snapshot.identity.sha256) {
|
|
5826
6531
|
return { kind: "error", reason: "identity-mismatch" };
|
|
@@ -5884,6 +6589,15 @@ export class SessionManager {
|
|
|
5884
6589
|
throw new Error("Captured fork source authority changed before destination write.");
|
|
5885
6590
|
}
|
|
5886
6591
|
await manager.#rewriteFile();
|
|
6592
|
+
const afterWrite = inspectResumeSessionFile(snapshot.sourcePath, snapshot.storage);
|
|
6593
|
+
if ("kind" in afterWrite) {
|
|
6594
|
+
authorityFailure = afterWrite;
|
|
6595
|
+
throw new Error("Captured fork source authority changed during destination write.");
|
|
6596
|
+
}
|
|
6597
|
+
if (!sameResumeIdentity(snapshot.identity, afterWrite.identity)) {
|
|
6598
|
+
authorityFailure = { kind: "error", reason: "identity-mismatch" };
|
|
6599
|
+
throw new Error("Captured fork source authority changed during destination write.");
|
|
6600
|
+
}
|
|
5887
6601
|
if (manager.#sessionFile) writeTerminalBreadcrumb(manager.cwd, manager.#sessionFile);
|
|
5888
6602
|
return { kind: "forked", manager };
|
|
5889
6603
|
} catch (error) {
|
|
@@ -5968,27 +6682,48 @@ export class SessionManager {
|
|
|
5968
6682
|
identity: ResumeSessionIdentity,
|
|
5969
6683
|
sessionDir?: string,
|
|
5970
6684
|
storage: SessionStorage = new FileSessionStorage(),
|
|
6685
|
+
migrationPolicy: SessionDirectoryMigrationPolicy = "copy-retain",
|
|
5971
6686
|
): Promise<StrictSessionOpenResult> {
|
|
5972
|
-
|
|
6687
|
+
let sessionPath = identity.canonicalPath;
|
|
6688
|
+
if (!sessionDir && storage instanceof FileSessionStorage) {
|
|
6689
|
+
try {
|
|
6690
|
+
sessionPath = await SessionManager.prepareManagedCandidateForWrite(sessionPath, migrationPolicy);
|
|
6691
|
+
} catch (error) {
|
|
6692
|
+
if (error instanceof SessionMigrationPolicyError)
|
|
6693
|
+
return { kind: "error", reason: "legacy_migration_disabled" };
|
|
6694
|
+
throw error;
|
|
6695
|
+
}
|
|
6696
|
+
}
|
|
6697
|
+
const inspected = inspectResumeSessionFile(sessionPath, storage);
|
|
5973
6698
|
if ("kind" in inspected) return inspected;
|
|
5974
|
-
if (
|
|
6699
|
+
if (
|
|
6700
|
+
(sessionPath === identity.canonicalPath && !sameResumeIdentity(identity, inspected.identity)) ||
|
|
6701
|
+
(sessionPath !== identity.canonicalPath &&
|
|
6702
|
+
(identity.sessionId !== inspected.identity.sessionId || identity.sha256 !== inspected.identity.sha256))
|
|
6703
|
+
) {
|
|
5975
6704
|
return { kind: "error", reason: "identity-mismatch" };
|
|
5976
6705
|
}
|
|
5977
|
-
const entries =
|
|
6706
|
+
const entries = inspected.entries;
|
|
5978
6707
|
const header = entries[0] as SessionHeader;
|
|
5979
|
-
const dir = sessionDir ?? path.resolve(
|
|
6708
|
+
const dir = sessionDir ?? path.resolve(sessionPath, "..");
|
|
5980
6709
|
const manager = new SessionManager(header.cwd || getProjectDir(), dir, true, storage);
|
|
5981
|
-
await manager.#hydrateExistingSession(
|
|
5982
|
-
const ownershipInspection = inspectResumeSessionFile(
|
|
6710
|
+
await manager.#hydrateExistingSession(sessionPath, entries, inspected.migrationApplied);
|
|
6711
|
+
const ownershipInspection = inspectResumeSessionFile(sessionPath, storage);
|
|
5983
6712
|
if ("kind" in ownershipInspection) {
|
|
5984
6713
|
await manager.close();
|
|
5985
6714
|
return ownershipInspection;
|
|
5986
6715
|
}
|
|
5987
|
-
if (!sameResumeIdentity(identity, ownershipInspection.identity)) {
|
|
6716
|
+
if (!sameResumeIdentity(inspected.identity, ownershipInspection.identity)) {
|
|
5988
6717
|
await manager.close();
|
|
5989
6718
|
return { kind: "error", reason: "identity-mismatch" };
|
|
5990
6719
|
}
|
|
5991
|
-
|
|
6720
|
+
try {
|
|
6721
|
+
await manager.#sanitizeLoadedOpenAIResponsesReplayMetadataAndPersist();
|
|
6722
|
+
writeTerminalBreadcrumb(manager.cwd, sessionPath);
|
|
6723
|
+
} catch (error) {
|
|
6724
|
+
await manager.close();
|
|
6725
|
+
throw error;
|
|
6726
|
+
}
|
|
5992
6727
|
return { kind: "opened", manager };
|
|
5993
6728
|
}
|
|
5994
6729
|
|
|
@@ -6001,24 +6736,42 @@ export class SessionManager {
|
|
|
6001
6736
|
cwd: string,
|
|
6002
6737
|
sessionDir?: string,
|
|
6003
6738
|
storage: SessionStorage = new FileSessionStorage(),
|
|
6739
|
+
migrationPolicy: SessionDirectoryMigrationPolicy = "copy-retain",
|
|
6004
6740
|
): Promise<SessionManager> {
|
|
6005
6741
|
const dir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage);
|
|
6006
|
-
|
|
6742
|
+
const openSelectedStrictly = async (selectedPath: string): Promise<SessionManager | undefined> => {
|
|
6743
|
+
const inspected = await SessionManager.inspectSessionTailReadOnly(selectedPath, storage);
|
|
6744
|
+
if (inspected.kind === "error") return undefined;
|
|
6745
|
+
const opened = await SessionManager.openExistingStrict(
|
|
6746
|
+
inspected.identity,
|
|
6747
|
+
sessionDir,
|
|
6748
|
+
storage,
|
|
6749
|
+
migrationPolicy,
|
|
6750
|
+
);
|
|
6751
|
+
if (opened.kind === "error") {
|
|
6752
|
+
if (opened.reason === "legacy_migration_disabled") throw new SessionMigrationPolicyError();
|
|
6753
|
+
return undefined;
|
|
6754
|
+
}
|
|
6755
|
+
return opened.manager;
|
|
6756
|
+
};
|
|
6757
|
+
// Legacy paths are candidates only: the managed scan validates each transcript
|
|
6758
|
+
// header cwd before it can affect default resume authority.
|
|
6759
|
+
const managedCandidates = sessionDir
|
|
6760
|
+
? []
|
|
6761
|
+
: await SessionManager.listForResumePickerReadOnly(cwd, undefined, storage);
|
|
6007
6762
|
const terminalSession = await readTerminalBreadcrumb(cwd);
|
|
6008
|
-
|
|
6763
|
+
if (terminalSession) {
|
|
6764
|
+
const opened = await openSelectedStrictly(terminalSession);
|
|
6765
|
+
if (opened) return opened;
|
|
6766
|
+
}
|
|
6767
|
+
for (const candidate of managedCandidates) {
|
|
6768
|
+
const opened = await openSelectedStrictly(candidate.path);
|
|
6769
|
+
if (opened) return opened;
|
|
6770
|
+
}
|
|
6771
|
+
const mostRecent = await findMostRecentSession(dir, storage);
|
|
6009
6772
|
if (mostRecent) {
|
|
6010
|
-
|
|
6011
|
-
|
|
6012
|
-
// the invocation cwd; binding the manager (and HUD) to `cwd` would leave
|
|
6013
|
-
// it on the main checkout instead of the worktree it was created in.
|
|
6014
|
-
const header = (await loadEntriesFromFile(mostRecent, storage)).find(e => e.type === "session") as
|
|
6015
|
-
| SessionHeader
|
|
6016
|
-
| undefined;
|
|
6017
|
-
const resumeCwd = header?.cwd || cwd;
|
|
6018
|
-
const resumeDir = sessionDir ?? path.resolve(mostRecent, "..");
|
|
6019
|
-
const manager = new SessionManager(resumeCwd, resumeDir, true, storage);
|
|
6020
|
-
await manager.#initSessionFile(mostRecent);
|
|
6021
|
-
return manager;
|
|
6773
|
+
const opened = await openSelectedStrictly(mostRecent);
|
|
6774
|
+
if (opened) return opened;
|
|
6022
6775
|
}
|
|
6023
6776
|
const manager = new SessionManager(cwd, dir, true, storage);
|
|
6024
6777
|
manager.#initNewSession();
|
|
@@ -6045,11 +6798,10 @@ export class SessionManager {
|
|
|
6045
6798
|
sessionDir?: string,
|
|
6046
6799
|
storage: SessionStorage = new FileSessionStorage(),
|
|
6047
6800
|
): Promise<SessionInfo[]> {
|
|
6048
|
-
|
|
6801
|
+
if (!sessionDir) return await SessionManager.listForResumePickerReadOnly(cwd, undefined, storage);
|
|
6049
6802
|
try {
|
|
6050
|
-
await recoverOrphanedBackups(
|
|
6051
|
-
|
|
6052
|
-
return await collectSessionsFromFiles(files, storage);
|
|
6803
|
+
await recoverOrphanedBackups(sessionDir, storage);
|
|
6804
|
+
return await collectSessionsFromFiles(storage.listFilesSync(sessionDir, "*.jsonl"), storage);
|
|
6053
6805
|
} catch {
|
|
6054
6806
|
return [];
|
|
6055
6807
|
}
|
|
@@ -6059,12 +6811,39 @@ export class SessionManager {
|
|
|
6059
6811
|
* List all sessions across all project directories.
|
|
6060
6812
|
*/
|
|
6061
6813
|
static async listAll(storage: SessionStorage = new FileSessionStorage()): Promise<SessionInfo[]> {
|
|
6814
|
+
if (!(storage instanceof FileSessionStorage)) return [];
|
|
6062
6815
|
const sessionsRoot = path.join(getDefaultAgentDir(), "sessions");
|
|
6063
6816
|
try {
|
|
6064
|
-
const
|
|
6065
|
-
|
|
6066
|
-
|
|
6067
|
-
|
|
6817
|
+
const directories = await fs.promises.readdir(sessionsRoot, { withFileTypes: true });
|
|
6818
|
+
const seedFiles = directories
|
|
6819
|
+
.filter(entry => entry.isDirectory())
|
|
6820
|
+
.flatMap(entry => storage.listFilesSync(path.join(sessionsRoot, entry.name), "*.jsonl"));
|
|
6821
|
+
const seedSessions = await collectSessionsFromFiles(seedFiles, storage);
|
|
6822
|
+
const logicalFiles = new Set<string>();
|
|
6823
|
+
for (const cwd of new Set(seedSessions.map(session => session.cwd).filter(Boolean))) {
|
|
6824
|
+
const scope = resolveManagedScope({
|
|
6825
|
+
cwd,
|
|
6826
|
+
agentDir: path.resolve(sessionsRoot, ".."),
|
|
6827
|
+
sessionsRoot,
|
|
6828
|
+
});
|
|
6829
|
+
if (scope.kind !== "resolved") {
|
|
6830
|
+
logger.warn("Skipped invalid managed session scope during global listing", { message: scope.message });
|
|
6831
|
+
continue;
|
|
6832
|
+
}
|
|
6833
|
+
const listing = listManagedCandidates(scope.scope);
|
|
6834
|
+
if (listing.kind !== "complete") {
|
|
6835
|
+
logger.warn("Skipped unreadable managed session scope during global listing", {
|
|
6836
|
+
message: listing.message,
|
|
6837
|
+
});
|
|
6838
|
+
continue;
|
|
6839
|
+
}
|
|
6840
|
+
if (listing.invalid.length > 0)
|
|
6841
|
+
logger.warn("Ignored invalid managed session candidates during global listing", {
|
|
6842
|
+
count: listing.invalid.length,
|
|
6843
|
+
});
|
|
6844
|
+
for (const candidate of listing.owned) logicalFiles.add(candidate.path);
|
|
6845
|
+
}
|
|
6846
|
+
return await collectSessionsFromFiles([...logicalFiles], storage);
|
|
6068
6847
|
} catch {
|
|
6069
6848
|
return [];
|
|
6070
6849
|
}
|