@sayknow-cli/coding-agent 0.4.7 → 0.5.1
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 +122 -0
- package/dist/types/capability/index.d.ts +10 -20
- package/dist/types/capability/types.d.ts +3 -0
- package/dist/types/cli/args.d.ts +0 -2
- package/dist/types/cli/read-cli.d.ts +1 -0
- package/dist/types/cli.d.ts +9 -1
- package/dist/types/commands/daemon.d.ts +2 -2
- package/dist/types/commands/deep-interview.d.ts +9 -0
- package/dist/types/commands/harness.d.ts +22 -0
- package/dist/types/commands/read.d.ts +6 -0
- package/dist/types/commands/sdk.d.ts +20 -2
- package/dist/types/config/keybindings.d.ts +34 -4
- package/dist/types/config/model-profile-contract.d.ts +40 -0
- package/dist/types/config/model-profiles.d.ts +2 -3
- package/dist/types/config/model-registry.d.ts +5 -0
- package/dist/types/config/provider-auth-health.d.ts +14 -0
- package/dist/types/config/provider-ranking.d.ts +59 -0
- package/dist/types/config/settings-schema.d.ts +156 -10
- package/dist/types/config/settings.d.ts +3 -0
- package/dist/types/edit/modes/patch.d.ts +13 -0
- package/dist/types/edit/path-mutation-lock.d.ts +15 -0
- package/dist/types/eval/py/executor.d.ts +1 -1
- package/dist/types/eval/py/runner-artifact.d.ts +8 -0
- package/dist/types/eval/py/tool-bridge.d.ts +1 -2
- package/dist/types/extensibility/extensions/types.d.ts +43 -2
- package/dist/types/extensibility/shared-events.d.ts +6 -0
- package/dist/types/internal-urls/local-root-gc.d.ts +2 -0
- package/dist/types/main.d.ts +33 -2
- package/dist/types/memories/index.d.ts +2 -0
- package/dist/types/modes/acp/acp-agent.d.ts +22 -9
- package/dist/types/modes/components/assistant-message.d.ts +3 -1
- package/dist/types/modes/components/btw-panel.d.ts +1 -0
- package/dist/types/modes/components/irc-sidebar.d.ts +41 -0
- package/dist/types/modes/components/queue-pane.d.ts +8 -0
- package/dist/types/modes/components/queued-message-selector.d.ts +6 -0
- package/dist/types/modes/components/read-tool-group.d.ts +4 -4
- package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +2 -1
- package/dist/types/modes/components/session-observer-overlay.d.ts +4 -0
- package/dist/types/modes/components/settings-selector.d.ts +2 -0
- package/dist/types/modes/components/tool-execution.d.ts +8 -0
- package/dist/types/modes/components/tool-status-header.d.ts +4 -2
- package/dist/types/modes/components/welcome.d.ts +5 -0
- package/dist/types/modes/controllers/btw-controller.d.ts +5 -0
- package/dist/types/modes/controllers/command-controller.d.ts +2 -0
- package/dist/types/modes/controllers/event-controller.d.ts +3 -0
- package/dist/types/modes/interactive-mode.d.ts +13 -2
- package/dist/types/modes/irc-observation-ledger.d.ts +17 -1
- package/dist/types/modes/prompt-action-autocomplete.d.ts +6 -1
- package/dist/types/modes/prompt-suggestion-controller.d.ts +35 -0
- package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +2 -2
- package/dist/types/modes/types.d.ts +6 -0
- package/dist/types/modes/utils/hotkeys-markdown.d.ts +2 -1
- package/dist/types/modes/utils/ui-helpers.d.ts +16 -0
- package/dist/types/runtime/memory-domain.d.ts +9 -0
- package/dist/types/runtime/memory-guard-contract.d.ts +71 -0
- package/dist/types/runtime/memory-guard.d.ts +39 -0
- package/dist/types/runtime/memory-limit.d.ts +11 -0
- package/dist/types/runtime-mcp/content-limits.d.ts +7 -0
- package/dist/types/runtime-mcp/discoverable-tool-metadata.d.ts +1 -1
- package/dist/types/runtime-mcp/manager.d.ts +9 -1
- package/dist/types/runtime-mcp/oauth-flow.d.ts +0 -5
- package/dist/types/runtime-mcp/plugin-network-boundary.d.ts +14 -0
- package/dist/types/runtime-mcp/redaction.d.ts +2 -0
- package/dist/types/runtime-mcp/smithery-auth.d.ts +5 -0
- package/dist/types/sdk/acp/final-text.d.ts +19 -0
- package/dist/types/sdk/acp/mcp.d.ts +23 -0
- package/dist/types/sdk/broker/broker.d.ts +2 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +13 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +9 -1
- package/dist/types/sdk/bus/config-commands.d.ts +2 -2
- package/dist/types/sdk/bus/config.d.ts +2 -0
- package/dist/types/sdk/bus/control-drain-lease.d.ts +52 -0
- package/dist/types/sdk/bus/conversation-store.d.ts +1 -0
- package/dist/types/sdk/bus/daemon-paths.d.ts +1 -0
- package/dist/types/sdk/bus/index.d.ts +52 -4
- package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +37 -0
- package/dist/types/sdk/bus/lifecycle-commands.d.ts +5 -4
- package/dist/types/sdk/bus/notification-orchestration.d.ts +1 -0
- package/dist/types/sdk/bus/notification-service.d.ts +34 -0
- package/dist/types/sdk/bus/operator-runtime.d.ts +1 -0
- package/dist/types/sdk/bus/prompt-reconciliation.d.ts +106 -0
- package/dist/types/sdk/bus/rate-limit-pool.d.ts +2 -0
- package/dist/types/sdk/bus/reconciliation-store.d.ts +65 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +33 -9
- package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -1
- package/dist/types/sdk/bus/telegram-daemon.d.ts +286 -12
- package/dist/types/sdk/bus/telegram-reference.d.ts +2 -0
- package/dist/types/sdk/bus/topic-registry.d.ts +88 -2
- package/dist/types/sdk/client/discovery.d.ts +2 -0
- package/dist/types/sdk/client/liveness.d.ts +7 -0
- package/dist/types/sdk/host/control/operations.d.ts +3 -2
- package/dist/types/sdk/host/host.d.ts +3 -1
- package/dist/types/sdk/host/query/handlers.d.ts +14 -0
- package/dist/types/sdk/index.d.ts +2 -0
- package/dist/types/sdk/lifecycle-session.d.ts +10 -1
- package/dist/types/sdk/prompt-status.d.ts +88 -0
- package/dist/types/sdk/session-directory.d.ts +1 -1
- package/dist/types/sdk/session.d.ts +7 -0
- package/dist/types/sdk/startup-capability.d.ts +9 -0
- package/dist/types/sdk/transport/auth-preface.d.ts +9 -0
- package/dist/types/sdk/transport/index.d.ts +12 -0
- package/dist/types/sdk/transport/relay.d.ts +43 -0
- package/dist/types/sdk/transport/serve-cli.d.ts +4 -0
- package/dist/types/sdk/transport/socket.d.ts +7 -0
- package/dist/types/sdk/transport/stdio.d.ts +3 -0
- package/dist/types/session/agent-session.d.ts +83 -24
- package/dist/types/session/btw-contract.d.ts +16 -0
- package/dist/types/session/fallback-chain-controller.d.ts +18 -2
- package/dist/types/session/internal/managed-session-scope.d.ts +70 -10
- package/dist/types/session/internal/managed-session-storage.d.ts +34 -12
- package/dist/types/session/internal/native-publish-outcome.d.ts +48 -0
- package/dist/types/session/memory-guard-checkpoint-participant.d.ts +69 -0
- package/dist/types/session/session-manager.d.ts +89 -18
- package/dist/types/session/session-storage.d.ts +31 -0
- package/dist/types/session/streaming-output.d.ts +60 -5
- package/dist/types/setup/credential-import.d.ts +5 -1
- package/dist/types/setup/provider-onboarding.d.ts +2 -0
- package/dist/types/skc-runtime/deep-interview-runtime.d.ts +1 -1
- package/dist/types/skc-runtime/deep-interview-stage.d.ts +37 -0
- package/dist/types/skc-runtime/gc-runtime.d.ts +1 -1
- package/dist/types/skc-runtime/linux-proc.d.ts +22 -14
- package/dist/types/skc-runtime/memory-guard-owner-claims.d.ts +47 -0
- package/dist/types/skc-runtime/ralplan-runtime.d.ts +112 -1
- package/dist/types/skc-runtime/repository-binding.d.ts +63 -0
- package/dist/types/skc-runtime/state-runtime.d.ts +3 -1
- package/dist/types/skc-runtime/state-writer.d.ts +5 -1
- package/dist/types/skc-runtime/team-launch.d.ts +1 -1
- package/dist/types/skc-runtime/team-runtime.d.ts +9 -2
- package/dist/types/skc-runtime/team-store.d.ts +12 -0
- package/dist/types/skc-runtime/team-worker-memory-guard.d.ts +92 -0
- package/dist/types/skc-runtime/tmux-owner-isolation.d.ts +7 -0
- package/dist/types/skc-runtime/ultragoal-guard.d.ts +6 -5
- package/dist/types/skc-runtime/ultragoal-receipt-freshness.d.ts +28 -0
- package/dist/types/skc-runtime/ultragoal-runtime.d.ts +43 -5
- package/dist/types/skill-state/workflow-hud.d.ts +3 -0
- package/dist/types/ssh/utils.d.ts +1 -0
- package/dist/types/task/discovery.d.ts +3 -1
- package/dist/types/task/executor.d.ts +6 -0
- package/dist/types/task/index.d.ts +3 -1
- package/dist/types/task/provider-retry-status.d.ts +13 -0
- package/dist/types/task/receipt.d.ts +2 -0
- package/dist/types/task/types.d.ts +133 -0
- package/dist/types/task/ultragoal-redteam-activation.d.ts +33 -0
- package/dist/types/tools/browser/launch.d.ts +7 -0
- package/dist/types/tools/fetch.d.ts +22 -9
- package/dist/types/tools/image-gen.d.ts +63 -1
- package/dist/types/tools/index.d.ts +7 -1
- package/dist/types/tools/output-meta.d.ts +21 -1
- package/dist/types/tools/read-internals.d.ts +12 -0
- package/dist/types/tools/read.d.ts +45 -1
- package/dist/types/tools/resource-gc.d.ts +23 -0
- package/dist/types/tools/skill-discovery.d.ts +7 -0
- package/dist/types/tools/sqlite-reader.d.ts +7 -0
- package/dist/types/tools/tool-result.d.ts +7 -1
- package/dist/types/utils/prompt-suggestion.d.ts +33 -0
- package/dist/types/utils/shell-snapshot.d.ts +12 -0
- package/dist/types/web/insane/bridge.d.ts +1 -0
- package/dist/types/web/insane/url-guard.d.ts +17 -0
- package/package.json +8 -12
- package/scripts/benchmark-sticky-viewport-pr1.ts +239 -0
- package/scripts/capture-platform-shortcut-labels-showcase.ts +342 -0
- package/scripts/capture-sticky-viewport-showcase.ts +292 -0
- package/scripts/compile-args.ts +0 -1
- package/scripts/dogfood-repository-binding.ts +209 -0
- package/scripts/generate-docs-index.ts +4 -1
- package/scripts/generate-sdk-operation-inventory.ts +9 -0
- package/scripts/run-test-manifest.ts +6 -4
- package/scripts/verify-skc-sdk-canonicalization.ts +122 -18
- package/scripts/verify-sticky-viewport-showcase.ts +611 -0
- package/src/capability/index.ts +58 -42
- package/src/capability/types.ts +3 -0
- package/src/cli/args.ts +16 -100
- package/src/cli/fast-help.ts +1 -1
- package/src/cli/mcp-cli.ts +4 -21
- package/src/cli/notify-cli.ts +6 -38
- package/src/cli/read-cli.ts +2 -1
- package/src/cli/setup-cli.ts +10 -4
- package/src/cli.ts +133 -11
- package/src/commands/daemon.ts +22 -5
- package/src/commands/deep-interview.ts +26 -1
- package/src/commands/harness.ts +35 -12
- package/src/commands/read.ts +11 -2
- package/src/commands/sdk.ts +134 -17
- package/src/commands/team.ts +1 -1
- package/src/commit/agentic/index.ts +3 -3
- package/src/commit/map-reduce/index.ts +2 -2
- package/src/config/keybindings.ts +158 -25
- package/src/config/model-profile-contract.ts +174 -0
- package/src/config/model-profiles.ts +51 -10
- package/src/config/model-registry.ts +24 -4
- package/src/config/model-resolver.ts +2 -2
- package/src/config/provider-auth-health.ts +42 -0
- package/src/config/provider-ranking.ts +119 -0
- package/src/config/settings-schema.ts +157 -10
- package/src/config/settings.ts +254 -108
- package/src/coordinator-mcp/model-preset.ts +21 -74
- package/src/defaults/skc/skills/deep-interview/SKILL.md +147 -24
- package/src/defaults/skc/skills/ralplan/SKILL.md +91 -34
- package/src/defaults/skc/skills/team/SKILL.md +1 -1
- package/src/defaults/skc/skills/ultragoal/SKILL.md +68 -17
- package/src/discovery/ssh.ts +12 -1
- package/src/edit/index.ts +3 -6
- package/src/edit/modes/patch.ts +38 -1
- package/src/edit/modes/replace.ts +42 -0
- package/src/edit/path-mutation-lock.ts +112 -0
- package/src/eval/js/tool-bridge.ts +1 -1
- package/src/eval/py/executor.ts +37 -13
- package/src/eval/py/kernel.ts +1 -21
- package/src/eval/py/prelude.py +2 -2
- package/src/eval/py/runner-artifact.ts +84 -0
- package/src/eval/py/tool-bridge.ts +34 -18
- package/src/extensibility/extensions/runner.ts +4 -0
- package/src/extensibility/extensions/types.ts +42 -4
- package/src/extensibility/plugins/marketplace/fetcher.ts +191 -7
- package/src/extensibility/shared-events.ts +5 -0
- package/src/extensibility/skc-plugins/runtime-adapters.ts +2 -1
- package/src/hooks/native-skill-hook.ts +17 -4
- package/src/internal-urls/docs-index.generated.ts +17 -16
- package/src/internal-urls/local-protocol.ts +87 -25
- package/src/internal-urls/local-root-gc.ts +127 -0
- package/src/internal-urls/mcp-protocol.ts +73 -11
- package/src/main.ts +120 -42
- package/src/memories/index.ts +10 -0
- package/src/modes/DESIGN.md +52 -0
- package/src/modes/acp/acp-agent.ts +647 -83
- package/src/modes/acp/acp-event-mapper.ts +106 -12
- package/src/modes/acp/acp-mode.ts +39 -3
- package/src/modes/action-registry.ts +1 -1
- package/src/modes/components/assistant-message.ts +123 -22
- package/src/modes/components/btw-panel.ts +82 -16
- package/src/modes/components/irc-sidebar.ts +176 -62
- package/src/modes/components/model-selector.ts +61 -4
- package/src/modes/components/notifications-settings-editor.ts +1 -1
- package/src/modes/components/oauth-selector.ts +42 -12
- package/src/modes/components/plan-preview-overlay.ts +1 -1
- package/src/modes/components/queue-pane.ts +55 -9
- package/src/modes/components/queued-message-selector.ts +53 -10
- package/src/modes/components/read-tool-group.ts +82 -14
- package/src/modes/components/runtime-mcp-add-wizard.ts +5 -0
- package/src/modes/components/sayknow-pet-widget.ts +20 -8
- package/src/modes/components/session-observer-overlay.ts +187 -32
- package/src/modes/components/settings-selector.ts +60 -18
- package/src/modes/components/tool-execution.ts +138 -27
- package/src/modes/components/tool-status-header.ts +26 -8
- package/src/modes/components/welcome.ts +50 -23
- package/src/modes/controllers/btw-controller.ts +119 -36
- package/src/modes/controllers/command-controller.ts +101 -54
- package/src/modes/controllers/event-controller.ts +119 -27
- package/src/modes/controllers/extension-ui-controller.ts +23 -12
- package/src/modes/controllers/input-controller.ts +100 -8
- package/src/modes/controllers/runtime-mcp-command-controller.ts +29 -6
- package/src/modes/controllers/selector-controller.ts +4 -1
- package/src/modes/interactive-mode.ts +201 -60
- package/src/modes/irc-observation-ledger.ts +71 -15
- package/src/modes/prompt-action-autocomplete.ts +32 -13
- package/src/modes/prompt-suggestion-controller.ts +96 -0
- package/src/modes/runtime-init.ts +20 -2
- package/src/modes/shared/agent-wire/command-dispatch.ts +3 -3
- package/src/modes/shared/agent-wire/workflow-gate-broker.ts +2 -2
- package/src/modes/types.ts +6 -0
- package/src/modes/utils/hotkeys-markdown.ts +64 -36
- package/src/modes/utils/ui-helpers.ts +99 -4
- package/src/prompts/agent-fragments/restricted-bash.md +1 -1
- package/src/prompts/agents/architect.md +17 -3
- package/src/prompts/agents/critic.md +17 -2
- package/src/prompts/agents/executor.md +1 -1
- package/src/prompts/agents/planner.md +8 -1
- package/src/prompts/system/btw-user.md +3 -8
- package/src/prompts/system/prompt-suggestion-system.md +32 -0
- package/src/prompts/system/system-prompt.md +6 -3
- package/src/prompts/tools/cron.md +3 -1
- package/src/prompts/tools/read.md +4 -2
- package/src/prompts/tools/skill-discovery.md +1 -0
- package/src/runtime/memory-domain.ts +66 -0
- package/src/runtime/memory-guard-contract.ts +73 -0
- package/src/runtime/memory-guard.ts +292 -0
- package/src/runtime/memory-limit.ts +50 -0
- package/src/runtime-mcp/client.ts +41 -55
- package/src/runtime-mcp/content-limits.ts +71 -0
- package/src/runtime-mcp/discoverable-tool-metadata.ts +1 -1
- package/src/runtime-mcp/json-rpc.ts +28 -11
- package/src/runtime-mcp/manager.ts +77 -59
- package/src/runtime-mcp/oauth-flow.ts +0 -72
- package/src/runtime-mcp/plugin-network-boundary.ts +101 -0
- package/src/runtime-mcp/redaction.ts +30 -0
- package/src/runtime-mcp/smithery-auth.ts +17 -2
- package/src/runtime-mcp/smithery-connect.ts +11 -1
- package/src/runtime-mcp/transports/http.ts +63 -22
- package/src/sdk/acp/adapter.ts +8 -1
- package/src/sdk/acp/final-text.ts +40 -0
- package/src/sdk/acp/mcp.ts +26 -0
- package/src/sdk/broker/broker.ts +34 -20
- package/src/sdk/broker/identity.ts +22 -12
- package/src/sdk/broker/lifecycle.ts +293 -43
- package/src/sdk/broker/session-index.ts +11 -1
- package/src/sdk/bus/chat-daemon-cli.ts +2 -2
- package/src/sdk/bus/chat-daemon-control.ts +22 -5
- package/src/sdk/bus/config-commands.ts +2 -2
- package/src/sdk/bus/config.ts +10 -2
- package/src/sdk/bus/control-drain-lease.ts +104 -0
- package/src/sdk/bus/conversation-store.ts +40 -6
- package/src/sdk/bus/daemon-paths.ts +2 -0
- package/src/sdk/bus/index.ts +793 -121
- package/src/sdk/bus/kind-aware-reconciliation.ts +242 -0
- package/src/sdk/bus/lifecycle-commands.ts +63 -10
- package/src/sdk/bus/lifecycle-control-runtime.ts +11 -2
- package/src/sdk/bus/lifecycle-orchestrator.ts +32 -1
- package/src/sdk/bus/notification-orchestration.ts +12 -0
- package/src/sdk/bus/notification-service.ts +87 -25
- package/src/sdk/bus/operator-runtime.ts +31 -5
- package/src/sdk/bus/prompt-reconciliation.ts +240 -0
- package/src/sdk/bus/rate-limit-pool.ts +8 -0
- package/src/sdk/bus/recent-activity.ts +90 -26
- package/src/sdk/bus/reconciliation-store.ts +224 -0
- package/src/sdk/bus/telegram-cli.ts +6 -13
- package/src/sdk/bus/telegram-daemon-cli.ts +8 -3
- package/src/sdk/bus/telegram-daemon-contract.ts +34 -9
- package/src/sdk/bus/telegram-daemon-control.ts +161 -40
- package/src/sdk/bus/telegram-daemon.ts +3649 -593
- package/src/sdk/bus/telegram-reference.ts +20 -2
- package/src/sdk/bus/topic-registry.ts +451 -12
- package/src/sdk/cli/session-cli.ts +15 -2
- package/src/sdk/client/discovery.ts +19 -3
- package/src/sdk/client/liveness.ts +49 -0
- package/src/sdk/host/control/dispatch.ts +8 -2
- package/src/sdk/host/control/operations.ts +3 -2
- package/src/sdk/host/host.ts +27 -9
- package/src/sdk/host/query/handlers.ts +125 -5
- package/src/sdk/index.ts +12 -0
- package/src/sdk/lifecycle-session.ts +19 -3
- package/src/sdk/mcp/server.ts +46 -18
- package/src/sdk/prompt-status.ts +97 -0
- package/src/sdk/protocol/operation-inventory.generated.json +145 -0
- package/src/sdk/protocol/operation-registry.ts +24 -4
- package/src/sdk/session-directory.ts +5 -1
- package/src/sdk/session.ts +69 -8
- package/src/sdk/startup-capability.ts +15 -1
- package/src/sdk/transport/auth-preface.ts +82 -0
- package/src/sdk/transport/index.ts +18 -0
- package/src/sdk/transport/relay.ts +252 -0
- package/src/sdk/transport/serve-cli.ts +105 -0
- package/src/sdk/transport/socket.ts +197 -0
- package/src/sdk/transport/stdio.ts +17 -0
- package/src/session/agent-session.ts +1112 -314
- package/src/session/blob-store.ts +25 -7
- package/src/session/btw-contract.ts +59 -0
- package/src/session/fallback-chain-controller.ts +47 -8
- package/src/session/internal/managed-session-scope.ts +1426 -259
- package/src/session/internal/managed-session-storage.ts +414 -238
- package/src/session/internal/native-publish-outcome.ts +271 -0
- package/src/session/memory-guard-checkpoint-participant.ts +131 -0
- package/src/session/session-manager.ts +2079 -469
- package/src/session/session-storage.ts +151 -11
- package/src/session/streaming-output.ts +262 -43
- package/src/setup/credential-import.ts +28 -3
- package/src/setup/provider-onboarding.ts +49 -5
- package/src/setup/provider-presets.json +2 -1
- package/src/skc-runtime/deep-interview-runtime.ts +303 -36
- package/src/skc-runtime/deep-interview-stage.ts +1016 -0
- package/src/skc-runtime/deep-interview-state.ts +8 -5
- package/src/skc-runtime/gc-render.ts +1 -0
- package/src/skc-runtime/gc-runtime.ts +11 -1
- package/src/skc-runtime/linux-proc.ts +70 -36
- package/src/skc-runtime/managed-owner-supervisor.ts +83 -4
- package/src/skc-runtime/memory-guard-owner-claims.ts +389 -0
- package/src/skc-runtime/ralplan-runtime.ts +1057 -75
- package/src/skc-runtime/repository-binding.ts +267 -0
- package/src/skc-runtime/session-state-sidecar.ts +25 -9
- package/src/skc-runtime/state-runtime.ts +50 -12
- package/src/skc-runtime/state-writer.ts +68 -56
- package/src/skc-runtime/team-launch.ts +34 -5
- package/src/skc-runtime/team-runtime.ts +41 -1
- package/src/skc-runtime/team-store.ts +32 -0
- package/src/skc-runtime/team-worker-memory-guard.ts +363 -0
- package/src/skc-runtime/team-workers.ts +1 -0
- package/src/skc-runtime/tmux-common.ts +13 -2
- package/src/skc-runtime/tmux-owner-isolation.ts +20 -1
- package/src/skc-runtime/tmux-sessions.ts +45 -6
- package/src/skc-runtime/ultragoal-guard.ts +93 -15
- package/src/skc-runtime/ultragoal-receipt-freshness.ts +123 -0
- package/src/skc-runtime/ultragoal-runtime.ts +469 -38
- package/src/skc-runtime/workflow-manifest.generated.json +195 -5
- package/src/skc-runtime/workflow-manifest.ts +67 -3
- package/src/skill-state/workflow-hud.ts +25 -2
- package/src/skill-state/workflow-mutation-guard.ts +104 -12
- package/src/slash-commands/builtin-registry.ts +1 -1
- package/src/ssh/utils.ts +14 -0
- package/src/task/discovery.ts +8 -2
- package/src/task/executor.ts +165 -47
- package/src/task/index.ts +123 -15
- package/src/task/provider-retry-status.ts +88 -0
- package/src/task/receipt.ts +3 -0
- package/src/task/render.ts +45 -26
- package/src/task/skc-command.ts +2 -2
- package/src/task/types.ts +55 -10
- package/src/task/ultragoal-redteam-activation.ts +75 -0
- package/src/tools/ask.ts +1 -1
- package/src/tools/ast-edit.ts +2 -2
- package/src/tools/bash-allowed-prefixes.ts +45 -1
- package/src/tools/bash-pty-selection.ts +2 -2
- package/src/tools/browser/launch.ts +41 -6
- package/src/tools/browser/screenshot-format.ts +6 -1
- package/src/tools/cron.ts +1 -1
- package/src/tools/fetch.ts +127 -154
- package/src/tools/image-gen.ts +476 -23
- package/src/tools/index.ts +7 -1
- package/src/tools/output-meta.ts +176 -9
- package/src/tools/path-utils.ts +5 -1
- package/src/tools/read-internals.ts +44 -0
- package/src/tools/read.ts +1544 -310
- package/src/tools/resource-gc.ts +665 -125
- package/src/tools/skill-discovery.ts +39 -3
- package/src/tools/sqlite-reader.ts +63 -5
- package/src/tools/subagent-render.ts +74 -16
- package/src/tools/subagent.ts +2 -0
- package/src/tools/tool-result.ts +14 -1
- package/src/utils/edit-mode.ts +2 -2
- package/src/utils/pasted-image-path.ts +15 -6
- package/src/utils/prompt-suggestion.ts +265 -0
- package/src/utils/shell-snapshot.ts +173 -63
- package/src/utils/sixel.ts +3 -3
- package/src/web/insane/bridge.ts +5 -1
- package/src/web/insane/url-guard.ts +140 -27
- package/src/web/scrapers/docs-rs.ts +30 -5
- package/src/web/scrapers/types.ts +19 -49
- package/src/web/scrapers/utils.ts +12 -26
- package/src/web/search/providers/codex.ts +1 -1
- package/src/workflow/workflow-intent-diff.ts +4 -1
|
@@ -18,7 +18,6 @@ import * as native from "@sayknow-cli/natives";
|
|
|
18
18
|
import { getTerminalId } from "@sayknow-cli/tui";
|
|
19
19
|
import {
|
|
20
20
|
getBlobsDir,
|
|
21
|
-
getAgentDir as getDefaultAgentDir,
|
|
22
21
|
getProjectDir,
|
|
23
22
|
getSessionsDir,
|
|
24
23
|
getTerminalSessionsDir,
|
|
@@ -33,8 +32,8 @@ import {
|
|
|
33
32
|
} from "@sayknow-cli/utils";
|
|
34
33
|
import type { TtsrInjectionRecord } from "../export/ttsr";
|
|
35
34
|
import type { ManagedLegacyLocalMigrationSource } from "../internal-urls/local-protocol";
|
|
35
|
+
import { assertSafePathComponent } from "../skc-runtime/session-layout";
|
|
36
36
|
import { writeTextAtomic } from "../skc-runtime/state-writer";
|
|
37
|
-
|
|
38
37
|
import * as git from "../utils/git";
|
|
39
38
|
import { ArtifactManager } from "./artifacts";
|
|
40
39
|
import {
|
|
@@ -58,20 +57,45 @@ import {
|
|
|
58
57
|
canonicalizeTrustedPath,
|
|
59
58
|
deleteManagedSessionCandidate,
|
|
60
59
|
listManagedCandidates,
|
|
60
|
+
type ManagedCandidateWriteAuthority,
|
|
61
61
|
type ManagedMigrationPolicy,
|
|
62
|
+
type ManagedOpenCandidateResult,
|
|
62
63
|
type ManagedScope,
|
|
63
64
|
managedDirectoryAuthorityForScope,
|
|
64
65
|
managedRootForScope,
|
|
65
66
|
openManagedCandidateForWrite,
|
|
66
67
|
prepareManagedSessionScopeForWriteSync,
|
|
67
68
|
resolveManagedScope,
|
|
69
|
+
resolveManagedScopeForWrite,
|
|
68
70
|
} from "./internal/managed-session-scope";
|
|
69
71
|
import {
|
|
72
|
+
assertManagedDirectoryRoot,
|
|
73
|
+
fsyncManagedArtifactTree,
|
|
70
74
|
type ManagedDirectoryRoot,
|
|
71
75
|
type ManagedFileSnapshot,
|
|
72
76
|
ManagedSessionDescendantStore,
|
|
77
|
+
type ManagedSessionSecurityPolicy,
|
|
78
|
+
mayCleanManagedTreeStaging,
|
|
73
79
|
retainManagedDirectoryAuthority,
|
|
74
80
|
} from "./internal/managed-session-storage";
|
|
81
|
+
import { classifyNativePublishOutcome, formatNativePublishDiagnostic } from "./internal/native-publish-outcome";
|
|
82
|
+
import {
|
|
83
|
+
hasOnlyKeys as hasOnlyMemoryGuardKeys,
|
|
84
|
+
isMemoryGuardDecimalString,
|
|
85
|
+
isMemoryGuardRelativePath,
|
|
86
|
+
isMemoryGuardSha256Hex,
|
|
87
|
+
type MemoryGuardCheckpointBlobAuthorityV1,
|
|
88
|
+
type MemoryGuardCheckpointBlobManifestEntryV1,
|
|
89
|
+
type MemoryGuardCheckpointBlobManifestV1,
|
|
90
|
+
type MemoryGuardCreateCheckpointInput,
|
|
91
|
+
type MemoryGuardParticipantDescriptorV1,
|
|
92
|
+
type MemoryGuardParticipantIngressLease,
|
|
93
|
+
type MemoryGuardRestoreInput,
|
|
94
|
+
type MemoryGuardRestoreResult,
|
|
95
|
+
type MemoryGuardSessionManagerCheckpointV1,
|
|
96
|
+
memoryGuardCanonicalJson,
|
|
97
|
+
memoryGuardSha256Hex,
|
|
98
|
+
} from "./memory-guard-checkpoint-participant";
|
|
75
99
|
|
|
76
100
|
import {
|
|
77
101
|
type BashExecutionMessage,
|
|
@@ -127,6 +151,36 @@ export interface NewSessionOptions {
|
|
|
127
151
|
drop?: boolean;
|
|
128
152
|
}
|
|
129
153
|
|
|
154
|
+
/** Internal successor prepared without changing the manager's visible identity. */
|
|
155
|
+
export interface PreparedNewSession {
|
|
156
|
+
readonly sessionId: string;
|
|
157
|
+
readonly sessionFile: string | undefined;
|
|
158
|
+
readonly artifactsDir: string | null;
|
|
159
|
+
readonly managedLegacyLocalMigrationSource: ManagedLegacyLocalMigrationSource | null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
interface PreparedSessionIndex {
|
|
163
|
+
readonly byId: Map<string, SessionEntry>;
|
|
164
|
+
readonly labelsById: Map<string, string>;
|
|
165
|
+
readonly leafId: string | null;
|
|
166
|
+
readonly usageStatistics: UsageStatistics;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
interface PreparedNewSessionState extends PreparedNewSession {
|
|
170
|
+
readonly header: SessionHeader;
|
|
171
|
+
readonly fileEntries: FileEntry[];
|
|
172
|
+
readonly sessionName: string | undefined;
|
|
173
|
+
readonly titleSource: "auto" | "user" | undefined;
|
|
174
|
+
residentFileEntries?: FileEntry[];
|
|
175
|
+
residentTextBlobStore?: BlobStore;
|
|
176
|
+
index?: PreparedSessionIndex;
|
|
177
|
+
flushed: boolean;
|
|
178
|
+
committed: boolean;
|
|
179
|
+
discarded: boolean;
|
|
180
|
+
persistenceWriter?: NdjsonFileWriter;
|
|
181
|
+
persistenceTempPath?: string;
|
|
182
|
+
}
|
|
183
|
+
|
|
130
184
|
export interface SessionEntryBase {
|
|
131
185
|
type: string;
|
|
132
186
|
id: string;
|
|
@@ -443,6 +497,238 @@ export type DefaultModelSelectionStage = {
|
|
|
443
497
|
readonly tempPath: string | undefined;
|
|
444
498
|
readonly persistsToExistingFile: boolean;
|
|
445
499
|
};
|
|
500
|
+
export interface SessionManagerRevisionSnapshot {
|
|
501
|
+
entry: number;
|
|
502
|
+
leaf: number;
|
|
503
|
+
headerExport: number;
|
|
504
|
+
label: number;
|
|
505
|
+
replayMetadata: number;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
export interface SessionManagerCheckpointRevisionStrings {
|
|
509
|
+
entry: string;
|
|
510
|
+
leaf: string;
|
|
511
|
+
headerExport: string;
|
|
512
|
+
label: string;
|
|
513
|
+
replayMetadata: string;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function assertMemoryGuardSessionId(sessionId: string): void {
|
|
517
|
+
assertSafePathComponent(sessionId, "memory guard checkpoint session id");
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function memoryGuardParticipantRoot(checkpointRoot: string, sessionId: string): string {
|
|
521
|
+
assertMemoryGuardSessionId(sessionId);
|
|
522
|
+
return path.join(checkpointRoot, "participants", sessionId);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function memoryGuardParticipantRelativePath(sessionId: string, suffix: string): string {
|
|
526
|
+
assertMemoryGuardSessionId(sessionId);
|
|
527
|
+
return `participants/${sessionId}/${suffix}`;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async function fsyncDirectoryPath(directoryPath: string): Promise<void> {
|
|
531
|
+
const directory = await fs.promises.open(directoryPath, "r");
|
|
532
|
+
try {
|
|
533
|
+
await directory.sync();
|
|
534
|
+
} finally {
|
|
535
|
+
await directory.close();
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
async function ensureOwnerOnlyDirectory(directoryPath: string): Promise<void> {
|
|
540
|
+
await fs.promises.mkdir(directoryPath, { recursive: true, mode: 0o700 });
|
|
541
|
+
await fs.promises.chmod(directoryPath, 0o700).catch(() => undefined);
|
|
542
|
+
await fsyncDirectoryPath(directoryPath);
|
|
543
|
+
}
|
|
544
|
+
interface CreatedDirectoryIdentity {
|
|
545
|
+
dev: bigint;
|
|
546
|
+
ino: bigint;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
async function ensureOwnerOnlyDirectoryTracked(directoryPath: string): Promise<CreatedDirectoryIdentity | undefined> {
|
|
550
|
+
const createdPath = await fs.promises.mkdir(directoryPath, { recursive: true, mode: 0o700 });
|
|
551
|
+
await fs.promises.chmod(directoryPath, 0o700).catch(() => undefined);
|
|
552
|
+
await fsyncDirectoryPath(directoryPath);
|
|
553
|
+
if (createdPath === undefined) return undefined;
|
|
554
|
+
const stat = await fs.promises.lstat(directoryPath, { bigint: true });
|
|
555
|
+
return { dev: stat.dev, ino: stat.ino };
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async function removeCreatedDirectoryIfEmpty(
|
|
559
|
+
directoryPath: string,
|
|
560
|
+
created: CreatedDirectoryIdentity | undefined,
|
|
561
|
+
): Promise<void> {
|
|
562
|
+
if (!created) return;
|
|
563
|
+
const stat = await fs.promises.lstat(directoryPath, { bigint: true }).catch(() => undefined);
|
|
564
|
+
if (!stat || stat.dev !== created.dev || stat.ino !== created.ino) return;
|
|
565
|
+
await fs.promises.rmdir(directoryPath).catch(() => undefined);
|
|
566
|
+
await fsyncDirectoryPath(path.dirname(directoryPath)).catch(() => undefined);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
async function writeOwnerOnlyFileNoReplace(filePath: string, content: Uint8Array | string): Promise<void> {
|
|
570
|
+
await ensureOwnerOnlyDirectory(path.dirname(filePath));
|
|
571
|
+
const handle = await fs.promises.open(filePath, "wx", 0o600);
|
|
572
|
+
try {
|
|
573
|
+
await handle.writeFile(content);
|
|
574
|
+
await handle.sync();
|
|
575
|
+
} finally {
|
|
576
|
+
await handle.close();
|
|
577
|
+
}
|
|
578
|
+
await fs.promises.chmod(filePath, 0o600).catch(() => undefined);
|
|
579
|
+
await fsyncDirectoryPath(path.dirname(filePath));
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const MEMORY_GUARD_CHECKPOINT_FILE_MAX_BYTES = 64 * 1024 * 1024;
|
|
583
|
+
const MEMORY_GUARD_CHECKPOINT_BLOB_MAX_ENTRIES = 4096;
|
|
584
|
+
const MEMORY_GUARD_CHECKPOINT_BLOB_TOTAL_MAX_BYTES = 64 * 1024 * 1024;
|
|
585
|
+
|
|
586
|
+
function collectCheckpointBlobRefs(value: unknown, refs: Set<string> = new Set(), key?: string): Set<string> {
|
|
587
|
+
const addRef = (candidate: unknown): void => {
|
|
588
|
+
if (typeof candidate === "string" && parseBlobRef(candidate)) refs.add(candidate);
|
|
589
|
+
};
|
|
590
|
+
if (Array.isArray(value)) {
|
|
591
|
+
for (const item of value) collectCheckpointBlobRefs(item, refs, key);
|
|
592
|
+
return refs;
|
|
593
|
+
}
|
|
594
|
+
if (!value || typeof value !== "object") return refs;
|
|
595
|
+
if (isRecord(value) && value.kind === "cold_spill" && typeof value.ref === "string" && parseBlobRef(value.ref))
|
|
596
|
+
refs.add(value.ref);
|
|
597
|
+
if (isImageBlock(value) && key === TEXT_CONTENT_KEY) addRef(value.data);
|
|
598
|
+
if (hasImageUrl(value)) {
|
|
599
|
+
if (typeof value.image_url === "string") addRef(value.image_url);
|
|
600
|
+
else addRef(value.image_url.url);
|
|
601
|
+
}
|
|
602
|
+
for (const [childKey, item] of Object.entries(value as Record<string, unknown>)) {
|
|
603
|
+
if (childKey === "data" && key !== TEXT_CONTENT_KEY) addRef(item);
|
|
604
|
+
collectCheckpointBlobRefs(item, refs, childKey);
|
|
605
|
+
}
|
|
606
|
+
return refs;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function decodeCheckpointUtf8(data: Uint8Array): string | null {
|
|
610
|
+
try {
|
|
611
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(data);
|
|
612
|
+
} catch {
|
|
613
|
+
return null;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function validateMemoryGuardBlobAuthority(value: unknown): value is MemoryGuardCheckpointBlobAuthorityV1 {
|
|
618
|
+
if (
|
|
619
|
+
!isRecord(value) ||
|
|
620
|
+
!hasOnlyMemoryGuardKeys(value, ["kind", "manifest_relative_path", "manifest_sha256", "root_relative_path"])
|
|
621
|
+
)
|
|
622
|
+
return false;
|
|
623
|
+
return (
|
|
624
|
+
value.kind === "checkpoint_blob_tree_v1" &&
|
|
625
|
+
isMemoryGuardRelativePath(value.manifest_relative_path) &&
|
|
626
|
+
isMemoryGuardSha256Hex(value.manifest_sha256) &&
|
|
627
|
+
isMemoryGuardRelativePath(value.root_relative_path)
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function validateMemoryGuardTranscriptDescriptor(
|
|
632
|
+
value: unknown,
|
|
633
|
+
): value is MemoryGuardSessionManagerCheckpointV1["transcript"] {
|
|
634
|
+
if (!isRecord(value) || !hasOnlyMemoryGuardKeys(value, ["bytes", "relative_path", "sha256"])) return false;
|
|
635
|
+
return (
|
|
636
|
+
isMemoryGuardDecimalString(value.bytes) &&
|
|
637
|
+
isMemoryGuardRelativePath(value.relative_path) &&
|
|
638
|
+
isMemoryGuardSha256Hex(value.sha256)
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function validateMemoryGuardRevisions(value: unknown): value is SessionManagerCheckpointRevisionStrings {
|
|
643
|
+
if (!isRecord(value) || !hasOnlyMemoryGuardKeys(value, ["entry", "leaf", "headerExport", "label", "replayMetadata"]))
|
|
644
|
+
return false;
|
|
645
|
+
return (
|
|
646
|
+
isMemoryGuardDecimalString(value.entry) &&
|
|
647
|
+
isMemoryGuardDecimalString(value.leaf) &&
|
|
648
|
+
isMemoryGuardDecimalString(value.headerExport) &&
|
|
649
|
+
isMemoryGuardDecimalString(value.label) &&
|
|
650
|
+
isMemoryGuardDecimalString(value.replayMetadata)
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function validateMemoryGuardCheckpoint(value: unknown): value is MemoryGuardSessionManagerCheckpointV1 {
|
|
655
|
+
if (
|
|
656
|
+
!isRecord(value) ||
|
|
657
|
+
!hasOnlyMemoryGuardKeys(value, [
|
|
658
|
+
"blob_authority",
|
|
659
|
+
"revisions",
|
|
660
|
+
"schema_version",
|
|
661
|
+
"session_id",
|
|
662
|
+
"session_name",
|
|
663
|
+
"transcript",
|
|
664
|
+
])
|
|
665
|
+
)
|
|
666
|
+
return false;
|
|
667
|
+
return (
|
|
668
|
+
value.schema_version === 1 &&
|
|
669
|
+
typeof value.session_id === "string" &&
|
|
670
|
+
value.session_id.length > 0 &&
|
|
671
|
+
(value.session_name === null || typeof value.session_name === "string") &&
|
|
672
|
+
validateMemoryGuardBlobAuthority(value.blob_authority) &&
|
|
673
|
+
validateMemoryGuardRevisions(value.revisions) &&
|
|
674
|
+
validateMemoryGuardTranscriptDescriptor(value.transcript)
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function validateMemoryGuardBlobManifestEntry(value: unknown): value is MemoryGuardCheckpointBlobManifestEntryV1 {
|
|
679
|
+
if (!isRecord(value) || !hasOnlyMemoryGuardKeys(value, ["bytes", "relative_path", "sha256"])) return false;
|
|
680
|
+
return (
|
|
681
|
+
isMemoryGuardDecimalString(value.bytes) &&
|
|
682
|
+
isMemoryGuardRelativePath(value.relative_path) &&
|
|
683
|
+
isMemoryGuardSha256Hex(value.sha256)
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function validateMemoryGuardBlobManifest(value: unknown): value is MemoryGuardCheckpointBlobManifestV1 {
|
|
688
|
+
if (!isRecord(value) || !hasOnlyMemoryGuardKeys(value, ["entries", "schema_version"])) return false;
|
|
689
|
+
if (value.schema_version !== 1 || !Array.isArray(value.entries)) return false;
|
|
690
|
+
return value.entries.every(validateMemoryGuardBlobManifestEntry);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function memoryGuardParticipantMatchesCheckpoint(
|
|
694
|
+
participant: MemoryGuardParticipantDescriptorV1,
|
|
695
|
+
checkpoint: MemoryGuardSessionManagerCheckpointV1,
|
|
696
|
+
): boolean {
|
|
697
|
+
return (
|
|
698
|
+
participant.session_id === checkpoint.session_id &&
|
|
699
|
+
participant.session_name === checkpoint.session_name &&
|
|
700
|
+
JSON.stringify(participant.checkpoint) === JSON.stringify(checkpoint.blob_authority) &&
|
|
701
|
+
JSON.stringify(participant.revisions) === JSON.stringify(checkpoint.revisions) &&
|
|
702
|
+
JSON.stringify(participant.transcript) === JSON.stringify(checkpoint.transcript)
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function readCheckpointAuthorityFile(
|
|
707
|
+
authority: native.RecoveryFsRoot,
|
|
708
|
+
relativePath: string,
|
|
709
|
+
maxBytes: number,
|
|
710
|
+
): Uint8Array | null {
|
|
711
|
+
const result = authority.readManaged(relativePath);
|
|
712
|
+
if (!result.ok || !result.data || result.data.byteLength > maxBytes) return null;
|
|
713
|
+
return result.data;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function toCanonicalRevisionString(name: keyof SessionManagerRevisionSnapshot, value: number): string {
|
|
717
|
+
if (!Number.isSafeInteger(value) || value < 0) throw new Error(`invalid_session_manager_revision:${name}`);
|
|
718
|
+
return String(value);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
export function toSessionManagerCheckpointRevisionStrings(
|
|
722
|
+
snapshot: SessionManagerRevisionSnapshot,
|
|
723
|
+
): SessionManagerCheckpointRevisionStrings {
|
|
724
|
+
return {
|
|
725
|
+
entry: toCanonicalRevisionString("entry", snapshot.entry),
|
|
726
|
+
leaf: toCanonicalRevisionString("leaf", snapshot.leaf),
|
|
727
|
+
headerExport: toCanonicalRevisionString("headerExport", snapshot.headerExport),
|
|
728
|
+
label: toCanonicalRevisionString("label", snapshot.label),
|
|
729
|
+
replayMetadata: toCanonicalRevisionString("replayMetadata", snapshot.replayMetadata),
|
|
730
|
+
};
|
|
731
|
+
}
|
|
446
732
|
|
|
447
733
|
export type DefaultModelSelectionPromotion =
|
|
448
734
|
| { readonly kind: "promoted" }
|
|
@@ -540,6 +826,13 @@ export class SessionMigrationPolicyError extends Error {
|
|
|
540
826
|
}
|
|
541
827
|
}
|
|
542
828
|
|
|
829
|
+
export class SessionArtifactCapacityError extends Error {
|
|
830
|
+
constructor(message: string) {
|
|
831
|
+
super(message);
|
|
832
|
+
this.name = "SessionArtifactCapacityError";
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
543
836
|
export type ResumeTailInspection = ResumeTailResumable | ResumeTailTerminal | ResumeTailError;
|
|
544
837
|
export interface StrictSessionOpenSuccess {
|
|
545
838
|
kind: "opened";
|
|
@@ -548,7 +841,8 @@ export interface StrictSessionOpenSuccess {
|
|
|
548
841
|
|
|
549
842
|
export interface StrictSessionOpenFailure {
|
|
550
843
|
kind: "error";
|
|
551
|
-
reason: ResumeTailError["reason"] | "identity-mismatch" | "migration-required";
|
|
844
|
+
reason: ResumeTailError["reason"] | "identity-mismatch" | "migration-required" | "artifact_capacity_exceeded";
|
|
845
|
+
message?: string;
|
|
552
846
|
}
|
|
553
847
|
|
|
554
848
|
/** Exact transcript bytes and authority captured from one descriptor-bound read. */
|
|
@@ -1317,6 +1611,18 @@ function freezeInternalReadSnapshot<T>(value: T): T {
|
|
|
1317
1611
|
return copy;
|
|
1318
1612
|
}
|
|
1319
1613
|
|
|
1614
|
+
function managedScopeStartupError(
|
|
1615
|
+
action: "resolve" | "prepare",
|
|
1616
|
+
failure: Extract<ReturnType<typeof resolveManagedScopeForWrite>, { kind: "error" }>,
|
|
1617
|
+
): Error {
|
|
1618
|
+
return new Error(`Could not ${action} managed session scope.`, {
|
|
1619
|
+
cause: {
|
|
1620
|
+
classification: failure.cause?.classification ?? failure.code,
|
|
1621
|
+
...(failure.cause?.diagnostic === undefined ? {} : { diagnostic: failure.cause.diagnostic }),
|
|
1622
|
+
},
|
|
1623
|
+
});
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1320
1626
|
/** Resolve and prepare the default v2 session scope before any managed writer exists. */
|
|
1321
1627
|
function computeDefaultSessionDir(
|
|
1322
1628
|
cwd: string,
|
|
@@ -1324,10 +1630,13 @@ function computeDefaultSessionDir(
|
|
|
1324
1630
|
sessionsRoot: string = getSessionsDir(),
|
|
1325
1631
|
): string {
|
|
1326
1632
|
if (!(storage instanceof FileSessionStorage)) throw new SessionManagedStorageError();
|
|
1327
|
-
const resolved =
|
|
1328
|
-
if (resolved.kind === "error") throw
|
|
1329
|
-
const prepared = prepareManagedSessionScopeForWriteSync(
|
|
1330
|
-
|
|
1633
|
+
const resolved = resolveManagedScopeForWrite({ cwd, agentDir: path.resolve(sessionsRoot, ".."), sessionsRoot });
|
|
1634
|
+
if (resolved.kind === "error") throw managedScopeStartupError("resolve", resolved);
|
|
1635
|
+
const prepared = prepareManagedSessionScopeForWriteSync(
|
|
1636
|
+
resolved.scope,
|
|
1637
|
+
process.platform === "win32" ? "windows-existing-verify-first" : "default",
|
|
1638
|
+
);
|
|
1639
|
+
if (prepared.kind === "error") throw managedScopeStartupError("prepare", prepared);
|
|
1331
1640
|
return prepared.scope.directoryPath;
|
|
1332
1641
|
}
|
|
1333
1642
|
|
|
@@ -1408,6 +1717,13 @@ function managedFileSnapshotEquals(left: ManagedFileSnapshot | null, right: Mana
|
|
|
1408
1717
|
);
|
|
1409
1718
|
}
|
|
1410
1719
|
const trustedSessionDestinations = new WeakSet<SessionDestination>();
|
|
1720
|
+
const managedSecurityPolicies = new WeakMap<ManagedSessionSecurityContext, ManagedSessionSecurityPolicy>();
|
|
1721
|
+
|
|
1722
|
+
function managedSecurityPolicyForContext(securityContext: ManagedSessionSecurityContext): ManagedSessionSecurityPolicy {
|
|
1723
|
+
const policy = managedSecurityPolicies.get(securityContext);
|
|
1724
|
+
if (!policy) throw new Error("Managed session security policy unavailable");
|
|
1725
|
+
return policy;
|
|
1726
|
+
}
|
|
1411
1727
|
|
|
1412
1728
|
function managedStoreFromContext(
|
|
1413
1729
|
securityContext: ManagedSessionSecurityContext,
|
|
@@ -1419,6 +1735,7 @@ function managedStoreFromContext(
|
|
|
1419
1735
|
securityContext.retainedAuthority
|
|
1420
1736
|
? { authority: securityContext.retainedAuthority, authorityBaseDir: directory }
|
|
1421
1737
|
: undefined,
|
|
1738
|
+
managedSecurityPolicyForContext(securityContext),
|
|
1422
1739
|
);
|
|
1423
1740
|
}
|
|
1424
1741
|
|
|
@@ -1431,6 +1748,10 @@ function freezeManagedDestination(scope: ManagedScope): SessionDestination {
|
|
|
1431
1748
|
rootAuthority,
|
|
1432
1749
|
retainedAuthority: managedDirectoryAuthorityForScope(scope),
|
|
1433
1750
|
});
|
|
1751
|
+
managedSecurityPolicies.set(
|
|
1752
|
+
securityContext,
|
|
1753
|
+
scope.platform === "win32" ? "windows-existing-verify-first" : "default",
|
|
1754
|
+
);
|
|
1434
1755
|
const destination = Object.freeze({ kind: "managed" as const, directory: scope.directoryPath, securityContext });
|
|
1435
1756
|
trustedSessionDestinations.add(destination);
|
|
1436
1757
|
return destination;
|
|
@@ -1445,10 +1766,17 @@ function explicitDestination(directory: string): SessionDestination {
|
|
|
1445
1766
|
function managedDestination(cwd: string, storage: SessionStorage, agentDir?: string): SessionDestination {
|
|
1446
1767
|
if (!(storage instanceof FileSessionStorage)) throw new SessionManagedStorageError();
|
|
1447
1768
|
const sessionsRoot = getSessionsDir(agentDir);
|
|
1448
|
-
const resolved =
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1769
|
+
const resolved = resolveManagedScopeForWrite({
|
|
1770
|
+
cwd,
|
|
1771
|
+
agentDir: agentDir ?? path.resolve(sessionsRoot, ".."),
|
|
1772
|
+
sessionsRoot,
|
|
1773
|
+
});
|
|
1774
|
+
if (resolved.kind === "error") throw managedScopeStartupError("resolve", resolved);
|
|
1775
|
+
const prepared = prepareManagedSessionScopeForWriteSync(
|
|
1776
|
+
resolved.scope,
|
|
1777
|
+
process.platform === "win32" ? "windows-existing-verify-first" : "default",
|
|
1778
|
+
);
|
|
1779
|
+
if (prepared.kind === "error") throw managedScopeStartupError("prepare", prepared);
|
|
1452
1780
|
return freezeManagedDestination(prepared.scope);
|
|
1453
1781
|
}
|
|
1454
1782
|
|
|
@@ -1814,12 +2142,46 @@ function inspectResumeSessionFile(
|
|
|
1814
2142
|
mtimeNs: snapshot.mtimeNs,
|
|
1815
2143
|
sha256: crypto.createHash("sha256").update(bytes).digest("hex"),
|
|
1816
2144
|
};
|
|
1817
|
-
return { identity, content:
|
|
2145
|
+
return { identity, content: bytes, entries, context, migrationApplied };
|
|
1818
2146
|
} catch {
|
|
1819
2147
|
return { kind: "error", reason: "malformed" };
|
|
1820
2148
|
}
|
|
1821
2149
|
}
|
|
1822
2150
|
|
|
2151
|
+
/** Revalidate previously parsed authority without another decode, parse, migration, or context build. */
|
|
2152
|
+
function revalidateResumeSessionIdentity(
|
|
2153
|
+
filePath: string,
|
|
2154
|
+
storage: SessionStorage,
|
|
2155
|
+
expected: ResumeSessionIdentity,
|
|
2156
|
+
): StrictSessionOpenFailure | { kind: "valid" } {
|
|
2157
|
+
const canonicalPath = resolveEquivalentPath(path.resolve(filePath));
|
|
2158
|
+
let before: SessionStorageStat;
|
|
2159
|
+
let snapshot: SessionStorageSnapshot;
|
|
2160
|
+
let after: SessionStorageStat;
|
|
2161
|
+
try {
|
|
2162
|
+
before = storage.statSync(canonicalPath);
|
|
2163
|
+
if (!before.isFile || !storage.readSnapshotSync) return { kind: "error", reason: "read-failed" };
|
|
2164
|
+
snapshot = storage.readSnapshotSync(canonicalPath);
|
|
2165
|
+
after = storage.statSync(canonicalPath);
|
|
2166
|
+
} catch (error) {
|
|
2167
|
+
return resumeReadFailure(error, storage, canonicalPath);
|
|
2168
|
+
}
|
|
2169
|
+
if (!sameResumeStat(before, snapshot.stat) || !sameResumeStat(snapshot.stat, after)) {
|
|
2170
|
+
return { kind: "error", reason: "unstable" };
|
|
2171
|
+
}
|
|
2172
|
+
const observed: ResumeSessionIdentity = {
|
|
2173
|
+
canonicalPath,
|
|
2174
|
+
sessionId: expected.sessionId,
|
|
2175
|
+
dev: snapshot.stat.dev,
|
|
2176
|
+
ino: snapshot.stat.ino,
|
|
2177
|
+
size: snapshot.stat.size,
|
|
2178
|
+
mtimeMs: snapshot.stat.mtimeMs,
|
|
2179
|
+
mtimeNs: snapshot.stat.mtimeNs,
|
|
2180
|
+
sha256: crypto.createHash("sha256").update(snapshot.bytes).digest("hex"),
|
|
2181
|
+
};
|
|
2182
|
+
return sameResumeIdentity(expected, observed) ? { kind: "valid" } : { kind: "error", reason: "identity-mismatch" };
|
|
2183
|
+
}
|
|
2184
|
+
|
|
1823
2185
|
/**
|
|
1824
2186
|
* Convert legacy persisted blob references in loaded entries into resident sentinels.
|
|
1825
2187
|
* Images then materialize lazily at provider/display/export chokepoints instead of
|
|
@@ -2035,41 +2397,90 @@ export async function recoverOrphanedBackups(sessionDir: string, storage: Sessio
|
|
|
2035
2397
|
}
|
|
2036
2398
|
|
|
2037
2399
|
/**
|
|
2038
|
-
*
|
|
2039
|
-
*
|
|
2040
|
-
*
|
|
2400
|
+
* Returns session metadata sorted by mtime (newest first).
|
|
2401
|
+
*
|
|
2402
|
+
* Directory entries are cheap to stat, but opening every transcript before applying a
|
|
2403
|
+
* small caller limit makes welcome-screen startup scale with the entire session history.
|
|
2404
|
+
* Rank paths first, then read bounded batches until the requested number of valid
|
|
2405
|
+
* sessions has been found. Invalid or future-version files do not prevent older valid
|
|
2406
|
+
* sessions from filling the result.
|
|
2041
2407
|
*/
|
|
2042
|
-
async function getSortedSessions(
|
|
2408
|
+
async function getSortedSessions(
|
|
2409
|
+
sessionDir: string,
|
|
2410
|
+
storage: SessionStorage,
|
|
2411
|
+
limit?: number,
|
|
2412
|
+
): Promise<RecentSessionInfo[]> {
|
|
2043
2413
|
await recoverOrphanedBackups(sessionDir, storage);
|
|
2044
2414
|
try {
|
|
2045
|
-
|
|
2415
|
+
let candidates: Array<{ path: string; mtime: number }> | undefined;
|
|
2416
|
+
if (storage.listFilesByMtime) {
|
|
2417
|
+
try {
|
|
2418
|
+
candidates = (await storage.listFilesByMtime(sessionDir, "*.jsonl")).map(candidate => ({
|
|
2419
|
+
path: candidate.path,
|
|
2420
|
+
mtime: candidate.mtimeMs,
|
|
2421
|
+
}));
|
|
2422
|
+
} catch (error) {
|
|
2423
|
+
logger.warn("Native session mtime listing failed; using JavaScript fallback", {
|
|
2424
|
+
sessionDir,
|
|
2425
|
+
error: toError(error).message,
|
|
2426
|
+
});
|
|
2427
|
+
}
|
|
2428
|
+
} else {
|
|
2429
|
+
logger.debug("Native session mtime listing unavailable; using JavaScript fallback", { sessionDir });
|
|
2430
|
+
}
|
|
2431
|
+
candidates ??= storage.listFilesSync(sessionDir, "*.jsonl").flatMap(path => {
|
|
2432
|
+
try {
|
|
2433
|
+
return [{ path, mtime: storage.statSync(path).mtimeMs }];
|
|
2434
|
+
} catch {
|
|
2435
|
+
return [];
|
|
2436
|
+
}
|
|
2437
|
+
});
|
|
2438
|
+
candidates.sort((left, right) => {
|
|
2439
|
+
if (left.mtime !== right.mtime) return right.mtime - left.mtime;
|
|
2440
|
+
return left.path < right.path ? -1 : left.path > right.path ? 1 : 0;
|
|
2441
|
+
});
|
|
2442
|
+
|
|
2046
2443
|
const sessions: RecentSessionInfo[] = [];
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2444
|
+
const batchSize = Math.max(32, limit ?? 0);
|
|
2445
|
+
|
|
2446
|
+
for (
|
|
2447
|
+
let offset = 0;
|
|
2448
|
+
offset < candidates.length && (limit === undefined || sessions.length < limit);
|
|
2449
|
+
offset += batchSize
|
|
2450
|
+
) {
|
|
2451
|
+
const batch = candidates.slice(offset, offset + batchSize);
|
|
2452
|
+
const parsed = await Promise.all(
|
|
2453
|
+
batch.map(async candidate => {
|
|
2454
|
+
try {
|
|
2455
|
+
const buffer = Buffer.allocUnsafe(SESSION_LIST_PREFIX_BYTES);
|
|
2456
|
+
const content = await readSessionListPrefix(candidate.path, storage, buffer);
|
|
2457
|
+
const entries = parseJsonlLenient<Record<string, unknown>>(content);
|
|
2458
|
+
if (entries.length === 0) return undefined;
|
|
2459
|
+
const header = entries[0] as Record<string, unknown>;
|
|
2460
|
+
if (
|
|
2461
|
+
header.type !== "session" ||
|
|
2462
|
+
typeof header.id !== "string" ||
|
|
2463
|
+
!isSupportedSessionVersion(header.version)
|
|
2464
|
+
)
|
|
2465
|
+
return undefined;
|
|
2466
|
+
if (typeof header.version === "number" && header.version >= 4) {
|
|
2467
|
+
for (const patch of await readSessionListTrailingPatches(candidate.path, storage, buffer)) {
|
|
2468
|
+
applySessionListHeaderPatch(header as unknown as SessionListHeader, patch);
|
|
2469
|
+
}
|
|
2064
2470
|
}
|
|
2471
|
+
const firstPrompt = header.title ? undefined : extractFirstUserPrompt(entries);
|
|
2472
|
+
return new RecentSessionInfo(candidate.path, candidate.mtime, header, firstPrompt);
|
|
2473
|
+
} catch {
|
|
2474
|
+
return undefined;
|
|
2065
2475
|
}
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2476
|
+
}),
|
|
2477
|
+
);
|
|
2478
|
+
for (const session of parsed) {
|
|
2479
|
+
if (session) sessions.push(session);
|
|
2480
|
+
if (limit !== undefined && sessions.length >= limit) break;
|
|
2481
|
+
}
|
|
2482
|
+
}
|
|
2483
|
+
return sessions;
|
|
2073
2484
|
} catch {
|
|
2074
2485
|
return [];
|
|
2075
2486
|
}
|
|
@@ -2080,7 +2491,7 @@ export async function findMostRecentSession(
|
|
|
2080
2491
|
sessionDir: string,
|
|
2081
2492
|
storage: SessionStorage = new FileSessionStorage(),
|
|
2082
2493
|
): Promise<string | null> {
|
|
2083
|
-
const sessions = await getSortedSessions(sessionDir, storage);
|
|
2494
|
+
const sessions = await getSortedSessions(sessionDir, storage, 1);
|
|
2084
2495
|
return sessions[0]?.path || null;
|
|
2085
2496
|
}
|
|
2086
2497
|
|
|
@@ -2099,23 +2510,6 @@ function formatTimeAgo(date: Date): string {
|
|
|
2099
2510
|
return date.toLocaleDateString();
|
|
2100
2511
|
}
|
|
2101
2512
|
|
|
2102
|
-
async function syncPathAndParent(filePath: string): Promise<void> {
|
|
2103
|
-
const file = await fs.promises.open(filePath, "r");
|
|
2104
|
-
try {
|
|
2105
|
-
await file.sync();
|
|
2106
|
-
} finally {
|
|
2107
|
-
await file.close();
|
|
2108
|
-
}
|
|
2109
|
-
if (process.platform !== "win32") {
|
|
2110
|
-
const parent = await fs.promises.open(path.dirname(filePath), "r");
|
|
2111
|
-
try {
|
|
2112
|
-
await parent.sync();
|
|
2113
|
-
} finally {
|
|
2114
|
-
await parent.close();
|
|
2115
|
-
}
|
|
2116
|
-
}
|
|
2117
|
-
}
|
|
2118
|
-
|
|
2119
2513
|
interface SessionMoveDirectoryHandle {
|
|
2120
2514
|
sync(): Promise<void>;
|
|
2121
2515
|
close(): Promise<void>;
|
|
@@ -2135,40 +2529,6 @@ export async function syncSessionMoveDirectory(
|
|
|
2135
2529
|
}
|
|
2136
2530
|
}
|
|
2137
2531
|
|
|
2138
|
-
/** Durably flush every copied file and directory before an EXDEV source removal. */
|
|
2139
|
-
async function syncCopiedTree(treePath: string): Promise<void> {
|
|
2140
|
-
const stat = await fs.promises.lstat(treePath);
|
|
2141
|
-
if (stat.isSymbolicLink()) throw new Error("Refusing to fsync a copied symbolic link during a cross-device move");
|
|
2142
|
-
if (stat.isFile()) {
|
|
2143
|
-
const file = await fs.promises.open(treePath, "r");
|
|
2144
|
-
try {
|
|
2145
|
-
await file.sync();
|
|
2146
|
-
} finally {
|
|
2147
|
-
await file.close();
|
|
2148
|
-
}
|
|
2149
|
-
return;
|
|
2150
|
-
}
|
|
2151
|
-
if (!stat.isDirectory()) throw new Error("Copied cross-device move contains an unsupported filesystem entry");
|
|
2152
|
-
const directory = await fs.promises.opendir(treePath);
|
|
2153
|
-
try {
|
|
2154
|
-
for (;;) {
|
|
2155
|
-
const entry = await directory.read();
|
|
2156
|
-
if (entry === null) break;
|
|
2157
|
-
await syncCopiedTree(path.join(treePath, entry.name));
|
|
2158
|
-
}
|
|
2159
|
-
} finally {
|
|
2160
|
-
await directory.close();
|
|
2161
|
-
}
|
|
2162
|
-
if (process.platform !== "win32") {
|
|
2163
|
-
const handle = await fs.promises.open(treePath, "r");
|
|
2164
|
-
try {
|
|
2165
|
-
await handle.sync();
|
|
2166
|
-
} finally {
|
|
2167
|
-
await handle.close();
|
|
2168
|
-
}
|
|
2169
|
-
}
|
|
2170
|
-
}
|
|
2171
|
-
|
|
2172
2532
|
type CrossDeviceTreeIdentity = string;
|
|
2173
2533
|
|
|
2174
2534
|
/** Captures content plus topology; a later capture must match before source removal. */
|
|
@@ -2216,14 +2576,6 @@ async function captureCrossDeviceTreeIdentity(treePath: string): Promise<CrossDe
|
|
|
2216
2576
|
return `directory:${before.dev}:${before.ino}:${before.size}:${before.mtimeNs}:[${children.join(",")}]`;
|
|
2217
2577
|
}
|
|
2218
2578
|
|
|
2219
|
-
async function removeCrossDeviceSourceIfUnchanged(source: string, expected: CrossDeviceTreeIdentity): Promise<void> {
|
|
2220
|
-
if ((await captureCrossDeviceTreeIdentity(source)) !== expected)
|
|
2221
|
-
throw new Error("Cross-device move source changed before removal");
|
|
2222
|
-
const stat = await fs.promises.lstat(source);
|
|
2223
|
-
if (stat.isDirectory()) await fs.promises.rm(source, { recursive: true, force: false });
|
|
2224
|
-
else await fs.promises.unlink(source);
|
|
2225
|
-
}
|
|
2226
|
-
|
|
2227
2579
|
/**
|
|
2228
2580
|
* An EXDEV retirement must be driven by a native descriptor/handle-bound
|
|
2229
2581
|
* snapshot-copy-verify transaction. Do not fall back to pathname copy/remove:
|
|
@@ -2239,83 +2591,25 @@ class CrossDeviceMoveUnsupportedError extends Error {
|
|
|
2239
2591
|
}
|
|
2240
2592
|
}
|
|
2241
2593
|
|
|
2242
|
-
class PathMoveCompletedWithSyncFailure extends Error {
|
|
2243
|
-
constructor(readonly cause: unknown) {
|
|
2244
|
-
super("Path move completed but final source-directory sync failed.", { cause });
|
|
2245
|
-
}
|
|
2246
|
-
}
|
|
2247
|
-
|
|
2248
2594
|
async function movePathAcrossDevicesSafe(source: string, destination: string): Promise<void> {
|
|
2249
2595
|
const sourceIdentity = await captureCrossDeviceTreeIdentity(source);
|
|
2250
|
-
const
|
|
2251
|
-
if (
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
if (path.dirname(source) !== path.dirname(destination)) await syncSessionMoveDirectory(path.dirname(source));
|
|
2257
|
-
} catch (error) {
|
|
2258
|
-
throw new PathMoveCompletedWithSyncFailure(error);
|
|
2259
|
-
}
|
|
2596
|
+
const outcome = classifyNativePublishOutcome(native.renameNoReplacePath(source, destination));
|
|
2597
|
+
if (outcome.ok) {
|
|
2598
|
+
if ((await captureCrossDeviceTreeIdentity(destination)) !== sourceIdentity)
|
|
2599
|
+
throw new Error("Atomic session rename did not preserve the captured source identity");
|
|
2600
|
+
await syncSessionMoveDirectory(path.dirname(destination));
|
|
2601
|
+
if (path.dirname(source) !== path.dirname(destination)) await syncSessionMoveDirectory(path.dirname(source));
|
|
2260
2602
|
return;
|
|
2261
2603
|
}
|
|
2262
|
-
if (
|
|
2604
|
+
if (outcome.reason === "destination_exists") {
|
|
2263
2605
|
const error = new Error(`Session move destination already exists: ${destination}`) as NodeJS.ErrnoException;
|
|
2264
2606
|
error.code = "EEXIST";
|
|
2265
2607
|
throw error;
|
|
2266
2608
|
}
|
|
2267
|
-
if (
|
|
2268
|
-
throw new
|
|
2269
|
-
const
|
|
2270
|
-
|
|
2271
|
-
let destinationCreated = false;
|
|
2272
|
-
let sourceRemoved = false;
|
|
2273
|
-
try {
|
|
2274
|
-
try {
|
|
2275
|
-
await fs.promises.link(source, destination);
|
|
2276
|
-
destinationCreated = true;
|
|
2277
|
-
} catch (error) {
|
|
2278
|
-
if (hasFsCode(error, "EXDEV")) throw new CrossDeviceMoveUnsupportedError(source, destination, error);
|
|
2279
|
-
if (
|
|
2280
|
-
!hasFsCode(error, "EPERM") &&
|
|
2281
|
-
!hasFsCode(error, "EACCES") &&
|
|
2282
|
-
!hasFsCode(error, "ENOTSUP") &&
|
|
2283
|
-
!hasFsCode(error, "EOPNOTSUPP")
|
|
2284
|
-
)
|
|
2285
|
-
throw error;
|
|
2286
|
-
await fs.promises.copyFile(source, destination, fs.constants.COPYFILE_EXCL);
|
|
2287
|
-
destinationCreated = true;
|
|
2288
|
-
}
|
|
2289
|
-
await syncPathAndParent(destination);
|
|
2290
|
-
await removeCrossDeviceSourceIfUnchanged(source, sourceIdentity);
|
|
2291
|
-
sourceRemoved = true;
|
|
2292
|
-
await syncSessionMoveDirectory(path.dirname(source));
|
|
2293
|
-
return;
|
|
2294
|
-
} catch (error) {
|
|
2295
|
-
if (!sourceRemoved && destinationCreated) await fs.promises.unlink(destination).catch(() => undefined);
|
|
2296
|
-
if (sourceRemoved && !(error instanceof PathMoveCompletedWithSyncFailure))
|
|
2297
|
-
throw new PathMoveCompletedWithSyncFailure(error);
|
|
2298
|
-
throw error;
|
|
2299
|
-
}
|
|
2300
|
-
}
|
|
2301
|
-
|
|
2302
|
-
const destinationParent = await fs.promises.lstat(path.dirname(destination));
|
|
2303
|
-
if (sourceStat.dev !== destinationParent.dev) throw new CrossDeviceMoveUnsupportedError(source, destination);
|
|
2304
|
-
|
|
2305
|
-
try {
|
|
2306
|
-
await fs.promises.cp(source, destination, { recursive: true, force: false, errorOnExist: true });
|
|
2307
|
-
await syncCopiedTree(destination);
|
|
2308
|
-
await removeCrossDeviceSourceIfUnchanged(source, sourceIdentity);
|
|
2309
|
-
try {
|
|
2310
|
-
await syncSessionMoveDirectory(path.dirname(source));
|
|
2311
|
-
} catch (error) {
|
|
2312
|
-
throw new PathMoveCompletedWithSyncFailure(error);
|
|
2313
|
-
}
|
|
2314
|
-
} catch (error) {
|
|
2315
|
-
if (!(error instanceof PathMoveCompletedWithSyncFailure))
|
|
2316
|
-
await fs.promises.rm(destination, { recursive: true, force: true }).catch(() => undefined);
|
|
2317
|
-
throw error;
|
|
2318
|
-
}
|
|
2609
|
+
if (outcome.reason === "atomic_unavailable")
|
|
2610
|
+
throw new CrossDeviceMoveUnsupportedError(source, destination, new Error(formatNativePublishDiagnostic(outcome)));
|
|
2611
|
+
const message = `Atomic session rename failed: ${outcome.code ?? outcome.reason}`;
|
|
2612
|
+
throw new Error(message, { cause: new Error(formatNativePublishDiagnostic(outcome)) });
|
|
2319
2613
|
}
|
|
2320
2614
|
|
|
2321
2615
|
const MAX_PERSIST_CHARS = 500_000;
|
|
@@ -3461,6 +3755,81 @@ class NdjsonFileWriter {
|
|
|
3461
3755
|
}
|
|
3462
3756
|
}
|
|
3463
3757
|
|
|
3758
|
+
const PROJECT_SESSION_SCAN_MAX_DIRECTORIES = 4096;
|
|
3759
|
+
const PROJECT_SESSION_SCAN_MAX_FILES = 1000;
|
|
3760
|
+
|
|
3761
|
+
function isProjectSessionTranscriptPath(projectSkcDir: string, filePath: string): boolean {
|
|
3762
|
+
const relative = path.relative(projectSkcDir, filePath);
|
|
3763
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) return false;
|
|
3764
|
+
const segments = relative.split(path.sep);
|
|
3765
|
+
if (segments.length === 1) return true;
|
|
3766
|
+
const parent = segments.at(-2);
|
|
3767
|
+
return parent === "agent-session" || segments.includes("sessions");
|
|
3768
|
+
}
|
|
3769
|
+
|
|
3770
|
+
/**
|
|
3771
|
+
* Discover resumable transcripts intentionally stored inside a project's `.skc`.
|
|
3772
|
+
* Runtime token/audit JSONL files are excluded by requiring a known transcript
|
|
3773
|
+
* container (`agent-session` or `sessions`).
|
|
3774
|
+
*/
|
|
3775
|
+
function listProjectSessionTranscriptFiles(cwd: string): string[] {
|
|
3776
|
+
const projectSkcDir = path.join(path.resolve(cwd), ".skc");
|
|
3777
|
+
let rootStat: fs.Stats;
|
|
3778
|
+
try {
|
|
3779
|
+
rootStat = fs.lstatSync(projectSkcDir);
|
|
3780
|
+
} catch {
|
|
3781
|
+
return [];
|
|
3782
|
+
}
|
|
3783
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) return [];
|
|
3784
|
+
|
|
3785
|
+
const directories = [projectSkcDir];
|
|
3786
|
+
const files: string[] = [];
|
|
3787
|
+
let scannedDirectories = 0;
|
|
3788
|
+
while (directories.length > 0 && scannedDirectories < PROJECT_SESSION_SCAN_MAX_DIRECTORIES) {
|
|
3789
|
+
const directory = directories.pop()!;
|
|
3790
|
+
scannedDirectories++;
|
|
3791
|
+
let entries: fs.Dirent[];
|
|
3792
|
+
try {
|
|
3793
|
+
entries = fs.readdirSync(directory, { withFileTypes: true });
|
|
3794
|
+
} catch {
|
|
3795
|
+
continue;
|
|
3796
|
+
}
|
|
3797
|
+
for (const entry of entries) {
|
|
3798
|
+
if (entry.isSymbolicLink()) continue;
|
|
3799
|
+
const entryPath = path.join(directory, entry.name);
|
|
3800
|
+
if (entry.isDirectory()) {
|
|
3801
|
+
directories.push(entryPath);
|
|
3802
|
+
continue;
|
|
3803
|
+
}
|
|
3804
|
+
if (
|
|
3805
|
+
entry.isFile() &&
|
|
3806
|
+
!entry.name.startsWith(".") &&
|
|
3807
|
+
entry.name.endsWith(".jsonl") &&
|
|
3808
|
+
isProjectSessionTranscriptPath(projectSkcDir, entryPath)
|
|
3809
|
+
) {
|
|
3810
|
+
files.push(entryPath);
|
|
3811
|
+
if (files.length >= PROJECT_SESSION_SCAN_MAX_FILES) return files;
|
|
3812
|
+
}
|
|
3813
|
+
}
|
|
3814
|
+
}
|
|
3815
|
+
return files;
|
|
3816
|
+
}
|
|
3817
|
+
|
|
3818
|
+
async function collectProjectSessions(cwd: string, storage: FileSessionStorage): Promise<SessionInfo[]> {
|
|
3819
|
+
return await collectSessionsFromFiles(listProjectSessionTranscriptFiles(cwd), storage);
|
|
3820
|
+
}
|
|
3821
|
+
|
|
3822
|
+
function mergeSessionInventories(...inventories: SessionInfo[][]): SessionInfo[] {
|
|
3823
|
+
const sessions = new Map<string, SessionInfo>();
|
|
3824
|
+
for (const inventory of inventories) {
|
|
3825
|
+
for (const session of inventory) {
|
|
3826
|
+
const current = sessions.get(session.id);
|
|
3827
|
+
if (!current || session.modified.getTime() > current.modified.getTime()) sessions.set(session.id, session);
|
|
3828
|
+
}
|
|
3829
|
+
}
|
|
3830
|
+
return [...sessions.values()].sort((left, right) => right.modified.getTime() - left.modified.getTime());
|
|
3831
|
+
}
|
|
3832
|
+
|
|
3464
3833
|
const DEFAULT_WELCOME_RECENT_SESSION_LIMIT = 20;
|
|
3465
3834
|
|
|
3466
3835
|
/** Get recent sessions for display in welcome screen */
|
|
@@ -3469,8 +3838,22 @@ export async function getRecentSessions(
|
|
|
3469
3838
|
limit = DEFAULT_WELCOME_RECENT_SESSION_LIMIT,
|
|
3470
3839
|
storage: SessionStorage = new FileSessionStorage(),
|
|
3471
3840
|
): Promise<RecentSessionInfo[]> {
|
|
3472
|
-
|
|
3473
|
-
|
|
3841
|
+
return getSortedSessions(sessionDir, storage, limit);
|
|
3842
|
+
}
|
|
3843
|
+
|
|
3844
|
+
export function getRecentSessionDisplay(
|
|
3845
|
+
sessions: readonly SessionInfo[],
|
|
3846
|
+
limit = DEFAULT_WELCOME_RECENT_SESSION_LIMIT,
|
|
3847
|
+
): Array<{ name: string; timeAgo: string }> {
|
|
3848
|
+
return sessions.slice(0, limit).map(session => {
|
|
3849
|
+
const recent = new RecentSessionInfo(
|
|
3850
|
+
session.path,
|
|
3851
|
+
session.modified.getTime(),
|
|
3852
|
+
{ title: session.title, timestamp: session.created.toISOString() },
|
|
3853
|
+
session.firstMessage,
|
|
3854
|
+
);
|
|
3855
|
+
return { name: recent.name, timeAgo: recent.timeAgo };
|
|
3856
|
+
});
|
|
3474
3857
|
}
|
|
3475
3858
|
|
|
3476
3859
|
/**
|
|
@@ -3501,6 +3884,64 @@ function getTaskToolUsage(details: unknown): Usage | undefined {
|
|
|
3501
3884
|
return usage as Usage;
|
|
3502
3885
|
}
|
|
3503
3886
|
|
|
3887
|
+
interface ValidatedUsageTotals {
|
|
3888
|
+
input: number;
|
|
3889
|
+
output: number;
|
|
3890
|
+
cacheRead: number;
|
|
3891
|
+
cacheWrite: number;
|
|
3892
|
+
premiumRequests: number;
|
|
3893
|
+
cost: number;
|
|
3894
|
+
}
|
|
3895
|
+
|
|
3896
|
+
/** Max malformed-usage entry ids sampled for a single resume-time report. */
|
|
3897
|
+
const MALFORMED_USAGE_SAMPLE_LIMIT = 8;
|
|
3898
|
+
|
|
3899
|
+
function isFiniteNonNegativeNumber(value: unknown): value is number {
|
|
3900
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
3901
|
+
}
|
|
3902
|
+
|
|
3903
|
+
/**
|
|
3904
|
+
* Validate an untrusted persisted `usage` record before it is aggregated into
|
|
3905
|
+
* session usage statistics. `parseSessionEntries` accepts any parseable JSON, so a
|
|
3906
|
+
* torn/corrupt record can carry a `usage` that is still valid JSON yet malformed:
|
|
3907
|
+
* absent (`{}` yields NaN), numeric strings (`"10"` coerces sums into strings), or
|
|
3908
|
+
* negative buckets that silently reduce totals. Returns the validated finite
|
|
3909
|
+
* non-negative totals, or null when any bucket or `cost.total` is malformed — the
|
|
3910
|
+
* caller then skips and reports that record rather than poisoning every
|
|
3911
|
+
* getUsageStatistics() consumer. Absent `premiumRequests`/`cost` default to 0
|
|
3912
|
+
* (backward-compatible with older transcripts); a present-but-invalid field is
|
|
3913
|
+
* rejected.
|
|
3914
|
+
*/
|
|
3915
|
+
function validatePersistedUsageTotals(usage: unknown): ValidatedUsageTotals | null {
|
|
3916
|
+
if (typeof usage !== "object" || usage === null) return null;
|
|
3917
|
+
const record = usage as Record<string, unknown>;
|
|
3918
|
+
const input = record.input;
|
|
3919
|
+
const output = record.output;
|
|
3920
|
+
const cacheRead = record.cacheRead;
|
|
3921
|
+
const cacheWrite = record.cacheWrite;
|
|
3922
|
+
if (
|
|
3923
|
+
!isFiniteNonNegativeNumber(input) ||
|
|
3924
|
+
!isFiniteNonNegativeNumber(output) ||
|
|
3925
|
+
!isFiniteNonNegativeNumber(cacheRead) ||
|
|
3926
|
+
!isFiniteNonNegativeNumber(cacheWrite)
|
|
3927
|
+
)
|
|
3928
|
+
return null;
|
|
3929
|
+
// Default only on truly absent (=== undefined) properties; a present-but-null/invalid
|
|
3930
|
+
// premiumRequests is malformed, not zero.
|
|
3931
|
+
const premiumRequests = record.premiumRequests === undefined ? 0 : record.premiumRequests;
|
|
3932
|
+
if (!isFiniteNonNegativeNumber(premiumRequests)) return null;
|
|
3933
|
+
const rawCost = record.cost;
|
|
3934
|
+
let cost: unknown = 0;
|
|
3935
|
+
if (rawCost !== undefined) {
|
|
3936
|
+
// A present `cost` must be a non-array record carrying a finite non-negative `total`;
|
|
3937
|
+
// null, arrays, and an absent/invalid `total` are malformed — never silently zero.
|
|
3938
|
+
if (typeof rawCost !== "object" || rawCost === null || Array.isArray(rawCost)) return null;
|
|
3939
|
+
cost = (rawCost as Record<string, unknown>).total;
|
|
3940
|
+
}
|
|
3941
|
+
if (!isFiniteNonNegativeNumber(cost)) return null;
|
|
3942
|
+
return { input, output, cacheRead, cacheWrite, premiumRequests, cost };
|
|
3943
|
+
}
|
|
3944
|
+
|
|
3504
3945
|
function extractTextFromContent(content: Message["content"]): string {
|
|
3505
3946
|
if (typeof content === "string") return content;
|
|
3506
3947
|
return content
|
|
@@ -3867,9 +4308,11 @@ export async function resolveResumableSession(
|
|
|
3867
4308
|
cwd: string,
|
|
3868
4309
|
sessionDir?: string,
|
|
3869
4310
|
storage: SessionStorage = new FileSessionStorage(),
|
|
4311
|
+
managedAgentDir?: string,
|
|
3870
4312
|
): Promise<ResolvedSessionMatch | undefined> {
|
|
3871
|
-
const
|
|
3872
|
-
|
|
4313
|
+
const localSessions = sessionDir
|
|
4314
|
+
? await SessionManager.list(cwd, sessionDir, storage)
|
|
4315
|
+
: await SessionManager.listManagedForResumePickerReadOnly(cwd, managedAgentDir, storage);
|
|
3873
4316
|
const localMatch = localSessions.find(session => sessionMatchesResumeArg(session, sessionArg));
|
|
3874
4317
|
if (localMatch) {
|
|
3875
4318
|
return { session: localMatch, scope: "local" };
|
|
@@ -3879,7 +4322,7 @@ export async function resolveResumableSession(
|
|
|
3879
4322
|
return undefined;
|
|
3880
4323
|
}
|
|
3881
4324
|
|
|
3882
|
-
const globalSessions = await SessionManager.listAll(storage);
|
|
4325
|
+
const globalSessions = await SessionManager.listAll(storage, managedAgentDir);
|
|
3883
4326
|
const globalMatch = globalSessions.find(session => sessionMatchesResumeArg(session, sessionArg));
|
|
3884
4327
|
if (!globalMatch) {
|
|
3885
4328
|
return undefined;
|
|
@@ -3903,6 +4346,7 @@ export class SessionManager {
|
|
|
3903
4346
|
#sessionId: string = "";
|
|
3904
4347
|
/** True once a lifecycle pre-allocated id has been adopted (consume-once). */
|
|
3905
4348
|
#lifecycleIdAdopted: boolean = false;
|
|
4349
|
+
#preparedNewSessions = new Set<PreparedNewSessionState>();
|
|
3906
4350
|
#sessionName: string | undefined;
|
|
3907
4351
|
#titleSource: "auto" | "user" | undefined;
|
|
3908
4352
|
#sessionFile: string | undefined;
|
|
@@ -3910,7 +4354,10 @@ export class SessionManager {
|
|
|
3910
4354
|
#needsFullRewriteOnNextPersist: boolean = false;
|
|
3911
4355
|
#ensuredOnDisk: boolean = false;
|
|
3912
4356
|
#recoveryHydrationContext: RecoveryHydrationContext | undefined;
|
|
4357
|
+
#recoveryPromotionTranscriptPath: string | undefined;
|
|
4358
|
+
#memoryGuardParticipantIngressToken: symbol | undefined;
|
|
3913
4359
|
#fileEntries: FileEntry[] = [];
|
|
4360
|
+
#pendingStrictAdoption: { canonicalPath: string; identity: ResumeSessionIdentity } | undefined;
|
|
3914
4361
|
#byId: Map<string, SessionEntry> = new Map();
|
|
3915
4362
|
#labelsById: Map<string, string> = new Map();
|
|
3916
4363
|
#leafId: string | null = null;
|
|
@@ -3927,6 +4374,8 @@ export class SessionManager {
|
|
|
3927
4374
|
#persistChain: Promise<void> = Promise.resolve();
|
|
3928
4375
|
#persistError: Error | undefined;
|
|
3929
4376
|
#persistErrorReported = false;
|
|
4377
|
+
/** Failed staged persistence retains its exact writer and temporary pathname for retryable cleanup. */
|
|
4378
|
+
#preparedNewSessionCleanupInProgress = false;
|
|
3930
4379
|
|
|
3931
4380
|
#artifactManager: ArtifactManager | null = null;
|
|
3932
4381
|
#artifactManagerSessionFile: string | null = null;
|
|
@@ -3941,7 +4390,8 @@ export class SessionManager {
|
|
|
3941
4390
|
#inMemoryArtifactCounter = 0;
|
|
3942
4391
|
readonly #blobStore: BlobStore;
|
|
3943
4392
|
#residentTextBlobStore: BlobStore = new MemoryBlobStore();
|
|
3944
|
-
|
|
4393
|
+
#residentImageBlobStore: BlobStore;
|
|
4394
|
+
#memoryGuardCheckpointBlobs: Map<string, Buffer> | undefined;
|
|
3945
4395
|
#entryRevision = 0;
|
|
3946
4396
|
#leafRevision = 0;
|
|
3947
4397
|
/** Export/header cache invalidation contract; consumers may arrive after the revision field. */
|
|
@@ -4014,6 +4464,9 @@ export class SessionManager {
|
|
|
4014
4464
|
},
|
|
4015
4465
|
};
|
|
4016
4466
|
}
|
|
4467
|
+
#coldSpillReadStore(): BlobStore {
|
|
4468
|
+
return this.#memoryGuardCheckpointBlobs ? this.#residentImageBlobStore : this.#blobStore;
|
|
4469
|
+
}
|
|
4017
4470
|
|
|
4018
4471
|
#residentCacheDir(sessionFile: string): string {
|
|
4019
4472
|
const instance = ++residentCacheInstanceCounter;
|
|
@@ -4031,6 +4484,21 @@ export class SessionManager {
|
|
|
4031
4484
|
this.#buildIndex();
|
|
4032
4485
|
}
|
|
4033
4486
|
|
|
4487
|
+
#preparePreparedNewSessionForCommit(stage: PreparedNewSessionState): void {
|
|
4488
|
+
const residentTextBlobStore = new MemoryBlobStore();
|
|
4489
|
+
const residentFileEntries = stage.fileEntries.map(entry =>
|
|
4490
|
+
prepareEntryForResidentSync(entry, {
|
|
4491
|
+
textStore: residentTextBlobStore,
|
|
4492
|
+
imageStore: this.#residentImageBlobStore,
|
|
4493
|
+
sessionId: stage.sessionId,
|
|
4494
|
+
sessionFile: stage.sessionFile,
|
|
4495
|
+
}),
|
|
4496
|
+
);
|
|
4497
|
+
stage.residentTextBlobStore = residentTextBlobStore;
|
|
4498
|
+
stage.residentFileEntries = residentFileEntries;
|
|
4499
|
+
stage.index = this.#buildIndexForEntries(residentFileEntries, stage.sessionFile);
|
|
4500
|
+
}
|
|
4501
|
+
|
|
4034
4502
|
#resetMaterializedCaches(): void {
|
|
4035
4503
|
this.#materializedEntriesRevision = -1;
|
|
4036
4504
|
this.#materializedEntriesCache = undefined;
|
|
@@ -4055,13 +4523,7 @@ export class SessionManager {
|
|
|
4055
4523
|
* revision contract). Tests assert the invalidation mapping through this;
|
|
4056
4524
|
* future export/label-view caches key off their respective domains.
|
|
4057
4525
|
*/
|
|
4058
|
-
revisionSnapshot(): {
|
|
4059
|
-
entry: number;
|
|
4060
|
-
leaf: number;
|
|
4061
|
-
headerExport: number;
|
|
4062
|
-
label: number;
|
|
4063
|
-
replayMetadata: number;
|
|
4064
|
-
} {
|
|
4526
|
+
revisionSnapshot(): SessionManagerRevisionSnapshot {
|
|
4065
4527
|
return {
|
|
4066
4528
|
entry: this.#entryRevision,
|
|
4067
4529
|
leaf: this.#leafRevision,
|
|
@@ -4157,15 +4619,16 @@ export class SessionManager {
|
|
|
4157
4619
|
}
|
|
4158
4620
|
|
|
4159
4621
|
async #hydrateExistingSession(sessionFile: string, entries: FileEntry[], migrationApplied: boolean): Promise<void> {
|
|
4160
|
-
|
|
4161
|
-
|
|
4622
|
+
// Strict inspection creates these entries solely for this hydration path. Adopt that
|
|
4623
|
+
// final, validated representation instead of cloning the complete transcript again.
|
|
4624
|
+
const header = entries[0] as SessionHeader;
|
|
4162
4625
|
this.#sessionFile = this.storage instanceof FileSessionStorage ? path.resolve(sessionFile) : sessionFile;
|
|
4163
4626
|
this.#sessionId = header.id;
|
|
4164
4627
|
this.#sessionName = header.title;
|
|
4165
4628
|
this.#titleSource = header.titleSource;
|
|
4166
4629
|
this.#needsFullRewriteOnNextPersist = migrationApplied;
|
|
4167
|
-
await resolveBlobRefsInEntries(
|
|
4168
|
-
this.#fileEntries =
|
|
4630
|
+
await resolveBlobRefsInEntries(entries, this.#blobStore);
|
|
4631
|
+
this.#fileEntries = entries;
|
|
4169
4632
|
this.#resetResidentTextBlobStore();
|
|
4170
4633
|
this.#fileEntries = this.#fileEntries.map(entry =>
|
|
4171
4634
|
prepareEntryForResidentSync(entry, this.#residentBlobStores()),
|
|
@@ -4184,12 +4647,26 @@ export class SessionManager {
|
|
|
4184
4647
|
|
|
4185
4648
|
/** Switch to a different session file (used for resume and branching) */
|
|
4186
4649
|
async setSessionFile(sessionFile: string): Promise<void> {
|
|
4650
|
+
this.#assertRecoveryHydrationWritable();
|
|
4651
|
+
const resolvedSessionFile = this.storage instanceof FileSessionStorage ? path.resolve(sessionFile) : sessionFile;
|
|
4652
|
+
const strictAdoption = this.#pendingStrictAdoption;
|
|
4653
|
+
this.#pendingStrictAdoption = undefined;
|
|
4654
|
+
if (strictAdoption && resolvedSessionFile === strictAdoption.canonicalPath) {
|
|
4655
|
+
const inspected = inspectResumeSessionFile(resolvedSessionFile, this.storage);
|
|
4656
|
+
if ("kind" in inspected || !sameResumeIdentity(strictAdoption.identity, inspected.identity))
|
|
4657
|
+
throw new Error("Prepared managed session changed before strict adoption.");
|
|
4658
|
+
}
|
|
4187
4659
|
await this.#closePersistWriter();
|
|
4188
4660
|
this.#persistError = undefined;
|
|
4189
4661
|
this.#persistErrorReported = false;
|
|
4190
|
-
this.#sessionFile =
|
|
4662
|
+
this.#sessionFile = resolvedSessionFile;
|
|
4191
4663
|
writeTerminalBreadcrumb(this.cwd, this.#sessionFile);
|
|
4192
4664
|
this.#fileEntries = await loadEntriesFromFile(this.#sessionFile, this.storage);
|
|
4665
|
+
if (strictAdoption) {
|
|
4666
|
+
const inspected = inspectResumeSessionFile(this.#sessionFile, this.storage);
|
|
4667
|
+
if ("kind" in inspected || !sameResumeIdentity(strictAdoption.identity, inspected.identity))
|
|
4668
|
+
throw new Error("Prepared managed session changed during strict adoption.");
|
|
4669
|
+
}
|
|
4193
4670
|
if (this.#fileEntries.length > 0) {
|
|
4194
4671
|
const header = this.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
|
|
4195
4672
|
this.#sessionId = header?.id ?? createSessionId();
|
|
@@ -4224,10 +4701,496 @@ export class SessionManager {
|
|
|
4224
4701
|
|
|
4225
4702
|
/** Start a new session. Closes any existing writer first. */
|
|
4226
4703
|
async newSession(options?: NewSessionOptions): Promise<string | undefined> {
|
|
4704
|
+
this.#assertRecoveryHydrationWritable();
|
|
4705
|
+
const prepared = await this.prepareNewSession(options);
|
|
4706
|
+
try {
|
|
4707
|
+
this.commitPreparedNewSession(prepared);
|
|
4708
|
+
} catch (error) {
|
|
4709
|
+
try {
|
|
4710
|
+
await this.discardPreparedNewSession(prepared);
|
|
4711
|
+
} catch (cleanupError) {
|
|
4712
|
+
throw new AggregateError(
|
|
4713
|
+
[error, cleanupError],
|
|
4714
|
+
"New session adoption and staged session cleanup both failed.",
|
|
4715
|
+
);
|
|
4716
|
+
}
|
|
4717
|
+
throw error;
|
|
4718
|
+
}
|
|
4719
|
+
return prepared.sessionFile;
|
|
4720
|
+
}
|
|
4721
|
+
|
|
4722
|
+
/**
|
|
4723
|
+
* Allocate a fresh successor without publishing it through the manager's public
|
|
4724
|
+
* getters. The returned authority is deliberately immutable so readiness work
|
|
4725
|
+
* can resolve local:// against the successor while the predecessor stays live.
|
|
4726
|
+
* @internal
|
|
4727
|
+
*/
|
|
4728
|
+
async prepareNewSession(options?: NewSessionOptions): Promise<PreparedNewSession> {
|
|
4729
|
+
this.#assertRecoveryHydrationWritable();
|
|
4730
|
+
await this.#retryPreparedNewSessionCleanups();
|
|
4731
|
+
await this.#closePersistWriter();
|
|
4732
|
+
const preallocated = this.#lifecycleIdAdopted ? undefined : lifecyclePreallocatedSessionId();
|
|
4733
|
+
const sessionId = preallocated ?? createSessionId();
|
|
4734
|
+
const timestamp = new Date().toISOString();
|
|
4735
|
+
const sessionFile = this.persist
|
|
4736
|
+
? path.join(this.getSessionDir(), `${timestamp.replace(/[:.]/g, "-")}_${sessionId}.jsonl`)
|
|
4737
|
+
: undefined;
|
|
4738
|
+
const header: SessionHeader = {
|
|
4739
|
+
type: "session",
|
|
4740
|
+
version: CURRENT_SESSION_VERSION,
|
|
4741
|
+
id: sessionId,
|
|
4742
|
+
timestamp,
|
|
4743
|
+
cwd: this.cwd,
|
|
4744
|
+
parentSession: options?.parentSession,
|
|
4745
|
+
};
|
|
4746
|
+
const stage: PreparedNewSessionState = {
|
|
4747
|
+
sessionId,
|
|
4748
|
+
sessionFile,
|
|
4749
|
+
artifactsDir: sessionFile ? sessionFile.slice(0, -6) : null,
|
|
4750
|
+
managedLegacyLocalMigrationSource: this.#managedLegacyLocalMigrationSourceFor(sessionFile),
|
|
4751
|
+
header,
|
|
4752
|
+
fileEntries: [header],
|
|
4753
|
+
sessionName: undefined,
|
|
4754
|
+
titleSource: undefined,
|
|
4755
|
+
flushed: false,
|
|
4756
|
+
committed: false,
|
|
4757
|
+
discarded: false,
|
|
4758
|
+
};
|
|
4759
|
+
this.#preparedNewSessions.add(stage);
|
|
4760
|
+
this.#freezePreparedNewSessionIdentity(stage);
|
|
4761
|
+
return stage;
|
|
4762
|
+
}
|
|
4763
|
+
|
|
4764
|
+
/** Append a model selection to an unpublished successor. @internal */
|
|
4765
|
+
appendPreparedModelChange(prepared: PreparedNewSession, model: string): string {
|
|
4766
|
+
const stage = this.#getPreparedNewSessionStage(prepared);
|
|
4767
|
+
const entry: ModelChangeEntry = {
|
|
4768
|
+
type: "model_change",
|
|
4769
|
+
id: this.#nextPreparedNewSessionEntryId(stage),
|
|
4770
|
+
parentId: this.#preparedNewSessionLeafId(stage),
|
|
4771
|
+
timestamp: new Date().toISOString(),
|
|
4772
|
+
model,
|
|
4773
|
+
};
|
|
4774
|
+
stage.fileEntries.push(entry);
|
|
4775
|
+
return entry.id;
|
|
4776
|
+
}
|
|
4777
|
+
|
|
4778
|
+
/** Append a thinking-level selection to an unpublished successor. @internal */
|
|
4779
|
+
appendPreparedThinkingLevelChange(prepared: PreparedNewSession, thinkingLevel?: string): string {
|
|
4780
|
+
const stage = this.#getPreparedNewSessionStage(prepared);
|
|
4781
|
+
const entry: ThinkingLevelChangeEntry = {
|
|
4782
|
+
type: "thinking_level_change",
|
|
4783
|
+
id: this.#nextPreparedNewSessionEntryId(stage),
|
|
4784
|
+
parentId: this.#preparedNewSessionLeafId(stage),
|
|
4785
|
+
timestamp: new Date().toISOString(),
|
|
4786
|
+
thinkingLevel: thinkingLevel ?? null,
|
|
4787
|
+
};
|
|
4788
|
+
stage.fileEntries.push(entry);
|
|
4789
|
+
return entry.id;
|
|
4790
|
+
}
|
|
4791
|
+
|
|
4792
|
+
/** Append a service-tier selection to an unpublished successor. @internal */
|
|
4793
|
+
appendPreparedServiceTierChange(prepared: PreparedNewSession, serviceTier: ServiceTier | null): string {
|
|
4794
|
+
const stage = this.#getPreparedNewSessionStage(prepared);
|
|
4795
|
+
const entry: ServiceTierChangeEntry = {
|
|
4796
|
+
type: "service_tier_change",
|
|
4797
|
+
id: this.#nextPreparedNewSessionEntryId(stage),
|
|
4798
|
+
parentId: this.#preparedNewSessionLeafId(stage),
|
|
4799
|
+
timestamp: new Date().toISOString(),
|
|
4800
|
+
serviceTier,
|
|
4801
|
+
};
|
|
4802
|
+
stage.fileEntries.push(entry);
|
|
4803
|
+
return entry.id;
|
|
4804
|
+
}
|
|
4805
|
+
|
|
4806
|
+
/** Append a displayable custom message to an unpublished successor. @internal */
|
|
4807
|
+
appendPreparedCustomMessageEntry<T = unknown>(
|
|
4808
|
+
prepared: PreparedNewSession,
|
|
4809
|
+
customType: string,
|
|
4810
|
+
content: string | (TextContent | ImageContent)[],
|
|
4811
|
+
display: boolean,
|
|
4812
|
+
details?: T,
|
|
4813
|
+
attribution: MessageAttribution = "agent",
|
|
4814
|
+
): string {
|
|
4815
|
+
const stage = this.#getPreparedNewSessionStage(prepared);
|
|
4816
|
+
const entry: CustomMessageEntry<T> = {
|
|
4817
|
+
type: "custom_message",
|
|
4818
|
+
customType,
|
|
4819
|
+
content,
|
|
4820
|
+
display,
|
|
4821
|
+
details: stripInternalDetailsFields(details),
|
|
4822
|
+
attribution,
|
|
4823
|
+
id: this.#nextPreparedNewSessionEntryId(stage),
|
|
4824
|
+
parentId: this.#preparedNewSessionLeafId(stage),
|
|
4825
|
+
timestamp: new Date().toISOString(),
|
|
4826
|
+
};
|
|
4827
|
+
stage.fileEntries.push(entry);
|
|
4828
|
+
return entry.id;
|
|
4829
|
+
}
|
|
4830
|
+
|
|
4831
|
+
/** Persist an unpublished successor without adopting it. @internal */
|
|
4832
|
+
async ensurePreparedNewSessionOnDisk(prepared: PreparedNewSession): Promise<void> {
|
|
4833
|
+
const stage = this.#getPreparedNewSessionStage(prepared);
|
|
4834
|
+
if (!this.persist || !stage.sessionFile || stage.flushed) return;
|
|
4835
|
+
const entries = await Promise.all(
|
|
4836
|
+
stage.fileEntries.map(entry => prepareEntryForPersistence(entry, this.#blobStore)),
|
|
4837
|
+
);
|
|
4838
|
+
if (this.destination.kind === "managed") {
|
|
4839
|
+
const bytes = Buffer.from(`${entries.map(entry => JSON.stringify(entry)).join("\n")}\n`, "utf8");
|
|
4840
|
+
await this.#managedTranscriptStore(stage.sessionFile).replace(path.basename(stage.sessionFile), bytes);
|
|
4841
|
+
} else {
|
|
4842
|
+
const staleCleanupError = await this.#cleanupPreparedNewSessionPersistence(stage);
|
|
4843
|
+
if (staleCleanupError) throw staleCleanupError;
|
|
4844
|
+
const dir = path.resolve(stage.sessionFile, "..");
|
|
4845
|
+
const tempPath = path.join(dir, `.${path.basename(stage.sessionFile)}.${Snowflake.next()}.tmp`);
|
|
4846
|
+
const writer = new NdjsonFileWriter(this.storage, tempPath, { flags: "w" });
|
|
4847
|
+
stage.persistenceTempPath = tempPath;
|
|
4848
|
+
stage.persistenceWriter = writer;
|
|
4849
|
+
try {
|
|
4850
|
+
for (const entry of entries) await writer.write(entry);
|
|
4851
|
+
await writer.flush();
|
|
4852
|
+
await writer.fsync();
|
|
4853
|
+
await writer.close();
|
|
4854
|
+
stage.persistenceWriter = undefined;
|
|
4855
|
+
await this.#replaceSessionFile(tempPath, stage.sessionFile);
|
|
4856
|
+
stage.persistenceTempPath = undefined;
|
|
4857
|
+
} catch (error) {
|
|
4858
|
+
const cleanupError = await this.#cleanupPreparedNewSessionPersistence(stage);
|
|
4859
|
+
if (cleanupError)
|
|
4860
|
+
throw new AggregateError(
|
|
4861
|
+
[toError(error), cleanupError],
|
|
4862
|
+
"Prepared session persistence and temporary-file cleanup both failed.",
|
|
4863
|
+
);
|
|
4864
|
+
throw toError(error);
|
|
4865
|
+
}
|
|
4866
|
+
}
|
|
4867
|
+
stage.flushed = true;
|
|
4868
|
+
}
|
|
4869
|
+
|
|
4870
|
+
/** Build context from an unpublished successor without reading active manager state. @internal */
|
|
4871
|
+
buildPreparedNewSessionContext(prepared: PreparedNewSession): SessionContext {
|
|
4872
|
+
const stage = this.#getPreparedNewSessionStage(prepared);
|
|
4873
|
+
return buildSessionContext(
|
|
4874
|
+
stage.fileEntries.filter((entry): entry is SessionEntry => entry.type !== "session"),
|
|
4875
|
+
undefined,
|
|
4876
|
+
undefined,
|
|
4877
|
+
stage.sessionId,
|
|
4878
|
+
);
|
|
4879
|
+
}
|
|
4880
|
+
|
|
4881
|
+
#freezePreparedNewSessionIdentity(stage: PreparedNewSessionState): void {
|
|
4882
|
+
// Prevent identity ABA: callers must not rebind staged sessionId/file after prepare.
|
|
4883
|
+
Object.defineProperty(stage, "sessionId", {
|
|
4884
|
+
value: stage.sessionId,
|
|
4885
|
+
writable: false,
|
|
4886
|
+
enumerable: true,
|
|
4887
|
+
configurable: false,
|
|
4888
|
+
});
|
|
4889
|
+
Object.defineProperty(stage, "sessionFile", {
|
|
4890
|
+
value: stage.sessionFile,
|
|
4891
|
+
writable: false,
|
|
4892
|
+
enumerable: true,
|
|
4893
|
+
configurable: false,
|
|
4894
|
+
});
|
|
4895
|
+
Object.defineProperty(stage, "artifactsDir", {
|
|
4896
|
+
value: stage.artifactsDir,
|
|
4897
|
+
writable: false,
|
|
4898
|
+
enumerable: true,
|
|
4899
|
+
configurable: false,
|
|
4900
|
+
});
|
|
4901
|
+
Object.defineProperty(stage, "managedLegacyLocalMigrationSource", {
|
|
4902
|
+
value: stage.managedLegacyLocalMigrationSource,
|
|
4903
|
+
writable: false,
|
|
4904
|
+
enumerable: true,
|
|
4905
|
+
configurable: false,
|
|
4906
|
+
});
|
|
4907
|
+
}
|
|
4908
|
+
|
|
4909
|
+
#getPreparedNewSessionStage(prepared: PreparedNewSession): PreparedNewSessionState {
|
|
4910
|
+
const stage = prepared as PreparedNewSessionState;
|
|
4911
|
+
if (!this.#preparedNewSessions.has(stage) || stage.committed || stage.discarded)
|
|
4912
|
+
throw new Error("Prepared session is no longer available.");
|
|
4913
|
+
return stage;
|
|
4914
|
+
}
|
|
4915
|
+
|
|
4916
|
+
#preparedNewSessionLeafId(stage: PreparedNewSessionState): string | null {
|
|
4917
|
+
for (let index = stage.fileEntries.length - 1; index >= 0; index--) {
|
|
4918
|
+
const entry = stage.fileEntries[index];
|
|
4919
|
+
if (entry.type !== "session") return entry.id;
|
|
4920
|
+
}
|
|
4921
|
+
return null;
|
|
4922
|
+
}
|
|
4923
|
+
|
|
4924
|
+
#nextPreparedNewSessionEntryId(stage: PreparedNewSessionState): string {
|
|
4925
|
+
const ids = new Map<string, SessionEntry>();
|
|
4926
|
+
for (const entry of stage.fileEntries) {
|
|
4927
|
+
if (entry.type !== "session") ids.set(entry.id, entry);
|
|
4928
|
+
}
|
|
4929
|
+
return generateId(ids);
|
|
4930
|
+
}
|
|
4931
|
+
|
|
4932
|
+
/** Prepare a forked successor without publishing it through public manager state. @internal */
|
|
4933
|
+
async prepareFork(): Promise<PreparedNewSession | undefined> {
|
|
4934
|
+
await this.#retryPreparedNewSessionCleanups();
|
|
4935
|
+
if (!this.persist || !this.#sessionFile) return undefined;
|
|
4936
|
+
await this.#closePersistWriter();
|
|
4937
|
+
const sourceFile = this.#sessionFile;
|
|
4938
|
+
const sourceId = this.#sessionId;
|
|
4939
|
+
const timestamp = new Date().toISOString();
|
|
4940
|
+
const sessionId = createSessionId();
|
|
4941
|
+
const sessionFile = path.join(this.getSessionDir(), `${timestamp.replace(/[:.]/g, "-")}_${sessionId}.jsonl`);
|
|
4942
|
+
const header: SessionHeader = {
|
|
4943
|
+
type: "session",
|
|
4944
|
+
version: CURRENT_SESSION_VERSION,
|
|
4945
|
+
id: sessionId,
|
|
4946
|
+
title: this.#sessionName,
|
|
4947
|
+
titleSource: this.#titleSource,
|
|
4948
|
+
timestamp,
|
|
4949
|
+
cwd: this.cwd,
|
|
4950
|
+
parentSession: sourceId,
|
|
4951
|
+
};
|
|
4952
|
+
const entries: FileEntry[] = [
|
|
4953
|
+
header,
|
|
4954
|
+
...materializeResidentEntriesForReadSync(this.#fileEntries, this.#residentBlobStores()).filter(
|
|
4955
|
+
(entry): entry is SessionEntry => entry.type !== "session",
|
|
4956
|
+
),
|
|
4957
|
+
];
|
|
4958
|
+
const stage: PreparedNewSessionState = {
|
|
4959
|
+
sessionId,
|
|
4960
|
+
sessionFile,
|
|
4961
|
+
artifactsDir: sessionFile.slice(0, -6),
|
|
4962
|
+
managedLegacyLocalMigrationSource: this.#managedLegacyLocalMigrationSourceFor(sessionFile),
|
|
4963
|
+
header,
|
|
4964
|
+
fileEntries: entries,
|
|
4965
|
+
sessionName: this.#sessionName,
|
|
4966
|
+
titleSource: this.#titleSource,
|
|
4967
|
+
flushed: true,
|
|
4968
|
+
committed: false,
|
|
4969
|
+
discarded: false,
|
|
4970
|
+
};
|
|
4971
|
+
this.#preparedNewSessions.add(stage);
|
|
4972
|
+
this.#freezePreparedNewSessionIdentity(stage);
|
|
4973
|
+
try {
|
|
4974
|
+
await this.copyArtifactsForFork(sourceFile, sessionFile);
|
|
4975
|
+
const content = `${entries.map(entry => JSON.stringify(prepareEntryForPersistenceSync(entry, this.#blobStore))).join("\n")}\n`;
|
|
4976
|
+
if (this.destination.kind === "managed") {
|
|
4977
|
+
this.#managedTranscriptStore(sessionFile).publishNoReplaceSync(
|
|
4978
|
+
path.basename(sessionFile),
|
|
4979
|
+
Buffer.from(content, "utf8"),
|
|
4980
|
+
);
|
|
4981
|
+
} else {
|
|
4982
|
+
this.storage.writeTextSync(sessionFile, content);
|
|
4983
|
+
}
|
|
4984
|
+
} catch (error) {
|
|
4985
|
+
try {
|
|
4986
|
+
await this.discardPreparedNewSession(stage);
|
|
4987
|
+
} catch (cleanupError) {
|
|
4988
|
+
throw new AggregateError(
|
|
4989
|
+
[toError(error), toError(cleanupError)],
|
|
4990
|
+
"Fork preparation and staged session cleanup both failed.",
|
|
4991
|
+
);
|
|
4992
|
+
}
|
|
4993
|
+
throw toError(error);
|
|
4994
|
+
}
|
|
4995
|
+
return stage;
|
|
4996
|
+
}
|
|
4997
|
+
|
|
4998
|
+
/** Prepare a path-only branch successor without publishing it through public manager state. @internal */
|
|
4999
|
+
async prepareBranchedSession(leafId: string): Promise<PreparedNewSession> {
|
|
5000
|
+
this.#assertRecoveryHydrationWritable();
|
|
5001
|
+
await this.#retryPreparedNewSessionCleanups();
|
|
5002
|
+
const branchPath = this.#getCanonicalBranchClones(leafId);
|
|
5003
|
+
if (branchPath.length === 0) throw new Error(`Entry ${leafId} not found`);
|
|
4227
5004
|
await this.#closePersistWriter();
|
|
4228
|
-
const
|
|
5005
|
+
const timestamp = new Date().toISOString();
|
|
5006
|
+
const sessionId = createSessionId();
|
|
5007
|
+
const sessionFile = this.persist
|
|
5008
|
+
? path.join(this.getSessionDir(), `${timestamp.replace(/[:.]/g, "-")}_${sessionId}.jsonl`)
|
|
5009
|
+
: undefined;
|
|
5010
|
+
const header: SessionHeader = {
|
|
5011
|
+
type: "session",
|
|
5012
|
+
version: CURRENT_SESSION_VERSION,
|
|
5013
|
+
id: sessionId,
|
|
5014
|
+
timestamp,
|
|
5015
|
+
cwd: this.cwd,
|
|
5016
|
+
parentSession: this.persist ? this.#sessionFile : undefined,
|
|
5017
|
+
};
|
|
5018
|
+
const pathWithoutLabels = branchPath.filter(entry => entry.type !== "label");
|
|
5019
|
+
const pathEntryIds = new Set(pathWithoutLabels.map(entry => entry.id));
|
|
5020
|
+
const labelEntries: LabelEntry[] = [];
|
|
5021
|
+
let labelParentId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id ?? null;
|
|
5022
|
+
for (const [targetId, label] of this.#labelsById) {
|
|
5023
|
+
if (!pathEntryIds.has(targetId)) continue;
|
|
5024
|
+
const labelEntry: LabelEntry = {
|
|
5025
|
+
type: "label",
|
|
5026
|
+
id: generateId({ has: id => pathEntryIds.has(id) }),
|
|
5027
|
+
parentId: labelParentId,
|
|
5028
|
+
timestamp: new Date().toISOString(),
|
|
5029
|
+
targetId,
|
|
5030
|
+
label,
|
|
5031
|
+
};
|
|
5032
|
+
pathEntryIds.add(labelEntry.id);
|
|
5033
|
+
labelEntries.push(labelEntry);
|
|
5034
|
+
labelParentId = labelEntry.id;
|
|
5035
|
+
}
|
|
5036
|
+
const entries: FileEntry[] = [
|
|
5037
|
+
header,
|
|
5038
|
+
...materializeResidentEntriesForReadSync(pathWithoutLabels, this.#residentBlobStores()),
|
|
5039
|
+
...labelEntries,
|
|
5040
|
+
];
|
|
5041
|
+
const stage: PreparedNewSessionState = {
|
|
5042
|
+
sessionId,
|
|
5043
|
+
sessionFile,
|
|
5044
|
+
artifactsDir: sessionFile ? sessionFile.slice(0, -6) : null,
|
|
5045
|
+
managedLegacyLocalMigrationSource: this.#managedLegacyLocalMigrationSourceFor(sessionFile),
|
|
5046
|
+
header,
|
|
5047
|
+
fileEntries: entries,
|
|
5048
|
+
sessionName: undefined,
|
|
5049
|
+
titleSource: undefined,
|
|
5050
|
+
flushed: this.persist,
|
|
5051
|
+
committed: false,
|
|
5052
|
+
discarded: false,
|
|
5053
|
+
};
|
|
5054
|
+
this.#preparedNewSessions.add(stage);
|
|
5055
|
+
this.#freezePreparedNewSessionIdentity(stage);
|
|
5056
|
+
try {
|
|
5057
|
+
if (sessionFile) {
|
|
5058
|
+
const content = `${entries.map(entry => JSON.stringify(prepareEntryForPersistenceSync(entry, this.#blobStore))).join("\n")}\n`;
|
|
5059
|
+
if (this.destination.kind === "managed") {
|
|
5060
|
+
this.#managedTranscriptStore(sessionFile).publishNoReplaceSync(
|
|
5061
|
+
path.basename(sessionFile),
|
|
5062
|
+
Buffer.from(content, "utf8"),
|
|
5063
|
+
);
|
|
5064
|
+
} else {
|
|
5065
|
+
this.storage.writeTextSync(sessionFile, content);
|
|
5066
|
+
}
|
|
5067
|
+
}
|
|
5068
|
+
return stage;
|
|
5069
|
+
} catch (error) {
|
|
5070
|
+
try {
|
|
5071
|
+
await this.discardPreparedNewSession(stage);
|
|
5072
|
+
} catch (cleanupError) {
|
|
5073
|
+
throw new AggregateError(
|
|
5074
|
+
[error, cleanupError],
|
|
5075
|
+
"Branch preparation and staged session cleanup both failed.",
|
|
5076
|
+
);
|
|
5077
|
+
}
|
|
5078
|
+
throw error;
|
|
5079
|
+
}
|
|
5080
|
+
}
|
|
5081
|
+
|
|
5082
|
+
/** Publish a prepared successor synchronously after all readiness awaits succeed. @internal */
|
|
5083
|
+
commitPreparedNewSession(prepared: PreparedNewSession): void {
|
|
5084
|
+
this.#assertRecoveryHydrationWritable();
|
|
5085
|
+
const stage = prepared as PreparedNewSessionState;
|
|
5086
|
+
if (!this.#preparedNewSessions.has(stage) || stage.committed || stage.discarded) {
|
|
5087
|
+
throw new Error("Prepared session is no longer available.");
|
|
5088
|
+
}
|
|
5089
|
+
this.#preparePreparedNewSessionForCommit(stage);
|
|
5090
|
+
const residentFileEntries = stage.residentFileEntries;
|
|
5091
|
+
const residentTextBlobStore = stage.residentTextBlobStore;
|
|
5092
|
+
const index = stage.index;
|
|
5093
|
+
if (!residentFileEntries || !residentTextBlobStore || !index)
|
|
5094
|
+
throw new Error("Prepared session adoption is incomplete.");
|
|
5095
|
+
if (stage.sessionFile) writeTerminalBreadcrumb(this.cwd, stage.sessionFile);
|
|
5096
|
+
|
|
5097
|
+
stage.committed = true;
|
|
5098
|
+
this.#preparedNewSessions.delete(stage);
|
|
5099
|
+
if (!this.#lifecycleIdAdopted && stage.sessionId === lifecyclePreallocatedSessionId())
|
|
5100
|
+
this.#lifecycleIdAdopted = true;
|
|
5101
|
+
this.#persistChain = Promise.resolve();
|
|
5102
|
+
this.#persistError = undefined;
|
|
5103
|
+
this.#persistErrorReported = false;
|
|
5104
|
+
this.#sessionId = stage.sessionId;
|
|
5105
|
+
this.#sessionName = stage.sessionName;
|
|
5106
|
+
this.#titleSource = stage.titleSource;
|
|
5107
|
+
this.#sessionFile = stage.sessionFile;
|
|
5108
|
+
this.#fileEntries = residentFileEntries;
|
|
5109
|
+
this.#residentTextBlobStore = residentTextBlobStore;
|
|
5110
|
+
this.#byId = index.byId;
|
|
5111
|
+
this.#labelsById = index.labelsById;
|
|
5112
|
+
this.#leafId = index.leafId;
|
|
5113
|
+
this.#usageStatistics = index.usageStatistics;
|
|
5114
|
+
this.#flushed = stage.flushed;
|
|
5115
|
+
this.#needsFullRewriteOnNextPersist = false;
|
|
5116
|
+
this.#ensuredOnDisk = stage.flushed;
|
|
5117
|
+
this.#inMemoryArtifacts = null;
|
|
5118
|
+
this.#inMemoryArtifactCounter = 0;
|
|
5119
|
+
this.#artifactManager = null;
|
|
5120
|
+
this.#artifactManagerSessionFile = null;
|
|
5121
|
+
this.#adoptedArtifactManager = null;
|
|
4229
5122
|
this.#bumpAllRevisions();
|
|
4230
|
-
|
|
5123
|
+
}
|
|
5124
|
+
|
|
5125
|
+
/** Exact-discard only an uncommitted successor prepared by this manager. @internal */
|
|
5126
|
+
async discardPreparedNewSession(prepared: PreparedNewSession): Promise<void> {
|
|
5127
|
+
const stage = prepared as PreparedNewSessionState;
|
|
5128
|
+
if (!this.#preparedNewSessions.has(stage) || stage.committed || stage.discarded) return;
|
|
5129
|
+
const persistenceCleanupError = await this.#cleanupPreparedNewSessionPersistence(stage);
|
|
5130
|
+
let sessionCleanupError: Error | undefined;
|
|
5131
|
+
if (stage.sessionFile) {
|
|
5132
|
+
try {
|
|
5133
|
+
await this.discardUncommittedSession(stage.sessionFile);
|
|
5134
|
+
} catch (error) {
|
|
5135
|
+
sessionCleanupError = toError(error);
|
|
5136
|
+
}
|
|
5137
|
+
}
|
|
5138
|
+
if (persistenceCleanupError || sessionCleanupError) {
|
|
5139
|
+
const errors = [persistenceCleanupError, sessionCleanupError].filter(
|
|
5140
|
+
(error): error is Error => error !== undefined,
|
|
5141
|
+
);
|
|
5142
|
+
throw errors.length === 1 ? errors[0] : new AggregateError(errors, "Prepared session cleanup failed.");
|
|
5143
|
+
}
|
|
5144
|
+
this.#preparedNewSessions.delete(stage);
|
|
5145
|
+
stage.discarded = true;
|
|
5146
|
+
}
|
|
5147
|
+
|
|
5148
|
+
async #retryPreparedNewSessionCleanups(): Promise<void> {
|
|
5149
|
+
if (this.#preparedNewSessionCleanupInProgress) return;
|
|
5150
|
+
this.#preparedNewSessionCleanupInProgress = true;
|
|
5151
|
+
try {
|
|
5152
|
+
const errors: Error[] = [];
|
|
5153
|
+
for (const stage of [...this.#preparedNewSessions]) {
|
|
5154
|
+
if (stage.committed || stage.discarded) continue;
|
|
5155
|
+
try {
|
|
5156
|
+
await this.discardPreparedNewSession(stage);
|
|
5157
|
+
} catch (error) {
|
|
5158
|
+
errors.push(toError(error));
|
|
5159
|
+
}
|
|
5160
|
+
}
|
|
5161
|
+
if (errors.length > 0)
|
|
5162
|
+
throw errors.length === 1
|
|
5163
|
+
? errors[0]
|
|
5164
|
+
: new AggregateError(errors, "Prepared session cleanup retry failed.");
|
|
5165
|
+
} finally {
|
|
5166
|
+
this.#preparedNewSessionCleanupInProgress = false;
|
|
5167
|
+
}
|
|
5168
|
+
}
|
|
5169
|
+
|
|
5170
|
+
async #cleanupPreparedNewSessionPersistence(stage: PreparedNewSessionState): Promise<Error | undefined> {
|
|
5171
|
+
const errors: Error[] = [];
|
|
5172
|
+
if (stage.persistenceWriter) {
|
|
5173
|
+
try {
|
|
5174
|
+
await stage.persistenceWriter.close();
|
|
5175
|
+
stage.persistenceWriter = undefined;
|
|
5176
|
+
} catch (error) {
|
|
5177
|
+
errors.push(toError(error));
|
|
5178
|
+
}
|
|
5179
|
+
}
|
|
5180
|
+
if (stage.persistenceTempPath && !stage.persistenceWriter) {
|
|
5181
|
+
try {
|
|
5182
|
+
await this.storage.unlink(stage.persistenceTempPath);
|
|
5183
|
+
stage.persistenceTempPath = undefined;
|
|
5184
|
+
} catch (error) {
|
|
5185
|
+
if (!isEnoent(error)) errors.push(toError(error));
|
|
5186
|
+
else stage.persistenceTempPath = undefined;
|
|
5187
|
+
}
|
|
5188
|
+
}
|
|
5189
|
+
return errors.length === 0
|
|
5190
|
+
? undefined
|
|
5191
|
+
: errors.length === 1
|
|
5192
|
+
? errors[0]
|
|
5193
|
+
: new AggregateError(errors, "Prepared session temporary-file cleanup failed.");
|
|
4231
5194
|
}
|
|
4232
5195
|
|
|
4233
5196
|
/** Tombstone and exact-delete managed transcripts, detaching the active transcript first. */
|
|
@@ -4255,7 +5218,8 @@ export class SessionManager {
|
|
|
4255
5218
|
const candidate = listing.owned.find(candidate => path.resolve(candidate.path) === requestedPath);
|
|
4256
5219
|
if (!candidate) throw new Error("Managed session deletion requires exact logical authorization.");
|
|
4257
5220
|
const deleted = await deleteManagedSessionCandidate(resolved.scope, candidate);
|
|
4258
|
-
if (deleted.kind
|
|
5221
|
+
if (deleted.kind !== "deleted" && deleted.kind !== "already_deleted")
|
|
5222
|
+
throw new Error(`Could not delete managed session: ${deleted.message}`);
|
|
4259
5223
|
} else {
|
|
4260
5224
|
await this.storage.deleteSessionWithArtifacts(sessionPath);
|
|
4261
5225
|
}
|
|
@@ -4384,27 +5348,20 @@ export class SessionManager {
|
|
|
4384
5348
|
throw new Error("managed_fork_transcript_changed");
|
|
4385
5349
|
}
|
|
4386
5350
|
} catch (error) {
|
|
4387
|
-
|
|
4388
|
-
|
|
4389
|
-
if (this.destination.kind === "managed") {
|
|
5351
|
+
const failure = error;
|
|
5352
|
+
if (forkArtifactPublication) {
|
|
4390
5353
|
try {
|
|
4391
|
-
|
|
4392
|
-
|
|
4393
|
-
|
|
4394
|
-
|
|
4395
|
-
forkArtifactPublication.cleanupRelativePath,
|
|
4396
|
-
forkArtifactPublication.snapshot,
|
|
4397
|
-
);
|
|
5354
|
+
forkArtifactPublication.cleanupStore.removeTreeExpected(
|
|
5355
|
+
forkArtifactPublication.cleanupRelativePath,
|
|
5356
|
+
forkArtifactPublication.snapshot,
|
|
5357
|
+
);
|
|
4398
5358
|
} catch (cleanupError) {
|
|
4399
|
-
|
|
4400
|
-
|
|
4401
|
-
|
|
4402
|
-
|
|
4403
|
-
|
|
4404
|
-
|
|
4405
|
-
await this.storage.unlink(failedSessionFile);
|
|
4406
|
-
} catch {
|
|
4407
|
-
// Preserve ambiguous cleanup evidence while restoring the active manager.
|
|
5359
|
+
const cleanupMessage = toError(cleanupError).message;
|
|
5360
|
+
if (cleanupMessage !== "cleanup_pending") {
|
|
5361
|
+
throw new Error(`Failed to clean up fork artifacts: ${cleanupMessage}`, {
|
|
5362
|
+
cause: toError(failure),
|
|
5363
|
+
});
|
|
5364
|
+
}
|
|
4408
5365
|
}
|
|
4409
5366
|
}
|
|
4410
5367
|
this.#sessionId = oldSessionId;
|
|
@@ -4483,8 +5440,7 @@ export class SessionManager {
|
|
|
4483
5440
|
}
|
|
4484
5441
|
if (JSON.stringify(parentStore.captureTree(sourceName)) !== JSON.stringify(sourceSnapshot))
|
|
4485
5442
|
throw new Error("artifact_source_changed");
|
|
4486
|
-
parentStore.moveTreeNoReplace(stagingName, destinationName, stagingSnapshot);
|
|
4487
|
-
publishedSnapshot = parentStore.captureTree(destinationName);
|
|
5443
|
+
publishedSnapshot = parentStore.moveTreeNoReplace(stagingName, destinationName, stagingSnapshot);
|
|
4488
5444
|
const comparable = (tree: native.NativeDirectoryTreeSnapshot) =>
|
|
4489
5445
|
tree.entries.map(entry => ({
|
|
4490
5446
|
relativePath: entry.relativePath,
|
|
@@ -4508,12 +5464,15 @@ export class SessionManager {
|
|
|
4508
5464
|
};
|
|
4509
5465
|
} catch (error) {
|
|
4510
5466
|
try {
|
|
4511
|
-
if (
|
|
4512
|
-
|
|
5467
|
+
if (stagingSnapshot && mayCleanManagedTreeStaging(error))
|
|
5468
|
+
parentStore.removeTreeExpected(stagingName, stagingSnapshot);
|
|
4513
5469
|
} catch (cleanupError) {
|
|
4514
|
-
|
|
4515
|
-
|
|
4516
|
-
|
|
5470
|
+
const cleanupMessage = toError(cleanupError).message;
|
|
5471
|
+
if (cleanupMessage !== "cleanup_pending") {
|
|
5472
|
+
throw new Error(`Failed to clean up managed fork artifacts: ${cleanupMessage}`, {
|
|
5473
|
+
cause: toError(error),
|
|
5474
|
+
});
|
|
5475
|
+
}
|
|
4517
5476
|
}
|
|
4518
5477
|
throw error;
|
|
4519
5478
|
}
|
|
@@ -4531,19 +5490,15 @@ export class SessionManager {
|
|
|
4531
5490
|
const previousSessionDir = this.sessionDir;
|
|
4532
5491
|
const previousSessionFile = this.#sessionFile;
|
|
4533
5492
|
const previousDestination = this.destination;
|
|
4534
|
-
const previousArtifactDir = previousSessionFile?.slice(0, -6);
|
|
4535
5493
|
|
|
4536
|
-
const nextDestination =
|
|
4537
|
-
|
|
4538
|
-
|
|
4539
|
-
|
|
4540
|
-
|
|
4541
|
-
|
|
4542
|
-
|
|
4543
|
-
|
|
4544
|
-
: computeDefaultSessionDir(resolvedCwd, this.storage);
|
|
4545
|
-
return explicitDestination(directory);
|
|
4546
|
-
})();
|
|
5494
|
+
const nextDestination =
|
|
5495
|
+
this.storage instanceof FileSessionStorage
|
|
5496
|
+
? managedDestination(
|
|
5497
|
+
resolvedCwd,
|
|
5498
|
+
this.storage,
|
|
5499
|
+
this.destination.kind === "managed" ? this.destination.securityContext.agentDir : undefined,
|
|
5500
|
+
)
|
|
5501
|
+
: this.destination;
|
|
4547
5502
|
const newSessionDir = nextDestination.directory;
|
|
4548
5503
|
let hadSessionFile = false;
|
|
4549
5504
|
const managedMove = this.destination.kind === "managed" && nextDestination.kind === "managed";
|
|
@@ -4646,15 +5601,8 @@ export class SessionManager {
|
|
|
4646
5601
|
managedPublishedArtifacts,
|
|
4647
5602
|
);
|
|
4648
5603
|
}
|
|
4649
|
-
if (managedTranscript)
|
|
5604
|
+
if (managedTranscript && !managedSourceStore.readExpected(path.basename(oldSessionFile)))
|
|
4650
5605
|
await managedSourceStore.publishNoReplace(path.basename(oldSessionFile), managedTranscript.bytes);
|
|
4651
|
-
if (managedPublishedArtifacts)
|
|
4652
|
-
managedDestinationStore.removeTreeExpected(
|
|
4653
|
-
path.basename(newArtifactDir),
|
|
4654
|
-
managedPublishedArtifacts,
|
|
4655
|
-
);
|
|
4656
|
-
if (managedPublishedTranscript)
|
|
4657
|
-
managedDestinationStore.removeExpected(path.basename(newSessionFile), managedPublishedTranscript);
|
|
4658
5606
|
};
|
|
4659
5607
|
managedTranscript = managedSourceStore.readExpected(path.basename(oldSessionFile));
|
|
4660
5608
|
hadSessionFile = managedTranscript !== null;
|
|
@@ -4691,10 +5639,20 @@ export class SessionManager {
|
|
|
4691
5639
|
if (!retainedTreeSnapshotEquals(terminalArtifacts, managedPublishedArtifacts))
|
|
4692
5640
|
throw new Error("managed_move_destination_artifacts_changed");
|
|
4693
5641
|
}
|
|
4694
|
-
if (managedArtifacts)
|
|
4695
|
-
|
|
4696
|
-
|
|
4697
|
-
|
|
5642
|
+
if (managedArtifacts) {
|
|
5643
|
+
try {
|
|
5644
|
+
managedSourceStore.removeTreeExpected(path.basename(oldArtifactDir), managedArtifacts);
|
|
5645
|
+
} catch (cleanupError) {
|
|
5646
|
+
if (toError(cleanupError).message !== "cleanup_pending") throw cleanupError;
|
|
5647
|
+
}
|
|
5648
|
+
}
|
|
5649
|
+
if (managedTranscript) {
|
|
5650
|
+
try {
|
|
5651
|
+
managedSourceStore.removeExpected(path.basename(oldSessionFile), managedTranscript);
|
|
5652
|
+
} catch (cleanupError) {
|
|
5653
|
+
if (toError(cleanupError).message !== "cleanup_pending") throw cleanupError;
|
|
5654
|
+
}
|
|
5655
|
+
}
|
|
4698
5656
|
} else {
|
|
4699
5657
|
if (hadSessionFile && oldSessionFile !== newSessionFile) {
|
|
4700
5658
|
await movePathAcrossDevicesSafe(oldSessionFile, newSessionFile);
|
|
@@ -4750,13 +5708,6 @@ export class SessionManager {
|
|
|
4750
5708
|
!managedSourceStore.readExpected(path.basename(oldSessionFile))
|
|
4751
5709
|
)
|
|
4752
5710
|
await managedSourceStore.publishNoReplace(path.basename(oldSessionFile), managedTranscript.bytes);
|
|
4753
|
-
if (managedPublishedArtifacts)
|
|
4754
|
-
managedDestinationStore.removeTreeExpected(
|
|
4755
|
-
path.basename(newArtifactDir),
|
|
4756
|
-
managedPublishedArtifacts,
|
|
4757
|
-
);
|
|
4758
|
-
if (managedPublishedTranscript)
|
|
4759
|
-
managedDestinationStore.removeExpected(path.basename(newSessionFile), managedPublishedTranscript);
|
|
4760
5711
|
} catch (rollbackErr) {
|
|
4761
5712
|
restoreResidentStateAndThrow(
|
|
4762
5713
|
new Error(`Failed to rollback managed move: ${toError(rollbackErr).message}`, {
|
|
@@ -4777,7 +5728,8 @@ export class SessionManager {
|
|
|
4777
5728
|
this.#bumpAllRevisions();
|
|
4778
5729
|
}
|
|
4779
5730
|
|
|
4780
|
-
// Update cwd and sessionDir after
|
|
5731
|
+
// Update cwd and sessionDir after physical publication succeeds. Metadata failures restore the source
|
|
5732
|
+
// authority but deliberately retain any destination publication evidence rather than deleting it.
|
|
4781
5733
|
this.cwd = resolvedCwd;
|
|
4782
5734
|
this.sessionDir = newSessionDir;
|
|
4783
5735
|
this.destination = nextDestination;
|
|
@@ -4798,60 +5750,37 @@ export class SessionManager {
|
|
|
4798
5750
|
await this.#appendHeaderPatch({ cwd: resolvedCwd });
|
|
4799
5751
|
}
|
|
4800
5752
|
} catch (error) {
|
|
4801
|
-
|
|
4802
|
-
|
|
4803
|
-
|
|
4804
|
-
|
|
4805
|
-
|
|
4806
|
-
|
|
4807
|
-
|
|
4808
|
-
|
|
4809
|
-
|
|
4810
|
-
|
|
4811
|
-
|
|
4812
|
-
|
|
4813
|
-
|
|
4814
|
-
this.#managedTranscriptStoreCache =
|
|
4815
|
-
managedMove && managedSourceStore
|
|
4816
|
-
? { directory: path.resolve(previousSessionDir), store: managedSourceStore }
|
|
4817
|
-
: null;
|
|
4818
|
-
const header = this.#fileEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
|
|
4819
|
-
if (header) applyHeaderPatch(header, { cwd: previousCwd });
|
|
4820
|
-
this.#headerExportRevision++;
|
|
4821
|
-
throw error;
|
|
4822
|
-
}
|
|
4823
|
-
if (
|
|
4824
|
-
this.storage instanceof FileSessionStorage &&
|
|
4825
|
-
failedArtifactDir &&
|
|
4826
|
-
previousArtifactDir &&
|
|
4827
|
-
failedArtifactDir !== previousArtifactDir &&
|
|
4828
|
-
fs.existsSync(failedArtifactDir)
|
|
4829
|
-
) {
|
|
4830
|
-
await movePathAcrossDevicesSafe(failedArtifactDir, previousArtifactDir);
|
|
4831
|
-
}
|
|
4832
|
-
if (
|
|
4833
|
-
this.storage instanceof FileSessionStorage &&
|
|
4834
|
-
failedSessionFile &&
|
|
4835
|
-
previousSessionFile &&
|
|
4836
|
-
failedSessionFile !== previousSessionFile &&
|
|
4837
|
-
fs.existsSync(failedSessionFile)
|
|
4838
|
-
) {
|
|
4839
|
-
await movePathAcrossDevicesSafe(failedSessionFile, previousSessionFile);
|
|
5753
|
+
await this.#closePersistWriter().catch(() => {});
|
|
5754
|
+
this.#persistChain = Promise.resolve();
|
|
5755
|
+
this.#persistError = undefined;
|
|
5756
|
+
this.#persistErrorReported = false;
|
|
5757
|
+
if (rollbackManagedMove) {
|
|
5758
|
+
try {
|
|
5759
|
+
await rollbackManagedMove();
|
|
5760
|
+
} catch (rollbackError) {
|
|
5761
|
+
if (toError(rollbackError).message !== "cleanup_pending") {
|
|
5762
|
+
throw new Error(`Failed to rollback managed move: ${toError(rollbackError).message}`, {
|
|
5763
|
+
cause: toError(error),
|
|
5764
|
+
});
|
|
5765
|
+
}
|
|
4840
5766
|
}
|
|
4841
5767
|
this.#sessionFile = previousSessionFile;
|
|
4842
5768
|
this.cwd = previousCwd;
|
|
4843
5769
|
this.sessionDir = previousSessionDir;
|
|
4844
5770
|
this.destination = previousDestination;
|
|
4845
|
-
this.#managedTranscriptStoreCache =
|
|
5771
|
+
this.#managedTranscriptStoreCache =
|
|
5772
|
+
managedMove && managedSourceStore
|
|
5773
|
+
? { directory: path.resolve(previousSessionDir), store: managedSourceStore }
|
|
5774
|
+
: null;
|
|
4846
5775
|
const header = this.#fileEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
|
|
4847
5776
|
if (header) applyHeaderPatch(header, { cwd: previousCwd });
|
|
4848
5777
|
this.#headerExportRevision++;
|
|
4849
|
-
|
|
4850
|
-
} catch (rollbackError) {
|
|
4851
|
-
throw new Error(`Failed to rollback session move: ${toError(rollbackError).message}`, {
|
|
4852
|
-
cause: toError(error),
|
|
4853
|
-
});
|
|
5778
|
+
throw error;
|
|
4854
5779
|
}
|
|
5780
|
+
// The destination has already been published. Retain it rather than moving it
|
|
5781
|
+
// back over the source after a metadata failure.
|
|
5782
|
+
this.#managedTranscriptStoreCache = null;
|
|
5783
|
+
this.#headerExportRevision++;
|
|
4855
5784
|
throw error;
|
|
4856
5785
|
}
|
|
4857
5786
|
|
|
@@ -4907,43 +5836,129 @@ export class SessionManager {
|
|
|
4907
5836
|
}
|
|
4908
5837
|
|
|
4909
5838
|
#buildIndex(): void {
|
|
4910
|
-
this.#
|
|
4911
|
-
this.#
|
|
4912
|
-
this.#
|
|
4913
|
-
this.#
|
|
4914
|
-
|
|
5839
|
+
const index = this.#buildIndexForEntries(this.#fileEntries, this.#sessionFile);
|
|
5840
|
+
this.#byId = index.byId;
|
|
5841
|
+
this.#labelsById = index.labelsById;
|
|
5842
|
+
this.#leafId = index.leafId;
|
|
5843
|
+
this.#usageStatistics = index.usageStatistics;
|
|
5844
|
+
}
|
|
5845
|
+
|
|
5846
|
+
#buildIndexForEntries(fileEntries: FileEntry[], sessionFile: string | undefined): PreparedSessionIndex {
|
|
5847
|
+
const byId = new Map<string, SessionEntry>();
|
|
5848
|
+
const labelsById = new Map<string, string>();
|
|
5849
|
+
let leafId: string | null = null;
|
|
5850
|
+
const usageStatistics: UsageStatistics = {
|
|
5851
|
+
input: 0,
|
|
5852
|
+
output: 0,
|
|
5853
|
+
cacheRead: 0,
|
|
5854
|
+
cacheWrite: 0,
|
|
5855
|
+
premiumRequests: 0,
|
|
5856
|
+
cost: 0,
|
|
5857
|
+
};
|
|
5858
|
+
const addUsage = (totals: ValidatedUsageTotals): boolean => {
|
|
5859
|
+
const next = {
|
|
5860
|
+
input: usageStatistics.input + totals.input,
|
|
5861
|
+
output: usageStatistics.output + totals.output,
|
|
5862
|
+
cacheRead: usageStatistics.cacheRead + totals.cacheRead,
|
|
5863
|
+
cacheWrite: usageStatistics.cacheWrite + totals.cacheWrite,
|
|
5864
|
+
premiumRequests: usageStatistics.premiumRequests + totals.premiumRequests,
|
|
5865
|
+
cost: usageStatistics.cost + totals.cost,
|
|
5866
|
+
};
|
|
5867
|
+
if (Object.values(next).some(value => !Number.isFinite(value))) return false;
|
|
5868
|
+
Object.assign(usageStatistics, next);
|
|
5869
|
+
return true;
|
|
5870
|
+
};
|
|
5871
|
+
const accumulateUsage = (entry: SessionEntry): boolean => {
|
|
5872
|
+
if (entry.type !== "message") return false;
|
|
5873
|
+
const usage =
|
|
5874
|
+
entry.message.role === "assistant"
|
|
5875
|
+
? entry.message.usage
|
|
5876
|
+
: entry.message.role === "toolResult" && entry.message.toolName === "task"
|
|
5877
|
+
? getTaskToolUsage(entry.message.details)
|
|
5878
|
+
: undefined;
|
|
5879
|
+
if (usage === undefined) return false;
|
|
5880
|
+
const totals = validatePersistedUsageTotals(usage);
|
|
5881
|
+
return !(totals && addUsage(totals));
|
|
5882
|
+
};
|
|
5883
|
+
let malformedUsageRecords = 0;
|
|
5884
|
+
const malformedUsageSample: string[] = [];
|
|
5885
|
+
for (const entry of fileEntries) {
|
|
4915
5886
|
if (entry.type === "session") continue;
|
|
4916
|
-
|
|
4917
|
-
|
|
5887
|
+
byId.set(entry.id, entry);
|
|
5888
|
+
leafId = entry.id;
|
|
4918
5889
|
if (entry.type === "label") {
|
|
4919
|
-
if (entry.label)
|
|
4920
|
-
|
|
4921
|
-
} else {
|
|
4922
|
-
this.#labelsById.delete(entry.targetId);
|
|
4923
|
-
}
|
|
5890
|
+
if (entry.label) labelsById.set(entry.targetId, entry.label);
|
|
5891
|
+
else labelsById.delete(entry.targetId);
|
|
4924
5892
|
}
|
|
4925
|
-
if (entry
|
|
4926
|
-
|
|
4927
|
-
|
|
4928
|
-
this.#usageStatistics.output += usage.output;
|
|
4929
|
-
this.#usageStatistics.cacheRead += usage.cacheRead;
|
|
4930
|
-
this.#usageStatistics.cacheWrite += usage.cacheWrite;
|
|
4931
|
-
this.#usageStatistics.premiumRequests += usage.premiumRequests ?? 0;
|
|
4932
|
-
this.#usageStatistics.cost += usage.cost.total;
|
|
5893
|
+
if (accumulateUsage(entry)) {
|
|
5894
|
+
malformedUsageRecords++;
|
|
5895
|
+
if (malformedUsageSample.length < MALFORMED_USAGE_SAMPLE_LIMIT) malformedUsageSample.push(entry.id);
|
|
4933
5896
|
}
|
|
5897
|
+
}
|
|
5898
|
+
if (malformedUsageRecords > 0) {
|
|
5899
|
+
logger.warn("Skipped malformed or overflowing persisted usage records during resume aggregation", {
|
|
5900
|
+
sessionFile,
|
|
5901
|
+
count: malformedUsageRecords,
|
|
5902
|
+
sampleEntryIds: malformedUsageSample,
|
|
5903
|
+
});
|
|
5904
|
+
}
|
|
5905
|
+
return { byId, labelsById, leafId, usageStatistics };
|
|
5906
|
+
}
|
|
4934
5907
|
|
|
4935
|
-
|
|
4936
|
-
|
|
4937
|
-
|
|
4938
|
-
|
|
4939
|
-
|
|
4940
|
-
|
|
4941
|
-
|
|
4942
|
-
|
|
4943
|
-
|
|
4944
|
-
|
|
4945
|
-
|
|
5908
|
+
/**
|
|
5909
|
+
* Commit a validated record's totals only if every cumulative sum stays finite.
|
|
5910
|
+
* Individually-finite buckets can still overflow to Infinity in aggregate (e.g. two
|
|
5911
|
+
* Number.MAX_VALUE records), which would poison getUsageStatistics(); such a record is
|
|
5912
|
+
* rejected atomically (no partial commit). Returns true when committed, false when
|
|
5913
|
+
* rejected for cumulative overflow.
|
|
5914
|
+
*/
|
|
5915
|
+
#addValidatedUsage(totals: ValidatedUsageTotals): boolean {
|
|
5916
|
+
const next = {
|
|
5917
|
+
input: this.#usageStatistics.input + totals.input,
|
|
5918
|
+
output: this.#usageStatistics.output + totals.output,
|
|
5919
|
+
cacheRead: this.#usageStatistics.cacheRead + totals.cacheRead,
|
|
5920
|
+
cacheWrite: this.#usageStatistics.cacheWrite + totals.cacheWrite,
|
|
5921
|
+
premiumRequests: this.#usageStatistics.premiumRequests + totals.premiumRequests,
|
|
5922
|
+
cost: this.#usageStatistics.cost + totals.cost,
|
|
5923
|
+
};
|
|
5924
|
+
if (
|
|
5925
|
+
!Number.isFinite(next.input) ||
|
|
5926
|
+
!Number.isFinite(next.output) ||
|
|
5927
|
+
!Number.isFinite(next.cacheRead) ||
|
|
5928
|
+
!Number.isFinite(next.cacheWrite) ||
|
|
5929
|
+
!Number.isFinite(next.premiumRequests) ||
|
|
5930
|
+
!Number.isFinite(next.cost)
|
|
5931
|
+
)
|
|
5932
|
+
return false;
|
|
5933
|
+
this.#usageStatistics.input = next.input;
|
|
5934
|
+
this.#usageStatistics.output = next.output;
|
|
5935
|
+
this.#usageStatistics.cacheRead = next.cacheRead;
|
|
5936
|
+
this.#usageStatistics.cacheWrite = next.cacheWrite;
|
|
5937
|
+
this.#usageStatistics.premiumRequests = next.premiumRequests;
|
|
5938
|
+
this.#usageStatistics.cost = next.cost;
|
|
5939
|
+
return true;
|
|
5940
|
+
}
|
|
5941
|
+
|
|
5942
|
+
/**
|
|
5943
|
+
* Validate and accumulate one entry's persisted usage through the single shared,
|
|
5944
|
+
* overflow-guarded aggregation path used by both #buildIndex (resume) and #appendEntry
|
|
5945
|
+
* (runtime), covering the assistant and `task` tool-result shapes. Returns true when a
|
|
5946
|
+
* present usage record was skipped as malformed or overflowing (so the caller can report
|
|
5947
|
+
* it); false when the entry has no usage or was aggregated cleanly.
|
|
5948
|
+
*/
|
|
5949
|
+
#accumulateEntryUsage(entry: SessionEntry): boolean {
|
|
5950
|
+
if (entry.type !== "message") return false;
|
|
5951
|
+
if (entry.message.role === "assistant") {
|
|
5952
|
+
const totals = validatePersistedUsageTotals(entry.message.usage);
|
|
5953
|
+
return !(totals && this.#addValidatedUsage(totals));
|
|
5954
|
+
}
|
|
5955
|
+
if (entry.message.role === "toolResult" && entry.message.toolName === "task") {
|
|
5956
|
+
const rawTaskUsage = getTaskToolUsage(entry.message.details);
|
|
5957
|
+
if (rawTaskUsage === undefined) return false;
|
|
5958
|
+
const totals = validatePersistedUsageTotals(rawTaskUsage);
|
|
5959
|
+
return !(totals && this.#addValidatedUsage(totals));
|
|
4946
5960
|
}
|
|
5961
|
+
return false;
|
|
4947
5962
|
}
|
|
4948
5963
|
|
|
4949
5964
|
#recordPersistError(err: unknown): Error {
|
|
@@ -5433,6 +6448,15 @@ export class SessionManager {
|
|
|
5433
6448
|
|
|
5434
6449
|
/** Close the persistent writer after flushing all pending data. */
|
|
5435
6450
|
async close(): Promise<void> {
|
|
6451
|
+
// Drain any uncommitted prepared successors before releasing resources so
|
|
6452
|
+
// dispose/shutdown retains exact cleanup authority (#3138).
|
|
6453
|
+
try {
|
|
6454
|
+
await this.#retryPreparedNewSessionCleanups();
|
|
6455
|
+
} catch (error) {
|
|
6456
|
+
logger.warn("Prepared session cleanup during close failed; retained for retry", {
|
|
6457
|
+
error: toError(error).message,
|
|
6458
|
+
});
|
|
6459
|
+
}
|
|
5436
6460
|
await this.#queuePersistTask(async () => {
|
|
5437
6461
|
if (this.#persistWriter) {
|
|
5438
6462
|
await this.#closePersistWriterInternal();
|
|
@@ -5457,6 +6481,14 @@ export class SessionManager {
|
|
|
5457
6481
|
* writer closure before any destructive operation.
|
|
5458
6482
|
*/
|
|
5459
6483
|
async closeStrict(): Promise<SessionManagerCloseOutcome> {
|
|
6484
|
+
// Drain staged successors on the strict ACP dispose path as well as best-effort close (#3138).
|
|
6485
|
+
try {
|
|
6486
|
+
await this.#retryPreparedNewSessionCleanups();
|
|
6487
|
+
} catch (error) {
|
|
6488
|
+
logger.warn("Prepared session cleanup during closeStrict failed; retained for retry", {
|
|
6489
|
+
error: toError(error).message,
|
|
6490
|
+
});
|
|
6491
|
+
}
|
|
5460
6492
|
let outcome: SessionManagerCloseOutcome = { kind: "closed" };
|
|
5461
6493
|
await this.#queuePersistTask(async () => {
|
|
5462
6494
|
const writer = this.#persistWriter;
|
|
@@ -5523,6 +6555,17 @@ export class SessionManager {
|
|
|
5523
6555
|
return this.sessionDir;
|
|
5524
6556
|
}
|
|
5525
6557
|
|
|
6558
|
+
/** Lists picker candidates within this manager's captured destination authority. */
|
|
6559
|
+
async listForResumePickerReadOnly(): Promise<SessionInfo[]> {
|
|
6560
|
+
return this.destination.kind === "managed"
|
|
6561
|
+
? await SessionManager.listManagedForResumePickerReadOnly(
|
|
6562
|
+
this.cwd,
|
|
6563
|
+
this.destination.securityContext.agentDir,
|
|
6564
|
+
this.storage,
|
|
6565
|
+
)
|
|
6566
|
+
: await SessionManager.listForResumePickerReadOnly(this.cwd, this.destination.directory, this.storage);
|
|
6567
|
+
}
|
|
6568
|
+
|
|
5526
6569
|
getSessionId(): string {
|
|
5527
6570
|
return this.#sessionId;
|
|
5528
6571
|
}
|
|
@@ -5531,6 +6574,160 @@ export class SessionManager {
|
|
|
5531
6574
|
return this.#sessionFile;
|
|
5532
6575
|
}
|
|
5533
6576
|
|
|
6577
|
+
acquireMemoryGuardParticipantIngressLease(): MemoryGuardParticipantIngressLease {
|
|
6578
|
+
if (this.#memoryGuardParticipantIngressToken)
|
|
6579
|
+
throw new Error("memory_guard_participant_ingress_lease_already_held");
|
|
6580
|
+
const token = Symbol("memory_guard_participant_ingress_lease");
|
|
6581
|
+
this.#memoryGuardParticipantIngressToken = token;
|
|
6582
|
+
let released = false;
|
|
6583
|
+
return Object.freeze({
|
|
6584
|
+
token,
|
|
6585
|
+
release: () => {
|
|
6586
|
+
if (released) return;
|
|
6587
|
+
released = true;
|
|
6588
|
+
if (this.#memoryGuardParticipantIngressToken === token)
|
|
6589
|
+
this.#memoryGuardParticipantIngressToken = undefined;
|
|
6590
|
+
},
|
|
6591
|
+
});
|
|
6592
|
+
}
|
|
6593
|
+
|
|
6594
|
+
#assertMemoryGuardParticipantIngressLease(lease: MemoryGuardParticipantIngressLease): void {
|
|
6595
|
+
if (this.#memoryGuardParticipantIngressToken !== lease.token) {
|
|
6596
|
+
throw new Error("memory_guard_participant_ingress_lease_invalid");
|
|
6597
|
+
}
|
|
6598
|
+
}
|
|
6599
|
+
|
|
6600
|
+
#stageMemoryGuardCheckpointBlobs(blobs: Map<string, Buffer>): void {
|
|
6601
|
+
const stagedImageStore = new MemoryBlobStore();
|
|
6602
|
+
for (const blob of blobs.values()) {
|
|
6603
|
+
this.#residentTextBlobStore.putSync(blob);
|
|
6604
|
+
stagedImageStore.putSync(blob);
|
|
6605
|
+
}
|
|
6606
|
+
this.#residentImageBlobStore = stagedImageStore;
|
|
6607
|
+
this.#memoryGuardCheckpointBlobs = blobs;
|
|
6608
|
+
}
|
|
6609
|
+
|
|
6610
|
+
async createMemoryGuardCheckpoint(
|
|
6611
|
+
input: MemoryGuardCreateCheckpointInput,
|
|
6612
|
+
): Promise<MemoryGuardSessionManagerCheckpointV1> {
|
|
6613
|
+
this.#assertMemoryGuardParticipantIngressLease(input.ingressLease);
|
|
6614
|
+
if (!this.#sessionFile) throw new Error("memory_guard_checkpoint_session_file_unavailable");
|
|
6615
|
+
await this.flush();
|
|
6616
|
+
const captured = SessionManager.captureTranscriptStrict(this.#sessionFile, this.storage);
|
|
6617
|
+
if (captured.kind !== "captured") {
|
|
6618
|
+
throw new Error(`memory_guard_checkpoint_capture_failed:${captured.reason}`);
|
|
6619
|
+
}
|
|
6620
|
+
if (captured.snapshot.content.byteLength > MEMORY_GUARD_CHECKPOINT_FILE_MAX_BYTES)
|
|
6621
|
+
throw new Error("memory_guard_checkpoint_transcript_capacity_exceeded");
|
|
6622
|
+
const transcriptText = decodeCheckpointUtf8(captured.snapshot.content);
|
|
6623
|
+
if (transcriptText === null) throw new Error("memory_guard_checkpoint_transcript_unreadable");
|
|
6624
|
+
const entries = parseSessionEntries(transcriptText);
|
|
6625
|
+
const sessionId = this.getSessionId();
|
|
6626
|
+
assertMemoryGuardSessionId(sessionId);
|
|
6627
|
+
const sessionName = this.getSessionName() ?? null;
|
|
6628
|
+
const revisions = this.revisionSnapshot();
|
|
6629
|
+
const participantRoot = memoryGuardParticipantRoot(input.checkpointRoot, sessionId);
|
|
6630
|
+
const transcriptRelativePath = memoryGuardParticipantRelativePath(sessionId, "transcript.jsonl");
|
|
6631
|
+
const blobRootRelativePath = memoryGuardParticipantRelativePath(sessionId, "blobs");
|
|
6632
|
+
const blobManifestRelativePath = memoryGuardParticipantRelativePath(sessionId, "blob-manifest.json");
|
|
6633
|
+
const refs = [...collectCheckpointBlobRefs(entries)].sort();
|
|
6634
|
+
if (refs.length > MEMORY_GUARD_CHECKPOINT_BLOB_MAX_ENTRIES)
|
|
6635
|
+
throw new Error("memory_guard_checkpoint_blob_count_exceeded");
|
|
6636
|
+
const blobManifestEntries: MemoryGuardCheckpointBlobManifestEntryV1[] = [];
|
|
6637
|
+
const blobWrites: Array<{ data: Buffer; relativePath: string }> = [];
|
|
6638
|
+
let aggregateBlobBytes = 0;
|
|
6639
|
+
for (const ref of refs) {
|
|
6640
|
+
const hash = parseBlobRef(ref);
|
|
6641
|
+
if (!hash) continue;
|
|
6642
|
+
const data =
|
|
6643
|
+
this.#residentTextBlobStore.getCheckedSync(hash) ??
|
|
6644
|
+
this.#residentImageBlobStore.getCheckedSync(hash) ??
|
|
6645
|
+
this.#blobStore.getCheckedSync(hash);
|
|
6646
|
+
if (!data) throw new Error(`memory_guard_checkpoint_blob_missing:${hash}`);
|
|
6647
|
+
if (data.byteLength > MEMORY_GUARD_CHECKPOINT_FILE_MAX_BYTES)
|
|
6648
|
+
throw new Error(`memory_guard_checkpoint_blob_capacity_exceeded:${hash}`);
|
|
6649
|
+
aggregateBlobBytes += data.byteLength;
|
|
6650
|
+
if (aggregateBlobBytes > MEMORY_GUARD_CHECKPOINT_BLOB_TOTAL_MAX_BYTES)
|
|
6651
|
+
throw new Error("memory_guard_checkpoint_blob_total_capacity_exceeded");
|
|
6652
|
+
const relativePath = hash;
|
|
6653
|
+
blobWrites.push({ data, relativePath });
|
|
6654
|
+
blobManifestEntries.push({
|
|
6655
|
+
bytes: String(data.byteLength),
|
|
6656
|
+
relative_path: relativePath,
|
|
6657
|
+
sha256: memoryGuardSha256Hex(data),
|
|
6658
|
+
});
|
|
6659
|
+
}
|
|
6660
|
+
const publishedCheckpointPaths: string[] = [];
|
|
6661
|
+
const cleanupPublishedCheckpointPaths = async (): Promise<void> => {
|
|
6662
|
+
for (const publishedPath of publishedCheckpointPaths.reverse())
|
|
6663
|
+
await fs.promises.rm(publishedPath, { force: true }).catch(() => undefined);
|
|
6664
|
+
await fs.promises.rmdir(path.join(input.checkpointRoot, blobRootRelativePath)).catch(() => undefined);
|
|
6665
|
+
await fs.promises.rmdir(participantRoot).catch(() => undefined);
|
|
6666
|
+
};
|
|
6667
|
+
const publishCheckpointFile = async (filePath: string, data: Uint8Array | string): Promise<void> => {
|
|
6668
|
+
try {
|
|
6669
|
+
await writeOwnerOnlyFileNoReplace(filePath, data);
|
|
6670
|
+
publishedCheckpointPaths.push(filePath);
|
|
6671
|
+
} catch (error) {
|
|
6672
|
+
await cleanupPublishedCheckpointPaths();
|
|
6673
|
+
throw error;
|
|
6674
|
+
}
|
|
6675
|
+
};
|
|
6676
|
+
await ensureOwnerOnlyDirectory(participantRoot);
|
|
6677
|
+
await publishCheckpointFile(path.join(input.checkpointRoot, transcriptRelativePath), captured.snapshot.content);
|
|
6678
|
+
for (const write of blobWrites)
|
|
6679
|
+
await publishCheckpointFile(
|
|
6680
|
+
path.join(input.checkpointRoot, blobRootRelativePath, write.relativePath),
|
|
6681
|
+
write.data,
|
|
6682
|
+
);
|
|
6683
|
+
blobManifestEntries.sort((left, right) => left.relative_path.localeCompare(right.relative_path));
|
|
6684
|
+
const blobManifest: MemoryGuardCheckpointBlobManifestV1 = {
|
|
6685
|
+
entries: blobManifestEntries,
|
|
6686
|
+
schema_version: 1,
|
|
6687
|
+
};
|
|
6688
|
+
const blobManifestText = memoryGuardCanonicalJson(blobManifest);
|
|
6689
|
+
await publishCheckpointFile(path.join(input.checkpointRoot, blobManifestRelativePath), blobManifestText);
|
|
6690
|
+
const checkpoint: MemoryGuardSessionManagerCheckpointV1 = {
|
|
6691
|
+
blob_authority: {
|
|
6692
|
+
kind: "checkpoint_blob_tree_v1",
|
|
6693
|
+
manifest_relative_path: blobManifestRelativePath,
|
|
6694
|
+
manifest_sha256: memoryGuardSha256Hex(blobManifestText),
|
|
6695
|
+
root_relative_path: blobRootRelativePath,
|
|
6696
|
+
},
|
|
6697
|
+
revisions: toSessionManagerCheckpointRevisionStrings(revisions),
|
|
6698
|
+
schema_version: 1,
|
|
6699
|
+
session_id: sessionId,
|
|
6700
|
+
session_name: sessionName,
|
|
6701
|
+
transcript: {
|
|
6702
|
+
bytes: String(captured.snapshot.content.byteLength),
|
|
6703
|
+
relative_path: transcriptRelativePath,
|
|
6704
|
+
sha256: captured.snapshot.identity.sha256,
|
|
6705
|
+
},
|
|
6706
|
+
};
|
|
6707
|
+
const checkpointPath = path.join(
|
|
6708
|
+
input.checkpointRoot,
|
|
6709
|
+
memoryGuardParticipantRelativePath(sessionId, "session-manager.json"),
|
|
6710
|
+
);
|
|
6711
|
+
await publishCheckpointFile(checkpointPath, memoryGuardCanonicalJson(checkpoint));
|
|
6712
|
+
const currentRevisions = this.revisionSnapshot();
|
|
6713
|
+
const recaptured = SessionManager.captureTranscriptStrict(this.#sessionFile, this.storage);
|
|
6714
|
+
if (
|
|
6715
|
+
this.getSessionId() !== sessionId ||
|
|
6716
|
+
(this.getSessionName() ?? null) !== sessionName ||
|
|
6717
|
+
Object.keys(revisions).some(
|
|
6718
|
+
key =>
|
|
6719
|
+
currentRevisions[key as keyof SessionManagerRevisionSnapshot] !==
|
|
6720
|
+
revisions[key as keyof SessionManagerRevisionSnapshot],
|
|
6721
|
+
) ||
|
|
6722
|
+
recaptured.kind !== "captured" ||
|
|
6723
|
+
!sameResumeIdentity(captured.snapshot.identity, recaptured.snapshot.identity)
|
|
6724
|
+
) {
|
|
6725
|
+
await cleanupPublishedCheckpointPaths();
|
|
6726
|
+
throw new Error("memory_guard_checkpoint_state_changed_during_capture");
|
|
6727
|
+
}
|
|
6728
|
+
return checkpoint;
|
|
6729
|
+
}
|
|
6730
|
+
|
|
5534
6731
|
/**
|
|
5535
6732
|
* Returns the session artifacts directory path (session file path without .jsonl).
|
|
5536
6733
|
* Returns null when the session is not persisted to a file.
|
|
@@ -5550,15 +6747,28 @@ export class SessionManager {
|
|
|
5550
6747
|
|
|
5551
6748
|
/** Supplies opaque retained authority for mandatory managed legacy local migration. */
|
|
5552
6749
|
getManagedLegacyLocalMigrationSource(): ManagedLegacyLocalMigrationSource | null {
|
|
5553
|
-
|
|
5554
|
-
|
|
6750
|
+
return this.#managedLegacyLocalMigrationSourceFor(this.#sessionFile);
|
|
6751
|
+
}
|
|
6752
|
+
|
|
6753
|
+
#managedLegacyLocalMigrationSourceFor(sessionFile: string | undefined): ManagedLegacyLocalMigrationSource | null {
|
|
6754
|
+
if (this.destination.kind !== "managed" || !sessionFile || this.#adoptedArtifactManager) return null;
|
|
6755
|
+
const store = this.#managedTranscriptStore(sessionFile);
|
|
6756
|
+
const legacyArtifactsRoot = path.basename(sessionFile.slice(0, -6));
|
|
6757
|
+
const legacyLocalRoot = path.posix.join(legacyArtifactsRoot, "local");
|
|
5555
6758
|
return {
|
|
5556
6759
|
capture: async () => {
|
|
5557
6760
|
let snapshot: native.NativeDirectoryTreeSnapshot;
|
|
5558
6761
|
try {
|
|
5559
|
-
snapshot = store.captureTree(
|
|
6762
|
+
snapshot = store.captureTree(legacyLocalRoot);
|
|
5560
6763
|
} catch (error) {
|
|
5561
6764
|
if (error instanceof Error && error.message === "not_found") return null;
|
|
6765
|
+
if (error instanceof Error && error.message === "reparse_point") {
|
|
6766
|
+
try {
|
|
6767
|
+
store.captureTree(legacyArtifactsRoot);
|
|
6768
|
+
} catch (artifactsError) {
|
|
6769
|
+
if (artifactsError instanceof Error && artifactsError.message === "not_found") return null;
|
|
6770
|
+
}
|
|
6771
|
+
}
|
|
5562
6772
|
throw error;
|
|
5563
6773
|
}
|
|
5564
6774
|
let totalBytes = 0;
|
|
@@ -5566,7 +6776,7 @@ export class SessionManager {
|
|
|
5566
6776
|
if (totalBytes > 64 * 1024 * 1024) throw new Error("Legacy local:// migration exceeds the safe size limit");
|
|
5567
6777
|
const entries = snapshot.entries.map(entry => {
|
|
5568
6778
|
if (entry.kind === "directory") return { relativePath: entry.relativePath, kind: "directory" as const };
|
|
5569
|
-
const captured = store.readExpected(path.posix.join(
|
|
6779
|
+
const captured = store.readExpected(path.posix.join(legacyLocalRoot, entry.relativePath));
|
|
5570
6780
|
if (
|
|
5571
6781
|
!captured ||
|
|
5572
6782
|
captured.identity.dev.toString() !== entry.dev ||
|
|
@@ -5582,12 +6792,12 @@ export class SessionManager {
|
|
|
5582
6792
|
sha256: entry.sha256,
|
|
5583
6793
|
};
|
|
5584
6794
|
});
|
|
5585
|
-
const verified = store.captureTree(
|
|
6795
|
+
const verified = store.captureTree(legacyLocalRoot);
|
|
5586
6796
|
if (JSON.stringify(verified) !== JSON.stringify(snapshot))
|
|
5587
6797
|
throw new Error("Legacy local:// migration source changed during capture");
|
|
5588
6798
|
return { snapshot, entries };
|
|
5589
6799
|
},
|
|
5590
|
-
retire: snapshot => store.removeTreeExpected(
|
|
6800
|
+
retire: snapshot => store.removeTreeExpected(legacyLocalRoot, snapshot),
|
|
5591
6801
|
};
|
|
5592
6802
|
}
|
|
5593
6803
|
|
|
@@ -5779,7 +6989,11 @@ export class SessionManager {
|
|
|
5779
6989
|
* @param source - "user" for explicit renames (/rename command, RPC); "auto" for generated titles.
|
|
5780
6990
|
* Auto-generated titles are silently ignored when the user has already set a name.
|
|
5781
6991
|
*/
|
|
6992
|
+
#assertRecoveryHydrationWritable(): void {
|
|
6993
|
+
if (this.#recoveryHydrationContext) throw new Error("recovery_hydration_not_promoted");
|
|
6994
|
+
}
|
|
5782
6995
|
async setSessionName(name: string, source: "auto" | "user" = "auto"): Promise<boolean> {
|
|
6996
|
+
this.#assertRecoveryHydrationWritable();
|
|
5783
6997
|
// User-set names take permanent precedence over auto-generated ones.
|
|
5784
6998
|
if (this.#titleSource === "user" && source === "auto") return false;
|
|
5785
6999
|
|
|
@@ -5905,6 +7119,7 @@ export class SessionManager {
|
|
|
5905
7119
|
}
|
|
5906
7120
|
|
|
5907
7121
|
#appendEntry(entry: SessionEntry): void {
|
|
7122
|
+
this.#assertRecoveryHydrationWritable();
|
|
5908
7123
|
const normalizedEntry = normalizeSessionEntryForStorage(entry);
|
|
5909
7124
|
const residentEntry = prepareEntryForResidentSync(normalizedEntry, this.#residentBlobStores()) as SessionEntry;
|
|
5910
7125
|
this.#fileEntries.push(residentEntry);
|
|
@@ -5914,26 +7129,14 @@ export class SessionManager {
|
|
|
5914
7129
|
this.#leafRevision++;
|
|
5915
7130
|
if (entry.type === "label") this.#labelRevision++;
|
|
5916
7131
|
this._persist(residentEntry);
|
|
5917
|
-
|
|
5918
|
-
|
|
5919
|
-
|
|
5920
|
-
|
|
5921
|
-
|
|
5922
|
-
|
|
5923
|
-
|
|
5924
|
-
|
|
5925
|
-
}
|
|
5926
|
-
|
|
5927
|
-
if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolName === "task") {
|
|
5928
|
-
const usage = getTaskToolUsage(entry.message.details);
|
|
5929
|
-
if (usage) {
|
|
5930
|
-
this.#usageStatistics.input += usage.input;
|
|
5931
|
-
this.#usageStatistics.output += usage.output;
|
|
5932
|
-
this.#usageStatistics.cacheRead += usage.cacheRead;
|
|
5933
|
-
this.#usageStatistics.cacheWrite += usage.cacheWrite;
|
|
5934
|
-
this.#usageStatistics.premiumRequests += usage.premiumRequests ?? 0;
|
|
5935
|
-
this.#usageStatistics.cost += usage.cost.total;
|
|
5936
|
-
}
|
|
7132
|
+
// Same validated, overflow-guarded aggregation as the resume path (#buildIndex): a
|
|
7133
|
+
// malformed task-result or assistant usage shape reaching the append path must not
|
|
7134
|
+
// crash or poison getUsageStatistics() either.
|
|
7135
|
+
if (this.#accumulateEntryUsage(entry)) {
|
|
7136
|
+
logger.warn("Skipped malformed or overflowing usage on appended entry", {
|
|
7137
|
+
sessionFile: this.#sessionFile,
|
|
7138
|
+
entryId: entry.id,
|
|
7139
|
+
});
|
|
5937
7140
|
}
|
|
5938
7141
|
}
|
|
5939
7142
|
|
|
@@ -6135,6 +7338,7 @@ export class SessionManager {
|
|
|
6135
7338
|
* the canonical store. This applies such mutations for real.
|
|
6136
7339
|
*/
|
|
6137
7340
|
applyEntryMessageUpdates(entries: readonly SessionMessageEntry[]): void {
|
|
7341
|
+
this.#assertRecoveryHydrationWritable();
|
|
6138
7342
|
for (const updated of entries) {
|
|
6139
7343
|
const canonical = this.#byId.get(updated.id);
|
|
6140
7344
|
if (canonical?.type !== "message") continue;
|
|
@@ -6151,6 +7355,7 @@ export class SessionManager {
|
|
|
6151
7355
|
|
|
6152
7356
|
/** Write mutated custom-message entries back into the canonical entry store by id. */
|
|
6153
7357
|
applyCustomMessageEntryUpdates(entries: readonly CustomMessageEntry[]): void {
|
|
7358
|
+
this.#assertRecoveryHydrationWritable();
|
|
6154
7359
|
for (const updated of entries) {
|
|
6155
7360
|
const canonical = this.#byId.get(updated.id);
|
|
6156
7361
|
if (canonical?.type !== "custom_message") continue;
|
|
@@ -6170,6 +7375,7 @@ export class SessionManager {
|
|
|
6170
7375
|
* Use sparingly (e.g., pruning old tool outputs).
|
|
6171
7376
|
*/
|
|
6172
7377
|
async rewriteEntries(): Promise<void> {
|
|
7378
|
+
this.#assertRecoveryHydrationWritable();
|
|
6173
7379
|
if (!this.persist || !this.#sessionFile) return;
|
|
6174
7380
|
await this.#rewriteFile();
|
|
6175
7381
|
}
|
|
@@ -6335,6 +7541,7 @@ export class SessionManager {
|
|
|
6335
7541
|
}
|
|
6336
7542
|
|
|
6337
7543
|
evictCompactedContent(firstKeptEntryId: string, compactionEntryId: string): EvictCompactedContentResult {
|
|
7544
|
+
this.#assertRecoveryHydrationWritable();
|
|
6338
7545
|
const firstKept = this.#byId.get(firstKeptEntryId);
|
|
6339
7546
|
const compaction = this.#byId.get(compactionEntryId);
|
|
6340
7547
|
if (!firstKept) throw new Error(`Entry ${firstKeptEntryId} not found`);
|
|
@@ -6485,7 +7692,7 @@ export class SessionManager {
|
|
|
6485
7692
|
? cloneSessionEntry(
|
|
6486
7693
|
rehydrateColdSpillEntry(
|
|
6487
7694
|
materializeResidentEntryForReadSync(entry, this.#residentBlobStores(), new Map()),
|
|
6488
|
-
this.#
|
|
7695
|
+
this.#coldSpillReadStore(),
|
|
6489
7696
|
this.#residentBlobStoresForColdRehydrate(),
|
|
6490
7697
|
),
|
|
6491
7698
|
)
|
|
@@ -6504,7 +7711,7 @@ export class SessionManager {
|
|
|
6504
7711
|
cloneSessionEntry(
|
|
6505
7712
|
rehydrateColdSpillEntry(
|
|
6506
7713
|
materializeResidentEntryForReadSync(current, this.#residentBlobStores(), cache),
|
|
6507
|
-
this.#
|
|
7714
|
+
this.#coldSpillReadStore(),
|
|
6508
7715
|
this.#residentBlobStoresForColdRehydrate(),
|
|
6509
7716
|
),
|
|
6510
7717
|
),
|
|
@@ -6546,7 +7753,7 @@ export class SessionManager {
|
|
|
6546
7753
|
cloneSessionEntry(
|
|
6547
7754
|
rehydrateColdSpillEntry(
|
|
6548
7755
|
materializeResidentEntryForReadSync(entry, this.#residentBlobStores(), cache),
|
|
6549
|
-
this.#
|
|
7756
|
+
this.#coldSpillReadStore(),
|
|
6550
7757
|
this.#residentBlobStoresForColdRehydrate(),
|
|
6551
7758
|
),
|
|
6552
7759
|
),
|
|
@@ -6712,7 +7919,7 @@ export class SessionManager {
|
|
|
6712
7919
|
const materialized = materializeResidentEntryForReadSync(entry, this.#residentBlobStores(), new Map());
|
|
6713
7920
|
const rehydrated = rehydrateColdSpillEntry(
|
|
6714
7921
|
materialized,
|
|
6715
|
-
this.#
|
|
7922
|
+
this.#coldSpillReadStore(),
|
|
6716
7923
|
this.#residentBlobStoresForColdRehydrate(),
|
|
6717
7924
|
);
|
|
6718
7925
|
if (rehydrated !== materialized) this.#coldSpillReadCount += this.#countColdSpillPayloads(entry);
|
|
@@ -6728,6 +7935,7 @@ export class SessionManager {
|
|
|
6728
7935
|
}
|
|
6729
7936
|
/** Strip stale OpenAI Responses assistant replay metadata from loaded in-memory entries without persisting it. */
|
|
6730
7937
|
sanitizeLoadedOpenAIResponsesReplayMetadata(): boolean {
|
|
7938
|
+
this.#assertRecoveryHydrationWritable();
|
|
6731
7939
|
return this.#sanitizeLoadedOpenAIResponsesReplayMetadata().length > 0;
|
|
6732
7940
|
}
|
|
6733
7941
|
|
|
@@ -6883,6 +8091,7 @@ export class SessionManager {
|
|
|
6883
8091
|
* are not modified or deleted.
|
|
6884
8092
|
*/
|
|
6885
8093
|
branch(branchFromId: string): void {
|
|
8094
|
+
this.#assertRecoveryHydrationWritable();
|
|
6886
8095
|
if (!this.#byId.has(branchFromId)) {
|
|
6887
8096
|
throw new Error(`Entry ${branchFromId} not found`);
|
|
6888
8097
|
}
|
|
@@ -6896,6 +8105,7 @@ export class SessionManager {
|
|
|
6896
8105
|
* Use this when navigating to re-edit the first user message.
|
|
6897
8106
|
*/
|
|
6898
8107
|
resetLeaf(): void {
|
|
8108
|
+
this.#assertRecoveryHydrationWritable();
|
|
6899
8109
|
this.#leafId = null;
|
|
6900
8110
|
this.#leafRevision++;
|
|
6901
8111
|
}
|
|
@@ -6906,6 +8116,7 @@ export class SessionManager {
|
|
|
6906
8116
|
* context from the abandoned conversation path.
|
|
6907
8117
|
*/
|
|
6908
8118
|
branchWithSummary(branchFromId: string | null, summary: string, details?: unknown, fromExtension?: boolean): string {
|
|
8119
|
+
this.#assertRecoveryHydrationWritable();
|
|
6909
8120
|
if (branchFromId !== null && !this.#byId.has(branchFromId)) {
|
|
6910
8121
|
throw new Error(`Entry ${branchFromId} not found`);
|
|
6911
8122
|
}
|
|
@@ -6930,6 +8141,7 @@ export class SessionManager {
|
|
|
6930
8141
|
* Returns the new session file path, or undefined if not persisting.
|
|
6931
8142
|
*/
|
|
6932
8143
|
createBranchedSession(leafId: string): string | undefined {
|
|
8144
|
+
this.#assertRecoveryHydrationWritable();
|
|
6933
8145
|
const previousSessionFile = this.#sessionFile;
|
|
6934
8146
|
const branchPath = this.#getCanonicalBranchClones(leafId);
|
|
6935
8147
|
if (branchPath.length === 0) {
|
|
@@ -7080,6 +8292,12 @@ export class SessionManager {
|
|
|
7080
8292
|
authority instanceof ManagedSessionDescendantStore
|
|
7081
8293
|
? authority.retainAuthority()
|
|
7082
8294
|
: retainManagedDirectoryAuthority(rootAuthority, directory);
|
|
8295
|
+
const policy =
|
|
8296
|
+
authority instanceof ManagedSessionDescendantStore
|
|
8297
|
+
? authority.securityPolicy
|
|
8298
|
+
: process.platform === "win32"
|
|
8299
|
+
? "windows-existing-verify-first"
|
|
8300
|
+
: "default";
|
|
7083
8301
|
const securityContext = createManagedSessionSecurityContext({
|
|
7084
8302
|
agentDir: rootAuthority.canonicalPath,
|
|
7085
8303
|
sessionsRoot: rootAuthority.canonicalPath,
|
|
@@ -7087,6 +8305,7 @@ export class SessionManager {
|
|
|
7087
8305
|
rootAuthority,
|
|
7088
8306
|
retainedAuthority,
|
|
7089
8307
|
});
|
|
8308
|
+
managedSecurityPolicies.set(securityContext, policy);
|
|
7090
8309
|
const destination = Object.freeze({ kind: "managed" as const, directory, securityContext });
|
|
7091
8310
|
trustedSessionDestinations.add(destination);
|
|
7092
8311
|
return destination;
|
|
@@ -7115,20 +8334,61 @@ export class SessionManager {
|
|
|
7115
8334
|
return manager;
|
|
7116
8335
|
}
|
|
7117
8336
|
|
|
8337
|
+
/** Prepare a selected candidate using this manager's captured managed destination authority. */
|
|
8338
|
+
async prepareManagedCandidateForWrite(
|
|
8339
|
+
filePath: string,
|
|
8340
|
+
migrationPolicy: SessionDirectoryMigrationPolicy,
|
|
8341
|
+
expectedIdentity?: ResumeSessionIdentity,
|
|
8342
|
+
): Promise<string> {
|
|
8343
|
+
return SessionManager.prepareManagedCandidateForWrite(
|
|
8344
|
+
filePath,
|
|
8345
|
+
migrationPolicy,
|
|
8346
|
+
this.destination,
|
|
8347
|
+
expectedIdentity,
|
|
8348
|
+
);
|
|
8349
|
+
}
|
|
8350
|
+
|
|
8351
|
+
/** Prepare a managed candidate and retain its exact post-preparation identity for the next adoption. */
|
|
8352
|
+
async prepareManagedCandidateForStrictAdoption(
|
|
8353
|
+
filePath: string,
|
|
8354
|
+
migrationPolicy: SessionDirectoryMigrationPolicy,
|
|
8355
|
+
expectedIdentity: ResumeSessionIdentity,
|
|
8356
|
+
): Promise<string> {
|
|
8357
|
+
const preparedPath = await this.prepareManagedCandidateForWrite(filePath, migrationPolicy, expectedIdentity);
|
|
8358
|
+
const inspected = inspectResumeSessionFile(preparedPath, this.storage);
|
|
8359
|
+
if ("kind" in inspected) throw new Error(`Could not inspect prepared managed session: ${inspected.reason}`);
|
|
8360
|
+
this.#pendingStrictAdoption = { canonicalPath: inspected.identity.canonicalPath, identity: inspected.identity };
|
|
8361
|
+
return preparedPath;
|
|
8362
|
+
}
|
|
8363
|
+
|
|
7118
8364
|
/** Resolve a default-managed candidate through binding validation and copy-retain migration before mutation. */
|
|
7119
8365
|
static async prepareManagedCandidateForWrite(
|
|
7120
8366
|
filePath: string,
|
|
7121
8367
|
migrationPolicy: SessionDirectoryMigrationPolicy,
|
|
8368
|
+
destination: SessionDestination,
|
|
8369
|
+
expectedIdentity?: ResumeSessionIdentity,
|
|
7122
8370
|
): Promise<string> {
|
|
8371
|
+
if (!trustedSessionDestinations.has(destination) || destination.kind !== "managed")
|
|
8372
|
+
throw new Error("Managed candidate preparation requires a trusted managed destination authority");
|
|
7123
8373
|
const storage = new FileSessionStorage();
|
|
8374
|
+
const managedDestinationStore = managedStoreFromContext(destination.securityContext, destination.directory);
|
|
8375
|
+
const assertManagedDestinationBound = (): void => {
|
|
8376
|
+
managedDestinationStore.assertBound();
|
|
8377
|
+
if (!destination.securityContext.retainedAuthority)
|
|
8378
|
+
assertManagedDirectoryRoot(destination.securityContext.rootAuthority);
|
|
8379
|
+
};
|
|
8380
|
+
|
|
8381
|
+
assertManagedDestinationBound();
|
|
7124
8382
|
const inspected = inspectResumeSessionFile(filePath, storage);
|
|
7125
8383
|
if ("kind" in inspected) {
|
|
7126
8384
|
if (inspected.reason === "missing") return filePath;
|
|
7127
8385
|
throw new Error(`Could not inspect managed session: ${inspected.reason}`);
|
|
7128
8386
|
}
|
|
8387
|
+
if (expectedIdentity && !sameResumeIdentity(expectedIdentity, inspected.identity))
|
|
8388
|
+
throw new Error("Managed session changed before migration authority was adopted.");
|
|
7129
8389
|
const header = inspected.entries.find(entry => entry.type === "session") as SessionHeader | undefined;
|
|
7130
8390
|
if (!header?.cwd) return filePath;
|
|
7131
|
-
const sessionsRoot =
|
|
8391
|
+
const sessionsRoot = destination.securityContext.sessionsRoot;
|
|
7132
8392
|
const resolvedPath = path.resolve(filePath);
|
|
7133
8393
|
const relativeToManagedRoot = path.relative(sessionsRoot, resolvedPath);
|
|
7134
8394
|
const isManagedPath =
|
|
@@ -7136,14 +8396,17 @@ export class SessionManager {
|
|
|
7136
8396
|
(!relativeToManagedRoot.startsWith("..") && !path.isAbsolute(relativeToManagedRoot));
|
|
7137
8397
|
|
|
7138
8398
|
if (!isManagedPath) return filePath;
|
|
8399
|
+
assertManagedDestinationBound();
|
|
7139
8400
|
const resolved = resolveManagedScope({
|
|
7140
8401
|
cwd: header.cwd,
|
|
7141
|
-
agentDir:
|
|
8402
|
+
agentDir: destination.securityContext.agentDir,
|
|
7142
8403
|
sessionsRoot,
|
|
7143
8404
|
});
|
|
7144
8405
|
if (resolved.kind === "error") throw new Error(`Could not resolve managed session scope: ${resolved.message}`);
|
|
7145
8406
|
|
|
8407
|
+
assertManagedDestinationBound();
|
|
7146
8408
|
const listing = listManagedCandidates(resolved.scope);
|
|
8409
|
+
assertManagedDestinationBound();
|
|
7147
8410
|
if (listing.kind !== "complete") throw new Error(`Managed session scan failed: ${listing.message}`);
|
|
7148
8411
|
|
|
7149
8412
|
const candidate = listing.owned.find(item => path.resolve(item.path) === resolvedPath);
|
|
@@ -7160,11 +8423,37 @@ export class SessionManager {
|
|
|
7160
8423
|
candidate.identity.sha256 !== inspected.identity.sha256
|
|
7161
8424
|
)
|
|
7162
8425
|
throw new Error("Managed session changed before migration authority was adopted.");
|
|
7163
|
-
|
|
8426
|
+
assertManagedDestinationBound();
|
|
8427
|
+
const authority: ManagedCandidateWriteAuthority = {
|
|
8428
|
+
rootAuthority: destination.securityContext.rootAuthority,
|
|
8429
|
+
...(path.resolve(destination.directory) === path.resolve(resolved.scope.directoryPath)
|
|
8430
|
+
? {
|
|
8431
|
+
retainedAuthority: destination.securityContext.retainedAuthority,
|
|
8432
|
+
retainedDirectory: destination.directory,
|
|
8433
|
+
}
|
|
8434
|
+
: {}),
|
|
8435
|
+
};
|
|
8436
|
+
let opened: ManagedOpenCandidateResult;
|
|
8437
|
+
try {
|
|
8438
|
+
opened = await openManagedCandidateForWrite(
|
|
8439
|
+
resolved.scope,
|
|
8440
|
+
candidate,
|
|
8441
|
+
expectedIdentity ?? inspected.identity,
|
|
8442
|
+
migrationPolicy,
|
|
8443
|
+
authority,
|
|
8444
|
+
);
|
|
8445
|
+
} catch (error) {
|
|
8446
|
+
if (error instanceof Error && error.message.startsWith("Managed root authority changed"))
|
|
8447
|
+
managedDestinationStore.assertBound();
|
|
8448
|
+
throw error;
|
|
8449
|
+
}
|
|
7164
8450
|
if (opened.kind === "error") {
|
|
8451
|
+
managedDestinationStore.assertBound();
|
|
7165
8452
|
if (opened.code === "legacy_migration_disabled") throw new SessionMigrationPolicyError();
|
|
8453
|
+
if (opened.code === "artifact_capacity_exceeded") throw new SessionArtifactCapacityError(opened.message);
|
|
7166
8454
|
throw new Error(`Could not open managed session: ${opened.message}`);
|
|
7167
8455
|
}
|
|
8456
|
+
assertManagedDestinationBound();
|
|
7168
8457
|
return opened.path;
|
|
7169
8458
|
}
|
|
7170
8459
|
|
|
@@ -7182,7 +8471,7 @@ export class SessionManager {
|
|
|
7182
8471
|
const destination = destinationFor(cwd, destinationInput, storage);
|
|
7183
8472
|
const managedSourcePath =
|
|
7184
8473
|
destination.kind === "managed" && storage instanceof FileSessionStorage
|
|
7185
|
-
? await SessionManager.prepareManagedCandidateForWrite(sourcePath, migrationPolicy)
|
|
8474
|
+
? await SessionManager.prepareManagedCandidateForWrite(sourcePath, migrationPolicy, destination)
|
|
7186
8475
|
: sourcePath;
|
|
7187
8476
|
const dir = destination.directory;
|
|
7188
8477
|
const manager = new SessionManager(cwd, dir, true, storage, destination);
|
|
@@ -7246,10 +8535,9 @@ export class SessionManager {
|
|
|
7246
8535
|
return manager;
|
|
7247
8536
|
}
|
|
7248
8537
|
|
|
7249
|
-
const
|
|
7250
|
-
const inspected = inspectResumeSessionFile(managedPath, storage);
|
|
8538
|
+
const inspected = inspectResumeSessionFile(filePath, storage);
|
|
7251
8539
|
if ("kind" in inspected) {
|
|
7252
|
-
if (inspected.reason === "missing"
|
|
8540
|
+
if (inspected.reason === "missing") {
|
|
7253
8541
|
const manager = new SessionManager(getProjectDir(), destination.directory, true, storage, destination);
|
|
7254
8542
|
await manager.#initSessionFile(filePath);
|
|
7255
8543
|
return manager;
|
|
@@ -7317,54 +8605,79 @@ export class SessionManager {
|
|
|
7317
8605
|
return manager;
|
|
7318
8606
|
}
|
|
7319
8607
|
/**
|
|
7320
|
-
* List sessions for the resume picker without recovery or other
|
|
8608
|
+
* List default-managed sessions for the resume picker without recovery or other
|
|
8609
|
+
* maintenance writes. This is the only picker inventory that includes legacy
|
|
8610
|
+
* sibling directories.
|
|
8611
|
+
*/
|
|
8612
|
+
static async listManagedForResumePickerReadOnly(
|
|
8613
|
+
cwd: string,
|
|
8614
|
+
managedAgentDir?: string,
|
|
8615
|
+
storage: SessionStorage = new FileSessionStorage(),
|
|
8616
|
+
): Promise<SessionInfo[]> {
|
|
8617
|
+
if (!(storage instanceof FileSessionStorage)) return [];
|
|
8618
|
+
const sessionsRoot = getSessionsDir(managedAgentDir);
|
|
8619
|
+
const resolved = resolveManagedScope({
|
|
8620
|
+
cwd,
|
|
8621
|
+
agentDir: managedAgentDir ?? path.resolve(sessionsRoot, ".."),
|
|
8622
|
+
sessionsRoot,
|
|
8623
|
+
});
|
|
8624
|
+
if (resolved.kind === "error") return [];
|
|
8625
|
+
const listing = listManagedCandidates(resolved.scope);
|
|
8626
|
+
if (listing.kind === "error") return [];
|
|
8627
|
+
const managed = await collectSessionsFromFiles(
|
|
8628
|
+
listing.owned.map(candidate => candidate.path),
|
|
8629
|
+
storage,
|
|
8630
|
+
);
|
|
8631
|
+
return mergeSessionInventories(managed, await collectProjectSessions(cwd, storage));
|
|
8632
|
+
}
|
|
8633
|
+
|
|
8634
|
+
/**
|
|
8635
|
+
* List sessions from an explicitly supplied picker directory without recovery
|
|
8636
|
+
* or other maintenance writes. Unlike managed inventory, this never scans
|
|
8637
|
+
* legacy sibling directories.
|
|
7321
8638
|
*/
|
|
7322
8639
|
static async listForResumePickerReadOnly(
|
|
7323
8640
|
cwd: string,
|
|
7324
8641
|
sessionDir?: string,
|
|
7325
8642
|
storage: SessionStorage = new FileSessionStorage(),
|
|
7326
8643
|
): Promise<SessionInfo[]> {
|
|
7327
|
-
if (!sessionDir
|
|
7328
|
-
if (sessionDir && !resolveManagedSessionRoot(sessionDir, cwd)) {
|
|
7329
|
-
try {
|
|
7330
|
-
return await collectSessionsFromFiles(storage.listFilesSync(sessionDir, "*.jsonl"), storage);
|
|
7331
|
-
} catch {
|
|
7332
|
-
return [];
|
|
7333
|
-
}
|
|
7334
|
-
}
|
|
7335
|
-
const sessionsRoot = sessionDir
|
|
7336
|
-
? (resolveManagedSessionRoot(sessionDir, cwd) ?? getSessionsDir())
|
|
7337
|
-
: getSessionsDir();
|
|
7338
|
-
const resolved = resolveManagedScope({ cwd, agentDir: path.resolve(sessionsRoot, ".."), sessionsRoot });
|
|
7339
|
-
if (resolved.kind === "error") return [];
|
|
7340
|
-
if (storage instanceof FileSessionStorage) {
|
|
7341
|
-
const listing = listManagedCandidates(resolved.scope);
|
|
7342
|
-
if (listing.kind === "error") return [];
|
|
7343
|
-
return await collectSessionsFromFiles(
|
|
7344
|
-
listing.owned.map(candidate => candidate.path),
|
|
7345
|
-
storage,
|
|
7346
|
-
);
|
|
7347
|
-
}
|
|
7348
|
-
const legacyDirectory = `--${resolved.scope.canonicalCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
|
|
7349
|
-
const files = new Set<string>();
|
|
8644
|
+
if (!sessionDir) return await SessionManager.listManagedForResumePickerReadOnly(cwd, undefined, storage);
|
|
7350
8645
|
try {
|
|
7351
|
-
|
|
7352
|
-
for (const file of storage.listFilesSync(directory, "*.jsonl")) files.add(file);
|
|
7353
|
-
}
|
|
8646
|
+
return await collectSessionsFromFiles(storage.listFilesSync(sessionDir, "*.jsonl"), storage);
|
|
7354
8647
|
} catch {
|
|
7355
8648
|
return [];
|
|
7356
8649
|
}
|
|
7357
|
-
return (await collectSessionsFromFiles([...files], storage)).filter(
|
|
7358
|
-
session => resolveEquivalentPath(session.cwd) === resolved.scope.canonicalCwd,
|
|
7359
|
-
);
|
|
7360
8650
|
}
|
|
7361
8651
|
|
|
7362
|
-
/**
|
|
8652
|
+
/** Delete an authorized managed or project-local picker candidate. */
|
|
7363
8653
|
static async deleteManagedCandidate(sessionPath: string): Promise<void> {
|
|
7364
8654
|
const storage = new FileSessionStorage();
|
|
7365
8655
|
const entries = await loadEntriesFromFile(sessionPath, storage);
|
|
7366
8656
|
const header = entries.find(entry => entry.type === "session") as SessionHeader | undefined;
|
|
7367
|
-
if (!header?.cwd) throw new Error("Session has no valid
|
|
8657
|
+
if (!header?.cwd) throw new Error("Session has no valid workspace header.");
|
|
8658
|
+
const projectSkcDir = path.join(path.resolve(header.cwd), ".skc");
|
|
8659
|
+
if (isProjectSessionTranscriptPath(projectSkcDir, sessionPath)) {
|
|
8660
|
+
const relativePath = path.relative(projectSkcDir, path.resolve(sessionPath)).split(path.sep).join("/");
|
|
8661
|
+
const authority = native.openRecoveryFsRoot(projectSkcDir);
|
|
8662
|
+
try {
|
|
8663
|
+
const captured = authority.readManaged(relativePath);
|
|
8664
|
+
if (!captured.ok || !captured.identity || !captured.data || !captured.identity.sha256)
|
|
8665
|
+
throw new Error("Project session is no longer an authorized candidate.");
|
|
8666
|
+
const removed = authority.removeManaged(
|
|
8667
|
+
relativePath,
|
|
8668
|
+
captured.identity.dev,
|
|
8669
|
+
captured.identity.ino,
|
|
8670
|
+
captured.identity.size,
|
|
8671
|
+
captured.identity.mtimeNs,
|
|
8672
|
+
captured.identity.ctimeNs,
|
|
8673
|
+
captured.identity.sha256,
|
|
8674
|
+
);
|
|
8675
|
+
if (!removed.ok) throw new Error(removed.code ?? "Could not delete project session.");
|
|
8676
|
+
return;
|
|
8677
|
+
} finally {
|
|
8678
|
+
authority.close();
|
|
8679
|
+
}
|
|
8680
|
+
}
|
|
7368
8681
|
const sessionsRoot = path.resolve(sessionPath, "../..");
|
|
7369
8682
|
const resolved = resolveManagedScope({
|
|
7370
8683
|
cwd: header.cwd,
|
|
@@ -7377,7 +8690,8 @@ export class SessionManager {
|
|
|
7377
8690
|
const candidate = listing.owned.find(item => path.resolve(item.path) === path.resolve(sessionPath));
|
|
7378
8691
|
if (!candidate) throw new Error("Session is not an authorized managed candidate.");
|
|
7379
8692
|
const deleted = await deleteManagedSessionCandidate(resolved.scope, candidate);
|
|
7380
|
-
if (deleted.kind
|
|
8693
|
+
if (deleted.kind !== "deleted" && deleted.kind !== "already_deleted")
|
|
8694
|
+
throw new Error(`Could not delete managed session: ${deleted.message}`);
|
|
7381
8695
|
}
|
|
7382
8696
|
|
|
7383
8697
|
/** Capture exact source content for a strict fork without granting write ownership. */
|
|
@@ -7437,16 +8751,22 @@ export class SessionManager {
|
|
|
7437
8751
|
}
|
|
7438
8752
|
|
|
7439
8753
|
const dir = destination.directory;
|
|
7440
|
-
const
|
|
8754
|
+
const privateStagingDir =
|
|
7441
8755
|
destination.kind !== "managed" &&
|
|
7442
8756
|
snapshot.storage instanceof FileSessionStorage &&
|
|
7443
|
-
!snapshot.storage.existsSync(dir)
|
|
8757
|
+
!snapshot.storage.existsSync(dir)
|
|
8758
|
+
? fs.mkdtempSync(path.join(path.dirname(dir), `.${path.basename(dir)}.fork-staging-`))
|
|
8759
|
+
: undefined;
|
|
8760
|
+
const forkDestination = privateStagingDir ? explicitDestination(privateStagingDir) : destination;
|
|
7444
8761
|
let managedForkStore: ManagedSessionDescendantStore | undefined;
|
|
7445
8762
|
let managedForkTranscript: ManagedFileSnapshot | null = null;
|
|
7446
8763
|
let manager: SessionManager | undefined;
|
|
7447
8764
|
let authorityFailure: StrictSessionOpenFailure | undefined;
|
|
8765
|
+
let privateStagingSnapshot: native.NativeDirectoryTreeSnapshot | undefined;
|
|
8766
|
+
let privateStagingPublished = false;
|
|
8767
|
+
|
|
7448
8768
|
try {
|
|
7449
|
-
manager = new SessionManager(cwd, dir, true, snapshot.storage,
|
|
8769
|
+
manager = new SessionManager(cwd, privateStagingDir ?? dir, true, snapshot.storage, forkDestination);
|
|
7450
8770
|
await resolveBlobRefsInEntries(forkEntries, manager.#blobStore);
|
|
7451
8771
|
manager.#fileEntries = forkEntries;
|
|
7452
8772
|
const sourceHeader = manager.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
|
|
@@ -7476,6 +8796,13 @@ export class SessionManager {
|
|
|
7476
8796
|
throw new Error("Captured fork source authority changed before destination write.");
|
|
7477
8797
|
}
|
|
7478
8798
|
await manager.#rewriteFile();
|
|
8799
|
+
if (privateStagingDir) {
|
|
8800
|
+
const capturedStaging = native.snapshotDirectoryTree(privateStagingDir);
|
|
8801
|
+
if (!capturedStaging.ok || !capturedStaging.snapshot)
|
|
8802
|
+
throw new Error(capturedStaging.code ?? "fork_staging_snapshot_failed");
|
|
8803
|
+
privateStagingSnapshot = capturedStaging.snapshot;
|
|
8804
|
+
}
|
|
8805
|
+
|
|
7479
8806
|
if (destination.kind === "managed" && manager.#sessionFile) {
|
|
7480
8807
|
managedForkStore = manager.#managedTranscriptStore();
|
|
7481
8808
|
managedForkTranscript = managedForkStore.readExpected(path.basename(manager.#sessionFile));
|
|
@@ -7495,66 +8822,57 @@ export class SessionManager {
|
|
|
7495
8822
|
if (!managedFileSnapshotEquals(terminalTranscript, managedForkTranscript))
|
|
7496
8823
|
throw new Error("managed_fork_transcript_changed");
|
|
7497
8824
|
}
|
|
8825
|
+
if (privateStagingDir && manager.#sessionFile) {
|
|
8826
|
+
const materializedEntries = materializeResidentEntriesForReadSync(
|
|
8827
|
+
manager.#fileEntries,
|
|
8828
|
+
manager.#residentBlobStores(),
|
|
8829
|
+
);
|
|
8830
|
+
manager.#fileEntries = materializedEntries;
|
|
8831
|
+
manager.#disposeResidentTextBlobStore();
|
|
8832
|
+
const capturedStaging = native.snapshotDirectoryTree(privateStagingDir);
|
|
8833
|
+
if (!capturedStaging.ok || !capturedStaging.snapshot)
|
|
8834
|
+
throw new Error(capturedStaging.code ?? "fork_staging_snapshot_failed");
|
|
8835
|
+
privateStagingSnapshot = capturedStaging.snapshot;
|
|
8836
|
+
fsyncManagedArtifactTree(privateStagingDir);
|
|
8837
|
+
const outcome = classifyNativePublishOutcome(native.renameNoReplacePath(privateStagingDir, dir));
|
|
8838
|
+
if (!outcome.ok) throw new Error(outcome.code ?? "fork_destination_publish_failed");
|
|
8839
|
+
privateStagingPublished = true;
|
|
8840
|
+
const terminal = native.snapshotDirectoryTree(dir);
|
|
8841
|
+
if (
|
|
8842
|
+
!terminal.ok ||
|
|
8843
|
+
!terminal.snapshot ||
|
|
8844
|
+
!retainedTreeSnapshotEqualsAfterRename(terminal.snapshot, privateStagingSnapshot)
|
|
8845
|
+
)
|
|
8846
|
+
throw new Error("fork_destination_terminal_identity_changed");
|
|
8847
|
+
await syncSessionMoveDirectory(path.dirname(dir));
|
|
8848
|
+
const durableTerminal = native.snapshotDirectoryTree(dir);
|
|
8849
|
+
if (
|
|
8850
|
+
!durableTerminal.ok ||
|
|
8851
|
+
!durableTerminal.snapshot ||
|
|
8852
|
+
!retainedTreeSnapshotEqualsAfterRename(durableTerminal.snapshot, privateStagingSnapshot)
|
|
8853
|
+
)
|
|
8854
|
+
throw new Error("fork_destination_durability_identity_changed");
|
|
8855
|
+
manager.sessionDir = dir;
|
|
8856
|
+
manager.destination = destination;
|
|
8857
|
+
manager.#sessionFile = path.join(dir, path.basename(manager.#sessionFile));
|
|
8858
|
+
manager.#resetResidentTextBlobStore();
|
|
8859
|
+
manager.#reexternalizeFileEntriesForResidentStore();
|
|
8860
|
+
manager.#bumpAllRevisions();
|
|
8861
|
+
}
|
|
7498
8862
|
if (manager.#sessionFile) writeTerminalBreadcrumb(manager.cwd, manager.#sessionFile);
|
|
7499
8863
|
return { kind: "forked", manager };
|
|
7500
8864
|
} catch (error) {
|
|
7501
8865
|
if (manager) {
|
|
7502
|
-
const sessionFile = manager.#sessionFile;
|
|
7503
8866
|
const cleanupErrors: Error[] = [];
|
|
7504
8867
|
try {
|
|
7505
8868
|
await manager.close();
|
|
7506
8869
|
} catch (cleanupError) {
|
|
7507
|
-
cleanupErrors.push(toError(cleanupError));
|
|
7508
|
-
}
|
|
7509
|
-
if (sessionFile) {
|
|
7510
|
-
if (destination.kind === "managed") {
|
|
7511
|
-
try {
|
|
7512
|
-
if (managedForkStore && managedForkTranscript)
|
|
7513
|
-
managedForkStore.removeExpected(path.basename(sessionFile), managedForkTranscript);
|
|
7514
|
-
} catch (cleanupError) {
|
|
7515
|
-
cleanupErrors.push(toError(cleanupError));
|
|
7516
|
-
}
|
|
7517
|
-
} else {
|
|
7518
|
-
try {
|
|
7519
|
-
await manager.storage.unlink(sessionFile);
|
|
7520
|
-
} catch (cleanupError) {
|
|
7521
|
-
if (!isEnoent(cleanupError)) cleanupErrors.push(toError(cleanupError));
|
|
7522
|
-
}
|
|
7523
|
-
if (manager.storage instanceof FileSessionStorage) {
|
|
7524
|
-
try {
|
|
7525
|
-
await fs.promises.rm(sessionFile.slice(0, -6), { recursive: true, force: true });
|
|
7526
|
-
} catch (cleanupError) {
|
|
7527
|
-
cleanupErrors.push(toError(cleanupError));
|
|
7528
|
-
}
|
|
7529
|
-
try {
|
|
7530
|
-
const sessionFileName = path.basename(sessionFile);
|
|
7531
|
-
const transientPrefix = `.${sessionFileName}.`;
|
|
7532
|
-
const backupPrefix = `${sessionFileName}.`;
|
|
7533
|
-
for (const name of await fs.promises.readdir(path.dirname(sessionFile))) {
|
|
7534
|
-
if (
|
|
7535
|
-
(name.startsWith(transientPrefix) && name.endsWith(".tmp")) ||
|
|
7536
|
-
(name.startsWith(backupPrefix) && name.endsWith(".bak"))
|
|
7537
|
-
) {
|
|
7538
|
-
await fs.promises.rm(path.join(path.dirname(sessionFile), name), {
|
|
7539
|
-
recursive: true,
|
|
7540
|
-
force: true,
|
|
7541
|
-
});
|
|
7542
|
-
}
|
|
7543
|
-
}
|
|
7544
|
-
} catch (cleanupError) {
|
|
7545
|
-
if (!isEnoent(cleanupError)) cleanupErrors.push(toError(cleanupError));
|
|
7546
|
-
}
|
|
7547
|
-
}
|
|
7548
|
-
}
|
|
8870
|
+
if (toError(cleanupError).message !== "cleanup_pending") cleanupErrors.push(toError(cleanupError));
|
|
7549
8871
|
}
|
|
7550
|
-
if (
|
|
7551
|
-
|
|
7552
|
-
|
|
7553
|
-
|
|
7554
|
-
const code = (cleanupError as NodeJS.ErrnoException).code;
|
|
7555
|
-
if (code !== "ENOENT" && code !== "ENOTEMPTY" && code !== "EEXIST")
|
|
7556
|
-
cleanupErrors.push(toError(cleanupError));
|
|
7557
|
-
}
|
|
8872
|
+
if (!privateStagingPublished && privateStagingDir && privateStagingSnapshot) {
|
|
8873
|
+
const removed = native.exactRemoveDirectoryTree(privateStagingDir, privateStagingSnapshot);
|
|
8874
|
+
if (!removed.ok && removed.code !== "not_found" && removed.code !== "cleanup_pending")
|
|
8875
|
+
cleanupErrors.push(new Error(removed.code ?? "fork_staging_cleanup_failed"));
|
|
7558
8876
|
}
|
|
7559
8877
|
if (cleanupErrors.length > 0) {
|
|
7560
8878
|
throw new Error(`Failed to clean up fork destination: ${cleanupErrors[0]!.message}`, {
|
|
@@ -7567,6 +8885,194 @@ export class SessionManager {
|
|
|
7567
8885
|
}
|
|
7568
8886
|
}
|
|
7569
8887
|
|
|
8888
|
+
static async restoreMemoryGuardCheckpoint(input: MemoryGuardRestoreInput): Promise<MemoryGuardRestoreResult> {
|
|
8889
|
+
try {
|
|
8890
|
+
assertMemoryGuardSessionId(input.checkpoint.session_id);
|
|
8891
|
+
} catch {
|
|
8892
|
+
return { kind: "blocked", reason: "checkpoint-mismatch" };
|
|
8893
|
+
}
|
|
8894
|
+
if (!validateMemoryGuardCheckpoint(input.checkpoint)) return { kind: "blocked", reason: "checkpoint-mismatch" };
|
|
8895
|
+
if (!memoryGuardParticipantMatchesCheckpoint(input.participant, input.checkpoint)) {
|
|
8896
|
+
return { kind: "blocked", reason: "participant-mismatch" };
|
|
8897
|
+
}
|
|
8898
|
+
const checkpointText = readCheckpointAuthorityFile(
|
|
8899
|
+
input.incidentAuthority,
|
|
8900
|
+
memoryGuardParticipantRelativePath(input.checkpoint.session_id, "session-manager.json"),
|
|
8901
|
+
256 * 1024,
|
|
8902
|
+
);
|
|
8903
|
+
if (!checkpointText) return { kind: "blocked", reason: "checkpoint-mismatch" };
|
|
8904
|
+
const checkpointCanonical = memoryGuardCanonicalJson(input.checkpoint);
|
|
8905
|
+
if (decodeCheckpointUtf8(checkpointText) !== checkpointCanonical) {
|
|
8906
|
+
return { kind: "blocked", reason: "checkpoint-mismatch" };
|
|
8907
|
+
}
|
|
8908
|
+
const transcriptBytes = Number(input.checkpoint.transcript.bytes);
|
|
8909
|
+
if (!Number.isSafeInteger(transcriptBytes) || transcriptBytes < 0) {
|
|
8910
|
+
return { kind: "blocked", reason: "transcript-mismatch" };
|
|
8911
|
+
}
|
|
8912
|
+
const transcriptData = readCheckpointAuthorityFile(
|
|
8913
|
+
input.incidentAuthority,
|
|
8914
|
+
input.checkpoint.transcript.relative_path,
|
|
8915
|
+
transcriptBytes + 1,
|
|
8916
|
+
);
|
|
8917
|
+
if (!transcriptData || transcriptData.byteLength !== transcriptBytes) {
|
|
8918
|
+
return { kind: "blocked", reason: "transcript-mismatch" };
|
|
8919
|
+
}
|
|
8920
|
+
if (memoryGuardSha256Hex(transcriptData) !== input.checkpoint.transcript.sha256) {
|
|
8921
|
+
return { kind: "blocked", reason: "transcript-mismatch" };
|
|
8922
|
+
}
|
|
8923
|
+
const transcriptText = decodeCheckpointUtf8(transcriptData);
|
|
8924
|
+
if (transcriptText === null) return { kind: "blocked", reason: "transcript-mismatch" };
|
|
8925
|
+
let transcriptEntries: FileEntry[];
|
|
8926
|
+
try {
|
|
8927
|
+
transcriptEntries = parseSessionEntries(transcriptText);
|
|
8928
|
+
} catch {
|
|
8929
|
+
return { kind: "blocked", reason: "malformed" };
|
|
8930
|
+
}
|
|
8931
|
+
const transcriptHeader = transcriptEntries[0] as SessionHeader | undefined;
|
|
8932
|
+
if (
|
|
8933
|
+
transcriptHeader?.type !== "session" ||
|
|
8934
|
+
transcriptHeader.id !== input.checkpoint.session_id ||
|
|
8935
|
+
(transcriptHeader.title ?? null) !== input.checkpoint.session_name
|
|
8936
|
+
) {
|
|
8937
|
+
return { kind: "blocked", reason: "transcript-mismatch" };
|
|
8938
|
+
}
|
|
8939
|
+
const manifestData = readCheckpointAuthorityFile(
|
|
8940
|
+
input.incidentAuthority,
|
|
8941
|
+
input.checkpoint.blob_authority.manifest_relative_path,
|
|
8942
|
+
8 * 1024 * 1024,
|
|
8943
|
+
);
|
|
8944
|
+
if (!manifestData) return { kind: "blocked", reason: "blob-authority-mismatch" };
|
|
8945
|
+
if (memoryGuardSha256Hex(manifestData) !== input.checkpoint.blob_authority.manifest_sha256) {
|
|
8946
|
+
return { kind: "blocked", reason: "blob-authority-mismatch" };
|
|
8947
|
+
}
|
|
8948
|
+
const manifestText = decodeCheckpointUtf8(manifestData);
|
|
8949
|
+
if (manifestText === null) return { kind: "blocked", reason: "blob-manifest-mismatch" };
|
|
8950
|
+
let blobManifest: unknown;
|
|
8951
|
+
try {
|
|
8952
|
+
blobManifest = JSON.parse(manifestText);
|
|
8953
|
+
} catch {
|
|
8954
|
+
return { kind: "blocked", reason: "blob-manifest-mismatch" };
|
|
8955
|
+
}
|
|
8956
|
+
if (!validateMemoryGuardBlobManifest(blobManifest)) return { kind: "blocked", reason: "blob-manifest-mismatch" };
|
|
8957
|
+
if (memoryGuardCanonicalJson(blobManifest) !== manifestText)
|
|
8958
|
+
return { kind: "blocked", reason: "blob-manifest-mismatch" };
|
|
8959
|
+
if (blobManifest.entries.length > MEMORY_GUARD_CHECKPOINT_BLOB_MAX_ENTRIES)
|
|
8960
|
+
return { kind: "blocked", reason: "blob-manifest-mismatch" };
|
|
8961
|
+
let checkpointBlobBytes = 0;
|
|
8962
|
+
const seenBlobPaths = new Set<string>();
|
|
8963
|
+
const checkpointBlobs = new Map<string, Buffer>();
|
|
8964
|
+
for (const entry of blobManifest.entries) {
|
|
8965
|
+
if (
|
|
8966
|
+
entry.relative_path !== entry.sha256 ||
|
|
8967
|
+
seenBlobPaths.has(entry.relative_path) ||
|
|
8968
|
+
checkpointBlobs.has(entry.sha256)
|
|
8969
|
+
)
|
|
8970
|
+
return { kind: "blocked", reason: "blob-manifest-mismatch" };
|
|
8971
|
+
seenBlobPaths.add(entry.relative_path);
|
|
8972
|
+
const blobBytes = Number(entry.bytes);
|
|
8973
|
+
if (!Number.isSafeInteger(blobBytes) || blobBytes < 0)
|
|
8974
|
+
return { kind: "blocked", reason: "blob-manifest-mismatch" };
|
|
8975
|
+
checkpointBlobBytes += blobBytes;
|
|
8976
|
+
if (checkpointBlobBytes > MEMORY_GUARD_CHECKPOINT_BLOB_TOTAL_MAX_BYTES)
|
|
8977
|
+
return { kind: "blocked", reason: "blob-manifest-mismatch" };
|
|
8978
|
+
const blobPath = `${input.checkpoint.blob_authority.root_relative_path}/${entry.relative_path}`;
|
|
8979
|
+
const blobData = readCheckpointAuthorityFile(input.incidentAuthority, blobPath, blobBytes + 1);
|
|
8980
|
+
if (!blobData || blobData.byteLength !== blobBytes) return { kind: "blocked", reason: "blob-missing" };
|
|
8981
|
+
if (memoryGuardSha256Hex(blobData) !== entry.sha256) return { kind: "blocked", reason: "blob-hash-mismatch" };
|
|
8982
|
+
checkpointBlobs.set(entry.sha256, Buffer.from(blobData));
|
|
8983
|
+
}
|
|
8984
|
+
for (const ref of collectCheckpointBlobRefs(transcriptEntries)) {
|
|
8985
|
+
const hash = parseBlobRef(ref);
|
|
8986
|
+
if (!hash || !checkpointBlobs.has(hash)) return { kind: "blocked", reason: "blob-manifest-mismatch" };
|
|
8987
|
+
}
|
|
8988
|
+
const storage = new FileSessionStorage();
|
|
8989
|
+
let destination: SessionDestination;
|
|
8990
|
+
try {
|
|
8991
|
+
destination =
|
|
8992
|
+
input.destination === undefined
|
|
8993
|
+
? explicitDestination(path.join(os.tmpdir(), `skc-memory-guard-${input.checkpoint.session_id}`))
|
|
8994
|
+
: destinationFor(getProjectDir(), input.destination, storage);
|
|
8995
|
+
} catch {
|
|
8996
|
+
return { kind: "blocked", reason: "destination-unavailable" };
|
|
8997
|
+
}
|
|
8998
|
+
const transcriptPath = path.join(
|
|
8999
|
+
destination.directory,
|
|
9000
|
+
`.${input.checkpoint.session_id}.memory-guard.${crypto.randomUUID()}.jsonl`,
|
|
9001
|
+
);
|
|
9002
|
+
const transcriptName = path.basename(transcriptPath);
|
|
9003
|
+
const managedStagingStore =
|
|
9004
|
+
destination.kind === "managed"
|
|
9005
|
+
? managedStoreFromContext(destination.securityContext, destination.directory)
|
|
9006
|
+
: undefined;
|
|
9007
|
+
const cleanupTranscript = async (): Promise<void> => {
|
|
9008
|
+
if (managedStagingStore) {
|
|
9009
|
+
await managedStagingStore.remove(transcriptName);
|
|
9010
|
+
return;
|
|
9011
|
+
}
|
|
9012
|
+
await fs.promises.rm(transcriptPath, { force: true });
|
|
9013
|
+
};
|
|
9014
|
+
let opened: RecoveryHydrationOpenResult;
|
|
9015
|
+
let transcriptIdentity: ResumeSessionIdentity;
|
|
9016
|
+
let createdDestination: CreatedDirectoryIdentity | undefined;
|
|
9017
|
+
try {
|
|
9018
|
+
if (managedStagingStore) await managedStagingStore.publishNoReplace(transcriptName, transcriptData);
|
|
9019
|
+
else {
|
|
9020
|
+
createdDestination = await ensureOwnerOnlyDirectoryTracked(destination.directory);
|
|
9021
|
+
await writeOwnerOnlyFileNoReplace(transcriptPath, transcriptData);
|
|
9022
|
+
}
|
|
9023
|
+
const captured = SessionManager.captureTranscriptStrict(transcriptPath, storage);
|
|
9024
|
+
if (captured.kind !== "captured") {
|
|
9025
|
+
await cleanupTranscript();
|
|
9026
|
+
await removeCreatedDirectoryIfEmpty(destination.directory, createdDestination);
|
|
9027
|
+
return { kind: "blocked", reason: captured.reason };
|
|
9028
|
+
}
|
|
9029
|
+
transcriptIdentity = captured.snapshot.identity;
|
|
9030
|
+
opened = await SessionManager.openExistingForRecoveryHydrationStrict(
|
|
9031
|
+
transcriptIdentity,
|
|
9032
|
+
input.destination,
|
|
9033
|
+
storage,
|
|
9034
|
+
);
|
|
9035
|
+
} catch {
|
|
9036
|
+
await cleanupTranscript();
|
|
9037
|
+
await removeCreatedDirectoryIfEmpty(destination.directory, createdDestination);
|
|
9038
|
+
return { kind: "blocked", reason: "destination-unavailable" };
|
|
9039
|
+
}
|
|
9040
|
+
if (opened.kind === "error") {
|
|
9041
|
+
await cleanupTranscript();
|
|
9042
|
+
await removeCreatedDirectoryIfEmpty(destination.directory, createdDestination);
|
|
9043
|
+
return { kind: "blocked", reason: opened.reason };
|
|
9044
|
+
}
|
|
9045
|
+
if (
|
|
9046
|
+
opened.manager.getSessionId() !== input.checkpoint.session_id ||
|
|
9047
|
+
(opened.manager.getSessionName() ?? null) !== input.checkpoint.session_name
|
|
9048
|
+
) {
|
|
9049
|
+
await opened.manager.close();
|
|
9050
|
+
await cleanupTranscript();
|
|
9051
|
+
await removeCreatedDirectoryIfEmpty(destination.directory, createdDestination);
|
|
9052
|
+
return { kind: "blocked", reason: "transcript-mismatch" };
|
|
9053
|
+
}
|
|
9054
|
+
opened.manager.#stageMemoryGuardCheckpointBlobs(checkpointBlobs);
|
|
9055
|
+
opened.manager.#recoveryPromotionTranscriptPath = path.join(
|
|
9056
|
+
destination.directory,
|
|
9057
|
+
`${new Date().toISOString().replace(/[:.]/g, "-")}_${input.checkpoint.session_id}.jsonl`,
|
|
9058
|
+
);
|
|
9059
|
+
let cleanupConsumed = false;
|
|
9060
|
+
const cleanup = async (): Promise<void> => {
|
|
9061
|
+
if (cleanupConsumed) return;
|
|
9062
|
+
await opened.manager.close();
|
|
9063
|
+
await cleanupTranscript();
|
|
9064
|
+
await removeCreatedDirectoryIfEmpty(destination.directory, createdDestination);
|
|
9065
|
+
cleanupConsumed = true;
|
|
9066
|
+
};
|
|
9067
|
+
return {
|
|
9068
|
+
kind: "staged",
|
|
9069
|
+
manager: opened.manager,
|
|
9070
|
+
hydrationContext: opened.context,
|
|
9071
|
+
transcriptIdentity,
|
|
9072
|
+
cleanup,
|
|
9073
|
+
};
|
|
9074
|
+
}
|
|
9075
|
+
|
|
7570
9076
|
/** Inspect a selected session without acquiring write-capable ownership. */
|
|
7571
9077
|
static async inspectSessionTailReadOnly(
|
|
7572
9078
|
filePath: string,
|
|
@@ -7635,8 +9141,31 @@ export class SessionManager {
|
|
|
7635
9141
|
if ("kind" in inspected || !sameResumeIdentity(context.identity, inspected.identity)) {
|
|
7636
9142
|
throw new Error("Recovery transcript authority changed before promotion.");
|
|
7637
9143
|
}
|
|
9144
|
+
if (this.#memoryGuardCheckpointBlobs) {
|
|
9145
|
+
for (const blob of this.#memoryGuardCheckpointBlobs.values()) this.#blobStore.putImmutableSync(blob);
|
|
9146
|
+
this.#residentImageBlobStore = this.#blobStore;
|
|
9147
|
+
this.#memoryGuardCheckpointBlobs = undefined;
|
|
9148
|
+
}
|
|
7638
9149
|
await this.#sanitizeLoadedOpenAIResponsesReplayMetadataAndPersist();
|
|
7639
|
-
|
|
9150
|
+
const promotionPath = this.#recoveryPromotionTranscriptPath;
|
|
9151
|
+
if (!promotionPath) throw new Error("Recovery transcript promotion path is unavailable.");
|
|
9152
|
+
const promotedSource = inspectResumeSessionFile(context.identity.canonicalPath, this.storage);
|
|
9153
|
+
if ("kind" in promotedSource) throw new Error("Recovery transcript became unavailable before publication.");
|
|
9154
|
+
if (this.destination.kind === "managed") {
|
|
9155
|
+
const store = this.#managedTranscriptStore();
|
|
9156
|
+
const sourceName = path.basename(context.identity.canonicalPath);
|
|
9157
|
+
const sourceSnapshot = store.readExpected(sourceName);
|
|
9158
|
+
if (!sourceSnapshot) throw new Error("Recovery transcript authority changed before publication.");
|
|
9159
|
+
await store.publishNoReplace(path.basename(promotionPath), promotedSource.content);
|
|
9160
|
+
store.removeExpected(sourceName, sourceSnapshot);
|
|
9161
|
+
} else {
|
|
9162
|
+
await writeOwnerOnlyFileNoReplace(promotionPath, promotedSource.content);
|
|
9163
|
+
await fs.promises.rm(context.identity.canonicalPath, { force: true });
|
|
9164
|
+
await fsyncDirectoryPath(path.dirname(context.identity.canonicalPath));
|
|
9165
|
+
}
|
|
9166
|
+
this.#sessionFile = promotionPath;
|
|
9167
|
+
this.#recoveryPromotionTranscriptPath = undefined;
|
|
9168
|
+
writeTerminalBreadcrumb(this.cwd, promotionPath);
|
|
7640
9169
|
this.#recoveryHydrationContext = undefined;
|
|
7641
9170
|
}
|
|
7642
9171
|
|
|
@@ -7658,36 +9187,41 @@ export class SessionManager {
|
|
|
7658
9187
|
let sessionPath = identity.canonicalPath;
|
|
7659
9188
|
if (destination.kind === "managed" && storage instanceof FileSessionStorage) {
|
|
7660
9189
|
try {
|
|
7661
|
-
sessionPath = await SessionManager.prepareManagedCandidateForWrite(
|
|
9190
|
+
sessionPath = await SessionManager.prepareManagedCandidateForWrite(
|
|
9191
|
+
sessionPath,
|
|
9192
|
+
migrationPolicy,
|
|
9193
|
+
destination,
|
|
9194
|
+
identity,
|
|
9195
|
+
);
|
|
7662
9196
|
} catch (error) {
|
|
7663
9197
|
if (error instanceof SessionMigrationPolicyError)
|
|
7664
9198
|
return { kind: "error", reason: "legacy_migration_disabled" };
|
|
9199
|
+
if (error instanceof SessionArtifactCapacityError)
|
|
9200
|
+
return { kind: "error", reason: "artifact_capacity_exceeded", message: error.message };
|
|
9201
|
+
if (
|
|
9202
|
+
error instanceof Error &&
|
|
9203
|
+
(error.message.includes("source_changed") || error.message.includes("changed before migration"))
|
|
9204
|
+
)
|
|
9205
|
+
return { kind: "error", reason: "identity-mismatch" };
|
|
7665
9206
|
throw error;
|
|
7666
9207
|
}
|
|
7667
9208
|
}
|
|
7668
9209
|
const inspected = inspectResumeSessionFile(sessionPath, storage);
|
|
7669
9210
|
if ("kind" in inspected) return inspected;
|
|
7670
|
-
if (
|
|
7671
|
-
(sessionPath === identity.canonicalPath && !sameResumeIdentity(identity, inspected.identity)) ||
|
|
7672
|
-
(sessionPath !== identity.canonicalPath &&
|
|
7673
|
-
(identity.sessionId !== inspected.identity.sessionId || identity.sha256 !== inspected.identity.sha256))
|
|
7674
|
-
) {
|
|
9211
|
+
if (sessionPath === identity.canonicalPath && !sameResumeIdentity(identity, inspected.identity)) {
|
|
7675
9212
|
return { kind: "error", reason: "identity-mismatch" };
|
|
7676
9213
|
}
|
|
9214
|
+
|
|
7677
9215
|
const entries = inspected.entries;
|
|
7678
9216
|
const header = entries[0] as SessionHeader;
|
|
7679
9217
|
const dir = destination.directory;
|
|
7680
9218
|
const manager = new SessionManager(header.cwd || getProjectDir(), dir, true, storage, destination);
|
|
7681
9219
|
await manager.#hydrateExistingSession(sessionPath, entries, inspected.migrationApplied);
|
|
7682
|
-
const ownershipInspection =
|
|
7683
|
-
if (
|
|
9220
|
+
const ownershipInspection = revalidateResumeSessionIdentity(sessionPath, storage, inspected.identity);
|
|
9221
|
+
if (ownershipInspection.kind === "error") {
|
|
7684
9222
|
await manager.close();
|
|
7685
9223
|
return ownershipInspection;
|
|
7686
9224
|
}
|
|
7687
|
-
if (!sameResumeIdentity(inspected.identity, ownershipInspection.identity)) {
|
|
7688
|
-
await manager.close();
|
|
7689
|
-
return { kind: "error", reason: "identity-mismatch" };
|
|
7690
|
-
}
|
|
7691
9225
|
try {
|
|
7692
9226
|
await manager.#sanitizeLoadedOpenAIResponsesReplayMetadataAndPersist();
|
|
7693
9227
|
writeTerminalBreadcrumb(manager.cwd, sessionPath);
|
|
@@ -7722,6 +9256,10 @@ export class SessionManager {
|
|
|
7722
9256
|
);
|
|
7723
9257
|
if (opened.kind === "error") {
|
|
7724
9258
|
if (opened.reason === "legacy_migration_disabled") throw new SessionMigrationPolicyError();
|
|
9259
|
+
if (opened.reason === "artifact_capacity_exceeded")
|
|
9260
|
+
throw new SessionArtifactCapacityError(
|
|
9261
|
+
opened.message ?? "Session artifacts exceed the migration capacity.",
|
|
9262
|
+
);
|
|
7725
9263
|
return undefined;
|
|
7726
9264
|
}
|
|
7727
9265
|
return opened.manager;
|
|
@@ -7731,9 +9269,20 @@ export class SessionManager {
|
|
|
7731
9269
|
const managedCandidates =
|
|
7732
9270
|
destination.kind === "explicit"
|
|
7733
9271
|
? []
|
|
7734
|
-
: await SessionManager.
|
|
9272
|
+
: await SessionManager.listManagedForResumePickerReadOnly(
|
|
9273
|
+
cwd,
|
|
9274
|
+
destination.securityContext.agentDir,
|
|
9275
|
+
storage,
|
|
9276
|
+
);
|
|
9277
|
+
|
|
7735
9278
|
const terminalSession = await readTerminalBreadcrumb(cwd);
|
|
7736
|
-
|
|
9279
|
+
const terminalSessionIsInExplicitRoot = (() => {
|
|
9280
|
+
if (destination.kind === "managed" || !terminalSession) return destination.kind === "managed";
|
|
9281
|
+
const canonicalRoot = canonicalizeTrustedPath(destination.directory);
|
|
9282
|
+
const canonicalSessionPath = canonicalizeTrustedPath(terminalSession);
|
|
9283
|
+
return pathIsWithin(canonicalRoot, canonicalSessionPath);
|
|
9284
|
+
})();
|
|
9285
|
+
if (terminalSession && terminalSessionIsInExplicitRoot) {
|
|
7737
9286
|
const opened = await openSelectedStrictly(terminalSession);
|
|
7738
9287
|
if (opened) return opened;
|
|
7739
9288
|
}
|
|
@@ -7771,7 +9320,7 @@ export class SessionManager {
|
|
|
7771
9320
|
sessionDir?: string,
|
|
7772
9321
|
storage: SessionStorage = new FileSessionStorage(),
|
|
7773
9322
|
): Promise<SessionInfo[]> {
|
|
7774
|
-
if (!sessionDir) return await SessionManager.
|
|
9323
|
+
if (!sessionDir) return await SessionManager.listManagedForResumePickerReadOnly(cwd, undefined, storage);
|
|
7775
9324
|
try {
|
|
7776
9325
|
await recoverOrphanedBackups(sessionDir, storage);
|
|
7777
9326
|
return await collectSessionsFromFiles(storage.listFilesSync(sessionDir, "*.jsonl"), storage);
|
|
@@ -7783,9 +9332,12 @@ export class SessionManager {
|
|
|
7783
9332
|
/**
|
|
7784
9333
|
* List all sessions across all project directories.
|
|
7785
9334
|
*/
|
|
7786
|
-
static async listAll(
|
|
9335
|
+
static async listAll(
|
|
9336
|
+
storage: SessionStorage = new FileSessionStorage(),
|
|
9337
|
+
managedAgentDir?: string,
|
|
9338
|
+
): Promise<SessionInfo[]> {
|
|
7787
9339
|
if (!(storage instanceof FileSessionStorage)) return [];
|
|
7788
|
-
const sessionsRoot =
|
|
9340
|
+
const sessionsRoot = getSessionsDir(managedAgentDir);
|
|
7789
9341
|
try {
|
|
7790
9342
|
const directories = await fs.promises.readdir(sessionsRoot, { withFileTypes: true });
|
|
7791
9343
|
const seedFiles = directories
|
|
@@ -7821,6 +9373,19 @@ export class SessionManager {
|
|
|
7821
9373
|
return [];
|
|
7822
9374
|
}
|
|
7823
9375
|
}
|
|
9376
|
+
/**
|
|
9377
|
+
* Strict inventory bound to this manager's captured session authority. Managed
|
|
9378
|
+
* managers include authorized legacy and current candidates; explicit managers
|
|
9379
|
+
* authorize only their exact directory.
|
|
9380
|
+
*/
|
|
9381
|
+
inventorySessionsStrict(): StrictInventoryResult {
|
|
9382
|
+
return SessionManager.inventorySessionsStrict(this.cwd, {
|
|
9383
|
+
sessionDir: this.destination.kind === "explicit" ? this.destination.directory : undefined,
|
|
9384
|
+
storage: this.storage,
|
|
9385
|
+
destination: this.destination,
|
|
9386
|
+
});
|
|
9387
|
+
}
|
|
9388
|
+
|
|
7824
9389
|
/**
|
|
7825
9390
|
* Strict raw scoped inventory for ACP authorization. Enumerates the scoped
|
|
7826
9391
|
* session directory without suppressing any root/scan/lstat/read/parse/stat/
|
|
@@ -7830,10 +9395,55 @@ export class SessionManager {
|
|
|
7830
9395
|
*/
|
|
7831
9396
|
static inventorySessionsStrict(
|
|
7832
9397
|
cwd: string,
|
|
7833
|
-
options?: { sessionDir?: string; storage?: SessionStorage },
|
|
9398
|
+
options?: { sessionDir?: string; storage?: SessionStorage; destination?: SessionDestination },
|
|
7834
9399
|
): StrictInventoryResult {
|
|
7835
9400
|
const storage = options?.storage ?? new FileSessionStorage();
|
|
7836
|
-
const
|
|
9401
|
+
const destination = options?.destination;
|
|
9402
|
+
const dir =
|
|
9403
|
+
options?.sessionDir ?? destination?.directory ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage);
|
|
9404
|
+
if (destination?.kind === "managed") {
|
|
9405
|
+
if (!trustedSessionDestinations.has(destination) || !(storage instanceof FileSessionStorage)) {
|
|
9406
|
+
return {
|
|
9407
|
+
kind: "failure",
|
|
9408
|
+
failures: [{ kind: "scan", message: "Managed strict inventory authority is unavailable", path: dir }],
|
|
9409
|
+
};
|
|
9410
|
+
}
|
|
9411
|
+
try {
|
|
9412
|
+
const store = managedStoreFromContext(destination.securityContext, destination.directory);
|
|
9413
|
+
assertManagedDirectoryRoot(destination.securityContext.rootAuthority);
|
|
9414
|
+
store.assertBound();
|
|
9415
|
+
const resolved = resolveManagedScope({
|
|
9416
|
+
cwd,
|
|
9417
|
+
agentDir: destination.securityContext.agentDir,
|
|
9418
|
+
sessionsRoot: destination.securityContext.sessionsRoot,
|
|
9419
|
+
});
|
|
9420
|
+
if (resolved.kind === "error")
|
|
9421
|
+
return { kind: "failure", failures: [{ kind: "root", message: resolved.message, path: dir }] };
|
|
9422
|
+
const listing = listManagedCandidates(resolved.scope);
|
|
9423
|
+
if (listing.kind === "error")
|
|
9424
|
+
return { kind: "failure", failures: [{ kind: "scan", message: listing.message, path: dir }] };
|
|
9425
|
+
const failures: StrictInventoryFailure[] = [];
|
|
9426
|
+
const candidates: StrictInventoryCandidate[] = [];
|
|
9427
|
+
for (const managedCandidate of listing.owned) {
|
|
9428
|
+
const candidate = inventoryReadCandidate(
|
|
9429
|
+
storage,
|
|
9430
|
+
managedCandidate.path,
|
|
9431
|
+
cwd,
|
|
9432
|
+
path.dirname(managedCandidate.path),
|
|
9433
|
+
);
|
|
9434
|
+
if ("failures" in candidate) failures.push(...candidate.failures);
|
|
9435
|
+
else candidates.push(candidate.candidate);
|
|
9436
|
+
}
|
|
9437
|
+
assertManagedDirectoryRoot(destination.securityContext.rootAuthority);
|
|
9438
|
+
store.assertBound();
|
|
9439
|
+
return failures.length > 0 ? { kind: "failure", failures } : { kind: "complete", candidates };
|
|
9440
|
+
} catch {
|
|
9441
|
+
return {
|
|
9442
|
+
kind: "failure",
|
|
9443
|
+
failures: [{ kind: "root", message: "Managed strict inventory authority changed", path: dir }],
|
|
9444
|
+
};
|
|
9445
|
+
}
|
|
9446
|
+
}
|
|
7837
9447
|
if (!storage.listFilesStrictSync) {
|
|
7838
9448
|
return {
|
|
7839
9449
|
kind: "failure",
|