diff --git a/README.md b/README.md index 4950aae..a05337b 100644 --- a/README.md +++ b/README.md @@ -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. --- diff --git a/openspec/changes/agent-codex-vscode-active-agents-worktree-status-2026-04-21-21-23/.openspec.yaml b/openspec/changes/agent-codex-vscode-active-agents-worktree-status-2026-04-21-21-23/.openspec.yaml new file mode 100644 index 0000000..4b8c565 --- /dev/null +++ b/openspec/changes/agent-codex-vscode-active-agents-worktree-status-2026-04-21-21-23/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-21 diff --git a/openspec/changes/agent-codex-vscode-active-agents-worktree-status-2026-04-21-21-23/proposal.md b/openspec/changes/agent-codex-vscode-active-agents-worktree-status-2026-04-21-21-23/proposal.md new file mode 100644 index 0000000..8f1ea12 --- /dev/null +++ b/openspec/changes/agent-codex-vscode-active-agents-worktree-status-2026-04-21-21-23/proposal.md @@ -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. diff --git a/openspec/changes/agent-codex-vscode-active-agents-worktree-status-2026-04-21-21-23/specs/vscode-active-agents-extension/spec.md b/openspec/changes/agent-codex-vscode-active-agents-worktree-status-2026-04-21-21-23/specs/vscode-active-agents-extension/spec.md new file mode 100644 index 0000000..006adf6 --- /dev/null +++ b/openspec/changes/agent-codex-vscode-active-agents-worktree-status-2026-04-21-21-23/specs/vscode-active-agents-extension/spec.md @@ -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. diff --git a/openspec/changes/agent-codex-vscode-active-agents-worktree-status-2026-04-21-21-23/tasks.md b/openspec/changes/agent-codex-vscode-active-agents-worktree-status-2026-04-21-21-23/tasks.md new file mode 100644 index 0000000..8951241 --- /dev/null +++ b/openspec/changes/agent-codex-vscode-active-agents-worktree-status-2026-04-21-21-23/tasks.md @@ -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// --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). diff --git a/templates/vscode/guardex-active-agents/README.md b/templates/vscode/guardex-active-agents/README.md index ea3ffb4..aa9fabf 100644 --- a/templates/vscode/guardex-active-agents/README.md +++ b/templates/vscode/guardex-active-agents/README.md @@ -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/`. diff --git a/templates/vscode/guardex-active-agents/extension.js b/templates/vscode/guardex-active-agents/extension.js index 1028cd3..2c89e51 100644 --- a/templates/vscode/guardex-active-agents/extension.js +++ b/templates/vscode/guardex-active-agents/extension.js @@ -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 = { diff --git a/templates/vscode/guardex-active-agents/session-schema.js b/templates/vscode/guardex-active-agents/session-schema.js index eed9851..bd689af 100644 --- a/templates/vscode/guardex-active-agents/session-schema.js +++ b/templates/vscode/guardex-active-agents/session-schema.js @@ -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(); @@ -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)); @@ -173,6 +266,7 @@ function readActiveSessions(repoRoot, options = {}) { } normalized.elapsedLabel = formatElapsedFrom(normalized.startedAt, now); + Object.assign(normalized, deriveSessionActivity(normalized)); sessions.push(normalized); } @@ -192,10 +286,14 @@ module.exports = { SESSION_SCHEMA_VERSION, activeSessionsDirForRepo, buildSessionRecord, + collectWorktreeChangedPaths, deriveSessionLabel, + deriveSessionActivity, formatElapsedFrom, + formatFileCount, isPidAlive, normalizeSessionRecord, + previewChangedPaths, readActiveSessions, sanitizeBranchForFile, sessionFileNameForBranch, diff --git a/test/vscode-active-agents-session-state.test.js b/test/vscode-active-agents-session-state.test.js index 7497b51..a55519f 100644 --- a/test/vscode-active-agents-session-state.test.js +++ b/test/vscode-active-agents-session-state.test.js @@ -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; @@ -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'); @@ -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', @@ -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?.(); + } +});