@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
@@ -37,6 +37,10 @@ interface AgentSessionEvent {
37
37
  event: ClaudeCodeHookEvent;
38
38
  sessionId: string;
39
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;
40
44
  pid?: number;
41
45
  source?: string;
42
46
  model?: string;
@@ -84,11 +88,17 @@ interface SessionBookkeeping {
84
88
  pendingFileClears: Map<string, NodeJS.Timeout>;
85
89
  /** Refcount of in-flight read-only file tool calls (Read/NotebookRead) per absolute path.
86
90
  * Mirrors {@link currentFileCounts} so consumers can distinguish "looking at" from "working
87
- * on" without overloading `currentFiles`. */
91
+ * on". */
88
92
  currentReadCounts: Map<string, number>;
89
93
  /** Per-path cooldown timers for reads — same semantics as {@link pendingFileClears}, keeps a
90
94
  * recently-read file visible briefly after the read completes. */
91
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 }>;
92
102
  /** Set to true when `resolveGitInfo` for the session's current cwd returned `undefined`
93
103
  * (cwd is not a git repo). Prevents the `ensureSession` retry from firing forever on
94
104
  * every subsequent hook event. Cleared when the session's cwd changes (re-resolution
@@ -100,12 +110,12 @@ interface SessionBookkeeping {
100
110
  priorPhase?: { phase: AgentSessionPhase; phaseSince: Date };
101
111
  }
102
112
 
103
- const staleCheckIntervalMs = 60 * 1000; // 1 minute
104
- /** Cooldown between PostToolUse and dropping the file from `currentFiles`. Held long enough that
105
- * surfaces consuming the list (WIP file decoration, treemap activity overlay) keep showing a file
106
- * the agent just touched well past the moment the tool call completed a single edit reads as a
107
- * sustained presence rather than a flash. */
108
- const postToolUseClearDelayMs = 120000;
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
109
119
 
110
120
  /** Grace period before `Stop` commits to `idle`. Long enough to absorb a same-turn continuation
111
121
  * (hook-driven re-prompt, IPC event reordering, auto-resume); short enough that legitimate idle
@@ -122,6 +132,8 @@ interface SessionFileData {
122
132
  sessionId: string;
123
133
  event: string;
124
134
  cwd: string;
135
+ /** CLI-provided launch directory; absent on older CLIs (fall back to `cwd`). */
136
+ initialCwd?: string;
125
137
  pid: number;
126
138
  toolName?: string | null;
127
139
  agentId?: string | null;
@@ -148,6 +160,8 @@ interface SessionContext {
148
160
  workspacePath?: string;
149
161
  isInWorkspace?: boolean;
150
162
  cwd?: string;
163
+ /** CLI-provided launch directory (authoritative when present). */
164
+ initialCwd?: string;
151
165
  planFile?: string;
152
166
  sessionName?: string;
153
167
  }
@@ -170,19 +184,33 @@ function normalizeWorkspacePath(value: string | null | undefined): string | unde
170
184
  return value ? normalizePath(value) : undefined;
171
185
  }
172
186
 
173
- /** Order-insensitive comparison — entries come from a `Map.values()` iterator, but tracked
174
- * tool-use ids are uncorrelated with file order, so two snapshots with the same files in a
175
- * different order shouldn't fire a no-op change event. Treats `undefined` and `[]` as equal. */
176
- function currentFilesEqual(a: readonly string[] | undefined, b: readonly string[] | undefined): boolean {
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 {
177
198
  const aLen = a?.length ?? 0;
178
199
  const bLen = b?.length ?? 0;
179
200
  if (aLen !== bLen) return false;
180
201
  if (aLen === 0) return true;
181
- if (aLen === 1) return a![0] === b![0];
182
202
 
183
- const set = new Set(a);
184
- for (const v of b!) {
185
- if (!set.has(v)) return false;
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;
186
214
  }
187
215
  return true;
188
216
  }
@@ -207,6 +235,10 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
207
235
  private readonly _pendingIdleTimers = new Map<string, NodeJS.Timeout>();
208
236
  private _workspacePaths: string[] = [];
209
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;
210
242
  protected _transcriptReader: ClaudeCodeTranscriptReader = new ClaudeCodeTranscriptReader();
211
243
 
212
244
  constructor(private readonly callbacks: AgentProviderCallbacks) {}
@@ -271,6 +303,20 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
271
303
  }
272
304
  }
273
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
+
274
320
  dispose(): void {
275
321
  this._disposed = true;
276
322
  this.stop();
@@ -386,7 +432,10 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
386
432
  void this.querySiblingWindowSessions();
387
433
 
388
434
  this._staleCheckTimer?.dispose();
389
- this._staleCheckTimer = disposableInterval(() => void this.syncSessions(), staleCheckIntervalMs);
435
+ this._staleCheckTimer = disposableInterval(
436
+ () => void this.syncSessions({ gate: true }),
437
+ staleCheckIntervalMs,
438
+ );
390
439
  } catch (ex) {
391
440
  Logger.error(ex, 'ClaudeCodeProvider.ensureIpcServer');
392
441
  }
@@ -399,6 +448,7 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
399
448
  workspacePath: workspacePath,
400
449
  isInWorkspace: workspacePath != null,
401
450
  cwd: event.cwd,
451
+ initialCwd: event.initialCwd,
402
452
  planFile: event.planFile,
403
453
  sessionName: event.sessionName,
404
454
  };
@@ -503,7 +553,7 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
503
553
  const statusDetail =
504
554
  toolInput != null && toolName ? describeToolInput(toolName, toolInput) : toolName || undefined;
505
555
  // Capture pre-update status so we can tell whether the upcoming updateSessionStatus
506
- // will fire on its own — needed to avoid dropping currentFiles changes when the
556
+ // will fire on its own — needed to avoid dropping fileActivity changes when the
507
557
  // session is already in tool_use (back-to-back tool calls).
508
558
  const sessionIndex = this._sessions.findIndex(s => s.id === event.sessionId);
509
559
  const prevStatus = sessionIndex >= 0 ? this._sessions[sessionIndex].status : undefined;
@@ -526,11 +576,28 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
526
576
  bk.activeToolCount = Math.max(0, bk.activeToolCount - 1);
527
577
  // Cooldown the file-edit decoration instead of dropping it immediately, so a quick
528
578
  // follow-up Edit/Write to the same path doesn't blink. The deferred clear fires its
529
- // own change event when it expires.
530
- this.scheduleClearToolUseFile(event.sessionId, event);
531
- this.scheduleClearToolUseRead(event.sessionId, event);
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);
532
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;
533
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();
534
601
  }
535
602
  break;
536
603
  }
@@ -542,7 +609,10 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
542
609
  pending.reject(new Error('Session stopped'));
543
610
  this._pendingPermissions.delete(event.sessionId);
544
611
  }
545
- this.resetBookkeeping(event.sessionId);
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);
546
616
  // Apply Stop's metadata synchronously so we don't carry stale context through the
547
617
  // debounce window — any in-window mutations (e.g. CwdChanged) survive when the
548
618
  // timer fires. ensureSession doesn't fire on metadata-only updates of existing
@@ -778,6 +848,7 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
778
848
  pendingFileClears: new Map(),
779
849
  currentReadCounts: new Map(),
780
850
  pendingReadClears: new Map(),
851
+ lastTouchedAt: new Map(),
781
852
  gitInfoUnresolvable: false,
782
853
  };
783
854
  this._sessionBookkeeping.set(sessionId, bk);
@@ -785,6 +856,33 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
785
856
  return bk;
786
857
  }
787
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
+
788
886
  private resetBookkeeping(sessionId: string): void {
789
887
  const bk = this.getBookkeeping(sessionId);
790
888
  bk.activeToolCount = 0;
@@ -793,9 +891,44 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
793
891
  bk.currentFileCounts.clear();
794
892
  this.cancelPendingReadClears(bk);
795
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).
796
915
  bk.gitInfoUnresolvable = false;
797
- this.syncSessionCurrentFiles(sessionId, bk);
798
- this.syncSessionCurrentReads(sessionId, bk);
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
+ }
799
932
  }
800
933
 
801
934
  private cancelPendingFileClears(bk: SessionBookkeeping): void {
@@ -827,11 +960,37 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
827
960
  return getToolFilePath(toolName, toolInput);
828
961
  }
829
962
 
830
- /** Records that a file-mutating tool call is in flight against `filePath` so the WIP details
831
- * panel can decorate the row. Refcounts per path keyed by path (not `tool_use_id`, which
832
- * the GK CLI doesn't reliably surface) so parallel calls bump the count and Post/Pre order
833
- * doesn't matter. Returns whether the session's published list actually changed so callers
834
- * can fire when the surrounding {@link updateSessionStatus} short-circuits. */
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. */
835
994
  private trackToolUseFile(sessionId: string, event: AgentSessionEvent): boolean {
836
995
  const filePath = this.getEventFilePath(event);
837
996
  if (filePath == null) return false;
@@ -845,32 +1004,48 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
845
1004
  bk.pendingFileClears.delete(filePath);
846
1005
  }
847
1006
  bk.currentFileCounts.set(filePath, (bk.currentFileCounts.get(filePath) ?? 0) + 1);
848
- return this.syncSessionCurrentFiles(sessionId, bk);
1007
+ this.stampLastTouched(bk, filePath, 'edit');
1008
+ return this.syncSessionFileActivityForChild(sessionId);
849
1009
  }
850
1010
 
851
1011
  /** Decrements the in-flight refcount for a finished tool call's file. While the count stays
852
- * above zero (overlapping parallel calls) the path remains decorated. When the count drops
853
- * to zero, leaves the entry in place at count `0` and schedules a {@link postToolUseClearDelayMs}
854
- * cooldown before actually dropping it — so back-to-back Edit/Write to the same file doesn't
855
- * flicker the decoration. A Pre arriving during cooldown cancels the timer and bumps the
856
- * count back above zero. */
857
- private scheduleClearToolUseFile(sessionId: string, event: AgentSessionEvent): void {
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 {
858
1020
  const filePath = this.getEventFilePath(event);
859
- if (filePath == null) return;
1021
+ if (filePath == null) return false;
860
1022
 
861
1023
  const bk = this.getBookkeeping(sessionId);
862
1024
  const count = bk.currentFileCounts.get(filePath);
863
1025
  // A duplicate / out-of-order Post during cooldown (count is already 0) would drive the
864
1026
  // refcount negative and schedule a second timer — ignore those so the cooldown stays
865
1027
  // authoritative until either its timer fires or a fresh Pre bumps the count back up.
866
- if (count == null || count <= 0) return;
1028
+ if (count == null || count <= 0) return false;
867
1029
 
868
1030
  const next = count - 1;
869
1031
  bk.currentFileCounts.set(filePath, next);
870
- if (next > 0) return;
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
+ }
871
1043
 
872
- // Refcount just hit zero keep the path in `currentFileCounts` (still decorated) for the
873
- // cooldown window, then drop it.
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 {
874
1049
  const existing = bk.pendingFileClears.get(filePath);
875
1050
  if (existing != null) {
876
1051
  clearTimeout(existing);
@@ -888,15 +1063,16 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
888
1063
  if ((cur.currentFileCounts.get(filePath) ?? 0) > 0) return;
889
1064
 
890
1065
  cur.currentFileCounts.delete(filePath);
891
- if (this.syncSessionCurrentFiles(sessionId, cur)) {
1066
+ this.clearLastTouched(cur, filePath, 'edit');
1067
+ if (this.syncSessionFileActivityForChild(sessionId)) {
892
1068
  this._onDidChangeSessions.fire();
893
1069
  }
894
- }, postToolUseClearDelayMs);
1070
+ }, this.activityDecayMs);
895
1071
  bk.pendingFileClears.set(filePath, timer);
896
1072
  }
897
1073
 
898
1074
  /** Drops a tracked tool call's file immediately (no cooldown). Used for PermissionDenied,
899
- * where the tool never ran — keeping a "currently editing" badge would be misleading.
1075
+ * where the tool never ran — keeping the "live" badge would be misleading.
900
1076
  * Returns whether the published list changed. */
901
1077
  private untrackToolUseFile(sessionId: string, event: AgentSessionEvent): boolean {
902
1078
  const filePath = this.getEventFilePath(event);
@@ -907,7 +1083,7 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
907
1083
  if (count == null) return false;
908
1084
  if (count > 1) {
909
1085
  bk.currentFileCounts.set(filePath, count - 1);
910
- return false;
1086
+ return this.syncSessionFileActivityForChild(sessionId);
911
1087
  }
912
1088
 
913
1089
  const pending = bk.pendingFileClears.get(filePath);
@@ -916,22 +1092,8 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
916
1092
  bk.pendingFileClears.delete(filePath);
917
1093
  }
918
1094
  bk.currentFileCounts.delete(filePath);
919
- return this.syncSessionCurrentFiles(sessionId, bk);
920
- }
921
-
922
- /** Writes the bookkeeping's tracked file paths onto the session in place. Returns whether the
923
- * session's `currentFiles` actually changed. Keys (not values) — entries with count `0` are
924
- * cooldown-residents, still part of the published list. */
925
- private syncSessionCurrentFiles(sessionId: string, bk: SessionBookkeeping): boolean {
926
- const index = this._sessions.findIndex(s => s.id === sessionId);
927
- if (index < 0) return false;
928
-
929
- const prev = this._sessions[index];
930
- const next = bk.currentFileCounts.size > 0 ? [...bk.currentFileCounts.keys()] : undefined;
931
- if (currentFilesEqual(prev.currentFiles, next)) return false;
932
-
933
- this._sessions[index] = { ...prev, currentFiles: next };
934
- return true;
1095
+ this.clearLastTouched(bk, filePath, 'edit');
1096
+ return this.syncSessionFileActivityForChild(sessionId);
935
1097
  }
936
1098
 
937
1099
  /** Mirror of {@link getEventFilePath} for read-only file tools. */
@@ -957,24 +1119,34 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
957
1119
  bk.pendingReadClears.delete(filePath);
958
1120
  }
959
1121
  bk.currentReadCounts.set(filePath, (bk.currentReadCounts.get(filePath) ?? 0) + 1);
960
- return this.syncSessionCurrentReads(sessionId, bk);
1122
+ this.stampLastTouched(bk, filePath, 'read');
1123
+ return this.syncSessionFileActivityForChild(sessionId);
961
1124
  }
962
1125
 
963
- /** Cooldown mirror of {@link scheduleClearToolUseFile} for read-only file tools. */
964
- private scheduleClearToolUseRead(sessionId: string, event: AgentSessionEvent): void {
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 {
965
1129
  const filePath = this.getEventReadPath(event);
966
- if (filePath == null) return;
1130
+ if (filePath == null) return false;
967
1131
 
968
1132
  const bk = this.getBookkeeping(sessionId);
969
1133
  const count = bk.currentReadCounts.get(filePath);
970
1134
  // Same guard as scheduleClearToolUseFile — duplicate / out-of-order Posts during cooldown
971
1135
  // would otherwise drive the refcount negative and stack up redundant clear timers.
972
- if (count == null || count <= 0) return;
1136
+ if (count == null || count <= 0) return false;
973
1137
 
974
1138
  const next = count - 1;
975
1139
  bk.currentReadCounts.set(filePath, next);
976
- if (next > 0) return;
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
+ }
977
1147
 
1148
+ /** Read-class mirror of {@link scheduleFileDecayEviction}. */
1149
+ private scheduleReadDecayEviction(bk: SessionBookkeeping, sessionId: string, filePath: string): void {
978
1150
  const existing = bk.pendingReadClears.get(filePath);
979
1151
  if (existing != null) {
980
1152
  clearTimeout(existing);
@@ -990,10 +1162,11 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
990
1162
  if ((cur.currentReadCounts.get(filePath) ?? 0) > 0) return;
991
1163
 
992
1164
  cur.currentReadCounts.delete(filePath);
993
- if (this.syncSessionCurrentReads(sessionId, cur)) {
1165
+ this.clearLastTouched(cur, filePath, 'read');
1166
+ if (this.syncSessionFileActivityForChild(sessionId)) {
994
1167
  this._onDidChangeSessions.fire();
995
1168
  }
996
- }, postToolUseClearDelayMs);
1169
+ }, this.activityDecayMs);
997
1170
  bk.pendingReadClears.set(filePath, timer);
998
1171
  }
999
1172
 
@@ -1007,7 +1180,7 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
1007
1180
  if (count == null) return false;
1008
1181
  if (count > 1) {
1009
1182
  bk.currentReadCounts.set(filePath, count - 1);
1010
- return false;
1183
+ return this.syncSessionFileActivityForChild(sessionId);
1011
1184
  }
1012
1185
 
1013
1186
  const pending = bk.pendingReadClears.get(filePath);
@@ -1016,21 +1189,96 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
1016
1189
  bk.pendingReadClears.delete(filePath);
1017
1190
  }
1018
1191
  bk.currentReadCounts.delete(filePath);
1019
- return this.syncSessionCurrentReads(sessionId, bk);
1192
+ this.clearLastTouched(bk, filePath, 'read');
1193
+ return this.syncSessionFileActivityForChild(sessionId);
1020
1194
  }
1021
1195
 
1022
- /** Sync mirror of {@link syncSessionCurrentFiles} for read paths. Uses {@link currentFilesEqual}
1023
- * (which is just a generic order-insensitive `string[] | undefined` comparator). */
1024
- private syncSessionCurrentReads(sessionId: string, bk: SessionBookkeeping): boolean {
1025
- const index = this._sessions.findIndex(s => s.id === sessionId);
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);
1026
1216
  if (index < 0) return false;
1027
1217
 
1028
- const prev = this._sessions[index];
1029
- const next = bk.currentReadCounts.size > 0 ? [...bk.currentReadCounts.keys()] : undefined;
1030
- if (currentFilesEqual(prev.currentReads, next)) return false;
1218
+ const parent = this._sessions[index];
1219
+ const parentBk = this._sessionBookkeeping.get(parentSessionId);
1031
1220
 
1032
- this._sessions[index] = { ...prev, currentReads: next };
1033
- return true;
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;
1034
1282
  }
1035
1283
 
1036
1284
  /** Defer the `Stop → idle` commit. If the agent produces a non-idle event within
@@ -1244,7 +1492,7 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
1244
1492
  }
1245
1493
 
1246
1494
  private ensureSession(sessionId: string, context?: SessionContext): { index: number; changed: boolean } {
1247
- const { pid, workspacePath, isInWorkspace, cwd, planFile, sessionName } = context ?? {};
1495
+ const { pid, workspacePath, isInWorkspace, cwd, initialCwd, planFile, sessionName } = context ?? {};
1248
1496
 
1249
1497
  let index = this._sessions.findIndex(s => s.id === sessionId);
1250
1498
  if (index < 0) {
@@ -1263,6 +1511,7 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
1263
1511
  isSubagent: false,
1264
1512
  workspacePath: workspacePath,
1265
1513
  cwd: cwd,
1514
+ initialCwd: initialCwd ?? cwd,
1266
1515
  planFile: planFile,
1267
1516
  isInWorkspace: isInWorkspace ?? false,
1268
1517
  });
@@ -1285,6 +1534,12 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
1285
1534
  const updatedPlanFile = planFile ?? existing.planFile;
1286
1535
  const updatedName = sessionName || existing.name;
1287
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;
1288
1543
 
1289
1544
  let changed = false;
1290
1545
  if (
@@ -1293,7 +1548,8 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
1293
1548
  updatedIsInWorkspace !== existing.isInWorkspace ||
1294
1549
  updatedPlanFile !== existing.planFile ||
1295
1550
  updatedName !== existing.name ||
1296
- updatedCwd !== existing.cwd
1551
+ updatedCwd !== existing.cwd ||
1552
+ updatedInitialCwd !== existing.initialCwd
1297
1553
  ) {
1298
1554
  this._sessions[index] = {
1299
1555
  ...existing,
@@ -1301,6 +1557,7 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
1301
1557
  pid: updatedPid,
1302
1558
  workspacePath: updatedWorkspacePath,
1303
1559
  cwd: updatedCwd,
1560
+ initialCwd: updatedInitialCwd,
1304
1561
  planFile: updatedPlanFile,
1305
1562
  isInWorkspace: updatedIsInWorkspace,
1306
1563
  };
@@ -1375,16 +1632,29 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
1375
1632
  return;
1376
1633
  }
1377
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
+
1378
1644
  if (
1379
1645
  cwd !== session.cwd ||
1380
1646
  info.worktreePath !== session.worktreePath ||
1381
- info.repoRoot !== session.commonPath
1647
+ info.repoRoot !== session.commonPath ||
1648
+ updatedInitialWorktreePath !== session.initialWorktreePath ||
1649
+ updatedInitialCommonPath !== session.initialCommonPath
1382
1650
  ) {
1383
1651
  this._sessions[index] = {
1384
1652
  ...session,
1385
1653
  cwd: cwd,
1386
1654
  worktreePath: info.worktreePath,
1387
1655
  commonPath: info.repoRoot,
1656
+ initialWorktreePath: updatedInitialWorktreePath,
1657
+ initialCommonPath: updatedInitialCommonPath,
1388
1658
  };
1389
1659
  this._onDidChangeSessions.fire();
1390
1660
  }
@@ -1440,6 +1710,14 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
1440
1710
  this._pendingPermissions.delete(id);
1441
1711
  }
1442
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
+ }
1443
1721
  this._sessionBookkeeping.delete(id);
1444
1722
  this._transcriptReader.forget(id);
1445
1723
  }
@@ -1449,7 +1727,15 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
1449
1727
  return true;
1450
1728
  }
1451
1729
 
1452
- private async syncSessions(): Promise<void> {
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
+
1453
1739
  let sessions: SessionFileData[];
1454
1740
  try {
1455
1741
  const output = await this.callbacks.runCLICommand(['ai', 'hook', 'list-sessions', '--json']);
@@ -1461,14 +1747,29 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
1461
1747
  return;
1462
1748
  }
1463
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
+
1464
1760
  let changed = false;
1761
+ let discovered = 0;
1465
1762
 
1466
1763
  for (const data of sessions) {
1467
1764
  if (!data.sessionId || !data.pid || !isProcessAlive(data.pid)) {
1468
1765
  continue;
1469
1766
  }
1470
1767
 
1471
- if (this._sessions.some(s => s.id === data.sessionId)) continue;
1768
+ polledAlive.add(data.sessionId);
1769
+
1770
+ if (known.has(data.sessionId)) continue;
1771
+
1772
+ known.add(data.sessionId);
1472
1773
 
1473
1774
  const workspacePath = this.resolveWorkspacePath(data.cwd);
1474
1775
  const isInWorkspace = workspacePath != null;
@@ -1503,6 +1804,10 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
1503
1804
  isSubagent: false,
1504
1805
  workspacePath: workspacePath,
1505
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,
1506
1811
  planFile: data.planFile ?? undefined,
1507
1812
  isInWorkspace: isInWorkspace,
1508
1813
  lastPrompt: prepareStoredPrompt(data.prompt ?? undefined),
@@ -1510,6 +1815,7 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
1510
1815
  subagents: subagents,
1511
1816
  });
1512
1817
  changed = true;
1818
+ discovered++;
1513
1819
 
1514
1820
  if (data.cwd) {
1515
1821
  void this.resolveGitInfo(data.sessionId, data.cwd);
@@ -1524,6 +1830,29 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
1524
1830
  if (changed) {
1525
1831
  this._onDidChangeSessions.fire();
1526
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
+ }
1527
1856
  }
1528
1857
 
1529
1858
  private async querySiblingWindowSessions(): Promise<void> {
@@ -1585,6 +1914,7 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
1585
1914
  this.cancelPendingReadClears(bk);
1586
1915
  bk.currentFileCounts.clear();
1587
1916
  bk.currentReadCounts.clear();
1917
+ bk.lastTouchedAt.clear();
1588
1918
  }
1589
1919
  this._sessions[idx] = {
1590
1920
  ...existing,
@@ -1594,17 +1924,27 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
1594
1924
  statusDetail: peerSession.statusDetail,
1595
1925
  lastActivity: peerActivity,
1596
1926
  subagents: rehydrateSubagents(peerSession.subagents),
1597
- // Carry the peer's in-flight file-edit set + repo identity across so
1598
- // peer-window WIP decorations + sameFamily anchoring follow the agent.
1599
- // Owned by the peer (it's the hook recipient); we never originate these
1600
- // fields for a remote session.
1601
- currentFiles: peerSession.currentFiles,
1602
- currentReads: peerSession.currentReads,
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,
1603
1937
  commonPath: peerSession.commonPath ?? existing.commonPath,
1604
1938
  // Pick up the peer's latest cwd so our backfill probe (queued below)
1605
1939
  // targets the right path. Stale-cwd locally would just re-probe the
1606
1940
  // non-repo dir and hit the gitInfoUnresolvable retry guard again.
1607
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,
1608
1948
  // Forward peer-discovered transcript titles. Both windows can resolve them
1609
1949
  // independently against the same on-disk transcript, but only the peer's
1610
1950
  // hook flow drives its updates — without this, we'd freeze the snapshot