@gitkraken/core-gitlens 0.5.113 → 0.5.115

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