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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ To install the real companion into local VS Code from a Guardex-wired repo:
node scripts/install-vscode-active-agents-extension.js
```

It adds an `Active Agents` view to the Source Control container, reads `.omx/state/active-sessions/*.json`, and uses VS Code's native `loading~spin` codicon for the running-state affordance. Reload the VS Code window after install.
It adds an `Active Agents` view to the Source Control container, reads `.omx/state/active-sessions/*.json`, derives `thinking` versus `working` from each live sandbox worktree, and uses VS Code's native `loading~spin` codicon for the running-state affordance. Reload the VS Code window after install.

---

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-21
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
## Why

- The Active Agents companion already shows live Guardex lanes, but every row is hardcoded to `thinking` even after the agent starts changing files in its sandbox.
- In multi-agent VS Code flows, users need to tell which worktree is still planning versus which one is actively moving without leaving Source Control.

## What Changes

- Derive per-session activity from the live sandbox worktree so clean lanes stay `thinking` while dirty lanes surface `working`.
- Update the Active Agents SCM rows and tooltips to include the live activity state, changed-file count, and changed-path preview.
- Add focused regression coverage for the activity inference and the rendered SCM row copy.

## Impact

- Affected surfaces: `templates/vscode/guardex-active-agents/extension.js`, `templates/vscode/guardex-active-agents/session-schema.js`, `test/vscode-active-agents-session-state.test.js`, and README/OpenSpec docs.
- Risk is narrow because the change stays read-only and derives activity from the existing live worktree instead of introducing a new runtime protocol.
- If git activity cannot be inspected for a live worktree, the companion must fall back to `thinking` instead of crashing or hiding the session row.
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
## ADDED Requirements

### Requirement: Active Agents rows reflect live sandbox worktree activity
The system SHALL describe whether each live Guardex sandbox is still thinking or is actively working inside its worktree.

#### Scenario: Clean worktree stays thinking
- **WHEN** a live session points at a clean sandbox worktree
- **THEN** the Active Agents row description begins with `thinking`
- **AND** it still includes the elapsed time for that live lane.

#### Scenario: Dirty worktree surfaces working state
- **WHEN** a live session points at a sandbox worktree with tracked or untracked file changes
- **THEN** the Active Agents row description begins with `working`
- **AND** it includes the changed-file count before the elapsed time
- **AND** the row tooltip includes a preview of the changed paths.

#### Scenario: Activity inference falls back safely
- **WHEN** the companion cannot inspect the worktree git state for an otherwise live session
- **THEN** the row still renders as an active agent
- **AND** the description falls back to `thinking` instead of crashing or disappearing.
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
## 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.

## 1. Specification

- [x] 1.1 Finalize proposal scope and acceptance criteria for `agent-codex-vscode-active-agents-worktree-status-2026-04-21-21-23`.
- [x] 1.2 Define normative requirements in `specs/vscode-active-agents-extension/spec.md`.

## 2. Implementation

- [x] 2.1 Derive live `thinking` versus `working` status from each active sandbox worktree and surface it in the SCM row description/tooltip.
- [x] 2.2 Add/update focused regression coverage plus README guidance for the richer Active Agents status copy.

## 3. Verification

- [x] 3.1 Run targeted project verification commands.
- [x] 3.2 Run `openspec validate agent-codex-vscode-active-agents-worktree-status-2026-04-21-21-23 --type change --strict`.
- [x] 3.3 Run `openspec validate --specs`.

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

- [ ] 4.1 Run the cleanup pipeline: `bash scripts/agent-branch-finish.sh --branch agent/<your-name>/<branch-slug> --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).
1 change: 1 addition & 0 deletions templates/vscode/guardex-active-agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ What it does:

- Adds an `Active Agents` view to the Source Control container.
- Renders one row per live Guardex sandbox session.
- Derives `thinking` versus `working` from the live sandbox worktree and shows changed-file counts for active edits.
- Uses VS Code's native animated `loading~spin` icon for the running-state affordance.
- Reads repo-local presence files from `.omx/state/active-sessions/`.

Expand Down
16 changes: 13 additions & 3 deletions templates/vscode/guardex-active-agents/extension.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,23 @@ class SessionItem extends vscode.TreeItem {
constructor(session) {
super(session.label, vscode.TreeItemCollapsibleState.None);
this.session = session;
this.description = `thinking · ${formatElapsedFrom(session.startedAt)}`;
this.tooltip = [
const descriptionParts = [session.activityLabel || 'thinking'];
if (session.activityCountLabel) {
descriptionParts.push(session.activityCountLabel);
}
descriptionParts.push(session.elapsedLabel || formatElapsedFrom(session.startedAt));
this.description = descriptionParts.join(' · ');
const tooltipLines = [
session.branch,
`${session.agentName} · ${session.taskName}`,
`Status ${this.description}`,
session.changeCount > 0
? `Changed ${session.activityCountLabel}: ${session.activitySummary}`
: session.activitySummary,
`Started ${session.startedAt}`,
session.worktreePath,
].join('\n');
];
this.tooltip = tooltipLines.filter(Boolean).join('\n');
this.iconPath = new vscode.ThemeIcon('loading~spin');
this.contextValue = 'gitguardex.session';
this.command = {
Expand Down
98 changes: 98 additions & 0 deletions templates/vscode/guardex-active-agents/session-schema.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
const fs = require('node:fs');
const path = require('node:path');
const cp = require('node:child_process');

const ACTIVE_SESSIONS_RELATIVE_DIR = path.join('.omx', 'state', 'active-sessions');
const SESSION_SCHEMA_VERSION = 1;
const LOCK_FILE_RELATIVE = path.join('.omx', 'state', 'agent-file-locks.json');
const MAX_CHANGED_PATH_PREVIEW = 3;

function toNonEmptyString(value, fallback = '') {
const normalized = typeof value === 'string' ? value.trim() : String(value || '').trim();
Expand Down Expand Up @@ -31,6 +34,96 @@ function sessionFilePathForBranch(repoRoot, branch) {
return path.join(activeSessionsDirForRepo(repoRoot), sessionFileNameForBranch(branch));
}

function splitOutputLines(output) {
if (typeof output !== 'string') {
return null;
}

return output
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
}

function runGitLines(worktreePath, args) {
try {
const output = cp.execFileSync('git', ['-C', worktreePath, ...args], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
});
return splitOutputLines(output);
} catch (_error) {
return null;
}
}

function formatFileCount(count) {
return `${count} file${count === 1 ? '' : 's'}`;
}

function previewChangedPaths(paths) {
if (!Array.isArray(paths) || paths.length === 0) {
return '';
}

if (paths.length <= MAX_CHANGED_PATH_PREVIEW) {
return paths.join(', ');
}

const preview = paths.slice(0, MAX_CHANGED_PATH_PREVIEW).join(', ');
return `${preview}, +${paths.length - MAX_CHANGED_PATH_PREVIEW} more`;
}

function collectWorktreeChangedPaths(worktreePath) {
const changedGroups = [
runGitLines(worktreePath, ['diff', '--name-only', '--', '.', `:(exclude)${LOCK_FILE_RELATIVE}`]),
runGitLines(worktreePath, ['diff', '--cached', '--name-only', '--', '.', `:(exclude)${LOCK_FILE_RELATIVE}`]),
runGitLines(worktreePath, ['ls-files', '--others', '--exclude-standard']),
];

if (changedGroups.some((group) => group === null)) {
return null;
}

return [...new Set(changedGroups.flat())]
.filter((relativePath) => relativePath && relativePath !== LOCK_FILE_RELATIVE)
.sort((left, right) => left.localeCompare(right));
}

function deriveSessionActivity(session) {
const changedPaths = collectWorktreeChangedPaths(session.worktreePath);
if (!changedPaths) {
return {
activityKind: 'thinking',
activityLabel: 'thinking',
activityCountLabel: '',
activitySummary: 'Worktree activity unavailable.',
changeCount: 0,
changedPaths: [],
};
}

if (changedPaths.length === 0) {
return {
activityKind: 'thinking',
activityLabel: 'thinking',
activityCountLabel: '',
activitySummary: 'Worktree clean.',
changeCount: 0,
changedPaths: [],
};
}

return {
activityKind: 'working',
activityLabel: 'working',
activityCountLabel: formatFileCount(changedPaths.length),
activitySummary: previewChangedPaths(changedPaths),
changeCount: changedPaths.length,
changedPaths,
};
}

function buildSessionRecord(input) {
const repoRoot = path.resolve(toNonEmptyString(input.repoRoot));
const worktreePath = path.resolve(toNonEmptyString(input.worktreePath));
Expand Down Expand Up @@ -173,6 +266,7 @@ function readActiveSessions(repoRoot, options = {}) {
}

normalized.elapsedLabel = formatElapsedFrom(normalized.startedAt, now);
Object.assign(normalized, deriveSessionActivity(normalized));
sessions.push(normalized);
}

Expand All @@ -192,10 +286,14 @@ module.exports = {
SESSION_SCHEMA_VERSION,
activeSessionsDirForRepo,
buildSessionRecord,
collectWorktreeChangedPaths,
deriveSessionLabel,
deriveSessionActivity,
formatElapsedFrom,
formatFileCount,
isPidAlive,
normalizeSessionRecord,
previewChangedPaths,
readActiveSessions,
sanitizeBranchForFile,
sessionFileNameForBranch,
Expand Down
93 changes: 93 additions & 0 deletions test/vscode-active-agents-session-state.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,22 @@ function runNode(scriptPath, args, options = {}) {
});
}

function runGit(repoPath, args, options = {}) {
const result = cp.spawnSync('git', ['-C', repoPath, ...args], {
encoding: 'utf8',
...options,
});
assert.equal(result.status, 0, result.stderr || result.stdout);
return result;
}

function initGitRepo(repoPath) {
fs.mkdirSync(repoPath, { recursive: true });
runGit(repoPath, ['init']);
runGit(repoPath, ['config', 'user.email', 'guardex-tests@example.com']);
runGit(repoPath, ['config', 'user.name', 'Guardex Tests']);
}

function loadExtensionWithMockVscode(mockVscode) {
const Module = require('node:module');
const originalLoad = Module._load;
Expand Down Expand Up @@ -214,6 +230,38 @@ test('session-schema ignores stale or invalid session records', () => {
assert.equal(sessions[0].branch, liveRecord.branch);
});

test('session-schema derives working activity from dirty sandbox worktrees', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-active-session-working-'));
const worktreePath = path.join(tempRoot, 'sandbox');
initGitRepo(worktreePath);
fs.writeFileSync(path.join(worktreePath, 'tracked.txt'), 'base\n', 'utf8');
runGit(worktreePath, ['add', 'tracked.txt']);
runGit(worktreePath, ['commit', '-m', 'baseline']);

fs.writeFileSync(path.join(worktreePath, 'tracked.txt'), 'base\nchanged\n', 'utf8');
fs.writeFileSync(path.join(worktreePath, 'new-file.txt'), 'new\n', 'utf8');

const record = sessionSchema.buildSessionRecord({
repoRoot: tempRoot,
branch: 'agent/codex/working-task',
taskName: 'working-task',
agentName: 'codex',
worktreePath,
pid: process.pid,
cliName: 'codex',
});
const sessionPath = sessionSchema.sessionFilePathForBranch(tempRoot, record.branch);
fs.mkdirSync(path.dirname(sessionPath), { recursive: true });
fs.writeFileSync(sessionPath, `${JSON.stringify(record, null, 2)}\n`, 'utf8');

const [session] = sessionSchema.readActiveSessions(tempRoot);
assert.equal(session.activityKind, 'working');
assert.equal(session.changeCount, 2);
assert.equal(session.activityCountLabel, '2 files');
assert.deepEqual(session.changedPaths, ['new-file.txt', 'tracked.txt']);
assert.equal(session.activitySummary, 'new-file.txt, tracked.txt');
});

test('install-vscode-active-agents-extension installs the current extension version and prunes older copies', () => {
const tempExtensionsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-ext-'));
const staleDir = path.join(tempExtensionsDir, 'recodeee.gitguardex-active-agents-0.0.0');
Expand Down Expand Up @@ -288,6 +336,7 @@ test('active-agents extension updates the SCM badge for live sessions', async ()
const provider = registrations.providers[0].provider;
const [sessionItem] = await provider.getChildren();
assert.equal(sessionItem.label, 'live-task');
assert.match(sessionItem.description, /^thinking · \d+[smhd]/);
assert.deepEqual(registrations.treeViews[0].badge, {
value: 1,
tooltip: '1 active agent',
Expand All @@ -298,3 +347,47 @@ test('active-agents extension updates the SCM badge for live sessions', async ()
subscription.dispose?.();
}
});

test('active-agents extension shows working rows when the sandbox has changes', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-working-view-'));
const worktreePath = path.join(tempRoot, 'sandbox');
initGitRepo(worktreePath);
fs.writeFileSync(path.join(worktreePath, 'tracked.txt'), 'base\n', 'utf8');
runGit(worktreePath, ['add', 'tracked.txt']);
runGit(worktreePath, ['commit', '-m', 'baseline']);
fs.writeFileSync(path.join(worktreePath, 'tracked.txt'), 'base\nchanged\n', 'utf8');
fs.writeFileSync(path.join(worktreePath, 'new-file.txt'), 'new\n', 'utf8');

const sessionPath = sessionSchema.sessionFilePathForBranch(tempRoot, 'agent/codex/live-task');
fs.mkdirSync(path.dirname(sessionPath), { recursive: true });
fs.writeFileSync(
sessionPath,
`${JSON.stringify(sessionSchema.buildSessionRecord({
repoRoot: tempRoot,
branch: 'agent/codex/live-task',
taskName: 'live-task',
agentName: 'codex',
worktreePath,
pid: process.pid,
cliName: 'codex',
}), null, 2)}\n`,
'utf8',
);

const { registrations, vscode } = createMockVscode(tempRoot);
vscode.workspace.findFiles = async () => [{ fsPath: sessionPath }];
const extension = loadExtensionWithMockVscode(vscode);
const context = { subscriptions: [] };

extension.activate(context);

const provider = registrations.providers[0].provider;
const [sessionItem] = await provider.getChildren();
assert.equal(sessionItem.label, 'sandbox');
assert.match(sessionItem.description, /^working · 2 files · /);
assert.match(sessionItem.tooltip, /Changed 2 files: new-file\.txt, tracked\.txt/);

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