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
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-23
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# agent-codex-dismiss-stale-active-session-2026-04-23-18-29 (minimal / T1)

Branch: `agent/codex/dismiss-stale-active-session-2026-04-23-18-29`

Describe the change in a sentence or two. Commit message is the spec of record.

## Handoff

- Handoff: change=`agent-codex-dismiss-stale-active-session-2026-04-23-18-29`; branch=`agent/codex/dismiss-stale-active-session-2026-04-23-18-29`; scope=`Active Agents dismiss action for stalled/dead rows, template parity, manifest bump, focused extension tests`; action=`continue this sandbox, add a separate Dismiss action that removes stale active-session records without reusing Stop, then verify and finish cleanup after the earlier usage-limit takeover`.
- Copy prompt: Continue `agent-codex-dismiss-stale-active-session-2026-04-23-18-29` on branch `agent/codex/dismiss-stale-active-session-2026-04-23-18-29`. Work inside the existing sandbox, review `openspec/changes/agent-codex-dismiss-stale-active-session-2026-04-23-18-29/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/dismiss-stale-active-session-2026-04-23-18-29 --base main --via-pr --wait-for-merge --cleanup`.
- Result: added a separate `Dismiss` action for `stalled`/`dead` Active Agents rows, deleting the matching `.omx/state/active-sessions/*.json` record without reusing the live `Stop` flow; verified with `node --test test/vscode-active-agents-session-state.test.js` (`54/54`).

## Cleanup

- [ ] Run: `gx branch finish --branch agent/codex/dismiss-stale-active-session-2026-04-23-18-29 --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`).
80 changes: 79 additions & 1 deletion templates/vscode/guardex-active-agents/extension.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ const path = require('node:path');
const cp = require('node:child_process');
const vscode = require('vscode');
const {
clearWorktreeActivityCache,
formatElapsedFrom,
readActiveSessions,
readRepoChanges,
readSessionInspectData,
sanitizeBranchForFile,
sessionFilePathForBranch,
} = require('./session-schema.js');

const SESSION_DECORATION_SCHEME = 'gitguardex-agent';
Expand Down Expand Up @@ -65,6 +67,7 @@ const SESSION_ACTIVITY_ICON_IDS = {
stalled: 'clock',
dead: 'error',
};
const DISMISSABLE_SESSION_ACTIVITY_KINDS = new Set(['stalled', 'dead']);
const SESSION_PROVIDER_BRANDS = {
openai: {
id: 'openai',
Expand Down Expand Up @@ -1289,7 +1292,7 @@ class SessionItem extends vscode.TreeItem {
: buildSessionCardDescription(session);
this.tooltip = buildSessionTooltip(session, this.description);
this.iconPath = themeIcon(resolveSessionActivityIconId(session.activityKind));
this.contextValue = 'gitguardex.session';
this.contextValue = sessionContextValue(session);
this.command = {
command: 'gitguardex.activeAgents.openWorktree',
title: 'Open Agent Worktree',
Expand All @@ -1298,6 +1301,35 @@ class SessionItem extends vscode.TreeItem {
}
}

function sessionContextValue(session) {
const activityKind = typeof session?.activityKind === 'string' ? session.activityKind.trim() : '';
return activityKind
? `gitguardex.session.${activityKind}`
: 'gitguardex.session';
}

function canDismissSession(session) {
return DISMISSABLE_SESSION_ACTIVITY_KINDS.has(session?.activityKind);
}

function buildDismissSessionDetail(session, statePath) {
const repoRoot = typeof session?.repoRoot === 'string' ? session.repoRoot.trim() : '';
const relativeStatePath = repoRoot
? path.relative(repoRoot, statePath) || path.basename(statePath)
: path.basename(statePath);
const detailParts = [
`Remove ${relativeStatePath} and hide this session from Active Agents.`,
];

if (session?.activityKind === 'stalled') {
detailParts.push('This dismisses the stale sidebar row only; use Stop if you want to interrupt a live agent.');
} else {
detailParts.push('This clears the stale session record from the sidebar.');
}

return detailParts.join(' ');
}

class FolderItem extends vscode.TreeItem {
constructor(label, relativePath, items, options = {}) {
super(
Expand Down Expand Up @@ -1845,6 +1877,51 @@ async function stopSession(session, refresh) {
}
}

async function dismissSession(session, refresh) {
if (!canDismissSession(session)) {
showSessionMessage('Only stalled or dead sessions can be dismissed.');
return;
}

const repoRoot = typeof session?.repoRoot === 'string' ? session.repoRoot.trim() : '';
if (!repoRoot) {
showSessionMessage('Cannot dismiss session: missing repo root.');
return;
}
if (!session?.branch) {
showSessionMessage('Cannot dismiss session: missing branch name.');
return;
}

const statePath = sessionFilePathForBranch(repoRoot, session.branch);
if (!fs.existsSync(statePath)) {
clearWorktreeActivityCache(session.worktreePath);
refresh();
showSessionMessage(`Session record already gone for ${sessionDisplayLabel(session)}.`);
return;
}

const confirmed = await vscode.window.showWarningMessage(
`Dismiss ${sessionDisplayLabel(session)}?`,
{
modal: true,
detail: buildDismissSessionDetail(session, statePath),
},
'Dismiss',
);
if (confirmed !== 'Dismiss') {
return;
}

try {
fs.unlinkSync(statePath);
clearWorktreeActivityCache(session.worktreePath);
refresh();
} catch (error) {
showSessionMessage(`Failed to dismiss session ${sessionDisplayLabel(session)}: ${error.message}`);
}
}

function readGitDirPath(targetPath) {
const normalizedTargetPath = typeof targetPath === 'string' ? targetPath.trim() : '';
if (!normalizedTargetPath) {
Expand Down Expand Up @@ -3358,6 +3435,7 @@ function activate(context) {
vscode.commands.registerCommand('gitguardex.activeAgents.finishSession', finishSession),
vscode.commands.registerCommand('gitguardex.activeAgents.syncSession', syncSession),
vscode.commands.registerCommand('gitguardex.activeAgents.stopSession', (session) => stopSession(session, refresh)),
vscode.commands.registerCommand('gitguardex.activeAgents.dismissSession', (session) => dismissSession(session, refresh)),
vscode.workspace.onDidChangeWorkspaceFolders(handleWorkspaceFoldersChanged),
activeSessionsWatcher,
lockWatcher,
Expand Down
24 changes: 17 additions & 7 deletions templates/vscode/guardex-active-agents/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"displayName": "GitGuardex Active Agents",
"description": "Shows live Guardex sandbox sessions and repo changes in a dedicated VS Code Active Agents sidebar.",
"publisher": "recodeee",
"version": "0.0.17",
"version": "0.0.18",
"license": "MIT",
"icon": "icon.png",
"engines": {
Expand Down Expand Up @@ -65,6 +65,11 @@
"title": "Stop",
"icon": "$(debug-stop)"
},
{
"command": "gitguardex.activeAgents.dismissSession",
"title": "Dismiss",
"icon": "$(trash)"
},
{
"command": "gitguardex.activeAgents.showSessionTerminal",
"title": "Show Terminal",
Expand Down Expand Up @@ -125,32 +130,37 @@
"view/item/context": [
{
"command": "gitguardex.activeAgents.openWorktree",
"when": "view == gitguardex.activeAgents && viewItem == gitguardex.session",
"when": "view == gitguardex.activeAgents && viewItem =~ /^gitguardex\\.session(\\.|$)/",
"group": "inline"
},
{
"command": "gitguardex.activeAgents.inspect",
"when": "view == gitguardex.activeAgents && viewItem == gitguardex.session",
"when": "view == gitguardex.activeAgents && viewItem =~ /^gitguardex\\.session(\\.|$)/",
"group": "inline"
},
{
"command": "gitguardex.activeAgents.showSessionTerminal",
"when": "view == gitguardex.activeAgents && viewItem == gitguardex.session",
"when": "view == gitguardex.activeAgents && viewItem =~ /^gitguardex\\.session(\\.|$)/",
"group": "inline"
},
{
"command": "gitguardex.activeAgents.finishSession",
"when": "view == gitguardex.activeAgents && viewItem == gitguardex.session",
"when": "view == gitguardex.activeAgents && viewItem =~ /^gitguardex\\.session(\\.|$)/",
"group": "inline"
},
{
"command": "gitguardex.activeAgents.syncSession",
"when": "view == gitguardex.activeAgents && viewItem == gitguardex.session",
"when": "view == gitguardex.activeAgents && viewItem =~ /^gitguardex\\.session(\\.|$)/",
"group": "inline"
},
{
"command": "gitguardex.activeAgents.stopSession",
"when": "view == gitguardex.activeAgents && viewItem == gitguardex.session",
"when": "view == gitguardex.activeAgents && viewItem =~ /^gitguardex\\.session(\\.|$)/",
"group": "inline"
},
{
"command": "gitguardex.activeAgents.dismissSession",
"when": "view == gitguardex.activeAgents && viewItem =~ /^gitguardex\\.session\\.(stalled|dead)$/",
"group": "inline"
}
]
Expand Down
78 changes: 78 additions & 0 deletions test/vscode-active-agents-session-state.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1354,6 +1354,34 @@ test('active-agents manifest contributes restart actions for extension managemen
);
});

test('active-agents manifest contributes dismiss only for stalled and dead session rows', () => {
const manifest = readExtensionManifest();
const templateManifest = readExtensionManifest(templateExtensionManifestPath);

const dismissCommand = manifest.contributes.commands.find(
(entry) => entry.command === 'gitguardex.activeAgents.dismissSession',
);
assert.deepEqual(dismissCommand, {
command: 'gitguardex.activeAgents.dismissSession',
title: 'Dismiss',
icon: '$(trash)',
});

const dismissMenuAction = manifest.contributes.menus['view/item/context'].find(
(entry) => entry.command === 'gitguardex.activeAgents.dismissSession',
);
assert.deepEqual(dismissMenuAction, {
command: 'gitguardex.activeAgents.dismissSession',
when: 'view == gitguardex.activeAgents && viewItem =~ /^gitguardex\\.session\\.(stalled|dead)$/',
group: 'inline',
});

assert.deepEqual(
manifest.contributes.menus['view/item/context'],
templateManifest.contributes.menus['view/item/context'],
);
});

test('active-agents extension auto-installs a newer workspace build and offers reload', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-autoupdate-'));
const repoManifest = {
Expand Down Expand Up @@ -2836,12 +2864,15 @@ test('active-agents extension groups blocked, working, idle, stalled, and dead s
assert.equal(blockedItem.iconPath.id, 'warning');
assert.match(workingItem.description, /^Working: codex · via OpenAI · 1 changed file/);
assert.equal(workingItem.iconPath.id, 'loading~spin');
assert.equal(workingItem.contextValue, 'gitguardex.session.working');
assert.match(idleItem.description, /^Idle: codex · via OpenAI/);
assert.equal(idleItem.iconPath.id, 'comment-discussion');
assert.match(stalledItem.description, /^Stale: codex · via OpenAI/);
assert.equal(stalledItem.iconPath.id, 'clock');
assert.equal(stalledItem.contextValue, 'gitguardex.session.stalled');
assert.match(deadItem.description, /^Dead: codex · via OpenAI/);
assert.equal(deadItem.iconPath.id, 'error');
assert.equal(deadItem.contextValue, 'gitguardex.session.dead');
assert.deepEqual(registrations.treeViews[0].badge, {
value: 5,
tooltip: repoItem.description,
Expand Down Expand Up @@ -3515,6 +3546,53 @@ test('active-agents extension confirms stop and routes through gx agents stop --
}
});

test('active-agents extension dismisses stalled session rows by deleting the matching active-session record', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-dismiss-session-'));
const worktreePath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-dismiss-worktree-'));
const sessionPath = writeSessionRecord(tempRoot, sessionSchema.buildSessionRecord({
repoRoot: tempRoot,
branch: 'agent/codex/stalled-task',
taskName: 'stalled-task',
agentName: 'codex',
worktreePath,
pid: 4242,
cliName: 'codex',
}));
const { registrations, vscode } = createMockVscode(tempRoot);
const extension = loadExtensionWithMockVscode(vscode);
const context = { subscriptions: [] };

vscode.window.showWarningMessage = async (...args) => {
registrations.warningMessages.push(args);
return 'Dismiss';
};

extension.activate(context);
const provider = registrations.providers[0].provider;
await flushAsyncWork();
provider.onDidChangeTreeDataEmitter.fireCount = 0;

await registrations.commands.get('gitguardex.activeAgents.dismissSession')({
label: 'stalled-task',
branch: 'agent/codex/stalled-task',
activityKind: 'stalled',
repoRoot: tempRoot,
worktreePath,
});
await flushAsyncWork();

assert.equal(fs.existsSync(sessionPath), false);
assert.ok(registrations.providers[0].provider.onDidChangeTreeDataEmitter.fireCount >= 1);
assert.equal(registrations.warningMessages.length, 1);
assert.match(registrations.warningMessages[0][0], /Dismiss stalled-task\?/);
assert.match(registrations.warningMessages[0][1].detail, /\.omx[\/\\]state[\/\\]active-sessions/);
assert.match(registrations.warningMessages[0][1].detail, /stale sidebar row only/);

for (const subscription of context.subscriptions) {
subscription.dispose?.();
}
});

test('active-agents extension uses bundled OpenSpec icons in Active Agents tree nodes', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-openspec-icons-'));
initGitRepo(tempRoot);
Expand Down
Loading
Loading