diff --git a/README.md b/README.md index 7276114..77c8fbe 100644 --- a/README.md +++ b/README.md @@ -245,7 +245,7 @@ To install the real companion into local VS Code from a GitGuardex-wired repo: node scripts/install-vscode-active-agents-extension.js ``` -It adds an `Active Agents` view to the Source Control container, groups each live repo into `ACTIVE AGENTS` and `CHANGES` sections, splits `ACTIVE AGENTS` into `WORKING NOW` and `THINKING` when both states are present, reads `.omx/state/active-sessions/*.json`, derives `thinking` versus `working` from each live sandbox worktree, and surfaces a working-count summary in the repo/header affordances. Reload the VS Code window after install. +It adds an `Active Agents` view to the Source Control container, groups each live repo into `ACTIVE AGENTS` and `CHANGES` sections, splits `ACTIVE AGENTS` into `BLOCKED`, `WORKING NOW`, `IDLE`, `STALLED`, and `DEAD` when those states are present, reads `.omx/state/active-sessions/*.json`, derives session state from git conflict markers, dirty worktree status, PID liveness, and recent file mtimes, and surfaces working/dead counts in the repo/header affordances. Reload the VS Code window after install. --- diff --git a/openspec/changes/agent-codex-nest-active-agent-changes-2026-04-22-10-53/.openspec.yaml b/openspec/changes/agent-codex-nest-active-agent-changes-2026-04-22-10-53/.openspec.yaml new file mode 100644 index 0000000..25345f4 --- /dev/null +++ b/openspec/changes/agent-codex-nest-active-agent-changes-2026-04-22-10-53/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-22 diff --git a/openspec/changes/agent-codex-nest-active-agent-changes-2026-04-22-10-53/notes.md b/openspec/changes/agent-codex-nest-active-agent-changes-2026-04-22-10-53/notes.md new file mode 100644 index 0000000..cc10fcd --- /dev/null +++ b/openspec/changes/agent-codex-nest-active-agent-changes-2026-04-22-10-53/notes.md @@ -0,0 +1,10 @@ +# T1 Notes + +- Handoff: change=`agent-codex-nest-active-agent-changes-2026-04-22-10-53`; scope=`vscode/guardex-active-agents/{extension.js,session-schema.js}`; action=`nest repo changes under the owning active session row when the path belongs to that session worktree, and keep only unmatched paths in a Repo root residual group`. +- Add `changedPaths` to normalized active-session records so the extension reuses the per-worktree change set already derived from each session's worktree status. +- Rebuild the CHANGES tree grouping so active-session worktree ownership is resolved once and rendered as session subtrees with the existing folder/change item components. +- Preserve the existing file/folder rendering for all leaf nodes and leave only paths that do not belong to any active worktree inside a residual `Repo root` group. +- Verification widened scope: runtime helper parity was already broken on this branch baseline, so `scripts/{agent-branch-start.sh,agent-branch-finish.sh,codex-agent.sh}` are also being resynced to their template behavior to clear repo-wide verification. +- Verification follow-up: `test/merge-workflow.test.js` still expected a repo-local merge shim, but the install surface now keeps workflow shims CLI-owned by default; align that stale assertion with the current zero-copy setup policy so full-suite verification matches the rest of the install tests. +- Replace the Active Agents polling loop with file watchers for `**/.omx/state/active-sessions/*.json`, `**/.omx/state/agent-file-locks.json`, and each live session worktree index so the SCM companion refreshes only when session or dirty-state inputs change. +- Debounce watcher-driven refresh to a trailing 250ms timer and keep a session-keyed watcher map so per-session index watchers are created and disposed as active sessions appear and vanish. diff --git a/openspec/changes/agent-codex-vscode-working-agents-groups-2026-04-22-09-05/specs/vscode-working-agents-groups/spec.md b/openspec/changes/agent-codex-vscode-working-agents-groups-2026-04-22-09-05/specs/vscode-working-agents-groups/spec.md index ee321d9..e4e1700 100644 --- a/openspec/changes/agent-codex-vscode-working-agents-groups-2026-04-22-09-05/specs/vscode-working-agents-groups/spec.md +++ b/openspec/changes/agent-codex-vscode-working-agents-groups-2026-04-22-09-05/specs/vscode-working-agents-groups/spec.md @@ -1,20 +1,23 @@ ## ADDED Requirements -### Requirement: Active Agents highlights currently working lanes -The VS Code Active Agents companion SHALL separate actively editing Guardex lanes from idle-thinking lanes inside the `ACTIVE AGENTS` section. +### Requirement: Active Agents highlights sandbox session state clearly +The VS Code Active Agents companion SHALL separate Guardex sandbox sessions into explicit state groups inside the `ACTIVE AGENTS` section. -#### Scenario: Working and thinking sessions render in separate groups -- **WHEN** a repo has both live `working` and `thinking` Guardex sessions +#### Scenario: Session states render in distinct groups +- **WHEN** a repo has live Guardex sessions inferred as `blocked`, `working`, `idle`, `stalled`, or `dead` - **THEN** the repo node contains an `ACTIVE AGENTS` section -- **AND** that section contains `WORKING NOW` and `THINKING` child groups -- **AND** the working group appears before the thinking group. +- **AND** that section renders child groups for each present state +- **AND** the groups are ordered `BLOCKED`, `WORKING NOW`, `IDLE`, `STALLED`, `DEAD` +- **AND** the `BLOCKED` group appears above `WORKING NOW`. -#### Scenario: Repo summary exposes working counts -- **WHEN** a repo has one or more live working sessions -- **THEN** the repo row description includes the working count in addition to the active session count -- **AND** the Source Control badge tooltip mentions how many active sessions are currently working. +#### Scenario: Repo summary exposes working and dead counts +- **WHEN** a repo has one or more live `working` or `dead` sessions +- **THEN** the repo row description includes the working count in addition to the active count +- **AND** the repo row description includes the dead count when present +- **AND** the Source Control badge tooltip mentions working-now and dead counts when present. -#### Scenario: Working sessions use a distinct visual affordance -- **WHEN** a live Guardex session is inferred as `working` -- **THEN** its row uses a distinct codicon from `thinking` rows +#### Scenario: Each session state uses a distinct visual affordance +- **WHEN** a live Guardex session is inferred as `blocked`, `working`, `idle`, `stalled`, or `dead` +- **THEN** its row uses a distinct codicon for that state +- **AND** its tooltip summarizes the derived state reason - **AND** the row still keeps the existing activity/count/elapsed description text. diff --git a/openspec/changes/agent-codex-vscode-working-agents-groups-2026-04-22-09-05/tasks.md b/openspec/changes/agent-codex-vscode-working-agents-groups-2026-04-22-09-05/tasks.md index bd57762..5fdf727 100644 --- a/openspec/changes/agent-codex-vscode-working-agents-groups-2026-04-22-09-05/tasks.md +++ b/openspec/changes/agent-codex-vscode-working-agents-groups-2026-04-22-09-05/tasks.md @@ -15,9 +15,9 @@ Handoff: 2026-04-22 09:05Z codex owns `templates/vscode/guardex-active-agents/*` ## 2. Implementation -- [x] 2.1 Split the `ACTIVE AGENTS` section into visible `WORKING NOW` and `THINKING` groups, preserving live session rows. -- [x] 2.2 Surface working counts in the repo row / view badge summary and add a distinct icon for working lanes. -- [x] 2.3 Update README guidance and focused regression tests for the new grouping behavior. +- [x] 2.1 Expand session activity derivation to `blocked`, `working`, `idle`, `stalled`, and `dead`, using git markers, dirty worktree status, PID liveness, and worktree mtimes. +- [x] 2.2 Group `ACTIVE AGENTS` into visible `BLOCKED`, `WORKING NOW`, `IDLE`, `STALLED`, and `DEAD` sections, with distinct ThemeIcons/tooltips and updated repo/badge summaries. +- [x] 2.3 Update README guidance and focused regression tests for the expanded session-state behavior. ## 3. Verification @@ -30,3 +30,5 @@ Handoff: 2026-04-22 09:05Z codex owns `templates/vscode/guardex-active-agents/*` - [ ] 4.1 Run the cleanup pipeline: `bash scripts/agent-branch-finish.sh --branch agent/codex/vscode-working-agents-groups-2026-04-22-09-05 --base main --via-pr --wait-for-merge --cleanup`. - [ ] 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). + +BLOCKED: This sandbox picked up unrelated concurrent edits in `scripts/agent-branch-finish.sh`, `scripts/agent-branch-start.sh`, `scripts/codex-agent.sh`, plus a new untracked `openspec/changes/agent-codex-nest-active-agent-changes-2026-04-22-10-53/` workspace after the session-state patch was verified. Do not run finish/cleanup until the branch owner decides whether to keep or split those additional changes. diff --git a/scripts/agent-branch-finish.sh b/scripts/agent-branch-finish.sh index fb528e1..fa67866 100755 --- a/scripts/agent-branch-finish.sh +++ b/scripts/agent-branch-finish.sh @@ -9,11 +9,30 @@ DELETE_REMOTE_BRANCH=0 DELETE_REMOTE_BRANCH_EXPLICIT=0 MERGE_MODE="auto" GH_BIN="${GUARDEX_GH_BIN:-gh}" +NODE_BIN="${GUARDEX_NODE_BIN:-node}" +CLI_ENTRY="${GUARDEX_CLI_ENTRY:-}" CLEANUP_AFTER_MERGE_RAW="${GUARDEX_FINISH_CLEANUP:-false}" WAIT_FOR_MERGE_RAW="${GUARDEX_FINISH_WAIT_FOR_MERGE:-false}" WAIT_TIMEOUT_SECONDS_RAW="${GUARDEX_FINISH_WAIT_TIMEOUT_SECONDS:-1800}" WAIT_POLL_SECONDS_RAW="${GUARDEX_FINISH_WAIT_POLL_SECONDS:-10}" +run_guardex_cli() { + if [[ -n "$CLI_ENTRY" ]]; then + "$NODE_BIN" "$CLI_ENTRY" "$@" + return $? + fi + if command -v gx >/dev/null 2>&1; then + gx "$@" + return $? + fi + if command -v gitguardex >/dev/null 2>&1; then + gitguardex "$@" + return $? + fi + echo "[agent-branch-finish] Guardex CLI entrypoint unavailable; rerun via gx." >&2 + return 127 +} + normalize_bool() { local raw="${1:-}" local fallback="${2:-0}" @@ -431,7 +450,7 @@ run_pr_flow() { if [[ -z "$pr_title" ]]; then pr_title="Merge ${SOURCE_BRANCH} into ${BASE_BRANCH}" fi - pr_body="Automated by scripts/agent-branch-finish.sh (PR flow)." + pr_body="Automated by gx branch finish (PR flow)." "$GH_BIN" pr create \ --base "$BASE_BRANCH" \ @@ -517,9 +536,7 @@ if [[ "$PUSH_ENABLED" -eq 1 ]]; then fi fi -if [[ -x "${repo_root}/scripts/agent-file-locks.py" ]]; then - python3 "${repo_root}/scripts/agent-file-locks.py" release --branch "$SOURCE_BRANCH" >/dev/null 2>&1 || true -fi +run_guardex_cli locks release --branch "$SOURCE_BRANCH" >/dev/null 2>&1 || true base_worktree="$(get_worktree_for_branch "$BASE_BRANCH")" if [[ -n "$base_worktree" ]] && is_clean_worktree "$base_worktree" && [[ "$PUSH_ENABLED" -eq 1 ]]; then @@ -555,29 +572,25 @@ if [[ "$CLEANUP_AFTER_MERGE" -eq 1 ]]; then fi fi - if [[ -x "${repo_root}/scripts/agent-worktree-prune.sh" ]]; then - prune_args=(--base "$BASE_BRANCH" --only-dirty-worktrees --delete-branches) - if [[ "$DELETE_REMOTE_BRANCH" -eq 1 ]]; then - prune_args+=(--delete-remote-branches) - fi - if ! bash "${repo_root}/scripts/agent-worktree-prune.sh" "${prune_args[@]}"; then - echo "[agent-branch-finish] Warning: automatic worktree prune failed." >&2 - echo "[agent-branch-finish] You can run manual cleanup: bash scripts/agent-worktree-prune.sh --base ${BASE_BRANCH} --delete-branches" >&2 - fi + prune_args=(--base "$BASE_BRANCH" --only-dirty-worktrees --delete-branches) + if [[ "$DELETE_REMOTE_BRANCH" -eq 1 ]]; then + prune_args+=(--delete-remote-branches) + fi + if ! run_guardex_cli worktree prune "${prune_args[@]}"; then + echo "[agent-branch-finish] Warning: automatic worktree prune failed." >&2 + echo "[agent-branch-finish] You can run manual cleanup: gx cleanup --base ${BASE_BRANCH}" >&2 fi echo "[agent-branch-finish] Merged '${SOURCE_BRANCH}' into '${BASE_BRANCH}' via ${merge_status} flow and cleaned source branch/worktree." if [[ "$source_worktree" == "$current_worktree" && "$source_worktree" == "${agent_worktree_root}"/* ]]; then echo "[agent-branch-finish] Current worktree '${source_worktree}' still exists because it is the active shell cwd." >&2 - echo "[agent-branch-finish] Leave this directory, then run: bash scripts/agent-worktree-prune.sh --base ${BASE_BRANCH} --delete-branches" >&2 + echo "[agent-branch-finish] Leave this directory, then run: gx cleanup --base ${BASE_BRANCH}" >&2 fi else - if [[ -x "${repo_root}/scripts/agent-worktree-prune.sh" ]]; then - if ! bash "${repo_root}/scripts/agent-worktree-prune.sh" --base "$BASE_BRANCH"; then - echo "[agent-branch-finish] Warning: temporary worktree prune failed." >&2 - fi + if ! run_guardex_cli worktree prune --base "$BASE_BRANCH"; then + echo "[agent-branch-finish] Warning: temporary worktree prune failed." >&2 fi echo "[agent-branch-finish] Merged '${SOURCE_BRANCH}' into '${BASE_BRANCH}' via ${merge_status} flow and kept source branch/worktree." - echo "[agent-branch-finish] Cleanup later with: bash scripts/agent-worktree-prune.sh --base ${BASE_BRANCH} --delete-branches --delete-remote-branches" + echo "[agent-branch-finish] Cleanup later with: gx cleanup --base ${BASE_BRANCH}" fi diff --git a/scripts/codex-agent.sh b/scripts/codex-agent.sh index 75b349c..6a05817 100755 --- a/scripts/codex-agent.sh +++ b/scripts/codex-agent.sh @@ -6,6 +6,8 @@ AGENT_NAME="${GUARDEX_AGENT_NAME:-agent}" BASE_BRANCH="${GUARDEX_BASE_BRANCH:-}" BASE_BRANCH_EXPLICIT=0 CODEX_BIN="${GUARDEX_CODEX_BIN:-codex}" +NODE_BIN="${GUARDEX_NODE_BIN:-node}" +CLI_ENTRY="${GUARDEX_CLI_ENTRY:-}" AUTO_FINISH_RAW="${GUARDEX_CODEX_AUTO_FINISH:-true}" AUTO_REVIEW_ON_CONFLICT_RAW="${GUARDEX_CODEX_AUTO_REVIEW_ON_CONFLICT:-true}" AUTO_CLEANUP_RAW="${GUARDEX_CODEX_AUTO_CLEANUP:-true}" @@ -16,6 +18,23 @@ OPENSPEC_CHANGE_SLUG_OVERRIDE="${GUARDEX_OPENSPEC_CHANGE_SLUG:-}" OPENSPEC_CAPABILITY_SLUG_OVERRIDE="${GUARDEX_OPENSPEC_CAPABILITY_SLUG:-}" OPENSPEC_MASTERPLAN_LABEL_RAW="${GUARDEX_OPENSPEC_MASTERPLAN_LABEL-masterplan}" +run_guardex_cli() { + if [[ -n "$CLI_ENTRY" ]]; then + "$NODE_BIN" "$CLI_ENTRY" "$@" + return $? + fi + if command -v gx >/dev/null 2>&1; then + gx "$@" + return $? + fi + if command -v gitguardex >/dev/null 2>&1; then + gitguardex "$@" + return $? + fi + echo "[codex-agent] Guardex CLI entrypoint unavailable; rerun via gx." >&2 + return 127 +} + normalize_bool() { local raw="${1:-}" local fallback="${2:-0}" @@ -143,6 +162,7 @@ if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then exit 1 fi repo_root="$(git rev-parse --show-toplevel)" +active_session_state_script="${repo_root}/scripts/agent-session-state.js" guardex_env_helper="${repo_root}/scripts/guardex-env.sh" if [[ -f "$guardex_env_helper" ]]; then @@ -373,11 +393,6 @@ start_sandbox_fallback() { printf '[agent-branch-start] Worktree: %s\n' "$worktree_path" } -if [[ ! -x "${repo_root}/scripts/agent-branch-start.sh" ]]; then - echo "[codex-agent] Missing scripts/agent-branch-start.sh. Run: gx setup" >&2 - exit 1 -fi - start_args=("$TASK_NAME" "$AGENT_NAME") if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 ]]; then start_args+=("$BASE_BRANCH") @@ -390,7 +405,7 @@ set +e start_output="$( GUARDEX_OPENSPEC_AUTO_INIT="$OPENSPEC_AUTO_INIT" \ GUARDEX_OPENSPEC_MASTERPLAN_LABEL="$OPENSPEC_MASTERPLAN_LABEL_RAW" \ - bash "${repo_root}/scripts/agent-branch-start.sh" "${start_args[@]}" 2>&1 + run_guardex_cli branch start "${start_args[@]}" 2>&1 )" start_status=$? set -e @@ -446,6 +461,40 @@ has_origin_remote() { git -C "$repo_root" remote get-url origin >/dev/null 2>&1 } +run_active_session_state() { + local action="$1" + shift + + if [[ ! -f "$active_session_state_script" ]]; then + return 0 + fi + if ! command -v "$NODE_BIN" >/dev/null 2>&1; then + return 0 + fi + + "$NODE_BIN" "$active_session_state_script" "$action" "$@" >/dev/null 2>&1 || true +} + +record_active_session_state() { + local wt="$1" + local branch="$2" + + run_active_session_state \ + start \ + --repo "$repo_root" \ + --branch "$branch" \ + --task "$TASK_NAME" \ + --agent "$AGENT_NAME" \ + --worktree "$wt" \ + --pid "$$" \ + --cli "$CODEX_BIN" +} + +clear_active_session_state() { + local branch="$1" + run_active_session_state stop --repo "$repo_root" --branch "$branch" +} + origin_remote_supports_pr_finish() { local origin_url origin_url="$(git -C "$repo_root" remote get-url origin 2>/dev/null || true)" @@ -493,7 +542,7 @@ print_takeover_prompt() { change_artifact="openspec/changes/${change_slug}/" fi - finish_cmd="bash scripts/agent-branch-finish.sh --branch \"${branch}\" --base ${base_branch} --via-pr --wait-for-merge --cleanup" + finish_cmd="gx branch finish --branch \"${branch}\" --base ${base_branch} --via-pr --wait-for-merge --cleanup" echo "[codex-agent] Takeover sandbox: ${wt}" echo "[codex-agent] Takeover prompt: Continue \`${change_slug}\` on branch \`${branch}\`. Work inside \`${wt}\`, review \`${change_artifact}\`, continue from the current state instead of creating a new sandbox, and when the work is done run \`${finish_cmd}\`." @@ -549,24 +598,12 @@ ensure_openspec_plan_workspace() { return 0 fi - hydrate_local_helper_in_worktree "$wt" "scripts/openspec/init-plan-workspace.sh" - - local openspec_script="${wt}/scripts/openspec/init-plan-workspace.sh" - if [[ ! -f "$openspec_script" ]]; then - echo "[codex-agent] Missing OpenSpec init script in sandbox: ${openspec_script}" >&2 - echo "[codex-agent] Run 'gx setup --target ${repo_root}' and retry." >&2 - return 1 - fi - if [[ ! -x "$openspec_script" ]]; then - chmod +x "$openspec_script" 2>/dev/null || true - fi - local plan_slug plan_slug="$(resolve_openspec_plan_slug "$branch")" local init_output="" if ! init_output="$( cd "$wt" - bash "scripts/openspec/init-plan-workspace.sh" "$plan_slug" 2>&1 + run_guardex_cli internal run-shell planInit "$plan_slug" 2>&1 )"; then printf '%s\n' "$init_output" >&2 echo "[codex-agent] OpenSpec workspace initialization failed for plan '${plan_slug}'." >&2 @@ -586,24 +623,12 @@ ensure_openspec_change_workspace() { return 0 fi - hydrate_local_helper_in_worktree "$wt" "scripts/openspec/init-change-workspace.sh" - - local openspec_script="${wt}/scripts/openspec/init-change-workspace.sh" - if [[ ! -f "$openspec_script" ]]; then - echo "[codex-agent] Missing OpenSpec change init script in sandbox: ${openspec_script}" >&2 - echo "[codex-agent] Run 'gx setup --target ${repo_root}' and retry." >&2 - return 1 - fi - if [[ ! -x "$openspec_script" ]]; then - chmod +x "$openspec_script" 2>/dev/null || true - fi - local change_slug capability_slug init_output="" change_slug="$(resolve_openspec_change_slug "$branch")" capability_slug="$(resolve_openspec_capability_slug)" if ! init_output="$( cd "$wt" - bash "scripts/openspec/init-change-workspace.sh" "$change_slug" "$capability_slug" 2>&1 + run_guardex_cli internal run-shell changeInit "$change_slug" "$capability_slug" 2>&1 )"; then printf '%s\n' "$init_output" >&2 echo "[codex-agent] OpenSpec workspace initialization failed for change '${change_slug}'." >&2 @@ -632,11 +657,6 @@ worktree_has_changes() { claim_changed_files() { local wt="$1" local branch="$2" - local lock_script="${repo_root}/scripts/agent-file-locks.py" - - if [[ ! -x "$lock_script" ]]; then - return 0 - fi local changed_raw deleted_raw changed_raw="$({ @@ -647,7 +667,7 @@ claim_changed_files() { if [[ -n "$changed_raw" ]]; then mapfile -t changed_files < <(printf '%s\n' "$changed_raw") - python3 "$lock_script" claim --branch "$branch" "${changed_files[@]}" >/dev/null 2>&1 || true + run_guardex_cli locks claim --branch "$branch" "${changed_files[@]}" >/dev/null 2>&1 || true fi deleted_raw="$({ @@ -657,7 +677,7 @@ claim_changed_files() { if [[ -n "$deleted_raw" ]]; then mapfile -t deleted_files < <(printf '%s\n' "$deleted_raw") - python3 "$lock_script" allow-delete --branch "$branch" "${deleted_files[@]}" >/dev/null 2>&1 || true + run_guardex_cli locks allow-delete --branch "$branch" "${deleted_files[@]}" >/dev/null 2>&1 || true fi } @@ -806,7 +826,7 @@ run_finish_flow() { return 2 fi - if finish_output="$(bash "${repo_root}/scripts/agent-branch-finish.sh" "${finish_args[@]}" 2>&1)"; then + if finish_output="$(run_guardex_cli branch finish "${finish_args[@]}" 2>&1)"; then printf '%s\n' "$finish_output" return 0 fi @@ -829,7 +849,7 @@ run_finish_flow() { fi ) - if finish_output="$(bash "${repo_root}/scripts/agent-branch-finish.sh" "${finish_args[@]}" 2>&1)"; then + if finish_output="$(run_guardex_cli branch finish "${finish_args[@]}" 2>&1)"; then printf '%s\n' "$finish_output" return 0 fi @@ -858,6 +878,19 @@ if ! ensure_openspec_plan_workspace "$worktree_path" "$worktree_branch"; then exit 1 fi +active_session_recorded=0 +cleanup_active_session_state_on_exit() { + set +e + if [[ "${active_session_recorded:-0}" -eq 1 && -n "${worktree_branch:-}" && "${worktree_branch:-}" != "HEAD" ]]; then + clear_active_session_state "$worktree_branch" + active_session_recorded=0 + fi +} + +record_active_session_state "$worktree_path" "$worktree_branch" +active_session_recorded=1 +trap cleanup_active_session_state_on_exit EXIT INT TERM + echo "[codex-agent] Launching ${CODEX_BIN} in sandbox: $worktree_path" cd "$worktree_path" set +e @@ -866,6 +899,8 @@ codex_exit="$?" set -e cd "$repo_root" +cleanup_active_session_state_on_exit +trap - EXIT INT TERM final_exit="$codex_exit" auto_finish_completed=0 @@ -903,18 +938,16 @@ if [[ "$AUTO_FINISH" -eq 1 && -n "$worktree_branch" && "$worktree_branch" != "HE fi fi -if [[ -x "${repo_root}/scripts/agent-worktree-prune.sh" ]]; then - echo "[codex-agent] Session ended (exit=${codex_exit}). Running worktree cleanup..." - prune_args=() - if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 ]]; then - prune_args+=(--base "$BASE_BRANCH") - fi - if [[ "$AUTO_CLEANUP" -eq 1 && "$auto_finish_completed" -eq 1 ]]; then - prune_args+=(--only-dirty-worktrees --delete-branches --delete-remote-branches) - fi - if ! bash "${repo_root}/scripts/agent-worktree-prune.sh" "${prune_args[@]}"; then - echo "[codex-agent] Warning: automatic worktree cleanup failed." >&2 - fi +echo "[codex-agent] Session ended (exit=${codex_exit}). Running worktree cleanup..." +prune_args=() +if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 ]]; then + prune_args+=(--base "$BASE_BRANCH") +fi +if [[ "$AUTO_CLEANUP" -eq 1 && "$auto_finish_completed" -eq 1 ]]; then + prune_args+=(--only-dirty-worktrees --delete-branches --delete-remote-branches) +fi +if ! run_guardex_cli worktree prune "${prune_args[@]}"; then + echo "[codex-agent] Warning: automatic worktree cleanup failed." >&2 fi if [[ ! -d "$worktree_path" ]]; then @@ -927,7 +960,7 @@ else echo "[codex-agent] Branch kept intentionally. Cleanup on demand: gx cleanup --branch \"${worktree_branch}\"" else print_takeover_prompt "$worktree_path" "$worktree_branch" - echo "[codex-agent] If finished, merge with: bash scripts/agent-branch-finish.sh --branch \"${worktree_branch}\" --base dev --via-pr --wait-for-merge" + echo "[codex-agent] If finished, merge with: gx branch finish --branch \"${worktree_branch}\" --base dev --via-pr --wait-for-merge" echo "[codex-agent] Cleanup on demand: gx cleanup --branch \"${worktree_branch}\"" fi fi diff --git a/templates/vscode/guardex-active-agents/README.md b/templates/vscode/guardex-active-agents/README.md index 23228d9..0cd0b42 100644 --- a/templates/vscode/guardex-active-agents/README.md +++ b/templates/vscode/guardex-active-agents/README.md @@ -19,9 +19,9 @@ What it does: - 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 `WORKING NOW` and `THINKING` groups so active edit lanes stand out immediately. +- 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. - Shows repo-root git changes in a sibling `CHANGES` section when the guarded repo itself is dirty. -- Derives `thinking` versus `working` from the live sandbox worktree, surfaces working counts in the repo/header summary, and shows changed-file counts for active edits. -- Uses VS Code's native animated `loading~spin` icon for the running-state affordance. +- 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/`. diff --git a/templates/vscode/guardex-active-agents/extension.js b/templates/vscode/guardex-active-agents/extension.js index 623f107..4f99bf1 100644 --- a/templates/vscode/guardex-active-agents/extension.js +++ b/templates/vscode/guardex-active-agents/extension.js @@ -13,13 +13,57 @@ const SESSION_DECORATION_SCHEME = 'gitguardex-agent'; const IDLE_WARNING_MS = 10 * 60 * 1000; const IDLE_ERROR_MS = 30 * 60 * 1000; const LOCK_FILE_RELATIVE = path.join('.omx', 'state', 'agent-file-locks.json'); +const ACTIVE_SESSION_FILES_GLOB = '**/.omx/state/active-sessions/*.json'; +const AGENT_FILE_LOCKS_GLOB = '**/.omx/state/agent-file-locks.json'; +const SESSION_SCAN_EXCLUDE_GLOB = '**/{node_modules,.git,.omx/agent-worktrees,.omc/agent-worktrees}/**'; +const SESSION_SCAN_LIMIT = 200; +const REFRESH_DEBOUNCE_MS = 250; +const SESSION_ACTIVITY_GROUPS = [ + { kind: 'blocked', label: 'BLOCKED' }, + { kind: 'working', label: 'WORKING NOW' }, + { kind: 'idle', label: 'IDLE' }, + { kind: 'stalled', label: 'STALLED' }, + { kind: 'dead', label: 'DEAD' }, +]; +const SESSION_ACTIVITY_ICON_IDS = { + blocked: 'warning', + working: 'edit', + idle: 'loading~spin', + stalled: 'clock', + dead: 'error', +}; function sessionDecorationUri(branch) { return vscode.Uri.parse(`${SESSION_DECORATION_SCHEME}://${sanitizeBranchForFile(branch)}`); } function sessionIdleDecoration(session, now = Date.now()) { - if (!session || session.activityKind === 'working') { + if (!session) { + return undefined; + } + + if (session.activityKind === 'blocked') { + return { + badge: '!', + tooltip: 'blocked', + color: new vscode.ThemeColor('list.warningForeground'), + }; + } + if (session.activityKind === 'dead') { + return { + badge: 'x', + tooltip: 'dead', + color: new vscode.ThemeColor('list.errorForeground'), + }; + } + if (session.activityKind === 'stalled') { + return { + badge: '!', + tooltip: 'stalled', + color: new vscode.ThemeColor('list.errorForeground'), + }; + } + if (session.activityKind === 'working') { return undefined; } @@ -74,14 +118,30 @@ class SessionDecorationProvider { } } +class InfoItem extends vscode.TreeItem { + constructor(label, description = '') { + super(label, vscode.TreeItemCollapsibleState.None); + this.description = description; + this.iconPath = new vscode.ThemeIcon('info'); + } +} + class RepoItem extends vscode.TreeItem { constructor(repoRoot, sessions, changes) { super(path.basename(repoRoot), vscode.TreeItemCollapsibleState.Expanded); this.repoRoot = repoRoot; this.sessions = sessions; this.changes = changes; - const descriptionParts = [`${sessions.length} active`]; + const descriptionParts = []; + const activeCount = countActiveSessions(sessions); + const deadCount = countSessionsByActivityKind(sessions, 'dead'); const workingCount = countWorkingSessions(sessions); + if (activeCount > 0) { + descriptionParts.push(`${activeCount} active`); + } + if (deadCount > 0) { + descriptionParts.push(`${deadCount} dead`); + } if (workingCount > 0) { descriptionParts.push(`${workingCount} working`); } @@ -109,10 +169,14 @@ class SectionItem extends vscode.TreeItem { } class SessionItem extends vscode.TreeItem { - constructor(session) { + constructor(session, items = []) { const lockCount = Number.isFinite(session.lockCount) ? session.lockCount : 0; - super(`${session.label} 馃敀 ${lockCount}`, vscode.TreeItemCollapsibleState.None); + super( + `${session.label} 馃敀 ${lockCount}`, + items.length > 0 ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.None, + ); this.session = session; + this.items = items; this.resourceUri = sessionDecorationUri(session.branch); const descriptionParts = [session.activityLabel || 'thinking']; if (session.activityCountLabel) { @@ -128,13 +192,13 @@ class SessionItem extends vscode.TreeItem { ? `Changed ${session.activityCountLabel}: ${session.activitySummary}` : session.activitySummary, `Locks ${lockCount}`, + session.pidAlive === false ? `PID ${session.pid} not alive` : `PID ${session.pid} alive`, + session.lastFileActivityAt ? `Last file activity ${session.lastFileActivityAt}` : '', `Started ${session.startedAt}`, session.worktreePath, ]; this.tooltip = tooltipLines.filter(Boolean).join('\n'); - this.iconPath = session.activityKind === 'working' - ? new vscode.ThemeIcon('edit') - : new vscode.ThemeIcon('loading~spin'); + this.iconPath = new vscode.ThemeIcon(resolveSessionActivityIconId(session.activityKind)); this.contextValue = 'gitguardex.session'; this.command = { command: 'gitguardex.activeAgents.openWorktree', @@ -306,7 +370,7 @@ function repoRootFromLockFile(filePath) { } function normalizeRelativePath(relativePath) { - return String(relativePath || '').replace(/\\/g, '/'); + return String(relativePath || '').replace(/\\/g, '/').replace(/^\.\//, ''); } function emptyLockRegistry() { @@ -389,6 +453,104 @@ function decorateChange(change, lockRegistry, owningBranch) { }; } +function isPathWithin(parentPath, targetPath) { + const relativePath = path.relative(parentPath, targetPath); + return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath)); +} + +function localizeChangeForSession(session, change) { + if (!change?.absolutePath || !isPathWithin(session.worktreePath, change.absolutePath)) { + return null; + } + + let originalPath = change.originalPath; + if (originalPath) { + const originalAbsolutePath = path.join(session.repoRoot, originalPath); + if (isPathWithin(session.worktreePath, originalAbsolutePath)) { + originalPath = normalizeRelativePath(path.relative(session.worktreePath, originalAbsolutePath)); + } + } + + return { + ...change, + relativePath: normalizeRelativePath(path.relative(session.worktreePath, change.absolutePath)), + originalPath, + }; +} + +async function findRepoSessionEntries() { + const sessionFiles = await vscode.workspace.findFiles( + ACTIVE_SESSION_FILES_GLOB, + SESSION_SCAN_EXCLUDE_GLOB, + SESSION_SCAN_LIMIT, + ); + + const repoRoots = new Set(); + for (const uri of sessionFiles) { + repoRoots.add(repoRootFromSessionFile(uri.fsPath)); + } + + if (repoRoots.size === 0) { + for (const workspaceFolder of vscode.workspace.workspaceFolders || []) { + repoRoots.add(workspaceFolder.uri.fsPath); + } + } + + const repoEntries = []; + for (const repoRoot of repoRoots) { + const sessions = readActiveSessions(repoRoot, { includeStale: true }); + if (sessions.length > 0) { + repoEntries.push({ repoRoot, sessions }); + } + } + + repoEntries.sort((left, right) => left.repoRoot.localeCompare(right.repoRoot)); + return repoEntries; +} + +function resolveSessionWatcherKey(session) { + return `${path.resolve(session.repoRoot)}::${session.branch}::${path.resolve(session.worktreePath)}`; +} + +function resolveSessionGitIndexPath(worktreePath) { + const gitPath = path.join(worktreePath, '.git'); + const defaultIndexPath = path.join(gitPath, 'index'); + + try { + if (fs.statSync(gitPath).isDirectory()) { + return defaultIndexPath; + } + } catch (_error) { + return defaultIndexPath; + } + + try { + const gitPointer = fs.readFileSync(gitPath, 'utf8'); + const match = gitPointer.match(/^gitdir:\s*(.+)$/m); + if (match?.[1]) { + return path.resolve(worktreePath, match[1].trim(), 'index'); + } + } catch (_error) { + return defaultIndexPath; + } + + return defaultIndexPath; +} + +function bindRefreshWatcher(watcher, refresh) { + return [ + watcher.onDidCreate(refresh), + watcher.onDidChange(refresh), + watcher.onDidDelete(refresh), + ]; +} + +function disposeAll(disposables) { + for (const disposable of disposables) { + disposable?.dispose?.(); + } +} + function buildChangeTreeNodes(changes) { const root = []; @@ -454,9 +616,67 @@ function countWorkingSessions(sessions) { return sessions.filter((session) => session.activityKind === 'working').length; } -<<<<<<< HEAD -function shellQuote(value) { - return `'${String(value).replace(/'/g, `'\\''`)}'`; +function buildGroupedChangeTreeNodes(sessions, changes) { + const changesBySession = new Map(); + const sessionByChangedPath = new Map(); + const repoRootChanges = []; + + for (const session of sessions) { + changesBySession.set(session.branch, []); + for (const changedPath of session.changedPaths || []) { + if (!sessionByChangedPath.has(changedPath)) { + sessionByChangedPath.set(changedPath, session); + } + } + } + + for (const change of changes) { + const normalizedRelativePath = normalizeRelativePath(change.relativePath); + const session = sessionByChangedPath.get(normalizedRelativePath) + || sessions.find((candidate) => isPathWithin(candidate.worktreePath, change.absolutePath)); + if (!session) { + repoRootChanges.push(change); + continue; + } + + const localizedChange = localizeChangeForSession(session, change); + if (!localizedChange) { + repoRootChanges.push(change); + continue; + } + + changesBySession.get(session.branch).push(localizedChange); + } + + const items = sessions + .map((session) => { + const sessionChanges = changesBySession.get(session.branch) || []; + if (sessionChanges.length === 0) { + return null; + } + return new SessionItem(session, buildChangeTreeNodes(sessionChanges)); + }) + .filter(Boolean); + + if (repoRootChanges.length > 0) { + items.push(new SectionItem('Repo root', buildChangeTreeNodes(repoRootChanges), { + description: String(repoRootChanges.length), + })); + } + + return items; +} + +function countActiveSessions(sessions) { + return sessions.filter((session) => session.activityKind !== 'dead').length; +} + +function countSessionsByActivityKind(sessions, activityKind) { + return sessions.filter((session) => session.activityKind === activityKind).length; +} + +function resolveSessionActivityIconId(activityKind) { + return SESSION_ACTIVITY_ICON_IDS[activityKind] || 'loading~spin'; } async function pickRepoRoot() { @@ -530,7 +750,8 @@ async function startAgentFromPrompt(refresh) { true, ); refresh(); -======= +} + function sessionSelectionKey(session) { if (!session?.repoRoot || !session?.branch) { return ''; @@ -561,23 +782,17 @@ function stageWorktreeForCommit(worktreePath) { function commitWorktree(worktreePath, message) { runGitCommand(worktreePath, ['commit', '-m', message]); ->>>>>>> 60c38c6 (Let operators commit the selected sandbox from the Active Agents SCM view) } function buildActiveAgentGroupNodes(sessions) { - const workingSessions = sessions - .filter((session) => session.activityKind === 'working') - .map((session) => new SessionItem(session)); - const thinkingSessions = sessions - .filter((session) => session.activityKind !== 'working') - .map((session) => new SessionItem(session)); const groups = []; - - if (workingSessions.length > 0) { - groups.push(new SectionItem('WORKING NOW', workingSessions)); - } - if (thinkingSessions.length > 0) { - groups.push(new SectionItem('THINKING', thinkingSessions)); + for (const group of SESSION_ACTIVITY_GROUPS) { + const groupSessions = sessions + .filter((session) => session.activityKind === group.kind) + .map((session) => new SessionItem(session)); + if (groupSessions.length > 0) { + groups.push(new SectionItem(group.label, groupSessions)); + } } return groups; @@ -601,7 +816,7 @@ class ActiveAgentsProvider { attachTreeView(treeView) { this.treeView = treeView; - this.updateViewState(0, 0); + this.updateViewState(0, 0, 0); treeView.onDidChangeSelection?.((event) => { const sessionItem = event.selection.find((item) => item instanceof SessionItem); this.setSelectedSession(sessionItem?.session || null); @@ -633,19 +848,32 @@ class ActiveAgentsProvider { this.setSelectedSession(nextSession || null); } - updateViewState(sessionCount, workingCount) { + updateViewState(sessionCount, workingCount, deadCount) { if (!this.treeView) { return; } + const activeCount = Math.max(0, sessionCount - deadCount); + const badgeTooltipParts = []; + if (activeCount > 0) { + badgeTooltipParts.push(`${activeCount} active agent${activeCount === 1 ? '' : 's'}`); + } + if (deadCount > 0) { + badgeTooltipParts.push(`${deadCount} dead`); + } + if (workingCount > 0) { + badgeTooltipParts.push(`${workingCount} working now`); + } + this.treeView.badge = sessionCount > 0 ? { value: sessionCount, - tooltip: `${sessionCount} active agent${sessionCount === 1 ? '' : 's'}` - + (workingCount > 0 ? ` 路 ${workingCount} working now` : ''), + tooltip: badgeTooltipParts.join(' 路 '), } : undefined; - this.treeView.message = undefined; + this.treeView.message = sessionCount > 0 + ? undefined + : 'Start a sandbox session to populate this view.'; } async syncRepoEntries() { @@ -655,8 +883,12 @@ class ActiveAgentsProvider { (total, entry) => total + countWorkingSessions(entry.sessions), 0, ); + const deadCount = repoEntries.reduce( + (total, entry) => total + countSessionsByActivityKind(entry.sessions, 'dead'), + 0, + ); - this.updateViewState(sessionCount, workingCount); + this.updateViewState(sessionCount, workingCount, deadCount); this.decorationProvider?.updateSessions(repoEntries.flatMap((entry) => entry.sessions)); return repoEntries; } @@ -689,12 +921,14 @@ class ActiveAgentsProvider { }), ]; if (element.changes.length > 0) { - sectionItems.push(new SectionItem('CHANGES', buildChangeTreeNodes(element.changes))); + sectionItems.push(new SectionItem('CHANGES', buildGroupedChangeTreeNodes(element.sessions, element.changes), { + description: String(element.changes.length), + })); } return sectionItems; } - if (element instanceof SectionItem || element instanceof FolderItem) { + if (element instanceof SectionItem || element instanceof FolderItem || element instanceof SessionItem) { return element.items; } @@ -702,54 +936,100 @@ class ActiveAgentsProvider { this.syncSelectedSession(repoEntries); if (repoEntries.length === 0) { - return []; + return [new InfoItem('No active Guardex agents', 'Open or start a sandbox session.')]; } return repoEntries.map((entry) => new RepoItem(entry.repoRoot, entry.sessions, entry.changes)); } async loadRepoEntries() { - const sessionFiles = await vscode.workspace.findFiles( - '**/.omx/state/active-sessions/*.json', - '**/{node_modules,.git,.omx/agent-worktrees,.omc/agent-worktrees}/**', - 200, - ); + const repoEntries = await findRepoSessionEntries(); + return repoEntries.map((entry) => { + const repoRoot = entry.repoRoot; + const lockRegistry = this.getLockRegistryForRepo(repoRoot); + const currentBranch = readCurrentBranch(repoRoot); + return { + repoRoot, + sessions: entry.sessions.map((session) => decorateSession(session, lockRegistry)), + changes: readRepoChanges(repoRoot).map((change) => ( + decorateChange(change, lockRegistry, currentBranch) + )), + }; + }); + } +} + +class ActiveAgentsRefreshController { + constructor(provider) { + this.provider = provider; + this.refreshTimer = null; + this.sessionWatchers = new Map(); + } - const repoRoots = new Set(); - for (const uri of sessionFiles) { - repoRoots.add(repoRootFromSessionFile(uri.fsPath)); + scheduleRefresh() { + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); } + this.refreshTimer = setTimeout(() => { + this.refreshTimer = null; + void this.refreshNow(); + }, REFRESH_DEBOUNCE_MS); + } - if (repoRoots.size === 0) { - for (const workspaceFolder of vscode.workspace.workspaceFolders || []) { - repoRoots.add(workspaceFolder.uri.fsPath); + async refreshNow() { + await this.syncSessionWatchers(); + await this.provider.refresh(); + } + + async syncSessionWatchers() { + const repoEntries = await findRepoSessionEntries(); + const liveSessionKeys = new Set(); + + for (const entry of repoEntries) { + for (const session of entry.sessions) { + const sessionKey = resolveSessionWatcherKey(session); + liveSessionKeys.add(sessionKey); + if (this.sessionWatchers.has(sessionKey)) { + continue; + } + + const watcher = vscode.workspace.createFileSystemWatcher( + resolveSessionGitIndexPath(session.worktreePath), + ); + const disposables = bindRefreshWatcher(watcher, () => this.scheduleRefresh()); + this.sessionWatchers.set(sessionKey, { watcher, disposables }); } } - const repoEntries = []; - for (const repoRoot of repoRoots) { - const lockRegistry = this.getLockRegistryForRepo(repoRoot); - const sessions = readActiveSessions(repoRoot).map((session) => decorateSession(session, lockRegistry)); - if (sessions.length > 0) { - const currentBranch = readCurrentBranch(repoRoot); - repoEntries.push({ - repoRoot, - sessions, - changes: readRepoChanges(repoRoot).map((change) => ( - decorateChange(change, lockRegistry, currentBranch) - )), - }); + for (const [sessionKey, entry] of this.sessionWatchers) { + if (liveSessionKeys.has(sessionKey)) { + continue; } + + disposeAll(entry.disposables); + entry.watcher.dispose(); + this.sessionWatchers.delete(sessionKey); + } + } + + dispose() { + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + this.refreshTimer = null; } - repoEntries.sort((left, right) => left.repoRoot.localeCompare(right.repoRoot)); - return repoEntries; + for (const entry of this.sessionWatchers.values()) { + disposeAll(entry.disposables); + entry.watcher.dispose(); + } + this.sessionWatchers.clear(); } } function activate(context) { const decorationProvider = new SessionDecorationProvider(); const provider = new ActiveAgentsProvider(decorationProvider); + const refreshController = new ActiveAgentsRefreshController(provider); const treeView = vscode.window.createTreeView('gitguardex.activeAgents', { treeDataProvider: provider, showCollapseAll: true, @@ -759,11 +1039,10 @@ function activate(context) { 'Active Agents Commit', ); provider.attachTreeView(treeView); - const refresh = () => { - void provider.refresh(); - }; - const sessionWatcher = vscode.workspace.createFileSystemWatcher('**/.omx/state/active-sessions/*.json'); - const lockWatcher = vscode.workspace.createFileSystemWatcher('**/.omx/state/agent-file-locks.json'); + const scheduleRefresh = () => refreshController.scheduleRefresh(); + const refresh = () => void refreshController.refreshNow(); + const activeSessionsWatcher = vscode.workspace.createFileSystemWatcher(ACTIVE_SESSION_FILES_GLOB); + const lockWatcher = vscode.workspace.createFileSystemWatcher(AGENT_FILE_LOCKS_GLOB); const updateCommitInput = (session) => { sourceControl.inputBox.enabled = true; sourceControl.inputBox.visible = true; @@ -815,7 +1094,7 @@ function activate(context) { if (uri?.fsPath) { provider.refreshLockRegistryForFile(uri.fsPath); } - refresh(); + scheduleRefresh(); }; provider.onDidChangeSelectedSession(updateCommitInput); @@ -823,6 +1102,7 @@ function activate(context) { context.subscriptions.push( treeView, sourceControl, + refreshController, vscode.window.registerFileDecorationProvider(decorationProvider), vscode.commands.registerCommand('gitguardex.activeAgents.startAgent', () => startAgentFromPrompt(refresh)), vscode.commands.registerCommand('gitguardex.activeAgents.refresh', refresh), @@ -854,18 +1134,17 @@ function activate(context) { vscode.commands.registerCommand('gitguardex.activeAgents.syncSession', syncSession), vscode.commands.registerCommand('gitguardex.activeAgents.stopSession', (session) => stopSession(session, refresh)), vscode.commands.registerCommand('gitguardex.activeAgents.openSessionDiff', openSessionDiff), - vscode.workspace.onDidChangeWorkspaceFolders(refresh), - sessionWatcher, + vscode.workspace.onDidChangeWorkspaceFolders(scheduleRefresh), + activeSessionsWatcher, lockWatcher, { dispose: () => clearInterval(interval) }, ); - sessionWatcher.onDidCreate(refresh, undefined, context.subscriptions); - sessionWatcher.onDidChange(refresh, undefined, context.subscriptions); - sessionWatcher.onDidDelete(refresh, undefined, context.subscriptions); - lockWatcher.onDidCreate(refreshLockRegistry, undefined, context.subscriptions); - lockWatcher.onDidChange(refreshLockRegistry, undefined, context.subscriptions); - lockWatcher.onDidDelete(refreshLockRegistry, undefined, context.subscriptions); + context.subscriptions.push( + ...bindRefreshWatcher(activeSessionsWatcher, scheduleRefresh), + ...bindRefreshWatcher(lockWatcher, refreshLockRegistry), + ); + void refreshController.refreshNow(); } function deactivate() {} diff --git a/templates/vscode/guardex-active-agents/session-schema.js b/templates/vscode/guardex-active-agents/session-schema.js index 98390bf..e9282a0 100644 --- a/templates/vscode/guardex-active-agents/session-schema.js +++ b/templates/vscode/guardex-active-agents/session-schema.js @@ -8,6 +8,22 @@ const LOCK_FILE_RELATIVE = path.join('.omx', 'state', 'agent-file-locks.json'); 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 IDLE_ACTIVITY_WINDOW_MS = 2 * 60 * 1000; +const STALLED_ACTIVITY_WINDOW_MS = 15 * 60 * 1000; +const BLOCKING_GIT_STATES = [ + { + label: 'Rebase in progress.', + markers: ['REBASE_HEAD', 'rebase-apply', 'rebase-merge'], + }, + { + label: 'Merge in progress.', + markers: ['MERGE_HEAD'], + }, + { + label: 'Cherry-pick in progress.', + markers: ['CHERRY_PICK_HEAD'], + }, +]; function toNonEmptyString(value, fallback = '') { const normalized = typeof value === 'string' ? value.trim() : String(value || '').trim(); @@ -46,6 +62,10 @@ function splitOutputLines(output) { .filter((line) => line.trim().length > 0); } +function normalizeRelativePath(value) { + return toNonEmptyString(value).replace(/\\/g, '/').replace(/^\.\//, ''); +} + function runGitLines(worktreePath, args) { try { const output = cp.execFileSync('git', ['-C', worktreePath, ...args], { @@ -184,37 +204,187 @@ function collectWorktreeChangedPaths(worktreePath) { .sort((left, right) => left.localeCompare(right)); } -function deriveSessionActivity(session) { - const changedPaths = collectWorktreeChangedPaths(session.worktreePath); - if (!changedPaths) { +function resolveWorktreeGitDir(worktreePath) { + const gitPath = path.join(path.resolve(worktreePath), '.git'); + try { + if (fs.statSync(gitPath).isDirectory()) { + return gitPath; + } + } catch (_error) { + return null; + } + + try { + const gitPointer = fs.readFileSync(gitPath, 'utf8'); + const match = gitPointer.match(/^gitdir:\s*(.+)$/m); + if (match?.[1]) { + return path.resolve(worktreePath, match[1].trim()); + } + } catch (_error) { + return null; + } + + return null; +} + +function deriveBlockingGitLabel(worktreePath) { + const gitDir = resolveWorktreeGitDir(worktreePath); + if (!gitDir) { + return ''; + } + + for (const blockingState of BLOCKING_GIT_STATES) { + if (blockingState.markers.some((marker) => fs.existsSync(path.join(gitDir, marker)))) { + return blockingState.label; + } + } + + return ''; +} + +function collectWorktreeTrackedPaths(worktreePath) { + const trackedPaths = runGitLines(worktreePath, ['ls-files', '--cached', '--others', '--exclude-standard']); + if (!trackedPaths) { + return null; + } + + return [...new Set(trackedPaths)] + .filter(Boolean) + .sort((left, right) => left.localeCompare(right)); +} + +function deriveLatestWorktreeFileActivity(worktreePath) { + const trackedPaths = collectWorktreeTrackedPaths(worktreePath); + if (!trackedPaths) { + return null; + } + + let latestMtimeMs = null; + for (const relativePath of trackedPaths) { + const absolutePath = path.join(worktreePath, relativePath); + try { + const stats = fs.statSync(absolutePath); + if (!stats.isFile() || !Number.isFinite(stats.mtimeMs)) { + continue; + } + latestMtimeMs = latestMtimeMs === null + ? stats.mtimeMs + : Math.max(latestMtimeMs, stats.mtimeMs); + } catch (_error) { + continue; + } + } + + return latestMtimeMs; +} + +function deriveSessionActivity(session, options = {}) { + const now = Number.isFinite(options.now) ? options.now : Date.now(); + const blockingLabel = deriveBlockingGitLabel(session.worktreePath); + if (blockingLabel) { return { - activityKind: 'thinking', - activityLabel: 'thinking', + activityKind: 'blocked', + activityLabel: 'blocked', + activityCountLabel: '', + activitySummary: blockingLabel, + changeCount: 0, + changedPaths: [], + pidAlive: isPidAlive(session.pid), + lastFileActivityAt: '', + lastFileActivityLabel: '', + }; + } + + const pidAlive = isPidAlive(session.pid); + if (!pidAlive) { + return { + activityKind: 'dead', + activityLabel: 'dead', + activityCountLabel: '', + activitySummary: 'Recorded PID is not alive.', + changeCount: 0, + changedPaths: [], + pidAlive, + lastFileActivityAt: '', + lastFileActivityLabel: '', + }; + } + + const worktreeChangedPaths = collectWorktreeChangedPaths(session.worktreePath); + if (!worktreeChangedPaths) { + return { + activityKind: 'idle', + activityLabel: 'idle', activityCountLabel: '', activitySummary: 'Worktree activity unavailable.', changeCount: 0, changedPaths: [], + pidAlive, + lastFileActivityAt: '', + lastFileActivityLabel: '', }; } - if (changedPaths.length === 0) { + if (worktreeChangedPaths.length > 0) { + const changedPaths = [...new Set(worktreeChangedPaths + .map((relativePath) => normalizeRelativePath( + path.relative(session.repoRoot, path.resolve(session.worktreePath, relativePath)), + )) + .filter(Boolean))] + .sort((left, right) => left.localeCompare(right)); + + return { + activityKind: 'working', + activityLabel: 'working', + activityCountLabel: formatFileCount(worktreeChangedPaths.length), + activitySummary: previewChangedPaths(worktreeChangedPaths), + changeCount: worktreeChangedPaths.length, + changedPaths, + pidAlive, + lastFileActivityAt: '', + lastFileActivityLabel: '', + }; + } + + const latestFileActivityMs = deriveLatestWorktreeFileActivity(session.worktreePath); + const lastFileActivityAt = Number.isFinite(latestFileActivityMs) + ? new Date(latestFileActivityMs).toISOString() + : ''; + const lastFileActivityLabel = lastFileActivityAt + ? formatElapsedFrom(lastFileActivityAt, now) + : ''; + const lastFileActivityAgeMs = Number.isFinite(latestFileActivityMs) + ? Math.max(0, now - latestFileActivityMs) + : null; + + if (lastFileActivityAgeMs !== null && lastFileActivityAgeMs > STALLED_ACTIVITY_WINDOW_MS) { return { - activityKind: 'thinking', - activityLabel: 'thinking', + activityKind: 'stalled', + activityLabel: 'stalled', activityCountLabel: '', - activitySummary: 'Worktree clean.', + activitySummary: `Worktree clean. No file activity for ${lastFileActivityLabel}.`, changeCount: 0, changedPaths: [], + pidAlive, + lastFileActivityAt, + lastFileActivityLabel, }; } return { - activityKind: 'working', - activityLabel: 'working', - activityCountLabel: formatFileCount(changedPaths.length), - activitySummary: previewChangedPaths(changedPaths), - changeCount: changedPaths.length, - changedPaths, + activityKind: 'idle', + activityLabel: 'idle', + activityCountLabel: '', + activitySummary: lastFileActivityAgeMs !== null && lastFileActivityAgeMs <= IDLE_ACTIVITY_WINDOW_MS + ? `Worktree clean. Recent file activity ${lastFileActivityLabel} ago.` + : lastFileActivityLabel + ? `Worktree clean. Last file activity ${lastFileActivityLabel} ago.` + : 'Worktree clean.', + changeCount: 0, + changedPaths: [], + pidAlive, + lastFileActivityAt, + lastFileActivityLabel, }; } @@ -289,6 +459,7 @@ function normalizeSessionRecord(input, options = {}) { startedAt: startedAt.toISOString(), filePath: toNonEmptyString(options.filePath), label: deriveSessionLabel(branch, worktreePath), + changedPaths: [], }; } @@ -360,7 +531,7 @@ function readActiveSessions(repoRoot, options = {}) { } normalized.elapsedLabel = formatElapsedFrom(normalized.startedAt, now); - Object.assign(normalized, deriveSessionActivity(normalized)); + Object.assign(normalized, deriveSessionActivity(normalized, { now })); sessions.push(normalized); } @@ -393,6 +564,9 @@ module.exports = { activeSessionsDirForRepo, buildSessionRecord, collectWorktreeChangedPaths, + collectWorktreeTrackedPaths, + deriveBlockingGitLabel, + deriveLatestWorktreeFileActivity, deriveSessionLabel, deriveSessionActivity, formatElapsedFrom, @@ -404,6 +578,7 @@ module.exports = { readActiveSessions, readRepoChanges, deriveRepoChangeStatus, + resolveWorktreeGitDir, sanitizeBranchForFile, sessionFileNameForBranch, sessionFilePathForBranch, diff --git a/test/merge-workflow.test.js b/test/merge-workflow.test.js index 3f3986d..1dfb327 100644 --- a/test/merge-workflow.test.js +++ b/test/merge-workflow.test.js @@ -133,7 +133,7 @@ function extractMergeTargetWorktree(output) { return match[1].trim(); } -test('setup installs the managed merge workflow shim without package script churn', () => { +test('setup keeps the merge workflow shim CLI-owned without package script churn', () => { const repoDir = initRepo(); seedCommit(repoDir); @@ -141,8 +141,7 @@ test('setup installs the managed merge workflow shim without package script chur assert.equal(result.status, 0, result.stderr || result.stdout); const mergeScriptPath = path.join(repoDir, 'scripts', 'agent-branch-merge.sh'); - assert.equal(fs.existsSync(mergeScriptPath), true, 'merge script should be installed'); - fs.accessSync(mergeScriptPath, fs.constants.X_OK); + assert.equal(fs.existsSync(mergeScriptPath), false, 'merge script should stay CLI-owned'); const packageJson = JSON.parse(fs.readFileSync(path.join(repoDir, 'package.json'), 'utf8')); assert.equal(packageJson.scripts['agent:branch:merge'], undefined); diff --git a/test/vscode-active-agents-session-state.test.js b/test/vscode-active-agents-session-state.test.js index a244554..6cfcbaa 100644 --- a/test/vscode-active-agents-session-state.test.js +++ b/test/vscode-active-agents-session-state.test.js @@ -40,7 +40,19 @@ function initGitRepo(repoPath) { runGit(repoPath, ['config', 'user.name', 'Guardex Tests']); } -function loadExtensionWithMockVscode(mockVscode) { +function setPathMtime(filePath, whenMs) { + const when = new Date(whenMs); + fs.utimesSync(filePath, when, when); +} + +function writeSessionRecord(repoRoot, record) { + const sessionPath = sessionSchema.sessionFilePathForBranch(repoRoot, record.branch); + fs.mkdirSync(path.dirname(sessionPath), { recursive: true }); + fs.writeFileSync(sessionPath, `${JSON.stringify(record, null, 2)}\n`, 'utf8'); + return sessionPath; +} + +function loadExtensionWithMockVscode(mockVscode, mockSessionSchema = null) { const Module = require('node:module'); const originalLoad = Module._load; delete require.cache[require.resolve(extensionEntry)]; @@ -49,6 +61,9 @@ function loadExtensionWithMockVscode(mockVscode) { if (request === 'vscode') { return mockVscode; } + if (mockSessionSchema && request === './session-schema.js' && parent?.filename === extensionEntry) { + return mockSessionSchema; + } return originalLoad.call(this, request, parent, isMain); }; @@ -71,16 +86,15 @@ function createMockVscode(tempRoot) { openedDocuments: [], shownDocuments: [], infoMessages: [], -<<<<<<< HEAD inputResponses: [], quickPickCalls: [], quickPickResponse: undefined, -======= informationMessages: [], errorMessages: [], ->>>>>>> 60c38c6 (Let operators commit the selected sandbox from the Active Agents SCM view) warningMessages: [], + fileWatchers: [], watchers: [], + workspaceFolderListeners: [], }; class TreeItem { @@ -104,6 +118,7 @@ function createMockVscode(tempRoot) { class EventEmitter { constructor() { + this.fireCount = 0; this.listeners = []; this.event = (listener, thisArg, disposables) => { const boundListener = thisArg ? listener.bind(thisArg) : listener; @@ -121,13 +136,14 @@ function createMockVscode(tempRoot) { } fire(event) { + this.fireCount += 1; for (const listener of [...this.listeners]) { listener(event); } } } - const disposable = () => ({ dispose() {} }); + const disposable = (onDispose) => ({ dispose: onDispose || (() => {}) }); function createFileWatcher(pattern) { const listeners = { @@ -136,16 +152,20 @@ function createMockVscode(tempRoot) { delete: [], }; - return { + const watcher = { + disposed: false, pattern, onDidCreate(callback, thisArg) { listeners.create.push({ callback, thisArg }); + return disposable(); }, onDidChange(callback, thisArg) { listeners.change.push({ callback, thisArg }); + return disposable(); }, onDidDelete(callback, thisArg) { listeners.delete.push({ callback, thisArg }); + return disposable(); }, fireCreate(uri) { for (const listener of listeners.create) { @@ -162,8 +182,13 @@ function createMockVscode(tempRoot) { listener.callback.call(listener.thisArg, uri); } }, - dispose() {}, + dispose() { + watcher.disposed = true; + }, }; + registrations.watchers.push(watcher); + registrations.fileWatchers.push(watcher); + return watcher; } return { @@ -190,11 +215,7 @@ function createMockVscode(tempRoot) { }, registerCommand: (command, handler) => { registrations.commands.set(command, handler); - return { - dispose() { - registrations.commands.delete(command); - }, - }; + return disposable(() => registrations.commands.delete(command)); }, }, scm: { @@ -317,13 +338,17 @@ function createMockVscode(tempRoot) { registrations.openedDocuments.push(document); return document; }, - createFileSystemWatcher: (pattern) => { - const watcher = createFileWatcher(pattern); - registrations.watchers.push(watcher); - return watcher; - }, + createFileSystemWatcher: (pattern) => createFileWatcher(pattern), findFiles: async () => [], - onDidChangeWorkspaceFolders: () => disposable(), + onDidChangeWorkspaceFolders: (listener) => { + registrations.workspaceFolderListeners.push(listener); + return disposable(() => { + const index = registrations.workspaceFolderListeners.indexOf(listener); + if (index >= 0) { + registrations.workspaceFolderListeners.splice(index, 1); + } + }); + }, workspaceFolders: [{ uri: { fsPath: tempRoot } }], }, ThemeColor, @@ -331,6 +356,11 @@ function createMockVscode(tempRoot) { }; } +async function flushAsyncWork() { + await Promise.resolve(); + await new Promise((resolve) => setImmediate(resolve)); +} + test('agent-session-state writes and removes active session records', () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-active-session-')); const branch = 'agent/codex/demo-task'; @@ -420,6 +450,13 @@ test('session-schema ignores stale or invalid session records', () => { const sessions = sessionSchema.readActiveSessions(tempRoot); assert.equal(sessions.length, 1); assert.equal(sessions[0].branch, liveRecord.branch); + + const sessionsIncludingStale = sessionSchema.readActiveSessions(tempRoot, { includeStale: true }); + assert.equal(sessionsIncludingStale.length, 2); + assert.equal( + sessionsIncludingStale.find((session) => session.branch === staleRecord.branch)?.activityKind, + 'dead', + ); }); test('session-schema derives working activity from dirty sandbox worktrees', () => { @@ -450,10 +487,89 @@ test('session-schema derives working activity from dirty sandbox worktrees', () assert.equal(session.activityKind, 'working'); assert.equal(session.changeCount, 2); assert.equal(session.activityCountLabel, '2 files'); - assert.deepEqual(session.changedPaths, ['new-file.txt', 'tracked.txt']); + assert.deepEqual(session.changedPaths, ['sandbox/new-file.txt', 'sandbox/tracked.txt']); assert.equal(session.activitySummary, 'new-file.txt, tracked.txt'); }); +test('session-schema derives blocked activity from git markers in the worktree git dir', () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-active-session-blocked-')); + const worktreePath = path.join(tempRoot, 'sandbox'); + initGitRepo(worktreePath); + fs.writeFileSync(path.join(worktreePath, 'tracked.txt'), 'base\n', 'utf8'); + runGit(worktreePath, ['add', 'tracked.txt']); + runGit(worktreePath, ['commit', '-m', 'baseline']); + fs.writeFileSync(path.join(worktreePath, '.git', 'MERGE_HEAD'), 'deadbeef\n', 'utf8'); + + const session = sessionSchema.deriveSessionActivity(sessionSchema.buildSessionRecord({ + repoRoot: tempRoot, + branch: 'agent/codex/blocked-task', + taskName: 'blocked-task', + agentName: 'codex', + worktreePath, + pid: process.pid, + cliName: 'codex', + })); + + assert.equal(session.activityKind, 'blocked'); + assert.equal(session.activitySummary, 'Merge in progress.'); +}); + +test('session-schema derives idle and stalled activity from clean worktree mtimes', () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-active-session-idle-')); + const worktreePath = path.join(tempRoot, 'sandbox'); + const trackedPath = path.join(worktreePath, 'tracked.txt'); + initGitRepo(worktreePath); + fs.writeFileSync(trackedPath, 'base\n', 'utf8'); + runGit(worktreePath, ['add', 'tracked.txt']); + runGit(worktreePath, ['commit', '-m', 'baseline']); + + const record = sessionSchema.buildSessionRecord({ + repoRoot: tempRoot, + branch: 'agent/codex/idle-task', + taskName: 'idle-task', + agentName: 'codex', + worktreePath, + pid: process.pid, + cliName: 'codex', + }); + const now = Date.parse('2026-04-22T10:00:00.000Z'); + + setPathMtime(trackedPath, now - 45_000); + const idleSession = sessionSchema.deriveSessionActivity(record, { now }); + assert.equal(idleSession.activityKind, 'idle'); + assert.match(idleSession.activitySummary, /Recent file activity 45s ago\./); + assert.equal(idleSession.lastFileActivityAt, new Date(now - 45_000).toISOString()); + + setPathMtime(trackedPath, now - (20 * 60 * 1000)); + const stalledSession = sessionSchema.deriveSessionActivity(record, { now }); + assert.equal(stalledSession.activityKind, 'stalled'); + assert.match(stalledSession.activitySummary, /No file activity for 20m 0s\./); + assert.equal(stalledSession.lastFileActivityAt, new Date(now - (20 * 60 * 1000)).toISOString()); +}); + +test('session-schema derives dead activity when the recorded pid is not alive', () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-active-session-dead-')); + const worktreePath = path.join(tempRoot, 'sandbox'); + initGitRepo(worktreePath); + fs.writeFileSync(path.join(worktreePath, 'tracked.txt'), 'base\n', 'utf8'); + runGit(worktreePath, ['add', 'tracked.txt']); + runGit(worktreePath, ['commit', '-m', 'baseline']); + + const session = sessionSchema.deriveSessionActivity(sessionSchema.buildSessionRecord({ + repoRoot: tempRoot, + branch: 'agent/codex/dead-task', + taskName: 'dead-task', + agentName: 'codex', + worktreePath, + pid: 999999, + cliName: 'codex', + })); + + assert.equal(session.activityKind, 'dead'); + assert.equal(session.activitySummary, 'Recorded PID is not alive.'); + assert.equal(session.pidAlive, false); +}); + test('session-schema derives repo change rows from root git status', () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-active-session-root-status-')); initGitRepo(tempRoot); @@ -500,6 +616,7 @@ test('active-agents extension registers tree and decoration providers', async () const context = { subscriptions: [] }; extension.activate(context); + await flushAsyncWork(); assert.equal(registrations.treeViews.length, 1); assert.equal(registrations.sourceControls.length, 1); @@ -512,15 +629,25 @@ test('active-agents extension registers tree and decoration providers', async () assert.equal(registrations.providers.length, 1); assert.equal(registrations.providers[0].viewId, 'gitguardex.activeAgents'); assert.equal(registrations.decorationProviders.length, 1); + assert.equal(registrations.fileWatchers.length, 2); + assert.deepEqual( + registrations.fileWatchers.map((watcher) => watcher.pattern), + [ + '**/.omx/state/active-sessions/*.json', + '**/.omx/state/agent-file-locks.json', + ], + ); + assert.equal(registrations.workspaceFolderListeners.length, 1); const provider = registrations.providers[0].provider; assert.equal(typeof provider.getTreeItem, 'function'); assert.equal(typeof registrations.commands.get('gitguardex.activeAgents.startAgent'), 'function'); const rootItems = await provider.getChildren(); - assert.deepEqual(rootItems, []); + assert.equal(rootItems.length, 1); + assert.equal(rootItems[0].label, 'No active Guardex agents'); assert.equal(registrations.treeViews[0].badge, undefined); - assert.equal(registrations.treeViews[0].message, undefined); + assert.equal(registrations.treeViews[0].message, 'Start a sandbox session to populate this view.'); for (const subscription of context.subscriptions) { subscription.dispose?.(); @@ -559,21 +686,15 @@ test('active-agents extension startAgent command prompts and runs gx branch star test('active-agents extension groups live sessions under a repo node', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-live-view-')); - const sessionPath = sessionSchema.sessionFilePathForBranch(tempRoot, 'agent/codex/live-task'); - fs.mkdirSync(path.dirname(sessionPath), { recursive: true }); - fs.writeFileSync( - sessionPath, - `${JSON.stringify(sessionSchema.buildSessionRecord({ - repoRoot: tempRoot, - branch: 'agent/codex/live-task', - taskName: 'live-task', - agentName: 'codex', - worktreePath: path.join(tempRoot, '.omx', 'agent-worktrees', 'live-task'), - pid: process.pid, - cliName: 'codex', - }), null, 2)}\n`, - 'utf8', - ); + const sessionPath = writeSessionRecord(tempRoot, sessionSchema.buildSessionRecord({ + repoRoot: tempRoot, + branch: 'agent/codex/live-task', + taskName: 'live-task', + agentName: 'codex', + worktreePath: path.join(tempRoot, '.omx', 'agent-worktrees', 'live-task'), + pid: process.pid, + cliName: 'codex', + })); const { registrations, vscode } = createMockVscode(tempRoot); vscode.workspace.findFiles = async () => [{ fsPath: sessionPath }]; @@ -581,6 +702,7 @@ test('active-agents extension groups live sessions under a repo node', async () const context = { subscriptions: [] }; extension.activate(context); + await flushAsyncWork(); const provider = registrations.providers[0].provider; const [repoItem] = await provider.getChildren(); @@ -591,12 +713,12 @@ test('active-agents extension groups live sessions under a repo node', async () assert.equal(agentsSection.label, 'ACTIVE AGENTS'); assert.equal(agentsSection.description, '1'); - const [thinkingSection] = await provider.getChildren(agentsSection); - assert.equal(thinkingSection.label, 'THINKING'); + const [idleSection] = await provider.getChildren(agentsSection); + assert.equal(idleSection.label, 'IDLE'); - const [sessionItem] = await provider.getChildren(thinkingSection); + const [sessionItem] = await provider.getChildren(idleSection); assert.equal(sessionItem.label, 'live-task 馃敀 0'); - assert.match(sessionItem.description, /^thinking 路 \d+[smhd]/); + assert.match(sessionItem.description, /^idle 路 \d+[smhd]/); assert.equal(sessionItem.iconPath.id, 'loading~spin'); assert.equal(sessionItem.resourceUri.scheme, 'gitguardex-agent'); assert.equal( @@ -681,6 +803,7 @@ test('active-agents extension decorates idle clean sessions without overriding w const context = { subscriptions: [] }; extension.activate(context); + await flushAsyncWork(); const provider = registrations.providers[0].provider; await provider.getChildren(); @@ -744,6 +867,7 @@ test('active-agents refresh also invalidates session decorations', async () => { const provider = registrations.providers[0].provider; await provider.getChildren(); + await flushAsyncWork(); let decorationRefreshCount = 0; registrations.decorationProviders[0].onDidChangeFileDecorations(() => { @@ -751,7 +875,7 @@ test('active-agents refresh also invalidates session decorations', async () => { }); await provider.refresh(); - assert.equal(decorationRefreshCount, 1); + assert.ok(decorationRefreshCount >= 1); for (const subscription of context.subscriptions) { subscription.dispose?.(); @@ -766,40 +890,62 @@ test('active-agents extension shows grouped repo changes beside active agents', runGit(tempRoot, ['commit', '-m', 'baseline']); fs.writeFileSync(path.join(tempRoot, 'root-file.txt'), 'base\nchanged\n', 'utf8'); - const worktreePath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-working-session-')); + const worktreePath = path.join(tempRoot, 'sandbox'); initGitRepo(worktreePath); + fs.mkdirSync(path.join(worktreePath, 'src'), { recursive: true }); fs.writeFileSync(path.join(worktreePath, 'tracked.txt'), 'base\n', 'utf8'); + fs.writeFileSync(path.join(worktreePath, 'src', 'nested.js'), 'base\n', 'utf8'); runGit(worktreePath, ['add', 'tracked.txt']); + runGit(worktreePath, ['add', 'src/nested.js']); runGit(worktreePath, ['commit', '-m', 'baseline']); fs.writeFileSync(path.join(worktreePath, 'tracked.txt'), 'base\nchanged\n', 'utf8'); - fs.writeFileSync(path.join(worktreePath, 'new-file.txt'), 'new\n', 'utf8'); + fs.writeFileSync(path.join(worktreePath, 'src', 'nested.js'), 'base\nchanged\n', 'utf8'); - const sessionPath = sessionSchema.sessionFilePathForBranch(tempRoot, 'agent/codex/live-task'); - fs.mkdirSync(path.dirname(sessionPath), { recursive: true }); - fs.writeFileSync( - sessionPath, - `${JSON.stringify(sessionSchema.buildSessionRecord({ - repoRoot: tempRoot, - branch: 'agent/codex/live-task', - taskName: 'live-task', - agentName: 'codex', - worktreePath, - pid: process.pid, - cliName: 'codex', - }), null, 2)}\n`, - 'utf8', - ); + const sessionPath = writeSessionRecord(tempRoot, sessionSchema.buildSessionRecord({ + repoRoot: tempRoot, + branch: 'agent/codex/live-task', + taskName: 'live-task', + agentName: 'codex', + worktreePath, + pid: process.pid, + cliName: 'codex', + })); const { registrations, vscode } = createMockVscode(tempRoot); vscode.workspace.findFiles = async () => [{ fsPath: sessionPath }]; - const extension = loadExtensionWithMockVscode(vscode); + const mockSessionSchema = { + ...sessionSchema, + readActiveSessions: () => sessionSchema.readActiveSessions(tempRoot, { includeStale: true }), + readRepoChanges: () => [ + { + relativePath: 'sandbox/src/nested.js', + absolutePath: path.join(worktreePath, 'src', 'nested.js'), + statusLabel: 'M', + statusText: 'Modified', + }, + { + relativePath: 'sandbox/tracked.txt', + absolutePath: path.join(worktreePath, 'tracked.txt'), + statusLabel: 'M', + statusText: 'Modified', + }, + { + relativePath: 'root-file.txt', + absolutePath: path.join(tempRoot, 'root-file.txt'), + statusLabel: 'M', + statusText: 'Modified', + }, + ], + }; + const extension = loadExtensionWithMockVscode(vscode, mockSessionSchema); const context = { subscriptions: [] }; extension.activate(context); + await flushAsyncWork(); const provider = registrations.providers[0].provider; const [repoItem] = await provider.getChildren(); - assert.equal(repoItem.description, '1 active 路 1 working 路 1 changed'); + assert.equal(repoItem.description, '1 active 路 1 working 路 3 changed'); const [agentsSection, changesSection] = await provider.getChildren(repoItem); assert.equal(agentsSection.label, 'ACTIVE AGENTS'); assert.equal(changesSection.label, 'CHANGES'); @@ -810,17 +956,31 @@ test('active-agents extension shows grouped repo changes beside active agents', const [sessionItem] = await provider.getChildren(workingSection); assert.equal(sessionItem.label, `${path.basename(worktreePath)} 馃敀 0`); assert.match(sessionItem.description, /^working 路 2 files 路 /); - assert.match(sessionItem.tooltip, /Changed 2 files: new-file\.txt, tracked\.txt/); + assert.match(sessionItem.tooltip, /Changed 2 files: src\/nested\.js, tracked\.txt/); assert.equal(sessionItem.iconPath.id, 'edit'); assert.deepEqual(registrations.treeViews[0].badge, { value: 1, tooltip: '1 active agent 路 1 working now', }); - const [changeItem] = await provider.getChildren(changesSection); - assert.equal(changeItem.label, 'root-file.txt'); - assert.equal(changeItem.description, 'M'); - assert.match(changeItem.tooltip, /Status Modified/); + const [sessionGroup, repoRootGroup] = await provider.getChildren(changesSection); + assert.equal(sessionGroup.label, `${path.basename(worktreePath)} 馃敀 0`); + assert.match(sessionGroup.description, /^working 路 2 files 路 /); + assert.equal(repoRootGroup.label, 'Repo root'); + + const [folderItem, trackedItem] = await provider.getChildren(sessionGroup); + assert.equal(folderItem.label, 'src'); + assert.equal(trackedItem.label, 'tracked.txt'); + assert.match(trackedItem.tooltip, /^tracked\.txt\nStatus Modified\n/); + + const [nestedItem] = await provider.getChildren(folderItem); + assert.equal(nestedItem.label, 'nested.js'); + assert.match(nestedItem.tooltip, /^src\/nested\.js\nStatus Modified\n/); + + const [rootItem] = await provider.getChildren(repoRootGroup); + assert.equal(rootItem.label, 'root-file.txt'); + assert.equal(rootItem.description, 'M'); + assert.match(rootItem.tooltip, /^root-file\.txt\nStatus Modified\n/); for (const subscription of context.subscriptions) { subscription.dispose?.(); @@ -881,16 +1041,18 @@ test('active-agents extension decorates sessions and repo changes from the lock const context = { subscriptions: [] }; extension.activate(context); + await flushAsyncWork(); const provider = registrations.providers[0].provider; const [repoItem] = await provider.getChildren(); const [agentsSection, changesSection] = await provider.getChildren(repoItem); - const [thinkingSection] = await provider.getChildren(agentsSection); - const [sessionItem] = await provider.getChildren(thinkingSection); + const [idleSection] = await provider.getChildren(agentsSection); + const [sessionItem] = await provider.getChildren(idleSection); assert.equal(sessionItem.label, `${path.basename(worktreePath)} 馃敀 1`); assert.match(sessionItem.tooltip, /Locks 1/); - const [changeItem] = await provider.getChildren(changesSection); + 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/); @@ -953,6 +1115,7 @@ test('active-agents extension re-reads lock state on watcher events instead of e try { extension.activate(context); + await flushAsyncWork(); const provider = registrations.providers[0].provider; const lockWatcher = registrations.watchers.find((watcher) => watcher.pattern === '**/.omx/state/agent-file-locks.json'); @@ -960,8 +1123,8 @@ test('active-agents extension re-reads lock state on watcher events instead of e const [repoItem] = await provider.getChildren(); const [agentsSection] = await provider.getChildren(repoItem); - const [thinkingSection] = await provider.getChildren(agentsSection); - const [sessionItem] = await provider.getChildren(thinkingSection); + const [idleSection] = await provider.getChildren(agentsSection); + const [sessionItem] = await provider.getChildren(idleSection); assert.equal(sessionItem.label, `${path.basename(worktreePath)} 馃敀 1`); assert.equal(lockReadCount, 1); @@ -987,8 +1150,8 @@ test('active-agents extension re-reads lock state on watcher events instead of e const [updatedRepoItem] = await provider.getChildren(); const [updatedAgentsSection] = await provider.getChildren(updatedRepoItem); - const [updatedThinkingSection] = await provider.getChildren(updatedAgentsSection); - const [updatedSessionItem] = await provider.getChildren(updatedThinkingSection); + const [updatedIdleSection] = await provider.getChildren(updatedAgentsSection); + const [updatedSessionItem] = await provider.getChildren(updatedIdleSection); assert.equal(updatedSessionItem.label, `${path.basename(worktreePath)} 馃敀 2`); await provider.getChildren(); @@ -1001,79 +1164,134 @@ test('active-agents extension re-reads lock state on watcher events instead of e } }); -test('active-agents extension splits working and thinking sessions into separate groups', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-mixed-view-')); +test('active-agents extension groups blocked, working, idle, stalled, and dead sessions in order', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-state-groups-')); + const now = Date.now(); + + const blockedPath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-blocked-')); + initGitRepo(blockedPath); + fs.writeFileSync(path.join(blockedPath, 'tracked.txt'), 'base\n', 'utf8'); + runGit(blockedPath, ['add', 'tracked.txt']); + runGit(blockedPath, ['commit', '-m', 'baseline']); + fs.writeFileSync(path.join(blockedPath, '.git', 'MERGE_HEAD'), 'deadbeef\n', 'utf8'); - const workingPath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-mixed-working-')); + const workingPath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-working-')); initGitRepo(workingPath); fs.writeFileSync(path.join(workingPath, 'tracked.txt'), 'base\n', 'utf8'); runGit(workingPath, ['add', 'tracked.txt']); runGit(workingPath, ['commit', '-m', 'baseline']); fs.writeFileSync(path.join(workingPath, 'tracked.txt'), 'base\nchanged\n', 'utf8'); - const thinkingPath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-mixed-thinking-')); - initGitRepo(thinkingPath); - fs.writeFileSync(path.join(thinkingPath, 'tracked.txt'), 'base\n', 'utf8'); - runGit(thinkingPath, ['add', 'tracked.txt']); - runGit(thinkingPath, ['commit', '-m', 'baseline']); - - const workingSessionPath = sessionSchema.sessionFilePathForBranch(tempRoot, 'agent/codex/working-task'); - fs.mkdirSync(path.dirname(workingSessionPath), { recursive: true }); - fs.writeFileSync( - workingSessionPath, - `${JSON.stringify(sessionSchema.buildSessionRecord({ - repoRoot: tempRoot, - branch: 'agent/codex/working-task', - taskName: 'working-task', - agentName: 'codex', - worktreePath: workingPath, - pid: process.pid, - cliName: 'codex', - }), null, 2)}\n`, - 'utf8', - ); - - const thinkingSessionPath = sessionSchema.sessionFilePathForBranch(tempRoot, 'agent/codex/thinking-task'); - fs.writeFileSync( - thinkingSessionPath, - `${JSON.stringify(sessionSchema.buildSessionRecord({ - repoRoot: tempRoot, - branch: 'agent/codex/thinking-task', - taskName: 'thinking-task', - agentName: 'codex', - worktreePath: thinkingPath, - pid: process.pid, - cliName: 'codex', - }), null, 2)}\n`, - 'utf8', - ); + const idlePath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-idle-')); + initGitRepo(idlePath); + fs.writeFileSync(path.join(idlePath, 'tracked.txt'), 'base\n', 'utf8'); + runGit(idlePath, ['add', 'tracked.txt']); + runGit(idlePath, ['commit', '-m', 'baseline']); + setPathMtime(path.join(idlePath, 'tracked.txt'), now - 30_000); + + const stalledPath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-stalled-')); + initGitRepo(stalledPath); + fs.writeFileSync(path.join(stalledPath, 'tracked.txt'), 'base\n', 'utf8'); + runGit(stalledPath, ['add', 'tracked.txt']); + runGit(stalledPath, ['commit', '-m', 'baseline']); + setPathMtime(path.join(stalledPath, 'tracked.txt'), now - (20 * 60 * 1000)); + + const deadPath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-dead-')); + initGitRepo(deadPath); + fs.writeFileSync(path.join(deadPath, 'tracked.txt'), 'base\n', 'utf8'); + runGit(deadPath, ['add', 'tracked.txt']); + runGit(deadPath, ['commit', '-m', 'baseline']); + + const blockedSessionPath = writeSessionRecord(tempRoot, sessionSchema.buildSessionRecord({ + repoRoot: tempRoot, + branch: 'agent/codex/blocked-task', + taskName: 'blocked-task', + agentName: 'codex', + worktreePath: blockedPath, + pid: process.pid, + cliName: 'codex', + })); + const workingSessionPath = writeSessionRecord(tempRoot, sessionSchema.buildSessionRecord({ + repoRoot: tempRoot, + branch: 'agent/codex/working-task', + taskName: 'working-task', + agentName: 'codex', + worktreePath: workingPath, + pid: process.pid, + cliName: 'codex', + })); + const idleSessionPath = writeSessionRecord(tempRoot, sessionSchema.buildSessionRecord({ + repoRoot: tempRoot, + branch: 'agent/codex/idle-task', + taskName: 'idle-task', + agentName: 'codex', + worktreePath: idlePath, + pid: process.pid, + cliName: 'codex', + })); + const stalledSessionPath = writeSessionRecord(tempRoot, sessionSchema.buildSessionRecord({ + repoRoot: tempRoot, + branch: 'agent/codex/stalled-task', + taskName: 'stalled-task', + agentName: 'codex', + worktreePath: stalledPath, + pid: process.pid, + cliName: 'codex', + })); + const deadSessionPath = writeSessionRecord(tempRoot, sessionSchema.buildSessionRecord({ + repoRoot: tempRoot, + branch: 'agent/codex/dead-task', + taskName: 'dead-task', + agentName: 'codex', + worktreePath: deadPath, + pid: 999999, + cliName: 'codex', + })); const { registrations, vscode } = createMockVscode(tempRoot); vscode.workspace.findFiles = async () => [ + { fsPath: blockedSessionPath }, { fsPath: workingSessionPath }, - { fsPath: thinkingSessionPath }, + { fsPath: idleSessionPath }, + { fsPath: stalledSessionPath }, + { fsPath: deadSessionPath }, ]; const extension = loadExtensionWithMockVscode(vscode); const context = { subscriptions: [] }; extension.activate(context); + await flushAsyncWork(); const provider = registrations.providers[0].provider; const [repoItem] = await provider.getChildren(); - assert.equal(repoItem.description, '2 active 路 1 working'); + assert.equal(repoItem.description, '4 active 路 1 dead 路 1 working'); const [agentsSection] = await provider.getChildren(repoItem); - const [workingSection, thinkingSection] = await provider.getChildren(agentsSection); + const [blockedSection, workingSection, idleSection, stalledSection, deadSection] = await provider.getChildren(agentsSection); + assert.equal(blockedSection.label, 'BLOCKED'); assert.equal(workingSection.label, 'WORKING NOW'); - assert.equal(thinkingSection.label, 'THINKING'); + assert.equal(idleSection.label, 'IDLE'); + assert.equal(stalledSection.label, 'STALLED'); + assert.equal(deadSection.label, 'DEAD'); + const [blockedItem] = await provider.getChildren(blockedSection); const [workingItem] = await provider.getChildren(workingSection); - const [thinkingItem] = await provider.getChildren(thinkingSection); + const [idleItem] = await provider.getChildren(idleSection); + const [stalledItem] = await provider.getChildren(stalledSection); + const [deadItem] = await provider.getChildren(deadSection); + assert.match(blockedItem.description, /^blocked 路 \d+[smhd]/); + assert.equal(blockedItem.iconPath.id, 'warning'); assert.match(workingItem.description, /^working 路 1 file 路 /); - assert.match(thinkingItem.description, /^thinking 路 \d+[smhd]/); + assert.equal(workingItem.iconPath.id, 'edit'); + assert.match(idleItem.description, /^idle 路 \d+[smhd]/); + assert.equal(idleItem.iconPath.id, 'loading~spin'); + assert.match(stalledItem.description, /^stalled 路 \d+[smhd]/); + assert.equal(stalledItem.iconPath.id, 'clock'); + assert.match(deadItem.description, /^dead 路 \d+[smhd]/); + assert.equal(deadItem.iconPath.id, 'error'); assert.deepEqual(registrations.treeViews[0].badge, { - value: 2, - tooltip: '2 active agents 路 1 working now', + value: 5, + tooltip: '4 active agents 路 1 dead 路 1 working now', }); for (const subscription of context.subscriptions) { @@ -1081,6 +1299,78 @@ test('active-agents extension splits working and thinking sessions into separate } }); +test('active-agents extension watches active sessions, lock files, and session git indexes', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-watchers-')); + const worktreePath = path.join(tempRoot, 'sandbox'); + initGitRepo(worktreePath); + + const sessionPath = writeSessionRecord(tempRoot, sessionSchema.buildSessionRecord({ + repoRoot: tempRoot, + branch: 'agent/codex/watch-task', + taskName: 'watch-task', + agentName: 'codex', + worktreePath, + pid: process.pid, + cliName: 'codex', + })); + + const { registrations, vscode } = createMockVscode(tempRoot); + let currentSessionFiles = [{ fsPath: sessionPath }]; + vscode.workspace.findFiles = async () => currentSessionFiles; + const extension = loadExtensionWithMockVscode(vscode); + const context = { subscriptions: [] }; + + extension.activate(context); + await flushAsyncWork(); + + assert.deepEqual( + registrations.fileWatchers.map((watcher) => watcher.pattern), + [ + '**/.omx/state/active-sessions/*.json', + '**/.omx/state/agent-file-locks.json', + path.join(worktreePath, '.git', 'index'), + ], + ); + + currentSessionFiles = []; + fs.unlinkSync(sessionPath); + registrations.fileWatchers[0].fireDelete({ fsPath: sessionPath }); + await new Promise((resolve) => setTimeout(resolve, 350)); + await flushAsyncWork(); + + assert.equal(registrations.fileWatchers[2].disposed, true); + + for (const subscription of context.subscriptions) { + subscription.dispose?.(); + } +}); + +test('active-agents extension debounces refresh events with a trailing 250ms timer', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-debounce-')); + const { registrations, vscode } = createMockVscode(tempRoot); + const extension = loadExtensionWithMockVscode(vscode); + const context = { subscriptions: [] }; + + extension.activate(context); + await flushAsyncWork(); + + const provider = registrations.providers[0].provider; + provider.onDidChangeTreeDataEmitter.fireCount = 0; + + registrations.fileWatchers[0].fireChange({ fsPath: path.join(tempRoot, '.omx', 'state', 'active-sessions', 'a.json') }); + registrations.fileWatchers[1].fireChange({ fsPath: path.join(tempRoot, '.omx', 'state', 'agent-file-locks.json') }); + assert.equal(provider.onDidChangeTreeDataEmitter.fireCount, 0); + + await new Promise((resolve) => setTimeout(resolve, 300)); + await flushAsyncWork(); + + assert.equal(provider.onDidChangeTreeDataEmitter.fireCount, 1); + + for (const subscription of context.subscriptions) { + subscription.dispose?.(); + } +}); + test('active-agents extension commits the selected session worktree from the SCM input', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-commit-view-')); const worktreePath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-commit-session-')); @@ -1118,6 +1408,7 @@ test('active-agents extension commits the selected session worktree from the SCM const context = { subscriptions: [] }; extension.activate(context); + await flushAsyncWork(); const provider = registrations.providers[0].provider; const [repoItem] = await provider.getChildren(); @@ -1199,12 +1490,13 @@ test('active-agents extension launches finish and sync commands in session termi const context = { subscriptions: [] }; extension.activate(context); + await flushAsyncWork(); const provider = registrations.providers[0].provider; const [repoItem] = await provider.getChildren(); const [agentsSection] = await provider.getChildren(repoItem); - const [thinkingSection] = await provider.getChildren(agentsSection); - const [sessionItem] = await provider.getChildren(thinkingSection); + const [idleSection] = await provider.getChildren(agentsSection); + const [sessionItem] = await provider.getChildren(idleSection); await registrations.commands.get('gitguardex.activeAgents.finishSession')(sessionItem.session); await registrations.commands.get('gitguardex.activeAgents.syncSession')(sessionItem.session); @@ -1236,7 +1528,6 @@ test('active-agents extension confirms stop and sends SIGTERM to the session pid const { registrations, vscode } = createMockVscode(tempRoot); const extension = loadExtensionWithMockVscode(vscode); const context = { subscriptions: [] }; - let refreshCount = 0; let killed = null; const originalKill = process.kill; @@ -1251,22 +1542,20 @@ test('active-agents extension confirms stop and sends SIGTERM to the session pid try { extension.activate(context); const provider = registrations.providers[0].provider; - const originalRefresh = provider.refresh.bind(provider); - provider.refresh = () => { - refreshCount += 1; - return originalRefresh(); - }; + await flushAsyncWork(); + provider.onDidChangeTreeDataEmitter.fireCount = 0; await registrations.commands.get('gitguardex.activeAgents.stopSession')({ label: 'live-task', pid: 4242, }); + await flushAsyncWork(); } finally { process.kill = originalKill; } assert.deepEqual(killed, { pid: 4242, signal: 'SIGTERM' }); - assert.equal(refreshCount, 1); + 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 23228d9..0cd0b42 100644 --- a/vscode/guardex-active-agents/README.md +++ b/vscode/guardex-active-agents/README.md @@ -19,9 +19,9 @@ What it does: - 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 `WORKING NOW` and `THINKING` groups so active edit lanes stand out immediately. +- 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. - Shows repo-root git changes in a sibling `CHANGES` section when the guarded repo itself is dirty. -- Derives `thinking` versus `working` from the live sandbox worktree, surfaces working counts in the repo/header summary, and shows changed-file counts for active edits. -- Uses VS Code's native animated `loading~spin` icon for the running-state affordance. +- 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/`. diff --git a/vscode/guardex-active-agents/extension.js b/vscode/guardex-active-agents/extension.js index 623f107..4f99bf1 100644 --- a/vscode/guardex-active-agents/extension.js +++ b/vscode/guardex-active-agents/extension.js @@ -13,13 +13,57 @@ const SESSION_DECORATION_SCHEME = 'gitguardex-agent'; const IDLE_WARNING_MS = 10 * 60 * 1000; const IDLE_ERROR_MS = 30 * 60 * 1000; const LOCK_FILE_RELATIVE = path.join('.omx', 'state', 'agent-file-locks.json'); +const ACTIVE_SESSION_FILES_GLOB = '**/.omx/state/active-sessions/*.json'; +const AGENT_FILE_LOCKS_GLOB = '**/.omx/state/agent-file-locks.json'; +const SESSION_SCAN_EXCLUDE_GLOB = '**/{node_modules,.git,.omx/agent-worktrees,.omc/agent-worktrees}/**'; +const SESSION_SCAN_LIMIT = 200; +const REFRESH_DEBOUNCE_MS = 250; +const SESSION_ACTIVITY_GROUPS = [ + { kind: 'blocked', label: 'BLOCKED' }, + { kind: 'working', label: 'WORKING NOW' }, + { kind: 'idle', label: 'IDLE' }, + { kind: 'stalled', label: 'STALLED' }, + { kind: 'dead', label: 'DEAD' }, +]; +const SESSION_ACTIVITY_ICON_IDS = { + blocked: 'warning', + working: 'edit', + idle: 'loading~spin', + stalled: 'clock', + dead: 'error', +}; function sessionDecorationUri(branch) { return vscode.Uri.parse(`${SESSION_DECORATION_SCHEME}://${sanitizeBranchForFile(branch)}`); } function sessionIdleDecoration(session, now = Date.now()) { - if (!session || session.activityKind === 'working') { + if (!session) { + return undefined; + } + + if (session.activityKind === 'blocked') { + return { + badge: '!', + tooltip: 'blocked', + color: new vscode.ThemeColor('list.warningForeground'), + }; + } + if (session.activityKind === 'dead') { + return { + badge: 'x', + tooltip: 'dead', + color: new vscode.ThemeColor('list.errorForeground'), + }; + } + if (session.activityKind === 'stalled') { + return { + badge: '!', + tooltip: 'stalled', + color: new vscode.ThemeColor('list.errorForeground'), + }; + } + if (session.activityKind === 'working') { return undefined; } @@ -74,14 +118,30 @@ class SessionDecorationProvider { } } +class InfoItem extends vscode.TreeItem { + constructor(label, description = '') { + super(label, vscode.TreeItemCollapsibleState.None); + this.description = description; + this.iconPath = new vscode.ThemeIcon('info'); + } +} + class RepoItem extends vscode.TreeItem { constructor(repoRoot, sessions, changes) { super(path.basename(repoRoot), vscode.TreeItemCollapsibleState.Expanded); this.repoRoot = repoRoot; this.sessions = sessions; this.changes = changes; - const descriptionParts = [`${sessions.length} active`]; + const descriptionParts = []; + const activeCount = countActiveSessions(sessions); + const deadCount = countSessionsByActivityKind(sessions, 'dead'); const workingCount = countWorkingSessions(sessions); + if (activeCount > 0) { + descriptionParts.push(`${activeCount} active`); + } + if (deadCount > 0) { + descriptionParts.push(`${deadCount} dead`); + } if (workingCount > 0) { descriptionParts.push(`${workingCount} working`); } @@ -109,10 +169,14 @@ class SectionItem extends vscode.TreeItem { } class SessionItem extends vscode.TreeItem { - constructor(session) { + constructor(session, items = []) { const lockCount = Number.isFinite(session.lockCount) ? session.lockCount : 0; - super(`${session.label} 馃敀 ${lockCount}`, vscode.TreeItemCollapsibleState.None); + super( + `${session.label} 馃敀 ${lockCount}`, + items.length > 0 ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.None, + ); this.session = session; + this.items = items; this.resourceUri = sessionDecorationUri(session.branch); const descriptionParts = [session.activityLabel || 'thinking']; if (session.activityCountLabel) { @@ -128,13 +192,13 @@ class SessionItem extends vscode.TreeItem { ? `Changed ${session.activityCountLabel}: ${session.activitySummary}` : session.activitySummary, `Locks ${lockCount}`, + session.pidAlive === false ? `PID ${session.pid} not alive` : `PID ${session.pid} alive`, + session.lastFileActivityAt ? `Last file activity ${session.lastFileActivityAt}` : '', `Started ${session.startedAt}`, session.worktreePath, ]; this.tooltip = tooltipLines.filter(Boolean).join('\n'); - this.iconPath = session.activityKind === 'working' - ? new vscode.ThemeIcon('edit') - : new vscode.ThemeIcon('loading~spin'); + this.iconPath = new vscode.ThemeIcon(resolveSessionActivityIconId(session.activityKind)); this.contextValue = 'gitguardex.session'; this.command = { command: 'gitguardex.activeAgents.openWorktree', @@ -306,7 +370,7 @@ function repoRootFromLockFile(filePath) { } function normalizeRelativePath(relativePath) { - return String(relativePath || '').replace(/\\/g, '/'); + return String(relativePath || '').replace(/\\/g, '/').replace(/^\.\//, ''); } function emptyLockRegistry() { @@ -389,6 +453,104 @@ function decorateChange(change, lockRegistry, owningBranch) { }; } +function isPathWithin(parentPath, targetPath) { + const relativePath = path.relative(parentPath, targetPath); + return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath)); +} + +function localizeChangeForSession(session, change) { + if (!change?.absolutePath || !isPathWithin(session.worktreePath, change.absolutePath)) { + return null; + } + + let originalPath = change.originalPath; + if (originalPath) { + const originalAbsolutePath = path.join(session.repoRoot, originalPath); + if (isPathWithin(session.worktreePath, originalAbsolutePath)) { + originalPath = normalizeRelativePath(path.relative(session.worktreePath, originalAbsolutePath)); + } + } + + return { + ...change, + relativePath: normalizeRelativePath(path.relative(session.worktreePath, change.absolutePath)), + originalPath, + }; +} + +async function findRepoSessionEntries() { + const sessionFiles = await vscode.workspace.findFiles( + ACTIVE_SESSION_FILES_GLOB, + SESSION_SCAN_EXCLUDE_GLOB, + SESSION_SCAN_LIMIT, + ); + + const repoRoots = new Set(); + for (const uri of sessionFiles) { + repoRoots.add(repoRootFromSessionFile(uri.fsPath)); + } + + if (repoRoots.size === 0) { + for (const workspaceFolder of vscode.workspace.workspaceFolders || []) { + repoRoots.add(workspaceFolder.uri.fsPath); + } + } + + const repoEntries = []; + for (const repoRoot of repoRoots) { + const sessions = readActiveSessions(repoRoot, { includeStale: true }); + if (sessions.length > 0) { + repoEntries.push({ repoRoot, sessions }); + } + } + + repoEntries.sort((left, right) => left.repoRoot.localeCompare(right.repoRoot)); + return repoEntries; +} + +function resolveSessionWatcherKey(session) { + return `${path.resolve(session.repoRoot)}::${session.branch}::${path.resolve(session.worktreePath)}`; +} + +function resolveSessionGitIndexPath(worktreePath) { + const gitPath = path.join(worktreePath, '.git'); + const defaultIndexPath = path.join(gitPath, 'index'); + + try { + if (fs.statSync(gitPath).isDirectory()) { + return defaultIndexPath; + } + } catch (_error) { + return defaultIndexPath; + } + + try { + const gitPointer = fs.readFileSync(gitPath, 'utf8'); + const match = gitPointer.match(/^gitdir:\s*(.+)$/m); + if (match?.[1]) { + return path.resolve(worktreePath, match[1].trim(), 'index'); + } + } catch (_error) { + return defaultIndexPath; + } + + return defaultIndexPath; +} + +function bindRefreshWatcher(watcher, refresh) { + return [ + watcher.onDidCreate(refresh), + watcher.onDidChange(refresh), + watcher.onDidDelete(refresh), + ]; +} + +function disposeAll(disposables) { + for (const disposable of disposables) { + disposable?.dispose?.(); + } +} + function buildChangeTreeNodes(changes) { const root = []; @@ -454,9 +616,67 @@ function countWorkingSessions(sessions) { return sessions.filter((session) => session.activityKind === 'working').length; } -<<<<<<< HEAD -function shellQuote(value) { - return `'${String(value).replace(/'/g, `'\\''`)}'`; +function buildGroupedChangeTreeNodes(sessions, changes) { + const changesBySession = new Map(); + const sessionByChangedPath = new Map(); + const repoRootChanges = []; + + for (const session of sessions) { + changesBySession.set(session.branch, []); + for (const changedPath of session.changedPaths || []) { + if (!sessionByChangedPath.has(changedPath)) { + sessionByChangedPath.set(changedPath, session); + } + } + } + + for (const change of changes) { + const normalizedRelativePath = normalizeRelativePath(change.relativePath); + const session = sessionByChangedPath.get(normalizedRelativePath) + || sessions.find((candidate) => isPathWithin(candidate.worktreePath, change.absolutePath)); + if (!session) { + repoRootChanges.push(change); + continue; + } + + const localizedChange = localizeChangeForSession(session, change); + if (!localizedChange) { + repoRootChanges.push(change); + continue; + } + + changesBySession.get(session.branch).push(localizedChange); + } + + const items = sessions + .map((session) => { + const sessionChanges = changesBySession.get(session.branch) || []; + if (sessionChanges.length === 0) { + return null; + } + return new SessionItem(session, buildChangeTreeNodes(sessionChanges)); + }) + .filter(Boolean); + + if (repoRootChanges.length > 0) { + items.push(new SectionItem('Repo root', buildChangeTreeNodes(repoRootChanges), { + description: String(repoRootChanges.length), + })); + } + + return items; +} + +function countActiveSessions(sessions) { + return sessions.filter((session) => session.activityKind !== 'dead').length; +} + +function countSessionsByActivityKind(sessions, activityKind) { + return sessions.filter((session) => session.activityKind === activityKind).length; +} + +function resolveSessionActivityIconId(activityKind) { + return SESSION_ACTIVITY_ICON_IDS[activityKind] || 'loading~spin'; } async function pickRepoRoot() { @@ -530,7 +750,8 @@ async function startAgentFromPrompt(refresh) { true, ); refresh(); -======= +} + function sessionSelectionKey(session) { if (!session?.repoRoot || !session?.branch) { return ''; @@ -561,23 +782,17 @@ function stageWorktreeForCommit(worktreePath) { function commitWorktree(worktreePath, message) { runGitCommand(worktreePath, ['commit', '-m', message]); ->>>>>>> 60c38c6 (Let operators commit the selected sandbox from the Active Agents SCM view) } function buildActiveAgentGroupNodes(sessions) { - const workingSessions = sessions - .filter((session) => session.activityKind === 'working') - .map((session) => new SessionItem(session)); - const thinkingSessions = sessions - .filter((session) => session.activityKind !== 'working') - .map((session) => new SessionItem(session)); const groups = []; - - if (workingSessions.length > 0) { - groups.push(new SectionItem('WORKING NOW', workingSessions)); - } - if (thinkingSessions.length > 0) { - groups.push(new SectionItem('THINKING', thinkingSessions)); + for (const group of SESSION_ACTIVITY_GROUPS) { + const groupSessions = sessions + .filter((session) => session.activityKind === group.kind) + .map((session) => new SessionItem(session)); + if (groupSessions.length > 0) { + groups.push(new SectionItem(group.label, groupSessions)); + } } return groups; @@ -601,7 +816,7 @@ class ActiveAgentsProvider { attachTreeView(treeView) { this.treeView = treeView; - this.updateViewState(0, 0); + this.updateViewState(0, 0, 0); treeView.onDidChangeSelection?.((event) => { const sessionItem = event.selection.find((item) => item instanceof SessionItem); this.setSelectedSession(sessionItem?.session || null); @@ -633,19 +848,32 @@ class ActiveAgentsProvider { this.setSelectedSession(nextSession || null); } - updateViewState(sessionCount, workingCount) { + updateViewState(sessionCount, workingCount, deadCount) { if (!this.treeView) { return; } + const activeCount = Math.max(0, sessionCount - deadCount); + const badgeTooltipParts = []; + if (activeCount > 0) { + badgeTooltipParts.push(`${activeCount} active agent${activeCount === 1 ? '' : 's'}`); + } + if (deadCount > 0) { + badgeTooltipParts.push(`${deadCount} dead`); + } + if (workingCount > 0) { + badgeTooltipParts.push(`${workingCount} working now`); + } + this.treeView.badge = sessionCount > 0 ? { value: sessionCount, - tooltip: `${sessionCount} active agent${sessionCount === 1 ? '' : 's'}` - + (workingCount > 0 ? ` 路 ${workingCount} working now` : ''), + tooltip: badgeTooltipParts.join(' 路 '), } : undefined; - this.treeView.message = undefined; + this.treeView.message = sessionCount > 0 + ? undefined + : 'Start a sandbox session to populate this view.'; } async syncRepoEntries() { @@ -655,8 +883,12 @@ class ActiveAgentsProvider { (total, entry) => total + countWorkingSessions(entry.sessions), 0, ); + const deadCount = repoEntries.reduce( + (total, entry) => total + countSessionsByActivityKind(entry.sessions, 'dead'), + 0, + ); - this.updateViewState(sessionCount, workingCount); + this.updateViewState(sessionCount, workingCount, deadCount); this.decorationProvider?.updateSessions(repoEntries.flatMap((entry) => entry.sessions)); return repoEntries; } @@ -689,12 +921,14 @@ class ActiveAgentsProvider { }), ]; if (element.changes.length > 0) { - sectionItems.push(new SectionItem('CHANGES', buildChangeTreeNodes(element.changes))); + sectionItems.push(new SectionItem('CHANGES', buildGroupedChangeTreeNodes(element.sessions, element.changes), { + description: String(element.changes.length), + })); } return sectionItems; } - if (element instanceof SectionItem || element instanceof FolderItem) { + if (element instanceof SectionItem || element instanceof FolderItem || element instanceof SessionItem) { return element.items; } @@ -702,54 +936,100 @@ class ActiveAgentsProvider { this.syncSelectedSession(repoEntries); if (repoEntries.length === 0) { - return []; + return [new InfoItem('No active Guardex agents', 'Open or start a sandbox session.')]; } return repoEntries.map((entry) => new RepoItem(entry.repoRoot, entry.sessions, entry.changes)); } async loadRepoEntries() { - const sessionFiles = await vscode.workspace.findFiles( - '**/.omx/state/active-sessions/*.json', - '**/{node_modules,.git,.omx/agent-worktrees,.omc/agent-worktrees}/**', - 200, - ); + const repoEntries = await findRepoSessionEntries(); + return repoEntries.map((entry) => { + const repoRoot = entry.repoRoot; + const lockRegistry = this.getLockRegistryForRepo(repoRoot); + const currentBranch = readCurrentBranch(repoRoot); + return { + repoRoot, + sessions: entry.sessions.map((session) => decorateSession(session, lockRegistry)), + changes: readRepoChanges(repoRoot).map((change) => ( + decorateChange(change, lockRegistry, currentBranch) + )), + }; + }); + } +} + +class ActiveAgentsRefreshController { + constructor(provider) { + this.provider = provider; + this.refreshTimer = null; + this.sessionWatchers = new Map(); + } - const repoRoots = new Set(); - for (const uri of sessionFiles) { - repoRoots.add(repoRootFromSessionFile(uri.fsPath)); + scheduleRefresh() { + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); } + this.refreshTimer = setTimeout(() => { + this.refreshTimer = null; + void this.refreshNow(); + }, REFRESH_DEBOUNCE_MS); + } - if (repoRoots.size === 0) { - for (const workspaceFolder of vscode.workspace.workspaceFolders || []) { - repoRoots.add(workspaceFolder.uri.fsPath); + async refreshNow() { + await this.syncSessionWatchers(); + await this.provider.refresh(); + } + + async syncSessionWatchers() { + const repoEntries = await findRepoSessionEntries(); + const liveSessionKeys = new Set(); + + for (const entry of repoEntries) { + for (const session of entry.sessions) { + const sessionKey = resolveSessionWatcherKey(session); + liveSessionKeys.add(sessionKey); + if (this.sessionWatchers.has(sessionKey)) { + continue; + } + + const watcher = vscode.workspace.createFileSystemWatcher( + resolveSessionGitIndexPath(session.worktreePath), + ); + const disposables = bindRefreshWatcher(watcher, () => this.scheduleRefresh()); + this.sessionWatchers.set(sessionKey, { watcher, disposables }); } } - const repoEntries = []; - for (const repoRoot of repoRoots) { - const lockRegistry = this.getLockRegistryForRepo(repoRoot); - const sessions = readActiveSessions(repoRoot).map((session) => decorateSession(session, lockRegistry)); - if (sessions.length > 0) { - const currentBranch = readCurrentBranch(repoRoot); - repoEntries.push({ - repoRoot, - sessions, - changes: readRepoChanges(repoRoot).map((change) => ( - decorateChange(change, lockRegistry, currentBranch) - )), - }); + for (const [sessionKey, entry] of this.sessionWatchers) { + if (liveSessionKeys.has(sessionKey)) { + continue; } + + disposeAll(entry.disposables); + entry.watcher.dispose(); + this.sessionWatchers.delete(sessionKey); + } + } + + dispose() { + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + this.refreshTimer = null; } - repoEntries.sort((left, right) => left.repoRoot.localeCompare(right.repoRoot)); - return repoEntries; + for (const entry of this.sessionWatchers.values()) { + disposeAll(entry.disposables); + entry.watcher.dispose(); + } + this.sessionWatchers.clear(); } } function activate(context) { const decorationProvider = new SessionDecorationProvider(); const provider = new ActiveAgentsProvider(decorationProvider); + const refreshController = new ActiveAgentsRefreshController(provider); const treeView = vscode.window.createTreeView('gitguardex.activeAgents', { treeDataProvider: provider, showCollapseAll: true, @@ -759,11 +1039,10 @@ function activate(context) { 'Active Agents Commit', ); provider.attachTreeView(treeView); - const refresh = () => { - void provider.refresh(); - }; - const sessionWatcher = vscode.workspace.createFileSystemWatcher('**/.omx/state/active-sessions/*.json'); - const lockWatcher = vscode.workspace.createFileSystemWatcher('**/.omx/state/agent-file-locks.json'); + const scheduleRefresh = () => refreshController.scheduleRefresh(); + const refresh = () => void refreshController.refreshNow(); + const activeSessionsWatcher = vscode.workspace.createFileSystemWatcher(ACTIVE_SESSION_FILES_GLOB); + const lockWatcher = vscode.workspace.createFileSystemWatcher(AGENT_FILE_LOCKS_GLOB); const updateCommitInput = (session) => { sourceControl.inputBox.enabled = true; sourceControl.inputBox.visible = true; @@ -815,7 +1094,7 @@ function activate(context) { if (uri?.fsPath) { provider.refreshLockRegistryForFile(uri.fsPath); } - refresh(); + scheduleRefresh(); }; provider.onDidChangeSelectedSession(updateCommitInput); @@ -823,6 +1102,7 @@ function activate(context) { context.subscriptions.push( treeView, sourceControl, + refreshController, vscode.window.registerFileDecorationProvider(decorationProvider), vscode.commands.registerCommand('gitguardex.activeAgents.startAgent', () => startAgentFromPrompt(refresh)), vscode.commands.registerCommand('gitguardex.activeAgents.refresh', refresh), @@ -854,18 +1134,17 @@ function activate(context) { vscode.commands.registerCommand('gitguardex.activeAgents.syncSession', syncSession), vscode.commands.registerCommand('gitguardex.activeAgents.stopSession', (session) => stopSession(session, refresh)), vscode.commands.registerCommand('gitguardex.activeAgents.openSessionDiff', openSessionDiff), - vscode.workspace.onDidChangeWorkspaceFolders(refresh), - sessionWatcher, + vscode.workspace.onDidChangeWorkspaceFolders(scheduleRefresh), + activeSessionsWatcher, lockWatcher, { dispose: () => clearInterval(interval) }, ); - sessionWatcher.onDidCreate(refresh, undefined, context.subscriptions); - sessionWatcher.onDidChange(refresh, undefined, context.subscriptions); - sessionWatcher.onDidDelete(refresh, undefined, context.subscriptions); - lockWatcher.onDidCreate(refreshLockRegistry, undefined, context.subscriptions); - lockWatcher.onDidChange(refreshLockRegistry, undefined, context.subscriptions); - lockWatcher.onDidDelete(refreshLockRegistry, undefined, context.subscriptions); + context.subscriptions.push( + ...bindRefreshWatcher(activeSessionsWatcher, scheduleRefresh), + ...bindRefreshWatcher(lockWatcher, refreshLockRegistry), + ); + void refreshController.refreshNow(); } function deactivate() {} diff --git a/vscode/guardex-active-agents/session-schema.js b/vscode/guardex-active-agents/session-schema.js index 98390bf..e9282a0 100644 --- a/vscode/guardex-active-agents/session-schema.js +++ b/vscode/guardex-active-agents/session-schema.js @@ -8,6 +8,22 @@ const LOCK_FILE_RELATIVE = path.join('.omx', 'state', 'agent-file-locks.json'); 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 IDLE_ACTIVITY_WINDOW_MS = 2 * 60 * 1000; +const STALLED_ACTIVITY_WINDOW_MS = 15 * 60 * 1000; +const BLOCKING_GIT_STATES = [ + { + label: 'Rebase in progress.', + markers: ['REBASE_HEAD', 'rebase-apply', 'rebase-merge'], + }, + { + label: 'Merge in progress.', + markers: ['MERGE_HEAD'], + }, + { + label: 'Cherry-pick in progress.', + markers: ['CHERRY_PICK_HEAD'], + }, +]; function toNonEmptyString(value, fallback = '') { const normalized = typeof value === 'string' ? value.trim() : String(value || '').trim(); @@ -46,6 +62,10 @@ function splitOutputLines(output) { .filter((line) => line.trim().length > 0); } +function normalizeRelativePath(value) { + return toNonEmptyString(value).replace(/\\/g, '/').replace(/^\.\//, ''); +} + function runGitLines(worktreePath, args) { try { const output = cp.execFileSync('git', ['-C', worktreePath, ...args], { @@ -184,37 +204,187 @@ function collectWorktreeChangedPaths(worktreePath) { .sort((left, right) => left.localeCompare(right)); } -function deriveSessionActivity(session) { - const changedPaths = collectWorktreeChangedPaths(session.worktreePath); - if (!changedPaths) { +function resolveWorktreeGitDir(worktreePath) { + const gitPath = path.join(path.resolve(worktreePath), '.git'); + try { + if (fs.statSync(gitPath).isDirectory()) { + return gitPath; + } + } catch (_error) { + return null; + } + + try { + const gitPointer = fs.readFileSync(gitPath, 'utf8'); + const match = gitPointer.match(/^gitdir:\s*(.+)$/m); + if (match?.[1]) { + return path.resolve(worktreePath, match[1].trim()); + } + } catch (_error) { + return null; + } + + return null; +} + +function deriveBlockingGitLabel(worktreePath) { + const gitDir = resolveWorktreeGitDir(worktreePath); + if (!gitDir) { + return ''; + } + + for (const blockingState of BLOCKING_GIT_STATES) { + if (blockingState.markers.some((marker) => fs.existsSync(path.join(gitDir, marker)))) { + return blockingState.label; + } + } + + return ''; +} + +function collectWorktreeTrackedPaths(worktreePath) { + const trackedPaths = runGitLines(worktreePath, ['ls-files', '--cached', '--others', '--exclude-standard']); + if (!trackedPaths) { + return null; + } + + return [...new Set(trackedPaths)] + .filter(Boolean) + .sort((left, right) => left.localeCompare(right)); +} + +function deriveLatestWorktreeFileActivity(worktreePath) { + const trackedPaths = collectWorktreeTrackedPaths(worktreePath); + if (!trackedPaths) { + return null; + } + + let latestMtimeMs = null; + for (const relativePath of trackedPaths) { + const absolutePath = path.join(worktreePath, relativePath); + try { + const stats = fs.statSync(absolutePath); + if (!stats.isFile() || !Number.isFinite(stats.mtimeMs)) { + continue; + } + latestMtimeMs = latestMtimeMs === null + ? stats.mtimeMs + : Math.max(latestMtimeMs, stats.mtimeMs); + } catch (_error) { + continue; + } + } + + return latestMtimeMs; +} + +function deriveSessionActivity(session, options = {}) { + const now = Number.isFinite(options.now) ? options.now : Date.now(); + const blockingLabel = deriveBlockingGitLabel(session.worktreePath); + if (blockingLabel) { return { - activityKind: 'thinking', - activityLabel: 'thinking', + activityKind: 'blocked', + activityLabel: 'blocked', + activityCountLabel: '', + activitySummary: blockingLabel, + changeCount: 0, + changedPaths: [], + pidAlive: isPidAlive(session.pid), + lastFileActivityAt: '', + lastFileActivityLabel: '', + }; + } + + const pidAlive = isPidAlive(session.pid); + if (!pidAlive) { + return { + activityKind: 'dead', + activityLabel: 'dead', + activityCountLabel: '', + activitySummary: 'Recorded PID is not alive.', + changeCount: 0, + changedPaths: [], + pidAlive, + lastFileActivityAt: '', + lastFileActivityLabel: '', + }; + } + + const worktreeChangedPaths = collectWorktreeChangedPaths(session.worktreePath); + if (!worktreeChangedPaths) { + return { + activityKind: 'idle', + activityLabel: 'idle', activityCountLabel: '', activitySummary: 'Worktree activity unavailable.', changeCount: 0, changedPaths: [], + pidAlive, + lastFileActivityAt: '', + lastFileActivityLabel: '', }; } - if (changedPaths.length === 0) { + if (worktreeChangedPaths.length > 0) { + const changedPaths = [...new Set(worktreeChangedPaths + .map((relativePath) => normalizeRelativePath( + path.relative(session.repoRoot, path.resolve(session.worktreePath, relativePath)), + )) + .filter(Boolean))] + .sort((left, right) => left.localeCompare(right)); + + return { + activityKind: 'working', + activityLabel: 'working', + activityCountLabel: formatFileCount(worktreeChangedPaths.length), + activitySummary: previewChangedPaths(worktreeChangedPaths), + changeCount: worktreeChangedPaths.length, + changedPaths, + pidAlive, + lastFileActivityAt: '', + lastFileActivityLabel: '', + }; + } + + const latestFileActivityMs = deriveLatestWorktreeFileActivity(session.worktreePath); + const lastFileActivityAt = Number.isFinite(latestFileActivityMs) + ? new Date(latestFileActivityMs).toISOString() + : ''; + const lastFileActivityLabel = lastFileActivityAt + ? formatElapsedFrom(lastFileActivityAt, now) + : ''; + const lastFileActivityAgeMs = Number.isFinite(latestFileActivityMs) + ? Math.max(0, now - latestFileActivityMs) + : null; + + if (lastFileActivityAgeMs !== null && lastFileActivityAgeMs > STALLED_ACTIVITY_WINDOW_MS) { return { - activityKind: 'thinking', - activityLabel: 'thinking', + activityKind: 'stalled', + activityLabel: 'stalled', activityCountLabel: '', - activitySummary: 'Worktree clean.', + activitySummary: `Worktree clean. No file activity for ${lastFileActivityLabel}.`, changeCount: 0, changedPaths: [], + pidAlive, + lastFileActivityAt, + lastFileActivityLabel, }; } return { - activityKind: 'working', - activityLabel: 'working', - activityCountLabel: formatFileCount(changedPaths.length), - activitySummary: previewChangedPaths(changedPaths), - changeCount: changedPaths.length, - changedPaths, + activityKind: 'idle', + activityLabel: 'idle', + activityCountLabel: '', + activitySummary: lastFileActivityAgeMs !== null && lastFileActivityAgeMs <= IDLE_ACTIVITY_WINDOW_MS + ? `Worktree clean. Recent file activity ${lastFileActivityLabel} ago.` + : lastFileActivityLabel + ? `Worktree clean. Last file activity ${lastFileActivityLabel} ago.` + : 'Worktree clean.', + changeCount: 0, + changedPaths: [], + pidAlive, + lastFileActivityAt, + lastFileActivityLabel, }; } @@ -289,6 +459,7 @@ function normalizeSessionRecord(input, options = {}) { startedAt: startedAt.toISOString(), filePath: toNonEmptyString(options.filePath), label: deriveSessionLabel(branch, worktreePath), + changedPaths: [], }; } @@ -360,7 +531,7 @@ function readActiveSessions(repoRoot, options = {}) { } normalized.elapsedLabel = formatElapsedFrom(normalized.startedAt, now); - Object.assign(normalized, deriveSessionActivity(normalized)); + Object.assign(normalized, deriveSessionActivity(normalized, { now })); sessions.push(normalized); } @@ -393,6 +564,9 @@ module.exports = { activeSessionsDirForRepo, buildSessionRecord, collectWorktreeChangedPaths, + collectWorktreeTrackedPaths, + deriveBlockingGitLabel, + deriveLatestWorktreeFileActivity, deriveSessionLabel, deriveSessionActivity, formatElapsedFrom, @@ -404,6 +578,7 @@ module.exports = { readActiveSessions, readRepoChanges, deriveRepoChangeStatus, + resolveWorktreeGitDir, sanitizeBranchForFile, sessionFileNameForBranch, sessionFilePathForBranch,