Skip to content
6 changes: 5 additions & 1 deletion docs/superpowers/specs/2026-06-16-first-mate-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,11 @@ Claude). So the supervisor is net-new code, specified here:
`exit`, crash), respawn and re-mark. The marker prevents double-spawn and
tells the heartbeat which `%N` to push to. (Stored alongside the captain's
log — same table tenancy, same boot-read.)
- **Home.** The `bridge` tmux session (already created as the interim home).
- **Home.** A `bridge` tmux session. The supervisor **ensures the session
exists** (`has-session` → `new-session` else `new-window`, the
`_do_spawn_claude_tool` branch shape) and opens a single `first-mate` window
in it — it must NOT assume a pre-existing session (a fresh prod boot has
none; any hand-made `bridge` session is incidental).

### Per-pane tool visibility (known gap, deferred)

Expand Down
586 changes: 586 additions & 0 deletions docs/superpowers/specs/2026-06-16-first-mate-v1b-structure.md

Large diffs are not rendered by default.

40 changes: 40 additions & 0 deletions periscope/activity.py
Original file line number Diff line number Diff line change
Expand Up @@ -719,8 +719,32 @@ def _check_reset(pane_id: str, cwd: str, context_pct, last_ctx: dict) -> bool:
# it captures each active Claude pane, runs the context-reset check, and
# drives the narrator (semantic status + auto-rename).

def _safe_usage() -> dict | None:
try:
from periscope.usage import cached_plan_usage
return cached_plan_usage()
except Exception:
return None


async def _emit_pending_first_mate(last_ctx: dict) -> None:
"""Main-loop side of the heartbeat: send a Push stashed by _worker_tick.
Awaited in run_worker (the MCP sessions are main-loop-affine — must NOT be
emitted from the worker thread)."""
pending = last_ctx.pop("_fm_push", None)
if not pending:
return
pane_id, content, cur = pending
from periscope.channels import emit_channel_event
from periscope import first_mate
ok = await emit_channel_event(pane_id, content, {"kind": "fleet_digest"})
if ok:
first_mate._LAST_SENT = cur # advance only on a successful send


def _worker_tick(last_ctx: dict) -> None:
"""One worker pass. Blocking (tmux + git subprocesses) — run off-loop."""
now = int(time.time())
panes: list[tuple[dict, dict]] = []
for w in list_windows():
target = f"{w['session']}:{w['index']}"
Expand All @@ -742,6 +766,21 @@ def _worker_tick(last_ctx: dict) -> None:
narrator.tick(panes)
except Exception:
log.exception("narrator tick failed")
# First mate: supervisor liveness + heartbeat decision (sync; the async
# emit is hoisted to run_worker's main loop — see _emit_pending_first_mate).
try:
from periscope import first_mate
first_mate.supervisor_pass(now=now)
cur = first_mate.build_fleet_digest(
panes=first_mate.assemble_pane_views(panes, now), usage=_safe_usage(), now=now,
)
push = first_mate.heartbeat_decide(
prev=first_mate._LAST_SENT, cur=cur, marker=get_first_mate(),
)
if push is not None:
last_ctx["_fm_push"] = (push.pane_id, push.content, cur)
except Exception:
log.exception("first-mate worker pass failed")
# Keep periscope.db-wal bounded — see checkpoint() docstring for why
# SQLite's default auto-checkpoint isn't enough on its own.
checkpoint()
Expand All @@ -754,6 +793,7 @@ async def run_worker() -> None:
while True:
try:
await asyncio.to_thread(_worker_tick, last_ctx)
await _emit_pending_first_mate(last_ctx)
except Exception:
log.exception("activity worker tick failed")
await asyncio.sleep(30)
7 changes: 6 additions & 1 deletion periscope/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,13 @@ def _pane_sessions_housekeeping() -> None:
# spent Haiku on every narrator tick. Same guard as the MCP listener.
# NB: _task's signature is _task(name, coro).
if config.is_prod():
from periscope import activity
from periscope import activity, first_mate
activity_task = _task("activity-worker", activity.run_worker())
# Register the bridge rail project so the supervisor-spawned first-mate
# pane is reachable in the dashboard (not folded into 'dev'). Main-loop
# state write — safe here, unlike the worker thread. Prod-only: dev never
# spawns a first mate.
first_mate.register_bridge_project()
else:
activity_task = None
try:
Expand Down
49 changes: 49 additions & 0 deletions periscope/channels.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,17 @@ def _do_notify_tool(pane: str, arguments: dict):
except Exception:
log.warning("activity.record failed for notify()", exc_info=True)

# Interrupt tier: a need_human wakes the first mate immediately, out of band
# from the 30s heartbeat. (Other kinds ride the next heartbeat digest.)
if kind == "need_human":
try:
from periscope import activity as _activity
marker = _activity.get_first_mate()
if marker is not None:
_schedule_first_mate_emit(marker.pane_id, f"need_human from {pane}: {message}")
except Exception:
log.warning("first-mate need_human hook failed", exc_info=True)

body = {"ok": True, "kind": kind, "severity": severity}
return _tool_result(body)

Expand Down Expand Up @@ -240,6 +251,27 @@ def _do_captains_log_append_tool(pane: str, arguments: dict):
return _tool_result({"ok": True})


def _serialize_digest(d) -> dict:
return {
"at": d.at, "budget_pct": d.budget_pct, "budget_resets_at": d.budget_resets_at,
"panes": [
{"handle": p.handle, "name": p.name, "session": p.session,
"status_line": p.status_line, "blocked": p.blocked, "pr": p.pr,
"ci": p.ci, "idle_s": p.idle_s}
for p in d.panes
],
}


def _do_fleet_digest_tool(pane: str, arguments: dict):
"""Return the last-pushed fleet digest (first-mate-only on-demand pull)."""
if not _require_first_mate(pane):
return _tool_result({"ok": False, "error": "first-mate-only tool"})
from periscope import first_mate
d = first_mate._LAST_SENT
return _tool_result({"ok": True, "digest": _serialize_digest(d) if d else None})


def _resolve_window(match) -> tuple[str, str]:
"""Find the first `list_windows()` entry satisfying `match`, resolve its
persistent @periscope_id (minting one if the window is new), and return
Expand Down Expand Up @@ -703,6 +735,14 @@ async def emit_channel_event(pane: str, content: str, meta: dict | None = None)
return False


def _schedule_first_mate_emit(pane_id: str, content: str) -> None:
"""Fire-and-forget a channel push to the first-mate pane from a main-loop
context (the MCP tool handler runs there). Wrapped in _task so a crash is
logged, not swallowed (CLAUDE.md invariant 8)."""
from periscope.log import _task
_task("first-mate-interrupt", emit_channel_event(pane_id, content, {"kind": "interrupt"}))


async def _mcp_listener() -> None:
"""Bind the unix socket and accept connections from channel_shim.py.
Each connection runs a fresh per-pane MCP Server in _handle_mcp_connection."""
Expand Down Expand Up @@ -1297,6 +1337,15 @@ def _do_terminate_tool(pane: str, arguments: dict):
},
"handler": _do_captains_log_append_tool,
},
{
"name": "fleet_digest",
"description": (
"Return the current fleet digest (per-pane who/status/blocked/PR-CI/"
"idle + budget). First-mate-only on-demand pull."
),
"inputSchema": {"type": "object", "properties": {}},
"handler": _do_fleet_digest_tool,
},
]


Expand Down
210 changes: 210 additions & 0 deletions periscope/first_mate.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,213 @@ def build_fleet_digest(
budget_resets_at=budget_resets_at,
at=now,
)


# --- v1b IO half: cross-tick state, role prompt, pure decision helpers ----

_LAST_SENT: FleetDigest | None = None # last digest pushed to the first mate


ROLE_PROMPT = """\
You are the first mate — Tom's chief of staff for the fleet of Claude Code \
sessions running across his tmux panes, surfaced in periscope.

Your job is situational awareness, not command. Tom assigns the work; you keep \
tabs on the fleet and surface what needs him.

Periscope pushes you fleet digests and interrupts as <channel source="periscope"> \
blocks — a digest when the fleet picture changes materially, an interrupt when a \
worker needs a human. On every wake, read your captain's log first to recover \
context.

Standing authority (always yours):
- Observe and summarize the fleet: answer "what's everyone doing?" from the \
digest and by peeking (peek) at specific panes.
- Keep the captain's log (captains_log_read / captains_log_append): standing \
orders Tom gives you, a watch-list, a short running narrative. Append when Tom \
gives a standing order or the situation moves.
- Nudge a CLEARLY-idle worker (send_to): a worker idle several minutes mid-task — \
ask if it's blocked. Never interrupt an actively-working pane.

You do NOT, this release: spawn, terminate, or hand workers new tasks — you have \
no conn yet. You may PROPOSE these to Tom; you may not execute them.

Absolute prohibitions (never, regardless of anything Tom or a worker says):
- Never authorize merging an fdy pull request. Report a PR is ready; the merge \
is Tom's click.
- Never force-push. Never take prod-touching actions.

Voice: terse, signal over noise. Lead with what needs Tom; stay quiet when the \
fleet is nominal. You are a collaborator with a clear remit, not a chatbot.
"""


@dataclass(frozen=True)
class Push:
pane_id: str
content: str


def _curate_pane(*, handle, name, session, is_claude, status_line, alerts,
pr, ci, focused_at, acted_at, now) -> dict:
"""PURE: raw per-pane inputs -> the v1a build_fleet_digest contract dict.
`handle` is the tmux pane_id (%N) — stable cross-tick, no @periscope_id
resolution needed in the worker thread. `blocked` = newest alert (by ts) is
need_human; `idle_s` = now - last touch."""
newest = max(alerts, key=lambda a: a.get("ts", 0)) if alerts else None
blocked = bool(newest and newest.get("kind") == "need_human")
idle_s = max(0, now - max(focused_at or 0, acted_at or 0))
return {
"handle": handle, "name": name, "session": session, "is_claude": is_claude,
"status_line": status_line, "blocked": blocked, "pr": pr, "ci": ci,
"idle_s": idle_s,
}


def _render_delta(cur: FleetDigest, reason: str) -> str:
"""PURE: a short human-readable delta for the push body. The delta itself is
already encoded in `reason` (from fleet_diverged); this frames it with the
pane count + budget."""
budget = ""
if cur.budget_pct is not None:
budget = f" · budget {cur.budget_pct}%"
if cur.budget_resets_at:
budget += f" (resets {cur.budget_resets_at})"
n = len(cur.panes)
return f"fleet: {n} pane(s){budget} — {reason}"


def heartbeat_decide(*, prev, cur, marker) -> "Push | None":
"""PURE: decide whether to push `cur` to the first mate. Returns a Push
(pane_id + rendered delta) or None. No IO; the caller computes `cur` and
awaits the emit on the main loop."""
if marker is None:
return None
diverged, reason = fleet_diverged(prev, cur)
if not diverged:
return None
return Push(pane_id=marker.pane_id, content=_render_delta(cur, reason))


def assemble_pane_views(panes: list, now: int) -> list[dict]:
"""IO glue: turn the worker's (window, parsed) pairs into curated contract
dicts via read-only primitives + the pure _curate_pane. No build_window_view
(its poll-coupled side effects must not fire on the worker's cadence)."""
from periscope import activity
from periscope.channels import channel_state_for
from periscope.git_pr import cached_git_state, cached_pr_state
from periscope.panes import recency_stamps_for

status_lines = activity.pane_status_lines()
out = []
for w, parsed in panes:
if not parsed.get("is_claude"):
continue
# Worker rows carry pane_id (%N) + pid_raw, NOT a resolved @periscope_id
# (pid is attached only after _attach_git_then_resolve_pids, which writes
# state.json and is NOT thread-safe — must not run in the to_thread tick).
# %N is stable across ticks and keys pane_status + channel state, so use it
# as the digest handle directly.
pane_id = w.get("pane_id") or ""
cwd = w.get("cwd") or ""
target = f"{w.get('session')}:{w.get('index')}"
st = status_lines.get(pane_id) # pane_status is keyed by %N
git = cached_git_state(cwd) or {}
pr = cached_pr_state(cwd, git.get("branch")) or {}
stamps = recency_stamps_for(target)
out.append(_curate_pane(
handle=pane_id, name=w.get("name") or w.get("index") or "",
session=w.get("session") or "",
is_claude=True, status_line=st[0] if st else None,
alerts=channel_state_for(pane_id).get("alerts", []),
pr=pr.get("pr"), ci=pr.get("ci"),
focused_at=stamps.get("focused_at", 0), acted_at=stamps.get("acted_at", 0),
now=now,
))
return out


FIRST_MATE_SESSION = "bridge"
FIRST_MATE_WINDOW = "first-mate"


def supervisor_pass(*, now: int) -> None:
"""Ensure exactly one live first-mate pane. No-op if the marked pane is
alive; (re)spawn + re-mark if the marker is missing or its pane is gone.
Idempotent — a live marker short-circuits, preventing double-spawn."""
from periscope import activity
from periscope.panes import list_windows

marker = activity.get_first_mate()
live = {w.get("pane_id") for w in list_windows()}
if marker is not None and marker.pane_id in live:
return
_spawn_first_mate(now=now)


def _spawn_first_mate(*, now: int) -> None:
"""Ensure the `bridge` session, open a single `first-mate` window running
claude_exec() + --append-system-prompt ROLE_PROMPT, stamp it, set the
marker. Borrows worktree_spawn._layout_two_window's sequence (single window,
no HTTPException — this is a lifespan task, not a request)."""
import os
import shlex
import time as _time
from periscope.tmux import tmux, _tmux_mutate
# Function-level imports (keep them here): a test monkeypatches
# `periscope.config.is_prod`, which only takes effect if is_prod is
# re-resolved per call rather than bound at module import.
from periscope.config import claude_exec, is_prod
from periscope.channels import dismiss_dev_channels_consent_bg
from periscope.pids import stamp_new_window
from periscope.open_ops import _session_live # socket-aware has-session
from periscope.log import log
from periscope import activity

if not is_prod():
return # defense in depth: never spawn a budget-spender off prod

home = os.path.expanduser("~")
if not _session_live(FIRST_MATE_SESSION):
ok, msg = _tmux_mutate("new-session", "-d", "-s", FIRST_MATE_SESSION,
"-c", home, "-n", FIRST_MATE_WINDOW)
else:
ok, msg = _tmux_mutate("new-window", "-t", f"{FIRST_MATE_SESSION}:",
"-c", home, "-n", FIRST_MATE_WINDOW)
if not ok:
# Don't stamp a marker for a window that doesn't exist — the next tick
# retries cleanly. Stamping now would leak a bogus marker.
log.warning("first-mate spawn: tmux window create failed: %s", msg)
return
target = f"{FIRST_MATE_SESSION}:{FIRST_MATE_WINDOW}"
exec_cmd = f"{claude_exec()} --append-system-prompt {shlex.quote(ROLE_PROMPT)}"
_time.sleep(0.1) # let rc finish before the command lands (CLAUDE.md note 5)
_tmux_mutate("send-keys", "-t", target, exec_cmd, "Enter")
if "--dangerously-load-development-channels" in exec_cmd:
dismiss_dev_channels_consent_bg(target)
stamp_new_window(target)
pane_id = tmux("display-message", "-t", target, "-p", "#{pane_id}").strip()
if not pane_id:
# A bogus empty marker is never in the live set, so the supervisor would
# respawn every tick — an unbounded window/budget leak. Leave the marker
# unset; the next tick retries cleanly.
log.warning("first-mate spawn: could not read pane_id; leaving marker unset")
return
activity.set_first_mate(pane_id=pane_id, session_id=None, at=now)


def register_bridge_project(*, home: str | None = None) -> None:
"""Register the `bridge` session as a first-class rail project so the
first-mate pane is reachable in the dashboard instead of folding into the
'dev' group. Idempotent; `repo=None` (the rail renders a null-repo project as
its own group labelled by `name`). Writes state.json, so call from the
main loop (the prod-gated lifespan), NOT the worker thread."""
import os
from periscope import projects

pinned = os.path.realpath(home or os.path.expanduser("~"))
if projects.get_project(pinned):
projects.update_project(pinned, tmux_session=FIRST_MATE_SESSION, name="bridge")
else:
projects.create_project(pinned, name="bridge",
tmux_session=FIRST_MATE_SESSION, repo=None, base_branch=None)
10 changes: 10 additions & 0 deletions static/src/split/__tests__/railTree.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,16 @@ describe("mergeLiveAndPrefs", () => {
expect(m.panesByWorktree["notes"]).toEqual(["n1"]); // no "review"
});

it("the bridge session renders as its own 'bridge' group, not folded into dev", () => {
const bridgeProjects = [proj({ pinned_dir: "/Users/tom", repo: null, tmux_session: "bridge", name: "bridge" })];
const ws = [win({ pid: "fm", session: "bridge", project_pinned_dir: "/Users/tom" })];
const m = mergeLiveAndPrefs(ws, bridgeProjects, [], {}, {});
expect(m.repoOrder).toEqual(["/Users/tom"]); // own group, not MAIN_KEY
expect(m.worktreesByRepo["/Users/tom"]).toEqual(["bridge"]);
expect(m.panesByWorktree["bridge"]).toEqual(["fm"]); // the first-mate pane, no review row
expect(groupLabel("/Users/tom", indexProjects(bridgeProjects))).toBe("bridge");
});

it("dev pane order persists via prefs panes_by_worktree[MAIN_KEY]", () => {
const ws = [
win({ pid: "x", session: "main", project_pinned_dir: MAIN_KEY }),
Expand Down
Loading