diff --git a/openspec/changes/agent-codex-improve-active-agents-extension-runtime-2026-04-22-16-55/.openspec.yaml b/openspec/changes/agent-codex-improve-active-agents-extension-runtime-2026-04-22-16-55/.openspec.yaml new file mode 100644 index 0000000..25345f4 --- /dev/null +++ b/openspec/changes/agent-codex-improve-active-agents-extension-runtime-2026-04-22-16-55/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-22 diff --git a/openspec/changes/agent-codex-improve-active-agents-extension-runtime-2026-04-22-16-55/proposal.md b/openspec/changes/agent-codex-improve-active-agents-extension-runtime-2026-04-22-16-55/proposal.md new file mode 100644 index 0000000..2b49ad6 --- /dev/null +++ b/openspec/changes/agent-codex-improve-active-agents-extension-runtime-2026-04-22-16-55/proposal.md @@ -0,0 +1,20 @@ +## Why + +- The Active Agents companion already has grouped rows, lock awareness, and AGENT.lock fallback, but the runtime contract is still thin in the high-value places: launcher heartbeat freshness, repo-root change filtering, per-session touched-file visibility, and context keys. +- The extension should stay a read-only state viewer. Any lifecycle action must continue shelling to `gx` rather than mutating git/session state directly. +- The duplicated install source trees (`vscode/guardex-active-agents/` and `templates/vscode/guardex-active-agents/`) have drifted, so the shipped extension can lag the template behavior. + +## What Changes + +- Extend the active-session writer schema with `lastHeartbeatAt` and advisory `state`, add a `heartbeat` subcommand, and wire `gx internal heartbeat --branch ` to the helper. +- Keep the wrapper session record fresh while a Codex sandbox is running, and clear the heartbeat loop when the session exits. +- Filter repo-root `CHANGES` so managed agent worktree paths and active-session state files never appear as root dirt. +- Render changed-file rows beneath each session in `ACTIVE AGENTS`, including lock-conflict warnings when touched files are claimed by another branch. +- Set `guardex.hasAgents` and `guardex.hasConflicts` context keys from the provider summary. +- Reconcile the template/source extension copies and docs so local install uses the same behavior as tests. + +## Impact + +- Affected surfaces: `scripts/agent-session-state.js`, `scripts/codex-agent.sh`, `src/hooks/index.js`, `src/context.js`, both VS Code extension source trees, focused extension tests, and this OpenSpec change. +- Risk is bounded to local runtime state/view behavior. The extension remains read-only for state; finish/sync/stop/open actions still route through terminals or VS Code APIs. +- Large worktrees are sensitive to file scanning, so the patch preserves git/status-based bounded scans instead of introducing recursive unbounded watchers. diff --git a/openspec/changes/agent-codex-improve-active-agents-extension-runtime-2026-04-22-16-55/specs/vscode-active-agents-extension/spec.md b/openspec/changes/agent-codex-improve-active-agents-extension-runtime-2026-04-22-16-55/specs/vscode-active-agents-extension/spec.md new file mode 100644 index 0000000..2474fea --- /dev/null +++ b/openspec/changes/agent-codex-improve-active-agents-extension-runtime-2026-04-22-16-55/specs/vscode-active-agents-extension/spec.md @@ -0,0 +1,47 @@ +## ADDED Requirements + +### Requirement: Active session records expose live heartbeat freshness +Guardex SHALL write active-session records with a heartbeat timestamp that the extension can use to distinguish live sessions from crashed or abandoned launcher records. + +#### Scenario: Session start creates heartbeat fields +- **WHEN** the active-session helper starts a session record +- **THEN** the JSON record includes `startedAt` +- **AND** the JSON record includes `lastHeartbeatAt` +- **AND** the JSON record includes an advisory `state` value. + +#### Scenario: Heartbeat refreshes an existing record +- **WHEN** `gx internal heartbeat --branch ` is run in a Guardex repo with an active-session record for that branch +- **THEN** the matching record's `lastHeartbeatAt` advances +- **AND** existing task, branch, pid, and worktree metadata are preserved. + +### Requirement: Active Agents keeps repo-root changes separate from sandbox changes +The extension SHALL show repo-root `CHANGES` only for dirty files that belong to the guarded repo root, not files under managed agent worktrees or session-state internals. + +#### Scenario: Managed worktree files are dirty under the repo root +- **WHEN** `git status --porcelain` reports changes under `.omx/agent-worktrees/` or `.omc/agent-worktrees/` +- **THEN** those paths are omitted from the repo-root `CHANGES` section. + +#### Scenario: Session state files are dirty +- **WHEN** `.omx/state/active-sessions/*.json` or `.omx/state/agent-file-locks.json` is dirty +- **THEN** those state files are omitted from repo-root `CHANGES`. + +### Requirement: Active Agents shows touched files under each live session +The extension SHALL render changed-file rows beneath each session row when a sandbox worktree has touched files. + +#### Scenario: Session worktree has dirty files +- **WHEN** a session derives `working` state from dirty worktree paths +- **THEN** the session row is expandable +- **AND** its children list the touched worktree-relative files. + +#### Scenario: Touched file is locked by another branch +- **WHEN** a session touched file intersects `.omx/state/agent-file-locks.json` with an owner branch different from the session branch +- **THEN** that file row uses a warning icon +- **AND** its tooltip names the lock owner. + +### Requirement: Active Agents publishes context keys for surrounding UI +The extension SHALL publish context keys that describe whether agents and lock conflicts are currently visible. + +#### Scenario: Provider refresh observes sessions and conflicts +- **WHEN** the Active Agents provider refreshes +- **THEN** it sets `guardex.hasAgents` to true if at least one session is present +- **AND** it sets `guardex.hasConflicts` to true if any visible touched file or repo-root change is locked by another branch. diff --git a/openspec/changes/agent-codex-improve-active-agents-extension-runtime-2026-04-22-16-55/tasks.md b/openspec/changes/agent-codex-improve-active-agents-extension-runtime-2026-04-22-16-55/tasks.md new file mode 100644 index 0000000..416e9e5 --- /dev/null +++ b/openspec/changes/agent-codex-improve-active-agents-extension-runtime-2026-04-22-16-55/tasks.md @@ -0,0 +1,39 @@ +## Definition of Done + +This change is complete only when **all** of the following are true: + +- Every checkbox below is checked. +- The agent branch reaches `MERGED` state on `origin` and the PR URL + state are recorded in the completion handoff. +- If any step blocks (test failure, conflict, ambiguous result), append a `BLOCKED:` line under section 4 explaining the blocker and **STOP**. Do not tick remaining cleanup boxes; do not silently skip the cleanup pipeline. + +## Handoff + +- Handoff: change=`agent-codex-improve-active-agents-extension-runtime-2026-04-22-16-55`; branch=`agent/codex/improve-active-agents-extension-runtime-2026-04-22-16-55`; scope=`active-session heartbeat writer, Active Agents tree runtime signals, repo-root changes filtering, lock conflict/context keys, mirrored extension sources/tests/docs`; action=`implement the delta-only runtime gaps, verify focused tests/specs, then finish via PR merge cleanup`. +- Copy prompt: Continue `agent-codex-improve-active-agents-extension-runtime-2026-04-22-16-55` on branch `agent/codex/improve-active-agents-extension-runtime-2026-04-22-16-55`. Work inside the existing sandbox, review `openspec/changes/agent-codex-improve-active-agents-extension-runtime-2026-04-22-16-55/tasks.md`, continue from the current state instead of creating a new sandbox, and when the work is done run `gx branch finish --branch agent/codex/improve-active-agents-extension-runtime-2026-04-22-16-55 --base main --via-pr --wait-for-merge --cleanup`. +- Join handoff: resumed the existing sandbox, validated the current heartbeat/runtime diff against proposal + spec, ran focused verification, and will finish via PR merge cleanup from this same lane. + +## 1. Specification + +- [x] 1.1 Finalize proposal scope and acceptance criteria for `agent-codex-improve-active-agents-extension-runtime-2026-04-22-16-55`. +- [x] 1.2 Define normative requirements in `specs/vscode-active-agents-extension/spec.md`. + +## 2. Implementation + +- [x] 2.1 Add active-session heartbeat schema/writer support and `gx internal heartbeat --branch `. +- [x] 2.2 Keep Codex wrapper session records fresh while the sandbox process runs. +- [x] 2.3 Filter repo-root `CHANGES` to exclude managed worktrees/session state and add per-session touched-file rows. +- [x] 2.4 Surface lock conflicts and update `guardex.hasAgents` / `guardex.hasConflicts` context keys. +- [x] 2.5 Reconcile `vscode/guardex-active-agents/*` with `templates/vscode/guardex-active-agents/*` and update docs. +- [x] 2.6 Add/update focused regression coverage. + +## 3. Verification + +- [x] 3.1 Run targeted project verification commands. +- [x] 3.2 Run `openspec validate agent-codex-improve-active-agents-extension-runtime-2026-04-22-16-55 --type change --strict`. +- [x] 3.3 Run `openspec validate --specs`. + +## 4. Cleanup (mandatory; run before claiming completion) + +- [ ] 4.1 Run the cleanup pipeline: `gx branch finish --branch agent/codex/improve-active-agents-extension-runtime-2026-04-22-16-55 --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 index 2e65554..e6fe2f4 100755 --- a/scripts/agent-session-state.js +++ b/scripts/agent-session-state.js @@ -23,7 +23,9 @@ 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 ]\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' ); } @@ -68,6 +70,7 @@ function writeSessionRecord(options) { taskMode: options['task-mode'], openspecTier: options['openspec-tier'], taskRoutingReason: options['routing-reason'], + state: options.state, }); const targetPath = sessionSchema.sessionFilePathForBranch(repoRoot, branch); @@ -75,6 +78,53 @@ function writeSessionRecord(options) { 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'); @@ -96,6 +146,14 @@ function main() { writeSessionRecord(options); return; } + if (command === 'heartbeat') { + refreshSessionRecord(options); + return; + } + if (command === 'terminate') { + terminateSessionProcess(options); + return; + } if (command === 'stop') { removeSessionRecord(options); return; diff --git a/scripts/codex-agent.sh b/scripts/codex-agent.sh index 3de9eca..c29fb71 100755 --- a/scripts/codex-agent.sh +++ b/scripts/codex-agent.sh @@ -636,6 +636,41 @@ clear_active_session_state() { 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)" @@ -1024,9 +1059,11 @@ 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 @@ -1034,6 +1071,7 @@ cleanup_active_session_state_on_exit() { 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" diff --git a/src/context.js b/src/context.js index ebff7f2..f1c728d 100644 --- a/src/context.js +++ b/src/context.js @@ -202,6 +202,7 @@ 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'), diff --git a/src/hooks/index.js b/src/hooks/index.js index 52ad3a9..691b469 100644 --- a/src/hooks/index.js +++ b/src/hooks/index.js @@ -1,5 +1,39 @@ 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, @@ -55,6 +89,36 @@ 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 index ae65c18..e6fe2f4 100755 --- a/templates/scripts/agent-session-state.js +++ b/templates/scripts/agent-session-state.js @@ -23,7 +23,9 @@ 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 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' ); } @@ -65,6 +67,10 @@ function writeSessionRecord(options) { 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); @@ -72,6 +78,53 @@ function writeSessionRecord(options) { 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'); @@ -93,6 +146,14 @@ function main() { writeSessionRecord(options); return; } + if (command === 'heartbeat') { + refreshSessionRecord(options); + return; + } + if (command === 'terminate') { + terminateSessionProcess(options); + return; + } if (command === 'stop') { removeSessionRecord(options); return; diff --git a/templates/scripts/codex-agent.sh b/templates/scripts/codex-agent.sh index 3de9eca..c29fb71 100755 --- a/templates/scripts/codex-agent.sh +++ b/templates/scripts/codex-agent.sh @@ -636,6 +636,41 @@ clear_active_session_state() { 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)" @@ -1024,9 +1059,11 @@ 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 @@ -1034,6 +1071,7 @@ cleanup_active_session_state_on_exit() { 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" diff --git a/templates/vscode/guardex-active-agents/README.md b/templates/vscode/guardex-active-agents/README.md index e18d22c..0b6f8e6 100644 --- a/templates/vscode/guardex-active-agents/README.md +++ b/templates/vscode/guardex-active-agents/README.md @@ -20,10 +20,11 @@ What it does: - Bundles a local GitGuardex icon so repo installs show branded extension metadata inside VS Code. - Adds an `Active Agents` view to the Source Control container. - Renders one repo node per live Guardex workspace with grouped `ACTIVE AGENTS` and `CHANGES` sections. -- Splits live sessions inside `ACTIVE AGENTS` into `BLOCKED`, `WORKING NOW`, `IDLE`, `STALLED`, and `DEAD` groups so stuck, active, and inactive lanes stand out immediately. +- Splits live sessions inside `ACTIVE AGENTS` into `BLOCKED`, `WORKING NOW`, `THINKING`, `STALLED`, and `DEAD` groups so stuck, active, and inactive lanes stand out immediately. - Mirrors the same live state in the VS Code status bar so the selected session or active-agent count stays visible outside the tree. -- Shows one row per live Guardex sandbox session inside those activity groups. +- Shows one row per live Guardex sandbox session inside those activity groups, with changed-file rows nested under sessions that are touching files. - 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, PID liveness, and recent file mtimes, surfaces working/dead counts in the repo/header summary, and shows changed-file counts for active edits. -- Uses distinct VS Code codicons for each session state: `warning`, `edit`, `loading~spin`, `clock`, and `error`. -- Reads repo-local presence files from `.omx/state/active-sessions/` and falls back to managed worktree-root `AGENT.lock` telemetry when the launcher session file is absent. +- 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 index f5ef86e..d8ab96c 100644 --- a/templates/vscode/guardex-active-agents/extension.js +++ b/templates/vscode/guardex-active-agents/extension.js @@ -27,14 +27,14 @@ const UPDATE_LATER_ACTION = 'Later'; const SESSION_ACTIVITY_GROUPS = [ { kind: 'blocked', label: 'BLOCKED' }, { kind: 'working', label: 'WORKING NOW' }, - { kind: 'idle', label: 'IDLE' }, + { kind: 'idle', label: 'THINKING' }, { kind: 'stalled', label: 'STALLED' }, { kind: 'dead', label: 'DEAD' }, ]; const SESSION_ACTIVITY_ICON_IDS = { blocked: 'warning', - working: 'edit', - idle: 'loading~spin', + working: 'loading~spin', + idle: 'comment-discussion', stalled: 'clock', dead: 'error', }; @@ -265,8 +265,9 @@ class RepoItem extends vscode.TreeItem { if (workingCount > 0) { descriptionParts.push(`${workingCount} working`); } - if (changes.length > 0) { - descriptionParts.push(`${changes.length} changed`); + const changedCount = countChangedPaths(repoRoot, sessions, changes); + if (changedCount > 0) { + descriptionParts.push(`${changedCount} changed`); } this.description = descriptionParts.join(' 路 '); this.tooltip = [ @@ -315,6 +316,7 @@ class SessionItem extends vscode.TreeItem { ? `Changed ${session.activityCountLabel}: ${session.activitySummary}` : session.activitySummary, `Locks ${lockCount}`, + session.conflictCount > 0 ? `Conflicts ${session.conflictCount}` : '', Number.isInteger(session.pid) && session.pid > 0 ? session.pidAlive === false ? `PID ${session.pid} not alive` @@ -441,24 +443,27 @@ async function stopSession(session, refresh) { showSessionMessage('Cannot stop session: missing pid.'); return; } + if (!session?.branch) { + showSessionMessage('Cannot stop session: missing branch name.'); + return; + } const confirmed = await vscode.window.showWarningMessage( `Stop ${sessionDisplayLabel(session)}?`, - { modal: true, detail: `Send SIGTERM to pid ${pid}.` }, + { modal: true, detail: `Ask gx to send SIGTERM to pid ${pid}.` }, 'Stop', ); if (confirmed !== 'Stop') { return; } - try { - process.kill(pid, 'SIGTERM'); - refresh(); - } catch (error) { - showSessionMessage( - `Failed to stop session ${sessionDisplayLabel(session)}: ${error?.message || String(error)}`, - ); - } + runSessionTerminalCommand( + session, + 'Stop', + 'debug-stop', + `gx internal stop-session --branch ${shellQuote(session.branch)}`, + ); + refresh(); } async function openSessionDiff(session) { @@ -684,9 +689,12 @@ async function maybeAutoUpdateActiveAgentsExtension(context) { } function decorateSession(session, lockRegistry) { + const touchedChanges = buildSessionTouchedChanges(session, lockRegistry); return { ...session, lockCount: lockRegistry.countsByBranch.get(session.branch) || 0, + touchedChanges, + conflictCount: touchedChanges.filter((change) => change.hasForeignLock).length, }; } @@ -700,6 +708,28 @@ function decorateChange(change, lockRegistry, owningBranch) { }; } +function buildSessionTouchedChanges(session, lockRegistry) { + const changedPaths = Array.isArray(session.worktreeChangedPaths) + ? session.worktreeChangedPaths + : []; + return [...new Set(changedPaths.map(normalizeRelativePath).filter(Boolean))] + .sort((left, right) => left.localeCompare(right)) + .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)); @@ -876,6 +906,31 @@ function countWorkingSessions(sessions) { return sessions.filter((session) => session.activityKind === 'working').length; } +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 buildGroupedChangeTreeNodes(sessions, changes) { const changesBySession = new Map(); const sessionByChangedPath = new Map(); @@ -1049,7 +1104,10 @@ function buildActiveAgentGroupNodes(sessions) { for (const group of SESSION_ACTIVITY_GROUPS) { const groupSessions = sessions .filter((session) => session.activityKind === group.kind) - .map((session) => new SessionItem(session)); + .map((session) => new SessionItem( + session, + buildChangeTreeNodes(session.touchedChanges || []), + )); if (groupSessions.length > 0) { groups.push(new SectionItem(group.label, groupSessions)); } @@ -1072,6 +1130,7 @@ class ActiveAgentsProvider { sessionCount: 0, workingCount: 0, deadCount: 0, + conflictCount: 0, }; } @@ -1118,7 +1177,7 @@ class ActiveAgentsProvider { this.setSelectedSession(nextSession || null); } - updateViewState(sessionCount, workingCount, deadCount) { + updateViewState(sessionCount, workingCount, deadCount, conflictCount = 0) { if (!this.treeView) { return; } @@ -1128,7 +1187,10 @@ class ActiveAgentsProvider { sessionCount, workingCount, deadCount, + conflictCount, }; + void vscode.commands.executeCommand('setContext', 'guardex.hasAgents', sessionCount > 0); + void vscode.commands.executeCommand('setContext', 'guardex.hasConflicts', conflictCount > 0); const badgeTooltipParts = []; if (activeCount > 0) { badgeTooltipParts.push(`${activeCount} active agent${activeCount === 1 ? '' : 's'}`); @@ -1139,6 +1201,9 @@ class ActiveAgentsProvider { if (workingCount > 0) { badgeTooltipParts.push(`${workingCount} working now`); } + if (conflictCount > 0) { + badgeTooltipParts.push(`${conflictCount} conflict${conflictCount === 1 ? '' : 's'}`); + } this.treeView.badge = sessionCount > 0 ? { @@ -1162,8 +1227,12 @@ class ActiveAgentsProvider { (total, entry) => total + countSessionsByActivityKind(entry.sessions, 'dead'), 0, ); + const conflictCount = repoEntries.reduce( + (total, entry) => total + countEntryConflicts(entry), + 0, + ); - this.updateViewState(sessionCount, workingCount, deadCount); + this.updateViewState(sessionCount, workingCount, deadCount, conflictCount); this.decorationProvider?.updateSessions(repoEntries.flatMap((entry) => entry.sessions)); this.decorationProvider?.updateLockEntries(repoEntries); return repoEntries; @@ -1189,20 +1258,6 @@ class ActiveAgentsProvider { this.readLockRegistryForRepo(repoRootFromLockFile(filePath)); } - 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 = [ @@ -1250,6 +1305,15 @@ class ActiveAgentsProvider { } } +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 ActiveAgentsRefreshController { constructor(provider) { this.provider = provider; diff --git a/templates/vscode/guardex-active-agents/package.json b/templates/vscode/guardex-active-agents/package.json index d01d27d..bda0cb9 100644 --- a/templates/vscode/guardex-active-agents/package.json +++ b/templates/vscode/guardex-active-agents/package.json @@ -80,7 +80,7 @@ "view/title": [ { "command": "gitguardex.activeAgents.commitSelectedSession", - "when": "view == gitguardex.activeAgents", + "when": "view == gitguardex.activeAgents && guardex.hasAgents", "group": "navigation@1" }, { diff --git a/templates/vscode/guardex-active-agents/session-schema.js b/templates/vscode/guardex-active-agents/session-schema.js index 4d8ae5f..5a76561 100644 --- a/templates/vscode/guardex-active-agents/session-schema.js +++ b/templates/vscode/guardex-active-agents/session-schema.js @@ -13,8 +13,12 @@ const MANAGED_WORKTREE_ROOTS = [ 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 ADVISORY_SESSION_STATES = new Set(['working', 'thinking', 'idle']); const BLOCKING_GIT_STATES = [ { label: 'Rebase in progress.', @@ -50,6 +54,11 @@ function normalizeOpenSpecTier(value) { 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'; @@ -212,6 +221,9 @@ function parseRepoChangeLine(repoRoot, line) { || 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; } @@ -323,6 +335,23 @@ 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 { @@ -332,6 +361,7 @@ function deriveSessionActivity(session, options = {}) { activitySummary: blockingLabel, changeCount: 0, changedPaths: [], + worktreeChangedPaths: [], pidAlive, lastFileActivityAt: '', lastFileActivityLabel: '', @@ -346,6 +376,7 @@ function deriveSessionActivity(session, options = {}) { activitySummary: 'Recorded PID is not alive.', changeCount: 0, changedPaths: [], + worktreeChangedPaths: [], pidAlive, lastFileActivityAt: '', lastFileActivityLabel: '', @@ -361,6 +392,7 @@ function deriveSessionActivity(session, options = {}) { activitySummary: 'Worktree activity unavailable.', changeCount: 0, changedPaths: [], + worktreeChangedPaths: [], pidAlive, lastFileActivityAt: '', lastFileActivityLabel: '', @@ -368,6 +400,10 @@ function deriveSessionActivity(session, options = {}) { } if (worktreeChangedPaths.length > 0) { + const worktreeRelativePaths = [...new Set(worktreeChangedPaths + .map((relativePath) => normalizeRelativePath(relativePath)) + .filter(Boolean))] + .sort((left, right) => left.localeCompare(right)); const changedPaths = [...new Set(worktreeChangedPaths .map((relativePath) => normalizeRelativePath( path.relative(session.repoRoot, path.resolve(session.worktreePath, relativePath)), @@ -382,6 +418,7 @@ function deriveSessionActivity(session, options = {}) { activitySummary: previewChangedPaths(worktreeChangedPaths), changeCount: worktreeChangedPaths.length, changedPaths, + worktreeChangedPaths: worktreeRelativePaths, pidAlive, lastFileActivityAt: '', lastFileActivityLabel: '', @@ -407,6 +444,7 @@ function deriveSessionActivity(session, options = {}) { activitySummary: `Worktree clean. No file activity for ${lastFileActivityLabel}.`, changeCount: 0, changedPaths: [], + worktreeChangedPaths: [], pidAlive, lastFileActivityAt, lastFileActivityLabel, @@ -424,6 +462,7 @@ function deriveSessionActivity(session, options = {}) { : 'Worktree clean.', changeCount: 0, changedPaths: [], + worktreeChangedPaths: [], pidAlive, lastFileActivityAt, lastFileActivityLabel, @@ -436,6 +475,7 @@ function buildSessionRecord(input) { 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'); @@ -452,6 +492,9 @@ function buildSessionRecord(input) { 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, @@ -467,6 +510,8 @@ function buildSessionRecord(input) { openspecTier: normalizeOpenSpecTier(input.openspecTier), taskRoutingReason: toNonEmptyString(input.taskRoutingReason), startedAt: startedAt.toISOString(), + lastHeartbeatAt: lastHeartbeatAt.toISOString(), + state: normalizeAdvisoryState(input.state), }; } @@ -487,9 +532,17 @@ function normalizeSessionRecord(input, options = {}) { 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())) { + if ( + !repoRoot + || !branch + || !worktreePath + || !pid + || Number.isNaN(startedAt.getTime()) + || Number.isNaN(lastHeartbeatAt.getTime()) + ) { return null; } @@ -507,9 +560,12 @@ function normalizeSessionRecord(input, options = {}) { 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: '', @@ -646,9 +702,12 @@ function buildWorktreeLockSession(repoRoot, worktreePath, lockPayload, options = 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'), diff --git a/test/vscode-active-agents-session-state.test.js b/test/vscode-active-agents-session-state.test.js index bd75733..a837f08 100644 --- a/test/vscode-active-agents-session-state.test.js +++ b/test/vscode-active-agents-session-state.test.js @@ -6,6 +6,7 @@ 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( @@ -564,6 +565,9 @@ test('agent-session-state writes and removes active session records', () => { 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); @@ -571,6 +575,21 @@ test('agent-session-state writes and removes active session records', () => { 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', @@ -582,6 +601,43 @@ test('agent-session-state writes and removes active session records', () => { 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); @@ -643,6 +699,7 @@ test('session-schema falls back to managed worktree AGENT.lock telemetry when la 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); @@ -650,7 +707,8 @@ test('session-schema falls back to managed worktree AGENT.lock telemetry when la 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, 'idle'); + 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'); }); @@ -715,6 +773,7 @@ test('session-schema derives working activity from dirty sandbox worktrees', () 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'); }); @@ -797,6 +856,26 @@ test('session-schema derives dead activity when the 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); @@ -806,6 +885,29 @@ test('session-schema derives repo change rows from root git status', () => { 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( @@ -897,6 +999,7 @@ test('active-agents extension auto-installs a newer workspace build and offers r 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', ''); @@ -906,7 +1009,7 @@ test('active-agents extension auto-installs a newer workspace build and offers r const { registrations, vscode } = createMockVscode(tempRoot); registrations.infoResponses.push('Reload Window'); const extension = loadExtensionWithMockVscode(vscode); - const context = { + context = { subscriptions: [], extension: { packageJSON: { @@ -936,6 +1039,9 @@ test('active-agents extension auto-installs a newer workspace build and offers r ); } finally { cp.execFile = originalExecFile; + for (const subscription of context?.subscriptions ?? []) { + subscription.dispose?.(); + } } }); @@ -1049,12 +1155,12 @@ test('active-agents extension groups live sessions under a repo node', async () assert.equal(agentsSection.description, '1'); const [idleSection] = await provider.getChildren(agentsSection); - assert.equal(idleSection.label, 'IDLE'); + assert.equal(idleSection.label, 'THINKING'); const [sessionItem] = await provider.getChildren(idleSection); assert.equal(sessionItem.label, 'live-task 馃敀 0'); assert.match(sessionItem.description, /^idle 路 \d+[smhd]/); - assert.equal(sessionItem.iconPath.id, 'loading~spin'); + assert.equal(sessionItem.iconPath.id, 'comment-discussion'); assert.equal(sessionItem.resourceUri.scheme, 'gitguardex-agent'); assert.equal( sessionItem.resourceUri.toString(), @@ -1065,6 +1171,14 @@ test('active-agents extension groups live sessions under a repo node', async () tooltip: '1 active agent', }); 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?.(); @@ -1292,7 +1406,11 @@ test('active-agents extension shows grouped repo changes beside active agents', assert.equal(sessionItem.label, `${path.basename(worktreePath)} 馃敀 0`); assert.match(sessionItem.description, /^working 路 2 files 路 /); assert.match(sessionItem.tooltip, /Changed 2 files: src\/nested\.js, tracked\.txt/); - assert.equal(sessionItem.iconPath.id, 'edit'); + assert.equal(sessionItem.iconPath.id, 'loading~spin'); + const [activeFolderItem, activeTrackedItem] = await provider.getChildren(sessionItem); + assert.equal(activeFolderItem.label, 'src'); + assert.equal(activeTrackedItem.label, 'tracked.txt'); + assert.match(activeTrackedItem.tooltip, /^tracked\.txt\nStatus Touched\n/); assert.deepEqual(registrations.treeViews[0].badge, { value: 1, tooltip: '1 active agent 路 1 working now', @@ -1364,6 +1482,7 @@ test('active-agents extension surfaces live managed worktrees from AGENT.lock fa assert.equal(repoItem.description, '1 active 路 1 working 路 1 changed'); const [agentsSection] = await provider.getChildren(repoItem); + assert.deepEqual((await provider.getChildren(repoItem)).map((item) => item.label), ['ACTIVE AGENTS']); const [workingSection] = await provider.getChildren(agentsSection); const [sessionItem] = await provider.getChildren(workingSection); assert.equal(workingSection.label, 'WORKING NOW'); @@ -1389,6 +1508,7 @@ test('active-agents extension decorates sessions and repo changes from the lock 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); @@ -1421,6 +1541,11 @@ test('active-agents extension decorates sessions and repo changes from the lock 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'); @@ -1435,16 +1560,35 @@ test('active-agents extension decorates sessions and repo changes from the lock const provider = registrations.providers[0].provider; const [repoItem] = await provider.getChildren(); const [agentsSection, changesSection] = await provider.getChildren(repoItem); - const [idleSection] = await provider.getChildren(agentsSection); - const [sessionItem] = await provider.getChildren(idleSection); + assert.equal(repoItem.description, '1 active 路 1 working 路 2 changed'); + const [workingSection] = await provider.getChildren(agentsSection); + const [sessionItem] = await provider.getChildren(workingSection); assert.equal(sessionItem.label, `${path.basename(worktreePath)} 馃敀 1`); assert.match(sessionItem.tooltip, /Locks 1/); + assert.match(sessionItem.tooltip, /Conflicts 1/); + + const [sessionChangeItem] = await provider.getChildren(sessionItem); + assert.equal(sessionChangeItem.label, 'tracked.txt'); + assert.equal(sessionChangeItem.iconPath.id, 'warning'); + assert.match(sessionChangeItem.tooltip, /Locked by agent\/codex\/other-task/); const [repoRootGroup] = await provider.getChildren(changesSection); const [changeItem] = await provider.getChildren(repoRootGroup); 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: '1 active agent 路 1 working now 路 2 conflicts', + }); + 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?.(); @@ -1653,13 +1797,13 @@ test('active-agents extension groups blocked, working, idle, stalled, and dead s const provider = registrations.providers[0].provider; const [repoItem] = await provider.getChildren(); - assert.equal(repoItem.description, '4 active 路 1 dead 路 1 working'); + assert.equal(repoItem.description, '4 active 路 1 dead 路 1 working 路 1 changed'); const [agentsSection] = await provider.getChildren(repoItem); const [blockedSection, workingSection, idleSection, stalledSection, deadSection] = await provider.getChildren(agentsSection); assert.equal(blockedSection.label, 'BLOCKED'); assert.equal(workingSection.label, 'WORKING NOW'); - assert.equal(idleSection.label, 'IDLE'); + assert.equal(idleSection.label, 'THINKING'); assert.equal(stalledSection.label, 'STALLED'); assert.equal(deadSection.label, 'DEAD'); @@ -1671,9 +1815,9 @@ test('active-agents extension groups blocked, working, idle, stalled, and dead s assert.match(blockedItem.description, /^blocked 路 \d+[smhd]/); assert.equal(blockedItem.iconPath.id, 'warning'); assert.match(workingItem.description, /^working 路 1 file 路 /); - assert.equal(workingItem.iconPath.id, 'edit'); + assert.equal(workingItem.iconPath.id, 'loading~spin'); assert.match(idleItem.description, /^idle 路 \d+[smhd]/); - assert.equal(idleItem.iconPath.id, 'loading~spin'); + assert.equal(idleItem.iconPath.id, 'comment-discussion'); assert.match(stalledItem.description, /^stalled 路 \d+[smhd]/); assert.equal(stalledItem.iconPath.id, 'clock'); assert.match(deadItem.description, /^dead 路 \d+[smhd]/); @@ -2090,38 +2234,37 @@ test('active-agents extension launches finish and sync commands in session termi } }); -test('active-agents extension confirms stop and sends SIGTERM to the session pid', async () => { +test('active-agents extension confirms stop and routes termination through gx', 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: [] }; - let killed = null; - const originalKill = process.kill; vscode.window.showWarningMessage = async (...args) => { registrations.warningMessages.push(args); return 'Stop'; }; - process.kill = (pid, signal) => { - killed = { pid, signal }; - }; - try { - extension.activate(context); - const provider = registrations.providers[0].provider; - await flushAsyncWork(); - provider.onDidChangeTreeDataEmitter.fireCount = 0; + 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', - pid: 4242, - }); - await flushAsyncWork(); - } finally { - process.kill = originalKill; - } + await registrations.commands.get('gitguardex.activeAgents.stopSession')({ + label: 'live-task', + branch: 'agent/codex/live-task', + worktreePath, + pid: 4242, + }); + await flushAsyncWork(); - assert.deepEqual(killed, { pid: 4242, signal: 'SIGTERM' }); + assert.equal(registrations.terminals.length, 1); + assert.equal(registrations.terminals[0].options.cwd, worktreePath); + assert.equal(registrations.terminals[0].options.iconPath.id, 'debug-stop'); + assert.deepEqual(registrations.terminals[0].sentTexts, [ + { text: "gx internal stop-session --branch 'agent/codex/live-task'", addNewLine: true }, + ]); assert.ok(registrations.providers[0].provider.onDidChangeTreeDataEmitter.fireCount >= 1); assert.equal(registrations.warningMessages.length, 1); assert.match(registrations.warningMessages[0][0], /Stop live-task\?/); diff --git a/vscode/guardex-active-agents/README.md b/vscode/guardex-active-agents/README.md index df5e83c..0b6f8e6 100644 --- a/vscode/guardex-active-agents/README.md +++ b/vscode/guardex-active-agents/README.md @@ -20,9 +20,11 @@ What it does: - Bundles a local GitGuardex icon so repo installs show branded extension metadata inside VS Code. - Adds an `Active Agents` view to the Source Control container. - Renders one repo node per live Guardex workspace with grouped `ACTIVE AGENTS` and `CHANGES` sections. -- Splits live sessions inside `ACTIVE AGENTS` into `BLOCKED`, `WORKING NOW`, `IDLE`, `STALLED`, and `DEAD` groups so stuck, active, and inactive lanes stand out immediately. -- Shows one row per live Guardex sandbox session inside those activity groups. +- Splits live sessions inside `ACTIVE AGENTS` into `BLOCKED`, `WORKING NOW`, `THINKING`, `STALLED`, and `DEAD` groups so stuck, active, and inactive lanes stand out immediately. +- Mirrors the same live state in the VS Code status bar so the selected session or active-agent count stays visible outside the tree. +- Shows one row per live Guardex sandbox session inside those activity groups, with changed-file rows nested under sessions that are touching files. - 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, PID liveness, and recent file mtimes, surfaces working/dead counts in the repo/header summary, and shows changed-file counts for active edits. -- Uses distinct VS Code codicons for each session state: `warning`, `edit`, `loading~spin`, `clock`, and `error`. -- Reads repo-local presence files from `.omx/state/active-sessions/` and falls back to managed worktree-root `AGENT.lock` telemetry when the launcher session file is absent. +- 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 index be733a2..d8ab96c 100644 --- a/vscode/guardex-active-agents/extension.js +++ b/vscode/guardex-active-agents/extension.js @@ -27,14 +27,14 @@ const UPDATE_LATER_ACTION = 'Later'; const SESSION_ACTIVITY_GROUPS = [ { kind: 'blocked', label: 'BLOCKED' }, { kind: 'working', label: 'WORKING NOW' }, - { kind: 'idle', label: 'IDLE' }, + { kind: 'idle', label: 'THINKING' }, { kind: 'stalled', label: 'STALLED' }, { kind: 'dead', label: 'DEAD' }, ]; const SESSION_ACTIVITY_ICON_IDS = { blocked: 'warning', - working: 'edit', - idle: 'loading~spin', + working: 'loading~spin', + idle: 'comment-discussion', stalled: 'clock', dead: 'error', }; @@ -97,10 +97,83 @@ function sessionIdleDecoration(session, now = Date.now()) { return undefined; } +function formatCountLabel(count, singular, plural = `${singular}s`) { + return `${count} ${count === 1 ? singular : plural}`; +} + +function sessionIdentityLabel(session) { + const agentName = typeof session?.agentName === 'string' ? session.agentName.trim() : ''; + const taskName = typeof session?.taskName === 'string' ? session.taskName.trim() : ''; + 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')} (Ctrl+Enter)`; +} + +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 activeCount = Math.max(0, (summary?.sessionCount || 0) - (summary?.deadCount || 0)); + if (activeCount > 0) { + return `$(git-branch) ${formatCountLabel(activeCount, 'active agent')}`; + } + 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 Source Control.', + ].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'), + summary?.deadCount ? formatCountLabel(summary.deadCount, 'dead session') : '', + 'Click to open Source Control.', + ].filter(Boolean).join('\n'); +} + 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; } @@ -111,13 +184,54 @@ class SessionDecorationProvider { ); } + 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) { - return undefined; + 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', + ), + }; } return sessionIdleDecoration(this.sessionsByUri.get(uri.toString()), this.nowProvider()); @@ -151,8 +265,9 @@ class RepoItem extends vscode.TreeItem { if (workingCount > 0) { descriptionParts.push(`${workingCount} working`); } - if (changes.length > 0) { - descriptionParts.push(`${changes.length} changed`); + const changedCount = countChangedPaths(repoRoot, sessions, changes); + if (changedCount > 0) { + descriptionParts.push(`${changedCount} changed`); } this.description = descriptionParts.join(' 路 '); this.tooltip = [ @@ -201,6 +316,7 @@ class SessionItem extends vscode.TreeItem { ? `Changed ${session.activityCountLabel}: ${session.activitySummary}` : session.activitySummary, `Locks ${lockCount}`, + session.conflictCount > 0 ? `Conflicts ${session.conflictCount}` : '', Number.isInteger(session.pid) && session.pid > 0 ? session.pidAlive === false ? `PID ${session.pid} not alive` @@ -327,24 +443,27 @@ async function stopSession(session, refresh) { showSessionMessage('Cannot stop session: missing pid.'); return; } + if (!session?.branch) { + showSessionMessage('Cannot stop session: missing branch name.'); + return; + } const confirmed = await vscode.window.showWarningMessage( `Stop ${sessionDisplayLabel(session)}?`, - { modal: true, detail: `Send SIGTERM to pid ${pid}.` }, + { modal: true, detail: `Ask gx to send SIGTERM to pid ${pid}.` }, 'Stop', ); if (confirmed !== 'Stop') { return; } - try { - process.kill(pid, 'SIGTERM'); - refresh(); - } catch (error) { - showSessionMessage( - `Failed to stop session ${sessionDisplayLabel(session)}: ${error?.message || String(error)}`, - ); - } + runSessionTerminalCommand( + session, + 'Stop', + 'debug-stop', + `gx internal stop-session --branch ${shellQuote(session.branch)}`, + ); + refresh(); } async function openSessionDiff(session) { @@ -570,9 +689,12 @@ async function maybeAutoUpdateActiveAgentsExtension(context) { } function decorateSession(session, lockRegistry) { + const touchedChanges = buildSessionTouchedChanges(session, lockRegistry); return { ...session, lockCount: lockRegistry.countsByBranch.get(session.branch) || 0, + touchedChanges, + conflictCount: touchedChanges.filter((change) => change.hasForeignLock).length, }; } @@ -586,6 +708,28 @@ function decorateChange(change, lockRegistry, owningBranch) { }; } +function buildSessionTouchedChanges(session, lockRegistry) { + const changedPaths = Array.isArray(session.worktreeChangedPaths) + ? session.worktreeChangedPaths + : []; + return [...new Set(changedPaths.map(normalizeRelativePath).filter(Boolean))] + .sort((left, right) => left.localeCompare(right)) + .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)); @@ -762,6 +906,31 @@ function countWorkingSessions(sessions) { return sessions.filter((session) => session.activityKind === 'working').length; } +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 buildGroupedChangeTreeNodes(sessions, changes) { const changesBySession = new Map(); const sessionByChangedPath = new Map(); @@ -935,7 +1104,10 @@ function buildActiveAgentGroupNodes(sessions) { for (const group of SESSION_ACTIVITY_GROUPS) { const groupSessions = sessions .filter((session) => session.activityKind === group.kind) - .map((session) => new SessionItem(session)); + .map((session) => new SessionItem( + session, + buildChangeTreeNodes(session.touchedChanges || []), + )); if (groupSessions.length > 0) { groups.push(new SectionItem(group.label, groupSessions)); } @@ -954,6 +1126,12 @@ class ActiveAgentsProvider { this.treeView = null; this.lockRegistryByRepoRoot = new Map(); this.selectedSession = null; + this.viewSummary = { + sessionCount: 0, + workingCount: 0, + deadCount: 0, + conflictCount: 0, + }; } getTreeItem(element) { @@ -974,6 +1152,7 @@ class ActiveAgentsProvider { 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); } @@ -983,6 +1162,10 @@ class ActiveAgentsProvider { return this.selectedSession ? { ...this.selectedSession } : null; } + getViewSummary() { + return { ...this.viewSummary }; + } + syncSelectedSession(repoEntries) { if (!this.selectedSession) { return; @@ -994,12 +1177,20 @@ class ActiveAgentsProvider { this.setSelectedSession(nextSession || null); } - updateViewState(sessionCount, workingCount, deadCount) { + updateViewState(sessionCount, workingCount, deadCount, conflictCount = 0) { if (!this.treeView) { return; } const activeCount = Math.max(0, sessionCount - deadCount); + this.viewSummary = { + sessionCount, + workingCount, + deadCount, + conflictCount, + }; + void vscode.commands.executeCommand('setContext', 'guardex.hasAgents', sessionCount > 0); + void vscode.commands.executeCommand('setContext', 'guardex.hasConflicts', conflictCount > 0); const badgeTooltipParts = []; if (activeCount > 0) { badgeTooltipParts.push(`${activeCount} active agent${activeCount === 1 ? '' : 's'}`); @@ -1010,6 +1201,9 @@ class ActiveAgentsProvider { if (workingCount > 0) { badgeTooltipParts.push(`${workingCount} working now`); } + if (conflictCount > 0) { + badgeTooltipParts.push(`${conflictCount} conflict${conflictCount === 1 ? '' : 's'}`); + } this.treeView.badge = sessionCount > 0 ? { @@ -1033,9 +1227,14 @@ class ActiveAgentsProvider { (total, entry) => total + countSessionsByActivityKind(entry.sessions, 'dead'), 0, ); + const conflictCount = repoEntries.reduce( + (total, entry) => total + countEntryConflicts(entry), + 0, + ); - this.updateViewState(sessionCount, workingCount, deadCount); + this.updateViewState(sessionCount, workingCount, deadCount, conflictCount); this.decorationProvider?.updateSessions(repoEntries.flatMap((entry) => entry.sessions)); + this.decorationProvider?.updateLockEntries(repoEntries); return repoEntries; } @@ -1059,20 +1258,6 @@ class ActiveAgentsProvider { this.readLockRegistryForRepo(repoRootFromLockFile(filePath)); } - 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 = [ @@ -1114,11 +1299,21 @@ class ActiveAgentsProvider { changes: readRepoChanges(repoRoot).map((change) => ( decorateChange(change, lockRegistry, currentBranch) )), + lockEntries: Array.from(lockRegistry.entriesByPath.entries()), }; }); } } +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 ActiveAgentsRefreshController { constructor(provider) { this.provider = provider; @@ -1198,6 +1393,9 @@ function activate(context) { 'gitguardex.activeAgents.commitInput', 'Active Agents Commit', ); + 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 refresh = () => void refreshController.refreshNow(); @@ -1207,11 +1405,24 @@ function activate(context) { const updateCommitInput = (session) => { sourceControl.inputBox.enabled = true; sourceControl.inputBox.visible = true; - sourceControl.inputBox.placeholder = session?.label - ? `Commit ${session.label} (Ctrl+Enter)` - : 'Pick an Active Agents session to commit its worktree.'; + sourceControl.inputBox.placeholder = sessionCommitPlaceholder(session); + }; + 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(); }; updateCommitInput(null); + updateStatusBar(); const commitSelectedSession = async () => { const selectedSession = provider.getSelectedSession(); if (!selectedSession?.worktreePath) { @@ -1258,15 +1469,27 @@ function activate(context) { scheduleRefresh(); }; - provider.onDidChangeSelectedSession(updateCommitInput); + provider.onDidChangeSelectedSession((session) => { + updateCommitInput(session); + updateStatusBar(); + decorationProvider.refresh(); + }); + provider.onDidChangeTreeData(() => { + updateCommitInput(provider.getSelectedSession()); + updateStatusBar(); + }); context.subscriptions.push( treeView, sourceControl, + activeAgentsStatusItem, 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.focus', async () => { + await vscode.commands.executeCommand('workbench.view.scm'); + }), vscode.commands.registerCommand('gitguardex.activeAgents.commitSelectedSession', commitSelectedSession), vscode.commands.registerCommand('gitguardex.activeAgents.openWorktree', async (session) => { if (!session?.worktreePath) { diff --git a/vscode/guardex-active-agents/package.json b/vscode/guardex-active-agents/package.json index d01d27d..bda0cb9 100644 --- a/vscode/guardex-active-agents/package.json +++ b/vscode/guardex-active-agents/package.json @@ -80,7 +80,7 @@ "view/title": [ { "command": "gitguardex.activeAgents.commitSelectedSession", - "when": "view == gitguardex.activeAgents", + "when": "view == gitguardex.activeAgents && guardex.hasAgents", "group": "navigation@1" }, { diff --git a/vscode/guardex-active-agents/session-schema.js b/vscode/guardex-active-agents/session-schema.js index 4d8ae5f..5a76561 100644 --- a/vscode/guardex-active-agents/session-schema.js +++ b/vscode/guardex-active-agents/session-schema.js @@ -13,8 +13,12 @@ const MANAGED_WORKTREE_ROOTS = [ 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 ADVISORY_SESSION_STATES = new Set(['working', 'thinking', 'idle']); const BLOCKING_GIT_STATES = [ { label: 'Rebase in progress.', @@ -50,6 +54,11 @@ function normalizeOpenSpecTier(value) { 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'; @@ -212,6 +221,9 @@ function parseRepoChangeLine(repoRoot, line) { || 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; } @@ -323,6 +335,23 @@ 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 { @@ -332,6 +361,7 @@ function deriveSessionActivity(session, options = {}) { activitySummary: blockingLabel, changeCount: 0, changedPaths: [], + worktreeChangedPaths: [], pidAlive, lastFileActivityAt: '', lastFileActivityLabel: '', @@ -346,6 +376,7 @@ function deriveSessionActivity(session, options = {}) { activitySummary: 'Recorded PID is not alive.', changeCount: 0, changedPaths: [], + worktreeChangedPaths: [], pidAlive, lastFileActivityAt: '', lastFileActivityLabel: '', @@ -361,6 +392,7 @@ function deriveSessionActivity(session, options = {}) { activitySummary: 'Worktree activity unavailable.', changeCount: 0, changedPaths: [], + worktreeChangedPaths: [], pidAlive, lastFileActivityAt: '', lastFileActivityLabel: '', @@ -368,6 +400,10 @@ function deriveSessionActivity(session, options = {}) { } if (worktreeChangedPaths.length > 0) { + const worktreeRelativePaths = [...new Set(worktreeChangedPaths + .map((relativePath) => normalizeRelativePath(relativePath)) + .filter(Boolean))] + .sort((left, right) => left.localeCompare(right)); const changedPaths = [...new Set(worktreeChangedPaths .map((relativePath) => normalizeRelativePath( path.relative(session.repoRoot, path.resolve(session.worktreePath, relativePath)), @@ -382,6 +418,7 @@ function deriveSessionActivity(session, options = {}) { activitySummary: previewChangedPaths(worktreeChangedPaths), changeCount: worktreeChangedPaths.length, changedPaths, + worktreeChangedPaths: worktreeRelativePaths, pidAlive, lastFileActivityAt: '', lastFileActivityLabel: '', @@ -407,6 +444,7 @@ function deriveSessionActivity(session, options = {}) { activitySummary: `Worktree clean. No file activity for ${lastFileActivityLabel}.`, changeCount: 0, changedPaths: [], + worktreeChangedPaths: [], pidAlive, lastFileActivityAt, lastFileActivityLabel, @@ -424,6 +462,7 @@ function deriveSessionActivity(session, options = {}) { : 'Worktree clean.', changeCount: 0, changedPaths: [], + worktreeChangedPaths: [], pidAlive, lastFileActivityAt, lastFileActivityLabel, @@ -436,6 +475,7 @@ function buildSessionRecord(input) { 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'); @@ -452,6 +492,9 @@ function buildSessionRecord(input) { 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, @@ -467,6 +510,8 @@ function buildSessionRecord(input) { openspecTier: normalizeOpenSpecTier(input.openspecTier), taskRoutingReason: toNonEmptyString(input.taskRoutingReason), startedAt: startedAt.toISOString(), + lastHeartbeatAt: lastHeartbeatAt.toISOString(), + state: normalizeAdvisoryState(input.state), }; } @@ -487,9 +532,17 @@ function normalizeSessionRecord(input, options = {}) { 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())) { + if ( + !repoRoot + || !branch + || !worktreePath + || !pid + || Number.isNaN(startedAt.getTime()) + || Number.isNaN(lastHeartbeatAt.getTime()) + ) { return null; } @@ -507,9 +560,12 @@ function normalizeSessionRecord(input, options = {}) { 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: '', @@ -646,9 +702,12 @@ function buildWorktreeLockSession(repoRoot, worktreePath, lockPayload, options = 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'),