diff --git a/openspec/changes/agent-codex-inspect-active-agent-session-2026-04-22-17-45/.openspec.yaml b/openspec/changes/agent-codex-inspect-active-agent-session-2026-04-22-17-45/.openspec.yaml
new file mode 100644
index 0000000..25345f4
--- /dev/null
+++ b/openspec/changes/agent-codex-inspect-active-agent-session-2026-04-22-17-45/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-04-22
diff --git a/openspec/changes/agent-codex-inspect-active-agent-session-2026-04-22-17-45/proposal.md b/openspec/changes/agent-codex-inspect-active-agent-session-2026-04-22-17-45/proposal.md
new file mode 100644
index 0000000..ee50f7a
--- /dev/null
+++ b/openspec/changes/agent-codex-inspect-active-agent-session-2026-04-22-17-45/proposal.md
@@ -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.
diff --git a/openspec/changes/agent-codex-inspect-active-agent-session-2026-04-22-17-45/specs/vscode-active-agents-inspect-panel/spec.md b/openspec/changes/agent-codex-inspect-active-agent-session-2026-04-22-17-45/specs/vscode-active-agents-inspect-panel/spec.md
new file mode 100644
index 0000000..db33dfe
--- /dev/null
+++ b/openspec/changes/agent-codex-inspect-active-agent-session-2026-04-22-17-45/specs/vscode-active-agents-inspect-panel/spec.md
@@ -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/
${escapeHtml(entry.relativePath)}${entry.allowDelete ? ' delete ok' : ''}${entry.claimedAt ? ` ${escapeHtml(entry.claimedAt)}` : ''}No held locks recorded for this session.
'; + const logContent = inspectData?.logTailText + ? escapeHtml(inspectData.logTailText) + : 'No log output available.'; + + return ` + + + + + + + +${escapeHtml(session.branch)}${escapeHtml(session.worktreePath)}${escapeHtml(inspectData?.baseBranch || 'dev')}${escapeHtml(inspectData?.logPath || 'Unavailable')}${logContent}
+
+`;
+}
+
class SessionDecorationProvider {
constructor(nowProvider = () => Date.now()) {
this.nowProvider = nowProvider;
@@ -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();
}
@@ -1418,6 +1626,7 @@ class ActiveAgentsRefreshController {
async refreshNow() {
await this.syncSessionWatchers();
await this.provider.refresh();
+ this.inspectPanelManager?.refresh();
}
async syncSessionWatchers() {
@@ -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,
@@ -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;
@@ -1567,6 +1778,7 @@ function activate(context) {
treeView,
sourceControl,
activeAgentsStatusItem,
+ inspectPanelManager,
refreshController,
vscode.window.registerFileDecorationProvider(decorationProvider),
vscode.commands.registerCommand('gitguardex.activeAgents.startAgent', () => startAgentFromPrompt(refresh)),
@@ -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)),
@@ -1606,6 +1821,7 @@ function activate(context) {
activeSessionsWatcher,
lockWatcher,
worktreeLockWatcher,
+ logWatcher,
{ dispose: () => clearInterval(interval) },
);
@@ -1613,6 +1829,7 @@ function activate(context) {
...bindRefreshWatcher(activeSessionsWatcher, scheduleRefresh),
...bindRefreshWatcher(lockWatcher, refreshLockRegistry),
...bindRefreshWatcher(worktreeLockWatcher, scheduleRefresh),
+ ...bindRefreshWatcher(logWatcher, scheduleRefresh),
);
void refreshController.refreshNow();
void maybeAutoUpdateActiveAgentsExtension(context);
diff --git a/templates/vscode/guardex-active-agents/package.json b/templates/vscode/guardex-active-agents/package.json
index f50f4d9..6795d0b 100644
--- a/templates/vscode/guardex-active-agents/package.json
+++ b/templates/vscode/guardex-active-agents/package.json
@@ -36,6 +36,11 @@
"title": "Commit Selected Session",
"icon": "$(check)"
},
+ {
+ "command": "gitguardex.activeAgents.inspect",
+ "title": "Inspect Session",
+ "icon": "$(info)"
+ },
{
"command": "gitguardex.activeAgents.openWorktree",
"title": "Open Agent Worktree"
@@ -95,6 +100,11 @@
"when": "view == gitguardex.activeAgents && viewItem == gitguardex.session",
"group": "inline"
},
+ {
+ "command": "gitguardex.activeAgents.inspect",
+ "when": "view == gitguardex.activeAgents && viewItem == gitguardex.session",
+ "group": "inline"
+ },
{
"command": "gitguardex.activeAgents.finishSession",
"when": "view == gitguardex.activeAgents && viewItem == gitguardex.session",
diff --git a/templates/vscode/guardex-active-agents/session-schema.js b/templates/vscode/guardex-active-agents/session-schema.js
index 367585f..252ccfc 100644
--- a/templates/vscode/guardex-active-agents/session-schema.js
+++ b/templates/vscode/guardex-active-agents/session-schema.js
@@ -5,6 +5,7 @@ const cp = require('node:child_process');
const ACTIVE_SESSIONS_RELATIVE_DIR = path.join('.omx', 'state', 'active-sessions');
const SESSION_SCHEMA_VERSION = 1;
const LOCK_FILE_RELATIVE = path.join('.omx', 'state', 'agent-file-locks.json');
+const LOGS_RELATIVE_DIR = path.join('.omx', 'logs');
const AGENT_WORKTREE_LOCK_FILE = 'AGENT.lock';
const MANAGED_WORKTREE_ROOTS = [
path.join('.omx', 'agent-worktrees'),
@@ -18,6 +19,8 @@ const MANAGED_WORKTREE_FILTER_PREFIXES = MANAGED_WORKTREE_ROOTS
const IDLE_ACTIVITY_WINDOW_MS = 2 * 60 * 1000;
const STALLED_ACTIVITY_WINDOW_MS = 15 * 60 * 1000;
const HEARTBEAT_STALE_MS = 5 * 60 * 1000;
+const DEFAULT_BASE_BRANCH = 'dev';
+const DEFAULT_LOG_TAIL_LINE_COUNT = 200;
const ADVISORY_SESSION_STATES = new Set(['working', 'thinking', 'idle']);
const WORKTREE_ACTIVITY_CACHE_TTL_MS = 3_000;
const MAX_WORKTREE_ACTIVITY_STAT_PATHS = 200;
@@ -131,6 +134,138 @@ function readJsonFile(filePath) {
}
}
+function readConfiguredBaseBranch(repoRoot) {
+ const lines = runGitLines(path.resolve(repoRoot), ['config', '--get', 'multiagent.baseBranch']);
+ if (Array.isArray(lines) && typeof lines[0] === 'string' && lines[0].trim()) {
+ return lines[0].trim();
+ }
+ return DEFAULT_BASE_BRANCH;
+}
+
+function readAheadBehindCounts(worktreePath, branch, baseBranch) {
+ const normalizedWorktreePath = toNonEmptyString(worktreePath);
+ const normalizedBranch = toNonEmptyString(branch);
+ const normalizedBaseBranch = toNonEmptyString(baseBranch, DEFAULT_BASE_BRANCH);
+ const compareRef = `origin/${normalizedBaseBranch}`;
+
+ if (!normalizedWorktreePath || !normalizedBranch) {
+ return {
+ compareRef,
+ aheadCount: null,
+ behindCount: null,
+ };
+ }
+
+ const lines = runGitLines(normalizedWorktreePath, [
+ 'rev-list',
+ '--left-right',
+ '--count',
+ `${normalizedBranch}...${compareRef}`,
+ ]);
+ const match = Array.isArray(lines) && typeof lines[0] === 'string'
+ ? lines[0].trim().match(/^(\d+)\s+(\d+)$/)
+ : null;
+ if (!match) {
+ return {
+ compareRef,
+ aheadCount: null,
+ behindCount: null,
+ };
+ }
+
+ return {
+ compareRef,
+ aheadCount: Number.parseInt(match[1], 10),
+ behindCount: Number.parseInt(match[2], 10),
+ };
+}
+
+function sessionLogPath(repoRoot, branch) {
+ const normalizedRepoRoot = toNonEmptyString(repoRoot);
+ const normalizedBranch = toNonEmptyString(branch);
+ if (!normalizedRepoRoot || !normalizedBranch) {
+ return '';
+ }
+
+ return path.join(
+ path.resolve(normalizedRepoRoot),
+ LOGS_RELATIVE_DIR,
+ `agent-${sanitizeBranchForFile(normalizedBranch)}.log`,
+ );
+}
+
+function readLogTail(filePath, maxLines = DEFAULT_LOG_TAIL_LINE_COUNT) {
+ const normalizedFilePath = toNonEmptyString(filePath);
+ const normalizedMaxLines = toPositiveInteger(maxLines) || DEFAULT_LOG_TAIL_LINE_COUNT;
+ if (!normalizedFilePath || !fs.existsSync(normalizedFilePath)) {
+ return [];
+ }
+
+ try {
+ const lines = fs.readFileSync(normalizedFilePath, 'utf8').split(/\r?\n/);
+ while (lines.length > 0 && lines[lines.length - 1] === '') {
+ lines.pop();
+ }
+ return lines.slice(-normalizedMaxLines);
+ } catch (_error) {
+ return [];
+ }
+}
+
+function readSessionHeldLocks(repoRoot, branch) {
+ const normalizedRepoRoot = toNonEmptyString(repoRoot);
+ const normalizedBranch = toNonEmptyString(branch);
+ if (!normalizedRepoRoot || !normalizedBranch) {
+ return [];
+ }
+
+ const parsed = readJsonFile(path.join(path.resolve(normalizedRepoRoot), LOCK_FILE_RELATIVE));
+ const locks = parsed?.locks;
+ if (!locks || typeof locks !== 'object' || Array.isArray(locks)) {
+ return [];
+ }
+
+ return Object.entries(locks)
+ .map(([rawRelativePath, entry]) => {
+ if (!entry || typeof entry !== 'object') {
+ return null;
+ }
+
+ const relativePath = normalizeRelativePath(rawRelativePath);
+ const ownerBranch = toNonEmptyString(entry.branch);
+ if (!relativePath || ownerBranch !== normalizedBranch) {
+ return null;
+ }
+
+ return {
+ relativePath,
+ claimedAt: toNonEmptyString(entry.claimed_at),
+ allowDelete: Boolean(entry.allow_delete),
+ };
+ })
+ .filter(Boolean)
+ .sort((left, right) => left.relativePath.localeCompare(right.relativePath));
+}
+
+function readSessionInspectData(session, options = {}) {
+ const repoRoot = toNonEmptyString(session?.repoRoot);
+ const branch = toNonEmptyString(session?.branch);
+ const worktreePath = toNonEmptyString(session?.worktreePath);
+ const baseBranch = readConfiguredBaseBranch(repoRoot);
+ const logPath = sessionLogPath(repoRoot, branch);
+ const logTailLines = readLogTail(logPath, options.logLines);
+
+ return {
+ baseBranch,
+ logPath,
+ logExists: Boolean(logPath) && fs.existsSync(logPath),
+ logTailLines,
+ logTailText: logTailLines.join('\n'),
+ heldLocks: readSessionHeldLocks(repoRoot, branch),
+ ...readAheadBehindCounts(worktreePath, branch, baseBranch),
+ };
+}
+
function normalizeIsoString(value, fallback = '') {
const normalized = toNonEmptyString(value);
if (!normalized) {
@@ -964,7 +1099,13 @@ module.exports = {
readWorktreeLockSessions,
readRepoChanges,
deriveRepoChangeStatus,
+ readAheadBehindCounts,
+ readConfiguredBaseBranch,
+ readLogTail,
resolveWorktreeGitDir,
+ readSessionHeldLocks,
+ readSessionInspectData,
+ sessionLogPath,
sanitizeBranchForFile,
sessionFileNameForBranch,
sessionFilePathForBranch,
diff --git a/test/vscode-active-agents-session-state.test.js b/test/vscode-active-agents-session-state.test.js
index 6bb5bce..80ba921 100644
--- a/test/vscode-active-agents-session-state.test.js
+++ b/test/vscode-active-agents-session-state.test.js
@@ -228,6 +228,7 @@ function createMockVscode(tempRoot) {
informationMessages: [],
errorMessages: [],
warningMessages: [],
+ webviewPanels: [],
fileWatchers: [],
watchers: [],
workspaceFolderListeners: [],
@@ -341,6 +342,9 @@ function createMockVscode(tempRoot) {
Left: 1,
Right: 2,
},
+ ViewColumn: {
+ Beside: 2,
+ },
commands: {
executeCommand: async (command, ...args) => {
registrations.executedCommands.push({ command, args });
@@ -438,6 +442,43 @@ function createMockVscode(tempRoot) {
registrations.shownDocuments.push({ document, options });
return { document };
},
+ createWebviewPanel: (viewType, title, column, options) => {
+ const disposeListeners = [];
+ const panel = {
+ viewType,
+ title,
+ column,
+ options,
+ disposed: false,
+ revealCalls: [],
+ webview: {
+ html: '',
+ },
+ onDidDispose(listener) {
+ disposeListeners.push(listener);
+ return disposable(() => {
+ const index = disposeListeners.indexOf(listener);
+ if (index >= 0) {
+ disposeListeners.splice(index, 1);
+ }
+ });
+ },
+ reveal(nextColumn) {
+ panel.revealCalls.push(nextColumn);
+ },
+ dispose() {
+ if (panel.disposed) {
+ return;
+ }
+ panel.disposed = true;
+ for (const listener of [...disposeListeners]) {
+ listener();
+ }
+ },
+ };
+ registrations.webviewPanels.push(panel);
+ return panel;
+ },
createTreeView: (viewId, options) => {
const selectionListeners = [];
const treeView = {
@@ -977,6 +1018,75 @@ test('session-schema derives repo change rows from root git status', () => {
);
});
+test('session-schema reads inspect data from base-branch config, log tail, and held locks', () => {
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-active-session-inspect-'));
+ const remoteRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-active-session-inspect-remote-'));
+ const branch = 'agent/codex/inspect-task';
+
+ initGitRepo(tempRoot);
+ runGit(tempRoot, ['checkout', '-b', 'main']);
+ fs.writeFileSync(path.join(tempRoot, 'tracked.txt'), 'base\n', 'utf8');
+ runGit(tempRoot, ['add', 'tracked.txt']);
+ runGit(tempRoot, ['commit', '-m', 'baseline']);
+ runGit(remoteRoot, ['init', '--bare']);
+ runGit(tempRoot, ['remote', 'add', 'origin', remoteRoot]);
+ runGit(tempRoot, ['push', '-u', 'origin', 'main']);
+ runGit(tempRoot, ['config', 'multiagent.baseBranch', 'main']);
+ runGit(tempRoot, ['checkout', '-b', branch]);
+ fs.writeFileSync(path.join(tempRoot, 'tracked.txt'), 'base\ninspect\n', 'utf8');
+ runGit(tempRoot, ['add', 'tracked.txt']);
+ runGit(tempRoot, ['commit', '-m', 'inspect ahead commit']);
+
+ const logPath = path.join(
+ tempRoot,
+ '.omx',
+ 'logs',
+ `agent-${sessionSchema.sanitizeBranchForFile(branch)}.log`,
+ );
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
+ fs.writeFileSync(logPath, 'log line 1\nlog line 2\n', 'utf8');
+
+ const lockPath = path.join(tempRoot, '.omx', 'state', 'agent-file-locks.json');
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
+ fs.writeFileSync(lockPath, `${JSON.stringify({
+ locks: {
+ 'src/alpha.js': {
+ branch,
+ claimed_at: '2026-04-22T09:10:00.000Z',
+ allow_delete: false,
+ },
+ 'src/beta.js': {
+ branch,
+ claimed_at: '2026-04-22T09:11:00.000Z',
+ allow_delete: true,
+ },
+ 'src/foreign.js': {
+ branch: 'agent/codex/other-task',
+ claimed_at: '2026-04-22T09:12:00.000Z',
+ allow_delete: false,
+ },
+ },
+ }, null, 2)}\n`, 'utf8');
+
+ const inspectData = sessionSchema.readSessionInspectData({
+ repoRoot: tempRoot,
+ branch,
+ worktreePath: tempRoot,
+ });
+
+ assert.equal(inspectData.baseBranch, 'main');
+ assert.equal(inspectData.compareRef, 'origin/main');
+ assert.equal(inspectData.aheadCount, 1);
+ assert.equal(inspectData.behindCount, 0);
+ assert.equal(inspectData.logPath, logPath);
+ assert.equal(inspectData.logExists, true);
+ assert.match(inspectData.logTailText, /log line 2/);
+ assert.deepEqual(
+ inspectData.heldLocks.map((entry) => entry.relativePath),
+ ['src/alpha.js', 'src/beta.js'],
+ );
+});
+
test('install-vscode-active-agents-extension installs the current extension version and prunes older copies', () => {
const tempExtensionsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-ext-'));
const manifest = readExtensionManifest();
@@ -1127,13 +1237,14 @@ test('active-agents extension registers tree and decoration providers', async ()
assert.equal(registrations.providers.length, 1);
assert.equal(registrations.providers[0].viewId, 'gitguardex.activeAgents');
assert.equal(registrations.decorationProviders.length, 1);
- assert.equal(registrations.fileWatchers.length, 3);
+ assert.equal(registrations.fileWatchers.length, 4);
assert.deepEqual(
registrations.fileWatchers.map((watcher) => watcher.pattern),
[
'**/.omx/state/active-sessions/*.json',
'**/.omx/state/agent-file-locks.json',
'**/{.omx,.omc}/agent-worktrees/**/AGENT.lock',
+ '**/.omx/logs/*.log',
],
);
assert.equal(registrations.workspaceFolderListeners.length, 1);
@@ -1141,6 +1252,7 @@ test('active-agents extension registers tree and decoration providers', async ()
const provider = registrations.providers[0].provider;
assert.equal(typeof provider.getTreeItem, 'function');
assert.equal(typeof registrations.commands.get('gitguardex.activeAgents.startAgent'), 'function');
+ assert.equal(typeof registrations.commands.get('gitguardex.activeAgents.inspect'), 'function');
const rootItems = await provider.getChildren();
assert.equal(rootItems.length, 1);
@@ -1922,7 +2034,7 @@ test('active-agents extension groups blocked, working, idle, stalled, and dead s
}
});
-test('active-agents extension watches active sessions, lock files, and session git indexes', async () => {
+test('active-agents extension watches active sessions, lock files, logs, and session git indexes', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-watchers-'));
const worktreePath = path.join(tempRoot, 'sandbox');
initGitRepo(worktreePath);
@@ -1952,6 +2064,7 @@ test('active-agents extension watches active sessions, lock files, and session g
'**/.omx/state/active-sessions/*.json',
'**/.omx/state/agent-file-locks.json',
'**/{.omx,.omc}/agent-worktrees/**/AGENT.lock',
+ '**/.omx/logs/*.log',
path.join(worktreePath, '.git', 'index'),
],
);
@@ -1962,7 +2075,7 @@ test('active-agents extension watches active sessions, lock files, and session g
await new Promise((resolve) => setTimeout(resolve, 350));
await flushAsyncWork();
- assert.equal(registrations.fileWatchers[3].disposed, true);
+ assert.equal(registrations.fileWatchers[4].disposed, true);
for (const subscription of context.subscriptions) {
subscription.dispose?.();
@@ -1984,6 +2097,7 @@ test('active-agents extension debounces refresh events with a trailing 250ms tim
registrations.fileWatchers[0].fireChange({ fsPath: path.join(tempRoot, '.omx', 'state', 'active-sessions', 'a.json') });
registrations.fileWatchers[1].fireChange({ fsPath: path.join(tempRoot, '.omx', 'state', 'agent-file-locks.json') });
registrations.fileWatchers[2].fireChange({ fsPath: path.join(tempRoot, '.omx', 'agent-worktrees', 'agent__codex__a', 'AGENT.lock') });
+ registrations.fileWatchers[3].fireChange({ fsPath: path.join(tempRoot, '.omx', 'logs', 'agent-agent__codex__a.log') });
assert.equal(provider.onDidChangeTreeDataEmitter.fireCount, 0);
await new Promise((resolve) => setTimeout(resolve, 300));
@@ -2324,6 +2438,111 @@ test('active-agents extension launches finish and sync commands in session termi
}
});
+test('active-agents extension opens and refreshes the inspect panel from shared watcher events', async () => {
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-inspect-panel-'));
+ const remoteRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-inspect-remote-'));
+ const branch = 'agent/codex/inspect-task';
+
+ initGitRepo(tempRoot);
+ runGit(tempRoot, ['checkout', '-b', 'main']);
+ fs.writeFileSync(path.join(tempRoot, 'tracked.txt'), 'base\n', 'utf8');
+ runGit(tempRoot, ['add', 'tracked.txt']);
+ runGit(tempRoot, ['commit', '-m', 'baseline']);
+ runGit(remoteRoot, ['init', '--bare']);
+ runGit(tempRoot, ['remote', 'add', 'origin', remoteRoot]);
+ runGit(tempRoot, ['push', '-u', 'origin', 'main']);
+ runGit(tempRoot, ['config', 'multiagent.baseBranch', 'main']);
+ runGit(tempRoot, ['checkout', '-b', branch]);
+ fs.writeFileSync(path.join(tempRoot, 'tracked.txt'), 'base\ninspect\n', 'utf8');
+ runGit(tempRoot, ['add', 'tracked.txt']);
+ runGit(tempRoot, ['commit', '-m', 'inspect ahead commit']);
+
+ const sessionPath = sessionSchema.sessionFilePathForBranch(tempRoot, branch);
+ fs.mkdirSync(path.dirname(sessionPath), { recursive: true });
+ fs.writeFileSync(
+ sessionPath,
+ `${JSON.stringify(sessionSchema.buildSessionRecord({
+ repoRoot: tempRoot,
+ branch,
+ taskName: 'inspect-task',
+ agentName: 'codex',
+ worktreePath: tempRoot,
+ pid: process.pid,
+ cliName: 'codex',
+ }), null, 2)}\n`,
+ 'utf8',
+ );
+
+ const lockPath = path.join(tempRoot, '.omx', 'state', 'agent-file-locks.json');
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
+ fs.writeFileSync(lockPath, `${JSON.stringify({
+ locks: {
+ 'src/owned-file.txt': {
+ branch,
+ claimed_at: '2026-04-22T09:13:00.000Z',
+ allow_delete: false,
+ },
+ },
+ }, null, 2)}\n`, 'utf8');
+
+ const logPath = path.join(
+ tempRoot,
+ '.omx',
+ 'logs',
+ `agent-${sessionSchema.sanitizeBranchForFile(branch)}.log`,
+ );
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
+ fs.writeFileSync(logPath, 'log line 1\n', 'utf8');
+
+ const { registrations, vscode } = createMockVscode(tempRoot);
+ vscode.workspace.findFiles = async (pattern) => {
+ if (pattern === '**/.omx/state/active-sessions/*.json') {
+ return [{ fsPath: sessionPath }];
+ }
+ return [];
+ };
+ const extension = loadExtensionWithMockVscode(vscode);
+ const context = { subscriptions: [] };
+
+ extension.activate(context);
+ await flushAsyncWork();
+
+ const provider = registrations.providers[0].provider;
+ const [repoItem] = await provider.getChildren();
+ const [agentsSection] = await provider.getChildren(repoItem);
+ const [groupSection] = await provider.getChildren(agentsSection);
+ const [sessionItem] = await provider.getChildren(groupSection);
+
+ await registrations.commands.get('gitguardex.activeAgents.inspect')(sessionItem.session);
+
+ assert.equal(registrations.webviewPanels.length, 1);
+ const panel = registrations.webviewPanels[0];
+ assert.equal(panel.viewType, 'gitguardex.activeAgents.inspect');
+ assert.match(panel.title, /Inspect inspect-task/);
+ assert.match(panel.webview.html, /origin\/main/);
+ assert.match(panel.webview.html, /1 ahead/);
+ assert.match(panel.webview.html, /0 behind/);
+ assert.match(panel.webview.html, /src\/owned-file.txt/);
+ assert.match(panel.webview.html, /log line 1/);
+
+ fs.writeFileSync(logPath, 'log line 1\nlog line 2\n', 'utf8');
+ const logWatcher = registrations.watchers.find((watcher) => watcher.pattern === '**/.omx/logs/*.log');
+ assert.ok(logWatcher, 'expected log watcher registration');
+ logWatcher.fireChange({ fsPath: logPath });
+ await new Promise((resolve) => setTimeout(resolve, 300));
+ await flushAsyncWork();
+
+ assert.match(panel.webview.html, /log line 2/);
+
+ await registrations.commands.get('gitguardex.activeAgents.inspect')(sessionItem.session);
+ assert.equal(registrations.webviewPanels.length, 1);
+ assert.equal(panel.revealCalls.length, 1);
+
+ for (const subscription of context.subscriptions) {
+ subscription.dispose?.();
+ }
+});
+
test('active-agents extension confirms stop and routes through gx agents stop --pid', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-stop-session-'));
const worktreePath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-stop-worktree-'));
diff --git a/vscode/guardex-active-agents/extension.js b/vscode/guardex-active-agents/extension.js
index a527385..df11917 100644
--- a/vscode/guardex-active-agents/extension.js
+++ b/vscode/guardex-active-agents/extension.js
@@ -6,6 +6,7 @@ const {
formatElapsedFrom,
readActiveSessions,
readRepoChanges,
+ readSessionInspectData,
sanitizeBranchForFile,
} = require('./session-schema.js');
@@ -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;
@@ -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' },
@@ -169,6 +172,134 @@ function buildActiveAgentsStatusTooltip(selectedSession, summary) {
].filter(Boolean).join('\n');
}
+function escapeHtml(value) {
+ return String(value || '')
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+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
+ ? `${escapeHtml(entry.relativePath)}${entry.allowDelete ? ' delete ok' : ''}${entry.claimedAt ? ` ${escapeHtml(entry.claimedAt)}` : ''}No held locks recorded for this session.
'; + const logContent = inspectData?.logTailText + ? escapeHtml(inspectData.logTailText) + : 'No log output available.'; + + return ` + + + + + + + +${escapeHtml(session.branch)}${escapeHtml(session.worktreePath)}${escapeHtml(inspectData?.baseBranch || 'dev')}${escapeHtml(inspectData?.logPath || 'Unavailable')}${logContent}
+
+`;
+}
+
class SessionDecorationProvider {
constructor(nowProvider = () => Date.now()) {
this.nowProvider = nowProvider;
@@ -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();
}
@@ -1418,6 +1626,7 @@ class ActiveAgentsRefreshController {
async refreshNow() {
await this.syncSessionWatchers();
await this.provider.refresh();
+ this.inspectPanelManager?.refresh();
}
async syncSessionWatchers() {
@@ -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,
@@ -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;
@@ -1567,6 +1778,7 @@ function activate(context) {
treeView,
sourceControl,
activeAgentsStatusItem,
+ inspectPanelManager,
refreshController,
vscode.window.registerFileDecorationProvider(decorationProvider),
vscode.commands.registerCommand('gitguardex.activeAgents.startAgent', () => startAgentFromPrompt(refresh)),
@@ -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)),
@@ -1606,6 +1821,7 @@ function activate(context) {
activeSessionsWatcher,
lockWatcher,
worktreeLockWatcher,
+ logWatcher,
{ dispose: () => clearInterval(interval) },
);
@@ -1613,6 +1829,7 @@ function activate(context) {
...bindRefreshWatcher(activeSessionsWatcher, scheduleRefresh),
...bindRefreshWatcher(lockWatcher, refreshLockRegistry),
...bindRefreshWatcher(worktreeLockWatcher, scheduleRefresh),
+ ...bindRefreshWatcher(logWatcher, scheduleRefresh),
);
void refreshController.refreshNow();
void maybeAutoUpdateActiveAgentsExtension(context);
diff --git a/vscode/guardex-active-agents/package.json b/vscode/guardex-active-agents/package.json
index f50f4d9..6795d0b 100644
--- a/vscode/guardex-active-agents/package.json
+++ b/vscode/guardex-active-agents/package.json
@@ -36,6 +36,11 @@
"title": "Commit Selected Session",
"icon": "$(check)"
},
+ {
+ "command": "gitguardex.activeAgents.inspect",
+ "title": "Inspect Session",
+ "icon": "$(info)"
+ },
{
"command": "gitguardex.activeAgents.openWorktree",
"title": "Open Agent Worktree"
@@ -95,6 +100,11 @@
"when": "view == gitguardex.activeAgents && viewItem == gitguardex.session",
"group": "inline"
},
+ {
+ "command": "gitguardex.activeAgents.inspect",
+ "when": "view == gitguardex.activeAgents && viewItem == gitguardex.session",
+ "group": "inline"
+ },
{
"command": "gitguardex.activeAgents.finishSession",
"when": "view == gitguardex.activeAgents && viewItem == gitguardex.session",
diff --git a/vscode/guardex-active-agents/session-schema.js b/vscode/guardex-active-agents/session-schema.js
index 367585f..252ccfc 100644
--- a/vscode/guardex-active-agents/session-schema.js
+++ b/vscode/guardex-active-agents/session-schema.js
@@ -5,6 +5,7 @@ const cp = require('node:child_process');
const ACTIVE_SESSIONS_RELATIVE_DIR = path.join('.omx', 'state', 'active-sessions');
const SESSION_SCHEMA_VERSION = 1;
const LOCK_FILE_RELATIVE = path.join('.omx', 'state', 'agent-file-locks.json');
+const LOGS_RELATIVE_DIR = path.join('.omx', 'logs');
const AGENT_WORKTREE_LOCK_FILE = 'AGENT.lock';
const MANAGED_WORKTREE_ROOTS = [
path.join('.omx', 'agent-worktrees'),
@@ -18,6 +19,8 @@ const MANAGED_WORKTREE_FILTER_PREFIXES = MANAGED_WORKTREE_ROOTS
const IDLE_ACTIVITY_WINDOW_MS = 2 * 60 * 1000;
const STALLED_ACTIVITY_WINDOW_MS = 15 * 60 * 1000;
const HEARTBEAT_STALE_MS = 5 * 60 * 1000;
+const DEFAULT_BASE_BRANCH = 'dev';
+const DEFAULT_LOG_TAIL_LINE_COUNT = 200;
const ADVISORY_SESSION_STATES = new Set(['working', 'thinking', 'idle']);
const WORKTREE_ACTIVITY_CACHE_TTL_MS = 3_000;
const MAX_WORKTREE_ACTIVITY_STAT_PATHS = 200;
@@ -131,6 +134,138 @@ function readJsonFile(filePath) {
}
}
+function readConfiguredBaseBranch(repoRoot) {
+ const lines = runGitLines(path.resolve(repoRoot), ['config', '--get', 'multiagent.baseBranch']);
+ if (Array.isArray(lines) && typeof lines[0] === 'string' && lines[0].trim()) {
+ return lines[0].trim();
+ }
+ return DEFAULT_BASE_BRANCH;
+}
+
+function readAheadBehindCounts(worktreePath, branch, baseBranch) {
+ const normalizedWorktreePath = toNonEmptyString(worktreePath);
+ const normalizedBranch = toNonEmptyString(branch);
+ const normalizedBaseBranch = toNonEmptyString(baseBranch, DEFAULT_BASE_BRANCH);
+ const compareRef = `origin/${normalizedBaseBranch}`;
+
+ if (!normalizedWorktreePath || !normalizedBranch) {
+ return {
+ compareRef,
+ aheadCount: null,
+ behindCount: null,
+ };
+ }
+
+ const lines = runGitLines(normalizedWorktreePath, [
+ 'rev-list',
+ '--left-right',
+ '--count',
+ `${normalizedBranch}...${compareRef}`,
+ ]);
+ const match = Array.isArray(lines) && typeof lines[0] === 'string'
+ ? lines[0].trim().match(/^(\d+)\s+(\d+)$/)
+ : null;
+ if (!match) {
+ return {
+ compareRef,
+ aheadCount: null,
+ behindCount: null,
+ };
+ }
+
+ return {
+ compareRef,
+ aheadCount: Number.parseInt(match[1], 10),
+ behindCount: Number.parseInt(match[2], 10),
+ };
+}
+
+function sessionLogPath(repoRoot, branch) {
+ const normalizedRepoRoot = toNonEmptyString(repoRoot);
+ const normalizedBranch = toNonEmptyString(branch);
+ if (!normalizedRepoRoot || !normalizedBranch) {
+ return '';
+ }
+
+ return path.join(
+ path.resolve(normalizedRepoRoot),
+ LOGS_RELATIVE_DIR,
+ `agent-${sanitizeBranchForFile(normalizedBranch)}.log`,
+ );
+}
+
+function readLogTail(filePath, maxLines = DEFAULT_LOG_TAIL_LINE_COUNT) {
+ const normalizedFilePath = toNonEmptyString(filePath);
+ const normalizedMaxLines = toPositiveInteger(maxLines) || DEFAULT_LOG_TAIL_LINE_COUNT;
+ if (!normalizedFilePath || !fs.existsSync(normalizedFilePath)) {
+ return [];
+ }
+
+ try {
+ const lines = fs.readFileSync(normalizedFilePath, 'utf8').split(/\r?\n/);
+ while (lines.length > 0 && lines[lines.length - 1] === '') {
+ lines.pop();
+ }
+ return lines.slice(-normalizedMaxLines);
+ } catch (_error) {
+ return [];
+ }
+}
+
+function readSessionHeldLocks(repoRoot, branch) {
+ const normalizedRepoRoot = toNonEmptyString(repoRoot);
+ const normalizedBranch = toNonEmptyString(branch);
+ if (!normalizedRepoRoot || !normalizedBranch) {
+ return [];
+ }
+
+ const parsed = readJsonFile(path.join(path.resolve(normalizedRepoRoot), LOCK_FILE_RELATIVE));
+ const locks = parsed?.locks;
+ if (!locks || typeof locks !== 'object' || Array.isArray(locks)) {
+ return [];
+ }
+
+ return Object.entries(locks)
+ .map(([rawRelativePath, entry]) => {
+ if (!entry || typeof entry !== 'object') {
+ return null;
+ }
+
+ const relativePath = normalizeRelativePath(rawRelativePath);
+ const ownerBranch = toNonEmptyString(entry.branch);
+ if (!relativePath || ownerBranch !== normalizedBranch) {
+ return null;
+ }
+
+ return {
+ relativePath,
+ claimedAt: toNonEmptyString(entry.claimed_at),
+ allowDelete: Boolean(entry.allow_delete),
+ };
+ })
+ .filter(Boolean)
+ .sort((left, right) => left.relativePath.localeCompare(right.relativePath));
+}
+
+function readSessionInspectData(session, options = {}) {
+ const repoRoot = toNonEmptyString(session?.repoRoot);
+ const branch = toNonEmptyString(session?.branch);
+ const worktreePath = toNonEmptyString(session?.worktreePath);
+ const baseBranch = readConfiguredBaseBranch(repoRoot);
+ const logPath = sessionLogPath(repoRoot, branch);
+ const logTailLines = readLogTail(logPath, options.logLines);
+
+ return {
+ baseBranch,
+ logPath,
+ logExists: Boolean(logPath) && fs.existsSync(logPath),
+ logTailLines,
+ logTailText: logTailLines.join('\n'),
+ heldLocks: readSessionHeldLocks(repoRoot, branch),
+ ...readAheadBehindCounts(worktreePath, branch, baseBranch),
+ };
+}
+
function normalizeIsoString(value, fallback = '') {
const normalized = toNonEmptyString(value);
if (!normalized) {
@@ -964,7 +1099,13 @@ module.exports = {
readWorktreeLockSessions,
readRepoChanges,
deriveRepoChangeStatus,
+ readAheadBehindCounts,
+ readConfiguredBaseBranch,
+ readLogTail,
resolveWorktreeGitDir,
+ readSessionHeldLocks,
+ readSessionInspectData,
+ sessionLogPath,
sanitizeBranchForFile,
sessionFileNameForBranch,
sessionFilePathForBranch,