@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
|
@@ -39,6 +39,9 @@ import { ClaudeCodeTranscriptReader } from './claudeCodeTranscript.js';
|
|
|
39
39
|
interface AgentSessionEvent {
|
|
40
40
|
event: ClaudeCodeHookEvent;
|
|
41
41
|
sessionId: string;
|
|
42
|
+
/** `gk ai hook` client id of the originating host (`claude-code`, `codex`, …) — NOT
|
|
43
|
+
* `AgentSession.providerId`. Absent on older CLIs. */
|
|
44
|
+
providerId?: string;
|
|
42
45
|
cwd: string;
|
|
43
46
|
/** The agent's launch directory, captured first-hand by the CLI and preserved across events.
|
|
44
47
|
* Optional — older CLIs don't send it, in which case consumers fall back to deriving it from
|
|
@@ -59,6 +62,10 @@ interface AgentSessionEvent {
|
|
|
59
62
|
toolInput?: Record<string, unknown>;
|
|
60
63
|
permissionSuggestions?: PermissionSuggestion[];
|
|
61
64
|
hookInput?: Record<string, unknown>;
|
|
65
|
+
/** CLI-resolved worktree roots for this session's cwd visits, newest last — same shape as
|
|
66
|
+
* {@link SessionFileData.cwdTimeline}. The CLI runs `rev-parse` at hook time and includes this
|
|
67
|
+
* on every event, even though it isn't part of the raw hook payload. */
|
|
68
|
+
cwdTimeline?: { cwd: string; worktree?: string; at?: string }[];
|
|
62
69
|
}
|
|
63
70
|
|
|
64
71
|
interface PermissionResponse {
|
|
@@ -72,10 +79,15 @@ interface PermissionResponse {
|
|
|
72
79
|
}
|
|
73
80
|
|
|
74
81
|
interface PendingPermissionEntry {
|
|
75
|
-
|
|
76
|
-
|
|
82
|
+
/** All connections waiting on this ask — duplicate deliveries (CLI retry after its 60s
|
|
83
|
+
* response-header timeout) attach here and settle together. */
|
|
84
|
+
resolvers: { resolve(response: PermissionResponse): void; reject(reason: Error): void }[];
|
|
77
85
|
toolName: string;
|
|
78
86
|
toolDescription: string;
|
|
87
|
+
/** From `hookInput.tool_use_id` when the CLI surfaces it; identity gate for stale-clearing. */
|
|
88
|
+
toolUseId?: string;
|
|
89
|
+
/** Serialized tool_input, for duplicate-delivery detection when `toolUseId` is absent. */
|
|
90
|
+
toolInputJson?: string;
|
|
79
91
|
}
|
|
80
92
|
|
|
81
93
|
interface SessionBookkeeping {
|
|
@@ -107,13 +119,48 @@ interface SessionBookkeeping {
|
|
|
107
119
|
* every subsequent hook event. Cleared when the session's cwd changes (re-resolution
|
|
108
120
|
* warranted) or on resetBookkeeping (Stop/SessionStart). */
|
|
109
121
|
gitInfoUnresolvable: boolean;
|
|
122
|
+
/** Set while the session's `worktreePath` came from the CLI (an event's `cwdTimeline`, or a poll
|
|
123
|
+
* record's). The CLI resolves the worktree for the exact cwd at hook time; the local probe answers
|
|
124
|
+
* for whichever repo the host registry matches, which for a nested cwd can be the parent worktree.
|
|
125
|
+
* While set, `resolveGitInfo` keeps the seated value instead of overwriting it. Cleared when the
|
|
126
|
+
* cwd moves without the CLI explaining the new location — attribution goes back to the probe. */
|
|
127
|
+
cliSeatedWorktree?: boolean;
|
|
110
128
|
/** Phase that immediately preceded the current one, with its original `phaseSince`. Used by
|
|
111
129
|
* `resolvePhaseSince` to restore continuity when phase oscillates back within a short
|
|
112
130
|
* window (e.g., Stop → idle → working after a continuation crosses the debounce). */
|
|
113
131
|
priorPhase?: { phase: AgentSessionPhase; phaseSince: Date };
|
|
132
|
+
/** Set once the reconciliation poll has seen this session's durable `ended` record. Gates
|
|
133
|
+
* completed-session removal: a session the live `SessionEnd` path just transitioned is never
|
|
134
|
+
* reconcile-removed by an already-in-flight poll that legitimately missed it — only after a poll
|
|
135
|
+
* has confirmed the CLI still lists it. */
|
|
136
|
+
polledAtLeastOnce?: boolean;
|
|
137
|
+
/** Set once a completed session's transcript has been read on demand (via
|
|
138
|
+
* `resolveCompletedSessionDetails`). Terminal transcripts don't change, so the read happens at
|
|
139
|
+
* most once regardless of how many times the row is opened. */
|
|
140
|
+
completedDetailsResolved?: boolean;
|
|
114
141
|
}
|
|
115
142
|
|
|
116
143
|
const staleCheckIntervalMs = 15 * 60 * 1000; // 15 minutes
|
|
144
|
+
/** Minimum spacing between gated reconciliation polls while NOTHING is running. The quarter-hourly
|
|
145
|
+
* tick exists to reap agents that died without a `SessionEnd`; with no live session there's nothing
|
|
146
|
+
* to reap, leaving only terminal-history reconciliation and the CLI's retention sweep — both fine
|
|
147
|
+
* hourly. Window startup polls ungated, so this never delays a fresh window. */
|
|
148
|
+
const idleReconcileIntervalMs = 60 * 60 * 1000; // 1 hour
|
|
149
|
+
/** How long a freshly-completed row is protected from absence-based removal. The CLI persists the
|
|
150
|
+
* ended record (atomic temp+rename) BEFORE broadcasting the hook event, so a poll that starts after
|
|
151
|
+
* we mark a row completed will list it — this guards only against that ordering changing on the CLI
|
|
152
|
+
* side, where the cost of being wrong is a completed row silently vanishing. */
|
|
153
|
+
const completedRemovalGraceMs = 30 * 1000; // 30 seconds
|
|
154
|
+
/** How long the machine-global archived-session id list (fetched via a separate `--status archived`
|
|
155
|
+
* CLI query) is cached. `getPastSessions` asks for it once per worktree on panel open, so without
|
|
156
|
+
* this a multi-worktree graph spawns N identical CLI processes. `archiveSession` invalidates the
|
|
157
|
+
* cache, so a just-archived row never lingers under "Past". */
|
|
158
|
+
const archivedSessionIdsCacheTtlMs = 10 * 1000; // 10 seconds
|
|
159
|
+
/** Completed sessions defer git + transcript resolution (a 30-day cold-start would otherwise fan out
|
|
160
|
+
* hundreds of probes). Exception: sessions ended within this window still surface on WIP rows /
|
|
161
|
+
* branch cards, which match strictly by resolved `worktreePath` — so their git info is resolved
|
|
162
|
+
* eagerly at poll time (a handful), keeping the older tail lazy. Mirrors the 24h WIP-row window. */
|
|
163
|
+
const recentCompletedGitResolveThresholdMs = 24 * 60 * 60 * 1000; // 24 hours
|
|
117
164
|
/** Default cooldown between PostToolUse and dropping the file from `fileActivity`. Held long
|
|
118
165
|
* enough that the treemap activity overlay can render a decay tail well past the moment the tool
|
|
119
166
|
* call completed. The host may override per-call via `AgentProviderCallbacks.getActivityDecayMs`
|
|
@@ -133,10 +180,18 @@ const phaseSinceRestoreWindowMs = 2000;
|
|
|
133
180
|
|
|
134
181
|
interface SessionFileData {
|
|
135
182
|
sessionId: string;
|
|
183
|
+
/** `gk ai hook` client id of the owning host — see {@link AgentSessionEvent.providerId}. */
|
|
184
|
+
providerId?: string;
|
|
136
185
|
event: string;
|
|
137
186
|
cwd: string;
|
|
138
187
|
/** CLI-provided launch directory; absent on older CLIs (fall back to `cwd`). */
|
|
139
188
|
initialCwd?: string;
|
|
189
|
+
/** Worktree roots (`git rev-parse --show-toplevel`) the CLI resolved for this session's cwd
|
|
190
|
+
* visits, newest last. Absent on older CLIs. Lets us read `worktreePath` straight from the
|
|
191
|
+
* durable store — so completed sessions attach to branch cards / WIP rows / the resume picker at
|
|
192
|
+
* any age without a git probe (the CLI already ran `rev-parse` at hook time). */
|
|
193
|
+
cwdTimeline?: { cwd: string; worktree?: string; at?: string }[];
|
|
194
|
+
worktrees?: string[];
|
|
140
195
|
pid: number;
|
|
141
196
|
toolName?: string | null;
|
|
142
197
|
agentId?: string | null;
|
|
@@ -149,6 +204,102 @@ interface SessionFileData {
|
|
|
149
204
|
sessionName?: string | null;
|
|
150
205
|
prompt?: string | null;
|
|
151
206
|
firstPrompt?: string | null;
|
|
207
|
+
/** CLI durable-session lifecycle status. Absent on legacy files → treated as `active`. `ended`
|
|
208
|
+
* records are surfaced as terminal `completed` sessions; `archived` is excluded by the query. */
|
|
209
|
+
status?: 'active' | 'ended' | 'archived';
|
|
210
|
+
/** Why the session ended (`session-end`, `rotated`, `stale`, `dead-pid`, `pid-zero-idle`,
|
|
211
|
+
* `archived`). Present only on `ended`/`archived` records. */
|
|
212
|
+
endReason?: string;
|
|
213
|
+
/** RFC3339 timestamp the session ended; used as `lastActivity`/`phaseSince` for completed rows. */
|
|
214
|
+
endedAt?: string;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The session's current worktree root from the CLI's durable record — the last cwd-visit's resolved
|
|
218
|
+
* worktree, falling back to the last distinct worktree seen. `undefined` on older CLIs that don't
|
|
219
|
+
* record it, so the caller resolves git the slow way instead. */
|
|
220
|
+
function worktreeRootFromData(data: SessionFileData): string | undefined {
|
|
221
|
+
const fromTimeline = data.cwdTimeline?.at(-1)?.worktree;
|
|
222
|
+
if (fromTimeline) return fromTimeline;
|
|
223
|
+
|
|
224
|
+
return data.worktrees?.at(-1) || undefined;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** The CLI resolves the worktree for `event.cwd` at hook time and includes it on every event —
|
|
228
|
+
* using its answer seats `worktreePath` synchronously, closing the in-flight-probe window where
|
|
229
|
+
* a session that just moved would otherwise still show its previous worktree until `resolveGitInfo`
|
|
230
|
+
* catches up. Scans from the end for the timeline entry matching the event's current cwd. */
|
|
231
|
+
function worktreeFromEvent(event: AgentSessionEvent): string | undefined {
|
|
232
|
+
const timeline = event.cwdTimeline;
|
|
233
|
+
if (timeline == null) return undefined;
|
|
234
|
+
|
|
235
|
+
for (let i = timeline.length - 1; i >= 0; i--) {
|
|
236
|
+
const entry = timeline[i];
|
|
237
|
+
if (entry.cwd === event.cwd) return entry.worktree || undefined;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return undefined;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Synthesizes an unresolvable ask for a `permission_requested` row that has no routable
|
|
244
|
+
* `PendingPermission` — a non-blocking `Notification`/`PermissionRequest` tail, or a
|
|
245
|
+
* poll-discovered live/revived row. None of these hold a blocking hook entry to route an
|
|
246
|
+
* Allow/Deny through, so the card must show what the agent is waiting on without offering
|
|
247
|
+
* actions the host can't fulfill. */
|
|
248
|
+
function synthesizeUnresolvableAsk(toolName: string | null | undefined, planFilePath?: string): PendingPermission {
|
|
249
|
+
if (toolName != null) {
|
|
250
|
+
const kind = classifyPermissionKind(toolName);
|
|
251
|
+
return {
|
|
252
|
+
kind: kind,
|
|
253
|
+
toolName: toolName,
|
|
254
|
+
toolDescription: toolName,
|
|
255
|
+
// A plan ask with no body at least links the plan the agent wrote, so the card can offer
|
|
256
|
+
// View Plan instead of a bare tool name.
|
|
257
|
+
planFilePath: kind === 'plan' ? planFilePath : undefined,
|
|
258
|
+
resolvable: false,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
kind: 'elicitation',
|
|
264
|
+
toolName: 'Input Required',
|
|
265
|
+
toolDescription: 'Waiting for input',
|
|
266
|
+
resolvable: false,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** JSON.stringify with object keys recursively sorted — array order is preserved. `tool_input`
|
|
271
|
+
* can arrive with different key order (or via the top-level fallback field) between deliveries;
|
|
272
|
+
* canonicalizing makes input equality reliable across those variations. */
|
|
273
|
+
function stableStringify(value: unknown): string {
|
|
274
|
+
return JSON.stringify(value, (_key, val: unknown) => {
|
|
275
|
+
if (val == null || typeof val !== 'object' || Array.isArray(val)) return val;
|
|
276
|
+
|
|
277
|
+
const sorted: Record<string, unknown> = {};
|
|
278
|
+
for (const key of Object.keys(val).sort()) {
|
|
279
|
+
sorted[key] = (val as Record<string, unknown>)[key];
|
|
280
|
+
}
|
|
281
|
+
return sorted;
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Field-wise equality for the ask a `permission_requested` row publishes. `updateSessionStatus`
|
|
286
|
+
* compares it before short-circuiting so a second ask arriving while the row is already
|
|
287
|
+
* `permission_requested` (a non-blocking tail for a different tool) still reaches subscribers. */
|
|
288
|
+
function isSameAsk(a: PendingPermission | undefined, b: PendingPermission | undefined): boolean {
|
|
289
|
+
if (a === b) return true;
|
|
290
|
+
if (a == null || b == null) return false;
|
|
291
|
+
|
|
292
|
+
return (
|
|
293
|
+
a.kind === b.kind &&
|
|
294
|
+
a.toolName === b.toolName &&
|
|
295
|
+
a.toolDescription === b.toolDescription &&
|
|
296
|
+
a.toolInputDescription === b.toolInputDescription &&
|
|
297
|
+
a.resolvable === b.resolvable &&
|
|
298
|
+
a.planFilePath === b.planFilePath &&
|
|
299
|
+
a.planSummary === b.planSummary &&
|
|
300
|
+
a.questionText === b.questionText &&
|
|
301
|
+
a.questionCount === b.questionCount
|
|
302
|
+
);
|
|
152
303
|
}
|
|
153
304
|
|
|
154
305
|
interface DiscoveryFile {
|
|
@@ -167,6 +318,10 @@ interface SessionContext {
|
|
|
167
318
|
initialCwd?: string;
|
|
168
319
|
planFile?: string;
|
|
169
320
|
sessionName?: string;
|
|
321
|
+
/** The model the session is running, from the hook event. */
|
|
322
|
+
model?: string;
|
|
323
|
+
/** CLI-resolved worktree root for the current cwd (authoritative when present). */
|
|
324
|
+
worktreePath?: string;
|
|
170
325
|
}
|
|
171
326
|
|
|
172
327
|
type SerializedAgentSession = Omit<AgentSession, 'lastActivity' | 'phaseSince' | 'subagents'> & {
|
|
@@ -218,6 +373,14 @@ function fileActivityStructurallyEqual(
|
|
|
218
373
|
return true;
|
|
219
374
|
}
|
|
220
375
|
|
|
376
|
+
/** True when a CLI invocation failed because it didn't recognize a flag/command. `--status` and the
|
|
377
|
+
* active-only `list-sessions` default shipped together (gk 3.1.69); an older CLI rejects `--status`
|
|
378
|
+
* and returns all sessions unfiltered, so the flagless retry is the correct legacy equivalent. */
|
|
379
|
+
function isUnknownFlagError(ex: unknown): boolean {
|
|
380
|
+
const message = ex instanceof Error ? ex.message : String(ex);
|
|
381
|
+
return /unknown (?:flag|shorthand flag|command)/i.test(message);
|
|
382
|
+
}
|
|
383
|
+
|
|
221
384
|
export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
222
385
|
readonly id = 'claudeCode';
|
|
223
386
|
readonly name = 'Claude Code';
|
|
@@ -231,17 +394,32 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
231
394
|
private _disposed = false;
|
|
232
395
|
private _handlerDisposables: UnifiedDisposable[] = [];
|
|
233
396
|
private readonly _pendingPermissions = new Map<string, PendingPermissionEntry>();
|
|
234
|
-
|
|
397
|
+
/** `sessionId -> cwd currently being probed`, and the newest cwd requested while that probe runs.
|
|
398
|
+
* A probe's answer is only valid for the cwd it was started with, so a session that moves
|
|
399
|
+
* mid-flight must supersede rather than dedupe — otherwise the older lookup lands last and
|
|
400
|
+
* overwrites the newer location with the directory the session already left. */
|
|
401
|
+
private readonly _resolveGitInfoInFlight = new Map<string, string>();
|
|
402
|
+
private readonly _resolveGitInfoPending = new Map<string, string>();
|
|
403
|
+
/** Short-TTL cache of the machine-global archived-session id list. Holds the in-flight promise so
|
|
404
|
+
* concurrent per-worktree `getPastSessions` calls share one CLI spawn; `archiveSession` clears it. */
|
|
405
|
+
private _archivedSessionIdsCache: { promise: Promise<string[]>; at: number } | undefined;
|
|
235
406
|
private readonly _sessionBookkeeping = new Map<string, SessionBookkeeping>();
|
|
236
407
|
/** Per-session timers for the deferred `Stop → idle` commit. The handle is cleared (and the
|
|
237
408
|
* transition cancelled) by any non-idle status update arriving before the timer fires. */
|
|
238
409
|
private readonly _pendingIdleTimers = new Map<string, NodeJS.Timeout>();
|
|
239
410
|
private _workspacePaths: string[] = [];
|
|
240
411
|
private _staleCheckTimer: UnifiedDisposable | undefined;
|
|
412
|
+
/** When the last poll actually ran (gated or not) — paces the idle cadence above. */
|
|
413
|
+
private _lastSyncAt = 0;
|
|
241
414
|
/** Whether Claude hooks are installed, pushed by the host via {@link setClaudeHooksInstalled}.
|
|
242
415
|
* Fail-open (`true`) until the first push lands so a fresh window with real hooks isn't
|
|
243
416
|
* suppressed on its first ticks. Gates the reconciliation poll in {@link syncSessions}. */
|
|
244
417
|
private _claudeHooksInstalled = true;
|
|
418
|
+
/** Whether the CLI supports `list-sessions --status` (and with it the durable ended-session
|
|
419
|
+
* store). Optimistic until the poll's flagless fallback proves otherwise. When unsupported,
|
|
420
|
+
* completed rows are untenable — no poll can ever confirm or archive one — so `SessionEnd`
|
|
421
|
+
* reverts to removing the session and reconciliation drops any completed stragglers. */
|
|
422
|
+
private _statusFilterSupported = true;
|
|
245
423
|
protected _transcriptReader: ClaudeCodeTranscriptReader = new ClaudeCodeTranscriptReader();
|
|
246
424
|
|
|
247
425
|
constructor(private readonly callbacks: AgentProviderCallbacks) {}
|
|
@@ -259,6 +437,10 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
259
437
|
// IPC server stays up intentionally — hooks fire even when the window is unfocused
|
|
260
438
|
}
|
|
261
439
|
|
|
440
|
+
sync(): Promise<void> {
|
|
441
|
+
return this.syncSessions();
|
|
442
|
+
}
|
|
443
|
+
|
|
262
444
|
updateWorkspacePaths(workspacePaths: string[]): void {
|
|
263
445
|
this._workspacePaths = workspacePaths.map(p => normalizePath(p));
|
|
264
446
|
|
|
@@ -352,7 +534,7 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
352
534
|
}
|
|
353
535
|
this._handlerDisposables = [];
|
|
354
536
|
for (const pending of this._pendingPermissions.values()) {
|
|
355
|
-
|
|
537
|
+
this.rejectAllPending(pending, new Error('Provider disposed'));
|
|
356
538
|
}
|
|
357
539
|
this._pendingPermissions.clear();
|
|
358
540
|
for (const bk of this._sessionBookkeeping.values()) {
|
|
@@ -390,16 +572,21 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
390
572
|
}),
|
|
391
573
|
this.callbacks.ipc.registerHandler('agents/sessions/list', () =>
|
|
392
574
|
Promise.resolve(
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
575
|
+
// Exclude terminal `completed` rows: an older-version peer has no `completed` phase and
|
|
576
|
+
// would import them as phantom "idle" sessions. Our own poll surfaces completed sessions
|
|
577
|
+
// independently, so peers never need them from here.
|
|
578
|
+
this._sessions
|
|
579
|
+
.filter(s => s.status !== 'completed')
|
|
580
|
+
.map(s => ({
|
|
581
|
+
...s,
|
|
582
|
+
lastActivity: s.lastActivity.toISOString(),
|
|
583
|
+
phaseSince: s.phaseSince.toISOString(),
|
|
584
|
+
subagents: s.subagents?.map(sub => ({
|
|
585
|
+
...sub,
|
|
586
|
+
lastActivity: sub.lastActivity.toISOString(),
|
|
587
|
+
phaseSince: sub.phaseSince.toISOString(),
|
|
588
|
+
})),
|
|
401
589
|
})),
|
|
402
|
-
})),
|
|
403
590
|
),
|
|
404
591
|
),
|
|
405
592
|
this.callbacks.ipc.registerHandler('agents/sessions/open', async request => {
|
|
@@ -467,6 +654,14 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
467
654
|
}
|
|
468
655
|
|
|
469
656
|
private handleSessionEvent(event: AgentSessionEvent, isBlocking: boolean): Promise<PermissionResponse | void> {
|
|
657
|
+
// The CLI broadcasts every AI host's events to every listener. Absent = accept: older CLIs
|
|
658
|
+
// don't stamp it. A dropped blocking request gets no decision, so the CLI waits out its own
|
|
659
|
+
// hook timeout — correct, since we must not answer for an agent we don't track.
|
|
660
|
+
if (event.providerId != null && event.providerId !== 'claude-code') {
|
|
661
|
+
Logger.debug(`ClaudeCodeProvider.handleSessionEvent: ignoring ${event.event} from ${event.providerId}`);
|
|
662
|
+
return Promise.resolve();
|
|
663
|
+
}
|
|
664
|
+
|
|
470
665
|
const workspacePath = this.resolveWorkspacePath(event.cwd);
|
|
471
666
|
const eventContext: SessionContext = {
|
|
472
667
|
pid: event.pid,
|
|
@@ -476,6 +671,8 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
476
671
|
initialCwd: event.initialCwd,
|
|
477
672
|
planFile: event.planFile,
|
|
478
673
|
sessionName: event.sessionName,
|
|
674
|
+
model: event.model,
|
|
675
|
+
worktreePath: worktreeFromEvent(event),
|
|
479
676
|
};
|
|
480
677
|
const tag = this.sessionTag(event.sessionId, event.sessionName ?? 'unnamed');
|
|
481
678
|
|
|
@@ -503,11 +700,30 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
503
700
|
}
|
|
504
701
|
}
|
|
505
702
|
} else {
|
|
506
|
-
// SessionStart for a session we already track is almost always a CLI replay
|
|
507
|
-
// (resume/reconnect/hook re-init). Don't clobber live state — the next real
|
|
508
|
-
// event will re-establish status. Refresh pid only.
|
|
509
703
|
const prev = this._sessions[index];
|
|
510
|
-
if (
|
|
704
|
+
if (prev.status === 'completed') {
|
|
705
|
+
// Resuming a terminal session reuses its id, so a SessionStart on a completed
|
|
706
|
+
// row IS the resume signal — revive it to a live `idle` row (clearing the
|
|
707
|
+
// completed/archivable state) with clean bookkeeping that completeSession tore
|
|
708
|
+
// down. The next real event advances it out of idle. A `SessionStart` arrives on
|
|
709
|
+
// THIS window's hook flow, so the resume is locally owned now: clear the peer
|
|
710
|
+
// ownership carried over from the terminal record, and take the resume's pid
|
|
711
|
+
// (never the old terminal/reused one — retaining it would let dispatch focus a
|
|
712
|
+
// stale or unrelated process).
|
|
713
|
+
this._sessions[index] = {
|
|
714
|
+
...prev,
|
|
715
|
+
status: 'idle',
|
|
716
|
+
phase: 'idle',
|
|
717
|
+
phaseSince: new Date(),
|
|
718
|
+
lastActivity: new Date(),
|
|
719
|
+
pid: event.pid,
|
|
720
|
+
isPeerOwned: undefined,
|
|
721
|
+
};
|
|
722
|
+
this.resetBookkeeping(event.sessionId);
|
|
723
|
+
} else if (event.pid != null && event.pid !== prev.pid) {
|
|
724
|
+
// SessionStart for a live session we already track is almost always a CLI
|
|
725
|
+
// replay (resume/reconnect/hook re-init). Don't clobber live state — the next
|
|
726
|
+
// real event re-establishes status. Refresh pid only.
|
|
511
727
|
this._sessions[index] = { ...prev, pid: event.pid, lastActivity: new Date() };
|
|
512
728
|
}
|
|
513
729
|
}
|
|
@@ -521,30 +737,54 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
521
737
|
case 'SessionEnd': {
|
|
522
738
|
const pending = this._pendingPermissions.get(event.sessionId);
|
|
523
739
|
if (pending != null) {
|
|
524
|
-
|
|
740
|
+
this.rejectAllPending(pending, new Error('Session ended'));
|
|
525
741
|
this._pendingPermissions.delete(event.sessionId);
|
|
526
742
|
}
|
|
527
743
|
this.cancelPendingIdleTransition(event.sessionId);
|
|
528
744
|
|
|
529
745
|
const index = this._sessions.findIndex(s => s.id === event.sessionId);
|
|
530
746
|
if (index >= 0) {
|
|
531
|
-
this.
|
|
747
|
+
if (this._statusFilterSupported) {
|
|
748
|
+
// Transition to a terminal `completed` row rather than removing it — completed
|
|
749
|
+
// sessions stay visible (de-emphasized) until archived or 30-day-purged by the CLI.
|
|
750
|
+
this.completeSession(index, new Date());
|
|
751
|
+
} else {
|
|
752
|
+
// Legacy CLI (no `--status` durable store): a completed row could never be
|
|
753
|
+
// poll-confirmed nor archived, so keep the pre-completed remove-on-end behavior.
|
|
754
|
+
this._sessions.splice(index, 1);
|
|
755
|
+
const bk = this._sessionBookkeeping.get(event.sessionId);
|
|
756
|
+
if (bk != null) {
|
|
757
|
+
this.cancelPendingFileClears(bk);
|
|
758
|
+
this.cancelPendingReadClears(bk);
|
|
759
|
+
}
|
|
760
|
+
this._sessionBookkeeping.delete(event.sessionId);
|
|
761
|
+
this._transcriptReader.forget(event.sessionId);
|
|
762
|
+
}
|
|
532
763
|
this._onDidChangeSessions.fire();
|
|
764
|
+
} else {
|
|
765
|
+
// SessionEnd for a session we no longer track (already pruned/removed, or a duplicate
|
|
766
|
+
// end): still clear any bookkeeping timers + transcript cache the row left behind, so
|
|
767
|
+
// they don't leak. The tracked branches above already tear this down themselves.
|
|
768
|
+
const bk = this._sessionBookkeeping.get(event.sessionId);
|
|
769
|
+
if (bk != null) {
|
|
770
|
+
this.cancelPendingFileClears(bk);
|
|
771
|
+
this.cancelPendingReadClears(bk);
|
|
772
|
+
}
|
|
773
|
+
this._sessionBookkeeping.delete(event.sessionId);
|
|
774
|
+
this._transcriptReader.forget(event.sessionId);
|
|
533
775
|
}
|
|
534
|
-
const bk = this._sessionBookkeeping.get(event.sessionId);
|
|
535
|
-
if (bk != null) {
|
|
536
|
-
this.cancelPendingFileClears(bk);
|
|
537
|
-
this.cancelPendingReadClears(bk);
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
this._sessionBookkeeping.delete(event.sessionId);
|
|
541
|
-
this._transcriptReader.forget(event.sessionId);
|
|
542
776
|
this.callbacks.onSessionEnded?.(this.id);
|
|
543
777
|
break;
|
|
544
778
|
}
|
|
545
779
|
|
|
546
780
|
case 'UserPromptSubmit': {
|
|
547
|
-
const { index } = this.ensureSession(event.sessionId, eventContext);
|
|
781
|
+
const { index, changed } = this.ensureSession(event.sessionId, eventContext);
|
|
782
|
+
// Seated metadata (location, model, name) has to reach subscribers even when the prompt
|
|
783
|
+
// turns out to be synthetic and the status update below never runs.
|
|
784
|
+
if (changed) {
|
|
785
|
+
this._onDidChangeSessions.fire();
|
|
786
|
+
}
|
|
787
|
+
|
|
548
788
|
const cleaned = prepareStoredPrompt(event.prompt);
|
|
549
789
|
// A `UserPromptSubmit` whose payload sanitizes to nothing is harness-synthetic
|
|
550
790
|
// (e.g. background-bash <task-notification>, slash-command stdout echo). Treat it
|
|
@@ -596,7 +836,7 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
596
836
|
|
|
597
837
|
case 'PostToolUse':
|
|
598
838
|
case 'PostToolUseFailure': {
|
|
599
|
-
this.clearStalePermission(event.sessionId, event.event);
|
|
839
|
+
this.clearStalePermission(event.sessionId, event.event, this.settledByFromEvent(event));
|
|
600
840
|
const bk = this.getBookkeeping(event.sessionId);
|
|
601
841
|
bk.activeToolCount = Math.max(0, bk.activeToolCount - 1);
|
|
602
842
|
// Cooldown the file-edit decoration instead of dropping it immediately, so a quick
|
|
@@ -631,7 +871,7 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
631
871
|
case 'StopFailure': {
|
|
632
872
|
const pending = this._pendingPermissions.get(event.sessionId);
|
|
633
873
|
if (pending != null) {
|
|
634
|
-
|
|
874
|
+
this.rejectAllPending(pending, new Error('Session stopped'));
|
|
635
875
|
this._pendingPermissions.delete(event.sessionId);
|
|
636
876
|
}
|
|
637
877
|
// Turn ended: stop the "live" pulse but keep the per-operation decay tail fading over
|
|
@@ -667,10 +907,30 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
667
907
|
// No-op — session is already idle from the preceding stop/session-start event
|
|
668
908
|
break;
|
|
669
909
|
case 'permission_prompt':
|
|
910
|
+
this.updateSessionStatus(event.sessionId, 'permission_requested', {
|
|
911
|
+
...eventContext,
|
|
912
|
+
statusDetail: event.toolName,
|
|
913
|
+
pendingPermission: synthesizeUnresolvableAsk(
|
|
914
|
+
event.toolName,
|
|
915
|
+
// Plan-kind body: prefer the session's planFile, fall back to the event's
|
|
916
|
+
// — mirrors the blocking path.
|
|
917
|
+
this._sessions.find(s => s.id === event.sessionId)?.planFile ?? event.planFile,
|
|
918
|
+
),
|
|
919
|
+
});
|
|
920
|
+
break;
|
|
670
921
|
case 'elicitation_dialog':
|
|
922
|
+
// An MCP elicitation, not a tool permission — `toolName` names the elicitation, so
|
|
923
|
+
// classifying it as a tool ask would mislabel the card. Same shape the `Elicitation`
|
|
924
|
+
// hook produces.
|
|
671
925
|
this.updateSessionStatus(event.sessionId, 'permission_requested', {
|
|
672
926
|
...eventContext,
|
|
673
927
|
statusDetail: event.toolName,
|
|
928
|
+
pendingPermission: {
|
|
929
|
+
kind: 'elicitation',
|
|
930
|
+
toolName: event.toolName ?? 'Input Required',
|
|
931
|
+
toolDescription: event.toolName ?? 'Waiting for input',
|
|
932
|
+
resolvable: false,
|
|
933
|
+
},
|
|
674
934
|
});
|
|
675
935
|
break;
|
|
676
936
|
default:
|
|
@@ -688,14 +948,37 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
688
948
|
const toolDescription = describeToolInput(toolName, toolInput);
|
|
689
949
|
const toolInputDescription = (toolInput.description as string | undefined) || undefined;
|
|
690
950
|
const kind: PendingPermissionKind = classifyPermissionKind(toolName);
|
|
951
|
+
const toolUseId = hookInput?.tool_use_id as string | undefined;
|
|
952
|
+
const toolInputJson = stableStringify(toolInput);
|
|
691
953
|
|
|
692
954
|
return new Promise<PermissionResponse>((resolve, reject) => {
|
|
693
955
|
const existing = this._pendingPermissions.get(event.sessionId);
|
|
956
|
+
// Same identity as `settledByFromEvent`'s comparisons: `toolUseId` when both sides
|
|
957
|
+
// have one, else toolName + canonical input.
|
|
958
|
+
const isDuplicateDelivery =
|
|
959
|
+
existing != null &&
|
|
960
|
+
((existing.toolUseId != null && toolUseId != null && existing.toolUseId === toolUseId) ||
|
|
961
|
+
(existing.toolUseId == null &&
|
|
962
|
+
toolUseId == null &&
|
|
963
|
+
existing.toolName === toolName &&
|
|
964
|
+
existing.toolInputJson === toolInputJson));
|
|
965
|
+
|
|
966
|
+
if (isDuplicateDelivery) {
|
|
967
|
+
// Duplicate delivery of the same ask (CLI retry after its 60s response-header
|
|
968
|
+
// timeout) — attach instead of replacing so both connections settle together.
|
|
969
|
+
// Nothing about the display changes, so skip rebuilding it below. Accepted
|
|
970
|
+
// residual: two independent, identical, concurrent asks are byte-for-byte
|
|
971
|
+
// indistinguishable on the wire and coalesce the same way, receiving one
|
|
972
|
+
// decision — evicting instead would falsely deny a live ask.
|
|
973
|
+
existing.resolvers.push({ resolve: resolve, reject: reject });
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
|
|
694
977
|
if (existing != null) {
|
|
695
978
|
Logger.debug(
|
|
696
979
|
`ClaudeCodeProvider.handleSessionEvent: auto-denying stale permission ${tag} tool=${existing.toolName}`,
|
|
697
980
|
);
|
|
698
|
-
|
|
981
|
+
this.resolveAllPending(existing, {
|
|
699
982
|
hookSpecificOutput: {
|
|
700
983
|
hookEventName: 'PermissionRequest',
|
|
701
984
|
decision: { behavior: 'deny' },
|
|
@@ -704,10 +987,11 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
704
987
|
}
|
|
705
988
|
|
|
706
989
|
this._pendingPermissions.set(event.sessionId, {
|
|
707
|
-
resolve: resolve,
|
|
708
|
-
reject: reject,
|
|
990
|
+
resolvers: [{ resolve: resolve, reject: reject }],
|
|
709
991
|
toolName: toolName,
|
|
710
992
|
toolDescription: toolDescription,
|
|
993
|
+
toolUseId: toolUseId,
|
|
994
|
+
toolInputJson: toolInputJson,
|
|
711
995
|
});
|
|
712
996
|
|
|
713
997
|
// Plan-kind body: prefer the existing session's planFile (set via SessionStart/
|
|
@@ -732,19 +1016,50 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
732
1016
|
questionText: questionDetails?.text,
|
|
733
1017
|
questionCount: questionDetails?.count,
|
|
734
1018
|
};
|
|
735
|
-
this.updateSessionWithPermission(event.sessionId, permission,
|
|
1019
|
+
this.updateSessionWithPermission(event.sessionId, permission, eventContext);
|
|
736
1020
|
});
|
|
737
1021
|
}
|
|
738
1022
|
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
1023
|
+
{
|
|
1024
|
+
// Mirror the blocking payload's shape so the ask renders identically either way —
|
|
1025
|
+
// just unresolvable, since no blocking hook entry exists to route an answer through.
|
|
1026
|
+
const toolName = (hookInput?.tool_name as string | undefined) ?? event.toolName;
|
|
1027
|
+
const kind = toolName != null ? classifyPermissionKind(toolName) : undefined;
|
|
1028
|
+
// Plan-kind body: same session-first preference as the blocking path.
|
|
1029
|
+
const planFilePath =
|
|
1030
|
+
kind === 'plan'
|
|
1031
|
+
? (this._sessions.find(s => s.id === event.sessionId)?.planFile ?? event.planFile)
|
|
1032
|
+
: undefined;
|
|
1033
|
+
// Same body extraction as the blocking path — surfaces render `questionText`/
|
|
1034
|
+
// `planSummary` in place of the tool description, so omitting them would show a
|
|
1035
|
+
// generic "awaiting" card for a question the input actually carries.
|
|
1036
|
+
const questionDetails =
|
|
1037
|
+
kind === 'question' && toolInput != null ? extractQuestionDetails(toolInput) : undefined;
|
|
1038
|
+
const synthesized: PendingPermission =
|
|
1039
|
+
kind != null && toolName != null && toolInput != null
|
|
1040
|
+
? {
|
|
1041
|
+
kind: kind,
|
|
1042
|
+
toolName: toolName,
|
|
1043
|
+
toolDescription: describeToolInput(toolName, toolInput),
|
|
1044
|
+
toolInputDescription: (toolInput.description as string | undefined) || undefined,
|
|
1045
|
+
planFilePath: planFilePath,
|
|
1046
|
+
planSummary: kind === 'plan' ? extractPlanSummary(toolInput) : undefined,
|
|
1047
|
+
questionText: questionDetails?.text,
|
|
1048
|
+
questionCount: questionDetails?.count,
|
|
1049
|
+
resolvable: false,
|
|
1050
|
+
}
|
|
1051
|
+
: synthesizeUnresolvableAsk(toolName, planFilePath);
|
|
1052
|
+
this.updateSessionStatus(event.sessionId, 'permission_requested', {
|
|
1053
|
+
...eventContext,
|
|
1054
|
+
statusDetail: event.toolName,
|
|
1055
|
+
pendingPermission: synthesized,
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
743
1058
|
break;
|
|
744
1059
|
}
|
|
745
1060
|
|
|
746
1061
|
case 'PermissionDenied': {
|
|
747
|
-
this.clearStalePermission(event.sessionId, 'PermissionDenied');
|
|
1062
|
+
this.clearStalePermission(event.sessionId, 'PermissionDenied', this.settledByFromEvent(event));
|
|
748
1063
|
const bk = this.getBookkeeping(event.sessionId);
|
|
749
1064
|
bk.activeToolCount = Math.max(0, bk.activeToolCount - 1);
|
|
750
1065
|
const filesChanged = this.untrackToolUseFile(event.sessionId, event);
|
|
@@ -769,6 +1084,9 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
769
1084
|
kind: 'elicitation',
|
|
770
1085
|
toolName: event.toolName ?? 'Input Required',
|
|
771
1086
|
toolDescription: event.toolName ?? 'Waiting for input',
|
|
1087
|
+
// `Elicitation` arrives on a non-blocking hook, so no entry exists for
|
|
1088
|
+
// `resolvePermission` to answer — the user responds in the agent's own session.
|
|
1089
|
+
resolvable: false,
|
|
772
1090
|
};
|
|
773
1091
|
this.updateSessionStatus(event.sessionId, 'permission_requested', {
|
|
774
1092
|
...eventContext,
|
|
@@ -790,16 +1108,15 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
790
1108
|
|
|
791
1109
|
case 'CwdChanged':
|
|
792
1110
|
if (event.cwd) {
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
1111
|
+
// Route through `ensureSession`: it owns the whole location transition — timeline
|
|
1112
|
+
// seat (or handing attribution back to the probe when the CLI didn't explain the
|
|
1113
|
+
// move), workspacePath/isInWorkspace recompute, the unresolvable-flag clear, and
|
|
1114
|
+
// the probe gating. Writing `cwd` directly here left `cliSeatedWorktree` set, so
|
|
1115
|
+
// the probe's answer for the new cwd could never replace the stale seated one.
|
|
1116
|
+
const { changed } = this.ensureSession(event.sessionId, eventContext);
|
|
1117
|
+
if (changed) {
|
|
796
1118
|
this._onDidChangeSessions.fire();
|
|
797
1119
|
}
|
|
798
|
-
// Clear the unresolvable flag so the new cwd actually gets re-probed — without
|
|
799
|
-
// this, a session that started in a non-git cwd would stay flagged forever even
|
|
800
|
-
// after moving into a git repo.
|
|
801
|
-
this.getBookkeeping(event.sessionId).gitInfoUnresolvable = false;
|
|
802
|
-
void this.resolveGitInfo(event.sessionId, event.cwd);
|
|
803
1120
|
}
|
|
804
1121
|
break;
|
|
805
1122
|
|
|
@@ -918,6 +1235,8 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
918
1235
|
bk.currentReadCounts.clear();
|
|
919
1236
|
bk.lastTouchedAt.clear();
|
|
920
1237
|
bk.gitInfoUnresolvable = false;
|
|
1238
|
+
// `cliSeatedWorktree` deliberately survives: SessionStart seats the worktree via
|
|
1239
|
+
// `ensureSession` BEFORE resetting bookkeeping, and an unexplained cwd move clears it anyway.
|
|
921
1240
|
const parentId = this.findOwningParentId(sessionId);
|
|
922
1241
|
if (parentId != null) {
|
|
923
1242
|
this.syncSessionFileActivity(parentId);
|
|
@@ -1318,8 +1637,10 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1318
1637
|
this.cancelPendingIdleTransition(sessionId);
|
|
1319
1638
|
const timer = setTimeout(() => {
|
|
1320
1639
|
this._pendingIdleTimers.delete(sessionId);
|
|
1321
|
-
// Session may have been removed (SessionEnd, prune) during the window
|
|
1322
|
-
|
|
1640
|
+
// Session may have been removed (SessionEnd, prune) during the window, or completed by a
|
|
1641
|
+
// poll/SessionEnd that raced this timer — reviving a terminal row to idle would zombie it.
|
|
1642
|
+
const idx = this._sessions.findIndex(s => s.id === sessionId);
|
|
1643
|
+
if (idx < 0 || this._sessions[idx].status === 'completed') return;
|
|
1323
1644
|
|
|
1324
1645
|
this.updateSessionStatus(sessionId, 'idle');
|
|
1325
1646
|
}, stopToIdleDebounceMs);
|
|
@@ -1358,22 +1679,107 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1358
1679
|
return now;
|
|
1359
1680
|
}
|
|
1360
1681
|
|
|
1682
|
+
/** Extracts the ask identity a settlement event refers to. `PostToolUse`/`PermissionDenied`
|
|
1683
|
+
* carry `hookInput.tool_use_id` and `hookInput.tool_input` in production; the top-level
|
|
1684
|
+
* `event.toolName`/`event.toolInput` are the older-CLI fallback. Input is canonicalized so key
|
|
1685
|
+
* order (or arriving via the fallback field instead of `hookInput`) doesn't defeat comparison. */
|
|
1686
|
+
private settledByFromEvent(event: AgentSessionEvent): {
|
|
1687
|
+
toolUseId?: string;
|
|
1688
|
+
toolName?: string;
|
|
1689
|
+
toolInputJson?: string;
|
|
1690
|
+
} {
|
|
1691
|
+
const hookInput = event.hookInput;
|
|
1692
|
+
const toolInput = (hookInput?.tool_input as Record<string, unknown> | undefined) ?? event.toolInput;
|
|
1693
|
+
return {
|
|
1694
|
+
toolUseId: hookInput?.tool_use_id as string | undefined,
|
|
1695
|
+
toolName: (hookInput?.tool_name as string | undefined) ?? event.toolName,
|
|
1696
|
+
toolInputJson: toolInput != null ? stableStringify(toolInput) : undefined,
|
|
1697
|
+
};
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
private resolveAllPending(entry: PendingPermissionEntry, response: PermissionResponse): void {
|
|
1701
|
+
for (const resolver of entry.resolvers) {
|
|
1702
|
+
resolver.resolve(response);
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
private rejectAllPending(entry: PendingPermissionEntry, reason: Error): void {
|
|
1707
|
+
for (const resolver of entry.resolvers) {
|
|
1708
|
+
resolver.reject(reason);
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1361
1712
|
// Called when a pending permission was resolved outside GitLens (e.g. via the CLI terminal).
|
|
1362
1713
|
// The 'deny' response is a safe no-op since the tool has already run or been denied upstream.
|
|
1363
|
-
|
|
1714
|
+
// `settledBy` identifies the ask the settling event actually refers to — out-of-order/duplicate
|
|
1715
|
+
// hook deliveries (PostToolUse for an unrelated tool, a PermissionDenied for an already-evicted
|
|
1716
|
+
// earlier ask) must never deny a still-live blocking ask just because it shares a session id.
|
|
1717
|
+
// `PermissionDenied` itself only ever fires after Claude Code's auto-mode classifier denies a
|
|
1718
|
+
// tool call — never as an echo of a deny a `PermissionRequest` response sent.
|
|
1719
|
+
private clearStalePermission(
|
|
1720
|
+
sessionId: string,
|
|
1721
|
+
reason: string,
|
|
1722
|
+
settledBy?: { toolUseId?: string; toolName?: string; toolInputJson?: string },
|
|
1723
|
+
): void {
|
|
1364
1724
|
const bk = this.getBookkeeping(sessionId);
|
|
1365
|
-
if (bk.pendingPermission == null) return;
|
|
1366
|
-
|
|
1367
|
-
Logger.debug(`ClaudeCodeProvider.clearStalePermission: ${reason} ${this.sessionTag(sessionId)}`);
|
|
1368
|
-
|
|
1369
1725
|
const pending = this._pendingPermissions.get(sessionId);
|
|
1726
|
+
|
|
1370
1727
|
if (pending != null) {
|
|
1371
|
-
|
|
1728
|
+
if (settledBy != null) {
|
|
1729
|
+
let mismatch = false;
|
|
1730
|
+
|
|
1731
|
+
if (pending.toolUseId != null && settledBy.toolUseId != null) {
|
|
1732
|
+
// Rule 1: both sides identified — the only trustworthy comparison. `PermissionDenied`/
|
|
1733
|
+
// `PostToolUse` hookInput DOES carry `tool_use_id`, but `PermissionRequest` never does,
|
|
1734
|
+
// so `pending` never actually has one — this branch only engages if a future CLI adds
|
|
1735
|
+
// it to requests too. The asymmetry is deliberate, not a gap to close.
|
|
1736
|
+
mismatch = pending.toolUseId !== settledBy.toolUseId;
|
|
1737
|
+
} else if (pending.toolName === '') {
|
|
1738
|
+
// Rule 2: no identity was captured at registration (degraded payload) — nothing to
|
|
1739
|
+
// gate on, so clear unconditionally rather than stranding the ask forever.
|
|
1740
|
+
mismatch = false;
|
|
1741
|
+
} else {
|
|
1742
|
+
// Rule 3: name must match; when both sides have input, it must match too. A
|
|
1743
|
+
// PostToolUse for a different invocation of the same tool (different input) leaves
|
|
1744
|
+
// the ask untouched.
|
|
1745
|
+
mismatch =
|
|
1746
|
+
settledBy.toolName !== pending.toolName ||
|
|
1747
|
+
(pending.toolInputJson != null &&
|
|
1748
|
+
settledBy.toolInputJson != null &&
|
|
1749
|
+
pending.toolInputJson !== settledBy.toolInputJson);
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
if (mismatch) {
|
|
1753
|
+
Logger.debug(
|
|
1754
|
+
`ClaudeCodeProvider.clearStalePermission: skip (identity mismatch) ${reason} ${this.sessionTag(sessionId)} pending=${pending.toolUseId ?? pending.toolName} settledBy=${settledBy.toolUseId ?? settledBy.toolName ?? 'unknown'}`,
|
|
1755
|
+
);
|
|
1756
|
+
return;
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
Logger.debug(`ClaudeCodeProvider.clearStalePermission: ${reason} ${this.sessionTag(sessionId)}`);
|
|
1761
|
+
this.resolveAllPending(pending, {
|
|
1372
1762
|
hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: { behavior: 'deny' } },
|
|
1373
1763
|
});
|
|
1374
1764
|
this._pendingPermissions.delete(sessionId);
|
|
1765
|
+
bk.pendingPermission = undefined;
|
|
1766
|
+
return;
|
|
1375
1767
|
}
|
|
1376
1768
|
|
|
1769
|
+
if (bk.pendingPermission == null) return;
|
|
1770
|
+
|
|
1771
|
+
if (
|
|
1772
|
+
settledBy?.toolName != null &&
|
|
1773
|
+
bk.pendingPermission.toolName != null &&
|
|
1774
|
+
settledBy.toolName !== bk.pendingPermission.toolName
|
|
1775
|
+
) {
|
|
1776
|
+
Logger.debug(
|
|
1777
|
+
`ClaudeCodeProvider.clearStalePermission: skip (display-only identity mismatch) ${reason} ${this.sessionTag(sessionId)} pending=${bk.pendingPermission.toolName} settledBy=${settledBy.toolName}`,
|
|
1778
|
+
);
|
|
1779
|
+
return;
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
Logger.debug(`ClaudeCodeProvider.clearStalePermission: ${reason} ${this.sessionTag(sessionId)}`);
|
|
1377
1783
|
bk.pendingPermission = undefined;
|
|
1378
1784
|
}
|
|
1379
1785
|
|
|
@@ -1388,7 +1794,7 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1388
1794
|
Logger.debug(
|
|
1389
1795
|
`ClaudeCodeProvider.resolvePermission: ${decision} ${this.sessionTag(sessionId)} tool=${pending.toolName}`,
|
|
1390
1796
|
);
|
|
1391
|
-
|
|
1797
|
+
this.resolveAllPending(pending, {
|
|
1392
1798
|
hookSpecificOutput: {
|
|
1393
1799
|
hookEventName: 'PermissionRequest',
|
|
1394
1800
|
decision: { behavior: decision, updatedPermissions: updatedPermissions },
|
|
@@ -1409,6 +1815,138 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1409
1815
|
return true;
|
|
1410
1816
|
}
|
|
1411
1817
|
|
|
1818
|
+
async archiveSession(sessionId: string): Promise<boolean> {
|
|
1819
|
+
// Authoritative status guard: the CLI's `archive-session` ends an active session first, so a
|
|
1820
|
+
// stale webview action (or a row that resumed out of `completed` since the click) must never
|
|
1821
|
+
// reach the CLI — archiving is only ever valid on a terminal row. Callers gate the UI too,
|
|
1822
|
+
// but this is the last line that owns live process safety. Returns `false` when refused so the
|
|
1823
|
+
// host doesn't record a success telemetry event for an archive that never happened.
|
|
1824
|
+
const session = this._sessions.find(s => s.id === sessionId);
|
|
1825
|
+
if (session != null && session.status !== 'completed') {
|
|
1826
|
+
Logger.warn(
|
|
1827
|
+
`ClaudeCodeProvider.archiveSession: refusing to archive non-completed ${this.sessionTag(sessionId)} (status=${session.status})`,
|
|
1828
|
+
);
|
|
1829
|
+
return false;
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
Logger.debug(`ClaudeCodeProvider.archiveSession: ${this.sessionTag(sessionId)}`);
|
|
1833
|
+
await this.callbacks.runCLICommand(['ai', 'hook', 'archive-session', sessionId, '--json']);
|
|
1834
|
+
// Invalidate the archived-id cache so the next `getPastSessions` re-fetches and excludes this id.
|
|
1835
|
+
this._archivedSessionIdsCache = undefined;
|
|
1836
|
+
// Optimistically drop it — archived sessions are excluded from the `active,ended` poll, so the
|
|
1837
|
+
// next reconciliation confirms the removal regardless.
|
|
1838
|
+
const index = this._sessions.findIndex(s => s.id === sessionId);
|
|
1839
|
+
if (index >= 0) {
|
|
1840
|
+
this._sessions.splice(index, 1);
|
|
1841
|
+
this._sessionBookkeeping.delete(sessionId);
|
|
1842
|
+
this._transcriptReader.forget(sessionId);
|
|
1843
|
+
this._onDidChangeSessions.fire();
|
|
1844
|
+
}
|
|
1845
|
+
return true;
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
/** Lists the ids of sessions the CLI has archived, so callers (the "Past" transcript listing) can
|
|
1849
|
+
* exclude them — the tracked row is gone, but the transcript on disk survives and would otherwise
|
|
1850
|
+
* resurface there. Resolves to `[]` on any error, including a CLI too old to support the
|
|
1851
|
+
* `archived` status filter. */
|
|
1852
|
+
async getArchivedSessionIds(): Promise<string[]> {
|
|
1853
|
+
const cached = this._archivedSessionIdsCache;
|
|
1854
|
+
if (cached != null && Date.now() - cached.at < archivedSessionIdsCacheTtlMs) {
|
|
1855
|
+
return cached.promise;
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
const promise = this.fetchArchivedSessionIds();
|
|
1859
|
+
this._archivedSessionIdsCache = { promise: promise, at: Date.now() };
|
|
1860
|
+
return promise;
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
private async fetchArchivedSessionIds(): Promise<string[]> {
|
|
1864
|
+
try {
|
|
1865
|
+
const output = await this.callbacks.runCLICommand([
|
|
1866
|
+
'ai',
|
|
1867
|
+
'hook',
|
|
1868
|
+
'list-sessions',
|
|
1869
|
+
'--status',
|
|
1870
|
+
'archived',
|
|
1871
|
+
'--json',
|
|
1872
|
+
]);
|
|
1873
|
+
const sessions = JSON.parse(output) as SessionFileData[];
|
|
1874
|
+
return sessions.map(s => s.sessionId).filter(id => id.length > 0);
|
|
1875
|
+
} catch {
|
|
1876
|
+
return [];
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
resolveCompletedSessionDetails(sessionId: string): void {
|
|
1881
|
+
const session = this._sessions.find(s => s.id === sessionId);
|
|
1882
|
+
// Only completed sessions defer resolution; live sessions resolve eagerly on discovery.
|
|
1883
|
+
if (session?.status !== 'completed') return;
|
|
1884
|
+
|
|
1885
|
+
// The durable store drops the prompt (and only ever carries an auto-slug name) for most ended
|
|
1886
|
+
// sessions, so read the terminal transcript once on demand to recover a real title + the
|
|
1887
|
+
// first/last prompt. Both the git probe and transcript read sit under the guard so repeated
|
|
1888
|
+
// opens don't re-probe git or re-read a possibly-large file.
|
|
1889
|
+
const bk = this.getBookkeeping(sessionId);
|
|
1890
|
+
if (bk.completedDetailsResolved) return;
|
|
1891
|
+
|
|
1892
|
+
bk.completedDetailsResolved = true;
|
|
1893
|
+
|
|
1894
|
+
if (session.cwd) {
|
|
1895
|
+
void this.resolveGitInfo(sessionId, session.cwd);
|
|
1896
|
+
}
|
|
1897
|
+
void this.applyCompletedTranscriptDetails(sessionId, session.cwd);
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
private async applyCompletedTranscriptDetails(sessionId: string, cwd: string | undefined): Promise<void> {
|
|
1901
|
+
let details;
|
|
1902
|
+
try {
|
|
1903
|
+
details = await this._transcriptReader.resolveCompletedDetails(sessionId, cwd);
|
|
1904
|
+
} catch {
|
|
1905
|
+
// A read error (transient I/O, permissions) is not terminal state — clear the once-only
|
|
1906
|
+
// guard so a later open retries. A `undefined` result IS terminal (no transcript on disk),
|
|
1907
|
+
// so it keeps the guard and never re-scans.
|
|
1908
|
+
const bk = this._sessionBookkeeping.get(sessionId);
|
|
1909
|
+
if (bk != null) {
|
|
1910
|
+
bk.completedDetailsResolved = false;
|
|
1911
|
+
}
|
|
1912
|
+
return;
|
|
1913
|
+
}
|
|
1914
|
+
if (details == null) return;
|
|
1915
|
+
|
|
1916
|
+
const index = this._sessions.findIndex(s => s.id === sessionId);
|
|
1917
|
+
if (index < 0) return;
|
|
1918
|
+
|
|
1919
|
+
const session = this._sessions[index];
|
|
1920
|
+
// The session may have been archived/removed while we were reading.
|
|
1921
|
+
if (session.status !== 'completed') return;
|
|
1922
|
+
|
|
1923
|
+
const { titles } = details;
|
|
1924
|
+
const nextTitles =
|
|
1925
|
+
titles.custom != null || titles.ai != null || titles.agent != null
|
|
1926
|
+
? { custom: titles.custom, ai: titles.ai, agent: titles.agent }
|
|
1927
|
+
: session.transcriptTitles;
|
|
1928
|
+
// Durable-store prompts (full text) win over the transcript's (CLI-truncated) copy; only
|
|
1929
|
+
// backfill from the transcript when the field is empty.
|
|
1930
|
+
const nextFirstPrompt = session.firstPrompt ?? prepareStoredPrompt(details.firstPrompt);
|
|
1931
|
+
const nextLastPrompt = session.lastPrompt ?? prepareStoredPrompt(details.lastPrompt);
|
|
1932
|
+
|
|
1933
|
+
const titlesChanged =
|
|
1934
|
+
nextTitles?.custom !== session.transcriptTitles?.custom ||
|
|
1935
|
+
nextTitles?.ai !== session.transcriptTitles?.ai ||
|
|
1936
|
+
nextTitles?.agent !== session.transcriptTitles?.agent;
|
|
1937
|
+
if (!titlesChanged && nextFirstPrompt === session.firstPrompt && nextLastPrompt === session.lastPrompt) {
|
|
1938
|
+
return;
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
this._sessions[index] = {
|
|
1942
|
+
...session,
|
|
1943
|
+
transcriptTitles: nextTitles,
|
|
1944
|
+
firstPrompt: nextFirstPrompt,
|
|
1945
|
+
lastPrompt: nextLastPrompt,
|
|
1946
|
+
};
|
|
1947
|
+
this._onDidChangeSessions.fire();
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1412
1950
|
private matchesWorkspace(workspacePath: string | undefined): boolean {
|
|
1413
1951
|
if (!workspacePath) return false;
|
|
1414
1952
|
|
|
@@ -1430,12 +1968,18 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1430
1968
|
);
|
|
1431
1969
|
}
|
|
1432
1970
|
|
|
1433
|
-
private updateSessionWithPermission(
|
|
1971
|
+
private updateSessionWithPermission(
|
|
1972
|
+
sessionId: string,
|
|
1973
|
+
permission: PendingPermission,
|
|
1974
|
+
context?: SessionContext,
|
|
1975
|
+
): void {
|
|
1434
1976
|
// A pending permission is a working/waiting transition — cancel any deferred Stop → idle
|
|
1435
1977
|
// commit so the session doesn't briefly flip to idle before showing the prompt.
|
|
1436
1978
|
this.cancelPendingIdleTransition(sessionId);
|
|
1437
1979
|
|
|
1438
|
-
|
|
1980
|
+
// The full event context, not just the pid: a blocking ask can be the first event of a
|
|
1981
|
+
// session, and a row created here with no location would sit unattributed for the whole block.
|
|
1982
|
+
const { index } = this.ensureSession(sessionId, context);
|
|
1439
1983
|
|
|
1440
1984
|
const prev = this._sessions[index];
|
|
1441
1985
|
const newPhase = getPhaseForStatus('permission_requested');
|
|
@@ -1455,7 +1999,7 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1455
1999
|
private updateSessionStatus(
|
|
1456
2000
|
sessionId: string,
|
|
1457
2001
|
status: AgentSessionStatus,
|
|
1458
|
-
options?: SessionContext & { statusDetail?: string },
|
|
2002
|
+
options?: SessionContext & { statusDetail?: string; pendingPermission?: PendingPermission },
|
|
1459
2003
|
): void {
|
|
1460
2004
|
// Any non-idle status update implicitly cancels a deferred Stop → idle commit. The
|
|
1461
2005
|
// timer is owned here so the schedule/cancel pair is uniformly enforced regardless of
|
|
@@ -1482,7 +2026,15 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1482
2026
|
}
|
|
1483
2027
|
|
|
1484
2028
|
const statusDetail = options?.statusDetail;
|
|
1485
|
-
|
|
2029
|
+
// Resolved before the short-circuit: a second ask can arrive while the row already sits at
|
|
2030
|
+
// `permission_requested`, and comparing only status+detail would publish the first ask forever.
|
|
2031
|
+
const nextPendingPermission =
|
|
2032
|
+
status === 'permission_requested' ? (bk.pendingPermission ?? options?.pendingPermission) : undefined;
|
|
2033
|
+
if (
|
|
2034
|
+
prev.status === status &&
|
|
2035
|
+
prev.statusDetail === statusDetail &&
|
|
2036
|
+
isSameAsk(prev.pendingPermission, nextPendingPermission)
|
|
2037
|
+
) {
|
|
1486
2038
|
if (metadataChanged) {
|
|
1487
2039
|
this._onDidChangeSessions.fire();
|
|
1488
2040
|
}
|
|
@@ -1496,7 +2048,7 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1496
2048
|
phase: newPhase,
|
|
1497
2049
|
phaseSince: this.resolvePhaseSince(sessionId, prev.phase, prev.phaseSince, newPhase),
|
|
1498
2050
|
statusDetail: statusDetail,
|
|
1499
|
-
pendingPermission:
|
|
2051
|
+
pendingPermission: nextPendingPermission,
|
|
1500
2052
|
lastActivity: new Date(),
|
|
1501
2053
|
};
|
|
1502
2054
|
this._onDidChangeSessions.fire();
|
|
@@ -1517,11 +2069,15 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1517
2069
|
}
|
|
1518
2070
|
|
|
1519
2071
|
private ensureSession(sessionId: string, context?: SessionContext): { index: number; changed: boolean } {
|
|
1520
|
-
const { pid, workspacePath, isInWorkspace, cwd, initialCwd, planFile, sessionName } =
|
|
2072
|
+
const { pid, workspacePath, isInWorkspace, cwd, initialCwd, planFile, sessionName, model, worktreePath } =
|
|
2073
|
+
context ?? {};
|
|
1521
2074
|
|
|
1522
2075
|
let index = this._sessions.findIndex(s => s.id === sessionId);
|
|
1523
2076
|
if (index < 0) {
|
|
1524
2077
|
const now = new Date();
|
|
2078
|
+
// Same worktree-derived fallback as the update branch below: a session created with a
|
|
2079
|
+
// scratch cwd but a CLI-seated worktree still belongs to that worktree's workspace.
|
|
2080
|
+
const createdWorkspacePath = workspacePath ?? this.resolveWorkspacePath(worktreePath);
|
|
1525
2081
|
index = this._sessions.length;
|
|
1526
2082
|
this._sessions.push({
|
|
1527
2083
|
id: sessionId,
|
|
@@ -1534,15 +2090,20 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1534
2090
|
pid: pid,
|
|
1535
2091
|
lastActivity: now,
|
|
1536
2092
|
isSubagent: false,
|
|
1537
|
-
workspacePath:
|
|
2093
|
+
workspacePath: createdWorkspacePath,
|
|
1538
2094
|
cwd: cwd,
|
|
1539
2095
|
initialCwd: initialCwd ?? cwd,
|
|
1540
2096
|
planFile: planFile,
|
|
1541
|
-
isInWorkspace: isInWorkspace ?? false,
|
|
2097
|
+
isInWorkspace: createdWorkspacePath != null ? true : (isInWorkspace ?? false),
|
|
2098
|
+
model: model,
|
|
2099
|
+
worktreePath: worktreePath != null ? normalizePath(worktreePath) : undefined,
|
|
1542
2100
|
});
|
|
1543
2101
|
Logger.debug(
|
|
1544
2102
|
`ClaudeCodeProvider.ensureSession: implicitly created ${this.sessionTag(sessionId, sessionName ?? 'unnamed')}`,
|
|
1545
2103
|
);
|
|
2104
|
+
if (worktreePath != null) {
|
|
2105
|
+
this.getBookkeeping(sessionId).cliSeatedWorktree = true;
|
|
2106
|
+
}
|
|
1546
2107
|
this._onDidChangeSessions.fire();
|
|
1547
2108
|
|
|
1548
2109
|
if (cwd != null) {
|
|
@@ -1554,10 +2115,9 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1554
2115
|
|
|
1555
2116
|
const existing = this._sessions[index];
|
|
1556
2117
|
const updatedPid = pid != null && existing.pid == null ? pid : existing.pid;
|
|
1557
|
-
const updatedWorkspacePath = workspacePath || existing.workspacePath;
|
|
1558
|
-
const updatedIsInWorkspace = workspacePath ? (isInWorkspace ?? existing.isInWorkspace) : existing.isInWorkspace;
|
|
1559
2118
|
const updatedPlanFile = planFile ?? existing.planFile;
|
|
1560
2119
|
const updatedName = sessionName || existing.name;
|
|
2120
|
+
const updatedModel = model || existing.model;
|
|
1561
2121
|
const updatedCwd = cwd ?? existing.cwd;
|
|
1562
2122
|
// Prefer the CLI's authoritative `initialCwd` (the true launch dir it captured first-hand,
|
|
1563
2123
|
// even for sessions that started before this window opened); otherwise keep whatever we
|
|
@@ -1565,6 +2125,32 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1565
2125
|
// don't send `initialCwd`, or a cold-create before any cwd was known). Comparing live `cwd`
|
|
1566
2126
|
// against `initialCwd` is how consumers detect launch-vs-current drift, so it stays stable.
|
|
1567
2127
|
const updatedInitialCwd = initialCwd ?? existing.initialCwd ?? cwd;
|
|
2128
|
+
// The CLI resolves this at hook time and sends it on every event — authoritative when
|
|
2129
|
+
// present, so it seats ahead of the `resolveGitInfo` probe below (which still fills
|
|
2130
|
+
// `commonPath` and remains the fallback for older CLIs that don't send `cwdTimeline`).
|
|
2131
|
+
const updatedWorktreePath = worktreePath != null ? normalizePath(worktreePath) : existing.worktreePath;
|
|
2132
|
+
// Workspace membership follows the ATTRIBUTION, not the raw cwd: a scratch-dir excursion
|
|
2133
|
+
// (cwd in /tmp, worktree kept) stays in-workspace via its worktree, while a genuine move to
|
|
2134
|
+
// a repo outside the workspace honestly drops membership when events carry a cwd.
|
|
2135
|
+
const updatedWorkspacePath =
|
|
2136
|
+
workspacePath ??
|
|
2137
|
+
this.resolveWorkspacePath(updatedWorktreePath) ??
|
|
2138
|
+
(cwd == null ? existing.workspacePath : undefined);
|
|
2139
|
+
const updatedIsInWorkspace = cwd == null ? existing.isInWorkspace : updatedWorkspacePath != null;
|
|
2140
|
+
|
|
2141
|
+
const cwdMoved = cwd != null && updatedCwd !== existing.cwd;
|
|
2142
|
+
const worktreeMoved =
|
|
2143
|
+
updatedWorktreePath != null &&
|
|
2144
|
+
existing.worktreePath != null &&
|
|
2145
|
+
updatedWorktreePath !== existing.worktreePath;
|
|
2146
|
+
|
|
2147
|
+
// The CLI explaining the location owns attribution; a move it didn't explain hands it back to
|
|
2148
|
+
// the probe.
|
|
2149
|
+
if (worktreePath != null) {
|
|
2150
|
+
this.getBookkeeping(sessionId).cliSeatedWorktree = true;
|
|
2151
|
+
} else if (cwdMoved) {
|
|
2152
|
+
this.getBookkeeping(sessionId).cliSeatedWorktree = false;
|
|
2153
|
+
}
|
|
1568
2154
|
|
|
1569
2155
|
let changed = false;
|
|
1570
2156
|
if (
|
|
@@ -1573,18 +2159,27 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1573
2159
|
updatedIsInWorkspace !== existing.isInWorkspace ||
|
|
1574
2160
|
updatedPlanFile !== existing.planFile ||
|
|
1575
2161
|
updatedName !== existing.name ||
|
|
2162
|
+
updatedModel !== existing.model ||
|
|
1576
2163
|
updatedCwd !== existing.cwd ||
|
|
1577
|
-
updatedInitialCwd !== existing.initialCwd
|
|
2164
|
+
updatedInitialCwd !== existing.initialCwd ||
|
|
2165
|
+
updatedWorktreePath !== existing.worktreePath
|
|
1578
2166
|
) {
|
|
1579
2167
|
this._sessions[index] = {
|
|
1580
2168
|
...existing,
|
|
1581
2169
|
name: updatedName,
|
|
2170
|
+
model: updatedModel,
|
|
1582
2171
|
pid: updatedPid,
|
|
1583
2172
|
workspacePath: updatedWorkspacePath,
|
|
1584
2173
|
cwd: updatedCwd,
|
|
1585
2174
|
initialCwd: updatedInitialCwd,
|
|
1586
2175
|
planFile: updatedPlanFile,
|
|
1587
2176
|
isInWorkspace: updatedIsInWorkspace,
|
|
2177
|
+
worktreePath: updatedWorktreePath,
|
|
2178
|
+
// A worktree move can cross repos, and the spread would otherwise carry the old repo
|
|
2179
|
+
// identity forward — which consumers prefer over the new worktree, pinning the card to
|
|
2180
|
+
// the wrong repo. Drop it; the probe below refills it. Mirrors the poll's ended-record
|
|
2181
|
+
// re-seat.
|
|
2182
|
+
commonPath: worktreeMoved ? undefined : existing.commonPath,
|
|
1588
2183
|
};
|
|
1589
2184
|
changed = true;
|
|
1590
2185
|
}
|
|
@@ -1598,13 +2193,30 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1598
2193
|
// `resolveGitInfo` confirms the cwd isn't a git repo, preventing a retry storm on every
|
|
1599
2194
|
// hook event for non-git-repo cwds. The flag is cleared on cwd change (`CwdChanged`) and
|
|
1600
2195
|
// on `resetBookkeeping` (Stop/SessionStart) so re-resolution can happen when warranted.
|
|
2196
|
+
// A cwd move re-resolves too. `worktreePath`/`commonPath` are resolved from whichever cwd was
|
|
2197
|
+
// seen first, so an agent that moves between worktrees would otherwise stay pinned to its
|
|
2198
|
+
// launch repo — attaching to the wrong WIP row and leaving its real worktree showing no
|
|
2199
|
+
// agents at all. `CwdChanged` covers the move the CLI announces; this covers an ordinary
|
|
2200
|
+
// event that simply arrives carrying a new cwd.
|
|
2201
|
+
//
|
|
2202
|
+
// A cwd the CLI already attributed to the same worktree needs no probe at all — that's the
|
|
2203
|
+
// common case (an agent `cd`-ing around inside its own tree), and probing it would spawn a git
|
|
2204
|
+
// call per hook event for an answer we already have. A first seat (no prior worktree) still
|
|
2205
|
+
// counts as a location change: the move may be out of a non-git dir whose probe latched
|
|
2206
|
+
// `gitInfoUnresolvable`, and `commonPath` still needs the probe.
|
|
2207
|
+
const locationNeedsProbe = cwdMoved && (worktreePath == null || worktreeMoved || existing.worktreePath == null);
|
|
2208
|
+
if (locationNeedsProbe) {
|
|
2209
|
+
this.getBookkeeping(sessionId).gitInfoUnresolvable = false;
|
|
2210
|
+
}
|
|
2211
|
+
|
|
1601
2212
|
if (
|
|
1602
2213
|
cwd != null &&
|
|
1603
|
-
(existing.worktreePath == null || existing.commonPath == null) &&
|
|
2214
|
+
(locationNeedsProbe || existing.worktreePath == null || existing.commonPath == null) &&
|
|
1604
2215
|
!this.getBookkeeping(sessionId).gitInfoUnresolvable
|
|
1605
2216
|
) {
|
|
1606
2217
|
void this.resolveGitInfo(sessionId, cwd);
|
|
1607
2218
|
}
|
|
2219
|
+
|
|
1608
2220
|
return { index: index, changed: changed };
|
|
1609
2221
|
}
|
|
1610
2222
|
|
|
@@ -1618,9 +2230,20 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1618
2230
|
// would spawn a fresh git probe per hook event. The `gitInfoUnresolvable` bookkeeping flag
|
|
1619
2231
|
// (set below when `info == null`) is the long-lived suppressor across hook events; this
|
|
1620
2232
|
// in-flight set only dedupes concurrent overlapping probes within a single resolution.
|
|
1621
|
-
|
|
2233
|
+
//
|
|
2234
|
+
// Keyed by cwd as well: a probe for the SAME cwd is genuinely redundant, but one for a
|
|
2235
|
+
// different cwd (a missed `CwdChanged` corrected off the durable record, say) must not be
|
|
2236
|
+
// swallowed — the in-flight answer describes the old directory. Record it as pending so the
|
|
2237
|
+
// running probe hands off to it, and discard the stale answer below.
|
|
2238
|
+
const inFlightCwd = this._resolveGitInfoInFlight.get(sessionId);
|
|
2239
|
+
if (inFlightCwd != null) {
|
|
2240
|
+
if (inFlightCwd !== cwd) {
|
|
2241
|
+
this._resolveGitInfoPending.set(sessionId, cwd);
|
|
2242
|
+
}
|
|
2243
|
+
return;
|
|
2244
|
+
}
|
|
1622
2245
|
|
|
1623
|
-
this._resolveGitInfoInFlight.
|
|
2246
|
+
this._resolveGitInfoInFlight.set(sessionId, cwd);
|
|
1624
2247
|
try {
|
|
1625
2248
|
let info: Awaited<ReturnType<typeof resolveGitInfo>> | undefined;
|
|
1626
2249
|
try {
|
|
@@ -1633,6 +2256,11 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1633
2256
|
const index = this._sessions.findIndex(s => s.id === sessionId);
|
|
1634
2257
|
if (index < 0) return;
|
|
1635
2258
|
|
|
2259
|
+
// Superseded while we were probing: this answer describes a directory the session has
|
|
2260
|
+
// already left, so applying it would undo the newer location. Drop it — the `finally`
|
|
2261
|
+
// below re-runs for the newest cwd.
|
|
2262
|
+
if (this._resolveGitInfoPending.has(sessionId)) return;
|
|
2263
|
+
|
|
1636
2264
|
const session = this._sessions[index];
|
|
1637
2265
|
|
|
1638
2266
|
// `workspacePath` stays as the matched workspace folder (or undefined). Repo identity
|
|
@@ -1657,18 +2285,26 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1657
2285
|
return;
|
|
1658
2286
|
}
|
|
1659
2287
|
|
|
2288
|
+
// A CLI-seated worktree wins over the probe's answer (see `cliSeatedWorktree`): the CLI
|
|
2289
|
+
// resolved it for this exact cwd, while the probe can name the parent worktree for a nested
|
|
2290
|
+
// one. `commonPath` always comes from the probe — the CLI doesn't resolve repo identity.
|
|
2291
|
+
const effectiveWorktreePath =
|
|
2292
|
+
this.getBookkeeping(sessionId).cliSeatedWorktree && session.worktreePath != null
|
|
2293
|
+
? session.worktreePath
|
|
2294
|
+
: info.worktreePath;
|
|
2295
|
+
|
|
1660
2296
|
// Capture the worktree/commonPath at the first successful resolve so consumers can
|
|
1661
2297
|
// detect drift (e.g. an agent that `cd`'d into a sibling worktree after launch). Gated
|
|
1662
2298
|
// on `initialCommonPath` — captured together with `initialWorktreePath` so a first
|
|
1663
2299
|
// resolve where `info.worktreePath` happens to be undefined doesn't leave the latter
|
|
1664
2300
|
// open to a later overwrite. Once set, never overwritten.
|
|
1665
2301
|
const firstResolve = session.initialCommonPath == null;
|
|
1666
|
-
const updatedInitialWorktreePath = firstResolve ?
|
|
2302
|
+
const updatedInitialWorktreePath = firstResolve ? effectiveWorktreePath : session.initialWorktreePath;
|
|
1667
2303
|
const updatedInitialCommonPath = firstResolve ? info.repoRoot : session.initialCommonPath;
|
|
1668
2304
|
|
|
1669
2305
|
if (
|
|
1670
2306
|
cwd !== session.cwd ||
|
|
1671
|
-
|
|
2307
|
+
effectiveWorktreePath !== session.worktreePath ||
|
|
1672
2308
|
info.repoRoot !== session.commonPath ||
|
|
1673
2309
|
updatedInitialWorktreePath !== session.initialWorktreePath ||
|
|
1674
2310
|
updatedInitialCommonPath !== session.initialCommonPath
|
|
@@ -1676,7 +2312,7 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1676
2312
|
this._sessions[index] = {
|
|
1677
2313
|
...session,
|
|
1678
2314
|
cwd: cwd,
|
|
1679
|
-
worktreePath:
|
|
2315
|
+
worktreePath: effectiveWorktreePath,
|
|
1680
2316
|
commonPath: info.repoRoot,
|
|
1681
2317
|
initialWorktreePath: updatedInitialWorktreePath,
|
|
1682
2318
|
initialCommonPath: updatedInitialCommonPath,
|
|
@@ -1685,6 +2321,14 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1685
2321
|
}
|
|
1686
2322
|
} finally {
|
|
1687
2323
|
this._resolveGitInfoInFlight.delete(sessionId);
|
|
2324
|
+
|
|
2325
|
+
// Hand off to the newest cwd requested while this probe ran, so a move that arrived
|
|
2326
|
+
// mid-flight still resolves instead of being dropped by the dedupe.
|
|
2327
|
+
const pendingCwd = this._resolveGitInfoPending.get(sessionId);
|
|
2328
|
+
if (pendingCwd != null) {
|
|
2329
|
+
this._resolveGitInfoPending.delete(sessionId);
|
|
2330
|
+
void this.resolveGitInfo(sessionId, pendingCwd);
|
|
2331
|
+
}
|
|
1688
2332
|
}
|
|
1689
2333
|
}
|
|
1690
2334
|
|
|
@@ -1711,10 +2355,58 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1711
2355
|
this._onDidChangeSessions.fire();
|
|
1712
2356
|
}
|
|
1713
2357
|
|
|
2358
|
+
/** Transitions the session at `index` to the terminal `completed` state in place: keeps the row,
|
|
2359
|
+
* stamps `endedAt` as its activity time, clears transient state (statusDetail, pendingPermission,
|
|
2360
|
+
* subagents), and tears down its bookkeeping/timers. Shared by the live `SessionEnd` path and the
|
|
2361
|
+
* poll backstop for a `SessionEnd` the live path missed. Does not fire change events — the caller
|
|
2362
|
+
* batches those. */
|
|
2363
|
+
private completeSession(index: number, endedAt: Date): void {
|
|
2364
|
+
const prev = this._sessions[index];
|
|
2365
|
+
this._sessions[index] = {
|
|
2366
|
+
...prev,
|
|
2367
|
+
status: 'completed',
|
|
2368
|
+
phase: 'completed',
|
|
2369
|
+
phaseSince: endedAt,
|
|
2370
|
+
lastActivity: endedAt,
|
|
2371
|
+
statusDetail: undefined,
|
|
2372
|
+
pendingPermission: undefined,
|
|
2373
|
+
subagents: undefined,
|
|
2374
|
+
// A terminal row carries no live working state — drop the file-activity heatmap so
|
|
2375
|
+
// consumers reading it directly don't render a frozen tail for a finished session.
|
|
2376
|
+
fileActivity: undefined,
|
|
2377
|
+
};
|
|
2378
|
+
// Terminal teardown, owned here so every caller gets it — the live `SessionEnd` path does
|
|
2379
|
+
// this before calling us, but the poll's missed-SessionEnd transition doesn't: reject any
|
|
2380
|
+
// in-flight permission promise (otherwise a blocking request leaks until dispose) and cancel a
|
|
2381
|
+
// pending Stop→idle timer (otherwise it fires inside its window and revives this row to a
|
|
2382
|
+
// live zombie).
|
|
2383
|
+
const pending = this._pendingPermissions.get(prev.id);
|
|
2384
|
+
if (pending != null) {
|
|
2385
|
+
this.rejectAllPending(pending, new Error('Session completed'));
|
|
2386
|
+
this._pendingPermissions.delete(prev.id);
|
|
2387
|
+
}
|
|
2388
|
+
this.cancelPendingIdleTransition(prev.id);
|
|
2389
|
+
|
|
2390
|
+
const bk = this._sessionBookkeeping.get(prev.id);
|
|
2391
|
+
if (bk != null) {
|
|
2392
|
+
this.cancelPendingFileClears(bk);
|
|
2393
|
+
this.cancelPendingReadClears(bk);
|
|
2394
|
+
}
|
|
2395
|
+
this._sessionBookkeeping.delete(prev.id);
|
|
2396
|
+
this._transcriptReader.forget(prev.id);
|
|
2397
|
+
}
|
|
2398
|
+
|
|
1714
2399
|
private pruneDeadSessions(): boolean {
|
|
1715
2400
|
const kept: AgentSession[] = [];
|
|
1716
2401
|
const removedIds: string[] = [];
|
|
1717
2402
|
for (const s of this._sessions) {
|
|
2403
|
+
// Completed sessions are terminal and durable — their PID is usually dead by design, so
|
|
2404
|
+
// liveness pruning must never drop them (they leave only via poll reconciliation).
|
|
2405
|
+
if (s.status === 'completed') {
|
|
2406
|
+
kept.push(s);
|
|
2407
|
+
continue;
|
|
2408
|
+
}
|
|
2409
|
+
|
|
1718
2410
|
// A session blocking on us for a permission decision is by definition alive,
|
|
1719
2411
|
// even if `kill(pid, 0)` says otherwise (e.g. transient EPERM/ESRCH). Dropping
|
|
1720
2412
|
// it here loses pendingPermission and lastPrompt; the next syncSessions then
|
|
@@ -1731,7 +2423,7 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1731
2423
|
for (const id of removedIds) {
|
|
1732
2424
|
const pending = this._pendingPermissions.get(id);
|
|
1733
2425
|
if (pending != null) {
|
|
1734
|
-
|
|
2426
|
+
this.rejectAllPending(pending, new Error('Session pruned'));
|
|
1735
2427
|
this._pendingPermissions.delete(id);
|
|
1736
2428
|
}
|
|
1737
2429
|
this.cancelPendingIdleTransition(id);
|
|
@@ -1759,11 +2451,60 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1759
2451
|
// sessions exist — that's the only local backstop for pruning agents that die without firing
|
|
1760
2452
|
// `SessionEnd`, and it keeps us correct even if hook detection is stale/wrong. The bootstrap
|
|
1761
2453
|
// call in `ensureIpcServer` passes no options, so cold-start discovery always runs.
|
|
2454
|
+
// Deliberately counts ALL tracked sessions, completed included. A completed row is something
|
|
2455
|
+
// we have to reconcile — it's removed only when the CLI stops listing it (archived from
|
|
2456
|
+
// another window, or aged out) — and this poll is the sole mechanism that does so. It's also
|
|
2457
|
+
// what triggers the CLI's own 30-day retention sweep, which runs on `list-sessions` and
|
|
2458
|
+
// nothing else: no timer, no daemon. Gating it out to save a spawn wouldn't avoid work, it
|
|
2459
|
+
// would strand the rows on screen AND the records on disk until the window reloads.
|
|
1762
2460
|
if (options?.gate && this._sessions.length === 0 && !this._claudeHooksInstalled) return;
|
|
1763
2461
|
|
|
2462
|
+
// Narrowly: hooks off AND nothing running, i.e. we hold only terminal history. There's no
|
|
2463
|
+
// dying process to reap and no live state to correct — just rows to reconcile and the
|
|
2464
|
+
// retention sweep to trigger, neither of which needs quarter-hourly resolution. Back the
|
|
2465
|
+
// cadence off rather than stopping (window startup polls ungated, so a reload is current).
|
|
2466
|
+
// Hooks ON keeps the full cadence deliberately: there the tick is also the backstop for a
|
|
2467
|
+
// session whose hook events never reached our IPC server.
|
|
2468
|
+
if (
|
|
2469
|
+
options?.gate &&
|
|
2470
|
+
!this._claudeHooksInstalled &&
|
|
2471
|
+
!this._sessions.some(s => s.status !== 'completed') &&
|
|
2472
|
+
Date.now() - this._lastSyncAt < idleReconcileIntervalMs
|
|
2473
|
+
) {
|
|
2474
|
+
return;
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
// Stamped BEFORE the CLI call so the reconciliation below can tell "this poll's snapshot was
|
|
2478
|
+
// taken after we completed the row" from "an in-flight poll that predates it" — see the
|
|
2479
|
+
// completed-removal filter.
|
|
2480
|
+
const pollStartedAt = Date.now();
|
|
2481
|
+
this._lastSyncAt = pollStartedAt;
|
|
2482
|
+
|
|
1764
2483
|
let sessions: SessionFileData[];
|
|
1765
2484
|
try {
|
|
1766
|
-
|
|
2485
|
+
let output: string;
|
|
2486
|
+
try {
|
|
2487
|
+
// `--status active,ended` surfaces both live sessions and the CLI's durable ended records
|
|
2488
|
+
// (excluding `archived`). Newer CLIs default `list-sessions` to active-only, so the flag
|
|
2489
|
+
// is required to see completed sessions at all.
|
|
2490
|
+
output = await this.callbacks.runCLICommand([
|
|
2491
|
+
'ai',
|
|
2492
|
+
'hook',
|
|
2493
|
+
'list-sessions',
|
|
2494
|
+
'--status',
|
|
2495
|
+
'active,ended',
|
|
2496
|
+
'--json',
|
|
2497
|
+
]);
|
|
2498
|
+
this._statusFilterSupported = true;
|
|
2499
|
+
} catch (ex) {
|
|
2500
|
+
// Only retry on an unknown-flag error — a pre-3.1.69 CLI rejects `--status` but returns
|
|
2501
|
+
// all sessions unfiltered, so the flagless call is the correct legacy equivalent. Real
|
|
2502
|
+
// failures (CLI missing, spawn error) rethrow to the prune-and-return handler below.
|
|
2503
|
+
if (!isUnknownFlagError(ex)) throw ex;
|
|
2504
|
+
|
|
2505
|
+
this._statusFilterSupported = false;
|
|
2506
|
+
output = await this.callbacks.runCLICommand(['ai', 'hook', 'list-sessions', '--json']);
|
|
2507
|
+
}
|
|
1767
2508
|
sessions = JSON.parse(output) as SessionFileData[];
|
|
1768
2509
|
} catch {
|
|
1769
2510
|
if (this.pruneDeadSessions()) {
|
|
@@ -1775,24 +2516,255 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1775
2516
|
// Snapshot what the live IPC path had already tracked, so we can detect drift between it and
|
|
1776
2517
|
// what the poll returns. Ideally they match exactly — any difference means the live push path
|
|
1777
2518
|
// missed an add (poll discovers it) or a teardown (poll no longer reports a session we track).
|
|
1778
|
-
|
|
2519
|
+
// Drift is measured over *live* sessions only; completed sessions are durable history, so their
|
|
2520
|
+
// absence from `polledAlive` is expected and must not skew the discrepancy signal.
|
|
2521
|
+
const trackedLiveBefore = new Set(this._sessions.filter(s => s.status !== 'completed').map(s => s.id));
|
|
1779
2522
|
const polledAlive = new Set<string>();
|
|
1780
|
-
//
|
|
1781
|
-
//
|
|
1782
|
-
|
|
1783
|
-
|
|
2523
|
+
// Every id the poll returned (live *and* completed). Drives completed-session reconciliation:
|
|
2524
|
+
// a tracked completed session absent from this set was archived/purged by the CLI.
|
|
2525
|
+
const polledIds = new Set<string>();
|
|
2526
|
+
// Accumulates ids already tracked or added during this poll, so the membership check is O(1)
|
|
2527
|
+
// and duplicate ids within a single response are only added once.
|
|
2528
|
+
const known = new Set(this._sessions.map(s => s.id));
|
|
1784
2529
|
|
|
1785
2530
|
let changed = false;
|
|
1786
2531
|
let discovered = 0;
|
|
1787
2532
|
|
|
1788
2533
|
for (const data of sessions) {
|
|
1789
|
-
if (!data.sessionId
|
|
2534
|
+
if (!data.sessionId) continue;
|
|
2535
|
+
|
|
2536
|
+
// Another AI host's record in the CLI's shared store. Must stay ahead of `polledIds`/
|
|
2537
|
+
// `polledAlive`: an id in either would skew completed-row reconciliation and the
|
|
2538
|
+
// `onSyncDiscrepancy` drift signal below.
|
|
2539
|
+
if (data.providerId != null && data.providerId !== 'claude-code') continue;
|
|
2540
|
+
|
|
2541
|
+
const effectiveStatus = data.status ?? 'active';
|
|
2542
|
+
|
|
2543
|
+
// Archived records are excluded by the `active,ended` query; guard anyway.
|
|
2544
|
+
if (effectiveStatus === 'archived') continue;
|
|
2545
|
+
|
|
2546
|
+
if (effectiveStatus === 'ended') {
|
|
2547
|
+
polledIds.add(data.sessionId);
|
|
2548
|
+
const endedDate = new Date(data.endedAt ?? data.updatedAt);
|
|
2549
|
+
|
|
2550
|
+
const existingIndex = this._sessions.findIndex(s => s.id === data.sessionId);
|
|
2551
|
+
if (existingIndex >= 0) {
|
|
2552
|
+
// Already tracked. If the live path missed the SessionEnd (e.g. `/clear` kept the
|
|
2553
|
+
// PID alive under a new id), transition it to completed now — otherwise a live-status
|
|
2554
|
+
// row would linger as a zombie, since pruneDeadSessions can't reap a live PID.
|
|
2555
|
+
if (this._sessions[existingIndex].status !== 'completed') {
|
|
2556
|
+
this.completeSession(existingIndex, endedDate);
|
|
2557
|
+
changed = true;
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2560
|
+
// Re-seat the row on the record's location. The durable record is authoritative for a
|
|
2561
|
+
// terminal session, and a `CwdChanged` hook we never received would otherwise leave the
|
|
2562
|
+
// card attached to the wrong worktree — and "Open Session" resuming from a stale
|
|
2563
|
+
// directory. Deliberately limited to location: prompts/titles have their own on-demand
|
|
2564
|
+
// transcript resolution, which holds richer values than the record does.
|
|
2565
|
+
const existing = this._sessions[existingIndex];
|
|
2566
|
+
const worktreeRoot = worktreeRootFromData(data);
|
|
2567
|
+
const cwdChanged = Boolean(data.cwd) && data.cwd !== existing.cwd;
|
|
2568
|
+
// Pre-`cwdTimeline` records carry no worktree data. If the cwd moved, whatever we hold
|
|
2569
|
+
// is no longer trustworthy — it may point into a different repo entirely — so drop it
|
|
2570
|
+
// rather than leave the card filed under the wrong branch, and re-resolve below. A cwd
|
|
2571
|
+
// change is a discrete event, so that's one probe, not the cold-start fan-out the
|
|
2572
|
+
// completed path exists to avoid.
|
|
2573
|
+
const nextWorktreePath =
|
|
2574
|
+
worktreeRoot != null
|
|
2575
|
+
? normalizePath(worktreeRoot)
|
|
2576
|
+
: cwdChanged
|
|
2577
|
+
? undefined
|
|
2578
|
+
: existing.worktreePath;
|
|
2579
|
+
const worktreeMoved = nextWorktreePath !== existing.worktreePath;
|
|
2580
|
+
if (cwdChanged || worktreeMoved) {
|
|
2581
|
+
const workspacePath = this.resolveWorkspacePath(data.cwd || existing.cwd);
|
|
2582
|
+
this._sessions[existingIndex] = {
|
|
2583
|
+
...existing,
|
|
2584
|
+
cwd: data.cwd || existing.cwd,
|
|
2585
|
+
initialCwd: existing.initialCwd ?? data.initialCwd ?? data.cwd,
|
|
2586
|
+
worktreePath: nextWorktreePath,
|
|
2587
|
+
workspacePath: workspacePath,
|
|
2588
|
+
isInWorkspace: workspacePath != null,
|
|
2589
|
+
// A worktree move can cross repos, and the spread would otherwise carry the old
|
|
2590
|
+
// repo identity forward — which consumers PREFER over the freshly-resolved
|
|
2591
|
+
// worktree metadata, pinning the card to the wrong repo. Drop it and let the
|
|
2592
|
+
// host backfill from the new worktree.
|
|
2593
|
+
commonPath: worktreeMoved ? undefined : existing.commonPath,
|
|
2594
|
+
};
|
|
2595
|
+
changed = true;
|
|
2596
|
+
|
|
2597
|
+
// Nothing in the record to re-seat it with (older CLI), so resolve the new cwd
|
|
2598
|
+
// once rather than leaving the row location-less.
|
|
2599
|
+
if (nextWorktreePath == null && data.cwd) {
|
|
2600
|
+
void this.resolveGitInfo(data.sessionId, data.cwd);
|
|
2601
|
+
}
|
|
2602
|
+
}
|
|
2603
|
+
|
|
2604
|
+
this.getBookkeeping(data.sessionId).polledAtLeastOnce = true;
|
|
2605
|
+
continue;
|
|
2606
|
+
}
|
|
2607
|
+
|
|
2608
|
+
known.add(data.sessionId);
|
|
2609
|
+
const workspacePath = this.resolveWorkspacePath(data.cwd);
|
|
2610
|
+
const worktreeRoot = worktreeRootFromData(data);
|
|
2611
|
+
this._sessions.push({
|
|
2612
|
+
id: data.sessionId,
|
|
2613
|
+
providerId: this.id,
|
|
2614
|
+
providerName: this.name,
|
|
2615
|
+
name: data.sessionName || undefined,
|
|
2616
|
+
status: 'completed',
|
|
2617
|
+
phase: 'completed',
|
|
2618
|
+
phaseSince: endedDate,
|
|
2619
|
+
pid: data.pid,
|
|
2620
|
+
lastActivity: endedDate,
|
|
2621
|
+
isSubagent: false,
|
|
2622
|
+
workspacePath: workspacePath,
|
|
2623
|
+
// Read straight from the CLI's durable record so completed sessions attach to branch
|
|
2624
|
+
// cards / WIP rows / the resume picker at any age, no git probe.
|
|
2625
|
+
worktreePath: worktreeRoot != null ? normalizePath(worktreeRoot) : undefined,
|
|
2626
|
+
cwd: data.cwd,
|
|
2627
|
+
initialCwd: data.initialCwd ?? data.cwd,
|
|
2628
|
+
planFile: data.planFile ?? undefined,
|
|
2629
|
+
isInWorkspace: workspacePath != null,
|
|
2630
|
+
lastPrompt: prepareStoredPrompt(data.prompt ?? undefined),
|
|
2631
|
+
firstPrompt: prepareStoredPrompt(data.firstPrompt ?? undefined),
|
|
2632
|
+
model: data.model ?? undefined,
|
|
2633
|
+
endReason: data.endReason ?? undefined,
|
|
2634
|
+
endedAt: data.endedAt != null ? new Date(data.endedAt).getTime() : undefined,
|
|
2635
|
+
// Subagents are meaningless once terminal.
|
|
2636
|
+
subagents: undefined,
|
|
2637
|
+
});
|
|
2638
|
+
this.getBookkeeping(data.sessionId).polledAtLeastOnce = true;
|
|
2639
|
+
changed = true;
|
|
2640
|
+
// Titles/prompts still resolve lazily via resolveCompletedSessionDetails (a 30-day
|
|
2641
|
+
// cold-start must not fan out transcript reads). worktreePath comes from the record
|
|
2642
|
+
// above; only fall back to an eager git probe when the record predates that CLI field,
|
|
2643
|
+
// and only for the recent (WIP-window) tail so the fallback stays bounded.
|
|
2644
|
+
if (
|
|
2645
|
+
worktreeRoot == null &&
|
|
2646
|
+
data.cwd &&
|
|
2647
|
+
Date.now() - endedDate.getTime() < recentCompletedGitResolveThresholdMs
|
|
2648
|
+
) {
|
|
2649
|
+
void this.resolveGitInfo(data.sessionId, data.cwd);
|
|
2650
|
+
}
|
|
1790
2651
|
continue;
|
|
1791
2652
|
}
|
|
1792
2653
|
|
|
1793
|
-
|
|
2654
|
+
// Active / legacy (absent-status) path — only genuinely-live processes are discoverable.
|
|
2655
|
+
if (!data.pid || !isProcessAlive(data.pid)) continue;
|
|
1794
2656
|
|
|
1795
|
-
|
|
2657
|
+
polledAlive.add(data.sessionId);
|
|
2658
|
+
polledIds.add(data.sessionId);
|
|
2659
|
+
|
|
2660
|
+
if (known.has(data.sessionId)) {
|
|
2661
|
+
// Already tracked.
|
|
2662
|
+
const existingIndex = this._sessions.findIndex(s => s.id === data.sessionId);
|
|
2663
|
+
if (existingIndex >= 0) {
|
|
2664
|
+
const existing = this._sessions[existingIndex];
|
|
2665
|
+
// The status the CLI's last event implies — a resumed session may already be mid-work
|
|
2666
|
+
// (`PreToolUse` → tool_use, `UserPromptSubmit` → thinking), and hardcoding `idle` would
|
|
2667
|
+
// mislabel active work and risk a concurrent-write resume.
|
|
2668
|
+
const polledStatus = deriveStatusFromEvent(data.event);
|
|
2669
|
+
if (existing.status === 'completed') {
|
|
2670
|
+
// We hold it as `completed` but the CLI now reports it as a live process (a
|
|
2671
|
+
// resume the live SessionStart path missed) — revive it, the symmetric backstop
|
|
2672
|
+
// to the ended-branch's live→completed transition above. Leave genuinely-live
|
|
2673
|
+
// rows to the IPC path.
|
|
2674
|
+
const revivedAt = new Date(data.updatedAt);
|
|
2675
|
+
// Only when the record is at least as new as the completion. `SessionEnd` fires
|
|
2676
|
+
// while the process is still winding down, so a poll whose CLI call started
|
|
2677
|
+
// BEFORE it returns a snapshot that still says "active" with a live pid — reviving
|
|
2678
|
+
// off that stale record would resurrect the row, and `pruneDeadSessions` would
|
|
2679
|
+
// then delete it outright once the process exits, losing the completed row.
|
|
2680
|
+
if (revivedAt.getTime() < existing.phaseSince.getTime()) continue;
|
|
2681
|
+
|
|
2682
|
+
// The poll has no blocking hook entry for this ask (it's discovered off the CLI's
|
|
2683
|
+
// durable record, not this window's IPC path) — synthesize an unresolvable one so
|
|
2684
|
+
// the row shows what it's waiting on instead of an empty "Needs input" card.
|
|
2685
|
+
const revivedPendingPermission =
|
|
2686
|
+
polledStatus === 'permission_requested'
|
|
2687
|
+
? synthesizeUnresolvableAsk(data.toolName, data.planFile ?? undefined)
|
|
2688
|
+
: undefined;
|
|
2689
|
+
// Seat worktreePath from the record, mirroring the ended-record re-seat above — the
|
|
2690
|
+
// existing row is otherwise stuck with whatever (or nothing) it had while completed.
|
|
2691
|
+
const revivedWorktreeRoot = worktreeRootFromData(data);
|
|
2692
|
+
// No record worktree data (older CLI): KEEP the completed row's attribution. A
|
|
2693
|
+
// resume whose cwd is scratch space (e.g. /tmp) still belongs to its worktree —
|
|
2694
|
+
// re-rooting happens only when git affirmatively resolves a new one (the probe
|
|
2695
|
+
// below, on success). Attribution persists until something better is known.
|
|
2696
|
+
const revivedWorktreePath =
|
|
2697
|
+
revivedWorktreeRoot != null ? normalizePath(revivedWorktreeRoot) : existing.worktreePath;
|
|
2698
|
+
// Membership follows the attribution, mirroring `ensureSession`: cwd first, the
|
|
2699
|
+
// kept worktree as the fallback for a scratch-dir resume.
|
|
2700
|
+
const revivedWorkspacePath =
|
|
2701
|
+
this.resolveWorkspacePath(data.cwd) ?? this.resolveWorkspacePath(revivedWorktreePath);
|
|
2702
|
+
this._sessions[existingIndex] = {
|
|
2703
|
+
...existing,
|
|
2704
|
+
status: polledStatus,
|
|
2705
|
+
phase: getPhaseForStatus(polledStatus),
|
|
2706
|
+
phaseSince: revivedAt,
|
|
2707
|
+
lastActivity: revivedAt,
|
|
2708
|
+
// Fresh pid from the CLI, and re-classify ownership on next open rather than
|
|
2709
|
+
// trusting the terminal record's `isPeerOwned`.
|
|
2710
|
+
pid: data.pid,
|
|
2711
|
+
isPeerOwned: undefined,
|
|
2712
|
+
pendingPermission: revivedPendingPermission,
|
|
2713
|
+
// The resume can be running somewhere else entirely, so take the record's
|
|
2714
|
+
// location rather than leaving the row filed under where it ended.
|
|
2715
|
+
cwd: data.cwd ?? existing.cwd,
|
|
2716
|
+
workspacePath:
|
|
2717
|
+
revivedWorkspacePath ?? (data.cwd == null ? existing.workspacePath : undefined),
|
|
2718
|
+
isInWorkspace: data.cwd == null ? existing.isInWorkspace : revivedWorkspacePath != null,
|
|
2719
|
+
worktreePath: revivedWorktreePath,
|
|
2720
|
+
// A worktree move can cross repos — drop the stale repo identity and let the
|
|
2721
|
+
// probe below refill it.
|
|
2722
|
+
commonPath: revivedWorktreePath !== existing.worktreePath ? undefined : existing.commonPath,
|
|
2723
|
+
// No longer terminal: leaving these set makes the row read as completed to
|
|
2724
|
+
// consumers that key off them.
|
|
2725
|
+
endReason: undefined,
|
|
2726
|
+
endedAt: undefined,
|
|
2727
|
+
};
|
|
2728
|
+
this.resetBookkeeping(data.sessionId);
|
|
2729
|
+
// The record's worktree is the CLI's own resolution; without one the probe owns
|
|
2730
|
+
// attribution again.
|
|
2731
|
+
this.getBookkeeping(data.sessionId).cliSeatedWorktree = revivedWorktreeRoot != null;
|
|
2732
|
+
if (data.cwd != null && data.cwd !== existing.cwd) {
|
|
2733
|
+
void this.resolveGitInfo(data.sessionId, data.cwd);
|
|
2734
|
+
}
|
|
2735
|
+
changed = true;
|
|
2736
|
+
} else if (
|
|
2737
|
+
existing.pendingPermission?.resolvable === false &&
|
|
2738
|
+
polledStatus !== 'permission_requested' &&
|
|
2739
|
+
new Date(data.updatedAt).getTime() > existing.lastActivity.getTime()
|
|
2740
|
+
) {
|
|
2741
|
+
// The record has moved past the ask this row still shows. Only a synthesized
|
|
2742
|
+
// (`resolvable: false`) ask is repairable this way — a routable one is bookkeeping-
|
|
2743
|
+
// owned and cleared by its own resolution paths, which the record can't see.
|
|
2744
|
+
this._sessions[existingIndex] = {
|
|
2745
|
+
...existing,
|
|
2746
|
+
status: polledStatus,
|
|
2747
|
+
phase: getPhaseForStatus(polledStatus),
|
|
2748
|
+
phaseSince: new Date(data.updatedAt),
|
|
2749
|
+
// The detail described the ask's tool; carry the record's current tool for
|
|
2750
|
+
// tool_use, else clear — keeping it would show the answered ask's tool as
|
|
2751
|
+
// the active one.
|
|
2752
|
+
statusDetail: polledStatus === 'tool_use' ? (data.toolName ?? undefined) : undefined,
|
|
2753
|
+
pendingPermission: undefined,
|
|
2754
|
+
lastActivity: new Date(data.updatedAt),
|
|
2755
|
+
};
|
|
2756
|
+
changed = true;
|
|
2757
|
+
} else if (existing.transcriptTitles?.ai == null && existing.transcriptTitles?.custom == null) {
|
|
2758
|
+
// A live session we discovered via the poll (another worktree/window owns its
|
|
2759
|
+
// hook flow) gets none of the local IPC re-resolves (SessionStart, idle). So one
|
|
2760
|
+
// discovered before Claude wrote its `ai-title` would show the repo slug forever.
|
|
2761
|
+
// Re-check the transcript each poll until a real title lands — cheap: the reader
|
|
2762
|
+
// tail-reads from its mtime cache and no-ops when the file hasn't grown.
|
|
2763
|
+
void this.resolveTranscriptTitles(data.sessionId, data.cwd);
|
|
2764
|
+
}
|
|
2765
|
+
}
|
|
2766
|
+
continue;
|
|
2767
|
+
}
|
|
1796
2768
|
|
|
1797
2769
|
known.add(data.sessionId);
|
|
1798
2770
|
|
|
@@ -1801,6 +2773,15 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1801
2773
|
const status = deriveStatusFromEvent(data.event);
|
|
1802
2774
|
const phase = getPhaseForStatus(status);
|
|
1803
2775
|
const activityDate = new Date(data.updatedAt);
|
|
2776
|
+
// Same rationale as the revived-session branch above: no blocking hook entry exists for a
|
|
2777
|
+
// poll-discovered ask, so synthesize an unresolvable one for the card.
|
|
2778
|
+
const pendingPermission =
|
|
2779
|
+
status === 'permission_requested'
|
|
2780
|
+
? synthesizeUnresolvableAsk(data.toolName, data.planFile ?? undefined)
|
|
2781
|
+
: undefined;
|
|
2782
|
+
// Read straight from the CLI's durable record so the row attaches to the right branch
|
|
2783
|
+
// card/WIP row immediately, no git probe — mirrors the completed-session push below.
|
|
2784
|
+
const worktreeRoot = worktreeRootFromData(data);
|
|
1804
2785
|
|
|
1805
2786
|
const subagents: AgentSession[] | undefined = data.subagents?.map(sub => ({
|
|
1806
2787
|
id: sub.agentId,
|
|
@@ -1837,17 +2818,54 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1837
2818
|
isInWorkspace: isInWorkspace,
|
|
1838
2819
|
lastPrompt: prepareStoredPrompt(data.prompt ?? undefined),
|
|
1839
2820
|
firstPrompt: prepareStoredPrompt(data.firstPrompt ?? undefined),
|
|
2821
|
+
model: data.model ?? undefined,
|
|
1840
2822
|
subagents: subagents,
|
|
2823
|
+
pendingPermission: pendingPermission,
|
|
2824
|
+
worktreePath: worktreeRoot != null ? normalizePath(worktreeRoot) : undefined,
|
|
1841
2825
|
});
|
|
1842
2826
|
changed = true;
|
|
1843
2827
|
discovered++;
|
|
1844
2828
|
|
|
2829
|
+
// The record's worktree is the CLI's own resolution — mark it seated so the probe below
|
|
2830
|
+
// (still wanted for `commonPath`) can't clobber it for a nested worktree. Mirrors the
|
|
2831
|
+
// revival branch above.
|
|
2832
|
+
this.getBookkeeping(data.sessionId).cliSeatedWorktree = worktreeRoot != null;
|
|
1845
2833
|
if (data.cwd) {
|
|
1846
2834
|
void this.resolveGitInfo(data.sessionId, data.cwd);
|
|
1847
2835
|
}
|
|
1848
2836
|
void this.resolveTranscriptTitles(data.sessionId, data.cwd);
|
|
1849
2837
|
}
|
|
1850
2838
|
|
|
2839
|
+
// The poll is authoritative for completed-session removal (archived elsewhere or 30-day-purged).
|
|
2840
|
+
// Only remove ones a prior poll confirmed present — never a session the live SessionEnd path just
|
|
2841
|
+
// transitioned, which an already-in-flight poll can legitimately miss (guarded by polledAtLeastOnce).
|
|
2842
|
+
// On a legacy CLI (no `--status`) no poll can ever confirm a completed row, so the guard would
|
|
2843
|
+
// instead pin it forever — there, absence from the poll is the confirmation.
|
|
2844
|
+
const staleCompleted = this._sessions.filter(
|
|
2845
|
+
s =>
|
|
2846
|
+
s.status === 'completed' &&
|
|
2847
|
+
!polledIds.has(s.id) &&
|
|
2848
|
+
(!this._statusFilterSupported ||
|
|
2849
|
+
this._sessionBookkeeping.get(s.id)?.polledAtLeastOnce === true ||
|
|
2850
|
+
// Or this poll's snapshot postdates the completion, so its absence is authoritative.
|
|
2851
|
+
// `polledAtLeastOnce` alone can never be set for a session whose record disappeared
|
|
2852
|
+
// (archived from another window, or purged) before any poll observed it — and
|
|
2853
|
+
// `completeSession` drops the bookkeeping — so without this the row is unremovable for
|
|
2854
|
+
// the window's lifetime. The timestamp answers the same question the flag was proxying:
|
|
2855
|
+
// could this poll have seen it? The grace keeps a just-completed row out of reach of
|
|
2856
|
+
// an absence that's really a not-yet-visible record.
|
|
2857
|
+
s.phaseSince.getTime() < pollStartedAt - completedRemovalGraceMs),
|
|
2858
|
+
);
|
|
2859
|
+
if (staleCompleted.length > 0) {
|
|
2860
|
+
const staleIds = new Set(staleCompleted.map(s => s.id));
|
|
2861
|
+
this._sessions = this._sessions.filter(s => !staleIds.has(s.id));
|
|
2862
|
+
for (const s of staleCompleted) {
|
|
2863
|
+
this._sessionBookkeeping.delete(s.id);
|
|
2864
|
+
this._transcriptReader.forget(s.id);
|
|
2865
|
+
}
|
|
2866
|
+
changed = true;
|
|
2867
|
+
}
|
|
2868
|
+
|
|
1851
2869
|
if (this.pruneDeadSessions()) {
|
|
1852
2870
|
changed = true;
|
|
1853
2871
|
}
|
|
@@ -1859,11 +2877,11 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1859
2877
|
// Report any drift between the live-synced set and the poll. Only on the recurring (gated)
|
|
1860
2878
|
// poll — the ungated bootstrap call runs before the live path has had a chance to receive
|
|
1861
2879
|
// any events, so its discoveries are expected cold-start state, not drift. `missing` counts
|
|
1862
|
-
// sessions we still track that the poll no longer reports alive (a teardown the live path
|
|
2880
|
+
// live sessions we still track that the poll no longer reports alive (a teardown the live path
|
|
1863
2881
|
// missed, e.g. an agent killed without firing `SessionEnd`).
|
|
1864
2882
|
if (options?.gate) {
|
|
1865
2883
|
let missing = 0;
|
|
1866
|
-
for (const id of
|
|
2884
|
+
for (const id of trackedLiveBefore) {
|
|
1867
2885
|
if (!polledAlive.has(id)) {
|
|
1868
2886
|
missing++;
|
|
1869
2887
|
}
|
|
@@ -1874,13 +2892,13 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1874
2892
|
discovered: discovered,
|
|
1875
2893
|
missing: missing,
|
|
1876
2894
|
polled: polledAlive.size,
|
|
1877
|
-
tracked:
|
|
2895
|
+
tracked: trackedLiveBefore.size,
|
|
1878
2896
|
});
|
|
1879
2897
|
}
|
|
1880
2898
|
}
|
|
1881
2899
|
}
|
|
1882
2900
|
|
|
1883
|
-
|
|
2901
|
+
protected async querySiblingWindowSessions(): Promise<void> {
|
|
1884
2902
|
const discoveryDir = this.callbacks.ipc.agentDiscoveryDir;
|
|
1885
2903
|
if (discoveryDir == null) return;
|
|
1886
2904
|
|
|
@@ -1912,6 +2930,12 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1912
2930
|
if (peerSessions == null) continue;
|
|
1913
2931
|
|
|
1914
2932
|
for (const peerSession of peerSessions) {
|
|
2933
|
+
// Skip terminal rows a peer published: completed sessions live in the machine-global CLI
|
|
2934
|
+
// store, so our own poll surfaces them with proper reconciliation. Importing them from a
|
|
2935
|
+
// peer would add a row with no `polledAtLeastOnce` that our poll can't confirm — a ghost
|
|
2936
|
+
// pinned for this window's lifetime if the store later drops it.
|
|
2937
|
+
if (peerSession.status === 'completed') continue;
|
|
2938
|
+
|
|
1915
2939
|
const peerActivity = new Date(peerSession.lastActivity);
|
|
1916
2940
|
const peerPhaseSince = new Date(peerSession.phaseSince);
|
|
1917
2941
|
|
|
@@ -1929,6 +2953,11 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1929
2953
|
|
|
1930
2954
|
if (peerActivity > existing.lastActivity) {
|
|
1931
2955
|
const idx = this._sessions.indexOf(existing);
|
|
2956
|
+
// A terminal row the peer now reports live IS a resume the peer owns. Mirror the
|
|
2957
|
+
// poll/SessionStart revive paths: take the peer's pid (retaining the terminal
|
|
2958
|
+
// record's dead — and possibly reused — pid would let dispatch focus an unrelated
|
|
2959
|
+
// process) and mark it peer-owned so opens route through the peer's IPC.
|
|
2960
|
+
const revivedFromCompleted = existing.status === 'completed';
|
|
1932
2961
|
// Peer is the authoritative hook recipient for this session, so wipe any
|
|
1933
2962
|
// local bookkeeping that survived from a previous local-ownership window.
|
|
1934
2963
|
// Without this, refcounts/timers from a prior local-hosting stretch would
|
|
@@ -1948,6 +2977,15 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1948
2977
|
phaseSince: peerPhaseSince,
|
|
1949
2978
|
statusDetail: peerSession.statusDetail,
|
|
1950
2979
|
lastActivity: peerActivity,
|
|
2980
|
+
// A locally-routable ask wins — a blocking ask fans out to every window, so we
|
|
2981
|
+
// may hold the hook entry the peer lacks. Otherwise mirror the peer's ask marked
|
|
2982
|
+
// unresolvable (we have no entry to answer it), and clear it when the peer did.
|
|
2983
|
+
pendingPermission:
|
|
2984
|
+
this._sessionBookkeeping.get(peerSession.id)?.pendingPermission ??
|
|
2985
|
+
(peerSession.pendingPermission != null
|
|
2986
|
+
? { ...peerSession.pendingPermission, resolvable: false }
|
|
2987
|
+
: undefined),
|
|
2988
|
+
...(revivedFromCompleted ? { pid: peerSession.pid, isPeerOwned: true } : undefined),
|
|
1951
2989
|
subagents: rehydrateSubagents(peerSession.subagents),
|
|
1952
2990
|
// Carry the peer's published fileActivity across so peer-window WIP
|
|
1953
2991
|
// decorations + treemap heatmap follow the agent. Owned by the peer (it's
|
|
@@ -1983,6 +3021,11 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1983
3021
|
...peerSession,
|
|
1984
3022
|
lastActivity: peerActivity,
|
|
1985
3023
|
phaseSince: peerPhaseSince,
|
|
3024
|
+
// The peer owns the hook entry, so an imported ask can never be routed from here.
|
|
3025
|
+
pendingPermission:
|
|
3026
|
+
peerSession.pendingPermission != null
|
|
3027
|
+
? { ...peerSession.pendingPermission, resolvable: false }
|
|
3028
|
+
: undefined,
|
|
1986
3029
|
workspacePath: normalizeWorkspacePath(peerSession.workspacePath),
|
|
1987
3030
|
isInWorkspace: this.matchesWorkspace(peerSession.workspacePath),
|
|
1988
3031
|
subagents: rehydrateSubagents(peerSession.subagents),
|