From 25ba15062b00111e70c0b6036f151aaea8863302 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Tue, 21 Apr 2026 21:30:44 +0200 Subject: [PATCH] Show live Guardex worktree activity in the VS Code SCM view The Active Agents companion already showed presence, but every row was hardcoded to thinking. This change derives live activity from each sandbox worktree so clean lanes stay thinking while dirty lanes surface working with changed-file counts and tooltip previews. Constraint: VS Code companion state remains repo-local and read-only; do not require a new runtime daemon. Rejected: Add a periodic activity writer protocol | unnecessary complexity for a read-only status hint. Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep activity inference resilient; if git inspection fails, fall back to thinking rather than dropping the session row. Tested: node --test test/vscode-active-agents-session-state.test.js Tested: openspec validate agent-codex-vscode-active-agents-worktree-status-2026-04-21-21-23 --type change --strict Tested: openspec validate --specs Not-tested: VS Code interactive rendering in a live editor window --- README.md | 2 +- .../.openspec.yaml | 2 + .../proposal.md | 16 +++ .../vscode-active-agents-extension/spec.md | 20 ++++ .../tasks.md | 29 ++++++ .../vscode/guardex-active-agents/README.md | 1 + .../vscode/guardex-active-agents/extension.js | 16 ++- .../guardex-active-agents/session-schema.js | 98 +++++++++++++++++++ ...vscode-active-agents-session-state.test.js | 93 ++++++++++++++++++ 9 files changed, 273 insertions(+), 4 deletions(-) create mode 100644 openspec/changes/agent-codex-vscode-active-agents-worktree-status-2026-04-21-21-23/.openspec.yaml create mode 100644 openspec/changes/agent-codex-vscode-active-agents-worktree-status-2026-04-21-21-23/proposal.md create mode 100644 openspec/changes/agent-codex-vscode-active-agents-worktree-status-2026-04-21-21-23/specs/vscode-active-agents-extension/spec.md create mode 100644 openspec/changes/agent-codex-vscode-active-agents-worktree-status-2026-04-21-21-23/tasks.md 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?.(); + } +});