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-22
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
## Why

- Active Agents already lets the user open a worktree, diff changes, and finish or stop a session, but there is still no single scan-first surface inside VS Code for "why is this branch stuck?"
- The missing inspect surface forces the user back to the terminal to piece together branch divergence, held locks, and agent logs for the selected session.

## What Changes

- Add `gitguardex.activeAgents.inspect` to the Active Agents companion manifest and expose it as a session-scoped inline action.
- Add inspect-data helpers in `session-schema.js` for configured base-branch lookup, ahead/behind counts, session log path + tail, and held-lock extraction.
- Add a single webview inspect panel in `extension.js` that renders the selected session and refreshes through the same debounced watcher path used by the tree view, including `.omx/logs/*.log`.
- Mirror the runtime changes into `templates/vscode/guardex-active-agents/*` and cover the inspect flow in `test/vscode-active-agents-session-state.test.js`.

## Impact

- Affected surfaces: `vscode/guardex-active-agents/*`, `templates/vscode/guardex-active-agents/*`, and `test/vscode-active-agents-session-state.test.js`.
- Risks: the inspect panel touches both manifest and refresh/watcher plumbing, so the live and template copies must stay in sync and the focused extension test must remain green.
- Rollout note: the extension manifest version must increase because shipped extension files change.
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
## ADDED Requirements

### Requirement: Active Agents exposes a session inspect panel
The Active Agents companion SHALL expose a session-scoped inspect surface for the selected sandbox session.

#### Scenario: Inspect selected session details
- **WHEN** the user runs `gitguardex.activeAgents.inspect` for a session row
- **THEN** the extension opens an inspect panel for that session
- **AND** the panel shows the configured base branch, ahead/behind counts vs `origin/<base>`, held locks, and the agent log tail when available.

### Requirement: Inspect data comes from the same watcher-driven refresh loop
The inspect panel SHALL refresh from the same debounced watcher cycle used by the Active Agents tree.

#### Scenario: Log or session state changes while inspect is open
- **WHEN** active-session files, lock files, managed worktree locks, session git indexes, or `.omx/logs/*.log` change
- **THEN** the existing debounced refresh loop updates the Active Agents tree
- **AND** any open inspect panel re-renders the same session from refreshed data without a separate polling loop.
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
## 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.

## Handoff

- Handoff: change=`agent-codex-inspect-active-agent-session-2026-04-22-17-45`; branch=`agent/codex/inspect-active-agent-session-2026-04-22-17-45`; scope=`vscode/guardex-active-agents/{extension.js,session-schema.js,package.json}`, `templates/vscode/guardex-active-agents/{extension.js,session-schema.js,package.json}`, `test/vscode-active-agents-session-state.test.js`, `openspec/changes/agent-codex-inspect-active-agent-session-2026-04-22-17-45/*`; action=`add the Active Agents inspect panel and keep it on the existing watcher-driven refresh path`.
- Copy prompt: Continue `agent-codex-inspect-active-agent-session-2026-04-22-17-45` on branch `agent/codex/inspect-active-agent-session-2026-04-22-17-45`. Work inside the existing sandbox, review `openspec/changes/agent-codex-inspect-active-agent-session-2026-04-22-17-45/tasks.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/inspect-active-agent-session-2026-04-22-17-45 --base main --via-pr --wait-for-merge --cleanup`.

## 1. Specification

- [x] 1.1 Finalize proposal scope and acceptance criteria for `agent-codex-inspect-active-agent-session-2026-04-22-17-45`.
- [x] 1.2 Define normative requirements in `specs/vscode-active-agents-inspect-panel/spec.md`.

## 2. Implementation

- [x] 2.1 Add `gitguardex.activeAgents.inspect`, the inspect webview manager, and the `.omx/logs/*.log` watcher path in both the runtime and template extension bundles.
- [x] 2.2 Add `session-schema.js` helpers for base-branch lookup, ahead/behind counts, log tail reading, and held-lock extraction.
- [x] 2.3 Add/update focused regression coverage for inspect rendering and watcher-driven refresh.

## 3. Verification

- [x] 3.1 Run `node --test test/vscode-active-agents-session-state.test.js`.
- [x] 3.2 Run `openspec validate agent-codex-inspect-active-agent-session-2026-04-22-17-45 --type change --strict`.
- [x] 3.3 Run `openspec validate --specs`.

## 4. Cleanup (mandatory; run before claiming completion)

- [ ] 4.1 Run the cleanup pipeline: `gx branch finish --branch agent/codex/inspect-active-agent-session-2026-04-22-17-45 --base main --via-pr --wait-for-merge --cleanup`. This handles commit -> push -> PR create -> merge wait -> worktree prune in one invocation.
- [ ] 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).
221 changes: 219 additions & 2 deletions templates/vscode/guardex-active-agents/extension.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const {
formatElapsedFrom,
readActiveSessions,
readRepoChanges,
readSessionInspectData,
sanitizeBranchForFile,
} = require('./session-schema.js');

Expand All @@ -16,6 +17,7 @@ const LOCK_FILE_RELATIVE = path.join('.omx', 'state', 'agent-file-locks.json');
const ACTIVE_SESSION_FILES_GLOB = '**/.omx/state/active-sessions/*.json';
const AGENT_FILE_LOCKS_GLOB = '**/.omx/state/agent-file-locks.json';
const WORKTREE_AGENT_LOCKS_GLOB = '**/{.omx,.omc}/agent-worktrees/**/AGENT.lock';
const AGENT_LOG_FILES_GLOB = '**/.omx/logs/*.log';
const SESSION_SCAN_EXCLUDE_GLOB = '**/{node_modules,.git,.omx/agent-worktrees,.omc/agent-worktrees}/**';
const WORKTREE_LOCK_SCAN_EXCLUDE_GLOB = '**/{node_modules,.git}/**';
const SESSION_SCAN_LIMIT = 200;
Expand All @@ -25,6 +27,7 @@ const ACTIVE_AGENTS_INSTALL_SCRIPT_RELATIVE = path.join('scripts', 'install-vsco
const RELOAD_WINDOW_ACTION = 'Reload Window';
const UPDATE_LATER_ACTION = 'Later';
const REFRESH_POLL_INTERVAL_MS = 30_000;
const INSPECT_PANEL_VIEW_TYPE = 'gitguardex.activeAgents.inspect';
const SESSION_ACTIVITY_GROUPS = [
{ kind: 'blocked', label: 'BLOCKED' },
{ kind: 'working', label: 'WORKING NOW' },
Expand Down Expand Up @@ -169,6 +172,134 @@ function buildActiveAgentsStatusTooltip(selectedSession, summary) {
].filter(Boolean).join('\n');
}

function escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

function formatInspectBranchSummary(inspectData) {
if (Number.isInteger(inspectData?.aheadCount) && Number.isInteger(inspectData?.behindCount)) {
return `${inspectData.aheadCount} ahead · ${inspectData.behindCount} behind vs ${inspectData.compareRef}`;
}
return `Branch comparison unavailable vs ${inspectData?.compareRef || 'origin/dev'}`;
}

function inspectPanelTitle(session) {
return `Inspect ${sessionDisplayLabel(session)}`;
}

function renderInspectPanelHtml(session, inspectData) {
const heldLocksMarkup = Array.isArray(inspectData?.heldLocks) && inspectData.heldLocks.length > 0
? `<ul>${inspectData.heldLocks.map((entry) => (
`<li><code>${escapeHtml(entry.relativePath)}</code>${entry.allowDelete ? ' <span class="pill">delete ok</span>' : ''}${entry.claimedAt ? ` <span class="muted">${escapeHtml(entry.claimedAt)}</span>` : ''}</li>`
)).join('')}</ul>`
: '<p class="muted">No held locks recorded for this session.</p>';
const logContent = inspectData?.logTailText
? escapeHtml(inspectData.logTailText)
: 'No log output available.';

return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
:root {
color-scheme: light dark;
font-family: var(--vscode-font-family);
}
body {
padding: 16px;
color: var(--vscode-foreground);
background: var(--vscode-editor-background);
}
h1, h2 {
margin: 0 0 12px;
font-weight: 600;
}
h2 {
margin-top: 20px;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--vscode-descriptionForeground);
}
.grid {
display: grid;
grid-template-columns: minmax(140px, 220px) 1fr;
gap: 8px 12px;
margin: 0;
}
dt {
color: var(--vscode-descriptionForeground);
}
dd {
margin: 0;
word-break: break-word;
}
code, pre {
font-family: var(--vscode-editor-font-family, monospace);
font-size: 12px;
}
pre {
margin: 0;
padding: 12px;
border-radius: 8px;
overflow: auto;
background: var(--vscode-textCodeBlock-background, rgba(127, 127, 127, 0.12));
border: 1px solid var(--vscode-editorWidget-border, transparent);
white-space: pre-wrap;
word-break: break-word;
}
ul {
margin: 0;
padding-left: 20px;
}
li + li {
margin-top: 6px;
}
.muted {
color: var(--vscode-descriptionForeground);
}
.pill {
display: inline-block;
margin-left: 6px;
padding: 1px 6px;
border-radius: 999px;
background: var(--vscode-badge-background);
color: var(--vscode-badge-foreground);
font-size: 11px;
}
</style>
</head>
<body>
<h1>${escapeHtml(sessionIdentityLabel(session))}</h1>
<dl class="grid">
<dt>Branch</dt>
<dd><code>${escapeHtml(session.branch)}</code></dd>
<dt>Worktree</dt>
<dd><code>${escapeHtml(session.worktreePath)}</code></dd>
<dt>Base branch</dt>
<dd><code>${escapeHtml(inspectData?.baseBranch || 'dev')}</code></dd>
<dt>Divergence</dt>
<dd>${escapeHtml(formatInspectBranchSummary(inspectData))}</dd>
<dt>Held locks</dt>
<dd>${Array.isArray(inspectData?.heldLocks) ? inspectData.heldLocks.length : 0}</dd>
<dt>Log file</dt>
<dd><code>${escapeHtml(inspectData?.logPath || 'Unavailable')}</code></dd>
</dl>
<h2>Held Locks</h2>
${heldLocksMarkup}
<h2>Agent Log Tail</h2>
<pre>${logContent}</pre>
</body>
</html>`;
}

class SessionDecorationProvider {
constructor(nowProvider = () => Date.now()) {
this.nowProvider = nowProvider;
Expand Down Expand Up @@ -1398,9 +1529,86 @@ function countEntryConflicts(entry) {
return sessionConflicts + changeConflicts;
}

class SessionInspectPanelManager {
constructor() {
this.panel = null;
this.session = null;
}

open(session) {
const targetSession = session?.branch ? { ...session } : null;
if (!targetSession?.repoRoot || !targetSession?.branch) {
showSessionMessage('Pick an Active Agents session first.');
return;
}
if (!vscode.window.createWebviewPanel) {
showSessionMessage('Inspect panel is unavailable in this VS Code build.');
return;
}

this.session = targetSession;
if (!this.panel) {
this.panel = vscode.window.createWebviewPanel(
INSPECT_PANEL_VIEW_TYPE,
inspectPanelTitle(targetSession),
vscode.ViewColumn?.Beside,
{
enableFindWidget: true,
enableScripts: false,
retainContextWhenHidden: true,
},
);
this.panel.onDidDispose(() => {
this.panel = null;
this.session = null;
});
} else {
this.panel.reveal?.(vscode.ViewColumn?.Beside);
}

this.render();
}

resolveSession() {
if (!this.session?.repoRoot || !this.session?.branch) {
return this.session ? { ...this.session } : null;
}

return readActiveSessions(this.session.repoRoot, { includeStale: true })
.find((entry) => sessionSelectionKey(entry) === sessionSelectionKey(this.session))
|| { ...this.session };
}

render() {
if (!this.panel || !this.session) {
return;
}

const session = this.resolveSession();
if (!session) {
return;
}

this.session = { ...session };
this.panel.title = inspectPanelTitle(session);
this.panel.webview.html = renderInspectPanelHtml(session, readSessionInspectData(session));
}

refresh() {
this.render();
}

dispose() {
this.panel?.dispose();
this.panel = null;
this.session = null;
}
}

class ActiveAgentsRefreshController {
constructor(provider) {
constructor(provider, inspectPanelManager = null) {
this.provider = provider;
this.inspectPanelManager = inspectPanelManager;
this.refreshTimer = null;
this.sessionWatchers = new Map();
}
Expand All @@ -1418,6 +1626,7 @@ class ActiveAgentsRefreshController {
async refreshNow() {
await this.syncSessionWatchers();
await this.provider.refresh();
this.inspectPanelManager?.refresh();
}

async syncSessionWatchers() {
Expand Down Expand Up @@ -1468,7 +1677,8 @@ class ActiveAgentsRefreshController {
function activate(context) {
const decorationProvider = new SessionDecorationProvider();
const provider = new ActiveAgentsProvider(decorationProvider);
const refreshController = new ActiveAgentsRefreshController(provider);
const inspectPanelManager = new SessionInspectPanelManager();
const refreshController = new ActiveAgentsRefreshController(provider, inspectPanelManager);
const treeView = vscode.window.createTreeView('gitguardex.activeAgents', {
treeDataProvider: provider,
showCollapseAll: true,
Expand All @@ -1486,6 +1696,7 @@ function activate(context) {
const activeSessionsWatcher = vscode.workspace.createFileSystemWatcher(ACTIVE_SESSION_FILES_GLOB);
const lockWatcher = vscode.workspace.createFileSystemWatcher(AGENT_FILE_LOCKS_GLOB);
const worktreeLockWatcher = vscode.workspace.createFileSystemWatcher(WORKTREE_AGENT_LOCKS_GLOB);
const logWatcher = vscode.workspace.createFileSystemWatcher(AGENT_LOG_FILES_GLOB);
const updateCommitInput = (session) => {
sourceControl.inputBox.enabled = true;
sourceControl.inputBox.visible = true;
Expand Down Expand Up @@ -1567,6 +1778,7 @@ function activate(context) {
treeView,
sourceControl,
activeAgentsStatusItem,
inspectPanelManager,
refreshController,
vscode.window.registerFileDecorationProvider(decorationProvider),
vscode.commands.registerCommand('gitguardex.activeAgents.startAgent', () => startAgentFromPrompt(refresh)),
Expand Down Expand Up @@ -1598,6 +1810,9 @@ function activate(context) {

await vscode.commands.executeCommand('vscode.open', vscode.Uri.file(change.absolutePath));
}),
vscode.commands.registerCommand('gitguardex.activeAgents.inspect', (session) => {
inspectPanelManager.open(session || provider.getSelectedSession());
}),
vscode.commands.registerCommand('gitguardex.activeAgents.finishSession', finishSession),
vscode.commands.registerCommand('gitguardex.activeAgents.syncSession', syncSession),
vscode.commands.registerCommand('gitguardex.activeAgents.stopSession', (session) => stopSession(session, refresh)),
Expand All @@ -1606,13 +1821,15 @@ function activate(context) {
activeSessionsWatcher,
lockWatcher,
worktreeLockWatcher,
logWatcher,
{ dispose: () => clearInterval(interval) },
);

context.subscriptions.push(
...bindRefreshWatcher(activeSessionsWatcher, scheduleRefresh),
...bindRefreshWatcher(lockWatcher, refreshLockRegistry),
...bindRefreshWatcher(worktreeLockWatcher, scheduleRefresh),
...bindRefreshWatcher(logWatcher, scheduleRefresh),
);
void refreshController.refreshNow();
void maybeAutoUpdateActiveAgentsExtension(context);
Expand Down
Loading