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/`, 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. diff --git a/openspec/changes/agent-codex-inspect-active-agent-session-2026-04-22-17-45/tasks.md b/openspec/changes/agent-codex-inspect-active-agent-session-2026-04-22-17-45/tasks.md new file mode 100644 index 0000000..f831905 --- /dev/null +++ b/openspec/changes/agent-codex-inspect-active-agent-session-2026-04-22-17-45/tasks.md @@ -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). diff --git a/templates/vscode/guardex-active-agents/extension.js b/templates/vscode/guardex-active-agents/extension.js index a527385..df11917 100644 --- a/templates/vscode/guardex-active-agents/extension.js +++ b/templates/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 + ? `` + : '

No held locks recorded for this session.

'; + const logContent = inspectData?.logTailText + ? escapeHtml(inspectData.logTailText) + : 'No log output available.'; + + return ` + + + + + + + +

${escapeHtml(sessionIdentityLabel(session))}

+
+
Branch
+
${escapeHtml(session.branch)}
+
Worktree
+
${escapeHtml(session.worktreePath)}
+
Base branch
+
${escapeHtml(inspectData?.baseBranch || 'dev')}
+
Divergence
+
${escapeHtml(formatInspectBranchSummary(inspectData))}
+
Held locks
+
${Array.isArray(inspectData?.heldLocks) ? inspectData.heldLocks.length : 0}
+
Log file
+
${escapeHtml(inspectData?.logPath || 'Unavailable')}
+
+

Held Locks

+ ${heldLocksMarkup} +

Agent Log Tail

+
${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 + ? `` + : '

No held locks recorded for this session.

'; + const logContent = inspectData?.logTailText + ? escapeHtml(inspectData.logTailText) + : 'No log output available.'; + + return ` + + + + + + + +

${escapeHtml(sessionIdentityLabel(session))}

+
+
Branch
+
${escapeHtml(session.branch)}
+
Worktree
+
${escapeHtml(session.worktreePath)}
+
Base branch
+
${escapeHtml(inspectData?.baseBranch || 'dev')}
+
Divergence
+
${escapeHtml(formatInspectBranchSummary(inspectData))}
+
Held locks
+
${Array.isArray(inspectData?.heldLocks) ? inspectData.heldLocks.length : 0}
+
Log file
+
${escapeHtml(inspectData?.logPath || 'Unavailable')}
+
+

Held Locks

+ ${heldLocksMarkup} +

Agent Log Tail

+
${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,