Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Show Active Agents in a second VS Code window

## Why

The Active Agents view already discovers nested repos when a parent workspace is open, but a second VS Code window opened directly on a Guardex worktree or a subfolder inside that repo can miss the owning repo's active-session state. Operators need the second window to show the same repo-local Active Agents view without leaking unrelated parent-workspace agents.

## What Changes

- Resolve each workspace folder to its owning Guardex repo root instead of assuming the folder path itself is the repo root.
- Keep the Active Agents view scoped to the resolved repo root so a `gitguardex` window only shows `gitguardex` agents.
- Add focused regression coverage for a second VS Code window opened on a linked Guardex worktree.

## Impact

This only changes repo discovery for the VS Code Active Agents companion. It does not change Guardex branch creation, locking, or finish behavior.
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
## ADDED Requirements

### Requirement: Second-window repo-root resolution

The VS Code `gitguardex.activeAgents` view MUST resolve an opened workspace folder to its owning Guardex repo root before reading Active Agents session state, so a second VS Code window opened on a linked worktree or repo subfolder still shows the owning repo's sessions.

#### Scenario: Linked worktree window still shows the owning repo sessions

- **GIVEN** a Guardex repo root has active-session records under `.omx/state/active-sessions/`
- **AND** a second VS Code window is opened on a linked worktree under `.omx/agent-worktrees/...`
- **WHEN** the Active Agents view scans workspace folders for repo candidates
- **THEN** it resolves the owning repo root from the linked worktree git metadata
- **AND** it reads sessions from that owning repo root instead of the worktree path.

### Requirement: Repo-scoped second-window filtering

The VS Code `gitguardex.activeAgents` view MUST keep the tree scoped to the resolved repo root for the currently opened repo, so a `gitguardex` window only shows `gitguardex` agents even when the parent workspace has other Guardex repos.

#### Scenario: Nested repo window only shows nested repo agents

- **GIVEN** a parent workspace contains multiple Guardex repos
- **AND** a second VS Code window is opened directly on the nested `gitguardex` repo or one of its linked worktrees
- **WHEN** the Active Agents view renders the top-level repo rows
- **THEN** it only renders the resolved `gitguardex` repo root for that window
- **AND** it does not add unrelated parent-repo agent sessions to that tree.
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
## Definition of Done

This change is complete only when **all** of the following are true:

- Every checkbox below is checked.
- The agent branch reaches `MERGED` state on `origin` and the PR URL + state are recorded in the completion handoff.
- If any step blocks (test failure, conflict, ambiguous result), append a `BLOCKED:` line under section 4 explaining the blocker and **STOP**. Do not tick remaining cleanup boxes; do not silently skip the cleanup pipeline.

## Handoff

- Handoff: change=`agent-codex-show-active-agents-in-second-vscode-wind-2026-04-23-16-50`; branch=`agent/codex/show-active-agents-in-second-vscode-wind-2026-04-23-16-50`; scope=`VS Code Active Agents repo-root resolution for second-window worktree/subdir views, template parity, focused regression`; action=`show owning gitguardex agents when another VS Code window opens on a linked worktree or nested repo path, verify, then finish via PR merge cleanup`.

## 1. Specification

- [x] 1.1 Define second-window repo-root resolution requirements.
- [x] 1.2 Keep cleanup evidence requirements explicit.

## 2. Implementation

- [x] 2.1 Resolve workspace folders to owning repo roots before reading Active Agents sessions.
- [x] 2.2 Keep the view scoped to the resolved repo root so a gitguardex window does not show unrelated parent-repo agents.
- [x] 2.3 Mirror extension changes in `templates/vscode/guardex-active-agents/extension.js`.
- [x] 2.4 Add focused regression coverage for a linked-worktree VS Code window.

## 3. Verification

- [x] 3.1 Run `node --test test/vscode-active-agents-session-state.test.js`.
- [x] 3.2 Run `openspec validate agent-codex-show-active-agents-in-second-vscode-wind-2026-04-23-16-50 --type change --strict`.
- [x] 3.3 Run `openspec validate --specs`.

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

- [ ] 4.1 Run `gx branch finish --branch agent/codex/show-active-agents-in-second-vscode-wind-2026-04-23-16-50 --base main --via-pr --wait-for-merge --cleanup`.
- [ ] 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).
85 changes: 84 additions & 1 deletion templates/vscode/guardex-active-agents/extension.js
Original file line number Diff line number Diff line change
Expand Up @@ -1781,6 +1781,89 @@ async function openSessionDiff(session) {
}
}

function readGitDirPath(targetPath) {
const normalizedTargetPath = typeof targetPath === 'string' ? targetPath.trim() : '';
if (!normalizedTargetPath) {
return '';
}

const gitPath = path.join(path.resolve(normalizedTargetPath), '.git');
try {
if (fs.statSync(gitPath).isDirectory()) {
return gitPath;
}
} catch (_error) {
return '';
}

try {
const gitPointer = fs.readFileSync(gitPath, 'utf8');
const match = gitPointer.match(/^gitdir:\s*(.+)$/m);
if (match?.[1]) {
return path.resolve(path.dirname(gitPath), match[1].trim());
}
} catch (_error) {
return '';
}

return '';
}

function resolveRepoRootFromGitDir(targetPath) {
const gitDir = readGitDirPath(targetPath);
if (!gitDir) {
return '';
}

let commonDir = gitDir;
try {
const commonDirPath = path.join(gitDir, 'commondir');
if (fs.existsSync(commonDirPath)) {
const rawCommonDir = fs.readFileSync(commonDirPath, 'utf8').trim();
if (rawCommonDir) {
commonDir = path.resolve(gitDir, rawCommonDir);
}
}
} catch (_error) {
// Fall back to the direct git dir when commondir is unreadable.
}

return path.basename(commonDir) === '.git'
? path.resolve(path.dirname(commonDir))
: '';
}

function readGitTopLevel(targetPath) {
try {
return cp.execFileSync('git', ['-C', targetPath, 'rev-parse', '--show-toplevel'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
} catch (_error) {
return '';
}
}

function resolveWorkspaceFolderRepoRoot(workspacePath) {
const normalizedWorkspacePath = typeof workspacePath === 'string' ? workspacePath.trim() : '';
if (!normalizedWorkspacePath) {
return '';
}

const absoluteWorkspacePath = path.resolve(normalizedWorkspacePath);
const directRepoRoot = resolveRepoRootFromGitDir(absoluteWorkspacePath);
if (directRepoRoot) {
return directRepoRoot;
}

const gitTopLevel = readGitTopLevel(absoluteWorkspacePath);
if (!gitTopLevel) {
return absoluteWorkspacePath;
}

return resolveRepoRootFromGitDir(gitTopLevel) || path.resolve(gitTopLevel);
}

function repoRootFromSessionFile(filePath) {
return path.resolve(path.dirname(filePath), '..', '..', '..');
}
Expand Down Expand Up @@ -2109,7 +2192,7 @@ async function findRepoSessionEntries() {
}
for (const workspaceFolder of vscode.workspace.workspaceFolders || []) {
if (workspaceFolder?.uri?.fsPath) {
addRepoRootCandidate(workspaceFolder.uri.fsPath);
addRepoRootCandidate(resolveWorkspaceFolderRepoRoot(workspaceFolder.uri.fsPath));
}
}

Expand Down
2 changes: 1 addition & 1 deletion templates/vscode/guardex-active-agents/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"displayName": "GitGuardex Active Agents",
"description": "Shows live Guardex sandbox sessions and repo changes in a dedicated VS Code Active Agents sidebar.",
"publisher": "recodeee",
"version": "0.0.15",
"version": "0.0.16",
"license": "MIT",
"icon": "icon.png",
"engines": {
Expand Down
56 changes: 56 additions & 0 deletions test/vscode-active-agents-session-state.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2376,6 +2376,62 @@ test('active-agents extension surfaces plain managed worktrees from workspace fa
}
});

test('active-agents extension resolves owning repo sessions when the window is opened on a linked worktree', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-linked-worktree-view-'));
initGitRepo(tempRoot);
fs.writeFileSync(path.join(tempRoot, 'tracked.txt'), 'base\n', 'utf8');
runGit(tempRoot, ['add', 'tracked.txt']);
runGit(tempRoot, ['commit', '-m', 'baseline']);

const branch = 'agent/codex/linked-worktree-visible-task';
const worktreePath = path.join(
tempRoot,
'.omx',
'agent-worktrees',
'agent__codex__linked-worktree-visible-task',
);
fs.mkdirSync(path.dirname(worktreePath), { recursive: true });
runGit(tempRoot, ['worktree', 'add', '-b', branch, worktreePath]);
fs.writeFileSync(path.join(worktreePath, 'tracked.txt'), 'base\nchanged\n', 'utf8');

writeSessionRecord(tempRoot, sessionSchema.buildSessionRecord({
repoRoot: tempRoot,
branch,
taskName: 'linked-worktree-visible-task',
agentName: 'codex',
worktreePath,
pid: process.pid,
cliName: 'codex',
state: 'working',
}));

const { registrations, vscode } = createMockVscode(tempRoot);
vscode.workspace.workspaceFolders = [{ uri: { fsPath: worktreePath } }];
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.label, path.basename(tempRoot));
assert.equal(repoItem.description, '1 working agent · 0 idle agents · 0 unassigned changes · 0 locked files · 0 conflicts');

const workingSection = await getSectionByLabel(provider, repoItem, 'Working now');
const { worktreeItem, sessionItem } = await getOnlyWorktreeAndSession(provider, workingSection);
assert.equal(worktreeItem, null);
assert.equal(sessionItem.session.repoRoot, tempRoot);
assert.equal(sessionItem.session.worktreePath, worktreePath);
assert.equal(sessionItem.session.branch, branch);
assert.match(sessionItem.description, /^Working: codex · via OpenAI · 1 changed file/);

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);
Expand Down
85 changes: 84 additions & 1 deletion vscode/guardex-active-agents/extension.js
Original file line number Diff line number Diff line change
Expand Up @@ -1781,6 +1781,89 @@ async function openSessionDiff(session) {
}
}

function readGitDirPath(targetPath) {
const normalizedTargetPath = typeof targetPath === 'string' ? targetPath.trim() : '';
if (!normalizedTargetPath) {
return '';
}

const gitPath = path.join(path.resolve(normalizedTargetPath), '.git');
try {
if (fs.statSync(gitPath).isDirectory()) {
return gitPath;
}
} catch (_error) {
return '';
}

try {
const gitPointer = fs.readFileSync(gitPath, 'utf8');
const match = gitPointer.match(/^gitdir:\s*(.+)$/m);
if (match?.[1]) {
return path.resolve(path.dirname(gitPath), match[1].trim());
}
} catch (_error) {
return '';
}

return '';
}

function resolveRepoRootFromGitDir(targetPath) {
const gitDir = readGitDirPath(targetPath);
if (!gitDir) {
return '';
}

let commonDir = gitDir;
try {
const commonDirPath = path.join(gitDir, 'commondir');
if (fs.existsSync(commonDirPath)) {
const rawCommonDir = fs.readFileSync(commonDirPath, 'utf8').trim();
if (rawCommonDir) {
commonDir = path.resolve(gitDir, rawCommonDir);
}
}
} catch (_error) {
// Fall back to the direct git dir when commondir is unreadable.
}

return path.basename(commonDir) === '.git'
? path.resolve(path.dirname(commonDir))
: '';
}

function readGitTopLevel(targetPath) {
try {
return cp.execFileSync('git', ['-C', targetPath, 'rev-parse', '--show-toplevel'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
} catch (_error) {
return '';
}
}

function resolveWorkspaceFolderRepoRoot(workspacePath) {
const normalizedWorkspacePath = typeof workspacePath === 'string' ? workspacePath.trim() : '';
if (!normalizedWorkspacePath) {
return '';
}

const absoluteWorkspacePath = path.resolve(normalizedWorkspacePath);
const directRepoRoot = resolveRepoRootFromGitDir(absoluteWorkspacePath);
if (directRepoRoot) {
return directRepoRoot;
}

const gitTopLevel = readGitTopLevel(absoluteWorkspacePath);
if (!gitTopLevel) {
return absoluteWorkspacePath;
}

return resolveRepoRootFromGitDir(gitTopLevel) || path.resolve(gitTopLevel);
}

function repoRootFromSessionFile(filePath) {
return path.resolve(path.dirname(filePath), '..', '..', '..');
}
Expand Down Expand Up @@ -2109,7 +2192,7 @@ async function findRepoSessionEntries() {
}
for (const workspaceFolder of vscode.workspace.workspaceFolders || []) {
if (workspaceFolder?.uri?.fsPath) {
addRepoRootCandidate(workspaceFolder.uri.fsPath);
addRepoRootCandidate(resolveWorkspaceFolderRepoRoot(workspaceFolder.uri.fsPath));
}
}

Expand Down
2 changes: 1 addition & 1 deletion vscode/guardex-active-agents/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"displayName": "GitGuardex Active Agents",
"description": "Shows live Guardex sandbox sessions and repo changes in a dedicated VS Code Active Agents sidebar.",
"publisher": "recodeee",
"version": "0.0.15",
"version": "0.0.16",
"license": "MIT",
"icon": "icon.png",
"engines": {
Expand Down