diff --git a/openspec/changes/agent-codex-vscode-tree-lock-decorations-2026-04-22-10-55/proposal.md b/openspec/changes/agent-codex-vscode-tree-lock-decorations-2026-04-22-10-55/proposal.md new file mode 100644 index 0000000..fa7fe64 --- /dev/null +++ b/openspec/changes/agent-codex-vscode-tree-lock-decorations-2026-04-22-10-55/proposal.md @@ -0,0 +1,16 @@ +## Why + +- The Active Agents companion already distinguishes `working` from `thinking`, but long-idle clean sessions still blend into the same neutral styling. +- VS Code tree-item decorations need a stable `resourceUri`, so session rows need a synthetic URI that stays tied to the branch identity instead of a real file path. + +## What Changes + +- Add a `vscode.FileDecorationProvider` for synthetic `gitguardex-agent://` session URIs. +- Set `SessionItem.resourceUri` from the session branch so idle clean lanes can be decorated without affecting changed-file rows. +- Surface idle thresholds for clean sessions: yellow after 10 minutes, red after 30 minutes, while leaving `working` lanes on their existing styling. +- Fire decoration refreshes whenever the Active Agents tree refreshes so elapsed-idle styling stays current. + +## Impact + +- Highlights stale clean lanes in the Source Control companion without altering working-session emphasis. +- Keeps decoration behavior branch-stable across reloads and session refreshes. diff --git a/openspec/changes/agent-codex-vscode-tree-lock-decorations-2026-04-22-10-55/specs/vscode-active-agents-extension/spec.md b/openspec/changes/agent-codex-vscode-tree-lock-decorations-2026-04-22-10-55/specs/vscode-active-agents-extension/spec.md new file mode 100644 index 0000000..73ef5bd --- /dev/null +++ b/openspec/changes/agent-codex-vscode-tree-lock-decorations-2026-04-22-10-55/specs/vscode-active-agents-extension/spec.md @@ -0,0 +1,34 @@ +## ADDED Requirements + +### Requirement: Active Agents rows expose synthetic branch decoration URIs +The VS Code Active Agents companion SHALL assign each session row a synthetic `gitguardex-agent://` resource URI so tree decorations can target live Guardex branches without pointing at a real file on disk. + +#### Scenario: Session rows use sanitized branch identity +- **WHEN** the companion renders a live session row +- **THEN** the row `resourceUri` uses the `gitguardex-agent` scheme +- **AND** the URI path is derived from the branch name with the same sanitization used for session-state filenames. + +### Requirement: Idle clean sessions are color-coded by elapsed time +The VS Code Active Agents companion SHALL decorate clean live sessions according to how long they have stayed idle. + +#### Scenario: Clean session idle longer than ten minutes warns in yellow +- **WHEN** a live session has no working changes and has been running for more than 10 minutes but not more than 30 minutes +- **THEN** the session row decoration uses a yellow warning color +- **AND** the decoration tooltip reads `idle 10m+`. + +#### Scenario: Clean session idle longer than thirty minutes warns in red +- **WHEN** a live session has no working changes and has been running for more than 30 minutes +- **THEN** the session row decoration uses a red error color +- **AND** the decoration tooltip reads `idle 30m+`. + +#### Scenario: Working sessions keep their existing styling +- **WHEN** a live session currently has working changes in its sandbox worktree +- **THEN** the decoration provider returns no color override for that row. + +### Requirement: Tree refreshes also refresh idle decorations +The VS Code Active Agents companion SHALL invalidate session decorations whenever the tree data refreshes. + +#### Scenario: Refresh path fires decoration updates +- **WHEN** the tree refresh callback runs because of timers, watchers, or manual refresh +- **THEN** the file-decoration provider emits `onDidChangeFileDecorations` +- **AND** idle decoration colors can update without reloading the extension host. diff --git a/openspec/changes/agent-codex-vscode-tree-lock-decorations-2026-04-22-10-55/tasks.md b/openspec/changes/agent-codex-vscode-tree-lock-decorations-2026-04-22-10-55/tasks.md new file mode 100644 index 0000000..1fb9b64 --- /dev/null +++ b/openspec/changes/agent-codex-vscode-tree-lock-decorations-2026-04-22-10-55/tasks.md @@ -0,0 +1,29 @@ +## Definition of Done + +This change is complete only when **all** of the following are true: + +- Every checkbox below is checked. +- The agent branch reaches `MERGED` state on `origin` and the PR URL + state are recorded in the completion handoff. +- If any step blocks (test failure, conflict, ambiguous result), append a `BLOCKED:` line under section 4 explaining the blocker and **STOP**. Do not tick remaining cleanup boxes; do not silently skip the cleanup pipeline. + +## 1. Specification + +- [x] 1.1 Capture the synthetic session URI and idle-decoration rules in branch-local OpenSpec artifacts. + +## 2. Implementation + +- [x] 2.1 Add a session decoration provider keyed by `gitguardex-agent://` URIs and assign `resourceUri` on `SessionItem`. +- [x] 2.2 Fire decoration refreshes from the Active Agents refresh path and register the provider in `activate()`. +- [x] 2.3 Mirror the extension change into `templates/vscode/guardex-active-agents/extension.js`. +- [x] 2.4 Add or update focused regression coverage for the idle-decoration behavior. + +## 3. Verification + +- [x] 3.1 Run `node --test test/vscode-active-agents-session-state.test.js`. +- [x] 3.2 Run `openspec validate agent-codex-vscode-tree-lock-decorations-2026-04-22-10-55 --type change --strict`. + +## 4. Cleanup (mandatory; run before claiming completion) + +- [ ] 4.1 Run the cleanup pipeline: `bash scripts/agent-branch-finish.sh --branch agent/codex/vscode-tree-lock-decorations-2026-04-22-10-55 --base main --via-pr --wait-for-merge --cleanup`. +- [ ] 4.2 Record the PR URL and final merge state (`MERGED`) in the completion handoff. +- [ ] 4.3 Confirm the sandbox worktree is gone (`git worktree list` no longer shows the agent path; `git branch -a` shows no surviving local/remote refs for the branch). diff --git a/templates/vscode/guardex-active-agents/extension.js b/templates/vscode/guardex-active-agents/extension.js index a0e3fbc..cdad978 100644 --- a/templates/vscode/guardex-active-agents/extension.js +++ b/templates/vscode/guardex-active-agents/extension.js @@ -2,7 +2,77 @@ const fs = require('node:fs'); const path = require('node:path'); const cp = require('node:child_process'); const vscode = require('vscode'); -const { formatElapsedFrom, readActiveSessions, readRepoChanges } = require('./session-schema.js'); +const { + formatElapsedFrom, + readActiveSessions, + readRepoChanges, + sanitizeBranchForFile, +} = require('./session-schema.js'); + +const SESSION_DECORATION_SCHEME = 'gitguardex-agent'; +const IDLE_WARNING_MS = 10 * 60 * 1000; +const IDLE_ERROR_MS = 30 * 60 * 1000; +const LOCK_FILE_RELATIVE = path.join('.omx', 'state', 'agent-file-locks.json'); + +function sessionDecorationUri(branch) { + return vscode.Uri.parse(`${SESSION_DECORATION_SCHEME}://${sanitizeBranchForFile(branch)}`); +} + +function sessionIdleDecoration(session, now = Date.now()) { + if (!session || session.activityKind === 'working') { + return undefined; + } + + const startedAtMs = Date.parse(session.startedAt); + if (!Number.isFinite(startedAtMs)) { + return undefined; + } + + const elapsedMs = now - startedAtMs; + if (elapsedMs > IDLE_ERROR_MS) { + return { + badge: '30m+', + tooltip: 'idle 30m+', + color: new vscode.ThemeColor('list.errorForeground'), + }; + } + if (elapsedMs > IDLE_WARNING_MS) { + return { + badge: '10m+', + tooltip: 'idle 10m+', + color: new vscode.ThemeColor('list.warningForeground'), + }; + } + + return undefined; +} + +class SessionDecorationProvider { + constructor(nowProvider = () => Date.now()) { + this.nowProvider = nowProvider; + this.sessionsByUri = new Map(); + this.onDidChangeFileDecorationsEmitter = new vscode.EventEmitter(); + this.onDidChangeFileDecorations = this.onDidChangeFileDecorationsEmitter.event; + } + + updateSessions(sessions) { + this.sessionsByUri = new Map( + sessions.map((session) => [sessionDecorationUri(session.branch).toString(), session]), + ); + } + + refresh() { + this.onDidChangeFileDecorationsEmitter.fire(); + } + + provideFileDecoration(uri) { + if (!uri || uri.scheme !== SESSION_DECORATION_SCHEME) { + return undefined; + } + + return sessionIdleDecoration(this.sessionsByUri.get(uri.toString()), this.nowProvider()); + } +} class InfoItem extends vscode.TreeItem { constructor(label, description = '') { @@ -48,8 +118,10 @@ class SectionItem extends vscode.TreeItem { class SessionItem extends vscode.TreeItem { constructor(session) { - super(session.label, vscode.TreeItemCollapsibleState.None); + const lockCount = Number.isFinite(session.lockCount) ? session.lockCount : 0; + super(`${session.label} ๐Ÿ”’ ${lockCount}`, vscode.TreeItemCollapsibleState.None); this.session = session; + this.resourceUri = sessionDecorationUri(session.branch); const descriptionParts = [session.activityLabel || 'thinking']; if (session.activityCountLabel) { descriptionParts.push(session.activityCountLabel); @@ -63,6 +135,7 @@ class SessionItem extends vscode.TreeItem { session.changeCount > 0 ? `Changed ${session.activityCountLabel}: ${session.activitySummary}` : session.activitySummary, + `Locks ${lockCount}`, `Started ${session.startedAt}`, session.worktreePath, ]; @@ -99,9 +172,13 @@ class ChangeItem extends vscode.TreeItem { change.relativePath, `Status ${change.statusText}`, change.originalPath ? `Renamed from ${change.originalPath}` : '', + change.hasForeignLock ? `Locked by ${change.lockOwnerBranch}` : '', change.absolutePath, ].filter(Boolean).join('\n'); this.resourceUri = vscode.Uri.file(change.absolutePath); + if (change.hasForeignLock) { + this.iconPath = new vscode.ThemeIcon('warning'); + } this.contextValue = 'gitguardex.change'; this.command = { command: 'gitguardex.activeAgents.openChange', @@ -232,6 +309,94 @@ function repoRootFromSessionFile(filePath) { return path.resolve(path.dirname(filePath), '..', '..', '..'); } +function repoRootFromLockFile(filePath) { + return path.resolve(path.dirname(filePath), '..', '..'); +} + +function normalizeRelativePath(relativePath) { + return String(relativePath || '').replace(/\\/g, '/'); +} + +function emptyLockRegistry() { + return { + entriesByPath: new Map(), + countsByBranch: new Map(), + }; +} + +function readLockRegistry(repoRoot) { + const lockPath = path.join(repoRoot, LOCK_FILE_RELATIVE); + if (!fs.existsSync(lockPath)) { + return emptyLockRegistry(); + } + + let parsed; + try { + parsed = JSON.parse(fs.readFileSync(lockPath, 'utf8')); + } catch (_error) { + return emptyLockRegistry(); + } + + const locks = parsed?.locks; + if (!locks || typeof locks !== 'object' || Array.isArray(locks)) { + return emptyLockRegistry(); + } + + const entriesByPath = new Map(); + const countsByBranch = new Map(); + for (const [rawRelativePath, entry] of Object.entries(locks)) { + if (!entry || typeof entry !== 'object') { + continue; + } + + const relativePath = normalizeRelativePath(rawRelativePath); + const branch = typeof entry.branch === 'string' ? entry.branch.trim() : ''; + if (!relativePath || !branch) { + continue; + } + + entriesByPath.set(relativePath, { + branch, + claimedAt: typeof entry.claimed_at === 'string' ? entry.claimed_at : '', + allowDelete: Boolean(entry.allow_delete), + }); + countsByBranch.set(branch, (countsByBranch.get(branch) || 0) + 1); + } + + return { + entriesByPath, + countsByBranch, + }; +} + +function readCurrentBranch(repoRoot) { + try { + return cp.execFileSync('git', ['-C', repoRoot, 'rev-parse', '--abbrev-ref', 'HEAD'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + } catch (_error) { + return ''; + } +} + +function decorateSession(session, lockRegistry) { + return { + ...session, + lockCount: lockRegistry.countsByBranch.get(session.branch) || 0, + }; +} + +function decorateChange(change, lockRegistry, owningBranch) { + const lockEntry = lockRegistry.entriesByPath.get(normalizeRelativePath(change.relativePath)); + const lockOwnerBranch = lockEntry?.branch || ''; + return { + ...change, + lockOwnerBranch, + hasForeignLock: Boolean(lockOwnerBranch) && (!owningBranch || lockOwnerBranch !== owningBranch), + }; +} + function buildChangeTreeNodes(changes) { const root = []; @@ -317,10 +482,12 @@ function buildActiveAgentGroupNodes(sessions) { } class ActiveAgentsProvider { - constructor() { + constructor(decorationProvider) { + this.decorationProvider = decorationProvider; this.onDidChangeTreeDataEmitter = new vscode.EventEmitter(); this.onDidChangeTreeData = this.onDidChangeTreeDataEmitter.event; this.treeView = null; + this.lockRegistryByRepoRoot = new Map(); } getTreeItem(element) { @@ -349,8 +516,37 @@ class ActiveAgentsProvider { : 'Start a sandbox session to populate this view.'; } - refresh() { + async syncRepoEntries() { + const repoEntries = await this.loadRepoEntries(); + const sessionCount = repoEntries.reduce((total, entry) => total + entry.sessions.length, 0); + const workingCount = repoEntries.reduce( + (total, entry) => total + countWorkingSessions(entry.sessions), + 0, + ); + + this.updateViewState(sessionCount, workingCount); + this.decorationProvider?.updateSessions(repoEntries.flatMap((entry) => entry.sessions)); + return repoEntries; + } + + async refresh() { + await this.syncRepoEntries(); this.onDidChangeTreeDataEmitter.fire(); + this.decorationProvider?.refresh(); + } + + readLockRegistryForRepo(repoRoot) { + const lockRegistry = readLockRegistry(repoRoot); + this.lockRegistryByRepoRoot.set(repoRoot, lockRegistry); + return lockRegistry; + } + + getLockRegistryForRepo(repoRoot) { + return this.lockRegistryByRepoRoot.get(repoRoot) || this.readLockRegistryForRepo(repoRoot); + } + + refreshLockRegistryForFile(filePath) { + this.readLockRegistryForRepo(repoRootFromLockFile(filePath)); } async getChildren(element) { @@ -370,13 +566,7 @@ class ActiveAgentsProvider { return element.items; } - const repoEntries = await this.loadRepoEntries(); - const sessionCount = repoEntries.reduce((total, entry) => total + entry.sessions.length, 0); - const workingCount = repoEntries.reduce( - (total, entry) => total + countWorkingSessions(entry.sessions), - 0, - ); - this.updateViewState(sessionCount, workingCount); + const repoEntries = await this.syncRepoEntries(); if (repoEntries.length === 0) { return [new InfoItem('No active Guardex agents', 'Open or start a sandbox session.')]; @@ -405,12 +595,16 @@ class ActiveAgentsProvider { const repoEntries = []; for (const repoRoot of repoRoots) { - const sessions = readActiveSessions(repoRoot); + const lockRegistry = this.getLockRegistryForRepo(repoRoot); + const sessions = readActiveSessions(repoRoot).map((session) => decorateSession(session, lockRegistry)); if (sessions.length > 0) { + const currentBranch = readCurrentBranch(repoRoot); repoEntries.push({ repoRoot, sessions, - changes: readRepoChanges(repoRoot), + changes: readRepoChanges(repoRoot).map((change) => ( + decorateChange(change, lockRegistry, currentBranch) + )), }); } } @@ -421,18 +615,29 @@ class ActiveAgentsProvider { } function activate(context) { - const provider = new ActiveAgentsProvider(); + const decorationProvider = new SessionDecorationProvider(); + const provider = new ActiveAgentsProvider(decorationProvider); const treeView = vscode.window.createTreeView('gitguardex.activeAgents', { treeDataProvider: provider, showCollapseAll: true, }); provider.attachTreeView(treeView); - const refresh = () => provider.refresh(); - const watcher = vscode.workspace.createFileSystemWatcher('**/.omx/state/active-sessions/*.json'); + const refresh = () => { + void provider.refresh(); + }; + const sessionWatcher = vscode.workspace.createFileSystemWatcher('**/.omx/state/active-sessions/*.json'); + const lockWatcher = vscode.workspace.createFileSystemWatcher('**/.omx/state/agent-file-locks.json'); const interval = setInterval(refresh, 5_000); + const refreshLockRegistry = (uri) => { + if (uri?.fsPath) { + provider.refreshLockRegistryForFile(uri.fsPath); + } + refresh(); + }; context.subscriptions.push( treeView, + vscode.window.registerFileDecorationProvider(decorationProvider), vscode.commands.registerCommand('gitguardex.activeAgents.refresh', refresh), vscode.commands.registerCommand('gitguardex.activeAgents.openWorktree', async (session) => { if (!session?.worktreePath) { @@ -462,13 +667,17 @@ function activate(context) { vscode.commands.registerCommand('gitguardex.activeAgents.stopSession', (session) => stopSession(session, refresh)), vscode.commands.registerCommand('gitguardex.activeAgents.openSessionDiff', openSessionDiff), vscode.workspace.onDidChangeWorkspaceFolders(refresh), - watcher, + sessionWatcher, + lockWatcher, { dispose: () => clearInterval(interval) }, ); - watcher.onDidCreate(refresh, undefined, context.subscriptions); - watcher.onDidChange(refresh, undefined, context.subscriptions); - watcher.onDidDelete(refresh, undefined, context.subscriptions); + sessionWatcher.onDidCreate(refresh, undefined, context.subscriptions); + sessionWatcher.onDidChange(refresh, undefined, context.subscriptions); + sessionWatcher.onDidDelete(refresh, undefined, context.subscriptions); + lockWatcher.onDidCreate(refreshLockRegistry, undefined, context.subscriptions); + lockWatcher.onDidChange(refreshLockRegistry, undefined, context.subscriptions); + lockWatcher.onDidDelete(refreshLockRegistry, undefined, context.subscriptions); } function deactivate() {} diff --git a/templates/vscode/guardex-active-agents/session-schema.js b/templates/vscode/guardex-active-agents/session-schema.js index aaeef7b..98390bf 100644 --- a/templates/vscode/guardex-active-agents/session-schema.js +++ b/templates/vscode/guardex-active-agents/session-schema.js @@ -7,6 +7,7 @@ const SESSION_SCHEMA_VERSION = 1; const LOCK_FILE_RELATIVE = path.join('.omx', 'state', 'agent-file-locks.json'); const MAX_CHANGED_PATH_PREVIEW = 3; const ACTIVE_SESSIONS_FILTER_PREFIX = ACTIVE_SESSIONS_RELATIVE_DIR.split(path.sep).join('/'); +const LOCK_FILE_FILTER_PATH = LOCK_FILE_RELATIVE.split(path.sep).join('/'); function toNonEmptyString(value, fallback = '') { const normalized = typeof value === 'string' ? value.trim() : String(value || '').trim(); @@ -150,7 +151,9 @@ function parseRepoChangeLine(repoRoot, line) { const normalizedRelativePath = relativePath.split(path.sep).join('/'); if ( - normalizedRelativePath === ACTIVE_SESSIONS_FILTER_PREFIX + normalizedRelativePath === LOCK_FILE_FILTER_PATH + || normalizedRelativePath.startsWith(`${LOCK_FILE_FILTER_PATH}/`) + || normalizedRelativePath === ACTIVE_SESSIONS_FILTER_PREFIX || normalizedRelativePath.startsWith(`${ACTIVE_SESSIONS_FILTER_PREFIX}/`) ) { return null; diff --git a/test/vscode-active-agents-session-state.test.js b/test/vscode-active-agents-session-state.test.js index 87ca74d..3b79300 100644 --- a/test/vscode-active-agents-session-state.test.js +++ b/test/vscode-active-agents-session-state.test.js @@ -62,6 +62,7 @@ function loadExtensionWithMockVscode(mockVscode) { function createMockVscode(tempRoot) { const registrations = { providers: [], + decorationProviders: [], treeViews: [], commands: new Map(), executedCommands: [], @@ -70,6 +71,7 @@ function createMockVscode(tempRoot) { shownDocuments: [], infoMessages: [], warningMessages: [], + watchers: [], }; class TreeItem { @@ -85,21 +87,75 @@ function createMockVscode(tempRoot) { } } + class ThemeColor { + constructor(id) { + this.id = id; + } + } + class EventEmitter { constructor() { - this.event = () => {}; + this.listeners = []; + this.event = (listener, thisArg, disposables) => { + const boundListener = thisArg ? listener.bind(thisArg) : listener; + this.listeners.push(boundListener); + const registration = { + dispose: () => { + this.listeners = this.listeners.filter((entry) => entry !== boundListener); + }, + }; + if (Array.isArray(disposables)) { + disposables.push(registration); + } + return registration; + }; } - fire() {} + fire(event) { + for (const listener of [...this.listeners]) { + listener(event); + } + } } const disposable = () => ({ dispose() {} }); - const fileWatcher = { - onDidCreate() {}, - onDidChange() {}, - onDidDelete() {}, - dispose() {}, - }; + + function createFileWatcher(pattern) { + const listeners = { + create: [], + change: [], + delete: [], + }; + + return { + pattern, + onDidCreate(callback, thisArg) { + listeners.create.push({ callback, thisArg }); + }, + onDidChange(callback, thisArg) { + listeners.change.push({ callback, thisArg }); + }, + onDidDelete(callback, thisArg) { + listeners.delete.push({ callback, thisArg }); + }, + fireCreate(uri) { + for (const listener of listeners.create) { + listener.callback.call(listener.thisArg, uri); + } + }, + fireChange(uri) { + for (const listener of listeners.change) { + listener.callback.call(listener.thisArg, uri); + } + }, + fireDelete(uri) { + for (const listener of listeners.delete) { + listener.callback.call(listener.thisArg, uri); + } + }, + dispose() {}, + }; + } return { registrations, @@ -121,7 +177,25 @@ function createMockVscode(tempRoot) { }, }, Uri: { - file: (fsPath) => ({ fsPath }), + file: (fsPath) => ({ + scheme: 'file', + fsPath, + path: fsPath, + toString() { + return `file://${fsPath}`; + }, + }), + parse: (value) => { + const parsed = new URL(value); + return { + scheme: parsed.protocol.replace(/:$/, ''), + authority: parsed.host, + path: parsed.pathname, + toString() { + return value; + }, + }; + }, }, window: { showInformationMessage: async (...args) => { @@ -164,6 +238,10 @@ function createMockVscode(tempRoot) { registrations.providers.push({ viewId, provider: options.treeDataProvider }); return treeView; }, + registerFileDecorationProvider: (provider) => { + registrations.decorationProviders.push(provider); + return disposable(); + }, registerTreeDataProvider: (viewId, provider) => { registrations.providers.push({ viewId, provider }); return disposable(); @@ -178,11 +256,16 @@ function createMockVscode(tempRoot) { registrations.openedDocuments.push(document); return document; }, - createFileSystemWatcher: () => fileWatcher, + createFileSystemWatcher: (pattern) => { + const watcher = createFileWatcher(pattern); + registrations.watchers.push(watcher); + return watcher; + }, findFiles: async () => [], onDidChangeWorkspaceFolders: () => disposable(), workspaceFolders: [{ uri: { fsPath: tempRoot } }], }, + ThemeColor, }, }; } @@ -349,7 +432,7 @@ test('install-vscode-active-agents-extension installs the current extension vers assert.match(result.stdout, /Reload the VS Code window/); }); -test('active-agents extension registers a provider with getTreeItem', async () => { +test('active-agents extension registers tree and decoration providers', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-view-')); const { registrations, vscode } = createMockVscode(tempRoot); const extension = loadExtensionWithMockVscode(vscode); @@ -361,6 +444,7 @@ test('active-agents extension registers a provider with getTreeItem', async () = assert.equal(registrations.treeViews[0].viewId, 'gitguardex.activeAgents'); assert.equal(registrations.providers.length, 1); assert.equal(registrations.providers[0].viewId, 'gitguardex.activeAgents'); + assert.equal(registrations.decorationProviders.length, 1); const provider = registrations.providers[0].provider; assert.equal(typeof provider.getTreeItem, 'function'); @@ -414,9 +498,14 @@ test('active-agents extension groups live sessions under a repo node', async () assert.equal(thinkingSection.label, 'THINKING'); const [sessionItem] = await provider.getChildren(thinkingSection); - assert.equal(sessionItem.label, 'live-task'); + assert.equal(sessionItem.label, 'live-task ๐Ÿ”’ 0'); assert.match(sessionItem.description, /^thinking ยท \d+[smhd]/); assert.equal(sessionItem.iconPath.id, 'loading~spin'); + assert.equal(sessionItem.resourceUri.scheme, 'gitguardex-agent'); + assert.equal( + sessionItem.resourceUri.toString(), + `gitguardex-agent://${sessionSchema.sanitizeBranchForFile('agent/codex/live-task')}`, + ); assert.deepEqual(registrations.treeViews[0].badge, { value: 1, tooltip: '1 active agent', @@ -428,6 +517,150 @@ test('active-agents extension groups live sessions under a repo node', async () } }); +test('active-agents extension decorates idle clean sessions without overriding working rows', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-idle-decorations-')); + + const idleWarningPath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-idle-warning-')); + initGitRepo(idleWarningPath); + fs.writeFileSync(path.join(idleWarningPath, 'tracked.txt'), 'base\n', 'utf8'); + runGit(idleWarningPath, ['add', 'tracked.txt']); + runGit(idleWarningPath, ['commit', '-m', 'baseline']); + + const idleErrorPath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-idle-error-')); + initGitRepo(idleErrorPath); + fs.writeFileSync(path.join(idleErrorPath, 'tracked.txt'), 'base\n', 'utf8'); + runGit(idleErrorPath, ['add', 'tracked.txt']); + runGit(idleErrorPath, ['commit', '-m', 'baseline']); + + const workingPath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-idle-working-')); + initGitRepo(workingPath); + fs.writeFileSync(path.join(workingPath, 'tracked.txt'), 'base\n', 'utf8'); + runGit(workingPath, ['add', 'tracked.txt']); + runGit(workingPath, ['commit', '-m', 'baseline']); + fs.writeFileSync(path.join(workingPath, 'tracked.txt'), 'base\nchanged\n', 'utf8'); + + const sessionRecords = [ + { + branch: 'agent/codex/idle-warning', + worktreePath: idleWarningPath, + startedAt: new Date(Date.now() - (11 * 60 * 1000)).toISOString(), + }, + { + branch: 'agent/codex/idle-error', + worktreePath: idleErrorPath, + startedAt: new Date(Date.now() - (31 * 60 * 1000)).toISOString(), + }, + { + branch: 'agent/codex/working-now', + worktreePath: workingPath, + startedAt: new Date(Date.now() - (31 * 60 * 1000)).toISOString(), + }, + ]; + + for (const record of sessionRecords) { + const sessionPath = sessionSchema.sessionFilePathForBranch(tempRoot, record.branch); + fs.mkdirSync(path.dirname(sessionPath), { recursive: true }); + fs.writeFileSync( + sessionPath, + `${JSON.stringify(sessionSchema.buildSessionRecord({ + repoRoot: tempRoot, + branch: record.branch, + taskName: path.basename(record.worktreePath), + agentName: 'codex', + worktreePath: record.worktreePath, + pid: process.pid, + cliName: 'codex', + startedAt: record.startedAt, + }), null, 2)}\n`, + 'utf8', + ); + } + + const { registrations, vscode } = createMockVscode(tempRoot); + vscode.workspace.findFiles = async () => sessionRecords.map((record) => ({ + fsPath: sessionSchema.sessionFilePathForBranch(tempRoot, record.branch), + })); + const extension = loadExtensionWithMockVscode(vscode); + const context = { subscriptions: [] }; + + extension.activate(context); + + const provider = registrations.providers[0].provider; + await provider.getChildren(); + const decorationProvider = registrations.decorationProviders[0]; + + const warningDecoration = decorationProvider.provideFileDecoration(vscode.Uri.parse( + `gitguardex-agent://${sessionSchema.sanitizeBranchForFile('agent/codex/idle-warning')}`, + )); + assert.equal(warningDecoration.badge, '10m+'); + assert.equal(warningDecoration.tooltip, 'idle 10m+'); + assert.equal(warningDecoration.color.id, 'list.warningForeground'); + + const errorDecoration = decorationProvider.provideFileDecoration(vscode.Uri.parse( + `gitguardex-agent://${sessionSchema.sanitizeBranchForFile('agent/codex/idle-error')}`, + )); + assert.equal(errorDecoration.badge, '30m+'); + assert.equal(errorDecoration.tooltip, 'idle 30m+'); + assert.equal(errorDecoration.color.id, 'list.errorForeground'); + + const workingDecoration = decorationProvider.provideFileDecoration(vscode.Uri.parse( + `gitguardex-agent://${sessionSchema.sanitizeBranchForFile('agent/codex/working-now')}`, + )); + assert.equal(workingDecoration, undefined); + + for (const subscription of context.subscriptions) { + subscription.dispose?.(); + } +}); + +test('active-agents refresh also invalidates session decorations', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-decoration-refresh-')); + const worktreePath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-decoration-refresh-session-')); + initGitRepo(worktreePath); + fs.writeFileSync(path.join(worktreePath, 'tracked.txt'), 'base\n', 'utf8'); + runGit(worktreePath, ['add', 'tracked.txt']); + runGit(worktreePath, ['commit', '-m', 'baseline']); + + const sessionPath = sessionSchema.sessionFilePathForBranch(tempRoot, 'agent/codex/idle-refresh'); + fs.mkdirSync(path.dirname(sessionPath), { recursive: true }); + fs.writeFileSync( + sessionPath, + `${JSON.stringify(sessionSchema.buildSessionRecord({ + repoRoot: tempRoot, + branch: 'agent/codex/idle-refresh', + taskName: 'idle-refresh', + agentName: 'codex', + worktreePath, + pid: process.pid, + cliName: 'codex', + startedAt: new Date(Date.now() - (11 * 60 * 1000)).toISOString(), + }), null, 2)}\n`, + 'utf8', + ); + + const { registrations, vscode } = createMockVscode(tempRoot); + vscode.workspace.findFiles = async () => [{ fsPath: sessionPath }]; + const extension = loadExtensionWithMockVscode(vscode); + const context = { subscriptions: [] }; + + extension.activate(context); + + const provider = registrations.providers[0].provider; + await provider.getChildren(); + + let decorationRefreshCount = 0; + registrations.decorationProviders[0].onDidChangeFileDecorations(() => { + decorationRefreshCount += 1; + }); + + await provider.refresh(); + assert.equal(decorationRefreshCount, 1); + + for (const subscription of context.subscriptions) { + subscription.dispose?.(); + } +}); + test('active-agents extension shows grouped repo changes beside active agents', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-working-view-')); initGitRepo(tempRoot); @@ -478,7 +711,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)); + assert.equal(sessionItem.label, `${path.basename(worktreePath)} ๐Ÿ”’ 0`); assert.match(sessionItem.description, /^working ยท 2 files ยท /); assert.match(sessionItem.tooltip, /Changed 2 files: new-file\.txt, tracked\.txt/); assert.equal(sessionItem.iconPath.id, 'edit'); @@ -497,6 +730,180 @@ test('active-agents extension shows grouped repo changes beside active agents', } }); +test('active-agents extension decorates sessions and repo changes from the lock registry', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-lock-decorations-')); + initGitRepo(tempRoot); + fs.writeFileSync(path.join(tempRoot, 'root-file.txt'), 'base\n', 'utf8'); + runGit(tempRoot, ['add', 'root-file.txt']); + runGit(tempRoot, ['commit', '-m', 'baseline']); + fs.writeFileSync(path.join(tempRoot, 'root-file.txt'), 'base\nchanged\n', 'utf8'); + + const worktreePath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-lock-worktree-')); + initGitRepo(worktreePath); + fs.writeFileSync(path.join(worktreePath, 'tracked.txt'), 'base\n', 'utf8'); + runGit(worktreePath, ['add', 'tracked.txt']); + runGit(worktreePath, ['commit', '-m', 'baseline']); + + const branch = 'agent/codex/live-task'; + const sessionPath = sessionSchema.sessionFilePathForBranch(tempRoot, branch); + fs.mkdirSync(path.dirname(sessionPath), { recursive: true }); + fs.writeFileSync( + sessionPath, + `${JSON.stringify(sessionSchema.buildSessionRecord({ + repoRoot: tempRoot, + branch, + taskName: 'live-task', + agentName: 'codex', + worktreePath, + pid: process.pid, + cliName: 'codex', + }), null, 2)}\n`, + 'utf8', + ); + + const lockPath = path.join(tempRoot, '.omx', 'state', 'agent-file-locks.json'); + fs.mkdirSync(path.dirname(lockPath), { recursive: true }); + fs.writeFileSync(lockPath, `${JSON.stringify({ + locks: { + 'owned-file.txt': { + branch, + claimed_at: '2026-04-22T08:55:00.000Z', + allow_delete: false, + }, + 'root-file.txt': { + branch: 'agent/codex/other-task', + claimed_at: '2026-04-22T08:56:00.000Z', + allow_delete: false, + }, + }, + }, null, 2)}\n`, 'utf8'); + + const { registrations, vscode } = createMockVscode(tempRoot); + vscode.workspace.findFiles = async () => [{ fsPath: sessionPath }]; + const extension = loadExtensionWithMockVscode(vscode); + const context = { subscriptions: [] }; + + extension.activate(context); + + const provider = registrations.providers[0].provider; + const [repoItem] = await provider.getChildren(); + const [agentsSection, changesSection] = 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.match(sessionItem.tooltip, /Locks 1/); + + const [changeItem] = await provider.getChildren(changesSection); + assert.equal(changeItem.label, 'root-file.txt'); + assert.equal(changeItem.iconPath.id, 'warning'); + assert.match(changeItem.tooltip, /Locked by agent\/codex\/other-task/); + + for (const subscription of context.subscriptions) { + subscription.dispose?.(); + } +}); + +test('active-agents extension re-reads lock state on watcher events instead of every tree load', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-lock-watch-')); + const branch = 'agent/codex/live-task'; + const worktreePath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-lock-watch-worktree-')); + initGitRepo(worktreePath); + fs.writeFileSync(path.join(worktreePath, 'tracked.txt'), 'base\n', 'utf8'); + runGit(worktreePath, ['add', 'tracked.txt']); + runGit(worktreePath, ['commit', '-m', 'baseline']); + + const sessionPath = sessionSchema.sessionFilePathForBranch(tempRoot, branch); + fs.mkdirSync(path.dirname(sessionPath), { recursive: true }); + fs.writeFileSync( + sessionPath, + `${JSON.stringify(sessionSchema.buildSessionRecord({ + repoRoot: tempRoot, + branch, + taskName: 'live-task', + agentName: 'codex', + worktreePath, + pid: process.pid, + cliName: 'codex', + }), null, 2)}\n`, + 'utf8', + ); + + const lockPath = path.join(tempRoot, '.omx', 'state', 'agent-file-locks.json'); + fs.mkdirSync(path.dirname(lockPath), { recursive: true }); + fs.writeFileSync(lockPath, `${JSON.stringify({ + locks: { + 'owned-file.txt': { + branch, + claimed_at: '2026-04-22T08:57:00.000Z', + allow_delete: false, + }, + }, + }, null, 2)}\n`, 'utf8'); + + const { registrations, vscode } = createMockVscode(tempRoot); + vscode.workspace.findFiles = async () => [{ fsPath: sessionPath }]; + const extension = loadExtensionWithMockVscode(vscode); + const context = { subscriptions: [] }; + + const originalReadFileSync = fs.readFileSync; + let lockReadCount = 0; + fs.readFileSync = function patchedReadFileSync(filePath, ...args) { + if (path.resolve(String(filePath)) === lockPath) { + lockReadCount += 1; + } + return originalReadFileSync.call(this, filePath, ...args); + }; + + try { + extension.activate(context); + + const provider = registrations.providers[0].provider; + const lockWatcher = registrations.watchers.find((watcher) => watcher.pattern === '**/.omx/state/agent-file-locks.json'); + assert.ok(lockWatcher, 'expected lock watcher registration'); + + const [repoItem] = await provider.getChildren(); + 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(lockReadCount, 1); + + await provider.getChildren(); + assert.equal(lockReadCount, 1); + + fs.writeFileSync(lockPath, `${JSON.stringify({ + locks: { + 'owned-file.txt': { + branch, + claimed_at: '2026-04-22T08:57:00.000Z', + allow_delete: false, + }, + 'second-owned-file.txt': { + branch, + claimed_at: '2026-04-22T08:58:00.000Z', + allow_delete: false, + }, + }, + }, null, 2)}\n`, 'utf8'); + lockWatcher.fireChange({ fsPath: lockPath }); + assert.equal(lockReadCount, 2); + + const [updatedRepoItem] = await provider.getChildren(); + 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`); + + await provider.getChildren(); + assert.equal(lockReadCount, 2); + } finally { + fs.readFileSync = originalReadFileSync; + for (const subscription of context.subscriptions) { + subscription.dispose?.(); + } + } +}); + test('active-agents extension splits working and thinking sessions into separate groups', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-mixed-view-')); diff --git a/vscode/guardex-active-agents/extension.js b/vscode/guardex-active-agents/extension.js index a0e3fbc..cdad978 100644 --- a/vscode/guardex-active-agents/extension.js +++ b/vscode/guardex-active-agents/extension.js @@ -2,7 +2,77 @@ const fs = require('node:fs'); const path = require('node:path'); const cp = require('node:child_process'); const vscode = require('vscode'); -const { formatElapsedFrom, readActiveSessions, readRepoChanges } = require('./session-schema.js'); +const { + formatElapsedFrom, + readActiveSessions, + readRepoChanges, + sanitizeBranchForFile, +} = require('./session-schema.js'); + +const SESSION_DECORATION_SCHEME = 'gitguardex-agent'; +const IDLE_WARNING_MS = 10 * 60 * 1000; +const IDLE_ERROR_MS = 30 * 60 * 1000; +const LOCK_FILE_RELATIVE = path.join('.omx', 'state', 'agent-file-locks.json'); + +function sessionDecorationUri(branch) { + return vscode.Uri.parse(`${SESSION_DECORATION_SCHEME}://${sanitizeBranchForFile(branch)}`); +} + +function sessionIdleDecoration(session, now = Date.now()) { + if (!session || session.activityKind === 'working') { + return undefined; + } + + const startedAtMs = Date.parse(session.startedAt); + if (!Number.isFinite(startedAtMs)) { + return undefined; + } + + const elapsedMs = now - startedAtMs; + if (elapsedMs > IDLE_ERROR_MS) { + return { + badge: '30m+', + tooltip: 'idle 30m+', + color: new vscode.ThemeColor('list.errorForeground'), + }; + } + if (elapsedMs > IDLE_WARNING_MS) { + return { + badge: '10m+', + tooltip: 'idle 10m+', + color: new vscode.ThemeColor('list.warningForeground'), + }; + } + + return undefined; +} + +class SessionDecorationProvider { + constructor(nowProvider = () => Date.now()) { + this.nowProvider = nowProvider; + this.sessionsByUri = new Map(); + this.onDidChangeFileDecorationsEmitter = new vscode.EventEmitter(); + this.onDidChangeFileDecorations = this.onDidChangeFileDecorationsEmitter.event; + } + + updateSessions(sessions) { + this.sessionsByUri = new Map( + sessions.map((session) => [sessionDecorationUri(session.branch).toString(), session]), + ); + } + + refresh() { + this.onDidChangeFileDecorationsEmitter.fire(); + } + + provideFileDecoration(uri) { + if (!uri || uri.scheme !== SESSION_DECORATION_SCHEME) { + return undefined; + } + + return sessionIdleDecoration(this.sessionsByUri.get(uri.toString()), this.nowProvider()); + } +} class InfoItem extends vscode.TreeItem { constructor(label, description = '') { @@ -48,8 +118,10 @@ class SectionItem extends vscode.TreeItem { class SessionItem extends vscode.TreeItem { constructor(session) { - super(session.label, vscode.TreeItemCollapsibleState.None); + const lockCount = Number.isFinite(session.lockCount) ? session.lockCount : 0; + super(`${session.label} ๐Ÿ”’ ${lockCount}`, vscode.TreeItemCollapsibleState.None); this.session = session; + this.resourceUri = sessionDecorationUri(session.branch); const descriptionParts = [session.activityLabel || 'thinking']; if (session.activityCountLabel) { descriptionParts.push(session.activityCountLabel); @@ -63,6 +135,7 @@ class SessionItem extends vscode.TreeItem { session.changeCount > 0 ? `Changed ${session.activityCountLabel}: ${session.activitySummary}` : session.activitySummary, + `Locks ${lockCount}`, `Started ${session.startedAt}`, session.worktreePath, ]; @@ -99,9 +172,13 @@ class ChangeItem extends vscode.TreeItem { change.relativePath, `Status ${change.statusText}`, change.originalPath ? `Renamed from ${change.originalPath}` : '', + change.hasForeignLock ? `Locked by ${change.lockOwnerBranch}` : '', change.absolutePath, ].filter(Boolean).join('\n'); this.resourceUri = vscode.Uri.file(change.absolutePath); + if (change.hasForeignLock) { + this.iconPath = new vscode.ThemeIcon('warning'); + } this.contextValue = 'gitguardex.change'; this.command = { command: 'gitguardex.activeAgents.openChange', @@ -232,6 +309,94 @@ function repoRootFromSessionFile(filePath) { return path.resolve(path.dirname(filePath), '..', '..', '..'); } +function repoRootFromLockFile(filePath) { + return path.resolve(path.dirname(filePath), '..', '..'); +} + +function normalizeRelativePath(relativePath) { + return String(relativePath || '').replace(/\\/g, '/'); +} + +function emptyLockRegistry() { + return { + entriesByPath: new Map(), + countsByBranch: new Map(), + }; +} + +function readLockRegistry(repoRoot) { + const lockPath = path.join(repoRoot, LOCK_FILE_RELATIVE); + if (!fs.existsSync(lockPath)) { + return emptyLockRegistry(); + } + + let parsed; + try { + parsed = JSON.parse(fs.readFileSync(lockPath, 'utf8')); + } catch (_error) { + return emptyLockRegistry(); + } + + const locks = parsed?.locks; + if (!locks || typeof locks !== 'object' || Array.isArray(locks)) { + return emptyLockRegistry(); + } + + const entriesByPath = new Map(); + const countsByBranch = new Map(); + for (const [rawRelativePath, entry] of Object.entries(locks)) { + if (!entry || typeof entry !== 'object') { + continue; + } + + const relativePath = normalizeRelativePath(rawRelativePath); + const branch = typeof entry.branch === 'string' ? entry.branch.trim() : ''; + if (!relativePath || !branch) { + continue; + } + + entriesByPath.set(relativePath, { + branch, + claimedAt: typeof entry.claimed_at === 'string' ? entry.claimed_at : '', + allowDelete: Boolean(entry.allow_delete), + }); + countsByBranch.set(branch, (countsByBranch.get(branch) || 0) + 1); + } + + return { + entriesByPath, + countsByBranch, + }; +} + +function readCurrentBranch(repoRoot) { + try { + return cp.execFileSync('git', ['-C', repoRoot, 'rev-parse', '--abbrev-ref', 'HEAD'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + } catch (_error) { + return ''; + } +} + +function decorateSession(session, lockRegistry) { + return { + ...session, + lockCount: lockRegistry.countsByBranch.get(session.branch) || 0, + }; +} + +function decorateChange(change, lockRegistry, owningBranch) { + const lockEntry = lockRegistry.entriesByPath.get(normalizeRelativePath(change.relativePath)); + const lockOwnerBranch = lockEntry?.branch || ''; + return { + ...change, + lockOwnerBranch, + hasForeignLock: Boolean(lockOwnerBranch) && (!owningBranch || lockOwnerBranch !== owningBranch), + }; +} + function buildChangeTreeNodes(changes) { const root = []; @@ -317,10 +482,12 @@ function buildActiveAgentGroupNodes(sessions) { } class ActiveAgentsProvider { - constructor() { + constructor(decorationProvider) { + this.decorationProvider = decorationProvider; this.onDidChangeTreeDataEmitter = new vscode.EventEmitter(); this.onDidChangeTreeData = this.onDidChangeTreeDataEmitter.event; this.treeView = null; + this.lockRegistryByRepoRoot = new Map(); } getTreeItem(element) { @@ -349,8 +516,37 @@ class ActiveAgentsProvider { : 'Start a sandbox session to populate this view.'; } - refresh() { + async syncRepoEntries() { + const repoEntries = await this.loadRepoEntries(); + const sessionCount = repoEntries.reduce((total, entry) => total + entry.sessions.length, 0); + const workingCount = repoEntries.reduce( + (total, entry) => total + countWorkingSessions(entry.sessions), + 0, + ); + + this.updateViewState(sessionCount, workingCount); + this.decorationProvider?.updateSessions(repoEntries.flatMap((entry) => entry.sessions)); + return repoEntries; + } + + async refresh() { + await this.syncRepoEntries(); this.onDidChangeTreeDataEmitter.fire(); + this.decorationProvider?.refresh(); + } + + readLockRegistryForRepo(repoRoot) { + const lockRegistry = readLockRegistry(repoRoot); + this.lockRegistryByRepoRoot.set(repoRoot, lockRegistry); + return lockRegistry; + } + + getLockRegistryForRepo(repoRoot) { + return this.lockRegistryByRepoRoot.get(repoRoot) || this.readLockRegistryForRepo(repoRoot); + } + + refreshLockRegistryForFile(filePath) { + this.readLockRegistryForRepo(repoRootFromLockFile(filePath)); } async getChildren(element) { @@ -370,13 +566,7 @@ class ActiveAgentsProvider { return element.items; } - const repoEntries = await this.loadRepoEntries(); - const sessionCount = repoEntries.reduce((total, entry) => total + entry.sessions.length, 0); - const workingCount = repoEntries.reduce( - (total, entry) => total + countWorkingSessions(entry.sessions), - 0, - ); - this.updateViewState(sessionCount, workingCount); + const repoEntries = await this.syncRepoEntries(); if (repoEntries.length === 0) { return [new InfoItem('No active Guardex agents', 'Open or start a sandbox session.')]; @@ -405,12 +595,16 @@ class ActiveAgentsProvider { const repoEntries = []; for (const repoRoot of repoRoots) { - const sessions = readActiveSessions(repoRoot); + const lockRegistry = this.getLockRegistryForRepo(repoRoot); + const sessions = readActiveSessions(repoRoot).map((session) => decorateSession(session, lockRegistry)); if (sessions.length > 0) { + const currentBranch = readCurrentBranch(repoRoot); repoEntries.push({ repoRoot, sessions, - changes: readRepoChanges(repoRoot), + changes: readRepoChanges(repoRoot).map((change) => ( + decorateChange(change, lockRegistry, currentBranch) + )), }); } } @@ -421,18 +615,29 @@ class ActiveAgentsProvider { } function activate(context) { - const provider = new ActiveAgentsProvider(); + const decorationProvider = new SessionDecorationProvider(); + const provider = new ActiveAgentsProvider(decorationProvider); const treeView = vscode.window.createTreeView('gitguardex.activeAgents', { treeDataProvider: provider, showCollapseAll: true, }); provider.attachTreeView(treeView); - const refresh = () => provider.refresh(); - const watcher = vscode.workspace.createFileSystemWatcher('**/.omx/state/active-sessions/*.json'); + const refresh = () => { + void provider.refresh(); + }; + const sessionWatcher = vscode.workspace.createFileSystemWatcher('**/.omx/state/active-sessions/*.json'); + const lockWatcher = vscode.workspace.createFileSystemWatcher('**/.omx/state/agent-file-locks.json'); const interval = setInterval(refresh, 5_000); + const refreshLockRegistry = (uri) => { + if (uri?.fsPath) { + provider.refreshLockRegistryForFile(uri.fsPath); + } + refresh(); + }; context.subscriptions.push( treeView, + vscode.window.registerFileDecorationProvider(decorationProvider), vscode.commands.registerCommand('gitguardex.activeAgents.refresh', refresh), vscode.commands.registerCommand('gitguardex.activeAgents.openWorktree', async (session) => { if (!session?.worktreePath) { @@ -462,13 +667,17 @@ function activate(context) { vscode.commands.registerCommand('gitguardex.activeAgents.stopSession', (session) => stopSession(session, refresh)), vscode.commands.registerCommand('gitguardex.activeAgents.openSessionDiff', openSessionDiff), vscode.workspace.onDidChangeWorkspaceFolders(refresh), - watcher, + sessionWatcher, + lockWatcher, { dispose: () => clearInterval(interval) }, ); - watcher.onDidCreate(refresh, undefined, context.subscriptions); - watcher.onDidChange(refresh, undefined, context.subscriptions); - watcher.onDidDelete(refresh, undefined, context.subscriptions); + sessionWatcher.onDidCreate(refresh, undefined, context.subscriptions); + sessionWatcher.onDidChange(refresh, undefined, context.subscriptions); + sessionWatcher.onDidDelete(refresh, undefined, context.subscriptions); + lockWatcher.onDidCreate(refreshLockRegistry, undefined, context.subscriptions); + lockWatcher.onDidChange(refreshLockRegistry, undefined, context.subscriptions); + lockWatcher.onDidDelete(refreshLockRegistry, undefined, context.subscriptions); } function deactivate() {} diff --git a/vscode/guardex-active-agents/session-schema.js b/vscode/guardex-active-agents/session-schema.js index aaeef7b..98390bf 100644 --- a/vscode/guardex-active-agents/session-schema.js +++ b/vscode/guardex-active-agents/session-schema.js @@ -7,6 +7,7 @@ const SESSION_SCHEMA_VERSION = 1; const LOCK_FILE_RELATIVE = path.join('.omx', 'state', 'agent-file-locks.json'); const MAX_CHANGED_PATH_PREVIEW = 3; const ACTIVE_SESSIONS_FILTER_PREFIX = ACTIVE_SESSIONS_RELATIVE_DIR.split(path.sep).join('/'); +const LOCK_FILE_FILTER_PATH = LOCK_FILE_RELATIVE.split(path.sep).join('/'); function toNonEmptyString(value, fallback = '') { const normalized = typeof value === 'string' ? value.trim() : String(value || '').trim(); @@ -150,7 +151,9 @@ function parseRepoChangeLine(repoRoot, line) { const normalizedRelativePath = relativePath.split(path.sep).join('/'); if ( - normalizedRelativePath === ACTIVE_SESSIONS_FILTER_PREFIX + normalizedRelativePath === LOCK_FILE_FILTER_PATH + || normalizedRelativePath.startsWith(`${LOCK_FILE_FILTER_PATH}/`) + || normalizedRelativePath === ACTIVE_SESSIONS_FILTER_PREFIX || normalizedRelativePath.startsWith(`${ACTIVE_SESSIONS_FILTER_PREFIX}/`) ) { return null;