Skip to content

Commit 7f75922

Browse files
NagyViktNagyViktOmX
authored
Let Active Agents target nested repos (#561)
VS Code workspaces can expose a parent repo plus nested storefront/backend repos, and the Start agent command previously defaulted to the workspace root. That made it too easy to start the wrong lane when the user wanted a nested repo branch/worktree while keeping that nested repo's visible main checkout stable. This adds bounded nested Git repo discovery to the Active Agents picker, keeps the installed template copy in sync, and adds a regression proving the terminal cwd follows the selected nested repo. Constraint: VS Code Source Control commonly exposes nested repos in one workspace. Rejected: Change branch-start semantics globally | the CLI already creates isolated worktrees when invoked from the correct repo root. Confidence: high Scope-risk: narrow Directive: Keep the extension source and templates/vscode/guardex-active-agents/extension.js in sync for install parity. Tested: node --test test/vscode-active-agents-session-state.test.js Tested: node --check vscode/guardex-active-agents/extension.js Tested: openspec validate agent-codex-codex-task-2026-05-11-15-20-2 --type change --strict Tested: openspec validate --specs Not-tested: Full npm test; metadata.test.js has unrelated release-lane failures for Cosign v4.1.2 vs expected v4.1.1 and missing README v7.0.43 notes. Co-authored-by: NagyVikt <nagy.viktordp@gmail.com> Co-authored-by: OmX <omx@oh-my-codex.dev>
1 parent f5cf54d commit 7f75922

7 files changed

Lines changed: 280 additions & 18 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
schema: spec-driven
2+
created: 2026-05-11
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
## Why
2+
3+
- VS Code Source Control can show a parent workspace repo plus nested Git repos such as `apps/storefront` and `apps/backend`.
4+
- Starting a Guardex lane from the Active Agents sidebar previously defaulted to the workspace folder, so users could not choose the nested repo that owns the visible `main` branch they want to keep stable.
5+
- The launcher should make the selected nested repo the command cwd, allowing `gx branch start` to create an isolated `agent/*` branch/worktree for that repo without switching its visible `main` checkout.
6+
7+
## What Changes
8+
9+
- Discover nested Git repos under workspace folders with a bounded filesystem scan that skips managed worktrees and build/dependency folders.
10+
- Prompt for the target repo when the workspace contains more than one Git repo, including nested repos.
11+
- Keep the extension template copy in sync and cover nested repo targeting with a focused Active Agents regression.
12+
13+
## Impact
14+
15+
- Affects the VS Code Active Agents `Start agent` command only.
16+
- Single-repo workspaces keep the previous no-picker flow.
17+
- The scan is depth-limited to avoid walking large dependency/build trees.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
## ADDED Requirements
2+
3+
### Requirement: VS Code Active Agents nested repo targeting
4+
The VS Code Active Agents `Start agent` command SHALL allow users to target a nested Git repository discovered below the workspace root.
5+
6+
#### Scenario: Workspace has nested storefront and backend repos
7+
- **WHEN** the workspace contains nested Git repositories such as `apps/storefront` and `apps/backend`
8+
- **AND** the user runs `Start agent` from the Active Agents view
9+
- **THEN** the extension prompts for the target Git repo
10+
- **AND** the spawned terminal uses the selected nested repo as its cwd
11+
- **AND** the launcher command creates a Guardex agent branch/worktree for that nested repo instead of changing the visible nested repo's `main` checkout in place.
12+
13+
#### Scenario: Workspace has one Git repo
14+
- **WHEN** only one Git repo is available in the workspace
15+
- **THEN** the extension keeps the existing direct start flow without an unnecessary picker.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
## Definition of Done
2+
3+
This change is complete only when **all** of the following are true:
4+
5+
- Every checkbox below is checked.
6+
- The agent branch reaches `MERGED` state on `origin` and the PR URL + state are recorded in the completion handoff.
7+
- 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.
8+
9+
## Handoff
10+
11+
- Handoff: change=`agent-codex-codex-task-2026-05-11-15-20-2`; branch=`agent/codex/codex-task-2026-05-11-15-20-2`; scope=`VS Code Active Agents nested repo start picker`; action=`finish cleanup after verification`.
12+
- Copy prompt: Continue `agent-codex-codex-task-2026-05-11-15-20-2` on branch `agent/codex/codex-task-2026-05-11-15-20-2`. Work inside the existing sandbox, review `openspec/changes/agent-codex-codex-task-2026-05-11-15-20-2/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/codex-task-2026-05-11-15-20-2 --base main --via-pr --wait-for-merge --cleanup`.
13+
14+
## 1. Specification
15+
16+
- [x] 1.1 Finalize proposal scope and acceptance criteria for `agent-codex-codex-task-2026-05-11-15-20-2`.
17+
- [x] 1.2 Define normative requirements in `specs/codex-task/spec.md`.
18+
19+
## 2. Implementation
20+
21+
- [x] 2.1 Implement scoped behavior changes.
22+
- [x] 2.2 Add/update focused regression coverage.
23+
24+
## 3. Verification
25+
26+
- [x] 3.1 Run targeted project verification commands.
27+
- [x] 3.2 Run `openspec validate agent-codex-codex-task-2026-05-11-15-20-2 --type change --strict`.
28+
- [x] 3.3 Run `openspec validate --specs`.
29+
30+
## 4. Cleanup (mandatory; run before claiming completion)
31+
32+
- [ ] 4.1 Run the cleanup pipeline: `gx branch finish --branch agent/<your-name>/<branch-slug> --base dev --via-pr --wait-for-merge --cleanup`. This handles commit -> push -> PR create -> merge wait -> worktree prune in one invocation.
33+
- [ ] 4.2 Record the PR URL and final merge state (`MERGED`) in the completion handoff.
34+
- [ ] 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).

templates/vscode/guardex-active-agents/extension.js

Lines changed: 85 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1532,6 +1532,82 @@ function readPackageJson(repoRoot) {
15321532
}
15331533
}
15341534

1535+
function hasGitMarker(dirPath) {
1536+
return fs.existsSync(path.join(dirPath, '.git'));
1537+
}
1538+
1539+
function shouldSkipRepoDiscoveryDir(dirName) {
1540+
return new Set([
1541+
'.git',
1542+
'.omx',
1543+
'.omc',
1544+
'node_modules',
1545+
'dist',
1546+
'build',
1547+
'.next',
1548+
]).has(dirName);
1549+
}
1550+
1551+
function discoverNestedGitRepoRoots(rootPath, maxDepth = 3) {
1552+
const discovered = [];
1553+
1554+
function visit(dirPath, depth) {
1555+
if (depth > maxDepth) return;
1556+
let entries = [];
1557+
try {
1558+
entries = fs.readdirSync(dirPath, { withFileTypes: true });
1559+
} catch (_error) {
1560+
return;
1561+
}
1562+
1563+
for (const entry of entries) {
1564+
if (!entry.isDirectory() || shouldSkipRepoDiscoveryDir(entry.name)) {
1565+
continue;
1566+
}
1567+
const childPath = path.join(dirPath, entry.name);
1568+
if (hasGitMarker(childPath)) {
1569+
discovered.push(childPath);
1570+
continue;
1571+
}
1572+
visit(childPath, depth + 1);
1573+
}
1574+
}
1575+
1576+
visit(rootPath, 1);
1577+
return discovered;
1578+
}
1579+
1580+
function discoverWorkspaceRepoRoots() {
1581+
const workspaceFolders = vscode.workspace.workspaceFolders || [];
1582+
const seen = new Set();
1583+
const roots = [];
1584+
1585+
for (const folder of workspaceFolders) {
1586+
const rootPath = folder?.uri?.fsPath;
1587+
if (!rootPath || seen.has(rootPath)) {
1588+
continue;
1589+
}
1590+
seen.add(rootPath);
1591+
roots.push(rootPath);
1592+
1593+
for (const nestedRoot of discoverNestedGitRepoRoots(rootPath)) {
1594+
if (seen.has(nestedRoot)) {
1595+
continue;
1596+
}
1597+
seen.add(nestedRoot);
1598+
roots.push(nestedRoot);
1599+
}
1600+
}
1601+
1602+
return roots;
1603+
}
1604+
1605+
function repoPickLabel(repoRoot) {
1606+
const parent = path.basename(path.dirname(repoRoot));
1607+
const base = path.basename(repoRoot);
1608+
return parent ? `${parent}/${base}` : base;
1609+
}
1610+
15351611
function resolveStartAgentCommand(repoRoot, details) {
15361612
const taskArg = shellQuote(details.taskName);
15371613
const agentArg = shellQuote(details.agentName);
@@ -2822,23 +2898,23 @@ function resolveSessionActivityIconId(activityKind) {
28222898
}
28232899

28242900
async function pickRepoRoot() {
2825-
const workspaceFolders = vscode.workspace.workspaceFolders || [];
2826-
if (workspaceFolders.length === 0) {
2901+
const repoRoots = discoverWorkspaceRepoRoots();
2902+
if (repoRoots.length === 0) {
28272903
vscode.window.showInformationMessage?.('Open a Guardex workspace folder to start an agent.');
28282904
return null;
28292905
}
28302906

2831-
if (workspaceFolders.length === 1) {
2832-
return workspaceFolders[0].uri.fsPath;
2907+
if (repoRoots.length === 1) {
2908+
return repoRoots[0];
28332909
}
28342910

2835-
const picks = workspaceFolders.map((folder) => ({
2836-
label: path.basename(folder.uri.fsPath),
2837-
description: folder.uri.fsPath,
2838-
repoRoot: folder.uri.fsPath,
2911+
const picks = repoRoots.map((repoRoot) => ({
2912+
label: repoPickLabel(repoRoot),
2913+
description: repoRoot,
2914+
repoRoot,
28392915
}));
28402916
const selection = await vscode.window.showQuickPick?.(picks, {
2841-
placeHolder: 'Select the Guardex repo where the Start agent launcher should run.',
2917+
placeHolder: 'Select the Git repo where the Start agent launcher should run.',
28422918
});
28432919
return selection?.repoRoot || null;
28442920
}

test/vscode-active-agents-session-state.test.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1861,6 +1861,48 @@ test('active-agents extension startAgent command falls back to gx branch start w
18611861
}
18621862
});
18631863

1864+
test('active-agents extension startAgent can target a nested Git repo', async () => {
1865+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-start-agent-nested-'));
1866+
const storefrontRoot = path.join(tempRoot, 'apps', 'storefront');
1867+
const backendRoot = path.join(tempRoot, 'apps', 'backend');
1868+
fs.mkdirSync(path.join(storefrontRoot, '.git'), { recursive: true });
1869+
fs.mkdirSync(path.join(backendRoot, '.git'), { recursive: true });
1870+
const { registrations, vscode } = createMockVscode(tempRoot);
1871+
registrations.quickPickResponse = {
1872+
label: 'apps/storefront',
1873+
description: storefrontRoot,
1874+
repoRoot: storefrontRoot,
1875+
};
1876+
registrations.inputResponses.push('nested task', 'codex');
1877+
const extension = loadExtensionWithMockVscode(vscode);
1878+
const context = { subscriptions: [] };
1879+
1880+
extension.activate(context);
1881+
1882+
await registrations.commands.get('gitguardex.activeAgents.startAgent')();
1883+
1884+
assert.equal(registrations.quickPickCalls.length, 1);
1885+
assert.deepEqual(
1886+
registrations.quickPickCalls[0].items.map((item) => item.repoRoot),
1887+
[tempRoot, backendRoot, storefrontRoot],
1888+
);
1889+
assert.equal(registrations.terminals.length, 1);
1890+
assert.deepEqual(registrations.terminals[0].options, {
1891+
name: `GitGuardex: ${path.basename(storefrontRoot)}`,
1892+
cwd: storefrontRoot,
1893+
});
1894+
assert.deepEqual(registrations.terminals[0].sentTexts, [
1895+
{
1896+
text: "gx branch start 'nested task' 'codex'",
1897+
addNewLine: true,
1898+
},
1899+
]);
1900+
1901+
for (const subscription of context.subscriptions) {
1902+
subscription.dispose?.();
1903+
}
1904+
});
1905+
18641906
test('active-agents extension groups live sessions under a repo node', async () => {
18651907
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-live-view-'));
18661908
const sessionPath = writeSessionRecord(tempRoot, sessionSchema.buildSessionRecord({

vscode/guardex-active-agents/extension.js

Lines changed: 85 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1532,6 +1532,82 @@ function readPackageJson(repoRoot) {
15321532
}
15331533
}
15341534

1535+
function hasGitMarker(dirPath) {
1536+
return fs.existsSync(path.join(dirPath, '.git'));
1537+
}
1538+
1539+
function shouldSkipRepoDiscoveryDir(dirName) {
1540+
return new Set([
1541+
'.git',
1542+
'.omx',
1543+
'.omc',
1544+
'node_modules',
1545+
'dist',
1546+
'build',
1547+
'.next',
1548+
]).has(dirName);
1549+
}
1550+
1551+
function discoverNestedGitRepoRoots(rootPath, maxDepth = 3) {
1552+
const discovered = [];
1553+
1554+
function visit(dirPath, depth) {
1555+
if (depth > maxDepth) return;
1556+
let entries = [];
1557+
try {
1558+
entries = fs.readdirSync(dirPath, { withFileTypes: true });
1559+
} catch (_error) {
1560+
return;
1561+
}
1562+
1563+
for (const entry of entries) {
1564+
if (!entry.isDirectory() || shouldSkipRepoDiscoveryDir(entry.name)) {
1565+
continue;
1566+
}
1567+
const childPath = path.join(dirPath, entry.name);
1568+
if (hasGitMarker(childPath)) {
1569+
discovered.push(childPath);
1570+
continue;
1571+
}
1572+
visit(childPath, depth + 1);
1573+
}
1574+
}
1575+
1576+
visit(rootPath, 1);
1577+
return discovered;
1578+
}
1579+
1580+
function discoverWorkspaceRepoRoots() {
1581+
const workspaceFolders = vscode.workspace.workspaceFolders || [];
1582+
const seen = new Set();
1583+
const roots = [];
1584+
1585+
for (const folder of workspaceFolders) {
1586+
const rootPath = folder?.uri?.fsPath;
1587+
if (!rootPath || seen.has(rootPath)) {
1588+
continue;
1589+
}
1590+
seen.add(rootPath);
1591+
roots.push(rootPath);
1592+
1593+
for (const nestedRoot of discoverNestedGitRepoRoots(rootPath)) {
1594+
if (seen.has(nestedRoot)) {
1595+
continue;
1596+
}
1597+
seen.add(nestedRoot);
1598+
roots.push(nestedRoot);
1599+
}
1600+
}
1601+
1602+
return roots;
1603+
}
1604+
1605+
function repoPickLabel(repoRoot) {
1606+
const parent = path.basename(path.dirname(repoRoot));
1607+
const base = path.basename(repoRoot);
1608+
return parent ? `${parent}/${base}` : base;
1609+
}
1610+
15351611
function resolveStartAgentCommand(repoRoot, details) {
15361612
const taskArg = shellQuote(details.taskName);
15371613
const agentArg = shellQuote(details.agentName);
@@ -2822,23 +2898,23 @@ function resolveSessionActivityIconId(activityKind) {
28222898
}
28232899

28242900
async function pickRepoRoot() {
2825-
const workspaceFolders = vscode.workspace.workspaceFolders || [];
2826-
if (workspaceFolders.length === 0) {
2901+
const repoRoots = discoverWorkspaceRepoRoots();
2902+
if (repoRoots.length === 0) {
28272903
vscode.window.showInformationMessage?.('Open a Guardex workspace folder to start an agent.');
28282904
return null;
28292905
}
28302906

2831-
if (workspaceFolders.length === 1) {
2832-
return workspaceFolders[0].uri.fsPath;
2907+
if (repoRoots.length === 1) {
2908+
return repoRoots[0];
28332909
}
28342910

2835-
const picks = workspaceFolders.map((folder) => ({
2836-
label: path.basename(folder.uri.fsPath),
2837-
description: folder.uri.fsPath,
2838-
repoRoot: folder.uri.fsPath,
2911+
const picks = repoRoots.map((repoRoot) => ({
2912+
label: repoPickLabel(repoRoot),
2913+
description: repoRoot,
2914+
repoRoot,
28392915
}));
28402916
const selection = await vscode.window.showQuickPick?.(picks, {
2841-
placeHolder: 'Select the Guardex repo where the Start agent launcher should run.',
2917+
placeHolder: 'Select the Git repo where the Start agent launcher should run.',
28422918
});
28432919
return selection?.repoRoot || null;
28442920
}

0 commit comments

Comments
 (0)