@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.
Files changed (241) hide show
  1. package/CHANGELOG.md +17 -1
  2. package/dist/git/cache.d.ts +4 -0
  3. package/dist/git/cache.d.ts.map +1 -1
  4. package/dist/git/cache.js +33 -0
  5. package/dist/git/cache.js.map +1 -1
  6. package/dist/git/context.d.ts +10 -0
  7. package/dist/git/context.d.ts.map +1 -1
  8. package/dist/git/context.js.map +1 -1
  9. package/dist/git/errors.d.ts +6 -3
  10. package/dist/git/errors.d.ts.map +1 -1
  11. package/dist/git/errors.js +9 -1
  12. package/dist/git/errors.js.map +1 -1
  13. package/dist/git/features.d.ts +1 -1
  14. package/dist/git/features.d.ts.map +1 -1
  15. package/dist/git/features.js +38 -0
  16. package/dist/git/features.js.map +1 -1
  17. package/dist/git/gitHealth.d.ts +153 -0
  18. package/dist/git/gitHealth.d.ts.map +1 -0
  19. package/dist/git/gitHealth.js +353 -0
  20. package/dist/git/gitHealth.js.map +1 -0
  21. package/dist/git/models/search.d.ts +6 -3
  22. package/dist/git/models/search.d.ts.map +1 -1
  23. package/dist/git/models/search.js +5 -1
  24. package/dist/git/models/search.js.map +1 -1
  25. package/dist/git/parsers/diffParser.d.ts +8 -2
  26. package/dist/git/parsers/diffParser.d.ts.map +1 -1
  27. package/dist/git/parsers/diffParser.js +27 -4
  28. package/dist/git/parsers/diffParser.js.map +1 -1
  29. package/dist/git/providers/config.d.ts +36 -3
  30. package/dist/git/providers/config.d.ts.map +1 -1
  31. package/dist/git/providers/graph.d.ts +9 -0
  32. package/dist/git/providers/graph.d.ts.map +1 -1
  33. package/dist/git/providers/maintenance.d.ts +222 -0
  34. package/dist/git/providers/maintenance.d.ts.map +1 -0
  35. package/dist/git/providers/maintenance.js +20 -0
  36. package/dist/git/providers/maintenance.js.map +1 -0
  37. package/dist/git/providers/operations.d.ts +10 -0
  38. package/dist/git/providers/operations.d.ts.map +1 -1
  39. package/dist/git/providers/pausedOperations.d.ts +3 -0
  40. package/dist/git/providers/pausedOperations.d.ts.map +1 -1
  41. package/dist/git/providers/provider.d.ts +2 -0
  42. package/dist/git/providers/provider.d.ts.map +1 -1
  43. package/dist/git/providers/tags.d.ts +3 -0
  44. package/dist/git/providers/tags.d.ts.map +1 -1
  45. package/dist/git/repositoryService.d.ts +1 -0
  46. package/dist/git/repositoryService.d.ts.map +1 -1
  47. package/dist/git/repositoryService.js.map +1 -1
  48. package/dist/git/run.types.d.ts +8 -0
  49. package/dist/git/run.types.d.ts.map +1 -1
  50. package/dist/git/service.js +3 -0
  51. package/dist/git/service.js.map +1 -1
  52. package/dist/git/utils/search.utils.d.ts.map +1 -1
  53. package/dist/git/utils/search.utils.js +112 -5
  54. package/dist/git/utils/search.utils.js.map +1 -1
  55. package/dist/git/watching/watchService.d.ts +3 -0
  56. package/dist/git/watching/watchService.d.ts.map +1 -1
  57. package/dist/git/watching/watchService.js +8 -0
  58. package/dist/git/watching/watchService.js.map +1 -1
  59. package/dist/git-cli/cliGitProvider.d.ts +11 -0
  60. package/dist/git-cli/cliGitProvider.d.ts.map +1 -1
  61. package/dist/git-cli/cliGitProvider.js +12 -0
  62. package/dist/git-cli/cliGitProvider.js.map +1 -1
  63. package/dist/git-cli/exec/git.d.ts +23 -1
  64. package/dist/git-cli/exec/git.d.ts.map +1 -1
  65. package/dist/git-cli/exec/git.js +83 -8
  66. package/dist/git-cli/exec/git.js.map +1 -1
  67. package/dist/git-cli/exec/gitQueue.d.ts +2 -0
  68. package/dist/git-cli/exec/gitQueue.d.ts.map +1 -1
  69. package/dist/git-cli/exec/gitQueue.js +19 -17
  70. package/dist/git-cli/exec/gitQueue.js.map +1 -1
  71. package/dist/git-cli/parsers/logParser.d.ts.map +1 -1
  72. package/dist/git-cli/parsers/logParser.js +4 -1
  73. package/dist/git-cli/parsers/logParser.js.map +1 -1
  74. package/dist/git-cli/providers/blame.d.ts.map +1 -1
  75. package/dist/git-cli/providers/blame.js +4 -7
  76. package/dist/git-cli/providers/blame.js.map +1 -1
  77. package/dist/git-cli/providers/branches.d.ts.map +1 -1
  78. package/dist/git-cli/providers/branches.js +13 -3
  79. package/dist/git-cli/providers/branches.js.map +1 -1
  80. package/dist/git-cli/providers/commits.d.ts.map +1 -1
  81. package/dist/git-cli/providers/commits.js +34 -3
  82. package/dist/git-cli/providers/commits.js.map +1 -1
  83. package/dist/git-cli/providers/config.d.ts +27 -0
  84. package/dist/git-cli/providers/config.d.ts.map +1 -1
  85. package/dist/git-cli/providers/config.js +31 -6
  86. package/dist/git-cli/providers/config.js.map +1 -1
  87. package/dist/git-cli/providers/graph.d.ts +8 -0
  88. package/dist/git-cli/providers/graph.d.ts.map +1 -1
  89. package/dist/git-cli/providers/graph.js +85 -6
  90. package/dist/git-cli/providers/graph.js.map +1 -1
  91. package/dist/git-cli/providers/maintenance.d.ts +306 -0
  92. package/dist/git-cli/providers/maintenance.d.ts.map +1 -0
  93. package/dist/git-cli/providers/maintenance.js +2129 -0
  94. package/dist/git-cli/providers/maintenance.js.map +1 -0
  95. package/dist/git-cli/providers/operations.d.ts +1 -0
  96. package/dist/git-cli/providers/operations.d.ts.map +1 -1
  97. package/dist/git-cli/providers/operations.js +8 -0
  98. package/dist/git-cli/providers/operations.js.map +1 -1
  99. package/dist/git-cli/providers/pausedOperations.d.ts +1 -0
  100. package/dist/git-cli/providers/pausedOperations.d.ts.map +1 -1
  101. package/dist/git-cli/providers/pausedOperations.js +1 -1
  102. package/dist/git-cli/providers/pausedOperations.js.map +1 -1
  103. package/dist/git-cli/providers/tags.d.ts +3 -0
  104. package/dist/git-cli/providers/tags.d.ts.map +1 -1
  105. package/dist/git-cli/providers/tags.js +23 -1
  106. package/dist/git-cli/providers/tags.js.map +1 -1
  107. package/dist/plus/agents/agentCapabilities.d.ts +77 -0
  108. package/dist/plus/agents/agentCapabilities.d.ts.map +1 -0
  109. package/dist/plus/agents/agentCapabilities.js +197 -0
  110. package/dist/plus/agents/agentCapabilities.js.map +1 -0
  111. package/dist/plus/agents/providers/claudeCodeTranscript.d.ts +77 -13
  112. package/dist/plus/agents/providers/claudeCodeTranscript.d.ts.map +1 -1
  113. package/dist/plus/agents/providers/claudeCodeTranscript.js +263 -55
  114. package/dist/plus/agents/providers/claudeCodeTranscript.js.map +1 -1
  115. package/dist/plus/agents/providers/{claudeCodeProvider.d.ts → gkAgentProvider.d.ts} +52 -19
  116. package/dist/plus/agents/providers/gkAgentProvider.d.ts.map +1 -0
  117. package/dist/plus/agents/providers/{claudeCodeProvider.js → gkAgentProvider.js} +935 -262
  118. package/dist/plus/agents/providers/gkAgentProvider.js.map +1 -0
  119. package/dist/plus/agents/types.d.ts +113 -35
  120. package/dist/plus/agents/types.d.ts.map +1 -1
  121. package/dist/plus/agents/types.js +7 -4
  122. package/dist/plus/agents/types.js.map +1 -1
  123. package/dist/plus/ai/models/promptTemplates.d.ts +1 -1
  124. package/dist/plus/ai/models/promptTemplates.d.ts.map +1 -1
  125. package/dist/plus/ai/prompts.d.ts +4 -0
  126. package/dist/plus/ai/prompts.d.ts.map +1 -1
  127. package/dist/plus/ai/prompts.js +59 -4
  128. package/dist/plus/ai/prompts.js.map +1 -1
  129. package/dist/plus/git-github/api/github.d.ts +44 -1
  130. package/dist/plus/git-github/api/github.d.ts.map +1 -1
  131. package/dist/plus/git-github/api/github.js +318 -25
  132. package/dist/plus/git-github/api/github.js.map +1 -1
  133. package/dist/plus/git-github/api/issueSearchQuery.d.ts +13 -0
  134. package/dist/plus/git-github/api/issueSearchQuery.d.ts.map +1 -1
  135. package/dist/plus/git-github/api/issueSearchQuery.js +41 -0
  136. package/dist/plus/git-github/api/issueSearchQuery.js.map +1 -1
  137. package/dist/plus/git-github/providers/github/blame.d.ts.map +1 -1
  138. package/dist/plus/git-github/providers/github/blame.js +2 -1
  139. package/dist/plus/git-github/providers/github/blame.js.map +1 -1
  140. package/dist/plus/integrations/integrationService.d.ts +11 -0
  141. package/dist/plus/integrations/integrationService.d.ts.map +1 -1
  142. package/dist/plus/integrations/integrationService.js +4 -0
  143. package/dist/plus/integrations/integrationService.js.map +1 -1
  144. package/dist/plus/integrations/manager.d.ts +26 -0
  145. package/dist/plus/integrations/manager.d.ts.map +1 -1
  146. package/dist/plus/integrations/models/gitHostIntegration.d.ts +31 -0
  147. package/dist/plus/integrations/models/gitHostIntegration.d.ts.map +1 -1
  148. package/dist/plus/integrations/models/gitHostIntegration.js +31 -0
  149. package/dist/plus/integrations/models/gitHostIntegration.js.map +1 -1
  150. package/dist/plus/integrations/models/integration.d.ts +1 -1
  151. package/dist/plus/integrations/models/integration.d.ts.map +1 -1
  152. package/dist/plus/integrations/models/integration.js.map +1 -1
  153. package/dist/plus/integrations/providers/github.d.ts +9 -0
  154. package/dist/plus/integrations/providers/github.d.ts.map +1 -1
  155. package/dist/plus/integrations/providers/github.js +7 -0
  156. package/dist/plus/integrations/providers/github.js.map +1 -1
  157. package/dist/plus/integrations/reads/broaden.d.ts +47 -10
  158. package/dist/plus/integrations/reads/broaden.d.ts.map +1 -1
  159. package/dist/plus/integrations/reads/broaden.js +144 -33
  160. package/dist/plus/integrations/reads/broaden.js.map +1 -1
  161. package/dist/plus/integrations/reads/filters.d.ts +18 -0
  162. package/dist/plus/integrations/reads/filters.d.ts.map +1 -1
  163. package/dist/plus/integrations/reads/filters.js +37 -1
  164. package/dist/plus/integrations/reads/filters.js.map +1 -1
  165. package/dist/plus/integrations/reads/issueBatch.d.ts +51 -0
  166. package/dist/plus/integrations/reads/issueBatch.d.ts.map +1 -0
  167. package/dist/plus/integrations/reads/issueBatch.js +91 -0
  168. package/dist/plus/integrations/reads/issueBatch.js.map +1 -0
  169. package/dist/plus/integrations/reads/sweeps.d.ts.map +1 -1
  170. package/dist/plus/integrations/reads/sweeps.js +5 -3
  171. package/dist/plus/integrations/reads/sweeps.js.map +1 -1
  172. package/dist/utils/promiseCache.d.ts +19 -0
  173. package/dist/utils/promiseCache.d.ts.map +1 -1
  174. package/dist/utils/promiseCache.js +30 -0
  175. package/dist/utils/promiseCache.js.map +1 -1
  176. package/dist/utils/resourceUsage.d.ts +5 -0
  177. package/dist/utils/resourceUsage.d.ts.map +1 -0
  178. package/dist/utils/resourceUsage.js +2 -0
  179. package/dist/utils/resourceUsage.js.map +1 -0
  180. package/dist/utils/string.d.ts +12 -0
  181. package/dist/utils/string.d.ts.map +1 -1
  182. package/dist/utils/string.js +36 -1
  183. package/dist/utils/string.js.map +1 -1
  184. package/docs/integrations.md +29 -25
  185. package/docs/kepler-read-api-parity.md +12 -4
  186. package/package.json +11 -3
  187. package/src/git/cache.ts +36 -0
  188. package/src/git/context.ts +11 -0
  189. package/src/git/errors.ts +13 -1
  190. package/src/git/features.ts +51 -0
  191. package/src/git/gitHealth.ts +522 -0
  192. package/src/git/models/search.ts +19 -4
  193. package/src/git/parsers/diffParser.ts +27 -4
  194. package/src/git/providers/config.ts +42 -1
  195. package/src/git/providers/graph.ts +12 -0
  196. package/src/git/providers/maintenance.ts +224 -0
  197. package/src/git/providers/operations.ts +10 -0
  198. package/src/git/providers/pausedOperations.ts +3 -0
  199. package/src/git/providers/provider.ts +2 -0
  200. package/src/git/providers/tags.ts +1 -0
  201. package/src/git/repositoryService.ts +1 -0
  202. package/src/git/run.types.ts +8 -0
  203. package/src/git/service.ts +3 -0
  204. package/src/git/utils/search.utils.ts +129 -5
  205. package/src/git/watching/watchService.ts +10 -0
  206. package/src/git-cli/cliGitProvider.ts +25 -0
  207. package/src/git-cli/exec/git.ts +120 -6
  208. package/src/git-cli/exec/gitQueue.ts +19 -17
  209. package/src/git-cli/parsers/logParser.ts +4 -1
  210. package/src/git-cli/providers/blame.ts +3 -8
  211. package/src/git-cli/providers/branches.ts +28 -8
  212. package/src/git-cli/providers/commits.ts +34 -2
  213. package/src/git-cli/providers/config.ts +38 -6
  214. package/src/git-cli/providers/graph.ts +107 -7
  215. package/src/git-cli/providers/maintenance.ts +2599 -0
  216. package/src/git-cli/providers/operations.ts +11 -0
  217. package/src/git-cli/providers/pausedOperations.ts +7 -2
  218. package/src/git-cli/providers/tags.ts +36 -1
  219. package/src/plus/agents/agentCapabilities.ts +286 -0
  220. package/src/plus/agents/providers/claudeCodeTranscript.ts +313 -58
  221. package/src/plus/agents/providers/{claudeCodeProvider.ts → gkAgentProvider.ts} +1123 -277
  222. package/src/plus/agents/types.ts +137 -40
  223. package/src/plus/ai/models/promptTemplates.ts +1 -1
  224. package/src/plus/ai/prompts.ts +62 -4
  225. package/src/plus/git-github/api/github.ts +353 -24
  226. package/src/plus/git-github/api/issueSearchQuery.ts +49 -1
  227. package/src/plus/git-github/providers/github/blame.ts +2 -1
  228. package/src/plus/integrations/integrationService.ts +15 -0
  229. package/src/plus/integrations/manager.ts +26 -0
  230. package/src/plus/integrations/models/gitHostIntegration.ts +48 -0
  231. package/src/plus/integrations/models/integration.ts +1 -0
  232. package/src/plus/integrations/providers/github.ts +18 -0
  233. package/src/plus/integrations/reads/broaden.ts +282 -70
  234. package/src/plus/integrations/reads/filters.ts +39 -1
  235. package/src/plus/integrations/reads/issueBatch.ts +197 -0
  236. package/src/plus/integrations/reads/sweeps.ts +19 -12
  237. package/src/utils/promiseCache.ts +43 -0
  238. package/src/utils/resourceUsage.ts +5 -0
  239. package/src/utils/string.ts +38 -1
  240. package/dist/plus/agents/providers/claudeCodeProvider.d.ts.map +0 -1
  241. 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
- ClaudeCodeHookEvent,
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: ClaudeCodeHookEvent;
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
- * completed-session removal: a session the live `SessionEnd` path just transitioned is never
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
- /** Set once a completed session's transcript has been read on demand (via
138
- * `resolveCompletedSessionDetails`). Terminal transcripts don't change, so the read happens at
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
- completedDetailsResolved?: boolean;
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-completed row is protected from absence-based removal. The CLI persists the
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 completed will list it — this guards only against that ordering changing on the CLI
152
- * side, where the cost of being wrong is a completed row silently vanishing. */
153
- const completedRemovalGraceMs = 30 * 1000; // 30 seconds
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. `getPastSessions` asks for it once per worktree on panel open, so without
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
- /** Completed sessions defer git + transcript resolution (a 30-day cold-start would otherwise fan out
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 recentCompletedGitResolveThresholdMs = 24 * 60 * 60 * 1000; // 24 hours
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 completed sessions attach to branch cards / WIP rows / the resume picker at
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 `completed` sessions; `archived` is excluded by the query. */
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 completed rows. */
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 ClaudeCodeProvider implements AgentSessionProvider {
385
- readonly id = 'claudeCode';
386
- readonly name = 'Claude Code';
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 Claude hooks are installed, pushed by the host via {@link setClaudeHooksInstalled}.
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 _claudeHooksInstalled = true;
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
- * completed rows are untenable — no poll can ever confirm or archive one — so `SessionEnd`
421
- * reverts to removing the session and reconciliation drops any completed stragglers. */
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
- setClaudeHooksInstalled(installed: boolean): void {
492
- const wasOff = !this._claudeHooksInstalled;
493
- this._claudeHooksInstalled = installed;
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 past sessions out of Claude's transcript store. Claude homes each transcript under the
506
- * directory encoding the session's current cwd (migrating it if the session `cd`s), so the
507
- * directory for `cwd` is exactly what `claude --resume <id>` can find when invoked from there —
508
- * every session returned is resumable from `cwd`. */
509
- async listResumableSessions(cwd: string, options?: ResumableSessionsOptions): Promise<ResumableSessionsResult> {
510
- const { sessions, total } = await this._transcriptReader.listSessions(cwd, {
511
- limit: options?.limit,
512
- excludeSessionIds: options?.excludeSessionIds,
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 resumable = sessions.map<ResumableAgentSession>(s => ({
516
- id: s.sessionId,
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: resumable, total: total };
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 ClaudeCodeProvider.prototype.ensureIpcServer>()
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 `completed` rows: an older-version peer has no `completed` phase and
576
- // would import them as phantom "idle" sessions. Our own poll surfaces completed sessions
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 !== 'completed')
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
- `ClaudeCodeProvider.agents/sessions/open: ${ex instanceof Error ? ex.message : String(ex)}`,
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, 'ClaudeCodeProvider.ensureIpcServer');
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, 'ClaudeCodeProvider.ensureIpcServer');
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. Absent = accept: older CLIs
658
- // don't stamp it. A dropped blocking request gets no decision, so the CLI waits out its own
659
- // hook timeout correct, since we must not answer for an agent we don't track.
660
- if (event.providerId != null && event.providerId !== 'claude-code') {
661
- Logger.debug(`ClaudeCodeProvider.handleSessionEvent: ignoring ${event.event} from ${event.providerId}`);
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 (event.event === 'SessionStart' || event.event === 'SessionEnd') {
680
- Logger.info(`ClaudeCodeProvider.handleSessionEvent: ${event.event} ${tag}`);
1021
+ if (hookEvent === 'SessionStart' || hookEvent === 'SessionEnd') {
1022
+ Logger.info(`GkAgentProvider.handleSessionEvent: ${eventTag} ${tag}`);
681
1023
  } else {
682
1024
  Logger.debug(
683
- `ClaudeCodeProvider.handleSessionEvent: ${event.event} ${tag}${event.toolName ? ` tool=${event.toolName}` : ''}${event.agentId ? ` agent=${event.agentId}` : ''}${event.notificationType ? ` type=${event.notificationType}` : ''}`,
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 (event.event) {
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 === 'completed') {
705
- // Resuming a terminal session reuses its id, so a SessionStart on a completed
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
- // completed/archivable state) with clean bookkeeping that completeSession tore
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?.(this.id);
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 `completed` row rather than removing it — completed
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.completeSession(index, new Date());
1092
+ this.endSession(index, new Date());
751
1093
  } else {
752
- // Legacy CLI (no `--status` durable store): a completed row could never be
753
- // poll-confirmed nor archived, so keep the pre-completed remove-on-end behavior.
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?.(this.id);
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 hookInput = event.hookInput;
816
- const toolInput = (hookInput?.tool_input as Record<string, unknown> | undefined) ?? event.toolInput;
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, event.event, this.settledByFromEvent(event));
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: event.toolName,
1257
+ statusDetail: toolName,
913
1258
  pendingPermission: synthesizeUnresolvableAsk(
914
- event.toolName,
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: event.toolName,
1272
+ statusDetail: toolName,
928
1273
  pendingPermission: {
929
1274
  kind: 'elicitation',
930
- toolName: event.toolName ?? 'Input Required',
931
- toolDescription: event.toolName ?? 'Waiting for input',
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
- if (isBlocking && toolInput != null) {
947
- const toolName = (hookInput?.tool_name as string | undefined) ?? event.toolName ?? '';
948
- const toolDescription = describeToolInput(toolName, toolInput);
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(toolName);
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 === 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
- `ClaudeCodeProvider.handleSessionEvent: auto-denying stale permission ${tag} tool=${existing.toolName}`,
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: 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: 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: event.toolName,
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(event.sessionId, 'PermissionDenied', this.settledByFromEvent(event));
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: event.toolName ?? 'Input Required',
1086
- toolDescription: event.toolName ?? 'Waiting for input',
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: event.toolName,
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: this.id,
1131
- providerName: this.name,
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.hookInput?.tool_name as string | undefined) ?? event.toolName;
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(sessionId: string, event: AgentSessionEvent): boolean {
1364
- const filePath = this.getEventFilePath(event);
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.hookInput?.tool_name as string | undefined) ?? event.toolName;
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(sessionId: string, event: AgentSessionEvent): boolean {
1473
- const filePath = this.getEventReadPath(event);
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 completed by a
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 === 'completed') return;
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(event: AgentSessionEvent): {
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: (hookInput?.tool_name as string | undefined) ?? event.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
- `ClaudeCodeProvider.clearStalePermission: skip (identity mismatch) ${reason} ${this.sessionTag(sessionId)} pending=${pending.toolUseId ?? pending.toolName} settledBy=${settledBy.toolUseId ?? settledBy.toolName ?? 'unknown'}`,
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(`ClaudeCodeProvider.clearStalePermission: ${reason} ${this.sessionTag(sessionId)}`);
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
- `ClaudeCodeProvider.clearStalePermission: skip (display-only identity mismatch) ${reason} ${this.sessionTag(sessionId)} pending=${bk.pendingPermission.toolName} settledBy=${settledBy.toolName}`,
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(`ClaudeCodeProvider.clearStalePermission: ${reason} ${this.sessionTag(sessionId)}`);
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
- `ClaudeCodeProvider.resolvePermission: ${decision} ${this.sessionTag(sessionId)} tool=${pending.toolName}`,
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: this.id,
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 `completed` since the click) must never
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 !== 'completed') {
2218
+ if (session != null && session.status !== 'ended') {
1826
2219
  Logger.warn(
1827
- `ClaudeCodeProvider.archiveSession: refusing to archive non-completed ${this.sessionTag(sessionId)} (status=${session.status})`,
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(`ClaudeCodeProvider.archiveSession: ${this.sessionTag(sessionId)}`);
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
- resolveCompletedSessionDetails(sessionId: string): void {
2273
+ resolveEndedSessionDetails(sessionId: string): void {
1881
2274
  const session = this._sessions.find(s => s.id === sessionId);
1882
- // Only completed sessions defer resolution; live sessions resolve eagerly on discovery.
1883
- if (session?.status !== 'completed') return;
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.completedDetailsResolved) return;
2283
+ if (bk.endedDetailsResolved) return;
1891
2284
 
1892
- bk.completedDetailsResolved = true;
2285
+ bk.endedDetailsResolved = true;
1893
2286
 
1894
2287
  if (session.cwd) {
1895
2288
  void this.resolveGitInfo(sessionId, session.cwd);
1896
2289
  }
1897
- void this.applyCompletedTranscriptDetails(sessionId, session.cwd);
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
- private async applyCompletedTranscriptDetails(sessionId: string, cwd: string | undefined): Promise<void> {
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.resolveCompletedDetails(sessionId, cwd);
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.completedDetailsResolved = false;
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 !== 'completed') return;
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 { pid, workspacePath, isInWorkspace, cwd, initialCwd, planFile, sessionName, model, worktreePath } =
2073
- context ?? {};
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: this.id,
2085
- providerName: this.name,
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
- `ClaudeCodeProvider.ensureSession: implicitly created ${this.sessionTag(sessionId, sessionName ?? 'unnamed')}`,
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
- const locationNeedsProbe = cwdMoved && (worktreePath == null || worktreeMoved || existing.worktreePath == null);
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
- // Git not available or not a git repo fall through to mark unresolvable below
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
- if (cwd !== session.cwd) {
2282
- this._sessions[index] = { ...session, cwd: cwd };
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 `completed` state in place: keeps the row,
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 completeSession(index: number, endedAt: Date): void {
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: 'completed',
2368
- phase: 'completed',
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 completed'));
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
- // Completed sessions are terminal and durable — their PID is usually dead by design, so
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 === 'completed') {
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
- `ClaudeCodeProvider.syncSessions: removed ${removedIds.length} stale session(s): ${removedIds.map(id => id.substring(0, 8)).join(', ')}`,
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, completed included. A completed row is something
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._claudeHooksInstalled) return;
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._claudeHooksInstalled &&
2471
- !this._sessions.some(s => s.status !== 'completed') &&
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 completed the row" from "an in-flight poll that predates it" — see the
2479
- // completed-removal filter.
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 completed sessions at all.
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; completed sessions are durable history, so their
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
- const trackedLiveBefore = new Set(this._sessions.filter(s => s.status !== 'completed').map(s => s.id));
2522
- const polledAlive = new Set<string>();
2523
- // Every id the poll returned (live *and* completed). Drives completed-session reconciliation:
2524
- // a tracked completed session absent from this set was archived/purged by the CLI.
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
- let discovered = 0;
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
- // Another AI host's record in the CLI's shared store. Must stay ahead of `polledIds`/
2537
- // `polledAlive`: an id in either would skew completed-row reconciliation and the
2538
- // `onSyncDiscrepancy` drift signal below.
2539
- if (data.providerId != null && data.providerId !== 'claude-code') continue;
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
- // Already tracked. If the live path missed the SessionEnd (e.g. `/clear` kept the
2553
- // PID alive under a new id), transition it to completed now — otherwise a live-status
2554
- // row would linger as a zombie, since pruneDeadSessions can't reap a live PID.
2555
- if (this._sessions[existingIndex].status !== 'completed') {
2556
- this.completeSession(existingIndex, endedDate);
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 the record's location. The durable record is authoritative for a
2561
- // terminal session, and a `CwdChanged` hook we never received would otherwise leave the
2562
- // card attached to the wrong worktree and "Open Session" resuming from a stale
2563
- // directory. Deliberately limited to location: prompts/titles have their own on-demand
2564
- // transcript resolution, which holds richer values than the record does.
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 worktreeRoot = worktreeRootFromData(data);
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
- // change is a discrete event, so that's one probe, not the cold-start fan-out the
2572
- // completed path exists to avoid.
2573
- const nextWorktreePath =
2574
- worktreeRoot != null
2575
- ? normalizePath(worktreeRoot)
2576
- : cwdChanged
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(data.cwd || existing.cwd);
3225
+ const workspacePath = this.resolveWorkspacePath(currentCwd);
2582
3226
  this._sessions[existingIndex] = {
2583
3227
  ...existing,
2584
- cwd: data.cwd || existing.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), so resolve the new cwd
2598
- // once rather than leaving the row location-less.
2599
- if (nextWorktreePath == null && data.cwd) {
2600
- void this.resolveGitInfo(data.sessionId, data.cwd);
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
- this.getBookkeeping(data.sessionId).polledAtLeastOnce = true;
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(data.cwd);
2610
- const worktreeRoot = worktreeRootFromData(data);
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: this.id,
2614
- providerName: this.name,
3345
+ providerId: capabilities.providerId,
3346
+ providerName: capabilities.displayName,
2615
3347
  name: data.sessionName || undefined,
2616
- status: 'completed',
2617
- phase: 'completed',
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 completed sessions attach to branch
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: worktreeRoot != null ? normalizePath(worktreeRoot) : undefined,
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
- // Titles/prompts still resolve lazily via resolveCompletedSessionDetails (a 30-day
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
- worktreeRoot == null &&
3384
+ recordWorktreeRoot == null &&
2646
3385
  data.cwd &&
2647
- Date.now() - endedDate.getTime() < recentCompletedGitResolveThresholdMs
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.add(data.sessionId);
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(data.event);
2669
- if (existing.status === 'completed') {
2670
- // We hold it as `completed` but the CLI now reports it as a live process (a
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→completed transition above. Leave genuinely-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 completion. `SessionEnd` fires
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 completed row.
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(data.toolName, data.planFile ?? undefined)
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 completed.
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 completed row's attribution. A
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 completed to
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
- if (data.cwd != null && data.cwd !== existing.cwd) {
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' ? (data.toolName ?? undefined) : undefined,
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(data.toolName, data.planFile ?? undefined)
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 completed-session push below.
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: this.id,
2789
- providerName: this.name,
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: this.id,
2803
- providerName: this.name,
3607
+ providerId: capabilities.providerId,
3608
+ providerName: capabilities.displayName,
2804
3609
  name: data.sessionName || undefined,
2805
3610
  status: status,
2806
3611
  phase: phase,
2807
- phaseSince: activityDate,
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
- discovered++;
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 completed-session removal (archived elsewhere or 30-day-purged).
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 a completed row, so the guard would
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 staleCompleted = this._sessions.filter(
3655
+ const staleEnded = this._sessions.filter(
2845
3656
  s =>
2846
- s.status === 'completed' &&
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 completion, so its absence is authoritative.
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
- // `completeSession` drops the bookkeeping — so without this the row is unremovable for
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-completed row out of reach of
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 - completedRemovalGraceMs),
3668
+ s.phaseSince.getTime() < pollStartedAt - endedRemovalGraceMs),
2858
3669
  );
2859
- if (staleCompleted.length > 0) {
2860
- const staleIds = new Set(staleCompleted.map(s => s.id));
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 staleCompleted) {
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
- let missing = 0;
2884
- for (const id of trackedLiveBefore) {
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
- if (discovered > 0 || missing > 0) {
2890
- this.callbacks.onSyncDiscrepancy?.({
2891
- provider: this.id,
2892
- discovered: discovered,
2893
- missing: missing,
2894
- polled: polledAlive.size,
2895
- tracked: trackedLiveBefore.size,
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: completed sessions live in the machine-global CLI
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
- if (peerSession.status === 'completed') continue;
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 revivedFromCompleted = existing.status === 'completed';
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
- ...(revivedFromCompleted ? { pid: peerSession.pid, isPeerOwned: true } : undefined),
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
- `ClaudeCodeProvider.notifyPeerOpenSession: peer at ${discovery.address} returned ${response.status}`,
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
- `ClaudeCodeProvider.notifyPeerOpenSession: peer at ${discovery.address} could not open session ${sessionId}`,
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
- `ClaudeCodeProvider.notifyPeerOpenSession: peer at ${discovery.address} unreachable: ${
4025
+ `GkAgentProvider.notifyPeerOpenSession: peer at ${discovery.address} unreachable: ${
3180
4026
  ex instanceof Error ? ex.message : String(ex)
3181
4027
  }`,
3182
4028
  );