Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
Original file line number Diff line number Diff line change
@@ -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`).
1 change: 1 addition & 0 deletions templates/vscode/guardex-active-agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
169 changes: 164 additions & 5 deletions templates/vscode/guardex-active-agents/extension.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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());
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
}
Expand All @@ -865,6 +985,10 @@ class ActiveAgentsProvider {
return this.selectedSession ? { ...this.selectedSession } : null;
}

getViewSummary() {
return { ...this.viewSummary };
}

syncSelectedSession(repoEntries) {
if (!this.selectedSession) {
return;
Expand All @@ -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'}`);
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -996,6 +1126,7 @@ class ActiveAgentsProvider {
changes: readRepoChanges(repoRoot).map((change) => (
decorateChange(change, lockRegistry, currentBranch)
)),
lockEntries: Array.from(lockRegistry.entriesByPath.entries()),
};
});
}
Expand Down Expand Up @@ -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();
Expand All @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
31 changes: 30 additions & 1 deletion test/vscode-active-agents-session-state.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ function createMockVscode(tempRoot) {
providers: [],
decorationProviders: [],
treeViews: [],
statusBarItems: [],
commands: new Map(),
executedCommands: [],
sourceControls: [],
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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.',
Expand Down Expand Up @@ -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';

Expand Down