Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-22
Original file line number Diff line number Diff line change
@@ -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 <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.
Original file line number Diff line number Diff line change
@@ -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 <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.
Original file line number Diff line number Diff line change
@@ -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 <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).
60 changes: 59 additions & 1 deletion scripts/agent-session-state.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ const sessionSchema = resolveSessionSchemaModule();
function usage() {
return (
'Usage:\n' +
' node scripts/agent-session-state.js start --repo <path> --branch <name> --task <task> --agent <agent> --worktree <path> --pid <pid> --cli <name> [--task-mode <caveman|omx>] [--openspec-tier <T0|T1|T2|T3>] [--routing-reason <text>]\n' +
' node scripts/agent-session-state.js start --repo <path> --branch <name> --task <task> --agent <agent> --worktree <path> --pid <pid> --cli <name> [--task-mode <caveman|omx>] [--openspec-tier <T0|T1|T2|T3>] [--routing-reason <text>] [--state <working|thinking|idle>]\n' +
' node scripts/agent-session-state.js heartbeat --repo <path> --branch <name> [--state <working|thinking|idle>]\n' +
' node scripts/agent-session-state.js terminate --repo <path> --branch <name>\n' +
' node scripts/agent-session-state.js stop --repo <path> --branch <name>\n'
);
}
Expand Down Expand Up @@ -68,13 +70,61 @@ function writeSessionRecord(options) {
taskMode: options['task-mode'],
openspecTier: options['openspec-tier'],
taskRoutingReason: options['routing-reason'],
state: options.state,
});

const targetPath = sessionSchema.sessionFilePathForBranch(repoRoot, branch);
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
fs.writeFileSync(targetPath, `${JSON.stringify(record, null, 2)}\n`, 'utf8');
}

function refreshSessionRecord(options) {
const repoRoot = requireOption(options, 'repo');
const branch = requireOption(options, 'branch');
const targetPath = sessionSchema.sessionFilePathForBranch(repoRoot, branch);
if (!fs.existsSync(targetPath)) {
return;
}

const parsed = JSON.parse(fs.readFileSync(targetPath, 'utf8'));
const nextRecord = {
...parsed,
lastHeartbeatAt: new Date().toISOString(),
};
if (options.state) {
nextRecord.state = options.state;
}

fs.writeFileSync(targetPath, `${JSON.stringify(nextRecord, null, 2)}\n`, 'utf8');
}

function readSessionRecord(options) {
const repoRoot = requireOption(options, 'repo');
const branch = requireOption(options, 'branch');
const targetPath = sessionSchema.sessionFilePathForBranch(repoRoot, branch);
if (!fs.existsSync(targetPath)) {
return null;
}
return JSON.parse(fs.readFileSync(targetPath, 'utf8'));
}

function terminateSessionProcess(options) {
const record = readSessionRecord(options);
const pid = Number(record?.pid);
if (!Number.isInteger(pid) || pid <= 0) {
throw new Error('No live pid recorded for branch.');
}

try {
process.kill(pid, 'SIGTERM');
} catch (error) {
if (error?.code === 'ESRCH') {
return;
}
throw error;
}
}

function removeSessionRecord(options) {
const repoRoot = requireOption(options, 'repo');
const branch = requireOption(options, 'branch');
Expand All @@ -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;
Expand Down
38 changes: 38 additions & 0 deletions scripts/codex-agent.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down Expand Up @@ -1024,16 +1059,19 @@ fi
echo "[codex-agent] Task routing: $(describe_task_routing) (${TASK_ROUTING_REASON})"

active_session_recorded=0
active_session_heartbeat_pid=""
cleanup_active_session_state_on_exit() {
set +e
if [[ "${active_session_recorded:-0}" -eq 1 && -n "${worktree_branch:-}" && "${worktree_branch:-}" != "HEAD" ]]; then
stop_active_session_heartbeat
clear_active_session_state "$worktree_branch"
active_session_recorded=0
fi
}

record_active_session_state "$worktree_path" "$worktree_branch"
active_session_recorded=1
start_active_session_heartbeat "$worktree_branch"
trap cleanup_active_session_state_on_exit EXIT INT TERM

echo "[codex-agent] Launching ${CODEX_BIN} in sandbox: $worktree_path"
Expand Down
1 change: 1 addition & 0 deletions src/context.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
64 changes: 64 additions & 0 deletions src/hooks/index.js
Original file line number Diff line number Diff line change
@@ -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 <agent/...>');
}

return { branch, state };
}

function hook(rawArgs, deps) {
const {
extractTargetedArgs,
Expand Down Expand Up @@ -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)'}`);
}
Expand Down
Loading