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..17b391e 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}/**'; @@ -69,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)}`); } @@ -131,6 +175,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 = []; @@ -278,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) { @@ -458,6 +530,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 +627,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 +636,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 +648,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,14 +666,18 @@ 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 ? `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}` : '', session.recentChangeSummary ? `Recent ${session.recentChangeSummary}` : '', topFiles ? `Top files ${topFiles}` : '', riskSummary ? `Signals ${riskSummary}` : '', @@ -949,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'); } } @@ -959,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; } } @@ -974,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'; } } @@ -989,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'; } } @@ -1002,23 +1131,22 @@ 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), ].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); } @@ -1057,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', @@ -1068,13 +1196,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 = themeIcon(options.iconId || 'folder', options.iconColorId); + this.contextValue = options.contextValue || 'gitguardex.folder'; } } @@ -1098,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 = { @@ -1139,18 +1273,242 @@ 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 session?.branch || sessionDisplayLabel(session); + return sessionTaskLabel(session) || 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 +2203,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 +2355,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 +2368,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,28 +2444,35 @@ 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)); + groups.push(new SectionItem(group.label, worktreeItems, { + iconId: resolveSessionActivityIconId(group.kind), + })); } } @@ -2293,6 +2688,7 @@ class ActiveAgentsProvider { if (workingNowItems.length > 0) { sectionItems.push(new SectionItem('Working now', workingNowItems, { description: String(workingNowItems.length), + iconId: 'loading~spin', })); } @@ -2301,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', })); } @@ -2316,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); @@ -2323,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/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/templates/vscode/guardex-active-agents/session-schema.js b/templates/vscode/guardex-active-agents/session-schema.js index e561987..8677a1b 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')); @@ -742,8 +793,10 @@ 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), 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), }; } @@ -794,8 +848,10 @@ 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), 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..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,15 +1995,27 @@ 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, latestTaskPreview); + assert.equal(rawWorktreeItem.description, 'working: codex'); + const [rawSessionItem] = await provider.getChildren(rawWorktreeItem); + 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, `${path.basename(worktreePath)}`); - assert.equal(worktreeGroup.description, '1 agent · 2 changed'); + 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, sessionItem.session.branch); + assert.equal(sessionGroup.label, latestTaskPreview); + 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 +2052,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 +2070,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 +2103,35 @@ 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.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/); + + 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, 'Implement live worktree telemetry'); + 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 +2143,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 +2400,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, '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..17b391e 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}/**'; @@ -69,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)}`); } @@ -131,6 +175,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 = []; @@ -278,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) { @@ -458,6 +530,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 +627,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 +636,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 +648,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,14 +666,18 @@ 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 ? `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}` : '', session.recentChangeSummary ? `Recent ${session.recentChangeSummary}` : '', topFiles ? `Top files ${topFiles}` : '', riskSummary ? `Signals ${riskSummary}` : '', @@ -949,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'); } } @@ -959,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; } } @@ -974,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'; } } @@ -989,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'; } } @@ -1002,23 +1131,22 @@ 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), ].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); } @@ -1057,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', @@ -1068,13 +1196,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 = themeIcon(options.iconId || 'folder', options.iconColorId); + this.contextValue = options.contextValue || 'gitguardex.folder'; } } @@ -1098,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 = { @@ -1139,18 +1273,242 @@ 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 session?.branch || sessionDisplayLabel(session); + return sessionTaskLabel(session) || 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 +2203,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 +2355,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 +2368,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,28 +2444,35 @@ 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)); + groups.push(new SectionItem(group.label, worktreeItems, { + iconId: resolveSessionActivityIconId(group.kind), + })); } } @@ -2293,6 +2688,7 @@ class ActiveAgentsProvider { if (workingNowItems.length > 0) { sectionItems.push(new SectionItem('Working now', workingNowItems, { description: String(workingNowItems.length), + iconId: 'loading~spin', })); } @@ -2301,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', })); } @@ -2316,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); @@ -2323,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/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": { diff --git a/vscode/guardex-active-agents/session-schema.js b/vscode/guardex-active-agents/session-schema.js index e561987..8677a1b 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')); @@ -742,8 +793,10 @@ 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), 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), }; } @@ -794,8 +848,10 @@ 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), 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); }