@gitkraken/core-gitlens 0.3.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 (170) hide show
  1. package/CHANGELOG.md +8 -1
  2. package/dist/git/cache.d.ts +12 -6
  3. package/dist/git/cache.d.ts.map +1 -1
  4. package/dist/git/cache.js +40 -39
  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/graph.d.ts +96 -0
  14. package/dist/git/models/graph.d.ts.map +1 -1
  15. package/dist/git/models/graph.js +24 -1
  16. package/dist/git/models/graph.js.map +1 -1
  17. package/dist/git/models/remoteProvider.d.ts +1 -0
  18. package/dist/git/models/remoteProvider.d.ts.map +1 -1
  19. package/dist/git/models/remoteProvider.js.map +1 -1
  20. package/dist/git/models/repository.d.ts +21 -12
  21. package/dist/git/models/repository.d.ts.map +1 -1
  22. package/dist/git/models/repository.js +70 -84
  23. package/dist/git/models/repository.js.map +1 -1
  24. package/dist/git/models/statusFile.d.ts +9 -0
  25. package/dist/git/models/statusFile.d.ts.map +1 -1
  26. package/dist/git/models/statusFile.js +11 -0
  27. package/dist/git/models/statusFile.js.map +1 -1
  28. package/dist/git/providers/commits.d.ts +4 -0
  29. package/dist/git/providers/commits.d.ts.map +1 -1
  30. package/dist/git/providers/config.d.ts +1 -3
  31. package/dist/git/providers/config.d.ts.map +1 -1
  32. package/dist/git/providers/operations.d.ts +39 -13
  33. package/dist/git/providers/operations.d.ts.map +1 -1
  34. package/dist/git/remotes/custom.d.ts +2 -0
  35. package/dist/git/remotes/custom.d.ts.map +1 -1
  36. package/dist/git/remotes/custom.js +20 -0
  37. package/dist/git/remotes/custom.js.map +1 -1
  38. package/dist/git/utils/contributor.utils.d.ts +3 -0
  39. package/dist/git/utils/contributor.utils.d.ts.map +1 -1
  40. package/dist/git/utils/contributor.utils.js +22 -0
  41. package/dist/git/utils/contributor.utils.js.map +1 -1
  42. package/dist/git/utils/reachability.utils.d.ts +37 -0
  43. package/dist/git/utils/reachability.utils.d.ts.map +1 -0
  44. package/dist/git/utils/reachability.utils.js +96 -0
  45. package/dist/git/utils/reachability.utils.js.map +1 -0
  46. package/dist/git/watching/watchService.d.ts +4 -2
  47. package/dist/git/watching/watchService.d.ts.map +1 -1
  48. package/dist/git/watching/watchService.js +8 -3
  49. package/dist/git/watching/watchService.js.map +1 -1
  50. package/dist/git/watching/watchSession.d.ts +14 -12
  51. package/dist/git/watching/watchSession.d.ts.map +1 -1
  52. package/dist/git/watching/watchSession.js +56 -55
  53. package/dist/git/watching/watchSession.js.map +1 -1
  54. package/dist/git-cli/providers/branches.d.ts.map +1 -1
  55. package/dist/git-cli/providers/branches.js +1 -0
  56. package/dist/git-cli/providers/branches.js.map +1 -1
  57. package/dist/git-cli/providers/commits.d.ts +1 -0
  58. package/dist/git-cli/providers/commits.d.ts.map +1 -1
  59. package/dist/git-cli/providers/commits.js +12 -0
  60. package/dist/git-cli/providers/commits.js.map +1 -1
  61. package/dist/git-cli/providers/config.d.ts +8 -4
  62. package/dist/git-cli/providers/config.d.ts.map +1 -1
  63. package/dist/git-cli/providers/config.js +62 -39
  64. package/dist/git-cli/providers/config.js.map +1 -1
  65. package/dist/git-cli/providers/diff.d.ts.map +1 -1
  66. package/dist/git-cli/providers/diff.js +1 -1
  67. package/dist/git-cli/providers/diff.js.map +1 -1
  68. package/dist/git-cli/providers/graph.d.ts.map +1 -1
  69. package/dist/git-cli/providers/graph.js +132 -14
  70. package/dist/git-cli/providers/graph.js.map +1 -1
  71. package/dist/git-cli/providers/operations.d.ts +6 -5
  72. package/dist/git-cli/providers/operations.d.ts.map +1 -1
  73. package/dist/git-cli/providers/operations.js +97 -95
  74. package/dist/git-cli/providers/operations.js.map +1 -1
  75. package/dist/git-cli/providers/stash.d.ts.map +1 -1
  76. package/dist/git-cli/providers/stash.js +20 -2
  77. package/dist/git-cli/providers/stash.js.map +1 -1
  78. package/dist/git-cli/providers/status.d.ts.map +1 -1
  79. package/dist/git-cli/providers/status.js +5 -2
  80. package/dist/git-cli/providers/status.js.map +1 -1
  81. package/dist/ipc/discovery.d.ts +22 -0
  82. package/dist/ipc/discovery.d.ts.map +1 -1
  83. package/dist/ipc/discovery.js +111 -2
  84. package/dist/ipc/discovery.js.map +1 -1
  85. package/dist/plus/agents/providers/claudeCodeProvider.d.ts +73 -20
  86. package/dist/plus/agents/providers/claudeCodeProvider.d.ts.map +1 -1
  87. package/dist/plus/agents/providers/claudeCodeProvider.js +394 -88
  88. package/dist/plus/agents/providers/claudeCodeProvider.js.map +1 -1
  89. package/dist/plus/agents/types.d.ts +62 -11
  90. package/dist/plus/agents/types.d.ts.map +1 -1
  91. package/dist/plus/ai/models/model.d.ts +1 -1
  92. package/dist/plus/ai/models/model.d.ts.map +1 -1
  93. package/dist/plus/ai/models/model.js.map +1 -1
  94. package/dist/plus/ai/models/provider.d.ts +3 -0
  95. package/dist/plus/ai/models/provider.d.ts.map +1 -1
  96. package/dist/plus/ai/providers/gitkrakenProvider.d.ts +1 -1
  97. package/dist/plus/ai/providers/gitkrakenProvider.d.ts.map +1 -1
  98. package/dist/plus/ai/providers/gitkrakenProvider.js +4 -1
  99. package/dist/plus/ai/providers/gitkrakenProvider.js.map +1 -1
  100. package/dist/plus/ai/providers/openAICompatibleProviderBase.d.ts +4 -3
  101. package/dist/plus/ai/providers/openAICompatibleProviderBase.d.ts.map +1 -1
  102. package/dist/plus/ai/providers/openAICompatibleProviderBase.js +9 -6
  103. package/dist/plus/ai/providers/openAICompatibleProviderBase.js.map +1 -1
  104. package/dist/plus/ai/utils/ai.utils.d.ts.map +1 -1
  105. package/dist/plus/ai/utils/ai.utils.js +2 -0
  106. package/dist/plus/ai/utils/ai.utils.js.map +1 -1
  107. package/dist/plus/git-github/api/github.d.ts +2 -0
  108. package/dist/plus/git-github/api/github.d.ts.map +1 -1
  109. package/dist/plus/git-github/api/github.js +93 -6
  110. package/dist/plus/git-github/api/github.js.map +1 -1
  111. package/dist/plus/git-github/providers/github/refs.js +1 -1
  112. package/dist/utils/date.d.ts.map +1 -1
  113. package/dist/utils/date.js +6 -0
  114. package/dist/utils/date.js.map +1 -1
  115. package/dist/utils/debounce.js +1 -1
  116. package/dist/utils/iterable.d.ts +7 -1
  117. package/dist/utils/iterable.d.ts.map +1 -1
  118. package/dist/utils/iterable.js +12 -4
  119. package/dist/utils/iterable.js.map +1 -1
  120. package/dist/utils/object.d.ts +22 -1
  121. package/dist/utils/object.d.ts.map +1 -1
  122. package/dist/utils/object.js +56 -3
  123. package/dist/utils/object.js.map +1 -1
  124. package/dist/utils/paging.js +14 -15
  125. package/dist/utils/paging.js.map +1 -1
  126. package/dist/utils/promiseCache.d.ts.map +1 -1
  127. package/dist/utils/promiseCache.js +12 -4
  128. package/dist/utils/promiseCache.js.map +1 -1
  129. package/dist/utils/string.js +1 -1
  130. package/package.json +1 -1
  131. package/src/git/cache.ts +58 -70
  132. package/src/git/context.ts +21 -0
  133. package/src/git/features.ts +2 -0
  134. package/src/git/models/graph.ts +97 -0
  135. package/src/git/models/remoteProvider.ts +1 -0
  136. package/src/git/models/repository.ts +84 -93
  137. package/src/git/models/statusFile.ts +12 -0
  138. package/src/git/providers/commits.ts +4 -0
  139. package/src/git/providers/config.ts +1 -5
  140. package/src/git/providers/operations.ts +42 -22
  141. package/src/git/remotes/custom.ts +23 -0
  142. package/src/git/utils/contributor.utils.ts +24 -0
  143. package/src/git/utils/reachability.utils.ts +109 -0
  144. package/src/git/watching/watchService.ts +9 -4
  145. package/src/git/watching/watchSession.ts +61 -55
  146. package/src/git-cli/providers/branches.ts +1 -0
  147. package/src/git-cli/providers/commits.ts +24 -0
  148. package/src/git-cli/providers/config.ts +63 -49
  149. package/src/git-cli/providers/diff.ts +2 -1
  150. package/src/git-cli/providers/graph.ts +145 -14
  151. package/src/git-cli/providers/operations.ts +118 -130
  152. package/src/git-cli/providers/stash.ts +20 -2
  153. package/src/git-cli/providers/status.ts +11 -3
  154. package/src/ipc/discovery.ts +132 -2
  155. package/src/plus/agents/providers/claudeCodeProvider.ts +430 -90
  156. package/src/plus/agents/types.ts +59 -11
  157. package/src/plus/ai/models/model.ts +1 -0
  158. package/src/plus/ai/models/provider.ts +7 -1
  159. package/src/plus/ai/providers/gitkrakenProvider.ts +4 -0
  160. package/src/plus/ai/providers/openAICompatibleProviderBase.ts +21 -4
  161. package/src/plus/ai/utils/ai.utils.ts +2 -0
  162. package/src/plus/git-github/api/github.ts +120 -8
  163. package/src/plus/git-github/providers/github/refs.ts +1 -1
  164. package/src/utils/date.ts +5 -0
  165. package/src/utils/debounce.ts +1 -1
  166. package/src/utils/iterable.ts +14 -3
  167. package/src/utils/object.ts +55 -4
  168. package/src/utils/paging.ts +14 -14
  169. package/src/utils/promiseCache.ts +24 -16
  170. package/src/utils/string.ts +1 -1
@@ -15,12 +15,12 @@ import { prepareStoredPrompt } from '../sanitizePrompt.js';
15
15
  import { classifyPermissionKind, deriveStatusFromEvent, describeToolInput, extractPlanSummary, extractQuestionDetails, getToolFilePath, getToolReadPath, isProcessAlive, rehydrateSubagents, } from '../stateMachine.js';
16
16
  import { getPhaseForStatus } from '../types.js';
17
17
  import { ClaudeCodeTranscriptReader } from './claudeCodeTranscript.js';
18
- const staleCheckIntervalMs = 60 * 1000; // 1 minute
19
- /** Cooldown between PostToolUse and dropping the file from `currentFiles`. Held long enough that
20
- * surfaces consuming the list (WIP file decoration, treemap activity overlay) keep showing a file
21
- * the agent just touched well past the moment the tool call completed a single edit reads as a
22
- * sustained presence rather than a flash. */
23
- const postToolUseClearDelayMs = 120000;
18
+ const staleCheckIntervalMs = 15 * 60 * 1000; // 15 minutes
19
+ /** Default cooldown between PostToolUse and dropping the file from `fileActivity`. Held long
20
+ * enough that the treemap activity overlay can render a decay tail well past the moment the tool
21
+ * call completed. The host may override per-call via `AgentProviderCallbacks.getActivityDecayMs`
22
+ * (driven by the user's `gitlens.graph.experimental.visualizations.activityDecay` setting). */
23
+ const defaultActivityDecayMs = 5 * 60 * 1000; // 5 minutes
24
24
  /** Grace period before `Stop` commits to `idle`. Long enough to absorb a same-turn continuation
25
25
  * (hook-driven re-prompt, IPC event reordering, auto-resume); short enough that legitimate idle
26
26
  * is visible promptly. */
@@ -38,21 +38,33 @@ const phaseSinceRestoreWindowMs = 2000;
38
38
  function normalizeWorkspacePath(value) {
39
39
  return value ? normalizePath(value) : undefined;
40
40
  }
41
- /** Order-insensitive comparison entries come from a `Map.values()` iterator, but tracked
42
- * tool-use ids are uncorrelated with file order, so two snapshots with the same files in a
43
- * different order shouldn't fire a no-op change event. Treats `undefined` and `[]` as equal. */
44
- function currentFilesEqual(a, b) {
41
+ /** Structural comparison of two `fileActivity` arrays same set of paths, same kinds present,
42
+ * same `reading`/`editing` flags. Ignores numeric timestamp values because those drift between
43
+ * every call (Date.now() advances) even when nothing has actually changed. Used to gate event
44
+ * firing: timestamps still get refreshed via the `_sessions[]` write, but a fire only happens
45
+ * when the structural shape changed. Treats `undefined` and `[]` as equal. */
46
+ function fileActivityStructurallyEqual(a, b) {
45
47
  const aLen = a?.length ?? 0;
46
48
  const bLen = b?.length ?? 0;
47
49
  if (aLen !== bLen)
48
50
  return false;
49
51
  if (aLen === 0)
50
52
  return true;
51
- if (aLen === 1)
52
- return a[0] === b[0];
53
- const set = new Set(a);
54
- for (const v of b) {
55
- if (!set.has(v))
53
+ const byPath = new Map();
54
+ for (const entry of a) {
55
+ byPath.set(entry.path, entry);
56
+ }
57
+ for (const entry of b) {
58
+ const other = byPath.get(entry.path);
59
+ if (other == null)
60
+ return false;
61
+ if ((entry.readAt != null) !== (other.readAt != null))
62
+ return false;
63
+ if ((entry.editedAt != null) !== (other.editedAt != null))
64
+ return false;
65
+ if ((entry.reading ?? false) !== (other.reading ?? false))
66
+ return false;
67
+ if ((entry.editing ?? false) !== (other.editing ?? false))
56
68
  return false;
57
69
  }
58
70
  return true;
@@ -76,6 +88,10 @@ export class ClaudeCodeProvider {
76
88
  _pendingIdleTimers = new Map();
77
89
  _workspacePaths = [];
78
90
  _staleCheckTimer;
91
+ /** Whether Claude hooks are installed, pushed by the host via {@link setClaudeHooksInstalled}.
92
+ * Fail-open (`true`) until the first push lands so a fresh window with real hooks isn't
93
+ * suppressed on its first ticks. Gates the reconciliation poll in {@link syncSessions}. */
94
+ _claudeHooksInstalled = true;
79
95
  _transcriptReader = new ClaudeCodeTranscriptReader();
80
96
  constructor(callbacks) {
81
97
  this.callbacks = callbacks;
@@ -132,6 +148,19 @@ export class ClaudeCodeProvider {
132
148
  .catch((ex) => Logger.error(ex, 'ClaudeCodeProvider.updateWorkspacePaths: publishAgents failed'));
133
149
  }
134
150
  }
151
+ setClaudeHooksInstalled(installed) {
152
+ const wasOff = !this._claudeHooksInstalled;
153
+ this._claudeHooksInstalled = installed;
154
+ // On a fresh off→on transition, reconcile immediately rather than waiting up to a full
155
+ // interval — picks up any session that was already running before hooks were installed.
156
+ // This is a deliberate discovery pass (like the cold-start bootstrap), not a routine tick:
157
+ // hooks were just off, so the live path couldn't have seen these sessions and anything we
158
+ // find here is expected, not drift. Run it ungated so it neither skips nor reports drift
159
+ // telemetry (an ungated `syncSessions()` always polls and never emits `onSyncDiscrepancy`).
160
+ if (installed && wasOff) {
161
+ void this.syncSessions();
162
+ }
163
+ }
135
164
  dispose() {
136
165
  this._disposed = true;
137
166
  this.stop();
@@ -233,7 +262,7 @@ export class ClaudeCodeProvider {
233
262
  return;
234
263
  void this.querySiblingWindowSessions();
235
264
  this._staleCheckTimer?.dispose();
236
- this._staleCheckTimer = disposableInterval(() => void this.syncSessions(), staleCheckIntervalMs);
265
+ this._staleCheckTimer = disposableInterval(() => void this.syncSessions({ gate: true }), staleCheckIntervalMs);
237
266
  }
238
267
  catch (ex) {
239
268
  Logger.error(ex, 'ClaudeCodeProvider.ensureIpcServer');
@@ -246,6 +275,7 @@ export class ClaudeCodeProvider {
246
275
  workspacePath: workspacePath,
247
276
  isInWorkspace: workspacePath != null,
248
277
  cwd: event.cwd,
278
+ initialCwd: event.initialCwd,
249
279
  planFile: event.planFile,
250
280
  sessionName: event.sessionName,
251
281
  };
@@ -340,7 +370,7 @@ export class ClaudeCodeProvider {
340
370
  const toolName = hookInput?.tool_name ?? event.toolName ?? '';
341
371
  const statusDetail = toolInput != null && toolName ? describeToolInput(toolName, toolInput) : toolName || undefined;
342
372
  // Capture pre-update status so we can tell whether the upcoming updateSessionStatus
343
- // will fire on its own — needed to avoid dropping currentFiles changes when the
373
+ // will fire on its own — needed to avoid dropping fileActivity changes when the
344
374
  // session is already in tool_use (back-to-back tool calls).
345
375
  const sessionIndex = this._sessions.findIndex(s => s.id === event.sessionId);
346
376
  const prevStatus = sessionIndex >= 0 ? this._sessions[sessionIndex].status : undefined;
@@ -362,11 +392,28 @@ export class ClaudeCodeProvider {
362
392
  bk.activeToolCount = Math.max(0, bk.activeToolCount - 1);
363
393
  // Cooldown the file-edit decoration instead of dropping it immediately, so a quick
364
394
  // follow-up Edit/Write to the same path doesn't blink. The deferred clear fires its
365
- // own change event when it expires.
366
- this.scheduleClearToolUseFile(event.sessionId, event);
367
- this.scheduleClearToolUseRead(event.sessionId, event);
395
+ // own change event when it expires. The immediate re-sync inside each flips
396
+ // `editing`/`reading` off the moment refcount hits zero, though — capture whether that
397
+ // changed the published list so we fire it ourselves when the status update below short-
398
+ // circuits (a parallel tool is still in flight, so activeToolCount stays > 0).
399
+ const filesChanged = this.scheduleClearToolUseFile(event.sessionId, event);
400
+ const readsChanged = this.scheduleClearToolUseRead(event.sessionId, event);
368
401
  if (bk.activeToolCount === 0) {
402
+ // updateSessionStatus short-circuits without firing when status+detail are unchanged
403
+ // (already 'thinking' with no detail) — which would drop the editing/reading flag-flip
404
+ // above and leave a stale live pulse. Capture whether it will fire on its own and, if
405
+ // not, fire explicitly — mirrors the PreToolUse / PermissionDenied guards.
406
+ const idx = this._sessions.findIndex(s => s.id === event.sessionId);
407
+ const statusWillFire = idx < 0 ||
408
+ this._sessions[idx].status !== 'thinking' ||
409
+ this._sessions[idx].statusDetail != null;
369
410
  this.updateSessionStatus(event.sessionId, 'thinking', eventContext);
411
+ if ((filesChanged || readsChanged) && !statusWillFire) {
412
+ this._onDidChangeSessions.fire();
413
+ }
414
+ }
415
+ else if (filesChanged || readsChanged) {
416
+ this._onDidChangeSessions.fire();
370
417
  }
371
418
  break;
372
419
  }
@@ -377,7 +424,10 @@ export class ClaudeCodeProvider {
377
424
  pending.reject(new Error('Session stopped'));
378
425
  this._pendingPermissions.delete(event.sessionId);
379
426
  }
380
- this.resetBookkeeping(event.sessionId);
427
+ // Turn ended: stop the "live" pulse but keep the per-operation decay tail fading over
428
+ // the configured window (anchored at each tool's completion, not at the turn end). A full
429
+ // reset here would wipe the tail the instant the agent finishes a response.
430
+ this.settleBookkeeping(event.sessionId);
381
431
  // Apply Stop's metadata synchronously so we don't carry stale context through the
382
432
  // debounce window — any in-window mutations (e.g. CwdChanged) survive when the
383
433
  // timer fires. ensureSession doesn't fire on metadata-only updates of existing
@@ -586,12 +636,39 @@ export class ClaudeCodeProvider {
586
636
  pendingFileClears: new Map(),
587
637
  currentReadCounts: new Map(),
588
638
  pendingReadClears: new Map(),
639
+ lastTouchedAt: new Map(),
589
640
  gitInfoUnresolvable: false,
590
641
  };
591
642
  this._sessionBookkeeping.set(sessionId, bk);
592
643
  }
593
644
  return bk;
594
645
  }
646
+ /** Returns the active decay window in milliseconds — the cooldown between PostToolUse and
647
+ * dropping the path from the bookkeeping. Reads through the host callback every time so a
648
+ * setting change takes effect on the next Pre/Post pair without re-wiring (existing timers
649
+ * keep their original timeout — acceptable since they're short-lived relative to the setting). */
650
+ get activityDecayMs() {
651
+ return this.callbacks.getActivityDecayMs?.() ?? defaultActivityDecayMs;
652
+ }
653
+ /** Locates the *parent* session id that owns the given session id — returns the id itself when
654
+ * it matches a top-level session, otherwise the `id` of the parent that holds it under
655
+ * `subagents[]`. Returns `undefined` when neither match (session no longer exists). Used to
656
+ * route per-session bookkeeping changes to the right top-level `syncSessionFileActivity` call.
657
+ * Tool events are parent-keyed (the CLI sends a sub-agent's tool events under the parent id),
658
+ * so the `subagents[]` branch is a defensive fallback for any other-keyed caller. */
659
+ findOwningParentId(sessionId) {
660
+ for (const s of this._sessions) {
661
+ if (s.id === sessionId)
662
+ return s.id;
663
+ if (s.subagents != null) {
664
+ for (const sub of s.subagents) {
665
+ if (sub.id === sessionId)
666
+ return s.id;
667
+ }
668
+ }
669
+ }
670
+ return undefined;
671
+ }
595
672
  resetBookkeeping(sessionId) {
596
673
  const bk = this.getBookkeeping(sessionId);
597
674
  bk.activeToolCount = 0;
@@ -600,9 +677,43 @@ export class ClaudeCodeProvider {
600
677
  bk.currentFileCounts.clear();
601
678
  this.cancelPendingReadClears(bk);
602
679
  bk.currentReadCounts.clear();
680
+ bk.lastTouchedAt.clear();
603
681
  bk.gitInfoUnresolvable = false;
604
- this.syncSessionCurrentFiles(sessionId, bk);
605
- this.syncSessionCurrentReads(sessionId, bk);
682
+ const parentId = this.findOwningParentId(sessionId);
683
+ if (parentId != null) {
684
+ this.syncSessionFileActivity(parentId);
685
+ }
686
+ }
687
+ /** Turn-end settle (Stop): the agent finished its turn, so nothing is "live" anymore — but
688
+ * the per-operation decay tail must survive. Heat fades over {@link activityDecayMs} measured
689
+ * from when each *tool* finished (the PostToolUse cooldown), NOT from when the turn ends, so a
690
+ * Stop must not cancel those cooldowns or clear `lastTouchedAt`. We only reset turn-scoped
691
+ * state and force-finish any path whose PostToolUse never arrived before Stop: drop its
692
+ * refcount to 0 (flipping `editing`/`reading` off so the pulse stops) and schedule the same
693
+ * decay eviction a normal completion would. Contrast {@link resetBookkeeping}, which hard-
694
+ * wipes everything for SessionStart/SessionEnd (the agent is gone, not merely between turns). */
695
+ settleBookkeeping(sessionId) {
696
+ const bk = this.getBookkeeping(sessionId);
697
+ bk.activeToolCount = 0;
698
+ bk.pendingPermission = undefined;
699
+ // Allow git re-resolution next turn (matches the prior reset-on-Stop intent).
700
+ bk.gitInfoUnresolvable = false;
701
+ for (const [path, count] of bk.currentFileCounts) {
702
+ if (count > 0) {
703
+ bk.currentFileCounts.set(path, 0);
704
+ this.scheduleFileDecayEviction(bk, sessionId, path);
705
+ }
706
+ }
707
+ for (const [path, count] of bk.currentReadCounts) {
708
+ if (count > 0) {
709
+ bk.currentReadCounts.set(path, 0);
710
+ this.scheduleReadDecayEviction(bk, sessionId, path);
711
+ }
712
+ }
713
+ const parentId = this.findOwningParentId(sessionId);
714
+ if (parentId != null && this.syncSessionFileActivity(parentId)) {
715
+ this._onDidChangeSessions.fire();
716
+ }
606
717
  }
607
718
  cancelPendingFileClears(bk) {
608
719
  for (const timer of bk.pendingFileClears.values()) {
@@ -630,11 +741,37 @@ export class ClaudeCodeProvider {
630
741
  const toolInput = hookInputPopulated ? rawHookInput : event.toolInput;
631
742
  return getToolFilePath(toolName, toolInput);
632
743
  }
633
- /** Records that a file-mutating tool call is in flight against `filePath` so the WIP details
634
- * panel can decorate the row. Refcounts per path keyed by path (not `tool_use_id`, which
635
- * the GK CLI doesn't reliably surface) so parallel calls bump the count and Post/Pre order
636
- * doesn't matter. Returns whether the session's published list actually changed so callers
637
- * can fire when the surrounding {@link updateSessionStatus} short-circuits. */
744
+ /** Stamps the per-path/per-kind `lastTouchedAt[kind]` to the current epoch ms. Stamped on every
745
+ * PreToolUse for the matching kind so `serializeFileActivity` can emit `readAt`/`editedAt` as
746
+ * a relative `now - timestamp` delta. */
747
+ stampLastTouched(bk, filePath, kind) {
748
+ const existing = bk.lastTouchedAt.get(filePath);
749
+ const next = existing != null ? { ...existing } : {};
750
+ next[kind] = Date.now();
751
+ bk.lastTouchedAt.set(filePath, next);
752
+ }
753
+ /** Drops the `lastTouchedAt[kind]` slot for `filePath`, and the whole entry when neither kind
754
+ * remains. Mirrors the cooldown-timer eviction in `scheduleClear*` — once the path is gone
755
+ * from the count map for that kind, its timestamp is no longer load-bearing. */
756
+ clearLastTouched(bk, filePath, kind) {
757
+ const existing = bk.lastTouchedAt.get(filePath);
758
+ if (existing == null)
759
+ return;
760
+ if (existing[kind] == null)
761
+ return;
762
+ const next = { ...existing, [kind]: undefined };
763
+ if (next.edit == null && next.read == null) {
764
+ bk.lastTouchedAt.delete(filePath);
765
+ }
766
+ else {
767
+ bk.lastTouchedAt.set(filePath, next);
768
+ }
769
+ }
770
+ /** Records that a file-mutating tool call is in flight against `filePath` so consumers can
771
+ * highlight the path. Refcounts per path — keyed by path (not `tool_use_id`, which the GK CLI
772
+ * doesn't reliably surface) so parallel calls bump the count and Post/Pre order doesn't matter.
773
+ * Returns whether the parent's published `fileActivity` actually changed so callers can fire
774
+ * when the surrounding {@link updateSessionStatus} short-circuits. */
638
775
  trackToolUseFile(sessionId, event) {
639
776
  const filePath = this.getEventFilePath(event);
640
777
  if (filePath == null)
@@ -648,31 +785,46 @@ export class ClaudeCodeProvider {
648
785
  bk.pendingFileClears.delete(filePath);
649
786
  }
650
787
  bk.currentFileCounts.set(filePath, (bk.currentFileCounts.get(filePath) ?? 0) + 1);
651
- return this.syncSessionCurrentFiles(sessionId, bk);
788
+ this.stampLastTouched(bk, filePath, 'edit');
789
+ return this.syncSessionFileActivityForChild(sessionId);
652
790
  }
653
791
  /** Decrements the in-flight refcount for a finished tool call's file. While the count stays
654
- * above zero (overlapping parallel calls) the path remains decorated. When the count drops
655
- * to zero, leaves the entry in place at count `0` and schedules a {@link postToolUseClearDelayMs}
656
- * cooldown before actually dropping it — so back-to-back Edit/Write to the same file doesn't
657
- * flicker the decoration. A Pre arriving during cooldown cancels the timer and bumps the
658
- * count back above zero. */
792
+ * above zero (overlapping parallel calls) the path remains marked. When the count drops to
793
+ * zero, leaves the entry in place at count `0` and schedules a {@link activityDecayMs}
794
+ * cooldown before actually dropping it — so a recently-edited file lingers as a decay tail
795
+ * on the treemap. A Pre arriving during cooldown cancels the timer and bumps the count back
796
+ * above zero. Returns whether the parent's published `fileActivity` changed (i.e. `editing`
797
+ * flipped off) so the PostToolUse caller can fire when its `updateSessionStatus` short-
798
+ * circuits (a parallel tool still in flight keeps activeToolCount > 0). */
659
799
  scheduleClearToolUseFile(sessionId, event) {
660
800
  const filePath = this.getEventFilePath(event);
661
801
  if (filePath == null)
662
- return;
802
+ return false;
663
803
  const bk = this.getBookkeeping(sessionId);
664
804
  const count = bk.currentFileCounts.get(filePath);
665
805
  // A duplicate / out-of-order Post during cooldown (count is already 0) would drive the
666
806
  // refcount negative and schedule a second timer — ignore those so the cooldown stays
667
807
  // authoritative until either its timer fires or a fresh Pre bumps the count back up.
668
808
  if (count == null || count <= 0)
669
- return;
809
+ return false;
670
810
  const next = count - 1;
671
811
  bk.currentFileCounts.set(filePath, next);
812
+ // Re-sync so `editing` drops from `true` to `undefined` the moment refcount hits zero —
813
+ // the cooldown only governs when the path itself leaves the snapshot, not when the
814
+ // "live" boolean flips off.
815
+ const changed = this.syncSessionFileActivityForChild(sessionId);
672
816
  if (next > 0)
673
- return;
674
- // Refcount just hit zero — keep the path in `currentFileCounts` (still decorated) for the
675
- // cooldown window, then drop it.
817
+ return changed;
818
+ // Refcount just hit zero — keep the path in `currentFileCounts` for the cooldown window,
819
+ // then drop it.
820
+ this.scheduleFileDecayEviction(bk, sessionId, filePath);
821
+ return changed;
822
+ }
823
+ /** Schedules (replacing any prior timer) the {@link activityDecayMs} cooldown that finally
824
+ * drops `filePath` from the edit bookkeeping once it has fully decayed. Shared by the
825
+ * PostToolUse cooldown path and the Stop settle ({@link settleBookkeeping}); the path stays
826
+ * in `currentFileCounts` at count 0 meanwhile so its `editedAt` keeps aging on the client. */
827
+ scheduleFileDecayEviction(bk, sessionId, filePath) {
676
828
  const existing = bk.pendingFileClears.get(filePath);
677
829
  if (existing != null) {
678
830
  clearTimeout(existing);
@@ -689,14 +841,15 @@ export class ClaudeCodeProvider {
689
841
  if ((cur.currentFileCounts.get(filePath) ?? 0) > 0)
690
842
  return;
691
843
  cur.currentFileCounts.delete(filePath);
692
- if (this.syncSessionCurrentFiles(sessionId, cur)) {
844
+ this.clearLastTouched(cur, filePath, 'edit');
845
+ if (this.syncSessionFileActivityForChild(sessionId)) {
693
846
  this._onDidChangeSessions.fire();
694
847
  }
695
- }, postToolUseClearDelayMs);
848
+ }, this.activityDecayMs);
696
849
  bk.pendingFileClears.set(filePath, timer);
697
850
  }
698
851
  /** Drops a tracked tool call's file immediately (no cooldown). Used for PermissionDenied,
699
- * where the tool never ran — keeping a "currently editing" badge would be misleading.
852
+ * where the tool never ran — keeping the "live" badge would be misleading.
700
853
  * Returns whether the published list changed. */
701
854
  untrackToolUseFile(sessionId, event) {
702
855
  const filePath = this.getEventFilePath(event);
@@ -708,7 +861,7 @@ export class ClaudeCodeProvider {
708
861
  return false;
709
862
  if (count > 1) {
710
863
  bk.currentFileCounts.set(filePath, count - 1);
711
- return false;
864
+ return this.syncSessionFileActivityForChild(sessionId);
712
865
  }
713
866
  const pending = bk.pendingFileClears.get(filePath);
714
867
  if (pending != null) {
@@ -716,21 +869,8 @@ export class ClaudeCodeProvider {
716
869
  bk.pendingFileClears.delete(filePath);
717
870
  }
718
871
  bk.currentFileCounts.delete(filePath);
719
- return this.syncSessionCurrentFiles(sessionId, bk);
720
- }
721
- /** Writes the bookkeeping's tracked file paths onto the session in place. Returns whether the
722
- * session's `currentFiles` actually changed. Keys (not values) — entries with count `0` are
723
- * cooldown-residents, still part of the published list. */
724
- syncSessionCurrentFiles(sessionId, bk) {
725
- const index = this._sessions.findIndex(s => s.id === sessionId);
726
- if (index < 0)
727
- return false;
728
- const prev = this._sessions[index];
729
- const next = bk.currentFileCounts.size > 0 ? [...bk.currentFileCounts.keys()] : undefined;
730
- if (currentFilesEqual(prev.currentFiles, next))
731
- return false;
732
- this._sessions[index] = { ...prev, currentFiles: next };
733
- return true;
872
+ this.clearLastTouched(bk, filePath, 'edit');
873
+ return this.syncSessionFileActivityForChild(sessionId);
734
874
  }
735
875
  /** Mirror of {@link getEventFilePath} for read-only file tools. */
736
876
  getEventReadPath(event) {
@@ -754,23 +894,32 @@ export class ClaudeCodeProvider {
754
894
  bk.pendingReadClears.delete(filePath);
755
895
  }
756
896
  bk.currentReadCounts.set(filePath, (bk.currentReadCounts.get(filePath) ?? 0) + 1);
757
- return this.syncSessionCurrentReads(sessionId, bk);
897
+ this.stampLastTouched(bk, filePath, 'read');
898
+ return this.syncSessionFileActivityForChild(sessionId);
758
899
  }
759
- /** Cooldown mirror of {@link scheduleClearToolUseFile} for read-only file tools. */
900
+ /** Cooldown mirror of {@link scheduleClearToolUseFile} for read-only file tools. Returns
901
+ * whether the parent's published `fileActivity` changed (i.e. `reading` flipped off). */
760
902
  scheduleClearToolUseRead(sessionId, event) {
761
903
  const filePath = this.getEventReadPath(event);
762
904
  if (filePath == null)
763
- return;
905
+ return false;
764
906
  const bk = this.getBookkeeping(sessionId);
765
907
  const count = bk.currentReadCounts.get(filePath);
766
908
  // Same guard as scheduleClearToolUseFile — duplicate / out-of-order Posts during cooldown
767
909
  // would otherwise drive the refcount negative and stack up redundant clear timers.
768
910
  if (count == null || count <= 0)
769
- return;
911
+ return false;
770
912
  const next = count - 1;
771
913
  bk.currentReadCounts.set(filePath, next);
914
+ // Re-sync so `reading` drops to `undefined` the moment refcount hits zero.
915
+ const changed = this.syncSessionFileActivityForChild(sessionId);
772
916
  if (next > 0)
773
- return;
917
+ return changed;
918
+ this.scheduleReadDecayEviction(bk, sessionId, filePath);
919
+ return changed;
920
+ }
921
+ /** Read-class mirror of {@link scheduleFileDecayEviction}. */
922
+ scheduleReadDecayEviction(bk, sessionId, filePath) {
774
923
  const existing = bk.pendingReadClears.get(filePath);
775
924
  if (existing != null) {
776
925
  clearTimeout(existing);
@@ -785,10 +934,11 @@ export class ClaudeCodeProvider {
785
934
  if ((cur.currentReadCounts.get(filePath) ?? 0) > 0)
786
935
  return;
787
936
  cur.currentReadCounts.delete(filePath);
788
- if (this.syncSessionCurrentReads(sessionId, cur)) {
937
+ this.clearLastTouched(cur, filePath, 'read');
938
+ if (this.syncSessionFileActivityForChild(sessionId)) {
789
939
  this._onDidChangeSessions.fire();
790
940
  }
791
- }, postToolUseClearDelayMs);
941
+ }, this.activityDecayMs);
792
942
  bk.pendingReadClears.set(filePath, timer);
793
943
  }
794
944
  /** Immediate-drop mirror of {@link untrackToolUseFile} for read-only file tools. */
@@ -802,7 +952,7 @@ export class ClaudeCodeProvider {
802
952
  return false;
803
953
  if (count > 1) {
804
954
  bk.currentReadCounts.set(filePath, count - 1);
805
- return false;
955
+ return this.syncSessionFileActivityForChild(sessionId);
806
956
  }
807
957
  const pending = bk.pendingReadClears.get(filePath);
808
958
  if (pending != null) {
@@ -810,20 +960,89 @@ export class ClaudeCodeProvider {
810
960
  bk.pendingReadClears.delete(filePath);
811
961
  }
812
962
  bk.currentReadCounts.delete(filePath);
813
- return this.syncSessionCurrentReads(sessionId, bk);
963
+ this.clearLastTouched(bk, filePath, 'read');
964
+ return this.syncSessionFileActivityForChild(sessionId);
814
965
  }
815
- /** Sync mirror of {@link syncSessionCurrentFiles} for read paths. Uses {@link currentFilesEqual}
816
- * (which is just a generic order-insensitive `string[] | undefined` comparator). */
817
- syncSessionCurrentReads(sessionId, bk) {
818
- const index = this._sessions.findIndex(s => s.id === sessionId);
819
- if (index < 0)
966
+ /** Convenience wrapper that resolves the parent for a possibly-sub-agent session id, then calls
967
+ * {@link syncSessionFileActivity}. Tool-call handlers call this without knowing whether the
968
+ * session is a top-level or sub-agent session. Returns `false` when the session isn't found
969
+ * (already pruned / unknown peer id). */
970
+ syncSessionFileActivityForChild(sessionId) {
971
+ const parentId = this.findOwningParentId(sessionId);
972
+ if (parentId == null)
820
973
  return false;
821
- const prev = this._sessions[index];
822
- const next = bk.currentReadCounts.size > 0 ? [...bk.currentReadCounts.keys()] : undefined;
823
- if (currentFilesEqual(prev.currentReads, next))
974
+ return this.syncSessionFileActivity(parentId);
975
+ }
976
+ /** Rebuilds the parent session's `fileActivity` array from its bookkeeping and writes it onto
977
+ * the session in place. Returns whether the *structural* shape changed (paths/kinds/flags) —
978
+ * timestamps are always refreshed regardless, but a fire is gated on structural change so
979
+ * non-meaningful drift doesn't churn the wire.
980
+ *
981
+ * No separate sub-agent rollup is needed: the GK CLI keys a sub-agent's tool events under its
982
+ * PARENT session id (with `agentId` set), so they accumulate in the parent's own bookkeeping
983
+ * directly and are already reflected here. Sub-agent serialized objects carry no `fileActivity`. */
984
+ syncSessionFileActivity(parentSessionId) {
985
+ const index = this._sessions.findIndex(s => s.id === parentSessionId);
986
+ if (index < 0)
824
987
  return false;
825
- this._sessions[index] = { ...prev, currentReads: next };
826
- return true;
988
+ const parent = this._sessions[index];
989
+ const parentBk = this._sessionBookkeeping.get(parentSessionId);
990
+ const merged = new Map();
991
+ const contributeFrom = (bk) => {
992
+ if (bk == null)
993
+ return;
994
+ for (const [path, count] of bk.currentFileCounts) {
995
+ let entry = merged.get(path);
996
+ if (entry == null) {
997
+ entry = { editCount: 0, readCount: 0 };
998
+ merged.set(path, entry);
999
+ }
1000
+ entry.editCount += count;
1001
+ const ts = bk.lastTouchedAt.get(path)?.edit;
1002
+ if (ts != null && (entry.editAt == null || ts > entry.editAt)) {
1003
+ entry.editAt = ts;
1004
+ }
1005
+ }
1006
+ for (const [path, count] of bk.currentReadCounts) {
1007
+ let entry = merged.get(path);
1008
+ if (entry == null) {
1009
+ entry = { editCount: 0, readCount: 0 };
1010
+ merged.set(path, entry);
1011
+ }
1012
+ entry.readCount += count;
1013
+ const ts = bk.lastTouchedAt.get(path)?.read;
1014
+ if (ts != null && (entry.readAt == null || ts > entry.readAt)) {
1015
+ entry.readAt = ts;
1016
+ }
1017
+ }
1018
+ };
1019
+ contributeFrom(parentBk);
1020
+ const now = Date.now();
1021
+ let next;
1022
+ if (merged.size > 0) {
1023
+ next = [];
1024
+ for (const [path, contrib] of merged) {
1025
+ const entry = { path: path };
1026
+ if (contrib.editAt != null) {
1027
+ entry.editedAt = Math.max(0, now - contrib.editAt);
1028
+ }
1029
+ if (contrib.readAt != null) {
1030
+ entry.readAt = Math.max(0, now - contrib.readAt);
1031
+ }
1032
+ if (contrib.editCount > 0) {
1033
+ entry.editing = true;
1034
+ }
1035
+ if (contrib.readCount > 0) {
1036
+ entry.reading = true;
1037
+ }
1038
+ next.push(entry);
1039
+ }
1040
+ }
1041
+ const changed = !fileActivityStructurallyEqual(parent.fileActivity, next);
1042
+ // Always replace so timestamps reflect the latest serialization, even when the structural
1043
+ // shape is unchanged (so a downstream fire from anywhere else carries fresh `sinceMs`).
1044
+ this._sessions[index] = { ...parent, fileActivity: next };
1045
+ return changed;
827
1046
  }
828
1047
  /** Defer the `Stop → idle` commit. If the agent produces a non-idle event within
829
1048
  * `stopToIdleDebounceMs`, `cancelPendingIdleTransition` (called from `updateSessionStatus`)
@@ -993,7 +1212,7 @@ export class ClaudeCodeProvider {
993
1212
  }
994
1213
  }
995
1214
  ensureSession(sessionId, context) {
996
- const { pid, workspacePath, isInWorkspace, cwd, planFile, sessionName } = context ?? {};
1215
+ const { pid, workspacePath, isInWorkspace, cwd, initialCwd, planFile, sessionName } = context ?? {};
997
1216
  let index = this._sessions.findIndex(s => s.id === sessionId);
998
1217
  if (index < 0) {
999
1218
  const now = new Date();
@@ -1011,6 +1230,7 @@ export class ClaudeCodeProvider {
1011
1230
  isSubagent: false,
1012
1231
  workspacePath: workspacePath,
1013
1232
  cwd: cwd,
1233
+ initialCwd: initialCwd ?? cwd,
1014
1234
  planFile: planFile,
1015
1235
  isInWorkspace: isInWorkspace ?? false,
1016
1236
  });
@@ -1029,19 +1249,27 @@ export class ClaudeCodeProvider {
1029
1249
  const updatedPlanFile = planFile ?? existing.planFile;
1030
1250
  const updatedName = sessionName || existing.name;
1031
1251
  const updatedCwd = cwd ?? existing.cwd;
1252
+ // Prefer the CLI's authoritative `initialCwd` (the true launch dir it captured first-hand,
1253
+ // even for sessions that started before this window opened); otherwise keep whatever we
1254
+ // already captured, and only as a last resort backfill from the current cwd (older CLIs that
1255
+ // don't send `initialCwd`, or a cold-create before any cwd was known). Comparing live `cwd`
1256
+ // against `initialCwd` is how consumers detect launch-vs-current drift, so it stays stable.
1257
+ const updatedInitialCwd = initialCwd ?? existing.initialCwd ?? cwd;
1032
1258
  let changed = false;
1033
1259
  if (updatedPid !== existing.pid ||
1034
1260
  updatedWorkspacePath !== existing.workspacePath ||
1035
1261
  updatedIsInWorkspace !== existing.isInWorkspace ||
1036
1262
  updatedPlanFile !== existing.planFile ||
1037
1263
  updatedName !== existing.name ||
1038
- updatedCwd !== existing.cwd) {
1264
+ updatedCwd !== existing.cwd ||
1265
+ updatedInitialCwd !== existing.initialCwd) {
1039
1266
  this._sessions[index] = {
1040
1267
  ...existing,
1041
1268
  name: updatedName,
1042
1269
  pid: updatedPid,
1043
1270
  workspacePath: updatedWorkspacePath,
1044
1271
  cwd: updatedCwd,
1272
+ initialCwd: updatedInitialCwd,
1045
1273
  planFile: updatedPlanFile,
1046
1274
  isInWorkspace: updatedIsInWorkspace,
1047
1275
  };
@@ -1110,14 +1338,26 @@ export class ClaudeCodeProvider {
1110
1338
  }
1111
1339
  return;
1112
1340
  }
1341
+ // Capture the worktree/commonPath at the first successful resolve so consumers can
1342
+ // detect drift (e.g. an agent that `cd`'d into a sibling worktree after launch). Gated
1343
+ // on `initialCommonPath` — captured together with `initialWorktreePath` so a first
1344
+ // resolve where `info.worktreePath` happens to be undefined doesn't leave the latter
1345
+ // open to a later overwrite. Once set, never overwritten.
1346
+ const firstResolve = session.initialCommonPath == null;
1347
+ const updatedInitialWorktreePath = firstResolve ? info.worktreePath : session.initialWorktreePath;
1348
+ const updatedInitialCommonPath = firstResolve ? info.repoRoot : session.initialCommonPath;
1113
1349
  if (cwd !== session.cwd ||
1114
1350
  info.worktreePath !== session.worktreePath ||
1115
- info.repoRoot !== session.commonPath) {
1351
+ info.repoRoot !== session.commonPath ||
1352
+ updatedInitialWorktreePath !== session.initialWorktreePath ||
1353
+ updatedInitialCommonPath !== session.initialCommonPath) {
1116
1354
  this._sessions[index] = {
1117
1355
  ...session,
1118
1356
  cwd: cwd,
1119
1357
  worktreePath: info.worktreePath,
1120
1358
  commonPath: info.repoRoot,
1359
+ initialWorktreePath: updatedInitialWorktreePath,
1360
+ initialCommonPath: updatedInitialCommonPath,
1121
1361
  };
1122
1362
  this._onDidChangeSessions.fire();
1123
1363
  }
@@ -1174,13 +1414,29 @@ export class ClaudeCodeProvider {
1174
1414
  this._pendingPermissions.delete(id);
1175
1415
  }
1176
1416
  this.cancelPendingIdleTransition(id);
1417
+ // Cancel any in-flight decay-eviction timers before dropping the bookkeeping (mirrors
1418
+ // SessionEnd). A Stopped-then-killed session reaches here with armed cooldown timers
1419
+ // (settleBookkeeping leaves them running), so without this they'd leak until they fire.
1420
+ const bk = this._sessionBookkeeping.get(id);
1421
+ if (bk != null) {
1422
+ this.cancelPendingFileClears(bk);
1423
+ this.cancelPendingReadClears(bk);
1424
+ }
1177
1425
  this._sessionBookkeeping.delete(id);
1178
1426
  this._transcriptReader.forget(id);
1179
1427
  }
1180
1428
  Logger.debug(`ClaudeCodeProvider.syncSessions: removed ${removedIds.length} stale session(s): ${removedIds.map(id => id.substring(0, 8)).join(', ')}`);
1181
1429
  return true;
1182
1430
  }
1183
- async syncSessions() {
1431
+ async syncSessions(options) {
1432
+ // Recurring (gated) calls skip the CLI spawn when there's nothing to reconcile: no tracked
1433
+ // sessions AND no installed hooks. Sessions only ever appear via the IPC push path or a peer,
1434
+ // so an empty list with hooks off means no agents are reachable here. We still poll whenever
1435
+ // sessions exist — that's the only local backstop for pruning agents that die without firing
1436
+ // `SessionEnd`, and it keeps us correct even if hook detection is stale/wrong. The bootstrap
1437
+ // call in `ensureIpcServer` passes no options, so cold-start discovery always runs.
1438
+ if (options?.gate && this._sessions.length === 0 && !this._claudeHooksInstalled)
1439
+ return;
1184
1440
  let sessions;
1185
1441
  try {
1186
1442
  const output = await this.callbacks.runCLICommand(['ai', 'hook', 'list-sessions', '--json']);
@@ -1192,13 +1448,25 @@ export class ClaudeCodeProvider {
1192
1448
  }
1193
1449
  return;
1194
1450
  }
1451
+ // Snapshot what the live IPC path had already tracked, so we can detect drift between it and
1452
+ // what the poll returns. Ideally they match exactly — any difference means the live push path
1453
+ // missed an add (poll discovers it) or a teardown (poll no longer reports a session we track).
1454
+ const trackedBefore = new Set(this._sessions.map(s => s.id));
1455
+ const polledAlive = new Set();
1456
+ // Mutable copy of `trackedBefore` that also accumulates ids added during this poll, so the
1457
+ // membership check below is O(1) and duplicate ids within a single poll response are only
1458
+ // added once. `trackedBefore` itself stays the pre-poll snapshot used by the `missing` calc.
1459
+ const known = new Set(trackedBefore);
1195
1460
  let changed = false;
1461
+ let discovered = 0;
1196
1462
  for (const data of sessions) {
1197
1463
  if (!data.sessionId || !data.pid || !isProcessAlive(data.pid)) {
1198
1464
  continue;
1199
1465
  }
1200
- if (this._sessions.some(s => s.id === data.sessionId))
1466
+ polledAlive.add(data.sessionId);
1467
+ if (known.has(data.sessionId))
1201
1468
  continue;
1469
+ known.add(data.sessionId);
1202
1470
  const workspacePath = this.resolveWorkspacePath(data.cwd);
1203
1471
  const isInWorkspace = workspacePath != null;
1204
1472
  const status = deriveStatusFromEvent(data.event);
@@ -1230,6 +1498,10 @@ export class ClaudeCodeProvider {
1230
1498
  isSubagent: false,
1231
1499
  workspacePath: workspacePath,
1232
1500
  cwd: data.cwd,
1501
+ // Prefer the CLI's launch dir; fall back to the snapshot's (possibly drifted) cwd on
1502
+ // older CLIs that don't record it. Without this, a window that cold-starts after the
1503
+ // agent drifted would stamp the drifted cwd as the launch dir.
1504
+ initialCwd: data.initialCwd ?? data.cwd,
1233
1505
  planFile: data.planFile ?? undefined,
1234
1506
  isInWorkspace: isInWorkspace,
1235
1507
  lastPrompt: prepareStoredPrompt(data.prompt ?? undefined),
@@ -1237,6 +1509,7 @@ export class ClaudeCodeProvider {
1237
1509
  subagents: subagents,
1238
1510
  });
1239
1511
  changed = true;
1512
+ discovered++;
1240
1513
  if (data.cwd) {
1241
1514
  void this.resolveGitInfo(data.sessionId, data.cwd);
1242
1515
  }
@@ -1248,6 +1521,28 @@ export class ClaudeCodeProvider {
1248
1521
  if (changed) {
1249
1522
  this._onDidChangeSessions.fire();
1250
1523
  }
1524
+ // Report any drift between the live-synced set and the poll. Only on the recurring (gated)
1525
+ // poll — the ungated bootstrap call runs before the live path has had a chance to receive
1526
+ // any events, so its discoveries are expected cold-start state, not drift. `missing` counts
1527
+ // sessions we still track that the poll no longer reports alive (a teardown the live path
1528
+ // missed, e.g. an agent killed without firing `SessionEnd`).
1529
+ if (options?.gate) {
1530
+ let missing = 0;
1531
+ for (const id of trackedBefore) {
1532
+ if (!polledAlive.has(id)) {
1533
+ missing++;
1534
+ }
1535
+ }
1536
+ if (discovered > 0 || missing > 0) {
1537
+ this.callbacks.onSyncDiscrepancy?.({
1538
+ provider: this.id,
1539
+ discovered: discovered,
1540
+ missing: missing,
1541
+ polled: polledAlive.size,
1542
+ tracked: trackedBefore.size,
1543
+ });
1544
+ }
1545
+ }
1251
1546
  }
1252
1547
  async querySiblingWindowSessions() {
1253
1548
  const discoveryDir = this.callbacks.ipc.agentDiscoveryDir;
@@ -1300,6 +1595,7 @@ export class ClaudeCodeProvider {
1300
1595
  this.cancelPendingReadClears(bk);
1301
1596
  bk.currentFileCounts.clear();
1302
1597
  bk.currentReadCounts.clear();
1598
+ bk.lastTouchedAt.clear();
1303
1599
  }
1304
1600
  this._sessions[idx] = {
1305
1601
  ...existing,
@@ -1309,17 +1605,27 @@ export class ClaudeCodeProvider {
1309
1605
  statusDetail: peerSession.statusDetail,
1310
1606
  lastActivity: peerActivity,
1311
1607
  subagents: rehydrateSubagents(peerSession.subagents),
1312
- // Carry the peer's in-flight file-edit set + repo identity across so
1313
- // peer-window WIP decorations + sameFamily anchoring follow the agent.
1314
- // Owned by the peer (it's the hook recipient); we never originate these
1315
- // fields for a remote session.
1316
- currentFiles: peerSession.currentFiles,
1317
- currentReads: peerSession.currentReads,
1608
+ // Carry the peer's published fileActivity across so peer-window WIP
1609
+ // decorations + treemap heatmap follow the agent. Owned by the peer (it's
1610
+ // the hook recipient); we never originate this field for a remote session.
1611
+ fileActivity: peerSession.fileActivity,
1612
+ // Track the peer's resolved repo identity. The peer owns the hook flow and
1613
+ // re-resolves both on `CwdChanged`, so a worktree move (e.g. the agent
1614
+ // `cd`'d into a sibling worktree of the same repo — `commonPath` unchanged
1615
+ // but `worktreePath` changed) only reaches us if we carry BOTH. Carrying
1616
+ // `commonPath` alone froze the displayed worktree at first-discovery.
1617
+ worktreePath: peerSession.worktreePath ?? existing.worktreePath,
1318
1618
  commonPath: peerSession.commonPath ?? existing.commonPath,
1319
1619
  // Pick up the peer's latest cwd so our backfill probe (queued below)
1320
1620
  // targets the right path. Stale-cwd locally would just re-probe the
1321
1621
  // non-repo dir and hit the gitInfoUnresolvable retry guard again.
1322
1622
  cwd: cwdChanged ? peerSession.cwd : existing.cwd,
1623
+ // Launch-state fields are immutable per session: prefer whichever side
1624
+ // already set them (peer that owns the hook flow typically sets first),
1625
+ // and never overwrite a populated local value with the peer's.
1626
+ initialCwd: existing.initialCwd ?? peerSession.initialCwd,
1627
+ initialWorktreePath: existing.initialWorktreePath ?? peerSession.initialWorktreePath,
1628
+ initialCommonPath: existing.initialCommonPath ?? peerSession.initialCommonPath,
1323
1629
  // Forward peer-discovered transcript titles. Both windows can resolve them
1324
1630
  // independently against the same on-disk transcript, but only the peer's
1325
1631
  // hook flow drives its updates — without this, we'd freeze the snapshot