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,2 @@
schema: spec-driven
created: 2026-05-11
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
## Why

- VS Code Source Control can show a parent workspace repo plus nested Git repos such as `apps/storefront` and `apps/backend`.
- 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.
- 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.

## What Changes

- Discover nested Git repos under workspace folders with a bounded filesystem scan that skips managed worktrees and build/dependency folders.
- Prompt for the target repo when the workspace contains more than one Git repo, including nested repos.
- Keep the extension template copy in sync and cover nested repo targeting with a focused Active Agents regression.

## Impact

- Affects the VS Code Active Agents `Start agent` command only.
- Single-repo workspaces keep the previous no-picker flow.
- The scan is depth-limited to avoid walking large dependency/build trees.
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
## ADDED Requirements

### Requirement: VS Code Active Agents nested repo targeting
The VS Code Active Agents `Start agent` command SHALL allow users to target a nested Git repository discovered below the workspace root.

#### Scenario: Workspace has nested storefront and backend repos
- **WHEN** the workspace contains nested Git repositories such as `apps/storefront` and `apps/backend`
- **AND** the user runs `Start agent` from the Active Agents view
- **THEN** the extension prompts for the target Git repo
- **AND** the spawned terminal uses the selected nested repo as its cwd
- **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.

#### Scenario: Workspace has one Git repo
- **WHEN** only one Git repo is available in the workspace
- **THEN** the extension keeps the existing direct start flow without an unnecessary picker.
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
## 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-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`.
- 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`.

## 1. Specification

- [x] 1.1 Finalize proposal scope and acceptance criteria for `agent-codex-codex-task-2026-05-11-15-20-2`.
- [x] 1.2 Define normative requirements in `specs/codex-task/spec.md`.

## 2. Implementation

- [x] 2.1 Implement scoped behavior changes.
- [x] 2.2 Add/update focused regression coverage.

## 3. Verification

- [x] 3.1 Run targeted project verification commands.
- [x] 3.2 Run `openspec validate agent-codex-codex-task-2026-05-11-15-20-2 --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/<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.
- [ ] 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).
94 changes: 85 additions & 9 deletions templates/vscode/guardex-active-agents/extension.js
Original file line number Diff line number Diff line change
Expand Up @@ -1532,6 +1532,82 @@ function readPackageJson(repoRoot) {
}
}

function hasGitMarker(dirPath) {
return fs.existsSync(path.join(dirPath, '.git'));
}

function shouldSkipRepoDiscoveryDir(dirName) {
return new Set([
'.git',
'.omx',
'.omc',
'node_modules',
'dist',
'build',
'.next',
]).has(dirName);
}

function discoverNestedGitRepoRoots(rootPath, maxDepth = 3) {
const discovered = [];

function visit(dirPath, depth) {
if (depth > maxDepth) return;
let entries = [];
try {
entries = fs.readdirSync(dirPath, { withFileTypes: true });
} catch (_error) {
return;
}

for (const entry of entries) {
if (!entry.isDirectory() || shouldSkipRepoDiscoveryDir(entry.name)) {
continue;
}
const childPath = path.join(dirPath, entry.name);
if (hasGitMarker(childPath)) {
discovered.push(childPath);
continue;
}
visit(childPath, depth + 1);
}
}

visit(rootPath, 1);
return discovered;
}

function discoverWorkspaceRepoRoots() {
const workspaceFolders = vscode.workspace.workspaceFolders || [];
const seen = new Set();
const roots = [];

for (const folder of workspaceFolders) {
const rootPath = folder?.uri?.fsPath;
if (!rootPath || seen.has(rootPath)) {
continue;
}
seen.add(rootPath);
roots.push(rootPath);

for (const nestedRoot of discoverNestedGitRepoRoots(rootPath)) {
if (seen.has(nestedRoot)) {
continue;
}
seen.add(nestedRoot);
roots.push(nestedRoot);
}
}

return roots;
}

function repoPickLabel(repoRoot) {
const parent = path.basename(path.dirname(repoRoot));
const base = path.basename(repoRoot);
return parent ? `${parent}/${base}` : base;
}

function resolveStartAgentCommand(repoRoot, details) {
const taskArg = shellQuote(details.taskName);
const agentArg = shellQuote(details.agentName);
Expand Down Expand Up @@ -2822,23 +2898,23 @@ function resolveSessionActivityIconId(activityKind) {
}

async function pickRepoRoot() {
const workspaceFolders = vscode.workspace.workspaceFolders || [];
if (workspaceFolders.length === 0) {
const repoRoots = discoverWorkspaceRepoRoots();
if (repoRoots.length === 0) {
vscode.window.showInformationMessage?.('Open a Guardex workspace folder to start an agent.');
return null;
}

if (workspaceFolders.length === 1) {
return workspaceFolders[0].uri.fsPath;
if (repoRoots.length === 1) {
return repoRoots[0];
}

const picks = workspaceFolders.map((folder) => ({
label: path.basename(folder.uri.fsPath),
description: folder.uri.fsPath,
repoRoot: folder.uri.fsPath,
const picks = repoRoots.map((repoRoot) => ({
label: repoPickLabel(repoRoot),
description: repoRoot,
repoRoot,
}));
const selection = await vscode.window.showQuickPick?.(picks, {
placeHolder: 'Select the Guardex repo where the Start agent launcher should run.',
placeHolder: 'Select the Git repo where the Start agent launcher should run.',
});
return selection?.repoRoot || null;
}
Expand Down
42 changes: 42 additions & 0 deletions test/vscode-active-agents-session-state.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1861,6 +1861,48 @@ test('active-agents extension startAgent command falls back to gx branch start w
}
});

test('active-agents extension startAgent can target a nested Git repo', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-start-agent-nested-'));
const storefrontRoot = path.join(tempRoot, 'apps', 'storefront');
const backendRoot = path.join(tempRoot, 'apps', 'backend');
fs.mkdirSync(path.join(storefrontRoot, '.git'), { recursive: true });
fs.mkdirSync(path.join(backendRoot, '.git'), { recursive: true });
const { registrations, vscode } = createMockVscode(tempRoot);
registrations.quickPickResponse = {
label: 'apps/storefront',
description: storefrontRoot,
repoRoot: storefrontRoot,
};
registrations.inputResponses.push('nested task', 'codex');
const extension = loadExtensionWithMockVscode(vscode);
const context = { subscriptions: [] };

extension.activate(context);

await registrations.commands.get('gitguardex.activeAgents.startAgent')();

assert.equal(registrations.quickPickCalls.length, 1);
assert.deepEqual(
registrations.quickPickCalls[0].items.map((item) => item.repoRoot),
[tempRoot, backendRoot, storefrontRoot],
);
assert.equal(registrations.terminals.length, 1);
assert.deepEqual(registrations.terminals[0].options, {
name: `GitGuardex: ${path.basename(storefrontRoot)}`,
cwd: storefrontRoot,
});
assert.deepEqual(registrations.terminals[0].sentTexts, [
{
text: "gx branch start 'nested task' 'codex'",
addNewLine: true,
},
]);

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

test('active-agents extension groups live sessions under a repo node', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-live-view-'));
const sessionPath = writeSessionRecord(tempRoot, sessionSchema.buildSessionRecord({
Expand Down
94 changes: 85 additions & 9 deletions vscode/guardex-active-agents/extension.js
Original file line number Diff line number Diff line change
Expand Up @@ -1532,6 +1532,82 @@ function readPackageJson(repoRoot) {
}
}

function hasGitMarker(dirPath) {
return fs.existsSync(path.join(dirPath, '.git'));
}

function shouldSkipRepoDiscoveryDir(dirName) {
return new Set([
'.git',
'.omx',
'.omc',
'node_modules',
'dist',
'build',
'.next',
]).has(dirName);
}

function discoverNestedGitRepoRoots(rootPath, maxDepth = 3) {
const discovered = [];

function visit(dirPath, depth) {
if (depth > maxDepth) return;
let entries = [];
try {
entries = fs.readdirSync(dirPath, { withFileTypes: true });
} catch (_error) {
return;
}

for (const entry of entries) {
if (!entry.isDirectory() || shouldSkipRepoDiscoveryDir(entry.name)) {
continue;
}
const childPath = path.join(dirPath, entry.name);
if (hasGitMarker(childPath)) {
discovered.push(childPath);
continue;
}
visit(childPath, depth + 1);
}
}

visit(rootPath, 1);
return discovered;
}

function discoverWorkspaceRepoRoots() {
const workspaceFolders = vscode.workspace.workspaceFolders || [];
const seen = new Set();
const roots = [];

for (const folder of workspaceFolders) {
const rootPath = folder?.uri?.fsPath;
if (!rootPath || seen.has(rootPath)) {
continue;
}
seen.add(rootPath);
roots.push(rootPath);

for (const nestedRoot of discoverNestedGitRepoRoots(rootPath)) {
if (seen.has(nestedRoot)) {
continue;
}
seen.add(nestedRoot);
roots.push(nestedRoot);
}
}

return roots;
}

function repoPickLabel(repoRoot) {
const parent = path.basename(path.dirname(repoRoot));
const base = path.basename(repoRoot);
return parent ? `${parent}/${base}` : base;
}

function resolveStartAgentCommand(repoRoot, details) {
const taskArg = shellQuote(details.taskName);
const agentArg = shellQuote(details.agentName);
Expand Down Expand Up @@ -2822,23 +2898,23 @@ function resolveSessionActivityIconId(activityKind) {
}

async function pickRepoRoot() {
const workspaceFolders = vscode.workspace.workspaceFolders || [];
if (workspaceFolders.length === 0) {
const repoRoots = discoverWorkspaceRepoRoots();
if (repoRoots.length === 0) {
vscode.window.showInformationMessage?.('Open a Guardex workspace folder to start an agent.');
return null;
}

if (workspaceFolders.length === 1) {
return workspaceFolders[0].uri.fsPath;
if (repoRoots.length === 1) {
return repoRoots[0];
}

const picks = workspaceFolders.map((folder) => ({
label: path.basename(folder.uri.fsPath),
description: folder.uri.fsPath,
repoRoot: folder.uri.fsPath,
const picks = repoRoots.map((repoRoot) => ({
label: repoPickLabel(repoRoot),
description: repoRoot,
repoRoot,
}));
const selection = await vscode.window.showQuickPick?.(picks, {
placeHolder: 'Select the Guardex repo where the Start agent launcher should run.',
placeHolder: 'Select the Git repo where the Start agent launcher should run.',
});
return selection?.repoRoot || null;
}
Expand Down
Loading