diff --git a/openspec/changes/agent-codex-add-openspec-and-provider-icons-2026-04-23-14-02/proposal.md b/openspec/changes/agent-codex-add-openspec-and-provider-icons-2026-04-23-14-02/proposal.md new file mode 100644 index 0000000..5fc5a10 --- /dev/null +++ b/openspec/changes/agent-codex-add-openspec-and-provider-icons-2026-04-23-14-02/proposal.md @@ -0,0 +1,16 @@ +## Why + +- The repo now ships a VS Code Source Control companion for Active Agents, but the Explorer still lacks repo-specific visual cues for OpenSpec folders, agent surfaces, and hook files. +- Active Agents rows also do not distinguish Codex/OpenAI versus Claude sessions, snapshot identity, or branch-group semantics in the place operators watch most closely: the working row beside the loader. + +## What Changes + +- Add a bundled `GitGuardex File Icons` theme to the shipped VS Code companion with distinct icons for OpenSpec `changes`, `plan`, `specs`, agent surfaces, hook paths, and related config/context files. +- Surface provider-aware Active Agents row copy and badges so Codex/OpenAI sessions read as `OpenAI`, Claude sessions read as `Claude`, snapshot-backed rows show the snapshot name and initial badge, and raw agent branch groups use a branch icon plus `working: agent` state copy. +- Keep the live extension, template extension, packaging metadata, docs, and focused tests aligned. + +## Impact + +- Affected surfaces: `vscode/guardex-active-agents/*`, `templates/vscode/guardex-active-agents/*`, `src/context.js`, `test/vscode-active-agents-session-state.test.js`, `test/metadata.test.js`, and `test/setup.test.js`. +- Risk stays narrow: this is presentation-only work inside the VS Code companion bundle and its packaging metadata. +- Operator caveat: Explorer icons require selecting the bundled file icon theme inside VS Code; the Active Agents row/provider badges work immediately once the updated extension is installed. diff --git a/openspec/changes/agent-codex-add-openspec-and-provider-icons-2026-04-23-14-02/specs/vscode-active-agents-provider-icons/spec.md b/openspec/changes/agent-codex-add-openspec-and-provider-icons-2026-04-23-14-02/specs/vscode-active-agents-provider-icons/spec.md new file mode 100644 index 0000000..0f5ffbe --- /dev/null +++ b/openspec/changes/agent-codex-add-openspec-and-provider-icons-2026-04-23-14-02/specs/vscode-active-agents-provider-icons/spec.md @@ -0,0 +1,55 @@ +## ADDED Requirements + +### Requirement: Active Agents rows show provider-aware working state + +Active Agents session rows SHALL surface provider identity for Codex/OpenAI and Claude sessions without sacrificing higher-priority warning badges. + +#### Scenario: Codex session shows OpenAI branding in the row + +- **GIVEN** an active session record whose CLI or agent identity resolves to Codex/OpenAI +- **WHEN** the Active Agents tree renders the session row +- **THEN** the row description includes `OpenAI` +- **AND** the session decoration exposes an `AI` badge whenever no blocked/dead/stalled/idle-threshold badge overrides it + +#### Scenario: Snapshot session shows the snapshot name and badge + +- **GIVEN** an active session or managed worktree telemetry record carries snapshot identity such as `nagyviktor@edixa.com` +- **WHEN** the Active Agents tree renders the session row +- **THEN** the row description includes the snapshot name +- **AND** the session decoration exposes the first alphanumeric snapshot initial, such as `N`, ahead of provider-only badges + +#### Scenario: Claude session shows Claude branding in the row + +- **GIVEN** an active session record whose CLI or agent identity resolves to Claude +- **WHEN** the Active Agents tree renders the session row +- **THEN** the row description includes `Claude` +- **AND** the session decoration exposes a `CL` badge whenever no blocked/dead/stalled/idle-threshold badge overrides it + +#### Scenario: Raw agent branch groups use branch presentation + +- **GIVEN** the Active Agents raw tree groups sessions by worktree branch +- **WHEN** a worktree group is rendered +- **THEN** the row uses the VS Code `git-branch` icon instead of the generic folder icon +- **AND** the row description includes the current state plus agent name, such as `working: codex` + +### Requirement: Bundled Explorer file icon theme highlights repo workflow surfaces + +The shipped VS Code companion SHALL bundle an optional file icon theme that gives workflow-critical repo paths distinct Explorer icons. + +#### Scenario: OpenSpec and workflow folders receive semantic icons + +- **GIVEN** the bundled `GitGuardex File Icons` theme is selected in VS Code +- **WHEN** the Explorer renders folders named `changes`, `plan`, `specs`, `.agents`, `agent-worktrees`, `.githooks`, or `rules` +- **THEN** each folder uses a bundled semantic icon instead of the generic default + +#### Scenario: Key workflow files receive semantic icons + +- **GIVEN** the bundled `GitGuardex File Icons` theme is selected in VS Code +- **WHEN** the Explorer renders workflow files such as `AGENTS.md`, `CLAUDE.md`, `proposal.md`, `tasks.md`, `plan.md`, `spec.md`, `config.yaml`, `.openspec.yaml`, `context-docs-cue.md`, `pre-commit`, `pre-push`, or `post-checkout` +- **THEN** each file uses the corresponding bundled semantic icon + +#### Scenario: Install bundle ships the icon theme assets + +- **GIVEN** maintainers install the workspace extension bundle through `scripts/install-vscode-active-agents-extension.js` +- **WHEN** the extension payload is copied into the VS Code extensions directory +- **THEN** the installed bundle contains the icon-theme manifest plus the SVG assets referenced by it diff --git a/openspec/changes/agent-codex-add-openspec-and-provider-icons-2026-04-23-14-02/tasks.md b/openspec/changes/agent-codex-add-openspec-and-provider-icons-2026-04-23-14-02/tasks.md new file mode 100644 index 0000000..6ae5b9a --- /dev/null +++ b/openspec/changes/agent-codex-add-openspec-and-provider-icons-2026-04-23-14-02/tasks.md @@ -0,0 +1,34 @@ +## Definition of Done + +This change is complete only when all of the following are true: + +- Every checkbox below is checked. +- Focused extension/package tests pass. +- Cleanup records the final PR URL plus `MERGED` evidence, or a `BLOCKED:` line explains why finish could not complete. + +Handoff: 2026-04-23 codex owns branch `agent/codex/add-openspec-and-provider-icons-2026-04-23-14-02`, the Active Agents extension bundle, mirrored template files, focused tests, and this OpenSpec change to ship Explorer file icons plus provider-aware Active Agents rows. + +## 1. Specification + +- [x] 1.1 Finalize proposal scope for Explorer file icons plus provider-aware Active Agents rows. +- [x] 1.2 Define normative requirements in `specs/vscode-active-agents-provider-icons/spec.md`. + +## 2. Implementation + +- [x] 2.1 Add bundled file icon theme assets and manifest wiring for OpenSpec, agent, and hook surfaces. +- [x] 2.2 Add provider/snapshot-aware Active Agents row labels/badges and branch-icon worktree groups without overriding higher-priority warning/idle decorations. +- [x] 2.3 Keep live/template extension sources, docs, and packaging metadata aligned. + +## 3. Verification + +- [x] 3.1 Run focused extension/install/package coverage. Result: `node --test test/vscode-active-agents-session-state.test.js test/metadata.test.js test/setup.test.js` passed `102/102`. +- [x] 3.2 Run `openspec validate agent-codex-add-openspec-and-provider-icons-2026-04-23-14-02 --type change --strict`. Result: passed. +- [x] 3.3 Run `openspec validate --specs`. Result: `No items found to validate.` + +## 4. Cleanup (mandatory; run before claiming completion) + +- [ ] 4.1 Run `gx branch finish --branch "agent/codex/add-openspec-and-provider-icons-2026-04-23-14-02" --base main --via-pr --wait-for-merge --cleanup`. +- [ ] 4.2 Record the PR URL and final `MERGED` state in the completion handoff. +- [ ] 4.3 Confirm the sandbox worktree and branch refs are gone after cleanup. + +BLOCKED: diff --git a/src/context.js b/src/context.js index 2c931fe..6fa31a4 100644 --- a/src/context.js +++ b/src/context.js @@ -129,6 +129,14 @@ const TEMPLATE_FILES = [ 'vscode/guardex-active-agents/session-schema.js', 'vscode/guardex-active-agents/README.md', 'vscode/guardex-active-agents/icon.png', + 'vscode/guardex-active-agents/fileicons/gitguardex-fileicons.json', + 'vscode/guardex-active-agents/fileicons/icons/agent.svg', + 'vscode/guardex-active-agents/fileicons/icons/branch.svg', + 'vscode/guardex-active-agents/fileicons/icons/config.svg', + 'vscode/guardex-active-agents/fileicons/icons/hook.svg', + 'vscode/guardex-active-agents/fileicons/icons/openspec.svg', + 'vscode/guardex-active-agents/fileicons/icons/plan.svg', + 'vscode/guardex-active-agents/fileicons/icons/spec.svg', ]; const PACKAGE_ROOT_SOURCE_OVERRIDES = new Set([ @@ -139,6 +147,14 @@ const PACKAGE_ROOT_SOURCE_OVERRIDES = new Set([ 'vscode/guardex-active-agents/session-schema.js', 'vscode/guardex-active-agents/README.md', 'vscode/guardex-active-agents/icon.png', + 'vscode/guardex-active-agents/fileicons/gitguardex-fileicons.json', + 'vscode/guardex-active-agents/fileicons/icons/agent.svg', + 'vscode/guardex-active-agents/fileicons/icons/branch.svg', + 'vscode/guardex-active-agents/fileicons/icons/config.svg', + 'vscode/guardex-active-agents/fileicons/icons/hook.svg', + 'vscode/guardex-active-agents/fileicons/icons/openspec.svg', + 'vscode/guardex-active-agents/fileicons/icons/plan.svg', + 'vscode/guardex-active-agents/fileicons/icons/spec.svg', ]); const LEGACY_WORKFLOW_SHIM_SPECS = [ diff --git a/templates/vscode/guardex-active-agents/README.md b/templates/vscode/guardex-active-agents/README.md index 6878a1c..9b0c580 100644 --- a/templates/vscode/guardex-active-agents/README.md +++ b/templates/vscode/guardex-active-agents/README.md @@ -18,11 +18,14 @@ node scripts/install-vscode-active-agents-extension.js What it does: - Bundles a local GitGuardex icon so repo installs show branded extension metadata inside VS Code. +- Bundles the optional `GitGuardex File Icons` theme for OpenSpec, agent worktree, and hook files in Explorer. - Adds an `Active Agents` view to the Source Control container. - Renders one repo node per live Guardex workspace with grouped `ACTIVE AGENTS` and `CHANGES` sections. - Splits live sessions inside `ACTIVE AGENTS` into `BLOCKED`, `WORKING NOW`, `THINKING`, `STALLED`, and `DEAD` groups so stuck, active, and inactive lanes stand out immediately. - Mirrors the same live state in the VS Code status bar so the selected session or active-agent count stays visible outside the tree. - Shows one row per live Guardex sandbox session inside those activity groups, with changed-file rows nested under sessions that are touching files. +- Labels session rows with provider identity and snapshot context; snapshot-backed rows use a one-letter snapshot badge such as `N` for `nagyviktor@edixa.com`. +- Shows raw agent branch groups with the `git-branch` icon instead of the generic folder icon. - Shows repo-root git changes in a sibling `CHANGES` section when the guarded repo itself is dirty. - Derives session state from dirty worktree status, git conflict markers, heartbeat freshness, PID liveness, and recent file mtimes, surfaces working/dead/conflict counts in the repo/header summary, and shows changed-file counts for active edits. - Uses distinct VS Code codicons for each session state, including animated `loading~spin` for `WORKING NOW`. diff --git a/templates/vscode/guardex-active-agents/extension.js b/templates/vscode/guardex-active-agents/extension.js index 255344c..0f9a096 100644 --- a/templates/vscode/guardex-active-agents/extension.js +++ b/templates/vscode/guardex-active-agents/extension.js @@ -56,6 +56,18 @@ const SESSION_ACTIVITY_ICON_IDS = { stalled: 'clock', dead: 'error', }; +const SESSION_PROVIDER_BRANDS = { + openai: { + id: 'openai', + label: 'OpenAI', + badge: 'AI', + }, + claude: { + id: 'claude', + label: 'Claude', + badge: 'CL', + }, +}; function sessionDecorationUri(branch) { return vscode.Uri.parse(`${SESSION_DECORATION_SCHEME}://${sanitizeBranchForFile(branch)}`); @@ -134,6 +146,84 @@ function uniqueStringList(values) { return result; } +function normalizeSessionProviderToken(value) { + return typeof value === 'string' ? value.trim().toLowerCase() : ''; +} + +function resolveSessionProvider(session) { + const signals = [ + session?.cliName, + session?.agentName, + session?.branch, + ] + .map(normalizeSessionProviderToken) + .filter(Boolean); + + if (signals.some((value) => value.includes('claude'))) { + return { + ...SESSION_PROVIDER_BRANDS.claude, + cliName: typeof session?.cliName === 'string' ? session.cliName.trim() : '', + }; + } + if (signals.some((value) => value.includes('codex') || value.includes('openai'))) { + return { + ...SESSION_PROVIDER_BRANDS.openai, + cliName: typeof session?.cliName === 'string' ? session.cliName.trim() : '', + }; + } + return null; +} + +function sessionProviderDecoration(session) { + const provider = resolveSessionProvider(session); + if (!provider) { + return undefined; + } + + const cliName = provider.cliName || provider.id; + return { + badge: provider.badge, + tooltip: `${provider.label} session via ${cliName}`, + }; +} + +function normalizeSnapshotIdentityValue(value) { + return typeof value === 'string' ? value.trim() : ''; +} + +function sessionSnapshotDisplayName(session) { + return normalizeSnapshotIdentityValue(session?.snapshotName) + || normalizeSnapshotIdentityValue(session?.snapshotEmail); +} + +function sessionSnapshotBadge(session) { + const displayName = sessionSnapshotDisplayName(session); + const match = displayName.match(/[a-z0-9]/i); + return match ? match[0].toUpperCase() : ''; +} + +function sessionSnapshotDescription(session) { + const displayName = sessionSnapshotDisplayName(session); + return displayName ? `snapshot ${displayName}` : ''; +} + +function sessionSnapshotDecoration(session) { + const badge = sessionSnapshotBadge(session); + const displayName = sessionSnapshotDisplayName(session); + if (!badge || !displayName) { + return undefined; + } + + return { + badge, + tooltip: `Snapshot ${displayName}`, + }; +} + +function sessionIdentityDecoration(session) { + return sessionSnapshotDecoration(session) || sessionProviderDecoration(session); +} + function stringListsEqual(left, right) { if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) { return false; @@ -408,9 +498,12 @@ function changeRiskBadges(change) { } function buildSessionCardDescription(session) { + const provider = resolveSessionProvider(session); + const statusAgentLabel = `${sessionStatusLabel(session)}: ${session.agentName || 'agent'}`; const descriptionParts = [ - session.agentName || 'agent', - sessionStatusLabel(session), + statusAgentLabel, + provider?.label ? `via ${provider.label}` : '', + sessionSnapshotDescription(session), session.deltaLabel || '', session.changeCount > 0 ? formatCountLabel(session.changeCount, 'changed file') : '', session.lockCount > 0 ? formatCountLabel(session.lockCount, 'lock') : '', @@ -421,7 +514,16 @@ function buildSessionCardDescription(session) { } function buildRawSessionDescription(session) { - const descriptionParts = [session.activityLabel || 'thinking']; + const provider = resolveSessionProvider(session); + const status = sessionStatusLabel(session).toLowerCase(); + const descriptionParts = [`${status}: ${session.agentName || 'agent'}`]; + if (provider?.label) { + descriptionParts.push(provider.label); + } + const snapshot = sessionSnapshotDescription(session); + if (snapshot) { + descriptionParts.push(snapshot); + } if (session.activityCountLabel) { descriptionParts.push(session.activityCountLabel); } @@ -433,6 +535,7 @@ function buildRawSessionDescription(session) { } function buildSessionTooltip(session, description) { + const provider = resolveSessionProvider(session); const riskSummary = uniqueStringList([ ...(session?.riskBadges || []), session?.deltaLabel || '', @@ -440,6 +543,10 @@ function buildSessionTooltip(session, description) { const topFiles = session?.topChangedFilesLabel || summarizeCompactPaths(session?.worktreeChangedPaths || []); return [ session.branch, + provider?.label + ? `Provider ${provider.label}${provider.cliName ? ` (${provider.cliName})` : ''}` + : '', + sessionSnapshotDisplayName(session) ? `Snapshot ${sessionSnapshotDisplayName(session)}` : '', `${session.agentName} · ${session.taskName}`, `Status ${description}`, session.recentChangeSummary ? `Recent ${session.recentChangeSummary}` : '', @@ -461,6 +568,23 @@ function buildUnassignedChangeDescription(change) { ].filter(Boolean).join(' · '); } +function buildWorktreeBranchDescription(sessions) { + const sessionList = Array.isArray(sessions) ? sessions : []; + const primarySession = sessionList[0] || null; + if (!primarySession) { + return ''; + } + + const descriptionParts = [ + `${sessionStatusLabel(primarySession).toLowerCase()}: ${primarySession.agentName || 'agent'}`, + sessionSnapshotDescription(primarySession), + ]; + if (sessionList.length > 1) { + descriptionParts.push(formatCountLabel(sessionList.length, 'agent')); + } + return descriptionParts.filter(Boolean).join(' · '); +} + function buildOverviewDescription(summary) { return [ formatCountLabel(summary?.workingCount || 0, 'working agent'), @@ -812,7 +936,12 @@ class SessionDecorationProvider { }; } - return sessionIdleDecoration(this.sessionsByUri.get(uri.toString()), this.nowProvider()); + const session = this.sessionsByUri.get(uri.toString()); + const idleDecoration = sessionIdleDecoration(session, this.nowProvider()); + if (idleDecoration) { + return idleDecoration; + } + return sessionIdentityDecoration(session); } } @@ -869,6 +998,7 @@ class WorktreeItem extends vscode.TreeItem { constructor(worktreePath, sessions, items = [], options = {}) { const normalizedWorktreePath = typeof worktreePath === 'string' ? worktreePath.trim() : ''; const sessionList = Array.isArray(sessions) ? sessions : []; + const primarySession = options.resourceSession || sessionList[0] || null; const changedCount = Number.isInteger(options.changedCount) ? options.changedCount : sessionList.reduce((total, session) => total + (session.changeCount || 0), 0); @@ -877,7 +1007,7 @@ class WorktreeItem extends vscode.TreeItem { descriptionParts.push(`${changedCount} changed`); } super( - path.basename(normalizedWorktreePath || '') || 'worktree', + options.label || path.basename(normalizedWorktreePath || '') || 'worktree', items.length > 0 ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.None, ); this.worktreePath = normalizedWorktreePath; @@ -888,13 +1018,16 @@ class WorktreeItem extends vscode.TreeItem { normalizedWorktreePath, ...sessionList.map((session) => session.branch).filter(Boolean), ].filter(Boolean).join('\n'); - this.iconPath = new vscode.ThemeIcon('folder'); + this.iconPath = new vscode.ThemeIcon(options.iconId || 'folder'); + if (options.useSessionDecoration && primarySession?.branch) { + this.resourceUri = sessionDecorationUri(primarySession.branch); + } this.contextValue = 'gitguardex.worktree'; - if (sessionList[0]?.worktreePath) { + if (primarySession?.worktreePath) { this.command = { command: 'gitguardex.activeAgents.openWorktree', title: 'Open Agent Worktree', - arguments: [sessionList[0]], + arguments: [primarySession], }; } } @@ -1856,6 +1989,8 @@ function commitWorktree(worktreePath, message) { } function buildSessionDetailItems(session) { + const provider = resolveSessionProvider(session); + const snapshot = sessionSnapshotDisplayName(session); const items = [ new DetailItem('Recent change', session.recentChangeSummary || 'No recent change summary.', { iconId: 'history', @@ -1871,6 +2006,16 @@ function buildSessionDetailItems(session) { tooltip: session.worktreePath, }), ]; + if (snapshot) { + items.splice(3, 0, new DetailItem('Snapshot', snapshot, { + iconId: 'account', + })); + } + if (provider?.label) { + items.splice(3, 0, new DetailItem('Provider', provider.label, { + iconId: 'sparkle', + })); + } const badgeSummary = uniqueStringList([ ...(session.riskBadges || []), session.deltaLabel || '', @@ -1923,6 +2068,12 @@ function buildRawActiveAgentGroupNodes(sessions) { variant: 'raw', }, )), + { + description: buildWorktreeBranchDescription(worktreeSessions), + iconId: 'git-branch', + resourceSession: worktreeSessions[0], + useSessionDecoration: true, + }, ) )); if (worktreeItems.length > 0) { diff --git a/templates/vscode/guardex-active-agents/fileicons/gitguardex-fileicons.json b/templates/vscode/guardex-active-agents/fileicons/gitguardex-fileicons.json new file mode 100644 index 0000000..e8e5968 --- /dev/null +++ b/templates/vscode/guardex-active-agents/fileicons/gitguardex-fileicons.json @@ -0,0 +1,54 @@ +{ + "iconDefinitions": { + "_gitguardex_agent": { + "iconPath": "./icons/agent.svg" + }, + "_gitguardex_branch": { + "iconPath": "./icons/branch.svg" + }, + "_gitguardex_config": { + "iconPath": "./icons/config.svg" + }, + "_gitguardex_hook": { + "iconPath": "./icons/hook.svg" + }, + "_gitguardex_openspec": { + "iconPath": "./icons/openspec.svg" + }, + "_gitguardex_plan": { + "iconPath": "./icons/plan.svg" + }, + "_gitguardex_spec": { + "iconPath": "./icons/spec.svg" + } + }, + "folderNames": { + ".agents": "_gitguardex_agent", + ".githooks": "_gitguardex_hook", + ".omc": "_gitguardex_agent", + ".omx": "_gitguardex_agent", + "agent-worktrees": "_gitguardex_branch", + "changes": "_gitguardex_openspec", + "plan": "_gitguardex_plan", + "rules": "_gitguardex_spec", + "specs": "_gitguardex_spec" + }, + "fileNames": { + ".openspec.yaml": "_gitguardex_config", + "AGENT.lock": "_gitguardex_agent", + "AGENTS.md": "_gitguardex_agent", + "CLAUDE.md": "_gitguardex_agent", + "config.yaml": "_gitguardex_config", + "context-docs-cue.md": "_gitguardex_spec", + "post-checkout": "_gitguardex_hook", + "pre-commit": "_gitguardex_hook", + "pre-push": "_gitguardex_hook", + "proposal.md": "_gitguardex_openspec", + "spec.md": "_gitguardex_spec", + "tasks.md": "_gitguardex_plan", + "plan.md": "_gitguardex_plan" + }, + "fileExtensions": { + "openspec.yaml": "_gitguardex_config" + } +} diff --git a/templates/vscode/guardex-active-agents/fileicons/icons/agent.svg b/templates/vscode/guardex-active-agents/fileicons/icons/agent.svg new file mode 100644 index 0000000..7a71d75 --- /dev/null +++ b/templates/vscode/guardex-active-agents/fileicons/icons/agent.svg @@ -0,0 +1,4 @@ + + + + diff --git a/templates/vscode/guardex-active-agents/fileicons/icons/branch.svg b/templates/vscode/guardex-active-agents/fileicons/icons/branch.svg new file mode 100644 index 0000000..f55fed0 --- /dev/null +++ b/templates/vscode/guardex-active-agents/fileicons/icons/branch.svg @@ -0,0 +1,4 @@ + + + + diff --git a/templates/vscode/guardex-active-agents/fileicons/icons/config.svg b/templates/vscode/guardex-active-agents/fileicons/icons/config.svg new file mode 100644 index 0000000..d6d45ee --- /dev/null +++ b/templates/vscode/guardex-active-agents/fileicons/icons/config.svg @@ -0,0 +1,4 @@ + + + + diff --git a/templates/vscode/guardex-active-agents/fileicons/icons/hook.svg b/templates/vscode/guardex-active-agents/fileicons/icons/hook.svg new file mode 100644 index 0000000..3478b55 --- /dev/null +++ b/templates/vscode/guardex-active-agents/fileicons/icons/hook.svg @@ -0,0 +1,3 @@ + + + diff --git a/templates/vscode/guardex-active-agents/fileicons/icons/openspec.svg b/templates/vscode/guardex-active-agents/fileicons/icons/openspec.svg new file mode 100644 index 0000000..84314d6 --- /dev/null +++ b/templates/vscode/guardex-active-agents/fileicons/icons/openspec.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/templates/vscode/guardex-active-agents/fileicons/icons/plan.svg b/templates/vscode/guardex-active-agents/fileicons/icons/plan.svg new file mode 100644 index 0000000..c4f65a6 --- /dev/null +++ b/templates/vscode/guardex-active-agents/fileicons/icons/plan.svg @@ -0,0 +1,4 @@ + + + + diff --git a/templates/vscode/guardex-active-agents/fileicons/icons/spec.svg b/templates/vscode/guardex-active-agents/fileicons/icons/spec.svg new file mode 100644 index 0000000..9eb1fa5 --- /dev/null +++ b/templates/vscode/guardex-active-agents/fileicons/icons/spec.svg @@ -0,0 +1,4 @@ + + + + diff --git a/templates/vscode/guardex-active-agents/package.json b/templates/vscode/guardex-active-agents/package.json index 05ce3e8..f9fad4f 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 inside VS Code Source Control.", "publisher": "recodeee", - "version": "0.0.9", + "version": "0.0.10", "license": "MIT", "icon": "icon.png", "engines": { @@ -22,6 +22,13 @@ ], "main": "./extension.js", "contributes": { + "iconThemes": [ + { + "id": "gitguardex-file-icons", + "label": "GitGuardex File Icons", + "path": "./fileicons/gitguardex-fileicons.json" + } + ], "commands": [ { "command": "gitguardex.activeAgents.startAgent", diff --git a/templates/vscode/guardex-active-agents/session-schema.js b/templates/vscode/guardex-active-agents/session-schema.js index d2a71c8..e561987 100644 --- a/templates/vscode/guardex-active-agents/session-schema.js +++ b/templates/vscode/guardex-active-agents/session-schema.js @@ -744,6 +744,8 @@ function buildSessionRecord(input) { taskName: toNonEmptyString(input.taskName, 'task'), latestTaskPreview: '', agentName: toNonEmptyString(input.agentName, 'agent'), + snapshotName: toNonEmptyString(input.snapshotName), + snapshotEmail: toNonEmptyString(input.snapshotEmail || input.email), worktreePath, pid, cliName: toNonEmptyString(input.cliName, 'codex'), @@ -794,6 +796,8 @@ function normalizeSessionRecord(input, options = {}) { taskName: toNonEmptyString(input.taskName, 'task'), latestTaskPreview: '', agentName: toNonEmptyString(input.agentName, 'agent'), + snapshotName: toNonEmptyString(input.snapshotName), + snapshotEmail: toNonEmptyString(input.snapshotEmail || input.email), worktreePath: path.resolve(worktreePath), pid, cliName: toNonEmptyString(input.cliName, 'codex'), @@ -915,7 +919,18 @@ function sortSessionsByTimestamp(sessions) { } function deriveLockTaskAnchor(entries, fallbackTaskName, fallbackTimestamp) { - const sortedEntries = [...entries].sort((left, right) => { + const sortedEntries = sortTelemetryEntriesForAnchor(entries); + + const latestEntry = sortedEntries[0] || null; + return { + taskName: latestEntry?.taskPreview || fallbackTaskName || 'task', + latestTaskPreview: latestEntry?.taskPreview || '', + timestamp: latestEntry?.taskUpdatedAt || fallbackTimestamp || '', + }; +} + +function sortTelemetryEntriesForAnchor(entries) { + return [...entries].sort((left, right) => { const timeDelta = Date.parse(right.taskUpdatedAt || '') - Date.parse(left.taskUpdatedAt || ''); if (timeDelta !== 0) { return timeDelta; @@ -925,12 +940,14 @@ function deriveLockTaskAnchor(entries, fallbackTaskName, fallbackTimestamp) { } return (right.projectPath || '').localeCompare(left.projectPath || ''); }); +} - const latestEntry = sortedEntries[0] || null; +function deriveLockSnapshotIdentity(entries) { + const latestEntry = sortTelemetryEntriesForAnchor(entries) + .find((entry) => entry?.snapshotName || entry?.email) || null; return { - taskName: latestEntry?.taskPreview || fallbackTaskName || 'task', - latestTaskPreview: latestEntry?.taskPreview || '', - timestamp: latestEntry?.taskUpdatedAt || fallbackTimestamp || '', + snapshotName: toNonEmptyString(latestEntry?.snapshotName), + snapshotEmail: toNonEmptyString(latestEntry?.email), }; } @@ -944,6 +961,7 @@ function buildWorktreeLockSession(repoRoot, worktreePath, lockPayload, options = : `agent/telemetry/${path.basename(worktreePath)}`; const label = deriveSessionLabel(effectiveBranch, worktreePath); const taskAnchor = deriveLockTaskAnchor(telemetryEntries, label, telemetryUpdatedAt); + const snapshotIdentity = deriveLockSnapshotIdentity(telemetryEntries); const startedAt = taskAnchor.timestamp || telemetryUpdatedAt || new Date(now).toISOString(); const session = { @@ -953,6 +971,8 @@ function buildWorktreeLockSession(repoRoot, worktreePath, lockPayload, options = taskName: taskAnchor.taskName, latestTaskPreview: taskAnchor.latestTaskPreview, agentName: deriveAgentNameFromBranch(effectiveBranch), + snapshotName: snapshotIdentity.snapshotName, + snapshotEmail: snapshotIdentity.snapshotEmail, worktreePath: path.resolve(worktreePath), pid: null, cliName: 'codex', @@ -995,6 +1015,8 @@ function buildManagedWorktreeSession(repoRoot, worktreePath, options = {}) { taskName: label, latestTaskPreview: '', agentName: deriveAgentNameFromBranch(branch), + snapshotName: '', + snapshotEmail: '', worktreePath: path.resolve(worktreePath), pid: null, cliName: 'gx', diff --git a/test/metadata.test.js b/test/metadata.test.js index 2ae25b3..96df253 100644 --- a/test/metadata.test.js +++ b/test/metadata.test.js @@ -138,6 +138,14 @@ test('critical runtime helper scripts and active-agents sources stay in sync wit ['templates/vscode/guardex-active-agents/extension.js', 'vscode/guardex-active-agents/extension.js'], ['templates/vscode/guardex-active-agents/session-schema.js', 'vscode/guardex-active-agents/session-schema.js'], ['templates/vscode/guardex-active-agents/icon.png', 'vscode/guardex-active-agents/icon.png'], + ['templates/vscode/guardex-active-agents/fileicons/gitguardex-fileicons.json', 'vscode/guardex-active-agents/fileicons/gitguardex-fileicons.json'], + ['templates/vscode/guardex-active-agents/fileicons/icons/agent.svg', 'vscode/guardex-active-agents/fileicons/icons/agent.svg'], + ['templates/vscode/guardex-active-agents/fileicons/icons/branch.svg', 'vscode/guardex-active-agents/fileicons/icons/branch.svg'], + ['templates/vscode/guardex-active-agents/fileicons/icons/config.svg', 'vscode/guardex-active-agents/fileicons/icons/config.svg'], + ['templates/vscode/guardex-active-agents/fileicons/icons/hook.svg', 'vscode/guardex-active-agents/fileicons/icons/hook.svg'], + ['templates/vscode/guardex-active-agents/fileicons/icons/openspec.svg', 'vscode/guardex-active-agents/fileicons/icons/openspec.svg'], + ['templates/vscode/guardex-active-agents/fileicons/icons/plan.svg', 'vscode/guardex-active-agents/fileicons/icons/plan.svg'], + ['templates/vscode/guardex-active-agents/fileicons/icons/spec.svg', 'vscode/guardex-active-agents/fileicons/icons/spec.svg'], ]; for (const [templatePath, runtimePath] of pairs) { diff --git a/test/setup.test.js b/test/setup.test.js index e3759bd..632c643 100644 --- a/test/setup.test.js +++ b/test/setup.test.js @@ -180,6 +180,14 @@ test('setup provisions workflow files and repo config', () => { 'vscode/guardex-active-agents/extension.js', 'vscode/guardex-active-agents/session-schema.js', 'vscode/guardex-active-agents/icon.png', + 'vscode/guardex-active-agents/fileicons/gitguardex-fileicons.json', + 'vscode/guardex-active-agents/fileicons/icons/agent.svg', + 'vscode/guardex-active-agents/fileicons/icons/branch.svg', + 'vscode/guardex-active-agents/fileicons/icons/config.svg', + 'vscode/guardex-active-agents/fileicons/icons/hook.svg', + 'vscode/guardex-active-agents/fileicons/icons/openspec.svg', + 'vscode/guardex-active-agents/fileicons/icons/plan.svg', + 'vscode/guardex-active-agents/fileicons/icons/spec.svg', ]; for (const relativePath of canonicalBundleFiles) { const installedPath = path.join(repoDir, relativePath); diff --git a/test/vscode-active-agents-session-state.test.js b/test/vscode-active-agents-session-state.test.js index 1a43482..49185fd 100644 --- a/test/vscode-active-agents-session-state.test.js +++ b/test/vscode-active-agents-session-state.test.js @@ -1214,8 +1214,11 @@ test('install-vscode-active-agents-extension installs the current extension into assert.equal(installedManifest.icon, 'icon.png'); assert.equal(installedManifest.version, manifest.version); assert.deepEqual(installedManifest.activationEvents, manifest.activationEvents); + assert.deepEqual(installedManifest.contributes.iconThemes, manifest.contributes.iconThemes); assert.equal(installedManifest.activationEvents.includes('onStartupFinished'), true); assert.equal(fs.existsSync(path.join(canonicalDir, 'icon.png')), true); + assert.equal(fs.existsSync(path.join(canonicalDir, 'fileicons', 'gitguardex-fileicons.json')), true); + assert.equal(fs.existsSync(path.join(canonicalDir, 'fileicons', 'icons', 'openspec.svg')), true); assert.equal(fs.existsSync(currentVersionDir), true); assert.equal(fs.existsSync(path.join(recentCompatDir, 'package.json')), true); assert.equal(fs.existsSync(path.join(recentCompatDir, 'stale.txt')), false); @@ -1247,6 +1250,11 @@ test('active-agents extension edits require a higher manifest version than the b templateManifest.activationEvents, 'Live and template Active Agents activation events must stay in sync.', ); + assert.deepEqual( + liveManifest.contributes.iconThemes, + templateManifest.contributes.iconThemes, + 'Live and template Active Agents icon theme contributions must stay in sync.', + ); assert.equal( liveManifest.activationEvents.includes('onStartupFinished'), true, @@ -1261,6 +1269,31 @@ test('active-agents extension edits require a higher manifest version than the b ); }); +test('active-agents file icon theme maps Guardex workflow paths and ships referenced assets', () => { + const manifest = readExtensionManifest(); + const themeContribution = manifest.contributes.iconThemes.find((entry) => entry.id === 'gitguardex-file-icons'); + assert.ok(themeContribution, 'Expected the GitGuardex file icon theme contribution.'); + assert.equal(themeContribution.path, './fileicons/gitguardex-fileicons.json'); + + const themePath = path.join(path.dirname(extensionManifestPath), themeContribution.path); + const theme = readJson(themePath); + assert.equal(theme.folderNames.changes, '_gitguardex_openspec'); + assert.equal(theme.folderNames.plan, '_gitguardex_plan'); + assert.equal(theme.folderNames.specs, '_gitguardex_spec'); + assert.equal(theme.folderNames['agent-worktrees'], '_gitguardex_branch'); + assert.equal(theme.folderNames['.githooks'], '_gitguardex_hook'); + assert.equal(theme.fileNames['AGENTS.md'], '_gitguardex_agent'); + assert.equal(theme.fileNames['proposal.md'], '_gitguardex_openspec'); + assert.equal(theme.fileNames['tasks.md'], '_gitguardex_plan'); + assert.equal(theme.fileNames['spec.md'], '_gitguardex_spec'); + assert.equal(theme.fileNames['pre-commit'], '_gitguardex_hook'); + + for (const definition of Object.values(theme.iconDefinitions)) { + assert.equal(typeof definition.iconPath, 'string'); + assert.equal(fs.existsSync(path.join(path.dirname(themePath), definition.iconPath)), true); + } +}); + test('active-agents extension auto-installs a newer workspace build and offers reload', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-autoupdate-')); const repoManifest = { @@ -1580,7 +1613,7 @@ test('active-agents extension groups live sessions under a repo node', async () assert.equal(worktreeItem, null); assert.equal(sessionItem.label, 'live-task'); assert.equal(sessionItem.session.branch, 'agent/codex/live-task'); - assert.match(sessionItem.description, /^codex · Idle/); + assert.match(sessionItem.description, /^Idle: codex · via OpenAI/); assert.equal(sessionItem.iconPath.id, 'comment-discussion'); assert.equal(sessionItem.resourceUri.scheme, 'gitguardex-agent'); assert.equal( @@ -1606,6 +1639,69 @@ test('active-agents extension groups live sessions under a repo node', async () } }); +test('active-agents extension shows provider and snapshot identity badges', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-provider-badges-')); + const codexWorktreePath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-provider-codex-')); + const claudeWorktreePath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-provider-claude-')); + initGitRepo(codexWorktreePath); + initGitRepo(claudeWorktreePath); + + const codexSessionPath = writeSessionRecord(tempRoot, sessionSchema.buildSessionRecord({ + repoRoot: tempRoot, + branch: 'agent/codex/provider-task', + taskName: 'provider-task', + agentName: 'codex', + snapshotName: 'nagyviktor@edixa.com', + worktreePath: codexWorktreePath, + pid: process.pid, + cliName: 'codex', + })); + const claudeSessionPath = writeSessionRecord(tempRoot, sessionSchema.buildSessionRecord({ + repoRoot: tempRoot, + branch: 'agent/claude/provider-task', + taskName: 'provider-task', + agentName: 'claude', + worktreePath: claudeWorktreePath, + pid: process.pid, + cliName: 'claude', + })); + + const { registrations, vscode } = createMockVscode(tempRoot); + vscode.workspace.findFiles = async () => [ + { fsPath: codexSessionPath }, + { fsPath: claudeSessionPath }, + ]; + const extension = loadExtensionWithMockVscode(vscode); + const context = { subscriptions: [] }; + + extension.activate(context); + await flushAsyncWork(); + + const provider = registrations.providers[0].provider; + const [repoItem] = await provider.getChildren(); + const idleSection = await getSectionByLabel(provider, repoItem, 'Idle / thinking'); + const codexItem = await getSessionByBranch(provider, idleSection, 'agent/codex/provider-task'); + const claudeItem = await getSessionByBranch(provider, idleSection, 'agent/claude/provider-task'); + assert.match(codexItem.description, /^Idle: codex · via OpenAI · snapshot nagyviktor@edixa\.com/); + assert.match(claudeItem.description, /^Idle: claude · via Claude/); + + const decorationProvider = registrations.decorationProviders[0]; + const codexDecoration = decorationProvider.provideFileDecoration(vscode.Uri.parse( + `gitguardex-agent://${sessionSchema.sanitizeBranchForFile('agent/codex/provider-task')}`, + )); + const claudeDecoration = decorationProvider.provideFileDecoration(vscode.Uri.parse( + `gitguardex-agent://${sessionSchema.sanitizeBranchForFile('agent/claude/provider-task')}`, + )); + assert.equal(codexDecoration.badge, 'N'); + assert.equal(codexDecoration.tooltip, 'Snapshot nagyviktor@edixa.com'); + assert.equal(claudeDecoration.badge, 'CL'); + assert.equal(claudeDecoration.tooltip, 'Claude session via claude'); + + for (const subscription of context.subscriptions) { + subscription.dispose?.(); + } +}); + test('active-agents extension decorates idle clean sessions without overriding working rows', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-idle-decorations-')); @@ -1696,7 +1792,8 @@ test('active-agents extension decorates idle clean sessions without overriding w const workingDecoration = decorationProvider.provideFileDecoration(vscode.Uri.parse( `gitguardex-agent://${sessionSchema.sanitizeBranchForFile('agent/codex/working-now')}`, )); - assert.equal(workingDecoration, undefined); + assert.equal(workingDecoration.badge, 'AI'); + assert.equal(workingDecoration.tooltip, 'OpenAI session via codex'); for (const subscription of context.subscriptions) { subscription.dispose?.(); @@ -1831,7 +1928,7 @@ test('active-agents extension shows grouped repo changes beside active agents', assert.equal(worktreeItem, null); assert.equal(sessionItem.label, 'live-task'); assert.equal(sessionItem.session.branch, 'agent/codex/live-task'); - assert.match(sessionItem.description, /^codex · Working · 2 changed files/); + assert.match(sessionItem.description, /^Working: codex · via OpenAI · 2 changed files/); assert.match(sessionItem.tooltip, /Recent Changed src\/nested\.js, tracked\.txt/); assert.equal(sessionItem.iconPath.id, 'loading~spin'); const sessionDetails = await provider.getChildren(sessionItem); @@ -1891,6 +1988,25 @@ test('active-agents extension surfaces live managed worktrees from AGENT.lock fa 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: 'nagyviktor@edixa.com', + accountId: 'acct-1', + email: 'nagyviktor@edixa.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, + }, + ], + }, + ], }); const { registrations, vscode } = createMockVscode(tempRoot); @@ -1922,8 +2038,15 @@ test('active-agents extension surfaces live managed worktrees from AGENT.lock fa const { worktreeItem, sessionItem } = await getOnlyWorktreeAndSession(provider, workingSection); assert.equal(worktreeItem, null); assert.equal(sessionItem.session.branch, 'agent/codex/lock-visible-task'); - assert.match(sessionItem.description, /^codex · Working · 1 changed file/); + assert.match(sessionItem.description, /^Working: codex · via OpenAI · snapshot nagyviktor@edixa\.com · 1 changed file/); + assert.equal(sessionItem.session.snapshotName, 'nagyviktor@edixa.com'); assert.match(sessionItem.tooltip, /Telemetry updated 2026-04-22T09:01:00.000Z/); + assert.match(sessionItem.tooltip, /Snapshot nagyviktor@edixa\.com/); + const snapshotDecoration = registrations.decorationProviders[0].provideFileDecoration(vscode.Uri.parse( + `gitguardex-agent://${sessionSchema.sanitizeBranchForFile('agent/codex/lock-visible-task')}`, + )); + assert.equal(snapshotDecoration.badge, 'N'); + assert.equal(snapshotDecoration.tooltip, 'Snapshot nagyviktor@edixa.com'); for (const subscription of context.subscriptions) { subscription.dispose?.(); @@ -1964,7 +2087,7 @@ test('active-agents extension surfaces plain managed worktrees from workspace fa const { worktreeItem, sessionItem } = await getOnlyWorktreeAndSession(provider, workingSection); assert.equal(worktreeItem, null); assert.equal(sessionItem.session.branch, 'agent/codex/plain-visible-task'); - assert.match(sessionItem.description, /^codex · Working · 1 changed file/); + assert.match(sessionItem.description, /^Working: codex · via OpenAI · 1 changed file/); assert.match(sessionItem.tooltip, /Started /); for (const subscription of context.subscriptions) { @@ -2050,6 +2173,9 @@ 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)); + 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); const [sessionChangeItem] = await provider.getChildren(sessionGroup); assert.equal(sessionChangeItem.label, 'tracked.txt'); @@ -2300,15 +2426,15 @@ test('active-agents extension groups blocked, working, idle, stalled, and dead s const idleItem = await getSessionByBranch(provider, idleThinkingSection, 'agent/codex/idle-task'); const stalledItem = await getSessionByBranch(provider, idleThinkingSection, 'agent/codex/stalled-task'); const deadItem = await getSessionByBranch(provider, idleThinkingSection, 'agent/codex/dead-task'); - assert.match(blockedItem.description, /^codex · Blocked/); + assert.match(blockedItem.description, /^Blocked: codex · via OpenAI/); assert.equal(blockedItem.iconPath.id, 'warning'); - assert.match(workingItem.description, /^codex · Working · 1 changed file/); + assert.match(workingItem.description, /^Working: codex · via OpenAI · 1 changed file/); assert.equal(workingItem.iconPath.id, 'loading~spin'); - assert.match(idleItem.description, /^codex · Idle/); + assert.match(idleItem.description, /^Idle: codex · via OpenAI/); assert.equal(idleItem.iconPath.id, 'comment-discussion'); - assert.match(stalledItem.description, /^codex · Stale/); + assert.match(stalledItem.description, /^Stale: codex · via OpenAI/); assert.equal(stalledItem.iconPath.id, 'clock'); - assert.match(deadItem.description, /^codex · Dead/); + assert.match(deadItem.description, /^Dead: codex · via OpenAI/); assert.equal(deadItem.iconPath.id, 'error'); assert.deepEqual(registrations.treeViews[0].badge, { value: 5, diff --git a/vscode/guardex-active-agents/README.md b/vscode/guardex-active-agents/README.md index 6878a1c..9b0c580 100644 --- a/vscode/guardex-active-agents/README.md +++ b/vscode/guardex-active-agents/README.md @@ -18,11 +18,14 @@ node scripts/install-vscode-active-agents-extension.js What it does: - Bundles a local GitGuardex icon so repo installs show branded extension metadata inside VS Code. +- Bundles the optional `GitGuardex File Icons` theme for OpenSpec, agent worktree, and hook files in Explorer. - Adds an `Active Agents` view to the Source Control container. - Renders one repo node per live Guardex workspace with grouped `ACTIVE AGENTS` and `CHANGES` sections. - Splits live sessions inside `ACTIVE AGENTS` into `BLOCKED`, `WORKING NOW`, `THINKING`, `STALLED`, and `DEAD` groups so stuck, active, and inactive lanes stand out immediately. - Mirrors the same live state in the VS Code status bar so the selected session or active-agent count stays visible outside the tree. - Shows one row per live Guardex sandbox session inside those activity groups, with changed-file rows nested under sessions that are touching files. +- Labels session rows with provider identity and snapshot context; snapshot-backed rows use a one-letter snapshot badge such as `N` for `nagyviktor@edixa.com`. +- Shows raw agent branch groups with the `git-branch` icon instead of the generic folder icon. - Shows repo-root git changes in a sibling `CHANGES` section when the guarded repo itself is dirty. - Derives session state from dirty worktree status, git conflict markers, heartbeat freshness, PID liveness, and recent file mtimes, surfaces working/dead/conflict counts in the repo/header summary, and shows changed-file counts for active edits. - Uses distinct VS Code codicons for each session state, including animated `loading~spin` for `WORKING NOW`. diff --git a/vscode/guardex-active-agents/extension.js b/vscode/guardex-active-agents/extension.js index 255344c..0f9a096 100644 --- a/vscode/guardex-active-agents/extension.js +++ b/vscode/guardex-active-agents/extension.js @@ -56,6 +56,18 @@ const SESSION_ACTIVITY_ICON_IDS = { stalled: 'clock', dead: 'error', }; +const SESSION_PROVIDER_BRANDS = { + openai: { + id: 'openai', + label: 'OpenAI', + badge: 'AI', + }, + claude: { + id: 'claude', + label: 'Claude', + badge: 'CL', + }, +}; function sessionDecorationUri(branch) { return vscode.Uri.parse(`${SESSION_DECORATION_SCHEME}://${sanitizeBranchForFile(branch)}`); @@ -134,6 +146,84 @@ function uniqueStringList(values) { return result; } +function normalizeSessionProviderToken(value) { + return typeof value === 'string' ? value.trim().toLowerCase() : ''; +} + +function resolveSessionProvider(session) { + const signals = [ + session?.cliName, + session?.agentName, + session?.branch, + ] + .map(normalizeSessionProviderToken) + .filter(Boolean); + + if (signals.some((value) => value.includes('claude'))) { + return { + ...SESSION_PROVIDER_BRANDS.claude, + cliName: typeof session?.cliName === 'string' ? session.cliName.trim() : '', + }; + } + if (signals.some((value) => value.includes('codex') || value.includes('openai'))) { + return { + ...SESSION_PROVIDER_BRANDS.openai, + cliName: typeof session?.cliName === 'string' ? session.cliName.trim() : '', + }; + } + return null; +} + +function sessionProviderDecoration(session) { + const provider = resolveSessionProvider(session); + if (!provider) { + return undefined; + } + + const cliName = provider.cliName || provider.id; + return { + badge: provider.badge, + tooltip: `${provider.label} session via ${cliName}`, + }; +} + +function normalizeSnapshotIdentityValue(value) { + return typeof value === 'string' ? value.trim() : ''; +} + +function sessionSnapshotDisplayName(session) { + return normalizeSnapshotIdentityValue(session?.snapshotName) + || normalizeSnapshotIdentityValue(session?.snapshotEmail); +} + +function sessionSnapshotBadge(session) { + const displayName = sessionSnapshotDisplayName(session); + const match = displayName.match(/[a-z0-9]/i); + return match ? match[0].toUpperCase() : ''; +} + +function sessionSnapshotDescription(session) { + const displayName = sessionSnapshotDisplayName(session); + return displayName ? `snapshot ${displayName}` : ''; +} + +function sessionSnapshotDecoration(session) { + const badge = sessionSnapshotBadge(session); + const displayName = sessionSnapshotDisplayName(session); + if (!badge || !displayName) { + return undefined; + } + + return { + badge, + tooltip: `Snapshot ${displayName}`, + }; +} + +function sessionIdentityDecoration(session) { + return sessionSnapshotDecoration(session) || sessionProviderDecoration(session); +} + function stringListsEqual(left, right) { if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) { return false; @@ -408,9 +498,12 @@ function changeRiskBadges(change) { } function buildSessionCardDescription(session) { + const provider = resolveSessionProvider(session); + const statusAgentLabel = `${sessionStatusLabel(session)}: ${session.agentName || 'agent'}`; const descriptionParts = [ - session.agentName || 'agent', - sessionStatusLabel(session), + statusAgentLabel, + provider?.label ? `via ${provider.label}` : '', + sessionSnapshotDescription(session), session.deltaLabel || '', session.changeCount > 0 ? formatCountLabel(session.changeCount, 'changed file') : '', session.lockCount > 0 ? formatCountLabel(session.lockCount, 'lock') : '', @@ -421,7 +514,16 @@ function buildSessionCardDescription(session) { } function buildRawSessionDescription(session) { - const descriptionParts = [session.activityLabel || 'thinking']; + const provider = resolveSessionProvider(session); + const status = sessionStatusLabel(session).toLowerCase(); + const descriptionParts = [`${status}: ${session.agentName || 'agent'}`]; + if (provider?.label) { + descriptionParts.push(provider.label); + } + const snapshot = sessionSnapshotDescription(session); + if (snapshot) { + descriptionParts.push(snapshot); + } if (session.activityCountLabel) { descriptionParts.push(session.activityCountLabel); } @@ -433,6 +535,7 @@ function buildRawSessionDescription(session) { } function buildSessionTooltip(session, description) { + const provider = resolveSessionProvider(session); const riskSummary = uniqueStringList([ ...(session?.riskBadges || []), session?.deltaLabel || '', @@ -440,6 +543,10 @@ function buildSessionTooltip(session, description) { const topFiles = session?.topChangedFilesLabel || summarizeCompactPaths(session?.worktreeChangedPaths || []); return [ session.branch, + provider?.label + ? `Provider ${provider.label}${provider.cliName ? ` (${provider.cliName})` : ''}` + : '', + sessionSnapshotDisplayName(session) ? `Snapshot ${sessionSnapshotDisplayName(session)}` : '', `${session.agentName} · ${session.taskName}`, `Status ${description}`, session.recentChangeSummary ? `Recent ${session.recentChangeSummary}` : '', @@ -461,6 +568,23 @@ function buildUnassignedChangeDescription(change) { ].filter(Boolean).join(' · '); } +function buildWorktreeBranchDescription(sessions) { + const sessionList = Array.isArray(sessions) ? sessions : []; + const primarySession = sessionList[0] || null; + if (!primarySession) { + return ''; + } + + const descriptionParts = [ + `${sessionStatusLabel(primarySession).toLowerCase()}: ${primarySession.agentName || 'agent'}`, + sessionSnapshotDescription(primarySession), + ]; + if (sessionList.length > 1) { + descriptionParts.push(formatCountLabel(sessionList.length, 'agent')); + } + return descriptionParts.filter(Boolean).join(' · '); +} + function buildOverviewDescription(summary) { return [ formatCountLabel(summary?.workingCount || 0, 'working agent'), @@ -812,7 +936,12 @@ class SessionDecorationProvider { }; } - return sessionIdleDecoration(this.sessionsByUri.get(uri.toString()), this.nowProvider()); + const session = this.sessionsByUri.get(uri.toString()); + const idleDecoration = sessionIdleDecoration(session, this.nowProvider()); + if (idleDecoration) { + return idleDecoration; + } + return sessionIdentityDecoration(session); } } @@ -869,6 +998,7 @@ class WorktreeItem extends vscode.TreeItem { constructor(worktreePath, sessions, items = [], options = {}) { const normalizedWorktreePath = typeof worktreePath === 'string' ? worktreePath.trim() : ''; const sessionList = Array.isArray(sessions) ? sessions : []; + const primarySession = options.resourceSession || sessionList[0] || null; const changedCount = Number.isInteger(options.changedCount) ? options.changedCount : sessionList.reduce((total, session) => total + (session.changeCount || 0), 0); @@ -877,7 +1007,7 @@ class WorktreeItem extends vscode.TreeItem { descriptionParts.push(`${changedCount} changed`); } super( - path.basename(normalizedWorktreePath || '') || 'worktree', + options.label || path.basename(normalizedWorktreePath || '') || 'worktree', items.length > 0 ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.None, ); this.worktreePath = normalizedWorktreePath; @@ -888,13 +1018,16 @@ class WorktreeItem extends vscode.TreeItem { normalizedWorktreePath, ...sessionList.map((session) => session.branch).filter(Boolean), ].filter(Boolean).join('\n'); - this.iconPath = new vscode.ThemeIcon('folder'); + this.iconPath = new vscode.ThemeIcon(options.iconId || 'folder'); + if (options.useSessionDecoration && primarySession?.branch) { + this.resourceUri = sessionDecorationUri(primarySession.branch); + } this.contextValue = 'gitguardex.worktree'; - if (sessionList[0]?.worktreePath) { + if (primarySession?.worktreePath) { this.command = { command: 'gitguardex.activeAgents.openWorktree', title: 'Open Agent Worktree', - arguments: [sessionList[0]], + arguments: [primarySession], }; } } @@ -1856,6 +1989,8 @@ function commitWorktree(worktreePath, message) { } function buildSessionDetailItems(session) { + const provider = resolveSessionProvider(session); + const snapshot = sessionSnapshotDisplayName(session); const items = [ new DetailItem('Recent change', session.recentChangeSummary || 'No recent change summary.', { iconId: 'history', @@ -1871,6 +2006,16 @@ function buildSessionDetailItems(session) { tooltip: session.worktreePath, }), ]; + if (snapshot) { + items.splice(3, 0, new DetailItem('Snapshot', snapshot, { + iconId: 'account', + })); + } + if (provider?.label) { + items.splice(3, 0, new DetailItem('Provider', provider.label, { + iconId: 'sparkle', + })); + } const badgeSummary = uniqueStringList([ ...(session.riskBadges || []), session.deltaLabel || '', @@ -1923,6 +2068,12 @@ function buildRawActiveAgentGroupNodes(sessions) { variant: 'raw', }, )), + { + description: buildWorktreeBranchDescription(worktreeSessions), + iconId: 'git-branch', + resourceSession: worktreeSessions[0], + useSessionDecoration: true, + }, ) )); if (worktreeItems.length > 0) { diff --git a/vscode/guardex-active-agents/fileicons/gitguardex-fileicons.json b/vscode/guardex-active-agents/fileicons/gitguardex-fileicons.json new file mode 100644 index 0000000..e8e5968 --- /dev/null +++ b/vscode/guardex-active-agents/fileicons/gitguardex-fileicons.json @@ -0,0 +1,54 @@ +{ + "iconDefinitions": { + "_gitguardex_agent": { + "iconPath": "./icons/agent.svg" + }, + "_gitguardex_branch": { + "iconPath": "./icons/branch.svg" + }, + "_gitguardex_config": { + "iconPath": "./icons/config.svg" + }, + "_gitguardex_hook": { + "iconPath": "./icons/hook.svg" + }, + "_gitguardex_openspec": { + "iconPath": "./icons/openspec.svg" + }, + "_gitguardex_plan": { + "iconPath": "./icons/plan.svg" + }, + "_gitguardex_spec": { + "iconPath": "./icons/spec.svg" + } + }, + "folderNames": { + ".agents": "_gitguardex_agent", + ".githooks": "_gitguardex_hook", + ".omc": "_gitguardex_agent", + ".omx": "_gitguardex_agent", + "agent-worktrees": "_gitguardex_branch", + "changes": "_gitguardex_openspec", + "plan": "_gitguardex_plan", + "rules": "_gitguardex_spec", + "specs": "_gitguardex_spec" + }, + "fileNames": { + ".openspec.yaml": "_gitguardex_config", + "AGENT.lock": "_gitguardex_agent", + "AGENTS.md": "_gitguardex_agent", + "CLAUDE.md": "_gitguardex_agent", + "config.yaml": "_gitguardex_config", + "context-docs-cue.md": "_gitguardex_spec", + "post-checkout": "_gitguardex_hook", + "pre-commit": "_gitguardex_hook", + "pre-push": "_gitguardex_hook", + "proposal.md": "_gitguardex_openspec", + "spec.md": "_gitguardex_spec", + "tasks.md": "_gitguardex_plan", + "plan.md": "_gitguardex_plan" + }, + "fileExtensions": { + "openspec.yaml": "_gitguardex_config" + } +} diff --git a/vscode/guardex-active-agents/fileicons/icons/agent.svg b/vscode/guardex-active-agents/fileicons/icons/agent.svg new file mode 100644 index 0000000..7a71d75 --- /dev/null +++ b/vscode/guardex-active-agents/fileicons/icons/agent.svg @@ -0,0 +1,4 @@ + + + + diff --git a/vscode/guardex-active-agents/fileicons/icons/branch.svg b/vscode/guardex-active-agents/fileicons/icons/branch.svg new file mode 100644 index 0000000..f55fed0 --- /dev/null +++ b/vscode/guardex-active-agents/fileicons/icons/branch.svg @@ -0,0 +1,4 @@ + + + + diff --git a/vscode/guardex-active-agents/fileicons/icons/config.svg b/vscode/guardex-active-agents/fileicons/icons/config.svg new file mode 100644 index 0000000..d6d45ee --- /dev/null +++ b/vscode/guardex-active-agents/fileicons/icons/config.svg @@ -0,0 +1,4 @@ + + + + diff --git a/vscode/guardex-active-agents/fileicons/icons/hook.svg b/vscode/guardex-active-agents/fileicons/icons/hook.svg new file mode 100644 index 0000000..3478b55 --- /dev/null +++ b/vscode/guardex-active-agents/fileicons/icons/hook.svg @@ -0,0 +1,3 @@ + + + diff --git a/vscode/guardex-active-agents/fileicons/icons/openspec.svg b/vscode/guardex-active-agents/fileicons/icons/openspec.svg new file mode 100644 index 0000000..84314d6 --- /dev/null +++ b/vscode/guardex-active-agents/fileicons/icons/openspec.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/vscode/guardex-active-agents/fileicons/icons/plan.svg b/vscode/guardex-active-agents/fileicons/icons/plan.svg new file mode 100644 index 0000000..c4f65a6 --- /dev/null +++ b/vscode/guardex-active-agents/fileicons/icons/plan.svg @@ -0,0 +1,4 @@ + + + + diff --git a/vscode/guardex-active-agents/fileicons/icons/spec.svg b/vscode/guardex-active-agents/fileicons/icons/spec.svg new file mode 100644 index 0000000..9eb1fa5 --- /dev/null +++ b/vscode/guardex-active-agents/fileicons/icons/spec.svg @@ -0,0 +1,4 @@ + + + + diff --git a/vscode/guardex-active-agents/package.json b/vscode/guardex-active-agents/package.json index 05ce3e8..f9fad4f 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 inside VS Code Source Control.", "publisher": "recodeee", - "version": "0.0.9", + "version": "0.0.10", "license": "MIT", "icon": "icon.png", "engines": { @@ -22,6 +22,13 @@ ], "main": "./extension.js", "contributes": { + "iconThemes": [ + { + "id": "gitguardex-file-icons", + "label": "GitGuardex File Icons", + "path": "./fileicons/gitguardex-fileicons.json" + } + ], "commands": [ { "command": "gitguardex.activeAgents.startAgent", diff --git a/vscode/guardex-active-agents/session-schema.js b/vscode/guardex-active-agents/session-schema.js index d2a71c8..e561987 100644 --- a/vscode/guardex-active-agents/session-schema.js +++ b/vscode/guardex-active-agents/session-schema.js @@ -744,6 +744,8 @@ function buildSessionRecord(input) { taskName: toNonEmptyString(input.taskName, 'task'), latestTaskPreview: '', agentName: toNonEmptyString(input.agentName, 'agent'), + snapshotName: toNonEmptyString(input.snapshotName), + snapshotEmail: toNonEmptyString(input.snapshotEmail || input.email), worktreePath, pid, cliName: toNonEmptyString(input.cliName, 'codex'), @@ -794,6 +796,8 @@ function normalizeSessionRecord(input, options = {}) { taskName: toNonEmptyString(input.taskName, 'task'), latestTaskPreview: '', agentName: toNonEmptyString(input.agentName, 'agent'), + snapshotName: toNonEmptyString(input.snapshotName), + snapshotEmail: toNonEmptyString(input.snapshotEmail || input.email), worktreePath: path.resolve(worktreePath), pid, cliName: toNonEmptyString(input.cliName, 'codex'), @@ -915,7 +919,18 @@ function sortSessionsByTimestamp(sessions) { } function deriveLockTaskAnchor(entries, fallbackTaskName, fallbackTimestamp) { - const sortedEntries = [...entries].sort((left, right) => { + const sortedEntries = sortTelemetryEntriesForAnchor(entries); + + const latestEntry = sortedEntries[0] || null; + return { + taskName: latestEntry?.taskPreview || fallbackTaskName || 'task', + latestTaskPreview: latestEntry?.taskPreview || '', + timestamp: latestEntry?.taskUpdatedAt || fallbackTimestamp || '', + }; +} + +function sortTelemetryEntriesForAnchor(entries) { + return [...entries].sort((left, right) => { const timeDelta = Date.parse(right.taskUpdatedAt || '') - Date.parse(left.taskUpdatedAt || ''); if (timeDelta !== 0) { return timeDelta; @@ -925,12 +940,14 @@ function deriveLockTaskAnchor(entries, fallbackTaskName, fallbackTimestamp) { } return (right.projectPath || '').localeCompare(left.projectPath || ''); }); +} - const latestEntry = sortedEntries[0] || null; +function deriveLockSnapshotIdentity(entries) { + const latestEntry = sortTelemetryEntriesForAnchor(entries) + .find((entry) => entry?.snapshotName || entry?.email) || null; return { - taskName: latestEntry?.taskPreview || fallbackTaskName || 'task', - latestTaskPreview: latestEntry?.taskPreview || '', - timestamp: latestEntry?.taskUpdatedAt || fallbackTimestamp || '', + snapshotName: toNonEmptyString(latestEntry?.snapshotName), + snapshotEmail: toNonEmptyString(latestEntry?.email), }; } @@ -944,6 +961,7 @@ function buildWorktreeLockSession(repoRoot, worktreePath, lockPayload, options = : `agent/telemetry/${path.basename(worktreePath)}`; const label = deriveSessionLabel(effectiveBranch, worktreePath); const taskAnchor = deriveLockTaskAnchor(telemetryEntries, label, telemetryUpdatedAt); + const snapshotIdentity = deriveLockSnapshotIdentity(telemetryEntries); const startedAt = taskAnchor.timestamp || telemetryUpdatedAt || new Date(now).toISOString(); const session = { @@ -953,6 +971,8 @@ function buildWorktreeLockSession(repoRoot, worktreePath, lockPayload, options = taskName: taskAnchor.taskName, latestTaskPreview: taskAnchor.latestTaskPreview, agentName: deriveAgentNameFromBranch(effectiveBranch), + snapshotName: snapshotIdentity.snapshotName, + snapshotEmail: snapshotIdentity.snapshotEmail, worktreePath: path.resolve(worktreePath), pid: null, cliName: 'codex', @@ -995,6 +1015,8 @@ function buildManagedWorktreeSession(repoRoot, worktreePath, options = {}) { taskName: label, latestTaskPreview: '', agentName: deriveAgentNameFromBranch(branch), + snapshotName: '', + snapshotEmail: '', worktreePath: path.resolve(worktreePath), pid: null, cliName: 'gx',