@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
@@ -39,6 +39,9 @@ import { ClaudeCodeTranscriptReader } from './claudeCodeTranscript.js';
39
39
  interface AgentSessionEvent {
40
40
  event: ClaudeCodeHookEvent;
41
41
  sessionId: string;
42
+ /** `gk ai hook` client id of the originating host (`claude-code`, `codex`, …) — NOT
43
+ * `AgentSession.providerId`. Absent on older CLIs. */
44
+ providerId?: string;
42
45
  cwd: string;
43
46
  /** The agent's launch directory, captured first-hand by the CLI and preserved across events.
44
47
  * Optional — older CLIs don't send it, in which case consumers fall back to deriving it from
@@ -59,6 +62,10 @@ interface AgentSessionEvent {
59
62
  toolInput?: Record<string, unknown>;
60
63
  permissionSuggestions?: PermissionSuggestion[];
61
64
  hookInput?: Record<string, unknown>;
65
+ /** CLI-resolved worktree roots for this session's cwd visits, newest last — same shape as
66
+ * {@link SessionFileData.cwdTimeline}. The CLI runs `rev-parse` at hook time and includes this
67
+ * on every event, even though it isn't part of the raw hook payload. */
68
+ cwdTimeline?: { cwd: string; worktree?: string; at?: string }[];
62
69
  }
63
70
 
64
71
  interface PermissionResponse {
@@ -72,10 +79,15 @@ interface PermissionResponse {
72
79
  }
73
80
 
74
81
  interface PendingPermissionEntry {
75
- resolve(response: PermissionResponse): void;
76
- reject(reason: Error): void;
82
+ /** All connections waiting on this ask — duplicate deliveries (CLI retry after its 60s
83
+ * response-header timeout) attach here and settle together. */
84
+ resolvers: { resolve(response: PermissionResponse): void; reject(reason: Error): void }[];
77
85
  toolName: string;
78
86
  toolDescription: string;
87
+ /** From `hookInput.tool_use_id` when the CLI surfaces it; identity gate for stale-clearing. */
88
+ toolUseId?: string;
89
+ /** Serialized tool_input, for duplicate-delivery detection when `toolUseId` is absent. */
90
+ toolInputJson?: string;
79
91
  }
80
92
 
81
93
  interface SessionBookkeeping {
@@ -107,13 +119,48 @@ interface SessionBookkeeping {
107
119
  * every subsequent hook event. Cleared when the session's cwd changes (re-resolution
108
120
  * warranted) or on resetBookkeeping (Stop/SessionStart). */
109
121
  gitInfoUnresolvable: boolean;
122
+ /** Set while the session's `worktreePath` came from the CLI (an event's `cwdTimeline`, or a poll
123
+ * record's). The CLI resolves the worktree for the exact cwd at hook time; the local probe answers
124
+ * for whichever repo the host registry matches, which for a nested cwd can be the parent worktree.
125
+ * While set, `resolveGitInfo` keeps the seated value instead of overwriting it. Cleared when the
126
+ * cwd moves without the CLI explaining the new location — attribution goes back to the probe. */
127
+ cliSeatedWorktree?: boolean;
110
128
  /** Phase that immediately preceded the current one, with its original `phaseSince`. Used by
111
129
  * `resolvePhaseSince` to restore continuity when phase oscillates back within a short
112
130
  * window (e.g., Stop → idle → working after a continuation crosses the debounce). */
113
131
  priorPhase?: { phase: AgentSessionPhase; phaseSince: Date };
132
+ /** Set once the reconciliation poll has seen this session's durable `ended` record. Gates
133
+ * completed-session removal: a session the live `SessionEnd` path just transitioned is never
134
+ * reconcile-removed by an already-in-flight poll that legitimately missed it — only after a poll
135
+ * has confirmed the CLI still lists it. */
136
+ polledAtLeastOnce?: boolean;
137
+ /** Set once a completed session's transcript has been read on demand (via
138
+ * `resolveCompletedSessionDetails`). Terminal transcripts don't change, so the read happens at
139
+ * most once regardless of how many times the row is opened. */
140
+ completedDetailsResolved?: boolean;
114
141
  }
115
142
 
116
143
  const staleCheckIntervalMs = 15 * 60 * 1000; // 15 minutes
144
+ /** Minimum spacing between gated reconciliation polls while NOTHING is running. The quarter-hourly
145
+ * tick exists to reap agents that died without a `SessionEnd`; with no live session there's nothing
146
+ * to reap, leaving only terminal-history reconciliation and the CLI's retention sweep — both fine
147
+ * hourly. Window startup polls ungated, so this never delays a fresh window. */
148
+ const idleReconcileIntervalMs = 60 * 60 * 1000; // 1 hour
149
+ /** How long a freshly-completed row is protected from absence-based removal. The CLI persists the
150
+ * ended record (atomic temp+rename) BEFORE broadcasting the hook event, so a poll that starts after
151
+ * we mark a row completed will list it — this guards only against that ordering changing on the CLI
152
+ * side, where the cost of being wrong is a completed row silently vanishing. */
153
+ const completedRemovalGraceMs = 30 * 1000; // 30 seconds
154
+ /** How long the machine-global archived-session id list (fetched via a separate `--status archived`
155
+ * CLI query) is cached. `getPastSessions` asks for it once per worktree on panel open, so without
156
+ * this a multi-worktree graph spawns N identical CLI processes. `archiveSession` invalidates the
157
+ * cache, so a just-archived row never lingers under "Past". */
158
+ const archivedSessionIdsCacheTtlMs = 10 * 1000; // 10 seconds
159
+ /** Completed sessions defer git + transcript resolution (a 30-day cold-start would otherwise fan out
160
+ * hundreds of probes). Exception: sessions ended within this window still surface on WIP rows /
161
+ * branch cards, which match strictly by resolved `worktreePath` — so their git info is resolved
162
+ * eagerly at poll time (a handful), keeping the older tail lazy. Mirrors the 24h WIP-row window. */
163
+ const recentCompletedGitResolveThresholdMs = 24 * 60 * 60 * 1000; // 24 hours
117
164
  /** Default cooldown between PostToolUse and dropping the file from `fileActivity`. Held long
118
165
  * enough that the treemap activity overlay can render a decay tail well past the moment the tool
119
166
  * call completed. The host may override per-call via `AgentProviderCallbacks.getActivityDecayMs`
@@ -133,10 +180,18 @@ const phaseSinceRestoreWindowMs = 2000;
133
180
 
134
181
  interface SessionFileData {
135
182
  sessionId: string;
183
+ /** `gk ai hook` client id of the owning host — see {@link AgentSessionEvent.providerId}. */
184
+ providerId?: string;
136
185
  event: string;
137
186
  cwd: string;
138
187
  /** CLI-provided launch directory; absent on older CLIs (fall back to `cwd`). */
139
188
  initialCwd?: string;
189
+ /** Worktree roots (`git rev-parse --show-toplevel`) the CLI resolved for this session's cwd
190
+ * visits, newest last. Absent on older CLIs. Lets us read `worktreePath` straight from the
191
+ * durable store — so completed sessions attach to branch cards / WIP rows / the resume picker at
192
+ * any age without a git probe (the CLI already ran `rev-parse` at hook time). */
193
+ cwdTimeline?: { cwd: string; worktree?: string; at?: string }[];
194
+ worktrees?: string[];
140
195
  pid: number;
141
196
  toolName?: string | null;
142
197
  agentId?: string | null;
@@ -149,6 +204,102 @@ interface SessionFileData {
149
204
  sessionName?: string | null;
150
205
  prompt?: string | null;
151
206
  firstPrompt?: string | null;
207
+ /** CLI durable-session lifecycle status. Absent on legacy files → treated as `active`. `ended`
208
+ * records are surfaced as terminal `completed` sessions; `archived` is excluded by the query. */
209
+ status?: 'active' | 'ended' | 'archived';
210
+ /** Why the session ended (`session-end`, `rotated`, `stale`, `dead-pid`, `pid-zero-idle`,
211
+ * `archived`). Present only on `ended`/`archived` records. */
212
+ endReason?: string;
213
+ /** RFC3339 timestamp the session ended; used as `lastActivity`/`phaseSince` for completed rows. */
214
+ endedAt?: string;
215
+ }
216
+
217
+ /** The session's current worktree root from the CLI's durable record — the last cwd-visit's resolved
218
+ * worktree, falling back to the last distinct worktree seen. `undefined` on older CLIs that don't
219
+ * record it, so the caller resolves git the slow way instead. */
220
+ function worktreeRootFromData(data: SessionFileData): string | undefined {
221
+ const fromTimeline = data.cwdTimeline?.at(-1)?.worktree;
222
+ if (fromTimeline) return fromTimeline;
223
+
224
+ return data.worktrees?.at(-1) || undefined;
225
+ }
226
+
227
+ /** The CLI resolves the worktree for `event.cwd` at hook time and includes it on every event —
228
+ * using its answer seats `worktreePath` synchronously, closing the in-flight-probe window where
229
+ * a session that just moved would otherwise still show its previous worktree until `resolveGitInfo`
230
+ * catches up. Scans from the end for the timeline entry matching the event's current cwd. */
231
+ function worktreeFromEvent(event: AgentSessionEvent): string | undefined {
232
+ const timeline = event.cwdTimeline;
233
+ if (timeline == null) return undefined;
234
+
235
+ for (let i = timeline.length - 1; i >= 0; i--) {
236
+ const entry = timeline[i];
237
+ if (entry.cwd === event.cwd) return entry.worktree || undefined;
238
+ }
239
+
240
+ return undefined;
241
+ }
242
+
243
+ /** Synthesizes an unresolvable ask for a `permission_requested` row that has no routable
244
+ * `PendingPermission` — a non-blocking `Notification`/`PermissionRequest` tail, or a
245
+ * poll-discovered live/revived row. None of these hold a blocking hook entry to route an
246
+ * Allow/Deny through, so the card must show what the agent is waiting on without offering
247
+ * actions the host can't fulfill. */
248
+ function synthesizeUnresolvableAsk(toolName: string | null | undefined, planFilePath?: string): PendingPermission {
249
+ if (toolName != null) {
250
+ const kind = classifyPermissionKind(toolName);
251
+ return {
252
+ kind: kind,
253
+ toolName: toolName,
254
+ toolDescription: toolName,
255
+ // A plan ask with no body at least links the plan the agent wrote, so the card can offer
256
+ // View Plan instead of a bare tool name.
257
+ planFilePath: kind === 'plan' ? planFilePath : undefined,
258
+ resolvable: false,
259
+ };
260
+ }
261
+
262
+ return {
263
+ kind: 'elicitation',
264
+ toolName: 'Input Required',
265
+ toolDescription: 'Waiting for input',
266
+ resolvable: false,
267
+ };
268
+ }
269
+
270
+ /** JSON.stringify with object keys recursively sorted — array order is preserved. `tool_input`
271
+ * can arrive with different key order (or via the top-level fallback field) between deliveries;
272
+ * canonicalizing makes input equality reliable across those variations. */
273
+ function stableStringify(value: unknown): string {
274
+ return JSON.stringify(value, (_key, val: unknown) => {
275
+ if (val == null || typeof val !== 'object' || Array.isArray(val)) return val;
276
+
277
+ const sorted: Record<string, unknown> = {};
278
+ for (const key of Object.keys(val).sort()) {
279
+ sorted[key] = (val as Record<string, unknown>)[key];
280
+ }
281
+ return sorted;
282
+ });
283
+ }
284
+
285
+ /** Field-wise equality for the ask a `permission_requested` row publishes. `updateSessionStatus`
286
+ * compares it before short-circuiting so a second ask arriving while the row is already
287
+ * `permission_requested` (a non-blocking tail for a different tool) still reaches subscribers. */
288
+ function isSameAsk(a: PendingPermission | undefined, b: PendingPermission | undefined): boolean {
289
+ if (a === b) return true;
290
+ if (a == null || b == null) return false;
291
+
292
+ return (
293
+ a.kind === b.kind &&
294
+ a.toolName === b.toolName &&
295
+ a.toolDescription === b.toolDescription &&
296
+ a.toolInputDescription === b.toolInputDescription &&
297
+ a.resolvable === b.resolvable &&
298
+ a.planFilePath === b.planFilePath &&
299
+ a.planSummary === b.planSummary &&
300
+ a.questionText === b.questionText &&
301
+ a.questionCount === b.questionCount
302
+ );
152
303
  }
153
304
 
154
305
  interface DiscoveryFile {
@@ -167,6 +318,10 @@ interface SessionContext {
167
318
  initialCwd?: string;
168
319
  planFile?: string;
169
320
  sessionName?: string;
321
+ /** The model the session is running, from the hook event. */
322
+ model?: string;
323
+ /** CLI-resolved worktree root for the current cwd (authoritative when present). */
324
+ worktreePath?: string;
170
325
  }
171
326
 
172
327
  type SerializedAgentSession = Omit<AgentSession, 'lastActivity' | 'phaseSince' | 'subagents'> & {
@@ -218,6 +373,14 @@ function fileActivityStructurallyEqual(
218
373
  return true;
219
374
  }
220
375
 
376
+ /** True when a CLI invocation failed because it didn't recognize a flag/command. `--status` and the
377
+ * active-only `list-sessions` default shipped together (gk 3.1.69); an older CLI rejects `--status`
378
+ * and returns all sessions unfiltered, so the flagless retry is the correct legacy equivalent. */
379
+ function isUnknownFlagError(ex: unknown): boolean {
380
+ const message = ex instanceof Error ? ex.message : String(ex);
381
+ return /unknown (?:flag|shorthand flag|command)/i.test(message);
382
+ }
383
+
221
384
  export class ClaudeCodeProvider implements AgentSessionProvider {
222
385
  readonly id = 'claudeCode';
223
386
  readonly name = 'Claude Code';
@@ -231,17 +394,32 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
231
394
  private _disposed = false;
232
395
  private _handlerDisposables: UnifiedDisposable[] = [];
233
396
  private readonly _pendingPermissions = new Map<string, PendingPermissionEntry>();
234
- private readonly _resolveGitInfoInFlight = new Set<string>();
397
+ /** `sessionId -> cwd currently being probed`, and the newest cwd requested while that probe runs.
398
+ * A probe's answer is only valid for the cwd it was started with, so a session that moves
399
+ * mid-flight must supersede rather than dedupe — otherwise the older lookup lands last and
400
+ * overwrites the newer location with the directory the session already left. */
401
+ private readonly _resolveGitInfoInFlight = new Map<string, string>();
402
+ private readonly _resolveGitInfoPending = new Map<string, string>();
403
+ /** Short-TTL cache of the machine-global archived-session id list. Holds the in-flight promise so
404
+ * concurrent per-worktree `getPastSessions` calls share one CLI spawn; `archiveSession` clears it. */
405
+ private _archivedSessionIdsCache: { promise: Promise<string[]>; at: number } | undefined;
235
406
  private readonly _sessionBookkeeping = new Map<string, SessionBookkeeping>();
236
407
  /** Per-session timers for the deferred `Stop → idle` commit. The handle is cleared (and the
237
408
  * transition cancelled) by any non-idle status update arriving before the timer fires. */
238
409
  private readonly _pendingIdleTimers = new Map<string, NodeJS.Timeout>();
239
410
  private _workspacePaths: string[] = [];
240
411
  private _staleCheckTimer: UnifiedDisposable | undefined;
412
+ /** When the last poll actually ran (gated or not) — paces the idle cadence above. */
413
+ private _lastSyncAt = 0;
241
414
  /** Whether Claude hooks are installed, pushed by the host via {@link setClaudeHooksInstalled}.
242
415
  * Fail-open (`true`) until the first push lands so a fresh window with real hooks isn't
243
416
  * suppressed on its first ticks. Gates the reconciliation poll in {@link syncSessions}. */
244
417
  private _claudeHooksInstalled = true;
418
+ /** Whether the CLI supports `list-sessions --status` (and with it the durable ended-session
419
+ * store). Optimistic until the poll's flagless fallback proves otherwise. When unsupported,
420
+ * completed rows are untenable — no poll can ever confirm or archive one — so `SessionEnd`
421
+ * reverts to removing the session and reconciliation drops any completed stragglers. */
422
+ private _statusFilterSupported = true;
245
423
  protected _transcriptReader: ClaudeCodeTranscriptReader = new ClaudeCodeTranscriptReader();
246
424
 
247
425
  constructor(private readonly callbacks: AgentProviderCallbacks) {}
@@ -259,6 +437,10 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
259
437
  // IPC server stays up intentionally — hooks fire even when the window is unfocused
260
438
  }
261
439
 
440
+ sync(): Promise<void> {
441
+ return this.syncSessions();
442
+ }
443
+
262
444
  updateWorkspacePaths(workspacePaths: string[]): void {
263
445
  this._workspacePaths = workspacePaths.map(p => normalizePath(p));
264
446
 
@@ -352,7 +534,7 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
352
534
  }
353
535
  this._handlerDisposables = [];
354
536
  for (const pending of this._pendingPermissions.values()) {
355
- pending.reject(new Error('Provider disposed'));
537
+ this.rejectAllPending(pending, new Error('Provider disposed'));
356
538
  }
357
539
  this._pendingPermissions.clear();
358
540
  for (const bk of this._sessionBookkeeping.values()) {
@@ -390,16 +572,21 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
390
572
  }),
391
573
  this.callbacks.ipc.registerHandler('agents/sessions/list', () =>
392
574
  Promise.resolve(
393
- this._sessions.map(s => ({
394
- ...s,
395
- lastActivity: s.lastActivity.toISOString(),
396
- phaseSince: s.phaseSince.toISOString(),
397
- subagents: s.subagents?.map(sub => ({
398
- ...sub,
399
- lastActivity: sub.lastActivity.toISOString(),
400
- phaseSince: sub.phaseSince.toISOString(),
575
+ // Exclude terminal `completed` rows: an older-version peer has no `completed` phase and
576
+ // would import them as phantom "idle" sessions. Our own poll surfaces completed sessions
577
+ // independently, so peers never need them from here.
578
+ this._sessions
579
+ .filter(s => s.status !== 'completed')
580
+ .map(s => ({
581
+ ...s,
582
+ lastActivity: s.lastActivity.toISOString(),
583
+ phaseSince: s.phaseSince.toISOString(),
584
+ subagents: s.subagents?.map(sub => ({
585
+ ...sub,
586
+ lastActivity: sub.lastActivity.toISOString(),
587
+ phaseSince: sub.phaseSince.toISOString(),
588
+ })),
401
589
  })),
402
- })),
403
590
  ),
404
591
  ),
405
592
  this.callbacks.ipc.registerHandler('agents/sessions/open', async request => {
@@ -467,6 +654,14 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
467
654
  }
468
655
 
469
656
  private handleSessionEvent(event: AgentSessionEvent, isBlocking: boolean): Promise<PermissionResponse | void> {
657
+ // The CLI broadcasts every AI host's events to every listener. Absent = accept: older CLIs
658
+ // don't stamp it. A dropped blocking request gets no decision, so the CLI waits out its own
659
+ // hook timeout — correct, since we must not answer for an agent we don't track.
660
+ if (event.providerId != null && event.providerId !== 'claude-code') {
661
+ Logger.debug(`ClaudeCodeProvider.handleSessionEvent: ignoring ${event.event} from ${event.providerId}`);
662
+ return Promise.resolve();
663
+ }
664
+
470
665
  const workspacePath = this.resolveWorkspacePath(event.cwd);
471
666
  const eventContext: SessionContext = {
472
667
  pid: event.pid,
@@ -476,6 +671,8 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
476
671
  initialCwd: event.initialCwd,
477
672
  planFile: event.planFile,
478
673
  sessionName: event.sessionName,
674
+ model: event.model,
675
+ worktreePath: worktreeFromEvent(event),
479
676
  };
480
677
  const tag = this.sessionTag(event.sessionId, event.sessionName ?? 'unnamed');
481
678
 
@@ -503,11 +700,30 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
503
700
  }
504
701
  }
505
702
  } else {
506
- // SessionStart for a session we already track is almost always a CLI replay
507
- // (resume/reconnect/hook re-init). Don't clobber live state — the next real
508
- // event will re-establish status. Refresh pid only.
509
703
  const prev = this._sessions[index];
510
- if (event.pid != null && event.pid !== prev.pid) {
704
+ if (prev.status === 'completed') {
705
+ // Resuming a terminal session reuses its id, so a SessionStart on a completed
706
+ // row IS the resume signal — revive it to a live `idle` row (clearing the
707
+ // completed/archivable state) with clean bookkeeping that completeSession tore
708
+ // down. The next real event advances it out of idle. A `SessionStart` arrives on
709
+ // THIS window's hook flow, so the resume is locally owned now: clear the peer
710
+ // ownership carried over from the terminal record, and take the resume's pid
711
+ // (never the old terminal/reused one — retaining it would let dispatch focus a
712
+ // stale or unrelated process).
713
+ this._sessions[index] = {
714
+ ...prev,
715
+ status: 'idle',
716
+ phase: 'idle',
717
+ phaseSince: new Date(),
718
+ lastActivity: new Date(),
719
+ pid: event.pid,
720
+ isPeerOwned: undefined,
721
+ };
722
+ this.resetBookkeeping(event.sessionId);
723
+ } else if (event.pid != null && event.pid !== prev.pid) {
724
+ // SessionStart for a live session we already track is almost always a CLI
725
+ // replay (resume/reconnect/hook re-init). Don't clobber live state — the next
726
+ // real event re-establishes status. Refresh pid only.
511
727
  this._sessions[index] = { ...prev, pid: event.pid, lastActivity: new Date() };
512
728
  }
513
729
  }
@@ -521,30 +737,54 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
521
737
  case 'SessionEnd': {
522
738
  const pending = this._pendingPermissions.get(event.sessionId);
523
739
  if (pending != null) {
524
- pending.reject(new Error('Session ended'));
740
+ this.rejectAllPending(pending, new Error('Session ended'));
525
741
  this._pendingPermissions.delete(event.sessionId);
526
742
  }
527
743
  this.cancelPendingIdleTransition(event.sessionId);
528
744
 
529
745
  const index = this._sessions.findIndex(s => s.id === event.sessionId);
530
746
  if (index >= 0) {
531
- this._sessions.splice(index, 1);
747
+ if (this._statusFilterSupported) {
748
+ // Transition to a terminal `completed` row rather than removing it — completed
749
+ // sessions stay visible (de-emphasized) until archived or 30-day-purged by the CLI.
750
+ this.completeSession(index, new Date());
751
+ } else {
752
+ // Legacy CLI (no `--status` durable store): a completed row could never be
753
+ // poll-confirmed nor archived, so keep the pre-completed remove-on-end behavior.
754
+ this._sessions.splice(index, 1);
755
+ const bk = this._sessionBookkeeping.get(event.sessionId);
756
+ if (bk != null) {
757
+ this.cancelPendingFileClears(bk);
758
+ this.cancelPendingReadClears(bk);
759
+ }
760
+ this._sessionBookkeeping.delete(event.sessionId);
761
+ this._transcriptReader.forget(event.sessionId);
762
+ }
532
763
  this._onDidChangeSessions.fire();
764
+ } else {
765
+ // SessionEnd for a session we no longer track (already pruned/removed, or a duplicate
766
+ // end): still clear any bookkeeping timers + transcript cache the row left behind, so
767
+ // they don't leak. The tracked branches above already tear this down themselves.
768
+ const bk = this._sessionBookkeeping.get(event.sessionId);
769
+ if (bk != null) {
770
+ this.cancelPendingFileClears(bk);
771
+ this.cancelPendingReadClears(bk);
772
+ }
773
+ this._sessionBookkeeping.delete(event.sessionId);
774
+ this._transcriptReader.forget(event.sessionId);
533
775
  }
534
- const bk = this._sessionBookkeeping.get(event.sessionId);
535
- if (bk != null) {
536
- this.cancelPendingFileClears(bk);
537
- this.cancelPendingReadClears(bk);
538
- }
539
-
540
- this._sessionBookkeeping.delete(event.sessionId);
541
- this._transcriptReader.forget(event.sessionId);
542
776
  this.callbacks.onSessionEnded?.(this.id);
543
777
  break;
544
778
  }
545
779
 
546
780
  case 'UserPromptSubmit': {
547
- const { index } = this.ensureSession(event.sessionId, eventContext);
781
+ const { index, changed } = this.ensureSession(event.sessionId, eventContext);
782
+ // Seated metadata (location, model, name) has to reach subscribers even when the prompt
783
+ // turns out to be synthetic and the status update below never runs.
784
+ if (changed) {
785
+ this._onDidChangeSessions.fire();
786
+ }
787
+
548
788
  const cleaned = prepareStoredPrompt(event.prompt);
549
789
  // A `UserPromptSubmit` whose payload sanitizes to nothing is harness-synthetic
550
790
  // (e.g. background-bash <task-notification>, slash-command stdout echo). Treat it
@@ -596,7 +836,7 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
596
836
 
597
837
  case 'PostToolUse':
598
838
  case 'PostToolUseFailure': {
599
- this.clearStalePermission(event.sessionId, event.event);
839
+ this.clearStalePermission(event.sessionId, event.event, this.settledByFromEvent(event));
600
840
  const bk = this.getBookkeeping(event.sessionId);
601
841
  bk.activeToolCount = Math.max(0, bk.activeToolCount - 1);
602
842
  // Cooldown the file-edit decoration instead of dropping it immediately, so a quick
@@ -631,7 +871,7 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
631
871
  case 'StopFailure': {
632
872
  const pending = this._pendingPermissions.get(event.sessionId);
633
873
  if (pending != null) {
634
- pending.reject(new Error('Session stopped'));
874
+ this.rejectAllPending(pending, new Error('Session stopped'));
635
875
  this._pendingPermissions.delete(event.sessionId);
636
876
  }
637
877
  // Turn ended: stop the "live" pulse but keep the per-operation decay tail fading over
@@ -667,10 +907,30 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
667
907
  // No-op — session is already idle from the preceding stop/session-start event
668
908
  break;
669
909
  case 'permission_prompt':
910
+ this.updateSessionStatus(event.sessionId, 'permission_requested', {
911
+ ...eventContext,
912
+ statusDetail: event.toolName,
913
+ pendingPermission: synthesizeUnresolvableAsk(
914
+ event.toolName,
915
+ // Plan-kind body: prefer the session's planFile, fall back to the event's
916
+ // — mirrors the blocking path.
917
+ this._sessions.find(s => s.id === event.sessionId)?.planFile ?? event.planFile,
918
+ ),
919
+ });
920
+ break;
670
921
  case 'elicitation_dialog':
922
+ // An MCP elicitation, not a tool permission — `toolName` names the elicitation, so
923
+ // classifying it as a tool ask would mislabel the card. Same shape the `Elicitation`
924
+ // hook produces.
671
925
  this.updateSessionStatus(event.sessionId, 'permission_requested', {
672
926
  ...eventContext,
673
927
  statusDetail: event.toolName,
928
+ pendingPermission: {
929
+ kind: 'elicitation',
930
+ toolName: event.toolName ?? 'Input Required',
931
+ toolDescription: event.toolName ?? 'Waiting for input',
932
+ resolvable: false,
933
+ },
674
934
  });
675
935
  break;
676
936
  default:
@@ -688,14 +948,37 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
688
948
  const toolDescription = describeToolInput(toolName, toolInput);
689
949
  const toolInputDescription = (toolInput.description as string | undefined) || undefined;
690
950
  const kind: PendingPermissionKind = classifyPermissionKind(toolName);
951
+ const toolUseId = hookInput?.tool_use_id as string | undefined;
952
+ const toolInputJson = stableStringify(toolInput);
691
953
 
692
954
  return new Promise<PermissionResponse>((resolve, reject) => {
693
955
  const existing = this._pendingPermissions.get(event.sessionId);
956
+ // Same identity as `settledByFromEvent`'s comparisons: `toolUseId` when both sides
957
+ // have one, else toolName + canonical input.
958
+ const isDuplicateDelivery =
959
+ existing != null &&
960
+ ((existing.toolUseId != null && toolUseId != null && existing.toolUseId === toolUseId) ||
961
+ (existing.toolUseId == null &&
962
+ toolUseId == null &&
963
+ existing.toolName === toolName &&
964
+ existing.toolInputJson === toolInputJson));
965
+
966
+ if (isDuplicateDelivery) {
967
+ // Duplicate delivery of the same ask (CLI retry after its 60s response-header
968
+ // timeout) — attach instead of replacing so both connections settle together.
969
+ // Nothing about the display changes, so skip rebuilding it below. Accepted
970
+ // residual: two independent, identical, concurrent asks are byte-for-byte
971
+ // indistinguishable on the wire and coalesce the same way, receiving one
972
+ // decision — evicting instead would falsely deny a live ask.
973
+ existing.resolvers.push({ resolve: resolve, reject: reject });
974
+ return;
975
+ }
976
+
694
977
  if (existing != null) {
695
978
  Logger.debug(
696
979
  `ClaudeCodeProvider.handleSessionEvent: auto-denying stale permission ${tag} tool=${existing.toolName}`,
697
980
  );
698
- existing.resolve({
981
+ this.resolveAllPending(existing, {
699
982
  hookSpecificOutput: {
700
983
  hookEventName: 'PermissionRequest',
701
984
  decision: { behavior: 'deny' },
@@ -704,10 +987,11 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
704
987
  }
705
988
 
706
989
  this._pendingPermissions.set(event.sessionId, {
707
- resolve: resolve,
708
- reject: reject,
990
+ resolvers: [{ resolve: resolve, reject: reject }],
709
991
  toolName: toolName,
710
992
  toolDescription: toolDescription,
993
+ toolUseId: toolUseId,
994
+ toolInputJson: toolInputJson,
711
995
  });
712
996
 
713
997
  // Plan-kind body: prefer the existing session's planFile (set via SessionStart/
@@ -732,19 +1016,50 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
732
1016
  questionText: questionDetails?.text,
733
1017
  questionCount: questionDetails?.count,
734
1018
  };
735
- this.updateSessionWithPermission(event.sessionId, permission, event.pid);
1019
+ this.updateSessionWithPermission(event.sessionId, permission, eventContext);
736
1020
  });
737
1021
  }
738
1022
 
739
- this.updateSessionStatus(event.sessionId, 'permission_requested', {
740
- ...eventContext,
741
- statusDetail: event.toolName,
742
- });
1023
+ {
1024
+ // Mirror the blocking payload's shape so the ask renders identically either way —
1025
+ // just unresolvable, since no blocking hook entry exists to route an answer through.
1026
+ const toolName = (hookInput?.tool_name as string | undefined) ?? event.toolName;
1027
+ const kind = toolName != null ? classifyPermissionKind(toolName) : undefined;
1028
+ // Plan-kind body: same session-first preference as the blocking path.
1029
+ const planFilePath =
1030
+ kind === 'plan'
1031
+ ? (this._sessions.find(s => s.id === event.sessionId)?.planFile ?? event.planFile)
1032
+ : undefined;
1033
+ // Same body extraction as the blocking path — surfaces render `questionText`/
1034
+ // `planSummary` in place of the tool description, so omitting them would show a
1035
+ // generic "awaiting" card for a question the input actually carries.
1036
+ const questionDetails =
1037
+ kind === 'question' && toolInput != null ? extractQuestionDetails(toolInput) : undefined;
1038
+ const synthesized: PendingPermission =
1039
+ kind != null && toolName != null && toolInput != null
1040
+ ? {
1041
+ kind: kind,
1042
+ toolName: toolName,
1043
+ toolDescription: describeToolInput(toolName, toolInput),
1044
+ toolInputDescription: (toolInput.description as string | undefined) || undefined,
1045
+ planFilePath: planFilePath,
1046
+ planSummary: kind === 'plan' ? extractPlanSummary(toolInput) : undefined,
1047
+ questionText: questionDetails?.text,
1048
+ questionCount: questionDetails?.count,
1049
+ resolvable: false,
1050
+ }
1051
+ : synthesizeUnresolvableAsk(toolName, planFilePath);
1052
+ this.updateSessionStatus(event.sessionId, 'permission_requested', {
1053
+ ...eventContext,
1054
+ statusDetail: event.toolName,
1055
+ pendingPermission: synthesized,
1056
+ });
1057
+ }
743
1058
  break;
744
1059
  }
745
1060
 
746
1061
  case 'PermissionDenied': {
747
- this.clearStalePermission(event.sessionId, 'PermissionDenied');
1062
+ this.clearStalePermission(event.sessionId, 'PermissionDenied', this.settledByFromEvent(event));
748
1063
  const bk = this.getBookkeeping(event.sessionId);
749
1064
  bk.activeToolCount = Math.max(0, bk.activeToolCount - 1);
750
1065
  const filesChanged = this.untrackToolUseFile(event.sessionId, event);
@@ -769,6 +1084,9 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
769
1084
  kind: 'elicitation',
770
1085
  toolName: event.toolName ?? 'Input Required',
771
1086
  toolDescription: event.toolName ?? 'Waiting for input',
1087
+ // `Elicitation` arrives on a non-blocking hook, so no entry exists for
1088
+ // `resolvePermission` to answer — the user responds in the agent's own session.
1089
+ resolvable: false,
772
1090
  };
773
1091
  this.updateSessionStatus(event.sessionId, 'permission_requested', {
774
1092
  ...eventContext,
@@ -790,16 +1108,15 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
790
1108
 
791
1109
  case 'CwdChanged':
792
1110
  if (event.cwd) {
793
- const index = this._sessions.findIndex(s => s.id === event.sessionId);
794
- if (index >= 0 && this._sessions[index].cwd !== event.cwd) {
795
- this._sessions[index] = { ...this._sessions[index], cwd: event.cwd };
1111
+ // Route through `ensureSession`: it owns the whole location transition — timeline
1112
+ // seat (or handing attribution back to the probe when the CLI didn't explain the
1113
+ // move), workspacePath/isInWorkspace recompute, the unresolvable-flag clear, and
1114
+ // the probe gating. Writing `cwd` directly here left `cliSeatedWorktree` set, so
1115
+ // the probe's answer for the new cwd could never replace the stale seated one.
1116
+ const { changed } = this.ensureSession(event.sessionId, eventContext);
1117
+ if (changed) {
796
1118
  this._onDidChangeSessions.fire();
797
1119
  }
798
- // Clear the unresolvable flag so the new cwd actually gets re-probed — without
799
- // this, a session that started in a non-git cwd would stay flagged forever even
800
- // after moving into a git repo.
801
- this.getBookkeeping(event.sessionId).gitInfoUnresolvable = false;
802
- void this.resolveGitInfo(event.sessionId, event.cwd);
803
1120
  }
804
1121
  break;
805
1122
 
@@ -918,6 +1235,8 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
918
1235
  bk.currentReadCounts.clear();
919
1236
  bk.lastTouchedAt.clear();
920
1237
  bk.gitInfoUnresolvable = false;
1238
+ // `cliSeatedWorktree` deliberately survives: SessionStart seats the worktree via
1239
+ // `ensureSession` BEFORE resetting bookkeeping, and an unexplained cwd move clears it anyway.
921
1240
  const parentId = this.findOwningParentId(sessionId);
922
1241
  if (parentId != null) {
923
1242
  this.syncSessionFileActivity(parentId);
@@ -1318,8 +1637,10 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
1318
1637
  this.cancelPendingIdleTransition(sessionId);
1319
1638
  const timer = setTimeout(() => {
1320
1639
  this._pendingIdleTimers.delete(sessionId);
1321
- // Session may have been removed (SessionEnd, prune) during the window.
1322
- if (this._sessions.findIndex(s => s.id === sessionId) < 0) return;
1640
+ // Session may have been removed (SessionEnd, prune) during the window, or completed by a
1641
+ // poll/SessionEnd that raced this timer reviving a terminal row to idle would zombie it.
1642
+ const idx = this._sessions.findIndex(s => s.id === sessionId);
1643
+ if (idx < 0 || this._sessions[idx].status === 'completed') return;
1323
1644
 
1324
1645
  this.updateSessionStatus(sessionId, 'idle');
1325
1646
  }, stopToIdleDebounceMs);
@@ -1358,22 +1679,107 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
1358
1679
  return now;
1359
1680
  }
1360
1681
 
1682
+ /** Extracts the ask identity a settlement event refers to. `PostToolUse`/`PermissionDenied`
1683
+ * carry `hookInput.tool_use_id` and `hookInput.tool_input` in production; the top-level
1684
+ * `event.toolName`/`event.toolInput` are the older-CLI fallback. Input is canonicalized so key
1685
+ * order (or arriving via the fallback field instead of `hookInput`) doesn't defeat comparison. */
1686
+ private settledByFromEvent(event: AgentSessionEvent): {
1687
+ toolUseId?: string;
1688
+ toolName?: string;
1689
+ toolInputJson?: string;
1690
+ } {
1691
+ const hookInput = event.hookInput;
1692
+ const toolInput = (hookInput?.tool_input as Record<string, unknown> | undefined) ?? event.toolInput;
1693
+ return {
1694
+ toolUseId: hookInput?.tool_use_id as string | undefined,
1695
+ toolName: (hookInput?.tool_name as string | undefined) ?? event.toolName,
1696
+ toolInputJson: toolInput != null ? stableStringify(toolInput) : undefined,
1697
+ };
1698
+ }
1699
+
1700
+ private resolveAllPending(entry: PendingPermissionEntry, response: PermissionResponse): void {
1701
+ for (const resolver of entry.resolvers) {
1702
+ resolver.resolve(response);
1703
+ }
1704
+ }
1705
+
1706
+ private rejectAllPending(entry: PendingPermissionEntry, reason: Error): void {
1707
+ for (const resolver of entry.resolvers) {
1708
+ resolver.reject(reason);
1709
+ }
1710
+ }
1711
+
1361
1712
  // Called when a pending permission was resolved outside GitLens (e.g. via the CLI terminal).
1362
1713
  // The 'deny' response is a safe no-op since the tool has already run or been denied upstream.
1363
- private clearStalePermission(sessionId: string, reason: string): void {
1714
+ // `settledBy` identifies the ask the settling event actually refers to — out-of-order/duplicate
1715
+ // hook deliveries (PostToolUse for an unrelated tool, a PermissionDenied for an already-evicted
1716
+ // earlier ask) must never deny a still-live blocking ask just because it shares a session id.
1717
+ // `PermissionDenied` itself only ever fires after Claude Code's auto-mode classifier denies a
1718
+ // tool call — never as an echo of a deny a `PermissionRequest` response sent.
1719
+ private clearStalePermission(
1720
+ sessionId: string,
1721
+ reason: string,
1722
+ settledBy?: { toolUseId?: string; toolName?: string; toolInputJson?: string },
1723
+ ): void {
1364
1724
  const bk = this.getBookkeeping(sessionId);
1365
- if (bk.pendingPermission == null) return;
1366
-
1367
- Logger.debug(`ClaudeCodeProvider.clearStalePermission: ${reason} ${this.sessionTag(sessionId)}`);
1368
-
1369
1725
  const pending = this._pendingPermissions.get(sessionId);
1726
+
1370
1727
  if (pending != null) {
1371
- pending.resolve({
1728
+ if (settledBy != null) {
1729
+ let mismatch = false;
1730
+
1731
+ if (pending.toolUseId != null && settledBy.toolUseId != null) {
1732
+ // Rule 1: both sides identified — the only trustworthy comparison. `PermissionDenied`/
1733
+ // `PostToolUse` hookInput DOES carry `tool_use_id`, but `PermissionRequest` never does,
1734
+ // so `pending` never actually has one — this branch only engages if a future CLI adds
1735
+ // it to requests too. The asymmetry is deliberate, not a gap to close.
1736
+ mismatch = pending.toolUseId !== settledBy.toolUseId;
1737
+ } else if (pending.toolName === '') {
1738
+ // Rule 2: no identity was captured at registration (degraded payload) — nothing to
1739
+ // gate on, so clear unconditionally rather than stranding the ask forever.
1740
+ mismatch = false;
1741
+ } else {
1742
+ // Rule 3: name must match; when both sides have input, it must match too. A
1743
+ // PostToolUse for a different invocation of the same tool (different input) leaves
1744
+ // the ask untouched.
1745
+ mismatch =
1746
+ settledBy.toolName !== pending.toolName ||
1747
+ (pending.toolInputJson != null &&
1748
+ settledBy.toolInputJson != null &&
1749
+ pending.toolInputJson !== settledBy.toolInputJson);
1750
+ }
1751
+
1752
+ if (mismatch) {
1753
+ Logger.debug(
1754
+ `ClaudeCodeProvider.clearStalePermission: skip (identity mismatch) ${reason} ${this.sessionTag(sessionId)} pending=${pending.toolUseId ?? pending.toolName} settledBy=${settledBy.toolUseId ?? settledBy.toolName ?? 'unknown'}`,
1755
+ );
1756
+ return;
1757
+ }
1758
+ }
1759
+
1760
+ Logger.debug(`ClaudeCodeProvider.clearStalePermission: ${reason} ${this.sessionTag(sessionId)}`);
1761
+ this.resolveAllPending(pending, {
1372
1762
  hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: { behavior: 'deny' } },
1373
1763
  });
1374
1764
  this._pendingPermissions.delete(sessionId);
1765
+ bk.pendingPermission = undefined;
1766
+ return;
1375
1767
  }
1376
1768
 
1769
+ if (bk.pendingPermission == null) return;
1770
+
1771
+ if (
1772
+ settledBy?.toolName != null &&
1773
+ bk.pendingPermission.toolName != null &&
1774
+ settledBy.toolName !== bk.pendingPermission.toolName
1775
+ ) {
1776
+ Logger.debug(
1777
+ `ClaudeCodeProvider.clearStalePermission: skip (display-only identity mismatch) ${reason} ${this.sessionTag(sessionId)} pending=${bk.pendingPermission.toolName} settledBy=${settledBy.toolName}`,
1778
+ );
1779
+ return;
1780
+ }
1781
+
1782
+ Logger.debug(`ClaudeCodeProvider.clearStalePermission: ${reason} ${this.sessionTag(sessionId)}`);
1377
1783
  bk.pendingPermission = undefined;
1378
1784
  }
1379
1785
 
@@ -1388,7 +1794,7 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
1388
1794
  Logger.debug(
1389
1795
  `ClaudeCodeProvider.resolvePermission: ${decision} ${this.sessionTag(sessionId)} tool=${pending.toolName}`,
1390
1796
  );
1391
- pending.resolve({
1797
+ this.resolveAllPending(pending, {
1392
1798
  hookSpecificOutput: {
1393
1799
  hookEventName: 'PermissionRequest',
1394
1800
  decision: { behavior: decision, updatedPermissions: updatedPermissions },
@@ -1409,6 +1815,138 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
1409
1815
  return true;
1410
1816
  }
1411
1817
 
1818
+ async archiveSession(sessionId: string): Promise<boolean> {
1819
+ // Authoritative status guard: the CLI's `archive-session` ends an active session first, so a
1820
+ // stale webview action (or a row that resumed out of `completed` since the click) must never
1821
+ // reach the CLI — archiving is only ever valid on a terminal row. Callers gate the UI too,
1822
+ // but this is the last line that owns live process safety. Returns `false` when refused so the
1823
+ // host doesn't record a success telemetry event for an archive that never happened.
1824
+ const session = this._sessions.find(s => s.id === sessionId);
1825
+ if (session != null && session.status !== 'completed') {
1826
+ Logger.warn(
1827
+ `ClaudeCodeProvider.archiveSession: refusing to archive non-completed ${this.sessionTag(sessionId)} (status=${session.status})`,
1828
+ );
1829
+ return false;
1830
+ }
1831
+
1832
+ Logger.debug(`ClaudeCodeProvider.archiveSession: ${this.sessionTag(sessionId)}`);
1833
+ await this.callbacks.runCLICommand(['ai', 'hook', 'archive-session', sessionId, '--json']);
1834
+ // Invalidate the archived-id cache so the next `getPastSessions` re-fetches and excludes this id.
1835
+ this._archivedSessionIdsCache = undefined;
1836
+ // Optimistically drop it — archived sessions are excluded from the `active,ended` poll, so the
1837
+ // next reconciliation confirms the removal regardless.
1838
+ const index = this._sessions.findIndex(s => s.id === sessionId);
1839
+ if (index >= 0) {
1840
+ this._sessions.splice(index, 1);
1841
+ this._sessionBookkeeping.delete(sessionId);
1842
+ this._transcriptReader.forget(sessionId);
1843
+ this._onDidChangeSessions.fire();
1844
+ }
1845
+ return true;
1846
+ }
1847
+
1848
+ /** Lists the ids of sessions the CLI has archived, so callers (the "Past" transcript listing) can
1849
+ * exclude them — the tracked row is gone, but the transcript on disk survives and would otherwise
1850
+ * resurface there. Resolves to `[]` on any error, including a CLI too old to support the
1851
+ * `archived` status filter. */
1852
+ async getArchivedSessionIds(): Promise<string[]> {
1853
+ const cached = this._archivedSessionIdsCache;
1854
+ if (cached != null && Date.now() - cached.at < archivedSessionIdsCacheTtlMs) {
1855
+ return cached.promise;
1856
+ }
1857
+
1858
+ const promise = this.fetchArchivedSessionIds();
1859
+ this._archivedSessionIdsCache = { promise: promise, at: Date.now() };
1860
+ return promise;
1861
+ }
1862
+
1863
+ private async fetchArchivedSessionIds(): Promise<string[]> {
1864
+ try {
1865
+ const output = await this.callbacks.runCLICommand([
1866
+ 'ai',
1867
+ 'hook',
1868
+ 'list-sessions',
1869
+ '--status',
1870
+ 'archived',
1871
+ '--json',
1872
+ ]);
1873
+ const sessions = JSON.parse(output) as SessionFileData[];
1874
+ return sessions.map(s => s.sessionId).filter(id => id.length > 0);
1875
+ } catch {
1876
+ return [];
1877
+ }
1878
+ }
1879
+
1880
+ resolveCompletedSessionDetails(sessionId: string): void {
1881
+ const session = this._sessions.find(s => s.id === sessionId);
1882
+ // Only completed sessions defer resolution; live sessions resolve eagerly on discovery.
1883
+ if (session?.status !== 'completed') return;
1884
+
1885
+ // The durable store drops the prompt (and only ever carries an auto-slug name) for most ended
1886
+ // sessions, so read the terminal transcript once on demand to recover a real title + the
1887
+ // first/last prompt. Both the git probe and transcript read sit under the guard so repeated
1888
+ // opens don't re-probe git or re-read a possibly-large file.
1889
+ const bk = this.getBookkeeping(sessionId);
1890
+ if (bk.completedDetailsResolved) return;
1891
+
1892
+ bk.completedDetailsResolved = true;
1893
+
1894
+ if (session.cwd) {
1895
+ void this.resolveGitInfo(sessionId, session.cwd);
1896
+ }
1897
+ void this.applyCompletedTranscriptDetails(sessionId, session.cwd);
1898
+ }
1899
+
1900
+ private async applyCompletedTranscriptDetails(sessionId: string, cwd: string | undefined): Promise<void> {
1901
+ let details;
1902
+ try {
1903
+ details = await this._transcriptReader.resolveCompletedDetails(sessionId, cwd);
1904
+ } catch {
1905
+ // A read error (transient I/O, permissions) is not terminal state — clear the once-only
1906
+ // guard so a later open retries. A `undefined` result IS terminal (no transcript on disk),
1907
+ // so it keeps the guard and never re-scans.
1908
+ const bk = this._sessionBookkeeping.get(sessionId);
1909
+ if (bk != null) {
1910
+ bk.completedDetailsResolved = false;
1911
+ }
1912
+ return;
1913
+ }
1914
+ if (details == null) return;
1915
+
1916
+ const index = this._sessions.findIndex(s => s.id === sessionId);
1917
+ if (index < 0) return;
1918
+
1919
+ const session = this._sessions[index];
1920
+ // The session may have been archived/removed while we were reading.
1921
+ if (session.status !== 'completed') return;
1922
+
1923
+ const { titles } = details;
1924
+ const nextTitles =
1925
+ titles.custom != null || titles.ai != null || titles.agent != null
1926
+ ? { custom: titles.custom, ai: titles.ai, agent: titles.agent }
1927
+ : session.transcriptTitles;
1928
+ // Durable-store prompts (full text) win over the transcript's (CLI-truncated) copy; only
1929
+ // backfill from the transcript when the field is empty.
1930
+ const nextFirstPrompt = session.firstPrompt ?? prepareStoredPrompt(details.firstPrompt);
1931
+ const nextLastPrompt = session.lastPrompt ?? prepareStoredPrompt(details.lastPrompt);
1932
+
1933
+ const titlesChanged =
1934
+ nextTitles?.custom !== session.transcriptTitles?.custom ||
1935
+ nextTitles?.ai !== session.transcriptTitles?.ai ||
1936
+ nextTitles?.agent !== session.transcriptTitles?.agent;
1937
+ if (!titlesChanged && nextFirstPrompt === session.firstPrompt && nextLastPrompt === session.lastPrompt) {
1938
+ return;
1939
+ }
1940
+
1941
+ this._sessions[index] = {
1942
+ ...session,
1943
+ transcriptTitles: nextTitles,
1944
+ firstPrompt: nextFirstPrompt,
1945
+ lastPrompt: nextLastPrompt,
1946
+ };
1947
+ this._onDidChangeSessions.fire();
1948
+ }
1949
+
1412
1950
  private matchesWorkspace(workspacePath: string | undefined): boolean {
1413
1951
  if (!workspacePath) return false;
1414
1952
 
@@ -1430,12 +1968,18 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
1430
1968
  );
1431
1969
  }
1432
1970
 
1433
- private updateSessionWithPermission(sessionId: string, permission: PendingPermission, pid?: number): void {
1971
+ private updateSessionWithPermission(
1972
+ sessionId: string,
1973
+ permission: PendingPermission,
1974
+ context?: SessionContext,
1975
+ ): void {
1434
1976
  // A pending permission is a working/waiting transition — cancel any deferred Stop → idle
1435
1977
  // commit so the session doesn't briefly flip to idle before showing the prompt.
1436
1978
  this.cancelPendingIdleTransition(sessionId);
1437
1979
 
1438
- const { index } = this.ensureSession(sessionId, { pid: pid });
1980
+ // The full event context, not just the pid: a blocking ask can be the first event of a
1981
+ // session, and a row created here with no location would sit unattributed for the whole block.
1982
+ const { index } = this.ensureSession(sessionId, context);
1439
1983
 
1440
1984
  const prev = this._sessions[index];
1441
1985
  const newPhase = getPhaseForStatus('permission_requested');
@@ -1455,7 +1999,7 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
1455
1999
  private updateSessionStatus(
1456
2000
  sessionId: string,
1457
2001
  status: AgentSessionStatus,
1458
- options?: SessionContext & { statusDetail?: string },
2002
+ options?: SessionContext & { statusDetail?: string; pendingPermission?: PendingPermission },
1459
2003
  ): void {
1460
2004
  // Any non-idle status update implicitly cancels a deferred Stop → idle commit. The
1461
2005
  // timer is owned here so the schedule/cancel pair is uniformly enforced regardless of
@@ -1482,7 +2026,15 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
1482
2026
  }
1483
2027
 
1484
2028
  const statusDetail = options?.statusDetail;
1485
- if (prev.status === status && prev.statusDetail === statusDetail) {
2029
+ // Resolved before the short-circuit: a second ask can arrive while the row already sits at
2030
+ // `permission_requested`, and comparing only status+detail would publish the first ask forever.
2031
+ const nextPendingPermission =
2032
+ status === 'permission_requested' ? (bk.pendingPermission ?? options?.pendingPermission) : undefined;
2033
+ if (
2034
+ prev.status === status &&
2035
+ prev.statusDetail === statusDetail &&
2036
+ isSameAsk(prev.pendingPermission, nextPendingPermission)
2037
+ ) {
1486
2038
  if (metadataChanged) {
1487
2039
  this._onDidChangeSessions.fire();
1488
2040
  }
@@ -1496,7 +2048,7 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
1496
2048
  phase: newPhase,
1497
2049
  phaseSince: this.resolvePhaseSince(sessionId, prev.phase, prev.phaseSince, newPhase),
1498
2050
  statusDetail: statusDetail,
1499
- pendingPermission: status === 'permission_requested' ? bk.pendingPermission : undefined,
2051
+ pendingPermission: nextPendingPermission,
1500
2052
  lastActivity: new Date(),
1501
2053
  };
1502
2054
  this._onDidChangeSessions.fire();
@@ -1517,11 +2069,15 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
1517
2069
  }
1518
2070
 
1519
2071
  private ensureSession(sessionId: string, context?: SessionContext): { index: number; changed: boolean } {
1520
- const { pid, workspacePath, isInWorkspace, cwd, initialCwd, planFile, sessionName } = context ?? {};
2072
+ const { pid, workspacePath, isInWorkspace, cwd, initialCwd, planFile, sessionName, model, worktreePath } =
2073
+ context ?? {};
1521
2074
 
1522
2075
  let index = this._sessions.findIndex(s => s.id === sessionId);
1523
2076
  if (index < 0) {
1524
2077
  const now = new Date();
2078
+ // Same worktree-derived fallback as the update branch below: a session created with a
2079
+ // scratch cwd but a CLI-seated worktree still belongs to that worktree's workspace.
2080
+ const createdWorkspacePath = workspacePath ?? this.resolveWorkspacePath(worktreePath);
1525
2081
  index = this._sessions.length;
1526
2082
  this._sessions.push({
1527
2083
  id: sessionId,
@@ -1534,15 +2090,20 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
1534
2090
  pid: pid,
1535
2091
  lastActivity: now,
1536
2092
  isSubagent: false,
1537
- workspacePath: workspacePath,
2093
+ workspacePath: createdWorkspacePath,
1538
2094
  cwd: cwd,
1539
2095
  initialCwd: initialCwd ?? cwd,
1540
2096
  planFile: planFile,
1541
- isInWorkspace: isInWorkspace ?? false,
2097
+ isInWorkspace: createdWorkspacePath != null ? true : (isInWorkspace ?? false),
2098
+ model: model,
2099
+ worktreePath: worktreePath != null ? normalizePath(worktreePath) : undefined,
1542
2100
  });
1543
2101
  Logger.debug(
1544
2102
  `ClaudeCodeProvider.ensureSession: implicitly created ${this.sessionTag(sessionId, sessionName ?? 'unnamed')}`,
1545
2103
  );
2104
+ if (worktreePath != null) {
2105
+ this.getBookkeeping(sessionId).cliSeatedWorktree = true;
2106
+ }
1546
2107
  this._onDidChangeSessions.fire();
1547
2108
 
1548
2109
  if (cwd != null) {
@@ -1554,10 +2115,9 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
1554
2115
 
1555
2116
  const existing = this._sessions[index];
1556
2117
  const updatedPid = pid != null && existing.pid == null ? pid : existing.pid;
1557
- const updatedWorkspacePath = workspacePath || existing.workspacePath;
1558
- const updatedIsInWorkspace = workspacePath ? (isInWorkspace ?? existing.isInWorkspace) : existing.isInWorkspace;
1559
2118
  const updatedPlanFile = planFile ?? existing.planFile;
1560
2119
  const updatedName = sessionName || existing.name;
2120
+ const updatedModel = model || existing.model;
1561
2121
  const updatedCwd = cwd ?? existing.cwd;
1562
2122
  // Prefer the CLI's authoritative `initialCwd` (the true launch dir it captured first-hand,
1563
2123
  // even for sessions that started before this window opened); otherwise keep whatever we
@@ -1565,6 +2125,32 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
1565
2125
  // don't send `initialCwd`, or a cold-create before any cwd was known). Comparing live `cwd`
1566
2126
  // against `initialCwd` is how consumers detect launch-vs-current drift, so it stays stable.
1567
2127
  const updatedInitialCwd = initialCwd ?? existing.initialCwd ?? cwd;
2128
+ // The CLI resolves this at hook time and sends it on every event — authoritative when
2129
+ // present, so it seats ahead of the `resolveGitInfo` probe below (which still fills
2130
+ // `commonPath` and remains the fallback for older CLIs that don't send `cwdTimeline`).
2131
+ const updatedWorktreePath = worktreePath != null ? normalizePath(worktreePath) : existing.worktreePath;
2132
+ // Workspace membership follows the ATTRIBUTION, not the raw cwd: a scratch-dir excursion
2133
+ // (cwd in /tmp, worktree kept) stays in-workspace via its worktree, while a genuine move to
2134
+ // a repo outside the workspace honestly drops membership when events carry a cwd.
2135
+ const updatedWorkspacePath =
2136
+ workspacePath ??
2137
+ this.resolveWorkspacePath(updatedWorktreePath) ??
2138
+ (cwd == null ? existing.workspacePath : undefined);
2139
+ const updatedIsInWorkspace = cwd == null ? existing.isInWorkspace : updatedWorkspacePath != null;
2140
+
2141
+ const cwdMoved = cwd != null && updatedCwd !== existing.cwd;
2142
+ const worktreeMoved =
2143
+ updatedWorktreePath != null &&
2144
+ existing.worktreePath != null &&
2145
+ updatedWorktreePath !== existing.worktreePath;
2146
+
2147
+ // The CLI explaining the location owns attribution; a move it didn't explain hands it back to
2148
+ // the probe.
2149
+ if (worktreePath != null) {
2150
+ this.getBookkeeping(sessionId).cliSeatedWorktree = true;
2151
+ } else if (cwdMoved) {
2152
+ this.getBookkeeping(sessionId).cliSeatedWorktree = false;
2153
+ }
1568
2154
 
1569
2155
  let changed = false;
1570
2156
  if (
@@ -1573,18 +2159,27 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
1573
2159
  updatedIsInWorkspace !== existing.isInWorkspace ||
1574
2160
  updatedPlanFile !== existing.planFile ||
1575
2161
  updatedName !== existing.name ||
2162
+ updatedModel !== existing.model ||
1576
2163
  updatedCwd !== existing.cwd ||
1577
- updatedInitialCwd !== existing.initialCwd
2164
+ updatedInitialCwd !== existing.initialCwd ||
2165
+ updatedWorktreePath !== existing.worktreePath
1578
2166
  ) {
1579
2167
  this._sessions[index] = {
1580
2168
  ...existing,
1581
2169
  name: updatedName,
2170
+ model: updatedModel,
1582
2171
  pid: updatedPid,
1583
2172
  workspacePath: updatedWorkspacePath,
1584
2173
  cwd: updatedCwd,
1585
2174
  initialCwd: updatedInitialCwd,
1586
2175
  planFile: updatedPlanFile,
1587
2176
  isInWorkspace: updatedIsInWorkspace,
2177
+ worktreePath: updatedWorktreePath,
2178
+ // A worktree move can cross repos, and the spread would otherwise carry the old repo
2179
+ // identity forward — which consumers prefer over the new worktree, pinning the card to
2180
+ // the wrong repo. Drop it; the probe below refills it. Mirrors the poll's ended-record
2181
+ // re-seat.
2182
+ commonPath: worktreeMoved ? undefined : existing.commonPath,
1588
2183
  };
1589
2184
  changed = true;
1590
2185
  }
@@ -1598,13 +2193,30 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
1598
2193
  // `resolveGitInfo` confirms the cwd isn't a git repo, preventing a retry storm on every
1599
2194
  // hook event for non-git-repo cwds. The flag is cleared on cwd change (`CwdChanged`) and
1600
2195
  // on `resetBookkeeping` (Stop/SessionStart) so re-resolution can happen when warranted.
2196
+ // A cwd move re-resolves too. `worktreePath`/`commonPath` are resolved from whichever cwd was
2197
+ // seen first, so an agent that moves between worktrees would otherwise stay pinned to its
2198
+ // launch repo — attaching to the wrong WIP row and leaving its real worktree showing no
2199
+ // agents at all. `CwdChanged` covers the move the CLI announces; this covers an ordinary
2200
+ // event that simply arrives carrying a new cwd.
2201
+ //
2202
+ // A cwd the CLI already attributed to the same worktree needs no probe at all — that's the
2203
+ // common case (an agent `cd`-ing around inside its own tree), and probing it would spawn a git
2204
+ // call per hook event for an answer we already have. A first seat (no prior worktree) still
2205
+ // counts as a location change: the move may be out of a non-git dir whose probe latched
2206
+ // `gitInfoUnresolvable`, and `commonPath` still needs the probe.
2207
+ const locationNeedsProbe = cwdMoved && (worktreePath == null || worktreeMoved || existing.worktreePath == null);
2208
+ if (locationNeedsProbe) {
2209
+ this.getBookkeeping(sessionId).gitInfoUnresolvable = false;
2210
+ }
2211
+
1601
2212
  if (
1602
2213
  cwd != null &&
1603
- (existing.worktreePath == null || existing.commonPath == null) &&
2214
+ (locationNeedsProbe || existing.worktreePath == null || existing.commonPath == null) &&
1604
2215
  !this.getBookkeeping(sessionId).gitInfoUnresolvable
1605
2216
  ) {
1606
2217
  void this.resolveGitInfo(sessionId, cwd);
1607
2218
  }
2219
+
1608
2220
  return { index: index, changed: changed };
1609
2221
  }
1610
2222
 
@@ -1618,9 +2230,20 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
1618
2230
  // would spawn a fresh git probe per hook event. The `gitInfoUnresolvable` bookkeeping flag
1619
2231
  // (set below when `info == null`) is the long-lived suppressor across hook events; this
1620
2232
  // in-flight set only dedupes concurrent overlapping probes within a single resolution.
1621
- if (this._resolveGitInfoInFlight.has(sessionId)) return;
2233
+ //
2234
+ // Keyed by cwd as well: a probe for the SAME cwd is genuinely redundant, but one for a
2235
+ // different cwd (a missed `CwdChanged` corrected off the durable record, say) must not be
2236
+ // swallowed — the in-flight answer describes the old directory. Record it as pending so the
2237
+ // running probe hands off to it, and discard the stale answer below.
2238
+ const inFlightCwd = this._resolveGitInfoInFlight.get(sessionId);
2239
+ if (inFlightCwd != null) {
2240
+ if (inFlightCwd !== cwd) {
2241
+ this._resolveGitInfoPending.set(sessionId, cwd);
2242
+ }
2243
+ return;
2244
+ }
1622
2245
 
1623
- this._resolveGitInfoInFlight.add(sessionId);
2246
+ this._resolveGitInfoInFlight.set(sessionId, cwd);
1624
2247
  try {
1625
2248
  let info: Awaited<ReturnType<typeof resolveGitInfo>> | undefined;
1626
2249
  try {
@@ -1633,6 +2256,11 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
1633
2256
  const index = this._sessions.findIndex(s => s.id === sessionId);
1634
2257
  if (index < 0) return;
1635
2258
 
2259
+ // Superseded while we were probing: this answer describes a directory the session has
2260
+ // already left, so applying it would undo the newer location. Drop it — the `finally`
2261
+ // below re-runs for the newest cwd.
2262
+ if (this._resolveGitInfoPending.has(sessionId)) return;
2263
+
1636
2264
  const session = this._sessions[index];
1637
2265
 
1638
2266
  // `workspacePath` stays as the matched workspace folder (or undefined). Repo identity
@@ -1657,18 +2285,26 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
1657
2285
  return;
1658
2286
  }
1659
2287
 
2288
+ // A CLI-seated worktree wins over the probe's answer (see `cliSeatedWorktree`): the CLI
2289
+ // resolved it for this exact cwd, while the probe can name the parent worktree for a nested
2290
+ // one. `commonPath` always comes from the probe — the CLI doesn't resolve repo identity.
2291
+ const effectiveWorktreePath =
2292
+ this.getBookkeeping(sessionId).cliSeatedWorktree && session.worktreePath != null
2293
+ ? session.worktreePath
2294
+ : info.worktreePath;
2295
+
1660
2296
  // Capture the worktree/commonPath at the first successful resolve so consumers can
1661
2297
  // detect drift (e.g. an agent that `cd`'d into a sibling worktree after launch). Gated
1662
2298
  // on `initialCommonPath` — captured together with `initialWorktreePath` so a first
1663
2299
  // resolve where `info.worktreePath` happens to be undefined doesn't leave the latter
1664
2300
  // open to a later overwrite. Once set, never overwritten.
1665
2301
  const firstResolve = session.initialCommonPath == null;
1666
- const updatedInitialWorktreePath = firstResolve ? info.worktreePath : session.initialWorktreePath;
2302
+ const updatedInitialWorktreePath = firstResolve ? effectiveWorktreePath : session.initialWorktreePath;
1667
2303
  const updatedInitialCommonPath = firstResolve ? info.repoRoot : session.initialCommonPath;
1668
2304
 
1669
2305
  if (
1670
2306
  cwd !== session.cwd ||
1671
- info.worktreePath !== session.worktreePath ||
2307
+ effectiveWorktreePath !== session.worktreePath ||
1672
2308
  info.repoRoot !== session.commonPath ||
1673
2309
  updatedInitialWorktreePath !== session.initialWorktreePath ||
1674
2310
  updatedInitialCommonPath !== session.initialCommonPath
@@ -1676,7 +2312,7 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
1676
2312
  this._sessions[index] = {
1677
2313
  ...session,
1678
2314
  cwd: cwd,
1679
- worktreePath: info.worktreePath,
2315
+ worktreePath: effectiveWorktreePath,
1680
2316
  commonPath: info.repoRoot,
1681
2317
  initialWorktreePath: updatedInitialWorktreePath,
1682
2318
  initialCommonPath: updatedInitialCommonPath,
@@ -1685,6 +2321,14 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
1685
2321
  }
1686
2322
  } finally {
1687
2323
  this._resolveGitInfoInFlight.delete(sessionId);
2324
+
2325
+ // Hand off to the newest cwd requested while this probe ran, so a move that arrived
2326
+ // mid-flight still resolves instead of being dropped by the dedupe.
2327
+ const pendingCwd = this._resolveGitInfoPending.get(sessionId);
2328
+ if (pendingCwd != null) {
2329
+ this._resolveGitInfoPending.delete(sessionId);
2330
+ void this.resolveGitInfo(sessionId, pendingCwd);
2331
+ }
1688
2332
  }
1689
2333
  }
1690
2334
 
@@ -1711,10 +2355,58 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
1711
2355
  this._onDidChangeSessions.fire();
1712
2356
  }
1713
2357
 
2358
+ /** Transitions the session at `index` to the terminal `completed` state in place: keeps the row,
2359
+ * stamps `endedAt` as its activity time, clears transient state (statusDetail, pendingPermission,
2360
+ * subagents), and tears down its bookkeeping/timers. Shared by the live `SessionEnd` path and the
2361
+ * poll backstop for a `SessionEnd` the live path missed. Does not fire change events — the caller
2362
+ * batches those. */
2363
+ private completeSession(index: number, endedAt: Date): void {
2364
+ const prev = this._sessions[index];
2365
+ this._sessions[index] = {
2366
+ ...prev,
2367
+ status: 'completed',
2368
+ phase: 'completed',
2369
+ phaseSince: endedAt,
2370
+ lastActivity: endedAt,
2371
+ statusDetail: undefined,
2372
+ pendingPermission: undefined,
2373
+ subagents: undefined,
2374
+ // A terminal row carries no live working state — drop the file-activity heatmap so
2375
+ // consumers reading it directly don't render a frozen tail for a finished session.
2376
+ fileActivity: undefined,
2377
+ };
2378
+ // Terminal teardown, owned here so every caller gets it — the live `SessionEnd` path does
2379
+ // this before calling us, but the poll's missed-SessionEnd transition doesn't: reject any
2380
+ // in-flight permission promise (otherwise a blocking request leaks until dispose) and cancel a
2381
+ // pending Stop→idle timer (otherwise it fires inside its window and revives this row to a
2382
+ // live zombie).
2383
+ const pending = this._pendingPermissions.get(prev.id);
2384
+ if (pending != null) {
2385
+ this.rejectAllPending(pending, new Error('Session completed'));
2386
+ this._pendingPermissions.delete(prev.id);
2387
+ }
2388
+ this.cancelPendingIdleTransition(prev.id);
2389
+
2390
+ const bk = this._sessionBookkeeping.get(prev.id);
2391
+ if (bk != null) {
2392
+ this.cancelPendingFileClears(bk);
2393
+ this.cancelPendingReadClears(bk);
2394
+ }
2395
+ this._sessionBookkeeping.delete(prev.id);
2396
+ this._transcriptReader.forget(prev.id);
2397
+ }
2398
+
1714
2399
  private pruneDeadSessions(): boolean {
1715
2400
  const kept: AgentSession[] = [];
1716
2401
  const removedIds: string[] = [];
1717
2402
  for (const s of this._sessions) {
2403
+ // Completed sessions are terminal and durable — their PID is usually dead by design, so
2404
+ // liveness pruning must never drop them (they leave only via poll reconciliation).
2405
+ if (s.status === 'completed') {
2406
+ kept.push(s);
2407
+ continue;
2408
+ }
2409
+
1718
2410
  // A session blocking on us for a permission decision is by definition alive,
1719
2411
  // even if `kill(pid, 0)` says otherwise (e.g. transient EPERM/ESRCH). Dropping
1720
2412
  // it here loses pendingPermission and lastPrompt; the next syncSessions then
@@ -1731,7 +2423,7 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
1731
2423
  for (const id of removedIds) {
1732
2424
  const pending = this._pendingPermissions.get(id);
1733
2425
  if (pending != null) {
1734
- pending.reject(new Error('Session pruned'));
2426
+ this.rejectAllPending(pending, new Error('Session pruned'));
1735
2427
  this._pendingPermissions.delete(id);
1736
2428
  }
1737
2429
  this.cancelPendingIdleTransition(id);
@@ -1759,11 +2451,60 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
1759
2451
  // sessions exist — that's the only local backstop for pruning agents that die without firing
1760
2452
  // `SessionEnd`, and it keeps us correct even if hook detection is stale/wrong. The bootstrap
1761
2453
  // call in `ensureIpcServer` passes no options, so cold-start discovery always runs.
2454
+ // Deliberately counts ALL tracked sessions, completed included. A completed row is something
2455
+ // we have to reconcile — it's removed only when the CLI stops listing it (archived from
2456
+ // another window, or aged out) — and this poll is the sole mechanism that does so. It's also
2457
+ // what triggers the CLI's own 30-day retention sweep, which runs on `list-sessions` and
2458
+ // nothing else: no timer, no daemon. Gating it out to save a spawn wouldn't avoid work, it
2459
+ // would strand the rows on screen AND the records on disk until the window reloads.
1762
2460
  if (options?.gate && this._sessions.length === 0 && !this._claudeHooksInstalled) return;
1763
2461
 
2462
+ // Narrowly: hooks off AND nothing running, i.e. we hold only terminal history. There's no
2463
+ // dying process to reap and no live state to correct — just rows to reconcile and the
2464
+ // retention sweep to trigger, neither of which needs quarter-hourly resolution. Back the
2465
+ // cadence off rather than stopping (window startup polls ungated, so a reload is current).
2466
+ // Hooks ON keeps the full cadence deliberately: there the tick is also the backstop for a
2467
+ // session whose hook events never reached our IPC server.
2468
+ if (
2469
+ options?.gate &&
2470
+ !this._claudeHooksInstalled &&
2471
+ !this._sessions.some(s => s.status !== 'completed') &&
2472
+ Date.now() - this._lastSyncAt < idleReconcileIntervalMs
2473
+ ) {
2474
+ return;
2475
+ }
2476
+
2477
+ // Stamped BEFORE the CLI call so the reconciliation below can tell "this poll's snapshot was
2478
+ // taken after we completed the row" from "an in-flight poll that predates it" — see the
2479
+ // completed-removal filter.
2480
+ const pollStartedAt = Date.now();
2481
+ this._lastSyncAt = pollStartedAt;
2482
+
1764
2483
  let sessions: SessionFileData[];
1765
2484
  try {
1766
- const output = await this.callbacks.runCLICommand(['ai', 'hook', 'list-sessions', '--json']);
2485
+ let output: string;
2486
+ try {
2487
+ // `--status active,ended` surfaces both live sessions and the CLI's durable ended records
2488
+ // (excluding `archived`). Newer CLIs default `list-sessions` to active-only, so the flag
2489
+ // is required to see completed sessions at all.
2490
+ output = await this.callbacks.runCLICommand([
2491
+ 'ai',
2492
+ 'hook',
2493
+ 'list-sessions',
2494
+ '--status',
2495
+ 'active,ended',
2496
+ '--json',
2497
+ ]);
2498
+ this._statusFilterSupported = true;
2499
+ } catch (ex) {
2500
+ // Only retry on an unknown-flag error — a pre-3.1.69 CLI rejects `--status` but returns
2501
+ // all sessions unfiltered, so the flagless call is the correct legacy equivalent. Real
2502
+ // failures (CLI missing, spawn error) rethrow to the prune-and-return handler below.
2503
+ if (!isUnknownFlagError(ex)) throw ex;
2504
+
2505
+ this._statusFilterSupported = false;
2506
+ output = await this.callbacks.runCLICommand(['ai', 'hook', 'list-sessions', '--json']);
2507
+ }
1767
2508
  sessions = JSON.parse(output) as SessionFileData[];
1768
2509
  } catch {
1769
2510
  if (this.pruneDeadSessions()) {
@@ -1775,24 +2516,255 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
1775
2516
  // Snapshot what the live IPC path had already tracked, so we can detect drift between it and
1776
2517
  // what the poll returns. Ideally they match exactly — any difference means the live push path
1777
2518
  // missed an add (poll discovers it) or a teardown (poll no longer reports a session we track).
1778
- const trackedBefore = new Set(this._sessions.map(s => s.id));
2519
+ // Drift is measured over *live* sessions only; completed sessions are durable history, so their
2520
+ // absence from `polledAlive` is expected and must not skew the discrepancy signal.
2521
+ const trackedLiveBefore = new Set(this._sessions.filter(s => s.status !== 'completed').map(s => s.id));
1779
2522
  const polledAlive = new Set<string>();
1780
- // Mutable copy of `trackedBefore` that also accumulates ids added during this poll, so the
1781
- // membership check below is O(1) and duplicate ids within a single poll response are only
1782
- // added once. `trackedBefore` itself stays the pre-poll snapshot used by the `missing` calc.
1783
- const known = new Set(trackedBefore);
2523
+ // Every id the poll returned (live *and* completed). Drives completed-session reconciliation:
2524
+ // a tracked completed session absent from this set was archived/purged by the CLI.
2525
+ const polledIds = new Set<string>();
2526
+ // Accumulates ids already tracked or added during this poll, so the membership check is O(1)
2527
+ // and duplicate ids within a single response are only added once.
2528
+ const known = new Set(this._sessions.map(s => s.id));
1784
2529
 
1785
2530
  let changed = false;
1786
2531
  let discovered = 0;
1787
2532
 
1788
2533
  for (const data of sessions) {
1789
- if (!data.sessionId || !data.pid || !isProcessAlive(data.pid)) {
2534
+ if (!data.sessionId) continue;
2535
+
2536
+ // Another AI host's record in the CLI's shared store. Must stay ahead of `polledIds`/
2537
+ // `polledAlive`: an id in either would skew completed-row reconciliation and the
2538
+ // `onSyncDiscrepancy` drift signal below.
2539
+ if (data.providerId != null && data.providerId !== 'claude-code') continue;
2540
+
2541
+ const effectiveStatus = data.status ?? 'active';
2542
+
2543
+ // Archived records are excluded by the `active,ended` query; guard anyway.
2544
+ if (effectiveStatus === 'archived') continue;
2545
+
2546
+ if (effectiveStatus === 'ended') {
2547
+ polledIds.add(data.sessionId);
2548
+ const endedDate = new Date(data.endedAt ?? data.updatedAt);
2549
+
2550
+ const existingIndex = this._sessions.findIndex(s => s.id === data.sessionId);
2551
+ if (existingIndex >= 0) {
2552
+ // Already tracked. If the live path missed the SessionEnd (e.g. `/clear` kept the
2553
+ // PID alive under a new id), transition it to completed now — otherwise a live-status
2554
+ // row would linger as a zombie, since pruneDeadSessions can't reap a live PID.
2555
+ if (this._sessions[existingIndex].status !== 'completed') {
2556
+ this.completeSession(existingIndex, endedDate);
2557
+ changed = true;
2558
+ }
2559
+
2560
+ // Re-seat the row on the record's location. The durable record is authoritative for a
2561
+ // terminal session, and a `CwdChanged` hook we never received would otherwise leave the
2562
+ // card attached to the wrong worktree — and "Open Session" resuming from a stale
2563
+ // directory. Deliberately limited to location: prompts/titles have their own on-demand
2564
+ // transcript resolution, which holds richer values than the record does.
2565
+ const existing = this._sessions[existingIndex];
2566
+ const worktreeRoot = worktreeRootFromData(data);
2567
+ const cwdChanged = Boolean(data.cwd) && data.cwd !== existing.cwd;
2568
+ // Pre-`cwdTimeline` records carry no worktree data. If the cwd moved, whatever we hold
2569
+ // is no longer trustworthy — it may point into a different repo entirely — so drop it
2570
+ // rather than leave the card filed under the wrong branch, and re-resolve below. A cwd
2571
+ // change is a discrete event, so that's one probe, not the cold-start fan-out the
2572
+ // completed path exists to avoid.
2573
+ const nextWorktreePath =
2574
+ worktreeRoot != null
2575
+ ? normalizePath(worktreeRoot)
2576
+ : cwdChanged
2577
+ ? undefined
2578
+ : existing.worktreePath;
2579
+ const worktreeMoved = nextWorktreePath !== existing.worktreePath;
2580
+ if (cwdChanged || worktreeMoved) {
2581
+ const workspacePath = this.resolveWorkspacePath(data.cwd || existing.cwd);
2582
+ this._sessions[existingIndex] = {
2583
+ ...existing,
2584
+ cwd: data.cwd || existing.cwd,
2585
+ initialCwd: existing.initialCwd ?? data.initialCwd ?? data.cwd,
2586
+ worktreePath: nextWorktreePath,
2587
+ workspacePath: workspacePath,
2588
+ isInWorkspace: workspacePath != null,
2589
+ // A worktree move can cross repos, and the spread would otherwise carry the old
2590
+ // repo identity forward — which consumers PREFER over the freshly-resolved
2591
+ // worktree metadata, pinning the card to the wrong repo. Drop it and let the
2592
+ // host backfill from the new worktree.
2593
+ commonPath: worktreeMoved ? undefined : existing.commonPath,
2594
+ };
2595
+ changed = true;
2596
+
2597
+ // Nothing in the record to re-seat it with (older CLI), so resolve the new cwd
2598
+ // once rather than leaving the row location-less.
2599
+ if (nextWorktreePath == null && data.cwd) {
2600
+ void this.resolveGitInfo(data.sessionId, data.cwd);
2601
+ }
2602
+ }
2603
+
2604
+ this.getBookkeeping(data.sessionId).polledAtLeastOnce = true;
2605
+ continue;
2606
+ }
2607
+
2608
+ known.add(data.sessionId);
2609
+ const workspacePath = this.resolveWorkspacePath(data.cwd);
2610
+ const worktreeRoot = worktreeRootFromData(data);
2611
+ this._sessions.push({
2612
+ id: data.sessionId,
2613
+ providerId: this.id,
2614
+ providerName: this.name,
2615
+ name: data.sessionName || undefined,
2616
+ status: 'completed',
2617
+ phase: 'completed',
2618
+ phaseSince: endedDate,
2619
+ pid: data.pid,
2620
+ lastActivity: endedDate,
2621
+ isSubagent: false,
2622
+ workspacePath: workspacePath,
2623
+ // Read straight from the CLI's durable record so completed sessions attach to branch
2624
+ // cards / WIP rows / the resume picker at any age, no git probe.
2625
+ worktreePath: worktreeRoot != null ? normalizePath(worktreeRoot) : undefined,
2626
+ cwd: data.cwd,
2627
+ initialCwd: data.initialCwd ?? data.cwd,
2628
+ planFile: data.planFile ?? undefined,
2629
+ isInWorkspace: workspacePath != null,
2630
+ lastPrompt: prepareStoredPrompt(data.prompt ?? undefined),
2631
+ firstPrompt: prepareStoredPrompt(data.firstPrompt ?? undefined),
2632
+ model: data.model ?? undefined,
2633
+ endReason: data.endReason ?? undefined,
2634
+ endedAt: data.endedAt != null ? new Date(data.endedAt).getTime() : undefined,
2635
+ // Subagents are meaningless once terminal.
2636
+ subagents: undefined,
2637
+ });
2638
+ this.getBookkeeping(data.sessionId).polledAtLeastOnce = true;
2639
+ changed = true;
2640
+ // Titles/prompts still resolve lazily via resolveCompletedSessionDetails (a 30-day
2641
+ // cold-start must not fan out transcript reads). worktreePath comes from the record
2642
+ // above; only fall back to an eager git probe when the record predates that CLI field,
2643
+ // and only for the recent (WIP-window) tail so the fallback stays bounded.
2644
+ if (
2645
+ worktreeRoot == null &&
2646
+ data.cwd &&
2647
+ Date.now() - endedDate.getTime() < recentCompletedGitResolveThresholdMs
2648
+ ) {
2649
+ void this.resolveGitInfo(data.sessionId, data.cwd);
2650
+ }
1790
2651
  continue;
1791
2652
  }
1792
2653
 
1793
- polledAlive.add(data.sessionId);
2654
+ // Active / legacy (absent-status) path — only genuinely-live processes are discoverable.
2655
+ if (!data.pid || !isProcessAlive(data.pid)) continue;
1794
2656
 
1795
- if (known.has(data.sessionId)) continue;
2657
+ polledAlive.add(data.sessionId);
2658
+ polledIds.add(data.sessionId);
2659
+
2660
+ if (known.has(data.sessionId)) {
2661
+ // Already tracked.
2662
+ const existingIndex = this._sessions.findIndex(s => s.id === data.sessionId);
2663
+ if (existingIndex >= 0) {
2664
+ const existing = this._sessions[existingIndex];
2665
+ // The status the CLI's last event implies — a resumed session may already be mid-work
2666
+ // (`PreToolUse` → tool_use, `UserPromptSubmit` → thinking), and hardcoding `idle` would
2667
+ // mislabel active work and risk a concurrent-write resume.
2668
+ const polledStatus = deriveStatusFromEvent(data.event);
2669
+ if (existing.status === 'completed') {
2670
+ // We hold it as `completed` but the CLI now reports it as a live process (a
2671
+ // resume the live SessionStart path missed) — revive it, the symmetric backstop
2672
+ // to the ended-branch's live→completed transition above. Leave genuinely-live
2673
+ // rows to the IPC path.
2674
+ const revivedAt = new Date(data.updatedAt);
2675
+ // Only when the record is at least as new as the completion. `SessionEnd` fires
2676
+ // while the process is still winding down, so a poll whose CLI call started
2677
+ // BEFORE it returns a snapshot that still says "active" with a live pid — reviving
2678
+ // off that stale record would resurrect the row, and `pruneDeadSessions` would
2679
+ // then delete it outright once the process exits, losing the completed row.
2680
+ if (revivedAt.getTime() < existing.phaseSince.getTime()) continue;
2681
+
2682
+ // The poll has no blocking hook entry for this ask (it's discovered off the CLI's
2683
+ // durable record, not this window's IPC path) — synthesize an unresolvable one so
2684
+ // the row shows what it's waiting on instead of an empty "Needs input" card.
2685
+ const revivedPendingPermission =
2686
+ polledStatus === 'permission_requested'
2687
+ ? synthesizeUnresolvableAsk(data.toolName, data.planFile ?? undefined)
2688
+ : undefined;
2689
+ // Seat worktreePath from the record, mirroring the ended-record re-seat above — the
2690
+ // existing row is otherwise stuck with whatever (or nothing) it had while completed.
2691
+ const revivedWorktreeRoot = worktreeRootFromData(data);
2692
+ // No record worktree data (older CLI): KEEP the completed row's attribution. A
2693
+ // resume whose cwd is scratch space (e.g. /tmp) still belongs to its worktree —
2694
+ // re-rooting happens only when git affirmatively resolves a new one (the probe
2695
+ // below, on success). Attribution persists until something better is known.
2696
+ const revivedWorktreePath =
2697
+ revivedWorktreeRoot != null ? normalizePath(revivedWorktreeRoot) : existing.worktreePath;
2698
+ // Membership follows the attribution, mirroring `ensureSession`: cwd first, the
2699
+ // kept worktree as the fallback for a scratch-dir resume.
2700
+ const revivedWorkspacePath =
2701
+ this.resolveWorkspacePath(data.cwd) ?? this.resolveWorkspacePath(revivedWorktreePath);
2702
+ this._sessions[existingIndex] = {
2703
+ ...existing,
2704
+ status: polledStatus,
2705
+ phase: getPhaseForStatus(polledStatus),
2706
+ phaseSince: revivedAt,
2707
+ lastActivity: revivedAt,
2708
+ // Fresh pid from the CLI, and re-classify ownership on next open rather than
2709
+ // trusting the terminal record's `isPeerOwned`.
2710
+ pid: data.pid,
2711
+ isPeerOwned: undefined,
2712
+ pendingPermission: revivedPendingPermission,
2713
+ // The resume can be running somewhere else entirely, so take the record's
2714
+ // location rather than leaving the row filed under where it ended.
2715
+ cwd: data.cwd ?? existing.cwd,
2716
+ workspacePath:
2717
+ revivedWorkspacePath ?? (data.cwd == null ? existing.workspacePath : undefined),
2718
+ isInWorkspace: data.cwd == null ? existing.isInWorkspace : revivedWorkspacePath != null,
2719
+ worktreePath: revivedWorktreePath,
2720
+ // A worktree move can cross repos — drop the stale repo identity and let the
2721
+ // probe below refill it.
2722
+ commonPath: revivedWorktreePath !== existing.worktreePath ? undefined : existing.commonPath,
2723
+ // No longer terminal: leaving these set makes the row read as completed to
2724
+ // consumers that key off them.
2725
+ endReason: undefined,
2726
+ endedAt: undefined,
2727
+ };
2728
+ this.resetBookkeeping(data.sessionId);
2729
+ // The record's worktree is the CLI's own resolution; without one the probe owns
2730
+ // attribution again.
2731
+ this.getBookkeeping(data.sessionId).cliSeatedWorktree = revivedWorktreeRoot != null;
2732
+ if (data.cwd != null && data.cwd !== existing.cwd) {
2733
+ void this.resolveGitInfo(data.sessionId, data.cwd);
2734
+ }
2735
+ changed = true;
2736
+ } else if (
2737
+ existing.pendingPermission?.resolvable === false &&
2738
+ polledStatus !== 'permission_requested' &&
2739
+ new Date(data.updatedAt).getTime() > existing.lastActivity.getTime()
2740
+ ) {
2741
+ // The record has moved past the ask this row still shows. Only a synthesized
2742
+ // (`resolvable: false`) ask is repairable this way — a routable one is bookkeeping-
2743
+ // owned and cleared by its own resolution paths, which the record can't see.
2744
+ this._sessions[existingIndex] = {
2745
+ ...existing,
2746
+ status: polledStatus,
2747
+ phase: getPhaseForStatus(polledStatus),
2748
+ phaseSince: new Date(data.updatedAt),
2749
+ // The detail described the ask's tool; carry the record's current tool for
2750
+ // tool_use, else clear — keeping it would show the answered ask's tool as
2751
+ // the active one.
2752
+ statusDetail: polledStatus === 'tool_use' ? (data.toolName ?? undefined) : undefined,
2753
+ pendingPermission: undefined,
2754
+ lastActivity: new Date(data.updatedAt),
2755
+ };
2756
+ changed = true;
2757
+ } else if (existing.transcriptTitles?.ai == null && existing.transcriptTitles?.custom == null) {
2758
+ // A live session we discovered via the poll (another worktree/window owns its
2759
+ // hook flow) gets none of the local IPC re-resolves (SessionStart, idle). So one
2760
+ // discovered before Claude wrote its `ai-title` would show the repo slug forever.
2761
+ // Re-check the transcript each poll until a real title lands — cheap: the reader
2762
+ // tail-reads from its mtime cache and no-ops when the file hasn't grown.
2763
+ void this.resolveTranscriptTitles(data.sessionId, data.cwd);
2764
+ }
2765
+ }
2766
+ continue;
2767
+ }
1796
2768
 
1797
2769
  known.add(data.sessionId);
1798
2770
 
@@ -1801,6 +2773,15 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
1801
2773
  const status = deriveStatusFromEvent(data.event);
1802
2774
  const phase = getPhaseForStatus(status);
1803
2775
  const activityDate = new Date(data.updatedAt);
2776
+ // Same rationale as the revived-session branch above: no blocking hook entry exists for a
2777
+ // poll-discovered ask, so synthesize an unresolvable one for the card.
2778
+ const pendingPermission =
2779
+ status === 'permission_requested'
2780
+ ? synthesizeUnresolvableAsk(data.toolName, data.planFile ?? undefined)
2781
+ : undefined;
2782
+ // Read straight from the CLI's durable record so the row attaches to the right branch
2783
+ // card/WIP row immediately, no git probe — mirrors the completed-session push below.
2784
+ const worktreeRoot = worktreeRootFromData(data);
1804
2785
 
1805
2786
  const subagents: AgentSession[] | undefined = data.subagents?.map(sub => ({
1806
2787
  id: sub.agentId,
@@ -1837,17 +2818,54 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
1837
2818
  isInWorkspace: isInWorkspace,
1838
2819
  lastPrompt: prepareStoredPrompt(data.prompt ?? undefined),
1839
2820
  firstPrompt: prepareStoredPrompt(data.firstPrompt ?? undefined),
2821
+ model: data.model ?? undefined,
1840
2822
  subagents: subagents,
2823
+ pendingPermission: pendingPermission,
2824
+ worktreePath: worktreeRoot != null ? normalizePath(worktreeRoot) : undefined,
1841
2825
  });
1842
2826
  changed = true;
1843
2827
  discovered++;
1844
2828
 
2829
+ // The record's worktree is the CLI's own resolution — mark it seated so the probe below
2830
+ // (still wanted for `commonPath`) can't clobber it for a nested worktree. Mirrors the
2831
+ // revival branch above.
2832
+ this.getBookkeeping(data.sessionId).cliSeatedWorktree = worktreeRoot != null;
1845
2833
  if (data.cwd) {
1846
2834
  void this.resolveGitInfo(data.sessionId, data.cwd);
1847
2835
  }
1848
2836
  void this.resolveTranscriptTitles(data.sessionId, data.cwd);
1849
2837
  }
1850
2838
 
2839
+ // The poll is authoritative for completed-session removal (archived elsewhere or 30-day-purged).
2840
+ // Only remove ones a prior poll confirmed present — never a session the live SessionEnd path just
2841
+ // transitioned, which an already-in-flight poll can legitimately miss (guarded by polledAtLeastOnce).
2842
+ // On a legacy CLI (no `--status`) no poll can ever confirm a completed row, so the guard would
2843
+ // instead pin it forever — there, absence from the poll is the confirmation.
2844
+ const staleCompleted = this._sessions.filter(
2845
+ s =>
2846
+ s.status === 'completed' &&
2847
+ !polledIds.has(s.id) &&
2848
+ (!this._statusFilterSupported ||
2849
+ this._sessionBookkeeping.get(s.id)?.polledAtLeastOnce === true ||
2850
+ // Or this poll's snapshot postdates the completion, so its absence is authoritative.
2851
+ // `polledAtLeastOnce` alone can never be set for a session whose record disappeared
2852
+ // (archived from another window, or purged) before any poll observed it — and
2853
+ // `completeSession` drops the bookkeeping — so without this the row is unremovable for
2854
+ // the window's lifetime. The timestamp answers the same question the flag was proxying:
2855
+ // could this poll have seen it? The grace keeps a just-completed row out of reach of
2856
+ // an absence that's really a not-yet-visible record.
2857
+ s.phaseSince.getTime() < pollStartedAt - completedRemovalGraceMs),
2858
+ );
2859
+ if (staleCompleted.length > 0) {
2860
+ const staleIds = new Set(staleCompleted.map(s => s.id));
2861
+ this._sessions = this._sessions.filter(s => !staleIds.has(s.id));
2862
+ for (const s of staleCompleted) {
2863
+ this._sessionBookkeeping.delete(s.id);
2864
+ this._transcriptReader.forget(s.id);
2865
+ }
2866
+ changed = true;
2867
+ }
2868
+
1851
2869
  if (this.pruneDeadSessions()) {
1852
2870
  changed = true;
1853
2871
  }
@@ -1859,11 +2877,11 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
1859
2877
  // Report any drift between the live-synced set and the poll. Only on the recurring (gated)
1860
2878
  // poll — the ungated bootstrap call runs before the live path has had a chance to receive
1861
2879
  // any events, so its discoveries are expected cold-start state, not drift. `missing` counts
1862
- // sessions we still track that the poll no longer reports alive (a teardown the live path
2880
+ // live sessions we still track that the poll no longer reports alive (a teardown the live path
1863
2881
  // missed, e.g. an agent killed without firing `SessionEnd`).
1864
2882
  if (options?.gate) {
1865
2883
  let missing = 0;
1866
- for (const id of trackedBefore) {
2884
+ for (const id of trackedLiveBefore) {
1867
2885
  if (!polledAlive.has(id)) {
1868
2886
  missing++;
1869
2887
  }
@@ -1874,13 +2892,13 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
1874
2892
  discovered: discovered,
1875
2893
  missing: missing,
1876
2894
  polled: polledAlive.size,
1877
- tracked: trackedBefore.size,
2895
+ tracked: trackedLiveBefore.size,
1878
2896
  });
1879
2897
  }
1880
2898
  }
1881
2899
  }
1882
2900
 
1883
- private async querySiblingWindowSessions(): Promise<void> {
2901
+ protected async querySiblingWindowSessions(): Promise<void> {
1884
2902
  const discoveryDir = this.callbacks.ipc.agentDiscoveryDir;
1885
2903
  if (discoveryDir == null) return;
1886
2904
 
@@ -1912,6 +2930,12 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
1912
2930
  if (peerSessions == null) continue;
1913
2931
 
1914
2932
  for (const peerSession of peerSessions) {
2933
+ // Skip terminal rows a peer published: completed sessions live in the machine-global CLI
2934
+ // store, so our own poll surfaces them with proper reconciliation. Importing them from a
2935
+ // peer would add a row with no `polledAtLeastOnce` that our poll can't confirm — a ghost
2936
+ // pinned for this window's lifetime if the store later drops it.
2937
+ if (peerSession.status === 'completed') continue;
2938
+
1915
2939
  const peerActivity = new Date(peerSession.lastActivity);
1916
2940
  const peerPhaseSince = new Date(peerSession.phaseSince);
1917
2941
 
@@ -1929,6 +2953,11 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
1929
2953
 
1930
2954
  if (peerActivity > existing.lastActivity) {
1931
2955
  const idx = this._sessions.indexOf(existing);
2956
+ // A terminal row the peer now reports live IS a resume the peer owns. Mirror the
2957
+ // poll/SessionStart revive paths: take the peer's pid (retaining the terminal
2958
+ // record's dead — and possibly reused — pid would let dispatch focus an unrelated
2959
+ // process) and mark it peer-owned so opens route through the peer's IPC.
2960
+ const revivedFromCompleted = existing.status === 'completed';
1932
2961
  // Peer is the authoritative hook recipient for this session, so wipe any
1933
2962
  // local bookkeeping that survived from a previous local-ownership window.
1934
2963
  // Without this, refcounts/timers from a prior local-hosting stretch would
@@ -1948,6 +2977,15 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
1948
2977
  phaseSince: peerPhaseSince,
1949
2978
  statusDetail: peerSession.statusDetail,
1950
2979
  lastActivity: peerActivity,
2980
+ // A locally-routable ask wins — a blocking ask fans out to every window, so we
2981
+ // may hold the hook entry the peer lacks. Otherwise mirror the peer's ask marked
2982
+ // unresolvable (we have no entry to answer it), and clear it when the peer did.
2983
+ pendingPermission:
2984
+ this._sessionBookkeeping.get(peerSession.id)?.pendingPermission ??
2985
+ (peerSession.pendingPermission != null
2986
+ ? { ...peerSession.pendingPermission, resolvable: false }
2987
+ : undefined),
2988
+ ...(revivedFromCompleted ? { pid: peerSession.pid, isPeerOwned: true } : undefined),
1951
2989
  subagents: rehydrateSubagents(peerSession.subagents),
1952
2990
  // Carry the peer's published fileActivity across so peer-window WIP
1953
2991
  // decorations + treemap heatmap follow the agent. Owned by the peer (it's
@@ -1983,6 +3021,11 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
1983
3021
  ...peerSession,
1984
3022
  lastActivity: peerActivity,
1985
3023
  phaseSince: peerPhaseSince,
3024
+ // The peer owns the hook entry, so an imported ask can never be routed from here.
3025
+ pendingPermission:
3026
+ peerSession.pendingPermission != null
3027
+ ? { ...peerSession.pendingPermission, resolvable: false }
3028
+ : undefined,
1986
3029
  workspacePath: normalizeWorkspacePath(peerSession.workspacePath),
1987
3030
  isInWorkspace: this.matchesWorkspace(peerSession.workspacePath),
1988
3031
  subagents: rehydrateSubagents(peerSession.subagents),