diff --git a/README.md b/README.md index b2c2d7a..6d8ecd7 100644 --- a/README.md +++ b/README.md @@ -269,7 +269,7 @@ To install the real companion into local VS Code from a GitGuardex-wired repo: node scripts/install-vscode-active-agents-extension.js ``` -It adds an `Active Agents` view to the Source Control container, groups each live repo into `ACTIVE AGENTS` and `CHANGES` sections, splits `ACTIVE AGENTS` into `BLOCKED`, `WORKING NOW`, `IDLE`, `STALLED`, and `DEAD` when those states are present, reads `.omx/state/active-sessions/*.json`, derives session state from git conflict markers, dirty worktree status, PID liveness, and recent file mtimes, and surfaces working/dead counts in the repo/header affordances. Reload the VS Code window after install. +It adds an `Active Agents` view to the Source Control container, groups each live repo into `ACTIVE AGENTS` and `CHANGES` sections, splits `ACTIVE AGENTS` into `BLOCKED`, `WORKING NOW`, `IDLE`, `STALLED`, and `DEAD` when those states are present, mirrors the selected session or active-agent count in the VS Code status bar, reads `.omx/state/active-sessions/*.json`, derives session state from git conflict markers, dirty worktree status, PID liveness, and recent file mtimes, and surfaces working/dead counts in the repo/header affordances. Reload the VS Code window after install. --- diff --git a/openspec/changes/agent-codex-scm-active-agent-signals-2026-04-22-15-54/notes.md b/openspec/changes/agent-codex-scm-active-agent-signals-2026-04-22-15-54/notes.md new file mode 100644 index 0000000..605f4e2 --- /dev/null +++ b/openspec/changes/agent-codex-scm-active-agent-signals-2026-04-22-15-54/notes.md @@ -0,0 +1,26 @@ +# agent-codex-scm-active-agent-signals-2026-04-22-15-54 (minimal / T1) + +Branch: `agent/codex/scm-active-agent-signals-2026-04-22-15-54` + +Make the existing VS Code `Active Agents` Source Control surface harder to miss during commit flow. Keep the grouped tree in the SCM container, but add stronger current-agent cues in the commit input, a status-bar shortcut, and file decorations for Guardex lock ownership on changed files. + +Scope: +- Extend the Active Agents extension manifest with a focus command and status-bar-friendly wording. +- Update `templates/vscode/guardex-active-agents/extension.js` to show selected-session branch metadata in the custom SCM commit input and status bar. +- Add file decorations for real file URIs so Guardex lock ownership is visible directly on changed files. +- Extend the active-agents regression suite for the new SCM input, status bar, and lock-decoration behavior. +- Update the extension README so the SCM affordances match the shipped experience. + +Verification: +- `node --test test/vscode-active-agents-session-state.test.js` + +## Handoff + +- Handoff: change=`agent-codex-scm-active-agent-signals-2026-04-22-15-54`; branch=`agent/codex/scm-active-agent-signals-2026-04-22-15-54`; scope=`templates/vscode/guardex-active-agents/*, test/vscode-active-agents-session-state.test.js, openspec/changes/agent-codex-scm-active-agent-signals-2026-04-22-15-54/*`; action=`add SCM-visible agent cues, verify with targeted extension tests, then finish this sandbox via PR merge + cleanup`. +- Copy prompt: Continue `agent-codex-scm-active-agent-signals-2026-04-22-15-54` on branch `agent/codex/scm-active-agent-signals-2026-04-22-15-54`. Work inside the existing sandbox, review `openspec/changes/agent-codex-scm-active-agent-signals-2026-04-22-15-54/notes.md`, continue from the current state instead of creating a new sandbox, and when the work is done run `gx branch finish --branch agent/codex/scm-active-agent-signals-2026-04-22-15-54 --base main --via-pr --wait-for-merge --cleanup`. + +## Cleanup + +- [ ] Run: `gx branch finish --branch agent/codex/scm-active-agent-signals-2026-04-22-15-54 --base main --via-pr --wait-for-merge --cleanup` +- [ ] Record PR URL + `MERGED` state in the completion handoff. +- [ ] Confirm sandbox worktree is gone (`git worktree list`, `git branch -a`). diff --git a/templates/vscode/guardex-active-agents/README.md b/templates/vscode/guardex-active-agents/README.md index 9d74245..6cfcf84 100644 --- a/templates/vscode/guardex-active-agents/README.md +++ b/templates/vscode/guardex-active-agents/README.md @@ -20,6 +20,7 @@ What it does: - Adds an `Active Agents` view to the Source Control container. - Renders one repo node per live Guardex workspace with grouped `ACTIVE AGENTS` and `CHANGES` sections. - Splits live sessions inside `ACTIVE AGENTS` into `BLOCKED`, `WORKING NOW`, `IDLE`, `STALLED`, and `DEAD` groups so stuck, active, and inactive lanes stand out immediately. +- Mirrors the same live state in the VS Code status bar so the selected session or active-agent count stays visible outside the tree. - Shows one row per live Guardex sandbox session inside those activity groups. - Shows repo-root git changes in a sibling `CHANGES` section when the guarded repo itself is dirty. - Derives session state from dirty worktree status, git conflict markers, PID liveness, and recent file mtimes, surfaces working/dead counts in the repo/header summary, and shows changed-file counts for active edits. diff --git a/templates/vscode/guardex-active-agents/extension.js b/templates/vscode/guardex-active-agents/extension.js index 4718b6c..947e008 100644 --- a/templates/vscode/guardex-active-agents/extension.js +++ b/templates/vscode/guardex-active-agents/extension.js @@ -93,10 +93,83 @@ function sessionIdleDecoration(session, now = Date.now()) { return undefined; } +function formatCountLabel(count, singular, plural = `${singular}s`) { + return `${count} ${count === 1 ? singular : plural}`; +} + +function sessionIdentityLabel(session) { + const agentName = typeof session?.agentName === 'string' ? session.agentName.trim() : ''; + const taskName = typeof session?.taskName === 'string' ? session.taskName.trim() : ''; + const label = typeof session?.label === 'string' ? session.label.trim() : ''; + + if (agentName && taskName) { + return `${agentName} · ${taskName}`; + } + if (agentName && label) { + return `${agentName} · ${label}`; + } + + return agentName || taskName || label || 'session'; +} + +function sessionCommitPlaceholder(session) { + if (!session?.branch) { + return 'Pick an Active Agents session to commit its worktree.'; + } + + return `Commit ${sessionIdentityLabel(session)} on ${session.branch} · ${formatCountLabel(session.lockCount || 0, 'lock')} (Ctrl+Enter)`; +} + +function agentNameFromBranch(branch) { + const segments = String(branch || '') + .split('/') + .map((segment) => segment.trim()) + .filter(Boolean); + if (segments[0] === 'agent' && segments[1]) { + return segments[1]; + } + return segments[0] || 'lock'; +} + +function agentBadgeFromBranch(branch) { + const normalized = agentNameFromBranch(branch).toUpperCase().replace(/[^A-Z0-9]/g, ''); + return normalized.slice(0, 2) || 'LK'; +} + +function buildActiveAgentsStatusSummary(summary) { + const activeCount = Math.max(0, (summary?.sessionCount || 0) - (summary?.deadCount || 0)); + if (activeCount > 0) { + return `$(git-branch) ${formatCountLabel(activeCount, 'active agent')}`; + } + return `$(git-branch) ${formatCountLabel(summary?.sessionCount || 0, 'tracked session')}`; +} + +function buildActiveAgentsStatusTooltip(selectedSession, summary) { + if (selectedSession?.branch) { + return [ + selectedSession.branch, + sessionIdentityLabel(selectedSession), + formatCountLabel(selectedSession.lockCount || 0, 'lock'), + selectedSession.worktreePath, + 'Click to open Source Control.', + ].filter(Boolean).join('\n'); + } + + const activeCount = Math.max(0, (summary?.sessionCount || 0) - (summary?.deadCount || 0)); + return [ + formatCountLabel(activeCount, 'active agent'), + formatCountLabel(summary?.workingCount || 0, 'working now session', 'working now sessions'), + summary?.deadCount ? formatCountLabel(summary.deadCount, 'dead session') : '', + 'Click to open Source Control.', + ].filter(Boolean).join('\n'); +} + class SessionDecorationProvider { constructor(nowProvider = () => Date.now()) { this.nowProvider = nowProvider; this.sessionsByUri = new Map(); + this.lockEntriesByFileUri = new Map(); + this.selectedBranch = ''; this.onDidChangeFileDecorationsEmitter = new vscode.EventEmitter(); this.onDidChangeFileDecorations = this.onDidChangeFileDecorationsEmitter.event; } @@ -107,13 +180,54 @@ class SessionDecorationProvider { ); } + updateLockEntries(repoEntries) { + const nextEntriesByUri = new Map(); + for (const entry of repoEntries || []) { + for (const [relativePath, lockEntry] of entry.lockEntries || []) { + nextEntriesByUri.set( + vscode.Uri.file(path.join(entry.repoRoot, relativePath)).toString(), + { branch: lockEntry.branch }, + ); + } + } + this.lockEntriesByFileUri = nextEntriesByUri; + } + + setSelectedBranch(branch) { + this.selectedBranch = typeof branch === 'string' ? branch.trim() : ''; + } + refresh() { this.onDidChangeFileDecorationsEmitter.fire(); } provideFileDecoration(uri) { if (!uri || uri.scheme !== SESSION_DECORATION_SCHEME) { - return undefined; + if (!uri || uri.scheme !== 'file') { + return undefined; + } + + const lockEntry = this.lockEntriesByFileUri.get(uri.toString()); + if (!lockEntry?.branch) { + return undefined; + } + + const ownsSelectedSession = Boolean(this.selectedBranch) && lockEntry.branch === this.selectedBranch; + return { + badge: agentBadgeFromBranch(lockEntry.branch), + tooltip: ownsSelectedSession + ? `Locked by selected session ${lockEntry.branch}` + : this.selectedBranch + ? `Locked by ${lockEntry.branch} (selected session: ${this.selectedBranch})` + : `Locked by ${lockEntry.branch}`, + color: new vscode.ThemeColor( + ownsSelectedSession + ? 'gitDecoration.modifiedResourceForeground' + : this.selectedBranch + ? 'list.errorForeground' + : 'list.warningForeground', + ), + }; } return sessionIdleDecoration(this.sessionsByUri.get(uri.toString()), this.nowProvider()); @@ -836,6 +950,11 @@ class ActiveAgentsProvider { this.treeView = null; this.lockRegistryByRepoRoot = new Map(); this.selectedSession = null; + this.viewSummary = { + sessionCount: 0, + workingCount: 0, + deadCount: 0, + }; } getTreeItem(element) { @@ -856,6 +975,7 @@ class ActiveAgentsProvider { const currentKey = sessionSelectionKey(this.selectedSession); const nextKey = sessionSelectionKey(nextSession); this.selectedSession = nextSession; + this.decorationProvider?.setSelectedBranch(nextSession?.branch || ''); if (currentKey !== nextKey) { this.onDidChangeSelectedSessionEmitter.fire(this.selectedSession); } @@ -865,6 +985,10 @@ class ActiveAgentsProvider { return this.selectedSession ? { ...this.selectedSession } : null; } + getViewSummary() { + return { ...this.viewSummary }; + } + syncSelectedSession(repoEntries) { if (!this.selectedSession) { return; @@ -882,6 +1006,11 @@ class ActiveAgentsProvider { } const activeCount = Math.max(0, sessionCount - deadCount); + this.viewSummary = { + sessionCount, + workingCount, + deadCount, + }; const badgeTooltipParts = []; if (activeCount > 0) { badgeTooltipParts.push(`${activeCount} active agent${activeCount === 1 ? '' : 's'}`); @@ -918,6 +1047,7 @@ class ActiveAgentsProvider { this.updateViewState(sessionCount, workingCount, deadCount); this.decorationProvider?.updateSessions(repoEntries.flatMap((entry) => entry.sessions)); + this.decorationProvider?.updateLockEntries(repoEntries); return repoEntries; } @@ -996,6 +1126,7 @@ class ActiveAgentsProvider { changes: readRepoChanges(repoRoot).map((change) => ( decorateChange(change, lockRegistry, currentBranch) )), + lockEntries: Array.from(lockRegistry.entriesByPath.entries()), }; }); } @@ -1080,6 +1211,9 @@ function activate(context) { 'gitguardex.activeAgents.commitInput', 'Active Agents Commit', ); + const activeAgentsStatusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 10); + activeAgentsStatusItem.name = 'GitGuardex Active Agents'; + activeAgentsStatusItem.command = 'gitguardex.activeAgents.focus'; provider.attachTreeView(treeView); const scheduleRefresh = () => refreshController.scheduleRefresh(); const refresh = () => void refreshController.refreshNow(); @@ -1089,11 +1223,24 @@ function activate(context) { const updateCommitInput = (session) => { sourceControl.inputBox.enabled = true; sourceControl.inputBox.visible = true; - sourceControl.inputBox.placeholder = session?.label - ? `Commit ${session.label} (Ctrl+Enter)` - : 'Pick an Active Agents session to commit its worktree.'; + sourceControl.inputBox.placeholder = sessionCommitPlaceholder(session); + }; + const updateStatusBar = () => { + const selectedSession = provider.getSelectedSession(); + const summary = provider.getViewSummary(); + if ((summary.sessionCount || 0) <= 0) { + activeAgentsStatusItem.hide(); + return; + } + + activeAgentsStatusItem.text = selectedSession?.branch + ? `$(git-branch) ${sessionIdentityLabel(selectedSession)} · ${formatCountLabel(selectedSession.lockCount || 0, 'lock')}` + : buildActiveAgentsStatusSummary(summary); + activeAgentsStatusItem.tooltip = buildActiveAgentsStatusTooltip(selectedSession, summary); + activeAgentsStatusItem.show(); }; updateCommitInput(null); + updateStatusBar(); const commitSelectedSession = async () => { const selectedSession = provider.getSelectedSession(); if (!selectedSession?.worktreePath) { @@ -1140,15 +1287,27 @@ function activate(context) { scheduleRefresh(); }; - provider.onDidChangeSelectedSession(updateCommitInput); + provider.onDidChangeSelectedSession((session) => { + updateCommitInput(session); + updateStatusBar(); + decorationProvider.refresh(); + }); + provider.onDidChangeTreeData(() => { + updateCommitInput(provider.getSelectedSession()); + updateStatusBar(); + }); context.subscriptions.push( treeView, sourceControl, + activeAgentsStatusItem, refreshController, vscode.window.registerFileDecorationProvider(decorationProvider), vscode.commands.registerCommand('gitguardex.activeAgents.startAgent', () => startAgentFromPrompt(refresh)), vscode.commands.registerCommand('gitguardex.activeAgents.refresh', refresh), + vscode.commands.registerCommand('gitguardex.activeAgents.focus', async () => { + await vscode.commands.executeCommand('workbench.view.scm'); + }), vscode.commands.registerCommand('gitguardex.activeAgents.commitSelectedSession', commitSelectedSession), vscode.commands.registerCommand('gitguardex.activeAgents.openWorktree', async (session) => { if (!session?.worktreePath) { diff --git a/test/vscode-active-agents-session-state.test.js b/test/vscode-active-agents-session-state.test.js index 7f14b3b..d4258e1 100644 --- a/test/vscode-active-agents-session-state.test.js +++ b/test/vscode-active-agents-session-state.test.js @@ -123,6 +123,7 @@ function createMockVscode(tempRoot) { providers: [], decorationProviders: [], treeViews: [], + statusBarItems: [], commands: new Map(), executedCommands: [], sourceControls: [], @@ -245,6 +246,10 @@ function createMockVscode(tempRoot) { None: 0, Expanded: 1, }, + StatusBarAlignment: { + Left: 1, + Right: 2, + }, commands: { executeCommand: async (command, ...args) => { registrations.executedCommands.push({ command, args }); @@ -364,6 +369,26 @@ function createMockVscode(tempRoot) { registrations.providers.push({ viewId, provider: options.treeDataProvider }); return treeView; }, + createStatusBarItem: (alignment, priority) => { + const statusBarItem = { + alignment, + priority, + text: '', + tooltip: '', + command: undefined, + name: undefined, + visible: false, + show() { + this.visible = true; + }, + hide() { + this.visible = false; + }, + dispose() {}, + }; + registrations.statusBarItems.push(statusBarItem); + return statusBarItem; + }, registerFileDecorationProvider: (provider) => { registrations.decorationProviders.push(provider); return disposable(); @@ -731,8 +756,12 @@ test('active-agents extension registers tree and decoration providers', async () assert.equal(registrations.treeViews.length, 1); assert.equal(registrations.sourceControls.length, 1); + assert.equal(registrations.statusBarItems.length, 1); assert.equal(registrations.treeViews[0].viewId, 'gitguardex.activeAgents'); assert.equal(registrations.sourceControls[0].label, 'Active Agents Commit'); + assert.equal(registrations.statusBarItems[0].name, 'GitGuardex Active Agents'); + assert.equal(registrations.statusBarItems[0].command, 'gitguardex.activeAgents.focus'); + assert.equal(registrations.statusBarItems[0].visible, false); assert.equal( registrations.sourceControls[0].inputBox.placeholder, 'Pick an Active Agents session to commit its worktree.', @@ -1587,7 +1616,7 @@ test('active-agents extension commits the selected session worktree from the SCM assert.equal( registrations.sourceControls[0].inputBox.placeholder, - `Commit ${sessionItem.session.label} (Ctrl+Enter)`, + `Commit ${sessionItem.session.agentName} · ${sessionItem.session.taskName} on ${sessionItem.session.branch} · 0 locks (Ctrl+Enter)`, ); registrations.sourceControls[0].inputBox.value = 'Ship the selected sandbox';