diff --git a/README.md b/README.md index 0eb3c22..60b14c9 100644 --- a/README.md +++ b/README.md @@ -340,6 +340,7 @@ gx sync ```sh gx agents start # review monitor + stale cleanup gx agents stop +gx agents stop --pid 12345 gx agents status # tuning diff --git a/openspec/changes/agent-codex-active-agents-bugfix-pass-2026-04-22-16-54/notes.md b/openspec/changes/agent-codex-active-agents-bugfix-pass-2026-04-22-16-54/notes.md new file mode 100644 index 0000000..b192085 --- /dev/null +++ b/openspec/changes/agent-codex-active-agents-bugfix-pass-2026-04-22-16-54/notes.md @@ -0,0 +1,25 @@ +# agent-codex-active-agents-bugfix-pass-2026-04-22-16-54 (minimal / T1) + +Branch: `agent/codex/active-agents-bugfix-pass-2026-04-22-16-54` + +Patch the shipped VS Code `Active Agents` companion bugs that remain after the grouped-session rollout. Keep the scope on the real defects: duplicate provider methods, expensive clean-worktree activity scans, stop-session process handling, and blocking diff rendering. + +Scope: +- Update `vscode/guardex-active-agents/session-schema.js` to bound and cache clean-worktree activity checks. +- Update `vscode/guardex-active-agents/extension.js` to remove the duplicate lock-registry methods, route stop through `gx`, replace the blocking diff dump with Git-native change opens, and drop the emoji lock label. +- Update the focused extension/CLI regressions so they cover the live `vscode/` source instead of the stale template copy, then add metadata parity coverage so the mirrored JS sources do not drift again. + +Verification: +- `node --test test/vscode-active-agents-session-state.test.js test/agents.test.js` +- `node --test test/metadata.test.js` + +## Handoff + +- Handoff: change=`agent-codex-active-agents-bugfix-pass-2026-04-22-16-54`; branch=`agent/codex/active-agents-bugfix-pass-2026-04-22-16-54`; scope=`vscode/guardex-active-agents/*, src/cli/{args.js,main.js}, test/{vscode-active-agents-session-state.test.js,agents.test.js}, openspec/changes/agent-codex-active-agents-bugfix-pass-2026-04-22-16-54/notes.md`; action=`fix the remaining Active Agents extension bugs, verify with targeted Node tests, then finish via PR merge + cleanup`. +- Copy prompt: Continue `agent-codex-active-agents-bugfix-pass-2026-04-22-16-54` on branch `agent/codex/active-agents-bugfix-pass-2026-04-22-16-54`. Work inside the existing sandbox, review `openspec/changes/agent-codex-active-agents-bugfix-pass-2026-04-22-16-54/notes.md`, continue from the current state instead of creating a new sandbox, and when the work is done run `gx branch finish --branch agent/codex/active-agents-bugfix-pass-2026-04-22-16-54 --base main --via-pr --wait-for-merge --cleanup`. + +## Cleanup + +- [ ] Run: `gx branch finish --branch agent/codex/active-agents-bugfix-pass-2026-04-22-16-54 --base main --via-pr --wait-for-merge --cleanup` +- [ ] Record PR URL + `MERGED` state in the completion handoff. +- [ ] Confirm sandbox worktree is gone (`git worktree list`, `git branch -a`). diff --git a/src/cli/args.js b/src/cli/args.js index 59f1e87..e7bf9f9 100644 --- a/src/cli/args.js +++ b/src/cli/args.js @@ -271,6 +271,7 @@ function parseAgentsArgs(rawArgs) { reviewIntervalSeconds: 30, cleanupIntervalSeconds: 60, idleMinutes: DEFAULT_SHADOW_CLEANUP_IDLE_MINUTES, + pid: null, }; for (let index = 0; index < rest.length; index += 1) { @@ -314,12 +315,28 @@ function parseAgentsArgs(rawArgs) { index += 1; continue; } + if (arg === '--pid') { + const next = rest[index + 1]; + if (!next) { + throw new Error('--pid requires a positive integer value'); + } + const parsedValue = Number.parseInt(next, 10); + if (!Number.isInteger(parsedValue) || parsedValue <= 0) { + throw new Error('--pid must be a positive integer'); + } + options.pid = parsedValue; + index += 1; + continue; + } throw new Error(`Unknown option: ${arg}`); } if (!['start', 'stop', 'status'].includes(options.subcommand)) { throw new Error(`Unknown agents subcommand: ${options.subcommand}`); } + if (options.pid !== null && options.subcommand !== 'stop') { + throw new Error('--pid is only supported with `gx agents stop`'); + } return options; } diff --git a/src/cli/main.js b/src/cli/main.js index 130cdd6..70aae7b 100755 --- a/src/cli/main.js +++ b/src/cli/main.js @@ -2219,10 +2219,15 @@ function processAlive(pid) { } try { process.kill(normalizedPid, 0); - return true; } catch (_error) { return false; } + + const state = readProcessState(normalizedPid); + if (state.startsWith('Z')) { + return false; + } + return true; } function sleepSeconds(seconds) { @@ -2240,6 +2245,14 @@ function readProcessCommand(pid) { return String(result.stdout || '').trim(); } +function readProcessState(pid) { + const result = run('ps', ['-o', 'stat=', '-p', String(pid)]); + if (isSpawnFailure(result) || result.status !== 0) { + return ''; + } + return String(result.stdout || '').trim(); +} + function stopAgentProcessByPid(pid, expectedToken = '') { const normalizedPid = Number.parseInt(String(pid || ''), 10); if (!Number.isInteger(normalizedPid) || normalizedPid <= 0) { @@ -2431,6 +2444,16 @@ function agents(rawArgs) { } if (options.subcommand === 'stop') { + if (options.pid) { + const stopResult = stopAgentProcessByPid(options.pid); + const success = ['stopped', 'not-running'].includes(stopResult.status); + console.log( + `[${TOOL_NAME}] Stopped agent pid ${options.pid} (${stopResult.status}).`, + ); + process.exitCode = success ? 0 : 1; + return; + } + const existingState = readAgentsState(repoRoot); if (!existingState) { console.log(`[${TOOL_NAME}] Repo agents are not running for ${repoRoot}.`); diff --git a/templates/vscode/guardex-active-agents/extension.js b/templates/vscode/guardex-active-agents/extension.js index d8ab96c..87f1c0c 100644 --- a/templates/vscode/guardex-active-agents/extension.js +++ b/templates/vscode/guardex-active-agents/extension.js @@ -24,6 +24,7 @@ const ACTIVE_AGENTS_MANIFEST_RELATIVE = path.join('vscode', 'guardex-active-agen const ACTIVE_AGENTS_INSTALL_SCRIPT_RELATIVE = path.join('scripts', 'install-vscode-active-agents-extension.js'); const RELOAD_WINDOW_ACTION = 'Reload Window'; const UPDATE_LATER_ACTION = 'Later'; +const REFRESH_POLL_INTERVAL_MS = 30_000; const SESSION_ACTIVITY_GROUPS = [ { kind: 'blocked', label: 'BLOCKED' }, { kind: 'working', label: 'WORKING NOW' }, @@ -293,7 +294,7 @@ class SessionItem extends vscode.TreeItem { constructor(session, items = []) { const lockCount = Number.isFinite(session.lockCount) ? session.lockCount : 0; super( - `${session.label} 馃敀 ${lockCount}`, + session.label, items.length > 0 ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.None, ); this.session = session; @@ -304,6 +305,9 @@ class SessionItem extends vscode.TreeItem { descriptionParts.push(session.activityCountLabel); } descriptionParts.push(session.elapsedLabel || formatElapsedFrom(session.startedAt)); + if (lockCount > 0) { + descriptionParts.push(`${lockCount} $(lock)`); + } this.description = descriptionParts.join(' 路 '); const tooltipLines = [ session.branch, @@ -437,6 +441,20 @@ function syncSession(session) { runSessionTerminalCommand(session, 'Sync', 'sync', 'gx sync'); } +function execFileAsync(command, args, options = {}) { + return new Promise((resolve, reject) => { + cp.execFile(command, args, options, (error, stdout = '', stderr = '') => { + if (error) { + error.stdout = stdout; + error.stderr = stderr; + reject(error); + return; + } + resolve({ stdout, stderr }); + }); + }); +} + async function stopSession(session, refresh) { const pid = Number(session?.pid); if (!Number.isInteger(pid) || pid <= 0) { @@ -450,20 +468,69 @@ async function stopSession(session, refresh) { const confirmed = await vscode.window.showWarningMessage( `Stop ${sessionDisplayLabel(session)}?`, - { modal: true, detail: `Ask gx to send SIGTERM to pid ${pid}.` }, + { modal: true, detail: `Run gx agents stop --pid ${pid}.` }, 'Stop', ); if (confirmed !== 'Stop') { return; } - runSessionTerminalCommand( - session, - 'Stop', - 'debug-stop', - `gx internal stop-session --branch ${shellQuote(session.branch)}`, - ); - refresh(); + try { + const commandCwd = session?.repoRoot || sessionWorktreePath(session) || process.cwd(); + const args = ['agents', 'stop', '--pid', String(pid)]; + if (session?.repoRoot) { + args.push('--target', session.repoRoot); + } + await execFileAsync('gx', args, { + cwd: commandCwd, + encoding: 'utf8', + maxBuffer: 1024 * 1024, + }); + refresh(); + } catch (error) { + showSessionMessage( + `Failed to stop session ${sessionDisplayLabel(session)}: ${formatGitCommandFailure(error)}`, + ); + } +} + +function sessionChangedPaths(session) { + const directPaths = Array.isArray(session?.changedPaths) + ? session.changedPaths.map(normalizeRelativePath).filter(Boolean) + : []; + if (directPaths.length > 0) { + return [...new Set(directPaths)]; + } + if (!session?.repoRoot || !session?.branch) { + return []; + } + + const liveSession = readActiveSessions(session.repoRoot) + .find((entry) => sessionSelectionKey(entry) === sessionSelectionKey(session)); + return Array.isArray(liveSession?.changedPaths) + ? [...new Set(liveSession.changedPaths.map(normalizeRelativePath).filter(Boolean))] + : []; +} + +async function pickSessionDiffPath(session) { + const changedPaths = sessionChangedPaths(session); + if (changedPaths.length === 0) { + return ''; + } + if (changedPaths.length === 1 || !vscode.window.showQuickPick) { + return changedPaths[0]; + } + + const picks = changedPaths.map((relativePath) => ({ + label: path.basename(relativePath), + description: relativePath, + relativePath, + })); + const selection = await vscode.window.showQuickPick(picks, { + placeHolder: `Select a changed file for ${sessionDisplayLabel(session)}`, + ignoreFocusOut: true, + }); + return selection?.relativePath || ''; } async function openSessionDiff(session) { @@ -472,27 +539,24 @@ async function openSessionDiff(session) { return; } - let diffOutput = ''; - try { - diffOutput = cp.execFileSync('git', ['-C', worktreePath, 'diff'], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - }); - } catch (error) { - const detail = [ - error?.stdout, - error?.stderr, - error?.message, - ].find((value) => typeof value === 'string' && value.trim().length > 0) || 'git diff failed.'; - showSessionMessage(`Failed to open diff for ${sessionDisplayLabel(session)}: ${detail.trim()}`); + const relativePath = await pickSessionDiffPath(session); + if (!relativePath) { + showSessionMessage(`No changed files to diff for ${sessionDisplayLabel(session)}.`); return; } - const document = await vscode.workspace.openTextDocument({ - language: 'diff', - content: diffOutput, - }); - await vscode.window.showTextDocument(document, { preview: false }); + const repoRoot = session?.repoRoot || worktreePath; + const absolutePath = path.resolve(repoRoot, relativePath); + const resourceUri = vscode.Uri.file(absolutePath); + try { + await vscode.commands.executeCommand('git.openChange', resourceUri); + } catch (error) { + if (fs.existsSync(absolutePath)) { + await vscode.commands.executeCommand('vscode.open', resourceUri); + return; + } + showSessionMessage(`Failed to open diff for ${sessionDisplayLabel(session)}: ${formatGitCommandFailure(error)}`); + } } function repoRootFromSessionFile(filePath) { @@ -1461,7 +1525,7 @@ function activate(context) { command: 'gitguardex.activeAgents.commitSelectedSession', title: 'Commit Selected Session', }; - const interval = setInterval(refresh, 5_000); + const interval = setInterval(refresh, REFRESH_POLL_INTERVAL_MS); const refreshLockRegistry = (uri) => { if (uri?.fsPath) { provider.refreshLockRegistryForFile(uri.fsPath); diff --git a/templates/vscode/guardex-active-agents/package.json b/templates/vscode/guardex-active-agents/package.json index bda0cb9..f490532 100644 --- a/templates/vscode/guardex-active-agents/package.json +++ b/templates/vscode/guardex-active-agents/package.json @@ -3,7 +3,7 @@ "displayName": "GitGuardex Active Agents", "description": "Shows live Guardex sandbox sessions and repo changes inside VS Code Source Control.", "publisher": "recodeee", - "version": "0.0.4", + "version": "0.0.5", "license": "MIT", "icon": "icon.png", "engines": { diff --git a/templates/vscode/guardex-active-agents/session-schema.js b/templates/vscode/guardex-active-agents/session-schema.js index 5a76561..367585f 100644 --- a/templates/vscode/guardex-active-agents/session-schema.js +++ b/templates/vscode/guardex-active-agents/session-schema.js @@ -19,6 +19,34 @@ const IDLE_ACTIVITY_WINDOW_MS = 2 * 60 * 1000; const STALLED_ACTIVITY_WINDOW_MS = 15 * 60 * 1000; const HEARTBEAT_STALE_MS = 5 * 60 * 1000; const ADVISORY_SESSION_STATES = new Set(['working', 'thinking', 'idle']); +const WORKTREE_ACTIVITY_CACHE_TTL_MS = 3_000; +const MAX_WORKTREE_ACTIVITY_STAT_PATHS = 200; +const WORKTREE_ACTIVITY_SKIP_PREFIXES = [ + '.git/', + '.omx/', + '.omc/', + 'node_modules/', + 'dist/', + 'build/', + 'coverage/', + '.next/', + 'out/', + 'vendor/', +]; +const WORKTREE_ACTIVITY_PRIORITY_PREFIXES = [ + 'src/', + 'app/', + 'apps/', + 'lib/', + 'packages/', + 'scripts/', + 'test/', + 'tests/', + 'vscode/', + 'templates/', + 'openspec/', + 'docs/', +]; const BLOCKING_GIT_STATES = [ { label: 'Rebase in progress.', @@ -33,6 +61,7 @@ const BLOCKING_GIT_STATES = [ markers: ['CHERRY_PICK_HEAD'], }, ]; +const worktreeActivityCache = new Map(); function toNonEmptyString(value, fallback = '') { const normalized = typeof value === 'string' ? value.trim() : String(value || '').trim(); @@ -306,14 +335,80 @@ function collectWorktreeTrackedPaths(worktreePath) { .sort((left, right) => left.localeCompare(right)); } -function deriveLatestWorktreeFileActivity(worktreePath) { +function shouldSkipWorktreeActivityPath(relativePath) { + const normalized = normalizeRelativePath(relativePath); + if (!normalized || normalized === LOCK_FILE_RELATIVE || normalized === AGENT_WORKTREE_LOCK_FILE) { + return true; + } + + return WORKTREE_ACTIVITY_SKIP_PREFIXES.some((prefix) => ( + normalized === prefix.slice(0, -1) || normalized.startsWith(prefix) + )); +} + +function worktreeActivityPathPriority(relativePath, recentPathsSet) { + if (recentPathsSet.has(relativePath)) { + return 0; + } + if (!relativePath.includes('/')) { + return 1; + } + if (WORKTREE_ACTIVITY_PRIORITY_PREFIXES.some((prefix) => relativePath.startsWith(prefix))) { + return 2; + } + return 3; +} + +function collectWorktreeActivityCandidatePaths(worktreePath, trackedPaths) { + const recentPaths = runGitLines(worktreePath, ['log', '-1', '--name-only', '--pretty=format:', '--', '.']) || []; + const filteredRecentPaths = [...new Set(recentPaths.map(normalizeRelativePath).filter(Boolean))] + .filter((relativePath) => !shouldSkipWorktreeActivityPath(relativePath)); + const recentPathSet = new Set(filteredRecentPaths); + const prioritizedTrackedPaths = trackedPaths + .map(normalizeRelativePath) + .filter(Boolean) + .filter((relativePath) => !shouldSkipWorktreeActivityPath(relativePath)) + .sort((left, right) => { + const priorityDelta = worktreeActivityPathPriority(left, recentPathSet) + - worktreeActivityPathPriority(right, recentPathSet); + if (priorityDelta !== 0) { + return priorityDelta; + } + return left.localeCompare(right); + }); + + return [...new Set([...filteredRecentPaths, ...prioritizedTrackedPaths])] + .slice(0, MAX_WORKTREE_ACTIVITY_STAT_PATHS); +} + +function clearWorktreeActivityCache(worktreePath = '') { + const normalizedWorktreePath = toNonEmptyString(worktreePath); + if (!normalizedWorktreePath) { + worktreeActivityCache.clear(); + return; + } + worktreeActivityCache.delete(path.resolve(normalizedWorktreePath)); +} + +function deriveLatestWorktreeFileActivity(worktreePath, options = {}) { + const now = Number.isFinite(options.now) ? options.now : Date.now(); + const useCache = options.useCache !== false; + const cacheKey = path.resolve(worktreePath); + if (useCache) { + const cached = worktreeActivityCache.get(cacheKey); + if (cached && (now - cached.checkedAtMs) < WORKTREE_ACTIVITY_CACHE_TTL_MS) { + return cached.latestMtimeMs; + } + } + const trackedPaths = collectWorktreeTrackedPaths(worktreePath); if (!trackedPaths) { return null; } + const candidatePaths = collectWorktreeActivityCandidatePaths(worktreePath, trackedPaths); let latestMtimeMs = null; - for (const relativePath of trackedPaths) { + for (const relativePath of candidatePaths) { const absolutePath = path.join(worktreePath, relativePath); try { const stats = fs.statSync(absolutePath); @@ -328,6 +423,13 @@ function deriveLatestWorktreeFileActivity(worktreePath) { } } + if (useCache) { + worktreeActivityCache.set(cacheKey, { + checkedAtMs: now, + latestMtimeMs, + }); + } + return latestMtimeMs; } @@ -404,6 +506,7 @@ function deriveSessionActivity(session, options = {}) { .map((relativePath) => normalizeRelativePath(relativePath)) .filter(Boolean))] .sort((left, right) => left.localeCompare(right)); + clearWorktreeActivityCache(session.worktreePath); const changedPaths = [...new Set(worktreeChangedPaths .map((relativePath) => normalizeRelativePath( path.relative(session.repoRoot, path.resolve(session.worktreePath, relativePath)), @@ -425,7 +528,10 @@ function deriveSessionActivity(session, options = {}) { }; } - const latestFileActivityMs = deriveLatestWorktreeFileActivity(session.worktreePath); + const latestFileActivityMs = deriveLatestWorktreeFileActivity(session.worktreePath, { + now, + useCache: options.useCache, + }); const lastFileActivityAt = Number.isFinite(latestFileActivityMs) ? new Date(latestFileActivityMs).toISOString() : ''; @@ -841,6 +947,7 @@ module.exports = { SESSION_SCHEMA_VERSION, activeSessionsDirForRepo, buildSessionRecord, + clearWorktreeActivityCache, collectWorktreeChangedPaths, collectWorktreeTrackedPaths, deriveBlockingGitLabel, diff --git a/test/agents.test.js b/test/agents.test.js index a3ab45d..b51ecdd 100644 --- a/test/agents.test.js +++ b/test/agents.test.js @@ -286,4 +286,39 @@ test('agents cleanup bot defaults to a 60-minute idle threshold', () => { assert.equal(waitForPidExit(state.cleanup.pid), true, 'cleanup bot pid should exit after stop'); }); +test('agents stop --pid stops a running process without repo bot state', async () => { + const repoDir = initRepo(); + seedCommit(repoDir); + + const child = cp.spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000);'], { + cwd: repoDir, + stdio: 'ignore', + }); + const exitPromise = new Promise((resolve) => { + child.once('exit', (code, signal) => resolve({ code, signal })); + }); + + const childPid = Number.parseInt(String(child.pid || ''), 10); + assert.equal(Number.isInteger(childPid) && childPid > 0, true, 'child process should expose a pid'); + assert.equal(isPidAlive(childPid), true, 'child process should be alive before stop'); + + try { + const result = runNode(['agents', 'stop', '--target', repoDir, '--pid', String(childPid)], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, new RegExp(`Stopped agent pid ${childPid} \\((stopped|not-running)\\)\\.`)); + const exitRecord = await Promise.race([ + exitPromise, + new Promise((_, reject) => { + setTimeout(() => reject(new Error('child process did not emit exit after stop --pid')), 3_000); + }), + ]); + assert.ok(exitRecord.signal === 'SIGTERM' || exitRecord.signal === 'SIGKILL' || exitRecord.code === 0); + } finally { + if (isPidAlive(childPid)) { + process.kill(childPid, 'SIGTERM'); + waitForPidExit(childPid); + } + } +}); + }); diff --git a/test/metadata.test.js b/test/metadata.test.js index e824895..cfea9b6 100644 --- a/test/metadata.test.js +++ b/test/metadata.test.js @@ -123,12 +123,14 @@ test('frontend mirror workflow skips cleanly when the mirror PAT is missing', () assert.match(workflow, /if:\s+\$\{\{\s*env\.SYNC_TOKEN != ''\s*\}\}/); }); -test('critical runtime helper scripts stay in sync with templates', () => { +test('critical runtime helper scripts and active-agents sources stay in sync with templates', () => { const pairs = [ ['templates/scripts/agent-branch-start.sh', 'scripts/agent-branch-start.sh'], ['templates/scripts/codex-agent.sh', 'scripts/codex-agent.sh'], ['templates/scripts/openspec/init-plan-workspace.sh', 'scripts/openspec/init-plan-workspace.sh'], ['templates/scripts/openspec/init-change-workspace.sh', 'scripts/openspec/init-change-workspace.sh'], + ['templates/vscode/guardex-active-agents/extension.js', 'vscode/guardex-active-agents/extension.js'], + ['templates/vscode/guardex-active-agents/session-schema.js', 'vscode/guardex-active-agents/session-schema.js'], ]; for (const [templatePath, runtimePath] of pairs) { diff --git a/test/vscode-active-agents-session-state.test.js b/test/vscode-active-agents-session-state.test.js index a837f08..5699c4c 100644 --- a/test/vscode-active-agents-session-state.test.js +++ b/test/vscode-active-agents-session-state.test.js @@ -24,12 +24,11 @@ const templateExtensionManifestPath = path.join( ); const sessionSchema = require(path.join( repoRoot, - 'templates', 'vscode', 'guardex-active-agents', 'session-schema.js', )); -const extensionEntry = path.join(repoRoot, 'templates', 'vscode', 'guardex-active-agents', 'extension.js'); +const extensionEntry = path.join(repoRoot, 'vscode', 'guardex-active-agents', 'extension.js'); function runNode(scriptPath, args, options = {}) { return cp.spawnSync('node', [scriptPath, ...args], { @@ -821,18 +820,77 @@ test('session-schema derives idle and stalled activity from clean worktree mtime const now = Date.parse('2026-04-22T10:00:00.000Z'); setPathMtime(trackedPath, now - 45_000); - const idleSession = sessionSchema.deriveSessionActivity(record, { now }); + const idleSession = sessionSchema.deriveSessionActivity(record, { now, useCache: false }); assert.equal(idleSession.activityKind, 'idle'); assert.match(idleSession.activitySummary, /Recent file activity 45s ago\./); assert.equal(idleSession.lastFileActivityAt, new Date(now - 45_000).toISOString()); setPathMtime(trackedPath, now - (20 * 60 * 1000)); - const stalledSession = sessionSchema.deriveSessionActivity(record, { now }); + const stalledSession = sessionSchema.deriveSessionActivity(record, { now, useCache: false }); assert.equal(stalledSession.activityKind, 'stalled'); assert.match(stalledSession.activitySummary, /No file activity for 20m 0s\./); assert.equal(stalledSession.lastFileActivityAt, new Date(now - (20 * 60 * 1000)).toISOString()); }); +test('session-schema caps clean-worktree stat scans and caches activity lookups briefly', () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-active-session-cache-')); + const worktreePath = path.join(tempRoot, 'sandbox'); + initGitRepo(worktreePath); + + for (let index = 0; index < 205; index += 1) { + const filePath = path.join(worktreePath, 'src', `tracked-${String(index).padStart(3, '0')}.txt`); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `file ${index}\n`, 'utf8'); + } + runGit(worktreePath, ['add', '.']); + runGit(worktreePath, ['commit', '-m', 'baseline']); + + const now = Date.parse('2026-04-22T10:00:00.000Z'); + for (let index = 0; index < 205; index += 1) { + setPathMtime( + path.join(worktreePath, 'src', `tracked-${String(index).padStart(3, '0')}.txt`), + now - 90_000, + ); + } + const trackedPath = path.join(worktreePath, 'src', 'tracked-000.txt'); + setPathMtime(trackedPath, now - 30_000); + + const record = sessionSchema.buildSessionRecord({ + repoRoot: tempRoot, + branch: 'agent/codex/cached-activity', + taskName: 'cached-activity', + agentName: 'codex', + worktreePath, + pid: process.pid, + cliName: 'codex', + }); + + let statCount = 0; + const originalStatSync = fs.statSync; + fs.statSync = (...args) => { + const filePath = String(args[0] || ''); + if (filePath.startsWith(worktreePath) && filePath.endsWith('.txt')) { + statCount += 1; + } + return originalStatSync(...args); + }; + + try { + const firstSession = sessionSchema.deriveSessionActivity(record, { now }); + const firstStatCount = statCount; + const secondSession = sessionSchema.deriveSessionActivity(record, { now: now + 1_000 }); + + assert.equal(firstSession.activityKind, 'idle'); + assert.equal(firstSession.lastFileActivityAt, new Date(now - 30_000).toISOString()); + assert.ok(firstStatCount <= 200, `expected <=200 file stats, saw ${firstStatCount}`); + assert.equal(secondSession.lastFileActivityAt, firstSession.lastFileActivityAt); + assert.equal(statCount, firstStatCount); + } finally { + fs.statSync = originalStatSync; + sessionSchema.clearWorktreeActivityCache(worktreePath); + } +}); + test('session-schema derives dead activity when the recorded pid is not alive', () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-active-session-dead-')); const worktreePath = path.join(tempRoot, 'sandbox'); @@ -1158,7 +1216,7 @@ test('active-agents extension groups live sessions under a repo node', async () assert.equal(idleSection.label, 'THINKING'); const [sessionItem] = await provider.getChildren(idleSection); - assert.equal(sessionItem.label, 'live-task 馃敀 0'); + assert.equal(sessionItem.label, 'live-task'); assert.match(sessionItem.description, /^idle 路 \d+[smhd]/); assert.equal(sessionItem.iconPath.id, 'comment-discussion'); assert.equal(sessionItem.resourceUri.scheme, 'gitguardex-agent'); @@ -1403,7 +1461,7 @@ test('active-agents extension shows grouped repo changes beside active agents', assert.equal(workingSection.label, 'WORKING NOW'); const [sessionItem] = await provider.getChildren(workingSection); - assert.equal(sessionItem.label, `${path.basename(worktreePath)} 馃敀 0`); + assert.equal(sessionItem.label, `${path.basename(worktreePath)}`); assert.match(sessionItem.description, /^working 路 2 files 路 /); assert.match(sessionItem.tooltip, /Changed 2 files: src\/nested\.js, tracked\.txt/); assert.equal(sessionItem.iconPath.id, 'loading~spin'); @@ -1417,7 +1475,7 @@ test('active-agents extension shows grouped repo changes beside active agents', }); const [sessionGroup, repoRootGroup] = await provider.getChildren(changesSection); - assert.equal(sessionGroup.label, `${path.basename(worktreePath)} 馃敀 0`); + assert.equal(sessionGroup.label, `${path.basename(worktreePath)}`); assert.match(sessionGroup.description, /^working 路 2 files 路 /); assert.equal(repoRootGroup.label, 'Repo root'); @@ -1486,7 +1544,7 @@ test('active-agents extension surfaces live managed worktrees from AGENT.lock fa const [workingSection] = await provider.getChildren(agentsSection); const [sessionItem] = await provider.getChildren(workingSection); assert.equal(workingSection.label, 'WORKING NOW'); - assert.equal(sessionItem.label, `${path.basename(worktreePath)} 馃敀 0`); + assert.equal(sessionItem.label, `${path.basename(worktreePath)}`); assert.match(sessionItem.description, /^working 路 1 file 路 /); assert.match(sessionItem.tooltip, /Telemetry updated 2026-04-22T09:01:00.000Z/); @@ -1563,7 +1621,7 @@ test('active-agents extension decorates sessions and repo changes from the lock assert.equal(repoItem.description, '1 active 路 1 working 路 2 changed'); const [workingSection] = await provider.getChildren(agentsSection); const [sessionItem] = await provider.getChildren(workingSection); - assert.equal(sessionItem.label, `${path.basename(worktreePath)} 馃敀 1`); + assert.equal(sessionItem.label, `${path.basename(worktreePath)}`); assert.match(sessionItem.tooltip, /Locks 1/); assert.match(sessionItem.tooltip, /Conflicts 1/); @@ -1658,7 +1716,7 @@ test('active-agents extension re-reads lock state on watcher events instead of e const [agentsSection] = await provider.getChildren(repoItem); const [idleSection] = await provider.getChildren(agentsSection); const [sessionItem] = await provider.getChildren(idleSection); - assert.equal(sessionItem.label, `${path.basename(worktreePath)} 馃敀 1`); + assert.equal(sessionItem.label, `${path.basename(worktreePath)}`); assert.equal(lockReadCount, 1); await provider.getChildren(); @@ -1685,7 +1743,7 @@ test('active-agents extension re-reads lock state on watcher events instead of e const [updatedAgentsSection] = await provider.getChildren(updatedRepoItem); const [updatedIdleSection] = await provider.getChildren(updatedAgentsSection); const [updatedSessionItem] = await provider.getChildren(updatedIdleSection); - assert.equal(updatedSessionItem.label, `${path.basename(worktreePath)} 馃敀 2`); + assert.equal(updatedSessionItem.label, `${path.basename(worktreePath)}`); await provider.getChildren(); assert.equal(lockReadCount, 2); @@ -2056,7 +2114,7 @@ test('active-agents extension decorates sessions and repo changes from the lock const [agentsSection, changesSection] = await provider.getChildren(repoItem); const [idleSection] = await provider.getChildren(agentsSection); const [sessionItem] = await provider.getChildren(idleSection); - assert.equal(sessionItem.label, `${path.basename(worktreePath)} 馃敀 1`); + assert.equal(sessionItem.label, `${path.basename(worktreePath)}`); assert.match(sessionItem.tooltip, /Locks 1/); const [repoRootGroup] = await provider.getChildren(changesSection); @@ -2132,7 +2190,7 @@ test('active-agents extension re-reads lock state on watcher events instead of e const [agentsSection] = await provider.getChildren(repoItem); const [thinkingSection] = await provider.getChildren(agentsSection); const [sessionItem] = await provider.getChildren(thinkingSection); - assert.equal(sessionItem.label, `${path.basename(worktreePath)} 馃敀 1`); + assert.equal(sessionItem.label, `${path.basename(worktreePath)}`); assert.equal(lockReadCount, 1); await provider.getChildren(); @@ -2159,7 +2217,7 @@ test('active-agents extension re-reads lock state on watcher events instead of e const [updatedAgentsSection] = await provider.getChildren(updatedRepoItem); const [updatedThinkingSection] = await provider.getChildren(updatedAgentsSection); const [updatedSessionItem] = await provider.getChildren(updatedThinkingSection); - assert.equal(updatedSessionItem.label, `${path.basename(worktreePath)} 馃敀 2`); + assert.equal(updatedSessionItem.label, `${path.basename(worktreePath)}`); await provider.getChildren(); assert.equal(lockReadCount, 2); @@ -2234,47 +2292,62 @@ test('active-agents extension launches finish and sync commands in session termi } }); -test('active-agents extension confirms stop and routes termination through gx', async () => { +test('active-agents extension confirms stop and routes through gx agents stop --pid', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-stop-session-')); const worktreePath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-stop-worktree-')); const { registrations, vscode } = createMockVscode(tempRoot); const extension = loadExtensionWithMockVscode(vscode); const context = { subscriptions: [] }; + let execCall = null; + const originalExecFile = cp.execFile; vscode.window.showWarningMessage = async (...args) => { registrations.warningMessages.push(args); return 'Stop'; }; + cp.execFile = (command, args, options, callback) => { + execCall = { command, args, options }; + callback(null, '[gx] Stopped agent pid 4242 (stopped).\n', ''); + }; - extension.activate(context); - const provider = registrations.providers[0].provider; - await flushAsyncWork(); - provider.onDidChangeTreeDataEmitter.fireCount = 0; + try { + extension.activate(context); + const provider = registrations.providers[0].provider; + await flushAsyncWork(); + provider.onDidChangeTreeDataEmitter.fireCount = 0; - await registrations.commands.get('gitguardex.activeAgents.stopSession')({ - label: 'live-task', - branch: 'agent/codex/live-task', - worktreePath, - pid: 4242, - }); - await flushAsyncWork(); + await registrations.commands.get('gitguardex.activeAgents.stopSession')({ + label: 'live-task', + branch: 'agent/codex/live-task', + pid: 4242, + repoRoot: tempRoot, + worktreePath, + }); + await flushAsyncWork(); + } finally { + cp.execFile = originalExecFile; + } - assert.equal(registrations.terminals.length, 1); - assert.equal(registrations.terminals[0].options.cwd, worktreePath); - assert.equal(registrations.terminals[0].options.iconPath.id, 'debug-stop'); - assert.deepEqual(registrations.terminals[0].sentTexts, [ - { text: "gx internal stop-session --branch 'agent/codex/live-task'", addNewLine: true }, - ]); + assert.deepEqual(execCall, { + command: 'gx', + args: ['agents', 'stop', '--pid', '4242', '--target', tempRoot], + options: { + cwd: tempRoot, + encoding: 'utf8', + maxBuffer: 1024 * 1024, + }, + }); assert.ok(registrations.providers[0].provider.onDidChangeTreeDataEmitter.fireCount >= 1); assert.equal(registrations.warningMessages.length, 1); assert.match(registrations.warningMessages[0][0], /Stop live-task\?/); + assert.match(registrations.warningMessages[0][1].detail, /gx agents stop --pid 4242/); for (const subscription of context.subscriptions) { subscription.dispose?.(); } }); -test('active-agents extension opens git diff output in an untitled editor', async () => { +test('active-agents extension opens the selected changed file through the Git diff UI', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-open-diff-')); const worktreePath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-open-diff-worktree-')); initGitRepo(worktreePath); @@ -2289,16 +2362,23 @@ test('active-agents extension opens git diff output in an untitled editor', asyn extension.activate(context); + const relativePath = path.relative(tempRoot, path.join(worktreePath, 'tracked.txt')).replace(/\\/g, '/'); await registrations.commands.get('gitguardex.activeAgents.openSessionDiff')({ label: 'live-task', + repoRoot: tempRoot, worktreePath, + changedPaths: [relativePath], }); - assert.equal(registrations.openedDocuments.length, 1); - assert.equal(registrations.openedDocuments[0].language, 'diff'); - assert.match(registrations.openedDocuments[0].content, /^diff --git /); - assert.equal(registrations.shownDocuments.length, 1); - assert.equal(registrations.shownDocuments[0].document.uri.scheme, 'untitled'); + assert.equal(registrations.openedDocuments.length, 0); + assert.equal(registrations.shownDocuments.length, 0); + const openChangeCalls = registrations.executedCommands + .filter((entry) => entry.command === 'git.openChange') + .map((entry) => [entry.command, entry.args[0]?.fsPath || null]); + assert.deepEqual( + openChangeCalls, + [['git.openChange', path.join(worktreePath, 'tracked.txt')]], + ); for (const subscription of context.subscriptions) { subscription.dispose?.(); diff --git a/vscode/guardex-active-agents/extension.js b/vscode/guardex-active-agents/extension.js index d8ab96c..87f1c0c 100644 --- a/vscode/guardex-active-agents/extension.js +++ b/vscode/guardex-active-agents/extension.js @@ -24,6 +24,7 @@ const ACTIVE_AGENTS_MANIFEST_RELATIVE = path.join('vscode', 'guardex-active-agen const ACTIVE_AGENTS_INSTALL_SCRIPT_RELATIVE = path.join('scripts', 'install-vscode-active-agents-extension.js'); const RELOAD_WINDOW_ACTION = 'Reload Window'; const UPDATE_LATER_ACTION = 'Later'; +const REFRESH_POLL_INTERVAL_MS = 30_000; const SESSION_ACTIVITY_GROUPS = [ { kind: 'blocked', label: 'BLOCKED' }, { kind: 'working', label: 'WORKING NOW' }, @@ -293,7 +294,7 @@ class SessionItem extends vscode.TreeItem { constructor(session, items = []) { const lockCount = Number.isFinite(session.lockCount) ? session.lockCount : 0; super( - `${session.label} 馃敀 ${lockCount}`, + session.label, items.length > 0 ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.None, ); this.session = session; @@ -304,6 +305,9 @@ class SessionItem extends vscode.TreeItem { descriptionParts.push(session.activityCountLabel); } descriptionParts.push(session.elapsedLabel || formatElapsedFrom(session.startedAt)); + if (lockCount > 0) { + descriptionParts.push(`${lockCount} $(lock)`); + } this.description = descriptionParts.join(' 路 '); const tooltipLines = [ session.branch, @@ -437,6 +441,20 @@ function syncSession(session) { runSessionTerminalCommand(session, 'Sync', 'sync', 'gx sync'); } +function execFileAsync(command, args, options = {}) { + return new Promise((resolve, reject) => { + cp.execFile(command, args, options, (error, stdout = '', stderr = '') => { + if (error) { + error.stdout = stdout; + error.stderr = stderr; + reject(error); + return; + } + resolve({ stdout, stderr }); + }); + }); +} + async function stopSession(session, refresh) { const pid = Number(session?.pid); if (!Number.isInteger(pid) || pid <= 0) { @@ -450,20 +468,69 @@ async function stopSession(session, refresh) { const confirmed = await vscode.window.showWarningMessage( `Stop ${sessionDisplayLabel(session)}?`, - { modal: true, detail: `Ask gx to send SIGTERM to pid ${pid}.` }, + { modal: true, detail: `Run gx agents stop --pid ${pid}.` }, 'Stop', ); if (confirmed !== 'Stop') { return; } - runSessionTerminalCommand( - session, - 'Stop', - 'debug-stop', - `gx internal stop-session --branch ${shellQuote(session.branch)}`, - ); - refresh(); + try { + const commandCwd = session?.repoRoot || sessionWorktreePath(session) || process.cwd(); + const args = ['agents', 'stop', '--pid', String(pid)]; + if (session?.repoRoot) { + args.push('--target', session.repoRoot); + } + await execFileAsync('gx', args, { + cwd: commandCwd, + encoding: 'utf8', + maxBuffer: 1024 * 1024, + }); + refresh(); + } catch (error) { + showSessionMessage( + `Failed to stop session ${sessionDisplayLabel(session)}: ${formatGitCommandFailure(error)}`, + ); + } +} + +function sessionChangedPaths(session) { + const directPaths = Array.isArray(session?.changedPaths) + ? session.changedPaths.map(normalizeRelativePath).filter(Boolean) + : []; + if (directPaths.length > 0) { + return [...new Set(directPaths)]; + } + if (!session?.repoRoot || !session?.branch) { + return []; + } + + const liveSession = readActiveSessions(session.repoRoot) + .find((entry) => sessionSelectionKey(entry) === sessionSelectionKey(session)); + return Array.isArray(liveSession?.changedPaths) + ? [...new Set(liveSession.changedPaths.map(normalizeRelativePath).filter(Boolean))] + : []; +} + +async function pickSessionDiffPath(session) { + const changedPaths = sessionChangedPaths(session); + if (changedPaths.length === 0) { + return ''; + } + if (changedPaths.length === 1 || !vscode.window.showQuickPick) { + return changedPaths[0]; + } + + const picks = changedPaths.map((relativePath) => ({ + label: path.basename(relativePath), + description: relativePath, + relativePath, + })); + const selection = await vscode.window.showQuickPick(picks, { + placeHolder: `Select a changed file for ${sessionDisplayLabel(session)}`, + ignoreFocusOut: true, + }); + return selection?.relativePath || ''; } async function openSessionDiff(session) { @@ -472,27 +539,24 @@ async function openSessionDiff(session) { return; } - let diffOutput = ''; - try { - diffOutput = cp.execFileSync('git', ['-C', worktreePath, 'diff'], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - }); - } catch (error) { - const detail = [ - error?.stdout, - error?.stderr, - error?.message, - ].find((value) => typeof value === 'string' && value.trim().length > 0) || 'git diff failed.'; - showSessionMessage(`Failed to open diff for ${sessionDisplayLabel(session)}: ${detail.trim()}`); + const relativePath = await pickSessionDiffPath(session); + if (!relativePath) { + showSessionMessage(`No changed files to diff for ${sessionDisplayLabel(session)}.`); return; } - const document = await vscode.workspace.openTextDocument({ - language: 'diff', - content: diffOutput, - }); - await vscode.window.showTextDocument(document, { preview: false }); + const repoRoot = session?.repoRoot || worktreePath; + const absolutePath = path.resolve(repoRoot, relativePath); + const resourceUri = vscode.Uri.file(absolutePath); + try { + await vscode.commands.executeCommand('git.openChange', resourceUri); + } catch (error) { + if (fs.existsSync(absolutePath)) { + await vscode.commands.executeCommand('vscode.open', resourceUri); + return; + } + showSessionMessage(`Failed to open diff for ${sessionDisplayLabel(session)}: ${formatGitCommandFailure(error)}`); + } } function repoRootFromSessionFile(filePath) { @@ -1461,7 +1525,7 @@ function activate(context) { command: 'gitguardex.activeAgents.commitSelectedSession', title: 'Commit Selected Session', }; - const interval = setInterval(refresh, 5_000); + const interval = setInterval(refresh, REFRESH_POLL_INTERVAL_MS); const refreshLockRegistry = (uri) => { if (uri?.fsPath) { provider.refreshLockRegistryForFile(uri.fsPath); diff --git a/vscode/guardex-active-agents/package.json b/vscode/guardex-active-agents/package.json index bda0cb9..f490532 100644 --- a/vscode/guardex-active-agents/package.json +++ b/vscode/guardex-active-agents/package.json @@ -3,7 +3,7 @@ "displayName": "GitGuardex Active Agents", "description": "Shows live Guardex sandbox sessions and repo changes inside VS Code Source Control.", "publisher": "recodeee", - "version": "0.0.4", + "version": "0.0.5", "license": "MIT", "icon": "icon.png", "engines": { diff --git a/vscode/guardex-active-agents/session-schema.js b/vscode/guardex-active-agents/session-schema.js index 5a76561..367585f 100644 --- a/vscode/guardex-active-agents/session-schema.js +++ b/vscode/guardex-active-agents/session-schema.js @@ -19,6 +19,34 @@ const IDLE_ACTIVITY_WINDOW_MS = 2 * 60 * 1000; const STALLED_ACTIVITY_WINDOW_MS = 15 * 60 * 1000; const HEARTBEAT_STALE_MS = 5 * 60 * 1000; const ADVISORY_SESSION_STATES = new Set(['working', 'thinking', 'idle']); +const WORKTREE_ACTIVITY_CACHE_TTL_MS = 3_000; +const MAX_WORKTREE_ACTIVITY_STAT_PATHS = 200; +const WORKTREE_ACTIVITY_SKIP_PREFIXES = [ + '.git/', + '.omx/', + '.omc/', + 'node_modules/', + 'dist/', + 'build/', + 'coverage/', + '.next/', + 'out/', + 'vendor/', +]; +const WORKTREE_ACTIVITY_PRIORITY_PREFIXES = [ + 'src/', + 'app/', + 'apps/', + 'lib/', + 'packages/', + 'scripts/', + 'test/', + 'tests/', + 'vscode/', + 'templates/', + 'openspec/', + 'docs/', +]; const BLOCKING_GIT_STATES = [ { label: 'Rebase in progress.', @@ -33,6 +61,7 @@ const BLOCKING_GIT_STATES = [ markers: ['CHERRY_PICK_HEAD'], }, ]; +const worktreeActivityCache = new Map(); function toNonEmptyString(value, fallback = '') { const normalized = typeof value === 'string' ? value.trim() : String(value || '').trim(); @@ -306,14 +335,80 @@ function collectWorktreeTrackedPaths(worktreePath) { .sort((left, right) => left.localeCompare(right)); } -function deriveLatestWorktreeFileActivity(worktreePath) { +function shouldSkipWorktreeActivityPath(relativePath) { + const normalized = normalizeRelativePath(relativePath); + if (!normalized || normalized === LOCK_FILE_RELATIVE || normalized === AGENT_WORKTREE_LOCK_FILE) { + return true; + } + + return WORKTREE_ACTIVITY_SKIP_PREFIXES.some((prefix) => ( + normalized === prefix.slice(0, -1) || normalized.startsWith(prefix) + )); +} + +function worktreeActivityPathPriority(relativePath, recentPathsSet) { + if (recentPathsSet.has(relativePath)) { + return 0; + } + if (!relativePath.includes('/')) { + return 1; + } + if (WORKTREE_ACTIVITY_PRIORITY_PREFIXES.some((prefix) => relativePath.startsWith(prefix))) { + return 2; + } + return 3; +} + +function collectWorktreeActivityCandidatePaths(worktreePath, trackedPaths) { + const recentPaths = runGitLines(worktreePath, ['log', '-1', '--name-only', '--pretty=format:', '--', '.']) || []; + const filteredRecentPaths = [...new Set(recentPaths.map(normalizeRelativePath).filter(Boolean))] + .filter((relativePath) => !shouldSkipWorktreeActivityPath(relativePath)); + const recentPathSet = new Set(filteredRecentPaths); + const prioritizedTrackedPaths = trackedPaths + .map(normalizeRelativePath) + .filter(Boolean) + .filter((relativePath) => !shouldSkipWorktreeActivityPath(relativePath)) + .sort((left, right) => { + const priorityDelta = worktreeActivityPathPriority(left, recentPathSet) + - worktreeActivityPathPriority(right, recentPathSet); + if (priorityDelta !== 0) { + return priorityDelta; + } + return left.localeCompare(right); + }); + + return [...new Set([...filteredRecentPaths, ...prioritizedTrackedPaths])] + .slice(0, MAX_WORKTREE_ACTIVITY_STAT_PATHS); +} + +function clearWorktreeActivityCache(worktreePath = '') { + const normalizedWorktreePath = toNonEmptyString(worktreePath); + if (!normalizedWorktreePath) { + worktreeActivityCache.clear(); + return; + } + worktreeActivityCache.delete(path.resolve(normalizedWorktreePath)); +} + +function deriveLatestWorktreeFileActivity(worktreePath, options = {}) { + const now = Number.isFinite(options.now) ? options.now : Date.now(); + const useCache = options.useCache !== false; + const cacheKey = path.resolve(worktreePath); + if (useCache) { + const cached = worktreeActivityCache.get(cacheKey); + if (cached && (now - cached.checkedAtMs) < WORKTREE_ACTIVITY_CACHE_TTL_MS) { + return cached.latestMtimeMs; + } + } + const trackedPaths = collectWorktreeTrackedPaths(worktreePath); if (!trackedPaths) { return null; } + const candidatePaths = collectWorktreeActivityCandidatePaths(worktreePath, trackedPaths); let latestMtimeMs = null; - for (const relativePath of trackedPaths) { + for (const relativePath of candidatePaths) { const absolutePath = path.join(worktreePath, relativePath); try { const stats = fs.statSync(absolutePath); @@ -328,6 +423,13 @@ function deriveLatestWorktreeFileActivity(worktreePath) { } } + if (useCache) { + worktreeActivityCache.set(cacheKey, { + checkedAtMs: now, + latestMtimeMs, + }); + } + return latestMtimeMs; } @@ -404,6 +506,7 @@ function deriveSessionActivity(session, options = {}) { .map((relativePath) => normalizeRelativePath(relativePath)) .filter(Boolean))] .sort((left, right) => left.localeCompare(right)); + clearWorktreeActivityCache(session.worktreePath); const changedPaths = [...new Set(worktreeChangedPaths .map((relativePath) => normalizeRelativePath( path.relative(session.repoRoot, path.resolve(session.worktreePath, relativePath)), @@ -425,7 +528,10 @@ function deriveSessionActivity(session, options = {}) { }; } - const latestFileActivityMs = deriveLatestWorktreeFileActivity(session.worktreePath); + const latestFileActivityMs = deriveLatestWorktreeFileActivity(session.worktreePath, { + now, + useCache: options.useCache, + }); const lastFileActivityAt = Number.isFinite(latestFileActivityMs) ? new Date(latestFileActivityMs).toISOString() : ''; @@ -841,6 +947,7 @@ module.exports = { SESSION_SCHEMA_VERSION, activeSessionsDirForRepo, buildSessionRecord, + clearWorktreeActivityCache, collectWorktreeChangedPaths, collectWorktreeTrackedPaths, deriveBlockingGitLabel,