@prometheus-ai/agent 0.5.3 → 0.5.8
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 +39 -0
- package/dist/cli.js +25110 -0
- package/dist/types/async/index.d.ts +0 -1
- package/dist/types/async/job-manager.d.ts +33 -0
- package/dist/types/autolearn/controller.d.ts +25 -0
- package/dist/types/autolearn/managed-skills.d.ts +45 -0
- package/dist/types/autoresearch/state.d.ts +1 -1
- package/dist/types/autoresearch/tools/init-experiment.d.ts +1 -1
- package/dist/types/autoresearch/tools/log-experiment.d.ts +1 -1
- package/dist/types/autoresearch/tools/run-experiment.d.ts +1 -1
- package/dist/types/autoresearch/tools/update-notes.d.ts +1 -1
- package/dist/types/autoresearch/types.d.ts +1 -1
- package/dist/types/capability/context-file.d.ts +0 -13
- package/dist/types/capability/mcp.d.ts +1 -0
- package/dist/types/capability/rule-buckets.d.ts +1 -1
- package/dist/types/capability/rule.d.ts +6 -1
- package/dist/types/capability/types.d.ts +0 -4
- package/dist/types/cli/args.d.ts +23 -3
- package/dist/types/cli/bench-cli.d.ts +78 -0
- package/dist/types/cli/claude-trace-cli.d.ts +7 -0
- package/dist/types/cli/dry-balance-cli.d.ts +16 -2
- package/dist/types/cli/gallery-cli.d.ts +43 -0
- package/dist/types/cli/gallery-fixtures/agentic.d.ts +2 -0
- package/dist/types/cli/gallery-fixtures/codeintel.d.ts +3 -0
- package/dist/types/cli/gallery-fixtures/edit.d.ts +3 -0
- package/dist/types/cli/gallery-fixtures/fs.d.ts +2 -0
- package/dist/types/cli/gallery-fixtures/index.d.ts +4 -0
- package/dist/types/cli/gallery-fixtures/interaction.d.ts +3 -0
- package/dist/types/cli/gallery-fixtures/memory.d.ts +2 -0
- package/dist/types/cli/gallery-fixtures/misc.d.ts +3 -0
- package/dist/types/cli/gallery-fixtures/search.d.ts +3 -0
- package/dist/types/cli/gallery-fixtures/shell.d.ts +3 -0
- package/dist/types/cli/gallery-fixtures/types.d.ts +55 -0
- package/dist/types/cli/gallery-fixtures/web.d.ts +2 -0
- package/dist/types/cli/gallery-screenshot.d.ts +35 -0
- package/dist/types/cli/gateway-cli.d.ts +4 -0
- package/dist/types/cli/grievances-cli.d.ts +1 -1
- package/dist/types/cli/list-models.d.ts +6 -14
- package/dist/types/cli/models-cli.d.ts +49 -0
- package/dist/types/cli/session-picker.d.ts +1 -1
- package/dist/types/cli/setup-cli.d.ts +1 -1
- package/dist/types/cli/setup-model-picker.d.ts +14 -0
- package/dist/types/cli/startup-cwd.d.ts +2 -0
- package/dist/types/cli/update-cli.d.ts +13 -40
- package/dist/types/cli/usage-cli.d.ts +81 -0
- package/dist/types/cli-commands.d.ts +12 -0
- package/dist/types/collab/crypto.d.ts +7 -0
- package/dist/types/collab/guest.d.ts +37 -0
- package/dist/types/collab/host.d.ts +29 -0
- package/dist/types/collab/protocol.d.ts +119 -0
- package/dist/types/collab/relay-client.d.ts +22 -0
- package/dist/types/commands/bench.d.ts +29 -0
- package/dist/types/commands/gallery.d.ts +47 -0
- package/dist/types/commands/gateway.d.ts +3 -0
- package/dist/types/commands/install.d.ts +1 -1
- package/dist/types/commands/join.d.ts +12 -0
- package/dist/types/commands/launch.d.ts +8 -4
- package/dist/types/commands/models.d.ts +33 -0
- package/dist/types/commands/read.d.ts +1 -1
- package/dist/types/commands/say.d.ts +24 -0
- package/dist/types/commands/token.d.ts +25 -0
- package/dist/types/commands/usage.d.ts +34 -0
- package/dist/types/commit/agentic/tools/analyze-file.d.ts +1 -1
- package/dist/types/commit/agentic/tools/git-file-diff.d.ts +1 -1
- package/dist/types/commit/agentic/tools/git-hunk.d.ts +1 -1
- package/dist/types/commit/agentic/tools/git-overview.d.ts +1 -1
- package/dist/types/commit/agentic/tools/propose-changelog.d.ts +1 -1
- package/dist/types/commit/agentic/tools/propose-commit.d.ts +1 -1
- package/dist/types/commit/agentic/tools/recent-commits.d.ts +1 -1
- package/dist/types/commit/agentic/tools/schemas.d.ts +1 -1
- package/dist/types/commit/agentic/tools/split-commit.d.ts +1 -1
- package/dist/types/commit/analysis/conventional.d.ts +2 -2
- package/dist/types/commit/analysis/summary.d.ts +2 -2
- package/dist/types/commit/changelog/generate.d.ts +3 -3
- package/dist/types/commit/changelog/index.d.ts +2 -2
- package/dist/types/commit/map-reduce/index.d.ts +3 -3
- package/dist/types/commit/map-reduce/map-phase.d.ts +2 -2
- package/dist/types/commit/map-reduce/reduce-phase.d.ts +2 -2
- package/dist/types/commit/model-selection.d.ts +10 -4
- package/dist/types/commit/shared-llm.d.ts +1 -1
- package/dist/types/config/api-key-resolver.d.ts +43 -0
- package/dist/types/config/append-only-context-mode.d.ts +2 -1
- package/dist/types/config/keybindings.d.ts +12 -7
- package/dist/types/config/model-discovery.d.ts +57 -0
- package/dist/types/config/model-equivalence.d.ts +1 -1
- package/dist/types/config/model-registry.d.ts +86 -222
- package/dist/types/config/model-resolver.d.ts +43 -12
- package/dist/types/config/model-roles.d.ts +29 -0
- package/dist/types/config/models-config-schema.d.ts +536 -43
- package/dist/types/config/models-config.d.ts +391 -0
- package/dist/types/config/settings-schema.d.ts +1211 -324
- package/dist/types/config/settings.d.ts +15 -3
- package/dist/types/dap/config.d.ts +14 -1
- package/dist/types/dap/types.d.ts +10 -0
- package/dist/types/debug/log-viewer.d.ts +1 -1
- package/dist/types/debug/raw-sse.d.ts +1 -1
- package/dist/types/debug/report-bundle.d.ts +3 -0
- package/dist/types/debug/terminal-info.d.ts +0 -1
- package/dist/types/discovery/at-imports.d.ts +15 -0
- package/dist/types/discovery/prometheus-extension-roots.d.ts +7 -7
- package/dist/types/edit/diff.d.ts +3 -2
- package/dist/types/edit/file-snapshot-store.d.ts +18 -0
- package/dist/types/edit/hashline/noop-loop-guard.d.ts +72 -0
- package/dist/types/edit/hashline/params.d.ts +1 -1
- package/dist/types/edit/index.d.ts +0 -1
- package/dist/types/edit/modes/apply-patch.d.ts +1 -1
- package/dist/types/edit/modes/patch.d.ts +1 -1
- package/dist/types/edit/modes/replace.d.ts +1 -1
- package/dist/types/edit/renderer.d.ts +1 -0
- package/dist/types/eval/__tests__/completion-bridge.test.d.ts +1 -0
- package/dist/types/eval/__tests__/helpers-local-roots.test.d.ts +1 -0
- package/dist/types/eval/__tests__/js-context-manager.test.d.ts +1 -0
- package/dist/types/eval/backend.d.ts +7 -2
- package/dist/types/eval/bridge-timeout.d.ts +1 -1
- package/dist/types/eval/completion-bridge.d.ts +25 -0
- package/dist/types/eval/idle-timeout.d.ts +1 -5
- package/dist/types/eval/js/context-manager.d.ts +1 -0
- package/dist/types/eval/js/executor.d.ts +2 -0
- package/dist/types/eval/js/index.d.ts +1 -1
- package/dist/types/eval/js/shared/helpers.d.ts +7 -1
- package/dist/types/eval/js/shared/rewrite-imports.d.ts +6 -6
- package/dist/types/eval/js/shared/runtime.d.ts +6 -1
- package/dist/types/eval/js/worker-protocol.d.ts +6 -0
- package/dist/types/eval/py/__tests__/prelude.test.d.ts +1 -0
- package/dist/types/eval/py/executor.d.ts +12 -0
- package/dist/types/eval/py/index.d.ts +1 -1
- package/dist/types/eval/py/kernel.d.ts +6 -1
- package/dist/types/eval/py/runtime.d.ts +9 -0
- package/dist/types/exa/index.d.ts +1 -19
- package/dist/types/exa/mcp-client.d.ts +10 -3
- package/dist/types/exa/types.d.ts +0 -83
- package/dist/types/exec/bash-executor.d.ts +7 -0
- package/dist/types/export/custom-share.d.ts +1 -2
- package/dist/types/export/html/index.d.ts +39 -0
- package/dist/types/export/html/template-js.d.ts +2 -0
- package/dist/types/export/share.d.ts +61 -0
- package/dist/types/export/ttsr.d.ts +14 -0
- package/dist/types/extensibility/custom-commands/types.d.ts +9 -4
- package/dist/types/extensibility/custom-tools/loader.d.ts +30 -4
- package/dist/types/extensibility/custom-tools/types.d.ts +16 -8
- package/dist/types/extensibility/extensions/index.d.ts +1 -1
- package/dist/types/extensibility/extensions/loader.d.ts +20 -1
- package/dist/types/extensibility/extensions/model-api.d.ts +17 -0
- package/dist/types/extensibility/extensions/runner.d.ts +5 -2
- package/dist/types/extensibility/extensions/types.d.ts +72 -11
- package/dist/types/extensibility/hooks/index.d.ts +2 -1
- package/dist/types/extensibility/hooks/loader.d.ts +1 -1
- package/dist/types/extensibility/hooks/types.d.ts +11 -5
- package/dist/types/extensibility/{legacy-pi-ai-shim.d.ts → legacy-package-ai-shim.d.ts} +2 -2
- package/dist/types/extensibility/plugins/{legacy-pi-compat.d.ts → legacy-package-compat.d.ts} +20 -3
- package/dist/types/extensibility/plugins/loader.d.ts +11 -0
- package/dist/types/extensibility/plugins/marketplace-auto-update.d.ts +8 -0
- package/dist/types/extensibility/plugins/types.d.ts +2 -2
- package/dist/types/extensibility/shared-events.d.ts +3 -3
- package/dist/types/extensibility/skills.d.ts +10 -0
- package/dist/types/extensibility/slash-commands.d.ts +1 -11
- package/dist/types/gateway/adapters/telegram/access.d.ts +4 -1
- package/dist/types/gateway/adapters/telegram/setup-api.d.ts +1 -1
- package/dist/types/gateway/adapters/telegram/webhook.d.ts +1 -1
- package/dist/types/gateway/types.d.ts +1 -1
- package/dist/types/goals/guided-setup.d.ts +18 -0
- package/dist/types/goals/state.d.ts +1 -1
- package/dist/types/goals/tools/goal-tool.d.ts +1 -1
- package/dist/types/hindsight/mental-models.d.ts +17 -8
- package/dist/types/hindsight/transcript.d.ts +1 -1
- package/dist/types/index.d.ts +5 -0
- package/dist/types/internal-urls/artifact-protocol.d.ts +2 -2
- package/dist/types/internal-urls/history-protocol.d.ts +14 -0
- package/dist/types/internal-urls/index.d.ts +1 -0
- package/dist/types/internal-urls/local-protocol.d.ts +14 -2
- package/dist/types/internal-urls/types.d.ts +1 -1
- package/dist/types/irc/bus.d.ts +79 -0
- package/dist/types/lib/xai-http.d.ts +1 -1
- package/dist/types/lsp/client.d.ts +10 -0
- package/dist/types/lsp/config.d.ts +2 -2
- package/dist/types/lsp/edits.d.ts +9 -0
- package/dist/types/lsp/format-options.d.ts +32 -0
- package/dist/types/lsp/index.d.ts +2 -7
- package/dist/types/lsp/types.d.ts +13 -1
- package/dist/types/lsp/utils.d.ts +6 -2
- package/dist/types/main.d.ts +23 -8
- package/dist/types/mcp/json-rpc.d.ts +5 -0
- package/dist/types/mcp/manager.d.ts +8 -0
- package/dist/types/mcp/oauth-discovery.d.ts +6 -1
- package/dist/types/mcp/oauth-flow.d.ts +13 -3
- package/dist/types/mcp/startup-events.d.ts +11 -0
- package/dist/types/mcp/tool-bridge.d.ts +2 -0
- package/dist/types/mcp/transports/stdio.d.ts +13 -0
- package/dist/types/mcp/types.d.ts +2 -0
- package/dist/types/memories/index.d.ts +7 -15
- package/dist/types/memories/storage.d.ts +0 -10
- package/dist/types/memory-backend/index.d.ts +3 -1
- package/dist/types/memory-backend/local-backend.d.ts +4 -3
- package/dist/types/memory-backend/resolve.d.ts +2 -2
- package/dist/types/memory-backend/runtime.d.ts +4 -0
- package/dist/types/memory-backend/types.d.ts +67 -2
- package/dist/types/mnemopi/config.d.ts +31 -1
- package/dist/types/mnemopi/state.d.ts +40 -2
- package/dist/types/modes/acp/acp-agent.d.ts +1 -2
- package/dist/types/modes/components/agent-dashboard.d.ts +17 -1
- package/dist/types/modes/components/agent-hub.d.ts +82 -0
- package/dist/types/modes/components/assistant-message.d.ts +5 -12
- package/dist/types/modes/components/bash-execution.d.ts +1 -1
- package/dist/types/modes/components/chat-block.d.ts +64 -0
- package/dist/types/modes/components/collab-prompt-message.d.ts +10 -0
- package/dist/types/modes/components/compaction-summary-message.d.ts +25 -5
- package/dist/types/modes/components/copy-selector.d.ts +1 -1
- package/dist/types/modes/components/custom-editor.d.ts +49 -2
- package/dist/types/modes/components/custom-editor.test.d.ts +1 -0
- package/dist/types/modes/components/dynamic-border.d.ts +1 -1
- package/dist/types/modes/components/extensions/extension-dashboard.d.ts +1 -1
- package/dist/types/modes/components/extensions/extension-list.d.ts +1 -1
- package/dist/types/modes/components/extensions/inspector-panel.d.ts +1 -1
- package/dist/types/modes/components/footer.d.ts +1 -1
- package/dist/types/modes/components/hook-editor.d.ts +5 -0
- package/dist/types/modes/components/hook-input.d.ts +4 -0
- package/dist/types/modes/components/hook-selector.d.ts +5 -7
- package/dist/types/modes/components/index.d.ts +1 -0
- package/dist/types/modes/components/late-diagnostics-message.d.ts +20 -0
- package/dist/types/modes/components/logout-account-selector.d.ts +8 -0
- package/dist/types/modes/components/mcp-add-wizard.d.ts +2 -1
- package/dist/types/modes/components/model-selector.d.ts +1 -1
- package/dist/types/modes/components/oauth-selector.d.ts +10 -1
- package/dist/types/modes/components/overlay-box.d.ts +17 -0
- package/dist/types/modes/components/plan-review-overlay.d.ts +61 -0
- package/dist/types/modes/components/plan-toc.d.ts +41 -0
- package/dist/types/modes/components/read-tool-group.d.ts +8 -0
- package/dist/types/modes/components/reset-usage-selector.d.ts +12 -0
- package/dist/types/modes/components/segment-track.d.ts +11 -6
- package/dist/types/modes/components/session-selector.d.ts +18 -9
- package/dist/types/modes/components/settings-defs.d.ts +9 -2
- package/dist/types/modes/components/settings-selector.d.ts +17 -4
- package/dist/types/modes/components/snapcompact-shape-preview.d.ts +31 -0
- package/dist/types/modes/components/status-line/component.d.ts +61 -0
- package/dist/types/modes/components/status-line/index.d.ts +1 -0
- package/dist/types/modes/components/status-line/types.d.ts +47 -3
- package/dist/types/modes/components/tiny-title-download-progress.d.ts +1 -1
- package/dist/types/modes/components/tool-execution.d.ts +49 -2
- package/dist/types/modes/components/transcript-container.d.ts +76 -26
- package/dist/types/modes/components/tree-selector.d.ts +2 -2
- package/dist/types/modes/components/ttsr-notification.d.ts +5 -1
- package/dist/types/modes/components/usage-row.d.ts +3 -0
- package/dist/types/modes/components/user-message-selector.d.ts +1 -1
- package/dist/types/modes/components/user-message.d.ts +2 -1
- package/dist/types/modes/components/visual-truncate.d.ts +1 -1
- package/dist/types/modes/components/welcome.d.ts +12 -2
- package/dist/types/modes/controllers/command-controller.d.ts +3 -2
- package/dist/types/modes/controllers/event-controller.d.ts +7 -1
- package/dist/types/modes/controllers/extension-ui-controller.d.ts +0 -1
- package/dist/types/modes/controllers/input-controller.d.ts +25 -3
- package/dist/types/modes/controllers/mcp-command-controller.d.ts +8 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +5 -2
- package/dist/types/modes/controllers/session-focus-controller.d.ts +31 -0
- package/dist/types/modes/controllers/streaming-reveal.d.ts +22 -0
- package/dist/types/modes/controllers/tan-command-controller.d.ts +6 -0
- package/dist/types/modes/controllers/tool-args-reveal.d.ts +43 -0
- package/dist/types/modes/gradient-highlight.d.ts +9 -4
- package/dist/types/modes/image-references.d.ts +14 -3
- package/dist/types/modes/index.d.ts +8 -7
- package/dist/types/modes/interactive-mode.d.ts +92 -16
- package/dist/types/modes/magic-keywords.d.ts +14 -2
- package/dist/types/modes/markdown-prose.d.ts +1 -1
- package/dist/types/modes/oauth-manual-input.d.ts +7 -0
- package/dist/types/modes/rpc/rpc-client.d.ts +48 -2
- package/dist/types/modes/rpc/rpc-mode.d.ts +67 -2
- package/dist/types/modes/rpc/rpc-subagents.d.ts +24 -0
- package/dist/types/modes/rpc/rpc-types.d.ts +113 -1
- package/dist/types/modes/runtime-init.d.ts +4 -0
- package/dist/types/modes/session-observer-registry.d.ts +9 -0
- package/dist/types/modes/setup-version.d.ts +11 -0
- package/dist/types/modes/setup-wizard/index.d.ts +7 -2
- package/dist/types/modes/setup-wizard/lazy.d.ts +2 -0
- package/dist/types/modes/setup-wizard/scenes/sign-in.d.ts +4 -1
- package/dist/types/modes/setup-wizard/scenes/types.d.ts +11 -2
- package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +6 -2
- package/dist/types/modes/setup-wizard/wizard-overlay.d.ts +1 -1
- package/dist/types/modes/theme/theme.d.ts +42 -7
- package/dist/types/modes/types.d.ts +62 -13
- package/dist/types/modes/utils/context-usage.d.ts +6 -1
- package/dist/types/modes/utils/copy-targets.d.ts +21 -1
- package/dist/types/modes/utils/ui-helpers.d.ts +4 -4
- package/dist/types/modes/workflow.d.ts +3 -3
- package/dist/types/plan-mode/approved-plan.d.ts +27 -8
- package/dist/types/plan-mode/plan-protection.d.ts +4 -4
- package/dist/types/registry/agent-lifecycle.d.ts +51 -0
- package/dist/types/registry/agent-registry.d.ts +33 -5
- package/dist/types/sdk.d.ts +46 -4
- package/dist/types/secrets/index.d.ts +1 -1
- package/dist/types/secrets/obfuscator.d.ts +9 -3
- package/dist/types/session/agent-session.d.ts +136 -66
- package/dist/types/session/agent-storage.d.ts +2 -1
- package/dist/types/session/auth-broker-config.d.ts +4 -0
- package/dist/types/session/auth-storage.d.ts +1 -1
- package/dist/types/session/codex-auto-reset.d.ts +111 -0
- package/dist/types/session/indexed-session-storage.d.ts +3 -3
- package/dist/types/session/messages.d.ts +26 -15
- package/dist/types/session/session-context.d.ts +39 -0
- package/dist/types/session/session-entries.d.ts +159 -0
- package/dist/types/session/session-history-format.d.ts +12 -0
- package/dist/types/session/session-listing.d.ts +69 -0
- package/dist/types/session/session-loader.d.ts +16 -0
- package/dist/types/session/session-manager.d.ts +107 -440
- package/dist/types/session/session-migrations.d.ts +12 -0
- package/dist/types/session/session-paths.d.ts +25 -0
- package/dist/types/session/session-persistence.d.ts +8 -0
- package/dist/types/session/session-storage.d.ts +11 -7
- package/dist/types/session/snapcompact-inline.d.ts +145 -0
- package/dist/types/session/snapcompact-savings-journal.d.ts +46 -0
- package/dist/types/session/streaming-output.d.ts +46 -0
- package/dist/types/session/tool-choice-queue.d.ts +6 -6
- package/dist/types/session/yield-queue.d.ts +10 -1
- package/dist/types/slash-commands/acp-builtins.d.ts +16 -0
- package/dist/types/slash-commands/available-commands.d.ts +34 -0
- package/dist/types/slash-commands/builtin-registry.d.ts +10 -0
- package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +14 -0
- package/dist/types/slash-commands/helpers/logout.d.ts +15 -0
- package/dist/types/slash-commands/helpers/reset-usage.d.ts +27 -0
- package/dist/types/slash-commands/helpers/stats-dashboard.d.ts +13 -0
- package/dist/types/slash-commands/types.d.ts +5 -9
- package/dist/types/ssh/connection-manager.d.ts +8 -0
- package/dist/types/stt/asr-client.d.ts +90 -0
- package/dist/types/stt/asr-protocol.d.ts +97 -0
- package/dist/types/stt/asr-worker.d.ts +2 -0
- package/dist/types/stt/downloader.d.ts +38 -0
- package/dist/types/stt/endpointer.d.ts +59 -0
- package/dist/types/stt/index.d.ts +5 -1
- package/dist/types/stt/models.d.ts +120 -0
- package/dist/types/stt/recorder.d.ts +17 -0
- package/dist/types/stt/stt-controller.d.ts +6 -0
- package/dist/types/stt/transcriber.d.ts +5 -7
- package/dist/types/stt/wav.d.ts +29 -0
- package/dist/types/system-prompt.d.ts +9 -1
- package/dist/types/task/commands.d.ts +1 -1
- package/dist/types/task/discovery.d.ts +1 -2
- package/dist/types/task/executor.d.ts +61 -2
- package/dist/types/task/index.d.ts +37 -6
- package/dist/types/task/output-manager.d.ts +0 -7
- package/dist/types/task/parallel.d.ts +2 -2
- package/dist/types/task/prometheus-command.d.ts +2 -2
- package/dist/types/task/render.d.ts +20 -7
- package/dist/types/task/repair-args.d.ts +8 -7
- package/dist/types/task/types.d.ts +109 -52
- package/dist/types/task/worktree.d.ts +2 -0
- package/dist/types/telemetry-export.d.ts +2 -2
- package/dist/types/thinking.d.ts +4 -0
- package/dist/types/tiny/models.d.ts +1 -1
- package/dist/types/tiny/title-client.d.ts +12 -1
- package/dist/types/tiny/title-protocol.d.ts +1 -0
- package/dist/types/tools/archive-reader.d.ts +5 -0
- package/dist/types/tools/ask.d.ts +6 -1
- package/dist/types/tools/ast-edit.d.ts +4 -1
- package/dist/types/tools/ast-grep.d.ts +4 -1
- package/dist/types/tools/bash.d.ts +5 -2
- package/dist/types/tools/browser/attach.d.ts +4 -4
- package/dist/types/tools/browser/cmux/cmux-tab.d.ts +202 -0
- package/dist/types/tools/browser/cmux/rpc.d.ts +70 -0
- package/dist/types/tools/browser/cmux/socket-client.d.ts +19 -0
- package/dist/types/tools/browser/registry.d.ts +17 -3
- package/dist/types/tools/browser/render.d.ts +2 -0
- package/dist/types/tools/browser/tab-protocol.d.ts +2 -0
- package/dist/types/tools/browser/tab-supervisor.d.ts +16 -4
- package/dist/types/tools/browser/tab-worker.d.ts +18 -1
- package/dist/types/tools/browser.d.ts +3 -1
- package/dist/types/tools/checkpoint.d.ts +1 -1
- package/dist/types/tools/conflict-detect.d.ts +16 -0
- package/dist/types/tools/debug.d.ts +1 -1
- package/dist/types/tools/eval-render.d.ts +1 -8
- package/dist/types/tools/eval.d.ts +9 -1
- package/dist/types/tools/fetch.d.ts +17 -8
- package/dist/types/tools/find.d.ts +1 -8
- package/dist/types/tools/gh-cache-invalidation.d.ts +6 -0
- package/dist/types/tools/gh.d.ts +4 -1
- package/dist/types/tools/github-cache.d.ts +19 -0
- package/dist/types/tools/grouped-file-output.d.ts +46 -12
- package/dist/types/tools/image-gen.d.ts +1 -1
- package/dist/types/tools/index.d.ts +89 -8
- package/dist/types/tools/inspect-image.d.ts +1 -1
- package/dist/types/tools/irc.d.ts +79 -39
- package/dist/types/tools/job.d.ts +8 -2
- package/dist/types/tools/learn.d.ts +51 -0
- package/dist/types/tools/manage-skill.d.ts +40 -0
- package/dist/types/tools/memory-edit.d.ts +2 -2
- package/dist/types/tools/memory-recall.d.ts +1 -1
- package/dist/types/tools/memory-reflect.d.ts +1 -1
- package/dist/types/tools/memory-render.d.ts +4 -1
- package/dist/types/tools/memory-retain.d.ts +1 -1
- package/dist/types/tools/path-utils.d.ts +17 -5
- package/dist/types/tools/plan-mode-guard.d.ts +18 -9
- package/dist/types/tools/read.d.ts +3 -2
- package/dist/types/tools/render-mermaid.d.ts +1 -1
- package/dist/types/tools/render-utils.d.ts +47 -27
- package/dist/types/tools/renderers.d.ts +10 -2
- package/dist/types/tools/report-tool-issue.d.ts +6 -1
- package/dist/types/tools/resolve.d.ts +1 -1
- package/dist/types/tools/review.d.ts +1 -1
- package/dist/types/tools/search-tool-bm25.d.ts +1 -1
- package/dist/types/tools/search.d.ts +7 -3
- package/dist/types/tools/sqlite-reader.d.ts +4 -0
- package/dist/types/tools/ssh.d.ts +2 -1
- package/dist/types/tools/todo.d.ts +7 -15
- package/dist/types/tools/tool-result.d.ts +2 -0
- package/dist/types/tools/tool-timeouts.d.ts +1 -1
- package/dist/types/tools/tts.d.ts +26 -1
- package/dist/types/tools/write.d.ts +6 -3
- package/dist/types/tools/yield.d.ts +8 -0
- package/dist/types/tts/downloader.d.ts +20 -0
- package/dist/types/tts/index.d.ts +8 -0
- package/dist/types/tts/models.d.ts +82 -0
- package/dist/types/tts/player.d.ts +32 -0
- package/dist/types/tts/runtime.d.ts +6 -0
- package/dist/types/tts/streaming-player.d.ts +41 -0
- package/dist/types/tts/tts-client.d.ts +93 -0
- package/dist/types/tts/tts-protocol.d.ts +95 -0
- package/dist/types/tts/tts-worker.d.ts +2 -0
- package/dist/types/tts/vocalizer.d.ts +41 -0
- package/dist/types/tts/wav.d.ts +8 -0
- package/dist/types/tui/code-cell.d.ts +0 -2
- package/dist/types/tui/hyperlink.d.ts +13 -7
- package/dist/types/tui/output-block.d.ts +16 -22
- package/dist/types/tui/status-line.d.ts +3 -0
- package/dist/types/utils/block-context.d.ts +35 -0
- package/dist/types/utils/changelog.d.ts +8 -0
- package/dist/types/utils/clipboard.d.ts +4 -3
- package/dist/types/utils/enhanced-paste.d.ts +20 -0
- package/dist/types/utils/file-mentions.d.ts +7 -0
- package/dist/types/utils/git.d.ts +22 -3
- package/dist/types/utils/image-loading.d.ts +30 -1
- package/dist/types/utils/session-color.d.ts +15 -3
- package/dist/types/utils/thinking-display.d.ts +17 -0
- package/dist/types/utils/title-generator.d.ts +3 -2
- package/dist/types/utils/tool-choice.d.ts +8 -0
- package/dist/types/utils/tools-manager.d.ts +2 -1
- package/dist/types/web/kagi.d.ts +2 -2
- package/dist/types/web/parallel.d.ts +3 -0
- package/dist/types/web/scrapers/github.d.ts +22 -0
- package/dist/types/web/scrapers/readthedocs.d.ts +3 -0
- package/dist/types/web/scrapers/types.d.ts +12 -0
- package/dist/types/web/search/index.d.ts +1 -1
- package/dist/types/web/search/providers/anthropic.d.ts +2 -1
- package/dist/types/web/search/providers/base.d.ts +2 -1
- package/dist/types/web/search/providers/brave.d.ts +2 -1
- package/dist/types/web/search/providers/codex.d.ts +2 -1
- package/dist/types/web/search/providers/exa.d.ts +2 -1
- package/dist/types/web/search/providers/gemini.d.ts +10 -6
- package/dist/types/web/search/providers/jina.d.ts +7 -2
- package/dist/types/web/search/providers/kagi.d.ts +7 -2
- package/dist/types/web/search/providers/kimi.d.ts +7 -2
- package/dist/types/web/search/providers/parallel.d.ts +2 -1
- package/dist/types/web/search/providers/perplexity.d.ts +10 -2
- package/dist/types/web/search/providers/searxng.d.ts +2 -1
- package/dist/types/web/search/providers/synthetic.d.ts +7 -3
- package/dist/types/web/search/providers/tavily.d.ts +2 -1
- package/dist/types/web/search/providers/zai.d.ts +2 -1
- package/dist/types/web/search/types.d.ts +1 -1
- package/examples/extensions/api-demo.ts +2 -2
- package/package.json +41 -15
- package/scripts/bench-guard.ts +71 -0
- package/scripts/build-binary.ts +24 -25
- package/scripts/bundle-dist.ts +97 -0
- package/scripts/generate-share-viewer.ts +34 -0
- package/scripts/prometheus +42 -0
- package/scripts/prometheus.ts +20 -0
- package/src/async/index.ts +0 -1
- package/src/async/job-manager.ts +106 -3
- package/src/auto-thinking/classifier.ts +2 -1
- package/src/autolearn/controller.ts +139 -0
- package/src/autolearn/managed-skills.ts +257 -0
- package/src/autoresearch/dashboard.ts +1 -1
- package/src/autoresearch/prompt-setup.md +6 -6
- package/src/autoresearch/prompt.md +6 -6
- package/src/autoresearch/state.ts +1 -1
- package/src/autoresearch/storage.ts +2 -1
- package/src/autoresearch/tools/init-experiment.ts +1 -1
- package/src/autoresearch/tools/log-experiment.ts +1 -1
- package/src/autoresearch/tools/run-experiment.ts +1 -1
- package/src/autoresearch/tools/update-notes.ts +1 -1
- package/src/autoresearch/types.ts +1 -1
- package/src/capability/context-file.ts +0 -14
- package/src/capability/fs.ts +10 -0
- package/src/capability/index.ts +1 -6
- package/src/capability/mcp.ts +1 -0
- package/src/capability/rule-buckets.ts +4 -2
- package/src/capability/rule.ts +10 -1
- package/src/capability/types.ts +0 -4
- package/src/cli/args.ts +66 -13
- package/src/cli/auth-broker-cli.ts +6 -7
- package/src/cli/auth-gateway-cli.ts +8 -9
- package/src/cli/bench-cli.ts +437 -0
- package/src/cli/claude-trace-cli.ts +28 -50
- package/src/cli/completion-gen.ts +28 -28
- package/src/cli/dry-balance-cli.ts +56 -23
- package/src/cli/gallery-cli.ts +231 -0
- package/src/cli/gallery-fixtures/agentic.ts +407 -0
- package/src/cli/gallery-fixtures/codeintel.ts +187 -0
- package/src/cli/gallery-fixtures/edit.ts +194 -0
- package/src/cli/gallery-fixtures/fs.ts +220 -0
- package/src/cli/gallery-fixtures/index.ts +40 -0
- package/src/cli/gallery-fixtures/interaction.ts +49 -0
- package/src/cli/gallery-fixtures/memory.ts +81 -0
- package/src/cli/gallery-fixtures/misc.ts +250 -0
- package/src/cli/gallery-fixtures/search.ts +213 -0
- package/src/cli/gallery-fixtures/shell.ts +167 -0
- package/src/cli/gallery-fixtures/types.ts +57 -0
- package/src/cli/gallery-fixtures/web.ts +158 -0
- package/src/cli/gallery-screenshot.ts +279 -0
- package/src/cli/gateway-cli.ts +32 -2
- package/src/cli/grievances-cli.ts +1 -1
- package/src/cli/list-models.ts +16 -174
- package/src/cli/models-cli.ts +429 -0
- package/src/cli/session-picker.ts +2 -1
- package/src/cli/setup-cli.ts +148 -47
- package/src/cli/setup-model-picker.ts +43 -0
- package/src/cli/startup-cwd.ts +68 -0
- package/src/cli/update-cli.ts +144 -272
- package/src/cli/usage-cli.ts +774 -0
- package/src/cli-commands.ts +36 -0
- package/src/cli.ts +141 -32
- package/src/collab/crypto.ts +63 -0
- package/src/collab/guest.ts +451 -0
- package/src/collab/host.ts +565 -0
- package/src/collab/protocol.ts +241 -0
- package/src/collab/relay-client.ts +216 -0
- package/src/commands/bench.ts +42 -0
- package/src/commands/complete.ts +1 -1
- package/src/commands/gallery.ts +52 -0
- package/src/commands/gateway.ts +4 -0
- package/src/commands/install.ts +1 -1
- package/src/commands/join.ts +39 -0
- package/src/commands/launch.ts +8 -4
- package/src/commands/models.ts +61 -0
- package/src/commands/read.ts +6 -3
- package/src/commands/say.ts +102 -0
- package/src/commands/setup.ts +1 -1
- package/src/commands/token.ts +89 -0
- package/src/commands/usage.ts +43 -0
- package/src/commit/agentic/agent.ts +2 -1
- package/src/commit/agentic/tools/analyze-file.ts +42 -20
- package/src/commit/agentic/tools/git-file-diff.ts +1 -1
- package/src/commit/agentic/tools/git-hunk.ts +1 -1
- package/src/commit/agentic/tools/git-overview.ts +1 -1
- package/src/commit/agentic/tools/propose-changelog.ts +1 -1
- package/src/commit/agentic/tools/propose-commit.ts +1 -1
- package/src/commit/agentic/tools/recent-commits.ts +1 -1
- package/src/commit/agentic/tools/schemas.ts +1 -1
- package/src/commit/agentic/tools/split-commit.ts +9 -2
- package/src/commit/analysis/conventional.ts +2 -2
- package/src/commit/analysis/summary.ts +3 -3
- package/src/commit/changelog/generate.ts +3 -3
- package/src/commit/changelog/index.ts +2 -2
- package/src/commit/map-reduce/index.ts +3 -3
- package/src/commit/map-reduce/map-phase.ts +2 -2
- package/src/commit/map-reduce/reduce-phase.ts +2 -2
- package/src/commit/model-selection.ts +35 -12
- package/src/commit/pipeline.ts +4 -4
- package/src/commit/shared-llm.ts +1 -1
- package/src/config/api-key-resolver.ts +67 -0
- package/src/config/append-only-context-mode.ts +6 -12
- package/src/config/keybindings.ts +9 -4
- package/src/config/mcp-schema.json +4 -0
- package/src/config/model-discovery.ts +574 -0
- package/src/config/model-equivalence.ts +5 -4
- package/src/config/model-registry.ts +659 -1093
- package/src/config/model-resolver.ts +374 -174
- package/src/config/model-roles.ts +88 -0
- package/src/config/models-config-schema.ts +61 -9
- package/src/config/models-config.ts +130 -0
- package/src/config/settings-schema.ts +1445 -382
- package/src/config/settings.ts +261 -69
- package/src/dap/client.ts +138 -53
- package/src/dap/config.ts +41 -2
- package/src/dap/defaults.json +1 -0
- package/src/dap/session.ts +263 -161
- package/src/dap/types.ts +10 -0
- package/src/debug/index.ts +50 -60
- package/src/debug/log-viewer.ts +1 -1
- package/src/debug/protocol-probe.ts +1 -1
- package/src/debug/raw-sse-buffer.ts +7 -4
- package/src/debug/raw-sse.ts +1 -1
- package/src/debug/report-bundle.ts +9 -0
- package/src/debug/terminal-info.ts +0 -3
- package/src/discovery/agents-md.ts +25 -21
- package/src/discovery/agents.ts +9 -15
- package/src/discovery/at-imports.ts +273 -0
- package/src/discovery/builtin-rules/index.ts +4 -0
- package/src/discovery/builtin-rules/ts-no-test-timers.md +55 -0
- package/src/discovery/builtin-rules/ts-redundant-clear-guard.md +75 -0
- package/src/discovery/builtin.ts +45 -23
- package/src/discovery/claude-plugins.ts +44 -5
- package/src/discovery/helpers.ts +50 -9
- package/src/discovery/prometheus-extension-roots.ts +10 -10
- package/src/discovery/prometheus-plugins.ts +10 -10
- package/src/edit/diff.ts +191 -4
- package/src/edit/file-snapshot-store.ts +34 -1
- package/src/edit/hashline/block-resolver.ts +20 -1
- package/src/edit/hashline/diff.ts +123 -2
- package/src/edit/hashline/execute.ts +60 -4
- package/src/edit/hashline/filesystem.ts +2 -1
- package/src/edit/hashline/noop-loop-guard.ts +99 -0
- package/src/edit/hashline/params.ts +1 -1
- package/src/edit/index.ts +47 -18
- package/src/edit/modes/apply-patch.ts +1 -1
- package/src/edit/modes/patch.ts +59 -3
- package/src/edit/modes/replace.ts +58 -24
- package/src/edit/notebook.ts +22 -2
- package/src/edit/renderer.ts +315 -151
- package/src/eval/__tests__/agent-bridge.test.ts +105 -39
- package/src/eval/__tests__/budget-bridge.test.ts +1 -1
- package/src/eval/__tests__/completion-bridge.test.ts +412 -0
- package/src/eval/__tests__/helpers-local-roots.test.ts +58 -0
- package/src/eval/__tests__/js-context-manager.test.ts +241 -0
- package/src/eval/__tests__/llm-bridge.test.ts +6 -4
- package/src/eval/__tests__/shared-executors.test.ts +34 -92
- package/src/eval/agent-bridge.ts +39 -23
- package/src/eval/backend.ts +15 -2
- package/src/eval/bridge-timeout.ts +1 -1
- package/src/eval/completion-bridge.ts +203 -0
- package/src/eval/idle-timeout.ts +3 -10
- package/src/eval/js/context-manager.ts +108 -31
- package/src/eval/js/executor.ts +9 -2
- package/src/eval/js/index.ts +7 -3
- package/src/eval/js/shared/helpers.ts +59 -13
- package/src/eval/js/shared/local-module-loader.ts +2 -2
- package/src/eval/js/shared/prelude.txt +167 -30
- package/src/eval/js/shared/rewrite-imports.ts +58 -34
- package/src/eval/js/shared/runtime.ts +24 -16
- package/src/eval/js/tool-bridge.ts +4 -0
- package/src/eval/js/worker-core.ts +1 -0
- package/src/eval/js/worker-entry.ts +6 -0
- package/src/eval/js/worker-protocol.ts +6 -0
- package/src/eval/llm-bridge.ts +2 -1
- package/src/eval/py/__tests__/prelude.test.ts +19 -0
- package/src/eval/py/executor.ts +70 -26
- package/src/eval/py/index.ts +13 -4
- package/src/eval/py/kernel.ts +48 -9
- package/src/eval/py/prelude.py +73 -24
- package/src/eval/py/runner.py +133 -28
- package/src/eval/py/runtime.ts +38 -1
- package/src/exa/index.ts +1 -26
- package/src/exa/mcp-client.ts +10 -10
- package/src/exa/types.ts +0 -97
- package/src/exec/bash-executor.ts +104 -7
- package/src/export/custom-share.ts +1 -1
- package/src/export/html/index.ts +119 -17
- package/src/export/html/share-loader.js +102 -0
- package/src/export/html/template-js.ts +6 -0
- package/src/export/html/template.css +745 -459
- package/src/export/html/template.css.d.ts +2 -0
- package/src/export/html/template.html +6 -3
- package/src/export/html/template.js +277 -891
- package/src/export/html/tool-views.generated.d.ts +2 -0
- package/src/export/html/tool-views.generated.js +38 -0
- package/src/export/share.ts +269 -0
- package/src/export/ttsr.ts +122 -1
- package/src/extensibility/custom-commands/loader.ts +7 -4
- package/src/extensibility/custom-commands/types.ts +9 -4
- package/src/extensibility/custom-tools/loader.ts +51 -23
- package/src/extensibility/custom-tools/types.ts +16 -8
- package/src/extensibility/extensions/get-commands-handler.ts +2 -1
- package/src/extensibility/extensions/index.ts +1 -0
- package/src/extensibility/extensions/loader.ts +70 -20
- package/src/extensibility/extensions/model-api.ts +41 -0
- package/src/extensibility/extensions/runner.ts +12 -2
- package/src/extensibility/extensions/types.ts +83 -11
- package/src/extensibility/extensions/wrapper.ts +41 -5
- package/src/extensibility/hooks/index.ts +2 -1
- package/src/extensibility/hooks/loader.ts +6 -3
- package/src/extensibility/hooks/types.ts +11 -5
- package/src/extensibility/{legacy-pi-ai-shim.ts → legacy-package-ai-shim.ts} +2 -2
- package/src/extensibility/plugins/doctor.ts +1 -2
- package/src/extensibility/plugins/installer.ts +2 -2
- package/src/extensibility/plugins/{legacy-pi-compat.ts → legacy-package-compat.ts} +165 -77
- package/src/extensibility/plugins/loader.ts +34 -23
- package/src/extensibility/plugins/manager.ts +226 -95
- package/src/extensibility/plugins/marketplace-auto-update.ts +49 -0
- package/src/extensibility/plugins/types.ts +3 -3
- package/src/extensibility/shared-events.ts +3 -3
- package/src/extensibility/skills.ts +113 -9
- package/src/extensibility/slash-commands.ts +1 -97
- package/src/gateway/adapters/telegram/access.ts +39 -4
- package/src/gateway/adapters/telegram/normalize.ts +16 -1
- package/src/gateway/adapters/telegram/setup-api.ts +7 -1
- package/src/gateway/adapters/telegram/webhook.ts +26 -5
- package/src/gateway/context.ts +9 -1
- package/src/gateway/types.ts +1 -1
- package/src/goals/guided-setup.ts +133 -0
- package/src/goals/state.ts +1 -1
- package/src/goals/tools/goal-tool.ts +38 -28
- package/src/hindsight/bank.ts +17 -2
- package/src/hindsight/client.ts +27 -2
- package/src/hindsight/mental-models.ts +59 -12
- package/src/hindsight/state.ts +12 -3
- package/src/hindsight/transcript.ts +1 -1
- package/src/index.ts +5 -0
- package/src/internal-urls/artifact-protocol.ts +11 -2
- package/src/internal-urls/docs-index.generated.ts +11 -9
- package/src/internal-urls/history-protocol.ts +113 -0
- package/src/internal-urls/index.ts +1 -0
- package/src/internal-urls/issue-pr-protocol.ts +22 -9
- package/src/internal-urls/local-protocol.ts +42 -7
- package/src/internal-urls/memory-protocol.ts +4 -31
- package/src/internal-urls/router.ts +3 -1
- package/src/internal-urls/types.ts +1 -1
- package/src/irc/bus.ts +303 -0
- package/src/lib/xai-http.ts +3 -3
- package/src/lsp/client.ts +245 -104
- package/src/lsp/clients/biome-client.ts +101 -39
- package/src/lsp/clients/lsp-linter-client.ts +2 -10
- package/src/lsp/config.ts +15 -5
- package/src/lsp/defaults.json +6 -0
- package/src/lsp/edits.ts +143 -95
- package/src/lsp/format-options.ts +119 -0
- package/src/lsp/index.ts +233 -93
- package/src/lsp/render.ts +11 -35
- package/src/lsp/types.ts +13 -1
- package/src/lsp/utils.ts +31 -12
- package/src/main.ts +396 -216
- package/src/mcp/config-writer.ts +7 -3
- package/src/mcp/json-rpc.ts +35 -5
- package/src/mcp/manager.ts +31 -16
- package/src/mcp/oauth-discovery.ts +34 -4
- package/src/mcp/oauth-flow.ts +61 -8
- package/src/mcp/render.ts +7 -1
- package/src/mcp/startup-events.ts +21 -0
- package/src/mcp/tool-bridge.ts +2 -0
- package/src/mcp/transports/stdio.ts +224 -4
- package/src/mcp/types.ts +2 -0
- package/src/memories/index.ts +174 -1128
- package/src/memories/storage.ts +2 -41
- package/src/memory-backend/index.ts +14 -1
- package/src/memory-backend/local-backend.ts +18 -3
- package/src/memory-backend/off-backend.ts +9 -0
- package/src/memory-backend/resolve.ts +4 -6
- package/src/memory-backend/runtime.ts +66 -0
- package/src/memory-backend/types.ts +82 -2
- package/src/mnemopi/backend.ts +220 -28
- package/src/mnemopi/config.ts +138 -33
- package/src/mnemopi/state.ts +91 -11
- package/src/modes/acp/acp-agent.ts +149 -142
- package/src/modes/acp/acp-event-mapper.ts +5 -1
- package/src/modes/components/agent-dashboard.ts +17 -11
- package/src/modes/components/agent-hub.ts +1346 -0
- package/src/modes/components/assistant-message.ts +190 -80
- package/src/modes/components/bash-execution.ts +1 -1
- package/src/modes/components/btw-panel.ts +5 -1
- package/src/modes/components/chat-block.ts +111 -0
- package/src/modes/components/collab-prompt-message.ts +30 -0
- package/src/modes/components/compaction-summary-message.ts +168 -33
- package/src/modes/components/copy-selector.ts +2 -45
- package/src/modes/components/custom-editor.test.ts +96 -0
- package/src/modes/components/custom-editor.ts +405 -118
- package/src/modes/components/custom-message.ts +1 -3
- package/src/modes/components/diff.ts +13 -2
- package/src/modes/components/dynamic-border.ts +12 -3
- package/src/modes/components/execution-shared.ts +1 -2
- package/src/modes/components/extensions/extension-dashboard.ts +8 -5
- package/src/modes/components/extensions/extension-list.ts +1 -1
- package/src/modes/components/extensions/inspector-panel.ts +7 -3
- package/src/modes/components/extensions/state-manager.ts +36 -41
- package/src/modes/components/footer.ts +4 -2
- package/src/modes/components/history-search.ts +1 -1
- package/src/modes/components/hook-editor.ts +8 -0
- package/src/modes/components/hook-input.ts +8 -0
- package/src/modes/components/hook-message.ts +1 -3
- package/src/modes/components/hook-selector.ts +6 -7
- package/src/modes/components/index.ts +1 -0
- package/src/modes/components/late-diagnostics-message.ts +60 -0
- package/src/modes/components/login-dialog.ts +1 -1
- package/src/modes/components/logout-account-selector.ts +130 -0
- package/src/modes/components/mcp-add-wizard.ts +14 -1
- package/src/modes/components/model-selector.ts +177 -75
- package/src/modes/components/oauth-selector.ts +102 -16
- package/src/modes/components/overlay-box.ts +108 -0
- package/src/modes/components/plan-review-overlay.ts +845 -0
- package/src/modes/components/plan-toc.ts +138 -0
- package/src/modes/components/plugin-settings.ts +22 -5
- package/src/modes/components/read-tool-group.ts +442 -39
- package/src/modes/components/reset-usage-selector.ts +161 -0
- package/src/modes/components/segment-track.ts +44 -7
- package/src/modes/components/session-selector.ts +97 -37
- package/src/modes/components/settings-defs.ts +28 -6
- package/src/modes/components/settings-selector.ts +541 -93
- package/src/modes/components/skill-message.ts +0 -1
- package/src/modes/components/snapcompact-shape-preview-doc.md +11 -0
- package/src/modes/components/snapcompact-shape-preview.ts +193 -0
- package/src/modes/components/{status-line.ts → status-line/component.ts} +205 -168
- package/src/modes/components/status-line/index.ts +1 -0
- package/src/modes/components/status-line/presets.ts +3 -3
- package/src/modes/components/status-line/segments.ts +26 -7
- package/src/modes/components/status-line/types.ts +40 -9
- package/src/modes/components/tiny-title-download-progress.ts +1 -1
- package/src/modes/components/tips.txt +7 -3
- package/src/modes/components/todo-reminder.ts +0 -2
- package/src/modes/components/tool-execution.ts +236 -103
- package/src/modes/components/transcript-container.ts +724 -99
- package/src/modes/components/tree-selector.ts +19 -4
- package/src/modes/components/ttsr-notification.ts +72 -30
- package/src/modes/components/usage-row.ts +18 -0
- package/src/modes/components/user-message-selector.ts +1 -1
- package/src/modes/components/user-message.ts +28 -12
- package/src/modes/components/visual-truncate.ts +1 -1
- package/src/modes/components/welcome.ts +80 -22
- package/src/modes/controllers/command-controller-shared.ts +7 -6
- package/src/modes/controllers/command-controller.ts +210 -180
- package/src/modes/controllers/event-controller.ts +352 -142
- package/src/modes/controllers/extension-ui-controller.ts +167 -208
- package/src/modes/controllers/input-controller.ts +778 -162
- package/src/modes/controllers/mcp-command-controller.ts +232 -80
- package/src/modes/controllers/selector-controller.ts +284 -145
- package/src/modes/controllers/session-focus-controller.ts +112 -0
- package/src/modes/controllers/ssh-command-controller.ts +2 -2
- package/src/modes/controllers/streaming-reveal.ts +295 -0
- package/src/modes/controllers/tan-command-controller.ts +173 -0
- package/src/modes/controllers/tool-args-reveal.ts +174 -0
- package/src/modes/gradient-highlight.ts +21 -9
- package/src/modes/image-references.ts +33 -7
- package/src/modes/index.ts +8 -25
- package/src/modes/interactive-mode.ts +840 -186
- package/src/modes/magic-keywords.ts +28 -6
- package/src/modes/markdown-prose.ts +1 -1
- package/src/modes/oauth-manual-input.ts +30 -3
- package/src/modes/rpc/rpc-client.ts +186 -3
- package/src/modes/rpc/rpc-mode.ts +318 -24
- package/src/modes/rpc/rpc-subagents.ts +265 -0
- package/src/modes/rpc/rpc-types.ts +111 -2
- package/src/modes/runtime-init.ts +28 -3
- package/src/modes/session-observer-registry.ts +72 -3
- package/src/modes/setup-version.ts +11 -0
- package/src/modes/setup-wizard/index.ts +16 -4
- package/src/modes/setup-wizard/lazy.ts +16 -0
- package/src/modes/setup-wizard/scenes/glyph.ts +25 -7
- package/src/modes/setup-wizard/scenes/providers.ts +45 -12
- package/src/modes/setup-wizard/scenes/sign-in.ts +14 -13
- package/src/modes/setup-wizard/scenes/splash.ts +1 -1
- package/src/modes/setup-wizard/scenes/telegram.ts +77 -22
- package/src/modes/setup-wizard/scenes/theme.ts +29 -2
- package/src/modes/setup-wizard/scenes/types.ts +11 -2
- package/src/modes/setup-wizard/scenes/web-search.ts +26 -9
- package/src/modes/setup-wizard/wizard-overlay.ts +40 -3
- package/src/modes/shared.ts +2 -0
- package/src/modes/theme/defaults/dark-poimandres.json +1 -1
- package/src/modes/theme/defaults/light-poimandres.json +1 -1
- package/src/modes/theme/shimmer.ts +20 -9
- package/src/modes/theme/theme-schema.json +1 -1
- package/src/modes/theme/theme.ts +342 -82
- package/src/modes/types.ts +60 -18
- package/src/modes/utils/context-usage.ts +88 -8
- package/src/modes/utils/copy-targets.ts +133 -27
- package/src/modes/utils/hotkeys-markdown.ts +3 -2
- package/src/modes/utils/ui-helpers.ts +191 -110
- package/src/modes/workflow.ts +10 -10
- package/src/plan-mode/approved-plan.ts +66 -43
- package/src/plan-mode/plan-protection.ts +4 -4
- package/src/priority.json +5 -1
- package/src/prompts/agents/designer.md +1 -1
- package/src/prompts/agents/explore.md +3 -3
- package/src/prompts/agents/librarian.md +2 -3
- package/src/prompts/agents/oracle.md +2 -2
- package/src/prompts/agents/plan.md +6 -6
- package/src/prompts/agents/reviewer.md +1 -1
- package/src/prompts/agents/task.md +6 -5
- package/src/prompts/bench.md +12 -0
- package/src/prompts/ci-green-request.md +5 -7
- package/src/prompts/goals/goal-budget-limit.md +2 -2
- package/src/prompts/goals/goal-continuation.md +4 -4
- package/src/prompts/goals/goal-mode-active.md +1 -1
- package/src/prompts/goals/guided-goal-interview.md +8 -0
- package/src/prompts/goals/guided-goal-system.md +12 -0
- package/src/prompts/memories/consolidation.md +2 -7
- package/src/prompts/memories/consolidation_system.md +4 -0
- package/src/prompts/memories/identity_review.md +2 -2
- package/src/prompts/memories/read-path.md +11 -10
- package/src/prompts/memories/stage_one_system.md +2 -2
- package/src/prompts/review-custom-request.md +1 -1
- package/src/prompts/system/agent-creation-architect.md +2 -2
- package/src/prompts/system/auto-continue.md +1 -1
- package/src/prompts/system/autolearn-guidance-learn.md +1 -0
- package/src/prompts/system/autolearn-guidance.md +7 -0
- package/src/prompts/system/autolearn-nudge.md +3 -0
- package/src/prompts/system/background-tan-dispatch.md +8 -0
- package/src/prompts/system/btw-user.md +2 -2
- package/src/prompts/system/commit-message-system.md +13 -1
- package/src/prompts/system/custom-system-prompt.md +1 -1
- package/src/prompts/system/eager-task.md +7 -0
- package/src/prompts/system/eager-todo.md +11 -6
- package/src/prompts/system/empty-stop-retry.md +4 -6
- package/src/prompts/system/irc-autoreply.md +6 -0
- package/src/prompts/system/irc-incoming.md +3 -4
- package/src/prompts/system/manual-continue.md +7 -0
- package/src/prompts/system/omfg-user.md +3 -4
- package/src/prompts/system/orchestrate-notice.md +10 -10
- package/src/prompts/system/personalities/default.md +26 -0
- package/src/prompts/system/personalities/friendly.md +17 -0
- package/src/prompts/system/personalities/pragmatic.md +15 -0
- package/src/prompts/system/plan-mode-active.md +70 -77
- package/src/prompts/system/plan-mode-approved.md +1 -1
- package/src/prompts/system/plan-mode-subagent.md +4 -5
- package/src/prompts/system/plan-mode-tool-decision-reminder.md +1 -1
- package/src/prompts/system/project-prompt.md +2 -2
- package/src/prompts/system/snapcompact-context-frames-note.md +1 -0
- package/src/prompts/system/snapcompact-context-stub.md +1 -0
- package/src/prompts/system/snapcompact-system-frames-note.md +1 -0
- package/src/prompts/system/snapcompact-system-stub.md +1 -0
- package/src/prompts/system/snapcompact-toolresult-note.md +1 -0
- package/src/prompts/system/subagent-system-prompt.md +7 -8
- package/src/prompts/system/system-prompt.md +28 -57
- package/src/prompts/system/tiny-title-system.md +1 -1
- package/src/prompts/system/title-marker-instruction.md +1 -0
- package/src/prompts/system/title-system-marker.md +16 -0
- package/src/prompts/system/title-system.md +16 -3
- package/src/prompts/system/ttsr-tool-reminder.md +1 -1
- package/src/prompts/system/workflow-notice.md +4 -4
- package/src/prompts/tools/ast-edit.md +1 -1
- package/src/prompts/tools/ast-grep.md +2 -2
- package/src/prompts/tools/bash.md +16 -8
- package/src/prompts/tools/browser.md +33 -43
- package/src/prompts/tools/debug.md +1 -1
- package/src/prompts/tools/eval.md +31 -51
- package/src/prompts/tools/find.md +0 -1
- package/src/prompts/tools/github.md +8 -7
- package/src/prompts/tools/goal.md +1 -1
- package/src/prompts/tools/image-gen.md +1 -1
- package/src/prompts/tools/inspect-image-system.md +1 -1
- package/src/prompts/tools/irc.md +39 -31
- package/src/prompts/tools/job.md +2 -1
- package/src/prompts/tools/learn.md +7 -0
- package/src/prompts/tools/lsp-late-diagnostic.md +8 -0
- package/src/prompts/tools/lsp.md +2 -2
- package/src/prompts/tools/manage-skill.md +9 -0
- package/src/prompts/tools/memory-edit.md +1 -1
- package/src/prompts/tools/patch.md +2 -2
- package/src/prompts/tools/read.md +31 -39
- package/src/prompts/tools/recall.md +1 -1
- package/src/prompts/tools/reflect.md +1 -1
- package/src/prompts/tools/render-mermaid.md +2 -2
- package/src/prompts/tools/replace.md +4 -10
- package/src/prompts/tools/rewind.md +2 -2
- package/src/prompts/tools/search-tool-bm25.md +1 -9
- package/src/prompts/tools/search.md +0 -1
- package/src/prompts/tools/ssh.md +0 -4
- package/src/prompts/tools/task-summary.md +5 -16
- package/src/prompts/tools/task.md +47 -31
- package/src/prompts/tools/todo.md +6 -3
- package/src/registry/agent-lifecycle.ts +218 -0
- package/src/registry/agent-registry.ts +46 -5
- package/src/sdk.ts +692 -219
- package/src/secrets/index.ts +8 -1
- package/src/secrets/obfuscator.ts +40 -19
- package/src/session/agent-session.ts +1577 -806
- package/src/session/agent-storage.ts +18 -9
- package/src/session/auth-broker-config.ts +30 -1
- package/src/session/auth-storage.ts +6 -0
- package/src/session/codex-auto-reset.ts +202 -0
- package/src/session/history-storage.ts +3 -2
- package/src/session/indexed-session-storage.ts +7 -10
- package/src/session/messages.ts +59 -95
- package/src/session/session-context.ts +352 -0
- package/src/session/session-dump-format.ts +12 -3
- package/src/session/session-entries.ts +194 -0
- package/src/session/session-history-format.ts +246 -0
- package/src/session/session-listing.ts +588 -0
- package/src/session/session-loader.ts +106 -0
- package/src/session/session-manager.ts +1003 -2920
- package/src/session/session-migrations.ts +78 -0
- package/src/session/session-paths.ts +193 -0
- package/src/session/session-persistence.ts +131 -0
- package/src/session/session-storage.ts +91 -30
- package/src/session/snapcompact-inline.ts +542 -0
- package/src/session/snapcompact-savings-journal.ts +113 -0
- package/src/session/streaming-output.ts +248 -11
- package/src/session/tool-choice-queue.ts +23 -11
- package/src/session/yield-queue.ts +20 -2
- package/src/slash-commands/acp-builtins.ts +25 -1
- package/src/slash-commands/available-commands.ts +105 -0
- package/src/slash-commands/builtin-registry.ts +575 -49
- package/src/slash-commands/helpers/active-oauth-account.ts +44 -0
- package/src/slash-commands/helpers/context-report.ts +28 -1
- package/src/slash-commands/helpers/logout.ts +88 -0
- package/src/slash-commands/helpers/reset-usage.ts +66 -0
- package/src/slash-commands/helpers/stats-dashboard.ts +85 -0
- package/src/slash-commands/helpers/usage-report.ts +38 -3
- package/src/slash-commands/types.ts +5 -9
- package/src/ssh/connection-manager.ts +27 -0
- package/src/ssh/ssh-executor.ts +60 -4
- package/src/stt/asr-client.ts +520 -0
- package/src/stt/asr-protocol.ts +65 -0
- package/src/stt/asr-worker.ts +790 -0
- package/src/stt/downloader.ts +107 -47
- package/src/stt/endpointer.ts +259 -0
- package/src/stt/index.ts +5 -1
- package/src/stt/models.ts +150 -0
- package/src/stt/recorder.ts +254 -67
- package/src/stt/stt-controller.ts +201 -22
- package/src/stt/transcriber.ts +37 -68
- package/src/stt/wav.ts +173 -0
- package/src/system-prompt.ts +52 -10
- package/src/task/agents.ts +3 -4
- package/src/task/commands.ts +3 -2
- package/src/task/discovery.ts +17 -24
- package/src/task/executor.ts +1054 -529
- package/src/task/index.ts +862 -757
- package/src/task/output-manager.ts +0 -11
- package/src/task/parallel.ts +3 -3
- package/src/task/prometheus-command.ts +2 -2
- package/src/task/render.ts +529 -182
- package/src/task/repair-args.ts +21 -9
- package/src/task/types.ts +144 -66
- package/src/task/worktree.ts +64 -56
- package/src/telemetry-export.ts +27 -9
- package/src/thinking.ts +9 -7
- package/src/tiny/models.ts +2 -2
- package/src/tiny/text.ts +5 -1
- package/src/tiny/title-client.ts +72 -20
- package/src/tiny/title-protocol.ts +1 -1
- package/src/tiny/worker.ts +23 -99
- package/src/tool-discovery/tool-index.ts +2 -0
- package/src/tools/archive-reader.ts +94 -2
- package/src/tools/ask.ts +234 -177
- package/src/tools/ast-edit.ts +136 -80
- package/src/tools/ast-grep.ts +41 -45
- package/src/tools/auto-generated-guard.ts +20 -3
- package/src/tools/bash-interactive.ts +28 -8
- package/src/tools/bash.ts +198 -35
- package/src/tools/browser/attach.ts +26 -7
- package/src/tools/browser/cmux/cmux-tab.ts +1264 -0
- package/src/tools/browser/cmux/rpc.ts +156 -0
- package/src/tools/browser/cmux/socket-client.ts +309 -0
- package/src/tools/browser/launch.ts +11 -2
- package/src/tools/browser/readable.ts +19 -2
- package/src/tools/browser/registry.ts +52 -5
- package/src/tools/browser/render.ts +13 -5
- package/src/tools/browser/tab-protocol.ts +2 -0
- package/src/tools/browser/tab-supervisor.ts +256 -34
- package/src/tools/browser/tab-worker.ts +259 -91
- package/src/tools/browser.ts +44 -2
- package/src/tools/checkpoint.ts +1 -1
- package/src/tools/conflict-detect.ts +50 -4
- package/src/tools/debug.ts +27 -12
- package/src/tools/eval-render.ts +32 -35
- package/src/tools/eval.ts +26 -12
- package/src/tools/fetch.ts +450 -99
- package/src/tools/find.ts +182 -142
- package/src/tools/gh-cache-invalidation.ts +255 -0
- package/src/tools/gh-renderer.ts +104 -51
- package/src/tools/gh.ts +232 -37
- package/src/tools/github-cache.ts +97 -7
- package/src/tools/grouped-file-output.ts +159 -52
- package/src/tools/image-gen.ts +237 -132
- package/src/tools/index.ts +147 -26
- package/src/tools/inspect-image-renderer.ts +74 -45
- package/src/tools/inspect-image.ts +12 -6
- package/src/tools/irc.ts +626 -173
- package/src/tools/job.ts +106 -29
- package/src/tools/learn.ts +144 -0
- package/src/tools/manage-skill.ts +104 -0
- package/src/tools/memory-edit.ts +4 -4
- package/src/tools/memory-recall.ts +7 -9
- package/src/tools/memory-reflect.ts +5 -9
- package/src/tools/memory-render.ts +23 -6
- package/src/tools/memory-retain.ts +4 -4
- package/src/tools/path-utils.ts +102 -48
- package/src/tools/plan-mode-guard.ts +101 -40
- package/src/tools/read.ts +475 -120
- package/src/tools/render-mermaid.ts +1 -1
- package/src/tools/render-utils.ts +132 -76
- package/src/tools/renderers.ts +12 -1
- package/src/tools/report-tool-issue.ts +14 -6
- package/src/tools/resolve.ts +20 -3
- package/src/tools/review.ts +2 -2
- package/src/tools/search-tool-bm25.ts +37 -24
- package/src/tools/search.ts +233 -115
- package/src/tools/sqlite-reader.ts +26 -17
- package/src/tools/ssh.ts +20 -14
- package/src/tools/todo.ts +197 -191
- package/src/tools/tool-result.ts +8 -0
- package/src/tools/tool-timeouts.ts +1 -1
- package/src/tools/tts.ts +205 -74
- package/src/tools/write.ts +291 -155
- package/src/tools/yield.ts +10 -1
- package/src/tts/downloader.ts +64 -0
- package/src/tts/index.ts +8 -0
- package/src/tts/models.ts +137 -0
- package/src/tts/player.ts +137 -0
- package/src/tts/runtime.ts +21 -0
- package/src/tts/streaming-player.ts +266 -0
- package/src/tts/tts-client.ts +647 -0
- package/src/tts/tts-protocol.ts +60 -0
- package/src/tts/tts-worker.ts +505 -0
- package/src/tts/vocalizer.ts +162 -0
- package/src/tts/wav.ts +58 -0
- package/src/tui/code-cell.ts +2 -7
- package/src/tui/hyperlink.ts +40 -26
- package/src/tui/output-block.ts +60 -108
- package/src/tui/status-line.ts +5 -1
- package/src/utils/block-context.ts +312 -0
- package/src/utils/changelog.ts +27 -1
- package/src/utils/clipboard.ts +91 -22
- package/src/utils/commit-message-generator.ts +8 -3
- package/src/utils/enhanced-paste.ts +230 -0
- package/src/utils/file-mentions.ts +3 -1
- package/src/utils/git.ts +315 -15
- package/src/utils/image-loading.ts +65 -4
- package/src/utils/session-color.ts +83 -9
- package/src/utils/thinking-display.ts +37 -0
- package/src/utils/title-generator.ts +73 -10
- package/src/utils/tool-choice.ts +16 -0
- package/src/utils/tools-manager.ts +19 -1
- package/src/web/kagi.ts +28 -26
- package/src/web/parallel.ts +7 -3
- package/src/web/scrapers/arxiv.ts +1 -1
- package/src/web/scrapers/github.ts +351 -3
- package/src/web/scrapers/go-pkg.ts +1 -1
- package/src/web/scrapers/iacr.ts +1 -1
- package/src/web/scrapers/readthedocs.ts +1 -1
- package/src/web/scrapers/twitter.ts +2 -1
- package/src/web/scrapers/types.ts +87 -8
- package/src/web/scrapers/wikipedia.ts +1 -1
- package/src/web/scrapers/youtube.ts +9 -3
- package/src/web/search/index.ts +15 -2
- package/src/web/search/providers/anthropic.ts +62 -21
- package/src/web/search/providers/base.ts +2 -1
- package/src/web/search/providers/brave.ts +5 -2
- package/src/web/search/providers/codex.ts +87 -51
- package/src/web/search/providers/exa.ts +101 -10
- package/src/web/search/providers/gemini.ts +49 -24
- package/src/web/search/providers/jina.ts +15 -5
- package/src/web/search/providers/kagi.ts +9 -2
- package/src/web/search/providers/kimi.ts +45 -20
- package/src/web/search/providers/parallel.ts +39 -24
- package/src/web/search/providers/perplexity.ts +226 -63
- package/src/web/search/providers/searxng.ts +19 -3
- package/src/web/search/providers/synthetic.ts +16 -11
- package/src/web/search/providers/tavily.ts +12 -9
- package/src/web/search/providers/zai.ts +22 -9
- package/src/web/search/render.ts +59 -64
- package/src/web/search/types.ts +5 -1
- package/dist/types/discovery/context-files.d.ts +0 -17
- package/dist/types/exa/factory.d.ts +0 -13
- package/dist/types/exa/render.d.ts +0 -19
- package/dist/types/exa/researcher.d.ts +0 -9
- package/dist/types/exa/search.d.ts +0 -9
- package/dist/types/exa/websets.d.ts +0 -9
- package/dist/types/export/html/template.generated.d.ts +0 -1
- package/dist/types/modes/components/session-observer-overlay.d.ts +0 -11
- package/dist/types/modes/components/status-line.d.ts +0 -77
- package/dist/types/slash-commands/headless-plan.d.ts +0 -3
- package/dist/types/stt/setup.d.ts +0 -18
- package/scripts/generate-template.ts +0 -33
- package/src/discovery/context-files.ts +0 -49
- package/src/exa/factory.ts +0 -60
- package/src/exa/render.ts +0 -244
- package/src/exa/researcher.ts +0 -36
- package/src/exa/search.ts +0 -47
- package/src/exa/websets.ts +0 -248
- package/src/export/html/template.generated.ts +0 -2
- package/src/modes/components/session-observer-overlay.ts +0 -852
- package/src/slash-commands/headless-plan.ts +0 -142
- package/src/stt/setup.ts +0 -52
- package/src/stt/transcribe.py +0 -70
- /package/dist/types/extensibility/{legacy-pi-coding-agent-shim.d.ts → legacy-package-agent-shim.d.ts} +0 -0
- /package/src/extensibility/{legacy-pi-coding-agent-shim.ts → legacy-package-agent-shim.ts} +0 -0
|
@@ -1,318 +1,242 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
|
-
import * as os from "node:os";
|
|
3
2
|
import * as path from "node:path";
|
|
4
|
-
import type {
|
|
5
|
-
import
|
|
6
|
-
ImageContent,
|
|
7
|
-
Message,
|
|
8
|
-
MessageAttribution,
|
|
9
|
-
ProviderPayload,
|
|
10
|
-
ServiceTier,
|
|
11
|
-
TextContent,
|
|
12
|
-
Usage,
|
|
13
|
-
} from "@prometheus-ai/ai";
|
|
14
|
-
import { getTerminalId } from "@prometheus-ai/tui";
|
|
15
|
-
import {
|
|
16
|
-
getBlobsDir,
|
|
17
|
-
getAgentDir as getDefaultAgentDir,
|
|
18
|
-
getProjectDir,
|
|
19
|
-
getSessionsDir,
|
|
20
|
-
getTerminalSessionsDir,
|
|
21
|
-
hasFsCode,
|
|
22
|
-
isEnoent,
|
|
23
|
-
logger,
|
|
24
|
-
parseJsonlLenient,
|
|
25
|
-
pathIsWithin,
|
|
26
|
-
resolveEquivalentPath,
|
|
27
|
-
Snowflake,
|
|
28
|
-
toError,
|
|
29
|
-
} from "@prometheus-ai/utils";
|
|
3
|
+
import type { ImageContent, Message, MessageAttribution, ServiceTier, TextContent, Usage } from "@prometheus-ai/ai";
|
|
4
|
+
import { getBlobsDir, getProjectDir, getSessionsDir, isEnoent, logger, toError } from "@prometheus-ai/utils";
|
|
30
5
|
import { ArtifactManager } from "./artifacts";
|
|
31
|
-
import {
|
|
32
|
-
type BlobPutOptions,
|
|
33
|
-
type BlobPutResult,
|
|
34
|
-
BlobStore,
|
|
35
|
-
externalizeImageData,
|
|
36
|
-
externalizeImageDataSync,
|
|
37
|
-
externalizeImageDataUrl,
|
|
38
|
-
externalizeImageDataUrlSync,
|
|
39
|
-
isBlobRef,
|
|
40
|
-
isImageDataUrl,
|
|
41
|
-
resolveImageData,
|
|
42
|
-
resolveImageDataUrl,
|
|
43
|
-
} from "./blob-store";
|
|
6
|
+
import { type BlobPutOptions, type BlobPutResult, BlobStore } from "./blob-store";
|
|
44
7
|
import {
|
|
45
8
|
type BashExecutionMessage,
|
|
46
9
|
type CustomMessage,
|
|
47
|
-
createBranchSummaryMessage,
|
|
48
|
-
createCompactionSummaryMessage,
|
|
49
|
-
createCustomMessage,
|
|
50
10
|
type FileMentionMessage,
|
|
51
11
|
type HookMessage,
|
|
52
12
|
type PythonExecutionMessage,
|
|
53
13
|
sanitizeRehydratedOpenAIResponsesAssistantMessage,
|
|
54
14
|
stripInternalDetailsFields,
|
|
55
15
|
} from "./messages";
|
|
56
|
-
import type
|
|
57
|
-
import {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
type
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
16
|
+
import { type BuildSessionContextOptions, buildSessionContext, type SessionContext } from "./session-context";
|
|
17
|
+
import {
|
|
18
|
+
type BranchSummaryEntry,
|
|
19
|
+
type CompactionEntry,
|
|
20
|
+
CURRENT_SESSION_VERSION,
|
|
21
|
+
type CustomEntry,
|
|
22
|
+
type CustomMessageEntry,
|
|
23
|
+
type FileEntry,
|
|
24
|
+
type LabelEntry,
|
|
25
|
+
type MCPToolSelectionEntry,
|
|
26
|
+
type ModeChangeEntry,
|
|
27
|
+
type ModelChangeEntry,
|
|
28
|
+
type NewSessionOptions,
|
|
29
|
+
type ServiceTierChangeEntry,
|
|
30
|
+
type SessionEntry,
|
|
31
|
+
type SessionHeader,
|
|
32
|
+
type SessionInitEntry,
|
|
33
|
+
type SessionMessageEntry,
|
|
34
|
+
type SessionTreeNode,
|
|
35
|
+
type ThinkingLevelChangeEntry,
|
|
36
|
+
type TtsrInjectionEntry,
|
|
37
|
+
type UsageStatistics,
|
|
38
|
+
} from "./session-entries";
|
|
39
|
+
import { findMostRecentSession, listAllSessions, listSessions, type SessionInfo } from "./session-listing";
|
|
40
|
+
import { loadEntriesFromFile, resolveBlobRefsInEntries } from "./session-loader";
|
|
41
|
+
import { generateId, migrateToCurrentVersion } from "./session-migrations";
|
|
42
|
+
import {
|
|
43
|
+
computeDefaultSessionDir,
|
|
44
|
+
readTerminalBreadcrumbEntry,
|
|
45
|
+
resolveManagedSessionRoot,
|
|
46
|
+
writeTerminalBreadcrumb,
|
|
47
|
+
} from "./session-paths";
|
|
48
|
+
import { prepareEntryForPersistence } from "./session-persistence";
|
|
49
|
+
import {
|
|
50
|
+
FileSessionStorage,
|
|
51
|
+
MemorySessionStorage,
|
|
52
|
+
type SessionStorage,
|
|
53
|
+
type SessionStorageWriter,
|
|
54
|
+
} from "./session-storage";
|
|
71
55
|
|
|
72
|
-
|
|
73
|
-
parentSession?: string;
|
|
74
|
-
/** Skip flushing the current session and delete it instead of saving. */
|
|
75
|
-
drop?: boolean;
|
|
76
|
-
}
|
|
56
|
+
const JSONL_SUFFIX_LENGTH = ".jsonl".length;
|
|
77
57
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
id: string;
|
|
81
|
-
parentId: string | null;
|
|
82
|
-
timestamp: string;
|
|
58
|
+
function mintSessionId(): string {
|
|
59
|
+
return Bun.randomUUIDv7();
|
|
83
60
|
}
|
|
84
61
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
message: AgentMessage;
|
|
62
|
+
function nowIso(): string {
|
|
63
|
+
return new Date().toISOString();
|
|
88
64
|
}
|
|
89
65
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
thinkingLevel?: string | null;
|
|
66
|
+
function fileSafeTimestamp(iso: string): string {
|
|
67
|
+
return iso.replace(/[:.]/g, "-");
|
|
93
68
|
}
|
|
94
69
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
/** Model in "provider/modelId" format */
|
|
98
|
-
model: string;
|
|
99
|
-
/** Role: "default", "smol", "slow", etc. Undefined treated as "default" */
|
|
100
|
-
role?: string;
|
|
70
|
+
function artifactsDirectoryFor(sessionFile: string | undefined): string | null {
|
|
71
|
+
return sessionFile ? sessionFile.slice(0, -JSONL_SUFFIX_LENGTH) : null;
|
|
101
72
|
}
|
|
102
73
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
serviceTier: ServiceTier | null;
|
|
74
|
+
function emptyUsageStatistics(): UsageStatistics {
|
|
75
|
+
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, premiumRequests: 0, cost: 0 };
|
|
106
76
|
}
|
|
107
77
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
firstKeptEntryId: string;
|
|
113
|
-
tokensBefore: number;
|
|
114
|
-
/** Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction) */
|
|
115
|
-
details?: T;
|
|
116
|
-
/** Hook-provided data to persist across compaction */
|
|
117
|
-
preserveData?: Record<string, unknown>;
|
|
118
|
-
/** True if generated by an extension, undefined/false if pi-generated (backward compatible) */
|
|
119
|
-
fromExtension?: boolean;
|
|
78
|
+
function taskUsageFrom(details: unknown): Usage | undefined {
|
|
79
|
+
if (details === null || typeof details !== "object") return undefined;
|
|
80
|
+
const maybeUsage = (details as Record<string, unknown>).usage;
|
|
81
|
+
return maybeUsage !== null && typeof maybeUsage === "object" ? (maybeUsage as Usage) : undefined;
|
|
120
82
|
}
|
|
121
83
|
|
|
122
|
-
|
|
123
|
-
type
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
/** True if generated by an extension, false if pi-generated */
|
|
129
|
-
fromExtension?: boolean;
|
|
84
|
+
function entryUsage(entry: SessionEntry): Usage | undefined {
|
|
85
|
+
if (entry.type !== "message") return undefined;
|
|
86
|
+
const message = entry.message;
|
|
87
|
+
if (message.role === "assistant") return message.usage;
|
|
88
|
+
if (message.role === "toolResult" && message.toolName === "task") return taskUsageFrom(message.details);
|
|
89
|
+
return undefined;
|
|
130
90
|
}
|
|
131
91
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
* For injecting content into context, see CustomMessageEntry.
|
|
141
|
-
*/
|
|
142
|
-
export interface CustomEntry<T = unknown> extends SessionEntryBase {
|
|
143
|
-
type: "custom";
|
|
144
|
-
customType: string;
|
|
145
|
-
data?: T;
|
|
92
|
+
function addUsage(target: UsageStatistics, usage: Usage | undefined): void {
|
|
93
|
+
if (!usage) return;
|
|
94
|
+
target.input += usage.input;
|
|
95
|
+
target.output += usage.output;
|
|
96
|
+
target.cacheRead += usage.cacheRead;
|
|
97
|
+
target.cacheWrite += usage.cacheWrite;
|
|
98
|
+
target.premiumRequests += usage.premiumRequests ?? 0;
|
|
99
|
+
target.cost += usage.cost.total;
|
|
146
100
|
}
|
|
147
101
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
type: "label";
|
|
151
|
-
targetId: string;
|
|
152
|
-
label: string | undefined;
|
|
102
|
+
function isAssistantEntry(entry: SessionEntry): boolean {
|
|
103
|
+
return entry.type === "message" && entry.message.role === "assistant";
|
|
153
104
|
}
|
|
154
105
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
type: "ttsr_injection";
|
|
158
|
-
/** Names of rules that were injected */
|
|
159
|
-
injectedRules: string[];
|
|
106
|
+
function orderedByTimestamp(a: SessionTreeNode, b: SessionTreeNode): number {
|
|
107
|
+
return new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime();
|
|
160
108
|
}
|
|
161
109
|
|
|
162
|
-
/**
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
110
|
+
/**
|
|
111
|
+
* Maintains the derived views over a session's entry list: id lookup, the
|
|
112
|
+
* parent→children adjacency, the resolved label map, the active leaf, and the
|
|
113
|
+
* running usage totals. Kept in lockstep with the manager's `#entries` so reads
|
|
114
|
+
* stay O(1)/O(children) instead of rescanning the whole journal.
|
|
115
|
+
*/
|
|
116
|
+
class SessionEntryIndex {
|
|
117
|
+
#entriesById = new Map<string, SessionEntry>();
|
|
118
|
+
#children = new Map<string | null, SessionEntry[]>();
|
|
119
|
+
#labels = new Map<string, string>();
|
|
120
|
+
#leaf: string | null = null;
|
|
121
|
+
#usage = emptyUsageStatistics();
|
|
168
122
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
/** Tools available to the agent */
|
|
177
|
-
tools: string[];
|
|
178
|
-
/** Output schema if structured output was requested */
|
|
179
|
-
outputSchema?: unknown;
|
|
180
|
-
}
|
|
123
|
+
clear(): void {
|
|
124
|
+
this.#entriesById.clear();
|
|
125
|
+
this.#children.clear();
|
|
126
|
+
this.#labels.clear();
|
|
127
|
+
this.#leaf = null;
|
|
128
|
+
this.#usage = emptyUsageStatistics();
|
|
129
|
+
}
|
|
181
130
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
mode: string;
|
|
187
|
-
/** Optional mode-specific data (e.g. plan file path) */
|
|
188
|
-
data?: Record<string, unknown>;
|
|
189
|
-
}
|
|
131
|
+
rebuild(entries: readonly SessionEntry[]): void {
|
|
132
|
+
this.clear();
|
|
133
|
+
for (const entry of entries) this.insert(entry);
|
|
134
|
+
}
|
|
190
135
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
*
|
|
195
|
-
* Unlike CustomEntry, this DOES participate in LLM context.
|
|
196
|
-
* The content participates in LLM context through convertToLlm().
|
|
197
|
-
* Use details for extension-specific metadata (not sent to LLM).
|
|
198
|
-
*
|
|
199
|
-
* display controls TUI rendering:
|
|
200
|
-
* - false: hidden entirely
|
|
201
|
-
* - true: rendered with distinct styling (different from user messages)
|
|
202
|
-
*/
|
|
203
|
-
export interface CustomMessageEntry<T = unknown> extends SessionEntryBase {
|
|
204
|
-
type: "custom_message";
|
|
205
|
-
customType: string;
|
|
206
|
-
content: string | (TextContent | ImageContent)[];
|
|
207
|
-
details?: T;
|
|
208
|
-
display: boolean;
|
|
209
|
-
/** Who initiated this message for billing/attribution semantics. */
|
|
210
|
-
attribution?: MessageAttribution;
|
|
211
|
-
}
|
|
136
|
+
insert(entry: SessionEntry): void {
|
|
137
|
+
this.#entriesById.set(entry.id, entry);
|
|
138
|
+
this.#leaf = entry.id;
|
|
212
139
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
| ThinkingLevelChangeEntry
|
|
217
|
-
| ModelChangeEntry
|
|
218
|
-
| ServiceTierChangeEntry
|
|
219
|
-
| CompactionEntry
|
|
220
|
-
| BranchSummaryEntry
|
|
221
|
-
| CustomEntry
|
|
222
|
-
| CustomMessageEntry
|
|
223
|
-
| LabelEntry
|
|
224
|
-
| TtsrInjectionEntry
|
|
225
|
-
| MCPToolSelectionEntry
|
|
226
|
-
| SessionInitEntry
|
|
227
|
-
| ModeChangeEntry;
|
|
228
|
-
|
|
229
|
-
/** Raw file entry (includes header) */
|
|
230
|
-
export type FileEntry = SessionHeader | SessionEntry;
|
|
231
|
-
|
|
232
|
-
/** Tree node for getTree() - defensive copy of session structure */
|
|
233
|
-
export interface SessionTreeNode {
|
|
234
|
-
entry: SessionEntry;
|
|
235
|
-
children: SessionTreeNode[];
|
|
236
|
-
/** Resolved label for this entry, if any */
|
|
237
|
-
label?: string;
|
|
238
|
-
}
|
|
140
|
+
const bucket = this.#children.get(entry.parentId);
|
|
141
|
+
if (bucket) bucket.push(entry);
|
|
142
|
+
else this.#children.set(entry.parentId, [entry]);
|
|
239
143
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
/** Model roles: { default: "provider/modelId", small: "provider/modelId", ... } */
|
|
245
|
-
models: Record<string, string>;
|
|
246
|
-
/** Names of TTSR rules that have been injected this session */
|
|
247
|
-
injectedTtsrRules: string[];
|
|
248
|
-
/** MCP tool names selected through discovery for this session branch. */
|
|
249
|
-
selectedMCPToolNames: string[];
|
|
250
|
-
/** Whether this branch contains an explicit persisted MCP selection entry. */
|
|
251
|
-
hasPersistedMCPToolSelection: boolean;
|
|
252
|
-
/** Active mode (e.g. "plan") or "none" if no special mode is active */
|
|
253
|
-
mode: string;
|
|
254
|
-
/** Mode-specific data from the last mode_change entry */
|
|
255
|
-
modeData?: Record<string, unknown>;
|
|
256
|
-
}
|
|
144
|
+
if (entry.type === "label") {
|
|
145
|
+
if (entry.label) this.#labels.set(entry.targetId, entry.label);
|
|
146
|
+
else this.#labels.delete(entry.targetId);
|
|
147
|
+
}
|
|
257
148
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
/** Lists session model strings to try when restoring, in fallback order. */
|
|
261
|
-
export function getRestorableSessionModels(
|
|
262
|
-
models: Readonly<Record<string, string>>,
|
|
263
|
-
lastModelChangeRole: string | undefined,
|
|
264
|
-
): string[] {
|
|
265
|
-
const defaultModel = models.default;
|
|
266
|
-
if (
|
|
267
|
-
!lastModelChangeRole ||
|
|
268
|
-
lastModelChangeRole === "default" ||
|
|
269
|
-
lastModelChangeRole === EPHEMERAL_MODEL_CHANGE_ROLE
|
|
270
|
-
) {
|
|
271
|
-
return defaultModel ? [defaultModel] : [];
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
const roleModel = models[lastModelChangeRole];
|
|
275
|
-
if (!roleModel) return defaultModel ? [defaultModel] : [];
|
|
276
|
-
if (!defaultModel || roleModel === defaultModel) return [roleModel];
|
|
277
|
-
return [roleModel, defaultModel];
|
|
278
|
-
}
|
|
149
|
+
addUsage(this.#usage, entryUsage(entry));
|
|
150
|
+
}
|
|
279
151
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
* a length-truncated turn.
|
|
288
|
-
* - `aborted` — the last assistant turn was cancelled by the user.
|
|
289
|
-
* - `error` — the last assistant turn ended in an error.
|
|
290
|
-
* - `pending` — a trailing user message with no assistant reply persisted after it.
|
|
291
|
-
* - `unknown` — status could not be determined (empty/header-only session, or the
|
|
292
|
-
* final message was larger than the tail window that was read).
|
|
293
|
-
*/
|
|
294
|
-
export type SessionStatus = "complete" | "interrupted" | "aborted" | "error" | "pending" | "unknown";
|
|
152
|
+
has(id: string): boolean {
|
|
153
|
+
return this.#entriesById.has(id);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
get(id: string): SessionEntry | undefined {
|
|
157
|
+
return this.#entriesById.get(id);
|
|
158
|
+
}
|
|
295
159
|
|
|
296
|
-
export interface SessionInfo {
|
|
297
|
-
path: string;
|
|
298
|
-
id: string;
|
|
299
|
-
/** Working directory where the session was started. Empty string for old sessions. */
|
|
300
|
-
cwd: string;
|
|
301
|
-
title?: string;
|
|
302
|
-
/** Path to the parent session (if this session was forked). */
|
|
303
|
-
parentSessionPath?: string;
|
|
304
|
-
created: Date;
|
|
305
|
-
modified: Date;
|
|
306
|
-
messageCount: number;
|
|
307
|
-
/** File size in bytes on disk; used for compact list rendering. */
|
|
308
|
-
size: number;
|
|
309
|
-
firstMessage: string;
|
|
310
|
-
allMessagesText: string;
|
|
311
160
|
/**
|
|
312
|
-
*
|
|
313
|
-
*
|
|
161
|
+
* The live id→entry map. Read-only for callers (lookups + `generateId`
|
|
162
|
+
* collision checks); never mutate it directly — go through `insert`/`rebuild`.
|
|
314
163
|
*/
|
|
315
|
-
|
|
164
|
+
entriesById(): Map<string, SessionEntry> {
|
|
165
|
+
return this.#entriesById;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
leafId(): string | null {
|
|
169
|
+
return this.#leaf;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
leafEntry(): SessionEntry | undefined {
|
|
173
|
+
return this.#leaf ? this.#entriesById.get(this.#leaf) : undefined;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
setLeaf(id: string | null): void {
|
|
177
|
+
this.#leaf = id;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
childrenOf(parentId: string): SessionEntry[] {
|
|
181
|
+
return [...(this.#children.get(parentId) ?? [])];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
labelFor(id: string): string | undefined {
|
|
185
|
+
return this.#labels.get(id);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
labelsInEffect(): IterableIterator<[string, string]> {
|
|
189
|
+
return this.#labels.entries();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
usageSnapshot(): UsageStatistics {
|
|
193
|
+
return { ...this.#usage };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
pathTo(id: string | null | undefined = this.#leaf): SessionEntry[] {
|
|
197
|
+
const branch: SessionEntry[] = [];
|
|
198
|
+
const seen = new Set<string>();
|
|
199
|
+
let cursor = id ? this.#entriesById.get(id) : undefined;
|
|
200
|
+
|
|
201
|
+
while (cursor && !seen.has(cursor.id)) {
|
|
202
|
+
seen.add(cursor.id);
|
|
203
|
+
branch.unshift(cursor);
|
|
204
|
+
cursor = cursor.parentId ? this.#entriesById.get(cursor.parentId) : undefined;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return branch;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
tree(entries: readonly SessionEntry[]): SessionTreeNode[] {
|
|
211
|
+
const nodes = new Map<string, SessionTreeNode>();
|
|
212
|
+
const roots: SessionTreeNode[] = [];
|
|
213
|
+
|
|
214
|
+
for (const entry of entries) {
|
|
215
|
+
nodes.set(entry.id, { entry, children: [], label: this.#labels.get(entry.id) });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
for (const entry of entries) {
|
|
219
|
+
const node = nodes.get(entry.id)!;
|
|
220
|
+
const parentId = entry.parentId;
|
|
221
|
+
if (parentId === null || parentId === entry.id) {
|
|
222
|
+
roots.push(node);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const parent = nodes.get(parentId);
|
|
227
|
+
if (parent) parent.children.push(node);
|
|
228
|
+
else roots.push(node);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const stack = [...roots];
|
|
232
|
+
while (stack.length > 0) {
|
|
233
|
+
const node = stack.pop()!;
|
|
234
|
+
node.children.sort(orderedByTimestamp);
|
|
235
|
+
stack.push(...node.children);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return roots;
|
|
239
|
+
}
|
|
316
240
|
}
|
|
317
241
|
|
|
318
242
|
export type ReadonlySessionManager = Pick<
|
|
@@ -340,2226 +264,709 @@ export type ReadonlySessionManager = Pick<
|
|
|
340
264
|
| "putBlobSync"
|
|
341
265
|
>;
|
|
342
266
|
|
|
343
|
-
|
|
344
|
-
|
|
267
|
+
interface SessionManagerStateSnapshot {
|
|
268
|
+
cwd: string;
|
|
269
|
+
sessionDir: string;
|
|
270
|
+
sessionId: string;
|
|
271
|
+
sessionName: string | undefined;
|
|
272
|
+
titleSource: "auto" | "user" | undefined;
|
|
273
|
+
sessionFile: string | undefined;
|
|
274
|
+
onDisk: boolean;
|
|
275
|
+
needsRewrite: boolean;
|
|
276
|
+
header: SessionHeader;
|
|
277
|
+
entries: SessionEntry[];
|
|
345
278
|
}
|
|
346
279
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
if (!byId.has(id)) return id;
|
|
352
|
-
}
|
|
353
|
-
return Snowflake.next(); // fallback to full snowflake id
|
|
280
|
+
interface DiskQueueOptions {
|
|
281
|
+
ignorePriorError?: boolean;
|
|
282
|
+
ignoreEpoch?: boolean;
|
|
283
|
+
epoch?: number;
|
|
354
284
|
}
|
|
355
285
|
|
|
356
|
-
/**
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
286
|
+
/**
|
|
287
|
+
* Stores and navigates an append-only conversation journal.
|
|
288
|
+
*
|
|
289
|
+
* A session is a JSONL file: one header line followed by entries. Entries form a
|
|
290
|
+
* tree by `(id, parentId)`, and the mutable leaf pointer selects which path is
|
|
291
|
+
* active for future appends and for LLM context construction.
|
|
292
|
+
*
|
|
293
|
+
* Durability is software-crash safe but not power-loss safe: appends are handed
|
|
294
|
+
* to the OS synchronously in-body (so an entry survives an OOM/SIGKILL the
|
|
295
|
+
* instant `appendMessage` returns) but never `fsync`'d. Full-file rewrites go
|
|
296
|
+
* through the storage layer's atomic temp-write+rename so a crash mid-rewrite
|
|
297
|
+
* cannot truncate the prior good file.
|
|
298
|
+
*/
|
|
299
|
+
export class SessionManager {
|
|
300
|
+
#cwd: string;
|
|
301
|
+
#sessionDir: string;
|
|
302
|
+
readonly #persist: boolean;
|
|
303
|
+
readonly #storage: SessionStorage;
|
|
304
|
+
readonly #blobs: BlobStore;
|
|
366
305
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
if (typeof comp.firstKeptEntryIndex === "number") {
|
|
375
|
-
const targetEntry = entries[comp.firstKeptEntryIndex];
|
|
376
|
-
if (targetEntry && targetEntry.type !== "session") {
|
|
377
|
-
comp.firstKeptEntryId = targetEntry.id;
|
|
378
|
-
}
|
|
379
|
-
delete comp.firstKeptEntryIndex;
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
}
|
|
306
|
+
#sessionId = "";
|
|
307
|
+
#sessionName: string | undefined;
|
|
308
|
+
#titleSource: "auto" | "user" | undefined;
|
|
309
|
+
#sessionFile: string | undefined;
|
|
310
|
+
#header!: SessionHeader;
|
|
311
|
+
#entries: SessionEntry[] = [];
|
|
312
|
+
#index = new SessionEntryIndex();
|
|
384
313
|
|
|
385
|
-
/**
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
}
|
|
314
|
+
/** File reflects all current entries; appends can go incrementally. */
|
|
315
|
+
#fileIsCurrent = false;
|
|
316
|
+
/** In-memory entries diverged from disk (load-migration/sanitize) → next persist must full-rewrite. */
|
|
317
|
+
#rewriteRequired = false;
|
|
318
|
+
/** Lazy gate crossed (ensureOnDisk / loaded file): every entry must persist from now on. */
|
|
319
|
+
#forceFileCreation = false;
|
|
392
320
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
}
|
|
321
|
+
/**
|
|
322
|
+
* Collab replication tap: invoked for every appended entry with the
|
|
323
|
+
* in-memory (pre-blob-externalization) entry, so inline images survive.
|
|
324
|
+
*/
|
|
325
|
+
onEntryAppended?: (entry: SessionEntry) => void;
|
|
401
326
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
function migrateToCurrentVersion(entries: FileEntry[]): boolean {
|
|
407
|
-
const header = entries.find(e => e.type === "session") as SessionHeader | undefined;
|
|
408
|
-
const version = header?.version ?? 1;
|
|
327
|
+
#turnBudgetTotal: number | null = null;
|
|
328
|
+
#turnBudgetHard = false;
|
|
329
|
+
#turnOutputBaseline = 0;
|
|
330
|
+
#turnEvalOutput = 0;
|
|
409
331
|
|
|
410
|
-
|
|
332
|
+
/** The single open append writer; the manager only ever writes one file at a time. */
|
|
333
|
+
#writer: SessionStorageWriter | undefined;
|
|
334
|
+
/** Serializes async disk work (flush/close/atomic rewrite). Appends are synchronous and bypass it. */
|
|
335
|
+
#diskTail: Promise<void> = Promise.resolve();
|
|
336
|
+
#diskFailure: Error | undefined;
|
|
337
|
+
#diskFailureLogged = false;
|
|
338
|
+
/** Bumped on every sync rewrite / chain reset so stale queued tasks become no-ops. */
|
|
339
|
+
#diskEpoch = 0;
|
|
411
340
|
|
|
412
|
-
|
|
413
|
-
|
|
341
|
+
#artifactManager: ArtifactManager | null = null;
|
|
342
|
+
#artifactManagerSessionFile: string | null = null;
|
|
343
|
+
#adoptedArtifactManager: ArtifactManager | null = null;
|
|
344
|
+
#inMemoryArtifacts: Map<string, string> | null = null;
|
|
345
|
+
#inMemoryArtifactCounter = 0;
|
|
414
346
|
|
|
415
|
-
|
|
416
|
-
|
|
347
|
+
#suppressBreadcrumb = false;
|
|
348
|
+
#sessionNameChangedCallbacks = new Set<() => void>();
|
|
417
349
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
350
|
+
private constructor(cwd: string, sessionDir: string, persist: boolean, storage: SessionStorage) {
|
|
351
|
+
this.#cwd = cwd;
|
|
352
|
+
this.#sessionDir = sessionDir;
|
|
353
|
+
this.#persist = persist;
|
|
354
|
+
this.#storage = storage;
|
|
355
|
+
this.#blobs = new BlobStore(getBlobsDir());
|
|
422
356
|
|
|
423
|
-
|
|
357
|
+
if (persist && sessionDir) this.#storage.ensureDirSync(sessionDir);
|
|
358
|
+
}
|
|
424
359
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
* Best effort: callers decide whether migration failures should surface.
|
|
428
|
-
*/
|
|
429
|
-
function migrateSessionDirPath(oldPath: string, newPath: string): void {
|
|
430
|
-
const existing = fs.statSync(newPath, { throwIfNoEntry: false });
|
|
431
|
-
if (existing?.isDirectory()) {
|
|
432
|
-
for (const file of fs.readdirSync(oldPath)) {
|
|
433
|
-
const src = path.join(oldPath, file);
|
|
434
|
-
const dst = path.join(newPath, file);
|
|
435
|
-
if (!fs.existsSync(dst)) {
|
|
436
|
-
fs.renameSync(src, dst);
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
fs.rmSync(oldPath, { recursive: true, force: true });
|
|
440
|
-
return;
|
|
360
|
+
#rememberBreadcrumb(cwd: string, sessionFile: string): void {
|
|
361
|
+
if (!this.#suppressBreadcrumb) writeTerminalBreadcrumb(cwd, sessionFile);
|
|
441
362
|
}
|
|
442
|
-
|
|
443
|
-
|
|
363
|
+
|
|
364
|
+
#clearDiskError(): void {
|
|
365
|
+
this.#diskFailure = undefined;
|
|
366
|
+
this.#diskFailureLogged = false;
|
|
444
367
|
}
|
|
445
|
-
fs.renameSync(oldPath, newPath);
|
|
446
|
-
}
|
|
447
368
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
}
|
|
369
|
+
#noteDiskFailure(errorLike: unknown): Error {
|
|
370
|
+
const error = toError(errorLike);
|
|
371
|
+
if (!this.#diskFailure) this.#diskFailure = error;
|
|
452
372
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
373
|
+
if (!this.#diskFailureLogged) {
|
|
374
|
+
this.#diskFailureLogged = true;
|
|
375
|
+
logger.error("Session persistence error.", {
|
|
376
|
+
sessionFile: this.#sessionFile,
|
|
377
|
+
error: error.message,
|
|
378
|
+
stack: error.stack,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
457
381
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
const canonicalCwd = resolveEquivalentPath(resolvedCwd);
|
|
461
|
-
const home = resolveEquivalentPath(os.homedir());
|
|
462
|
-
const tempRoot = resolveEquivalentPath(os.tmpdir());
|
|
463
|
-
const encodedDirName = pathIsWithin(home, canonicalCwd)
|
|
464
|
-
? encodeRelativeSessionDirName("-", home, canonicalCwd)
|
|
465
|
-
: pathIsWithin(tempRoot, canonicalCwd)
|
|
466
|
-
? encodeRelativeSessionDirName("-tmp", tempRoot, canonicalCwd)
|
|
467
|
-
: encodeLegacyAbsoluteSessionDirName(canonicalCwd);
|
|
468
|
-
return { encodedDirName, resolvedCwd };
|
|
469
|
-
}
|
|
382
|
+
return this.#diskFailure;
|
|
383
|
+
}
|
|
470
384
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
const homeEncoded = home.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-");
|
|
481
|
-
const oldPrefix = `--${homeEncoded}-`;
|
|
482
|
-
const oldExact = `--${homeEncoded}--`;
|
|
483
|
-
|
|
484
|
-
let entries: string[];
|
|
485
|
-
try {
|
|
486
|
-
entries = fs.readdirSync(sessionsRoot);
|
|
487
|
-
} catch {
|
|
488
|
-
return;
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
for (const entry of entries) {
|
|
492
|
-
let remainder: string;
|
|
493
|
-
if (entry === oldExact) {
|
|
494
|
-
remainder = "";
|
|
495
|
-
} else if (entry.startsWith(oldPrefix) && entry.endsWith("--")) {
|
|
496
|
-
remainder = entry.slice(oldPrefix.length, -2);
|
|
497
|
-
} else {
|
|
498
|
-
continue;
|
|
499
|
-
}
|
|
385
|
+
#scheduleDiskWork(work: () => Promise<void>, options: DiskQueueOptions = {}): Promise<void> {
|
|
386
|
+
const epoch = options.epoch ?? this.#diskEpoch;
|
|
387
|
+
const scheduled = this.#diskTail
|
|
388
|
+
.catch(() => undefined)
|
|
389
|
+
.then(async () => {
|
|
390
|
+
if (!options.ignoreEpoch && epoch !== this.#diskEpoch) return;
|
|
391
|
+
if (this.#diskFailure && !options.ignorePriorError) throw this.#diskFailure;
|
|
392
|
+
await work();
|
|
393
|
+
});
|
|
500
394
|
|
|
501
|
-
const
|
|
502
|
-
|
|
503
|
-
|
|
395
|
+
const reported = scheduled.catch(err => {
|
|
396
|
+
throw this.#noteDiskFailure(err);
|
|
397
|
+
});
|
|
398
|
+
this.#diskTail = reported.catch(() => undefined);
|
|
399
|
+
return reported;
|
|
400
|
+
}
|
|
504
401
|
|
|
402
|
+
async #drainAndCloseWriter(): Promise<void> {
|
|
505
403
|
try {
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
404
|
+
await this.#scheduleDiskWork(
|
|
405
|
+
async () => {
|
|
406
|
+
await this.#closeWriterHandle();
|
|
407
|
+
},
|
|
408
|
+
{ ignorePriorError: true, ignoreEpoch: true },
|
|
409
|
+
);
|
|
410
|
+
} finally {
|
|
411
|
+
this.#writer = undefined;
|
|
412
|
+
this.#diskTail = Promise.resolve();
|
|
509
413
|
}
|
|
510
414
|
}
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
function migrateLegacyAbsoluteSessionDir(cwd: string, sessionDir: string, sessionsRoot: string): void {
|
|
514
|
-
const legacyDir = path.join(sessionsRoot, encodeLegacyAbsoluteSessionDirName(cwd));
|
|
515
|
-
if (legacyDir === sessionDir || !fs.existsSync(legacyDir)) return;
|
|
516
415
|
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
416
|
+
#closeWriterEventually(): void {
|
|
417
|
+
const writer = this.#writer;
|
|
418
|
+
this.#writer = undefined;
|
|
419
|
+
if (writer) void writer.close().catch(() => undefined);
|
|
521
420
|
}
|
|
522
|
-
}
|
|
523
421
|
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
422
|
+
async #closeWriterHandle(): Promise<void> {
|
|
423
|
+
const writer = this.#writer;
|
|
424
|
+
if (!writer) return;
|
|
425
|
+
this.#writer = undefined;
|
|
426
|
+
await writer.close();
|
|
529
427
|
}
|
|
530
|
-
return path.dirname(sessionDir);
|
|
531
|
-
}
|
|
532
428
|
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
return parseJsonlLenient<FileEntry>(content);
|
|
536
|
-
}
|
|
429
|
+
#appendWriter(): SessionStorageWriter {
|
|
430
|
+
if (!this.#sessionFile) throw new Error("Cannot open a session writer before a session file exists");
|
|
537
431
|
|
|
538
|
-
|
|
539
|
-
for (let i = entries.length - 1; i >= 0; i--) {
|
|
540
|
-
if (entries[i].type === "compaction") {
|
|
541
|
-
return entries[i] as CompactionEntry;
|
|
542
|
-
}
|
|
543
|
-
}
|
|
544
|
-
return null;
|
|
545
|
-
}
|
|
432
|
+
if (this.#writer?.isOpen()) return this.#writer;
|
|
546
433
|
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
export function buildSessionContext(
|
|
553
|
-
entries: SessionEntry[],
|
|
554
|
-
leafId?: string | null,
|
|
555
|
-
byId?: Map<string, SessionEntry>,
|
|
556
|
-
): SessionContext {
|
|
557
|
-
// Build uuid index if not available
|
|
558
|
-
if (!byId) {
|
|
559
|
-
byId = new Map<string, SessionEntry>();
|
|
560
|
-
for (const entry of entries) {
|
|
561
|
-
byId.set(entry.id, entry);
|
|
562
|
-
}
|
|
434
|
+
this.#writer = this.#storage.openWriter(this.#sessionFile, {
|
|
435
|
+
flags: "a",
|
|
436
|
+
onError: err => this.#noteDiskFailure(err),
|
|
437
|
+
});
|
|
438
|
+
return this.#writer;
|
|
563
439
|
}
|
|
564
440
|
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
if (leafId === null) {
|
|
568
|
-
// Explicitly null - return no messages (navigated to before first entry)
|
|
569
|
-
return {
|
|
570
|
-
messages: [],
|
|
571
|
-
thinkingLevel: "off",
|
|
572
|
-
serviceTier: undefined,
|
|
573
|
-
models: {},
|
|
574
|
-
injectedTtsrRules: [],
|
|
575
|
-
selectedMCPToolNames: [],
|
|
576
|
-
hasPersistedMCPToolSelection: false,
|
|
577
|
-
mode: "none",
|
|
578
|
-
};
|
|
441
|
+
#lineFor(entry: FileEntry): string {
|
|
442
|
+
return `${JSON.stringify(prepareEntryForPersistence(entry, this.#blobs))}\n`;
|
|
579
443
|
}
|
|
580
|
-
|
|
581
|
-
|
|
444
|
+
|
|
445
|
+
#fileBody(): string {
|
|
446
|
+
let body = this.#lineFor(this.#header);
|
|
447
|
+
for (const entry of this.#entries) body += this.#lineFor(entry);
|
|
448
|
+
return body;
|
|
582
449
|
}
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
450
|
+
|
|
451
|
+
#historyContainsAssistantMessage(): boolean {
|
|
452
|
+
return this.#entries.some(isAssistantEntry);
|
|
586
453
|
}
|
|
587
454
|
|
|
588
|
-
|
|
589
|
-
return
|
|
590
|
-
messages: [],
|
|
591
|
-
thinkingLevel: "off",
|
|
592
|
-
serviceTier: undefined,
|
|
593
|
-
models: {},
|
|
594
|
-
injectedTtsrRules: [],
|
|
595
|
-
selectedMCPToolNames: [],
|
|
596
|
-
hasPersistedMCPToolSelection: false,
|
|
597
|
-
mode: "none",
|
|
598
|
-
};
|
|
455
|
+
#shouldHaveSessionFile(): boolean {
|
|
456
|
+
return this.#forceFileCreation || this.#fileIsCurrent || this.#historyContainsAssistantMessage();
|
|
599
457
|
}
|
|
600
458
|
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
// Track whether an explicit `model_change` with role="default" has been
|
|
620
|
-
// seen on this path. Once a user (or the agent itself) records an
|
|
621
|
-
// explicit default, later assistant-message inference must NOT overwrite
|
|
622
|
-
// it: temporary fallbacks (retry fallback, context promotion) and
|
|
623
|
-
// server-side model downgrades both produce assistant messages tagged
|
|
624
|
-
// with the wrong model id, which previously clobbered the user's pick on
|
|
625
|
-
// resume (issue #849).
|
|
626
|
-
let hasExplicitDefaultModel = false;
|
|
627
|
-
|
|
628
|
-
for (const entry of path) {
|
|
629
|
-
if (entry.type === "thinking_level_change") {
|
|
630
|
-
thinkingLevel = entry.thinkingLevel ?? "off";
|
|
631
|
-
} else if (entry.type === "model_change") {
|
|
632
|
-
// New format: { model: "provider/id", role?: string }
|
|
633
|
-
if (entry.model) {
|
|
634
|
-
const role = entry.role ?? "default";
|
|
635
|
-
models[role] = entry.model;
|
|
636
|
-
if (role === "default") {
|
|
637
|
-
hasExplicitDefaultModel = true;
|
|
638
|
-
}
|
|
639
|
-
}
|
|
640
|
-
} else if (entry.type === "service_tier_change") {
|
|
641
|
-
serviceTier = entry.serviceTier ?? undefined;
|
|
642
|
-
} else if (entry.type === "message" && entry.message.role === "assistant") {
|
|
643
|
-
// Legacy fallback: infer default model from assistant messages only
|
|
644
|
-
// when no explicit `model_change` (role=default) entry has been
|
|
645
|
-
// recorded yet. Newer sessions always record an explicit default
|
|
646
|
-
// model_change at the start of the conversation, so this branch is
|
|
647
|
-
// only used to keep pre-model_change sessions working.
|
|
648
|
-
if (!hasExplicitDefaultModel) {
|
|
649
|
-
models.default = `${entry.message.provider}/${entry.message.model}`;
|
|
650
|
-
}
|
|
651
|
-
} else if (entry.type === "compaction") {
|
|
652
|
-
compaction = entry;
|
|
653
|
-
} else if (entry.type === "ttsr_injection") {
|
|
654
|
-
// Collect injected TTSR rule names
|
|
655
|
-
for (const ruleName of entry.injectedRules) {
|
|
656
|
-
injectedTtsrRulesSet.add(ruleName);
|
|
657
|
-
}
|
|
658
|
-
} else if (entry.type === "mcp_tool_selection") {
|
|
659
|
-
selectedMCPToolNames = [...entry.selectedToolNames];
|
|
660
|
-
hasPersistedMCPToolSelection = true;
|
|
661
|
-
} else if (entry.type === "mode_change") {
|
|
662
|
-
mode = entry.mode;
|
|
663
|
-
modeData = entry.data;
|
|
459
|
+
/**
|
|
460
|
+
* Synchronously rewrite the whole file (header + entries) and keep no open
|
|
461
|
+
* writer; the next append re-opens one. `writeTextSync` returns with the
|
|
462
|
+
* bytes in the kernel page cache, so the file is software-crash durable.
|
|
463
|
+
*/
|
|
464
|
+
#rewriteSynchronously(): void {
|
|
465
|
+
if (!this.#persist || !this.#sessionFile) return;
|
|
466
|
+
|
|
467
|
+
try {
|
|
468
|
+
const body = this.#fileBody();
|
|
469
|
+
this.#diskEpoch++;
|
|
470
|
+
this.#diskTail = Promise.resolve();
|
|
471
|
+
this.#closeWriterEventually();
|
|
472
|
+
this.#storage.writeTextSync(this.#sessionFile, body);
|
|
473
|
+
this.#fileIsCurrent = true;
|
|
474
|
+
this.#rewriteRequired = false;
|
|
475
|
+
} catch (err) {
|
|
476
|
+
this.#noteDiskFailure(err);
|
|
664
477
|
}
|
|
665
478
|
}
|
|
666
479
|
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
entry.timestamp,
|
|
687
|
-
entry.attribution,
|
|
688
|
-
),
|
|
689
|
-
);
|
|
690
|
-
} else if (entry.type === "branch_summary" && entry.summary) {
|
|
691
|
-
messages.push(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp));
|
|
692
|
-
}
|
|
693
|
-
};
|
|
694
|
-
|
|
695
|
-
if (compaction) {
|
|
696
|
-
const providerPayload: ProviderPayload | undefined = (() => {
|
|
697
|
-
const candidate = compaction.preserveData?.openaiRemoteCompaction;
|
|
698
|
-
if (!candidate || typeof candidate !== "object") return undefined;
|
|
699
|
-
const remote = candidate as { provider?: unknown; replacementHistory?: unknown };
|
|
700
|
-
if (typeof remote.provider !== "string" || remote.provider.length === 0) return undefined;
|
|
701
|
-
if (!Array.isArray(remote.replacementHistory)) return undefined;
|
|
702
|
-
return {
|
|
703
|
-
type: "openaiResponsesHistory",
|
|
704
|
-
provider: remote.provider,
|
|
705
|
-
items: remote.replacementHistory as Array<Record<string, unknown>>,
|
|
706
|
-
};
|
|
707
|
-
})();
|
|
708
|
-
const remoteReplacementHistory = providerPayload?.items;
|
|
709
|
-
|
|
710
|
-
// Emit summary first
|
|
711
|
-
messages.push(
|
|
712
|
-
createCompactionSummaryMessage(
|
|
713
|
-
compaction.summary,
|
|
714
|
-
compaction.tokensBefore,
|
|
715
|
-
compaction.timestamp,
|
|
716
|
-
compaction.shortSummary,
|
|
717
|
-
providerPayload,
|
|
718
|
-
),
|
|
480
|
+
/**
|
|
481
|
+
* Rewrite the whole file atomically (temp-write + rename, EPERM-safe) on the
|
|
482
|
+
* disk chain. The body is serialized inside the task — after the writer is
|
|
483
|
+
* closed — so entries appended before the task runs are included.
|
|
484
|
+
*/
|
|
485
|
+
async #rewriteAtomically(): Promise<void> {
|
|
486
|
+
if (!this.#persist || !this.#sessionFile) return;
|
|
487
|
+
|
|
488
|
+
const epoch = this.#diskEpoch;
|
|
489
|
+
await this.#scheduleDiskWork(
|
|
490
|
+
async () => {
|
|
491
|
+
await this.#closeWriterHandle();
|
|
492
|
+
const sessionFile = this.#sessionFile;
|
|
493
|
+
if (!sessionFile) return;
|
|
494
|
+
await this.#storage.writeTextAtomic(sessionFile, this.#fileBody());
|
|
495
|
+
this.#fileIsCurrent = true;
|
|
496
|
+
this.#rewriteRequired = false;
|
|
497
|
+
},
|
|
498
|
+
{ epoch },
|
|
719
499
|
);
|
|
500
|
+
}
|
|
720
501
|
|
|
721
|
-
|
|
722
|
-
|
|
502
|
+
#appendToSessionFile(entry: SessionEntry): void {
|
|
503
|
+
if (!this.#persist || !this.#sessionFile) return;
|
|
504
|
+
if (this.#diskFailure) throw this.#diskFailure;
|
|
723
505
|
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
foundFirstKept = true;
|
|
731
|
-
}
|
|
732
|
-
if (foundFirstKept) {
|
|
733
|
-
appendMessage(entry);
|
|
734
|
-
}
|
|
735
|
-
}
|
|
506
|
+
// Lazy gate: a brand-new session is not written until it has an assistant
|
|
507
|
+
// message (or someone forced creation), so sessions that never produce
|
|
508
|
+
// output never create a file.
|
|
509
|
+
if (!this.#shouldHaveSessionFile()) {
|
|
510
|
+
this.#fileIsCurrent = false;
|
|
511
|
+
return;
|
|
736
512
|
}
|
|
737
513
|
|
|
738
|
-
//
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
} else {
|
|
744
|
-
// No compaction - emit all messages, handle branch summaries and custom messages
|
|
745
|
-
for (const entry of path) {
|
|
746
|
-
appendMessage(entry);
|
|
514
|
+
// Cold/divergent: not on disk yet, or in-memory entries diverged from the
|
|
515
|
+
// file → rewrite the whole file synchronously and keep going.
|
|
516
|
+
if (!this.#fileIsCurrent || this.#rewriteRequired) {
|
|
517
|
+
this.#rewriteSynchronously();
|
|
518
|
+
return;
|
|
747
519
|
}
|
|
748
|
-
}
|
|
749
520
|
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
// carries signed `thinking`/`redacted_thinking` is rejected by Anthropic — "thinking
|
|
762
|
-
// blocks in the latest assistant message cannot be modified", and signed thinking
|
|
763
|
-
// replayed out of its original turn shape can also fail signature validation (this
|
|
764
|
-
// bites the handoff/branch-summary request). So when we rewrite a turn we also
|
|
765
|
-
// neutralize its protected reasoning: drop `redactedThinking` (encrypted, no
|
|
766
|
-
// plaintext to keep) and clear `thinking` signatures so the provider encoder
|
|
767
|
-
// downgrades them to plain text (verified accepted by the live API), preserving the
|
|
768
|
-
// visible reasoning while removing the immutability/invalid-signature hazard. Drop a
|
|
769
|
-
// turn left with no content. (Live turns never qualify: their results are persisted
|
|
770
|
-
// on the same path before any context rebuild.)
|
|
771
|
-
const pairedToolResultIds = new Set<string>();
|
|
772
|
-
for (const message of messages) {
|
|
773
|
-
if (message.role === "toolResult") pairedToolResultIds.add(message.toolCallId);
|
|
774
|
-
}
|
|
775
|
-
for (let i = messages.length - 1; i >= 0; i--) {
|
|
776
|
-
const message = messages[i];
|
|
777
|
-
if (message.role !== "assistant") continue;
|
|
778
|
-
const hasDangling = message.content.some(
|
|
779
|
-
block => block.type === "toolCall" && !pairedToolResultIds.has(block.id),
|
|
780
|
-
);
|
|
781
|
-
if (!hasDangling) continue;
|
|
782
|
-
const normalized = message.content
|
|
783
|
-
.filter(
|
|
784
|
-
block =>
|
|
785
|
-
!(block.type === "toolCall" && !pairedToolResultIds.has(block.id)) && block.type !== "redactedThinking",
|
|
786
|
-
)
|
|
787
|
-
.map(block =>
|
|
788
|
-
block.type === "thinking" && block.thinkingSignature ? { ...block, thinkingSignature: undefined } : block,
|
|
789
|
-
);
|
|
790
|
-
if (normalized.length === 0) {
|
|
791
|
-
messages.splice(i, 1);
|
|
792
|
-
} else {
|
|
793
|
-
messages[i] = { ...message, content: normalized };
|
|
521
|
+
// Hot path: append synchronously so the entry is durable the instant this
|
|
522
|
+
// returns (file/memory writers perform the write in-body). Never routed
|
|
523
|
+
// through the async disk chain — durability must hold without a flush().
|
|
524
|
+
// A mid-close writer leaves `#writer` undefined, so `#appendWriter` simply
|
|
525
|
+
// opens a fresh append handle and the entry still lands.
|
|
526
|
+
try {
|
|
527
|
+
void this.#appendWriter()
|
|
528
|
+
.append(this.#lineFor(entry))
|
|
529
|
+
.catch(err => this.#noteDiskFailure(err));
|
|
530
|
+
} catch (err) {
|
|
531
|
+
this.#noteDiskFailure(err);
|
|
794
532
|
}
|
|
795
533
|
}
|
|
796
534
|
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
selectedMCPToolNames,
|
|
804
|
-
hasPersistedMCPToolSelection,
|
|
805
|
-
mode,
|
|
806
|
-
modeData,
|
|
807
|
-
};
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
/**
|
|
811
|
-
* Compute the default session directory for a cwd.
|
|
812
|
-
* Classifies cwd by canonical location so symlink/alias paths resolve to the
|
|
813
|
-
* same home-relative or temp-root directory names as their real targets.
|
|
814
|
-
*/
|
|
815
|
-
function computeDefaultSessionDir(
|
|
816
|
-
cwd: string,
|
|
817
|
-
storage: SessionStorage,
|
|
818
|
-
sessionsRoot: string = getSessionsDir(),
|
|
819
|
-
): string {
|
|
820
|
-
const { encodedDirName, resolvedCwd } = getDefaultSessionDirName(cwd);
|
|
821
|
-
migrateHomeSessionDirs(sessionsRoot);
|
|
822
|
-
const sessionDir = path.join(sessionsRoot, encodedDirName);
|
|
823
|
-
migrateLegacyAbsoluteSessionDir(resolvedCwd, sessionDir, sessionsRoot);
|
|
824
|
-
storage.ensureDirSync(sessionDir);
|
|
825
|
-
return sessionDir;
|
|
826
|
-
}
|
|
535
|
+
#resetToNewSession(options?: NewSessionOptions, forcedSessionFile?: string): string | undefined {
|
|
536
|
+
this.#diskTail = Promise.resolve();
|
|
537
|
+
this.#clearDiskError();
|
|
538
|
+
this.#sessionId = mintSessionId();
|
|
539
|
+
this.#sessionName = undefined;
|
|
540
|
+
this.#titleSource = undefined;
|
|
827
541
|
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
542
|
+
const timestamp = nowIso();
|
|
543
|
+
this.#header = {
|
|
544
|
+
type: "session",
|
|
545
|
+
version: CURRENT_SESSION_VERSION,
|
|
546
|
+
id: this.#sessionId,
|
|
547
|
+
timestamp,
|
|
548
|
+
cwd: this.#cwd,
|
|
549
|
+
parentSession: options?.parentSession,
|
|
550
|
+
};
|
|
831
551
|
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
}
|
|
552
|
+
this.#entries = [];
|
|
553
|
+
this.#index.clear();
|
|
554
|
+
this.#fileIsCurrent = false;
|
|
555
|
+
this.#rewriteRequired = false;
|
|
556
|
+
this.#forceFileCreation = false;
|
|
557
|
+
this.#turnBudgetTotal = null;
|
|
558
|
+
this.#turnBudgetHard = false;
|
|
559
|
+
this.#turnOutputBaseline = 0;
|
|
560
|
+
this.#turnEvalOutput = 0;
|
|
561
|
+
this.#artifactManager = null;
|
|
562
|
+
this.#artifactManagerSessionFile = null;
|
|
563
|
+
this.#adoptedArtifactManager = null;
|
|
564
|
+
this.#inMemoryArtifacts = null;
|
|
565
|
+
this.#inMemoryArtifactCounter = 0;
|
|
847
566
|
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
try {
|
|
857
|
-
const breadcrumbFile = path.join(getTerminalSessionsDir(), terminalId);
|
|
858
|
-
const content = await Bun.file(breadcrumbFile).text();
|
|
859
|
-
const lines = content.trim().split("\n");
|
|
860
|
-
if (lines.length < 2) return null;
|
|
861
|
-
|
|
862
|
-
const breadcrumbCwd = lines[0];
|
|
863
|
-
const sessionFile = lines[1];
|
|
864
|
-
|
|
865
|
-
// Only return if cwd matches (user might have cd'd)
|
|
866
|
-
if (path.resolve(breadcrumbCwd) !== path.resolve(cwd)) return null;
|
|
867
|
-
|
|
868
|
-
// Verify the session file still exists
|
|
869
|
-
const stat = fs.statSync(sessionFile, { throwIfNoEntry: false });
|
|
870
|
-
if (stat?.isFile()) return sessionFile;
|
|
871
|
-
} catch (err) {
|
|
872
|
-
if (!isEnoent(err)) logger.debug("Terminal breadcrumb read failed", { err });
|
|
873
|
-
// Breadcrumb doesn't exist or is corrupt — fall through
|
|
874
|
-
}
|
|
875
|
-
return null;
|
|
876
|
-
}
|
|
567
|
+
if (this.#persist) {
|
|
568
|
+
this.#sessionFile =
|
|
569
|
+
forcedSessionFile ??
|
|
570
|
+
path.join(this.#sessionDir, `${fileSafeTimestamp(timestamp)}_${this.#sessionId}.jsonl`);
|
|
571
|
+
this.#rememberBreadcrumb(this.#cwd, this.#sessionFile);
|
|
572
|
+
} else {
|
|
573
|
+
this.#sessionFile = undefined;
|
|
574
|
+
}
|
|
877
575
|
|
|
878
|
-
|
|
879
|
-
export async function loadEntriesFromFile(
|
|
880
|
-
filePath: string,
|
|
881
|
-
storage: SessionStorage = new FileSessionStorage(),
|
|
882
|
-
): Promise<FileEntry[]> {
|
|
883
|
-
let content: string;
|
|
884
|
-
try {
|
|
885
|
-
content = await storage.readText(filePath);
|
|
886
|
-
} catch (err) {
|
|
887
|
-
if (isEnoent(err)) return [];
|
|
888
|
-
throw err;
|
|
576
|
+
return this.#sessionFile;
|
|
889
577
|
}
|
|
890
|
-
const entries = parseJsonlLenient<FileEntry>(content);
|
|
891
578
|
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
579
|
+
#applyEntries(header: SessionHeader, entries: SessionEntry[]): void {
|
|
580
|
+
this.#header = header;
|
|
581
|
+
this.#entries = entries;
|
|
582
|
+
this.#sessionId = header.id;
|
|
583
|
+
this.#sessionName = header.title;
|
|
584
|
+
this.#titleSource = header.titleSource;
|
|
585
|
+
this.#index.rebuild(entries);
|
|
897
586
|
}
|
|
898
587
|
|
|
899
|
-
|
|
900
|
-
|
|
588
|
+
#freshEntryFields(): { id: string; parentId: string | null; timestamp: string } {
|
|
589
|
+
return {
|
|
590
|
+
id: generateId(this.#index),
|
|
591
|
+
parentId: this.#index.leafId(),
|
|
592
|
+
timestamp: nowIso(),
|
|
593
|
+
};
|
|
594
|
+
}
|
|
901
595
|
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
function hasImageUrl(value: unknown): value is { image_url: string } {
|
|
907
|
-
return typeof value === "object" && value !== null && "image_url" in value && typeof value.image_url === "string";
|
|
908
|
-
}
|
|
596
|
+
#recordEntry(entry: SessionEntry): void {
|
|
597
|
+
this.#entries.push(entry);
|
|
598
|
+
this.#index.insert(entry);
|
|
599
|
+
this.#appendToSessionFile(entry);
|
|
909
600
|
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
601
|
+
const callback = this.onEntryAppended;
|
|
602
|
+
if (callback) {
|
|
603
|
+
try {
|
|
604
|
+
callback(entry);
|
|
605
|
+
} catch (err) {
|
|
606
|
+
logger.warn("collab entry hook failed", { error: String(err) });
|
|
607
|
+
}
|
|
608
|
+
}
|
|
914
609
|
}
|
|
915
610
|
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
value.image_url = await resolveImageDataUrl(blobStore, value.image_url);
|
|
611
|
+
#draftPath(): string | null {
|
|
612
|
+
const artifactsDir = this.getArtifactsDir();
|
|
613
|
+
return artifactsDir ? path.join(artifactsDir, "draft.txt") : null;
|
|
920
614
|
}
|
|
921
615
|
|
|
922
|
-
|
|
923
|
-
|
|
616
|
+
#artifactManagerForSession(): ArtifactManager | null {
|
|
617
|
+
if (this.#adoptedArtifactManager) return this.#adoptedArtifactManager;
|
|
924
618
|
|
|
925
|
-
|
|
926
|
-
|
|
619
|
+
const sessionFile = this.#sessionFile;
|
|
620
|
+
if (!sessionFile) {
|
|
621
|
+
this.#artifactManager = null;
|
|
622
|
+
this.#artifactManagerSessionFile = null;
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
927
625
|
|
|
928
|
-
|
|
929
|
-
if (entry.type === "session") continue;
|
|
626
|
+
if (this.#artifactManager && this.#artifactManagerSessionFile === sessionFile) return this.#artifactManager;
|
|
930
627
|
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
contentArray = entry.content;
|
|
936
|
-
}
|
|
628
|
+
this.#artifactManager = new ArtifactManager(sessionFile.slice(0, -JSONL_SUFFIX_LENGTH));
|
|
629
|
+
this.#artifactManagerSessionFile = sessionFile;
|
|
630
|
+
return this.#artifactManager;
|
|
631
|
+
}
|
|
937
632
|
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
}),
|
|
945
|
-
);
|
|
946
|
-
}
|
|
633
|
+
#notifySessionNameListeners(): void {
|
|
634
|
+
for (const callback of [...this.#sessionNameChangedCallbacks]) {
|
|
635
|
+
try {
|
|
636
|
+
callback();
|
|
637
|
+
} catch (err) {
|
|
638
|
+
logger.warn("SessionManager: session name change hook failed", { error: String(err) });
|
|
947
639
|
}
|
|
948
640
|
}
|
|
641
|
+
}
|
|
949
642
|
|
|
950
|
-
|
|
643
|
+
static #cleanTitle(raw: string): string {
|
|
644
|
+
return raw
|
|
645
|
+
.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
|
|
646
|
+
.replace(/ +/g, " ")
|
|
647
|
+
.trim();
|
|
951
648
|
}
|
|
952
649
|
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
* Lightweight metadata for a session file, used in session picker UI.
|
|
958
|
-
* Uses lazy getters to defer string formatting until actually displayed.
|
|
959
|
-
*/
|
|
960
|
-
function sanitizeSessionName(value: string | undefined): string | undefined {
|
|
961
|
-
if (!value) return undefined;
|
|
962
|
-
const firstLine = value.split(/\r?\n/)[0] ?? "";
|
|
963
|
-
const stripped = firstLine.replace(/[\x00-\x1F\x7F]/g, "");
|
|
964
|
-
const trimmed = stripped.trim();
|
|
965
|
-
return trimmed.length > 0 ? trimmed : undefined;
|
|
966
|
-
}
|
|
967
|
-
|
|
968
|
-
class RecentSessionInfo {
|
|
969
|
-
#fullName: string | undefined;
|
|
970
|
-
#timeAgo: string | undefined;
|
|
971
|
-
readonly #headerTimestamp: string | undefined;
|
|
972
|
-
|
|
973
|
-
constructor(
|
|
974
|
-
readonly path: string,
|
|
975
|
-
readonly mtime: number,
|
|
976
|
-
header: Record<string, unknown>,
|
|
977
|
-
firstPrompt?: string,
|
|
978
|
-
) {
|
|
979
|
-
// Prefer an explicit title, then the first user prompt. The raw UUID `id` is
|
|
980
|
-
// intentionally not used as a fallback: showing it as a "name" is unfriendly and
|
|
981
|
-
// indistinguishable from neighboring sessions in the UI. The friendly fallback is
|
|
982
|
-
// derived lazily in `fullName` from the session timestamp.
|
|
983
|
-
const trystr = (v: unknown) => (typeof v === "string" ? v : undefined);
|
|
984
|
-
this.#fullName = sanitizeSessionName(trystr(header.title)) ?? sanitizeSessionName(firstPrompt);
|
|
985
|
-
this.#headerTimestamp = trystr(header.timestamp);
|
|
986
|
-
}
|
|
987
|
-
|
|
988
|
-
/** Display name. Falls back to a timestamp-based label, never the raw UUID. */
|
|
989
|
-
get fullName(): string {
|
|
990
|
-
if (this.#fullName) return this.#fullName;
|
|
991
|
-
const ts = this.#headerTimestamp ? Date.parse(this.#headerTimestamp) : Number.NaN;
|
|
992
|
-
const date = new Date(Number.isFinite(ts) ? ts : this.mtime);
|
|
993
|
-
const time = date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
|
994
|
-
this.#fullName = `Untitled · ${time}`;
|
|
995
|
-
return this.#fullName;
|
|
996
|
-
}
|
|
997
|
-
|
|
998
|
-
/**
|
|
999
|
-
* Display name without an arbitrary length cap. The renderer is responsible for
|
|
1000
|
-
* width-aware truncation so adjacent fields (e.g. the relative time) stay visible.
|
|
1001
|
-
*/
|
|
1002
|
-
get name(): string {
|
|
1003
|
-
return this.fullName;
|
|
1004
|
-
}
|
|
1005
|
-
|
|
1006
|
-
/** Human-readable relative time (e.g., "2 hours ago") */
|
|
1007
|
-
get timeAgo(): string {
|
|
1008
|
-
if (this.#timeAgo) return this.#timeAgo;
|
|
1009
|
-
this.#timeAgo = formatTimeAgo(new Date(this.mtime));
|
|
1010
|
-
return this.#timeAgo;
|
|
1011
|
-
}
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
|
-
/**
|
|
1015
|
-
* Extracts the text content from a user message entry.
|
|
1016
|
-
* Returns undefined if the entry is not a user message or has no text.
|
|
1017
|
-
*/
|
|
1018
|
-
function extractFirstUserPrompt(entries: Array<Record<string, unknown>>): string | undefined {
|
|
1019
|
-
for (const entry of entries) {
|
|
1020
|
-
if (entry.type !== "message") continue;
|
|
1021
|
-
const message = entry.message as Record<string, unknown> | undefined;
|
|
1022
|
-
if (message?.role !== "user") continue;
|
|
1023
|
-
const content = message.content;
|
|
1024
|
-
if (typeof content === "string") return content;
|
|
1025
|
-
if (Array.isArray(content)) {
|
|
1026
|
-
for (const block of content) {
|
|
1027
|
-
if (typeof block === "object" && block !== null && "text" in block) {
|
|
1028
|
-
const text = (block as { text: unknown }).text;
|
|
1029
|
-
if (typeof text === "string") return text;
|
|
1030
|
-
}
|
|
1031
|
-
}
|
|
1032
|
-
}
|
|
1033
|
-
}
|
|
1034
|
-
return undefined;
|
|
1035
|
-
}
|
|
1036
|
-
|
|
1037
|
-
/**
|
|
1038
|
-
* Promote orphaned `<basename>.jsonl.<snowflake>.bak` backups created by
|
|
1039
|
-
* `#replaceSessionFileAfterEperm` back to their primary path when the primary
|
|
1040
|
-
* is missing. This runs once per session-dir scan, before the main `*.jsonl`
|
|
1041
|
-
* glob, so a crash between the two renames in the EPERM-rewrite path does not
|
|
1042
|
-
* leave the user's last good state stranded outside the loader's view.
|
|
1043
|
-
*
|
|
1044
|
-
* Exported for testing.
|
|
1045
|
-
*/
|
|
1046
|
-
export async function recoverOrphanedBackups(sessionDir: string, storage: SessionStorage): Promise<void> {
|
|
1047
|
-
let backups: string[];
|
|
1048
|
-
try {
|
|
1049
|
-
backups = storage.listFilesSync(sessionDir, "*.bak");
|
|
1050
|
-
} catch {
|
|
1051
|
-
return;
|
|
1052
|
-
}
|
|
1053
|
-
if (backups.length === 0) return;
|
|
1054
|
-
// For each primary path, pick the newest backup (highest mtime) as the recovery source.
|
|
1055
|
-
const candidates = new Map<string, { backup: string; mtimeMs: number }>();
|
|
1056
|
-
for (const backup of backups) {
|
|
1057
|
-
const name = path.basename(backup);
|
|
1058
|
-
// Expect "<primary>.<snowflake>.bak" where <primary> ends in ".jsonl".
|
|
1059
|
-
if (!name.endsWith(".bak")) continue;
|
|
1060
|
-
const trimmed = name.slice(0, -".bak".length);
|
|
1061
|
-
const dotIdx = trimmed.lastIndexOf(".");
|
|
1062
|
-
if (dotIdx <= 0) continue;
|
|
1063
|
-
const primaryName = trimmed.slice(0, dotIdx);
|
|
1064
|
-
if (!primaryName.endsWith(".jsonl")) continue;
|
|
1065
|
-
const primaryPath = path.join(sessionDir, primaryName);
|
|
1066
|
-
let mtimeMs = 0;
|
|
1067
|
-
try {
|
|
1068
|
-
mtimeMs = storage.statSync(backup).mtimeMs;
|
|
1069
|
-
} catch {
|
|
1070
|
-
continue;
|
|
1071
|
-
}
|
|
1072
|
-
const existing = candidates.get(primaryPath);
|
|
1073
|
-
if (!existing || mtimeMs > existing.mtimeMs) {
|
|
1074
|
-
candidates.set(primaryPath, { backup, mtimeMs });
|
|
1075
|
-
}
|
|
1076
|
-
}
|
|
1077
|
-
for (const [primaryPath, { backup }] of candidates) {
|
|
1078
|
-
if (storage.existsSync(primaryPath)) continue;
|
|
1079
|
-
try {
|
|
1080
|
-
await storage.rename(backup, primaryPath);
|
|
1081
|
-
logger.warn("Recovered orphaned session backup", {
|
|
1082
|
-
sessionFile: primaryPath,
|
|
1083
|
-
backupPath: backup,
|
|
1084
|
-
});
|
|
1085
|
-
} catch (err) {
|
|
1086
|
-
logger.warn("Failed to recover orphaned session backup", {
|
|
1087
|
-
sessionFile: primaryPath,
|
|
1088
|
-
backupPath: backup,
|
|
1089
|
-
error: toError(err).message,
|
|
1090
|
-
});
|
|
1091
|
-
}
|
|
1092
|
-
}
|
|
1093
|
-
}
|
|
1094
|
-
|
|
1095
|
-
/**
|
|
1096
|
-
* Reads all session files from the directory and returns them sorted by mtime (newest first).
|
|
1097
|
-
* Uses low-level file I/O to efficiently read only the first 4KB of each file
|
|
1098
|
-
* to extract the JSON header and first user message without loading entire session logs into memory.
|
|
1099
|
-
*/
|
|
1100
|
-
async function getSortedSessions(sessionDir: string, storage: SessionStorage): Promise<RecentSessionInfo[]> {
|
|
1101
|
-
await recoverOrphanedBackups(sessionDir, storage);
|
|
1102
|
-
try {
|
|
1103
|
-
const files: string[] = storage.listFilesSync(sessionDir, "*.jsonl");
|
|
1104
|
-
const sessions: RecentSessionInfo[] = [];
|
|
1105
|
-
await Promise.all(
|
|
1106
|
-
files.map(async (path: string) => {
|
|
1107
|
-
try {
|
|
1108
|
-
const [content] = await storage.readTextSlices(path, 4096, 0);
|
|
1109
|
-
const entries = parseJsonlLenient<Record<string, unknown>>(content);
|
|
1110
|
-
if (entries.length === 0) return;
|
|
1111
|
-
const header = entries[0] as Record<string, unknown>;
|
|
1112
|
-
if (header.type !== "session" || typeof header.id !== "string") return;
|
|
1113
|
-
const mtime = storage.statSync(path).mtimeMs;
|
|
1114
|
-
const firstPrompt = header.title ? undefined : extractFirstUserPrompt(entries);
|
|
1115
|
-
sessions.push(new RecentSessionInfo(path, mtime, header, firstPrompt));
|
|
1116
|
-
} catch {}
|
|
1117
|
-
}),
|
|
1118
|
-
);
|
|
1119
|
-
return sessions.sort((a, b) => b.mtime - a.mtime);
|
|
1120
|
-
} catch {
|
|
1121
|
-
return [];
|
|
1122
|
-
}
|
|
1123
|
-
}
|
|
1124
|
-
|
|
1125
|
-
/** Exported for testing */
|
|
1126
|
-
export async function findMostRecentSession(
|
|
1127
|
-
sessionDir: string,
|
|
1128
|
-
storage: SessionStorage = new FileSessionStorage(),
|
|
1129
|
-
): Promise<string | null> {
|
|
1130
|
-
const sessions = await getSortedSessions(sessionDir, storage);
|
|
1131
|
-
return sessions[0]?.path || null;
|
|
1132
|
-
}
|
|
1133
|
-
|
|
1134
|
-
/** Format a time difference as a human-readable string */
|
|
1135
|
-
function formatTimeAgo(date: Date): string {
|
|
1136
|
-
const now = Date.now();
|
|
1137
|
-
const diffMs = now - date.getTime();
|
|
1138
|
-
const diffMins = Math.floor(diffMs / 60000);
|
|
1139
|
-
const diffHours = Math.floor(diffMs / 3600000);
|
|
1140
|
-
const diffDays = Math.floor(diffMs / 86400000);
|
|
1141
|
-
|
|
1142
|
-
if (diffMins < 1) return "just now";
|
|
1143
|
-
if (diffMins < 60) return `${diffMins}m ago`;
|
|
1144
|
-
if (diffHours < 24) return `${diffHours}h ago`;
|
|
1145
|
-
if (diffDays < 7) return `${diffDays}d ago`;
|
|
1146
|
-
return date.toLocaleDateString();
|
|
1147
|
-
}
|
|
1148
|
-
|
|
1149
|
-
const MAX_PERSIST_CHARS = 500_000;
|
|
1150
|
-
const TRUNCATION_NOTICE = "\n\n[Session persistence truncated large content]";
|
|
1151
|
-
/** Minimum base64 length to externalize to blob store (skip tiny inline images) */
|
|
1152
|
-
const BLOB_EXTERNALIZE_THRESHOLD = 1024;
|
|
1153
|
-
const TEXT_CONTENT_KEY = "content";
|
|
1154
|
-
|
|
1155
|
-
/**
|
|
1156
|
-
* Recursively truncate large strings in an object for session persistence.
|
|
1157
|
-
* - Truncates any oversized string fields (key-agnostic)
|
|
1158
|
-
* - Replaces oversized image blocks with text notices
|
|
1159
|
-
* - Updates lineCount when content is truncated
|
|
1160
|
-
* - Returns original object if no changes needed (structural sharing)
|
|
1161
|
-
*/
|
|
1162
|
-
function truncateString(value: string, maxLength: number): string {
|
|
1163
|
-
if (value.length <= maxLength) return value;
|
|
1164
|
-
let truncated = value.slice(0, maxLength);
|
|
1165
|
-
if (truncated.length > 0) {
|
|
1166
|
-
const last = truncated.charCodeAt(truncated.length - 1);
|
|
1167
|
-
if (last >= 0xd800 && last <= 0xdbff) {
|
|
1168
|
-
truncated = truncated.slice(0, -1);
|
|
1169
|
-
}
|
|
1170
|
-
}
|
|
1171
|
-
return truncated;
|
|
1172
|
-
}
|
|
1173
|
-
|
|
1174
|
-
function isImageBlock(value: unknown): value is { type: "image"; data: string; mimeType?: string } {
|
|
1175
|
-
return (
|
|
1176
|
-
typeof value === "object" &&
|
|
1177
|
-
value !== null &&
|
|
1178
|
-
"type" in value &&
|
|
1179
|
-
(value as { type?: string }).type === "image" &&
|
|
1180
|
-
"data" in value &&
|
|
1181
|
-
typeof (value as { data?: string }).data === "string"
|
|
1182
|
-
);
|
|
1183
|
-
}
|
|
1184
|
-
|
|
1185
|
-
async function truncateForPersistence(obj: FileEntry, blobStore: BlobStore, key?: string): Promise<FileEntry>;
|
|
1186
|
-
async function truncateForPersistence(obj: string, blobStore: BlobStore, key?: string): Promise<string>;
|
|
1187
|
-
async function truncateForPersistence(obj: unknown[], blobStore: BlobStore, key?: string): Promise<unknown[]>;
|
|
1188
|
-
async function truncateForPersistence(obj: object, blobStore: BlobStore, key?: string): Promise<object>;
|
|
1189
|
-
async function truncateForPersistence(
|
|
1190
|
-
obj: null | undefined,
|
|
1191
|
-
blobStore: BlobStore,
|
|
1192
|
-
key?: string,
|
|
1193
|
-
): Promise<null | undefined>;
|
|
1194
|
-
async function truncateForPersistence(obj: unknown, blobStore: BlobStore, key?: string): Promise<unknown> {
|
|
1195
|
-
if (obj === null || obj === undefined) return obj;
|
|
1196
|
-
|
|
1197
|
-
if (typeof obj === "string") {
|
|
1198
|
-
if (key === "image_url" && isImageDataUrl(obj)) {
|
|
1199
|
-
return externalizeImageDataUrl(blobStore, obj);
|
|
1200
|
-
}
|
|
1201
|
-
|
|
1202
|
-
if (obj.length > MAX_PERSIST_CHARS) {
|
|
1203
|
-
// Cryptographic signatures must be preserved exactly or cleared entirely — never truncated.
|
|
1204
|
-
// Truncation would produce an invalid signature that the API rejects.
|
|
1205
|
-
if (key === "thinkingSignature" || key === "thoughtSignature" || key === "textSignature") {
|
|
1206
|
-
return "";
|
|
1207
|
-
}
|
|
1208
|
-
|
|
1209
|
-
const limit = Math.max(0, MAX_PERSIST_CHARS - TRUNCATION_NOTICE.length);
|
|
1210
|
-
return `${truncateString(obj, limit)}${TRUNCATION_NOTICE}`;
|
|
1211
|
-
}
|
|
1212
|
-
|
|
1213
|
-
return obj;
|
|
1214
|
-
}
|
|
1215
|
-
|
|
1216
|
-
if (Array.isArray(obj)) {
|
|
1217
|
-
let changed = false;
|
|
1218
|
-
const result = await Promise.all(
|
|
1219
|
-
obj.map(async item => {
|
|
1220
|
-
// Special handling: compress oversized images while preserving shape
|
|
1221
|
-
if (key === TEXT_CONTENT_KEY && isImageBlock(item)) {
|
|
1222
|
-
if (!isBlobRef(item.data) && item.data.length >= BLOB_EXTERNALIZE_THRESHOLD) {
|
|
1223
|
-
changed = true;
|
|
1224
|
-
const blobRef = await externalizeImageData(blobStore, item.data, item.mimeType);
|
|
1225
|
-
return { ...item, data: blobRef };
|
|
1226
|
-
}
|
|
1227
|
-
}
|
|
1228
|
-
|
|
1229
|
-
const newItem = await truncateForPersistence(item, blobStore, key);
|
|
1230
|
-
if (newItem !== item) changed = true;
|
|
1231
|
-
return newItem;
|
|
1232
|
-
}),
|
|
1233
|
-
);
|
|
1234
|
-
return changed ? result : obj;
|
|
1235
|
-
}
|
|
1236
|
-
|
|
1237
|
-
if (typeof obj === "object") {
|
|
1238
|
-
let changed = false;
|
|
1239
|
-
const entries: Array<readonly [string, unknown]> = await Promise.all(
|
|
1240
|
-
Object.entries(obj).flatMap(([childKey, value]) => {
|
|
1241
|
-
// Strip transient/redundant properties that shouldn't be persisted.
|
|
1242
|
-
// - partialJson: streaming accumulator for tool call JSON parsing
|
|
1243
|
-
// - jsonlEvents: raw subprocess streaming events (already saved to artifact files)
|
|
1244
|
-
if (childKey === "partialJson" || childKey === "jsonlEvents") {
|
|
1245
|
-
changed = true;
|
|
1246
|
-
return [];
|
|
1247
|
-
}
|
|
1248
|
-
|
|
1249
|
-
return [
|
|
1250
|
-
(async () => {
|
|
1251
|
-
const newValue = await truncateForPersistence(value, blobStore, childKey);
|
|
1252
|
-
if (newValue !== value) changed = true;
|
|
1253
|
-
return [childKey, newValue] as const;
|
|
1254
|
-
})(),
|
|
1255
|
-
];
|
|
1256
|
-
}),
|
|
1257
|
-
);
|
|
1258
|
-
|
|
1259
|
-
if (!changed) return obj;
|
|
1260
|
-
|
|
1261
|
-
const contentEntry = entries.find(([childKey]) => childKey === "content");
|
|
1262
|
-
const lineCountEntry = entries.find(([childKey]) => childKey === "lineCount");
|
|
1263
|
-
if (
|
|
1264
|
-
contentEntry &&
|
|
1265
|
-
typeof contentEntry[1] === "string" &&
|
|
1266
|
-
lineCountEntry &&
|
|
1267
|
-
typeof lineCountEntry[1] === "number"
|
|
1268
|
-
) {
|
|
1269
|
-
const content = contentEntry[1];
|
|
1270
|
-
const updatedEntries = entries.map(([childKey, value]) =>
|
|
1271
|
-
childKey === "lineCount" ? ([childKey, content.split("\n").length] as const) : ([childKey, value] as const),
|
|
1272
|
-
);
|
|
1273
|
-
return Object.fromEntries(updatedEntries);
|
|
1274
|
-
}
|
|
1275
|
-
return Object.fromEntries(entries);
|
|
1276
|
-
}
|
|
1277
|
-
|
|
1278
|
-
return obj;
|
|
1279
|
-
}
|
|
1280
|
-
|
|
1281
|
-
async function prepareEntryForPersistence(entry: FileEntry, blobStore: BlobStore): Promise<FileEntry> {
|
|
1282
|
-
return truncateForPersistence(entry, blobStore);
|
|
1283
|
-
}
|
|
1284
|
-
|
|
1285
|
-
/**
|
|
1286
|
-
* Synchronous variant of {@link truncateForPersistence}.
|
|
1287
|
-
*
|
|
1288
|
-
* The async version's overhead — `Promise.all` over `Object.entries`/`Array.prototype.map`,
|
|
1289
|
-
* one microtask hop per nested node — is pure waste for entries without image blobs
|
|
1290
|
-
* (the vast majority). The fast path runs in one synchronous tick so an OOM/SIGKILL
|
|
1291
|
-
* landing right after `_persist` returns cannot lose the entry. Image externalization
|
|
1292
|
-
* still happens, but via the synchronous blob-store path (`fs.writeFileSync`), so the
|
|
1293
|
-
* blob bytes are in the kernel page cache before the JSONL line referencing them is
|
|
1294
|
-
* written.
|
|
1295
|
-
*/
|
|
1296
|
-
function truncateForPersistenceSync(obj: unknown, blobStore: BlobStore, key?: string): unknown {
|
|
1297
|
-
if (obj === null || obj === undefined) return obj;
|
|
1298
|
-
|
|
1299
|
-
if (typeof obj === "string") {
|
|
1300
|
-
if (key === "image_url" && isImageDataUrl(obj)) {
|
|
1301
|
-
return externalizeImageDataUrlSync(blobStore, obj);
|
|
1302
|
-
}
|
|
1303
|
-
if (obj.length > MAX_PERSIST_CHARS) {
|
|
1304
|
-
if (key === "thinkingSignature" || key === "thoughtSignature" || key === "textSignature") {
|
|
1305
|
-
return "";
|
|
1306
|
-
}
|
|
1307
|
-
const limit = Math.max(0, MAX_PERSIST_CHARS - TRUNCATION_NOTICE.length);
|
|
1308
|
-
return `${truncateString(obj, limit)}${TRUNCATION_NOTICE}`;
|
|
1309
|
-
}
|
|
1310
|
-
return obj;
|
|
1311
|
-
}
|
|
1312
|
-
|
|
1313
|
-
if (Array.isArray(obj)) {
|
|
1314
|
-
let changed = false;
|
|
1315
|
-
const result: unknown[] = new Array(obj.length);
|
|
1316
|
-
for (let i = 0; i < obj.length; i++) {
|
|
1317
|
-
const item = obj[i];
|
|
1318
|
-
if (
|
|
1319
|
-
key === TEXT_CONTENT_KEY &&
|
|
1320
|
-
isImageBlock(item) &&
|
|
1321
|
-
!isBlobRef(item.data) &&
|
|
1322
|
-
item.data.length >= BLOB_EXTERNALIZE_THRESHOLD
|
|
1323
|
-
) {
|
|
1324
|
-
changed = true;
|
|
1325
|
-
result[i] = { ...item, data: externalizeImageDataSync(blobStore, item.data, item.mimeType) };
|
|
1326
|
-
continue;
|
|
1327
|
-
}
|
|
1328
|
-
const newItem = truncateForPersistenceSync(item, blobStore, key);
|
|
1329
|
-
if (newItem !== item) changed = true;
|
|
1330
|
-
result[i] = newItem;
|
|
1331
|
-
}
|
|
1332
|
-
return changed ? result : obj;
|
|
1333
|
-
}
|
|
1334
|
-
|
|
1335
|
-
if (typeof obj === "object") {
|
|
1336
|
-
let changed = false;
|
|
1337
|
-
const entries: Array<readonly [string, unknown]> = [];
|
|
1338
|
-
for (const [childKey, value] of Object.entries(obj)) {
|
|
1339
|
-
if (childKey === "partialJson" || childKey === "jsonlEvents") {
|
|
1340
|
-
changed = true;
|
|
1341
|
-
continue;
|
|
1342
|
-
}
|
|
1343
|
-
const newValue = truncateForPersistenceSync(value, blobStore, childKey);
|
|
1344
|
-
if (newValue !== value) changed = true;
|
|
1345
|
-
entries.push([childKey, newValue]);
|
|
1346
|
-
}
|
|
1347
|
-
if (!changed) return obj;
|
|
1348
|
-
|
|
1349
|
-
const contentEntry = entries.find(([childKey]) => childKey === "content");
|
|
1350
|
-
const lineCountEntry = entries.find(([childKey]) => childKey === "lineCount");
|
|
1351
|
-
if (
|
|
1352
|
-
contentEntry &&
|
|
1353
|
-
typeof contentEntry[1] === "string" &&
|
|
1354
|
-
lineCountEntry &&
|
|
1355
|
-
typeof lineCountEntry[1] === "number"
|
|
1356
|
-
) {
|
|
1357
|
-
const content = contentEntry[1];
|
|
1358
|
-
const updatedEntries = entries.map(([childKey, value]) =>
|
|
1359
|
-
childKey === "lineCount" ? ([childKey, content.split("\n").length] as const) : ([childKey, value] as const),
|
|
1360
|
-
);
|
|
1361
|
-
return Object.fromEntries(updatedEntries);
|
|
1362
|
-
}
|
|
1363
|
-
return Object.fromEntries(entries);
|
|
1364
|
-
}
|
|
1365
|
-
|
|
1366
|
-
return obj;
|
|
1367
|
-
}
|
|
1368
|
-
|
|
1369
|
-
function prepareEntryForPersistenceSync(entry: FileEntry, blobStore: BlobStore): FileEntry {
|
|
1370
|
-
return truncateForPersistenceSync(entry, blobStore) as FileEntry;
|
|
1371
|
-
}
|
|
1372
|
-
|
|
1373
|
-
class NdjsonFileWriter {
|
|
1374
|
-
#writer: SessionStorageWriter;
|
|
1375
|
-
#closed = false;
|
|
1376
|
-
#closing = false;
|
|
1377
|
-
#error: Error | undefined;
|
|
1378
|
-
#pendingWrites: Promise<void> = Promise.resolve();
|
|
1379
|
-
#onError: ((err: Error) => void) | undefined;
|
|
1380
|
-
|
|
1381
|
-
constructor(storage: SessionStorage, path: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }) {
|
|
1382
|
-
this.#onError = options?.onError;
|
|
1383
|
-
this.#writer = storage.openWriter(path, {
|
|
1384
|
-
flags: options?.flags ?? "a",
|
|
1385
|
-
onError: (err: Error) => this.#recordError(err),
|
|
1386
|
-
});
|
|
1387
|
-
}
|
|
1388
|
-
|
|
1389
|
-
#recordError(err: unknown): Error {
|
|
1390
|
-
const writeErr = toError(err);
|
|
1391
|
-
if (!this.#error) this.#error = writeErr;
|
|
1392
|
-
this.#onError?.(writeErr);
|
|
1393
|
-
return writeErr;
|
|
1394
|
-
}
|
|
1395
|
-
|
|
1396
|
-
#enqueue(task: () => Promise<void>): Promise<void> {
|
|
1397
|
-
const run = async () => {
|
|
1398
|
-
if (this.#error) throw this.#error;
|
|
1399
|
-
await task();
|
|
1400
|
-
};
|
|
1401
|
-
const next = this.#pendingWrites.then(run);
|
|
1402
|
-
void next.catch((err: unknown) => {
|
|
1403
|
-
if (!this.#error) this.#error = toError(err);
|
|
1404
|
-
});
|
|
1405
|
-
this.#pendingWrites = next;
|
|
1406
|
-
return next;
|
|
1407
|
-
}
|
|
1408
|
-
|
|
1409
|
-
async #writeLine(line: string): Promise<void> {
|
|
1410
|
-
if (this.#error) throw this.#error;
|
|
1411
|
-
try {
|
|
1412
|
-
await this.#writer.writeLine(line);
|
|
1413
|
-
} catch (err) {
|
|
1414
|
-
throw this.#recordError(err);
|
|
1415
|
-
}
|
|
1416
|
-
}
|
|
1417
|
-
|
|
1418
|
-
/** Queue a write. Returns a promise so callers can await if needed. */
|
|
1419
|
-
write(entry: FileEntry): Promise<void> {
|
|
1420
|
-
if (this.#closed || this.#closing) throw new Error("Writer closed");
|
|
1421
|
-
if (this.#error) throw this.#error;
|
|
1422
|
-
const line = `${JSON.stringify(entry)}\n`;
|
|
1423
|
-
return this.#enqueue(() => this.#writeLine(line));
|
|
1424
|
-
}
|
|
1425
|
-
|
|
1426
|
-
/**
|
|
1427
|
-
* Synchronously serialize and append the entry. Returns once `fs.writeSync` has handed
|
|
1428
|
-
* the bytes to the kernel page cache — durable across OOM/SIGKILL even before fsync.
|
|
1429
|
-
*
|
|
1430
|
-
* Callers MUST NOT mix this with pending async `write()` calls on the same writer:
|
|
1431
|
-
* the async path is queued through `#pendingWrites`, but this method bypasses the
|
|
1432
|
-
* queue. Use only when no concurrent async write is in flight (the session-manager
|
|
1433
|
-
* persist path enforces this via `#flushed`/`#needsFullRewriteOnNextPersist`).
|
|
1434
|
-
*/
|
|
1435
|
-
writeSync(entry: FileEntry): void {
|
|
1436
|
-
if (this.#closed || this.#closing) throw new Error("Writer closed");
|
|
1437
|
-
if (this.#error) throw this.#error;
|
|
1438
|
-
const line = `${JSON.stringify(entry)}\n`;
|
|
1439
|
-
try {
|
|
1440
|
-
this.#writer.writeLineSync(line);
|
|
1441
|
-
} catch (err) {
|
|
1442
|
-
throw this.#recordError(err);
|
|
1443
|
-
}
|
|
1444
|
-
}
|
|
1445
|
-
|
|
1446
|
-
/** Flush all buffered data to disk. Waits for all queued writes. */
|
|
1447
|
-
async flush(): Promise<void> {
|
|
1448
|
-
if (this.#closed) return;
|
|
1449
|
-
if (this.#error) throw this.#error;
|
|
1450
|
-
|
|
1451
|
-
await this.#enqueue(async () => {});
|
|
1452
|
-
|
|
1453
|
-
if (this.#error) throw this.#error;
|
|
1454
|
-
|
|
1455
|
-
try {
|
|
1456
|
-
await this.#writer.flush();
|
|
1457
|
-
} catch (err) {
|
|
1458
|
-
throw this.#recordError(err);
|
|
1459
|
-
}
|
|
1460
|
-
}
|
|
1461
|
-
|
|
1462
|
-
/** Sync data to persistent storage. */
|
|
1463
|
-
async fsync(): Promise<void> {
|
|
1464
|
-
if (this.#closed) return;
|
|
1465
|
-
if (this.#error) throw this.#error;
|
|
1466
|
-
try {
|
|
1467
|
-
await this.#writer.fsync();
|
|
1468
|
-
} catch (err) {
|
|
1469
|
-
throw this.#recordError(err);
|
|
1470
|
-
}
|
|
1471
|
-
}
|
|
1472
|
-
|
|
1473
|
-
/** Close the writer, flushing all data. */
|
|
1474
|
-
async close(): Promise<void> {
|
|
1475
|
-
if (this.#closed || this.#closing) return;
|
|
1476
|
-
this.#closing = true;
|
|
1477
|
-
|
|
1478
|
-
let closeError: Error | undefined;
|
|
1479
|
-
try {
|
|
1480
|
-
await this.flush();
|
|
1481
|
-
} catch (err) {
|
|
1482
|
-
closeError = toError(err);
|
|
1483
|
-
}
|
|
1484
|
-
|
|
1485
|
-
try {
|
|
1486
|
-
await this.#pendingWrites;
|
|
1487
|
-
} catch (err) {
|
|
1488
|
-
if (!closeError) closeError = toError(err);
|
|
1489
|
-
}
|
|
1490
|
-
|
|
1491
|
-
try {
|
|
1492
|
-
await this.#writer.close();
|
|
1493
|
-
} catch (err) {
|
|
1494
|
-
const endErr = this.#recordError(err);
|
|
1495
|
-
if (!closeError) closeError = endErr;
|
|
1496
|
-
}
|
|
1497
|
-
|
|
1498
|
-
this.#closed = true;
|
|
1499
|
-
|
|
1500
|
-
if (!closeError && this.#error) closeError = this.#error;
|
|
1501
|
-
if (closeError) throw closeError;
|
|
1502
|
-
}
|
|
1503
|
-
|
|
1504
|
-
/** Check if there's a stored error. */
|
|
1505
|
-
getError(): Error | undefined {
|
|
1506
|
-
return this.#error;
|
|
1507
|
-
}
|
|
1508
|
-
|
|
1509
|
-
/** True while the writer accepts new writes (not closing or closed). */
|
|
1510
|
-
isOpen(): boolean {
|
|
1511
|
-
return !this.#closed && !this.#closing;
|
|
1512
|
-
}
|
|
1513
|
-
}
|
|
1514
|
-
|
|
1515
|
-
/** Get recent sessions for display in welcome screen */
|
|
1516
|
-
export async function getRecentSessions(
|
|
1517
|
-
sessionDir: string,
|
|
1518
|
-
limit = 3,
|
|
1519
|
-
storage: SessionStorage = new FileSessionStorage(),
|
|
1520
|
-
): Promise<RecentSessionInfo[]> {
|
|
1521
|
-
const sessions = await getSortedSessions(sessionDir, storage);
|
|
1522
|
-
return sessions.slice(0, limit);
|
|
1523
|
-
}
|
|
1524
|
-
|
|
1525
|
-
/**
|
|
1526
|
-
* Manages conversation sessions as append-only trees stored in JSONL files.
|
|
1527
|
-
*
|
|
1528
|
-
* Each session entry has an id and parentId forming a tree structure. The "leaf"
|
|
1529
|
-
* pointer tracks the current position. Appending creates a child of the current leaf.
|
|
1530
|
-
* Branching moves the leaf to an earlier entry, allowing new branches without
|
|
1531
|
-
* modifying history.
|
|
1532
|
-
*
|
|
1533
|
-
* Use buildSessionContext() to get the resolved message list for the LLM, which
|
|
1534
|
-
* handles compaction summaries and follows the path from root to current leaf.
|
|
1535
|
-
*/
|
|
1536
|
-
export interface UsageStatistics {
|
|
1537
|
-
input: number;
|
|
1538
|
-
output: number;
|
|
1539
|
-
cacheRead: number;
|
|
1540
|
-
cacheWrite: number;
|
|
1541
|
-
premiumRequests: number;
|
|
1542
|
-
cost: number;
|
|
1543
|
-
}
|
|
1544
|
-
|
|
1545
|
-
function getTaskToolUsage(details: unknown): Usage | undefined {
|
|
1546
|
-
if (!details || typeof details !== "object") return undefined;
|
|
1547
|
-
const record = details as Record<string, unknown>;
|
|
1548
|
-
const usage = record.usage;
|
|
1549
|
-
if (!usage || typeof usage !== "object") return undefined;
|
|
1550
|
-
return usage as Usage;
|
|
1551
|
-
}
|
|
1552
|
-
|
|
1553
|
-
function extractTextFromContent(content: Message["content"]): string {
|
|
1554
|
-
if (typeof content === "string") return content;
|
|
1555
|
-
return content
|
|
1556
|
-
.filter((block): block is TextContent => block.type === "text")
|
|
1557
|
-
.map(block => block.text)
|
|
1558
|
-
.join(" ");
|
|
1559
|
-
}
|
|
1560
|
-
|
|
1561
|
-
const SESSION_LIST_PREFIX_BYTES = 4096;
|
|
1562
|
-
/**
|
|
1563
|
-
* Tail window read to derive {@link SessionStatus}. Large enough to capture a
|
|
1564
|
-
* typical final assistant turn (thinking + text); when the final message exceeds
|
|
1565
|
-
* it the status falls back to `unknown` rather than misreporting.
|
|
1566
|
-
*/
|
|
1567
|
-
const SESSION_LIST_SUFFIX_BYTES = 32_768;
|
|
1568
|
-
const SESSION_LIST_PARALLEL_THRESHOLD = 64;
|
|
1569
|
-
const SESSION_LIST_MAX_WORKERS = 16;
|
|
1570
|
-
|
|
1571
|
-
/**
|
|
1572
|
-
* Derive a {@link SessionStatus} from a tail window of a session file. Entries are
|
|
1573
|
-
* newline-terminated on write, so within the window only the first line can be a
|
|
1574
|
-
* partial fragment — it simply fails to parse and is skipped. We walk backwards to
|
|
1575
|
-
* the last `message` entry and classify by its role / stop reason.
|
|
1576
|
-
*/
|
|
1577
|
-
function deriveSessionStatus(suffix: string): SessionStatus {
|
|
1578
|
-
if (!suffix) return "unknown";
|
|
1579
|
-
const lines = suffix.split("\n");
|
|
1580
|
-
for (let i = lines.length - 1; i >= 0; i--) {
|
|
1581
|
-
const line = lines[i];
|
|
1582
|
-
// Every persisted entry is `JSON.stringify(obj)` → starts with `{`. This
|
|
1583
|
-
// cheaply rejects blank lines and the leading partial fragment without
|
|
1584
|
-
// attempting to parse a multi-KB tail of a truncated line.
|
|
1585
|
-
if (line.charCodeAt(0) !== 123) continue;
|
|
1586
|
-
let entry: { type?: string; message?: TailMessage };
|
|
1587
|
-
try {
|
|
1588
|
-
entry = JSON.parse(line);
|
|
1589
|
-
} catch {
|
|
1590
|
-
continue;
|
|
1591
|
-
}
|
|
1592
|
-
if (entry.type === "message" && entry.message) {
|
|
1593
|
-
return statusFromTailMessage(entry.message);
|
|
1594
|
-
}
|
|
1595
|
-
}
|
|
1596
|
-
return "unknown";
|
|
1597
|
-
}
|
|
1598
|
-
|
|
1599
|
-
interface TailMessage {
|
|
1600
|
-
role?: string;
|
|
1601
|
-
stopReason?: string;
|
|
1602
|
-
content?: unknown;
|
|
1603
|
-
}
|
|
1604
|
-
|
|
1605
|
-
function isToolCallBlock(block: unknown): boolean {
|
|
1606
|
-
return typeof block === "object" && block !== null && (block as { type?: unknown }).type === "toolCall";
|
|
1607
|
-
}
|
|
1608
|
-
|
|
1609
|
-
function statusFromTailMessage(message: TailMessage): SessionStatus {
|
|
1610
|
-
switch (message.role) {
|
|
1611
|
-
case "assistant": {
|
|
1612
|
-
switch (message.stopReason) {
|
|
1613
|
-
case "error":
|
|
1614
|
-
return "error";
|
|
1615
|
-
case "aborted":
|
|
1616
|
-
return "aborted";
|
|
1617
|
-
case "length":
|
|
1618
|
-
return "interrupted";
|
|
1619
|
-
}
|
|
1620
|
-
// A turn that ends without unanswered tool calls means the agent yielded
|
|
1621
|
-
// control back to the user — complete. Trailing tool calls (no tool
|
|
1622
|
-
// results after) mean the loop was cut off before running them.
|
|
1623
|
-
const content = message.content;
|
|
1624
|
-
if (Array.isArray(content) && content.some(isToolCallBlock)) return "interrupted";
|
|
1625
|
-
return "complete";
|
|
1626
|
-
}
|
|
1627
|
-
case "toolResult":
|
|
1628
|
-
// Tools ran but the agent never produced the following assistant turn.
|
|
1629
|
-
return "interrupted";
|
|
1630
|
-
case "user":
|
|
1631
|
-
// User message with no assistant reply persisted after it.
|
|
1632
|
-
return "pending";
|
|
1633
|
-
default:
|
|
1634
|
-
return "unknown";
|
|
1635
|
-
}
|
|
1636
|
-
}
|
|
1637
|
-
|
|
1638
|
-
function decodeJsonStringFragment(value: string): string {
|
|
1639
|
-
const safeValue = value.endsWith("\\") ? value.slice(0, -1) : value;
|
|
1640
|
-
try {
|
|
1641
|
-
return JSON.parse(`"${safeValue}"`) as string;
|
|
1642
|
-
} catch {
|
|
1643
|
-
return safeValue
|
|
1644
|
-
.replace(/\\n/g, "\n")
|
|
1645
|
-
.replace(/\\r/g, "\r")
|
|
1646
|
-
.replace(/\\t/g, "\t")
|
|
1647
|
-
.replace(/\\"/g, '"')
|
|
1648
|
-
.replace(/\\\\/g, "\\");
|
|
1649
|
-
}
|
|
1650
|
-
}
|
|
1651
|
-
|
|
1652
|
-
function extractStringProperty(source: string, name: string, startIndex = 0): string | undefined {
|
|
1653
|
-
const propertyIndex = source.indexOf(`"${name}"`, startIndex);
|
|
1654
|
-
if (propertyIndex === -1) return undefined;
|
|
1655
|
-
|
|
1656
|
-
const colonIndex = source.indexOf(":", propertyIndex + name.length + 2);
|
|
1657
|
-
if (colonIndex === -1) return undefined;
|
|
1658
|
-
|
|
1659
|
-
let valueIndex = colonIndex + 1;
|
|
1660
|
-
while (valueIndex < source.length) {
|
|
1661
|
-
const char = source.charCodeAt(valueIndex);
|
|
1662
|
-
if (char !== 32 && char !== 9 && char !== 10 && char !== 13) break;
|
|
1663
|
-
valueIndex++;
|
|
1664
|
-
}
|
|
1665
|
-
if (source.charCodeAt(valueIndex) !== 34) return undefined;
|
|
1666
|
-
|
|
1667
|
-
const valueStart = valueIndex + 1;
|
|
1668
|
-
let escaped = false;
|
|
1669
|
-
for (let i = valueStart; i < source.length; i++) {
|
|
1670
|
-
const char = source.charCodeAt(i);
|
|
1671
|
-
if (escaped) {
|
|
1672
|
-
escaped = false;
|
|
1673
|
-
continue;
|
|
1674
|
-
}
|
|
1675
|
-
if (char === 92) {
|
|
1676
|
-
escaped = true;
|
|
1677
|
-
continue;
|
|
1678
|
-
}
|
|
1679
|
-
if (char === 34) {
|
|
1680
|
-
return decodeJsonStringFragment(source.slice(valueStart, i));
|
|
1681
|
-
}
|
|
1682
|
-
}
|
|
1683
|
-
|
|
1684
|
-
return decodeJsonStringFragment(source.slice(valueStart));
|
|
1685
|
-
}
|
|
1686
|
-
|
|
1687
|
-
function countMessageMarkers(content: string): number {
|
|
1688
|
-
let count = 0;
|
|
1689
|
-
let index = 0;
|
|
1690
|
-
while (index < content.length) {
|
|
1691
|
-
const typeIndex = content.indexOf('"type"', index);
|
|
1692
|
-
if (typeIndex === -1) break;
|
|
1693
|
-
const colonIndex = content.indexOf(":", typeIndex + 6);
|
|
1694
|
-
if (colonIndex === -1) break;
|
|
1695
|
-
const type = extractStringProperty(content, "type", typeIndex);
|
|
1696
|
-
if (type === "message") count++;
|
|
1697
|
-
index = colonIndex + 1;
|
|
1698
|
-
}
|
|
1699
|
-
return count;
|
|
1700
|
-
}
|
|
1701
|
-
|
|
1702
|
-
function extractFirstUserMessageFromPrefix(content: string): string | undefined {
|
|
1703
|
-
const roleIndex = content.indexOf('"role"');
|
|
1704
|
-
if (roleIndex === -1) return undefined;
|
|
1705
|
-
|
|
1706
|
-
let index = roleIndex;
|
|
1707
|
-
while (index !== -1) {
|
|
1708
|
-
const role = extractStringProperty(content, "role", index);
|
|
1709
|
-
if (role === "user") {
|
|
1710
|
-
return extractStringProperty(content, "content", index) ?? extractStringProperty(content, "text", index);
|
|
1711
|
-
}
|
|
1712
|
-
index = content.indexOf('"role"', index + 6);
|
|
1713
|
-
}
|
|
1714
|
-
|
|
1715
|
-
return undefined;
|
|
1716
|
-
}
|
|
1717
|
-
|
|
1718
|
-
interface SessionListHeader {
|
|
1719
|
-
type: "session";
|
|
1720
|
-
id: string;
|
|
1721
|
-
cwd?: string;
|
|
1722
|
-
title?: string;
|
|
1723
|
-
parentSession?: string;
|
|
1724
|
-
timestamp?: string;
|
|
1725
|
-
}
|
|
1726
|
-
|
|
1727
|
-
function parseSessionListHeader(
|
|
1728
|
-
content: string,
|
|
1729
|
-
entries: Array<Record<string, unknown>>,
|
|
1730
|
-
): SessionListHeader | undefined {
|
|
1731
|
-
const parsedHeader = entries[0];
|
|
1732
|
-
if (parsedHeader?.type === "session" && typeof parsedHeader.id === "string") {
|
|
1733
|
-
return {
|
|
1734
|
-
type: "session",
|
|
1735
|
-
id: parsedHeader.id,
|
|
1736
|
-
cwd: typeof parsedHeader.cwd === "string" ? parsedHeader.cwd : undefined,
|
|
1737
|
-
title: typeof parsedHeader.title === "string" ? parsedHeader.title : undefined,
|
|
1738
|
-
parentSession: typeof parsedHeader.parentSession === "string" ? parsedHeader.parentSession : undefined,
|
|
1739
|
-
timestamp: typeof parsedHeader.timestamp === "string" ? parsedHeader.timestamp : undefined,
|
|
1740
|
-
};
|
|
1741
|
-
}
|
|
1742
|
-
|
|
1743
|
-
const firstLineEnd = content.indexOf("\n");
|
|
1744
|
-
const firstLine = firstLineEnd === -1 ? content : content.slice(0, firstLineEnd);
|
|
1745
|
-
if (extractStringProperty(firstLine, "type") !== "session") return undefined;
|
|
1746
|
-
|
|
1747
|
-
const id = extractStringProperty(firstLine, "id");
|
|
1748
|
-
if (!id) return undefined;
|
|
1749
|
-
|
|
1750
|
-
return {
|
|
1751
|
-
type: "session",
|
|
1752
|
-
id,
|
|
1753
|
-
cwd: extractStringProperty(firstLine, "cwd"),
|
|
1754
|
-
title: extractStringProperty(firstLine, "title"),
|
|
1755
|
-
parentSession: extractStringProperty(firstLine, "parentSession"),
|
|
1756
|
-
timestamp: extractStringProperty(firstLine, "timestamp"),
|
|
1757
|
-
};
|
|
1758
|
-
}
|
|
1759
|
-
|
|
1760
|
-
function getSessionListWorkerCount(fileCount: number): number {
|
|
1761
|
-
if (fileCount <= SESSION_LIST_PARALLEL_THRESHOLD) return 1;
|
|
1762
|
-
return Math.min(
|
|
1763
|
-
SESSION_LIST_MAX_WORKERS,
|
|
1764
|
-
os.availableParallelism(),
|
|
1765
|
-
Math.ceil(fileCount / SESSION_LIST_PARALLEL_THRESHOLD),
|
|
1766
|
-
);
|
|
1767
|
-
}
|
|
1768
|
-
|
|
1769
|
-
async function collectSessionFromFile(file: string, storage: SessionStorage): Promise<SessionInfo | undefined> {
|
|
1770
|
-
try {
|
|
1771
|
-
const stat = storage.statSync(file);
|
|
1772
|
-
const [content, suffix] = await storage.readTextSlices(
|
|
1773
|
-
file,
|
|
1774
|
-
SESSION_LIST_PREFIX_BYTES,
|
|
1775
|
-
SESSION_LIST_SUFFIX_BYTES,
|
|
1776
|
-
);
|
|
1777
|
-
const { size, mtime } = stat;
|
|
1778
|
-
const entries = parseJsonlLenient<Record<string, unknown>>(content);
|
|
1779
|
-
const header = parseSessionListHeader(content, entries);
|
|
1780
|
-
if (!header) return undefined;
|
|
1781
|
-
|
|
1782
|
-
let parsedMessageCount = 0;
|
|
1783
|
-
let firstMessage = "";
|
|
1784
|
-
const allMessages: string[] = [];
|
|
1785
|
-
let shortSummary: string | undefined;
|
|
1786
|
-
|
|
1787
|
-
for (let i = 1; i < entries.length; i++) {
|
|
1788
|
-
const entry = entries[i] as { type?: string; message?: Message; shortSummary?: string };
|
|
1789
|
-
|
|
1790
|
-
if (entry.type === "compaction" && typeof entry.shortSummary === "string") {
|
|
1791
|
-
shortSummary = entry.shortSummary;
|
|
1792
|
-
}
|
|
1793
|
-
|
|
1794
|
-
if (entry.type === "message" && entry.message) {
|
|
1795
|
-
parsedMessageCount++;
|
|
1796
|
-
|
|
1797
|
-
if (entry.message.role === "user" || entry.message.role === "assistant") {
|
|
1798
|
-
const textContent = extractTextFromContent(entry.message.content);
|
|
1799
|
-
|
|
1800
|
-
if (textContent) {
|
|
1801
|
-
allMessages.push(textContent);
|
|
1802
|
-
|
|
1803
|
-
if (!firstMessage && entry.message.role === "user") {
|
|
1804
|
-
firstMessage = textContent;
|
|
1805
|
-
}
|
|
1806
|
-
}
|
|
1807
|
-
}
|
|
1808
|
-
}
|
|
1809
|
-
}
|
|
1810
|
-
|
|
1811
|
-
firstMessage ||= extractFirstUserMessageFromPrefix(content) ?? "";
|
|
1812
|
-
const messageCount = Math.max(parsedMessageCount, countMessageMarkers(content));
|
|
1813
|
-
return {
|
|
1814
|
-
path: file,
|
|
1815
|
-
id: header.id,
|
|
1816
|
-
cwd: header.cwd ?? "",
|
|
1817
|
-
title: header.title ?? shortSummary,
|
|
1818
|
-
parentSessionPath: header.parentSession,
|
|
1819
|
-
created: new Date(header.timestamp ?? ""),
|
|
1820
|
-
modified: mtime,
|
|
1821
|
-
messageCount,
|
|
1822
|
-
size,
|
|
1823
|
-
firstMessage: firstMessage || "(no messages)",
|
|
1824
|
-
allMessagesText: allMessages.length > 0 ? allMessages.join(" ") : firstMessage,
|
|
1825
|
-
status: deriveSessionStatus(suffix),
|
|
1826
|
-
};
|
|
1827
|
-
} catch {
|
|
1828
|
-
return undefined;
|
|
1829
|
-
}
|
|
1830
|
-
}
|
|
1831
|
-
|
|
1832
|
-
async function collectSessionsFromFileStride(
|
|
1833
|
-
files: string[],
|
|
1834
|
-
storage: SessionStorage,
|
|
1835
|
-
startIndex: number,
|
|
1836
|
-
stride: number,
|
|
1837
|
-
): Promise<SessionInfo[]> {
|
|
1838
|
-
const sessions: SessionInfo[] = [];
|
|
1839
|
-
|
|
1840
|
-
for (let i = startIndex; i < files.length; i += stride) {
|
|
1841
|
-
const session = await collectSessionFromFile(files[i], storage);
|
|
1842
|
-
if (session) sessions.push(session);
|
|
1843
|
-
}
|
|
1844
|
-
|
|
1845
|
-
return sessions;
|
|
1846
|
-
}
|
|
1847
|
-
|
|
1848
|
-
async function collectSessionsFromFiles(files: string[], storage: SessionStorage): Promise<SessionInfo[]> {
|
|
1849
|
-
const workerCount = getSessionListWorkerCount(files.length);
|
|
1850
|
-
const sessions =
|
|
1851
|
-
workerCount === 1
|
|
1852
|
-
? await collectSessionsFromFileStride(files, storage, 0, 1)
|
|
1853
|
-
: (
|
|
1854
|
-
await Promise.all(
|
|
1855
|
-
Array.from({ length: workerCount }, (_, workerIndex) =>
|
|
1856
|
-
collectSessionsFromFileStride(files, storage, workerIndex, workerCount),
|
|
1857
|
-
),
|
|
1858
|
-
)
|
|
1859
|
-
).flat();
|
|
1860
|
-
|
|
1861
|
-
sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
|
|
1862
|
-
return sessions;
|
|
1863
|
-
}
|
|
1864
|
-
|
|
1865
|
-
export interface ResolvedSessionMatch {
|
|
1866
|
-
session: SessionInfo;
|
|
1867
|
-
scope: "local" | "global";
|
|
1868
|
-
}
|
|
1869
|
-
|
|
1870
|
-
function sessionMatchesResumeArg(session: SessionInfo, sessionArg: string): boolean {
|
|
1871
|
-
const normalizedArg = sessionArg.toLowerCase();
|
|
1872
|
-
const normalizedId = session.id.toLowerCase();
|
|
1873
|
-
if (normalizedId.startsWith(normalizedArg)) {
|
|
1874
|
-
return true;
|
|
1875
|
-
}
|
|
1876
|
-
|
|
1877
|
-
const fileName = path.basename(session.path, ".jsonl").toLowerCase();
|
|
1878
|
-
if (fileName.startsWith(normalizedArg)) {
|
|
1879
|
-
return true;
|
|
1880
|
-
}
|
|
1881
|
-
|
|
1882
|
-
const separator = fileName.lastIndexOf("_");
|
|
1883
|
-
if (separator < 0) {
|
|
1884
|
-
return false;
|
|
1885
|
-
}
|
|
1886
|
-
|
|
1887
|
-
const fileSessionId = fileName.slice(separator + 1);
|
|
1888
|
-
return fileSessionId.startsWith(normalizedArg);
|
|
1889
|
-
}
|
|
1890
|
-
|
|
1891
|
-
export async function resolveResumableSession(
|
|
1892
|
-
sessionArg: string,
|
|
1893
|
-
cwd: string,
|
|
1894
|
-
sessionDir?: string,
|
|
1895
|
-
storage: SessionStorage = new FileSessionStorage(),
|
|
1896
|
-
): Promise<ResolvedSessionMatch | undefined> {
|
|
1897
|
-
const localSessionDir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage);
|
|
1898
|
-
const localSessions = await SessionManager.list(cwd, localSessionDir, storage);
|
|
1899
|
-
const localMatch = localSessions.find(session => sessionMatchesResumeArg(session, sessionArg));
|
|
1900
|
-
if (localMatch) {
|
|
1901
|
-
return { session: localMatch, scope: "local" };
|
|
1902
|
-
}
|
|
1903
|
-
|
|
1904
|
-
if (sessionDir) {
|
|
1905
|
-
return undefined;
|
|
1906
|
-
}
|
|
1907
|
-
|
|
1908
|
-
const globalSessions = await SessionManager.listAll(storage);
|
|
1909
|
-
const globalMatch = globalSessions.find(session => sessionMatchesResumeArg(session, sessionArg));
|
|
1910
|
-
if (!globalMatch) {
|
|
1911
|
-
return undefined;
|
|
1912
|
-
}
|
|
1913
|
-
|
|
1914
|
-
return { session: globalMatch, scope: "global" };
|
|
1915
|
-
}
|
|
1916
|
-
interface SessionManagerStateSnapshot {
|
|
1917
|
-
cwd: string;
|
|
1918
|
-
sessionDir: string;
|
|
1919
|
-
sessionId: string;
|
|
1920
|
-
sessionName: string | undefined;
|
|
1921
|
-
titleSource: "auto" | "user" | undefined;
|
|
1922
|
-
sessionFile: string | undefined;
|
|
1923
|
-
flushed: boolean;
|
|
1924
|
-
needsFullRewriteOnNextPersist: boolean;
|
|
1925
|
-
fileEntries: FileEntry[];
|
|
1926
|
-
}
|
|
1927
|
-
|
|
1928
|
-
export class SessionManager {
|
|
1929
|
-
#sessionId: string = "";
|
|
1930
|
-
#sessionName: string | undefined;
|
|
1931
|
-
#titleSource: "auto" | "user" | undefined;
|
|
1932
|
-
#sessionFile: string | undefined;
|
|
1933
|
-
#flushed: boolean = false;
|
|
1934
|
-
#needsFullRewriteOnNextPersist: boolean = false;
|
|
1935
|
-
#ensuredOnDisk: boolean = false;
|
|
1936
|
-
#fileEntries: FileEntry[] = [];
|
|
1937
|
-
#byId: Map<string, SessionEntry> = new Map();
|
|
1938
|
-
#labelsById: Map<string, string> = new Map();
|
|
1939
|
-
#leafId: string | null = null;
|
|
1940
|
-
#usageStatistics = {
|
|
1941
|
-
input: 0,
|
|
1942
|
-
output: 0,
|
|
1943
|
-
cacheRead: 0,
|
|
1944
|
-
cacheWrite: 0,
|
|
1945
|
-
premiumRequests: 0,
|
|
1946
|
-
cost: 0,
|
|
1947
|
-
} satisfies UsageStatistics;
|
|
1948
|
-
/** Per-turn output-token budget set by a `+Nk` directive (total null when none this turn). */
|
|
1949
|
-
#turnBudget: { total: number | null; hard: boolean } = { total: null, hard: false };
|
|
1950
|
-
/** Cumulative `output` snapshot captured when the current turn budget window opened. */
|
|
1951
|
-
#turnBaselineOutput = 0;
|
|
1952
|
-
/** Output tokens consumed by eval-spawned subagents in the current turn window. */
|
|
1953
|
-
#turnEvalOutput = 0;
|
|
1954
|
-
#persistWriter: NdjsonFileWriter | undefined;
|
|
1955
|
-
#persistWriterPath: string | undefined;
|
|
1956
|
-
#persistChain: Promise<void> = Promise.resolve();
|
|
1957
|
-
#persistError: Error | undefined;
|
|
1958
|
-
#persistErrorReported = false;
|
|
1959
|
-
#artifactManager: ArtifactManager | null = null;
|
|
1960
|
-
#artifactManagerSessionFile: string | null = null;
|
|
1961
|
-
// When set, take precedence over the lazily-derived per-session manager.
|
|
1962
|
-
// Subagents adopt the parent's manager so artifact IDs are unique across the
|
|
1963
|
-
// whole agent tree and all files land in the parent's artifacts dir.
|
|
1964
|
-
#adoptedArtifactManager: ArtifactManager | null = null;
|
|
1965
|
-
// In-memory artifact fallback for non-persistent sessions (persist=false).
|
|
1966
|
-
// Keyed by sequential numeric ID string; mirrors the file-based ArtifactManager ID scheme.
|
|
1967
|
-
#inMemoryArtifacts: Map<string, string> | null = null;
|
|
1968
|
-
#inMemoryArtifactCounter = 0;
|
|
1969
|
-
readonly #blobStore: BlobStore;
|
|
1970
|
-
|
|
1971
|
-
private constructor(
|
|
1972
|
-
private cwd: string,
|
|
1973
|
-
private sessionDir: string,
|
|
1974
|
-
private readonly persist: boolean,
|
|
1975
|
-
private readonly storage: SessionStorage,
|
|
1976
|
-
) {
|
|
1977
|
-
this.#blobStore = new BlobStore(getBlobsDir());
|
|
1978
|
-
if (persist && sessionDir) {
|
|
1979
|
-
this.storage.ensureDirSync(sessionDir);
|
|
1980
|
-
}
|
|
1981
|
-
// Note: call _initSession() or _initSessionFile() after construction
|
|
1982
|
-
}
|
|
1983
|
-
|
|
1984
|
-
/** Puts a binary blob into the blob store and returns the blob reference */
|
|
1985
|
-
async putBlob(data: Buffer, options?: BlobPutOptions): Promise<BlobPutResult> {
|
|
1986
|
-
return this.#blobStore.put(data, options);
|
|
1987
|
-
}
|
|
650
|
+
/** Puts a binary blob into the blob store and returns the blob reference. */
|
|
651
|
+
async putBlob(data: Buffer, options?: BlobPutOptions): Promise<BlobPutResult> {
|
|
652
|
+
return this.#blobs.put(data, options);
|
|
653
|
+
}
|
|
1988
654
|
|
|
1989
655
|
/** Synchronous variant of {@link putBlob} for rebuild-only render paths. */
|
|
1990
656
|
putBlobSync(data: Buffer, options?: BlobPutOptions): BlobPutResult {
|
|
1991
|
-
return this.#
|
|
657
|
+
return this.#blobs.putSync(data, options);
|
|
1992
658
|
}
|
|
1993
659
|
|
|
1994
660
|
captureState(): SessionManagerStateSnapshot {
|
|
1995
661
|
return {
|
|
1996
|
-
cwd: this
|
|
1997
|
-
sessionDir: this
|
|
662
|
+
cwd: this.#cwd,
|
|
663
|
+
sessionDir: this.#sessionDir,
|
|
1998
664
|
sessionId: this.#sessionId,
|
|
1999
665
|
sessionName: this.#sessionName,
|
|
2000
666
|
titleSource: this.#titleSource,
|
|
2001
667
|
sessionFile: this.#sessionFile,
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
// Snapshot
|
|
2005
|
-
//
|
|
2006
|
-
|
|
668
|
+
onDisk: this.#fileIsCurrent,
|
|
669
|
+
needsRewrite: this.#rewriteRequired,
|
|
670
|
+
// Snapshot header + entries by reference: switch/reload replaces the
|
|
671
|
+
// active header/array wholesale, so rollback needs no deep clone.
|
|
672
|
+
header: this.#header,
|
|
673
|
+
entries: [...this.#entries],
|
|
2007
674
|
};
|
|
2008
675
|
}
|
|
2009
676
|
|
|
2010
677
|
restoreState(snapshot: SessionManagerStateSnapshot): void {
|
|
2011
|
-
this
|
|
2012
|
-
this
|
|
2013
|
-
this.#
|
|
678
|
+
this.#closeWriterEventually();
|
|
679
|
+
this.#diskTail = Promise.resolve();
|
|
680
|
+
this.#clearDiskError();
|
|
681
|
+
|
|
682
|
+
this.#cwd = snapshot.cwd;
|
|
683
|
+
this.#sessionDir = snapshot.sessionDir;
|
|
684
|
+
this.#sessionFile = snapshot.sessionFile;
|
|
685
|
+
this.#fileIsCurrent = snapshot.onDisk;
|
|
686
|
+
this.#rewriteRequired = snapshot.needsRewrite;
|
|
687
|
+
this.#forceFileCreation = snapshot.onDisk;
|
|
688
|
+
this.#applyEntries(snapshot.header, [...snapshot.entries]);
|
|
2014
689
|
this.#sessionName = snapshot.sessionName;
|
|
2015
690
|
this.#titleSource = snapshot.titleSource;
|
|
2016
|
-
this.#sessionFile = snapshot.sessionFile;
|
|
2017
|
-
this.#flushed = snapshot.flushed;
|
|
2018
|
-
this.#needsFullRewriteOnNextPersist = snapshot.needsFullRewriteOnNextPersist;
|
|
2019
|
-
this.#fileEntries = [...snapshot.fileEntries];
|
|
2020
|
-
this.#persistWriter = undefined;
|
|
2021
|
-
this.#persistWriterPath = undefined;
|
|
2022
|
-
this.#persistChain = Promise.resolve();
|
|
2023
|
-
this.#persistError = undefined;
|
|
2024
|
-
this.#persistErrorReported = false;
|
|
2025
691
|
this.#artifactManager = null;
|
|
2026
692
|
this.#artifactManagerSessionFile = null;
|
|
2027
693
|
this.#adoptedArtifactManager = null;
|
|
2028
|
-
this.#buildIndex();
|
|
2029
|
-
if (this.#sessionFile) {
|
|
2030
|
-
writeTerminalBreadcrumb(this.cwd, this.#sessionFile);
|
|
2031
|
-
}
|
|
2032
|
-
}
|
|
2033
|
-
|
|
2034
|
-
/** Initialize with a specific session file (used by factory methods) */
|
|
2035
|
-
async #initSessionFile(sessionFile: string): Promise<void> {
|
|
2036
|
-
await this.setSessionFile(sessionFile);
|
|
2037
|
-
}
|
|
2038
694
|
|
|
2039
|
-
|
|
2040
|
-
#initNewSession(): void {
|
|
2041
|
-
this.#newSessionSync();
|
|
695
|
+
if (this.#sessionFile) this.#rememberBreadcrumb(this.#cwd, this.#sessionFile);
|
|
2042
696
|
}
|
|
2043
697
|
|
|
2044
|
-
/** Switch to a different session file (
|
|
698
|
+
/** Switch to a different session file (resume / branch). */
|
|
2045
699
|
async setSessionFile(sessionFile: string): Promise<void> {
|
|
2046
|
-
await this.#
|
|
2047
|
-
this.#
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
this.#
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
const headerCwd = header?.cwd ? path.resolve(header.cwd) : undefined;
|
|
2064
|
-
if (headerCwd && headerCwd !== this.cwd) {
|
|
2065
|
-
this.cwd = headerCwd;
|
|
2066
|
-
this.sessionDir = path.resolve(this.#sessionFile, "..");
|
|
2067
|
-
writeTerminalBreadcrumb(this.cwd, this.#sessionFile);
|
|
2068
|
-
}
|
|
2069
|
-
|
|
2070
|
-
this.#needsFullRewriteOnNextPersist = migrateToCurrentVersion(this.#fileEntries);
|
|
700
|
+
await this.#drainAndCloseWriter();
|
|
701
|
+
this.#clearDiskError();
|
|
702
|
+
|
|
703
|
+
const resolvedSessionFile = path.resolve(sessionFile);
|
|
704
|
+
this.#sessionFile = resolvedSessionFile;
|
|
705
|
+
this.#rememberBreadcrumb(this.#cwd, resolvedSessionFile);
|
|
706
|
+
|
|
707
|
+
const fileEntries = await loadEntriesFromFile(resolvedSessionFile, this.#storage);
|
|
708
|
+
if (fileEntries.length === 0) {
|
|
709
|
+
// Explicit but empty/missing path (e.g. --session flag): start fresh but
|
|
710
|
+
// keep the requested path and materialize the header immediately.
|
|
711
|
+
this.#resetToNewSession(undefined, resolvedSessionFile);
|
|
712
|
+
this.#forceFileCreation = true;
|
|
713
|
+
await this.#rewriteAtomically();
|
|
714
|
+
this.#fileIsCurrent = true;
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
2071
717
|
|
|
2072
|
-
|
|
2073
|
-
|
|
718
|
+
const migrated = migrateToCurrentVersion(fileEntries);
|
|
719
|
+
await resolveBlobRefsInEntries(fileEntries, this.#blobs);
|
|
720
|
+
// loadEntriesFromFile guarantees entries[0] is a valid session header.
|
|
721
|
+
const header = fileEntries[0] as SessionHeader;
|
|
2074
722
|
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
this.#
|
|
2081
|
-
this.#
|
|
2082
|
-
|
|
2083
|
-
this.#flushed = true;
|
|
2084
|
-
this.#ensuredOnDisk = true;
|
|
2085
|
-
return;
|
|
723
|
+
// Adopt the loaded session's working directory. Sessions live in a dir
|
|
724
|
+
// keyed by their cwd, so resuming a session from another project must
|
|
725
|
+
// re-point cwd/sessionDir at that project.
|
|
726
|
+
const headerCwd = header.cwd ? path.resolve(header.cwd) : undefined;
|
|
727
|
+
if (headerCwd && headerCwd !== path.resolve(this.#cwd)) {
|
|
728
|
+
this.#cwd = headerCwd;
|
|
729
|
+
this.#sessionDir = path.dirname(resolvedSessionFile);
|
|
730
|
+
this.#rememberBreadcrumb(this.#cwd, resolvedSessionFile);
|
|
2086
731
|
}
|
|
732
|
+
|
|
733
|
+
this.#applyEntries(header, fileEntries.slice(1) as SessionEntry[]);
|
|
734
|
+
this.#fileIsCurrent = true;
|
|
735
|
+
this.#rewriteRequired = migrated;
|
|
736
|
+
this.#forceFileCreation = true;
|
|
737
|
+
this.#artifactManager = null;
|
|
738
|
+
this.#artifactManagerSessionFile = null;
|
|
739
|
+
|
|
740
|
+
if (this.sanitizeLoadedOpenAIResponsesReplayMetadata()) this.#rewriteRequired = true;
|
|
2087
741
|
}
|
|
2088
742
|
|
|
2089
|
-
/** Start a new session.
|
|
743
|
+
/** Start a new session. Drains and closes any existing writer first. */
|
|
2090
744
|
async newSession(options?: NewSessionOptions): Promise<string | undefined> {
|
|
2091
|
-
await this.#
|
|
2092
|
-
return this.#
|
|
745
|
+
await this.#drainAndCloseWriter();
|
|
746
|
+
return this.#resetToNewSession(options);
|
|
2093
747
|
}
|
|
2094
748
|
|
|
2095
|
-
/** Delete a session file and its
|
|
749
|
+
/** Delete a session file and its artifact directory. ENOENT is treated as success. */
|
|
2096
750
|
async dropSession(sessionPath: string): Promise<void> {
|
|
2097
|
-
await this.#
|
|
751
|
+
await this.#drainAndCloseWriter();
|
|
2098
752
|
try {
|
|
2099
|
-
await this
|
|
753
|
+
await this.#storage.deleteSessionWithArtifacts(sessionPath);
|
|
2100
754
|
} catch (err) {
|
|
2101
|
-
if (isEnoent(err))
|
|
2102
|
-
throw err;
|
|
755
|
+
if (!isEnoent(err)) throw err;
|
|
2103
756
|
}
|
|
2104
757
|
}
|
|
2105
758
|
|
|
2106
759
|
/**
|
|
2107
|
-
* Fork the current session
|
|
2108
|
-
*
|
|
2109
|
-
* @returns { oldSessionFile, newSessionFile } or undefined if not persisting
|
|
760
|
+
* Fork the current session into a new file with the same entries.
|
|
761
|
+
* @returns the old and new session file paths, or undefined when not persisting.
|
|
2110
762
|
*/
|
|
2111
763
|
async fork(): Promise<{ oldSessionFile: string; newSessionFile: string } | undefined> {
|
|
2112
|
-
if (!this
|
|
2113
|
-
return undefined;
|
|
2114
|
-
}
|
|
764
|
+
if (!this.#persist || !this.#sessionFile) return undefined;
|
|
2115
765
|
|
|
2116
766
|
const oldSessionFile = this.#sessionFile;
|
|
2117
|
-
const
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
this.#
|
|
2123
|
-
this.#
|
|
2124
|
-
|
|
2125
|
-
// Create new session ID and header
|
|
2126
|
-
this.#sessionId = createSessionId();
|
|
2127
|
-
const timestamp = new Date().toISOString();
|
|
2128
|
-
const fileTimestamp = timestamp.replace(/[:.]/g, "-");
|
|
2129
|
-
this.#sessionFile = path.join(this.getSessionDir(), `${fileTimestamp}_${this.#sessionId}.jsonl`);
|
|
2130
|
-
|
|
2131
|
-
// Update the header with new ID but keep all entries
|
|
2132
|
-
const oldHeader = this.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
|
|
2133
|
-
const newHeader: SessionHeader = {
|
|
2134
|
-
type: "session",
|
|
2135
|
-
version: CURRENT_SESSION_VERSION,
|
|
2136
|
-
id: this.#sessionId,
|
|
2137
|
-
title: oldHeader?.title ?? this.#sessionName,
|
|
2138
|
-
titleSource: oldHeader?.titleSource ?? this.#titleSource,
|
|
2139
|
-
timestamp,
|
|
2140
|
-
cwd: this.cwd,
|
|
2141
|
-
parentSession: oldSessionId,
|
|
2142
|
-
};
|
|
2143
|
-
this.#sessionName = newHeader.title;
|
|
2144
|
-
this.#titleSource = newHeader.titleSource;
|
|
2145
|
-
|
|
2146
|
-
// Replace the header in fileEntries
|
|
2147
|
-
const entries = this.#fileEntries.filter((e): e is SessionEntry => e.type !== "session");
|
|
2148
|
-
this.#fileEntries = [newHeader, ...entries];
|
|
2149
|
-
|
|
2150
|
-
// Write the new session file
|
|
2151
|
-
this.#flushed = false;
|
|
2152
|
-
await this.#rewriteFile();
|
|
2153
|
-
|
|
2154
|
-
return { oldSessionFile, newSessionFile: this.#sessionFile };
|
|
2155
|
-
}
|
|
2156
|
-
|
|
2157
|
-
/**
|
|
2158
|
-
* Move the session to a new working directory.
|
|
2159
|
-
* Moves session files and artifacts on disk, updates all internal references,
|
|
2160
|
-
* and rewrites the session header with the new cwd.
|
|
2161
|
-
*/
|
|
2162
|
-
async moveTo(newCwd: string): Promise<void> {
|
|
2163
|
-
const resolvedCwd = path.resolve(newCwd);
|
|
2164
|
-
if (resolvedCwd === this.cwd) return;
|
|
2165
|
-
|
|
2166
|
-
const managedSessionsRoot = resolveManagedSessionRoot(this.sessionDir, this.cwd);
|
|
2167
|
-
const newSessionDir = managedSessionsRoot
|
|
2168
|
-
? computeDefaultSessionDir(resolvedCwd, this.storage, managedSessionsRoot)
|
|
2169
|
-
: computeDefaultSessionDir(resolvedCwd, this.storage);
|
|
2170
|
-
let hadSessionFile = false;
|
|
2171
|
-
|
|
2172
|
-
if (this.persist && this.#sessionFile) {
|
|
2173
|
-
// Close the persist writer before moving files
|
|
2174
|
-
await this.#closePersistWriter();
|
|
2175
|
-
this.#persistChain = Promise.resolve();
|
|
2176
|
-
this.#persistError = undefined;
|
|
2177
|
-
this.#persistErrorReported = false;
|
|
2178
|
-
|
|
2179
|
-
const oldSessionFile = this.#sessionFile;
|
|
2180
|
-
const newSessionFile = path.join(newSessionDir, path.basename(oldSessionFile));
|
|
2181
|
-
const oldArtifactDir = oldSessionFile.slice(0, -6); // strip .jsonl
|
|
2182
|
-
const newArtifactDir = newSessionFile.slice(0, -6);
|
|
2183
|
-
hadSessionFile = this.storage.existsSync(oldSessionFile);
|
|
2184
|
-
let movedSessionFile = false;
|
|
2185
|
-
let movedArtifactDir = false;
|
|
2186
|
-
|
|
2187
|
-
try {
|
|
2188
|
-
// Guard: session file may not exist yet (no assistant messages persisted)
|
|
2189
|
-
if (hadSessionFile) {
|
|
2190
|
-
await fs.promises.rename(oldSessionFile, newSessionFile);
|
|
2191
|
-
movedSessionFile = true;
|
|
2192
|
-
}
|
|
2193
|
-
|
|
2194
|
-
try {
|
|
2195
|
-
const stat = await fs.promises.stat(oldArtifactDir);
|
|
2196
|
-
if (stat.isDirectory()) {
|
|
2197
|
-
await fs.promises.rename(oldArtifactDir, newArtifactDir);
|
|
2198
|
-
movedArtifactDir = true;
|
|
2199
|
-
}
|
|
2200
|
-
} catch (err) {
|
|
2201
|
-
if (!isEnoent(err)) throw err;
|
|
2202
|
-
}
|
|
2203
|
-
} catch (err) {
|
|
2204
|
-
if (movedArtifactDir) {
|
|
2205
|
-
try {
|
|
2206
|
-
await fs.promises.rename(newArtifactDir, oldArtifactDir);
|
|
2207
|
-
} catch (rollbackErr) {
|
|
2208
|
-
throw new Error(
|
|
2209
|
-
`Failed to move artifacts and rollback: ${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}`,
|
|
2210
|
-
);
|
|
2211
|
-
}
|
|
2212
|
-
}
|
|
2213
|
-
if (movedSessionFile) {
|
|
2214
|
-
try {
|
|
2215
|
-
await fs.promises.rename(newSessionFile, oldSessionFile);
|
|
2216
|
-
} catch (rollbackErr) {
|
|
2217
|
-
throw new Error(
|
|
2218
|
-
`Failed to move session file and rollback: ${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}`,
|
|
2219
|
-
);
|
|
2220
|
-
}
|
|
2221
|
-
}
|
|
2222
|
-
throw err;
|
|
2223
|
-
}
|
|
2224
|
-
this.#sessionFile = newSessionFile;
|
|
2225
|
-
}
|
|
2226
|
-
|
|
2227
|
-
// Update cwd and sessionDir after the move succeeds.
|
|
2228
|
-
this.cwd = resolvedCwd;
|
|
2229
|
-
this.sessionDir = newSessionDir;
|
|
2230
|
-
|
|
2231
|
-
// Update the session header in fileEntries
|
|
2232
|
-
const header = this.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
|
|
2233
|
-
if (header) {
|
|
2234
|
-
header.cwd = resolvedCwd;
|
|
2235
|
-
}
|
|
2236
|
-
|
|
2237
|
-
// Rewrite the session file at its new location with updated header.
|
|
2238
|
-
// hadSessionFile: file existed before move → must rewrite to update cwd
|
|
2239
|
-
// hasAssistant: assistant messages in memory but file missing → recreate from memory
|
|
2240
|
-
// Neither true → fresh session, never written → preserve lazy-persist
|
|
2241
|
-
const hasAssistant = this.#fileEntries.some(e => e.type === "message" && e.message.role === "assistant");
|
|
2242
|
-
if (this.persist && this.#sessionFile && (hadSessionFile || hasAssistant)) {
|
|
2243
|
-
await this.#rewriteFile();
|
|
2244
|
-
}
|
|
2245
|
-
|
|
2246
|
-
// Update terminal breadcrumb
|
|
2247
|
-
if (this.#sessionFile) {
|
|
2248
|
-
writeTerminalBreadcrumb(resolvedCwd, this.#sessionFile);
|
|
2249
|
-
}
|
|
2250
|
-
}
|
|
2251
|
-
|
|
2252
|
-
/** Sync version for initial creation (no existing writer to close) */
|
|
2253
|
-
#newSessionSync(options?: NewSessionOptions): string | undefined {
|
|
2254
|
-
this.#persistChain = Promise.resolve();
|
|
2255
|
-
this.#persistError = undefined;
|
|
2256
|
-
this.#persistErrorReported = false;
|
|
2257
|
-
this.#sessionId = createSessionId();
|
|
2258
|
-
this.#sessionName = undefined;
|
|
2259
|
-
this.#titleSource = undefined;
|
|
2260
|
-
const timestamp = new Date().toISOString();
|
|
2261
|
-
const header: SessionHeader = {
|
|
767
|
+
const parentSessionId = this.#sessionId;
|
|
768
|
+
await this.#drainAndCloseWriter();
|
|
769
|
+
this.#clearDiskError();
|
|
770
|
+
|
|
771
|
+
const timestamp = nowIso();
|
|
772
|
+
this.#sessionId = mintSessionId();
|
|
773
|
+
this.#sessionFile = path.join(this.#sessionDir, `${fileSafeTimestamp(timestamp)}_${this.#sessionId}.jsonl`);
|
|
774
|
+
this.#header = {
|
|
2262
775
|
type: "session",
|
|
2263
776
|
version: CURRENT_SESSION_VERSION,
|
|
2264
777
|
id: this.#sessionId,
|
|
778
|
+
title: this.#header.title ?? this.#sessionName,
|
|
779
|
+
titleSource: this.#header.titleSource ?? this.#titleSource,
|
|
2265
780
|
timestamp,
|
|
2266
|
-
cwd: this
|
|
2267
|
-
parentSession:
|
|
781
|
+
cwd: this.#cwd,
|
|
782
|
+
parentSession: parentSessionId,
|
|
2268
783
|
};
|
|
2269
|
-
this.#
|
|
2270
|
-
this.#
|
|
2271
|
-
this.#
|
|
2272
|
-
this.#
|
|
2273
|
-
this.#
|
|
2274
|
-
this.#
|
|
2275
|
-
this.#
|
|
2276
|
-
this.#
|
|
2277
|
-
|
|
2278
|
-
this.#
|
|
2279
|
-
|
|
2280
|
-
if (this.persist) {
|
|
2281
|
-
const fileTimestamp = timestamp.replace(/[:.]/g, "-");
|
|
2282
|
-
this.#sessionFile = path.join(this.getSessionDir(), `${fileTimestamp}_${this.#sessionId}.jsonl`);
|
|
2283
|
-
writeTerminalBreadcrumb(this.cwd, this.#sessionFile);
|
|
2284
|
-
}
|
|
2285
|
-
return this.#sessionFile;
|
|
2286
|
-
}
|
|
2287
|
-
|
|
2288
|
-
#buildIndex(): void {
|
|
2289
|
-
this.#byId.clear();
|
|
2290
|
-
this.#labelsById.clear();
|
|
2291
|
-
this.#leafId = null;
|
|
2292
|
-
this.#usageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, premiumRequests: 0, cost: 0 };
|
|
2293
|
-
for (const entry of this.#fileEntries) {
|
|
2294
|
-
if (entry.type === "session") continue;
|
|
2295
|
-
this.#byId.set(entry.id, entry);
|
|
2296
|
-
this.#leafId = entry.id;
|
|
2297
|
-
if (entry.type === "label") {
|
|
2298
|
-
if (entry.label) {
|
|
2299
|
-
this.#labelsById.set(entry.targetId, entry.label);
|
|
2300
|
-
} else {
|
|
2301
|
-
this.#labelsById.delete(entry.targetId);
|
|
2302
|
-
}
|
|
2303
|
-
}
|
|
2304
|
-
if (entry.type === "message" && entry.message.role === "assistant") {
|
|
2305
|
-
const usage = entry.message.usage;
|
|
2306
|
-
this.#usageStatistics.input += usage.input;
|
|
2307
|
-
this.#usageStatistics.output += usage.output;
|
|
2308
|
-
this.#usageStatistics.cacheRead += usage.cacheRead;
|
|
2309
|
-
this.#usageStatistics.cacheWrite += usage.cacheWrite;
|
|
2310
|
-
this.#usageStatistics.premiumRequests += usage.premiumRequests ?? 0;
|
|
2311
|
-
this.#usageStatistics.cost += usage.cost.total;
|
|
2312
|
-
}
|
|
2313
|
-
|
|
2314
|
-
if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolName === "task") {
|
|
2315
|
-
const usage = getTaskToolUsage(entry.message.details);
|
|
2316
|
-
if (usage) {
|
|
2317
|
-
this.#usageStatistics.input += usage.input;
|
|
2318
|
-
this.#usageStatistics.output += usage.output;
|
|
2319
|
-
this.#usageStatistics.cacheRead += usage.cacheRead;
|
|
2320
|
-
this.#usageStatistics.cacheWrite += usage.cacheWrite;
|
|
2321
|
-
this.#usageStatistics.premiumRequests += usage.premiumRequests ?? 0;
|
|
2322
|
-
this.#usageStatistics.cost += usage.cost.total;
|
|
2323
|
-
}
|
|
2324
|
-
}
|
|
2325
|
-
}
|
|
2326
|
-
}
|
|
2327
|
-
|
|
2328
|
-
#recordPersistError(err: unknown): Error {
|
|
2329
|
-
const normalized = toError(err);
|
|
2330
|
-
if (!this.#persistError) this.#persistError = normalized;
|
|
2331
|
-
if (!this.#persistErrorReported) {
|
|
2332
|
-
this.#persistErrorReported = true;
|
|
2333
|
-
logger.error("Session persistence error.", {
|
|
2334
|
-
sessionFile: this.#sessionFile,
|
|
2335
|
-
error: normalized.message,
|
|
2336
|
-
stack: normalized.stack,
|
|
2337
|
-
});
|
|
2338
|
-
}
|
|
2339
|
-
return normalized;
|
|
2340
|
-
}
|
|
2341
|
-
|
|
2342
|
-
#queuePersistTask(task: () => Promise<void>, options?: { ignoreError?: boolean }): Promise<void> {
|
|
2343
|
-
const next = this.#persistChain.then(async () => {
|
|
2344
|
-
if (this.#persistError && !options?.ignoreError) throw this.#persistError;
|
|
2345
|
-
await task();
|
|
2346
|
-
});
|
|
2347
|
-
this.#persistChain = next.catch(err => {
|
|
2348
|
-
this.#recordPersistError(err);
|
|
2349
|
-
});
|
|
2350
|
-
return next;
|
|
2351
|
-
}
|
|
2352
|
-
|
|
2353
|
-
#ensurePersistWriter(): NdjsonFileWriter | undefined {
|
|
2354
|
-
if (!this.persist || !this.#sessionFile) return undefined;
|
|
2355
|
-
if (this.#persistError) throw this.#persistError;
|
|
2356
|
-
if (this.#persistWriter && this.#persistWriterPath === this.#sessionFile) {
|
|
2357
|
-
if (this.#persistWriter.isOpen()) return this.#persistWriter;
|
|
2358
|
-
// Cached writer for the current file is mid-close (queued
|
|
2359
|
-
// `#closePersistWriterInternal` has flipped `#closing` but not yet
|
|
2360
|
-
// cleared `#persistWriter`). Returning it would make `writeSync`
|
|
2361
|
-
// throw "Writer closed". Defer to the caller — `_persist` routes
|
|
2362
|
-
// the entry through the async rewrite path so it still lands on disk.
|
|
2363
|
-
return undefined;
|
|
2364
|
-
}
|
|
2365
|
-
// Note: caller must await _closePersistWriter() before calling this if switching files
|
|
2366
|
-
this.#persistWriter = new NdjsonFileWriter(this.storage, this.#sessionFile, {
|
|
2367
|
-
onError: err => {
|
|
2368
|
-
this.#recordPersistError(err);
|
|
2369
|
-
},
|
|
2370
|
-
});
|
|
2371
|
-
this.#persistWriterPath = this.#sessionFile;
|
|
2372
|
-
return this.#persistWriter;
|
|
784
|
+
this.#sessionName = this.#header.title;
|
|
785
|
+
this.#titleSource = this.#header.titleSource;
|
|
786
|
+
this.#fileIsCurrent = false;
|
|
787
|
+
this.#rewriteRequired = false;
|
|
788
|
+
this.#forceFileCreation = true;
|
|
789
|
+
this.#artifactManager = null;
|
|
790
|
+
this.#artifactManagerSessionFile = null;
|
|
791
|
+
this.#rememberBreadcrumb(this.#cwd, this.#sessionFile);
|
|
792
|
+
|
|
793
|
+
await this.#rewriteAtomically();
|
|
794
|
+
return { oldSessionFile, newSessionFile: this.#sessionFile };
|
|
2373
795
|
}
|
|
2374
796
|
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
797
|
+
/**
|
|
798
|
+
* Move the session to a new working directory: relocate the session file and
|
|
799
|
+
* artifacts on disk, update internal references, and rewrite the header cwd.
|
|
800
|
+
*/
|
|
801
|
+
async moveTo(newCwd: string, targetSessionDir?: string): Promise<void> {
|
|
802
|
+
const resolvedCwd = path.resolve(newCwd);
|
|
803
|
+
const resolvedTargetDir = targetSessionDir ? path.resolve(targetSessionDir) : undefined;
|
|
804
|
+
if (
|
|
805
|
+
resolvedCwd === path.resolve(this.#cwd) &&
|
|
806
|
+
(!resolvedTargetDir || resolvedTargetDir === path.resolve(this.#sessionDir))
|
|
807
|
+
) {
|
|
808
|
+
return;
|
|
2379
809
|
}
|
|
2380
|
-
this.#persistWriterPath = undefined;
|
|
2381
|
-
}
|
|
2382
810
|
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
);
|
|
2390
|
-
}
|
|
2391
|
-
// Windows can reject overwrite-style rename with EPERM even after our own writer is closed.
|
|
2392
|
-
// Move the old session file aside first so a failed retry can roll back to the last good file.
|
|
2393
|
-
// The backup uses a plain `<basename>.<snowflake>.bak` name (no leading dot) so that if the
|
|
2394
|
-
// process crashes between the two renames, `recoverOrphanedBackups` can find it via the
|
|
2395
|
-
// shared `*.bak` glob on both real and in-memory storage backends and promote it back to
|
|
2396
|
-
// the primary on the next session-dir scan.
|
|
811
|
+
const managedRoot = resolveManagedSessionRoot(this.#sessionDir, this.#cwd);
|
|
812
|
+
const nextSessionDir =
|
|
813
|
+
resolvedTargetDir ??
|
|
814
|
+
(managedRoot
|
|
815
|
+
? computeDefaultSessionDir(resolvedCwd, this.#storage, managedRoot)
|
|
816
|
+
: computeDefaultSessionDir(resolvedCwd, this.#storage));
|
|
2397
817
|
|
|
2398
|
-
|
|
2399
|
-
const dir = path.resolve(targetPath, "..");
|
|
2400
|
-
const backupPath = path.join(dir, `${path.basename(targetPath)}.${Snowflake.next()}.bak`);
|
|
2401
|
-
try {
|
|
2402
|
-
await this.storage.rename(targetPath, backupPath);
|
|
2403
|
-
} catch (err) {
|
|
2404
|
-
if (isEnoent(err)) {
|
|
2405
|
-
await this.storage.rename(tempPath, targetPath);
|
|
2406
|
-
return;
|
|
2407
|
-
}
|
|
2408
|
-
throw toError(renameError);
|
|
2409
|
-
}
|
|
818
|
+
let sessionFileExisted = false;
|
|
2410
819
|
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
const originalError = toError(renameError);
|
|
2416
|
-
try {
|
|
2417
|
-
await this.storage.rename(backupPath, targetPath);
|
|
2418
|
-
} catch (rollbackErr) {
|
|
2419
|
-
const rollbackError = toError(rollbackErr);
|
|
2420
|
-
throw new Error(
|
|
2421
|
-
`Failed to replace session file after EPERM (original: ${originalError.message}; retry: ${replaceError.message}); rollback from ${backupPath} also failed: ${rollbackError.message}`,
|
|
2422
|
-
{ cause: originalError },
|
|
2423
|
-
);
|
|
2424
|
-
}
|
|
2425
|
-
throw replaceError;
|
|
2426
|
-
}
|
|
820
|
+
if (this.#persist && this.#sessionFile) {
|
|
821
|
+
this.#storage.ensureDirSync(nextSessionDir);
|
|
822
|
+
await this.#drainAndCloseWriter();
|
|
823
|
+
this.#clearDiskError();
|
|
2427
824
|
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
}
|
|
2439
|
-
}
|
|
825
|
+
const oldSessionFile = this.#sessionFile;
|
|
826
|
+
const newSessionFile = path.join(nextSessionDir, path.basename(oldSessionFile));
|
|
827
|
+
const oldArtifactsDir = artifactsDirectoryFor(oldSessionFile)!;
|
|
828
|
+
const newArtifactsDir = artifactsDirectoryFor(newSessionFile)!;
|
|
829
|
+
const sessionPathChanged = path.resolve(oldSessionFile) !== path.resolve(newSessionFile);
|
|
830
|
+
const artifactPathChanged = path.resolve(oldArtifactsDir) !== path.resolve(newArtifactsDir);
|
|
831
|
+
sessionFileExisted = this.#storage.existsSync(oldSessionFile);
|
|
832
|
+
|
|
833
|
+
let sessionMoved = false;
|
|
834
|
+
let artifactsMoved = false;
|
|
2440
835
|
|
|
2441
|
-
async #replaceSessionFile(tempPath: string, targetPath: string): Promise<void> {
|
|
2442
|
-
try {
|
|
2443
|
-
await this.storage.rename(tempPath, targetPath);
|
|
2444
|
-
} catch (err) {
|
|
2445
|
-
if (!hasFsCode(err, "EPERM")) throw toError(err);
|
|
2446
|
-
await this.#replaceSessionFileAfterEperm(tempPath, targetPath, err);
|
|
2447
|
-
}
|
|
2448
|
-
}
|
|
2449
|
-
async #writeEntriesAtomically(entries: FileEntry[]): Promise<void> {
|
|
2450
|
-
if (!this.#sessionFile) return;
|
|
2451
|
-
const dir = path.resolve(this.#sessionFile, "..");
|
|
2452
|
-
const tempPath = path.join(dir, `.${path.basename(this.#sessionFile)}.${Snowflake.next()}.tmp`);
|
|
2453
|
-
const writer = new NdjsonFileWriter(this.storage, tempPath, { flags: "w" });
|
|
2454
|
-
try {
|
|
2455
|
-
for (const entry of entries) {
|
|
2456
|
-
await writer.write(entry);
|
|
2457
|
-
}
|
|
2458
|
-
await writer.flush();
|
|
2459
|
-
await writer.fsync();
|
|
2460
|
-
await writer.close();
|
|
2461
|
-
await this.#replaceSessionFile(tempPath, this.#sessionFile);
|
|
2462
|
-
} catch (err) {
|
|
2463
|
-
try {
|
|
2464
|
-
await writer.close();
|
|
2465
|
-
} catch {
|
|
2466
|
-
// Ignore cleanup errors
|
|
2467
|
-
}
|
|
2468
836
|
try {
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
837
|
+
if (sessionFileExisted && sessionPathChanged) {
|
|
838
|
+
await fs.promises.rename(oldSessionFile, newSessionFile);
|
|
839
|
+
sessionMoved = true;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
if (artifactPathChanged) {
|
|
843
|
+
try {
|
|
844
|
+
const artifactStat = await fs.promises.stat(oldArtifactsDir);
|
|
845
|
+
if (artifactStat.isDirectory()) {
|
|
846
|
+
await fs.promises.rename(oldArtifactsDir, newArtifactsDir);
|
|
847
|
+
artifactsMoved = true;
|
|
848
|
+
}
|
|
849
|
+
} catch (err) {
|
|
850
|
+
if (!isEnoent(err)) throw err;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
} catch (err) {
|
|
854
|
+
if (artifactsMoved) {
|
|
855
|
+
try {
|
|
856
|
+
await fs.promises.rename(newArtifactsDir, oldArtifactsDir);
|
|
857
|
+
} catch (rollbackErr) {
|
|
858
|
+
throw new Error(
|
|
859
|
+
`Failed to move artifacts and rollback: ${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}`,
|
|
860
|
+
);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
if (sessionMoved) {
|
|
865
|
+
try {
|
|
866
|
+
await fs.promises.rename(newSessionFile, oldSessionFile);
|
|
867
|
+
} catch (rollbackErr) {
|
|
868
|
+
throw new Error(
|
|
869
|
+
`Failed to move session file and rollback: ${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}`,
|
|
870
|
+
);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
throw err;
|
|
2472
875
|
}
|
|
2473
|
-
|
|
876
|
+
|
|
877
|
+
this.#sessionFile = newSessionFile;
|
|
878
|
+
this.#artifactManager = null;
|
|
879
|
+
this.#artifactManagerSessionFile = null;
|
|
2474
880
|
}
|
|
2475
|
-
}
|
|
2476
881
|
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
await this.#closePersistWriterInternal();
|
|
2481
|
-
const entries = await Promise.all(
|
|
2482
|
-
this.#fileEntries.map(entry => prepareEntryForPersistence(entry, this.#blobStore)),
|
|
2483
|
-
);
|
|
2484
|
-
await this.#writeEntriesAtomically(entries);
|
|
2485
|
-
this.#needsFullRewriteOnNextPersist = false;
|
|
2486
|
-
this.#flushed = true;
|
|
2487
|
-
});
|
|
2488
|
-
}
|
|
882
|
+
this.#cwd = resolvedCwd;
|
|
883
|
+
this.#sessionDir = nextSessionDir;
|
|
884
|
+
this.#header.cwd = resolvedCwd;
|
|
2489
885
|
|
|
2490
|
-
|
|
2491
|
-
|
|
886
|
+
// Rewrite at the new location when the file already existed (update cwd) or
|
|
887
|
+
// there is in-memory output worth materializing; otherwise stay lazy.
|
|
888
|
+
const hasAssistant = this.#historyContainsAssistantMessage();
|
|
889
|
+
if (this.#persist && this.#sessionFile && (sessionFileExisted || hasAssistant)) {
|
|
890
|
+
this.#forceFileCreation = true;
|
|
891
|
+
await this.#rewriteAtomically();
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
if (this.#sessionFile) this.#rememberBreadcrumb(resolvedCwd, this.#sessionFile);
|
|
2492
895
|
}
|
|
2493
896
|
|
|
2494
897
|
/**
|
|
2495
|
-
* Force
|
|
2496
|
-
*
|
|
898
|
+
* Force the session onto disk even with no assistant message yet (ACP
|
|
899
|
+
* session/new must create a discoverable file immediately).
|
|
2497
900
|
*/
|
|
2498
901
|
async ensureOnDisk(): Promise<void> {
|
|
2499
|
-
if (!this
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
this.#
|
|
902
|
+
if (!this.#persist || !this.#sessionFile) return;
|
|
903
|
+
this.#forceFileCreation = true;
|
|
904
|
+
if (this.#fileIsCurrent && !this.#rewriteRequired) return;
|
|
905
|
+
await this.#rewriteAtomically();
|
|
2503
906
|
}
|
|
2504
907
|
|
|
2505
|
-
/** Flush pending writes
|
|
908
|
+
/** Flush pending writes. Call before switching sessions or on shutdown. */
|
|
2506
909
|
async flush(): Promise<void> {
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
await this.#persistWriter.fsync();
|
|
2511
|
-
}
|
|
910
|
+
if (!this.#persist || !this.#sessionFile) return;
|
|
911
|
+
await this.#scheduleDiskWork(async () => {
|
|
912
|
+
if (this.#writer?.isOpen()) await this.#writer.flush();
|
|
2512
913
|
});
|
|
2513
|
-
if (this.#
|
|
914
|
+
if (this.#diskFailure) throw this.#diskFailure;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* Synchronously flush all in-memory entries to disk. Use when the process may
|
|
919
|
+
* exit before an async flush settles (Ctrl+C in the TUI). Software-crash
|
|
920
|
+
* durable; not atomic and not power-loss safe — a same-process crash never
|
|
921
|
+
* lands mid-`writeFileSync`.
|
|
922
|
+
*/
|
|
923
|
+
flushSync(): void {
|
|
924
|
+
if (!this.#persist || !this.#sessionFile) return;
|
|
925
|
+
if (this.#diskFailure) throw this.#diskFailure;
|
|
926
|
+
this.#rewriteSynchronously();
|
|
927
|
+
if (this.#diskFailure) throw this.#diskFailure;
|
|
2514
928
|
}
|
|
2515
929
|
|
|
2516
|
-
/**
|
|
930
|
+
/** Flush, then close the append writer. */
|
|
2517
931
|
async close(): Promise<void> {
|
|
2518
|
-
if (!this.#
|
|
2519
|
-
await this.#
|
|
2520
|
-
await this.#
|
|
2521
|
-
this.#
|
|
932
|
+
if (!this.#persist) return;
|
|
933
|
+
await this.#scheduleDiskWork(async () => {
|
|
934
|
+
await this.#closeWriterHandle();
|
|
935
|
+
this.#fileIsCurrent = true;
|
|
2522
936
|
});
|
|
2523
|
-
if (this.#
|
|
937
|
+
if (this.#diskFailure) throw this.#diskFailure;
|
|
2524
938
|
}
|
|
2525
939
|
|
|
2526
940
|
getCwd(): string {
|
|
2527
|
-
return this
|
|
941
|
+
return this.#cwd;
|
|
2528
942
|
}
|
|
2529
943
|
|
|
2530
|
-
/** Get usage statistics across all assistant messages in the session. */
|
|
2531
944
|
getUsageStatistics(): UsageStatistics {
|
|
2532
|
-
return this.#
|
|
945
|
+
return this.#index.usageSnapshot();
|
|
2533
946
|
}
|
|
2534
947
|
|
|
2535
948
|
/**
|
|
2536
949
|
* Open a new per-turn budget window: snapshot the cumulative output baseline,
|
|
2537
|
-
* reset the eval-subagent counter, and set the (optional) ceiling.
|
|
2538
|
-
* per real user message; `total` is null when no `+Nk` directive was present.
|
|
950
|
+
* reset the eval-subagent counter, and set the (optional) ceiling.
|
|
2539
951
|
*/
|
|
2540
952
|
beginTurnBudget(total: number | null, hard: boolean): void {
|
|
2541
|
-
this.#
|
|
2542
|
-
this.#
|
|
953
|
+
this.#turnBudgetTotal = total;
|
|
954
|
+
this.#turnBudgetHard = hard;
|
|
955
|
+
this.#turnOutputBaseline = this.#index.usageSnapshot().output;
|
|
2543
956
|
this.#turnEvalOutput = 0;
|
|
2544
957
|
}
|
|
2545
958
|
|
|
2546
|
-
/** Record output tokens consumed by an eval-spawned subagent in the current turn. */
|
|
2547
959
|
recordEvalSubagentOutput(output: number): void {
|
|
2548
960
|
if (Number.isFinite(output) && output > 0) this.#turnEvalOutput += output;
|
|
2549
961
|
}
|
|
2550
962
|
|
|
2551
|
-
/**
|
|
2552
|
-
* Current turn budget for the eval `budget` helper: the ceiling (null = none),
|
|
2553
|
-
* output tokens spent this turn (main loop + eval-spawned subagents, no
|
|
2554
|
-
* double-count), and whether the ceiling is hard.
|
|
2555
|
-
*/
|
|
2556
963
|
getTurnBudget(): { total: number | null; spent: number; hard: boolean } {
|
|
2557
|
-
const
|
|
2558
|
-
return { total: this.#
|
|
964
|
+
const mainOutput = Math.max(0, this.#index.usageSnapshot().output - this.#turnOutputBaseline);
|
|
965
|
+
return { total: this.#turnBudgetTotal, spent: mainOutput + this.#turnEvalOutput, hard: this.#turnBudgetHard };
|
|
2559
966
|
}
|
|
2560
967
|
|
|
2561
968
|
getSessionDir(): string {
|
|
2562
|
-
return this
|
|
969
|
+
return this.#sessionDir;
|
|
2563
970
|
}
|
|
2564
971
|
|
|
2565
972
|
getSessionId(): string {
|
|
@@ -2570,152 +977,78 @@ export class SessionManager {
|
|
|
2570
977
|
return this.#sessionFile;
|
|
2571
978
|
}
|
|
2572
979
|
|
|
2573
|
-
/**
|
|
2574
|
-
* Returns the session artifacts directory path (session file path without .jsonl).
|
|
2575
|
-
* Returns null when the session is not persisted to a file.
|
|
2576
|
-
* When this session has adopted an external ArtifactManager (subagent case),
|
|
2577
|
-
* returns that manager's directory so reads/writes land in the shared parent
|
|
2578
|
-
* dir instead of a private (non-existent) subdir.
|
|
2579
|
-
*/
|
|
2580
980
|
getArtifactsDir(): string | null {
|
|
2581
981
|
if (this.#adoptedArtifactManager) return this.#adoptedArtifactManager.dir;
|
|
2582
|
-
|
|
2583
|
-
return sessionFile ? sessionFile.slice(0, -6) : null;
|
|
982
|
+
return artifactsDirectoryFor(this.#sessionFile);
|
|
2584
983
|
}
|
|
2585
984
|
|
|
2586
|
-
/**
|
|
2587
|
-
* Adopt an externally-owned ArtifactManager. Used by subagents to share
|
|
2588
|
-
* the parent session's artifact directory and ID counter.
|
|
2589
|
-
*/
|
|
2590
985
|
adoptArtifactManager(manager: ArtifactManager): void {
|
|
2591
986
|
this.#adoptedArtifactManager = manager;
|
|
2592
987
|
}
|
|
2593
988
|
|
|
2594
|
-
/**
|
|
2595
|
-
* Returns the ArtifactManager this session writes through. Lazily creates
|
|
2596
|
-
* one bound to the current session file unless an external manager was
|
|
2597
|
-
* adopted via `adoptArtifactManager`. Returns null only for non-persistent
|
|
2598
|
-
* sessions with no adopted manager.
|
|
2599
|
-
*/
|
|
2600
989
|
getArtifactManager(): ArtifactManager | null {
|
|
2601
|
-
return this.#
|
|
2602
|
-
}
|
|
2603
|
-
|
|
2604
|
-
/**
|
|
2605
|
-
* Returns an artifact manager bound to the current session file.
|
|
2606
|
-
* Recreates the manager when the active session file changes.
|
|
2607
|
-
*/
|
|
2608
|
-
#getOrCreateArtifactManager(): ArtifactManager | null {
|
|
2609
|
-
if (this.#adoptedArtifactManager) return this.#adoptedArtifactManager;
|
|
2610
|
-
const sessionFile = this.#sessionFile;
|
|
2611
|
-
if (!sessionFile) {
|
|
2612
|
-
this.#artifactManager = null;
|
|
2613
|
-
this.#artifactManagerSessionFile = null;
|
|
2614
|
-
return null;
|
|
2615
|
-
}
|
|
2616
|
-
|
|
2617
|
-
if (this.#artifactManager && this.#artifactManagerSessionFile === sessionFile) {
|
|
2618
|
-
return this.#artifactManager;
|
|
2619
|
-
}
|
|
2620
|
-
|
|
2621
|
-
const manager = new ArtifactManager(sessionFile.slice(0, -6));
|
|
2622
|
-
this.#artifactManager = manager;
|
|
2623
|
-
this.#artifactManagerSessionFile = sessionFile;
|
|
2624
|
-
return manager;
|
|
990
|
+
return this.#artifactManagerForSession();
|
|
2625
991
|
}
|
|
2626
992
|
|
|
2627
|
-
/**
|
|
2628
|
-
* Allocate a new artifact path and ID for the current session.
|
|
2629
|
-
* Returns an empty object when the session is not persisted.
|
|
2630
|
-
*/
|
|
2631
993
|
async allocateArtifactPath(toolType: string): Promise<{ id?: string; path?: string }> {
|
|
2632
|
-
|
|
2633
|
-
if (!manager) return {};
|
|
2634
|
-
return manager.allocatePath(toolType);
|
|
994
|
+
return (await this.#artifactManagerForSession()?.allocatePath(toolType)) ?? {};
|
|
2635
995
|
}
|
|
2636
996
|
|
|
2637
|
-
/**
|
|
2638
|
-
* Save artifact content under the current session and return artifact ID.
|
|
2639
|
-
* Returns an artifact ID for all sessions (file-backed for persistent, in-memory fallback otherwise).
|
|
2640
|
-
*/
|
|
2641
997
|
async saveArtifact(content: string, toolType: string): Promise<string | undefined> {
|
|
2642
|
-
const manager = this.#
|
|
998
|
+
const manager = this.#artifactManagerForSession();
|
|
2643
999
|
if (manager) return manager.save(content, toolType);
|
|
2644
|
-
|
|
2645
|
-
|
|
1000
|
+
|
|
1001
|
+
// Non-persistent session: keep an in-memory copy so spill truncation works.
|
|
1002
|
+
this.#inMemoryArtifacts ??= new Map();
|
|
2646
1003
|
const id = String(this.#inMemoryArtifactCounter++);
|
|
2647
1004
|
this.#inMemoryArtifacts.set(id, content);
|
|
2648
1005
|
return id;
|
|
2649
1006
|
}
|
|
2650
1007
|
|
|
2651
|
-
/**
|
|
2652
|
-
* Resolve an artifact ID to an on-disk path for the current session.
|
|
2653
|
-
* Returns null when missing or when the session is not persisted.
|
|
2654
|
-
*/
|
|
2655
1008
|
async getArtifactPath(id: string): Promise<string | null> {
|
|
2656
|
-
|
|
2657
|
-
if (!manager) return null;
|
|
2658
|
-
return manager.getPath(id);
|
|
2659
|
-
}
|
|
2660
|
-
|
|
2661
|
-
/**
|
|
2662
|
-
* Path to the unsent-input draft sidecar for the current session. Lives inside
|
|
2663
|
-
* the artifacts directory so it is removed together with the session on
|
|
2664
|
-
* `dropSession`. Returns null when the session has no on-disk identity.
|
|
2665
|
-
*/
|
|
2666
|
-
#getDraftPath(): string | null {
|
|
2667
|
-
const dir = this.getArtifactsDir();
|
|
2668
|
-
return dir ? path.join(dir, "draft.txt") : null;
|
|
1009
|
+
return (await this.#artifactManagerForSession()?.getPath(id)) ?? null;
|
|
2669
1010
|
}
|
|
2670
1011
|
|
|
2671
|
-
/**
|
|
2672
|
-
* Persist (or clear) the current editor draft so the next resume of this
|
|
2673
|
-
* session can restore it. Empty text deletes any stale draft. No-op when the
|
|
2674
|
-
* session is not persisted.
|
|
2675
|
-
*/
|
|
2676
1012
|
async saveDraft(text: string): Promise<void> {
|
|
2677
|
-
const draftPath = this.#
|
|
2678
|
-
if (!draftPath || !this
|
|
1013
|
+
const draftPath = this.#draftPath();
|
|
1014
|
+
if (!draftPath || !this.#persist) return;
|
|
1015
|
+
|
|
2679
1016
|
if (text.length === 0) {
|
|
2680
1017
|
try {
|
|
2681
|
-
await this
|
|
1018
|
+
await this.#storage.unlink(draftPath);
|
|
2682
1019
|
} catch (err) {
|
|
2683
1020
|
if (!isEnoent(err)) throw err;
|
|
2684
1021
|
}
|
|
2685
1022
|
return;
|
|
2686
1023
|
}
|
|
2687
|
-
|
|
2688
|
-
//
|
|
2689
|
-
// never produced an assistant reply would persist a draft next to a
|
|
2690
|
-
// session file that does not exist on disk.
|
|
1024
|
+
|
|
1025
|
+
// Force the header onto disk so resume can find the file this draft attaches to.
|
|
2691
1026
|
await this.ensureOnDisk();
|
|
2692
|
-
await this
|
|
1027
|
+
await this.#storage.writeText(draftPath, text);
|
|
2693
1028
|
}
|
|
2694
1029
|
|
|
2695
|
-
/**
|
|
2696
|
-
* Read and remove the saved draft. Returns the previously-saved text, or
|
|
2697
|
-
* null when no draft is pending. Single-shot: a successful read removes the
|
|
2698
|
-
* sidecar so a subsequent resume does not re-restore the same text.
|
|
2699
|
-
*/
|
|
2700
1030
|
async consumeDraft(): Promise<string | null> {
|
|
2701
|
-
const draftPath = this.#
|
|
1031
|
+
const draftPath = this.#draftPath();
|
|
2702
1032
|
if (!draftPath) return null;
|
|
2703
|
-
|
|
1033
|
+
|
|
1034
|
+
let draft: string;
|
|
2704
1035
|
try {
|
|
2705
|
-
|
|
1036
|
+
draft = await this.#storage.readText(draftPath);
|
|
2706
1037
|
} catch (err) {
|
|
2707
1038
|
if (isEnoent(err)) return null;
|
|
2708
1039
|
throw err;
|
|
2709
1040
|
}
|
|
1041
|
+
|
|
2710
1042
|
try {
|
|
2711
|
-
await this
|
|
1043
|
+
await this.#storage.unlink(draftPath);
|
|
2712
1044
|
} catch (err) {
|
|
2713
1045
|
if (!isEnoent(err)) throw err;
|
|
2714
1046
|
}
|
|
2715
|
-
|
|
1047
|
+
|
|
1048
|
+
return draft;
|
|
2716
1049
|
}
|
|
2717
1050
|
|
|
2718
|
-
/** The source that set the session name: "user" (manual
|
|
1051
|
+
/** The source that set the session name: "user" (manual/RPC) or "auto" (generated title). */
|
|
2719
1052
|
get titleSource(): "auto" | "user" | undefined {
|
|
2720
1053
|
return this.#titleSource;
|
|
2721
1054
|
}
|
|
@@ -2724,125 +1057,58 @@ export class SessionManager {
|
|
|
2724
1057
|
return this.#sessionName;
|
|
2725
1058
|
}
|
|
2726
1059
|
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
return
|
|
2730
|
-
.
|
|
2731
|
-
|
|
2732
|
-
.trim();
|
|
1060
|
+
onSessionNameChanged(cb: () => void): () => void {
|
|
1061
|
+
this.#sessionNameChangedCallbacks.add(cb);
|
|
1062
|
+
return () => {
|
|
1063
|
+
this.#sessionNameChangedCallbacks.delete(cb);
|
|
1064
|
+
};
|
|
2733
1065
|
}
|
|
2734
1066
|
|
|
2735
1067
|
/**
|
|
2736
1068
|
* Set the session display name.
|
|
2737
|
-
* @param source
|
|
2738
|
-
* Auto
|
|
1069
|
+
* @param source "user" for explicit renames; "auto" for generated titles.
|
|
1070
|
+
* Auto titles are ignored once the user has set a name.
|
|
2739
1071
|
*/
|
|
2740
1072
|
async setSessionName(name: string, source: "auto" | "user" = "auto"): Promise<boolean> {
|
|
2741
|
-
// User-set names take permanent precedence over auto-generated ones.
|
|
2742
1073
|
if (this.#titleSource === "user" && source === "auto") return false;
|
|
2743
1074
|
|
|
2744
|
-
const
|
|
2745
|
-
if (!
|
|
1075
|
+
const title = SessionManager.#cleanTitle(name);
|
|
1076
|
+
if (!title) return false;
|
|
2746
1077
|
|
|
2747
|
-
this.#sessionName =
|
|
1078
|
+
this.#sessionName = title;
|
|
2748
1079
|
this.#titleSource = source;
|
|
1080
|
+
this.#header.title = title;
|
|
1081
|
+
this.#header.titleSource = source;
|
|
2749
1082
|
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
if (header) {
|
|
2753
|
-
header.title = sanitized;
|
|
2754
|
-
header.titleSource = source;
|
|
1083
|
+
if (this.#persist && this.#sessionFile && this.#storage.existsSync(this.#sessionFile)) {
|
|
1084
|
+
await this.#rewriteAtomically();
|
|
2755
1085
|
}
|
|
2756
1086
|
|
|
2757
|
-
|
|
2758
|
-
const sessionFile = this.#sessionFile;
|
|
2759
|
-
if (this.persist && sessionFile && this.storage.existsSync(sessionFile)) {
|
|
2760
|
-
await this.#rewriteFile();
|
|
2761
|
-
}
|
|
1087
|
+
this.#notifySessionNameListeners();
|
|
2762
1088
|
return true;
|
|
2763
1089
|
}
|
|
2764
1090
|
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
// been called, the session is already on disk and every entry must be flushed.
|
|
2772
|
-
if (!this.#ensuredOnDisk) {
|
|
2773
|
-
const hasAssistant = this.#fileEntries.some(e => e.type === "message" && e.message.role === "assistant");
|
|
2774
|
-
if (!hasAssistant) {
|
|
2775
|
-
// Mark as not flushed so when assistant arrives, all entries get written.
|
|
2776
|
-
this.#flushed = false;
|
|
2777
|
-
return;
|
|
2778
|
-
}
|
|
2779
|
-
}
|
|
2780
|
-
|
|
2781
|
-
if (this.#needsFullRewriteOnNextPersist || !this.#flushed) {
|
|
2782
|
-
// Cold path: rewrite the whole file atomically. Async — the writer is
|
|
2783
|
-
// closed/reopened and every entry is re-prepared. Errors flow through
|
|
2784
|
-
// `#persistChain` → `#recordPersistError`; we swallow the rejection
|
|
2785
|
-
// here to avoid an unhandled rejection when the persist dir races with
|
|
2786
|
-
// test-level tempDir cleanup.
|
|
2787
|
-
this.#rewriteFile().catch(() => {});
|
|
2788
|
-
return;
|
|
2789
|
-
}
|
|
2790
|
-
|
|
2791
|
-
// Hot path: synchronously truncate + append. `fs.writeSync` returns once the
|
|
2792
|
-
// bytes are in the kernel page cache, so the entry survives an OOM/SIGKILL
|
|
2793
|
-
// landing immediately after this call. Image externalization (rare) runs via
|
|
2794
|
-
// the synchronous blob-store path so blob bytes are durable before the JSONL
|
|
2795
|
-
// line referencing them is written.
|
|
2796
|
-
try {
|
|
2797
|
-
const writer = this.#ensurePersistWriter();
|
|
2798
|
-
if (!writer) {
|
|
2799
|
-
// `#ensurePersistWriter` returns undefined here only when the cached
|
|
2800
|
-
// writer is mid-close (the `!persist`/`!sessionFile` cases are
|
|
2801
|
-
// rejected above). Route through `#rewriteFile` so the entry — which
|
|
2802
|
-
// is already in `#fileEntries` — persists once the close drains.
|
|
2803
|
-
this.#rewriteFile().catch(() => {});
|
|
2804
|
-
return;
|
|
2805
|
-
}
|
|
2806
|
-
const persistedEntry = prepareEntryForPersistenceSync(entry, this.#blobStore);
|
|
2807
|
-
writer.writeSync(persistedEntry);
|
|
2808
|
-
} catch (err) {
|
|
2809
|
-
this.#recordPersistError(err);
|
|
2810
|
-
}
|
|
1091
|
+
/**
|
|
1092
|
+
* Append a foreign (host-authored) entry verbatim, preserving its
|
|
1093
|
+
* `id`/`parentId`. Used by collab guests to mirror the host session.
|
|
1094
|
+
*/
|
|
1095
|
+
ingestReplicatedEntry(entry: SessionEntry): void {
|
|
1096
|
+
this.#recordEntry(entry);
|
|
2811
1097
|
}
|
|
2812
1098
|
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
this.#usageStatistics.input += usage.input;
|
|
2821
|
-
this.#usageStatistics.output += usage.output;
|
|
2822
|
-
this.#usageStatistics.cacheRead += usage.cacheRead;
|
|
2823
|
-
this.#usageStatistics.cacheWrite += usage.cacheWrite;
|
|
2824
|
-
this.#usageStatistics.premiumRequests += usage.premiumRequests ?? 0;
|
|
2825
|
-
this.#usageStatistics.cost += usage.cost.total;
|
|
2826
|
-
}
|
|
2827
|
-
|
|
2828
|
-
if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolName === "task") {
|
|
2829
|
-
const usage = getTaskToolUsage(entry.message.details);
|
|
2830
|
-
if (usage) {
|
|
2831
|
-
this.#usageStatistics.input += usage.input;
|
|
2832
|
-
this.#usageStatistics.output += usage.output;
|
|
2833
|
-
this.#usageStatistics.cacheRead += usage.cacheRead;
|
|
2834
|
-
this.#usageStatistics.cacheWrite += usage.cacheWrite;
|
|
2835
|
-
this.#usageStatistics.premiumRequests += usage.premiumRequests ?? 0;
|
|
2836
|
-
this.#usageStatistics.cost += usage.cost.total;
|
|
2837
|
-
}
|
|
2838
|
-
}
|
|
1099
|
+
/**
|
|
1100
|
+
* Snapshot the session for collab replication: the live header plus a deep
|
|
1101
|
+
* copy of every entry (the host mutates entries in place on rewrite paths, so
|
|
1102
|
+
* guests must not share references).
|
|
1103
|
+
*/
|
|
1104
|
+
snapshotForReplication(): { header: SessionHeader; entries: SessionEntry[] } {
|
|
1105
|
+
return { header: structuredClone(this.#header), entries: structuredClone(this.#entries) as SessionEntry[] };
|
|
2839
1106
|
}
|
|
2840
1107
|
|
|
2841
|
-
/**
|
|
2842
|
-
*
|
|
2843
|
-
*
|
|
2844
|
-
*
|
|
2845
|
-
* These need to be appended via appendCompaction() and appendBranchSummary() methods.
|
|
1108
|
+
/**
|
|
1109
|
+
* Append a message as a child of the current leaf, then advance the leaf.
|
|
1110
|
+
* CompactionSummaryMessage / BranchSummaryMessage are rejected here — they are
|
|
1111
|
+
* top-level entries via appendCompaction()/branchWithSummary().
|
|
2846
1112
|
*/
|
|
2847
1113
|
appendMessage(
|
|
2848
1114
|
message:
|
|
@@ -2853,88 +1119,50 @@ export class SessionManager {
|
|
|
2853
1119
|
| PythonExecutionMessage
|
|
2854
1120
|
| FileMentionMessage,
|
|
2855
1121
|
): string {
|
|
2856
|
-
const entry: SessionMessageEntry = {
|
|
2857
|
-
|
|
2858
|
-
id: generateId(this.#byId),
|
|
2859
|
-
parentId: this.#leafId,
|
|
2860
|
-
timestamp: new Date().toISOString(),
|
|
2861
|
-
message,
|
|
2862
|
-
};
|
|
2863
|
-
this.#appendEntry(entry);
|
|
1122
|
+
const entry: SessionMessageEntry = { type: "message", ...this.#freshEntryFields(), message };
|
|
1123
|
+
this.#recordEntry(entry);
|
|
2864
1124
|
return entry.id;
|
|
2865
1125
|
}
|
|
2866
1126
|
|
|
2867
|
-
/** Append a thinking level change as child of current leaf, then advance leaf. Returns entry id. */
|
|
2868
1127
|
appendThinkingLevelChange(thinkingLevel?: string): string {
|
|
2869
1128
|
const entry: ThinkingLevelChangeEntry = {
|
|
2870
1129
|
type: "thinking_level_change",
|
|
2871
|
-
|
|
2872
|
-
parentId: this.#leafId,
|
|
2873
|
-
timestamp: new Date().toISOString(),
|
|
1130
|
+
...this.#freshEntryFields(),
|
|
2874
1131
|
thinkingLevel: thinkingLevel ?? null,
|
|
2875
1132
|
};
|
|
2876
|
-
this.#
|
|
1133
|
+
this.#recordEntry(entry);
|
|
2877
1134
|
return entry.id;
|
|
2878
1135
|
}
|
|
2879
1136
|
|
|
2880
1137
|
appendServiceTierChange(serviceTier: ServiceTier | null): string {
|
|
2881
|
-
const entry: ServiceTierChangeEntry = {
|
|
2882
|
-
|
|
2883
|
-
id: generateId(this.#byId),
|
|
2884
|
-
parentId: this.#leafId,
|
|
2885
|
-
timestamp: new Date().toISOString(),
|
|
2886
|
-
serviceTier,
|
|
2887
|
-
};
|
|
2888
|
-
this.#appendEntry(entry);
|
|
1138
|
+
const entry: ServiceTierChangeEntry = { type: "service_tier_change", ...this.#freshEntryFields(), serviceTier };
|
|
1139
|
+
this.#recordEntry(entry);
|
|
2889
1140
|
return entry.id;
|
|
2890
1141
|
}
|
|
2891
1142
|
|
|
2892
|
-
/** Append a mode change as child of current leaf, then advance leaf. Returns entry id. */
|
|
2893
1143
|
appendModeChange(mode: string, data?: Record<string, unknown>): string {
|
|
2894
|
-
const entry: ModeChangeEntry = {
|
|
2895
|
-
|
|
2896
|
-
id: generateId(this.#byId),
|
|
2897
|
-
parentId: this.#leafId,
|
|
2898
|
-
timestamp: new Date().toISOString(),
|
|
2899
|
-
mode,
|
|
2900
|
-
data,
|
|
2901
|
-
};
|
|
2902
|
-
this.#appendEntry(entry);
|
|
1144
|
+
const entry: ModeChangeEntry = { type: "mode_change", ...this.#freshEntryFields(), mode, data };
|
|
1145
|
+
this.#recordEntry(entry);
|
|
2903
1146
|
return entry.id;
|
|
2904
1147
|
}
|
|
2905
1148
|
|
|
2906
1149
|
/**
|
|
2907
|
-
* Append a model change as child of current leaf, then advance leaf.
|
|
1150
|
+
* Append a model change as a child of the current leaf, then advance the leaf.
|
|
2908
1151
|
* @param model Model in "provider/modelId" format
|
|
2909
1152
|
* @param role Optional role (default: "default")
|
|
2910
1153
|
*/
|
|
2911
1154
|
appendModelChange(model: string, role?: string): string {
|
|
2912
|
-
const entry: ModelChangeEntry = {
|
|
2913
|
-
|
|
2914
|
-
id: generateId(this.#byId),
|
|
2915
|
-
parentId: this.#leafId,
|
|
2916
|
-
timestamp: new Date().toISOString(),
|
|
2917
|
-
model,
|
|
2918
|
-
role,
|
|
2919
|
-
};
|
|
2920
|
-
this.#appendEntry(entry);
|
|
1155
|
+
const entry: ModelChangeEntry = { type: "model_change", ...this.#freshEntryFields(), model, role };
|
|
1156
|
+
this.#recordEntry(entry);
|
|
2921
1157
|
return entry.id;
|
|
2922
1158
|
}
|
|
2923
1159
|
|
|
2924
|
-
/** Append session init metadata (for subagent debugging/replay). Returns entry id. */
|
|
2925
1160
|
appendSessionInit(init: { systemPrompt: string; task: string; tools: string[]; outputSchema?: unknown }): string {
|
|
2926
|
-
const entry: SessionInitEntry = {
|
|
2927
|
-
|
|
2928
|
-
id: generateId(this.#byId),
|
|
2929
|
-
parentId: this.#leafId,
|
|
2930
|
-
timestamp: new Date().toISOString(),
|
|
2931
|
-
...init,
|
|
2932
|
-
};
|
|
2933
|
-
this.#appendEntry(entry);
|
|
1161
|
+
const entry: SessionInitEntry = { type: "session_init", ...this.#freshEntryFields(), ...init };
|
|
1162
|
+
this.#recordEntry(entry);
|
|
2934
1163
|
return entry.id;
|
|
2935
1164
|
}
|
|
2936
1165
|
|
|
2937
|
-
/** Append a compaction summary as child of current leaf, then advance leaf. Returns entry id. */
|
|
2938
1166
|
appendCompaction<T = unknown>(
|
|
2939
1167
|
summary: string,
|
|
2940
1168
|
shortSummary: string | undefined,
|
|
@@ -2946,9 +1174,7 @@ export class SessionManager {
|
|
|
2946
1174
|
): string {
|
|
2947
1175
|
const entry: CompactionEntry<T> = {
|
|
2948
1176
|
type: "compaction",
|
|
2949
|
-
|
|
2950
|
-
parentId: this.#leafId,
|
|
2951
|
-
timestamp: new Date().toISOString(),
|
|
1177
|
+
...this.#freshEntryFields(),
|
|
2952
1178
|
summary,
|
|
2953
1179
|
shortSummary,
|
|
2954
1180
|
firstKeptEntryId,
|
|
@@ -2957,31 +1183,23 @@ export class SessionManager {
|
|
|
2957
1183
|
fromExtension,
|
|
2958
1184
|
preserveData,
|
|
2959
1185
|
};
|
|
2960
|
-
this.#
|
|
1186
|
+
this.#recordEntry(entry);
|
|
2961
1187
|
return entry.id;
|
|
2962
1188
|
}
|
|
2963
1189
|
|
|
2964
|
-
/** Append a custom entry (for extensions) as child of current leaf, then advance leaf. Returns entry id. */
|
|
2965
1190
|
appendCustomEntry(customType: string, data?: unknown): string {
|
|
2966
|
-
const entry: CustomEntry = {
|
|
2967
|
-
|
|
2968
|
-
customType,
|
|
2969
|
-
data,
|
|
2970
|
-
id: generateId(this.#byId),
|
|
2971
|
-
parentId: this.#leafId,
|
|
2972
|
-
timestamp: new Date().toISOString(),
|
|
2973
|
-
};
|
|
2974
|
-
this.#appendEntry(entry);
|
|
1191
|
+
const entry: CustomEntry = { type: "custom", customType, data, ...this.#freshEntryFields() };
|
|
1192
|
+
this.#recordEntry(entry);
|
|
2975
1193
|
return entry.id;
|
|
2976
1194
|
}
|
|
2977
1195
|
|
|
2978
1196
|
/**
|
|
2979
|
-
* Rewrite the session file after in-place entry updates.
|
|
2980
|
-
* Use sparingly
|
|
1197
|
+
* Rewrite the session file after in-place entry updates (e.g. pruning old tool
|
|
1198
|
+
* outputs). Use sparingly.
|
|
2981
1199
|
*/
|
|
2982
1200
|
async rewriteEntries(): Promise<void> {
|
|
2983
|
-
if (!this
|
|
2984
|
-
await this.#
|
|
1201
|
+
if (!this.#persist || !this.#sessionFile) return;
|
|
1202
|
+
await this.#rewriteAtomically();
|
|
2985
1203
|
}
|
|
2986
1204
|
|
|
2987
1205
|
/**
|
|
@@ -2991,7 +1209,6 @@ export class SessionManager {
|
|
|
2991
1209
|
* @param display Whether to show in TUI (true = styled display, false = hidden)
|
|
2992
1210
|
* @param details Optional extension-specific metadata (not sent to LLM)
|
|
2993
1211
|
* @param attribution Who initiated this message for billing/attribution semantics
|
|
2994
|
-
* @returns Entry id
|
|
2995
1212
|
*/
|
|
2996
1213
|
appendCustomMessageEntry<T = unknown>(
|
|
2997
1214
|
customType: string,
|
|
@@ -3005,401 +1222,244 @@ export class SessionManager {
|
|
|
3005
1222
|
customType,
|
|
3006
1223
|
content,
|
|
3007
1224
|
display,
|
|
3008
|
-
// Drop AgentSession-internal transient fields
|
|
3009
|
-
// `INTERNAL_DETAILS_FIELDS`) before disk persistence. Single
|
|
3010
|
-
// chokepoint covers every CustomMessage write path.
|
|
1225
|
+
// Drop AgentSession-internal transient fields before disk persistence.
|
|
3011
1226
|
details: stripInternalDetailsFields(details),
|
|
3012
1227
|
attribution,
|
|
3013
|
-
|
|
3014
|
-
parentId: this.#leafId,
|
|
3015
|
-
timestamp: new Date().toISOString(),
|
|
1228
|
+
...this.#freshEntryFields(),
|
|
3016
1229
|
};
|
|
3017
|
-
this.#
|
|
1230
|
+
this.#recordEntry(entry);
|
|
3018
1231
|
return entry.id;
|
|
3019
1232
|
}
|
|
3020
1233
|
|
|
3021
|
-
// =========================================================================
|
|
3022
|
-
// TTSR (Time Traveling Stream Rules)
|
|
3023
|
-
// =========================================================================
|
|
3024
|
-
|
|
3025
1234
|
/**
|
|
3026
1235
|
* Append an MCP tool selection entry recording the discovery-selected MCP tools.
|
|
3027
|
-
* @param selectedToolNames MCP tool names selected for this branch
|
|
3028
|
-
* @returns Entry id
|
|
3029
1236
|
*/
|
|
3030
1237
|
appendMCPToolSelection(selectedToolNames: string[]): string {
|
|
3031
1238
|
const entry: MCPToolSelectionEntry = {
|
|
3032
1239
|
type: "mcp_tool_selection",
|
|
3033
|
-
|
|
3034
|
-
parentId: this.#leafId,
|
|
3035
|
-
timestamp: new Date().toISOString(),
|
|
1240
|
+
...this.#freshEntryFields(),
|
|
3036
1241
|
selectedToolNames: [...selectedToolNames],
|
|
3037
1242
|
};
|
|
3038
|
-
this.#
|
|
1243
|
+
this.#recordEntry(entry);
|
|
3039
1244
|
return entry.id;
|
|
3040
1245
|
}
|
|
3041
1246
|
|
|
3042
|
-
/**
|
|
3043
|
-
* Append a TTSR injection entry recording which rules were injected.
|
|
3044
|
-
* @param ruleNames Names of rules that were injected
|
|
3045
|
-
* @returns Entry id
|
|
3046
|
-
*/
|
|
1247
|
+
/** Append a TTSR injection entry recording which rules were injected. */
|
|
3047
1248
|
appendTtsrInjection(ruleNames: string[]): string {
|
|
3048
1249
|
const entry: TtsrInjectionEntry = {
|
|
3049
1250
|
type: "ttsr_injection",
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
timestamp: new Date().toISOString(),
|
|
3053
|
-
injectedRules: ruleNames,
|
|
1251
|
+
...this.#freshEntryFields(),
|
|
1252
|
+
injectedRules: [...ruleNames],
|
|
3054
1253
|
};
|
|
3055
|
-
this.#
|
|
1254
|
+
this.#recordEntry(entry);
|
|
3056
1255
|
return entry.id;
|
|
3057
1256
|
}
|
|
3058
1257
|
|
|
3059
|
-
/**
|
|
3060
|
-
* Get all unique TTSR rule names that have been injected in the current branch.
|
|
3061
|
-
* Scans from root to current leaf for ttsr_injection entries.
|
|
3062
|
-
*/
|
|
1258
|
+
/** All unique TTSR rule names injected on the current branch (root → leaf). */
|
|
3063
1259
|
getInjectedTtsrRules(): string[] {
|
|
3064
|
-
const
|
|
3065
|
-
const
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
for (const name of entry.injectedRules) {
|
|
3069
|
-
ruleNames.add(name);
|
|
3070
|
-
}
|
|
3071
|
-
}
|
|
1260
|
+
const names = new Set<string>();
|
|
1261
|
+
for (const entry of this.getBranch()) {
|
|
1262
|
+
if (entry.type !== "ttsr_injection") continue;
|
|
1263
|
+
for (const name of entry.injectedRules) names.add(name);
|
|
3072
1264
|
}
|
|
3073
|
-
return
|
|
1265
|
+
return [...names];
|
|
3074
1266
|
}
|
|
3075
1267
|
|
|
3076
|
-
// =========================================================================
|
|
3077
|
-
// Tree Traversal
|
|
3078
|
-
// =========================================================================
|
|
3079
|
-
|
|
3080
1268
|
getLeafId(): string | null {
|
|
3081
|
-
return this.#leafId;
|
|
1269
|
+
return this.#index.leafId();
|
|
3082
1270
|
}
|
|
3083
1271
|
|
|
3084
1272
|
getLeafEntry(): SessionEntry | undefined {
|
|
3085
|
-
return this.#
|
|
1273
|
+
return this.#index.leafEntry();
|
|
3086
1274
|
}
|
|
3087
1275
|
|
|
3088
1276
|
/**
|
|
3089
|
-
*
|
|
3090
|
-
*
|
|
1277
|
+
* The most recent model role on the current branch, or undefined when no
|
|
1278
|
+
* model change has been recorded.
|
|
3091
1279
|
*/
|
|
3092
1280
|
getLastModelChangeRole(): string | undefined {
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
}
|
|
3098
|
-
current = current.parentId ? this.#byId.get(current.parentId) : undefined;
|
|
1281
|
+
const branch = this.getBranch();
|
|
1282
|
+
for (let index = branch.length - 1; index >= 0; index--) {
|
|
1283
|
+
const entry = branch[index];
|
|
1284
|
+
if (entry.type === "model_change") return entry.role ?? "default";
|
|
3099
1285
|
}
|
|
3100
1286
|
return undefined;
|
|
3101
1287
|
}
|
|
3102
1288
|
|
|
3103
1289
|
getEntry(id: string): SessionEntry | undefined {
|
|
3104
|
-
return this.#
|
|
1290
|
+
return this.#index.get(id);
|
|
3105
1291
|
}
|
|
3106
1292
|
|
|
3107
|
-
/**
|
|
3108
|
-
* Get all direct children of an entry.
|
|
3109
|
-
*/
|
|
1293
|
+
/** All direct children of an entry. */
|
|
3110
1294
|
getChildren(parentId: string): SessionEntry[] {
|
|
3111
|
-
|
|
3112
|
-
for (const entry of this.#byId.values()) {
|
|
3113
|
-
if (entry.parentId === parentId) {
|
|
3114
|
-
children.push(entry);
|
|
3115
|
-
}
|
|
3116
|
-
}
|
|
3117
|
-
return children;
|
|
1295
|
+
return this.#index.childrenOf(parentId);
|
|
3118
1296
|
}
|
|
3119
1297
|
|
|
3120
|
-
/**
|
|
3121
|
-
* Get the label for an entry, if any.
|
|
3122
|
-
*/
|
|
3123
1298
|
getLabel(id: string): string | undefined {
|
|
3124
|
-
return this.#
|
|
1299
|
+
return this.#index.labelFor(id);
|
|
3125
1300
|
}
|
|
3126
1301
|
|
|
3127
1302
|
/**
|
|
3128
|
-
* Set or clear a label on an entry.
|
|
3129
|
-
* Labels are user-defined markers for bookmarking/navigation.
|
|
3130
|
-
* Pass undefined or empty string to clear the label.
|
|
1303
|
+
* Set or clear a label on an entry. Pass undefined/empty to clear.
|
|
3131
1304
|
*/
|
|
3132
1305
|
appendLabelChange(targetId: string, label: string | undefined): string {
|
|
3133
|
-
if (!this.#
|
|
3134
|
-
|
|
3135
|
-
}
|
|
3136
|
-
|
|
3137
|
-
type: "label",
|
|
3138
|
-
id: generateId(this.#byId),
|
|
3139
|
-
parentId: this.#leafId,
|
|
3140
|
-
timestamp: new Date().toISOString(),
|
|
3141
|
-
targetId,
|
|
3142
|
-
label,
|
|
3143
|
-
};
|
|
3144
|
-
this.#appendEntry(entry);
|
|
3145
|
-
if (label) {
|
|
3146
|
-
this.#labelsById.set(targetId, label);
|
|
3147
|
-
} else {
|
|
3148
|
-
this.#labelsById.delete(targetId);
|
|
3149
|
-
}
|
|
1306
|
+
if (!this.#index.has(targetId)) throw new Error(`Entry ${targetId} not found`);
|
|
1307
|
+
|
|
1308
|
+
const entry: LabelEntry = { type: "label", ...this.#freshEntryFields(), targetId, label };
|
|
1309
|
+
this.#recordEntry(entry);
|
|
3150
1310
|
return entry.id;
|
|
3151
1311
|
}
|
|
3152
1312
|
|
|
3153
1313
|
/**
|
|
3154
|
-
* Walk from entry to root, returning
|
|
3155
|
-
*
|
|
3156
|
-
* Use buildSessionContext() to get the resolved messages for the LLM.
|
|
1314
|
+
* Walk from an entry to root, returning entries in path order. Includes all
|
|
1315
|
+
* entry types; use buildSessionContext() for the resolved LLM messages.
|
|
3157
1316
|
*/
|
|
3158
1317
|
getBranch(fromId?: string): SessionEntry[] {
|
|
3159
|
-
|
|
3160
|
-
const startId = fromId ?? this.#leafId;
|
|
3161
|
-
let current = startId ? this.#byId.get(startId) : undefined;
|
|
3162
|
-
while (current) {
|
|
3163
|
-
path.unshift(current);
|
|
3164
|
-
current = current.parentId ? this.#byId.get(current.parentId) : undefined;
|
|
3165
|
-
}
|
|
3166
|
-
return path;
|
|
1318
|
+
return this.#index.pathTo(fromId ?? this.#index.leafId());
|
|
3167
1319
|
}
|
|
3168
1320
|
|
|
3169
1321
|
/**
|
|
3170
|
-
* Build the session context (
|
|
3171
|
-
*
|
|
1322
|
+
* Build the session context (LLM messages), or — with `{ transcript: true }` —
|
|
1323
|
+
* the full-history display transcript, from the current leaf path.
|
|
3172
1324
|
*/
|
|
3173
|
-
buildSessionContext(): SessionContext {
|
|
3174
|
-
return buildSessionContext(this
|
|
1325
|
+
buildSessionContext(options?: BuildSessionContextOptions): SessionContext {
|
|
1326
|
+
return buildSessionContext(this.#entries, this.#index.leafId(), this.#index.entriesById(), options);
|
|
3175
1327
|
}
|
|
3176
1328
|
|
|
3177
|
-
/** Strip stale OpenAI Responses assistant replay metadata from loaded
|
|
1329
|
+
/** Strip stale OpenAI Responses assistant replay metadata from loaded entries. */
|
|
3178
1330
|
sanitizeLoadedOpenAIResponsesReplayMetadata(): boolean {
|
|
3179
|
-
let
|
|
3180
|
-
for (const entry of this.#
|
|
3181
|
-
if (entry.type !== "message" || entry.message.role !== "assistant")
|
|
3182
|
-
continue;
|
|
3183
|
-
}
|
|
1331
|
+
let changed = false;
|
|
1332
|
+
for (const entry of this.#entries) {
|
|
1333
|
+
if (entry.type !== "message" || entry.message.role !== "assistant") continue;
|
|
3184
1334
|
|
|
3185
|
-
const
|
|
3186
|
-
if (
|
|
3187
|
-
continue;
|
|
3188
|
-
}
|
|
1335
|
+
const sanitized = sanitizeRehydratedOpenAIResponsesAssistantMessage(entry.message);
|
|
1336
|
+
if (sanitized === entry.message) continue;
|
|
3189
1337
|
|
|
3190
|
-
entry.message =
|
|
3191
|
-
|
|
1338
|
+
entry.message = sanitized;
|
|
1339
|
+
changed = true;
|
|
3192
1340
|
}
|
|
3193
1341
|
|
|
3194
|
-
return
|
|
1342
|
+
return changed;
|
|
3195
1343
|
}
|
|
3196
1344
|
|
|
3197
|
-
/**
|
|
3198
|
-
* Get session header.
|
|
3199
|
-
*/
|
|
3200
1345
|
getHeader(): SessionHeader | null {
|
|
3201
|
-
|
|
3202
|
-
return h ? (h as SessionHeader) : null;
|
|
1346
|
+
return this.#header;
|
|
3203
1347
|
}
|
|
3204
1348
|
|
|
3205
|
-
/**
|
|
3206
|
-
* Get all session entries (excludes header). Returns a shallow copy.
|
|
3207
|
-
* The session is append-only: use appendXXX() to add entries, branch() to
|
|
3208
|
-
* change the leaf pointer. Entries cannot be modified or deleted.
|
|
3209
|
-
*/
|
|
1349
|
+
/** All session entries (excludes header). Returns a shallow copy. */
|
|
3210
1350
|
getEntries(): SessionEntry[] {
|
|
3211
|
-
return this.#
|
|
1351
|
+
return [...this.#entries];
|
|
3212
1352
|
}
|
|
3213
1353
|
|
|
3214
1354
|
/**
|
|
3215
|
-
*
|
|
3216
|
-
*
|
|
3217
|
-
* Orphaned entries (broken parent chain) are also returned as roots.
|
|
1355
|
+
* The session as a tree. A well-formed session has exactly one root; orphaned
|
|
1356
|
+
* entries (broken parent chain) are returned as roots too.
|
|
3218
1357
|
*/
|
|
3219
1358
|
getTree(): SessionTreeNode[] {
|
|
3220
|
-
|
|
3221
|
-
const nodeMap = new Map<string, SessionTreeNode>();
|
|
3222
|
-
const roots: SessionTreeNode[] = [];
|
|
3223
|
-
|
|
3224
|
-
// Create nodes with resolved labels
|
|
3225
|
-
for (const entry of entries) {
|
|
3226
|
-
const label = this.#labelsById.get(entry.id);
|
|
3227
|
-
nodeMap.set(entry.id, { entry, children: [], label });
|
|
3228
|
-
}
|
|
3229
|
-
|
|
3230
|
-
// Build tree
|
|
3231
|
-
for (const entry of entries) {
|
|
3232
|
-
const node = nodeMap.get(entry.id)!;
|
|
3233
|
-
if (entry.parentId === null || entry.parentId === entry.id) {
|
|
3234
|
-
roots.push(node);
|
|
3235
|
-
} else {
|
|
3236
|
-
const parent = nodeMap.get(entry.parentId);
|
|
3237
|
-
if (parent) {
|
|
3238
|
-
parent.children.push(node);
|
|
3239
|
-
} else {
|
|
3240
|
-
// Orphan - treat as root
|
|
3241
|
-
roots.push(node);
|
|
3242
|
-
}
|
|
3243
|
-
}
|
|
3244
|
-
}
|
|
3245
|
-
|
|
3246
|
-
// Sort children by timestamp (oldest first, newest at bottom)
|
|
3247
|
-
// Use iterative approach to avoid stack overflow on deep trees
|
|
3248
|
-
const stack: SessionTreeNode[] = [...roots];
|
|
3249
|
-
while (stack.length > 0) {
|
|
3250
|
-
const node = stack.pop()!;
|
|
3251
|
-
node.children.sort((a, b) => new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime());
|
|
3252
|
-
stack.push(...node.children);
|
|
3253
|
-
}
|
|
3254
|
-
|
|
3255
|
-
return roots;
|
|
1359
|
+
return this.#index.tree(this.#entries);
|
|
3256
1360
|
}
|
|
3257
1361
|
|
|
3258
|
-
// =========================================================================
|
|
3259
|
-
// Branching
|
|
3260
|
-
// =========================================================================
|
|
3261
|
-
|
|
3262
1362
|
/**
|
|
3263
|
-
*
|
|
3264
|
-
*
|
|
3265
|
-
* will create a child of that entry, forming a new branch. Existing entries
|
|
3266
|
-
* are not modified or deleted.
|
|
1363
|
+
* Move the leaf to an earlier entry so the next append forms a new branch.
|
|
1364
|
+
* Existing entries are never modified or deleted.
|
|
3267
1365
|
*/
|
|
3268
1366
|
branch(branchFromId: string): void {
|
|
3269
|
-
if (!this.#
|
|
3270
|
-
|
|
3271
|
-
}
|
|
3272
|
-
this.#leafId = branchFromId;
|
|
1367
|
+
if (!this.#index.has(branchFromId)) throw new Error(`Entry ${branchFromId} not found`);
|
|
1368
|
+
this.#index.setLeaf(branchFromId);
|
|
3273
1369
|
}
|
|
3274
1370
|
|
|
3275
|
-
/**
|
|
3276
|
-
* Reset the leaf pointer to null (before any entries).
|
|
3277
|
-
* The next appendXXX() call will create a new root entry (parentId = null).
|
|
3278
|
-
* Use this when navigating to re-edit the first user message.
|
|
3279
|
-
*/
|
|
1371
|
+
/** Reset the leaf to null so the next append creates a new root entry. */
|
|
3280
1372
|
resetLeaf(): void {
|
|
3281
|
-
this.#
|
|
1373
|
+
this.#index.setLeaf(null);
|
|
3282
1374
|
}
|
|
3283
1375
|
|
|
3284
|
-
/**
|
|
3285
|
-
* Start a new branch with a summary of the abandoned path.
|
|
3286
|
-
* Same as branch(), but also appends a branch_summary entry that captures
|
|
3287
|
-
* context from the abandoned conversation path.
|
|
3288
|
-
*/
|
|
1376
|
+
/** Like branch(), but also records a branch_summary of the abandoned path. */
|
|
3289
1377
|
branchWithSummary(branchFromId: string | null, summary: string, details?: unknown, fromExtension?: boolean): string {
|
|
3290
|
-
if (branchFromId !== null && !this.#
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
this.#leafId = branchFromId;
|
|
1378
|
+
if (branchFromId !== null && !this.#index.has(branchFromId)) throw new Error(`Entry ${branchFromId} not found`);
|
|
1379
|
+
|
|
1380
|
+
this.#index.setLeaf(branchFromId);
|
|
3294
1381
|
const entry: BranchSummaryEntry = {
|
|
3295
1382
|
type: "branch_summary",
|
|
3296
|
-
id: generateId(this.#
|
|
1383
|
+
id: generateId(this.#index),
|
|
3297
1384
|
parentId: branchFromId,
|
|
3298
|
-
timestamp:
|
|
1385
|
+
timestamp: nowIso(),
|
|
3299
1386
|
fromId: branchFromId ?? "root",
|
|
3300
1387
|
summary,
|
|
3301
1388
|
details,
|
|
3302
1389
|
fromExtension,
|
|
3303
1390
|
};
|
|
3304
|
-
this.#
|
|
1391
|
+
this.#recordEntry(entry);
|
|
3305
1392
|
return entry.id;
|
|
3306
1393
|
}
|
|
3307
1394
|
|
|
3308
1395
|
/**
|
|
3309
|
-
* Create a new session file containing only the path from root to
|
|
3310
|
-
*
|
|
3311
|
-
* Returns the new session file path, or undefined if not persisting.
|
|
1396
|
+
* Create a new session file containing only the path from root to `leafId`.
|
|
1397
|
+
* Returns the new file path, or undefined when not persisting.
|
|
3312
1398
|
*/
|
|
3313
1399
|
createBranchedSession(leafId: string): string | undefined {
|
|
3314
|
-
const
|
|
1400
|
+
const sourceSessionFile = this.#sessionFile;
|
|
3315
1401
|
const branchPath = this.getBranch(leafId);
|
|
3316
|
-
if (branchPath.length === 0) {
|
|
3317
|
-
throw new Error(`Entry ${leafId} not found`);
|
|
3318
|
-
}
|
|
1402
|
+
if (branchPath.length === 0) throw new Error(`Entry ${leafId} not found`);
|
|
3319
1403
|
|
|
3320
|
-
//
|
|
3321
|
-
const
|
|
3322
|
-
|
|
3323
|
-
const
|
|
3324
|
-
const
|
|
3325
|
-
|
|
3326
|
-
|
|
1404
|
+
// Drop label entries from the path; recreate them fresh from the resolved map.
|
|
1405
|
+
const entriesToKeep = branchPath.filter(entry => entry.type !== "label");
|
|
1406
|
+
const keptIds = new Set(entriesToKeep.map(entry => entry.id));
|
|
1407
|
+
const labelsToCarry: Array<{ targetId: string; label: string }> = [];
|
|
1408
|
+
for (const [targetId, label] of this.#index.labelsInEffect()) {
|
|
1409
|
+
if (keptIds.has(targetId)) labelsToCarry.push({ targetId, label });
|
|
1410
|
+
}
|
|
3327
1411
|
|
|
1412
|
+
const timestamp = nowIso();
|
|
1413
|
+
const newSessionId = mintSessionId();
|
|
1414
|
+
const newSessionFile = path.join(this.#sessionDir, `${fileSafeTimestamp(timestamp)}_${newSessionId}.jsonl`);
|
|
3328
1415
|
const header: SessionHeader = {
|
|
3329
1416
|
type: "session",
|
|
3330
1417
|
version: CURRENT_SESSION_VERSION,
|
|
3331
1418
|
id: newSessionId,
|
|
3332
1419
|
timestamp,
|
|
3333
|
-
cwd: this
|
|
3334
|
-
parentSession: this
|
|
1420
|
+
cwd: this.#cwd,
|
|
1421
|
+
parentSession: this.#persist ? sourceSessionFile : undefined,
|
|
3335
1422
|
};
|
|
3336
1423
|
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
const
|
|
3340
|
-
for (const [targetId, label] of this.#labelsById) {
|
|
3341
|
-
if (pathEntryIds.has(targetId)) {
|
|
3342
|
-
labelsToWrite.push({ targetId, label });
|
|
3343
|
-
}
|
|
3344
|
-
}
|
|
3345
|
-
|
|
3346
|
-
if (this.persist) {
|
|
3347
|
-
const lines: string[] = [];
|
|
3348
|
-
lines.push(JSON.stringify(header));
|
|
3349
|
-
for (const entry of pathWithoutLabels) {
|
|
3350
|
-
lines.push(JSON.stringify(entry));
|
|
3351
|
-
}
|
|
3352
|
-
// Write fresh label entries at the end
|
|
3353
|
-
const lastEntryId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null;
|
|
3354
|
-
let parentId = lastEntryId;
|
|
3355
|
-
const labelEntries: LabelEntry[] = [];
|
|
3356
|
-
for (const { targetId, label } of labelsToWrite) {
|
|
3357
|
-
const labelEntry: LabelEntry = {
|
|
3358
|
-
type: "label",
|
|
3359
|
-
id: generateId(new Set(pathEntryIds)),
|
|
3360
|
-
parentId,
|
|
3361
|
-
timestamp: new Date().toISOString(),
|
|
3362
|
-
targetId,
|
|
3363
|
-
label,
|
|
3364
|
-
};
|
|
3365
|
-
lines.push(JSON.stringify(labelEntry));
|
|
3366
|
-
pathEntryIds.add(labelEntry.id);
|
|
3367
|
-
labelEntries.push(labelEntry);
|
|
3368
|
-
parentId = labelEntry.id;
|
|
3369
|
-
}
|
|
3370
|
-
this.storage.writeTextSync(newSessionFile, `${lines.join("\n")}\n`);
|
|
3371
|
-
this.#fileEntries = [header, ...pathWithoutLabels, ...labelEntries];
|
|
3372
|
-
this.#sessionId = newSessionId;
|
|
3373
|
-
this.#sessionFile = newSessionFile;
|
|
3374
|
-
this.#flushed = true;
|
|
3375
|
-
this.#buildIndex();
|
|
3376
|
-
return newSessionFile;
|
|
3377
|
-
}
|
|
3378
|
-
|
|
3379
|
-
// In-memory mode: replace current session with the path + labels
|
|
3380
|
-
const labelEntries: LabelEntry[] = [];
|
|
3381
|
-
let parentId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null;
|
|
3382
|
-
for (const { targetId, label } of labelsToWrite) {
|
|
1424
|
+
const labels: LabelEntry[] = [];
|
|
1425
|
+
let parentId = entriesToKeep[entriesToKeep.length - 1]?.id ?? null;
|
|
1426
|
+
for (const carried of labelsToCarry) {
|
|
3383
1427
|
const labelEntry: LabelEntry = {
|
|
3384
1428
|
type: "label",
|
|
3385
|
-
id: generateId(new Set([...
|
|
1429
|
+
id: generateId(new Set([...keptIds, ...labels.map(entry => entry.id)])),
|
|
3386
1430
|
parentId,
|
|
3387
|
-
timestamp:
|
|
3388
|
-
targetId,
|
|
3389
|
-
label,
|
|
1431
|
+
timestamp: nowIso(),
|
|
1432
|
+
targetId: carried.targetId,
|
|
1433
|
+
label: carried.label,
|
|
3390
1434
|
};
|
|
3391
|
-
|
|
1435
|
+
labels.push(labelEntry);
|
|
3392
1436
|
parentId = labelEntry.id;
|
|
3393
1437
|
}
|
|
3394
|
-
|
|
1438
|
+
|
|
1439
|
+
this.#header = header;
|
|
1440
|
+
this.#entries = [...entriesToKeep, ...labels];
|
|
3395
1441
|
this.#sessionId = newSessionId;
|
|
3396
|
-
this.#
|
|
3397
|
-
|
|
1442
|
+
this.#sessionName = header.title;
|
|
1443
|
+
this.#titleSource = header.titleSource;
|
|
1444
|
+
this.#index.rebuild(this.#entries);
|
|
1445
|
+
this.#artifactManager = null;
|
|
1446
|
+
this.#artifactManagerSessionFile = null;
|
|
1447
|
+
this.#forceFileCreation = this.#persist;
|
|
1448
|
+
|
|
1449
|
+
if (!this.#persist) {
|
|
1450
|
+
this.#sessionFile = undefined;
|
|
1451
|
+
this.#fileIsCurrent = false;
|
|
1452
|
+
this.#rewriteRequired = false;
|
|
1453
|
+
return undefined;
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
this.#sessionFile = newSessionFile;
|
|
1457
|
+
this.#rewriteSynchronously();
|
|
1458
|
+
this.#rememberBreadcrumb(this.#cwd, newSessionFile);
|
|
1459
|
+
return newSessionFile;
|
|
3398
1460
|
}
|
|
3399
1461
|
|
|
3400
|
-
/**
|
|
3401
|
-
* Resolve the canonical default session directory for a cwd.
|
|
3402
|
-
*/
|
|
1462
|
+
/** Resolve the canonical default session directory for a cwd. */
|
|
3403
1463
|
static getDefaultSessionDir(
|
|
3404
1464
|
cwd: string,
|
|
3405
1465
|
agentDir?: string,
|
|
@@ -3410,104 +1470,143 @@ export class SessionManager {
|
|
|
3410
1470
|
|
|
3411
1471
|
/**
|
|
3412
1472
|
* Create a new session.
|
|
3413
|
-
* @param cwd Working directory (stored in session header)
|
|
3414
|
-
* @param sessionDir Optional session directory
|
|
1473
|
+
* @param cwd Working directory (stored in the session header)
|
|
1474
|
+
* @param sessionDir Optional session directory; defaults to the cwd-derived dir.
|
|
3415
1475
|
*/
|
|
3416
1476
|
static create(cwd: string, sessionDir?: string, storage: SessionStorage = new FileSessionStorage()): SessionManager {
|
|
3417
1477
|
const dir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage);
|
|
3418
1478
|
const manager = new SessionManager(cwd, dir, true, storage);
|
|
3419
|
-
manager.#
|
|
1479
|
+
manager.#resetToNewSession();
|
|
3420
1480
|
return manager;
|
|
3421
1481
|
}
|
|
3422
1482
|
|
|
3423
1483
|
/**
|
|
3424
|
-
* Fork a session into the current project directory
|
|
3425
|
-
*
|
|
1484
|
+
* Fork a session into the current project directory: copy history from another
|
|
1485
|
+
* session file while creating a fresh session file in this sessionDir.
|
|
3426
1486
|
*/
|
|
3427
1487
|
static async forkFrom(
|
|
3428
1488
|
sourcePath: string,
|
|
3429
1489
|
cwd: string,
|
|
3430
1490
|
sessionDir?: string,
|
|
3431
1491
|
storage: SessionStorage = new FileSessionStorage(),
|
|
1492
|
+
options?: { suppressBreadcrumb?: boolean },
|
|
3432
1493
|
): Promise<SessionManager> {
|
|
3433
1494
|
const dir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage);
|
|
3434
1495
|
const manager = new SessionManager(cwd, dir, true, storage);
|
|
3435
|
-
|
|
3436
|
-
|
|
3437
|
-
await
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
3441
|
-
const
|
|
3442
|
-
|
|
3443
|
-
|
|
3444
|
-
manager.#
|
|
3445
|
-
manager.#
|
|
3446
|
-
manager.#
|
|
1496
|
+
manager.#suppressBreadcrumb = options?.suppressBreadcrumb === true;
|
|
1497
|
+
|
|
1498
|
+
const sourceEntries = structuredClone(await loadEntriesFromFile(sourcePath, storage)) as FileEntry[];
|
|
1499
|
+
migrateToCurrentVersion(sourceEntries);
|
|
1500
|
+
await resolveBlobRefsInEntries(sourceEntries, manager.#blobs);
|
|
1501
|
+
|
|
1502
|
+
const sourceHeader = sourceEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
|
|
1503
|
+
const history = sourceEntries.filter(entry => entry.type !== "session") as SessionEntry[];
|
|
1504
|
+
manager.#resetToNewSession({ parentSession: sourceHeader?.id });
|
|
1505
|
+
manager.#header.title = sourceHeader?.title;
|
|
1506
|
+
manager.#header.titleSource = sourceHeader?.titleSource;
|
|
1507
|
+
manager.#sessionName = manager.#header.title;
|
|
1508
|
+
manager.#titleSource = manager.#header.titleSource;
|
|
1509
|
+
manager.#entries = history;
|
|
1510
|
+
manager.#index.rebuild(history);
|
|
3447
1511
|
manager.sanitizeLoadedOpenAIResponsesReplayMetadata();
|
|
3448
|
-
manager.#
|
|
3449
|
-
await manager.#
|
|
1512
|
+
manager.#forceFileCreation = true;
|
|
1513
|
+
await manager.#rewriteAtomically();
|
|
3450
1514
|
return manager;
|
|
3451
1515
|
}
|
|
3452
1516
|
|
|
3453
1517
|
/**
|
|
3454
1518
|
* Open a specific session file.
|
|
3455
|
-
* @param
|
|
3456
|
-
* @param sessionDir Optional session directory for /new or /branch. If omitted, derives from file's parent.
|
|
1519
|
+
* @param sessionDir Optional dir for /new or /branch; defaults to the file's parent.
|
|
3457
1520
|
*/
|
|
3458
1521
|
static async open(
|
|
3459
1522
|
filePath: string,
|
|
3460
1523
|
sessionDir?: string,
|
|
3461
1524
|
storage: SessionStorage = new FileSessionStorage(),
|
|
3462
1525
|
): Promise<SessionManager> {
|
|
3463
|
-
|
|
3464
|
-
const
|
|
3465
|
-
const header = entries.find(e => e.type === "session") as SessionHeader | undefined;
|
|
1526
|
+
const loaded = await loadEntriesFromFile(filePath, storage);
|
|
1527
|
+
const header = loaded.find(entry => entry.type === "session") as SessionHeader | undefined;
|
|
3466
1528
|
const cwd = header?.cwd ?? getProjectDir();
|
|
3467
|
-
|
|
3468
|
-
const dir = sessionDir ?? path.resolve(filePath, "..");
|
|
1529
|
+
const dir = sessionDir ?? path.dirname(path.resolve(filePath));
|
|
3469
1530
|
const manager = new SessionManager(cwd, dir, true, storage);
|
|
3470
|
-
await manager
|
|
1531
|
+
await manager.setSessionFile(filePath);
|
|
3471
1532
|
return manager;
|
|
3472
1533
|
}
|
|
3473
1534
|
|
|
3474
|
-
/**
|
|
3475
|
-
* Continue the most recent session, or create new if none.
|
|
3476
|
-
* @param cwd Working directory
|
|
3477
|
-
* @param sessionDir Optional session directory. If omitted, uses default (~/.prometheus/agent/sessions/<encoded-cwd>/).
|
|
3478
|
-
*/
|
|
1535
|
+
/** Continue the most recent session, or create a new one if none exists. */
|
|
3479
1536
|
static async continueRecent(
|
|
3480
1537
|
cwd: string,
|
|
3481
1538
|
sessionDir?: string,
|
|
3482
1539
|
storage: SessionStorage = new FileSessionStorage(),
|
|
3483
1540
|
): Promise<SessionManager> {
|
|
3484
1541
|
const dir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage);
|
|
3485
|
-
|
|
3486
|
-
const
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
if (
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
1542
|
+
const resolvedCwd = path.resolve(cwd);
|
|
1543
|
+
const breadcrumb = await readTerminalBreadcrumbEntry();
|
|
1544
|
+
let chosenSession: string | null | undefined;
|
|
1545
|
+
|
|
1546
|
+
if (breadcrumb) {
|
|
1547
|
+
const breadcrumbCwd = path.resolve(breadcrumb.cwd);
|
|
1548
|
+
if (breadcrumbCwd === resolvedCwd) {
|
|
1549
|
+
chosenSession = breadcrumb.sessionFile;
|
|
1550
|
+
} else {
|
|
1551
|
+
// The terminal's last session started in a different cwd. If that cwd is
|
|
1552
|
+
// gone (worktree move/rename) and this location has no sessions of its
|
|
1553
|
+
// own, re-root the moved session here instead of starting fresh. When an
|
|
1554
|
+
// explicit sessionDir is reused across the move, the stale breadcrumb file
|
|
1555
|
+
// may be the newest entry there; prefer a genuine current-cwd session.
|
|
1556
|
+
let newestInTargetDir = await findMostRecentSession(dir, storage);
|
|
1557
|
+
const breadcrumbFile = path.resolve(breadcrumb.sessionFile);
|
|
1558
|
+
const breadcrumbCwdMissing = !fs.existsSync(breadcrumbCwd);
|
|
1559
|
+
const newestIsBreadcrumb = newestInTargetDir ? path.resolve(newestInTargetDir) === breadcrumbFile : false;
|
|
1560
|
+
let currentProjectAlreadyHasSession = false;
|
|
1561
|
+
|
|
1562
|
+
if (breadcrumbCwdMissing && newestIsBreadcrumb) {
|
|
1563
|
+
const localSession = (await SessionManager.list(cwd, dir, storage)).find(
|
|
1564
|
+
session =>
|
|
1565
|
+
path.resolve(session.path) !== breadcrumbFile &&
|
|
1566
|
+
session.cwd &&
|
|
1567
|
+
path.resolve(session.cwd) === resolvedCwd,
|
|
1568
|
+
);
|
|
1569
|
+
if (localSession) {
|
|
1570
|
+
newestInTargetDir = localSession.path;
|
|
1571
|
+
currentProjectAlreadyHasSession = true;
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
const looksLikeMovedProject =
|
|
1576
|
+
breadcrumbCwdMissing &&
|
|
1577
|
+
(newestInTargetDir === null || (newestIsBreadcrumb && !currentProjectAlreadyHasSession));
|
|
1578
|
+
if (looksLikeMovedProject) {
|
|
1579
|
+
logger.info("Re-rooting moved session", { from: breadcrumbCwd, to: resolvedCwd });
|
|
1580
|
+
const manager = await SessionManager.open(breadcrumb.sessionFile, undefined, storage);
|
|
1581
|
+
await manager.moveTo(cwd, sessionDir);
|
|
1582
|
+
return manager;
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
chosenSession = newestInTargetDir;
|
|
1586
|
+
}
|
|
3493
1587
|
}
|
|
1588
|
+
|
|
1589
|
+
if (chosenSession === undefined) chosenSession = await findMostRecentSession(dir, storage);
|
|
1590
|
+
|
|
1591
|
+
const manager = new SessionManager(cwd, dir, true, storage);
|
|
1592
|
+
if (chosenSession) await manager.setSessionFile(chosenSession);
|
|
1593
|
+
else manager.#resetToNewSession();
|
|
3494
1594
|
return manager;
|
|
3495
1595
|
}
|
|
3496
1596
|
|
|
3497
|
-
/** Create an in-memory session (no file persistence) */
|
|
1597
|
+
/** Create an in-memory session (no file persistence). */
|
|
3498
1598
|
static inMemory(
|
|
3499
1599
|
cwd: string = getProjectDir(),
|
|
3500
1600
|
storage: SessionStorage = new MemorySessionStorage(),
|
|
3501
1601
|
): SessionManager {
|
|
3502
1602
|
const manager = new SessionManager(cwd, "", false, storage);
|
|
3503
|
-
manager.#
|
|
1603
|
+
manager.#resetToNewSession();
|
|
3504
1604
|
return manager;
|
|
3505
1605
|
}
|
|
3506
1606
|
|
|
3507
1607
|
/**
|
|
3508
|
-
* List
|
|
3509
|
-
* @param
|
|
3510
|
-
* @param sessionDir Optional session directory. If omitted, uses default (~/.prometheus/agent/sessions/<encoded-cwd>/).
|
|
1608
|
+
* List sessions for a project directory.
|
|
1609
|
+
* @param sessionDir Optional dir; defaults to the cwd-derived dir.
|
|
3511
1610
|
*/
|
|
3512
1611
|
static async list(
|
|
3513
1612
|
cwd: string,
|
|
@@ -3515,27 +1614,11 @@ export class SessionManager {
|
|
|
3515
1614
|
storage: SessionStorage = new FileSessionStorage(),
|
|
3516
1615
|
): Promise<SessionInfo[]> {
|
|
3517
1616
|
const dir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage);
|
|
3518
|
-
|
|
3519
|
-
await recoverOrphanedBackups(dir, storage);
|
|
3520
|
-
const files = storage.listFilesSync(dir, "*.jsonl");
|
|
3521
|
-
return await collectSessionsFromFiles(files, storage);
|
|
3522
|
-
} catch {
|
|
3523
|
-
return [];
|
|
3524
|
-
}
|
|
1617
|
+
return listSessions(dir, storage);
|
|
3525
1618
|
}
|
|
3526
1619
|
|
|
3527
|
-
/**
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
static async listAll(storage: SessionStorage = new FileSessionStorage()): Promise<SessionInfo[]> {
|
|
3531
|
-
const sessionsRoot = path.join(getDefaultAgentDir(), "sessions");
|
|
3532
|
-
try {
|
|
3533
|
-
const files = await Array.fromAsync(new Bun.Glob("*/*.jsonl").scan(sessionsRoot), name =>
|
|
3534
|
-
path.join(sessionsRoot, name),
|
|
3535
|
-
);
|
|
3536
|
-
return await collectSessionsFromFiles(files, storage);
|
|
3537
|
-
} catch {
|
|
3538
|
-
return [];
|
|
3539
|
-
}
|
|
1620
|
+
/** List all sessions across all project directories. */
|
|
1621
|
+
static listAll(storage: SessionStorage = new FileSessionStorage()): Promise<SessionInfo[]> {
|
|
1622
|
+
return listAllSessions(storage);
|
|
3540
1623
|
}
|
|
3541
1624
|
}
|