From 7b24dc3afc1574d054377da051af7f75761e2c09 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Thu, 23 Apr 2026 13:51:55 +0200 Subject: [PATCH 1/3] Make Active Agents rows readable for dense sandbox triage The raw tree exposed machine worktree names and full agent refs at the same time that richer session-health telemetry was landing on this branch. This keeps the extra health signal while reshaping visible labels and summaries so operators can scan by task first without losing full branch/worktree detail in tooltips. Constraint: Live and template Active Agents sources must stay in sync and version-bumped together Rejected: Keep full `agent/...` refs as raw labels | too noisy once many sandboxes are open Confidence: medium Scope-risk: narrow Directive: Keep raw tree labels compact and task-first, but preserve full refs in tooltips and commands Tested: node --test test/vscode-active-agents-session-state.test.js; openspec validate agent-codex-codex-task-2026-04-23-13-25 --type change --strict; openspec validate agent-codex-show-session-health-in-active-agents-2026-04-23 --type change --strict; openspec validate --specs Not-tested: Manual VS Code screenshot pass against a live workspace --- .../.openspec.yaml | 2 + .../proposal.md | 17 + .../spec.md | 23 + .../tasks.md | 35 ++ .../.openspec.yaml | 2 + .../proposal.md | 16 + .../spec.md | 17 + .../tasks.md | 40 ++ .../vscode/guardex-active-agents/extension.js | 484 +++++++++++++++--- .../guardex-active-agents/session-schema.js | 94 ++++ ...vscode-active-agents-session-state.test.js | 187 ++++++- vscode/guardex-active-agents/extension.js | 484 +++++++++++++++--- .../guardex-active-agents/session-schema.js | 94 ++++ 13 files changed, 1336 insertions(+), 159 deletions(-) create mode 100644 openspec/changes/agent-codex-codex-task-2026-04-23-13-25/.openspec.yaml create mode 100644 openspec/changes/agent-codex-codex-task-2026-04-23-13-25/proposal.md create mode 100644 openspec/changes/agent-codex-codex-task-2026-04-23-13-25/specs/agent-codex-codex-task-2026-04-23-13-25/spec.md create mode 100644 openspec/changes/agent-codex-codex-task-2026-04-23-13-25/tasks.md create mode 100644 openspec/changes/agent-codex-show-session-health-in-active-agents-2026-04-23/.openspec.yaml create mode 100644 openspec/changes/agent-codex-show-session-health-in-active-agents-2026-04-23/proposal.md create mode 100644 openspec/changes/agent-codex-show-session-health-in-active-agents-2026-04-23/specs/vscode-active-agents-session-health/spec.md create mode 100644 openspec/changes/agent-codex-show-session-health-in-active-agents-2026-04-23/tasks.md diff --git a/openspec/changes/agent-codex-codex-task-2026-04-23-13-25/.openspec.yaml b/openspec/changes/agent-codex-codex-task-2026-04-23-13-25/.openspec.yaml new file mode 100644 index 0000000..8b394c6 --- /dev/null +++ b/openspec/changes/agent-codex-codex-task-2026-04-23-13-25/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-23 diff --git a/openspec/changes/agent-codex-codex-task-2026-04-23-13-25/proposal.md b/openspec/changes/agent-codex-codex-task-2026-04-23-13-25/proposal.md new file mode 100644 index 0000000..627f987 --- /dev/null +++ b/openspec/changes/agent-codex-codex-task-2026-04-23-13-25/proposal.md @@ -0,0 +1,17 @@ +## Why + +- The raw Active Agents tree still surfaces machine worktree folder names and full `agent/...` refs, which slows scan time when many sandboxes are open. +- Operators need dense `3 files`-style summaries and branch labels that read like task rows, not filesystem internals. + +## What Changes + +- Prefer task-first labels for raw worktree groups when the worktree maps to a single active session. +- Compact raw branch rows from full `agent//` refs to a shorter owner/task label while keeping the full ref in tooltips. +- Normalize raw session summaries to `Working · 3 files · ...` wording and mirror the update into the template extension source. +- Bump the live/template Active Agents manifest versions for the shipped UI tweak. + +## Impact + +- Scope stays inside the VS Code Active Agents extension tree presentation. +- Full worktree paths and full branch refs remain available in tooltips and commands. +- Focused regression coverage stays in `test/vscode-active-agents-session-state.test.js`. diff --git a/openspec/changes/agent-codex-codex-task-2026-04-23-13-25/specs/agent-codex-codex-task-2026-04-23-13-25/spec.md b/openspec/changes/agent-codex-codex-task-2026-04-23-13-25/specs/agent-codex-codex-task-2026-04-23-13-25/spec.md new file mode 100644 index 0000000..10bfc8c --- /dev/null +++ b/openspec/changes/agent-codex-codex-task-2026-04-23-13-25/specs/agent-codex-codex-task-2026-04-23-13-25/spec.md @@ -0,0 +1,23 @@ +## ADDED Requirements + +### Requirement: raw Active Agents worktree rows stay task-first + +The VS Code Active Agents raw tree SHALL prefer a readable task label and compact worktree summary instead of the managed worktree folder basename when a worktree represents a single active session. + +#### Scenario: single-session worktree group + +- **WHEN** the raw Active Agents tree renders a managed worktree that contains one tracked session +- **THEN** the worktree row uses the session task name as its label +- **AND** the description shows compact agent/file/lock summary text +- **AND** the tooltip still exposes the full worktree path and full branch ref. + +### Requirement: raw Active Agents branch rows stay compact but identifiable + +The VS Code Active Agents raw tree SHALL render readable branch rows that preserve operator context without repeating the full `agent/...` ref in the visible label. + +#### Scenario: raw branch row + +- **WHEN** the raw Active Agents tree renders a session row under a worktree group +- **THEN** the row label uses a compact owner/task branch label +- **AND** the row description starts with a capitalized session status and compact file summary wording like `Working · 3 files` +- **AND** the tooltip still includes the full branch ref. diff --git a/openspec/changes/agent-codex-codex-task-2026-04-23-13-25/tasks.md b/openspec/changes/agent-codex-codex-task-2026-04-23-13-25/tasks.md new file mode 100644 index 0000000..4cc659a --- /dev/null +++ b/openspec/changes/agent-codex-codex-task-2026-04-23-13-25/tasks.md @@ -0,0 +1,35 @@ +## 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-codex-task-2026-04-23-13-25`; branch=`agent/codex/codex-task-2026-04-23-13-25`; scope=`Active Agents raw worktree labels, compact branch rows, mirrored template, focused tests`; action=`improve the screenshoted tree design inside the existing sandbox and finish normally`. + +## 1. Specification + +- [x] 1.1 Finalize proposal scope and acceptance criteria for `agent-codex-codex-task-2026-04-23-13-25`. +- [x] 1.2 Define normative requirements in `specs/agent-codex-codex-task-2026-04-23-13-25/spec.md`. + +## 2. Implementation + +- [x] 2.1 Tighten raw Active Agents worktree labels/descriptions to read task-first instead of machine-folder-first. +- [x] 2.2 Tighten raw branch row labels/descriptions to show compact owner/task labels and cleaner `3 files` wording. +- [x] 2.3 Mirror the tree update into the template extension source and bump the live/template manifest versions. +- [x] 2.4 Extend focused regression coverage for the raw tree presentation. + +## 3. Verification + +- [x] 3.1 Run `node --test test/vscode-active-agents-session-state.test.js`. +- [x] 3.2 Run `openspec validate agent-codex-codex-task-2026-04-23-13-25 --type change --strict`. +- [x] 3.3 Run `openspec validate --specs`. + +## 4. Cleanup (mandatory; run before claiming completion) + +- [ ] 4.1 Run the cleanup pipeline: `gx branch finish --branch agent/codex/codex-task-2026-04-23-13-25 --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/openspec/changes/agent-codex-show-session-health-in-active-agents-2026-04-23/.openspec.yaml b/openspec/changes/agent-codex-show-session-health-in-active-agents-2026-04-23/.openspec.yaml new file mode 100644 index 0000000..8b394c6 --- /dev/null +++ b/openspec/changes/agent-codex-show-session-health-in-active-agents-2026-04-23/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-23 diff --git a/openspec/changes/agent-codex-show-session-health-in-active-agents-2026-04-23/proposal.md b/openspec/changes/agent-codex-show-session-health-in-active-agents-2026-04-23/proposal.md new file mode 100644 index 0000000..ab746fe --- /dev/null +++ b/openspec/changes/agent-codex-show-session-health-in-active-agents-2026-04-23/proposal.md @@ -0,0 +1,16 @@ +## Why + +- Active Agents already shows state, file churn, and freshness, but it does not surface the per-session health score operators already scan in Cave Monitor. +- When several sandboxes are live, the user has to leave the VS Code tree and cross-check another surface to see which session is drifting hardest. + +## What Changes + +- Accept an optional `sessionHealth` payload from active-session JSON records and `AGENT.lock` snapshot telemetry. +- Show the compact score (`45/100`) in each session row, with the labeled summary in the tooltip and session detail list. +- Mirror the schema/rendering patch into the extension template and lock it with focused Active Agents tests. + +## Impact + +- Backward compatible: sessions without `sessionHealth` keep the current description and tooltip format. +- Affected surface: `vscode/guardex-active-agents/*`, `templates/vscode/guardex-active-agents/*`, focused extension tests, and this change workspace. +- This change only renders cave-monitor telemetry when a producer includes it; it does not change how the score is calculated. diff --git a/openspec/changes/agent-codex-show-session-health-in-active-agents-2026-04-23/specs/vscode-active-agents-session-health/spec.md b/openspec/changes/agent-codex-show-session-health-in-active-agents-2026-04-23/specs/vscode-active-agents-session-health/spec.md new file mode 100644 index 0000000..17dc0af --- /dev/null +++ b/openspec/changes/agent-codex-show-session-health-in-active-agents-2026-04-23/specs/vscode-active-agents-session-health/spec.md @@ -0,0 +1,17 @@ +## ADDED Requirements + +### Requirement: Active Agents rows surface optional session health +The VS Code Active Agents extension SHALL show the compact session-health score when an active-session record or `AGENT.lock` telemetry payload includes Cave Monitor health data for that session. + +#### Scenario: Active-session record includes session health +- **GIVEN** `.omx/state/active-sessions/.json` contains `sessionHealth.score=45` and `sessionHealth.label="Inefficient"` +- **WHEN** the extension renders that session in `Working now` or `Idle / thinking` +- **THEN** the row description includes `45/100` +- **AND** the tooltip or session detail list includes `45/100 · Inefficient` + +#### Scenario: Worktree lock fallback includes session health +- **GIVEN** no active-session JSON exists for a managed worktree +- **AND** the worktree `AGENT.lock` snapshot telemetry includes session health for the latest session preview +- **WHEN** the extension falls back to the `AGENT.lock` session +- **THEN** the rendered session row includes the compact `score/100` +- **AND** sessions without `sessionHealth` keep the current description format. diff --git a/openspec/changes/agent-codex-show-session-health-in-active-agents-2026-04-23/tasks.md b/openspec/changes/agent-codex-show-session-health-in-active-agents-2026-04-23/tasks.md new file mode 100644 index 0000000..d0b57a1 --- /dev/null +++ b/openspec/changes/agent-codex-show-session-health-in-active-agents-2026-04-23/tasks.md @@ -0,0 +1,40 @@ +## 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-show-session-health-in-active-agents-2026-04-23`; branch=`agent/codex/codex-task-2026-04-23-13-25`; scope=`vscode/guardex-active-agents/*`, `templates/vscode/guardex-active-agents/*`, `test/vscode-active-agents-session-state.test.js`, and this change workspace; action=`render optional Cave Monitor session-health scores in Active Agents rows without changing no-payload sessions`. +- Copy prompt: Continue `agent-codex-show-session-health-in-active-agents-2026-04-23` on branch `agent/codex/codex-task-2026-04-23-13-25`. Work inside the existing sandbox, review `openspec/changes/agent-codex-show-session-health-in-active-agents-2026-04-23/tasks.md`, continue from the current state instead of creating a new sandbox, and when the work is done run `gx branch finish --branch agent/codex/codex-task-2026-04-23-13-25 --base main --via-pr --wait-for-merge --cleanup`. + +## 1. Specification + +- [x] 1.1 Finalize proposal scope and acceptance criteria for `agent-codex-show-session-health-in-active-agents-2026-04-23`. +- [x] 1.2 Define normative requirements in `specs/vscode-active-agents-session-health/spec.md`. + +## 2. Implementation + +- [x] 2.1 Normalize optional `sessionHealth` payloads from active-session JSON records and `AGENT.lock` snapshot telemetry. +- [x] 2.2 Render compact session-health scores in Active Agents rows, tooltips, and detail items without changing sessions that lack health data. +- [x] 2.3 Mirror the source/template patch and add focused regression coverage. + +## 3. Verification + +- [x] 3.1 Run targeted project verification commands. +- [x] 3.2 Run `openspec validate agent-codex-show-session-health-in-active-agents-2026-04-23 --type change --strict`. +- [x] 3.3 Run `openspec validate --specs`. + +Verification evidence: +- `node --test test/vscode-active-agents-session-state.test.js` (pass, 42 tests) +- `openspec validate agent-codex-show-session-health-in-active-agents-2026-04-23 --type change --strict` (pass) +- `openspec validate --specs` (`No items found to validate.` in this worktree) + +## 4. Cleanup (mandatory; run before claiming completion) + +- [ ] 4.1 Run the cleanup pipeline: `gx branch finish --branch agent/codex/codex-task-2026-04-23-13-25 --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/extension.js b/templates/vscode/guardex-active-agents/extension.js index fcd9d29..a4caf67 100644 --- a/templates/vscode/guardex-active-agents/extension.js +++ b/templates/vscode/guardex-active-agents/extension.js @@ -17,6 +17,10 @@ const LOCK_FILE_RELATIVE = path.join('.omx', 'state', 'agent-file-locks.json'); const ACTIVE_SESSION_FILES_GLOB = '**/.omx/state/active-sessions/*.json'; const AGENT_FILE_LOCKS_GLOB = '**/.omx/state/agent-file-locks.json'; const WORKTREE_AGENT_LOCKS_GLOB = '**/{.omx,.omc}/agent-worktrees/**/AGENT.lock'; +const MANAGED_WORKTREE_RELATIVE_ROOTS = [ + path.join('.omx', 'agent-worktrees'), + path.join('.omc', 'agent-worktrees'), +]; const AGENT_LOG_FILES_GLOB = '**/.omx/logs/*.log'; const SESSION_SCAN_EXCLUDE_GLOB = '**/{node_modules,.git,.omx/agent-worktrees,.omc/agent-worktrees}/**'; const WORKTREE_LOCK_SCAN_EXCLUDE_GLOB = '**/{node_modules,.git}/**'; @@ -131,6 +135,34 @@ function formatCountLabel(count, singular, plural = `${singular}s`) { return `${count} ${count === 1 ? singular : plural}`; } +function branchSegments(branch) { + return String(branch || '') + .split('/') + .map((segment) => segment.trim()) + .filter(Boolean); +} + +function compactBranchLabel(branch) { + const segments = branchSegments(branch); + if (segments.length >= 3 && segments[0] === 'agent') { + return `${segments[1]}/${segments.slice(2).join('/')}`; + } + return segments.join('/'); +} + +function sessionFileCountLabel(session) { + const activityCountLabel = typeof session?.activityCountLabel === 'string' + ? session.activityCountLabel.trim() + : ''; + if (activityCountLabel) { + return activityCountLabel; + } + if ((session?.changeCount || 0) > 0) { + return formatCountLabel(session.changeCount, 'file'); + } + return ''; +} + function uniqueStringList(values) { const seen = new Set(); const result = []; @@ -458,6 +490,54 @@ function sessionStatusLabel(session) { } } +function sessionHealthScore(session) { + return Number.isInteger(session?.sessionHealth?.score) ? session.sessionHealth.score : null; +} + +function buildSessionHealthCompactLabel(session) { + const score = sessionHealthScore(session); + return score === null ? '' : `${score}/100`; +} + +function buildSessionHealthSummary(session) { + const compactLabel = buildSessionHealthCompactLabel(session); + if (!compactLabel) { + return ''; + } + + const label = typeof session?.sessionHealth?.label === 'string' + ? session.sessionHealth.label.trim() + : ''; + return label ? `${compactLabel} · ${label}` : compactLabel; +} + +function buildSessionHealthDriversSummary(session) { + const primaryDriver = typeof session?.sessionHealth?.primaryDriver === 'string' + ? session.sessionHealth.primaryDriver.trim() + : ''; + const secondaries = uniqueStringList(Array.isArray(session?.sessionHealth?.secondaries) + ? session.sessionHealth.secondaries.map((value) => String(value || '').trim()) + : []); + return [ + primaryDriver ? `Primary: ${primaryDriver}` : '', + secondaries.length > 0 ? `Secondary: ${secondaries.join(', ')}` : '', + ].filter(Boolean).join(' | '); +} + +function buildSessionHealthTooltip(session) { + const outputLine = typeof session?.sessionHealth?.outputLine === 'string' + ? session.sessionHealth.outputLine.trim() + : ''; + if (outputLine) { + return outputLine; + } + + return [ + buildSessionHealthSummary(session), + buildSessionHealthDriversSummary(session), + ].filter(Boolean).join('\n'); +} + function buildSessionTopFiles(session) { return uniqueStringList((session?.worktreeChangedPaths || []) .map(normalizeRelativePath) @@ -507,6 +587,7 @@ function buildSessionCardDescription(session) { session.deltaLabel || '', session.changeCount > 0 ? formatCountLabel(session.changeCount, 'changed file') : '', session.lockCount > 0 ? formatCountLabel(session.lockCount, 'lock') : '', + buildSessionHealthCompactLabel(session), session.freshnessLabel || '', session.lastActiveLabel ? `${session.lastActiveLabel} ago` : '', ].filter(Boolean); @@ -515,8 +596,11 @@ function buildSessionCardDescription(session) { function buildRawSessionDescription(session) { const provider = resolveSessionProvider(session); - const status = sessionStatusLabel(session).toLowerCase(); - const descriptionParts = [`${status}: ${session.agentName || 'agent'}`]; + const descriptionParts = [sessionStatusLabel(session)]; + const fileCountLabel = sessionFileCountLabel(session); + if (fileCountLabel) { + descriptionParts.push(fileCountLabel); + } if (provider?.label) { descriptionParts.push(provider.label); } @@ -524,10 +608,11 @@ function buildRawSessionDescription(session) { if (snapshot) { descriptionParts.push(snapshot); } - if (session.activityCountLabel) { - descriptionParts.push(session.activityCountLabel); - } descriptionParts.push(session.elapsedLabel || formatElapsedFrom(session.startedAt)); + const sessionHealthLabel = buildSessionHealthCompactLabel(session); + if (sessionHealthLabel) { + descriptionParts.push(sessionHealthLabel); + } if (session.lockCount > 0) { descriptionParts.push(formatCountLabel(session.lockCount, 'lock')); } @@ -541,6 +626,8 @@ function buildSessionTooltip(session, description) { session?.deltaLabel || '', ].filter(Boolean)).join(', '); const topFiles = session?.topChangedFilesLabel || summarizeCompactPaths(session?.worktreeChangedPaths || []); + const sessionHealthSummary = buildSessionHealthSummary(session); + const sessionHealthDrivers = buildSessionHealthDriversSummary(session); return [ session.branch, provider?.label @@ -549,6 +636,8 @@ function buildSessionTooltip(session, description) { sessionSnapshotDisplayName(session) ? `Snapshot ${sessionSnapshotDisplayName(session)}` : '', `${session.agentName} · ${session.taskName}`, `Status ${description}`, + sessionHealthSummary ? `Session health ${sessionHealthSummary}` : '', + sessionHealthDrivers ? `Drivers ${sessionHealthDrivers}` : '', session.recentChangeSummary ? `Recent ${session.recentChangeSummary}` : '', topFiles ? `Top files ${topFiles}` : '', riskSummary ? `Signals ${riskSummary}` : '', @@ -1002,18 +1091,17 @@ class WorktreeItem extends vscode.TreeItem { const changedCount = Number.isInteger(options.changedCount) ? options.changedCount : sessionList.reduce((total, session) => total + (session.changeCount || 0), 0); - const descriptionParts = [formatCountLabel(sessionList.length, 'agent')]; - if (changedCount > 0) { - descriptionParts.push(`${changedCount} changed`); - } + const label = typeof options.label === 'string' && options.label.trim() + ? options.label.trim() + : worktreeDisplayLabel(normalizedWorktreePath, sessionList); super( - options.label || path.basename(normalizedWorktreePath || '') || 'worktree', + label, items.length > 0 ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.None, ); this.worktreePath = normalizedWorktreePath; this.sessions = sessionList; this.items = items; - this.description = options.description || descriptionParts.join(' · '); + this.description = options.description || buildWorktreeDescription(sessionList, changedCount); this.tooltip = [ normalizedWorktreePath, ...sessionList.map((session) => session.branch).filter(Boolean), @@ -1068,13 +1156,19 @@ class SessionItem extends vscode.TreeItem { } class FolderItem extends vscode.TreeItem { - constructor(label, relativePath, items) { - super(label, vscode.TreeItemCollapsibleState.Expanded); + constructor(label, relativePath, items, options = {}) { + super( + label, + items.length > 0 + ? (options.collapsedState ?? vscode.TreeItemCollapsibleState.Expanded) + : vscode.TreeItemCollapsibleState.None, + ); this.relativePath = relativePath; this.items = items; - this.tooltip = relativePath; - this.iconPath = new vscode.ThemeIcon('folder'); - this.contextValue = 'gitguardex.folder'; + this.description = typeof options.description === 'string' ? options.description : ''; + this.tooltip = options.tooltip || relativePath || label; + this.iconPath = new vscode.ThemeIcon(options.iconId || 'folder'); + this.contextValue = options.contextValue || 'gitguardex.folder'; } } @@ -1144,13 +1238,216 @@ function sessionDisplayLabel(session) { } function sessionTreeLabel(session) { - return session?.branch || sessionDisplayLabel(session); + return compactBranchLabel(session?.branch) || sessionDisplayLabel(session); +} + +function worktreeDisplayLabel(worktreePath, sessions) { + const sessionList = Array.isArray(sessions) + ? sessions.filter(Boolean) + : []; + if (sessionList.length === 1) { + return sessionDisplayLabel(sessionList[0]); + } + + return path.basename(String(worktreePath || '').trim()) || 'worktree'; +} + +function buildWorktreeDescription(sessions, changedCount) { + const sessionList = Array.isArray(sessions) + ? sessions.filter(Boolean) + : []; + const primarySession = sessionList.length === 1 ? sessionList[0] : null; + const totalLocks = sessionList.reduce((total, session) => total + (session.lockCount || 0), 0); + const descriptionParts = []; + + if (primarySession?.agentName) { + descriptionParts.push(primarySession.agentName); + } else { + descriptionParts.push(formatCountLabel(sessionList.length, 'agent')); + } + + const fileCountLabel = primarySession + ? sessionFileCountLabel(primarySession) + : changedCount > 0 + ? formatCountLabel(changedCount, 'file') + : ''; + if (fileCountLabel) { + descriptionParts.push(fileCountLabel); + } + if (totalLocks > 0) { + descriptionParts.push(formatCountLabel(totalLocks, 'lock')); + } + + return descriptionParts.join(' · '); } function sessionWorktreePath(session) { return typeof session?.worktreePath === 'string' ? session.worktreePath.trim() : ''; } +function resolveSessionProjectRelativePath(session) { + const repoRoot = typeof session?.repoRoot === 'string' ? session.repoRoot.trim() : ''; + if (!repoRoot) { + return ''; + } + + const resolveCandidate = (candidatePath) => { + const normalizedCandidate = typeof candidatePath === 'string' ? candidatePath.trim() : ''; + if (!normalizedCandidate) { + return ''; + } + + const absolutePath = path.isAbsolute(normalizedCandidate) + ? path.resolve(normalizedCandidate) + : path.resolve(repoRoot, normalizedCandidate); + if (!isPathWithin(repoRoot, absolutePath) || !fs.existsSync(absolutePath)) { + return ''; + } + + return normalizeRelativePath(path.relative(repoRoot, absolutePath)); + }; + + const isManagedWorktreeRelativePath = (relativePath) => { + const normalizedRelativePath = normalizeRelativePath(relativePath); + return MANAGED_WORKTREE_RELATIVE_ROOTS.some((managedRoot) => { + const normalizedManagedRoot = normalizeRelativePath(managedRoot); + return normalizedRelativePath === normalizedManagedRoot + || normalizedRelativePath.startsWith(`${normalizedManagedRoot}/`); + }); + }; + + const explicitProjectPath = resolveCandidate(session?.projectPath); + if (explicitProjectPath && !isManagedWorktreeRelativePath(explicitProjectPath)) { + return explicitProjectPath; + } + + const namedProjectPath = resolveCandidate(session?.projectName); + if (namedProjectPath && !isManagedWorktreeRelativePath(namedProjectPath)) { + return namedProjectPath; + } + return ''; +} + +function worktreeProjectRelativePath(sessions) { + const projectPaths = uniqueStringList((sessions || []) + .map((session) => resolveSessionProjectRelativePath(session)) + .filter(Boolean)); + return projectPaths.length === 1 ? projectPaths[0] : ''; +} + +function buildProjectScopedDescription(entries) { + const sessions = (entries || []).flatMap((entry) => Array.isArray(entry?.sessions) ? entry.sessions : []); + if (sessions.length === 0) { + return ''; + } + + const changedCount = sessions.reduce((total, session) => total + (session.changeCount || 0), 0); + const lockCount = sessions.reduce((total, session) => total + (session.lockCount || 0), 0); + const descriptionParts = [formatCountLabel(sessions.length, 'agent')]; + if (changedCount > 0) { + descriptionParts.push(formatCountLabel(changedCount, 'file')); + } + if (lockCount > 0) { + descriptionParts.push(formatCountLabel(lockCount, 'lock')); + } + return descriptionParts.join(' · '); +} + +function buildProjectScopedItems(entries, options = {}) { + const normalizedEntries = Array.isArray(entries) + ? entries.filter((entry) => entry?.item) + : []; + const projectRoots = []; + const rootEntries = []; + let hasProjectFolders = false; + + function sortFolders(nodes) { + nodes.sort((left, right) => left.label.localeCompare(right.label)); + for (const node of nodes) { + sortFolders(node.children); + } + } + + for (const entry of normalizedEntries) { + const projectRelativePath = normalizeRelativePath(entry.projectRelativePath); + if (!projectRelativePath) { + rootEntries.push(entry); + continue; + } + + hasProjectFolders = true; + let nodes = projectRoots; + let folderPath = ''; + let parentNode = null; + for (const segment of projectRelativePath.split('/').filter(Boolean)) { + folderPath = folderPath ? path.posix.join(folderPath, segment) : segment; + let folderNode = nodes.find((node) => node.relativePath === folderPath); + if (!folderNode) { + folderNode = { + label: segment, + relativePath: folderPath, + children: [], + entries: [], + directEntries: [], + }; + nodes.push(folderNode); + } + folderNode.entries.push(entry); + parentNode = folderNode; + nodes = folderNode.children; + } + + if (parentNode) { + parentNode.directEntries.push(entry); + } else { + rootEntries.push(entry); + } + } + + if (!hasProjectFolders) { + return rootEntries.map((entry) => entry.item); + } + + sortFolders(projectRoots); + + function materialize(nodes) { + return nodes.map((node) => new FolderItem( + node.label, + node.relativePath, + [ + ...materialize(node.children), + ...node.directEntries.map((entry) => entry.item), + ], + { + description: buildProjectScopedDescription(node.entries), + tooltip: [node.relativePath, buildProjectScopedDescription(node.entries)].filter(Boolean).join('\n'), + }, + )); + } + + const items = materialize(projectRoots); + if (rootEntries.length === 0) { + return items; + } + + const rootLabel = typeof options.rootLabel === 'string' ? options.rootLabel.trim() : ''; + if (!rootLabel) { + items.push(...rootEntries.map((entry) => entry.item)); + return items; + } + + items.push(new FolderItem( + rootLabel, + '', + rootEntries.map((entry) => entry.item), + { + description: buildProjectScopedDescription(rootEntries), + tooltip: rootLabel, + }, + )); + return items; +} + function showSessionMessage(message) { vscode.window.showInformationMessage?.(message); } @@ -1845,25 +2142,31 @@ function partitionChangesByOwnership(sessions, changes) { function buildGroupedChangeTreeNodes(sessions, changes) { const { changesBySession, repoRootChanges } = partitionChangesByOwnership(sessions, changes); - const items = groupSessionsByWorktree( - sessions.filter((session) => (changesBySession.get(session.branch) || []).length > 0), - ).map(({ worktreePath, sessions: worktreeSessions }) => { - const sessionItems = worktreeSessions.map((session) => ( - new SessionItem( - session, - buildChangeTreeNodes(changesBySession.get(session.branch) || []), - { - label: sessionTreeLabel(session), - variant: 'raw', - }, - ) - )); - const changedCount = worktreeSessions.reduce( - (total, session) => total + ((changesBySession.get(session.branch) || []).length), - 0, - ); - return new WorktreeItem(worktreePath, worktreeSessions, sessionItems, { changedCount }); - }); + const items = buildProjectScopedItems( + groupSessionsByWorktree( + sessions.filter((session) => (changesBySession.get(session.branch) || []).length > 0), + ).map(({ worktreePath, sessions: worktreeSessions }) => { + const sessionItems = worktreeSessions.map((session) => ( + new SessionItem( + session, + buildChangeTreeNodes(changesBySession.get(session.branch) || []), + { + label: sessionTreeLabel(session), + variant: 'raw', + }, + ) + )); + const changedCount = worktreeSessions.reduce( + (total, session) => total + ((changesBySession.get(session.branch) || []).length), + 0, + ); + return { + projectRelativePath: worktreeProjectRelativePath(worktreeSessions), + sessions: worktreeSessions, + item: new WorktreeItem(worktreePath, worktreeSessions, sessionItems, { changedCount }), + }; + }), + ); if (repoRootChanges.length > 0) { items.push(new SectionItem('Repo root', buildChangeTreeNodes(repoRootChanges), { @@ -1991,6 +2294,12 @@ function commitWorktree(worktreePath, message) { function buildSessionDetailItems(session) { const provider = resolveSessionProvider(session); const snapshot = sessionSnapshotDisplayName(session); + const projectRelativePath = resolveSessionProjectRelativePath(session); + const badgeSummary = uniqueStringList([ + ...(session.riskBadges || []), + session.deltaLabel || '', + ].filter(Boolean)).join(', '); + const sessionHealthSummary = buildSessionHealthSummary(session); const items = [ new DetailItem('Recent change', session.recentChangeSummary || 'No recent change summary.', { iconId: 'history', @@ -1998,50 +2307,68 @@ function buildSessionDetailItems(session) { new DetailItem('Top files', session.topChangedFilesLabel || 'No tracked file edits.', { iconId: 'list-flat', }), - new DetailItem('Branch', session.branch, { - iconId: 'git-branch', - }), - new DetailItem('Worktree', session.worktreePath, { - iconId: 'folder', - tooltip: session.worktreePath, - }), ]; - if (snapshot) { - items.splice(3, 0, new DetailItem('Snapshot', snapshot, { - iconId: 'account', + if (badgeSummary) { + items.push(new DetailItem('Signals', badgeSummary, { + iconId: 'warning', + })); + } + if (sessionHealthSummary) { + items.push(new DetailItem('Session health', sessionHealthSummary, { + iconId: 'pulse', + tooltip: buildSessionHealthTooltip(session) || sessionHealthSummary, })); } if (provider?.label) { - items.splice(3, 0, new DetailItem('Provider', provider.label, { + items.push(new DetailItem('Provider', provider.label, { iconId: 'sparkle', })); } - const badgeSummary = uniqueStringList([ - ...(session.riskBadges || []), - session.deltaLabel || '', - ].filter(Boolean)).join(', '); - if (badgeSummary) { - items.splice(2, 0, new DetailItem('Signals', badgeSummary, { - iconId: 'warning', + if (snapshot) { + items.push(new DetailItem('Snapshot', snapshot, { + iconId: 'account', })); } + if (projectRelativePath) { + items.push(new DetailItem('Project', projectRelativePath, { + iconId: 'folder', + tooltip: projectRelativePath, + })); + } + items.push(new DetailItem('Branch', session.branch, { + iconId: 'git-branch', + })); + items.push(new DetailItem('Worktree', session.worktreePath, { + iconId: 'folder', + tooltip: session.worktreePath, + })); return items; } function buildWorkingNowNodes(sessions) { - return sortSessionsForWorkingNow( + const sessionEntries = sortSessionsForWorkingNow( sessions.filter((session) => ( session.activityKind === 'working' || session.activityKind === 'blocked' )), - ).map((session) => new SessionItem(session, buildSessionDetailItems(session))); + ).map((session) => ({ + projectRelativePath: resolveSessionProjectRelativePath(session), + sessions: [session], + item: new SessionItem(session, buildSessionDetailItems(session)), + })); + return buildProjectScopedItems(sessionEntries, { rootLabel: 'Repo root' }); } function buildIdleThinkingNodes(sessions) { - return sortSessionsForIdleThinking( + const sessionEntries = sortSessionsForIdleThinking( sessions.filter((session) => !( session.activityKind === 'working' || session.activityKind === 'blocked' )), - ).map((session) => new SessionItem(session, buildSessionDetailItems(session))); + ).map((session) => ({ + projectRelativePath: resolveSessionProjectRelativePath(session), + sessions: [session], + item: new SessionItem(session, buildSessionDetailItems(session)), + })); + return buildProjectScopedItems(sessionEntries, { rootLabel: 'Repo root' }); } function buildUnassignedChangeNodes(changes) { @@ -2056,26 +2383,31 @@ function buildRawActiveAgentGroupNodes(sessions) { const groups = []; for (const group of SESSION_ACTIVITY_GROUPS) { const groupSessions = sessions.filter((session) => session.activityKind === group.kind); - const worktreeItems = groupSessionsByWorktree(groupSessions).map(({ worktreePath, sessions: worktreeSessions }) => ( - new WorktreeItem( - worktreePath, - worktreeSessions, - worktreeSessions.map((session) => new SessionItem( - session, - buildChangeTreeNodes(session.touchedChanges || []), + const worktreeItems = buildProjectScopedItems( + groupSessionsByWorktree(groupSessions).map(({ worktreePath, sessions: worktreeSessions }) => ({ + projectRelativePath: worktreeProjectRelativePath(worktreeSessions), + sessions: worktreeSessions, + item: new WorktreeItem( + worktreePath, + worktreeSessions, + worktreeSessions.map((session) => new SessionItem( + session, + buildChangeTreeNodes(session.touchedChanges || []), + { + label: sessionTreeLabel(session), + variant: 'raw', + }, + )), { - label: sessionTreeLabel(session), - variant: 'raw', + description: buildWorktreeBranchDescription(worktreeSessions), + iconId: 'git-branch', + resourceSession: worktreeSessions[0], + useSessionDecoration: true, }, - )), - { - description: buildWorktreeBranchDescription(worktreeSessions), - iconId: 'git-branch', - resourceSession: worktreeSessions[0], - useSessionDecoration: true, - }, - ) - )); + ), + })), + { rootLabel: 'Repo root' }, + ); if (worktreeItems.length > 0) { groups.push(new SectionItem(group.label, worktreeItems)); } diff --git a/templates/vscode/guardex-active-agents/session-schema.js b/templates/vscode/guardex-active-agents/session-schema.js index e561987..1a34d18 100644 --- a/templates/vscode/guardex-active-agents/session-schema.js +++ b/templates/vscode/guardex-active-agents/session-schema.js @@ -76,6 +76,46 @@ function toPositiveInteger(value) { return Number.isInteger(normalized) && normalized > 0 ? normalized : null; } +function toBoundedInteger(value, min, max) { + const normalized = Number.parseInt(String(value ?? ''), 10); + if (!Number.isInteger(normalized) || normalized < min || normalized > max) { + return null; + } + return normalized; +} + +function normalizeStringList(values) { + if (!Array.isArray(values)) { + return []; + } + + return values + .map((value) => toNonEmptyString(value)) + .filter(Boolean); +} + +function normalizeSessionHealthPayload(input) { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + return null; + } + + const rawScores = input.scores && typeof input.scores === 'object' && !Array.isArray(input.scores) + ? input.scores + : null; + const score = toBoundedInteger(input.score ?? input.total ?? rawScores?.total, 0, 100); + if (score === null) { + return null; + } + + return { + score, + label: toNonEmptyString(input.label), + primaryDriver: toNonEmptyString(input.primaryDriver), + secondaries: normalizeStringList(input.secondaries), + outputLine: toNonEmptyString(input.outputLine), + }; +} + function normalizeTaskMode(value) { const normalized = toNonEmptyString(value).toLowerCase(); return normalized === 'caveman' || normalized === 'omx' ? normalized : ''; @@ -126,6 +166,17 @@ function normalizeRelativePath(value) { return toNonEmptyString(value).replace(/\\/g, '/').replace(/^\.\//, ''); } +function normalizeProjectPath(value) { + const normalized = toNonEmptyString(value); + if (!normalized) { + return ''; + } + + return path.isAbsolute(normalized) + ? path.resolve(normalized) + : normalizeRelativePath(normalized); +} + function readJsonFile(filePath) { try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); @@ -744,6 +795,8 @@ function buildSessionRecord(input) { taskName: toNonEmptyString(input.taskName, 'task'), latestTaskPreview: '', agentName: toNonEmptyString(input.agentName, 'agent'), + projectName: toNonEmptyString(input.projectName), + projectPath: normalizeProjectPath(input.projectPath), snapshotName: toNonEmptyString(input.snapshotName), snapshotEmail: toNonEmptyString(input.snapshotEmail || input.email), worktreePath, @@ -755,6 +808,7 @@ function buildSessionRecord(input) { startedAt: startedAt.toISOString(), lastHeartbeatAt: lastHeartbeatAt.toISOString(), state: normalizeAdvisoryState(input.state), + sessionHealth: normalizeSessionHealthPayload(input.sessionHealth || input.sessionSeverity), }; } @@ -796,6 +850,8 @@ function normalizeSessionRecord(input, options = {}) { taskName: toNonEmptyString(input.taskName, 'task'), latestTaskPreview: '', agentName: toNonEmptyString(input.agentName, 'agent'), + projectName: toNonEmptyString(input.projectName), + projectPath: normalizeProjectPath(input.projectPath), snapshotName: toNonEmptyString(input.snapshotName), snapshotEmail: toNonEmptyString(input.snapshotEmail || input.email), worktreePath: path.resolve(worktreePath), @@ -817,6 +873,7 @@ function normalizeSessionRecord(input, options = {}) { lockSnapshotCount: 0, lockSessionCount: 0, collaboration: false, + sessionHealth: normalizeSessionHealthPayload(input.sessionHealth || input.sessionSeverity), }; } @@ -901,6 +958,9 @@ function flattenTelemetrySnapshotSessions(lockPayload) { projectPath: toNonEmptyString(session?.projectPath), snapshotName: toNonEmptyString(snapshot?.snapshotName), email: toNonEmptyString(snapshot?.email), + sessionHealth: normalizeSessionHealthPayload( + session?.sessionHealth || session?.sessionSeverity || snapshot?.sessionHealth || snapshot?.sessionSeverity, + ), }); } } @@ -926,6 +986,7 @@ function deriveLockTaskAnchor(entries, fallbackTaskName, fallbackTimestamp) { taskName: latestEntry?.taskPreview || fallbackTaskName || 'task', latestTaskPreview: latestEntry?.taskPreview || '', timestamp: latestEntry?.taskUpdatedAt || fallbackTimestamp || '', + sessionHealth: latestEntry?.sessionHealth || null, }; } @@ -951,6 +1012,15 @@ function deriveLockSnapshotIdentity(entries) { }; } +function deriveLockProjectMetadata(entries) { + const latestEntry = sortTelemetryEntriesForAnchor(entries) + .find((entry) => entry?.projectPath || entry?.projectName) || null; + return { + projectName: toNonEmptyString(latestEntry?.projectName), + projectPath: normalizeProjectPath(latestEntry?.projectPath), + }; +} + function buildWorktreeLockSession(repoRoot, worktreePath, lockPayload, options = {}) { const now = options.now || Date.now(); const telemetryEntries = flattenTelemetrySnapshotSessions(lockPayload); @@ -962,6 +1032,7 @@ function buildWorktreeLockSession(repoRoot, worktreePath, lockPayload, options = const label = deriveSessionLabel(effectiveBranch, worktreePath); const taskAnchor = deriveLockTaskAnchor(telemetryEntries, label, telemetryUpdatedAt); const snapshotIdentity = deriveLockSnapshotIdentity(telemetryEntries); + const projectMetadata = deriveLockProjectMetadata(telemetryEntries); const startedAt = taskAnchor.timestamp || telemetryUpdatedAt || new Date(now).toISOString(); const session = { @@ -971,6 +1042,8 @@ function buildWorktreeLockSession(repoRoot, worktreePath, lockPayload, options = taskName: taskAnchor.taskName, latestTaskPreview: taskAnchor.latestTaskPreview, agentName: deriveAgentNameFromBranch(effectiveBranch), + projectName: projectMetadata.projectName, + projectPath: projectMetadata.projectPath, snapshotName: snapshotIdentity.snapshotName, snapshotEmail: snapshotIdentity.snapshotEmail, worktreePath: path.resolve(worktreePath), @@ -992,6 +1065,9 @@ function buildWorktreeLockSession(repoRoot, worktreePath, lockPayload, options = lockSnapshotCount: toPositiveInteger(lockPayload?.snapshotCount) || 0, lockSessionCount: toPositiveInteger(lockPayload?.sessionCount) || telemetryEntries.length, collaboration: Boolean(lockPayload?.collaboration), + sessionHealth: taskAnchor.sessionHealth || normalizeSessionHealthPayload( + lockPayload?.sessionHealth || lockPayload?.sessionSeverity, + ), }; session.elapsedLabel = formatElapsedFrom(session.startedAt, now); @@ -1015,6 +1091,8 @@ function buildManagedWorktreeSession(repoRoot, worktreePath, options = {}) { taskName: label, latestTaskPreview: '', agentName: deriveAgentNameFromBranch(branch), + projectName: '', + projectPath: '', snapshotName: '', snapshotEmail: '', worktreePath: path.resolve(worktreePath), @@ -1036,6 +1114,7 @@ function buildManagedWorktreeSession(repoRoot, worktreePath, options = {}) { lockSnapshotCount: 0, lockSessionCount: 0, collaboration: false, + sessionHealth: null, }; session.elapsedLabel = formatElapsedFrom(session.startedAt, now); @@ -1142,6 +1221,21 @@ function mergeSessionSources(primarySessions, lockSessions) { } if (lockSession) { consumedLockWorktrees.add(worktreeKey); + merged.push({ + ...session, + latestTaskPreview: session.latestTaskPreview || lockSession.latestTaskPreview, + projectName: session.projectName || lockSession.projectName, + projectPath: session.projectPath || lockSession.projectPath, + snapshotName: session.snapshotName || lockSession.snapshotName, + snapshotEmail: session.snapshotEmail || lockSession.snapshotEmail, + telemetryUpdatedAt: session.telemetryUpdatedAt || lockSession.telemetryUpdatedAt, + telemetrySource: session.telemetrySource || lockSession.telemetrySource, + lockSnapshotCount: session.lockSnapshotCount || lockSession.lockSnapshotCount, + lockSessionCount: session.lockSessionCount || lockSession.lockSessionCount, + collaboration: session.collaboration || lockSession.collaboration, + sessionHealth: session.sessionHealth || lockSession.sessionHealth, + }); + continue; } merged.push(session); } diff --git a/test/vscode-active-agents-session-state.test.js b/test/vscode-active-agents-session-state.test.js index 6c83f23..ffc7036 100644 --- a/test/vscode-active-agents-session-state.test.js +++ b/test/vscode-active-agents-session-state.test.js @@ -1991,14 +1991,24 @@ test('active-agents extension shows grouped repo changes beside active agents', assert.equal(unassignedChangeItem.label, 'root-file.txt'); assert.equal(unassignedChangeItem.description, 'M · Protected branch'); + const activeAgentTree = await getSectionByLabel(provider, advancedSection, 'Active agent tree'); + const rawWorkingSection = await getSectionByLabel(provider, activeAgentTree, 'WORKING NOW'); + const [rawWorktreeItem] = await provider.getChildren(rawWorkingSection); + assert.equal(rawWorktreeItem.label, 'live-task'); + assert.equal(rawWorktreeItem.description, 'working: codex'); + const [rawSessionItem] = await provider.getChildren(rawWorktreeItem); + assert.equal(rawSessionItem.label, 'codex/live-task'); + assert.match(rawSessionItem.description, /^Working · 2 files · /); + const rawPathTree = await getSectionByLabel(provider, advancedSection, 'Raw path tree'); const [worktreeGroup, repoRootGroup] = await provider.getChildren(rawPathTree); - assert.equal(worktreeGroup.label, `${path.basename(worktreePath)}`); - assert.equal(worktreeGroup.description, '1 agent · 2 changed'); + assert.equal(worktreeGroup.label, 'live-task'); + assert.equal(worktreeGroup.description, 'codex · 2 files'); assert.equal(repoRootGroup.label, 'Repo root'); const [sessionGroup] = await provider.getChildren(worktreeGroup); - assert.equal(sessionGroup.label, sessionItem.session.branch); + assert.equal(sessionGroup.label, 'codex/live-task'); + assert.match(sessionGroup.description, /^Working · 2 files · /); const [folderItem, trackedItem] = await provider.getChildren(sessionGroup); assert.equal(folderItem.label, 'src'); assert.equal(trackedItem.label, 'tracked.txt'); @@ -2035,6 +2045,8 @@ test('active-agents extension surfaces live managed worktrees from AGENT.lock fa runGit(worktreePath, ['add', 'src/live.js']); runGit(worktreePath, ['commit', '-m', 'baseline']); fs.writeFileSync(path.join(worktreePath, 'src', 'live.js'), 'base\nchanged\n', 'utf8'); + const projectPath = path.join(tempRoot, 'gitguardex'); + fs.mkdirSync(projectPath, { recursive: true }); const lockPath = writeWorktreeLock(worktreePath, { updatedAt: '2026-04-22T09:01:00.000Z', snapshots: [ @@ -2051,7 +2063,7 @@ test('active-agents extension surfaces live managed worktrees from AGENT.lock fa taskPreview: 'Implement live worktree telemetry', taskUpdatedAt: '2026-04-22T08:55:00.000Z', projectName: 'gitguardex', - projectPath: worktreePath, + projectPath, }, ], }, @@ -2084,13 +2096,34 @@ test('active-agents extension surfaces live managed worktrees from AGENT.lock fa 'Advanced details', ]); const workingSection = await getSectionByLabel(provider, repoItem, 'Working now'); - const { worktreeItem, sessionItem } = await getOnlyWorktreeAndSession(provider, workingSection); - assert.equal(worktreeItem, null); + const [projectFolder] = await provider.getChildren(workingSection); + assert.equal(projectFolder.label, 'gitguardex'); + assert.equal(projectFolder.description, '1 agent · 1 file'); + const [sessionItem] = await provider.getChildren(projectFolder); + assert.equal(sessionItem.label, 'Implement live worktree telemetry'); assert.equal(sessionItem.session.branch, 'agent/codex/lock-visible-task'); assert.match(sessionItem.description, /^Working: codex · via OpenAI · snapshot nagyviktor@edixa\.com · 1 changed file/); assert.equal(sessionItem.session.snapshotName, 'nagyviktor@edixa.com'); assert.match(sessionItem.tooltip, /Telemetry updated 2026-04-22T09:01:00.000Z/); assert.match(sessionItem.tooltip, /Snapshot nagyviktor@edixa\.com/); + + const advancedSection = await getSectionByLabel(provider, repoItem, 'Advanced details'); + const activeAgentTree = await getSectionByLabel(provider, advancedSection, 'Active agent tree'); + const rawWorkingSection = await getSectionByLabel(provider, activeAgentTree, 'WORKING NOW'); + const [rawProjectFolder] = await provider.getChildren(rawWorkingSection); + assert.equal(rawProjectFolder.label, 'gitguardex'); + assert.equal(rawProjectFolder.description, '1 agent · 1 file'); + const [rawWorktreeItem] = await provider.getChildren(rawProjectFolder); + assert.equal(rawWorktreeItem.label, 'Implement live worktree telemetry'); + assert.equal(rawWorktreeItem.description, 'working: codex · snapshot nagyviktor@edixa.com'); + assert.equal( + rawWorktreeItem.resourceUri.toString(), + `gitguardex-agent://${sessionSchema.sanitizeBranchForFile('agent/codex/lock-visible-task')}`, + ); + const [rawSessionItem] = await provider.getChildren(rawWorktreeItem); + assert.equal(rawSessionItem.label, 'codex/lock-visible-task'); + assert.match(rawSessionItem.description, /^Working · 1 file · /); + const snapshotDecoration = registrations.decorationProviders[0].provideFileDecoration(vscode.Uri.parse( `gitguardex-agent://${sessionSchema.sanitizeBranchForFile('agent/codex/lock-visible-task')}`, )); @@ -2102,6 +2135,144 @@ test('active-agents extension surfaces live managed worktrees from AGENT.lock fa } }); +test('active-agents extension shows session health from active-session records', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-session-health-active-')); + initGitRepo(tempRoot); + + const worktreePath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-session-health-worktree-')); + 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'); + + const branch = 'agent/codex/health-task'; + const sessionPath = sessionSchema.sessionFilePathForBranch(tempRoot, branch); + fs.mkdirSync(path.dirname(sessionPath), { recursive: true }); + const record = sessionSchema.buildSessionRecord({ + repoRoot: tempRoot, + branch, + taskName: 'health-task', + agentName: 'codex', + worktreePath, + pid: process.pid, + cliName: 'codex', + state: 'working', + }); + record.sessionHealth = { + score: 45, + label: 'Inefficient', + primaryDriver: 'turn fragmentation', + secondaries: ['write_stdin churn'], + outputLine: 'Score 45/100 — Inefficient. Primary: turn fragmentation. Secondaries: write_stdin churn.', + }; + fs.writeFileSync(sessionPath, `${JSON.stringify(record, 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); + await flushAsyncWork(); + + const provider = registrations.providers[0].provider; + const [repoItem] = await provider.getChildren(); + const workingSection = await getSectionByLabel(provider, repoItem, 'Working now'); + const { worktreeItem, sessionItem } = await getOnlyWorktreeAndSession(provider, workingSection); + assert.equal(worktreeItem, null); + assert.match(sessionItem.description, /^Working: codex · via OpenAI · 1 changed file · 45\/100/); + assert.match(sessionItem.tooltip, /Session health 45\/100 · Inefficient/); + const sessionDetails = await provider.getChildren(sessionItem); + const sessionHealthItem = sessionDetails.find((item) => item.label === 'Session health'); + assert.equal(sessionHealthItem?.description, '45/100 · Inefficient'); + assert.match(sessionHealthItem?.tooltip || '', /Score 45\/100 — Inefficient\./); + + for (const subscription of context.subscriptions) { + subscription.dispose?.(); + } +}); + +test('active-agents extension shows session health from AGENT.lock fallback telemetry', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-session-health-lock-')); + initGitRepo(tempRoot); + + const worktreePath = path.join( + tempRoot, + '.omx', + 'agent-worktrees', + 'agent__codex__health-lock-task', + ); + initGitRepo(worktreePath); + runGit(worktreePath, ['checkout', '-b', 'agent/codex/health-lock-task']); + fs.mkdirSync(path.join(worktreePath, 'src'), { recursive: true }); + fs.writeFileSync(path.join(worktreePath, 'src', 'live.js'), 'base\n', 'utf8'); + runGit(worktreePath, ['add', 'src/live.js']); + runGit(worktreePath, ['commit', '-m', 'baseline']); + fs.writeFileSync(path.join(worktreePath, 'src', 'live.js'), 'base\nchanged\n', 'utf8'); + const lockPath = writeWorktreeLock(worktreePath, { + updatedAt: '2026-04-22T09:01:00.000Z', + snapshots: [ + { + snapshotName: 'snapshot-a', + accountId: 'acct-1', + email: 'agent@example.com', + liveSessionCount: 1, + trackedSessionCount: 1, + compatSessionCount: 1, + sessions: [ + { + sessionKey: 'pid:101', + taskPreview: 'Implement live worktree telemetry', + taskUpdatedAt: '2026-04-22T08:55:00.000Z', + projectName: 'gitguardex', + projectPath: worktreePath, + sessionHealth: { + score: 45, + label: 'Inefficient', + primaryDriver: 'turn fragmentation', + secondaries: ['write_stdin churn'], + outputLine: 'Score 45/100 — Inefficient. Primary: turn fragmentation. Secondaries: write_stdin churn.', + }, + }, + ], + }, + ], + }); + + const { registrations, vscode } = createMockVscode(tempRoot); + vscode.workspace.findFiles = async (pattern) => { + if (pattern === '**/.omx/state/active-sessions/*.json') { + return []; + } + if (pattern === '**/{.omx,.omc}/agent-worktrees/**/AGENT.lock') { + return [{ fsPath: lockPath }]; + } + return []; + }; + const extension = loadExtensionWithMockVscode(vscode); + const context = { subscriptions: [] }; + + extension.activate(context); + await flushAsyncWork(); + + const provider = registrations.providers[0].provider; + const [repoItem] = await provider.getChildren(); + const workingSection = await getSectionByLabel(provider, repoItem, 'Working now'); + const { worktreeItem, sessionItem } = await getOnlyWorktreeAndSession(provider, workingSection); + assert.equal(worktreeItem, null); + assert.match(sessionItem.description, /^Working: codex · via OpenAI · snapshot snapshot-a · 1 changed file · 45\/100/); + assert.match(sessionItem.tooltip, /Session health 45\/100 · Inefficient/); + const sessionDetails = await provider.getChildren(sessionItem); + const sessionHealthItem = sessionDetails.find((item) => item.label === 'Session health'); + assert.equal(sessionHealthItem?.description, '45/100 · Inefficient'); + assert.match(sessionHealthItem?.tooltip || '', /Score 45\/100 — Inefficient\./); + + for (const subscription of context.subscriptions) { + subscription.dispose?.(); + } +}); + test('active-agents extension surfaces plain managed worktrees from workspace fallback', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-managed-worktree-view-')); initGitRepo(tempRoot); @@ -2221,11 +2392,13 @@ test('active-agents extension decorates sessions and repo changes from the lock const activeAgentTree = await getSectionByLabel(provider, advancedSection, 'Active agent tree'); const rawWorkingSection = await getSectionByLabel(provider, activeAgentTree, 'WORKING NOW'); - const worktreeGroup = await getChildByLabel(provider, rawWorkingSection, path.basename(worktreePath)); + const worktreeGroup = await getChildByLabel(provider, rawWorkingSection, 'live-task'); assert.equal(worktreeGroup.iconPath.id, 'git-branch'); assert.equal(worktreeGroup.description, 'working: codex'); assert.equal(worktreeGroup.resourceUri.toString(), `gitguardex-agent://${sessionSchema.sanitizeBranchForFile(branch)}`); const [sessionGroup] = await provider.getChildren(worktreeGroup); + assert.equal(sessionGroup.label, 'codex/live-task'); + assert.match(sessionGroup.description, /^Working · 1 file · /); const [sessionChangeItem] = await provider.getChildren(sessionGroup); assert.equal(sessionChangeItem.label, 'tracked.txt'); assert.equal(sessionChangeItem.iconPath.id, 'warning'); diff --git a/vscode/guardex-active-agents/extension.js b/vscode/guardex-active-agents/extension.js index fcd9d29..a4caf67 100644 --- a/vscode/guardex-active-agents/extension.js +++ b/vscode/guardex-active-agents/extension.js @@ -17,6 +17,10 @@ const LOCK_FILE_RELATIVE = path.join('.omx', 'state', 'agent-file-locks.json'); const ACTIVE_SESSION_FILES_GLOB = '**/.omx/state/active-sessions/*.json'; const AGENT_FILE_LOCKS_GLOB = '**/.omx/state/agent-file-locks.json'; const WORKTREE_AGENT_LOCKS_GLOB = '**/{.omx,.omc}/agent-worktrees/**/AGENT.lock'; +const MANAGED_WORKTREE_RELATIVE_ROOTS = [ + path.join('.omx', 'agent-worktrees'), + path.join('.omc', 'agent-worktrees'), +]; const AGENT_LOG_FILES_GLOB = '**/.omx/logs/*.log'; const SESSION_SCAN_EXCLUDE_GLOB = '**/{node_modules,.git,.omx/agent-worktrees,.omc/agent-worktrees}/**'; const WORKTREE_LOCK_SCAN_EXCLUDE_GLOB = '**/{node_modules,.git}/**'; @@ -131,6 +135,34 @@ function formatCountLabel(count, singular, plural = `${singular}s`) { return `${count} ${count === 1 ? singular : plural}`; } +function branchSegments(branch) { + return String(branch || '') + .split('/') + .map((segment) => segment.trim()) + .filter(Boolean); +} + +function compactBranchLabel(branch) { + const segments = branchSegments(branch); + if (segments.length >= 3 && segments[0] === 'agent') { + return `${segments[1]}/${segments.slice(2).join('/')}`; + } + return segments.join('/'); +} + +function sessionFileCountLabel(session) { + const activityCountLabel = typeof session?.activityCountLabel === 'string' + ? session.activityCountLabel.trim() + : ''; + if (activityCountLabel) { + return activityCountLabel; + } + if ((session?.changeCount || 0) > 0) { + return formatCountLabel(session.changeCount, 'file'); + } + return ''; +} + function uniqueStringList(values) { const seen = new Set(); const result = []; @@ -458,6 +490,54 @@ function sessionStatusLabel(session) { } } +function sessionHealthScore(session) { + return Number.isInteger(session?.sessionHealth?.score) ? session.sessionHealth.score : null; +} + +function buildSessionHealthCompactLabel(session) { + const score = sessionHealthScore(session); + return score === null ? '' : `${score}/100`; +} + +function buildSessionHealthSummary(session) { + const compactLabel = buildSessionHealthCompactLabel(session); + if (!compactLabel) { + return ''; + } + + const label = typeof session?.sessionHealth?.label === 'string' + ? session.sessionHealth.label.trim() + : ''; + return label ? `${compactLabel} · ${label}` : compactLabel; +} + +function buildSessionHealthDriversSummary(session) { + const primaryDriver = typeof session?.sessionHealth?.primaryDriver === 'string' + ? session.sessionHealth.primaryDriver.trim() + : ''; + const secondaries = uniqueStringList(Array.isArray(session?.sessionHealth?.secondaries) + ? session.sessionHealth.secondaries.map((value) => String(value || '').trim()) + : []); + return [ + primaryDriver ? `Primary: ${primaryDriver}` : '', + secondaries.length > 0 ? `Secondary: ${secondaries.join(', ')}` : '', + ].filter(Boolean).join(' | '); +} + +function buildSessionHealthTooltip(session) { + const outputLine = typeof session?.sessionHealth?.outputLine === 'string' + ? session.sessionHealth.outputLine.trim() + : ''; + if (outputLine) { + return outputLine; + } + + return [ + buildSessionHealthSummary(session), + buildSessionHealthDriversSummary(session), + ].filter(Boolean).join('\n'); +} + function buildSessionTopFiles(session) { return uniqueStringList((session?.worktreeChangedPaths || []) .map(normalizeRelativePath) @@ -507,6 +587,7 @@ function buildSessionCardDescription(session) { session.deltaLabel || '', session.changeCount > 0 ? formatCountLabel(session.changeCount, 'changed file') : '', session.lockCount > 0 ? formatCountLabel(session.lockCount, 'lock') : '', + buildSessionHealthCompactLabel(session), session.freshnessLabel || '', session.lastActiveLabel ? `${session.lastActiveLabel} ago` : '', ].filter(Boolean); @@ -515,8 +596,11 @@ function buildSessionCardDescription(session) { function buildRawSessionDescription(session) { const provider = resolveSessionProvider(session); - const status = sessionStatusLabel(session).toLowerCase(); - const descriptionParts = [`${status}: ${session.agentName || 'agent'}`]; + const descriptionParts = [sessionStatusLabel(session)]; + const fileCountLabel = sessionFileCountLabel(session); + if (fileCountLabel) { + descriptionParts.push(fileCountLabel); + } if (provider?.label) { descriptionParts.push(provider.label); } @@ -524,10 +608,11 @@ function buildRawSessionDescription(session) { if (snapshot) { descriptionParts.push(snapshot); } - if (session.activityCountLabel) { - descriptionParts.push(session.activityCountLabel); - } descriptionParts.push(session.elapsedLabel || formatElapsedFrom(session.startedAt)); + const sessionHealthLabel = buildSessionHealthCompactLabel(session); + if (sessionHealthLabel) { + descriptionParts.push(sessionHealthLabel); + } if (session.lockCount > 0) { descriptionParts.push(formatCountLabel(session.lockCount, 'lock')); } @@ -541,6 +626,8 @@ function buildSessionTooltip(session, description) { session?.deltaLabel || '', ].filter(Boolean)).join(', '); const topFiles = session?.topChangedFilesLabel || summarizeCompactPaths(session?.worktreeChangedPaths || []); + const sessionHealthSummary = buildSessionHealthSummary(session); + const sessionHealthDrivers = buildSessionHealthDriversSummary(session); return [ session.branch, provider?.label @@ -549,6 +636,8 @@ function buildSessionTooltip(session, description) { sessionSnapshotDisplayName(session) ? `Snapshot ${sessionSnapshotDisplayName(session)}` : '', `${session.agentName} · ${session.taskName}`, `Status ${description}`, + sessionHealthSummary ? `Session health ${sessionHealthSummary}` : '', + sessionHealthDrivers ? `Drivers ${sessionHealthDrivers}` : '', session.recentChangeSummary ? `Recent ${session.recentChangeSummary}` : '', topFiles ? `Top files ${topFiles}` : '', riskSummary ? `Signals ${riskSummary}` : '', @@ -1002,18 +1091,17 @@ class WorktreeItem extends vscode.TreeItem { const changedCount = Number.isInteger(options.changedCount) ? options.changedCount : sessionList.reduce((total, session) => total + (session.changeCount || 0), 0); - const descriptionParts = [formatCountLabel(sessionList.length, 'agent')]; - if (changedCount > 0) { - descriptionParts.push(`${changedCount} changed`); - } + const label = typeof options.label === 'string' && options.label.trim() + ? options.label.trim() + : worktreeDisplayLabel(normalizedWorktreePath, sessionList); super( - options.label || path.basename(normalizedWorktreePath || '') || 'worktree', + label, items.length > 0 ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.None, ); this.worktreePath = normalizedWorktreePath; this.sessions = sessionList; this.items = items; - this.description = options.description || descriptionParts.join(' · '); + this.description = options.description || buildWorktreeDescription(sessionList, changedCount); this.tooltip = [ normalizedWorktreePath, ...sessionList.map((session) => session.branch).filter(Boolean), @@ -1068,13 +1156,19 @@ class SessionItem extends vscode.TreeItem { } class FolderItem extends vscode.TreeItem { - constructor(label, relativePath, items) { - super(label, vscode.TreeItemCollapsibleState.Expanded); + constructor(label, relativePath, items, options = {}) { + super( + label, + items.length > 0 + ? (options.collapsedState ?? vscode.TreeItemCollapsibleState.Expanded) + : vscode.TreeItemCollapsibleState.None, + ); this.relativePath = relativePath; this.items = items; - this.tooltip = relativePath; - this.iconPath = new vscode.ThemeIcon('folder'); - this.contextValue = 'gitguardex.folder'; + this.description = typeof options.description === 'string' ? options.description : ''; + this.tooltip = options.tooltip || relativePath || label; + this.iconPath = new vscode.ThemeIcon(options.iconId || 'folder'); + this.contextValue = options.contextValue || 'gitguardex.folder'; } } @@ -1144,13 +1238,216 @@ function sessionDisplayLabel(session) { } function sessionTreeLabel(session) { - return session?.branch || sessionDisplayLabel(session); + return compactBranchLabel(session?.branch) || sessionDisplayLabel(session); +} + +function worktreeDisplayLabel(worktreePath, sessions) { + const sessionList = Array.isArray(sessions) + ? sessions.filter(Boolean) + : []; + if (sessionList.length === 1) { + return sessionDisplayLabel(sessionList[0]); + } + + return path.basename(String(worktreePath || '').trim()) || 'worktree'; +} + +function buildWorktreeDescription(sessions, changedCount) { + const sessionList = Array.isArray(sessions) + ? sessions.filter(Boolean) + : []; + const primarySession = sessionList.length === 1 ? sessionList[0] : null; + const totalLocks = sessionList.reduce((total, session) => total + (session.lockCount || 0), 0); + const descriptionParts = []; + + if (primarySession?.agentName) { + descriptionParts.push(primarySession.agentName); + } else { + descriptionParts.push(formatCountLabel(sessionList.length, 'agent')); + } + + const fileCountLabel = primarySession + ? sessionFileCountLabel(primarySession) + : changedCount > 0 + ? formatCountLabel(changedCount, 'file') + : ''; + if (fileCountLabel) { + descriptionParts.push(fileCountLabel); + } + if (totalLocks > 0) { + descriptionParts.push(formatCountLabel(totalLocks, 'lock')); + } + + return descriptionParts.join(' · '); } function sessionWorktreePath(session) { return typeof session?.worktreePath === 'string' ? session.worktreePath.trim() : ''; } +function resolveSessionProjectRelativePath(session) { + const repoRoot = typeof session?.repoRoot === 'string' ? session.repoRoot.trim() : ''; + if (!repoRoot) { + return ''; + } + + const resolveCandidate = (candidatePath) => { + const normalizedCandidate = typeof candidatePath === 'string' ? candidatePath.trim() : ''; + if (!normalizedCandidate) { + return ''; + } + + const absolutePath = path.isAbsolute(normalizedCandidate) + ? path.resolve(normalizedCandidate) + : path.resolve(repoRoot, normalizedCandidate); + if (!isPathWithin(repoRoot, absolutePath) || !fs.existsSync(absolutePath)) { + return ''; + } + + return normalizeRelativePath(path.relative(repoRoot, absolutePath)); + }; + + const isManagedWorktreeRelativePath = (relativePath) => { + const normalizedRelativePath = normalizeRelativePath(relativePath); + return MANAGED_WORKTREE_RELATIVE_ROOTS.some((managedRoot) => { + const normalizedManagedRoot = normalizeRelativePath(managedRoot); + return normalizedRelativePath === normalizedManagedRoot + || normalizedRelativePath.startsWith(`${normalizedManagedRoot}/`); + }); + }; + + const explicitProjectPath = resolveCandidate(session?.projectPath); + if (explicitProjectPath && !isManagedWorktreeRelativePath(explicitProjectPath)) { + return explicitProjectPath; + } + + const namedProjectPath = resolveCandidate(session?.projectName); + if (namedProjectPath && !isManagedWorktreeRelativePath(namedProjectPath)) { + return namedProjectPath; + } + return ''; +} + +function worktreeProjectRelativePath(sessions) { + const projectPaths = uniqueStringList((sessions || []) + .map((session) => resolveSessionProjectRelativePath(session)) + .filter(Boolean)); + return projectPaths.length === 1 ? projectPaths[0] : ''; +} + +function buildProjectScopedDescription(entries) { + const sessions = (entries || []).flatMap((entry) => Array.isArray(entry?.sessions) ? entry.sessions : []); + if (sessions.length === 0) { + return ''; + } + + const changedCount = sessions.reduce((total, session) => total + (session.changeCount || 0), 0); + const lockCount = sessions.reduce((total, session) => total + (session.lockCount || 0), 0); + const descriptionParts = [formatCountLabel(sessions.length, 'agent')]; + if (changedCount > 0) { + descriptionParts.push(formatCountLabel(changedCount, 'file')); + } + if (lockCount > 0) { + descriptionParts.push(formatCountLabel(lockCount, 'lock')); + } + return descriptionParts.join(' · '); +} + +function buildProjectScopedItems(entries, options = {}) { + const normalizedEntries = Array.isArray(entries) + ? entries.filter((entry) => entry?.item) + : []; + const projectRoots = []; + const rootEntries = []; + let hasProjectFolders = false; + + function sortFolders(nodes) { + nodes.sort((left, right) => left.label.localeCompare(right.label)); + for (const node of nodes) { + sortFolders(node.children); + } + } + + for (const entry of normalizedEntries) { + const projectRelativePath = normalizeRelativePath(entry.projectRelativePath); + if (!projectRelativePath) { + rootEntries.push(entry); + continue; + } + + hasProjectFolders = true; + let nodes = projectRoots; + let folderPath = ''; + let parentNode = null; + for (const segment of projectRelativePath.split('/').filter(Boolean)) { + folderPath = folderPath ? path.posix.join(folderPath, segment) : segment; + let folderNode = nodes.find((node) => node.relativePath === folderPath); + if (!folderNode) { + folderNode = { + label: segment, + relativePath: folderPath, + children: [], + entries: [], + directEntries: [], + }; + nodes.push(folderNode); + } + folderNode.entries.push(entry); + parentNode = folderNode; + nodes = folderNode.children; + } + + if (parentNode) { + parentNode.directEntries.push(entry); + } else { + rootEntries.push(entry); + } + } + + if (!hasProjectFolders) { + return rootEntries.map((entry) => entry.item); + } + + sortFolders(projectRoots); + + function materialize(nodes) { + return nodes.map((node) => new FolderItem( + node.label, + node.relativePath, + [ + ...materialize(node.children), + ...node.directEntries.map((entry) => entry.item), + ], + { + description: buildProjectScopedDescription(node.entries), + tooltip: [node.relativePath, buildProjectScopedDescription(node.entries)].filter(Boolean).join('\n'), + }, + )); + } + + const items = materialize(projectRoots); + if (rootEntries.length === 0) { + return items; + } + + const rootLabel = typeof options.rootLabel === 'string' ? options.rootLabel.trim() : ''; + if (!rootLabel) { + items.push(...rootEntries.map((entry) => entry.item)); + return items; + } + + items.push(new FolderItem( + rootLabel, + '', + rootEntries.map((entry) => entry.item), + { + description: buildProjectScopedDescription(rootEntries), + tooltip: rootLabel, + }, + )); + return items; +} + function showSessionMessage(message) { vscode.window.showInformationMessage?.(message); } @@ -1845,25 +2142,31 @@ function partitionChangesByOwnership(sessions, changes) { function buildGroupedChangeTreeNodes(sessions, changes) { const { changesBySession, repoRootChanges } = partitionChangesByOwnership(sessions, changes); - const items = groupSessionsByWorktree( - sessions.filter((session) => (changesBySession.get(session.branch) || []).length > 0), - ).map(({ worktreePath, sessions: worktreeSessions }) => { - const sessionItems = worktreeSessions.map((session) => ( - new SessionItem( - session, - buildChangeTreeNodes(changesBySession.get(session.branch) || []), - { - label: sessionTreeLabel(session), - variant: 'raw', - }, - ) - )); - const changedCount = worktreeSessions.reduce( - (total, session) => total + ((changesBySession.get(session.branch) || []).length), - 0, - ); - return new WorktreeItem(worktreePath, worktreeSessions, sessionItems, { changedCount }); - }); + const items = buildProjectScopedItems( + groupSessionsByWorktree( + sessions.filter((session) => (changesBySession.get(session.branch) || []).length > 0), + ).map(({ worktreePath, sessions: worktreeSessions }) => { + const sessionItems = worktreeSessions.map((session) => ( + new SessionItem( + session, + buildChangeTreeNodes(changesBySession.get(session.branch) || []), + { + label: sessionTreeLabel(session), + variant: 'raw', + }, + ) + )); + const changedCount = worktreeSessions.reduce( + (total, session) => total + ((changesBySession.get(session.branch) || []).length), + 0, + ); + return { + projectRelativePath: worktreeProjectRelativePath(worktreeSessions), + sessions: worktreeSessions, + item: new WorktreeItem(worktreePath, worktreeSessions, sessionItems, { changedCount }), + }; + }), + ); if (repoRootChanges.length > 0) { items.push(new SectionItem('Repo root', buildChangeTreeNodes(repoRootChanges), { @@ -1991,6 +2294,12 @@ function commitWorktree(worktreePath, message) { function buildSessionDetailItems(session) { const provider = resolveSessionProvider(session); const snapshot = sessionSnapshotDisplayName(session); + const projectRelativePath = resolveSessionProjectRelativePath(session); + const badgeSummary = uniqueStringList([ + ...(session.riskBadges || []), + session.deltaLabel || '', + ].filter(Boolean)).join(', '); + const sessionHealthSummary = buildSessionHealthSummary(session); const items = [ new DetailItem('Recent change', session.recentChangeSummary || 'No recent change summary.', { iconId: 'history', @@ -1998,50 +2307,68 @@ function buildSessionDetailItems(session) { new DetailItem('Top files', session.topChangedFilesLabel || 'No tracked file edits.', { iconId: 'list-flat', }), - new DetailItem('Branch', session.branch, { - iconId: 'git-branch', - }), - new DetailItem('Worktree', session.worktreePath, { - iconId: 'folder', - tooltip: session.worktreePath, - }), ]; - if (snapshot) { - items.splice(3, 0, new DetailItem('Snapshot', snapshot, { - iconId: 'account', + if (badgeSummary) { + items.push(new DetailItem('Signals', badgeSummary, { + iconId: 'warning', + })); + } + if (sessionHealthSummary) { + items.push(new DetailItem('Session health', sessionHealthSummary, { + iconId: 'pulse', + tooltip: buildSessionHealthTooltip(session) || sessionHealthSummary, })); } if (provider?.label) { - items.splice(3, 0, new DetailItem('Provider', provider.label, { + items.push(new DetailItem('Provider', provider.label, { iconId: 'sparkle', })); } - const badgeSummary = uniqueStringList([ - ...(session.riskBadges || []), - session.deltaLabel || '', - ].filter(Boolean)).join(', '); - if (badgeSummary) { - items.splice(2, 0, new DetailItem('Signals', badgeSummary, { - iconId: 'warning', + if (snapshot) { + items.push(new DetailItem('Snapshot', snapshot, { + iconId: 'account', })); } + if (projectRelativePath) { + items.push(new DetailItem('Project', projectRelativePath, { + iconId: 'folder', + tooltip: projectRelativePath, + })); + } + items.push(new DetailItem('Branch', session.branch, { + iconId: 'git-branch', + })); + items.push(new DetailItem('Worktree', session.worktreePath, { + iconId: 'folder', + tooltip: session.worktreePath, + })); return items; } function buildWorkingNowNodes(sessions) { - return sortSessionsForWorkingNow( + const sessionEntries = sortSessionsForWorkingNow( sessions.filter((session) => ( session.activityKind === 'working' || session.activityKind === 'blocked' )), - ).map((session) => new SessionItem(session, buildSessionDetailItems(session))); + ).map((session) => ({ + projectRelativePath: resolveSessionProjectRelativePath(session), + sessions: [session], + item: new SessionItem(session, buildSessionDetailItems(session)), + })); + return buildProjectScopedItems(sessionEntries, { rootLabel: 'Repo root' }); } function buildIdleThinkingNodes(sessions) { - return sortSessionsForIdleThinking( + const sessionEntries = sortSessionsForIdleThinking( sessions.filter((session) => !( session.activityKind === 'working' || session.activityKind === 'blocked' )), - ).map((session) => new SessionItem(session, buildSessionDetailItems(session))); + ).map((session) => ({ + projectRelativePath: resolveSessionProjectRelativePath(session), + sessions: [session], + item: new SessionItem(session, buildSessionDetailItems(session)), + })); + return buildProjectScopedItems(sessionEntries, { rootLabel: 'Repo root' }); } function buildUnassignedChangeNodes(changes) { @@ -2056,26 +2383,31 @@ function buildRawActiveAgentGroupNodes(sessions) { const groups = []; for (const group of SESSION_ACTIVITY_GROUPS) { const groupSessions = sessions.filter((session) => session.activityKind === group.kind); - const worktreeItems = groupSessionsByWorktree(groupSessions).map(({ worktreePath, sessions: worktreeSessions }) => ( - new WorktreeItem( - worktreePath, - worktreeSessions, - worktreeSessions.map((session) => new SessionItem( - session, - buildChangeTreeNodes(session.touchedChanges || []), + const worktreeItems = buildProjectScopedItems( + groupSessionsByWorktree(groupSessions).map(({ worktreePath, sessions: worktreeSessions }) => ({ + projectRelativePath: worktreeProjectRelativePath(worktreeSessions), + sessions: worktreeSessions, + item: new WorktreeItem( + worktreePath, + worktreeSessions, + worktreeSessions.map((session) => new SessionItem( + session, + buildChangeTreeNodes(session.touchedChanges || []), + { + label: sessionTreeLabel(session), + variant: 'raw', + }, + )), { - label: sessionTreeLabel(session), - variant: 'raw', + description: buildWorktreeBranchDescription(worktreeSessions), + iconId: 'git-branch', + resourceSession: worktreeSessions[0], + useSessionDecoration: true, }, - )), - { - description: buildWorktreeBranchDescription(worktreeSessions), - iconId: 'git-branch', - resourceSession: worktreeSessions[0], - useSessionDecoration: true, - }, - ) - )); + ), + })), + { rootLabel: 'Repo root' }, + ); if (worktreeItems.length > 0) { groups.push(new SectionItem(group.label, worktreeItems)); } diff --git a/vscode/guardex-active-agents/session-schema.js b/vscode/guardex-active-agents/session-schema.js index e561987..1a34d18 100644 --- a/vscode/guardex-active-agents/session-schema.js +++ b/vscode/guardex-active-agents/session-schema.js @@ -76,6 +76,46 @@ function toPositiveInteger(value) { return Number.isInteger(normalized) && normalized > 0 ? normalized : null; } +function toBoundedInteger(value, min, max) { + const normalized = Number.parseInt(String(value ?? ''), 10); + if (!Number.isInteger(normalized) || normalized < min || normalized > max) { + return null; + } + return normalized; +} + +function normalizeStringList(values) { + if (!Array.isArray(values)) { + return []; + } + + return values + .map((value) => toNonEmptyString(value)) + .filter(Boolean); +} + +function normalizeSessionHealthPayload(input) { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + return null; + } + + const rawScores = input.scores && typeof input.scores === 'object' && !Array.isArray(input.scores) + ? input.scores + : null; + const score = toBoundedInteger(input.score ?? input.total ?? rawScores?.total, 0, 100); + if (score === null) { + return null; + } + + return { + score, + label: toNonEmptyString(input.label), + primaryDriver: toNonEmptyString(input.primaryDriver), + secondaries: normalizeStringList(input.secondaries), + outputLine: toNonEmptyString(input.outputLine), + }; +} + function normalizeTaskMode(value) { const normalized = toNonEmptyString(value).toLowerCase(); return normalized === 'caveman' || normalized === 'omx' ? normalized : ''; @@ -126,6 +166,17 @@ function normalizeRelativePath(value) { return toNonEmptyString(value).replace(/\\/g, '/').replace(/^\.\//, ''); } +function normalizeProjectPath(value) { + const normalized = toNonEmptyString(value); + if (!normalized) { + return ''; + } + + return path.isAbsolute(normalized) + ? path.resolve(normalized) + : normalizeRelativePath(normalized); +} + function readJsonFile(filePath) { try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); @@ -744,6 +795,8 @@ function buildSessionRecord(input) { taskName: toNonEmptyString(input.taskName, 'task'), latestTaskPreview: '', agentName: toNonEmptyString(input.agentName, 'agent'), + projectName: toNonEmptyString(input.projectName), + projectPath: normalizeProjectPath(input.projectPath), snapshotName: toNonEmptyString(input.snapshotName), snapshotEmail: toNonEmptyString(input.snapshotEmail || input.email), worktreePath, @@ -755,6 +808,7 @@ function buildSessionRecord(input) { startedAt: startedAt.toISOString(), lastHeartbeatAt: lastHeartbeatAt.toISOString(), state: normalizeAdvisoryState(input.state), + sessionHealth: normalizeSessionHealthPayload(input.sessionHealth || input.sessionSeverity), }; } @@ -796,6 +850,8 @@ function normalizeSessionRecord(input, options = {}) { taskName: toNonEmptyString(input.taskName, 'task'), latestTaskPreview: '', agentName: toNonEmptyString(input.agentName, 'agent'), + projectName: toNonEmptyString(input.projectName), + projectPath: normalizeProjectPath(input.projectPath), snapshotName: toNonEmptyString(input.snapshotName), snapshotEmail: toNonEmptyString(input.snapshotEmail || input.email), worktreePath: path.resolve(worktreePath), @@ -817,6 +873,7 @@ function normalizeSessionRecord(input, options = {}) { lockSnapshotCount: 0, lockSessionCount: 0, collaboration: false, + sessionHealth: normalizeSessionHealthPayload(input.sessionHealth || input.sessionSeverity), }; } @@ -901,6 +958,9 @@ function flattenTelemetrySnapshotSessions(lockPayload) { projectPath: toNonEmptyString(session?.projectPath), snapshotName: toNonEmptyString(snapshot?.snapshotName), email: toNonEmptyString(snapshot?.email), + sessionHealth: normalizeSessionHealthPayload( + session?.sessionHealth || session?.sessionSeverity || snapshot?.sessionHealth || snapshot?.sessionSeverity, + ), }); } } @@ -926,6 +986,7 @@ function deriveLockTaskAnchor(entries, fallbackTaskName, fallbackTimestamp) { taskName: latestEntry?.taskPreview || fallbackTaskName || 'task', latestTaskPreview: latestEntry?.taskPreview || '', timestamp: latestEntry?.taskUpdatedAt || fallbackTimestamp || '', + sessionHealth: latestEntry?.sessionHealth || null, }; } @@ -951,6 +1012,15 @@ function deriveLockSnapshotIdentity(entries) { }; } +function deriveLockProjectMetadata(entries) { + const latestEntry = sortTelemetryEntriesForAnchor(entries) + .find((entry) => entry?.projectPath || entry?.projectName) || null; + return { + projectName: toNonEmptyString(latestEntry?.projectName), + projectPath: normalizeProjectPath(latestEntry?.projectPath), + }; +} + function buildWorktreeLockSession(repoRoot, worktreePath, lockPayload, options = {}) { const now = options.now || Date.now(); const telemetryEntries = flattenTelemetrySnapshotSessions(lockPayload); @@ -962,6 +1032,7 @@ function buildWorktreeLockSession(repoRoot, worktreePath, lockPayload, options = const label = deriveSessionLabel(effectiveBranch, worktreePath); const taskAnchor = deriveLockTaskAnchor(telemetryEntries, label, telemetryUpdatedAt); const snapshotIdentity = deriveLockSnapshotIdentity(telemetryEntries); + const projectMetadata = deriveLockProjectMetadata(telemetryEntries); const startedAt = taskAnchor.timestamp || telemetryUpdatedAt || new Date(now).toISOString(); const session = { @@ -971,6 +1042,8 @@ function buildWorktreeLockSession(repoRoot, worktreePath, lockPayload, options = taskName: taskAnchor.taskName, latestTaskPreview: taskAnchor.latestTaskPreview, agentName: deriveAgentNameFromBranch(effectiveBranch), + projectName: projectMetadata.projectName, + projectPath: projectMetadata.projectPath, snapshotName: snapshotIdentity.snapshotName, snapshotEmail: snapshotIdentity.snapshotEmail, worktreePath: path.resolve(worktreePath), @@ -992,6 +1065,9 @@ function buildWorktreeLockSession(repoRoot, worktreePath, lockPayload, options = lockSnapshotCount: toPositiveInteger(lockPayload?.snapshotCount) || 0, lockSessionCount: toPositiveInteger(lockPayload?.sessionCount) || telemetryEntries.length, collaboration: Boolean(lockPayload?.collaboration), + sessionHealth: taskAnchor.sessionHealth || normalizeSessionHealthPayload( + lockPayload?.sessionHealth || lockPayload?.sessionSeverity, + ), }; session.elapsedLabel = formatElapsedFrom(session.startedAt, now); @@ -1015,6 +1091,8 @@ function buildManagedWorktreeSession(repoRoot, worktreePath, options = {}) { taskName: label, latestTaskPreview: '', agentName: deriveAgentNameFromBranch(branch), + projectName: '', + projectPath: '', snapshotName: '', snapshotEmail: '', worktreePath: path.resolve(worktreePath), @@ -1036,6 +1114,7 @@ function buildManagedWorktreeSession(repoRoot, worktreePath, options = {}) { lockSnapshotCount: 0, lockSessionCount: 0, collaboration: false, + sessionHealth: null, }; session.elapsedLabel = formatElapsedFrom(session.startedAt, now); @@ -1142,6 +1221,21 @@ function mergeSessionSources(primarySessions, lockSessions) { } if (lockSession) { consumedLockWorktrees.add(worktreeKey); + merged.push({ + ...session, + latestTaskPreview: session.latestTaskPreview || lockSession.latestTaskPreview, + projectName: session.projectName || lockSession.projectName, + projectPath: session.projectPath || lockSession.projectPath, + snapshotName: session.snapshotName || lockSession.snapshotName, + snapshotEmail: session.snapshotEmail || lockSession.snapshotEmail, + telemetryUpdatedAt: session.telemetryUpdatedAt || lockSession.telemetryUpdatedAt, + telemetrySource: session.telemetrySource || lockSession.telemetrySource, + lockSnapshotCount: session.lockSnapshotCount || lockSession.lockSnapshotCount, + lockSessionCount: session.lockSessionCount || lockSession.lockSessionCount, + collaboration: session.collaboration || lockSession.collaboration, + sessionHealth: session.sessionHealth || lockSession.sessionHealth, + }); + continue; } merged.push(session); } From a9f405e0bc70e9e0e0711316e5e4c98e6879026d Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Thu, 23 Apr 2026 14:41:44 +0200 Subject: [PATCH 2/3] Keep Active Agents manifest version ahead of main Rebase brought extension and test changes onto a base branch that already carried version 0.0.11, so the guard test needed both live and template manifests to move forward together. Constraint: Focused Active Agents tests require the manifest version to increase above the base branch when extension files change Rejected: Leave version 0.0.10 in place | node --test test/vscode-active-agents-session-state.test.js fails on the rebased branch Confidence: high Scope-risk: narrow Directive: Bump live and template Active Agents manifests together whenever extension files change on a rebased branch Tested: node --test test/vscode-active-agents-session-state.test.js Not-tested: Manual VS Code install and reload flow on this branch --- templates/vscode/guardex-active-agents/package.json | 2 +- vscode/guardex-active-agents/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/vscode/guardex-active-agents/package.json b/templates/vscode/guardex-active-agents/package.json index 2666a83..1758ff0 100644 --- a/templates/vscode/guardex-active-agents/package.json +++ b/templates/vscode/guardex-active-agents/package.json @@ -3,7 +3,7 @@ "displayName": "GitGuardex Active Agents", "description": "Shows live Guardex sandbox sessions and repo changes in a dedicated VS Code Active Agents sidebar.", "publisher": "recodeee", - "version": "0.0.11", + "version": "0.0.13", "license": "MIT", "icon": "icon.png", "engines": { diff --git a/vscode/guardex-active-agents/package.json b/vscode/guardex-active-agents/package.json index 2666a83..1758ff0 100644 --- a/vscode/guardex-active-agents/package.json +++ b/vscode/guardex-active-agents/package.json @@ -3,7 +3,7 @@ "displayName": "GitGuardex Active Agents", "description": "Shows live Guardex sandbox sessions and repo changes in a dedicated VS Code Active Agents sidebar.", "publisher": "recodeee", - "version": "0.0.11", + "version": "0.0.13", "license": "MIT", "icon": "icon.png", "engines": { From 1da318e0534c2f41a82134d4abbf78f289b38b3f Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Thu, 23 Apr 2026 15:02:15 +0200 Subject: [PATCH 3/3] Make Active Agents rows easier to scan live The Active Agents tree now prefers live task previews over branch slugs, colorizes session and warning icons, and keeps the mirrored template plus focused tests aligned with the installed extension surface. Constraint: Must keep live and template Active Agents sources in sync and preserve existing grouped tree behavior Rejected: Hide the raw path tree entirely | diagnostics still need a visible debug surface Confidence: high Scope-risk: narrow Directive: Keep task-first labels and icon colors mirrored between vscode/ and templates/ whenever Active Agents tree rows change Tested: node --test test/vscode-active-agents-session-state.test.js test/metadata.test.js; openspec validate agent-codex-codex-task-2026-04-23-13-25 --type change --strict; openspec validate --specs Not-tested: Manual VS Code sidebar render after installing extension version 0.0.13 --- .../vscode/guardex-active-agents/extension.js | 95 ++++++++++++++++--- .../fileicons/icons/openspec.svg | 4 +- .../guardex-active-agents/session-schema.js | 4 +- ...vscode-active-agents-session-state.test.js | 30 +++--- vscode/guardex-active-agents/extension.js | 95 ++++++++++++++++--- .../fileicons/icons/openspec.svg | 4 +- .../guardex-active-agents/session-schema.js | 4 +- 7 files changed, 191 insertions(+), 45 deletions(-) diff --git a/templates/vscode/guardex-active-agents/extension.js b/templates/vscode/guardex-active-agents/extension.js index a4caf67..17b391e 100644 --- a/templates/vscode/guardex-active-agents/extension.js +++ b/templates/vscode/guardex-active-agents/extension.js @@ -73,6 +73,46 @@ const SESSION_PROVIDER_BRANDS = { }, }; +function iconColorId(iconId) { + switch (iconId) { + case 'warning': + case 'clock': + return 'list.warningForeground'; + case 'error': + return 'list.errorForeground'; + case 'loading~spin': + return 'gitDecoration.addedResourceForeground'; + case 'comment-discussion': + case 'info': + case 'repo': + case 'folder': + case 'graph': + case 'history': + return 'textLink.foreground'; + case 'git-branch': + return 'gitDecoration.modifiedResourceForeground'; + case 'account': + return 'terminal.ansiYellow'; + case 'sparkle': + return 'terminal.ansiMagenta'; + case 'list-flat': + return 'terminal.ansiCyan'; + case 'list-tree': + return 'terminal.ansiBlue'; + default: + return ''; + } +} + +function themeIcon(iconId, colorId = iconColorId(iconId)) { + if (!iconId) { + return undefined; + } + return colorId + ? new vscode.ThemeIcon(iconId, new vscode.ThemeColor(colorId)) + : new vscode.ThemeIcon(iconId); +} + function sessionDecorationUri(branch) { return vscode.Uri.parse(`${SESSION_DECORATION_SCHEME}://${sanitizeBranchForFile(branch)}`); } @@ -310,7 +350,7 @@ async function ensureManagedRepoScanIgnores() { function sessionIdentityLabel(session) { const agentName = typeof session?.agentName === 'string' ? session.agentName.trim() : ''; - const taskName = typeof session?.taskName === 'string' ? session.taskName.trim() : ''; + const taskName = sessionDisplayLabel(session); const label = typeof session?.label === 'string' ? session.label.trim() : ''; if (agentName && taskName) { @@ -634,7 +674,7 @@ function buildSessionTooltip(session, description) { ? `Provider ${provider.label}${provider.cliName ? ` (${provider.cliName})` : ''}` : '', sessionSnapshotDisplayName(session) ? `Snapshot ${sessionSnapshotDisplayName(session)}` : '', - `${session.agentName} · ${session.taskName}`, + `${session.agentName} · ${sessionDisplayLabel(session)}`, `Status ${description}`, sessionHealthSummary ? `Session health ${sessionHealthSummary}` : '', sessionHealthDrivers ? `Drivers ${sessionHealthDrivers}` : '', @@ -1038,7 +1078,7 @@ class InfoItem extends vscode.TreeItem { constructor(label, description = '') { super(label, vscode.TreeItemCollapsibleState.None); this.description = description; - this.iconPath = new vscode.ThemeIcon('info'); + this.iconPath = themeIcon('info'); this.tooltip = [label, description].filter(Boolean).join('\n'); } } @@ -1048,7 +1088,7 @@ class DetailItem extends vscode.TreeItem { super(label, vscode.TreeItemCollapsibleState.None); this.description = description; this.tooltip = options.tooltip || [label, description].filter(Boolean).join('\n'); - this.iconPath = options.iconId ? new vscode.ThemeIcon(options.iconId) : undefined; + this.iconPath = options.iconId ? themeIcon(options.iconId, options.iconColorId) : undefined; } } @@ -1063,7 +1103,7 @@ class RepoItem extends vscode.TreeItem { this.overview = options.overview || buildRepoOverview(sessions, this.unassignedChanges, this.lockEntries); this.description = buildRepoDescription(this.overview); this.tooltip = buildRepoTooltip(repoRoot, this.overview); - this.iconPath = new vscode.ThemeIcon('repo'); + this.iconPath = themeIcon('repo'); this.contextValue = 'gitguardex.repo'; } } @@ -1078,7 +1118,7 @@ class SectionItem extends vscode.TreeItem { this.description = options.description || (items.length > 0 ? String(items.length) : ''); this.tooltip = options.tooltip || [label, this.description].filter(Boolean).join('\n'); - this.iconPath = options.iconId ? new vscode.ThemeIcon(options.iconId) : undefined; + this.iconPath = options.iconId ? themeIcon(options.iconId, options.iconColorId) : undefined; this.contextValue = 'gitguardex.section'; } } @@ -1106,7 +1146,7 @@ class WorktreeItem extends vscode.TreeItem { normalizedWorktreePath, ...sessionList.map((session) => session.branch).filter(Boolean), ].filter(Boolean).join('\n'); - this.iconPath = new vscode.ThemeIcon(options.iconId || 'folder'); + this.iconPath = themeIcon(options.iconId || 'folder', options.iconColorId); if (options.useSessionDecoration && primarySession?.branch) { this.resourceUri = sessionDecorationUri(primarySession.branch); } @@ -1145,7 +1185,7 @@ class SessionItem extends vscode.TreeItem { ? buildRawSessionDescription(session) : buildSessionCardDescription(session); this.tooltip = buildSessionTooltip(session, this.description); - this.iconPath = new vscode.ThemeIcon(resolveSessionActivityIconId(session.activityKind)); + this.iconPath = themeIcon(resolveSessionActivityIconId(session.activityKind)); this.contextValue = 'gitguardex.session'; this.command = { command: 'gitguardex.activeAgents.openWorktree', @@ -1167,7 +1207,7 @@ class FolderItem extends vscode.TreeItem { this.items = items; this.description = typeof options.description === 'string' ? options.description : ''; this.tooltip = options.tooltip || relativePath || label; - this.iconPath = new vscode.ThemeIcon(options.iconId || 'folder'); + this.iconPath = themeIcon(options.iconId || 'folder', options.iconColorId); this.contextValue = options.contextValue || 'gitguardex.folder'; } } @@ -1192,7 +1232,7 @@ class ChangeItem extends vscode.TreeItem { ].filter(Boolean).join('\n'); this.resourceUri = vscode.Uri.file(change.absolutePath); if (options.iconId || change.hasForeignLock) { - this.iconPath = new vscode.ThemeIcon(options.iconId || 'warning'); + this.iconPath = themeIcon(options.iconId || 'warning', options.iconColorId || 'list.warningForeground'); } this.contextValue = 'gitguardex.change'; this.command = { @@ -1233,12 +1273,33 @@ function resolveStartAgentCommand(repoRoot, details) { return `gx branch start ${taskArg} ${agentArg}`; } +function sessionTaskLabel(session) { + const latestTaskPreview = typeof session?.latestTaskPreview === 'string' + ? session.latestTaskPreview.trim() + : ''; + if (latestTaskPreview) { + return latestTaskPreview; + } + + const taskName = typeof session?.taskName === 'string' ? session.taskName.trim() : ''; + if (taskName) { + return taskName; + } + + return ''; +} + function sessionDisplayLabel(session) { - return session?.taskName || session?.label || session?.branch || path.basename(session?.worktreePath || '') || 'session'; + return sessionTaskLabel(session) + || session?.label + || compactBranchLabel(session?.branch) + || session?.branch + || path.basename(session?.worktreePath || '') + || 'session'; } function sessionTreeLabel(session) { - return compactBranchLabel(session?.branch) || sessionDisplayLabel(session); + return sessionTaskLabel(session) || compactBranchLabel(session?.branch) || sessionDisplayLabel(session); } function worktreeDisplayLabel(worktreePath, sessions) { @@ -2409,7 +2470,9 @@ function buildRawActiveAgentGroupNodes(sessions) { { rootLabel: 'Repo root' }, ); if (worktreeItems.length > 0) { - groups.push(new SectionItem(group.label, worktreeItems)); + groups.push(new SectionItem(group.label, worktreeItems, { + iconId: resolveSessionActivityIconId(group.kind), + })); } } @@ -2625,6 +2688,7 @@ class ActiveAgentsProvider { if (workingNowItems.length > 0) { sectionItems.push(new SectionItem('Working now', workingNowItems, { description: String(workingNowItems.length), + iconId: 'loading~spin', })); } @@ -2633,12 +2697,14 @@ class ActiveAgentsProvider { sectionItems.push(new SectionItem('Idle / thinking', idleThinkingItems, { description: String(idleThinkingItems.length), collapsedState: vscode.TreeItemCollapsibleState.Collapsed, + iconId: 'comment-discussion', })); } if (element.unassignedChanges.length > 0) { sectionItems.push(new SectionItem('Unassigned changes', buildUnassignedChangeNodes(element.unassignedChanges), { description: String(element.unassignedChanges.length), + iconId: 'warning', })); } @@ -2648,6 +2714,7 @@ class ActiveAgentsProvider { advancedItems.push(new SectionItem('Active agent tree', rawActiveAgents, { description: String(element.sessions.length), collapsedState: vscode.TreeItemCollapsibleState.Collapsed, + iconId: 'git-branch', })); } const rawChangeTree = buildGroupedChangeTreeNodes(element.sessions, element.changes); @@ -2655,12 +2722,14 @@ class ActiveAgentsProvider { advancedItems.push(new SectionItem('Raw path tree', rawChangeTree, { description: String(element.changes.length), collapsedState: vscode.TreeItemCollapsibleState.Collapsed, + iconId: 'list-tree', })); } if (advancedItems.length > 0) { sectionItems.push(new SectionItem('Advanced details', advancedItems, { description: String(advancedItems.length), collapsedState: vscode.TreeItemCollapsibleState.Collapsed, + iconId: 'list-tree', })); } return sectionItems; diff --git a/templates/vscode/guardex-active-agents/fileicons/icons/openspec.svg b/templates/vscode/guardex-active-agents/fileicons/icons/openspec.svg index 84314d6..02f58ed 100644 --- a/templates/vscode/guardex-active-agents/fileicons/icons/openspec.svg +++ b/templates/vscode/guardex-active-agents/fileicons/icons/openspec.svg @@ -1,5 +1,5 @@ - - + + diff --git a/templates/vscode/guardex-active-agents/session-schema.js b/templates/vscode/guardex-active-agents/session-schema.js index 1a34d18..8677a1b 100644 --- a/templates/vscode/guardex-active-agents/session-schema.js +++ b/templates/vscode/guardex-active-agents/session-schema.js @@ -793,7 +793,7 @@ function buildSessionRecord(input) { repoRoot, branch, taskName: toNonEmptyString(input.taskName, 'task'), - latestTaskPreview: '', + latestTaskPreview: toNonEmptyString(input.latestTaskPreview), agentName: toNonEmptyString(input.agentName, 'agent'), projectName: toNonEmptyString(input.projectName), projectPath: normalizeProjectPath(input.projectPath), @@ -848,7 +848,7 @@ function normalizeSessionRecord(input, options = {}) { repoRoot: path.resolve(repoRoot), branch, taskName: toNonEmptyString(input.taskName, 'task'), - latestTaskPreview: '', + latestTaskPreview: toNonEmptyString(input.latestTaskPreview), agentName: toNonEmptyString(input.agentName, 'agent'), projectName: toNonEmptyString(input.projectName), projectPath: normalizeProjectPath(input.projectPath), diff --git a/test/vscode-active-agents-session-state.test.js b/test/vscode-active-agents-session-state.test.js index ffc7036..5a8add8 100644 --- a/test/vscode-active-agents-session-state.test.js +++ b/test/vscode-active-agents-session-state.test.js @@ -281,8 +281,9 @@ function createMockVscode(tempRoot) { } class ThemeIcon { - constructor(id) { + constructor(id, color) { this.id = id; + this.color = color; } } @@ -1917,7 +1918,8 @@ test('active-agents extension shows grouped repo changes beside active agents', fs.writeFileSync(path.join(worktreePath, 'tracked.txt'), 'base\nchanged\n', 'utf8'); fs.writeFileSync(path.join(worktreePath, 'src', 'nested.js'), 'base\nchanged\n', 'utf8'); - const sessionPath = writeSessionRecord(tempRoot, sessionSchema.buildSessionRecord({ + const latestTaskPreview = 'Fix cave hivemind hero layout'; + const liveSessionRecord = sessionSchema.buildSessionRecord({ repoRoot: tempRoot, branch: 'agent/codex/live-task', taskName: 'live-task', @@ -1925,7 +1927,9 @@ test('active-agents extension shows grouped repo changes beside active agents', worktreePath, pid: process.pid, cliName: 'codex', - })); + }); + liveSessionRecord.latestTaskPreview = latestTaskPreview; + const sessionPath = writeSessionRecord(tempRoot, liveSessionRecord); const { registrations, vscode } = createMockVscode(tempRoot); vscode.workspace.findFiles = async () => [{ fsPath: sessionPath }]; @@ -1975,11 +1979,12 @@ test('active-agents extension shows grouped repo changes beside active agents', const { worktreeItem, sessionItem } = await getOnlyWorktreeAndSession(provider, workingSection); assert.equal(worktreeItem, null); - assert.equal(sessionItem.label, 'live-task'); + assert.equal(sessionItem.label, latestTaskPreview); assert.equal(sessionItem.session.branch, 'agent/codex/live-task'); assert.match(sessionItem.description, /^Working: codex · via OpenAI · 2 changed files/); - assert.match(sessionItem.tooltip, /Recent Changed src\/nested\.js, tracked\.txt/); + assert.match(sessionItem.tooltip, /Recent Fix cave hivemind hero layout/); assert.equal(sessionItem.iconPath.id, 'loading~spin'); + assert.equal(sessionItem.iconPath.color.id, 'gitDecoration.addedResourceForeground'); const sessionDetails = await provider.getChildren(sessionItem); assert.equal(sessionDetails.find((item) => item.label === 'Top files')?.description, 'src/nested.js, tracked.txt'); assert.deepEqual(registrations.treeViews[0].badge, { @@ -1990,24 +1995,26 @@ test('active-agents extension shows grouped repo changes beside active agents', const [unassignedChangeItem] = await provider.getChildren(unassignedSection); assert.equal(unassignedChangeItem.label, 'root-file.txt'); assert.equal(unassignedChangeItem.description, 'M · Protected branch'); + assert.equal(unassignedChangeItem.iconPath.id, 'warning'); + assert.equal(unassignedChangeItem.iconPath.color.id, 'list.warningForeground'); const activeAgentTree = await getSectionByLabel(provider, advancedSection, 'Active agent tree'); const rawWorkingSection = await getSectionByLabel(provider, activeAgentTree, 'WORKING NOW'); const [rawWorktreeItem] = await provider.getChildren(rawWorkingSection); - assert.equal(rawWorktreeItem.label, 'live-task'); + assert.equal(rawWorktreeItem.label, latestTaskPreview); assert.equal(rawWorktreeItem.description, 'working: codex'); const [rawSessionItem] = await provider.getChildren(rawWorktreeItem); - assert.equal(rawSessionItem.label, 'codex/live-task'); + assert.equal(rawSessionItem.label, latestTaskPreview); assert.match(rawSessionItem.description, /^Working · 2 files · /); const rawPathTree = await getSectionByLabel(provider, advancedSection, 'Raw path tree'); const [worktreeGroup, repoRootGroup] = await provider.getChildren(rawPathTree); - assert.equal(worktreeGroup.label, 'live-task'); + assert.equal(worktreeGroup.label, latestTaskPreview); assert.equal(worktreeGroup.description, 'codex · 2 files'); assert.equal(repoRootGroup.label, 'Repo root'); const [sessionGroup] = await provider.getChildren(worktreeGroup); - assert.equal(sessionGroup.label, 'codex/live-task'); + assert.equal(sessionGroup.label, latestTaskPreview); assert.match(sessionGroup.description, /^Working · 2 files · /); const [folderItem, trackedItem] = await provider.getChildren(sessionGroup); assert.equal(folderItem.label, 'src'); @@ -2103,6 +2110,7 @@ test('active-agents extension surfaces live managed worktrees from AGENT.lock fa assert.equal(sessionItem.label, 'Implement live worktree telemetry'); assert.equal(sessionItem.session.branch, 'agent/codex/lock-visible-task'); assert.match(sessionItem.description, /^Working: codex · via OpenAI · snapshot nagyviktor@edixa\.com · 1 changed file/); + assert.equal(sessionItem.iconPath.color.id, 'gitDecoration.addedResourceForeground'); assert.equal(sessionItem.session.snapshotName, 'nagyviktor@edixa.com'); assert.match(sessionItem.tooltip, /Telemetry updated 2026-04-22T09:01:00.000Z/); assert.match(sessionItem.tooltip, /Snapshot nagyviktor@edixa\.com/); @@ -2121,7 +2129,7 @@ test('active-agents extension surfaces live managed worktrees from AGENT.lock fa `gitguardex-agent://${sessionSchema.sanitizeBranchForFile('agent/codex/lock-visible-task')}`, ); const [rawSessionItem] = await provider.getChildren(rawWorktreeItem); - assert.equal(rawSessionItem.label, 'codex/lock-visible-task'); + assert.equal(rawSessionItem.label, 'Implement live worktree telemetry'); assert.match(rawSessionItem.description, /^Working · 1 file · /); const snapshotDecoration = registrations.decorationProviders[0].provideFileDecoration(vscode.Uri.parse( @@ -2397,7 +2405,7 @@ test('active-agents extension decorates sessions and repo changes from the lock assert.equal(worktreeGroup.description, 'working: codex'); assert.equal(worktreeGroup.resourceUri.toString(), `gitguardex-agent://${sessionSchema.sanitizeBranchForFile(branch)}`); const [sessionGroup] = await provider.getChildren(worktreeGroup); - assert.equal(sessionGroup.label, 'codex/live-task'); + assert.equal(sessionGroup.label, 'live-task'); assert.match(sessionGroup.description, /^Working · 1 file · /); const [sessionChangeItem] = await provider.getChildren(sessionGroup); assert.equal(sessionChangeItem.label, 'tracked.txt'); diff --git a/vscode/guardex-active-agents/extension.js b/vscode/guardex-active-agents/extension.js index a4caf67..17b391e 100644 --- a/vscode/guardex-active-agents/extension.js +++ b/vscode/guardex-active-agents/extension.js @@ -73,6 +73,46 @@ const SESSION_PROVIDER_BRANDS = { }, }; +function iconColorId(iconId) { + switch (iconId) { + case 'warning': + case 'clock': + return 'list.warningForeground'; + case 'error': + return 'list.errorForeground'; + case 'loading~spin': + return 'gitDecoration.addedResourceForeground'; + case 'comment-discussion': + case 'info': + case 'repo': + case 'folder': + case 'graph': + case 'history': + return 'textLink.foreground'; + case 'git-branch': + return 'gitDecoration.modifiedResourceForeground'; + case 'account': + return 'terminal.ansiYellow'; + case 'sparkle': + return 'terminal.ansiMagenta'; + case 'list-flat': + return 'terminal.ansiCyan'; + case 'list-tree': + return 'terminal.ansiBlue'; + default: + return ''; + } +} + +function themeIcon(iconId, colorId = iconColorId(iconId)) { + if (!iconId) { + return undefined; + } + return colorId + ? new vscode.ThemeIcon(iconId, new vscode.ThemeColor(colorId)) + : new vscode.ThemeIcon(iconId); +} + function sessionDecorationUri(branch) { return vscode.Uri.parse(`${SESSION_DECORATION_SCHEME}://${sanitizeBranchForFile(branch)}`); } @@ -310,7 +350,7 @@ async function ensureManagedRepoScanIgnores() { function sessionIdentityLabel(session) { const agentName = typeof session?.agentName === 'string' ? session.agentName.trim() : ''; - const taskName = typeof session?.taskName === 'string' ? session.taskName.trim() : ''; + const taskName = sessionDisplayLabel(session); const label = typeof session?.label === 'string' ? session.label.trim() : ''; if (agentName && taskName) { @@ -634,7 +674,7 @@ function buildSessionTooltip(session, description) { ? `Provider ${provider.label}${provider.cliName ? ` (${provider.cliName})` : ''}` : '', sessionSnapshotDisplayName(session) ? `Snapshot ${sessionSnapshotDisplayName(session)}` : '', - `${session.agentName} · ${session.taskName}`, + `${session.agentName} · ${sessionDisplayLabel(session)}`, `Status ${description}`, sessionHealthSummary ? `Session health ${sessionHealthSummary}` : '', sessionHealthDrivers ? `Drivers ${sessionHealthDrivers}` : '', @@ -1038,7 +1078,7 @@ class InfoItem extends vscode.TreeItem { constructor(label, description = '') { super(label, vscode.TreeItemCollapsibleState.None); this.description = description; - this.iconPath = new vscode.ThemeIcon('info'); + this.iconPath = themeIcon('info'); this.tooltip = [label, description].filter(Boolean).join('\n'); } } @@ -1048,7 +1088,7 @@ class DetailItem extends vscode.TreeItem { super(label, vscode.TreeItemCollapsibleState.None); this.description = description; this.tooltip = options.tooltip || [label, description].filter(Boolean).join('\n'); - this.iconPath = options.iconId ? new vscode.ThemeIcon(options.iconId) : undefined; + this.iconPath = options.iconId ? themeIcon(options.iconId, options.iconColorId) : undefined; } } @@ -1063,7 +1103,7 @@ class RepoItem extends vscode.TreeItem { this.overview = options.overview || buildRepoOverview(sessions, this.unassignedChanges, this.lockEntries); this.description = buildRepoDescription(this.overview); this.tooltip = buildRepoTooltip(repoRoot, this.overview); - this.iconPath = new vscode.ThemeIcon('repo'); + this.iconPath = themeIcon('repo'); this.contextValue = 'gitguardex.repo'; } } @@ -1078,7 +1118,7 @@ class SectionItem extends vscode.TreeItem { this.description = options.description || (items.length > 0 ? String(items.length) : ''); this.tooltip = options.tooltip || [label, this.description].filter(Boolean).join('\n'); - this.iconPath = options.iconId ? new vscode.ThemeIcon(options.iconId) : undefined; + this.iconPath = options.iconId ? themeIcon(options.iconId, options.iconColorId) : undefined; this.contextValue = 'gitguardex.section'; } } @@ -1106,7 +1146,7 @@ class WorktreeItem extends vscode.TreeItem { normalizedWorktreePath, ...sessionList.map((session) => session.branch).filter(Boolean), ].filter(Boolean).join('\n'); - this.iconPath = new vscode.ThemeIcon(options.iconId || 'folder'); + this.iconPath = themeIcon(options.iconId || 'folder', options.iconColorId); if (options.useSessionDecoration && primarySession?.branch) { this.resourceUri = sessionDecorationUri(primarySession.branch); } @@ -1145,7 +1185,7 @@ class SessionItem extends vscode.TreeItem { ? buildRawSessionDescription(session) : buildSessionCardDescription(session); this.tooltip = buildSessionTooltip(session, this.description); - this.iconPath = new vscode.ThemeIcon(resolveSessionActivityIconId(session.activityKind)); + this.iconPath = themeIcon(resolveSessionActivityIconId(session.activityKind)); this.contextValue = 'gitguardex.session'; this.command = { command: 'gitguardex.activeAgents.openWorktree', @@ -1167,7 +1207,7 @@ class FolderItem extends vscode.TreeItem { this.items = items; this.description = typeof options.description === 'string' ? options.description : ''; this.tooltip = options.tooltip || relativePath || label; - this.iconPath = new vscode.ThemeIcon(options.iconId || 'folder'); + this.iconPath = themeIcon(options.iconId || 'folder', options.iconColorId); this.contextValue = options.contextValue || 'gitguardex.folder'; } } @@ -1192,7 +1232,7 @@ class ChangeItem extends vscode.TreeItem { ].filter(Boolean).join('\n'); this.resourceUri = vscode.Uri.file(change.absolutePath); if (options.iconId || change.hasForeignLock) { - this.iconPath = new vscode.ThemeIcon(options.iconId || 'warning'); + this.iconPath = themeIcon(options.iconId || 'warning', options.iconColorId || 'list.warningForeground'); } this.contextValue = 'gitguardex.change'; this.command = { @@ -1233,12 +1273,33 @@ function resolveStartAgentCommand(repoRoot, details) { return `gx branch start ${taskArg} ${agentArg}`; } +function sessionTaskLabel(session) { + const latestTaskPreview = typeof session?.latestTaskPreview === 'string' + ? session.latestTaskPreview.trim() + : ''; + if (latestTaskPreview) { + return latestTaskPreview; + } + + const taskName = typeof session?.taskName === 'string' ? session.taskName.trim() : ''; + if (taskName) { + return taskName; + } + + return ''; +} + function sessionDisplayLabel(session) { - return session?.taskName || session?.label || session?.branch || path.basename(session?.worktreePath || '') || 'session'; + return sessionTaskLabel(session) + || session?.label + || compactBranchLabel(session?.branch) + || session?.branch + || path.basename(session?.worktreePath || '') + || 'session'; } function sessionTreeLabel(session) { - return compactBranchLabel(session?.branch) || sessionDisplayLabel(session); + return sessionTaskLabel(session) || compactBranchLabel(session?.branch) || sessionDisplayLabel(session); } function worktreeDisplayLabel(worktreePath, sessions) { @@ -2409,7 +2470,9 @@ function buildRawActiveAgentGroupNodes(sessions) { { rootLabel: 'Repo root' }, ); if (worktreeItems.length > 0) { - groups.push(new SectionItem(group.label, worktreeItems)); + groups.push(new SectionItem(group.label, worktreeItems, { + iconId: resolveSessionActivityIconId(group.kind), + })); } } @@ -2625,6 +2688,7 @@ class ActiveAgentsProvider { if (workingNowItems.length > 0) { sectionItems.push(new SectionItem('Working now', workingNowItems, { description: String(workingNowItems.length), + iconId: 'loading~spin', })); } @@ -2633,12 +2697,14 @@ class ActiveAgentsProvider { sectionItems.push(new SectionItem('Idle / thinking', idleThinkingItems, { description: String(idleThinkingItems.length), collapsedState: vscode.TreeItemCollapsibleState.Collapsed, + iconId: 'comment-discussion', })); } if (element.unassignedChanges.length > 0) { sectionItems.push(new SectionItem('Unassigned changes', buildUnassignedChangeNodes(element.unassignedChanges), { description: String(element.unassignedChanges.length), + iconId: 'warning', })); } @@ -2648,6 +2714,7 @@ class ActiveAgentsProvider { advancedItems.push(new SectionItem('Active agent tree', rawActiveAgents, { description: String(element.sessions.length), collapsedState: vscode.TreeItemCollapsibleState.Collapsed, + iconId: 'git-branch', })); } const rawChangeTree = buildGroupedChangeTreeNodes(element.sessions, element.changes); @@ -2655,12 +2722,14 @@ class ActiveAgentsProvider { advancedItems.push(new SectionItem('Raw path tree', rawChangeTree, { description: String(element.changes.length), collapsedState: vscode.TreeItemCollapsibleState.Collapsed, + iconId: 'list-tree', })); } if (advancedItems.length > 0) { sectionItems.push(new SectionItem('Advanced details', advancedItems, { description: String(advancedItems.length), collapsedState: vscode.TreeItemCollapsibleState.Collapsed, + iconId: 'list-tree', })); } return sectionItems; diff --git a/vscode/guardex-active-agents/fileicons/icons/openspec.svg b/vscode/guardex-active-agents/fileicons/icons/openspec.svg index 84314d6..02f58ed 100644 --- a/vscode/guardex-active-agents/fileicons/icons/openspec.svg +++ b/vscode/guardex-active-agents/fileicons/icons/openspec.svg @@ -1,5 +1,5 @@ - - + + diff --git a/vscode/guardex-active-agents/session-schema.js b/vscode/guardex-active-agents/session-schema.js index 1a34d18..8677a1b 100644 --- a/vscode/guardex-active-agents/session-schema.js +++ b/vscode/guardex-active-agents/session-schema.js @@ -793,7 +793,7 @@ function buildSessionRecord(input) { repoRoot, branch, taskName: toNonEmptyString(input.taskName, 'task'), - latestTaskPreview: '', + latestTaskPreview: toNonEmptyString(input.latestTaskPreview), agentName: toNonEmptyString(input.agentName, 'agent'), projectName: toNonEmptyString(input.projectName), projectPath: normalizeProjectPath(input.projectPath), @@ -848,7 +848,7 @@ function normalizeSessionRecord(input, options = {}) { repoRoot: path.resolve(repoRoot), branch, taskName: toNonEmptyString(input.taskName, 'task'), - latestTaskPreview: '', + latestTaskPreview: toNonEmptyString(input.latestTaskPreview), agentName: toNonEmptyString(input.agentName, 'agent'), projectName: toNonEmptyString(input.projectName), projectPath: normalizeProjectPath(input.projectPath),