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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/channels.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,18 @@ Resolution falls back to newest-mtime-in-cwd when a pane has no recorded session
yet. The earlier `channel_shim.py` recorder was removed — the hook's payload is
strictly better (current vs spawn-frozen).

The same mapping gives a done banner its words (THI-416). For a `done` Claude
pane, `window_view` publishes `last_reply`: `turns.last_reply_for_session`
reads the session's transcript backward from the end to the newest assistant
text, skipping meta and sidechain events and Claude's bookkeeping rows
(`turn_duration`, `artifact-autoreact-ledger`, `mode`, …). It returns None once
a prompt follows that text (the new turn has said nothing yet), and flattens the
markdown so a link's URL doesn't fill the banner. It's cached per session on the
file's size and mtime, so a done pane costs a `stat` per poll. There is no
newest-mtime-in-cwd fallback here: a wrong transcript would put another pane's
words in the banner, so without the session the banner falls back to
`last_line`, the screen's guess.

## Permission requests (a banner's Allow / Deny)

**`pane_permission_hook.py`** is `pane_session_hook.py`'s sibling:
Expand Down
5 changes: 4 additions & 1 deletion docs/invariants.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ These are the non-obvious behaviors worth preserving:
`Resume this session with:` row under the composer means exited. The
status line alone isn't enough: it's the user's statusLine script's output,
and `STATUS_RE` knew one script's, so every other pane read as a shell
(THI-434). See `parse_pane`, `_detect_composer`.
(THI-434). A named session's top rule carries the name at its right end
(`──── thi-416-banner ─`) and still counts; missing it made the footer's
statusline the done banner's text (THI-416). See `parse_pane`,
`_detect_composer`, `_composer_rows`.

3. **WebSocket paint is self-healing, not perfect.** The initial blob
still mirrors tmux's size/cursor/alt-screen state (all from
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ Titles stay under ~40 characters (macOS truncates).
|---|---|---|---|---|---|
| `needs_input` | a pane is in needs-input. With a `pending_permission`: the permission variant. An AskUserQuestion or other dialog: the dialog variant | 2a: `pid@needs_since`. 2b: the permission's `request_id` when present | `{pane} needs you` / `{session} · {branch}` / `Allow {tool}: {summary}?`; dialog variant: the `waitLabel` text | permission: **Allow** · Deny · Open. Dialog: Open | reveal pane |
| `question` | an unacked, undismissed `need_human` alert | alert id | `{pane} asks` / `{session} · {branch}` / the message | text reply **Send** | reveal pane |
| `done` | a pane enters READY (`state: "done"`, the rail's READY rule) | `pid@completed_at` | `{pane} is done` / `{session} · PR #{n}` when a PR is linked, else branch / `w.recap` or `w.last_line`, first sentence | **Review diff** · Mark seen | reveal pane |
| `done` | a pane enters READY (`state: "done"`, the rail's READY rule) | `pid@completed_at` | `{pane} is done` / `{session} · PR #{n}` when a PR is linked, else branch / `w.last_reply` (the transcript's newest assistant text, THI-416), else `w.recap` or `w.last_line`, first sentence | **Review diff** · Mark seen | reveal pane |
| `ci_failed` | a linked open PR's `w.ci` changes to `✗` from anything else | `repo#pr` + the transition (fires again after a re-run fails again) | `CI failed on #{pr}` / `{session} · {pane}` / `✗ a · ✗ b · ✓ c` from `w.ci_checks` | **Ask agent to fix** · Open PR | open PR |
| `pr_merged` | a PR goes from `open` to `merged`, the `nextMergeWatch` rule, keyed per PR, not per pane | `repo#pr` | `PR #{pr} merged` / `{session} · {branch} → {pr_base}` / `The worktree and its session can be cleaned up.` | **Clean up worktree** · Open PR | open PR |
| `usage` | the worst meter's mood (`codingMood`) gets worse | `account/meter/resets_at/mood` | `Account {A} is running hot` / `{Meter} usage {pct}% · →{proj}% at reset` / `{n} sessions are working on {A}.` | **Spawn on {B}** · Details | open the usage pill |
Expand Down
84 changes: 66 additions & 18 deletions periscope/panes.py
Original file line number Diff line number Diff line change
Expand Up @@ -452,13 +452,14 @@ def list_windows() -> list[dict]:
def _split_buffers(content: str) -> tuple[list[str], list[str], list[str]]:
"""Split capture() output into (raw_rows, plain_rows, lines).

`content` includes SGR escape sequences (capture -e). `raw_rows` keeps
`content` includes escape sequences (capture -e). `raw_rows` keeps
them — the prompt-line ghost-text check needs the fg-color info to tell
real input from Claude's greyed-out suggestion. `plain_rows` strips
them; `lines` is the non-empty plain rows.
them, OSC 8 hyperlinks included (a linkified ticket reached the done
banner as `\\x1b]8;id=…`, THI-416); `lines` is the non-empty plain rows.
"""
raw_rows = content.rstrip("\n").split("\n")
plain_rows = [_ANSI_SGR_RE.sub("", row) for row in raw_rows]
plain_rows = [_strip_escapes(row) for row in raw_rows]
lines = [p for p in plain_rows if p.strip() != ""]
return raw_rows, plain_rows, lines

Expand Down Expand Up @@ -814,6 +815,12 @@ def _dialog_above(rows: list[str]) -> bool:
return False


# The composer's top rule: plain, or a named session's with the name at its
# right end (`──── thi-416-banner ─`), which hid the composer and handed the
# footer to last_line (THI-416). The lower rule and a dialog's stay plain.
_COMPOSER_TOP_RE = re.compile(r"^─{20,}(?: [^─]+ ─+)?\s*$")


def _composer_rows(content: str, footer_reach: int = _FOOTER_REACH) -> list[tuple[str, str]] | None:
"""(raw, plain) of the non-empty rows between the LAST two full-width
rules, where Claude draws its composer, or None when there is no such
Expand All @@ -822,14 +829,18 @@ def _composer_rows(content: str, footer_reach: int = _FOOTER_REACH) -> list[tupl
raw_rows = content.rstrip("\n").split("\n")
plain_rows = [_strip_escapes(row).rstrip() for row in raw_rows]
rules = [i for i, row in enumerate(plain_rows) if DIALOG_RULE_RE.match(row)]
if len(rules) < 2:
if not rules:
return None
footer = [row for row in plain_rows[rules[-1] + 1 :] if row.strip()]
bottom = rules[-1]
top = next((i for i in reversed(range(bottom)) if _COMPOSER_TOP_RE.match(plain_rows[i])), None)
if top is None:
return None
footer = [row for row in plain_rows[bottom + 1 :] if row.strip()]
if len(footer) > footer_reach or any(_EXITED_RE.match(row) for row in footer):
return None
if _dialog_above(plain_rows[: rules[-2]]):
if _dialog_above(plain_rows[:top]):
return None
return [(raw_rows[i], plain_rows[i]) for i in range(rules[-2] + 1, rules[-1]) if plain_rows[i].strip()]
return [(raw_rows[i], plain_rows[i]) for i in range(top + 1, bottom) if plain_rows[i].strip()]


def _detect_composer(content: str) -> bool:
Expand Down Expand Up @@ -914,18 +925,54 @@ def _extract_recap(lines: list[str]) -> str | None:
return re.sub(r"\s+", " ", recap)[:400]


def _last_meaningful_line(lines: list[str]) -> str:
"""The closest "real" content line from the bottom — recent prose, a
subtask line, or a past-tense indicator — skipping TUI chrome. Used for
shell panes and the card snippet fallback. "" if nothing real.
# Claude draws hints like `new task? /clear to save 433.4k tokens` flush right,
# just above the composer. A reply's prose is indented 2 under its ⏺, a list a
# few more.
_RIGHT_ALIGNED_INDENT = 20


def _is_turn_chrome(line: str) -> bool:
"""True for the turn's own lines that aren't the reply: its `✻ Brewed for
42s · done 1:27 PM` or `✻ Waiting for 1 background agent` line, a `⎿` tool
result or tip, a right-aligned hint. Not in _is_chrome_line: the asked-
question check reads the line above the `✻` indicator through it."""
s = line.strip()
if s.startswith(("✻", "⎿")) or IDLE_INDICATOR_RE.match(line):
return True
return len(line) - len(line.lstrip()) >= _RIGHT_ALIGNED_INDENT


# A collapsed tool group (`⏺ Read 3 files (ctrl+o to expand)`) or an MCP call
# (`⏺ periscope - notify (MCP)(kind: …)`): tool headers TOOL_CALL_RE misses.
_TOOL_HEADER_MARKS = ("(ctrl+o to expand)", "(MCP)(")


def _last_meaningful_line(rows: list[str]) -> str:
"""What a done banner says when the transcript can't (THI-416): the newest
reply on screen from its start, its first paragraph's wrapped rows joined.
Its last row was the banner once, half a sentence ("that use it would
fail."). "" when the newest ⏺ is a tool call: the turn ended without words.
With no ⏺ on screen, the closest line from the bottom that isn't chrome or
the turn's own (_is_turn_chrome). `rows` keeps blank rows: they end the
paragraph.
"""
for line in reversed(lines):
s = line.strip()
if not s:
continue
if _is_chrome_line(line):
for i in range(len(rows) - 1, -1, -1):
s = rows[i].strip()
if not s.startswith("⏺"):
continue
return s[:200]
if TOOL_CALL_RE.match(s) or any(mark in s for mark in _TOOL_HEADER_MARKS):
return ""
# Claude wraps a reply itself, each continuation row indented under ⏺.
para = [s.removeprefix("⏺").strip()]
for row in rows[i + 1 :]:
if not row.startswith(" ") or not row.strip() or _is_chrome_line(row) or _is_turn_chrome(row):
break
para.append(row.strip())
return " ".join(para)[:300]
for row in reversed(rows):
s = row.strip()
if s and not _is_chrome_line(row) and not _is_turn_chrome(row):
return s[:200]
return ""


Expand Down Expand Up @@ -1057,7 +1104,8 @@ def parse_pane(content: str) -> dict:
pending_input = None if needs_input else _detect_pending_input(raw_rows, plain_rows)

recap = _extract_recap(lines)
last_line = _last_meaningful_line(body)
# The same cut over every row: blank rows end the reply's first paragraph.
last_line = _last_meaningful_line(plain_rows[: _last_rule_index(plain_rows) + 1] if composer else plain_rows)

asked_question = _detect_asked_question(body) if agent == "claude" and not needs_input else False
api_error = _detect_api_error(body) if agent == "claude" else False
Expand Down
112 changes: 112 additions & 0 deletions periscope/turns.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@

Imports only periscope.* / history.* — never `from server import`."""

import json
import re
import threading
from pathlib import Path

import periscope.activity as activity
import periscope.session_status as session_status
from history.search import messages_from_jsonl
Expand Down Expand Up @@ -56,6 +61,113 @@ def jsonl_for_session_prefix(prefix: str):
return matches[0] if matches else None


# ── last_reply: what Claude last said, for a done banner (THI-416) ───────
# The screen can't say it: the footer's statusline, the turn's `✻ Brewed for
# 42s` line and tool results all passed for the reply. The transcript holds it
# as the newest assistant text, a few KB from the end under Claude's
# bookkeeping rows (turn_duration, artifact-autoreact-ledger, mode, …).
_REPLY_CHUNK = 64 * 1024
# Bounds the read for a turn that ended in a long run of tool calls.
_REPLY_REACH = 1024 * 1024
# The banner shows the first sentence, clipped at 140; this rides every poll.
_REPLY_CHARS = 400
# sid -> (path, size, mtime_ns, reply). A transcript never moves, so the glob
# runs once per session; a done pane's file sits still between polls, so the
# read runs once per turn. Cleared past _REPLY_SESSIONS (one row per session
# the server has seen finish).
_reply_cache: dict[str, tuple[Path, int, int, str | None]] = {}
_reply_lock = threading.Lock()
_REPLY_SESSIONS = 256

_FENCE_RE = re.compile(r"^\s*(`{3,}|~{3,}).*?^\s*\1[^\n]*$", re.MULTILINE | re.DOTALL)
_HEADING_RE = re.compile(r"^\s*#{1,6}\s.*$", re.MULTILINE)
_LINE_MARK_RE = re.compile(r"^\s*(?:>\s?|[-*+]\s+|\d+[.)]\s+)", re.MULTILINE)
_LINK_RE = re.compile(r"!?\[([^\]]*)\]\([^)]*\)")
# `**` and backticks only: `__`/`_`/`*` also sit inside identifiers.
_EMPHASIS_RE = re.compile(r"\*\*|`+")


def _message_text(msg) -> str:
"""An event's text, "" for a tool call or result, thinking, or no message."""
if not isinstance(msg, dict):
return ""
content = msg.get("content")
if isinstance(content, str):
return content
if not isinstance(content, list):
return ""
return "\n".join(b["text"] for b in content if isinstance(b, dict) and b.get("type") == "text" and b.get("text"))


def _plain(md: str) -> str:
"""A reply's markdown as one line of prose: a banner shows it raw, where a
link's URL would eat the 140 characters. Headings and code blocks go; they
are never the sentence."""
text = _FENCE_RE.sub("", md)
text = _HEADING_RE.sub("", text)
text = _LINE_MARK_RE.sub("", text)
text = _LINK_RE.sub(r"\1", text)
text = _EMPHASIS_RE.sub("", text)
return " ".join(text.split())[:_REPLY_CHARS]


def last_reply(path) -> str | None:
"""The newest assistant text in transcript `path`, as plain prose; None when
a prompt came after it (the new turn has said nothing yet), none sits in the
last _REPLY_REACH bytes, or the file can't be read. Meta and sidechain
events are skipped: a channel push, a subagent's words."""
reach = _REPLY_CHUNK
try:
with open(path, "rb") as f:
size = f.seek(0, 2)
while True:
start = max(0, size - reach)
f.seek(start)
rows = f.read(size - start).split(b"\n")
for row in reversed(rows[1:] if start else rows): # rows[0] starts mid-row
try:
ev = json.loads(row)
except ValueError:
continue
if not isinstance(ev, dict) or ev.get("isMeta") or ev.get("isSidechain"):
continue
text = _message_text(ev.get("message"))
if text.strip() and ev.get("type") == "assistant":
return _plain(text) or None
if text.strip() and ev.get("type") == "user":
return None
if not start or reach >= _REPLY_REACH:
return None
reach = min(reach * 4, _REPLY_REACH)
except OSError:
return None


def last_reply_for_session(session_id: str | None) -> str | None:
"""last_reply of the session's transcript, re-read only when it changes."""
if not session_id:
return None
with _reply_lock:
hit = _reply_cache.get(session_id)
path = hit[0] if hit else jsonl_for_session(session_id)
if path is None:
return None
try:
st = path.stat()
except OSError:
with _reply_lock:
_reply_cache.pop(session_id, None)
return None
if hit and hit[1:3] == (st.st_size, st.st_mtime_ns):
return hit[3]
reply = last_reply(path)
with _reply_lock:
if len(_reply_cache) >= _REPLY_SESSIONS:
_reply_cache.clear()
_reply_cache[session_id] = (path, st.st_size, st.st_mtime_ns, reply)
return reply


# Delivered channel pushes render into the recipient's context as a meta user
# turn opening with this marker (see channels.emit_channel_event).
CHANNEL_MARKER = '<channel source="periscope"'
Expand Down
8 changes: 7 additions & 1 deletion periscope/window_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
from periscope.store import SessionSetting, get_accounts, get_window, linear_team_keys
from periscope.tmux import capture
from periscope.tracks import resolve_track_for_window, track_kind, track_label
from periscope.turns import session_id_for_pane
from periscope.turns import last_reply_for_session, session_id_for_pane
from periscope.worktrees import affiliation

# Cache of the parsed-pane dict, keyed by (target, pane_id). Skips
Expand Down Expand Up @@ -534,6 +534,12 @@ def _while_live(entry: SessionSetting | None) -> str | None:
"acted_at": acked,
"completed_at": completed,
"needs_since": needs_since,
# What a done banner says: the reply, from the transcript, where
# last_line is the screen's guess (THI-416). Only a done pane notifies,
# and a working pane's file grows every poll.
"last_reply": last_reply_for_session(sid)
if parsed.get("state") == "done" and parsed.get("agent") == "claude"
else None,
# The request a banner's Allow / Deny would answer (or null: Open only).
"pending_permission": pending_permission,
"mem": mem_signal(claude_proc_for(sid)),
Expand Down
2 changes: 1 addition & 1 deletion static/dist/app.js

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions static/src/notify/__tests__/copy.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,18 @@ describe("payloadFor", () => {
expect(payloadFor({ kind: "done", w: w() }).body).toBe("Finished.");
});

it("says a done pane's reply from its transcript over anything read off its screen (THI-416)", () => {
// last_line is a screen scrape: the turn's `✻ … for 42s` line, a footer
// statusline when the composer goes unrecognised.
const status = "📁 periscope 🌿 main ✎6 | 🧠███░░░░░░░ 39% 📨 2 📤 403 | session: 387.5k in / 403 out 💰 $121.06 🤖 opus";
const body = (over) => payloadFor({ kind: "done", w: w(over) }).body;
expect(body({ last_reply: "I updated FDY-8473. It now covers the call.", recap: "An older recap.", last_line: status }))
.toBe("I updated FDY-8473.");
expect(body({ recap: "Opened the PR. Tests pass.", last_line: status })).toBe("Opened the PR.");
expect(body({ last_line: "Merged it. Prod restarted." })).toBe("Merged it.");
expect(body({ last_reply: "", recap: null, last_line: "" })).toBe("Finished.");
});

it("lists the failing checks first and links the PR", () => {
const p = payloadFor({
kind: "ci_failed",
Expand Down
4 changes: 3 additions & 1 deletion static/src/notify/copy.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,9 @@ export function payloadFor(ev, ctx = {}) {
// The linked PR only: an auto-detected one is just the branch's, often
// days old, and would ride every finished turn.
subtitle: w.pr && w.pr_linked ? `${w.session} · PR #${w.pr}` : where(w),
body: firstSentence(w.recap || w.last_line) || "Finished.",
// The reply from the transcript first: recap and last_line are read off
// the screen, where the footer's statusline passed for the reply (THI-416).
body: firstSentence(w.last_reply || w.recap || w.last_line) || "Finished.",
category: "done_seen", data: { open: "pane", pid: w.pid, session: w.session },
};
case "ci_failed": {
Expand Down
Loading
Loading