diff --git a/README.md b/README.md index 94886ea..4151e14 100644 --- a/README.md +++ b/README.md @@ -221,6 +221,14 @@ This is the real Source Control shape Guardex is aiming for: isolated agent bran ![Guarded VS Code Source Control example](https://raw.githubusercontent.com/recodeee/gitguardex/main/docs/images/workflow-source-control-grouped.png) +To install the real companion into local VS Code from a Guardex-wired repo: + +```sh +node scripts/install-vscode-active-agents-extension.js +``` + +It adds an `Active Agents` view to the Source Control container, reads `.omx/state/active-sessions/*.json`, and uses VS Code's native `loading~spin` codicon for the running-state affordance. Reload the VS Code window after install. + --- ## Commands diff --git a/bin/multiagent-safety.js b/bin/multiagent-safety.js index b291bee..7b14a16 100755 --- a/bin/multiagent-safety.js +++ b/bin/multiagent-safety.js @@ -90,8 +90,10 @@ const TEMPLATE_FILES = [ 'scripts/agent-branch-start.sh', 'scripts/agent-branch-finish.sh', 'scripts/agent-branch-merge.sh', + 'scripts/agent-session-state.js', 'scripts/codex-agent.sh', 'scripts/guardex-docker-loader.sh', + 'scripts/install-vscode-active-agents-extension.js', 'scripts/review-bot-watch.sh', 'scripts/agent-worktree-prune.sh', 'scripts/agent-file-locks.py', @@ -108,12 +110,17 @@ const TEMPLATE_FILES = [ 'claude/commands/gitguardex.md', 'github/pull.yml.example', 'github/workflows/cr.yml', + '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', ]; const REQUIRED_WORKFLOW_FILES = [ 'scripts/agent-branch-start.sh', 'scripts/agent-branch-finish.sh', 'scripts/agent-branch-merge.sh', + 'scripts/agent-session-state.js', 'scripts/guardex-docker-loader.sh', 'scripts/agent-worktree-prune.sh', 'scripts/agent-file-locks.py', @@ -153,8 +160,10 @@ const EXECUTABLE_RELATIVE_PATHS = new Set([ 'scripts/agent-branch-start.sh', 'scripts/agent-branch-finish.sh', 'scripts/agent-branch-merge.sh', + 'scripts/agent-session-state.js', 'scripts/codex-agent.sh', 'scripts/guardex-docker-loader.sh', + 'scripts/install-vscode-active-agents-extension.js', 'scripts/review-bot-watch.sh', 'scripts/agent-worktree-prune.sh', 'scripts/agent-file-locks.py', @@ -814,6 +823,9 @@ function toDestinationPath(relativeTemplatePath) { if (relativeTemplatePath.startsWith('github/')) { return `.${relativeTemplatePath}`; } + if (relativeTemplatePath.startsWith('vscode/')) { + return relativeTemplatePath; + } throw new Error(`Unsupported template path: ${relativeTemplatePath}`); } diff --git a/openspec/changes/agent-codex-vscode-active-agents-extension-2026-04-21-17-38/.openspec.yaml b/openspec/changes/agent-codex-vscode-active-agents-extension-2026-04-21-17-38/.openspec.yaml new file mode 100644 index 0000000..4b8c565 --- /dev/null +++ b/openspec/changes/agent-codex-vscode-active-agents-extension-2026-04-21-17-38/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-21 diff --git a/openspec/changes/agent-codex-vscode-active-agents-extension-2026-04-21-17-38/proposal.md b/openspec/changes/agent-codex-vscode-active-agents-extension-2026-04-21-17-38/proposal.md new file mode 100644 index 0000000..ca32a0d --- /dev/null +++ b/openspec/changes/agent-codex-vscode-active-agents-extension-2026-04-21-17-38/proposal.md @@ -0,0 +1,16 @@ +## Why + +- Guardex already documents a polished VS Code Source Control view, but the repo does not ship a real companion that surfaces live agent lanes inside VS Code. +- Users need a concrete way to see running Guardex/Codex sandboxes without reading lock JSON or switching to terminal status output. + +## What Changes + +- Add repo-local active-session state that `scripts/codex-agent.sh` writes while a sandbox session is running and removes on exit. +- Add a lightweight VS Code companion extension that contributes an `Active Agents` view inside the Source Control container and renders one spinning row per live session. +- Add a local install path for the companion extension so users can enable it in their real VS Code without publishing to the Marketplace first. + +## Impact + +- Affected surfaces: `scripts/codex-agent.sh`, new session-state/install helpers, README, tests, and a new `vscode/guardex-active-agents` companion directory. +- Primary risk is stale or misleading session rows if lifecycle cleanup fails, so the companion must ignore dead PIDs and the writer must remove state on wrapper exit. +- The companion should stay native to VS Code constraints by using built-in spinning codicons instead of custom animated SVGs. diff --git a/openspec/changes/agent-codex-vscode-active-agents-extension-2026-04-21-17-38/specs/vscode-active-agents-extension/spec.md b/openspec/changes/agent-codex-vscode-active-agents-extension-2026-04-21-17-38/specs/vscode-active-agents-extension/spec.md new file mode 100644 index 0000000..3762eed --- /dev/null +++ b/openspec/changes/agent-codex-vscode-active-agents-extension-2026-04-21-17-38/specs/vscode-active-agents-extension/spec.md @@ -0,0 +1,36 @@ +## ADDED Requirements + +### Requirement: Guardex writes active session presence for sandboxed Codex runs +The system SHALL write repo-local active session presence records while `scripts/codex-agent.sh` is running an interactive sandbox session. + +#### Scenario: Session start records live metadata +- **WHEN** `scripts/codex-agent.sh` launches Codex in a sandbox worktree +- **THEN** Guardex writes a JSON record under `.omx/state/active-sessions/` +- **AND** the record includes the repo root, sandbox branch, task name, agent name, worktree path, launch PID, CLI name, and start timestamp. + +#### Scenario: Session exit removes presence record +- **WHEN** the wrapper exits after Codex finishes +- **THEN** the corresponding `.omx/state/active-sessions/` record is removed +- **AND** later launches for the same branch can recreate it cleanly. + +### Requirement: VS Code companion shows active Guardex lanes in Source Control +The system SHALL provide a VS Code companion extension that surfaces live Guardex sessions in the Source Control container. + +#### Scenario: Live sessions render with native spinner +- **WHEN** the companion finds live session records for the current workspace +- **THEN** it shows an `Active Agents` view in the Source Control container +- **AND** each live session renders with a native animated VS Code icon equivalent to `loading~spin` +- **AND** the row includes the branch identity plus an elapsed-time description. + +#### Scenario: Dead or stale sessions are ignored +- **WHEN** a session record references a PID that is no longer running or contains invalid JSON +- **THEN** the companion does not render it as an active agent row +- **AND** valid rows continue to render. + +### Requirement: Local install path enables the companion without Marketplace publishing +The system SHALL provide a local install path for the companion extension from the repo checkout. + +#### Scenario: Local install copies the extension to the VS Code extensions directory +- **WHEN** the local install helper is run +- **THEN** it copies the companion extension into the target VS Code extensions directory using the extension package version +- **AND** it replaces older local installs for the same extension identifier so reload picks up the newest sources. diff --git a/openspec/changes/agent-codex-vscode-active-agents-extension-2026-04-21-17-38/tasks.md b/openspec/changes/agent-codex-vscode-active-agents-extension-2026-04-21-17-38/tasks.md new file mode 100644 index 0000000..470b906 --- /dev/null +++ b/openspec/changes/agent-codex-vscode-active-agents-extension-2026-04-21-17-38/tasks.md @@ -0,0 +1,31 @@ +## Definition of Done + +This change is complete only when **all** of the following are true: + +- Every checkbox below is checked. +- The agent branch reaches `MERGED` state on `origin` and the PR URL + state are recorded in the completion handoff. +- If any step blocks (test failure, conflict, ambiguous result), append a `BLOCKED:` line under section 4 explaining the blocker and **STOP**. Do not tick remaining cleanup boxes; do not silently skip the cleanup pipeline. + +## 1. Specification + +- [x] 1.1 Finalize proposal scope and acceptance criteria for `agent-codex-vscode-active-agents-extension-2026-04-21-17-38`. +- [x] 1.2 Define normative requirements in `specs/vscode-active-agents-extension/spec.md`. + +## 2. Implementation + +- [x] 2.1 Add repo-local active-session state writing/cleanup around `scripts/codex-agent.sh`. +- [x] 2.2 Add the VS Code Source Control companion view and local install path. +- [x] 2.3 Add/update focused regression coverage for session-state parsing and install behavior. +- [x] 2.4 Update README guidance for the real VS Code companion flow. + +## 3. Verification + +- [x] 3.1 Run targeted project verification commands for the session-state helper, extension sources, and install path. +- [x] 3.2 Run `openspec validate agent-codex-vscode-active-agents-extension-2026-04-21-17-38 --type change --strict`. +- [x] 3.3 Run `openspec validate --specs`. + +## 4. Cleanup (mandatory; run before claiming completion) + +- [ ] 4.1 Run the cleanup pipeline: `bash scripts/agent-branch-finish.sh --branch agent// --base main --via-pr --wait-for-merge --cleanup`. This handles commit -> push -> PR create -> merge wait -> worktree prune in one invocation. +- [ ] 4.2 Record the PR URL and final merge state (`MERGED`) in the completion handoff. +- [ ] 4.3 Confirm the sandbox worktree is gone (`git worktree list` no longer shows the agent path; `git branch -a` shows no surviving local/remote refs for the branch). diff --git a/scripts/agent-session-state.js b/scripts/agent-session-state.js new file mode 100755 index 0000000..ae65c18 --- /dev/null +++ b/scripts/agent-session-state.js @@ -0,0 +1,110 @@ +#!/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 \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'), + }); + + const targetPath = sessionSchema.sessionFilePathForBranch(repoRoot, branch); + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.writeFileSync(targetPath, `${JSON.stringify(record, null, 2)}\n`, 'utf8'); +} + +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 === '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/codex-agent.sh b/scripts/codex-agent.sh index a8a53c8..8b509dd 100755 --- a/scripts/codex-agent.sh +++ b/scripts/codex-agent.sh @@ -6,6 +6,7 @@ AGENT_NAME="${GUARDEX_AGENT_NAME:-agent}" BASE_BRANCH="${GUARDEX_BASE_BRANCH:-}" BASE_BRANCH_EXPLICIT=0 CODEX_BIN="${GUARDEX_CODEX_BIN:-codex}" +NODE_BIN="${GUARDEX_NODE_BIN:-node}" AUTO_FINISH_RAW="${GUARDEX_CODEX_AUTO_FINISH:-true}" AUTO_REVIEW_ON_CONFLICT_RAW="${GUARDEX_CODEX_AUTO_REVIEW_ON_CONFLICT:-true}" AUTO_CLEANUP_RAW="${GUARDEX_CODEX_AUTO_CLEANUP:-true}" @@ -143,6 +144,7 @@ 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 @@ -446,6 +448,40 @@ 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" +} + +clear_active_session_state() { + local branch="$1" + run_active_session_state stop --repo "$repo_root" --branch "$branch" +} + origin_remote_supports_pr_finish() { local origin_url origin_url="$(git -C "$repo_root" remote get-url origin 2>/dev/null || true)" @@ -833,6 +869,19 @@ if ! ensure_openspec_plan_workspace "$worktree_path" "$worktree_branch"; then exit 1 fi +active_session_recorded=0 +cleanup_active_session_state_on_exit() { + set +e + if [[ "${active_session_recorded:-0}" -eq 1 && -n "${worktree_branch:-}" && "${worktree_branch:-}" != "HEAD" ]]; then + clear_active_session_state "$worktree_branch" + active_session_recorded=0 + fi +} + +record_active_session_state "$worktree_path" "$worktree_branch" +active_session_recorded=1 +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 @@ -841,6 +890,8 @@ 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/scripts/install-vscode-active-agents-extension.js b/scripts/install-vscode-active-agents-extension.js new file mode 100755 index 0000000..9a7647f --- /dev/null +++ b/scripts/install-vscode-active-agents-extension.js @@ -0,0 +1,92 @@ +#!/usr/bin/env node + +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +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 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 targetDir = path.join(extensionsDir, `${extensionId}-${manifest.version}`); + + for (const entry of fs.readdirSync(extensionsDir, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + if (entry.name === path.basename(targetDir)) { + continue; + } + if (entry.name.startsWith(`${extensionId}-`)) { + removeIfExists(path.join(extensionsDir, entry.name)); + } + } + + removeIfExists(targetDir); + fs.cpSync(sourceDir, targetDir, { + recursive: true, + force: true, + preserveTimestamps: true, + }); + + process.stdout.write( + `[guardex-active-agents] Installed ${extensionId}@${manifest.version} to ${targetDir}\n` + + '[guardex-active-agents] Reload the VS Code window to activate the Source Control companion.\n', + ); +} + +try { + main(); +} catch (error) { + process.stderr.write(`[guardex-active-agents] ${error.message}\n`); + process.exitCode = 1; +} diff --git a/templates/scripts/agent-session-state.js b/templates/scripts/agent-session-state.js new file mode 100755 index 0000000..ae65c18 --- /dev/null +++ b/templates/scripts/agent-session-state.js @@ -0,0 +1,110 @@ +#!/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 \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'), + }); + + const targetPath = sessionSchema.sessionFilePathForBranch(repoRoot, branch); + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.writeFileSync(targetPath, `${JSON.stringify(record, null, 2)}\n`, 'utf8'); +} + +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 === '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 a8a53c8..8b509dd 100755 --- a/templates/scripts/codex-agent.sh +++ b/templates/scripts/codex-agent.sh @@ -6,6 +6,7 @@ AGENT_NAME="${GUARDEX_AGENT_NAME:-agent}" BASE_BRANCH="${GUARDEX_BASE_BRANCH:-}" BASE_BRANCH_EXPLICIT=0 CODEX_BIN="${GUARDEX_CODEX_BIN:-codex}" +NODE_BIN="${GUARDEX_NODE_BIN:-node}" AUTO_FINISH_RAW="${GUARDEX_CODEX_AUTO_FINISH:-true}" AUTO_REVIEW_ON_CONFLICT_RAW="${GUARDEX_CODEX_AUTO_REVIEW_ON_CONFLICT:-true}" AUTO_CLEANUP_RAW="${GUARDEX_CODEX_AUTO_CLEANUP:-true}" @@ -143,6 +144,7 @@ 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 @@ -446,6 +448,40 @@ 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" +} + +clear_active_session_state() { + local branch="$1" + run_active_session_state stop --repo "$repo_root" --branch "$branch" +} + origin_remote_supports_pr_finish() { local origin_url origin_url="$(git -C "$repo_root" remote get-url origin 2>/dev/null || true)" @@ -833,6 +869,19 @@ if ! ensure_openspec_plan_workspace "$worktree_path" "$worktree_branch"; then exit 1 fi +active_session_recorded=0 +cleanup_active_session_state_on_exit() { + set +e + if [[ "${active_session_recorded:-0}" -eq 1 && -n "${worktree_branch:-}" && "${worktree_branch:-}" != "HEAD" ]]; then + clear_active_session_state "$worktree_branch" + active_session_recorded=0 + fi +} + +record_active_session_state "$worktree_path" "$worktree_branch" +active_session_recorded=1 +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 @@ -841,6 +890,8 @@ 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 new file mode 100755 index 0000000..9a7647f --- /dev/null +++ b/templates/scripts/install-vscode-active-agents-extension.js @@ -0,0 +1,92 @@ +#!/usr/bin/env node + +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +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 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 targetDir = path.join(extensionsDir, `${extensionId}-${manifest.version}`); + + for (const entry of fs.readdirSync(extensionsDir, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + if (entry.name === path.basename(targetDir)) { + continue; + } + if (entry.name.startsWith(`${extensionId}-`)) { + removeIfExists(path.join(extensionsDir, entry.name)); + } + } + + removeIfExists(targetDir); + fs.cpSync(sourceDir, targetDir, { + recursive: true, + force: true, + preserveTimestamps: true, + }); + + process.stdout.write( + `[guardex-active-agents] Installed ${extensionId}@${manifest.version} to ${targetDir}\n` + + '[guardex-active-agents] Reload the VS Code window to activate the 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 new file mode 100644 index 0000000..ea3ffb4 --- /dev/null +++ b/templates/vscode/guardex-active-agents/README.md @@ -0,0 +1,18 @@ +# GitGuardex Active Agents + +Local VS Code companion for Guardex-managed repos. + +What it does: + +- Adds an `Active Agents` view to the Source Control container. +- Renders one row per live Guardex sandbox session. +- Uses VS Code's native animated `loading~spin` icon for the running-state affordance. +- Reads repo-local presence files from `.omx/state/active-sessions/`. + +Install from a Guardex-wired repo: + +```sh +node scripts/install-vscode-active-agents-extension.js +``` + +Then reload the VS Code window. diff --git a/templates/vscode/guardex-active-agents/extension.js b/templates/vscode/guardex-active-agents/extension.js new file mode 100644 index 0000000..7473f73 --- /dev/null +++ b/templates/vscode/guardex-active-agents/extension.js @@ -0,0 +1,147 @@ +const path = require('node:path'); +const vscode = require('vscode'); +const { formatElapsedFrom, readActiveSessions } = require('./session-schema.js'); + +class InfoItem extends vscode.TreeItem { + constructor(label, description = '') { + super(label, vscode.TreeItemCollapsibleState.None); + this.description = description; + this.iconPath = new vscode.ThemeIcon('info'); + } +} + +class RepoItem extends vscode.TreeItem { + constructor(repoRoot, sessions) { + super(path.basename(repoRoot), vscode.TreeItemCollapsibleState.Expanded); + this.repoRoot = repoRoot; + this.sessions = sessions; + this.description = `${sessions.length} active`; + this.tooltip = repoRoot; + this.iconPath = new vscode.ThemeIcon('repo'); + this.contextValue = 'gitguardex.repo'; + } +} + +class SessionItem extends vscode.TreeItem { + constructor(session) { + super(session.label, vscode.TreeItemCollapsibleState.None); + this.session = session; + this.description = `thinking · ${formatElapsedFrom(session.startedAt)}`; + this.tooltip = [ + session.branch, + `${session.agentName} · ${session.taskName}`, + `Started ${session.startedAt}`, + session.worktreePath, + ].join('\n'); + this.iconPath = new vscode.ThemeIcon('loading~spin'); + this.contextValue = 'gitguardex.session'; + this.command = { + command: 'gitguardex.activeAgents.openWorktree', + title: 'Open Agent Worktree', + arguments: [session], + }; + } +} + +function repoRootFromSessionFile(filePath) { + return path.resolve(path.dirname(filePath), '..', '..', '..'); +} + +class ActiveAgentsProvider { + constructor() { + this.onDidChangeTreeDataEmitter = new vscode.EventEmitter(); + this.onDidChangeTreeData = this.onDidChangeTreeDataEmitter.event; + } + + refresh() { + this.onDidChangeTreeDataEmitter.fire(); + } + + async getChildren(element) { + if (element instanceof RepoItem) { + return element.sessions.map((session) => new SessionItem(session)); + } + + const sessionsByRepo = await this.loadSessionsByRepo(); + const repos = [...sessionsByRepo.entries()] + .map(([repoRoot, sessions]) => ({ repoRoot, sessions })) + .filter((entry) => entry.sessions.length > 0) + .sort((left, right) => left.repoRoot.localeCompare(right.repoRoot)); + + if (repos.length === 0) { + return [new InfoItem('No active Guardex agents', 'Start a sandbox session to populate this view.')]; + } + + if (repos.length === 1) { + return repos[0].sessions.map((session) => new SessionItem(session)); + } + + return repos.map((entry) => new RepoItem(entry.repoRoot, entry.sessions)); + } + + async loadSessionsByRepo() { + const sessionFiles = await vscode.workspace.findFiles( + '**/.omx/state/active-sessions/*.json', + '**/{node_modules,.git,.omx/agent-worktrees,.omc/agent-worktrees}/**', + 200, + ); + + const repoRoots = new Set(); + for (const uri of sessionFiles) { + repoRoots.add(repoRootFromSessionFile(uri.fsPath)); + } + + if (repoRoots.size === 0) { + for (const workspaceFolder of vscode.workspace.workspaceFolders || []) { + repoRoots.add(workspaceFolder.uri.fsPath); + } + } + + const sessionsByRepo = new Map(); + for (const repoRoot of repoRoots) { + const sessions = readActiveSessions(repoRoot); + if (sessions.length > 0) { + sessionsByRepo.set(repoRoot, sessions); + } + } + + return sessionsByRepo; + } +} + +function activate(context) { + const provider = new ActiveAgentsProvider(); + const refresh = () => provider.refresh(); + const watcher = vscode.workspace.createFileSystemWatcher('**/.omx/state/active-sessions/*.json'); + const interval = setInterval(refresh, 5_000); + + context.subscriptions.push( + vscode.window.registerTreeDataProvider('gitguardex.activeAgents', provider), + vscode.commands.registerCommand('gitguardex.activeAgents.refresh', refresh), + 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.workspace.onDidChangeWorkspaceFolders(refresh), + watcher, + { dispose: () => clearInterval(interval) }, + ); + + watcher.onDidCreate(refresh, undefined, context.subscriptions); + watcher.onDidChange(refresh, undefined, context.subscriptions); + watcher.onDidDelete(refresh, undefined, context.subscriptions); +} + +function deactivate() {} + +module.exports = { + activate, + deactivate, +}; diff --git a/templates/vscode/guardex-active-agents/package.json b/templates/vscode/guardex-active-agents/package.json new file mode 100644 index 0000000..354efa2 --- /dev/null +++ b/templates/vscode/guardex-active-agents/package.json @@ -0,0 +1,56 @@ +{ + "name": "gitguardex-active-agents", + "displayName": "GitGuardex Active Agents", + "description": "Shows live Guardex sandbox sessions inside VS Code Source Control.", + "publisher": "recodeee", + "version": "0.0.1", + "license": "MIT", + "engines": { + "vscode": "^1.88.0" + }, + "categories": [ + "Source Control", + "Other" + ], + "activationEvents": [ + "workspaceContains:.omx/state/active-sessions", + "onView:gitguardex.activeAgents" + ], + "main": "./extension.js", + "contributes": { + "commands": [ + { + "command": "gitguardex.activeAgents.refresh", + "title": "Refresh Active Agents" + }, + { + "command": "gitguardex.activeAgents.openWorktree", + "title": "Open Agent Worktree" + } + ], + "views": { + "scm": [ + { + "id": "gitguardex.activeAgents", + "name": "Active Agents" + } + ] + }, + "menus": { + "view/title": [ + { + "command": "gitguardex.activeAgents.refresh", + "when": "view == gitguardex.activeAgents", + "group": "navigation" + } + ], + "view/item/context": [ + { + "command": "gitguardex.activeAgents.openWorktree", + "when": "view == gitguardex.activeAgents && viewItem == gitguardex.session", + "group": "inline" + } + ] + } + } +} diff --git a/templates/vscode/guardex-active-agents/session-schema.js b/templates/vscode/guardex-active-agents/session-schema.js new file mode 100644 index 0000000..eed9851 --- /dev/null +++ b/templates/vscode/guardex-active-agents/session-schema.js @@ -0,0 +1,203 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +const ACTIVE_SESSIONS_RELATIVE_DIR = path.join('.omx', 'state', 'active-sessions'); +const SESSION_SCHEMA_VERSION = 1; + +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 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 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(); + + 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'); + } + + return { + schemaVersion: SESSION_SCHEMA_VERSION, + repoRoot, + branch, + taskName: toNonEmptyString(input.taskName, 'task'), + agentName: toNonEmptyString(input.agentName, 'agent'), + worktreePath, + pid, + cliName: toNonEmptyString(input.cliName, 'codex'), + startedAt: startedAt.toISOString(), + }; +} + +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 pid = toPositiveInteger(input.pid); + + if (!repoRoot || !branch || !worktreePath || !pid || Number.isNaN(startedAt.getTime())) { + return null; + } + + return { + schemaVersion: toPositiveInteger(input.schemaVersion) || SESSION_SCHEMA_VERSION, + repoRoot: path.resolve(repoRoot), + branch, + taskName: toNonEmptyString(input.taskName, 'task'), + agentName: toNonEmptyString(input.agentName, 'agent'), + worktreePath: path.resolve(worktreePath), + pid, + cliName: toNonEmptyString(input.cliName, 'codex'), + startedAt: startedAt.toISOString(), + filePath: toNonEmptyString(options.filePath), + label: deriveSessionLabel(branch, worktreePath), + }; +} + +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 readActiveSessions(repoRoot, options = {}) { + const activeSessionsDir = activeSessionsDirForRepo(repoRoot); + if (!fs.existsSync(activeSessionsDir)) { + return []; + } + + const now = options.now || Date.now(); + const sessions = []; + for (const entry of fs.readdirSync(activeSessionsDir, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith('.json')) { + continue; + } + + const filePath = path.join(activeSessionsDir, entry.name); + let parsed; + try { + parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch (_error) { + continue; + } + + const normalized = normalizeSessionRecord(parsed, { filePath }); + if (!normalized) { + continue; + } + if (!options.includeStale && !isPidAlive(normalized.pid)) { + continue; + } + + normalized.elapsedLabel = formatElapsedFrom(normalized.startedAt, now); + sessions.push(normalized); + } + + 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; +} + +module.exports = { + ACTIVE_SESSIONS_RELATIVE_DIR, + SESSION_SCHEMA_VERSION, + activeSessionsDirForRepo, + buildSessionRecord, + deriveSessionLabel, + formatElapsedFrom, + isPidAlive, + normalizeSessionRecord, + readActiveSessions, + sanitizeBranchForFile, + sessionFileNameForBranch, + sessionFilePathForBranch, +}; diff --git a/test/vscode-active-agents-session-state.test.js b/test/vscode-active-agents-session-state.test.js new file mode 100644 index 0000000..9922076 --- /dev/null +++ b/test/vscode-active-agents-session-state.test.js @@ -0,0 +1,134 @@ +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 sessionScript = path.join(repoRoot, 'scripts', 'agent-session-state.js'); +const installScript = path.join(repoRoot, 'scripts', 'install-vscode-active-agents-extension.js'); +const sessionSchema = require(path.join( + repoRoot, + 'templates', + 'vscode', + 'guardex-active-agents', + 'session-schema.js', +)); + +function runNode(scriptPath, args, options = {}) { + return cp.spawnSync('node', [scriptPath, ...args], { + encoding: 'utf8', + ...options, + }); +} + +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', + ]); + 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); + + const sessions = sessionSchema.readActiveSessions(tempRoot); + assert.equal(sessions.length, 1); + assert.equal(sessions[0].label, 'agent__codex__demo-task'); + + const stop = runNode(sessionScript, [ + 'stop', + '--repo', + tempRoot, + '--branch', + branch, + ]); + assert.equal(stop.status, 0, stop.stderr); + assert.equal(fs.existsSync(sessionPath), false); +}); + +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); +}); + +test('install-vscode-active-agents-extension installs the current extension version and prunes older copies', () => { + const tempExtensionsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-ext-')); + const staleDir = path.join(tempExtensionsDir, 'recodeee.gitguardex-active-agents-0.0.0'); + fs.mkdirSync(staleDir, { recursive: true }); + fs.writeFileSync(path.join(staleDir, 'stale.txt'), 'old', 'utf8'); + + const result = runNode(installScript, ['--extensions-dir', tempExtensionsDir], { + cwd: repoRoot, + }); + assert.equal(result.status, 0, result.stderr); + + const installedDir = path.join(tempExtensionsDir, 'recodeee.gitguardex-active-agents-0.0.1'); + assert.equal(fs.existsSync(installedDir), true); + assert.equal(fs.existsSync(path.join(installedDir, 'extension.js')), true); + assert.equal(fs.existsSync(path.join(installedDir, 'session-schema.js')), true); + assert.equal(fs.existsSync(staleDir), false); + assert.match(result.stdout, /Reload the VS Code window/); +});