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