Skip to content

Commit bfaa053

Browse files
NagyViktNagyViktOmX
authored
Target nested SCM repos from Active Agents (#562)
VS Code can expose the outer monorepo plus nested storefront/backend repositories at the same time, so the Start Agent command now infers the active repo and launches through gx with an explicit target. The ambiguous picker also shows branch and dirty-state cues before launching. Constraint: Start Agent must not switch or mutate the outer checkout when the user is working inside a nested repository. Rejected: Keep scripts/codex-agent.sh and npm run agent:codex launcher fallbacks | they do not provide an explicit nested target and preserve the wrong-root failure mode. Confidence: high Scope-risk: narrow Directive: Keep source and template extension files in parity when changing bundled Active Agents behavior. Tested: node --test test/vscode-active-agents-session-state.test.js Tested: node --check vscode/guardex-active-agents/extension.js && node --check templates/vscode/guardex-active-agents/extension.js && diff -u vscode/guardex-active-agents/extension.js templates/vscode/guardex-active-agents/extension.js Tested: openspec validate agent-codex-active-agents-selected-scm-nested-start-2026-05-11-15-59 --type change --strict Tested: openspec validate --specs Not-tested: Manual VS Code SCM selection against a real multi-root window. Co-authored-by: NagyVikt <nagy.viktordp@gmail.com> Co-authored-by: OmX <omx@oh-my-codex.dev>
1 parent 7f75922 commit bfaa053

7 files changed

Lines changed: 230 additions & 48 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: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
## Why
2+
3+
VS Code Source Control shows the primary repo, nested storefront repo, and nested backend repo as separate SCM roots. The Active Agents Start Agent command must launch in the repo the user is actually working in, instead of silently falling back to the outer workspace root.
4+
5+
## What Changes
6+
7+
- Prefer the active SCM/editor repo root when multiple workspace Git repos are discovered.
8+
- Launch agents through the canonical `gx agents start --target <repo>` surface so nested repos do not require changing the outer checkout.
9+
- Add branch and dirty-state cues to the repo picker when the active repo cannot be inferred.
10+
- Cover the selected nested repo and active-editor repo paths with focused VS Code extension tests.
11+
12+
## Impact
13+
14+
The change is limited to the bundled Active Agents VS Code extension and its install template. Existing tree rendering and session inspection behavior are unchanged.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
## ADDED Requirements
2+
3+
### Requirement: Start Agent targets the selected workspace repository
4+
The Active Agents VS Code extension SHALL start new agents in the selected workspace Git repository rather than assuming the outer workspace root.
5+
6+
#### Scenario: Active editor belongs to a nested Git repository
7+
- **GIVEN** a workspace contains an outer folder and nested Git repositories
8+
- **AND** the active editor is inside one nested Git repository
9+
- **WHEN** the user runs `gitguardex.activeAgents.startAgent`
10+
- **THEN** the extension launches the terminal in that nested repository
11+
- **AND** the command uses `gx agents start --target <nested-repo>`
12+
- **AND** no repository picker is shown.
13+
14+
#### Scenario: Multiple repositories remain ambiguous
15+
- **GIVEN** a workspace contains multiple Git repositories
16+
- **AND** no active SCM/editor repository can be inferred
17+
- **WHEN** the user runs `gitguardex.activeAgents.startAgent`
18+
- **THEN** the extension shows a repository picker
19+
- **AND** each pick shows the repository path plus branch and dirty-state cues
20+
- **AND** the selected repository is passed to `gx agents start --target <selected-repo>`.
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
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-active-agents-selected-scm-nested-start-2026-05-11-15-59`; branch=`agent/codex/active-agents-selected-scm-nested-start-2026-05-11-15-59`; scope=`VS Code Active Agents Start Agent nested SCM targeting`; action=`finish cleanup after verification if resumed`.
12+
- Copy prompt: Continue `agent-codex-active-agents-selected-scm-nested-start-2026-05-11-15-59` on branch `agent/codex/active-agents-selected-scm-nested-start-2026-05-11-15-59`. Work inside the existing sandbox, review this tasks file, continue from the current state instead of creating a new sandbox, and when the work is done run `gx branch finish --branch agent/codex/active-agents-selected-scm-nested-start-2026-05-11-15-59 --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-active-agents-selected-scm-nested-start-2026-05-11-15-59`.
17+
- [x] 1.2 Define normative requirements in `specs/active-agents-selected-scm-nested-start/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+
- `node --test test/vscode-active-agents-session-state.test.js` -> pass, 61/61.
28+
- `node --check vscode/guardex-active-agents/extension.js && node --check templates/vscode/guardex-active-agents/extension.js && diff -u vscode/guardex-active-agents/extension.js templates/vscode/guardex-active-agents/extension.js` -> pass.
29+
- [x] 3.2 Run `openspec validate agent-codex-active-agents-selected-scm-nested-start-2026-05-11-15-59 --type change --strict` -> valid.
30+
- [x] 3.3 Run `openspec validate --specs` -> no items found to validate.
31+
32+
## 4. Cleanup (mandatory; run before claiming completion)
33+
34+
- [x] 4.1 Run the cleanup pipeline: `gx branch finish --branch agent/codex/active-agents-selected-scm-nested-start-2026-05-11-15-59 --base main --via-pr --wait-for-merge --cleanup`. This handles commit -> push -> PR create -> merge wait -> worktree prune in one invocation.
35+
- [x] 4.2 Record the PR URL and final merge state (`MERGED`) in the completion handoff.
36+
- [x] 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: 53 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1523,14 +1523,6 @@ function shellQuote(value) {
15231523
return `'${normalized.replace(/'/g, "'\"'\"'")}'`;
15241524
}
15251525

1526-
function readPackageJson(repoRoot) {
1527-
const packageJsonPath = path.join(repoRoot, 'package.json');
1528-
try {
1529-
return JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
1530-
} catch (_error) {
1531-
return null;
1532-
}
1533-
}
15341526

15351527
function hasGitMarker(dirPath) {
15361528
return fs.existsSync(path.join(dirPath, '.git'));
@@ -1608,20 +1600,56 @@ function repoPickLabel(repoRoot) {
16081600
return parent ? `${parent}/${base}` : base;
16091601
}
16101602

1611-
function resolveStartAgentCommand(repoRoot, details) {
1612-
const taskArg = shellQuote(details.taskName);
1613-
const agentArg = shellQuote(details.agentName);
1614-
const localCodexAgentPath = path.join(repoRoot, 'scripts', 'codex-agent.sh');
1615-
if (fs.existsSync(localCodexAgentPath)) {
1616-
return `bash ./scripts/codex-agent.sh ${taskArg} ${agentArg}`;
1603+
function readGitOutput(repoRoot, args) {
1604+
try {
1605+
return cp.execFileSync('git', ['-C', repoRoot, ...args], {
1606+
encoding: 'utf8',
1607+
stdio: ['ignore', 'pipe', 'ignore'],
1608+
}).trim();
1609+
} catch {
1610+
return null;
16171611
}
1612+
}
1613+
1614+
function repoGitSummary(repoRoot) {
1615+
const branch = readGitOutput(repoRoot, ['branch', '--show-current']) || 'unknown';
1616+
const status = readGitOutput(repoRoot, ['status', '--porcelain']);
1617+
return {
1618+
branch,
1619+
dirty: status === null ? 'unknown' : status.length > 0 ? 'dirty' : 'clean',
1620+
};
1621+
}
1622+
1623+
function repoPickDescription(repoRoot) {
1624+
const summary = repoGitSummary(repoRoot);
1625+
return `${summary.branch} · ${summary.dirty}`;
1626+
}
16181627

1619-
const agentCodexScript = readPackageJson(repoRoot)?.scripts?.['agent:codex'];
1620-
if (typeof agentCodexScript === 'string' && agentCodexScript.trim().length > 0) {
1621-
return `npm run agent:codex -- ${taskArg} ${agentArg}`;
1628+
function findRepoRootForPath(repoRoots, candidatePath) {
1629+
const normalizedCandidatePath = normalizeAbsolutePath(candidatePath);
1630+
if (!normalizedCandidatePath) {
1631+
return null;
16221632
}
16231633

1624-
return `gx branch start ${taskArg} ${agentArg}`;
1634+
return repoRoots
1635+
.filter((repoRoot) => isPathWithin(repoRoot, normalizedCandidatePath))
1636+
.sort((left, right) => right.length - left.length)[0] || null;
1637+
}
1638+
1639+
function activeScmRootPath() {
1640+
const sourceControl = vscode.scm?.activeSourceControl;
1641+
return sourceControl?.rootUri?.fsPath || sourceControl?.rootUri?.path || '';
1642+
}
1643+
1644+
function preferredRepoRoot(repoRoots) {
1645+
return findRepoRootForPath(repoRoots, activeScmRootPath())
1646+
|| findRepoRootForPath(repoRoots, vscode.window.activeTextEditor?.document?.uri?.fsPath);
1647+
}
1648+
1649+
function resolveStartAgentCommand(repoRoot, details) {
1650+
const taskArg = shellQuote(details.taskName);
1651+
const agentArg = shellQuote(details.agentName);
1652+
return `gx agents start ${taskArg} --agent ${agentArg} --target ${shellQuote(repoRoot)}`;
16251653
}
16261654

16271655
function sessionTaskLabel(session) {
@@ -2908,9 +2936,15 @@ async function pickRepoRoot() {
29082936
return repoRoots[0];
29092937
}
29102938

2939+
const selectedRepoRoot = preferredRepoRoot(repoRoots);
2940+
if (selectedRepoRoot) {
2941+
return selectedRepoRoot;
2942+
}
2943+
29112944
const picks = repoRoots.map((repoRoot) => ({
29122945
label: repoPickLabel(repoRoot),
2913-
description: repoRoot,
2946+
description: repoPickDescription(repoRoot),
2947+
detail: repoRoot,
29142948
repoRoot,
29152949
}));
29162950
const selection = await vscode.window.showQuickPick?.(picks, {

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

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1799,10 +1799,8 @@ test('active-agents extension self-heals managed repo-scan ignores on activation
17991799
}
18001800
});
18011801

1802-
test('active-agents extension startAgent command prefers the Guardex launcher in a terminal', async () => {
1802+
test('active-agents extension startAgent command uses gx agents start with a target repo', async () => {
18031803
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-start-agent-'));
1804-
fs.mkdirSync(path.join(tempRoot, 'scripts'), { recursive: true });
1805-
fs.writeFileSync(path.join(tempRoot, 'scripts', 'codex-agent.sh'), '#!/usr/bin/env bash\n', 'utf8');
18061804
const { registrations, vscode } = createMockVscode(tempRoot);
18071805
registrations.inputResponses.push('demo task', 'codex');
18081806
const extension = loadExtensionWithMockVscode(vscode);
@@ -1820,7 +1818,7 @@ test('active-agents extension startAgent command prefers the Guardex launcher in
18201818
assert.equal(registrations.terminals[0].shown, true);
18211819
assert.deepEqual(registrations.terminals[0].sentTexts, [
18221820
{
1823-
text: "bash ./scripts/codex-agent.sh 'demo task' 'codex'",
1821+
text: `gx agents start 'demo task' --agent 'codex' --target '${tempRoot}'`,
18241822
addNewLine: true,
18251823
},
18261824
]);
@@ -1831,7 +1829,7 @@ test('active-agents extension startAgent command prefers the Guardex launcher in
18311829
}
18321830
});
18331831

1834-
test('active-agents extension startAgent command falls back to gx branch start without a Guardex launcher', async () => {
1832+
test('active-agents extension startAgent command uses gx agents start for plain repos', async () => {
18351833
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-start-agent-fallback-'));
18361834
const { registrations, vscode } = createMockVscode(tempRoot);
18371835
registrations.inputResponses.push('demo task', 'codex');
@@ -1850,7 +1848,7 @@ test('active-agents extension startAgent command falls back to gx branch start w
18501848
assert.equal(registrations.terminals[0].shown, true);
18511849
assert.deepEqual(registrations.terminals[0].sentTexts, [
18521850
{
1853-
text: "gx branch start 'demo task' 'codex'",
1851+
text: `gx agents start 'demo task' --agent 'codex' --target '${tempRoot}'`,
18541852
addNewLine: true,
18551853
},
18561854
]);
@@ -1865,12 +1863,13 @@ test('active-agents extension startAgent can target a nested Git repo', async ()
18651863
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-start-agent-nested-'));
18661864
const storefrontRoot = path.join(tempRoot, 'apps', 'storefront');
18671865
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 });
1866+
initGitRepo(storefrontRoot);
1867+
initGitRepo(backendRoot);
1868+
fs.writeFileSync(path.join(storefrontRoot, 'dirty.txt'), 'changed\n', 'utf8');
18701869
const { registrations, vscode } = createMockVscode(tempRoot);
18711870
registrations.quickPickResponse = {
18721871
label: 'apps/storefront',
1873-
description: storefrontRoot,
1872+
description: 'main · dirty',
18741873
repoRoot: storefrontRoot,
18751874
};
18761875
registrations.inputResponses.push('nested task', 'codex');
@@ -1886,14 +1885,57 @@ test('active-agents extension startAgent can target a nested Git repo', async ()
18861885
registrations.quickPickCalls[0].items.map((item) => item.repoRoot),
18871886
[tempRoot, backendRoot, storefrontRoot],
18881887
);
1888+
assert.deepEqual(
1889+
registrations.quickPickCalls[0].items.map((item) => item.detail),
1890+
[tempRoot, backendRoot, storefrontRoot],
1891+
);
1892+
const storefrontPick = registrations.quickPickCalls[0].items.find((item) => item.repoRoot === storefrontRoot);
1893+
assert.ok(storefrontPick.description.endsWith(' · dirty'));
1894+
assert.equal(registrations.terminals.length, 1);
1895+
assert.deepEqual(registrations.terminals[0].options, {
1896+
name: `GitGuardex: ${path.basename(storefrontRoot)}`,
1897+
cwd: storefrontRoot,
1898+
});
1899+
assert.deepEqual(registrations.terminals[0].sentTexts, [
1900+
{
1901+
text: `gx agents start 'nested task' --agent 'codex' --target '${storefrontRoot}'`,
1902+
addNewLine: true,
1903+
},
1904+
]);
1905+
1906+
for (const subscription of context.subscriptions) {
1907+
subscription.dispose?.();
1908+
}
1909+
});
1910+
1911+
test('active-agents extension startAgent defaults to the active editor nested repo', async () => {
1912+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-start-agent-active-editor-'));
1913+
const storefrontRoot = path.join(tempRoot, 'apps', 'storefront');
1914+
const backendRoot = path.join(tempRoot, 'apps', 'backend');
1915+
const editorPath = path.join(storefrontRoot, 'src', 'home.tsx');
1916+
initGitRepo(storefrontRoot);
1917+
initGitRepo(backendRoot);
1918+
fs.mkdirSync(path.dirname(editorPath), { recursive: true });
1919+
fs.writeFileSync(editorPath, 'export {};\n', 'utf8');
1920+
const { registrations, vscode } = createMockVscode(tempRoot);
1921+
vscode.window.activeTextEditor = { document: { uri: vscode.Uri.file(editorPath) } };
1922+
registrations.inputResponses.push('active editor task', 'codex');
1923+
const extension = loadExtensionWithMockVscode(vscode);
1924+
const context = { subscriptions: [] };
1925+
1926+
extension.activate(context);
1927+
1928+
await registrations.commands.get('gitguardex.activeAgents.startAgent')();
1929+
1930+
assert.deepEqual(registrations.quickPickCalls, []);
18891931
assert.equal(registrations.terminals.length, 1);
18901932
assert.deepEqual(registrations.terminals[0].options, {
18911933
name: `GitGuardex: ${path.basename(storefrontRoot)}`,
18921934
cwd: storefrontRoot,
18931935
});
18941936
assert.deepEqual(registrations.terminals[0].sentTexts, [
18951937
{
1896-
text: "gx branch start 'nested task' 'codex'",
1938+
text: `gx agents start 'active editor task' --agent 'codex' --target '${storefrontRoot}'`,
18971939
addNewLine: true,
18981940
},
18991941
]);

vscode/guardex-active-agents/extension.js

Lines changed: 53 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1523,14 +1523,6 @@ function shellQuote(value) {
15231523
return `'${normalized.replace(/'/g, "'\"'\"'")}'`;
15241524
}
15251525

1526-
function readPackageJson(repoRoot) {
1527-
const packageJsonPath = path.join(repoRoot, 'package.json');
1528-
try {
1529-
return JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
1530-
} catch (_error) {
1531-
return null;
1532-
}
1533-
}
15341526

15351527
function hasGitMarker(dirPath) {
15361528
return fs.existsSync(path.join(dirPath, '.git'));
@@ -1608,20 +1600,56 @@ function repoPickLabel(repoRoot) {
16081600
return parent ? `${parent}/${base}` : base;
16091601
}
16101602

1611-
function resolveStartAgentCommand(repoRoot, details) {
1612-
const taskArg = shellQuote(details.taskName);
1613-
const agentArg = shellQuote(details.agentName);
1614-
const localCodexAgentPath = path.join(repoRoot, 'scripts', 'codex-agent.sh');
1615-
if (fs.existsSync(localCodexAgentPath)) {
1616-
return `bash ./scripts/codex-agent.sh ${taskArg} ${agentArg}`;
1603+
function readGitOutput(repoRoot, args) {
1604+
try {
1605+
return cp.execFileSync('git', ['-C', repoRoot, ...args], {
1606+
encoding: 'utf8',
1607+
stdio: ['ignore', 'pipe', 'ignore'],
1608+
}).trim();
1609+
} catch {
1610+
return null;
16171611
}
1612+
}
1613+
1614+
function repoGitSummary(repoRoot) {
1615+
const branch = readGitOutput(repoRoot, ['branch', '--show-current']) || 'unknown';
1616+
const status = readGitOutput(repoRoot, ['status', '--porcelain']);
1617+
return {
1618+
branch,
1619+
dirty: status === null ? 'unknown' : status.length > 0 ? 'dirty' : 'clean',
1620+
};
1621+
}
1622+
1623+
function repoPickDescription(repoRoot) {
1624+
const summary = repoGitSummary(repoRoot);
1625+
return `${summary.branch} · ${summary.dirty}`;
1626+
}
16181627

1619-
const agentCodexScript = readPackageJson(repoRoot)?.scripts?.['agent:codex'];
1620-
if (typeof agentCodexScript === 'string' && agentCodexScript.trim().length > 0) {
1621-
return `npm run agent:codex -- ${taskArg} ${agentArg}`;
1628+
function findRepoRootForPath(repoRoots, candidatePath) {
1629+
const normalizedCandidatePath = normalizeAbsolutePath(candidatePath);
1630+
if (!normalizedCandidatePath) {
1631+
return null;
16221632
}
16231633

1624-
return `gx branch start ${taskArg} ${agentArg}`;
1634+
return repoRoots
1635+
.filter((repoRoot) => isPathWithin(repoRoot, normalizedCandidatePath))
1636+
.sort((left, right) => right.length - left.length)[0] || null;
1637+
}
1638+
1639+
function activeScmRootPath() {
1640+
const sourceControl = vscode.scm?.activeSourceControl;
1641+
return sourceControl?.rootUri?.fsPath || sourceControl?.rootUri?.path || '';
1642+
}
1643+
1644+
function preferredRepoRoot(repoRoots) {
1645+
return findRepoRootForPath(repoRoots, activeScmRootPath())
1646+
|| findRepoRootForPath(repoRoots, vscode.window.activeTextEditor?.document?.uri?.fsPath);
1647+
}
1648+
1649+
function resolveStartAgentCommand(repoRoot, details) {
1650+
const taskArg = shellQuote(details.taskName);
1651+
const agentArg = shellQuote(details.agentName);
1652+
return `gx agents start ${taskArg} --agent ${agentArg} --target ${shellQuote(repoRoot)}`;
16251653
}
16261654

16271655
function sessionTaskLabel(session) {
@@ -2908,9 +2936,15 @@ async function pickRepoRoot() {
29082936
return repoRoots[0];
29092937
}
29102938

2939+
const selectedRepoRoot = preferredRepoRoot(repoRoots);
2940+
if (selectedRepoRoot) {
2941+
return selectedRepoRoot;
2942+
}
2943+
29112944
const picks = repoRoots.map((repoRoot) => ({
29122945
label: repoPickLabel(repoRoot),
2913-
description: repoRoot,
2946+
description: repoPickDescription(repoRoot),
2947+
detail: repoRoot,
29142948
repoRoot,
29152949
}));
29162950
const selection = await vscode.window.showQuickPick?.(picks, {

0 commit comments

Comments
 (0)