@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
|
@@ -5,7 +5,16 @@ import type { UnifiedDisposable } from '../../../utils/disposable.js';
|
|
|
5
5
|
import { disposableInterval } from '../../../utils/disposable.js';
|
|
6
6
|
import { Emitter } from '../../../utils/event.js';
|
|
7
7
|
import { Logger } from '../../../utils/logger.js';
|
|
8
|
-
import { normalizePath } from '../../../utils/path.js';
|
|
8
|
+
import { arePathsEqual, normalizePath } from '../../../utils/path.js';
|
|
9
|
+
import type { AgentCapabilities } from '../agentCapabilities.js';
|
|
10
|
+
import {
|
|
11
|
+
agentCapabilities,
|
|
12
|
+
claudeCodeCapabilities,
|
|
13
|
+
getAgentCapabilities,
|
|
14
|
+
getAgentCapabilitiesByProviderId,
|
|
15
|
+
resolveCanonicalHookEvent,
|
|
16
|
+
resolveCanonicalToolName,
|
|
17
|
+
} from '../agentCapabilities.js';
|
|
9
18
|
import { prepareStoredPrompt } from '../sanitizePrompt.js';
|
|
10
19
|
import {
|
|
11
20
|
classifyPermissionKind,
|
|
@@ -21,23 +30,29 @@ import {
|
|
|
21
30
|
import type {
|
|
22
31
|
AgentProviderCallbacks,
|
|
23
32
|
AgentSession,
|
|
33
|
+
AgentSessionHistoryItem,
|
|
34
|
+
AgentSessionHistoryOptions,
|
|
35
|
+
AgentSessionHistoryResult,
|
|
24
36
|
AgentSessionPhase,
|
|
25
37
|
AgentSessionProvider,
|
|
38
|
+
AgentSessionResumeOutcome,
|
|
39
|
+
AgentSessionResumeTarget,
|
|
26
40
|
AgentSessionStatus,
|
|
27
|
-
|
|
41
|
+
LiveAgentSession,
|
|
28
42
|
PendingPermission,
|
|
29
43
|
PendingPermissionKind,
|
|
30
44
|
PermissionDecision,
|
|
31
45
|
PermissionSuggestion,
|
|
32
|
-
ResumableAgentSession,
|
|
33
|
-
ResumableSessionsOptions,
|
|
34
|
-
ResumableSessionsResult,
|
|
35
46
|
} from '../types.js';
|
|
36
47
|
import { getPhaseForStatus } from '../types.js';
|
|
48
|
+
import type { EndedTranscriptDetails } from './claudeCodeTranscript.js';
|
|
37
49
|
import { ClaudeCodeTranscriptReader } from './claudeCodeTranscript.js';
|
|
38
50
|
|
|
39
51
|
interface AgentSessionEvent {
|
|
40
|
-
event
|
|
52
|
+
/** The originating agent's NATIVE hook event name, relayed verbatim by the CLI — NOT yet a
|
|
53
|
+
* canonical `AgentHookEvent`. Resolve it through {@link resolveCanonicalHookEvent} before
|
|
54
|
+
* switching on it. */
|
|
55
|
+
event: string;
|
|
41
56
|
sessionId: string;
|
|
42
57
|
/** `gk ai hook` client id of the originating host (`claude-code`, `codex`, …) — NOT
|
|
43
58
|
* `AgentSession.providerId`. Absent on older CLIs. */
|
|
@@ -130,14 +145,19 @@ interface SessionBookkeeping {
|
|
|
130
145
|
* window (e.g., Stop → idle → working after a continuation crosses the debounce). */
|
|
131
146
|
priorPhase?: { phase: AgentSessionPhase; phaseSince: Date };
|
|
132
147
|
/** Set once the reconciliation poll has seen this session's durable `ended` record. Gates
|
|
133
|
-
*
|
|
148
|
+
* ended-session removal: a session the live `SessionEnd` path just transitioned is never
|
|
134
149
|
* reconcile-removed by an already-in-flight poll that legitimately missed it — only after a poll
|
|
135
150
|
* has confirmed the CLI still lists it. */
|
|
136
151
|
polledAtLeastOnce?: boolean;
|
|
137
|
-
/**
|
|
138
|
-
*
|
|
152
|
+
/** The durable record's cwd that a live listing superseded when a resume moved the session
|
|
153
|
+
* elsewhere. While the record still reports this exact cwd, a poll that no longer finds the
|
|
154
|
+
* session in the live listing must not re-seat the row (or trust the record's `endedAt`) —
|
|
155
|
+
* the record predates the resume. Cleared when the record's cwd changes (the CLI caught up). */
|
|
156
|
+
supersededRecordCwd?: string;
|
|
157
|
+
/** Set once an ended session's transcript has been read on demand (via
|
|
158
|
+
* `resolveEndedSessionDetails`). Terminal transcripts don't change, so the read happens at
|
|
139
159
|
* most once regardless of how many times the row is opened. */
|
|
140
|
-
|
|
160
|
+
endedDetailsResolved?: boolean;
|
|
141
161
|
}
|
|
142
162
|
|
|
143
163
|
const staleCheckIntervalMs = 15 * 60 * 1000; // 15 minutes
|
|
@@ -146,21 +166,21 @@ const staleCheckIntervalMs = 15 * 60 * 1000; // 15 minutes
|
|
|
146
166
|
* to reap, leaving only terminal-history reconciliation and the CLI's retention sweep — both fine
|
|
147
167
|
* hourly. Window startup polls ungated, so this never delays a fresh window. */
|
|
148
168
|
const idleReconcileIntervalMs = 60 * 60 * 1000; // 1 hour
|
|
149
|
-
/** How long a freshly-
|
|
169
|
+
/** How long a freshly-ended row is protected from absence-based removal. The CLI persists the
|
|
150
170
|
* ended record (atomic temp+rename) BEFORE broadcasting the hook event, so a poll that starts after
|
|
151
|
-
* we mark a row
|
|
152
|
-
* side, where the cost of being wrong is
|
|
153
|
-
const
|
|
171
|
+
* we mark a row ended will list it — this guards only against that ordering changing on the CLI
|
|
172
|
+
* side, where the cost of being wrong is an ended row silently vanishing. */
|
|
173
|
+
const endedRemovalGraceMs = 30 * 1000; // 30 seconds
|
|
154
174
|
/** How long the machine-global archived-session id list (fetched via a separate `--status archived`
|
|
155
|
-
* CLI query) is cached. `
|
|
175
|
+
* CLI query) is cached. `listSessionHistory` asks for it once per worktree on panel open, so without
|
|
156
176
|
* this a multi-worktree graph spawns N identical CLI processes. `archiveSession` invalidates the
|
|
157
177
|
* cache, so a just-archived row never lingers under "Past". */
|
|
158
178
|
const archivedSessionIdsCacheTtlMs = 10 * 1000; // 10 seconds
|
|
159
|
-
/**
|
|
179
|
+
/** Ended sessions defer git + transcript resolution (a 30-day cold-start would otherwise fan out
|
|
160
180
|
* hundreds of probes). Exception: sessions ended within this window still surface on WIP rows /
|
|
161
181
|
* branch cards, which match strictly by resolved `worktreePath` — so their git info is resolved
|
|
162
182
|
* eagerly at poll time (a handful), keeping the older tail lazy. Mirrors the 24h WIP-row window. */
|
|
163
|
-
const
|
|
183
|
+
const recentEndedGitResolveThresholdMs = 24 * 60 * 60 * 1000; // 24 hours
|
|
164
184
|
/** Default cooldown between PostToolUse and dropping the file from `fileActivity`. Held long
|
|
165
185
|
* enough that the treemap activity overlay can render a decay tail well past the moment the tool
|
|
166
186
|
* call completed. The host may override per-call via `AgentProviderCallbacks.getActivityDecayMs`
|
|
@@ -182,13 +202,19 @@ interface SessionFileData {
|
|
|
182
202
|
sessionId: string;
|
|
183
203
|
/** `gk ai hook` client id of the owning host — see {@link AgentSessionEvent.providerId}. */
|
|
184
204
|
providerId?: string;
|
|
205
|
+
/** The owning agent's NATIVE hook event name — see {@link AgentSessionEvent.event}. */
|
|
185
206
|
event: string;
|
|
207
|
+
/** The agent's RAW hook payload for {@link event}, relayed verbatim by the CLI — same field the
|
|
208
|
+
* live path reads off {@link AgentSessionEvent.hookInput}. The poll needs it because some agents
|
|
209
|
+
* encode an event's meaning in the payload rather than its name (OpenCode's `session.status`),
|
|
210
|
+
* and {@link resolveCanonicalHookEvent} can't canonicalize those without being handed it. */
|
|
211
|
+
hookInput?: Record<string, unknown>;
|
|
186
212
|
cwd: string;
|
|
187
213
|
/** CLI-provided launch directory; absent on older CLIs (fall back to `cwd`). */
|
|
188
214
|
initialCwd?: string;
|
|
189
215
|
/** Worktree roots (`git rev-parse --show-toplevel`) the CLI resolved for this session's cwd
|
|
190
216
|
* visits, newest last. Absent on older CLIs. Lets us read `worktreePath` straight from the
|
|
191
|
-
* durable store — so
|
|
217
|
+
* durable store — so ended sessions attach to branch cards / WIP rows / the resume picker at
|
|
192
218
|
* any age without a git probe (the CLI already ran `rev-parse` at hook time). */
|
|
193
219
|
cwdTimeline?: { cwd: string; worktree?: string; at?: string }[];
|
|
194
220
|
worktrees?: string[];
|
|
@@ -205,12 +231,12 @@ interface SessionFileData {
|
|
|
205
231
|
prompt?: string | null;
|
|
206
232
|
firstPrompt?: string | null;
|
|
207
233
|
/** CLI durable-session lifecycle status. Absent on legacy files → treated as `active`. `ended`
|
|
208
|
-
* records are surfaced as terminal `
|
|
234
|
+
* records are surfaced as terminal `ended` sessions; `archived` is excluded by the query. */
|
|
209
235
|
status?: 'active' | 'ended' | 'archived';
|
|
210
236
|
/** Why the session ended (`session-end`, `rotated`, `stale`, `dead-pid`, `pid-zero-idle`,
|
|
211
237
|
* `archived`). Present only on `ended`/`archived` records. */
|
|
212
238
|
endReason?: string;
|
|
213
|
-
/** RFC3339 timestamp the session ended; used as `lastActivity`/`phaseSince` for
|
|
239
|
+
/** RFC3339 timestamp the session ended; used as `lastActivity`/`phaseSince` for ended rows. */
|
|
214
240
|
endedAt?: string;
|
|
215
241
|
}
|
|
216
242
|
|
|
@@ -240,6 +266,125 @@ function worktreeFromEvent(event: AgentSessionEvent): string | undefined {
|
|
|
240
266
|
return undefined;
|
|
241
267
|
}
|
|
242
268
|
|
|
269
|
+
/** Reorders `paths` so each distinct (already-normalized) value is positioned by its LAST
|
|
270
|
+
* occurrence — an earlier occurrence of a value that recurs later is dropped, and the value's one
|
|
271
|
+
* remaining slot sits where that last occurrence falls. Implemented via delete-then-reinsert into
|
|
272
|
+
* a `Map`: re-inserting a key after deleting it moves it to the end of `Map` iteration order,
|
|
273
|
+
* which is exactly "this value's position is its most recent occurrence". */
|
|
274
|
+
function orderByLastOccurrence(paths: readonly string[]): string[] {
|
|
275
|
+
const seen = new Map<string, true>();
|
|
276
|
+
for (const path of paths) {
|
|
277
|
+
seen.delete(path);
|
|
278
|
+
seen.set(path, true);
|
|
279
|
+
}
|
|
280
|
+
return [...seen.keys()];
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Distinct, normalized, non-empty `worktree` values from a `cwdTimeline`, ordered by recency —
|
|
284
|
+
* positioned by each path's LAST occurrence in the (chronological) timeline, so a path the
|
|
285
|
+
* session returns to later sorts after one it doesn't. `undefined` when the timeline is absent or
|
|
286
|
+
* names no worktree — callers should NOT overwrite an existing `visitedWorktreePaths` with an
|
|
287
|
+
* empty array in that case. */
|
|
288
|
+
function visitedFromTimeline(
|
|
289
|
+
timeline: { cwd: string; worktree?: string; at?: string }[] | undefined,
|
|
290
|
+
): string[] | undefined {
|
|
291
|
+
if (timeline == null) return undefined;
|
|
292
|
+
|
|
293
|
+
const paths: string[] = [];
|
|
294
|
+
for (const entry of timeline) {
|
|
295
|
+
if (!entry.worktree) continue;
|
|
296
|
+
|
|
297
|
+
paths.push(normalizePath(entry.worktree));
|
|
298
|
+
}
|
|
299
|
+
if (paths.length === 0) return undefined;
|
|
300
|
+
|
|
301
|
+
return orderByLastOccurrence(paths);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Unions `adds` (each an array or a single path, `undefined` skipped) into `existing`, normalizing
|
|
305
|
+
* every path, and reorders the result by recency: every add — including one that names a path
|
|
306
|
+
* already in `existing` — moves that path to the end, same "last occurrence wins the slot" rule
|
|
307
|
+
* {@link orderByLastOccurrence} applies to a single timeline. Adds arrive in chronological order at
|
|
308
|
+
* every call site (timeline-derived paths first, the current `worktreePath` folded last), so the
|
|
309
|
+
* current worktree normally ends up last.
|
|
310
|
+
*
|
|
311
|
+
* **Reference-stability contract — load-bearing**: returns `existing` BY REFERENCE, unchanged,
|
|
312
|
+
* when the resulting sequence is element-for-element identical to `existing`. Callers
|
|
313
|
+
* (`ensureSession`'s identity gate) rely on this to avoid firing spurious change events on every
|
|
314
|
+
* hook event / poll cycle when the visited set hasn't actually changed — re-processing an
|
|
315
|
+
* unchanged timeline plus the same current worktree reproduces the identical sequence (each path
|
|
316
|
+
* "moves" to the same slot it already occupies), so the comparison below still holds. */
|
|
317
|
+
function unionVisited(
|
|
318
|
+
existing: readonly string[] | undefined,
|
|
319
|
+
...adds: (string | readonly string[] | undefined)[]
|
|
320
|
+
): readonly string[] | undefined {
|
|
321
|
+
const combined = existing != null ? [...existing] : [];
|
|
322
|
+
for (const add of adds) {
|
|
323
|
+
if (add == null) continue;
|
|
324
|
+
|
|
325
|
+
const values = typeof add === 'string' ? [add] : add;
|
|
326
|
+
for (const value of values) {
|
|
327
|
+
if (!value) continue;
|
|
328
|
+
|
|
329
|
+
combined.push(normalizePath(value));
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const next = orderByLastOccurrence(combined);
|
|
334
|
+
if (existing != null && next.length === existing.length && next.every((path, i) => path === existing[i])) {
|
|
335
|
+
return existing;
|
|
336
|
+
}
|
|
337
|
+
return next.length > 0 ? next : undefined;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Peer-merge variant of {@link unionVisited}: same normalize-and-dedupe, but APPEND-ONLY —
|
|
341
|
+
* `existing`'s order is preserved and any path the peer names that we don't already have is
|
|
342
|
+
* appended in the peer's own order. A peer's order can't be trusted as fresher than ours without
|
|
343
|
+
* timestamps, so peer input never reorders what we already hold. Returns `existing` BY REFERENCE
|
|
344
|
+
* when nothing new is added — same rationale as {@link unionVisited}. */
|
|
345
|
+
function unionVisitedAppendOnly(
|
|
346
|
+
existing: readonly string[] | undefined,
|
|
347
|
+
...adds: (string | readonly string[] | undefined)[]
|
|
348
|
+
): readonly string[] | undefined {
|
|
349
|
+
const next = new Set(existing ?? []);
|
|
350
|
+
let grew = false;
|
|
351
|
+
for (const add of adds) {
|
|
352
|
+
if (add == null) continue;
|
|
353
|
+
|
|
354
|
+
const values = typeof add === 'string' ? [add] : add;
|
|
355
|
+
for (const value of values) {
|
|
356
|
+
if (!value) continue;
|
|
357
|
+
|
|
358
|
+
const normalized = normalizePath(value);
|
|
359
|
+
if (!next.has(normalized)) {
|
|
360
|
+
next.add(normalized);
|
|
361
|
+
grew = true;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return grew ? [...next] : existing;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** The event's tool name, translated from the agent's native vocabulary into the canonical (Claude
|
|
369
|
+
* Code) one. Prefers the raw `hookInput.tool_name` passthrough and falls back to the projected
|
|
370
|
+
* top-level field for older CLIs. Everything downstream — `describeToolInput`,
|
|
371
|
+
* `classifyPermissionKind`, `getToolFilePath`, `getToolReadPath`, and the display detail — expects
|
|
372
|
+
* canonical names, and Claude Code has no alias table, so its names pass through untouched. */
|
|
373
|
+
function canonicalToolNameFromEvent(capabilities: AgentCapabilities, event: AgentSessionEvent): string | undefined {
|
|
374
|
+
const native = (event.hookInput?.tool_name as string | undefined) ?? event.toolName;
|
|
375
|
+
return native != null ? resolveCanonicalToolName(capabilities, native) : undefined;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Whether GitLens can actually answer a blocking permission ask from this agent. Two conditions,
|
|
379
|
+
* deliberately separate: the agent must support blocking hooks at all, AND the response we build
|
|
380
|
+
* must be one it understands. {@link PermissionResponse} is Claude-shaped
|
|
381
|
+
* (`hookSpecificOutput.hookEventName: 'PermissionRequest'`), so an agent that supports blocking
|
|
382
|
+
* hooks but expects a different envelope stays observe-only until per-agent response builders land
|
|
383
|
+
* — at which point the second clause is what gets deleted. */
|
|
384
|
+
function canAnswerBlockingPermission(capabilities: AgentCapabilities): boolean {
|
|
385
|
+
return capabilities.supportsBlockingPermissions && capabilities.hookClientId === 'claude-code';
|
|
386
|
+
}
|
|
387
|
+
|
|
243
388
|
/** Synthesizes an unresolvable ask for a `permission_requested` row that has no routable
|
|
244
389
|
* `PendingPermission` — a non-blocking `Notification`/`PermissionRequest` tail, or a
|
|
245
390
|
* poll-discovered live/revived row. None of these hold a blocking hook entry to route an
|
|
@@ -310,6 +455,10 @@ interface DiscoveryFile {
|
|
|
310
455
|
}
|
|
311
456
|
|
|
312
457
|
interface SessionContext {
|
|
458
|
+
/** The owning agent's descriptor — the source of a created session's `providerId`/`providerName`.
|
|
459
|
+
* Omitted only by the deferred-commit paths (the `Stop → idle` timer, a permission resolve),
|
|
460
|
+
* which run against a row that already exists. */
|
|
461
|
+
capabilities?: AgentCapabilities;
|
|
313
462
|
pid?: number;
|
|
314
463
|
workspacePath?: string;
|
|
315
464
|
isInWorkspace?: boolean;
|
|
@@ -322,6 +471,8 @@ interface SessionContext {
|
|
|
322
471
|
model?: string;
|
|
323
472
|
/** CLI-resolved worktree root for the current cwd (authoritative when present). */
|
|
324
473
|
worktreePath?: string;
|
|
474
|
+
/** Distinct worktree roots observed via this event's cwdTimeline. */
|
|
475
|
+
visitedWorktreePaths?: string[];
|
|
325
476
|
}
|
|
326
477
|
|
|
327
478
|
type SerializedAgentSession = Omit<AgentSession, 'lastActivity' | 'phaseSince' | 'subagents'> & {
|
|
@@ -373,6 +524,67 @@ function fileActivityStructurallyEqual(
|
|
|
373
524
|
return true;
|
|
374
525
|
}
|
|
375
526
|
|
|
527
|
+
/**
|
|
528
|
+
* Maps what `claude agents --json` reports for a live session onto `AgentSessionStatus`, per Claude's
|
|
529
|
+
* documented agent-view vocabulary.
|
|
530
|
+
*
|
|
531
|
+
* `kind: 'interactive'` entries carry `status` while their process is alive, plus `waitingFor` when
|
|
532
|
+
* that status is `waiting` — `'permission prompt'` is the one value with a distinct status of its own
|
|
533
|
+
* here, since a permission request is a different kind of block from an ordinary question.
|
|
534
|
+
*
|
|
535
|
+
* `kind: 'background'` entries carry `state` instead: `working` / `blocked` / `done` / `failed` /
|
|
536
|
+
* `stopped`, where `blocked` IS the documented "needs input" state.
|
|
537
|
+
*
|
|
538
|
+
* Returns `undefined` for a background session in a TERMINAL state. `--json` lists background
|
|
539
|
+
* sessions "still working or blocked even when their process has exited", so a listing is not by
|
|
540
|
+
* itself proof of life — without this, a genuinely finished session would be resurrected as `idle`,
|
|
541
|
+
* which is the opposite of the bug this mapping exists to fix. Callers must not treat such an entry
|
|
542
|
+
* as live: ended-record paths trust the durable record, while active discovery/repair paths skip or
|
|
543
|
+
* remove the stale active row.
|
|
544
|
+
*
|
|
545
|
+
* Anything unrecognized degrades to `'idle'` rather than guessing a working/waiting phase.
|
|
546
|
+
*/
|
|
547
|
+
/** Resolves a non-terminal listing override for an `ended` record. `undefined` means "let the ended
|
|
548
|
+
* record stand": either the listing doesn't mention this session, or its background state is
|
|
549
|
+
* terminal (see {@link statusFromLiveSession}). Active-record paths inspect the entry separately
|
|
550
|
+
* because they must distinguish absence (unknown; keep record status) from terminal (skip/remove). */
|
|
551
|
+
function liveOverrideFor(
|
|
552
|
+
liveSessions: ReadonlyMap<string, LiveAgentSession> | undefined,
|
|
553
|
+
sessionId: string,
|
|
554
|
+
): { live: LiveAgentSession; status: AgentSessionStatus } | undefined {
|
|
555
|
+
const live = liveSessions?.get(sessionId);
|
|
556
|
+
if (live == null) return undefined;
|
|
557
|
+
|
|
558
|
+
const status = statusFromLiveSession(live);
|
|
559
|
+
return status != null ? { live: live, status: status } : undefined;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function statusFromLiveSession(live: {
|
|
563
|
+
status?: string;
|
|
564
|
+
state?: string;
|
|
565
|
+
waitingFor?: string;
|
|
566
|
+
}): AgentSessionStatus | undefined {
|
|
567
|
+
switch (live.status) {
|
|
568
|
+
case 'waiting':
|
|
569
|
+
return live.waitingFor === 'permission prompt' ? 'permission_requested' : 'waiting';
|
|
570
|
+
case 'busy':
|
|
571
|
+
return 'thinking';
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
switch (live.state) {
|
|
575
|
+
case 'blocked':
|
|
576
|
+
return 'waiting';
|
|
577
|
+
case 'working':
|
|
578
|
+
return 'thinking';
|
|
579
|
+
case 'done':
|
|
580
|
+
case 'failed':
|
|
581
|
+
case 'stopped':
|
|
582
|
+
return undefined;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
return 'idle';
|
|
586
|
+
}
|
|
587
|
+
|
|
376
588
|
/** True when a CLI invocation failed because it didn't recognize a flag/command. `--status` and the
|
|
377
589
|
* active-only `list-sessions` default shipped together (gk 3.1.69); an older CLI rejects `--status`
|
|
378
590
|
* and returns all sessions unfiltered, so the flagless retry is the correct legacy equivalent. */
|
|
@@ -381,10 +593,14 @@ function isUnknownFlagError(ex: unknown): boolean {
|
|
|
381
593
|
return /unknown (?:flag|shorthand flag|command)/i.test(message);
|
|
382
594
|
}
|
|
383
595
|
|
|
384
|
-
export class
|
|
385
|
-
|
|
386
|
-
|
|
596
|
+
export class GkAgentProvider implements AgentSessionProvider {
|
|
597
|
+
/** Identifies the PROVIDER, not an agent: this one hosts every `gk ai hook` client GitLens has
|
|
598
|
+
* a descriptor for. A session's own agent identity lives on {@link AgentSession.providerId}
|
|
599
|
+
* (`claudeCode`, `codex`, …), sourced from {@link AgentCapabilities} — never from here. */
|
|
600
|
+
readonly id = 'gkAgents';
|
|
601
|
+
readonly name = 'Coding Agents';
|
|
387
602
|
readonly icon = 'robot';
|
|
603
|
+
readonly agentProviderIds: readonly string[] = agentCapabilities.map(c => c.providerId);
|
|
388
604
|
|
|
389
605
|
private readonly _onDidChangeSessions = new Emitter<void>();
|
|
390
606
|
readonly onDidChangeSessions = this._onDidChangeSessions.event;
|
|
@@ -411,16 +627,20 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
411
627
|
private _staleCheckTimer: UnifiedDisposable | undefined;
|
|
412
628
|
/** When the last poll actually ran (gated or not) — paces the idle cadence above. */
|
|
413
629
|
private _lastSyncAt = 0;
|
|
414
|
-
/** Whether
|
|
630
|
+
/** Whether agent hooks are installed, pushed by the host via {@link setHooksInstalled}.
|
|
415
631
|
* Fail-open (`true`) until the first push lands so a fresh window with real hooks isn't
|
|
416
632
|
* suppressed on its first ticks. Gates the reconciliation poll in {@link syncSessions}. */
|
|
417
|
-
private
|
|
633
|
+
private _hooksInstalled = true;
|
|
418
634
|
/** Whether the CLI supports `list-sessions --status` (and with it the durable ended-session
|
|
419
635
|
* store). Optimistic until the poll's flagless fallback proves otherwise. When unsupported,
|
|
420
|
-
*
|
|
421
|
-
* reverts to removing the session and reconciliation drops any
|
|
636
|
+
* ended rows are untenable — no poll can ever confirm or archive one — so `SessionEnd`
|
|
637
|
+
* reverts to removing the session and reconciliation drops any ended stragglers. */
|
|
422
638
|
private _statusFilterSupported = true;
|
|
423
639
|
protected _transcriptReader: ClaudeCodeTranscriptReader = new ClaudeCodeTranscriptReader();
|
|
640
|
+
private _terminalGeneration = 0;
|
|
641
|
+
/** Ended-transcript detail results, cached forever per window — an ended transcript is
|
|
642
|
+
* immutable, so once resolved it never needs a second read. Keyed by session id. */
|
|
643
|
+
private readonly _sessionDetailsCache = new Map<string, Promise<EndedTranscriptDetails | undefined>>();
|
|
424
644
|
|
|
425
645
|
constructor(private readonly callbacks: AgentProviderCallbacks) {}
|
|
426
646
|
|
|
@@ -428,6 +648,20 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
428
648
|
return this._sessions;
|
|
429
649
|
}
|
|
430
650
|
|
|
651
|
+
get terminalGeneration(): number {
|
|
652
|
+
return this._terminalGeneration;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/** Single choke point for every terminal transition — a session ending, being removed on end
|
|
656
|
+
* (legacy path), being pruned, or a fresh ended record being discovered. Bumps the generation
|
|
657
|
+
* the host's history queries validate against (a transition mid-query means the answer may be
|
|
658
|
+
* missing a session) and drops the transcript listing caches, whose warm entries may predate
|
|
659
|
+
* the transcript the transition just left behind. */
|
|
660
|
+
private noteTerminalTransition(): void {
|
|
661
|
+
this._terminalGeneration++;
|
|
662
|
+
this._transcriptReader.invalidateListings();
|
|
663
|
+
}
|
|
664
|
+
|
|
431
665
|
start(workspacePaths: string[]): void {
|
|
432
666
|
this._workspacePaths = workspacePaths.map(p => normalizePath(p));
|
|
433
667
|
void this.ensureIpcServer();
|
|
@@ -482,15 +716,13 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
482
716
|
// Re-publish so the agents discovery file reflects the new workspacePaths.
|
|
483
717
|
this.callbacks.ipc
|
|
484
718
|
.publishAgents(this._workspacePaths)
|
|
485
|
-
.catch((ex: unknown) =>
|
|
486
|
-
Logger.error(ex, 'ClaudeCodeProvider.updateWorkspacePaths: publishAgents failed'),
|
|
487
|
-
);
|
|
719
|
+
.catch((ex: unknown) => Logger.error(ex, 'GkAgentProvider.updateWorkspacePaths: publishAgents failed'));
|
|
488
720
|
}
|
|
489
721
|
}
|
|
490
722
|
|
|
491
|
-
|
|
492
|
-
const wasOff = !this.
|
|
493
|
-
this.
|
|
723
|
+
setHooksInstalled(installed: boolean): void {
|
|
724
|
+
const wasOff = !this._hooksInstalled;
|
|
725
|
+
this._hooksInstalled = installed;
|
|
494
726
|
// On a fresh off→on transition, reconcile immediately rather than waiting up to a full
|
|
495
727
|
// interval — picks up any session that was already running before hooks were installed.
|
|
496
728
|
// This is a deliberate discovery pass (like the cold-start bootstrap), not a routine tick:
|
|
@@ -502,26 +734,114 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
502
734
|
}
|
|
503
735
|
}
|
|
504
736
|
|
|
505
|
-
/** Reads
|
|
506
|
-
* directory
|
|
507
|
-
*
|
|
508
|
-
*
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
737
|
+
/** Reads Claude history from its transcript store and tracked terminal records. The exact project
|
|
738
|
+
* directory preserves legacy sessions that predate the CLI's durable records. Recent ended
|
|
739
|
+
* records add an authoritative worktree-to-session association, so their ids are also recovered
|
|
740
|
+
* from other project directories when Claude left the transcript behind after a cwd/worktree
|
|
741
|
+
* move. A tracked record with no readable transcript remains visible and archivable, but does not
|
|
742
|
+
* advertise Resume. Archive filtering stays provider-local so ids from another harness cannot
|
|
743
|
+
* suppress Claude rows. `requireResume` drops the transcript-less tracked records, leaving only
|
|
744
|
+
* rows that carry a resume action — and a `total` counting only those. */
|
|
745
|
+
async listSessionHistory(cwd: string, options?: AgentSessionHistoryOptions): Promise<AgentSessionHistoryResult> {
|
|
746
|
+
const resumeOnly = options?.requireResume === true;
|
|
747
|
+
// No launcher means no row can carry resume, so a resume-only query has nothing to return.
|
|
748
|
+
if (resumeOnly && this.callbacks.resumeSession == null) return { sessions: [], total: 0 };
|
|
749
|
+
|
|
750
|
+
const archivedSessionIds = new Set(await this.getArchivedSessionIds());
|
|
751
|
+
const excludeSessionIds = new Set(options?.excludeSessionIds);
|
|
752
|
+
for (const id of archivedSessionIds) {
|
|
753
|
+
excludeSessionIds.add(id);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
const trackedEndedSessions: AgentSession[] = [];
|
|
757
|
+
const endedSessionIds = new Set<string>();
|
|
758
|
+
for (const session of this._sessions) {
|
|
759
|
+
if (
|
|
760
|
+
session.status === 'ended' &&
|
|
761
|
+
session.worktreePath != null &&
|
|
762
|
+
arePathsEqual(session.worktreePath, cwd) &&
|
|
763
|
+
!excludeSessionIds.has(session.id)
|
|
764
|
+
) {
|
|
765
|
+
// A tracked record's own row carries archive but no resume; its id still drives transcript
|
|
766
|
+
// recovery below, which does produce a resumable row when the transcript moved with it.
|
|
767
|
+
if (!resumeOnly) {
|
|
768
|
+
trackedEndedSessions.push(session);
|
|
769
|
+
}
|
|
770
|
+
// Only an agent whose sessions can be resumed feeds transcript recovery. The reader is
|
|
771
|
+
// Claude-transcript-specific, so another agent's id could only ever miss — but stating it
|
|
772
|
+
// keeps the resume capability the reason, rather than leaving it to a coincidence of which
|
|
773
|
+
// store the reader happens to read.
|
|
774
|
+
if (getAgentCapabilitiesByProviderId(session.providerId)?.supportsResume !== false) {
|
|
775
|
+
endedSessionIds.add(session.id);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
const [direct, recovered] = await Promise.all([
|
|
781
|
+
this._transcriptReader.listSessions(cwd, {
|
|
782
|
+
limit: options?.limit,
|
|
783
|
+
excludeSessionIds: excludeSessionIds,
|
|
784
|
+
}),
|
|
785
|
+
this._transcriptReader.listSessionsByIds(endedSessionIds, {
|
|
786
|
+
limit: options?.limit,
|
|
787
|
+
excludeCwd: cwd,
|
|
788
|
+
}),
|
|
789
|
+
]);
|
|
790
|
+
|
|
791
|
+
const historyById = new Map<string, AgentSessionHistoryItem>();
|
|
792
|
+
const addTranscript = (session: (typeof direct.sessions)[number], resumeCwd: string): void => {
|
|
793
|
+
if (excludeSessionIds.has(session.sessionId)) return;
|
|
794
|
+
|
|
795
|
+
const disposition = archivedSessionIds.has(session.sessionId) ? 'archived' : 'ended';
|
|
796
|
+
const resumeActions = this.callbacks.resumeSession != null ? { resume: { cwd: resumeCwd } } : {};
|
|
797
|
+
historyById.set(session.sessionId, {
|
|
798
|
+
id: session.sessionId,
|
|
799
|
+
// The reader only reads Claude Code's transcript store, so every row it produces is
|
|
800
|
+
// Claude's by construction.
|
|
801
|
+
providerId: claudeCodeCapabilities.providerId,
|
|
802
|
+
disposition: disposition,
|
|
803
|
+
actions: disposition === 'ended' ? { ...resumeActions, archive: true } : resumeActions,
|
|
804
|
+
lastActivity: new Date(session.lastActivityMs),
|
|
805
|
+
titles: session.titles,
|
|
806
|
+
lastPrompt: session.lastPrompt,
|
|
807
|
+
});
|
|
808
|
+
};
|
|
809
|
+
for (const session of direct.sessions) {
|
|
810
|
+
addTranscript(session, cwd);
|
|
811
|
+
}
|
|
812
|
+
for (const session of recovered.sessions) {
|
|
813
|
+
addTranscript(session, session.cwd);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
for (const session of trackedEndedSessions) {
|
|
817
|
+
if (historyById.has(session.id)) continue;
|
|
818
|
+
|
|
819
|
+
historyById.set(session.id, {
|
|
820
|
+
id: session.id,
|
|
821
|
+
providerId: session.providerId,
|
|
822
|
+
disposition: 'ended',
|
|
823
|
+
actions: { archive: true },
|
|
824
|
+
lastActivity: session.lastActivity,
|
|
825
|
+
name: session.name,
|
|
826
|
+
titles: session.transcriptTitles,
|
|
827
|
+
firstPrompt: session.firstPrompt,
|
|
828
|
+
lastPrompt: session.lastPrompt,
|
|
829
|
+
});
|
|
830
|
+
}
|
|
514
831
|
|
|
515
|
-
const
|
|
516
|
-
|
|
517
|
-
providerId: this.id,
|
|
518
|
-
cwd: cwd,
|
|
519
|
-
lastActivity: new Date(s.lastActivityMs),
|
|
520
|
-
titles: s.titles,
|
|
521
|
-
lastPrompt: s.lastPrompt,
|
|
522
|
-
}));
|
|
832
|
+
const history = [...historyById.values()].sort((a, b) => b.lastActivity.getTime() - a.lastActivity.getTime());
|
|
833
|
+
const limited = options?.limit != null && options.limit > 0 ? history.slice(0, options.limit) : history;
|
|
523
834
|
|
|
524
|
-
return { sessions:
|
|
835
|
+
return { sessions: limited, total: Math.max(direct.total + recovered.total, history.length) };
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
resumeSession(
|
|
839
|
+
sessionId: string,
|
|
840
|
+
cwd: string,
|
|
841
|
+
target: AgentSessionResumeTarget,
|
|
842
|
+
name?: string,
|
|
843
|
+
): Promise<AgentSessionResumeOutcome | false> {
|
|
844
|
+
return this.callbacks.resumeSession?.(sessionId, cwd, target, name) ?? Promise.resolve(false);
|
|
525
845
|
}
|
|
526
846
|
|
|
527
847
|
dispose(): void {
|
|
@@ -557,7 +877,7 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
557
877
|
this.dispose();
|
|
558
878
|
}
|
|
559
879
|
|
|
560
|
-
@gate<typeof
|
|
880
|
+
@gate<typeof GkAgentProvider.prototype.ensureIpcServer>()
|
|
561
881
|
private async ensureIpcServer(): Promise<void> {
|
|
562
882
|
if (this._ipcStarted || this._disposed) return;
|
|
563
883
|
|
|
@@ -572,11 +892,11 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
572
892
|
}),
|
|
573
893
|
this.callbacks.ipc.registerHandler('agents/sessions/list', () =>
|
|
574
894
|
Promise.resolve(
|
|
575
|
-
// Exclude terminal `
|
|
576
|
-
// would import them as phantom "idle" sessions. Our own poll surfaces
|
|
895
|
+
// Exclude terminal `ended` rows: an older-version peer has no `ended` phase and
|
|
896
|
+
// would import them as phantom "idle" sessions. Our own poll surfaces ended sessions
|
|
577
897
|
// independently, so peers never need them from here.
|
|
578
898
|
this._sessions
|
|
579
|
-
.filter(s => s.status !== '
|
|
899
|
+
.filter(s => s.status !== 'ended')
|
|
580
900
|
.map(s => ({
|
|
581
901
|
...s,
|
|
582
902
|
lastActivity: s.lastActivity.toISOString(),
|
|
@@ -600,7 +920,7 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
600
920
|
return { opened: true };
|
|
601
921
|
} catch (ex) {
|
|
602
922
|
Logger.warn(
|
|
603
|
-
`
|
|
923
|
+
`GkAgentProvider.agents/sessions/open: ${ex instanceof Error ? ex.message : String(ex)}`,
|
|
604
924
|
);
|
|
605
925
|
return { opened: false };
|
|
606
926
|
}
|
|
@@ -614,7 +934,7 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
614
934
|
for (const d of handlers) {
|
|
615
935
|
d.dispose();
|
|
616
936
|
}
|
|
617
|
-
Logger.error(ex, '
|
|
937
|
+
Logger.error(ex, 'GkAgentProvider.ensureIpcServer');
|
|
618
938
|
return;
|
|
619
939
|
}
|
|
620
940
|
|
|
@@ -649,21 +969,40 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
649
969
|
staleCheckIntervalMs,
|
|
650
970
|
);
|
|
651
971
|
} catch (ex) {
|
|
652
|
-
Logger.error(ex, '
|
|
972
|
+
Logger.error(ex, 'GkAgentProvider.ensureIpcServer');
|
|
653
973
|
}
|
|
654
974
|
}
|
|
655
975
|
|
|
656
976
|
private handleSessionEvent(event: AgentSessionEvent, isBlocking: boolean): Promise<PermissionResponse | void> {
|
|
657
|
-
// The CLI broadcasts every AI host's events to every listener
|
|
658
|
-
//
|
|
659
|
-
//
|
|
660
|
-
|
|
661
|
-
|
|
977
|
+
// The CLI broadcasts every AI host's events to every listener, stamped with its `gk ai hook`
|
|
978
|
+
// client id. Absent = `claude-code`: older CLIs don't stamp it, and no other client existed
|
|
979
|
+
// then. A client we hold no descriptor for is dropped — the CLI supports more than this table
|
|
980
|
+
// does, and we can't interpret an agent whose vocabulary we don't know. A dropped blocking
|
|
981
|
+
// request gets no decision, so the CLI waits out its own hook timeout, which is correct: we
|
|
982
|
+
// must not answer for an agent we don't track.
|
|
983
|
+
const hookClientId = event.providerId ?? 'claude-code';
|
|
984
|
+
const capabilities = getAgentCapabilities(hookClientId);
|
|
985
|
+
if (capabilities == null) {
|
|
986
|
+
Logger.debug(`GkAgentProvider.handleSessionEvent: ignoring ${event.event} from ${hookClientId}`);
|
|
987
|
+
return Promise.resolve();
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// The CLI relays each agent's native event name verbatim, so translate before doing anything
|
|
991
|
+
// else. An unresolvable name must never reach the switch below: it would match no case while
|
|
992
|
+
// still having created/updated a row, and for a blocking delivery would answer nothing while
|
|
993
|
+
// looking handled.
|
|
994
|
+
const hookEvent = resolveCanonicalHookEvent(capabilities, event.event, event.hookInput);
|
|
995
|
+
if (hookEvent == null) {
|
|
996
|
+
Logger.debug(
|
|
997
|
+
`GkAgentProvider.handleSessionEvent: ignoring unmapped event ${event.event} from ${hookClientId}`,
|
|
998
|
+
);
|
|
662
999
|
return Promise.resolve();
|
|
663
1000
|
}
|
|
664
1001
|
|
|
1002
|
+
const toolName = canonicalToolNameFromEvent(capabilities, event);
|
|
665
1003
|
const workspacePath = this.resolveWorkspacePath(event.cwd);
|
|
666
1004
|
const eventContext: SessionContext = {
|
|
1005
|
+
capabilities: capabilities,
|
|
667
1006
|
pid: event.pid,
|
|
668
1007
|
workspacePath: workspacePath,
|
|
669
1008
|
isInWorkspace: workspacePath != null,
|
|
@@ -673,18 +1012,21 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
673
1012
|
sessionName: event.sessionName,
|
|
674
1013
|
model: event.model,
|
|
675
1014
|
worktreePath: worktreeFromEvent(event),
|
|
1015
|
+
visitedWorktreePaths: visitedFromTimeline(event.cwdTimeline),
|
|
676
1016
|
};
|
|
677
1017
|
const tag = this.sessionTag(event.sessionId, event.sessionName ?? 'unnamed');
|
|
1018
|
+
// Log the native name alongside the canonical one — a mapping mistake is otherwise invisible.
|
|
1019
|
+
const eventTag = event.event === hookEvent ? hookEvent : `${hookEvent}(${event.event})`;
|
|
678
1020
|
|
|
679
|
-
if (
|
|
680
|
-
Logger.info(`
|
|
1021
|
+
if (hookEvent === 'SessionStart' || hookEvent === 'SessionEnd') {
|
|
1022
|
+
Logger.info(`GkAgentProvider.handleSessionEvent: ${eventTag} ${tag}`);
|
|
681
1023
|
} else {
|
|
682
1024
|
Logger.debug(
|
|
683
|
-
`
|
|
1025
|
+
`GkAgentProvider.handleSessionEvent: ${eventTag} ${tag}${toolName ? ` tool=${toolName}` : ''}${event.agentId ? ` agent=${event.agentId}` : ''}${event.notificationType ? ` type=${event.notificationType}` : ''}`,
|
|
684
1026
|
);
|
|
685
1027
|
}
|
|
686
1028
|
|
|
687
|
-
switch (
|
|
1029
|
+
switch (hookEvent) {
|
|
688
1030
|
case 'SessionStart': {
|
|
689
1031
|
const wasNew = this._sessions.findIndex(s => s.id === event.sessionId) < 0;
|
|
690
1032
|
const { index } = this.ensureSession(event.sessionId, eventContext);
|
|
@@ -701,10 +1043,10 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
701
1043
|
}
|
|
702
1044
|
} else {
|
|
703
1045
|
const prev = this._sessions[index];
|
|
704
|
-
if (prev.status === '
|
|
705
|
-
// Resuming a terminal session reuses its id, so a SessionStart on
|
|
1046
|
+
if (prev.status === 'ended') {
|
|
1047
|
+
// Resuming a terminal session reuses its id, so a SessionStart on an ended
|
|
706
1048
|
// row IS the resume signal — revive it to a live `idle` row (clearing the
|
|
707
|
-
//
|
|
1049
|
+
// ended/archivable state) with clean bookkeeping that endSession tore
|
|
708
1050
|
// down. The next real event advances it out of idle. A `SessionStart` arrives on
|
|
709
1051
|
// THIS window's hook flow, so the resume is locally owned now: clear the peer
|
|
710
1052
|
// ownership carried over from the terminal record, and take the resume's pid
|
|
@@ -730,7 +1072,7 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
730
1072
|
|
|
731
1073
|
this._onDidChangeSessions.fire();
|
|
732
1074
|
void this.resolveTranscriptTitles(event.sessionId, event.cwd);
|
|
733
|
-
this.callbacks.onSessionStarted?.(
|
|
1075
|
+
this.callbacks.onSessionStarted?.(capabilities.providerId);
|
|
734
1076
|
break;
|
|
735
1077
|
}
|
|
736
1078
|
|
|
@@ -745,12 +1087,12 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
745
1087
|
const index = this._sessions.findIndex(s => s.id === event.sessionId);
|
|
746
1088
|
if (index >= 0) {
|
|
747
1089
|
if (this._statusFilterSupported) {
|
|
748
|
-
// Transition to a terminal `
|
|
1090
|
+
// Transition to a terminal `ended` row rather than removing it — ended
|
|
749
1091
|
// sessions stay visible (de-emphasized) until archived or 30-day-purged by the CLI.
|
|
750
|
-
this.
|
|
1092
|
+
this.endSession(index, new Date());
|
|
751
1093
|
} else {
|
|
752
|
-
// Legacy CLI (no `--status` durable store):
|
|
753
|
-
// poll-confirmed nor archived, so keep the pre-
|
|
1094
|
+
// Legacy CLI (no `--status` durable store): an ended row could never be
|
|
1095
|
+
// poll-confirmed nor archived, so keep the pre-ended remove-on-end behavior.
|
|
754
1096
|
this._sessions.splice(index, 1);
|
|
755
1097
|
const bk = this._sessionBookkeeping.get(event.sessionId);
|
|
756
1098
|
if (bk != null) {
|
|
@@ -759,6 +1101,7 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
759
1101
|
}
|
|
760
1102
|
this._sessionBookkeeping.delete(event.sessionId);
|
|
761
1103
|
this._transcriptReader.forget(event.sessionId);
|
|
1104
|
+
this.noteTerminalTransition();
|
|
762
1105
|
}
|
|
763
1106
|
this._onDidChangeSessions.fire();
|
|
764
1107
|
} else {
|
|
@@ -772,8 +1115,11 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
772
1115
|
}
|
|
773
1116
|
this._sessionBookkeeping.delete(event.sessionId);
|
|
774
1117
|
this._transcriptReader.forget(event.sessionId);
|
|
1118
|
+
// Untracked or not, a session just reached a terminal state and finalized its
|
|
1119
|
+
// transcript — an in-flight history query must not accept an answer that predates it.
|
|
1120
|
+
this.noteTerminalTransition();
|
|
775
1121
|
}
|
|
776
|
-
this.callbacks.onSessionEnded?.(
|
|
1122
|
+
this.callbacks.onSessionEnded?.(capabilities.providerId);
|
|
777
1123
|
break;
|
|
778
1124
|
}
|
|
779
1125
|
|
|
@@ -806,15 +1152,14 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
806
1152
|
case 'PreToolUse': {
|
|
807
1153
|
const bk = this.getBookkeeping(event.sessionId);
|
|
808
1154
|
bk.activeToolCount++;
|
|
809
|
-
const filesChanged = this.trackToolUseFile(event.sessionId, event);
|
|
810
|
-
const readsChanged = this.trackToolUseRead(event.sessionId, event);
|
|
1155
|
+
const filesChanged = this.trackToolUseFile(event.sessionId, event, capabilities);
|
|
1156
|
+
const readsChanged = this.trackToolUseRead(event.sessionId, event, capabilities);
|
|
811
1157
|
// Resolve a rich `Bash(grep …)`-style detail via the same tool_input passthrough the
|
|
812
1158
|
// PermissionRequest handler uses, so working sessions surface what their tool is
|
|
813
1159
|
// actually doing — not just the bare tool name. Falls back to bare name when no
|
|
814
1160
|
// toolInput is available (older CLI / unhandled tool).
|
|
815
|
-
const
|
|
816
|
-
|
|
817
|
-
const toolName = (hookInput?.tool_name as string | undefined) ?? event.toolName ?? '';
|
|
1161
|
+
const toolInput =
|
|
1162
|
+
(event.hookInput?.tool_input as Record<string, unknown> | undefined) ?? event.toolInput;
|
|
818
1163
|
const statusDetail =
|
|
819
1164
|
toolInput != null && toolName ? describeToolInput(toolName, toolInput) : toolName || undefined;
|
|
820
1165
|
// Capture pre-update status so we can tell whether the upcoming updateSessionStatus
|
|
@@ -836,7 +1181,7 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
836
1181
|
|
|
837
1182
|
case 'PostToolUse':
|
|
838
1183
|
case 'PostToolUseFailure': {
|
|
839
|
-
this.clearStalePermission(event.sessionId,
|
|
1184
|
+
this.clearStalePermission(event.sessionId, hookEvent, this.settledByFromEvent(event, capabilities));
|
|
840
1185
|
const bk = this.getBookkeeping(event.sessionId);
|
|
841
1186
|
bk.activeToolCount = Math.max(0, bk.activeToolCount - 1);
|
|
842
1187
|
// Cooldown the file-edit decoration instead of dropping it immediately, so a quick
|
|
@@ -845,8 +1190,8 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
845
1190
|
// `editing`/`reading` off the moment refcount hits zero, though — capture whether that
|
|
846
1191
|
// changed the published list so we fire it ourselves when the status update below short-
|
|
847
1192
|
// circuits (a parallel tool is still in flight, so activeToolCount stays > 0).
|
|
848
|
-
const filesChanged = this.scheduleClearToolUseFile(event.sessionId, event);
|
|
849
|
-
const readsChanged = this.scheduleClearToolUseRead(event.sessionId, event);
|
|
1193
|
+
const filesChanged = this.scheduleClearToolUseFile(event.sessionId, event, capabilities);
|
|
1194
|
+
const readsChanged = this.scheduleClearToolUseRead(event.sessionId, event, capabilities);
|
|
850
1195
|
if (bk.activeToolCount === 0) {
|
|
851
1196
|
// updateSessionStatus short-circuits without firing when status+detail are unchanged
|
|
852
1197
|
// (already 'thinking' with no detail) — which would drop the editing/reading flag-flip
|
|
@@ -909,9 +1254,9 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
909
1254
|
case 'permission_prompt':
|
|
910
1255
|
this.updateSessionStatus(event.sessionId, 'permission_requested', {
|
|
911
1256
|
...eventContext,
|
|
912
|
-
statusDetail:
|
|
1257
|
+
statusDetail: toolName,
|
|
913
1258
|
pendingPermission: synthesizeUnresolvableAsk(
|
|
914
|
-
|
|
1259
|
+
toolName,
|
|
915
1260
|
// Plan-kind body: prefer the session's planFile, fall back to the event's
|
|
916
1261
|
// — mirrors the blocking path.
|
|
917
1262
|
this._sessions.find(s => s.id === event.sessionId)?.planFile ?? event.planFile,
|
|
@@ -924,11 +1269,11 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
924
1269
|
// hook produces.
|
|
925
1270
|
this.updateSessionStatus(event.sessionId, 'permission_requested', {
|
|
926
1271
|
...eventContext,
|
|
927
|
-
statusDetail:
|
|
1272
|
+
statusDetail: toolName,
|
|
928
1273
|
pendingPermission: {
|
|
929
1274
|
kind: 'elicitation',
|
|
930
|
-
toolName:
|
|
931
|
-
toolDescription:
|
|
1275
|
+
toolName: toolName ?? 'Input Required',
|
|
1276
|
+
toolDescription: toolName ?? 'Waiting for input',
|
|
932
1277
|
resolvable: false,
|
|
933
1278
|
},
|
|
934
1279
|
});
|
|
@@ -943,11 +1288,14 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
943
1288
|
// hookInput is the raw passthrough; fall back to top-level fields for older CLI versions.
|
|
944
1289
|
const hookInput = event.hookInput;
|
|
945
1290
|
const toolInput = (hookInput?.tool_input as Record<string, unknown> | undefined) ?? event.toolInput;
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
1291
|
+
// An agent whose asks we can't answer falls through to the observe-only branch below,
|
|
1292
|
+
// which publishes the same card marked `resolvable: false`. Registering a pending entry
|
|
1293
|
+
// for it would promise an Allow/Deny this window can never route.
|
|
1294
|
+
if (canAnswerBlockingPermission(capabilities) && isBlocking && toolInput != null) {
|
|
1295
|
+
const askToolName = toolName ?? '';
|
|
1296
|
+
const toolDescription = describeToolInput(askToolName, toolInput);
|
|
949
1297
|
const toolInputDescription = (toolInput.description as string | undefined) || undefined;
|
|
950
|
-
const kind: PendingPermissionKind = classifyPermissionKind(
|
|
1298
|
+
const kind: PendingPermissionKind = classifyPermissionKind(askToolName);
|
|
951
1299
|
const toolUseId = hookInput?.tool_use_id as string | undefined;
|
|
952
1300
|
const toolInputJson = stableStringify(toolInput);
|
|
953
1301
|
|
|
@@ -960,7 +1308,7 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
960
1308
|
((existing.toolUseId != null && toolUseId != null && existing.toolUseId === toolUseId) ||
|
|
961
1309
|
(existing.toolUseId == null &&
|
|
962
1310
|
toolUseId == null &&
|
|
963
|
-
existing.toolName ===
|
|
1311
|
+
existing.toolName === askToolName &&
|
|
964
1312
|
existing.toolInputJson === toolInputJson));
|
|
965
1313
|
|
|
966
1314
|
if (isDuplicateDelivery) {
|
|
@@ -976,7 +1324,7 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
976
1324
|
|
|
977
1325
|
if (existing != null) {
|
|
978
1326
|
Logger.debug(
|
|
979
|
-
`
|
|
1327
|
+
`GkAgentProvider.handleSessionEvent: auto-denying stale permission ${tag} tool=${existing.toolName}`,
|
|
980
1328
|
);
|
|
981
1329
|
this.resolveAllPending(existing, {
|
|
982
1330
|
hookSpecificOutput: {
|
|
@@ -988,7 +1336,7 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
988
1336
|
|
|
989
1337
|
this._pendingPermissions.set(event.sessionId, {
|
|
990
1338
|
resolvers: [{ resolve: resolve, reject: reject }],
|
|
991
|
-
toolName:
|
|
1339
|
+
toolName: askToolName,
|
|
992
1340
|
toolDescription: toolDescription,
|
|
993
1341
|
toolUseId: toolUseId,
|
|
994
1342
|
toolInputJson: toolInputJson,
|
|
@@ -1005,7 +1353,7 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1005
1353
|
|
|
1006
1354
|
const permission: PendingPermission = {
|
|
1007
1355
|
kind: kind,
|
|
1008
|
-
toolName:
|
|
1356
|
+
toolName: askToolName,
|
|
1009
1357
|
toolDescription: toolDescription,
|
|
1010
1358
|
toolInputDescription: toolInputDescription,
|
|
1011
1359
|
suggestions:
|
|
@@ -1023,7 +1371,6 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1023
1371
|
{
|
|
1024
1372
|
// Mirror the blocking payload's shape so the ask renders identically either way —
|
|
1025
1373
|
// just unresolvable, since no blocking hook entry exists to route an answer through.
|
|
1026
|
-
const toolName = (hookInput?.tool_name as string | undefined) ?? event.toolName;
|
|
1027
1374
|
const kind = toolName != null ? classifyPermissionKind(toolName) : undefined;
|
|
1028
1375
|
// Plan-kind body: same session-first preference as the blocking path.
|
|
1029
1376
|
const planFilePath =
|
|
@@ -1051,7 +1398,7 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1051
1398
|
: synthesizeUnresolvableAsk(toolName, planFilePath);
|
|
1052
1399
|
this.updateSessionStatus(event.sessionId, 'permission_requested', {
|
|
1053
1400
|
...eventContext,
|
|
1054
|
-
statusDetail:
|
|
1401
|
+
statusDetail: toolName,
|
|
1055
1402
|
pendingPermission: synthesized,
|
|
1056
1403
|
});
|
|
1057
1404
|
}
|
|
@@ -1059,11 +1406,15 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1059
1406
|
}
|
|
1060
1407
|
|
|
1061
1408
|
case 'PermissionDenied': {
|
|
1062
|
-
this.clearStalePermission(
|
|
1409
|
+
this.clearStalePermission(
|
|
1410
|
+
event.sessionId,
|
|
1411
|
+
'PermissionDenied',
|
|
1412
|
+
this.settledByFromEvent(event, capabilities),
|
|
1413
|
+
);
|
|
1063
1414
|
const bk = this.getBookkeeping(event.sessionId);
|
|
1064
1415
|
bk.activeToolCount = Math.max(0, bk.activeToolCount - 1);
|
|
1065
|
-
const filesChanged = this.untrackToolUseFile(event.sessionId, event);
|
|
1066
|
-
const readsChanged = this.untrackToolUseRead(event.sessionId, event);
|
|
1416
|
+
const filesChanged = this.untrackToolUseFile(event.sessionId, event, capabilities);
|
|
1417
|
+
const readsChanged = this.untrackToolUseRead(event.sessionId, event, capabilities);
|
|
1067
1418
|
// When a parallel tool is still active the next status equals the current one and
|
|
1068
1419
|
// updateSessionStatus short-circuits — without an explicit fire, the dropped path
|
|
1069
1420
|
// would never reach subscribers (treemap activity overlay, WIP decoration).
|
|
@@ -1082,15 +1433,15 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1082
1433
|
const bk = this.getBookkeeping(event.sessionId);
|
|
1083
1434
|
bk.pendingPermission = {
|
|
1084
1435
|
kind: 'elicitation',
|
|
1085
|
-
toolName:
|
|
1086
|
-
toolDescription:
|
|
1436
|
+
toolName: toolName ?? 'Input Required',
|
|
1437
|
+
toolDescription: toolName ?? 'Waiting for input',
|
|
1087
1438
|
// `Elicitation` arrives on a non-blocking hook, so no entry exists for
|
|
1088
1439
|
// `resolvePermission` to answer — the user responds in the agent's own session.
|
|
1089
1440
|
resolvable: false,
|
|
1090
1441
|
};
|
|
1091
1442
|
this.updateSessionStatus(event.sessionId, 'permission_requested', {
|
|
1092
1443
|
...eventContext,
|
|
1093
|
-
statusDetail:
|
|
1444
|
+
statusDetail: toolName,
|
|
1094
1445
|
});
|
|
1095
1446
|
break;
|
|
1096
1447
|
}
|
|
@@ -1127,8 +1478,8 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1127
1478
|
const now = new Date();
|
|
1128
1479
|
const subagent: AgentSession = {
|
|
1129
1480
|
id: event.agentId,
|
|
1130
|
-
providerId:
|
|
1131
|
-
providerName:
|
|
1481
|
+
providerId: capabilities.providerId,
|
|
1482
|
+
providerName: capabilities.displayName,
|
|
1132
1483
|
name: event.agentType ?? 'Subagent',
|
|
1133
1484
|
status: 'thinking',
|
|
1134
1485
|
phase: 'working',
|
|
@@ -1181,6 +1532,22 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1181
1532
|
: `[session=${sessionId.substring(0, 8)}]`;
|
|
1182
1533
|
}
|
|
1183
1534
|
|
|
1535
|
+
/** The owning agent's descriptor for a tracked session, resolved from the session's own
|
|
1536
|
+
* `providerId`. `undefined` when the session isn't tracked (pruned/archived since) or carries a
|
|
1537
|
+
* `providerId` the descriptor table doesn't know — callers pick their own fallback rather than
|
|
1538
|
+
* silently assuming Claude Code. */
|
|
1539
|
+
private getSessionCapabilities(sessionId: string): AgentCapabilities | undefined {
|
|
1540
|
+
const session = this._sessions.find(s => s.id === sessionId);
|
|
1541
|
+
return session != null ? getAgentCapabilitiesByProviderId(session.providerId) : undefined;
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
/** Whether the session's agent leaves a transcript on disk for {@link ClaudeCodeTranscriptReader}
|
|
1545
|
+
* to tail. Fails OPEN when no descriptor resolves, so an unknown/absent `providerId` can never
|
|
1546
|
+
* suppress the Claude path this reader exists for. */
|
|
1547
|
+
private supportsTranscripts(sessionId: string): boolean {
|
|
1548
|
+
return this.getSessionCapabilities(sessionId)?.supportsTranscripts ?? true;
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1184
1551
|
private getBookkeeping(sessionId: string): SessionBookkeeping {
|
|
1185
1552
|
let bk = this._sessionBookkeeping.get(sessionId);
|
|
1186
1553
|
if (bk == null) {
|
|
@@ -1294,8 +1661,8 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1294
1661
|
* for older CLI versions that only fill the top-level fields. Treats an empty-object
|
|
1295
1662
|
* `hookInput.tool_input` as missing — `??` would have kept it (truthy) and shadowed a
|
|
1296
1663
|
* populated `event.toolInput`. */
|
|
1297
|
-
private getEventFilePath(event: AgentSessionEvent): string | undefined {
|
|
1298
|
-
const toolName = (event
|
|
1664
|
+
private getEventFilePath(event: AgentSessionEvent, capabilities: AgentCapabilities): string | undefined {
|
|
1665
|
+
const toolName = canonicalToolNameFromEvent(capabilities, event);
|
|
1299
1666
|
if (toolName == null) return undefined;
|
|
1300
1667
|
|
|
1301
1668
|
const rawHookInput = event.hookInput?.tool_input as Record<string, unknown> | undefined;
|
|
@@ -1335,8 +1702,8 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1335
1702
|
* doesn't reliably surface) so parallel calls bump the count and Post/Pre order doesn't matter.
|
|
1336
1703
|
* Returns whether the parent's published `fileActivity` actually changed so callers can fire
|
|
1337
1704
|
* when the surrounding {@link updateSessionStatus} short-circuits. */
|
|
1338
|
-
private trackToolUseFile(sessionId: string, event: AgentSessionEvent): boolean {
|
|
1339
|
-
const filePath = this.getEventFilePath(event);
|
|
1705
|
+
private trackToolUseFile(sessionId: string, event: AgentSessionEvent, capabilities: AgentCapabilities): boolean {
|
|
1706
|
+
const filePath = this.getEventFilePath(event, capabilities);
|
|
1340
1707
|
if (filePath == null) return false;
|
|
1341
1708
|
|
|
1342
1709
|
const bk = this.getBookkeeping(sessionId);
|
|
@@ -1360,8 +1727,12 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1360
1727
|
* above zero. Returns whether the parent's published `fileActivity` changed (i.e. `editing`
|
|
1361
1728
|
* flipped off) so the PostToolUse caller can fire when its `updateSessionStatus` short-
|
|
1362
1729
|
* circuits (a parallel tool still in flight keeps activeToolCount > 0). */
|
|
1363
|
-
private scheduleClearToolUseFile(
|
|
1364
|
-
|
|
1730
|
+
private scheduleClearToolUseFile(
|
|
1731
|
+
sessionId: string,
|
|
1732
|
+
event: AgentSessionEvent,
|
|
1733
|
+
capabilities: AgentCapabilities,
|
|
1734
|
+
): boolean {
|
|
1735
|
+
const filePath = this.getEventFilePath(event, capabilities);
|
|
1365
1736
|
if (filePath == null) return false;
|
|
1366
1737
|
|
|
1367
1738
|
const bk = this.getBookkeeping(sessionId);
|
|
@@ -1418,8 +1789,8 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1418
1789
|
/** Drops a tracked tool call's file immediately (no cooldown). Used for PermissionDenied,
|
|
1419
1790
|
* where the tool never ran — keeping the "live" badge would be misleading.
|
|
1420
1791
|
* Returns whether the published list changed. */
|
|
1421
|
-
private untrackToolUseFile(sessionId: string, event: AgentSessionEvent): boolean {
|
|
1422
|
-
const filePath = this.getEventFilePath(event);
|
|
1792
|
+
private untrackToolUseFile(sessionId: string, event: AgentSessionEvent, capabilities: AgentCapabilities): boolean {
|
|
1793
|
+
const filePath = this.getEventFilePath(event, capabilities);
|
|
1423
1794
|
if (filePath == null) return false;
|
|
1424
1795
|
|
|
1425
1796
|
const bk = this.getBookkeeping(sessionId);
|
|
@@ -1441,8 +1812,8 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1441
1812
|
}
|
|
1442
1813
|
|
|
1443
1814
|
/** Mirror of {@link getEventFilePath} for read-only file tools. */
|
|
1444
|
-
private getEventReadPath(event: AgentSessionEvent): string | undefined {
|
|
1445
|
-
const toolName = (event
|
|
1815
|
+
private getEventReadPath(event: AgentSessionEvent, capabilities: AgentCapabilities): string | undefined {
|
|
1816
|
+
const toolName = canonicalToolNameFromEvent(capabilities, event);
|
|
1446
1817
|
if (toolName == null) return undefined;
|
|
1447
1818
|
|
|
1448
1819
|
const rawHookInput = event.hookInput?.tool_input as Record<string, unknown> | undefined;
|
|
@@ -1452,8 +1823,8 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1452
1823
|
}
|
|
1453
1824
|
|
|
1454
1825
|
/** Refcount mirror of {@link trackToolUseFile} for read-only file tools. */
|
|
1455
|
-
private trackToolUseRead(sessionId: string, event: AgentSessionEvent): boolean {
|
|
1456
|
-
const filePath = this.getEventReadPath(event);
|
|
1826
|
+
private trackToolUseRead(sessionId: string, event: AgentSessionEvent, capabilities: AgentCapabilities): boolean {
|
|
1827
|
+
const filePath = this.getEventReadPath(event, capabilities);
|
|
1457
1828
|
if (filePath == null) return false;
|
|
1458
1829
|
|
|
1459
1830
|
const bk = this.getBookkeeping(sessionId);
|
|
@@ -1469,8 +1840,12 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1469
1840
|
|
|
1470
1841
|
/** Cooldown mirror of {@link scheduleClearToolUseFile} for read-only file tools. Returns
|
|
1471
1842
|
* whether the parent's published `fileActivity` changed (i.e. `reading` flipped off). */
|
|
1472
|
-
private scheduleClearToolUseRead(
|
|
1473
|
-
|
|
1843
|
+
private scheduleClearToolUseRead(
|
|
1844
|
+
sessionId: string,
|
|
1845
|
+
event: AgentSessionEvent,
|
|
1846
|
+
capabilities: AgentCapabilities,
|
|
1847
|
+
): boolean {
|
|
1848
|
+
const filePath = this.getEventReadPath(event, capabilities);
|
|
1474
1849
|
if (filePath == null) return false;
|
|
1475
1850
|
|
|
1476
1851
|
const bk = this.getBookkeeping(sessionId);
|
|
@@ -1515,8 +1890,8 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1515
1890
|
}
|
|
1516
1891
|
|
|
1517
1892
|
/** Immediate-drop mirror of {@link untrackToolUseFile} for read-only file tools. */
|
|
1518
|
-
private untrackToolUseRead(sessionId: string, event: AgentSessionEvent): boolean {
|
|
1519
|
-
const filePath = this.getEventReadPath(event);
|
|
1893
|
+
private untrackToolUseRead(sessionId: string, event: AgentSessionEvent, capabilities: AgentCapabilities): boolean {
|
|
1894
|
+
const filePath = this.getEventReadPath(event, capabilities);
|
|
1520
1895
|
if (filePath == null) return false;
|
|
1521
1896
|
|
|
1522
1897
|
const bk = this.getBookkeeping(sessionId);
|
|
@@ -1560,6 +1935,8 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1560
1935
|
if (index < 0) return false;
|
|
1561
1936
|
|
|
1562
1937
|
const parent = this._sessions[index];
|
|
1938
|
+
if (parent.status === 'ended') return false;
|
|
1939
|
+
|
|
1563
1940
|
const parentBk = this._sessionBookkeeping.get(parentSessionId);
|
|
1564
1941
|
|
|
1565
1942
|
type Contrib = { editCount: number; readCount: number; editAt?: number; readAt?: number };
|
|
@@ -1637,10 +2014,10 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1637
2014
|
this.cancelPendingIdleTransition(sessionId);
|
|
1638
2015
|
const timer = setTimeout(() => {
|
|
1639
2016
|
this._pendingIdleTimers.delete(sessionId);
|
|
1640
|
-
// Session may have been removed (SessionEnd, prune) during the window, or
|
|
2017
|
+
// Session may have been removed (SessionEnd, prune) during the window, or ended by a
|
|
1641
2018
|
// poll/SessionEnd that raced this timer — reviving a terminal row to idle would zombie it.
|
|
1642
2019
|
const idx = this._sessions.findIndex(s => s.id === sessionId);
|
|
1643
|
-
if (idx < 0 || this._sessions[idx].status === '
|
|
2020
|
+
if (idx < 0 || this._sessions[idx].status === 'ended') return;
|
|
1644
2021
|
|
|
1645
2022
|
this.updateSessionStatus(sessionId, 'idle');
|
|
1646
2023
|
}, stopToIdleDebounceMs);
|
|
@@ -1683,7 +2060,10 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1683
2060
|
* carry `hookInput.tool_use_id` and `hookInput.tool_input` in production; the top-level
|
|
1684
2061
|
* `event.toolName`/`event.toolInput` are the older-CLI fallback. Input is canonicalized so key
|
|
1685
2062
|
* order (or arriving via the fallback field instead of `hookInput`) doesn't defeat comparison. */
|
|
1686
|
-
private settledByFromEvent(
|
|
2063
|
+
private settledByFromEvent(
|
|
2064
|
+
event: AgentSessionEvent,
|
|
2065
|
+
capabilities: AgentCapabilities,
|
|
2066
|
+
): {
|
|
1687
2067
|
toolUseId?: string;
|
|
1688
2068
|
toolName?: string;
|
|
1689
2069
|
toolInputJson?: string;
|
|
@@ -1692,7 +2072,9 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1692
2072
|
const toolInput = (hookInput?.tool_input as Record<string, unknown> | undefined) ?? event.toolInput;
|
|
1693
2073
|
return {
|
|
1694
2074
|
toolUseId: hookInput?.tool_use_id as string | undefined,
|
|
1695
|
-
toolName
|
|
2075
|
+
// Canonical, because the registered ask's `toolName` is canonical too — comparing a native
|
|
2076
|
+
// name against it would look like an identity mismatch and strand the ask.
|
|
2077
|
+
toolName: canonicalToolNameFromEvent(capabilities, event),
|
|
1696
2078
|
toolInputJson: toolInput != null ? stableStringify(toolInput) : undefined,
|
|
1697
2079
|
};
|
|
1698
2080
|
}
|
|
@@ -1751,13 +2133,13 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1751
2133
|
|
|
1752
2134
|
if (mismatch) {
|
|
1753
2135
|
Logger.debug(
|
|
1754
|
-
`
|
|
2136
|
+
`GkAgentProvider.clearStalePermission: skip (identity mismatch) ${reason} ${this.sessionTag(sessionId)} pending=${pending.toolUseId ?? pending.toolName} settledBy=${settledBy.toolUseId ?? settledBy.toolName ?? 'unknown'}`,
|
|
1755
2137
|
);
|
|
1756
2138
|
return;
|
|
1757
2139
|
}
|
|
1758
2140
|
}
|
|
1759
2141
|
|
|
1760
|
-
Logger.debug(`
|
|
2142
|
+
Logger.debug(`GkAgentProvider.clearStalePermission: ${reason} ${this.sessionTag(sessionId)}`);
|
|
1761
2143
|
this.resolveAllPending(pending, {
|
|
1762
2144
|
hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: { behavior: 'deny' } },
|
|
1763
2145
|
});
|
|
@@ -1774,12 +2156,12 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1774
2156
|
settledBy.toolName !== bk.pendingPermission.toolName
|
|
1775
2157
|
) {
|
|
1776
2158
|
Logger.debug(
|
|
1777
|
-
`
|
|
2159
|
+
`GkAgentProvider.clearStalePermission: skip (display-only identity mismatch) ${reason} ${this.sessionTag(sessionId)} pending=${bk.pendingPermission.toolName} settledBy=${settledBy.toolName}`,
|
|
1778
2160
|
);
|
|
1779
2161
|
return;
|
|
1780
2162
|
}
|
|
1781
2163
|
|
|
1782
|
-
Logger.debug(`
|
|
2164
|
+
Logger.debug(`GkAgentProvider.clearStalePermission: ${reason} ${this.sessionTag(sessionId)}`);
|
|
1783
2165
|
bk.pendingPermission = undefined;
|
|
1784
2166
|
}
|
|
1785
2167
|
|
|
@@ -1788,11 +2170,22 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1788
2170
|
decision: PermissionDecision,
|
|
1789
2171
|
updatedPermissions?: PermissionSuggestion[],
|
|
1790
2172
|
): boolean {
|
|
2173
|
+
// Explicit refusal rather than an incidental one: an agent we can't answer for never registers
|
|
2174
|
+
// a pending entry, so the `pending == null` check below would already return `false` — but the
|
|
2175
|
+
// caller deserves to know WHY, and the guard has to survive a future entry-registering path.
|
|
2176
|
+
const capabilities = this.getSessionCapabilities(sessionId);
|
|
2177
|
+
if (capabilities != null && !canAnswerBlockingPermission(capabilities)) {
|
|
2178
|
+
Logger.debug(
|
|
2179
|
+
`GkAgentProvider.resolvePermission: refusing ${decision} for ${this.sessionTag(sessionId)} — ${capabilities.displayName} asks are observe-only`,
|
|
2180
|
+
);
|
|
2181
|
+
return false;
|
|
2182
|
+
}
|
|
2183
|
+
|
|
1791
2184
|
const pending = this._pendingPermissions.get(sessionId);
|
|
1792
2185
|
if (pending == null) return false;
|
|
1793
2186
|
|
|
1794
2187
|
Logger.debug(
|
|
1795
|
-
`
|
|
2188
|
+
`GkAgentProvider.resolvePermission: ${decision} ${this.sessionTag(sessionId)} tool=${pending.toolName}`,
|
|
1796
2189
|
);
|
|
1797
2190
|
this.resolveAllPending(pending, {
|
|
1798
2191
|
hookSpecificOutput: {
|
|
@@ -1801,7 +2194,7 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1801
2194
|
},
|
|
1802
2195
|
});
|
|
1803
2196
|
this.callbacks.onPermissionResolved?.({
|
|
1804
|
-
provider:
|
|
2197
|
+
provider: capabilities?.providerId ?? claudeCodeCapabilities.providerId,
|
|
1805
2198
|
tool: pending.toolName,
|
|
1806
2199
|
decision: decision,
|
|
1807
2200
|
});
|
|
@@ -1817,19 +2210,19 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1817
2210
|
|
|
1818
2211
|
async archiveSession(sessionId: string): Promise<boolean> {
|
|
1819
2212
|
// Authoritative status guard: the CLI's `archive-session` ends an active session first, so a
|
|
1820
|
-
// stale webview action (or a row that resumed out of `
|
|
2213
|
+
// stale webview action (or a row that resumed out of `ended` since the click) must never
|
|
1821
2214
|
// reach the CLI — archiving is only ever valid on a terminal row. Callers gate the UI too,
|
|
1822
2215
|
// but this is the last line that owns live process safety. Returns `false` when refused so the
|
|
1823
2216
|
// host doesn't record a success telemetry event for an archive that never happened.
|
|
1824
2217
|
const session = this._sessions.find(s => s.id === sessionId);
|
|
1825
|
-
if (session != null && session.status !== '
|
|
2218
|
+
if (session != null && session.status !== 'ended') {
|
|
1826
2219
|
Logger.warn(
|
|
1827
|
-
`
|
|
2220
|
+
`GkAgentProvider.archiveSession: refusing to archive non-ended ${this.sessionTag(sessionId)} (status=${session.status})`,
|
|
1828
2221
|
);
|
|
1829
2222
|
return false;
|
|
1830
2223
|
}
|
|
1831
2224
|
|
|
1832
|
-
Logger.debug(`
|
|
2225
|
+
Logger.debug(`GkAgentProvider.archiveSession: ${this.sessionTag(sessionId)}`);
|
|
1833
2226
|
await this.callbacks.runCLICommand(['ai', 'hook', 'archive-session', sessionId, '--json']);
|
|
1834
2227
|
// Invalidate the archived-id cache so the next `getPastSessions` re-fetches and excludes this id.
|
|
1835
2228
|
this._archivedSessionIdsCache = undefined;
|
|
@@ -1877,37 +2270,70 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1877
2270
|
}
|
|
1878
2271
|
}
|
|
1879
2272
|
|
|
1880
|
-
|
|
2273
|
+
resolveEndedSessionDetails(sessionId: string): void {
|
|
1881
2274
|
const session = this._sessions.find(s => s.id === sessionId);
|
|
1882
|
-
// Only
|
|
1883
|
-
if (session?.status !== '
|
|
2275
|
+
// Only ended sessions defer resolution; live sessions resolve eagerly on discovery.
|
|
2276
|
+
if (session?.status !== 'ended') return;
|
|
1884
2277
|
|
|
1885
2278
|
// The durable store drops the prompt (and only ever carries an auto-slug name) for most ended
|
|
1886
2279
|
// sessions, so read the terminal transcript once on demand to recover a real title + the
|
|
1887
2280
|
// first/last prompt. Both the git probe and transcript read sit under the guard so repeated
|
|
1888
2281
|
// opens don't re-probe git or re-read a possibly-large file.
|
|
1889
2282
|
const bk = this.getBookkeeping(sessionId);
|
|
1890
|
-
if (bk.
|
|
2283
|
+
if (bk.endedDetailsResolved) return;
|
|
1891
2284
|
|
|
1892
|
-
bk.
|
|
2285
|
+
bk.endedDetailsResolved = true;
|
|
1893
2286
|
|
|
1894
2287
|
if (session.cwd) {
|
|
1895
2288
|
void this.resolveGitInfo(sessionId, session.cwd);
|
|
1896
2289
|
}
|
|
1897
|
-
|
|
2290
|
+
// Git identity resolves for every agent; the transcript read is Claude-only (see
|
|
2291
|
+
// `resolveTranscriptTitles`).
|
|
2292
|
+
if (this.supportsTranscripts(sessionId)) {
|
|
2293
|
+
void this.applyEndedTranscriptDetails(sessionId, session.cwd);
|
|
2294
|
+
}
|
|
1898
2295
|
}
|
|
1899
2296
|
|
|
1900
|
-
|
|
2297
|
+
/** No `_sessions` gate, unlike {@link resolveEndedSessionDetails} — must work for a
|
|
2298
|
+
* transcript-only id this window never tracked live (past-session-sheet callers). */
|
|
2299
|
+
async resolveSessionDetails(sessionId: string, cwd?: string): Promise<EndedTranscriptDetails | undefined> {
|
|
2300
|
+
// The reader only reads Claude Code's store, so an agent that leaves no transcript can only
|
|
2301
|
+
// ever miss here. Reachable rather than theoretical: an ended session of ANY agent becomes a
|
|
2302
|
+
// Past row, and opening its sheet routes here by `providerId`. `supportsTranscripts` fails
|
|
2303
|
+
// open, so the transcript-only ids this method exists for — untracked, therefore capability-
|
|
2304
|
+
// less, and Claude's by construction since only its store produces them — still resolve.
|
|
2305
|
+
if (!this.supportsTranscripts(sessionId)) return undefined;
|
|
2306
|
+
|
|
2307
|
+
let cached = this._sessionDetailsCache.get(sessionId);
|
|
2308
|
+
if (cached == null) {
|
|
2309
|
+
cached = this._transcriptReader.resolveEndedDetails(sessionId, cwd).catch((ex: unknown) => {
|
|
2310
|
+
// A read error (transient I/O, permissions) is not terminal state — drop the cache
|
|
2311
|
+
// entry so a later call retries. A resolved `undefined` IS terminal (no transcript on
|
|
2312
|
+
// disk) and stays cached.
|
|
2313
|
+
this._sessionDetailsCache.delete(sessionId);
|
|
2314
|
+
throw ex;
|
|
2315
|
+
});
|
|
2316
|
+
this._sessionDetailsCache.set(sessionId, cached);
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
try {
|
|
2320
|
+
return await cached;
|
|
2321
|
+
} catch {
|
|
2322
|
+
return undefined;
|
|
2323
|
+
}
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
private async applyEndedTranscriptDetails(sessionId: string, cwd: string | undefined): Promise<void> {
|
|
1901
2327
|
let details;
|
|
1902
2328
|
try {
|
|
1903
|
-
details = await this._transcriptReader.
|
|
2329
|
+
details = await this._transcriptReader.resolveEndedDetails(sessionId, cwd);
|
|
1904
2330
|
} catch {
|
|
1905
2331
|
// A read error (transient I/O, permissions) is not terminal state — clear the once-only
|
|
1906
2332
|
// guard so a later open retries. A `undefined` result IS terminal (no transcript on disk),
|
|
1907
2333
|
// so it keeps the guard and never re-scans.
|
|
1908
2334
|
const bk = this._sessionBookkeeping.get(sessionId);
|
|
1909
2335
|
if (bk != null) {
|
|
1910
|
-
bk.
|
|
2336
|
+
bk.endedDetailsResolved = false;
|
|
1911
2337
|
}
|
|
1912
2338
|
return;
|
|
1913
2339
|
}
|
|
@@ -1918,7 +2344,7 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
1918
2344
|
|
|
1919
2345
|
const session = this._sessions[index];
|
|
1920
2346
|
// The session may have been archived/removed while we were reading.
|
|
1921
|
-
if (session.status !== '
|
|
2347
|
+
if (session.status !== 'ended') return;
|
|
1922
2348
|
|
|
1923
2349
|
const { titles } = details;
|
|
1924
2350
|
const nextTitles =
|
|
@@ -2069,8 +2495,19 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
2069
2495
|
}
|
|
2070
2496
|
|
|
2071
2497
|
private ensureSession(sessionId: string, context?: SessionContext): { index: number; changed: boolean } {
|
|
2072
|
-
const {
|
|
2073
|
-
|
|
2498
|
+
const {
|
|
2499
|
+
capabilities,
|
|
2500
|
+
pid,
|
|
2501
|
+
workspacePath,
|
|
2502
|
+
isInWorkspace,
|
|
2503
|
+
cwd,
|
|
2504
|
+
initialCwd,
|
|
2505
|
+
planFile,
|
|
2506
|
+
sessionName,
|
|
2507
|
+
model,
|
|
2508
|
+
worktreePath,
|
|
2509
|
+
visitedWorktreePaths,
|
|
2510
|
+
} = context ?? {};
|
|
2074
2511
|
|
|
2075
2512
|
let index = this._sessions.findIndex(s => s.id === sessionId);
|
|
2076
2513
|
if (index < 0) {
|
|
@@ -2078,11 +2515,16 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
2078
2515
|
// Same worktree-derived fallback as the update branch below: a session created with a
|
|
2079
2516
|
// scratch cwd but a CLI-seated worktree still belongs to that worktree's workspace.
|
|
2080
2517
|
const createdWorkspacePath = workspacePath ?? this.resolveWorkspacePath(worktreePath);
|
|
2518
|
+
// Identity comes from the owning agent's descriptor, never from the provider — Claude's
|
|
2519
|
+
// `providerId` stays the `claudeCode` the descriptor encodes. The fallback covers the
|
|
2520
|
+
// deferred-commit paths (see `SessionContext.capabilities`), which only ever run against a
|
|
2521
|
+
// row that already exists; reaching it would mean a row created outside every event path.
|
|
2522
|
+
const identity = capabilities ?? claudeCodeCapabilities;
|
|
2081
2523
|
index = this._sessions.length;
|
|
2082
2524
|
this._sessions.push({
|
|
2083
2525
|
id: sessionId,
|
|
2084
|
-
providerId:
|
|
2085
|
-
providerName:
|
|
2526
|
+
providerId: identity.providerId,
|
|
2527
|
+
providerName: identity.displayName,
|
|
2086
2528
|
name: sessionName || undefined,
|
|
2087
2529
|
status: 'idle',
|
|
2088
2530
|
phase: getPhaseForStatus('idle'),
|
|
@@ -2097,9 +2539,10 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
2097
2539
|
isInWorkspace: createdWorkspacePath != null ? true : (isInWorkspace ?? false),
|
|
2098
2540
|
model: model,
|
|
2099
2541
|
worktreePath: worktreePath != null ? normalizePath(worktreePath) : undefined,
|
|
2542
|
+
visitedWorktreePaths: unionVisited(undefined, visitedWorktreePaths, worktreePath),
|
|
2100
2543
|
});
|
|
2101
2544
|
Logger.debug(
|
|
2102
|
-
`
|
|
2545
|
+
`GkAgentProvider.ensureSession: implicitly created ${this.sessionTag(sessionId, sessionName ?? 'unnamed')}`,
|
|
2103
2546
|
);
|
|
2104
2547
|
if (worktreePath != null) {
|
|
2105
2548
|
this.getBookkeeping(sessionId).cliSeatedWorktree = true;
|
|
@@ -2129,6 +2572,11 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
2129
2572
|
// present, so it seats ahead of the `resolveGitInfo` probe below (which still fills
|
|
2130
2573
|
// `commonPath` and remains the fallback for older CLIs that don't send `cwdTimeline`).
|
|
2131
2574
|
const updatedWorktreePath = worktreePath != null ? normalizePath(worktreePath) : existing.worktreePath;
|
|
2575
|
+
const updatedVisitedWorktreePaths = unionVisited(
|
|
2576
|
+
existing.visitedWorktreePaths,
|
|
2577
|
+
visitedWorktreePaths,
|
|
2578
|
+
updatedWorktreePath,
|
|
2579
|
+
);
|
|
2132
2580
|
// Workspace membership follows the ATTRIBUTION, not the raw cwd: a scratch-dir excursion
|
|
2133
2581
|
// (cwd in /tmp, worktree kept) stays in-workspace via its worktree, while a genuine move to
|
|
2134
2582
|
// a repo outside the workspace honestly drops membership when events carry a cwd.
|
|
@@ -2162,7 +2610,8 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
2162
2610
|
updatedModel !== existing.model ||
|
|
2163
2611
|
updatedCwd !== existing.cwd ||
|
|
2164
2612
|
updatedInitialCwd !== existing.initialCwd ||
|
|
2165
|
-
updatedWorktreePath !== existing.worktreePath
|
|
2613
|
+
updatedWorktreePath !== existing.worktreePath ||
|
|
2614
|
+
updatedVisitedWorktreePaths !== existing.visitedWorktreePaths
|
|
2166
2615
|
) {
|
|
2167
2616
|
this._sessions[index] = {
|
|
2168
2617
|
...existing,
|
|
@@ -2175,6 +2624,7 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
2175
2624
|
planFile: updatedPlanFile,
|
|
2176
2625
|
isInWorkspace: updatedIsInWorkspace,
|
|
2177
2626
|
worktreePath: updatedWorktreePath,
|
|
2627
|
+
visitedWorktreePaths: updatedVisitedWorktreePaths,
|
|
2178
2628
|
// A worktree move can cross repos, and the spread would otherwise carry the old repo
|
|
2179
2629
|
// identity forward — which consumers prefer over the new worktree, pinning the card to
|
|
2180
2630
|
// the wrong repo. Drop it; the probe below refills it. Mirrors the poll's ended-record
|
|
@@ -2204,7 +2654,12 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
2204
2654
|
// call per hook event for an answer we already have. A first seat (no prior worktree) still
|
|
2205
2655
|
// counts as a location change: the move may be out of a non-git dir whose probe latched
|
|
2206
2656
|
// `gitInfoUnresolvable`, and `commonPath` still needs the probe.
|
|
2207
|
-
|
|
2657
|
+
//
|
|
2658
|
+
// A worktree move is a location change on its own, even with the SAME cwd (cwdTimeline
|
|
2659
|
+
// updated, cwd unchanged) — the `commonPath` drop above (`worktreeMoved ? undefined : …`)
|
|
2660
|
+
// needs the probe to refill it, and without `worktreeMoved` here nothing else would fire it.
|
|
2661
|
+
const locationNeedsProbe =
|
|
2662
|
+
worktreeMoved || (cwdMoved && (worktreePath == null || existing.worktreePath == null));
|
|
2208
2663
|
if (locationNeedsProbe) {
|
|
2209
2664
|
this.getBookkeeping(sessionId).gitInfoUnresolvable = false;
|
|
2210
2665
|
}
|
|
@@ -2246,10 +2701,14 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
2246
2701
|
this._resolveGitInfoInFlight.set(sessionId, cwd);
|
|
2247
2702
|
try {
|
|
2248
2703
|
let info: Awaited<ReturnType<typeof resolveGitInfo>> | undefined;
|
|
2704
|
+
let failed = false;
|
|
2249
2705
|
try {
|
|
2250
2706
|
info = await resolveGitInfo(cwd);
|
|
2251
2707
|
} catch {
|
|
2252
|
-
//
|
|
2708
|
+
// A transient git/spawn failure, distinct from a confirmed "not a repo" (`info ==
|
|
2709
|
+
// null` below). Must not latch `gitInfoUnresolvable` — the next hook event retries
|
|
2710
|
+
// via `ensureSession`'s `commonPath == null` check.
|
|
2711
|
+
failed = true;
|
|
2253
2712
|
info = undefined;
|
|
2254
2713
|
}
|
|
2255
2714
|
|
|
@@ -2261,6 +2720,8 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
2261
2720
|
// below re-runs for the newest cwd.
|
|
2262
2721
|
if (this._resolveGitInfoPending.has(sessionId)) return;
|
|
2263
2722
|
|
|
2723
|
+
if (failed) return;
|
|
2724
|
+
|
|
2264
2725
|
const session = this._sessions[index];
|
|
2265
2726
|
|
|
2266
2727
|
// `workspacePath` stays as the matched workspace folder (or undefined). Repo identity
|
|
@@ -2278,8 +2739,51 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
2278
2739
|
// followed by a new session start).
|
|
2279
2740
|
if (info == null) {
|
|
2280
2741
|
this.getBookkeeping(sessionId).gitInfoUnresolvable = true;
|
|
2281
|
-
|
|
2282
|
-
|
|
2742
|
+
|
|
2743
|
+
// The cwd itself isn't a repo, but a CLI-seated worktree (from `cwdTimeline`, see
|
|
2744
|
+
// `worktreeFromEvent`) may still name one — e.g. a scratch cwd like `/tmp/x` whose
|
|
2745
|
+
// session the CLI attributed to a real worktree. Probe the worktree root inline, in
|
|
2746
|
+
// the same in-flight window, so a scratch cwd doesn't leave `commonPath` permanently
|
|
2747
|
+
// unresolved.
|
|
2748
|
+
let worktreeInfo: Awaited<ReturnType<typeof resolveGitInfo>> | undefined;
|
|
2749
|
+
let liveIndex = index;
|
|
2750
|
+
let liveSession = session;
|
|
2751
|
+
if (session.worktreePath != null && session.worktreePath !== cwd && session.commonPath == null) {
|
|
2752
|
+
try {
|
|
2753
|
+
worktreeInfo = await resolveGitInfo(session.worktreePath);
|
|
2754
|
+
} catch {
|
|
2755
|
+
worktreeInfo = undefined;
|
|
2756
|
+
}
|
|
2757
|
+
|
|
2758
|
+
// Re-find and re-read after this second await, for both the success and
|
|
2759
|
+
// fall-through writes below — the array can have been reassigned/spliced (e.g.
|
|
2760
|
+
// `pruneDeadSessions`), or the session's own fields updated, while the probe ran.
|
|
2761
|
+
liveIndex = this._sessions.findIndex(s => s.id === sessionId);
|
|
2762
|
+
if (liveIndex < 0) return;
|
|
2763
|
+
|
|
2764
|
+
if (this._resolveGitInfoPending.has(sessionId)) return;
|
|
2765
|
+
|
|
2766
|
+
liveSession = this._sessions[liveIndex];
|
|
2767
|
+
}
|
|
2768
|
+
|
|
2769
|
+
if (worktreeInfo != null) {
|
|
2770
|
+
const worktreeFirstResolve = liveSession.initialCommonPath == null;
|
|
2771
|
+
this._sessions[liveIndex] = {
|
|
2772
|
+
...liveSession,
|
|
2773
|
+
cwd: cwd,
|
|
2774
|
+
commonPath: worktreeInfo.repoRoot,
|
|
2775
|
+
initialWorktreePath: worktreeFirstResolve
|
|
2776
|
+
? liveSession.worktreePath
|
|
2777
|
+
: liveSession.initialWorktreePath,
|
|
2778
|
+
initialCommonPath: worktreeFirstResolve ? worktreeInfo.repoRoot : liveSession.initialCommonPath,
|
|
2779
|
+
visitedWorktreePaths: unionVisited(liveSession.visitedWorktreePaths, liveSession.worktreePath),
|
|
2780
|
+
};
|
|
2781
|
+
this._onDidChangeSessions.fire();
|
|
2782
|
+
return;
|
|
2783
|
+
}
|
|
2784
|
+
|
|
2785
|
+
if (cwd !== liveSession.cwd) {
|
|
2786
|
+
this._sessions[liveIndex] = { ...liveSession, cwd: cwd };
|
|
2283
2787
|
this._onDidChangeSessions.fire();
|
|
2284
2788
|
}
|
|
2285
2789
|
return;
|
|
@@ -2316,6 +2820,7 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
2316
2820
|
commonPath: info.repoRoot,
|
|
2317
2821
|
initialWorktreePath: updatedInitialWorktreePath,
|
|
2318
2822
|
initialCommonPath: updatedInitialCommonPath,
|
|
2823
|
+
visitedWorktreePaths: unionVisited(session.visitedWorktreePaths, effectiveWorktreePath),
|
|
2319
2824
|
};
|
|
2320
2825
|
this._onDidChangeSessions.fire();
|
|
2321
2826
|
}
|
|
@@ -2333,6 +2838,10 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
2333
2838
|
}
|
|
2334
2839
|
|
|
2335
2840
|
private async resolveTranscriptTitles(sessionId: string, cwd: string | undefined): Promise<void> {
|
|
2841
|
+
// The reader only understands Claude Code's `~/.claude/projects/**` JSONL store, so for any
|
|
2842
|
+
// other agent this would be a guaranteed-miss directory scan on every event and poll.
|
|
2843
|
+
if (!this.supportsTranscripts(sessionId)) return;
|
|
2844
|
+
|
|
2336
2845
|
let titles;
|
|
2337
2846
|
try {
|
|
2338
2847
|
titles = await this._transcriptReader.resolve(sessionId, cwd);
|
|
@@ -2355,25 +2864,22 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
2355
2864
|
this._onDidChangeSessions.fire();
|
|
2356
2865
|
}
|
|
2357
2866
|
|
|
2358
|
-
/** Transitions the session at `index` to the terminal `
|
|
2867
|
+
/** Transitions the session at `index` to the terminal `ended` state in place: keeps the row,
|
|
2359
2868
|
* stamps `endedAt` as its activity time, clears transient state (statusDetail, pendingPermission,
|
|
2360
2869
|
* subagents), and tears down its bookkeeping/timers. Shared by the live `SessionEnd` path and the
|
|
2361
2870
|
* poll backstop for a `SessionEnd` the live path missed. Does not fire change events — the caller
|
|
2362
2871
|
* batches those. */
|
|
2363
|
-
private
|
|
2872
|
+
private endSession(index: number, endedAt: Date): void {
|
|
2364
2873
|
const prev = this._sessions[index];
|
|
2365
2874
|
this._sessions[index] = {
|
|
2366
2875
|
...prev,
|
|
2367
|
-
status: '
|
|
2368
|
-
phase: '
|
|
2876
|
+
status: 'ended',
|
|
2877
|
+
phase: 'ended',
|
|
2369
2878
|
phaseSince: endedAt,
|
|
2370
2879
|
lastActivity: endedAt,
|
|
2371
2880
|
statusDetail: undefined,
|
|
2372
2881
|
pendingPermission: undefined,
|
|
2373
2882
|
subagents: undefined,
|
|
2374
|
-
// A terminal row carries no live working state — drop the file-activity heatmap so
|
|
2375
|
-
// consumers reading it directly don't render a frozen tail for a finished session.
|
|
2376
|
-
fileActivity: undefined,
|
|
2377
2883
|
};
|
|
2378
2884
|
// Terminal teardown, owned here so every caller gets it — the live `SessionEnd` path does
|
|
2379
2885
|
// this before calling us, but the poll's missed-SessionEnd transition doesn't: reject any
|
|
@@ -2382,7 +2888,7 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
2382
2888
|
// live zombie).
|
|
2383
2889
|
const pending = this._pendingPermissions.get(prev.id);
|
|
2384
2890
|
if (pending != null) {
|
|
2385
|
-
this.rejectAllPending(pending, new Error('Session
|
|
2891
|
+
this.rejectAllPending(pending, new Error('Session ended'));
|
|
2386
2892
|
this._pendingPermissions.delete(prev.id);
|
|
2387
2893
|
}
|
|
2388
2894
|
this.cancelPendingIdleTransition(prev.id);
|
|
@@ -2394,15 +2900,30 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
2394
2900
|
}
|
|
2395
2901
|
this._sessionBookkeeping.delete(prev.id);
|
|
2396
2902
|
this._transcriptReader.forget(prev.id);
|
|
2903
|
+
this.noteTerminalTransition();
|
|
2397
2904
|
}
|
|
2398
2905
|
|
|
2399
2906
|
private pruneDeadSessions(): boolean {
|
|
2400
2907
|
const kept: AgentSession[] = [];
|
|
2401
2908
|
const removedIds: string[] = [];
|
|
2402
2909
|
for (const s of this._sessions) {
|
|
2403
|
-
//
|
|
2910
|
+
// Ended sessions are terminal and durable — their PID is usually dead by design, so
|
|
2404
2911
|
// liveness pruning must never drop them (they leave only via poll reconciliation).
|
|
2405
|
-
if (s.status === '
|
|
2912
|
+
if (s.status === 'ended') {
|
|
2913
|
+
kept.push(s);
|
|
2914
|
+
continue;
|
|
2915
|
+
}
|
|
2916
|
+
|
|
2917
|
+
// An agent that multiplexes concurrent sessions in one process (Codex) reports the SAME
|
|
2918
|
+
// pid for all of them, so the pid answers "is the host process alive", not "is this
|
|
2919
|
+
// session alive" — in both directions. A live shared process would pin every session it
|
|
2920
|
+
// ever hosted, and a dead one would reap live siblings along with the finished session.
|
|
2921
|
+
// Neither is recoverable here, so pid liveness is not evidence for these rows at all:
|
|
2922
|
+
// keep them and let the paths that DO identify a session end them — a `SessionEnd`, or the
|
|
2923
|
+
// poll seeing the CLI's durable `ended` record (which the CLI writes for a stale session
|
|
2924
|
+
// of its own accord). The CLI takes the same position: for `pidSharingClients` a pid-only
|
|
2925
|
+
// match is a guess, so it keeps such an end revivable.
|
|
2926
|
+
if (getAgentCapabilitiesByProviderId(s.providerId)?.sharesPids === true) {
|
|
2406
2927
|
kept.push(s);
|
|
2407
2928
|
continue;
|
|
2408
2929
|
}
|
|
@@ -2438,8 +2959,9 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
2438
2959
|
this._sessionBookkeeping.delete(id);
|
|
2439
2960
|
this._transcriptReader.forget(id);
|
|
2440
2961
|
}
|
|
2962
|
+
this.noteTerminalTransition();
|
|
2441
2963
|
Logger.debug(
|
|
2442
|
-
`
|
|
2964
|
+
`GkAgentProvider.syncSessions: removed ${removedIds.length} stale session(s): ${removedIds.map(id => id.substring(0, 8)).join(', ')}`,
|
|
2443
2965
|
);
|
|
2444
2966
|
return true;
|
|
2445
2967
|
}
|
|
@@ -2451,13 +2973,13 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
2451
2973
|
// sessions exist — that's the only local backstop for pruning agents that die without firing
|
|
2452
2974
|
// `SessionEnd`, and it keeps us correct even if hook detection is stale/wrong. The bootstrap
|
|
2453
2975
|
// call in `ensureIpcServer` passes no options, so cold-start discovery always runs.
|
|
2454
|
-
// Deliberately counts ALL tracked sessions,
|
|
2976
|
+
// Deliberately counts ALL tracked sessions, ended included. An ended row is something
|
|
2455
2977
|
// we have to reconcile — it's removed only when the CLI stops listing it (archived from
|
|
2456
2978
|
// another window, or aged out) — and this poll is the sole mechanism that does so. It's also
|
|
2457
2979
|
// what triggers the CLI's own 30-day retention sweep, which runs on `list-sessions` and
|
|
2458
2980
|
// nothing else: no timer, no daemon. Gating it out to save a spawn wouldn't avoid work, it
|
|
2459
2981
|
// would strand the rows on screen AND the records on disk until the window reloads.
|
|
2460
|
-
if (options?.gate && this._sessions.length === 0 && !this.
|
|
2982
|
+
if (options?.gate && this._sessions.length === 0 && !this._hooksInstalled) return;
|
|
2461
2983
|
|
|
2462
2984
|
// Narrowly: hooks off AND nothing running, i.e. we hold only terminal history. There's no
|
|
2463
2985
|
// dying process to reap and no live state to correct — just rows to reconcile and the
|
|
@@ -2467,16 +2989,16 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
2467
2989
|
// session whose hook events never reached our IPC server.
|
|
2468
2990
|
if (
|
|
2469
2991
|
options?.gate &&
|
|
2470
|
-
!this.
|
|
2471
|
-
!this._sessions.some(s => s.status !== '
|
|
2992
|
+
!this._hooksInstalled &&
|
|
2993
|
+
!this._sessions.some(s => s.status !== 'ended') &&
|
|
2472
2994
|
Date.now() - this._lastSyncAt < idleReconcileIntervalMs
|
|
2473
2995
|
) {
|
|
2474
2996
|
return;
|
|
2475
2997
|
}
|
|
2476
2998
|
|
|
2477
2999
|
// Stamped BEFORE the CLI call so the reconciliation below can tell "this poll's snapshot was
|
|
2478
|
-
// taken after we
|
|
2479
|
-
//
|
|
3000
|
+
// taken after we ended the row" from "an in-flight poll that predates it" — see the
|
|
3001
|
+
// ended-removal filter.
|
|
2480
3002
|
const pollStartedAt = Date.now();
|
|
2481
3003
|
this._lastSyncAt = pollStartedAt;
|
|
2482
3004
|
|
|
@@ -2486,7 +3008,7 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
2486
3008
|
try {
|
|
2487
3009
|
// `--status active,ended` surfaces both live sessions and the CLI's durable ended records
|
|
2488
3010
|
// (excluding `archived`). Newer CLIs default `list-sessions` to active-only, so the flag
|
|
2489
|
-
// is required to see
|
|
3011
|
+
// is required to see ended sessions at all.
|
|
2490
3012
|
output = await this.callbacks.runCLICommand([
|
|
2491
3013
|
'ai',
|
|
2492
3014
|
'hook',
|
|
@@ -2506,7 +3028,13 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
2506
3028
|
output = await this.callbacks.runCLICommand(['ai', 'hook', 'list-sessions', '--json']);
|
|
2507
3029
|
}
|
|
2508
3030
|
sessions = JSON.parse(output) as SessionFileData[];
|
|
2509
|
-
} catch {
|
|
3031
|
+
} catch (ex) {
|
|
3032
|
+
// Expected whenever the CLI is missing or still installing, so not an error — but log it:
|
|
3033
|
+
// every session in the window silently stops reconciling until the next tick, and without
|
|
3034
|
+
// this the symptom ("no agents anywhere") is indistinguishable from having no agents.
|
|
3035
|
+
Logger.debug(
|
|
3036
|
+
`GkAgentProvider.syncSessions: poll skipped — ${ex instanceof Error ? ex.message : String(ex)}`,
|
|
3037
|
+
);
|
|
2510
3038
|
if (this.pruneDeadSessions()) {
|
|
2511
3039
|
this._onDidChangeSessions.fire();
|
|
2512
3040
|
}
|
|
@@ -2516,27 +3044,66 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
2516
3044
|
// Snapshot what the live IPC path had already tracked, so we can detect drift between it and
|
|
2517
3045
|
// what the poll returns. Ideally they match exactly — any difference means the live push path
|
|
2518
3046
|
// missed an add (poll discovers it) or a teardown (poll no longer reports a session we track).
|
|
2519
|
-
// Drift is measured over *live* sessions only;
|
|
3047
|
+
// Drift is measured over *live* sessions only; ended sessions are durable history, so their
|
|
2520
3048
|
// absence from `polledAlive` is expected and must not skew the discrepancy signal.
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
//
|
|
2524
|
-
//
|
|
3049
|
+
//
|
|
3050
|
+
// Both maps carry the owning agent's `providerId` per session so the report below can be split
|
|
3051
|
+
// per agent: the two paths' reliability differs by agent (one may never reach the live path at
|
|
3052
|
+
// all), and one pooled number would average that away into noise.
|
|
3053
|
+
const trackedLiveBefore = new Map(
|
|
3054
|
+
this._sessions.filter(s => s.status !== 'ended').map(s => [s.id, s.providerId] as const),
|
|
3055
|
+
);
|
|
3056
|
+
const polledAlive = new Map<string, string>();
|
|
3057
|
+
// Every id the poll returned (live *and* ended). Drives ended-session reconciliation:
|
|
3058
|
+
// a tracked ended session absent from this set was archived/purged by the CLI.
|
|
2525
3059
|
const polledIds = new Set<string>();
|
|
2526
3060
|
// Accumulates ids already tracked or added during this poll, so the membership check is O(1)
|
|
2527
3061
|
// and duplicate ids within a single response are only added once.
|
|
2528
3062
|
const known = new Set(this._sessions.map(s => s.id));
|
|
2529
3063
|
|
|
3064
|
+
// The CLI's durable store never revives a resumed session — once it moves a record to its
|
|
3065
|
+
// `ended` store, nothing moves it back, so a record reporting `ended` isn't proof the session
|
|
3066
|
+
// is over. The listing also repairs the two active-record cases that have no fresher hook state:
|
|
3067
|
+
// first discovery and synthesized, unresolvable permission asks. Fetched once per poll (not per
|
|
3068
|
+
// row); the host caches the underlying `claude agents --json` invocation behind a short TTL, so
|
|
3069
|
+
// this doesn't spawn a process per session.
|
|
3070
|
+
const liveSessions = await this.callbacks.getLiveAgentSessions?.();
|
|
3071
|
+
|
|
2530
3072
|
let changed = false;
|
|
2531
|
-
|
|
3073
|
+
const discoveredByAgent = new Map<string, number>();
|
|
3074
|
+
const noteDiscovered = (providerId: string): void => {
|
|
3075
|
+
discoveredByAgent.set(providerId, (discoveredByAgent.get(providerId) ?? 0) + 1);
|
|
3076
|
+
};
|
|
3077
|
+
/** Ids kept alive against a stale `ended` record. Logged below — this path has no other
|
|
3078
|
+
* observability, and "the agent isn't showing up" is otherwise indistinguishable from
|
|
3079
|
+
* "the listing never ran". */
|
|
3080
|
+
const keptAlive: string[] = [];
|
|
2532
3081
|
|
|
2533
3082
|
for (const data of sessions) {
|
|
2534
3083
|
if (!data.sessionId) continue;
|
|
2535
3084
|
|
|
2536
|
-
//
|
|
2537
|
-
// `polledAlive`: an id in either would skew
|
|
2538
|
-
// `onSyncDiscrepancy` drift signal below.
|
|
2539
|
-
|
|
3085
|
+
// A record from a client we hold no descriptor for (the CLI supports more than this table
|
|
3086
|
+
// does). Must stay ahead of `polledIds`/`polledAlive`: an id in either would skew ended-row
|
|
3087
|
+
// reconciliation and the `onSyncDiscrepancy` drift signal below. Absent = `claude-code`,
|
|
3088
|
+
// matching the live path — older CLIs don't stamp it.
|
|
3089
|
+
const capabilities = getAgentCapabilities(data.providerId ?? 'claude-code');
|
|
3090
|
+
if (capabilities == null) continue;
|
|
3091
|
+
|
|
3092
|
+
// The record's `event` is the agent's native name. Canonicalize it so `deriveStatusFromEvent`
|
|
3093
|
+
// sees a vocabulary it knows — an unmapped native name falls through to `data.event`, which
|
|
3094
|
+
// that function's `default` already resolves to `idle`. Deliberately NOT a skip: unlike the
|
|
3095
|
+
// live path, nothing here switches exhaustively on the name, and dropping the record would
|
|
3096
|
+
// hide a real session whose last event simply has no canonical analogue.
|
|
3097
|
+
//
|
|
3098
|
+
// `data.hookInput` must be passed: an agent whose event name alone can't determine the
|
|
3099
|
+
// canonical event (OpenCode's `session.status`) is resolved from the payload, so withholding
|
|
3100
|
+
// it makes those names permanently unresolvable on this path — and the poll is the only way
|
|
3101
|
+
// a pre-existing session enters after a window reload.
|
|
3102
|
+
const canonicalEvent = resolveCanonicalHookEvent(capabilities, data.event, data.hookInput) ?? data.event;
|
|
3103
|
+
// Same translation for the record's tool name — `classifyPermissionKind` and the display
|
|
3104
|
+
// detail expect canonical names.
|
|
3105
|
+
const polledToolName =
|
|
3106
|
+
data.toolName != null ? resolveCanonicalToolName(capabilities, data.toolName) : undefined;
|
|
2540
3107
|
|
|
2541
3108
|
const effectiveStatus = data.status ?? 'active';
|
|
2542
3109
|
|
|
@@ -2546,42 +3113,119 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
2546
3113
|
if (effectiveStatus === 'ended') {
|
|
2547
3114
|
polledIds.add(data.sessionId);
|
|
2548
3115
|
const endedDate = new Date(data.endedAt ?? data.updatedAt);
|
|
3116
|
+
const override = liveOverrideFor(liveSessions, data.sessionId);
|
|
3117
|
+
const liveCwd = override?.live.cwd;
|
|
3118
|
+
const currentCwd = liveCwd ?? data.cwd;
|
|
3119
|
+
const liveCwdMoved = liveCwd != null && liveCwd !== data.cwd;
|
|
3120
|
+
const recordWorktreeRoot = worktreeRootFromData(data);
|
|
3121
|
+
const currentWorktreeRoot = liveCwdMoved ? undefined : recordWorktreeRoot;
|
|
2549
3122
|
|
|
2550
3123
|
const existingIndex = this._sessions.findIndex(s => s.id === data.sessionId);
|
|
2551
3124
|
if (existingIndex >= 0) {
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
if (
|
|
2556
|
-
|
|
3125
|
+
const bookkeeping = this.getBookkeeping(data.sessionId);
|
|
3126
|
+
if (liveCwdMoved) {
|
|
3127
|
+
bookkeeping.supersededRecordCwd = data.cwd;
|
|
3128
|
+
} else if (
|
|
3129
|
+
bookkeeping.supersededRecordCwd != null &&
|
|
3130
|
+
bookkeeping.supersededRecordCwd !== data.cwd
|
|
3131
|
+
) {
|
|
3132
|
+
// The CLI rewrote the record for the resumed run — it describes the session again.
|
|
3133
|
+
bookkeeping.supersededRecordCwd = undefined;
|
|
3134
|
+
}
|
|
3135
|
+
|
|
3136
|
+
// The record still names the cwd a resume superseded, and this poll no longer lists the
|
|
3137
|
+
// session live: the resumed process exited (or died) before the CLI rewrote the record,
|
|
3138
|
+
// so neither its cwd nor its `endedAt` describe where or when the session last ran.
|
|
3139
|
+
const recordLocationStale = override == null && bookkeeping.supersededRecordCwd === data.cwd;
|
|
3140
|
+
|
|
3141
|
+
if (override != null) {
|
|
3142
|
+
const { live: liveSession, status: liveStatus } = override;
|
|
3143
|
+
// `claude agents --json` still lists this exact session id as live — the store's
|
|
3144
|
+
// `ended` record is stale. Keep the row alive instead of ending it, and prefer the
|
|
3145
|
+
// listing's pid — the record's own may already be dead, or reused. Background sessions
|
|
3146
|
+
// carry no pid at all, so fall back to whatever pid the row already has.
|
|
3147
|
+
//
|
|
3148
|
+
// The listing establishes LIVENESS, not status. It's a poll SAMPLE of a value that flips
|
|
3149
|
+
// many times per turn, so adopting it every poll stamps a row back to `idle` moments after
|
|
3150
|
+
// a hook event moved it to `tool_use`/`thinking` — the row then reads idle almost always
|
|
3151
|
+
// and working almost never. Hook events are strictly fresher, so the sample is only taken
|
|
3152
|
+
// when the row has nothing better: it was ended, and this poll is what revives it.
|
|
3153
|
+
const trackedSession = this._sessions[existingIndex];
|
|
3154
|
+
// The listing's pid, or none. Never fall back to the pid we already hold: the listing
|
|
3155
|
+
// omits one only for background sessions, which have no process of their own, and the
|
|
3156
|
+
// pid on the row is the dead one whose exit produced the `ended` record. Carrying it
|
|
3157
|
+
// onto a live row makes `pruneDeadSessions` reap the row later in this same poll.
|
|
3158
|
+
const nextPid = liveSession.pid;
|
|
3159
|
+
const revived = trackedSession.status === 'ended';
|
|
3160
|
+
const nextStatus = revived ? liveStatus : trackedSession.status;
|
|
3161
|
+
if (
|
|
3162
|
+
trackedSession.status !== nextStatus ||
|
|
3163
|
+
trackedSession.pid !== nextPid ||
|
|
3164
|
+
trackedSession.endReason != null ||
|
|
3165
|
+
trackedSession.endedAt != null
|
|
3166
|
+
) {
|
|
3167
|
+
this._sessions[existingIndex] = {
|
|
3168
|
+
...trackedSession,
|
|
3169
|
+
status: nextStatus,
|
|
3170
|
+
phase: getPhaseForStatus(nextStatus),
|
|
3171
|
+
// Only re-stamp the phase/activity clocks on the revive itself; otherwise a poll
|
|
3172
|
+
// keeps resetting them under a row whose status it didn't change.
|
|
3173
|
+
phaseSince: revived ? new Date(pollStartedAt) : trackedSession.phaseSince,
|
|
3174
|
+
lastActivity: revived ? new Date(pollStartedAt) : trackedSession.lastActivity,
|
|
3175
|
+
pid: nextPid,
|
|
3176
|
+
statusDetail: revived ? undefined : trackedSession.statusDetail,
|
|
3177
|
+
endReason: undefined,
|
|
3178
|
+
endedAt: undefined,
|
|
3179
|
+
};
|
|
3180
|
+
changed = true;
|
|
3181
|
+
}
|
|
3182
|
+
keptAlive.push(`${data.sessionId.substring(0, 8)}:${nextStatus}`);
|
|
3183
|
+
polledAlive.set(data.sessionId, capabilities.providerId);
|
|
3184
|
+
} else if (this._sessions[existingIndex].status !== 'ended') {
|
|
3185
|
+
// Already tracked, and Claude no longer lists it as live. If the live path missed the
|
|
3186
|
+
// SessionEnd (e.g. `/clear` kept the PID alive under a new id), transition it to
|
|
3187
|
+
// ended now — otherwise a live-status row would linger as a zombie, since
|
|
3188
|
+
// pruneDeadSessions can't reap a live PID.
|
|
3189
|
+
// The record's `endedAt` can predate activity this window already observed — a
|
|
3190
|
+
// superseded record after a cwd move, or a same-cwd resume the CLI hasn't rewritten
|
|
3191
|
+
// the record for yet — so never move the row's clock backward.
|
|
3192
|
+
const { lastActivity } = this._sessions[existingIndex];
|
|
3193
|
+
this.endSession(
|
|
3194
|
+
existingIndex,
|
|
3195
|
+
lastActivity.getTime() > endedDate.getTime() ? lastActivity : endedDate,
|
|
3196
|
+
);
|
|
2557
3197
|
changed = true;
|
|
2558
3198
|
}
|
|
2559
3199
|
|
|
2560
|
-
// Re-seat the row on
|
|
2561
|
-
// terminal session,
|
|
2562
|
-
//
|
|
2563
|
-
//
|
|
2564
|
-
//
|
|
3200
|
+
// Re-seat the row on its current location. The durable record is authoritative for a
|
|
3201
|
+
// terminal session, but an independent Claude listing can prove that record stale and
|
|
3202
|
+
// supply the resumed process's current cwd. Keep the prior worktree attribution until
|
|
3203
|
+
// the live cwd's git probe confirms a replacement — a resumed session can legitimately
|
|
3204
|
+
// sit in scratch space — but never let a later poll move it back to the ended record's
|
|
3205
|
+
// directory. A record already known superseded stays ignored until the CLI rewrites it,
|
|
3206
|
+
// so the row survives the resumed process exiting before that write lands. Prompts get a
|
|
3207
|
+
// hole-fill from the record below when nothing has been set yet; titles keep their own
|
|
3208
|
+
// on-demand transcript resolution, which holds richer values than the record does.
|
|
2565
3209
|
const existing = this._sessions[existingIndex];
|
|
2566
|
-
const
|
|
2567
|
-
const cwdChanged = Boolean(data.cwd) && data.cwd !== existing.cwd;
|
|
3210
|
+
const cwdChanged = !recordLocationStale && currentCwd !== existing.cwd;
|
|
2568
3211
|
// Pre-`cwdTimeline` records carry no worktree data. If the cwd moved, whatever we hold
|
|
2569
3212
|
// is no longer trustworthy — it may point into a different repo entirely — so drop it
|
|
2570
3213
|
// rather than leave the card filed under the wrong branch, and re-resolve below. A cwd
|
|
2571
|
-
//
|
|
2572
|
-
//
|
|
2573
|
-
const nextWorktreePath =
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
3214
|
+
// supplied by the live listing is different: preserve the old attribution as a fallback
|
|
3215
|
+
// until the current cwd's probe succeeds.
|
|
3216
|
+
const nextWorktreePath = recordLocationStale
|
|
3217
|
+
? existing.worktreePath
|
|
3218
|
+
: currentWorktreeRoot != null
|
|
3219
|
+
? normalizePath(currentWorktreeRoot)
|
|
3220
|
+
: cwdChanged && !liveCwdMoved
|
|
2577
3221
|
? undefined
|
|
2578
3222
|
: existing.worktreePath;
|
|
2579
3223
|
const worktreeMoved = nextWorktreePath !== existing.worktreePath;
|
|
2580
3224
|
if (cwdChanged || worktreeMoved) {
|
|
2581
|
-
const workspacePath = this.resolveWorkspacePath(
|
|
3225
|
+
const workspacePath = this.resolveWorkspacePath(currentCwd);
|
|
2582
3226
|
this._sessions[existingIndex] = {
|
|
2583
3227
|
...existing,
|
|
2584
|
-
cwd:
|
|
3228
|
+
cwd: currentCwd,
|
|
2585
3229
|
initialCwd: existing.initialCwd ?? data.initialCwd ?? data.cwd,
|
|
2586
3230
|
worktreePath: nextWorktreePath,
|
|
2587
3231
|
workspacePath: workspacePath,
|
|
@@ -2591,38 +3235,132 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
2591
3235
|
// worktree metadata, pinning the card to the wrong repo. Drop it and let the
|
|
2592
3236
|
// host backfill from the new worktree.
|
|
2593
3237
|
commonPath: worktreeMoved ? undefined : existing.commonPath,
|
|
3238
|
+
visitedWorktreePaths: unionVisited(
|
|
3239
|
+
existing.visitedWorktreePaths,
|
|
3240
|
+
visitedFromTimeline(data.cwdTimeline),
|
|
3241
|
+
data.worktrees,
|
|
3242
|
+
nextWorktreePath,
|
|
3243
|
+
),
|
|
2594
3244
|
};
|
|
2595
3245
|
changed = true;
|
|
2596
3246
|
|
|
2597
|
-
// Nothing in the record to re-seat it with (older CLI),
|
|
2598
|
-
//
|
|
2599
|
-
|
|
2600
|
-
|
|
3247
|
+
// Nothing in the record to re-seat it with (older CLI), or the record names a
|
|
3248
|
+
// NEW worktree the drop above just cleared `commonPath` for — either way, resolve
|
|
3249
|
+
// the cwd once rather than leaving the row location-less until some later event
|
|
3250
|
+
// happens to change `cwd`/`worktreePath` again (a same-worktree poll afterward
|
|
3251
|
+
// skips this branch entirely, since `worktreeMoved` goes false).
|
|
3252
|
+
if (liveCwdMoved || nextWorktreePath == null || worktreeMoved) {
|
|
3253
|
+
this.getBookkeeping(data.sessionId).cliSeatedWorktree = currentWorktreeRoot != null;
|
|
3254
|
+
void this.resolveGitInfo(data.sessionId, currentCwd);
|
|
2601
3255
|
}
|
|
2602
3256
|
}
|
|
2603
3257
|
|
|
2604
|
-
|
|
3258
|
+
// Prompts backfill-only: the record's prompt/firstPrompt sanitized the same way the
|
|
3259
|
+
// discovery path does. Never overwrites a value the hook path or a transcript resolve
|
|
3260
|
+
// already set.
|
|
3261
|
+
const currentSession = this._sessions[existingIndex];
|
|
3262
|
+
const nextLastPrompt = currentSession.lastPrompt ?? prepareStoredPrompt(data.prompt ?? undefined);
|
|
3263
|
+
const nextFirstPrompt =
|
|
3264
|
+
currentSession.firstPrompt ?? prepareStoredPrompt(data.firstPrompt ?? undefined);
|
|
3265
|
+
if (
|
|
3266
|
+
nextLastPrompt !== currentSession.lastPrompt ||
|
|
3267
|
+
nextFirstPrompt !== currentSession.firstPrompt
|
|
3268
|
+
) {
|
|
3269
|
+
this._sessions[existingIndex] = {
|
|
3270
|
+
...currentSession,
|
|
3271
|
+
lastPrompt: nextLastPrompt,
|
|
3272
|
+
firstPrompt: nextFirstPrompt,
|
|
3273
|
+
};
|
|
3274
|
+
changed = true;
|
|
3275
|
+
}
|
|
3276
|
+
|
|
3277
|
+
const nextBookkeeping = this.getBookkeeping(data.sessionId);
|
|
3278
|
+
nextBookkeeping.polledAtLeastOnce = true;
|
|
3279
|
+
// `endSession` above drops the bookkeeping entry, but the latch has to outlive that
|
|
3280
|
+
// transition — otherwise the next poll finds none and re-seats the row onto the very
|
|
3281
|
+
// record this one already knew was superseded.
|
|
3282
|
+
nextBookkeeping.supersededRecordCwd = bookkeeping.supersededRecordCwd;
|
|
2605
3283
|
continue;
|
|
2606
3284
|
}
|
|
2607
3285
|
|
|
2608
3286
|
known.add(data.sessionId);
|
|
2609
|
-
const workspacePath = this.resolveWorkspacePath(
|
|
2610
|
-
|
|
3287
|
+
const workspacePath = this.resolveWorkspacePath(currentCwd);
|
|
3288
|
+
|
|
3289
|
+
if (override != null) {
|
|
3290
|
+
const { live: liveSession, status: liveStatus } = override;
|
|
3291
|
+
// This window is learning about the session for the first time via its `ended`
|
|
3292
|
+
// record, but `claude agents --json` still lists it as live — same override as the
|
|
3293
|
+
// tracked branch above, just pushing a fresh row instead of updating one in place. The
|
|
3294
|
+
// listing's cwd is current; the ended record's worktree remains only as a fallback until
|
|
3295
|
+
// the live cwd's git probe resolves it.
|
|
3296
|
+
this._sessions.push({
|
|
3297
|
+
id: data.sessionId,
|
|
3298
|
+
providerId: capabilities.providerId,
|
|
3299
|
+
providerName: capabilities.displayName,
|
|
3300
|
+
name: data.sessionName || undefined,
|
|
3301
|
+
status: liveStatus,
|
|
3302
|
+
phase: getPhaseForStatus(liveStatus),
|
|
3303
|
+
phaseSince: new Date(pollStartedAt),
|
|
3304
|
+
// Listing's pid or none — never the record's, which is the dead process whose exit
|
|
3305
|
+
// wrote the `ended` record (see the tracked branch above).
|
|
3306
|
+
pid: liveSession.pid,
|
|
3307
|
+
lastActivity: new Date(pollStartedAt),
|
|
3308
|
+
isSubagent: false,
|
|
3309
|
+
workspacePath: workspacePath,
|
|
3310
|
+
worktreePath: recordWorktreeRoot != null ? normalizePath(recordWorktreeRoot) : undefined,
|
|
3311
|
+
visitedWorktreePaths: unionVisited(
|
|
3312
|
+
undefined,
|
|
3313
|
+
visitedFromTimeline(data.cwdTimeline),
|
|
3314
|
+
data.worktrees,
|
|
3315
|
+
recordWorktreeRoot != null ? normalizePath(recordWorktreeRoot) : undefined,
|
|
3316
|
+
),
|
|
3317
|
+
cwd: currentCwd,
|
|
3318
|
+
initialCwd: data.initialCwd ?? data.cwd,
|
|
3319
|
+
planFile: data.planFile ?? undefined,
|
|
3320
|
+
isInWorkspace: workspacePath != null,
|
|
3321
|
+
lastPrompt: prepareStoredPrompt(data.prompt ?? undefined),
|
|
3322
|
+
firstPrompt: prepareStoredPrompt(data.firstPrompt ?? undefined),
|
|
3323
|
+
model: data.model ?? undefined,
|
|
3324
|
+
});
|
|
3325
|
+
this.getBookkeeping(data.sessionId).polledAtLeastOnce = true;
|
|
3326
|
+
this.getBookkeeping(data.sessionId).cliSeatedWorktree = currentWorktreeRoot != null;
|
|
3327
|
+
if (liveCwdMoved) {
|
|
3328
|
+
// Latch the cwd this listing superseded, so a later poll that no longer finds the
|
|
3329
|
+
// session live doesn't re-seat the row back onto the not-yet-rewritten record.
|
|
3330
|
+
this.getBookkeeping(data.sessionId).supersededRecordCwd = data.cwd;
|
|
3331
|
+
}
|
|
3332
|
+
changed = true;
|
|
3333
|
+
noteDiscovered(capabilities.providerId);
|
|
3334
|
+
keptAlive.push(`${data.sessionId.substring(0, 8)}:${liveStatus}`);
|
|
3335
|
+
polledAlive.set(data.sessionId, capabilities.providerId);
|
|
3336
|
+
if (liveCwdMoved || recordWorktreeRoot == null) {
|
|
3337
|
+
void this.resolveGitInfo(data.sessionId, currentCwd);
|
|
3338
|
+
}
|
|
3339
|
+
void this.resolveTranscriptTitles(data.sessionId, currentCwd);
|
|
3340
|
+
continue;
|
|
3341
|
+
}
|
|
3342
|
+
|
|
2611
3343
|
this._sessions.push({
|
|
2612
3344
|
id: data.sessionId,
|
|
2613
|
-
providerId:
|
|
2614
|
-
providerName:
|
|
3345
|
+
providerId: capabilities.providerId,
|
|
3346
|
+
providerName: capabilities.displayName,
|
|
2615
3347
|
name: data.sessionName || undefined,
|
|
2616
|
-
status: '
|
|
2617
|
-
phase: '
|
|
3348
|
+
status: 'ended',
|
|
3349
|
+
phase: 'ended',
|
|
2618
3350
|
phaseSince: endedDate,
|
|
2619
3351
|
pid: data.pid,
|
|
2620
3352
|
lastActivity: endedDate,
|
|
2621
3353
|
isSubagent: false,
|
|
2622
3354
|
workspacePath: workspacePath,
|
|
2623
|
-
// Read straight from the CLI's durable record so
|
|
3355
|
+
// Read straight from the CLI's durable record so ended sessions attach to branch
|
|
2624
3356
|
// cards / WIP rows / the resume picker at any age, no git probe.
|
|
2625
|
-
worktreePath:
|
|
3357
|
+
worktreePath: recordWorktreeRoot != null ? normalizePath(recordWorktreeRoot) : undefined,
|
|
3358
|
+
visitedWorktreePaths: unionVisited(
|
|
3359
|
+
undefined,
|
|
3360
|
+
visitedFromTimeline(data.cwdTimeline),
|
|
3361
|
+
data.worktrees,
|
|
3362
|
+
recordWorktreeRoot != null ? normalizePath(recordWorktreeRoot) : undefined,
|
|
3363
|
+
),
|
|
2626
3364
|
cwd: data.cwd,
|
|
2627
3365
|
initialCwd: data.initialCwd ?? data.cwd,
|
|
2628
3366
|
planFile: data.planFile ?? undefined,
|
|
@@ -2637,14 +3375,15 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
2637
3375
|
});
|
|
2638
3376
|
this.getBookkeeping(data.sessionId).polledAtLeastOnce = true;
|
|
2639
3377
|
changed = true;
|
|
2640
|
-
|
|
3378
|
+
this.noteTerminalTransition();
|
|
3379
|
+
// Titles/prompts still resolve lazily via resolveEndedSessionDetails (a 30-day
|
|
2641
3380
|
// cold-start must not fan out transcript reads). worktreePath comes from the record
|
|
2642
3381
|
// above; only fall back to an eager git probe when the record predates that CLI field,
|
|
2643
3382
|
// and only for the recent (WIP-window) tail so the fallback stays bounded.
|
|
2644
3383
|
if (
|
|
2645
|
-
|
|
3384
|
+
recordWorktreeRoot == null &&
|
|
2646
3385
|
data.cwd &&
|
|
2647
|
-
Date.now() - endedDate.getTime() <
|
|
3386
|
+
Date.now() - endedDate.getTime() < recentEndedGitResolveThresholdMs
|
|
2648
3387
|
) {
|
|
2649
3388
|
void this.resolveGitInfo(data.sessionId, data.cwd);
|
|
2650
3389
|
}
|
|
@@ -2654,7 +3393,7 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
2654
3393
|
// Active / legacy (absent-status) path — only genuinely-live processes are discoverable.
|
|
2655
3394
|
if (!data.pid || !isProcessAlive(data.pid)) continue;
|
|
2656
3395
|
|
|
2657
|
-
polledAlive.
|
|
3396
|
+
polledAlive.set(data.sessionId, capabilities.providerId);
|
|
2658
3397
|
polledIds.add(data.sessionId);
|
|
2659
3398
|
|
|
2660
3399
|
if (known.has(data.sessionId)) {
|
|
@@ -2665,18 +3404,34 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
2665
3404
|
// The status the CLI's last event implies — a resumed session may already be mid-work
|
|
2666
3405
|
// (`PreToolUse` → tool_use, `UserPromptSubmit` → thinking), and hardcoding `idle` would
|
|
2667
3406
|
// mislabel active work and risk a concurrent-write resume.
|
|
2668
|
-
const polledStatus = deriveStatusFromEvent(
|
|
2669
|
-
|
|
2670
|
-
|
|
3407
|
+
const polledStatus = deriveStatusFromEvent(canonicalEvent);
|
|
3408
|
+
const isSynthesizedAsk =
|
|
3409
|
+
existing.status === 'permission_requested' && existing.pendingPermission?.resolvable === false;
|
|
3410
|
+
const liveEntry = isSynthesizedAsk ? liveSessions?.get(data.sessionId) : undefined;
|
|
3411
|
+
const liveStatus = liveEntry != null ? statusFromLiveSession(liveEntry) : undefined;
|
|
3412
|
+
if (isSynthesizedAsk && liveEntry != null && liveStatus == null) {
|
|
3413
|
+
// A terminal listing entry proves this active record is stale, even if its last event
|
|
3414
|
+
// advanced after the ask. It carries no authoritative terminal timestamp/details, so
|
|
3415
|
+
// remove the false live row and let the durable ended record restore it later. Run the
|
|
3416
|
+
// standard terminal teardown first so no timers or caches survive the row.
|
|
3417
|
+
this.endSession(existingIndex, new Date(pollStartedAt));
|
|
3418
|
+
this._sessions.splice(existingIndex, 1);
|
|
3419
|
+
polledAlive.delete(data.sessionId);
|
|
3420
|
+
changed = true;
|
|
3421
|
+
continue;
|
|
3422
|
+
}
|
|
3423
|
+
|
|
3424
|
+
if (existing.status === 'ended') {
|
|
3425
|
+
// We hold it as `ended` but the CLI now reports it as a live process (a
|
|
2671
3426
|
// resume the live SessionStart path missed) — revive it, the symmetric backstop
|
|
2672
|
-
// to the ended-branch's live→
|
|
3427
|
+
// to the ended-branch's live→ended transition above. Leave genuinely-live
|
|
2673
3428
|
// rows to the IPC path.
|
|
2674
3429
|
const revivedAt = new Date(data.updatedAt);
|
|
2675
|
-
// Only when the record is at least as new as the
|
|
3430
|
+
// Only when the record is at least as new as the end. `SessionEnd` fires
|
|
2676
3431
|
// while the process is still winding down, so a poll whose CLI call started
|
|
2677
3432
|
// BEFORE it returns a snapshot that still says "active" with a live pid — reviving
|
|
2678
3433
|
// off that stale record would resurrect the row, and `pruneDeadSessions` would
|
|
2679
|
-
// then delete it outright once the process exits, losing the
|
|
3434
|
+
// then delete it outright once the process exits, losing the ended row.
|
|
2680
3435
|
if (revivedAt.getTime() < existing.phaseSince.getTime()) continue;
|
|
2681
3436
|
|
|
2682
3437
|
// The poll has no blocking hook entry for this ask (it's discovered off the CLI's
|
|
@@ -2684,12 +3439,12 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
2684
3439
|
// the row shows what it's waiting on instead of an empty "Needs input" card.
|
|
2685
3440
|
const revivedPendingPermission =
|
|
2686
3441
|
polledStatus === 'permission_requested'
|
|
2687
|
-
? synthesizeUnresolvableAsk(
|
|
3442
|
+
? synthesizeUnresolvableAsk(polledToolName, data.planFile ?? undefined)
|
|
2688
3443
|
: undefined;
|
|
2689
3444
|
// Seat worktreePath from the record, mirroring the ended-record re-seat above — the
|
|
2690
|
-
// existing row is otherwise stuck with whatever (or nothing) it had while
|
|
3445
|
+
// existing row is otherwise stuck with whatever (or nothing) it had while ended.
|
|
2691
3446
|
const revivedWorktreeRoot = worktreeRootFromData(data);
|
|
2692
|
-
// No record worktree data (older CLI): KEEP the
|
|
3447
|
+
// No record worktree data (older CLI): KEEP the ended row's attribution. A
|
|
2693
3448
|
// resume whose cwd is scratch space (e.g. /tmp) still belongs to its worktree —
|
|
2694
3449
|
// re-rooting happens only when git affirmatively resolves a new one (the probe
|
|
2695
3450
|
// below, on success). Attribution persists until something better is known.
|
|
@@ -2717,10 +3472,16 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
2717
3472
|
revivedWorkspacePath ?? (data.cwd == null ? existing.workspacePath : undefined),
|
|
2718
3473
|
isInWorkspace: data.cwd == null ? existing.isInWorkspace : revivedWorkspacePath != null,
|
|
2719
3474
|
worktreePath: revivedWorktreePath,
|
|
3475
|
+
visitedWorktreePaths: unionVisited(
|
|
3476
|
+
existing.visitedWorktreePaths,
|
|
3477
|
+
visitedFromTimeline(data.cwdTimeline),
|
|
3478
|
+
data.worktrees,
|
|
3479
|
+
revivedWorktreePath,
|
|
3480
|
+
),
|
|
2720
3481
|
// A worktree move can cross repos — drop the stale repo identity and let the
|
|
2721
3482
|
// probe below refill it.
|
|
2722
3483
|
commonPath: revivedWorktreePath !== existing.worktreePath ? undefined : existing.commonPath,
|
|
2723
|
-
// No longer terminal: leaving these set makes the row read as
|
|
3484
|
+
// No longer terminal: leaving these set makes the row read as ended to
|
|
2724
3485
|
// consumers that key off them.
|
|
2725
3486
|
endReason: undefined,
|
|
2726
3487
|
endedAt: undefined,
|
|
@@ -2729,7 +3490,12 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
2729
3490
|
// The record's worktree is the CLI's own resolution; without one the probe owns
|
|
2730
3491
|
// attribution again.
|
|
2731
3492
|
this.getBookkeeping(data.sessionId).cliSeatedWorktree = revivedWorktreeRoot != null;
|
|
2732
|
-
|
|
3493
|
+
// A revive whose worktree moved but whose cwd happens to equal the prior one still
|
|
3494
|
+
// dropped `commonPath` above and needs the probe to refill it.
|
|
3495
|
+
if (
|
|
3496
|
+
data.cwd != null &&
|
|
3497
|
+
(data.cwd !== existing.cwd || revivedWorktreePath !== existing.worktreePath)
|
|
3498
|
+
) {
|
|
2733
3499
|
void this.resolveGitInfo(data.sessionId, data.cwd);
|
|
2734
3500
|
}
|
|
2735
3501
|
changed = true;
|
|
@@ -2749,11 +3515,29 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
2749
3515
|
// The detail described the ask's tool; carry the record's current tool for
|
|
2750
3516
|
// tool_use, else clear — keeping it would show the answered ask's tool as
|
|
2751
3517
|
// the active one.
|
|
2752
|
-
statusDetail: polledStatus === 'tool_use' ?
|
|
3518
|
+
statusDetail: polledStatus === 'tool_use' ? polledToolName : undefined,
|
|
2753
3519
|
pendingPermission: undefined,
|
|
2754
3520
|
lastActivity: new Date(data.updatedAt),
|
|
2755
3521
|
};
|
|
2756
3522
|
changed = true;
|
|
3523
|
+
} else if (isSynthesizedAsk) {
|
|
3524
|
+
// The record never moved past the ask it froze on — the ask was resolved while no
|
|
3525
|
+
// window was listening, so nothing ever advanced it — but Claude's own listing
|
|
3526
|
+
// says the session has moved on. Correct the row so it heals on the next poll
|
|
3527
|
+
// instead of waiting for a reload or fresh hook events. Only synthesized asks
|
|
3528
|
+
// are repairable here: a routable one is bookkeeping-owned and cleared by its
|
|
3529
|
+
// own resolution paths, which the listing must never preempt.
|
|
3530
|
+
if (liveStatus != null && liveStatus !== 'permission_requested') {
|
|
3531
|
+
this._sessions[existingIndex] = {
|
|
3532
|
+
...existing,
|
|
3533
|
+
status: liveStatus,
|
|
3534
|
+
phase: getPhaseForStatus(liveStatus),
|
|
3535
|
+
phaseSince: new Date(pollStartedAt),
|
|
3536
|
+
pendingPermission: undefined,
|
|
3537
|
+
statusDetail: undefined,
|
|
3538
|
+
};
|
|
3539
|
+
changed = true;
|
|
3540
|
+
}
|
|
2757
3541
|
} else if (existing.transcriptTitles?.ai == null && existing.transcriptTitles?.custom == null) {
|
|
2758
3542
|
// A live session we discovered via the poll (another worktree/window owns its
|
|
2759
3543
|
// hook flow) gets none of the local IPC re-resolves (SessionStart, idle). So one
|
|
@@ -2770,23 +3554,44 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
2770
3554
|
|
|
2771
3555
|
const workspacePath = this.resolveWorkspacePath(data.cwd);
|
|
2772
3556
|
const isInWorkspace = workspacePath != null;
|
|
2773
|
-
const status = deriveStatusFromEvent(data.event);
|
|
2774
|
-
const phase = getPhaseForStatus(status);
|
|
2775
3557
|
const activityDate = new Date(data.updatedAt);
|
|
3558
|
+
let status = deriveStatusFromEvent(canonicalEvent);
|
|
3559
|
+
let phaseSince = activityDate;
|
|
3560
|
+
// The record's last event can be arbitrarily stale — an ask answered while no window
|
|
3561
|
+
// was listening (e.g. mid-reload), or a session stopped right there, never advances it,
|
|
3562
|
+
// so every window start would re-seed the frozen state. Claude's own listing is
|
|
3563
|
+
// authoritative for what the session is doing right now — same contract as the
|
|
3564
|
+
// ended-record revival above. Absence from the listing (including its fail-soft empty
|
|
3565
|
+
// map on spawn failure) keeps record-derived status: absence must never mean dead.
|
|
3566
|
+
const liveEntry = liveSessions?.get(data.sessionId);
|
|
3567
|
+
if (liveEntry != null) {
|
|
3568
|
+
const liveStatus = statusFromLiveSession(liveEntry);
|
|
3569
|
+
if (liveStatus == null) {
|
|
3570
|
+
polledAlive.delete(data.sessionId);
|
|
3571
|
+
continue;
|
|
3572
|
+
}
|
|
3573
|
+
|
|
3574
|
+
if (liveStatus !== status) {
|
|
3575
|
+
status = liveStatus;
|
|
3576
|
+
phaseSince = new Date(pollStartedAt);
|
|
3577
|
+
}
|
|
3578
|
+
}
|
|
3579
|
+
|
|
3580
|
+
const phase = getPhaseForStatus(status);
|
|
2776
3581
|
// Same rationale as the revived-session branch above: no blocking hook entry exists for a
|
|
2777
3582
|
// poll-discovered ask, so synthesize an unresolvable one for the card.
|
|
2778
3583
|
const pendingPermission =
|
|
2779
3584
|
status === 'permission_requested'
|
|
2780
|
-
? synthesizeUnresolvableAsk(
|
|
3585
|
+
? synthesizeUnresolvableAsk(polledToolName, data.planFile ?? undefined)
|
|
2781
3586
|
: undefined;
|
|
2782
3587
|
// Read straight from the CLI's durable record so the row attaches to the right branch
|
|
2783
|
-
// card/WIP row immediately, no git probe — mirrors the
|
|
3588
|
+
// card/WIP row immediately, no git probe — mirrors the ended-session push below.
|
|
2784
3589
|
const worktreeRoot = worktreeRootFromData(data);
|
|
2785
3590
|
|
|
2786
3591
|
const subagents: AgentSession[] | undefined = data.subagents?.map(sub => ({
|
|
2787
3592
|
id: sub.agentId,
|
|
2788
|
-
providerId:
|
|
2789
|
-
providerName:
|
|
3593
|
+
providerId: capabilities.providerId,
|
|
3594
|
+
providerName: capabilities.displayName,
|
|
2790
3595
|
name: sub.agentType ?? 'Subagent',
|
|
2791
3596
|
status: 'thinking',
|
|
2792
3597
|
phase: 'working' as const,
|
|
@@ -2799,12 +3604,12 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
2799
3604
|
|
|
2800
3605
|
this._sessions.push({
|
|
2801
3606
|
id: data.sessionId,
|
|
2802
|
-
providerId:
|
|
2803
|
-
providerName:
|
|
3607
|
+
providerId: capabilities.providerId,
|
|
3608
|
+
providerName: capabilities.displayName,
|
|
2804
3609
|
name: data.sessionName || undefined,
|
|
2805
3610
|
status: status,
|
|
2806
3611
|
phase: phase,
|
|
2807
|
-
phaseSince:
|
|
3612
|
+
phaseSince: phaseSince,
|
|
2808
3613
|
pid: data.pid,
|
|
2809
3614
|
lastActivity: activityDate,
|
|
2810
3615
|
isSubagent: false,
|
|
@@ -2822,9 +3627,15 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
2822
3627
|
subagents: subagents,
|
|
2823
3628
|
pendingPermission: pendingPermission,
|
|
2824
3629
|
worktreePath: worktreeRoot != null ? normalizePath(worktreeRoot) : undefined,
|
|
3630
|
+
visitedWorktreePaths: unionVisited(
|
|
3631
|
+
undefined,
|
|
3632
|
+
visitedFromTimeline(data.cwdTimeline),
|
|
3633
|
+
data.worktrees,
|
|
3634
|
+
worktreeRoot != null ? normalizePath(worktreeRoot) : undefined,
|
|
3635
|
+
),
|
|
2825
3636
|
});
|
|
2826
3637
|
changed = true;
|
|
2827
|
-
|
|
3638
|
+
noteDiscovered(capabilities.providerId);
|
|
2828
3639
|
|
|
2829
3640
|
// The record's worktree is the CLI's own resolution — mark it seated so the probe below
|
|
2830
3641
|
// (still wanted for `commonPath`) can't clobber it for a nested worktree. Mirrors the
|
|
@@ -2836,36 +3647,44 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
2836
3647
|
void this.resolveTranscriptTitles(data.sessionId, data.cwd);
|
|
2837
3648
|
}
|
|
2838
3649
|
|
|
2839
|
-
// The poll is authoritative for
|
|
3650
|
+
// The poll is authoritative for ended-session removal (archived elsewhere or 30-day-purged).
|
|
2840
3651
|
// Only remove ones a prior poll confirmed present — never a session the live SessionEnd path just
|
|
2841
3652
|
// transitioned, which an already-in-flight poll can legitimately miss (guarded by polledAtLeastOnce).
|
|
2842
|
-
// On a legacy CLI (no `--status`) no poll can ever confirm
|
|
3653
|
+
// On a legacy CLI (no `--status`) no poll can ever confirm an ended row, so the guard would
|
|
2843
3654
|
// instead pin it forever — there, absence from the poll is the confirmation.
|
|
2844
|
-
const
|
|
3655
|
+
const staleEnded = this._sessions.filter(
|
|
2845
3656
|
s =>
|
|
2846
|
-
s.status === '
|
|
3657
|
+
s.status === 'ended' &&
|
|
2847
3658
|
!polledIds.has(s.id) &&
|
|
2848
3659
|
(!this._statusFilterSupported ||
|
|
2849
3660
|
this._sessionBookkeeping.get(s.id)?.polledAtLeastOnce === true ||
|
|
2850
|
-
// Or this poll's snapshot postdates the
|
|
3661
|
+
// Or this poll's snapshot postdates the end, so its absence is authoritative.
|
|
2851
3662
|
// `polledAtLeastOnce` alone can never be set for a session whose record disappeared
|
|
2852
3663
|
// (archived from another window, or purged) before any poll observed it — and
|
|
2853
|
-
// `
|
|
3664
|
+
// `endSession` drops the bookkeeping — so without this the row is unremovable for
|
|
2854
3665
|
// the window's lifetime. The timestamp answers the same question the flag was proxying:
|
|
2855
|
-
// could this poll have seen it? The grace keeps a just-
|
|
3666
|
+
// could this poll have seen it? The grace keeps a just-ended row out of reach of
|
|
2856
3667
|
// an absence that's really a not-yet-visible record.
|
|
2857
|
-
s.phaseSince.getTime() < pollStartedAt -
|
|
3668
|
+
s.phaseSince.getTime() < pollStartedAt - endedRemovalGraceMs),
|
|
2858
3669
|
);
|
|
2859
|
-
if (
|
|
2860
|
-
const staleIds = new Set(
|
|
3670
|
+
if (staleEnded.length > 0) {
|
|
3671
|
+
const staleIds = new Set(staleEnded.map(s => s.id));
|
|
2861
3672
|
this._sessions = this._sessions.filter(s => !staleIds.has(s.id));
|
|
2862
|
-
for (const s of
|
|
3673
|
+
for (const s of staleEnded) {
|
|
2863
3674
|
this._sessionBookkeeping.delete(s.id);
|
|
2864
3675
|
this._transcriptReader.forget(s.id);
|
|
2865
3676
|
}
|
|
2866
3677
|
changed = true;
|
|
2867
3678
|
}
|
|
2868
3679
|
|
|
3680
|
+
if (liveSessions != null) {
|
|
3681
|
+
Logger.debug(
|
|
3682
|
+
`GkAgentProvider.syncSessions: Claude lists ${liveSessions.size} session(s); kept ${keptAlive.length} alive against a stale ended record${
|
|
3683
|
+
keptAlive.length > 0 ? `: ${keptAlive.join(', ')}` : ''
|
|
3684
|
+
}`,
|
|
3685
|
+
);
|
|
3686
|
+
}
|
|
3687
|
+
|
|
2869
3688
|
if (this.pruneDeadSessions()) {
|
|
2870
3689
|
changed = true;
|
|
2871
3690
|
}
|
|
@@ -2880,20 +3699,38 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
2880
3699
|
// live sessions we still track that the poll no longer reports alive (a teardown the live path
|
|
2881
3700
|
// missed, e.g. an agent killed without firing `SessionEnd`).
|
|
2882
3701
|
if (options?.gate) {
|
|
2883
|
-
|
|
2884
|
-
|
|
3702
|
+
type Drift = { discovered: number; missing: number; polled: number; tracked: number };
|
|
3703
|
+
const driftByAgent = new Map<string, Drift>();
|
|
3704
|
+
const driftFor = (providerId: string): Drift => {
|
|
3705
|
+
let drift = driftByAgent.get(providerId);
|
|
3706
|
+
if (drift == null) {
|
|
3707
|
+
drift = { discovered: 0, missing: 0, polled: 0, tracked: 0 };
|
|
3708
|
+
driftByAgent.set(providerId, drift);
|
|
3709
|
+
}
|
|
3710
|
+
return drift;
|
|
3711
|
+
};
|
|
3712
|
+
|
|
3713
|
+
for (const [providerId, count] of discoveredByAgent) {
|
|
3714
|
+
driftFor(providerId).discovered += count;
|
|
3715
|
+
}
|
|
3716
|
+
for (const [id, providerId] of trackedLiveBefore) {
|
|
3717
|
+
const drift = driftFor(providerId);
|
|
3718
|
+
drift.tracked++;
|
|
2885
3719
|
if (!polledAlive.has(id)) {
|
|
2886
|
-
missing++;
|
|
3720
|
+
drift.missing++;
|
|
2887
3721
|
}
|
|
2888
3722
|
}
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
3723
|
+
for (const providerId of polledAlive.values()) {
|
|
3724
|
+
driftFor(providerId).polled++;
|
|
3725
|
+
}
|
|
3726
|
+
|
|
3727
|
+
// One report per agent, not one pooled report: the counts describe how well the live path
|
|
3728
|
+
// and the poll agree, which is a per-agent property. A single agent's numbers are unchanged
|
|
3729
|
+
// from the pre-multi-agent shape.
|
|
3730
|
+
for (const [providerId, drift] of driftByAgent) {
|
|
3731
|
+
if (drift.discovered === 0 && drift.missing === 0) continue;
|
|
3732
|
+
|
|
3733
|
+
this.callbacks.onSyncDiscrepancy?.({ provider: providerId, ...drift });
|
|
2897
3734
|
}
|
|
2898
3735
|
}
|
|
2899
3736
|
}
|
|
@@ -2930,17 +3767,18 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
2930
3767
|
if (peerSessions == null) continue;
|
|
2931
3768
|
|
|
2932
3769
|
for (const peerSession of peerSessions) {
|
|
2933
|
-
// Skip terminal rows a peer published:
|
|
3770
|
+
// Skip terminal rows a peer published: ended sessions live in the machine-global CLI
|
|
2934
3771
|
// store, so our own poll surfaces them with proper reconciliation. Importing them from a
|
|
2935
3772
|
// peer would add a row with no `polledAtLeastOnce` that our poll can't confirm — a ghost
|
|
2936
|
-
// pinned for this window's lifetime if the store later drops it.
|
|
2937
|
-
|
|
3773
|
+
// pinned for this window's lifetime if the store later drops it. (The publish endpoint
|
|
3774
|
+
// filters ended rows out anyway; this also keeps a version-skewed peer's terminal claim
|
|
3775
|
+
// from ending a row we still track live.)
|
|
3776
|
+
if (peerSession.status === 'ended') continue;
|
|
2938
3777
|
|
|
3778
|
+
const existing = this._sessions.find(s => s.id === peerSession.id);
|
|
2939
3779
|
const peerActivity = new Date(peerSession.lastActivity);
|
|
2940
3780
|
const peerPhaseSince = new Date(peerSession.phaseSince);
|
|
2941
3781
|
|
|
2942
|
-
const existing = this._sessions.find(s => s.id === peerSession.id);
|
|
2943
|
-
|
|
2944
3782
|
if (existing != null) {
|
|
2945
3783
|
// If the peer reports a different `cwd` than we last saw, the agent moved
|
|
2946
3784
|
// (peer fired a `CwdChanged` event we don't receive locally). Pick up the new
|
|
@@ -2957,7 +3795,7 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
2957
3795
|
// poll/SessionStart revive paths: take the peer's pid (retaining the terminal
|
|
2958
3796
|
// record's dead — and possibly reused — pid would let dispatch focus an unrelated
|
|
2959
3797
|
// process) and mark it peer-owned so opens route through the peer's IPC.
|
|
2960
|
-
const
|
|
3798
|
+
const revivedFromEnded = existing.status === 'ended';
|
|
2961
3799
|
// Peer is the authoritative hook recipient for this session, so wipe any
|
|
2962
3800
|
// local bookkeeping that survived from a previous local-ownership window.
|
|
2963
3801
|
// Without this, refcounts/timers from a prior local-hosting stretch would
|
|
@@ -2985,7 +3823,7 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
2985
3823
|
(peerSession.pendingPermission != null
|
|
2986
3824
|
? { ...peerSession.pendingPermission, resolvable: false }
|
|
2987
3825
|
: undefined),
|
|
2988
|
-
...(
|
|
3826
|
+
...(revivedFromEnded ? { pid: peerSession.pid, isPeerOwned: true } : undefined),
|
|
2989
3827
|
subagents: rehydrateSubagents(peerSession.subagents),
|
|
2990
3828
|
// Carry the peer's published fileActivity across so peer-window WIP
|
|
2991
3829
|
// decorations + treemap heatmap follow the agent. Owned by the peer (it's
|
|
@@ -2997,6 +3835,10 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
2997
3835
|
// but `worktreePath` changed) only reaches us if we carry BOTH. Carrying
|
|
2998
3836
|
// `commonPath` alone froze the displayed worktree at first-discovery.
|
|
2999
3837
|
worktreePath: peerSession.worktreePath ?? existing.worktreePath,
|
|
3838
|
+
visitedWorktreePaths: unionVisitedAppendOnly(
|
|
3839
|
+
existing.visitedWorktreePaths,
|
|
3840
|
+
peerSession.visitedWorktreePaths,
|
|
3841
|
+
),
|
|
3000
3842
|
commonPath: peerSession.commonPath ?? existing.commonPath,
|
|
3001
3843
|
// Pick up the peer's latest cwd so our backfill probe (queued below)
|
|
3002
3844
|
// targets the right path. Stale-cwd locally would just re-probe the
|
|
@@ -3008,6 +3850,10 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
3008
3850
|
initialCwd: existing.initialCwd ?? peerSession.initialCwd,
|
|
3009
3851
|
initialWorktreePath: existing.initialWorktreePath ?? peerSession.initialWorktreePath,
|
|
3010
3852
|
initialCommonPath: existing.initialCommonPath ?? peerSession.initialCommonPath,
|
|
3853
|
+
// The hook path, when present, carries richer un-truncated prompts than what a
|
|
3854
|
+
// peer forwards — prefer our own value over the peer's.
|
|
3855
|
+
lastPrompt: existing.lastPrompt ?? peerSession.lastPrompt,
|
|
3856
|
+
firstPrompt: existing.firstPrompt ?? peerSession.firstPrompt,
|
|
3011
3857
|
// Forward peer-discovered transcript titles. Both windows can resolve them
|
|
3012
3858
|
// independently against the same on-disk transcript, but only the peer's
|
|
3013
3859
|
// hook flow drives its updates — without this, we'd freeze the snapshot
|
|
@@ -3156,7 +4002,7 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
3156
4002
|
});
|
|
3157
4003
|
if (!response.ok) {
|
|
3158
4004
|
Logger.warn(
|
|
3159
|
-
`
|
|
4005
|
+
`GkAgentProvider.notifyPeerOpenSession: peer at ${discovery.address} returned ${response.status}`,
|
|
3160
4006
|
);
|
|
3161
4007
|
} else {
|
|
3162
4008
|
// Log a peer that failed to open the specific session, but still treat it
|
|
@@ -3167,7 +4013,7 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
3167
4013
|
| undefined;
|
|
3168
4014
|
if (body?.opened === false) {
|
|
3169
4015
|
Logger.warn(
|
|
3170
|
-
`
|
|
4016
|
+
`GkAgentProvider.notifyPeerOpenSession: peer at ${discovery.address} could not open session ${sessionId}`,
|
|
3171
4017
|
);
|
|
3172
4018
|
}
|
|
3173
4019
|
}
|
|
@@ -3176,7 +4022,7 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
|
|
|
3176
4022
|
// Peer advertised but unreachable (timeout, RST, etc.). Treat as no match so
|
|
3177
4023
|
// the caller opens a new window instead of trying to focus a dead window.
|
|
3178
4024
|
Logger.warn(
|
|
3179
|
-
`
|
|
4025
|
+
`GkAgentProvider.notifyPeerOpenSession: peer at ${discovery.address} unreachable: ${
|
|
3180
4026
|
ex instanceof Error ? ex.message : String(ex)
|
|
3181
4027
|
}`,
|
|
3182
4028
|
);
|