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-04-22
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
## Why

- The Active Agents companion currently installs into a versioned VS Code extension directory and deletes older patch directories on each update.
- VS Code keeps patch-specific extension locations cached across already-open windows, so pruning those directories can leave one window showing `Active Agents Commit` while another window can no longer resolve the companion until reload.

## What Changes

- Install the Active Agents companion into one canonical local extension directory derived from the extension id.
- Refresh a bounded same-major/minor patch compatibility window so already-open windows that still hold a recent older patch path can keep resolving the extension until reload.
- Update the installer output to tell the user to reload each already-open VS Code window after install/update.
- Add focused regression coverage for the new install layout.

## Impact

- Affects only the local VS Code companion install surface under `~/.vscode/extensions` plus installer regression tests.
- Keeps a small bounded set of patch compatibility copies to avoid stale-window breakage during rapid local iteration.
- Does not change the runtime feature set of the extension itself.
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
## ADDED Requirements

### Requirement: Active Agents local installs use a canonical extension directory
The Active Agents install flow SHALL publish the companion into one canonical local VS Code extension directory instead of making the newest versioned patch directory the only live copy.

#### Scenario: Installer refreshes the canonical install path
- **WHEN** `scripts/install-vscode-active-agents-extension.js` installs the companion
- **THEN** it writes the current extension payload into a stable local extension directory derived from the extension id
- **AND** that directory contains the current manifest, runtime entrypoint, session schema, and packaged assets
- **AND** focused regression coverage validates the installed payload.

### Requirement: Recent patch-version install paths stay loadable until reload
The Active Agents install flow SHALL keep recent same-major/minor patch-version install paths resolvable so already-open VS Code windows do not lose the companion because an older cached location was pruned before reload.

#### Scenario: Installer refreshes compatibility copies for recent patch paths
- **WHEN** the current companion version is `X.Y.Z`
- **THEN** the installer refreshes compatibility directories for a bounded recent patch window within `X.Y.*`
- **AND** the current patch-version directory stays loadable
- **AND** already-open windows that still point at a recent earlier patch path can continue resolving the extension until the window reloads.

### Requirement: Install output tells users to reload already-open windows
The Active Agents install flow SHALL tell the user that every already-open VS Code window needs a reload after install or auto-update.

#### Scenario: Install completes successfully
- **WHEN** the installer finishes copying the companion
- **THEN** stdout includes the installed version and canonical target directory
- **AND** stdout explicitly tells the user to reload each already-open VS Code window to pick up the newest companion.
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-fix-active-agents-install-stale-window-2026-04-22-18-11`; branch=`agent/codex/fix-active-agents-install-stale-window-2026-04-22-18-11`; scope=`canonical Active Agents install path plus recent patch-path compatibility copies and reload wording`; action=`patch the installer/tests in this sandbox, verify, then finish cleanup`.

## 1. Specification

- [x] 1.1 Finalize proposal scope and acceptance criteria for `agent-codex-fix-active-agents-install-stale-window-2026-04-22-18-11`.
- [x] 1.2 Define normative requirements in `specs/vscode-active-agents-extension/spec.md`.

## 2. Implementation

- [x] 2.1 Implement the canonical install path and compatibility copy behavior.
- [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-fix-active-agents-install-stale-window-2026-04-22-18-11 --type change --strict`.
- [x] 3.3 Run `openspec validate --specs`.

Verification note:
- `node --test test/vscode-active-agents-session-state.test.js test/metadata.test.js` passed `55/55`.
- `openspec validate agent-codex-fix-active-agents-install-stale-window-2026-04-22-18-11 --type change --strict` returned `Change 'agent-codex-fix-active-agents-install-stale-window-2026-04-22-18-11' is valid`.
- `openspec validate --specs` returned `No items found to validate.` in this repo state.

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

- [ ] 4.1 Run the cleanup pipeline: `gx branch finish --branch agent/codex/fix-active-agents-install-stale-window-2026-04-22-18-11 --base main --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).
49 changes: 38 additions & 11 deletions scripts/install-vscode-active-agents-extension.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');

const PATCH_COMPATIBILITY_WINDOW = 20;

function parseOptions(argv) {
const options = {};
for (let index = 0; index < argv.length; index += 1) {
Expand Down Expand Up @@ -43,6 +45,26 @@ function removeIfExists(targetPath) {
}
}

function parseSimpleSemver(version) {
const parts = String(version || '').trim().split('.').map((part) => Number.parseInt(part, 10));
if (parts.length !== 3 || parts.some((part) => Number.isNaN(part))) {
throw new Error(`Expected simple semver for the Active Agents companion, received "${version}".`);
}
return parts;
}

function buildInstallTargets(extensionId, version, extensionsDir) {
const [major, minor, patch] = parseSimpleSemver(version);
const firstCompatiblePatch = Math.max(0, patch - PATCH_COMPATIBILITY_WINDOW);
const targets = [path.join(extensionsDir, extensionId)];

for (let compatiblePatch = firstCompatiblePatch; compatiblePatch <= patch; compatiblePatch += 1) {
targets.push(path.join(extensionsDir, `${extensionId}-${major}.${minor}.${compatiblePatch}`));
}

return targets;
}

function main() {
const repoRoot = path.resolve(__dirname, '..');
const options = parseOptions(process.argv.slice(2));
Expand All @@ -57,30 +79,35 @@ function main() {
);

fs.mkdirSync(extensionsDir, { recursive: true });
const targetDir = path.join(extensionsDir, `${extensionId}-${manifest.version}`);
const targetDirs = buildInstallTargets(extensionId, manifest.version, extensionsDir);
const canonicalTargetDir = targetDirs[0];
const keepDirNames = new Set(targetDirs.map((targetDir) => path.basename(targetDir)));

for (const entry of fs.readdirSync(extensionsDir, { withFileTypes: true })) {
if (!entry.isDirectory()) {
continue;
}
if (entry.name === path.basename(targetDir)) {
if (keepDirNames.has(entry.name)) {
continue;
}
if (entry.name.startsWith(`${extensionId}-`)) {
if (entry.name === extensionId || entry.name.startsWith(`${extensionId}-`)) {
removeIfExists(path.join(extensionsDir, entry.name));
}
}

removeIfExists(targetDir);
fs.cpSync(sourceDir, targetDir, {
recursive: true,
force: true,
preserveTimestamps: true,
});
for (const targetDir of targetDirs) {
removeIfExists(targetDir);
fs.cpSync(sourceDir, targetDir, {
recursive: true,
force: true,
preserveTimestamps: true,
});
}

process.stdout.write(
`[guardex-active-agents] Installed ${extensionId}@${manifest.version} to ${targetDir}\n` +
'[guardex-active-agents] Reload the VS Code window to activate the Source Control companion.\n',
`[guardex-active-agents] Installed ${extensionId}@${manifest.version} to ${canonicalTargetDir}\n` +
`[guardex-active-agents] Refreshed ${targetDirs.length - 1} recent patch compatibility path(s) for already-open windows.\n` +
'[guardex-active-agents] Reload each already-open VS Code window to activate the newest Source Control companion.\n',
);
}

Expand Down
49 changes: 38 additions & 11 deletions templates/scripts/install-vscode-active-agents-extension.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');

const PATCH_COMPATIBILITY_WINDOW = 20;

function parseOptions(argv) {
const options = {};
for (let index = 0; index < argv.length; index += 1) {
Expand Down Expand Up @@ -43,6 +45,26 @@ function removeIfExists(targetPath) {
}
}

function parseSimpleSemver(version) {
const parts = String(version || '').trim().split('.').map((part) => Number.parseInt(part, 10));
if (parts.length !== 3 || parts.some((part) => Number.isNaN(part))) {
throw new Error(`Expected simple semver for the Active Agents companion, received "${version}".`);
}
return parts;
}

function buildInstallTargets(extensionId, version, extensionsDir) {
const [major, minor, patch] = parseSimpleSemver(version);
const firstCompatiblePatch = Math.max(0, patch - PATCH_COMPATIBILITY_WINDOW);
const targets = [path.join(extensionsDir, extensionId)];

for (let compatiblePatch = firstCompatiblePatch; compatiblePatch <= patch; compatiblePatch += 1) {
targets.push(path.join(extensionsDir, `${extensionId}-${major}.${minor}.${compatiblePatch}`));
}

return targets;
}

function main() {
const repoRoot = path.resolve(__dirname, '..');
const options = parseOptions(process.argv.slice(2));
Expand All @@ -57,30 +79,35 @@ function main() {
);

fs.mkdirSync(extensionsDir, { recursive: true });
const targetDir = path.join(extensionsDir, `${extensionId}-${manifest.version}`);
const targetDirs = buildInstallTargets(extensionId, manifest.version, extensionsDir);
const canonicalTargetDir = targetDirs[0];
const keepDirNames = new Set(targetDirs.map((targetDir) => path.basename(targetDir)));

for (const entry of fs.readdirSync(extensionsDir, { withFileTypes: true })) {
if (!entry.isDirectory()) {
continue;
}
if (entry.name === path.basename(targetDir)) {
if (keepDirNames.has(entry.name)) {
continue;
}
if (entry.name.startsWith(`${extensionId}-`)) {
if (entry.name === extensionId || entry.name.startsWith(`${extensionId}-`)) {
removeIfExists(path.join(extensionsDir, entry.name));
}
}

removeIfExists(targetDir);
fs.cpSync(sourceDir, targetDir, {
recursive: true,
force: true,
preserveTimestamps: true,
});
for (const targetDir of targetDirs) {
removeIfExists(targetDir);
fs.cpSync(sourceDir, targetDir, {
recursive: true,
force: true,
preserveTimestamps: true,
});
}

process.stdout.write(
`[guardex-active-agents] Installed ${extensionId}@${manifest.version} to ${targetDir}\n` +
'[guardex-active-agents] Reload the VS Code window to activate the Source Control companion.\n',
`[guardex-active-agents] Installed ${extensionId}@${manifest.version} to ${canonicalTargetDir}\n` +
`[guardex-active-agents] Refreshed ${targetDirs.length - 1} recent patch compatibility path(s) for already-open windows.\n` +
'[guardex-active-agents] Reload each already-open VS Code window to activate the newest Source Control companion.\n',
);
}

Expand Down
2 changes: 1 addition & 1 deletion templates/vscode/guardex-active-agents/extension.js
Original file line number Diff line number Diff line change
Expand Up @@ -899,7 +899,7 @@ async function maybeAutoUpdateActiveAgentsExtension(context) {
}

const selection = await vscode.window.showInformationMessage?.(
`GitGuardex Active Agents updated to ${candidate.version}. Reload Window to use the newest companion.`,
`GitGuardex Active Agents updated to ${candidate.version}. Reload this window now, then reload any other already-open VS Code windows to use the newest companion.`,
RELOAD_WINDOW_ACTION,
UPDATE_LATER_ACTION,
);
Expand Down
43 changes: 27 additions & 16 deletions test/vscode-active-agents-session-state.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1087,33 +1087,44 @@ test('session-schema reads inspect data from base-branch config, log tail, and h
);
});

test('install-vscode-active-agents-extension installs the current extension version and prunes older copies', () => {
test('install-vscode-active-agents-extension installs the current extension into a canonical dir and refreshes recent patch compatibility copies', () => {
const tempExtensionsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-ext-'));
const manifest = readExtensionManifest();
const staleDir = path.join(tempExtensionsDir, 'recodeee.gitguardex-active-agents-0.0.0');
fs.mkdirSync(staleDir, { recursive: true });
fs.writeFileSync(path.join(staleDir, 'stale.txt'), 'old', 'utf8');
const extensionId = `${manifest.publisher}.${manifest.name}`;
const [major, minor, patch] = parseSimpleSemver(manifest.version);
const canonicalDir = path.join(tempExtensionsDir, extensionId);
const currentVersionDir = path.join(tempExtensionsDir, `${extensionId}-${manifest.version}`);
const recentCompatDir = patch > 0
? path.join(tempExtensionsDir, `${extensionId}-${major}.${minor}.${patch - 1}`)
: currentVersionDir;
const farLegacyDir = path.join(tempExtensionsDir, `${extensionId}-99.99.99`);

fs.mkdirSync(recentCompatDir, { recursive: true });
fs.writeFileSync(path.join(recentCompatDir, 'stale.txt'), 'old', 'utf8');
fs.mkdirSync(farLegacyDir, { recursive: true });
fs.writeFileSync(path.join(farLegacyDir, 'stale.txt'), 'old', 'utf8');

const result = runNode(installScript, ['--extensions-dir', tempExtensionsDir], {
cwd: repoRoot,
});
assert.equal(result.status, 0, result.stderr);

const installedDir = path.join(
tempExtensionsDir,
`recodeee.gitguardex-active-agents-${manifest.version}`,
);
const installedManifest = readJson(path.join(installedDir, 'package.json'));
assert.equal(fs.existsSync(installedDir), true);
assert.equal(fs.existsSync(path.join(installedDir, 'extension.js')), true);
assert.equal(fs.existsSync(path.join(installedDir, 'session-schema.js')), true);
const installedManifest = readJson(path.join(canonicalDir, 'package.json'));
assert.equal(fs.existsSync(canonicalDir), true);
assert.equal(fs.existsSync(path.join(canonicalDir, 'extension.js')), true);
assert.equal(fs.existsSync(path.join(canonicalDir, 'session-schema.js')), true);
assert.equal(installedManifest.icon, 'icon.png');
assert.equal(installedManifest.version, manifest.version);
assert.deepEqual(installedManifest.activationEvents, manifest.activationEvents);
assert.equal(installedManifest.activationEvents.includes('onStartupFinished'), true);
assert.equal(fs.existsSync(path.join(installedDir, 'icon.png')), true);
assert.equal(fs.existsSync(staleDir), false);
assert.match(result.stdout, /Reload the VS Code window/);
assert.equal(fs.existsSync(path.join(canonicalDir, 'icon.png')), true);
assert.equal(fs.existsSync(currentVersionDir), true);
assert.equal(fs.existsSync(path.join(recentCompatDir, 'package.json')), true);
assert.equal(fs.existsSync(path.join(recentCompatDir, 'stale.txt')), false);
assert.equal(fs.existsSync(farLegacyDir), false);
assert.match(result.stdout, new RegExp(`Installed ${extensionId}@${manifest.version} to ${canonicalDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`));
assert.match(result.stdout, /Refreshed \d+ recent patch compatibility path\(s\)/);
assert.match(result.stdout, /Reload each already-open VS Code window/);
});

test('active-agents extension edits require a higher manifest version than the base branch', () => {
Expand Down Expand Up @@ -1196,7 +1207,7 @@ test('active-agents extension auto-installs a newer workspace build and offers r
assert.equal(execCalls[0].options.encoding, 'utf8');
assert.match(
registrations.informationMessages.at(-1),
/GitGuardex Active Agents updated to 9\.9\.9/,
/GitGuardex Active Agents updated to 9\.9\.9.*reload any other already-open VS Code windows/i,
);
assert.deepEqual(registrations.infoMessages.at(-1).slice(1), ['Reload Window', 'Later']);
assert.equal(
Expand Down
2 changes: 1 addition & 1 deletion vscode/guardex-active-agents/extension.js
Original file line number Diff line number Diff line change
Expand Up @@ -899,7 +899,7 @@ async function maybeAutoUpdateActiveAgentsExtension(context) {
}

const selection = await vscode.window.showInformationMessage?.(
`GitGuardex Active Agents updated to ${candidate.version}. Reload Window to use the newest companion.`,
`GitGuardex Active Agents updated to ${candidate.version}. Reload this window now, then reload any other already-open VS Code windows to use the newest companion.`,
RELOAD_WINDOW_ACTION,
UPDATE_LATER_ACTION,
);
Expand Down