@gitkraken/core-gitlens 0.5.110 → 0.5.111

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 (362) hide show
  1. package/CHANGELOG.md +16 -1
  2. package/dist/git/cache.d.ts +111 -6
  3. package/dist/git/cache.d.ts.map +1 -1
  4. package/dist/git/cache.js +312 -43
  5. package/dist/git/cache.js.map +1 -1
  6. package/dist/git/context.d.ts +11 -0
  7. package/dist/git/context.d.ts.map +1 -1
  8. package/dist/git/context.js.map +1 -1
  9. package/dist/git/models/fileStatus.d.ts +1 -0
  10. package/dist/git/models/fileStatus.d.ts.map +1 -1
  11. package/dist/git/models/fileStatus.js +1 -0
  12. package/dist/git/models/fileStatus.js.map +1 -1
  13. package/dist/git/models/issue.d.ts +4 -0
  14. package/dist/git/models/issue.d.ts.map +1 -1
  15. package/dist/git/models/mergeConflicts.d.ts +2 -0
  16. package/dist/git/models/mergeConflicts.d.ts.map +1 -1
  17. package/dist/git/models/pullRequest.d.ts +38 -2
  18. package/dist/git/models/pullRequest.d.ts.map +1 -1
  19. package/dist/git/models/pullRequest.js +12 -1
  20. package/dist/git/models/pullRequest.js.map +1 -1
  21. package/dist/git/models/repository.d.ts +32 -5
  22. package/dist/git/models/repository.d.ts.map +1 -1
  23. package/dist/git/models/repository.js +58 -12
  24. package/dist/git/models/repository.js.map +1 -1
  25. package/dist/git/models/statusFile.d.ts.map +1 -1
  26. package/dist/git/models/statusFile.js +3 -0
  27. package/dist/git/models/statusFile.js.map +1 -1
  28. package/dist/git/providers/branches.d.ts +2 -0
  29. package/dist/git/providers/branches.d.ts.map +1 -1
  30. package/dist/git/providers/commits.d.ts +8 -0
  31. package/dist/git/providers/commits.d.ts.map +1 -1
  32. package/dist/git/providers/config.d.ts +19 -1
  33. package/dist/git/providers/config.d.ts.map +1 -1
  34. package/dist/git/providers/operations.d.ts +1 -0
  35. package/dist/git/providers/operations.d.ts.map +1 -1
  36. package/dist/git/providers/pausedOperations.d.ts +2 -0
  37. package/dist/git/providers/pausedOperations.d.ts.map +1 -1
  38. package/dist/git/providers/status.d.ts +1 -0
  39. package/dist/git/providers/status.d.ts.map +1 -1
  40. package/dist/git/utils/config.utils.d.ts +3 -0
  41. package/dist/git/utils/config.utils.d.ts.map +1 -0
  42. package/dist/git/utils/config.utils.js +8 -0
  43. package/dist/git/utils/config.utils.js.map +1 -0
  44. package/dist/git/utils/issue.utils.d.ts.map +1 -1
  45. package/dist/git/utils/issue.utils.js +2 -0
  46. package/dist/git/utils/issue.utils.js.map +1 -1
  47. package/dist/git/utils/pausedOperationStatus.utils.d.ts +14 -0
  48. package/dist/git/utils/pausedOperationStatus.utils.d.ts.map +1 -1
  49. package/dist/git/utils/pausedOperationStatus.utils.js +23 -0
  50. package/dist/git/utils/pausedOperationStatus.utils.js.map +1 -1
  51. package/dist/git/utils/pullRequest.utils.d.ts +14 -1
  52. package/dist/git/utils/pullRequest.utils.d.ts.map +1 -1
  53. package/dist/git/utils/pullRequest.utils.js +31 -0
  54. package/dist/git/utils/pullRequest.utils.js.map +1 -1
  55. package/dist/git/watching/changeEvent.d.ts +6 -1
  56. package/dist/git/watching/changeEvent.d.ts.map +1 -1
  57. package/dist/git/watching/classifyChange.d.ts.map +1 -1
  58. package/dist/git/watching/classifyChange.js +14 -1
  59. package/dist/git/watching/classifyChange.js.map +1 -1
  60. package/dist/git/watching/watchService.d.ts.map +1 -1
  61. package/dist/git/watching/watchService.js +18 -3
  62. package/dist/git/watching/watchService.js.map +1 -1
  63. package/dist/git/watching/watchSession.d.ts +8 -1
  64. package/dist/git/watching/watchSession.d.ts.map +1 -1
  65. package/dist/git/watching/watchSession.js +38 -6
  66. package/dist/git/watching/watchSession.js.map +1 -1
  67. package/dist/git/watching/watcherPatterns.d.ts +2 -2
  68. package/dist/git/watching/watcherPatterns.d.ts.map +1 -1
  69. package/dist/git/watching/watcherPatterns.js +1 -1
  70. package/dist/git/watching/watcherPatterns.js.map +1 -1
  71. package/dist/git-cli/exec/git.d.ts +1 -1
  72. package/dist/git-cli/exec/git.d.ts.map +1 -1
  73. package/dist/git-cli/exec/git.js +13 -3
  74. package/dist/git-cli/exec/git.js.map +1 -1
  75. package/dist/git-cli/providers/branches.d.ts +19 -0
  76. package/dist/git-cli/providers/branches.d.ts.map +1 -1
  77. package/dist/git-cli/providers/branches.js +282 -78
  78. package/dist/git-cli/providers/branches.js.map +1 -1
  79. package/dist/git-cli/providers/commits.d.ts +4 -0
  80. package/dist/git-cli/providers/commits.d.ts.map +1 -1
  81. package/dist/git-cli/providers/commits.js +84 -11
  82. package/dist/git-cli/providers/commits.js.map +1 -1
  83. package/dist/git-cli/providers/config.d.ts +42 -0
  84. package/dist/git-cli/providers/config.d.ts.map +1 -1
  85. package/dist/git-cli/providers/config.js +186 -5
  86. package/dist/git-cli/providers/config.js.map +1 -1
  87. package/dist/git-cli/providers/contributors.d.ts.map +1 -1
  88. package/dist/git-cli/providers/contributors.js +1 -1
  89. package/dist/git-cli/providers/contributors.js.map +1 -1
  90. package/dist/git-cli/providers/graph.d.ts +2 -2
  91. package/dist/git-cli/providers/graph.d.ts.map +1 -1
  92. package/dist/git-cli/providers/graph.js +27 -9
  93. package/dist/git-cli/providers/graph.js.map +1 -1
  94. package/dist/git-cli/providers/operations.d.ts.map +1 -1
  95. package/dist/git-cli/providers/operations.js +40 -12
  96. package/dist/git-cli/providers/operations.js.map +1 -1
  97. package/dist/git-cli/providers/pausedOperations.d.ts +5 -0
  98. package/dist/git-cli/providers/pausedOperations.d.ts.map +1 -1
  99. package/dist/git-cli/providers/pausedOperations.js +87 -16
  100. package/dist/git-cli/providers/pausedOperations.js.map +1 -1
  101. package/dist/git-cli/providers/refs.d.ts +5 -3
  102. package/dist/git-cli/providers/refs.d.ts.map +1 -1
  103. package/dist/git-cli/providers/refs.js +33 -4
  104. package/dist/git-cli/providers/refs.js.map +1 -1
  105. package/dist/git-cli/providers/status.d.ts +1 -0
  106. package/dist/git-cli/providers/status.d.ts.map +1 -1
  107. package/dist/git-cli/providers/status.js +18 -3
  108. package/dist/git-cli/providers/status.js.map +1 -1
  109. package/dist/git-cli/providers/worktrees.d.ts.map +1 -1
  110. package/dist/git-cli/providers/worktrees.js +4 -0
  111. package/dist/git-cli/providers/worktrees.js.map +1 -1
  112. package/dist/git-cli/service.d.ts.map +1 -1
  113. package/dist/git-cli/service.js.map +1 -1
  114. package/dist/plus/agents/providers/claudeCodeProvider.d.ts +39 -1
  115. package/dist/plus/agents/providers/claudeCodeProvider.d.ts.map +1 -1
  116. package/dist/plus/agents/providers/claudeCodeProvider.js +978 -71
  117. package/dist/plus/agents/providers/claudeCodeProvider.js.map +1 -1
  118. package/dist/plus/agents/providers/claudeCodeTranscript.d.ts +24 -2
  119. package/dist/plus/agents/providers/claudeCodeTranscript.d.ts.map +1 -1
  120. package/dist/plus/agents/providers/claudeCodeTranscript.js +84 -9
  121. package/dist/plus/agents/providers/claudeCodeTranscript.js.map +1 -1
  122. package/dist/plus/agents/types.d.ts +37 -2
  123. package/dist/plus/agents/types.d.ts.map +1 -1
  124. package/dist/plus/agents/types.js +2 -0
  125. package/dist/plus/agents/types.js.map +1 -1
  126. package/dist/plus/ai/errors.d.ts +10 -0
  127. package/dist/plus/ai/errors.d.ts.map +1 -1
  128. package/dist/plus/ai/errors.js +29 -0
  129. package/dist/plus/ai/errors.js.map +1 -1
  130. package/dist/plus/ai/models/model.d.ts +4 -0
  131. package/dist/plus/ai/models/model.d.ts.map +1 -1
  132. package/dist/plus/ai/models/model.js +51 -6
  133. package/dist/plus/ai/models/model.js.map +1 -1
  134. package/dist/plus/ai/models/promptTemplates.d.ts +1 -1
  135. package/dist/plus/ai/models/promptTemplates.d.ts.map +1 -1
  136. package/dist/plus/ai/models/provider.d.ts +60 -2
  137. package/dist/plus/ai/models/provider.d.ts.map +1 -1
  138. package/dist/plus/ai/prompts.d.ts +12 -0
  139. package/dist/plus/ai/prompts.d.ts.map +1 -1
  140. package/dist/plus/ai/prompts.js +177 -61
  141. package/dist/plus/ai/prompts.js.map +1 -1
  142. package/dist/plus/ai/providers/anthropicProvider.d.ts +10 -2
  143. package/dist/plus/ai/providers/anthropicProvider.d.ts.map +1 -1
  144. package/dist/plus/ai/providers/anthropicProvider.js +139 -46
  145. package/dist/plus/ai/providers/anthropicProvider.js.map +1 -1
  146. package/dist/plus/ai/providers/deepSeekProvider.d.ts +2 -0
  147. package/dist/plus/ai/providers/deepSeekProvider.d.ts.map +1 -1
  148. package/dist/plus/ai/providers/deepSeekProvider.js +10 -21
  149. package/dist/plus/ai/providers/deepSeekProvider.js.map +1 -1
  150. package/dist/plus/ai/providers/geminiProvider.d.ts +1 -0
  151. package/dist/plus/ai/providers/geminiProvider.d.ts.map +1 -1
  152. package/dist/plus/ai/providers/geminiProvider.js +19 -0
  153. package/dist/plus/ai/providers/geminiProvider.js.map +1 -1
  154. package/dist/plus/ai/providers/gitkrakenProvider.d.ts +5 -1
  155. package/dist/plus/ai/providers/gitkrakenProvider.d.ts.map +1 -1
  156. package/dist/plus/ai/providers/gitkrakenProvider.js +118 -3
  157. package/dist/plus/ai/providers/gitkrakenProvider.js.map +1 -1
  158. package/dist/plus/ai/providers/huggingFaceProvider.d.ts +1 -0
  159. package/dist/plus/ai/providers/huggingFaceProvider.d.ts.map +1 -1
  160. package/dist/plus/ai/providers/huggingFaceProvider.js +5 -0
  161. package/dist/plus/ai/providers/huggingFaceProvider.js.map +1 -1
  162. package/dist/plus/ai/providers/mistralProvider.d.ts +3 -1
  163. package/dist/plus/ai/providers/mistralProvider.d.ts.map +1 -1
  164. package/dist/plus/ai/providers/mistralProvider.js +99 -19
  165. package/dist/plus/ai/providers/mistralProvider.js.map +1 -1
  166. package/dist/plus/ai/providers/ollamaProvider.d.ts +4 -3
  167. package/dist/plus/ai/providers/ollamaProvider.d.ts.map +1 -1
  168. package/dist/plus/ai/providers/ollamaProvider.js +38 -3
  169. package/dist/plus/ai/providers/ollamaProvider.js.map +1 -1
  170. package/dist/plus/ai/providers/openAICompatibleProvider.d.ts +1 -0
  171. package/dist/plus/ai/providers/openAICompatibleProvider.d.ts.map +1 -1
  172. package/dist/plus/ai/providers/openAICompatibleProvider.js +4 -0
  173. package/dist/plus/ai/providers/openAICompatibleProvider.js.map +1 -1
  174. package/dist/plus/ai/providers/openAICompatibleProviderBase.d.ts +154 -5
  175. package/dist/plus/ai/providers/openAICompatibleProviderBase.d.ts.map +1 -1
  176. package/dist/plus/ai/providers/openAICompatibleProviderBase.js +259 -10
  177. package/dist/plus/ai/providers/openAICompatibleProviderBase.js.map +1 -1
  178. package/dist/plus/ai/providers/openRouterProvider.d.ts +4 -0
  179. package/dist/plus/ai/providers/openRouterProvider.d.ts.map +1 -1
  180. package/dist/plus/ai/providers/openRouterProvider.js +9 -0
  181. package/dist/plus/ai/providers/openRouterProvider.js.map +1 -1
  182. package/dist/plus/ai/providers/openaiProvider.d.ts +1 -0
  183. package/dist/plus/ai/providers/openaiProvider.d.ts.map +1 -1
  184. package/dist/plus/ai/providers/openaiProvider.js +1 -0
  185. package/dist/plus/ai/providers/openaiProvider.js.map +1 -1
  186. package/dist/plus/ai/providers/responseFormatCache.d.ts +8 -0
  187. package/dist/plus/ai/providers/responseFormatCache.d.ts.map +1 -0
  188. package/dist/plus/ai/providers/responseFormatCache.js +25 -0
  189. package/dist/plus/ai/providers/responseFormatCache.js.map +1 -0
  190. package/dist/plus/ai/providers/xaiProvider.d.ts +1 -0
  191. package/dist/plus/ai/providers/xaiProvider.d.ts.map +1 -1
  192. package/dist/plus/ai/providers/xaiProvider.js +10 -1
  193. package/dist/plus/ai/providers/xaiProvider.js.map +1 -1
  194. package/dist/plus/ai/utils/ai.utils.d.ts +3 -0
  195. package/dist/plus/ai/utils/ai.utils.d.ts.map +1 -1
  196. package/dist/plus/ai/utils/ai.utils.js +13 -0
  197. package/dist/plus/ai/utils/ai.utils.js.map +1 -1
  198. package/dist/plus/ai/utils/results.utils.d.ts +16 -2
  199. package/dist/plus/ai/utils/results.utils.d.ts.map +1 -1
  200. package/dist/plus/ai/utils/results.utils.js +204 -28
  201. package/dist/plus/ai/utils/results.utils.js.map +1 -1
  202. package/dist/plus/git-github/api/github.d.ts +55 -0
  203. package/dist/plus/git-github/api/github.d.ts.map +1 -1
  204. package/dist/plus/git-github/api/github.js +177 -4
  205. package/dist/plus/git-github/api/github.js.map +1 -1
  206. package/dist/plus/git-github/models.d.ts +16 -0
  207. package/dist/plus/git-github/models.d.ts.map +1 -1
  208. package/dist/plus/git-github/models.js +29 -3
  209. package/dist/plus/git-github/models.js.map +1 -1
  210. package/dist/plus/git-github/providers/github/status.d.ts +1 -0
  211. package/dist/plus/git-github/providers/github/status.d.ts.map +1 -1
  212. package/dist/plus/git-github/providers/github/status.js.map +1 -1
  213. package/dist/plus/integrations/context.d.ts +4 -0
  214. package/dist/plus/integrations/context.d.ts.map +1 -1
  215. package/dist/plus/integrations/index.d.ts.map +1 -1
  216. package/dist/plus/integrations/index.js +2 -0
  217. package/dist/plus/integrations/index.js.map +1 -1
  218. package/dist/plus/integrations/models/gitHostIntegration.d.ts +6 -3
  219. package/dist/plus/integrations/models/gitHostIntegration.d.ts.map +1 -1
  220. package/dist/plus/integrations/models/gitHostIntegration.js +12 -2
  221. package/dist/plus/integrations/models/gitHostIntegration.js.map +1 -1
  222. package/dist/plus/integrations/models/integration.d.ts +3 -1
  223. package/dist/plus/integrations/models/integration.d.ts.map +1 -1
  224. package/dist/plus/integrations/models/integration.js +2 -2
  225. package/dist/plus/integrations/models/integration.js.map +1 -1
  226. package/dist/plus/integrations/providers/accounts.d.ts.map +1 -1
  227. package/dist/plus/integrations/providers/accounts.js +4 -0
  228. package/dist/plus/integrations/providers/accounts.js.map +1 -1
  229. package/dist/plus/integrations/providers/azure/models.d.ts.map +1 -1
  230. package/dist/plus/integrations/providers/azure/models.js +3 -0
  231. package/dist/plus/integrations/providers/azure/models.js.map +1 -1
  232. package/dist/plus/integrations/providers/bitbucket/models.d.ts.map +1 -1
  233. package/dist/plus/integrations/providers/bitbucket/models.js +1 -0
  234. package/dist/plus/integrations/providers/bitbucket/models.js.map +1 -1
  235. package/dist/plus/integrations/providers/github.d.ts +14 -2
  236. package/dist/plus/integrations/providers/github.d.ts.map +1 -1
  237. package/dist/plus/integrations/providers/github.js +55 -3
  238. package/dist/plus/integrations/providers/github.js.map +1 -1
  239. package/dist/plus/integrations/providers/gitlab.d.ts.map +1 -1
  240. package/dist/plus/integrations/providers/gitlab.js.map +1 -1
  241. package/dist/plus/integrations/providers/models.d.ts.map +1 -1
  242. package/dist/plus/integrations/providers/models.js +30 -4
  243. package/dist/plus/integrations/providers/models.js.map +1 -1
  244. package/dist/plus/integrations/reads/broaden.d.ts.map +1 -1
  245. package/dist/plus/integrations/reads/broaden.js +6 -1
  246. package/dist/plus/integrations/reads/broaden.js.map +1 -1
  247. package/dist/plus/integrations/reads/drains.d.ts.map +1 -1
  248. package/dist/plus/integrations/reads/drains.js +7 -4
  249. package/dist/plus/integrations/reads/drains.js.map +1 -1
  250. package/dist/plus/integrations/reads/hierarchy.d.ts.map +1 -1
  251. package/dist/plus/integrations/reads/hierarchy.js +3 -1
  252. package/dist/plus/integrations/reads/hierarchy.js.map +1 -1
  253. package/dist/plus/integrations/reads/issueTracker.d.ts.map +1 -1
  254. package/dist/plus/integrations/reads/issueTracker.js +8 -3
  255. package/dist/plus/integrations/reads/issueTracker.js.map +1 -1
  256. package/dist/plus/integrations/reads/issues.d.ts.map +1 -1
  257. package/dist/plus/integrations/reads/issues.js +9 -4
  258. package/dist/plus/integrations/reads/issues.js.map +1 -1
  259. package/dist/plus/integrations/reads/pullRequests.d.ts.map +1 -1
  260. package/dist/plus/integrations/reads/pullRequests.js +9 -4
  261. package/dist/plus/integrations/reads/pullRequests.js.map +1 -1
  262. package/dist/utils/env/browser/base64.d.ts +1 -1
  263. package/dist/utils/env/browser/base64.d.ts.map +1 -1
  264. package/dist/utils/env/node/base64.d.ts +1 -1
  265. package/dist/utils/env/node/base64.d.ts.map +1 -1
  266. package/dist/utils/env/node/base64.js +2 -0
  267. package/dist/utils/env/node/base64.js.map +1 -1
  268. package/dist/utils/keys/chord.d.ts +52 -0
  269. package/dist/utils/keys/chord.d.ts.map +1 -0
  270. package/dist/utils/keys/chord.js +135 -0
  271. package/dist/utils/keys/chord.js.map +1 -0
  272. package/dist/utils/keys/keybinding.d.ts +53 -0
  273. package/dist/utils/keys/keybinding.d.ts.map +1 -0
  274. package/dist/utils/keys/keybinding.js +58 -0
  275. package/dist/utils/keys/keybinding.js.map +1 -0
  276. package/dist/utils/path.d.ts +11 -0
  277. package/dist/utils/path.d.ts.map +1 -1
  278. package/dist/utils/path.js +13 -0
  279. package/dist/utils/path.js.map +1 -1
  280. package/package.json +13 -5
  281. package/src/git/cache.ts +383 -47
  282. package/src/git/context.ts +11 -0
  283. package/src/git/models/fileStatus.ts +1 -0
  284. package/src/git/models/issue.ts +4 -0
  285. package/src/git/models/mergeConflicts.ts +3 -2
  286. package/src/git/models/pullRequest.ts +33 -1
  287. package/src/git/models/repository.ts +58 -17
  288. package/src/git/models/statusFile.ts +3 -0
  289. package/src/git/providers/branches.ts +6 -0
  290. package/src/git/providers/commits.ts +11 -0
  291. package/src/git/providers/config.ts +18 -0
  292. package/src/git/providers/operations.ts +1 -0
  293. package/src/git/providers/pausedOperations.ts +2 -0
  294. package/src/git/providers/status.ts +1 -0
  295. package/src/git/utils/config.utils.ts +8 -0
  296. package/src/git/utils/issue.utils.ts +2 -0
  297. package/src/git/utils/pausedOperationStatus.utils.ts +28 -0
  298. package/src/git/utils/pullRequest.utils.ts +36 -0
  299. package/src/git/watching/changeEvent.ts +6 -1
  300. package/src/git/watching/classifyChange.ts +15 -1
  301. package/src/git/watching/watchService.ts +20 -3
  302. package/src/git/watching/watchSession.ts +41 -6
  303. package/src/git/watching/watcherPatterns.ts +1 -1
  304. package/src/git-cli/exec/git.ts +13 -3
  305. package/src/git-cli/providers/branches.ts +384 -132
  306. package/src/git-cli/providers/commits.ts +106 -15
  307. package/src/git-cli/providers/config.ts +229 -8
  308. package/src/git-cli/providers/contributors.ts +5 -1
  309. package/src/git-cli/providers/graph.ts +26 -13
  310. package/src/git-cli/providers/operations.ts +45 -20
  311. package/src/git-cli/providers/pausedOperations.ts +109 -17
  312. package/src/git-cli/providers/refs.ts +33 -3
  313. package/src/git-cli/providers/status.ts +25 -4
  314. package/src/git-cli/providers/worktrees.ts +5 -0
  315. package/src/git-cli/service.ts +0 -1
  316. package/src/plus/agents/providers/claudeCodeProvider.ts +1130 -87
  317. package/src/plus/agents/providers/claudeCodeTranscript.ts +95 -9
  318. package/src/plus/agents/types.ts +46 -2
  319. package/src/plus/ai/errors.ts +30 -0
  320. package/src/plus/ai/models/model.ts +56 -6
  321. package/src/plus/ai/models/promptTemplates.ts +1 -1
  322. package/src/plus/ai/models/provider.ts +69 -2
  323. package/src/plus/ai/prompts.ts +186 -61
  324. package/src/plus/ai/providers/anthropicProvider.ts +155 -47
  325. package/src/plus/ai/providers/deepSeekProvider.ts +11 -21
  326. package/src/plus/ai/providers/geminiProvider.ts +19 -0
  327. package/src/plus/ai/providers/gitkrakenProvider.ts +126 -3
  328. package/src/plus/ai/providers/huggingFaceProvider.ts +6 -0
  329. package/src/plus/ai/providers/mistralProvider.ts +102 -19
  330. package/src/plus/ai/providers/ollamaProvider.ts +55 -4
  331. package/src/plus/ai/providers/openAICompatibleProvider.ts +5 -0
  332. package/src/plus/ai/providers/openAICompatibleProviderBase.ts +360 -17
  333. package/src/plus/ai/providers/openRouterProvider.ts +17 -0
  334. package/src/plus/ai/providers/openaiProvider.ts +1 -0
  335. package/src/plus/ai/providers/responseFormatCache.ts +39 -0
  336. package/src/plus/ai/providers/xaiProvider.ts +10 -1
  337. package/src/plus/ai/utils/ai.utils.ts +14 -0
  338. package/src/plus/ai/utils/results.utils.ts +255 -31
  339. package/src/plus/git-github/api/github.ts +291 -7
  340. package/src/plus/git-github/models.ts +48 -2
  341. package/src/plus/git-github/providers/github/status.ts +1 -0
  342. package/src/plus/integrations/context.ts +4 -0
  343. package/src/plus/integrations/index.ts +2 -0
  344. package/src/plus/integrations/models/gitHostIntegration.ts +25 -2
  345. package/src/plus/integrations/models/integration.ts +23 -13
  346. package/src/plus/integrations/providers/accounts.ts +4 -0
  347. package/src/plus/integrations/providers/azure/models.ts +3 -0
  348. package/src/plus/integrations/providers/bitbucket/models.ts +1 -0
  349. package/src/plus/integrations/providers/github.ts +85 -1
  350. package/src/plus/integrations/providers/gitlab.ts +2 -10
  351. package/src/plus/integrations/providers/models.ts +36 -4
  352. package/src/plus/integrations/reads/broaden.ts +6 -1
  353. package/src/plus/integrations/reads/drains.ts +7 -4
  354. package/src/plus/integrations/reads/hierarchy.ts +3 -1
  355. package/src/plus/integrations/reads/issueTracker.ts +9 -3
  356. package/src/plus/integrations/reads/issues.ts +9 -4
  357. package/src/plus/integrations/reads/pullRequests.ts +9 -4
  358. package/src/utils/env/browser/base64.ts +1 -1
  359. package/src/utils/env/node/base64.ts +3 -1
  360. package/src/utils/keys/chord.ts +184 -0
  361. package/src/utils/keys/keybinding.ts +116 -0
  362. package/src/utils/path.ts +14 -0
@@ -16,6 +16,26 @@ import { classifyPermissionKind, deriveStatusFromEvent, describeToolInput, extra
16
16
  import { getPhaseForStatus } from '../types.js';
17
17
  import { ClaudeCodeTranscriptReader } from './claudeCodeTranscript.js';
18
18
  const staleCheckIntervalMs = 15 * 60 * 1000; // 15 minutes
19
+ /** Minimum spacing between gated reconciliation polls while NOTHING is running. The quarter-hourly
20
+ * tick exists to reap agents that died without a `SessionEnd`; with no live session there's nothing
21
+ * to reap, leaving only terminal-history reconciliation and the CLI's retention sweep — both fine
22
+ * hourly. Window startup polls ungated, so this never delays a fresh window. */
23
+ 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
+ * 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
29
+ /** 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
+ * this a multi-worktree graph spawns N identical CLI processes. `archiveSession` invalidates the
32
+ * cache, so a just-archived row never lingers under "Past". */
33
+ const archivedSessionIdsCacheTtlMs = 10 * 1000; // 10 seconds
34
+ /** Completed sessions defer git + transcript resolution (a 30-day cold-start would otherwise fan out
35
+ * hundreds of probes). Exception: sessions ended within this window still surface on WIP rows /
36
+ * branch cards, which match strictly by resolved `worktreePath` — so their git info is resolved
37
+ * 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
19
39
  /** Default cooldown between PostToolUse and dropping the file from `fileActivity`. Held long
20
40
  * enough that the treemap activity overlay can render a decay tail well past the moment the tool
21
41
  * call completed. The host may override per-call via `AgentProviderCallbacks.getActivityDecayMs`
@@ -30,6 +50,87 @@ const stopToIdleDebounceMs = 750;
30
50
  * than `stopToIdleDebounceMs` so a continuation that just barely crosses the debounce still
31
51
  * restores continuity. */
32
52
  const phaseSinceRestoreWindowMs = 2000;
53
+ /** The session's current worktree root from the CLI's durable record — the last cwd-visit's resolved
54
+ * worktree, falling back to the last distinct worktree seen. `undefined` on older CLIs that don't
55
+ * record it, so the caller resolves git the slow way instead. */
56
+ function worktreeRootFromData(data) {
57
+ const fromTimeline = data.cwdTimeline?.at(-1)?.worktree;
58
+ if (fromTimeline)
59
+ return fromTimeline;
60
+ return data.worktrees?.at(-1) || undefined;
61
+ }
62
+ /** The CLI resolves the worktree for `event.cwd` at hook time and includes it on every event —
63
+ * using its answer seats `worktreePath` synchronously, closing the in-flight-probe window where
64
+ * a session that just moved would otherwise still show its previous worktree until `resolveGitInfo`
65
+ * catches up. Scans from the end for the timeline entry matching the event's current cwd. */
66
+ function worktreeFromEvent(event) {
67
+ const timeline = event.cwdTimeline;
68
+ if (timeline == null)
69
+ return undefined;
70
+ for (let i = timeline.length - 1; i >= 0; i--) {
71
+ const entry = timeline[i];
72
+ if (entry.cwd === event.cwd)
73
+ return entry.worktree || undefined;
74
+ }
75
+ return undefined;
76
+ }
77
+ /** Synthesizes an unresolvable ask for a `permission_requested` row that has no routable
78
+ * `PendingPermission` — a non-blocking `Notification`/`PermissionRequest` tail, or a
79
+ * poll-discovered live/revived row. None of these hold a blocking hook entry to route an
80
+ * Allow/Deny through, so the card must show what the agent is waiting on without offering
81
+ * actions the host can't fulfill. */
82
+ function synthesizeUnresolvableAsk(toolName, planFilePath) {
83
+ if (toolName != null) {
84
+ const kind = classifyPermissionKind(toolName);
85
+ return {
86
+ kind: kind,
87
+ toolName: toolName,
88
+ toolDescription: toolName,
89
+ // A plan ask with no body at least links the plan the agent wrote, so the card can offer
90
+ // View Plan instead of a bare tool name.
91
+ planFilePath: kind === 'plan' ? planFilePath : undefined,
92
+ resolvable: false,
93
+ };
94
+ }
95
+ return {
96
+ kind: 'elicitation',
97
+ toolName: 'Input Required',
98
+ toolDescription: 'Waiting for input',
99
+ resolvable: false,
100
+ };
101
+ }
102
+ /** JSON.stringify with object keys recursively sorted — array order is preserved. `tool_input`
103
+ * can arrive with different key order (or via the top-level fallback field) between deliveries;
104
+ * canonicalizing makes input equality reliable across those variations. */
105
+ function stableStringify(value) {
106
+ return JSON.stringify(value, (_key, val) => {
107
+ if (val == null || typeof val !== 'object' || Array.isArray(val))
108
+ return val;
109
+ const sorted = {};
110
+ for (const key of Object.keys(val).sort()) {
111
+ sorted[key] = val[key];
112
+ }
113
+ return sorted;
114
+ });
115
+ }
116
+ /** Field-wise equality for the ask a `permission_requested` row publishes. `updateSessionStatus`
117
+ * compares it before short-circuiting so a second ask arriving while the row is already
118
+ * `permission_requested` (a non-blocking tail for a different tool) still reaches subscribers. */
119
+ function isSameAsk(a, b) {
120
+ if (a === b)
121
+ return true;
122
+ if (a == null || b == null)
123
+ return false;
124
+ return (a.kind === b.kind &&
125
+ a.toolName === b.toolName &&
126
+ a.toolDescription === b.toolDescription &&
127
+ a.toolInputDescription === b.toolInputDescription &&
128
+ a.resolvable === b.resolvable &&
129
+ a.planFilePath === b.planFilePath &&
130
+ a.planSummary === b.planSummary &&
131
+ a.questionText === b.questionText &&
132
+ a.questionCount === b.questionCount);
133
+ }
33
134
  /** Normalize a workspace path to GitLens canonical form (forward slashes, lower-case drive letter
34
135
  * on Windows). Comparisons throughout the home view assume this form, but `_workspacePaths` and
35
136
  * peer-session `workspacePath` values originate from `Uri.fsPath`, which on Windows uses
@@ -69,6 +170,13 @@ function fileActivityStructurallyEqual(a, b) {
69
170
  }
70
171
  return true;
71
172
  }
173
+ /** True when a CLI invocation failed because it didn't recognize a flag/command. `--status` and the
174
+ * active-only `list-sessions` default shipped together (gk 3.1.69); an older CLI rejects `--status`
175
+ * and returns all sessions unfiltered, so the flagless retry is the correct legacy equivalent. */
176
+ function isUnknownFlagError(ex) {
177
+ const message = ex instanceof Error ? ex.message : String(ex);
178
+ return /unknown (?:flag|shorthand flag|command)/i.test(message);
179
+ }
72
180
  export class ClaudeCodeProvider {
73
181
  callbacks;
74
182
  id = 'claudeCode';
@@ -81,17 +189,32 @@ export class ClaudeCodeProvider {
81
189
  _disposed = false;
82
190
  _handlerDisposables = [];
83
191
  _pendingPermissions = new Map();
84
- _resolveGitInfoInFlight = new Set();
192
+ /** `sessionId -> cwd currently being probed`, and the newest cwd requested while that probe runs.
193
+ * A probe's answer is only valid for the cwd it was started with, so a session that moves
194
+ * mid-flight must supersede rather than dedupe — otherwise the older lookup lands last and
195
+ * overwrites the newer location with the directory the session already left. */
196
+ _resolveGitInfoInFlight = new Map();
197
+ _resolveGitInfoPending = new Map();
198
+ /** Short-TTL cache of the machine-global archived-session id list. Holds the in-flight promise so
199
+ * concurrent per-worktree `getPastSessions` calls share one CLI spawn; `archiveSession` clears it. */
200
+ _archivedSessionIdsCache;
85
201
  _sessionBookkeeping = new Map();
86
202
  /** Per-session timers for the deferred `Stop → idle` commit. The handle is cleared (and the
87
203
  * transition cancelled) by any non-idle status update arriving before the timer fires. */
88
204
  _pendingIdleTimers = new Map();
89
205
  _workspacePaths = [];
90
206
  _staleCheckTimer;
207
+ /** When the last poll actually ran (gated or not) — paces the idle cadence above. */
208
+ _lastSyncAt = 0;
91
209
  /** Whether Claude hooks are installed, pushed by the host via {@link setClaudeHooksInstalled}.
92
210
  * Fail-open (`true`) until the first push lands so a fresh window with real hooks isn't
93
211
  * suppressed on its first ticks. Gates the reconciliation poll in {@link syncSessions}. */
94
212
  _claudeHooksInstalled = true;
213
+ /** Whether the CLI supports `list-sessions --status` (and with it the durable ended-session
214
+ * 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. */
217
+ _statusFilterSupported = true;
95
218
  _transcriptReader = new ClaudeCodeTranscriptReader();
96
219
  constructor(callbacks) {
97
220
  this.callbacks = callbacks;
@@ -106,6 +229,9 @@ export class ClaudeCodeProvider {
106
229
  stop() {
107
230
  // IPC server stays up intentionally — hooks fire even when the window is unfocused
108
231
  }
232
+ sync() {
233
+ return this.syncSessions();
234
+ }
109
235
  updateWorkspacePaths(workspacePaths) {
110
236
  this._workspacePaths = workspacePaths.map(p => normalizePath(p));
111
237
  let changed = false;
@@ -190,7 +316,7 @@ export class ClaudeCodeProvider {
190
316
  }
191
317
  this._handlerDisposables = [];
192
318
  for (const pending of this._pendingPermissions.values()) {
193
- pending.reject(new Error('Provider disposed'));
319
+ this.rejectAllPending(pending, new Error('Provider disposed'));
194
320
  }
195
321
  this._pendingPermissions.clear();
196
322
  for (const bk of this._sessionBookkeeping.values()) {
@@ -223,7 +349,13 @@ export class ClaudeCodeProvider {
223
349
  }
224
350
  return Promise.resolve();
225
351
  }),
226
- this.callbacks.ipc.registerHandler('agents/sessions/list', () => Promise.resolve(this._sessions.map(s => ({
352
+ 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
355
+ // independently, so peers never need them from here.
356
+ this._sessions
357
+ .filter(s => s.status !== 'completed')
358
+ .map(s => ({
227
359
  ...s,
228
360
  lastActivity: s.lastActivity.toISOString(),
229
361
  phaseSince: s.phaseSince.toISOString(),
@@ -288,6 +420,13 @@ export class ClaudeCodeProvider {
288
420
  }
289
421
  }
290
422
  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}`);
428
+ return Promise.resolve();
429
+ }
291
430
  const workspacePath = this.resolveWorkspacePath(event.cwd);
292
431
  const eventContext = {
293
432
  pid: event.pid,
@@ -297,6 +436,8 @@ export class ClaudeCodeProvider {
297
436
  initialCwd: event.initialCwd,
298
437
  planFile: event.planFile,
299
438
  sessionName: event.sessionName,
439
+ model: event.model,
440
+ worktreePath: worktreeFromEvent(event),
300
441
  };
301
442
  const tag = this.sessionTag(event.sessionId, event.sessionName ?? 'unnamed');
302
443
  if (event.event === 'SessionStart' || event.event === 'SessionEnd') {
@@ -321,11 +462,31 @@ export class ClaudeCodeProvider {
321
462
  }
322
463
  }
323
464
  else {
324
- // SessionStart for a session we already track is almost always a CLI replay
325
- // (resume/reconnect/hook re-init). Don't clobber live state — the next real
326
- // event will re-establish status. Refresh pid only.
327
465
  const prev = this._sessions[index];
328
- if (event.pid != null && event.pid !== prev.pid) {
466
+ if (prev.status === 'completed') {
467
+ // Resuming a terminal session reuses its id, so a SessionStart on a completed
468
+ // row IS the resume signal — revive it to a live `idle` row (clearing the
469
+ // completed/archivable state) with clean bookkeeping that completeSession tore
470
+ // down. The next real event advances it out of idle. A `SessionStart` arrives on
471
+ // THIS window's hook flow, so the resume is locally owned now: clear the peer
472
+ // ownership carried over from the terminal record, and take the resume's pid
473
+ // (never the old terminal/reused one — retaining it would let dispatch focus a
474
+ // stale or unrelated process).
475
+ this._sessions[index] = {
476
+ ...prev,
477
+ status: 'idle',
478
+ phase: 'idle',
479
+ phaseSince: new Date(),
480
+ lastActivity: new Date(),
481
+ pid: event.pid,
482
+ isPeerOwned: undefined,
483
+ };
484
+ this.resetBookkeeping(event.sessionId);
485
+ }
486
+ else if (event.pid != null && event.pid !== prev.pid) {
487
+ // SessionStart for a live session we already track is almost always a CLI
488
+ // replay (resume/reconnect/hook re-init). Don't clobber live state — the next
489
+ // real event re-establishes status. Refresh pid only.
329
490
  this._sessions[index] = { ...prev, pid: event.pid, lastActivity: new Date() };
330
491
  }
331
492
  }
@@ -337,27 +498,53 @@ export class ClaudeCodeProvider {
337
498
  case 'SessionEnd': {
338
499
  const pending = this._pendingPermissions.get(event.sessionId);
339
500
  if (pending != null) {
340
- pending.reject(new Error('Session ended'));
501
+ this.rejectAllPending(pending, new Error('Session ended'));
341
502
  this._pendingPermissions.delete(event.sessionId);
342
503
  }
343
504
  this.cancelPendingIdleTransition(event.sessionId);
344
505
  const index = this._sessions.findIndex(s => s.id === event.sessionId);
345
506
  if (index >= 0) {
346
- this._sessions.splice(index, 1);
507
+ if (this._statusFilterSupported) {
508
+ // Transition to a terminal `completed` row rather than removing it — completed
509
+ // sessions stay visible (de-emphasized) until archived or 30-day-purged by the CLI.
510
+ this.completeSession(index, new Date());
511
+ }
512
+ 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.
515
+ this._sessions.splice(index, 1);
516
+ const bk = this._sessionBookkeeping.get(event.sessionId);
517
+ if (bk != null) {
518
+ this.cancelPendingFileClears(bk);
519
+ this.cancelPendingReadClears(bk);
520
+ }
521
+ this._sessionBookkeeping.delete(event.sessionId);
522
+ this._transcriptReader.forget(event.sessionId);
523
+ }
347
524
  this._onDidChangeSessions.fire();
348
525
  }
349
- const bk = this._sessionBookkeeping.get(event.sessionId);
350
- if (bk != null) {
351
- this.cancelPendingFileClears(bk);
352
- this.cancelPendingReadClears(bk);
526
+ else {
527
+ // SessionEnd for a session we no longer track (already pruned/removed, or a duplicate
528
+ // end): still clear any bookkeeping timers + transcript cache the row left behind, so
529
+ // they don't leak. The tracked branches above already tear this down themselves.
530
+ const bk = this._sessionBookkeeping.get(event.sessionId);
531
+ if (bk != null) {
532
+ this.cancelPendingFileClears(bk);
533
+ this.cancelPendingReadClears(bk);
534
+ }
535
+ this._sessionBookkeeping.delete(event.sessionId);
536
+ this._transcriptReader.forget(event.sessionId);
353
537
  }
354
- this._sessionBookkeeping.delete(event.sessionId);
355
- this._transcriptReader.forget(event.sessionId);
356
538
  this.callbacks.onSessionEnded?.(this.id);
357
539
  break;
358
540
  }
359
541
  case 'UserPromptSubmit': {
360
- const { index } = this.ensureSession(event.sessionId, eventContext);
542
+ const { index, changed } = this.ensureSession(event.sessionId, eventContext);
543
+ // Seated metadata (location, model, name) has to reach subscribers even when the prompt
544
+ // turns out to be synthetic and the status update below never runs.
545
+ if (changed) {
546
+ this._onDidChangeSessions.fire();
547
+ }
361
548
  const cleaned = prepareStoredPrompt(event.prompt);
362
549
  // A `UserPromptSubmit` whose payload sanitizes to nothing is harness-synthetic
363
550
  // (e.g. background-bash <task-notification>, slash-command stdout echo). Treat it
@@ -406,7 +593,7 @@ export class ClaudeCodeProvider {
406
593
  }
407
594
  case 'PostToolUse':
408
595
  case 'PostToolUseFailure': {
409
- this.clearStalePermission(event.sessionId, event.event);
596
+ this.clearStalePermission(event.sessionId, event.event, this.settledByFromEvent(event));
410
597
  const bk = this.getBookkeeping(event.sessionId);
411
598
  bk.activeToolCount = Math.max(0, bk.activeToolCount - 1);
412
599
  // Cooldown the file-edit decoration instead of dropping it immediately, so a quick
@@ -440,7 +627,7 @@ export class ClaudeCodeProvider {
440
627
  case 'StopFailure': {
441
628
  const pending = this._pendingPermissions.get(event.sessionId);
442
629
  if (pending != null) {
443
- pending.reject(new Error('Session stopped'));
630
+ this.rejectAllPending(pending, new Error('Session stopped'));
444
631
  this._pendingPermissions.delete(event.sessionId);
445
632
  }
446
633
  // Turn ended: stop the "live" pulse but keep the per-operation decay tail fading over
@@ -473,10 +660,28 @@ export class ClaudeCodeProvider {
473
660
  // No-op — session is already idle from the preceding stop/session-start event
474
661
  break;
475
662
  case 'permission_prompt':
663
+ this.updateSessionStatus(event.sessionId, 'permission_requested', {
664
+ ...eventContext,
665
+ statusDetail: event.toolName,
666
+ pendingPermission: synthesizeUnresolvableAsk(event.toolName,
667
+ // Plan-kind body: prefer the session's planFile, fall back to the event's
668
+ // — mirrors the blocking path.
669
+ this._sessions.find(s => s.id === event.sessionId)?.planFile ?? event.planFile),
670
+ });
671
+ break;
476
672
  case 'elicitation_dialog':
673
+ // An MCP elicitation, not a tool permission — `toolName` names the elicitation, so
674
+ // classifying it as a tool ask would mislabel the card. Same shape the `Elicitation`
675
+ // hook produces.
477
676
  this.updateSessionStatus(event.sessionId, 'permission_requested', {
478
677
  ...eventContext,
479
678
  statusDetail: event.toolName,
679
+ pendingPermission: {
680
+ kind: 'elicitation',
681
+ toolName: event.toolName ?? 'Input Required',
682
+ toolDescription: event.toolName ?? 'Waiting for input',
683
+ resolvable: false,
684
+ },
480
685
  });
481
686
  break;
482
687
  default:
@@ -493,11 +698,31 @@ export class ClaudeCodeProvider {
493
698
  const toolDescription = describeToolInput(toolName, toolInput);
494
699
  const toolInputDescription = toolInput.description || undefined;
495
700
  const kind = classifyPermissionKind(toolName);
701
+ const toolUseId = hookInput?.tool_use_id;
702
+ const toolInputJson = stableStringify(toolInput);
496
703
  return new Promise((resolve, reject) => {
497
704
  const existing = this._pendingPermissions.get(event.sessionId);
705
+ // Same identity as `settledByFromEvent`'s comparisons: `toolUseId` when both sides
706
+ // have one, else toolName + canonical input.
707
+ const isDuplicateDelivery = existing != null &&
708
+ ((existing.toolUseId != null && toolUseId != null && existing.toolUseId === toolUseId) ||
709
+ (existing.toolUseId == null &&
710
+ toolUseId == null &&
711
+ existing.toolName === toolName &&
712
+ existing.toolInputJson === toolInputJson));
713
+ if (isDuplicateDelivery) {
714
+ // Duplicate delivery of the same ask (CLI retry after its 60s response-header
715
+ // timeout) — attach instead of replacing so both connections settle together.
716
+ // Nothing about the display changes, so skip rebuilding it below. Accepted
717
+ // residual: two independent, identical, concurrent asks are byte-for-byte
718
+ // indistinguishable on the wire and coalesce the same way, receiving one
719
+ // decision — evicting instead would falsely deny a live ask.
720
+ existing.resolvers.push({ resolve: resolve, reject: reject });
721
+ return;
722
+ }
498
723
  if (existing != null) {
499
724
  Logger.debug(`ClaudeCodeProvider.handleSessionEvent: auto-denying stale permission ${tag} tool=${existing.toolName}`);
500
- existing.resolve({
725
+ this.resolveAllPending(existing, {
501
726
  hookSpecificOutput: {
502
727
  hookEventName: 'PermissionRequest',
503
728
  decision: { behavior: 'deny' },
@@ -505,10 +730,11 @@ export class ClaudeCodeProvider {
505
730
  });
506
731
  }
507
732
  this._pendingPermissions.set(event.sessionId, {
508
- resolve: resolve,
509
- reject: reject,
733
+ resolvers: [{ resolve: resolve, reject: reject }],
510
734
  toolName: toolName,
511
735
  toolDescription: toolDescription,
736
+ toolUseId: toolUseId,
737
+ toolInputJson: toolInputJson,
512
738
  });
513
739
  // Plan-kind body: prefer the existing session's planFile (set via SessionStart/
514
740
  // PostToolUse) and fall back to the event's planFile for cold-start cases.
@@ -529,17 +755,45 @@ export class ClaudeCodeProvider {
529
755
  questionText: questionDetails?.text,
530
756
  questionCount: questionDetails?.count,
531
757
  };
532
- this.updateSessionWithPermission(event.sessionId, permission, event.pid);
758
+ this.updateSessionWithPermission(event.sessionId, permission, eventContext);
759
+ });
760
+ }
761
+ {
762
+ // Mirror the blocking payload's shape so the ask renders identically either way —
763
+ // just unresolvable, since no blocking hook entry exists to route an answer through.
764
+ const toolName = hookInput?.tool_name ?? event.toolName;
765
+ const kind = toolName != null ? classifyPermissionKind(toolName) : undefined;
766
+ // Plan-kind body: same session-first preference as the blocking path.
767
+ const planFilePath = kind === 'plan'
768
+ ? (this._sessions.find(s => s.id === event.sessionId)?.planFile ?? event.planFile)
769
+ : undefined;
770
+ // Same body extraction as the blocking path — surfaces render `questionText`/
771
+ // `planSummary` in place of the tool description, so omitting them would show a
772
+ // generic "awaiting" card for a question the input actually carries.
773
+ const questionDetails = kind === 'question' && toolInput != null ? extractQuestionDetails(toolInput) : undefined;
774
+ const synthesized = kind != null && toolName != null && toolInput != null
775
+ ? {
776
+ kind: kind,
777
+ toolName: toolName,
778
+ toolDescription: describeToolInput(toolName, toolInput),
779
+ toolInputDescription: toolInput.description || undefined,
780
+ planFilePath: planFilePath,
781
+ planSummary: kind === 'plan' ? extractPlanSummary(toolInput) : undefined,
782
+ questionText: questionDetails?.text,
783
+ questionCount: questionDetails?.count,
784
+ resolvable: false,
785
+ }
786
+ : synthesizeUnresolvableAsk(toolName, planFilePath);
787
+ this.updateSessionStatus(event.sessionId, 'permission_requested', {
788
+ ...eventContext,
789
+ statusDetail: event.toolName,
790
+ pendingPermission: synthesized,
533
791
  });
534
792
  }
535
- this.updateSessionStatus(event.sessionId, 'permission_requested', {
536
- ...eventContext,
537
- statusDetail: event.toolName,
538
- });
539
793
  break;
540
794
  }
541
795
  case 'PermissionDenied': {
542
- this.clearStalePermission(event.sessionId, 'PermissionDenied');
796
+ this.clearStalePermission(event.sessionId, 'PermissionDenied', this.settledByFromEvent(event));
543
797
  const bk = this.getBookkeeping(event.sessionId);
544
798
  bk.activeToolCount = Math.max(0, bk.activeToolCount - 1);
545
799
  const filesChanged = this.untrackToolUseFile(event.sessionId, event);
@@ -563,6 +817,9 @@ export class ClaudeCodeProvider {
563
817
  kind: 'elicitation',
564
818
  toolName: event.toolName ?? 'Input Required',
565
819
  toolDescription: event.toolName ?? 'Waiting for input',
820
+ // `Elicitation` arrives on a non-blocking hook, so no entry exists for
821
+ // `resolvePermission` to answer — the user responds in the agent's own session.
822
+ resolvable: false,
566
823
  };
567
824
  this.updateSessionStatus(event.sessionId, 'permission_requested', {
568
825
  ...eventContext,
@@ -578,16 +835,15 @@ export class ClaudeCodeProvider {
578
835
  }
579
836
  case 'CwdChanged':
580
837
  if (event.cwd) {
581
- const index = this._sessions.findIndex(s => s.id === event.sessionId);
582
- if (index >= 0 && this._sessions[index].cwd !== event.cwd) {
583
- this._sessions[index] = { ...this._sessions[index], cwd: event.cwd };
838
+ // Route through `ensureSession`: it owns the whole location transition — timeline
839
+ // seat (or handing attribution back to the probe when the CLI didn't explain the
840
+ // move), workspacePath/isInWorkspace recompute, the unresolvable-flag clear, and
841
+ // the probe gating. Writing `cwd` directly here left `cliSeatedWorktree` set, so
842
+ // the probe's answer for the new cwd could never replace the stale seated one.
843
+ const { changed } = this.ensureSession(event.sessionId, eventContext);
844
+ if (changed) {
584
845
  this._onDidChangeSessions.fire();
585
846
  }
586
- // Clear the unresolvable flag so the new cwd actually gets re-probed — without
587
- // this, a session that started in a non-git cwd would stay flagged forever even
588
- // after moving into a git repo.
589
- this.getBookkeeping(event.sessionId).gitInfoUnresolvable = false;
590
- void this.resolveGitInfo(event.sessionId, event.cwd);
591
847
  }
592
848
  break;
593
849
  case 'SubagentStart': {
@@ -698,6 +954,8 @@ export class ClaudeCodeProvider {
698
954
  bk.currentReadCounts.clear();
699
955
  bk.lastTouchedAt.clear();
700
956
  bk.gitInfoUnresolvable = false;
957
+ // `cliSeatedWorktree` deliberately survives: SessionStart seats the worktree via
958
+ // `ensureSession` BEFORE resetting bookkeeping, and an unexplained cwd move clears it anyway.
701
959
  const parentId = this.findOwningParentId(sessionId);
702
960
  if (parentId != null) {
703
961
  this.syncSessionFileActivity(parentId);
@@ -1075,8 +1333,10 @@ export class ClaudeCodeProvider {
1075
1333
  this.cancelPendingIdleTransition(sessionId);
1076
1334
  const timer = setTimeout(() => {
1077
1335
  this._pendingIdleTimers.delete(sessionId);
1078
- // Session may have been removed (SessionEnd, prune) during the window.
1079
- if (this._sessions.findIndex(s => s.id === sessionId) < 0)
1336
+ // Session may have been removed (SessionEnd, prune) during the window, or completed by a
1337
+ // poll/SessionEnd that raced this timer reviving a terminal row to idle would zombie it.
1338
+ const idx = this._sessions.findIndex(s => s.id === sessionId);
1339
+ if (idx < 0 || this._sessions[idx].status === 'completed')
1080
1340
  return;
1081
1341
  this.updateSessionStatus(sessionId, 'idle');
1082
1342
  }, stopToIdleDebounceMs);
@@ -1105,20 +1365,86 @@ export class ClaudeCodeProvider {
1105
1365
  bk.priorPhase = { phase: prevPhase, phaseSince: prevPhaseSince };
1106
1366
  return now;
1107
1367
  }
1368
+ /** Extracts the ask identity a settlement event refers to. `PostToolUse`/`PermissionDenied`
1369
+ * carry `hookInput.tool_use_id` and `hookInput.tool_input` in production; the top-level
1370
+ * `event.toolName`/`event.toolInput` are the older-CLI fallback. Input is canonicalized so key
1371
+ * order (or arriving via the fallback field instead of `hookInput`) doesn't defeat comparison. */
1372
+ settledByFromEvent(event) {
1373
+ const hookInput = event.hookInput;
1374
+ const toolInput = hookInput?.tool_input ?? event.toolInput;
1375
+ return {
1376
+ toolUseId: hookInput?.tool_use_id,
1377
+ toolName: hookInput?.tool_name ?? event.toolName,
1378
+ toolInputJson: toolInput != null ? stableStringify(toolInput) : undefined,
1379
+ };
1380
+ }
1381
+ resolveAllPending(entry, response) {
1382
+ for (const resolver of entry.resolvers) {
1383
+ resolver.resolve(response);
1384
+ }
1385
+ }
1386
+ rejectAllPending(entry, reason) {
1387
+ for (const resolver of entry.resolvers) {
1388
+ resolver.reject(reason);
1389
+ }
1390
+ }
1108
1391
  // Called when a pending permission was resolved outside GitLens (e.g. via the CLI terminal).
1109
1392
  // The 'deny' response is a safe no-op since the tool has already run or been denied upstream.
1110
- clearStalePermission(sessionId, reason) {
1393
+ // `settledBy` identifies the ask the settling event actually refers to — out-of-order/duplicate
1394
+ // hook deliveries (PostToolUse for an unrelated tool, a PermissionDenied for an already-evicted
1395
+ // earlier ask) must never deny a still-live blocking ask just because it shares a session id.
1396
+ // `PermissionDenied` itself only ever fires after Claude Code's auto-mode classifier denies a
1397
+ // tool call — never as an echo of a deny a `PermissionRequest` response sent.
1398
+ clearStalePermission(sessionId, reason, settledBy) {
1111
1399
  const bk = this.getBookkeeping(sessionId);
1112
- if (bk.pendingPermission == null)
1113
- return;
1114
- Logger.debug(`ClaudeCodeProvider.clearStalePermission: ${reason} ${this.sessionTag(sessionId)}`);
1115
1400
  const pending = this._pendingPermissions.get(sessionId);
1116
1401
  if (pending != null) {
1117
- pending.resolve({
1402
+ if (settledBy != null) {
1403
+ let mismatch = false;
1404
+ if (pending.toolUseId != null && settledBy.toolUseId != null) {
1405
+ // Rule 1: both sides identified — the only trustworthy comparison. `PermissionDenied`/
1406
+ // `PostToolUse` hookInput DOES carry `tool_use_id`, but `PermissionRequest` never does,
1407
+ // so `pending` never actually has one — this branch only engages if a future CLI adds
1408
+ // it to requests too. The asymmetry is deliberate, not a gap to close.
1409
+ mismatch = pending.toolUseId !== settledBy.toolUseId;
1410
+ }
1411
+ else if (pending.toolName === '') {
1412
+ // Rule 2: no identity was captured at registration (degraded payload) — nothing to
1413
+ // gate on, so clear unconditionally rather than stranding the ask forever.
1414
+ mismatch = false;
1415
+ }
1416
+ else {
1417
+ // Rule 3: name must match; when both sides have input, it must match too. A
1418
+ // PostToolUse for a different invocation of the same tool (different input) leaves
1419
+ // the ask untouched.
1420
+ mismatch =
1421
+ settledBy.toolName !== pending.toolName ||
1422
+ (pending.toolInputJson != null &&
1423
+ settledBy.toolInputJson != null &&
1424
+ pending.toolInputJson !== settledBy.toolInputJson);
1425
+ }
1426
+ 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'}`);
1428
+ return;
1429
+ }
1430
+ }
1431
+ Logger.debug(`ClaudeCodeProvider.clearStalePermission: ${reason} ${this.sessionTag(sessionId)}`);
1432
+ this.resolveAllPending(pending, {
1118
1433
  hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: { behavior: 'deny' } },
1119
1434
  });
1120
1435
  this._pendingPermissions.delete(sessionId);
1436
+ bk.pendingPermission = undefined;
1437
+ return;
1121
1438
  }
1439
+ if (bk.pendingPermission == null)
1440
+ return;
1441
+ if (settledBy?.toolName != null &&
1442
+ bk.pendingPermission.toolName != null &&
1443
+ 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}`);
1445
+ return;
1446
+ }
1447
+ Logger.debug(`ClaudeCodeProvider.clearStalePermission: ${reason} ${this.sessionTag(sessionId)}`);
1122
1448
  bk.pendingPermission = undefined;
1123
1449
  }
1124
1450
  resolvePermission(sessionId, decision, updatedPermissions) {
@@ -1126,7 +1452,7 @@ export class ClaudeCodeProvider {
1126
1452
  if (pending == null)
1127
1453
  return false;
1128
1454
  Logger.debug(`ClaudeCodeProvider.resolvePermission: ${decision} ${this.sessionTag(sessionId)} tool=${pending.toolName}`);
1129
- pending.resolve({
1455
+ this.resolveAllPending(pending, {
1130
1456
  hookSpecificOutput: {
1131
1457
  hookEventName: 'PermissionRequest',
1132
1458
  decision: { behavior: decision, updatedPermissions: updatedPermissions },
@@ -1144,6 +1470,126 @@ export class ClaudeCodeProvider {
1144
1470
  this.updateSessionStatus(sessionId, nextStatus);
1145
1471
  return true;
1146
1472
  }
1473
+ async archiveSession(sessionId) {
1474
+ // 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
1476
+ // reach the CLI — archiving is only ever valid on a terminal row. Callers gate the UI too,
1477
+ // but this is the last line that owns live process safety. Returns `false` when refused so the
1478
+ // host doesn't record a success telemetry event for an archive that never happened.
1479
+ 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})`);
1482
+ return false;
1483
+ }
1484
+ Logger.debug(`ClaudeCodeProvider.archiveSession: ${this.sessionTag(sessionId)}`);
1485
+ await this.callbacks.runCLICommand(['ai', 'hook', 'archive-session', sessionId, '--json']);
1486
+ // Invalidate the archived-id cache so the next `getPastSessions` re-fetches and excludes this id.
1487
+ this._archivedSessionIdsCache = undefined;
1488
+ // Optimistically drop it — archived sessions are excluded from the `active,ended` poll, so the
1489
+ // next reconciliation confirms the removal regardless.
1490
+ const index = this._sessions.findIndex(s => s.id === sessionId);
1491
+ if (index >= 0) {
1492
+ this._sessions.splice(index, 1);
1493
+ this._sessionBookkeeping.delete(sessionId);
1494
+ this._transcriptReader.forget(sessionId);
1495
+ this._onDidChangeSessions.fire();
1496
+ }
1497
+ return true;
1498
+ }
1499
+ /** Lists the ids of sessions the CLI has archived, so callers (the "Past" transcript listing) can
1500
+ * exclude them — the tracked row is gone, but the transcript on disk survives and would otherwise
1501
+ * resurface there. Resolves to `[]` on any error, including a CLI too old to support the
1502
+ * `archived` status filter. */
1503
+ async getArchivedSessionIds() {
1504
+ const cached = this._archivedSessionIdsCache;
1505
+ if (cached != null && Date.now() - cached.at < archivedSessionIdsCacheTtlMs) {
1506
+ return cached.promise;
1507
+ }
1508
+ const promise = this.fetchArchivedSessionIds();
1509
+ this._archivedSessionIdsCache = { promise: promise, at: Date.now() };
1510
+ return promise;
1511
+ }
1512
+ async fetchArchivedSessionIds() {
1513
+ try {
1514
+ const output = await this.callbacks.runCLICommand([
1515
+ 'ai',
1516
+ 'hook',
1517
+ 'list-sessions',
1518
+ '--status',
1519
+ 'archived',
1520
+ '--json',
1521
+ ]);
1522
+ const sessions = JSON.parse(output);
1523
+ return sessions.map(s => s.sessionId).filter(id => id.length > 0);
1524
+ }
1525
+ catch {
1526
+ return [];
1527
+ }
1528
+ }
1529
+ resolveCompletedSessionDetails(sessionId) {
1530
+ 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')
1533
+ return;
1534
+ // The durable store drops the prompt (and only ever carries an auto-slug name) for most ended
1535
+ // sessions, so read the terminal transcript once on demand to recover a real title + the
1536
+ // first/last prompt. Both the git probe and transcript read sit under the guard so repeated
1537
+ // opens don't re-probe git or re-read a possibly-large file.
1538
+ const bk = this.getBookkeeping(sessionId);
1539
+ if (bk.completedDetailsResolved)
1540
+ return;
1541
+ bk.completedDetailsResolved = true;
1542
+ if (session.cwd) {
1543
+ void this.resolveGitInfo(sessionId, session.cwd);
1544
+ }
1545
+ void this.applyCompletedTranscriptDetails(sessionId, session.cwd);
1546
+ }
1547
+ async applyCompletedTranscriptDetails(sessionId, cwd) {
1548
+ let details;
1549
+ try {
1550
+ details = await this._transcriptReader.resolveCompletedDetails(sessionId, cwd);
1551
+ }
1552
+ catch {
1553
+ // A read error (transient I/O, permissions) is not terminal state — clear the once-only
1554
+ // guard so a later open retries. A `undefined` result IS terminal (no transcript on disk),
1555
+ // so it keeps the guard and never re-scans.
1556
+ const bk = this._sessionBookkeeping.get(sessionId);
1557
+ if (bk != null) {
1558
+ bk.completedDetailsResolved = false;
1559
+ }
1560
+ return;
1561
+ }
1562
+ if (details == null)
1563
+ return;
1564
+ const index = this._sessions.findIndex(s => s.id === sessionId);
1565
+ if (index < 0)
1566
+ return;
1567
+ const session = this._sessions[index];
1568
+ // The session may have been archived/removed while we were reading.
1569
+ if (session.status !== 'completed')
1570
+ return;
1571
+ const { titles } = details;
1572
+ const nextTitles = titles.custom != null || titles.ai != null || titles.agent != null
1573
+ ? { custom: titles.custom, ai: titles.ai, agent: titles.agent }
1574
+ : session.transcriptTitles;
1575
+ // Durable-store prompts (full text) win over the transcript's (CLI-truncated) copy; only
1576
+ // backfill from the transcript when the field is empty.
1577
+ const nextFirstPrompt = session.firstPrompt ?? prepareStoredPrompt(details.firstPrompt);
1578
+ const nextLastPrompt = session.lastPrompt ?? prepareStoredPrompt(details.lastPrompt);
1579
+ const titlesChanged = nextTitles?.custom !== session.transcriptTitles?.custom ||
1580
+ nextTitles?.ai !== session.transcriptTitles?.ai ||
1581
+ nextTitles?.agent !== session.transcriptTitles?.agent;
1582
+ if (!titlesChanged && nextFirstPrompt === session.firstPrompt && nextLastPrompt === session.lastPrompt) {
1583
+ return;
1584
+ }
1585
+ this._sessions[index] = {
1586
+ ...session,
1587
+ transcriptTitles: nextTitles,
1588
+ firstPrompt: nextFirstPrompt,
1589
+ lastPrompt: nextLastPrompt,
1590
+ };
1591
+ this._onDidChangeSessions.fire();
1592
+ }
1147
1593
  matchesWorkspace(workspacePath) {
1148
1594
  if (!workspacePath)
1149
1595
  return false;
@@ -1159,11 +1605,13 @@ export class ClaudeCodeProvider {
1159
1605
  const normalized = normalizePath(cwd);
1160
1606
  return this._workspacePaths.find(p => normalized === p || normalized.startsWith(`${p}/`) || p.startsWith(`${normalized}/`));
1161
1607
  }
1162
- updateSessionWithPermission(sessionId, permission, pid) {
1608
+ updateSessionWithPermission(sessionId, permission, context) {
1163
1609
  // A pending permission is a working/waiting transition — cancel any deferred Stop → idle
1164
1610
  // commit so the session doesn't briefly flip to idle before showing the prompt.
1165
1611
  this.cancelPendingIdleTransition(sessionId);
1166
- const { index } = this.ensureSession(sessionId, { pid: pid });
1612
+ // The full event context, not just the pid: a blocking ask can be the first event of a
1613
+ // session, and a row created here with no location would sit unattributed for the whole block.
1614
+ const { index } = this.ensureSession(sessionId, context);
1167
1615
  const prev = this._sessions[index];
1168
1616
  const newPhase = getPhaseForStatus('permission_requested');
1169
1617
  this.getBookkeeping(sessionId).pendingPermission = permission;
@@ -1200,7 +1648,12 @@ export class ClaudeCodeProvider {
1200
1648
  return;
1201
1649
  }
1202
1650
  const statusDetail = options?.statusDetail;
1203
- if (prev.status === status && prev.statusDetail === statusDetail) {
1651
+ // Resolved before the short-circuit: a second ask can arrive while the row already sits at
1652
+ // `permission_requested`, and comparing only status+detail would publish the first ask forever.
1653
+ const nextPendingPermission = status === 'permission_requested' ? (bk.pendingPermission ?? options?.pendingPermission) : undefined;
1654
+ if (prev.status === status &&
1655
+ prev.statusDetail === statusDetail &&
1656
+ isSameAsk(prev.pendingPermission, nextPendingPermission)) {
1204
1657
  if (metadataChanged) {
1205
1658
  this._onDidChangeSessions.fire();
1206
1659
  }
@@ -1213,7 +1666,7 @@ export class ClaudeCodeProvider {
1213
1666
  phase: newPhase,
1214
1667
  phaseSince: this.resolvePhaseSince(sessionId, prev.phase, prev.phaseSince, newPhase),
1215
1668
  statusDetail: statusDetail,
1216
- pendingPermission: status === 'permission_requested' ? bk.pendingPermission : undefined,
1669
+ pendingPermission: nextPendingPermission,
1217
1670
  lastActivity: new Date(),
1218
1671
  };
1219
1672
  this._onDidChangeSessions.fire();
@@ -1231,10 +1684,13 @@ export class ClaudeCodeProvider {
1231
1684
  }
1232
1685
  }
1233
1686
  ensureSession(sessionId, context) {
1234
- const { pid, workspacePath, isInWorkspace, cwd, initialCwd, planFile, sessionName } = context ?? {};
1687
+ const { pid, workspacePath, isInWorkspace, cwd, initialCwd, planFile, sessionName, model, worktreePath } = context ?? {};
1235
1688
  let index = this._sessions.findIndex(s => s.id === sessionId);
1236
1689
  if (index < 0) {
1237
1690
  const now = new Date();
1691
+ // Same worktree-derived fallback as the update branch below: a session created with a
1692
+ // scratch cwd but a CLI-seated worktree still belongs to that worktree's workspace.
1693
+ const createdWorkspacePath = workspacePath ?? this.resolveWorkspacePath(worktreePath);
1238
1694
  index = this._sessions.length;
1239
1695
  this._sessions.push({
1240
1696
  id: sessionId,
@@ -1247,13 +1703,18 @@ export class ClaudeCodeProvider {
1247
1703
  pid: pid,
1248
1704
  lastActivity: now,
1249
1705
  isSubagent: false,
1250
- workspacePath: workspacePath,
1706
+ workspacePath: createdWorkspacePath,
1251
1707
  cwd: cwd,
1252
1708
  initialCwd: initialCwd ?? cwd,
1253
1709
  planFile: planFile,
1254
- isInWorkspace: isInWorkspace ?? false,
1710
+ isInWorkspace: createdWorkspacePath != null ? true : (isInWorkspace ?? false),
1711
+ model: model,
1712
+ worktreePath: worktreePath != null ? normalizePath(worktreePath) : undefined,
1255
1713
  });
1256
1714
  Logger.debug(`ClaudeCodeProvider.ensureSession: implicitly created ${this.sessionTag(sessionId, sessionName ?? 'unnamed')}`);
1715
+ if (worktreePath != null) {
1716
+ this.getBookkeeping(sessionId).cliSeatedWorktree = true;
1717
+ }
1257
1718
  this._onDidChangeSessions.fire();
1258
1719
  if (cwd != null) {
1259
1720
  void this.resolveGitInfo(sessionId, cwd);
@@ -1263,10 +1724,9 @@ export class ClaudeCodeProvider {
1263
1724
  }
1264
1725
  const existing = this._sessions[index];
1265
1726
  const updatedPid = pid != null && existing.pid == null ? pid : existing.pid;
1266
- const updatedWorkspacePath = workspacePath || existing.workspacePath;
1267
- const updatedIsInWorkspace = workspacePath ? (isInWorkspace ?? existing.isInWorkspace) : existing.isInWorkspace;
1268
1727
  const updatedPlanFile = planFile ?? existing.planFile;
1269
1728
  const updatedName = sessionName || existing.name;
1729
+ const updatedModel = model || existing.model;
1270
1730
  const updatedCwd = cwd ?? existing.cwd;
1271
1731
  // Prefer the CLI's authoritative `initialCwd` (the true launch dir it captured first-hand,
1272
1732
  // even for sessions that started before this window opened); otherwise keep whatever we
@@ -1274,23 +1734,55 @@ export class ClaudeCodeProvider {
1274
1734
  // don't send `initialCwd`, or a cold-create before any cwd was known). Comparing live `cwd`
1275
1735
  // against `initialCwd` is how consumers detect launch-vs-current drift, so it stays stable.
1276
1736
  const updatedInitialCwd = initialCwd ?? existing.initialCwd ?? cwd;
1737
+ // The CLI resolves this at hook time and sends it on every event — authoritative when
1738
+ // present, so it seats ahead of the `resolveGitInfo` probe below (which still fills
1739
+ // `commonPath` and remains the fallback for older CLIs that don't send `cwdTimeline`).
1740
+ const updatedWorktreePath = worktreePath != null ? normalizePath(worktreePath) : existing.worktreePath;
1741
+ // Workspace membership follows the ATTRIBUTION, not the raw cwd: a scratch-dir excursion
1742
+ // (cwd in /tmp, worktree kept) stays in-workspace via its worktree, while a genuine move to
1743
+ // a repo outside the workspace honestly drops membership when events carry a cwd.
1744
+ const updatedWorkspacePath = workspacePath ??
1745
+ this.resolveWorkspacePath(updatedWorktreePath) ??
1746
+ (cwd == null ? existing.workspacePath : undefined);
1747
+ const updatedIsInWorkspace = cwd == null ? existing.isInWorkspace : updatedWorkspacePath != null;
1748
+ const cwdMoved = cwd != null && updatedCwd !== existing.cwd;
1749
+ const worktreeMoved = updatedWorktreePath != null &&
1750
+ existing.worktreePath != null &&
1751
+ updatedWorktreePath !== existing.worktreePath;
1752
+ // The CLI explaining the location owns attribution; a move it didn't explain hands it back to
1753
+ // the probe.
1754
+ if (worktreePath != null) {
1755
+ this.getBookkeeping(sessionId).cliSeatedWorktree = true;
1756
+ }
1757
+ else if (cwdMoved) {
1758
+ this.getBookkeeping(sessionId).cliSeatedWorktree = false;
1759
+ }
1277
1760
  let changed = false;
1278
1761
  if (updatedPid !== existing.pid ||
1279
1762
  updatedWorkspacePath !== existing.workspacePath ||
1280
1763
  updatedIsInWorkspace !== existing.isInWorkspace ||
1281
1764
  updatedPlanFile !== existing.planFile ||
1282
1765
  updatedName !== existing.name ||
1766
+ updatedModel !== existing.model ||
1283
1767
  updatedCwd !== existing.cwd ||
1284
- updatedInitialCwd !== existing.initialCwd) {
1768
+ updatedInitialCwd !== existing.initialCwd ||
1769
+ updatedWorktreePath !== existing.worktreePath) {
1285
1770
  this._sessions[index] = {
1286
1771
  ...existing,
1287
1772
  name: updatedName,
1773
+ model: updatedModel,
1288
1774
  pid: updatedPid,
1289
1775
  workspacePath: updatedWorkspacePath,
1290
1776
  cwd: updatedCwd,
1291
1777
  initialCwd: updatedInitialCwd,
1292
1778
  planFile: updatedPlanFile,
1293
1779
  isInWorkspace: updatedIsInWorkspace,
1780
+ worktreePath: updatedWorktreePath,
1781
+ // A worktree move can cross repos, and the spread would otherwise carry the old repo
1782
+ // identity forward — which consumers prefer over the new worktree, pinning the card to
1783
+ // the wrong repo. Drop it; the probe below refills it. Mirrors the poll's ended-record
1784
+ // re-seat.
1785
+ commonPath: worktreeMoved ? undefined : existing.commonPath,
1294
1786
  };
1295
1787
  changed = true;
1296
1788
  }
@@ -1303,8 +1795,23 @@ export class ClaudeCodeProvider {
1303
1795
  // `resolveGitInfo` confirms the cwd isn't a git repo, preventing a retry storm on every
1304
1796
  // hook event for non-git-repo cwds. The flag is cleared on cwd change (`CwdChanged`) and
1305
1797
  // on `resetBookkeeping` (Stop/SessionStart) so re-resolution can happen when warranted.
1798
+ // A cwd move re-resolves too. `worktreePath`/`commonPath` are resolved from whichever cwd was
1799
+ // seen first, so an agent that moves between worktrees would otherwise stay pinned to its
1800
+ // launch repo — attaching to the wrong WIP row and leaving its real worktree showing no
1801
+ // agents at all. `CwdChanged` covers the move the CLI announces; this covers an ordinary
1802
+ // event that simply arrives carrying a new cwd.
1803
+ //
1804
+ // A cwd the CLI already attributed to the same worktree needs no probe at all — that's the
1805
+ // common case (an agent `cd`-ing around inside its own tree), and probing it would spawn a git
1806
+ // call per hook event for an answer we already have. A first seat (no prior worktree) still
1807
+ // counts as a location change: the move may be out of a non-git dir whose probe latched
1808
+ // `gitInfoUnresolvable`, and `commonPath` still needs the probe.
1809
+ const locationNeedsProbe = cwdMoved && (worktreePath == null || worktreeMoved || existing.worktreePath == null);
1810
+ if (locationNeedsProbe) {
1811
+ this.getBookkeeping(sessionId).gitInfoUnresolvable = false;
1812
+ }
1306
1813
  if (cwd != null &&
1307
- (existing.worktreePath == null || existing.commonPath == null) &&
1814
+ (locationNeedsProbe || existing.worktreePath == null || existing.commonPath == null) &&
1308
1815
  !this.getBookkeeping(sessionId).gitInfoUnresolvable) {
1309
1816
  void this.resolveGitInfo(sessionId, cwd);
1310
1817
  }
@@ -1320,9 +1827,19 @@ export class ClaudeCodeProvider {
1320
1827
  // would spawn a fresh git probe per hook event. The `gitInfoUnresolvable` bookkeeping flag
1321
1828
  // (set below when `info == null`) is the long-lived suppressor across hook events; this
1322
1829
  // in-flight set only dedupes concurrent overlapping probes within a single resolution.
1323
- if (this._resolveGitInfoInFlight.has(sessionId))
1830
+ //
1831
+ // Keyed by cwd as well: a probe for the SAME cwd is genuinely redundant, but one for a
1832
+ // different cwd (a missed `CwdChanged` corrected off the durable record, say) must not be
1833
+ // swallowed — the in-flight answer describes the old directory. Record it as pending so the
1834
+ // running probe hands off to it, and discard the stale answer below.
1835
+ const inFlightCwd = this._resolveGitInfoInFlight.get(sessionId);
1836
+ if (inFlightCwd != null) {
1837
+ if (inFlightCwd !== cwd) {
1838
+ this._resolveGitInfoPending.set(sessionId, cwd);
1839
+ }
1324
1840
  return;
1325
- this._resolveGitInfoInFlight.add(sessionId);
1841
+ }
1842
+ this._resolveGitInfoInFlight.set(sessionId, cwd);
1326
1843
  try {
1327
1844
  let info;
1328
1845
  try {
@@ -1335,6 +1852,11 @@ export class ClaudeCodeProvider {
1335
1852
  const index = this._sessions.findIndex(s => s.id === sessionId);
1336
1853
  if (index < 0)
1337
1854
  return;
1855
+ // Superseded while we were probing: this answer describes a directory the session has
1856
+ // already left, so applying it would undo the newer location. Drop it — the `finally`
1857
+ // below re-runs for the newest cwd.
1858
+ if (this._resolveGitInfoPending.has(sessionId))
1859
+ return;
1338
1860
  const session = this._sessions[index];
1339
1861
  // `workspacePath` stays as the matched workspace folder (or undefined). Repo identity
1340
1862
  // flows through `commonPath`, populated here from `info.repoRoot` at the same step as
@@ -1357,23 +1879,29 @@ export class ClaudeCodeProvider {
1357
1879
  }
1358
1880
  return;
1359
1881
  }
1882
+ // A CLI-seated worktree wins over the probe's answer (see `cliSeatedWorktree`): the CLI
1883
+ // resolved it for this exact cwd, while the probe can name the parent worktree for a nested
1884
+ // one. `commonPath` always comes from the probe — the CLI doesn't resolve repo identity.
1885
+ const effectiveWorktreePath = this.getBookkeeping(sessionId).cliSeatedWorktree && session.worktreePath != null
1886
+ ? session.worktreePath
1887
+ : info.worktreePath;
1360
1888
  // Capture the worktree/commonPath at the first successful resolve so consumers can
1361
1889
  // detect drift (e.g. an agent that `cd`'d into a sibling worktree after launch). Gated
1362
1890
  // on `initialCommonPath` — captured together with `initialWorktreePath` so a first
1363
1891
  // resolve where `info.worktreePath` happens to be undefined doesn't leave the latter
1364
1892
  // open to a later overwrite. Once set, never overwritten.
1365
1893
  const firstResolve = session.initialCommonPath == null;
1366
- const updatedInitialWorktreePath = firstResolve ? info.worktreePath : session.initialWorktreePath;
1894
+ const updatedInitialWorktreePath = firstResolve ? effectiveWorktreePath : session.initialWorktreePath;
1367
1895
  const updatedInitialCommonPath = firstResolve ? info.repoRoot : session.initialCommonPath;
1368
1896
  if (cwd !== session.cwd ||
1369
- info.worktreePath !== session.worktreePath ||
1897
+ effectiveWorktreePath !== session.worktreePath ||
1370
1898
  info.repoRoot !== session.commonPath ||
1371
1899
  updatedInitialWorktreePath !== session.initialWorktreePath ||
1372
1900
  updatedInitialCommonPath !== session.initialCommonPath) {
1373
1901
  this._sessions[index] = {
1374
1902
  ...session,
1375
1903
  cwd: cwd,
1376
- worktreePath: info.worktreePath,
1904
+ worktreePath: effectiveWorktreePath,
1377
1905
  commonPath: info.repoRoot,
1378
1906
  initialWorktreePath: updatedInitialWorktreePath,
1379
1907
  initialCommonPath: updatedInitialCommonPath,
@@ -1383,6 +1911,13 @@ export class ClaudeCodeProvider {
1383
1911
  }
1384
1912
  finally {
1385
1913
  this._resolveGitInfoInFlight.delete(sessionId);
1914
+ // Hand off to the newest cwd requested while this probe ran, so a move that arrived
1915
+ // mid-flight still resolves instead of being dropped by the dedupe.
1916
+ const pendingCwd = this._resolveGitInfoPending.get(sessionId);
1917
+ if (pendingCwd != null) {
1918
+ this._resolveGitInfoPending.delete(sessionId);
1919
+ void this.resolveGitInfo(sessionId, pendingCwd);
1920
+ }
1386
1921
  }
1387
1922
  }
1388
1923
  async resolveTranscriptTitles(sessionId, cwd) {
@@ -1408,10 +1943,55 @@ export class ClaudeCodeProvider {
1408
1943
  };
1409
1944
  this._onDidChangeSessions.fire();
1410
1945
  }
1946
+ /** Transitions the session at `index` to the terminal `completed` state in place: keeps the row,
1947
+ * stamps `endedAt` as its activity time, clears transient state (statusDetail, pendingPermission,
1948
+ * subagents), and tears down its bookkeeping/timers. Shared by the live `SessionEnd` path and the
1949
+ * poll backstop for a `SessionEnd` the live path missed. Does not fire change events — the caller
1950
+ * batches those. */
1951
+ completeSession(index, endedAt) {
1952
+ const prev = this._sessions[index];
1953
+ this._sessions[index] = {
1954
+ ...prev,
1955
+ status: 'completed',
1956
+ phase: 'completed',
1957
+ phaseSince: endedAt,
1958
+ lastActivity: endedAt,
1959
+ statusDetail: undefined,
1960
+ pendingPermission: undefined,
1961
+ 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
+ };
1966
+ // Terminal teardown, owned here so every caller gets it — the live `SessionEnd` path does
1967
+ // this before calling us, but the poll's missed-SessionEnd transition doesn't: reject any
1968
+ // in-flight permission promise (otherwise a blocking request leaks until dispose) and cancel a
1969
+ // pending Stop→idle timer (otherwise it fires inside its window and revives this row to a
1970
+ // live zombie).
1971
+ const pending = this._pendingPermissions.get(prev.id);
1972
+ if (pending != null) {
1973
+ this.rejectAllPending(pending, new Error('Session completed'));
1974
+ this._pendingPermissions.delete(prev.id);
1975
+ }
1976
+ this.cancelPendingIdleTransition(prev.id);
1977
+ const bk = this._sessionBookkeeping.get(prev.id);
1978
+ if (bk != null) {
1979
+ this.cancelPendingFileClears(bk);
1980
+ this.cancelPendingReadClears(bk);
1981
+ }
1982
+ this._sessionBookkeeping.delete(prev.id);
1983
+ this._transcriptReader.forget(prev.id);
1984
+ }
1411
1985
  pruneDeadSessions() {
1412
1986
  const kept = [];
1413
1987
  const removedIds = [];
1414
1988
  for (const s of this._sessions) {
1989
+ // Completed sessions are terminal and durable — their PID is usually dead by design, so
1990
+ // liveness pruning must never drop them (they leave only via poll reconciliation).
1991
+ if (s.status === 'completed') {
1992
+ kept.push(s);
1993
+ continue;
1994
+ }
1415
1995
  // A session blocking on us for a permission decision is by definition alive,
1416
1996
  // even if `kill(pid, 0)` says otherwise (e.g. transient EPERM/ESRCH). Dropping
1417
1997
  // it here loses pendingPermission and lastPrompt; the next syncSessions then
@@ -1429,7 +2009,7 @@ export class ClaudeCodeProvider {
1429
2009
  for (const id of removedIds) {
1430
2010
  const pending = this._pendingPermissions.get(id);
1431
2011
  if (pending != null) {
1432
- pending.reject(new Error('Session pruned'));
2012
+ this.rejectAllPending(pending, new Error('Session pruned'));
1433
2013
  this._pendingPermissions.delete(id);
1434
2014
  }
1435
2015
  this.cancelPendingIdleTransition(id);
@@ -1454,11 +2034,57 @@ export class ClaudeCodeProvider {
1454
2034
  // sessions exist — that's the only local backstop for pruning agents that die without firing
1455
2035
  // `SessionEnd`, and it keeps us correct even if hook detection is stale/wrong. The bootstrap
1456
2036
  // 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
2038
+ // we have to reconcile — it's removed only when the CLI stops listing it (archived from
2039
+ // another window, or aged out) — and this poll is the sole mechanism that does so. It's also
2040
+ // what triggers the CLI's own 30-day retention sweep, which runs on `list-sessions` and
2041
+ // nothing else: no timer, no daemon. Gating it out to save a spawn wouldn't avoid work, it
2042
+ // would strand the rows on screen AND the records on disk until the window reloads.
1457
2043
  if (options?.gate && this._sessions.length === 0 && !this._claudeHooksInstalled)
1458
2044
  return;
2045
+ // Narrowly: hooks off AND nothing running, i.e. we hold only terminal history. There's no
2046
+ // dying process to reap and no live state to correct — just rows to reconcile and the
2047
+ // retention sweep to trigger, neither of which needs quarter-hourly resolution. Back the
2048
+ // cadence off rather than stopping (window startup polls ungated, so a reload is current).
2049
+ // Hooks ON keeps the full cadence deliberately: there the tick is also the backstop for a
2050
+ // session whose hook events never reached our IPC server.
2051
+ if (options?.gate &&
2052
+ !this._claudeHooksInstalled &&
2053
+ !this._sessions.some(s => s.status !== 'completed') &&
2054
+ Date.now() - this._lastSyncAt < idleReconcileIntervalMs) {
2055
+ return;
2056
+ }
2057
+ // 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.
2060
+ const pollStartedAt = Date.now();
2061
+ this._lastSyncAt = pollStartedAt;
1459
2062
  let sessions;
1460
2063
  try {
1461
- const output = await this.callbacks.runCLICommand(['ai', 'hook', 'list-sessions', '--json']);
2064
+ let output;
2065
+ try {
2066
+ // `--status active,ended` surfaces both live sessions and the CLI's durable ended records
2067
+ // (excluding `archived`). Newer CLIs default `list-sessions` to active-only, so the flag
2068
+ // is required to see completed sessions at all.
2069
+ output = await this.callbacks.runCLICommand([
2070
+ 'ai',
2071
+ 'hook',
2072
+ 'list-sessions',
2073
+ '--status',
2074
+ 'active,ended',
2075
+ '--json',
2076
+ ]);
2077
+ this._statusFilterSupported = true;
2078
+ }
2079
+ catch (ex) {
2080
+ // Only retry on an unknown-flag error — a pre-3.1.69 CLI rejects `--status` but returns
2081
+ // all sessions unfiltered, so the flagless call is the correct legacy equivalent. Real
2082
+ // failures (CLI missing, spawn error) rethrow to the prune-and-return handler below.
2083
+ if (!isUnknownFlagError(ex))
2084
+ throw ex;
2085
+ this._statusFilterSupported = false;
2086
+ output = await this.callbacks.runCLICommand(['ai', 'hook', 'list-sessions', '--json']);
2087
+ }
1462
2088
  sessions = JSON.parse(output);
1463
2089
  }
1464
2090
  catch {
@@ -1470,27 +2096,252 @@ export class ClaudeCodeProvider {
1470
2096
  // Snapshot what the live IPC path had already tracked, so we can detect drift between it and
1471
2097
  // what the poll returns. Ideally they match exactly — any difference means the live push path
1472
2098
  // missed an add (poll discovers it) or a teardown (poll no longer reports a session we track).
1473
- const trackedBefore = new Set(this._sessions.map(s => s.id));
2099
+ // Drift is measured over *live* sessions only; completed sessions are durable history, so their
2100
+ // 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));
1474
2102
  const polledAlive = new Set();
1475
- // Mutable copy of `trackedBefore` that also accumulates ids added during this poll, so the
1476
- // membership check below is O(1) and duplicate ids within a single poll response are only
1477
- // added once. `trackedBefore` itself stays the pre-poll snapshot used by the `missing` calc.
1478
- const known = new Set(trackedBefore);
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.
2105
+ const polledIds = new Set();
2106
+ // Accumulates ids already tracked or added during this poll, so the membership check is O(1)
2107
+ // and duplicate ids within a single response are only added once.
2108
+ const known = new Set(this._sessions.map(s => s.id));
1479
2109
  let changed = false;
1480
2110
  let discovered = 0;
1481
2111
  for (const data of sessions) {
1482
- if (!data.sessionId || !data.pid || !isProcessAlive(data.pid)) {
2112
+ if (!data.sessionId)
2113
+ 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')
2118
+ continue;
2119
+ const effectiveStatus = data.status ?? 'active';
2120
+ // Archived records are excluded by the `active,ended` query; guard anyway.
2121
+ if (effectiveStatus === 'archived')
2122
+ continue;
2123
+ if (effectiveStatus === 'ended') {
2124
+ polledIds.add(data.sessionId);
2125
+ const endedDate = new Date(data.endedAt ?? data.updatedAt);
2126
+ const existingIndex = this._sessions.findIndex(s => s.id === data.sessionId);
2127
+ 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);
2133
+ changed = true;
2134
+ }
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.
2140
+ const existing = this._sessions[existingIndex];
2141
+ const worktreeRoot = worktreeRootFromData(data);
2142
+ const cwdChanged = Boolean(data.cwd) && data.cwd !== existing.cwd;
2143
+ // Pre-`cwdTimeline` records carry no worktree data. If the cwd moved, whatever we hold
2144
+ // is no longer trustworthy — it may point into a different repo entirely — so drop it
2145
+ // 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;
2153
+ const worktreeMoved = nextWorktreePath !== existing.worktreePath;
2154
+ if (cwdChanged || worktreeMoved) {
2155
+ const workspacePath = this.resolveWorkspacePath(data.cwd || existing.cwd);
2156
+ this._sessions[existingIndex] = {
2157
+ ...existing,
2158
+ cwd: data.cwd || existing.cwd,
2159
+ initialCwd: existing.initialCwd ?? data.initialCwd ?? data.cwd,
2160
+ worktreePath: nextWorktreePath,
2161
+ workspacePath: workspacePath,
2162
+ isInWorkspace: workspacePath != null,
2163
+ // A worktree move can cross repos, and the spread would otherwise carry the old
2164
+ // repo identity forward — which consumers PREFER over the freshly-resolved
2165
+ // worktree metadata, pinning the card to the wrong repo. Drop it and let the
2166
+ // host backfill from the new worktree.
2167
+ commonPath: worktreeMoved ? undefined : existing.commonPath,
2168
+ };
2169
+ 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);
2174
+ }
2175
+ }
2176
+ this.getBookkeeping(data.sessionId).polledAtLeastOnce = true;
2177
+ continue;
2178
+ }
2179
+ known.add(data.sessionId);
2180
+ const workspacePath = this.resolveWorkspacePath(data.cwd);
2181
+ const worktreeRoot = worktreeRootFromData(data);
2182
+ this._sessions.push({
2183
+ id: data.sessionId,
2184
+ providerId: this.id,
2185
+ providerName: this.name,
2186
+ name: data.sessionName || undefined,
2187
+ status: 'completed',
2188
+ phase: 'completed',
2189
+ phaseSince: endedDate,
2190
+ pid: data.pid,
2191
+ lastActivity: endedDate,
2192
+ isSubagent: false,
2193
+ workspacePath: workspacePath,
2194
+ // Read straight from the CLI's durable record so completed sessions attach to branch
2195
+ // cards / WIP rows / the resume picker at any age, no git probe.
2196
+ worktreePath: worktreeRoot != null ? normalizePath(worktreeRoot) : undefined,
2197
+ cwd: data.cwd,
2198
+ initialCwd: data.initialCwd ?? data.cwd,
2199
+ planFile: data.planFile ?? undefined,
2200
+ isInWorkspace: workspacePath != null,
2201
+ lastPrompt: prepareStoredPrompt(data.prompt ?? undefined),
2202
+ firstPrompt: prepareStoredPrompt(data.firstPrompt ?? undefined),
2203
+ model: data.model ?? undefined,
2204
+ endReason: data.endReason ?? undefined,
2205
+ endedAt: data.endedAt != null ? new Date(data.endedAt).getTime() : undefined,
2206
+ // Subagents are meaningless once terminal.
2207
+ subagents: undefined,
2208
+ });
2209
+ this.getBookkeeping(data.sessionId).polledAtLeastOnce = true;
2210
+ changed = true;
2211
+ // Titles/prompts still resolve lazily via resolveCompletedSessionDetails (a 30-day
2212
+ // cold-start must not fan out transcript reads). worktreePath comes from the record
2213
+ // above; only fall back to an eager git probe when the record predates that CLI field,
2214
+ // and only for the recent (WIP-window) tail so the fallback stays bounded.
2215
+ if (worktreeRoot == null &&
2216
+ data.cwd &&
2217
+ Date.now() - endedDate.getTime() < recentCompletedGitResolveThresholdMs) {
2218
+ void this.resolveGitInfo(data.sessionId, data.cwd);
2219
+ }
1483
2220
  continue;
1484
2221
  }
2222
+ // Active / legacy (absent-status) path — only genuinely-live processes are discoverable.
2223
+ if (!data.pid || !isProcessAlive(data.pid))
2224
+ continue;
1485
2225
  polledAlive.add(data.sessionId);
1486
- if (known.has(data.sessionId))
2226
+ polledIds.add(data.sessionId);
2227
+ if (known.has(data.sessionId)) {
2228
+ // Already tracked.
2229
+ const existingIndex = this._sessions.findIndex(s => s.id === data.sessionId);
2230
+ if (existingIndex >= 0) {
2231
+ const existing = this._sessions[existingIndex];
2232
+ // The status the CLI's last event implies — a resumed session may already be mid-work
2233
+ // (`PreToolUse` → tool_use, `UserPromptSubmit` → thinking), and hardcoding `idle` would
2234
+ // 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
2238
+ // resume the live SessionStart path missed) — revive it, the symmetric backstop
2239
+ // to the ended-branch's live→completed transition above. Leave genuinely-live
2240
+ // rows to the IPC path.
2241
+ const revivedAt = new Date(data.updatedAt);
2242
+ // Only when the record is at least as new as the completion. `SessionEnd` fires
2243
+ // while the process is still winding down, so a poll whose CLI call started
2244
+ // BEFORE it returns a snapshot that still says "active" with a live pid — reviving
2245
+ // off that stale record would resurrect the row, and `pruneDeadSessions` would
2246
+ // then delete it outright once the process exits, losing the completed row.
2247
+ if (revivedAt.getTime() < existing.phaseSince.getTime())
2248
+ continue;
2249
+ // The poll has no blocking hook entry for this ask (it's discovered off the CLI's
2250
+ // durable record, not this window's IPC path) — synthesize an unresolvable one so
2251
+ // the row shows what it's waiting on instead of an empty "Needs input" card.
2252
+ const revivedPendingPermission = polledStatus === 'permission_requested'
2253
+ ? synthesizeUnresolvableAsk(data.toolName, data.planFile ?? undefined)
2254
+ : undefined;
2255
+ // 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.
2257
+ const revivedWorktreeRoot = worktreeRootFromData(data);
2258
+ // No record worktree data (older CLI): KEEP the completed row's attribution. A
2259
+ // resume whose cwd is scratch space (e.g. /tmp) still belongs to its worktree —
2260
+ // re-rooting happens only when git affirmatively resolves a new one (the probe
2261
+ // below, on success). Attribution persists until something better is known.
2262
+ const revivedWorktreePath = revivedWorktreeRoot != null ? normalizePath(revivedWorktreeRoot) : existing.worktreePath;
2263
+ // Membership follows the attribution, mirroring `ensureSession`: cwd first, the
2264
+ // kept worktree as the fallback for a scratch-dir resume.
2265
+ const revivedWorkspacePath = this.resolveWorkspacePath(data.cwd) ?? this.resolveWorkspacePath(revivedWorktreePath);
2266
+ this._sessions[existingIndex] = {
2267
+ ...existing,
2268
+ status: polledStatus,
2269
+ phase: getPhaseForStatus(polledStatus),
2270
+ phaseSince: revivedAt,
2271
+ lastActivity: revivedAt,
2272
+ // Fresh pid from the CLI, and re-classify ownership on next open rather than
2273
+ // trusting the terminal record's `isPeerOwned`.
2274
+ pid: data.pid,
2275
+ isPeerOwned: undefined,
2276
+ pendingPermission: revivedPendingPermission,
2277
+ // The resume can be running somewhere else entirely, so take the record's
2278
+ // location rather than leaving the row filed under where it ended.
2279
+ cwd: data.cwd ?? existing.cwd,
2280
+ workspacePath: revivedWorkspacePath ?? (data.cwd == null ? existing.workspacePath : undefined),
2281
+ isInWorkspace: data.cwd == null ? existing.isInWorkspace : revivedWorkspacePath != null,
2282
+ worktreePath: revivedWorktreePath,
2283
+ // A worktree move can cross repos — drop the stale repo identity and let the
2284
+ // probe below refill it.
2285
+ commonPath: revivedWorktreePath !== existing.worktreePath ? undefined : existing.commonPath,
2286
+ // No longer terminal: leaving these set makes the row read as completed to
2287
+ // consumers that key off them.
2288
+ endReason: undefined,
2289
+ endedAt: undefined,
2290
+ };
2291
+ this.resetBookkeeping(data.sessionId);
2292
+ // The record's worktree is the CLI's own resolution; without one the probe owns
2293
+ // attribution again.
2294
+ this.getBookkeeping(data.sessionId).cliSeatedWorktree = revivedWorktreeRoot != null;
2295
+ if (data.cwd != null && data.cwd !== existing.cwd) {
2296
+ void this.resolveGitInfo(data.sessionId, data.cwd);
2297
+ }
2298
+ changed = true;
2299
+ }
2300
+ else if (existing.pendingPermission?.resolvable === false &&
2301
+ polledStatus !== 'permission_requested' &&
2302
+ new Date(data.updatedAt).getTime() > existing.lastActivity.getTime()) {
2303
+ // The record has moved past the ask this row still shows. Only a synthesized
2304
+ // (`resolvable: false`) ask is repairable this way — a routable one is bookkeeping-
2305
+ // owned and cleared by its own resolution paths, which the record can't see.
2306
+ this._sessions[existingIndex] = {
2307
+ ...existing,
2308
+ status: polledStatus,
2309
+ phase: getPhaseForStatus(polledStatus),
2310
+ phaseSince: new Date(data.updatedAt),
2311
+ // The detail described the ask's tool; carry the record's current tool for
2312
+ // tool_use, else clear — keeping it would show the answered ask's tool as
2313
+ // the active one.
2314
+ statusDetail: polledStatus === 'tool_use' ? (data.toolName ?? undefined) : undefined,
2315
+ pendingPermission: undefined,
2316
+ lastActivity: new Date(data.updatedAt),
2317
+ };
2318
+ changed = true;
2319
+ }
2320
+ else if (existing.transcriptTitles?.ai == null && existing.transcriptTitles?.custom == null) {
2321
+ // A live session we discovered via the poll (another worktree/window owns its
2322
+ // hook flow) gets none of the local IPC re-resolves (SessionStart, idle). So one
2323
+ // discovered before Claude wrote its `ai-title` would show the repo slug forever.
2324
+ // Re-check the transcript each poll until a real title lands — cheap: the reader
2325
+ // tail-reads from its mtime cache and no-ops when the file hasn't grown.
2326
+ void this.resolveTranscriptTitles(data.sessionId, data.cwd);
2327
+ }
2328
+ }
1487
2329
  continue;
2330
+ }
1488
2331
  known.add(data.sessionId);
1489
2332
  const workspacePath = this.resolveWorkspacePath(data.cwd);
1490
2333
  const isInWorkspace = workspacePath != null;
1491
2334
  const status = deriveStatusFromEvent(data.event);
1492
2335
  const phase = getPhaseForStatus(status);
1493
2336
  const activityDate = new Date(data.updatedAt);
2337
+ // Same rationale as the revived-session branch above: no blocking hook entry exists for a
2338
+ // poll-discovered ask, so synthesize an unresolvable one for the card.
2339
+ const pendingPermission = status === 'permission_requested'
2340
+ ? synthesizeUnresolvableAsk(data.toolName, data.planFile ?? undefined)
2341
+ : undefined;
2342
+ // 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.
2344
+ const worktreeRoot = worktreeRootFromData(data);
1494
2345
  const subagents = data.subagents?.map(sub => ({
1495
2346
  id: sub.agentId,
1496
2347
  providerId: this.id,
@@ -1525,15 +2376,48 @@ export class ClaudeCodeProvider {
1525
2376
  isInWorkspace: isInWorkspace,
1526
2377
  lastPrompt: prepareStoredPrompt(data.prompt ?? undefined),
1527
2378
  firstPrompt: prepareStoredPrompt(data.firstPrompt ?? undefined),
2379
+ model: data.model ?? undefined,
1528
2380
  subagents: subagents,
2381
+ pendingPermission: pendingPermission,
2382
+ worktreePath: worktreeRoot != null ? normalizePath(worktreeRoot) : undefined,
1529
2383
  });
1530
2384
  changed = true;
1531
2385
  discovered++;
2386
+ // The record's worktree is the CLI's own resolution — mark it seated so the probe below
2387
+ // (still wanted for `commonPath`) can't clobber it for a nested worktree. Mirrors the
2388
+ // revival branch above.
2389
+ this.getBookkeeping(data.sessionId).cliSeatedWorktree = worktreeRoot != null;
1532
2390
  if (data.cwd) {
1533
2391
  void this.resolveGitInfo(data.sessionId, data.cwd);
1534
2392
  }
1535
2393
  void this.resolveTranscriptTitles(data.sessionId, data.cwd);
1536
2394
  }
2395
+ // The poll is authoritative for completed-session removal (archived elsewhere or 30-day-purged).
2396
+ // Only remove ones a prior poll confirmed present — never a session the live SessionEnd path just
2397
+ // 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
2399
+ // instead pin it forever — there, absence from the poll is the confirmation.
2400
+ const staleCompleted = this._sessions.filter(s => s.status === 'completed' &&
2401
+ !polledIds.has(s.id) &&
2402
+ (!this._statusFilterSupported ||
2403
+ this._sessionBookkeeping.get(s.id)?.polledAtLeastOnce === true ||
2404
+ // Or this poll's snapshot postdates the completion, so its absence is authoritative.
2405
+ // `polledAtLeastOnce` alone can never be set for a session whose record disappeared
2406
+ // (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
2408
+ // 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
2410
+ // 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));
2414
+ this._sessions = this._sessions.filter(s => !staleIds.has(s.id));
2415
+ for (const s of staleCompleted) {
2416
+ this._sessionBookkeeping.delete(s.id);
2417
+ this._transcriptReader.forget(s.id);
2418
+ }
2419
+ changed = true;
2420
+ }
1537
2421
  if (this.pruneDeadSessions()) {
1538
2422
  changed = true;
1539
2423
  }
@@ -1543,11 +2427,11 @@ export class ClaudeCodeProvider {
1543
2427
  // Report any drift between the live-synced set and the poll. Only on the recurring (gated)
1544
2428
  // poll — the ungated bootstrap call runs before the live path has had a chance to receive
1545
2429
  // any events, so its discoveries are expected cold-start state, not drift. `missing` counts
1546
- // sessions we still track that the poll no longer reports alive (a teardown the live path
2430
+ // live sessions we still track that the poll no longer reports alive (a teardown the live path
1547
2431
  // missed, e.g. an agent killed without firing `SessionEnd`).
1548
2432
  if (options?.gate) {
1549
2433
  let missing = 0;
1550
- for (const id of trackedBefore) {
2434
+ for (const id of trackedLiveBefore) {
1551
2435
  if (!polledAlive.has(id)) {
1552
2436
  missing++;
1553
2437
  }
@@ -1558,7 +2442,7 @@ export class ClaudeCodeProvider {
1558
2442
  discovered: discovered,
1559
2443
  missing: missing,
1560
2444
  polled: polledAlive.size,
1561
- tracked: trackedBefore.size,
2445
+ tracked: trackedLiveBefore.size,
1562
2446
  });
1563
2447
  }
1564
2448
  }
@@ -1590,6 +2474,12 @@ export class ClaudeCodeProvider {
1590
2474
  if (peerSessions == null)
1591
2475
  continue;
1592
2476
  for (const peerSession of peerSessions) {
2477
+ // Skip terminal rows a peer published: completed sessions live in the machine-global CLI
2478
+ // store, so our own poll surfaces them with proper reconciliation. Importing them from a
2479
+ // 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')
2482
+ continue;
1593
2483
  const peerActivity = new Date(peerSession.lastActivity);
1594
2484
  const peerPhaseSince = new Date(peerSession.phaseSince);
1595
2485
  const existing = this._sessions.find(s => s.id === peerSession.id);
@@ -1604,6 +2494,11 @@ export class ClaudeCodeProvider {
1604
2494
  }
1605
2495
  if (peerActivity > existing.lastActivity) {
1606
2496
  const idx = this._sessions.indexOf(existing);
2497
+ // A terminal row the peer now reports live IS a resume the peer owns. Mirror the
2498
+ // poll/SessionStart revive paths: take the peer's pid (retaining the terminal
2499
+ // record's dead — and possibly reused — pid would let dispatch focus an unrelated
2500
+ // process) and mark it peer-owned so opens route through the peer's IPC.
2501
+ const revivedFromCompleted = existing.status === 'completed';
1607
2502
  // Peer is the authoritative hook recipient for this session, so wipe any
1608
2503
  // local bookkeeping that survived from a previous local-ownership window.
1609
2504
  // Without this, refcounts/timers from a prior local-hosting stretch would
@@ -1623,6 +2518,14 @@ export class ClaudeCodeProvider {
1623
2518
  phaseSince: peerPhaseSince,
1624
2519
  statusDetail: peerSession.statusDetail,
1625
2520
  lastActivity: peerActivity,
2521
+ // A locally-routable ask wins — a blocking ask fans out to every window, so we
2522
+ // may hold the hook entry the peer lacks. Otherwise mirror the peer's ask marked
2523
+ // unresolvable (we have no entry to answer it), and clear it when the peer did.
2524
+ pendingPermission: this._sessionBookkeeping.get(peerSession.id)?.pendingPermission ??
2525
+ (peerSession.pendingPermission != null
2526
+ ? { ...peerSession.pendingPermission, resolvable: false }
2527
+ : undefined),
2528
+ ...(revivedFromCompleted ? { pid: peerSession.pid, isPeerOwned: true } : undefined),
1626
2529
  subagents: rehydrateSubagents(peerSession.subagents),
1627
2530
  // Carry the peer's published fileActivity across so peer-window WIP
1628
2531
  // decorations + treemap heatmap follow the agent. Owned by the peer (it's
@@ -1659,6 +2562,10 @@ export class ClaudeCodeProvider {
1659
2562
  ...peerSession,
1660
2563
  lastActivity: peerActivity,
1661
2564
  phaseSince: peerPhaseSince,
2565
+ // The peer owns the hook entry, so an imported ask can never be routed from here.
2566
+ pendingPermission: peerSession.pendingPermission != null
2567
+ ? { ...peerSession.pendingPermission, resolvable: false }
2568
+ : undefined,
1662
2569
  workspacePath: normalizeWorkspacePath(peerSession.workspacePath),
1663
2570
  isInWorkspace: this.matchesWorkspace(peerSession.workspacePath),
1664
2571
  subagents: rehydrateSubagents(peerSession.subagents),