@gitkraken/core-gitlens 0.2.0 → 0.3.1

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 (448) hide show
  1. package/CHANGELOG.md +11 -1
  2. package/dist/git/cache.d.ts +38 -8
  3. package/dist/git/cache.d.ts.map +1 -1
  4. package/dist/git/cache.js +120 -55
  5. package/dist/git/cache.js.map +1 -1
  6. package/dist/git/context.d.ts +19 -0
  7. package/dist/git/context.d.ts.map +1 -1
  8. package/dist/git/context.js.map +1 -1
  9. package/dist/git/features.d.ts +1 -1
  10. package/dist/git/features.d.ts.map +1 -1
  11. package/dist/git/features.js +1 -0
  12. package/dist/git/features.js.map +1 -1
  13. package/dist/git/models/commit.d.ts.map +1 -1
  14. package/dist/git/models/commit.js.map +1 -1
  15. package/dist/git/models/graph.d.ts +96 -0
  16. package/dist/git/models/graph.d.ts.map +1 -1
  17. package/dist/git/models/graph.js +24 -1
  18. package/dist/git/models/graph.js.map +1 -1
  19. package/dist/git/models/reference.d.ts +31 -0
  20. package/dist/git/models/reference.d.ts.map +1 -1
  21. package/dist/git/models/remote.d.ts.map +1 -1
  22. package/dist/git/models/remote.js.map +1 -1
  23. package/dist/git/models/remoteProvider.d.ts +1 -0
  24. package/dist/git/models/remoteProvider.d.ts.map +1 -1
  25. package/dist/git/models/remoteProvider.js.map +1 -1
  26. package/dist/git/models/repository.d.ts +30 -13
  27. package/dist/git/models/repository.d.ts.map +1 -1
  28. package/dist/git/models/repository.js +89 -87
  29. package/dist/git/models/repository.js.map +1 -1
  30. package/dist/git/models/search.d.ts +2 -2
  31. package/dist/git/models/search.d.ts.map +1 -1
  32. package/dist/git/models/statusFile.d.ts +9 -0
  33. package/dist/git/models/statusFile.d.ts.map +1 -1
  34. package/dist/git/models/statusFile.js +11 -0
  35. package/dist/git/models/statusFile.js.map +1 -1
  36. package/dist/git/models/worktree.d.ts +4 -1
  37. package/dist/git/models/worktree.d.ts.map +1 -1
  38. package/dist/git/models/worktree.js +2 -2
  39. package/dist/git/models/worktree.js.map +1 -1
  40. package/dist/git/parsers/diffParser.d.ts.map +1 -1
  41. package/dist/git/parsers/diffParser.js.map +1 -1
  42. package/dist/git/providers/branches.d.ts +15 -4
  43. package/dist/git/providers/branches.d.ts.map +1 -1
  44. package/dist/git/providers/commits.d.ts +4 -0
  45. package/dist/git/providers/commits.d.ts.map +1 -1
  46. package/dist/git/providers/config.d.ts +5 -4
  47. package/dist/git/providers/config.d.ts.map +1 -1
  48. package/dist/git/providers/operations.d.ts +61 -12
  49. package/dist/git/providers/operations.d.ts.map +1 -1
  50. package/dist/git/providers/pausedOperations.d.ts +3 -1
  51. package/dist/git/providers/pausedOperations.d.ts.map +1 -1
  52. package/dist/git/providers/refs.d.ts +23 -2
  53. package/dist/git/providers/refs.d.ts.map +1 -1
  54. package/dist/git/providers/staging.d.ts +16 -0
  55. package/dist/git/providers/staging.d.ts.map +1 -1
  56. package/dist/git/providers/status.d.ts +4 -1
  57. package/dist/git/providers/status.d.ts.map +1 -1
  58. package/dist/git/remotes/azure-devops.d.ts.map +1 -1
  59. package/dist/git/remotes/azure-devops.js.map +1 -1
  60. package/dist/git/remotes/custom.d.ts +2 -0
  61. package/dist/git/remotes/custom.d.ts.map +1 -1
  62. package/dist/git/remotes/custom.js +20 -0
  63. package/dist/git/remotes/custom.js.map +1 -1
  64. package/dist/git/{exec.types.d.ts → run.types.d.ts} +22 -3
  65. package/dist/git/run.types.d.ts.map +1 -0
  66. package/dist/git/run.types.js +2 -0
  67. package/dist/git/run.types.js.map +1 -0
  68. package/dist/git/service.d.ts.map +1 -1
  69. package/dist/git/service.js.map +1 -1
  70. package/dist/git/utils/autolink.utils.d.ts.map +1 -1
  71. package/dist/git/utils/autolink.utils.js.map +1 -1
  72. package/dist/git/utils/blame.utils.d.ts.map +1 -1
  73. package/dist/git/utils/blame.utils.js.map +1 -1
  74. package/dist/git/utils/conflictResolution.utils.d.ts +6 -0
  75. package/dist/git/utils/conflictResolution.utils.d.ts.map +1 -0
  76. package/dist/git/utils/conflictResolution.utils.js +25 -0
  77. package/dist/git/utils/conflictResolution.utils.js.map +1 -0
  78. package/dist/git/utils/contributor.utils.d.ts +3 -0
  79. package/dist/git/utils/contributor.utils.d.ts.map +1 -1
  80. package/dist/git/utils/contributor.utils.js +22 -0
  81. package/dist/git/utils/contributor.utils.js.map +1 -1
  82. package/dist/git/utils/fileStatus.utils.d.ts +2 -1
  83. package/dist/git/utils/fileStatus.utils.d.ts.map +1 -1
  84. package/dist/git/utils/fileStatus.utils.js +4 -0
  85. package/dist/git/utils/fileStatus.utils.js.map +1 -1
  86. package/dist/git/utils/reachability.utils.d.ts +37 -0
  87. package/dist/git/utils/reachability.utils.d.ts.map +1 -0
  88. package/dist/git/utils/reachability.utils.js +96 -0
  89. package/dist/git/utils/reachability.utils.js.map +1 -0
  90. package/dist/git/utils/search.utils.d.ts.map +1 -1
  91. package/dist/git/utils/search.utils.js +3 -0
  92. package/dist/git/utils/search.utils.js.map +1 -1
  93. package/dist/git/utils/sorting.d.ts +8 -0
  94. package/dist/git/utils/sorting.d.ts.map +1 -1
  95. package/dist/git/utils/sorting.js +15 -0
  96. package/dist/git/utils/sorting.js.map +1 -1
  97. package/dist/git/utils/status.utils.d.ts.map +1 -1
  98. package/dist/git/utils/status.utils.js.map +1 -1
  99. package/dist/git/watching/classifyChange.d.ts.map +1 -1
  100. package/dist/git/watching/classifyChange.js +8 -0
  101. package/dist/git/watching/classifyChange.js.map +1 -1
  102. package/dist/git/watching/gitIgnoreFilter.js +1 -1
  103. package/dist/git/watching/gitIgnoreFilter.js.map +1 -1
  104. package/dist/git/watching/initWatcher.d.ts.map +1 -1
  105. package/dist/git/watching/initWatcher.js.map +1 -1
  106. package/dist/git/watching/watchGroup.d.ts.map +1 -1
  107. package/dist/git/watching/watchGroup.js.map +1 -1
  108. package/dist/git/watching/watchService.d.ts +4 -2
  109. package/dist/git/watching/watchService.d.ts.map +1 -1
  110. package/dist/git/watching/watchService.js +31 -6
  111. package/dist/git/watching/watchService.js.map +1 -1
  112. package/dist/git/watching/watchSession.d.ts +14 -12
  113. package/dist/git/watching/watchSession.d.ts.map +1 -1
  114. package/dist/git/watching/watchSession.js +56 -55
  115. package/dist/git/watching/watchSession.js.map +1 -1
  116. package/dist/git-cli/exec/exec.types.d.ts +1 -1
  117. package/dist/git-cli/exec/exec.types.d.ts.map +1 -1
  118. package/dist/git-cli/exec/git.d.ts +3 -3
  119. package/dist/git-cli/exec/git.d.ts.map +1 -1
  120. package/dist/git-cli/exec/git.js.map +1 -1
  121. package/dist/git-cli/exec/gitQueue.d.ts +10 -2
  122. package/dist/git-cli/exec/gitQueue.d.ts.map +1 -1
  123. package/dist/git-cli/exec/gitQueue.js +39 -12
  124. package/dist/git-cli/exec/gitQueue.js.map +1 -1
  125. package/dist/git-cli/parsers/blameParser.d.ts.map +1 -1
  126. package/dist/git-cli/parsers/blameParser.js.map +1 -1
  127. package/dist/git-cli/parsers/logParser.js.map +1 -1
  128. package/dist/git-cli/parsers/refParser.d.ts +3 -30
  129. package/dist/git-cli/parsers/refParser.d.ts.map +1 -1
  130. package/dist/git-cli/parsers/refParser.js +30 -30
  131. package/dist/git-cli/parsers/refParser.js.map +1 -1
  132. package/dist/git-cli/providers/blame.d.ts.map +1 -1
  133. package/dist/git-cli/providers/blame.js.map +1 -1
  134. package/dist/git-cli/providers/branches.d.ts +15 -5
  135. package/dist/git-cli/providers/branches.d.ts.map +1 -1
  136. package/dist/git-cli/providers/branches.js +99 -68
  137. package/dist/git-cli/providers/branches.js.map +1 -1
  138. package/dist/git-cli/providers/commits.d.ts +1 -0
  139. package/dist/git-cli/providers/commits.d.ts.map +1 -1
  140. package/dist/git-cli/providers/commits.js +86 -30
  141. package/dist/git-cli/providers/commits.js.map +1 -1
  142. package/dist/git-cli/providers/config.d.ts +19 -6
  143. package/dist/git-cli/providers/config.d.ts.map +1 -1
  144. package/dist/git-cli/providers/config.js +86 -38
  145. package/dist/git-cli/providers/config.js.map +1 -1
  146. package/dist/git-cli/providers/diff.d.ts.map +1 -1
  147. package/dist/git-cli/providers/diff.js +5 -3
  148. package/dist/git-cli/providers/diff.js.map +1 -1
  149. package/dist/git-cli/providers/graph.d.ts.map +1 -1
  150. package/dist/git-cli/providers/graph.js +139 -16
  151. package/dist/git-cli/providers/graph.js.map +1 -1
  152. package/dist/git-cli/providers/operations.d.ts +17 -14
  153. package/dist/git-cli/providers/operations.d.ts.map +1 -1
  154. package/dist/git-cli/providers/operations.js +135 -63
  155. package/dist/git-cli/providers/operations.js.map +1 -1
  156. package/dist/git-cli/providers/patch.d.ts.map +1 -1
  157. package/dist/git-cli/providers/patch.js.map +1 -1
  158. package/dist/git-cli/providers/pausedOperations.d.ts +3 -1
  159. package/dist/git-cli/providers/pausedOperations.d.ts.map +1 -1
  160. package/dist/git-cli/providers/pausedOperations.js +7 -1
  161. package/dist/git-cli/providers/pausedOperations.js.map +1 -1
  162. package/dist/git-cli/providers/refs.d.ts +19 -2
  163. package/dist/git-cli/providers/refs.d.ts.map +1 -1
  164. package/dist/git-cli/providers/refs.js +286 -2
  165. package/dist/git-cli/providers/refs.js.map +1 -1
  166. package/dist/git-cli/providers/revision.js.map +1 -1
  167. package/dist/git-cli/providers/staging.d.ts +6 -0
  168. package/dist/git-cli/providers/staging.d.ts.map +1 -1
  169. package/dist/git-cli/providers/staging.js +32 -0
  170. package/dist/git-cli/providers/staging.js.map +1 -1
  171. package/dist/git-cli/providers/stash.d.ts.map +1 -1
  172. package/dist/git-cli/providers/stash.js +20 -2
  173. package/dist/git-cli/providers/stash.js.map +1 -1
  174. package/dist/git-cli/providers/status.d.ts +4 -1
  175. package/dist/git-cli/providers/status.d.ts.map +1 -1
  176. package/dist/git-cli/providers/status.js +8 -4
  177. package/dist/git-cli/providers/status.js.map +1 -1
  178. package/dist/git-cli/providers/tags.d.ts.map +1 -1
  179. package/dist/git-cli/providers/tags.js +10 -6
  180. package/dist/git-cli/providers/tags.js.map +1 -1
  181. package/dist/ipc/discovery.d.ts +49 -0
  182. package/dist/ipc/discovery.d.ts.map +1 -0
  183. package/dist/ipc/discovery.js +147 -0
  184. package/dist/ipc/discovery.js.map +1 -0
  185. package/dist/ipc/ipcServer.d.ts +20 -0
  186. package/dist/ipc/ipcServer.d.ts.map +1 -0
  187. package/dist/ipc/ipcServer.js +162 -0
  188. package/dist/ipc/ipcServer.js.map +1 -0
  189. package/dist/plus/agents/providers/claudeCodeProvider.d.ts +167 -0
  190. package/dist/plus/agents/providers/claudeCodeProvider.d.ts.map +1 -0
  191. package/dist/plus/agents/providers/claudeCodeProvider.js +1786 -0
  192. package/dist/plus/agents/providers/claudeCodeProvider.js.map +1 -0
  193. package/dist/plus/agents/providers/claudeCodeTranscript.d.ts +36 -0
  194. package/dist/plus/agents/providers/claudeCodeTranscript.d.ts.map +1 -0
  195. package/dist/plus/agents/providers/claudeCodeTranscript.js +171 -0
  196. package/dist/plus/agents/providers/claudeCodeTranscript.js.map +1 -0
  197. package/dist/plus/agents/sanitizePrompt.d.ts +15 -0
  198. package/dist/plus/agents/sanitizePrompt.d.ts.map +1 -0
  199. package/dist/plus/agents/sanitizePrompt.js +103 -0
  200. package/dist/plus/agents/sanitizePrompt.js.map +1 -0
  201. package/dist/plus/agents/stateMachine.d.ts +32 -0
  202. package/dist/plus/agents/stateMachine.d.ts.map +1 -0
  203. package/dist/plus/agents/stateMachine.js +180 -0
  204. package/dist/plus/agents/stateMachine.js.map +1 -0
  205. package/dist/plus/agents/types.d.ts +254 -0
  206. package/dist/plus/agents/types.d.ts.map +1 -0
  207. package/dist/plus/agents/types.js +49 -0
  208. package/dist/plus/agents/types.js.map +1 -0
  209. package/dist/plus/ai/errors.d.ts +4 -1
  210. package/dist/plus/ai/errors.d.ts.map +1 -1
  211. package/dist/plus/ai/errors.js +38 -0
  212. package/dist/plus/ai/errors.js.map +1 -1
  213. package/dist/plus/ai/models/model.d.ts +1 -1
  214. package/dist/plus/ai/models/model.d.ts.map +1 -1
  215. package/dist/plus/ai/models/model.js.map +1 -1
  216. package/dist/plus/ai/models/promptTemplates.d.ts +11 -2
  217. package/dist/plus/ai/models/promptTemplates.d.ts.map +1 -1
  218. package/dist/plus/ai/models/provider.d.ts +3 -0
  219. package/dist/plus/ai/models/provider.d.ts.map +1 -1
  220. package/dist/plus/ai/prompts.d.ts +1 -0
  221. package/dist/plus/ai/prompts.d.ts.map +1 -1
  222. package/dist/plus/ai/prompts.js +48 -8
  223. package/dist/plus/ai/prompts.js.map +1 -1
  224. package/dist/plus/ai/providers/gitkrakenProvider.d.ts +1 -1
  225. package/dist/plus/ai/providers/gitkrakenProvider.d.ts.map +1 -1
  226. package/dist/plus/ai/providers/gitkrakenProvider.js +4 -1
  227. package/dist/plus/ai/providers/gitkrakenProvider.js.map +1 -1
  228. package/dist/plus/ai/providers/openAICompatibleProviderBase.d.ts +4 -3
  229. package/dist/plus/ai/providers/openAICompatibleProviderBase.d.ts.map +1 -1
  230. package/dist/plus/ai/providers/openAICompatibleProviderBase.js +9 -6
  231. package/dist/plus/ai/providers/openAICompatibleProviderBase.js.map +1 -1
  232. package/dist/plus/ai/utils/ai.utils.d.ts.map +1 -1
  233. package/dist/plus/ai/utils/ai.utils.js +2 -0
  234. package/dist/plus/ai/utils/ai.utils.js.map +1 -1
  235. package/dist/plus/ai/utils/results.utils.d.ts.map +1 -1
  236. package/dist/plus/ai/utils/results.utils.js.map +1 -1
  237. package/dist/plus/git-github/api/github.d.ts +12 -1
  238. package/dist/plus/git-github/api/github.d.ts.map +1 -1
  239. package/dist/plus/git-github/api/github.js +245 -51
  240. package/dist/plus/git-github/api/github.js.map +1 -1
  241. package/dist/plus/git-github/api/types.d.ts +8 -0
  242. package/dist/plus/git-github/api/types.d.ts.map +1 -1
  243. package/dist/plus/git-github/models.d.ts +1 -1
  244. package/dist/plus/git-github/models.d.ts.map +1 -1
  245. package/dist/plus/git-github/models.js.map +1 -1
  246. package/dist/plus/git-github/providers/github/blame.d.ts.map +1 -1
  247. package/dist/plus/git-github/providers/github/blame.js.map +1 -1
  248. package/dist/plus/git-github/providers/github/branches.d.ts +5 -1
  249. package/dist/plus/git-github/providers/github/branches.d.ts.map +1 -1
  250. package/dist/plus/git-github/providers/github/branches.js +47 -24
  251. package/dist/plus/git-github/providers/github/branches.js.map +1 -1
  252. package/dist/plus/git-github/providers/github/commits.d.ts.map +1 -1
  253. package/dist/plus/git-github/providers/github/commits.js +144 -92
  254. package/dist/plus/git-github/providers/github/commits.js.map +1 -1
  255. package/dist/plus/git-github/providers/github/diff.d.ts +1 -1
  256. package/dist/plus/git-github/providers/github/diff.d.ts.map +1 -1
  257. package/dist/plus/git-github/providers/github/diff.js +23 -9
  258. package/dist/plus/git-github/providers/github/diff.js.map +1 -1
  259. package/dist/plus/git-github/providers/github/refs.d.ts +9 -1
  260. package/dist/plus/git-github/providers/github/refs.d.ts.map +1 -1
  261. package/dist/plus/git-github/providers/github/refs.js +39 -14
  262. package/dist/plus/git-github/providers/github/refs.js.map +1 -1
  263. package/dist/plus/git-github/providers/github/revision.d.ts.map +1 -1
  264. package/dist/plus/git-github/providers/github/revision.js +1 -2
  265. package/dist/plus/git-github/providers/github/revision.js.map +1 -1
  266. package/dist/plus/git-github/providers/github/status.d.ts +4 -1
  267. package/dist/plus/git-github/providers/github/status.d.ts.map +1 -1
  268. package/dist/plus/git-github/providers/github/status.js +1 -1
  269. package/dist/plus/git-github/providers/github/status.js.map +1 -1
  270. package/dist/plus/git-github/providers/github/tags.d.ts.map +1 -1
  271. package/dist/plus/git-github/providers/github/tags.js +35 -36
  272. package/dist/plus/git-github/providers/github/tags.js.map +1 -1
  273. package/dist/plus/git-github/providers/githubGitProvider.d.ts.map +1 -1
  274. package/dist/plus/git-github/providers/githubGitProvider.js.map +1 -1
  275. package/dist/utils/color.d.ts.map +1 -1
  276. package/dist/utils/color.js.map +1 -1
  277. package/dist/utils/date.d.ts.map +1 -1
  278. package/dist/utils/date.js +6 -0
  279. package/dist/utils/date.js.map +1 -1
  280. package/dist/utils/debounce.js +1 -1
  281. package/dist/utils/decorators/serializable.d.ts.map +1 -1
  282. package/dist/utils/decorators/serializable.js.map +1 -1
  283. package/dist/utils/dedupedAsyncCache.d.ts +37 -0
  284. package/dist/utils/dedupedAsyncCache.d.ts.map +1 -0
  285. package/dist/utils/dedupedAsyncCache.js +68 -0
  286. package/dist/utils/dedupedAsyncCache.js.map +1 -0
  287. package/dist/utils/diff.d.ts +2 -0
  288. package/dist/utils/diff.d.ts.map +1 -0
  289. package/dist/utils/diff.js +53 -0
  290. package/dist/utils/diff.js.map +1 -0
  291. package/dist/utils/disposable.d.ts.map +1 -1
  292. package/dist/utils/disposable.js.map +1 -1
  293. package/dist/utils/env/node/exec.d.ts.map +1 -1
  294. package/dist/utils/env/node/exec.js.map +1 -1
  295. package/dist/utils/error.d.ts.map +1 -1
  296. package/dist/utils/error.js.map +1 -1
  297. package/dist/utils/iterable.d.ts +7 -1
  298. package/dist/utils/iterable.d.ts.map +1 -1
  299. package/dist/utils/iterable.js +12 -4
  300. package/dist/utils/iterable.js.map +1 -1
  301. package/dist/utils/logger.d.ts.map +1 -1
  302. package/dist/utils/logger.js.map +1 -1
  303. package/dist/utils/lruMap.d.ts +9 -0
  304. package/dist/utils/lruMap.d.ts.map +1 -1
  305. package/dist/utils/lruMap.js +26 -4
  306. package/dist/utils/lruMap.js.map +1 -1
  307. package/dist/utils/markdown.d.ts.map +1 -1
  308. package/dist/utils/markdown.js.map +1 -1
  309. package/dist/utils/mru.d.ts.map +1 -1
  310. package/dist/utils/mru.js.map +1 -1
  311. package/dist/utils/object.d.ts +22 -1
  312. package/dist/utils/object.d.ts.map +1 -1
  313. package/dist/utils/object.js +56 -3
  314. package/dist/utils/object.js.map +1 -1
  315. package/dist/utils/paging.js +14 -15
  316. package/dist/utils/paging.js.map +1 -1
  317. package/dist/utils/path.d.ts.map +1 -1
  318. package/dist/utils/path.js.map +1 -1
  319. package/dist/utils/promise.d.ts +1 -0
  320. package/dist/utils/promise.d.ts.map +1 -1
  321. package/dist/utils/promise.js +17 -0
  322. package/dist/utils/promise.js.map +1 -1
  323. package/dist/utils/promiseCache.d.ts +10 -0
  324. package/dist/utils/promiseCache.d.ts.map +1 -1
  325. package/dist/utils/promiseCache.js +34 -4
  326. package/dist/utils/promiseCache.js.map +1 -1
  327. package/dist/utils/string.d.ts.map +1 -1
  328. package/dist/utils/string.js +1 -1
  329. package/dist/utils/string.js.map +1 -1
  330. package/dist/utils/uri.d.ts.map +1 -1
  331. package/dist/utils/uri.js.map +1 -1
  332. package/dist/utils/version.d.ts.map +1 -1
  333. package/dist/utils/version.js.map +1 -1
  334. package/package.json +13 -1
  335. package/src/git/cache.ts +192 -90
  336. package/src/git/context.ts +21 -0
  337. package/src/git/features.ts +2 -0
  338. package/src/git/models/commit.ts +1 -0
  339. package/src/git/models/graph.ts +97 -0
  340. package/src/git/models/reference.ts +33 -0
  341. package/src/git/models/remote.ts +1 -0
  342. package/src/git/models/remoteProvider.ts +1 -0
  343. package/src/git/models/repository.ts +105 -96
  344. package/src/git/models/search.ts +2 -2
  345. package/src/git/models/statusFile.ts +12 -0
  346. package/src/git/models/worktree.ts +10 -2
  347. package/src/git/parsers/diffParser.ts +2 -0
  348. package/src/git/providers/branches.ts +22 -4
  349. package/src/git/providers/commits.ts +4 -0
  350. package/src/git/providers/config.ts +3 -5
  351. package/src/git/providers/operations.ts +65 -1
  352. package/src/git/providers/pausedOperations.ts +1 -0
  353. package/src/git/providers/refs.ts +32 -3
  354. package/src/git/providers/staging.ts +12 -0
  355. package/src/git/providers/status.ts +6 -1
  356. package/src/git/remotes/azure-devops.ts +2 -0
  357. package/src/git/remotes/custom.ts +23 -0
  358. package/src/git/{exec.types.ts → run.types.ts} +22 -2
  359. package/src/git/service.ts +1 -0
  360. package/src/git/utils/autolink.utils.ts +1 -0
  361. package/src/git/utils/blame.utils.ts +2 -0
  362. package/src/git/utils/conflictResolution.utils.ts +31 -0
  363. package/src/git/utils/contributor.utils.ts +24 -0
  364. package/src/git/utils/fileStatus.utils.ts +7 -1
  365. package/src/git/utils/reachability.utils.ts +109 -0
  366. package/src/git/utils/search.utils.ts +2 -0
  367. package/src/git/utils/sorting.ts +17 -0
  368. package/src/git/utils/status.utils.ts +1 -0
  369. package/src/git/watching/classifyChange.ts +9 -0
  370. package/src/git/watching/gitIgnoreFilter.ts +2 -2
  371. package/src/git/watching/initWatcher.ts +2 -0
  372. package/src/git/watching/watchGroup.ts +3 -0
  373. package/src/git/watching/watchService.ts +45 -7
  374. package/src/git/watching/watchSession.ts +71 -55
  375. package/src/git-cli/exec/exec.types.ts +2 -2
  376. package/src/git-cli/exec/git.ts +6 -5
  377. package/src/git-cli/exec/gitQueue.ts +41 -12
  378. package/src/git-cli/parsers/blameParser.ts +1 -0
  379. package/src/git-cli/parsers/logParser.ts +1 -0
  380. package/src/git-cli/parsers/refParser.ts +33 -34
  381. package/src/git-cli/providers/blame.ts +1 -0
  382. package/src/git-cli/providers/branches.ts +165 -93
  383. package/src/git-cli/providers/commits.ts +107 -37
  384. package/src/git-cli/providers/config.ts +101 -48
  385. package/src/git-cli/providers/diff.ts +10 -3
  386. package/src/git-cli/providers/graph.ts +156 -16
  387. package/src/git-cli/providers/operations.ts +208 -83
  388. package/src/git-cli/providers/patch.ts +1 -0
  389. package/src/git-cli/providers/pausedOperations.ts +8 -0
  390. package/src/git-cli/providers/refs.ts +247 -3
  391. package/src/git-cli/providers/revision.ts +2 -2
  392. package/src/git-cli/providers/staging.ts +33 -0
  393. package/src/git-cli/providers/stash.ts +20 -2
  394. package/src/git-cli/providers/status.ts +24 -5
  395. package/src/git-cli/providers/tags.ts +15 -16
  396. package/src/ipc/discovery.ts +186 -0
  397. package/src/ipc/ipcServer.ts +171 -0
  398. package/src/plus/agents/providers/claudeCodeProvider.ts +2122 -0
  399. package/src/plus/agents/providers/claudeCodeTranscript.ts +214 -0
  400. package/src/plus/agents/sanitizePrompt.ts +109 -0
  401. package/src/plus/agents/stateMachine.ts +197 -0
  402. package/src/plus/agents/types.ts +325 -0
  403. package/src/plus/ai/errors.ts +40 -0
  404. package/src/plus/ai/models/model.ts +1 -0
  405. package/src/plus/ai/models/promptTemplates.ts +13 -0
  406. package/src/plus/ai/models/provider.ts +7 -1
  407. package/src/plus/ai/prompts.ts +49 -8
  408. package/src/plus/ai/providers/gitkrakenProvider.ts +4 -0
  409. package/src/plus/ai/providers/openAICompatibleProviderBase.ts +21 -4
  410. package/src/plus/ai/utils/ai.utils.ts +2 -0
  411. package/src/plus/ai/utils/results.utils.ts +2 -0
  412. package/src/plus/git-github/api/github.ts +315 -57
  413. package/src/plus/git-github/api/types.ts +15 -0
  414. package/src/plus/git-github/models.ts +1 -1
  415. package/src/plus/git-github/providers/github/blame.ts +2 -0
  416. package/src/plus/git-github/providers/github/branches.ts +61 -31
  417. package/src/plus/git-github/providers/github/commits.ts +231 -160
  418. package/src/plus/git-github/providers/github/diff.ts +27 -19
  419. package/src/plus/git-github/providers/github/refs.ts +58 -23
  420. package/src/plus/git-github/providers/github/revision.ts +1 -2
  421. package/src/plus/git-github/providers/github/status.ts +6 -1
  422. package/src/plus/git-github/providers/github/tags.ts +61 -62
  423. package/src/plus/git-github/providers/githubGitProvider.ts +1 -0
  424. package/src/utils/color.ts +2 -0
  425. package/src/utils/date.ts +5 -0
  426. package/src/utils/debounce.ts +1 -1
  427. package/src/utils/decorators/serializable.ts +1 -0
  428. package/src/utils/dedupedAsyncCache.ts +74 -0
  429. package/src/utils/diff.ts +61 -0
  430. package/src/utils/disposable.ts +2 -0
  431. package/src/utils/env/node/exec.ts +2 -0
  432. package/src/utils/error.ts +1 -0
  433. package/src/utils/iterable.ts +17 -3
  434. package/src/utils/logger.ts +1 -0
  435. package/src/utils/lruMap.ts +29 -4
  436. package/src/utils/markdown.ts +1 -0
  437. package/src/utils/mru.ts +2 -0
  438. package/src/utils/object.ts +55 -4
  439. package/src/utils/paging.ts +14 -14
  440. package/src/utils/path.ts +1 -0
  441. package/src/utils/promise.ts +22 -0
  442. package/src/utils/promiseCache.ts +48 -16
  443. package/src/utils/string.ts +3 -1
  444. package/src/utils/uri.ts +1 -0
  445. package/src/utils/version.ts +1 -0
  446. package/dist/git/exec.types.d.ts.map +0 -1
  447. package/dist/git/exec.types.js +0 -2
  448. package/dist/git/exec.types.js.map +0 -1
@@ -0,0 +1,2122 @@
1
+ import { readdir, readFile } from 'fs/promises';
2
+ import { join } from 'path';
3
+ import { gate } from '../../../utils/decorators/gate.js';
4
+ import type { UnifiedDisposable } from '../../../utils/disposable.js';
5
+ import { disposableInterval } from '../../../utils/disposable.js';
6
+ import { Emitter } from '../../../utils/event.js';
7
+ import { Logger } from '../../../utils/logger.js';
8
+ import { normalizePath } from '../../../utils/path.js';
9
+ import { prepareStoredPrompt } from '../sanitizePrompt.js';
10
+ import {
11
+ classifyPermissionKind,
12
+ deriveStatusFromEvent,
13
+ describeToolInput,
14
+ extractPlanSummary,
15
+ extractQuestionDetails,
16
+ getToolFilePath,
17
+ getToolReadPath,
18
+ isProcessAlive,
19
+ rehydrateSubagents,
20
+ } from '../stateMachine.js';
21
+ import type {
22
+ AgentProviderCallbacks,
23
+ AgentSession,
24
+ AgentSessionPhase,
25
+ AgentSessionProvider,
26
+ AgentSessionStatus,
27
+ ClaudeCodeHookEvent,
28
+ PendingPermission,
29
+ PendingPermissionKind,
30
+ PermissionDecision,
31
+ PermissionSuggestion,
32
+ } from '../types.js';
33
+ import { getPhaseForStatus } from '../types.js';
34
+ import { ClaudeCodeTranscriptReader } from './claudeCodeTranscript.js';
35
+
36
+ interface AgentSessionEvent {
37
+ event: ClaudeCodeHookEvent;
38
+ sessionId: string;
39
+ cwd: string;
40
+ /** The agent's launch directory, captured first-hand by the CLI and preserved across events.
41
+ * Optional — older CLIs don't send it, in which case consumers fall back to deriving it from
42
+ * the first-seen `cwd`. */
43
+ initialCwd?: string;
44
+ pid?: number;
45
+ source?: string;
46
+ model?: string;
47
+ reason?: string;
48
+ toolName?: string;
49
+ agentId?: string;
50
+ agentType?: string;
51
+ planFile?: string;
52
+ notificationType?: string;
53
+ sessionName?: string;
54
+ prompt?: string;
55
+ firstPrompt?: string;
56
+ toolInput?: Record<string, unknown>;
57
+ permissionSuggestions?: PermissionSuggestion[];
58
+ hookInput?: Record<string, unknown>;
59
+ }
60
+
61
+ interface PermissionResponse {
62
+ hookSpecificOutput: {
63
+ hookEventName: 'PermissionRequest';
64
+ decision: {
65
+ behavior: PermissionDecision;
66
+ updatedPermissions?: PermissionSuggestion[];
67
+ };
68
+ };
69
+ }
70
+
71
+ interface PendingPermissionEntry {
72
+ resolve(response: PermissionResponse): void;
73
+ reject(reason: Error): void;
74
+ toolName: string;
75
+ toolDescription: string;
76
+ }
77
+
78
+ interface SessionBookkeeping {
79
+ activeToolCount: number;
80
+ pendingPermission?: PendingPermission;
81
+ /** Refcount of in-flight file-mutating tool calls per absolute path. Keyed by path (not
82
+ * `tool_use_id`) because the GK CLI hook payload doesn't reliably surface `tool_use_id` —
83
+ * refcounting per path is order-independent and tolerant of out-of-order Pre/Post pairs. */
84
+ currentFileCounts: Map<string, number>;
85
+ /** Per-path cooldown timers scheduled when a path's refcount hits zero, so back-to-back
86
+ * Edit/Write to the same path doesn't flicker the WIP decoration. A fresh Pre on the same
87
+ * path cancels its pending clear. */
88
+ pendingFileClears: Map<string, NodeJS.Timeout>;
89
+ /** Refcount of in-flight read-only file tool calls (Read/NotebookRead) per absolute path.
90
+ * Mirrors {@link currentFileCounts} so consumers can distinguish "looking at" from "working
91
+ * on". */
92
+ currentReadCounts: Map<string, number>;
93
+ /** Per-path cooldown timers for reads — same semantics as {@link pendingFileClears}, keeps a
94
+ * recently-read file visible briefly after the read completes. */
95
+ pendingReadClears: Map<string, NodeJS.Timeout>;
96
+ /** Epoch-ms timestamps of the last `PreToolUse` for each kind, per absolute path. Stamped on
97
+ * every Pre; the per-path `edit`/`read` slot survives through the cooldown window and is
98
+ * dropped only when the corresponding count map drops the path. Serialized into
99
+ * `AgentSession.fileActivity` as `editedAt = now - timestamp.edit` (relative ms), so the
100
+ * webview can compute heat decay without dealing with host clock skew. */
101
+ lastTouchedAt: Map<string, { edit?: number; read?: number }>;
102
+ /** Set to true when `resolveGitInfo` for the session's current cwd returned `undefined`
103
+ * (cwd is not a git repo). Prevents the `ensureSession` retry from firing forever on
104
+ * every subsequent hook event. Cleared when the session's cwd changes (re-resolution
105
+ * warranted) or on resetBookkeeping (Stop/SessionStart). */
106
+ gitInfoUnresolvable: boolean;
107
+ /** Phase that immediately preceded the current one, with its original `phaseSince`. Used by
108
+ * `resolvePhaseSince` to restore continuity when phase oscillates back within a short
109
+ * window (e.g., Stop → idle → working after a continuation crosses the debounce). */
110
+ priorPhase?: { phase: AgentSessionPhase; phaseSince: Date };
111
+ }
112
+
113
+ const staleCheckIntervalMs = 15 * 60 * 1000; // 15 minutes
114
+ /** Default cooldown between PostToolUse and dropping the file from `fileActivity`. Held long
115
+ * enough that the treemap activity overlay can render a decay tail well past the moment the tool
116
+ * call completed. The host may override per-call via `AgentProviderCallbacks.getActivityDecayMs`
117
+ * (driven by the user's `gitlens.graph.experimental.visualizations.activityDecay` setting). */
118
+ const defaultActivityDecayMs = 5 * 60 * 1000; // 5 minutes
119
+
120
+ /** Grace period before `Stop` commits to `idle`. Long enough to absorb a same-turn continuation
121
+ * (hook-driven re-prompt, IPC event reordering, auto-resume); short enough that legitimate idle
122
+ * is visible promptly. */
123
+ const stopToIdleDebounceMs = 750;
124
+
125
+ /** If a phase transitions away and then back within this window, restore the prior `phaseSince`
126
+ * so the displayed elapsed time doesn't snap to 0 on transient oscillations. Slightly longer
127
+ * than `stopToIdleDebounceMs` so a continuation that just barely crosses the debounce still
128
+ * restores continuity. */
129
+ const phaseSinceRestoreWindowMs = 2000;
130
+
131
+ interface SessionFileData {
132
+ sessionId: string;
133
+ event: string;
134
+ cwd: string;
135
+ /** CLI-provided launch directory; absent on older CLIs (fall back to `cwd`). */
136
+ initialCwd?: string;
137
+ pid: number;
138
+ toolName?: string | null;
139
+ agentId?: string | null;
140
+ agentType?: string | null;
141
+ source?: string | null;
142
+ model?: string | null;
143
+ updatedAt: string;
144
+ subagents?: { agentId: string; agentType: string }[];
145
+ planFile?: string | null;
146
+ sessionName?: string | null;
147
+ prompt?: string | null;
148
+ firstPrompt?: string | null;
149
+ }
150
+
151
+ interface DiscoveryFile {
152
+ token: string;
153
+ address: string;
154
+ port?: number;
155
+ workspacePaths?: string[];
156
+ }
157
+
158
+ interface SessionContext {
159
+ pid?: number;
160
+ workspacePath?: string;
161
+ isInWorkspace?: boolean;
162
+ cwd?: string;
163
+ /** CLI-provided launch directory (authoritative when present). */
164
+ initialCwd?: string;
165
+ planFile?: string;
166
+ sessionName?: string;
167
+ }
168
+
169
+ type SerializedAgentSession = Omit<AgentSession, 'lastActivity' | 'phaseSince' | 'subagents'> & {
170
+ lastActivity: string;
171
+ phaseSince: string;
172
+ subagents?: (Omit<AgentSession, 'lastActivity' | 'phaseSince'> & {
173
+ lastActivity: string;
174
+ phaseSince: string;
175
+ })[];
176
+ };
177
+
178
+ /** Normalize a workspace path to GitLens canonical form (forward slashes, lower-case drive letter
179
+ * on Windows). Comparisons throughout the home view assume this form, but `_workspacePaths` and
180
+ * peer-session `workspacePath` values originate from `Uri.fsPath`, which on Windows uses
181
+ * backslashes and preserves drive-letter casing. Without this, `session.workspacePath ===
182
+ * repository.path` always fails on Windows. */
183
+ function normalizeWorkspacePath(value: string | null | undefined): string | undefined {
184
+ return value ? normalizePath(value) : undefined;
185
+ }
186
+
187
+ type FileActivityEntry = NonNullable<AgentSession['fileActivity']>[number];
188
+
189
+ /** Structural comparison of two `fileActivity` arrays — same set of paths, same kinds present,
190
+ * same `reading`/`editing` flags. Ignores numeric timestamp values because those drift between
191
+ * every call (Date.now() advances) even when nothing has actually changed. Used to gate event
192
+ * firing: timestamps still get refreshed via the `_sessions[]` write, but a fire only happens
193
+ * when the structural shape changed. Treats `undefined` and `[]` as equal. */
194
+ function fileActivityStructurallyEqual(
195
+ a: readonly FileActivityEntry[] | undefined,
196
+ b: readonly FileActivityEntry[] | undefined,
197
+ ): boolean {
198
+ const aLen = a?.length ?? 0;
199
+ const bLen = b?.length ?? 0;
200
+ if (aLen !== bLen) return false;
201
+ if (aLen === 0) return true;
202
+
203
+ const byPath = new Map<string, FileActivityEntry>();
204
+ for (const entry of a!) {
205
+ byPath.set(entry.path, entry);
206
+ }
207
+ for (const entry of b!) {
208
+ const other = byPath.get(entry.path);
209
+ if (other == null) return false;
210
+ if ((entry.readAt != null) !== (other.readAt != null)) return false;
211
+ if ((entry.editedAt != null) !== (other.editedAt != null)) return false;
212
+ if ((entry.reading ?? false) !== (other.reading ?? false)) return false;
213
+ if ((entry.editing ?? false) !== (other.editing ?? false)) return false;
214
+ }
215
+ return true;
216
+ }
217
+
218
+ export class ClaudeCodeProvider implements AgentSessionProvider {
219
+ readonly id = 'claudeCode';
220
+ readonly name = 'Claude Code';
221
+ readonly icon = 'robot';
222
+
223
+ private readonly _onDidChangeSessions = new Emitter<void>();
224
+ readonly onDidChangeSessions = this._onDidChangeSessions.event;
225
+
226
+ private _sessions: AgentSession[] = [];
227
+ private _ipcStarted = false;
228
+ private _disposed = false;
229
+ private _handlerDisposables: UnifiedDisposable[] = [];
230
+ private readonly _pendingPermissions = new Map<string, PendingPermissionEntry>();
231
+ private readonly _resolveGitInfoInFlight = new Set<string>();
232
+ private readonly _sessionBookkeeping = new Map<string, SessionBookkeeping>();
233
+ /** Per-session timers for the deferred `Stop → idle` commit. The handle is cleared (and the
234
+ * transition cancelled) by any non-idle status update arriving before the timer fires. */
235
+ private readonly _pendingIdleTimers = new Map<string, NodeJS.Timeout>();
236
+ private _workspacePaths: string[] = [];
237
+ private _staleCheckTimer: UnifiedDisposable | undefined;
238
+ /** Whether Claude hooks are installed, pushed by the host via {@link setClaudeHooksInstalled}.
239
+ * Fail-open (`true`) until the first push lands so a fresh window with real hooks isn't
240
+ * suppressed on its first ticks. Gates the reconciliation poll in {@link syncSessions}. */
241
+ private _claudeHooksInstalled = true;
242
+ protected _transcriptReader: ClaudeCodeTranscriptReader = new ClaudeCodeTranscriptReader();
243
+
244
+ constructor(private readonly callbacks: AgentProviderCallbacks) {}
245
+
246
+ get sessions(): readonly AgentSession[] {
247
+ return this._sessions;
248
+ }
249
+
250
+ start(workspacePaths: string[]): void {
251
+ this._workspacePaths = workspacePaths.map(p => normalizePath(p));
252
+ void this.ensureIpcServer();
253
+ }
254
+
255
+ stop(): void {
256
+ // IPC server stays up intentionally — hooks fire even when the window is unfocused
257
+ }
258
+
259
+ updateWorkspacePaths(workspacePaths: string[]): void {
260
+ this._workspacePaths = workspacePaths.map(p => normalizePath(p));
261
+
262
+ let changed = false;
263
+ for (let i = 0; i < this._sessions.length; i++) {
264
+ const s = this._sessions[i];
265
+ const nextWorkspacePath = s.cwd != null ? this.resolveWorkspacePath(s.cwd) : undefined;
266
+ const nextIsInWorkspace = nextWorkspacePath != null;
267
+ let subagentsChanged = false;
268
+ const subagents = s.subagents?.map(sub => {
269
+ const subNextWorkspacePath = sub.cwd != null ? this.resolveWorkspacePath(sub.cwd) : nextWorkspacePath;
270
+ const subNextIsInWorkspace = sub.cwd != null ? subNextWorkspacePath != null : nextIsInWorkspace;
271
+ if (sub.isInWorkspace === subNextIsInWorkspace && sub.workspacePath === subNextWorkspacePath) {
272
+ return sub;
273
+ }
274
+
275
+ subagentsChanged = true;
276
+ return { ...sub, isInWorkspace: subNextIsInWorkspace, workspacePath: subNextWorkspacePath };
277
+ });
278
+ if (s.isInWorkspace !== nextIsInWorkspace || s.workspacePath !== nextWorkspacePath || subagentsChanged) {
279
+ this._sessions[i] = {
280
+ ...s,
281
+ isInWorkspace: nextIsInWorkspace,
282
+ workspacePath: nextWorkspacePath,
283
+ subagents: subagents,
284
+ };
285
+ changed = true;
286
+ }
287
+ }
288
+ if (changed) {
289
+ this._onDidChangeSessions.fire();
290
+ }
291
+
292
+ if (!this._ipcStarted) {
293
+ // IPC wasn't bootstrapped yet — start it. ensureIpcServer publishes with the
294
+ // current `_workspacePaths`.
295
+ void this.ensureIpcServer();
296
+ } else {
297
+ // Re-publish so the agents discovery file reflects the new workspacePaths.
298
+ this.callbacks.ipc
299
+ .publishAgents(this._workspacePaths)
300
+ .catch((ex: unknown) =>
301
+ Logger.error(ex, 'ClaudeCodeProvider.updateWorkspacePaths: publishAgents failed'),
302
+ );
303
+ }
304
+ }
305
+
306
+ setClaudeHooksInstalled(installed: boolean): void {
307
+ const wasOff = !this._claudeHooksInstalled;
308
+ this._claudeHooksInstalled = installed;
309
+ // On a fresh off→on transition, reconcile immediately rather than waiting up to a full
310
+ // interval — picks up any session that was already running before hooks were installed.
311
+ // This is a deliberate discovery pass (like the cold-start bootstrap), not a routine tick:
312
+ // hooks were just off, so the live path couldn't have seen these sessions and anything we
313
+ // find here is expected, not drift. Run it ungated so it neither skips nor reports drift
314
+ // telemetry (an ungated `syncSessions()` always polls and never emits `onSyncDiscrepancy`).
315
+ if (installed && wasOff) {
316
+ void this.syncSessions();
317
+ }
318
+ }
319
+
320
+ dispose(): void {
321
+ this._disposed = true;
322
+ this.stop();
323
+ this._staleCheckTimer?.dispose();
324
+ this._staleCheckTimer = undefined;
325
+ for (const d of this._handlerDisposables) {
326
+ d.dispose();
327
+ }
328
+ this._handlerDisposables = [];
329
+ for (const pending of this._pendingPermissions.values()) {
330
+ pending.reject(new Error('Provider disposed'));
331
+ }
332
+ this._pendingPermissions.clear();
333
+ for (const bk of this._sessionBookkeeping.values()) {
334
+ this.cancelPendingFileClears(bk);
335
+ this.cancelPendingReadClears(bk);
336
+ }
337
+ for (const timer of this._pendingIdleTimers.values()) {
338
+ clearTimeout(timer);
339
+ }
340
+ this._pendingIdleTimers.clear();
341
+ this._sessionBookkeeping.clear();
342
+ if (this._ipcStarted) {
343
+ void this.callbacks.ipc.unpublishAgents();
344
+ this._ipcStarted = false;
345
+ }
346
+ this._onDidChangeSessions.dispose();
347
+ }
348
+
349
+ [Symbol.dispose](): void {
350
+ this.dispose();
351
+ }
352
+
353
+ @gate<typeof ClaudeCodeProvider.prototype.ensureIpcServer>()
354
+ private async ensureIpcServer(): Promise<void> {
355
+ if (this._ipcStarted || this._disposed) return;
356
+
357
+ // Register handlers before any async work so blocking permission requests aren't delayed.
358
+ const handlers: UnifiedDisposable[] = [
359
+ this.callbacks.ipc.registerHandler('agents/session', (request, searchParams) => {
360
+ if (request != null) {
361
+ const isBlocking = searchParams.get('blocking') === 'true';
362
+ return this.handleSessionEvent(request as AgentSessionEvent, isBlocking);
363
+ }
364
+ return Promise.resolve();
365
+ }),
366
+ this.callbacks.ipc.registerHandler('agents/sessions/list', () =>
367
+ Promise.resolve(
368
+ this._sessions.map(s => ({
369
+ ...s,
370
+ lastActivity: s.lastActivity.toISOString(),
371
+ phaseSince: s.phaseSince.toISOString(),
372
+ subagents: s.subagents?.map(sub => ({
373
+ ...sub,
374
+ lastActivity: sub.lastActivity.toISOString(),
375
+ phaseSince: sub.phaseSince.toISOString(),
376
+ })),
377
+ })),
378
+ ),
379
+ ),
380
+ this.callbacks.ipc.registerHandler('agents/sessions/open', async request => {
381
+ const sessionId = (request as { sessionId?: string } | undefined)?.sessionId;
382
+ if (!sessionId || this.callbacks.openSessionInClaudeExtension == null) {
383
+ return { opened: false };
384
+ }
385
+
386
+ try {
387
+ await this.callbacks.openSessionInClaudeExtension(sessionId);
388
+ return { opened: true };
389
+ } catch (ex) {
390
+ Logger.warn(
391
+ `ClaudeCodeProvider.agents/sessions/open: ${ex instanceof Error ? ex.message : String(ex)}`,
392
+ );
393
+ return { opened: false };
394
+ }
395
+ }),
396
+ ];
397
+
398
+ try {
399
+ await this.callbacks.ipc.publishAgents(this._workspacePaths);
400
+ } catch (ex) {
401
+ // Unwind so a retry can re-register without hitting "already registered".
402
+ for (const d of handlers) {
403
+ d.dispose();
404
+ }
405
+ Logger.error(ex, 'ClaudeCodeProvider.ensureIpcServer');
406
+ return;
407
+ }
408
+
409
+ // `publishAgents` no-ops silently if the IPC server failed to start; don't seal
410
+ // retries in that case — the next call should re-attempt.
411
+ if (this.callbacks.ipc.port == null) {
412
+ for (const d of handlers) {
413
+ d.dispose();
414
+ }
415
+ return;
416
+ }
417
+
418
+ if (this._disposed) {
419
+ for (const d of handlers) {
420
+ d.dispose();
421
+ }
422
+ return;
423
+ }
424
+
425
+ this._handlerDisposables = handlers;
426
+ this._ipcStarted = true;
427
+
428
+ try {
429
+ await this.syncSessions();
430
+ if (this._disposed) return;
431
+
432
+ void this.querySiblingWindowSessions();
433
+
434
+ this._staleCheckTimer?.dispose();
435
+ this._staleCheckTimer = disposableInterval(
436
+ () => void this.syncSessions({ gate: true }),
437
+ staleCheckIntervalMs,
438
+ );
439
+ } catch (ex) {
440
+ Logger.error(ex, 'ClaudeCodeProvider.ensureIpcServer');
441
+ }
442
+ }
443
+
444
+ private handleSessionEvent(event: AgentSessionEvent, isBlocking: boolean): Promise<PermissionResponse | void> {
445
+ const workspacePath = this.resolveWorkspacePath(event.cwd);
446
+ const eventContext: SessionContext = {
447
+ pid: event.pid,
448
+ workspacePath: workspacePath,
449
+ isInWorkspace: workspacePath != null,
450
+ cwd: event.cwd,
451
+ initialCwd: event.initialCwd,
452
+ planFile: event.planFile,
453
+ sessionName: event.sessionName,
454
+ };
455
+ const tag = this.sessionTag(event.sessionId, event.sessionName ?? 'unnamed');
456
+
457
+ if (event.event === 'SessionStart' || event.event === 'SessionEnd') {
458
+ Logger.info(`ClaudeCodeProvider.handleSessionEvent: ${event.event} ${tag}`);
459
+ } else {
460
+ Logger.debug(
461
+ `ClaudeCodeProvider.handleSessionEvent: ${event.event} ${tag}${event.toolName ? ` tool=${event.toolName}` : ''}${event.agentId ? ` agent=${event.agentId}` : ''}${event.notificationType ? ` type=${event.notificationType}` : ''}`,
462
+ );
463
+ }
464
+
465
+ switch (event.event) {
466
+ case 'SessionStart': {
467
+ const wasNew = this._sessions.findIndex(s => s.id === event.sessionId) < 0;
468
+ const { index } = this.ensureSession(event.sessionId, eventContext);
469
+
470
+ if (wasNew) {
471
+ // ensureSession created the session at 'idle' already. Just refresh pid if
472
+ // the event carries one — bookkeeping was implicitly clean.
473
+ this.resetBookkeeping(event.sessionId);
474
+ if (event.pid != null) {
475
+ const prev = this._sessions[index];
476
+ if (prev.pid !== event.pid) {
477
+ this._sessions[index] = { ...prev, pid: event.pid };
478
+ }
479
+ }
480
+ } else {
481
+ // SessionStart for a session we already track is almost always a CLI replay
482
+ // (resume/reconnect/hook re-init). Don't clobber live state — the next real
483
+ // event will re-establish status. Refresh pid only.
484
+ const prev = this._sessions[index];
485
+ if (event.pid != null && event.pid !== prev.pid) {
486
+ this._sessions[index] = { ...prev, pid: event.pid, lastActivity: new Date() };
487
+ }
488
+ }
489
+
490
+ this._onDidChangeSessions.fire();
491
+ void this.resolveTranscriptTitles(event.sessionId, event.cwd);
492
+ this.callbacks.onSessionStarted?.(this.id);
493
+ break;
494
+ }
495
+
496
+ case 'SessionEnd': {
497
+ const pending = this._pendingPermissions.get(event.sessionId);
498
+ if (pending != null) {
499
+ pending.reject(new Error('Session ended'));
500
+ this._pendingPermissions.delete(event.sessionId);
501
+ }
502
+ this.cancelPendingIdleTransition(event.sessionId);
503
+
504
+ const index = this._sessions.findIndex(s => s.id === event.sessionId);
505
+ if (index >= 0) {
506
+ this._sessions.splice(index, 1);
507
+ this._onDidChangeSessions.fire();
508
+ }
509
+ const bk = this._sessionBookkeeping.get(event.sessionId);
510
+ if (bk != null) {
511
+ this.cancelPendingFileClears(bk);
512
+ this.cancelPendingReadClears(bk);
513
+ }
514
+
515
+ this._sessionBookkeeping.delete(event.sessionId);
516
+ this._transcriptReader.forget(event.sessionId);
517
+ this.callbacks.onSessionEnded?.(this.id);
518
+ break;
519
+ }
520
+
521
+ case 'UserPromptSubmit': {
522
+ const { index } = this.ensureSession(event.sessionId, eventContext);
523
+ const cleaned = prepareStoredPrompt(event.prompt);
524
+ // A `UserPromptSubmit` whose payload sanitizes to nothing is harness-synthetic
525
+ // (e.g. background-bash <task-notification>, slash-command stdout echo). Treat it
526
+ // as informational so it doesn't flip the session to `thinking` or wipe a pending
527
+ // permission when no real user activity occurred.
528
+ if (!cleaned) break;
529
+
530
+ const existing = this._sessions[index];
531
+ this._sessions[index] = {
532
+ ...existing,
533
+ lastPrompt: cleaned,
534
+ firstPrompt: existing.firstPrompt ?? prepareStoredPrompt(event.firstPrompt),
535
+ };
536
+ this.clearStalePermission(event.sessionId, 'UserPromptSubmit');
537
+ this.updateSessionStatus(event.sessionId, 'thinking', eventContext);
538
+ break;
539
+ }
540
+
541
+ case 'PreToolUse': {
542
+ const bk = this.getBookkeeping(event.sessionId);
543
+ bk.activeToolCount++;
544
+ const filesChanged = this.trackToolUseFile(event.sessionId, event);
545
+ const readsChanged = this.trackToolUseRead(event.sessionId, event);
546
+ // Resolve a rich `Bash(grep …)`-style detail via the same tool_input passthrough the
547
+ // PermissionRequest handler uses, so working sessions surface what their tool is
548
+ // actually doing — not just the bare tool name. Falls back to bare name when no
549
+ // toolInput is available (older CLI / unhandled tool).
550
+ const hookInput = event.hookInput;
551
+ const toolInput = (hookInput?.tool_input as Record<string, unknown> | undefined) ?? event.toolInput;
552
+ const toolName = (hookInput?.tool_name as string | undefined) ?? event.toolName ?? '';
553
+ const statusDetail =
554
+ toolInput != null && toolName ? describeToolInput(toolName, toolInput) : toolName || undefined;
555
+ // Capture pre-update status so we can tell whether the upcoming updateSessionStatus
556
+ // will fire on its own — needed to avoid dropping fileActivity changes when the
557
+ // session is already in tool_use (back-to-back tool calls).
558
+ const sessionIndex = this._sessions.findIndex(s => s.id === event.sessionId);
559
+ const prevStatus = sessionIndex >= 0 ? this._sessions[sessionIndex].status : undefined;
560
+ const prevDetail = sessionIndex >= 0 ? this._sessions[sessionIndex].statusDetail : undefined;
561
+ const statusWillFire = prevStatus !== 'tool_use' || prevDetail !== statusDetail;
562
+ this.updateSessionStatus(event.sessionId, 'tool_use', {
563
+ ...eventContext,
564
+ statusDetail: statusDetail,
565
+ });
566
+ if ((filesChanged || readsChanged) && !statusWillFire) {
567
+ this._onDidChangeSessions.fire();
568
+ }
569
+ break;
570
+ }
571
+
572
+ case 'PostToolUse':
573
+ case 'PostToolUseFailure': {
574
+ this.clearStalePermission(event.sessionId, event.event);
575
+ const bk = this.getBookkeeping(event.sessionId);
576
+ bk.activeToolCount = Math.max(0, bk.activeToolCount - 1);
577
+ // Cooldown the file-edit decoration instead of dropping it immediately, so a quick
578
+ // follow-up Edit/Write to the same path doesn't blink. The deferred clear fires its
579
+ // own change event when it expires. The immediate re-sync inside each flips
580
+ // `editing`/`reading` off the moment refcount hits zero, though — capture whether that
581
+ // changed the published list so we fire it ourselves when the status update below short-
582
+ // circuits (a parallel tool is still in flight, so activeToolCount stays > 0).
583
+ const filesChanged = this.scheduleClearToolUseFile(event.sessionId, event);
584
+ const readsChanged = this.scheduleClearToolUseRead(event.sessionId, event);
585
+ if (bk.activeToolCount === 0) {
586
+ // updateSessionStatus short-circuits without firing when status+detail are unchanged
587
+ // (already 'thinking' with no detail) — which would drop the editing/reading flag-flip
588
+ // above and leave a stale live pulse. Capture whether it will fire on its own and, if
589
+ // not, fire explicitly — mirrors the PreToolUse / PermissionDenied guards.
590
+ const idx = this._sessions.findIndex(s => s.id === event.sessionId);
591
+ const statusWillFire =
592
+ idx < 0 ||
593
+ this._sessions[idx].status !== 'thinking' ||
594
+ this._sessions[idx].statusDetail != null;
595
+ this.updateSessionStatus(event.sessionId, 'thinking', eventContext);
596
+ if ((filesChanged || readsChanged) && !statusWillFire) {
597
+ this._onDidChangeSessions.fire();
598
+ }
599
+ } else if (filesChanged || readsChanged) {
600
+ this._onDidChangeSessions.fire();
601
+ }
602
+ break;
603
+ }
604
+
605
+ case 'Stop':
606
+ case 'StopFailure': {
607
+ const pending = this._pendingPermissions.get(event.sessionId);
608
+ if (pending != null) {
609
+ pending.reject(new Error('Session stopped'));
610
+ this._pendingPermissions.delete(event.sessionId);
611
+ }
612
+ // Turn ended: stop the "live" pulse but keep the per-operation decay tail fading over
613
+ // the configured window (anchored at each tool's completion, not at the turn end). A full
614
+ // reset here would wipe the tail the instant the agent finishes a response.
615
+ this.settleBookkeeping(event.sessionId);
616
+ // Apply Stop's metadata synchronously so we don't carry stale context through the
617
+ // debounce window — any in-window mutations (e.g. CwdChanged) survive when the
618
+ // timer fires. ensureSession doesn't fire on metadata-only updates of existing
619
+ // sessions, so fire here to match the original Stop-handler's UI-update semantics.
620
+ const { changed } = this.ensureSession(event.sessionId, eventContext);
621
+ if (changed) {
622
+ this._onDidChangeSessions.fire();
623
+ }
624
+ // Defer the status change — if the agent continues within the debounce window
625
+ // (hook-driven re-prompt, IPC reordering, auto-resume), the next non-idle event
626
+ // cancels this and we never flick through idle.
627
+ this.scheduleIdleTransition(event.sessionId);
628
+ break;
629
+ }
630
+
631
+ case 'PreCompact':
632
+ this.updateSessionStatus(event.sessionId, 'compacting', eventContext);
633
+ break;
634
+
635
+ case 'PostCompact':
636
+ this.updateSessionStatus(event.sessionId, 'thinking', eventContext);
637
+ break;
638
+
639
+ case 'Notification':
640
+ switch (event.notificationType) {
641
+ case 'idle_prompt':
642
+ // No-op — session is already idle from the preceding stop/session-start event
643
+ break;
644
+ case 'permission_prompt':
645
+ case 'elicitation_dialog':
646
+ this.updateSessionStatus(event.sessionId, 'permission_requested', {
647
+ ...eventContext,
648
+ statusDetail: event.toolName,
649
+ });
650
+ break;
651
+ default:
652
+ // auth_success, unknown, or missing: no status change, just update timestamp
653
+ break;
654
+ }
655
+ break;
656
+
657
+ case 'PermissionRequest': {
658
+ // hookInput is the raw passthrough; fall back to top-level fields for older CLI versions.
659
+ const hookInput = event.hookInput;
660
+ const toolInput = (hookInput?.tool_input as Record<string, unknown> | undefined) ?? event.toolInput;
661
+ if (isBlocking && toolInput != null) {
662
+ const toolName = (hookInput?.tool_name as string | undefined) ?? event.toolName ?? '';
663
+ const toolDescription = describeToolInput(toolName, toolInput);
664
+ const toolInputDescription = (toolInput.description as string | undefined) || undefined;
665
+ const kind: PendingPermissionKind = classifyPermissionKind(toolName);
666
+
667
+ return new Promise<PermissionResponse>((resolve, reject) => {
668
+ const existing = this._pendingPermissions.get(event.sessionId);
669
+ if (existing != null) {
670
+ Logger.debug(
671
+ `ClaudeCodeProvider.handleSessionEvent: auto-denying stale permission ${tag} tool=${existing.toolName}`,
672
+ );
673
+ existing.resolve({
674
+ hookSpecificOutput: {
675
+ hookEventName: 'PermissionRequest',
676
+ decision: { behavior: 'deny' },
677
+ },
678
+ });
679
+ }
680
+
681
+ this._pendingPermissions.set(event.sessionId, {
682
+ resolve: resolve,
683
+ reject: reject,
684
+ toolName: toolName,
685
+ toolDescription: toolDescription,
686
+ });
687
+
688
+ // Plan-kind body: prefer the existing session's planFile (set via SessionStart/
689
+ // PostToolUse) and fall back to the event's planFile for cold-start cases.
690
+ const planFilePath =
691
+ kind === 'plan'
692
+ ? (this._sessions.find(s => s.id === event.sessionId)?.planFile ?? event.planFile)
693
+ : undefined;
694
+ const planSummary = kind === 'plan' ? extractPlanSummary(toolInput) : undefined;
695
+ const questionDetails = kind === 'question' ? extractQuestionDetails(toolInput) : undefined;
696
+
697
+ const permission: PendingPermission = {
698
+ kind: kind,
699
+ toolName: toolName,
700
+ toolDescription: toolDescription,
701
+ toolInputDescription: toolInputDescription,
702
+ suggestions:
703
+ (hookInput?.permission_suggestions as PermissionSuggestion[] | undefined) ??
704
+ event.permissionSuggestions,
705
+ planFilePath: planFilePath,
706
+ planSummary: planSummary,
707
+ questionText: questionDetails?.text,
708
+ questionCount: questionDetails?.count,
709
+ };
710
+ this.updateSessionWithPermission(event.sessionId, permission, event.pid);
711
+ });
712
+ }
713
+
714
+ this.updateSessionStatus(event.sessionId, 'permission_requested', {
715
+ ...eventContext,
716
+ statusDetail: event.toolName,
717
+ });
718
+ break;
719
+ }
720
+
721
+ case 'PermissionDenied': {
722
+ this.clearStalePermission(event.sessionId, 'PermissionDenied');
723
+ const bk = this.getBookkeeping(event.sessionId);
724
+ bk.activeToolCount = Math.max(0, bk.activeToolCount - 1);
725
+ const filesChanged = this.untrackToolUseFile(event.sessionId, event);
726
+ const readsChanged = this.untrackToolUseRead(event.sessionId, event);
727
+ // When a parallel tool is still active the next status equals the current one and
728
+ // updateSessionStatus short-circuits — without an explicit fire, the dropped path
729
+ // would never reach subscribers (treemap activity overlay, WIP decoration).
730
+ const nextStatus = bk.activeToolCount > 0 ? 'tool_use' : 'thinking';
731
+ const sessionIndex = this._sessions.findIndex(s => s.id === event.sessionId);
732
+ const prevStatus = sessionIndex >= 0 ? this._sessions[sessionIndex].status : undefined;
733
+ const statusWillFire = prevStatus !== nextStatus;
734
+ this.updateSessionStatus(event.sessionId, nextStatus, eventContext);
735
+ if ((filesChanged || readsChanged) && !statusWillFire) {
736
+ this._onDidChangeSessions.fire();
737
+ }
738
+ break;
739
+ }
740
+
741
+ case 'Elicitation': {
742
+ const bk = this.getBookkeeping(event.sessionId);
743
+ bk.pendingPermission = {
744
+ kind: 'elicitation',
745
+ toolName: event.toolName ?? 'Input Required',
746
+ toolDescription: event.toolName ?? 'Waiting for input',
747
+ };
748
+ this.updateSessionStatus(event.sessionId, 'permission_requested', {
749
+ ...eventContext,
750
+ statusDetail: event.toolName,
751
+ });
752
+ break;
753
+ }
754
+
755
+ case 'ElicitationResult': {
756
+ const bk = this.getBookkeeping(event.sessionId);
757
+ bk.pendingPermission = undefined;
758
+ this.updateSessionStatus(
759
+ event.sessionId,
760
+ bk.activeToolCount > 0 ? 'tool_use' : 'thinking',
761
+ eventContext,
762
+ );
763
+ break;
764
+ }
765
+
766
+ case 'CwdChanged':
767
+ if (event.cwd) {
768
+ const index = this._sessions.findIndex(s => s.id === event.sessionId);
769
+ if (index >= 0 && this._sessions[index].cwd !== event.cwd) {
770
+ this._sessions[index] = { ...this._sessions[index], cwd: event.cwd };
771
+ this._onDidChangeSessions.fire();
772
+ }
773
+ // Clear the unresolvable flag so the new cwd actually gets re-probed — without
774
+ // this, a session that started in a non-git cwd would stay flagged forever even
775
+ // after moving into a git repo.
776
+ this.getBookkeeping(event.sessionId).gitInfoUnresolvable = false;
777
+ void this.resolveGitInfo(event.sessionId, event.cwd);
778
+ }
779
+ break;
780
+
781
+ case 'SubagentStart': {
782
+ const { index: parentIdx } = this.ensureSession(event.sessionId, eventContext);
783
+ const parent = this._sessions[parentIdx];
784
+ if (event.agentId) {
785
+ const now = new Date();
786
+ const subagent: AgentSession = {
787
+ id: event.agentId,
788
+ providerId: this.id,
789
+ providerName: this.name,
790
+ name: event.agentType ?? 'Subagent',
791
+ status: 'thinking',
792
+ phase: 'working',
793
+ phaseSince: now,
794
+ lastActivity: now,
795
+ isSubagent: true,
796
+ parentId: event.sessionId,
797
+ isInWorkspace: workspacePath != null,
798
+ };
799
+ const existingSubs = parent.subagents ? [...parent.subagents] : [];
800
+ existingSubs.push(subagent);
801
+ this._sessions[parentIdx] = { ...parent, subagents: existingSubs };
802
+ this._onDidChangeSessions.fire();
803
+ }
804
+ break;
805
+ }
806
+
807
+ case 'SubagentStop': {
808
+ const { index: parentIdx } = this.ensureSession(event.sessionId, eventContext);
809
+ const parentSession = this._sessions[parentIdx];
810
+ if (parentSession.subagents != null && event.agentId) {
811
+ const filtered = parentSession.subagents.filter(s => s.id !== event.agentId);
812
+ if (filtered.length !== parentSession.subagents.length) {
813
+ this._sessions[parentIdx] = {
814
+ ...parentSession,
815
+ subagents: filtered.length > 0 ? filtered : undefined,
816
+ };
817
+ this._onDidChangeSessions.fire();
818
+ }
819
+ }
820
+ break;
821
+ }
822
+
823
+ case 'TeammateIdle':
824
+ case 'TaskCompleted':
825
+ case 'InstructionsLoaded':
826
+ case 'ConfigChange':
827
+ case 'WorktreeCreate':
828
+ case 'WorktreeRemove':
829
+ // Not yet handled — intentional no-op.
830
+ break;
831
+ }
832
+
833
+ return Promise.resolve();
834
+ }
835
+
836
+ private sessionTag(sessionId: string, name?: string): string {
837
+ return name != null
838
+ ? `[session=${sessionId.substring(0, 8)}(${name})]`
839
+ : `[session=${sessionId.substring(0, 8)}]`;
840
+ }
841
+
842
+ private getBookkeeping(sessionId: string): SessionBookkeeping {
843
+ let bk = this._sessionBookkeeping.get(sessionId);
844
+ if (bk == null) {
845
+ bk = {
846
+ activeToolCount: 0,
847
+ currentFileCounts: new Map(),
848
+ pendingFileClears: new Map(),
849
+ currentReadCounts: new Map(),
850
+ pendingReadClears: new Map(),
851
+ lastTouchedAt: new Map(),
852
+ gitInfoUnresolvable: false,
853
+ };
854
+ this._sessionBookkeeping.set(sessionId, bk);
855
+ }
856
+ return bk;
857
+ }
858
+
859
+ /** Returns the active decay window in milliseconds — the cooldown between PostToolUse and
860
+ * dropping the path from the bookkeeping. Reads through the host callback every time so a
861
+ * setting change takes effect on the next Pre/Post pair without re-wiring (existing timers
862
+ * keep their original timeout — acceptable since they're short-lived relative to the setting). */
863
+ private get activityDecayMs(): number {
864
+ return this.callbacks.getActivityDecayMs?.() ?? defaultActivityDecayMs;
865
+ }
866
+
867
+ /** Locates the *parent* session id that owns the given session id — returns the id itself when
868
+ * it matches a top-level session, otherwise the `id` of the parent that holds it under
869
+ * `subagents[]`. Returns `undefined` when neither match (session no longer exists). Used to
870
+ * route per-session bookkeeping changes to the right top-level `syncSessionFileActivity` call.
871
+ * Tool events are parent-keyed (the CLI sends a sub-agent's tool events under the parent id),
872
+ * so the `subagents[]` branch is a defensive fallback for any other-keyed caller. */
873
+ private findOwningParentId(sessionId: string): string | undefined {
874
+ for (const s of this._sessions) {
875
+ if (s.id === sessionId) return s.id;
876
+
877
+ if (s.subagents != null) {
878
+ for (const sub of s.subagents) {
879
+ if (sub.id === sessionId) return s.id;
880
+ }
881
+ }
882
+ }
883
+ return undefined;
884
+ }
885
+
886
+ private resetBookkeeping(sessionId: string): void {
887
+ const bk = this.getBookkeeping(sessionId);
888
+ bk.activeToolCount = 0;
889
+ bk.pendingPermission = undefined;
890
+ this.cancelPendingFileClears(bk);
891
+ bk.currentFileCounts.clear();
892
+ this.cancelPendingReadClears(bk);
893
+ bk.currentReadCounts.clear();
894
+ bk.lastTouchedAt.clear();
895
+ bk.gitInfoUnresolvable = false;
896
+ const parentId = this.findOwningParentId(sessionId);
897
+ if (parentId != null) {
898
+ this.syncSessionFileActivity(parentId);
899
+ }
900
+ }
901
+
902
+ /** Turn-end settle (Stop): the agent finished its turn, so nothing is "live" anymore — but
903
+ * the per-operation decay tail must survive. Heat fades over {@link activityDecayMs} measured
904
+ * from when each *tool* finished (the PostToolUse cooldown), NOT from when the turn ends, so a
905
+ * Stop must not cancel those cooldowns or clear `lastTouchedAt`. We only reset turn-scoped
906
+ * state and force-finish any path whose PostToolUse never arrived before Stop: drop its
907
+ * refcount to 0 (flipping `editing`/`reading` off so the pulse stops) and schedule the same
908
+ * decay eviction a normal completion would. Contrast {@link resetBookkeeping}, which hard-
909
+ * wipes everything for SessionStart/SessionEnd (the agent is gone, not merely between turns). */
910
+ private settleBookkeeping(sessionId: string): void {
911
+ const bk = this.getBookkeeping(sessionId);
912
+ bk.activeToolCount = 0;
913
+ bk.pendingPermission = undefined;
914
+ // Allow git re-resolution next turn (matches the prior reset-on-Stop intent).
915
+ bk.gitInfoUnresolvable = false;
916
+ for (const [path, count] of bk.currentFileCounts) {
917
+ if (count > 0) {
918
+ bk.currentFileCounts.set(path, 0);
919
+ this.scheduleFileDecayEviction(bk, sessionId, path);
920
+ }
921
+ }
922
+ for (const [path, count] of bk.currentReadCounts) {
923
+ if (count > 0) {
924
+ bk.currentReadCounts.set(path, 0);
925
+ this.scheduleReadDecayEviction(bk, sessionId, path);
926
+ }
927
+ }
928
+ const parentId = this.findOwningParentId(sessionId);
929
+ if (parentId != null && this.syncSessionFileActivity(parentId)) {
930
+ this._onDidChangeSessions.fire();
931
+ }
932
+ }
933
+
934
+ private cancelPendingFileClears(bk: SessionBookkeeping): void {
935
+ for (const timer of bk.pendingFileClears.values()) {
936
+ clearTimeout(timer);
937
+ }
938
+ bk.pendingFileClears.clear();
939
+ }
940
+
941
+ private cancelPendingReadClears(bk: SessionBookkeeping): void {
942
+ for (const timer of bk.pendingReadClears.values()) {
943
+ clearTimeout(timer);
944
+ }
945
+ bk.pendingReadClears.clear();
946
+ }
947
+
948
+ /** Extracts the targeted file path from a PreToolUse/PostToolUse event. Tries the raw
949
+ * `hookInput.tool_input` passthrough first, falls back to the projected `event.toolInput`
950
+ * for older CLI versions that only fill the top-level fields. Treats an empty-object
951
+ * `hookInput.tool_input` as missing — `??` would have kept it (truthy) and shadowed a
952
+ * populated `event.toolInput`. */
953
+ private getEventFilePath(event: AgentSessionEvent): string | undefined {
954
+ const toolName = (event.hookInput?.tool_name as string | undefined) ?? event.toolName;
955
+ if (toolName == null) return undefined;
956
+
957
+ const rawHookInput = event.hookInput?.tool_input as Record<string, unknown> | undefined;
958
+ const hookInputPopulated = rawHookInput != null && Object.keys(rawHookInput).length > 0;
959
+ const toolInput = hookInputPopulated ? rawHookInput : event.toolInput;
960
+ return getToolFilePath(toolName, toolInput);
961
+ }
962
+
963
+ /** Stamps the per-path/per-kind `lastTouchedAt[kind]` to the current epoch ms. Stamped on every
964
+ * PreToolUse for the matching kind so `serializeFileActivity` can emit `readAt`/`editedAt` as
965
+ * a relative `now - timestamp` delta. */
966
+ private stampLastTouched(bk: SessionBookkeeping, filePath: string, kind: 'read' | 'edit'): void {
967
+ const existing = bk.lastTouchedAt.get(filePath);
968
+ const next: { edit?: number; read?: number } = existing != null ? { ...existing } : {};
969
+ next[kind] = Date.now();
970
+ bk.lastTouchedAt.set(filePath, next);
971
+ }
972
+
973
+ /** Drops the `lastTouchedAt[kind]` slot for `filePath`, and the whole entry when neither kind
974
+ * remains. Mirrors the cooldown-timer eviction in `scheduleClear*` — once the path is gone
975
+ * from the count map for that kind, its timestamp is no longer load-bearing. */
976
+ private clearLastTouched(bk: SessionBookkeeping, filePath: string, kind: 'read' | 'edit'): void {
977
+ const existing = bk.lastTouchedAt.get(filePath);
978
+ if (existing == null) return;
979
+ if (existing[kind] == null) return;
980
+
981
+ const next: { edit?: number; read?: number } = { ...existing, [kind]: undefined };
982
+ if (next.edit == null && next.read == null) {
983
+ bk.lastTouchedAt.delete(filePath);
984
+ } else {
985
+ bk.lastTouchedAt.set(filePath, next);
986
+ }
987
+ }
988
+
989
+ /** Records that a file-mutating tool call is in flight against `filePath` so consumers can
990
+ * highlight the path. Refcounts per path — keyed by path (not `tool_use_id`, which the GK CLI
991
+ * doesn't reliably surface) so parallel calls bump the count and Post/Pre order doesn't matter.
992
+ * Returns whether the parent's published `fileActivity` actually changed so callers can fire
993
+ * when the surrounding {@link updateSessionStatus} short-circuits. */
994
+ private trackToolUseFile(sessionId: string, event: AgentSessionEvent): boolean {
995
+ const filePath = this.getEventFilePath(event);
996
+ if (filePath == null) return false;
997
+
998
+ const bk = this.getBookkeeping(sessionId);
999
+ // A fresh Pre during a pending Post-cooldown means the file is live again — cancel the
1000
+ // scheduled clear so the deferred drop doesn't run after the new tool already finished.
1001
+ const pendingClear = bk.pendingFileClears.get(filePath);
1002
+ if (pendingClear != null) {
1003
+ clearTimeout(pendingClear);
1004
+ bk.pendingFileClears.delete(filePath);
1005
+ }
1006
+ bk.currentFileCounts.set(filePath, (bk.currentFileCounts.get(filePath) ?? 0) + 1);
1007
+ this.stampLastTouched(bk, filePath, 'edit');
1008
+ return this.syncSessionFileActivityForChild(sessionId);
1009
+ }
1010
+
1011
+ /** Decrements the in-flight refcount for a finished tool call's file. While the count stays
1012
+ * above zero (overlapping parallel calls) the path remains marked. When the count drops to
1013
+ * zero, leaves the entry in place at count `0` and schedules a {@link activityDecayMs}
1014
+ * cooldown before actually dropping it — so a recently-edited file lingers as a decay tail
1015
+ * on the treemap. A Pre arriving during cooldown cancels the timer and bumps the count back
1016
+ * above zero. Returns whether the parent's published `fileActivity` changed (i.e. `editing`
1017
+ * flipped off) so the PostToolUse caller can fire when its `updateSessionStatus` short-
1018
+ * circuits (a parallel tool still in flight keeps activeToolCount > 0). */
1019
+ private scheduleClearToolUseFile(sessionId: string, event: AgentSessionEvent): boolean {
1020
+ const filePath = this.getEventFilePath(event);
1021
+ if (filePath == null) return false;
1022
+
1023
+ const bk = this.getBookkeeping(sessionId);
1024
+ const count = bk.currentFileCounts.get(filePath);
1025
+ // A duplicate / out-of-order Post during cooldown (count is already 0) would drive the
1026
+ // refcount negative and schedule a second timer — ignore those so the cooldown stays
1027
+ // authoritative until either its timer fires or a fresh Pre bumps the count back up.
1028
+ if (count == null || count <= 0) return false;
1029
+
1030
+ const next = count - 1;
1031
+ bk.currentFileCounts.set(filePath, next);
1032
+ // Re-sync so `editing` drops from `true` to `undefined` the moment refcount hits zero —
1033
+ // the cooldown only governs when the path itself leaves the snapshot, not when the
1034
+ // "live" boolean flips off.
1035
+ const changed = this.syncSessionFileActivityForChild(sessionId);
1036
+ if (next > 0) return changed;
1037
+
1038
+ // Refcount just hit zero — keep the path in `currentFileCounts` for the cooldown window,
1039
+ // then drop it.
1040
+ this.scheduleFileDecayEviction(bk, sessionId, filePath);
1041
+ return changed;
1042
+ }
1043
+
1044
+ /** Schedules (replacing any prior timer) the {@link activityDecayMs} cooldown that finally
1045
+ * drops `filePath` from the edit bookkeeping once it has fully decayed. Shared by the
1046
+ * PostToolUse cooldown path and the Stop settle ({@link settleBookkeeping}); the path stays
1047
+ * in `currentFileCounts` at count 0 meanwhile so its `editedAt` keeps aging on the client. */
1048
+ private scheduleFileDecayEviction(bk: SessionBookkeeping, sessionId: string, filePath: string): void {
1049
+ const existing = bk.pendingFileClears.get(filePath);
1050
+ if (existing != null) {
1051
+ clearTimeout(existing);
1052
+ }
1053
+
1054
+ const timer = setTimeout(() => {
1055
+ if (this._disposed) return;
1056
+
1057
+ const cur = this._sessionBookkeeping.get(sessionId);
1058
+ if (cur == null) return;
1059
+
1060
+ cur.pendingFileClears.delete(filePath);
1061
+ // Re-check: a Pre during cooldown bumped the count and cancelled this timer, but a
1062
+ // concurrent reset could have cleared everything — bail in either case.
1063
+ if ((cur.currentFileCounts.get(filePath) ?? 0) > 0) return;
1064
+
1065
+ cur.currentFileCounts.delete(filePath);
1066
+ this.clearLastTouched(cur, filePath, 'edit');
1067
+ if (this.syncSessionFileActivityForChild(sessionId)) {
1068
+ this._onDidChangeSessions.fire();
1069
+ }
1070
+ }, this.activityDecayMs);
1071
+ bk.pendingFileClears.set(filePath, timer);
1072
+ }
1073
+
1074
+ /** Drops a tracked tool call's file immediately (no cooldown). Used for PermissionDenied,
1075
+ * where the tool never ran — keeping the "live" badge would be misleading.
1076
+ * Returns whether the published list changed. */
1077
+ private untrackToolUseFile(sessionId: string, event: AgentSessionEvent): boolean {
1078
+ const filePath = this.getEventFilePath(event);
1079
+ if (filePath == null) return false;
1080
+
1081
+ const bk = this.getBookkeeping(sessionId);
1082
+ const count = bk.currentFileCounts.get(filePath);
1083
+ if (count == null) return false;
1084
+ if (count > 1) {
1085
+ bk.currentFileCounts.set(filePath, count - 1);
1086
+ return this.syncSessionFileActivityForChild(sessionId);
1087
+ }
1088
+
1089
+ const pending = bk.pendingFileClears.get(filePath);
1090
+ if (pending != null) {
1091
+ clearTimeout(pending);
1092
+ bk.pendingFileClears.delete(filePath);
1093
+ }
1094
+ bk.currentFileCounts.delete(filePath);
1095
+ this.clearLastTouched(bk, filePath, 'edit');
1096
+ return this.syncSessionFileActivityForChild(sessionId);
1097
+ }
1098
+
1099
+ /** Mirror of {@link getEventFilePath} for read-only file tools. */
1100
+ private getEventReadPath(event: AgentSessionEvent): string | undefined {
1101
+ const toolName = (event.hookInput?.tool_name as string | undefined) ?? event.toolName;
1102
+ if (toolName == null) return undefined;
1103
+
1104
+ const rawHookInput = event.hookInput?.tool_input as Record<string, unknown> | undefined;
1105
+ const hookInputPopulated = rawHookInput != null && Object.keys(rawHookInput).length > 0;
1106
+ const toolInput = hookInputPopulated ? rawHookInput : event.toolInput;
1107
+ return getToolReadPath(toolName, toolInput);
1108
+ }
1109
+
1110
+ /** Refcount mirror of {@link trackToolUseFile} for read-only file tools. */
1111
+ private trackToolUseRead(sessionId: string, event: AgentSessionEvent): boolean {
1112
+ const filePath = this.getEventReadPath(event);
1113
+ if (filePath == null) return false;
1114
+
1115
+ const bk = this.getBookkeeping(sessionId);
1116
+ const pendingClear = bk.pendingReadClears.get(filePath);
1117
+ if (pendingClear != null) {
1118
+ clearTimeout(pendingClear);
1119
+ bk.pendingReadClears.delete(filePath);
1120
+ }
1121
+ bk.currentReadCounts.set(filePath, (bk.currentReadCounts.get(filePath) ?? 0) + 1);
1122
+ this.stampLastTouched(bk, filePath, 'read');
1123
+ return this.syncSessionFileActivityForChild(sessionId);
1124
+ }
1125
+
1126
+ /** Cooldown mirror of {@link scheduleClearToolUseFile} for read-only file tools. Returns
1127
+ * whether the parent's published `fileActivity` changed (i.e. `reading` flipped off). */
1128
+ private scheduleClearToolUseRead(sessionId: string, event: AgentSessionEvent): boolean {
1129
+ const filePath = this.getEventReadPath(event);
1130
+ if (filePath == null) return false;
1131
+
1132
+ const bk = this.getBookkeeping(sessionId);
1133
+ const count = bk.currentReadCounts.get(filePath);
1134
+ // Same guard as scheduleClearToolUseFile — duplicate / out-of-order Posts during cooldown
1135
+ // would otherwise drive the refcount negative and stack up redundant clear timers.
1136
+ if (count == null || count <= 0) return false;
1137
+
1138
+ const next = count - 1;
1139
+ bk.currentReadCounts.set(filePath, next);
1140
+ // Re-sync so `reading` drops to `undefined` the moment refcount hits zero.
1141
+ const changed = this.syncSessionFileActivityForChild(sessionId);
1142
+ if (next > 0) return changed;
1143
+
1144
+ this.scheduleReadDecayEviction(bk, sessionId, filePath);
1145
+ return changed;
1146
+ }
1147
+
1148
+ /** Read-class mirror of {@link scheduleFileDecayEviction}. */
1149
+ private scheduleReadDecayEviction(bk: SessionBookkeeping, sessionId: string, filePath: string): void {
1150
+ const existing = bk.pendingReadClears.get(filePath);
1151
+ if (existing != null) {
1152
+ clearTimeout(existing);
1153
+ }
1154
+
1155
+ const timer = setTimeout(() => {
1156
+ if (this._disposed) return;
1157
+
1158
+ const cur = this._sessionBookkeeping.get(sessionId);
1159
+ if (cur == null) return;
1160
+
1161
+ cur.pendingReadClears.delete(filePath);
1162
+ if ((cur.currentReadCounts.get(filePath) ?? 0) > 0) return;
1163
+
1164
+ cur.currentReadCounts.delete(filePath);
1165
+ this.clearLastTouched(cur, filePath, 'read');
1166
+ if (this.syncSessionFileActivityForChild(sessionId)) {
1167
+ this._onDidChangeSessions.fire();
1168
+ }
1169
+ }, this.activityDecayMs);
1170
+ bk.pendingReadClears.set(filePath, timer);
1171
+ }
1172
+
1173
+ /** Immediate-drop mirror of {@link untrackToolUseFile} for read-only file tools. */
1174
+ private untrackToolUseRead(sessionId: string, event: AgentSessionEvent): boolean {
1175
+ const filePath = this.getEventReadPath(event);
1176
+ if (filePath == null) return false;
1177
+
1178
+ const bk = this.getBookkeeping(sessionId);
1179
+ const count = bk.currentReadCounts.get(filePath);
1180
+ if (count == null) return false;
1181
+ if (count > 1) {
1182
+ bk.currentReadCounts.set(filePath, count - 1);
1183
+ return this.syncSessionFileActivityForChild(sessionId);
1184
+ }
1185
+
1186
+ const pending = bk.pendingReadClears.get(filePath);
1187
+ if (pending != null) {
1188
+ clearTimeout(pending);
1189
+ bk.pendingReadClears.delete(filePath);
1190
+ }
1191
+ bk.currentReadCounts.delete(filePath);
1192
+ this.clearLastTouched(bk, filePath, 'read');
1193
+ return this.syncSessionFileActivityForChild(sessionId);
1194
+ }
1195
+
1196
+ /** Convenience wrapper that resolves the parent for a possibly-sub-agent session id, then calls
1197
+ * {@link syncSessionFileActivity}. Tool-call handlers call this without knowing whether the
1198
+ * session is a top-level or sub-agent session. Returns `false` when the session isn't found
1199
+ * (already pruned / unknown peer id). */
1200
+ private syncSessionFileActivityForChild(sessionId: string): boolean {
1201
+ const parentId = this.findOwningParentId(sessionId);
1202
+ if (parentId == null) return false;
1203
+ return this.syncSessionFileActivity(parentId);
1204
+ }
1205
+
1206
+ /** Rebuilds the parent session's `fileActivity` array from its bookkeeping and writes it onto
1207
+ * the session in place. Returns whether the *structural* shape changed (paths/kinds/flags) —
1208
+ * timestamps are always refreshed regardless, but a fire is gated on structural change so
1209
+ * non-meaningful drift doesn't churn the wire.
1210
+ *
1211
+ * No separate sub-agent rollup is needed: the GK CLI keys a sub-agent's tool events under its
1212
+ * PARENT session id (with `agentId` set), so they accumulate in the parent's own bookkeeping
1213
+ * directly and are already reflected here. Sub-agent serialized objects carry no `fileActivity`. */
1214
+ private syncSessionFileActivity(parentSessionId: string): boolean {
1215
+ const index = this._sessions.findIndex(s => s.id === parentSessionId);
1216
+ if (index < 0) return false;
1217
+
1218
+ const parent = this._sessions[index];
1219
+ const parentBk = this._sessionBookkeeping.get(parentSessionId);
1220
+
1221
+ type Contrib = { editCount: number; readCount: number; editAt?: number; readAt?: number };
1222
+ const merged = new Map<string, Contrib>();
1223
+
1224
+ const contributeFrom = (bk: SessionBookkeeping | undefined): void => {
1225
+ if (bk == null) return;
1226
+
1227
+ for (const [path, count] of bk.currentFileCounts) {
1228
+ let entry = merged.get(path);
1229
+ if (entry == null) {
1230
+ entry = { editCount: 0, readCount: 0 };
1231
+ merged.set(path, entry);
1232
+ }
1233
+ entry.editCount += count;
1234
+ const ts = bk.lastTouchedAt.get(path)?.edit;
1235
+ if (ts != null && (entry.editAt == null || ts > entry.editAt)) {
1236
+ entry.editAt = ts;
1237
+ }
1238
+ }
1239
+ for (const [path, count] of bk.currentReadCounts) {
1240
+ let entry = merged.get(path);
1241
+ if (entry == null) {
1242
+ entry = { editCount: 0, readCount: 0 };
1243
+ merged.set(path, entry);
1244
+ }
1245
+ entry.readCount += count;
1246
+ const ts = bk.lastTouchedAt.get(path)?.read;
1247
+ if (ts != null && (entry.readAt == null || ts > entry.readAt)) {
1248
+ entry.readAt = ts;
1249
+ }
1250
+ }
1251
+ };
1252
+
1253
+ contributeFrom(parentBk);
1254
+
1255
+ const now = Date.now();
1256
+ let next: FileActivityEntry[] | undefined;
1257
+ if (merged.size > 0) {
1258
+ next = [];
1259
+ for (const [path, contrib] of merged) {
1260
+ const entry: FileActivityEntry = { path: path };
1261
+ if (contrib.editAt != null) {
1262
+ entry.editedAt = Math.max(0, now - contrib.editAt);
1263
+ }
1264
+ if (contrib.readAt != null) {
1265
+ entry.readAt = Math.max(0, now - contrib.readAt);
1266
+ }
1267
+ if (contrib.editCount > 0) {
1268
+ entry.editing = true;
1269
+ }
1270
+ if (contrib.readCount > 0) {
1271
+ entry.reading = true;
1272
+ }
1273
+ next.push(entry);
1274
+ }
1275
+ }
1276
+
1277
+ const changed = !fileActivityStructurallyEqual(parent.fileActivity, next);
1278
+ // Always replace so timestamps reflect the latest serialization, even when the structural
1279
+ // shape is unchanged (so a downstream fire from anywhere else carries fresh `sinceMs`).
1280
+ this._sessions[index] = { ...parent, fileActivity: next };
1281
+ return changed;
1282
+ }
1283
+
1284
+ /** Defer the `Stop → idle` commit. If the agent produces a non-idle event within
1285
+ * `stopToIdleDebounceMs`, `cancelPendingIdleTransition` (called from `updateSessionStatus`)
1286
+ * cancels this and the session stays in its prior phase — no working → idle → working flicker.
1287
+ *
1288
+ * Intentionally takes no context: the caller is expected to apply any event metadata
1289
+ * synchronously before scheduling. Carrying Stop-time context into the timer would let
1290
+ * in-window metadata mutations (e.g. `CwdChanged` updating `_sessions[i].cwd` directly)
1291
+ * get reverted when the deferred commit replays the stale context through `ensureSession`. */
1292
+ private scheduleIdleTransition(sessionId: string): void {
1293
+ this.cancelPendingIdleTransition(sessionId);
1294
+ const timer = setTimeout(() => {
1295
+ this._pendingIdleTimers.delete(sessionId);
1296
+ // Session may have been removed (SessionEnd, prune) during the window.
1297
+ if (this._sessions.findIndex(s => s.id === sessionId) < 0) return;
1298
+
1299
+ this.updateSessionStatus(sessionId, 'idle');
1300
+ }, stopToIdleDebounceMs);
1301
+ this._pendingIdleTimers.set(sessionId, timer);
1302
+ }
1303
+
1304
+ private cancelPendingIdleTransition(sessionId: string): void {
1305
+ const timer = this._pendingIdleTimers.get(sessionId);
1306
+ if (timer == null) return;
1307
+
1308
+ clearTimeout(timer);
1309
+ this._pendingIdleTimers.delete(sessionId);
1310
+ }
1311
+
1312
+ /** Stable phase-since: if we're oscillating back into the phase we were just in (within a
1313
+ * short window), restore its original timestamp so the displayed elapsed time doesn't snap
1314
+ * to 0. Otherwise records the current phase as the new "prior" and returns `now`. */
1315
+ private resolvePhaseSince(
1316
+ sessionId: string,
1317
+ prevPhase: AgentSessionPhase,
1318
+ prevPhaseSince: Date,
1319
+ newPhase: AgentSessionPhase,
1320
+ ): Date {
1321
+ if (prevPhase === newPhase) return prevPhaseSince;
1322
+
1323
+ const bk = this.getBookkeeping(sessionId);
1324
+ const now = new Date();
1325
+
1326
+ if (bk.priorPhase?.phase === newPhase && now.getTime() - prevPhaseSince.getTime() < phaseSinceRestoreWindowMs) {
1327
+ const restored = bk.priorPhase.phaseSince;
1328
+ bk.priorPhase = { phase: prevPhase, phaseSince: prevPhaseSince };
1329
+ return restored;
1330
+ }
1331
+
1332
+ bk.priorPhase = { phase: prevPhase, phaseSince: prevPhaseSince };
1333
+ return now;
1334
+ }
1335
+
1336
+ // Called when a pending permission was resolved outside GitLens (e.g. via the CLI terminal).
1337
+ // The 'deny' response is a safe no-op since the tool has already run or been denied upstream.
1338
+ private clearStalePermission(sessionId: string, reason: string): void {
1339
+ const bk = this.getBookkeeping(sessionId);
1340
+ if (bk.pendingPermission == null) return;
1341
+
1342
+ Logger.debug(`ClaudeCodeProvider.clearStalePermission: ${reason} ${this.sessionTag(sessionId)}`);
1343
+
1344
+ const pending = this._pendingPermissions.get(sessionId);
1345
+ if (pending != null) {
1346
+ pending.resolve({
1347
+ hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: { behavior: 'deny' } },
1348
+ });
1349
+ this._pendingPermissions.delete(sessionId);
1350
+ }
1351
+
1352
+ bk.pendingPermission = undefined;
1353
+ }
1354
+
1355
+ resolvePermission(
1356
+ sessionId: string,
1357
+ decision: PermissionDecision,
1358
+ updatedPermissions?: PermissionSuggestion[],
1359
+ ): boolean {
1360
+ const pending = this._pendingPermissions.get(sessionId);
1361
+ if (pending == null) return false;
1362
+
1363
+ Logger.debug(
1364
+ `ClaudeCodeProvider.resolvePermission: ${decision} ${this.sessionTag(sessionId)} tool=${pending.toolName}`,
1365
+ );
1366
+ pending.resolve({
1367
+ hookSpecificOutput: {
1368
+ hookEventName: 'PermissionRequest',
1369
+ decision: { behavior: decision, updatedPermissions: updatedPermissions },
1370
+ },
1371
+ });
1372
+ this.callbacks.onPermissionResolved?.({
1373
+ provider: this.id,
1374
+ tool: pending.toolName,
1375
+ decision: decision,
1376
+ });
1377
+ this._pendingPermissions.delete(sessionId);
1378
+
1379
+ const bk = this.getBookkeeping(sessionId);
1380
+ bk.pendingPermission = undefined;
1381
+
1382
+ const nextStatus = bk.activeToolCount > 0 ? 'tool_use' : 'thinking';
1383
+ this.updateSessionStatus(sessionId, nextStatus);
1384
+ return true;
1385
+ }
1386
+
1387
+ private matchesWorkspace(workspacePath: string | undefined): boolean {
1388
+ if (!workspacePath) return false;
1389
+
1390
+ // `_workspacePaths` is normalized; normalize the input so prefix comparisons work
1391
+ // regardless of whether the caller passed a raw `fsPath` (CLI hook cwd, peer-session
1392
+ // workspacePath) or an already-normalized path.
1393
+ const normalized = normalizePath(workspacePath);
1394
+ return this._workspacePaths.some(
1395
+ p => normalized === p || normalized.startsWith(`${p}/`) || p.startsWith(`${normalized}/`),
1396
+ );
1397
+ }
1398
+
1399
+ private resolveWorkspacePath(cwd: string | undefined): string | undefined {
1400
+ if (!cwd) return undefined;
1401
+
1402
+ const normalized = normalizePath(cwd);
1403
+ return this._workspacePaths.find(
1404
+ p => normalized === p || normalized.startsWith(`${p}/`) || p.startsWith(`${normalized}/`),
1405
+ );
1406
+ }
1407
+
1408
+ private updateSessionWithPermission(sessionId: string, permission: PendingPermission, pid?: number): void {
1409
+ // A pending permission is a working/waiting transition — cancel any deferred Stop → idle
1410
+ // commit so the session doesn't briefly flip to idle before showing the prompt.
1411
+ this.cancelPendingIdleTransition(sessionId);
1412
+
1413
+ const { index } = this.ensureSession(sessionId, { pid: pid });
1414
+
1415
+ const prev = this._sessions[index];
1416
+ const newPhase = getPhaseForStatus('permission_requested');
1417
+ this.getBookkeeping(sessionId).pendingPermission = permission;
1418
+ this._sessions[index] = {
1419
+ ...prev,
1420
+ status: 'permission_requested',
1421
+ phase: newPhase,
1422
+ phaseSince: this.resolvePhaseSince(sessionId, prev.phase, prev.phaseSince, newPhase),
1423
+ statusDetail: permission.toolDescription,
1424
+ pendingPermission: permission,
1425
+ lastActivity: new Date(),
1426
+ };
1427
+ this._onDidChangeSessions.fire();
1428
+ }
1429
+
1430
+ private updateSessionStatus(
1431
+ sessionId: string,
1432
+ status: AgentSessionStatus,
1433
+ options?: SessionContext & { statusDetail?: string },
1434
+ ): void {
1435
+ // Any non-idle status update implicitly cancels a deferred Stop → idle commit. The
1436
+ // timer is owned here so the schedule/cancel pair is uniformly enforced regardless of
1437
+ // which event path produced the next status.
1438
+ if (status !== 'idle') {
1439
+ this.cancelPendingIdleTransition(sessionId);
1440
+ }
1441
+
1442
+ const { index, changed: metadataChanged } = this.ensureSession(sessionId, options);
1443
+
1444
+ const prev = this._sessions[index];
1445
+ const bk = this.getBookkeeping(sessionId);
1446
+
1447
+ // If a permission/elicitation is pending, preserve permission_requested state.
1448
+ // Tool counts and other bookkeeping are still updated by callers before this
1449
+ // method, but the visible status remains locked until the wait is explicitly
1450
+ // resolved.
1451
+ if (bk.pendingPermission != null && status !== 'permission_requested') {
1452
+ this._sessions[index] = { ...prev, lastActivity: new Date() };
1453
+ if (metadataChanged) {
1454
+ this._onDidChangeSessions.fire();
1455
+ }
1456
+ return;
1457
+ }
1458
+
1459
+ const statusDetail = options?.statusDetail;
1460
+ if (prev.status === status && prev.statusDetail === statusDetail) {
1461
+ if (metadataChanged) {
1462
+ this._onDidChangeSessions.fire();
1463
+ }
1464
+ return;
1465
+ }
1466
+
1467
+ const newPhase = getPhaseForStatus(status);
1468
+ this._sessions[index] = {
1469
+ ...prev,
1470
+ status: status,
1471
+ phase: newPhase,
1472
+ phaseSince: this.resolvePhaseSince(sessionId, prev.phase, prev.phaseSince, newPhase),
1473
+ statusDetail: statusDetail,
1474
+ pendingPermission: status === 'permission_requested' ? bk.pendingPermission : undefined,
1475
+ lastActivity: new Date(),
1476
+ };
1477
+ this._onDidChangeSessions.fire();
1478
+
1479
+ if (status === 'thinking' || status === 'tool_use' || status === 'compacting') {
1480
+ const sessionCwd = this._sessions[index].cwd;
1481
+ if (sessionCwd != null) {
1482
+ this.callbacks.onBranchAgentActivity?.(sessionCwd);
1483
+ }
1484
+ }
1485
+
1486
+ // On a fresh transition into idle, recheck transcript titles — Claude typically (re-)writes
1487
+ // `ai-title` right after finishing a response, so this is the prime moment to pick it up.
1488
+ // Tail-read caching makes repeated checks cheap.
1489
+ if (status === 'idle' && prev.status !== 'idle') {
1490
+ void this.resolveTranscriptTitles(sessionId, this._sessions[index].cwd);
1491
+ }
1492
+ }
1493
+
1494
+ private ensureSession(sessionId: string, context?: SessionContext): { index: number; changed: boolean } {
1495
+ const { pid, workspacePath, isInWorkspace, cwd, initialCwd, planFile, sessionName } = context ?? {};
1496
+
1497
+ let index = this._sessions.findIndex(s => s.id === sessionId);
1498
+ if (index < 0) {
1499
+ const now = new Date();
1500
+ index = this._sessions.length;
1501
+ this._sessions.push({
1502
+ id: sessionId,
1503
+ providerId: this.id,
1504
+ providerName: this.name,
1505
+ name: sessionName || undefined,
1506
+ status: 'idle',
1507
+ phase: getPhaseForStatus('idle'),
1508
+ phaseSince: now,
1509
+ pid: pid,
1510
+ lastActivity: now,
1511
+ isSubagent: false,
1512
+ workspacePath: workspacePath,
1513
+ cwd: cwd,
1514
+ initialCwd: initialCwd ?? cwd,
1515
+ planFile: planFile,
1516
+ isInWorkspace: isInWorkspace ?? false,
1517
+ });
1518
+ Logger.debug(
1519
+ `ClaudeCodeProvider.ensureSession: implicitly created ${this.sessionTag(sessionId, sessionName ?? 'unnamed')}`,
1520
+ );
1521
+ this._onDidChangeSessions.fire();
1522
+
1523
+ if (cwd != null) {
1524
+ void this.resolveGitInfo(sessionId, cwd);
1525
+ }
1526
+ void this.resolveTranscriptTitles(sessionId, cwd);
1527
+ return { index: index, changed: true };
1528
+ }
1529
+
1530
+ const existing = this._sessions[index];
1531
+ const updatedPid = pid != null && existing.pid == null ? pid : existing.pid;
1532
+ const updatedWorkspacePath = workspacePath || existing.workspacePath;
1533
+ const updatedIsInWorkspace = workspacePath ? (isInWorkspace ?? existing.isInWorkspace) : existing.isInWorkspace;
1534
+ const updatedPlanFile = planFile ?? existing.planFile;
1535
+ const updatedName = sessionName || existing.name;
1536
+ const updatedCwd = cwd ?? existing.cwd;
1537
+ // Prefer the CLI's authoritative `initialCwd` (the true launch dir it captured first-hand,
1538
+ // even for sessions that started before this window opened); otherwise keep whatever we
1539
+ // already captured, and only as a last resort backfill from the current cwd (older CLIs that
1540
+ // don't send `initialCwd`, or a cold-create before any cwd was known). Comparing live `cwd`
1541
+ // against `initialCwd` is how consumers detect launch-vs-current drift, so it stays stable.
1542
+ const updatedInitialCwd = initialCwd ?? existing.initialCwd ?? cwd;
1543
+
1544
+ let changed = false;
1545
+ if (
1546
+ updatedPid !== existing.pid ||
1547
+ updatedWorkspacePath !== existing.workspacePath ||
1548
+ updatedIsInWorkspace !== existing.isInWorkspace ||
1549
+ updatedPlanFile !== existing.planFile ||
1550
+ updatedName !== existing.name ||
1551
+ updatedCwd !== existing.cwd ||
1552
+ updatedInitialCwd !== existing.initialCwd
1553
+ ) {
1554
+ this._sessions[index] = {
1555
+ ...existing,
1556
+ name: updatedName,
1557
+ pid: updatedPid,
1558
+ workspacePath: updatedWorkspacePath,
1559
+ cwd: updatedCwd,
1560
+ initialCwd: updatedInitialCwd,
1561
+ planFile: updatedPlanFile,
1562
+ isInWorkspace: updatedIsInWorkspace,
1563
+ };
1564
+ changed = true;
1565
+ }
1566
+
1567
+ // Re-fire `resolveGitInfo` if the session is missing either `worktreePath` or `commonPath`.
1568
+ // Peer-synced sessions arrive with `worktreePath` already set (from the peer that owns the
1569
+ // hook flow) but may lack `commonPath` if the peer was running pre-commonPath code; this
1570
+ // gives us a chance to fill it in locally. Idempotent — `resolveGitInfo` only mutates the
1571
+ // session when one of `cwd`/`worktreePath`/`commonPath` actually changes. The
1572
+ // `gitInfoUnresolvable` bookkeeping flag (see `SessionBookkeeping`) is set once
1573
+ // `resolveGitInfo` confirms the cwd isn't a git repo, preventing a retry storm on every
1574
+ // hook event for non-git-repo cwds. The flag is cleared on cwd change (`CwdChanged`) and
1575
+ // on `resetBookkeeping` (Stop/SessionStart) so re-resolution can happen when warranted.
1576
+ if (
1577
+ cwd != null &&
1578
+ (existing.worktreePath == null || existing.commonPath == null) &&
1579
+ !this.getBookkeeping(sessionId).gitInfoUnresolvable
1580
+ ) {
1581
+ void this.resolveGitInfo(sessionId, cwd);
1582
+ }
1583
+ return { index: index, changed: changed };
1584
+ }
1585
+
1586
+ private async resolveGitInfo(sessionId: string, cwd: string): Promise<void> {
1587
+ const resolveGitInfo = this.callbacks.resolveGitInfo;
1588
+ if (resolveGitInfo == null) return;
1589
+
1590
+ // Dedupe concurrent calls for the same session — every hook event (PreToolUse, PostToolUse,
1591
+ // UserPromptSubmit, etc.) flows through `ensureSession`, which retries `resolveGitInfo`
1592
+ // whenever `commonPath`/`worktreePath` is missing. Without this guard a non-git-repo cwd
1593
+ // would spawn a fresh git probe per hook event. The `gitInfoUnresolvable` bookkeeping flag
1594
+ // (set below when `info == null`) is the long-lived suppressor across hook events; this
1595
+ // in-flight set only dedupes concurrent overlapping probes within a single resolution.
1596
+ if (this._resolveGitInfoInFlight.has(sessionId)) return;
1597
+
1598
+ this._resolveGitInfoInFlight.add(sessionId);
1599
+ try {
1600
+ let info: Awaited<ReturnType<typeof resolveGitInfo>> | undefined;
1601
+ try {
1602
+ info = await resolveGitInfo(cwd);
1603
+ } catch {
1604
+ // Git not available or not a git repo — fall through to mark unresolvable below
1605
+ info = undefined;
1606
+ }
1607
+
1608
+ const index = this._sessions.findIndex(s => s.id === sessionId);
1609
+ if (index < 0) return;
1610
+
1611
+ const session = this._sessions[index];
1612
+
1613
+ // `workspacePath` stays as the matched workspace folder (or undefined). Repo identity
1614
+ // flows through `commonPath`, populated here from `info.repoRoot` at the same step as
1615
+ // `worktreePath` — both available together from `resolveGitInfo`, much earlier than the
1616
+ // follow-on worktree-name refresh (which needs `getWorktrees()` on the parent repo).
1617
+ //
1618
+ // When `info == null` (cwd is not inside any git repo), set the `gitInfoUnresolvable`
1619
+ // bookkeeping flag so subsequent `ensureSession`/peer-sync retry checks skip re-firing.
1620
+ // We deliberately do NOT write a tombstone onto the session DTO — keeping `commonPath`
1621
+ // as `undefined` (a) keeps the wire-shape unambiguous for peers, (b) lets consumers
1622
+ // safely treat undefined as "no repo identity", and (c) means peers can still attempt
1623
+ // their own local resolution. The flag is cleared on cwd change (`CwdChanged`) and on
1624
+ // `resetBookkeeping` so re-resolution happens when warranted (e.g. user runs `git init`
1625
+ // followed by a new session start).
1626
+ if (info == null) {
1627
+ this.getBookkeeping(sessionId).gitInfoUnresolvable = true;
1628
+ if (cwd !== session.cwd) {
1629
+ this._sessions[index] = { ...session, cwd: cwd };
1630
+ this._onDidChangeSessions.fire();
1631
+ }
1632
+ return;
1633
+ }
1634
+
1635
+ // Capture the worktree/commonPath at the first successful resolve so consumers can
1636
+ // detect drift (e.g. an agent that `cd`'d into a sibling worktree after launch). Gated
1637
+ // on `initialCommonPath` — captured together with `initialWorktreePath` so a first
1638
+ // resolve where `info.worktreePath` happens to be undefined doesn't leave the latter
1639
+ // open to a later overwrite. Once set, never overwritten.
1640
+ const firstResolve = session.initialCommonPath == null;
1641
+ const updatedInitialWorktreePath = firstResolve ? info.worktreePath : session.initialWorktreePath;
1642
+ const updatedInitialCommonPath = firstResolve ? info.repoRoot : session.initialCommonPath;
1643
+
1644
+ if (
1645
+ cwd !== session.cwd ||
1646
+ info.worktreePath !== session.worktreePath ||
1647
+ info.repoRoot !== session.commonPath ||
1648
+ updatedInitialWorktreePath !== session.initialWorktreePath ||
1649
+ updatedInitialCommonPath !== session.initialCommonPath
1650
+ ) {
1651
+ this._sessions[index] = {
1652
+ ...session,
1653
+ cwd: cwd,
1654
+ worktreePath: info.worktreePath,
1655
+ commonPath: info.repoRoot,
1656
+ initialWorktreePath: updatedInitialWorktreePath,
1657
+ initialCommonPath: updatedInitialCommonPath,
1658
+ };
1659
+ this._onDidChangeSessions.fire();
1660
+ }
1661
+ } finally {
1662
+ this._resolveGitInfoInFlight.delete(sessionId);
1663
+ }
1664
+ }
1665
+
1666
+ private async resolveTranscriptTitles(sessionId: string, cwd: string | undefined): Promise<void> {
1667
+ let titles;
1668
+ try {
1669
+ titles = await this._transcriptReader.resolve(sessionId, cwd);
1670
+ } catch {
1671
+ return;
1672
+ }
1673
+ if (titles == null) return;
1674
+
1675
+ const index = this._sessions.findIndex(s => s.id === sessionId);
1676
+ if (index < 0) return;
1677
+
1678
+ const session = this._sessions[index];
1679
+ const prev = session.transcriptTitles;
1680
+ if (prev?.custom === titles.custom && prev?.ai === titles.ai && prev?.agent === titles.agent) return;
1681
+
1682
+ this._sessions[index] = {
1683
+ ...session,
1684
+ transcriptTitles: { custom: titles.custom, ai: titles.ai, agent: titles.agent },
1685
+ };
1686
+ this._onDidChangeSessions.fire();
1687
+ }
1688
+
1689
+ private pruneDeadSessions(): boolean {
1690
+ const kept: AgentSession[] = [];
1691
+ const removedIds: string[] = [];
1692
+ for (const s of this._sessions) {
1693
+ // A session blocking on us for a permission decision is by definition alive,
1694
+ // even if `kill(pid, 0)` says otherwise (e.g. transient EPERM/ESRCH). Dropping
1695
+ // it here loses pendingPermission and lastPrompt; the next syncSessions then
1696
+ // re-adds it as a stale shell with `permission_requested` status but no detail.
1697
+ if (s.pid == null || isProcessAlive(s.pid) || this._pendingPermissions.has(s.id)) {
1698
+ kept.push(s);
1699
+ } else {
1700
+ removedIds.push(s.id);
1701
+ }
1702
+ }
1703
+ if (removedIds.length === 0) return false;
1704
+
1705
+ this._sessions = kept;
1706
+ for (const id of removedIds) {
1707
+ const pending = this._pendingPermissions.get(id);
1708
+ if (pending != null) {
1709
+ pending.reject(new Error('Session pruned'));
1710
+ this._pendingPermissions.delete(id);
1711
+ }
1712
+ this.cancelPendingIdleTransition(id);
1713
+ // Cancel any in-flight decay-eviction timers before dropping the bookkeeping (mirrors
1714
+ // SessionEnd). A Stopped-then-killed session reaches here with armed cooldown timers
1715
+ // (settleBookkeeping leaves them running), so without this they'd leak until they fire.
1716
+ const bk = this._sessionBookkeeping.get(id);
1717
+ if (bk != null) {
1718
+ this.cancelPendingFileClears(bk);
1719
+ this.cancelPendingReadClears(bk);
1720
+ }
1721
+ this._sessionBookkeeping.delete(id);
1722
+ this._transcriptReader.forget(id);
1723
+ }
1724
+ Logger.debug(
1725
+ `ClaudeCodeProvider.syncSessions: removed ${removedIds.length} stale session(s): ${removedIds.map(id => id.substring(0, 8)).join(', ')}`,
1726
+ );
1727
+ return true;
1728
+ }
1729
+
1730
+ protected async syncSessions(options?: { gate?: boolean }): Promise<void> {
1731
+ // Recurring (gated) calls skip the CLI spawn when there's nothing to reconcile: no tracked
1732
+ // sessions AND no installed hooks. Sessions only ever appear via the IPC push path or a peer,
1733
+ // so an empty list with hooks off means no agents are reachable here. We still poll whenever
1734
+ // sessions exist — that's the only local backstop for pruning agents that die without firing
1735
+ // `SessionEnd`, and it keeps us correct even if hook detection is stale/wrong. The bootstrap
1736
+ // call in `ensureIpcServer` passes no options, so cold-start discovery always runs.
1737
+ if (options?.gate && this._sessions.length === 0 && !this._claudeHooksInstalled) return;
1738
+
1739
+ let sessions: SessionFileData[];
1740
+ try {
1741
+ const output = await this.callbacks.runCLICommand(['ai', 'hook', 'list-sessions', '--json']);
1742
+ sessions = JSON.parse(output) as SessionFileData[];
1743
+ } catch {
1744
+ if (this.pruneDeadSessions()) {
1745
+ this._onDidChangeSessions.fire();
1746
+ }
1747
+ return;
1748
+ }
1749
+
1750
+ // Snapshot what the live IPC path had already tracked, so we can detect drift between it and
1751
+ // what the poll returns. Ideally they match exactly — any difference means the live push path
1752
+ // missed an add (poll discovers it) or a teardown (poll no longer reports a session we track).
1753
+ const trackedBefore = new Set(this._sessions.map(s => s.id));
1754
+ const polledAlive = new Set<string>();
1755
+ // Mutable copy of `trackedBefore` that also accumulates ids added during this poll, so the
1756
+ // membership check below is O(1) and duplicate ids within a single poll response are only
1757
+ // added once. `trackedBefore` itself stays the pre-poll snapshot used by the `missing` calc.
1758
+ const known = new Set(trackedBefore);
1759
+
1760
+ let changed = false;
1761
+ let discovered = 0;
1762
+
1763
+ for (const data of sessions) {
1764
+ if (!data.sessionId || !data.pid || !isProcessAlive(data.pid)) {
1765
+ continue;
1766
+ }
1767
+
1768
+ polledAlive.add(data.sessionId);
1769
+
1770
+ if (known.has(data.sessionId)) continue;
1771
+
1772
+ known.add(data.sessionId);
1773
+
1774
+ const workspacePath = this.resolveWorkspacePath(data.cwd);
1775
+ const isInWorkspace = workspacePath != null;
1776
+ const status = deriveStatusFromEvent(data.event);
1777
+ const phase = getPhaseForStatus(status);
1778
+ const activityDate = new Date(data.updatedAt);
1779
+
1780
+ const subagents: AgentSession[] | undefined = data.subagents?.map(sub => ({
1781
+ id: sub.agentId,
1782
+ providerId: this.id,
1783
+ providerName: this.name,
1784
+ name: sub.agentType ?? 'Subagent',
1785
+ status: 'thinking',
1786
+ phase: 'working' as const,
1787
+ phaseSince: activityDate,
1788
+ lastActivity: activityDate,
1789
+ isSubagent: true,
1790
+ parentId: data.sessionId,
1791
+ isInWorkspace: isInWorkspace,
1792
+ }));
1793
+
1794
+ this._sessions.push({
1795
+ id: data.sessionId,
1796
+ providerId: this.id,
1797
+ providerName: this.name,
1798
+ name: data.sessionName || undefined,
1799
+ status: status,
1800
+ phase: phase,
1801
+ phaseSince: activityDate,
1802
+ pid: data.pid,
1803
+ lastActivity: activityDate,
1804
+ isSubagent: false,
1805
+ workspacePath: workspacePath,
1806
+ cwd: data.cwd,
1807
+ // Prefer the CLI's launch dir; fall back to the snapshot's (possibly drifted) cwd on
1808
+ // older CLIs that don't record it. Without this, a window that cold-starts after the
1809
+ // agent drifted would stamp the drifted cwd as the launch dir.
1810
+ initialCwd: data.initialCwd ?? data.cwd,
1811
+ planFile: data.planFile ?? undefined,
1812
+ isInWorkspace: isInWorkspace,
1813
+ lastPrompt: prepareStoredPrompt(data.prompt ?? undefined),
1814
+ firstPrompt: prepareStoredPrompt(data.firstPrompt ?? undefined),
1815
+ subagents: subagents,
1816
+ });
1817
+ changed = true;
1818
+ discovered++;
1819
+
1820
+ if (data.cwd) {
1821
+ void this.resolveGitInfo(data.sessionId, data.cwd);
1822
+ }
1823
+ void this.resolveTranscriptTitles(data.sessionId, data.cwd);
1824
+ }
1825
+
1826
+ if (this.pruneDeadSessions()) {
1827
+ changed = true;
1828
+ }
1829
+
1830
+ if (changed) {
1831
+ this._onDidChangeSessions.fire();
1832
+ }
1833
+
1834
+ // Report any drift between the live-synced set and the poll. Only on the recurring (gated)
1835
+ // poll — the ungated bootstrap call runs before the live path has had a chance to receive
1836
+ // any events, so its discoveries are expected cold-start state, not drift. `missing` counts
1837
+ // sessions we still track that the poll no longer reports alive (a teardown the live path
1838
+ // missed, e.g. an agent killed without firing `SessionEnd`).
1839
+ if (options?.gate) {
1840
+ let missing = 0;
1841
+ for (const id of trackedBefore) {
1842
+ if (!polledAlive.has(id)) {
1843
+ missing++;
1844
+ }
1845
+ }
1846
+ if (discovered > 0 || missing > 0) {
1847
+ this.callbacks.onSyncDiscrepancy?.({
1848
+ provider: this.id,
1849
+ discovered: discovered,
1850
+ missing: missing,
1851
+ polled: polledAlive.size,
1852
+ tracked: trackedBefore.size,
1853
+ });
1854
+ }
1855
+ }
1856
+ }
1857
+
1858
+ private async querySiblingWindowSessions(): Promise<void> {
1859
+ const discoveryDir = this.callbacks.ipc.agentDiscoveryDir;
1860
+ if (discoveryDir == null) return;
1861
+
1862
+ let files: string[];
1863
+ try {
1864
+ files = await readdir(discoveryDir);
1865
+ } catch {
1866
+ return;
1867
+ }
1868
+
1869
+ const ownPort = this.callbacks.ipc.port;
1870
+
1871
+ const peerBatches = await Promise.all(
1872
+ files
1873
+ .filter(f => f.startsWith('gitlens-ipc-server-') && f.endsWith('.json'))
1874
+ .map(async f => this.fetchPeerSessions(join(discoveryDir, f), ownPort)),
1875
+ );
1876
+
1877
+ let changed = false;
1878
+ // Peer sessions that arrived with `cwd` but no `commonPath` (peer was running pre-
1879
+ // commonPath code, OR the peer's own `resolveGitInfo` failed and we get to retry locally).
1880
+ // Resolved after the merge loop so the local Repository registry is consulted only once
1881
+ // per unique session, off the hot merge path. Keyed by id (value = cwd) so the same
1882
+ // session appearing in multiple peer responses (or re-queued within this loop) only
1883
+ // triggers a single backfill probe.
1884
+ const sessionsNeedingResolve = new Map<string, string>();
1885
+
1886
+ for (const peerSessions of peerBatches) {
1887
+ if (peerSessions == null) continue;
1888
+
1889
+ for (const peerSession of peerSessions) {
1890
+ const peerActivity = new Date(peerSession.lastActivity);
1891
+ const peerPhaseSince = new Date(peerSession.phaseSince);
1892
+
1893
+ const existing = this._sessions.find(s => s.id === peerSession.id);
1894
+
1895
+ if (existing != null) {
1896
+ // If the peer reports a different `cwd` than we last saw, the agent moved
1897
+ // (peer fired a `CwdChanged` event we don't receive locally). Pick up the new
1898
+ // cwd AND reset our local `gitInfoUnresolvable` flag — the new cwd might be a
1899
+ // git repo even if the previous one wasn't.
1900
+ const cwdChanged = peerSession.cwd != null && existing.cwd !== peerSession.cwd;
1901
+ if (cwdChanged && this._sessionBookkeeping.get(peerSession.id)?.gitInfoUnresolvable) {
1902
+ this.getBookkeeping(peerSession.id).gitInfoUnresolvable = false;
1903
+ }
1904
+
1905
+ if (peerActivity > existing.lastActivity) {
1906
+ const idx = this._sessions.indexOf(existing);
1907
+ // Peer is the authoritative hook recipient for this session, so wipe any
1908
+ // local bookkeeping that survived from a previous local-ownership window.
1909
+ // Without this, refcounts/timers from a prior local-hosting stretch would
1910
+ // resurface stale paths if ownership ever flips back to us.
1911
+ const bk = this._sessionBookkeeping.get(peerSession.id);
1912
+ if (bk != null) {
1913
+ this.cancelPendingFileClears(bk);
1914
+ this.cancelPendingReadClears(bk);
1915
+ bk.currentFileCounts.clear();
1916
+ bk.currentReadCounts.clear();
1917
+ bk.lastTouchedAt.clear();
1918
+ }
1919
+ this._sessions[idx] = {
1920
+ ...existing,
1921
+ status: peerSession.status,
1922
+ phase: peerSession.phase,
1923
+ phaseSince: peerPhaseSince,
1924
+ statusDetail: peerSession.statusDetail,
1925
+ lastActivity: peerActivity,
1926
+ subagents: rehydrateSubagents(peerSession.subagents),
1927
+ // Carry the peer's published fileActivity across so peer-window WIP
1928
+ // decorations + treemap heatmap follow the agent. Owned by the peer (it's
1929
+ // the hook recipient); we never originate this field for a remote session.
1930
+ fileActivity: peerSession.fileActivity,
1931
+ // Track the peer's resolved repo identity. The peer owns the hook flow and
1932
+ // re-resolves both on `CwdChanged`, so a worktree move (e.g. the agent
1933
+ // `cd`'d into a sibling worktree of the same repo — `commonPath` unchanged
1934
+ // but `worktreePath` changed) only reaches us if we carry BOTH. Carrying
1935
+ // `commonPath` alone froze the displayed worktree at first-discovery.
1936
+ worktreePath: peerSession.worktreePath ?? existing.worktreePath,
1937
+ commonPath: peerSession.commonPath ?? existing.commonPath,
1938
+ // Pick up the peer's latest cwd so our backfill probe (queued below)
1939
+ // targets the right path. Stale-cwd locally would just re-probe the
1940
+ // non-repo dir and hit the gitInfoUnresolvable retry guard again.
1941
+ cwd: cwdChanged ? peerSession.cwd : existing.cwd,
1942
+ // Launch-state fields are immutable per session: prefer whichever side
1943
+ // already set them (peer that owns the hook flow typically sets first),
1944
+ // and never overwrite a populated local value with the peer's.
1945
+ initialCwd: existing.initialCwd ?? peerSession.initialCwd,
1946
+ initialWorktreePath: existing.initialWorktreePath ?? peerSession.initialWorktreePath,
1947
+ initialCommonPath: existing.initialCommonPath ?? peerSession.initialCommonPath,
1948
+ // Forward peer-discovered transcript titles. Both windows can resolve them
1949
+ // independently against the same on-disk transcript, but only the peer's
1950
+ // hook flow drives its updates — without this, we'd freeze the snapshot
1951
+ // taken at first discovery.
1952
+ transcriptTitles: peerSession.transcriptTitles ?? existing.transcriptTitles,
1953
+ };
1954
+ changed = true;
1955
+ }
1956
+ } else {
1957
+ this._sessions.push({
1958
+ ...peerSession,
1959
+ lastActivity: peerActivity,
1960
+ phaseSince: peerPhaseSince,
1961
+ workspacePath: normalizeWorkspacePath(peerSession.workspacePath),
1962
+ isInWorkspace: this.matchesWorkspace(peerSession.workspacePath),
1963
+ subagents: rehydrateSubagents(peerSession.subagents),
1964
+ // Override whatever the peer published — this is OUR ownership view: the peer
1965
+ // (or some other window) hosts the live session, not us. Drives the dispatcher
1966
+ // to route opens through the peer's IPC + OS-level window focus instead of
1967
+ // calling `claude-vscode.editor.open` locally (which would just open an inert
1968
+ // view in our window).
1969
+ isPeerOwned: true,
1970
+ });
1971
+ changed = true;
1972
+ }
1973
+
1974
+ // Queue a local resolveGitInfo for any peer session that arrived without a
1975
+ // commonPath but has a cwd — we can fill it in from our own git registry. Skip
1976
+ // if our own bookkeeping already marked this session's cwd unresolvable (we tried
1977
+ // locally and the cwd isn't a git repo from our vantage point either).
1978
+ const merged = this._sessions.find(s => s.id === peerSession.id);
1979
+ if (
1980
+ merged?.commonPath == null &&
1981
+ merged?.cwd != null &&
1982
+ !this.getBookkeeping(merged.id).gitInfoUnresolvable
1983
+ ) {
1984
+ sessionsNeedingResolve.set(merged.id, merged.cwd);
1985
+ }
1986
+ }
1987
+ }
1988
+
1989
+ if (changed) {
1990
+ this._onDidChangeSessions.fire();
1991
+ }
1992
+
1993
+ // Backfill commonPath for peer-synced sessions whose owning peer didn't populate it.
1994
+ // `resolveGitInfo` is idempotent — only mutates + fires if it actually finds new info,
1995
+ // so the worst case here is a few wasted git probes for unresolvable cwds.
1996
+ for (const [id, cwd] of sessionsNeedingResolve) {
1997
+ void this.resolveGitInfo(id, cwd);
1998
+ }
1999
+ }
2000
+
2001
+ private async fetchPeerSessions(
2002
+ path: string,
2003
+ ownPort: number | undefined,
2004
+ ): Promise<SerializedAgentSession[] | undefined> {
2005
+ let discovery: DiscoveryFile;
2006
+ try {
2007
+ discovery = JSON.parse(await readFile(path, 'utf8')) as DiscoveryFile;
2008
+ } catch {
2009
+ return undefined;
2010
+ }
2011
+ if (ownPort != null && discovery.port === ownPort) return undefined;
2012
+
2013
+ try {
2014
+ const response = await fetch(`${discovery.address}/agents/sessions/list`, {
2015
+ method: 'POST',
2016
+ headers: { Authorization: `Bearer ${discovery.token}`, 'Content-Type': 'application/json' },
2017
+ body: '{}',
2018
+ signal: AbortSignal.timeout(2000),
2019
+ });
2020
+ if (!response.ok) return undefined;
2021
+ return (await response.json()) as SerializedAgentSession[];
2022
+ } catch {
2023
+ return undefined;
2024
+ }
2025
+ }
2026
+
2027
+ /** Find the peer GitLens window whose discovery file claims `workspacePath`, and POST to its
2028
+ * `/agents/sessions/open` route to ask its Claude Code extension to open the session.
2029
+ *
2030
+ * Resolves to `true` when at least one peer claimed the workspace AND was reachable (the POST
2031
+ * completed with any HTTP status). The peer's response shape (`{ opened: boolean }`) is logged
2032
+ * for diagnostics but does NOT affect the return value — an `opened: false` peer is still the
2033
+ * right window for the caller to focus, since it's the window that owns the session. Resolves
2034
+ * to `false` when no peer claimed the workspace OR every claimer was unreachable; the caller
2035
+ * treats that as "no peer to focus" and opens a new window instead of replacing the current
2036
+ * one. Best-effort: swallows scan, JSON-parse errors. */
2037
+ async notifyPeerOpenSession(workspacePath: string, sessionId: string): Promise<boolean> {
2038
+ const discoveryDir = this.callbacks.ipc.agentDiscoveryDir;
2039
+ if (discoveryDir == null) return false;
2040
+
2041
+ const target = normalizePath(workspacePath);
2042
+ const ownPort = this.callbacks.ipc.port;
2043
+
2044
+ let files: string[];
2045
+ try {
2046
+ files = await readdir(discoveryDir);
2047
+ } catch {
2048
+ return false;
2049
+ }
2050
+
2051
+ const results = await Promise.all(
2052
+ files
2053
+ .filter(f => f.startsWith('gitlens-ipc-server-') && f.endsWith('.json'))
2054
+ .map(async f => {
2055
+ let discovery: DiscoveryFile;
2056
+ try {
2057
+ discovery = JSON.parse(await readFile(join(discoveryDir, f), 'utf8')) as DiscoveryFile;
2058
+ } catch {
2059
+ return false;
2060
+ }
2061
+ if (ownPort != null && discovery.port === ownPort) return false;
2062
+ // Symmetric prefix containment — same shape as `matchesWorkspace()` above.
2063
+ // Strict equality misses the common case where the dispatcher passes a `cwd`
2064
+ // inside the peer's workspace folder (or, less commonly, a parent dir that
2065
+ // contains the peer's workspace).
2066
+ if (
2067
+ !discovery.workspacePaths?.some(p => {
2068
+ const normalized = normalizePath(p);
2069
+ return (
2070
+ normalized === target ||
2071
+ target.startsWith(`${normalized}/`) ||
2072
+ normalized.startsWith(`${target}/`)
2073
+ );
2074
+ })
2075
+ ) {
2076
+ return false;
2077
+ }
2078
+
2079
+ try {
2080
+ const response = await fetch(`${discovery.address}/agents/sessions/open`, {
2081
+ method: 'POST',
2082
+ headers: {
2083
+ Authorization: `Bearer ${discovery.token}`,
2084
+ 'Content-Type': 'application/json',
2085
+ },
2086
+ body: JSON.stringify({ sessionId: sessionId }),
2087
+ signal: AbortSignal.timeout(2000),
2088
+ });
2089
+ if (!response.ok) {
2090
+ Logger.warn(
2091
+ `ClaudeCodeProvider.notifyPeerOpenSession: peer at ${discovery.address} returned ${response.status}`,
2092
+ );
2093
+ } else {
2094
+ // Log a peer that failed to open the specific session, but still treat it
2095
+ // as a reachable claimer of the workspace — focusing that window is still
2096
+ // what the user wants.
2097
+ const body = (await response.json().catch(() => undefined)) as
2098
+ | { opened?: boolean }
2099
+ | undefined;
2100
+ if (body?.opened === false) {
2101
+ Logger.warn(
2102
+ `ClaudeCodeProvider.notifyPeerOpenSession: peer at ${discovery.address} could not open session ${sessionId}`,
2103
+ );
2104
+ }
2105
+ }
2106
+ return true;
2107
+ } catch (ex) {
2108
+ // Peer advertised but unreachable (timeout, RST, etc.). Treat as no match so
2109
+ // the caller opens a new window instead of trying to focus a dead window.
2110
+ Logger.warn(
2111
+ `ClaudeCodeProvider.notifyPeerOpenSession: peer at ${discovery.address} unreachable: ${
2112
+ ex instanceof Error ? ex.message : String(ex)
2113
+ }`,
2114
+ );
2115
+ return false;
2116
+ }
2117
+ }),
2118
+ );
2119
+
2120
+ return results.some(Boolean);
2121
+ }
2122
+ }