@gitkraken/core-gitlens 0.5.110 → 0.5.112
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -1
- package/dist/git/cache.d.ts +111 -6
- package/dist/git/cache.d.ts.map +1 -1
- package/dist/git/cache.js +312 -43
- package/dist/git/cache.js.map +1 -1
- package/dist/git/context.d.ts +11 -0
- package/dist/git/context.d.ts.map +1 -1
- package/dist/git/context.js.map +1 -1
- package/dist/git/models/fileStatus.d.ts +1 -0
- package/dist/git/models/fileStatus.d.ts.map +1 -1
- package/dist/git/models/fileStatus.js +1 -0
- package/dist/git/models/fileStatus.js.map +1 -1
- package/dist/git/models/issue.d.ts +4 -0
- package/dist/git/models/issue.d.ts.map +1 -1
- package/dist/git/models/mergeConflicts.d.ts +2 -0
- package/dist/git/models/mergeConflicts.d.ts.map +1 -1
- package/dist/git/models/pullRequest.d.ts +38 -2
- package/dist/git/models/pullRequest.d.ts.map +1 -1
- package/dist/git/models/pullRequest.js +12 -1
- package/dist/git/models/pullRequest.js.map +1 -1
- package/dist/git/models/repository.d.ts +32 -5
- package/dist/git/models/repository.d.ts.map +1 -1
- package/dist/git/models/repository.js +58 -12
- package/dist/git/models/repository.js.map +1 -1
- package/dist/git/models/statusFile.d.ts.map +1 -1
- package/dist/git/models/statusFile.js +3 -0
- package/dist/git/models/statusFile.js.map +1 -1
- package/dist/git/providers/branches.d.ts +2 -0
- package/dist/git/providers/branches.d.ts.map +1 -1
- package/dist/git/providers/commits.d.ts +8 -0
- package/dist/git/providers/commits.d.ts.map +1 -1
- package/dist/git/providers/config.d.ts +19 -1
- package/dist/git/providers/config.d.ts.map +1 -1
- package/dist/git/providers/operations.d.ts +1 -0
- package/dist/git/providers/operations.d.ts.map +1 -1
- package/dist/git/providers/pausedOperations.d.ts +2 -0
- package/dist/git/providers/pausedOperations.d.ts.map +1 -1
- package/dist/git/providers/status.d.ts +1 -0
- package/dist/git/providers/status.d.ts.map +1 -1
- package/dist/git/utils/config.utils.d.ts +3 -0
- package/dist/git/utils/config.utils.d.ts.map +1 -0
- package/dist/git/utils/config.utils.js +8 -0
- package/dist/git/utils/config.utils.js.map +1 -0
- package/dist/git/utils/issue.utils.d.ts.map +1 -1
- package/dist/git/utils/issue.utils.js +2 -0
- package/dist/git/utils/issue.utils.js.map +1 -1
- package/dist/git/utils/pausedOperationStatus.utils.d.ts +14 -0
- package/dist/git/utils/pausedOperationStatus.utils.d.ts.map +1 -1
- package/dist/git/utils/pausedOperationStatus.utils.js +23 -0
- package/dist/git/utils/pausedOperationStatus.utils.js.map +1 -1
- package/dist/git/utils/pullRequest.utils.d.ts +14 -1
- package/dist/git/utils/pullRequest.utils.d.ts.map +1 -1
- package/dist/git/utils/pullRequest.utils.js +31 -0
- package/dist/git/utils/pullRequest.utils.js.map +1 -1
- package/dist/git/watching/changeEvent.d.ts +6 -1
- package/dist/git/watching/changeEvent.d.ts.map +1 -1
- package/dist/git/watching/classifyChange.d.ts.map +1 -1
- package/dist/git/watching/classifyChange.js +14 -1
- package/dist/git/watching/classifyChange.js.map +1 -1
- package/dist/git/watching/watchService.d.ts.map +1 -1
- package/dist/git/watching/watchService.js +18 -3
- package/dist/git/watching/watchService.js.map +1 -1
- package/dist/git/watching/watchSession.d.ts +8 -1
- package/dist/git/watching/watchSession.d.ts.map +1 -1
- package/dist/git/watching/watchSession.js +38 -6
- package/dist/git/watching/watchSession.js.map +1 -1
- package/dist/git/watching/watcherPatterns.d.ts +2 -2
- package/dist/git/watching/watcherPatterns.d.ts.map +1 -1
- package/dist/git/watching/watcherPatterns.js +1 -1
- package/dist/git/watching/watcherPatterns.js.map +1 -1
- package/dist/git-cli/exec/git.d.ts +1 -1
- package/dist/git-cli/exec/git.d.ts.map +1 -1
- package/dist/git-cli/exec/git.js +13 -3
- package/dist/git-cli/exec/git.js.map +1 -1
- package/dist/git-cli/providers/branches.d.ts +19 -0
- package/dist/git-cli/providers/branches.d.ts.map +1 -1
- package/dist/git-cli/providers/branches.js +282 -78
- package/dist/git-cli/providers/branches.js.map +1 -1
- package/dist/git-cli/providers/commits.d.ts +4 -0
- package/dist/git-cli/providers/commits.d.ts.map +1 -1
- package/dist/git-cli/providers/commits.js +84 -11
- package/dist/git-cli/providers/commits.js.map +1 -1
- package/dist/git-cli/providers/config.d.ts +42 -0
- package/dist/git-cli/providers/config.d.ts.map +1 -1
- package/dist/git-cli/providers/config.js +186 -5
- package/dist/git-cli/providers/config.js.map +1 -1
- package/dist/git-cli/providers/contributors.d.ts.map +1 -1
- package/dist/git-cli/providers/contributors.js +1 -1
- package/dist/git-cli/providers/contributors.js.map +1 -1
- package/dist/git-cli/providers/graph.d.ts +2 -2
- package/dist/git-cli/providers/graph.d.ts.map +1 -1
- package/dist/git-cli/providers/graph.js +27 -9
- package/dist/git-cli/providers/graph.js.map +1 -1
- package/dist/git-cli/providers/operations.d.ts.map +1 -1
- package/dist/git-cli/providers/operations.js +40 -12
- package/dist/git-cli/providers/operations.js.map +1 -1
- package/dist/git-cli/providers/pausedOperations.d.ts +5 -0
- package/dist/git-cli/providers/pausedOperations.d.ts.map +1 -1
- package/dist/git-cli/providers/pausedOperations.js +87 -16
- package/dist/git-cli/providers/pausedOperations.js.map +1 -1
- package/dist/git-cli/providers/refs.d.ts +5 -3
- package/dist/git-cli/providers/refs.d.ts.map +1 -1
- package/dist/git-cli/providers/refs.js +33 -4
- package/dist/git-cli/providers/refs.js.map +1 -1
- package/dist/git-cli/providers/status.d.ts +1 -0
- package/dist/git-cli/providers/status.d.ts.map +1 -1
- package/dist/git-cli/providers/status.js +18 -3
- package/dist/git-cli/providers/status.js.map +1 -1
- package/dist/git-cli/providers/worktrees.d.ts.map +1 -1
- package/dist/git-cli/providers/worktrees.js +4 -0
- package/dist/git-cli/providers/worktrees.js.map +1 -1
- package/dist/git-cli/service.d.ts.map +1 -1
- package/dist/git-cli/service.js.map +1 -1
- package/dist/plus/agents/providers/claudeCodeProvider.d.ts +39 -1
- package/dist/plus/agents/providers/claudeCodeProvider.d.ts.map +1 -1
- package/dist/plus/agents/providers/claudeCodeProvider.js +978 -71
- package/dist/plus/agents/providers/claudeCodeProvider.js.map +1 -1
- package/dist/plus/agents/providers/claudeCodeTranscript.d.ts +24 -2
- package/dist/plus/agents/providers/claudeCodeTranscript.d.ts.map +1 -1
- package/dist/plus/agents/providers/claudeCodeTranscript.js +84 -9
- package/dist/plus/agents/providers/claudeCodeTranscript.js.map +1 -1
- package/dist/plus/agents/types.d.ts +37 -2
- package/dist/plus/agents/types.d.ts.map +1 -1
- package/dist/plus/agents/types.js +2 -0
- package/dist/plus/agents/types.js.map +1 -1
- package/dist/plus/ai/errors.d.ts +10 -0
- package/dist/plus/ai/errors.d.ts.map +1 -1
- package/dist/plus/ai/errors.js +29 -0
- package/dist/plus/ai/errors.js.map +1 -1
- package/dist/plus/ai/models/model.d.ts +4 -0
- package/dist/plus/ai/models/model.d.ts.map +1 -1
- package/dist/plus/ai/models/model.js +51 -6
- package/dist/plus/ai/models/model.js.map +1 -1
- package/dist/plus/ai/models/promptTemplates.d.ts +1 -1
- package/dist/plus/ai/models/promptTemplates.d.ts.map +1 -1
- package/dist/plus/ai/models/provider.d.ts +60 -2
- package/dist/plus/ai/models/provider.d.ts.map +1 -1
- package/dist/plus/ai/prompts.d.ts +12 -0
- package/dist/plus/ai/prompts.d.ts.map +1 -1
- package/dist/plus/ai/prompts.js +177 -61
- package/dist/plus/ai/prompts.js.map +1 -1
- package/dist/plus/ai/providers/anthropicProvider.d.ts +10 -2
- package/dist/plus/ai/providers/anthropicProvider.d.ts.map +1 -1
- package/dist/plus/ai/providers/anthropicProvider.js +139 -46
- package/dist/plus/ai/providers/anthropicProvider.js.map +1 -1
- package/dist/plus/ai/providers/deepSeekProvider.d.ts +2 -0
- package/dist/plus/ai/providers/deepSeekProvider.d.ts.map +1 -1
- package/dist/plus/ai/providers/deepSeekProvider.js +10 -21
- package/dist/plus/ai/providers/deepSeekProvider.js.map +1 -1
- package/dist/plus/ai/providers/geminiProvider.d.ts +1 -0
- package/dist/plus/ai/providers/geminiProvider.d.ts.map +1 -1
- package/dist/plus/ai/providers/geminiProvider.js +19 -0
- package/dist/plus/ai/providers/geminiProvider.js.map +1 -1
- package/dist/plus/ai/providers/gitkrakenProvider.d.ts +5 -1
- package/dist/plus/ai/providers/gitkrakenProvider.d.ts.map +1 -1
- package/dist/plus/ai/providers/gitkrakenProvider.js +118 -3
- package/dist/plus/ai/providers/gitkrakenProvider.js.map +1 -1
- package/dist/plus/ai/providers/huggingFaceProvider.d.ts +1 -0
- package/dist/plus/ai/providers/huggingFaceProvider.d.ts.map +1 -1
- package/dist/plus/ai/providers/huggingFaceProvider.js +5 -0
- package/dist/plus/ai/providers/huggingFaceProvider.js.map +1 -1
- package/dist/plus/ai/providers/mistralProvider.d.ts +3 -1
- package/dist/plus/ai/providers/mistralProvider.d.ts.map +1 -1
- package/dist/plus/ai/providers/mistralProvider.js +99 -19
- package/dist/plus/ai/providers/mistralProvider.js.map +1 -1
- package/dist/plus/ai/providers/ollamaProvider.d.ts +4 -3
- package/dist/plus/ai/providers/ollamaProvider.d.ts.map +1 -1
- package/dist/plus/ai/providers/ollamaProvider.js +38 -3
- package/dist/plus/ai/providers/ollamaProvider.js.map +1 -1
- package/dist/plus/ai/providers/openAICompatibleProvider.d.ts +1 -0
- package/dist/plus/ai/providers/openAICompatibleProvider.d.ts.map +1 -1
- package/dist/plus/ai/providers/openAICompatibleProvider.js +4 -0
- package/dist/plus/ai/providers/openAICompatibleProvider.js.map +1 -1
- package/dist/plus/ai/providers/openAICompatibleProviderBase.d.ts +154 -5
- package/dist/plus/ai/providers/openAICompatibleProviderBase.d.ts.map +1 -1
- package/dist/plus/ai/providers/openAICompatibleProviderBase.js +259 -10
- package/dist/plus/ai/providers/openAICompatibleProviderBase.js.map +1 -1
- package/dist/plus/ai/providers/openRouterProvider.d.ts +4 -0
- package/dist/plus/ai/providers/openRouterProvider.d.ts.map +1 -1
- package/dist/plus/ai/providers/openRouterProvider.js +9 -0
- package/dist/plus/ai/providers/openRouterProvider.js.map +1 -1
- package/dist/plus/ai/providers/openaiProvider.d.ts +1 -0
- package/dist/plus/ai/providers/openaiProvider.d.ts.map +1 -1
- package/dist/plus/ai/providers/openaiProvider.js +1 -0
- package/dist/plus/ai/providers/openaiProvider.js.map +1 -1
- package/dist/plus/ai/providers/responseFormatCache.d.ts +8 -0
- package/dist/plus/ai/providers/responseFormatCache.d.ts.map +1 -0
- package/dist/plus/ai/providers/responseFormatCache.js +25 -0
- package/dist/plus/ai/providers/responseFormatCache.js.map +1 -0
- package/dist/plus/ai/providers/xaiProvider.d.ts +1 -0
- package/dist/plus/ai/providers/xaiProvider.d.ts.map +1 -1
- package/dist/plus/ai/providers/xaiProvider.js +10 -1
- package/dist/plus/ai/providers/xaiProvider.js.map +1 -1
- package/dist/plus/ai/utils/ai.utils.d.ts +3 -0
- package/dist/plus/ai/utils/ai.utils.d.ts.map +1 -1
- package/dist/plus/ai/utils/ai.utils.js +13 -0
- package/dist/plus/ai/utils/ai.utils.js.map +1 -1
- package/dist/plus/ai/utils/results.utils.d.ts +16 -2
- package/dist/plus/ai/utils/results.utils.d.ts.map +1 -1
- package/dist/plus/ai/utils/results.utils.js +204 -28
- package/dist/plus/ai/utils/results.utils.js.map +1 -1
- package/dist/plus/git-github/api/github.d.ts +55 -0
- package/dist/plus/git-github/api/github.d.ts.map +1 -1
- package/dist/plus/git-github/api/github.js +177 -4
- package/dist/plus/git-github/api/github.js.map +1 -1
- package/dist/plus/git-github/models.d.ts +16 -0
- package/dist/plus/git-github/models.d.ts.map +1 -1
- package/dist/plus/git-github/models.js +29 -3
- package/dist/plus/git-github/models.js.map +1 -1
- package/dist/plus/git-github/providers/github/status.d.ts +1 -0
- package/dist/plus/git-github/providers/github/status.d.ts.map +1 -1
- package/dist/plus/git-github/providers/github/status.js.map +1 -1
- package/dist/plus/integrations/context.d.ts +4 -0
- package/dist/plus/integrations/context.d.ts.map +1 -1
- package/dist/plus/integrations/index.d.ts.map +1 -1
- package/dist/plus/integrations/index.js +2 -0
- package/dist/plus/integrations/index.js.map +1 -1
- package/dist/plus/integrations/models/gitHostIntegration.d.ts +6 -3
- package/dist/plus/integrations/models/gitHostIntegration.d.ts.map +1 -1
- package/dist/plus/integrations/models/gitHostIntegration.js +12 -2
- package/dist/plus/integrations/models/gitHostIntegration.js.map +1 -1
- package/dist/plus/integrations/models/integration.d.ts +3 -1
- package/dist/plus/integrations/models/integration.d.ts.map +1 -1
- package/dist/plus/integrations/models/integration.js +2 -2
- package/dist/plus/integrations/models/integration.js.map +1 -1
- package/dist/plus/integrations/providers/accounts.d.ts.map +1 -1
- package/dist/plus/integrations/providers/accounts.js +4 -0
- package/dist/plus/integrations/providers/accounts.js.map +1 -1
- package/dist/plus/integrations/providers/azure/models.d.ts.map +1 -1
- package/dist/plus/integrations/providers/azure/models.js +3 -0
- package/dist/plus/integrations/providers/azure/models.js.map +1 -1
- package/dist/plus/integrations/providers/bitbucket/models.d.ts.map +1 -1
- package/dist/plus/integrations/providers/bitbucket/models.js +1 -0
- package/dist/plus/integrations/providers/bitbucket/models.js.map +1 -1
- package/dist/plus/integrations/providers/github.d.ts +14 -2
- package/dist/plus/integrations/providers/github.d.ts.map +1 -1
- package/dist/plus/integrations/providers/github.js +55 -3
- package/dist/plus/integrations/providers/github.js.map +1 -1
- package/dist/plus/integrations/providers/gitlab.d.ts.map +1 -1
- package/dist/plus/integrations/providers/gitlab.js.map +1 -1
- package/dist/plus/integrations/providers/models.d.ts.map +1 -1
- package/dist/plus/integrations/providers/models.js +30 -4
- package/dist/plus/integrations/providers/models.js.map +1 -1
- package/dist/plus/integrations/reads/broaden.d.ts.map +1 -1
- package/dist/plus/integrations/reads/broaden.js +6 -1
- package/dist/plus/integrations/reads/broaden.js.map +1 -1
- package/dist/plus/integrations/reads/drains.d.ts.map +1 -1
- package/dist/plus/integrations/reads/drains.js +7 -4
- package/dist/plus/integrations/reads/drains.js.map +1 -1
- package/dist/plus/integrations/reads/hierarchy.d.ts.map +1 -1
- package/dist/plus/integrations/reads/hierarchy.js +3 -1
- package/dist/plus/integrations/reads/hierarchy.js.map +1 -1
- package/dist/plus/integrations/reads/issueTracker.d.ts.map +1 -1
- package/dist/plus/integrations/reads/issueTracker.js +8 -3
- package/dist/plus/integrations/reads/issueTracker.js.map +1 -1
- package/dist/plus/integrations/reads/issues.d.ts.map +1 -1
- package/dist/plus/integrations/reads/issues.js +9 -4
- package/dist/plus/integrations/reads/issues.js.map +1 -1
- package/dist/plus/integrations/reads/pullRequests.d.ts.map +1 -1
- package/dist/plus/integrations/reads/pullRequests.js +9 -4
- package/dist/plus/integrations/reads/pullRequests.js.map +1 -1
- package/dist/utils/env/browser/base64.d.ts +1 -1
- package/dist/utils/env/browser/base64.d.ts.map +1 -1
- package/dist/utils/env/node/base64.d.ts +1 -1
- package/dist/utils/env/node/base64.d.ts.map +1 -1
- package/dist/utils/env/node/base64.js +2 -0
- package/dist/utils/env/node/base64.js.map +1 -1
- package/dist/utils/keys/chord.d.ts +52 -0
- package/dist/utils/keys/chord.d.ts.map +1 -0
- package/dist/utils/keys/chord.js +135 -0
- package/dist/utils/keys/chord.js.map +1 -0
- package/dist/utils/keys/keybinding.d.ts +53 -0
- package/dist/utils/keys/keybinding.d.ts.map +1 -0
- package/dist/utils/keys/keybinding.js +58 -0
- package/dist/utils/keys/keybinding.js.map +1 -0
- package/dist/utils/path.d.ts +11 -0
- package/dist/utils/path.d.ts.map +1 -1
- package/dist/utils/path.js +13 -0
- package/dist/utils/path.js.map +1 -1
- package/package.json +13 -5
- package/src/git/cache.ts +383 -47
- package/src/git/context.ts +11 -0
- package/src/git/models/fileStatus.ts +1 -0
- package/src/git/models/issue.ts +4 -0
- package/src/git/models/mergeConflicts.ts +3 -2
- package/src/git/models/pullRequest.ts +33 -1
- package/src/git/models/repository.ts +58 -17
- package/src/git/models/statusFile.ts +3 -0
- package/src/git/providers/branches.ts +6 -0
- package/src/git/providers/commits.ts +11 -0
- package/src/git/providers/config.ts +18 -0
- package/src/git/providers/operations.ts +1 -0
- package/src/git/providers/pausedOperations.ts +2 -0
- package/src/git/providers/status.ts +1 -0
- package/src/git/utils/config.utils.ts +8 -0
- package/src/git/utils/issue.utils.ts +2 -0
- package/src/git/utils/pausedOperationStatus.utils.ts +28 -0
- package/src/git/utils/pullRequest.utils.ts +36 -0
- package/src/git/watching/changeEvent.ts +6 -1
- package/src/git/watching/classifyChange.ts +15 -1
- package/src/git/watching/watchService.ts +20 -3
- package/src/git/watching/watchSession.ts +41 -6
- package/src/git/watching/watcherPatterns.ts +1 -1
- package/src/git-cli/exec/git.ts +13 -3
- package/src/git-cli/providers/branches.ts +384 -132
- package/src/git-cli/providers/commits.ts +106 -15
- package/src/git-cli/providers/config.ts +229 -8
- package/src/git-cli/providers/contributors.ts +5 -1
- package/src/git-cli/providers/graph.ts +26 -13
- package/src/git-cli/providers/operations.ts +45 -20
- package/src/git-cli/providers/pausedOperations.ts +109 -17
- package/src/git-cli/providers/refs.ts +33 -3
- package/src/git-cli/providers/status.ts +25 -4
- package/src/git-cli/providers/worktrees.ts +5 -0
- package/src/git-cli/service.ts +0 -1
- package/src/plus/agents/providers/claudeCodeProvider.ts +1130 -87
- package/src/plus/agents/providers/claudeCodeTranscript.ts +95 -9
- package/src/plus/agents/types.ts +46 -2
- package/src/plus/ai/errors.ts +30 -0
- package/src/plus/ai/models/model.ts +56 -6
- package/src/plus/ai/models/promptTemplates.ts +1 -1
- package/src/plus/ai/models/provider.ts +69 -2
- package/src/plus/ai/prompts.ts +186 -61
- package/src/plus/ai/providers/anthropicProvider.ts +155 -47
- package/src/plus/ai/providers/deepSeekProvider.ts +11 -21
- package/src/plus/ai/providers/geminiProvider.ts +19 -0
- package/src/plus/ai/providers/gitkrakenProvider.ts +126 -3
- package/src/plus/ai/providers/huggingFaceProvider.ts +6 -0
- package/src/plus/ai/providers/mistralProvider.ts +102 -19
- package/src/plus/ai/providers/ollamaProvider.ts +55 -4
- package/src/plus/ai/providers/openAICompatibleProvider.ts +5 -0
- package/src/plus/ai/providers/openAICompatibleProviderBase.ts +360 -17
- package/src/plus/ai/providers/openRouterProvider.ts +17 -0
- package/src/plus/ai/providers/openaiProvider.ts +1 -0
- package/src/plus/ai/providers/responseFormatCache.ts +39 -0
- package/src/plus/ai/providers/xaiProvider.ts +10 -1
- package/src/plus/ai/utils/ai.utils.ts +14 -0
- package/src/plus/ai/utils/results.utils.ts +255 -31
- package/src/plus/git-github/api/github.ts +291 -7
- package/src/plus/git-github/models.ts +48 -2
- package/src/plus/git-github/providers/github/status.ts +1 -0
- package/src/plus/integrations/context.ts +4 -0
- package/src/plus/integrations/index.ts +2 -0
- package/src/plus/integrations/models/gitHostIntegration.ts +25 -2
- package/src/plus/integrations/models/integration.ts +23 -13
- package/src/plus/integrations/providers/accounts.ts +4 -0
- package/src/plus/integrations/providers/azure/models.ts +3 -0
- package/src/plus/integrations/providers/bitbucket/models.ts +1 -0
- package/src/plus/integrations/providers/github.ts +85 -1
- package/src/plus/integrations/providers/gitlab.ts +2 -10
- package/src/plus/integrations/providers/models.ts +36 -4
- package/src/plus/integrations/reads/broaden.ts +6 -1
- package/src/plus/integrations/reads/drains.ts +7 -4
- package/src/plus/integrations/reads/hierarchy.ts +3 -1
- package/src/plus/integrations/reads/issueTracker.ts +9 -3
- package/src/plus/integrations/reads/issues.ts +9 -4
- package/src/plus/integrations/reads/pullRequests.ts +9 -4
- package/src/utils/env/browser/base64.ts +1 -1
- package/src/utils/env/node/base64.ts +3 -1
- package/src/utils/keys/chord.ts +184 -0
- package/src/utils/keys/keybinding.ts +116 -0
- package/src/utils/path.ts +14 -0
|
@@ -16,6 +16,26 @@ import { classifyPermissionKind, deriveStatusFromEvent, describeToolInput, extra
|
|
|
16
16
|
import { getPhaseForStatus } from '../types.js';
|
|
17
17
|
import { ClaudeCodeTranscriptReader } from './claudeCodeTranscript.js';
|
|
18
18
|
const staleCheckIntervalMs = 15 * 60 * 1000; // 15 minutes
|
|
19
|
+
/** Minimum spacing between gated reconciliation polls while NOTHING is running. The quarter-hourly
|
|
20
|
+
* tick exists to reap agents that died without a `SessionEnd`; with no live session there's nothing
|
|
21
|
+
* to reap, leaving only terminal-history reconciliation and the CLI's retention sweep — both fine
|
|
22
|
+
* hourly. Window startup polls ungated, so this never delays a fresh window. */
|
|
23
|
+
const idleReconcileIntervalMs = 60 * 60 * 1000; // 1 hour
|
|
24
|
+
/** How long a freshly-completed row is protected from absence-based removal. The CLI persists the
|
|
25
|
+
* ended record (atomic temp+rename) BEFORE broadcasting the hook event, so a poll that starts after
|
|
26
|
+
* we mark a row completed will list it — this guards only against that ordering changing on the CLI
|
|
27
|
+
* side, where the cost of being wrong is a completed row silently vanishing. */
|
|
28
|
+
const completedRemovalGraceMs = 30 * 1000; // 30 seconds
|
|
29
|
+
/** How long the machine-global archived-session id list (fetched via a separate `--status archived`
|
|
30
|
+
* CLI query) is cached. `getPastSessions` asks for it once per worktree on panel open, so without
|
|
31
|
+
* this a multi-worktree graph spawns N identical CLI processes. `archiveSession` invalidates the
|
|
32
|
+
* cache, so a just-archived row never lingers under "Past". */
|
|
33
|
+
const archivedSessionIdsCacheTtlMs = 10 * 1000; // 10 seconds
|
|
34
|
+
/** Completed sessions defer git + transcript resolution (a 30-day cold-start would otherwise fan out
|
|
35
|
+
* hundreds of probes). Exception: sessions ended within this window still surface on WIP rows /
|
|
36
|
+
* branch cards, which match strictly by resolved `worktreePath` — so their git info is resolved
|
|
37
|
+
* eagerly at poll time (a handful), keeping the older tail lazy. Mirrors the 24h WIP-row window. */
|
|
38
|
+
const recentCompletedGitResolveThresholdMs = 24 * 60 * 60 * 1000; // 24 hours
|
|
19
39
|
/** Default cooldown between PostToolUse and dropping the file from `fileActivity`. Held long
|
|
20
40
|
* enough that the treemap activity overlay can render a decay tail well past the moment the tool
|
|
21
41
|
* call completed. The host may override per-call via `AgentProviderCallbacks.getActivityDecayMs`
|
|
@@ -30,6 +50,87 @@ const stopToIdleDebounceMs = 750;
|
|
|
30
50
|
* than `stopToIdleDebounceMs` so a continuation that just barely crosses the debounce still
|
|
31
51
|
* restores continuity. */
|
|
32
52
|
const phaseSinceRestoreWindowMs = 2000;
|
|
53
|
+
/** The session's current worktree root from the CLI's durable record — the last cwd-visit's resolved
|
|
54
|
+
* worktree, falling back to the last distinct worktree seen. `undefined` on older CLIs that don't
|
|
55
|
+
* record it, so the caller resolves git the slow way instead. */
|
|
56
|
+
function worktreeRootFromData(data) {
|
|
57
|
+
const fromTimeline = data.cwdTimeline?.at(-1)?.worktree;
|
|
58
|
+
if (fromTimeline)
|
|
59
|
+
return fromTimeline;
|
|
60
|
+
return data.worktrees?.at(-1) || undefined;
|
|
61
|
+
}
|
|
62
|
+
/** The CLI resolves the worktree for `event.cwd` at hook time and includes it on every event —
|
|
63
|
+
* using its answer seats `worktreePath` synchronously, closing the in-flight-probe window where
|
|
64
|
+
* a session that just moved would otherwise still show its previous worktree until `resolveGitInfo`
|
|
65
|
+
* catches up. Scans from the end for the timeline entry matching the event's current cwd. */
|
|
66
|
+
function worktreeFromEvent(event) {
|
|
67
|
+
const timeline = event.cwdTimeline;
|
|
68
|
+
if (timeline == null)
|
|
69
|
+
return undefined;
|
|
70
|
+
for (let i = timeline.length - 1; i >= 0; i--) {
|
|
71
|
+
const entry = timeline[i];
|
|
72
|
+
if (entry.cwd === event.cwd)
|
|
73
|
+
return entry.worktree || undefined;
|
|
74
|
+
}
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
/** Synthesizes an unresolvable ask for a `permission_requested` row that has no routable
|
|
78
|
+
* `PendingPermission` — a non-blocking `Notification`/`PermissionRequest` tail, or a
|
|
79
|
+
* poll-discovered live/revived row. None of these hold a blocking hook entry to route an
|
|
80
|
+
* Allow/Deny through, so the card must show what the agent is waiting on without offering
|
|
81
|
+
* actions the host can't fulfill. */
|
|
82
|
+
function synthesizeUnresolvableAsk(toolName, planFilePath) {
|
|
83
|
+
if (toolName != null) {
|
|
84
|
+
const kind = classifyPermissionKind(toolName);
|
|
85
|
+
return {
|
|
86
|
+
kind: kind,
|
|
87
|
+
toolName: toolName,
|
|
88
|
+
toolDescription: toolName,
|
|
89
|
+
// A plan ask with no body at least links the plan the agent wrote, so the card can offer
|
|
90
|
+
// View Plan instead of a bare tool name.
|
|
91
|
+
planFilePath: kind === 'plan' ? planFilePath : undefined,
|
|
92
|
+
resolvable: false,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
kind: 'elicitation',
|
|
97
|
+
toolName: 'Input Required',
|
|
98
|
+
toolDescription: 'Waiting for input',
|
|
99
|
+
resolvable: false,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/** JSON.stringify with object keys recursively sorted — array order is preserved. `tool_input`
|
|
103
|
+
* can arrive with different key order (or via the top-level fallback field) between deliveries;
|
|
104
|
+
* canonicalizing makes input equality reliable across those variations. */
|
|
105
|
+
function stableStringify(value) {
|
|
106
|
+
return JSON.stringify(value, (_key, val) => {
|
|
107
|
+
if (val == null || typeof val !== 'object' || Array.isArray(val))
|
|
108
|
+
return val;
|
|
109
|
+
const sorted = {};
|
|
110
|
+
for (const key of Object.keys(val).sort()) {
|
|
111
|
+
sorted[key] = val[key];
|
|
112
|
+
}
|
|
113
|
+
return sorted;
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
/** Field-wise equality for the ask a `permission_requested` row publishes. `updateSessionStatus`
|
|
117
|
+
* compares it before short-circuiting so a second ask arriving while the row is already
|
|
118
|
+
* `permission_requested` (a non-blocking tail for a different tool) still reaches subscribers. */
|
|
119
|
+
function isSameAsk(a, b) {
|
|
120
|
+
if (a === b)
|
|
121
|
+
return true;
|
|
122
|
+
if (a == null || b == null)
|
|
123
|
+
return false;
|
|
124
|
+
return (a.kind === b.kind &&
|
|
125
|
+
a.toolName === b.toolName &&
|
|
126
|
+
a.toolDescription === b.toolDescription &&
|
|
127
|
+
a.toolInputDescription === b.toolInputDescription &&
|
|
128
|
+
a.resolvable === b.resolvable &&
|
|
129
|
+
a.planFilePath === b.planFilePath &&
|
|
130
|
+
a.planSummary === b.planSummary &&
|
|
131
|
+
a.questionText === b.questionText &&
|
|
132
|
+
a.questionCount === b.questionCount);
|
|
133
|
+
}
|
|
33
134
|
/** Normalize a workspace path to GitLens canonical form (forward slashes, lower-case drive letter
|
|
34
135
|
* on Windows). Comparisons throughout the home view assume this form, but `_workspacePaths` and
|
|
35
136
|
* peer-session `workspacePath` values originate from `Uri.fsPath`, which on Windows uses
|
|
@@ -69,6 +170,13 @@ function fileActivityStructurallyEqual(a, b) {
|
|
|
69
170
|
}
|
|
70
171
|
return true;
|
|
71
172
|
}
|
|
173
|
+
/** True when a CLI invocation failed because it didn't recognize a flag/command. `--status` and the
|
|
174
|
+
* active-only `list-sessions` default shipped together (gk 3.1.69); an older CLI rejects `--status`
|
|
175
|
+
* and returns all sessions unfiltered, so the flagless retry is the correct legacy equivalent. */
|
|
176
|
+
function isUnknownFlagError(ex) {
|
|
177
|
+
const message = ex instanceof Error ? ex.message : String(ex);
|
|
178
|
+
return /unknown (?:flag|shorthand flag|command)/i.test(message);
|
|
179
|
+
}
|
|
72
180
|
export class ClaudeCodeProvider {
|
|
73
181
|
callbacks;
|
|
74
182
|
id = 'claudeCode';
|
|
@@ -81,17 +189,32 @@ export class ClaudeCodeProvider {
|
|
|
81
189
|
_disposed = false;
|
|
82
190
|
_handlerDisposables = [];
|
|
83
191
|
_pendingPermissions = new Map();
|
|
84
|
-
|
|
192
|
+
/** `sessionId -> cwd currently being probed`, and the newest cwd requested while that probe runs.
|
|
193
|
+
* A probe's answer is only valid for the cwd it was started with, so a session that moves
|
|
194
|
+
* mid-flight must supersede rather than dedupe — otherwise the older lookup lands last and
|
|
195
|
+
* overwrites the newer location with the directory the session already left. */
|
|
196
|
+
_resolveGitInfoInFlight = new Map();
|
|
197
|
+
_resolveGitInfoPending = new Map();
|
|
198
|
+
/** Short-TTL cache of the machine-global archived-session id list. Holds the in-flight promise so
|
|
199
|
+
* concurrent per-worktree `getPastSessions` calls share one CLI spawn; `archiveSession` clears it. */
|
|
200
|
+
_archivedSessionIdsCache;
|
|
85
201
|
_sessionBookkeeping = new Map();
|
|
86
202
|
/** Per-session timers for the deferred `Stop → idle` commit. The handle is cleared (and the
|
|
87
203
|
* transition cancelled) by any non-idle status update arriving before the timer fires. */
|
|
88
204
|
_pendingIdleTimers = new Map();
|
|
89
205
|
_workspacePaths = [];
|
|
90
206
|
_staleCheckTimer;
|
|
207
|
+
/** When the last poll actually ran (gated or not) — paces the idle cadence above. */
|
|
208
|
+
_lastSyncAt = 0;
|
|
91
209
|
/** Whether Claude hooks are installed, pushed by the host via {@link setClaudeHooksInstalled}.
|
|
92
210
|
* Fail-open (`true`) until the first push lands so a fresh window with real hooks isn't
|
|
93
211
|
* suppressed on its first ticks. Gates the reconciliation poll in {@link syncSessions}. */
|
|
94
212
|
_claudeHooksInstalled = true;
|
|
213
|
+
/** Whether the CLI supports `list-sessions --status` (and with it the durable ended-session
|
|
214
|
+
* store). Optimistic until the poll's flagless fallback proves otherwise. When unsupported,
|
|
215
|
+
* completed rows are untenable — no poll can ever confirm or archive one — so `SessionEnd`
|
|
216
|
+
* reverts to removing the session and reconciliation drops any completed stragglers. */
|
|
217
|
+
_statusFilterSupported = true;
|
|
95
218
|
_transcriptReader = new ClaudeCodeTranscriptReader();
|
|
96
219
|
constructor(callbacks) {
|
|
97
220
|
this.callbacks = callbacks;
|
|
@@ -106,6 +229,9 @@ export class ClaudeCodeProvider {
|
|
|
106
229
|
stop() {
|
|
107
230
|
// IPC server stays up intentionally — hooks fire even when the window is unfocused
|
|
108
231
|
}
|
|
232
|
+
sync() {
|
|
233
|
+
return this.syncSessions();
|
|
234
|
+
}
|
|
109
235
|
updateWorkspacePaths(workspacePaths) {
|
|
110
236
|
this._workspacePaths = workspacePaths.map(p => normalizePath(p));
|
|
111
237
|
let changed = false;
|
|
@@ -190,7 +316,7 @@ export class ClaudeCodeProvider {
|
|
|
190
316
|
}
|
|
191
317
|
this._handlerDisposables = [];
|
|
192
318
|
for (const pending of this._pendingPermissions.values()) {
|
|
193
|
-
|
|
319
|
+
this.rejectAllPending(pending, new Error('Provider disposed'));
|
|
194
320
|
}
|
|
195
321
|
this._pendingPermissions.clear();
|
|
196
322
|
for (const bk of this._sessionBookkeeping.values()) {
|
|
@@ -223,7 +349,13 @@ export class ClaudeCodeProvider {
|
|
|
223
349
|
}
|
|
224
350
|
return Promise.resolve();
|
|
225
351
|
}),
|
|
226
|
-
this.callbacks.ipc.registerHandler('agents/sessions/list', () => Promise.resolve(
|
|
352
|
+
this.callbacks.ipc.registerHandler('agents/sessions/list', () => Promise.resolve(
|
|
353
|
+
// Exclude terminal `completed` rows: an older-version peer has no `completed` phase and
|
|
354
|
+
// would import them as phantom "idle" sessions. Our own poll surfaces completed sessions
|
|
355
|
+
// independently, so peers never need them from here.
|
|
356
|
+
this._sessions
|
|
357
|
+
.filter(s => s.status !== 'completed')
|
|
358
|
+
.map(s => ({
|
|
227
359
|
...s,
|
|
228
360
|
lastActivity: s.lastActivity.toISOString(),
|
|
229
361
|
phaseSince: s.phaseSince.toISOString(),
|
|
@@ -288,6 +420,13 @@ export class ClaudeCodeProvider {
|
|
|
288
420
|
}
|
|
289
421
|
}
|
|
290
422
|
handleSessionEvent(event, isBlocking) {
|
|
423
|
+
// The CLI broadcasts every AI host's events to every listener. Absent = accept: older CLIs
|
|
424
|
+
// don't stamp it. A dropped blocking request gets no decision, so the CLI waits out its own
|
|
425
|
+
// hook timeout — correct, since we must not answer for an agent we don't track.
|
|
426
|
+
if (event.providerId != null && event.providerId !== 'claude-code') {
|
|
427
|
+
Logger.debug(`ClaudeCodeProvider.handleSessionEvent: ignoring ${event.event} from ${event.providerId}`);
|
|
428
|
+
return Promise.resolve();
|
|
429
|
+
}
|
|
291
430
|
const workspacePath = this.resolveWorkspacePath(event.cwd);
|
|
292
431
|
const eventContext = {
|
|
293
432
|
pid: event.pid,
|
|
@@ -297,6 +436,8 @@ export class ClaudeCodeProvider {
|
|
|
297
436
|
initialCwd: event.initialCwd,
|
|
298
437
|
planFile: event.planFile,
|
|
299
438
|
sessionName: event.sessionName,
|
|
439
|
+
model: event.model,
|
|
440
|
+
worktreePath: worktreeFromEvent(event),
|
|
300
441
|
};
|
|
301
442
|
const tag = this.sessionTag(event.sessionId, event.sessionName ?? 'unnamed');
|
|
302
443
|
if (event.event === 'SessionStart' || event.event === 'SessionEnd') {
|
|
@@ -321,11 +462,31 @@ export class ClaudeCodeProvider {
|
|
|
321
462
|
}
|
|
322
463
|
}
|
|
323
464
|
else {
|
|
324
|
-
// SessionStart for a session we already track is almost always a CLI replay
|
|
325
|
-
// (resume/reconnect/hook re-init). Don't clobber live state — the next real
|
|
326
|
-
// event will re-establish status. Refresh pid only.
|
|
327
465
|
const prev = this._sessions[index];
|
|
328
|
-
if (
|
|
466
|
+
if (prev.status === 'completed') {
|
|
467
|
+
// Resuming a terminal session reuses its id, so a SessionStart on a completed
|
|
468
|
+
// row IS the resume signal — revive it to a live `idle` row (clearing the
|
|
469
|
+
// completed/archivable state) with clean bookkeeping that completeSession tore
|
|
470
|
+
// down. The next real event advances it out of idle. A `SessionStart` arrives on
|
|
471
|
+
// THIS window's hook flow, so the resume is locally owned now: clear the peer
|
|
472
|
+
// ownership carried over from the terminal record, and take the resume's pid
|
|
473
|
+
// (never the old terminal/reused one — retaining it would let dispatch focus a
|
|
474
|
+
// stale or unrelated process).
|
|
475
|
+
this._sessions[index] = {
|
|
476
|
+
...prev,
|
|
477
|
+
status: 'idle',
|
|
478
|
+
phase: 'idle',
|
|
479
|
+
phaseSince: new Date(),
|
|
480
|
+
lastActivity: new Date(),
|
|
481
|
+
pid: event.pid,
|
|
482
|
+
isPeerOwned: undefined,
|
|
483
|
+
};
|
|
484
|
+
this.resetBookkeeping(event.sessionId);
|
|
485
|
+
}
|
|
486
|
+
else if (event.pid != null && event.pid !== prev.pid) {
|
|
487
|
+
// SessionStart for a live session we already track is almost always a CLI
|
|
488
|
+
// replay (resume/reconnect/hook re-init). Don't clobber live state — the next
|
|
489
|
+
// real event re-establishes status. Refresh pid only.
|
|
329
490
|
this._sessions[index] = { ...prev, pid: event.pid, lastActivity: new Date() };
|
|
330
491
|
}
|
|
331
492
|
}
|
|
@@ -337,27 +498,53 @@ export class ClaudeCodeProvider {
|
|
|
337
498
|
case 'SessionEnd': {
|
|
338
499
|
const pending = this._pendingPermissions.get(event.sessionId);
|
|
339
500
|
if (pending != null) {
|
|
340
|
-
|
|
501
|
+
this.rejectAllPending(pending, new Error('Session ended'));
|
|
341
502
|
this._pendingPermissions.delete(event.sessionId);
|
|
342
503
|
}
|
|
343
504
|
this.cancelPendingIdleTransition(event.sessionId);
|
|
344
505
|
const index = this._sessions.findIndex(s => s.id === event.sessionId);
|
|
345
506
|
if (index >= 0) {
|
|
346
|
-
this.
|
|
507
|
+
if (this._statusFilterSupported) {
|
|
508
|
+
// Transition to a terminal `completed` row rather than removing it — completed
|
|
509
|
+
// sessions stay visible (de-emphasized) until archived or 30-day-purged by the CLI.
|
|
510
|
+
this.completeSession(index, new Date());
|
|
511
|
+
}
|
|
512
|
+
else {
|
|
513
|
+
// Legacy CLI (no `--status` durable store): a completed row could never be
|
|
514
|
+
// poll-confirmed nor archived, so keep the pre-completed remove-on-end behavior.
|
|
515
|
+
this._sessions.splice(index, 1);
|
|
516
|
+
const bk = this._sessionBookkeeping.get(event.sessionId);
|
|
517
|
+
if (bk != null) {
|
|
518
|
+
this.cancelPendingFileClears(bk);
|
|
519
|
+
this.cancelPendingReadClears(bk);
|
|
520
|
+
}
|
|
521
|
+
this._sessionBookkeeping.delete(event.sessionId);
|
|
522
|
+
this._transcriptReader.forget(event.sessionId);
|
|
523
|
+
}
|
|
347
524
|
this._onDidChangeSessions.fire();
|
|
348
525
|
}
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
this.
|
|
526
|
+
else {
|
|
527
|
+
// SessionEnd for a session we no longer track (already pruned/removed, or a duplicate
|
|
528
|
+
// end): still clear any bookkeeping timers + transcript cache the row left behind, so
|
|
529
|
+
// they don't leak. The tracked branches above already tear this down themselves.
|
|
530
|
+
const bk = this._sessionBookkeeping.get(event.sessionId);
|
|
531
|
+
if (bk != null) {
|
|
532
|
+
this.cancelPendingFileClears(bk);
|
|
533
|
+
this.cancelPendingReadClears(bk);
|
|
534
|
+
}
|
|
535
|
+
this._sessionBookkeeping.delete(event.sessionId);
|
|
536
|
+
this._transcriptReader.forget(event.sessionId);
|
|
353
537
|
}
|
|
354
|
-
this._sessionBookkeeping.delete(event.sessionId);
|
|
355
|
-
this._transcriptReader.forget(event.sessionId);
|
|
356
538
|
this.callbacks.onSessionEnded?.(this.id);
|
|
357
539
|
break;
|
|
358
540
|
}
|
|
359
541
|
case 'UserPromptSubmit': {
|
|
360
|
-
const { index } = this.ensureSession(event.sessionId, eventContext);
|
|
542
|
+
const { index, changed } = this.ensureSession(event.sessionId, eventContext);
|
|
543
|
+
// Seated metadata (location, model, name) has to reach subscribers even when the prompt
|
|
544
|
+
// turns out to be synthetic and the status update below never runs.
|
|
545
|
+
if (changed) {
|
|
546
|
+
this._onDidChangeSessions.fire();
|
|
547
|
+
}
|
|
361
548
|
const cleaned = prepareStoredPrompt(event.prompt);
|
|
362
549
|
// A `UserPromptSubmit` whose payload sanitizes to nothing is harness-synthetic
|
|
363
550
|
// (e.g. background-bash <task-notification>, slash-command stdout echo). Treat it
|
|
@@ -406,7 +593,7 @@ export class ClaudeCodeProvider {
|
|
|
406
593
|
}
|
|
407
594
|
case 'PostToolUse':
|
|
408
595
|
case 'PostToolUseFailure': {
|
|
409
|
-
this.clearStalePermission(event.sessionId, event.event);
|
|
596
|
+
this.clearStalePermission(event.sessionId, event.event, this.settledByFromEvent(event));
|
|
410
597
|
const bk = this.getBookkeeping(event.sessionId);
|
|
411
598
|
bk.activeToolCount = Math.max(0, bk.activeToolCount - 1);
|
|
412
599
|
// Cooldown the file-edit decoration instead of dropping it immediately, so a quick
|
|
@@ -440,7 +627,7 @@ export class ClaudeCodeProvider {
|
|
|
440
627
|
case 'StopFailure': {
|
|
441
628
|
const pending = this._pendingPermissions.get(event.sessionId);
|
|
442
629
|
if (pending != null) {
|
|
443
|
-
|
|
630
|
+
this.rejectAllPending(pending, new Error('Session stopped'));
|
|
444
631
|
this._pendingPermissions.delete(event.sessionId);
|
|
445
632
|
}
|
|
446
633
|
// Turn ended: stop the "live" pulse but keep the per-operation decay tail fading over
|
|
@@ -473,10 +660,28 @@ export class ClaudeCodeProvider {
|
|
|
473
660
|
// No-op — session is already idle from the preceding stop/session-start event
|
|
474
661
|
break;
|
|
475
662
|
case 'permission_prompt':
|
|
663
|
+
this.updateSessionStatus(event.sessionId, 'permission_requested', {
|
|
664
|
+
...eventContext,
|
|
665
|
+
statusDetail: event.toolName,
|
|
666
|
+
pendingPermission: synthesizeUnresolvableAsk(event.toolName,
|
|
667
|
+
// Plan-kind body: prefer the session's planFile, fall back to the event's
|
|
668
|
+
// — mirrors the blocking path.
|
|
669
|
+
this._sessions.find(s => s.id === event.sessionId)?.planFile ?? event.planFile),
|
|
670
|
+
});
|
|
671
|
+
break;
|
|
476
672
|
case 'elicitation_dialog':
|
|
673
|
+
// An MCP elicitation, not a tool permission — `toolName` names the elicitation, so
|
|
674
|
+
// classifying it as a tool ask would mislabel the card. Same shape the `Elicitation`
|
|
675
|
+
// hook produces.
|
|
477
676
|
this.updateSessionStatus(event.sessionId, 'permission_requested', {
|
|
478
677
|
...eventContext,
|
|
479
678
|
statusDetail: event.toolName,
|
|
679
|
+
pendingPermission: {
|
|
680
|
+
kind: 'elicitation',
|
|
681
|
+
toolName: event.toolName ?? 'Input Required',
|
|
682
|
+
toolDescription: event.toolName ?? 'Waiting for input',
|
|
683
|
+
resolvable: false,
|
|
684
|
+
},
|
|
480
685
|
});
|
|
481
686
|
break;
|
|
482
687
|
default:
|
|
@@ -493,11 +698,31 @@ export class ClaudeCodeProvider {
|
|
|
493
698
|
const toolDescription = describeToolInput(toolName, toolInput);
|
|
494
699
|
const toolInputDescription = toolInput.description || undefined;
|
|
495
700
|
const kind = classifyPermissionKind(toolName);
|
|
701
|
+
const toolUseId = hookInput?.tool_use_id;
|
|
702
|
+
const toolInputJson = stableStringify(toolInput);
|
|
496
703
|
return new Promise((resolve, reject) => {
|
|
497
704
|
const existing = this._pendingPermissions.get(event.sessionId);
|
|
705
|
+
// Same identity as `settledByFromEvent`'s comparisons: `toolUseId` when both sides
|
|
706
|
+
// have one, else toolName + canonical input.
|
|
707
|
+
const isDuplicateDelivery = existing != null &&
|
|
708
|
+
((existing.toolUseId != null && toolUseId != null && existing.toolUseId === toolUseId) ||
|
|
709
|
+
(existing.toolUseId == null &&
|
|
710
|
+
toolUseId == null &&
|
|
711
|
+
existing.toolName === toolName &&
|
|
712
|
+
existing.toolInputJson === toolInputJson));
|
|
713
|
+
if (isDuplicateDelivery) {
|
|
714
|
+
// Duplicate delivery of the same ask (CLI retry after its 60s response-header
|
|
715
|
+
// timeout) — attach instead of replacing so both connections settle together.
|
|
716
|
+
// Nothing about the display changes, so skip rebuilding it below. Accepted
|
|
717
|
+
// residual: two independent, identical, concurrent asks are byte-for-byte
|
|
718
|
+
// indistinguishable on the wire and coalesce the same way, receiving one
|
|
719
|
+
// decision — evicting instead would falsely deny a live ask.
|
|
720
|
+
existing.resolvers.push({ resolve: resolve, reject: reject });
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
498
723
|
if (existing != null) {
|
|
499
724
|
Logger.debug(`ClaudeCodeProvider.handleSessionEvent: auto-denying stale permission ${tag} tool=${existing.toolName}`);
|
|
500
|
-
|
|
725
|
+
this.resolveAllPending(existing, {
|
|
501
726
|
hookSpecificOutput: {
|
|
502
727
|
hookEventName: 'PermissionRequest',
|
|
503
728
|
decision: { behavior: 'deny' },
|
|
@@ -505,10 +730,11 @@ export class ClaudeCodeProvider {
|
|
|
505
730
|
});
|
|
506
731
|
}
|
|
507
732
|
this._pendingPermissions.set(event.sessionId, {
|
|
508
|
-
resolve: resolve,
|
|
509
|
-
reject: reject,
|
|
733
|
+
resolvers: [{ resolve: resolve, reject: reject }],
|
|
510
734
|
toolName: toolName,
|
|
511
735
|
toolDescription: toolDescription,
|
|
736
|
+
toolUseId: toolUseId,
|
|
737
|
+
toolInputJson: toolInputJson,
|
|
512
738
|
});
|
|
513
739
|
// Plan-kind body: prefer the existing session's planFile (set via SessionStart/
|
|
514
740
|
// PostToolUse) and fall back to the event's planFile for cold-start cases.
|
|
@@ -529,17 +755,45 @@ export class ClaudeCodeProvider {
|
|
|
529
755
|
questionText: questionDetails?.text,
|
|
530
756
|
questionCount: questionDetails?.count,
|
|
531
757
|
};
|
|
532
|
-
this.updateSessionWithPermission(event.sessionId, permission,
|
|
758
|
+
this.updateSessionWithPermission(event.sessionId, permission, eventContext);
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
{
|
|
762
|
+
// Mirror the blocking payload's shape so the ask renders identically either way —
|
|
763
|
+
// just unresolvable, since no blocking hook entry exists to route an answer through.
|
|
764
|
+
const toolName = hookInput?.tool_name ?? event.toolName;
|
|
765
|
+
const kind = toolName != null ? classifyPermissionKind(toolName) : undefined;
|
|
766
|
+
// Plan-kind body: same session-first preference as the blocking path.
|
|
767
|
+
const planFilePath = kind === 'plan'
|
|
768
|
+
? (this._sessions.find(s => s.id === event.sessionId)?.planFile ?? event.planFile)
|
|
769
|
+
: undefined;
|
|
770
|
+
// Same body extraction as the blocking path — surfaces render `questionText`/
|
|
771
|
+
// `planSummary` in place of the tool description, so omitting them would show a
|
|
772
|
+
// generic "awaiting" card for a question the input actually carries.
|
|
773
|
+
const questionDetails = kind === 'question' && toolInput != null ? extractQuestionDetails(toolInput) : undefined;
|
|
774
|
+
const synthesized = kind != null && toolName != null && toolInput != null
|
|
775
|
+
? {
|
|
776
|
+
kind: kind,
|
|
777
|
+
toolName: toolName,
|
|
778
|
+
toolDescription: describeToolInput(toolName, toolInput),
|
|
779
|
+
toolInputDescription: toolInput.description || undefined,
|
|
780
|
+
planFilePath: planFilePath,
|
|
781
|
+
planSummary: kind === 'plan' ? extractPlanSummary(toolInput) : undefined,
|
|
782
|
+
questionText: questionDetails?.text,
|
|
783
|
+
questionCount: questionDetails?.count,
|
|
784
|
+
resolvable: false,
|
|
785
|
+
}
|
|
786
|
+
: synthesizeUnresolvableAsk(toolName, planFilePath);
|
|
787
|
+
this.updateSessionStatus(event.sessionId, 'permission_requested', {
|
|
788
|
+
...eventContext,
|
|
789
|
+
statusDetail: event.toolName,
|
|
790
|
+
pendingPermission: synthesized,
|
|
533
791
|
});
|
|
534
792
|
}
|
|
535
|
-
this.updateSessionStatus(event.sessionId, 'permission_requested', {
|
|
536
|
-
...eventContext,
|
|
537
|
-
statusDetail: event.toolName,
|
|
538
|
-
});
|
|
539
793
|
break;
|
|
540
794
|
}
|
|
541
795
|
case 'PermissionDenied': {
|
|
542
|
-
this.clearStalePermission(event.sessionId, 'PermissionDenied');
|
|
796
|
+
this.clearStalePermission(event.sessionId, 'PermissionDenied', this.settledByFromEvent(event));
|
|
543
797
|
const bk = this.getBookkeeping(event.sessionId);
|
|
544
798
|
bk.activeToolCount = Math.max(0, bk.activeToolCount - 1);
|
|
545
799
|
const filesChanged = this.untrackToolUseFile(event.sessionId, event);
|
|
@@ -563,6 +817,9 @@ export class ClaudeCodeProvider {
|
|
|
563
817
|
kind: 'elicitation',
|
|
564
818
|
toolName: event.toolName ?? 'Input Required',
|
|
565
819
|
toolDescription: event.toolName ?? 'Waiting for input',
|
|
820
|
+
// `Elicitation` arrives on a non-blocking hook, so no entry exists for
|
|
821
|
+
// `resolvePermission` to answer — the user responds in the agent's own session.
|
|
822
|
+
resolvable: false,
|
|
566
823
|
};
|
|
567
824
|
this.updateSessionStatus(event.sessionId, 'permission_requested', {
|
|
568
825
|
...eventContext,
|
|
@@ -578,16 +835,15 @@ export class ClaudeCodeProvider {
|
|
|
578
835
|
}
|
|
579
836
|
case 'CwdChanged':
|
|
580
837
|
if (event.cwd) {
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
838
|
+
// Route through `ensureSession`: it owns the whole location transition — timeline
|
|
839
|
+
// seat (or handing attribution back to the probe when the CLI didn't explain the
|
|
840
|
+
// move), workspacePath/isInWorkspace recompute, the unresolvable-flag clear, and
|
|
841
|
+
// the probe gating. Writing `cwd` directly here left `cliSeatedWorktree` set, so
|
|
842
|
+
// the probe's answer for the new cwd could never replace the stale seated one.
|
|
843
|
+
const { changed } = this.ensureSession(event.sessionId, eventContext);
|
|
844
|
+
if (changed) {
|
|
584
845
|
this._onDidChangeSessions.fire();
|
|
585
846
|
}
|
|
586
|
-
// Clear the unresolvable flag so the new cwd actually gets re-probed — without
|
|
587
|
-
// this, a session that started in a non-git cwd would stay flagged forever even
|
|
588
|
-
// after moving into a git repo.
|
|
589
|
-
this.getBookkeeping(event.sessionId).gitInfoUnresolvable = false;
|
|
590
|
-
void this.resolveGitInfo(event.sessionId, event.cwd);
|
|
591
847
|
}
|
|
592
848
|
break;
|
|
593
849
|
case 'SubagentStart': {
|
|
@@ -698,6 +954,8 @@ export class ClaudeCodeProvider {
|
|
|
698
954
|
bk.currentReadCounts.clear();
|
|
699
955
|
bk.lastTouchedAt.clear();
|
|
700
956
|
bk.gitInfoUnresolvable = false;
|
|
957
|
+
// `cliSeatedWorktree` deliberately survives: SessionStart seats the worktree via
|
|
958
|
+
// `ensureSession` BEFORE resetting bookkeeping, and an unexplained cwd move clears it anyway.
|
|
701
959
|
const parentId = this.findOwningParentId(sessionId);
|
|
702
960
|
if (parentId != null) {
|
|
703
961
|
this.syncSessionFileActivity(parentId);
|
|
@@ -1075,8 +1333,10 @@ export class ClaudeCodeProvider {
|
|
|
1075
1333
|
this.cancelPendingIdleTransition(sessionId);
|
|
1076
1334
|
const timer = setTimeout(() => {
|
|
1077
1335
|
this._pendingIdleTimers.delete(sessionId);
|
|
1078
|
-
// Session may have been removed (SessionEnd, prune) during the window
|
|
1079
|
-
|
|
1336
|
+
// Session may have been removed (SessionEnd, prune) during the window, or completed by a
|
|
1337
|
+
// poll/SessionEnd that raced this timer — reviving a terminal row to idle would zombie it.
|
|
1338
|
+
const idx = this._sessions.findIndex(s => s.id === sessionId);
|
|
1339
|
+
if (idx < 0 || this._sessions[idx].status === 'completed')
|
|
1080
1340
|
return;
|
|
1081
1341
|
this.updateSessionStatus(sessionId, 'idle');
|
|
1082
1342
|
}, stopToIdleDebounceMs);
|
|
@@ -1105,20 +1365,86 @@ export class ClaudeCodeProvider {
|
|
|
1105
1365
|
bk.priorPhase = { phase: prevPhase, phaseSince: prevPhaseSince };
|
|
1106
1366
|
return now;
|
|
1107
1367
|
}
|
|
1368
|
+
/** Extracts the ask identity a settlement event refers to. `PostToolUse`/`PermissionDenied`
|
|
1369
|
+
* carry `hookInput.tool_use_id` and `hookInput.tool_input` in production; the top-level
|
|
1370
|
+
* `event.toolName`/`event.toolInput` are the older-CLI fallback. Input is canonicalized so key
|
|
1371
|
+
* order (or arriving via the fallback field instead of `hookInput`) doesn't defeat comparison. */
|
|
1372
|
+
settledByFromEvent(event) {
|
|
1373
|
+
const hookInput = event.hookInput;
|
|
1374
|
+
const toolInput = hookInput?.tool_input ?? event.toolInput;
|
|
1375
|
+
return {
|
|
1376
|
+
toolUseId: hookInput?.tool_use_id,
|
|
1377
|
+
toolName: hookInput?.tool_name ?? event.toolName,
|
|
1378
|
+
toolInputJson: toolInput != null ? stableStringify(toolInput) : undefined,
|
|
1379
|
+
};
|
|
1380
|
+
}
|
|
1381
|
+
resolveAllPending(entry, response) {
|
|
1382
|
+
for (const resolver of entry.resolvers) {
|
|
1383
|
+
resolver.resolve(response);
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
rejectAllPending(entry, reason) {
|
|
1387
|
+
for (const resolver of entry.resolvers) {
|
|
1388
|
+
resolver.reject(reason);
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1108
1391
|
// Called when a pending permission was resolved outside GitLens (e.g. via the CLI terminal).
|
|
1109
1392
|
// The 'deny' response is a safe no-op since the tool has already run or been denied upstream.
|
|
1110
|
-
|
|
1393
|
+
// `settledBy` identifies the ask the settling event actually refers to — out-of-order/duplicate
|
|
1394
|
+
// hook deliveries (PostToolUse for an unrelated tool, a PermissionDenied for an already-evicted
|
|
1395
|
+
// earlier ask) must never deny a still-live blocking ask just because it shares a session id.
|
|
1396
|
+
// `PermissionDenied` itself only ever fires after Claude Code's auto-mode classifier denies a
|
|
1397
|
+
// tool call — never as an echo of a deny a `PermissionRequest` response sent.
|
|
1398
|
+
clearStalePermission(sessionId, reason, settledBy) {
|
|
1111
1399
|
const bk = this.getBookkeeping(sessionId);
|
|
1112
|
-
if (bk.pendingPermission == null)
|
|
1113
|
-
return;
|
|
1114
|
-
Logger.debug(`ClaudeCodeProvider.clearStalePermission: ${reason} ${this.sessionTag(sessionId)}`);
|
|
1115
1400
|
const pending = this._pendingPermissions.get(sessionId);
|
|
1116
1401
|
if (pending != null) {
|
|
1117
|
-
|
|
1402
|
+
if (settledBy != null) {
|
|
1403
|
+
let mismatch = false;
|
|
1404
|
+
if (pending.toolUseId != null && settledBy.toolUseId != null) {
|
|
1405
|
+
// Rule 1: both sides identified — the only trustworthy comparison. `PermissionDenied`/
|
|
1406
|
+
// `PostToolUse` hookInput DOES carry `tool_use_id`, but `PermissionRequest` never does,
|
|
1407
|
+
// so `pending` never actually has one — this branch only engages if a future CLI adds
|
|
1408
|
+
// it to requests too. The asymmetry is deliberate, not a gap to close.
|
|
1409
|
+
mismatch = pending.toolUseId !== settledBy.toolUseId;
|
|
1410
|
+
}
|
|
1411
|
+
else if (pending.toolName === '') {
|
|
1412
|
+
// Rule 2: no identity was captured at registration (degraded payload) — nothing to
|
|
1413
|
+
// gate on, so clear unconditionally rather than stranding the ask forever.
|
|
1414
|
+
mismatch = false;
|
|
1415
|
+
}
|
|
1416
|
+
else {
|
|
1417
|
+
// Rule 3: name must match; when both sides have input, it must match too. A
|
|
1418
|
+
// PostToolUse for a different invocation of the same tool (different input) leaves
|
|
1419
|
+
// the ask untouched.
|
|
1420
|
+
mismatch =
|
|
1421
|
+
settledBy.toolName !== pending.toolName ||
|
|
1422
|
+
(pending.toolInputJson != null &&
|
|
1423
|
+
settledBy.toolInputJson != null &&
|
|
1424
|
+
pending.toolInputJson !== settledBy.toolInputJson);
|
|
1425
|
+
}
|
|
1426
|
+
if (mismatch) {
|
|
1427
|
+
Logger.debug(`ClaudeCodeProvider.clearStalePermission: skip (identity mismatch) ${reason} ${this.sessionTag(sessionId)} pending=${pending.toolUseId ?? pending.toolName} settledBy=${settledBy.toolUseId ?? settledBy.toolName ?? 'unknown'}`);
|
|
1428
|
+
return;
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
Logger.debug(`ClaudeCodeProvider.clearStalePermission: ${reason} ${this.sessionTag(sessionId)}`);
|
|
1432
|
+
this.resolveAllPending(pending, {
|
|
1118
1433
|
hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: { behavior: 'deny' } },
|
|
1119
1434
|
});
|
|
1120
1435
|
this._pendingPermissions.delete(sessionId);
|
|
1436
|
+
bk.pendingPermission = undefined;
|
|
1437
|
+
return;
|
|
1121
1438
|
}
|
|
1439
|
+
if (bk.pendingPermission == null)
|
|
1440
|
+
return;
|
|
1441
|
+
if (settledBy?.toolName != null &&
|
|
1442
|
+
bk.pendingPermission.toolName != null &&
|
|
1443
|
+
settledBy.toolName !== bk.pendingPermission.toolName) {
|
|
1444
|
+
Logger.debug(`ClaudeCodeProvider.clearStalePermission: skip (display-only identity mismatch) ${reason} ${this.sessionTag(sessionId)} pending=${bk.pendingPermission.toolName} settledBy=${settledBy.toolName}`);
|
|
1445
|
+
return;
|
|
1446
|
+
}
|
|
1447
|
+
Logger.debug(`ClaudeCodeProvider.clearStalePermission: ${reason} ${this.sessionTag(sessionId)}`);
|
|
1122
1448
|
bk.pendingPermission = undefined;
|
|
1123
1449
|
}
|
|
1124
1450
|
resolvePermission(sessionId, decision, updatedPermissions) {
|
|
@@ -1126,7 +1452,7 @@ export class ClaudeCodeProvider {
|
|
|
1126
1452
|
if (pending == null)
|
|
1127
1453
|
return false;
|
|
1128
1454
|
Logger.debug(`ClaudeCodeProvider.resolvePermission: ${decision} ${this.sessionTag(sessionId)} tool=${pending.toolName}`);
|
|
1129
|
-
|
|
1455
|
+
this.resolveAllPending(pending, {
|
|
1130
1456
|
hookSpecificOutput: {
|
|
1131
1457
|
hookEventName: 'PermissionRequest',
|
|
1132
1458
|
decision: { behavior: decision, updatedPermissions: updatedPermissions },
|
|
@@ -1144,6 +1470,126 @@ export class ClaudeCodeProvider {
|
|
|
1144
1470
|
this.updateSessionStatus(sessionId, nextStatus);
|
|
1145
1471
|
return true;
|
|
1146
1472
|
}
|
|
1473
|
+
async archiveSession(sessionId) {
|
|
1474
|
+
// Authoritative status guard: the CLI's `archive-session` ends an active session first, so a
|
|
1475
|
+
// stale webview action (or a row that resumed out of `completed` since the click) must never
|
|
1476
|
+
// reach the CLI — archiving is only ever valid on a terminal row. Callers gate the UI too,
|
|
1477
|
+
// but this is the last line that owns live process safety. Returns `false` when refused so the
|
|
1478
|
+
// host doesn't record a success telemetry event for an archive that never happened.
|
|
1479
|
+
const session = this._sessions.find(s => s.id === sessionId);
|
|
1480
|
+
if (session != null && session.status !== 'completed') {
|
|
1481
|
+
Logger.warn(`ClaudeCodeProvider.archiveSession: refusing to archive non-completed ${this.sessionTag(sessionId)} (status=${session.status})`);
|
|
1482
|
+
return false;
|
|
1483
|
+
}
|
|
1484
|
+
Logger.debug(`ClaudeCodeProvider.archiveSession: ${this.sessionTag(sessionId)}`);
|
|
1485
|
+
await this.callbacks.runCLICommand(['ai', 'hook', 'archive-session', sessionId, '--json']);
|
|
1486
|
+
// Invalidate the archived-id cache so the next `getPastSessions` re-fetches and excludes this id.
|
|
1487
|
+
this._archivedSessionIdsCache = undefined;
|
|
1488
|
+
// Optimistically drop it — archived sessions are excluded from the `active,ended` poll, so the
|
|
1489
|
+
// next reconciliation confirms the removal regardless.
|
|
1490
|
+
const index = this._sessions.findIndex(s => s.id === sessionId);
|
|
1491
|
+
if (index >= 0) {
|
|
1492
|
+
this._sessions.splice(index, 1);
|
|
1493
|
+
this._sessionBookkeeping.delete(sessionId);
|
|
1494
|
+
this._transcriptReader.forget(sessionId);
|
|
1495
|
+
this._onDidChangeSessions.fire();
|
|
1496
|
+
}
|
|
1497
|
+
return true;
|
|
1498
|
+
}
|
|
1499
|
+
/** Lists the ids of sessions the CLI has archived, so callers (the "Past" transcript listing) can
|
|
1500
|
+
* exclude them — the tracked row is gone, but the transcript on disk survives and would otherwise
|
|
1501
|
+
* resurface there. Resolves to `[]` on any error, including a CLI too old to support the
|
|
1502
|
+
* `archived` status filter. */
|
|
1503
|
+
async getArchivedSessionIds() {
|
|
1504
|
+
const cached = this._archivedSessionIdsCache;
|
|
1505
|
+
if (cached != null && Date.now() - cached.at < archivedSessionIdsCacheTtlMs) {
|
|
1506
|
+
return cached.promise;
|
|
1507
|
+
}
|
|
1508
|
+
const promise = this.fetchArchivedSessionIds();
|
|
1509
|
+
this._archivedSessionIdsCache = { promise: promise, at: Date.now() };
|
|
1510
|
+
return promise;
|
|
1511
|
+
}
|
|
1512
|
+
async fetchArchivedSessionIds() {
|
|
1513
|
+
try {
|
|
1514
|
+
const output = await this.callbacks.runCLICommand([
|
|
1515
|
+
'ai',
|
|
1516
|
+
'hook',
|
|
1517
|
+
'list-sessions',
|
|
1518
|
+
'--status',
|
|
1519
|
+
'archived',
|
|
1520
|
+
'--json',
|
|
1521
|
+
]);
|
|
1522
|
+
const sessions = JSON.parse(output);
|
|
1523
|
+
return sessions.map(s => s.sessionId).filter(id => id.length > 0);
|
|
1524
|
+
}
|
|
1525
|
+
catch {
|
|
1526
|
+
return [];
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
resolveCompletedSessionDetails(sessionId) {
|
|
1530
|
+
const session = this._sessions.find(s => s.id === sessionId);
|
|
1531
|
+
// Only completed sessions defer resolution; live sessions resolve eagerly on discovery.
|
|
1532
|
+
if (session?.status !== 'completed')
|
|
1533
|
+
return;
|
|
1534
|
+
// The durable store drops the prompt (and only ever carries an auto-slug name) for most ended
|
|
1535
|
+
// sessions, so read the terminal transcript once on demand to recover a real title + the
|
|
1536
|
+
// first/last prompt. Both the git probe and transcript read sit under the guard so repeated
|
|
1537
|
+
// opens don't re-probe git or re-read a possibly-large file.
|
|
1538
|
+
const bk = this.getBookkeeping(sessionId);
|
|
1539
|
+
if (bk.completedDetailsResolved)
|
|
1540
|
+
return;
|
|
1541
|
+
bk.completedDetailsResolved = true;
|
|
1542
|
+
if (session.cwd) {
|
|
1543
|
+
void this.resolveGitInfo(sessionId, session.cwd);
|
|
1544
|
+
}
|
|
1545
|
+
void this.applyCompletedTranscriptDetails(sessionId, session.cwd);
|
|
1546
|
+
}
|
|
1547
|
+
async applyCompletedTranscriptDetails(sessionId, cwd) {
|
|
1548
|
+
let details;
|
|
1549
|
+
try {
|
|
1550
|
+
details = await this._transcriptReader.resolveCompletedDetails(sessionId, cwd);
|
|
1551
|
+
}
|
|
1552
|
+
catch {
|
|
1553
|
+
// A read error (transient I/O, permissions) is not terminal state — clear the once-only
|
|
1554
|
+
// guard so a later open retries. A `undefined` result IS terminal (no transcript on disk),
|
|
1555
|
+
// so it keeps the guard and never re-scans.
|
|
1556
|
+
const bk = this._sessionBookkeeping.get(sessionId);
|
|
1557
|
+
if (bk != null) {
|
|
1558
|
+
bk.completedDetailsResolved = false;
|
|
1559
|
+
}
|
|
1560
|
+
return;
|
|
1561
|
+
}
|
|
1562
|
+
if (details == null)
|
|
1563
|
+
return;
|
|
1564
|
+
const index = this._sessions.findIndex(s => s.id === sessionId);
|
|
1565
|
+
if (index < 0)
|
|
1566
|
+
return;
|
|
1567
|
+
const session = this._sessions[index];
|
|
1568
|
+
// The session may have been archived/removed while we were reading.
|
|
1569
|
+
if (session.status !== 'completed')
|
|
1570
|
+
return;
|
|
1571
|
+
const { titles } = details;
|
|
1572
|
+
const nextTitles = titles.custom != null || titles.ai != null || titles.agent != null
|
|
1573
|
+
? { custom: titles.custom, ai: titles.ai, agent: titles.agent }
|
|
1574
|
+
: session.transcriptTitles;
|
|
1575
|
+
// Durable-store prompts (full text) win over the transcript's (CLI-truncated) copy; only
|
|
1576
|
+
// backfill from the transcript when the field is empty.
|
|
1577
|
+
const nextFirstPrompt = session.firstPrompt ?? prepareStoredPrompt(details.firstPrompt);
|
|
1578
|
+
const nextLastPrompt = session.lastPrompt ?? prepareStoredPrompt(details.lastPrompt);
|
|
1579
|
+
const titlesChanged = nextTitles?.custom !== session.transcriptTitles?.custom ||
|
|
1580
|
+
nextTitles?.ai !== session.transcriptTitles?.ai ||
|
|
1581
|
+
nextTitles?.agent !== session.transcriptTitles?.agent;
|
|
1582
|
+
if (!titlesChanged && nextFirstPrompt === session.firstPrompt && nextLastPrompt === session.lastPrompt) {
|
|
1583
|
+
return;
|
|
1584
|
+
}
|
|
1585
|
+
this._sessions[index] = {
|
|
1586
|
+
...session,
|
|
1587
|
+
transcriptTitles: nextTitles,
|
|
1588
|
+
firstPrompt: nextFirstPrompt,
|
|
1589
|
+
lastPrompt: nextLastPrompt,
|
|
1590
|
+
};
|
|
1591
|
+
this._onDidChangeSessions.fire();
|
|
1592
|
+
}
|
|
1147
1593
|
matchesWorkspace(workspacePath) {
|
|
1148
1594
|
if (!workspacePath)
|
|
1149
1595
|
return false;
|
|
@@ -1159,11 +1605,13 @@ export class ClaudeCodeProvider {
|
|
|
1159
1605
|
const normalized = normalizePath(cwd);
|
|
1160
1606
|
return this._workspacePaths.find(p => normalized === p || normalized.startsWith(`${p}/`) || p.startsWith(`${normalized}/`));
|
|
1161
1607
|
}
|
|
1162
|
-
updateSessionWithPermission(sessionId, permission,
|
|
1608
|
+
updateSessionWithPermission(sessionId, permission, context) {
|
|
1163
1609
|
// A pending permission is a working/waiting transition — cancel any deferred Stop → idle
|
|
1164
1610
|
// commit so the session doesn't briefly flip to idle before showing the prompt.
|
|
1165
1611
|
this.cancelPendingIdleTransition(sessionId);
|
|
1166
|
-
|
|
1612
|
+
// The full event context, not just the pid: a blocking ask can be the first event of a
|
|
1613
|
+
// session, and a row created here with no location would sit unattributed for the whole block.
|
|
1614
|
+
const { index } = this.ensureSession(sessionId, context);
|
|
1167
1615
|
const prev = this._sessions[index];
|
|
1168
1616
|
const newPhase = getPhaseForStatus('permission_requested');
|
|
1169
1617
|
this.getBookkeeping(sessionId).pendingPermission = permission;
|
|
@@ -1200,7 +1648,12 @@ export class ClaudeCodeProvider {
|
|
|
1200
1648
|
return;
|
|
1201
1649
|
}
|
|
1202
1650
|
const statusDetail = options?.statusDetail;
|
|
1203
|
-
|
|
1651
|
+
// Resolved before the short-circuit: a second ask can arrive while the row already sits at
|
|
1652
|
+
// `permission_requested`, and comparing only status+detail would publish the first ask forever.
|
|
1653
|
+
const nextPendingPermission = status === 'permission_requested' ? (bk.pendingPermission ?? options?.pendingPermission) : undefined;
|
|
1654
|
+
if (prev.status === status &&
|
|
1655
|
+
prev.statusDetail === statusDetail &&
|
|
1656
|
+
isSameAsk(prev.pendingPermission, nextPendingPermission)) {
|
|
1204
1657
|
if (metadataChanged) {
|
|
1205
1658
|
this._onDidChangeSessions.fire();
|
|
1206
1659
|
}
|
|
@@ -1213,7 +1666,7 @@ export class ClaudeCodeProvider {
|
|
|
1213
1666
|
phase: newPhase,
|
|
1214
1667
|
phaseSince: this.resolvePhaseSince(sessionId, prev.phase, prev.phaseSince, newPhase),
|
|
1215
1668
|
statusDetail: statusDetail,
|
|
1216
|
-
pendingPermission:
|
|
1669
|
+
pendingPermission: nextPendingPermission,
|
|
1217
1670
|
lastActivity: new Date(),
|
|
1218
1671
|
};
|
|
1219
1672
|
this._onDidChangeSessions.fire();
|
|
@@ -1231,10 +1684,13 @@ export class ClaudeCodeProvider {
|
|
|
1231
1684
|
}
|
|
1232
1685
|
}
|
|
1233
1686
|
ensureSession(sessionId, context) {
|
|
1234
|
-
const { pid, workspacePath, isInWorkspace, cwd, initialCwd, planFile, sessionName } = context ?? {};
|
|
1687
|
+
const { pid, workspacePath, isInWorkspace, cwd, initialCwd, planFile, sessionName, model, worktreePath } = context ?? {};
|
|
1235
1688
|
let index = this._sessions.findIndex(s => s.id === sessionId);
|
|
1236
1689
|
if (index < 0) {
|
|
1237
1690
|
const now = new Date();
|
|
1691
|
+
// Same worktree-derived fallback as the update branch below: a session created with a
|
|
1692
|
+
// scratch cwd but a CLI-seated worktree still belongs to that worktree's workspace.
|
|
1693
|
+
const createdWorkspacePath = workspacePath ?? this.resolveWorkspacePath(worktreePath);
|
|
1238
1694
|
index = this._sessions.length;
|
|
1239
1695
|
this._sessions.push({
|
|
1240
1696
|
id: sessionId,
|
|
@@ -1247,13 +1703,18 @@ export class ClaudeCodeProvider {
|
|
|
1247
1703
|
pid: pid,
|
|
1248
1704
|
lastActivity: now,
|
|
1249
1705
|
isSubagent: false,
|
|
1250
|
-
workspacePath:
|
|
1706
|
+
workspacePath: createdWorkspacePath,
|
|
1251
1707
|
cwd: cwd,
|
|
1252
1708
|
initialCwd: initialCwd ?? cwd,
|
|
1253
1709
|
planFile: planFile,
|
|
1254
|
-
isInWorkspace: isInWorkspace ?? false,
|
|
1710
|
+
isInWorkspace: createdWorkspacePath != null ? true : (isInWorkspace ?? false),
|
|
1711
|
+
model: model,
|
|
1712
|
+
worktreePath: worktreePath != null ? normalizePath(worktreePath) : undefined,
|
|
1255
1713
|
});
|
|
1256
1714
|
Logger.debug(`ClaudeCodeProvider.ensureSession: implicitly created ${this.sessionTag(sessionId, sessionName ?? 'unnamed')}`);
|
|
1715
|
+
if (worktreePath != null) {
|
|
1716
|
+
this.getBookkeeping(sessionId).cliSeatedWorktree = true;
|
|
1717
|
+
}
|
|
1257
1718
|
this._onDidChangeSessions.fire();
|
|
1258
1719
|
if (cwd != null) {
|
|
1259
1720
|
void this.resolveGitInfo(sessionId, cwd);
|
|
@@ -1263,10 +1724,9 @@ export class ClaudeCodeProvider {
|
|
|
1263
1724
|
}
|
|
1264
1725
|
const existing = this._sessions[index];
|
|
1265
1726
|
const updatedPid = pid != null && existing.pid == null ? pid : existing.pid;
|
|
1266
|
-
const updatedWorkspacePath = workspacePath || existing.workspacePath;
|
|
1267
|
-
const updatedIsInWorkspace = workspacePath ? (isInWorkspace ?? existing.isInWorkspace) : existing.isInWorkspace;
|
|
1268
1727
|
const updatedPlanFile = planFile ?? existing.planFile;
|
|
1269
1728
|
const updatedName = sessionName || existing.name;
|
|
1729
|
+
const updatedModel = model || existing.model;
|
|
1270
1730
|
const updatedCwd = cwd ?? existing.cwd;
|
|
1271
1731
|
// Prefer the CLI's authoritative `initialCwd` (the true launch dir it captured first-hand,
|
|
1272
1732
|
// even for sessions that started before this window opened); otherwise keep whatever we
|
|
@@ -1274,23 +1734,55 @@ export class ClaudeCodeProvider {
|
|
|
1274
1734
|
// don't send `initialCwd`, or a cold-create before any cwd was known). Comparing live `cwd`
|
|
1275
1735
|
// against `initialCwd` is how consumers detect launch-vs-current drift, so it stays stable.
|
|
1276
1736
|
const updatedInitialCwd = initialCwd ?? existing.initialCwd ?? cwd;
|
|
1737
|
+
// The CLI resolves this at hook time and sends it on every event — authoritative when
|
|
1738
|
+
// present, so it seats ahead of the `resolveGitInfo` probe below (which still fills
|
|
1739
|
+
// `commonPath` and remains the fallback for older CLIs that don't send `cwdTimeline`).
|
|
1740
|
+
const updatedWorktreePath = worktreePath != null ? normalizePath(worktreePath) : existing.worktreePath;
|
|
1741
|
+
// Workspace membership follows the ATTRIBUTION, not the raw cwd: a scratch-dir excursion
|
|
1742
|
+
// (cwd in /tmp, worktree kept) stays in-workspace via its worktree, while a genuine move to
|
|
1743
|
+
// a repo outside the workspace honestly drops membership when events carry a cwd.
|
|
1744
|
+
const updatedWorkspacePath = workspacePath ??
|
|
1745
|
+
this.resolveWorkspacePath(updatedWorktreePath) ??
|
|
1746
|
+
(cwd == null ? existing.workspacePath : undefined);
|
|
1747
|
+
const updatedIsInWorkspace = cwd == null ? existing.isInWorkspace : updatedWorkspacePath != null;
|
|
1748
|
+
const cwdMoved = cwd != null && updatedCwd !== existing.cwd;
|
|
1749
|
+
const worktreeMoved = updatedWorktreePath != null &&
|
|
1750
|
+
existing.worktreePath != null &&
|
|
1751
|
+
updatedWorktreePath !== existing.worktreePath;
|
|
1752
|
+
// The CLI explaining the location owns attribution; a move it didn't explain hands it back to
|
|
1753
|
+
// the probe.
|
|
1754
|
+
if (worktreePath != null) {
|
|
1755
|
+
this.getBookkeeping(sessionId).cliSeatedWorktree = true;
|
|
1756
|
+
}
|
|
1757
|
+
else if (cwdMoved) {
|
|
1758
|
+
this.getBookkeeping(sessionId).cliSeatedWorktree = false;
|
|
1759
|
+
}
|
|
1277
1760
|
let changed = false;
|
|
1278
1761
|
if (updatedPid !== existing.pid ||
|
|
1279
1762
|
updatedWorkspacePath !== existing.workspacePath ||
|
|
1280
1763
|
updatedIsInWorkspace !== existing.isInWorkspace ||
|
|
1281
1764
|
updatedPlanFile !== existing.planFile ||
|
|
1282
1765
|
updatedName !== existing.name ||
|
|
1766
|
+
updatedModel !== existing.model ||
|
|
1283
1767
|
updatedCwd !== existing.cwd ||
|
|
1284
|
-
updatedInitialCwd !== existing.initialCwd
|
|
1768
|
+
updatedInitialCwd !== existing.initialCwd ||
|
|
1769
|
+
updatedWorktreePath !== existing.worktreePath) {
|
|
1285
1770
|
this._sessions[index] = {
|
|
1286
1771
|
...existing,
|
|
1287
1772
|
name: updatedName,
|
|
1773
|
+
model: updatedModel,
|
|
1288
1774
|
pid: updatedPid,
|
|
1289
1775
|
workspacePath: updatedWorkspacePath,
|
|
1290
1776
|
cwd: updatedCwd,
|
|
1291
1777
|
initialCwd: updatedInitialCwd,
|
|
1292
1778
|
planFile: updatedPlanFile,
|
|
1293
1779
|
isInWorkspace: updatedIsInWorkspace,
|
|
1780
|
+
worktreePath: updatedWorktreePath,
|
|
1781
|
+
// A worktree move can cross repos, and the spread would otherwise carry the old repo
|
|
1782
|
+
// identity forward — which consumers prefer over the new worktree, pinning the card to
|
|
1783
|
+
// the wrong repo. Drop it; the probe below refills it. Mirrors the poll's ended-record
|
|
1784
|
+
// re-seat.
|
|
1785
|
+
commonPath: worktreeMoved ? undefined : existing.commonPath,
|
|
1294
1786
|
};
|
|
1295
1787
|
changed = true;
|
|
1296
1788
|
}
|
|
@@ -1303,8 +1795,23 @@ export class ClaudeCodeProvider {
|
|
|
1303
1795
|
// `resolveGitInfo` confirms the cwd isn't a git repo, preventing a retry storm on every
|
|
1304
1796
|
// hook event for non-git-repo cwds. The flag is cleared on cwd change (`CwdChanged`) and
|
|
1305
1797
|
// on `resetBookkeeping` (Stop/SessionStart) so re-resolution can happen when warranted.
|
|
1798
|
+
// A cwd move re-resolves too. `worktreePath`/`commonPath` are resolved from whichever cwd was
|
|
1799
|
+
// seen first, so an agent that moves between worktrees would otherwise stay pinned to its
|
|
1800
|
+
// launch repo — attaching to the wrong WIP row and leaving its real worktree showing no
|
|
1801
|
+
// agents at all. `CwdChanged` covers the move the CLI announces; this covers an ordinary
|
|
1802
|
+
// event that simply arrives carrying a new cwd.
|
|
1803
|
+
//
|
|
1804
|
+
// A cwd the CLI already attributed to the same worktree needs no probe at all — that's the
|
|
1805
|
+
// common case (an agent `cd`-ing around inside its own tree), and probing it would spawn a git
|
|
1806
|
+
// call per hook event for an answer we already have. A first seat (no prior worktree) still
|
|
1807
|
+
// counts as a location change: the move may be out of a non-git dir whose probe latched
|
|
1808
|
+
// `gitInfoUnresolvable`, and `commonPath` still needs the probe.
|
|
1809
|
+
const locationNeedsProbe = cwdMoved && (worktreePath == null || worktreeMoved || existing.worktreePath == null);
|
|
1810
|
+
if (locationNeedsProbe) {
|
|
1811
|
+
this.getBookkeeping(sessionId).gitInfoUnresolvable = false;
|
|
1812
|
+
}
|
|
1306
1813
|
if (cwd != null &&
|
|
1307
|
-
(existing.worktreePath == null || existing.commonPath == null) &&
|
|
1814
|
+
(locationNeedsProbe || existing.worktreePath == null || existing.commonPath == null) &&
|
|
1308
1815
|
!this.getBookkeeping(sessionId).gitInfoUnresolvable) {
|
|
1309
1816
|
void this.resolveGitInfo(sessionId, cwd);
|
|
1310
1817
|
}
|
|
@@ -1320,9 +1827,19 @@ export class ClaudeCodeProvider {
|
|
|
1320
1827
|
// would spawn a fresh git probe per hook event. The `gitInfoUnresolvable` bookkeeping flag
|
|
1321
1828
|
// (set below when `info == null`) is the long-lived suppressor across hook events; this
|
|
1322
1829
|
// in-flight set only dedupes concurrent overlapping probes within a single resolution.
|
|
1323
|
-
|
|
1830
|
+
//
|
|
1831
|
+
// Keyed by cwd as well: a probe for the SAME cwd is genuinely redundant, but one for a
|
|
1832
|
+
// different cwd (a missed `CwdChanged` corrected off the durable record, say) must not be
|
|
1833
|
+
// swallowed — the in-flight answer describes the old directory. Record it as pending so the
|
|
1834
|
+
// running probe hands off to it, and discard the stale answer below.
|
|
1835
|
+
const inFlightCwd = this._resolveGitInfoInFlight.get(sessionId);
|
|
1836
|
+
if (inFlightCwd != null) {
|
|
1837
|
+
if (inFlightCwd !== cwd) {
|
|
1838
|
+
this._resolveGitInfoPending.set(sessionId, cwd);
|
|
1839
|
+
}
|
|
1324
1840
|
return;
|
|
1325
|
-
|
|
1841
|
+
}
|
|
1842
|
+
this._resolveGitInfoInFlight.set(sessionId, cwd);
|
|
1326
1843
|
try {
|
|
1327
1844
|
let info;
|
|
1328
1845
|
try {
|
|
@@ -1335,6 +1852,11 @@ export class ClaudeCodeProvider {
|
|
|
1335
1852
|
const index = this._sessions.findIndex(s => s.id === sessionId);
|
|
1336
1853
|
if (index < 0)
|
|
1337
1854
|
return;
|
|
1855
|
+
// Superseded while we were probing: this answer describes a directory the session has
|
|
1856
|
+
// already left, so applying it would undo the newer location. Drop it — the `finally`
|
|
1857
|
+
// below re-runs for the newest cwd.
|
|
1858
|
+
if (this._resolveGitInfoPending.has(sessionId))
|
|
1859
|
+
return;
|
|
1338
1860
|
const session = this._sessions[index];
|
|
1339
1861
|
// `workspacePath` stays as the matched workspace folder (or undefined). Repo identity
|
|
1340
1862
|
// flows through `commonPath`, populated here from `info.repoRoot` at the same step as
|
|
@@ -1357,23 +1879,29 @@ export class ClaudeCodeProvider {
|
|
|
1357
1879
|
}
|
|
1358
1880
|
return;
|
|
1359
1881
|
}
|
|
1882
|
+
// A CLI-seated worktree wins over the probe's answer (see `cliSeatedWorktree`): the CLI
|
|
1883
|
+
// resolved it for this exact cwd, while the probe can name the parent worktree for a nested
|
|
1884
|
+
// one. `commonPath` always comes from the probe — the CLI doesn't resolve repo identity.
|
|
1885
|
+
const effectiveWorktreePath = this.getBookkeeping(sessionId).cliSeatedWorktree && session.worktreePath != null
|
|
1886
|
+
? session.worktreePath
|
|
1887
|
+
: info.worktreePath;
|
|
1360
1888
|
// Capture the worktree/commonPath at the first successful resolve so consumers can
|
|
1361
1889
|
// detect drift (e.g. an agent that `cd`'d into a sibling worktree after launch). Gated
|
|
1362
1890
|
// on `initialCommonPath` — captured together with `initialWorktreePath` so a first
|
|
1363
1891
|
// resolve where `info.worktreePath` happens to be undefined doesn't leave the latter
|
|
1364
1892
|
// open to a later overwrite. Once set, never overwritten.
|
|
1365
1893
|
const firstResolve = session.initialCommonPath == null;
|
|
1366
|
-
const updatedInitialWorktreePath = firstResolve ?
|
|
1894
|
+
const updatedInitialWorktreePath = firstResolve ? effectiveWorktreePath : session.initialWorktreePath;
|
|
1367
1895
|
const updatedInitialCommonPath = firstResolve ? info.repoRoot : session.initialCommonPath;
|
|
1368
1896
|
if (cwd !== session.cwd ||
|
|
1369
|
-
|
|
1897
|
+
effectiveWorktreePath !== session.worktreePath ||
|
|
1370
1898
|
info.repoRoot !== session.commonPath ||
|
|
1371
1899
|
updatedInitialWorktreePath !== session.initialWorktreePath ||
|
|
1372
1900
|
updatedInitialCommonPath !== session.initialCommonPath) {
|
|
1373
1901
|
this._sessions[index] = {
|
|
1374
1902
|
...session,
|
|
1375
1903
|
cwd: cwd,
|
|
1376
|
-
worktreePath:
|
|
1904
|
+
worktreePath: effectiveWorktreePath,
|
|
1377
1905
|
commonPath: info.repoRoot,
|
|
1378
1906
|
initialWorktreePath: updatedInitialWorktreePath,
|
|
1379
1907
|
initialCommonPath: updatedInitialCommonPath,
|
|
@@ -1383,6 +1911,13 @@ export class ClaudeCodeProvider {
|
|
|
1383
1911
|
}
|
|
1384
1912
|
finally {
|
|
1385
1913
|
this._resolveGitInfoInFlight.delete(sessionId);
|
|
1914
|
+
// Hand off to the newest cwd requested while this probe ran, so a move that arrived
|
|
1915
|
+
// mid-flight still resolves instead of being dropped by the dedupe.
|
|
1916
|
+
const pendingCwd = this._resolveGitInfoPending.get(sessionId);
|
|
1917
|
+
if (pendingCwd != null) {
|
|
1918
|
+
this._resolveGitInfoPending.delete(sessionId);
|
|
1919
|
+
void this.resolveGitInfo(sessionId, pendingCwd);
|
|
1920
|
+
}
|
|
1386
1921
|
}
|
|
1387
1922
|
}
|
|
1388
1923
|
async resolveTranscriptTitles(sessionId, cwd) {
|
|
@@ -1408,10 +1943,55 @@ export class ClaudeCodeProvider {
|
|
|
1408
1943
|
};
|
|
1409
1944
|
this._onDidChangeSessions.fire();
|
|
1410
1945
|
}
|
|
1946
|
+
/** Transitions the session at `index` to the terminal `completed` state in place: keeps the row,
|
|
1947
|
+
* stamps `endedAt` as its activity time, clears transient state (statusDetail, pendingPermission,
|
|
1948
|
+
* subagents), and tears down its bookkeeping/timers. Shared by the live `SessionEnd` path and the
|
|
1949
|
+
* poll backstop for a `SessionEnd` the live path missed. Does not fire change events — the caller
|
|
1950
|
+
* batches those. */
|
|
1951
|
+
completeSession(index, endedAt) {
|
|
1952
|
+
const prev = this._sessions[index];
|
|
1953
|
+
this._sessions[index] = {
|
|
1954
|
+
...prev,
|
|
1955
|
+
status: 'completed',
|
|
1956
|
+
phase: 'completed',
|
|
1957
|
+
phaseSince: endedAt,
|
|
1958
|
+
lastActivity: endedAt,
|
|
1959
|
+
statusDetail: undefined,
|
|
1960
|
+
pendingPermission: undefined,
|
|
1961
|
+
subagents: undefined,
|
|
1962
|
+
// A terminal row carries no live working state — drop the file-activity heatmap so
|
|
1963
|
+
// consumers reading it directly don't render a frozen tail for a finished session.
|
|
1964
|
+
fileActivity: undefined,
|
|
1965
|
+
};
|
|
1966
|
+
// Terminal teardown, owned here so every caller gets it — the live `SessionEnd` path does
|
|
1967
|
+
// this before calling us, but the poll's missed-SessionEnd transition doesn't: reject any
|
|
1968
|
+
// in-flight permission promise (otherwise a blocking request leaks until dispose) and cancel a
|
|
1969
|
+
// pending Stop→idle timer (otherwise it fires inside its window and revives this row to a
|
|
1970
|
+
// live zombie).
|
|
1971
|
+
const pending = this._pendingPermissions.get(prev.id);
|
|
1972
|
+
if (pending != null) {
|
|
1973
|
+
this.rejectAllPending(pending, new Error('Session completed'));
|
|
1974
|
+
this._pendingPermissions.delete(prev.id);
|
|
1975
|
+
}
|
|
1976
|
+
this.cancelPendingIdleTransition(prev.id);
|
|
1977
|
+
const bk = this._sessionBookkeeping.get(prev.id);
|
|
1978
|
+
if (bk != null) {
|
|
1979
|
+
this.cancelPendingFileClears(bk);
|
|
1980
|
+
this.cancelPendingReadClears(bk);
|
|
1981
|
+
}
|
|
1982
|
+
this._sessionBookkeeping.delete(prev.id);
|
|
1983
|
+
this._transcriptReader.forget(prev.id);
|
|
1984
|
+
}
|
|
1411
1985
|
pruneDeadSessions() {
|
|
1412
1986
|
const kept = [];
|
|
1413
1987
|
const removedIds = [];
|
|
1414
1988
|
for (const s of this._sessions) {
|
|
1989
|
+
// Completed sessions are terminal and durable — their PID is usually dead by design, so
|
|
1990
|
+
// liveness pruning must never drop them (they leave only via poll reconciliation).
|
|
1991
|
+
if (s.status === 'completed') {
|
|
1992
|
+
kept.push(s);
|
|
1993
|
+
continue;
|
|
1994
|
+
}
|
|
1415
1995
|
// A session blocking on us for a permission decision is by definition alive,
|
|
1416
1996
|
// even if `kill(pid, 0)` says otherwise (e.g. transient EPERM/ESRCH). Dropping
|
|
1417
1997
|
// it here loses pendingPermission and lastPrompt; the next syncSessions then
|
|
@@ -1429,7 +2009,7 @@ export class ClaudeCodeProvider {
|
|
|
1429
2009
|
for (const id of removedIds) {
|
|
1430
2010
|
const pending = this._pendingPermissions.get(id);
|
|
1431
2011
|
if (pending != null) {
|
|
1432
|
-
|
|
2012
|
+
this.rejectAllPending(pending, new Error('Session pruned'));
|
|
1433
2013
|
this._pendingPermissions.delete(id);
|
|
1434
2014
|
}
|
|
1435
2015
|
this.cancelPendingIdleTransition(id);
|
|
@@ -1454,11 +2034,57 @@ export class ClaudeCodeProvider {
|
|
|
1454
2034
|
// sessions exist — that's the only local backstop for pruning agents that die without firing
|
|
1455
2035
|
// `SessionEnd`, and it keeps us correct even if hook detection is stale/wrong. The bootstrap
|
|
1456
2036
|
// call in `ensureIpcServer` passes no options, so cold-start discovery always runs.
|
|
2037
|
+
// Deliberately counts ALL tracked sessions, completed included. A completed row is something
|
|
2038
|
+
// we have to reconcile — it's removed only when the CLI stops listing it (archived from
|
|
2039
|
+
// another window, or aged out) — and this poll is the sole mechanism that does so. It's also
|
|
2040
|
+
// what triggers the CLI's own 30-day retention sweep, which runs on `list-sessions` and
|
|
2041
|
+
// nothing else: no timer, no daemon. Gating it out to save a spawn wouldn't avoid work, it
|
|
2042
|
+
// would strand the rows on screen AND the records on disk until the window reloads.
|
|
1457
2043
|
if (options?.gate && this._sessions.length === 0 && !this._claudeHooksInstalled)
|
|
1458
2044
|
return;
|
|
2045
|
+
// Narrowly: hooks off AND nothing running, i.e. we hold only terminal history. There's no
|
|
2046
|
+
// dying process to reap and no live state to correct — just rows to reconcile and the
|
|
2047
|
+
// retention sweep to trigger, neither of which needs quarter-hourly resolution. Back the
|
|
2048
|
+
// cadence off rather than stopping (window startup polls ungated, so a reload is current).
|
|
2049
|
+
// Hooks ON keeps the full cadence deliberately: there the tick is also the backstop for a
|
|
2050
|
+
// session whose hook events never reached our IPC server.
|
|
2051
|
+
if (options?.gate &&
|
|
2052
|
+
!this._claudeHooksInstalled &&
|
|
2053
|
+
!this._sessions.some(s => s.status !== 'completed') &&
|
|
2054
|
+
Date.now() - this._lastSyncAt < idleReconcileIntervalMs) {
|
|
2055
|
+
return;
|
|
2056
|
+
}
|
|
2057
|
+
// Stamped BEFORE the CLI call so the reconciliation below can tell "this poll's snapshot was
|
|
2058
|
+
// taken after we completed the row" from "an in-flight poll that predates it" — see the
|
|
2059
|
+
// completed-removal filter.
|
|
2060
|
+
const pollStartedAt = Date.now();
|
|
2061
|
+
this._lastSyncAt = pollStartedAt;
|
|
1459
2062
|
let sessions;
|
|
1460
2063
|
try {
|
|
1461
|
-
|
|
2064
|
+
let output;
|
|
2065
|
+
try {
|
|
2066
|
+
// `--status active,ended` surfaces both live sessions and the CLI's durable ended records
|
|
2067
|
+
// (excluding `archived`). Newer CLIs default `list-sessions` to active-only, so the flag
|
|
2068
|
+
// is required to see completed sessions at all.
|
|
2069
|
+
output = await this.callbacks.runCLICommand([
|
|
2070
|
+
'ai',
|
|
2071
|
+
'hook',
|
|
2072
|
+
'list-sessions',
|
|
2073
|
+
'--status',
|
|
2074
|
+
'active,ended',
|
|
2075
|
+
'--json',
|
|
2076
|
+
]);
|
|
2077
|
+
this._statusFilterSupported = true;
|
|
2078
|
+
}
|
|
2079
|
+
catch (ex) {
|
|
2080
|
+
// Only retry on an unknown-flag error — a pre-3.1.69 CLI rejects `--status` but returns
|
|
2081
|
+
// all sessions unfiltered, so the flagless call is the correct legacy equivalent. Real
|
|
2082
|
+
// failures (CLI missing, spawn error) rethrow to the prune-and-return handler below.
|
|
2083
|
+
if (!isUnknownFlagError(ex))
|
|
2084
|
+
throw ex;
|
|
2085
|
+
this._statusFilterSupported = false;
|
|
2086
|
+
output = await this.callbacks.runCLICommand(['ai', 'hook', 'list-sessions', '--json']);
|
|
2087
|
+
}
|
|
1462
2088
|
sessions = JSON.parse(output);
|
|
1463
2089
|
}
|
|
1464
2090
|
catch {
|
|
@@ -1470,27 +2096,252 @@ export class ClaudeCodeProvider {
|
|
|
1470
2096
|
// Snapshot what the live IPC path had already tracked, so we can detect drift between it and
|
|
1471
2097
|
// what the poll returns. Ideally they match exactly — any difference means the live push path
|
|
1472
2098
|
// missed an add (poll discovers it) or a teardown (poll no longer reports a session we track).
|
|
1473
|
-
|
|
2099
|
+
// Drift is measured over *live* sessions only; completed sessions are durable history, so their
|
|
2100
|
+
// absence from `polledAlive` is expected and must not skew the discrepancy signal.
|
|
2101
|
+
const trackedLiveBefore = new Set(this._sessions.filter(s => s.status !== 'completed').map(s => s.id));
|
|
1474
2102
|
const polledAlive = new Set();
|
|
1475
|
-
//
|
|
1476
|
-
//
|
|
1477
|
-
|
|
1478
|
-
|
|
2103
|
+
// Every id the poll returned (live *and* completed). Drives completed-session reconciliation:
|
|
2104
|
+
// a tracked completed session absent from this set was archived/purged by the CLI.
|
|
2105
|
+
const polledIds = new Set();
|
|
2106
|
+
// Accumulates ids already tracked or added during this poll, so the membership check is O(1)
|
|
2107
|
+
// and duplicate ids within a single response are only added once.
|
|
2108
|
+
const known = new Set(this._sessions.map(s => s.id));
|
|
1479
2109
|
let changed = false;
|
|
1480
2110
|
let discovered = 0;
|
|
1481
2111
|
for (const data of sessions) {
|
|
1482
|
-
if (!data.sessionId
|
|
2112
|
+
if (!data.sessionId)
|
|
2113
|
+
continue;
|
|
2114
|
+
// Another AI host's record in the CLI's shared store. Must stay ahead of `polledIds`/
|
|
2115
|
+
// `polledAlive`: an id in either would skew completed-row reconciliation and the
|
|
2116
|
+
// `onSyncDiscrepancy` drift signal below.
|
|
2117
|
+
if (data.providerId != null && data.providerId !== 'claude-code')
|
|
2118
|
+
continue;
|
|
2119
|
+
const effectiveStatus = data.status ?? 'active';
|
|
2120
|
+
// Archived records are excluded by the `active,ended` query; guard anyway.
|
|
2121
|
+
if (effectiveStatus === 'archived')
|
|
2122
|
+
continue;
|
|
2123
|
+
if (effectiveStatus === 'ended') {
|
|
2124
|
+
polledIds.add(data.sessionId);
|
|
2125
|
+
const endedDate = new Date(data.endedAt ?? data.updatedAt);
|
|
2126
|
+
const existingIndex = this._sessions.findIndex(s => s.id === data.sessionId);
|
|
2127
|
+
if (existingIndex >= 0) {
|
|
2128
|
+
// Already tracked. If the live path missed the SessionEnd (e.g. `/clear` kept the
|
|
2129
|
+
// PID alive under a new id), transition it to completed now — otherwise a live-status
|
|
2130
|
+
// row would linger as a zombie, since pruneDeadSessions can't reap a live PID.
|
|
2131
|
+
if (this._sessions[existingIndex].status !== 'completed') {
|
|
2132
|
+
this.completeSession(existingIndex, endedDate);
|
|
2133
|
+
changed = true;
|
|
2134
|
+
}
|
|
2135
|
+
// Re-seat the row on the record's location. The durable record is authoritative for a
|
|
2136
|
+
// terminal session, and a `CwdChanged` hook we never received would otherwise leave the
|
|
2137
|
+
// card attached to the wrong worktree — and "Open Session" resuming from a stale
|
|
2138
|
+
// directory. Deliberately limited to location: prompts/titles have their own on-demand
|
|
2139
|
+
// transcript resolution, which holds richer values than the record does.
|
|
2140
|
+
const existing = this._sessions[existingIndex];
|
|
2141
|
+
const worktreeRoot = worktreeRootFromData(data);
|
|
2142
|
+
const cwdChanged = Boolean(data.cwd) && data.cwd !== existing.cwd;
|
|
2143
|
+
// Pre-`cwdTimeline` records carry no worktree data. If the cwd moved, whatever we hold
|
|
2144
|
+
// is no longer trustworthy — it may point into a different repo entirely — so drop it
|
|
2145
|
+
// rather than leave the card filed under the wrong branch, and re-resolve below. A cwd
|
|
2146
|
+
// change is a discrete event, so that's one probe, not the cold-start fan-out the
|
|
2147
|
+
// completed path exists to avoid.
|
|
2148
|
+
const nextWorktreePath = worktreeRoot != null
|
|
2149
|
+
? normalizePath(worktreeRoot)
|
|
2150
|
+
: cwdChanged
|
|
2151
|
+
? undefined
|
|
2152
|
+
: existing.worktreePath;
|
|
2153
|
+
const worktreeMoved = nextWorktreePath !== existing.worktreePath;
|
|
2154
|
+
if (cwdChanged || worktreeMoved) {
|
|
2155
|
+
const workspacePath = this.resolveWorkspacePath(data.cwd || existing.cwd);
|
|
2156
|
+
this._sessions[existingIndex] = {
|
|
2157
|
+
...existing,
|
|
2158
|
+
cwd: data.cwd || existing.cwd,
|
|
2159
|
+
initialCwd: existing.initialCwd ?? data.initialCwd ?? data.cwd,
|
|
2160
|
+
worktreePath: nextWorktreePath,
|
|
2161
|
+
workspacePath: workspacePath,
|
|
2162
|
+
isInWorkspace: workspacePath != null,
|
|
2163
|
+
// A worktree move can cross repos, and the spread would otherwise carry the old
|
|
2164
|
+
// repo identity forward — which consumers PREFER over the freshly-resolved
|
|
2165
|
+
// worktree metadata, pinning the card to the wrong repo. Drop it and let the
|
|
2166
|
+
// host backfill from the new worktree.
|
|
2167
|
+
commonPath: worktreeMoved ? undefined : existing.commonPath,
|
|
2168
|
+
};
|
|
2169
|
+
changed = true;
|
|
2170
|
+
// Nothing in the record to re-seat it with (older CLI), so resolve the new cwd
|
|
2171
|
+
// once rather than leaving the row location-less.
|
|
2172
|
+
if (nextWorktreePath == null && data.cwd) {
|
|
2173
|
+
void this.resolveGitInfo(data.sessionId, data.cwd);
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
this.getBookkeeping(data.sessionId).polledAtLeastOnce = true;
|
|
2177
|
+
continue;
|
|
2178
|
+
}
|
|
2179
|
+
known.add(data.sessionId);
|
|
2180
|
+
const workspacePath = this.resolveWorkspacePath(data.cwd);
|
|
2181
|
+
const worktreeRoot = worktreeRootFromData(data);
|
|
2182
|
+
this._sessions.push({
|
|
2183
|
+
id: data.sessionId,
|
|
2184
|
+
providerId: this.id,
|
|
2185
|
+
providerName: this.name,
|
|
2186
|
+
name: data.sessionName || undefined,
|
|
2187
|
+
status: 'completed',
|
|
2188
|
+
phase: 'completed',
|
|
2189
|
+
phaseSince: endedDate,
|
|
2190
|
+
pid: data.pid,
|
|
2191
|
+
lastActivity: endedDate,
|
|
2192
|
+
isSubagent: false,
|
|
2193
|
+
workspacePath: workspacePath,
|
|
2194
|
+
// Read straight from the CLI's durable record so completed sessions attach to branch
|
|
2195
|
+
// cards / WIP rows / the resume picker at any age, no git probe.
|
|
2196
|
+
worktreePath: worktreeRoot != null ? normalizePath(worktreeRoot) : undefined,
|
|
2197
|
+
cwd: data.cwd,
|
|
2198
|
+
initialCwd: data.initialCwd ?? data.cwd,
|
|
2199
|
+
planFile: data.planFile ?? undefined,
|
|
2200
|
+
isInWorkspace: workspacePath != null,
|
|
2201
|
+
lastPrompt: prepareStoredPrompt(data.prompt ?? undefined),
|
|
2202
|
+
firstPrompt: prepareStoredPrompt(data.firstPrompt ?? undefined),
|
|
2203
|
+
model: data.model ?? undefined,
|
|
2204
|
+
endReason: data.endReason ?? undefined,
|
|
2205
|
+
endedAt: data.endedAt != null ? new Date(data.endedAt).getTime() : undefined,
|
|
2206
|
+
// Subagents are meaningless once terminal.
|
|
2207
|
+
subagents: undefined,
|
|
2208
|
+
});
|
|
2209
|
+
this.getBookkeeping(data.sessionId).polledAtLeastOnce = true;
|
|
2210
|
+
changed = true;
|
|
2211
|
+
// Titles/prompts still resolve lazily via resolveCompletedSessionDetails (a 30-day
|
|
2212
|
+
// cold-start must not fan out transcript reads). worktreePath comes from the record
|
|
2213
|
+
// above; only fall back to an eager git probe when the record predates that CLI field,
|
|
2214
|
+
// and only for the recent (WIP-window) tail so the fallback stays bounded.
|
|
2215
|
+
if (worktreeRoot == null &&
|
|
2216
|
+
data.cwd &&
|
|
2217
|
+
Date.now() - endedDate.getTime() < recentCompletedGitResolveThresholdMs) {
|
|
2218
|
+
void this.resolveGitInfo(data.sessionId, data.cwd);
|
|
2219
|
+
}
|
|
1483
2220
|
continue;
|
|
1484
2221
|
}
|
|
2222
|
+
// Active / legacy (absent-status) path — only genuinely-live processes are discoverable.
|
|
2223
|
+
if (!data.pid || !isProcessAlive(data.pid))
|
|
2224
|
+
continue;
|
|
1485
2225
|
polledAlive.add(data.sessionId);
|
|
1486
|
-
|
|
2226
|
+
polledIds.add(data.sessionId);
|
|
2227
|
+
if (known.has(data.sessionId)) {
|
|
2228
|
+
// Already tracked.
|
|
2229
|
+
const existingIndex = this._sessions.findIndex(s => s.id === data.sessionId);
|
|
2230
|
+
if (existingIndex >= 0) {
|
|
2231
|
+
const existing = this._sessions[existingIndex];
|
|
2232
|
+
// The status the CLI's last event implies — a resumed session may already be mid-work
|
|
2233
|
+
// (`PreToolUse` → tool_use, `UserPromptSubmit` → thinking), and hardcoding `idle` would
|
|
2234
|
+
// mislabel active work and risk a concurrent-write resume.
|
|
2235
|
+
const polledStatus = deriveStatusFromEvent(data.event);
|
|
2236
|
+
if (existing.status === 'completed') {
|
|
2237
|
+
// We hold it as `completed` but the CLI now reports it as a live process (a
|
|
2238
|
+
// resume the live SessionStart path missed) — revive it, the symmetric backstop
|
|
2239
|
+
// to the ended-branch's live→completed transition above. Leave genuinely-live
|
|
2240
|
+
// rows to the IPC path.
|
|
2241
|
+
const revivedAt = new Date(data.updatedAt);
|
|
2242
|
+
// Only when the record is at least as new as the completion. `SessionEnd` fires
|
|
2243
|
+
// while the process is still winding down, so a poll whose CLI call started
|
|
2244
|
+
// BEFORE it returns a snapshot that still says "active" with a live pid — reviving
|
|
2245
|
+
// off that stale record would resurrect the row, and `pruneDeadSessions` would
|
|
2246
|
+
// then delete it outright once the process exits, losing the completed row.
|
|
2247
|
+
if (revivedAt.getTime() < existing.phaseSince.getTime())
|
|
2248
|
+
continue;
|
|
2249
|
+
// The poll has no blocking hook entry for this ask (it's discovered off the CLI's
|
|
2250
|
+
// durable record, not this window's IPC path) — synthesize an unresolvable one so
|
|
2251
|
+
// the row shows what it's waiting on instead of an empty "Needs input" card.
|
|
2252
|
+
const revivedPendingPermission = polledStatus === 'permission_requested'
|
|
2253
|
+
? synthesizeUnresolvableAsk(data.toolName, data.planFile ?? undefined)
|
|
2254
|
+
: undefined;
|
|
2255
|
+
// Seat worktreePath from the record, mirroring the ended-record re-seat above — the
|
|
2256
|
+
// existing row is otherwise stuck with whatever (or nothing) it had while completed.
|
|
2257
|
+
const revivedWorktreeRoot = worktreeRootFromData(data);
|
|
2258
|
+
// No record worktree data (older CLI): KEEP the completed row's attribution. A
|
|
2259
|
+
// resume whose cwd is scratch space (e.g. /tmp) still belongs to its worktree —
|
|
2260
|
+
// re-rooting happens only when git affirmatively resolves a new one (the probe
|
|
2261
|
+
// below, on success). Attribution persists until something better is known.
|
|
2262
|
+
const revivedWorktreePath = revivedWorktreeRoot != null ? normalizePath(revivedWorktreeRoot) : existing.worktreePath;
|
|
2263
|
+
// Membership follows the attribution, mirroring `ensureSession`: cwd first, the
|
|
2264
|
+
// kept worktree as the fallback for a scratch-dir resume.
|
|
2265
|
+
const revivedWorkspacePath = this.resolveWorkspacePath(data.cwd) ?? this.resolveWorkspacePath(revivedWorktreePath);
|
|
2266
|
+
this._sessions[existingIndex] = {
|
|
2267
|
+
...existing,
|
|
2268
|
+
status: polledStatus,
|
|
2269
|
+
phase: getPhaseForStatus(polledStatus),
|
|
2270
|
+
phaseSince: revivedAt,
|
|
2271
|
+
lastActivity: revivedAt,
|
|
2272
|
+
// Fresh pid from the CLI, and re-classify ownership on next open rather than
|
|
2273
|
+
// trusting the terminal record's `isPeerOwned`.
|
|
2274
|
+
pid: data.pid,
|
|
2275
|
+
isPeerOwned: undefined,
|
|
2276
|
+
pendingPermission: revivedPendingPermission,
|
|
2277
|
+
// The resume can be running somewhere else entirely, so take the record's
|
|
2278
|
+
// location rather than leaving the row filed under where it ended.
|
|
2279
|
+
cwd: data.cwd ?? existing.cwd,
|
|
2280
|
+
workspacePath: revivedWorkspacePath ?? (data.cwd == null ? existing.workspacePath : undefined),
|
|
2281
|
+
isInWorkspace: data.cwd == null ? existing.isInWorkspace : revivedWorkspacePath != null,
|
|
2282
|
+
worktreePath: revivedWorktreePath,
|
|
2283
|
+
// A worktree move can cross repos — drop the stale repo identity and let the
|
|
2284
|
+
// probe below refill it.
|
|
2285
|
+
commonPath: revivedWorktreePath !== existing.worktreePath ? undefined : existing.commonPath,
|
|
2286
|
+
// No longer terminal: leaving these set makes the row read as completed to
|
|
2287
|
+
// consumers that key off them.
|
|
2288
|
+
endReason: undefined,
|
|
2289
|
+
endedAt: undefined,
|
|
2290
|
+
};
|
|
2291
|
+
this.resetBookkeeping(data.sessionId);
|
|
2292
|
+
// The record's worktree is the CLI's own resolution; without one the probe owns
|
|
2293
|
+
// attribution again.
|
|
2294
|
+
this.getBookkeeping(data.sessionId).cliSeatedWorktree = revivedWorktreeRoot != null;
|
|
2295
|
+
if (data.cwd != null && data.cwd !== existing.cwd) {
|
|
2296
|
+
void this.resolveGitInfo(data.sessionId, data.cwd);
|
|
2297
|
+
}
|
|
2298
|
+
changed = true;
|
|
2299
|
+
}
|
|
2300
|
+
else if (existing.pendingPermission?.resolvable === false &&
|
|
2301
|
+
polledStatus !== 'permission_requested' &&
|
|
2302
|
+
new Date(data.updatedAt).getTime() > existing.lastActivity.getTime()) {
|
|
2303
|
+
// The record has moved past the ask this row still shows. Only a synthesized
|
|
2304
|
+
// (`resolvable: false`) ask is repairable this way — a routable one is bookkeeping-
|
|
2305
|
+
// owned and cleared by its own resolution paths, which the record can't see.
|
|
2306
|
+
this._sessions[existingIndex] = {
|
|
2307
|
+
...existing,
|
|
2308
|
+
status: polledStatus,
|
|
2309
|
+
phase: getPhaseForStatus(polledStatus),
|
|
2310
|
+
phaseSince: new Date(data.updatedAt),
|
|
2311
|
+
// The detail described the ask's tool; carry the record's current tool for
|
|
2312
|
+
// tool_use, else clear — keeping it would show the answered ask's tool as
|
|
2313
|
+
// the active one.
|
|
2314
|
+
statusDetail: polledStatus === 'tool_use' ? (data.toolName ?? undefined) : undefined,
|
|
2315
|
+
pendingPermission: undefined,
|
|
2316
|
+
lastActivity: new Date(data.updatedAt),
|
|
2317
|
+
};
|
|
2318
|
+
changed = true;
|
|
2319
|
+
}
|
|
2320
|
+
else if (existing.transcriptTitles?.ai == null && existing.transcriptTitles?.custom == null) {
|
|
2321
|
+
// A live session we discovered via the poll (another worktree/window owns its
|
|
2322
|
+
// hook flow) gets none of the local IPC re-resolves (SessionStart, idle). So one
|
|
2323
|
+
// discovered before Claude wrote its `ai-title` would show the repo slug forever.
|
|
2324
|
+
// Re-check the transcript each poll until a real title lands — cheap: the reader
|
|
2325
|
+
// tail-reads from its mtime cache and no-ops when the file hasn't grown.
|
|
2326
|
+
void this.resolveTranscriptTitles(data.sessionId, data.cwd);
|
|
2327
|
+
}
|
|
2328
|
+
}
|
|
1487
2329
|
continue;
|
|
2330
|
+
}
|
|
1488
2331
|
known.add(data.sessionId);
|
|
1489
2332
|
const workspacePath = this.resolveWorkspacePath(data.cwd);
|
|
1490
2333
|
const isInWorkspace = workspacePath != null;
|
|
1491
2334
|
const status = deriveStatusFromEvent(data.event);
|
|
1492
2335
|
const phase = getPhaseForStatus(status);
|
|
1493
2336
|
const activityDate = new Date(data.updatedAt);
|
|
2337
|
+
// Same rationale as the revived-session branch above: no blocking hook entry exists for a
|
|
2338
|
+
// poll-discovered ask, so synthesize an unresolvable one for the card.
|
|
2339
|
+
const pendingPermission = status === 'permission_requested'
|
|
2340
|
+
? synthesizeUnresolvableAsk(data.toolName, data.planFile ?? undefined)
|
|
2341
|
+
: undefined;
|
|
2342
|
+
// Read straight from the CLI's durable record so the row attaches to the right branch
|
|
2343
|
+
// card/WIP row immediately, no git probe — mirrors the completed-session push below.
|
|
2344
|
+
const worktreeRoot = worktreeRootFromData(data);
|
|
1494
2345
|
const subagents = data.subagents?.map(sub => ({
|
|
1495
2346
|
id: sub.agentId,
|
|
1496
2347
|
providerId: this.id,
|
|
@@ -1525,15 +2376,48 @@ export class ClaudeCodeProvider {
|
|
|
1525
2376
|
isInWorkspace: isInWorkspace,
|
|
1526
2377
|
lastPrompt: prepareStoredPrompt(data.prompt ?? undefined),
|
|
1527
2378
|
firstPrompt: prepareStoredPrompt(data.firstPrompt ?? undefined),
|
|
2379
|
+
model: data.model ?? undefined,
|
|
1528
2380
|
subagents: subagents,
|
|
2381
|
+
pendingPermission: pendingPermission,
|
|
2382
|
+
worktreePath: worktreeRoot != null ? normalizePath(worktreeRoot) : undefined,
|
|
1529
2383
|
});
|
|
1530
2384
|
changed = true;
|
|
1531
2385
|
discovered++;
|
|
2386
|
+
// The record's worktree is the CLI's own resolution — mark it seated so the probe below
|
|
2387
|
+
// (still wanted for `commonPath`) can't clobber it for a nested worktree. Mirrors the
|
|
2388
|
+
// revival branch above.
|
|
2389
|
+
this.getBookkeeping(data.sessionId).cliSeatedWorktree = worktreeRoot != null;
|
|
1532
2390
|
if (data.cwd) {
|
|
1533
2391
|
void this.resolveGitInfo(data.sessionId, data.cwd);
|
|
1534
2392
|
}
|
|
1535
2393
|
void this.resolveTranscriptTitles(data.sessionId, data.cwd);
|
|
1536
2394
|
}
|
|
2395
|
+
// The poll is authoritative for completed-session removal (archived elsewhere or 30-day-purged).
|
|
2396
|
+
// Only remove ones a prior poll confirmed present — never a session the live SessionEnd path just
|
|
2397
|
+
// transitioned, which an already-in-flight poll can legitimately miss (guarded by polledAtLeastOnce).
|
|
2398
|
+
// On a legacy CLI (no `--status`) no poll can ever confirm a completed row, so the guard would
|
|
2399
|
+
// instead pin it forever — there, absence from the poll is the confirmation.
|
|
2400
|
+
const staleCompleted = this._sessions.filter(s => s.status === 'completed' &&
|
|
2401
|
+
!polledIds.has(s.id) &&
|
|
2402
|
+
(!this._statusFilterSupported ||
|
|
2403
|
+
this._sessionBookkeeping.get(s.id)?.polledAtLeastOnce === true ||
|
|
2404
|
+
// Or this poll's snapshot postdates the completion, so its absence is authoritative.
|
|
2405
|
+
// `polledAtLeastOnce` alone can never be set for a session whose record disappeared
|
|
2406
|
+
// (archived from another window, or purged) before any poll observed it — and
|
|
2407
|
+
// `completeSession` drops the bookkeeping — so without this the row is unremovable for
|
|
2408
|
+
// the window's lifetime. The timestamp answers the same question the flag was proxying:
|
|
2409
|
+
// could this poll have seen it? The grace keeps a just-completed row out of reach of
|
|
2410
|
+
// an absence that's really a not-yet-visible record.
|
|
2411
|
+
s.phaseSince.getTime() < pollStartedAt - completedRemovalGraceMs));
|
|
2412
|
+
if (staleCompleted.length > 0) {
|
|
2413
|
+
const staleIds = new Set(staleCompleted.map(s => s.id));
|
|
2414
|
+
this._sessions = this._sessions.filter(s => !staleIds.has(s.id));
|
|
2415
|
+
for (const s of staleCompleted) {
|
|
2416
|
+
this._sessionBookkeeping.delete(s.id);
|
|
2417
|
+
this._transcriptReader.forget(s.id);
|
|
2418
|
+
}
|
|
2419
|
+
changed = true;
|
|
2420
|
+
}
|
|
1537
2421
|
if (this.pruneDeadSessions()) {
|
|
1538
2422
|
changed = true;
|
|
1539
2423
|
}
|
|
@@ -1543,11 +2427,11 @@ export class ClaudeCodeProvider {
|
|
|
1543
2427
|
// Report any drift between the live-synced set and the poll. Only on the recurring (gated)
|
|
1544
2428
|
// poll — the ungated bootstrap call runs before the live path has had a chance to receive
|
|
1545
2429
|
// any events, so its discoveries are expected cold-start state, not drift. `missing` counts
|
|
1546
|
-
// sessions we still track that the poll no longer reports alive (a teardown the live path
|
|
2430
|
+
// live sessions we still track that the poll no longer reports alive (a teardown the live path
|
|
1547
2431
|
// missed, e.g. an agent killed without firing `SessionEnd`).
|
|
1548
2432
|
if (options?.gate) {
|
|
1549
2433
|
let missing = 0;
|
|
1550
|
-
for (const id of
|
|
2434
|
+
for (const id of trackedLiveBefore) {
|
|
1551
2435
|
if (!polledAlive.has(id)) {
|
|
1552
2436
|
missing++;
|
|
1553
2437
|
}
|
|
@@ -1558,7 +2442,7 @@ export class ClaudeCodeProvider {
|
|
|
1558
2442
|
discovered: discovered,
|
|
1559
2443
|
missing: missing,
|
|
1560
2444
|
polled: polledAlive.size,
|
|
1561
|
-
tracked:
|
|
2445
|
+
tracked: trackedLiveBefore.size,
|
|
1562
2446
|
});
|
|
1563
2447
|
}
|
|
1564
2448
|
}
|
|
@@ -1590,6 +2474,12 @@ export class ClaudeCodeProvider {
|
|
|
1590
2474
|
if (peerSessions == null)
|
|
1591
2475
|
continue;
|
|
1592
2476
|
for (const peerSession of peerSessions) {
|
|
2477
|
+
// Skip terminal rows a peer published: completed sessions live in the machine-global CLI
|
|
2478
|
+
// store, so our own poll surfaces them with proper reconciliation. Importing them from a
|
|
2479
|
+
// peer would add a row with no `polledAtLeastOnce` that our poll can't confirm — a ghost
|
|
2480
|
+
// pinned for this window's lifetime if the store later drops it.
|
|
2481
|
+
if (peerSession.status === 'completed')
|
|
2482
|
+
continue;
|
|
1593
2483
|
const peerActivity = new Date(peerSession.lastActivity);
|
|
1594
2484
|
const peerPhaseSince = new Date(peerSession.phaseSince);
|
|
1595
2485
|
const existing = this._sessions.find(s => s.id === peerSession.id);
|
|
@@ -1604,6 +2494,11 @@ export class ClaudeCodeProvider {
|
|
|
1604
2494
|
}
|
|
1605
2495
|
if (peerActivity > existing.lastActivity) {
|
|
1606
2496
|
const idx = this._sessions.indexOf(existing);
|
|
2497
|
+
// A terminal row the peer now reports live IS a resume the peer owns. Mirror the
|
|
2498
|
+
// poll/SessionStart revive paths: take the peer's pid (retaining the terminal
|
|
2499
|
+
// record's dead — and possibly reused — pid would let dispatch focus an unrelated
|
|
2500
|
+
// process) and mark it peer-owned so opens route through the peer's IPC.
|
|
2501
|
+
const revivedFromCompleted = existing.status === 'completed';
|
|
1607
2502
|
// Peer is the authoritative hook recipient for this session, so wipe any
|
|
1608
2503
|
// local bookkeeping that survived from a previous local-ownership window.
|
|
1609
2504
|
// Without this, refcounts/timers from a prior local-hosting stretch would
|
|
@@ -1623,6 +2518,14 @@ export class ClaudeCodeProvider {
|
|
|
1623
2518
|
phaseSince: peerPhaseSince,
|
|
1624
2519
|
statusDetail: peerSession.statusDetail,
|
|
1625
2520
|
lastActivity: peerActivity,
|
|
2521
|
+
// A locally-routable ask wins — a blocking ask fans out to every window, so we
|
|
2522
|
+
// may hold the hook entry the peer lacks. Otherwise mirror the peer's ask marked
|
|
2523
|
+
// unresolvable (we have no entry to answer it), and clear it when the peer did.
|
|
2524
|
+
pendingPermission: this._sessionBookkeeping.get(peerSession.id)?.pendingPermission ??
|
|
2525
|
+
(peerSession.pendingPermission != null
|
|
2526
|
+
? { ...peerSession.pendingPermission, resolvable: false }
|
|
2527
|
+
: undefined),
|
|
2528
|
+
...(revivedFromCompleted ? { pid: peerSession.pid, isPeerOwned: true } : undefined),
|
|
1626
2529
|
subagents: rehydrateSubagents(peerSession.subagents),
|
|
1627
2530
|
// Carry the peer's published fileActivity across so peer-window WIP
|
|
1628
2531
|
// decorations + treemap heatmap follow the agent. Owned by the peer (it's
|
|
@@ -1659,6 +2562,10 @@ export class ClaudeCodeProvider {
|
|
|
1659
2562
|
...peerSession,
|
|
1660
2563
|
lastActivity: peerActivity,
|
|
1661
2564
|
phaseSince: peerPhaseSince,
|
|
2565
|
+
// The peer owns the hook entry, so an imported ask can never be routed from here.
|
|
2566
|
+
pendingPermission: peerSession.pendingPermission != null
|
|
2567
|
+
? { ...peerSession.pendingPermission, resolvable: false }
|
|
2568
|
+
: undefined,
|
|
1662
2569
|
workspacePath: normalizeWorkspacePath(peerSession.workspacePath),
|
|
1663
2570
|
isInWorkspace: this.matchesWorkspace(peerSession.workspacePath),
|
|
1664
2571
|
subagents: rehydrateSubagents(peerSession.subagents),
|