@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.
- package/CHANGELOG.md +8 -1
- package/dist/git/cache.d.ts +12 -6
- package/dist/git/cache.d.ts.map +1 -1
- package/dist/git/cache.js +40 -39
- package/dist/git/cache.js.map +1 -1
- package/dist/git/context.d.ts +19 -0
- package/dist/git/context.d.ts.map +1 -1
- package/dist/git/context.js.map +1 -1
- package/dist/git/features.d.ts +1 -1
- package/dist/git/features.d.ts.map +1 -1
- package/dist/git/features.js +1 -0
- package/dist/git/features.js.map +1 -1
- package/dist/git/models/graph.d.ts +96 -0
- package/dist/git/models/graph.d.ts.map +1 -1
- package/dist/git/models/graph.js +24 -1
- package/dist/git/models/graph.js.map +1 -1
- package/dist/git/models/remoteProvider.d.ts +1 -0
- package/dist/git/models/remoteProvider.d.ts.map +1 -1
- package/dist/git/models/remoteProvider.js.map +1 -1
- package/dist/git/models/repository.d.ts +21 -12
- package/dist/git/models/repository.d.ts.map +1 -1
- package/dist/git/models/repository.js +70 -84
- package/dist/git/models/repository.js.map +1 -1
- package/dist/git/models/statusFile.d.ts +9 -0
- package/dist/git/models/statusFile.d.ts.map +1 -1
- package/dist/git/models/statusFile.js +11 -0
- package/dist/git/models/statusFile.js.map +1 -1
- package/dist/git/providers/commits.d.ts +4 -0
- package/dist/git/providers/commits.d.ts.map +1 -1
- package/dist/git/providers/config.d.ts +1 -3
- package/dist/git/providers/config.d.ts.map +1 -1
- package/dist/git/providers/operations.d.ts +39 -13
- package/dist/git/providers/operations.d.ts.map +1 -1
- package/dist/git/remotes/custom.d.ts +2 -0
- package/dist/git/remotes/custom.d.ts.map +1 -1
- package/dist/git/remotes/custom.js +20 -0
- package/dist/git/remotes/custom.js.map +1 -1
- package/dist/git/utils/contributor.utils.d.ts +3 -0
- package/dist/git/utils/contributor.utils.d.ts.map +1 -1
- package/dist/git/utils/contributor.utils.js +22 -0
- package/dist/git/utils/contributor.utils.js.map +1 -1
- package/dist/git/utils/reachability.utils.d.ts +37 -0
- package/dist/git/utils/reachability.utils.d.ts.map +1 -0
- package/dist/git/utils/reachability.utils.js +96 -0
- package/dist/git/utils/reachability.utils.js.map +1 -0
- package/dist/git/watching/watchService.d.ts +4 -2
- package/dist/git/watching/watchService.d.ts.map +1 -1
- package/dist/git/watching/watchService.js +8 -3
- package/dist/git/watching/watchService.js.map +1 -1
- package/dist/git/watching/watchSession.d.ts +14 -12
- package/dist/git/watching/watchSession.d.ts.map +1 -1
- package/dist/git/watching/watchSession.js +56 -55
- package/dist/git/watching/watchSession.js.map +1 -1
- package/dist/git-cli/providers/branches.d.ts.map +1 -1
- package/dist/git-cli/providers/branches.js +1 -0
- package/dist/git-cli/providers/branches.js.map +1 -1
- package/dist/git-cli/providers/commits.d.ts +1 -0
- package/dist/git-cli/providers/commits.d.ts.map +1 -1
- package/dist/git-cli/providers/commits.js +12 -0
- package/dist/git-cli/providers/commits.js.map +1 -1
- package/dist/git-cli/providers/config.d.ts +8 -4
- package/dist/git-cli/providers/config.d.ts.map +1 -1
- package/dist/git-cli/providers/config.js +62 -39
- package/dist/git-cli/providers/config.js.map +1 -1
- package/dist/git-cli/providers/diff.d.ts.map +1 -1
- package/dist/git-cli/providers/diff.js +1 -1
- package/dist/git-cli/providers/diff.js.map +1 -1
- package/dist/git-cli/providers/graph.d.ts.map +1 -1
- package/dist/git-cli/providers/graph.js +132 -14
- package/dist/git-cli/providers/graph.js.map +1 -1
- package/dist/git-cli/providers/operations.d.ts +6 -5
- package/dist/git-cli/providers/operations.d.ts.map +1 -1
- package/dist/git-cli/providers/operations.js +97 -95
- package/dist/git-cli/providers/operations.js.map +1 -1
- package/dist/git-cli/providers/stash.d.ts.map +1 -1
- package/dist/git-cli/providers/stash.js +20 -2
- package/dist/git-cli/providers/stash.js.map +1 -1
- package/dist/git-cli/providers/status.d.ts.map +1 -1
- package/dist/git-cli/providers/status.js +5 -2
- package/dist/git-cli/providers/status.js.map +1 -1
- package/dist/ipc/discovery.d.ts +22 -0
- package/dist/ipc/discovery.d.ts.map +1 -1
- package/dist/ipc/discovery.js +111 -2
- package/dist/ipc/discovery.js.map +1 -1
- package/dist/plus/agents/providers/claudeCodeProvider.d.ts +73 -20
- package/dist/plus/agents/providers/claudeCodeProvider.d.ts.map +1 -1
- package/dist/plus/agents/providers/claudeCodeProvider.js +394 -88
- package/dist/plus/agents/providers/claudeCodeProvider.js.map +1 -1
- package/dist/plus/agents/types.d.ts +62 -11
- package/dist/plus/agents/types.d.ts.map +1 -1
- package/dist/plus/ai/models/model.d.ts +1 -1
- package/dist/plus/ai/models/model.d.ts.map +1 -1
- package/dist/plus/ai/models/model.js.map +1 -1
- package/dist/plus/ai/models/provider.d.ts +3 -0
- package/dist/plus/ai/models/provider.d.ts.map +1 -1
- package/dist/plus/ai/providers/gitkrakenProvider.d.ts +1 -1
- package/dist/plus/ai/providers/gitkrakenProvider.d.ts.map +1 -1
- package/dist/plus/ai/providers/gitkrakenProvider.js +4 -1
- package/dist/plus/ai/providers/gitkrakenProvider.js.map +1 -1
- package/dist/plus/ai/providers/openAICompatibleProviderBase.d.ts +4 -3
- package/dist/plus/ai/providers/openAICompatibleProviderBase.d.ts.map +1 -1
- package/dist/plus/ai/providers/openAICompatibleProviderBase.js +9 -6
- package/dist/plus/ai/providers/openAICompatibleProviderBase.js.map +1 -1
- package/dist/plus/ai/utils/ai.utils.d.ts.map +1 -1
- package/dist/plus/ai/utils/ai.utils.js +2 -0
- package/dist/plus/ai/utils/ai.utils.js.map +1 -1
- package/dist/plus/git-github/api/github.d.ts +2 -0
- package/dist/plus/git-github/api/github.d.ts.map +1 -1
- package/dist/plus/git-github/api/github.js +93 -6
- package/dist/plus/git-github/api/github.js.map +1 -1
- package/dist/plus/git-github/providers/github/refs.js +1 -1
- package/dist/utils/date.d.ts.map +1 -1
- package/dist/utils/date.js +6 -0
- package/dist/utils/date.js.map +1 -1
- package/dist/utils/debounce.js +1 -1
- package/dist/utils/iterable.d.ts +7 -1
- package/dist/utils/iterable.d.ts.map +1 -1
- package/dist/utils/iterable.js +12 -4
- package/dist/utils/iterable.js.map +1 -1
- package/dist/utils/object.d.ts +22 -1
- package/dist/utils/object.d.ts.map +1 -1
- package/dist/utils/object.js +56 -3
- package/dist/utils/object.js.map +1 -1
- package/dist/utils/paging.js +14 -15
- package/dist/utils/paging.js.map +1 -1
- package/dist/utils/promiseCache.d.ts.map +1 -1
- package/dist/utils/promiseCache.js +12 -4
- package/dist/utils/promiseCache.js.map +1 -1
- package/dist/utils/string.js +1 -1
- package/package.json +1 -1
- package/src/git/cache.ts +58 -70
- package/src/git/context.ts +21 -0
- package/src/git/features.ts +2 -0
- package/src/git/models/graph.ts +97 -0
- package/src/git/models/remoteProvider.ts +1 -0
- package/src/git/models/repository.ts +84 -93
- package/src/git/models/statusFile.ts +12 -0
- package/src/git/providers/commits.ts +4 -0
- package/src/git/providers/config.ts +1 -5
- package/src/git/providers/operations.ts +42 -22
- package/src/git/remotes/custom.ts +23 -0
- package/src/git/utils/contributor.utils.ts +24 -0
- package/src/git/utils/reachability.utils.ts +109 -0
- package/src/git/watching/watchService.ts +9 -4
- package/src/git/watching/watchSession.ts +61 -55
- package/src/git-cli/providers/branches.ts +1 -0
- package/src/git-cli/providers/commits.ts +24 -0
- package/src/git-cli/providers/config.ts +63 -49
- package/src/git-cli/providers/diff.ts +2 -1
- package/src/git-cli/providers/graph.ts +145 -14
- package/src/git-cli/providers/operations.ts +118 -130
- package/src/git-cli/providers/stash.ts +20 -2
- package/src/git-cli/providers/status.ts +11 -3
- package/src/ipc/discovery.ts +132 -2
- package/src/plus/agents/providers/claudeCodeProvider.ts +430 -90
- package/src/plus/agents/types.ts +59 -11
- package/src/plus/ai/models/model.ts +1 -0
- package/src/plus/ai/models/provider.ts +7 -1
- package/src/plus/ai/providers/gitkrakenProvider.ts +4 -0
- package/src/plus/ai/providers/openAICompatibleProviderBase.ts +21 -4
- package/src/plus/ai/utils/ai.utils.ts +2 -0
- package/src/plus/git-github/api/github.ts +120 -8
- package/src/plus/git-github/providers/github/refs.ts +1 -1
- package/src/utils/date.ts +5 -0
- package/src/utils/debounce.ts +1 -1
- package/src/utils/iterable.ts +14 -3
- package/src/utils/object.ts +55 -4
- package/src/utils/paging.ts +14 -14
- package/src/utils/promiseCache.ts +24 -16
- 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"
|
|
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; //
|
|
104
|
-
/**
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
const
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
184
|
-
for (const
|
|
185
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
531
|
-
|
|
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
|
-
|
|
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
|
-
|
|
798
|
-
|
|
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
|
-
/**
|
|
831
|
-
*
|
|
832
|
-
*
|
|
833
|
-
|
|
834
|
-
|
|
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
|
-
|
|
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
|
|
853
|
-
*
|
|
854
|
-
* cooldown before actually dropping it — so
|
|
855
|
-
*
|
|
856
|
-
*
|
|
857
|
-
|
|
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
|
-
|
|
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
|
-
|
|
873
|
-
|
|
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
|
-
|
|
1066
|
+
this.clearLastTouched(cur, filePath, 'edit');
|
|
1067
|
+
if (this.syncSessionFileActivityForChild(sessionId)) {
|
|
892
1068
|
this._onDidChangeSessions.fire();
|
|
893
1069
|
}
|
|
894
|
-
},
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1165
|
+
this.clearLastTouched(cur, filePath, 'read');
|
|
1166
|
+
if (this.syncSessionFileActivityForChild(sessionId)) {
|
|
994
1167
|
this._onDidChangeSessions.fire();
|
|
995
1168
|
}
|
|
996
|
-
},
|
|
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
|
|
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
|
-
|
|
1192
|
+
this.clearLastTouched(bk, filePath, 'read');
|
|
1193
|
+
return this.syncSessionFileActivityForChild(sessionId);
|
|
1020
1194
|
}
|
|
1021
1195
|
|
|
1022
|
-
/**
|
|
1023
|
-
*
|
|
1024
|
-
|
|
1025
|
-
|
|
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
|
|
1029
|
-
const
|
|
1030
|
-
if (currentFilesEqual(prev.currentReads, next)) return false;
|
|
1218
|
+
const parent = this._sessions[index];
|
|
1219
|
+
const parentBk = this._sessionBookkeeping.get(parentSessionId);
|
|
1031
1220
|
|
|
1032
|
-
|
|
1033
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1598
|
-
//
|
|
1599
|
-
//
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
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
|