Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
9881e2c
docs(specs): fewer forks per refresh — measure, one tmux and one ps s…
tdody Nov 2, 2026
7a641f3
docs(plans): fewer forks per refresh implementation plan (THI-450)
tdody Nov 2, 2026
45bd1b4
docs(specs,plans): fewer forks per refresh — review fixes: whole exec…
tdody Nov 2, 2026
cbee58e
forks: an audit hook counts every fork the server makes, by command, …
tdody Nov 2, 2026
acd9847
state: /api/state reports what each refresh costs (THI-450)
tdody Nov 2, 2026
f012985
agent_processes: one C-locale ps per refresh, executable from argv[0]…
tdody Nov 2, 2026
1adeda1
panes: one list-panes per refresh serves list_windows, all_pane_ids a…
tdody Nov 3, 2026
f23f75b
panes, tmux: the skipped-row warning names \x1f too, the filter comme…
tdody Nov 3, 2026
c8d14d5
agent_processes: one shared LC_ALL=C/TZ=UTC ps env for every fork, so…
tdody Nov 3, 2026
2868206
git_pr: git state in two commands, repo root and remote cached per cw…
tdody Nov 3, 2026
7b6ddc0
git_pr: cached git state refreshes in the background on a jittered 15…
tdody Nov 3, 2026
1fe0371
gh: every gh call carries a token read every 10 minutes, telemetry an…
tdody Nov 3, 2026
34b3d80
ui: the resource popover closes with what a refresh costs (THI-450)
tdody Nov 3, 2026
762094e
git_pr: line counts from diff-index, which never writes the index; gi…
tdody Nov 3, 2026
3318b09
docs(specs): fewer-forks spec — diff-index's lock-free line counts, t…
tdody Nov 3, 2026
cf43fc1
git_pr: the refresh's isdir comment says why it sits outside the lock…
tdody Nov 3, 2026
b96de70
docs: window rows come from one list-panes; conftest seeds; forks and…
tdody Nov 3, 2026
34b46dc
gh: a 401 is HTTP 401 or 401 Unauthorized, not a PR numbered 401 (THI…
tdody Nov 3, 2026
a0d24ad
docs, tests: what the conftest seeds prevent; list-panes in store's m…
tdody Nov 3, 2026
aa3eb5d
tmux: _run takes env overrides merged over os.environ, not a whole en…
tdody Nov 3, 2026
18ea207
gh: a token for the wrong host (*.ghe.com vs github.com) retries once…
tdody Nov 3, 2026
9b66714
gh: AVAILABLE is the one is-gh-installed check, replacing git_pr's an…
tdody Nov 3, 2026
77d0778
git_pr: the repo-identity cache stores an entry only once a slug is f…
tdody Nov 3, 2026
e50fb48
tmux: a newline in a window name is reconstructed across list-panes r…
tdody Nov 3, 2026
2cbd6ec
git_pr: cached_git_state calls _bg like its siblings, no RuntimeError…
tdody Nov 3, 2026
f638064
git_pr, agent_processes, session_status: comments say what the code d…
tdody Nov 3, 2026
288bb8f
tmux: recent_panes -> panes_snapshot, matching process_snapshot and i…
tdody Nov 3, 2026
acd1df2
tests: the non-ASCII argv locale regression also clears LC_CTYPE and …
tdody Nov 3, 2026
255fbdc
tests: the lifespan test asserts forks.install() wires the audit hook…
tdody Nov 3, 2026
cf1aeb0
agent_processes: the process snapshot is stamped when ps returns, so …
tdody Nov 3, 2026
59f4fe8
state, tmux, agent_processes: a refresh pins the list-panes rows and …
tdody Nov 3, 2026
949dde5
agent_processes: a failed or timed-out ps serves the last table while…
tdody Nov 3, 2026
951f027
session_status: pane_claude_pids is rebuilt when the pane map or the …
tdody Nov 3, 2026
954f2c6
agent_processes, session_status: is_claude_executable is the one clau…
tdody Nov 3, 2026
bd10f9d
docs(specs), docs: snapshots pinned per refresh, ps stamped on return…
tdody Nov 3, 2026
f550721
forks, state, session_status, static: one build_state run is a poll, …
tdody Nov 3, 2026
ded16d5
ResourcePill: render the poll's slowest build and top forking command…
tdody Nov 3, 2026
ef22f35
channels, state: _channel_gc spares a pane whose alert lands at or af…
tdody Nov 3, 2026
f2fd308
gh: a token re-read clears _tokenless_cwds so a 401 doesn't skip GH_T…
tdody Nov 3, 2026
3f455bd
docs(specs): match the fewer-forks spec to the code — poll not refres…
tdody Nov 3, 2026
abcdbf6
ResourcePill: bind slowestLine/topForksLine once instead of calling e…
tdody Nov 3, 2026
45f7bc0
tmux: the pinned-snapshot box holds one set of rows, replaced not app…
tdody Nov 3, 2026
bed9ad5
comments: drop the ticket number from code comments the branch added …
tdody Nov 3, 2026
125ac69
docs(specs): drop the \x1f assumption the newline bullet already stat…
tdody Nov 3, 2026
d43ef30
Merge remote-tracking branch 'origin/main' into thibaultdody/thi-450-…
tdody Nov 3, 2026
9034e88
Merge remote-tracking branch 'origin/main' into thibaultdody/thi-450-…
tdody Nov 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ would double-load every global.
| `config.py` | Cross-cutting paths + constants; `instance_file()` keeps prod/dev stores apart |
| `log.py` | Logging + `_bg` / `_task` crash wrappers — every thread/task goes through these so crashes surface |
| `pidfile.py` | Single-instance reclaim (reloader child counts as the same instance) |
| `forks.py` | Audit-hook fork counter + each poll's cost (`/api/state` `build_stats`, the resource popover's poll block; the full map on `/api/healthz`) |
| `gh.py` | Every `gh` call: token read every 10 minutes, telemetry and update check off |
| `tmux.py` / `tmux_input.py` / `tmux_mirror.py` | Subprocess wrappers / persistent control-mode input client / control-mode pane mirror with reconcile frames |
| `store.py` | `state.json` layer (`_STATE`, load/write, migrations) |
| `channels.py` | In-process MCP server + tool implementations |
Expand Down
12 changes: 12 additions & 0 deletions docs/invariants.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ These are the non-obvious behaviors worth preserving:
active window in its session, or when the user acts on it via the
dashboard (focus/send). See `update_focus_from_windows`.

The window rows come from one `list-panes -a` (`tmux.list_panes`), one
row per window off its active pane (`#{pane_active}`); `active` still
reads `#{window_active}`.

2. **Claude detection is anchored to the bottom of the pane.** A pane is
Claude when its composer (`❯` between two full-width rules) has no more
than a footer under it, or the status line is in the last 4 non-empty
Expand Down Expand Up @@ -189,6 +193,14 @@ These are the non-obvious behaviors worth preserving:
cross-poll residue; `grep "re-minting\|duplicate @periscope_id"`
catches both.

`list_panes` carries `#{window_id}` and the window option
`#{@periscope_id}` on every pane row, so a stamp still targets the
window id. `list_windows` always forks a fresh `list-panes` — a stamp or
a new window must read back at once — and only `all_pane_ids` and
session_status's pane → pid map read the shared snapshot
(`tmux.panes_snapshot`: the poll's own rows inside `build_state`,
else under 1 s old).

12. **Rebind eligibility (`_REBIND_TTL_S`, 15 min) is NOT GC retention
(`_PID_TTL_S`, 30 days).** Rebind exists so persisted state reattaches
when the tmux server restarts — window options are lost, so every
Expand Down
2,882 changes: 2,882 additions & 0 deletions docs/superpowers/plans/2026-11-02-fewer-forks-per-refresh.md

Large diffs are not rendered by default.

246 changes: 246 additions & 0 deletions docs/superpowers/specs/2026-11-02-fewer-forks-per-refresh-design.md

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,14 @@ fixture seeds the cache so no plan-usage thread ever spawns, and
`fresh_activity_db` teardown holds `activity._LOCK` before closing `_CONN`.
Patching one call site (e.g. `periscope.app.cached_plan_usage`) is NOT enough —
`routes.state` holds its own binding, so the fix lives at the cache layer.

**The shared snapshots are seeded, so their readers don't fork.** conftest
seeds `tmux._panes_snapshot`, `agent_processes._snapshot_cache` and
`gh._token_cache` with unexpiring empty entries, and clears git_pr's per-path
caches (a stale entry would start a real `_bg` git refresh). So no test runs
`gh auth token`, and `all_pane_ids`, session_status and the process readers
fork no `list-panes` or `ps` — a test that calls the real `list_windows`
still forks one `list-panes`. A test of one of those paths resets the seed
itself (`tests/test_gh.py` overrides `_no_gh_token` by name). The live-tmux
oracle in `test_panes.py` and the live-`ps` tests in
`test_agent_processes.py` and `test_session_status.py` fork on purpose.
123 changes: 99 additions & 24 deletions periscope/agent_processes.py
Original file line number Diff line number Diff line change
@@ -1,107 +1,180 @@
"""Process-tree evidence for interactive coding agents.

This module is deliberately stdlib-only. A failed ``ps`` invocation means
"no opinion" rather than "the agent exited"; callers use ``None`` to preserve
that distinction.
This module is deliberately stdlib-only. A failed ``ps`` invocation serves
the last table while it is recent, then means "no opinion" rather than "the
agent exited"; callers use ``None`` to preserve that distinction.
"""

from __future__ import annotations

import contextlib
import logging
import os
import re
import subprocess
import threading
import time
from collections import deque
from contextvars import ContextVar
from dataclasses import dataclass
from datetime import datetime
from datetime import UTC, datetime

log = logging.getLogger(__name__)


@dataclass(frozen=True)
class ProcessInfo:
pid: int
ppid: int
started_at: int
comm: str
# argv[0]: the executable as launched. Not ps's comm, which macOS cuts to
# 16 characters unless it is the last column (`/Users/tom/.loca`).
exe: str
argv: str
state: str


_PS_ROW_RE = re.compile(
r"^\s*(\d+)\s+(\d+)\s+"
r"(\w{3}\s+\w{3}\s+\d+\s+\d\d:\d\d:\d\d\s+\d{4})\s+"
r"(\S+)\s+(\S+)(?:\s+(.*))?$"
r"(\S+)(?:\s+(.*))?$"
)
_PS_TTL_S = 1.0
_PS_TIMEOUT_S = 2.0
# A failed ps serves the last table while it is at most this old, instead of
# blanking session state, the pane -> claude map, codex detection and resources
# for a poll. A loaded poll runs 3-13 s, so the last table is up to ~14 s old
# when the next poll's ps times out.
_PS_KEEP_S = 15.0
_PS_FAILURE_LOG_EVERY_S = 60.0
_lock = threading.Lock()
_snapshot_cache: tuple[float, dict[int, ProcessInfo] | None] | None = None
# (monotonic time, table) from the last ps that succeeded.
_last_table: tuple[float, dict[int, ProcessInfo]] | None = None
_failure_logged_at: float | None = None
# Inside pinned_snapshot(): the table the block took, in a list its context
# copies share.
_pinned: ContextVar[list[dict[int, ProcessInfo] | None]] = ContextVar("pinned_process_snapshot")
# pane_id -> (pane pid, pane start, agent pid, agent start)
_pane_cache: dict[str, tuple[int, int, int, int]] = {}


def parse_ps_snapshot(output: str) -> dict[int, ProcessInfo]:
"""Parse macOS ``ps`` output emitted by :func:`_take_snapshot`."""
"""Parse the `pid ppid lstart state args` rows :func:`_take_snapshot` reads."""
result: dict[int, ProcessInfo] = {}
for line in output.splitlines():
match = _PS_ROW_RE.match(line)
if not match:
continue
pid, ppid, started, state, comm, argv = match.groups()
pid, ppid, started, state, argv = match.groups()
try:
started_at = int(datetime.strptime(started, "%a %b %d %H:%M:%S %Y").timestamp())
started_at = int(datetime.strptime(started, "%a %b %d %H:%M:%S %Y").replace(tzinfo=UTC).timestamp())
except ValueError:
continue
argv = (argv or "").strip()
info = ProcessInfo(
pid=int(pid),
ppid=int(ppid),
started_at=started_at,
comm=comm,
argv=argv or comm,
exe=argv.split(maxsplit=1)[0] if argv else "",
argv=argv,
state=state,
)
result[info.pid] = info
return result


def ps_env() -> dict[str, str]:
"""LC_ALL=C so lstart parses and argv bytes encode the same in both ps
calls `_env_tail` compares; TZ=UTC so lstart is unambiguous across DST."""
return {**os.environ, "LC_ALL": "C", "TZ": "UTC"}


def _take_snapshot() -> dict[int, ProcessInfo] | None:
try:
proc = subprocess.run(
["ps", "-axo", "pid=,ppid=,lstart=,state=,comm=,args="],
["ps", "-axo", "pid=,ppid=,lstart=,state=,args="],
check=True,
capture_output=True,
text=True,
timeout=2,
timeout=_PS_TIMEOUT_S,
env=ps_env(),
)
except (OSError, subprocess.SubprocessError):
return None
return parse_ps_snapshot(proc.stdout)


@contextlib.contextmanager
def pinned_snapshot():
"""Inside the block, and in copies of its context, process_snapshot()
serves the table it took first, however long the block runs.
build_state's poll is the block: a loaded one runs 3–13 s, and every
reader in it gets one table."""
token = _pinned.set([])
try:
yield
finally:
_pinned.reset(token)


def process_snapshot() -> dict[int, ProcessInfo] | None:
"""Return a briefly cached system process snapshot."""
global _snapshot_cache
"""The system process table: the pinned one inside pinned_snapshot(),
else the last one while under _PS_TTL_S old, else a fresh `ps`."""
global _snapshot_cache, _last_table, _failure_logged_at
pinned = _pinned.get(None)
if pinned:
return pinned[-1]
now = time.monotonic()
with _lock:
if _snapshot_cache and now - _snapshot_cache[0] < _PS_TTL_S:
return _snapshot_cache[1]
snapshot = _take_snapshot()
_snapshot_cache = (now, snapshot)
return snapshot
snapshot = _snapshot_cache[1]
else:
snapshot = _take_snapshot()
# Stamped when ps returns, not when it started: a ps slower than the
# TTL would otherwise be expired on arrival. Callers queued on the lock
# read `now` before it, so they reuse this table.
returned_at = time.monotonic()
if snapshot is not None:
_last_table = (returned_at, snapshot)
else:
kept = _last_table if _last_table and returned_at - _last_table[0] <= _PS_KEEP_S else None
snapshot = kept[1] if kept else None
if _failure_logged_at is None or returned_at - _failure_logged_at >= _PS_FAILURE_LOG_EVERY_S:
_failure_logged_at = returned_at
log.warning(
"ps failed or ran past %.0f s; %s",
_PS_TIMEOUT_S,
f"serving the table from {returned_at - kept[0]:.1f} s ago" if kept else "no process table",
)
_snapshot_cache = (returned_at, snapshot)
if pinned is not None:
pinned.append(snapshot)
return snapshot


def _is_codex_executable(proc: ProcessInfo) -> bool:
# Match the executable itself, never an arbitrary mention in argv.
return os.path.basename(proc.comm).lower() in {"codex", "codex.exe"}
return os.path.basename(proc.exe).lower() in {"codex", "codex.exe"}


def is_claude_executable(proc: ProcessInfo) -> bool:
# The launcher (`claude`, `.../bin/claude`) and the versioned binary
# (`.../share/claude/versions/<v>`) both say so; a `node` whose arguments
# merely mention ~/.claude doesn't.
return "claude" in proc.exe.lower()


def descendants(root_pid: int, snapshot: dict[int, ProcessInfo]):
"""`root_pid`'s process (when in the snapshot) and every process below it."""
"""`root_pid`'s process (when in the snapshot) and every process below it,
nearest first."""
children: dict[int, list[ProcessInfo]] = {}
for proc in snapshot.values():
children.setdefault(proc.ppid, []).append(proc)
pending = [root_pid]
pending = deque([root_pid])
seen: set[int] = set()
while pending:
parent = pending.pop()
parent = pending.popleft()
if parent in seen:
continue
seen.add(parent)
Expand Down Expand Up @@ -158,7 +231,9 @@ def codex_process_for_pane(pane_id: str, pane_pid: int | str | None) -> bool | N

def clear_caches() -> None:
"""Test/support hook; runtime callers normally rely on TTL/invalidation."""
global _snapshot_cache
global _snapshot_cache, _last_table, _failure_logged_at
with _lock:
_snapshot_cache = None
_last_table = None
_failure_logged_at = None
_pane_cache.clear()
3 changes: 2 additions & 1 deletion periscope/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,9 @@

@asynccontextmanager
async def lifespan(_app: FastAPI):
from periscope import config
from periscope import config, forks

forks.install()
log.info("periscope starting (pid=%d, port=%d)", os.getpid(), config.PORT)
from periscope import store

Expand Down
23 changes: 15 additions & 8 deletions periscope/channels.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,13 +155,21 @@ def _unknown_account_error(arguments: dict) -> list | None:
return _tool_result({"ok": False, "error": f"unknown account {account!r}; registered: {', '.join(ids)}"})


def _channel_gc(known_pane_ids: set[str]) -> None:
"""Drop alert state for panes that no longer exist. Session registry is
GC'd by the connection handler on disconnect, not here."""
def _channel_gc(known_pane_ids: set[str], since: float) -> None:
"""Drop alert state for panes that no longer exist, except a pane whose
newest alert landed at or after `since` — the poll's pane set can be as
old as the poll itself (its pinned list-panes rows), so a pane
spawned mid-poll must not look dead before its own first alert gets here.
Session registry is GC'd by the connection handler on disconnect, not
here."""
with _CHANNELS_LOCK:
for d in (_CHANNEL_ALERTS, _CHANNEL_UNREAD):
for stale in [k for k in d if k not in known_pane_ids]:
d.pop(stale, None)
stale = {k for k in (*_CHANNEL_ALERTS, *_CHANNEL_UNREAD) if k not in known_pane_ids}
for pane_id in stale:
newest = max((a.get("ts") or 0 for a in _CHANNEL_ALERTS.get(pane_id, [])), default=0)
if newest >= since:
continue
_CHANNEL_ALERTS.pop(pane_id, None)
_CHANNEL_UNREAD.pop(pane_id, None)


def _record_alert(pane: str, message: str, kind: str = "info", severity: str = "info") -> dict:
Expand Down Expand Up @@ -1146,8 +1154,7 @@ def pane_channel_ready(pane_id: str) -> bool | None:
pid = session_status.pane_claude_pids().get(pane_id)
if pid is None:
return None
_kids, cmds = session_status.proc_table()
cmd = cmds.get(pid)
cmd = session_status.command_lines().get(pid)
if cmd is None:
return None
return config.CHANNEL_FLAG in cmd
Expand Down
8 changes: 2 additions & 6 deletions periscope/cleanup.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
import time
from typing import TypedDict

from periscope import worktrees
from periscope import gh, worktrees
from periscope.gitutil import detect_default_branch, worktree_root
from periscope.log import log
from periscope.projects import MAIN_KEY, Project, all_projects
Expand Down Expand Up @@ -70,11 +70,7 @@ def _pr_state(repo: str, pr_number: int) -> str | None:
cached = _pr_state_cache.get(key)
if cached and _now() - cached[0] < _PR_STATE_TTL:
return cached[1]
code, out = _run(
["gh", "pr", "view", str(pr_number), "--json", "state"],
cwd=repo,
timeout=10.0,
)
code, out = gh.run("pr", "view", str(pr_number), "--json", "state", cwd=repo, timeout=10.0)
state: str | None = None
if code == 0 and out:
try:
Expand Down
Loading
Loading