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,17 @@
# Show nested Active Agents subprojects

## Why

The Active Agents view can show the parent workspace, such as `recodee`, while hiding active managed worktrees that belong to a nested repo such as `recodee/gitguardex` when that nested repo has plain managed worktrees but no active-session files or `AGENT.lock` files. Operators need the top-level view to show the full workspace path so it is clear that work is happening under `recodee -> gitguardex`.

## What Changes

- Discover nested repo roots from managed worktree `.git` files under `.omx/agent-worktrees` and `.omc/agent-worktrees`.
- Keep workspace roots in the scan even when other session files are found.
- Label nested repo roots relative to their workspace folder, for example `recodee -> gitguardex`.
- Watch managed worktree `.git` files so new plain worktrees refresh the Active Agents view.
- Keep live/template VS Code extension copies and focused tests in sync.

## Impact

This is limited to the VS Code Active Agents companion tree. It does not change Guardex branch creation, locking, or finish behavior.
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
## ADDED Requirements

### Requirement: Nested subproject Active Agents discovery

The VS Code `gitguardex.activeAgents` view MUST discover nested repository roots under the open workspace when those nested repositories have managed agent worktrees under `.omx/agent-worktrees` or `.omc/agent-worktrees`, even when those worktrees only expose a worktree `.git` file and do not expose active-session JSON or `AGENT.lock` telemetry.

#### Scenario: Top-level view includes a nested repo with plain managed worktrees

- **GIVEN** a workspace folder such as `recodee`
- **AND** a nested repository such as `recodee/gitguardex`
- **AND** the nested repository has a managed worktree under `.omx/agent-worktrees`
- **WHEN** the Active Agents view scans the workspace
- **THEN** it includes the nested repository in the top-level repo list
- **AND** it reads the nested repository sessions from that nested repository root.

### Requirement: Workspace-relative nested repo labels

The VS Code `gitguardex.activeAgents` view MUST label nested repository roots relative to the open workspace folder so operators can see the workspace-to-subproject path at the top level.

#### Scenario: Nested repo label shows workspace and subproject

- **GIVEN** the open workspace folder is `recodee`
- **AND** the discovered active repo root is `recodee/gitguardex`
- **WHEN** the Active Agents view renders the repo row
- **THEN** the repo row label is `recodee -> gitguardex`.

### Requirement: Managed worktree discovery refresh

The VS Code `gitguardex.activeAgents` view MUST refresh when managed worktree `.git` files are created, changed, or deleted under `.omx/agent-worktrees` or `.omc/agent-worktrees`.

#### Scenario: New plain managed worktree appears without active-session telemetry

- **GIVEN** a nested repository has no active-session JSON and no `AGENT.lock` telemetry
- **WHEN** a managed worktree `.git` file appears under `.omx/agent-worktrees`
- **THEN** the Active Agents view schedules a refresh
- **AND** the nested repository becomes visible if it has readable managed worktree sessions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
## 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-subproject-active-agents-2026-04-23-16-04`; branch=`agent/codex/show-subproject-active-agents-2026-04-23-16-04`; scope=`VS Code Active Agents nested subproject discovery, labels, watchers, template parity, and focused regression`; action=`show nested gitguardex-style managed worktrees at top level as workspace -> subproject, verify, then finish via PR merge cleanup`.

## 1. Specification

- [x] 1.1 Define nested subproject discovery and labeling requirements.
- [x] 1.2 Keep completion and cleanup evidence requirements explicit.

## 2. Implementation

- [x] 2.1 Discover plain managed-worktree repos from `.omx/.omc/agent-worktrees/*/.git`.
- [x] 2.2 Keep workspace roots in the candidate scan while filtering nested managed-worktree copies.
- [x] 2.3 Render nested repo labels as `workspace -> subproject`.
- [x] 2.4 Add a managed-worktree `.git` watcher for refresh.
- [x] 2.5 Mirror extension changes in `templates/vscode/guardex-active-agents/extension.js`.
- [x] 2.6 Bump live/template Active Agents manifests for extension install 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-show-subproject-active-agents-2026-04-23-16-04 --type change --strict`.
- [x] 3.3 Run `openspec validate --specs`.
- [x] 3.4 Run `npm test`.

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

- [ ] 4.1 Run `gx branch finish --branch agent/codex/show-subproject-active-agents-2026-04-23-16-04 --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).
79 changes: 71 additions & 8 deletions templates/vscode/guardex-active-agents/extension.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@ 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 MANAGED_WORKTREE_GIT_FILES_GLOB = '**/{.omx,.omc}/agent-worktrees/*/.git';
const MANAGED_WORKTREE_RELATIVE_ROOTS = [
path.join('.omx', 'agent-worktrees'),
path.join('.omc', 'agent-worktrees'),
];
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 MANAGED_WORKTREE_GIT_SCAN_EXCLUDE_GLOB = '**/node_modules/**';
const SESSION_SCAN_LIMIT = 200;
const REFRESH_DEBOUNCE_MS = 250;
const RECENTLY_ACTIVE_WINDOW_MS = 10 * 60 * 1000;
Expand Down Expand Up @@ -735,6 +737,30 @@ function buildRepoTooltip(repoRoot, summary) {
].join('\n');
}

function repoRootDisplayLabel(repoRoot) {
const normalizedRepoRoot = path.resolve(repoRoot);
const matchingWorkspaceRoots = (vscode.workspace.workspaceFolders || [])
.map((folder) => (typeof folder?.uri?.fsPath === 'string' ? path.resolve(folder.uri.fsPath) : ''))
.filter((workspaceRoot) => workspaceRoot && isPathWithin(workspaceRoot, normalizedRepoRoot))
.sort((left, right) => right.length - left.length);

const workspaceRoot = matchingWorkspaceRoots[0];
if (!workspaceRoot) {
return path.basename(normalizedRepoRoot);
}

const workspaceLabel = path.basename(workspaceRoot);
const relativePath = normalizeRelativePath(path.relative(workspaceRoot, normalizedRepoRoot));
if (!relativePath) {
return workspaceLabel;
}

return [
workspaceLabel,
...relativePath.split('/').filter(Boolean),
].join(' -> ');
}

function sessionSnapshotKey(session) {
return `${session?.repoRoot || ''}::${session?.branch || ''}`;
}
Expand Down Expand Up @@ -1094,7 +1120,10 @@ class DetailItem extends vscode.TreeItem {

class RepoItem extends vscode.TreeItem {
constructor(repoRoot, sessions, changes, options = {}) {
super(path.basename(repoRoot), vscode.TreeItemCollapsibleState.Expanded);
const label = typeof options.label === 'string' && options.label.trim()
? options.label.trim()
: repoRootDisplayLabel(repoRoot);
super(label, vscode.TreeItemCollapsibleState.Expanded);
this.repoRoot = repoRoot;
this.sessions = sessions;
this.changes = changes;
Expand Down Expand Up @@ -1684,6 +1713,10 @@ function repoRootFromWorktreeLockFile(filePath) {
return path.resolve(path.dirname(filePath), '..', '..', '..');
}

function repoRootFromManagedWorktreeGitFile(filePath) {
return path.resolve(path.dirname(filePath), '..', '..', '..');
}

function repoRootFromLockFile(filePath) {
return path.resolve(path.dirname(filePath), '..', '..');
}
Expand Down Expand Up @@ -1947,7 +1980,7 @@ function localizeChangeForSession(session, change) {
}

async function findRepoSessionEntries() {
const [sessionFiles, worktreeLockFiles] = await Promise.all([
const [sessionFiles, worktreeLockFiles, managedWorktreeGitFiles] = await Promise.all([
vscode.workspace.findFiles(
ACTIVE_SESSION_FILES_GLOB,
SESSION_SCAN_EXCLUDE_GLOB,
Expand All @@ -1958,22 +1991,49 @@ async function findRepoSessionEntries() {
WORKTREE_LOCK_SCAN_EXCLUDE_GLOB,
SESSION_SCAN_LIMIT,
),
vscode.workspace.findFiles(
MANAGED_WORKTREE_GIT_FILES_GLOB,
MANAGED_WORKTREE_GIT_SCAN_EXCLUDE_GLOB,
SESSION_SCAN_LIMIT,
),
]);

const repoRoots = new Set();
const addRepoRootCandidate = (repoRoot) => {
if (typeof repoRoot !== 'string' || !repoRoot.trim()) {
return;
}

const normalizedRepoRoot = path.resolve(repoRoot);
const isInsideWorkspaceManagedWorktree = (vscode.workspace.workspaceFolders || [])
.map((folder) => (typeof folder?.uri?.fsPath === 'string' ? path.resolve(folder.uri.fsPath) : ''))
.filter(Boolean)
.some((workspaceRoot) => MANAGED_WORKTREE_RELATIVE_ROOTS.some((relativeRoot) => (
isPathWithin(path.join(workspaceRoot, relativeRoot), normalizedRepoRoot)
)));
if (!isInsideWorkspaceManagedWorktree) {
repoRoots.add(normalizedRepoRoot);
}
};

for (const uri of sessionFiles) {
repoRoots.add(repoRootFromSessionFile(uri.fsPath));
addRepoRootCandidate(repoRootFromSessionFile(uri.fsPath));
}
for (const uri of worktreeLockFiles) {
if (path.basename(uri.fsPath) !== 'AGENT.lock') {
continue;
}
repoRoots.add(repoRootFromWorktreeLockFile(uri.fsPath));
addRepoRootCandidate(repoRootFromWorktreeLockFile(uri.fsPath));
}

if (repoRoots.size === 0) {
for (const workspaceFolder of vscode.workspace.workspaceFolders || []) {
repoRoots.add(workspaceFolder.uri.fsPath);
for (const uri of managedWorktreeGitFiles) {
if (path.basename(uri.fsPath) !== '.git') {
continue;
}
addRepoRootCandidate(repoRootFromManagedWorktreeGitFile(uri.fsPath));
}
for (const workspaceFolder of vscode.workspace.workspaceFolders || []) {
if (workspaceFolder?.uri?.fsPath) {
addRepoRootCandidate(workspaceFolder.uri.fsPath);
}
}

Expand Down Expand Up @@ -2951,6 +3011,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 managedWorktreeGitWatcher = vscode.workspace.createFileSystemWatcher(MANAGED_WORKTREE_GIT_FILES_GLOB);
const logWatcher = vscode.workspace.createFileSystemWatcher(AGENT_LOG_FILES_GLOB);
const updateCommitInput = (session) => {
sourceControl.inputBox.enabled = true;
Expand Down Expand Up @@ -3076,6 +3137,7 @@ function activate(context) {
activeSessionsWatcher,
lockWatcher,
worktreeLockWatcher,
managedWorktreeGitWatcher,
logWatcher,
{ dispose: () => clearInterval(interval) },
);
Expand All @@ -3084,6 +3146,7 @@ function activate(context) {
...bindRefreshWatcher(activeSessionsWatcher, scheduleRefresh),
...bindRefreshWatcher(lockWatcher, refreshLockRegistry),
...bindRefreshWatcher(worktreeLockWatcher, scheduleRefresh),
...bindRefreshWatcher(managedWorktreeGitWatcher, scheduleRefresh),
...bindRefreshWatcher(logWatcher, scheduleRefresh),
);
void ensureManagedRepoScanIgnores();
Expand Down
72 changes: 69 additions & 3 deletions test/vscode-active-agents-session-state.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1387,13 +1387,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, 4);
assert.equal(registrations.fileWatchers.length, 5);
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,.omc}/agent-worktrees/*/.git',
'**/.omx/logs/*.log',
],
);
Expand Down Expand Up @@ -1669,6 +1670,69 @@ test('active-agents extension groups live sessions under a repo node', async ()
}
});

test('active-agents extension discovers nested managed-worktree subprojects under workspace roots', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-subprojects-'));
const nestedRepoRoot = path.join(tempRoot, 'gitguardex');
initGitRepo(nestedRepoRoot);
fs.writeFileSync(path.join(nestedRepoRoot, 'tracked.txt'), 'base\n', 'utf8');
runGit(nestedRepoRoot, ['add', 'tracked.txt']);
runGit(nestedRepoRoot, ['commit', '-m', 'baseline']);

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

const managedWorktreeGitFile = path.join(worktreePath, '.git');
assert.equal(fs.statSync(managedWorktreeGitFile).isFile(), true);

const { registrations, vscode } = createMockVscode(tempRoot);
vscode.workspace.findFiles = async (pattern) => {
if (pattern === '**/{.omx,.omc}/agent-worktrees/*/.git') {
return [{ fsPath: managedWorktreeGitFile }];
}
return [];
};
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)} -> gitguardex`);
assert.equal(repoItem.repoRoot, nestedRepoRoot);
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, nestedRepoRoot);
assert.equal(sessionItem.session.worktreePath, worktreePath);
assert.equal(sessionItem.session.branch, 'agent/codex/nested-visible-task');
assert.match(sessionItem.description, /^Working: codex · via OpenAI · 1 changed file/);
assert.deepEqual(registrations.treeViews[0].badge, {
value: 1,
tooltip: repoItem.description,
});

for (const subscription of context.subscriptions) {
subscription.dispose?.();
}
});

test('active-agents extension shows provider and snapshot identity badges', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-provider-badges-'));
const codexWorktreePath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-provider-codex-'));
Expand Down Expand Up @@ -2686,6 +2750,7 @@ test('active-agents extension watches active sessions, lock files, logs, and ses
'**/.omx/state/active-sessions/*.json',
'**/.omx/state/agent-file-locks.json',
'**/{.omx,.omc}/agent-worktrees/**/AGENT.lock',
'**/{.omx,.omc}/agent-worktrees/*/.git',
'**/.omx/logs/*.log',
path.join(worktreePath, '.git', 'index'),
],
Expand All @@ -2697,7 +2762,7 @@ test('active-agents extension watches active sessions, lock files, logs, and ses
await new Promise((resolve) => setTimeout(resolve, 350));
await flushAsyncWork();

assert.equal(registrations.fileWatchers[4].disposed, true);
assert.equal(registrations.fileWatchers[5].disposed, true);

for (const subscription of context.subscriptions) {
subscription.dispose?.();
Expand All @@ -2719,7 +2784,8 @@ 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') });
registrations.fileWatchers[3].fireChange({ fsPath: path.join(tempRoot, '.omx', 'agent-worktrees', 'agent__codex__a', '.git') });
registrations.fileWatchers[4].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));
Expand Down
Loading