diff --git a/openspec/changes/agent-claude-remove-vscode-active-agents-extension-2026-05-15-14-19/.openspec.yaml b/openspec/changes/agent-claude-remove-vscode-active-agents-extension-2026-05-15-14-19/.openspec.yaml new file mode 100644 index 00000000..9f708669 --- /dev/null +++ b/openspec/changes/agent-claude-remove-vscode-active-agents-extension-2026-05-15-14-19/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-15 diff --git a/openspec/changes/agent-claude-remove-vscode-active-agents-extension-2026-05-15-14-19/proposal.md b/openspec/changes/agent-claude-remove-vscode-active-agents-extension-2026-05-15-14-19/proposal.md new file mode 100644 index 00000000..2a55a88d --- /dev/null +++ b/openspec/changes/agent-claude-remove-vscode-active-agents-extension-2026-05-15-14-19/proposal.md @@ -0,0 +1,22 @@ +## Why + +The bundled VS Code extension `Recodee.gitguardex-active-agents` and its supporting `agent-session-state.js` heartbeat plumbing were never adopted in practice. They added an install prompt to `gx setup`, kept the active-agent session writer running on every codex-agent launch, and shipped ~10k lines of extension source on every npm publish. None of it is required for the core gx guardrail flow. + +## What Changes + +- Remove the `vscode/guardex-active-agents/` extension source and its `templates/vscode/` mirror. +- Remove `scripts/agent-session-state.js` and its template (it only fed the deleted extension). +- Remove `scripts/install-vscode-active-agents-extension.js` and its template. +- Remove `maybePromptInstallVscodeExtension()` from `gx setup` (no install prompt, no `GUARDEX_SKIP_VSCODE_EXT_PROMPT` env). +- Remove `gx internal heartbeat` and `gx internal stop-session` subcommands (only the extension called them). +- Strip the `active_session_state_*` helper functions, heartbeat loop, and exit trap from `templates/scripts/codex-agent.sh`. +- Drop the `vscode/` template-path branch, `sessionState` package asset, and all `agent-session-state.js` entries from `TEMPLATE_FILES`, `PACKAGE_ROOT_SOURCE_OVERRIDES`, `LEGACY_MANAGED_REPO_FILES`, and `MANAGED_GITIGNORE_PATHS` in `src/context.js`. +- Update `test/setup.test.js`, `test/metadata.test.js`, and `test/helpers/install-test-helpers.js` to stop asserting on the removed files. + +## Impact + +- Smaller npm payload; no dead extension code on disk. +- `gx setup` and `gx doctor` no longer touch `code --install-extension`. +- `codex-agent.sh` no longer spawns a background heartbeat process per launch. +- Consumer repos that previously had `scripts/agent-session-state.js` scaffolded as gitignored will see it stop being re-materialized on the next `gx setup` — safe to delete locally. +- The `.vscode/settings.json` IDE settings (separate from the extension) are unchanged. diff --git a/openspec/changes/agent-claude-remove-vscode-active-agents-extension-2026-05-15-14-19/specs/remove-vscode-active-agents-extension/spec.md b/openspec/changes/agent-claude-remove-vscode-active-agents-extension-2026-05-15-14-19/specs/remove-vscode-active-agents-extension/spec.md new file mode 100644 index 00000000..0f2a4f71 --- /dev/null +++ b/openspec/changes/agent-claude-remove-vscode-active-agents-extension-2026-05-15-14-19/specs/remove-vscode-active-agents-extension/spec.md @@ -0,0 +1,10 @@ +## REMOVED Requirements + +### Requirement: Optional VS Code active-agents extension install prompt +`gx setup` MUST NOT prompt to install the `Recodee.gitguardex-active-agents` VS Code extension, MUST NOT honor `GUARDEX_SKIP_VSCODE_EXT_PROMPT`, and MUST NOT shell out to `code --install-extension` as part of setup. The extension source under `vscode/guardex-active-agents/` and its `templates/vscode/` mirror SHALL NOT ship with the package. + +### Requirement: Agent session-state heartbeat helper +The CLI MUST NOT expose `gx internal heartbeat` or `gx internal stop-session` subcommands, MUST NOT register a `sessionState` entry in `PACKAGE_SCRIPT_ASSETS`, and MUST NOT scaffold `scripts/agent-session-state.js` into managed repos. The `templates/scripts/codex-agent.sh` launcher MUST NOT record, refresh, or terminate active-session state and MUST NOT run a background heartbeat loop for the duration of the codex CLI subprocess. + +### Requirement: VS Code extension destination path mapping +`src/context.js#toDestinationPath` MUST NOT accept relative template paths beginning with `vscode/`. Any caller passing such a path is treated as an unsupported template and the function throws. diff --git a/openspec/changes/agent-claude-remove-vscode-active-agents-extension-2026-05-15-14-19/tasks.md b/openspec/changes/agent-claude-remove-vscode-active-agents-extension-2026-05-15-14-19/tasks.md new file mode 100644 index 00000000..ca486256 --- /dev/null +++ b/openspec/changes/agent-claude-remove-vscode-active-agents-extension-2026-05-15-14-19/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. +- 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-claude-remove-vscode-active-agents-extension-2026-05-15-14-19`; branch=`agent//`; scope=`TODO`; action=`continue this sandbox or finish cleanup after a usage-limit/manual takeover`. +- Copy prompt: Continue `agent-claude-remove-vscode-active-agents-extension-2026-05-15-14-19` on branch `agent//`. Work inside the existing sandbox, review `openspec/changes/agent-claude-remove-vscode-active-agents-extension-2026-05-15-14-19/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// --base dev --via-pr --wait-for-merge --cleanup`. + +## 1. Specification + +- [x] 1.1 Finalize proposal scope and acceptance criteria for `agent-claude-remove-vscode-active-agents-extension-2026-05-15-14-19`. +- [x] 1.2 Define normative requirements in `specs/remove-vscode-active-agents-extension/spec.md`. + +## 2. Implementation + +- [x] 2.1 Implement scoped behavior changes. +- [x] 2.2 Add/update focused regression coverage. + +## 3. Verification + +- [x] 3.1 Run targeted project verification commands. +- [x] 3.2 Run `openspec validate agent-claude-remove-vscode-active-agents-extension-2026-05-15-14-19 --type change --strict`. +- [x] 3.3 Run `openspec validate --specs`. + +## 4. Cleanup (mandatory; run before claiming completion) + +- [x] 4.1 Run the cleanup pipeline: `gx branch finish --branch agent// --base dev --via-pr --wait-for-merge --cleanup`. This handles commit -> push -> PR create -> merge wait -> worktree prune in one invocation. +- [x] 4.2 Record the PR URL and final merge state (`MERGED`) in the completion handoff. +- [x] 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/scripts/agent-session-state.js b/scripts/agent-session-state.js deleted file mode 100755 index e6fe2f43..00000000 --- a/scripts/agent-session-state.js +++ /dev/null @@ -1,171 +0,0 @@ -#!/usr/bin/env node - -const fs = require('node:fs'); -const path = require('node:path'); - -function resolveSessionSchemaModule() { - const candidates = [ - path.resolve(__dirname, '..', 'vscode', 'guardex-active-agents', 'session-schema.js'), - path.resolve(__dirname, '..', 'templates', 'vscode', 'guardex-active-agents', 'session-schema.js'), - ]; - - for (const candidate of candidates) { - if (fs.existsSync(candidate)) { - return require(candidate); - } - } - - throw new Error('Could not resolve Guardex active-agent session schema module.'); -} - -const sessionSchema = resolveSessionSchemaModule(); - -function usage() { - return ( - 'Usage:\n' + - ' node scripts/agent-session-state.js start --repo --branch --task --agent --worktree --pid --cli [--task-mode ] [--openspec-tier ] [--routing-reason ] [--state ]\n' + - ' node scripts/agent-session-state.js heartbeat --repo --branch [--state ]\n' + - ' node scripts/agent-session-state.js terminate --repo --branch \n' + - ' node scripts/agent-session-state.js stop --repo --branch \n' - ); -} - -function parseOptions(argv) { - const options = {}; - for (let index = 0; index < argv.length; index += 1) { - const token = argv[index]; - if (!token.startsWith('--')) { - throw new Error(`Unexpected argument: ${token}`); - } - const key = token.slice(2); - const value = argv[index + 1]; - if (!value || value.startsWith('--')) { - throw new Error(`Missing value for --${key}`); - } - options[key] = value; - index += 1; - } - return options; -} - -function requireOption(options, key) { - const value = options[key]; - if (!value) { - throw new Error(`Missing required option --${key}`); - } - return value; -} - -function writeSessionRecord(options) { - const repoRoot = requireOption(options, 'repo'); - const branch = requireOption(options, 'branch'); - const record = sessionSchema.buildSessionRecord({ - repoRoot, - branch, - taskName: requireOption(options, 'task'), - agentName: requireOption(options, 'agent'), - worktreePath: requireOption(options, 'worktree'), - pid: requireOption(options, 'pid'), - cliName: requireOption(options, 'cli'), - taskMode: options['task-mode'], - openspecTier: options['openspec-tier'], - taskRoutingReason: options['routing-reason'], - state: options.state, - }); - - const targetPath = sessionSchema.sessionFilePathForBranch(repoRoot, branch); - fs.mkdirSync(path.dirname(targetPath), { recursive: true }); - fs.writeFileSync(targetPath, `${JSON.stringify(record, null, 2)}\n`, 'utf8'); -} - -function refreshSessionRecord(options) { - const repoRoot = requireOption(options, 'repo'); - const branch = requireOption(options, 'branch'); - const targetPath = sessionSchema.sessionFilePathForBranch(repoRoot, branch); - if (!fs.existsSync(targetPath)) { - return; - } - - const parsed = JSON.parse(fs.readFileSync(targetPath, 'utf8')); - const nextRecord = { - ...parsed, - lastHeartbeatAt: new Date().toISOString(), - }; - if (options.state) { - nextRecord.state = options.state; - } - - fs.writeFileSync(targetPath, `${JSON.stringify(nextRecord, null, 2)}\n`, 'utf8'); -} - -function readSessionRecord(options) { - const repoRoot = requireOption(options, 'repo'); - const branch = requireOption(options, 'branch'); - const targetPath = sessionSchema.sessionFilePathForBranch(repoRoot, branch); - if (!fs.existsSync(targetPath)) { - return null; - } - return JSON.parse(fs.readFileSync(targetPath, 'utf8')); -} - -function terminateSessionProcess(options) { - const record = readSessionRecord(options); - const pid = Number(record?.pid); - if (!Number.isInteger(pid) || pid <= 0) { - throw new Error('No live pid recorded for branch.'); - } - - try { - process.kill(pid, 'SIGTERM'); - } catch (error) { - if (error?.code === 'ESRCH') { - return; - } - throw error; - } -} - -function removeSessionRecord(options) { - const repoRoot = requireOption(options, 'repo'); - const branch = requireOption(options, 'branch'); - const targetPath = sessionSchema.sessionFilePathForBranch(repoRoot, branch); - if (fs.existsSync(targetPath)) { - fs.unlinkSync(targetPath); - } -} - -function main() { - const [command, ...rest] = process.argv.slice(2); - if (!command || ['-h', '--help', 'help'].includes(command)) { - process.stdout.write(usage()); - return; - } - - const options = parseOptions(rest); - if (command === 'start') { - writeSessionRecord(options); - return; - } - if (command === 'heartbeat') { - refreshSessionRecord(options); - return; - } - if (command === 'terminate') { - terminateSessionProcess(options); - return; - } - if (command === 'stop') { - removeSessionRecord(options); - return; - } - - throw new Error(`Unknown subcommand: ${command}`); -} - -try { - main(); -} catch (error) { - process.stderr.write(`[guardex-active-session] ${error.message}\n`); - process.stderr.write(usage()); - process.exitCode = 1; -} diff --git a/scripts/install-vscode-active-agents-extension.js b/scripts/install-vscode-active-agents-extension.js deleted file mode 100755 index 6253cdb6..00000000 --- a/scripts/install-vscode-active-agents-extension.js +++ /dev/null @@ -1,135 +0,0 @@ -#!/usr/bin/env node - -const fs = require('node:fs'); -const os = require('node:os'); -const path = require('node:path'); - -const PATCH_COMPATIBILITY_WINDOW = 20; -const RETIRED_EXTENSION_IDS = [ - 'recodeee.gitguardex-active-agents', -]; - -function parseOptions(argv) { - const options = {}; - for (let index = 0; index < argv.length; index += 1) { - const token = argv[index]; - if (!token.startsWith('--')) { - throw new Error(`Unexpected argument: ${token}`); - } - const key = token.slice(2); - const value = argv[index + 1]; - if (!value || value.startsWith('--')) { - throw new Error(`Missing value for --${key}`); - } - options[key] = value; - index += 1; - } - return options; -} - -function resolveExtensionSource(repoRoot) { - const candidates = [ - path.join(repoRoot, 'vscode', 'guardex-active-agents'), - path.join(repoRoot, 'templates', 'vscode', 'guardex-active-agents'), - ]; - - for (const candidate of candidates) { - if (fs.existsSync(path.join(candidate, 'package.json'))) { - return candidate; - } - } - - throw new Error('Could not find the Guardex VS Code companion sources.'); -} - -function removeIfExists(targetPath) { - if (fs.existsSync(targetPath)) { - fs.rmSync(targetPath, { recursive: true, force: true }); - } -} - -function parseSimpleSemver(version) { - const parts = String(version || '').trim().split('.').map((part) => Number.parseInt(part, 10)); - if (parts.length !== 3 || parts.some((part) => Number.isNaN(part))) { - throw new Error(`Expected simple semver for the Active Agents companion, received "${version}".`); - } - return parts; -} - -function buildInstallTargets(extensionId, version, extensionsDir) { - const [major, minor, patch] = parseSimpleSemver(version); - const firstCompatiblePatch = Math.max(0, patch - PATCH_COMPATIBILITY_WINDOW); - const targets = [path.join(extensionsDir, extensionId)]; - - for (let compatiblePatch = firstCompatiblePatch; compatiblePatch <= patch; compatiblePatch += 1) { - targets.push(path.join(extensionsDir, `${extensionId}-${major}.${minor}.${compatiblePatch}`)); - } - - return targets; -} - -function isRetiredExtensionInstall(entryName, currentExtensionId) { - return RETIRED_EXTENSION_IDS - .filter((extensionId) => extensionId !== currentExtensionId) - .some((extensionId) => entryName === extensionId || entryName.startsWith(`${extensionId}-`)); -} - -function main() { - const repoRoot = path.resolve(__dirname, '..'); - const options = parseOptions(process.argv.slice(2)); - const sourceDir = resolveExtensionSource(repoRoot); - const manifest = JSON.parse(fs.readFileSync(path.join(sourceDir, 'package.json'), 'utf8')); - const extensionId = `${manifest.publisher}.${manifest.name}`; - const extensionsDir = path.resolve( - options['extensions-dir'] || - process.env.GUARDEX_VSCODE_EXTENSIONS_DIR || - process.env.VSCODE_EXTENSIONS_DIR || - path.join(os.homedir(), '.vscode', 'extensions'), - ); - - fs.mkdirSync(extensionsDir, { recursive: true }); - const targetDirs = buildInstallTargets(extensionId, manifest.version, extensionsDir); - const canonicalTargetDir = targetDirs[0]; - const keepDirNames = new Set(targetDirs.map((targetDir) => path.basename(targetDir))); - let retiredInstallCount = 0; - - for (const entry of fs.readdirSync(extensionsDir, { withFileTypes: true })) { - if (!entry.isDirectory()) { - continue; - } - if (isRetiredExtensionInstall(entry.name, extensionId)) { - removeIfExists(path.join(extensionsDir, entry.name)); - retiredInstallCount += 1; - continue; - } - if (keepDirNames.has(entry.name)) { - continue; - } - if (entry.name === extensionId || entry.name.startsWith(`${extensionId}-`)) { - removeIfExists(path.join(extensionsDir, entry.name)); - } - } - - for (const targetDir of targetDirs) { - removeIfExists(targetDir); - fs.cpSync(sourceDir, targetDir, { - recursive: true, - force: true, - preserveTimestamps: true, - }); - } - - process.stdout.write( - `[guardex-active-agents] Installed ${extensionId}@${manifest.version} to ${canonicalTargetDir}\n` + - `[guardex-active-agents] Refreshed ${targetDirs.length - 1} recent patch compatibility path(s) for already-open windows.\n` + - `[guardex-active-agents] Removed ${retiredInstallCount} retired extension install path(s).\n` + - '[guardex-active-agents] Reload each already-open VS Code window to activate the newest Source Control companion.\n', - ); -} - -try { - main(); -} catch (error) { - process.stderr.write(`[guardex-active-agents] ${error.message}\n`); - process.exitCode = 1; -} diff --git a/src/cli/main.js b/src/cli/main.js index 67f48b00..de1990fa 100755 --- a/src/cli/main.js +++ b/src/cli/main.js @@ -1268,84 +1268,6 @@ function promptYesNoStrict(question) { } } -const VSCODE_EXTENSION_ID = 'Recodee.gitguardex-active-agents'; -const VSCODE_EXTENSION_DISPLAY_NAME = 'GitGuardex Active Agents'; - -function maybePromptInstallVscodeExtension(options) { - if (options.dryRun) { - console.log( - `[${TOOL_NAME}] (dry-run) Would offer to install VS Code extension '${VSCODE_EXTENSION_ID}'.`, - ); - return; - } - - if (envFlagIsTruthy(process.env.GUARDEX_SKIP_VSCODE_EXT_PROMPT)) { - return; - } - - const codeProbe = cp.spawnSync('code', ['--version'], { stdio: 'ignore' }); - if (codeProbe.error || codeProbe.status !== 0) { - return; - } - - const listProbe = cp.spawnSync('code', ['--list-extensions'], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'], - }); - if (!listProbe.error && listProbe.status === 0) { - const alreadyInstalled = String(listProbe.stdout || '') - .split('\n') - .some((line) => line.trim().toLowerCase() === VSCODE_EXTENSION_ID.toLowerCase()); - if (alreadyInstalled) { - console.log( - `[${TOOL_NAME}] ✅ VS Code extension '${VSCODE_EXTENSION_ID}' already installed.`, - ); - return; - } - } - - let approved; - if (options.yesGlobalInstall) { - approved = true; - } else if (options.noGlobalInstall) { - approved = false; - } else if (!isInteractiveTerminal()) { - console.log( - `[${TOOL_NAME}] Optional VS Code extension '${VSCODE_EXTENSION_ID}' ` + - `(${VSCODE_EXTENSION_DISPLAY_NAME}) not installed. ` + - `Install later: code --install-extension ${VSCODE_EXTENSION_ID}`, - ); - return; - } else { - approved = promptYesNoStrict( - `Install VS Code extension '${VSCODE_EXTENSION_ID}' (${VSCODE_EXTENSION_DISPLAY_NAME}) now?`, - ); - } - - if (!approved) { - console.log( - `[${TOOL_NAME}] ⚠️ VS Code extension skipped. ` + - `Set GUARDEX_SKIP_VSCODE_EXT_PROMPT=1 to silence or run 'code --install-extension ${VSCODE_EXTENSION_ID}' later.`, - ); - return; - } - - const install = cp.spawnSync('code', ['--install-extension', VSCODE_EXTENSION_ID], { - stdio: 'inherit', - }); - if (install.error || install.status !== 0) { - console.log( - `[${TOOL_NAME}] ⚠️ VS Code extension install failed. ` + - `Retry manually: code --install-extension ${VSCODE_EXTENSION_ID}`, - ); - return; - } - console.log( - `[${TOOL_NAME}] ✅ VS Code extension '${VSCODE_EXTENSION_ID}' installed. ` + - `Reload the VS Code window to activate it.`, - ); -} - function resolveGlobalInstallApproval(options) { if (options.yesGlobalInstall && options.noGlobalInstall) { throw new Error('Cannot use both --yes-global-install and --no-global-install'); @@ -3046,8 +2968,6 @@ function setup(rawArgs) { } } - maybePromptInstallVscodeExtension(options); - printRequiredSystemToolStatus(); const topRepoRoot = resolveRepoRoot(options.target); diff --git a/src/context.js b/src/context.js index 93d137f0..ed5b7a93 100644 --- a/src/context.js +++ b/src/context.js @@ -138,9 +138,6 @@ function toDestinationPath(relativeTemplatePath) { if (relativeTemplatePath.startsWith('github/')) { return `.${relativeTemplatePath}`; } - if (relativeTemplatePath.startsWith('vscode/')) { - return relativeTemplatePath; - } throw new Error(`Unsupported template path: ${relativeTemplatePath}`); } @@ -153,7 +150,7 @@ function toDestinationPath(relativeTemplatePath) { // replaced with a regular file. Edit only the templates/scripts/ copy; // the symlink propagates. // -// 2. SCAFFOLD-ONLY files (the 4 below + workflows + vscode extension): +// 2. SCAFFOLD-ONLY files (the 3 below + workflows): // tracked only under templates/; scaffolded into gitignored // scripts/ (or .githooks/, etc.) by `gx setup`. Consumer // repos receive a regular file copy at the destination; gitguardex @@ -165,48 +162,17 @@ function toDestinationPath(relativeTemplatePath) { // pattern (2), append the destination path to .gitignore's multiagent- // safety block (auto-managed by syncManagedGitignoreLines below). const TEMPLATE_FILES = [ - 'scripts/agent-session-state.js', 'scripts/agent-preflight.sh', 'scripts/guardex-docker-loader.sh', 'scripts/guardex-env.sh', - 'scripts/install-vscode-active-agents-extension.js', 'github/pull.yml.example', 'github/workflows/ci.yml', 'github/workflows/ci-full.yml', 'github/workflows/cr.yml', 'github/workflows/README.md', - 'vscode/guardex-active-agents/package.json', - 'vscode/guardex-active-agents/extension.js', - '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([ - 'scripts/agent-session-state.js', - 'scripts/install-vscode-active-agents-extension.js', - 'vscode/guardex-active-agents/package.json', - 'vscode/guardex-active-agents/extension.js', - '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(); const LEGACY_WORKFLOW_SHIM_SPECS = [ { relativePath: 'scripts/agent-branch-start.sh', kind: 'shell', command: ['branch', 'start'] }, @@ -229,9 +195,7 @@ const MANAGED_TEMPLATE_SCRIPT_FILES = MANAGED_TEMPLATE_DESTINATIONS.filter((entr const LEGACY_MANAGED_REPO_FILES = [ ...LEGACY_WORKFLOW_SHIMS, - 'scripts/agent-session-state.js', 'scripts/guardex-docker-loader.sh', - 'scripts/install-vscode-active-agents-extension.js', 'scripts/guardex-env.sh', 'scripts/install-agent-git-hooks.sh', '.githooks/pre-commit', @@ -280,7 +244,6 @@ const PACKAGE_SCRIPT_ASSETS = { branchMerge: path.join(TEMPLATE_ROOT, 'scripts', 'agent-branch-merge.sh'), codexAgent: path.join(TEMPLATE_ROOT, 'scripts', 'codex-agent.sh'), reviewBot: path.join(TEMPLATE_ROOT, 'scripts', 'review-bot-watch.sh'), - sessionState: path.join(TEMPLATE_ROOT, 'scripts', 'agent-session-state.js'), worktreePrune: path.join(TEMPLATE_ROOT, 'scripts', 'agent-worktree-prune.sh'), lockTool: path.join(TEMPLATE_ROOT, 'scripts', 'agent-file-locks.py'), planInit: path.join(TEMPLATE_ROOT, 'scripts', 'openspec', 'init-plan-workspace.sh'), @@ -344,10 +307,8 @@ const MANAGED_GITIGNORE_PATHS = [ '!.vscode/', '.vscode/*', '!.vscode/settings.json', - 'scripts/agent-session-state.js', 'scripts/guardex-docker-loader.sh', 'scripts/guardex-env.sh', - 'scripts/install-vscode-active-agents-extension.js', '.githooks', 'oh-my-codex/', LOCK_FILE_RELATIVE, diff --git a/src/hooks/index.js b/src/hooks/index.js index 691b469c..52ad3a9c 100644 --- a/src/hooks/index.js +++ b/src/hooks/index.js @@ -1,39 +1,5 @@ const path = require('node:path'); -function requireFlagValue(rawArgs, index, flagName) { - const value = rawArgs[index + 1]; - if (!value || value.startsWith('--')) { - throw new Error(`${flagName} requires a value`); - } - return value; -} - -function parseHeartbeatArgs(rawArgs) { - let branch = ''; - let state = ''; - - for (let index = 0; index < rawArgs.length; index += 1) { - const arg = rawArgs[index]; - if (arg === '--branch') { - branch = requireFlagValue(rawArgs, index, '--branch'); - index += 1; - continue; - } - if (arg === '--state') { - state = requireFlagValue(rawArgs, index, '--state'); - index += 1; - continue; - } - throw new Error(`Unknown heartbeat option: ${arg}`); - } - - if (!branch) { - throw new Error('heartbeat requires --branch '); - } - - return { branch, state }; -} - function hook(rawArgs, deps) { const { extractTargetedArgs, @@ -89,36 +55,6 @@ function internal(rawArgs, deps) { } = deps; const [subcommand, assetKey, ...rest] = rawArgs; - if (subcommand === 'heartbeat') { - const { target, passthrough } = extractTargetedArgs([assetKey, ...rest].filter(Boolean)); - const repoRoot = resolveRepoRoot(target); - const options = parseHeartbeatArgs(passthrough); - const heartbeatArgs = ['heartbeat', '--repo', repoRoot, '--branch', options.branch]; - if (options.state) { - heartbeatArgs.push('--state', options.state); - } - const result = runPackageAsset('sessionState', heartbeatArgs, { cwd: repoRoot }); - if (result.stdout) process.stdout.write(result.stdout); - if (result.stderr) process.stderr.write(result.stderr); - process.exitCode = result.status; - return; - } - if (subcommand === 'stop-session') { - const { target, passthrough } = extractTargetedArgs([assetKey, ...rest].filter(Boolean)); - const repoRoot = resolveRepoRoot(target); - const options = parseHeartbeatArgs(passthrough); - const result = runPackageAsset('sessionState', [ - 'terminate', - '--repo', - repoRoot, - '--branch', - options.branch, - ], { cwd: repoRoot }); - if (result.stdout) process.stdout.write(result.stdout); - if (result.stderr) process.stderr.write(result.stderr); - process.exitCode = result.status; - return; - } if (subcommand !== 'run-shell') { throw new Error(`Unknown internal command: ${subcommand || '(missing)'}`); } diff --git a/templates/scripts/agent-session-state.js b/templates/scripts/agent-session-state.js deleted file mode 100755 index e6fe2f43..00000000 --- a/templates/scripts/agent-session-state.js +++ /dev/null @@ -1,171 +0,0 @@ -#!/usr/bin/env node - -const fs = require('node:fs'); -const path = require('node:path'); - -function resolveSessionSchemaModule() { - const candidates = [ - path.resolve(__dirname, '..', 'vscode', 'guardex-active-agents', 'session-schema.js'), - path.resolve(__dirname, '..', 'templates', 'vscode', 'guardex-active-agents', 'session-schema.js'), - ]; - - for (const candidate of candidates) { - if (fs.existsSync(candidate)) { - return require(candidate); - } - } - - throw new Error('Could not resolve Guardex active-agent session schema module.'); -} - -const sessionSchema = resolveSessionSchemaModule(); - -function usage() { - return ( - 'Usage:\n' + - ' node scripts/agent-session-state.js start --repo --branch --task --agent --worktree --pid --cli [--task-mode ] [--openspec-tier ] [--routing-reason ] [--state ]\n' + - ' node scripts/agent-session-state.js heartbeat --repo --branch [--state ]\n' + - ' node scripts/agent-session-state.js terminate --repo --branch \n' + - ' node scripts/agent-session-state.js stop --repo --branch \n' - ); -} - -function parseOptions(argv) { - const options = {}; - for (let index = 0; index < argv.length; index += 1) { - const token = argv[index]; - if (!token.startsWith('--')) { - throw new Error(`Unexpected argument: ${token}`); - } - const key = token.slice(2); - const value = argv[index + 1]; - if (!value || value.startsWith('--')) { - throw new Error(`Missing value for --${key}`); - } - options[key] = value; - index += 1; - } - return options; -} - -function requireOption(options, key) { - const value = options[key]; - if (!value) { - throw new Error(`Missing required option --${key}`); - } - return value; -} - -function writeSessionRecord(options) { - const repoRoot = requireOption(options, 'repo'); - const branch = requireOption(options, 'branch'); - const record = sessionSchema.buildSessionRecord({ - repoRoot, - branch, - taskName: requireOption(options, 'task'), - agentName: requireOption(options, 'agent'), - worktreePath: requireOption(options, 'worktree'), - pid: requireOption(options, 'pid'), - cliName: requireOption(options, 'cli'), - taskMode: options['task-mode'], - openspecTier: options['openspec-tier'], - taskRoutingReason: options['routing-reason'], - state: options.state, - }); - - const targetPath = sessionSchema.sessionFilePathForBranch(repoRoot, branch); - fs.mkdirSync(path.dirname(targetPath), { recursive: true }); - fs.writeFileSync(targetPath, `${JSON.stringify(record, null, 2)}\n`, 'utf8'); -} - -function refreshSessionRecord(options) { - const repoRoot = requireOption(options, 'repo'); - const branch = requireOption(options, 'branch'); - const targetPath = sessionSchema.sessionFilePathForBranch(repoRoot, branch); - if (!fs.existsSync(targetPath)) { - return; - } - - const parsed = JSON.parse(fs.readFileSync(targetPath, 'utf8')); - const nextRecord = { - ...parsed, - lastHeartbeatAt: new Date().toISOString(), - }; - if (options.state) { - nextRecord.state = options.state; - } - - fs.writeFileSync(targetPath, `${JSON.stringify(nextRecord, null, 2)}\n`, 'utf8'); -} - -function readSessionRecord(options) { - const repoRoot = requireOption(options, 'repo'); - const branch = requireOption(options, 'branch'); - const targetPath = sessionSchema.sessionFilePathForBranch(repoRoot, branch); - if (!fs.existsSync(targetPath)) { - return null; - } - return JSON.parse(fs.readFileSync(targetPath, 'utf8')); -} - -function terminateSessionProcess(options) { - const record = readSessionRecord(options); - const pid = Number(record?.pid); - if (!Number.isInteger(pid) || pid <= 0) { - throw new Error('No live pid recorded for branch.'); - } - - try { - process.kill(pid, 'SIGTERM'); - } catch (error) { - if (error?.code === 'ESRCH') { - return; - } - throw error; - } -} - -function removeSessionRecord(options) { - const repoRoot = requireOption(options, 'repo'); - const branch = requireOption(options, 'branch'); - const targetPath = sessionSchema.sessionFilePathForBranch(repoRoot, branch); - if (fs.existsSync(targetPath)) { - fs.unlinkSync(targetPath); - } -} - -function main() { - const [command, ...rest] = process.argv.slice(2); - if (!command || ['-h', '--help', 'help'].includes(command)) { - process.stdout.write(usage()); - return; - } - - const options = parseOptions(rest); - if (command === 'start') { - writeSessionRecord(options); - return; - } - if (command === 'heartbeat') { - refreshSessionRecord(options); - return; - } - if (command === 'terminate') { - terminateSessionProcess(options); - return; - } - if (command === 'stop') { - removeSessionRecord(options); - return; - } - - throw new Error(`Unknown subcommand: ${command}`); -} - -try { - main(); -} catch (error) { - process.stderr.write(`[guardex-active-session] ${error.message}\n`); - process.stderr.write(usage()); - process.exitCode = 1; -} diff --git a/templates/scripts/codex-agent.sh b/templates/scripts/codex-agent.sh index 89687707..66f2805f 100755 --- a/templates/scripts/codex-agent.sh +++ b/templates/scripts/codex-agent.sh @@ -324,7 +324,6 @@ if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then exit 1 fi repo_root="$(git rev-parse --show-toplevel)" -active_session_state_script="${repo_root}/scripts/agent-session-state.js" guardex_env_helper="${repo_root}/scripts/guardex-env.sh" if [[ -f "$guardex_env_helper" ]]; then @@ -638,78 +637,6 @@ has_origin_remote() { git -C "$repo_root" remote get-url origin >/dev/null 2>&1 } -run_active_session_state() { - local action="$1" - shift - - if [[ ! -f "$active_session_state_script" ]]; then - return 0 - fi - if ! command -v "$NODE_BIN" >/dev/null 2>&1; then - return 0 - fi - - "$NODE_BIN" "$active_session_state_script" "$action" "$@" >/dev/null 2>&1 || true -} - -record_active_session_state() { - local wt="$1" - local branch="$2" - - run_active_session_state \ - start \ - --repo "$repo_root" \ - --branch "$branch" \ - --task "$TASK_NAME" \ - --agent "$AGENT_NAME" \ - --worktree "$wt" \ - --pid "$$" \ - --cli "$CODEX_BIN" \ - --task-mode "$TASK_MODE" \ - --openspec-tier "$OPENSPEC_TIER" \ - --routing-reason "$TASK_ROUTING_REASON" -} - -clear_active_session_state() { - local branch="$1" - run_active_session_state stop --repo "$repo_root" --branch "$branch" -} - -heartbeat_active_session_state() { - local branch="$1" - run_active_session_state heartbeat --repo "$repo_root" --branch "$branch" --state working -} - -normalize_heartbeat_interval_seconds() { - local raw="${GUARDEX_ACTIVE_SESSION_HEARTBEAT_INTERVAL_SECONDS:-15}" - if [[ "$raw" =~ ^[0-9]+$ ]] && [[ "$raw" -ge 1 ]]; then - printf '%s' "$raw" - return 0 - fi - printf '15' -} - -start_active_session_heartbeat() { - local branch="$1" - local interval - interval="$(normalize_heartbeat_interval_seconds)" - ( - while true; do - sleep "$interval" || break - heartbeat_active_session_state "$branch" - done - ) & - active_session_heartbeat_pid="$!" -} - -stop_active_session_heartbeat() { - if [[ -n "${active_session_heartbeat_pid:-}" ]]; then - kill "$active_session_heartbeat_pid" >/dev/null 2>&1 || true - wait "$active_session_heartbeat_pid" >/dev/null 2>&1 || true - active_session_heartbeat_pid="" - fi -} - origin_remote_supports_pr_finish() { local origin_url origin_url="$(git -C "$repo_root" remote get-url origin 2>/dev/null || true)" @@ -1103,22 +1030,6 @@ fi echo "[codex-agent] Task routing: $(describe_task_routing) (${TASK_ROUTING_REASON})" -active_session_recorded=0 -active_session_heartbeat_pid="" -cleanup_active_session_state_on_exit() { - set +e - if [[ "${active_session_recorded:-0}" -eq 1 && -n "${worktree_branch:-}" && "${worktree_branch:-}" != "HEAD" ]]; then - stop_active_session_heartbeat - clear_active_session_state "$worktree_branch" - active_session_recorded=0 - fi -} - -record_active_session_state "$worktree_path" "$worktree_branch" -active_session_recorded=1 -start_active_session_heartbeat "$worktree_branch" -trap cleanup_active_session_state_on_exit EXIT INT TERM - echo "[codex-agent] Launching ${CODEX_BIN} in sandbox: $worktree_path" cd "$worktree_path" set +e @@ -1131,8 +1042,6 @@ codex_exit="$?" set -e cd "$repo_root" -cleanup_active_session_state_on_exit -trap - EXIT INT TERM final_exit="$codex_exit" auto_finish_completed=0 diff --git a/templates/scripts/install-vscode-active-agents-extension.js b/templates/scripts/install-vscode-active-agents-extension.js deleted file mode 100755 index 6253cdb6..00000000 --- a/templates/scripts/install-vscode-active-agents-extension.js +++ /dev/null @@ -1,135 +0,0 @@ -#!/usr/bin/env node - -const fs = require('node:fs'); -const os = require('node:os'); -const path = require('node:path'); - -const PATCH_COMPATIBILITY_WINDOW = 20; -const RETIRED_EXTENSION_IDS = [ - 'recodeee.gitguardex-active-agents', -]; - -function parseOptions(argv) { - const options = {}; - for (let index = 0; index < argv.length; index += 1) { - const token = argv[index]; - if (!token.startsWith('--')) { - throw new Error(`Unexpected argument: ${token}`); - } - const key = token.slice(2); - const value = argv[index + 1]; - if (!value || value.startsWith('--')) { - throw new Error(`Missing value for --${key}`); - } - options[key] = value; - index += 1; - } - return options; -} - -function resolveExtensionSource(repoRoot) { - const candidates = [ - path.join(repoRoot, 'vscode', 'guardex-active-agents'), - path.join(repoRoot, 'templates', 'vscode', 'guardex-active-agents'), - ]; - - for (const candidate of candidates) { - if (fs.existsSync(path.join(candidate, 'package.json'))) { - return candidate; - } - } - - throw new Error('Could not find the Guardex VS Code companion sources.'); -} - -function removeIfExists(targetPath) { - if (fs.existsSync(targetPath)) { - fs.rmSync(targetPath, { recursive: true, force: true }); - } -} - -function parseSimpleSemver(version) { - const parts = String(version || '').trim().split('.').map((part) => Number.parseInt(part, 10)); - if (parts.length !== 3 || parts.some((part) => Number.isNaN(part))) { - throw new Error(`Expected simple semver for the Active Agents companion, received "${version}".`); - } - return parts; -} - -function buildInstallTargets(extensionId, version, extensionsDir) { - const [major, minor, patch] = parseSimpleSemver(version); - const firstCompatiblePatch = Math.max(0, patch - PATCH_COMPATIBILITY_WINDOW); - const targets = [path.join(extensionsDir, extensionId)]; - - for (let compatiblePatch = firstCompatiblePatch; compatiblePatch <= patch; compatiblePatch += 1) { - targets.push(path.join(extensionsDir, `${extensionId}-${major}.${minor}.${compatiblePatch}`)); - } - - return targets; -} - -function isRetiredExtensionInstall(entryName, currentExtensionId) { - return RETIRED_EXTENSION_IDS - .filter((extensionId) => extensionId !== currentExtensionId) - .some((extensionId) => entryName === extensionId || entryName.startsWith(`${extensionId}-`)); -} - -function main() { - const repoRoot = path.resolve(__dirname, '..'); - const options = parseOptions(process.argv.slice(2)); - const sourceDir = resolveExtensionSource(repoRoot); - const manifest = JSON.parse(fs.readFileSync(path.join(sourceDir, 'package.json'), 'utf8')); - const extensionId = `${manifest.publisher}.${manifest.name}`; - const extensionsDir = path.resolve( - options['extensions-dir'] || - process.env.GUARDEX_VSCODE_EXTENSIONS_DIR || - process.env.VSCODE_EXTENSIONS_DIR || - path.join(os.homedir(), '.vscode', 'extensions'), - ); - - fs.mkdirSync(extensionsDir, { recursive: true }); - const targetDirs = buildInstallTargets(extensionId, manifest.version, extensionsDir); - const canonicalTargetDir = targetDirs[0]; - const keepDirNames = new Set(targetDirs.map((targetDir) => path.basename(targetDir))); - let retiredInstallCount = 0; - - for (const entry of fs.readdirSync(extensionsDir, { withFileTypes: true })) { - if (!entry.isDirectory()) { - continue; - } - if (isRetiredExtensionInstall(entry.name, extensionId)) { - removeIfExists(path.join(extensionsDir, entry.name)); - retiredInstallCount += 1; - continue; - } - if (keepDirNames.has(entry.name)) { - continue; - } - if (entry.name === extensionId || entry.name.startsWith(`${extensionId}-`)) { - removeIfExists(path.join(extensionsDir, entry.name)); - } - } - - for (const targetDir of targetDirs) { - removeIfExists(targetDir); - fs.cpSync(sourceDir, targetDir, { - recursive: true, - force: true, - preserveTimestamps: true, - }); - } - - process.stdout.write( - `[guardex-active-agents] Installed ${extensionId}@${manifest.version} to ${canonicalTargetDir}\n` + - `[guardex-active-agents] Refreshed ${targetDirs.length - 1} recent patch compatibility path(s) for already-open windows.\n` + - `[guardex-active-agents] Removed ${retiredInstallCount} retired extension install path(s).\n` + - '[guardex-active-agents] Reload each already-open VS Code window to activate the newest Source Control companion.\n', - ); -} - -try { - main(); -} catch (error) { - process.stderr.write(`[guardex-active-agents] ${error.message}\n`); - process.exitCode = 1; -} diff --git a/templates/vscode/guardex-active-agents/README.md b/templates/vscode/guardex-active-agents/README.md deleted file mode 100644 index ea5ff11a..00000000 --- a/templates/vscode/guardex-active-agents/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# GitGuardex Active Agents - -Local VS Code companion for Guardex-managed repos. - -## Quick Start - -Use the dedicated Active Agents sidebar icon to create or inspect Guardex sandboxes quickly. - -1. Install from a Guardex-wired repo: - -```sh -node scripts/install-vscode-active-agents-extension.js -``` - -2. Reload the VS Code window. -3. In the Activity Bar, open the dedicated `Active Agents` hive icon. Use `Start agent` to enter a task + agent name and launch the repo Guardex agent runner. The companion prefers `bash scripts/codex-agent.sh` when present, falls back to `npm run agent:codex --`, and only uses `gx branch start` as a last resort. - -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 a dedicated `Active Agents` Activity Bar container with a hive icon and live badge count for active sessions. -- 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. -- Keeps the built-in Source Control view focused on real Git repositories; the Active Agents commit command prompts for a message from its own toolbar action. -- 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`. -- Reads repo-local presence files from `.omx/state/active-sessions/`, expects `lastHeartbeatAt` freshness, and falls back to managed worktree-root `AGENT.lock` telemetry when the launcher session file is absent. -- Publishes `guardex.hasAgents` and `guardex.hasConflicts` context keys for other VS Code contributions. diff --git a/templates/vscode/guardex-active-agents/extension.js b/templates/vscode/guardex-active-agents/extension.js deleted file mode 100644 index 6154f838..00000000 --- a/templates/vscode/guardex-active-agents/extension.js +++ /dev/null @@ -1,3892 +0,0 @@ -const fs = require('node:fs'); -const path = require('node:path'); -const cp = require('node:child_process'); -const http = require('node:http'); -const os = require('node:os'); -const vscode = require('vscode'); -const { - clearWorktreeActivityCache, - formatElapsedFrom, - readActiveSessions, - readRepoChanges, - readSessionInspectData, - sanitizeBranchForFile, - sessionFilePathForBranch, -} = require('./session-schema.js'); - -const SESSION_DECORATION_SCHEME = 'gitguardex-agent'; -const IDLE_WARNING_MS = 10 * 60 * 1000; -const IDLE_ERROR_MS = 30 * 60 * 1000; -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_GIT_FILES_GLOB = '**/{.omx,.omc}/agent-worktrees/*/.git'; -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}/**'; -const MANAGED_WORKTREE_GIT_SCAN_EXCLUDE_GLOB = '**/node_modules/**'; -const SESSION_SCAN_LIMIT = 200; -const REFRESH_DEBOUNCE_MS = 250; -const RECENTLY_ACTIVE_WINDOW_MS = 10 * 60 * 1000; -const SESSION_TOP_FILE_COUNT = 3; -const ACTIVE_AGENTS_MANIFEST_RELATIVE = path.join('vscode', 'guardex-active-agents', 'package.json'); -const ACTIVE_AGENTS_INSTALL_SCRIPT_RELATIVE = path.join('scripts', 'install-vscode-active-agents-extension.js'); -const RELOAD_WINDOW_ACTION = 'Reload Window'; -const UPDATE_LATER_ACTION = 'Later'; -const ACTIVE_AGENTS_EXTENSION_ID = 'Recodee.gitguardex-active-agents'; -const RESTART_EXTENSION_HOST_COMMAND = 'workbench.action.restartExtensionHost'; -const REFRESH_POLL_INTERVAL_MS = 30_000; -const INSPECT_PANEL_VIEW_TYPE = 'gitguardex.activeAgents.inspect'; -const COLONY_DEFAULT_PORT = 37777; -const COLONY_SNAPSHOT_TTL_MS = 5_000; -const COLONY_FETCH_TIMEOUT_MS = 800; - -function colonyDataDir() { - return process.env.COLONY_HOME - || process.env.CAVEMEM_HOME - || path.join(os.homedir(), '.colony'); -} - -function readColonyPort() { - try { - const raw = fs.readFileSync(path.join(colonyDataDir(), 'settings.json'), 'utf8'); - const parsed = JSON.parse(raw); - const port = Number(parsed?.workerPort); - return Number.isFinite(port) && port > 0 ? port : COLONY_DEFAULT_PORT; - } catch (_error) { - return COLONY_DEFAULT_PORT; - } -} - -function fetchColonyJson(urlPath) { - return new Promise((resolve) => { - const req = http.get( - { - hostname: '127.0.0.1', - port: readColonyPort(), - path: urlPath, - timeout: COLONY_FETCH_TIMEOUT_MS, - }, - (res) => { - if (res.statusCode !== 200) { - res.resume(); - resolve(null); - return; - } - let body = ''; - res.setEncoding('utf8'); - res.on('data', (chunk) => { - body += chunk; - }); - res.on('end', () => { - try { - resolve(JSON.parse(body)); - } catch (_error) { - resolve(null); - } - }); - }, - ); - req.on('error', () => resolve(null)); - req.on('timeout', () => { - req.destroy(); - resolve(null); - }); - }); -} - -const colonyTasksCache = new Map(); - -async function readColonyTasksForRepo(repoRoot) { - const cached = colonyTasksCache.get(repoRoot); - if (cached && Date.now() - cached.at < COLONY_SNAPSHOT_TTL_MS) { - return cached.tasks; - } - const tasks = await fetchColonyJson( - `/api/colony/tasks?repo_root=${encodeURIComponent(repoRoot)}`, - ); - const resolved = Array.isArray(tasks) ? tasks : []; - colonyTasksCache.set(repoRoot, { at: Date.now(), tasks: resolved }); - return resolved; -} - -function compactColonyBranchLabel(branch) { - if (typeof branch !== 'string' || !branch) return 'unknown'; - const parts = branch.split('/').filter(Boolean); - return parts.length > 2 ? parts.slice(-2).join('/') : branch; -} -const GIT_CONFIGURATION_SECTION = 'git'; -const REPO_SCAN_IGNORED_FOLDERS_SETTING = 'repositoryScanIgnoredFolders'; -const BUNDLED_FILE_ICONS_MANIFEST_RELATIVE = path.join('fileicons', 'gitguardex-fileicons.json'); -const MANAGED_REPO_SCAN_IGNORED_FOLDERS = [ - '.omx/agent-worktrees', - '**/.omx/agent-worktrees', - '.omx/.tmp-worktrees', - '**/.omx/.tmp-worktrees', - '.omc/agent-worktrees', - '**/.omc/agent-worktrees', - '.omc/.tmp-worktrees', - '**/.omc/.tmp-worktrees', -]; -const SESSION_ACTIVITY_GROUPS = [ - { kind: 'blocked', label: 'BLOCKED' }, - { kind: 'working', label: 'WORKING NOW' }, - { kind: 'finished', label: 'NEEDS CLEANUP' }, - { kind: 'idle', label: 'THINKING' }, - { kind: 'stalled', label: 'STALLED' }, - { kind: 'dead', label: 'DEAD' }, -]; -const SESSION_ACTIVITY_ICON_IDS = { - blocked: 'warning', - working: 'loading~spin', - finished: 'pass-filled', - idle: 'comment-discussion', - stalled: 'clock', - dead: 'error', -}; -const DISMISSABLE_SESSION_ACTIVITY_KINDS = new Set(['stalled', 'dead']); -const SESSION_PROVIDER_BRANDS = { - openai: { - id: 'openai', - label: 'OpenAI', - badge: 'AI', - }, - claude: { - id: 'claude', - label: 'Claude', - badge: 'CL', - }, -}; -let bundledTreeIconThemeCache = null; - -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': - case 'dashboard': - case 'inbox': - case 'file-directory': - case 'settings-gear': - case 'folder-library': - return 'textLink.foreground'; - case 'git-branch': - return 'gitDecoration.modifiedResourceForeground'; - case 'account': - return 'terminal.ansiYellow'; - case 'debug-pause': - return 'terminal.ansiYellow'; - case 'sparkle': - case 'rocket': - return 'terminal.ansiMagenta'; - case 'list-flat': - case 'device-camera': - return 'terminal.ansiCyan'; - case 'list-tree': - case 'telescope': - return 'terminal.ansiBlue'; - case 'organization': - return 'terminal.ansiGreen'; - case 'pass-filled': - case 'pass': - case 'check': - return 'testing.iconPassed'; - 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)}`); -} - -function emptyBundledTreeIconTheme() { - return { - iconPathById: new Map(), - fileNames: {}, - folderNames: {}, - fileExtensions: {}, - }; -} - -function loadBundledTreeIconTheme() { - if (bundledTreeIconThemeCache) { - return bundledTreeIconThemeCache; - } - - const manifestPath = path.join(__dirname, BUNDLED_FILE_ICONS_MANIFEST_RELATIVE); - try { - const parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); - const manifestDir = path.dirname(manifestPath); - const iconPathById = new Map(); - for (const [iconId, definition] of Object.entries(parsed?.iconDefinitions || {})) { - if (typeof definition?.iconPath !== 'string' || !definition.iconPath.trim()) { - continue; - } - const iconUri = vscode.Uri.file(path.resolve(manifestDir, definition.iconPath)); - iconPathById.set(iconId, { - light: iconUri, - dark: iconUri, - }); - } - bundledTreeIconThemeCache = { - iconPathById, - fileNames: parsed?.fileNames || {}, - folderNames: parsed?.folderNames || {}, - fileExtensions: parsed?.fileExtensions || {}, - }; - } catch (_error) { - bundledTreeIconThemeCache = emptyBundledTreeIconTheme(); - } - - return bundledTreeIconThemeCache; -} - -function resolveBundledTreeItemIconId(relativePath, kind = 'file') { - const normalizedRelativePath = normalizeRelativePath(relativePath); - const entryName = path.posix.basename(normalizedRelativePath || ''); - if (!entryName) { - return ''; - } - - const bundledTheme = loadBundledTreeIconTheme(); - if (kind === 'folder') { - return bundledTheme.folderNames[entryName] || ''; - } - - if (bundledTheme.fileNames[entryName]) { - return bundledTheme.fileNames[entryName]; - } - - const matchingExtension = Object.keys(bundledTheme.fileExtensions) - .sort((left, right) => right.length - left.length) - .find((extension) => entryName === extension || entryName.endsWith(`.${extension}`)); - return matchingExtension ? bundledTheme.fileExtensions[matchingExtension] : ''; -} - -function resolveBundledTreeItemIcon(relativePath, kind = 'file') { - const bundledTheme = loadBundledTreeIconTheme(); - const iconId = resolveBundledTreeItemIconId(relativePath, kind); - return iconId ? bundledTheme.iconPathById.get(iconId) : undefined; -} - -function sessionIdleDecoration(session, now = Date.now()) { - if (!session) { - return undefined; - } - - if (session.activityKind === 'blocked') { - return { - badge: '!', - tooltip: 'blocked', - color: new vscode.ThemeColor('list.warningForeground'), - }; - } - if (session.activityKind === 'dead') { - return { - badge: 'x', - tooltip: 'dead', - color: new vscode.ThemeColor('list.errorForeground'), - }; - } - if (session.activityKind === 'stalled') { - return { - badge: '!', - tooltip: 'stalled', - color: new vscode.ThemeColor('list.errorForeground'), - }; - } - if (session.activityKind === 'working') { - return undefined; - } - - const startedAtMs = Date.parse(session.startedAt); - if (!Number.isFinite(startedAtMs)) { - return undefined; - } - - const elapsedMs = now - startedAtMs; - if (elapsedMs > IDLE_ERROR_MS) { - return { - badge: '30m+', - tooltip: 'idle 30m+', - color: new vscode.ThemeColor('list.errorForeground'), - }; - } - if (elapsedMs > IDLE_WARNING_MS) { - return { - badge: '10m+', - tooltip: 'idle 10m+', - color: new vscode.ThemeColor('list.warningForeground'), - }; - } - - return undefined; -} - -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 = []; - - for (const value of values) { - if (typeof value !== 'string' || seen.has(value)) { - continue; - } - seen.add(value); - result.push(value); - } - - 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; - } - - return left.every((value, index) => value === right[index]); -} - -async function ensureManagedRepoScanIgnores() { - if (typeof vscode.workspace.getConfiguration !== 'function') { - return; - } - - const workspaceFolders = vscode.workspace.workspaceFolders || []; - if (workspaceFolders.length === 0) { - return; - } - - const workspaceFolderTarget = workspaceFolders.length > 1 - ? vscode.ConfigurationTarget?.WorkspaceFolder - : vscode.ConfigurationTarget?.Workspace; - if (workspaceFolderTarget === undefined) { - return; - } - - for (const workspaceFolder of workspaceFolders) { - const gitConfig = vscode.workspace.getConfiguration(GIT_CONFIGURATION_SECTION, workspaceFolder); - const configuredIgnoredFolders = gitConfig.get(REPO_SCAN_IGNORED_FOLDERS_SETTING); - const existingIgnoredFolders = Array.isArray(configuredIgnoredFolders) - ? configuredIgnoredFolders - : []; - const nextIgnoredFolders = uniqueStringList([ - ...existingIgnoredFolders, - ...MANAGED_REPO_SCAN_IGNORED_FOLDERS, - ]); - - if (stringListsEqual(existingIgnoredFolders, nextIgnoredFolders)) { - continue; - } - - try { - await gitConfig.update( - REPO_SCAN_IGNORED_FOLDERS_SETTING, - nextIgnoredFolders, - workspaceFolderTarget, - ); - } catch { - // Leave the extension usable even when the current workspace settings cannot be updated. - } - } -} - -function sessionIdentityLabel(session) { - const agentName = typeof session?.agentName === 'string' ? session.agentName.trim() : ''; - const taskName = sessionDisplayLabel(session); - const label = typeof session?.label === 'string' ? session.label.trim() : ''; - - if (agentName && taskName) { - return `${agentName} · ${taskName}`; - } - if (agentName && label) { - return `${agentName} · ${label}`; - } - - return agentName || taskName || label || 'session'; -} - -function sessionCommitPlaceholder(session) { - if (!session?.branch) { - return 'Pick an Active Agents session to commit its worktree.'; - } - - return `Commit ${sessionIdentityLabel(session)} on ${session.branch} · ${formatCountLabel(session.lockCount || 0, 'lock')}`; -} - -function agentNameFromBranch(branch) { - const segments = String(branch || '') - .split('/') - .map((segment) => segment.trim()) - .filter(Boolean); - if (segments[0] === 'agent' && segments[1]) { - return segments[1]; - } - return segments[0] || 'lock'; -} - -function agentBadgeFromBranch(branch) { - const normalized = agentNameFromBranch(branch).toUpperCase().replace(/[^A-Z0-9]/g, ''); - return normalized.slice(0, 2) || 'LK'; -} - -function buildActiveAgentsStatusSummary(summary) { - const workingCount = summary?.workingCount || 0; - const finishedCount = summary?.finishedCount || 0; - const idleCount = summary?.idleCount || 0; - if (workingCount > 0 || finishedCount > 0 || idleCount > 0) { - const parts = [`${workingCount} working`]; - if (finishedCount > 0) { - parts.push(`${finishedCount} needs cleanup`); - } - parts.push(`${idleCount} idle`); - return `$(git-branch) ${parts.join(' · ')}`; - } - return `$(git-branch) ${formatCountLabel(summary?.sessionCount || 0, 'tracked session')}`; -} - -function buildActiveAgentsStatusTooltip(selectedSession, summary) { - if (selectedSession?.branch) { - return [ - selectedSession.branch, - sessionIdentityLabel(selectedSession), - formatCountLabel(selectedSession.lockCount || 0, 'lock'), - selectedSession.worktreePath, - 'Click to open Active Agents.', - ].filter(Boolean).join('\n'); - } - - const activeCount = Math.max(0, (summary?.sessionCount || 0) - (summary?.deadCount || 0)); - return [ - formatCountLabel(activeCount, 'active agent'), - formatCountLabel(summary?.workingCount || 0, 'working now session', 'working now sessions'), - formatCountLabel(summary?.finishedCount || 0, 'needs cleanup session'), - formatCountLabel(summary?.idleCount || 0, 'idle session'), - formatCountLabel(summary?.unassignedChangeCount || 0, 'unassigned change'), - formatCountLabel(summary?.lockedFileCount || 0, 'locked file'), - summary?.deadCount ? formatCountLabel(summary.deadCount, 'dead session') : '', - 'Click to open Active Agents.', - ].filter(Boolean).join('\n'); -} - -function compactRelativePath(relativePath) { - const normalized = normalizeRelativePath(relativePath); - if (!normalized) { - return ''; - } - - const segments = normalized.split('/').filter(Boolean); - if (segments.length <= 2) { - return normalized; - } - - return `${segments[0]}/.../${segments[segments.length - 1]}`; -} - -function summarizeCompactPaths(paths, maxCount = SESSION_TOP_FILE_COUNT) { - const compactPaths = uniqueStringList((paths || []) - .map(normalizeRelativePath) - .filter(Boolean) - .map((relativePath) => compactRelativePath(relativePath))) - .slice(0, maxCount); - if (compactPaths.length === 0) { - return ''; - } - return compactPaths.join(', '); -} - -function isProtectedBranchName(branch) { - return branch === 'main' || branch === 'dev'; -} - -function countWorkingSessions(sessions) { - return sessions.filter((session) => ( - session.activityKind === 'working' || session.activityKind === 'blocked' - )).length; -} - -function countFinishedSessions(sessions) { - return sessions.filter((session) => session.activityKind === 'finished').length; -} - -function countIdleSessions(sessions) { - return sessions.filter((session) => ( - session.activityKind === 'idle' || session.activityKind === 'stalled' - )).length; -} - -function sessionLastActiveAt(session) { - return [ - session?.lastHeartbeatAt, - session?.lastFileActivityAt, - session?.telemetryUpdatedAt, - session?.startedAt, - ].find((value) => typeof value === 'string' && value.trim().length > 0) || ''; -} - -function sessionLastActiveLabel(session) { - const lastActiveAt = sessionLastActiveAt(session); - if (!lastActiveAt) { - return ''; - } - return formatElapsedFrom(lastActiveAt); -} - -function sessionLastActiveAgeMs(session, now = Date.now()) { - const lastActiveAt = sessionLastActiveAt(session); - const timestamp = Date.parse(lastActiveAt); - if (!Number.isFinite(timestamp)) { - return null; - } - return Math.max(0, now - timestamp); -} - -function sessionFreshnessLabel(session, now = Date.now()) { - const ageMs = sessionLastActiveAgeMs(session, now); - if (session.activityKind === 'blocked') { - return 'Needs attention'; - } - if (session.activityKind === 'finished') { - return 'Needs cleanup'; - } - if (session.activityKind === 'stalled') { - return 'Possibly stale'; - } - if (session.activityKind === 'dead') { - return 'Stopped'; - } - if (ageMs === null) { - return ''; - } - if (ageMs <= IDLE_WARNING_MS) { - return 'Fresh'; - } - if (ageMs <= RECENTLY_ACTIVE_WINDOW_MS) { - return 'Recently active'; - } - if (session.activityKind === 'idle') { - return 'Idle'; - } - return 'Recently active'; -} - -function sessionStatusLabel(session) { - switch (session.activityKind) { - case 'blocked': - return 'Blocked'; - case 'working': - return 'Working'; - case 'finished': - return 'Needs cleanup'; - case 'idle': - return 'Idle'; - case 'stalled': - return 'Stale'; - case 'dead': - return 'Dead'; - default: - return 'Thinking'; - } -} - -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) - .filter(Boolean)) - .slice(0, SESSION_TOP_FILE_COUNT); -} - -function buildSessionRecentChangeSummary(session) { - if (session?.latestTaskPreview && session.latestTaskPreview !== session.taskName) { - return session.latestTaskPreview; - } - const topFiles = summarizeCompactPaths(session?.worktreeChangedPaths || []); - if (topFiles) { - return `Changed ${topFiles}`; - } - if (session?.activitySummary) { - return session.activitySummary; - } - return 'No recent change summary.'; -} - -function sessionRiskBadges(session) { - return uniqueStringList([ - session?.activityKind === 'blocked' ? 'Blocked' : '', - session?.activityKind === 'stalled' ? 'Stale' : '', - session?.conflictCount > 0 ? 'Conflict' : '', - session?.lockCount > 0 ? 'Locked' : '', - ].filter(Boolean)); -} - -function changeRiskBadges(change) { - return uniqueStringList([ - change?.protectedBranch ? 'Protected branch' : '', - change?.hasForeignLock ? 'Conflict' : '', - !change?.hasForeignLock && change?.lockOwnerBranch ? 'Locked' : '', - change?.deltaLabel || '', - ].filter(Boolean)); -} - -function changeNeedsWarningIcon(change) { - return Boolean( - change?.protectedBranch - || change?.hasForeignLock - || (!change?.hasForeignLock && change?.lockOwnerBranch), - ); -} - -function buildSessionCardDescription(session) { - const provider = resolveSessionProvider(session); - const statusAgentLabel = `${sessionStatusLabel(session)}: ${session.agentName || 'agent'}`; - const descriptionParts = [ - 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') : '', - buildSessionHealthCompactLabel(session), - session.freshnessLabel || '', - session.lastActiveLabel ? `${session.lastActiveLabel} ago` : '', - ].filter(Boolean); - return descriptionParts.join(' · '); -} - -function buildRawSessionDescription(session) { - const provider = resolveSessionProvider(session); - const descriptionParts = [sessionStatusLabel(session)]; - const fileCountLabel = sessionFileCountLabel(session); - if (fileCountLabel) { - descriptionParts.push(fileCountLabel); - } - if (provider?.label) { - descriptionParts.push(provider.label); - } - const snapshot = sessionSnapshotDescription(session); - if (snapshot) { - descriptionParts.push(snapshot); - } - 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')); - } - return descriptionParts.join(' · '); -} - -function buildSessionTooltip(session, description) { - const provider = resolveSessionProvider(session); - const riskSummary = uniqueStringList([ - ...(session?.riskBadges || []), - 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} · ${sessionDisplayLabel(session)}`, - `Status ${description}`, - sessionHealthSummary ? `Session health ${sessionHealthSummary}` : '', - sessionHealthDrivers ? `Drivers ${sessionHealthDrivers}` : '', - session.recentChangeSummary ? `Recent ${session.recentChangeSummary}` : '', - topFiles ? `Top files ${topFiles}` : '', - riskSummary ? `Signals ${riskSummary}` : '', - session.conflictCount > 0 ? `Conflicts ${session.conflictCount}` : '', - session.lastActiveAt ? `Last active ${session.lastActiveAt}` : '', - session.sourceKind === 'worktree-lock' - ? `Telemetry updated ${session.telemetryUpdatedAt || session.startedAt}` - : `Started ${session.startedAt}`, - session.worktreePath, - ].filter(Boolean).join('\n'); -} - -function buildUnassignedChangeDescription(change) { - return [ - change.statusLabel, - ...changeRiskBadges(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'), - formatCountLabel(summary?.finishedCount || 0, 'needs cleanup agent'), - formatCountLabel(summary?.idleCount || 0, 'idle agent'), - summary?.colonyTaskCount - ? formatCountLabel(summary.colonyTaskCount, 'colony task') - : '', - summary?.pendingHandoffCount - ? formatCountLabel(summary.pendingHandoffCount, 'pending handoff') - : '', - formatCountLabel(summary?.unassignedChangeCount || 0, 'unassigned change'), - formatCountLabel(summary?.lockedFileCount || 0, 'locked file'), - formatCountLabel(summary?.conflictCount || 0, 'conflict'), - ] - .filter(Boolean) - .join(' · '); -} - -function buildRepoDescription(summary) { - return buildOverviewDescription(summary); -} - -function buildRepoTooltip(repoRoot, summary) { - return [ - repoRoot, - buildOverviewDescription(summary), - ].join('\n'); -} - -function repoRootDisplayLabel(repoRoot) { - const normalizedRepoRoot = path.resolve(repoRoot); - const matchingWorkspaceRoots = (vscode.workspace.workspaceFolders || []) - .map((folder) => (typeof folder?.uri?.fsPath === 'string' ? path.resolve(folder.uri.fsPath) : '')) - .filter((workspaceRoot) => workspaceRoot && isPathWithin(workspaceRoot, normalizedRepoRoot)) - .sort((left, right) => right.length - left.length); - - const workspaceRoot = matchingWorkspaceRoots[0]; - if (!workspaceRoot) { - return path.basename(normalizedRepoRoot); - } - - const workspaceLabel = path.basename(workspaceRoot); - const relativePath = normalizeRelativePath(path.relative(workspaceRoot, normalizedRepoRoot)); - if (!relativePath) { - return workspaceLabel; - } - - return [ - workspaceLabel, - ...relativePath.split('/').filter(Boolean), - ].join('/'); -} - -function sessionSnapshotKey(session) { - return `${session?.repoRoot || ''}::${session?.branch || ''}`; -} - -function changeSnapshotKey(repoRoot, change) { - return `${repoRoot || ''}::${normalizeRelativePath(change?.relativePath)}`; -} - -function buildSessionSnapshot(session) { - return { - activityKind: session.activityKind, - changeCount: session.changeCount || 0, - conflictCount: session.conflictCount || 0, - lockCount: session.lockCount || 0, - changedPaths: [...(session.changedPaths || [])], - }; -} - -function buildChangeSnapshot(change) { - return { - statusLabel: change.statusLabel, - hasForeignLock: Boolean(change.hasForeignLock), - lockOwnerBranch: change.lockOwnerBranch || '', - }; -} - -function deriveSessionDelta(previousSnapshot, currentSession) { - if (!previousSnapshot) { - return ''; - } - if (currentSession.conflictCount > previousSnapshot.conflictCount) { - return 'Conflict'; - } - if (currentSession.activityKind !== previousSnapshot.activityKind) { - return sessionStatusLabel(currentSession); - } - if ( - currentSession.changeCount !== previousSnapshot.changeCount - || !stringListsEqual(currentSession.changedPaths || [], previousSnapshot.changedPaths || []) - ) { - return 'New'; - } - if (currentSession.lockCount !== previousSnapshot.lockCount) { - return 'Updated'; - } - return ''; -} - -function deriveChangeDelta(previousSnapshot, currentChange) { - if (!previousSnapshot) { - return ''; - } - if (currentChange.hasForeignLock && !previousSnapshot.hasForeignLock) { - return 'Conflict'; - } - if ( - currentChange.statusLabel !== previousSnapshot.statusLabel - || currentChange.lockOwnerBranch !== previousSnapshot.lockOwnerBranch - ) { - return 'Updated'; - } - return ''; -} - -function workingSessionSortKey(session) { - if (session.activityKind === 'blocked') { - return 0; - } - if (session.conflictCount > 0) { - return 1; - } - if (session.deltaLabel === 'Conflict') { - return 2; - } - if (session.deltaLabel === 'New') { - return 3; - } - if (session.activityKind === 'finished') { - return 5; - } - return 4; -} - -function idleSessionSortKey(session) { - if (session.activityKind === 'stalled') { - return 0; - } - if (session.activityKind === 'idle') { - return 1; - } - if (session.activityKind === 'dead') { - return 2; - } - return 3; -} - -function sortSessionsForWorkingNow(sessions) { - return [...sessions].sort((left, right) => { - const keyDelta = workingSessionSortKey(left) - workingSessionSortKey(right); - if (keyDelta !== 0) { - return keyDelta; - } - const timeDelta = sessionLastActiveAgeMs(left) - sessionLastActiveAgeMs(right); - if (Number.isFinite(timeDelta) && timeDelta !== 0) { - return timeDelta; - } - const changeDelta = (right.changeCount || 0) - (left.changeCount || 0); - if (changeDelta !== 0) { - return changeDelta; - } - return sessionDisplayLabel(left).localeCompare(sessionDisplayLabel(right)); - }); -} - -function sortSessionsForIdleThinking(sessions) { - return [...sessions].sort((left, right) => { - const keyDelta = idleSessionSortKey(left) - idleSessionSortKey(right); - if (keyDelta !== 0) { - return keyDelta; - } - const timeDelta = sessionLastActiveAgeMs(right) - sessionLastActiveAgeMs(left); - if (Number.isFinite(timeDelta) && timeDelta !== 0) { - return timeDelta; - } - return sessionDisplayLabel(left).localeCompare(sessionDisplayLabel(right)); - }); -} - -function sortUnassignedChanges(changes) { - return [...changes].sort((left, right) => { - const leftBadges = changeRiskBadges(left).length; - const rightBadges = changeRiskBadges(right).length; - if (leftBadges !== rightBadges) { - return rightBadges - leftBadges; - } - return normalizeRelativePath(left.relativePath).localeCompare(normalizeRelativePath(right.relativePath)); - }); -} - -function escapeHtml(value) { - return String(value || '') - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} - -function formatInspectBranchSummary(inspectData) { - if (Number.isInteger(inspectData?.aheadCount) && Number.isInteger(inspectData?.behindCount)) { - return `${inspectData.aheadCount} ahead · ${inspectData.behindCount} behind vs ${inspectData.compareRef}`; - } - return `Branch comparison unavailable vs ${inspectData?.compareRef || 'origin/dev'}`; -} - -function inspectPanelTitle(session) { - return `Inspect ${sessionDisplayLabel(session)}`; -} - -function renderInspectPanelHtml(session, inspectData) { - const heldLocksMarkup = Array.isArray(inspectData?.heldLocks) && inspectData.heldLocks.length > 0 - ? `
    ${inspectData.heldLocks.map((entry) => ( - `
  • ${escapeHtml(entry.relativePath)}${entry.allowDelete ? ' delete ok' : ''}${entry.claimedAt ? ` ${escapeHtml(entry.claimedAt)}` : ''}
  • ` - )).join('')}
` - : '

No held locks recorded for this session.

'; - const logContent = inspectData?.logTailText - ? escapeHtml(inspectData.logTailText) - : 'No log output available.'; - - return ` - - - - - - - -

${escapeHtml(sessionIdentityLabel(session))}

-
-
Branch
-
${escapeHtml(session.branch)}
-
Worktree
-
${escapeHtml(session.worktreePath)}
-
Base branch
-
${escapeHtml(inspectData?.baseBranch || 'dev')}
-
Divergence
-
${escapeHtml(formatInspectBranchSummary(inspectData))}
-
Held locks
-
${Array.isArray(inspectData?.heldLocks) ? inspectData.heldLocks.length : 0}
-
Log file
-
${escapeHtml(inspectData?.logPath || 'Unavailable')}
-
-

Held Locks

- ${heldLocksMarkup} -

Agent Log Tail

-
${logContent}
- -`; -} - -class SessionDecorationProvider { - constructor(nowProvider = () => Date.now()) { - this.nowProvider = nowProvider; - this.sessionsByUri = new Map(); - this.lockEntriesByFileUri = new Map(); - this.selectedBranch = ''; - this.onDidChangeFileDecorationsEmitter = new vscode.EventEmitter(); - this.onDidChangeFileDecorations = this.onDidChangeFileDecorationsEmitter.event; - } - - updateSessions(sessions) { - this.sessionsByUri = new Map( - sessions.map((session) => [sessionDecorationUri(session.branch).toString(), session]), - ); - } - - updateLockEntries(repoEntries) { - const nextEntriesByUri = new Map(); - for (const entry of repoEntries || []) { - for (const [relativePath, lockEntry] of entry.lockEntries || []) { - nextEntriesByUri.set( - vscode.Uri.file(path.join(entry.repoRoot, relativePath)).toString(), - { branch: lockEntry.branch }, - ); - } - } - this.lockEntriesByFileUri = nextEntriesByUri; - } - - setSelectedBranch(branch) { - this.selectedBranch = typeof branch === 'string' ? branch.trim() : ''; - } - - refresh() { - this.onDidChangeFileDecorationsEmitter.fire(); - } - - provideFileDecoration(uri) { - if (!uri || uri.scheme !== SESSION_DECORATION_SCHEME) { - if (!uri || uri.scheme !== 'file') { - return undefined; - } - - const lockEntry = this.lockEntriesByFileUri.get(uri.toString()); - if (!lockEntry?.branch) { - return undefined; - } - - const ownsSelectedSession = Boolean(this.selectedBranch) && lockEntry.branch === this.selectedBranch; - return { - badge: agentBadgeFromBranch(lockEntry.branch), - tooltip: ownsSelectedSession - ? `Locked by selected session ${lockEntry.branch}` - : this.selectedBranch - ? `Locked by ${lockEntry.branch} (selected session: ${this.selectedBranch})` - : `Locked by ${lockEntry.branch}`, - color: new vscode.ThemeColor( - ownsSelectedSession - ? 'gitDecoration.modifiedResourceForeground' - : this.selectedBranch - ? 'list.errorForeground' - : 'list.warningForeground', - ), - }; - } - - const session = this.sessionsByUri.get(uri.toString()); - const idleDecoration = sessionIdleDecoration(session, this.nowProvider()); - if (idleDecoration) { - return idleDecoration; - } - return sessionIdentityDecoration(session); - } -} - -class InfoItem extends vscode.TreeItem { - constructor(label, description = '') { - super(label, vscode.TreeItemCollapsibleState.None); - this.description = description; - this.iconPath = themeIcon('info'); - this.tooltip = [label, description].filter(Boolean).join('\n'); - } -} - -class DetailItem extends vscode.TreeItem { - constructor(label, description = '', options = {}) { - super(label, vscode.TreeItemCollapsibleState.None); - this.description = description; - this.tooltip = options.tooltip || [label, description].filter(Boolean).join('\n'); - this.iconPath = options.iconId ? themeIcon(options.iconId, options.iconColorId) : undefined; - } -} - -class RepoItem extends vscode.TreeItem { - constructor(repoRoot, sessions, changes, options = {}) { - const label = typeof options.label === 'string' && options.label.trim() - ? options.label.trim() - : repoRootDisplayLabel(repoRoot); - super(label, vscode.TreeItemCollapsibleState.Expanded); - this.repoRoot = repoRoot; - this.sessions = sessions; - this.changes = changes; - this.unassignedChanges = options.unassignedChanges || []; - this.lockEntries = options.lockEntries || []; - this.colonyTasks = Array.isArray(options.colonyTasks) ? options.colonyTasks : []; - this.overview = options.overview - || buildRepoOverview(sessions, this.unassignedChanges, this.lockEntries, this.colonyTasks); - this.description = buildRepoDescription(this.overview); - this.tooltip = buildRepoTooltip(repoRoot, this.overview); - this.iconPath = themeIcon('repo'); - this.contextValue = 'gitguardex.repo'; - } -} - -class SectionItem extends vscode.TreeItem { - constructor(label, items, options = {}) { - const collapsibleState = items.length > 0 - ? (options.collapsedState ?? vscode.TreeItemCollapsibleState.Expanded) - : vscode.TreeItemCollapsibleState.None; - super(label, collapsibleState); - this.items = items; - 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 ? themeIcon(options.iconId, options.iconColorId) : undefined; - this.contextValue = 'gitguardex.section'; - } -} - -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); - const label = typeof options.label === 'string' && options.label.trim() - ? options.label.trim() - : worktreeDisplayLabel(normalizedWorktreePath, sessionList); - super( - label, - items.length > 0 ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.None, - ); - this.worktreePath = normalizedWorktreePath; - this.sessions = sessionList; - this.items = items; - this.description = options.description || buildWorktreeDescription(sessionList, changedCount); - this.tooltip = [ - normalizedWorktreePath, - ...sessionList.map((session) => session.branch).filter(Boolean), - ].filter(Boolean).join('\n'); - this.iconPath = themeIcon(options.iconId || 'folder', options.iconColorId); - if (options.useSessionDecoration && primarySession?.branch) { - this.resourceUri = sessionDecorationUri(primarySession.branch); - } - this.contextValue = 'gitguardex.worktree'; - if (primarySession?.worktreePath) { - this.command = { - command: 'gitguardex.activeAgents.openWorktree', - title: 'Open Agent Worktree', - arguments: [primarySession], - }; - } - } -} - -class SessionItem extends vscode.TreeItem { - constructor(session, items = [], options = {}) { - const variant = options.variant === 'raw' ? 'raw' : 'card'; - const label = typeof options.label === 'string' && options.label.trim() - ? options.label.trim() - : (variant === 'raw' ? session.label : sessionDisplayLabel(session)); - const collapsibleState = items.length > 0 - ? (options.collapsedState ?? ( - variant === 'raw' - ? vscode.TreeItemCollapsibleState.Expanded - : vscode.TreeItemCollapsibleState.Collapsed - )) - : vscode.TreeItemCollapsibleState.None; - super( - label, - collapsibleState, - ); - this.session = session; - this.items = items; - this.resourceUri = sessionDecorationUri(session.branch); - this.description = variant === 'raw' - ? buildRawSessionDescription(session) - : buildSessionCardDescription(session); - this.tooltip = buildSessionTooltip(session, this.description); - this.iconPath = themeIcon(resolveSessionActivityIconId(session.activityKind)); - this.contextValue = sessionContextValue(session); - this.command = { - command: 'gitguardex.activeAgents.openWorktree', - title: 'Open Agent Worktree', - arguments: [session], - }; - } -} - -function sessionContextValue(session) { - const activityKind = typeof session?.activityKind === 'string' ? session.activityKind.trim() : ''; - return activityKind - ? `gitguardex.session.${activityKind}` - : 'gitguardex.session'; -} - -function canDismissSession(session) { - return DISMISSABLE_SESSION_ACTIVITY_KINDS.has(session?.activityKind); -} - -function buildDismissSessionDetail(session, statePath) { - const repoRoot = typeof session?.repoRoot === 'string' ? session.repoRoot.trim() : ''; - const relativeStatePath = repoRoot - ? path.relative(repoRoot, statePath) || path.basename(statePath) - : path.basename(statePath); - const detailParts = [ - `Remove ${relativeStatePath} and hide this session from Active Agents.`, - ]; - - if (session?.activityKind === 'stalled') { - detailParts.push('This dismisses the stale sidebar row only; use Stop if you want to interrupt a live agent.'); - } else { - detailParts.push('This clears the stale session record from the sidebar.'); - } - - return detailParts.join(' '); -} - -class FolderItem extends vscode.TreeItem { - 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.description = typeof options.description === 'string' ? options.description : ''; - this.tooltip = options.tooltip || relativePath || label; - this.iconPath = options.iconPath - || (!options.iconId ? resolveBundledTreeItemIcon(relativePath || label, 'folder') : undefined) - || themeIcon(options.iconId || 'folder', options.iconColorId); - this.contextValue = options.contextValue || 'gitguardex.folder'; - } -} - -class ChangeItem extends vscode.TreeItem { - constructor(change, options = {}) { - const label = typeof options.label === 'string' && options.label.trim() - ? options.label.trim() - : path.basename(change.relativePath); - super(label, vscode.TreeItemCollapsibleState.None); - this.change = change; - this.description = typeof options.description === 'string' - ? options.description - : change.statusLabel; - this.tooltip = [ - change.relativePath, - `Summary ${this.description}`, - `Status ${change.statusText}`, - change.originalPath ? `Renamed from ${change.originalPath}` : '', - change.hasForeignLock ? `Locked by ${change.lockOwnerBranch}` : '', - change.absolutePath, - ].filter(Boolean).join('\n'); - this.resourceUri = vscode.Uri.file(change.absolutePath); - if (options.iconId || change.hasForeignLock) { - this.iconPath = themeIcon(options.iconId || 'warning', options.iconColorId || 'list.warningForeground'); - } else { - this.iconPath = options.iconPath || resolveBundledTreeItemIcon(change.relativePath || label, 'file'); - } - this.contextValue = 'gitguardex.change'; - this.command = { - command: 'gitguardex.activeAgents.openChange', - title: 'Open Changed File', - arguments: [change], - }; - } -} - -function shellQuote(value) { - const normalized = String(value || ''); - return `'${normalized.replace(/'/g, "'\"'\"'")}'`; -} - - -function hasGitMarker(dirPath) { - return fs.existsSync(path.join(dirPath, '.git')); -} - -function shouldSkipRepoDiscoveryDir(dirName) { - return new Set([ - '.git', - '.omx', - '.omc', - 'node_modules', - 'dist', - 'build', - '.next', - ]).has(dirName); -} - -function discoverNestedGitRepoRoots(rootPath, maxDepth = 3) { - const discovered = []; - - function visit(dirPath, depth) { - if (depth > maxDepth) return; - let entries = []; - try { - entries = fs.readdirSync(dirPath, { withFileTypes: true }); - } catch (_error) { - return; - } - - for (const entry of entries) { - if (!entry.isDirectory() || shouldSkipRepoDiscoveryDir(entry.name)) { - continue; - } - const childPath = path.join(dirPath, entry.name); - if (hasGitMarker(childPath)) { - discovered.push(childPath); - continue; - } - visit(childPath, depth + 1); - } - } - - visit(rootPath, 1); - return discovered; -} - -function discoverWorkspaceRepoRoots() { - const workspaceFolders = vscode.workspace.workspaceFolders || []; - const seen = new Set(); - const roots = []; - - for (const folder of workspaceFolders) { - const rootPath = folder?.uri?.fsPath; - if (!rootPath || seen.has(rootPath)) { - continue; - } - seen.add(rootPath); - roots.push(rootPath); - - for (const nestedRoot of discoverNestedGitRepoRoots(rootPath)) { - if (seen.has(nestedRoot)) { - continue; - } - seen.add(nestedRoot); - roots.push(nestedRoot); - } - } - - return roots; -} - -function repoPickLabel(repoRoot) { - const parent = path.basename(path.dirname(repoRoot)); - const base = path.basename(repoRoot); - return parent ? `${parent}/${base}` : base; -} - -function readGitOutput(repoRoot, args) { - try { - return cp.execFileSync('git', ['-C', repoRoot, ...args], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'], - }).trim(); - } catch { - return null; - } -} - -function repoGitSummary(repoRoot) { - const branch = readGitOutput(repoRoot, ['branch', '--show-current']) || 'unknown'; - const status = readGitOutput(repoRoot, ['status', '--porcelain']); - return { - branch, - dirty: status === null ? 'unknown' : status.length > 0 ? 'dirty' : 'clean', - }; -} - -function repoPickDescription(repoRoot) { - const summary = repoGitSummary(repoRoot); - return `${summary.branch} · ${summary.dirty}`; -} - -function findRepoRootForPath(repoRoots, candidatePath) { - const normalizedCandidatePath = normalizeAbsolutePath(candidatePath); - if (!normalizedCandidatePath) { - return null; - } - - return repoRoots - .filter((repoRoot) => isPathWithin(repoRoot, normalizedCandidatePath)) - .sort((left, right) => right.length - left.length)[0] || null; -} - -function activeScmRootPath() { - const sourceControl = vscode.scm?.activeSourceControl; - return sourceControl?.rootUri?.fsPath || sourceControl?.rootUri?.path || ''; -} - -function preferredRepoRoot(repoRoots) { - return findRepoRootForPath(repoRoots, activeScmRootPath()) - || findRepoRootForPath(repoRoots, vscode.window.activeTextEditor?.document?.uri?.fsPath); -} - -function resolveStartAgentCommand(repoRoot, details) { - const taskArg = shellQuote(details.taskName); - const agentArg = shellQuote(details.agentName); - return `gx agents start ${taskArg} --agent ${agentArg} --target ${shellQuote(repoRoot)}`; -} - -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 sessionTaskLabel(session) - || session?.label - || compactBranchLabel(session?.branch) - || session?.branch - || path.basename(session?.worktreePath || '') - || 'session'; -} - -function sessionTreeLabel(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 repoEntryDisplayLabel(repoRoot, sessions) { - const repoLabel = repoRootDisplayLabel(repoRoot); - const projectPaths = uniqueStringList((sessions || []) - .map((session) => resolveSessionProjectRelativePath(session)) - .filter(Boolean)); - if (projectPaths.length !== 1) { - return repoLabel; - } - - const [projectRelativePath] = projectPaths; - const hasRootScopedSession = (sessions || []).some( - (session) => !resolveSessionProjectRelativePath(session), - ); - if (!projectRelativePath || hasRootScopedSession) { - return repoLabel; - } - if (repoLabel.endsWith(`/${projectRelativePath}`)) { - return repoLabel; - } - return `${repoLabel}/${projectRelativePath}`; -} - -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); -} - -function ensureSessionWorktree(session, actionLabel) { - const worktreePath = sessionWorktreePath(session); - if (!worktreePath) { - showSessionMessage(`Cannot ${actionLabel}: missing worktree path.`); - return ''; - } - if (!fs.existsSync(worktreePath)) { - showSessionMessage(`Cannot ${actionLabel}: worktree is no longer on disk: ${worktreePath}`); - return ''; - } - return worktreePath; -} - -function runSessionTerminalCommand(session, actionLabel, iconId, commandText) { - const worktreePath = ensureSessionWorktree(session, actionLabel.toLowerCase()); - if (!worktreePath) { - return; - } - - const terminal = vscode.window.createTerminal({ - name: `GitGuardex ${actionLabel}: ${sessionDisplayLabel(session)}`, - cwd: worktreePath, - iconPath: new vscode.ThemeIcon(iconId), - }); - terminal.show(); - terminal.sendText(commandText, true); -} - -function sessionTerminalLabel(session) { - return `GitGuardex Terminal: ${sessionDisplayLabel(session)}`; -} - -function listWindowTerminals() { - return Array.isArray(vscode.window.terminals) ? vscode.window.terminals : []; -} - -function focusTerminal(terminal) { - terminal?.show?.(false); -} - -async function terminalProcessId(terminal) { - if (!terminal?.processId) { - return null; - } - - try { - const pid = await terminal.processId; - return Number.isInteger(pid) && pid > 0 ? pid : null; - } catch (_error) { - return null; - } -} - -function findFallbackSessionTerminal(session) { - const label = sessionTerminalLabel(session); - return listWindowTerminals().find((terminal) => terminal?.name === label) || null; -} - -async function findSessionTerminal(session) { - const pid = Number(session?.pid); - if (!Number.isInteger(pid) || pid <= 0) { - return null; - } - - for (const terminal of listWindowTerminals()) { - if (await terminalProcessId(terminal) === pid) { - return terminal; - } - } - - return null; -} - -function openFallbackSessionTerminal(session, worktreePath) { - const existingTerminal = findFallbackSessionTerminal(session); - if (existingTerminal) { - focusTerminal(existingTerminal); - return existingTerminal; - } - - const terminal = vscode.window.createTerminal({ - name: sessionTerminalLabel(session), - cwd: worktreePath, - iconPath: new vscode.ThemeIcon('terminal'), - }); - focusTerminal(terminal); - return terminal; -} - -async function showSessionTerminal(session) { - const worktreePath = ensureSessionWorktree(session, 'show terminal'); - if (!worktreePath) { - return; - } - - const terminal = await findSessionTerminal(session); - if (terminal) { - focusTerminal(terminal); - return; - } - - openFallbackSessionTerminal(session, worktreePath); -} - -function finishSession(session) { - if (!session?.branch) { - showSessionMessage('Cannot finish session: missing branch name.'); - return; - } - runSessionTerminalCommand( - session, - 'Finish', - 'check', - `gx branch finish --branch ${shellQuote(session.branch)}`, - ); -} - -function syncSession(session) { - runSessionTerminalCommand(session, 'Sync', 'sync', 'gx sync'); -} - -async function restartActiveAgents(extensionId) { - if (extensionId && extensionId !== ACTIVE_AGENTS_EXTENSION_ID) { - return; - } - await vscode.commands.executeCommand(RESTART_EXTENSION_HOST_COMMAND); -} - -function execFileAsync(command, args, options = {}) { - return new Promise((resolve, reject) => { - cp.execFile(command, args, options, (error, stdout = '', stderr = '') => { - if (error) { - error.stdout = stdout; - error.stderr = stderr; - reject(error); - return; - } - resolve({ stdout, stderr }); - }); - }); -} - -function buildStopSessionCommandText(session, pid) { - const parts = ['gx', 'agents', 'stop', '--pid', String(pid)]; - if (session?.repoRoot) { - parts.push('--target', session.repoRoot); - } - return parts.map(shellQuote).join(' '); -} - -async function stopSession(session, refresh) { - const pid = Number(session?.pid); - if (!Number.isInteger(pid) || pid <= 0) { - showSessionMessage('Cannot stop session: missing pid.'); - return; - } - if (!session?.branch) { - showSessionMessage('Cannot stop session: missing branch name.'); - return; - } - - const sessionTerminal = await findSessionTerminal(session); - const stopCommandText = buildStopSessionCommandText(session, pid); - const confirmed = await vscode.window.showWarningMessage( - `Stop ${sessionDisplayLabel(session)}?`, - { - modal: true, - detail: sessionTerminal - ? 'Send Ctrl+C to the live session terminal.' - : `No live session terminal found. Run ${stopCommandText}.`, - }, - 'Stop', - ); - if (confirmed !== 'Stop') { - return; - } - - if (sessionTerminal) { - focusTerminal(sessionTerminal); - sessionTerminal.sendText('\u0003', false); - refresh(); - return; - } - - try { - const commandCwd = session?.repoRoot || sessionWorktreePath(session) || process.cwd(); - const args = ['agents', 'stop', '--pid', String(pid)]; - if (session?.repoRoot) { - args.push('--target', session.repoRoot); - } - await execFileAsync('gx', args, { - cwd: commandCwd, - encoding: 'utf8', - maxBuffer: 1024 * 1024, - }); - refresh(); - } catch (error) { - showSessionMessage( - `Failed to stop session ${sessionDisplayLabel(session)}: ${formatGitCommandFailure(error)}`, - ); - } -} - -async function dismissSession(session, refresh) { - if (!canDismissSession(session)) { - showSessionMessage('Only stalled or dead sessions can be dismissed.'); - return; - } - - const repoRoot = typeof session?.repoRoot === 'string' ? session.repoRoot.trim() : ''; - if (!repoRoot) { - showSessionMessage('Cannot dismiss session: missing repo root.'); - return; - } - if (!session?.branch) { - showSessionMessage('Cannot dismiss session: missing branch name.'); - return; - } - - const statePath = sessionFilePathForBranch(repoRoot, session.branch); - if (!fs.existsSync(statePath)) { - clearWorktreeActivityCache(session.worktreePath); - refresh(); - showSessionMessage(`Session record already gone for ${sessionDisplayLabel(session)}.`); - return; - } - - const confirmed = await vscode.window.showWarningMessage( - `Dismiss ${sessionDisplayLabel(session)}?`, - { - modal: true, - detail: buildDismissSessionDetail(session, statePath), - }, - 'Dismiss', - ); - if (confirmed !== 'Dismiss') { - return; - } - - try { - fs.unlinkSync(statePath); - clearWorktreeActivityCache(session.worktreePath); - refresh(); - } catch (error) { - showSessionMessage(`Failed to dismiss session ${sessionDisplayLabel(session)}: ${error.message}`); - } -} - -function readGitDirPath(targetPath) { - const normalizedTargetPath = typeof targetPath === 'string' ? targetPath.trim() : ''; - if (!normalizedTargetPath) { - return ''; - } - - const gitPath = path.join(path.resolve(normalizedTargetPath), '.git'); - try { - if (fs.statSync(gitPath).isDirectory()) { - return gitPath; - } - } catch (_error) { - return ''; - } - - try { - const gitPointer = fs.readFileSync(gitPath, 'utf8'); - const match = gitPointer.match(/^gitdir:\s*(.+)$/m); - if (match?.[1]) { - return path.resolve(path.dirname(gitPath), match[1].trim()); - } - } catch (_error) { - return ''; - } - - return ''; -} - -function resolveRepoRootFromGitDir(targetPath) { - const gitDir = readGitDirPath(targetPath); - if (!gitDir) { - return ''; - } - - let commonDir = gitDir; - try { - const commonDirPath = path.join(gitDir, 'commondir'); - if (fs.existsSync(commonDirPath)) { - const rawCommonDir = fs.readFileSync(commonDirPath, 'utf8').trim(); - if (rawCommonDir) { - commonDir = path.resolve(gitDir, rawCommonDir); - } - } - } catch (_error) { - // Fall back to the direct git dir when commondir is unreadable. - } - - return path.basename(commonDir) === '.git' - ? path.resolve(path.dirname(commonDir)) - : ''; -} - -function readGitTopLevel(targetPath) { - try { - return cp.execFileSync('git', ['-C', targetPath, 'rev-parse', '--show-toplevel'], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'], - }).trim(); - } catch (_error) { - return ''; - } -} - -function resolveWorkspaceFolderRepoRoot(workspacePath) { - const normalizedWorkspacePath = typeof workspacePath === 'string' ? workspacePath.trim() : ''; - if (!normalizedWorkspacePath) { - return ''; - } - - const absoluteWorkspacePath = path.resolve(normalizedWorkspacePath); - const directRepoRoot = resolveRepoRootFromGitDir(absoluteWorkspacePath); - if (directRepoRoot) { - return directRepoRoot; - } - - const gitTopLevel = readGitTopLevel(absoluteWorkspacePath); - if (!gitTopLevel) { - return absoluteWorkspacePath; - } - - return resolveRepoRootFromGitDir(gitTopLevel) || path.resolve(gitTopLevel); -} - -function repoRootFromSessionFile(filePath) { - return path.resolve(path.dirname(filePath), '..', '..', '..'); -} - -function repoRootFromWorktreeLockFile(filePath) { - return path.resolve(path.dirname(filePath), '..', '..', '..'); -} - -function repoRootFromManagedWorktreeGitFile(filePath) { - return path.resolve(path.dirname(filePath), '..', '..', '..'); -} - -function repoRootFromLockFile(filePath) { - return path.resolve(path.dirname(filePath), '..', '..'); -} - -function normalizeRelativePath(relativePath) { - return String(relativePath || '').replace(/\\/g, '/').replace(/^\.\//, ''); -} - -function emptyLockRegistry() { - return { - entriesByPath: new Map(), - countsByBranch: new Map(), - }; -} - -function readLockRegistry(repoRoot) { - const lockPath = path.join(repoRoot, LOCK_FILE_RELATIVE); - if (!fs.existsSync(lockPath)) { - return emptyLockRegistry(); - } - - let parsed; - try { - parsed = JSON.parse(fs.readFileSync(lockPath, 'utf8')); - } catch (_error) { - return emptyLockRegistry(); - } - - const locks = parsed?.locks; - if (!locks || typeof locks !== 'object' || Array.isArray(locks)) { - return emptyLockRegistry(); - } - - const entriesByPath = new Map(); - const countsByBranch = new Map(); - for (const [rawRelativePath, entry] of Object.entries(locks)) { - if (!entry || typeof entry !== 'object') { - continue; - } - - const relativePath = normalizeRelativePath(rawRelativePath); - const branch = typeof entry.branch === 'string' ? entry.branch.trim() : ''; - if (!relativePath || !branch) { - continue; - } - - entriesByPath.set(relativePath, { - branch, - claimedAt: typeof entry.claimed_at === 'string' ? entry.claimed_at : '', - allowDelete: Boolean(entry.allow_delete), - }); - countsByBranch.set(branch, (countsByBranch.get(branch) || 0) + 1); - } - - return { - entriesByPath, - countsByBranch, - }; -} - -function readCurrentBranch(repoRoot) { - try { - return cp.execFileSync('git', ['-C', repoRoot, 'rev-parse', '--abbrev-ref', 'HEAD'], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'], - }).trim(); - } catch (_error) { - return ''; - } -} - -function parseSimpleSemver(version) { - const parts = String(version || '') - .split('.') - .map((part) => Number.parseInt(part, 10)); - if (parts.length !== 3 || parts.some((part) => Number.isNaN(part))) { - return null; - } - return parts; -} - -function compareSimpleSemver(left, right) { - const leftParts = parseSimpleSemver(left); - const rightParts = parseSimpleSemver(right); - if (!leftParts || !rightParts) { - return 0; - } - - for (let index = 0; index < leftParts.length; index += 1) { - if (leftParts[index] !== rightParts[index]) { - return leftParts[index] - rightParts[index]; - } - } - - return 0; -} - -function readJsonFile(filePath) { - try { - return JSON.parse(fs.readFileSync(filePath, 'utf8')); - } catch (_error) { - return null; - } -} - -function resolveActiveAgentsAutoUpdateCandidate(installedVersion) { - const candidates = []; - - for (const workspaceFolder of vscode.workspace.workspaceFolders || []) { - const repoRoot = workspaceFolder?.uri?.fsPath; - if (!repoRoot) { - continue; - } - - const manifestPath = path.join(repoRoot, ACTIVE_AGENTS_MANIFEST_RELATIVE); - const installScriptPath = path.join(repoRoot, ACTIVE_AGENTS_INSTALL_SCRIPT_RELATIVE); - if (!fs.existsSync(manifestPath) || !fs.existsSync(installScriptPath)) { - continue; - } - - const manifest = readJsonFile(manifestPath); - const nextVersion = typeof manifest?.version === 'string' ? manifest.version.trim() : ''; - if (!nextVersion || compareSimpleSemver(nextVersion, installedVersion) <= 0) { - continue; - } - - candidates.push({ repoRoot, installScriptPath, version: nextVersion }); - } - - candidates.sort((left, right) => compareSimpleSemver(right.version, left.version)); - return candidates[0] || null; -} - -function runActiveAgentsInstallScript(repoRoot, installScriptPath) { - return new Promise((resolve, reject) => { - cp.execFile( - process.execPath, - [installScriptPath], - { cwd: repoRoot, encoding: 'utf8' }, - (error, stdout, stderr) => { - if (error) { - reject(new Error(String(stderr || stdout || error.message || '').trim() || 'install failed')); - return; - } - resolve({ stdout, stderr }); - }, - ); - }); -} - -async function maybeAutoUpdateActiveAgentsExtension(context) { - const installedVersion = typeof context?.extension?.packageJSON?.version === 'string' - ? context.extension.packageJSON.version.trim() - : ''; - if (!installedVersion) { - return; - } - - const candidate = resolveActiveAgentsAutoUpdateCandidate(installedVersion); - if (!candidate) { - return; - } - - try { - await runActiveAgentsInstallScript(candidate.repoRoot, candidate.installScriptPath); - } catch (error) { - const failure = typeof error?.message === 'string' && error.message.trim() - ? error.message.trim() - : 'install failed'; - vscode.window.showWarningMessage?.( - `GitGuardex Active Agents could not auto-update to ${candidate.version}: ${failure}`, - ); - return; - } - - const selection = await vscode.window.showInformationMessage?.( - `GitGuardex Active Agents updated to ${candidate.version}. Reload this window now, then reload any other already-open VS Code windows to use the newest companion.`, - RELOAD_WINDOW_ACTION, - UPDATE_LATER_ACTION, - ); - if (selection === RELOAD_WINDOW_ACTION) { - await vscode.commands.executeCommand('workbench.action.reloadWindow'); - } -} - -function decorateSession(session, lockRegistry) { - const touchedChanges = buildSessionTouchedChanges(session, lockRegistry); - const decorated = { - ...session, - lockCount: lockRegistry.countsByBranch.get(session.branch) || 0, - touchedChanges, - conflictCount: touchedChanges.filter((change) => change.hasForeignLock).length, - }; - decorated.lastActiveAt = sessionLastActiveAt(decorated); - decorated.lastActiveLabel = sessionLastActiveLabel(decorated); - decorated.freshnessLabel = sessionFreshnessLabel(decorated); - decorated.topChangedFiles = buildSessionTopFiles(decorated); - decorated.topChangedFilesLabel = summarizeCompactPaths(decorated.topChangedFiles); - decorated.recentChangeSummary = buildSessionRecentChangeSummary(decorated); - decorated.riskBadges = sessionRiskBadges(decorated); - return decorated; -} - -function decorateChange(change, lockRegistry, owningBranch) { - const lockEntry = lockRegistry.entriesByPath.get(normalizeRelativePath(change.relativePath)); - const lockOwnerBranch = lockEntry?.branch || ''; - const decorated = { - ...change, - lockOwnerBranch, - hasForeignLock: Boolean(lockOwnerBranch) && (!owningBranch || lockOwnerBranch !== owningBranch), - protectedBranch: isProtectedBranchName(owningBranch), - }; - decorated.riskBadges = changeRiskBadges(decorated); - return decorated; -} - -function buildSessionTouchedChanges(session, lockRegistry) { - const changedPaths = Array.isArray(session.worktreeChangedPaths) - ? session.worktreeChangedPaths - : []; - return [...new Set(changedPaths.map(normalizeRelativePath).filter(Boolean))] - .map((relativePath) => { - const lockEntry = lockRegistry.entriesByPath.get(relativePath); - const lockOwnerBranch = lockEntry?.branch || ''; - return { - relativePath, - absolutePath: path.join(session.worktreePath, relativePath), - originalPath: '', - statusCode: 'M', - statusLabel: 'M', - statusText: 'Touched', - lockOwnerBranch, - hasForeignLock: Boolean(lockOwnerBranch) && lockOwnerBranch !== session.branch, - }; - }); -} - -function isPathWithin(parentPath, targetPath) { - const relativePath = path.relative(parentPath, targetPath); - return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath)); -} - -function normalizeAbsolutePath(value) { - return typeof value === 'string' && value.trim() ? path.resolve(value) : ''; -} - -function isManagedWorktreePath(worktreePath) { - const normalizedWorktreePath = normalizeAbsolutePath(worktreePath); - if (!normalizedWorktreePath) { - return false; - } - - return MANAGED_WORKTREE_RELATIVE_ROOTS.some((relativeRoot) => { - const normalizedRelativeRoot = path.normalize(relativeRoot); - const marker = `${path.sep}${normalizedRelativeRoot}${path.sep}`; - return normalizedWorktreePath.includes(marker); - }); -} - -function removeDeletedWorktreeWorkspaceFolder(worktreePath) { - if (typeof vscode.workspace.updateWorkspaceFolders !== 'function') { - return false; - } - - const normalizedWorktreePath = normalizeAbsolutePath(worktreePath); - if (!normalizedWorktreePath) { - return false; - } - - const workspaceFolders = vscode.workspace.workspaceFolders || []; - const folderIndex = workspaceFolders.findIndex((folder) => ( - normalizeAbsolutePath(folder?.uri?.fsPath) === normalizedWorktreePath - )); - if (folderIndex < 0) { - return false; - } - - try { - return vscode.workspace.updateWorkspaceFolders(folderIndex, 1) === true; - } catch (_error) { - return false; - } -} - -async function closeDeletedWorktreeRepository(worktreePath) { - const normalizedWorktreePath = normalizeAbsolutePath(worktreePath); - if (!normalizedWorktreePath || fs.existsSync(normalizedWorktreePath)) { - return false; - } - - try { - await vscode.commands.executeCommand('git.close', vscode.Uri.file(normalizedWorktreePath)); - } catch (_error) { - // The Git extension may have already removed this repository. - } - - removeDeletedWorktreeWorkspaceFolder(normalizedWorktreePath); - return true; -} - -function findDeletedManagedWorkspaceFolders() { - return (vscode.workspace.workspaceFolders || []) - .map((folder) => normalizeAbsolutePath(folder?.uri?.fsPath)) - .filter((workspacePath) => ( - workspacePath - && !fs.existsSync(workspacePath) - && isManagedWorktreePath(workspacePath) - )); -} - -function localizeChangeForSession(session, change) { - if (!change?.absolutePath || !isPathWithin(session.worktreePath, change.absolutePath)) { - return null; - } - - let originalPath = change.originalPath; - if (originalPath) { - const originalAbsolutePath = path.join(session.repoRoot, originalPath); - if (isPathWithin(session.worktreePath, originalAbsolutePath)) { - originalPath = normalizeRelativePath(path.relative(session.worktreePath, originalAbsolutePath)); - } - } - - return { - ...change, - relativePath: normalizeRelativePath(path.relative(session.worktreePath, change.absolutePath)), - originalPath, - }; -} - -async function findRepoSessionEntries() { - const [sessionFiles, worktreeLockFiles, managedWorktreeGitFiles] = await Promise.all([ - vscode.workspace.findFiles( - ACTIVE_SESSION_FILES_GLOB, - SESSION_SCAN_EXCLUDE_GLOB, - SESSION_SCAN_LIMIT, - ), - vscode.workspace.findFiles( - WORKTREE_AGENT_LOCKS_GLOB, - WORKTREE_LOCK_SCAN_EXCLUDE_GLOB, - SESSION_SCAN_LIMIT, - ), - vscode.workspace.findFiles( - MANAGED_WORKTREE_GIT_FILES_GLOB, - MANAGED_WORKTREE_GIT_SCAN_EXCLUDE_GLOB, - SESSION_SCAN_LIMIT, - ), - ]); - - const repoRoots = new Set(); - const addRepoRootCandidate = (repoRoot) => { - if (typeof repoRoot !== 'string' || !repoRoot.trim()) { - return; - } - - const normalizedRepoRoot = path.resolve(repoRoot); - const isInsideWorkspaceManagedWorktree = (vscode.workspace.workspaceFolders || []) - .map((folder) => (typeof folder?.uri?.fsPath === 'string' ? path.resolve(folder.uri.fsPath) : '')) - .filter(Boolean) - .some((workspaceRoot) => MANAGED_WORKTREE_RELATIVE_ROOTS.some((relativeRoot) => ( - isPathWithin(path.join(workspaceRoot, relativeRoot), normalizedRepoRoot) - ))); - if (!isInsideWorkspaceManagedWorktree) { - repoRoots.add(normalizedRepoRoot); - } - }; - - for (const uri of sessionFiles) { - addRepoRootCandidate(repoRootFromSessionFile(uri.fsPath)); - } - for (const uri of worktreeLockFiles) { - if (path.basename(uri.fsPath) !== 'AGENT.lock') { - continue; - } - addRepoRootCandidate(repoRootFromWorktreeLockFile(uri.fsPath)); - } - for (const uri of managedWorktreeGitFiles) { - if (path.basename(uri.fsPath) !== '.git') { - continue; - } - addRepoRootCandidate(repoRootFromManagedWorktreeGitFile(uri.fsPath)); - } - for (const workspaceFolder of vscode.workspace.workspaceFolders || []) { - if (workspaceFolder?.uri?.fsPath) { - addRepoRootCandidate(resolveWorkspaceFolderRepoRoot(workspaceFolder.uri.fsPath)); - } - } - - const repoEntries = []; - for (const repoRoot of repoRoots) { - const sessions = readActiveSessions(repoRoot, { includeStale: true }); - if (sessions.length > 0) { - repoEntries.push({ repoRoot, sessions }); - } - } - - repoEntries.sort((left, right) => left.repoRoot.localeCompare(right.repoRoot)); - return repoEntries; -} - -function resolveSessionWatcherKey(session) { - return `${path.resolve(session.repoRoot)}::${session.branch}::${path.resolve(session.worktreePath)}`; -} - -function resolveSessionGitIndexPath(worktreePath) { - const gitPath = path.join(worktreePath, '.git'); - const defaultIndexPath = path.join(gitPath, 'index'); - - try { - if (fs.statSync(gitPath).isDirectory()) { - return defaultIndexPath; - } - } catch (_error) { - return defaultIndexPath; - } - - try { - const gitPointer = fs.readFileSync(gitPath, 'utf8'); - const match = gitPointer.match(/^gitdir:\s*(.+)$/m); - if (match?.[1]) { - return path.resolve(worktreePath, match[1].trim(), 'index'); - } - } catch (_error) { - return defaultIndexPath; - } - - return defaultIndexPath; -} - -function bindRefreshWatcher(watcher, refresh) { - return [ - watcher.onDidCreate(refresh), - watcher.onDidChange(refresh), - watcher.onDidDelete(refresh), - ]; -} - -function disposeAll(disposables) { - for (const disposable of disposables) { - disposable?.dispose?.(); - } -} - -function buildChangeTreeNodes(changes) { - const root = []; - - function sortNodes(nodes) { - nodes.sort((left, right) => { - const leftIsFolder = left.kind === 'folder'; - const rightIsFolder = right.kind === 'folder'; - if (leftIsFolder !== rightIsFolder) { - return leftIsFolder ? -1 : 1; - } - return left.label.localeCompare(right.label); - }); - - for (const node of nodes) { - if (node.kind === 'folder') { - sortNodes(node.children); - } - } - } - - for (const change of changes) { - const segments = change.relativePath.split(/[\\/]+/).filter(Boolean); - if (segments.length <= 1) { - root.push({ kind: 'change', label: change.relativePath, change }); - continue; - } - - let nodes = root; - let folderPath = ''; - for (const segment of segments.slice(0, -1)) { - folderPath = folderPath ? path.posix.join(folderPath, segment) : segment; - let folderNode = nodes.find((node) => node.kind === 'folder' && node.relativePath === folderPath); - if (!folderNode) { - folderNode = { - kind: 'folder', - label: segment, - relativePath: folderPath, - children: [], - }; - nodes.push(folderNode); - } - nodes = folderNode.children; - } - - nodes.push({ kind: 'change', label: change.relativePath, change }); - } - - sortNodes(root); - - function materialize(nodes) { - return nodes.map((node) => { - if (node.kind === 'folder') { - return new FolderItem(node.label, node.relativePath, materialize(node.children)); - } - return new ChangeItem(node.change); - }); - } - - return materialize(root); -} - -function countChangedPaths(repoRoot, sessions, changes) { - const changedKeys = new Set(); - - for (const change of changes || []) { - if (change?.relativePath) { - changedKeys.add(normalizeRelativePath(change.relativePath)); - } - } - - for (const session of sessions || []) { - for (const change of session.touchedChanges || []) { - const absolutePath = change?.absolutePath - || path.join(session.worktreePath || '', change?.relativePath || ''); - const normalizedRelativePath = absolutePath && isPathWithin(repoRoot, absolutePath) - ? normalizeRelativePath(path.relative(repoRoot, absolutePath)) - : `${session.branch}:${normalizeRelativePath(change?.relativePath)}`; - if (normalizedRelativePath) { - changedKeys.add(normalizedRelativePath); - } - } - } - - return changedKeys.size; -} - -function buildRepoOverview(sessions, unassignedChanges, lockEntries, colonyTasks = []) { - const colonyTaskList = Array.isArray(colonyTasks) ? colonyTasks : []; - return { - sessionCount: sessions.length, - workingCount: countWorkingSessions(sessions), - finishedCount: countFinishedSessions(sessions), - idleCount: countIdleSessions(sessions), - unassignedChangeCount: (unassignedChanges || []).length, - lockedFileCount: Array.isArray(lockEntries) ? lockEntries.length : 0, - conflictCount: sessions.reduce( - (total, session) => total + (session.conflictCount || 0), - 0, - ) + (unassignedChanges || []).filter((change) => change.hasForeignLock).length, - colonyTaskCount: colonyTaskList.length, - pendingHandoffCount: colonyTaskList.reduce( - (total, task) => total + (task.pending_handoff_count || 0), - 0, - ), - }; -} - -function groupSessionsByWorktree(sessions) { - const sessionsByWorktree = new Map(); - - for (const session of sessions || []) { - const worktreePath = sessionWorktreePath(session); - const key = worktreePath || session?.branch || `session-${sessionsByWorktree.size + 1}`; - if (!sessionsByWorktree.has(key)) { - sessionsByWorktree.set(key, { - worktreePath, - sessions: [], - }); - } - sessionsByWorktree.get(key).sessions.push(session); - } - - return [...sessionsByWorktree.values()] - .map((entry) => ({ - ...entry, - sessions: entry.sessions.sort((left, right) => ( - sessionTreeLabel(left).localeCompare(sessionTreeLabel(right)) - )), - })) - .sort((left, right) => { - const leftLabel = path.basename(left.worktreePath || '') || ''; - const rightLabel = path.basename(right.worktreePath || '') || ''; - return leftLabel.localeCompare(rightLabel) - || (left.worktreePath || '').localeCompare(right.worktreePath || ''); - }); -} - -function partitionChangesByOwnership(sessions, changes) { - const changesBySession = new Map(); - const sessionByChangedPath = new Map(); - const repoRootChanges = []; - - for (const session of sessions) { - changesBySession.set(session.branch, []); - for (const changedPath of session.changedPaths || []) { - if (!sessionByChangedPath.has(changedPath)) { - sessionByChangedPath.set(changedPath, session); - } - } - } - - for (const change of changes) { - const normalizedRelativePath = normalizeRelativePath(change.relativePath); - const session = sessionByChangedPath.get(normalizedRelativePath) - || sessions.find((candidate) => isPathWithin(candidate.worktreePath, change.absolutePath)); - if (!session) { - repoRootChanges.push(change); - continue; - } - - const localizedChange = localizeChangeForSession(session, change); - if (!localizedChange) { - repoRootChanges.push(change); - continue; - } - - changesBySession.get(session.branch).push(localizedChange); - } - - return { - changesBySession, - repoRootChanges, - }; -} - -function buildGroupedChangeTreeNodes(sessions, changes) { - const { changesBySession, repoRootChanges } = partitionChangesByOwnership(sessions, changes); - - 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), { - description: String(repoRootChanges.length), - })); - } - - return items; -} - -function countActiveSessions(sessions) { - return sessions.filter((session) => session.activityKind !== 'dead').length; -} - -function countSessionsByActivityKind(sessions, activityKind) { - return sessions.filter((session) => session.activityKind === activityKind).length; -} - -function resolveSessionActivityIconId(activityKind) { - return SESSION_ACTIVITY_ICON_IDS[activityKind] || 'loading~spin'; -} - -async function pickRepoRoot() { - const repoRoots = discoverWorkspaceRepoRoots(); - if (repoRoots.length === 0) { - vscode.window.showInformationMessage?.('Open a Guardex workspace folder to start an agent.'); - return null; - } - - if (repoRoots.length === 1) { - return repoRoots[0]; - } - - const selectedRepoRoot = preferredRepoRoot(repoRoots); - if (selectedRepoRoot) { - return selectedRepoRoot; - } - - const picks = repoRoots.map((repoRoot) => ({ - label: repoPickLabel(repoRoot), - description: repoPickDescription(repoRoot), - detail: repoRoot, - repoRoot, - })); - const selection = await vscode.window.showQuickPick?.(picks, { - placeHolder: 'Select the Git repo where the Start agent launcher should run.', - }); - return selection?.repoRoot || null; -} - -async function promptStartAgentDetails() { - const taskName = await vscode.window.showInputBox?.({ - prompt: 'Task for the Guardex agent launcher', - placeHolder: 'vscode active agents welcome view', - ignoreFocusOut: true, - validateInput: (value) => value.trim() ? undefined : 'Task is required.', - }); - if (!taskName) { - return null; - } - - const agentName = await vscode.window.showInputBox?.({ - prompt: 'Agent name for the Guardex agent launcher', - placeHolder: 'codex', - value: 'codex', - ignoreFocusOut: true, - validateInput: (value) => value.trim() ? undefined : 'Agent name is required.', - }); - if (!agentName) { - return null; - } - - return { - taskName: taskName.trim(), - agentName: agentName.trim(), - }; -} - -async function startAgentFromPrompt(refresh) { - const repoRoot = await pickRepoRoot(); - if (!repoRoot) { - return; - } - - const details = await promptStartAgentDetails(); - if (!details) { - return; - } - - const terminal = vscode.window.createTerminal?.({ - name: `GitGuardex: ${path.basename(repoRoot)}`, - cwd: repoRoot, - }); - terminal?.show(true); - terminal?.sendText(resolveStartAgentCommand(repoRoot, details), true); - refresh(); -} - -function sessionSelectionKey(session) { - if (!session?.repoRoot || !session?.branch) { - return ''; - } - - return `${session.repoRoot}::${session.branch}`; -} - -function formatGitCommandFailure(error) { - for (const value of [error?.stderr, error?.stdout, error?.message]) { - if (typeof value === 'string' && value.trim().length > 0) { - return value.trim(); - } - } - return 'Git command failed.'; -} - -function runGitCommand(worktreePath, args) { - return cp.execFileSync('git', ['-C', worktreePath, ...args], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - }); -} - -function stageWorktreeForCommit(worktreePath) { - runGitCommand(worktreePath, ['add', '-A', '--', '.', `:(exclude)${LOCK_FILE_RELATIVE}`]); -} - -function commitWorktree(worktreePath, message) { - runGitCommand(worktreePath, ['commit', '-m', 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', - }), - new DetailItem('Top files', session.topChangedFilesLabel || 'No tracked file edits.', { - iconId: 'list-flat', - }), - ]; - 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.push(new DetailItem('Provider', provider.label, { - iconId: 'rocket', - })); - } - if (snapshot) { - items.push(new DetailItem('Snapshot', snapshot, { - iconId: 'device-camera', - })); - } - 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-library', - tooltip: session.worktreePath, - })); - return items; -} - -function buildWorkingNowNodes(sessions) { - const sessionEntries = sortSessionsForWorkingNow( - sessions.filter((session) => ( - session.activityKind === 'working' || session.activityKind === 'blocked' - )), - ).map((session) => ({ - projectRelativePath: resolveSessionProjectRelativePath(session), - sessions: [session], - item: new SessionItem(session, buildSessionDetailItems(session)), - })); - return buildProjectScopedItems(sessionEntries, { rootLabel: 'Repo root' }); -} - -function buildIdleThinkingNodes(sessions) { - const sessionEntries = sortSessionsForIdleThinking( - sessions.filter((session) => !( - session.activityKind === 'working' - || session.activityKind === 'blocked' - || session.activityKind === 'finished' - )), - ).map((session) => ({ - projectRelativePath: resolveSessionProjectRelativePath(session), - sessions: [session], - item: new SessionItem(session, buildSessionDetailItems(session)), - })); - return buildProjectScopedItems(sessionEntries, { rootLabel: 'Repo root' }); -} - -function buildNeedsCleanupNodes(sessions) { - const sessionEntries = sessions - .filter((session) => session.activityKind === 'finished') - .map((session) => ({ - projectRelativePath: resolveSessionProjectRelativePath(session), - sessions: [session], - item: new SessionItem(session, buildSessionDetailItems(session)), - })); - return buildProjectScopedItems(sessionEntries, { rootLabel: 'Repo root' }); -} - -function buildUnassignedChangeNodes(changes) { - return sortUnassignedChanges(changes).map((change) => new ChangeItem(change, { - label: compactRelativePath(change.relativePath), - description: buildUnassignedChangeDescription(change), - iconId: changeNeedsWarningIcon(change) ? 'warning' : undefined, - })); -} - -function buildRawActiveAgentGroupNodes(sessions) { - const groups = []; - for (const group of SESSION_ACTIVITY_GROUPS) { - const groupSessions = sessions.filter((session) => session.activityKind === group.kind); - 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', - }, - )), - { - 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, { - iconId: resolveSessionActivityIconId(group.kind), - })); - } - } - - return groups; -} - -class ActiveAgentsProvider { - constructor(decorationProvider) { - this.decorationProvider = decorationProvider; - this.onDidChangeTreeDataEmitter = new vscode.EventEmitter(); - this.onDidChangeTreeData = this.onDidChangeTreeDataEmitter.event; - this.onDidChangeSelectedSessionEmitter = new vscode.EventEmitter(); - this.onDidChangeSelectedSession = this.onDidChangeSelectedSessionEmitter.event; - this.treeView = null; - this.lockRegistryByRepoRoot = new Map(); - this.selectedSession = null; - this.viewSummary = { - sessionCount: 0, - workingCount: 0, - finishedCount: 0, - idleCount: 0, - unassignedChangeCount: 0, - lockedFileCount: 0, - deadCount: 0, - conflictCount: 0, - }; - this.previousSnapshot = null; - } - - getTreeItem(element) { - return element; - } - - attachTreeView(treeView) { - this.treeView = treeView; - this.updateViewState({ - sessionCount: 0, - workingCount: 0, - finishedCount: 0, - idleCount: 0, - unassignedChangeCount: 0, - lockedFileCount: 0, - deadCount: 0, - conflictCount: 0, - }); - treeView.onDidChangeSelection?.((event) => { - const sessionItem = event.selection.find((item) => item instanceof SessionItem); - this.setSelectedSession(sessionItem?.session || null); - }); - } - - setSelectedSession(session) { - const nextSession = session?.worktreePath ? { ...session } : null; - const currentKey = sessionSelectionKey(this.selectedSession); - const nextKey = sessionSelectionKey(nextSession); - this.selectedSession = nextSession; - this.decorationProvider?.setSelectedBranch(nextSession?.branch || ''); - if (currentKey !== nextKey) { - this.onDidChangeSelectedSessionEmitter.fire(this.selectedSession); - } - } - - getSelectedSession() { - return this.selectedSession ? { ...this.selectedSession } : null; - } - - getViewSummary() { - return { ...this.viewSummary }; - } - - syncSelectedSession(repoEntries) { - if (!this.selectedSession) { - return; - } - - const nextSession = repoEntries - .flatMap((entry) => entry.sessions) - .find((session) => sessionSelectionKey(session) === sessionSelectionKey(this.selectedSession)); - this.setSelectedSession(nextSession || null); - } - - updateViewState(summary) { - if (!this.treeView) { - return; - } - - const sessionCount = summary?.sessionCount || 0; - const conflictCount = summary?.conflictCount || 0; - this.viewSummary = { ...summary }; - void vscode.commands.executeCommand('setContext', 'guardex.hasAgents', sessionCount > 0); - void vscode.commands.executeCommand('setContext', 'guardex.hasConflicts', conflictCount > 0); - - this.treeView.badge = sessionCount > 0 - ? { - value: sessionCount, - tooltip: buildOverviewDescription(summary), - } - : undefined; - this.treeView.message = undefined; - } - - annotateRepoEntries(repoEntries) { - const hasPreviousSnapshot = Boolean(this.previousSnapshot); - const nextSnapshot = { - sessions: new Map(), - changes: new Map(), - }; - - const annotatedEntries = repoEntries.map((entry) => { - const sessions = entry.sessions.map((session) => { - const snapshotKey = sessionSnapshotKey(session); - nextSnapshot.sessions.set(snapshotKey, buildSessionSnapshot(session)); - const deltaLabel = hasPreviousSnapshot - ? deriveSessionDelta(this.previousSnapshot.sessions.get(snapshotKey), session) - : ''; - return { - ...session, - deltaLabel, - riskBadges: uniqueStringList([ - ...(session.riskBadges || []), - deltaLabel, - ].filter(Boolean)), - }; - }); - - const changes = entry.changes.map((change) => { - const snapshotKey = changeSnapshotKey(entry.repoRoot, change); - nextSnapshot.changes.set(snapshotKey, buildChangeSnapshot(change)); - const deltaLabel = hasPreviousSnapshot - ? deriveChangeDelta(this.previousSnapshot.changes.get(snapshotKey), change) - : ''; - return { - ...change, - deltaLabel, - riskBadges: changeRiskBadges({ - ...change, - deltaLabel, - }), - }; - }); - - const { repoRootChanges } = partitionChangesByOwnership(sessions, changes); - const unassignedChanges = sortUnassignedChanges(repoRootChanges); - const colonyTasks = Array.isArray(entry.colonyTasks) ? entry.colonyTasks : []; - return { - ...entry, - sessions, - changes, - unassignedChanges, - colonyTasks, - overview: buildRepoOverview(sessions, unassignedChanges, entry.lockEntries, colonyTasks), - }; - }); - - this.previousSnapshot = nextSnapshot; - return annotatedEntries; - } - - async syncRepoEntries() { - const repoEntries = this.annotateRepoEntries(await this.loadRepoEntries()); - const summary = { - sessionCount: repoEntries.reduce((total, entry) => total + entry.sessions.length, 0), - workingCount: repoEntries.reduce((total, entry) => total + entry.overview.workingCount, 0), - finishedCount: repoEntries.reduce( - (total, entry) => total + (entry.overview.finishedCount || 0), - 0, - ), - idleCount: repoEntries.reduce((total, entry) => total + entry.overview.idleCount, 0), - unassignedChangeCount: repoEntries.reduce( - (total, entry) => total + entry.overview.unassignedChangeCount, - 0, - ), - lockedFileCount: repoEntries.reduce((total, entry) => total + entry.overview.lockedFileCount, 0), - deadCount: repoEntries.reduce( - (total, entry) => total + countSessionsByActivityKind(entry.sessions, 'dead'), - 0, - ), - conflictCount: repoEntries.reduce((total, entry) => total + entry.overview.conflictCount, 0), - }; - - this.updateViewState(summary); - this.decorationProvider?.updateSessions(repoEntries.flatMap((entry) => entry.sessions)); - this.decorationProvider?.updateLockEntries(repoEntries); - return repoEntries; - } - - async refresh() { - await this.syncRepoEntries(); - this.onDidChangeTreeDataEmitter.fire(); - this.decorationProvider?.refresh(); - } - - readLockRegistryForRepo(repoRoot) { - const lockRegistry = readLockRegistry(repoRoot); - this.lockRegistryByRepoRoot.set(repoRoot, lockRegistry); - return lockRegistry; - } - - getLockRegistryForRepo(repoRoot) { - return this.lockRegistryByRepoRoot.get(repoRoot) || this.readLockRegistryForRepo(repoRoot); - } - - refreshLockRegistryForFile(filePath) { - this.readLockRegistryForRepo(repoRootFromLockFile(filePath)); - } - - async getChildren(element) { - if (element instanceof RepoItem) { - const sectionItems = [ - new SectionItem('Overview', [ - new DetailItem('Summary', buildOverviewDescription(element.overview), { - iconId: 'dashboard', - tooltip: buildRepoTooltip(element.repoRoot, element.overview), - }), - ], { - description: '1', - iconId: 'telescope', - }), - ]; - - const workingNowItems = buildWorkingNowNodes(element.sessions); - if (workingNowItems.length > 0) { - sectionItems.push(new SectionItem('Working now', workingNowItems, { - description: String(workingNowItems.length), - collapsedState: vscode.TreeItemCollapsibleState.Collapsed, - iconId: 'loading~spin', - })); - } - - const needsCleanupItems = buildNeedsCleanupNodes(element.sessions); - if (needsCleanupItems.length > 0) { - sectionItems.push(new SectionItem('Needs cleanup', needsCleanupItems, { - description: String(needsCleanupItems.length), - collapsedState: vscode.TreeItemCollapsibleState.Collapsed, - iconId: 'pass-filled', - })); - } - - const idleThinkingItems = buildIdleThinkingNodes(element.sessions); - if (idleThinkingItems.length > 0) { - sectionItems.push(new SectionItem('Idle / thinking', idleThinkingItems, { - description: String(idleThinkingItems.length), - collapsedState: vscode.TreeItemCollapsibleState.Collapsed, - iconId: 'debug-pause', - })); - } - - if (element.unassignedChanges.length > 0) { - sectionItems.push(new SectionItem('Unassigned changes', buildUnassignedChangeNodes(element.unassignedChanges), { - description: String(element.unassignedChanges.length), - iconId: 'inbox', - })); - } - - const advancedItems = []; - const rawActiveAgents = buildRawActiveAgentGroupNodes(element.sessions); - if (rawActiveAgents.length > 0) { - advancedItems.push(new SectionItem('Active agent tree', rawActiveAgents, { - description: String(element.sessions.length), - collapsedState: vscode.TreeItemCollapsibleState.Collapsed, - iconId: 'organization', - })); - } - const rawChangeTree = buildGroupedChangeTreeNodes(element.sessions, element.changes); - if (rawChangeTree.length > 0) { - advancedItems.push(new SectionItem('Raw path tree', rawChangeTree, { - description: String(element.changes.length), - collapsedState: vscode.TreeItemCollapsibleState.Collapsed, - iconId: 'file-directory', - })); - } - const colonyTaskList = Array.isArray(element.colonyTasks) ? element.colonyTasks : []; - if (colonyTaskList.length > 0) { - const colonyItems = colonyTaskList.map((task) => { - const pendingLabel = task.pending_handoff_count > 0 - ? formatCountLabel(task.pending_handoff_count, 'pending handoff') - : 'quiet'; - const participantLabel = - (task.participants || []).map((p) => p.agent).filter(Boolean).join(', ') - || 'no participants'; - return new DetailItem( - `#${task.id} · ${compactColonyBranchLabel(task.branch)}`, - `${participantLabel} · ${pendingLabel}`, - { - iconId: task.pending_handoff_count > 0 ? 'warning' : 'comment-discussion', - tooltip: [ - task.branch, - `task #${task.id}`, - participantLabel, - task.pending_handoff_count > 0 - ? formatCountLabel(task.pending_handoff_count, 'pending handoff') - : '', - ].filter(Boolean).join('\n'), - }, - ); - }); - advancedItems.push(new SectionItem('Colony tasks', colonyItems, { - description: String(colonyItems.length), - collapsedState: vscode.TreeItemCollapsibleState.Collapsed, - iconId: 'organization', - })); - } - if (advancedItems.length > 0) { - sectionItems.push(new SectionItem('Advanced details', advancedItems, { - description: String(advancedItems.length), - collapsedState: vscode.TreeItemCollapsibleState.Collapsed, - iconId: 'settings-gear', - })); - } - return sectionItems; - } - - if (element instanceof SectionItem || element instanceof FolderItem || element instanceof WorktreeItem || element instanceof SessionItem) { - return element.items; - } - - const repoEntries = await this.syncRepoEntries(); - this.syncSelectedSession(repoEntries); - - if (repoEntries.length === 0) { - return [new InfoItem('No active Guardex agents', 'Open or start a sandbox session.')]; - } - - return repoEntries.map((entry) => new RepoItem(entry.repoRoot, entry.sessions, entry.changes, { - label: repoEntryDisplayLabel(entry.repoRoot, entry.sessions), - overview: entry.overview, - unassignedChanges: entry.unassignedChanges, - lockEntries: entry.lockEntries, - colonyTasks: entry.colonyTasks, - })); - } - - async loadRepoEntries() { - const repoEntries = await findRepoSessionEntries(); - return Promise.all( - repoEntries.map(async (entry) => { - const repoRoot = entry.repoRoot; - const lockRegistry = this.getLockRegistryForRepo(repoRoot); - const currentBranch = readCurrentBranch(repoRoot); - const colonyTasks = await readColonyTasksForRepo(repoRoot); - return { - repoRoot, - sessions: entry.sessions.map((session) => decorateSession(session, lockRegistry)), - changes: readRepoChanges(repoRoot).map((change) => ( - decorateChange(change, lockRegistry, currentBranch) - )), - lockEntries: Array.from(lockRegistry.entriesByPath.entries()), - colonyTasks, - }; - }), - ); - } -} - -function countEntryConflicts(entry) { - const sessionConflicts = entry.sessions.reduce( - (total, session) => total + (session.conflictCount || 0), - 0, - ); - const changeConflicts = entry.changes.filter((change) => change.hasForeignLock).length; - return sessionConflicts + changeConflicts; -} - -class SessionInspectPanelManager { - constructor() { - this.panel = null; - this.session = null; - } - - open(session) { - const targetSession = session?.branch ? { ...session } : null; - if (!targetSession?.repoRoot || !targetSession?.branch) { - showSessionMessage('Pick an Active Agents session first.'); - return; - } - if (!vscode.window.createWebviewPanel) { - showSessionMessage('Inspect panel is unavailable in this VS Code build.'); - return; - } - - this.session = targetSession; - if (!this.panel) { - this.panel = vscode.window.createWebviewPanel( - INSPECT_PANEL_VIEW_TYPE, - inspectPanelTitle(targetSession), - vscode.ViewColumn?.Beside, - { - enableFindWidget: true, - enableScripts: false, - retainContextWhenHidden: true, - }, - ); - this.panel.onDidDispose(() => { - this.panel = null; - this.session = null; - }); - } else { - this.panel.reveal?.(vscode.ViewColumn?.Beside); - } - - this.render(); - } - - resolveSession() { - if (!this.session?.repoRoot || !this.session?.branch) { - return this.session ? { ...this.session } : null; - } - - return readActiveSessions(this.session.repoRoot, { includeStale: true }) - .find((entry) => sessionSelectionKey(entry) === sessionSelectionKey(this.session)) - || { ...this.session }; - } - - render() { - if (!this.panel || !this.session) { - return; - } - - const session = this.resolveSession(); - if (!session) { - return; - } - - this.session = { ...session }; - this.panel.title = inspectPanelTitle(session); - this.panel.webview.html = renderInspectPanelHtml(session, readSessionInspectData(session)); - } - - refresh() { - this.render(); - } - - dispose() { - this.panel?.dispose(); - this.panel = null; - this.session = null; - } -} - -class ActiveAgentsRefreshController { - constructor(provider, inspectPanelManager = null) { - this.provider = provider; - this.inspectPanelManager = inspectPanelManager; - this.refreshTimer = null; - this.sessionWatchers = new Map(); - this.closedMissingWorktreeRepositories = new Set(); - this.observedWorktreePaths = new Set(); - } - - scheduleRefresh() { - if (this.refreshTimer) { - clearTimeout(this.refreshTimer); - } - this.refreshTimer = setTimeout(() => { - this.refreshTimer = null; - void this.refreshNow(); - }, REFRESH_DEBOUNCE_MS); - } - - async refreshNow() { - await this.syncSessionWatchers(); - await this.provider.refresh(); - this.inspectPanelManager?.refresh(); - } - - async syncSessionWatchers() { - const repoEntries = await findRepoSessionEntries(); - const liveSessionKeys = new Set(); - - for (const workspacePath of findDeletedManagedWorkspaceFolders()) { - await this.closeMissingWorktreeRepository(workspacePath); - } - - for (const entry of repoEntries) { - for (const session of entry.sessions) { - const worktreePath = sessionWorktreePath(session); - const normalizedWorktreePath = normalizeAbsolutePath(worktreePath); - if (normalizedWorktreePath && !fs.existsSync(normalizedWorktreePath)) { - await this.closeMissingWorktreeRepository(normalizedWorktreePath); - continue; - } - if (normalizedWorktreePath) { - this.closedMissingWorktreeRepositories.delete(normalizedWorktreePath); - this.observedWorktreePaths.add(normalizedWorktreePath); - } - - const sessionKey = resolveSessionWatcherKey(session); - liveSessionKeys.add(sessionKey); - if (this.sessionWatchers.has(sessionKey)) { - continue; - } - - const watcher = vscode.workspace.createFileSystemWatcher( - resolveSessionGitIndexPath(session.worktreePath), - ); - const disposables = bindRefreshWatcher(watcher, () => this.scheduleRefresh()); - this.sessionWatchers.set(sessionKey, { - watcher, - disposables, - worktreePath: normalizedWorktreePath, - }); - } - } - - for (const observedWorktreePath of this.observedWorktreePaths) { - if (fs.existsSync(observedWorktreePath)) { - this.closedMissingWorktreeRepositories.delete(observedWorktreePath); - continue; - } - await this.closeMissingWorktreeRepository(observedWorktreePath); - } - - for (const [sessionKey, entry] of this.sessionWatchers) { - if (liveSessionKeys.has(sessionKey)) { - continue; - } - - if (entry.worktreePath && !fs.existsSync(entry.worktreePath)) { - await this.closeMissingWorktreeRepository(entry.worktreePath); - } - disposeAll(entry.disposables); - entry.watcher.dispose(); - this.sessionWatchers.delete(sessionKey); - } - } - - async closeMissingWorktreeRepository(worktreePath) { - const normalizedWorktreePath = normalizeAbsolutePath(worktreePath); - if (!normalizedWorktreePath || this.closedMissingWorktreeRepositories.has(normalizedWorktreePath)) { - return; - } - - this.closedMissingWorktreeRepositories.add(normalizedWorktreePath); - await closeDeletedWorktreeRepository(normalizedWorktreePath); - } - - dispose() { - if (this.refreshTimer) { - clearTimeout(this.refreshTimer); - this.refreshTimer = null; - } - - for (const entry of this.sessionWatchers.values()) { - disposeAll(entry.disposables); - entry.watcher.dispose(); - } - this.sessionWatchers.clear(); - } -} - -function activate(context) { - const decorationProvider = new SessionDecorationProvider(); - const provider = new ActiveAgentsProvider(decorationProvider); - const inspectPanelManager = new SessionInspectPanelManager(); - const refreshController = new ActiveAgentsRefreshController(provider, inspectPanelManager); - const treeView = vscode.window.createTreeView('gitguardex.activeAgents', { - treeDataProvider: provider, - showCollapseAll: true, - }); - const activeAgentsStatusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 10); - activeAgentsStatusItem.name = 'GitGuardex Active Agents'; - activeAgentsStatusItem.command = 'gitguardex.activeAgents.focus'; - provider.attachTreeView(treeView); - const scheduleRefresh = () => refreshController.scheduleRefresh(); - const handleWorkspaceFoldersChanged = () => { - scheduleRefresh(); - void ensureManagedRepoScanIgnores(); - }; - const refresh = () => void refreshController.refreshNow(); - const activeSessionsWatcher = vscode.workspace.createFileSystemWatcher(ACTIVE_SESSION_FILES_GLOB); - const lockWatcher = vscode.workspace.createFileSystemWatcher(AGENT_FILE_LOCKS_GLOB); - const worktreeLockWatcher = vscode.workspace.createFileSystemWatcher(WORKTREE_AGENT_LOCKS_GLOB); - const managedWorktreeGitWatcher = vscode.workspace.createFileSystemWatcher(MANAGED_WORKTREE_GIT_FILES_GLOB); - const logWatcher = vscode.workspace.createFileSystemWatcher(AGENT_LOG_FILES_GLOB); - const updateStatusBar = () => { - const selectedSession = provider.getSelectedSession(); - const summary = provider.getViewSummary(); - if ((summary.sessionCount || 0) <= 0) { - activeAgentsStatusItem.hide(); - return; - } - - activeAgentsStatusItem.text = selectedSession?.branch - ? `$(git-branch) ${sessionIdentityLabel(selectedSession)} · ${formatCountLabel(selectedSession.lockCount || 0, 'lock')}` - : buildActiveAgentsStatusSummary(summary); - activeAgentsStatusItem.tooltip = buildActiveAgentsStatusTooltip(selectedSession, summary); - activeAgentsStatusItem.show(); - }; - updateStatusBar(); - const readCommitMessageForSession = async (session) => { - const rawMessage = await vscode.window.showInputBox?.({ - prompt: `Commit ${sessionIdentityLabel(session)} worktree`, - placeHolder: sessionCommitPlaceholder(session), - ignoreFocusOut: true, - }); - if (rawMessage === undefined) { - return undefined; - } - return String(rawMessage).trim(); - }; - const commitSelectedSession = async () => { - const selectedSession = provider.getSelectedSession(); - if (!selectedSession?.worktreePath) { - vscode.window.showInformationMessage?.('Pick an Active Agents session first.'); - return; - } - - if (!fs.existsSync(selectedSession.worktreePath)) { - vscode.window.showInformationMessage?.( - `Selected session worktree is no longer on disk: ${selectedSession.worktreePath}`, - ); - return; - } - - const message = await readCommitMessageForSession(selectedSession); - if (message === undefined) { - return; - } - if (!message) { - vscode.window.showInformationMessage?.('Enter a commit message first.'); - return; - } - - try { - stageWorktreeForCommit(selectedSession.worktreePath); - commitWorktree(selectedSession.worktreePath, message); - refresh(); - } catch (error) { - const failure = formatGitCommandFailure(error); - if (/nothing to commit|no changes added to commit/i.test(failure)) { - vscode.window.showInformationMessage?.(`No changes to commit in ${selectedSession.label}.`); - return; - } - vscode.window.showErrorMessage?.(`Active Agents commit failed: ${failure}`); - } - }; - const interval = setInterval(refresh, REFRESH_POLL_INTERVAL_MS); - const refreshLockRegistry = (uri) => { - if (uri?.fsPath) { - provider.refreshLockRegistryForFile(uri.fsPath); - } - scheduleRefresh(); - }; - - provider.onDidChangeSelectedSession((session) => { - updateStatusBar(); - decorationProvider.refresh(); - }); - provider.onDidChangeTreeData(() => { - updateStatusBar(); - }); - - context.subscriptions.push( - treeView, - activeAgentsStatusItem, - inspectPanelManager, - refreshController, - vscode.window.registerFileDecorationProvider(decorationProvider), - vscode.commands.registerCommand('gitguardex.activeAgents.startAgent', () => startAgentFromPrompt(refresh)), - vscode.commands.registerCommand('gitguardex.activeAgents.refresh', refresh), - vscode.commands.registerCommand('gitguardex.activeAgents.restart', restartActiveAgents), - vscode.commands.registerCommand('gitguardex.activeAgents.focus', async () => { - await vscode.commands.executeCommand('workbench.view.extension.gitguardex-active-agents-container'); - }), - vscode.commands.registerCommand('gitguardex.activeAgents.commitSelectedSession', commitSelectedSession), - vscode.commands.registerCommand('gitguardex.activeAgents.openWorktree', async (session) => { - if (!session?.worktreePath) { - return; - } - - await vscode.commands.executeCommand( - 'vscode.openFolder', - vscode.Uri.file(session.worktreePath), - { forceNewWindow: true }, - ); - }), - vscode.commands.registerCommand('gitguardex.activeAgents.openChange', async (change) => { - if (!change?.absolutePath) { - return; - } - - if (!fs.existsSync(change.absolutePath)) { - vscode.window.showInformationMessage?.(`Changed path is no longer on disk: ${change.relativePath}`); - return; - } - - await vscode.commands.executeCommand('vscode.open', vscode.Uri.file(change.absolutePath)); - }), - vscode.commands.registerCommand('gitguardex.activeAgents.inspect', (session) => { - inspectPanelManager.open(session || provider.getSelectedSession()); - }), - vscode.commands.registerCommand('gitguardex.activeAgents.showSessionTerminal', showSessionTerminal), - vscode.commands.registerCommand('gitguardex.activeAgents.finishSession', finishSession), - vscode.commands.registerCommand('gitguardex.activeAgents.syncSession', syncSession), - vscode.commands.registerCommand('gitguardex.activeAgents.stopSession', (session) => stopSession(session, refresh)), - vscode.commands.registerCommand('gitguardex.activeAgents.dismissSession', (session) => dismissSession(session, refresh)), - vscode.workspace.onDidChangeWorkspaceFolders(handleWorkspaceFoldersChanged), - activeSessionsWatcher, - lockWatcher, - worktreeLockWatcher, - managedWorktreeGitWatcher, - logWatcher, - { dispose: () => clearInterval(interval) }, - ); - - context.subscriptions.push( - ...bindRefreshWatcher(activeSessionsWatcher, scheduleRefresh), - ...bindRefreshWatcher(lockWatcher, refreshLockRegistry), - ...bindRefreshWatcher(worktreeLockWatcher, scheduleRefresh), - ...bindRefreshWatcher(managedWorktreeGitWatcher, scheduleRefresh), - ...bindRefreshWatcher(logWatcher, scheduleRefresh), - ); - void ensureManagedRepoScanIgnores(); - void refreshController.refreshNow(); - void maybeAutoUpdateActiveAgentsExtension(context); -} - -function deactivate() {} - -module.exports = { - activate, - deactivate, -}; diff --git a/templates/vscode/guardex-active-agents/fileicons/gitguardex-fileicons.json b/templates/vscode/guardex-active-agents/fileicons/gitguardex-fileicons.json deleted file mode 100644 index e8e59681..00000000 --- a/templates/vscode/guardex-active-agents/fileicons/gitguardex-fileicons.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "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 deleted file mode 100644 index f29ca828..00000000 --- a/templates/vscode/guardex-active-agents/fileicons/icons/agent.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/templates/vscode/guardex-active-agents/fileicons/icons/branch.svg b/templates/vscode/guardex-active-agents/fileicons/icons/branch.svg deleted file mode 100644 index 62242793..00000000 --- a/templates/vscode/guardex-active-agents/fileicons/icons/branch.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/templates/vscode/guardex-active-agents/fileicons/icons/config.svg b/templates/vscode/guardex-active-agents/fileicons/icons/config.svg deleted file mode 100644 index 6b4e2f9c..00000000 --- a/templates/vscode/guardex-active-agents/fileicons/icons/config.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/templates/vscode/guardex-active-agents/fileicons/icons/hook.svg b/templates/vscode/guardex-active-agents/fileicons/icons/hook.svg deleted file mode 100644 index 384987c2..00000000 --- a/templates/vscode/guardex-active-agents/fileicons/icons/hook.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/templates/vscode/guardex-active-agents/fileicons/icons/openspec.svg b/templates/vscode/guardex-active-agents/fileicons/icons/openspec.svg deleted file mode 100644 index 8cc93ff9..00000000 --- a/templates/vscode/guardex-active-agents/fileicons/icons/openspec.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/templates/vscode/guardex-active-agents/fileicons/icons/plan.svg b/templates/vscode/guardex-active-agents/fileicons/icons/plan.svg deleted file mode 100644 index 15255686..00000000 --- a/templates/vscode/guardex-active-agents/fileicons/icons/plan.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/templates/vscode/guardex-active-agents/fileicons/icons/spec.svg b/templates/vscode/guardex-active-agents/fileicons/icons/spec.svg deleted file mode 100644 index 7b3da2be..00000000 --- a/templates/vscode/guardex-active-agents/fileicons/icons/spec.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/templates/vscode/guardex-active-agents/icon.png b/templates/vscode/guardex-active-agents/icon.png deleted file mode 100644 index e0b97505..00000000 Binary files a/templates/vscode/guardex-active-agents/icon.png and /dev/null differ diff --git a/templates/vscode/guardex-active-agents/media/active-agents-hivemind.svg b/templates/vscode/guardex-active-agents/media/active-agents-hivemind.svg deleted file mode 100644 index dd24e091..00000000 --- a/templates/vscode/guardex-active-agents/media/active-agents-hivemind.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - diff --git a/templates/vscode/guardex-active-agents/package.json b/templates/vscode/guardex-active-agents/package.json deleted file mode 100644 index c725ca6a..00000000 --- a/templates/vscode/guardex-active-agents/package.json +++ /dev/null @@ -1,169 +0,0 @@ -{ - "name": "gitguardex-active-agents", - "displayName": "GitGuardex Active Agents", - "description": "Shows live Guardex sandbox sessions and repo changes in a dedicated VS Code Active Agents sidebar.", - "publisher": "Recodee", - "version": "0.0.21", - "license": "MIT", - "icon": "icon.png", - "engines": { - "vscode": "^1.88.0" - }, - "categories": [ - "SCM Providers", - "Other" - ], - "activationEvents": [ - "onStartupFinished", - "workspaceContains:.omx/state/active-sessions", - "workspaceContains:.omx/agent-worktrees", - "workspaceContains:.omc/agent-worktrees", - "onView:gitguardex.activeAgents" - ], - "main": "./extension.js", - "contributes": { - "commands": [ - { - "command": "gitguardex.activeAgents.startAgent", - "title": "Start Guardex Agent" - }, - { - "command": "gitguardex.activeAgents.refresh", - "title": "Refresh Active Agents" - }, - { - "command": "gitguardex.activeAgents.restart", - "title": "Restart Active Agents", - "icon": "$(debug-restart)" - }, - { - "command": "gitguardex.activeAgents.commitSelectedSession", - "title": "Commit Selected Session", - "icon": "$(check)" - }, - { - "command": "gitguardex.activeAgents.inspect", - "title": "Inspect Session", - "icon": "$(info)" - }, - { - "command": "gitguardex.activeAgents.openWorktree", - "title": "Open Agent Worktree" - }, - { - "command": "gitguardex.activeAgents.finishSession", - "title": "Finish", - "icon": "$(check)" - }, - { - "command": "gitguardex.activeAgents.syncSession", - "title": "Sync", - "icon": "$(sync)" - }, - { - "command": "gitguardex.activeAgents.stopSession", - "title": "Stop", - "icon": "$(debug-stop)" - }, - { - "command": "gitguardex.activeAgents.dismissSession", - "title": "Dismiss", - "icon": "$(trash)" - }, - { - "command": "gitguardex.activeAgents.showSessionTerminal", - "title": "Show Terminal", - "icon": "$(terminal)" - } - ], - "viewsContainers": { - "activitybar": [ - { - "id": "gitguardex-active-agents-container", - "title": "Active Agents", - "icon": "media/active-agents-hivemind.svg" - } - ] - }, - "views": { - "gitguardex-active-agents-container": [ - { - "id": "gitguardex.activeAgents", - "name": "Active Agents", - "contextualTitle": "Active Agents", - "icon": "media/active-agents-hivemind.svg", - "visibility": "visible" - } - ] - }, - "viewsWelcome": [ - { - "view": "gitguardex.activeAgents", - "contents": "No live Guardex agents are visible in this workspace yet.\n\nThis sidebar tracks Guardex session files and managed worktree telemetry without taking over Source Control.\n\n[Start agent](command:gitguardex.activeAgents.startAgent)\n[Open guide](https://github.com/recodeee/gitguardex/blob/main/vscode/guardex-active-agents/README.md#quick-start)\n[Refresh](command:gitguardex.activeAgents.refresh)" - } - ], - "menus": { - "view/title": [ - { - "command": "gitguardex.activeAgents.commitSelectedSession", - "when": "view == gitguardex.activeAgents && guardex.hasAgents", - "group": "navigation@1" - }, - { - "command": "gitguardex.activeAgents.restart", - "when": "view == gitguardex.activeAgents", - "group": "navigation@8" - }, - { - "command": "gitguardex.activeAgents.refresh", - "when": "view == gitguardex.activeAgents", - "group": "navigation@9" - } - ], - "extension/context": [ - { - "command": "gitguardex.activeAgents.restart", - "when": "extension == Recodee.gitguardex-active-agents && extensionStatus == installed", - "group": "2_configure@2" - } - ], - "view/item/context": [ - { - "command": "gitguardex.activeAgents.openWorktree", - "when": "view == gitguardex.activeAgents && viewItem =~ /^gitguardex\\.session(\\.|$)/", - "group": "inline" - }, - { - "command": "gitguardex.activeAgents.inspect", - "when": "view == gitguardex.activeAgents && viewItem =~ /^gitguardex\\.session(\\.|$)/", - "group": "inline" - }, - { - "command": "gitguardex.activeAgents.showSessionTerminal", - "when": "view == gitguardex.activeAgents && viewItem =~ /^gitguardex\\.session(\\.|$)/", - "group": "inline" - }, - { - "command": "gitguardex.activeAgents.finishSession", - "when": "view == gitguardex.activeAgents && viewItem =~ /^gitguardex\\.session(\\.|$)/", - "group": "inline" - }, - { - "command": "gitguardex.activeAgents.syncSession", - "when": "view == gitguardex.activeAgents && viewItem =~ /^gitguardex\\.session(\\.|$)/", - "group": "inline" - }, - { - "command": "gitguardex.activeAgents.stopSession", - "when": "view == gitguardex.activeAgents && viewItem =~ /^gitguardex\\.session(\\.|$)/", - "group": "inline" - }, - { - "command": "gitguardex.activeAgents.dismissSession", - "when": "view == gitguardex.activeAgents && viewItem =~ /^gitguardex\\.session\\.(stalled|dead)$/", - "group": "inline" - } - ] - } - } -} diff --git a/templates/vscode/guardex-active-agents/session-schema.js b/templates/vscode/guardex-active-agents/session-schema.js deleted file mode 100644 index 5d2b22c0..00000000 --- a/templates/vscode/guardex-active-agents/session-schema.js +++ /dev/null @@ -1,1348 +0,0 @@ -const fs = require('node:fs'); -const path = require('node:path'); -const cp = require('node:child_process'); - -const ACTIVE_SESSIONS_RELATIVE_DIR = path.join('.omx', 'state', 'active-sessions'); -const SESSION_SCHEMA_VERSION = 1; -const LOCK_FILE_RELATIVE = path.join('.omx', 'state', 'agent-file-locks.json'); -const LOGS_RELATIVE_DIR = path.join('.omx', 'logs'); -const AGENT_WORKTREE_LOCK_FILE = 'AGENT.lock'; -const MANAGED_WORKTREE_ROOTS = [ - path.join('.omx', 'agent-worktrees'), - path.join('.omc', 'agent-worktrees'), -]; -const MAX_CHANGED_PATH_PREVIEW = 3; -const ACTIVE_SESSIONS_FILTER_PREFIX = ACTIVE_SESSIONS_RELATIVE_DIR.split(path.sep).join('/'); -const LOCK_FILE_FILTER_PATH = LOCK_FILE_RELATIVE.split(path.sep).join('/'); -const MANAGED_WORKTREE_FILTER_PREFIXES = MANAGED_WORKTREE_ROOTS - .map((relativeRoot) => relativeRoot.split(path.sep).join('/').replace(/\/+$/, '')); -const IDLE_ACTIVITY_WINDOW_MS = 2 * 60 * 1000; -const STALLED_ACTIVITY_WINDOW_MS = 15 * 60 * 1000; -const HEARTBEAT_STALE_MS = 5 * 60 * 1000; -const DEFAULT_BASE_BRANCH = 'dev'; -const DEFAULT_LOG_TAIL_LINE_COUNT = 200; -const ADVISORY_SESSION_STATES = new Set(['working', 'thinking', 'idle']); -const WORKTREE_ACTIVITY_CACHE_TTL_MS = 3_000; -const MAX_WORKTREE_ACTIVITY_STAT_PATHS = 200; -const WORKTREE_ACTIVITY_SKIP_PREFIXES = [ - '.git/', - '.omx/', - '.omc/', - 'node_modules/', - 'dist/', - 'build/', - 'coverage/', - '.next/', - 'out/', - 'vendor/', -]; -const WORKTREE_ACTIVITY_PRIORITY_PREFIXES = [ - 'src/', - 'app/', - 'apps/', - 'lib/', - 'packages/', - 'scripts/', - 'test/', - 'tests/', - 'vscode/', - 'templates/', - 'openspec/', - 'docs/', -]; -const BLOCKING_GIT_STATES = [ - { - label: 'Rebase in progress.', - markers: ['REBASE_HEAD', 'rebase-apply', 'rebase-merge'], - }, - { - label: 'Merge in progress.', - markers: ['MERGE_HEAD'], - }, - { - label: 'Cherry-pick in progress.', - markers: ['CHERRY_PICK_HEAD'], - }, -]; -const worktreeActivityCache = new Map(); - -function toNonEmptyString(value, fallback = '') { - const normalized = typeof value === 'string' ? value.trim() : String(value || '').trim(); - return normalized || fallback; -} - -function toPositiveInteger(value) { - const normalized = Number.parseInt(String(value || ''), 10); - 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 : ''; -} - -function normalizeOpenSpecTier(value) { - const normalized = toNonEmptyString(value).toUpperCase(); - return ['T0', 'T1', 'T2', 'T3'].includes(normalized) ? normalized : ''; -} - -function normalizeAdvisoryState(value, fallback = 'working') { - const normalized = toNonEmptyString(value).toLowerCase(); - return ADVISORY_SESSION_STATES.has(normalized) ? normalized : fallback; -} - -function sanitizeBranchForFile(branch) { - const normalized = toNonEmptyString(branch, 'session'); - return normalized.replace(/[^a-zA-Z0-9._-]+/g, '__').replace(/^_+|_+$/g, '') || 'session'; -} - -function sessionFileNameForBranch(branch) { - return `${sanitizeBranchForFile(branch)}.json`; -} - -function activeSessionsDirForRepo(repoRoot) { - return path.join(path.resolve(repoRoot), ACTIVE_SESSIONS_RELATIVE_DIR); -} - -function sessionFilePathForBranch(repoRoot, branch) { - return path.join(activeSessionsDirForRepo(repoRoot), sessionFileNameForBranch(branch)); -} - -function resolveManagedWorktreeRoots(repoRoot) { - return MANAGED_WORKTREE_ROOTS.map((relativeRoot) => path.join(path.resolve(repoRoot), relativeRoot)); -} - -function splitOutputLines(output) { - if (typeof output !== 'string') { - return null; - } - - return output - .split(/\r?\n/) - .filter((line) => line.trim().length > 0); -} - -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')); - } catch (_error) { - return null; - } -} - -function readConfiguredBaseBranch(repoRoot) { - const lines = runGitLines(path.resolve(repoRoot), ['config', '--get', 'multiagent.baseBranch']); - if (Array.isArray(lines) && typeof lines[0] === 'string' && lines[0].trim()) { - return lines[0].trim(); - } - return DEFAULT_BASE_BRANCH; -} - -function readAheadBehindCounts(worktreePath, branch, baseBranch) { - const normalizedWorktreePath = toNonEmptyString(worktreePath); - const normalizedBranch = toNonEmptyString(branch); - const normalizedBaseBranch = toNonEmptyString(baseBranch, DEFAULT_BASE_BRANCH); - const compareRef = `origin/${normalizedBaseBranch}`; - - if (!normalizedWorktreePath || !normalizedBranch) { - return { - compareRef, - aheadCount: null, - behindCount: null, - }; - } - - const lines = runGitLines(normalizedWorktreePath, [ - 'rev-list', - '--left-right', - '--count', - `${normalizedBranch}...${compareRef}`, - ]); - const match = Array.isArray(lines) && typeof lines[0] === 'string' - ? lines[0].trim().match(/^(\d+)\s+(\d+)$/) - : null; - if (!match) { - return { - compareRef, - aheadCount: null, - behindCount: null, - }; - } - - return { - compareRef, - aheadCount: Number.parseInt(match[1], 10), - behindCount: Number.parseInt(match[2], 10), - }; -} - -function sessionLogPath(repoRoot, branch) { - const normalizedRepoRoot = toNonEmptyString(repoRoot); - const normalizedBranch = toNonEmptyString(branch); - if (!normalizedRepoRoot || !normalizedBranch) { - return ''; - } - - return path.join( - path.resolve(normalizedRepoRoot), - LOGS_RELATIVE_DIR, - `agent-${sanitizeBranchForFile(normalizedBranch)}.log`, - ); -} - -function readLogTail(filePath, maxLines = DEFAULT_LOG_TAIL_LINE_COUNT) { - const normalizedFilePath = toNonEmptyString(filePath); - const normalizedMaxLines = toPositiveInteger(maxLines) || DEFAULT_LOG_TAIL_LINE_COUNT; - if (!normalizedFilePath || !fs.existsSync(normalizedFilePath)) { - return []; - } - - try { - const lines = fs.readFileSync(normalizedFilePath, 'utf8').split(/\r?\n/); - while (lines.length > 0 && lines[lines.length - 1] === '') { - lines.pop(); - } - return lines.slice(-normalizedMaxLines); - } catch (_error) { - return []; - } -} - -function readSessionHeldLocks(repoRoot, branch) { - const normalizedRepoRoot = toNonEmptyString(repoRoot); - const normalizedBranch = toNonEmptyString(branch); - if (!normalizedRepoRoot || !normalizedBranch) { - return []; - } - - const parsed = readJsonFile(path.join(path.resolve(normalizedRepoRoot), LOCK_FILE_RELATIVE)); - const locks = parsed?.locks; - if (!locks || typeof locks !== 'object' || Array.isArray(locks)) { - return []; - } - - return Object.entries(locks) - .map(([rawRelativePath, entry]) => { - if (!entry || typeof entry !== 'object') { - return null; - } - - const relativePath = normalizeRelativePath(rawRelativePath); - const ownerBranch = toNonEmptyString(entry.branch); - if (!relativePath || ownerBranch !== normalizedBranch) { - return null; - } - - return { - relativePath, - claimedAt: toNonEmptyString(entry.claimed_at), - allowDelete: Boolean(entry.allow_delete), - }; - }) - .filter(Boolean) - .sort((left, right) => left.relativePath.localeCompare(right.relativePath)); -} - -function readSessionInspectData(session, options = {}) { - const repoRoot = toNonEmptyString(session?.repoRoot); - const branch = toNonEmptyString(session?.branch); - const worktreePath = toNonEmptyString(session?.worktreePath); - const baseBranch = readConfiguredBaseBranch(repoRoot); - const logPath = sessionLogPath(repoRoot, branch); - const logTailLines = readLogTail(logPath, options.logLines); - - return { - baseBranch, - logPath, - logExists: Boolean(logPath) && fs.existsSync(logPath), - logTailLines, - logTailText: logTailLines.join('\n'), - heldLocks: readSessionHeldLocks(repoRoot, branch), - ...readAheadBehindCounts(worktreePath, branch, baseBranch), - }; -} - -function normalizeIsoString(value, fallback = '') { - const normalized = toNonEmptyString(value); - if (!normalized) { - return fallback; - } - - const timestamp = Date.parse(normalized); - return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : fallback; -} - -function runGitLines(worktreePath, args) { - try { - const output = cp.execFileSync('git', ['-C', worktreePath, ...args], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'], - }); - return splitOutputLines(output); - } catch (_error) { - return null; - } -} - -function unquoteGitPath(value) { - if (typeof value !== 'string') { - return ''; - } - - const trimmed = value.trim(); - if (!trimmed.startsWith('"') || !trimmed.endsWith('"')) { - return trimmed; - } - - try { - return JSON.parse(trimmed); - } catch (_error) { - return trimmed.slice(1, -1); - } -} - -function formatFileCount(count) { - return `${count} file${count === 1 ? '' : 's'}`; -} - -function previewChangedPaths(paths) { - if (!Array.isArray(paths) || paths.length === 0) { - return ''; - } - - if (paths.length <= MAX_CHANGED_PATH_PREVIEW) { - return paths.join(', '); - } - - const preview = paths.slice(0, MAX_CHANGED_PATH_PREVIEW).join(', '); - return `${preview}, +${paths.length - MAX_CHANGED_PATH_PREVIEW} more`; -} - -function deriveRepoChangeStatus(statusPair) { - if (statusPair === '??') { - return { - statusCode: '??', - statusLabel: 'U', - statusText: 'Untracked', - }; - } - - const code = [statusPair[1], statusPair[0]].find((value) => value && value !== ' ') || 'M'; - const statusTextByCode = { - A: 'Added', - C: 'Copied', - D: 'Deleted', - M: 'Modified', - R: 'Renamed', - T: 'Type changed', - U: 'Conflicted', - }; - - return { - statusCode: code, - statusLabel: code, - statusText: statusTextByCode[code] || 'Changed', - }; -} - -function parseRepoChangeLine(repoRoot, line) { - if (typeof line !== 'string' || line.length < 4) { - return null; - } - - const statusPair = line.slice(0, 2); - if (statusPair === '!!') { - return null; - } - - const rawPath = line.slice(3).trim(); - if (!rawPath) { - return null; - } - - let relativePath = rawPath; - let originalPath = ''; - if (rawPath.includes(' -> ')) { - const parts = rawPath.split(' -> '); - if (parts.length === 2) { - originalPath = unquoteGitPath(parts[0]); - relativePath = parts[1]; - } - } - - relativePath = unquoteGitPath(relativePath); - if (!relativePath) { - return null; - } - - const normalizedRelativePath = relativePath.split(path.sep).join('/'); - if ( - normalizedRelativePath === LOCK_FILE_FILTER_PATH - || normalizedRelativePath.startsWith(`${LOCK_FILE_FILTER_PATH}/`) - || normalizedRelativePath === ACTIVE_SESSIONS_FILTER_PREFIX - || normalizedRelativePath.startsWith(`${ACTIVE_SESSIONS_FILTER_PREFIX}/`) - || MANAGED_WORKTREE_FILTER_PREFIXES.some((prefix) => ( - normalizedRelativePath === prefix || normalizedRelativePath.startsWith(`${prefix}/`) - )) - ) { - return null; - } - - const status = deriveRepoChangeStatus(statusPair); - return { - ...status, - originalPath, - relativePath, - absolutePath: path.join(path.resolve(repoRoot), relativePath), - }; -} - -function collectWorktreeChangedPaths(worktreePath) { - const changedGroups = [ - runGitLines(worktreePath, ['diff', '--name-only', '--', '.', `:(exclude)${LOCK_FILE_RELATIVE}`, `:(exclude)${AGENT_WORKTREE_LOCK_FILE}`]), - runGitLines(worktreePath, ['diff', '--cached', '--name-only', '--', '.', `:(exclude)${LOCK_FILE_RELATIVE}`, `:(exclude)${AGENT_WORKTREE_LOCK_FILE}`]), - runGitLines(worktreePath, ['ls-files', '--others', '--exclude-standard']), - ]; - - if (changedGroups.some((group) => group === null)) { - return null; - } - - return [...new Set(changedGroups.flat())] - .filter((relativePath) => ( - relativePath - && relativePath !== LOCK_FILE_RELATIVE - && relativePath !== AGENT_WORKTREE_LOCK_FILE - )) - .sort((left, right) => left.localeCompare(right)); -} - -function resolveWorktreeGitDir(worktreePath) { - const gitPath = path.join(path.resolve(worktreePath), '.git'); - try { - if (fs.statSync(gitPath).isDirectory()) { - return gitPath; - } - } catch (_error) { - return null; - } - - try { - const gitPointer = fs.readFileSync(gitPath, 'utf8'); - const match = gitPointer.match(/^gitdir:\s*(.+)$/m); - if (match?.[1]) { - return path.resolve(worktreePath, match[1].trim()); - } - } catch (_error) { - return null; - } - - return null; -} - -function deriveBlockingGitLabel(worktreePath) { - const gitDir = resolveWorktreeGitDir(worktreePath); - if (!gitDir) { - return ''; - } - - for (const blockingState of BLOCKING_GIT_STATES) { - if (blockingState.markers.some((marker) => fs.existsSync(path.join(gitDir, marker)))) { - return blockingState.label; - } - } - - return ''; -} - -function collectWorktreeTrackedPaths(worktreePath) { - const trackedPaths = runGitLines(worktreePath, ['ls-files', '--cached', '--others', '--exclude-standard']); - if (!trackedPaths) { - return null; - } - - return [...new Set(trackedPaths)] - .filter(Boolean) - .sort((left, right) => left.localeCompare(right)); -} - -function shouldSkipWorktreeActivityPath(relativePath) { - const normalized = normalizeRelativePath(relativePath); - if (!normalized || normalized === LOCK_FILE_RELATIVE || normalized === AGENT_WORKTREE_LOCK_FILE) { - return true; - } - - return WORKTREE_ACTIVITY_SKIP_PREFIXES.some((prefix) => ( - normalized === prefix.slice(0, -1) || normalized.startsWith(prefix) - )); -} - -function worktreeActivityPathPriority(relativePath, recentPathsSet) { - if (recentPathsSet.has(relativePath)) { - return 0; - } - if (!relativePath.includes('/')) { - return 1; - } - if (WORKTREE_ACTIVITY_PRIORITY_PREFIXES.some((prefix) => relativePath.startsWith(prefix))) { - return 2; - } - return 3; -} - -function collectWorktreeActivityCandidatePaths(worktreePath, trackedPaths) { - const recentPaths = runGitLines(worktreePath, ['log', '-1', '--name-only', '--pretty=format:', '--', '.']) || []; - const filteredRecentPaths = [...new Set(recentPaths.map(normalizeRelativePath).filter(Boolean))] - .filter((relativePath) => !shouldSkipWorktreeActivityPath(relativePath)); - const recentPathSet = new Set(filteredRecentPaths); - const prioritizedTrackedPaths = trackedPaths - .map(normalizeRelativePath) - .filter(Boolean) - .filter((relativePath) => !shouldSkipWorktreeActivityPath(relativePath)) - .sort((left, right) => { - const priorityDelta = worktreeActivityPathPriority(left, recentPathSet) - - worktreeActivityPathPriority(right, recentPathSet); - if (priorityDelta !== 0) { - return priorityDelta; - } - return left.localeCompare(right); - }); - - return [...new Set([...filteredRecentPaths, ...prioritizedTrackedPaths])] - .slice(0, MAX_WORKTREE_ACTIVITY_STAT_PATHS); -} - -function clearWorktreeActivityCache(worktreePath = '') { - const normalizedWorktreePath = toNonEmptyString(worktreePath); - if (!normalizedWorktreePath) { - worktreeActivityCache.clear(); - return; - } - worktreeActivityCache.delete(path.resolve(normalizedWorktreePath)); -} - -function deriveLatestWorktreeFileActivity(worktreePath, options = {}) { - const now = Number.isFinite(options.now) ? options.now : Date.now(); - const useCache = options.useCache !== false; - const cacheKey = path.resolve(worktreePath); - if (useCache) { - const cached = worktreeActivityCache.get(cacheKey); - if (cached && (now - cached.checkedAtMs) < WORKTREE_ACTIVITY_CACHE_TTL_MS) { - return cached.latestMtimeMs; - } - } - - const trackedPaths = collectWorktreeTrackedPaths(worktreePath); - if (!trackedPaths) { - return null; - } - - const candidatePaths = collectWorktreeActivityCandidatePaths(worktreePath, trackedPaths); - let latestMtimeMs = null; - for (const relativePath of candidatePaths) { - const absolutePath = path.join(worktreePath, relativePath); - try { - const stats = fs.statSync(absolutePath); - if (!stats.isFile() || !Number.isFinite(stats.mtimeMs)) { - continue; - } - latestMtimeMs = latestMtimeMs === null - ? stats.mtimeMs - : Math.max(latestMtimeMs, stats.mtimeMs); - } catch (_error) { - continue; - } - } - - if (useCache) { - worktreeActivityCache.set(cacheKey, { - checkedAtMs: now, - latestMtimeMs, - }); - } - - return latestMtimeMs; -} - -function deriveSessionActivity(session, options = {}) { - const now = Number.isFinite(options.now) ? options.now : Date.now(); - const pid = toPositiveInteger(session?.pid); - const pidAlive = pid ? isPidAlive(pid) : null; - const heartbeatAt = normalizeIsoString(session?.lastHeartbeatAt); - const heartbeatMs = Date.parse(heartbeatAt); - if (heartbeatAt && Number.isFinite(heartbeatMs) && now - heartbeatMs > HEARTBEAT_STALE_MS) { - return { - activityKind: 'dead', - activityLabel: 'dead', - activityCountLabel: '', - activitySummary: `Heartbeat stale for ${formatElapsedFrom(heartbeatAt, now)}.`, - changeCount: 0, - changedPaths: [], - worktreeChangedPaths: [], - pidAlive, - lastFileActivityAt: '', - lastFileActivityLabel: '', - }; - } - - const blockingLabel = deriveBlockingGitLabel(session.worktreePath); - if (blockingLabel) { - return { - activityKind: 'blocked', - activityLabel: 'blocked', - activityCountLabel: '', - activitySummary: blockingLabel, - changeCount: 0, - changedPaths: [], - worktreeChangedPaths: [], - pidAlive, - lastFileActivityAt: '', - lastFileActivityLabel: '', - }; - } - - if (pid && !pidAlive) { - return { - activityKind: 'dead', - activityLabel: 'dead', - activityCountLabel: '', - activitySummary: 'Recorded PID is not alive.', - changeCount: 0, - changedPaths: [], - worktreeChangedPaths: [], - pidAlive, - lastFileActivityAt: '', - lastFileActivityLabel: '', - }; - } - - const worktreeChangedPaths = collectWorktreeChangedPaths(session.worktreePath); - if (!worktreeChangedPaths) { - return { - activityKind: 'idle', - activityLabel: 'idle', - activityCountLabel: '', - activitySummary: 'Worktree activity unavailable.', - changeCount: 0, - changedPaths: [], - worktreeChangedPaths: [], - pidAlive, - lastFileActivityAt: '', - lastFileActivityLabel: '', - }; - } - - if (worktreeChangedPaths.length > 0) { - const worktreeRelativePaths = [...new Set(worktreeChangedPaths - .map((relativePath) => normalizeRelativePath(relativePath)) - .filter(Boolean))] - .sort((left, right) => left.localeCompare(right)); - clearWorktreeActivityCache(session.worktreePath); - const changedPaths = [...new Set(worktreeChangedPaths - .map((relativePath) => normalizeRelativePath( - path.relative(session.repoRoot, path.resolve(session.worktreePath, relativePath)), - )) - .filter(Boolean))] - .sort((left, right) => left.localeCompare(right)); - - const workingLatestFileActivityMs = deriveLatestWorktreeFileActivity(session.worktreePath, { - now, - useCache: options.useCache, - }); - const workingLastFileActivityAt = Number.isFinite(workingLatestFileActivityMs) - ? new Date(workingLatestFileActivityMs).toISOString() - : ''; - const workingLastFileActivityLabel = workingLastFileActivityAt - ? formatElapsedFrom(workingLastFileActivityAt, now) - : ''; - const workingFileActivityAgeMs = Number.isFinite(workingLatestFileActivityMs) - ? Math.max(0, now - workingLatestFileActivityMs) - : null; - const isFinishedUncommitted = workingFileActivityAgeMs !== null - && workingFileActivityAgeMs > IDLE_ACTIVITY_WINDOW_MS; - - return { - activityKind: isFinishedUncommitted ? 'finished' : 'working', - activityLabel: isFinishedUncommitted ? 'finished' : 'working', - activityCountLabel: formatFileCount(worktreeChangedPaths.length), - activitySummary: isFinishedUncommitted && workingLastFileActivityLabel - ? `${previewChangedPaths(worktreeChangedPaths)} · idle ${workingLastFileActivityLabel}` - : previewChangedPaths(worktreeChangedPaths), - changeCount: worktreeChangedPaths.length, - changedPaths, - worktreeChangedPaths: worktreeRelativePaths, - pidAlive, - lastFileActivityAt: workingLastFileActivityAt, - lastFileActivityLabel: workingLastFileActivityLabel, - }; - } - - const latestFileActivityMs = deriveLatestWorktreeFileActivity(session.worktreePath, { - now, - useCache: options.useCache, - }); - const lastFileActivityAt = Number.isFinite(latestFileActivityMs) - ? new Date(latestFileActivityMs).toISOString() - : ''; - const lastFileActivityLabel = lastFileActivityAt - ? formatElapsedFrom(lastFileActivityAt, now) - : ''; - const lastFileActivityAgeMs = Number.isFinite(latestFileActivityMs) - ? Math.max(0, now - latestFileActivityMs) - : null; - - if (lastFileActivityAgeMs !== null && lastFileActivityAgeMs > STALLED_ACTIVITY_WINDOW_MS) { - return { - activityKind: 'stalled', - activityLabel: 'stalled', - activityCountLabel: '', - activitySummary: `Worktree clean. No file activity for ${lastFileActivityLabel}.`, - changeCount: 0, - changedPaths: [], - worktreeChangedPaths: [], - pidAlive, - lastFileActivityAt, - lastFileActivityLabel, - }; - } - - return { - activityKind: 'idle', - activityLabel: 'idle', - activityCountLabel: '', - activitySummary: lastFileActivityAgeMs !== null && lastFileActivityAgeMs <= IDLE_ACTIVITY_WINDOW_MS - ? `Worktree clean. Recent file activity ${lastFileActivityLabel} ago.` - : lastFileActivityLabel - ? `Worktree clean. Last file activity ${lastFileActivityLabel} ago.` - : 'Worktree clean.', - changeCount: 0, - changedPaths: [], - worktreeChangedPaths: [], - pidAlive, - lastFileActivityAt, - lastFileActivityLabel, - }; -} - -function buildSessionRecord(input) { - const repoRoot = path.resolve(toNonEmptyString(input.repoRoot)); - const worktreePath = path.resolve(toNonEmptyString(input.worktreePath)); - const branch = toNonEmptyString(input.branch); - const pid = toPositiveInteger(input.pid); - const startedAt = input.startedAt ? new Date(input.startedAt) : new Date(); - const lastHeartbeatAt = input.lastHeartbeatAt ? new Date(input.lastHeartbeatAt) : new Date(); - - if (!branch) { - throw new Error('branch is required'); - } - if (!repoRoot) { - throw new Error('repoRoot is required'); - } - if (!worktreePath) { - throw new Error('worktreePath is required'); - } - if (!pid) { - throw new Error('pid must be a positive integer'); - } - if (Number.isNaN(startedAt.getTime())) { - throw new Error('startedAt must be a valid date'); - } - if (Number.isNaN(lastHeartbeatAt.getTime())) { - throw new Error('lastHeartbeatAt must be a valid date'); - } - - return { - schemaVersion: SESSION_SCHEMA_VERSION, - repoRoot, - branch, - taskName: toNonEmptyString(input.taskName, 'task'), - 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, - pid, - cliName: toNonEmptyString(input.cliName, 'codex'), - taskMode: normalizeTaskMode(input.taskMode), - openspecTier: normalizeOpenSpecTier(input.openspecTier), - taskRoutingReason: toNonEmptyString(input.taskRoutingReason), - startedAt: startedAt.toISOString(), - lastHeartbeatAt: lastHeartbeatAt.toISOString(), - state: normalizeAdvisoryState(input.state), - sessionHealth: normalizeSessionHealthPayload(input.sessionHealth || input.sessionSeverity), - }; -} - -function deriveSessionLabel(branch, worktreePath) { - const worktreeLeaf = toNonEmptyString(path.basename(worktreePath || '')); - if (worktreeLeaf) { - return worktreeLeaf; - } - return toNonEmptyString(branch).replace(/[\\/]+/g, '-') || 'unknown-agent'; -} - -function normalizeSessionRecord(input, options = {}) { - if (!input || typeof input !== 'object') { - return null; - } - - const repoRoot = toNonEmptyString(input.repoRoot); - const branch = toNonEmptyString(input.branch); - const worktreePath = toNonEmptyString(input.worktreePath); - const startedAt = new Date(input.startedAt); - const lastHeartbeatAt = new Date(input.lastHeartbeatAt || input.startedAt); - const pid = toPositiveInteger(input.pid); - - if ( - !repoRoot - || !branch - || !worktreePath - || !pid - || Number.isNaN(startedAt.getTime()) - || Number.isNaN(lastHeartbeatAt.getTime()) - ) { - return null; - } - - return { - schemaVersion: toPositiveInteger(input.schemaVersion) || SESSION_SCHEMA_VERSION, - repoRoot: path.resolve(repoRoot), - branch, - taskName: toNonEmptyString(input.taskName, 'task'), - 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), - pid, - cliName: toNonEmptyString(input.cliName, 'codex'), - taskMode: normalizeTaskMode(input.taskMode), - openspecTier: normalizeOpenSpecTier(input.openspecTier), - taskRoutingReason: toNonEmptyString(input.taskRoutingReason), - startedAt: startedAt.toISOString(), - lastHeartbeatAt: lastHeartbeatAt.toISOString(), - state: normalizeAdvisoryState(input.state, 'idle'), - filePath: toNonEmptyString(options.filePath), - label: deriveSessionLabel(branch, worktreePath), - changedPaths: [], - worktreeChangedPaths: [], - sourceKind: 'active-session', - telemetryUpdatedAt: '', - telemetrySource: '', - lockSnapshotCount: 0, - lockSessionCount: 0, - collaboration: false, - sessionHealth: normalizeSessionHealthPayload(input.sessionHealth || input.sessionSeverity), - }; -} - -function formatElapsedFrom(startedAt, now = Date.now()) { - const startedAtMs = startedAt instanceof Date ? startedAt.getTime() : Date.parse(startedAt); - if (!Number.isFinite(startedAtMs)) { - return '0s'; - } - - const totalSeconds = Math.max(0, Math.floor((now - startedAtMs) / 1000)); - const days = Math.floor(totalSeconds / 86_400); - const hours = Math.floor((totalSeconds % 86_400) / 3_600); - const minutes = Math.floor((totalSeconds % 3_600) / 60); - const seconds = totalSeconds % 60; - - if (days > 0) { - return `${days}d ${hours}h`; - } - if (hours > 0) { - return `${hours}h ${minutes}m`; - } - if (minutes > 0) { - return `${minutes}m ${seconds}s`; - } - return `${seconds}s`; -} - -function isPidAlive(pid) { - const normalizedPid = toPositiveInteger(pid); - if (!normalizedPid) { - return false; - } - - try { - process.kill(normalizedPid, 0); - return true; - } catch (_error) { - return false; - } -} - -function readWorktreeBranch(worktreePath) { - const lines = runGitLines(worktreePath, ['rev-parse', '--abbrev-ref', 'HEAD']); - return Array.isArray(lines) && typeof lines[0] === 'string' ? lines[0].trim() : ''; -} - -function deriveAgentNameFromBranch(branch) { - const parts = toNonEmptyString(branch).split('/').filter(Boolean); - if (parts.length >= 2 && parts[0] === 'agent') { - return parts[1]; - } - return 'agent'; -} - -function isManagedAgentBranch(branch) { - return toNonEmptyString(branch).startsWith('agent/'); -} - -function deriveManagedWorktreeStartedAt(worktreePath, now = Date.now()) { - try { - const stats = fs.statSync(worktreePath); - if (Number.isFinite(stats.mtimeMs)) { - return new Date(stats.mtimeMs).toISOString(); - } - } catch (_error) { - // Directory mtime is best-effort context only; fall back to current scan time. - } - - return new Date(now).toISOString(); -} - -function flattenTelemetrySnapshotSessions(lockPayload) { - const flattened = []; - const snapshots = Array.isArray(lockPayload?.snapshots) ? lockPayload.snapshots : []; - for (const snapshot of snapshots) { - const snapshotSessions = Array.isArray(snapshot?.sessions) ? snapshot.sessions : []; - for (const session of snapshotSessions) { - flattened.push({ - taskPreview: toNonEmptyString(session?.taskPreview), - taskUpdatedAt: normalizeIsoString(session?.taskUpdatedAt), - projectName: toNonEmptyString(session?.projectName), - projectPath: toNonEmptyString(session?.projectPath), - snapshotName: toNonEmptyString(snapshot?.snapshotName), - email: toNonEmptyString(snapshot?.email), - sessionHealth: normalizeSessionHealthPayload( - session?.sessionHealth || session?.sessionSeverity || snapshot?.sessionHealth || snapshot?.sessionSeverity, - ), - }); - } - } - return flattened; -} - -function sortSessionsByTimestamp(sessions) { - sessions.sort((left, right) => { - const timeDelta = Date.parse(right.startedAt) - Date.parse(left.startedAt); - if (timeDelta !== 0) { - return timeDelta; - } - return left.label.localeCompare(right.label); - }); - return sessions; -} - -function deriveLockTaskAnchor(entries, fallbackTaskName, fallbackTimestamp) { - const sortedEntries = sortTelemetryEntriesForAnchor(entries); - - const latestEntry = sortedEntries[0] || null; - return { - taskName: latestEntry?.taskPreview || fallbackTaskName || 'task', - latestTaskPreview: latestEntry?.taskPreview || '', - timestamp: latestEntry?.taskUpdatedAt || fallbackTimestamp || '', - sessionHealth: latestEntry?.sessionHealth || null, - }; -} - -function sortTelemetryEntriesForAnchor(entries) { - return [...entries].sort((left, right) => { - const timeDelta = Date.parse(right.taskUpdatedAt || '') - Date.parse(left.taskUpdatedAt || ''); - if (timeDelta !== 0) { - return timeDelta; - } - if (Boolean(right.taskPreview) !== Boolean(left.taskPreview)) { - return Number(Boolean(right.taskPreview)) - Number(Boolean(left.taskPreview)); - } - return (right.projectPath || '').localeCompare(left.projectPath || ''); - }); -} - -function deriveLockSnapshotIdentity(entries) { - const latestEntry = sortTelemetryEntriesForAnchor(entries) - .find((entry) => entry?.snapshotName || entry?.email) || null; - return { - snapshotName: toNonEmptyString(latestEntry?.snapshotName), - snapshotEmail: toNonEmptyString(latestEntry?.email), - }; -} - -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); - const telemetryUpdatedAt = normalizeIsoString(lockPayload?.updatedAt); - const branch = readWorktreeBranch(worktreePath); - const effectiveBranch = branch && branch !== 'HEAD' - ? branch - : `agent/telemetry/${path.basename(worktreePath)}`; - 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 = { - schemaVersion: toPositiveInteger(lockPayload?.schemaVersion) || SESSION_SCHEMA_VERSION, - repoRoot: path.resolve(repoRoot), - branch: effectiveBranch, - 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), - pid: null, - cliName: 'codex', - taskMode: '', - openspecTier: '', - taskRoutingReason: '', - startedAt, - lastHeartbeatAt: '', - state: '', - filePath: path.join(worktreePath, AGENT_WORKTREE_LOCK_FILE), - label, - changedPaths: [], - worktreeChangedPaths: [], - sourceKind: 'worktree-lock', - telemetryUpdatedAt: telemetryUpdatedAt || startedAt, - telemetrySource: toNonEmptyString(lockPayload?.source, 'worktree-lock'), - 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); - Object.assign(session, deriveSessionActivity(session, { now })); - return session; -} - -function buildManagedWorktreeSession(repoRoot, worktreePath, options = {}) { - const now = options.now || Date.now(); - const branch = readWorktreeBranch(worktreePath); - if (!branch || branch === 'HEAD' || !isManagedAgentBranch(branch)) { - return null; - } - - const label = deriveSessionLabel(branch, worktreePath); - const startedAt = deriveManagedWorktreeStartedAt(worktreePath, now); - const session = { - schemaVersion: SESSION_SCHEMA_VERSION, - repoRoot: path.resolve(repoRoot), - branch, - taskName: label, - latestTaskPreview: '', - agentName: deriveAgentNameFromBranch(branch), - projectName: '', - projectPath: '', - snapshotName: '', - snapshotEmail: '', - worktreePath: path.resolve(worktreePath), - pid: null, - cliName: 'gx', - taskMode: '', - openspecTier: '', - taskRoutingReason: '', - startedAt, - lastHeartbeatAt: '', - state: '', - filePath: path.join(worktreePath, '.git'), - label, - changedPaths: [], - worktreeChangedPaths: [], - sourceKind: 'managed-worktree', - telemetryUpdatedAt: '', - telemetrySource: 'managed-worktree', - lockSnapshotCount: 0, - lockSessionCount: 0, - collaboration: false, - sessionHealth: null, - }; - - session.elapsedLabel = formatElapsedFrom(session.startedAt, now); - Object.assign(session, deriveSessionActivity(session, { now })); - return session; -} - -function readWorktreeLockSessions(repoRoot, options = {}) { - const sessions = []; - for (const managedRoot of resolveManagedWorktreeRoots(repoRoot)) { - if (!fs.existsSync(managedRoot)) { - continue; - } - - let entries; - try { - entries = fs.readdirSync(managedRoot, { withFileTypes: true }); - } catch (_error) { - continue; - } - - for (const entry of entries) { - if (!entry.isDirectory()) { - continue; - } - - const worktreePath = path.join(managedRoot, entry.name); - const lockPath = path.join(worktreePath, AGENT_WORKTREE_LOCK_FILE); - if (!fs.existsSync(lockPath)) { - continue; - } - - const lockPayload = readJsonFile(lockPath); - if (!lockPayload || typeof lockPayload !== 'object' || Array.isArray(lockPayload)) { - continue; - } - - const telemetryEntries = flattenTelemetrySnapshotSessions(lockPayload); - if (telemetryEntries.length === 0 && !toPositiveInteger(lockPayload.sessionCount)) { - continue; - } - - sessions.push(buildWorktreeLockSession(repoRoot, worktreePath, lockPayload, options)); - } - } - - return sortSessionsByTimestamp(sessions); -} - -function readManagedWorktreeSessions(repoRoot, options = {}) { - const lockSessions = readWorktreeLockSessions(repoRoot, options); - const lockSessionsByWorktree = new Map( - lockSessions.map((session) => [path.resolve(session.worktreePath), session]), - ); - const sessions = []; - - for (const managedRoot of resolveManagedWorktreeRoots(repoRoot)) { - if (!fs.existsSync(managedRoot)) { - continue; - } - - let entries; - try { - entries = fs.readdirSync(managedRoot, { withFileTypes: true }); - } catch (_error) { - continue; - } - - for (const entry of entries) { - if (!entry.isDirectory()) { - continue; - } - - const worktreePath = path.join(managedRoot, entry.name); - const worktreeKey = path.resolve(worktreePath); - const lockSession = lockSessionsByWorktree.get(worktreeKey); - if (lockSession) { - sessions.push(lockSession); - continue; - } - - const managedSession = buildManagedWorktreeSession(repoRoot, worktreePath, options); - if (managedSession) { - sessions.push(managedSession); - } - } - } - - return sortSessionsByTimestamp(sessions); -} - -function mergeSessionSources(primarySessions, lockSessions) { - const lockSessionsByWorktree = new Map( - lockSessions.map((session) => [path.resolve(session.worktreePath), session]), - ); - const consumedLockWorktrees = new Set(); - const merged = []; - - for (const session of primarySessions) { - const worktreeKey = path.resolve(session.worktreePath); - const lockSession = lockSessionsByWorktree.get(worktreeKey); - if (lockSession && session.activityKind === 'dead') { - continue; - } - 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); - } - - for (const lockSession of lockSessions) { - const worktreeKey = path.resolve(lockSession.worktreePath); - if (!consumedLockWorktrees.has(worktreeKey)) { - merged.push(lockSession); - } - } - - return sortSessionsByTimestamp(merged); -} - -function readActiveSessions(repoRoot, options = {}) { - const activeSessionsDir = activeSessionsDirForRepo(repoRoot); - const now = options.now || Date.now(); - const sessionFileSessions = []; - if (fs.existsSync(activeSessionsDir)) { - for (const entry of fs.readdirSync(activeSessionsDir, { withFileTypes: true })) { - if (!entry.isFile() || !entry.name.endsWith('.json')) { - continue; - } - - const filePath = path.join(activeSessionsDir, entry.name); - const parsed = readJsonFile(filePath); - const normalized = normalizeSessionRecord(parsed, { filePath }); - if (!normalized) { - continue; - } - if (!options.includeStale && !isPidAlive(normalized.pid)) { - continue; - } - - normalized.elapsedLabel = formatElapsedFrom(normalized.startedAt, now); - Object.assign(normalized, deriveSessionActivity(normalized, { now })); - sessionFileSessions.push(normalized); - } - } - - return mergeSessionSources( - sortSessionsByTimestamp(sessionFileSessions), - readManagedWorktreeSessions(repoRoot, { now }), - ); -} - -function readRepoChanges(repoRoot) { - const statusLines = runGitLines(repoRoot, ['status', '--porcelain=v1', '--untracked-files=all']); - if (!statusLines) { - return []; - } - - return statusLines - .map((line) => parseRepoChangeLine(repoRoot, line)) - .filter(Boolean) - .sort((left, right) => left.relativePath.localeCompare(right.relativePath)); -} - -module.exports = { - ACTIVE_SESSIONS_RELATIVE_DIR, - SESSION_SCHEMA_VERSION, - activeSessionsDirForRepo, - buildSessionRecord, - clearWorktreeActivityCache, - collectWorktreeChangedPaths, - collectWorktreeTrackedPaths, - deriveBlockingGitLabel, - deriveLatestWorktreeFileActivity, - deriveSessionLabel, - deriveSessionActivity, - formatElapsedFrom, - formatFileCount, - isPidAlive, - normalizeSessionRecord, - parseRepoChangeLine, - previewChangedPaths, - readActiveSessions, - readManagedWorktreeSessions, - readWorktreeLockSessions, - readRepoChanges, - deriveRepoChangeStatus, - readAheadBehindCounts, - readConfiguredBaseBranch, - readLogTail, - resolveWorktreeGitDir, - readSessionHeldLocks, - readSessionInspectData, - sessionLogPath, - sanitizeBranchForFile, - sessionFileNameForBranch, - sessionFilePathForBranch, -}; diff --git a/test/helpers/install-test-helpers.js b/test/helpers/install-test-helpers.js index 4406c41c..ad93f4e9 100644 --- a/test/helpers/install-test-helpers.js +++ b/test/helpers/install-test-helpers.js @@ -140,10 +140,8 @@ function assertZeroCopyManagedGitignore(content) { assert.match(content, /^!\.vscode\/$/m); assert.match(content, /^\.vscode\/\*$/m); assert.match(content, /^!\.vscode\/settings\.json$/m); - assert.match(content, /^scripts\/agent-session-state\.js$/m); assert.match(content, /^scripts\/guardex-docker-loader\.sh$/m); assert.match(content, /^scripts\/guardex-env\.sh$/m); - assert.match(content, /^scripts\/install-vscode-active-agents-extension\.js$/m); assert.doesNotMatch(content, /^scripts\/\*$/m); assert.doesNotMatch(content, /^scripts\/agent-branch-start\.sh$/m); assert.doesNotMatch(content, /^scripts\/agent-file-locks\.py$/m); diff --git a/test/metadata.test.js b/test/metadata.test.js index 080991d1..944458ef 100644 --- a/test/metadata.test.js +++ b/test/metadata.test.js @@ -220,28 +220,13 @@ test('frontend mirror workflow skips cleanly when the mirror PAT is missing', () assert.match(workflow, /if:\s+\$\{\{\s*env\.SYNC_TOKEN != ''\s*\}\}/); }); -test('critical runtime helper scripts and active-agents sources stay in sync with templates', () => { +test('critical runtime helper scripts stay in sync with templates', () => { const pairs = [ ['templates/scripts/agent-branch-start.sh', 'scripts/agent-branch-start.sh'], ['templates/scripts/agent-branch-finish.sh', 'scripts/agent-branch-finish.sh'], ['templates/scripts/codex-agent.sh', 'scripts/codex-agent.sh'], ['templates/scripts/openspec/init-plan-workspace.sh', 'scripts/openspec/init-plan-workspace.sh'], ['templates/scripts/openspec/init-change-workspace.sh', 'scripts/openspec/init-change-workspace.sh'], - ['templates/scripts/agent-session-state.js', 'scripts/agent-session-state.js'], - ['templates/scripts/install-vscode-active-agents-extension.js', 'scripts/install-vscode-active-agents-extension.js'], - ['templates/vscode/guardex-active-agents/package.json', 'vscode/guardex-active-agents/package.json'], - ['templates/vscode/guardex-active-agents/README.md', 'vscode/guardex-active-agents/README.md'], - ['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 3ac9ffde..dcc7921f 100644 --- a/test/setup.test.js +++ b/test/setup.test.js @@ -62,8 +62,6 @@ const { defineSpawnSuite, } = require('./helpers/install-test-helpers'); -const packageRepoRoot = path.resolve(__dirname, '..'); - defineSpawnSuite('setup integration suite', () => { test('setup provisions workflow files and repo config', () => { @@ -84,10 +82,8 @@ test('setup provisions workflow files and repo config', () => { '.omc/agent-worktrees', '.omx/notepad.md', '.omx/project-memory.json', - 'scripts/agent-session-state.js', 'scripts/guardex-docker-loader.sh', 'scripts/guardex-env.sh', - 'scripts/install-vscode-active-agents-extension.js', '.githooks/pre-commit', '.githooks/pre-push', '.githooks/post-merge', @@ -156,10 +152,8 @@ test('setup provisions workflow files and repo config', () => { const gitignoreContent = fs.readFileSync(path.join(repoDir, '.gitignore'), 'utf8'); assert.match(gitignoreContent, /# multiagent-safety:START/); - assert.match(gitignoreContent, /^scripts\/agent-session-state\.js$/m); assert.match(gitignoreContent, /^scripts\/guardex-docker-loader\.sh$/m); assert.match(gitignoreContent, /^scripts\/guardex-env\.sh$/m); - assert.match(gitignoreContent, /^scripts\/install-vscode-active-agents-extension\.js$/m); assert.doesNotMatch(gitignoreContent, /^scripts\/\*$/m); assert.doesNotMatch(gitignoreContent, /^scripts\/agent-branch-start\.sh$/m); assert.doesNotMatch(gitignoreContent, /^scripts\/agent-file-locks\.py$/m); @@ -181,31 +175,6 @@ test('setup provisions workflow files and repo config', () => { const secondRun = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); assert.equal(secondRun.status, 0, secondRun.stderr || secondRun.stdout); - - const canonicalBundleFiles = [ - 'vscode/guardex-active-agents/package.json', - 'vscode/guardex-active-agents/README.md', - '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); - const expectedPath = path.join(packageRepoRoot, relativePath); - assert.equal( - Buffer.compare(fs.readFileSync(installedPath), fs.readFileSync(expectedPath)), - 0, - `${relativePath} should match the package repo canonical bundle`, - ); - } }); test('setup preserves an existing root CLAUDE.md instead of replacing it', () => { @@ -821,7 +790,7 @@ test('setup refreshes initialized protected main through a sandbox and prunes it const initialGitignore = fs.readFileSync(gitignorePath, 'utf8'); fs.writeFileSync( gitignorePath, - initialGitignore.replace(/^scripts\/agent-session-state\.js\n/m, ''), + initialGitignore.replace(/^scripts\/guardex-docker-loader\.sh\n/m, ''), 'utf8', ); @@ -843,7 +812,7 @@ test('setup refreshes initialized protected main through a sandbox and prunes it assert.equal(sandboxBranchCheck.stdout.trim(), '', 'setup sandbox branch should be pruned'); const refreshedGitignore = fs.readFileSync(gitignorePath, 'utf8'); - assert.match(refreshedGitignore, /^scripts\/agent-session-state\.js$/m); + assert.match(refreshedGitignore, /^scripts\/guardex-docker-loader\.sh$/m); }); diff --git a/test/vscode-active-agents-session-state.test.js b/test/vscode-active-agents-session-state.test.js deleted file mode 100644 index f074c5d1..00000000 --- a/test/vscode-active-agents-session-state.test.js +++ /dev/null @@ -1,4079 +0,0 @@ -const test = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const os = require('node:os'); -const path = require('node:path'); -const cp = require('node:child_process'); - -const repoRoot = path.resolve(__dirname, '..'); -const cliEntry = path.join(repoRoot, 'bin', 'multiagent-safety.js'); -const sessionScript = path.join(repoRoot, 'scripts', 'agent-session-state.js'); -const installScript = path.join(repoRoot, 'scripts', 'install-vscode-active-agents-extension.js'); -const extensionManifestPath = path.join( - repoRoot, - 'vscode', - 'guardex-active-agents', - 'package.json', -); -const templateExtensionManifestPath = path.join( - repoRoot, - 'templates', - 'vscode', - 'guardex-active-agents', - 'package.json', -); -const sessionSchema = require(path.join( - repoRoot, - 'vscode', - 'guardex-active-agents', - 'session-schema.js', -)); -const extensionEntry = path.join(repoRoot, 'vscode', 'guardex-active-agents', 'extension.js'); - -function runNode(scriptPath, args, options = {}) { - return cp.spawnSync('node', [scriptPath, ...args], { - encoding: 'utf8', - ...options, - }); -} - -function runGit(repoPath, args, options = {}) { - const result = cp.spawnSync('git', ['-C', repoPath, ...args], { - encoding: 'utf8', - ...options, - }); - assert.equal(result.status, 0, result.stderr || result.stdout); - return result; -} - -function initGitRepo(repoPath) { - fs.mkdirSync(repoPath, { recursive: true }); - runGit(repoPath, ['init']); - runGit(repoPath, ['config', 'user.email', 'guardex-tests@example.com']); - runGit(repoPath, ['config', 'user.name', 'Guardex Tests']); -} - -function readJson(filePath) { - return JSON.parse(fs.readFileSync(filePath, 'utf8')); -} - -function parseSimpleSemver(version) { - const parts = version.split('.').map((part) => Number.parseInt(part, 10)); - assert.equal(parts.length, 3, `Expected simple semver, received ${version}`); - for (const part of parts) { - assert.equal(Number.isNaN(part), false, `Expected numeric semver, received ${version}`); - } - return parts; -} - -function compareSimpleSemver(left, right) { - const leftParts = parseSimpleSemver(left); - const rightParts = parseSimpleSemver(right); - for (let index = 0; index < leftParts.length; index += 1) { - if (leftParts[index] !== rightParts[index]) { - return leftParts[index] - rightParts[index]; - } - } - return 0; -} - -function resolveRepoBaseRef() { - for (const candidate of ['origin/main', 'main']) { - const result = cp.spawnSync('git', ['-C', repoRoot, 'rev-parse', '--verify', candidate], { - encoding: 'utf8', - }); - if (result.status === 0) { - return candidate; - } - } - throw new Error('Could not resolve a base ref for the extension version guard.'); -} - -function readExtensionManifest(filePath = extensionManifestPath) { - return readJson(filePath); -} - -function readBaseExtensionManifest(baseRef) { - const result = cp.spawnSync( - 'git', - ['-C', repoRoot, 'show', `${baseRef}:vscode/guardex-active-agents/package.json`], - { - encoding: 'utf8', - }, - ); - assert.equal(result.status, 0, result.stderr || result.stdout); - return JSON.parse(result.stdout); -} - -function readChangedExtensionPaths(baseRef) { - const result = cp.spawnSync( - 'git', - [ - '-C', - repoRoot, - 'diff', - '--name-only', - `${baseRef}...HEAD`, - '--', - 'vscode/guardex-active-agents', - 'templates/vscode/guardex-active-agents', - 'scripts/install-vscode-active-agents-extension.js', - ], - { - encoding: 'utf8', - }, - ); - assert.equal(result.status, 0, result.stderr || result.stdout); - return result.stdout - .split('\n') - .map((entry) => entry.trim()) - .filter(Boolean); -} - -function setPathMtime(filePath, whenMs) { - const when = new Date(whenMs); - fs.utimesSync(filePath, when, when); -} - -function writeSessionRecord(repoRoot, record) { - const sessionPath = sessionSchema.sessionFilePathForBranch(repoRoot, record.branch); - fs.mkdirSync(path.dirname(sessionPath), { recursive: true }); - fs.writeFileSync(sessionPath, `${JSON.stringify(record, null, 2)}\n`, 'utf8'); - return sessionPath; -} - -function buildWorktreeLockPayload(worktreePath, overrides = {}) { - return { - schemaVersion: 1, - source: 'recodee-live-telemetry', - updatedAt: '2026-04-22T08:56:00.000Z', - worktreePath, - worktreeName: path.basename(worktreePath), - collaboration: false, - snapshotCount: 1, - sessionCount: 1, - 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, - }, - ], - }, - ], - ...overrides, - }; -} - -function writeWorktreeLock(worktreePath, overrides = {}) { - const lockPath = path.join(worktreePath, 'AGENT.lock'); - fs.mkdirSync(path.dirname(lockPath), { recursive: true }); - fs.writeFileSync( - lockPath, - `${JSON.stringify(buildWorktreeLockPayload(worktreePath, overrides), null, 2)}\n`, - 'utf8', - ); - return lockPath; -} - -async function getOnlyChild(provider, item) { - const children = await provider.getChildren(item); - assert.equal(children.length, 1, `Expected exactly one child for ${item?.label || 'item'}`); - return children[0]; -} - -async function getOnlyWorktreeAndSession(provider, sectionItem) { - const firstItem = await getOnlyChild(provider, sectionItem); - if (firstItem?.session) { - return { worktreeItem: null, sessionItem: firstItem }; - } - const worktreeItem = firstItem; - const sessionItem = await getOnlyChild(provider, firstItem); - return { worktreeItem, sessionItem }; -} - -async function getSectionByLabel(provider, parentItem, label) { - const children = await provider.getChildren(parentItem); - const match = children.find((item) => item.label === label); - assert.ok(match, `Expected section ${label}`); - return match; -} - -async function getChildByLabel(provider, parentItem, label) { - const children = await provider.getChildren(parentItem); - const match = children.find((item) => item.label === label); - assert.ok(match, `Expected child ${label}`); - return match; -} - -function assertBundledIcon(item, iconFileName) { - assert.equal( - item?.iconPath?.light?.fsPath.endsWith(path.join('fileicons', 'icons', iconFileName)), - true, - `Expected ${item?.label || 'item'} to use ${iconFileName}`, - ); - assert.equal(item?.iconPath?.light?.fsPath, item?.iconPath?.dark?.fsPath); -} - -async function getSessionByBranch(provider, sectionItem, branch) { - const children = await provider.getChildren(sectionItem); - const match = children.find((item) => item.session?.branch === branch); - assert.ok(match, `Expected session ${branch}`); - return match; -} - -function loadExtensionWithMockVscode(mockVscode, mockSessionSchema = null) { - const Module = require('node:module'); - const originalLoad = Module._load; - delete require.cache[require.resolve(extensionEntry)]; - - Module._load = function patchedModuleLoad(request, parent, isMain) { - if (request === 'vscode') { - return mockVscode; - } - if (mockSessionSchema && request === './session-schema.js' && parent?.filename === extensionEntry) { - return mockSessionSchema; - } - return originalLoad.call(this, request, parent, isMain); - }; - - try { - return require(extensionEntry); - } finally { - Module._load = originalLoad; - } -} - -function createMockVscode(tempRoot) { - const registrations = { - providers: [], - decorationProviders: [], - treeViews: [], - statusBarItems: [], - commands: new Map(), - executedCommands: [], - sourceControls: [], - terminals: [], - nextTerminalPid: 7000, - openedDocuments: [], - shownDocuments: [], - infoMessages: [], - infoResponses: [], - inputResponses: [], - inputBoxCalls: [], - quickPickCalls: [], - quickPickResponse: undefined, - informationMessages: [], - errorMessages: [], - warningMessages: [], - webviewPanels: [], - fileWatchers: [], - watchers: [], - workspaceFolderListeners: [], - workspaceFolderUpdates: [], - configurationUpdates: [], - workspaceConfigurationValues: new Map(), - }; - - class TreeItem { - constructor(label, collapsibleState) { - this.label = label; - this.collapsibleState = collapsibleState; - } - } - - class ThemeIcon { - constructor(id, color) { - this.id = id; - this.color = color; - } - } - - class ThemeColor { - constructor(id) { - this.id = id; - } - } - - class EventEmitter { - constructor() { - this.fireCount = 0; - this.listeners = []; - this.event = (listener, thisArg, disposables) => { - const boundListener = thisArg ? listener.bind(thisArg) : listener; - this.listeners.push(boundListener); - const registration = { - dispose: () => { - this.listeners = this.listeners.filter((entry) => entry !== boundListener); - }, - }; - if (Array.isArray(disposables)) { - disposables.push(registration); - } - return registration; - }; - } - - fire(event) { - this.fireCount += 1; - for (const listener of [...this.listeners]) { - listener(event); - } - } - } - - const disposable = (onDispose) => ({ dispose: onDispose || (() => {}) }); - const ConfigurationTarget = { - Workspace: 'workspace', - WorkspaceFolder: 'workspaceFolder', - }; - const configurationKey = (section, scopePath, key) => `${section}::${scopePath}::${key}`; - const resolveWorkspaceScopePath = (scope) => scope?.uri?.fsPath || tempRoot; - const readConfigurationValue = (section, scope, key) => { - const scopePath = resolveWorkspaceScopePath(scope); - const scopedKey = configurationKey(section, scopePath, key); - if (registrations.workspaceConfigurationValues.has(scopedKey)) { - return registrations.workspaceConfigurationValues.get(scopedKey); - } - return registrations.workspaceConfigurationValues.get(configurationKey(section, tempRoot, key)); - }; - const writeConfigurationValue = (section, scopePath, key, value) => { - registrations.workspaceConfigurationValues.set(configurationKey(section, scopePath, key), value); - }; - registrations.getConfigurationValue = (section, scopePath, key) => ( - registrations.workspaceConfigurationValues.get(configurationKey(section, scopePath, key)) - ); - registrations.setConfigurationValue = (section, scopePath, key, value) => { - writeConfigurationValue(section, scopePath, key, value); - }; - - function createFileWatcher(pattern) { - const listeners = { - create: [], - change: [], - delete: [], - }; - - const watcher = { - disposed: false, - pattern, - onDidCreate(callback, thisArg) { - listeners.create.push({ callback, thisArg }); - return disposable(); - }, - onDidChange(callback, thisArg) { - listeners.change.push({ callback, thisArg }); - return disposable(); - }, - onDidDelete(callback, thisArg) { - listeners.delete.push({ callback, thisArg }); - return disposable(); - }, - fireCreate(uri) { - for (const listener of listeners.create) { - listener.callback.call(listener.thisArg, uri); - } - }, - fireChange(uri) { - for (const listener of listeners.change) { - listener.callback.call(listener.thisArg, uri); - } - }, - fireDelete(uri) { - for (const listener of listeners.delete) { - listener.callback.call(listener.thisArg, uri); - } - }, - dispose() { - watcher.disposed = true; - }, - }; - registrations.watchers.push(watcher); - registrations.fileWatchers.push(watcher); - return watcher; - } - - return { - registrations, - vscode: { - TreeItem, - ThemeIcon, - EventEmitter, - TreeItemCollapsibleState: { - None: 0, - Expanded: 1, - Collapsed: 2, - }, - StatusBarAlignment: { - Left: 1, - Right: 2, - }, - ViewColumn: { - Beside: 2, - }, - commands: { - executeCommand: async (command, ...args) => { - registrations.executedCommands.push({ command, args }); - if (command === 'setContext') { - return undefined; - } - const handler = registrations.commands.get(command); - if (handler) { - return handler(...args); - } - return undefined; - }, - registerCommand: (command, handler) => { - registrations.commands.set(command, handler); - return disposable(() => registrations.commands.delete(command)); - }, - }, - scm: { - createSourceControl: (id, label) => { - const sourceControl = { - id, - label, - inputBox: { - value: '', - placeholder: '', - enabled: true, - visible: true, - }, - acceptInputCommand: undefined, - dispose() {}, - }; - registrations.sourceControls.push(sourceControl); - return sourceControl; - }, - }, - Uri: { - file: (fsPath) => ({ - scheme: 'file', - fsPath, - path: fsPath, - toString() { - return `file://${fsPath}`; - }, - }), - parse: (value) => { - const parsed = new URL(value); - return { - scheme: parsed.protocol.replace(/:$/, ''), - authority: parsed.host, - path: parsed.pathname, - toString() { - return value; - }, - }; - }, - }, - window: { - terminals: registrations.terminals, - showInformationMessage: async (...args) => { - registrations.infoMessages.push(args); - if (typeof args[0] === 'string') { - registrations.informationMessages.push(args[0]); - } - return registrations.infoResponses.shift(); - }, - showErrorMessage: async (message) => { - registrations.errorMessages.push(message); - return undefined; - }, - showWarningMessage: async (...args) => { - registrations.warningMessages.push(args); - return undefined; - }, - showInputBox: async (options) => { - registrations.inputBoxCalls.push(options); - return registrations.inputResponses.shift(); - }, - showQuickPick: async (items, options) => { - registrations.quickPickCalls.push({ items, options }); - return registrations.quickPickResponse; - }, - createTerminal: (options) => { - const terminal = { - options, - name: options?.name, - processId: Promise.resolve(options?.processId ?? registrations.nextTerminalPid++), - shown: false, - showArgs: [], - sentTexts: [], - show(preserveFocus) { - this.shown = true; - this.showArgs.push(preserveFocus); - }, - sendText(text, addNewLine) { - this.sentTexts.push({ text, addNewLine }); - }, - dispose() {}, - }; - registrations.terminals.push(terminal); - return terminal; - }, - showTextDocument: async (document, options) => { - registrations.shownDocuments.push({ document, options }); - return { document }; - }, - createWebviewPanel: (viewType, title, column, options) => { - const disposeListeners = []; - const panel = { - viewType, - title, - column, - options, - disposed: false, - revealCalls: [], - webview: { - html: '', - }, - onDidDispose(listener) { - disposeListeners.push(listener); - return disposable(() => { - const index = disposeListeners.indexOf(listener); - if (index >= 0) { - disposeListeners.splice(index, 1); - } - }); - }, - reveal(nextColumn) { - panel.revealCalls.push(nextColumn); - }, - dispose() { - if (panel.disposed) { - return; - } - panel.disposed = true; - for (const listener of [...disposeListeners]) { - listener(); - } - }, - }; - registrations.webviewPanels.push(panel); - return panel; - }, - createTreeView: (viewId, options) => { - const selectionListeners = []; - const treeView = { - viewId, - options, - badge: undefined, - message: undefined, - onDidChangeSelection(listener) { - selectionListeners.push(listener); - return disposable(); - }, - fireSelection(selection) { - for (const listener of selectionListeners) { - listener({ selection }); - } - }, - dispose() {}, - }; - registrations.treeViews.push(treeView); - registrations.providers.push({ viewId, provider: options.treeDataProvider }); - return treeView; - }, - createStatusBarItem: (alignment, priority) => { - const statusBarItem = { - alignment, - priority, - text: '', - tooltip: '', - command: undefined, - name: undefined, - visible: false, - show() { - this.visible = true; - }, - hide() { - this.visible = false; - }, - dispose() {}, - }; - registrations.statusBarItems.push(statusBarItem); - return statusBarItem; - }, - registerFileDecorationProvider: (provider) => { - registrations.decorationProviders.push(provider); - return disposable(); - }, - registerTreeDataProvider: (viewId, provider) => { - registrations.providers.push({ viewId, provider }); - return disposable(); - }, - }, - workspace: { - openTextDocument: async (options) => { - const document = { - ...options, - uri: { scheme: 'untitled' }, - }; - registrations.openedDocuments.push(document); - return document; - }, - createFileSystemWatcher: (pattern) => createFileWatcher(pattern), - findFiles: async () => [], - getConfiguration: (section, scope) => ({ - get: (key) => readConfigurationValue(section, scope, key), - update: async (key, value, target) => { - const scopePath = target === ConfigurationTarget.WorkspaceFolder - ? resolveWorkspaceScopePath(scope) - : tempRoot; - registrations.configurationUpdates.push({ section, key, scopePath, target, value }); - writeConfigurationValue(section, scopePath, key, value); - }, - }), - onDidChangeWorkspaceFolders: (listener) => { - registrations.workspaceFolderListeners.push(listener); - return disposable(() => { - const index = registrations.workspaceFolderListeners.indexOf(listener); - if (index >= 0) { - registrations.workspaceFolderListeners.splice(index, 1); - } - }); - }, - updateWorkspaceFolders(start, deleteCount, ...folders) { - registrations.workspaceFolderUpdates.push({ start, deleteCount, folders }); - this.workspaceFolders.splice(start, deleteCount, ...folders); - return true; - }, - workspaceFolders: [{ uri: { fsPath: tempRoot } }], - }, - ConfigurationTarget, - ThemeColor, - }, - }; -} - -async function flushAsyncWork() { - await Promise.resolve(); - await new Promise((resolve) => setImmediate(resolve)); -} - -test('agent-session-state writes and removes active session records', () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-active-session-')); - const branch = 'agent/codex/demo-task'; - const worktreePath = path.join(tempRoot, '.omx', 'agent-worktrees', 'agent__codex__demo-task'); - fs.mkdirSync(worktreePath, { recursive: true }); - - const start = runNode(sessionScript, [ - 'start', - '--repo', - tempRoot, - '--branch', - branch, - '--task', - 'demo-task', - '--agent', - 'codex', - '--worktree', - worktreePath, - '--pid', - String(process.pid), - '--cli', - 'codex', - '--task-mode', - 'caveman', - '--openspec-tier', - 'T1', - '--routing-reason', - 'explicit lightweight prefix', - ]); - assert.equal(start.status, 0, start.stderr); - - const sessionPath = sessionSchema.sessionFilePathForBranch(tempRoot, branch); - assert.equal(path.basename(sessionPath), 'agent__codex__demo-task.json'); - assert.equal(fs.existsSync(sessionPath), true); - - const parsed = JSON.parse(fs.readFileSync(sessionPath, 'utf8')); - assert.equal(parsed.branch, branch); - assert.equal(parsed.taskName, 'demo-task'); - assert.equal(parsed.agentName, 'codex'); - assert.equal(parsed.worktreePath, worktreePath); - assert.equal(parsed.taskMode, 'caveman'); - assert.equal(parsed.openspecTier, 'T1'); - assert.equal(parsed.taskRoutingReason, 'explicit lightweight prefix'); - assert.equal(parsed.state, 'working'); - assert.equal(typeof parsed.lastHeartbeatAt, 'string'); - assert.ok(Date.parse(parsed.lastHeartbeatAt) >= Date.parse(parsed.startedAt)); - - const sessions = sessionSchema.readActiveSessions(tempRoot); - assert.equal(sessions.length, 1); - assert.equal(sessions[0].label, 'agent__codex__demo-task'); - assert.equal(sessions[0].taskMode, 'caveman'); - assert.equal(sessions[0].openspecTier, 'T1'); - - const heartbeat = runNode(sessionScript, [ - 'heartbeat', - '--repo', - tempRoot, - '--branch', - branch, - '--state', - 'thinking', - ]); - assert.equal(heartbeat.status, 0, heartbeat.stderr); - const heartbeatParsed = JSON.parse(fs.readFileSync(sessionPath, 'utf8')); - assert.equal(heartbeatParsed.branch, branch); - assert.equal(heartbeatParsed.state, 'thinking'); - assert.ok(Date.parse(heartbeatParsed.lastHeartbeatAt) >= Date.parse(parsed.lastHeartbeatAt)); - - const stop = runNode(sessionScript, [ - 'stop', - '--repo', - tempRoot, - '--branch', - branch, - ]); - assert.equal(stop.status, 0, stop.stderr); - assert.equal(fs.existsSync(sessionPath), false); -}); - -test('gx internal heartbeat refreshes active session records through the CLI', () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-active-session-cli-heartbeat-')); - initGitRepo(tempRoot); - const branch = 'agent/codex/cli-heartbeat-task'; - const worktreePath = path.join(tempRoot, '.omx', 'agent-worktrees', 'agent__codex__cli-heartbeat-task'); - fs.mkdirSync(worktreePath, { recursive: true }); - const sessionPath = writeSessionRecord(tempRoot, sessionSchema.buildSessionRecord({ - repoRoot: tempRoot, - branch, - taskName: 'cli-heartbeat-task', - agentName: 'codex', - worktreePath, - pid: process.pid, - cliName: 'codex', - state: 'working', - })); - const before = JSON.parse(fs.readFileSync(sessionPath, 'utf8')); - - const heartbeat = runNode(cliEntry, [ - 'internal', - 'heartbeat', - '--target', - tempRoot, - '--branch', - branch, - '--state', - 'idle', - ], { cwd: repoRoot }); - assert.equal(heartbeat.status, 0, heartbeat.stderr); - - const after = JSON.parse(fs.readFileSync(sessionPath, 'utf8')); - assert.equal(after.branch, branch); - assert.equal(after.taskName, before.taskName); - assert.equal(after.state, 'idle'); - assert.ok(Date.parse(after.lastHeartbeatAt) >= Date.parse(before.lastHeartbeatAt)); -}); - -test('session-schema ignores stale or invalid session records', () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-active-session-stale-')); - const activeSessionsDir = sessionSchema.activeSessionsDirForRepo(tempRoot); - fs.mkdirSync(activeSessionsDir, { recursive: true }); - - const liveRecord = sessionSchema.buildSessionRecord({ - repoRoot: tempRoot, - branch: 'agent/codex/live-task', - taskName: 'live-task', - agentName: 'codex', - worktreePath: path.join(tempRoot, '.omx', 'agent-worktrees', 'live-task'), - pid: process.pid, - cliName: 'codex', - }); - fs.writeFileSync( - sessionSchema.sessionFilePathForBranch(tempRoot, liveRecord.branch), - `${JSON.stringify(liveRecord, null, 2)}\n`, - 'utf8', - ); - - const staleRecord = sessionSchema.buildSessionRecord({ - repoRoot: tempRoot, - branch: 'agent/codex/stale-task', - taskName: 'stale-task', - agentName: 'codex', - worktreePath: path.join(tempRoot, '.omx', 'agent-worktrees', 'stale-task'), - pid: 999999, - cliName: 'codex', - }); - fs.writeFileSync( - sessionSchema.sessionFilePathForBranch(tempRoot, staleRecord.branch), - `${JSON.stringify(staleRecord, null, 2)}\n`, - 'utf8', - ); - fs.writeFileSync(path.join(activeSessionsDir, 'broken.json'), '{broken json', 'utf8'); - - const sessions = sessionSchema.readActiveSessions(tempRoot); - assert.equal(sessions.length, 1); - assert.equal(sessions[0].branch, liveRecord.branch); - - const sessionsIncludingStale = sessionSchema.readActiveSessions(tempRoot, { includeStale: true }); - assert.equal(sessionsIncludingStale.length, 2); - assert.equal( - sessionsIncludingStale.find((session) => session.branch === staleRecord.branch)?.activityKind, - 'dead', - ); -}); - -test('session-schema falls back to managed worktree AGENT.lock telemetry when launcher state is absent', () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-active-session-lock-fallback-')); - const worktreePath = path.join( - tempRoot, - '.omx', - 'agent-worktrees', - 'agent__codex__live-lock-task', - ); - initGitRepo(worktreePath); - runGit(worktreePath, ['checkout', '-b', 'agent/codex/live-lock-task']); - 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'); - writeWorktreeLock(worktreePath); - - const [session] = sessionSchema.readActiveSessions(tempRoot); - assert.equal(session.sourceKind, 'worktree-lock'); - assert.equal(session.branch, 'agent/codex/live-lock-task'); - assert.equal(session.agentName, 'codex'); - assert.equal(session.taskName, 'Implement live worktree telemetry'); - assert.equal(session.activityKind, 'working'); - assert.equal(session.activityCountLabel, '1 file'); - assert.equal(session.telemetrySource, 'recodee-live-telemetry'); - assert.equal(session.telemetryUpdatedAt, '2026-04-22T08:56:00.000Z'); -}); - -test('session-schema falls back to plain managed worktrees when launcher state and AGENT.lock are absent', () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-active-session-managed-fallback-')); - const worktreePath = path.join( - tempRoot, - '.omx', - 'agent-worktrees', - 'agent__codex__plain-visible-task', - ); - initGitRepo(worktreePath); - runGit(worktreePath, ['checkout', '-b', 'agent/codex/plain-visible-task']); - 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 [session] = sessionSchema.readActiveSessions(tempRoot); - assert.equal(session.sourceKind, 'managed-worktree'); - assert.equal(session.branch, 'agent/codex/plain-visible-task'); - assert.equal(session.agentName, 'codex'); - assert.equal(session.taskName, 'agent__codex__plain-visible-task'); - assert.equal(session.activityKind, 'working'); - assert.equal(session.activityCountLabel, '1 file'); - assert.equal(session.telemetrySource, 'managed-worktree'); -}); - -test('session-schema prefers live worktree telemetry over a dead launcher record for the same worktree', () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-active-session-lock-prefer-')); - const worktreePath = path.join( - tempRoot, - '.omx', - 'agent-worktrees', - 'agent__codex__replace-dead-session', - ); - initGitRepo(worktreePath); - runGit(worktreePath, ['checkout', '-b', 'agent/codex/replace-dead-session']); - fs.writeFileSync(path.join(worktreePath, 'tracked.txt'), 'base\n', 'utf8'); - runGit(worktreePath, ['add', 'tracked.txt']); - runGit(worktreePath, ['commit', '-m', 'baseline']); - writeWorktreeLock(worktreePath, { - updatedAt: '2026-04-22T08:57:00.000Z', - }); - writeSessionRecord(tempRoot, sessionSchema.buildSessionRecord({ - repoRoot: tempRoot, - branch: 'agent/codex/replace-dead-session', - taskName: 'replace-dead-session', - agentName: 'codex', - worktreePath, - pid: 999999, - cliName: 'codex', - })); - - const [session] = sessionSchema.readActiveSessions(tempRoot, { includeStale: true }); - assert.equal(session.sourceKind, 'worktree-lock'); - assert.equal(session.activityKind, 'idle'); -}); - -test('session-schema derives working activity from dirty sandbox worktrees', () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-active-session-working-')); - const worktreePath = path.join(tempRoot, 'sandbox'); - initGitRepo(worktreePath); - fs.writeFileSync(path.join(worktreePath, 'tracked.txt'), 'base\n', 'utf8'); - runGit(worktreePath, ['add', 'tracked.txt']); - runGit(worktreePath, ['commit', '-m', 'baseline']); - - fs.writeFileSync(path.join(worktreePath, 'tracked.txt'), 'base\nchanged\n', 'utf8'); - fs.writeFileSync(path.join(worktreePath, 'new-file.txt'), 'new\n', 'utf8'); - - const record = sessionSchema.buildSessionRecord({ - repoRoot: tempRoot, - branch: 'agent/codex/working-task', - taskName: 'working-task', - agentName: 'codex', - worktreePath, - pid: process.pid, - cliName: 'codex', - }); - const sessionPath = sessionSchema.sessionFilePathForBranch(tempRoot, record.branch); - fs.mkdirSync(path.dirname(sessionPath), { recursive: true }); - fs.writeFileSync(sessionPath, `${JSON.stringify(record, null, 2)}\n`, 'utf8'); - - const [session] = sessionSchema.readActiveSessions(tempRoot); - assert.equal(session.activityKind, 'working'); - assert.equal(session.changeCount, 2); - assert.equal(session.activityCountLabel, '2 files'); - assert.deepEqual(session.changedPaths, ['sandbox/new-file.txt', 'sandbox/tracked.txt']); - assert.deepEqual(session.worktreeChangedPaths, ['new-file.txt', 'tracked.txt']); - assert.equal(session.activitySummary, 'new-file.txt, tracked.txt'); -}); - -test('session-schema derives blocked activity from git markers in the worktree git dir', () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-active-session-blocked-')); - const worktreePath = path.join(tempRoot, 'sandbox'); - initGitRepo(worktreePath); - fs.writeFileSync(path.join(worktreePath, 'tracked.txt'), 'base\n', 'utf8'); - runGit(worktreePath, ['add', 'tracked.txt']); - runGit(worktreePath, ['commit', '-m', 'baseline']); - fs.writeFileSync(path.join(worktreePath, '.git', 'MERGE_HEAD'), 'deadbeef\n', 'utf8'); - - const session = sessionSchema.deriveSessionActivity(sessionSchema.buildSessionRecord({ - repoRoot: tempRoot, - branch: 'agent/codex/blocked-task', - taskName: 'blocked-task', - agentName: 'codex', - worktreePath, - pid: process.pid, - cliName: 'codex', - })); - - assert.equal(session.activityKind, 'blocked'); - assert.equal(session.activitySummary, 'Merge in progress.'); -}); - -test('session-schema derives idle and stalled activity from clean worktree mtimes', () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-active-session-idle-')); - const worktreePath = path.join(tempRoot, 'sandbox'); - const trackedPath = path.join(worktreePath, 'tracked.txt'); - initGitRepo(worktreePath); - fs.writeFileSync(trackedPath, 'base\n', 'utf8'); - runGit(worktreePath, ['add', 'tracked.txt']); - runGit(worktreePath, ['commit', '-m', 'baseline']); - - const record = sessionSchema.buildSessionRecord({ - repoRoot: tempRoot, - branch: 'agent/codex/idle-task', - taskName: 'idle-task', - agentName: 'codex', - worktreePath, - pid: process.pid, - cliName: 'codex', - }); - const now = Date.parse('2026-04-22T10:00:00.000Z'); - - setPathMtime(trackedPath, now - 45_000); - const idleSession = sessionSchema.deriveSessionActivity(record, { now, useCache: false }); - assert.equal(idleSession.activityKind, 'idle'); - assert.match(idleSession.activitySummary, /Recent file activity 45s ago\./); - assert.equal(idleSession.lastFileActivityAt, new Date(now - 45_000).toISOString()); - - setPathMtime(trackedPath, now - (20 * 60 * 1000)); - const stalledSession = sessionSchema.deriveSessionActivity(record, { now, useCache: false }); - assert.equal(stalledSession.activityKind, 'stalled'); - assert.match(stalledSession.activitySummary, /No file activity for 20m 0s\./); - assert.equal(stalledSession.lastFileActivityAt, new Date(now - (20 * 60 * 1000)).toISOString()); -}); - -test('session-schema caps clean-worktree stat scans and caches activity lookups briefly', () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-active-session-cache-')); - const worktreePath = path.join(tempRoot, 'sandbox'); - initGitRepo(worktreePath); - - for (let index = 0; index < 205; index += 1) { - const filePath = path.join(worktreePath, 'src', `tracked-${String(index).padStart(3, '0')}.txt`); - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, `file ${index}\n`, 'utf8'); - } - runGit(worktreePath, ['add', '.']); - runGit(worktreePath, ['commit', '-m', 'baseline']); - - const now = Date.parse('2026-04-22T10:00:00.000Z'); - for (let index = 0; index < 205; index += 1) { - setPathMtime( - path.join(worktreePath, 'src', `tracked-${String(index).padStart(3, '0')}.txt`), - now - 90_000, - ); - } - const trackedPath = path.join(worktreePath, 'src', 'tracked-000.txt'); - setPathMtime(trackedPath, now - 30_000); - - const record = sessionSchema.buildSessionRecord({ - repoRoot: tempRoot, - branch: 'agent/codex/cached-activity', - taskName: 'cached-activity', - agentName: 'codex', - worktreePath, - pid: process.pid, - cliName: 'codex', - }); - - let statCount = 0; - const originalStatSync = fs.statSync; - fs.statSync = (...args) => { - const filePath = String(args[0] || ''); - if (filePath.startsWith(worktreePath) && filePath.endsWith('.txt')) { - statCount += 1; - } - return originalStatSync(...args); - }; - - try { - const firstSession = sessionSchema.deriveSessionActivity(record, { now }); - const firstStatCount = statCount; - const secondSession = sessionSchema.deriveSessionActivity(record, { now: now + 1_000 }); - - assert.equal(firstSession.activityKind, 'idle'); - assert.equal(firstSession.lastFileActivityAt, new Date(now - 30_000).toISOString()); - assert.ok(firstStatCount <= 200, `expected <=200 file stats, saw ${firstStatCount}`); - assert.equal(secondSession.lastFileActivityAt, firstSession.lastFileActivityAt); - assert.equal(statCount, firstStatCount); - } finally { - fs.statSync = originalStatSync; - sessionSchema.clearWorktreeActivityCache(worktreePath); - } -}); - -test('session-schema derives dead activity when the recorded pid is not alive', () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-active-session-dead-')); - const worktreePath = path.join(tempRoot, 'sandbox'); - initGitRepo(worktreePath); - fs.writeFileSync(path.join(worktreePath, 'tracked.txt'), 'base\n', 'utf8'); - runGit(worktreePath, ['add', 'tracked.txt']); - runGit(worktreePath, ['commit', '-m', 'baseline']); - - const session = sessionSchema.deriveSessionActivity(sessionSchema.buildSessionRecord({ - repoRoot: tempRoot, - branch: 'agent/codex/dead-task', - taskName: 'dead-task', - agentName: 'codex', - worktreePath, - pid: 999999, - cliName: 'codex', - })); - - assert.equal(session.activityKind, 'dead'); - assert.equal(session.activitySummary, 'Recorded PID is not alive.'); - assert.equal(session.pidAlive, false); -}); - -test('session-schema derives dead activity when launcher heartbeat is stale', () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-active-session-stale-heartbeat-')); - const worktreePath = path.join(tempRoot, 'sandbox'); - const lastHeartbeatAt = '2026-04-22T10:00:00.000Z'; - const session = sessionSchema.deriveSessionActivity(sessionSchema.buildSessionRecord({ - repoRoot: tempRoot, - branch: 'agent/codex/stale-heartbeat-task', - taskName: 'stale-heartbeat-task', - agentName: 'codex', - worktreePath, - pid: process.pid, - cliName: 'codex', - startedAt: lastHeartbeatAt, - lastHeartbeatAt, - }), { now: Date.parse('2026-04-22T10:06:00.000Z') }); - - assert.equal(session.activityKind, 'dead'); - assert.equal(session.activitySummary, 'Heartbeat stale for 6m 0s.'); -}); - -test('session-schema derives repo change rows from root git status', () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-active-session-root-status-')); - initGitRepo(tempRoot); - fs.writeFileSync(path.join(tempRoot, 'tracked.txt'), 'base\n', 'utf8'); - runGit(tempRoot, ['add', 'tracked.txt']); - runGit(tempRoot, ['commit', '-m', 'baseline']); - - fs.writeFileSync(path.join(tempRoot, 'tracked.txt'), 'base\nchanged\n', 'utf8'); - fs.writeFileSync(path.join(tempRoot, 'new-file.txt'), 'new\n', 'utf8'); - fs.mkdirSync(path.join(tempRoot, '.omx', 'agent-worktrees', 'agent__codex__sandbox'), { recursive: true }); - fs.writeFileSync( - path.join(tempRoot, '.omx', 'agent-worktrees', 'agent__codex__sandbox', 'sandbox.txt'), - 'sandbox\n', - 'utf8', - ); - fs.mkdirSync(path.join(tempRoot, '.omc', 'agent-worktrees', 'agent__claude__sandbox'), { recursive: true }); - fs.writeFileSync( - path.join(tempRoot, '.omc', 'agent-worktrees', 'agent__claude__sandbox', 'sandbox.txt'), - 'sandbox\n', - 'utf8', - ); - fs.mkdirSync(path.join(tempRoot, '.omx', 'state', 'active-sessions'), { recursive: true }); - fs.writeFileSync( - path.join(tempRoot, '.omx', 'state', 'active-sessions', 'agent__codex__sandbox.json'), - '{}\n', - 'utf8', - ); - fs.writeFileSync( - path.join(tempRoot, '.omx', 'state', 'agent-file-locks.json'), - '{"locks":{}}\n', - 'utf8', - ); - - const changes = sessionSchema.readRepoChanges(tempRoot); - assert.deepEqual( - changes.map((change) => [change.relativePath, change.statusLabel]), - [ - ['new-file.txt', 'U'], - ['tracked.txt', 'M'], - ], - ); -}); - -test('session-schema reads inspect data from base-branch config, log tail, and held locks', () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-active-session-inspect-')); - const remoteRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-active-session-inspect-remote-')); - const branch = 'agent/codex/inspect-task'; - - initGitRepo(tempRoot); - runGit(tempRoot, ['checkout', '-b', 'main']); - fs.writeFileSync(path.join(tempRoot, 'tracked.txt'), 'base\n', 'utf8'); - runGit(tempRoot, ['add', 'tracked.txt']); - runGit(tempRoot, ['commit', '-m', 'baseline']); - runGit(remoteRoot, ['init', '--bare']); - runGit(tempRoot, ['remote', 'add', 'origin', remoteRoot]); - runGit(tempRoot, ['push', '-u', 'origin', 'main']); - runGit(tempRoot, ['config', 'multiagent.baseBranch', 'main']); - runGit(tempRoot, ['checkout', '-b', branch]); - fs.writeFileSync(path.join(tempRoot, 'tracked.txt'), 'base\ninspect\n', 'utf8'); - runGit(tempRoot, ['add', 'tracked.txt']); - runGit(tempRoot, ['commit', '-m', 'inspect ahead commit']); - - const logPath = path.join( - tempRoot, - '.omx', - 'logs', - `agent-${sessionSchema.sanitizeBranchForFile(branch)}.log`, - ); - fs.mkdirSync(path.dirname(logPath), { recursive: true }); - fs.writeFileSync(logPath, 'log line 1\nlog line 2\n', 'utf8'); - - const lockPath = path.join(tempRoot, '.omx', 'state', 'agent-file-locks.json'); - fs.mkdirSync(path.dirname(lockPath), { recursive: true }); - fs.writeFileSync(lockPath, `${JSON.stringify({ - locks: { - 'src/alpha.js': { - branch, - claimed_at: '2026-04-22T09:10:00.000Z', - allow_delete: false, - }, - 'src/beta.js': { - branch, - claimed_at: '2026-04-22T09:11:00.000Z', - allow_delete: true, - }, - 'src/foreign.js': { - branch: 'agent/codex/other-task', - claimed_at: '2026-04-22T09:12:00.000Z', - allow_delete: false, - }, - }, - }, null, 2)}\n`, 'utf8'); - - const inspectData = sessionSchema.readSessionInspectData({ - repoRoot: tempRoot, - branch, - worktreePath: tempRoot, - }); - - assert.equal(inspectData.baseBranch, 'main'); - assert.equal(inspectData.compareRef, 'origin/main'); - assert.equal(inspectData.aheadCount, 1); - assert.equal(inspectData.behindCount, 0); - assert.equal(inspectData.logPath, logPath); - assert.equal(inspectData.logExists, true); - assert.match(inspectData.logTailText, /log line 2/); - assert.deepEqual( - inspectData.heldLocks.map((entry) => entry.relativePath), - ['src/alpha.js', 'src/beta.js'], - ); -}); - -test('install-vscode-active-agents-extension installs the current extension into a canonical dir and refreshes recent patch compatibility copies', () => { - const tempExtensionsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-ext-')); - const manifest = readExtensionManifest(); - const extensionId = `${manifest.publisher}.${manifest.name}`; - const [major, minor, patch] = parseSimpleSemver(manifest.version); - const canonicalDir = path.join(tempExtensionsDir, extensionId); - const currentVersionDir = path.join(tempExtensionsDir, `${extensionId}-${manifest.version}`); - const recentCompatDir = patch > 0 - ? path.join(tempExtensionsDir, `${extensionId}-${major}.${minor}.${patch - 1}`) - : currentVersionDir; - const farLegacyDir = path.join(tempExtensionsDir, `${extensionId}-99.99.99`); - const retiredLegacyDir = path.join(tempExtensionsDir, 'recodeee.gitguardex-active-agents'); - const retiredLegacyVersionDir = path.join(tempExtensionsDir, 'recodeee.gitguardex-active-agents-0.0.18'); - - fs.mkdirSync(recentCompatDir, { recursive: true }); - fs.writeFileSync(path.join(recentCompatDir, 'stale.txt'), 'old', 'utf8'); - fs.mkdirSync(farLegacyDir, { recursive: true }); - fs.writeFileSync(path.join(farLegacyDir, 'stale.txt'), 'old', 'utf8'); - fs.mkdirSync(retiredLegacyDir, { recursive: true }); - fs.writeFileSync(path.join(retiredLegacyDir, 'extension.js'), 'vscode.scm.createSourceControl();\n', 'utf8'); - fs.mkdirSync(retiredLegacyVersionDir, { recursive: true }); - fs.writeFileSync(path.join(retiredLegacyVersionDir, 'extension.js'), 'vscode.scm.createSourceControl();\n', 'utf8'); - - const result = runNode(installScript, ['--extensions-dir', tempExtensionsDir], { - cwd: repoRoot, - }); - assert.equal(result.status, 0, result.stderr); - - const installedManifest = readJson(path.join(canonicalDir, 'package.json')); - assert.equal(fs.existsSync(canonicalDir), true); - assert.equal(fs.existsSync(path.join(canonicalDir, 'extension.js')), true); - assert.equal(fs.existsSync(path.join(canonicalDir, 'session-schema.js')), true); - assert.equal(installedManifest.icon, 'icon.png'); - assert.equal(installedManifest.version, manifest.version); - assert.deepEqual(installedManifest.activationEvents, manifest.activationEvents); - assert.equal(installedManifest.contributes.iconThemes, undefined); - 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); - assert.equal(fs.existsSync(farLegacyDir), false); - assert.equal(fs.existsSync(retiredLegacyDir), false); - assert.equal(fs.existsSync(retiredLegacyVersionDir), false); - assert.match(result.stdout, new RegExp(`Installed ${extensionId}@${manifest.version} to ${canonicalDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`)); - assert.match(result.stdout, /Refreshed \d+ recent patch compatibility path\(s\)/); - assert.match(result.stdout, /Removed 2 retired extension install path\(s\)/); - assert.match(result.stdout, /Reload each already-open VS Code window/); -}); - -test('active-agents extension edits require a higher manifest version than the base branch', () => { - const baseRef = resolveRepoBaseRef(); - const changedPaths = readChangedExtensionPaths(baseRef); - - if (changedPaths.length === 0) { - return; - } - - const liveManifest = readExtensionManifest(); - const templateManifest = readExtensionManifest(templateExtensionManifestPath); - const baseManifest = readBaseExtensionManifest(baseRef); - - assert.equal( - liveManifest.version, - templateManifest.version, - 'Live and template Active Agents manifests must stay in sync.', - ); - assert.deepEqual( - liveManifest.activationEvents, - templateManifest.activationEvents, - 'Live and template Active Agents activation events must stay in sync.', - ); - assert.equal( - liveManifest.contributes.iconThemes, - templateManifest.contributes.iconThemes, - 'Live and template Active Agents icon theme contributions must stay in sync.', - ); - assert.deepEqual( - liveManifest.contributes.viewsContainers, - templateManifest.contributes.viewsContainers, - 'Live and template Active Agents view containers must stay in sync.', - ); - assert.equal( - liveManifest.activationEvents.includes('onStartupFinished'), - true, - 'Active Agents manifests must activate on VS Code startup.', - ); - assert.ok( - compareSimpleSemver(liveManifest.version, baseManifest.version) > 0, - [ - `Active Agents extension files changed (${changedPaths.join(', ')})`, - `but version ${liveManifest.version} did not increase above ${baseManifest.version}.`, - ].join(' '), - ); -}); - -test('active-agents manifest uses a dedicated activity bar container with a hive icon', () => { - const manifest = readExtensionManifest(); - const activitybarContainers = manifest.contributes.viewsContainers?.activitybar || []; - const activeAgentsContainer = activitybarContainers.find( - (entry) => entry.id === 'gitguardex-active-agents-container', - ); - assert.ok(activeAgentsContainer, 'Expected the Active Agents activity bar container.'); - assert.match(activeAgentsContainer.id, /^[A-Za-z0-9_-]+$/); - assert.equal(activeAgentsContainer.title, 'Active Agents'); - assert.equal(activeAgentsContainer.icon, 'media/active-agents-hivemind.svg'); - - const activeAgentsViews = manifest.contributes.views?.['gitguardex-active-agents-container'] || []; - assert.deepEqual(activeAgentsViews, [ - { - id: 'gitguardex.activeAgents', - name: 'Active Agents', - contextualTitle: 'Active Agents', - icon: 'media/active-agents-hivemind.svg', - visibility: 'visible', - }, - ]); -}); - -test('active-agents manifest does not contribute a file icon theme', () => { - const manifest = readExtensionManifest(); - assert.equal(manifest.contributes.iconThemes, undefined); -}); - -test('active-agents manifest contributes restart actions for extension management and view title', () => { - const manifest = readExtensionManifest(); - const templateManifest = readExtensionManifest(templateExtensionManifestPath); - - const restartCommand = manifest.contributes.commands.find( - (entry) => entry.command === 'gitguardex.activeAgents.restart', - ); - assert.deepEqual(restartCommand, { - command: 'gitguardex.activeAgents.restart', - title: 'Restart Active Agents', - icon: '$(debug-restart)', - }); - - const restartViewTitleAction = manifest.contributes.menus['view/title'].find( - (entry) => entry.command === 'gitguardex.activeAgents.restart', - ); - assert.deepEqual(restartViewTitleAction, { - command: 'gitguardex.activeAgents.restart', - when: 'view == gitguardex.activeAgents', - group: 'navigation@8', - }); - - const restartExtensionAction = manifest.contributes.menus['extension/context'].find( - (entry) => entry.command === 'gitguardex.activeAgents.restart', - ); - assert.deepEqual(restartExtensionAction, { - command: 'gitguardex.activeAgents.restart', - when: 'extension == Recodee.gitguardex-active-agents && extensionStatus == installed', - group: '2_configure@2', - }); - - assert.deepEqual( - manifest.contributes.menus['extension/context'], - templateManifest.contributes.menus['extension/context'], - ); -}); - -test('active-agents manifest contributes dismiss only for stalled and dead session rows', () => { - const manifest = readExtensionManifest(); - const templateManifest = readExtensionManifest(templateExtensionManifestPath); - - const dismissCommand = manifest.contributes.commands.find( - (entry) => entry.command === 'gitguardex.activeAgents.dismissSession', - ); - assert.deepEqual(dismissCommand, { - command: 'gitguardex.activeAgents.dismissSession', - title: 'Dismiss', - icon: '$(trash)', - }); - - const dismissMenuAction = manifest.contributes.menus['view/item/context'].find( - (entry) => entry.command === 'gitguardex.activeAgents.dismissSession', - ); - assert.deepEqual(dismissMenuAction, { - command: 'gitguardex.activeAgents.dismissSession', - when: 'view == gitguardex.activeAgents && viewItem =~ /^gitguardex\\.session\\.(stalled|dead)$/', - group: 'inline', - }); - - assert.deepEqual( - manifest.contributes.menus['view/item/context'], - templateManifest.contributes.menus['view/item/context'], - ); -}); - -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 = { - ...readExtensionManifest(), - version: '9.9.9', - }; - const repoManifestPath = path.join(tempRoot, 'vscode', 'guardex-active-agents', 'package.json'); - const repoInstallScriptPath = path.join(tempRoot, 'scripts', 'install-vscode-active-agents-extension.js'); - fs.mkdirSync(path.dirname(repoManifestPath), { recursive: true }); - fs.writeFileSync(repoManifestPath, `${JSON.stringify(repoManifest, null, 2)}\n`, 'utf8'); - fs.mkdirSync(path.dirname(repoInstallScriptPath), { recursive: true }); - fs.writeFileSync(repoInstallScriptPath, '#!/usr/bin/env node\n', 'utf8'); - - const execCalls = []; - const originalExecFile = cp.execFile; - let context; - cp.execFile = (file, args, options, callback) => { - execCalls.push({ file, args, options }); - callback(null, '[guardex-active-agents] ok\n', ''); - }; - - try { - const { registrations, vscode } = createMockVscode(tempRoot); - registrations.infoResponses.push('Reload Window'); - const extension = loadExtensionWithMockVscode(vscode); - context = { - subscriptions: [], - extension: { - packageJSON: { - version: '0.0.2', - }, - }, - }; - - extension.activate(context); - await flushAsyncWork(); - - assert.equal(execCalls.length, 1); - assert.equal(execCalls[0].file, process.execPath); - assert.deepEqual(execCalls[0].args, [repoInstallScriptPath]); - assert.equal(execCalls[0].options.cwd, tempRoot); - assert.equal(execCalls[0].options.encoding, 'utf8'); - assert.match( - registrations.informationMessages.at(-1), - /GitGuardex Active Agents updated to 9\.9\.9.*reload any other already-open VS Code windows/i, - ); - assert.deepEqual(registrations.infoMessages.at(-1).slice(1), ['Reload Window', 'Later']); - assert.equal( - registrations.executedCommands.some( - (entry) => entry.command === 'workbench.action.reloadWindow', - ), - true, - ); - } finally { - cp.execFile = originalExecFile; - for (const subscription of context?.subscriptions ?? []) { - subscription.dispose?.(); - } - } -}); - -test('active-agents extension registers tree and decoration providers', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-view-')); - const { registrations, vscode } = createMockVscode(tempRoot); - const extension = loadExtensionWithMockVscode(vscode); - const context = { subscriptions: [] }; - - extension.activate(context); - await flushAsyncWork(); - - assert.equal(registrations.treeViews.length, 1); - assert.equal(registrations.sourceControls.length, 0); - assert.equal(registrations.statusBarItems.length, 1); - assert.equal(registrations.treeViews[0].viewId, 'gitguardex.activeAgents'); - assert.equal(registrations.statusBarItems[0].name, 'GitGuardex Active Agents'); - assert.equal(registrations.statusBarItems[0].command, 'gitguardex.activeAgents.focus'); - assert.equal(registrations.statusBarItems[0].visible, false); - assert.equal(registrations.providers.length, 1); - assert.equal(registrations.providers[0].viewId, 'gitguardex.activeAgents'); - assert.equal(registrations.decorationProviders.length, 1); - assert.equal(registrations.fileWatchers.length, 5); - assert.deepEqual( - registrations.fileWatchers.map((watcher) => watcher.pattern), - [ - '**/.omx/state/active-sessions/*.json', - '**/.omx/state/agent-file-locks.json', - '**/{.omx,.omc}/agent-worktrees/**/AGENT.lock', - '**/{.omx,.omc}/agent-worktrees/*/.git', - '**/.omx/logs/*.log', - ], - ); - assert.equal(registrations.workspaceFolderListeners.length, 1); - - const provider = registrations.providers[0].provider; - assert.equal(typeof provider.getTreeItem, 'function'); - assert.equal(typeof registrations.commands.get('gitguardex.activeAgents.startAgent'), 'function'); - assert.equal(typeof registrations.commands.get('gitguardex.activeAgents.restart'), 'function'); - assert.equal(typeof registrations.commands.get('gitguardex.activeAgents.inspect'), 'function'); - - const rootItems = await provider.getChildren(); - assert.equal(rootItems.length, 1); - assert.equal(rootItems[0].label, 'No active Guardex agents'); - assert.equal(registrations.treeViews[0].badge, undefined); - assert.equal(registrations.treeViews[0].message, undefined); - - for (const subscription of context.subscriptions) { - subscription.dispose?.(); - } -}); - -test('active-agents extension closes deleted worktree repositories during refresh', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-close-deleted-')); - const worktreePath = path.join(tempRoot, '.omx', 'agent-worktrees', 'deleted-task'); - fs.mkdirSync(worktreePath, { recursive: true }); - const sessionPath = writeSessionRecord(tempRoot, sessionSchema.buildSessionRecord({ - repoRoot: tempRoot, - branch: 'agent/codex/deleted-task', - taskName: 'deleted-task', - agentName: 'codex', - worktreePath, - pid: process.pid, - cliName: 'codex', - })); - - const { registrations, vscode } = createMockVscode(tempRoot); - vscode.workspace.workspaceFolders = [ - { uri: { fsPath: tempRoot } }, - { uri: { fsPath: worktreePath } }, - ]; - vscode.workspace.findFiles = async () => [{ fsPath: sessionPath }]; - const extension = loadExtensionWithMockVscode(vscode); - const context = { subscriptions: [] }; - - extension.activate(context); - await flushAsyncWork(); - - const gitCloseCalls = () => registrations.executedCommands.filter((entry) => ( - entry.command === 'git.close' - )); - assert.equal(gitCloseCalls().length, 0); - - fs.rmSync(worktreePath, { recursive: true, force: true }); - await registrations.commands.get('gitguardex.activeAgents.refresh')(); - await flushAsyncWork(); - - assert.equal(gitCloseCalls().length, 1); - assert.equal(gitCloseCalls()[0].args[0].fsPath, path.resolve(worktreePath)); - assert.deepEqual(registrations.workspaceFolderUpdates, [ - { start: 1, deleteCount: 1, folders: [] }, - ]); - assert.deepEqual( - vscode.workspace.workspaceFolders.map((folder) => folder.uri.fsPath), - [tempRoot], - ); - - await registrations.commands.get('gitguardex.activeAgents.refresh')(); - await flushAsyncWork(); - assert.equal(gitCloseCalls().length, 1); - - for (const subscription of context.subscriptions) { - subscription.dispose?.(); - } -}); - -test('active-agents extension closes deleted worktrees after session records disappear', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-close-deleted-no-session-')); - const worktreePath = path.join(tempRoot, '.omx', 'agent-worktrees', 'deleted-task'); - fs.mkdirSync(worktreePath, { recursive: true }); - const sessionPath = writeSessionRecord(tempRoot, sessionSchema.buildSessionRecord({ - repoRoot: tempRoot, - branch: 'agent/codex/deleted-task', - taskName: 'deleted-task', - agentName: 'codex', - worktreePath, - pid: process.pid, - cliName: 'codex', - })); - let currentSessionFiles = [{ fsPath: sessionPath }]; - - const { registrations, vscode } = createMockVscode(tempRoot); - vscode.workspace.workspaceFolders = [ - { uri: { fsPath: tempRoot } }, - { uri: { fsPath: worktreePath } }, - ]; - vscode.workspace.findFiles = async () => currentSessionFiles; - const extension = loadExtensionWithMockVscode(vscode); - const context = { subscriptions: [] }; - - extension.activate(context); - await flushAsyncWork(); - - const gitCloseCalls = () => registrations.executedCommands.filter((entry) => ( - entry.command === 'git.close' - )); - assert.equal(gitCloseCalls().length, 0); - - fs.rmSync(worktreePath, { recursive: true, force: true }); - fs.rmSync(sessionPath, { force: true }); - currentSessionFiles = []; - await registrations.commands.get('gitguardex.activeAgents.refresh')(); - await flushAsyncWork(); - - assert.equal(gitCloseCalls().length, 1); - assert.equal(gitCloseCalls()[0].args[0].fsPath, path.resolve(worktreePath)); - assert.deepEqual(registrations.workspaceFolderUpdates, [ - { start: 1, deleteCount: 1, folders: [] }, - ]); - assert.deepEqual( - vscode.workspace.workspaceFolders.map((folder) => folder.uri.fsPath), - [tempRoot], - ); - - for (const subscription of context.subscriptions) { - subscription.dispose?.(); - } -}); - -test('active-agents extension closes deleted managed workspace folders without session state', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-close-stale-folder-')); - const worktreePath = path.join(tempRoot, '.omc', 'agent-worktrees', 'deleted-task'); - const { registrations, vscode } = createMockVscode(tempRoot); - vscode.workspace.workspaceFolders = [ - { uri: { fsPath: tempRoot } }, - { uri: { fsPath: worktreePath } }, - ]; - vscode.workspace.findFiles = async () => []; - const extension = loadExtensionWithMockVscode(vscode); - const context = { subscriptions: [] }; - - extension.activate(context); - await flushAsyncWork(); - - const gitCloseCalls = registrations.executedCommands.filter((entry) => ( - entry.command === 'git.close' - )); - assert.equal(gitCloseCalls.length, 1); - assert.equal(gitCloseCalls[0].args[0].fsPath, path.resolve(worktreePath)); - assert.deepEqual(registrations.workspaceFolderUpdates, [ - { start: 1, deleteCount: 1, folders: [] }, - ]); - assert.deepEqual( - vscode.workspace.workspaceFolders.map((folder) => folder.uri.fsPath), - [tempRoot], - ); - - for (const subscription of context.subscriptions) { - subscription.dispose?.(); - } -}); - -test('active-agents restart command restarts the extension host for this extension only', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-restart-command-')); - const { registrations, vscode } = createMockVscode(tempRoot); - const extension = loadExtensionWithMockVscode(vscode); - const context = { subscriptions: [] }; - - extension.activate(context); - await flushAsyncWork(); - - await registrations.commands.get('gitguardex.activeAgents.restart')('Recodee.gitguardex-active-agents'); - await registrations.commands.get('gitguardex.activeAgents.restart')('someone.else'); - - const restartCalls = registrations.executedCommands.filter( - (entry) => entry.command === 'workbench.action.restartExtensionHost', - ); - assert.equal(restartCalls.length, 1); - - for (const subscription of context.subscriptions) { - subscription.dispose?.(); - } -}); - -test('active-agents focus command opens the dedicated sidebar container', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-focus-view-')); - const { registrations, vscode } = createMockVscode(tempRoot); - const extension = loadExtensionWithMockVscode(vscode); - const context = { subscriptions: [] }; - - extension.activate(context); - await flushAsyncWork(); - await vscode.commands.executeCommand('gitguardex.activeAgents.focus'); - - assert.equal( - registrations.executedCommands.some((entry) => ( - entry.command === 'workbench.view.extension.gitguardex-active-agents-container' - )), - true, - ); - - for (const subscription of context.subscriptions) { - subscription.dispose?.(); - } -}); - -test('active-agents extension self-heals managed repo-scan ignores on activation and workspace changes', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-scan-ignores-')); - const secondRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-scan-ignores-second-')); - const { registrations, vscode } = createMockVscode(tempRoot); - const extension = loadExtensionWithMockVscode(vscode); - const context = { subscriptions: [] }; - const managedRepoScanIgnoredFolders = [ - '.omx/agent-worktrees', - '**/.omx/agent-worktrees', - '.omx/.tmp-worktrees', - '**/.omx/.tmp-worktrees', - '.omc/agent-worktrees', - '**/.omc/agent-worktrees', - '.omc/.tmp-worktrees', - '**/.omc/.tmp-worktrees', - ]; - const mergeManagedRepoScanIgnores = (values) => Array.from(new Set([ - ...values, - ...managedRepoScanIgnoredFolders, - ])); - - registrations.setConfigurationValue('git', tempRoot, 'repositoryScanIgnoredFolders', [ - 'custom-ignore', - '.omx/agent-worktrees', - '.omx/agent-worktrees', - ]); - - extension.activate(context); - await flushAsyncWork(); - - assert.deepEqual( - registrations.getConfigurationValue('git', tempRoot, 'repositoryScanIgnoredFolders'), - mergeManagedRepoScanIgnores([ - 'custom-ignore', - '.omx/agent-worktrees', - '.omx/agent-worktrees', - ]), - ); - assert.deepEqual(registrations.configurationUpdates, [ - { - section: 'git', - key: 'repositoryScanIgnoredFolders', - scopePath: tempRoot, - target: vscode.ConfigurationTarget.Workspace, - value: mergeManagedRepoScanIgnores([ - 'custom-ignore', - '.omx/agent-worktrees', - '.omx/agent-worktrees', - ]), - }, - ]); - - registrations.setConfigurationValue('git', secondRoot, 'repositoryScanIgnoredFolders', [ - 'second-ignore', - '.omc/agent-worktrees', - ]); - vscode.workspace.workspaceFolders = [ - { uri: { fsPath: tempRoot } }, - { uri: { fsPath: secondRoot } }, - ]; - registrations.workspaceFolderListeners[0]({ - added: [{ uri: { fsPath: secondRoot } }], - removed: [], - }); - await flushAsyncWork(); - - assert.deepEqual( - registrations.getConfigurationValue('git', secondRoot, 'repositoryScanIgnoredFolders'), - mergeManagedRepoScanIgnores([ - 'second-ignore', - '.omc/agent-worktrees', - ]), - ); - assert.deepEqual(registrations.configurationUpdates, [ - { - section: 'git', - key: 'repositoryScanIgnoredFolders', - scopePath: tempRoot, - target: vscode.ConfigurationTarget.Workspace, - value: mergeManagedRepoScanIgnores([ - 'custom-ignore', - '.omx/agent-worktrees', - '.omx/agent-worktrees', - ]), - }, - { - section: 'git', - key: 'repositoryScanIgnoredFolders', - scopePath: secondRoot, - target: vscode.ConfigurationTarget.WorkspaceFolder, - value: mergeManagedRepoScanIgnores([ - 'second-ignore', - '.omc/agent-worktrees', - ]), - }, - ]); - - for (const subscription of context.subscriptions) { - subscription.dispose?.(); - } -}); - -test('active-agents extension startAgent command uses gx agents start with a target repo', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-start-agent-')); - const { registrations, vscode } = createMockVscode(tempRoot); - registrations.inputResponses.push('demo task', 'codex'); - const extension = loadExtensionWithMockVscode(vscode); - const context = { subscriptions: [] }; - - extension.activate(context); - - await registrations.commands.get('gitguardex.activeAgents.startAgent')(); - - assert.equal(registrations.terminals.length, 1); - assert.deepEqual(registrations.terminals[0].options, { - name: `GitGuardex: ${path.basename(tempRoot)}`, - cwd: tempRoot, - }); - assert.equal(registrations.terminals[0].shown, true); - assert.deepEqual(registrations.terminals[0].sentTexts, [ - { - text: `gx agents start 'demo task' --agent 'codex' --target '${tempRoot}'`, - addNewLine: true, - }, - ]); - assert.deepEqual(registrations.quickPickCalls, []); - - for (const subscription of context.subscriptions) { - subscription.dispose?.(); - } -}); - -test('active-agents extension startAgent command uses gx agents start for plain repos', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-start-agent-fallback-')); - const { registrations, vscode } = createMockVscode(tempRoot); - registrations.inputResponses.push('demo task', 'codex'); - const extension = loadExtensionWithMockVscode(vscode); - const context = { subscriptions: [] }; - - extension.activate(context); - - await registrations.commands.get('gitguardex.activeAgents.startAgent')(); - - assert.equal(registrations.terminals.length, 1); - assert.deepEqual(registrations.terminals[0].options, { - name: `GitGuardex: ${path.basename(tempRoot)}`, - cwd: tempRoot, - }); - assert.equal(registrations.terminals[0].shown, true); - assert.deepEqual(registrations.terminals[0].sentTexts, [ - { - text: `gx agents start 'demo task' --agent 'codex' --target '${tempRoot}'`, - addNewLine: true, - }, - ]); - assert.deepEqual(registrations.quickPickCalls, []); - - for (const subscription of context.subscriptions) { - subscription.dispose?.(); - } -}); - -test('active-agents extension startAgent can target a nested Git repo', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-start-agent-nested-')); - const storefrontRoot = path.join(tempRoot, 'apps', 'storefront'); - const backendRoot = path.join(tempRoot, 'apps', 'backend'); - initGitRepo(storefrontRoot); - initGitRepo(backendRoot); - fs.writeFileSync(path.join(storefrontRoot, 'dirty.txt'), 'changed\n', 'utf8'); - const { registrations, vscode } = createMockVscode(tempRoot); - registrations.quickPickResponse = { - label: 'apps/storefront', - description: 'main · dirty', - repoRoot: storefrontRoot, - }; - registrations.inputResponses.push('nested task', 'codex'); - const extension = loadExtensionWithMockVscode(vscode); - const context = { subscriptions: [] }; - - extension.activate(context); - - await registrations.commands.get('gitguardex.activeAgents.startAgent')(); - - assert.equal(registrations.quickPickCalls.length, 1); - assert.deepEqual( - registrations.quickPickCalls[0].items.map((item) => item.repoRoot), - [tempRoot, backendRoot, storefrontRoot], - ); - assert.deepEqual( - registrations.quickPickCalls[0].items.map((item) => item.detail), - [tempRoot, backendRoot, storefrontRoot], - ); - const storefrontPick = registrations.quickPickCalls[0].items.find((item) => item.repoRoot === storefrontRoot); - assert.ok(storefrontPick.description.endsWith(' · dirty')); - assert.equal(registrations.terminals.length, 1); - assert.deepEqual(registrations.terminals[0].options, { - name: `GitGuardex: ${path.basename(storefrontRoot)}`, - cwd: storefrontRoot, - }); - assert.deepEqual(registrations.terminals[0].sentTexts, [ - { - text: `gx agents start 'nested task' --agent 'codex' --target '${storefrontRoot}'`, - addNewLine: true, - }, - ]); - - for (const subscription of context.subscriptions) { - subscription.dispose?.(); - } -}); - -test('active-agents extension startAgent defaults to the active editor nested repo', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-start-agent-active-editor-')); - const storefrontRoot = path.join(tempRoot, 'apps', 'storefront'); - const backendRoot = path.join(tempRoot, 'apps', 'backend'); - const editorPath = path.join(storefrontRoot, 'src', 'home.tsx'); - initGitRepo(storefrontRoot); - initGitRepo(backendRoot); - fs.mkdirSync(path.dirname(editorPath), { recursive: true }); - fs.writeFileSync(editorPath, 'export {};\n', 'utf8'); - const { registrations, vscode } = createMockVscode(tempRoot); - vscode.window.activeTextEditor = { document: { uri: vscode.Uri.file(editorPath) } }; - registrations.inputResponses.push('active editor task', 'codex'); - const extension = loadExtensionWithMockVscode(vscode); - const context = { subscriptions: [] }; - - extension.activate(context); - - await registrations.commands.get('gitguardex.activeAgents.startAgent')(); - - assert.deepEqual(registrations.quickPickCalls, []); - assert.equal(registrations.terminals.length, 1); - assert.deepEqual(registrations.terminals[0].options, { - name: `GitGuardex: ${path.basename(storefrontRoot)}`, - cwd: storefrontRoot, - }); - assert.deepEqual(registrations.terminals[0].sentTexts, [ - { - text: `gx agents start 'active editor task' --agent 'codex' --target '${storefrontRoot}'`, - addNewLine: true, - }, - ]); - - for (const subscription of context.subscriptions) { - subscription.dispose?.(); - } -}); - -test('active-agents extension groups live sessions under a repo node', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-live-view-')); - const sessionPath = writeSessionRecord(tempRoot, sessionSchema.buildSessionRecord({ - repoRoot: tempRoot, - branch: 'agent/codex/live-task', - taskName: 'live-task', - agentName: 'codex', - worktreePath: path.join(tempRoot, '.omx', 'agent-worktrees', 'live-task'), - pid: process.pid, - cliName: 'codex', - })); - - 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(); - assert.equal(repoItem.label, path.basename(tempRoot)); - assert.equal(repoItem.description, '0 working agents · 0 needs cleanup agents · 1 idle agent · 0 unassigned changes · 0 locked files · 0 conflicts'); - - assert.deepEqual((await provider.getChildren(repoItem)).map((item) => item.label), [ - 'Overview', - 'Idle / thinking', - 'Advanced details', - ]); - const overviewSection = await getSectionByLabel(provider, repoItem, 'Overview'); - const [summaryItem] = await provider.getChildren(overviewSection); - assert.equal(summaryItem.label, 'Summary'); - assert.equal(summaryItem.description, repoItem.description); - - const idleSection = await getSectionByLabel(provider, repoItem, 'Idle / thinking'); - assert.equal(idleSection.description, '1'); - - const { worktreeItem, sessionItem } = await getOnlyWorktreeAndSession(provider, idleSection); - assert.equal(worktreeItem, null); - assert.equal(sessionItem.label, 'live-task'); - assert.equal(sessionItem.session.branch, 'agent/codex/live-task'); - assert.match(sessionItem.description, /^Idle: codex · via OpenAI/); - assert.equal(sessionItem.iconPath.id, 'comment-discussion'); - assert.equal(sessionItem.resourceUri.scheme, 'gitguardex-agent'); - assert.equal( - sessionItem.resourceUri.toString(), - `gitguardex-agent://${sessionSchema.sanitizeBranchForFile('agent/codex/live-task')}`, - ); - assert.deepEqual(registrations.treeViews[0].badge, { - value: 1, - tooltip: repoItem.description, - }); - assert.equal(registrations.treeViews[0].message, undefined); - assert.equal( - registrations.executedCommands.some((entry) => ( - entry.command === 'setContext' - && entry.args[0] === 'guardex.hasAgents' - && entry.args[1] === true - )), - true, - ); - - for (const subscription of context.subscriptions) { - subscription.dispose?.(); - } -}); - -test('active-agents extension labels idle dirty finished worktrees as needing cleanup', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-cleanup-label-')); - initGitRepo(tempRoot); - fs.writeFileSync(path.join(tempRoot, 'tracked.txt'), 'base\n', 'utf8'); - runGit(tempRoot, ['add', 'tracked.txt']); - runGit(tempRoot, ['commit', '-m', 'baseline']); - - const worktreePath = path.join(tempRoot, '.omx', 'agent-worktrees', 'finished-task'); - fs.mkdirSync(path.dirname(worktreePath), { recursive: true }); - runGit(tempRoot, [ - 'worktree', - 'add', - '-b', - 'agent/codex/finished-task', - worktreePath, - 'HEAD', - ]); - const changedPath = path.join(worktreePath, 'tracked.txt'); - fs.writeFileSync(changedPath, 'base\nleftover cleanup\n', 'utf8'); - setPathMtime(changedPath, Date.now() - (20 * 60 * 1000)); - - const sessionPath = writeSessionRecord(tempRoot, sessionSchema.buildSessionRecord({ - repoRoot: tempRoot, - branch: 'agent/codex/finished-task', - taskName: 'finished-task', - agentName: 'codex', - worktreePath, - pid: process.pid, - cliName: 'codex', - })); - - 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(); - assert.equal(repoItem.description, '0 working agents · 1 needs cleanup agent · 0 idle agents · 0 unassigned changes · 0 locked files · 0 conflicts'); - assert.deepEqual((await provider.getChildren(repoItem)).map((item) => item.label), [ - 'Overview', - 'Needs cleanup', - 'Advanced details', - ]); - - const cleanupSection = await getSectionByLabel(provider, repoItem, 'Needs cleanup'); - const { worktreeItem, sessionItem } = await getOnlyWorktreeAndSession(provider, cleanupSection); - assert.equal(worktreeItem, null); - assert.equal(sessionItem.label, 'finished-task'); - assert.match(sessionItem.description, /^Needs cleanup: codex · via OpenAI · 1 changed file/); - assert.equal(sessionItem.iconPath.id, 'pass-filled'); - assert.deepEqual(registrations.treeViews[0].badge, { - value: 1, - tooltip: repoItem.description, - }); - - for (const subscription of context.subscriptions) { - subscription.dispose?.(); - } -}); - -test('active-agents extension discovers nested managed-worktree subprojects under workspace roots', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-subprojects-')); - const nestedRepoRoot = path.join(tempRoot, 'gitguardex'); - initGitRepo(nestedRepoRoot); - fs.writeFileSync(path.join(nestedRepoRoot, 'tracked.txt'), 'base\n', 'utf8'); - runGit(nestedRepoRoot, ['add', 'tracked.txt']); - runGit(nestedRepoRoot, ['commit', '-m', 'baseline']); - - const worktreePath = path.join( - nestedRepoRoot, - '.omx', - 'agent-worktrees', - 'agent__codex__nested-visible-task', - ); - fs.mkdirSync(path.dirname(worktreePath), { recursive: true }); - runGit(nestedRepoRoot, [ - 'worktree', - 'add', - '-b', - 'agent/codex/nested-visible-task', - worktreePath, - ]); - fs.writeFileSync(path.join(worktreePath, 'tracked.txt'), 'base\nchanged\n', 'utf8'); - - const managedWorktreeGitFile = path.join(worktreePath, '.git'); - assert.equal(fs.statSync(managedWorktreeGitFile).isFile(), true); - - const { registrations, vscode } = createMockVscode(tempRoot); - vscode.workspace.findFiles = async (pattern) => { - if (pattern === '**/{.omx,.omc}/agent-worktrees/*/.git') { - return [{ fsPath: managedWorktreeGitFile }]; - } - return []; - }; - const extension = loadExtensionWithMockVscode(vscode); - const context = { subscriptions: [] }; - - extension.activate(context); - await flushAsyncWork(); - - const provider = registrations.providers[0].provider; - const [repoItem] = await provider.getChildren(); - assert.equal(repoItem.label, `${path.basename(tempRoot)}/gitguardex`); - assert.equal(repoItem.repoRoot, nestedRepoRoot); - assert.equal(repoItem.description, '1 working agent · 0 needs cleanup agents · 0 idle agents · 0 unassigned changes · 0 locked files · 0 conflicts'); - - const workingSection = await getSectionByLabel(provider, repoItem, 'Working now'); - const { worktreeItem, sessionItem } = await getOnlyWorktreeAndSession(provider, workingSection); - assert.equal(worktreeItem, null); - assert.equal(sessionItem.session.repoRoot, nestedRepoRoot); - assert.equal(sessionItem.session.worktreePath, worktreePath); - assert.equal(sessionItem.session.branch, 'agent/codex/nested-visible-task'); - assert.match(sessionItem.description, /^Working: codex · via OpenAI · 1 changed file/); - assert.deepEqual(registrations.treeViews[0].badge, { - value: 1, - tooltip: repoItem.description, - }); - - for (const subscription of context.subscriptions) { - subscription.dispose?.(); - } -}); - -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-')); - - const idleWarningPath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-idle-warning-')); - initGitRepo(idleWarningPath); - fs.writeFileSync(path.join(idleWarningPath, 'tracked.txt'), 'base\n', 'utf8'); - runGit(idleWarningPath, ['add', 'tracked.txt']); - runGit(idleWarningPath, ['commit', '-m', 'baseline']); - - const idleErrorPath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-idle-error-')); - initGitRepo(idleErrorPath); - fs.writeFileSync(path.join(idleErrorPath, 'tracked.txt'), 'base\n', 'utf8'); - runGit(idleErrorPath, ['add', 'tracked.txt']); - runGit(idleErrorPath, ['commit', '-m', 'baseline']); - - const workingPath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-idle-working-')); - initGitRepo(workingPath); - fs.writeFileSync(path.join(workingPath, 'tracked.txt'), 'base\n', 'utf8'); - runGit(workingPath, ['add', 'tracked.txt']); - runGit(workingPath, ['commit', '-m', 'baseline']); - fs.writeFileSync(path.join(workingPath, 'tracked.txt'), 'base\nchanged\n', 'utf8'); - - const sessionRecords = [ - { - branch: 'agent/codex/idle-warning', - worktreePath: idleWarningPath, - startedAt: new Date(Date.now() - (11 * 60 * 1000)).toISOString(), - }, - { - branch: 'agent/codex/idle-error', - worktreePath: idleErrorPath, - startedAt: new Date(Date.now() - (31 * 60 * 1000)).toISOString(), - }, - { - branch: 'agent/codex/working-now', - worktreePath: workingPath, - startedAt: new Date(Date.now() - (31 * 60 * 1000)).toISOString(), - }, - ]; - - for (const record of sessionRecords) { - const sessionPath = sessionSchema.sessionFilePathForBranch(tempRoot, record.branch); - fs.mkdirSync(path.dirname(sessionPath), { recursive: true }); - fs.writeFileSync( - sessionPath, - `${JSON.stringify(sessionSchema.buildSessionRecord({ - repoRoot: tempRoot, - branch: record.branch, - taskName: path.basename(record.worktreePath), - agentName: 'codex', - worktreePath: record.worktreePath, - pid: process.pid, - cliName: 'codex', - startedAt: record.startedAt, - }), null, 2)}\n`, - 'utf8', - ); - } - - const { registrations, vscode } = createMockVscode(tempRoot); - vscode.workspace.findFiles = async () => sessionRecords.map((record) => ({ - fsPath: sessionSchema.sessionFilePathForBranch(tempRoot, record.branch), - })); - const extension = loadExtensionWithMockVscode(vscode); - const context = { subscriptions: [] }; - - extension.activate(context); - await flushAsyncWork(); - - const provider = registrations.providers[0].provider; - await provider.getChildren(); - const decorationProvider = registrations.decorationProviders[0]; - - const warningDecoration = decorationProvider.provideFileDecoration(vscode.Uri.parse( - `gitguardex-agent://${sessionSchema.sanitizeBranchForFile('agent/codex/idle-warning')}`, - )); - assert.equal(warningDecoration.badge, '10m+'); - assert.equal(warningDecoration.tooltip, 'idle 10m+'); - assert.equal(warningDecoration.color.id, 'list.warningForeground'); - - const errorDecoration = decorationProvider.provideFileDecoration(vscode.Uri.parse( - `gitguardex-agent://${sessionSchema.sanitizeBranchForFile('agent/codex/idle-error')}`, - )); - assert.equal(errorDecoration.badge, '30m+'); - assert.equal(errorDecoration.tooltip, 'idle 30m+'); - assert.equal(errorDecoration.color.id, 'list.errorForeground'); - - const workingDecoration = decorationProvider.provideFileDecoration(vscode.Uri.parse( - `gitguardex-agent://${sessionSchema.sanitizeBranchForFile('agent/codex/working-now')}`, - )); - assert.equal(workingDecoration.badge, 'AI'); - assert.equal(workingDecoration.tooltip, 'OpenAI session via codex'); - - for (const subscription of context.subscriptions) { - subscription.dispose?.(); - } -}); - -test('active-agents refresh also invalidates session decorations', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-decoration-refresh-')); - const worktreePath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-decoration-refresh-session-')); - initGitRepo(worktreePath); - fs.writeFileSync(path.join(worktreePath, 'tracked.txt'), 'base\n', 'utf8'); - runGit(worktreePath, ['add', 'tracked.txt']); - runGit(worktreePath, ['commit', '-m', 'baseline']); - - const sessionPath = sessionSchema.sessionFilePathForBranch(tempRoot, 'agent/codex/idle-refresh'); - fs.mkdirSync(path.dirname(sessionPath), { recursive: true }); - fs.writeFileSync( - sessionPath, - `${JSON.stringify(sessionSchema.buildSessionRecord({ - repoRoot: tempRoot, - branch: 'agent/codex/idle-refresh', - taskName: 'idle-refresh', - agentName: 'codex', - worktreePath, - pid: process.pid, - cliName: 'codex', - startedAt: new Date(Date.now() - (11 * 60 * 1000)).toISOString(), - }), null, 2)}\n`, - 'utf8', - ); - - const { registrations, vscode } = createMockVscode(tempRoot); - vscode.workspace.findFiles = async () => [{ fsPath: sessionPath }]; - const extension = loadExtensionWithMockVscode(vscode); - const context = { subscriptions: [] }; - - extension.activate(context); - - const provider = registrations.providers[0].provider; - await provider.getChildren(); - await flushAsyncWork(); - - let decorationRefreshCount = 0; - registrations.decorationProviders[0].onDidChangeFileDecorations(() => { - decorationRefreshCount += 1; - }); - - await provider.refresh(); - assert.ok(decorationRefreshCount >= 1); - - for (const subscription of context.subscriptions) { - subscription.dispose?.(); - } -}); - -test('active-agents extension shows grouped repo changes beside active agents', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-working-view-')); - initGitRepo(tempRoot); - fs.writeFileSync(path.join(tempRoot, 'root-file.txt'), 'base\n', 'utf8'); - runGit(tempRoot, ['add', 'root-file.txt']); - runGit(tempRoot, ['commit', '-m', 'baseline']); - fs.writeFileSync(path.join(tempRoot, 'root-file.txt'), 'base\nchanged\n', 'utf8'); - - const worktreePath = path.join(tempRoot, 'sandbox'); - initGitRepo(worktreePath); - fs.mkdirSync(path.join(worktreePath, 'src'), { recursive: true }); - fs.writeFileSync(path.join(worktreePath, 'tracked.txt'), 'base\n', 'utf8'); - fs.writeFileSync(path.join(worktreePath, 'src', 'nested.js'), 'base\n', 'utf8'); - runGit(worktreePath, ['add', 'tracked.txt']); - runGit(worktreePath, ['add', 'src/nested.js']); - runGit(worktreePath, ['commit', '-m', 'baseline']); - fs.writeFileSync(path.join(worktreePath, 'tracked.txt'), 'base\nchanged\n', 'utf8'); - fs.writeFileSync(path.join(worktreePath, 'src', 'nested.js'), 'base\nchanged\n', 'utf8'); - - const latestTaskPreview = 'Fix cave hivemind hero layout'; - const liveSessionRecord = sessionSchema.buildSessionRecord({ - repoRoot: tempRoot, - branch: 'agent/codex/live-task', - taskName: 'live-task', - agentName: 'codex', - 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 }]; - const mockSessionSchema = { - ...sessionSchema, - readActiveSessions: () => sessionSchema.readActiveSessions(tempRoot, { includeStale: true }), - readRepoChanges: () => [ - { - relativePath: 'sandbox/src/nested.js', - absolutePath: path.join(worktreePath, 'src', 'nested.js'), - statusLabel: 'M', - statusText: 'Modified', - }, - { - relativePath: 'sandbox/tracked.txt', - absolutePath: path.join(worktreePath, 'tracked.txt'), - statusLabel: 'M', - statusText: 'Modified', - }, - { - relativePath: 'root-file.txt', - absolutePath: path.join(tempRoot, 'root-file.txt'), - statusLabel: 'M', - statusText: 'Modified', - }, - ], - }; - const extension = loadExtensionWithMockVscode(vscode, mockSessionSchema); - const context = { subscriptions: [] }; - - extension.activate(context); - await flushAsyncWork(); - - const provider = registrations.providers[0].provider; - const [repoItem] = await provider.getChildren(); - assert.equal(repoItem.description, '1 working agent · 0 needs cleanup agents · 0 idle agents · 1 unassigned change · 0 locked files · 0 conflicts'); - assert.deepEqual((await provider.getChildren(repoItem)).map((item) => item.label), [ - 'Overview', - 'Working now', - 'Unassigned changes', - 'Advanced details', - ]); - - const workingSection = await getSectionByLabel(provider, repoItem, 'Working now'); - const unassignedSection = await getSectionByLabel(provider, repoItem, 'Unassigned changes'); - const advancedSection = await getSectionByLabel(provider, repoItem, 'Advanced details'); - const overviewSection = await getSectionByLabel(provider, repoItem, 'Overview'); - - assert.equal(overviewSection.collapsibleState, vscode.TreeItemCollapsibleState.Expanded); - assert.equal(workingSection.collapsibleState, vscode.TreeItemCollapsibleState.Collapsed); - assert.equal(advancedSection.collapsibleState, vscode.TreeItemCollapsibleState.Collapsed); - - const { worktreeItem, sessionItem } = await getOnlyWorktreeAndSession(provider, workingSection); - assert.equal(worktreeItem, null); - 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 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, { - value: 1, - tooltip: repoItem.description, - }); - - 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, latestTaskPreview); - assert.equal(worktreeGroup.description, 'codex · 2 files'); - assert.equal(repoRootGroup.label, 'Repo root'); - - const [sessionGroup] = await provider.getChildren(worktreeGroup); - 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'); - assert.match(trackedItem.tooltip, /^tracked\.txt\nSummary M\nStatus Modified\n/); - - const [nestedItem] = await provider.getChildren(folderItem); - assert.equal(nestedItem.label, 'nested.js'); - assert.match(nestedItem.tooltip, /^src\/nested\.js\nSummary M\nStatus Modified\n/); - - const [rootItem] = await provider.getChildren(repoRootGroup); - assert.equal(rootItem.label, 'root-file.txt'); - assert.equal(rootItem.description, 'M'); - assert.match(rootItem.tooltip, /^root-file\.txt\nSummary M\nStatus Modified\n/); - - for (const subscription of context.subscriptions) { - subscription.dispose?.(); - } -}); - -test('active-agents extension surfaces live managed worktrees from AGENT.lock fallback', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-worktree-lock-view-')); - initGitRepo(tempRoot); - - const worktreePath = path.join( - tempRoot, - '.omx', - 'agent-worktrees', - 'agent__codex__lock-visible-task', - ); - initGitRepo(worktreePath); - runGit(worktreePath, ['checkout', '-b', 'agent/codex/lock-visible-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 projectPath = path.join(tempRoot, 'gitguardex'); - fs.mkdirSync(projectPath, { recursive: true }); - 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, - }, - ], - }, - ], - }); - - 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(); - assert.equal(repoItem.label, `${path.basename(tempRoot)}/gitguardex`); - assert.equal(repoItem.description, '1 working agent · 0 needs cleanup agents · 0 idle agents · 0 unassigned changes · 0 locked files · 0 conflicts'); - - assert.deepEqual((await provider.getChildren(repoItem)).map((item) => item.label), [ - 'Overview', - 'Working now', - 'Advanced details', - ]); - const workingSection = await getSectionByLabel(provider, repoItem, 'Working now'); - const overviewSection = await getSectionByLabel(provider, repoItem, 'Overview'); - 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'); - assert.equal(overviewSection.collapsibleState, vscode.TreeItemCollapsibleState.Expanded); - assert.equal(workingSection.collapsibleState, vscode.TreeItemCollapsibleState.Collapsed); - assert.equal(advancedSection.collapsibleState, vscode.TreeItemCollapsibleState.Collapsed); - 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')}`, - )); - assert.equal(snapshotDecoration.badge, 'N'); - assert.equal(snapshotDecoration.tooltip, 'Snapshot nagyviktor@edixa.com'); - - for (const subscription of context.subscriptions) { - subscription.dispose?.(); - } -}); - -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); - - const worktreePath = path.join( - tempRoot, - '.omx', - 'agent-worktrees', - 'agent__codex__plain-visible-task', - ); - initGitRepo(worktreePath); - runGit(worktreePath, ['checkout', '-b', 'agent/codex/plain-visible-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 { registrations, vscode } = createMockVscode(tempRoot); - vscode.workspace.findFiles = async () => []; - const extension = loadExtensionWithMockVscode(vscode); - const context = { subscriptions: [] }; - - extension.activate(context); - await flushAsyncWork(); - - const provider = registrations.providers[0].provider; - const [repoItem] = await provider.getChildren(); - assert.equal(repoItem.description, '1 working agent · 0 needs cleanup agents · 0 idle agents · 0 unassigned changes · 0 locked files · 0 conflicts'); - - const workingSection = await getSectionByLabel(provider, repoItem, 'Working now'); - 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, /^Working: codex · via OpenAI · 1 changed file/); - assert.match(sessionItem.tooltip, /Started /); - - for (const subscription of context.subscriptions) { - subscription.dispose?.(); - } -}); - -test('active-agents extension resolves owning repo sessions when the window is opened on a linked worktree', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-linked-worktree-view-')); - initGitRepo(tempRoot); - fs.writeFileSync(path.join(tempRoot, 'tracked.txt'), 'base\n', 'utf8'); - runGit(tempRoot, ['add', 'tracked.txt']); - runGit(tempRoot, ['commit', '-m', 'baseline']); - - const branch = 'agent/codex/linked-worktree-visible-task'; - const worktreePath = path.join( - tempRoot, - '.omx', - 'agent-worktrees', - 'agent__codex__linked-worktree-visible-task', - ); - fs.mkdirSync(path.dirname(worktreePath), { recursive: true }); - runGit(tempRoot, ['worktree', 'add', '-b', branch, worktreePath]); - fs.writeFileSync(path.join(worktreePath, 'tracked.txt'), 'base\nchanged\n', 'utf8'); - - writeSessionRecord(tempRoot, sessionSchema.buildSessionRecord({ - repoRoot: tempRoot, - branch, - taskName: 'linked-worktree-visible-task', - agentName: 'codex', - worktreePath, - pid: process.pid, - cliName: 'codex', - state: 'working', - })); - - const { registrations, vscode } = createMockVscode(tempRoot); - vscode.workspace.workspaceFolders = [{ uri: { fsPath: worktreePath } }]; - vscode.workspace.findFiles = async () => []; - const extension = loadExtensionWithMockVscode(vscode); - const context = { subscriptions: [] }; - - extension.activate(context); - await flushAsyncWork(); - - const provider = registrations.providers[0].provider; - const [repoItem] = await provider.getChildren(); - assert.equal(repoItem.label, path.basename(tempRoot)); - assert.equal(repoItem.description, '1 working agent · 0 needs cleanup agents · 0 idle agents · 0 unassigned changes · 0 locked files · 0 conflicts'); - - const workingSection = await getSectionByLabel(provider, repoItem, 'Working now'); - const { worktreeItem, sessionItem } = await getOnlyWorktreeAndSession(provider, workingSection); - assert.equal(worktreeItem, null); - assert.equal(sessionItem.session.repoRoot, tempRoot); - assert.equal(sessionItem.session.worktreePath, worktreePath); - assert.equal(sessionItem.session.branch, branch); - assert.match(sessionItem.description, /^Working: codex · via OpenAI · 1 changed file/); - - for (const subscription of context.subscriptions) { - subscription.dispose?.(); - } -}); - -test('active-agents extension decorates sessions and repo changes from the lock registry', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-lock-decorations-')); - initGitRepo(tempRoot); - fs.writeFileSync(path.join(tempRoot, 'root-file.txt'), 'base\n', 'utf8'); - runGit(tempRoot, ['add', 'root-file.txt']); - runGit(tempRoot, ['commit', '-m', 'baseline']); - fs.writeFileSync(path.join(tempRoot, 'root-file.txt'), 'base\nchanged\n', 'utf8'); - - const worktreePath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-lock-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/live-task'; - const sessionPath = sessionSchema.sessionFilePathForBranch(tempRoot, branch); - fs.mkdirSync(path.dirname(sessionPath), { recursive: true }); - fs.writeFileSync( - sessionPath, - `${JSON.stringify(sessionSchema.buildSessionRecord({ - repoRoot: tempRoot, - branch, - taskName: 'live-task', - agentName: 'codex', - worktreePath, - pid: process.pid, - cliName: 'codex', - }), null, 2)}\n`, - 'utf8', - ); - - const lockPath = path.join(tempRoot, '.omx', 'state', 'agent-file-locks.json'); - fs.mkdirSync(path.dirname(lockPath), { recursive: true }); - fs.writeFileSync(lockPath, `${JSON.stringify({ - locks: { - 'owned-file.txt': { - branch, - claimed_at: '2026-04-22T08:55:00.000Z', - allow_delete: false, - }, - 'root-file.txt': { - branch: 'agent/codex/other-task', - claimed_at: '2026-04-22T08:56:00.000Z', - allow_delete: false, - }, - 'tracked.txt': { - branch: 'agent/codex/other-task', - claimed_at: '2026-04-22T08:57:00.000Z', - allow_delete: false, - }, - }, - }, 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(); - assert.equal(repoItem.description, '1 working agent · 0 needs cleanup agents · 0 idle agents · 1 unassigned change · 3 locked files · 2 conflicts'); - const workingSection = await getSectionByLabel(provider, repoItem, 'Working now'); - const unassignedSection = await getSectionByLabel(provider, repoItem, 'Unassigned changes'); - const advancedSection = await getSectionByLabel(provider, repoItem, 'Advanced details'); - const { worktreeItem, sessionItem } = await getOnlyWorktreeAndSession(provider, workingSection); - assert.equal(worktreeItem, null); - assert.equal(sessionItem.label, 'live-task'); - assert.equal(sessionItem.session.branch, branch); - assert.match(sessionItem.tooltip, /1 lock/); - assert.match(sessionItem.tooltip, /Conflicts 1/); - - const activeAgentTree = await getSectionByLabel(provider, advancedSection, 'Active agent tree'); - const rawWorkingSection = await getSectionByLabel(provider, activeAgentTree, 'WORKING NOW'); - 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'); - assert.match(sessionChangeItem.tooltip, /Locked by agent\/codex\/other-task/); - - const [changeItem] = await provider.getChildren(unassignedSection); - assert.equal(changeItem.label, 'root-file.txt'); - assert.equal(changeItem.iconPath.id, 'warning'); - assert.match(changeItem.tooltip, /Locked by agent\/codex\/other-task/); - assert.deepEqual(registrations.treeViews[0].badge, { - value: 1, - tooltip: repoItem.description, - }); - assert.equal( - registrations.executedCommands.some((entry) => ( - entry.command === 'setContext' - && entry.args[0] === 'guardex.hasConflicts' - && entry.args[1] === true - )), - true, - ); - - for (const subscription of context.subscriptions) { - subscription.dispose?.(); - } -}); - -test('active-agents extension re-reads lock state on watcher events instead of every tree load', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-lock-watch-')); - const branch = 'agent/codex/live-task'; - const worktreePath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-lock-watch-worktree-')); - initGitRepo(worktreePath); - fs.writeFileSync(path.join(worktreePath, 'tracked.txt'), 'base\n', 'utf8'); - runGit(worktreePath, ['add', 'tracked.txt']); - runGit(worktreePath, ['commit', '-m', 'baseline']); - - const sessionPath = sessionSchema.sessionFilePathForBranch(tempRoot, branch); - fs.mkdirSync(path.dirname(sessionPath), { recursive: true }); - fs.writeFileSync( - sessionPath, - `${JSON.stringify(sessionSchema.buildSessionRecord({ - repoRoot: tempRoot, - branch, - taskName: 'live-task', - agentName: 'codex', - worktreePath, - pid: process.pid, - cliName: 'codex', - }), null, 2)}\n`, - 'utf8', - ); - - const lockPath = path.join(tempRoot, '.omx', 'state', 'agent-file-locks.json'); - fs.mkdirSync(path.dirname(lockPath), { recursive: true }); - fs.writeFileSync(lockPath, `${JSON.stringify({ - locks: { - 'owned-file.txt': { - branch, - claimed_at: '2026-04-22T08:57:00.000Z', - allow_delete: false, - }, - }, - }, null, 2)}\n`, 'utf8'); - - const { registrations, vscode } = createMockVscode(tempRoot); - vscode.workspace.findFiles = async () => [{ fsPath: sessionPath }]; - const extension = loadExtensionWithMockVscode(vscode); - const context = { subscriptions: [] }; - - const originalReadFileSync = fs.readFileSync; - let lockReadCount = 0; - fs.readFileSync = function patchedReadFileSync(filePath, ...args) { - if (path.resolve(String(filePath)) === lockPath) { - lockReadCount += 1; - } - return originalReadFileSync.call(this, filePath, ...args); - }; - - try { - extension.activate(context); - await flushAsyncWork(); - - const provider = registrations.providers[0].provider; - const lockWatcher = registrations.watchers.find((watcher) => watcher.pattern === '**/.omx/state/agent-file-locks.json'); - assert.ok(lockWatcher, 'expected lock watcher registration'); - - const [repoItem] = await provider.getChildren(); - const idleSection = await getSectionByLabel(provider, repoItem, 'Idle / thinking'); - const { worktreeItem, sessionItem } = await getOnlyWorktreeAndSession(provider, idleSection); - assert.equal(worktreeItem, null); - assert.equal(sessionItem.label, 'live-task'); - assert.equal(sessionItem.session.branch, branch); - assert.equal(lockReadCount, 1); - - await provider.getChildren(); - assert.equal(lockReadCount, 1); - - fs.writeFileSync(lockPath, `${JSON.stringify({ - locks: { - 'owned-file.txt': { - branch, - claimed_at: '2026-04-22T08:57:00.000Z', - allow_delete: false, - }, - 'second-owned-file.txt': { - branch, - claimed_at: '2026-04-22T08:58:00.000Z', - allow_delete: false, - }, - }, - }, null, 2)}\n`, 'utf8'); - lockWatcher.fireChange({ fsPath: lockPath }); - assert.equal(lockReadCount, 2); - - const [updatedRepoItem] = await provider.getChildren(); - const updatedIdleSection = await getSectionByLabel(provider, updatedRepoItem, 'Idle / thinking'); - const { worktreeItem: updatedWorktreeItem, sessionItem: updatedSessionItem } = await getOnlyWorktreeAndSession(provider, updatedIdleSection); - assert.equal(updatedWorktreeItem, null); - assert.equal(updatedSessionItem.label, 'live-task'); - assert.equal(updatedSessionItem.session.branch, branch); - - await provider.getChildren(); - assert.equal(lockReadCount, 2); - } finally { - fs.readFileSync = originalReadFileSync; - for (const subscription of context.subscriptions) { - subscription.dispose?.(); - } - } -}); - -test('active-agents extension groups blocked, working, idle, stalled, and dead sessions in order', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-state-groups-')); - const now = Date.now(); - - const blockedPath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-blocked-')); - initGitRepo(blockedPath); - fs.writeFileSync(path.join(blockedPath, 'tracked.txt'), 'base\n', 'utf8'); - runGit(blockedPath, ['add', 'tracked.txt']); - runGit(blockedPath, ['commit', '-m', 'baseline']); - fs.writeFileSync(path.join(blockedPath, '.git', 'MERGE_HEAD'), 'deadbeef\n', 'utf8'); - - const workingPath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-working-')); - initGitRepo(workingPath); - fs.writeFileSync(path.join(workingPath, 'tracked.txt'), 'base\n', 'utf8'); - runGit(workingPath, ['add', 'tracked.txt']); - runGit(workingPath, ['commit', '-m', 'baseline']); - fs.writeFileSync(path.join(workingPath, 'tracked.txt'), 'base\nchanged\n', 'utf8'); - - const idlePath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-idle-')); - initGitRepo(idlePath); - fs.writeFileSync(path.join(idlePath, 'tracked.txt'), 'base\n', 'utf8'); - runGit(idlePath, ['add', 'tracked.txt']); - runGit(idlePath, ['commit', '-m', 'baseline']); - setPathMtime(path.join(idlePath, 'tracked.txt'), now - 30_000); - - const stalledPath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-stalled-')); - initGitRepo(stalledPath); - fs.writeFileSync(path.join(stalledPath, 'tracked.txt'), 'base\n', 'utf8'); - runGit(stalledPath, ['add', 'tracked.txt']); - runGit(stalledPath, ['commit', '-m', 'baseline']); - setPathMtime(path.join(stalledPath, 'tracked.txt'), now - (20 * 60 * 1000)); - - const deadPath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-dead-')); - initGitRepo(deadPath); - fs.writeFileSync(path.join(deadPath, 'tracked.txt'), 'base\n', 'utf8'); - runGit(deadPath, ['add', 'tracked.txt']); - runGit(deadPath, ['commit', '-m', 'baseline']); - - const blockedSessionPath = writeSessionRecord(tempRoot, sessionSchema.buildSessionRecord({ - repoRoot: tempRoot, - branch: 'agent/codex/blocked-task', - taskName: 'blocked-task', - agentName: 'codex', - worktreePath: blockedPath, - pid: process.pid, - cliName: 'codex', - })); - const workingSessionPath = writeSessionRecord(tempRoot, sessionSchema.buildSessionRecord({ - repoRoot: tempRoot, - branch: 'agent/codex/working-task', - taskName: 'working-task', - agentName: 'codex', - worktreePath: workingPath, - pid: process.pid, - cliName: 'codex', - })); - const idleSessionPath = writeSessionRecord(tempRoot, sessionSchema.buildSessionRecord({ - repoRoot: tempRoot, - branch: 'agent/codex/idle-task', - taskName: 'idle-task', - agentName: 'codex', - worktreePath: idlePath, - pid: process.pid, - cliName: 'codex', - })); - const stalledSessionPath = writeSessionRecord(tempRoot, sessionSchema.buildSessionRecord({ - repoRoot: tempRoot, - branch: 'agent/codex/stalled-task', - taskName: 'stalled-task', - agentName: 'codex', - worktreePath: stalledPath, - pid: process.pid, - cliName: 'codex', - })); - const deadSessionPath = writeSessionRecord(tempRoot, sessionSchema.buildSessionRecord({ - repoRoot: tempRoot, - branch: 'agent/codex/dead-task', - taskName: 'dead-task', - agentName: 'codex', - worktreePath: deadPath, - pid: 999999, - cliName: 'codex', - })); - - const { registrations, vscode } = createMockVscode(tempRoot); - vscode.workspace.findFiles = async () => [ - { fsPath: blockedSessionPath }, - { fsPath: workingSessionPath }, - { fsPath: idleSessionPath }, - { fsPath: stalledSessionPath }, - { fsPath: deadSessionPath }, - ]; - const extension = loadExtensionWithMockVscode(vscode); - const context = { subscriptions: [] }; - - extension.activate(context); - await flushAsyncWork(); - - const provider = registrations.providers[0].provider; - const [repoItem] = await provider.getChildren(); - assert.equal(repoItem.description, '2 working agents · 0 needs cleanup agents · 2 idle agents · 0 unassigned changes · 0 locked files · 0 conflicts'); - - assert.deepEqual((await provider.getChildren(repoItem)).map((item) => item.label), [ - 'Overview', - 'Working now', - 'Idle / thinking', - 'Advanced details', - ]); - const workingSection = await getSectionByLabel(provider, repoItem, 'Working now'); - const idleThinkingSection = await getSectionByLabel(provider, repoItem, 'Idle / thinking'); - assert.equal(workingSection.description, '2'); - assert.equal(idleThinkingSection.description, '3'); - - const blockedItem = await getSessionByBranch(provider, workingSection, 'agent/codex/blocked-task'); - const workingItem = await getSessionByBranch(provider, workingSection, 'agent/codex/working-task'); - 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, /^Blocked: codex · via OpenAI/); - assert.equal(blockedItem.iconPath.id, 'warning'); - assert.match(workingItem.description, /^Working: codex · via OpenAI · 1 changed file/); - assert.equal(workingItem.iconPath.id, 'loading~spin'); - assert.equal(workingItem.contextValue, 'gitguardex.session.working'); - assert.match(idleItem.description, /^Idle: codex · via OpenAI/); - assert.equal(idleItem.iconPath.id, 'comment-discussion'); - assert.match(stalledItem.description, /^Stale: codex · via OpenAI/); - assert.equal(stalledItem.iconPath.id, 'clock'); - assert.equal(stalledItem.contextValue, 'gitguardex.session.stalled'); - assert.match(deadItem.description, /^Dead: codex · via OpenAI/); - assert.equal(deadItem.iconPath.id, 'error'); - assert.equal(deadItem.contextValue, 'gitguardex.session.dead'); - assert.deepEqual(registrations.treeViews[0].badge, { - value: 5, - tooltip: repoItem.description, - }); - - for (const subscription of context.subscriptions) { - subscription.dispose?.(); - } -}); - -test('active-agents extension watches active sessions, lock files, logs, and session git indexes', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-watchers-')); - const worktreePath = path.join(tempRoot, 'sandbox'); - initGitRepo(worktreePath); - - const sessionPath = writeSessionRecord(tempRoot, sessionSchema.buildSessionRecord({ - repoRoot: tempRoot, - branch: 'agent/codex/watch-task', - taskName: 'watch-task', - agentName: 'codex', - worktreePath, - pid: process.pid, - cliName: 'codex', - })); - - const { registrations, vscode } = createMockVscode(tempRoot); - let currentSessionFiles = [{ fsPath: sessionPath }]; - vscode.workspace.findFiles = async () => currentSessionFiles; - const extension = loadExtensionWithMockVscode(vscode); - const context = { subscriptions: [] }; - - extension.activate(context); - await flushAsyncWork(); - - assert.deepEqual( - registrations.fileWatchers.map((watcher) => watcher.pattern), - [ - '**/.omx/state/active-sessions/*.json', - '**/.omx/state/agent-file-locks.json', - '**/{.omx,.omc}/agent-worktrees/**/AGENT.lock', - '**/{.omx,.omc}/agent-worktrees/*/.git', - '**/.omx/logs/*.log', - path.join(worktreePath, '.git', 'index'), - ], - ); - - currentSessionFiles = []; - fs.unlinkSync(sessionPath); - registrations.fileWatchers[0].fireDelete({ fsPath: sessionPath }); - await new Promise((resolve) => setTimeout(resolve, 350)); - await flushAsyncWork(); - - assert.equal(registrations.fileWatchers[5].disposed, true); - - for (const subscription of context.subscriptions) { - subscription.dispose?.(); - } -}); - -test('active-agents extension debounces refresh events with a trailing 250ms timer', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-debounce-')); - const { registrations, vscode } = createMockVscode(tempRoot); - const extension = loadExtensionWithMockVscode(vscode); - const context = { subscriptions: [] }; - - extension.activate(context); - await flushAsyncWork(); - - const provider = registrations.providers[0].provider; - provider.onDidChangeTreeDataEmitter.fireCount = 0; - - registrations.fileWatchers[0].fireChange({ fsPath: path.join(tempRoot, '.omx', 'state', 'active-sessions', 'a.json') }); - registrations.fileWatchers[1].fireChange({ fsPath: path.join(tempRoot, '.omx', 'state', 'agent-file-locks.json') }); - registrations.fileWatchers[2].fireChange({ fsPath: path.join(tempRoot, '.omx', 'agent-worktrees', 'agent__codex__a', 'AGENT.lock') }); - registrations.fileWatchers[3].fireChange({ fsPath: path.join(tempRoot, '.omx', 'agent-worktrees', 'agent__codex__a', '.git') }); - registrations.fileWatchers[4].fireChange({ fsPath: path.join(tempRoot, '.omx', 'logs', 'agent-agent__codex__a.log') }); - assert.equal(provider.onDidChangeTreeDataEmitter.fireCount, 0); - - await new Promise((resolve) => setTimeout(resolve, 300)); - await flushAsyncWork(); - - assert.equal(provider.onDidChangeTreeDataEmitter.fireCount, 1); - - for (const subscription of context.subscriptions) { - subscription.dispose?.(); - } -}); - -test('active-agents extension commits the selected session worktree from the header prompt', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-commit-view-')); - const worktreePath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-commit-session-')); - initGitRepo(worktreePath); - fs.writeFileSync(path.join(worktreePath, 'tracked.txt'), 'base\n', 'utf8'); - runGit(worktreePath, ['add', 'tracked.txt']); - runGit(worktreePath, ['commit', '-m', 'baseline']); - fs.writeFileSync(path.join(worktreePath, 'tracked.txt'), 'base\nchanged\n', 'utf8'); - fs.mkdirSync(path.join(worktreePath, '.omx', 'state'), { recursive: true }); - fs.writeFileSync( - path.join(worktreePath, '.omx', 'state', 'agent-file-locks.json'), - '{"owner":"codex"}\n', - 'utf8', - ); - - const sessionPath = sessionSchema.sessionFilePathForBranch(tempRoot, 'agent/codex/commit-task'); - fs.mkdirSync(path.dirname(sessionPath), { recursive: true }); - fs.writeFileSync( - sessionPath, - `${JSON.stringify(sessionSchema.buildSessionRecord({ - repoRoot: tempRoot, - branch: 'agent/codex/commit-task', - taskName: 'commit-task', - agentName: 'codex', - worktreePath, - pid: process.pid, - cliName: 'codex', - }), null, 2)}\n`, - 'utf8', - ); - - const { registrations, vscode } = createMockVscode(tempRoot); - vscode.workspace.findFiles = async () => [{ fsPath: sessionPath }]; - const extension = loadExtensionWithMockVscode(vscode); - const context = { subscriptions: [] }; - - extension.activate(context); - await flushAsyncWork(); - - const provider = registrations.providers[0].provider; - const [repoItem] = await provider.getChildren(); - const workingSection = await getSectionByLabel(provider, repoItem, 'Working now'); - const { sessionItem } = await getOnlyWorktreeAndSession(provider, workingSection); - registrations.treeViews[0].fireSelection([sessionItem]); - - registrations.inputResponses.push('Ship the selected sandbox'); - assert.equal(registrations.sourceControls.length, 0); - assert.deepEqual(registrations.inputBoxCalls, []); - - await vscode.commands.executeCommand('gitguardex.activeAgents.commitSelectedSession'); - - assert.equal( - registrations.inputBoxCalls.at(-1).placeHolder, - `Commit ${sessionItem.session.agentName} · ${sessionItem.session.taskName} on ${sessionItem.session.branch} · 0 locks`, - ); - assert.equal(registrations.inputBoxCalls.at(-1).prompt, 'Commit codex · commit-task worktree'); - - const commitMessage = runGit(worktreePath, ['log', '-1', '--pretty=%s']).stdout.trim(); - assert.equal(commitMessage, 'Ship the selected sandbox'); - assert.equal(runGit(worktreePath, ['status', '--short', '--', 'tracked.txt']).stdout.trim(), ''); - assert.equal( - runGit(worktreePath, ['status', '--short', '--', '.omx/state/agent-file-locks.json']).stdout.trim(), - '?? .omx/state/agent-file-locks.json', - ); - assert.deepEqual(registrations.informationMessages, []); - assert.deepEqual(registrations.errorMessages, []); - - for (const subscription of context.subscriptions) { - subscription.dispose?.(); - } -}); - -test('active-agents extension asks for a session before committing', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-no-selection-')); - const { registrations, vscode } = createMockVscode(tempRoot); - const extension = loadExtensionWithMockVscode(vscode); - const context = { subscriptions: [] }; - - extension.activate(context); - - await vscode.commands.executeCommand('gitguardex.activeAgents.commitSelectedSession'); - - assert.deepEqual(registrations.informationMessages, ['Pick an Active Agents session first.']); - assert.deepEqual(registrations.errorMessages, []); - - for (const subscription of context.subscriptions) { - subscription.dispose?.(); - } -}); - -test('active-agents extension decorates sessions and repo changes from the lock registry', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-lock-decorations-')); - initGitRepo(tempRoot); - fs.writeFileSync(path.join(tempRoot, 'root-file.txt'), 'base\n', 'utf8'); - runGit(tempRoot, ['add', 'root-file.txt']); - runGit(tempRoot, ['commit', '-m', 'baseline']); - fs.writeFileSync(path.join(tempRoot, 'root-file.txt'), 'base\nchanged\n', 'utf8'); - - const worktreePath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-lock-worktree-')); - initGitRepo(worktreePath); - fs.writeFileSync(path.join(worktreePath, 'tracked.txt'), 'base\n', 'utf8'); - runGit(worktreePath, ['add', 'tracked.txt']); - runGit(worktreePath, ['commit', '-m', 'baseline']); - - const branch = 'agent/codex/live-task'; - const sessionPath = sessionSchema.sessionFilePathForBranch(tempRoot, branch); - fs.mkdirSync(path.dirname(sessionPath), { recursive: true }); - fs.writeFileSync( - sessionPath, - `${JSON.stringify(sessionSchema.buildSessionRecord({ - repoRoot: tempRoot, - branch, - taskName: 'live-task', - agentName: 'codex', - worktreePath, - pid: process.pid, - cliName: 'codex', - }), null, 2)}\n`, - 'utf8', - ); - - const lockPath = path.join(tempRoot, '.omx', 'state', 'agent-file-locks.json'); - fs.mkdirSync(path.dirname(lockPath), { recursive: true }); - fs.writeFileSync(lockPath, `${JSON.stringify({ - locks: { - 'owned-file.txt': { - branch, - claimed_at: '2026-04-22T08:55:00.000Z', - allow_delete: false, - }, - 'root-file.txt': { - branch: 'agent/codex/other-task', - claimed_at: '2026-04-22T08:56:00.000Z', - allow_delete: false, - }, - }, - }, 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 idleSection = await getSectionByLabel(provider, repoItem, 'Idle / thinking'); - const unassignedSection = await getSectionByLabel(provider, repoItem, 'Unassigned changes'); - const { worktreeItem, sessionItem } = await getOnlyWorktreeAndSession(provider, idleSection); - assert.equal(worktreeItem, null); - assert.equal(sessionItem.label, 'live-task'); - assert.equal(sessionItem.session.branch, branch); - assert.match(sessionItem.tooltip, /1 lock/); - - const [changeItem] = await provider.getChildren(unassignedSection); - assert.equal(changeItem.label, 'root-file.txt'); - assert.equal(changeItem.iconPath.id, 'warning'); - assert.match(changeItem.tooltip, /Locked by agent\/codex\/other-task/); - - for (const subscription of context.subscriptions) { - subscription.dispose?.(); - } -}); - -test('active-agents extension re-reads lock state on watcher events instead of every tree load', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-lock-watch-')); - const branch = 'agent/codex/live-task'; - const worktreePath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-lock-watch-worktree-')); - initGitRepo(worktreePath); - fs.writeFileSync(path.join(worktreePath, 'tracked.txt'), 'base\n', 'utf8'); - runGit(worktreePath, ['add', 'tracked.txt']); - runGit(worktreePath, ['commit', '-m', 'baseline']); - - const sessionPath = sessionSchema.sessionFilePathForBranch(tempRoot, branch); - fs.mkdirSync(path.dirname(sessionPath), { recursive: true }); - fs.writeFileSync( - sessionPath, - `${JSON.stringify(sessionSchema.buildSessionRecord({ - repoRoot: tempRoot, - branch, - taskName: 'live-task', - agentName: 'codex', - worktreePath, - pid: process.pid, - cliName: 'codex', - }), null, 2)}\n`, - 'utf8', - ); - - const lockPath = path.join(tempRoot, '.omx', 'state', 'agent-file-locks.json'); - fs.mkdirSync(path.dirname(lockPath), { recursive: true }); - fs.writeFileSync(lockPath, `${JSON.stringify({ - locks: { - 'owned-file.txt': { - branch, - claimed_at: '2026-04-22T08:57:00.000Z', - allow_delete: false, - }, - }, - }, null, 2)}\n`, 'utf8'); - - const { registrations, vscode } = createMockVscode(tempRoot); - vscode.workspace.findFiles = async () => [{ fsPath: sessionPath }]; - const extension = loadExtensionWithMockVscode(vscode); - const context = { subscriptions: [] }; - - const originalReadFileSync = fs.readFileSync; - let lockReadCount = 0; - fs.readFileSync = function patchedReadFileSync(filePath, ...args) { - if (path.resolve(String(filePath)) === lockPath) { - lockReadCount += 1; - } - return originalReadFileSync.call(this, filePath, ...args); - }; - - try { - extension.activate(context); - - const provider = registrations.providers[0].provider; - const lockWatcher = registrations.watchers.find((watcher) => watcher.pattern === '**/.omx/state/agent-file-locks.json'); - assert.ok(lockWatcher, 'expected lock watcher registration'); - - const [repoItem] = await provider.getChildren(); - const thinkingSection = await getSectionByLabel(provider, repoItem, 'Idle / thinking'); - const { worktreeItem, sessionItem } = await getOnlyWorktreeAndSession(provider, thinkingSection); - assert.equal(worktreeItem, null); - assert.equal(sessionItem.label, 'live-task'); - assert.equal(sessionItem.session.branch, branch); - assert.equal(lockReadCount, 1); - - await provider.getChildren(); - assert.equal(lockReadCount, 1); - - fs.writeFileSync(lockPath, `${JSON.stringify({ - locks: { - 'owned-file.txt': { - branch, - claimed_at: '2026-04-22T08:57:00.000Z', - allow_delete: false, - }, - 'second-owned-file.txt': { - branch, - claimed_at: '2026-04-22T08:58:00.000Z', - allow_delete: false, - }, - }, - }, null, 2)}\n`, 'utf8'); - lockWatcher.fireChange({ fsPath: lockPath }); - assert.equal(lockReadCount, 2); - - const [updatedRepoItem] = await provider.getChildren(); - const updatedThinkingSection = await getSectionByLabel(provider, updatedRepoItem, 'Idle / thinking'); - const { worktreeItem: updatedWorktreeItem, sessionItem: updatedSessionItem } = await getOnlyWorktreeAndSession(provider, updatedThinkingSection); - assert.equal(updatedWorktreeItem, null); - assert.equal(updatedSessionItem.label, 'live-task'); - assert.equal(updatedSessionItem.session.branch, branch); - - await provider.getChildren(); - assert.equal(lockReadCount, 2); - } finally { - fs.readFileSync = originalReadFileSync; - for (const subscription of context.subscriptions) { - subscription.dispose?.(); - } - } -}); - -test('active-agents extension launches finish and sync commands in session terminals', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-inline-actions-')); - const worktreePath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-inline-worktree-')); - initGitRepo(worktreePath); - fs.writeFileSync(path.join(worktreePath, 'tracked.txt'), 'base\n', 'utf8'); - runGit(worktreePath, ['add', 'tracked.txt']); - runGit(worktreePath, ['commit', '-m', 'baseline']); - - const sessionPath = sessionSchema.sessionFilePathForBranch(tempRoot, 'agent/codex/live-task'); - fs.mkdirSync(path.dirname(sessionPath), { recursive: true }); - fs.writeFileSync( - sessionPath, - `${JSON.stringify(sessionSchema.buildSessionRecord({ - repoRoot: tempRoot, - branch: 'agent/codex/live-task', - taskName: 'live-task', - agentName: 'codex', - worktreePath, - pid: process.pid, - cliName: 'codex', - }), null, 2)}\n`, - 'utf8', - ); - - const { registrations, vscode } = createMockVscode(tempRoot); - vscode.workspace.findFiles = async () => [{ fsPath: sessionPath }]; - const extension = loadExtensionWithMockVscode(vscode); - const context = { subscriptions: [] }; - - extension.activate(context); - await flushAsyncWork(); - - const provider = registrations.providers[0].provider; - const [repoItem] = await provider.getChildren(); - const idleSection = await getSectionByLabel(provider, repoItem, 'Idle / thinking'); - const { sessionItem } = await getOnlyWorktreeAndSession(provider, idleSection); - - await registrations.commands.get('gitguardex.activeAgents.finishSession')(sessionItem.session); - await registrations.commands.get('gitguardex.activeAgents.syncSession')(sessionItem.session); - - assert.equal(registrations.terminals.length, 2); - assert.equal(registrations.terminals[0].options.cwd, worktreePath); - assert.equal(registrations.terminals[0].options.iconPath.id, 'check'); - assert.match(registrations.terminals[0].options.name, /GitGuardex Finish: live-task/); - assert.deepEqual(registrations.terminals[0].sentTexts, [ - { text: "gx branch finish --branch 'agent/codex/live-task'", addNewLine: true }, - ]); - assert.equal(registrations.terminals[0].shown, true); - - assert.equal(registrations.terminals[1].options.cwd, worktreePath); - assert.equal(registrations.terminals[1].options.iconPath.id, 'sync'); - assert.match(registrations.terminals[1].options.name, /GitGuardex Sync: live-task/); - assert.deepEqual(registrations.terminals[1].sentTexts, [ - { text: 'gx sync', addNewLine: true }, - ]); - assert.equal(registrations.terminals[1].shown, true); - - for (const subscription of context.subscriptions) { - subscription.dispose?.(); - } -}); - -test('active-agents extension opens and refreshes the inspect panel from shared watcher events', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-inspect-panel-')); - const remoteRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-inspect-remote-')); - const branch = 'agent/codex/inspect-task'; - - initGitRepo(tempRoot); - runGit(tempRoot, ['checkout', '-b', 'main']); - fs.writeFileSync(path.join(tempRoot, 'tracked.txt'), 'base\n', 'utf8'); - runGit(tempRoot, ['add', 'tracked.txt']); - runGit(tempRoot, ['commit', '-m', 'baseline']); - runGit(remoteRoot, ['init', '--bare']); - runGit(tempRoot, ['remote', 'add', 'origin', remoteRoot]); - runGit(tempRoot, ['push', '-u', 'origin', 'main']); - runGit(tempRoot, ['config', 'multiagent.baseBranch', 'main']); - runGit(tempRoot, ['checkout', '-b', branch]); - fs.writeFileSync(path.join(tempRoot, 'tracked.txt'), 'base\ninspect\n', 'utf8'); - runGit(tempRoot, ['add', 'tracked.txt']); - runGit(tempRoot, ['commit', '-m', 'inspect ahead commit']); - - const sessionPath = sessionSchema.sessionFilePathForBranch(tempRoot, branch); - fs.mkdirSync(path.dirname(sessionPath), { recursive: true }); - fs.writeFileSync( - sessionPath, - `${JSON.stringify(sessionSchema.buildSessionRecord({ - repoRoot: tempRoot, - branch, - taskName: 'inspect-task', - agentName: 'codex', - worktreePath: tempRoot, - pid: process.pid, - cliName: 'codex', - }), null, 2)}\n`, - 'utf8', - ); - - const lockPath = path.join(tempRoot, '.omx', 'state', 'agent-file-locks.json'); - fs.mkdirSync(path.dirname(lockPath), { recursive: true }); - fs.writeFileSync(lockPath, `${JSON.stringify({ - locks: { - 'src/owned-file.txt': { - branch, - claimed_at: '2026-04-22T09:13:00.000Z', - allow_delete: false, - }, - }, - }, null, 2)}\n`, 'utf8'); - - const logPath = path.join( - tempRoot, - '.omx', - 'logs', - `agent-${sessionSchema.sanitizeBranchForFile(branch)}.log`, - ); - fs.mkdirSync(path.dirname(logPath), { recursive: true }); - fs.writeFileSync(logPath, 'log line 1\n', 'utf8'); - - const { registrations, vscode } = createMockVscode(tempRoot); - vscode.workspace.findFiles = async (pattern) => { - if (pattern === '**/.omx/state/active-sessions/*.json') { - return [{ fsPath: sessionPath }]; - } - 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 groupSection = await getSectionByLabel(provider, repoItem, 'Working now'); - const { sessionItem } = await getOnlyWorktreeAndSession(provider, groupSection); - - await registrations.commands.get('gitguardex.activeAgents.inspect')(sessionItem.session); - - assert.equal(registrations.webviewPanels.length, 1); - const panel = registrations.webviewPanels[0]; - assert.equal(panel.viewType, 'gitguardex.activeAgents.inspect'); - assert.match(panel.title, /Inspect inspect-task/); - assert.match(panel.webview.html, /origin\/main/); - assert.match(panel.webview.html, /1 ahead/); - assert.match(panel.webview.html, /0 behind/); - assert.match(panel.webview.html, /src\/owned-file.txt/); - assert.match(panel.webview.html, /log line 1/); - - fs.writeFileSync(logPath, 'log line 1\nlog line 2\n', 'utf8'); - const logWatcher = registrations.watchers.find((watcher) => watcher.pattern === '**/.omx/logs/*.log'); - assert.ok(logWatcher, 'expected log watcher registration'); - logWatcher.fireChange({ fsPath: logPath }); - await new Promise((resolve) => setTimeout(resolve, 300)); - await flushAsyncWork(); - - assert.match(panel.webview.html, /log line 2/); - - await registrations.commands.get('gitguardex.activeAgents.inspect')(sessionItem.session); - assert.equal(registrations.webviewPanels.length, 1); - assert.equal(panel.revealCalls.length, 1); - - for (const subscription of context.subscriptions) { - subscription.dispose?.(); - } -}); - -test('active-agents extension reveals the matching session terminal and opens a fallback worktree terminal when needed', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-show-terminal-')); - const worktreePath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-show-terminal-worktree-')); - const { registrations, vscode } = createMockVscode(tempRoot); - const extension = loadExtensionWithMockVscode(vscode); - const context = { subscriptions: [] }; - - extension.activate(context); - - const liveTerminal = vscode.window.createTerminal({ - name: `GitGuardex: ${path.basename(tempRoot)}`, - cwd: tempRoot, - processId: 4242, - }); - await registrations.commands.get('gitguardex.activeAgents.showSessionTerminal')({ - label: 'live-task', - branch: 'agent/codex/live-task', - pid: 4242, - repoRoot: tempRoot, - worktreePath, - }); - - assert.equal(registrations.terminals.length, 1); - assert.equal(liveTerminal.shown, true); - assert.deepEqual(liveTerminal.showArgs, [false]); - assert.deepEqual(liveTerminal.sentTexts, []); - - await registrations.commands.get('gitguardex.activeAgents.showSessionTerminal')({ - label: 'fallback-task', - branch: 'agent/codex/fallback-task', - pid: 9001, - repoRoot: tempRoot, - worktreePath, - }); - - assert.equal(registrations.terminals.length, 2); - assert.equal(registrations.terminals[1].options.name, 'GitGuardex Terminal: fallback-task'); - assert.equal(registrations.terminals[1].options.cwd, worktreePath); - assert.equal(registrations.terminals[1].options.iconPath.id, 'terminal'); - assert.equal(registrations.terminals[1].shown, true); - assert.deepEqual(registrations.terminals[1].showArgs, [false]); - assert.deepEqual(registrations.terminals[1].sentTexts, []); - - for (const subscription of context.subscriptions) { - subscription.dispose?.(); - } -}); - -test('active-agents extension stops matching session terminals with Ctrl+C before gx fallback', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-stop-session-')); - const worktreePath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-stop-worktree-')); - const { registrations, vscode } = createMockVscode(tempRoot); - const extension = loadExtensionWithMockVscode(vscode); - const context = { subscriptions: [] }; - - vscode.window.showWarningMessage = async (...args) => { - registrations.warningMessages.push(args); - return 'Stop'; - }; - - extension.activate(context); - const provider = registrations.providers[0].provider; - await flushAsyncWork(); - provider.onDidChangeTreeDataEmitter.fireCount = 0; - - const liveTerminal = vscode.window.createTerminal({ - name: `GitGuardex: ${path.basename(tempRoot)}`, - cwd: tempRoot, - processId: 4242, - }); - - await registrations.commands.get('gitguardex.activeAgents.stopSession')({ - label: 'live-task', - branch: 'agent/codex/live-task', - pid: 4242, - repoRoot: tempRoot, - worktreePath, - }); - await flushAsyncWork(); - - assert.ok(registrations.providers[0].provider.onDidChangeTreeDataEmitter.fireCount >= 1); - assert.equal(registrations.warningMessages.length, 1); - assert.match(registrations.warningMessages[0][0], /Stop live-task\?/); - assert.match(registrations.warningMessages[0][1].detail, /Ctrl\+C/); - assert.equal(liveTerminal.shown, true); - assert.deepEqual(liveTerminal.showArgs, [false]); - assert.deepEqual(liveTerminal.sentTexts, [ - { text: '\u0003', addNewLine: false }, - ]); - - for (const subscription of context.subscriptions) { - subscription.dispose?.(); - } -}); - -test('active-agents extension confirms stop and routes through gx agents stop --pid when no live terminal matches', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-stop-session-fallback-')); - const worktreePath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-stop-worktree-fallback-')); - const { registrations, vscode } = createMockVscode(tempRoot); - const extension = loadExtensionWithMockVscode(vscode); - const context = { subscriptions: [] }; - let execCall = null; - const originalExecFile = cp.execFile; - - vscode.window.showWarningMessage = async (...args) => { - registrations.warningMessages.push(args); - return 'Stop'; - }; - cp.execFile = (command, args, options, callback) => { - execCall = { command, args, options }; - callback(null, '[gx] Stopped agent pid 4242 (stopped).\n', ''); - }; - - try { - extension.activate(context); - const provider = registrations.providers[0].provider; - await flushAsyncWork(); - provider.onDidChangeTreeDataEmitter.fireCount = 0; - - await registrations.commands.get('gitguardex.activeAgents.stopSession')({ - label: 'live-task', - branch: 'agent/codex/live-task', - pid: 4242, - repoRoot: tempRoot, - worktreePath, - }); - await flushAsyncWork(); - } finally { - cp.execFile = originalExecFile; - } - - assert.deepEqual(execCall, { - command: 'gx', - args: ['agents', 'stop', '--pid', '4242', '--target', tempRoot], - options: { - cwd: tempRoot, - encoding: 'utf8', - maxBuffer: 1024 * 1024, - }, - }); - assert.ok(registrations.providers[0].provider.onDidChangeTreeDataEmitter.fireCount >= 1); - assert.equal(registrations.warningMessages.length, 1); - assert.match(registrations.warningMessages[0][0], /Stop live-task\?/); - assert.match(registrations.warningMessages[0][1].detail, /--pid/); - assert.match(registrations.warningMessages[0][1].detail, /4242/); - assert.match(registrations.warningMessages[0][1].detail, /--target/); - - for (const subscription of context.subscriptions) { - subscription.dispose?.(); - } -}); - -test('active-agents extension dismisses stalled session rows by deleting the matching active-session record', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-dismiss-session-')); - const worktreePath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-dismiss-worktree-')); - const sessionPath = writeSessionRecord(tempRoot, sessionSchema.buildSessionRecord({ - repoRoot: tempRoot, - branch: 'agent/codex/stalled-task', - taskName: 'stalled-task', - agentName: 'codex', - worktreePath, - pid: 4242, - cliName: 'codex', - })); - const { registrations, vscode } = createMockVscode(tempRoot); - const extension = loadExtensionWithMockVscode(vscode); - const context = { subscriptions: [] }; - - vscode.window.showWarningMessage = async (...args) => { - registrations.warningMessages.push(args); - return 'Dismiss'; - }; - - extension.activate(context); - const provider = registrations.providers[0].provider; - await flushAsyncWork(); - provider.onDidChangeTreeDataEmitter.fireCount = 0; - - await registrations.commands.get('gitguardex.activeAgents.dismissSession')({ - label: 'stalled-task', - branch: 'agent/codex/stalled-task', - activityKind: 'stalled', - repoRoot: tempRoot, - worktreePath, - }); - await flushAsyncWork(); - - assert.equal(fs.existsSync(sessionPath), false); - assert.ok(registrations.providers[0].provider.onDidChangeTreeDataEmitter.fireCount >= 1); - assert.equal(registrations.warningMessages.length, 1); - assert.match(registrations.warningMessages[0][0], /Dismiss stalled-task\?/); - assert.match(registrations.warningMessages[0][1].detail, /\.omx[\/\\]state[\/\\]active-sessions/); - assert.match(registrations.warningMessages[0][1].detail, /stale sidebar row only/); - - for (const subscription of context.subscriptions) { - subscription.dispose?.(); - } -}); - -test('active-agents extension uses bundled OpenSpec icons in Active Agents tree nodes', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-openspec-icons-')); - initGitRepo(tempRoot); - const branch = 'agent/codex/openspec-icons'; - runGit(tempRoot, ['checkout', '-b', branch]); - - const proposalPath = path.join(tempRoot, 'openspec', 'changes', 'icon-pass', 'proposal.md'); - const tasksPath = path.join(tempRoot, 'openspec', 'changes', 'icon-pass', 'tasks.md'); - const specPath = path.join(tempRoot, 'openspec', 'changes', 'icon-pass', 'specs', 'active-agents-icons', 'spec.md'); - fs.mkdirSync(path.dirname(proposalPath), { recursive: true }); - fs.mkdirSync(path.dirname(specPath), { recursive: true }); - fs.writeFileSync(proposalPath, 'proposal base\n', 'utf8'); - fs.writeFileSync(tasksPath, 'tasks base\n', 'utf8'); - fs.writeFileSync(specPath, 'spec base\n', 'utf8'); - runGit(tempRoot, ['add', 'openspec']); - runGit(tempRoot, ['commit', '-m', 'baseline']); - fs.writeFileSync(proposalPath, 'proposal base\nchanged\n', 'utf8'); - fs.writeFileSync(tasksPath, 'tasks base\nchanged\n', 'utf8'); - fs.writeFileSync(specPath, 'spec base\nchanged\n', 'utf8'); - - const sessionPath = sessionSchema.sessionFilePathForBranch(tempRoot, branch); - fs.mkdirSync(path.dirname(sessionPath), { recursive: true }); - fs.writeFileSync( - sessionPath, - `${JSON.stringify(sessionSchema.buildSessionRecord({ - repoRoot: tempRoot, - branch, - taskName: 'openspec-icons', - agentName: 'codex', - worktreePath: tempRoot, - pid: process.pid, - cliName: 'codex', - }), null, 2)}\n`, - 'utf8', - ); - - const { registrations, vscode } = createMockVscode(tempRoot); - vscode.workspace.findFiles = async () => [{ fsPath: sessionPath }]; - const extension = loadExtensionWithMockVscode(vscode); - const context = { subscriptions: [] }; - - extension.activate(context); - await flushAsyncWork(); - - const provider = registrations.providers[0].provider; - const [repoItem] = await provider.getChildren(); - 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 { sessionItem } = await getOnlyWorktreeAndSession(provider, rawWorkingSection); - - const openspecFolder = await getChildByLabel(provider, sessionItem, 'openspec'); - const changesFolder = await getChildByLabel(provider, openspecFolder, 'changes'); - assertBundledIcon(changesFolder, 'openspec.svg'); - - const iconPassFolder = await getChildByLabel(provider, changesFolder, 'icon-pass'); - const proposalItem = await getChildByLabel(provider, iconPassFolder, 'proposal.md'); - const specsFolder = await getChildByLabel(provider, iconPassFolder, 'specs'); - const tasksItem = await getChildByLabel(provider, iconPassFolder, 'tasks.md'); - assertBundledIcon(proposalItem, 'openspec.svg'); - assertBundledIcon(specsFolder, 'spec.svg'); - assertBundledIcon(tasksItem, 'plan.svg'); - - const activeAgentsIconsFolder = await getChildByLabel(provider, specsFolder, 'active-agents-icons'); - const specItem = await getChildByLabel(provider, activeAgentsIconsFolder, 'spec.md'); - assertBundledIcon(specItem, 'spec.svg'); - - for (const subscription of context.subscriptions) { - subscription.dispose?.(); - } -}); - -test('active-agents extension keeps semantic OpenSpec icons for delta-only unassigned changes', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-openspec-unassigned-icons-')); - initGitRepo(tempRoot); - fs.writeFileSync(path.join(tempRoot, 'tracked.txt'), 'base\n', 'utf8'); - runGit(tempRoot, ['add', 'tracked.txt']); - runGit(tempRoot, ['commit', '-m', 'baseline']); - runGit(tempRoot, ['checkout', '-b', 'agent/codex/unassigned-root']); - const branch = 'agent/codex/live-task'; - const worktreePath = path.join( - tempRoot, - '.omx', - 'agent-worktrees', - 'agent__codex__live-task', - ); - fs.mkdirSync(path.dirname(worktreePath), { recursive: true }); - runGit(tempRoot, ['worktree', 'add', '-b', branch, worktreePath]); - const changeDir = path.join(tempRoot, 'openspec', 'changes', 'icon-pass'); - const proposalPath = path.join(changeDir, 'proposal.md'); - const tasksPath = path.join(changeDir, 'tasks.md'); - const specPath = path.join(changeDir, 'specs', 'active-agents-icons', 'spec.md'); - fs.mkdirSync(path.dirname(specPath), { recursive: true }); - fs.writeFileSync(proposalPath, 'proposal\n', 'utf8'); - fs.writeFileSync(tasksPath, 'tasks\n', 'utf8'); - fs.writeFileSync(specPath, 'spec\n', 'utf8'); - writeSessionRecord(tempRoot, sessionSchema.buildSessionRecord({ - repoRoot: tempRoot, - branch, - taskName: 'live-task', - agentName: 'codex', - worktreePath, - pid: process.pid, - cliName: 'codex', - state: 'working', - })); - - const { registrations, vscode } = createMockVscode(tempRoot); - vscode.workspace.findFiles = async () => []; - let repoChanges = [ - { - relativePath: 'openspec/changes/icon-pass/proposal.md', - absolutePath: proposalPath, - statusLabel: 'A', - statusText: 'Added', - }, - { - relativePath: 'openspec/changes/icon-pass/tasks.md', - absolutePath: tasksPath, - statusLabel: 'A', - statusText: 'Added', - }, - { - relativePath: 'openspec/changes/icon-pass/specs/active-agents-icons/spec.md', - absolutePath: specPath, - statusLabel: 'A', - statusText: 'Added', - }, - ]; - const mockSessionSchema = { - ...sessionSchema, - readActiveSessions: () => sessionSchema.readActiveSessions(tempRoot, { includeStale: true }), - readRepoChanges: () => repoChanges, - }; - const extension = loadExtensionWithMockVscode(vscode, mockSessionSchema); - const context = { subscriptions: [] }; - - extension.activate(context); - const provider = registrations.providers[0].provider; - await provider.getChildren(); - await flushAsyncWork(); - - repoChanges = repoChanges.map((change) => ({ - ...change, - statusLabel: 'M', - statusText: 'Modified', - })); - const [repoItem] = await provider.getChildren(); - const unassignedSection = await getSectionByLabel(provider, repoItem, 'Unassigned changes'); - const unassignedItems = await provider.getChildren(unassignedSection); - assert.equal(unassignedItems.length, 3); - - const proposalItem = unassignedItems.find((item) => item.label === 'openspec/.../proposal.md'); - const tasksItem = unassignedItems.find((item) => item.label === 'openspec/.../tasks.md'); - const specItem = unassignedItems.find((item) => item.label === 'openspec/.../spec.md'); - assert.ok(proposalItem); - assert.ok(tasksItem); - assert.ok(specItem); - assert.equal(proposalItem.description, 'M · Updated'); - assert.equal(tasksItem.description, 'M · Updated'); - assert.equal(specItem.description, 'M · Updated'); - assertBundledIcon(proposalItem, 'openspec.svg'); - assertBundledIcon(tasksItem, 'plan.svg'); - assertBundledIcon(specItem, 'spec.svg'); - - for (const subscription of context.subscriptions) { - subscription.dispose?.(); - } -}); diff --git a/vscode/guardex-active-agents/README.md b/vscode/guardex-active-agents/README.md deleted file mode 100644 index ea5ff11a..00000000 --- a/vscode/guardex-active-agents/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# GitGuardex Active Agents - -Local VS Code companion for Guardex-managed repos. - -## Quick Start - -Use the dedicated Active Agents sidebar icon to create or inspect Guardex sandboxes quickly. - -1. Install from a Guardex-wired repo: - -```sh -node scripts/install-vscode-active-agents-extension.js -``` - -2. Reload the VS Code window. -3. In the Activity Bar, open the dedicated `Active Agents` hive icon. Use `Start agent` to enter a task + agent name and launch the repo Guardex agent runner. The companion prefers `bash scripts/codex-agent.sh` when present, falls back to `npm run agent:codex --`, and only uses `gx branch start` as a last resort. - -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 a dedicated `Active Agents` Activity Bar container with a hive icon and live badge count for active sessions. -- 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. -- Keeps the built-in Source Control view focused on real Git repositories; the Active Agents commit command prompts for a message from its own toolbar action. -- 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`. -- Reads repo-local presence files from `.omx/state/active-sessions/`, expects `lastHeartbeatAt` freshness, and falls back to managed worktree-root `AGENT.lock` telemetry when the launcher session file is absent. -- Publishes `guardex.hasAgents` and `guardex.hasConflicts` context keys for other VS Code contributions. diff --git a/vscode/guardex-active-agents/extension.js b/vscode/guardex-active-agents/extension.js deleted file mode 100644 index 6154f838..00000000 --- a/vscode/guardex-active-agents/extension.js +++ /dev/null @@ -1,3892 +0,0 @@ -const fs = require('node:fs'); -const path = require('node:path'); -const cp = require('node:child_process'); -const http = require('node:http'); -const os = require('node:os'); -const vscode = require('vscode'); -const { - clearWorktreeActivityCache, - formatElapsedFrom, - readActiveSessions, - readRepoChanges, - readSessionInspectData, - sanitizeBranchForFile, - sessionFilePathForBranch, -} = require('./session-schema.js'); - -const SESSION_DECORATION_SCHEME = 'gitguardex-agent'; -const IDLE_WARNING_MS = 10 * 60 * 1000; -const IDLE_ERROR_MS = 30 * 60 * 1000; -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_GIT_FILES_GLOB = '**/{.omx,.omc}/agent-worktrees/*/.git'; -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}/**'; -const MANAGED_WORKTREE_GIT_SCAN_EXCLUDE_GLOB = '**/node_modules/**'; -const SESSION_SCAN_LIMIT = 200; -const REFRESH_DEBOUNCE_MS = 250; -const RECENTLY_ACTIVE_WINDOW_MS = 10 * 60 * 1000; -const SESSION_TOP_FILE_COUNT = 3; -const ACTIVE_AGENTS_MANIFEST_RELATIVE = path.join('vscode', 'guardex-active-agents', 'package.json'); -const ACTIVE_AGENTS_INSTALL_SCRIPT_RELATIVE = path.join('scripts', 'install-vscode-active-agents-extension.js'); -const RELOAD_WINDOW_ACTION = 'Reload Window'; -const UPDATE_LATER_ACTION = 'Later'; -const ACTIVE_AGENTS_EXTENSION_ID = 'Recodee.gitguardex-active-agents'; -const RESTART_EXTENSION_HOST_COMMAND = 'workbench.action.restartExtensionHost'; -const REFRESH_POLL_INTERVAL_MS = 30_000; -const INSPECT_PANEL_VIEW_TYPE = 'gitguardex.activeAgents.inspect'; -const COLONY_DEFAULT_PORT = 37777; -const COLONY_SNAPSHOT_TTL_MS = 5_000; -const COLONY_FETCH_TIMEOUT_MS = 800; - -function colonyDataDir() { - return process.env.COLONY_HOME - || process.env.CAVEMEM_HOME - || path.join(os.homedir(), '.colony'); -} - -function readColonyPort() { - try { - const raw = fs.readFileSync(path.join(colonyDataDir(), 'settings.json'), 'utf8'); - const parsed = JSON.parse(raw); - const port = Number(parsed?.workerPort); - return Number.isFinite(port) && port > 0 ? port : COLONY_DEFAULT_PORT; - } catch (_error) { - return COLONY_DEFAULT_PORT; - } -} - -function fetchColonyJson(urlPath) { - return new Promise((resolve) => { - const req = http.get( - { - hostname: '127.0.0.1', - port: readColonyPort(), - path: urlPath, - timeout: COLONY_FETCH_TIMEOUT_MS, - }, - (res) => { - if (res.statusCode !== 200) { - res.resume(); - resolve(null); - return; - } - let body = ''; - res.setEncoding('utf8'); - res.on('data', (chunk) => { - body += chunk; - }); - res.on('end', () => { - try { - resolve(JSON.parse(body)); - } catch (_error) { - resolve(null); - } - }); - }, - ); - req.on('error', () => resolve(null)); - req.on('timeout', () => { - req.destroy(); - resolve(null); - }); - }); -} - -const colonyTasksCache = new Map(); - -async function readColonyTasksForRepo(repoRoot) { - const cached = colonyTasksCache.get(repoRoot); - if (cached && Date.now() - cached.at < COLONY_SNAPSHOT_TTL_MS) { - return cached.tasks; - } - const tasks = await fetchColonyJson( - `/api/colony/tasks?repo_root=${encodeURIComponent(repoRoot)}`, - ); - const resolved = Array.isArray(tasks) ? tasks : []; - colonyTasksCache.set(repoRoot, { at: Date.now(), tasks: resolved }); - return resolved; -} - -function compactColonyBranchLabel(branch) { - if (typeof branch !== 'string' || !branch) return 'unknown'; - const parts = branch.split('/').filter(Boolean); - return parts.length > 2 ? parts.slice(-2).join('/') : branch; -} -const GIT_CONFIGURATION_SECTION = 'git'; -const REPO_SCAN_IGNORED_FOLDERS_SETTING = 'repositoryScanIgnoredFolders'; -const BUNDLED_FILE_ICONS_MANIFEST_RELATIVE = path.join('fileicons', 'gitguardex-fileicons.json'); -const MANAGED_REPO_SCAN_IGNORED_FOLDERS = [ - '.omx/agent-worktrees', - '**/.omx/agent-worktrees', - '.omx/.tmp-worktrees', - '**/.omx/.tmp-worktrees', - '.omc/agent-worktrees', - '**/.omc/agent-worktrees', - '.omc/.tmp-worktrees', - '**/.omc/.tmp-worktrees', -]; -const SESSION_ACTIVITY_GROUPS = [ - { kind: 'blocked', label: 'BLOCKED' }, - { kind: 'working', label: 'WORKING NOW' }, - { kind: 'finished', label: 'NEEDS CLEANUP' }, - { kind: 'idle', label: 'THINKING' }, - { kind: 'stalled', label: 'STALLED' }, - { kind: 'dead', label: 'DEAD' }, -]; -const SESSION_ACTIVITY_ICON_IDS = { - blocked: 'warning', - working: 'loading~spin', - finished: 'pass-filled', - idle: 'comment-discussion', - stalled: 'clock', - dead: 'error', -}; -const DISMISSABLE_SESSION_ACTIVITY_KINDS = new Set(['stalled', 'dead']); -const SESSION_PROVIDER_BRANDS = { - openai: { - id: 'openai', - label: 'OpenAI', - badge: 'AI', - }, - claude: { - id: 'claude', - label: 'Claude', - badge: 'CL', - }, -}; -let bundledTreeIconThemeCache = null; - -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': - case 'dashboard': - case 'inbox': - case 'file-directory': - case 'settings-gear': - case 'folder-library': - return 'textLink.foreground'; - case 'git-branch': - return 'gitDecoration.modifiedResourceForeground'; - case 'account': - return 'terminal.ansiYellow'; - case 'debug-pause': - return 'terminal.ansiYellow'; - case 'sparkle': - case 'rocket': - return 'terminal.ansiMagenta'; - case 'list-flat': - case 'device-camera': - return 'terminal.ansiCyan'; - case 'list-tree': - case 'telescope': - return 'terminal.ansiBlue'; - case 'organization': - return 'terminal.ansiGreen'; - case 'pass-filled': - case 'pass': - case 'check': - return 'testing.iconPassed'; - 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)}`); -} - -function emptyBundledTreeIconTheme() { - return { - iconPathById: new Map(), - fileNames: {}, - folderNames: {}, - fileExtensions: {}, - }; -} - -function loadBundledTreeIconTheme() { - if (bundledTreeIconThemeCache) { - return bundledTreeIconThemeCache; - } - - const manifestPath = path.join(__dirname, BUNDLED_FILE_ICONS_MANIFEST_RELATIVE); - try { - const parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); - const manifestDir = path.dirname(manifestPath); - const iconPathById = new Map(); - for (const [iconId, definition] of Object.entries(parsed?.iconDefinitions || {})) { - if (typeof definition?.iconPath !== 'string' || !definition.iconPath.trim()) { - continue; - } - const iconUri = vscode.Uri.file(path.resolve(manifestDir, definition.iconPath)); - iconPathById.set(iconId, { - light: iconUri, - dark: iconUri, - }); - } - bundledTreeIconThemeCache = { - iconPathById, - fileNames: parsed?.fileNames || {}, - folderNames: parsed?.folderNames || {}, - fileExtensions: parsed?.fileExtensions || {}, - }; - } catch (_error) { - bundledTreeIconThemeCache = emptyBundledTreeIconTheme(); - } - - return bundledTreeIconThemeCache; -} - -function resolveBundledTreeItemIconId(relativePath, kind = 'file') { - const normalizedRelativePath = normalizeRelativePath(relativePath); - const entryName = path.posix.basename(normalizedRelativePath || ''); - if (!entryName) { - return ''; - } - - const bundledTheme = loadBundledTreeIconTheme(); - if (kind === 'folder') { - return bundledTheme.folderNames[entryName] || ''; - } - - if (bundledTheme.fileNames[entryName]) { - return bundledTheme.fileNames[entryName]; - } - - const matchingExtension = Object.keys(bundledTheme.fileExtensions) - .sort((left, right) => right.length - left.length) - .find((extension) => entryName === extension || entryName.endsWith(`.${extension}`)); - return matchingExtension ? bundledTheme.fileExtensions[matchingExtension] : ''; -} - -function resolveBundledTreeItemIcon(relativePath, kind = 'file') { - const bundledTheme = loadBundledTreeIconTheme(); - const iconId = resolveBundledTreeItemIconId(relativePath, kind); - return iconId ? bundledTheme.iconPathById.get(iconId) : undefined; -} - -function sessionIdleDecoration(session, now = Date.now()) { - if (!session) { - return undefined; - } - - if (session.activityKind === 'blocked') { - return { - badge: '!', - tooltip: 'blocked', - color: new vscode.ThemeColor('list.warningForeground'), - }; - } - if (session.activityKind === 'dead') { - return { - badge: 'x', - tooltip: 'dead', - color: new vscode.ThemeColor('list.errorForeground'), - }; - } - if (session.activityKind === 'stalled') { - return { - badge: '!', - tooltip: 'stalled', - color: new vscode.ThemeColor('list.errorForeground'), - }; - } - if (session.activityKind === 'working') { - return undefined; - } - - const startedAtMs = Date.parse(session.startedAt); - if (!Number.isFinite(startedAtMs)) { - return undefined; - } - - const elapsedMs = now - startedAtMs; - if (elapsedMs > IDLE_ERROR_MS) { - return { - badge: '30m+', - tooltip: 'idle 30m+', - color: new vscode.ThemeColor('list.errorForeground'), - }; - } - if (elapsedMs > IDLE_WARNING_MS) { - return { - badge: '10m+', - tooltip: 'idle 10m+', - color: new vscode.ThemeColor('list.warningForeground'), - }; - } - - return undefined; -} - -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 = []; - - for (const value of values) { - if (typeof value !== 'string' || seen.has(value)) { - continue; - } - seen.add(value); - result.push(value); - } - - 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; - } - - return left.every((value, index) => value === right[index]); -} - -async function ensureManagedRepoScanIgnores() { - if (typeof vscode.workspace.getConfiguration !== 'function') { - return; - } - - const workspaceFolders = vscode.workspace.workspaceFolders || []; - if (workspaceFolders.length === 0) { - return; - } - - const workspaceFolderTarget = workspaceFolders.length > 1 - ? vscode.ConfigurationTarget?.WorkspaceFolder - : vscode.ConfigurationTarget?.Workspace; - if (workspaceFolderTarget === undefined) { - return; - } - - for (const workspaceFolder of workspaceFolders) { - const gitConfig = vscode.workspace.getConfiguration(GIT_CONFIGURATION_SECTION, workspaceFolder); - const configuredIgnoredFolders = gitConfig.get(REPO_SCAN_IGNORED_FOLDERS_SETTING); - const existingIgnoredFolders = Array.isArray(configuredIgnoredFolders) - ? configuredIgnoredFolders - : []; - const nextIgnoredFolders = uniqueStringList([ - ...existingIgnoredFolders, - ...MANAGED_REPO_SCAN_IGNORED_FOLDERS, - ]); - - if (stringListsEqual(existingIgnoredFolders, nextIgnoredFolders)) { - continue; - } - - try { - await gitConfig.update( - REPO_SCAN_IGNORED_FOLDERS_SETTING, - nextIgnoredFolders, - workspaceFolderTarget, - ); - } catch { - // Leave the extension usable even when the current workspace settings cannot be updated. - } - } -} - -function sessionIdentityLabel(session) { - const agentName = typeof session?.agentName === 'string' ? session.agentName.trim() : ''; - const taskName = sessionDisplayLabel(session); - const label = typeof session?.label === 'string' ? session.label.trim() : ''; - - if (agentName && taskName) { - return `${agentName} · ${taskName}`; - } - if (agentName && label) { - return `${agentName} · ${label}`; - } - - return agentName || taskName || label || 'session'; -} - -function sessionCommitPlaceholder(session) { - if (!session?.branch) { - return 'Pick an Active Agents session to commit its worktree.'; - } - - return `Commit ${sessionIdentityLabel(session)} on ${session.branch} · ${formatCountLabel(session.lockCount || 0, 'lock')}`; -} - -function agentNameFromBranch(branch) { - const segments = String(branch || '') - .split('/') - .map((segment) => segment.trim()) - .filter(Boolean); - if (segments[0] === 'agent' && segments[1]) { - return segments[1]; - } - return segments[0] || 'lock'; -} - -function agentBadgeFromBranch(branch) { - const normalized = agentNameFromBranch(branch).toUpperCase().replace(/[^A-Z0-9]/g, ''); - return normalized.slice(0, 2) || 'LK'; -} - -function buildActiveAgentsStatusSummary(summary) { - const workingCount = summary?.workingCount || 0; - const finishedCount = summary?.finishedCount || 0; - const idleCount = summary?.idleCount || 0; - if (workingCount > 0 || finishedCount > 0 || idleCount > 0) { - const parts = [`${workingCount} working`]; - if (finishedCount > 0) { - parts.push(`${finishedCount} needs cleanup`); - } - parts.push(`${idleCount} idle`); - return `$(git-branch) ${parts.join(' · ')}`; - } - return `$(git-branch) ${formatCountLabel(summary?.sessionCount || 0, 'tracked session')}`; -} - -function buildActiveAgentsStatusTooltip(selectedSession, summary) { - if (selectedSession?.branch) { - return [ - selectedSession.branch, - sessionIdentityLabel(selectedSession), - formatCountLabel(selectedSession.lockCount || 0, 'lock'), - selectedSession.worktreePath, - 'Click to open Active Agents.', - ].filter(Boolean).join('\n'); - } - - const activeCount = Math.max(0, (summary?.sessionCount || 0) - (summary?.deadCount || 0)); - return [ - formatCountLabel(activeCount, 'active agent'), - formatCountLabel(summary?.workingCount || 0, 'working now session', 'working now sessions'), - formatCountLabel(summary?.finishedCount || 0, 'needs cleanup session'), - formatCountLabel(summary?.idleCount || 0, 'idle session'), - formatCountLabel(summary?.unassignedChangeCount || 0, 'unassigned change'), - formatCountLabel(summary?.lockedFileCount || 0, 'locked file'), - summary?.deadCount ? formatCountLabel(summary.deadCount, 'dead session') : '', - 'Click to open Active Agents.', - ].filter(Boolean).join('\n'); -} - -function compactRelativePath(relativePath) { - const normalized = normalizeRelativePath(relativePath); - if (!normalized) { - return ''; - } - - const segments = normalized.split('/').filter(Boolean); - if (segments.length <= 2) { - return normalized; - } - - return `${segments[0]}/.../${segments[segments.length - 1]}`; -} - -function summarizeCompactPaths(paths, maxCount = SESSION_TOP_FILE_COUNT) { - const compactPaths = uniqueStringList((paths || []) - .map(normalizeRelativePath) - .filter(Boolean) - .map((relativePath) => compactRelativePath(relativePath))) - .slice(0, maxCount); - if (compactPaths.length === 0) { - return ''; - } - return compactPaths.join(', '); -} - -function isProtectedBranchName(branch) { - return branch === 'main' || branch === 'dev'; -} - -function countWorkingSessions(sessions) { - return sessions.filter((session) => ( - session.activityKind === 'working' || session.activityKind === 'blocked' - )).length; -} - -function countFinishedSessions(sessions) { - return sessions.filter((session) => session.activityKind === 'finished').length; -} - -function countIdleSessions(sessions) { - return sessions.filter((session) => ( - session.activityKind === 'idle' || session.activityKind === 'stalled' - )).length; -} - -function sessionLastActiveAt(session) { - return [ - session?.lastHeartbeatAt, - session?.lastFileActivityAt, - session?.telemetryUpdatedAt, - session?.startedAt, - ].find((value) => typeof value === 'string' && value.trim().length > 0) || ''; -} - -function sessionLastActiveLabel(session) { - const lastActiveAt = sessionLastActiveAt(session); - if (!lastActiveAt) { - return ''; - } - return formatElapsedFrom(lastActiveAt); -} - -function sessionLastActiveAgeMs(session, now = Date.now()) { - const lastActiveAt = sessionLastActiveAt(session); - const timestamp = Date.parse(lastActiveAt); - if (!Number.isFinite(timestamp)) { - return null; - } - return Math.max(0, now - timestamp); -} - -function sessionFreshnessLabel(session, now = Date.now()) { - const ageMs = sessionLastActiveAgeMs(session, now); - if (session.activityKind === 'blocked') { - return 'Needs attention'; - } - if (session.activityKind === 'finished') { - return 'Needs cleanup'; - } - if (session.activityKind === 'stalled') { - return 'Possibly stale'; - } - if (session.activityKind === 'dead') { - return 'Stopped'; - } - if (ageMs === null) { - return ''; - } - if (ageMs <= IDLE_WARNING_MS) { - return 'Fresh'; - } - if (ageMs <= RECENTLY_ACTIVE_WINDOW_MS) { - return 'Recently active'; - } - if (session.activityKind === 'idle') { - return 'Idle'; - } - return 'Recently active'; -} - -function sessionStatusLabel(session) { - switch (session.activityKind) { - case 'blocked': - return 'Blocked'; - case 'working': - return 'Working'; - case 'finished': - return 'Needs cleanup'; - case 'idle': - return 'Idle'; - case 'stalled': - return 'Stale'; - case 'dead': - return 'Dead'; - default: - return 'Thinking'; - } -} - -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) - .filter(Boolean)) - .slice(0, SESSION_TOP_FILE_COUNT); -} - -function buildSessionRecentChangeSummary(session) { - if (session?.latestTaskPreview && session.latestTaskPreview !== session.taskName) { - return session.latestTaskPreview; - } - const topFiles = summarizeCompactPaths(session?.worktreeChangedPaths || []); - if (topFiles) { - return `Changed ${topFiles}`; - } - if (session?.activitySummary) { - return session.activitySummary; - } - return 'No recent change summary.'; -} - -function sessionRiskBadges(session) { - return uniqueStringList([ - session?.activityKind === 'blocked' ? 'Blocked' : '', - session?.activityKind === 'stalled' ? 'Stale' : '', - session?.conflictCount > 0 ? 'Conflict' : '', - session?.lockCount > 0 ? 'Locked' : '', - ].filter(Boolean)); -} - -function changeRiskBadges(change) { - return uniqueStringList([ - change?.protectedBranch ? 'Protected branch' : '', - change?.hasForeignLock ? 'Conflict' : '', - !change?.hasForeignLock && change?.lockOwnerBranch ? 'Locked' : '', - change?.deltaLabel || '', - ].filter(Boolean)); -} - -function changeNeedsWarningIcon(change) { - return Boolean( - change?.protectedBranch - || change?.hasForeignLock - || (!change?.hasForeignLock && change?.lockOwnerBranch), - ); -} - -function buildSessionCardDescription(session) { - const provider = resolveSessionProvider(session); - const statusAgentLabel = `${sessionStatusLabel(session)}: ${session.agentName || 'agent'}`; - const descriptionParts = [ - 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') : '', - buildSessionHealthCompactLabel(session), - session.freshnessLabel || '', - session.lastActiveLabel ? `${session.lastActiveLabel} ago` : '', - ].filter(Boolean); - return descriptionParts.join(' · '); -} - -function buildRawSessionDescription(session) { - const provider = resolveSessionProvider(session); - const descriptionParts = [sessionStatusLabel(session)]; - const fileCountLabel = sessionFileCountLabel(session); - if (fileCountLabel) { - descriptionParts.push(fileCountLabel); - } - if (provider?.label) { - descriptionParts.push(provider.label); - } - const snapshot = sessionSnapshotDescription(session); - if (snapshot) { - descriptionParts.push(snapshot); - } - 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')); - } - return descriptionParts.join(' · '); -} - -function buildSessionTooltip(session, description) { - const provider = resolveSessionProvider(session); - const riskSummary = uniqueStringList([ - ...(session?.riskBadges || []), - 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} · ${sessionDisplayLabel(session)}`, - `Status ${description}`, - sessionHealthSummary ? `Session health ${sessionHealthSummary}` : '', - sessionHealthDrivers ? `Drivers ${sessionHealthDrivers}` : '', - session.recentChangeSummary ? `Recent ${session.recentChangeSummary}` : '', - topFiles ? `Top files ${topFiles}` : '', - riskSummary ? `Signals ${riskSummary}` : '', - session.conflictCount > 0 ? `Conflicts ${session.conflictCount}` : '', - session.lastActiveAt ? `Last active ${session.lastActiveAt}` : '', - session.sourceKind === 'worktree-lock' - ? `Telemetry updated ${session.telemetryUpdatedAt || session.startedAt}` - : `Started ${session.startedAt}`, - session.worktreePath, - ].filter(Boolean).join('\n'); -} - -function buildUnassignedChangeDescription(change) { - return [ - change.statusLabel, - ...changeRiskBadges(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'), - formatCountLabel(summary?.finishedCount || 0, 'needs cleanup agent'), - formatCountLabel(summary?.idleCount || 0, 'idle agent'), - summary?.colonyTaskCount - ? formatCountLabel(summary.colonyTaskCount, 'colony task') - : '', - summary?.pendingHandoffCount - ? formatCountLabel(summary.pendingHandoffCount, 'pending handoff') - : '', - formatCountLabel(summary?.unassignedChangeCount || 0, 'unassigned change'), - formatCountLabel(summary?.lockedFileCount || 0, 'locked file'), - formatCountLabel(summary?.conflictCount || 0, 'conflict'), - ] - .filter(Boolean) - .join(' · '); -} - -function buildRepoDescription(summary) { - return buildOverviewDescription(summary); -} - -function buildRepoTooltip(repoRoot, summary) { - return [ - repoRoot, - buildOverviewDescription(summary), - ].join('\n'); -} - -function repoRootDisplayLabel(repoRoot) { - const normalizedRepoRoot = path.resolve(repoRoot); - const matchingWorkspaceRoots = (vscode.workspace.workspaceFolders || []) - .map((folder) => (typeof folder?.uri?.fsPath === 'string' ? path.resolve(folder.uri.fsPath) : '')) - .filter((workspaceRoot) => workspaceRoot && isPathWithin(workspaceRoot, normalizedRepoRoot)) - .sort((left, right) => right.length - left.length); - - const workspaceRoot = matchingWorkspaceRoots[0]; - if (!workspaceRoot) { - return path.basename(normalizedRepoRoot); - } - - const workspaceLabel = path.basename(workspaceRoot); - const relativePath = normalizeRelativePath(path.relative(workspaceRoot, normalizedRepoRoot)); - if (!relativePath) { - return workspaceLabel; - } - - return [ - workspaceLabel, - ...relativePath.split('/').filter(Boolean), - ].join('/'); -} - -function sessionSnapshotKey(session) { - return `${session?.repoRoot || ''}::${session?.branch || ''}`; -} - -function changeSnapshotKey(repoRoot, change) { - return `${repoRoot || ''}::${normalizeRelativePath(change?.relativePath)}`; -} - -function buildSessionSnapshot(session) { - return { - activityKind: session.activityKind, - changeCount: session.changeCount || 0, - conflictCount: session.conflictCount || 0, - lockCount: session.lockCount || 0, - changedPaths: [...(session.changedPaths || [])], - }; -} - -function buildChangeSnapshot(change) { - return { - statusLabel: change.statusLabel, - hasForeignLock: Boolean(change.hasForeignLock), - lockOwnerBranch: change.lockOwnerBranch || '', - }; -} - -function deriveSessionDelta(previousSnapshot, currentSession) { - if (!previousSnapshot) { - return ''; - } - if (currentSession.conflictCount > previousSnapshot.conflictCount) { - return 'Conflict'; - } - if (currentSession.activityKind !== previousSnapshot.activityKind) { - return sessionStatusLabel(currentSession); - } - if ( - currentSession.changeCount !== previousSnapshot.changeCount - || !stringListsEqual(currentSession.changedPaths || [], previousSnapshot.changedPaths || []) - ) { - return 'New'; - } - if (currentSession.lockCount !== previousSnapshot.lockCount) { - return 'Updated'; - } - return ''; -} - -function deriveChangeDelta(previousSnapshot, currentChange) { - if (!previousSnapshot) { - return ''; - } - if (currentChange.hasForeignLock && !previousSnapshot.hasForeignLock) { - return 'Conflict'; - } - if ( - currentChange.statusLabel !== previousSnapshot.statusLabel - || currentChange.lockOwnerBranch !== previousSnapshot.lockOwnerBranch - ) { - return 'Updated'; - } - return ''; -} - -function workingSessionSortKey(session) { - if (session.activityKind === 'blocked') { - return 0; - } - if (session.conflictCount > 0) { - return 1; - } - if (session.deltaLabel === 'Conflict') { - return 2; - } - if (session.deltaLabel === 'New') { - return 3; - } - if (session.activityKind === 'finished') { - return 5; - } - return 4; -} - -function idleSessionSortKey(session) { - if (session.activityKind === 'stalled') { - return 0; - } - if (session.activityKind === 'idle') { - return 1; - } - if (session.activityKind === 'dead') { - return 2; - } - return 3; -} - -function sortSessionsForWorkingNow(sessions) { - return [...sessions].sort((left, right) => { - const keyDelta = workingSessionSortKey(left) - workingSessionSortKey(right); - if (keyDelta !== 0) { - return keyDelta; - } - const timeDelta = sessionLastActiveAgeMs(left) - sessionLastActiveAgeMs(right); - if (Number.isFinite(timeDelta) && timeDelta !== 0) { - return timeDelta; - } - const changeDelta = (right.changeCount || 0) - (left.changeCount || 0); - if (changeDelta !== 0) { - return changeDelta; - } - return sessionDisplayLabel(left).localeCompare(sessionDisplayLabel(right)); - }); -} - -function sortSessionsForIdleThinking(sessions) { - return [...sessions].sort((left, right) => { - const keyDelta = idleSessionSortKey(left) - idleSessionSortKey(right); - if (keyDelta !== 0) { - return keyDelta; - } - const timeDelta = sessionLastActiveAgeMs(right) - sessionLastActiveAgeMs(left); - if (Number.isFinite(timeDelta) && timeDelta !== 0) { - return timeDelta; - } - return sessionDisplayLabel(left).localeCompare(sessionDisplayLabel(right)); - }); -} - -function sortUnassignedChanges(changes) { - return [...changes].sort((left, right) => { - const leftBadges = changeRiskBadges(left).length; - const rightBadges = changeRiskBadges(right).length; - if (leftBadges !== rightBadges) { - return rightBadges - leftBadges; - } - return normalizeRelativePath(left.relativePath).localeCompare(normalizeRelativePath(right.relativePath)); - }); -} - -function escapeHtml(value) { - return String(value || '') - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} - -function formatInspectBranchSummary(inspectData) { - if (Number.isInteger(inspectData?.aheadCount) && Number.isInteger(inspectData?.behindCount)) { - return `${inspectData.aheadCount} ahead · ${inspectData.behindCount} behind vs ${inspectData.compareRef}`; - } - return `Branch comparison unavailable vs ${inspectData?.compareRef || 'origin/dev'}`; -} - -function inspectPanelTitle(session) { - return `Inspect ${sessionDisplayLabel(session)}`; -} - -function renderInspectPanelHtml(session, inspectData) { - const heldLocksMarkup = Array.isArray(inspectData?.heldLocks) && inspectData.heldLocks.length > 0 - ? `
    ${inspectData.heldLocks.map((entry) => ( - `
  • ${escapeHtml(entry.relativePath)}${entry.allowDelete ? ' delete ok' : ''}${entry.claimedAt ? ` ${escapeHtml(entry.claimedAt)}` : ''}
  • ` - )).join('')}
` - : '

No held locks recorded for this session.

'; - const logContent = inspectData?.logTailText - ? escapeHtml(inspectData.logTailText) - : 'No log output available.'; - - return ` - - - - - - - -

${escapeHtml(sessionIdentityLabel(session))}

-
-
Branch
-
${escapeHtml(session.branch)}
-
Worktree
-
${escapeHtml(session.worktreePath)}
-
Base branch
-
${escapeHtml(inspectData?.baseBranch || 'dev')}
-
Divergence
-
${escapeHtml(formatInspectBranchSummary(inspectData))}
-
Held locks
-
${Array.isArray(inspectData?.heldLocks) ? inspectData.heldLocks.length : 0}
-
Log file
-
${escapeHtml(inspectData?.logPath || 'Unavailable')}
-
-

Held Locks

- ${heldLocksMarkup} -

Agent Log Tail

-
${logContent}
- -`; -} - -class SessionDecorationProvider { - constructor(nowProvider = () => Date.now()) { - this.nowProvider = nowProvider; - this.sessionsByUri = new Map(); - this.lockEntriesByFileUri = new Map(); - this.selectedBranch = ''; - this.onDidChangeFileDecorationsEmitter = new vscode.EventEmitter(); - this.onDidChangeFileDecorations = this.onDidChangeFileDecorationsEmitter.event; - } - - updateSessions(sessions) { - this.sessionsByUri = new Map( - sessions.map((session) => [sessionDecorationUri(session.branch).toString(), session]), - ); - } - - updateLockEntries(repoEntries) { - const nextEntriesByUri = new Map(); - for (const entry of repoEntries || []) { - for (const [relativePath, lockEntry] of entry.lockEntries || []) { - nextEntriesByUri.set( - vscode.Uri.file(path.join(entry.repoRoot, relativePath)).toString(), - { branch: lockEntry.branch }, - ); - } - } - this.lockEntriesByFileUri = nextEntriesByUri; - } - - setSelectedBranch(branch) { - this.selectedBranch = typeof branch === 'string' ? branch.trim() : ''; - } - - refresh() { - this.onDidChangeFileDecorationsEmitter.fire(); - } - - provideFileDecoration(uri) { - if (!uri || uri.scheme !== SESSION_DECORATION_SCHEME) { - if (!uri || uri.scheme !== 'file') { - return undefined; - } - - const lockEntry = this.lockEntriesByFileUri.get(uri.toString()); - if (!lockEntry?.branch) { - return undefined; - } - - const ownsSelectedSession = Boolean(this.selectedBranch) && lockEntry.branch === this.selectedBranch; - return { - badge: agentBadgeFromBranch(lockEntry.branch), - tooltip: ownsSelectedSession - ? `Locked by selected session ${lockEntry.branch}` - : this.selectedBranch - ? `Locked by ${lockEntry.branch} (selected session: ${this.selectedBranch})` - : `Locked by ${lockEntry.branch}`, - color: new vscode.ThemeColor( - ownsSelectedSession - ? 'gitDecoration.modifiedResourceForeground' - : this.selectedBranch - ? 'list.errorForeground' - : 'list.warningForeground', - ), - }; - } - - const session = this.sessionsByUri.get(uri.toString()); - const idleDecoration = sessionIdleDecoration(session, this.nowProvider()); - if (idleDecoration) { - return idleDecoration; - } - return sessionIdentityDecoration(session); - } -} - -class InfoItem extends vscode.TreeItem { - constructor(label, description = '') { - super(label, vscode.TreeItemCollapsibleState.None); - this.description = description; - this.iconPath = themeIcon('info'); - this.tooltip = [label, description].filter(Boolean).join('\n'); - } -} - -class DetailItem extends vscode.TreeItem { - constructor(label, description = '', options = {}) { - super(label, vscode.TreeItemCollapsibleState.None); - this.description = description; - this.tooltip = options.tooltip || [label, description].filter(Boolean).join('\n'); - this.iconPath = options.iconId ? themeIcon(options.iconId, options.iconColorId) : undefined; - } -} - -class RepoItem extends vscode.TreeItem { - constructor(repoRoot, sessions, changes, options = {}) { - const label = typeof options.label === 'string' && options.label.trim() - ? options.label.trim() - : repoRootDisplayLabel(repoRoot); - super(label, vscode.TreeItemCollapsibleState.Expanded); - this.repoRoot = repoRoot; - this.sessions = sessions; - this.changes = changes; - this.unassignedChanges = options.unassignedChanges || []; - this.lockEntries = options.lockEntries || []; - this.colonyTasks = Array.isArray(options.colonyTasks) ? options.colonyTasks : []; - this.overview = options.overview - || buildRepoOverview(sessions, this.unassignedChanges, this.lockEntries, this.colonyTasks); - this.description = buildRepoDescription(this.overview); - this.tooltip = buildRepoTooltip(repoRoot, this.overview); - this.iconPath = themeIcon('repo'); - this.contextValue = 'gitguardex.repo'; - } -} - -class SectionItem extends vscode.TreeItem { - constructor(label, items, options = {}) { - const collapsibleState = items.length > 0 - ? (options.collapsedState ?? vscode.TreeItemCollapsibleState.Expanded) - : vscode.TreeItemCollapsibleState.None; - super(label, collapsibleState); - this.items = items; - 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 ? themeIcon(options.iconId, options.iconColorId) : undefined; - this.contextValue = 'gitguardex.section'; - } -} - -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); - const label = typeof options.label === 'string' && options.label.trim() - ? options.label.trim() - : worktreeDisplayLabel(normalizedWorktreePath, sessionList); - super( - label, - items.length > 0 ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.None, - ); - this.worktreePath = normalizedWorktreePath; - this.sessions = sessionList; - this.items = items; - this.description = options.description || buildWorktreeDescription(sessionList, changedCount); - this.tooltip = [ - normalizedWorktreePath, - ...sessionList.map((session) => session.branch).filter(Boolean), - ].filter(Boolean).join('\n'); - this.iconPath = themeIcon(options.iconId || 'folder', options.iconColorId); - if (options.useSessionDecoration && primarySession?.branch) { - this.resourceUri = sessionDecorationUri(primarySession.branch); - } - this.contextValue = 'gitguardex.worktree'; - if (primarySession?.worktreePath) { - this.command = { - command: 'gitguardex.activeAgents.openWorktree', - title: 'Open Agent Worktree', - arguments: [primarySession], - }; - } - } -} - -class SessionItem extends vscode.TreeItem { - constructor(session, items = [], options = {}) { - const variant = options.variant === 'raw' ? 'raw' : 'card'; - const label = typeof options.label === 'string' && options.label.trim() - ? options.label.trim() - : (variant === 'raw' ? session.label : sessionDisplayLabel(session)); - const collapsibleState = items.length > 0 - ? (options.collapsedState ?? ( - variant === 'raw' - ? vscode.TreeItemCollapsibleState.Expanded - : vscode.TreeItemCollapsibleState.Collapsed - )) - : vscode.TreeItemCollapsibleState.None; - super( - label, - collapsibleState, - ); - this.session = session; - this.items = items; - this.resourceUri = sessionDecorationUri(session.branch); - this.description = variant === 'raw' - ? buildRawSessionDescription(session) - : buildSessionCardDescription(session); - this.tooltip = buildSessionTooltip(session, this.description); - this.iconPath = themeIcon(resolveSessionActivityIconId(session.activityKind)); - this.contextValue = sessionContextValue(session); - this.command = { - command: 'gitguardex.activeAgents.openWorktree', - title: 'Open Agent Worktree', - arguments: [session], - }; - } -} - -function sessionContextValue(session) { - const activityKind = typeof session?.activityKind === 'string' ? session.activityKind.trim() : ''; - return activityKind - ? `gitguardex.session.${activityKind}` - : 'gitguardex.session'; -} - -function canDismissSession(session) { - return DISMISSABLE_SESSION_ACTIVITY_KINDS.has(session?.activityKind); -} - -function buildDismissSessionDetail(session, statePath) { - const repoRoot = typeof session?.repoRoot === 'string' ? session.repoRoot.trim() : ''; - const relativeStatePath = repoRoot - ? path.relative(repoRoot, statePath) || path.basename(statePath) - : path.basename(statePath); - const detailParts = [ - `Remove ${relativeStatePath} and hide this session from Active Agents.`, - ]; - - if (session?.activityKind === 'stalled') { - detailParts.push('This dismisses the stale sidebar row only; use Stop if you want to interrupt a live agent.'); - } else { - detailParts.push('This clears the stale session record from the sidebar.'); - } - - return detailParts.join(' '); -} - -class FolderItem extends vscode.TreeItem { - 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.description = typeof options.description === 'string' ? options.description : ''; - this.tooltip = options.tooltip || relativePath || label; - this.iconPath = options.iconPath - || (!options.iconId ? resolveBundledTreeItemIcon(relativePath || label, 'folder') : undefined) - || themeIcon(options.iconId || 'folder', options.iconColorId); - this.contextValue = options.contextValue || 'gitguardex.folder'; - } -} - -class ChangeItem extends vscode.TreeItem { - constructor(change, options = {}) { - const label = typeof options.label === 'string' && options.label.trim() - ? options.label.trim() - : path.basename(change.relativePath); - super(label, vscode.TreeItemCollapsibleState.None); - this.change = change; - this.description = typeof options.description === 'string' - ? options.description - : change.statusLabel; - this.tooltip = [ - change.relativePath, - `Summary ${this.description}`, - `Status ${change.statusText}`, - change.originalPath ? `Renamed from ${change.originalPath}` : '', - change.hasForeignLock ? `Locked by ${change.lockOwnerBranch}` : '', - change.absolutePath, - ].filter(Boolean).join('\n'); - this.resourceUri = vscode.Uri.file(change.absolutePath); - if (options.iconId || change.hasForeignLock) { - this.iconPath = themeIcon(options.iconId || 'warning', options.iconColorId || 'list.warningForeground'); - } else { - this.iconPath = options.iconPath || resolveBundledTreeItemIcon(change.relativePath || label, 'file'); - } - this.contextValue = 'gitguardex.change'; - this.command = { - command: 'gitguardex.activeAgents.openChange', - title: 'Open Changed File', - arguments: [change], - }; - } -} - -function shellQuote(value) { - const normalized = String(value || ''); - return `'${normalized.replace(/'/g, "'\"'\"'")}'`; -} - - -function hasGitMarker(dirPath) { - return fs.existsSync(path.join(dirPath, '.git')); -} - -function shouldSkipRepoDiscoveryDir(dirName) { - return new Set([ - '.git', - '.omx', - '.omc', - 'node_modules', - 'dist', - 'build', - '.next', - ]).has(dirName); -} - -function discoverNestedGitRepoRoots(rootPath, maxDepth = 3) { - const discovered = []; - - function visit(dirPath, depth) { - if (depth > maxDepth) return; - let entries = []; - try { - entries = fs.readdirSync(dirPath, { withFileTypes: true }); - } catch (_error) { - return; - } - - for (const entry of entries) { - if (!entry.isDirectory() || shouldSkipRepoDiscoveryDir(entry.name)) { - continue; - } - const childPath = path.join(dirPath, entry.name); - if (hasGitMarker(childPath)) { - discovered.push(childPath); - continue; - } - visit(childPath, depth + 1); - } - } - - visit(rootPath, 1); - return discovered; -} - -function discoverWorkspaceRepoRoots() { - const workspaceFolders = vscode.workspace.workspaceFolders || []; - const seen = new Set(); - const roots = []; - - for (const folder of workspaceFolders) { - const rootPath = folder?.uri?.fsPath; - if (!rootPath || seen.has(rootPath)) { - continue; - } - seen.add(rootPath); - roots.push(rootPath); - - for (const nestedRoot of discoverNestedGitRepoRoots(rootPath)) { - if (seen.has(nestedRoot)) { - continue; - } - seen.add(nestedRoot); - roots.push(nestedRoot); - } - } - - return roots; -} - -function repoPickLabel(repoRoot) { - const parent = path.basename(path.dirname(repoRoot)); - const base = path.basename(repoRoot); - return parent ? `${parent}/${base}` : base; -} - -function readGitOutput(repoRoot, args) { - try { - return cp.execFileSync('git', ['-C', repoRoot, ...args], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'], - }).trim(); - } catch { - return null; - } -} - -function repoGitSummary(repoRoot) { - const branch = readGitOutput(repoRoot, ['branch', '--show-current']) || 'unknown'; - const status = readGitOutput(repoRoot, ['status', '--porcelain']); - return { - branch, - dirty: status === null ? 'unknown' : status.length > 0 ? 'dirty' : 'clean', - }; -} - -function repoPickDescription(repoRoot) { - const summary = repoGitSummary(repoRoot); - return `${summary.branch} · ${summary.dirty}`; -} - -function findRepoRootForPath(repoRoots, candidatePath) { - const normalizedCandidatePath = normalizeAbsolutePath(candidatePath); - if (!normalizedCandidatePath) { - return null; - } - - return repoRoots - .filter((repoRoot) => isPathWithin(repoRoot, normalizedCandidatePath)) - .sort((left, right) => right.length - left.length)[0] || null; -} - -function activeScmRootPath() { - const sourceControl = vscode.scm?.activeSourceControl; - return sourceControl?.rootUri?.fsPath || sourceControl?.rootUri?.path || ''; -} - -function preferredRepoRoot(repoRoots) { - return findRepoRootForPath(repoRoots, activeScmRootPath()) - || findRepoRootForPath(repoRoots, vscode.window.activeTextEditor?.document?.uri?.fsPath); -} - -function resolveStartAgentCommand(repoRoot, details) { - const taskArg = shellQuote(details.taskName); - const agentArg = shellQuote(details.agentName); - return `gx agents start ${taskArg} --agent ${agentArg} --target ${shellQuote(repoRoot)}`; -} - -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 sessionTaskLabel(session) - || session?.label - || compactBranchLabel(session?.branch) - || session?.branch - || path.basename(session?.worktreePath || '') - || 'session'; -} - -function sessionTreeLabel(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 repoEntryDisplayLabel(repoRoot, sessions) { - const repoLabel = repoRootDisplayLabel(repoRoot); - const projectPaths = uniqueStringList((sessions || []) - .map((session) => resolveSessionProjectRelativePath(session)) - .filter(Boolean)); - if (projectPaths.length !== 1) { - return repoLabel; - } - - const [projectRelativePath] = projectPaths; - const hasRootScopedSession = (sessions || []).some( - (session) => !resolveSessionProjectRelativePath(session), - ); - if (!projectRelativePath || hasRootScopedSession) { - return repoLabel; - } - if (repoLabel.endsWith(`/${projectRelativePath}`)) { - return repoLabel; - } - return `${repoLabel}/${projectRelativePath}`; -} - -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); -} - -function ensureSessionWorktree(session, actionLabel) { - const worktreePath = sessionWorktreePath(session); - if (!worktreePath) { - showSessionMessage(`Cannot ${actionLabel}: missing worktree path.`); - return ''; - } - if (!fs.existsSync(worktreePath)) { - showSessionMessage(`Cannot ${actionLabel}: worktree is no longer on disk: ${worktreePath}`); - return ''; - } - return worktreePath; -} - -function runSessionTerminalCommand(session, actionLabel, iconId, commandText) { - const worktreePath = ensureSessionWorktree(session, actionLabel.toLowerCase()); - if (!worktreePath) { - return; - } - - const terminal = vscode.window.createTerminal({ - name: `GitGuardex ${actionLabel}: ${sessionDisplayLabel(session)}`, - cwd: worktreePath, - iconPath: new vscode.ThemeIcon(iconId), - }); - terminal.show(); - terminal.sendText(commandText, true); -} - -function sessionTerminalLabel(session) { - return `GitGuardex Terminal: ${sessionDisplayLabel(session)}`; -} - -function listWindowTerminals() { - return Array.isArray(vscode.window.terminals) ? vscode.window.terminals : []; -} - -function focusTerminal(terminal) { - terminal?.show?.(false); -} - -async function terminalProcessId(terminal) { - if (!terminal?.processId) { - return null; - } - - try { - const pid = await terminal.processId; - return Number.isInteger(pid) && pid > 0 ? pid : null; - } catch (_error) { - return null; - } -} - -function findFallbackSessionTerminal(session) { - const label = sessionTerminalLabel(session); - return listWindowTerminals().find((terminal) => terminal?.name === label) || null; -} - -async function findSessionTerminal(session) { - const pid = Number(session?.pid); - if (!Number.isInteger(pid) || pid <= 0) { - return null; - } - - for (const terminal of listWindowTerminals()) { - if (await terminalProcessId(terminal) === pid) { - return terminal; - } - } - - return null; -} - -function openFallbackSessionTerminal(session, worktreePath) { - const existingTerminal = findFallbackSessionTerminal(session); - if (existingTerminal) { - focusTerminal(existingTerminal); - return existingTerminal; - } - - const terminal = vscode.window.createTerminal({ - name: sessionTerminalLabel(session), - cwd: worktreePath, - iconPath: new vscode.ThemeIcon('terminal'), - }); - focusTerminal(terminal); - return terminal; -} - -async function showSessionTerminal(session) { - const worktreePath = ensureSessionWorktree(session, 'show terminal'); - if (!worktreePath) { - return; - } - - const terminal = await findSessionTerminal(session); - if (terminal) { - focusTerminal(terminal); - return; - } - - openFallbackSessionTerminal(session, worktreePath); -} - -function finishSession(session) { - if (!session?.branch) { - showSessionMessage('Cannot finish session: missing branch name.'); - return; - } - runSessionTerminalCommand( - session, - 'Finish', - 'check', - `gx branch finish --branch ${shellQuote(session.branch)}`, - ); -} - -function syncSession(session) { - runSessionTerminalCommand(session, 'Sync', 'sync', 'gx sync'); -} - -async function restartActiveAgents(extensionId) { - if (extensionId && extensionId !== ACTIVE_AGENTS_EXTENSION_ID) { - return; - } - await vscode.commands.executeCommand(RESTART_EXTENSION_HOST_COMMAND); -} - -function execFileAsync(command, args, options = {}) { - return new Promise((resolve, reject) => { - cp.execFile(command, args, options, (error, stdout = '', stderr = '') => { - if (error) { - error.stdout = stdout; - error.stderr = stderr; - reject(error); - return; - } - resolve({ stdout, stderr }); - }); - }); -} - -function buildStopSessionCommandText(session, pid) { - const parts = ['gx', 'agents', 'stop', '--pid', String(pid)]; - if (session?.repoRoot) { - parts.push('--target', session.repoRoot); - } - return parts.map(shellQuote).join(' '); -} - -async function stopSession(session, refresh) { - const pid = Number(session?.pid); - if (!Number.isInteger(pid) || pid <= 0) { - showSessionMessage('Cannot stop session: missing pid.'); - return; - } - if (!session?.branch) { - showSessionMessage('Cannot stop session: missing branch name.'); - return; - } - - const sessionTerminal = await findSessionTerminal(session); - const stopCommandText = buildStopSessionCommandText(session, pid); - const confirmed = await vscode.window.showWarningMessage( - `Stop ${sessionDisplayLabel(session)}?`, - { - modal: true, - detail: sessionTerminal - ? 'Send Ctrl+C to the live session terminal.' - : `No live session terminal found. Run ${stopCommandText}.`, - }, - 'Stop', - ); - if (confirmed !== 'Stop') { - return; - } - - if (sessionTerminal) { - focusTerminal(sessionTerminal); - sessionTerminal.sendText('\u0003', false); - refresh(); - return; - } - - try { - const commandCwd = session?.repoRoot || sessionWorktreePath(session) || process.cwd(); - const args = ['agents', 'stop', '--pid', String(pid)]; - if (session?.repoRoot) { - args.push('--target', session.repoRoot); - } - await execFileAsync('gx', args, { - cwd: commandCwd, - encoding: 'utf8', - maxBuffer: 1024 * 1024, - }); - refresh(); - } catch (error) { - showSessionMessage( - `Failed to stop session ${sessionDisplayLabel(session)}: ${formatGitCommandFailure(error)}`, - ); - } -} - -async function dismissSession(session, refresh) { - if (!canDismissSession(session)) { - showSessionMessage('Only stalled or dead sessions can be dismissed.'); - return; - } - - const repoRoot = typeof session?.repoRoot === 'string' ? session.repoRoot.trim() : ''; - if (!repoRoot) { - showSessionMessage('Cannot dismiss session: missing repo root.'); - return; - } - if (!session?.branch) { - showSessionMessage('Cannot dismiss session: missing branch name.'); - return; - } - - const statePath = sessionFilePathForBranch(repoRoot, session.branch); - if (!fs.existsSync(statePath)) { - clearWorktreeActivityCache(session.worktreePath); - refresh(); - showSessionMessage(`Session record already gone for ${sessionDisplayLabel(session)}.`); - return; - } - - const confirmed = await vscode.window.showWarningMessage( - `Dismiss ${sessionDisplayLabel(session)}?`, - { - modal: true, - detail: buildDismissSessionDetail(session, statePath), - }, - 'Dismiss', - ); - if (confirmed !== 'Dismiss') { - return; - } - - try { - fs.unlinkSync(statePath); - clearWorktreeActivityCache(session.worktreePath); - refresh(); - } catch (error) { - showSessionMessage(`Failed to dismiss session ${sessionDisplayLabel(session)}: ${error.message}`); - } -} - -function readGitDirPath(targetPath) { - const normalizedTargetPath = typeof targetPath === 'string' ? targetPath.trim() : ''; - if (!normalizedTargetPath) { - return ''; - } - - const gitPath = path.join(path.resolve(normalizedTargetPath), '.git'); - try { - if (fs.statSync(gitPath).isDirectory()) { - return gitPath; - } - } catch (_error) { - return ''; - } - - try { - const gitPointer = fs.readFileSync(gitPath, 'utf8'); - const match = gitPointer.match(/^gitdir:\s*(.+)$/m); - if (match?.[1]) { - return path.resolve(path.dirname(gitPath), match[1].trim()); - } - } catch (_error) { - return ''; - } - - return ''; -} - -function resolveRepoRootFromGitDir(targetPath) { - const gitDir = readGitDirPath(targetPath); - if (!gitDir) { - return ''; - } - - let commonDir = gitDir; - try { - const commonDirPath = path.join(gitDir, 'commondir'); - if (fs.existsSync(commonDirPath)) { - const rawCommonDir = fs.readFileSync(commonDirPath, 'utf8').trim(); - if (rawCommonDir) { - commonDir = path.resolve(gitDir, rawCommonDir); - } - } - } catch (_error) { - // Fall back to the direct git dir when commondir is unreadable. - } - - return path.basename(commonDir) === '.git' - ? path.resolve(path.dirname(commonDir)) - : ''; -} - -function readGitTopLevel(targetPath) { - try { - return cp.execFileSync('git', ['-C', targetPath, 'rev-parse', '--show-toplevel'], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'], - }).trim(); - } catch (_error) { - return ''; - } -} - -function resolveWorkspaceFolderRepoRoot(workspacePath) { - const normalizedWorkspacePath = typeof workspacePath === 'string' ? workspacePath.trim() : ''; - if (!normalizedWorkspacePath) { - return ''; - } - - const absoluteWorkspacePath = path.resolve(normalizedWorkspacePath); - const directRepoRoot = resolveRepoRootFromGitDir(absoluteWorkspacePath); - if (directRepoRoot) { - return directRepoRoot; - } - - const gitTopLevel = readGitTopLevel(absoluteWorkspacePath); - if (!gitTopLevel) { - return absoluteWorkspacePath; - } - - return resolveRepoRootFromGitDir(gitTopLevel) || path.resolve(gitTopLevel); -} - -function repoRootFromSessionFile(filePath) { - return path.resolve(path.dirname(filePath), '..', '..', '..'); -} - -function repoRootFromWorktreeLockFile(filePath) { - return path.resolve(path.dirname(filePath), '..', '..', '..'); -} - -function repoRootFromManagedWorktreeGitFile(filePath) { - return path.resolve(path.dirname(filePath), '..', '..', '..'); -} - -function repoRootFromLockFile(filePath) { - return path.resolve(path.dirname(filePath), '..', '..'); -} - -function normalizeRelativePath(relativePath) { - return String(relativePath || '').replace(/\\/g, '/').replace(/^\.\//, ''); -} - -function emptyLockRegistry() { - return { - entriesByPath: new Map(), - countsByBranch: new Map(), - }; -} - -function readLockRegistry(repoRoot) { - const lockPath = path.join(repoRoot, LOCK_FILE_RELATIVE); - if (!fs.existsSync(lockPath)) { - return emptyLockRegistry(); - } - - let parsed; - try { - parsed = JSON.parse(fs.readFileSync(lockPath, 'utf8')); - } catch (_error) { - return emptyLockRegistry(); - } - - const locks = parsed?.locks; - if (!locks || typeof locks !== 'object' || Array.isArray(locks)) { - return emptyLockRegistry(); - } - - const entriesByPath = new Map(); - const countsByBranch = new Map(); - for (const [rawRelativePath, entry] of Object.entries(locks)) { - if (!entry || typeof entry !== 'object') { - continue; - } - - const relativePath = normalizeRelativePath(rawRelativePath); - const branch = typeof entry.branch === 'string' ? entry.branch.trim() : ''; - if (!relativePath || !branch) { - continue; - } - - entriesByPath.set(relativePath, { - branch, - claimedAt: typeof entry.claimed_at === 'string' ? entry.claimed_at : '', - allowDelete: Boolean(entry.allow_delete), - }); - countsByBranch.set(branch, (countsByBranch.get(branch) || 0) + 1); - } - - return { - entriesByPath, - countsByBranch, - }; -} - -function readCurrentBranch(repoRoot) { - try { - return cp.execFileSync('git', ['-C', repoRoot, 'rev-parse', '--abbrev-ref', 'HEAD'], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'], - }).trim(); - } catch (_error) { - return ''; - } -} - -function parseSimpleSemver(version) { - const parts = String(version || '') - .split('.') - .map((part) => Number.parseInt(part, 10)); - if (parts.length !== 3 || parts.some((part) => Number.isNaN(part))) { - return null; - } - return parts; -} - -function compareSimpleSemver(left, right) { - const leftParts = parseSimpleSemver(left); - const rightParts = parseSimpleSemver(right); - if (!leftParts || !rightParts) { - return 0; - } - - for (let index = 0; index < leftParts.length; index += 1) { - if (leftParts[index] !== rightParts[index]) { - return leftParts[index] - rightParts[index]; - } - } - - return 0; -} - -function readJsonFile(filePath) { - try { - return JSON.parse(fs.readFileSync(filePath, 'utf8')); - } catch (_error) { - return null; - } -} - -function resolveActiveAgentsAutoUpdateCandidate(installedVersion) { - const candidates = []; - - for (const workspaceFolder of vscode.workspace.workspaceFolders || []) { - const repoRoot = workspaceFolder?.uri?.fsPath; - if (!repoRoot) { - continue; - } - - const manifestPath = path.join(repoRoot, ACTIVE_AGENTS_MANIFEST_RELATIVE); - const installScriptPath = path.join(repoRoot, ACTIVE_AGENTS_INSTALL_SCRIPT_RELATIVE); - if (!fs.existsSync(manifestPath) || !fs.existsSync(installScriptPath)) { - continue; - } - - const manifest = readJsonFile(manifestPath); - const nextVersion = typeof manifest?.version === 'string' ? manifest.version.trim() : ''; - if (!nextVersion || compareSimpleSemver(nextVersion, installedVersion) <= 0) { - continue; - } - - candidates.push({ repoRoot, installScriptPath, version: nextVersion }); - } - - candidates.sort((left, right) => compareSimpleSemver(right.version, left.version)); - return candidates[0] || null; -} - -function runActiveAgentsInstallScript(repoRoot, installScriptPath) { - return new Promise((resolve, reject) => { - cp.execFile( - process.execPath, - [installScriptPath], - { cwd: repoRoot, encoding: 'utf8' }, - (error, stdout, stderr) => { - if (error) { - reject(new Error(String(stderr || stdout || error.message || '').trim() || 'install failed')); - return; - } - resolve({ stdout, stderr }); - }, - ); - }); -} - -async function maybeAutoUpdateActiveAgentsExtension(context) { - const installedVersion = typeof context?.extension?.packageJSON?.version === 'string' - ? context.extension.packageJSON.version.trim() - : ''; - if (!installedVersion) { - return; - } - - const candidate = resolveActiveAgentsAutoUpdateCandidate(installedVersion); - if (!candidate) { - return; - } - - try { - await runActiveAgentsInstallScript(candidate.repoRoot, candidate.installScriptPath); - } catch (error) { - const failure = typeof error?.message === 'string' && error.message.trim() - ? error.message.trim() - : 'install failed'; - vscode.window.showWarningMessage?.( - `GitGuardex Active Agents could not auto-update to ${candidate.version}: ${failure}`, - ); - return; - } - - const selection = await vscode.window.showInformationMessage?.( - `GitGuardex Active Agents updated to ${candidate.version}. Reload this window now, then reload any other already-open VS Code windows to use the newest companion.`, - RELOAD_WINDOW_ACTION, - UPDATE_LATER_ACTION, - ); - if (selection === RELOAD_WINDOW_ACTION) { - await vscode.commands.executeCommand('workbench.action.reloadWindow'); - } -} - -function decorateSession(session, lockRegistry) { - const touchedChanges = buildSessionTouchedChanges(session, lockRegistry); - const decorated = { - ...session, - lockCount: lockRegistry.countsByBranch.get(session.branch) || 0, - touchedChanges, - conflictCount: touchedChanges.filter((change) => change.hasForeignLock).length, - }; - decorated.lastActiveAt = sessionLastActiveAt(decorated); - decorated.lastActiveLabel = sessionLastActiveLabel(decorated); - decorated.freshnessLabel = sessionFreshnessLabel(decorated); - decorated.topChangedFiles = buildSessionTopFiles(decorated); - decorated.topChangedFilesLabel = summarizeCompactPaths(decorated.topChangedFiles); - decorated.recentChangeSummary = buildSessionRecentChangeSummary(decorated); - decorated.riskBadges = sessionRiskBadges(decorated); - return decorated; -} - -function decorateChange(change, lockRegistry, owningBranch) { - const lockEntry = lockRegistry.entriesByPath.get(normalizeRelativePath(change.relativePath)); - const lockOwnerBranch = lockEntry?.branch || ''; - const decorated = { - ...change, - lockOwnerBranch, - hasForeignLock: Boolean(lockOwnerBranch) && (!owningBranch || lockOwnerBranch !== owningBranch), - protectedBranch: isProtectedBranchName(owningBranch), - }; - decorated.riskBadges = changeRiskBadges(decorated); - return decorated; -} - -function buildSessionTouchedChanges(session, lockRegistry) { - const changedPaths = Array.isArray(session.worktreeChangedPaths) - ? session.worktreeChangedPaths - : []; - return [...new Set(changedPaths.map(normalizeRelativePath).filter(Boolean))] - .map((relativePath) => { - const lockEntry = lockRegistry.entriesByPath.get(relativePath); - const lockOwnerBranch = lockEntry?.branch || ''; - return { - relativePath, - absolutePath: path.join(session.worktreePath, relativePath), - originalPath: '', - statusCode: 'M', - statusLabel: 'M', - statusText: 'Touched', - lockOwnerBranch, - hasForeignLock: Boolean(lockOwnerBranch) && lockOwnerBranch !== session.branch, - }; - }); -} - -function isPathWithin(parentPath, targetPath) { - const relativePath = path.relative(parentPath, targetPath); - return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath)); -} - -function normalizeAbsolutePath(value) { - return typeof value === 'string' && value.trim() ? path.resolve(value) : ''; -} - -function isManagedWorktreePath(worktreePath) { - const normalizedWorktreePath = normalizeAbsolutePath(worktreePath); - if (!normalizedWorktreePath) { - return false; - } - - return MANAGED_WORKTREE_RELATIVE_ROOTS.some((relativeRoot) => { - const normalizedRelativeRoot = path.normalize(relativeRoot); - const marker = `${path.sep}${normalizedRelativeRoot}${path.sep}`; - return normalizedWorktreePath.includes(marker); - }); -} - -function removeDeletedWorktreeWorkspaceFolder(worktreePath) { - if (typeof vscode.workspace.updateWorkspaceFolders !== 'function') { - return false; - } - - const normalizedWorktreePath = normalizeAbsolutePath(worktreePath); - if (!normalizedWorktreePath) { - return false; - } - - const workspaceFolders = vscode.workspace.workspaceFolders || []; - const folderIndex = workspaceFolders.findIndex((folder) => ( - normalizeAbsolutePath(folder?.uri?.fsPath) === normalizedWorktreePath - )); - if (folderIndex < 0) { - return false; - } - - try { - return vscode.workspace.updateWorkspaceFolders(folderIndex, 1) === true; - } catch (_error) { - return false; - } -} - -async function closeDeletedWorktreeRepository(worktreePath) { - const normalizedWorktreePath = normalizeAbsolutePath(worktreePath); - if (!normalizedWorktreePath || fs.existsSync(normalizedWorktreePath)) { - return false; - } - - try { - await vscode.commands.executeCommand('git.close', vscode.Uri.file(normalizedWorktreePath)); - } catch (_error) { - // The Git extension may have already removed this repository. - } - - removeDeletedWorktreeWorkspaceFolder(normalizedWorktreePath); - return true; -} - -function findDeletedManagedWorkspaceFolders() { - return (vscode.workspace.workspaceFolders || []) - .map((folder) => normalizeAbsolutePath(folder?.uri?.fsPath)) - .filter((workspacePath) => ( - workspacePath - && !fs.existsSync(workspacePath) - && isManagedWorktreePath(workspacePath) - )); -} - -function localizeChangeForSession(session, change) { - if (!change?.absolutePath || !isPathWithin(session.worktreePath, change.absolutePath)) { - return null; - } - - let originalPath = change.originalPath; - if (originalPath) { - const originalAbsolutePath = path.join(session.repoRoot, originalPath); - if (isPathWithin(session.worktreePath, originalAbsolutePath)) { - originalPath = normalizeRelativePath(path.relative(session.worktreePath, originalAbsolutePath)); - } - } - - return { - ...change, - relativePath: normalizeRelativePath(path.relative(session.worktreePath, change.absolutePath)), - originalPath, - }; -} - -async function findRepoSessionEntries() { - const [sessionFiles, worktreeLockFiles, managedWorktreeGitFiles] = await Promise.all([ - vscode.workspace.findFiles( - ACTIVE_SESSION_FILES_GLOB, - SESSION_SCAN_EXCLUDE_GLOB, - SESSION_SCAN_LIMIT, - ), - vscode.workspace.findFiles( - WORKTREE_AGENT_LOCKS_GLOB, - WORKTREE_LOCK_SCAN_EXCLUDE_GLOB, - SESSION_SCAN_LIMIT, - ), - vscode.workspace.findFiles( - MANAGED_WORKTREE_GIT_FILES_GLOB, - MANAGED_WORKTREE_GIT_SCAN_EXCLUDE_GLOB, - SESSION_SCAN_LIMIT, - ), - ]); - - const repoRoots = new Set(); - const addRepoRootCandidate = (repoRoot) => { - if (typeof repoRoot !== 'string' || !repoRoot.trim()) { - return; - } - - const normalizedRepoRoot = path.resolve(repoRoot); - const isInsideWorkspaceManagedWorktree = (vscode.workspace.workspaceFolders || []) - .map((folder) => (typeof folder?.uri?.fsPath === 'string' ? path.resolve(folder.uri.fsPath) : '')) - .filter(Boolean) - .some((workspaceRoot) => MANAGED_WORKTREE_RELATIVE_ROOTS.some((relativeRoot) => ( - isPathWithin(path.join(workspaceRoot, relativeRoot), normalizedRepoRoot) - ))); - if (!isInsideWorkspaceManagedWorktree) { - repoRoots.add(normalizedRepoRoot); - } - }; - - for (const uri of sessionFiles) { - addRepoRootCandidate(repoRootFromSessionFile(uri.fsPath)); - } - for (const uri of worktreeLockFiles) { - if (path.basename(uri.fsPath) !== 'AGENT.lock') { - continue; - } - addRepoRootCandidate(repoRootFromWorktreeLockFile(uri.fsPath)); - } - for (const uri of managedWorktreeGitFiles) { - if (path.basename(uri.fsPath) !== '.git') { - continue; - } - addRepoRootCandidate(repoRootFromManagedWorktreeGitFile(uri.fsPath)); - } - for (const workspaceFolder of vscode.workspace.workspaceFolders || []) { - if (workspaceFolder?.uri?.fsPath) { - addRepoRootCandidate(resolveWorkspaceFolderRepoRoot(workspaceFolder.uri.fsPath)); - } - } - - const repoEntries = []; - for (const repoRoot of repoRoots) { - const sessions = readActiveSessions(repoRoot, { includeStale: true }); - if (sessions.length > 0) { - repoEntries.push({ repoRoot, sessions }); - } - } - - repoEntries.sort((left, right) => left.repoRoot.localeCompare(right.repoRoot)); - return repoEntries; -} - -function resolveSessionWatcherKey(session) { - return `${path.resolve(session.repoRoot)}::${session.branch}::${path.resolve(session.worktreePath)}`; -} - -function resolveSessionGitIndexPath(worktreePath) { - const gitPath = path.join(worktreePath, '.git'); - const defaultIndexPath = path.join(gitPath, 'index'); - - try { - if (fs.statSync(gitPath).isDirectory()) { - return defaultIndexPath; - } - } catch (_error) { - return defaultIndexPath; - } - - try { - const gitPointer = fs.readFileSync(gitPath, 'utf8'); - const match = gitPointer.match(/^gitdir:\s*(.+)$/m); - if (match?.[1]) { - return path.resolve(worktreePath, match[1].trim(), 'index'); - } - } catch (_error) { - return defaultIndexPath; - } - - return defaultIndexPath; -} - -function bindRefreshWatcher(watcher, refresh) { - return [ - watcher.onDidCreate(refresh), - watcher.onDidChange(refresh), - watcher.onDidDelete(refresh), - ]; -} - -function disposeAll(disposables) { - for (const disposable of disposables) { - disposable?.dispose?.(); - } -} - -function buildChangeTreeNodes(changes) { - const root = []; - - function sortNodes(nodes) { - nodes.sort((left, right) => { - const leftIsFolder = left.kind === 'folder'; - const rightIsFolder = right.kind === 'folder'; - if (leftIsFolder !== rightIsFolder) { - return leftIsFolder ? -1 : 1; - } - return left.label.localeCompare(right.label); - }); - - for (const node of nodes) { - if (node.kind === 'folder') { - sortNodes(node.children); - } - } - } - - for (const change of changes) { - const segments = change.relativePath.split(/[\\/]+/).filter(Boolean); - if (segments.length <= 1) { - root.push({ kind: 'change', label: change.relativePath, change }); - continue; - } - - let nodes = root; - let folderPath = ''; - for (const segment of segments.slice(0, -1)) { - folderPath = folderPath ? path.posix.join(folderPath, segment) : segment; - let folderNode = nodes.find((node) => node.kind === 'folder' && node.relativePath === folderPath); - if (!folderNode) { - folderNode = { - kind: 'folder', - label: segment, - relativePath: folderPath, - children: [], - }; - nodes.push(folderNode); - } - nodes = folderNode.children; - } - - nodes.push({ kind: 'change', label: change.relativePath, change }); - } - - sortNodes(root); - - function materialize(nodes) { - return nodes.map((node) => { - if (node.kind === 'folder') { - return new FolderItem(node.label, node.relativePath, materialize(node.children)); - } - return new ChangeItem(node.change); - }); - } - - return materialize(root); -} - -function countChangedPaths(repoRoot, sessions, changes) { - const changedKeys = new Set(); - - for (const change of changes || []) { - if (change?.relativePath) { - changedKeys.add(normalizeRelativePath(change.relativePath)); - } - } - - for (const session of sessions || []) { - for (const change of session.touchedChanges || []) { - const absolutePath = change?.absolutePath - || path.join(session.worktreePath || '', change?.relativePath || ''); - const normalizedRelativePath = absolutePath && isPathWithin(repoRoot, absolutePath) - ? normalizeRelativePath(path.relative(repoRoot, absolutePath)) - : `${session.branch}:${normalizeRelativePath(change?.relativePath)}`; - if (normalizedRelativePath) { - changedKeys.add(normalizedRelativePath); - } - } - } - - return changedKeys.size; -} - -function buildRepoOverview(sessions, unassignedChanges, lockEntries, colonyTasks = []) { - const colonyTaskList = Array.isArray(colonyTasks) ? colonyTasks : []; - return { - sessionCount: sessions.length, - workingCount: countWorkingSessions(sessions), - finishedCount: countFinishedSessions(sessions), - idleCount: countIdleSessions(sessions), - unassignedChangeCount: (unassignedChanges || []).length, - lockedFileCount: Array.isArray(lockEntries) ? lockEntries.length : 0, - conflictCount: sessions.reduce( - (total, session) => total + (session.conflictCount || 0), - 0, - ) + (unassignedChanges || []).filter((change) => change.hasForeignLock).length, - colonyTaskCount: colonyTaskList.length, - pendingHandoffCount: colonyTaskList.reduce( - (total, task) => total + (task.pending_handoff_count || 0), - 0, - ), - }; -} - -function groupSessionsByWorktree(sessions) { - const sessionsByWorktree = new Map(); - - for (const session of sessions || []) { - const worktreePath = sessionWorktreePath(session); - const key = worktreePath || session?.branch || `session-${sessionsByWorktree.size + 1}`; - if (!sessionsByWorktree.has(key)) { - sessionsByWorktree.set(key, { - worktreePath, - sessions: [], - }); - } - sessionsByWorktree.get(key).sessions.push(session); - } - - return [...sessionsByWorktree.values()] - .map((entry) => ({ - ...entry, - sessions: entry.sessions.sort((left, right) => ( - sessionTreeLabel(left).localeCompare(sessionTreeLabel(right)) - )), - })) - .sort((left, right) => { - const leftLabel = path.basename(left.worktreePath || '') || ''; - const rightLabel = path.basename(right.worktreePath || '') || ''; - return leftLabel.localeCompare(rightLabel) - || (left.worktreePath || '').localeCompare(right.worktreePath || ''); - }); -} - -function partitionChangesByOwnership(sessions, changes) { - const changesBySession = new Map(); - const sessionByChangedPath = new Map(); - const repoRootChanges = []; - - for (const session of sessions) { - changesBySession.set(session.branch, []); - for (const changedPath of session.changedPaths || []) { - if (!sessionByChangedPath.has(changedPath)) { - sessionByChangedPath.set(changedPath, session); - } - } - } - - for (const change of changes) { - const normalizedRelativePath = normalizeRelativePath(change.relativePath); - const session = sessionByChangedPath.get(normalizedRelativePath) - || sessions.find((candidate) => isPathWithin(candidate.worktreePath, change.absolutePath)); - if (!session) { - repoRootChanges.push(change); - continue; - } - - const localizedChange = localizeChangeForSession(session, change); - if (!localizedChange) { - repoRootChanges.push(change); - continue; - } - - changesBySession.get(session.branch).push(localizedChange); - } - - return { - changesBySession, - repoRootChanges, - }; -} - -function buildGroupedChangeTreeNodes(sessions, changes) { - const { changesBySession, repoRootChanges } = partitionChangesByOwnership(sessions, changes); - - 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), { - description: String(repoRootChanges.length), - })); - } - - return items; -} - -function countActiveSessions(sessions) { - return sessions.filter((session) => session.activityKind !== 'dead').length; -} - -function countSessionsByActivityKind(sessions, activityKind) { - return sessions.filter((session) => session.activityKind === activityKind).length; -} - -function resolveSessionActivityIconId(activityKind) { - return SESSION_ACTIVITY_ICON_IDS[activityKind] || 'loading~spin'; -} - -async function pickRepoRoot() { - const repoRoots = discoverWorkspaceRepoRoots(); - if (repoRoots.length === 0) { - vscode.window.showInformationMessage?.('Open a Guardex workspace folder to start an agent.'); - return null; - } - - if (repoRoots.length === 1) { - return repoRoots[0]; - } - - const selectedRepoRoot = preferredRepoRoot(repoRoots); - if (selectedRepoRoot) { - return selectedRepoRoot; - } - - const picks = repoRoots.map((repoRoot) => ({ - label: repoPickLabel(repoRoot), - description: repoPickDescription(repoRoot), - detail: repoRoot, - repoRoot, - })); - const selection = await vscode.window.showQuickPick?.(picks, { - placeHolder: 'Select the Git repo where the Start agent launcher should run.', - }); - return selection?.repoRoot || null; -} - -async function promptStartAgentDetails() { - const taskName = await vscode.window.showInputBox?.({ - prompt: 'Task for the Guardex agent launcher', - placeHolder: 'vscode active agents welcome view', - ignoreFocusOut: true, - validateInput: (value) => value.trim() ? undefined : 'Task is required.', - }); - if (!taskName) { - return null; - } - - const agentName = await vscode.window.showInputBox?.({ - prompt: 'Agent name for the Guardex agent launcher', - placeHolder: 'codex', - value: 'codex', - ignoreFocusOut: true, - validateInput: (value) => value.trim() ? undefined : 'Agent name is required.', - }); - if (!agentName) { - return null; - } - - return { - taskName: taskName.trim(), - agentName: agentName.trim(), - }; -} - -async function startAgentFromPrompt(refresh) { - const repoRoot = await pickRepoRoot(); - if (!repoRoot) { - return; - } - - const details = await promptStartAgentDetails(); - if (!details) { - return; - } - - const terminal = vscode.window.createTerminal?.({ - name: `GitGuardex: ${path.basename(repoRoot)}`, - cwd: repoRoot, - }); - terminal?.show(true); - terminal?.sendText(resolveStartAgentCommand(repoRoot, details), true); - refresh(); -} - -function sessionSelectionKey(session) { - if (!session?.repoRoot || !session?.branch) { - return ''; - } - - return `${session.repoRoot}::${session.branch}`; -} - -function formatGitCommandFailure(error) { - for (const value of [error?.stderr, error?.stdout, error?.message]) { - if (typeof value === 'string' && value.trim().length > 0) { - return value.trim(); - } - } - return 'Git command failed.'; -} - -function runGitCommand(worktreePath, args) { - return cp.execFileSync('git', ['-C', worktreePath, ...args], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - }); -} - -function stageWorktreeForCommit(worktreePath) { - runGitCommand(worktreePath, ['add', '-A', '--', '.', `:(exclude)${LOCK_FILE_RELATIVE}`]); -} - -function commitWorktree(worktreePath, message) { - runGitCommand(worktreePath, ['commit', '-m', 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', - }), - new DetailItem('Top files', session.topChangedFilesLabel || 'No tracked file edits.', { - iconId: 'list-flat', - }), - ]; - 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.push(new DetailItem('Provider', provider.label, { - iconId: 'rocket', - })); - } - if (snapshot) { - items.push(new DetailItem('Snapshot', snapshot, { - iconId: 'device-camera', - })); - } - 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-library', - tooltip: session.worktreePath, - })); - return items; -} - -function buildWorkingNowNodes(sessions) { - const sessionEntries = sortSessionsForWorkingNow( - sessions.filter((session) => ( - session.activityKind === 'working' || session.activityKind === 'blocked' - )), - ).map((session) => ({ - projectRelativePath: resolveSessionProjectRelativePath(session), - sessions: [session], - item: new SessionItem(session, buildSessionDetailItems(session)), - })); - return buildProjectScopedItems(sessionEntries, { rootLabel: 'Repo root' }); -} - -function buildIdleThinkingNodes(sessions) { - const sessionEntries = sortSessionsForIdleThinking( - sessions.filter((session) => !( - session.activityKind === 'working' - || session.activityKind === 'blocked' - || session.activityKind === 'finished' - )), - ).map((session) => ({ - projectRelativePath: resolveSessionProjectRelativePath(session), - sessions: [session], - item: new SessionItem(session, buildSessionDetailItems(session)), - })); - return buildProjectScopedItems(sessionEntries, { rootLabel: 'Repo root' }); -} - -function buildNeedsCleanupNodes(sessions) { - const sessionEntries = sessions - .filter((session) => session.activityKind === 'finished') - .map((session) => ({ - projectRelativePath: resolveSessionProjectRelativePath(session), - sessions: [session], - item: new SessionItem(session, buildSessionDetailItems(session)), - })); - return buildProjectScopedItems(sessionEntries, { rootLabel: 'Repo root' }); -} - -function buildUnassignedChangeNodes(changes) { - return sortUnassignedChanges(changes).map((change) => new ChangeItem(change, { - label: compactRelativePath(change.relativePath), - description: buildUnassignedChangeDescription(change), - iconId: changeNeedsWarningIcon(change) ? 'warning' : undefined, - })); -} - -function buildRawActiveAgentGroupNodes(sessions) { - const groups = []; - for (const group of SESSION_ACTIVITY_GROUPS) { - const groupSessions = sessions.filter((session) => session.activityKind === group.kind); - 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', - }, - )), - { - 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, { - iconId: resolveSessionActivityIconId(group.kind), - })); - } - } - - return groups; -} - -class ActiveAgentsProvider { - constructor(decorationProvider) { - this.decorationProvider = decorationProvider; - this.onDidChangeTreeDataEmitter = new vscode.EventEmitter(); - this.onDidChangeTreeData = this.onDidChangeTreeDataEmitter.event; - this.onDidChangeSelectedSessionEmitter = new vscode.EventEmitter(); - this.onDidChangeSelectedSession = this.onDidChangeSelectedSessionEmitter.event; - this.treeView = null; - this.lockRegistryByRepoRoot = new Map(); - this.selectedSession = null; - this.viewSummary = { - sessionCount: 0, - workingCount: 0, - finishedCount: 0, - idleCount: 0, - unassignedChangeCount: 0, - lockedFileCount: 0, - deadCount: 0, - conflictCount: 0, - }; - this.previousSnapshot = null; - } - - getTreeItem(element) { - return element; - } - - attachTreeView(treeView) { - this.treeView = treeView; - this.updateViewState({ - sessionCount: 0, - workingCount: 0, - finishedCount: 0, - idleCount: 0, - unassignedChangeCount: 0, - lockedFileCount: 0, - deadCount: 0, - conflictCount: 0, - }); - treeView.onDidChangeSelection?.((event) => { - const sessionItem = event.selection.find((item) => item instanceof SessionItem); - this.setSelectedSession(sessionItem?.session || null); - }); - } - - setSelectedSession(session) { - const nextSession = session?.worktreePath ? { ...session } : null; - const currentKey = sessionSelectionKey(this.selectedSession); - const nextKey = sessionSelectionKey(nextSession); - this.selectedSession = nextSession; - this.decorationProvider?.setSelectedBranch(nextSession?.branch || ''); - if (currentKey !== nextKey) { - this.onDidChangeSelectedSessionEmitter.fire(this.selectedSession); - } - } - - getSelectedSession() { - return this.selectedSession ? { ...this.selectedSession } : null; - } - - getViewSummary() { - return { ...this.viewSummary }; - } - - syncSelectedSession(repoEntries) { - if (!this.selectedSession) { - return; - } - - const nextSession = repoEntries - .flatMap((entry) => entry.sessions) - .find((session) => sessionSelectionKey(session) === sessionSelectionKey(this.selectedSession)); - this.setSelectedSession(nextSession || null); - } - - updateViewState(summary) { - if (!this.treeView) { - return; - } - - const sessionCount = summary?.sessionCount || 0; - const conflictCount = summary?.conflictCount || 0; - this.viewSummary = { ...summary }; - void vscode.commands.executeCommand('setContext', 'guardex.hasAgents', sessionCount > 0); - void vscode.commands.executeCommand('setContext', 'guardex.hasConflicts', conflictCount > 0); - - this.treeView.badge = sessionCount > 0 - ? { - value: sessionCount, - tooltip: buildOverviewDescription(summary), - } - : undefined; - this.treeView.message = undefined; - } - - annotateRepoEntries(repoEntries) { - const hasPreviousSnapshot = Boolean(this.previousSnapshot); - const nextSnapshot = { - sessions: new Map(), - changes: new Map(), - }; - - const annotatedEntries = repoEntries.map((entry) => { - const sessions = entry.sessions.map((session) => { - const snapshotKey = sessionSnapshotKey(session); - nextSnapshot.sessions.set(snapshotKey, buildSessionSnapshot(session)); - const deltaLabel = hasPreviousSnapshot - ? deriveSessionDelta(this.previousSnapshot.sessions.get(snapshotKey), session) - : ''; - return { - ...session, - deltaLabel, - riskBadges: uniqueStringList([ - ...(session.riskBadges || []), - deltaLabel, - ].filter(Boolean)), - }; - }); - - const changes = entry.changes.map((change) => { - const snapshotKey = changeSnapshotKey(entry.repoRoot, change); - nextSnapshot.changes.set(snapshotKey, buildChangeSnapshot(change)); - const deltaLabel = hasPreviousSnapshot - ? deriveChangeDelta(this.previousSnapshot.changes.get(snapshotKey), change) - : ''; - return { - ...change, - deltaLabel, - riskBadges: changeRiskBadges({ - ...change, - deltaLabel, - }), - }; - }); - - const { repoRootChanges } = partitionChangesByOwnership(sessions, changes); - const unassignedChanges = sortUnassignedChanges(repoRootChanges); - const colonyTasks = Array.isArray(entry.colonyTasks) ? entry.colonyTasks : []; - return { - ...entry, - sessions, - changes, - unassignedChanges, - colonyTasks, - overview: buildRepoOverview(sessions, unassignedChanges, entry.lockEntries, colonyTasks), - }; - }); - - this.previousSnapshot = nextSnapshot; - return annotatedEntries; - } - - async syncRepoEntries() { - const repoEntries = this.annotateRepoEntries(await this.loadRepoEntries()); - const summary = { - sessionCount: repoEntries.reduce((total, entry) => total + entry.sessions.length, 0), - workingCount: repoEntries.reduce((total, entry) => total + entry.overview.workingCount, 0), - finishedCount: repoEntries.reduce( - (total, entry) => total + (entry.overview.finishedCount || 0), - 0, - ), - idleCount: repoEntries.reduce((total, entry) => total + entry.overview.idleCount, 0), - unassignedChangeCount: repoEntries.reduce( - (total, entry) => total + entry.overview.unassignedChangeCount, - 0, - ), - lockedFileCount: repoEntries.reduce((total, entry) => total + entry.overview.lockedFileCount, 0), - deadCount: repoEntries.reduce( - (total, entry) => total + countSessionsByActivityKind(entry.sessions, 'dead'), - 0, - ), - conflictCount: repoEntries.reduce((total, entry) => total + entry.overview.conflictCount, 0), - }; - - this.updateViewState(summary); - this.decorationProvider?.updateSessions(repoEntries.flatMap((entry) => entry.sessions)); - this.decorationProvider?.updateLockEntries(repoEntries); - return repoEntries; - } - - async refresh() { - await this.syncRepoEntries(); - this.onDidChangeTreeDataEmitter.fire(); - this.decorationProvider?.refresh(); - } - - readLockRegistryForRepo(repoRoot) { - const lockRegistry = readLockRegistry(repoRoot); - this.lockRegistryByRepoRoot.set(repoRoot, lockRegistry); - return lockRegistry; - } - - getLockRegistryForRepo(repoRoot) { - return this.lockRegistryByRepoRoot.get(repoRoot) || this.readLockRegistryForRepo(repoRoot); - } - - refreshLockRegistryForFile(filePath) { - this.readLockRegistryForRepo(repoRootFromLockFile(filePath)); - } - - async getChildren(element) { - if (element instanceof RepoItem) { - const sectionItems = [ - new SectionItem('Overview', [ - new DetailItem('Summary', buildOverviewDescription(element.overview), { - iconId: 'dashboard', - tooltip: buildRepoTooltip(element.repoRoot, element.overview), - }), - ], { - description: '1', - iconId: 'telescope', - }), - ]; - - const workingNowItems = buildWorkingNowNodes(element.sessions); - if (workingNowItems.length > 0) { - sectionItems.push(new SectionItem('Working now', workingNowItems, { - description: String(workingNowItems.length), - collapsedState: vscode.TreeItemCollapsibleState.Collapsed, - iconId: 'loading~spin', - })); - } - - const needsCleanupItems = buildNeedsCleanupNodes(element.sessions); - if (needsCleanupItems.length > 0) { - sectionItems.push(new SectionItem('Needs cleanup', needsCleanupItems, { - description: String(needsCleanupItems.length), - collapsedState: vscode.TreeItemCollapsibleState.Collapsed, - iconId: 'pass-filled', - })); - } - - const idleThinkingItems = buildIdleThinkingNodes(element.sessions); - if (idleThinkingItems.length > 0) { - sectionItems.push(new SectionItem('Idle / thinking', idleThinkingItems, { - description: String(idleThinkingItems.length), - collapsedState: vscode.TreeItemCollapsibleState.Collapsed, - iconId: 'debug-pause', - })); - } - - if (element.unassignedChanges.length > 0) { - sectionItems.push(new SectionItem('Unassigned changes', buildUnassignedChangeNodes(element.unassignedChanges), { - description: String(element.unassignedChanges.length), - iconId: 'inbox', - })); - } - - const advancedItems = []; - const rawActiveAgents = buildRawActiveAgentGroupNodes(element.sessions); - if (rawActiveAgents.length > 0) { - advancedItems.push(new SectionItem('Active agent tree', rawActiveAgents, { - description: String(element.sessions.length), - collapsedState: vscode.TreeItemCollapsibleState.Collapsed, - iconId: 'organization', - })); - } - const rawChangeTree = buildGroupedChangeTreeNodes(element.sessions, element.changes); - if (rawChangeTree.length > 0) { - advancedItems.push(new SectionItem('Raw path tree', rawChangeTree, { - description: String(element.changes.length), - collapsedState: vscode.TreeItemCollapsibleState.Collapsed, - iconId: 'file-directory', - })); - } - const colonyTaskList = Array.isArray(element.colonyTasks) ? element.colonyTasks : []; - if (colonyTaskList.length > 0) { - const colonyItems = colonyTaskList.map((task) => { - const pendingLabel = task.pending_handoff_count > 0 - ? formatCountLabel(task.pending_handoff_count, 'pending handoff') - : 'quiet'; - const participantLabel = - (task.participants || []).map((p) => p.agent).filter(Boolean).join(', ') - || 'no participants'; - return new DetailItem( - `#${task.id} · ${compactColonyBranchLabel(task.branch)}`, - `${participantLabel} · ${pendingLabel}`, - { - iconId: task.pending_handoff_count > 0 ? 'warning' : 'comment-discussion', - tooltip: [ - task.branch, - `task #${task.id}`, - participantLabel, - task.pending_handoff_count > 0 - ? formatCountLabel(task.pending_handoff_count, 'pending handoff') - : '', - ].filter(Boolean).join('\n'), - }, - ); - }); - advancedItems.push(new SectionItem('Colony tasks', colonyItems, { - description: String(colonyItems.length), - collapsedState: vscode.TreeItemCollapsibleState.Collapsed, - iconId: 'organization', - })); - } - if (advancedItems.length > 0) { - sectionItems.push(new SectionItem('Advanced details', advancedItems, { - description: String(advancedItems.length), - collapsedState: vscode.TreeItemCollapsibleState.Collapsed, - iconId: 'settings-gear', - })); - } - return sectionItems; - } - - if (element instanceof SectionItem || element instanceof FolderItem || element instanceof WorktreeItem || element instanceof SessionItem) { - return element.items; - } - - const repoEntries = await this.syncRepoEntries(); - this.syncSelectedSession(repoEntries); - - if (repoEntries.length === 0) { - return [new InfoItem('No active Guardex agents', 'Open or start a sandbox session.')]; - } - - return repoEntries.map((entry) => new RepoItem(entry.repoRoot, entry.sessions, entry.changes, { - label: repoEntryDisplayLabel(entry.repoRoot, entry.sessions), - overview: entry.overview, - unassignedChanges: entry.unassignedChanges, - lockEntries: entry.lockEntries, - colonyTasks: entry.colonyTasks, - })); - } - - async loadRepoEntries() { - const repoEntries = await findRepoSessionEntries(); - return Promise.all( - repoEntries.map(async (entry) => { - const repoRoot = entry.repoRoot; - const lockRegistry = this.getLockRegistryForRepo(repoRoot); - const currentBranch = readCurrentBranch(repoRoot); - const colonyTasks = await readColonyTasksForRepo(repoRoot); - return { - repoRoot, - sessions: entry.sessions.map((session) => decorateSession(session, lockRegistry)), - changes: readRepoChanges(repoRoot).map((change) => ( - decorateChange(change, lockRegistry, currentBranch) - )), - lockEntries: Array.from(lockRegistry.entriesByPath.entries()), - colonyTasks, - }; - }), - ); - } -} - -function countEntryConflicts(entry) { - const sessionConflicts = entry.sessions.reduce( - (total, session) => total + (session.conflictCount || 0), - 0, - ); - const changeConflicts = entry.changes.filter((change) => change.hasForeignLock).length; - return sessionConflicts + changeConflicts; -} - -class SessionInspectPanelManager { - constructor() { - this.panel = null; - this.session = null; - } - - open(session) { - const targetSession = session?.branch ? { ...session } : null; - if (!targetSession?.repoRoot || !targetSession?.branch) { - showSessionMessage('Pick an Active Agents session first.'); - return; - } - if (!vscode.window.createWebviewPanel) { - showSessionMessage('Inspect panel is unavailable in this VS Code build.'); - return; - } - - this.session = targetSession; - if (!this.panel) { - this.panel = vscode.window.createWebviewPanel( - INSPECT_PANEL_VIEW_TYPE, - inspectPanelTitle(targetSession), - vscode.ViewColumn?.Beside, - { - enableFindWidget: true, - enableScripts: false, - retainContextWhenHidden: true, - }, - ); - this.panel.onDidDispose(() => { - this.panel = null; - this.session = null; - }); - } else { - this.panel.reveal?.(vscode.ViewColumn?.Beside); - } - - this.render(); - } - - resolveSession() { - if (!this.session?.repoRoot || !this.session?.branch) { - return this.session ? { ...this.session } : null; - } - - return readActiveSessions(this.session.repoRoot, { includeStale: true }) - .find((entry) => sessionSelectionKey(entry) === sessionSelectionKey(this.session)) - || { ...this.session }; - } - - render() { - if (!this.panel || !this.session) { - return; - } - - const session = this.resolveSession(); - if (!session) { - return; - } - - this.session = { ...session }; - this.panel.title = inspectPanelTitle(session); - this.panel.webview.html = renderInspectPanelHtml(session, readSessionInspectData(session)); - } - - refresh() { - this.render(); - } - - dispose() { - this.panel?.dispose(); - this.panel = null; - this.session = null; - } -} - -class ActiveAgentsRefreshController { - constructor(provider, inspectPanelManager = null) { - this.provider = provider; - this.inspectPanelManager = inspectPanelManager; - this.refreshTimer = null; - this.sessionWatchers = new Map(); - this.closedMissingWorktreeRepositories = new Set(); - this.observedWorktreePaths = new Set(); - } - - scheduleRefresh() { - if (this.refreshTimer) { - clearTimeout(this.refreshTimer); - } - this.refreshTimer = setTimeout(() => { - this.refreshTimer = null; - void this.refreshNow(); - }, REFRESH_DEBOUNCE_MS); - } - - async refreshNow() { - await this.syncSessionWatchers(); - await this.provider.refresh(); - this.inspectPanelManager?.refresh(); - } - - async syncSessionWatchers() { - const repoEntries = await findRepoSessionEntries(); - const liveSessionKeys = new Set(); - - for (const workspacePath of findDeletedManagedWorkspaceFolders()) { - await this.closeMissingWorktreeRepository(workspacePath); - } - - for (const entry of repoEntries) { - for (const session of entry.sessions) { - const worktreePath = sessionWorktreePath(session); - const normalizedWorktreePath = normalizeAbsolutePath(worktreePath); - if (normalizedWorktreePath && !fs.existsSync(normalizedWorktreePath)) { - await this.closeMissingWorktreeRepository(normalizedWorktreePath); - continue; - } - if (normalizedWorktreePath) { - this.closedMissingWorktreeRepositories.delete(normalizedWorktreePath); - this.observedWorktreePaths.add(normalizedWorktreePath); - } - - const sessionKey = resolveSessionWatcherKey(session); - liveSessionKeys.add(sessionKey); - if (this.sessionWatchers.has(sessionKey)) { - continue; - } - - const watcher = vscode.workspace.createFileSystemWatcher( - resolveSessionGitIndexPath(session.worktreePath), - ); - const disposables = bindRefreshWatcher(watcher, () => this.scheduleRefresh()); - this.sessionWatchers.set(sessionKey, { - watcher, - disposables, - worktreePath: normalizedWorktreePath, - }); - } - } - - for (const observedWorktreePath of this.observedWorktreePaths) { - if (fs.existsSync(observedWorktreePath)) { - this.closedMissingWorktreeRepositories.delete(observedWorktreePath); - continue; - } - await this.closeMissingWorktreeRepository(observedWorktreePath); - } - - for (const [sessionKey, entry] of this.sessionWatchers) { - if (liveSessionKeys.has(sessionKey)) { - continue; - } - - if (entry.worktreePath && !fs.existsSync(entry.worktreePath)) { - await this.closeMissingWorktreeRepository(entry.worktreePath); - } - disposeAll(entry.disposables); - entry.watcher.dispose(); - this.sessionWatchers.delete(sessionKey); - } - } - - async closeMissingWorktreeRepository(worktreePath) { - const normalizedWorktreePath = normalizeAbsolutePath(worktreePath); - if (!normalizedWorktreePath || this.closedMissingWorktreeRepositories.has(normalizedWorktreePath)) { - return; - } - - this.closedMissingWorktreeRepositories.add(normalizedWorktreePath); - await closeDeletedWorktreeRepository(normalizedWorktreePath); - } - - dispose() { - if (this.refreshTimer) { - clearTimeout(this.refreshTimer); - this.refreshTimer = null; - } - - for (const entry of this.sessionWatchers.values()) { - disposeAll(entry.disposables); - entry.watcher.dispose(); - } - this.sessionWatchers.clear(); - } -} - -function activate(context) { - const decorationProvider = new SessionDecorationProvider(); - const provider = new ActiveAgentsProvider(decorationProvider); - const inspectPanelManager = new SessionInspectPanelManager(); - const refreshController = new ActiveAgentsRefreshController(provider, inspectPanelManager); - const treeView = vscode.window.createTreeView('gitguardex.activeAgents', { - treeDataProvider: provider, - showCollapseAll: true, - }); - const activeAgentsStatusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 10); - activeAgentsStatusItem.name = 'GitGuardex Active Agents'; - activeAgentsStatusItem.command = 'gitguardex.activeAgents.focus'; - provider.attachTreeView(treeView); - const scheduleRefresh = () => refreshController.scheduleRefresh(); - const handleWorkspaceFoldersChanged = () => { - scheduleRefresh(); - void ensureManagedRepoScanIgnores(); - }; - const refresh = () => void refreshController.refreshNow(); - const activeSessionsWatcher = vscode.workspace.createFileSystemWatcher(ACTIVE_SESSION_FILES_GLOB); - const lockWatcher = vscode.workspace.createFileSystemWatcher(AGENT_FILE_LOCKS_GLOB); - const worktreeLockWatcher = vscode.workspace.createFileSystemWatcher(WORKTREE_AGENT_LOCKS_GLOB); - const managedWorktreeGitWatcher = vscode.workspace.createFileSystemWatcher(MANAGED_WORKTREE_GIT_FILES_GLOB); - const logWatcher = vscode.workspace.createFileSystemWatcher(AGENT_LOG_FILES_GLOB); - const updateStatusBar = () => { - const selectedSession = provider.getSelectedSession(); - const summary = provider.getViewSummary(); - if ((summary.sessionCount || 0) <= 0) { - activeAgentsStatusItem.hide(); - return; - } - - activeAgentsStatusItem.text = selectedSession?.branch - ? `$(git-branch) ${sessionIdentityLabel(selectedSession)} · ${formatCountLabel(selectedSession.lockCount || 0, 'lock')}` - : buildActiveAgentsStatusSummary(summary); - activeAgentsStatusItem.tooltip = buildActiveAgentsStatusTooltip(selectedSession, summary); - activeAgentsStatusItem.show(); - }; - updateStatusBar(); - const readCommitMessageForSession = async (session) => { - const rawMessage = await vscode.window.showInputBox?.({ - prompt: `Commit ${sessionIdentityLabel(session)} worktree`, - placeHolder: sessionCommitPlaceholder(session), - ignoreFocusOut: true, - }); - if (rawMessage === undefined) { - return undefined; - } - return String(rawMessage).trim(); - }; - const commitSelectedSession = async () => { - const selectedSession = provider.getSelectedSession(); - if (!selectedSession?.worktreePath) { - vscode.window.showInformationMessage?.('Pick an Active Agents session first.'); - return; - } - - if (!fs.existsSync(selectedSession.worktreePath)) { - vscode.window.showInformationMessage?.( - `Selected session worktree is no longer on disk: ${selectedSession.worktreePath}`, - ); - return; - } - - const message = await readCommitMessageForSession(selectedSession); - if (message === undefined) { - return; - } - if (!message) { - vscode.window.showInformationMessage?.('Enter a commit message first.'); - return; - } - - try { - stageWorktreeForCommit(selectedSession.worktreePath); - commitWorktree(selectedSession.worktreePath, message); - refresh(); - } catch (error) { - const failure = formatGitCommandFailure(error); - if (/nothing to commit|no changes added to commit/i.test(failure)) { - vscode.window.showInformationMessage?.(`No changes to commit in ${selectedSession.label}.`); - return; - } - vscode.window.showErrorMessage?.(`Active Agents commit failed: ${failure}`); - } - }; - const interval = setInterval(refresh, REFRESH_POLL_INTERVAL_MS); - const refreshLockRegistry = (uri) => { - if (uri?.fsPath) { - provider.refreshLockRegistryForFile(uri.fsPath); - } - scheduleRefresh(); - }; - - provider.onDidChangeSelectedSession((session) => { - updateStatusBar(); - decorationProvider.refresh(); - }); - provider.onDidChangeTreeData(() => { - updateStatusBar(); - }); - - context.subscriptions.push( - treeView, - activeAgentsStatusItem, - inspectPanelManager, - refreshController, - vscode.window.registerFileDecorationProvider(decorationProvider), - vscode.commands.registerCommand('gitguardex.activeAgents.startAgent', () => startAgentFromPrompt(refresh)), - vscode.commands.registerCommand('gitguardex.activeAgents.refresh', refresh), - vscode.commands.registerCommand('gitguardex.activeAgents.restart', restartActiveAgents), - vscode.commands.registerCommand('gitguardex.activeAgents.focus', async () => { - await vscode.commands.executeCommand('workbench.view.extension.gitguardex-active-agents-container'); - }), - vscode.commands.registerCommand('gitguardex.activeAgents.commitSelectedSession', commitSelectedSession), - vscode.commands.registerCommand('gitguardex.activeAgents.openWorktree', async (session) => { - if (!session?.worktreePath) { - return; - } - - await vscode.commands.executeCommand( - 'vscode.openFolder', - vscode.Uri.file(session.worktreePath), - { forceNewWindow: true }, - ); - }), - vscode.commands.registerCommand('gitguardex.activeAgents.openChange', async (change) => { - if (!change?.absolutePath) { - return; - } - - if (!fs.existsSync(change.absolutePath)) { - vscode.window.showInformationMessage?.(`Changed path is no longer on disk: ${change.relativePath}`); - return; - } - - await vscode.commands.executeCommand('vscode.open', vscode.Uri.file(change.absolutePath)); - }), - vscode.commands.registerCommand('gitguardex.activeAgents.inspect', (session) => { - inspectPanelManager.open(session || provider.getSelectedSession()); - }), - vscode.commands.registerCommand('gitguardex.activeAgents.showSessionTerminal', showSessionTerminal), - vscode.commands.registerCommand('gitguardex.activeAgents.finishSession', finishSession), - vscode.commands.registerCommand('gitguardex.activeAgents.syncSession', syncSession), - vscode.commands.registerCommand('gitguardex.activeAgents.stopSession', (session) => stopSession(session, refresh)), - vscode.commands.registerCommand('gitguardex.activeAgents.dismissSession', (session) => dismissSession(session, refresh)), - vscode.workspace.onDidChangeWorkspaceFolders(handleWorkspaceFoldersChanged), - activeSessionsWatcher, - lockWatcher, - worktreeLockWatcher, - managedWorktreeGitWatcher, - logWatcher, - { dispose: () => clearInterval(interval) }, - ); - - context.subscriptions.push( - ...bindRefreshWatcher(activeSessionsWatcher, scheduleRefresh), - ...bindRefreshWatcher(lockWatcher, refreshLockRegistry), - ...bindRefreshWatcher(worktreeLockWatcher, scheduleRefresh), - ...bindRefreshWatcher(managedWorktreeGitWatcher, scheduleRefresh), - ...bindRefreshWatcher(logWatcher, scheduleRefresh), - ); - void ensureManagedRepoScanIgnores(); - void refreshController.refreshNow(); - void maybeAutoUpdateActiveAgentsExtension(context); -} - -function deactivate() {} - -module.exports = { - activate, - deactivate, -}; diff --git a/vscode/guardex-active-agents/fileicons/gitguardex-fileicons.json b/vscode/guardex-active-agents/fileicons/gitguardex-fileicons.json deleted file mode 100644 index e8e59681..00000000 --- a/vscode/guardex-active-agents/fileicons/gitguardex-fileicons.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "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 deleted file mode 100644 index f29ca828..00000000 --- a/vscode/guardex-active-agents/fileicons/icons/agent.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/vscode/guardex-active-agents/fileicons/icons/branch.svg b/vscode/guardex-active-agents/fileicons/icons/branch.svg deleted file mode 100644 index 62242793..00000000 --- a/vscode/guardex-active-agents/fileicons/icons/branch.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/vscode/guardex-active-agents/fileicons/icons/config.svg b/vscode/guardex-active-agents/fileicons/icons/config.svg deleted file mode 100644 index 6b4e2f9c..00000000 --- a/vscode/guardex-active-agents/fileicons/icons/config.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/vscode/guardex-active-agents/fileicons/icons/hook.svg b/vscode/guardex-active-agents/fileicons/icons/hook.svg deleted file mode 100644 index 384987c2..00000000 --- a/vscode/guardex-active-agents/fileicons/icons/hook.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/vscode/guardex-active-agents/fileicons/icons/openspec.svg b/vscode/guardex-active-agents/fileicons/icons/openspec.svg deleted file mode 100644 index 8cc93ff9..00000000 --- a/vscode/guardex-active-agents/fileicons/icons/openspec.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/vscode/guardex-active-agents/fileicons/icons/plan.svg b/vscode/guardex-active-agents/fileicons/icons/plan.svg deleted file mode 100644 index 15255686..00000000 --- a/vscode/guardex-active-agents/fileicons/icons/plan.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/vscode/guardex-active-agents/fileicons/icons/spec.svg b/vscode/guardex-active-agents/fileicons/icons/spec.svg deleted file mode 100644 index 7b3da2be..00000000 --- a/vscode/guardex-active-agents/fileicons/icons/spec.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/vscode/guardex-active-agents/icon.png b/vscode/guardex-active-agents/icon.png deleted file mode 100644 index e0b97505..00000000 Binary files a/vscode/guardex-active-agents/icon.png and /dev/null differ diff --git a/vscode/guardex-active-agents/media/active-agents-hivemind.svg b/vscode/guardex-active-agents/media/active-agents-hivemind.svg deleted file mode 100644 index dd24e091..00000000 --- a/vscode/guardex-active-agents/media/active-agents-hivemind.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - diff --git a/vscode/guardex-active-agents/package.json b/vscode/guardex-active-agents/package.json deleted file mode 100644 index c725ca6a..00000000 --- a/vscode/guardex-active-agents/package.json +++ /dev/null @@ -1,169 +0,0 @@ -{ - "name": "gitguardex-active-agents", - "displayName": "GitGuardex Active Agents", - "description": "Shows live Guardex sandbox sessions and repo changes in a dedicated VS Code Active Agents sidebar.", - "publisher": "Recodee", - "version": "0.0.21", - "license": "MIT", - "icon": "icon.png", - "engines": { - "vscode": "^1.88.0" - }, - "categories": [ - "SCM Providers", - "Other" - ], - "activationEvents": [ - "onStartupFinished", - "workspaceContains:.omx/state/active-sessions", - "workspaceContains:.omx/agent-worktrees", - "workspaceContains:.omc/agent-worktrees", - "onView:gitguardex.activeAgents" - ], - "main": "./extension.js", - "contributes": { - "commands": [ - { - "command": "gitguardex.activeAgents.startAgent", - "title": "Start Guardex Agent" - }, - { - "command": "gitguardex.activeAgents.refresh", - "title": "Refresh Active Agents" - }, - { - "command": "gitguardex.activeAgents.restart", - "title": "Restart Active Agents", - "icon": "$(debug-restart)" - }, - { - "command": "gitguardex.activeAgents.commitSelectedSession", - "title": "Commit Selected Session", - "icon": "$(check)" - }, - { - "command": "gitguardex.activeAgents.inspect", - "title": "Inspect Session", - "icon": "$(info)" - }, - { - "command": "gitguardex.activeAgents.openWorktree", - "title": "Open Agent Worktree" - }, - { - "command": "gitguardex.activeAgents.finishSession", - "title": "Finish", - "icon": "$(check)" - }, - { - "command": "gitguardex.activeAgents.syncSession", - "title": "Sync", - "icon": "$(sync)" - }, - { - "command": "gitguardex.activeAgents.stopSession", - "title": "Stop", - "icon": "$(debug-stop)" - }, - { - "command": "gitguardex.activeAgents.dismissSession", - "title": "Dismiss", - "icon": "$(trash)" - }, - { - "command": "gitguardex.activeAgents.showSessionTerminal", - "title": "Show Terminal", - "icon": "$(terminal)" - } - ], - "viewsContainers": { - "activitybar": [ - { - "id": "gitguardex-active-agents-container", - "title": "Active Agents", - "icon": "media/active-agents-hivemind.svg" - } - ] - }, - "views": { - "gitguardex-active-agents-container": [ - { - "id": "gitguardex.activeAgents", - "name": "Active Agents", - "contextualTitle": "Active Agents", - "icon": "media/active-agents-hivemind.svg", - "visibility": "visible" - } - ] - }, - "viewsWelcome": [ - { - "view": "gitguardex.activeAgents", - "contents": "No live Guardex agents are visible in this workspace yet.\n\nThis sidebar tracks Guardex session files and managed worktree telemetry without taking over Source Control.\n\n[Start agent](command:gitguardex.activeAgents.startAgent)\n[Open guide](https://github.com/recodeee/gitguardex/blob/main/vscode/guardex-active-agents/README.md#quick-start)\n[Refresh](command:gitguardex.activeAgents.refresh)" - } - ], - "menus": { - "view/title": [ - { - "command": "gitguardex.activeAgents.commitSelectedSession", - "when": "view == gitguardex.activeAgents && guardex.hasAgents", - "group": "navigation@1" - }, - { - "command": "gitguardex.activeAgents.restart", - "when": "view == gitguardex.activeAgents", - "group": "navigation@8" - }, - { - "command": "gitguardex.activeAgents.refresh", - "when": "view == gitguardex.activeAgents", - "group": "navigation@9" - } - ], - "extension/context": [ - { - "command": "gitguardex.activeAgents.restart", - "when": "extension == Recodee.gitguardex-active-agents && extensionStatus == installed", - "group": "2_configure@2" - } - ], - "view/item/context": [ - { - "command": "gitguardex.activeAgents.openWorktree", - "when": "view == gitguardex.activeAgents && viewItem =~ /^gitguardex\\.session(\\.|$)/", - "group": "inline" - }, - { - "command": "gitguardex.activeAgents.inspect", - "when": "view == gitguardex.activeAgents && viewItem =~ /^gitguardex\\.session(\\.|$)/", - "group": "inline" - }, - { - "command": "gitguardex.activeAgents.showSessionTerminal", - "when": "view == gitguardex.activeAgents && viewItem =~ /^gitguardex\\.session(\\.|$)/", - "group": "inline" - }, - { - "command": "gitguardex.activeAgents.finishSession", - "when": "view == gitguardex.activeAgents && viewItem =~ /^gitguardex\\.session(\\.|$)/", - "group": "inline" - }, - { - "command": "gitguardex.activeAgents.syncSession", - "when": "view == gitguardex.activeAgents && viewItem =~ /^gitguardex\\.session(\\.|$)/", - "group": "inline" - }, - { - "command": "gitguardex.activeAgents.stopSession", - "when": "view == gitguardex.activeAgents && viewItem =~ /^gitguardex\\.session(\\.|$)/", - "group": "inline" - }, - { - "command": "gitguardex.activeAgents.dismissSession", - "when": "view == gitguardex.activeAgents && viewItem =~ /^gitguardex\\.session\\.(stalled|dead)$/", - "group": "inline" - } - ] - } - } -} diff --git a/vscode/guardex-active-agents/session-schema.js b/vscode/guardex-active-agents/session-schema.js deleted file mode 100644 index 5d2b22c0..00000000 --- a/vscode/guardex-active-agents/session-schema.js +++ /dev/null @@ -1,1348 +0,0 @@ -const fs = require('node:fs'); -const path = require('node:path'); -const cp = require('node:child_process'); - -const ACTIVE_SESSIONS_RELATIVE_DIR = path.join('.omx', 'state', 'active-sessions'); -const SESSION_SCHEMA_VERSION = 1; -const LOCK_FILE_RELATIVE = path.join('.omx', 'state', 'agent-file-locks.json'); -const LOGS_RELATIVE_DIR = path.join('.omx', 'logs'); -const AGENT_WORKTREE_LOCK_FILE = 'AGENT.lock'; -const MANAGED_WORKTREE_ROOTS = [ - path.join('.omx', 'agent-worktrees'), - path.join('.omc', 'agent-worktrees'), -]; -const MAX_CHANGED_PATH_PREVIEW = 3; -const ACTIVE_SESSIONS_FILTER_PREFIX = ACTIVE_SESSIONS_RELATIVE_DIR.split(path.sep).join('/'); -const LOCK_FILE_FILTER_PATH = LOCK_FILE_RELATIVE.split(path.sep).join('/'); -const MANAGED_WORKTREE_FILTER_PREFIXES = MANAGED_WORKTREE_ROOTS - .map((relativeRoot) => relativeRoot.split(path.sep).join('/').replace(/\/+$/, '')); -const IDLE_ACTIVITY_WINDOW_MS = 2 * 60 * 1000; -const STALLED_ACTIVITY_WINDOW_MS = 15 * 60 * 1000; -const HEARTBEAT_STALE_MS = 5 * 60 * 1000; -const DEFAULT_BASE_BRANCH = 'dev'; -const DEFAULT_LOG_TAIL_LINE_COUNT = 200; -const ADVISORY_SESSION_STATES = new Set(['working', 'thinking', 'idle']); -const WORKTREE_ACTIVITY_CACHE_TTL_MS = 3_000; -const MAX_WORKTREE_ACTIVITY_STAT_PATHS = 200; -const WORKTREE_ACTIVITY_SKIP_PREFIXES = [ - '.git/', - '.omx/', - '.omc/', - 'node_modules/', - 'dist/', - 'build/', - 'coverage/', - '.next/', - 'out/', - 'vendor/', -]; -const WORKTREE_ACTIVITY_PRIORITY_PREFIXES = [ - 'src/', - 'app/', - 'apps/', - 'lib/', - 'packages/', - 'scripts/', - 'test/', - 'tests/', - 'vscode/', - 'templates/', - 'openspec/', - 'docs/', -]; -const BLOCKING_GIT_STATES = [ - { - label: 'Rebase in progress.', - markers: ['REBASE_HEAD', 'rebase-apply', 'rebase-merge'], - }, - { - label: 'Merge in progress.', - markers: ['MERGE_HEAD'], - }, - { - label: 'Cherry-pick in progress.', - markers: ['CHERRY_PICK_HEAD'], - }, -]; -const worktreeActivityCache = new Map(); - -function toNonEmptyString(value, fallback = '') { - const normalized = typeof value === 'string' ? value.trim() : String(value || '').trim(); - return normalized || fallback; -} - -function toPositiveInteger(value) { - const normalized = Number.parseInt(String(value || ''), 10); - 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 : ''; -} - -function normalizeOpenSpecTier(value) { - const normalized = toNonEmptyString(value).toUpperCase(); - return ['T0', 'T1', 'T2', 'T3'].includes(normalized) ? normalized : ''; -} - -function normalizeAdvisoryState(value, fallback = 'working') { - const normalized = toNonEmptyString(value).toLowerCase(); - return ADVISORY_SESSION_STATES.has(normalized) ? normalized : fallback; -} - -function sanitizeBranchForFile(branch) { - const normalized = toNonEmptyString(branch, 'session'); - return normalized.replace(/[^a-zA-Z0-9._-]+/g, '__').replace(/^_+|_+$/g, '') || 'session'; -} - -function sessionFileNameForBranch(branch) { - return `${sanitizeBranchForFile(branch)}.json`; -} - -function activeSessionsDirForRepo(repoRoot) { - return path.join(path.resolve(repoRoot), ACTIVE_SESSIONS_RELATIVE_DIR); -} - -function sessionFilePathForBranch(repoRoot, branch) { - return path.join(activeSessionsDirForRepo(repoRoot), sessionFileNameForBranch(branch)); -} - -function resolveManagedWorktreeRoots(repoRoot) { - return MANAGED_WORKTREE_ROOTS.map((relativeRoot) => path.join(path.resolve(repoRoot), relativeRoot)); -} - -function splitOutputLines(output) { - if (typeof output !== 'string') { - return null; - } - - return output - .split(/\r?\n/) - .filter((line) => line.trim().length > 0); -} - -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')); - } catch (_error) { - return null; - } -} - -function readConfiguredBaseBranch(repoRoot) { - const lines = runGitLines(path.resolve(repoRoot), ['config', '--get', 'multiagent.baseBranch']); - if (Array.isArray(lines) && typeof lines[0] === 'string' && lines[0].trim()) { - return lines[0].trim(); - } - return DEFAULT_BASE_BRANCH; -} - -function readAheadBehindCounts(worktreePath, branch, baseBranch) { - const normalizedWorktreePath = toNonEmptyString(worktreePath); - const normalizedBranch = toNonEmptyString(branch); - const normalizedBaseBranch = toNonEmptyString(baseBranch, DEFAULT_BASE_BRANCH); - const compareRef = `origin/${normalizedBaseBranch}`; - - if (!normalizedWorktreePath || !normalizedBranch) { - return { - compareRef, - aheadCount: null, - behindCount: null, - }; - } - - const lines = runGitLines(normalizedWorktreePath, [ - 'rev-list', - '--left-right', - '--count', - `${normalizedBranch}...${compareRef}`, - ]); - const match = Array.isArray(lines) && typeof lines[0] === 'string' - ? lines[0].trim().match(/^(\d+)\s+(\d+)$/) - : null; - if (!match) { - return { - compareRef, - aheadCount: null, - behindCount: null, - }; - } - - return { - compareRef, - aheadCount: Number.parseInt(match[1], 10), - behindCount: Number.parseInt(match[2], 10), - }; -} - -function sessionLogPath(repoRoot, branch) { - const normalizedRepoRoot = toNonEmptyString(repoRoot); - const normalizedBranch = toNonEmptyString(branch); - if (!normalizedRepoRoot || !normalizedBranch) { - return ''; - } - - return path.join( - path.resolve(normalizedRepoRoot), - LOGS_RELATIVE_DIR, - `agent-${sanitizeBranchForFile(normalizedBranch)}.log`, - ); -} - -function readLogTail(filePath, maxLines = DEFAULT_LOG_TAIL_LINE_COUNT) { - const normalizedFilePath = toNonEmptyString(filePath); - const normalizedMaxLines = toPositiveInteger(maxLines) || DEFAULT_LOG_TAIL_LINE_COUNT; - if (!normalizedFilePath || !fs.existsSync(normalizedFilePath)) { - return []; - } - - try { - const lines = fs.readFileSync(normalizedFilePath, 'utf8').split(/\r?\n/); - while (lines.length > 0 && lines[lines.length - 1] === '') { - lines.pop(); - } - return lines.slice(-normalizedMaxLines); - } catch (_error) { - return []; - } -} - -function readSessionHeldLocks(repoRoot, branch) { - const normalizedRepoRoot = toNonEmptyString(repoRoot); - const normalizedBranch = toNonEmptyString(branch); - if (!normalizedRepoRoot || !normalizedBranch) { - return []; - } - - const parsed = readJsonFile(path.join(path.resolve(normalizedRepoRoot), LOCK_FILE_RELATIVE)); - const locks = parsed?.locks; - if (!locks || typeof locks !== 'object' || Array.isArray(locks)) { - return []; - } - - return Object.entries(locks) - .map(([rawRelativePath, entry]) => { - if (!entry || typeof entry !== 'object') { - return null; - } - - const relativePath = normalizeRelativePath(rawRelativePath); - const ownerBranch = toNonEmptyString(entry.branch); - if (!relativePath || ownerBranch !== normalizedBranch) { - return null; - } - - return { - relativePath, - claimedAt: toNonEmptyString(entry.claimed_at), - allowDelete: Boolean(entry.allow_delete), - }; - }) - .filter(Boolean) - .sort((left, right) => left.relativePath.localeCompare(right.relativePath)); -} - -function readSessionInspectData(session, options = {}) { - const repoRoot = toNonEmptyString(session?.repoRoot); - const branch = toNonEmptyString(session?.branch); - const worktreePath = toNonEmptyString(session?.worktreePath); - const baseBranch = readConfiguredBaseBranch(repoRoot); - const logPath = sessionLogPath(repoRoot, branch); - const logTailLines = readLogTail(logPath, options.logLines); - - return { - baseBranch, - logPath, - logExists: Boolean(logPath) && fs.existsSync(logPath), - logTailLines, - logTailText: logTailLines.join('\n'), - heldLocks: readSessionHeldLocks(repoRoot, branch), - ...readAheadBehindCounts(worktreePath, branch, baseBranch), - }; -} - -function normalizeIsoString(value, fallback = '') { - const normalized = toNonEmptyString(value); - if (!normalized) { - return fallback; - } - - const timestamp = Date.parse(normalized); - return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : fallback; -} - -function runGitLines(worktreePath, args) { - try { - const output = cp.execFileSync('git', ['-C', worktreePath, ...args], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'], - }); - return splitOutputLines(output); - } catch (_error) { - return null; - } -} - -function unquoteGitPath(value) { - if (typeof value !== 'string') { - return ''; - } - - const trimmed = value.trim(); - if (!trimmed.startsWith('"') || !trimmed.endsWith('"')) { - return trimmed; - } - - try { - return JSON.parse(trimmed); - } catch (_error) { - return trimmed.slice(1, -1); - } -} - -function formatFileCount(count) { - return `${count} file${count === 1 ? '' : 's'}`; -} - -function previewChangedPaths(paths) { - if (!Array.isArray(paths) || paths.length === 0) { - return ''; - } - - if (paths.length <= MAX_CHANGED_PATH_PREVIEW) { - return paths.join(', '); - } - - const preview = paths.slice(0, MAX_CHANGED_PATH_PREVIEW).join(', '); - return `${preview}, +${paths.length - MAX_CHANGED_PATH_PREVIEW} more`; -} - -function deriveRepoChangeStatus(statusPair) { - if (statusPair === '??') { - return { - statusCode: '??', - statusLabel: 'U', - statusText: 'Untracked', - }; - } - - const code = [statusPair[1], statusPair[0]].find((value) => value && value !== ' ') || 'M'; - const statusTextByCode = { - A: 'Added', - C: 'Copied', - D: 'Deleted', - M: 'Modified', - R: 'Renamed', - T: 'Type changed', - U: 'Conflicted', - }; - - return { - statusCode: code, - statusLabel: code, - statusText: statusTextByCode[code] || 'Changed', - }; -} - -function parseRepoChangeLine(repoRoot, line) { - if (typeof line !== 'string' || line.length < 4) { - return null; - } - - const statusPair = line.slice(0, 2); - if (statusPair === '!!') { - return null; - } - - const rawPath = line.slice(3).trim(); - if (!rawPath) { - return null; - } - - let relativePath = rawPath; - let originalPath = ''; - if (rawPath.includes(' -> ')) { - const parts = rawPath.split(' -> '); - if (parts.length === 2) { - originalPath = unquoteGitPath(parts[0]); - relativePath = parts[1]; - } - } - - relativePath = unquoteGitPath(relativePath); - if (!relativePath) { - return null; - } - - const normalizedRelativePath = relativePath.split(path.sep).join('/'); - if ( - normalizedRelativePath === LOCK_FILE_FILTER_PATH - || normalizedRelativePath.startsWith(`${LOCK_FILE_FILTER_PATH}/`) - || normalizedRelativePath === ACTIVE_SESSIONS_FILTER_PREFIX - || normalizedRelativePath.startsWith(`${ACTIVE_SESSIONS_FILTER_PREFIX}/`) - || MANAGED_WORKTREE_FILTER_PREFIXES.some((prefix) => ( - normalizedRelativePath === prefix || normalizedRelativePath.startsWith(`${prefix}/`) - )) - ) { - return null; - } - - const status = deriveRepoChangeStatus(statusPair); - return { - ...status, - originalPath, - relativePath, - absolutePath: path.join(path.resolve(repoRoot), relativePath), - }; -} - -function collectWorktreeChangedPaths(worktreePath) { - const changedGroups = [ - runGitLines(worktreePath, ['diff', '--name-only', '--', '.', `:(exclude)${LOCK_FILE_RELATIVE}`, `:(exclude)${AGENT_WORKTREE_LOCK_FILE}`]), - runGitLines(worktreePath, ['diff', '--cached', '--name-only', '--', '.', `:(exclude)${LOCK_FILE_RELATIVE}`, `:(exclude)${AGENT_WORKTREE_LOCK_FILE}`]), - runGitLines(worktreePath, ['ls-files', '--others', '--exclude-standard']), - ]; - - if (changedGroups.some((group) => group === null)) { - return null; - } - - return [...new Set(changedGroups.flat())] - .filter((relativePath) => ( - relativePath - && relativePath !== LOCK_FILE_RELATIVE - && relativePath !== AGENT_WORKTREE_LOCK_FILE - )) - .sort((left, right) => left.localeCompare(right)); -} - -function resolveWorktreeGitDir(worktreePath) { - const gitPath = path.join(path.resolve(worktreePath), '.git'); - try { - if (fs.statSync(gitPath).isDirectory()) { - return gitPath; - } - } catch (_error) { - return null; - } - - try { - const gitPointer = fs.readFileSync(gitPath, 'utf8'); - const match = gitPointer.match(/^gitdir:\s*(.+)$/m); - if (match?.[1]) { - return path.resolve(worktreePath, match[1].trim()); - } - } catch (_error) { - return null; - } - - return null; -} - -function deriveBlockingGitLabel(worktreePath) { - const gitDir = resolveWorktreeGitDir(worktreePath); - if (!gitDir) { - return ''; - } - - for (const blockingState of BLOCKING_GIT_STATES) { - if (blockingState.markers.some((marker) => fs.existsSync(path.join(gitDir, marker)))) { - return blockingState.label; - } - } - - return ''; -} - -function collectWorktreeTrackedPaths(worktreePath) { - const trackedPaths = runGitLines(worktreePath, ['ls-files', '--cached', '--others', '--exclude-standard']); - if (!trackedPaths) { - return null; - } - - return [...new Set(trackedPaths)] - .filter(Boolean) - .sort((left, right) => left.localeCompare(right)); -} - -function shouldSkipWorktreeActivityPath(relativePath) { - const normalized = normalizeRelativePath(relativePath); - if (!normalized || normalized === LOCK_FILE_RELATIVE || normalized === AGENT_WORKTREE_LOCK_FILE) { - return true; - } - - return WORKTREE_ACTIVITY_SKIP_PREFIXES.some((prefix) => ( - normalized === prefix.slice(0, -1) || normalized.startsWith(prefix) - )); -} - -function worktreeActivityPathPriority(relativePath, recentPathsSet) { - if (recentPathsSet.has(relativePath)) { - return 0; - } - if (!relativePath.includes('/')) { - return 1; - } - if (WORKTREE_ACTIVITY_PRIORITY_PREFIXES.some((prefix) => relativePath.startsWith(prefix))) { - return 2; - } - return 3; -} - -function collectWorktreeActivityCandidatePaths(worktreePath, trackedPaths) { - const recentPaths = runGitLines(worktreePath, ['log', '-1', '--name-only', '--pretty=format:', '--', '.']) || []; - const filteredRecentPaths = [...new Set(recentPaths.map(normalizeRelativePath).filter(Boolean))] - .filter((relativePath) => !shouldSkipWorktreeActivityPath(relativePath)); - const recentPathSet = new Set(filteredRecentPaths); - const prioritizedTrackedPaths = trackedPaths - .map(normalizeRelativePath) - .filter(Boolean) - .filter((relativePath) => !shouldSkipWorktreeActivityPath(relativePath)) - .sort((left, right) => { - const priorityDelta = worktreeActivityPathPriority(left, recentPathSet) - - worktreeActivityPathPriority(right, recentPathSet); - if (priorityDelta !== 0) { - return priorityDelta; - } - return left.localeCompare(right); - }); - - return [...new Set([...filteredRecentPaths, ...prioritizedTrackedPaths])] - .slice(0, MAX_WORKTREE_ACTIVITY_STAT_PATHS); -} - -function clearWorktreeActivityCache(worktreePath = '') { - const normalizedWorktreePath = toNonEmptyString(worktreePath); - if (!normalizedWorktreePath) { - worktreeActivityCache.clear(); - return; - } - worktreeActivityCache.delete(path.resolve(normalizedWorktreePath)); -} - -function deriveLatestWorktreeFileActivity(worktreePath, options = {}) { - const now = Number.isFinite(options.now) ? options.now : Date.now(); - const useCache = options.useCache !== false; - const cacheKey = path.resolve(worktreePath); - if (useCache) { - const cached = worktreeActivityCache.get(cacheKey); - if (cached && (now - cached.checkedAtMs) < WORKTREE_ACTIVITY_CACHE_TTL_MS) { - return cached.latestMtimeMs; - } - } - - const trackedPaths = collectWorktreeTrackedPaths(worktreePath); - if (!trackedPaths) { - return null; - } - - const candidatePaths = collectWorktreeActivityCandidatePaths(worktreePath, trackedPaths); - let latestMtimeMs = null; - for (const relativePath of candidatePaths) { - const absolutePath = path.join(worktreePath, relativePath); - try { - const stats = fs.statSync(absolutePath); - if (!stats.isFile() || !Number.isFinite(stats.mtimeMs)) { - continue; - } - latestMtimeMs = latestMtimeMs === null - ? stats.mtimeMs - : Math.max(latestMtimeMs, stats.mtimeMs); - } catch (_error) { - continue; - } - } - - if (useCache) { - worktreeActivityCache.set(cacheKey, { - checkedAtMs: now, - latestMtimeMs, - }); - } - - return latestMtimeMs; -} - -function deriveSessionActivity(session, options = {}) { - const now = Number.isFinite(options.now) ? options.now : Date.now(); - const pid = toPositiveInteger(session?.pid); - const pidAlive = pid ? isPidAlive(pid) : null; - const heartbeatAt = normalizeIsoString(session?.lastHeartbeatAt); - const heartbeatMs = Date.parse(heartbeatAt); - if (heartbeatAt && Number.isFinite(heartbeatMs) && now - heartbeatMs > HEARTBEAT_STALE_MS) { - return { - activityKind: 'dead', - activityLabel: 'dead', - activityCountLabel: '', - activitySummary: `Heartbeat stale for ${formatElapsedFrom(heartbeatAt, now)}.`, - changeCount: 0, - changedPaths: [], - worktreeChangedPaths: [], - pidAlive, - lastFileActivityAt: '', - lastFileActivityLabel: '', - }; - } - - const blockingLabel = deriveBlockingGitLabel(session.worktreePath); - if (blockingLabel) { - return { - activityKind: 'blocked', - activityLabel: 'blocked', - activityCountLabel: '', - activitySummary: blockingLabel, - changeCount: 0, - changedPaths: [], - worktreeChangedPaths: [], - pidAlive, - lastFileActivityAt: '', - lastFileActivityLabel: '', - }; - } - - if (pid && !pidAlive) { - return { - activityKind: 'dead', - activityLabel: 'dead', - activityCountLabel: '', - activitySummary: 'Recorded PID is not alive.', - changeCount: 0, - changedPaths: [], - worktreeChangedPaths: [], - pidAlive, - lastFileActivityAt: '', - lastFileActivityLabel: '', - }; - } - - const worktreeChangedPaths = collectWorktreeChangedPaths(session.worktreePath); - if (!worktreeChangedPaths) { - return { - activityKind: 'idle', - activityLabel: 'idle', - activityCountLabel: '', - activitySummary: 'Worktree activity unavailable.', - changeCount: 0, - changedPaths: [], - worktreeChangedPaths: [], - pidAlive, - lastFileActivityAt: '', - lastFileActivityLabel: '', - }; - } - - if (worktreeChangedPaths.length > 0) { - const worktreeRelativePaths = [...new Set(worktreeChangedPaths - .map((relativePath) => normalizeRelativePath(relativePath)) - .filter(Boolean))] - .sort((left, right) => left.localeCompare(right)); - clearWorktreeActivityCache(session.worktreePath); - const changedPaths = [...new Set(worktreeChangedPaths - .map((relativePath) => normalizeRelativePath( - path.relative(session.repoRoot, path.resolve(session.worktreePath, relativePath)), - )) - .filter(Boolean))] - .sort((left, right) => left.localeCompare(right)); - - const workingLatestFileActivityMs = deriveLatestWorktreeFileActivity(session.worktreePath, { - now, - useCache: options.useCache, - }); - const workingLastFileActivityAt = Number.isFinite(workingLatestFileActivityMs) - ? new Date(workingLatestFileActivityMs).toISOString() - : ''; - const workingLastFileActivityLabel = workingLastFileActivityAt - ? formatElapsedFrom(workingLastFileActivityAt, now) - : ''; - const workingFileActivityAgeMs = Number.isFinite(workingLatestFileActivityMs) - ? Math.max(0, now - workingLatestFileActivityMs) - : null; - const isFinishedUncommitted = workingFileActivityAgeMs !== null - && workingFileActivityAgeMs > IDLE_ACTIVITY_WINDOW_MS; - - return { - activityKind: isFinishedUncommitted ? 'finished' : 'working', - activityLabel: isFinishedUncommitted ? 'finished' : 'working', - activityCountLabel: formatFileCount(worktreeChangedPaths.length), - activitySummary: isFinishedUncommitted && workingLastFileActivityLabel - ? `${previewChangedPaths(worktreeChangedPaths)} · idle ${workingLastFileActivityLabel}` - : previewChangedPaths(worktreeChangedPaths), - changeCount: worktreeChangedPaths.length, - changedPaths, - worktreeChangedPaths: worktreeRelativePaths, - pidAlive, - lastFileActivityAt: workingLastFileActivityAt, - lastFileActivityLabel: workingLastFileActivityLabel, - }; - } - - const latestFileActivityMs = deriveLatestWorktreeFileActivity(session.worktreePath, { - now, - useCache: options.useCache, - }); - const lastFileActivityAt = Number.isFinite(latestFileActivityMs) - ? new Date(latestFileActivityMs).toISOString() - : ''; - const lastFileActivityLabel = lastFileActivityAt - ? formatElapsedFrom(lastFileActivityAt, now) - : ''; - const lastFileActivityAgeMs = Number.isFinite(latestFileActivityMs) - ? Math.max(0, now - latestFileActivityMs) - : null; - - if (lastFileActivityAgeMs !== null && lastFileActivityAgeMs > STALLED_ACTIVITY_WINDOW_MS) { - return { - activityKind: 'stalled', - activityLabel: 'stalled', - activityCountLabel: '', - activitySummary: `Worktree clean. No file activity for ${lastFileActivityLabel}.`, - changeCount: 0, - changedPaths: [], - worktreeChangedPaths: [], - pidAlive, - lastFileActivityAt, - lastFileActivityLabel, - }; - } - - return { - activityKind: 'idle', - activityLabel: 'idle', - activityCountLabel: '', - activitySummary: lastFileActivityAgeMs !== null && lastFileActivityAgeMs <= IDLE_ACTIVITY_WINDOW_MS - ? `Worktree clean. Recent file activity ${lastFileActivityLabel} ago.` - : lastFileActivityLabel - ? `Worktree clean. Last file activity ${lastFileActivityLabel} ago.` - : 'Worktree clean.', - changeCount: 0, - changedPaths: [], - worktreeChangedPaths: [], - pidAlive, - lastFileActivityAt, - lastFileActivityLabel, - }; -} - -function buildSessionRecord(input) { - const repoRoot = path.resolve(toNonEmptyString(input.repoRoot)); - const worktreePath = path.resolve(toNonEmptyString(input.worktreePath)); - const branch = toNonEmptyString(input.branch); - const pid = toPositiveInteger(input.pid); - const startedAt = input.startedAt ? new Date(input.startedAt) : new Date(); - const lastHeartbeatAt = input.lastHeartbeatAt ? new Date(input.lastHeartbeatAt) : new Date(); - - if (!branch) { - throw new Error('branch is required'); - } - if (!repoRoot) { - throw new Error('repoRoot is required'); - } - if (!worktreePath) { - throw new Error('worktreePath is required'); - } - if (!pid) { - throw new Error('pid must be a positive integer'); - } - if (Number.isNaN(startedAt.getTime())) { - throw new Error('startedAt must be a valid date'); - } - if (Number.isNaN(lastHeartbeatAt.getTime())) { - throw new Error('lastHeartbeatAt must be a valid date'); - } - - return { - schemaVersion: SESSION_SCHEMA_VERSION, - repoRoot, - branch, - taskName: toNonEmptyString(input.taskName, 'task'), - 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, - pid, - cliName: toNonEmptyString(input.cliName, 'codex'), - taskMode: normalizeTaskMode(input.taskMode), - openspecTier: normalizeOpenSpecTier(input.openspecTier), - taskRoutingReason: toNonEmptyString(input.taskRoutingReason), - startedAt: startedAt.toISOString(), - lastHeartbeatAt: lastHeartbeatAt.toISOString(), - state: normalizeAdvisoryState(input.state), - sessionHealth: normalizeSessionHealthPayload(input.sessionHealth || input.sessionSeverity), - }; -} - -function deriveSessionLabel(branch, worktreePath) { - const worktreeLeaf = toNonEmptyString(path.basename(worktreePath || '')); - if (worktreeLeaf) { - return worktreeLeaf; - } - return toNonEmptyString(branch).replace(/[\\/]+/g, '-') || 'unknown-agent'; -} - -function normalizeSessionRecord(input, options = {}) { - if (!input || typeof input !== 'object') { - return null; - } - - const repoRoot = toNonEmptyString(input.repoRoot); - const branch = toNonEmptyString(input.branch); - const worktreePath = toNonEmptyString(input.worktreePath); - const startedAt = new Date(input.startedAt); - const lastHeartbeatAt = new Date(input.lastHeartbeatAt || input.startedAt); - const pid = toPositiveInteger(input.pid); - - if ( - !repoRoot - || !branch - || !worktreePath - || !pid - || Number.isNaN(startedAt.getTime()) - || Number.isNaN(lastHeartbeatAt.getTime()) - ) { - return null; - } - - return { - schemaVersion: toPositiveInteger(input.schemaVersion) || SESSION_SCHEMA_VERSION, - repoRoot: path.resolve(repoRoot), - branch, - taskName: toNonEmptyString(input.taskName, 'task'), - 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), - pid, - cliName: toNonEmptyString(input.cliName, 'codex'), - taskMode: normalizeTaskMode(input.taskMode), - openspecTier: normalizeOpenSpecTier(input.openspecTier), - taskRoutingReason: toNonEmptyString(input.taskRoutingReason), - startedAt: startedAt.toISOString(), - lastHeartbeatAt: lastHeartbeatAt.toISOString(), - state: normalizeAdvisoryState(input.state, 'idle'), - filePath: toNonEmptyString(options.filePath), - label: deriveSessionLabel(branch, worktreePath), - changedPaths: [], - worktreeChangedPaths: [], - sourceKind: 'active-session', - telemetryUpdatedAt: '', - telemetrySource: '', - lockSnapshotCount: 0, - lockSessionCount: 0, - collaboration: false, - sessionHealth: normalizeSessionHealthPayload(input.sessionHealth || input.sessionSeverity), - }; -} - -function formatElapsedFrom(startedAt, now = Date.now()) { - const startedAtMs = startedAt instanceof Date ? startedAt.getTime() : Date.parse(startedAt); - if (!Number.isFinite(startedAtMs)) { - return '0s'; - } - - const totalSeconds = Math.max(0, Math.floor((now - startedAtMs) / 1000)); - const days = Math.floor(totalSeconds / 86_400); - const hours = Math.floor((totalSeconds % 86_400) / 3_600); - const minutes = Math.floor((totalSeconds % 3_600) / 60); - const seconds = totalSeconds % 60; - - if (days > 0) { - return `${days}d ${hours}h`; - } - if (hours > 0) { - return `${hours}h ${minutes}m`; - } - if (minutes > 0) { - return `${minutes}m ${seconds}s`; - } - return `${seconds}s`; -} - -function isPidAlive(pid) { - const normalizedPid = toPositiveInteger(pid); - if (!normalizedPid) { - return false; - } - - try { - process.kill(normalizedPid, 0); - return true; - } catch (_error) { - return false; - } -} - -function readWorktreeBranch(worktreePath) { - const lines = runGitLines(worktreePath, ['rev-parse', '--abbrev-ref', 'HEAD']); - return Array.isArray(lines) && typeof lines[0] === 'string' ? lines[0].trim() : ''; -} - -function deriveAgentNameFromBranch(branch) { - const parts = toNonEmptyString(branch).split('/').filter(Boolean); - if (parts.length >= 2 && parts[0] === 'agent') { - return parts[1]; - } - return 'agent'; -} - -function isManagedAgentBranch(branch) { - return toNonEmptyString(branch).startsWith('agent/'); -} - -function deriveManagedWorktreeStartedAt(worktreePath, now = Date.now()) { - try { - const stats = fs.statSync(worktreePath); - if (Number.isFinite(stats.mtimeMs)) { - return new Date(stats.mtimeMs).toISOString(); - } - } catch (_error) { - // Directory mtime is best-effort context only; fall back to current scan time. - } - - return new Date(now).toISOString(); -} - -function flattenTelemetrySnapshotSessions(lockPayload) { - const flattened = []; - const snapshots = Array.isArray(lockPayload?.snapshots) ? lockPayload.snapshots : []; - for (const snapshot of snapshots) { - const snapshotSessions = Array.isArray(snapshot?.sessions) ? snapshot.sessions : []; - for (const session of snapshotSessions) { - flattened.push({ - taskPreview: toNonEmptyString(session?.taskPreview), - taskUpdatedAt: normalizeIsoString(session?.taskUpdatedAt), - projectName: toNonEmptyString(session?.projectName), - projectPath: toNonEmptyString(session?.projectPath), - snapshotName: toNonEmptyString(snapshot?.snapshotName), - email: toNonEmptyString(snapshot?.email), - sessionHealth: normalizeSessionHealthPayload( - session?.sessionHealth || session?.sessionSeverity || snapshot?.sessionHealth || snapshot?.sessionSeverity, - ), - }); - } - } - return flattened; -} - -function sortSessionsByTimestamp(sessions) { - sessions.sort((left, right) => { - const timeDelta = Date.parse(right.startedAt) - Date.parse(left.startedAt); - if (timeDelta !== 0) { - return timeDelta; - } - return left.label.localeCompare(right.label); - }); - return sessions; -} - -function deriveLockTaskAnchor(entries, fallbackTaskName, fallbackTimestamp) { - const sortedEntries = sortTelemetryEntriesForAnchor(entries); - - const latestEntry = sortedEntries[0] || null; - return { - taskName: latestEntry?.taskPreview || fallbackTaskName || 'task', - latestTaskPreview: latestEntry?.taskPreview || '', - timestamp: latestEntry?.taskUpdatedAt || fallbackTimestamp || '', - sessionHealth: latestEntry?.sessionHealth || null, - }; -} - -function sortTelemetryEntriesForAnchor(entries) { - return [...entries].sort((left, right) => { - const timeDelta = Date.parse(right.taskUpdatedAt || '') - Date.parse(left.taskUpdatedAt || ''); - if (timeDelta !== 0) { - return timeDelta; - } - if (Boolean(right.taskPreview) !== Boolean(left.taskPreview)) { - return Number(Boolean(right.taskPreview)) - Number(Boolean(left.taskPreview)); - } - return (right.projectPath || '').localeCompare(left.projectPath || ''); - }); -} - -function deriveLockSnapshotIdentity(entries) { - const latestEntry = sortTelemetryEntriesForAnchor(entries) - .find((entry) => entry?.snapshotName || entry?.email) || null; - return { - snapshotName: toNonEmptyString(latestEntry?.snapshotName), - snapshotEmail: toNonEmptyString(latestEntry?.email), - }; -} - -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); - const telemetryUpdatedAt = normalizeIsoString(lockPayload?.updatedAt); - const branch = readWorktreeBranch(worktreePath); - const effectiveBranch = branch && branch !== 'HEAD' - ? branch - : `agent/telemetry/${path.basename(worktreePath)}`; - 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 = { - schemaVersion: toPositiveInteger(lockPayload?.schemaVersion) || SESSION_SCHEMA_VERSION, - repoRoot: path.resolve(repoRoot), - branch: effectiveBranch, - 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), - pid: null, - cliName: 'codex', - taskMode: '', - openspecTier: '', - taskRoutingReason: '', - startedAt, - lastHeartbeatAt: '', - state: '', - filePath: path.join(worktreePath, AGENT_WORKTREE_LOCK_FILE), - label, - changedPaths: [], - worktreeChangedPaths: [], - sourceKind: 'worktree-lock', - telemetryUpdatedAt: telemetryUpdatedAt || startedAt, - telemetrySource: toNonEmptyString(lockPayload?.source, 'worktree-lock'), - 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); - Object.assign(session, deriveSessionActivity(session, { now })); - return session; -} - -function buildManagedWorktreeSession(repoRoot, worktreePath, options = {}) { - const now = options.now || Date.now(); - const branch = readWorktreeBranch(worktreePath); - if (!branch || branch === 'HEAD' || !isManagedAgentBranch(branch)) { - return null; - } - - const label = deriveSessionLabel(branch, worktreePath); - const startedAt = deriveManagedWorktreeStartedAt(worktreePath, now); - const session = { - schemaVersion: SESSION_SCHEMA_VERSION, - repoRoot: path.resolve(repoRoot), - branch, - taskName: label, - latestTaskPreview: '', - agentName: deriveAgentNameFromBranch(branch), - projectName: '', - projectPath: '', - snapshotName: '', - snapshotEmail: '', - worktreePath: path.resolve(worktreePath), - pid: null, - cliName: 'gx', - taskMode: '', - openspecTier: '', - taskRoutingReason: '', - startedAt, - lastHeartbeatAt: '', - state: '', - filePath: path.join(worktreePath, '.git'), - label, - changedPaths: [], - worktreeChangedPaths: [], - sourceKind: 'managed-worktree', - telemetryUpdatedAt: '', - telemetrySource: 'managed-worktree', - lockSnapshotCount: 0, - lockSessionCount: 0, - collaboration: false, - sessionHealth: null, - }; - - session.elapsedLabel = formatElapsedFrom(session.startedAt, now); - Object.assign(session, deriveSessionActivity(session, { now })); - return session; -} - -function readWorktreeLockSessions(repoRoot, options = {}) { - const sessions = []; - for (const managedRoot of resolveManagedWorktreeRoots(repoRoot)) { - if (!fs.existsSync(managedRoot)) { - continue; - } - - let entries; - try { - entries = fs.readdirSync(managedRoot, { withFileTypes: true }); - } catch (_error) { - continue; - } - - for (const entry of entries) { - if (!entry.isDirectory()) { - continue; - } - - const worktreePath = path.join(managedRoot, entry.name); - const lockPath = path.join(worktreePath, AGENT_WORKTREE_LOCK_FILE); - if (!fs.existsSync(lockPath)) { - continue; - } - - const lockPayload = readJsonFile(lockPath); - if (!lockPayload || typeof lockPayload !== 'object' || Array.isArray(lockPayload)) { - continue; - } - - const telemetryEntries = flattenTelemetrySnapshotSessions(lockPayload); - if (telemetryEntries.length === 0 && !toPositiveInteger(lockPayload.sessionCount)) { - continue; - } - - sessions.push(buildWorktreeLockSession(repoRoot, worktreePath, lockPayload, options)); - } - } - - return sortSessionsByTimestamp(sessions); -} - -function readManagedWorktreeSessions(repoRoot, options = {}) { - const lockSessions = readWorktreeLockSessions(repoRoot, options); - const lockSessionsByWorktree = new Map( - lockSessions.map((session) => [path.resolve(session.worktreePath), session]), - ); - const sessions = []; - - for (const managedRoot of resolveManagedWorktreeRoots(repoRoot)) { - if (!fs.existsSync(managedRoot)) { - continue; - } - - let entries; - try { - entries = fs.readdirSync(managedRoot, { withFileTypes: true }); - } catch (_error) { - continue; - } - - for (const entry of entries) { - if (!entry.isDirectory()) { - continue; - } - - const worktreePath = path.join(managedRoot, entry.name); - const worktreeKey = path.resolve(worktreePath); - const lockSession = lockSessionsByWorktree.get(worktreeKey); - if (lockSession) { - sessions.push(lockSession); - continue; - } - - const managedSession = buildManagedWorktreeSession(repoRoot, worktreePath, options); - if (managedSession) { - sessions.push(managedSession); - } - } - } - - return sortSessionsByTimestamp(sessions); -} - -function mergeSessionSources(primarySessions, lockSessions) { - const lockSessionsByWorktree = new Map( - lockSessions.map((session) => [path.resolve(session.worktreePath), session]), - ); - const consumedLockWorktrees = new Set(); - const merged = []; - - for (const session of primarySessions) { - const worktreeKey = path.resolve(session.worktreePath); - const lockSession = lockSessionsByWorktree.get(worktreeKey); - if (lockSession && session.activityKind === 'dead') { - continue; - } - 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); - } - - for (const lockSession of lockSessions) { - const worktreeKey = path.resolve(lockSession.worktreePath); - if (!consumedLockWorktrees.has(worktreeKey)) { - merged.push(lockSession); - } - } - - return sortSessionsByTimestamp(merged); -} - -function readActiveSessions(repoRoot, options = {}) { - const activeSessionsDir = activeSessionsDirForRepo(repoRoot); - const now = options.now || Date.now(); - const sessionFileSessions = []; - if (fs.existsSync(activeSessionsDir)) { - for (const entry of fs.readdirSync(activeSessionsDir, { withFileTypes: true })) { - if (!entry.isFile() || !entry.name.endsWith('.json')) { - continue; - } - - const filePath = path.join(activeSessionsDir, entry.name); - const parsed = readJsonFile(filePath); - const normalized = normalizeSessionRecord(parsed, { filePath }); - if (!normalized) { - continue; - } - if (!options.includeStale && !isPidAlive(normalized.pid)) { - continue; - } - - normalized.elapsedLabel = formatElapsedFrom(normalized.startedAt, now); - Object.assign(normalized, deriveSessionActivity(normalized, { now })); - sessionFileSessions.push(normalized); - } - } - - return mergeSessionSources( - sortSessionsByTimestamp(sessionFileSessions), - readManagedWorktreeSessions(repoRoot, { now }), - ); -} - -function readRepoChanges(repoRoot) { - const statusLines = runGitLines(repoRoot, ['status', '--porcelain=v1', '--untracked-files=all']); - if (!statusLines) { - return []; - } - - return statusLines - .map((line) => parseRepoChangeLine(repoRoot, line)) - .filter(Boolean) - .sort((left, right) => left.relativePath.localeCompare(right.relativePath)); -} - -module.exports = { - ACTIVE_SESSIONS_RELATIVE_DIR, - SESSION_SCHEMA_VERSION, - activeSessionsDirForRepo, - buildSessionRecord, - clearWorktreeActivityCache, - collectWorktreeChangedPaths, - collectWorktreeTrackedPaths, - deriveBlockingGitLabel, - deriveLatestWorktreeFileActivity, - deriveSessionLabel, - deriveSessionActivity, - formatElapsedFrom, - formatFileCount, - isPidAlive, - normalizeSessionRecord, - parseRepoChangeLine, - previewChangedPaths, - readActiveSessions, - readManagedWorktreeSessions, - readWorktreeLockSessions, - readRepoChanges, - deriveRepoChangeStatus, - readAheadBehindCounts, - readConfiguredBaseBranch, - readLogTail, - resolveWorktreeGitDir, - readSessionHeldLocks, - readSessionInspectData, - sessionLogPath, - sanitizeBranchForFile, - sessionFileNameForBranch, - sessionFilePathForBranch, -};