From 2ece0ea6c32a0fcd4060ebfcdd0730bed7195ce1 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Tue, 30 Jun 2026 02:57:19 +0200 Subject: [PATCH 1/2] feat(hooks): live session presence for concurrent Claude sessions Two sessions editing the same worktree now see each other. Generalize the PostToolUse edit tracker into a per-session presence registry (all files, not just .py; branch/worktree/current-file + last_seen heartbeat) via a shared _session_presence.py, and surface OTHER live sessions in the SessionStart/ UserPromptSubmit advisor on any branch, with per-turn change-detection. Scoped to same-worktree visibility (the reported account.tsx collision). Fail-open and additive: errors leave the protected-branch advisory intact. --- .claude/hooks/_session_presence.py | 279 ++++++++++++++++++ .claude/hooks/agent_branch_advisor.py | 109 +++++-- .claude/hooks/post_edit_tracker.py | 90 ++---- .../.openspec.yaml | 2 + .../proposal.md | 31 ++ .../spec.md | 55 ++++ .../tasks.md | 34 +++ src/cli/commands/claude.js | 4 + test/session-presence.test.js | 172 +++++++++++ 9 files changed, 694 insertions(+), 82 deletions(-) create mode 100644 .claude/hooks/_session_presence.py create mode 100644 openspec/changes/agent-claude-live-session-presence-for-concurrent-cla-2026-06-30-02-47/.openspec.yaml create mode 100644 openspec/changes/agent-claude-live-session-presence-for-concurrent-cla-2026-06-30-02-47/proposal.md create mode 100644 openspec/changes/agent-claude-live-session-presence-for-concurrent-cla-2026-06-30-02-47/specs/live-session-presence-for-concurrent-claude-sessions/spec.md create mode 100644 openspec/changes/agent-claude-live-session-presence-for-concurrent-cla-2026-06-30-02-47/tasks.md create mode 100644 test/session-presence.test.js diff --git a/.claude/hooks/_session_presence.py b/.claude/hooks/_session_presence.py new file mode 100644 index 00000000..2aefb367 --- /dev/null +++ b/.claude/hooks/_session_presence.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python3 +"""Shared live-session presence registry for gitguardex Claude hooks. + +Multiple Claude Code sessions can run against the SAME worktree at once (two +chats editing the same files). gitguardex's file locks are branch-scoped and +only fire at commit time, so two sessions in one tree get no edit-time signal +that the other is touching the same path — the exact collision behind +"account.tsx is being live-edited by your other session". + +This module turns the per-session PostToolUse edit record (already written by +post_edit_tracker.py) into a real *presence* registry and reads it back: + + - record_edit() upsert THIS session's record on every Edit/Write + - read_live_sessions() the OTHER sessions editing this tree right now + - format_block() a compact human banner line for the advisor hook + - presence_fingerprint() change-detection so the banner doesn't spam + +State lives next to the other hook state, one file per session: + .claude/hooks/state/session-.json + +"Live" = last edit within a sliding window (default 900s, override with +GUARDEX_PRESENCE_WINDOW_SEC). last_seen IS the heartbeat — it is refreshed on +every edit, so an idle session naturally drops off the "editing now" view. + +Everything here is best-effort and fail-open: any error returns an empty/default +result rather than raising, so a hook importing this never blocks a session. +""" + +import json +import os +import subprocess +import time +from datetime import datetime, timezone +from pathlib import Path + +DEFAULT_WINDOW_SEC = 900 +# Runtime / bookkeeping churn that is not the agent's actual work. +EXCLUDE_PREFIXES = (".claude/", ".omx/", ".omc/", ".git/", ".codex/") +EXCLUDE_SUBSTRINGS = ("__pycache__/",) +MAX_FILES = 25 + + +def state_dir() -> Path: + """Directory holding per-session state, shared with the other hooks.""" + return Path(__file__).resolve().parent / "state" + + +def window_sec() -> int: + raw = os.environ.get("GUARDEX_PRESENCE_WINDOW_SEC", "") + try: + value = int(raw) + return value if value > 0 else DEFAULT_WINDOW_SEC + except (TypeError, ValueError): + return DEFAULT_WINDOW_SEC + + +def _git(cwd: str, args: list[str]) -> "str | None": + if not cwd: + return None + try: + result = subprocess.run( + ["git", *args], + cwd=cwd, + capture_output=True, + text=True, + timeout=5, + ) + except (OSError, subprocess.SubprocessError): + return None + if result.returncode != 0: + return None + return result.stdout.strip() or None + + +def worktree_top(cwd: str) -> "str | None": + return _git(cwd, ["rev-parse", "--show-toplevel"]) + + +def current_branch(cwd: str) -> "str | None": + branch = _git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]) + return None if not branch or branch == "HEAD" else branch + + +def _record_path(session_id: str) -> Path: + return state_dir() / f"session-{session_id}.json" + + +def _relpath(file_path: str, base: str) -> "str | None": + """Repo-relative path under `base`, or None if outside the tree / invalid.""" + if not file_path: + return None + if not base: + return os.path.basename(file_path) + try: + rel = os.path.relpath(file_path, base) + except (ValueError, TypeError): + return os.path.basename(file_path) + # A path outside the worktree (../) is not part of this tree's work. + if rel.startswith(".."): + return None + return rel + + +def is_trackable(rel: str) -> bool: + if not rel: + return False + if any(rel.startswith(prefix) for prefix in EXCLUDE_PREFIXES): + return False + if any(sub in rel for sub in EXCLUDE_SUBSTRINGS): + return False + return True + + +def _read_record(session_id: str) -> dict: + try: + return json.loads(_record_path(session_id).read_text()) + except (OSError, ValueError): + return {} + + +def _iso(now: "float | None") -> str: + ts = time.time() if now is None else now + return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat() + + +def _parse_iso(value: "str | None") -> "float | None": + if not value: + return None + try: + return datetime.fromisoformat(value).timestamp() + except (TypeError, ValueError): + return None + + +def record_edit( + *, + session_id: str, + cwd: str, + file_path: str, + tool: "str | None" = None, + now: "float | None" = None, +) -> "dict | None": + """Upsert this session's presence record for a single edited file. + + Returns the written record, or None when there is nothing to record + (no session id, or an excluded / out-of-tree path). Never raises. + """ + if not session_id: + return None + try: + top = worktree_top(cwd) or cwd or "" + rel = _relpath(file_path, top) + if not rel or not is_trackable(rel): + return None + + record = _read_record(session_id) + files = record.get("files") + if not isinstance(files, list): + files = [] + if rel in files: + files.remove(rel) # move-to-most-recent + files.append(rel) + if len(files) > MAX_FILES: + files = files[-MAX_FILES:] + + record.update( + { + "session_id": session_id, + "repo_root": top, + "worktree": top, + "branch": current_branch(cwd), + "current_file": rel, + "files": files, + "last_seen": _iso(now), + "tool": tool, + # Legacy fields kept so anything reading the old dirty record + # shape still works. + "modified": True, + "last_modified": _iso(now), + } + ) + + sdir = state_dir() + sdir.mkdir(parents=True, exist_ok=True) + path = _record_path(session_id) + tmp = path.with_suffix(".json.tmp") + tmp.write_text(json.dumps(record, indent=2)) + os.replace(tmp, path) # atomic + return record + except OSError: + return None + + +def read_live_sessions( + *, + exclude_session: "str | None" = None, + repo_root: "str | None" = None, + now: "float | None" = None, + window: "int | None" = None, +) -> list: + """Other sessions that edited a file in this tree within the live window. + + Sorted most-recently-active first. Filters out the calling session and, + when `repo_root` is given, any record from a different repo root. + """ + now_ts = time.time() if now is None else now + win = window if window is not None else window_sec() + root_real = os.path.realpath(repo_root) if repo_root else None + out = [] + try: + candidates = sorted(state_dir().glob("session-*.json")) + except OSError: + return out + for path in candidates: + try: + record = json.loads(path.read_text()) + except (OSError, ValueError): + continue + sid = record.get("session_id") + if not sid or sid == exclude_session: + continue + if root_real and record.get("repo_root"): + if os.path.realpath(record["repo_root"]) != root_real: + continue + age = None + seen = _parse_iso(record.get("last_seen")) + if seen is not None: + age = now_ts - seen + if age is None or age < 0 or age > win: + continue + record["_age_sec"] = int(age) + out.append(record) + out.sort(key=lambda r: r.get("_age_sec", 1 << 30)) + return out + + +def _human_age(age_sec: int) -> str: + if age_sec < 60: + return f"{age_sec}s" + if age_sec < 3600: + return f"{age_sec // 60}m" + return f"{age_sec // 3600}h" + + +def presence_fingerprint(sessions: list) -> str: + """Stable signature of who-is-editing-what; changes when the set changes, + NOT when timestamps tick — so a per-turn banner only re-fires on real drift.""" + parts = sorted( + f"{(s.get('session_id') or '')[:8]}:{s.get('current_file') or ''}" + for s in sessions + ) + return ";".join(parts) + + +def format_block(sessions: list, *, limit: int = 3) -> "str | None": + """Compact banner block for the advisor hook, or None when no live peers.""" + if not sessions: + return None + count = len(sessions) + plural = "s" if count > 1 else "" + head = ( + f"↹ GUARDEX live sessions: {count} other session{plural} " + "editing in this worktree right now:" + ) + lines = [head] + for record in sessions[:limit]: + sid = (record.get("session_id") or "????????")[:8] + current = record.get("current_file") or "(unknown file)" + files = record.get("files") or [] + extra = max(0, len(files) - 1) + more = f" (+{extra} more)" if extra else "" + age = _human_age(int(record.get("_age_sec", 0))) + lines.append(f" • sess {sid} — {current}{more} · {age} ago") + if count > limit: + lines.append(f" • …and {count - limit} more") + lines.append( + "Claim files (gx locks claim) or coordinate before editing the same paths." + ) + return "\n".join(lines) diff --git a/.claude/hooks/agent_branch_advisor.py b/.claude/hooks/agent_branch_advisor.py index c9913837..a31b03b6 100755 --- a/.claude/hooks/agent_branch_advisor.py +++ b/.claude/hooks/agent_branch_advisor.py @@ -57,6 +57,18 @@ except Exception: # noqa: BLE001 - fail open if sibling hook is missing/older sys.exit(0) +try: + # Live-session presence is additive: the protected-branch advisory still + # works if this sibling module is missing/older. + from _session_presence import ( + format_block, + presence_fingerprint, + read_live_sessions, + worktree_top, + ) +except Exception: # noqa: BLE001 + read_live_sessions = None + SUPPORTED_EVENTS = ("SessionStart", "UserPromptSubmit") @@ -99,28 +111,73 @@ def _advisor_state_path(session_id: str) -> Path: return Path(__file__).resolve().parent / "state" / f"advisor-{session_id}.json" -def already_advised(session_id: str) -> bool: - """True if this session already saw the full advisory. Fail-open to False.""" +def _read_state(session_id: str) -> dict: + """Per-session marker contents (advised flag + presence fingerprint).""" if not session_id: - return False + return {} try: - return _advisor_state_path(session_id).exists() - except OSError: - return False + return json.loads(_advisor_state_path(session_id).read_text()) + except (OSError, ValueError): + return {} -def mark_advised(session_id: str) -> None: - """Record that the full advisory fired for this session. Best-effort.""" +def _write_state(session_id: str, data: dict) -> None: if not session_id: return try: path = _advisor_state_path(session_id) path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps({"advised": True})) + path.write_text(json.dumps(data)) except OSError: pass +def already_advised(session_id: str) -> bool: + """True if this session already saw the full advisory. Fail-open to False. + + Reads the `advised` flag rather than mere file existence — the presence + surfacing may create this state file before the advisory ever fires. + """ + return bool(_read_state(session_id).get("advised")) + + +def mark_advised(session_id: str) -> None: + """Record that the full advisory fired for this session. Best-effort.""" + state = _read_state(session_id) + state["advised"] = True + _write_state(session_id, state) + + +def presence_text(cwd: str, session_id: str, event: str) -> "str | None": + """Banner block for OTHER live sessions editing this worktree, or None. + + Surfaces on ANY branch (agent worktrees included — that is where concurrent + edits actually collide). SessionStart always announces; UserPromptSubmit + only re-announces when the set of who-is-editing-what changed, so a quiet + turn stays quiet. + """ + if read_live_sessions is None: + return None + try: + # Scope to THIS worktree, matched on the same basis the tracker records + # (`git rev-parse --show-toplevel`), so unrelated trees never leak in. + root = worktree_top(cwd) + if not root: + return None + sessions = read_live_sessions(exclude_session=session_id, repo_root=root) + except Exception: # noqa: BLE001 - presence is best-effort, never blocks + return None + if not sessions: + return None + fingerprint = presence_fingerprint(sessions) + state = _read_state(session_id) + if event == "UserPromptSubmit" and state.get("presence_fp") == fingerprint: + return None + state["presence_fp"] = fingerprint + _write_state(session_id, state) + return format_block(sessions) + + def main() -> None: try: raw = sys.stdin.read() @@ -140,25 +197,33 @@ def main() -> None: except Exception: # noqa: BLE001 - never let a git/env hiccup block the agent sys.exit(0) - if not branch or is_agent_branch(branch): - sys.exit(0) - if branch not in resolve_protected_branches(repo_root): + # Branch advisory: ONLY on a protected base. Per-session dedup — full + # advisory once (educates), one-line reminder after (catches drift back + # onto a protected branch without re-paying the full text every turn). + advisory = None + try: + protected = resolve_protected_branches(repo_root) + except Exception: # noqa: BLE001 + protected = set() + if branch and not is_agent_branch(branch) and branch in protected: + if already_advised(session_id): + advisory = reminder_text(branch) + else: + advisory = advisory_text(branch) + mark_advised(session_id) + + # Live-session presence: on ANY branch (agent worktrees too). + presence = presence_text(cwd, session_id, event) + + parts = [part for part in (advisory, presence) if part] + if not parts: sys.exit(0) - # Per-session dedup: full advisory once (educates), one-line reminder after - # (still catches drift back onto a protected branch without re-paying the - # full text on every turn). - if already_advised(session_id): - text = reminder_text(branch) - else: - text = advisory_text(branch) - mark_advised(session_id) - hook_event = event if event in SUPPORTED_EVENTS else "SessionStart" payload = { "hookSpecificOutput": { "hookEventName": hook_event, - "additionalContext": text, + "additionalContext": "\n\n".join(parts), } } print(json.dumps(payload)) diff --git a/.claude/hooks/post_edit_tracker.py b/.claude/hooks/post_edit_tracker.py index 935cfbd0..f0110aaa 100755 --- a/.claude/hooks/post_edit_tracker.py +++ b/.claude/hooks/post_edit_tracker.py @@ -1,15 +1,29 @@ #!/usr/bin/env python3 -"""PostToolUse hook — track code file modifications. +"""PostToolUse hook — record this session's live editing presence. Matcher: Edit|Write|MultiEdit -Records modified code files in dirty-{session_id}.json. +On every edit, upsert a per-session presence record +(.claude/hooks/state/session-.json) capturing the file just +touched, the branch/worktree, and a fresh last_seen heartbeat. A sibling +session reads these back (via _session_presence.read_live_sessions, surfaced by +agent_branch_advisor.py) to see who else is editing the same tree right now. + +Unlike the old tracker this is NOT restricted to Python backend files — any +edited path in the tree counts, since the whole point is to surface concurrent +edits to the SAME file (e.g. a .tsx storefront page) across sessions. + +Fail-open: any error → exit 0, never blocks the edit. """ import json import sys -from datetime import datetime, timezone -from pathlib import Path + +try: + from _session_presence import record_edit +except ImportError: # presence module missing → nothing to record, fail open + def record_edit(**_kwargs: object) -> None: + return None try: from _analytics import emit_event @@ -19,32 +33,6 @@ def emit_event(*_a: object, **_k: object) -> None: pass -# Code file extensions (backend only) -CODE_EXTENSIONS = {".py"} - -# Code directories (relative to project root) -CODE_DIRS = {"app/", "tests/"} - -# Exclusion patterns -EXCLUDE_PATTERNS = {"__pycache__/", ".claude/", ".agents/"} - - -def is_code_file(file_path: str, project_dir: str) -> bool: - """Determine if the given path is a code file.""" - if Path(file_path).suffix not in CODE_EXTENSIONS: - return False - - try: - rel = str(Path(file_path).relative_to(project_dir)) - except ValueError: - return False - - if any(excl in rel for excl in EXCLUDE_PATTERNS): - return False - - return any(rel.startswith(d) for d in CODE_DIRS) - - def main() -> None: try: input_data = json.loads(sys.stdin.read()) @@ -52,38 +40,20 @@ def main() -> None: sys.exit(0) session_id = input_data.get("session_id", "unknown") - tool_input = input_data.get("tool_input", {}) + tool_input = input_data.get("tool_input", {}) or {} file_path = tool_input.get("file_path", "") - project_dir = input_data.get("cwd", "") + cwd = input_data.get("cwd", "") or "" + tool = input_data.get("tool_name", "") or None - if not file_path or not is_code_file(file_path, project_dir): + if not file_path: sys.exit(0) - # Record dirty state - hook_dir = Path(__file__).resolve().parent - state_dir = hook_dir / "state" - state_dir.mkdir(parents=True, exist_ok=True) - state_path = state_dir / f"dirty-{session_id}.json" - - # Load existing state - state: dict[str, object] = {"modified": True, "files": [], "last_modified": ""} - if state_path.exists(): - try: - with open(state_path) as f: - state = json.load(f) - except (json.JSONDecodeError, PermissionError): - pass - - # Add file (deduplicate) - files = state.get("files", []) - if isinstance(files, list) and file_path not in files: - files.append(file_path) - state["files"] = files - state["modified"] = True - state["last_modified"] = datetime.now(timezone.utc).isoformat() - - with open(state_path, "w") as f: - json.dump(state, f, indent=2) + record = record_edit( + session_id=session_id, + cwd=cwd, + file_path=file_path, + tool=tool, + ) emit_event( session_id, @@ -91,8 +61,8 @@ def main() -> None: { "hook": "post_edit_tracker", "trigger": "PostToolUse", - "outcome": "tracked", - "matched_count": 1, + "outcome": "tracked" if record else "skipped", + "matched_count": 1 if record else 0, "exit_code": 0, }, ) diff --git a/openspec/changes/agent-claude-live-session-presence-for-concurrent-cla-2026-06-30-02-47/.openspec.yaml b/openspec/changes/agent-claude-live-session-presence-for-concurrent-cla-2026-06-30-02-47/.openspec.yaml new file mode 100644 index 00000000..d6b53dee --- /dev/null +++ b/openspec/changes/agent-claude-live-session-presence-for-concurrent-cla-2026-06-30-02-47/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-30 diff --git a/openspec/changes/agent-claude-live-session-presence-for-concurrent-cla-2026-06-30-02-47/proposal.md b/openspec/changes/agent-claude-live-session-presence-for-concurrent-cla-2026-06-30-02-47/proposal.md new file mode 100644 index 00000000..7ee0cc5d --- /dev/null +++ b/openspec/changes/agent-claude-live-session-presence-for-concurrent-cla-2026-06-30-02-47/proposal.md @@ -0,0 +1,31 @@ +## Why + +Two Claude Code sessions can run against the SAME worktree at once (the reported +case: one session live-editing `account.tsx` while another works the address +tab). gitguardex file locks are branch-scoped and only fire at commit time, so +neither session gets an edit-time signal that the other is in the same file — +collisions are discovered by accident. + +## What Changes + +- Generalize the PostToolUse edit tracker into a live-session presence registry: + record every in-tree edit (not just `.py`) with branch, worktree, current file, + and a `last_seen` heartbeat, in `.claude/hooks/state/session-.json`. +- New shared module `_session_presence.py` (writer + reader + banner formatter + + change-detection fingerprint). +- The SessionStart/UserPromptSubmit advisor surfaces OTHER live sessions in the + same worktree — on ANY branch (agent worktrees too) — naming who is editing + which file, with per-turn change-detection so it does not spam. +- Register `_session_presence.py` in `MANAGED_HOOK_FILES` so `gx claude install` + distributes it with the hooks that import it. + +## Impact + +- Affected surfaces: `.claude/hooks/post_edit_tracker.py`, + `.claude/hooks/agent_branch_advisor.py`, new `.claude/hooks/_session_presence.py`, + `src/cli/commands/claude.js` (manifest), `test/session-presence.test.js`. +- Fail-open and additive: any error leaves the existing protected-branch advisory + intact and exits 0; presence is never a blocker. +- Scope (MVP): same-worktree visibility (the reported case). Cross-worktree + presence is a follow-up — sessions in different worktrees write to separate + per-worktree state dirs. diff --git a/openspec/changes/agent-claude-live-session-presence-for-concurrent-cla-2026-06-30-02-47/specs/live-session-presence-for-concurrent-claude-sessions/spec.md b/openspec/changes/agent-claude-live-session-presence-for-concurrent-cla-2026-06-30-02-47/specs/live-session-presence-for-concurrent-claude-sessions/spec.md new file mode 100644 index 00000000..9c33a81f --- /dev/null +++ b/openspec/changes/agent-claude-live-session-presence-for-concurrent-cla-2026-06-30-02-47/specs/live-session-presence-for-concurrent-claude-sessions/spec.md @@ -0,0 +1,55 @@ +## ADDED Requirements + +### Requirement: Record per-session live editing presence +On every Claude Code `Edit`/`Write`/`MultiEdit`, the PostToolUse tracker SHALL +upsert a per-session presence record under `.claude/hooks/state/session-.json` +capturing the edited file (repo-relative), the branch, the worktree, and a fresh +`last_seen` heartbeat. Recording SHALL cover all in-tree files (not only Python), +and SHALL exclude runtime/bookkeeping paths (`.claude/`, `.omx/`, `.omc/`, +`.git/`, `.codex/`, `__pycache__/`) and any path outside the worktree. + +#### Scenario: An edit is recorded as presence +- **WHEN** a session edits `src/storefront/account.tsx` +- **THEN** `session-.json` lists `src/storefront/account.tsx` as `current_file` +- **AND** `last_seen` is refreshed to the time of the edit. + +### Requirement: Surface other live sessions to the agent +The SessionStart / UserPromptSubmit advisor SHALL report OTHER sessions that have +a live presence record in the SAME worktree, on ANY branch (agent worktrees +included), as an `additionalContext` banner. A session SHALL NOT be shown its own +record. Presence reporting SHALL be scoped to the current worktree. + +#### Scenario: A sibling session's edit is surfaced +- **WHEN** session A edits `account.tsx` and session B starts in the same worktree +- **THEN** session B's banner names session A and `account.tsx`. + +#### Scenario: A session does not see itself +- **WHEN** session A is the only session with a presence record +- **THEN** the advisor produces no presence banner for session A. + +### Requirement: Liveness window +A presence record SHALL count as live only when its `last_seen` is within a +sliding window (default 900 seconds, overridable via +`GUARDEX_PRESENCE_WINDOW_SEC`). A record older than the window SHALL be omitted. + +#### Scenario: A stale session drops off +- **WHEN** a session's last edit is older than the configured window +- **THEN** it is not shown in the presence banner. + +### Requirement: No per-turn spam +On `UserPromptSubmit`, the advisor SHALL re-emit the presence banner only when the +set of who-is-editing-what has changed since the last emit; an unchanged set SHALL +produce no output. `SessionStart` SHALL always announce current live peers. + +#### Scenario: Unchanged set stays quiet +- **WHEN** the live-session set is unchanged across consecutive prompts +- **THEN** only the first turn emits a presence banner. + +### Requirement: Fail-open +Presence recording and reporting SHALL never block a session or an edit. Any +error (missing module, unreadable state, non-git cwd) SHALL result in no presence +output and a zero exit, leaving the existing protected-branch advisory intact. + +#### Scenario: Presence is additive +- **WHEN** the presence module is absent or errors +- **THEN** the protected-branch advisory still emits and the hook exits 0. diff --git a/openspec/changes/agent-claude-live-session-presence-for-concurrent-cla-2026-06-30-02-47/tasks.md b/openspec/changes/agent-claude-live-session-presence-for-concurrent-cla-2026-06-30-02-47/tasks.md new file mode 100644 index 00000000..e5d116c1 --- /dev/null +++ b/openspec/changes/agent-claude-live-session-presence-for-concurrent-cla-2026-06-30-02-47/tasks.md @@ -0,0 +1,34 @@ +## Definition of Done + +This change is complete only when **all** of the following are true: + +- Every checkbox below is checked. +- The agent branch reaches `MERGED` state on `origin` and the PR URL + state are recorded in the completion handoff. +- If any step blocks (test failure, conflict, ambiguous result), append a `BLOCKED:` line under section 4 explaining the blocker and **STOP**. Do not tick remaining cleanup boxes; do not silently skip the cleanup pipeline. + +## Handoff + +- Handoff: change=`agent-claude-live-session-presence-for-concurrent-cla-2026-06-30-02-47`; branch=`agent//`; scope=`TODO`; action=`continue this sandbox or finish cleanup after a usage-limit/manual takeover`. +- Copy prompt: Continue `agent-claude-live-session-presence-for-concurrent-cla-2026-06-30-02-47` on branch `agent//`. Work inside the existing sandbox, review `openspec/changes/agent-claude-live-session-presence-for-concurrent-cla-2026-06-30-02-47/tasks.md`, continue from the current state instead of creating a new sandbox, and when the work is done run `gx branch finish --branch agent// --base dev --via-pr --wait-for-merge --cleanup`. + +## 1. Specification + +- [x] 1.1 Finalize proposal scope and acceptance criteria for `agent-claude-live-session-presence-for-concurrent-cla-2026-06-30-02-47`. +- [x] 1.2 Define normative requirements in `specs/live-session-presence-for-concurrent-claude-sessions/spec.md`. + +## 2. Implementation + +- [x] 2.1 Implement scoped behavior changes. +- [x] 2.2 Add/update focused regression coverage. + +## 3. Verification + +- [x] 3.1 Run targeted project verification commands. +- [x] 3.2 Run `openspec validate agent-claude-live-session-presence-for-concurrent-cla-2026-06-30-02-47 --type change --strict`. +- [x] 3.3 Run `openspec validate --specs`. + +## 4. Cleanup (mandatory; run before claiming completion) + +- [ ] 4.1 Run the cleanup pipeline: `gx branch finish --branch agent// --base dev --via-pr --wait-for-merge --cleanup`. This handles commit -> push -> PR create -> merge wait -> worktree prune in one invocation. +- [ ] 4.2 Record the PR URL and final merge state (`MERGED`) in the completion handoff. +- [ ] 4.3 Confirm the sandbox worktree is gone (`git worktree list` no longer shows the agent path; `git branch -a` shows no surviving local/remote refs for the branch). diff --git a/src/cli/commands/claude.js b/src/cli/commands/claude.js index bfa09d51..a1c09abf 100644 --- a/src/cli/commands/claude.js +++ b/src/cli/commands/claude.js @@ -31,6 +31,10 @@ const MANAGED_HOOK_FILES = [ 'agent_branch_advisor.py', 'post_edit_tracker.py', 'skill_tracker.py', + // Shared module imported by agent_branch_advisor.py + post_edit_tracker.py + // for live-session presence. Must ship with them or those hooks fail their + // import (fail-open, but then presence is silently absent on target repos). + '_session_presence.py', ]; const MANAGED_SLASH_COMMANDS = [ diff --git a/test/session-presence.test.js b/test/session-presence.test.js new file mode 100644 index 00000000..d5cb19a0 --- /dev/null +++ b/test/session-presence.test.js @@ -0,0 +1,172 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const cp = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const repoRoot = path.resolve(__dirname, '..'); +const advisorHook = path.join(repoRoot, '.claude', 'hooks', 'agent_branch_advisor.py'); +const trackerHook = path.join(repoRoot, '.claude', 'hooks', 'post_edit_tracker.py'); +const stateDir = path.join(repoRoot, '.claude', 'hooks', 'state'); + +let counter = 0; +function freshSessionId() { + counter += 1; + return `test-presence-${process.pid}-${counter}`; +} + +function sessionStatePath(sessionId) { + return path.join(stateDir, `session-${sessionId}.json`); +} +function advisorStatePath(sessionId) { + return path.join(stateDir, `advisor-${sessionId}.json`); +} +function cleanup(dir, ...sessionIds) { + fs.rmSync(dir, { recursive: true, force: true }); + for (const sid of sessionIds) { + fs.rmSync(sessionStatePath(sid), { force: true }); + fs.rmSync(advisorStatePath(sid), { force: true }); + } +} + +/** Ephemeral git repo on a given branch with one real commit. */ +function makeRepoOn(branchName) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'presence-')); + const run = (...args) => + cp.spawnSync('git', ['-c', 'core.hooksPath=/dev/null', ...args], { cwd: dir, encoding: 'utf8' }); + assert.equal(run('init', '-q', '-b', branchName).status, 0); + run('config', 'user.email', 'test@example.com'); + run('config', 'user.name', 'Test'); + run('config', 'commit.gpgsign', 'false'); + fs.writeFileSync(path.join(dir, 'seed.txt'), 'seed\n'); + run('add', '.'); + assert.equal(run('commit', '-q', '-m', 'seed').status, 0); + return dir; +} + +function cleanEnv() { + const e = { ...process.env }; + for (const k of [ + 'GUARDEX_AGENT_BRANCH_PREFIXES', + 'GUARDEX_PROTECTED_BRANCHES', + 'GUARDEX_ON', + 'GUARDEX_PRESENCE_WINDOW_SEC', + ]) { + delete e[k]; + } + return e; +} + +/** Drive the PostToolUse tracker: session `sessionId` edits `relFile` in `cwd`. */ +function recordEdit(cwd, sessionId, relFile) { + const result = cp.spawnSync('python3', [trackerHook], { + cwd, + input: JSON.stringify({ + session_id: sessionId, + cwd, + tool_name: 'Edit', + tool_input: { file_path: path.join(cwd, relFile) }, + }), + encoding: 'utf8', + env: cleanEnv(), + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + return result; +} + +function invokeAdvisor(cwd, event, sessionId, extraEnv = {}) { + return cp.spawnSync('python3', [advisorHook], { + cwd, + input: JSON.stringify({ hook_event_name: event, cwd, session_id: sessionId }), + encoding: 'utf8', + env: { ...cleanEnv(), ...extraEnv }, + }); +} + +function additionalContext(result) { + assert.equal(result.status, 0, result.stderr || result.stdout); + if (!result.stdout.trim()) return null; + return JSON.parse(result.stdout).hookSpecificOutput.additionalContext; +} + +test('a sibling session\'s live edit surfaces on an agent branch', () => { + const dir = makeRepoOn('agent/test/lane'); + const a = freshSessionId(); + const b = freshSessionId(); + try { + recordEdit(dir, a, 'src/storefront/account.tsx'); + const ctx = additionalContext(invokeAdvisor(dir, 'SessionStart', b)); + assert.ok(ctx, 'expected a presence banner for the sibling session'); + assert.match(ctx, /live sessions/); + assert.match(ctx, /account\.tsx/); + assert.match(ctx, new RegExp(a.slice(0, 8))); + } finally { + cleanup(dir, a, b); + } +}); + +test('a session does not see itself', () => { + const dir = makeRepoOn('agent/test/lane'); + const a = freshSessionId(); + try { + recordEdit(dir, a, 'src/foo.ts'); + const result = invokeAdvisor(dir, 'SessionStart', a); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout.trim(), '', 'a session must not report its own edits as a peer'); + } finally { + cleanup(dir, a); + } +}); + +test('a session whose last edit is outside the live window is not shown', () => { + const dir = makeRepoOn('agent/test/lane'); + const a = freshSessionId(); + const b = freshSessionId(); + try { + recordEdit(dir, a, 'src/foo.ts'); + // Backdate A's heartbeat ~5 min and read with a 60s window -> stale. + const recPath = sessionStatePath(a); + const rec = JSON.parse(fs.readFileSync(recPath, 'utf8')); + rec.last_seen = new Date(Date.now() - 5 * 60 * 1000).toISOString(); + fs.writeFileSync(recPath, JSON.stringify(rec)); + + const result = invokeAdvisor(dir, 'SessionStart', b, { GUARDEX_PRESENCE_WINDOW_SEC: '60' }); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout.trim(), '', 'a stale (idle) session should drop off the live view'); + } finally { + cleanup(dir, a, b); + } +}); + +test('UserPromptSubmit stays quiet when the editing set is unchanged', () => { + const dir = makeRepoOn('agent/test/lane'); + const a = freshSessionId(); + const b = freshSessionId(); + try { + recordEdit(dir, a, 'src/storefront/account.tsx'); + const first = additionalContext(invokeAdvisor(dir, 'SessionStart', b)); + assert.match(first, /account\.tsx/, 'first announce shows the peer'); + + const second = invokeAdvisor(dir, 'UserPromptSubmit', b); + assert.equal(second.stdout.trim(), '', 'unchanged set -> no per-turn spam'); + } finally { + cleanup(dir, a, b); + } +}); + +test('protected-branch advisory and live presence combine in one banner', () => { + const dir = makeRepoOn('main'); + const a = freshSessionId(); + const b = freshSessionId(); + try { + recordEdit(dir, a, 'src/storefront/account.tsx'); + const ctx = additionalContext(invokeAdvisor(dir, 'SessionStart', b)); + assert.ok(ctx, 'expected a combined banner'); + assert.match(ctx, /BLOCKED here by gitguardex/, 'protected-branch advisory present'); + assert.match(ctx, /live sessions/, 'presence block present'); + assert.match(ctx, /account\.tsx/); + } finally { + cleanup(dir, a, b); + } +}); From c1cfa418fc7db52e06a07b5e70eb57340294cd4b Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Tue, 30 Jun 2026 03:06:26 +0200 Subject: [PATCH 2/2] fix(hooks): harden presence registry fail-open (review findings) - _read_record/read_live_sessions: normalize non-dict JSON to {} so a corrupt state file can't raise AttributeError and escape the fail-open guard (HIGH) - _relpath: return None (not basename) when no worktree base, so out-of-tree edits aren't recorded and can't leak as phantom peers (MEDIUM) - read_live_sessions: exclude records with empty/missing repo_root from the worktree-scoped view (MEDIUM) - record_edit: widen catch to Exception (structural fail-open) - advisor _write_state: atomic tmp+rename so a partial write can't drop the advised flag (LOW) - test: corrupt non-dict state file does not break tracker or advisor --- .claude/hooks/_session_presence.py | 21 ++++++++++++++++----- .claude/hooks/agent_branch_advisor.py | 4 +++- test/session-presence.test.js | 22 ++++++++++++++++++++++ 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/.claude/hooks/_session_presence.py b/.claude/hooks/_session_presence.py index 2aefb367..4d39e9f3 100644 --- a/.claude/hooks/_session_presence.py +++ b/.claude/hooks/_session_presence.py @@ -90,7 +90,9 @@ def _relpath(file_path: str, base: str) -> "str | None": if not file_path: return None if not base: - return os.path.basename(file_path) + # No worktree to scope against — cannot place this path in a tree, so + # it is not trackable presence (and must not leak in as a phantom peer). + return None try: rel = os.path.relpath(file_path, base) except (ValueError, TypeError): @@ -113,9 +115,13 @@ def is_trackable(rel: str) -> bool: def _read_record(session_id: str) -> dict: try: - return json.loads(_record_path(session_id).read_text()) + result = json.loads(_record_path(session_id).read_text()) except (OSError, ValueError): return {} + # A corrupt/older state file may parse to a non-object (null, list, number). + # Returning it would raise AttributeError on .get() in record_edit and escape + # the fail-open guard, so normalize to an empty dict. + return result if isinstance(result, dict) else {} def _iso(now: "float | None") -> str: @@ -187,7 +193,7 @@ def record_edit( tmp.write_text(json.dumps(record, indent=2)) os.replace(tmp, path) # atomic return record - except OSError: + except Exception: # noqa: BLE001 - fail-open: a tracker error must never block an edit return None @@ -216,11 +222,16 @@ def read_live_sessions( record = json.loads(path.read_text()) except (OSError, ValueError): continue + if not isinstance(record, dict): + continue sid = record.get("session_id") if not sid or sid == exclude_session: continue - if root_real and record.get("repo_root"): - if os.path.realpath(record["repo_root"]) != root_real: + if root_real: + # A record with no/empty repo_root cannot be confirmed to belong to + # this worktree — exclude it rather than letting it leak in as a peer. + record_root = record.get("repo_root") + if not record_root or os.path.realpath(record_root) != root_real: continue age = None seen = _parse_iso(record.get("last_seen")) diff --git a/.claude/hooks/agent_branch_advisor.py b/.claude/hooks/agent_branch_advisor.py index a31b03b6..65f3cd68 100755 --- a/.claude/hooks/agent_branch_advisor.py +++ b/.claude/hooks/agent_branch_advisor.py @@ -127,7 +127,9 @@ def _write_state(session_id: str, data: dict) -> None: try: path = _advisor_state_path(session_id) path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(data)) + tmp = path.with_suffix(".json.tmp") + tmp.write_text(json.dumps(data)) + os.replace(tmp, path) # atomic: never leave a truncated state file except OSError: pass diff --git a/test/session-presence.test.js b/test/session-presence.test.js index d5cb19a0..0c57f4e1 100644 --- a/test/session-presence.test.js +++ b/test/session-presence.test.js @@ -155,6 +155,28 @@ test('UserPromptSubmit stays quiet when the editing set is unchanged', () => { } }); +test('a corrupt (non-dict) state file never breaks the tracker or the advisor', () => { + const dir = makeRepoOn('agent/test/lane'); + const a = freshSessionId(); // owner of the corrupt record + const b = freshSessionId(); // reader + try { + // Simulate a half-written / hand-edited record that parses to a non-object. + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync(sessionStatePath(a), 'null'); + + // Tracker must recover (read non-dict -> {}), record cleanly, and exit 0. + recordEdit(dir, a, 'src/foo.ts'); + const rec = JSON.parse(fs.readFileSync(sessionStatePath(a), 'utf8')); + assert.equal(rec.current_file, 'src/foo.ts'); + + // Advisor must not crash on any leftover non-dict record and must exit 0. + const result = invokeAdvisor(dir, 'SessionStart', b); + assert.equal(result.status, 0, result.stderr); + } finally { + cleanup(dir, a, b); + } +}); + test('protected-branch advisory and live presence combine in one banner', () => { const dir = makeRepoOn('main'); const a = freshSessionId();