@gitkraken/core-gitlens 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -1
- package/dist/git/cache.d.ts +28 -4
- package/dist/git/cache.d.ts.map +1 -1
- package/dist/git/cache.js +82 -18
- package/dist/git/cache.js.map +1 -1
- package/dist/git/models/commit.d.ts.map +1 -1
- package/dist/git/models/commit.js.map +1 -1
- package/dist/git/models/reference.d.ts +31 -0
- package/dist/git/models/reference.d.ts.map +1 -1
- package/dist/git/models/remote.d.ts.map +1 -1
- package/dist/git/models/remote.js.map +1 -1
- package/dist/git/models/repository.d.ts +9 -1
- package/dist/git/models/repository.d.ts.map +1 -1
- package/dist/git/models/repository.js +19 -3
- package/dist/git/models/repository.js.map +1 -1
- package/dist/git/models/search.d.ts +2 -2
- package/dist/git/models/search.d.ts.map +1 -1
- package/dist/git/models/worktree.d.ts +4 -1
- package/dist/git/models/worktree.d.ts.map +1 -1
- package/dist/git/models/worktree.js +2 -2
- package/dist/git/models/worktree.js.map +1 -1
- package/dist/git/parsers/diffParser.d.ts.map +1 -1
- package/dist/git/parsers/diffParser.js.map +1 -1
- package/dist/git/providers/branches.d.ts +15 -4
- package/dist/git/providers/branches.d.ts.map +1 -1
- package/dist/git/providers/config.d.ts +4 -1
- package/dist/git/providers/config.d.ts.map +1 -1
- package/dist/git/providers/operations.d.ts +33 -10
- package/dist/git/providers/operations.d.ts.map +1 -1
- package/dist/git/providers/pausedOperations.d.ts +3 -1
- package/dist/git/providers/pausedOperations.d.ts.map +1 -1
- package/dist/git/providers/refs.d.ts +23 -2
- package/dist/git/providers/refs.d.ts.map +1 -1
- package/dist/git/providers/staging.d.ts +16 -0
- package/dist/git/providers/staging.d.ts.map +1 -1
- package/dist/git/providers/status.d.ts +4 -1
- package/dist/git/providers/status.d.ts.map +1 -1
- package/dist/git/remotes/azure-devops.d.ts.map +1 -1
- package/dist/git/remotes/azure-devops.js.map +1 -1
- package/dist/git/{exec.types.d.ts → run.types.d.ts} +22 -3
- package/dist/git/run.types.d.ts.map +1 -0
- package/dist/git/run.types.js +2 -0
- package/dist/git/run.types.js.map +1 -0
- package/dist/git/service.d.ts.map +1 -1
- package/dist/git/service.js.map +1 -1
- package/dist/git/utils/autolink.utils.d.ts.map +1 -1
- package/dist/git/utils/autolink.utils.js.map +1 -1
- package/dist/git/utils/blame.utils.d.ts.map +1 -1
- package/dist/git/utils/blame.utils.js.map +1 -1
- package/dist/git/utils/conflictResolution.utils.d.ts +6 -0
- package/dist/git/utils/conflictResolution.utils.d.ts.map +1 -0
- package/dist/git/utils/conflictResolution.utils.js +25 -0
- package/dist/git/utils/conflictResolution.utils.js.map +1 -0
- package/dist/git/utils/fileStatus.utils.d.ts +2 -1
- package/dist/git/utils/fileStatus.utils.d.ts.map +1 -1
- package/dist/git/utils/fileStatus.utils.js +4 -0
- package/dist/git/utils/fileStatus.utils.js.map +1 -1
- package/dist/git/utils/search.utils.d.ts.map +1 -1
- package/dist/git/utils/search.utils.js +3 -0
- package/dist/git/utils/search.utils.js.map +1 -1
- package/dist/git/utils/sorting.d.ts +8 -0
- package/dist/git/utils/sorting.d.ts.map +1 -1
- package/dist/git/utils/sorting.js +15 -0
- package/dist/git/utils/sorting.js.map +1 -1
- package/dist/git/utils/status.utils.d.ts.map +1 -1
- package/dist/git/utils/status.utils.js.map +1 -1
- package/dist/git/watching/classifyChange.d.ts.map +1 -1
- package/dist/git/watching/classifyChange.js +8 -0
- package/dist/git/watching/classifyChange.js.map +1 -1
- package/dist/git/watching/gitIgnoreFilter.js +1 -1
- package/dist/git/watching/gitIgnoreFilter.js.map +1 -1
- package/dist/git/watching/initWatcher.d.ts.map +1 -1
- package/dist/git/watching/initWatcher.js.map +1 -1
- package/dist/git/watching/watchGroup.d.ts.map +1 -1
- package/dist/git/watching/watchGroup.js.map +1 -1
- package/dist/git/watching/watchService.d.ts.map +1 -1
- package/dist/git/watching/watchService.js +24 -4
- package/dist/git/watching/watchService.js.map +1 -1
- package/dist/git/watching/watchSession.d.ts.map +1 -1
- package/dist/git/watching/watchSession.js.map +1 -1
- package/dist/git-cli/exec/exec.types.d.ts +1 -1
- package/dist/git-cli/exec/exec.types.d.ts.map +1 -1
- package/dist/git-cli/exec/git.d.ts +3 -3
- package/dist/git-cli/exec/git.d.ts.map +1 -1
- package/dist/git-cli/exec/git.js.map +1 -1
- package/dist/git-cli/exec/gitQueue.d.ts +10 -2
- package/dist/git-cli/exec/gitQueue.d.ts.map +1 -1
- package/dist/git-cli/exec/gitQueue.js +39 -12
- package/dist/git-cli/exec/gitQueue.js.map +1 -1
- package/dist/git-cli/parsers/blameParser.d.ts.map +1 -1
- package/dist/git-cli/parsers/blameParser.js.map +1 -1
- package/dist/git-cli/parsers/logParser.js.map +1 -1
- package/dist/git-cli/parsers/refParser.d.ts +3 -30
- package/dist/git-cli/parsers/refParser.d.ts.map +1 -1
- package/dist/git-cli/parsers/refParser.js +30 -30
- package/dist/git-cli/parsers/refParser.js.map +1 -1
- package/dist/git-cli/providers/blame.d.ts.map +1 -1
- package/dist/git-cli/providers/blame.js.map +1 -1
- package/dist/git-cli/providers/branches.d.ts +15 -5
- package/dist/git-cli/providers/branches.d.ts.map +1 -1
- package/dist/git-cli/providers/branches.js +98 -68
- package/dist/git-cli/providers/branches.js.map +1 -1
- package/dist/git-cli/providers/commits.d.ts.map +1 -1
- package/dist/git-cli/providers/commits.js +74 -30
- package/dist/git-cli/providers/commits.js.map +1 -1
- package/dist/git-cli/providers/config.d.ts +11 -2
- package/dist/git-cli/providers/config.d.ts.map +1 -1
- package/dist/git-cli/providers/config.js +27 -2
- package/dist/git-cli/providers/config.js.map +1 -1
- package/dist/git-cli/providers/diff.d.ts.map +1 -1
- package/dist/git-cli/providers/diff.js +4 -2
- package/dist/git-cli/providers/diff.js.map +1 -1
- package/dist/git-cli/providers/graph.d.ts.map +1 -1
- package/dist/git-cli/providers/graph.js +7 -2
- package/dist/git-cli/providers/graph.js.map +1 -1
- package/dist/git-cli/providers/operations.d.ts +13 -11
- package/dist/git-cli/providers/operations.d.ts.map +1 -1
- package/dist/git-cli/providers/operations.js +108 -38
- package/dist/git-cli/providers/operations.js.map +1 -1
- package/dist/git-cli/providers/patch.d.ts.map +1 -1
- package/dist/git-cli/providers/patch.js.map +1 -1
- package/dist/git-cli/providers/pausedOperations.d.ts +3 -1
- package/dist/git-cli/providers/pausedOperations.d.ts.map +1 -1
- package/dist/git-cli/providers/pausedOperations.js +7 -1
- package/dist/git-cli/providers/pausedOperations.js.map +1 -1
- package/dist/git-cli/providers/refs.d.ts +19 -2
- package/dist/git-cli/providers/refs.d.ts.map +1 -1
- package/dist/git-cli/providers/refs.js +286 -2
- package/dist/git-cli/providers/refs.js.map +1 -1
- package/dist/git-cli/providers/revision.js.map +1 -1
- package/dist/git-cli/providers/staging.d.ts +6 -0
- package/dist/git-cli/providers/staging.d.ts.map +1 -1
- package/dist/git-cli/providers/staging.js +32 -0
- package/dist/git-cli/providers/staging.js.map +1 -1
- package/dist/git-cli/providers/status.d.ts +4 -1
- package/dist/git-cli/providers/status.d.ts.map +1 -1
- package/dist/git-cli/providers/status.js +4 -3
- package/dist/git-cli/providers/status.js.map +1 -1
- package/dist/git-cli/providers/tags.d.ts.map +1 -1
- package/dist/git-cli/providers/tags.js +10 -6
- package/dist/git-cli/providers/tags.js.map +1 -1
- package/dist/ipc/discovery.d.ts +27 -0
- package/dist/ipc/discovery.d.ts.map +1 -0
- package/dist/ipc/discovery.js +38 -0
- package/dist/ipc/discovery.js.map +1 -0
- package/dist/ipc/ipcServer.d.ts +20 -0
- package/dist/ipc/ipcServer.d.ts.map +1 -0
- package/dist/ipc/ipcServer.js +162 -0
- package/dist/ipc/ipcServer.js.map +1 -0
- package/dist/plus/agents/providers/claudeCodeProvider.d.ts +114 -0
- package/dist/plus/agents/providers/claudeCodeProvider.d.ts.map +1 -0
- package/dist/plus/agents/providers/claudeCodeProvider.js +1480 -0
- package/dist/plus/agents/providers/claudeCodeProvider.js.map +1 -0
- package/dist/plus/agents/providers/claudeCodeTranscript.d.ts +36 -0
- package/dist/plus/agents/providers/claudeCodeTranscript.d.ts.map +1 -0
- package/dist/plus/agents/providers/claudeCodeTranscript.js +171 -0
- package/dist/plus/agents/providers/claudeCodeTranscript.js.map +1 -0
- package/dist/plus/agents/sanitizePrompt.d.ts +15 -0
- package/dist/plus/agents/sanitizePrompt.d.ts.map +1 -0
- package/dist/plus/agents/sanitizePrompt.js +103 -0
- package/dist/plus/agents/sanitizePrompt.js.map +1 -0
- package/dist/plus/agents/stateMachine.d.ts +32 -0
- package/dist/plus/agents/stateMachine.d.ts.map +1 -0
- package/dist/plus/agents/stateMachine.js +180 -0
- package/dist/plus/agents/stateMachine.js.map +1 -0
- package/dist/plus/agents/types.d.ts +203 -0
- package/dist/plus/agents/types.d.ts.map +1 -0
- package/dist/plus/agents/types.js +49 -0
- package/dist/plus/agents/types.js.map +1 -0
- package/dist/plus/ai/errors.d.ts +4 -1
- package/dist/plus/ai/errors.d.ts.map +1 -1
- package/dist/plus/ai/errors.js +38 -0
- package/dist/plus/ai/errors.js.map +1 -1
- package/dist/plus/ai/models/promptTemplates.d.ts +11 -2
- package/dist/plus/ai/models/promptTemplates.d.ts.map +1 -1
- package/dist/plus/ai/prompts.d.ts +1 -0
- package/dist/plus/ai/prompts.d.ts.map +1 -1
- package/dist/plus/ai/prompts.js +48 -8
- package/dist/plus/ai/prompts.js.map +1 -1
- package/dist/plus/ai/utils/results.utils.d.ts.map +1 -1
- package/dist/plus/ai/utils/results.utils.js.map +1 -1
- package/dist/plus/git-github/api/github.d.ts +10 -1
- package/dist/plus/git-github/api/github.d.ts.map +1 -1
- package/dist/plus/git-github/api/github.js +156 -49
- package/dist/plus/git-github/api/github.js.map +1 -1
- package/dist/plus/git-github/api/types.d.ts +8 -0
- package/dist/plus/git-github/api/types.d.ts.map +1 -1
- package/dist/plus/git-github/models.d.ts +1 -1
- package/dist/plus/git-github/models.d.ts.map +1 -1
- package/dist/plus/git-github/models.js.map +1 -1
- package/dist/plus/git-github/providers/github/blame.d.ts.map +1 -1
- package/dist/plus/git-github/providers/github/blame.js.map +1 -1
- package/dist/plus/git-github/providers/github/branches.d.ts +5 -1
- package/dist/plus/git-github/providers/github/branches.d.ts.map +1 -1
- package/dist/plus/git-github/providers/github/branches.js +47 -24
- package/dist/plus/git-github/providers/github/branches.js.map +1 -1
- package/dist/plus/git-github/providers/github/commits.d.ts.map +1 -1
- package/dist/plus/git-github/providers/github/commits.js +144 -92
- package/dist/plus/git-github/providers/github/commits.js.map +1 -1
- package/dist/plus/git-github/providers/github/diff.d.ts +1 -1
- package/dist/plus/git-github/providers/github/diff.d.ts.map +1 -1
- package/dist/plus/git-github/providers/github/diff.js +23 -9
- package/dist/plus/git-github/providers/github/diff.js.map +1 -1
- package/dist/plus/git-github/providers/github/refs.d.ts +9 -1
- package/dist/plus/git-github/providers/github/refs.d.ts.map +1 -1
- package/dist/plus/git-github/providers/github/refs.js +38 -13
- package/dist/plus/git-github/providers/github/refs.js.map +1 -1
- package/dist/plus/git-github/providers/github/revision.d.ts.map +1 -1
- package/dist/plus/git-github/providers/github/revision.js +1 -2
- package/dist/plus/git-github/providers/github/revision.js.map +1 -1
- package/dist/plus/git-github/providers/github/status.d.ts +4 -1
- package/dist/plus/git-github/providers/github/status.d.ts.map +1 -1
- package/dist/plus/git-github/providers/github/status.js +1 -1
- package/dist/plus/git-github/providers/github/status.js.map +1 -1
- package/dist/plus/git-github/providers/github/tags.d.ts.map +1 -1
- package/dist/plus/git-github/providers/github/tags.js +35 -36
- package/dist/plus/git-github/providers/github/tags.js.map +1 -1
- package/dist/plus/git-github/providers/githubGitProvider.d.ts.map +1 -1
- package/dist/plus/git-github/providers/githubGitProvider.js.map +1 -1
- package/dist/utils/color.d.ts.map +1 -1
- package/dist/utils/color.js.map +1 -1
- package/dist/utils/decorators/serializable.d.ts.map +1 -1
- package/dist/utils/decorators/serializable.js.map +1 -1
- package/dist/utils/dedupedAsyncCache.d.ts +37 -0
- package/dist/utils/dedupedAsyncCache.d.ts.map +1 -0
- package/dist/utils/dedupedAsyncCache.js +68 -0
- package/dist/utils/dedupedAsyncCache.js.map +1 -0
- package/dist/utils/diff.d.ts +2 -0
- package/dist/utils/diff.d.ts.map +1 -0
- package/dist/utils/diff.js +53 -0
- package/dist/utils/diff.js.map +1 -0
- package/dist/utils/disposable.d.ts.map +1 -1
- package/dist/utils/disposable.js.map +1 -1
- package/dist/utils/env/node/exec.d.ts.map +1 -1
- package/dist/utils/env/node/exec.js.map +1 -1
- package/dist/utils/error.d.ts.map +1 -1
- package/dist/utils/error.js.map +1 -1
- package/dist/utils/iterable.d.ts.map +1 -1
- package/dist/utils/iterable.js.map +1 -1
- package/dist/utils/logger.d.ts.map +1 -1
- package/dist/utils/logger.js.map +1 -1
- package/dist/utils/lruMap.d.ts +9 -0
- package/dist/utils/lruMap.d.ts.map +1 -1
- package/dist/utils/lruMap.js +26 -4
- package/dist/utils/lruMap.js.map +1 -1
- package/dist/utils/markdown.d.ts.map +1 -1
- package/dist/utils/markdown.js.map +1 -1
- package/dist/utils/mru.d.ts.map +1 -1
- package/dist/utils/mru.js.map +1 -1
- package/dist/utils/path.d.ts.map +1 -1
- package/dist/utils/path.js.map +1 -1
- package/dist/utils/promise.d.ts +1 -0
- package/dist/utils/promise.d.ts.map +1 -1
- package/dist/utils/promise.js +17 -0
- package/dist/utils/promise.js.map +1 -1
- package/dist/utils/promiseCache.d.ts +10 -0
- package/dist/utils/promiseCache.d.ts.map +1 -1
- package/dist/utils/promiseCache.js +22 -0
- package/dist/utils/promiseCache.js.map +1 -1
- package/dist/utils/string.d.ts.map +1 -1
- package/dist/utils/string.js.map +1 -1
- package/dist/utils/uri.d.ts.map +1 -1
- package/dist/utils/uri.js.map +1 -1
- package/dist/utils/version.d.ts.map +1 -1
- package/dist/utils/version.js.map +1 -1
- package/package.json +13 -1
- package/src/git/cache.ts +142 -28
- package/src/git/models/commit.ts +1 -0
- package/src/git/models/reference.ts +33 -0
- package/src/git/models/remote.ts +1 -0
- package/src/git/models/repository.ts +21 -3
- package/src/git/models/search.ts +2 -2
- package/src/git/models/worktree.ts +10 -2
- package/src/git/parsers/diffParser.ts +2 -0
- package/src/git/providers/branches.ts +22 -4
- package/src/git/providers/config.ts +2 -0
- package/src/git/providers/operations.ts +44 -0
- package/src/git/providers/pausedOperations.ts +1 -0
- package/src/git/providers/refs.ts +32 -3
- package/src/git/providers/staging.ts +12 -0
- package/src/git/providers/status.ts +6 -1
- package/src/git/remotes/azure-devops.ts +2 -0
- package/src/git/{exec.types.ts → run.types.ts} +22 -2
- package/src/git/service.ts +1 -0
- package/src/git/utils/autolink.utils.ts +1 -0
- package/src/git/utils/blame.utils.ts +2 -0
- package/src/git/utils/conflictResolution.utils.ts +31 -0
- package/src/git/utils/fileStatus.utils.ts +7 -1
- package/src/git/utils/search.utils.ts +2 -0
- package/src/git/utils/sorting.ts +17 -0
- package/src/git/utils/status.utils.ts +1 -0
- package/src/git/watching/classifyChange.ts +9 -0
- package/src/git/watching/gitIgnoreFilter.ts +2 -2
- package/src/git/watching/initWatcher.ts +2 -0
- package/src/git/watching/watchGroup.ts +3 -0
- package/src/git/watching/watchService.ts +38 -5
- package/src/git/watching/watchSession.ts +10 -0
- package/src/git-cli/exec/exec.types.ts +2 -2
- package/src/git-cli/exec/git.ts +6 -5
- package/src/git-cli/exec/gitQueue.ts +41 -12
- package/src/git-cli/parsers/blameParser.ts +1 -0
- package/src/git-cli/parsers/logParser.ts +1 -0
- package/src/git-cli/parsers/refParser.ts +33 -34
- package/src/git-cli/providers/blame.ts +1 -0
- package/src/git-cli/providers/branches.ts +164 -93
- package/src/git-cli/providers/commits.ts +83 -37
- package/src/git-cli/providers/config.ts +41 -2
- package/src/git-cli/providers/diff.ts +8 -2
- package/src/git-cli/providers/graph.ts +11 -2
- package/src/git-cli/providers/operations.ts +186 -49
- package/src/git-cli/providers/patch.ts +1 -0
- package/src/git-cli/providers/pausedOperations.ts +8 -0
- package/src/git-cli/providers/refs.ts +247 -3
- package/src/git-cli/providers/revision.ts +2 -2
- package/src/git-cli/providers/staging.ts +33 -0
- package/src/git-cli/providers/status.ts +15 -4
- package/src/git-cli/providers/tags.ts +15 -16
- package/src/ipc/discovery.ts +56 -0
- package/src/ipc/ipcServer.ts +171 -0
- package/src/plus/agents/providers/claudeCodeProvider.ts +1782 -0
- package/src/plus/agents/providers/claudeCodeTranscript.ts +214 -0
- package/src/plus/agents/sanitizePrompt.ts +109 -0
- package/src/plus/agents/stateMachine.ts +197 -0
- package/src/plus/agents/types.ts +277 -0
- package/src/plus/ai/errors.ts +40 -0
- package/src/plus/ai/models/promptTemplates.ts +13 -0
- package/src/plus/ai/prompts.ts +49 -8
- package/src/plus/ai/utils/results.utils.ts +2 -0
- package/src/plus/git-github/api/github.ts +199 -53
- package/src/plus/git-github/api/types.ts +15 -0
- package/src/plus/git-github/models.ts +1 -1
- package/src/plus/git-github/providers/github/blame.ts +2 -0
- package/src/plus/git-github/providers/github/branches.ts +61 -31
- package/src/plus/git-github/providers/github/commits.ts +231 -160
- package/src/plus/git-github/providers/github/diff.ts +27 -19
- package/src/plus/git-github/providers/github/refs.ts +57 -22
- package/src/plus/git-github/providers/github/revision.ts +1 -2
- package/src/plus/git-github/providers/github/status.ts +6 -1
- package/src/plus/git-github/providers/github/tags.ts +61 -62
- package/src/plus/git-github/providers/githubGitProvider.ts +1 -0
- package/src/utils/color.ts +2 -0
- package/src/utils/decorators/serializable.ts +1 -0
- package/src/utils/dedupedAsyncCache.ts +74 -0
- package/src/utils/diff.ts +61 -0
- package/src/utils/disposable.ts +2 -0
- package/src/utils/env/node/exec.ts +2 -0
- package/src/utils/error.ts +1 -0
- package/src/utils/iterable.ts +3 -0
- package/src/utils/logger.ts +1 -0
- package/src/utils/lruMap.ts +29 -4
- package/src/utils/markdown.ts +1 -0
- package/src/utils/mru.ts +2 -0
- package/src/utils/path.ts +1 -0
- package/src/utils/promise.ts +22 -0
- package/src/utils/promiseCache.ts +24 -0
- package/src/utils/string.ts +2 -0
- package/src/utils/uri.ts +1 -0
- package/src/utils/version.ts +1 -0
- package/dist/git/exec.types.d.ts.map +0 -1
- package/dist/git/exec.types.js +0 -2
- package/dist/git/exec.types.js.map +0 -1
|
@@ -0,0 +1,1782 @@
|
|
|
1
|
+
import { readdir, readFile } from 'fs/promises';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { gate } from '../../../utils/decorators/gate.js';
|
|
4
|
+
import type { UnifiedDisposable } from '../../../utils/disposable.js';
|
|
5
|
+
import { disposableInterval } from '../../../utils/disposable.js';
|
|
6
|
+
import { Emitter } from '../../../utils/event.js';
|
|
7
|
+
import { Logger } from '../../../utils/logger.js';
|
|
8
|
+
import { normalizePath } from '../../../utils/path.js';
|
|
9
|
+
import { prepareStoredPrompt } from '../sanitizePrompt.js';
|
|
10
|
+
import {
|
|
11
|
+
classifyPermissionKind,
|
|
12
|
+
deriveStatusFromEvent,
|
|
13
|
+
describeToolInput,
|
|
14
|
+
extractPlanSummary,
|
|
15
|
+
extractQuestionDetails,
|
|
16
|
+
getToolFilePath,
|
|
17
|
+
getToolReadPath,
|
|
18
|
+
isProcessAlive,
|
|
19
|
+
rehydrateSubagents,
|
|
20
|
+
} from '../stateMachine.js';
|
|
21
|
+
import type {
|
|
22
|
+
AgentProviderCallbacks,
|
|
23
|
+
AgentSession,
|
|
24
|
+
AgentSessionPhase,
|
|
25
|
+
AgentSessionProvider,
|
|
26
|
+
AgentSessionStatus,
|
|
27
|
+
ClaudeCodeHookEvent,
|
|
28
|
+
PendingPermission,
|
|
29
|
+
PendingPermissionKind,
|
|
30
|
+
PermissionDecision,
|
|
31
|
+
PermissionSuggestion,
|
|
32
|
+
} from '../types.js';
|
|
33
|
+
import { getPhaseForStatus } from '../types.js';
|
|
34
|
+
import { ClaudeCodeTranscriptReader } from './claudeCodeTranscript.js';
|
|
35
|
+
|
|
36
|
+
interface AgentSessionEvent {
|
|
37
|
+
event: ClaudeCodeHookEvent;
|
|
38
|
+
sessionId: string;
|
|
39
|
+
cwd: string;
|
|
40
|
+
pid?: number;
|
|
41
|
+
source?: string;
|
|
42
|
+
model?: string;
|
|
43
|
+
reason?: string;
|
|
44
|
+
toolName?: string;
|
|
45
|
+
agentId?: string;
|
|
46
|
+
agentType?: string;
|
|
47
|
+
planFile?: string;
|
|
48
|
+
notificationType?: string;
|
|
49
|
+
sessionName?: string;
|
|
50
|
+
prompt?: string;
|
|
51
|
+
firstPrompt?: string;
|
|
52
|
+
toolInput?: Record<string, unknown>;
|
|
53
|
+
permissionSuggestions?: PermissionSuggestion[];
|
|
54
|
+
hookInput?: Record<string, unknown>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface PermissionResponse {
|
|
58
|
+
hookSpecificOutput: {
|
|
59
|
+
hookEventName: 'PermissionRequest';
|
|
60
|
+
decision: {
|
|
61
|
+
behavior: PermissionDecision;
|
|
62
|
+
updatedPermissions?: PermissionSuggestion[];
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface PendingPermissionEntry {
|
|
68
|
+
resolve(response: PermissionResponse): void;
|
|
69
|
+
reject(reason: Error): void;
|
|
70
|
+
toolName: string;
|
|
71
|
+
toolDescription: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface SessionBookkeeping {
|
|
75
|
+
activeToolCount: number;
|
|
76
|
+
pendingPermission?: PendingPermission;
|
|
77
|
+
/** Refcount of in-flight file-mutating tool calls per absolute path. Keyed by path (not
|
|
78
|
+
* `tool_use_id`) because the GK CLI hook payload doesn't reliably surface `tool_use_id` —
|
|
79
|
+
* refcounting per path is order-independent and tolerant of out-of-order Pre/Post pairs. */
|
|
80
|
+
currentFileCounts: Map<string, number>;
|
|
81
|
+
/** Per-path cooldown timers scheduled when a path's refcount hits zero, so back-to-back
|
|
82
|
+
* Edit/Write to the same path doesn't flicker the WIP decoration. A fresh Pre on the same
|
|
83
|
+
* path cancels its pending clear. */
|
|
84
|
+
pendingFileClears: Map<string, NodeJS.Timeout>;
|
|
85
|
+
/** Refcount of in-flight read-only file tool calls (Read/NotebookRead) per absolute path.
|
|
86
|
+
* Mirrors {@link currentFileCounts} so consumers can distinguish "looking at" from "working
|
|
87
|
+
* on" without overloading `currentFiles`. */
|
|
88
|
+
currentReadCounts: Map<string, number>;
|
|
89
|
+
/** Per-path cooldown timers for reads — same semantics as {@link pendingFileClears}, keeps a
|
|
90
|
+
* recently-read file visible briefly after the read completes. */
|
|
91
|
+
pendingReadClears: Map<string, NodeJS.Timeout>;
|
|
92
|
+
/** Set to true when `resolveGitInfo` for the session's current cwd returned `undefined`
|
|
93
|
+
* (cwd is not a git repo). Prevents the `ensureSession` retry from firing forever on
|
|
94
|
+
* every subsequent hook event. Cleared when the session's cwd changes (re-resolution
|
|
95
|
+
* warranted) or on resetBookkeeping (Stop/SessionStart). */
|
|
96
|
+
gitInfoUnresolvable: boolean;
|
|
97
|
+
/** Phase that immediately preceded the current one, with its original `phaseSince`. Used by
|
|
98
|
+
* `resolvePhaseSince` to restore continuity when phase oscillates back within a short
|
|
99
|
+
* window (e.g., Stop → idle → working after a continuation crosses the debounce). */
|
|
100
|
+
priorPhase?: { phase: AgentSessionPhase; phaseSince: Date };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const staleCheckIntervalMs = 60 * 1000; // 1 minute
|
|
104
|
+
/** Cooldown between PostToolUse and dropping the file from `currentFiles`. Held long enough that
|
|
105
|
+
* surfaces consuming the list (WIP file decoration, treemap activity overlay) keep showing a file
|
|
106
|
+
* the agent just touched well past the moment the tool call completed — a single edit reads as a
|
|
107
|
+
* sustained presence rather than a flash. */
|
|
108
|
+
const postToolUseClearDelayMs = 120000;
|
|
109
|
+
|
|
110
|
+
/** Grace period before `Stop` commits to `idle`. Long enough to absorb a same-turn continuation
|
|
111
|
+
* (hook-driven re-prompt, IPC event reordering, auto-resume); short enough that legitimate idle
|
|
112
|
+
* is visible promptly. */
|
|
113
|
+
const stopToIdleDebounceMs = 750;
|
|
114
|
+
|
|
115
|
+
/** If a phase transitions away and then back within this window, restore the prior `phaseSince`
|
|
116
|
+
* so the displayed elapsed time doesn't snap to 0 on transient oscillations. Slightly longer
|
|
117
|
+
* than `stopToIdleDebounceMs` so a continuation that just barely crosses the debounce still
|
|
118
|
+
* restores continuity. */
|
|
119
|
+
const phaseSinceRestoreWindowMs = 2000;
|
|
120
|
+
|
|
121
|
+
interface SessionFileData {
|
|
122
|
+
sessionId: string;
|
|
123
|
+
event: string;
|
|
124
|
+
cwd: string;
|
|
125
|
+
pid: number;
|
|
126
|
+
toolName?: string | null;
|
|
127
|
+
agentId?: string | null;
|
|
128
|
+
agentType?: string | null;
|
|
129
|
+
source?: string | null;
|
|
130
|
+
model?: string | null;
|
|
131
|
+
updatedAt: string;
|
|
132
|
+
subagents?: { agentId: string; agentType: string }[];
|
|
133
|
+
planFile?: string | null;
|
|
134
|
+
sessionName?: string | null;
|
|
135
|
+
prompt?: string | null;
|
|
136
|
+
firstPrompt?: string | null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
interface DiscoveryFile {
|
|
140
|
+
token: string;
|
|
141
|
+
address: string;
|
|
142
|
+
port?: number;
|
|
143
|
+
workspacePaths?: string[];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
interface SessionContext {
|
|
147
|
+
pid?: number;
|
|
148
|
+
workspacePath?: string;
|
|
149
|
+
isInWorkspace?: boolean;
|
|
150
|
+
cwd?: string;
|
|
151
|
+
planFile?: string;
|
|
152
|
+
sessionName?: string;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
type SerializedAgentSession = Omit<AgentSession, 'lastActivity' | 'phaseSince' | 'subagents'> & {
|
|
156
|
+
lastActivity: string;
|
|
157
|
+
phaseSince: string;
|
|
158
|
+
subagents?: (Omit<AgentSession, 'lastActivity' | 'phaseSince'> & {
|
|
159
|
+
lastActivity: string;
|
|
160
|
+
phaseSince: string;
|
|
161
|
+
})[];
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/** Normalize a workspace path to GitLens canonical form (forward slashes, lower-case drive letter
|
|
165
|
+
* on Windows). Comparisons throughout the home view assume this form, but `_workspacePaths` and
|
|
166
|
+
* peer-session `workspacePath` values originate from `Uri.fsPath`, which on Windows uses
|
|
167
|
+
* backslashes and preserves drive-letter casing. Without this, `session.workspacePath ===
|
|
168
|
+
* repository.path` always fails on Windows. */
|
|
169
|
+
function normalizeWorkspacePath(value: string | null | undefined): string | undefined {
|
|
170
|
+
return value ? normalizePath(value) : undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Order-insensitive comparison — entries come from a `Map.values()` iterator, but tracked
|
|
174
|
+
* tool-use ids are uncorrelated with file order, so two snapshots with the same files in a
|
|
175
|
+
* different order shouldn't fire a no-op change event. Treats `undefined` and `[]` as equal. */
|
|
176
|
+
function currentFilesEqual(a: readonly string[] | undefined, b: readonly string[] | undefined): boolean {
|
|
177
|
+
const aLen = a?.length ?? 0;
|
|
178
|
+
const bLen = b?.length ?? 0;
|
|
179
|
+
if (aLen !== bLen) return false;
|
|
180
|
+
if (aLen === 0) return true;
|
|
181
|
+
if (aLen === 1) return a![0] === b![0];
|
|
182
|
+
|
|
183
|
+
const set = new Set(a);
|
|
184
|
+
for (const v of b!) {
|
|
185
|
+
if (!set.has(v)) return false;
|
|
186
|
+
}
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
191
|
+
readonly id = 'claudeCode';
|
|
192
|
+
readonly name = 'Claude Code';
|
|
193
|
+
readonly icon = 'robot';
|
|
194
|
+
|
|
195
|
+
private readonly _onDidChangeSessions = new Emitter<void>();
|
|
196
|
+
readonly onDidChangeSessions = this._onDidChangeSessions.event;
|
|
197
|
+
|
|
198
|
+
private _sessions: AgentSession[] = [];
|
|
199
|
+
private _ipcStarted = false;
|
|
200
|
+
private _disposed = false;
|
|
201
|
+
private _handlerDisposables: UnifiedDisposable[] = [];
|
|
202
|
+
private readonly _pendingPermissions = new Map<string, PendingPermissionEntry>();
|
|
203
|
+
private readonly _resolveGitInfoInFlight = new Set<string>();
|
|
204
|
+
private readonly _sessionBookkeeping = new Map<string, SessionBookkeeping>();
|
|
205
|
+
/** Per-session timers for the deferred `Stop → idle` commit. The handle is cleared (and the
|
|
206
|
+
* transition cancelled) by any non-idle status update arriving before the timer fires. */
|
|
207
|
+
private readonly _pendingIdleTimers = new Map<string, NodeJS.Timeout>();
|
|
208
|
+
private _workspacePaths: string[] = [];
|
|
209
|
+
private _staleCheckTimer: UnifiedDisposable | undefined;
|
|
210
|
+
protected _transcriptReader: ClaudeCodeTranscriptReader = new ClaudeCodeTranscriptReader();
|
|
211
|
+
|
|
212
|
+
constructor(private readonly callbacks: AgentProviderCallbacks) {}
|
|
213
|
+
|
|
214
|
+
get sessions(): readonly AgentSession[] {
|
|
215
|
+
return this._sessions;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
start(workspacePaths: string[]): void {
|
|
219
|
+
this._workspacePaths = workspacePaths.map(p => normalizePath(p));
|
|
220
|
+
void this.ensureIpcServer();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
stop(): void {
|
|
224
|
+
// IPC server stays up intentionally — hooks fire even when the window is unfocused
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
updateWorkspacePaths(workspacePaths: string[]): void {
|
|
228
|
+
this._workspacePaths = workspacePaths.map(p => normalizePath(p));
|
|
229
|
+
|
|
230
|
+
let changed = false;
|
|
231
|
+
for (let i = 0; i < this._sessions.length; i++) {
|
|
232
|
+
const s = this._sessions[i];
|
|
233
|
+
const nextWorkspacePath = s.cwd != null ? this.resolveWorkspacePath(s.cwd) : undefined;
|
|
234
|
+
const nextIsInWorkspace = nextWorkspacePath != null;
|
|
235
|
+
let subagentsChanged = false;
|
|
236
|
+
const subagents = s.subagents?.map(sub => {
|
|
237
|
+
const subNextWorkspacePath = sub.cwd != null ? this.resolveWorkspacePath(sub.cwd) : nextWorkspacePath;
|
|
238
|
+
const subNextIsInWorkspace = sub.cwd != null ? subNextWorkspacePath != null : nextIsInWorkspace;
|
|
239
|
+
if (sub.isInWorkspace === subNextIsInWorkspace && sub.workspacePath === subNextWorkspacePath) {
|
|
240
|
+
return sub;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
subagentsChanged = true;
|
|
244
|
+
return { ...sub, isInWorkspace: subNextIsInWorkspace, workspacePath: subNextWorkspacePath };
|
|
245
|
+
});
|
|
246
|
+
if (s.isInWorkspace !== nextIsInWorkspace || s.workspacePath !== nextWorkspacePath || subagentsChanged) {
|
|
247
|
+
this._sessions[i] = {
|
|
248
|
+
...s,
|
|
249
|
+
isInWorkspace: nextIsInWorkspace,
|
|
250
|
+
workspacePath: nextWorkspacePath,
|
|
251
|
+
subagents: subagents,
|
|
252
|
+
};
|
|
253
|
+
changed = true;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (changed) {
|
|
257
|
+
this._onDidChangeSessions.fire();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (!this._ipcStarted) {
|
|
261
|
+
// IPC wasn't bootstrapped yet — start it. ensureIpcServer publishes with the
|
|
262
|
+
// current `_workspacePaths`.
|
|
263
|
+
void this.ensureIpcServer();
|
|
264
|
+
} else {
|
|
265
|
+
// Re-publish so the agents discovery file reflects the new workspacePaths.
|
|
266
|
+
this.callbacks.ipc
|
|
267
|
+
.publishAgents(this._workspacePaths)
|
|
268
|
+
.catch((ex: unknown) =>
|
|
269
|
+
Logger.error(ex, 'ClaudeCodeProvider.updateWorkspacePaths: publishAgents failed'),
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
dispose(): void {
|
|
275
|
+
this._disposed = true;
|
|
276
|
+
this.stop();
|
|
277
|
+
this._staleCheckTimer?.dispose();
|
|
278
|
+
this._staleCheckTimer = undefined;
|
|
279
|
+
for (const d of this._handlerDisposables) {
|
|
280
|
+
d.dispose();
|
|
281
|
+
}
|
|
282
|
+
this._handlerDisposables = [];
|
|
283
|
+
for (const pending of this._pendingPermissions.values()) {
|
|
284
|
+
pending.reject(new Error('Provider disposed'));
|
|
285
|
+
}
|
|
286
|
+
this._pendingPermissions.clear();
|
|
287
|
+
for (const bk of this._sessionBookkeeping.values()) {
|
|
288
|
+
this.cancelPendingFileClears(bk);
|
|
289
|
+
this.cancelPendingReadClears(bk);
|
|
290
|
+
}
|
|
291
|
+
for (const timer of this._pendingIdleTimers.values()) {
|
|
292
|
+
clearTimeout(timer);
|
|
293
|
+
}
|
|
294
|
+
this._pendingIdleTimers.clear();
|
|
295
|
+
this._sessionBookkeeping.clear();
|
|
296
|
+
if (this._ipcStarted) {
|
|
297
|
+
void this.callbacks.ipc.unpublishAgents();
|
|
298
|
+
this._ipcStarted = false;
|
|
299
|
+
}
|
|
300
|
+
this._onDidChangeSessions.dispose();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
[Symbol.dispose](): void {
|
|
304
|
+
this.dispose();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
@gate<typeof ClaudeCodeProvider.prototype.ensureIpcServer>()
|
|
308
|
+
private async ensureIpcServer(): Promise<void> {
|
|
309
|
+
if (this._ipcStarted || this._disposed) return;
|
|
310
|
+
|
|
311
|
+
// Register handlers before any async work so blocking permission requests aren't delayed.
|
|
312
|
+
const handlers: UnifiedDisposable[] = [
|
|
313
|
+
this.callbacks.ipc.registerHandler('agents/session', (request, searchParams) => {
|
|
314
|
+
if (request != null) {
|
|
315
|
+
const isBlocking = searchParams.get('blocking') === 'true';
|
|
316
|
+
return this.handleSessionEvent(request as AgentSessionEvent, isBlocking);
|
|
317
|
+
}
|
|
318
|
+
return Promise.resolve();
|
|
319
|
+
}),
|
|
320
|
+
this.callbacks.ipc.registerHandler('agents/sessions/list', () =>
|
|
321
|
+
Promise.resolve(
|
|
322
|
+
this._sessions.map(s => ({
|
|
323
|
+
...s,
|
|
324
|
+
lastActivity: s.lastActivity.toISOString(),
|
|
325
|
+
phaseSince: s.phaseSince.toISOString(),
|
|
326
|
+
subagents: s.subagents?.map(sub => ({
|
|
327
|
+
...sub,
|
|
328
|
+
lastActivity: sub.lastActivity.toISOString(),
|
|
329
|
+
phaseSince: sub.phaseSince.toISOString(),
|
|
330
|
+
})),
|
|
331
|
+
})),
|
|
332
|
+
),
|
|
333
|
+
),
|
|
334
|
+
this.callbacks.ipc.registerHandler('agents/sessions/open', async request => {
|
|
335
|
+
const sessionId = (request as { sessionId?: string } | undefined)?.sessionId;
|
|
336
|
+
if (!sessionId || this.callbacks.openSessionInClaudeExtension == null) {
|
|
337
|
+
return { opened: false };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
try {
|
|
341
|
+
await this.callbacks.openSessionInClaudeExtension(sessionId);
|
|
342
|
+
return { opened: true };
|
|
343
|
+
} catch (ex) {
|
|
344
|
+
Logger.warn(
|
|
345
|
+
`ClaudeCodeProvider.agents/sessions/open: ${ex instanceof Error ? ex.message : String(ex)}`,
|
|
346
|
+
);
|
|
347
|
+
return { opened: false };
|
|
348
|
+
}
|
|
349
|
+
}),
|
|
350
|
+
];
|
|
351
|
+
|
|
352
|
+
try {
|
|
353
|
+
await this.callbacks.ipc.publishAgents(this._workspacePaths);
|
|
354
|
+
} catch (ex) {
|
|
355
|
+
// Unwind so a retry can re-register without hitting "already registered".
|
|
356
|
+
for (const d of handlers) {
|
|
357
|
+
d.dispose();
|
|
358
|
+
}
|
|
359
|
+
Logger.error(ex, 'ClaudeCodeProvider.ensureIpcServer');
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// `publishAgents` no-ops silently if the IPC server failed to start; don't seal
|
|
364
|
+
// retries in that case — the next call should re-attempt.
|
|
365
|
+
if (this.callbacks.ipc.port == null) {
|
|
366
|
+
for (const d of handlers) {
|
|
367
|
+
d.dispose();
|
|
368
|
+
}
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (this._disposed) {
|
|
373
|
+
for (const d of handlers) {
|
|
374
|
+
d.dispose();
|
|
375
|
+
}
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
this._handlerDisposables = handlers;
|
|
380
|
+
this._ipcStarted = true;
|
|
381
|
+
|
|
382
|
+
try {
|
|
383
|
+
await this.syncSessions();
|
|
384
|
+
if (this._disposed) return;
|
|
385
|
+
|
|
386
|
+
void this.querySiblingWindowSessions();
|
|
387
|
+
|
|
388
|
+
this._staleCheckTimer?.dispose();
|
|
389
|
+
this._staleCheckTimer = disposableInterval(() => void this.syncSessions(), staleCheckIntervalMs);
|
|
390
|
+
} catch (ex) {
|
|
391
|
+
Logger.error(ex, 'ClaudeCodeProvider.ensureIpcServer');
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
private handleSessionEvent(event: AgentSessionEvent, isBlocking: boolean): Promise<PermissionResponse | void> {
|
|
396
|
+
const workspacePath = this.resolveWorkspacePath(event.cwd);
|
|
397
|
+
const eventContext: SessionContext = {
|
|
398
|
+
pid: event.pid,
|
|
399
|
+
workspacePath: workspacePath,
|
|
400
|
+
isInWorkspace: workspacePath != null,
|
|
401
|
+
cwd: event.cwd,
|
|
402
|
+
planFile: event.planFile,
|
|
403
|
+
sessionName: event.sessionName,
|
|
404
|
+
};
|
|
405
|
+
const tag = this.sessionTag(event.sessionId, event.sessionName ?? 'unnamed');
|
|
406
|
+
|
|
407
|
+
if (event.event === 'SessionStart' || event.event === 'SessionEnd') {
|
|
408
|
+
Logger.info(`ClaudeCodeProvider.handleSessionEvent: ${event.event} ${tag}`);
|
|
409
|
+
} else {
|
|
410
|
+
Logger.debug(
|
|
411
|
+
`ClaudeCodeProvider.handleSessionEvent: ${event.event} ${tag}${event.toolName ? ` tool=${event.toolName}` : ''}${event.agentId ? ` agent=${event.agentId}` : ''}${event.notificationType ? ` type=${event.notificationType}` : ''}`,
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
switch (event.event) {
|
|
416
|
+
case 'SessionStart': {
|
|
417
|
+
const wasNew = this._sessions.findIndex(s => s.id === event.sessionId) < 0;
|
|
418
|
+
const { index } = this.ensureSession(event.sessionId, eventContext);
|
|
419
|
+
|
|
420
|
+
if (wasNew) {
|
|
421
|
+
// ensureSession created the session at 'idle' already. Just refresh pid if
|
|
422
|
+
// the event carries one — bookkeeping was implicitly clean.
|
|
423
|
+
this.resetBookkeeping(event.sessionId);
|
|
424
|
+
if (event.pid != null) {
|
|
425
|
+
const prev = this._sessions[index];
|
|
426
|
+
if (prev.pid !== event.pid) {
|
|
427
|
+
this._sessions[index] = { ...prev, pid: event.pid };
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
} else {
|
|
431
|
+
// SessionStart for a session we already track is almost always a CLI replay
|
|
432
|
+
// (resume/reconnect/hook re-init). Don't clobber live state — the next real
|
|
433
|
+
// event will re-establish status. Refresh pid only.
|
|
434
|
+
const prev = this._sessions[index];
|
|
435
|
+
if (event.pid != null && event.pid !== prev.pid) {
|
|
436
|
+
this._sessions[index] = { ...prev, pid: event.pid, lastActivity: new Date() };
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
this._onDidChangeSessions.fire();
|
|
441
|
+
void this.resolveTranscriptTitles(event.sessionId, event.cwd);
|
|
442
|
+
this.callbacks.onSessionStarted?.(this.id);
|
|
443
|
+
break;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
case 'SessionEnd': {
|
|
447
|
+
const pending = this._pendingPermissions.get(event.sessionId);
|
|
448
|
+
if (pending != null) {
|
|
449
|
+
pending.reject(new Error('Session ended'));
|
|
450
|
+
this._pendingPermissions.delete(event.sessionId);
|
|
451
|
+
}
|
|
452
|
+
this.cancelPendingIdleTransition(event.sessionId);
|
|
453
|
+
|
|
454
|
+
const index = this._sessions.findIndex(s => s.id === event.sessionId);
|
|
455
|
+
if (index >= 0) {
|
|
456
|
+
this._sessions.splice(index, 1);
|
|
457
|
+
this._onDidChangeSessions.fire();
|
|
458
|
+
}
|
|
459
|
+
const bk = this._sessionBookkeeping.get(event.sessionId);
|
|
460
|
+
if (bk != null) {
|
|
461
|
+
this.cancelPendingFileClears(bk);
|
|
462
|
+
this.cancelPendingReadClears(bk);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
this._sessionBookkeeping.delete(event.sessionId);
|
|
466
|
+
this._transcriptReader.forget(event.sessionId);
|
|
467
|
+
this.callbacks.onSessionEnded?.(this.id);
|
|
468
|
+
break;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
case 'UserPromptSubmit': {
|
|
472
|
+
const { index } = this.ensureSession(event.sessionId, eventContext);
|
|
473
|
+
const cleaned = prepareStoredPrompt(event.prompt);
|
|
474
|
+
// A `UserPromptSubmit` whose payload sanitizes to nothing is harness-synthetic
|
|
475
|
+
// (e.g. background-bash <task-notification>, slash-command stdout echo). Treat it
|
|
476
|
+
// as informational so it doesn't flip the session to `thinking` or wipe a pending
|
|
477
|
+
// permission when no real user activity occurred.
|
|
478
|
+
if (!cleaned) break;
|
|
479
|
+
|
|
480
|
+
const existing = this._sessions[index];
|
|
481
|
+
this._sessions[index] = {
|
|
482
|
+
...existing,
|
|
483
|
+
lastPrompt: cleaned,
|
|
484
|
+
firstPrompt: existing.firstPrompt ?? prepareStoredPrompt(event.firstPrompt),
|
|
485
|
+
};
|
|
486
|
+
this.clearStalePermission(event.sessionId, 'UserPromptSubmit');
|
|
487
|
+
this.updateSessionStatus(event.sessionId, 'thinking', eventContext);
|
|
488
|
+
break;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
case 'PreToolUse': {
|
|
492
|
+
const bk = this.getBookkeeping(event.sessionId);
|
|
493
|
+
bk.activeToolCount++;
|
|
494
|
+
const filesChanged = this.trackToolUseFile(event.sessionId, event);
|
|
495
|
+
const readsChanged = this.trackToolUseRead(event.sessionId, event);
|
|
496
|
+
// Resolve a rich `Bash(grep …)`-style detail via the same tool_input passthrough the
|
|
497
|
+
// PermissionRequest handler uses, so working sessions surface what their tool is
|
|
498
|
+
// actually doing — not just the bare tool name. Falls back to bare name when no
|
|
499
|
+
// toolInput is available (older CLI / unhandled tool).
|
|
500
|
+
const hookInput = event.hookInput;
|
|
501
|
+
const toolInput = (hookInput?.tool_input as Record<string, unknown> | undefined) ?? event.toolInput;
|
|
502
|
+
const toolName = (hookInput?.tool_name as string | undefined) ?? event.toolName ?? '';
|
|
503
|
+
const statusDetail =
|
|
504
|
+
toolInput != null && toolName ? describeToolInput(toolName, toolInput) : toolName || undefined;
|
|
505
|
+
// Capture pre-update status so we can tell whether the upcoming updateSessionStatus
|
|
506
|
+
// will fire on its own — needed to avoid dropping currentFiles changes when the
|
|
507
|
+
// session is already in tool_use (back-to-back tool calls).
|
|
508
|
+
const sessionIndex = this._sessions.findIndex(s => s.id === event.sessionId);
|
|
509
|
+
const prevStatus = sessionIndex >= 0 ? this._sessions[sessionIndex].status : undefined;
|
|
510
|
+
const prevDetail = sessionIndex >= 0 ? this._sessions[sessionIndex].statusDetail : undefined;
|
|
511
|
+
const statusWillFire = prevStatus !== 'tool_use' || prevDetail !== statusDetail;
|
|
512
|
+
this.updateSessionStatus(event.sessionId, 'tool_use', {
|
|
513
|
+
...eventContext,
|
|
514
|
+
statusDetail: statusDetail,
|
|
515
|
+
});
|
|
516
|
+
if ((filesChanged || readsChanged) && !statusWillFire) {
|
|
517
|
+
this._onDidChangeSessions.fire();
|
|
518
|
+
}
|
|
519
|
+
break;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
case 'PostToolUse':
|
|
523
|
+
case 'PostToolUseFailure': {
|
|
524
|
+
this.clearStalePermission(event.sessionId, event.event);
|
|
525
|
+
const bk = this.getBookkeeping(event.sessionId);
|
|
526
|
+
bk.activeToolCount = Math.max(0, bk.activeToolCount - 1);
|
|
527
|
+
// Cooldown the file-edit decoration instead of dropping it immediately, so a quick
|
|
528
|
+
// follow-up Edit/Write to the same path doesn't blink. The deferred clear fires its
|
|
529
|
+
// own change event when it expires.
|
|
530
|
+
this.scheduleClearToolUseFile(event.sessionId, event);
|
|
531
|
+
this.scheduleClearToolUseRead(event.sessionId, event);
|
|
532
|
+
if (bk.activeToolCount === 0) {
|
|
533
|
+
this.updateSessionStatus(event.sessionId, 'thinking', eventContext);
|
|
534
|
+
}
|
|
535
|
+
break;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
case 'Stop':
|
|
539
|
+
case 'StopFailure': {
|
|
540
|
+
const pending = this._pendingPermissions.get(event.sessionId);
|
|
541
|
+
if (pending != null) {
|
|
542
|
+
pending.reject(new Error('Session stopped'));
|
|
543
|
+
this._pendingPermissions.delete(event.sessionId);
|
|
544
|
+
}
|
|
545
|
+
this.resetBookkeeping(event.sessionId);
|
|
546
|
+
// Apply Stop's metadata synchronously so we don't carry stale context through the
|
|
547
|
+
// debounce window — any in-window mutations (e.g. CwdChanged) survive when the
|
|
548
|
+
// timer fires. ensureSession doesn't fire on metadata-only updates of existing
|
|
549
|
+
// sessions, so fire here to match the original Stop-handler's UI-update semantics.
|
|
550
|
+
const { changed } = this.ensureSession(event.sessionId, eventContext);
|
|
551
|
+
if (changed) {
|
|
552
|
+
this._onDidChangeSessions.fire();
|
|
553
|
+
}
|
|
554
|
+
// Defer the status change — if the agent continues within the debounce window
|
|
555
|
+
// (hook-driven re-prompt, IPC reordering, auto-resume), the next non-idle event
|
|
556
|
+
// cancels this and we never flick through idle.
|
|
557
|
+
this.scheduleIdleTransition(event.sessionId);
|
|
558
|
+
break;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
case 'PreCompact':
|
|
562
|
+
this.updateSessionStatus(event.sessionId, 'compacting', eventContext);
|
|
563
|
+
break;
|
|
564
|
+
|
|
565
|
+
case 'PostCompact':
|
|
566
|
+
this.updateSessionStatus(event.sessionId, 'thinking', eventContext);
|
|
567
|
+
break;
|
|
568
|
+
|
|
569
|
+
case 'Notification':
|
|
570
|
+
switch (event.notificationType) {
|
|
571
|
+
case 'idle_prompt':
|
|
572
|
+
// No-op — session is already idle from the preceding stop/session-start event
|
|
573
|
+
break;
|
|
574
|
+
case 'permission_prompt':
|
|
575
|
+
case 'elicitation_dialog':
|
|
576
|
+
this.updateSessionStatus(event.sessionId, 'permission_requested', {
|
|
577
|
+
...eventContext,
|
|
578
|
+
statusDetail: event.toolName,
|
|
579
|
+
});
|
|
580
|
+
break;
|
|
581
|
+
default:
|
|
582
|
+
// auth_success, unknown, or missing: no status change, just update timestamp
|
|
583
|
+
break;
|
|
584
|
+
}
|
|
585
|
+
break;
|
|
586
|
+
|
|
587
|
+
case 'PermissionRequest': {
|
|
588
|
+
// hookInput is the raw passthrough; fall back to top-level fields for older CLI versions.
|
|
589
|
+
const hookInput = event.hookInput;
|
|
590
|
+
const toolInput = (hookInput?.tool_input as Record<string, unknown> | undefined) ?? event.toolInput;
|
|
591
|
+
if (isBlocking && toolInput != null) {
|
|
592
|
+
const toolName = (hookInput?.tool_name as string | undefined) ?? event.toolName ?? '';
|
|
593
|
+
const toolDescription = describeToolInput(toolName, toolInput);
|
|
594
|
+
const toolInputDescription = (toolInput.description as string | undefined) || undefined;
|
|
595
|
+
const kind: PendingPermissionKind = classifyPermissionKind(toolName);
|
|
596
|
+
|
|
597
|
+
return new Promise<PermissionResponse>((resolve, reject) => {
|
|
598
|
+
const existing = this._pendingPermissions.get(event.sessionId);
|
|
599
|
+
if (existing != null) {
|
|
600
|
+
Logger.debug(
|
|
601
|
+
`ClaudeCodeProvider.handleSessionEvent: auto-denying stale permission ${tag} tool=${existing.toolName}`,
|
|
602
|
+
);
|
|
603
|
+
existing.resolve({
|
|
604
|
+
hookSpecificOutput: {
|
|
605
|
+
hookEventName: 'PermissionRequest',
|
|
606
|
+
decision: { behavior: 'deny' },
|
|
607
|
+
},
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
this._pendingPermissions.set(event.sessionId, {
|
|
612
|
+
resolve: resolve,
|
|
613
|
+
reject: reject,
|
|
614
|
+
toolName: toolName,
|
|
615
|
+
toolDescription: toolDescription,
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
// Plan-kind body: prefer the existing session's planFile (set via SessionStart/
|
|
619
|
+
// PostToolUse) and fall back to the event's planFile for cold-start cases.
|
|
620
|
+
const planFilePath =
|
|
621
|
+
kind === 'plan'
|
|
622
|
+
? (this._sessions.find(s => s.id === event.sessionId)?.planFile ?? event.planFile)
|
|
623
|
+
: undefined;
|
|
624
|
+
const planSummary = kind === 'plan' ? extractPlanSummary(toolInput) : undefined;
|
|
625
|
+
const questionDetails = kind === 'question' ? extractQuestionDetails(toolInput) : undefined;
|
|
626
|
+
|
|
627
|
+
const permission: PendingPermission = {
|
|
628
|
+
kind: kind,
|
|
629
|
+
toolName: toolName,
|
|
630
|
+
toolDescription: toolDescription,
|
|
631
|
+
toolInputDescription: toolInputDescription,
|
|
632
|
+
suggestions:
|
|
633
|
+
(hookInput?.permission_suggestions as PermissionSuggestion[] | undefined) ??
|
|
634
|
+
event.permissionSuggestions,
|
|
635
|
+
planFilePath: planFilePath,
|
|
636
|
+
planSummary: planSummary,
|
|
637
|
+
questionText: questionDetails?.text,
|
|
638
|
+
questionCount: questionDetails?.count,
|
|
639
|
+
};
|
|
640
|
+
this.updateSessionWithPermission(event.sessionId, permission, event.pid);
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
this.updateSessionStatus(event.sessionId, 'permission_requested', {
|
|
645
|
+
...eventContext,
|
|
646
|
+
statusDetail: event.toolName,
|
|
647
|
+
});
|
|
648
|
+
break;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
case 'PermissionDenied': {
|
|
652
|
+
this.clearStalePermission(event.sessionId, 'PermissionDenied');
|
|
653
|
+
const bk = this.getBookkeeping(event.sessionId);
|
|
654
|
+
bk.activeToolCount = Math.max(0, bk.activeToolCount - 1);
|
|
655
|
+
const filesChanged = this.untrackToolUseFile(event.sessionId, event);
|
|
656
|
+
const readsChanged = this.untrackToolUseRead(event.sessionId, event);
|
|
657
|
+
// When a parallel tool is still active the next status equals the current one and
|
|
658
|
+
// updateSessionStatus short-circuits — without an explicit fire, the dropped path
|
|
659
|
+
// would never reach subscribers (treemap activity overlay, WIP decoration).
|
|
660
|
+
const nextStatus = bk.activeToolCount > 0 ? 'tool_use' : 'thinking';
|
|
661
|
+
const sessionIndex = this._sessions.findIndex(s => s.id === event.sessionId);
|
|
662
|
+
const prevStatus = sessionIndex >= 0 ? this._sessions[sessionIndex].status : undefined;
|
|
663
|
+
const statusWillFire = prevStatus !== nextStatus;
|
|
664
|
+
this.updateSessionStatus(event.sessionId, nextStatus, eventContext);
|
|
665
|
+
if ((filesChanged || readsChanged) && !statusWillFire) {
|
|
666
|
+
this._onDidChangeSessions.fire();
|
|
667
|
+
}
|
|
668
|
+
break;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
case 'Elicitation': {
|
|
672
|
+
const bk = this.getBookkeeping(event.sessionId);
|
|
673
|
+
bk.pendingPermission = {
|
|
674
|
+
kind: 'elicitation',
|
|
675
|
+
toolName: event.toolName ?? 'Input Required',
|
|
676
|
+
toolDescription: event.toolName ?? 'Waiting for input',
|
|
677
|
+
};
|
|
678
|
+
this.updateSessionStatus(event.sessionId, 'permission_requested', {
|
|
679
|
+
...eventContext,
|
|
680
|
+
statusDetail: event.toolName,
|
|
681
|
+
});
|
|
682
|
+
break;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
case 'ElicitationResult': {
|
|
686
|
+
const bk = this.getBookkeeping(event.sessionId);
|
|
687
|
+
bk.pendingPermission = undefined;
|
|
688
|
+
this.updateSessionStatus(
|
|
689
|
+
event.sessionId,
|
|
690
|
+
bk.activeToolCount > 0 ? 'tool_use' : 'thinking',
|
|
691
|
+
eventContext,
|
|
692
|
+
);
|
|
693
|
+
break;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
case 'CwdChanged':
|
|
697
|
+
if (event.cwd) {
|
|
698
|
+
const index = this._sessions.findIndex(s => s.id === event.sessionId);
|
|
699
|
+
if (index >= 0 && this._sessions[index].cwd !== event.cwd) {
|
|
700
|
+
this._sessions[index] = { ...this._sessions[index], cwd: event.cwd };
|
|
701
|
+
this._onDidChangeSessions.fire();
|
|
702
|
+
}
|
|
703
|
+
// Clear the unresolvable flag so the new cwd actually gets re-probed — without
|
|
704
|
+
// this, a session that started in a non-git cwd would stay flagged forever even
|
|
705
|
+
// after moving into a git repo.
|
|
706
|
+
this.getBookkeeping(event.sessionId).gitInfoUnresolvable = false;
|
|
707
|
+
void this.resolveGitInfo(event.sessionId, event.cwd);
|
|
708
|
+
}
|
|
709
|
+
break;
|
|
710
|
+
|
|
711
|
+
case 'SubagentStart': {
|
|
712
|
+
const { index: parentIdx } = this.ensureSession(event.sessionId, eventContext);
|
|
713
|
+
const parent = this._sessions[parentIdx];
|
|
714
|
+
if (event.agentId) {
|
|
715
|
+
const now = new Date();
|
|
716
|
+
const subagent: AgentSession = {
|
|
717
|
+
id: event.agentId,
|
|
718
|
+
providerId: this.id,
|
|
719
|
+
providerName: this.name,
|
|
720
|
+
name: event.agentType ?? 'Subagent',
|
|
721
|
+
status: 'thinking',
|
|
722
|
+
phase: 'working',
|
|
723
|
+
phaseSince: now,
|
|
724
|
+
lastActivity: now,
|
|
725
|
+
isSubagent: true,
|
|
726
|
+
parentId: event.sessionId,
|
|
727
|
+
isInWorkspace: workspacePath != null,
|
|
728
|
+
};
|
|
729
|
+
const existingSubs = parent.subagents ? [...parent.subagents] : [];
|
|
730
|
+
existingSubs.push(subagent);
|
|
731
|
+
this._sessions[parentIdx] = { ...parent, subagents: existingSubs };
|
|
732
|
+
this._onDidChangeSessions.fire();
|
|
733
|
+
}
|
|
734
|
+
break;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
case 'SubagentStop': {
|
|
738
|
+
const { index: parentIdx } = this.ensureSession(event.sessionId, eventContext);
|
|
739
|
+
const parentSession = this._sessions[parentIdx];
|
|
740
|
+
if (parentSession.subagents != null && event.agentId) {
|
|
741
|
+
const filtered = parentSession.subagents.filter(s => s.id !== event.agentId);
|
|
742
|
+
if (filtered.length !== parentSession.subagents.length) {
|
|
743
|
+
this._sessions[parentIdx] = {
|
|
744
|
+
...parentSession,
|
|
745
|
+
subagents: filtered.length > 0 ? filtered : undefined,
|
|
746
|
+
};
|
|
747
|
+
this._onDidChangeSessions.fire();
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
break;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
case 'TeammateIdle':
|
|
754
|
+
case 'TaskCompleted':
|
|
755
|
+
case 'InstructionsLoaded':
|
|
756
|
+
case 'ConfigChange':
|
|
757
|
+
case 'WorktreeCreate':
|
|
758
|
+
case 'WorktreeRemove':
|
|
759
|
+
// Not yet handled — intentional no-op.
|
|
760
|
+
break;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
return Promise.resolve();
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
private sessionTag(sessionId: string, name?: string): string {
|
|
767
|
+
return name != null
|
|
768
|
+
? `[session=${sessionId.substring(0, 8)}(${name})]`
|
|
769
|
+
: `[session=${sessionId.substring(0, 8)}]`;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
private getBookkeeping(sessionId: string): SessionBookkeeping {
|
|
773
|
+
let bk = this._sessionBookkeeping.get(sessionId);
|
|
774
|
+
if (bk == null) {
|
|
775
|
+
bk = {
|
|
776
|
+
activeToolCount: 0,
|
|
777
|
+
currentFileCounts: new Map(),
|
|
778
|
+
pendingFileClears: new Map(),
|
|
779
|
+
currentReadCounts: new Map(),
|
|
780
|
+
pendingReadClears: new Map(),
|
|
781
|
+
gitInfoUnresolvable: false,
|
|
782
|
+
};
|
|
783
|
+
this._sessionBookkeeping.set(sessionId, bk);
|
|
784
|
+
}
|
|
785
|
+
return bk;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
private resetBookkeeping(sessionId: string): void {
|
|
789
|
+
const bk = this.getBookkeeping(sessionId);
|
|
790
|
+
bk.activeToolCount = 0;
|
|
791
|
+
bk.pendingPermission = undefined;
|
|
792
|
+
this.cancelPendingFileClears(bk);
|
|
793
|
+
bk.currentFileCounts.clear();
|
|
794
|
+
this.cancelPendingReadClears(bk);
|
|
795
|
+
bk.currentReadCounts.clear();
|
|
796
|
+
bk.gitInfoUnresolvable = false;
|
|
797
|
+
this.syncSessionCurrentFiles(sessionId, bk);
|
|
798
|
+
this.syncSessionCurrentReads(sessionId, bk);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
private cancelPendingFileClears(bk: SessionBookkeeping): void {
|
|
802
|
+
for (const timer of bk.pendingFileClears.values()) {
|
|
803
|
+
clearTimeout(timer);
|
|
804
|
+
}
|
|
805
|
+
bk.pendingFileClears.clear();
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
private cancelPendingReadClears(bk: SessionBookkeeping): void {
|
|
809
|
+
for (const timer of bk.pendingReadClears.values()) {
|
|
810
|
+
clearTimeout(timer);
|
|
811
|
+
}
|
|
812
|
+
bk.pendingReadClears.clear();
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/** Extracts the targeted file path from a PreToolUse/PostToolUse event. Tries the raw
|
|
816
|
+
* `hookInput.tool_input` passthrough first, falls back to the projected `event.toolInput`
|
|
817
|
+
* for older CLI versions that only fill the top-level fields. Treats an empty-object
|
|
818
|
+
* `hookInput.tool_input` as missing — `??` would have kept it (truthy) and shadowed a
|
|
819
|
+
* populated `event.toolInput`. */
|
|
820
|
+
private getEventFilePath(event: AgentSessionEvent): string | undefined {
|
|
821
|
+
const toolName = (event.hookInput?.tool_name as string | undefined) ?? event.toolName;
|
|
822
|
+
if (toolName == null) return undefined;
|
|
823
|
+
|
|
824
|
+
const rawHookInput = event.hookInput?.tool_input as Record<string, unknown> | undefined;
|
|
825
|
+
const hookInputPopulated = rawHookInput != null && Object.keys(rawHookInput).length > 0;
|
|
826
|
+
const toolInput = hookInputPopulated ? rawHookInput : event.toolInput;
|
|
827
|
+
return getToolFilePath(toolName, toolInput);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/** Records that a file-mutating tool call is in flight against `filePath` so the WIP details
|
|
831
|
+
* panel can decorate the row. Refcounts per path — keyed by path (not `tool_use_id`, which
|
|
832
|
+
* the GK CLI doesn't reliably surface) so parallel calls bump the count and Post/Pre order
|
|
833
|
+
* doesn't matter. Returns whether the session's published list actually changed so callers
|
|
834
|
+
* can fire when the surrounding {@link updateSessionStatus} short-circuits. */
|
|
835
|
+
private trackToolUseFile(sessionId: string, event: AgentSessionEvent): boolean {
|
|
836
|
+
const filePath = this.getEventFilePath(event);
|
|
837
|
+
if (filePath == null) return false;
|
|
838
|
+
|
|
839
|
+
const bk = this.getBookkeeping(sessionId);
|
|
840
|
+
// A fresh Pre during a pending Post-cooldown means the file is live again — cancel the
|
|
841
|
+
// scheduled clear so the deferred drop doesn't run after the new tool already finished.
|
|
842
|
+
const pendingClear = bk.pendingFileClears.get(filePath);
|
|
843
|
+
if (pendingClear != null) {
|
|
844
|
+
clearTimeout(pendingClear);
|
|
845
|
+
bk.pendingFileClears.delete(filePath);
|
|
846
|
+
}
|
|
847
|
+
bk.currentFileCounts.set(filePath, (bk.currentFileCounts.get(filePath) ?? 0) + 1);
|
|
848
|
+
return this.syncSessionCurrentFiles(sessionId, bk);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/** Decrements the in-flight refcount for a finished tool call's file. While the count stays
|
|
852
|
+
* above zero (overlapping parallel calls) the path remains decorated. When the count drops
|
|
853
|
+
* to zero, leaves the entry in place at count `0` and schedules a {@link postToolUseClearDelayMs}
|
|
854
|
+
* cooldown before actually dropping it — so back-to-back Edit/Write to the same file doesn't
|
|
855
|
+
* flicker the decoration. A Pre arriving during cooldown cancels the timer and bumps the
|
|
856
|
+
* count back above zero. */
|
|
857
|
+
private scheduleClearToolUseFile(sessionId: string, event: AgentSessionEvent): void {
|
|
858
|
+
const filePath = this.getEventFilePath(event);
|
|
859
|
+
if (filePath == null) return;
|
|
860
|
+
|
|
861
|
+
const bk = this.getBookkeeping(sessionId);
|
|
862
|
+
const count = bk.currentFileCounts.get(filePath);
|
|
863
|
+
// A duplicate / out-of-order Post during cooldown (count is already 0) would drive the
|
|
864
|
+
// refcount negative and schedule a second timer — ignore those so the cooldown stays
|
|
865
|
+
// authoritative until either its timer fires or a fresh Pre bumps the count back up.
|
|
866
|
+
if (count == null || count <= 0) return;
|
|
867
|
+
|
|
868
|
+
const next = count - 1;
|
|
869
|
+
bk.currentFileCounts.set(filePath, next);
|
|
870
|
+
if (next > 0) return;
|
|
871
|
+
|
|
872
|
+
// Refcount just hit zero — keep the path in `currentFileCounts` (still decorated) for the
|
|
873
|
+
// cooldown window, then drop it.
|
|
874
|
+
const existing = bk.pendingFileClears.get(filePath);
|
|
875
|
+
if (existing != null) {
|
|
876
|
+
clearTimeout(existing);
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
const timer = setTimeout(() => {
|
|
880
|
+
if (this._disposed) return;
|
|
881
|
+
|
|
882
|
+
const cur = this._sessionBookkeeping.get(sessionId);
|
|
883
|
+
if (cur == null) return;
|
|
884
|
+
|
|
885
|
+
cur.pendingFileClears.delete(filePath);
|
|
886
|
+
// Re-check: a Pre during cooldown bumped the count and cancelled this timer, but a
|
|
887
|
+
// concurrent reset could have cleared everything — bail in either case.
|
|
888
|
+
if ((cur.currentFileCounts.get(filePath) ?? 0) > 0) return;
|
|
889
|
+
|
|
890
|
+
cur.currentFileCounts.delete(filePath);
|
|
891
|
+
if (this.syncSessionCurrentFiles(sessionId, cur)) {
|
|
892
|
+
this._onDidChangeSessions.fire();
|
|
893
|
+
}
|
|
894
|
+
}, postToolUseClearDelayMs);
|
|
895
|
+
bk.pendingFileClears.set(filePath, timer);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/** Drops a tracked tool call's file immediately (no cooldown). Used for PermissionDenied,
|
|
899
|
+
* where the tool never ran — keeping a "currently editing" badge would be misleading.
|
|
900
|
+
* Returns whether the published list changed. */
|
|
901
|
+
private untrackToolUseFile(sessionId: string, event: AgentSessionEvent): boolean {
|
|
902
|
+
const filePath = this.getEventFilePath(event);
|
|
903
|
+
if (filePath == null) return false;
|
|
904
|
+
|
|
905
|
+
const bk = this.getBookkeeping(sessionId);
|
|
906
|
+
const count = bk.currentFileCounts.get(filePath);
|
|
907
|
+
if (count == null) return false;
|
|
908
|
+
if (count > 1) {
|
|
909
|
+
bk.currentFileCounts.set(filePath, count - 1);
|
|
910
|
+
return false;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
const pending = bk.pendingFileClears.get(filePath);
|
|
914
|
+
if (pending != null) {
|
|
915
|
+
clearTimeout(pending);
|
|
916
|
+
bk.pendingFileClears.delete(filePath);
|
|
917
|
+
}
|
|
918
|
+
bk.currentFileCounts.delete(filePath);
|
|
919
|
+
return this.syncSessionCurrentFiles(sessionId, bk);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/** Writes the bookkeeping's tracked file paths onto the session in place. Returns whether the
|
|
923
|
+
* session's `currentFiles` actually changed. Keys (not values) — entries with count `0` are
|
|
924
|
+
* cooldown-residents, still part of the published list. */
|
|
925
|
+
private syncSessionCurrentFiles(sessionId: string, bk: SessionBookkeeping): boolean {
|
|
926
|
+
const index = this._sessions.findIndex(s => s.id === sessionId);
|
|
927
|
+
if (index < 0) return false;
|
|
928
|
+
|
|
929
|
+
const prev = this._sessions[index];
|
|
930
|
+
const next = bk.currentFileCounts.size > 0 ? [...bk.currentFileCounts.keys()] : undefined;
|
|
931
|
+
if (currentFilesEqual(prev.currentFiles, next)) return false;
|
|
932
|
+
|
|
933
|
+
this._sessions[index] = { ...prev, currentFiles: next };
|
|
934
|
+
return true;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/** Mirror of {@link getEventFilePath} for read-only file tools. */
|
|
938
|
+
private getEventReadPath(event: AgentSessionEvent): string | undefined {
|
|
939
|
+
const toolName = (event.hookInput?.tool_name as string | undefined) ?? event.toolName;
|
|
940
|
+
if (toolName == null) return undefined;
|
|
941
|
+
|
|
942
|
+
const rawHookInput = event.hookInput?.tool_input as Record<string, unknown> | undefined;
|
|
943
|
+
const hookInputPopulated = rawHookInput != null && Object.keys(rawHookInput).length > 0;
|
|
944
|
+
const toolInput = hookInputPopulated ? rawHookInput : event.toolInput;
|
|
945
|
+
return getToolReadPath(toolName, toolInput);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/** Refcount mirror of {@link trackToolUseFile} for read-only file tools. */
|
|
949
|
+
private trackToolUseRead(sessionId: string, event: AgentSessionEvent): boolean {
|
|
950
|
+
const filePath = this.getEventReadPath(event);
|
|
951
|
+
if (filePath == null) return false;
|
|
952
|
+
|
|
953
|
+
const bk = this.getBookkeeping(sessionId);
|
|
954
|
+
const pendingClear = bk.pendingReadClears.get(filePath);
|
|
955
|
+
if (pendingClear != null) {
|
|
956
|
+
clearTimeout(pendingClear);
|
|
957
|
+
bk.pendingReadClears.delete(filePath);
|
|
958
|
+
}
|
|
959
|
+
bk.currentReadCounts.set(filePath, (bk.currentReadCounts.get(filePath) ?? 0) + 1);
|
|
960
|
+
return this.syncSessionCurrentReads(sessionId, bk);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/** Cooldown mirror of {@link scheduleClearToolUseFile} for read-only file tools. */
|
|
964
|
+
private scheduleClearToolUseRead(sessionId: string, event: AgentSessionEvent): void {
|
|
965
|
+
const filePath = this.getEventReadPath(event);
|
|
966
|
+
if (filePath == null) return;
|
|
967
|
+
|
|
968
|
+
const bk = this.getBookkeeping(sessionId);
|
|
969
|
+
const count = bk.currentReadCounts.get(filePath);
|
|
970
|
+
// Same guard as scheduleClearToolUseFile — duplicate / out-of-order Posts during cooldown
|
|
971
|
+
// would otherwise drive the refcount negative and stack up redundant clear timers.
|
|
972
|
+
if (count == null || count <= 0) return;
|
|
973
|
+
|
|
974
|
+
const next = count - 1;
|
|
975
|
+
bk.currentReadCounts.set(filePath, next);
|
|
976
|
+
if (next > 0) return;
|
|
977
|
+
|
|
978
|
+
const existing = bk.pendingReadClears.get(filePath);
|
|
979
|
+
if (existing != null) {
|
|
980
|
+
clearTimeout(existing);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
const timer = setTimeout(() => {
|
|
984
|
+
if (this._disposed) return;
|
|
985
|
+
|
|
986
|
+
const cur = this._sessionBookkeeping.get(sessionId);
|
|
987
|
+
if (cur == null) return;
|
|
988
|
+
|
|
989
|
+
cur.pendingReadClears.delete(filePath);
|
|
990
|
+
if ((cur.currentReadCounts.get(filePath) ?? 0) > 0) return;
|
|
991
|
+
|
|
992
|
+
cur.currentReadCounts.delete(filePath);
|
|
993
|
+
if (this.syncSessionCurrentReads(sessionId, cur)) {
|
|
994
|
+
this._onDidChangeSessions.fire();
|
|
995
|
+
}
|
|
996
|
+
}, postToolUseClearDelayMs);
|
|
997
|
+
bk.pendingReadClears.set(filePath, timer);
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/** Immediate-drop mirror of {@link untrackToolUseFile} for read-only file tools. */
|
|
1001
|
+
private untrackToolUseRead(sessionId: string, event: AgentSessionEvent): boolean {
|
|
1002
|
+
const filePath = this.getEventReadPath(event);
|
|
1003
|
+
if (filePath == null) return false;
|
|
1004
|
+
|
|
1005
|
+
const bk = this.getBookkeeping(sessionId);
|
|
1006
|
+
const count = bk.currentReadCounts.get(filePath);
|
|
1007
|
+
if (count == null) return false;
|
|
1008
|
+
if (count > 1) {
|
|
1009
|
+
bk.currentReadCounts.set(filePath, count - 1);
|
|
1010
|
+
return false;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
const pending = bk.pendingReadClears.get(filePath);
|
|
1014
|
+
if (pending != null) {
|
|
1015
|
+
clearTimeout(pending);
|
|
1016
|
+
bk.pendingReadClears.delete(filePath);
|
|
1017
|
+
}
|
|
1018
|
+
bk.currentReadCounts.delete(filePath);
|
|
1019
|
+
return this.syncSessionCurrentReads(sessionId, bk);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/** Sync mirror of {@link syncSessionCurrentFiles} for read paths. Uses {@link currentFilesEqual}
|
|
1023
|
+
* (which is just a generic order-insensitive `string[] | undefined` comparator). */
|
|
1024
|
+
private syncSessionCurrentReads(sessionId: string, bk: SessionBookkeeping): boolean {
|
|
1025
|
+
const index = this._sessions.findIndex(s => s.id === sessionId);
|
|
1026
|
+
if (index < 0) return false;
|
|
1027
|
+
|
|
1028
|
+
const prev = this._sessions[index];
|
|
1029
|
+
const next = bk.currentReadCounts.size > 0 ? [...bk.currentReadCounts.keys()] : undefined;
|
|
1030
|
+
if (currentFilesEqual(prev.currentReads, next)) return false;
|
|
1031
|
+
|
|
1032
|
+
this._sessions[index] = { ...prev, currentReads: next };
|
|
1033
|
+
return true;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/** Defer the `Stop → idle` commit. If the agent produces a non-idle event within
|
|
1037
|
+
* `stopToIdleDebounceMs`, `cancelPendingIdleTransition` (called from `updateSessionStatus`)
|
|
1038
|
+
* cancels this and the session stays in its prior phase — no working → idle → working flicker.
|
|
1039
|
+
*
|
|
1040
|
+
* Intentionally takes no context: the caller is expected to apply any event metadata
|
|
1041
|
+
* synchronously before scheduling. Carrying Stop-time context into the timer would let
|
|
1042
|
+
* in-window metadata mutations (e.g. `CwdChanged` updating `_sessions[i].cwd` directly)
|
|
1043
|
+
* get reverted when the deferred commit replays the stale context through `ensureSession`. */
|
|
1044
|
+
private scheduleIdleTransition(sessionId: string): void {
|
|
1045
|
+
this.cancelPendingIdleTransition(sessionId);
|
|
1046
|
+
const timer = setTimeout(() => {
|
|
1047
|
+
this._pendingIdleTimers.delete(sessionId);
|
|
1048
|
+
// Session may have been removed (SessionEnd, prune) during the window.
|
|
1049
|
+
if (this._sessions.findIndex(s => s.id === sessionId) < 0) return;
|
|
1050
|
+
|
|
1051
|
+
this.updateSessionStatus(sessionId, 'idle');
|
|
1052
|
+
}, stopToIdleDebounceMs);
|
|
1053
|
+
this._pendingIdleTimers.set(sessionId, timer);
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
private cancelPendingIdleTransition(sessionId: string): void {
|
|
1057
|
+
const timer = this._pendingIdleTimers.get(sessionId);
|
|
1058
|
+
if (timer == null) return;
|
|
1059
|
+
|
|
1060
|
+
clearTimeout(timer);
|
|
1061
|
+
this._pendingIdleTimers.delete(sessionId);
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
/** Stable phase-since: if we're oscillating back into the phase we were just in (within a
|
|
1065
|
+
* short window), restore its original timestamp so the displayed elapsed time doesn't snap
|
|
1066
|
+
* to 0. Otherwise records the current phase as the new "prior" and returns `now`. */
|
|
1067
|
+
private resolvePhaseSince(
|
|
1068
|
+
sessionId: string,
|
|
1069
|
+
prevPhase: AgentSessionPhase,
|
|
1070
|
+
prevPhaseSince: Date,
|
|
1071
|
+
newPhase: AgentSessionPhase,
|
|
1072
|
+
): Date {
|
|
1073
|
+
if (prevPhase === newPhase) return prevPhaseSince;
|
|
1074
|
+
|
|
1075
|
+
const bk = this.getBookkeeping(sessionId);
|
|
1076
|
+
const now = new Date();
|
|
1077
|
+
|
|
1078
|
+
if (bk.priorPhase?.phase === newPhase && now.getTime() - prevPhaseSince.getTime() < phaseSinceRestoreWindowMs) {
|
|
1079
|
+
const restored = bk.priorPhase.phaseSince;
|
|
1080
|
+
bk.priorPhase = { phase: prevPhase, phaseSince: prevPhaseSince };
|
|
1081
|
+
return restored;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
bk.priorPhase = { phase: prevPhase, phaseSince: prevPhaseSince };
|
|
1085
|
+
return now;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
// Called when a pending permission was resolved outside GitLens (e.g. via the CLI terminal).
|
|
1089
|
+
// The 'deny' response is a safe no-op since the tool has already run or been denied upstream.
|
|
1090
|
+
private clearStalePermission(sessionId: string, reason: string): void {
|
|
1091
|
+
const bk = this.getBookkeeping(sessionId);
|
|
1092
|
+
if (bk.pendingPermission == null) return;
|
|
1093
|
+
|
|
1094
|
+
Logger.debug(`ClaudeCodeProvider.clearStalePermission: ${reason} ${this.sessionTag(sessionId)}`);
|
|
1095
|
+
|
|
1096
|
+
const pending = this._pendingPermissions.get(sessionId);
|
|
1097
|
+
if (pending != null) {
|
|
1098
|
+
pending.resolve({
|
|
1099
|
+
hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: { behavior: 'deny' } },
|
|
1100
|
+
});
|
|
1101
|
+
this._pendingPermissions.delete(sessionId);
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
bk.pendingPermission = undefined;
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
resolvePermission(
|
|
1108
|
+
sessionId: string,
|
|
1109
|
+
decision: PermissionDecision,
|
|
1110
|
+
updatedPermissions?: PermissionSuggestion[],
|
|
1111
|
+
): boolean {
|
|
1112
|
+
const pending = this._pendingPermissions.get(sessionId);
|
|
1113
|
+
if (pending == null) return false;
|
|
1114
|
+
|
|
1115
|
+
Logger.debug(
|
|
1116
|
+
`ClaudeCodeProvider.resolvePermission: ${decision} ${this.sessionTag(sessionId)} tool=${pending.toolName}`,
|
|
1117
|
+
);
|
|
1118
|
+
pending.resolve({
|
|
1119
|
+
hookSpecificOutput: {
|
|
1120
|
+
hookEventName: 'PermissionRequest',
|
|
1121
|
+
decision: { behavior: decision, updatedPermissions: updatedPermissions },
|
|
1122
|
+
},
|
|
1123
|
+
});
|
|
1124
|
+
this.callbacks.onPermissionResolved?.({
|
|
1125
|
+
provider: this.id,
|
|
1126
|
+
tool: pending.toolName,
|
|
1127
|
+
decision: decision,
|
|
1128
|
+
});
|
|
1129
|
+
this._pendingPermissions.delete(sessionId);
|
|
1130
|
+
|
|
1131
|
+
const bk = this.getBookkeeping(sessionId);
|
|
1132
|
+
bk.pendingPermission = undefined;
|
|
1133
|
+
|
|
1134
|
+
const nextStatus = bk.activeToolCount > 0 ? 'tool_use' : 'thinking';
|
|
1135
|
+
this.updateSessionStatus(sessionId, nextStatus);
|
|
1136
|
+
return true;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
private matchesWorkspace(workspacePath: string | undefined): boolean {
|
|
1140
|
+
if (!workspacePath) return false;
|
|
1141
|
+
|
|
1142
|
+
// `_workspacePaths` is normalized; normalize the input so prefix comparisons work
|
|
1143
|
+
// regardless of whether the caller passed a raw `fsPath` (CLI hook cwd, peer-session
|
|
1144
|
+
// workspacePath) or an already-normalized path.
|
|
1145
|
+
const normalized = normalizePath(workspacePath);
|
|
1146
|
+
return this._workspacePaths.some(
|
|
1147
|
+
p => normalized === p || normalized.startsWith(`${p}/`) || p.startsWith(`${normalized}/`),
|
|
1148
|
+
);
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
private resolveWorkspacePath(cwd: string | undefined): string | undefined {
|
|
1152
|
+
if (!cwd) return undefined;
|
|
1153
|
+
|
|
1154
|
+
const normalized = normalizePath(cwd);
|
|
1155
|
+
return this._workspacePaths.find(
|
|
1156
|
+
p => normalized === p || normalized.startsWith(`${p}/`) || p.startsWith(`${normalized}/`),
|
|
1157
|
+
);
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
private updateSessionWithPermission(sessionId: string, permission: PendingPermission, pid?: number): void {
|
|
1161
|
+
// A pending permission is a working/waiting transition — cancel any deferred Stop → idle
|
|
1162
|
+
// commit so the session doesn't briefly flip to idle before showing the prompt.
|
|
1163
|
+
this.cancelPendingIdleTransition(sessionId);
|
|
1164
|
+
|
|
1165
|
+
const { index } = this.ensureSession(sessionId, { pid: pid });
|
|
1166
|
+
|
|
1167
|
+
const prev = this._sessions[index];
|
|
1168
|
+
const newPhase = getPhaseForStatus('permission_requested');
|
|
1169
|
+
this.getBookkeeping(sessionId).pendingPermission = permission;
|
|
1170
|
+
this._sessions[index] = {
|
|
1171
|
+
...prev,
|
|
1172
|
+
status: 'permission_requested',
|
|
1173
|
+
phase: newPhase,
|
|
1174
|
+
phaseSince: this.resolvePhaseSince(sessionId, prev.phase, prev.phaseSince, newPhase),
|
|
1175
|
+
statusDetail: permission.toolDescription,
|
|
1176
|
+
pendingPermission: permission,
|
|
1177
|
+
lastActivity: new Date(),
|
|
1178
|
+
};
|
|
1179
|
+
this._onDidChangeSessions.fire();
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
private updateSessionStatus(
|
|
1183
|
+
sessionId: string,
|
|
1184
|
+
status: AgentSessionStatus,
|
|
1185
|
+
options?: SessionContext & { statusDetail?: string },
|
|
1186
|
+
): void {
|
|
1187
|
+
// Any non-idle status update implicitly cancels a deferred Stop → idle commit. The
|
|
1188
|
+
// timer is owned here so the schedule/cancel pair is uniformly enforced regardless of
|
|
1189
|
+
// which event path produced the next status.
|
|
1190
|
+
if (status !== 'idle') {
|
|
1191
|
+
this.cancelPendingIdleTransition(sessionId);
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
const { index, changed: metadataChanged } = this.ensureSession(sessionId, options);
|
|
1195
|
+
|
|
1196
|
+
const prev = this._sessions[index];
|
|
1197
|
+
const bk = this.getBookkeeping(sessionId);
|
|
1198
|
+
|
|
1199
|
+
// If a permission/elicitation is pending, preserve permission_requested state.
|
|
1200
|
+
// Tool counts and other bookkeeping are still updated by callers before this
|
|
1201
|
+
// method, but the visible status remains locked until the wait is explicitly
|
|
1202
|
+
// resolved.
|
|
1203
|
+
if (bk.pendingPermission != null && status !== 'permission_requested') {
|
|
1204
|
+
this._sessions[index] = { ...prev, lastActivity: new Date() };
|
|
1205
|
+
if (metadataChanged) {
|
|
1206
|
+
this._onDidChangeSessions.fire();
|
|
1207
|
+
}
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
const statusDetail = options?.statusDetail;
|
|
1212
|
+
if (prev.status === status && prev.statusDetail === statusDetail) {
|
|
1213
|
+
if (metadataChanged) {
|
|
1214
|
+
this._onDidChangeSessions.fire();
|
|
1215
|
+
}
|
|
1216
|
+
return;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
const newPhase = getPhaseForStatus(status);
|
|
1220
|
+
this._sessions[index] = {
|
|
1221
|
+
...prev,
|
|
1222
|
+
status: status,
|
|
1223
|
+
phase: newPhase,
|
|
1224
|
+
phaseSince: this.resolvePhaseSince(sessionId, prev.phase, prev.phaseSince, newPhase),
|
|
1225
|
+
statusDetail: statusDetail,
|
|
1226
|
+
pendingPermission: status === 'permission_requested' ? bk.pendingPermission : undefined,
|
|
1227
|
+
lastActivity: new Date(),
|
|
1228
|
+
};
|
|
1229
|
+
this._onDidChangeSessions.fire();
|
|
1230
|
+
|
|
1231
|
+
if (status === 'thinking' || status === 'tool_use' || status === 'compacting') {
|
|
1232
|
+
const sessionCwd = this._sessions[index].cwd;
|
|
1233
|
+
if (sessionCwd != null) {
|
|
1234
|
+
this.callbacks.onBranchAgentActivity?.(sessionCwd);
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
// On a fresh transition into idle, recheck transcript titles — Claude typically (re-)writes
|
|
1239
|
+
// `ai-title` right after finishing a response, so this is the prime moment to pick it up.
|
|
1240
|
+
// Tail-read caching makes repeated checks cheap.
|
|
1241
|
+
if (status === 'idle' && prev.status !== 'idle') {
|
|
1242
|
+
void this.resolveTranscriptTitles(sessionId, this._sessions[index].cwd);
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
private ensureSession(sessionId: string, context?: SessionContext): { index: number; changed: boolean } {
|
|
1247
|
+
const { pid, workspacePath, isInWorkspace, cwd, planFile, sessionName } = context ?? {};
|
|
1248
|
+
|
|
1249
|
+
let index = this._sessions.findIndex(s => s.id === sessionId);
|
|
1250
|
+
if (index < 0) {
|
|
1251
|
+
const now = new Date();
|
|
1252
|
+
index = this._sessions.length;
|
|
1253
|
+
this._sessions.push({
|
|
1254
|
+
id: sessionId,
|
|
1255
|
+
providerId: this.id,
|
|
1256
|
+
providerName: this.name,
|
|
1257
|
+
name: sessionName || undefined,
|
|
1258
|
+
status: 'idle',
|
|
1259
|
+
phase: getPhaseForStatus('idle'),
|
|
1260
|
+
phaseSince: now,
|
|
1261
|
+
pid: pid,
|
|
1262
|
+
lastActivity: now,
|
|
1263
|
+
isSubagent: false,
|
|
1264
|
+
workspacePath: workspacePath,
|
|
1265
|
+
cwd: cwd,
|
|
1266
|
+
planFile: planFile,
|
|
1267
|
+
isInWorkspace: isInWorkspace ?? false,
|
|
1268
|
+
});
|
|
1269
|
+
Logger.debug(
|
|
1270
|
+
`ClaudeCodeProvider.ensureSession: implicitly created ${this.sessionTag(sessionId, sessionName ?? 'unnamed')}`,
|
|
1271
|
+
);
|
|
1272
|
+
this._onDidChangeSessions.fire();
|
|
1273
|
+
|
|
1274
|
+
if (cwd != null) {
|
|
1275
|
+
void this.resolveGitInfo(sessionId, cwd);
|
|
1276
|
+
}
|
|
1277
|
+
void this.resolveTranscriptTitles(sessionId, cwd);
|
|
1278
|
+
return { index: index, changed: true };
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
const existing = this._sessions[index];
|
|
1282
|
+
const updatedPid = pid != null && existing.pid == null ? pid : existing.pid;
|
|
1283
|
+
const updatedWorkspacePath = workspacePath || existing.workspacePath;
|
|
1284
|
+
const updatedIsInWorkspace = workspacePath ? (isInWorkspace ?? existing.isInWorkspace) : existing.isInWorkspace;
|
|
1285
|
+
const updatedPlanFile = planFile ?? existing.planFile;
|
|
1286
|
+
const updatedName = sessionName || existing.name;
|
|
1287
|
+
const updatedCwd = cwd ?? existing.cwd;
|
|
1288
|
+
|
|
1289
|
+
let changed = false;
|
|
1290
|
+
if (
|
|
1291
|
+
updatedPid !== existing.pid ||
|
|
1292
|
+
updatedWorkspacePath !== existing.workspacePath ||
|
|
1293
|
+
updatedIsInWorkspace !== existing.isInWorkspace ||
|
|
1294
|
+
updatedPlanFile !== existing.planFile ||
|
|
1295
|
+
updatedName !== existing.name ||
|
|
1296
|
+
updatedCwd !== existing.cwd
|
|
1297
|
+
) {
|
|
1298
|
+
this._sessions[index] = {
|
|
1299
|
+
...existing,
|
|
1300
|
+
name: updatedName,
|
|
1301
|
+
pid: updatedPid,
|
|
1302
|
+
workspacePath: updatedWorkspacePath,
|
|
1303
|
+
cwd: updatedCwd,
|
|
1304
|
+
planFile: updatedPlanFile,
|
|
1305
|
+
isInWorkspace: updatedIsInWorkspace,
|
|
1306
|
+
};
|
|
1307
|
+
changed = true;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
// Re-fire `resolveGitInfo` if the session is missing either `worktreePath` or `commonPath`.
|
|
1311
|
+
// Peer-synced sessions arrive with `worktreePath` already set (from the peer that owns the
|
|
1312
|
+
// hook flow) but may lack `commonPath` if the peer was running pre-commonPath code; this
|
|
1313
|
+
// gives us a chance to fill it in locally. Idempotent — `resolveGitInfo` only mutates the
|
|
1314
|
+
// session when one of `cwd`/`worktreePath`/`commonPath` actually changes. The
|
|
1315
|
+
// `gitInfoUnresolvable` bookkeeping flag (see `SessionBookkeeping`) is set once
|
|
1316
|
+
// `resolveGitInfo` confirms the cwd isn't a git repo, preventing a retry storm on every
|
|
1317
|
+
// hook event for non-git-repo cwds. The flag is cleared on cwd change (`CwdChanged`) and
|
|
1318
|
+
// on `resetBookkeeping` (Stop/SessionStart) so re-resolution can happen when warranted.
|
|
1319
|
+
if (
|
|
1320
|
+
cwd != null &&
|
|
1321
|
+
(existing.worktreePath == null || existing.commonPath == null) &&
|
|
1322
|
+
!this.getBookkeeping(sessionId).gitInfoUnresolvable
|
|
1323
|
+
) {
|
|
1324
|
+
void this.resolveGitInfo(sessionId, cwd);
|
|
1325
|
+
}
|
|
1326
|
+
return { index: index, changed: changed };
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
private async resolveGitInfo(sessionId: string, cwd: string): Promise<void> {
|
|
1330
|
+
const resolveGitInfo = this.callbacks.resolveGitInfo;
|
|
1331
|
+
if (resolveGitInfo == null) return;
|
|
1332
|
+
|
|
1333
|
+
// Dedupe concurrent calls for the same session — every hook event (PreToolUse, PostToolUse,
|
|
1334
|
+
// UserPromptSubmit, etc.) flows through `ensureSession`, which retries `resolveGitInfo`
|
|
1335
|
+
// whenever `commonPath`/`worktreePath` is missing. Without this guard a non-git-repo cwd
|
|
1336
|
+
// would spawn a fresh git probe per hook event. The `gitInfoUnresolvable` bookkeeping flag
|
|
1337
|
+
// (set below when `info == null`) is the long-lived suppressor across hook events; this
|
|
1338
|
+
// in-flight set only dedupes concurrent overlapping probes within a single resolution.
|
|
1339
|
+
if (this._resolveGitInfoInFlight.has(sessionId)) return;
|
|
1340
|
+
|
|
1341
|
+
this._resolveGitInfoInFlight.add(sessionId);
|
|
1342
|
+
try {
|
|
1343
|
+
let info: Awaited<ReturnType<typeof resolveGitInfo>> | undefined;
|
|
1344
|
+
try {
|
|
1345
|
+
info = await resolveGitInfo(cwd);
|
|
1346
|
+
} catch {
|
|
1347
|
+
// Git not available or not a git repo — fall through to mark unresolvable below
|
|
1348
|
+
info = undefined;
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
const index = this._sessions.findIndex(s => s.id === sessionId);
|
|
1352
|
+
if (index < 0) return;
|
|
1353
|
+
|
|
1354
|
+
const session = this._sessions[index];
|
|
1355
|
+
|
|
1356
|
+
// `workspacePath` stays as the matched workspace folder (or undefined). Repo identity
|
|
1357
|
+
// flows through `commonPath`, populated here from `info.repoRoot` at the same step as
|
|
1358
|
+
// `worktreePath` — both available together from `resolveGitInfo`, much earlier than the
|
|
1359
|
+
// follow-on worktree-name refresh (which needs `getWorktrees()` on the parent repo).
|
|
1360
|
+
//
|
|
1361
|
+
// When `info == null` (cwd is not inside any git repo), set the `gitInfoUnresolvable`
|
|
1362
|
+
// bookkeeping flag so subsequent `ensureSession`/peer-sync retry checks skip re-firing.
|
|
1363
|
+
// We deliberately do NOT write a tombstone onto the session DTO — keeping `commonPath`
|
|
1364
|
+
// as `undefined` (a) keeps the wire-shape unambiguous for peers, (b) lets consumers
|
|
1365
|
+
// safely treat undefined as "no repo identity", and (c) means peers can still attempt
|
|
1366
|
+
// their own local resolution. The flag is cleared on cwd change (`CwdChanged`) and on
|
|
1367
|
+
// `resetBookkeeping` so re-resolution happens when warranted (e.g. user runs `git init`
|
|
1368
|
+
// followed by a new session start).
|
|
1369
|
+
if (info == null) {
|
|
1370
|
+
this.getBookkeeping(sessionId).gitInfoUnresolvable = true;
|
|
1371
|
+
if (cwd !== session.cwd) {
|
|
1372
|
+
this._sessions[index] = { ...session, cwd: cwd };
|
|
1373
|
+
this._onDidChangeSessions.fire();
|
|
1374
|
+
}
|
|
1375
|
+
return;
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
if (
|
|
1379
|
+
cwd !== session.cwd ||
|
|
1380
|
+
info.worktreePath !== session.worktreePath ||
|
|
1381
|
+
info.repoRoot !== session.commonPath
|
|
1382
|
+
) {
|
|
1383
|
+
this._sessions[index] = {
|
|
1384
|
+
...session,
|
|
1385
|
+
cwd: cwd,
|
|
1386
|
+
worktreePath: info.worktreePath,
|
|
1387
|
+
commonPath: info.repoRoot,
|
|
1388
|
+
};
|
|
1389
|
+
this._onDidChangeSessions.fire();
|
|
1390
|
+
}
|
|
1391
|
+
} finally {
|
|
1392
|
+
this._resolveGitInfoInFlight.delete(sessionId);
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
private async resolveTranscriptTitles(sessionId: string, cwd: string | undefined): Promise<void> {
|
|
1397
|
+
let titles;
|
|
1398
|
+
try {
|
|
1399
|
+
titles = await this._transcriptReader.resolve(sessionId, cwd);
|
|
1400
|
+
} catch {
|
|
1401
|
+
return;
|
|
1402
|
+
}
|
|
1403
|
+
if (titles == null) return;
|
|
1404
|
+
|
|
1405
|
+
const index = this._sessions.findIndex(s => s.id === sessionId);
|
|
1406
|
+
if (index < 0) return;
|
|
1407
|
+
|
|
1408
|
+
const session = this._sessions[index];
|
|
1409
|
+
const prev = session.transcriptTitles;
|
|
1410
|
+
if (prev?.custom === titles.custom && prev?.ai === titles.ai && prev?.agent === titles.agent) return;
|
|
1411
|
+
|
|
1412
|
+
this._sessions[index] = {
|
|
1413
|
+
...session,
|
|
1414
|
+
transcriptTitles: { custom: titles.custom, ai: titles.ai, agent: titles.agent },
|
|
1415
|
+
};
|
|
1416
|
+
this._onDidChangeSessions.fire();
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
private pruneDeadSessions(): boolean {
|
|
1420
|
+
const kept: AgentSession[] = [];
|
|
1421
|
+
const removedIds: string[] = [];
|
|
1422
|
+
for (const s of this._sessions) {
|
|
1423
|
+
// A session blocking on us for a permission decision is by definition alive,
|
|
1424
|
+
// even if `kill(pid, 0)` says otherwise (e.g. transient EPERM/ESRCH). Dropping
|
|
1425
|
+
// it here loses pendingPermission and lastPrompt; the next syncSessions then
|
|
1426
|
+
// re-adds it as a stale shell with `permission_requested` status but no detail.
|
|
1427
|
+
if (s.pid == null || isProcessAlive(s.pid) || this._pendingPermissions.has(s.id)) {
|
|
1428
|
+
kept.push(s);
|
|
1429
|
+
} else {
|
|
1430
|
+
removedIds.push(s.id);
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
if (removedIds.length === 0) return false;
|
|
1434
|
+
|
|
1435
|
+
this._sessions = kept;
|
|
1436
|
+
for (const id of removedIds) {
|
|
1437
|
+
const pending = this._pendingPermissions.get(id);
|
|
1438
|
+
if (pending != null) {
|
|
1439
|
+
pending.reject(new Error('Session pruned'));
|
|
1440
|
+
this._pendingPermissions.delete(id);
|
|
1441
|
+
}
|
|
1442
|
+
this.cancelPendingIdleTransition(id);
|
|
1443
|
+
this._sessionBookkeeping.delete(id);
|
|
1444
|
+
this._transcriptReader.forget(id);
|
|
1445
|
+
}
|
|
1446
|
+
Logger.debug(
|
|
1447
|
+
`ClaudeCodeProvider.syncSessions: removed ${removedIds.length} stale session(s): ${removedIds.map(id => id.substring(0, 8)).join(', ')}`,
|
|
1448
|
+
);
|
|
1449
|
+
return true;
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
private async syncSessions(): Promise<void> {
|
|
1453
|
+
let sessions: SessionFileData[];
|
|
1454
|
+
try {
|
|
1455
|
+
const output = await this.callbacks.runCLICommand(['ai', 'hook', 'list-sessions', '--json']);
|
|
1456
|
+
sessions = JSON.parse(output) as SessionFileData[];
|
|
1457
|
+
} catch {
|
|
1458
|
+
if (this.pruneDeadSessions()) {
|
|
1459
|
+
this._onDidChangeSessions.fire();
|
|
1460
|
+
}
|
|
1461
|
+
return;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
let changed = false;
|
|
1465
|
+
|
|
1466
|
+
for (const data of sessions) {
|
|
1467
|
+
if (!data.sessionId || !data.pid || !isProcessAlive(data.pid)) {
|
|
1468
|
+
continue;
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
if (this._sessions.some(s => s.id === data.sessionId)) continue;
|
|
1472
|
+
|
|
1473
|
+
const workspacePath = this.resolveWorkspacePath(data.cwd);
|
|
1474
|
+
const isInWorkspace = workspacePath != null;
|
|
1475
|
+
const status = deriveStatusFromEvent(data.event);
|
|
1476
|
+
const phase = getPhaseForStatus(status);
|
|
1477
|
+
const activityDate = new Date(data.updatedAt);
|
|
1478
|
+
|
|
1479
|
+
const subagents: AgentSession[] | undefined = data.subagents?.map(sub => ({
|
|
1480
|
+
id: sub.agentId,
|
|
1481
|
+
providerId: this.id,
|
|
1482
|
+
providerName: this.name,
|
|
1483
|
+
name: sub.agentType ?? 'Subagent',
|
|
1484
|
+
status: 'thinking',
|
|
1485
|
+
phase: 'working' as const,
|
|
1486
|
+
phaseSince: activityDate,
|
|
1487
|
+
lastActivity: activityDate,
|
|
1488
|
+
isSubagent: true,
|
|
1489
|
+
parentId: data.sessionId,
|
|
1490
|
+
isInWorkspace: isInWorkspace,
|
|
1491
|
+
}));
|
|
1492
|
+
|
|
1493
|
+
this._sessions.push({
|
|
1494
|
+
id: data.sessionId,
|
|
1495
|
+
providerId: this.id,
|
|
1496
|
+
providerName: this.name,
|
|
1497
|
+
name: data.sessionName || undefined,
|
|
1498
|
+
status: status,
|
|
1499
|
+
phase: phase,
|
|
1500
|
+
phaseSince: activityDate,
|
|
1501
|
+
pid: data.pid,
|
|
1502
|
+
lastActivity: activityDate,
|
|
1503
|
+
isSubagent: false,
|
|
1504
|
+
workspacePath: workspacePath,
|
|
1505
|
+
cwd: data.cwd,
|
|
1506
|
+
planFile: data.planFile ?? undefined,
|
|
1507
|
+
isInWorkspace: isInWorkspace,
|
|
1508
|
+
lastPrompt: prepareStoredPrompt(data.prompt ?? undefined),
|
|
1509
|
+
firstPrompt: prepareStoredPrompt(data.firstPrompt ?? undefined),
|
|
1510
|
+
subagents: subagents,
|
|
1511
|
+
});
|
|
1512
|
+
changed = true;
|
|
1513
|
+
|
|
1514
|
+
if (data.cwd) {
|
|
1515
|
+
void this.resolveGitInfo(data.sessionId, data.cwd);
|
|
1516
|
+
}
|
|
1517
|
+
void this.resolveTranscriptTitles(data.sessionId, data.cwd);
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
if (this.pruneDeadSessions()) {
|
|
1521
|
+
changed = true;
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
if (changed) {
|
|
1525
|
+
this._onDidChangeSessions.fire();
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
private async querySiblingWindowSessions(): Promise<void> {
|
|
1530
|
+
const discoveryDir = this.callbacks.ipc.agentDiscoveryDir;
|
|
1531
|
+
if (discoveryDir == null) return;
|
|
1532
|
+
|
|
1533
|
+
let files: string[];
|
|
1534
|
+
try {
|
|
1535
|
+
files = await readdir(discoveryDir);
|
|
1536
|
+
} catch {
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
const ownPort = this.callbacks.ipc.port;
|
|
1541
|
+
|
|
1542
|
+
const peerBatches = await Promise.all(
|
|
1543
|
+
files
|
|
1544
|
+
.filter(f => f.startsWith('gitlens-ipc-server-') && f.endsWith('.json'))
|
|
1545
|
+
.map(async f => this.fetchPeerSessions(join(discoveryDir, f), ownPort)),
|
|
1546
|
+
);
|
|
1547
|
+
|
|
1548
|
+
let changed = false;
|
|
1549
|
+
// Peer sessions that arrived with `cwd` but no `commonPath` (peer was running pre-
|
|
1550
|
+
// commonPath code, OR the peer's own `resolveGitInfo` failed and we get to retry locally).
|
|
1551
|
+
// Resolved after the merge loop so the local Repository registry is consulted only once
|
|
1552
|
+
// per unique session, off the hot merge path. Keyed by id (value = cwd) so the same
|
|
1553
|
+
// session appearing in multiple peer responses (or re-queued within this loop) only
|
|
1554
|
+
// triggers a single backfill probe.
|
|
1555
|
+
const sessionsNeedingResolve = new Map<string, string>();
|
|
1556
|
+
|
|
1557
|
+
for (const peerSessions of peerBatches) {
|
|
1558
|
+
if (peerSessions == null) continue;
|
|
1559
|
+
|
|
1560
|
+
for (const peerSession of peerSessions) {
|
|
1561
|
+
const peerActivity = new Date(peerSession.lastActivity);
|
|
1562
|
+
const peerPhaseSince = new Date(peerSession.phaseSince);
|
|
1563
|
+
|
|
1564
|
+
const existing = this._sessions.find(s => s.id === peerSession.id);
|
|
1565
|
+
|
|
1566
|
+
if (existing != null) {
|
|
1567
|
+
// If the peer reports a different `cwd` than we last saw, the agent moved
|
|
1568
|
+
// (peer fired a `CwdChanged` event we don't receive locally). Pick up the new
|
|
1569
|
+
// cwd AND reset our local `gitInfoUnresolvable` flag — the new cwd might be a
|
|
1570
|
+
// git repo even if the previous one wasn't.
|
|
1571
|
+
const cwdChanged = peerSession.cwd != null && existing.cwd !== peerSession.cwd;
|
|
1572
|
+
if (cwdChanged && this._sessionBookkeeping.get(peerSession.id)?.gitInfoUnresolvable) {
|
|
1573
|
+
this.getBookkeeping(peerSession.id).gitInfoUnresolvable = false;
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
if (peerActivity > existing.lastActivity) {
|
|
1577
|
+
const idx = this._sessions.indexOf(existing);
|
|
1578
|
+
// Peer is the authoritative hook recipient for this session, so wipe any
|
|
1579
|
+
// local bookkeeping that survived from a previous local-ownership window.
|
|
1580
|
+
// Without this, refcounts/timers from a prior local-hosting stretch would
|
|
1581
|
+
// resurface stale paths if ownership ever flips back to us.
|
|
1582
|
+
const bk = this._sessionBookkeeping.get(peerSession.id);
|
|
1583
|
+
if (bk != null) {
|
|
1584
|
+
this.cancelPendingFileClears(bk);
|
|
1585
|
+
this.cancelPendingReadClears(bk);
|
|
1586
|
+
bk.currentFileCounts.clear();
|
|
1587
|
+
bk.currentReadCounts.clear();
|
|
1588
|
+
}
|
|
1589
|
+
this._sessions[idx] = {
|
|
1590
|
+
...existing,
|
|
1591
|
+
status: peerSession.status,
|
|
1592
|
+
phase: peerSession.phase,
|
|
1593
|
+
phaseSince: peerPhaseSince,
|
|
1594
|
+
statusDetail: peerSession.statusDetail,
|
|
1595
|
+
lastActivity: peerActivity,
|
|
1596
|
+
subagents: rehydrateSubagents(peerSession.subagents),
|
|
1597
|
+
// Carry the peer's in-flight file-edit set + repo identity across so
|
|
1598
|
+
// peer-window WIP decorations + sameFamily anchoring follow the agent.
|
|
1599
|
+
// Owned by the peer (it's the hook recipient); we never originate these
|
|
1600
|
+
// fields for a remote session.
|
|
1601
|
+
currentFiles: peerSession.currentFiles,
|
|
1602
|
+
currentReads: peerSession.currentReads,
|
|
1603
|
+
commonPath: peerSession.commonPath ?? existing.commonPath,
|
|
1604
|
+
// Pick up the peer's latest cwd so our backfill probe (queued below)
|
|
1605
|
+
// targets the right path. Stale-cwd locally would just re-probe the
|
|
1606
|
+
// non-repo dir and hit the gitInfoUnresolvable retry guard again.
|
|
1607
|
+
cwd: cwdChanged ? peerSession.cwd : existing.cwd,
|
|
1608
|
+
// Forward peer-discovered transcript titles. Both windows can resolve them
|
|
1609
|
+
// independently against the same on-disk transcript, but only the peer's
|
|
1610
|
+
// hook flow drives its updates — without this, we'd freeze the snapshot
|
|
1611
|
+
// taken at first discovery.
|
|
1612
|
+
transcriptTitles: peerSession.transcriptTitles ?? existing.transcriptTitles,
|
|
1613
|
+
};
|
|
1614
|
+
changed = true;
|
|
1615
|
+
}
|
|
1616
|
+
} else {
|
|
1617
|
+
this._sessions.push({
|
|
1618
|
+
...peerSession,
|
|
1619
|
+
lastActivity: peerActivity,
|
|
1620
|
+
phaseSince: peerPhaseSince,
|
|
1621
|
+
workspacePath: normalizeWorkspacePath(peerSession.workspacePath),
|
|
1622
|
+
isInWorkspace: this.matchesWorkspace(peerSession.workspacePath),
|
|
1623
|
+
subagents: rehydrateSubagents(peerSession.subagents),
|
|
1624
|
+
// Override whatever the peer published — this is OUR ownership view: the peer
|
|
1625
|
+
// (or some other window) hosts the live session, not us. Drives the dispatcher
|
|
1626
|
+
// to route opens through the peer's IPC + OS-level window focus instead of
|
|
1627
|
+
// calling `claude-vscode.editor.open` locally (which would just open an inert
|
|
1628
|
+
// view in our window).
|
|
1629
|
+
isPeerOwned: true,
|
|
1630
|
+
});
|
|
1631
|
+
changed = true;
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
// Queue a local resolveGitInfo for any peer session that arrived without a
|
|
1635
|
+
// commonPath but has a cwd — we can fill it in from our own git registry. Skip
|
|
1636
|
+
// if our own bookkeeping already marked this session's cwd unresolvable (we tried
|
|
1637
|
+
// locally and the cwd isn't a git repo from our vantage point either).
|
|
1638
|
+
const merged = this._sessions.find(s => s.id === peerSession.id);
|
|
1639
|
+
if (
|
|
1640
|
+
merged?.commonPath == null &&
|
|
1641
|
+
merged?.cwd != null &&
|
|
1642
|
+
!this.getBookkeeping(merged.id).gitInfoUnresolvable
|
|
1643
|
+
) {
|
|
1644
|
+
sessionsNeedingResolve.set(merged.id, merged.cwd);
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
if (changed) {
|
|
1650
|
+
this._onDidChangeSessions.fire();
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
// Backfill commonPath for peer-synced sessions whose owning peer didn't populate it.
|
|
1654
|
+
// `resolveGitInfo` is idempotent — only mutates + fires if it actually finds new info,
|
|
1655
|
+
// so the worst case here is a few wasted git probes for unresolvable cwds.
|
|
1656
|
+
for (const [id, cwd] of sessionsNeedingResolve) {
|
|
1657
|
+
void this.resolveGitInfo(id, cwd);
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
private async fetchPeerSessions(
|
|
1662
|
+
path: string,
|
|
1663
|
+
ownPort: number | undefined,
|
|
1664
|
+
): Promise<SerializedAgentSession[] | undefined> {
|
|
1665
|
+
let discovery: DiscoveryFile;
|
|
1666
|
+
try {
|
|
1667
|
+
discovery = JSON.parse(await readFile(path, 'utf8')) as DiscoveryFile;
|
|
1668
|
+
} catch {
|
|
1669
|
+
return undefined;
|
|
1670
|
+
}
|
|
1671
|
+
if (ownPort != null && discovery.port === ownPort) return undefined;
|
|
1672
|
+
|
|
1673
|
+
try {
|
|
1674
|
+
const response = await fetch(`${discovery.address}/agents/sessions/list`, {
|
|
1675
|
+
method: 'POST',
|
|
1676
|
+
headers: { Authorization: `Bearer ${discovery.token}`, 'Content-Type': 'application/json' },
|
|
1677
|
+
body: '{}',
|
|
1678
|
+
signal: AbortSignal.timeout(2000),
|
|
1679
|
+
});
|
|
1680
|
+
if (!response.ok) return undefined;
|
|
1681
|
+
return (await response.json()) as SerializedAgentSession[];
|
|
1682
|
+
} catch {
|
|
1683
|
+
return undefined;
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
/** Find the peer GitLens window whose discovery file claims `workspacePath`, and POST to its
|
|
1688
|
+
* `/agents/sessions/open` route to ask its Claude Code extension to open the session.
|
|
1689
|
+
*
|
|
1690
|
+
* Resolves to `true` when at least one peer claimed the workspace AND was reachable (the POST
|
|
1691
|
+
* completed with any HTTP status). The peer's response shape (`{ opened: boolean }`) is logged
|
|
1692
|
+
* for diagnostics but does NOT affect the return value — an `opened: false` peer is still the
|
|
1693
|
+
* right window for the caller to focus, since it's the window that owns the session. Resolves
|
|
1694
|
+
* to `false` when no peer claimed the workspace OR every claimer was unreachable; the caller
|
|
1695
|
+
* treats that as "no peer to focus" and opens a new window instead of replacing the current
|
|
1696
|
+
* one. Best-effort: swallows scan, JSON-parse errors. */
|
|
1697
|
+
async notifyPeerOpenSession(workspacePath: string, sessionId: string): Promise<boolean> {
|
|
1698
|
+
const discoveryDir = this.callbacks.ipc.agentDiscoveryDir;
|
|
1699
|
+
if (discoveryDir == null) return false;
|
|
1700
|
+
|
|
1701
|
+
const target = normalizePath(workspacePath);
|
|
1702
|
+
const ownPort = this.callbacks.ipc.port;
|
|
1703
|
+
|
|
1704
|
+
let files: string[];
|
|
1705
|
+
try {
|
|
1706
|
+
files = await readdir(discoveryDir);
|
|
1707
|
+
} catch {
|
|
1708
|
+
return false;
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
const results = await Promise.all(
|
|
1712
|
+
files
|
|
1713
|
+
.filter(f => f.startsWith('gitlens-ipc-server-') && f.endsWith('.json'))
|
|
1714
|
+
.map(async f => {
|
|
1715
|
+
let discovery: DiscoveryFile;
|
|
1716
|
+
try {
|
|
1717
|
+
discovery = JSON.parse(await readFile(join(discoveryDir, f), 'utf8')) as DiscoveryFile;
|
|
1718
|
+
} catch {
|
|
1719
|
+
return false;
|
|
1720
|
+
}
|
|
1721
|
+
if (ownPort != null && discovery.port === ownPort) return false;
|
|
1722
|
+
// Symmetric prefix containment — same shape as `matchesWorkspace()` above.
|
|
1723
|
+
// Strict equality misses the common case where the dispatcher passes a `cwd`
|
|
1724
|
+
// inside the peer's workspace folder (or, less commonly, a parent dir that
|
|
1725
|
+
// contains the peer's workspace).
|
|
1726
|
+
if (
|
|
1727
|
+
!discovery.workspacePaths?.some(p => {
|
|
1728
|
+
const normalized = normalizePath(p);
|
|
1729
|
+
return (
|
|
1730
|
+
normalized === target ||
|
|
1731
|
+
target.startsWith(`${normalized}/`) ||
|
|
1732
|
+
normalized.startsWith(`${target}/`)
|
|
1733
|
+
);
|
|
1734
|
+
})
|
|
1735
|
+
) {
|
|
1736
|
+
return false;
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
try {
|
|
1740
|
+
const response = await fetch(`${discovery.address}/agents/sessions/open`, {
|
|
1741
|
+
method: 'POST',
|
|
1742
|
+
headers: {
|
|
1743
|
+
Authorization: `Bearer ${discovery.token}`,
|
|
1744
|
+
'Content-Type': 'application/json',
|
|
1745
|
+
},
|
|
1746
|
+
body: JSON.stringify({ sessionId: sessionId }),
|
|
1747
|
+
signal: AbortSignal.timeout(2000),
|
|
1748
|
+
});
|
|
1749
|
+
if (!response.ok) {
|
|
1750
|
+
Logger.warn(
|
|
1751
|
+
`ClaudeCodeProvider.notifyPeerOpenSession: peer at ${discovery.address} returned ${response.status}`,
|
|
1752
|
+
);
|
|
1753
|
+
} else {
|
|
1754
|
+
// Log a peer that failed to open the specific session, but still treat it
|
|
1755
|
+
// as a reachable claimer of the workspace — focusing that window is still
|
|
1756
|
+
// what the user wants.
|
|
1757
|
+
const body = (await response.json().catch(() => undefined)) as
|
|
1758
|
+
| { opened?: boolean }
|
|
1759
|
+
| undefined;
|
|
1760
|
+
if (body?.opened === false) {
|
|
1761
|
+
Logger.warn(
|
|
1762
|
+
`ClaudeCodeProvider.notifyPeerOpenSession: peer at ${discovery.address} could not open session ${sessionId}`,
|
|
1763
|
+
);
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
return true;
|
|
1767
|
+
} catch (ex) {
|
|
1768
|
+
// Peer advertised but unreachable (timeout, RST, etc.). Treat as no match so
|
|
1769
|
+
// the caller opens a new window instead of trying to focus a dead window.
|
|
1770
|
+
Logger.warn(
|
|
1771
|
+
`ClaudeCodeProvider.notifyPeerOpenSession: peer at ${discovery.address} unreachable: ${
|
|
1772
|
+
ex instanceof Error ? ex.message : String(ex)
|
|
1773
|
+
}`,
|
|
1774
|
+
);
|
|
1775
|
+
return false;
|
|
1776
|
+
}
|
|
1777
|
+
}),
|
|
1778
|
+
);
|
|
1779
|
+
|
|
1780
|
+
return results.some(Boolean);
|
|
1781
|
+
}
|
|
1782
|
+
}
|