From b95b3522052510bc50a54dad013c3377a63c11a7 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Wed, 22 Apr 2026 11:04:16 +0200 Subject: [PATCH 1/2] Highlight idle active-agent lanes in VS Code trees Synthetic gitguardex-agent:// URIs let the Active Agents view decorate session rows without pointing at workspace files. The refresh path now invalidates both tree data and session decorations, and the focused tests cover URI assignment plus the 10 minute and 30 minute idle thresholds. Constraint: Session rows are not backed by real files, so the decoration key must be synthetic and branch-stable Constraint: Working rows must keep their existing styling Rejected: Reuse workspace file URIs for session rows | would couple session decoration to unrelated files Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep vscode/guardex-active-agents/extension.js and templates/vscode/guardex-active-agents/extension.js mirrored when session-row UI changes Tested: node --test test/vscode-active-agents-session-state.test.js; openspec validate agent-codex-vscode-tree-lock-decorations-2026-04-22-10-55 --type change --strict Not-tested: npm test hangs in test/install.test.js while autofinish flows are spawned --- .../proposal.md | 16 + .../vscode-active-agents-extension/spec.md | 34 ++ .../tasks.md | 29 ++ .../vscode/guardex-active-agents/extension.js | 249 +++++++++- ...vscode-active-agents-session-state.test.js | 433 +++++++++++++++++- vscode/guardex-active-agents/extension.js | 249 +++++++++- 6 files changed, 957 insertions(+), 53 deletions(-) create mode 100644 openspec/changes/agent-codex-vscode-tree-lock-decorations-2026-04-22-10-55/proposal.md create mode 100644 openspec/changes/agent-codex-vscode-tree-lock-decorations-2026-04-22-10-55/specs/vscode-active-agents-extension/spec.md create mode 100644 openspec/changes/agent-codex-vscode-tree-lock-decorations-2026-04-22-10-55/tasks.md 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/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() {} From 3551ae543e76d024be31878cb4f7d67d40c22361 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Wed, 22 Apr 2026 11:08:09 +0200 Subject: [PATCH 2/2] Hide the Guardex lock registry from repo change rows The active-agents session-schema tests on current main already expect .omx/state/agent-file-locks.json to stay out of the CHANGES section. Filtering that path in the runtime and template schema keeps the existing lock-registry rows green without changing the idle session decoration behavior. Constraint: The lock registry is Guardex metadata, not a user edit, so showing it in repo change rows creates noisy false positives Rejected: Skip the existing lock-registry assertions | would leave the focused extension test file red on current main Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep metadata files out of repo-root change lists unless the companion explicitly needs to surface them Tested: node --test test/vscode-active-agents-session-state.test.js Not-tested: npm test still hangs in test/install.test.js while autofinish flows are spawned --- templates/vscode/guardex-active-agents/session-schema.js | 5 ++++- vscode/guardex-active-agents/session-schema.js | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) 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/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;