@gitkraken/core-gitlens 0.5.113 → 0.5.114
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 +17 -1
- package/dist/git/cache.d.ts +4 -0
- package/dist/git/cache.d.ts.map +1 -1
- package/dist/git/cache.js +33 -0
- package/dist/git/cache.js.map +1 -1
- package/dist/git/context.d.ts +10 -0
- package/dist/git/context.d.ts.map +1 -1
- package/dist/git/context.js.map +1 -1
- package/dist/git/errors.d.ts +6 -3
- package/dist/git/errors.d.ts.map +1 -1
- package/dist/git/errors.js +9 -1
- package/dist/git/errors.js.map +1 -1
- package/dist/git/features.d.ts +1 -1
- package/dist/git/features.d.ts.map +1 -1
- package/dist/git/features.js +38 -0
- package/dist/git/features.js.map +1 -1
- package/dist/git/gitHealth.d.ts +153 -0
- package/dist/git/gitHealth.d.ts.map +1 -0
- package/dist/git/gitHealth.js +353 -0
- package/dist/git/gitHealth.js.map +1 -0
- package/dist/git/models/search.d.ts +6 -3
- package/dist/git/models/search.d.ts.map +1 -1
- package/dist/git/models/search.js +5 -1
- package/dist/git/models/search.js.map +1 -1
- package/dist/git/parsers/diffParser.d.ts +8 -2
- package/dist/git/parsers/diffParser.d.ts.map +1 -1
- package/dist/git/parsers/diffParser.js +27 -4
- package/dist/git/parsers/diffParser.js.map +1 -1
- package/dist/git/providers/config.d.ts +36 -3
- package/dist/git/providers/config.d.ts.map +1 -1
- package/dist/git/providers/graph.d.ts +9 -0
- package/dist/git/providers/graph.d.ts.map +1 -1
- package/dist/git/providers/maintenance.d.ts +222 -0
- package/dist/git/providers/maintenance.d.ts.map +1 -0
- package/dist/git/providers/maintenance.js +20 -0
- package/dist/git/providers/maintenance.js.map +1 -0
- package/dist/git/providers/operations.d.ts +10 -0
- package/dist/git/providers/operations.d.ts.map +1 -1
- package/dist/git/providers/pausedOperations.d.ts +3 -0
- package/dist/git/providers/pausedOperations.d.ts.map +1 -1
- package/dist/git/providers/provider.d.ts +2 -0
- package/dist/git/providers/provider.d.ts.map +1 -1
- package/dist/git/providers/tags.d.ts +3 -0
- package/dist/git/providers/tags.d.ts.map +1 -1
- package/dist/git/repositoryService.d.ts +1 -0
- package/dist/git/repositoryService.d.ts.map +1 -1
- package/dist/git/repositoryService.js.map +1 -1
- package/dist/git/run.types.d.ts +8 -0
- package/dist/git/run.types.d.ts.map +1 -1
- package/dist/git/service.js +3 -0
- package/dist/git/service.js.map +1 -1
- package/dist/git/utils/search.utils.d.ts.map +1 -1
- package/dist/git/utils/search.utils.js +112 -5
- package/dist/git/utils/search.utils.js.map +1 -1
- package/dist/git/watching/watchService.d.ts +3 -0
- package/dist/git/watching/watchService.d.ts.map +1 -1
- package/dist/git/watching/watchService.js +8 -0
- package/dist/git/watching/watchService.js.map +1 -1
- package/dist/git-cli/cliGitProvider.d.ts +11 -0
- package/dist/git-cli/cliGitProvider.d.ts.map +1 -1
- package/dist/git-cli/cliGitProvider.js +12 -0
- package/dist/git-cli/cliGitProvider.js.map +1 -1
- package/dist/git-cli/exec/git.d.ts +23 -1
- package/dist/git-cli/exec/git.d.ts.map +1 -1
- package/dist/git-cli/exec/git.js +83 -8
- package/dist/git-cli/exec/git.js.map +1 -1
- package/dist/git-cli/exec/gitQueue.d.ts +2 -0
- package/dist/git-cli/exec/gitQueue.d.ts.map +1 -1
- package/dist/git-cli/exec/gitQueue.js +19 -17
- package/dist/git-cli/exec/gitQueue.js.map +1 -1
- package/dist/git-cli/parsers/logParser.d.ts.map +1 -1
- package/dist/git-cli/parsers/logParser.js +4 -1
- package/dist/git-cli/parsers/logParser.js.map +1 -1
- package/dist/git-cli/providers/blame.d.ts.map +1 -1
- package/dist/git-cli/providers/blame.js +4 -7
- package/dist/git-cli/providers/blame.js.map +1 -1
- package/dist/git-cli/providers/branches.d.ts.map +1 -1
- package/dist/git-cli/providers/branches.js +13 -3
- 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 +34 -3
- package/dist/git-cli/providers/commits.js.map +1 -1
- package/dist/git-cli/providers/config.d.ts +27 -0
- package/dist/git-cli/providers/config.d.ts.map +1 -1
- package/dist/git-cli/providers/config.js +31 -6
- package/dist/git-cli/providers/config.js.map +1 -1
- package/dist/git-cli/providers/graph.d.ts +8 -0
- package/dist/git-cli/providers/graph.d.ts.map +1 -1
- package/dist/git-cli/providers/graph.js +85 -6
- package/dist/git-cli/providers/graph.js.map +1 -1
- package/dist/git-cli/providers/maintenance.d.ts +306 -0
- package/dist/git-cli/providers/maintenance.d.ts.map +1 -0
- package/dist/git-cli/providers/maintenance.js +2129 -0
- package/dist/git-cli/providers/maintenance.js.map +1 -0
- package/dist/git-cli/providers/operations.d.ts +1 -0
- package/dist/git-cli/providers/operations.d.ts.map +1 -1
- package/dist/git-cli/providers/operations.js +8 -0
- package/dist/git-cli/providers/operations.js.map +1 -1
- package/dist/git-cli/providers/pausedOperations.d.ts +1 -0
- package/dist/git-cli/providers/pausedOperations.d.ts.map +1 -1
- package/dist/git-cli/providers/pausedOperations.js +1 -1
- package/dist/git-cli/providers/pausedOperations.js.map +1 -1
- package/dist/git-cli/providers/tags.d.ts +3 -0
- package/dist/git-cli/providers/tags.d.ts.map +1 -1
- package/dist/git-cli/providers/tags.js +23 -1
- package/dist/git-cli/providers/tags.js.map +1 -1
- package/dist/plus/agents/agentCapabilities.d.ts +77 -0
- package/dist/plus/agents/agentCapabilities.d.ts.map +1 -0
- package/dist/plus/agents/agentCapabilities.js +197 -0
- package/dist/plus/agents/agentCapabilities.js.map +1 -0
- package/dist/plus/agents/providers/claudeCodeTranscript.d.ts +77 -13
- package/dist/plus/agents/providers/claudeCodeTranscript.d.ts.map +1 -1
- package/dist/plus/agents/providers/claudeCodeTranscript.js +263 -55
- package/dist/plus/agents/providers/claudeCodeTranscript.js.map +1 -1
- package/dist/plus/agents/providers/{claudeCodeProvider.d.ts → gkAgentProvider.d.ts} +52 -19
- package/dist/plus/agents/providers/gkAgentProvider.d.ts.map +1 -0
- package/dist/plus/agents/providers/{claudeCodeProvider.js → gkAgentProvider.js} +935 -262
- package/dist/plus/agents/providers/gkAgentProvider.js.map +1 -0
- package/dist/plus/agents/types.d.ts +113 -35
- package/dist/plus/agents/types.d.ts.map +1 -1
- package/dist/plus/agents/types.js +7 -4
- package/dist/plus/agents/types.js.map +1 -1
- package/dist/plus/ai/models/promptTemplates.d.ts +1 -1
- package/dist/plus/ai/models/promptTemplates.d.ts.map +1 -1
- package/dist/plus/ai/prompts.d.ts +4 -0
- package/dist/plus/ai/prompts.d.ts.map +1 -1
- package/dist/plus/ai/prompts.js +59 -4
- package/dist/plus/ai/prompts.js.map +1 -1
- package/dist/plus/git-github/api/github.d.ts +44 -1
- package/dist/plus/git-github/api/github.d.ts.map +1 -1
- package/dist/plus/git-github/api/github.js +318 -25
- package/dist/plus/git-github/api/github.js.map +1 -1
- package/dist/plus/git-github/api/issueSearchQuery.d.ts +13 -0
- package/dist/plus/git-github/api/issueSearchQuery.d.ts.map +1 -1
- package/dist/plus/git-github/api/issueSearchQuery.js +41 -0
- package/dist/plus/git-github/api/issueSearchQuery.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 +2 -1
- package/dist/plus/git-github/providers/github/blame.js.map +1 -1
- package/dist/plus/integrations/integrationService.d.ts +11 -0
- package/dist/plus/integrations/integrationService.d.ts.map +1 -1
- package/dist/plus/integrations/integrationService.js +4 -0
- package/dist/plus/integrations/integrationService.js.map +1 -1
- package/dist/plus/integrations/manager.d.ts +26 -0
- package/dist/plus/integrations/manager.d.ts.map +1 -1
- package/dist/plus/integrations/models/gitHostIntegration.d.ts +31 -0
- package/dist/plus/integrations/models/gitHostIntegration.d.ts.map +1 -1
- package/dist/plus/integrations/models/gitHostIntegration.js +31 -0
- package/dist/plus/integrations/models/gitHostIntegration.js.map +1 -1
- package/dist/plus/integrations/models/integration.d.ts +1 -1
- package/dist/plus/integrations/models/integration.d.ts.map +1 -1
- package/dist/plus/integrations/models/integration.js.map +1 -1
- package/dist/plus/integrations/providers/github.d.ts +9 -0
- package/dist/plus/integrations/providers/github.d.ts.map +1 -1
- package/dist/plus/integrations/providers/github.js +7 -0
- package/dist/plus/integrations/providers/github.js.map +1 -1
- package/dist/plus/integrations/reads/broaden.d.ts +47 -10
- package/dist/plus/integrations/reads/broaden.d.ts.map +1 -1
- package/dist/plus/integrations/reads/broaden.js +144 -33
- package/dist/plus/integrations/reads/broaden.js.map +1 -1
- package/dist/plus/integrations/reads/filters.d.ts +18 -0
- package/dist/plus/integrations/reads/filters.d.ts.map +1 -1
- package/dist/plus/integrations/reads/filters.js +37 -1
- package/dist/plus/integrations/reads/filters.js.map +1 -1
- package/dist/plus/integrations/reads/issueBatch.d.ts +51 -0
- package/dist/plus/integrations/reads/issueBatch.d.ts.map +1 -0
- package/dist/plus/integrations/reads/issueBatch.js +91 -0
- package/dist/plus/integrations/reads/issueBatch.js.map +1 -0
- package/dist/plus/integrations/reads/sweeps.d.ts.map +1 -1
- package/dist/plus/integrations/reads/sweeps.js +5 -3
- package/dist/plus/integrations/reads/sweeps.js.map +1 -1
- package/dist/utils/promiseCache.d.ts +19 -0
- package/dist/utils/promiseCache.d.ts.map +1 -1
- package/dist/utils/promiseCache.js +30 -0
- package/dist/utils/promiseCache.js.map +1 -1
- package/dist/utils/resourceUsage.d.ts +5 -0
- package/dist/utils/resourceUsage.d.ts.map +1 -0
- package/dist/utils/resourceUsage.js +2 -0
- package/dist/utils/resourceUsage.js.map +1 -0
- package/dist/utils/string.d.ts +12 -0
- package/dist/utils/string.d.ts.map +1 -1
- package/dist/utils/string.js +36 -1
- package/dist/utils/string.js.map +1 -1
- package/docs/integrations.md +29 -25
- package/docs/kepler-read-api-parity.md +12 -4
- package/package.json +11 -3
- package/src/git/cache.ts +36 -0
- package/src/git/context.ts +11 -0
- package/src/git/errors.ts +13 -1
- package/src/git/features.ts +51 -0
- package/src/git/gitHealth.ts +522 -0
- package/src/git/models/search.ts +19 -4
- package/src/git/parsers/diffParser.ts +27 -4
- package/src/git/providers/config.ts +42 -1
- package/src/git/providers/graph.ts +12 -0
- package/src/git/providers/maintenance.ts +224 -0
- package/src/git/providers/operations.ts +10 -0
- package/src/git/providers/pausedOperations.ts +3 -0
- package/src/git/providers/provider.ts +2 -0
- package/src/git/providers/tags.ts +1 -0
- package/src/git/repositoryService.ts +1 -0
- package/src/git/run.types.ts +8 -0
- package/src/git/service.ts +3 -0
- package/src/git/utils/search.utils.ts +129 -5
- package/src/git/watching/watchService.ts +10 -0
- package/src/git-cli/cliGitProvider.ts +25 -0
- package/src/git-cli/exec/git.ts +120 -6
- package/src/git-cli/exec/gitQueue.ts +19 -17
- package/src/git-cli/parsers/logParser.ts +4 -1
- package/src/git-cli/providers/blame.ts +3 -8
- package/src/git-cli/providers/branches.ts +28 -8
- package/src/git-cli/providers/commits.ts +34 -2
- package/src/git-cli/providers/config.ts +38 -6
- package/src/git-cli/providers/graph.ts +107 -7
- package/src/git-cli/providers/maintenance.ts +2599 -0
- package/src/git-cli/providers/operations.ts +11 -0
- package/src/git-cli/providers/pausedOperations.ts +7 -2
- package/src/git-cli/providers/tags.ts +36 -1
- package/src/plus/agents/agentCapabilities.ts +286 -0
- package/src/plus/agents/providers/claudeCodeTranscript.ts +313 -58
- package/src/plus/agents/providers/{claudeCodeProvider.ts → gkAgentProvider.ts} +1123 -277
- package/src/plus/agents/types.ts +137 -40
- package/src/plus/ai/models/promptTemplates.ts +1 -1
- package/src/plus/ai/prompts.ts +62 -4
- package/src/plus/git-github/api/github.ts +353 -24
- package/src/plus/git-github/api/issueSearchQuery.ts +49 -1
- package/src/plus/git-github/providers/github/blame.ts +2 -1
- package/src/plus/integrations/integrationService.ts +15 -0
- package/src/plus/integrations/manager.ts +26 -0
- package/src/plus/integrations/models/gitHostIntegration.ts +48 -0
- package/src/plus/integrations/models/integration.ts +1 -0
- package/src/plus/integrations/providers/github.ts +18 -0
- package/src/plus/integrations/reads/broaden.ts +282 -70
- package/src/plus/integrations/reads/filters.ts +39 -1
- package/src/plus/integrations/reads/issueBatch.ts +197 -0
- package/src/plus/integrations/reads/sweeps.ts +19 -12
- package/src/utils/promiseCache.ts +43 -0
- package/src/utils/resourceUsage.ts +5 -0
- package/src/utils/string.ts +38 -1
- package/dist/plus/agents/providers/claudeCodeProvider.d.ts.map +0 -1
- package/dist/plus/agents/providers/claudeCodeProvider.js.map +0 -1
|
@@ -10,7 +10,8 @@ import { gate } from '../../../utils/decorators/gate.js';
|
|
|
10
10
|
import { disposableInterval } from '../../../utils/disposable.js';
|
|
11
11
|
import { Emitter } from '../../../utils/event.js';
|
|
12
12
|
import { Logger } from '../../../utils/logger.js';
|
|
13
|
-
import { normalizePath } from '../../../utils/path.js';
|
|
13
|
+
import { arePathsEqual, normalizePath } from '../../../utils/path.js';
|
|
14
|
+
import { agentCapabilities, claudeCodeCapabilities, getAgentCapabilities, getAgentCapabilitiesByProviderId, resolveCanonicalHookEvent, resolveCanonicalToolName, } from '../agentCapabilities.js';
|
|
14
15
|
import { prepareStoredPrompt } from '../sanitizePrompt.js';
|
|
15
16
|
import { classifyPermissionKind, deriveStatusFromEvent, describeToolInput, extractPlanSummary, extractQuestionDetails, getToolFilePath, getToolReadPath, isProcessAlive, rehydrateSubagents, } from '../stateMachine.js';
|
|
16
17
|
import { getPhaseForStatus } from '../types.js';
|
|
@@ -21,21 +22,21 @@ const staleCheckIntervalMs = 15 * 60 * 1000; // 15 minutes
|
|
|
21
22
|
* to reap, leaving only terminal-history reconciliation and the CLI's retention sweep — both fine
|
|
22
23
|
* hourly. Window startup polls ungated, so this never delays a fresh window. */
|
|
23
24
|
const idleReconcileIntervalMs = 60 * 60 * 1000; // 1 hour
|
|
24
|
-
/** How long a freshly-
|
|
25
|
+
/** How long a freshly-ended row is protected from absence-based removal. The CLI persists the
|
|
25
26
|
* ended record (atomic temp+rename) BEFORE broadcasting the hook event, so a poll that starts after
|
|
26
|
-
* we mark a row
|
|
27
|
-
* side, where the cost of being wrong is
|
|
28
|
-
const
|
|
27
|
+
* we mark a row ended will list it — this guards only against that ordering changing on the CLI
|
|
28
|
+
* side, where the cost of being wrong is an ended row silently vanishing. */
|
|
29
|
+
const endedRemovalGraceMs = 30 * 1000; // 30 seconds
|
|
29
30
|
/** How long the machine-global archived-session id list (fetched via a separate `--status archived`
|
|
30
|
-
* CLI query) is cached. `
|
|
31
|
+
* CLI query) is cached. `listSessionHistory` asks for it once per worktree on panel open, so without
|
|
31
32
|
* this a multi-worktree graph spawns N identical CLI processes. `archiveSession` invalidates the
|
|
32
33
|
* cache, so a just-archived row never lingers under "Past". */
|
|
33
34
|
const archivedSessionIdsCacheTtlMs = 10 * 1000; // 10 seconds
|
|
34
|
-
/**
|
|
35
|
+
/** Ended sessions defer git + transcript resolution (a 30-day cold-start would otherwise fan out
|
|
35
36
|
* hundreds of probes). Exception: sessions ended within this window still surface on WIP rows /
|
|
36
37
|
* branch cards, which match strictly by resolved `worktreePath` — so their git info is resolved
|
|
37
38
|
* eagerly at poll time (a handful), keeping the older tail lazy. Mirrors the 24h WIP-row window. */
|
|
38
|
-
const
|
|
39
|
+
const recentEndedGitResolveThresholdMs = 24 * 60 * 60 * 1000; // 24 hours
|
|
39
40
|
/** Default cooldown between PostToolUse and dropping the file from `fileActivity`. Held long
|
|
40
41
|
* enough that the treemap activity overlay can render a decay tail well past the moment the tool
|
|
41
42
|
* call completed. The host may override per-call via `AgentProviderCallbacks.getActivityDecayMs`
|
|
@@ -74,6 +75,110 @@ function worktreeFromEvent(event) {
|
|
|
74
75
|
}
|
|
75
76
|
return undefined;
|
|
76
77
|
}
|
|
78
|
+
/** Reorders `paths` so each distinct (already-normalized) value is positioned by its LAST
|
|
79
|
+
* occurrence — an earlier occurrence of a value that recurs later is dropped, and the value's one
|
|
80
|
+
* remaining slot sits where that last occurrence falls. Implemented via delete-then-reinsert into
|
|
81
|
+
* a `Map`: re-inserting a key after deleting it moves it to the end of `Map` iteration order,
|
|
82
|
+
* which is exactly "this value's position is its most recent occurrence". */
|
|
83
|
+
function orderByLastOccurrence(paths) {
|
|
84
|
+
const seen = new Map();
|
|
85
|
+
for (const path of paths) {
|
|
86
|
+
seen.delete(path);
|
|
87
|
+
seen.set(path, true);
|
|
88
|
+
}
|
|
89
|
+
return [...seen.keys()];
|
|
90
|
+
}
|
|
91
|
+
/** Distinct, normalized, non-empty `worktree` values from a `cwdTimeline`, ordered by recency —
|
|
92
|
+
* positioned by each path's LAST occurrence in the (chronological) timeline, so a path the
|
|
93
|
+
* session returns to later sorts after one it doesn't. `undefined` when the timeline is absent or
|
|
94
|
+
* names no worktree — callers should NOT overwrite an existing `visitedWorktreePaths` with an
|
|
95
|
+
* empty array in that case. */
|
|
96
|
+
function visitedFromTimeline(timeline) {
|
|
97
|
+
if (timeline == null)
|
|
98
|
+
return undefined;
|
|
99
|
+
const paths = [];
|
|
100
|
+
for (const entry of timeline) {
|
|
101
|
+
if (!entry.worktree)
|
|
102
|
+
continue;
|
|
103
|
+
paths.push(normalizePath(entry.worktree));
|
|
104
|
+
}
|
|
105
|
+
if (paths.length === 0)
|
|
106
|
+
return undefined;
|
|
107
|
+
return orderByLastOccurrence(paths);
|
|
108
|
+
}
|
|
109
|
+
/** Unions `adds` (each an array or a single path, `undefined` skipped) into `existing`, normalizing
|
|
110
|
+
* every path, and reorders the result by recency: every add — including one that names a path
|
|
111
|
+
* already in `existing` — moves that path to the end, same "last occurrence wins the slot" rule
|
|
112
|
+
* {@link orderByLastOccurrence} applies to a single timeline. Adds arrive in chronological order at
|
|
113
|
+
* every call site (timeline-derived paths first, the current `worktreePath` folded last), so the
|
|
114
|
+
* current worktree normally ends up last.
|
|
115
|
+
*
|
|
116
|
+
* **Reference-stability contract — load-bearing**: returns `existing` BY REFERENCE, unchanged,
|
|
117
|
+
* when the resulting sequence is element-for-element identical to `existing`. Callers
|
|
118
|
+
* (`ensureSession`'s identity gate) rely on this to avoid firing spurious change events on every
|
|
119
|
+
* hook event / poll cycle when the visited set hasn't actually changed — re-processing an
|
|
120
|
+
* unchanged timeline plus the same current worktree reproduces the identical sequence (each path
|
|
121
|
+
* "moves" to the same slot it already occupies), so the comparison below still holds. */
|
|
122
|
+
function unionVisited(existing, ...adds) {
|
|
123
|
+
const combined = existing != null ? [...existing] : [];
|
|
124
|
+
for (const add of adds) {
|
|
125
|
+
if (add == null)
|
|
126
|
+
continue;
|
|
127
|
+
const values = typeof add === 'string' ? [add] : add;
|
|
128
|
+
for (const value of values) {
|
|
129
|
+
if (!value)
|
|
130
|
+
continue;
|
|
131
|
+
combined.push(normalizePath(value));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const next = orderByLastOccurrence(combined);
|
|
135
|
+
if (existing != null && next.length === existing.length && next.every((path, i) => path === existing[i])) {
|
|
136
|
+
return existing;
|
|
137
|
+
}
|
|
138
|
+
return next.length > 0 ? next : undefined;
|
|
139
|
+
}
|
|
140
|
+
/** Peer-merge variant of {@link unionVisited}: same normalize-and-dedupe, but APPEND-ONLY —
|
|
141
|
+
* `existing`'s order is preserved and any path the peer names that we don't already have is
|
|
142
|
+
* appended in the peer's own order. A peer's order can't be trusted as fresher than ours without
|
|
143
|
+
* timestamps, so peer input never reorders what we already hold. Returns `existing` BY REFERENCE
|
|
144
|
+
* when nothing new is added — same rationale as {@link unionVisited}. */
|
|
145
|
+
function unionVisitedAppendOnly(existing, ...adds) {
|
|
146
|
+
const next = new Set(existing ?? []);
|
|
147
|
+
let grew = false;
|
|
148
|
+
for (const add of adds) {
|
|
149
|
+
if (add == null)
|
|
150
|
+
continue;
|
|
151
|
+
const values = typeof add === 'string' ? [add] : add;
|
|
152
|
+
for (const value of values) {
|
|
153
|
+
if (!value)
|
|
154
|
+
continue;
|
|
155
|
+
const normalized = normalizePath(value);
|
|
156
|
+
if (!next.has(normalized)) {
|
|
157
|
+
next.add(normalized);
|
|
158
|
+
grew = true;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return grew ? [...next] : existing;
|
|
163
|
+
}
|
|
164
|
+
/** The event's tool name, translated from the agent's native vocabulary into the canonical (Claude
|
|
165
|
+
* Code) one. Prefers the raw `hookInput.tool_name` passthrough and falls back to the projected
|
|
166
|
+
* top-level field for older CLIs. Everything downstream — `describeToolInput`,
|
|
167
|
+
* `classifyPermissionKind`, `getToolFilePath`, `getToolReadPath`, and the display detail — expects
|
|
168
|
+
* canonical names, and Claude Code has no alias table, so its names pass through untouched. */
|
|
169
|
+
function canonicalToolNameFromEvent(capabilities, event) {
|
|
170
|
+
const native = event.hookInput?.tool_name ?? event.toolName;
|
|
171
|
+
return native != null ? resolveCanonicalToolName(capabilities, native) : undefined;
|
|
172
|
+
}
|
|
173
|
+
/** Whether GitLens can actually answer a blocking permission ask from this agent. Two conditions,
|
|
174
|
+
* deliberately separate: the agent must support blocking hooks at all, AND the response we build
|
|
175
|
+
* must be one it understands. {@link PermissionResponse} is Claude-shaped
|
|
176
|
+
* (`hookSpecificOutput.hookEventName: 'PermissionRequest'`), so an agent that supports blocking
|
|
177
|
+
* hooks but expects a different envelope stays observe-only until per-agent response builders land
|
|
178
|
+
* — at which point the second clause is what gets deleted. */
|
|
179
|
+
function canAnswerBlockingPermission(capabilities) {
|
|
180
|
+
return capabilities.supportsBlockingPermissions && capabilities.hookClientId === 'claude-code';
|
|
181
|
+
}
|
|
77
182
|
/** Synthesizes an unresolvable ask for a `permission_requested` row that has no routable
|
|
78
183
|
* `PendingPermission` — a non-blocking `Notification`/`PermissionRequest` tail, or a
|
|
79
184
|
* poll-discovered live/revived row. None of these hold a blocking hook entry to route an
|
|
@@ -170,6 +275,56 @@ function fileActivityStructurallyEqual(a, b) {
|
|
|
170
275
|
}
|
|
171
276
|
return true;
|
|
172
277
|
}
|
|
278
|
+
/**
|
|
279
|
+
* Maps what `claude agents --json` reports for a live session onto `AgentSessionStatus`, per Claude's
|
|
280
|
+
* documented agent-view vocabulary.
|
|
281
|
+
*
|
|
282
|
+
* `kind: 'interactive'` entries carry `status` while their process is alive, plus `waitingFor` when
|
|
283
|
+
* that status is `waiting` — `'permission prompt'` is the one value with a distinct status of its own
|
|
284
|
+
* here, since a permission request is a different kind of block from an ordinary question.
|
|
285
|
+
*
|
|
286
|
+
* `kind: 'background'` entries carry `state` instead: `working` / `blocked` / `done` / `failed` /
|
|
287
|
+
* `stopped`, where `blocked` IS the documented "needs input" state.
|
|
288
|
+
*
|
|
289
|
+
* Returns `undefined` for a background session in a TERMINAL state. `--json` lists background
|
|
290
|
+
* sessions "still working or blocked even when their process has exited", so a listing is not by
|
|
291
|
+
* itself proof of life — without this, a genuinely finished session would be resurrected as `idle`,
|
|
292
|
+
* which is the opposite of the bug this mapping exists to fix. Callers must not treat such an entry
|
|
293
|
+
* as live: ended-record paths trust the durable record, while active discovery/repair paths skip or
|
|
294
|
+
* remove the stale active row.
|
|
295
|
+
*
|
|
296
|
+
* Anything unrecognized degrades to `'idle'` rather than guessing a working/waiting phase.
|
|
297
|
+
*/
|
|
298
|
+
/** Resolves a non-terminal listing override for an `ended` record. `undefined` means "let the ended
|
|
299
|
+
* record stand": either the listing doesn't mention this session, or its background state is
|
|
300
|
+
* terminal (see {@link statusFromLiveSession}). Active-record paths inspect the entry separately
|
|
301
|
+
* because they must distinguish absence (unknown; keep record status) from terminal (skip/remove). */
|
|
302
|
+
function liveOverrideFor(liveSessions, sessionId) {
|
|
303
|
+
const live = liveSessions?.get(sessionId);
|
|
304
|
+
if (live == null)
|
|
305
|
+
return undefined;
|
|
306
|
+
const status = statusFromLiveSession(live);
|
|
307
|
+
return status != null ? { live: live, status: status } : undefined;
|
|
308
|
+
}
|
|
309
|
+
function statusFromLiveSession(live) {
|
|
310
|
+
switch (live.status) {
|
|
311
|
+
case 'waiting':
|
|
312
|
+
return live.waitingFor === 'permission prompt' ? 'permission_requested' : 'waiting';
|
|
313
|
+
case 'busy':
|
|
314
|
+
return 'thinking';
|
|
315
|
+
}
|
|
316
|
+
switch (live.state) {
|
|
317
|
+
case 'blocked':
|
|
318
|
+
return 'waiting';
|
|
319
|
+
case 'working':
|
|
320
|
+
return 'thinking';
|
|
321
|
+
case 'done':
|
|
322
|
+
case 'failed':
|
|
323
|
+
case 'stopped':
|
|
324
|
+
return undefined;
|
|
325
|
+
}
|
|
326
|
+
return 'idle';
|
|
327
|
+
}
|
|
173
328
|
/** True when a CLI invocation failed because it didn't recognize a flag/command. `--status` and the
|
|
174
329
|
* active-only `list-sessions` default shipped together (gk 3.1.69); an older CLI rejects `--status`
|
|
175
330
|
* and returns all sessions unfiltered, so the flagless retry is the correct legacy equivalent. */
|
|
@@ -177,11 +332,15 @@ function isUnknownFlagError(ex) {
|
|
|
177
332
|
const message = ex instanceof Error ? ex.message : String(ex);
|
|
178
333
|
return /unknown (?:flag|shorthand flag|command)/i.test(message);
|
|
179
334
|
}
|
|
180
|
-
export class
|
|
335
|
+
export class GkAgentProvider {
|
|
181
336
|
callbacks;
|
|
182
|
-
|
|
183
|
-
|
|
337
|
+
/** Identifies the PROVIDER, not an agent: this one hosts every `gk ai hook` client GitLens has
|
|
338
|
+
* a descriptor for. A session's own agent identity lives on {@link AgentSession.providerId}
|
|
339
|
+
* (`claudeCode`, `codex`, …), sourced from {@link AgentCapabilities} — never from here. */
|
|
340
|
+
id = 'gkAgents';
|
|
341
|
+
name = 'Coding Agents';
|
|
184
342
|
icon = 'robot';
|
|
343
|
+
agentProviderIds = agentCapabilities.map(c => c.providerId);
|
|
185
344
|
_onDidChangeSessions = new Emitter();
|
|
186
345
|
onDidChangeSessions = this._onDidChangeSessions.event;
|
|
187
346
|
_sessions = [];
|
|
@@ -206,22 +365,38 @@ export class ClaudeCodeProvider {
|
|
|
206
365
|
_staleCheckTimer;
|
|
207
366
|
/** When the last poll actually ran (gated or not) — paces the idle cadence above. */
|
|
208
367
|
_lastSyncAt = 0;
|
|
209
|
-
/** Whether
|
|
368
|
+
/** Whether agent hooks are installed, pushed by the host via {@link setHooksInstalled}.
|
|
210
369
|
* Fail-open (`true`) until the first push lands so a fresh window with real hooks isn't
|
|
211
370
|
* suppressed on its first ticks. Gates the reconciliation poll in {@link syncSessions}. */
|
|
212
|
-
|
|
371
|
+
_hooksInstalled = true;
|
|
213
372
|
/** Whether the CLI supports `list-sessions --status` (and with it the durable ended-session
|
|
214
373
|
* store). Optimistic until the poll's flagless fallback proves otherwise. When unsupported,
|
|
215
|
-
*
|
|
216
|
-
* reverts to removing the session and reconciliation drops any
|
|
374
|
+
* ended rows are untenable — no poll can ever confirm or archive one — so `SessionEnd`
|
|
375
|
+
* reverts to removing the session and reconciliation drops any ended stragglers. */
|
|
217
376
|
_statusFilterSupported = true;
|
|
218
377
|
_transcriptReader = new ClaudeCodeTranscriptReader();
|
|
378
|
+
_terminalGeneration = 0;
|
|
379
|
+
/** Ended-transcript detail results, cached forever per window — an ended transcript is
|
|
380
|
+
* immutable, so once resolved it never needs a second read. Keyed by session id. */
|
|
381
|
+
_sessionDetailsCache = new Map();
|
|
219
382
|
constructor(callbacks) {
|
|
220
383
|
this.callbacks = callbacks;
|
|
221
384
|
}
|
|
222
385
|
get sessions() {
|
|
223
386
|
return this._sessions;
|
|
224
387
|
}
|
|
388
|
+
get terminalGeneration() {
|
|
389
|
+
return this._terminalGeneration;
|
|
390
|
+
}
|
|
391
|
+
/** Single choke point for every terminal transition — a session ending, being removed on end
|
|
392
|
+
* (legacy path), being pruned, or a fresh ended record being discovered. Bumps the generation
|
|
393
|
+
* the host's history queries validate against (a transition mid-query means the answer may be
|
|
394
|
+
* missing a session) and drops the transcript listing caches, whose warm entries may predate
|
|
395
|
+
* the transcript the transition just left behind. */
|
|
396
|
+
noteTerminalTransition() {
|
|
397
|
+
this._terminalGeneration++;
|
|
398
|
+
this._transcriptReader.invalidateListings();
|
|
399
|
+
}
|
|
225
400
|
start(workspacePaths) {
|
|
226
401
|
this._workspacePaths = workspacePaths.map(p => normalizePath(p));
|
|
227
402
|
void this.ensureIpcServer();
|
|
@@ -271,12 +446,12 @@ export class ClaudeCodeProvider {
|
|
|
271
446
|
// Re-publish so the agents discovery file reflects the new workspacePaths.
|
|
272
447
|
this.callbacks.ipc
|
|
273
448
|
.publishAgents(this._workspacePaths)
|
|
274
|
-
.catch((ex) => Logger.error(ex, '
|
|
449
|
+
.catch((ex) => Logger.error(ex, 'GkAgentProvider.updateWorkspacePaths: publishAgents failed'));
|
|
275
450
|
}
|
|
276
451
|
}
|
|
277
|
-
|
|
278
|
-
const wasOff = !this.
|
|
279
|
-
this.
|
|
452
|
+
setHooksInstalled(installed) {
|
|
453
|
+
const wasOff = !this._hooksInstalled;
|
|
454
|
+
this._hooksInstalled = installed;
|
|
280
455
|
// On a fresh off→on transition, reconcile immediately rather than waiting up to a full
|
|
281
456
|
// interval — picks up any session that was already running before hooks were installed.
|
|
282
457
|
// This is a deliberate discovery pass (like the cold-start bootstrap), not a routine tick:
|
|
@@ -287,24 +462,100 @@ export class ClaudeCodeProvider {
|
|
|
287
462
|
void this.syncSessions();
|
|
288
463
|
}
|
|
289
464
|
}
|
|
290
|
-
/** Reads
|
|
291
|
-
* directory
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
465
|
+
/** Reads Claude history from its transcript store and tracked terminal records. The exact project
|
|
466
|
+
* directory preserves legacy sessions that predate the CLI's durable records. Recent ended
|
|
467
|
+
* records add an authoritative worktree-to-session association, so their ids are also recovered
|
|
468
|
+
* from other project directories when Claude left the transcript behind after a cwd/worktree
|
|
469
|
+
* move. A tracked record with no readable transcript remains visible and archivable, but does not
|
|
470
|
+
* advertise Resume. Archive filtering stays provider-local so ids from another harness cannot
|
|
471
|
+
* suppress Claude rows. `requireResume` drops the transcript-less tracked records, leaving only
|
|
472
|
+
* rows that carry a resume action — and a `total` counting only those. */
|
|
473
|
+
async listSessionHistory(cwd, options) {
|
|
474
|
+
const resumeOnly = options?.requireResume === true;
|
|
475
|
+
// No launcher means no row can carry resume, so a resume-only query has nothing to return.
|
|
476
|
+
if (resumeOnly && this.callbacks.resumeSession == null)
|
|
477
|
+
return { sessions: [], total: 0 };
|
|
478
|
+
const archivedSessionIds = new Set(await this.getArchivedSessionIds());
|
|
479
|
+
const excludeSessionIds = new Set(options?.excludeSessionIds);
|
|
480
|
+
for (const id of archivedSessionIds) {
|
|
481
|
+
excludeSessionIds.add(id);
|
|
482
|
+
}
|
|
483
|
+
const trackedEndedSessions = [];
|
|
484
|
+
const endedSessionIds = new Set();
|
|
485
|
+
for (const session of this._sessions) {
|
|
486
|
+
if (session.status === 'ended' &&
|
|
487
|
+
session.worktreePath != null &&
|
|
488
|
+
arePathsEqual(session.worktreePath, cwd) &&
|
|
489
|
+
!excludeSessionIds.has(session.id)) {
|
|
490
|
+
// A tracked record's own row carries archive but no resume; its id still drives transcript
|
|
491
|
+
// recovery below, which does produce a resumable row when the transcript moved with it.
|
|
492
|
+
if (!resumeOnly) {
|
|
493
|
+
trackedEndedSessions.push(session);
|
|
494
|
+
}
|
|
495
|
+
// Only an agent whose sessions can be resumed feeds transcript recovery. The reader is
|
|
496
|
+
// Claude-transcript-specific, so another agent's id could only ever miss — but stating it
|
|
497
|
+
// keeps the resume capability the reason, rather than leaving it to a coincidence of which
|
|
498
|
+
// store the reader happens to read.
|
|
499
|
+
if (getAgentCapabilitiesByProviderId(session.providerId)?.supportsResume !== false) {
|
|
500
|
+
endedSessionIds.add(session.id);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
const [direct, recovered] = await Promise.all([
|
|
505
|
+
this._transcriptReader.listSessions(cwd, {
|
|
506
|
+
limit: options?.limit,
|
|
507
|
+
excludeSessionIds: excludeSessionIds,
|
|
508
|
+
}),
|
|
509
|
+
this._transcriptReader.listSessionsByIds(endedSessionIds, {
|
|
510
|
+
limit: options?.limit,
|
|
511
|
+
excludeCwd: cwd,
|
|
512
|
+
}),
|
|
513
|
+
]);
|
|
514
|
+
const historyById = new Map();
|
|
515
|
+
const addTranscript = (session, resumeCwd) => {
|
|
516
|
+
if (excludeSessionIds.has(session.sessionId))
|
|
517
|
+
return;
|
|
518
|
+
const disposition = archivedSessionIds.has(session.sessionId) ? 'archived' : 'ended';
|
|
519
|
+
const resumeActions = this.callbacks.resumeSession != null ? { resume: { cwd: resumeCwd } } : {};
|
|
520
|
+
historyById.set(session.sessionId, {
|
|
521
|
+
id: session.sessionId,
|
|
522
|
+
// The reader only reads Claude Code's transcript store, so every row it produces is
|
|
523
|
+
// Claude's by construction.
|
|
524
|
+
providerId: claudeCodeCapabilities.providerId,
|
|
525
|
+
disposition: disposition,
|
|
526
|
+
actions: disposition === 'ended' ? { ...resumeActions, archive: true } : resumeActions,
|
|
527
|
+
lastActivity: new Date(session.lastActivityMs),
|
|
528
|
+
titles: session.titles,
|
|
529
|
+
lastPrompt: session.lastPrompt,
|
|
530
|
+
});
|
|
531
|
+
};
|
|
532
|
+
for (const session of direct.sessions) {
|
|
533
|
+
addTranscript(session, cwd);
|
|
534
|
+
}
|
|
535
|
+
for (const session of recovered.sessions) {
|
|
536
|
+
addTranscript(session, session.cwd);
|
|
537
|
+
}
|
|
538
|
+
for (const session of trackedEndedSessions) {
|
|
539
|
+
if (historyById.has(session.id))
|
|
540
|
+
continue;
|
|
541
|
+
historyById.set(session.id, {
|
|
542
|
+
id: session.id,
|
|
543
|
+
providerId: session.providerId,
|
|
544
|
+
disposition: 'ended',
|
|
545
|
+
actions: { archive: true },
|
|
546
|
+
lastActivity: session.lastActivity,
|
|
547
|
+
name: session.name,
|
|
548
|
+
titles: session.transcriptTitles,
|
|
549
|
+
firstPrompt: session.firstPrompt,
|
|
550
|
+
lastPrompt: session.lastPrompt,
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
const history = [...historyById.values()].sort((a, b) => b.lastActivity.getTime() - a.lastActivity.getTime());
|
|
554
|
+
const limited = options?.limit != null && options.limit > 0 ? history.slice(0, options.limit) : history;
|
|
555
|
+
return { sessions: limited, total: Math.max(direct.total + recovered.total, history.length) };
|
|
556
|
+
}
|
|
557
|
+
resumeSession(sessionId, cwd, target, name) {
|
|
558
|
+
return this.callbacks.resumeSession?.(sessionId, cwd, target, name) ?? Promise.resolve(false);
|
|
308
559
|
}
|
|
309
560
|
dispose() {
|
|
310
561
|
this._disposed = true;
|
|
@@ -350,11 +601,11 @@ export class ClaudeCodeProvider {
|
|
|
350
601
|
return Promise.resolve();
|
|
351
602
|
}),
|
|
352
603
|
this.callbacks.ipc.registerHandler('agents/sessions/list', () => Promise.resolve(
|
|
353
|
-
// Exclude terminal `
|
|
354
|
-
// would import them as phantom "idle" sessions. Our own poll surfaces
|
|
604
|
+
// Exclude terminal `ended` rows: an older-version peer has no `ended` phase and
|
|
605
|
+
// would import them as phantom "idle" sessions. Our own poll surfaces ended sessions
|
|
355
606
|
// independently, so peers never need them from here.
|
|
356
607
|
this._sessions
|
|
357
|
-
.filter(s => s.status !== '
|
|
608
|
+
.filter(s => s.status !== 'ended')
|
|
358
609
|
.map(s => ({
|
|
359
610
|
...s,
|
|
360
611
|
lastActivity: s.lastActivity.toISOString(),
|
|
@@ -375,7 +626,7 @@ export class ClaudeCodeProvider {
|
|
|
375
626
|
return { opened: true };
|
|
376
627
|
}
|
|
377
628
|
catch (ex) {
|
|
378
|
-
Logger.warn(`
|
|
629
|
+
Logger.warn(`GkAgentProvider.agents/sessions/open: ${ex instanceof Error ? ex.message : String(ex)}`);
|
|
379
630
|
return { opened: false };
|
|
380
631
|
}
|
|
381
632
|
}),
|
|
@@ -388,7 +639,7 @@ export class ClaudeCodeProvider {
|
|
|
388
639
|
for (const d of handlers) {
|
|
389
640
|
d.dispose();
|
|
390
641
|
}
|
|
391
|
-
Logger.error(ex, '
|
|
642
|
+
Logger.error(ex, 'GkAgentProvider.ensureIpcServer');
|
|
392
643
|
return;
|
|
393
644
|
}
|
|
394
645
|
// `publishAgents` no-ops silently if the IPC server failed to start; don't seal
|
|
@@ -416,19 +667,35 @@ export class ClaudeCodeProvider {
|
|
|
416
667
|
this._staleCheckTimer = disposableInterval(() => void this.syncSessions({ gate: true }), staleCheckIntervalMs);
|
|
417
668
|
}
|
|
418
669
|
catch (ex) {
|
|
419
|
-
Logger.error(ex, '
|
|
670
|
+
Logger.error(ex, 'GkAgentProvider.ensureIpcServer');
|
|
420
671
|
}
|
|
421
672
|
}
|
|
422
673
|
handleSessionEvent(event, isBlocking) {
|
|
423
|
-
// The CLI broadcasts every AI host's events to every listener
|
|
424
|
-
//
|
|
425
|
-
//
|
|
426
|
-
|
|
427
|
-
|
|
674
|
+
// The CLI broadcasts every AI host's events to every listener, stamped with its `gk ai hook`
|
|
675
|
+
// client id. Absent = `claude-code`: older CLIs don't stamp it, and no other client existed
|
|
676
|
+
// then. A client we hold no descriptor for is dropped — the CLI supports more than this table
|
|
677
|
+
// does, and we can't interpret an agent whose vocabulary we don't know. A dropped blocking
|
|
678
|
+
// request gets no decision, so the CLI waits out its own hook timeout, which is correct: we
|
|
679
|
+
// must not answer for an agent we don't track.
|
|
680
|
+
const hookClientId = event.providerId ?? 'claude-code';
|
|
681
|
+
const capabilities = getAgentCapabilities(hookClientId);
|
|
682
|
+
if (capabilities == null) {
|
|
683
|
+
Logger.debug(`GkAgentProvider.handleSessionEvent: ignoring ${event.event} from ${hookClientId}`);
|
|
428
684
|
return Promise.resolve();
|
|
429
685
|
}
|
|
686
|
+
// The CLI relays each agent's native event name verbatim, so translate before doing anything
|
|
687
|
+
// else. An unresolvable name must never reach the switch below: it would match no case while
|
|
688
|
+
// still having created/updated a row, and for a blocking delivery would answer nothing while
|
|
689
|
+
// looking handled.
|
|
690
|
+
const hookEvent = resolveCanonicalHookEvent(capabilities, event.event, event.hookInput);
|
|
691
|
+
if (hookEvent == null) {
|
|
692
|
+
Logger.debug(`GkAgentProvider.handleSessionEvent: ignoring unmapped event ${event.event} from ${hookClientId}`);
|
|
693
|
+
return Promise.resolve();
|
|
694
|
+
}
|
|
695
|
+
const toolName = canonicalToolNameFromEvent(capabilities, event);
|
|
430
696
|
const workspacePath = this.resolveWorkspacePath(event.cwd);
|
|
431
697
|
const eventContext = {
|
|
698
|
+
capabilities: capabilities,
|
|
432
699
|
pid: event.pid,
|
|
433
700
|
workspacePath: workspacePath,
|
|
434
701
|
isInWorkspace: workspacePath != null,
|
|
@@ -438,15 +705,18 @@ export class ClaudeCodeProvider {
|
|
|
438
705
|
sessionName: event.sessionName,
|
|
439
706
|
model: event.model,
|
|
440
707
|
worktreePath: worktreeFromEvent(event),
|
|
708
|
+
visitedWorktreePaths: visitedFromTimeline(event.cwdTimeline),
|
|
441
709
|
};
|
|
442
710
|
const tag = this.sessionTag(event.sessionId, event.sessionName ?? 'unnamed');
|
|
443
|
-
|
|
444
|
-
|
|
711
|
+
// Log the native name alongside the canonical one — a mapping mistake is otherwise invisible.
|
|
712
|
+
const eventTag = event.event === hookEvent ? hookEvent : `${hookEvent}(${event.event})`;
|
|
713
|
+
if (hookEvent === 'SessionStart' || hookEvent === 'SessionEnd') {
|
|
714
|
+
Logger.info(`GkAgentProvider.handleSessionEvent: ${eventTag} ${tag}`);
|
|
445
715
|
}
|
|
446
716
|
else {
|
|
447
|
-
Logger.debug(`
|
|
717
|
+
Logger.debug(`GkAgentProvider.handleSessionEvent: ${eventTag} ${tag}${toolName ? ` tool=${toolName}` : ''}${event.agentId ? ` agent=${event.agentId}` : ''}${event.notificationType ? ` type=${event.notificationType}` : ''}`);
|
|
448
718
|
}
|
|
449
|
-
switch (
|
|
719
|
+
switch (hookEvent) {
|
|
450
720
|
case 'SessionStart': {
|
|
451
721
|
const wasNew = this._sessions.findIndex(s => s.id === event.sessionId) < 0;
|
|
452
722
|
const { index } = this.ensureSession(event.sessionId, eventContext);
|
|
@@ -463,10 +733,10 @@ export class ClaudeCodeProvider {
|
|
|
463
733
|
}
|
|
464
734
|
else {
|
|
465
735
|
const prev = this._sessions[index];
|
|
466
|
-
if (prev.status === '
|
|
467
|
-
// Resuming a terminal session reuses its id, so a SessionStart on
|
|
736
|
+
if (prev.status === 'ended') {
|
|
737
|
+
// Resuming a terminal session reuses its id, so a SessionStart on an ended
|
|
468
738
|
// row IS the resume signal — revive it to a live `idle` row (clearing the
|
|
469
|
-
//
|
|
739
|
+
// ended/archivable state) with clean bookkeeping that endSession tore
|
|
470
740
|
// down. The next real event advances it out of idle. A `SessionStart` arrives on
|
|
471
741
|
// THIS window's hook flow, so the resume is locally owned now: clear the peer
|
|
472
742
|
// ownership carried over from the terminal record, and take the resume's pid
|
|
@@ -492,7 +762,7 @@ export class ClaudeCodeProvider {
|
|
|
492
762
|
}
|
|
493
763
|
this._onDidChangeSessions.fire();
|
|
494
764
|
void this.resolveTranscriptTitles(event.sessionId, event.cwd);
|
|
495
|
-
this.callbacks.onSessionStarted?.(
|
|
765
|
+
this.callbacks.onSessionStarted?.(capabilities.providerId);
|
|
496
766
|
break;
|
|
497
767
|
}
|
|
498
768
|
case 'SessionEnd': {
|
|
@@ -505,13 +775,13 @@ export class ClaudeCodeProvider {
|
|
|
505
775
|
const index = this._sessions.findIndex(s => s.id === event.sessionId);
|
|
506
776
|
if (index >= 0) {
|
|
507
777
|
if (this._statusFilterSupported) {
|
|
508
|
-
// Transition to a terminal `
|
|
778
|
+
// Transition to a terminal `ended` row rather than removing it — ended
|
|
509
779
|
// sessions stay visible (de-emphasized) until archived or 30-day-purged by the CLI.
|
|
510
|
-
this.
|
|
780
|
+
this.endSession(index, new Date());
|
|
511
781
|
}
|
|
512
782
|
else {
|
|
513
|
-
// Legacy CLI (no `--status` durable store):
|
|
514
|
-
// poll-confirmed nor archived, so keep the pre-
|
|
783
|
+
// Legacy CLI (no `--status` durable store): an ended row could never be
|
|
784
|
+
// poll-confirmed nor archived, so keep the pre-ended remove-on-end behavior.
|
|
515
785
|
this._sessions.splice(index, 1);
|
|
516
786
|
const bk = this._sessionBookkeeping.get(event.sessionId);
|
|
517
787
|
if (bk != null) {
|
|
@@ -520,6 +790,7 @@ export class ClaudeCodeProvider {
|
|
|
520
790
|
}
|
|
521
791
|
this._sessionBookkeeping.delete(event.sessionId);
|
|
522
792
|
this._transcriptReader.forget(event.sessionId);
|
|
793
|
+
this.noteTerminalTransition();
|
|
523
794
|
}
|
|
524
795
|
this._onDidChangeSessions.fire();
|
|
525
796
|
}
|
|
@@ -534,8 +805,11 @@ export class ClaudeCodeProvider {
|
|
|
534
805
|
}
|
|
535
806
|
this._sessionBookkeeping.delete(event.sessionId);
|
|
536
807
|
this._transcriptReader.forget(event.sessionId);
|
|
808
|
+
// Untracked or not, a session just reached a terminal state and finalized its
|
|
809
|
+
// transcript — an in-flight history query must not accept an answer that predates it.
|
|
810
|
+
this.noteTerminalTransition();
|
|
537
811
|
}
|
|
538
|
-
this.callbacks.onSessionEnded?.(
|
|
812
|
+
this.callbacks.onSessionEnded?.(capabilities.providerId);
|
|
539
813
|
break;
|
|
540
814
|
}
|
|
541
815
|
case 'UserPromptSubmit': {
|
|
@@ -565,15 +839,13 @@ export class ClaudeCodeProvider {
|
|
|
565
839
|
case 'PreToolUse': {
|
|
566
840
|
const bk = this.getBookkeeping(event.sessionId);
|
|
567
841
|
bk.activeToolCount++;
|
|
568
|
-
const filesChanged = this.trackToolUseFile(event.sessionId, event);
|
|
569
|
-
const readsChanged = this.trackToolUseRead(event.sessionId, event);
|
|
842
|
+
const filesChanged = this.trackToolUseFile(event.sessionId, event, capabilities);
|
|
843
|
+
const readsChanged = this.trackToolUseRead(event.sessionId, event, capabilities);
|
|
570
844
|
// Resolve a rich `Bash(grep …)`-style detail via the same tool_input passthrough the
|
|
571
845
|
// PermissionRequest handler uses, so working sessions surface what their tool is
|
|
572
846
|
// actually doing — not just the bare tool name. Falls back to bare name when no
|
|
573
847
|
// toolInput is available (older CLI / unhandled tool).
|
|
574
|
-
const
|
|
575
|
-
const toolInput = hookInput?.tool_input ?? event.toolInput;
|
|
576
|
-
const toolName = hookInput?.tool_name ?? event.toolName ?? '';
|
|
848
|
+
const toolInput = event.hookInput?.tool_input ?? event.toolInput;
|
|
577
849
|
const statusDetail = toolInput != null && toolName ? describeToolInput(toolName, toolInput) : toolName || undefined;
|
|
578
850
|
// Capture pre-update status so we can tell whether the upcoming updateSessionStatus
|
|
579
851
|
// will fire on its own — needed to avoid dropping fileActivity changes when the
|
|
@@ -593,7 +865,7 @@ export class ClaudeCodeProvider {
|
|
|
593
865
|
}
|
|
594
866
|
case 'PostToolUse':
|
|
595
867
|
case 'PostToolUseFailure': {
|
|
596
|
-
this.clearStalePermission(event.sessionId,
|
|
868
|
+
this.clearStalePermission(event.sessionId, hookEvent, this.settledByFromEvent(event, capabilities));
|
|
597
869
|
const bk = this.getBookkeeping(event.sessionId);
|
|
598
870
|
bk.activeToolCount = Math.max(0, bk.activeToolCount - 1);
|
|
599
871
|
// Cooldown the file-edit decoration instead of dropping it immediately, so a quick
|
|
@@ -602,8 +874,8 @@ export class ClaudeCodeProvider {
|
|
|
602
874
|
// `editing`/`reading` off the moment refcount hits zero, though — capture whether that
|
|
603
875
|
// changed the published list so we fire it ourselves when the status update below short-
|
|
604
876
|
// circuits (a parallel tool is still in flight, so activeToolCount stays > 0).
|
|
605
|
-
const filesChanged = this.scheduleClearToolUseFile(event.sessionId, event);
|
|
606
|
-
const readsChanged = this.scheduleClearToolUseRead(event.sessionId, event);
|
|
877
|
+
const filesChanged = this.scheduleClearToolUseFile(event.sessionId, event, capabilities);
|
|
878
|
+
const readsChanged = this.scheduleClearToolUseRead(event.sessionId, event, capabilities);
|
|
607
879
|
if (bk.activeToolCount === 0) {
|
|
608
880
|
// updateSessionStatus short-circuits without firing when status+detail are unchanged
|
|
609
881
|
// (already 'thinking' with no detail) — which would drop the editing/reading flag-flip
|
|
@@ -662,8 +934,8 @@ export class ClaudeCodeProvider {
|
|
|
662
934
|
case 'permission_prompt':
|
|
663
935
|
this.updateSessionStatus(event.sessionId, 'permission_requested', {
|
|
664
936
|
...eventContext,
|
|
665
|
-
statusDetail:
|
|
666
|
-
pendingPermission: synthesizeUnresolvableAsk(
|
|
937
|
+
statusDetail: toolName,
|
|
938
|
+
pendingPermission: synthesizeUnresolvableAsk(toolName,
|
|
667
939
|
// Plan-kind body: prefer the session's planFile, fall back to the event's
|
|
668
940
|
// — mirrors the blocking path.
|
|
669
941
|
this._sessions.find(s => s.id === event.sessionId)?.planFile ?? event.planFile),
|
|
@@ -675,11 +947,11 @@ export class ClaudeCodeProvider {
|
|
|
675
947
|
// hook produces.
|
|
676
948
|
this.updateSessionStatus(event.sessionId, 'permission_requested', {
|
|
677
949
|
...eventContext,
|
|
678
|
-
statusDetail:
|
|
950
|
+
statusDetail: toolName,
|
|
679
951
|
pendingPermission: {
|
|
680
952
|
kind: 'elicitation',
|
|
681
|
-
toolName:
|
|
682
|
-
toolDescription:
|
|
953
|
+
toolName: toolName ?? 'Input Required',
|
|
954
|
+
toolDescription: toolName ?? 'Waiting for input',
|
|
683
955
|
resolvable: false,
|
|
684
956
|
},
|
|
685
957
|
});
|
|
@@ -693,11 +965,14 @@ export class ClaudeCodeProvider {
|
|
|
693
965
|
// hookInput is the raw passthrough; fall back to top-level fields for older CLI versions.
|
|
694
966
|
const hookInput = event.hookInput;
|
|
695
967
|
const toolInput = hookInput?.tool_input ?? event.toolInput;
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
968
|
+
// An agent whose asks we can't answer falls through to the observe-only branch below,
|
|
969
|
+
// which publishes the same card marked `resolvable: false`. Registering a pending entry
|
|
970
|
+
// for it would promise an Allow/Deny this window can never route.
|
|
971
|
+
if (canAnswerBlockingPermission(capabilities) && isBlocking && toolInput != null) {
|
|
972
|
+
const askToolName = toolName ?? '';
|
|
973
|
+
const toolDescription = describeToolInput(askToolName, toolInput);
|
|
699
974
|
const toolInputDescription = toolInput.description || undefined;
|
|
700
|
-
const kind = classifyPermissionKind(
|
|
975
|
+
const kind = classifyPermissionKind(askToolName);
|
|
701
976
|
const toolUseId = hookInput?.tool_use_id;
|
|
702
977
|
const toolInputJson = stableStringify(toolInput);
|
|
703
978
|
return new Promise((resolve, reject) => {
|
|
@@ -708,7 +983,7 @@ export class ClaudeCodeProvider {
|
|
|
708
983
|
((existing.toolUseId != null && toolUseId != null && existing.toolUseId === toolUseId) ||
|
|
709
984
|
(existing.toolUseId == null &&
|
|
710
985
|
toolUseId == null &&
|
|
711
|
-
existing.toolName ===
|
|
986
|
+
existing.toolName === askToolName &&
|
|
712
987
|
existing.toolInputJson === toolInputJson));
|
|
713
988
|
if (isDuplicateDelivery) {
|
|
714
989
|
// Duplicate delivery of the same ask (CLI retry after its 60s response-header
|
|
@@ -721,7 +996,7 @@ export class ClaudeCodeProvider {
|
|
|
721
996
|
return;
|
|
722
997
|
}
|
|
723
998
|
if (existing != null) {
|
|
724
|
-
Logger.debug(`
|
|
999
|
+
Logger.debug(`GkAgentProvider.handleSessionEvent: auto-denying stale permission ${tag} tool=${existing.toolName}`);
|
|
725
1000
|
this.resolveAllPending(existing, {
|
|
726
1001
|
hookSpecificOutput: {
|
|
727
1002
|
hookEventName: 'PermissionRequest',
|
|
@@ -731,7 +1006,7 @@ export class ClaudeCodeProvider {
|
|
|
731
1006
|
}
|
|
732
1007
|
this._pendingPermissions.set(event.sessionId, {
|
|
733
1008
|
resolvers: [{ resolve: resolve, reject: reject }],
|
|
734
|
-
toolName:
|
|
1009
|
+
toolName: askToolName,
|
|
735
1010
|
toolDescription: toolDescription,
|
|
736
1011
|
toolUseId: toolUseId,
|
|
737
1012
|
toolInputJson: toolInputJson,
|
|
@@ -745,7 +1020,7 @@ export class ClaudeCodeProvider {
|
|
|
745
1020
|
const questionDetails = kind === 'question' ? extractQuestionDetails(toolInput) : undefined;
|
|
746
1021
|
const permission = {
|
|
747
1022
|
kind: kind,
|
|
748
|
-
toolName:
|
|
1023
|
+
toolName: askToolName,
|
|
749
1024
|
toolDescription: toolDescription,
|
|
750
1025
|
toolInputDescription: toolInputDescription,
|
|
751
1026
|
suggestions: hookInput?.permission_suggestions ??
|
|
@@ -761,7 +1036,6 @@ export class ClaudeCodeProvider {
|
|
|
761
1036
|
{
|
|
762
1037
|
// Mirror the blocking payload's shape so the ask renders identically either way —
|
|
763
1038
|
// just unresolvable, since no blocking hook entry exists to route an answer through.
|
|
764
|
-
const toolName = hookInput?.tool_name ?? event.toolName;
|
|
765
1039
|
const kind = toolName != null ? classifyPermissionKind(toolName) : undefined;
|
|
766
1040
|
// Plan-kind body: same session-first preference as the blocking path.
|
|
767
1041
|
const planFilePath = kind === 'plan'
|
|
@@ -786,18 +1060,18 @@ export class ClaudeCodeProvider {
|
|
|
786
1060
|
: synthesizeUnresolvableAsk(toolName, planFilePath);
|
|
787
1061
|
this.updateSessionStatus(event.sessionId, 'permission_requested', {
|
|
788
1062
|
...eventContext,
|
|
789
|
-
statusDetail:
|
|
1063
|
+
statusDetail: toolName,
|
|
790
1064
|
pendingPermission: synthesized,
|
|
791
1065
|
});
|
|
792
1066
|
}
|
|
793
1067
|
break;
|
|
794
1068
|
}
|
|
795
1069
|
case 'PermissionDenied': {
|
|
796
|
-
this.clearStalePermission(event.sessionId, 'PermissionDenied', this.settledByFromEvent(event));
|
|
1070
|
+
this.clearStalePermission(event.sessionId, 'PermissionDenied', this.settledByFromEvent(event, capabilities));
|
|
797
1071
|
const bk = this.getBookkeeping(event.sessionId);
|
|
798
1072
|
bk.activeToolCount = Math.max(0, bk.activeToolCount - 1);
|
|
799
|
-
const filesChanged = this.untrackToolUseFile(event.sessionId, event);
|
|
800
|
-
const readsChanged = this.untrackToolUseRead(event.sessionId, event);
|
|
1073
|
+
const filesChanged = this.untrackToolUseFile(event.sessionId, event, capabilities);
|
|
1074
|
+
const readsChanged = this.untrackToolUseRead(event.sessionId, event, capabilities);
|
|
801
1075
|
// When a parallel tool is still active the next status equals the current one and
|
|
802
1076
|
// updateSessionStatus short-circuits — without an explicit fire, the dropped path
|
|
803
1077
|
// would never reach subscribers (treemap activity overlay, WIP decoration).
|
|
@@ -815,15 +1089,15 @@ export class ClaudeCodeProvider {
|
|
|
815
1089
|
const bk = this.getBookkeeping(event.sessionId);
|
|
816
1090
|
bk.pendingPermission = {
|
|
817
1091
|
kind: 'elicitation',
|
|
818
|
-
toolName:
|
|
819
|
-
toolDescription:
|
|
1092
|
+
toolName: toolName ?? 'Input Required',
|
|
1093
|
+
toolDescription: toolName ?? 'Waiting for input',
|
|
820
1094
|
// `Elicitation` arrives on a non-blocking hook, so no entry exists for
|
|
821
1095
|
// `resolvePermission` to answer — the user responds in the agent's own session.
|
|
822
1096
|
resolvable: false,
|
|
823
1097
|
};
|
|
824
1098
|
this.updateSessionStatus(event.sessionId, 'permission_requested', {
|
|
825
1099
|
...eventContext,
|
|
826
|
-
statusDetail:
|
|
1100
|
+
statusDetail: toolName,
|
|
827
1101
|
});
|
|
828
1102
|
break;
|
|
829
1103
|
}
|
|
@@ -853,8 +1127,8 @@ export class ClaudeCodeProvider {
|
|
|
853
1127
|
const now = new Date();
|
|
854
1128
|
const subagent = {
|
|
855
1129
|
id: event.agentId,
|
|
856
|
-
providerId:
|
|
857
|
-
providerName:
|
|
1130
|
+
providerId: capabilities.providerId,
|
|
1131
|
+
providerName: capabilities.displayName,
|
|
858
1132
|
name: event.agentType ?? 'Subagent',
|
|
859
1133
|
status: 'thinking',
|
|
860
1134
|
phase: 'working',
|
|
@@ -902,6 +1176,20 @@ export class ClaudeCodeProvider {
|
|
|
902
1176
|
? `[session=${sessionId.substring(0, 8)}(${name})]`
|
|
903
1177
|
: `[session=${sessionId.substring(0, 8)}]`;
|
|
904
1178
|
}
|
|
1179
|
+
/** The owning agent's descriptor for a tracked session, resolved from the session's own
|
|
1180
|
+
* `providerId`. `undefined` when the session isn't tracked (pruned/archived since) or carries a
|
|
1181
|
+
* `providerId` the descriptor table doesn't know — callers pick their own fallback rather than
|
|
1182
|
+
* silently assuming Claude Code. */
|
|
1183
|
+
getSessionCapabilities(sessionId) {
|
|
1184
|
+
const session = this._sessions.find(s => s.id === sessionId);
|
|
1185
|
+
return session != null ? getAgentCapabilitiesByProviderId(session.providerId) : undefined;
|
|
1186
|
+
}
|
|
1187
|
+
/** Whether the session's agent leaves a transcript on disk for {@link ClaudeCodeTranscriptReader}
|
|
1188
|
+
* to tail. Fails OPEN when no descriptor resolves, so an unknown/absent `providerId` can never
|
|
1189
|
+
* suppress the Claude path this reader exists for. */
|
|
1190
|
+
supportsTranscripts(sessionId) {
|
|
1191
|
+
return this.getSessionCapabilities(sessionId)?.supportsTranscripts ?? true;
|
|
1192
|
+
}
|
|
905
1193
|
getBookkeeping(sessionId) {
|
|
906
1194
|
let bk = this._sessionBookkeeping.get(sessionId);
|
|
907
1195
|
if (bk == null) {
|
|
@@ -1009,8 +1297,8 @@ export class ClaudeCodeProvider {
|
|
|
1009
1297
|
* for older CLI versions that only fill the top-level fields. Treats an empty-object
|
|
1010
1298
|
* `hookInput.tool_input` as missing — `??` would have kept it (truthy) and shadowed a
|
|
1011
1299
|
* populated `event.toolInput`. */
|
|
1012
|
-
getEventFilePath(event) {
|
|
1013
|
-
const toolName =
|
|
1300
|
+
getEventFilePath(event, capabilities) {
|
|
1301
|
+
const toolName = canonicalToolNameFromEvent(capabilities, event);
|
|
1014
1302
|
if (toolName == null)
|
|
1015
1303
|
return undefined;
|
|
1016
1304
|
const rawHookInput = event.hookInput?.tool_input;
|
|
@@ -1049,8 +1337,8 @@ export class ClaudeCodeProvider {
|
|
|
1049
1337
|
* doesn't reliably surface) so parallel calls bump the count and Post/Pre order doesn't matter.
|
|
1050
1338
|
* Returns whether the parent's published `fileActivity` actually changed so callers can fire
|
|
1051
1339
|
* when the surrounding {@link updateSessionStatus} short-circuits. */
|
|
1052
|
-
trackToolUseFile(sessionId, event) {
|
|
1053
|
-
const filePath = this.getEventFilePath(event);
|
|
1340
|
+
trackToolUseFile(sessionId, event, capabilities) {
|
|
1341
|
+
const filePath = this.getEventFilePath(event, capabilities);
|
|
1054
1342
|
if (filePath == null)
|
|
1055
1343
|
return false;
|
|
1056
1344
|
const bk = this.getBookkeeping(sessionId);
|
|
@@ -1073,8 +1361,8 @@ export class ClaudeCodeProvider {
|
|
|
1073
1361
|
* above zero. Returns whether the parent's published `fileActivity` changed (i.e. `editing`
|
|
1074
1362
|
* flipped off) so the PostToolUse caller can fire when its `updateSessionStatus` short-
|
|
1075
1363
|
* circuits (a parallel tool still in flight keeps activeToolCount > 0). */
|
|
1076
|
-
scheduleClearToolUseFile(sessionId, event) {
|
|
1077
|
-
const filePath = this.getEventFilePath(event);
|
|
1364
|
+
scheduleClearToolUseFile(sessionId, event, capabilities) {
|
|
1365
|
+
const filePath = this.getEventFilePath(event, capabilities);
|
|
1078
1366
|
if (filePath == null)
|
|
1079
1367
|
return false;
|
|
1080
1368
|
const bk = this.getBookkeeping(sessionId);
|
|
@@ -1128,8 +1416,8 @@ export class ClaudeCodeProvider {
|
|
|
1128
1416
|
/** Drops a tracked tool call's file immediately (no cooldown). Used for PermissionDenied,
|
|
1129
1417
|
* where the tool never ran — keeping the "live" badge would be misleading.
|
|
1130
1418
|
* Returns whether the published list changed. */
|
|
1131
|
-
untrackToolUseFile(sessionId, event) {
|
|
1132
|
-
const filePath = this.getEventFilePath(event);
|
|
1419
|
+
untrackToolUseFile(sessionId, event, capabilities) {
|
|
1420
|
+
const filePath = this.getEventFilePath(event, capabilities);
|
|
1133
1421
|
if (filePath == null)
|
|
1134
1422
|
return false;
|
|
1135
1423
|
const bk = this.getBookkeeping(sessionId);
|
|
@@ -1150,8 +1438,8 @@ export class ClaudeCodeProvider {
|
|
|
1150
1438
|
return this.syncSessionFileActivityForChild(sessionId);
|
|
1151
1439
|
}
|
|
1152
1440
|
/** Mirror of {@link getEventFilePath} for read-only file tools. */
|
|
1153
|
-
getEventReadPath(event) {
|
|
1154
|
-
const toolName =
|
|
1441
|
+
getEventReadPath(event, capabilities) {
|
|
1442
|
+
const toolName = canonicalToolNameFromEvent(capabilities, event);
|
|
1155
1443
|
if (toolName == null)
|
|
1156
1444
|
return undefined;
|
|
1157
1445
|
const rawHookInput = event.hookInput?.tool_input;
|
|
@@ -1160,8 +1448,8 @@ export class ClaudeCodeProvider {
|
|
|
1160
1448
|
return getToolReadPath(toolName, toolInput);
|
|
1161
1449
|
}
|
|
1162
1450
|
/** Refcount mirror of {@link trackToolUseFile} for read-only file tools. */
|
|
1163
|
-
trackToolUseRead(sessionId, event) {
|
|
1164
|
-
const filePath = this.getEventReadPath(event);
|
|
1451
|
+
trackToolUseRead(sessionId, event, capabilities) {
|
|
1452
|
+
const filePath = this.getEventReadPath(event, capabilities);
|
|
1165
1453
|
if (filePath == null)
|
|
1166
1454
|
return false;
|
|
1167
1455
|
const bk = this.getBookkeeping(sessionId);
|
|
@@ -1176,8 +1464,8 @@ export class ClaudeCodeProvider {
|
|
|
1176
1464
|
}
|
|
1177
1465
|
/** Cooldown mirror of {@link scheduleClearToolUseFile} for read-only file tools. Returns
|
|
1178
1466
|
* whether the parent's published `fileActivity` changed (i.e. `reading` flipped off). */
|
|
1179
|
-
scheduleClearToolUseRead(sessionId, event) {
|
|
1180
|
-
const filePath = this.getEventReadPath(event);
|
|
1467
|
+
scheduleClearToolUseRead(sessionId, event, capabilities) {
|
|
1468
|
+
const filePath = this.getEventReadPath(event, capabilities);
|
|
1181
1469
|
if (filePath == null)
|
|
1182
1470
|
return false;
|
|
1183
1471
|
const bk = this.getBookkeeping(sessionId);
|
|
@@ -1219,8 +1507,8 @@ export class ClaudeCodeProvider {
|
|
|
1219
1507
|
bk.pendingReadClears.set(filePath, timer);
|
|
1220
1508
|
}
|
|
1221
1509
|
/** Immediate-drop mirror of {@link untrackToolUseFile} for read-only file tools. */
|
|
1222
|
-
untrackToolUseRead(sessionId, event) {
|
|
1223
|
-
const filePath = this.getEventReadPath(event);
|
|
1510
|
+
untrackToolUseRead(sessionId, event, capabilities) {
|
|
1511
|
+
const filePath = this.getEventReadPath(event, capabilities);
|
|
1224
1512
|
if (filePath == null)
|
|
1225
1513
|
return false;
|
|
1226
1514
|
const bk = this.getBookkeeping(sessionId);
|
|
@@ -1263,6 +1551,8 @@ export class ClaudeCodeProvider {
|
|
|
1263
1551
|
if (index < 0)
|
|
1264
1552
|
return false;
|
|
1265
1553
|
const parent = this._sessions[index];
|
|
1554
|
+
if (parent.status === 'ended')
|
|
1555
|
+
return false;
|
|
1266
1556
|
const parentBk = this._sessionBookkeeping.get(parentSessionId);
|
|
1267
1557
|
const merged = new Map();
|
|
1268
1558
|
const contributeFrom = (bk) => {
|
|
@@ -1333,10 +1623,10 @@ export class ClaudeCodeProvider {
|
|
|
1333
1623
|
this.cancelPendingIdleTransition(sessionId);
|
|
1334
1624
|
const timer = setTimeout(() => {
|
|
1335
1625
|
this._pendingIdleTimers.delete(sessionId);
|
|
1336
|
-
// Session may have been removed (SessionEnd, prune) during the window, or
|
|
1626
|
+
// Session may have been removed (SessionEnd, prune) during the window, or ended by a
|
|
1337
1627
|
// poll/SessionEnd that raced this timer — reviving a terminal row to idle would zombie it.
|
|
1338
1628
|
const idx = this._sessions.findIndex(s => s.id === sessionId);
|
|
1339
|
-
if (idx < 0 || this._sessions[idx].status === '
|
|
1629
|
+
if (idx < 0 || this._sessions[idx].status === 'ended')
|
|
1340
1630
|
return;
|
|
1341
1631
|
this.updateSessionStatus(sessionId, 'idle');
|
|
1342
1632
|
}, stopToIdleDebounceMs);
|
|
@@ -1369,12 +1659,14 @@ export class ClaudeCodeProvider {
|
|
|
1369
1659
|
* carry `hookInput.tool_use_id` and `hookInput.tool_input` in production; the top-level
|
|
1370
1660
|
* `event.toolName`/`event.toolInput` are the older-CLI fallback. Input is canonicalized so key
|
|
1371
1661
|
* order (or arriving via the fallback field instead of `hookInput`) doesn't defeat comparison. */
|
|
1372
|
-
settledByFromEvent(event) {
|
|
1662
|
+
settledByFromEvent(event, capabilities) {
|
|
1373
1663
|
const hookInput = event.hookInput;
|
|
1374
1664
|
const toolInput = hookInput?.tool_input ?? event.toolInput;
|
|
1375
1665
|
return {
|
|
1376
1666
|
toolUseId: hookInput?.tool_use_id,
|
|
1377
|
-
toolName
|
|
1667
|
+
// Canonical, because the registered ask's `toolName` is canonical too — comparing a native
|
|
1668
|
+
// name against it would look like an identity mismatch and strand the ask.
|
|
1669
|
+
toolName: canonicalToolNameFromEvent(capabilities, event),
|
|
1378
1670
|
toolInputJson: toolInput != null ? stableStringify(toolInput) : undefined,
|
|
1379
1671
|
};
|
|
1380
1672
|
}
|
|
@@ -1424,11 +1716,11 @@ export class ClaudeCodeProvider {
|
|
|
1424
1716
|
pending.toolInputJson !== settledBy.toolInputJson);
|
|
1425
1717
|
}
|
|
1426
1718
|
if (mismatch) {
|
|
1427
|
-
Logger.debug(`
|
|
1719
|
+
Logger.debug(`GkAgentProvider.clearStalePermission: skip (identity mismatch) ${reason} ${this.sessionTag(sessionId)} pending=${pending.toolUseId ?? pending.toolName} settledBy=${settledBy.toolUseId ?? settledBy.toolName ?? 'unknown'}`);
|
|
1428
1720
|
return;
|
|
1429
1721
|
}
|
|
1430
1722
|
}
|
|
1431
|
-
Logger.debug(`
|
|
1723
|
+
Logger.debug(`GkAgentProvider.clearStalePermission: ${reason} ${this.sessionTag(sessionId)}`);
|
|
1432
1724
|
this.resolveAllPending(pending, {
|
|
1433
1725
|
hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: { behavior: 'deny' } },
|
|
1434
1726
|
});
|
|
@@ -1441,17 +1733,25 @@ export class ClaudeCodeProvider {
|
|
|
1441
1733
|
if (settledBy?.toolName != null &&
|
|
1442
1734
|
bk.pendingPermission.toolName != null &&
|
|
1443
1735
|
settledBy.toolName !== bk.pendingPermission.toolName) {
|
|
1444
|
-
Logger.debug(`
|
|
1736
|
+
Logger.debug(`GkAgentProvider.clearStalePermission: skip (display-only identity mismatch) ${reason} ${this.sessionTag(sessionId)} pending=${bk.pendingPermission.toolName} settledBy=${settledBy.toolName}`);
|
|
1445
1737
|
return;
|
|
1446
1738
|
}
|
|
1447
|
-
Logger.debug(`
|
|
1739
|
+
Logger.debug(`GkAgentProvider.clearStalePermission: ${reason} ${this.sessionTag(sessionId)}`);
|
|
1448
1740
|
bk.pendingPermission = undefined;
|
|
1449
1741
|
}
|
|
1450
1742
|
resolvePermission(sessionId, decision, updatedPermissions) {
|
|
1743
|
+
// Explicit refusal rather than an incidental one: an agent we can't answer for never registers
|
|
1744
|
+
// a pending entry, so the `pending == null` check below would already return `false` — but the
|
|
1745
|
+
// caller deserves to know WHY, and the guard has to survive a future entry-registering path.
|
|
1746
|
+
const capabilities = this.getSessionCapabilities(sessionId);
|
|
1747
|
+
if (capabilities != null && !canAnswerBlockingPermission(capabilities)) {
|
|
1748
|
+
Logger.debug(`GkAgentProvider.resolvePermission: refusing ${decision} for ${this.sessionTag(sessionId)} — ${capabilities.displayName} asks are observe-only`);
|
|
1749
|
+
return false;
|
|
1750
|
+
}
|
|
1451
1751
|
const pending = this._pendingPermissions.get(sessionId);
|
|
1452
1752
|
if (pending == null)
|
|
1453
1753
|
return false;
|
|
1454
|
-
Logger.debug(`
|
|
1754
|
+
Logger.debug(`GkAgentProvider.resolvePermission: ${decision} ${this.sessionTag(sessionId)} tool=${pending.toolName}`);
|
|
1455
1755
|
this.resolveAllPending(pending, {
|
|
1456
1756
|
hookSpecificOutput: {
|
|
1457
1757
|
hookEventName: 'PermissionRequest',
|
|
@@ -1459,7 +1759,7 @@ export class ClaudeCodeProvider {
|
|
|
1459
1759
|
},
|
|
1460
1760
|
});
|
|
1461
1761
|
this.callbacks.onPermissionResolved?.({
|
|
1462
|
-
provider:
|
|
1762
|
+
provider: capabilities?.providerId ?? claudeCodeCapabilities.providerId,
|
|
1463
1763
|
tool: pending.toolName,
|
|
1464
1764
|
decision: decision,
|
|
1465
1765
|
});
|
|
@@ -1472,16 +1772,16 @@ export class ClaudeCodeProvider {
|
|
|
1472
1772
|
}
|
|
1473
1773
|
async archiveSession(sessionId) {
|
|
1474
1774
|
// Authoritative status guard: the CLI's `archive-session` ends an active session first, so a
|
|
1475
|
-
// stale webview action (or a row that resumed out of `
|
|
1775
|
+
// stale webview action (or a row that resumed out of `ended` since the click) must never
|
|
1476
1776
|
// reach the CLI — archiving is only ever valid on a terminal row. Callers gate the UI too,
|
|
1477
1777
|
// but this is the last line that owns live process safety. Returns `false` when refused so the
|
|
1478
1778
|
// host doesn't record a success telemetry event for an archive that never happened.
|
|
1479
1779
|
const session = this._sessions.find(s => s.id === sessionId);
|
|
1480
|
-
if (session != null && session.status !== '
|
|
1481
|
-
Logger.warn(`
|
|
1780
|
+
if (session != null && session.status !== 'ended') {
|
|
1781
|
+
Logger.warn(`GkAgentProvider.archiveSession: refusing to archive non-ended ${this.sessionTag(sessionId)} (status=${session.status})`);
|
|
1482
1782
|
return false;
|
|
1483
1783
|
}
|
|
1484
|
-
Logger.debug(`
|
|
1784
|
+
Logger.debug(`GkAgentProvider.archiveSession: ${this.sessionTag(sessionId)}`);
|
|
1485
1785
|
await this.callbacks.runCLICommand(['ai', 'hook', 'archive-session', sessionId, '--json']);
|
|
1486
1786
|
// Invalidate the archived-id cache so the next `getPastSessions` re-fetches and excludes this id.
|
|
1487
1787
|
this._archivedSessionIdsCache = undefined;
|
|
@@ -1526,28 +1826,60 @@ export class ClaudeCodeProvider {
|
|
|
1526
1826
|
return [];
|
|
1527
1827
|
}
|
|
1528
1828
|
}
|
|
1529
|
-
|
|
1829
|
+
resolveEndedSessionDetails(sessionId) {
|
|
1530
1830
|
const session = this._sessions.find(s => s.id === sessionId);
|
|
1531
|
-
// Only
|
|
1532
|
-
if (session?.status !== '
|
|
1831
|
+
// Only ended sessions defer resolution; live sessions resolve eagerly on discovery.
|
|
1832
|
+
if (session?.status !== 'ended')
|
|
1533
1833
|
return;
|
|
1534
1834
|
// The durable store drops the prompt (and only ever carries an auto-slug name) for most ended
|
|
1535
1835
|
// sessions, so read the terminal transcript once on demand to recover a real title + the
|
|
1536
1836
|
// first/last prompt. Both the git probe and transcript read sit under the guard so repeated
|
|
1537
1837
|
// opens don't re-probe git or re-read a possibly-large file.
|
|
1538
1838
|
const bk = this.getBookkeeping(sessionId);
|
|
1539
|
-
if (bk.
|
|
1839
|
+
if (bk.endedDetailsResolved)
|
|
1540
1840
|
return;
|
|
1541
|
-
bk.
|
|
1841
|
+
bk.endedDetailsResolved = true;
|
|
1542
1842
|
if (session.cwd) {
|
|
1543
1843
|
void this.resolveGitInfo(sessionId, session.cwd);
|
|
1544
1844
|
}
|
|
1545
|
-
|
|
1845
|
+
// Git identity resolves for every agent; the transcript read is Claude-only (see
|
|
1846
|
+
// `resolveTranscriptTitles`).
|
|
1847
|
+
if (this.supportsTranscripts(sessionId)) {
|
|
1848
|
+
void this.applyEndedTranscriptDetails(sessionId, session.cwd);
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
/** No `_sessions` gate, unlike {@link resolveEndedSessionDetails} — must work for a
|
|
1852
|
+
* transcript-only id this window never tracked live (past-session-sheet callers). */
|
|
1853
|
+
async resolveSessionDetails(sessionId, cwd) {
|
|
1854
|
+
// The reader only reads Claude Code's store, so an agent that leaves no transcript can only
|
|
1855
|
+
// ever miss here. Reachable rather than theoretical: an ended session of ANY agent becomes a
|
|
1856
|
+
// Past row, and opening its sheet routes here by `providerId`. `supportsTranscripts` fails
|
|
1857
|
+
// open, so the transcript-only ids this method exists for — untracked, therefore capability-
|
|
1858
|
+
// less, and Claude's by construction since only its store produces them — still resolve.
|
|
1859
|
+
if (!this.supportsTranscripts(sessionId))
|
|
1860
|
+
return undefined;
|
|
1861
|
+
let cached = this._sessionDetailsCache.get(sessionId);
|
|
1862
|
+
if (cached == null) {
|
|
1863
|
+
cached = this._transcriptReader.resolveEndedDetails(sessionId, cwd).catch((ex) => {
|
|
1864
|
+
// A read error (transient I/O, permissions) is not terminal state — drop the cache
|
|
1865
|
+
// entry so a later call retries. A resolved `undefined` IS terminal (no transcript on
|
|
1866
|
+
// disk) and stays cached.
|
|
1867
|
+
this._sessionDetailsCache.delete(sessionId);
|
|
1868
|
+
throw ex;
|
|
1869
|
+
});
|
|
1870
|
+
this._sessionDetailsCache.set(sessionId, cached);
|
|
1871
|
+
}
|
|
1872
|
+
try {
|
|
1873
|
+
return await cached;
|
|
1874
|
+
}
|
|
1875
|
+
catch {
|
|
1876
|
+
return undefined;
|
|
1877
|
+
}
|
|
1546
1878
|
}
|
|
1547
|
-
async
|
|
1879
|
+
async applyEndedTranscriptDetails(sessionId, cwd) {
|
|
1548
1880
|
let details;
|
|
1549
1881
|
try {
|
|
1550
|
-
details = await this._transcriptReader.
|
|
1882
|
+
details = await this._transcriptReader.resolveEndedDetails(sessionId, cwd);
|
|
1551
1883
|
}
|
|
1552
1884
|
catch {
|
|
1553
1885
|
// A read error (transient I/O, permissions) is not terminal state — clear the once-only
|
|
@@ -1555,7 +1887,7 @@ export class ClaudeCodeProvider {
|
|
|
1555
1887
|
// so it keeps the guard and never re-scans.
|
|
1556
1888
|
const bk = this._sessionBookkeeping.get(sessionId);
|
|
1557
1889
|
if (bk != null) {
|
|
1558
|
-
bk.
|
|
1890
|
+
bk.endedDetailsResolved = false;
|
|
1559
1891
|
}
|
|
1560
1892
|
return;
|
|
1561
1893
|
}
|
|
@@ -1566,7 +1898,7 @@ export class ClaudeCodeProvider {
|
|
|
1566
1898
|
return;
|
|
1567
1899
|
const session = this._sessions[index];
|
|
1568
1900
|
// The session may have been archived/removed while we were reading.
|
|
1569
|
-
if (session.status !== '
|
|
1901
|
+
if (session.status !== 'ended')
|
|
1570
1902
|
return;
|
|
1571
1903
|
const { titles } = details;
|
|
1572
1904
|
const nextTitles = titles.custom != null || titles.ai != null || titles.agent != null
|
|
@@ -1684,18 +2016,23 @@ export class ClaudeCodeProvider {
|
|
|
1684
2016
|
}
|
|
1685
2017
|
}
|
|
1686
2018
|
ensureSession(sessionId, context) {
|
|
1687
|
-
const { pid, workspacePath, isInWorkspace, cwd, initialCwd, planFile, sessionName, model, worktreePath } = context ?? {};
|
|
2019
|
+
const { capabilities, pid, workspacePath, isInWorkspace, cwd, initialCwd, planFile, sessionName, model, worktreePath, visitedWorktreePaths, } = context ?? {};
|
|
1688
2020
|
let index = this._sessions.findIndex(s => s.id === sessionId);
|
|
1689
2021
|
if (index < 0) {
|
|
1690
2022
|
const now = new Date();
|
|
1691
2023
|
// Same worktree-derived fallback as the update branch below: a session created with a
|
|
1692
2024
|
// scratch cwd but a CLI-seated worktree still belongs to that worktree's workspace.
|
|
1693
2025
|
const createdWorkspacePath = workspacePath ?? this.resolveWorkspacePath(worktreePath);
|
|
2026
|
+
// Identity comes from the owning agent's descriptor, never from the provider — Claude's
|
|
2027
|
+
// `providerId` stays the `claudeCode` the descriptor encodes. The fallback covers the
|
|
2028
|
+
// deferred-commit paths (see `SessionContext.capabilities`), which only ever run against a
|
|
2029
|
+
// row that already exists; reaching it would mean a row created outside every event path.
|
|
2030
|
+
const identity = capabilities ?? claudeCodeCapabilities;
|
|
1694
2031
|
index = this._sessions.length;
|
|
1695
2032
|
this._sessions.push({
|
|
1696
2033
|
id: sessionId,
|
|
1697
|
-
providerId:
|
|
1698
|
-
providerName:
|
|
2034
|
+
providerId: identity.providerId,
|
|
2035
|
+
providerName: identity.displayName,
|
|
1699
2036
|
name: sessionName || undefined,
|
|
1700
2037
|
status: 'idle',
|
|
1701
2038
|
phase: getPhaseForStatus('idle'),
|
|
@@ -1710,8 +2047,9 @@ export class ClaudeCodeProvider {
|
|
|
1710
2047
|
isInWorkspace: createdWorkspacePath != null ? true : (isInWorkspace ?? false),
|
|
1711
2048
|
model: model,
|
|
1712
2049
|
worktreePath: worktreePath != null ? normalizePath(worktreePath) : undefined,
|
|
2050
|
+
visitedWorktreePaths: unionVisited(undefined, visitedWorktreePaths, worktreePath),
|
|
1713
2051
|
});
|
|
1714
|
-
Logger.debug(`
|
|
2052
|
+
Logger.debug(`GkAgentProvider.ensureSession: implicitly created ${this.sessionTag(sessionId, sessionName ?? 'unnamed')}`);
|
|
1715
2053
|
if (worktreePath != null) {
|
|
1716
2054
|
this.getBookkeeping(sessionId).cliSeatedWorktree = true;
|
|
1717
2055
|
}
|
|
@@ -1738,6 +2076,7 @@ export class ClaudeCodeProvider {
|
|
|
1738
2076
|
// present, so it seats ahead of the `resolveGitInfo` probe below (which still fills
|
|
1739
2077
|
// `commonPath` and remains the fallback for older CLIs that don't send `cwdTimeline`).
|
|
1740
2078
|
const updatedWorktreePath = worktreePath != null ? normalizePath(worktreePath) : existing.worktreePath;
|
|
2079
|
+
const updatedVisitedWorktreePaths = unionVisited(existing.visitedWorktreePaths, visitedWorktreePaths, updatedWorktreePath);
|
|
1741
2080
|
// Workspace membership follows the ATTRIBUTION, not the raw cwd: a scratch-dir excursion
|
|
1742
2081
|
// (cwd in /tmp, worktree kept) stays in-workspace via its worktree, while a genuine move to
|
|
1743
2082
|
// a repo outside the workspace honestly drops membership when events carry a cwd.
|
|
@@ -1766,7 +2105,8 @@ export class ClaudeCodeProvider {
|
|
|
1766
2105
|
updatedModel !== existing.model ||
|
|
1767
2106
|
updatedCwd !== existing.cwd ||
|
|
1768
2107
|
updatedInitialCwd !== existing.initialCwd ||
|
|
1769
|
-
updatedWorktreePath !== existing.worktreePath
|
|
2108
|
+
updatedWorktreePath !== existing.worktreePath ||
|
|
2109
|
+
updatedVisitedWorktreePaths !== existing.visitedWorktreePaths) {
|
|
1770
2110
|
this._sessions[index] = {
|
|
1771
2111
|
...existing,
|
|
1772
2112
|
name: updatedName,
|
|
@@ -1778,6 +2118,7 @@ export class ClaudeCodeProvider {
|
|
|
1778
2118
|
planFile: updatedPlanFile,
|
|
1779
2119
|
isInWorkspace: updatedIsInWorkspace,
|
|
1780
2120
|
worktreePath: updatedWorktreePath,
|
|
2121
|
+
visitedWorktreePaths: updatedVisitedWorktreePaths,
|
|
1781
2122
|
// A worktree move can cross repos, and the spread would otherwise carry the old repo
|
|
1782
2123
|
// identity forward — which consumers prefer over the new worktree, pinning the card to
|
|
1783
2124
|
// the wrong repo. Drop it; the probe below refills it. Mirrors the poll's ended-record
|
|
@@ -1806,7 +2147,11 @@ export class ClaudeCodeProvider {
|
|
|
1806
2147
|
// call per hook event for an answer we already have. A first seat (no prior worktree) still
|
|
1807
2148
|
// counts as a location change: the move may be out of a non-git dir whose probe latched
|
|
1808
2149
|
// `gitInfoUnresolvable`, and `commonPath` still needs the probe.
|
|
1809
|
-
|
|
2150
|
+
//
|
|
2151
|
+
// A worktree move is a location change on its own, even with the SAME cwd (cwdTimeline
|
|
2152
|
+
// updated, cwd unchanged) — the `commonPath` drop above (`worktreeMoved ? undefined : …`)
|
|
2153
|
+
// needs the probe to refill it, and without `worktreeMoved` here nothing else would fire it.
|
|
2154
|
+
const locationNeedsProbe = worktreeMoved || (cwdMoved && (worktreePath == null || existing.worktreePath == null));
|
|
1810
2155
|
if (locationNeedsProbe) {
|
|
1811
2156
|
this.getBookkeeping(sessionId).gitInfoUnresolvable = false;
|
|
1812
2157
|
}
|
|
@@ -1842,11 +2187,15 @@ export class ClaudeCodeProvider {
|
|
|
1842
2187
|
this._resolveGitInfoInFlight.set(sessionId, cwd);
|
|
1843
2188
|
try {
|
|
1844
2189
|
let info;
|
|
2190
|
+
let failed = false;
|
|
1845
2191
|
try {
|
|
1846
2192
|
info = await resolveGitInfo(cwd);
|
|
1847
2193
|
}
|
|
1848
2194
|
catch {
|
|
1849
|
-
//
|
|
2195
|
+
// A transient git/spawn failure, distinct from a confirmed "not a repo" (`info ==
|
|
2196
|
+
// null` below). Must not latch `gitInfoUnresolvable` — the next hook event retries
|
|
2197
|
+
// via `ensureSession`'s `commonPath == null` check.
|
|
2198
|
+
failed = true;
|
|
1850
2199
|
info = undefined;
|
|
1851
2200
|
}
|
|
1852
2201
|
const index = this._sessions.findIndex(s => s.id === sessionId);
|
|
@@ -1857,6 +2206,8 @@ export class ClaudeCodeProvider {
|
|
|
1857
2206
|
// below re-runs for the newest cwd.
|
|
1858
2207
|
if (this._resolveGitInfoPending.has(sessionId))
|
|
1859
2208
|
return;
|
|
2209
|
+
if (failed)
|
|
2210
|
+
return;
|
|
1860
2211
|
const session = this._sessions[index];
|
|
1861
2212
|
// `workspacePath` stays as the matched workspace folder (or undefined). Repo identity
|
|
1862
2213
|
// flows through `commonPath`, populated here from `info.repoRoot` at the same step as
|
|
@@ -1873,8 +2224,48 @@ export class ClaudeCodeProvider {
|
|
|
1873
2224
|
// followed by a new session start).
|
|
1874
2225
|
if (info == null) {
|
|
1875
2226
|
this.getBookkeeping(sessionId).gitInfoUnresolvable = true;
|
|
1876
|
-
|
|
1877
|
-
|
|
2227
|
+
// The cwd itself isn't a repo, but a CLI-seated worktree (from `cwdTimeline`, see
|
|
2228
|
+
// `worktreeFromEvent`) may still name one — e.g. a scratch cwd like `/tmp/x` whose
|
|
2229
|
+
// session the CLI attributed to a real worktree. Probe the worktree root inline, in
|
|
2230
|
+
// the same in-flight window, so a scratch cwd doesn't leave `commonPath` permanently
|
|
2231
|
+
// unresolved.
|
|
2232
|
+
let worktreeInfo;
|
|
2233
|
+
let liveIndex = index;
|
|
2234
|
+
let liveSession = session;
|
|
2235
|
+
if (session.worktreePath != null && session.worktreePath !== cwd && session.commonPath == null) {
|
|
2236
|
+
try {
|
|
2237
|
+
worktreeInfo = await resolveGitInfo(session.worktreePath);
|
|
2238
|
+
}
|
|
2239
|
+
catch {
|
|
2240
|
+
worktreeInfo = undefined;
|
|
2241
|
+
}
|
|
2242
|
+
// Re-find and re-read after this second await, for both the success and
|
|
2243
|
+
// fall-through writes below — the array can have been reassigned/spliced (e.g.
|
|
2244
|
+
// `pruneDeadSessions`), or the session's own fields updated, while the probe ran.
|
|
2245
|
+
liveIndex = this._sessions.findIndex(s => s.id === sessionId);
|
|
2246
|
+
if (liveIndex < 0)
|
|
2247
|
+
return;
|
|
2248
|
+
if (this._resolveGitInfoPending.has(sessionId))
|
|
2249
|
+
return;
|
|
2250
|
+
liveSession = this._sessions[liveIndex];
|
|
2251
|
+
}
|
|
2252
|
+
if (worktreeInfo != null) {
|
|
2253
|
+
const worktreeFirstResolve = liveSession.initialCommonPath == null;
|
|
2254
|
+
this._sessions[liveIndex] = {
|
|
2255
|
+
...liveSession,
|
|
2256
|
+
cwd: cwd,
|
|
2257
|
+
commonPath: worktreeInfo.repoRoot,
|
|
2258
|
+
initialWorktreePath: worktreeFirstResolve
|
|
2259
|
+
? liveSession.worktreePath
|
|
2260
|
+
: liveSession.initialWorktreePath,
|
|
2261
|
+
initialCommonPath: worktreeFirstResolve ? worktreeInfo.repoRoot : liveSession.initialCommonPath,
|
|
2262
|
+
visitedWorktreePaths: unionVisited(liveSession.visitedWorktreePaths, liveSession.worktreePath),
|
|
2263
|
+
};
|
|
2264
|
+
this._onDidChangeSessions.fire();
|
|
2265
|
+
return;
|
|
2266
|
+
}
|
|
2267
|
+
if (cwd !== liveSession.cwd) {
|
|
2268
|
+
this._sessions[liveIndex] = { ...liveSession, cwd: cwd };
|
|
1878
2269
|
this._onDidChangeSessions.fire();
|
|
1879
2270
|
}
|
|
1880
2271
|
return;
|
|
@@ -1905,6 +2296,7 @@ export class ClaudeCodeProvider {
|
|
|
1905
2296
|
commonPath: info.repoRoot,
|
|
1906
2297
|
initialWorktreePath: updatedInitialWorktreePath,
|
|
1907
2298
|
initialCommonPath: updatedInitialCommonPath,
|
|
2299
|
+
visitedWorktreePaths: unionVisited(session.visitedWorktreePaths, effectiveWorktreePath),
|
|
1908
2300
|
};
|
|
1909
2301
|
this._onDidChangeSessions.fire();
|
|
1910
2302
|
}
|
|
@@ -1921,6 +2313,10 @@ export class ClaudeCodeProvider {
|
|
|
1921
2313
|
}
|
|
1922
2314
|
}
|
|
1923
2315
|
async resolveTranscriptTitles(sessionId, cwd) {
|
|
2316
|
+
// The reader only understands Claude Code's `~/.claude/projects/**` JSONL store, so for any
|
|
2317
|
+
// other agent this would be a guaranteed-miss directory scan on every event and poll.
|
|
2318
|
+
if (!this.supportsTranscripts(sessionId))
|
|
2319
|
+
return;
|
|
1924
2320
|
let titles;
|
|
1925
2321
|
try {
|
|
1926
2322
|
titles = await this._transcriptReader.resolve(sessionId, cwd);
|
|
@@ -1943,25 +2339,22 @@ export class ClaudeCodeProvider {
|
|
|
1943
2339
|
};
|
|
1944
2340
|
this._onDidChangeSessions.fire();
|
|
1945
2341
|
}
|
|
1946
|
-
/** Transitions the session at `index` to the terminal `
|
|
2342
|
+
/** Transitions the session at `index` to the terminal `ended` state in place: keeps the row,
|
|
1947
2343
|
* stamps `endedAt` as its activity time, clears transient state (statusDetail, pendingPermission,
|
|
1948
2344
|
* subagents), and tears down its bookkeeping/timers. Shared by the live `SessionEnd` path and the
|
|
1949
2345
|
* poll backstop for a `SessionEnd` the live path missed. Does not fire change events — the caller
|
|
1950
2346
|
* batches those. */
|
|
1951
|
-
|
|
2347
|
+
endSession(index, endedAt) {
|
|
1952
2348
|
const prev = this._sessions[index];
|
|
1953
2349
|
this._sessions[index] = {
|
|
1954
2350
|
...prev,
|
|
1955
|
-
status: '
|
|
1956
|
-
phase: '
|
|
2351
|
+
status: 'ended',
|
|
2352
|
+
phase: 'ended',
|
|
1957
2353
|
phaseSince: endedAt,
|
|
1958
2354
|
lastActivity: endedAt,
|
|
1959
2355
|
statusDetail: undefined,
|
|
1960
2356
|
pendingPermission: undefined,
|
|
1961
2357
|
subagents: undefined,
|
|
1962
|
-
// A terminal row carries no live working state — drop the file-activity heatmap so
|
|
1963
|
-
// consumers reading it directly don't render a frozen tail for a finished session.
|
|
1964
|
-
fileActivity: undefined,
|
|
1965
2358
|
};
|
|
1966
2359
|
// Terminal teardown, owned here so every caller gets it — the live `SessionEnd` path does
|
|
1967
2360
|
// this before calling us, but the poll's missed-SessionEnd transition doesn't: reject any
|
|
@@ -1970,7 +2363,7 @@ export class ClaudeCodeProvider {
|
|
|
1970
2363
|
// live zombie).
|
|
1971
2364
|
const pending = this._pendingPermissions.get(prev.id);
|
|
1972
2365
|
if (pending != null) {
|
|
1973
|
-
this.rejectAllPending(pending, new Error('Session
|
|
2366
|
+
this.rejectAllPending(pending, new Error('Session ended'));
|
|
1974
2367
|
this._pendingPermissions.delete(prev.id);
|
|
1975
2368
|
}
|
|
1976
2369
|
this.cancelPendingIdleTransition(prev.id);
|
|
@@ -1981,14 +2374,28 @@ export class ClaudeCodeProvider {
|
|
|
1981
2374
|
}
|
|
1982
2375
|
this._sessionBookkeeping.delete(prev.id);
|
|
1983
2376
|
this._transcriptReader.forget(prev.id);
|
|
2377
|
+
this.noteTerminalTransition();
|
|
1984
2378
|
}
|
|
1985
2379
|
pruneDeadSessions() {
|
|
1986
2380
|
const kept = [];
|
|
1987
2381
|
const removedIds = [];
|
|
1988
2382
|
for (const s of this._sessions) {
|
|
1989
|
-
//
|
|
2383
|
+
// Ended sessions are terminal and durable — their PID is usually dead by design, so
|
|
1990
2384
|
// liveness pruning must never drop them (they leave only via poll reconciliation).
|
|
1991
|
-
if (s.status === '
|
|
2385
|
+
if (s.status === 'ended') {
|
|
2386
|
+
kept.push(s);
|
|
2387
|
+
continue;
|
|
2388
|
+
}
|
|
2389
|
+
// An agent that multiplexes concurrent sessions in one process (Codex) reports the SAME
|
|
2390
|
+
// pid for all of them, so the pid answers "is the host process alive", not "is this
|
|
2391
|
+
// session alive" — in both directions. A live shared process would pin every session it
|
|
2392
|
+
// ever hosted, and a dead one would reap live siblings along with the finished session.
|
|
2393
|
+
// Neither is recoverable here, so pid liveness is not evidence for these rows at all:
|
|
2394
|
+
// keep them and let the paths that DO identify a session end them — a `SessionEnd`, or the
|
|
2395
|
+
// poll seeing the CLI's durable `ended` record (which the CLI writes for a stale session
|
|
2396
|
+
// of its own accord). The CLI takes the same position: for `pidSharingClients` a pid-only
|
|
2397
|
+
// match is a guess, so it keeps such an end revivable.
|
|
2398
|
+
if (getAgentCapabilitiesByProviderId(s.providerId)?.sharesPids === true) {
|
|
1992
2399
|
kept.push(s);
|
|
1993
2400
|
continue;
|
|
1994
2401
|
}
|
|
@@ -2024,7 +2431,8 @@ export class ClaudeCodeProvider {
|
|
|
2024
2431
|
this._sessionBookkeeping.delete(id);
|
|
2025
2432
|
this._transcriptReader.forget(id);
|
|
2026
2433
|
}
|
|
2027
|
-
|
|
2434
|
+
this.noteTerminalTransition();
|
|
2435
|
+
Logger.debug(`GkAgentProvider.syncSessions: removed ${removedIds.length} stale session(s): ${removedIds.map(id => id.substring(0, 8)).join(', ')}`);
|
|
2028
2436
|
return true;
|
|
2029
2437
|
}
|
|
2030
2438
|
async syncSessions(options) {
|
|
@@ -2034,13 +2442,13 @@ export class ClaudeCodeProvider {
|
|
|
2034
2442
|
// sessions exist — that's the only local backstop for pruning agents that die without firing
|
|
2035
2443
|
// `SessionEnd`, and it keeps us correct even if hook detection is stale/wrong. The bootstrap
|
|
2036
2444
|
// call in `ensureIpcServer` passes no options, so cold-start discovery always runs.
|
|
2037
|
-
// Deliberately counts ALL tracked sessions,
|
|
2445
|
+
// Deliberately counts ALL tracked sessions, ended included. An ended row is something
|
|
2038
2446
|
// we have to reconcile — it's removed only when the CLI stops listing it (archived from
|
|
2039
2447
|
// another window, or aged out) — and this poll is the sole mechanism that does so. It's also
|
|
2040
2448
|
// what triggers the CLI's own 30-day retention sweep, which runs on `list-sessions` and
|
|
2041
2449
|
// nothing else: no timer, no daemon. Gating it out to save a spawn wouldn't avoid work, it
|
|
2042
2450
|
// would strand the rows on screen AND the records on disk until the window reloads.
|
|
2043
|
-
if (options?.gate && this._sessions.length === 0 && !this.
|
|
2451
|
+
if (options?.gate && this._sessions.length === 0 && !this._hooksInstalled)
|
|
2044
2452
|
return;
|
|
2045
2453
|
// Narrowly: hooks off AND nothing running, i.e. we hold only terminal history. There's no
|
|
2046
2454
|
// dying process to reap and no live state to correct — just rows to reconcile and the
|
|
@@ -2049,14 +2457,14 @@ export class ClaudeCodeProvider {
|
|
|
2049
2457
|
// Hooks ON keeps the full cadence deliberately: there the tick is also the backstop for a
|
|
2050
2458
|
// session whose hook events never reached our IPC server.
|
|
2051
2459
|
if (options?.gate &&
|
|
2052
|
-
!this.
|
|
2053
|
-
!this._sessions.some(s => s.status !== '
|
|
2460
|
+
!this._hooksInstalled &&
|
|
2461
|
+
!this._sessions.some(s => s.status !== 'ended') &&
|
|
2054
2462
|
Date.now() - this._lastSyncAt < idleReconcileIntervalMs) {
|
|
2055
2463
|
return;
|
|
2056
2464
|
}
|
|
2057
2465
|
// Stamped BEFORE the CLI call so the reconciliation below can tell "this poll's snapshot was
|
|
2058
|
-
// taken after we
|
|
2059
|
-
//
|
|
2466
|
+
// taken after we ended the row" from "an in-flight poll that predates it" — see the
|
|
2467
|
+
// ended-removal filter.
|
|
2060
2468
|
const pollStartedAt = Date.now();
|
|
2061
2469
|
this._lastSyncAt = pollStartedAt;
|
|
2062
2470
|
let sessions;
|
|
@@ -2065,7 +2473,7 @@ export class ClaudeCodeProvider {
|
|
|
2065
2473
|
try {
|
|
2066
2474
|
// `--status active,ended` surfaces both live sessions and the CLI's durable ended records
|
|
2067
2475
|
// (excluding `archived`). Newer CLIs default `list-sessions` to active-only, so the flag
|
|
2068
|
-
// is required to see
|
|
2476
|
+
// is required to see ended sessions at all.
|
|
2069
2477
|
output = await this.callbacks.runCLICommand([
|
|
2070
2478
|
'ai',
|
|
2071
2479
|
'hook',
|
|
@@ -2087,7 +2495,11 @@ export class ClaudeCodeProvider {
|
|
|
2087
2495
|
}
|
|
2088
2496
|
sessions = JSON.parse(output);
|
|
2089
2497
|
}
|
|
2090
|
-
catch {
|
|
2498
|
+
catch (ex) {
|
|
2499
|
+
// Expected whenever the CLI is missing or still installing, so not an error — but log it:
|
|
2500
|
+
// every session in the window silently stops reconciling until the next tick, and without
|
|
2501
|
+
// this the symptom ("no agents anywhere") is indistinguishable from having no agents.
|
|
2502
|
+
Logger.debug(`GkAgentProvider.syncSessions: poll skipped — ${ex instanceof Error ? ex.message : String(ex)}`);
|
|
2091
2503
|
if (this.pruneDeadSessions()) {
|
|
2092
2504
|
this._onDidChangeSessions.fire();
|
|
2093
2505
|
}
|
|
@@ -2096,26 +2508,60 @@ export class ClaudeCodeProvider {
|
|
|
2096
2508
|
// Snapshot what the live IPC path had already tracked, so we can detect drift between it and
|
|
2097
2509
|
// what the poll returns. Ideally they match exactly — any difference means the live push path
|
|
2098
2510
|
// missed an add (poll discovers it) or a teardown (poll no longer reports a session we track).
|
|
2099
|
-
// Drift is measured over *live* sessions only;
|
|
2511
|
+
// Drift is measured over *live* sessions only; ended sessions are durable history, so their
|
|
2100
2512
|
// absence from `polledAlive` is expected and must not skew the discrepancy signal.
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
//
|
|
2104
|
-
//
|
|
2513
|
+
//
|
|
2514
|
+
// Both maps carry the owning agent's `providerId` per session so the report below can be split
|
|
2515
|
+
// per agent: the two paths' reliability differs by agent (one may never reach the live path at
|
|
2516
|
+
// all), and one pooled number would average that away into noise.
|
|
2517
|
+
const trackedLiveBefore = new Map(this._sessions.filter(s => s.status !== 'ended').map(s => [s.id, s.providerId]));
|
|
2518
|
+
const polledAlive = new Map();
|
|
2519
|
+
// Every id the poll returned (live *and* ended). Drives ended-session reconciliation:
|
|
2520
|
+
// a tracked ended session absent from this set was archived/purged by the CLI.
|
|
2105
2521
|
const polledIds = new Set();
|
|
2106
2522
|
// Accumulates ids already tracked or added during this poll, so the membership check is O(1)
|
|
2107
2523
|
// and duplicate ids within a single response are only added once.
|
|
2108
2524
|
const known = new Set(this._sessions.map(s => s.id));
|
|
2525
|
+
// The CLI's durable store never revives a resumed session — once it moves a record to its
|
|
2526
|
+
// `ended` store, nothing moves it back, so a record reporting `ended` isn't proof the session
|
|
2527
|
+
// is over. The listing also repairs the two active-record cases that have no fresher hook state:
|
|
2528
|
+
// first discovery and synthesized, unresolvable permission asks. Fetched once per poll (not per
|
|
2529
|
+
// row); the host caches the underlying `claude agents --json` invocation behind a short TTL, so
|
|
2530
|
+
// this doesn't spawn a process per session.
|
|
2531
|
+
const liveSessions = await this.callbacks.getLiveAgentSessions?.();
|
|
2109
2532
|
let changed = false;
|
|
2110
|
-
|
|
2533
|
+
const discoveredByAgent = new Map();
|
|
2534
|
+
const noteDiscovered = (providerId) => {
|
|
2535
|
+
discoveredByAgent.set(providerId, (discoveredByAgent.get(providerId) ?? 0) + 1);
|
|
2536
|
+
};
|
|
2537
|
+
/** Ids kept alive against a stale `ended` record. Logged below — this path has no other
|
|
2538
|
+
* observability, and "the agent isn't showing up" is otherwise indistinguishable from
|
|
2539
|
+
* "the listing never ran". */
|
|
2540
|
+
const keptAlive = [];
|
|
2111
2541
|
for (const data of sessions) {
|
|
2112
2542
|
if (!data.sessionId)
|
|
2113
2543
|
continue;
|
|
2114
|
-
//
|
|
2115
|
-
// `polledAlive`: an id in either would skew
|
|
2116
|
-
// `onSyncDiscrepancy` drift signal below.
|
|
2117
|
-
|
|
2544
|
+
// A record from a client we hold no descriptor for (the CLI supports more than this table
|
|
2545
|
+
// does). Must stay ahead of `polledIds`/`polledAlive`: an id in either would skew ended-row
|
|
2546
|
+
// reconciliation and the `onSyncDiscrepancy` drift signal below. Absent = `claude-code`,
|
|
2547
|
+
// matching the live path — older CLIs don't stamp it.
|
|
2548
|
+
const capabilities = getAgentCapabilities(data.providerId ?? 'claude-code');
|
|
2549
|
+
if (capabilities == null)
|
|
2118
2550
|
continue;
|
|
2551
|
+
// The record's `event` is the agent's native name. Canonicalize it so `deriveStatusFromEvent`
|
|
2552
|
+
// sees a vocabulary it knows — an unmapped native name falls through to `data.event`, which
|
|
2553
|
+
// that function's `default` already resolves to `idle`. Deliberately NOT a skip: unlike the
|
|
2554
|
+
// live path, nothing here switches exhaustively on the name, and dropping the record would
|
|
2555
|
+
// hide a real session whose last event simply has no canonical analogue.
|
|
2556
|
+
//
|
|
2557
|
+
// `data.hookInput` must be passed: an agent whose event name alone can't determine the
|
|
2558
|
+
// canonical event (OpenCode's `session.status`) is resolved from the payload, so withholding
|
|
2559
|
+
// it makes those names permanently unresolvable on this path — and the poll is the only way
|
|
2560
|
+
// a pre-existing session enters after a window reload.
|
|
2561
|
+
const canonicalEvent = resolveCanonicalHookEvent(capabilities, data.event, data.hookInput) ?? data.event;
|
|
2562
|
+
// Same translation for the record's tool name — `classifyPermissionKind` and the display
|
|
2563
|
+
// detail expect canonical names.
|
|
2564
|
+
const polledToolName = data.toolName != null ? resolveCanonicalToolName(capabilities, data.toolName) : undefined;
|
|
2119
2565
|
const effectiveStatus = data.status ?? 'active';
|
|
2120
2566
|
// Archived records are excluded by the `active,ended` query; guard anyway.
|
|
2121
2567
|
if (effectiveStatus === 'archived')
|
|
@@ -2123,39 +2569,110 @@ export class ClaudeCodeProvider {
|
|
|
2123
2569
|
if (effectiveStatus === 'ended') {
|
|
2124
2570
|
polledIds.add(data.sessionId);
|
|
2125
2571
|
const endedDate = new Date(data.endedAt ?? data.updatedAt);
|
|
2572
|
+
const override = liveOverrideFor(liveSessions, data.sessionId);
|
|
2573
|
+
const liveCwd = override?.live.cwd;
|
|
2574
|
+
const currentCwd = liveCwd ?? data.cwd;
|
|
2575
|
+
const liveCwdMoved = liveCwd != null && liveCwd !== data.cwd;
|
|
2576
|
+
const recordWorktreeRoot = worktreeRootFromData(data);
|
|
2577
|
+
const currentWorktreeRoot = liveCwdMoved ? undefined : recordWorktreeRoot;
|
|
2126
2578
|
const existingIndex = this._sessions.findIndex(s => s.id === data.sessionId);
|
|
2127
2579
|
if (existingIndex >= 0) {
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2580
|
+
const bookkeeping = this.getBookkeeping(data.sessionId);
|
|
2581
|
+
if (liveCwdMoved) {
|
|
2582
|
+
bookkeeping.supersededRecordCwd = data.cwd;
|
|
2583
|
+
}
|
|
2584
|
+
else if (bookkeeping.supersededRecordCwd != null &&
|
|
2585
|
+
bookkeeping.supersededRecordCwd !== data.cwd) {
|
|
2586
|
+
// The CLI rewrote the record for the resumed run — it describes the session again.
|
|
2587
|
+
bookkeeping.supersededRecordCwd = undefined;
|
|
2588
|
+
}
|
|
2589
|
+
// The record still names the cwd a resume superseded, and this poll no longer lists the
|
|
2590
|
+
// session live: the resumed process exited (or died) before the CLI rewrote the record,
|
|
2591
|
+
// so neither its cwd nor its `endedAt` describe where or when the session last ran.
|
|
2592
|
+
const recordLocationStale = override == null && bookkeeping.supersededRecordCwd === data.cwd;
|
|
2593
|
+
if (override != null) {
|
|
2594
|
+
const { live: liveSession, status: liveStatus } = override;
|
|
2595
|
+
// `claude agents --json` still lists this exact session id as live — the store's
|
|
2596
|
+
// `ended` record is stale. Keep the row alive instead of ending it, and prefer the
|
|
2597
|
+
// listing's pid — the record's own may already be dead, or reused. Background sessions
|
|
2598
|
+
// carry no pid at all, so fall back to whatever pid the row already has.
|
|
2599
|
+
//
|
|
2600
|
+
// The listing establishes LIVENESS, not status. It's a poll SAMPLE of a value that flips
|
|
2601
|
+
// many times per turn, so adopting it every poll stamps a row back to `idle` moments after
|
|
2602
|
+
// a hook event moved it to `tool_use`/`thinking` — the row then reads idle almost always
|
|
2603
|
+
// and working almost never. Hook events are strictly fresher, so the sample is only taken
|
|
2604
|
+
// when the row has nothing better: it was ended, and this poll is what revives it.
|
|
2605
|
+
const trackedSession = this._sessions[existingIndex];
|
|
2606
|
+
// The listing's pid, or none. Never fall back to the pid we already hold: the listing
|
|
2607
|
+
// omits one only for background sessions, which have no process of their own, and the
|
|
2608
|
+
// pid on the row is the dead one whose exit produced the `ended` record. Carrying it
|
|
2609
|
+
// onto a live row makes `pruneDeadSessions` reap the row later in this same poll.
|
|
2610
|
+
const nextPid = liveSession.pid;
|
|
2611
|
+
const revived = trackedSession.status === 'ended';
|
|
2612
|
+
const nextStatus = revived ? liveStatus : trackedSession.status;
|
|
2613
|
+
if (trackedSession.status !== nextStatus ||
|
|
2614
|
+
trackedSession.pid !== nextPid ||
|
|
2615
|
+
trackedSession.endReason != null ||
|
|
2616
|
+
trackedSession.endedAt != null) {
|
|
2617
|
+
this._sessions[existingIndex] = {
|
|
2618
|
+
...trackedSession,
|
|
2619
|
+
status: nextStatus,
|
|
2620
|
+
phase: getPhaseForStatus(nextStatus),
|
|
2621
|
+
// Only re-stamp the phase/activity clocks on the revive itself; otherwise a poll
|
|
2622
|
+
// keeps resetting them under a row whose status it didn't change.
|
|
2623
|
+
phaseSince: revived ? new Date(pollStartedAt) : trackedSession.phaseSince,
|
|
2624
|
+
lastActivity: revived ? new Date(pollStartedAt) : trackedSession.lastActivity,
|
|
2625
|
+
pid: nextPid,
|
|
2626
|
+
statusDetail: revived ? undefined : trackedSession.statusDetail,
|
|
2627
|
+
endReason: undefined,
|
|
2628
|
+
endedAt: undefined,
|
|
2629
|
+
};
|
|
2630
|
+
changed = true;
|
|
2631
|
+
}
|
|
2632
|
+
keptAlive.push(`${data.sessionId.substring(0, 8)}:${nextStatus}`);
|
|
2633
|
+
polledAlive.set(data.sessionId, capabilities.providerId);
|
|
2634
|
+
}
|
|
2635
|
+
else if (this._sessions[existingIndex].status !== 'ended') {
|
|
2636
|
+
// Already tracked, and Claude no longer lists it as live. If the live path missed the
|
|
2637
|
+
// SessionEnd (e.g. `/clear` kept the PID alive under a new id), transition it to
|
|
2638
|
+
// ended now — otherwise a live-status row would linger as a zombie, since
|
|
2639
|
+
// pruneDeadSessions can't reap a live PID.
|
|
2640
|
+
// The record's `endedAt` can predate activity this window already observed — a
|
|
2641
|
+
// superseded record after a cwd move, or a same-cwd resume the CLI hasn't rewritten
|
|
2642
|
+
// the record for yet — so never move the row's clock backward.
|
|
2643
|
+
const { lastActivity } = this._sessions[existingIndex];
|
|
2644
|
+
this.endSession(existingIndex, lastActivity.getTime() > endedDate.getTime() ? lastActivity : endedDate);
|
|
2133
2645
|
changed = true;
|
|
2134
2646
|
}
|
|
2135
|
-
// Re-seat the row on
|
|
2136
|
-
// terminal session,
|
|
2137
|
-
//
|
|
2138
|
-
//
|
|
2139
|
-
//
|
|
2647
|
+
// Re-seat the row on its current location. The durable record is authoritative for a
|
|
2648
|
+
// terminal session, but an independent Claude listing can prove that record stale and
|
|
2649
|
+
// supply the resumed process's current cwd. Keep the prior worktree attribution until
|
|
2650
|
+
// the live cwd's git probe confirms a replacement — a resumed session can legitimately
|
|
2651
|
+
// sit in scratch space — but never let a later poll move it back to the ended record's
|
|
2652
|
+
// directory. A record already known superseded stays ignored until the CLI rewrites it,
|
|
2653
|
+
// so the row survives the resumed process exiting before that write lands. Prompts get a
|
|
2654
|
+
// hole-fill from the record below when nothing has been set yet; titles keep their own
|
|
2655
|
+
// on-demand transcript resolution, which holds richer values than the record does.
|
|
2140
2656
|
const existing = this._sessions[existingIndex];
|
|
2141
|
-
const
|
|
2142
|
-
const cwdChanged = Boolean(data.cwd) && data.cwd !== existing.cwd;
|
|
2657
|
+
const cwdChanged = !recordLocationStale && currentCwd !== existing.cwd;
|
|
2143
2658
|
// Pre-`cwdTimeline` records carry no worktree data. If the cwd moved, whatever we hold
|
|
2144
2659
|
// is no longer trustworthy — it may point into a different repo entirely — so drop it
|
|
2145
2660
|
// rather than leave the card filed under the wrong branch, and re-resolve below. A cwd
|
|
2146
|
-
//
|
|
2147
|
-
//
|
|
2148
|
-
const nextWorktreePath =
|
|
2149
|
-
?
|
|
2150
|
-
:
|
|
2151
|
-
?
|
|
2152
|
-
:
|
|
2661
|
+
// supplied by the live listing is different: preserve the old attribution as a fallback
|
|
2662
|
+
// until the current cwd's probe succeeds.
|
|
2663
|
+
const nextWorktreePath = recordLocationStale
|
|
2664
|
+
? existing.worktreePath
|
|
2665
|
+
: currentWorktreeRoot != null
|
|
2666
|
+
? normalizePath(currentWorktreeRoot)
|
|
2667
|
+
: cwdChanged && !liveCwdMoved
|
|
2668
|
+
? undefined
|
|
2669
|
+
: existing.worktreePath;
|
|
2153
2670
|
const worktreeMoved = nextWorktreePath !== existing.worktreePath;
|
|
2154
2671
|
if (cwdChanged || worktreeMoved) {
|
|
2155
|
-
const workspacePath = this.resolveWorkspacePath(
|
|
2672
|
+
const workspacePath = this.resolveWorkspacePath(currentCwd);
|
|
2156
2673
|
this._sessions[existingIndex] = {
|
|
2157
2674
|
...existing,
|
|
2158
|
-
cwd:
|
|
2675
|
+
cwd: currentCwd,
|
|
2159
2676
|
initialCwd: existing.initialCwd ?? data.initialCwd ?? data.cwd,
|
|
2160
2677
|
worktreePath: nextWorktreePath,
|
|
2161
2678
|
workspacePath: workspacePath,
|
|
@@ -2165,35 +2682,108 @@ export class ClaudeCodeProvider {
|
|
|
2165
2682
|
// worktree metadata, pinning the card to the wrong repo. Drop it and let the
|
|
2166
2683
|
// host backfill from the new worktree.
|
|
2167
2684
|
commonPath: worktreeMoved ? undefined : existing.commonPath,
|
|
2685
|
+
visitedWorktreePaths: unionVisited(existing.visitedWorktreePaths, visitedFromTimeline(data.cwdTimeline), data.worktrees, nextWorktreePath),
|
|
2168
2686
|
};
|
|
2169
2687
|
changed = true;
|
|
2170
|
-
// Nothing in the record to re-seat it with (older CLI),
|
|
2171
|
-
//
|
|
2172
|
-
|
|
2173
|
-
|
|
2688
|
+
// Nothing in the record to re-seat it with (older CLI), or the record names a
|
|
2689
|
+
// NEW worktree the drop above just cleared `commonPath` for — either way, resolve
|
|
2690
|
+
// the cwd once rather than leaving the row location-less until some later event
|
|
2691
|
+
// happens to change `cwd`/`worktreePath` again (a same-worktree poll afterward
|
|
2692
|
+
// skips this branch entirely, since `worktreeMoved` goes false).
|
|
2693
|
+
if (liveCwdMoved || nextWorktreePath == null || worktreeMoved) {
|
|
2694
|
+
this.getBookkeeping(data.sessionId).cliSeatedWorktree = currentWorktreeRoot != null;
|
|
2695
|
+
void this.resolveGitInfo(data.sessionId, currentCwd);
|
|
2174
2696
|
}
|
|
2175
2697
|
}
|
|
2176
|
-
|
|
2698
|
+
// Prompts backfill-only: the record's prompt/firstPrompt sanitized the same way the
|
|
2699
|
+
// discovery path does. Never overwrites a value the hook path or a transcript resolve
|
|
2700
|
+
// already set.
|
|
2701
|
+
const currentSession = this._sessions[existingIndex];
|
|
2702
|
+
const nextLastPrompt = currentSession.lastPrompt ?? prepareStoredPrompt(data.prompt ?? undefined);
|
|
2703
|
+
const nextFirstPrompt = currentSession.firstPrompt ?? prepareStoredPrompt(data.firstPrompt ?? undefined);
|
|
2704
|
+
if (nextLastPrompt !== currentSession.lastPrompt ||
|
|
2705
|
+
nextFirstPrompt !== currentSession.firstPrompt) {
|
|
2706
|
+
this._sessions[existingIndex] = {
|
|
2707
|
+
...currentSession,
|
|
2708
|
+
lastPrompt: nextLastPrompt,
|
|
2709
|
+
firstPrompt: nextFirstPrompt,
|
|
2710
|
+
};
|
|
2711
|
+
changed = true;
|
|
2712
|
+
}
|
|
2713
|
+
const nextBookkeeping = this.getBookkeeping(data.sessionId);
|
|
2714
|
+
nextBookkeeping.polledAtLeastOnce = true;
|
|
2715
|
+
// `endSession` above drops the bookkeeping entry, but the latch has to outlive that
|
|
2716
|
+
// transition — otherwise the next poll finds none and re-seats the row onto the very
|
|
2717
|
+
// record this one already knew was superseded.
|
|
2718
|
+
nextBookkeeping.supersededRecordCwd = bookkeeping.supersededRecordCwd;
|
|
2177
2719
|
continue;
|
|
2178
2720
|
}
|
|
2179
2721
|
known.add(data.sessionId);
|
|
2180
|
-
const workspacePath = this.resolveWorkspacePath(
|
|
2181
|
-
|
|
2722
|
+
const workspacePath = this.resolveWorkspacePath(currentCwd);
|
|
2723
|
+
if (override != null) {
|
|
2724
|
+
const { live: liveSession, status: liveStatus } = override;
|
|
2725
|
+
// This window is learning about the session for the first time via its `ended`
|
|
2726
|
+
// record, but `claude agents --json` still lists it as live — same override as the
|
|
2727
|
+
// tracked branch above, just pushing a fresh row instead of updating one in place. The
|
|
2728
|
+
// listing's cwd is current; the ended record's worktree remains only as a fallback until
|
|
2729
|
+
// the live cwd's git probe resolves it.
|
|
2730
|
+
this._sessions.push({
|
|
2731
|
+
id: data.sessionId,
|
|
2732
|
+
providerId: capabilities.providerId,
|
|
2733
|
+
providerName: capabilities.displayName,
|
|
2734
|
+
name: data.sessionName || undefined,
|
|
2735
|
+
status: liveStatus,
|
|
2736
|
+
phase: getPhaseForStatus(liveStatus),
|
|
2737
|
+
phaseSince: new Date(pollStartedAt),
|
|
2738
|
+
// Listing's pid or none — never the record's, which is the dead process whose exit
|
|
2739
|
+
// wrote the `ended` record (see the tracked branch above).
|
|
2740
|
+
pid: liveSession.pid,
|
|
2741
|
+
lastActivity: new Date(pollStartedAt),
|
|
2742
|
+
isSubagent: false,
|
|
2743
|
+
workspacePath: workspacePath,
|
|
2744
|
+
worktreePath: recordWorktreeRoot != null ? normalizePath(recordWorktreeRoot) : undefined,
|
|
2745
|
+
visitedWorktreePaths: unionVisited(undefined, visitedFromTimeline(data.cwdTimeline), data.worktrees, recordWorktreeRoot != null ? normalizePath(recordWorktreeRoot) : undefined),
|
|
2746
|
+
cwd: currentCwd,
|
|
2747
|
+
initialCwd: data.initialCwd ?? data.cwd,
|
|
2748
|
+
planFile: data.planFile ?? undefined,
|
|
2749
|
+
isInWorkspace: workspacePath != null,
|
|
2750
|
+
lastPrompt: prepareStoredPrompt(data.prompt ?? undefined),
|
|
2751
|
+
firstPrompt: prepareStoredPrompt(data.firstPrompt ?? undefined),
|
|
2752
|
+
model: data.model ?? undefined,
|
|
2753
|
+
});
|
|
2754
|
+
this.getBookkeeping(data.sessionId).polledAtLeastOnce = true;
|
|
2755
|
+
this.getBookkeeping(data.sessionId).cliSeatedWorktree = currentWorktreeRoot != null;
|
|
2756
|
+
if (liveCwdMoved) {
|
|
2757
|
+
// Latch the cwd this listing superseded, so a later poll that no longer finds the
|
|
2758
|
+
// session live doesn't re-seat the row back onto the not-yet-rewritten record.
|
|
2759
|
+
this.getBookkeeping(data.sessionId).supersededRecordCwd = data.cwd;
|
|
2760
|
+
}
|
|
2761
|
+
changed = true;
|
|
2762
|
+
noteDiscovered(capabilities.providerId);
|
|
2763
|
+
keptAlive.push(`${data.sessionId.substring(0, 8)}:${liveStatus}`);
|
|
2764
|
+
polledAlive.set(data.sessionId, capabilities.providerId);
|
|
2765
|
+
if (liveCwdMoved || recordWorktreeRoot == null) {
|
|
2766
|
+
void this.resolveGitInfo(data.sessionId, currentCwd);
|
|
2767
|
+
}
|
|
2768
|
+
void this.resolveTranscriptTitles(data.sessionId, currentCwd);
|
|
2769
|
+
continue;
|
|
2770
|
+
}
|
|
2182
2771
|
this._sessions.push({
|
|
2183
2772
|
id: data.sessionId,
|
|
2184
|
-
providerId:
|
|
2185
|
-
providerName:
|
|
2773
|
+
providerId: capabilities.providerId,
|
|
2774
|
+
providerName: capabilities.displayName,
|
|
2186
2775
|
name: data.sessionName || undefined,
|
|
2187
|
-
status: '
|
|
2188
|
-
phase: '
|
|
2776
|
+
status: 'ended',
|
|
2777
|
+
phase: 'ended',
|
|
2189
2778
|
phaseSince: endedDate,
|
|
2190
2779
|
pid: data.pid,
|
|
2191
2780
|
lastActivity: endedDate,
|
|
2192
2781
|
isSubagent: false,
|
|
2193
2782
|
workspacePath: workspacePath,
|
|
2194
|
-
// Read straight from the CLI's durable record so
|
|
2783
|
+
// Read straight from the CLI's durable record so ended sessions attach to branch
|
|
2195
2784
|
// cards / WIP rows / the resume picker at any age, no git probe.
|
|
2196
|
-
worktreePath:
|
|
2785
|
+
worktreePath: recordWorktreeRoot != null ? normalizePath(recordWorktreeRoot) : undefined,
|
|
2786
|
+
visitedWorktreePaths: unionVisited(undefined, visitedFromTimeline(data.cwdTimeline), data.worktrees, recordWorktreeRoot != null ? normalizePath(recordWorktreeRoot) : undefined),
|
|
2197
2787
|
cwd: data.cwd,
|
|
2198
2788
|
initialCwd: data.initialCwd ?? data.cwd,
|
|
2199
2789
|
planFile: data.planFile ?? undefined,
|
|
@@ -2208,13 +2798,14 @@ export class ClaudeCodeProvider {
|
|
|
2208
2798
|
});
|
|
2209
2799
|
this.getBookkeeping(data.sessionId).polledAtLeastOnce = true;
|
|
2210
2800
|
changed = true;
|
|
2211
|
-
|
|
2801
|
+
this.noteTerminalTransition();
|
|
2802
|
+
// Titles/prompts still resolve lazily via resolveEndedSessionDetails (a 30-day
|
|
2212
2803
|
// cold-start must not fan out transcript reads). worktreePath comes from the record
|
|
2213
2804
|
// above; only fall back to an eager git probe when the record predates that CLI field,
|
|
2214
2805
|
// and only for the recent (WIP-window) tail so the fallback stays bounded.
|
|
2215
|
-
if (
|
|
2806
|
+
if (recordWorktreeRoot == null &&
|
|
2216
2807
|
data.cwd &&
|
|
2217
|
-
Date.now() - endedDate.getTime() <
|
|
2808
|
+
Date.now() - endedDate.getTime() < recentEndedGitResolveThresholdMs) {
|
|
2218
2809
|
void this.resolveGitInfo(data.sessionId, data.cwd);
|
|
2219
2810
|
}
|
|
2220
2811
|
continue;
|
|
@@ -2222,7 +2813,7 @@ export class ClaudeCodeProvider {
|
|
|
2222
2813
|
// Active / legacy (absent-status) path — only genuinely-live processes are discoverable.
|
|
2223
2814
|
if (!data.pid || !isProcessAlive(data.pid))
|
|
2224
2815
|
continue;
|
|
2225
|
-
polledAlive.
|
|
2816
|
+
polledAlive.set(data.sessionId, capabilities.providerId);
|
|
2226
2817
|
polledIds.add(data.sessionId);
|
|
2227
2818
|
if (known.has(data.sessionId)) {
|
|
2228
2819
|
// Already tracked.
|
|
@@ -2232,30 +2823,44 @@ export class ClaudeCodeProvider {
|
|
|
2232
2823
|
// The status the CLI's last event implies — a resumed session may already be mid-work
|
|
2233
2824
|
// (`PreToolUse` → tool_use, `UserPromptSubmit` → thinking), and hardcoding `idle` would
|
|
2234
2825
|
// mislabel active work and risk a concurrent-write resume.
|
|
2235
|
-
const polledStatus = deriveStatusFromEvent(
|
|
2236
|
-
|
|
2237
|
-
|
|
2826
|
+
const polledStatus = deriveStatusFromEvent(canonicalEvent);
|
|
2827
|
+
const isSynthesizedAsk = existing.status === 'permission_requested' && existing.pendingPermission?.resolvable === false;
|
|
2828
|
+
const liveEntry = isSynthesizedAsk ? liveSessions?.get(data.sessionId) : undefined;
|
|
2829
|
+
const liveStatus = liveEntry != null ? statusFromLiveSession(liveEntry) : undefined;
|
|
2830
|
+
if (isSynthesizedAsk && liveEntry != null && liveStatus == null) {
|
|
2831
|
+
// A terminal listing entry proves this active record is stale, even if its last event
|
|
2832
|
+
// advanced after the ask. It carries no authoritative terminal timestamp/details, so
|
|
2833
|
+
// remove the false live row and let the durable ended record restore it later. Run the
|
|
2834
|
+
// standard terminal teardown first so no timers or caches survive the row.
|
|
2835
|
+
this.endSession(existingIndex, new Date(pollStartedAt));
|
|
2836
|
+
this._sessions.splice(existingIndex, 1);
|
|
2837
|
+
polledAlive.delete(data.sessionId);
|
|
2838
|
+
changed = true;
|
|
2839
|
+
continue;
|
|
2840
|
+
}
|
|
2841
|
+
if (existing.status === 'ended') {
|
|
2842
|
+
// We hold it as `ended` but the CLI now reports it as a live process (a
|
|
2238
2843
|
// resume the live SessionStart path missed) — revive it, the symmetric backstop
|
|
2239
|
-
// to the ended-branch's live→
|
|
2844
|
+
// to the ended-branch's live→ended transition above. Leave genuinely-live
|
|
2240
2845
|
// rows to the IPC path.
|
|
2241
2846
|
const revivedAt = new Date(data.updatedAt);
|
|
2242
|
-
// Only when the record is at least as new as the
|
|
2847
|
+
// Only when the record is at least as new as the end. `SessionEnd` fires
|
|
2243
2848
|
// while the process is still winding down, so a poll whose CLI call started
|
|
2244
2849
|
// BEFORE it returns a snapshot that still says "active" with a live pid — reviving
|
|
2245
2850
|
// off that stale record would resurrect the row, and `pruneDeadSessions` would
|
|
2246
|
-
// then delete it outright once the process exits, losing the
|
|
2851
|
+
// then delete it outright once the process exits, losing the ended row.
|
|
2247
2852
|
if (revivedAt.getTime() < existing.phaseSince.getTime())
|
|
2248
2853
|
continue;
|
|
2249
2854
|
// The poll has no blocking hook entry for this ask (it's discovered off the CLI's
|
|
2250
2855
|
// durable record, not this window's IPC path) — synthesize an unresolvable one so
|
|
2251
2856
|
// the row shows what it's waiting on instead of an empty "Needs input" card.
|
|
2252
2857
|
const revivedPendingPermission = polledStatus === 'permission_requested'
|
|
2253
|
-
? synthesizeUnresolvableAsk(
|
|
2858
|
+
? synthesizeUnresolvableAsk(polledToolName, data.planFile ?? undefined)
|
|
2254
2859
|
: undefined;
|
|
2255
2860
|
// Seat worktreePath from the record, mirroring the ended-record re-seat above — the
|
|
2256
|
-
// existing row is otherwise stuck with whatever (or nothing) it had while
|
|
2861
|
+
// existing row is otherwise stuck with whatever (or nothing) it had while ended.
|
|
2257
2862
|
const revivedWorktreeRoot = worktreeRootFromData(data);
|
|
2258
|
-
// No record worktree data (older CLI): KEEP the
|
|
2863
|
+
// No record worktree data (older CLI): KEEP the ended row's attribution. A
|
|
2259
2864
|
// resume whose cwd is scratch space (e.g. /tmp) still belongs to its worktree —
|
|
2260
2865
|
// re-rooting happens only when git affirmatively resolves a new one (the probe
|
|
2261
2866
|
// below, on success). Attribution persists until something better is known.
|
|
@@ -2280,10 +2885,11 @@ export class ClaudeCodeProvider {
|
|
|
2280
2885
|
workspacePath: revivedWorkspacePath ?? (data.cwd == null ? existing.workspacePath : undefined),
|
|
2281
2886
|
isInWorkspace: data.cwd == null ? existing.isInWorkspace : revivedWorkspacePath != null,
|
|
2282
2887
|
worktreePath: revivedWorktreePath,
|
|
2888
|
+
visitedWorktreePaths: unionVisited(existing.visitedWorktreePaths, visitedFromTimeline(data.cwdTimeline), data.worktrees, revivedWorktreePath),
|
|
2283
2889
|
// A worktree move can cross repos — drop the stale repo identity and let the
|
|
2284
2890
|
// probe below refill it.
|
|
2285
2891
|
commonPath: revivedWorktreePath !== existing.worktreePath ? undefined : existing.commonPath,
|
|
2286
|
-
// No longer terminal: leaving these set makes the row read as
|
|
2892
|
+
// No longer terminal: leaving these set makes the row read as ended to
|
|
2287
2893
|
// consumers that key off them.
|
|
2288
2894
|
endReason: undefined,
|
|
2289
2895
|
endedAt: undefined,
|
|
@@ -2292,7 +2898,10 @@ export class ClaudeCodeProvider {
|
|
|
2292
2898
|
// The record's worktree is the CLI's own resolution; without one the probe owns
|
|
2293
2899
|
// attribution again.
|
|
2294
2900
|
this.getBookkeeping(data.sessionId).cliSeatedWorktree = revivedWorktreeRoot != null;
|
|
2295
|
-
|
|
2901
|
+
// A revive whose worktree moved but whose cwd happens to equal the prior one still
|
|
2902
|
+
// dropped `commonPath` above and needs the probe to refill it.
|
|
2903
|
+
if (data.cwd != null &&
|
|
2904
|
+
(data.cwd !== existing.cwd || revivedWorktreePath !== existing.worktreePath)) {
|
|
2296
2905
|
void this.resolveGitInfo(data.sessionId, data.cwd);
|
|
2297
2906
|
}
|
|
2298
2907
|
changed = true;
|
|
@@ -2311,12 +2920,31 @@ export class ClaudeCodeProvider {
|
|
|
2311
2920
|
// The detail described the ask's tool; carry the record's current tool for
|
|
2312
2921
|
// tool_use, else clear — keeping it would show the answered ask's tool as
|
|
2313
2922
|
// the active one.
|
|
2314
|
-
statusDetail: polledStatus === 'tool_use' ?
|
|
2923
|
+
statusDetail: polledStatus === 'tool_use' ? polledToolName : undefined,
|
|
2315
2924
|
pendingPermission: undefined,
|
|
2316
2925
|
lastActivity: new Date(data.updatedAt),
|
|
2317
2926
|
};
|
|
2318
2927
|
changed = true;
|
|
2319
2928
|
}
|
|
2929
|
+
else if (isSynthesizedAsk) {
|
|
2930
|
+
// The record never moved past the ask it froze on — the ask was resolved while no
|
|
2931
|
+
// window was listening, so nothing ever advanced it — but Claude's own listing
|
|
2932
|
+
// says the session has moved on. Correct the row so it heals on the next poll
|
|
2933
|
+
// instead of waiting for a reload or fresh hook events. Only synthesized asks
|
|
2934
|
+
// are repairable here: a routable one is bookkeeping-owned and cleared by its
|
|
2935
|
+
// own resolution paths, which the listing must never preempt.
|
|
2936
|
+
if (liveStatus != null && liveStatus !== 'permission_requested') {
|
|
2937
|
+
this._sessions[existingIndex] = {
|
|
2938
|
+
...existing,
|
|
2939
|
+
status: liveStatus,
|
|
2940
|
+
phase: getPhaseForStatus(liveStatus),
|
|
2941
|
+
phaseSince: new Date(pollStartedAt),
|
|
2942
|
+
pendingPermission: undefined,
|
|
2943
|
+
statusDetail: undefined,
|
|
2944
|
+
};
|
|
2945
|
+
changed = true;
|
|
2946
|
+
}
|
|
2947
|
+
}
|
|
2320
2948
|
else if (existing.transcriptTitles?.ai == null && existing.transcriptTitles?.custom == null) {
|
|
2321
2949
|
// A live session we discovered via the poll (another worktree/window owns its
|
|
2322
2950
|
// hook flow) gets none of the local IPC re-resolves (SessionStart, idle). So one
|
|
@@ -2331,21 +2959,40 @@ export class ClaudeCodeProvider {
|
|
|
2331
2959
|
known.add(data.sessionId);
|
|
2332
2960
|
const workspacePath = this.resolveWorkspacePath(data.cwd);
|
|
2333
2961
|
const isInWorkspace = workspacePath != null;
|
|
2334
|
-
const status = deriveStatusFromEvent(data.event);
|
|
2335
|
-
const phase = getPhaseForStatus(status);
|
|
2336
2962
|
const activityDate = new Date(data.updatedAt);
|
|
2963
|
+
let status = deriveStatusFromEvent(canonicalEvent);
|
|
2964
|
+
let phaseSince = activityDate;
|
|
2965
|
+
// The record's last event can be arbitrarily stale — an ask answered while no window
|
|
2966
|
+
// was listening (e.g. mid-reload), or a session stopped right there, never advances it,
|
|
2967
|
+
// so every window start would re-seed the frozen state. Claude's own listing is
|
|
2968
|
+
// authoritative for what the session is doing right now — same contract as the
|
|
2969
|
+
// ended-record revival above. Absence from the listing (including its fail-soft empty
|
|
2970
|
+
// map on spawn failure) keeps record-derived status: absence must never mean dead.
|
|
2971
|
+
const liveEntry = liveSessions?.get(data.sessionId);
|
|
2972
|
+
if (liveEntry != null) {
|
|
2973
|
+
const liveStatus = statusFromLiveSession(liveEntry);
|
|
2974
|
+
if (liveStatus == null) {
|
|
2975
|
+
polledAlive.delete(data.sessionId);
|
|
2976
|
+
continue;
|
|
2977
|
+
}
|
|
2978
|
+
if (liveStatus !== status) {
|
|
2979
|
+
status = liveStatus;
|
|
2980
|
+
phaseSince = new Date(pollStartedAt);
|
|
2981
|
+
}
|
|
2982
|
+
}
|
|
2983
|
+
const phase = getPhaseForStatus(status);
|
|
2337
2984
|
// Same rationale as the revived-session branch above: no blocking hook entry exists for a
|
|
2338
2985
|
// poll-discovered ask, so synthesize an unresolvable one for the card.
|
|
2339
2986
|
const pendingPermission = status === 'permission_requested'
|
|
2340
|
-
? synthesizeUnresolvableAsk(
|
|
2987
|
+
? synthesizeUnresolvableAsk(polledToolName, data.planFile ?? undefined)
|
|
2341
2988
|
: undefined;
|
|
2342
2989
|
// Read straight from the CLI's durable record so the row attaches to the right branch
|
|
2343
|
-
// card/WIP row immediately, no git probe — mirrors the
|
|
2990
|
+
// card/WIP row immediately, no git probe — mirrors the ended-session push below.
|
|
2344
2991
|
const worktreeRoot = worktreeRootFromData(data);
|
|
2345
2992
|
const subagents = data.subagents?.map(sub => ({
|
|
2346
2993
|
id: sub.agentId,
|
|
2347
|
-
providerId:
|
|
2348
|
-
providerName:
|
|
2994
|
+
providerId: capabilities.providerId,
|
|
2995
|
+
providerName: capabilities.displayName,
|
|
2349
2996
|
name: sub.agentType ?? 'Subagent',
|
|
2350
2997
|
status: 'thinking',
|
|
2351
2998
|
phase: 'working',
|
|
@@ -2357,12 +3004,12 @@ export class ClaudeCodeProvider {
|
|
|
2357
3004
|
}));
|
|
2358
3005
|
this._sessions.push({
|
|
2359
3006
|
id: data.sessionId,
|
|
2360
|
-
providerId:
|
|
2361
|
-
providerName:
|
|
3007
|
+
providerId: capabilities.providerId,
|
|
3008
|
+
providerName: capabilities.displayName,
|
|
2362
3009
|
name: data.sessionName || undefined,
|
|
2363
3010
|
status: status,
|
|
2364
3011
|
phase: phase,
|
|
2365
|
-
phaseSince:
|
|
3012
|
+
phaseSince: phaseSince,
|
|
2366
3013
|
pid: data.pid,
|
|
2367
3014
|
lastActivity: activityDate,
|
|
2368
3015
|
isSubagent: false,
|
|
@@ -2380,9 +3027,10 @@ export class ClaudeCodeProvider {
|
|
|
2380
3027
|
subagents: subagents,
|
|
2381
3028
|
pendingPermission: pendingPermission,
|
|
2382
3029
|
worktreePath: worktreeRoot != null ? normalizePath(worktreeRoot) : undefined,
|
|
3030
|
+
visitedWorktreePaths: unionVisited(undefined, visitedFromTimeline(data.cwdTimeline), data.worktrees, worktreeRoot != null ? normalizePath(worktreeRoot) : undefined),
|
|
2383
3031
|
});
|
|
2384
3032
|
changed = true;
|
|
2385
|
-
|
|
3033
|
+
noteDiscovered(capabilities.providerId);
|
|
2386
3034
|
// The record's worktree is the CLI's own resolution — mark it seated so the probe below
|
|
2387
3035
|
// (still wanted for `commonPath`) can't clobber it for a nested worktree. Mirrors the
|
|
2388
3036
|
// revival branch above.
|
|
@@ -2392,32 +3040,35 @@ export class ClaudeCodeProvider {
|
|
|
2392
3040
|
}
|
|
2393
3041
|
void this.resolveTranscriptTitles(data.sessionId, data.cwd);
|
|
2394
3042
|
}
|
|
2395
|
-
// The poll is authoritative for
|
|
3043
|
+
// The poll is authoritative for ended-session removal (archived elsewhere or 30-day-purged).
|
|
2396
3044
|
// Only remove ones a prior poll confirmed present — never a session the live SessionEnd path just
|
|
2397
3045
|
// transitioned, which an already-in-flight poll can legitimately miss (guarded by polledAtLeastOnce).
|
|
2398
|
-
// On a legacy CLI (no `--status`) no poll can ever confirm
|
|
3046
|
+
// On a legacy CLI (no `--status`) no poll can ever confirm an ended row, so the guard would
|
|
2399
3047
|
// instead pin it forever — there, absence from the poll is the confirmation.
|
|
2400
|
-
const
|
|
3048
|
+
const staleEnded = this._sessions.filter(s => s.status === 'ended' &&
|
|
2401
3049
|
!polledIds.has(s.id) &&
|
|
2402
3050
|
(!this._statusFilterSupported ||
|
|
2403
3051
|
this._sessionBookkeeping.get(s.id)?.polledAtLeastOnce === true ||
|
|
2404
|
-
// Or this poll's snapshot postdates the
|
|
3052
|
+
// Or this poll's snapshot postdates the end, so its absence is authoritative.
|
|
2405
3053
|
// `polledAtLeastOnce` alone can never be set for a session whose record disappeared
|
|
2406
3054
|
// (archived from another window, or purged) before any poll observed it — and
|
|
2407
|
-
// `
|
|
3055
|
+
// `endSession` drops the bookkeeping — so without this the row is unremovable for
|
|
2408
3056
|
// the window's lifetime. The timestamp answers the same question the flag was proxying:
|
|
2409
|
-
// could this poll have seen it? The grace keeps a just-
|
|
3057
|
+
// could this poll have seen it? The grace keeps a just-ended row out of reach of
|
|
2410
3058
|
// an absence that's really a not-yet-visible record.
|
|
2411
|
-
s.phaseSince.getTime() < pollStartedAt -
|
|
2412
|
-
if (
|
|
2413
|
-
const staleIds = new Set(
|
|
3059
|
+
s.phaseSince.getTime() < pollStartedAt - endedRemovalGraceMs));
|
|
3060
|
+
if (staleEnded.length > 0) {
|
|
3061
|
+
const staleIds = new Set(staleEnded.map(s => s.id));
|
|
2414
3062
|
this._sessions = this._sessions.filter(s => !staleIds.has(s.id));
|
|
2415
|
-
for (const s of
|
|
3063
|
+
for (const s of staleEnded) {
|
|
2416
3064
|
this._sessionBookkeeping.delete(s.id);
|
|
2417
3065
|
this._transcriptReader.forget(s.id);
|
|
2418
3066
|
}
|
|
2419
3067
|
changed = true;
|
|
2420
3068
|
}
|
|
3069
|
+
if (liveSessions != null) {
|
|
3070
|
+
Logger.debug(`GkAgentProvider.syncSessions: Claude lists ${liveSessions.size} session(s); kept ${keptAlive.length} alive against a stale ended record${keptAlive.length > 0 ? `: ${keptAlive.join(', ')}` : ''}`);
|
|
3071
|
+
}
|
|
2421
3072
|
if (this.pruneDeadSessions()) {
|
|
2422
3073
|
changed = true;
|
|
2423
3074
|
}
|
|
@@ -2430,20 +3081,35 @@ export class ClaudeCodeProvider {
|
|
|
2430
3081
|
// live sessions we still track that the poll no longer reports alive (a teardown the live path
|
|
2431
3082
|
// missed, e.g. an agent killed without firing `SessionEnd`).
|
|
2432
3083
|
if (options?.gate) {
|
|
2433
|
-
|
|
2434
|
-
|
|
3084
|
+
const driftByAgent = new Map();
|
|
3085
|
+
const driftFor = (providerId) => {
|
|
3086
|
+
let drift = driftByAgent.get(providerId);
|
|
3087
|
+
if (drift == null) {
|
|
3088
|
+
drift = { discovered: 0, missing: 0, polled: 0, tracked: 0 };
|
|
3089
|
+
driftByAgent.set(providerId, drift);
|
|
3090
|
+
}
|
|
3091
|
+
return drift;
|
|
3092
|
+
};
|
|
3093
|
+
for (const [providerId, count] of discoveredByAgent) {
|
|
3094
|
+
driftFor(providerId).discovered += count;
|
|
3095
|
+
}
|
|
3096
|
+
for (const [id, providerId] of trackedLiveBefore) {
|
|
3097
|
+
const drift = driftFor(providerId);
|
|
3098
|
+
drift.tracked++;
|
|
2435
3099
|
if (!polledAlive.has(id)) {
|
|
2436
|
-
missing++;
|
|
3100
|
+
drift.missing++;
|
|
2437
3101
|
}
|
|
2438
3102
|
}
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
3103
|
+
for (const providerId of polledAlive.values()) {
|
|
3104
|
+
driftFor(providerId).polled++;
|
|
3105
|
+
}
|
|
3106
|
+
// One report per agent, not one pooled report: the counts describe how well the live path
|
|
3107
|
+
// and the poll agree, which is a per-agent property. A single agent's numbers are unchanged
|
|
3108
|
+
// from the pre-multi-agent shape.
|
|
3109
|
+
for (const [providerId, drift] of driftByAgent) {
|
|
3110
|
+
if (drift.discovered === 0 && drift.missing === 0)
|
|
3111
|
+
continue;
|
|
3112
|
+
this.callbacks.onSyncDiscrepancy?.({ provider: providerId, ...drift });
|
|
2447
3113
|
}
|
|
2448
3114
|
}
|
|
2449
3115
|
}
|
|
@@ -2474,15 +3140,17 @@ export class ClaudeCodeProvider {
|
|
|
2474
3140
|
if (peerSessions == null)
|
|
2475
3141
|
continue;
|
|
2476
3142
|
for (const peerSession of peerSessions) {
|
|
2477
|
-
// Skip terminal rows a peer published:
|
|
3143
|
+
// Skip terminal rows a peer published: ended sessions live in the machine-global CLI
|
|
2478
3144
|
// store, so our own poll surfaces them with proper reconciliation. Importing them from a
|
|
2479
3145
|
// peer would add a row with no `polledAtLeastOnce` that our poll can't confirm — a ghost
|
|
2480
|
-
// pinned for this window's lifetime if the store later drops it.
|
|
2481
|
-
|
|
3146
|
+
// pinned for this window's lifetime if the store later drops it. (The publish endpoint
|
|
3147
|
+
// filters ended rows out anyway; this also keeps a version-skewed peer's terminal claim
|
|
3148
|
+
// from ending a row we still track live.)
|
|
3149
|
+
if (peerSession.status === 'ended')
|
|
2482
3150
|
continue;
|
|
3151
|
+
const existing = this._sessions.find(s => s.id === peerSession.id);
|
|
2483
3152
|
const peerActivity = new Date(peerSession.lastActivity);
|
|
2484
3153
|
const peerPhaseSince = new Date(peerSession.phaseSince);
|
|
2485
|
-
const existing = this._sessions.find(s => s.id === peerSession.id);
|
|
2486
3154
|
if (existing != null) {
|
|
2487
3155
|
// If the peer reports a different `cwd` than we last saw, the agent moved
|
|
2488
3156
|
// (peer fired a `CwdChanged` event we don't receive locally). Pick up the new
|
|
@@ -2498,7 +3166,7 @@ export class ClaudeCodeProvider {
|
|
|
2498
3166
|
// poll/SessionStart revive paths: take the peer's pid (retaining the terminal
|
|
2499
3167
|
// record's dead — and possibly reused — pid would let dispatch focus an unrelated
|
|
2500
3168
|
// process) and mark it peer-owned so opens route through the peer's IPC.
|
|
2501
|
-
const
|
|
3169
|
+
const revivedFromEnded = existing.status === 'ended';
|
|
2502
3170
|
// Peer is the authoritative hook recipient for this session, so wipe any
|
|
2503
3171
|
// local bookkeeping that survived from a previous local-ownership window.
|
|
2504
3172
|
// Without this, refcounts/timers from a prior local-hosting stretch would
|
|
@@ -2525,7 +3193,7 @@ export class ClaudeCodeProvider {
|
|
|
2525
3193
|
(peerSession.pendingPermission != null
|
|
2526
3194
|
? { ...peerSession.pendingPermission, resolvable: false }
|
|
2527
3195
|
: undefined),
|
|
2528
|
-
...(
|
|
3196
|
+
...(revivedFromEnded ? { pid: peerSession.pid, isPeerOwned: true } : undefined),
|
|
2529
3197
|
subagents: rehydrateSubagents(peerSession.subagents),
|
|
2530
3198
|
// Carry the peer's published fileActivity across so peer-window WIP
|
|
2531
3199
|
// decorations + treemap heatmap follow the agent. Owned by the peer (it's
|
|
@@ -2537,6 +3205,7 @@ export class ClaudeCodeProvider {
|
|
|
2537
3205
|
// but `worktreePath` changed) only reaches us if we carry BOTH. Carrying
|
|
2538
3206
|
// `commonPath` alone froze the displayed worktree at first-discovery.
|
|
2539
3207
|
worktreePath: peerSession.worktreePath ?? existing.worktreePath,
|
|
3208
|
+
visitedWorktreePaths: unionVisitedAppendOnly(existing.visitedWorktreePaths, peerSession.visitedWorktreePaths),
|
|
2540
3209
|
commonPath: peerSession.commonPath ?? existing.commonPath,
|
|
2541
3210
|
// Pick up the peer's latest cwd so our backfill probe (queued below)
|
|
2542
3211
|
// targets the right path. Stale-cwd locally would just re-probe the
|
|
@@ -2548,6 +3217,10 @@ export class ClaudeCodeProvider {
|
|
|
2548
3217
|
initialCwd: existing.initialCwd ?? peerSession.initialCwd,
|
|
2549
3218
|
initialWorktreePath: existing.initialWorktreePath ?? peerSession.initialWorktreePath,
|
|
2550
3219
|
initialCommonPath: existing.initialCommonPath ?? peerSession.initialCommonPath,
|
|
3220
|
+
// The hook path, when present, carries richer un-truncated prompts than what a
|
|
3221
|
+
// peer forwards — prefer our own value over the peer's.
|
|
3222
|
+
lastPrompt: existing.lastPrompt ?? peerSession.lastPrompt,
|
|
3223
|
+
firstPrompt: existing.firstPrompt ?? peerSession.firstPrompt,
|
|
2551
3224
|
// Forward peer-discovered transcript titles. Both windows can resolve them
|
|
2552
3225
|
// independently against the same on-disk transcript, but only the peer's
|
|
2553
3226
|
// hook flow drives its updates — without this, we'd freeze the snapshot
|
|
@@ -2683,7 +3356,7 @@ export class ClaudeCodeProvider {
|
|
|
2683
3356
|
signal: AbortSignal.timeout(2000),
|
|
2684
3357
|
});
|
|
2685
3358
|
if (!response.ok) {
|
|
2686
|
-
Logger.warn(`
|
|
3359
|
+
Logger.warn(`GkAgentProvider.notifyPeerOpenSession: peer at ${discovery.address} returned ${response.status}`);
|
|
2687
3360
|
}
|
|
2688
3361
|
else {
|
|
2689
3362
|
// Log a peer that failed to open the specific session, but still treat it
|
|
@@ -2691,7 +3364,7 @@ export class ClaudeCodeProvider {
|
|
|
2691
3364
|
// what the user wants.
|
|
2692
3365
|
const body = (await response.json().catch(() => undefined));
|
|
2693
3366
|
if (body?.opened === false) {
|
|
2694
|
-
Logger.warn(`
|
|
3367
|
+
Logger.warn(`GkAgentProvider.notifyPeerOpenSession: peer at ${discovery.address} could not open session ${sessionId}`);
|
|
2695
3368
|
}
|
|
2696
3369
|
}
|
|
2697
3370
|
return true;
|
|
@@ -2699,7 +3372,7 @@ export class ClaudeCodeProvider {
|
|
|
2699
3372
|
catch (ex) {
|
|
2700
3373
|
// Peer advertised but unreachable (timeout, RST, etc.). Treat as no match so
|
|
2701
3374
|
// the caller opens a new window instead of trying to focus a dead window.
|
|
2702
|
-
Logger.warn(`
|
|
3375
|
+
Logger.warn(`GkAgentProvider.notifyPeerOpenSession: peer at ${discovery.address} unreachable: ${ex instanceof Error ? ex.message : String(ex)}`);
|
|
2703
3376
|
return false;
|
|
2704
3377
|
}
|
|
2705
3378
|
}));
|
|
@@ -2708,5 +3381,5 @@ export class ClaudeCodeProvider {
|
|
|
2708
3381
|
}
|
|
2709
3382
|
__decorate([
|
|
2710
3383
|
gate()
|
|
2711
|
-
],
|
|
2712
|
-
//# sourceMappingURL=
|
|
3384
|
+
], GkAgentProvider.prototype, "ensureIpcServer", null);
|
|
3385
|
+
//# sourceMappingURL=gkAgentProvider.js.map
|