diff --git a/openspec/changes/agent-codex-patch-vscode-active-agents-empty-list-2026-04-23-11-32/proposal.md b/openspec/changes/agent-codex-patch-vscode-active-agents-empty-list-2026-04-23-11-32/proposal.md new file mode 100644 index 00000000..8f1bf2a8 --- /dev/null +++ b/openspec/changes/agent-codex-patch-vscode-active-agents-empty-list-2026-04-23-11-32/proposal.md @@ -0,0 +1,16 @@ +# Patch Active Agents empty-list fallback + +## Problem + +The VS Code Active Agents view can show `No active Guardex agents` even when `gx branch start` has created a managed sandbox under `.omx/agent-worktrees/`. The current fallback only sees launcher session JSON files or `AGENT.lock` telemetry, so plain Guardex worktrees stay invisible. + +## Scope + +- Teach the session reader to synthesize rows from real managed `agent/*` git worktrees when no launcher state or `AGENT.lock` exists. +- Keep richer `AGENT.lock` telemetry preferred when present. +- Add focused regression coverage for session discovery and the SCM tree view. + +## Out Of Scope + +- New VS Code commands or layout redesigns. +- Changes to branch creation, launcher heartbeat emission, or lock ownership semantics. diff --git a/openspec/changes/agent-codex-patch-vscode-active-agents-empty-list-2026-04-23-11-32/specs/vscode-active-agents-extension/spec.md b/openspec/changes/agent-codex-patch-vscode-active-agents-empty-list-2026-04-23-11-32/specs/vscode-active-agents-extension/spec.md new file mode 100644 index 00000000..2b13def5 --- /dev/null +++ b/openspec/changes/agent-codex-patch-vscode-active-agents-empty-list-2026-04-23-11-32/specs/vscode-active-agents-extension/spec.md @@ -0,0 +1,20 @@ +## ADDED Requirements + +### Requirement: Active Agents shows managed sandboxes without launcher telemetry + +The GitGuardex Active Agents VS Code companion SHALL surface real managed `agent/*` git worktrees under `.omx/agent-worktrees/` and `.omc/agent-worktrees/` even when no `.omx/state/active-sessions/*.json` launcher record and no worktree `AGENT.lock` telemetry exists. + +#### Scenario: Plain managed worktree is visible + +- **GIVEN** a repository has a managed worktree under `.omx/agent-worktrees/` +- **AND** that worktree is checked out on an `agent/*` branch +- **AND** there is no active-session JSON file or worktree `AGENT.lock` +- **WHEN** the Active Agents view refreshes +- **THEN** the view shows that worktree as an active agent row +- **AND** dirty files inside the worktree drive the row activity state and changed-file count + +#### Scenario: Telemetry remains preferred + +- **GIVEN** a managed worktree has valid `AGENT.lock` telemetry +- **WHEN** the Active Agents view refreshes +- **THEN** the view uses the richer telemetry-backed session data for that worktree instead of the plain fallback row diff --git a/openspec/changes/agent-codex-patch-vscode-active-agents-empty-list-2026-04-23-11-32/tasks.md b/openspec/changes/agent-codex-patch-vscode-active-agents-empty-list-2026-04-23-11-32/tasks.md new file mode 100644 index 00000000..1440f322 --- /dev/null +++ b/openspec/changes/agent-codex-patch-vscode-active-agents-empty-list-2026-04-23-11-32/tasks.md @@ -0,0 +1,28 @@ +# Tasks + +Handoff: change=`agent-codex-patch-vscode-active-agents-empty-list-2026-04-23-11-32`; branch=`agent/codex/patch-vscode-active-agents-empty-list-2026-04-23-11-32`; scope=`vscode/guardex-active-agents/session-schema.js`, `templates/vscode/guardex-active-agents/session-schema.js`, `test/vscode-active-agents-session-state.test.js`; action=`patch Active Agents discovery so plain managed worktrees show in the VS Code SCM view, verify, then finish via PR merge cleanup`. + +## 1. Spec + +- [x] 1.1 Capture the empty-list fallback failure and acceptance criteria. + +## 2. Tests + +- [x] 2.1 Add session-schema regression coverage for plain managed worktrees with no launcher JSON and no `AGENT.lock`. +- [x] 2.2 Add extension-view regression coverage proving workspace fallback renders those worktrees instead of the empty state. + +## 3. Implementation + +- [x] 3.1 Add managed-worktree session synthesis while keeping `AGENT.lock` telemetry preferred. +- [x] 3.2 Mirror the session schema change into the install template. +- [x] 3.3 Bump live/template Active Agents extension manifests from `0.0.7` to `0.0.8` so local VS Code auto-update can supersede the installed build. + +## 4. Verification + +- [x] 4.1 Run focused Active Agents tests. Result: `node --test test/vscode-active-agents-session-state.test.js` passed, 39/39; `node --test test/metadata.test.js` passed, 18/18. +- [x] 4.2 Validate OpenSpec specs. Result: `openspec validate agent-codex-patch-vscode-active-agents-empty-list-2026-04-23-11-32 --strict` passed; `openspec validate --specs` returned no main-spec items. + +## 5. Cleanup + +- [ ] 5.1 Commit, push, open PR, wait for `MERGED`, and prune the sandbox with `gx branch finish --branch "agent/codex/patch-vscode-active-agents-empty-list-2026-04-23-11-32" --base main --via-pr --wait-for-merge --cleanup`. +- [ ] 5.2 Record PR URL and final `MERGED` cleanup evidence here. diff --git a/templates/vscode/guardex-active-agents/package.json b/templates/vscode/guardex-active-agents/package.json index 8e1224ed..a5df26e1 100644 --- a/templates/vscode/guardex-active-agents/package.json +++ b/templates/vscode/guardex-active-agents/package.json @@ -3,7 +3,7 @@ "displayName": "GitGuardex Active Agents", "description": "Shows live Guardex sandbox sessions and repo changes inside VS Code Source Control.", "publisher": "recodeee", - "version": "0.0.7", + "version": "0.0.8", "license": "MIT", "icon": "icon.png", "engines": { diff --git a/templates/vscode/guardex-active-agents/session-schema.js b/templates/vscode/guardex-active-agents/session-schema.js index 252ccfc7..d2a71c8d 100644 --- a/templates/vscode/guardex-active-agents/session-schema.js +++ b/templates/vscode/guardex-active-agents/session-schema.js @@ -867,6 +867,23 @@ function deriveAgentNameFromBranch(branch) { return 'agent'; } +function isManagedAgentBranch(branch) { + return toNonEmptyString(branch).startsWith('agent/'); +} + +function deriveManagedWorktreeStartedAt(worktreePath, now = Date.now()) { + try { + const stats = fs.statSync(worktreePath); + if (Number.isFinite(stats.mtimeMs)) { + return new Date(stats.mtimeMs).toISOString(); + } + } catch (_error) { + // Directory mtime is best-effort context only; fall back to current scan time. + } + + return new Date(now).toISOString(); +} + function flattenTelemetrySnapshotSessions(lockPayload) { const flattened = []; const snapshots = Array.isArray(lockPayload?.snapshots) ? lockPayload.snapshots : []; @@ -962,6 +979,48 @@ function buildWorktreeLockSession(repoRoot, worktreePath, lockPayload, options = return session; } +function buildManagedWorktreeSession(repoRoot, worktreePath, options = {}) { + const now = options.now || Date.now(); + const branch = readWorktreeBranch(worktreePath); + if (!branch || branch === 'HEAD' || !isManagedAgentBranch(branch)) { + return null; + } + + const label = deriveSessionLabel(branch, worktreePath); + const startedAt = deriveManagedWorktreeStartedAt(worktreePath, now); + const session = { + schemaVersion: SESSION_SCHEMA_VERSION, + repoRoot: path.resolve(repoRoot), + branch, + taskName: label, + latestTaskPreview: '', + agentName: deriveAgentNameFromBranch(branch), + worktreePath: path.resolve(worktreePath), + pid: null, + cliName: 'gx', + taskMode: '', + openspecTier: '', + taskRoutingReason: '', + startedAt, + lastHeartbeatAt: '', + state: '', + filePath: path.join(worktreePath, '.git'), + label, + changedPaths: [], + worktreeChangedPaths: [], + sourceKind: 'managed-worktree', + telemetryUpdatedAt: '', + telemetrySource: 'managed-worktree', + lockSnapshotCount: 0, + lockSessionCount: 0, + collaboration: false, + }; + + session.elapsedLabel = formatElapsedFrom(session.startedAt, now); + Object.assign(session, deriveSessionActivity(session, { now })); + return session; +} + function readWorktreeLockSessions(repoRoot, options = {}) { const sessions = []; for (const managedRoot of resolveManagedWorktreeRoots(repoRoot)) { @@ -1004,6 +1063,48 @@ function readWorktreeLockSessions(repoRoot, options = {}) { return sortSessionsByTimestamp(sessions); } +function readManagedWorktreeSessions(repoRoot, options = {}) { + const lockSessions = readWorktreeLockSessions(repoRoot, options); + const lockSessionsByWorktree = new Map( + lockSessions.map((session) => [path.resolve(session.worktreePath), session]), + ); + const sessions = []; + + for (const managedRoot of resolveManagedWorktreeRoots(repoRoot)) { + if (!fs.existsSync(managedRoot)) { + continue; + } + + let entries; + try { + entries = fs.readdirSync(managedRoot, { withFileTypes: true }); + } catch (_error) { + continue; + } + + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + + const worktreePath = path.join(managedRoot, entry.name); + const worktreeKey = path.resolve(worktreePath); + const lockSession = lockSessionsByWorktree.get(worktreeKey); + if (lockSession) { + sessions.push(lockSession); + continue; + } + + const managedSession = buildManagedWorktreeSession(repoRoot, worktreePath, options); + if (managedSession) { + sessions.push(managedSession); + } + } + } + + return sortSessionsByTimestamp(sessions); +} + function mergeSessionSources(primarySessions, lockSessions) { const lockSessionsByWorktree = new Map( lockSessions.map((session) => [path.resolve(session.worktreePath), session]), @@ -1061,7 +1162,7 @@ function readActiveSessions(repoRoot, options = {}) { return mergeSessionSources( sortSessionsByTimestamp(sessionFileSessions), - readWorktreeLockSessions(repoRoot, { now }), + readManagedWorktreeSessions(repoRoot, { now }), ); } @@ -1096,6 +1197,7 @@ module.exports = { parseRepoChangeLine, previewChangedPaths, readActiveSessions, + readManagedWorktreeSessions, readWorktreeLockSessions, readRepoChanges, deriveRepoChangeStatus, diff --git a/test/vscode-active-agents-session-state.test.js b/test/vscode-active-agents-session-state.test.js index 6a8c1aae..45ef0438 100644 --- a/test/vscode-active-agents-session-state.test.js +++ b/test/vscode-active-agents-session-state.test.js @@ -765,6 +765,31 @@ test('session-schema falls back to managed worktree AGENT.lock telemetry when la assert.equal(session.telemetryUpdatedAt, '2026-04-22T08:56:00.000Z'); }); +test('session-schema falls back to plain managed worktrees when launcher state and AGENT.lock are absent', () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-active-session-managed-fallback-')); + const worktreePath = path.join( + tempRoot, + '.omx', + 'agent-worktrees', + 'agent__codex__plain-visible-task', + ); + initGitRepo(worktreePath); + runGit(worktreePath, ['checkout', '-b', 'agent/codex/plain-visible-task']); + fs.writeFileSync(path.join(worktreePath, 'tracked.txt'), 'base\n', 'utf8'); + runGit(worktreePath, ['add', 'tracked.txt']); + runGit(worktreePath, ['commit', '-m', 'baseline']); + fs.writeFileSync(path.join(worktreePath, 'tracked.txt'), 'base\nchanged\n', 'utf8'); + + const [session] = sessionSchema.readActiveSessions(tempRoot); + assert.equal(session.sourceKind, 'managed-worktree'); + assert.equal(session.branch, 'agent/codex/plain-visible-task'); + assert.equal(session.agentName, 'codex'); + assert.equal(session.taskName, 'agent__codex__plain-visible-task'); + assert.equal(session.activityKind, 'working'); + assert.equal(session.activityCountLabel, '1 file'); + assert.equal(session.telemetrySource, 'managed-worktree'); +}); + test('session-schema prefers live worktree telemetry over a dead launcher record for the same worktree', () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-active-session-lock-prefer-')); const worktreePath = path.join( @@ -1725,6 +1750,50 @@ test('active-agents extension surfaces live managed worktrees from AGENT.lock fa } }); +test('active-agents extension surfaces plain managed worktrees from workspace fallback', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-managed-worktree-view-')); + initGitRepo(tempRoot); + + const worktreePath = path.join( + tempRoot, + '.omx', + 'agent-worktrees', + 'agent__codex__plain-visible-task', + ); + initGitRepo(worktreePath); + runGit(worktreePath, ['checkout', '-b', 'agent/codex/plain-visible-task']); + fs.mkdirSync(path.join(worktreePath, 'src'), { recursive: true }); + fs.writeFileSync(path.join(worktreePath, 'src', 'live.js'), 'base\n', 'utf8'); + runGit(worktreePath, ['add', 'src/live.js']); + runGit(worktreePath, ['commit', '-m', 'baseline']); + fs.writeFileSync(path.join(worktreePath, 'src', 'live.js'), 'base\nchanged\n', 'utf8'); + + const { registrations, vscode } = createMockVscode(tempRoot); + vscode.workspace.findFiles = async () => []; + const extension = loadExtensionWithMockVscode(vscode); + const context = { subscriptions: [] }; + + extension.activate(context); + await flushAsyncWork(); + + const provider = registrations.providers[0].provider; + const [repoItem] = await provider.getChildren(); + assert.equal(repoItem.description, '1 active · 1 working · 1 changed'); + + const [agentsSection] = await provider.getChildren(repoItem); + const [workingSection] = await provider.getChildren(agentsSection); + const { worktreeItem, sessionItem } = await getOnlyWorktreeAndSession(provider, workingSection); + assert.equal(workingSection.label, 'WORKING NOW'); + assert.equal(worktreeItem.label, path.basename(worktreePath)); + assert.equal(sessionItem.label, 'agent/codex/plain-visible-task'); + assert.match(sessionItem.description, /^working · 1 file · /); + assert.match(sessionItem.tooltip, /Started /); + + for (const subscription of context.subscriptions) { + subscription.dispose?.(); + } +}); + test('active-agents extension decorates sessions and repo changes from the lock registry', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-lock-decorations-')); initGitRepo(tempRoot); diff --git a/vscode/guardex-active-agents/package.json b/vscode/guardex-active-agents/package.json index 8e1224ed..a5df26e1 100644 --- a/vscode/guardex-active-agents/package.json +++ b/vscode/guardex-active-agents/package.json @@ -3,7 +3,7 @@ "displayName": "GitGuardex Active Agents", "description": "Shows live Guardex sandbox sessions and repo changes inside VS Code Source Control.", "publisher": "recodeee", - "version": "0.0.7", + "version": "0.0.8", "license": "MIT", "icon": "icon.png", "engines": { diff --git a/vscode/guardex-active-agents/session-schema.js b/vscode/guardex-active-agents/session-schema.js index 252ccfc7..d2a71c8d 100644 --- a/vscode/guardex-active-agents/session-schema.js +++ b/vscode/guardex-active-agents/session-schema.js @@ -867,6 +867,23 @@ function deriveAgentNameFromBranch(branch) { return 'agent'; } +function isManagedAgentBranch(branch) { + return toNonEmptyString(branch).startsWith('agent/'); +} + +function deriveManagedWorktreeStartedAt(worktreePath, now = Date.now()) { + try { + const stats = fs.statSync(worktreePath); + if (Number.isFinite(stats.mtimeMs)) { + return new Date(stats.mtimeMs).toISOString(); + } + } catch (_error) { + // Directory mtime is best-effort context only; fall back to current scan time. + } + + return new Date(now).toISOString(); +} + function flattenTelemetrySnapshotSessions(lockPayload) { const flattened = []; const snapshots = Array.isArray(lockPayload?.snapshots) ? lockPayload.snapshots : []; @@ -962,6 +979,48 @@ function buildWorktreeLockSession(repoRoot, worktreePath, lockPayload, options = return session; } +function buildManagedWorktreeSession(repoRoot, worktreePath, options = {}) { + const now = options.now || Date.now(); + const branch = readWorktreeBranch(worktreePath); + if (!branch || branch === 'HEAD' || !isManagedAgentBranch(branch)) { + return null; + } + + const label = deriveSessionLabel(branch, worktreePath); + const startedAt = deriveManagedWorktreeStartedAt(worktreePath, now); + const session = { + schemaVersion: SESSION_SCHEMA_VERSION, + repoRoot: path.resolve(repoRoot), + branch, + taskName: label, + latestTaskPreview: '', + agentName: deriveAgentNameFromBranch(branch), + worktreePath: path.resolve(worktreePath), + pid: null, + cliName: 'gx', + taskMode: '', + openspecTier: '', + taskRoutingReason: '', + startedAt, + lastHeartbeatAt: '', + state: '', + filePath: path.join(worktreePath, '.git'), + label, + changedPaths: [], + worktreeChangedPaths: [], + sourceKind: 'managed-worktree', + telemetryUpdatedAt: '', + telemetrySource: 'managed-worktree', + lockSnapshotCount: 0, + lockSessionCount: 0, + collaboration: false, + }; + + session.elapsedLabel = formatElapsedFrom(session.startedAt, now); + Object.assign(session, deriveSessionActivity(session, { now })); + return session; +} + function readWorktreeLockSessions(repoRoot, options = {}) { const sessions = []; for (const managedRoot of resolveManagedWorktreeRoots(repoRoot)) { @@ -1004,6 +1063,48 @@ function readWorktreeLockSessions(repoRoot, options = {}) { return sortSessionsByTimestamp(sessions); } +function readManagedWorktreeSessions(repoRoot, options = {}) { + const lockSessions = readWorktreeLockSessions(repoRoot, options); + const lockSessionsByWorktree = new Map( + lockSessions.map((session) => [path.resolve(session.worktreePath), session]), + ); + const sessions = []; + + for (const managedRoot of resolveManagedWorktreeRoots(repoRoot)) { + if (!fs.existsSync(managedRoot)) { + continue; + } + + let entries; + try { + entries = fs.readdirSync(managedRoot, { withFileTypes: true }); + } catch (_error) { + continue; + } + + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + + const worktreePath = path.join(managedRoot, entry.name); + const worktreeKey = path.resolve(worktreePath); + const lockSession = lockSessionsByWorktree.get(worktreeKey); + if (lockSession) { + sessions.push(lockSession); + continue; + } + + const managedSession = buildManagedWorktreeSession(repoRoot, worktreePath, options); + if (managedSession) { + sessions.push(managedSession); + } + } + } + + return sortSessionsByTimestamp(sessions); +} + function mergeSessionSources(primarySessions, lockSessions) { const lockSessionsByWorktree = new Map( lockSessions.map((session) => [path.resolve(session.worktreePath), session]), @@ -1061,7 +1162,7 @@ function readActiveSessions(repoRoot, options = {}) { return mergeSessionSources( sortSessionsByTimestamp(sessionFileSessions), - readWorktreeLockSessions(repoRoot, { now }), + readManagedWorktreeSessions(repoRoot, { now }), ); } @@ -1096,6 +1197,7 @@ module.exports = { parseRepoChangeLine, previewChangedPaths, readActiveSessions, + readManagedWorktreeSessions, readWorktreeLockSessions, readRepoChanges, deriveRepoChangeStatus,