@cjhyy/code-shell-core 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -23
- package/dist/agent/agent-definition-registry.d.ts +2 -2
- package/dist/agent/agent-definition-registry.js +47 -10
- package/dist/agent/agent-definition.d.ts +5 -0
- package/dist/agent/agent-definition.js +14 -0
- package/dist/automation/index.d.ts +7 -4
- package/dist/automation/index.js +4 -2
- package/dist/automation/runner.js +3 -1
- package/dist/automation/scheduler.d.ts +57 -1
- package/dist/automation/scheduler.js +123 -23
- package/dist/automation/store.d.ts +6 -2
- package/dist/automation/store.js +7 -3
- package/dist/capabilities/index.d.ts +109 -0
- package/dist/capabilities/index.js +80 -0
- package/dist/capability-control/disabled-lists.d.ts +2 -1
- package/dist/capability-control/disabled-lists.js +3 -5
- package/dist/capability-control/overlay.d.ts +14 -1
- package/dist/capability-control/overlay.js +40 -0
- package/dist/capability-control/service.js +6 -4
- package/dist/cli/agent-server-stdio.d.ts +1 -1
- package/dist/cli/agent-server-stdio.js +66 -6
- package/dist/cli/agent-server-tcp.js +20 -5
- package/dist/context/compaction.d.ts +39 -3
- package/dist/context/compaction.js +151 -39
- package/dist/context/manager.d.ts +20 -4
- package/dist/context/manager.js +42 -7
- package/dist/context/token-counter.js +5 -3
- package/dist/context/tool-result-storage.d.ts +6 -0
- package/dist/context/tool-result-storage.js +25 -4
- package/dist/credentials/access.d.ts +13 -2
- package/dist/credentials/access.js +99 -4
- package/dist/credentials/index.d.ts +3 -2
- package/dist/credentials/index.js +2 -1
- package/dist/credentials/oauth.d.ts +9 -1
- package/dist/credentials/oauth.js +80 -1
- package/dist/credentials/store.d.ts +16 -2
- package/dist/credentials/store.js +33 -11
- package/dist/credentials/types.d.ts +62 -1
- package/dist/credentials/types.js +16 -1
- package/dist/credentials/use-credential-tool.js +15 -1
- package/dist/engine/auxiliary-pipeline.d.ts +40 -0
- package/dist/engine/auxiliary-pipeline.js +181 -0
- package/dist/{settings → engine}/disk-defaults.d.ts +2 -2
- package/dist/{settings → engine}/disk-defaults.js +5 -2
- package/dist/engine/dynamic-tool-defs.d.ts +4 -0
- package/dist/engine/dynamic-tool-defs.js +4 -0
- package/dist/engine/engine.d.ts +272 -184
- package/dist/engine/engine.js +1869 -1864
- package/dist/engine/file-history-hook.d.ts +11 -0
- package/dist/engine/file-history-hook.js +31 -0
- package/dist/engine/goal-judge-context.d.ts +9 -0
- package/dist/engine/goal-judge-context.js +234 -0
- package/dist/engine/injected-context-cache.d.ts +6 -0
- package/dist/engine/injected-context-cache.js +13 -0
- package/dist/engine/input-attachments.d.ts +1 -1
- package/dist/engine/model-facade.d.ts +3 -3
- package/dist/engine/model-facade.js +15 -14
- package/dist/engine/permission-controller.d.ts +55 -0
- package/dist/engine/permission-controller.js +148 -0
- package/dist/engine/prompt-cache-diagnostics.d.ts +58 -0
- package/dist/engine/prompt-cache-diagnostics.js +178 -0
- package/dist/engine/run-accounting.d.ts +41 -0
- package/dist/engine/run-accounting.js +126 -0
- package/dist/engine/run-context.d.ts +44 -0
- package/dist/engine/run-context.js +88 -0
- package/dist/engine/run-environment.d.ts +30 -0
- package/dist/engine/run-environment.js +77 -0
- package/dist/engine/run-finalize.d.ts +58 -0
- package/dist/engine/run-finalize.js +245 -0
- package/dist/engine/run-goal.d.ts +58 -0
- package/dist/engine/run-goal.js +188 -0
- package/dist/engine/run-image-input.d.ts +1 -1
- package/dist/engine/run-image-input.js +5 -3
- package/dist/engine/run-session-open.d.ts +41 -0
- package/dist/engine/run-session-open.js +186 -0
- package/dist/engine/run-setup.d.ts +42 -0
- package/dist/engine/run-setup.js +58 -0
- package/dist/engine/run-tooling.d.ts +125 -0
- package/dist/engine/run-tooling.js +283 -0
- package/dist/engine/run-types.d.ts +139 -0
- package/dist/engine/run-types.js +12 -0
- package/dist/engine/run-workspace.d.ts +50 -0
- package/dist/engine/run-workspace.js +114 -0
- package/dist/engine/session-title.d.ts +2 -1
- package/dist/engine/session-title.js +4 -1
- package/dist/engine/steer-queue.d.ts +1 -1
- package/dist/engine/subagent-spawner.d.ts +32 -0
- package/dist/engine/subagent-spawner.js +287 -0
- package/dist/engine/tool-summary.d.ts +2 -2
- package/dist/engine/tool-summary.js +2 -2
- package/dist/engine/turn-loop.d.ts +67 -10
- package/dist/engine/turn-loop.js +538 -97
- package/dist/engine/types.d.ts +35 -4
- package/dist/engine/types.js +1 -1
- package/dist/{engine/goal.d.ts → goal/lifecycle.d.ts} +80 -6
- package/dist/goal/lifecycle.js +482 -0
- package/dist/hooks/events.d.ts +26 -9
- package/dist/hooks/events.js +0 -3
- package/dist/hooks/goal-stop-hook.d.ts +28 -29
- package/dist/hooks/goal-stop-hook.js +344 -144
- package/dist/hooks/registry.js +4 -1
- package/dist/hooks/shell-runner.d.ts +12 -1
- package/dist/hooks/shell-runner.js +160 -9
- package/dist/index.d.ts +51 -110
- package/dist/index.extension.d.ts +51 -0
- package/dist/index.extension.js +39 -0
- package/dist/index.internal.d.ts +86 -0
- package/dist/index.internal.js +92 -0
- package/dist/index.js +37 -125
- package/dist/links/cli.d.ts +48 -0
- package/dist/links/cli.js +617 -0
- package/dist/links/http.d.ts +40 -0
- package/dist/links/http.js +166 -0
- package/dist/links/index.d.ts +4 -0
- package/dist/links/index.js +3 -0
- package/dist/links/link-action-tool.d.ts +5 -0
- package/dist/links/link-action-tool.js +241 -0
- package/dist/links/providers.d.ts +8 -0
- package/dist/links/providers.js +665 -0
- package/dist/links/types.d.ts +44 -0
- package/dist/llm/client-base.d.ts +7 -0
- package/dist/llm/client-base.js +72 -10
- package/dist/llm/model-pool.d.ts +1 -1
- package/dist/llm/model-pool.js +1 -1
- package/dist/llm/providers/anthropic.d.ts +1 -0
- package/dist/llm/providers/anthropic.js +9 -0
- package/dist/llm/providers/openai.d.ts +1 -0
- package/dist/llm/providers/openai.js +17 -0
- package/dist/llm/types.d.ts +12 -5
- package/dist/model-catalog/index.js +15 -2
- package/dist/model-catalog/resolve.d.ts +13 -1
- package/dist/model-catalog/resolve.js +37 -2
- package/dist/model-catalog/types.d.ts +74 -48
- package/dist/model-catalog/types.js +16 -5
- package/dist/model-catalog/upsert.d.ts +3 -1
- package/dist/model-catalog/upsert.js +9 -0
- package/dist/onboarding.d.ts +0 -1
- package/dist/onboarding.js +1 -22
- package/dist/panel-apps/bindings.d.ts +24 -0
- package/dist/panel-apps/bindings.js +69 -0
- package/dist/panel-apps/github-archive.d.ts +21 -0
- package/dist/panel-apps/github-archive.js +98 -0
- package/dist/panel-apps/index.d.ts +4 -0
- package/dist/panel-apps/index.js +4 -0
- package/dist/panel-apps/installer.d.ts +68 -0
- package/dist/panel-apps/installer.js +622 -0
- package/dist/panel-apps/manifest.d.ts +364 -0
- package/dist/panel-apps/manifest.js +189 -0
- package/dist/panel-apps/paths.d.ts +13 -0
- package/dist/panel-apps/paths.js +38 -0
- package/dist/panel-apps/registry.d.ts +51 -0
- package/dist/panel-apps/registry.js +99 -0
- package/dist/panel-apps/runtime.d.ts +80 -0
- package/dist/panel-apps/runtime.js +154 -0
- package/dist/plugins/gitOps.js +8 -1
- package/dist/plugins/installedPlugins.d.ts +2 -1
- package/dist/plugins/installedPlugins.js +146 -8
- package/dist/plugins/installer/codex/convertAgents.d.ts +4 -1
- package/dist/plugins/installer/codex/convertAgents.js +62 -8
- package/dist/plugins/installer/codex/convertCommands.d.ts +5 -5
- package/dist/plugins/installer/codex/convertCommands.js +35 -12
- package/dist/plugins/installer/codex/convertHooks.d.ts +10 -0
- package/dist/plugins/installer/codex/convertHooks.js +82 -0
- package/dist/plugins/installer/codex/convertSkills.js +41 -6
- package/dist/plugins/installer/install.d.ts +7 -1
- package/dist/plugins/installer/install.js +21 -87
- package/dist/plugins/installer/installFromArchive.d.ts +26 -4
- package/dist/plugins/installer/installFromArchive.js +44 -27
- package/dist/plugins/installer/installFromNpm.d.ts +31 -0
- package/dist/plugins/installer/installFromNpm.js +273 -0
- package/dist/plugins/installer/loadPluginAgents.js +17 -4
- package/dist/plugins/installer/loadPluginMcp.d.ts +14 -0
- package/dist/plugins/installer/loadPluginMcp.js +245 -35
- package/dist/plugins/installer/normalizeManifest.d.ts +13 -0
- package/dist/plugins/installer/normalizeManifest.js +306 -0
- package/dist/plugins/installer/npmTar.d.ts +13 -0
- package/dist/plugins/installer/npmTar.js +321 -0
- package/dist/plugins/installer/parseSource.d.ts +11 -0
- package/dist/plugins/installer/parseSource.js +48 -0
- package/dist/plugins/installer/preview.d.ts +101 -0
- package/dist/plugins/installer/preview.js +336 -0
- package/dist/plugins/installer/projectPluginSource.d.ts +14 -0
- package/dist/plugins/installer/projectPluginSource.js +140 -0
- package/dist/plugins/installer/types.d.ts +775 -0
- package/dist/plugins/installer/types.js +100 -0
- package/dist/plugins/installer/unzip.d.ts +11 -0
- package/dist/plugins/installer/unzip.js +123 -9
- package/dist/plugins/installer/update.d.ts +2 -2
- package/dist/plugins/installer/update.js +43 -4
- package/dist/plugins/loadPluginHooks.d.ts +19 -2
- package/dist/plugins/loadPluginHooks.js +97 -86
- package/dist/plugins/pluginAutomationTemplates.d.ts +18 -0
- package/dist/plugins/pluginAutomationTemplates.js +50 -0
- package/dist/plugins/pluginCatalog.d.ts +45 -0
- package/dist/plugins/pluginCatalog.js +87 -0
- package/dist/plugins/pluginCommandHook.d.ts +10 -4
- package/dist/plugins/pluginCommandHook.js +133 -18
- package/dist/plugins/pluginCommandsLoader.d.ts +19 -0
- package/dist/plugins/pluginCommandsLoader.js +181 -7
- package/dist/plugins/pluginContent.d.ts +10 -1
- package/dist/plugins/pluginContent.js +87 -30
- package/dist/plugins/pluginHookApproval.d.ts +30 -0
- package/dist/plugins/pluginHookApproval.js +160 -0
- package/dist/plugins/pluginHookIntegrity.d.ts +69 -0
- package/dist/plugins/pluginHookIntegrity.js +361 -0
- package/dist/plugins/pluginInstaller.js +71 -12
- package/dist/plugins/pluginMcpApproval.d.ts +19 -0
- package/dist/plugins/pluginMcpApproval.js +110 -0
- package/dist/plugins/pluginMcpIntegrity.d.ts +21 -0
- package/dist/plugins/pluginMcpIntegrity.js +74 -0
- package/dist/plugins/types.d.ts +30 -0
- package/dist/plugins/varRewrite.d.ts +9 -5
- package/dist/plugins/varRewrite.js +26 -17
- package/dist/preset/index.d.ts +11 -8
- package/dist/preset/index.js +62 -203
- package/dist/product/define.js +9 -3
- package/dist/product/types.d.ts +0 -2
- package/dist/profile/activation.d.ts +36 -0
- package/dist/profile/activation.js +57 -0
- package/dist/profile/catalog-store.d.ts +77 -0
- package/dist/profile/catalog-store.js +346 -0
- package/dist/profile/catalog.d.ts +54 -0
- package/dist/profile/catalog.js +152 -0
- package/dist/profile/index.d.ts +7 -0
- package/dist/profile/index.js +7 -0
- package/dist/profile/requirements.d.ts +76 -0
- package/dist/profile/requirements.js +117 -0
- package/dist/profile/resolve.d.ts +11 -0
- package/dist/profile/resolve.js +41 -0
- package/dist/profile/store.d.ts +17 -0
- package/dist/profile/store.js +167 -0
- package/dist/profile/types.d.ts +321 -0
- package/dist/profile/types.js +104 -0
- package/dist/prompt/composer.d.ts +46 -7
- package/dist/prompt/composer.js +78 -48
- package/dist/prompt/instruction-scanner.d.ts +6 -5
- package/dist/prompt/instruction-scanner.js +8 -23
- package/dist/prompt/section-loader.d.ts +2 -2
- package/dist/prompt/section-loader.js +7 -4
- package/dist/prompt/sections/browser.md +10 -9
- package/dist/prompt/sections/harness-base.md +9 -0
- package/dist/protocol/chat-session-manager.d.ts +77 -3
- package/dist/protocol/chat-session-manager.js +194 -18
- package/dist/protocol/chat-session.d.ts +72 -9
- package/dist/protocol/chat-session.js +151 -16
- package/dist/protocol/client.d.ts +37 -4
- package/dist/protocol/client.js +118 -6
- package/dist/protocol/index.d.ts +2 -2
- package/dist/protocol/index.js +2 -2
- package/dist/protocol/mobile-remote-types.d.ts +406 -0
- package/dist/protocol/mobile-remote-types.js +1 -0
- package/dist/protocol/server.d.ts +152 -14
- package/dist/protocol/server.js +1574 -229
- package/dist/protocol/types.d.ts +182 -32
- package/dist/protocol/types.js +18 -0
- package/dist/run/ArtifactTracker.d.ts +6 -1
- package/dist/run/ArtifactTracker.js +29 -21
- package/dist/run/EngineRunner.d.ts +2 -0
- package/dist/run/EngineRunner.js +1 -0
- package/dist/run/RunManager.d.ts +26 -0
- package/dist/run/RunManager.js +82 -11
- package/dist/run/factory.d.ts +3 -0
- package/dist/run/factory.js +2 -0
- package/dist/runtime/safe-spawn.d.ts +6 -0
- package/dist/runtime/safe-spawn.js +8 -13
- package/dist/services/auto-dream.d.ts +13 -1
- package/dist/services/auto-dream.js +56 -8
- package/dist/services/dream-consolidation.d.ts +3 -0
- package/dist/services/dream-consolidation.js +14 -2
- package/dist/services/index.d.ts +1 -1
- package/dist/services/index.js +1 -1
- package/dist/services/memory-orchestrator.js +6 -2
- package/dist/services/oauth.d.ts +34 -10
- package/dist/services/oauth.js +236 -98
- package/dist/services/session-memory.d.ts +4 -4
- package/dist/services/session-memory.js +13 -10
- package/dist/session/memory.d.ts +26 -7
- package/dist/session/memory.js +79 -21
- package/dist/session/session-manager.d.ts +208 -25
- package/dist/session/session-manager.js +1232 -116
- package/dist/session/session-message.d.ts +22 -0
- package/dist/session/session-message.js +1 -0
- package/dist/session/transcript.d.ts +76 -6
- package/dist/session/transcript.js +278 -7
- package/dist/settings/feature-flags.d.ts +40 -0
- package/dist/settings/feature-flags.js +40 -0
- package/dist/settings/manager.d.ts +39 -1
- package/dist/settings/manager.js +102 -41
- package/dist/settings/migrate-config.js +4 -2
- package/dist/settings/schema.d.ts +511 -177
- package/dist/settings/schema.js +98 -25
- package/dist/skills/scanner.d.ts +6 -2
- package/dist/skills/scanner.js +160 -9
- package/dist/sources/adapter.d.ts +14 -0
- package/dist/sources/adapter.js +7 -0
- package/dist/sources/adapters/local-files.d.ts +10 -0
- package/dist/sources/adapters/local-files.js +127 -0
- package/dist/sources/adapters/mcp-resource.d.ts +17 -0
- package/dist/sources/adapters/mcp-resource.js +41 -0
- package/dist/sources/adapters/mock.d.ts +3 -0
- package/dist/sources/adapters/mock.js +29 -0
- package/dist/sources/binding.d.ts +6 -0
- package/dist/sources/binding.js +24 -0
- package/dist/sources/catalog.d.ts +6 -0
- package/dist/sources/catalog.js +64 -0
- package/dist/sources/context-summary.d.ts +8 -0
- package/dist/sources/context-summary.js +13 -0
- package/dist/sources/credential-status.d.ts +2 -0
- package/dist/sources/credential-status.js +13 -0
- package/dist/sources/index.d.ts +10 -0
- package/dist/sources/index.js +10 -0
- package/dist/sources/resolve.d.ts +28 -0
- package/dist/sources/resolve.js +47 -0
- package/dist/sources/truncate-utf8.d.ts +7 -0
- package/dist/sources/truncate-utf8.js +18 -0
- package/dist/sources/types.d.ts +71 -0
- package/dist/sources/types.js +26 -0
- package/dist/testing/fetch-stub.d.ts +32 -0
- package/dist/testing/fetch-stub.js +24 -0
- package/dist/themes/image.d.ts +18 -0
- package/dist/themes/image.js +117 -0
- package/dist/themes/index.d.ts +4 -0
- package/dist/themes/index.js +4 -0
- package/dist/themes/installer.d.ts +50 -0
- package/dist/themes/installer.js +290 -0
- package/dist/themes/manifest.d.ts +102 -0
- package/dist/themes/manifest.js +86 -0
- package/dist/themes/paths.d.ts +14 -0
- package/dist/themes/paths.js +33 -0
- package/dist/tool-system/browser-bridge.d.ts +53 -11
- package/dist/tool-system/browser-bridge.js +8 -9
- package/dist/tool-system/builtin/agent-heartbeat.d.ts +2 -2
- package/dist/tool-system/builtin/agent-heartbeat.js +20 -6
- package/dist/tool-system/builtin/agent-notifications.d.ts +127 -72
- package/dist/tool-system/builtin/agent-notifications.js +291 -101
- package/dist/tool-system/builtin/agent-output-file.js +1 -3
- package/dist/tool-system/builtin/agent-progress.d.ts +6 -0
- package/dist/tool-system/builtin/agent-progress.js +83 -0
- package/dist/tool-system/builtin/agent-registry.d.ts +38 -2
- package/dist/tool-system/builtin/agent-registry.js +178 -9
- package/dist/tool-system/builtin/agent-transcript-translator.js +1 -1
- package/dist/tool-system/builtin/agent.d.ts +2 -0
- package/dist/tool-system/builtin/agent.js +279 -34
- package/dist/tool-system/builtin/background-jobs.d.ts +63 -7
- package/dist/tool-system/builtin/background-jobs.js +111 -15
- package/dist/tool-system/builtin/background-work.d.ts +12 -1
- package/dist/tool-system/builtin/background-work.js +12 -1
- package/dist/tool-system/builtin/bash.d.ts +3 -5
- package/dist/tool-system/builtin/bash.js +14 -6
- package/dist/tool-system/builtin/browser-tools.d.ts +8 -5
- package/dist/tool-system/builtin/browser-tools.js +89 -23
- package/dist/tool-system/builtin/edit-model-catalog.d.ts +6 -0
- package/dist/tool-system/builtin/edit-model-catalog.js +172 -12
- package/dist/tool-system/builtin/edit.d.ts +2 -1
- package/dist/tool-system/builtin/edit.js +14 -10
- package/dist/tool-system/builtin/file-cache.d.ts +2 -0
- package/dist/tool-system/builtin/file-cache.js +4 -0
- package/dist/tool-system/builtin/generate-image.js +17 -10
- package/dist/tool-system/builtin/generate-video.d.ts +4 -0
- package/dist/tool-system/builtin/generate-video.js +164 -28
- package/dist/tool-system/builtin/glob.d.ts +2 -1
- package/dist/tool-system/builtin/glob.js +28 -3
- package/dist/tool-system/builtin/grep.d.ts +1 -0
- package/dist/tool-system/builtin/grep.js +82 -17
- package/dist/tool-system/builtin/image-uploader.js +7 -1
- package/dist/tool-system/builtin/index.d.ts +57 -18
- package/dist/tool-system/builtin/index.js +280 -167
- package/dist/tool-system/builtin/memory.d.ts +6 -0
- package/dist/tool-system/builtin/memory.js +36 -15
- package/dist/tool-system/builtin/panel.d.ts +9 -0
- package/dist/tool-system/builtin/panel.js +314 -0
- package/dist/tool-system/builtin/read.js +5 -1
- package/dist/tool-system/builtin/send-message-to-session.d.ts +6 -0
- package/dist/tool-system/builtin/send-message-to-session.js +67 -0
- package/dist/tool-system/builtin/sources.d.ts +8 -0
- package/dist/tool-system/builtin/sources.js +137 -0
- package/dist/tool-system/builtin/tool-search.js +7 -2
- package/dist/tool-system/builtin/update-automation-memory.js +2 -0
- package/dist/tool-system/builtin/video-providers.d.ts +12 -15
- package/dist/tool-system/builtin/video-providers.js +1 -0
- package/dist/tool-system/builtin/view-image.d.ts +2 -2
- package/dist/tool-system/builtin/web-fetch.js +60 -15
- package/dist/tool-system/builtin/web-search.js +1 -3
- package/dist/tool-system/builtin/write.d.ts +2 -1
- package/dist/tool-system/builtin/write.js +14 -4
- package/dist/tool-system/capability-module.d.ts +93 -0
- package/dist/tool-system/capability-module.js +53 -0
- package/dist/tool-system/context.d.ts +94 -24
- package/dist/tool-system/executor.js +99 -58
- package/dist/tool-system/external-tool-exposure.d.ts +60 -0
- package/dist/tool-system/external-tool-exposure.js +304 -0
- package/dist/tool-system/mcp-manager.d.ts +17 -1
- package/dist/tool-system/mcp-manager.js +72 -7
- package/dist/tool-system/mcp-tool-policy.d.ts +9 -0
- package/dist/tool-system/mcp-tool-policy.js +34 -0
- package/dist/tool-system/panel-bridge.d.ts +53 -0
- package/dist/tool-system/panel-bridge.js +1 -0
- package/dist/tool-system/path-policy.d.ts +19 -0
- package/dist/tool-system/path-policy.js +62 -1
- package/dist/tool-system/permission.d.ts +69 -3
- package/dist/tool-system/permission.js +429 -30
- package/dist/tool-system/registry.d.ts +15 -3
- package/dist/tool-system/registry.js +82 -40
- package/dist/tool-system/sandbox/index.js +3 -8
- package/dist/tool-system/sandbox/seatbelt.js +2 -6
- package/dist/tool-system/session-tool-host.d.ts +139 -0
- package/dist/tool-system/session-tool-host.js +167 -0
- package/dist/tool-system/testing/tool-registry-harness.d.ts +31 -0
- package/dist/tool-system/testing/tool-registry-harness.js +51 -0
- package/dist/tool-system/validation.d.ts +14 -7
- package/dist/tool-system/validation.js +701 -7
- package/dist/types.d.ts +201 -20
- package/dist/{cc-orchestrator → utils}/cwd-normalize.d.ts +1 -0
- package/dist/{cc-orchestrator → utils}/cwd-normalize.js +1 -0
- package/dist/utils/file-mutex.d.ts +65 -0
- package/dist/utils/file-mutex.js +145 -0
- package/dist/utils/json.d.ts +2 -4
- package/dist/utils/json.js +2 -4
- package/dist/utils/lockfile.d.ts +30 -1
- package/dist/utils/lockfile.js +2 -2
- package/dist/utils/secret-scrubber.d.ts +8 -0
- package/dist/utils/secret-scrubber.js +227 -0
- package/dist/utils/toolDisplay.js +1 -1
- package/package.json +20 -5
- package/THIRD_PARTY_NOTICES.md +0 -206
- package/dist/arena/arena.d.ts +0 -43
- package/dist/arena/arena.js +0 -333
- package/dist/arena/context/context-tools.d.ts +0 -16
- package/dist/arena/context/context-tools.js +0 -272
- package/dist/arena/context/within-root.d.ts +0 -7
- package/dist/arena/context/within-root.js +0 -15
- package/dist/arena/detect-mode.d.ts +0 -20
- package/dist/arena/detect-mode.js +0 -78
- package/dist/arena/digest-builder.d.ts +0 -25
- package/dist/arena/digest-builder.js +0 -120
- package/dist/arena/index.d.ts +0 -29
- package/dist/arena/index.js +0 -29
- package/dist/arena/iterate/convergence.d.ts +0 -25
- package/dist/arena/iterate/convergence.js +0 -103
- package/dist/arena/iterate/formats/index.d.ts +0 -22
- package/dist/arena/iterate/formats/index.js +0 -283
- package/dist/arena/iterate/index.d.ts +0 -11
- package/dist/arena/iterate/index.js +0 -9
- package/dist/arena/iterate/iterative-arena.d.ts +0 -23
- package/dist/arena/iterate/iterative-arena.js +0 -237
- package/dist/arena/iterate/parse.d.ts +0 -42
- package/dist/arena/iterate/parse.js +0 -123
- package/dist/arena/iterate/phases/argue.d.ts +0 -22
- package/dist/arena/iterate/phases/argue.js +0 -165
- package/dist/arena/iterate/phases/revise.d.ts +0 -16
- package/dist/arena/iterate/phases/revise.js +0 -62
- package/dist/arena/iterate/phases/tournament.d.ts +0 -34
- package/dist/arena/iterate/phases/tournament.js +0 -113
- package/dist/arena/iterate/tools/web-tools.d.ts +0 -13
- package/dist/arena/iterate/tools/web-tools.js +0 -54
- package/dist/arena/iterate/types.d.ts +0 -152
- package/dist/arena/iterate/types.js +0 -8
- package/dist/arena/ledger.d.ts +0 -47
- package/dist/arena/ledger.js +0 -159
- package/dist/arena/lenses/architecture.d.ts +0 -5
- package/dist/arena/lenses/architecture.js +0 -22
- package/dist/arena/lenses/engineering.d.ts +0 -5
- package/dist/arena/lenses/engineering.js +0 -22
- package/dist/arena/lenses/general.d.ts +0 -5
- package/dist/arena/lenses/general.js +0 -20
- package/dist/arena/lenses/index.d.ts +0 -16
- package/dist/arena/lenses/index.js +0 -47
- package/dist/arena/lenses/product.d.ts +0 -5
- package/dist/arena/lenses/product.js +0 -22
- package/dist/arena/model-presets.d.ts +0 -23
- package/dist/arena/model-presets.js +0 -44
- package/dist/arena/phases/adjudication.d.ts +0 -24
- package/dist/arena/phases/adjudication.js +0 -141
- package/dist/arena/phases/build-consensus.d.ts +0 -29
- package/dist/arena/phases/build-consensus.js +0 -83
- package/dist/arena/phases/claim-registry.d.ts +0 -26
- package/dist/arena/phases/claim-registry.js +0 -60
- package/dist/arena/phases/cross-review.d.ts +0 -45
- package/dist/arena/phases/cross-review.js +0 -218
- package/dist/arena/phases/debate-rounds.d.ts +0 -27
- package/dist/arena/phases/debate-rounds.js +0 -159
- package/dist/arena/phases/participant-research.d.ts +0 -38
- package/dist/arena/phases/participant-research.js +0 -314
- package/dist/arena/phases/planning-detail-expansion.d.ts +0 -38
- package/dist/arena/phases/planning-detail-expansion.js +0 -119
- package/dist/arena/planner.d.ts +0 -27
- package/dist/arena/planner.js +0 -311
- package/dist/arena/providers/docs.d.ts +0 -7
- package/dist/arena/providers/docs.js +0 -111
- package/dist/arena/providers/git.d.ts +0 -8
- package/dist/arena/providers/git.js +0 -174
- package/dist/arena/providers/index.d.ts +0 -32
- package/dist/arena/providers/index.js +0 -132
- package/dist/arena/providers/none.d.ts +0 -7
- package/dist/arena/providers/none.js +0 -11
- package/dist/arena/providers/repo.d.ts +0 -7
- package/dist/arena/providers/repo.js +0 -258
- package/dist/arena/render/session.d.ts +0 -17
- package/dist/arena/render/session.js +0 -190
- package/dist/arena/render/terminal.d.ts +0 -34
- package/dist/arena/render/terminal.js +0 -286
- package/dist/arena/strategies/discussion.d.ts +0 -25
- package/dist/arena/strategies/discussion.js +0 -143
- package/dist/arena/strategies/index.d.ts +0 -15
- package/dist/arena/strategies/index.js +0 -28
- package/dist/arena/strategies/language-wrapper.d.ts +0 -17
- package/dist/arena/strategies/language-wrapper.js +0 -102
- package/dist/arena/strategies/lens-wrapper.d.ts +0 -16
- package/dist/arena/strategies/lens-wrapper.js +0 -236
- package/dist/arena/strategies/planning.d.ts +0 -30
- package/dist/arena/strategies/planning.js +0 -225
- package/dist/arena/strategies/review.d.ts +0 -26
- package/dist/arena/strategies/review.js +0 -168
- package/dist/arena/strategies/utils.d.ts +0 -36
- package/dist/arena/strategies/utils.js +0 -603
- package/dist/arena/tools/selector.d.ts +0 -17
- package/dist/arena/tools/selector.js +0 -61
- package/dist/arena/transitions.d.ts +0 -53
- package/dist/arena/transitions.js +0 -97
- package/dist/arena/types.d.ts +0 -514
- package/dist/arena/types.js +0 -27
- package/dist/cc-orchestrator/agent-adapter.d.ts +0 -54
- package/dist/cc-orchestrator/agent-adapter.js +0 -143
- package/dist/cc-orchestrator/cc-capability.d.ts +0 -19
- package/dist/cc-orchestrator/cc-capability.js +0 -53
- package/dist/cc-orchestrator/codex-session-discovery.d.ts +0 -24
- package/dist/cc-orchestrator/codex-session-discovery.js +0 -191
- package/dist/cc-orchestrator/codex-session-history.d.ts +0 -38
- package/dist/cc-orchestrator/codex-session-history.js +0 -247
- package/dist/cc-orchestrator/external-agent-bindings.d.ts +0 -27
- package/dist/cc-orchestrator/external-agent-bindings.js +0 -150
- package/dist/cc-orchestrator/external-agent-changes.d.ts +0 -19
- package/dist/cc-orchestrator/external-agent-changes.js +0 -231
- package/dist/cc-orchestrator/external-agent-driver.d.ts +0 -19
- package/dist/cc-orchestrator/external-agent-driver.js +0 -284
- package/dist/cc-orchestrator/external-agent-session-store.d.ts +0 -23
- package/dist/cc-orchestrator/external-agent-session-store.js +0 -146
- package/dist/cc-orchestrator/index.d.ts +0 -8
- package/dist/cc-orchestrator/index.js +0 -8
- package/dist/cc-orchestrator/relevance-judge.d.ts +0 -15
- package/dist/cc-orchestrator/relevance-judge.js +0 -29
- package/dist/cc-orchestrator/session-discovery.d.ts +0 -46
- package/dist/cc-orchestrator/session-discovery.js +0 -125
- package/dist/cc-orchestrator/session-history.d.ts +0 -54
- package/dist/cc-orchestrator/session-history.js +0 -150
- package/dist/cron/cron-runtime.d.ts +0 -2
- package/dist/cron/cron-runtime.js +0 -2
- package/dist/cron/cron-store.d.ts +0 -2
- package/dist/cron/cron-store.js +0 -2
- package/dist/cron/scheduler.d.ts +0 -7
- package/dist/cron/scheduler.js +0 -7
- package/dist/engine/goal.js +0 -206
- package/dist/external-agents/config.d.ts +0 -2
- package/dist/external-agents/config.js +0 -15
- package/dist/external-agents/types.d.ts +0 -31
- package/dist/git/parse-log.d.ts +0 -13
- package/dist/git/parse-log.js +0 -21
- package/dist/git/utils.d.ts +0 -49
- package/dist/git/utils.js +0 -161
- package/dist/git/worktree/crud.d.ts +0 -69
- package/dist/git/worktree/crud.js +0 -206
- package/dist/git/worktree/diff.d.ts +0 -14
- package/dist/git/worktree/diff.js +0 -82
- package/dist/git/worktree/git-exec.d.ts +0 -7
- package/dist/git/worktree/git-exec.js +0 -51
- package/dist/git/worktree/index.d.ts +0 -5
- package/dist/git/worktree/index.js +0 -5
- package/dist/git/worktree/query.d.ts +0 -42
- package/dist/git/worktree/query.js +0 -121
- package/dist/git/worktree/slug.d.ts +0 -11
- package/dist/git/worktree/slug.js +0 -58
- package/dist/git/worktree.d.ts +0 -1
- package/dist/git/worktree.js +0 -5
- package/dist/lsp/client.d.ts +0 -41
- package/dist/lsp/client.js +0 -186
- package/dist/lsp/manager.d.ts +0 -40
- package/dist/lsp/manager.js +0 -155
- package/dist/lsp/root-path.d.ts +0 -9
- package/dist/lsp/root-path.js +0 -12
- package/dist/lsp/servers.d.ts +0 -16
- package/dist/lsp/servers.js +0 -60
- package/dist/prompt/sections/coding.md +0 -35
- package/dist/quota/credentials.d.ts +0 -3
- package/dist/quota/credentials.js +0 -80
- package/dist/quota/index.d.ts +0 -36
- package/dist/quota/index.js +0 -155
- package/dist/quota/types.d.ts +0 -48
- package/dist/quota/types.js +0 -13
- package/dist/review/review-prompt.d.ts +0 -28
- package/dist/review/review-prompt.js +0 -81
- package/dist/state.d.ts +0 -160
- package/dist/state.js +0 -267
- package/dist/tool-system/builtin/apply-patch/applier.d.ts +0 -26
- package/dist/tool-system/builtin/apply-patch/applier.js +0 -308
- package/dist/tool-system/builtin/apply-patch/backup-targets.d.ts +0 -10
- package/dist/tool-system/builtin/apply-patch/backup-targets.js +0 -30
- package/dist/tool-system/builtin/apply-patch/index.d.ts +0 -20
- package/dist/tool-system/builtin/apply-patch/index.js +0 -105
- package/dist/tool-system/builtin/apply-patch/parser.d.ts +0 -17
- package/dist/tool-system/builtin/apply-patch/parser.js +0 -212
- package/dist/tool-system/builtin/apply-patch/seek-sequence.d.ts +0 -18
- package/dist/tool-system/builtin/apply-patch/seek-sequence.js +0 -123
- package/dist/tool-system/builtin/apply-patch/types.d.ts +0 -49
- package/dist/tool-system/builtin/apply-patch/types.js +0 -13
- package/dist/tool-system/builtin/arena.d.ts +0 -31
- package/dist/tool-system/builtin/arena.js +0 -415
- package/dist/tool-system/builtin/brief.d.ts +0 -6
- package/dist/tool-system/builtin/brief.js +0 -41
- package/dist/tool-system/builtin/check-quota.d.ts +0 -15
- package/dist/tool-system/builtin/check-quota.js +0 -34
- package/dist/tool-system/builtin/drive-claude-code.d.ts +0 -51
- package/dist/tool-system/builtin/drive-claude-code.js +0 -698
- package/dist/tool-system/builtin/lsp.d.ts +0 -7
- package/dist/tool-system/builtin/lsp.js +0 -144
- package/dist/tool-system/builtin/notebook-edit.d.ts +0 -7
- package/dist/tool-system/builtin/notebook-edit.js +0 -127
- package/dist/tool-system/builtin/worktree.d.ts +0 -11
- package/dist/tool-system/builtin/worktree.js +0 -345
- /package/dist/{external-agents → links}/types.js +0 -0
- /package/dist/{engine/session-usage.d.ts → session/usage.d.ts} +0 -0
- /package/dist/{engine/session-usage.js → session/usage.js} +0 -0
|
@@ -1,15 +1,169 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Session lifecycle manager.
|
|
3
3
|
*/
|
|
4
|
-
import { closeSync, existsSync, mkdirSync, lstatSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, writeFileSync, } from "node:fs";
|
|
4
|
+
import { closeSync, existsSync, mkdirSync, lstatSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
7
8
|
import { nanoid } from "nanoid";
|
|
8
9
|
import { Transcript } from "./transcript.js";
|
|
9
10
|
import { SessionError } from "../exceptions.js";
|
|
10
|
-
import { normalizeCumulativeUsageCounters } from "
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
11
|
+
import { addCumulativeUsage, addTokenUsage, normalizeCumulativeUsageCounters } from "./usage.js";
|
|
12
|
+
import { armGoalLifecycle, createGoalLifecycle, decodeGoalLifecycle, deriveLegacyGoalId, goalConfigFromLifecycle, isGoalLifecycleCurrent, isSameGoalVersion, mergeGoalTerminals, terminateGoalLifecycle, waitGoalLifecycle, } from "../goal/lifecycle.js";
|
|
13
|
+
import { lockSync } from "../utils/lockfile.js";
|
|
14
|
+
import { resolveCapabilities } from "../capabilities/index.js";
|
|
15
|
+
// Shared close epochs for SessionManager instances in this process. Concurrent
|
|
16
|
+
// Engines bind the same epoch; only close advances it. This intentionally does
|
|
17
|
+
// not claim cross-process/Worker protection.
|
|
18
|
+
const currentSessionCloseEpochs = new Map();
|
|
19
|
+
const SESSION_STATE_LOCK_STALE_MS = 10_000;
|
|
20
|
+
const SESSION_STATE_LOCK_RETRY_DELAYS_MS = [5, 10, 20, 40];
|
|
21
|
+
const syncSleepCell = new Int32Array(new SharedArrayBuffer(4));
|
|
22
|
+
function sleepSync(ms) {
|
|
23
|
+
Atomics.wait(syncSleepCell, 0, 0, ms);
|
|
24
|
+
}
|
|
25
|
+
function lifecycleTerminalReason(reason) {
|
|
26
|
+
return reason;
|
|
27
|
+
}
|
|
28
|
+
function newestLegacyTerminal(state) {
|
|
29
|
+
return mergeGoalTerminals(state.goalTerminals, state.goalTerminal).at(-1);
|
|
30
|
+
}
|
|
31
|
+
function legacyTerminalMatchesGoal(terminal, goal) {
|
|
32
|
+
if (terminal.goalId && goal.goalId) {
|
|
33
|
+
return (terminal.goalId === goal.goalId &&
|
|
34
|
+
Math.max(1, Math.floor(terminal.revision ?? 1)) ===
|
|
35
|
+
Math.max(1, Math.floor(goal.revision ?? 1)));
|
|
36
|
+
}
|
|
37
|
+
return terminal.objective === goal.objective && terminal.setAtMs === goal.setAtMs;
|
|
38
|
+
}
|
|
39
|
+
/** Decode the canonical union or derive it once from legacy aliases. */
|
|
40
|
+
function hydrateGoalLifecycle(state) {
|
|
41
|
+
const hasCanonical = Object.prototype.hasOwnProperty.call(state, "goalLifecycle");
|
|
42
|
+
const persistedLifecycle = state.goalLifecycle;
|
|
43
|
+
let lifecycle;
|
|
44
|
+
if (hasCanonical && persistedLifecycle !== undefined) {
|
|
45
|
+
lifecycle = decodeGoalLifecycle(persistedLifecycle);
|
|
46
|
+
if (!lifecycle) {
|
|
47
|
+
throw new SessionError(`Session goal lifecycle is unsupported or corrupt for ${state.sessionId}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
else if (state.activeGoal) {
|
|
51
|
+
const active = {
|
|
52
|
+
...state.activeGoal,
|
|
53
|
+
goalId: state.activeGoal.goalId ?? deriveLegacyGoalId(state.sessionId, state.activeGoal),
|
|
54
|
+
revision: Math.max(1, Math.floor(state.activeGoal.revision ?? 1)),
|
|
55
|
+
};
|
|
56
|
+
const terminal = mergeGoalTerminals(state.goalTerminals, state.goalTerminal).find((candidate) => legacyTerminalMatchesGoal(candidate, active));
|
|
57
|
+
const base = createGoalLifecycle(active, active.paused === true ? "paused" : "active");
|
|
58
|
+
lifecycle = terminal
|
|
59
|
+
? terminateGoalLifecycle(base, lifecycleTerminalReason(terminal.reason), terminal.terminatedAtMs ?? Date.now())
|
|
60
|
+
: base;
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
const terminal = newestLegacyTerminal(state);
|
|
64
|
+
if (terminal) {
|
|
65
|
+
const goal = {
|
|
66
|
+
objective: terminal.objective,
|
|
67
|
+
goalId: terminal.goalId ??
|
|
68
|
+
deriveLegacyGoalId(state.sessionId, {
|
|
69
|
+
objective: terminal.objective,
|
|
70
|
+
setAtMs: terminal.setAtMs,
|
|
71
|
+
}),
|
|
72
|
+
revision: Math.max(1, Math.floor(terminal.revision ?? 1)),
|
|
73
|
+
...(terminal.setAtMs !== undefined ? { setAtMs: terminal.setAtMs } : {}),
|
|
74
|
+
};
|
|
75
|
+
lifecycle = terminateGoalLifecycle(createGoalLifecycle(goal, "active", terminal.terminatedAtMs ?? Date.now()), lifecycleTerminalReason(terminal.reason), terminal.terminatedAtMs ?? Date.now());
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (lifecycle)
|
|
79
|
+
state.goalLifecycle = lifecycle;
|
|
80
|
+
else
|
|
81
|
+
delete state.goalLifecycle;
|
|
82
|
+
// Legacy aliases are migration inputs only. Never reconstruct them in RAM:
|
|
83
|
+
// all runtime readers and writers consume the canonical lifecycle union.
|
|
84
|
+
delete state.activeGoal;
|
|
85
|
+
delete state.goalTerminal;
|
|
86
|
+
delete state.goalTerminals;
|
|
87
|
+
return state;
|
|
88
|
+
}
|
|
89
|
+
/** New files and runtime state contain only the canonical union. */
|
|
90
|
+
function stateForPersistence(state) {
|
|
91
|
+
const persisted = { ...state };
|
|
92
|
+
delete persisted.activeGoal;
|
|
93
|
+
delete persisted.goalTerminal;
|
|
94
|
+
delete persisted.goalTerminals;
|
|
95
|
+
return persisted;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Temporary adapter for tests/older in-repo callers that still mutate the
|
|
99
|
+
* hydrated aliases before calling whole-state saveState. Production Goal paths
|
|
100
|
+
* use the domain methods below. The adapter never writes aliases to disk.
|
|
101
|
+
*/
|
|
102
|
+
function adoptCompatibilityGoalMutation(state) {
|
|
103
|
+
const activeTouched = Object.prototype.hasOwnProperty.call(state, "activeGoal");
|
|
104
|
+
const terminalTouched = Object.prototype.hasOwnProperty.call(state, "goalTerminal") ||
|
|
105
|
+
Object.prototype.hasOwnProperty.call(state, "goalTerminals");
|
|
106
|
+
if (!activeTouched && !terminalTouched)
|
|
107
|
+
return;
|
|
108
|
+
const lifecycle = decodeGoalLifecycle(state.goalLifecycle);
|
|
109
|
+
const alias = state.activeGoal
|
|
110
|
+
? {
|
|
111
|
+
...state.activeGoal,
|
|
112
|
+
goalId: state.activeGoal.goalId ?? deriveLegacyGoalId(state.sessionId, state.activeGoal),
|
|
113
|
+
revision: Math.max(1, Math.floor(state.activeGoal.revision ?? 1)),
|
|
114
|
+
}
|
|
115
|
+
: undefined;
|
|
116
|
+
const terminals = mergeGoalTerminals(state.goalTerminals, state.goalTerminal);
|
|
117
|
+
if (!lifecycle) {
|
|
118
|
+
if (alias) {
|
|
119
|
+
state.goalLifecycle = createGoalLifecycle(alias, alias.paused === true ? "paused" : "active");
|
|
120
|
+
}
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (alias) {
|
|
124
|
+
const sameIdentity = lifecycle.goalId === alias.goalId;
|
|
125
|
+
const sameRevision = lifecycle.revision === alias.revision;
|
|
126
|
+
const matchingTerminal = terminals.find((terminal) => legacyTerminalMatchesGoal(terminal, alias));
|
|
127
|
+
if (matchingTerminal) {
|
|
128
|
+
const base = createGoalLifecycle(alias, alias.paused === true ? "paused" : "active");
|
|
129
|
+
state.goalLifecycle = terminateGoalLifecycle(base, lifecycleTerminalReason(matchingTerminal.reason), matchingTerminal.terminatedAtMs ?? Date.now());
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (lifecycle.phase === "terminal" && sameIdentity && sameRevision)
|
|
133
|
+
return;
|
|
134
|
+
const canonicalGoal = goalConfigFromLifecycle(lifecycle);
|
|
135
|
+
if (!sameIdentity || !sameRevision || !isSameGoalVersion(alias, canonicalGoal)) {
|
|
136
|
+
state.goalLifecycle = createGoalLifecycle(alias, alias.paused === true ? "paused" : "active");
|
|
137
|
+
}
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const matchingTerminal = terminals.find((terminal) => legacyTerminalMatchesGoal(terminal, goalConfigFromLifecycle(lifecycle)));
|
|
141
|
+
if (matchingTerminal && lifecycle.phase !== "terminal") {
|
|
142
|
+
state.goalLifecycle = terminateGoalLifecycle(lifecycle, lifecycleTerminalReason(matchingTerminal.reason), matchingTerminal.terminatedAtMs ?? Date.now());
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const FORK_COPY_EVENT_TYPES = new Set([
|
|
146
|
+
"message",
|
|
147
|
+
"tool_use",
|
|
148
|
+
"tool_result",
|
|
149
|
+
"summary",
|
|
150
|
+
"context_transfer",
|
|
151
|
+
"content_replace",
|
|
152
|
+
"subagent",
|
|
153
|
+
"external_file_changes",
|
|
154
|
+
"goal_progress",
|
|
155
|
+
"turn_boundary",
|
|
156
|
+
"turn_stopped",
|
|
157
|
+
"error",
|
|
158
|
+
]);
|
|
159
|
+
const FORK_SKIP_EVENT_TYPES = new Set([
|
|
160
|
+
"session_meta",
|
|
161
|
+
"file_history",
|
|
162
|
+
"plan_operation",
|
|
163
|
+
]);
|
|
164
|
+
const FORK_STAGING_NAME = /^\.pending-fork-[A-Za-z0-9_.-]+-[A-Za-z0-9_-]{8}$/;
|
|
165
|
+
const FORK_STAGING_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
166
|
+
const FORK_STAGING_CLEANUP_LIMIT = 32;
|
|
13
167
|
/**
|
|
14
168
|
* Validate a session ID before it is joined into a filesystem path.
|
|
15
169
|
*
|
|
@@ -47,6 +201,18 @@ export function assertSafeSessionId(sessionId) {
|
|
|
47
201
|
throw new SessionError(`invalid session id: too long (max 128 chars)`);
|
|
48
202
|
}
|
|
49
203
|
}
|
|
204
|
+
/**
|
|
205
|
+
* Runtime source of truth for sessions whose state must not outlive their UI
|
|
206
|
+
* lifecycle. The explicit marker covers modern side forks; the qchat namespace
|
|
207
|
+
* keeps historical/blank quick chats fail-closed if their older state file did
|
|
208
|
+
* not contain the marker yet.
|
|
209
|
+
*/
|
|
210
|
+
export function isEphemeralSessionState(state) {
|
|
211
|
+
return state.ephemeral === true || state.sessionId.startsWith("qchat-");
|
|
212
|
+
}
|
|
213
|
+
function normalizedSessionKind(kind) {
|
|
214
|
+
return kind === "pet" ? "pet" : "work";
|
|
215
|
+
}
|
|
50
216
|
/**
|
|
51
217
|
* Resolve the `.code-shell` home dir. `CODE_SHELL_HOME` overrides the default
|
|
52
218
|
* `~/.code-shell` — mirrors Codex's `CODEX_HOME`. Tests set it to a temp dir
|
|
@@ -57,6 +223,14 @@ export function assertSafeSessionId(sessionId) {
|
|
|
57
223
|
export function codeShellHome() {
|
|
58
224
|
return process.env.CODE_SHELL_HOME || join(homedir(), ".code-shell");
|
|
59
225
|
}
|
|
226
|
+
/**
|
|
227
|
+
* Canonical root for every persisted CodeShell session. An explicit `home`
|
|
228
|
+
* (a `~/.code-shell`-equivalent data root) overrides the default resolution;
|
|
229
|
+
* absent → `codeShellHome()` exactly as before.
|
|
230
|
+
*/
|
|
231
|
+
export function sessionsRoot(home) {
|
|
232
|
+
return join(home ?? codeShellHome(), "sessions");
|
|
233
|
+
}
|
|
60
234
|
function isSessionWorkspace(value) {
|
|
61
235
|
if (!value || typeof value !== "object")
|
|
62
236
|
return false;
|
|
@@ -77,7 +251,16 @@ function isSessionWorkspace(value) {
|
|
|
77
251
|
wt.baseRef.length > 0 &&
|
|
78
252
|
wt.createdBy === "codeshell");
|
|
79
253
|
}
|
|
80
|
-
|
|
254
|
+
/**
|
|
255
|
+
* Read the session's persisted main-project root from the legacy `state.cwd`
|
|
256
|
+
* field. The field name is part of the state.json compatibility contract and
|
|
257
|
+
* must not be rewritten to `projectRoot`; `state.workspace.root` separately
|
|
258
|
+
* identifies the current main/worktree execution root used on resume.
|
|
259
|
+
*/
|
|
260
|
+
export function sessionMainRoot(state) {
|
|
261
|
+
return typeof state.cwd === "string" && state.cwd.length > 0 ? state.cwd : undefined;
|
|
262
|
+
}
|
|
263
|
+
async function validateResumeWorktreeRoot(root, workspaceCapability) {
|
|
81
264
|
if (!existsSync(root))
|
|
82
265
|
return "no longer exists";
|
|
83
266
|
let stat;
|
|
@@ -91,15 +274,66 @@ async function validateResumeWorktreeRoot(root) {
|
|
|
91
274
|
return "is not a valid git worktree (symbolic link)";
|
|
92
275
|
if (!stat.isDirectory())
|
|
93
276
|
return "is not a valid git worktree (not a directory)";
|
|
94
|
-
if (!(await
|
|
95
|
-
return "is not a valid
|
|
277
|
+
if (!(await workspaceCapability.validateRoot(root)))
|
|
278
|
+
return "is not a valid workspace root";
|
|
96
279
|
return null;
|
|
97
280
|
}
|
|
98
281
|
export class SessionManager {
|
|
99
282
|
sessionsDir;
|
|
100
|
-
|
|
101
|
-
|
|
283
|
+
registeredCloseEpochs = new Map();
|
|
284
|
+
workspaceCapability;
|
|
285
|
+
constructor(storageDir, workspaceCapability) {
|
|
286
|
+
this.sessionsDir = storageDir ?? sessionsRoot();
|
|
287
|
+
this.workspaceCapability =
|
|
288
|
+
workspaceCapability ??
|
|
289
|
+
resolveCapabilities()
|
|
290
|
+
.map((capability) => capability.sessionWorkspace)
|
|
291
|
+
.find((candidate) => candidate !== undefined);
|
|
102
292
|
mkdirSync(this.sessionsDir, { recursive: true });
|
|
293
|
+
this.cleanupStaleForkStaging();
|
|
294
|
+
}
|
|
295
|
+
cleanupStaleForkStaging() {
|
|
296
|
+
let removed = 0;
|
|
297
|
+
let entries;
|
|
298
|
+
try {
|
|
299
|
+
entries = readdirSync(this.sessionsDir, { withFileTypes: true });
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
for (const entry of entries) {
|
|
305
|
+
if (removed >= FORK_STAGING_CLEANUP_LIMIT)
|
|
306
|
+
return;
|
|
307
|
+
if (!entry.isDirectory() || !FORK_STAGING_NAME.test(entry.name))
|
|
308
|
+
continue;
|
|
309
|
+
const path = join(this.sessionsDir, entry.name);
|
|
310
|
+
try {
|
|
311
|
+
if (Date.now() - statSync(path).mtimeMs <= FORK_STAGING_MAX_AGE_MS)
|
|
312
|
+
continue;
|
|
313
|
+
rmSync(path, { recursive: true, force: true });
|
|
314
|
+
removed++;
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
// Startup cleanup is best effort; a live/unreadable staging directory
|
|
318
|
+
// must never prevent the SessionManager from serving normal sessions.
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
/** Bind one Engine/session pair to the current close epoch without advancing it. */
|
|
323
|
+
registerSessionGeneration(sessionId) {
|
|
324
|
+
assertSafeSessionId(sessionId);
|
|
325
|
+
const key = this.generationKey(sessionId);
|
|
326
|
+
const generation = currentSessionCloseEpochs.get(key) ?? 0;
|
|
327
|
+
this.registeredCloseEpochs.set(sessionId, generation);
|
|
328
|
+
return generation;
|
|
329
|
+
}
|
|
330
|
+
/** Advance the close epoch once before close waits for the old run to settle. */
|
|
331
|
+
incrementSessionGeneration(sessionId) {
|
|
332
|
+
assertSafeSessionId(sessionId);
|
|
333
|
+
const key = this.generationKey(sessionId);
|
|
334
|
+
const next = (currentSessionCloseEpochs.get(key) ?? 0) + 1;
|
|
335
|
+
currentSessionCloseEpochs.set(key, next);
|
|
336
|
+
return next;
|
|
103
337
|
}
|
|
104
338
|
/**
|
|
105
339
|
* Create a new on-disk session. If `explicitSessionId` is passed, use
|
|
@@ -108,7 +342,7 @@ export class SessionManager {
|
|
|
108
342
|
* nanoid. Either way the on-disk directory is materialized and the
|
|
109
343
|
* state.json + transcript.jsonl files are written before return.
|
|
110
344
|
*/
|
|
111
|
-
create(cwd, model, provider, explicitSessionId, parentSessionId, origin) {
|
|
345
|
+
create(cwd, model, provider, explicitSessionId, parentSessionId, origin, kind = "work") {
|
|
112
346
|
// External callers may pass any string; nanoid output is trusted. Either
|
|
113
347
|
// way the ID gets joined into a filesystem path, so the public entry
|
|
114
348
|
// point validates before that join.
|
|
@@ -127,6 +361,8 @@ export class SessionManager {
|
|
|
127
361
|
}
|
|
128
362
|
const state = {
|
|
129
363
|
sessionId,
|
|
364
|
+
kind,
|
|
365
|
+
stateRevision: 0,
|
|
130
366
|
cwd,
|
|
131
367
|
workspace: { root: cwd, kind: "main" },
|
|
132
368
|
startedAt: Date.now(),
|
|
@@ -144,6 +380,7 @@ export class SessionManager {
|
|
|
144
380
|
// new top-level session (key present, null) apart from a legacy session
|
|
145
381
|
// (key absent) and from a sub-agent (key present, non-empty string).
|
|
146
382
|
parentSessionId: parentSessionId ?? null,
|
|
383
|
+
...(sessionId.startsWith("qchat-") ? { ephemeral: true } : {}),
|
|
147
384
|
...(origin ? { origin } : {}),
|
|
148
385
|
};
|
|
149
386
|
// Atomic write (tmp+rename) like saveState, so a crash during this one-time
|
|
@@ -159,6 +396,7 @@ export class SessionManager {
|
|
|
159
396
|
model,
|
|
160
397
|
provider,
|
|
161
398
|
startedAt: state.startedAt,
|
|
399
|
+
kind,
|
|
162
400
|
});
|
|
163
401
|
return { state, transcript };
|
|
164
402
|
}
|
|
@@ -180,16 +418,124 @@ export class SessionManager {
|
|
|
180
418
|
return existsSync(join(this.sessionsDir, sessionId));
|
|
181
419
|
}
|
|
182
420
|
/**
|
|
183
|
-
* Cheap
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
* so a project-bound session keeps loading its own agents/settings/memory
|
|
188
|
-
* instead of falling back to process.cwd(). Returns undefined for an
|
|
189
|
-
* unknown, malformed, or traversal-shaped id rather than throwing — the
|
|
190
|
-
* caller treats "no recoverable cwd" the same as "not given".
|
|
421
|
+
* Cheap persisted-main-root probe — reads only state.json, NOT the transcript
|
|
422
|
+
* (unlike resume()). This deliberately reads legacy `state.cwd`, not the
|
|
423
|
+
* current `state.workspace.root`; callers use it as the stable main-project
|
|
424
|
+
* fallback when a worktree is unavailable or is being released.
|
|
191
425
|
*/
|
|
426
|
+
readSessionMainRoot(sessionId) {
|
|
427
|
+
try {
|
|
428
|
+
assertSafeSessionId(sessionId);
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
return undefined;
|
|
432
|
+
}
|
|
433
|
+
const stateFile = join(this.sessionsDir, sessionId, "state.json");
|
|
434
|
+
if (!existsSync(stateFile))
|
|
435
|
+
return undefined;
|
|
436
|
+
try {
|
|
437
|
+
const state = JSON.parse(readFileSync(stateFile, "utf-8"));
|
|
438
|
+
return sessionMainRoot(state);
|
|
439
|
+
}
|
|
440
|
+
catch {
|
|
441
|
+
return undefined;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
/** Cheap durable classification read. Legacy sessions are ordinary work sessions. */
|
|
445
|
+
readSessionKind(sessionId) {
|
|
446
|
+
try {
|
|
447
|
+
assertSafeSessionId(sessionId);
|
|
448
|
+
}
|
|
449
|
+
catch {
|
|
450
|
+
return undefined;
|
|
451
|
+
}
|
|
452
|
+
const stateFile = join(this.sessionsDir, sessionId, "state.json");
|
|
453
|
+
if (!existsSync(stateFile))
|
|
454
|
+
return undefined;
|
|
455
|
+
try {
|
|
456
|
+
const state = JSON.parse(readFileSync(stateFile, "utf-8"));
|
|
457
|
+
return normalizedSessionKind(state.kind);
|
|
458
|
+
}
|
|
459
|
+
catch {
|
|
460
|
+
return undefined;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
/** Cheap durable digital-human binding read. */
|
|
464
|
+
readSessionWorkspaceProfile(sessionId) {
|
|
465
|
+
try {
|
|
466
|
+
assertSafeSessionId(sessionId);
|
|
467
|
+
const stateFile = join(this.sessionsDir, sessionId, "state.json");
|
|
468
|
+
if (!existsSync(stateFile))
|
|
469
|
+
return undefined;
|
|
470
|
+
const state = JSON.parse(readFileSync(stateFile, "utf-8"));
|
|
471
|
+
return typeof state.workspaceProfile === "string" && state.workspaceProfile
|
|
472
|
+
? state.workspaceProfile
|
|
473
|
+
: undefined;
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
return undefined;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Find durable Sessions currently bound to one digital-human profile without loading
|
|
481
|
+
* transcripts. Used by profile deletion guards so a reusable/historical
|
|
482
|
+
* Session cannot be left permanently unresumable.
|
|
483
|
+
*/
|
|
484
|
+
/**
|
|
485
|
+
* Sessions whose state pins this digital human.
|
|
486
|
+
*
|
|
487
|
+
* `includeArchived: false` drops archived Sessions: they are closed history,
|
|
488
|
+
* not a live binding, so they should not block deleting the profile. A
|
|
489
|
+
* dangling name is already handled downstream — resolveActiveWorkspaceProfile
|
|
490
|
+
* logs `profile.active_missing_from_library` and returns undefined.
|
|
491
|
+
*/
|
|
492
|
+
findSessionIdsByWorkspaceProfile(profileName, limit = 20, options = {}) {
|
|
493
|
+
const includeArchived = options.includeArchived !== false;
|
|
494
|
+
const target = typeof profileName === "string" ? profileName.trim() : "";
|
|
495
|
+
if (!target || target.length > 64 || limit <= 0)
|
|
496
|
+
return [];
|
|
497
|
+
const cap = Math.max(1, Math.min(100, Math.floor(limit)));
|
|
498
|
+
let entries;
|
|
499
|
+
try {
|
|
500
|
+
entries = readdirSync(this.sessionsDir, { withFileTypes: true });
|
|
501
|
+
}
|
|
502
|
+
catch {
|
|
503
|
+
return [];
|
|
504
|
+
}
|
|
505
|
+
const matches = [];
|
|
506
|
+
for (const entry of entries) {
|
|
507
|
+
if (matches.length >= cap)
|
|
508
|
+
break;
|
|
509
|
+
if (!entry.isDirectory() ||
|
|
510
|
+
entry.name.startsWith(".pending-") ||
|
|
511
|
+
entry.name.startsWith("qchat-")) {
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
try {
|
|
515
|
+
assertSafeSessionId(entry.name);
|
|
516
|
+
const stateFile = join(this.sessionsDir, entry.name, "state.json");
|
|
517
|
+
const info = lstatSync(stateFile);
|
|
518
|
+
if (info.isSymbolicLink() || !info.isFile() || info.size > 1024 * 1024)
|
|
519
|
+
continue;
|
|
520
|
+
const state = JSON.parse(readFileSync(stateFile, "utf-8"));
|
|
521
|
+
if (state.workspaceProfile !== target)
|
|
522
|
+
continue;
|
|
523
|
+
if (!includeArchived && typeof state.archivedAt === "number")
|
|
524
|
+
continue;
|
|
525
|
+
matches.push(entry.name);
|
|
526
|
+
}
|
|
527
|
+
catch {
|
|
528
|
+
// Corrupt or unsafe Sessions are isolated from the deletion decision.
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
return matches.sort();
|
|
532
|
+
}
|
|
533
|
+
/** @deprecated Use readSessionMainRoot; retained for public API compatibility. */
|
|
192
534
|
readCwd(sessionId) {
|
|
535
|
+
return this.readSessionMainRoot(sessionId);
|
|
536
|
+
}
|
|
537
|
+
/** Disk-only direct-parent ACL metadata. Undefined means unprovable/corrupt. */
|
|
538
|
+
readParentSessionId(sessionId) {
|
|
193
539
|
try {
|
|
194
540
|
assertSafeSessionId(sessionId);
|
|
195
541
|
}
|
|
@@ -201,7 +547,9 @@ export class SessionManager {
|
|
|
201
547
|
return undefined;
|
|
202
548
|
try {
|
|
203
549
|
const state = JSON.parse(readFileSync(stateFile, "utf-8"));
|
|
204
|
-
return
|
|
550
|
+
return state.parentSessionId === null || typeof state.parentSessionId === "string"
|
|
551
|
+
? state.parentSessionId
|
|
552
|
+
: undefined;
|
|
205
553
|
}
|
|
206
554
|
catch {
|
|
207
555
|
return undefined;
|
|
@@ -226,9 +574,8 @@ export class SessionManager {
|
|
|
226
574
|
const state = JSON.parse(readFileSync(stateFile, "utf-8"));
|
|
227
575
|
if (isSessionWorkspace(state.workspace))
|
|
228
576
|
return state.workspace;
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
: undefined;
|
|
577
|
+
const mainRoot = sessionMainRoot(state);
|
|
578
|
+
return mainRoot ? { root: mainRoot, kind: "main" } : undefined;
|
|
232
579
|
}
|
|
233
580
|
catch {
|
|
234
581
|
return undefined;
|
|
@@ -244,19 +591,30 @@ export class SessionManager {
|
|
|
244
591
|
if (!isSessionWorkspace(workspace)) {
|
|
245
592
|
throw new SessionError(`invalid workspace for session ${sessionId}`);
|
|
246
593
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
594
|
+
return this.updateSessionState(sessionId, { workspace });
|
|
595
|
+
}
|
|
596
|
+
/** Read the durable archival timestamp; undefined = not archived / unprovable. */
|
|
597
|
+
readSessionArchivedAt(sessionId) {
|
|
598
|
+
try {
|
|
599
|
+
assertSafeSessionId(sessionId);
|
|
250
600
|
}
|
|
251
|
-
|
|
601
|
+
catch {
|
|
602
|
+
return undefined;
|
|
603
|
+
}
|
|
604
|
+
const stateFile = join(this.sessionsDir, sessionId, "state.json");
|
|
605
|
+
if (!existsSync(stateFile))
|
|
606
|
+
return undefined;
|
|
252
607
|
try {
|
|
253
|
-
state = JSON.parse(readFileSync(stateFile, "utf-8"));
|
|
608
|
+
const state = JSON.parse(readFileSync(stateFile, "utf-8"));
|
|
609
|
+
return typeof state.archivedAt === "number" ? state.archivedAt : undefined;
|
|
254
610
|
}
|
|
255
|
-
catch
|
|
256
|
-
|
|
611
|
+
catch {
|
|
612
|
+
return undefined;
|
|
257
613
|
}
|
|
258
|
-
|
|
259
|
-
|
|
614
|
+
}
|
|
615
|
+
/** Set (number) or clear (undefined) the durable archival marker. */
|
|
616
|
+
setSessionArchived(sessionId, archivedAt) {
|
|
617
|
+
return this.updateSessionState(sessionId, { archivedAt });
|
|
260
618
|
}
|
|
261
619
|
recordWorkspaceHandoff(sessionId, from, to) {
|
|
262
620
|
assertSafeSessionId(sessionId);
|
|
@@ -298,9 +656,8 @@ export class SessionManager {
|
|
|
298
656
|
catch (err) {
|
|
299
657
|
throw new SessionError(`Session state is corrupt for ${sessionId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
300
658
|
}
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
: undefined;
|
|
659
|
+
const mainRoot = sessionMainRoot(state);
|
|
660
|
+
const legacyMain = mainRoot ? { root: mainRoot, kind: "main" } : undefined;
|
|
304
661
|
const workspace = isSessionWorkspace(state.workspace) ? state.workspace : legacyMain;
|
|
305
662
|
if (!workspace) {
|
|
306
663
|
throw new SessionError(`Session ${sessionId} has no recoverable cwd`);
|
|
@@ -313,16 +670,28 @@ export class SessionManager {
|
|
|
313
670
|
reason: isSessionWorkspace(state.workspace) ? "main" : "legacy",
|
|
314
671
|
};
|
|
315
672
|
}
|
|
316
|
-
|
|
673
|
+
if (!this.workspaceCapability) {
|
|
674
|
+
return {
|
|
675
|
+
ok: false,
|
|
676
|
+
cwd: mainRoot ?? workspace.root,
|
|
677
|
+
workspace,
|
|
678
|
+
reason: "workspace_capability_unavailable",
|
|
679
|
+
message: `Session ${sessionId} uses a product workspace, but this host did not install ` +
|
|
680
|
+
`the matching workspace capability. Install it before resuming this session.`,
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
const invalidWorktreeReason = await validateResumeWorktreeRoot(workspace.root, this.workspaceCapability);
|
|
317
684
|
if (!invalidWorktreeReason) {
|
|
318
685
|
return { ok: true, cwd: workspace.root, workspace, reason: "worktree" };
|
|
319
686
|
}
|
|
320
687
|
const branch = workspace.worktree?.branch;
|
|
321
|
-
const
|
|
322
|
-
if (branch &&
|
|
688
|
+
const fallbackMainRoot = mainRoot ?? workspace.root;
|
|
689
|
+
if (branch &&
|
|
690
|
+
existsSync(fallbackMainRoot) &&
|
|
691
|
+
(await this.workspaceCapability.branchExists(fallbackMainRoot, branch))) {
|
|
323
692
|
return {
|
|
324
693
|
ok: false,
|
|
325
|
-
cwd:
|
|
694
|
+
cwd: fallbackMainRoot,
|
|
326
695
|
workspace,
|
|
327
696
|
reason: "worktree_missing_branch_exists",
|
|
328
697
|
message: `Session ${sessionId} is bound to worktree ${workspace.root}, but that directory ` +
|
|
@@ -331,23 +700,23 @@ export class SessionManager {
|
|
|
331
700
|
`back to main explicitly.`,
|
|
332
701
|
};
|
|
333
702
|
}
|
|
334
|
-
const fallback = { root:
|
|
703
|
+
const fallback = { root: fallbackMainRoot, kind: "main" };
|
|
335
704
|
state.workspace = fallback;
|
|
336
|
-
this.
|
|
705
|
+
state.stateRevision = this.updateSessionState(sessionId, { workspace: fallback });
|
|
337
706
|
return {
|
|
338
707
|
ok: true,
|
|
339
|
-
cwd:
|
|
708
|
+
cwd: fallbackMainRoot,
|
|
340
709
|
workspace: fallback,
|
|
341
710
|
reason: "worktree_missing_branch_gone",
|
|
342
711
|
message: `Session ${sessionId} was bound to worktree ${workspace.root}, but that directory ` +
|
|
343
712
|
`${invalidWorktreeReason}${branch ? ` and branch ${branch} is gone` : ""}; fell back to main ` +
|
|
344
|
-
`${
|
|
713
|
+
`${fallbackMainRoot}. Re-run the request if you want to continue there.`,
|
|
345
714
|
};
|
|
346
715
|
}
|
|
347
716
|
/**
|
|
348
717
|
* Cheap "does this session have a persisted goal?" probe — reads only
|
|
349
|
-
* state.json, NOT the transcript (like
|
|
350
|
-
*
|
|
718
|
+
* state.json, NOT the transcript (like readSessionMainRoot). A persistent goal lives in
|
|
719
|
+
* the canonical goalLifecycle union; it is never appended to the transcript as an event, so
|
|
351
720
|
* a session rebuilt from disk (e.g. localStorage wiped) can't recover the
|
|
352
721
|
* goal from its messages. The desktop host calls this on session load to
|
|
353
722
|
* re-surface the active-goal block + its Cancel button, which would otherwise
|
|
@@ -366,10 +735,18 @@ export class SessionManager {
|
|
|
366
735
|
if (!existsSync(stateFile))
|
|
367
736
|
return undefined;
|
|
368
737
|
try {
|
|
369
|
-
const state = JSON.parse(readFileSync(stateFile, "utf-8"));
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
738
|
+
const state = hydrateGoalLifecycle(JSON.parse(readFileSync(stateFile, "utf-8")));
|
|
739
|
+
const lifecycle = state.goalLifecycle;
|
|
740
|
+
if (!lifecycle || lifecycle.phase === "terminal")
|
|
741
|
+
return undefined;
|
|
742
|
+
const goal = goalConfigFromLifecycle(lifecycle);
|
|
743
|
+
// Expose a concrete version even for legacy state.json so UI delete/edit
|
|
744
|
+
// operations can send an expected fence instead of acting unconditionally.
|
|
745
|
+
return {
|
|
746
|
+
...goal,
|
|
747
|
+
goalId: goal.goalId ?? deriveLegacyGoalId(sessionId, goal),
|
|
748
|
+
revision: goal.revision ?? 1,
|
|
749
|
+
};
|
|
373
750
|
}
|
|
374
751
|
catch {
|
|
375
752
|
return undefined;
|
|
@@ -377,8 +754,9 @@ export class SessionManager {
|
|
|
377
754
|
}
|
|
378
755
|
/**
|
|
379
756
|
* Disk-only "wipe this session's persistent goal" — the counterpart to
|
|
380
|
-
* readActiveGoal. Reads state.json,
|
|
381
|
-
*
|
|
757
|
+
* readActiveGoal. Reads state.json, transitions the matching goalLifecycle
|
|
758
|
+
* to terminal(user_cleared), and rewrites it through the field-level CAS.
|
|
759
|
+
* Returns true only if a goal was actually
|
|
382
760
|
* present (idempotent — clearing a session with no goal is a no-op returning
|
|
383
761
|
* false). Never throws on an unknown / malformed / traversal-shaped id
|
|
384
762
|
* (returns false), matching readActiveGoal's tolerance.
|
|
@@ -391,28 +769,107 @@ export class SessionManager {
|
|
|
391
769
|
* Engine.clearGoal also delegates its disk write here so the wipe logic lives
|
|
392
770
|
* in exactly one place.
|
|
393
771
|
*/
|
|
394
|
-
clearActiveGoal(sessionId) {
|
|
772
|
+
clearActiveGoal(sessionId, expected) {
|
|
395
773
|
try {
|
|
396
774
|
assertSafeSessionId(sessionId);
|
|
397
775
|
}
|
|
398
776
|
catch {
|
|
399
777
|
return false;
|
|
400
778
|
}
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
779
|
+
for (let attempt = 0; attempt <= SESSION_STATE_LOCK_RETRY_DELAYS_MS.length; attempt++) {
|
|
780
|
+
let state;
|
|
781
|
+
try {
|
|
782
|
+
state = this.readPersistedState(sessionId);
|
|
783
|
+
}
|
|
784
|
+
catch {
|
|
785
|
+
return false;
|
|
786
|
+
}
|
|
787
|
+
const lifecycle = state.goalLifecycle;
|
|
788
|
+
if (!lifecycle || !isGoalLifecycleCurrent(lifecycle))
|
|
789
|
+
return false;
|
|
790
|
+
const clearedGoal = goalConfigFromLifecycle(lifecycle);
|
|
791
|
+
const revision = lifecycle.revision;
|
|
792
|
+
if (expected?.goalId !== undefined && clearedGoal.goalId !== expected.goalId)
|
|
793
|
+
return false;
|
|
794
|
+
if (expected?.revision !== undefined && revision !== expected.revision)
|
|
795
|
+
return false;
|
|
796
|
+
const terminal = terminateGoalLifecycle(lifecycle, "user_cleared");
|
|
797
|
+
if (!terminal)
|
|
798
|
+
return false;
|
|
799
|
+
state.goalLifecycle = terminal;
|
|
800
|
+
hydrateGoalLifecycle(state);
|
|
801
|
+
const result = this.saveStateAttempt(state);
|
|
802
|
+
if (result.ok)
|
|
803
|
+
return true;
|
|
804
|
+
if (result.reason !== "revision_conflict")
|
|
805
|
+
return false;
|
|
806
|
+
}
|
|
807
|
+
return false;
|
|
808
|
+
}
|
|
809
|
+
/**
|
|
810
|
+
* Edit or pause/resume the currently active goal with an identity-checked,
|
|
811
|
+
* field-level CAS. The goalId is preserved: editing the objective changes
|
|
812
|
+
* the same user-owned goal rather than terminally cancelling it and arming a
|
|
813
|
+
* replacement. A changed objective receives a fresh deadline anchor.
|
|
814
|
+
*
|
|
815
|
+
* Returns undefined when the session/goal is absent or the patch is invalid.
|
|
816
|
+
* The returned state lets a live Engine rebase its in-memory bundle after
|
|
817
|
+
* this control operation.
|
|
818
|
+
*/
|
|
819
|
+
updateActiveGoal(sessionId, patch) {
|
|
405
820
|
try {
|
|
406
|
-
|
|
821
|
+
assertSafeSessionId(sessionId);
|
|
407
822
|
}
|
|
408
823
|
catch {
|
|
409
|
-
return
|
|
824
|
+
return undefined;
|
|
410
825
|
}
|
|
411
|
-
if (
|
|
412
|
-
return
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
826
|
+
if (patch.objective === undefined && patch.paused === undefined)
|
|
827
|
+
return undefined;
|
|
828
|
+
if (patch.objective !== undefined && !patch.objective.trim())
|
|
829
|
+
return undefined;
|
|
830
|
+
if (patch.paused !== undefined && typeof patch.paused !== "boolean")
|
|
831
|
+
return undefined;
|
|
832
|
+
let expectedGoalId = patch.expectedGoalId;
|
|
833
|
+
let expectedRevision = patch.expectedRevision;
|
|
834
|
+
for (let attempt = 0; attempt <= SESSION_STATE_LOCK_RETRY_DELAYS_MS.length; attempt++) {
|
|
835
|
+
let state;
|
|
836
|
+
try {
|
|
837
|
+
state = this.readPersistedState(sessionId);
|
|
838
|
+
}
|
|
839
|
+
catch {
|
|
840
|
+
return undefined;
|
|
841
|
+
}
|
|
842
|
+
const lifecycle = state.goalLifecycle;
|
|
843
|
+
if (!lifecycle || !isGoalLifecycleCurrent(lifecycle))
|
|
844
|
+
return undefined;
|
|
845
|
+
const current = goalConfigFromLifecycle(lifecycle);
|
|
846
|
+
const currentRevision = lifecycle.revision;
|
|
847
|
+
expectedGoalId ??= current.goalId;
|
|
848
|
+
expectedRevision ??= currentRevision;
|
|
849
|
+
if (current.goalId !== expectedGoalId || currentRevision !== expectedRevision) {
|
|
850
|
+
return undefined;
|
|
851
|
+
}
|
|
852
|
+
const objective = patch.objective?.trim() ?? current.objective;
|
|
853
|
+
const objectiveChanged = objective !== current.objective;
|
|
854
|
+
const goal = {
|
|
855
|
+
...current,
|
|
856
|
+
objective,
|
|
857
|
+
revision: currentRevision + 1,
|
|
858
|
+
...(objectiveChanged ? { setAtMs: Date.now() } : {}),
|
|
859
|
+
};
|
|
860
|
+
if (patch.paused === true)
|
|
861
|
+
goal.paused = true;
|
|
862
|
+
else if (patch.paused === false)
|
|
863
|
+
delete goal.paused;
|
|
864
|
+
state.goalLifecycle = createGoalLifecycle(goal, goal.paused === true ? "paused" : "active");
|
|
865
|
+
hydrateGoalLifecycle(state);
|
|
866
|
+
const result = this.saveStateAttempt(state);
|
|
867
|
+
if (result.ok)
|
|
868
|
+
return { goal, stateRevision: state.stateRevision, state };
|
|
869
|
+
if (result.reason !== "revision_conflict")
|
|
870
|
+
return undefined;
|
|
871
|
+
}
|
|
872
|
+
return undefined;
|
|
416
873
|
}
|
|
417
874
|
resume(sessionId) {
|
|
418
875
|
assertSafeSessionId(sessionId);
|
|
@@ -426,7 +883,7 @@ export class SessionManager {
|
|
|
426
883
|
}
|
|
427
884
|
let state;
|
|
428
885
|
try {
|
|
429
|
-
state = JSON.parse(readFileSync(stateFile, "utf-8"));
|
|
886
|
+
state = hydrateGoalLifecycle(JSON.parse(readFileSync(stateFile, "utf-8")));
|
|
430
887
|
}
|
|
431
888
|
catch (err) {
|
|
432
889
|
// A corrupt state.json (external tampering, disk corruption, or a crash
|
|
@@ -436,73 +893,586 @@ export class SessionManager {
|
|
|
436
893
|
}
|
|
437
894
|
const transcriptFile = join(sessionDir, "transcript.jsonl");
|
|
438
895
|
const transcript = Transcript.loadFromFile(transcriptFile);
|
|
896
|
+
state.kind = normalizedSessionKind(state.kind);
|
|
439
897
|
state.status = "active";
|
|
898
|
+
delete state.lastCompletionKind;
|
|
440
899
|
Object.assign(state, normalizeCumulativeUsageCounters(state, state.tokenUsage));
|
|
441
900
|
return { state, transcript };
|
|
442
901
|
}
|
|
443
|
-
|
|
902
|
+
/**
|
|
903
|
+
* Return the exact persistence directory owned by this manager.
|
|
904
|
+
*
|
|
905
|
+
* Cross-session sidecars (for example versioned handoffs) use this to stay
|
|
906
|
+
* inside an identity/custom-data-root boundary instead of falling back to
|
|
907
|
+
* the process-global default sessions directory.
|
|
908
|
+
*/
|
|
909
|
+
getStorageDir() {
|
|
910
|
+
return this.sessionsDir;
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* Read one durable session state without changing its lifecycle status.
|
|
914
|
+
* `resume()` intentionally marks the returned in-memory state active; host
|
|
915
|
+
* coordination code needs a read-only snapshot for completed cursors and
|
|
916
|
+
* workspace/profile metadata.
|
|
917
|
+
*/
|
|
918
|
+
readSessionState(sessionId) {
|
|
919
|
+
try {
|
|
920
|
+
assertSafeSessionId(sessionId);
|
|
921
|
+
return structuredClone(this.readPersistedState(sessionId));
|
|
922
|
+
}
|
|
923
|
+
catch {
|
|
924
|
+
return undefined;
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* Merge a field-level state update into the latest persisted snapshot.
|
|
929
|
+
*
|
|
930
|
+
* Unlike saveState(), callers do not supply a potentially stale whole-state
|
|
931
|
+
* object. saveState serializes the read/revision-check/write section with the
|
|
932
|
+
* per-session lock and returns the new persisted revision to the caller.
|
|
933
|
+
*/
|
|
934
|
+
updateSessionState(sessionId, partial) {
|
|
935
|
+
assertSafeSessionId(sessionId);
|
|
936
|
+
for (let attempt = 0; attempt <= SESSION_STATE_LOCK_RETRY_DELAYS_MS.length; attempt++) {
|
|
937
|
+
const state = this.readPersistedState(sessionId);
|
|
938
|
+
if (partial.kind !== undefined &&
|
|
939
|
+
normalizedSessionKind(partial.kind) !== normalizedSessionKind(state.kind)) {
|
|
940
|
+
throw new SessionError(`Session kind is immutable for ${sessionId}`);
|
|
941
|
+
}
|
|
942
|
+
Object.assign(state, partial);
|
|
943
|
+
state.sessionId = sessionId;
|
|
944
|
+
const result = this.saveStateAttempt(state);
|
|
945
|
+
if (result.ok)
|
|
946
|
+
return state.stateRevision;
|
|
947
|
+
if (result.reason === "generation_conflict") {
|
|
948
|
+
throw new SessionError(`Session generation conflict for ${sessionId}`);
|
|
949
|
+
}
|
|
950
|
+
if (result.reason === "lock_conflict") {
|
|
951
|
+
throw new SessionError(`Session state lock contention for ${sessionId}`);
|
|
952
|
+
}
|
|
953
|
+
if (result.reason === "kind_conflict") {
|
|
954
|
+
throw new SessionError(`Session kind is immutable for ${sessionId}`);
|
|
955
|
+
}
|
|
956
|
+
// A field-level update is explicitly authorized to merge into the newest
|
|
957
|
+
// snapshot. Re-read it on the next iteration instead of treating its CAS
|
|
958
|
+
// miss like a lock collision.
|
|
959
|
+
}
|
|
960
|
+
throw new SessionError(`Session state revision conflict for ${sessionId}`);
|
|
961
|
+
}
|
|
962
|
+
/**
|
|
963
|
+
* Atomically add one billed auxiliary request to the latest source state.
|
|
964
|
+
* This field-level read/add/write avoids publishing a detached stale state
|
|
965
|
+
* snapshot and accounts prompt, completion, total, cache, and cost together.
|
|
966
|
+
*/
|
|
967
|
+
recordAuxiliaryUsage(sessionId, usage, costState) {
|
|
968
|
+
assertSafeSessionId(sessionId);
|
|
969
|
+
for (let attempt = 0; attempt <= SESSION_STATE_LOCK_RETRY_DELAYS_MS.length; attempt++) {
|
|
970
|
+
const state = this.readPersistedState(sessionId);
|
|
971
|
+
Object.assign(state, normalizeCumulativeUsageCounters(state, state.tokenUsage));
|
|
972
|
+
Object.assign(state, addCumulativeUsage(state, usage));
|
|
973
|
+
state.tokenUsage = addTokenUsage(state.tokenUsage, usage);
|
|
974
|
+
if (costState !== undefined)
|
|
975
|
+
state.costState = costState;
|
|
976
|
+
const result = this.saveStateAttempt(state);
|
|
977
|
+
if (result.ok)
|
|
978
|
+
return;
|
|
979
|
+
if (result.reason === "generation_conflict") {
|
|
980
|
+
throw new SessionError(`Session generation conflict for ${sessionId}`);
|
|
981
|
+
}
|
|
982
|
+
if (result.reason === "lock_conflict") {
|
|
983
|
+
throw new SessionError(`Session state lock contention for ${sessionId}`);
|
|
984
|
+
}
|
|
985
|
+
// Recompute the delta from the latest counters after a CAS miss so the
|
|
986
|
+
// auxiliary usage is added exactly once without overwriting another writer.
|
|
987
|
+
}
|
|
988
|
+
throw new SessionError(`Session state revision conflict for ${sessionId}`);
|
|
989
|
+
}
|
|
990
|
+
/** @deprecated Compatibility fixture writer; runtime code must use domain updates. */
|
|
991
|
+
saveState(state, generation) {
|
|
992
|
+
adoptCompatibilityGoalMutation(state);
|
|
993
|
+
return this.saveStateAttempt(state, generation).ok;
|
|
994
|
+
}
|
|
995
|
+
/**
|
|
996
|
+
* Persist one goal terminal transition without allowing a revision race to
|
|
997
|
+
* resurrect that goal. This is always a Goal-domain update: read the newest
|
|
998
|
+
* snapshot, transition only the matching lifecycle identity, then commit. A detached run
|
|
999
|
+
* snapshot is never used as the write candidate,
|
|
1000
|
+
* so a successful terminal transition cannot publish stale summary/workspace
|
|
1001
|
+
* or accounting fields. Lock/generation conflicts remain distinct failures.
|
|
1002
|
+
*/
|
|
1003
|
+
saveGoalTerminal(state, goal, reason) {
|
|
1004
|
+
return this.saveGoalTerminalOutcome(state, goal, reason) !== "failed";
|
|
1005
|
+
}
|
|
1006
|
+
/**
|
|
1007
|
+
* Persist a terminal transition and distinguish a durable commit from a stale
|
|
1008
|
+
* run whose Goal identity has already been replaced. Callers may publish a
|
|
1009
|
+
* terminal event only for `persisted`.
|
|
1010
|
+
*/
|
|
1011
|
+
saveGoalTerminalOutcome(state, goal, reason) {
|
|
1012
|
+
if (!goal)
|
|
1013
|
+
return "failed";
|
|
1014
|
+
const terminalGoal = {
|
|
1015
|
+
...goal,
|
|
1016
|
+
goalId: goal.goalId ?? deriveLegacyGoalId(state.sessionId, goal),
|
|
1017
|
+
revision: goal.revision ?? 1,
|
|
1018
|
+
};
|
|
1019
|
+
for (let attempt = 0; attempt <= SESSION_STATE_LOCK_RETRY_DELAYS_MS.length; attempt++) {
|
|
1020
|
+
const latest = this.readPersistedState(state.sessionId);
|
|
1021
|
+
const lifecycle = latest.goalLifecycle;
|
|
1022
|
+
if (!lifecycle)
|
|
1023
|
+
return "failed";
|
|
1024
|
+
if (lifecycle.goalId !== terminalGoal.goalId ||
|
|
1025
|
+
lifecycle.revision !== (terminalGoal.revision ?? 1)) {
|
|
1026
|
+
// A newer replacement/edit already owns the canonical slot. The stale
|
|
1027
|
+
// run is obsolete, but must not mutate that newer goal.
|
|
1028
|
+
this.rebaseLiveState(state, latest);
|
|
1029
|
+
return "obsolete";
|
|
1030
|
+
}
|
|
1031
|
+
if (lifecycle.phase !== "terminal") {
|
|
1032
|
+
latest.goalLifecycle = terminateGoalLifecycle(lifecycle, lifecycleTerminalReason(reason));
|
|
1033
|
+
hydrateGoalLifecycle(latest);
|
|
1034
|
+
}
|
|
1035
|
+
const result = this.saveStateAttempt(latest);
|
|
1036
|
+
if (result.ok) {
|
|
1037
|
+
this.rebaseLiveState(state, latest);
|
|
1038
|
+
return "persisted";
|
|
1039
|
+
}
|
|
1040
|
+
if (result.reason !== "revision_conflict")
|
|
1041
|
+
return "failed";
|
|
1042
|
+
}
|
|
1043
|
+
return "failed";
|
|
1044
|
+
}
|
|
1045
|
+
/**
|
|
1046
|
+
* Arm or migrate one active goal as a Goal-domain update. The newest state is
|
|
1047
|
+
* always the write base, so unrelated fields written by another host survive.
|
|
1048
|
+
* Replacement is explicit and terminally closes the latest active identity;
|
|
1049
|
+
* a non-replacement write may only normalize the same legacy/current goal.
|
|
1050
|
+
*/
|
|
1051
|
+
saveActiveGoal(state, goal, options = {}) {
|
|
1052
|
+
const armedGoal = {
|
|
1053
|
+
...goal,
|
|
1054
|
+
goalId: goal.goalId ?? deriveLegacyGoalId(state.sessionId, goal),
|
|
1055
|
+
revision: goal.revision ?? 1,
|
|
1056
|
+
};
|
|
1057
|
+
for (let attempt = 0; attempt <= SESSION_STATE_LOCK_RETRY_DELAYS_MS.length; attempt++) {
|
|
1058
|
+
const latest = this.readPersistedState(state.sessionId);
|
|
1059
|
+
const current = latest.goalLifecycle;
|
|
1060
|
+
if (current) {
|
|
1061
|
+
const sameIdentity = current.goalId === armedGoal.goalId;
|
|
1062
|
+
const sameRevision = current.revision === (armedGoal.revision ?? 1);
|
|
1063
|
+
if (current.phase === "terminal" && sameIdentity && sameRevision)
|
|
1064
|
+
return false;
|
|
1065
|
+
if (current.phase !== "terminal" && !options.replaceCurrent && !sameIdentity)
|
|
1066
|
+
return false;
|
|
1067
|
+
}
|
|
1068
|
+
if (current?.phase === "waiting" &&
|
|
1069
|
+
current.goalId === armedGoal.goalId &&
|
|
1070
|
+
current.revision === (armedGoal.revision ?? 1)) {
|
|
1071
|
+
latest.goalLifecycle = armGoalLifecycle(current) ?? current;
|
|
1072
|
+
}
|
|
1073
|
+
else {
|
|
1074
|
+
latest.goalLifecycle = createGoalLifecycle(armedGoal, armedGoal.paused === true ? "paused" : "active");
|
|
1075
|
+
}
|
|
1076
|
+
hydrateGoalLifecycle(latest);
|
|
1077
|
+
const result = this.saveStateAttempt(latest);
|
|
1078
|
+
if (result.ok) {
|
|
1079
|
+
this.rebaseLiveState(state, latest);
|
|
1080
|
+
return true;
|
|
1081
|
+
}
|
|
1082
|
+
if (result.reason !== "revision_conflict")
|
|
1083
|
+
return false;
|
|
1084
|
+
}
|
|
1085
|
+
return false;
|
|
1086
|
+
}
|
|
1087
|
+
/** Persist active → waiting only for the expected concrete Goal version. */
|
|
1088
|
+
markGoalWaiting(state, goal) {
|
|
1089
|
+
const expectedGoalId = goal.goalId ?? deriveLegacyGoalId(state.sessionId, goal);
|
|
1090
|
+
const expectedRevision = Math.max(1, Math.floor(goal.revision ?? 1));
|
|
1091
|
+
for (let attempt = 0; attempt <= SESSION_STATE_LOCK_RETRY_DELAYS_MS.length; attempt++) {
|
|
1092
|
+
const latest = this.readPersistedState(state.sessionId);
|
|
1093
|
+
const lifecycle = latest.goalLifecycle;
|
|
1094
|
+
if (!lifecycle)
|
|
1095
|
+
return false;
|
|
1096
|
+
if (lifecycle.goalId !== expectedGoalId || lifecycle.revision !== expectedRevision) {
|
|
1097
|
+
this.rebaseLiveState(state, latest);
|
|
1098
|
+
return false;
|
|
1099
|
+
}
|
|
1100
|
+
if (lifecycle.phase === "waiting") {
|
|
1101
|
+
this.rebaseLiveState(state, latest);
|
|
1102
|
+
return true;
|
|
1103
|
+
}
|
|
1104
|
+
const waiting = waitGoalLifecycle(lifecycle);
|
|
1105
|
+
if (!waiting)
|
|
1106
|
+
return false;
|
|
1107
|
+
latest.goalLifecycle = waiting;
|
|
1108
|
+
hydrateGoalLifecycle(latest);
|
|
1109
|
+
const result = this.saveStateAttempt(latest);
|
|
1110
|
+
if (result.ok) {
|
|
1111
|
+
this.rebaseLiveState(state, latest);
|
|
1112
|
+
return true;
|
|
1113
|
+
}
|
|
1114
|
+
if (result.reason !== "revision_conflict")
|
|
1115
|
+
return false;
|
|
1116
|
+
}
|
|
1117
|
+
return false;
|
|
1118
|
+
}
|
|
1119
|
+
/** Drop a matching active goal only when its terminal identity is durable. */
|
|
1120
|
+
clearTerminatedActiveGoal(state, goal) {
|
|
1121
|
+
for (let attempt = 0; attempt <= SESSION_STATE_LOCK_RETRY_DELAYS_MS.length; attempt++) {
|
|
1122
|
+
const latest = this.readPersistedState(state.sessionId);
|
|
1123
|
+
const expectedGoalId = goal.goalId ?? deriveLegacyGoalId(state.sessionId, goal);
|
|
1124
|
+
const lifecycle = latest.goalLifecycle;
|
|
1125
|
+
if (!lifecycle ||
|
|
1126
|
+
lifecycle.goalId !== expectedGoalId ||
|
|
1127
|
+
lifecycle.revision !== Math.max(1, Math.floor(goal.revision ?? 1))) {
|
|
1128
|
+
this.rebaseLiveState(state, latest);
|
|
1129
|
+
return true;
|
|
1130
|
+
}
|
|
1131
|
+
if (lifecycle.phase !== "terminal")
|
|
1132
|
+
return false;
|
|
1133
|
+
this.rebaseLiveState(state, latest);
|
|
1134
|
+
return true;
|
|
1135
|
+
}
|
|
1136
|
+
return false;
|
|
1137
|
+
}
|
|
1138
|
+
/**
|
|
1139
|
+
* Commit only the fields owned by a live run. The detached `state` object is
|
|
1140
|
+
* used solely as a live-view target for the returned revision; it is never
|
|
1141
|
+
* serialized wholesale. This prevents a Goal/workspace/title domain write
|
|
1142
|
+
* from authorizing a later stale metadata overwrite.
|
|
1143
|
+
*/
|
|
1144
|
+
saveStateOrUpdateFields(state, partial) {
|
|
1145
|
+
try {
|
|
1146
|
+
const stateRevision = this.updateSessionState(state.sessionId, partial);
|
|
1147
|
+
Object.assign(state, partial, { stateRevision });
|
|
1148
|
+
return true;
|
|
1149
|
+
}
|
|
1150
|
+
catch {
|
|
1151
|
+
return false;
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
saveStateAttempt(state, generation) {
|
|
444
1155
|
// state.sessionId could come from a deserialized state.json that was
|
|
445
1156
|
// tampered with on disk. Validate before joining.
|
|
446
1157
|
assertSafeSessionId(state.sessionId);
|
|
1158
|
+
try {
|
|
1159
|
+
hydrateGoalLifecycle(state);
|
|
1160
|
+
}
|
|
1161
|
+
catch {
|
|
1162
|
+
return { ok: false, reason: "goal_schema_conflict" };
|
|
1163
|
+
}
|
|
1164
|
+
const writerGeneration = generation ?? this.registeredCloseEpochs.get(state.sessionId);
|
|
1165
|
+
if (writerGeneration !== undefined &&
|
|
1166
|
+
(currentSessionCloseEpochs.get(this.generationKey(state.sessionId)) ?? 0) !== writerGeneration) {
|
|
1167
|
+
return { ok: false, reason: "generation_conflict" };
|
|
1168
|
+
}
|
|
447
1169
|
const sessionDir = join(this.sessionsDir, state.sessionId);
|
|
448
1170
|
mkdirSync(sessionDir, { recursive: true });
|
|
449
|
-
// Atomic write: stage to .tmp, then rename. Protects against two processes
|
|
450
|
-
// clobbering each other's state.json mid-write.
|
|
451
1171
|
const target = join(sessionDir, "state.json");
|
|
452
|
-
//
|
|
453
|
-
//
|
|
454
|
-
//
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
1172
|
+
// proper-lockfile uses an atomic mkdir lock plus an mtime lease. It
|
|
1173
|
+
// recovers crash-orphaned locks after `stale` and refreshes live locks so a
|
|
1174
|
+
// slow writer is not stolen. Short genuine contention gets bounded retry.
|
|
1175
|
+
const release = this.acquireStateLock(target);
|
|
1176
|
+
if (!release)
|
|
1177
|
+
return { ok: false, reason: "lock_conflict" };
|
|
1178
|
+
try {
|
|
1179
|
+
let persisted;
|
|
1180
|
+
if (existsSync(target)) {
|
|
1181
|
+
try {
|
|
1182
|
+
persisted = hydrateGoalLifecycle(JSON.parse(readFileSync(target, "utf-8")));
|
|
1183
|
+
}
|
|
1184
|
+
catch {
|
|
1185
|
+
return { ok: false, reason: "goal_schema_conflict" };
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
const incomingKind = normalizedSessionKind(state.kind);
|
|
1189
|
+
const persistedKind = persisted ? normalizedSessionKind(persisted.kind) : incomingKind;
|
|
1190
|
+
if (persisted && incomingKind !== persistedKind) {
|
|
1191
|
+
return { ok: false, reason: "kind_conflict" };
|
|
1192
|
+
}
|
|
1193
|
+
state.kind = persistedKind;
|
|
1194
|
+
const persistedRevision = persisted?.stateRevision;
|
|
1195
|
+
const incomingRevision = state.stateRevision;
|
|
1196
|
+
const revisionsMatch = persisted === undefined ||
|
|
1197
|
+
(persistedRevision === undefined && incomingRevision === undefined) ||
|
|
1198
|
+
(typeof persistedRevision === "number" && incomingRevision === persistedRevision);
|
|
1199
|
+
if (!revisionsMatch)
|
|
1200
|
+
return { ok: false, reason: "revision_conflict" };
|
|
1201
|
+
// A title can be generated by a field-level writer while this bundle is
|
|
1202
|
+
// live. Preserve it when the incoming object never owned the field.
|
|
1203
|
+
if (persisted?.title !== undefined && !("title" in state)) {
|
|
1204
|
+
state.title = persisted.title;
|
|
1205
|
+
}
|
|
1206
|
+
state.stateRevision = (persistedRevision ?? incomingRevision ?? 0) + 1;
|
|
1207
|
+
const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
|
|
1208
|
+
writeFileSync(tmp, JSON.stringify(stateForPersistence(state), null, 2), "utf-8");
|
|
1209
|
+
renameSync(tmp, target);
|
|
1210
|
+
return { ok: true };
|
|
1211
|
+
}
|
|
1212
|
+
finally {
|
|
458
1213
|
try {
|
|
459
|
-
|
|
1214
|
+
release();
|
|
460
1215
|
}
|
|
461
1216
|
catch {
|
|
462
|
-
//
|
|
463
|
-
// behavior and overwrite malformed state with the caller's snapshot.
|
|
1217
|
+
// A compromised/reaped lease must not mask the persistence result.
|
|
464
1218
|
}
|
|
465
1219
|
}
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
1220
|
+
}
|
|
1221
|
+
acquireStateLock(target) {
|
|
1222
|
+
const lockPath = `${target}.lock`;
|
|
1223
|
+
for (let attempt = 0; attempt <= SESSION_STATE_LOCK_RETRY_DELAYS_MS.length; attempt++) {
|
|
1224
|
+
if (!this.prepareLegacyStateLock(lockPath)) {
|
|
1225
|
+
const delay = SESSION_STATE_LOCK_RETRY_DELAYS_MS[attempt];
|
|
1226
|
+
if (delay === undefined)
|
|
1227
|
+
return undefined;
|
|
1228
|
+
sleepSync(delay);
|
|
1229
|
+
continue;
|
|
1230
|
+
}
|
|
1231
|
+
try {
|
|
1232
|
+
return lockSync(target, {
|
|
1233
|
+
stale: SESSION_STATE_LOCK_STALE_MS,
|
|
1234
|
+
update: SESSION_STATE_LOCK_STALE_MS / 2,
|
|
1235
|
+
retries: 0,
|
|
1236
|
+
realpath: false,
|
|
1237
|
+
});
|
|
1238
|
+
}
|
|
1239
|
+
catch (err) {
|
|
1240
|
+
const code = err.code;
|
|
1241
|
+
if (code !== "ELOCKED" && code !== "EEXIST")
|
|
1242
|
+
throw err;
|
|
1243
|
+
const delay = SESSION_STATE_LOCK_RETRY_DELAYS_MS[attempt];
|
|
1244
|
+
if (delay === undefined)
|
|
1245
|
+
return undefined;
|
|
1246
|
+
sleepSync(delay);
|
|
1247
|
+
}
|
|
472
1248
|
}
|
|
473
|
-
|
|
474
|
-
writeFileSync(tmp, JSON.stringify(state, null, 2), "utf-8");
|
|
475
|
-
renameSync(tmp, target);
|
|
1249
|
+
return undefined;
|
|
476
1250
|
}
|
|
477
|
-
/**
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
1251
|
+
/** Recover only the old implementation's regular-file orphan lock. */
|
|
1252
|
+
prepareLegacyStateLock(lockPath) {
|
|
1253
|
+
if (!existsSync(lockPath))
|
|
1254
|
+
return true;
|
|
1255
|
+
let stat;
|
|
1256
|
+
try {
|
|
1257
|
+
stat = lstatSync(lockPath);
|
|
1258
|
+
}
|
|
1259
|
+
catch {
|
|
1260
|
+
return true;
|
|
1261
|
+
}
|
|
1262
|
+
if (stat.isDirectory())
|
|
1263
|
+
return true;
|
|
1264
|
+
if (Date.now() - stat.mtimeMs <= SESSION_STATE_LOCK_STALE_MS)
|
|
1265
|
+
return false;
|
|
1266
|
+
try {
|
|
1267
|
+
rmSync(lockPath, { force: true });
|
|
1268
|
+
return true;
|
|
1269
|
+
}
|
|
1270
|
+
catch {
|
|
1271
|
+
return false;
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
readPersistedState(sessionId) {
|
|
1275
|
+
const stateFile = join(this.sessionsDir, sessionId, "state.json");
|
|
1276
|
+
if (!existsSync(stateFile)) {
|
|
1277
|
+
throw new SessionError(`Session state file not found: ${sessionId}`);
|
|
1278
|
+
}
|
|
1279
|
+
try {
|
|
1280
|
+
return hydrateGoalLifecycle(JSON.parse(readFileSync(stateFile, "utf-8")));
|
|
1281
|
+
}
|
|
1282
|
+
catch (err) {
|
|
1283
|
+
throw new SessionError(`Session state is corrupt for ${sessionId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
rebaseLiveState(live, persisted) {
|
|
1287
|
+
for (const key of Object.keys(live)) {
|
|
1288
|
+
if (!(key in persisted))
|
|
1289
|
+
delete live[key];
|
|
1290
|
+
}
|
|
1291
|
+
Object.assign(live, persisted);
|
|
1292
|
+
}
|
|
1293
|
+
generationKey(sessionId) {
|
|
1294
|
+
return `${this.sessionsDir}\0${sessionId}`;
|
|
1295
|
+
}
|
|
1296
|
+
/** Create an independent, top-level session from a frozen event cursor. */
|
|
1297
|
+
fork(sourceSessionId, options = {}) {
|
|
1298
|
+
assertSafeSessionId(sourceSessionId);
|
|
1299
|
+
if (options.targetSessionId !== undefined)
|
|
1300
|
+
assertSafeSessionId(options.targetSessionId);
|
|
1301
|
+
if (options.snapshotMode === "completed" && options.throughEventId !== undefined) {
|
|
1302
|
+
throw new SessionError(`Completed snapshot cannot use an explicit fork cursor`);
|
|
1303
|
+
}
|
|
1304
|
+
const snapshot = this.readForkSnapshot(sourceSessionId, options.throughEventId, options.snapshotMode ?? "tail");
|
|
1305
|
+
const targetSessionId = options.targetSessionId ?? nanoid(16);
|
|
1306
|
+
const createdAt = Date.now();
|
|
1307
|
+
const lineage = {
|
|
1308
|
+
sessionId: sourceSessionId,
|
|
1309
|
+
mode: "full",
|
|
1310
|
+
fromEventId: snapshot.copiedEvents[0]?.id,
|
|
1311
|
+
throughEventId: snapshot.copiedEvents[snapshot.copiedEvents.length - 1]?.id,
|
|
1312
|
+
sourceEventCount: snapshot.copiedEvents.length,
|
|
1313
|
+
createdAt,
|
|
1314
|
+
};
|
|
1315
|
+
const state = buildForkState(snapshot.sourceState, targetSessionId, lineage, createdAt);
|
|
1316
|
+
if (options.ephemeral === true)
|
|
1317
|
+
state.ephemeral = true;
|
|
1318
|
+
const events = buildForkTranscript(snapshot.copiedEvents, state);
|
|
1319
|
+
const bundle = this.publishSessionAtomically(targetSessionId, state, events);
|
|
1320
|
+
return { bundle, lineage, copiedEventCount: snapshot.copiedEvents.length };
|
|
1321
|
+
}
|
|
1322
|
+
/** Freeze and validate an inclusive source range before any model call. */
|
|
1323
|
+
selectContextPackage(sourceSessionId, range) {
|
|
1324
|
+
assertSafeSessionId(sourceSessionId);
|
|
1325
|
+
const transcriptFile = join(this.sessionsDir, sourceSessionId, "transcript.jsonl");
|
|
1326
|
+
const stateFile = join(this.sessionsDir, sourceSessionId, "state.json");
|
|
1327
|
+
if (!existsSync(stateFile))
|
|
1328
|
+
throw new SessionError(`Session not found: ${sourceSessionId}`);
|
|
1329
|
+
const parsed = Transcript.readEvents(transcriptFile);
|
|
1330
|
+
if (parsed.malformedLineCount > 0) {
|
|
1331
|
+
throw new SessionError(`Session transcript is malformed for ${sourceSessionId}: ${parsed.malformedLineCount} invalid line(s)`);
|
|
1332
|
+
}
|
|
1333
|
+
return Transcript.selectContextRange(parsed.events, range);
|
|
1334
|
+
}
|
|
1335
|
+
/** Publish a summary-only top-level fork after summarization has succeeded. */
|
|
1336
|
+
createSummaryFork(sourceSessionId, options) {
|
|
1337
|
+
assertSafeSessionId(sourceSessionId);
|
|
1338
|
+
if (options.targetSessionId !== undefined)
|
|
1339
|
+
assertSafeSessionId(options.targetSessionId);
|
|
1340
|
+
if (!options.fromEventId || !options.toEventId) {
|
|
1341
|
+
throw new SessionError("Summary fork requires a closed source event range");
|
|
1342
|
+
}
|
|
1343
|
+
if (!options.summary.trim())
|
|
1344
|
+
throw new SessionError("Summary fork requires a non-empty summary");
|
|
483
1345
|
const source = this.resume(sourceSessionId);
|
|
484
|
-
const
|
|
485
|
-
|
|
486
|
-
const
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
1346
|
+
const targetSessionId = options.targetSessionId ?? nanoid(16);
|
|
1347
|
+
const createdAt = Date.now();
|
|
1348
|
+
const lineage = {
|
|
1349
|
+
sessionId: sourceSessionId,
|
|
1350
|
+
mode: "summary",
|
|
1351
|
+
fromEventId: options.fromEventId,
|
|
1352
|
+
throughEventId: options.toEventId,
|
|
1353
|
+
sourceEventCount: options.sourceEventCount,
|
|
1354
|
+
createdAt,
|
|
1355
|
+
};
|
|
1356
|
+
const state = buildForkState(source.state, targetSessionId, lineage, createdAt);
|
|
1357
|
+
const [meta] = buildForkTranscript([], state);
|
|
1358
|
+
const summaryEvent = {
|
|
1359
|
+
id: nanoid(12),
|
|
1360
|
+
type: "context_transfer",
|
|
1361
|
+
timestamp: createdAt,
|
|
1362
|
+
turnNumber: 0,
|
|
1363
|
+
data: {
|
|
1364
|
+
summary: options.summary,
|
|
1365
|
+
sourceRange: {
|
|
1366
|
+
sessionId: sourceSessionId,
|
|
1367
|
+
fromEventId: options.fromEventId,
|
|
1368
|
+
toEventId: options.toEventId,
|
|
1369
|
+
},
|
|
1370
|
+
sourceEventCount: options.sourceEventCount,
|
|
1371
|
+
estimatedTokens: options.estimatedTokens,
|
|
1372
|
+
summaryVersion: 1,
|
|
1373
|
+
summaryHash: createHash("sha256").update(options.summary).digest("hex"),
|
|
1374
|
+
},
|
|
1375
|
+
};
|
|
1376
|
+
const bundle = this.publishSessionAtomically(targetSessionId, state, [meta, summaryEvent]);
|
|
1377
|
+
return { bundle, lineage, copiedEventCount: 0 };
|
|
1378
|
+
}
|
|
1379
|
+
readForkSnapshot(sourceSessionId, throughEventId, snapshotMode) {
|
|
1380
|
+
const sessionDir = join(this.sessionsDir, sourceSessionId);
|
|
1381
|
+
const stateFile = join(sessionDir, "state.json");
|
|
1382
|
+
const transcriptFile = join(sessionDir, "transcript.jsonl");
|
|
1383
|
+
if (!existsSync(stateFile))
|
|
1384
|
+
throw new SessionError(`Session not found: ${sourceSessionId}`);
|
|
1385
|
+
let sourceState;
|
|
1386
|
+
try {
|
|
1387
|
+
sourceState = JSON.parse(readFileSync(stateFile, "utf-8"));
|
|
1388
|
+
}
|
|
1389
|
+
catch (err) {
|
|
1390
|
+
throw new SessionError(`Session state is corrupt for ${sourceSessionId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1391
|
+
}
|
|
1392
|
+
const parsed = Transcript.readEvents(transcriptFile);
|
|
1393
|
+
if (parsed.malformedLineCount > 0) {
|
|
1394
|
+
throw new SessionError(`Session transcript is malformed for ${sourceSessionId}: ${parsed.malformedLineCount} invalid line(s)`);
|
|
1395
|
+
}
|
|
1396
|
+
const sourceEvents = structuredClone(parsed.events);
|
|
1397
|
+
let frozen = sourceEvents;
|
|
1398
|
+
const effectiveCursor = snapshotMode === "completed" ? sourceState.completedThroughEventId : throughEventId;
|
|
1399
|
+
if (snapshotMode === "completed" && effectiveCursor === undefined) {
|
|
1400
|
+
// Legacy sessions predate completedThroughEventId. Their tail is a safe
|
|
1401
|
+
// completed snapshot only when the persisted lifecycle status says the
|
|
1402
|
+
// last run completed naturally. A modern schema marker without a cursor
|
|
1403
|
+
// means persistence was degraded, so even status=completed must not use
|
|
1404
|
+
// the tail. Active/error/abort tails may be partial as well.
|
|
1405
|
+
frozen =
|
|
1406
|
+
sourceState.completedSnapshotVersion === undefined && sourceState.status === "completed"
|
|
1407
|
+
? sourceEvents
|
|
1408
|
+
: [];
|
|
1409
|
+
}
|
|
1410
|
+
else if (effectiveCursor !== undefined) {
|
|
1411
|
+
const matches = sourceEvents
|
|
1412
|
+
.map((event, index) => (event.id === effectiveCursor ? index : -1))
|
|
1413
|
+
.filter((index) => index >= 0);
|
|
1414
|
+
if (matches.length !== 1) {
|
|
1415
|
+
throw new SessionError(snapshotMode === "completed"
|
|
1416
|
+
? `Completed fork cursor must identify exactly one source event`
|
|
1417
|
+
: `Fork cursor must identify exactly one source event`);
|
|
1418
|
+
}
|
|
1419
|
+
const cursor = sourceEvents[matches[0]];
|
|
1420
|
+
if (cursor.type === "session_meta") {
|
|
1421
|
+
throw new SessionError(`Fork cursor cannot point at session metadata`);
|
|
1422
|
+
}
|
|
1423
|
+
frozen = sourceEvents.slice(0, matches[0] + 1);
|
|
1424
|
+
}
|
|
1425
|
+
const copiedEvents = [];
|
|
1426
|
+
for (const event of frozen) {
|
|
1427
|
+
if (FORK_SKIP_EVENT_TYPES.has(event.type))
|
|
1428
|
+
continue;
|
|
1429
|
+
if (!FORK_COPY_EVENT_TYPES.has(event.type)) {
|
|
1430
|
+
throw new SessionError(`Unsupported transcript event in fork: ${String(event.type)}`);
|
|
1431
|
+
}
|
|
1432
|
+
copiedEvents.push(structuredClone(event));
|
|
1433
|
+
}
|
|
1434
|
+
validateForkToolPairs(copiedEvents);
|
|
1435
|
+
return { sourceState: structuredClone(sourceState), copiedEvents };
|
|
1436
|
+
}
|
|
1437
|
+
publishSessionAtomically(targetSessionId, state, events) {
|
|
1438
|
+
const targetDir = join(this.sessionsDir, targetSessionId);
|
|
1439
|
+
if (existsSync(targetDir))
|
|
1440
|
+
throw new SessionError(`Session already exists: ${targetSessionId}`);
|
|
1441
|
+
const stagingDir = join(this.sessionsDir, `.pending-fork-${targetSessionId}-${nanoid(8)}`);
|
|
1442
|
+
let published = false;
|
|
1443
|
+
try {
|
|
1444
|
+
mkdirSync(stagingDir);
|
|
1445
|
+
writeFileSync(join(stagingDir, "state.json"), JSON.stringify(state, null, 2), {
|
|
1446
|
+
encoding: "utf-8",
|
|
1447
|
+
mode: 0o600,
|
|
1448
|
+
});
|
|
1449
|
+
const jsonl = events.map((event) => JSON.stringify(event)).join("\n") + "\n";
|
|
1450
|
+
writeFileSync(join(stagingDir, "transcript.jsonl"), jsonl, {
|
|
1451
|
+
encoding: "utf-8",
|
|
1452
|
+
mode: 0o600,
|
|
1453
|
+
});
|
|
1454
|
+
if (existsSync(targetDir)) {
|
|
1455
|
+
throw new SessionError(`Session already exists: ${targetSessionId}`);
|
|
495
1456
|
}
|
|
496
|
-
|
|
1457
|
+
renameSync(stagingDir, targetDir);
|
|
1458
|
+
published = true;
|
|
1459
|
+
return this.resume(targetSessionId);
|
|
1460
|
+
}
|
|
1461
|
+
finally {
|
|
1462
|
+
if (!published)
|
|
1463
|
+
rmSync(stagingDir, { recursive: true, force: true });
|
|
497
1464
|
}
|
|
498
|
-
this.saveState(newBundle.state);
|
|
499
|
-
return newBundle;
|
|
500
1465
|
}
|
|
501
|
-
list(limit = 20) {
|
|
1466
|
+
list(limit = 20, opts) {
|
|
502
1467
|
if (!existsSync(this.sessionsDir))
|
|
503
1468
|
return [];
|
|
1469
|
+
// Extension-owned session kinds stay out of generic lists. Defaults to
|
|
1470
|
+
// hiding "pet" so default-arg callers keep today's behavior.
|
|
1471
|
+
// TODO(pet-out-of-core): drop the default once every caller passes the
|
|
1472
|
+
// host's hidden-kind union explicitly.
|
|
1473
|
+
const excludeKinds = opts?.excludeKinds ?? ["pet"];
|
|
504
1474
|
const dirs = readdirSync(this.sessionsDir, { withFileTypes: true })
|
|
505
|
-
.filter((d) => d.isDirectory())
|
|
1475
|
+
.filter((d) => d.isDirectory() && !d.name.startsWith(".pending-") && !d.name.startsWith("qchat-"))
|
|
506
1476
|
.map((d) => d.name);
|
|
507
1477
|
const candidates = [];
|
|
508
1478
|
for (const dir of dirs) {
|
|
@@ -529,11 +1499,17 @@ export class SessionManager {
|
|
|
529
1499
|
candidates.push({ dir, lastActiveAt, transcriptFile, stateFile, transcriptExists });
|
|
530
1500
|
}
|
|
531
1501
|
candidates.sort((a, b) => b.lastActiveAt - a.lastActiveAt);
|
|
532
|
-
const top = candidates.slice(0, limit);
|
|
533
1502
|
const sessions = [];
|
|
534
|
-
for (const c of
|
|
1503
|
+
for (const c of candidates) {
|
|
1504
|
+
if (sessions.length >= limit)
|
|
1505
|
+
break;
|
|
535
1506
|
try {
|
|
536
1507
|
const state = JSON.parse(readFileSync(c.stateFile, "utf-8"));
|
|
1508
|
+
if (isEphemeralSessionState(state))
|
|
1509
|
+
continue;
|
|
1510
|
+
state.kind = normalizedSessionKind(state.kind);
|
|
1511
|
+
if (excludeKinds.includes(state.kind))
|
|
1512
|
+
continue;
|
|
537
1513
|
const preview = c.transcriptExists ? readLastUserMessage(c.transcriptFile) : undefined;
|
|
538
1514
|
sessions.push({ ...state, preview, lastActiveAt: c.lastActiveAt });
|
|
539
1515
|
}
|
|
@@ -544,14 +1520,154 @@ export class SessionManager {
|
|
|
544
1520
|
return sessions;
|
|
545
1521
|
}
|
|
546
1522
|
}
|
|
547
|
-
function
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
1523
|
+
export function buildForkState(source, targetSessionId, lineage, startedAt = Date.now()) {
|
|
1524
|
+
const workspace = isSessionWorkspace(source.workspace)
|
|
1525
|
+
? structuredClone(source.workspace)
|
|
1526
|
+
: { root: source.cwd, kind: "main" };
|
|
1527
|
+
return {
|
|
1528
|
+
sessionId: targetSessionId,
|
|
1529
|
+
kind: "work",
|
|
1530
|
+
stateRevision: 0,
|
|
1531
|
+
cwd: source.cwd,
|
|
1532
|
+
workspace,
|
|
1533
|
+
startedAt,
|
|
1534
|
+
model: source.model,
|
|
1535
|
+
provider: source.provider,
|
|
1536
|
+
tokenUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
|
1537
|
+
cumulativePromptTokens: 0,
|
|
1538
|
+
cumulativeCacheReadTokens: 0,
|
|
1539
|
+
cumulativeCacheCreationTokens: 0,
|
|
1540
|
+
turnCount: 0,
|
|
1541
|
+
turnSeq: 0,
|
|
1542
|
+
invokedSkills: [],
|
|
1543
|
+
parentSessionId: null,
|
|
1544
|
+
forkedFrom: structuredClone(lineage),
|
|
1545
|
+
...(source.origin ? { origin: source.origin } : {}),
|
|
1546
|
+
status: "active",
|
|
1547
|
+
};
|
|
1548
|
+
}
|
|
1549
|
+
export function buildForkTranscript(sourceEvents, state) {
|
|
1550
|
+
const meta = {
|
|
1551
|
+
id: nanoid(12),
|
|
1552
|
+
type: "session_meta",
|
|
1553
|
+
timestamp: state.startedAt,
|
|
1554
|
+
turnNumber: 0,
|
|
1555
|
+
data: {
|
|
1556
|
+
sessionId: state.sessionId,
|
|
1557
|
+
cwd: state.cwd,
|
|
1558
|
+
workspace: structuredClone(state.workspace),
|
|
1559
|
+
model: state.model,
|
|
1560
|
+
provider: state.provider,
|
|
1561
|
+
startedAt: state.startedAt,
|
|
1562
|
+
forkedFrom: structuredClone(state.forkedFrom),
|
|
1563
|
+
},
|
|
1564
|
+
};
|
|
1565
|
+
return [
|
|
1566
|
+
meta,
|
|
1567
|
+
...sourceEvents.map((source) => ({
|
|
1568
|
+
...structuredClone(source),
|
|
1569
|
+
id: nanoid(12),
|
|
1570
|
+
})),
|
|
1571
|
+
];
|
|
1572
|
+
}
|
|
1573
|
+
function validateForkToolPairs(events) {
|
|
1574
|
+
const projectedUses = [];
|
|
1575
|
+
const projectedResults = [];
|
|
1576
|
+
const seenProjectedUses = new Set();
|
|
1577
|
+
const seenProjectedResults = new Set();
|
|
1578
|
+
const projectedPending = [];
|
|
1579
|
+
for (const message of Transcript.eventsToMessages(events)) {
|
|
1580
|
+
const blocks = Array.isArray(message.content) ? message.content : [];
|
|
1581
|
+
const uses = blocks.filter((block) => block.type === "tool_use");
|
|
1582
|
+
const results = blocks.filter((block) => block.type === "tool_result");
|
|
1583
|
+
if (projectedPending.length > 0) {
|
|
1584
|
+
if (message.role !== "user" || results.length !== blocks.length || results.length === 0) {
|
|
1585
|
+
throw new SessionError(`Fork provider history inserts a message before pending tool results`);
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
else if (results.length > 0) {
|
|
1589
|
+
throw new SessionError(`Fork provider history contains an orphaned tool result`);
|
|
1590
|
+
}
|
|
1591
|
+
if (uses.length > 0) {
|
|
1592
|
+
if (message.role !== "assistant" || results.length > 0) {
|
|
1593
|
+
throw new SessionError(`Fork provider history contains tool use blocks in an invalid role`);
|
|
1594
|
+
}
|
|
1595
|
+
for (const block of uses) {
|
|
1596
|
+
const id = block.id;
|
|
1597
|
+
if (typeof id !== "string" || !id) {
|
|
1598
|
+
throw new SessionError(`Fork provider history contains a tool use without id`);
|
|
1599
|
+
}
|
|
1600
|
+
if (seenProjectedUses.has(id)) {
|
|
1601
|
+
throw new SessionError(`Fork provider history contains duplicate tool use ${id}`);
|
|
1602
|
+
}
|
|
1603
|
+
seenProjectedUses.add(id);
|
|
1604
|
+
projectedUses.push(id);
|
|
1605
|
+
projectedPending.push(id);
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
for (const block of results) {
|
|
1609
|
+
const id = block.tool_use_id;
|
|
1610
|
+
if (typeof id !== "string" || !id) {
|
|
1611
|
+
throw new SessionError(`Fork provider history contains a tool result without id`);
|
|
1612
|
+
}
|
|
1613
|
+
if (seenProjectedResults.has(id)) {
|
|
1614
|
+
throw new SessionError(`Fork provider history contains duplicate tool result ${id}`);
|
|
1615
|
+
}
|
|
1616
|
+
const expected = projectedPending.shift();
|
|
1617
|
+
if (!expected) {
|
|
1618
|
+
throw new SessionError(`Fork provider history contains an orphaned tool result (${id})`);
|
|
1619
|
+
}
|
|
1620
|
+
if (expected !== id) {
|
|
1621
|
+
throw new SessionError(`Fork provider history tool result order mismatch: expected ${expected}, received ${id}`);
|
|
1622
|
+
}
|
|
1623
|
+
seenProjectedResults.add(id);
|
|
1624
|
+
projectedResults.push(id);
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
if (projectedPending.length > 0) {
|
|
1628
|
+
throw new SessionError(`Fork cursor splits an unfinished tool round in provider history (${projectedPending[0]})`);
|
|
1629
|
+
}
|
|
1630
|
+
const metadataUses = [];
|
|
1631
|
+
const metadataResults = [];
|
|
1632
|
+
const seenMetadataUses = new Set();
|
|
1633
|
+
const seenMetadataResults = new Set();
|
|
1634
|
+
const metadataPending = [];
|
|
1635
|
+
for (const event of events) {
|
|
1636
|
+
if (event.type !== "tool_use" && event.type !== "tool_result")
|
|
1637
|
+
continue;
|
|
1638
|
+
const id = event.data.toolCallId;
|
|
1639
|
+
if (typeof id !== "string" || !id) {
|
|
1640
|
+
throw new SessionError(`Fork snapshot contains a tool event without toolCallId`);
|
|
1641
|
+
}
|
|
1642
|
+
if (event.type === "tool_use") {
|
|
1643
|
+
if (seenMetadataUses.has(id)) {
|
|
1644
|
+
throw new SessionError(`Fork snapshot contains duplicate tool use metadata ${id}`);
|
|
1645
|
+
}
|
|
1646
|
+
seenMetadataUses.add(id);
|
|
1647
|
+
metadataUses.push(id);
|
|
1648
|
+
metadataPending.push(id);
|
|
1649
|
+
continue;
|
|
1650
|
+
}
|
|
1651
|
+
if (seenMetadataResults.has(id)) {
|
|
1652
|
+
throw new SessionError(`Fork snapshot contains duplicate tool result metadata ${id}`);
|
|
1653
|
+
}
|
|
1654
|
+
const expected = metadataPending.shift();
|
|
1655
|
+
if (!expected) {
|
|
1656
|
+
throw new SessionError(`Fork snapshot contains tool result metadata before use (${id})`);
|
|
1657
|
+
}
|
|
1658
|
+
if (expected !== id) {
|
|
1659
|
+
throw new SessionError(`Fork snapshot tool metadata order mismatch: expected ${expected}, received ${id}`);
|
|
1660
|
+
}
|
|
1661
|
+
seenMetadataResults.add(id);
|
|
1662
|
+
metadataResults.push(id);
|
|
1663
|
+
}
|
|
1664
|
+
if (metadataPending.length > 0) {
|
|
1665
|
+
throw new SessionError(`Fork cursor splits unfinished tool metadata (${metadataPending[0]})`);
|
|
1666
|
+
}
|
|
1667
|
+
const sameIds = (left, right) => left.length === right.length && left.every((id, index) => id === right[index]);
|
|
1668
|
+
if (!sameIds(projectedUses, metadataUses) || !sameIds(projectedResults, metadataResults)) {
|
|
1669
|
+
throw new SessionError(`Fork tool metadata does not match provider history`);
|
|
1670
|
+
}
|
|
555
1671
|
}
|
|
556
1672
|
/**
|
|
557
1673
|
* Scan a transcript.jsonl for the LAST user message and return a short
|