diff --git a/openspec/changes/agent-codex-dismiss-stale-active-session-2026-04-23-18-29/.openspec.yaml b/openspec/changes/agent-codex-dismiss-stale-active-session-2026-04-23-18-29/.openspec.yaml new file mode 100644 index 0000000..8b394c6 --- /dev/null +++ b/openspec/changes/agent-codex-dismiss-stale-active-session-2026-04-23-18-29/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-23 diff --git a/openspec/changes/agent-codex-dismiss-stale-active-session-2026-04-23-18-29/notes.md b/openspec/changes/agent-codex-dismiss-stale-active-session-2026-04-23-18-29/notes.md new file mode 100644 index 0000000..8c42ab0 --- /dev/null +++ b/openspec/changes/agent-codex-dismiss-stale-active-session-2026-04-23-18-29/notes.md @@ -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`). diff --git a/templates/vscode/guardex-active-agents/extension.js b/templates/vscode/guardex-active-agents/extension.js index 3c4f07d..6546080 100644 --- a/templates/vscode/guardex-active-agents/extension.js +++ b/templates/vscode/guardex-active-agents/extension.js @@ -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'; @@ -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', @@ -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', @@ -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( @@ -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) { @@ -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, diff --git a/templates/vscode/guardex-active-agents/package.json b/templates/vscode/guardex-active-agents/package.json index 9b2aeb6..39bf80f 100644 --- a/templates/vscode/guardex-active-agents/package.json +++ b/templates/vscode/guardex-active-agents/package.json @@ -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": { @@ -65,6 +65,11 @@ "title": "Stop", "icon": "$(debug-stop)" }, + { + "command": "gitguardex.activeAgents.dismissSession", + "title": "Dismiss", + "icon": "$(trash)" + }, { "command": "gitguardex.activeAgents.showSessionTerminal", "title": "Show Terminal", @@ -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" } ] diff --git a/test/vscode-active-agents-session-state.test.js b/test/vscode-active-agents-session-state.test.js index 2b0e2b3..c1340b9 100644 --- a/test/vscode-active-agents-session-state.test.js +++ b/test/vscode-active-agents-session-state.test.js @@ -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 = { @@ -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, @@ -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); diff --git a/vscode/guardex-active-agents/extension.js b/vscode/guardex-active-agents/extension.js index 3c4f07d..6546080 100644 --- a/vscode/guardex-active-agents/extension.js +++ b/vscode/guardex-active-agents/extension.js @@ -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'; @@ -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', @@ -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', @@ -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( @@ -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) { @@ -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, diff --git a/vscode/guardex-active-agents/package.json b/vscode/guardex-active-agents/package.json index 9b2aeb6..39bf80f 100644 --- a/vscode/guardex-active-agents/package.json +++ b/vscode/guardex-active-agents/package.json @@ -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": { @@ -65,6 +65,11 @@ "title": "Stop", "icon": "$(debug-stop)" }, + { + "command": "gitguardex.activeAgents.dismissSession", + "title": "Dismiss", + "icon": "$(trash)" + }, { "command": "gitguardex.activeAgents.showSessionTerminal", "title": "Show Terminal", @@ -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" } ]