app
Session Monitor
Public Made by Adomby adom
Drive your Claude Code sessions from your phone — every container's sessions in one board. Talk to any of them by voice or text, pick the model per session, and get a push when a session asks a question or is ready for you. Install this CLI to hook a container in and manage its keeper.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114
#!/usr/bin/env python3
"""Session Monitor keeper — runs INSIDE each session container.
The keeper-fed model: instead of the monitor pulling from claude.ai (cookie +
Cloudflare + a laptop pup tab), each container's keeper reads its OWN Claude Code
sessions from local files and REPORTS them up to the shared monitor service —
tagged with the owner identity. No claude.ai, no cookie, no Cloudflare, and it
works while the laptop is off (the container is up).
This file is M1: the read+report loop. M2 adds supervising `claude remote-control`
+ revive; the monitor service stores reports per-owner (multi-tenant).
"""
import os, json, glob, time, urllib.request, socket, shutil, subprocess, threading, re, base64, hashlib
HOME = os.path.expanduser("~")
SESSIONS_DIR = os.path.join(HOME, ".claude", "sessions")
PROJECTS_DIR = os.path.join(HOME, ".claude", "projects")
DEFAULT_NEW_CWD = os.environ.get("KEEPER_NEW_CWD", os.path.join(HOME, "project")) # start new sessions here
CONFIG = os.path.join(HOME, ".claude.json")
# Where to report. The shared monitor service URL (defaults to the local server
# for the single-container demo; set SM_SERVICE_URL to the service container).
SERVICE = os.environ.get("SM_SERVICE_URL", "http://localhost:8846").rstrip("/")
SM_TOKEN = os.environ.get("SM_TOKEN", "") # shared secret for the public service
REPORT_EVERY = int(os.environ.get("KEEPER_INTERVAL", "60"))
FULL_MAX = int(os.environ.get("KEEPER_FULL_MAX", "1500")) # cap the on-demand full transcript (bounds payload/RAM)
# The running keeper's own code version. Bump on every keeper.py change that a
# container should pick up. Written to ~/.session-keeper/keeper.version on startup
# and sent in each report, so BOTH the CLI (`keeper status`) and the board can tell
# when a container is running stale keeper code — no more "healthy" while ancient.
# Keep in lockstep with the package version.
KEEPER_VERSION = "0.9.0"
VERSION_FILE = os.path.join(HOME, ".session-keeper", "keeper.version")
def _headers(extra=None):
# Cloudflare's WAF 403s the default Python-urllib UA — set a real one.
h = {"content-type": "application/json", "User-Agent": "session-keeper/1.0 (+adom)"}
if SM_TOKEN:
h["X-SM-Token"] = SM_TOKEN
if extra:
h.update(extra)
return h
RC_MARKERS = ("remote-control", "remote_control", "environment_secret",
"claudeusercontent", "remote-control-sdk")
# --- image attachments ---------------------------------------------------------
# The service stores images (phone upload / paste, and agent-produced images) by an
# unguessable id. For a delivered command we GET each id into a local inbox and hand
# the agent the ABSOLUTE PATHS in the prompt (its Read tool has native vision). We
# surface images back to the board too: the injected prompt carries a marker whose
# paths embed the id, so the transcript's user turn is self-describing on re-scan
# (survives a keeper restart with no extra state); agent images are found by path in
# assistant text, uploaded, and attached to that message.
INBOX = os.path.join(HOME, ".session-keeper", "inbox")
IMG_EXT = {"image/png": "png", "image/jpeg": "jpg", "image/webp": "webp", "image/gif": "gif"}
MEDIA_BY_EXT = {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
"webp": "image/webp", "gif": "image/gif"}
IMG_MAX_BYTES = 12 * 1024 * 1024
INBOX_MAX = 200 # cap inbox files (prune oldest)
_IMG_MARKER = re.compile(r"\[The user attached (?:image|file)\(s\)\. View them with the Read tool: ([^\]]+)\]")
_IMG_PATH_RE = re.compile(r"(/[^\s'\"`)]+?\.(?:png|jpe?g|webp|gif))", re.IGNORECASE)
def _safe_iid(iid):
return bool(iid) and bool(re.match(r"^[A-Za-z0-9_-]{16,64}$", iid))
def _prune_inbox():
try:
files = [os.path.join(INBOX, f) for f in os.listdir(INBOX)]
if len(files) <= INBOX_MAX:
return
files.sort(key=lambda p: os.path.getmtime(p))
for p in files[:len(files) - INBOX_MAX]:
try:
os.remove(p)
except Exception:
pass
except Exception:
pass
def fetch_command_images(image_ids):
"""Download a command's image ids into the local inbox; return (paths, ok) where
ok is False if ANY requested id failed to download (so the caller can retry via
reclaim rather than silently injecting text-only). Filenames = sanitized id only."""
paths, ok = [], True
if not image_ids:
return paths, ok
os.makedirs(INBOX, exist_ok=True)
for iid in list(image_ids)[:8]:
if not _safe_iid(iid):
ok = False
continue
try:
req = urllib.request.Request(SERVICE + "/api/img/" + iid, headers=_headers())
with urllib.request.urlopen(req, timeout=30) as r:
data = r.read()
ctype = (r.headers.get("Content-Type", "") or "").split(";")[0].strip()
disp = r.headers.get("Content-Disposition", "") or ""
m_fn = re.search(r'filename="([^"\r\n]+)"', disp)
if m_fn: # general file: keep the real name (agent sees it)
safe = "".join(c for c in os.path.basename(m_fn.group(1)) if c.isalnum() or c in "._-")[:80]
path = os.path.join(INBOX, iid + "__" + (safe or "file.bin"))
else: # image: id + ext from the content type
ext = IMG_EXT.get(ctype, "png")
path = os.path.join(INBOX, iid + "." + ext)
with open(path, "wb") as f:
f.write(data)
paths.append(path)
except Exception:
ok = False
_prune_inbox()
return paths, ok
IMG_EXTS = {"png", "jpg", "jpeg", "webp", "gif"}
def _text_with_images(text, paths):
if not paths:
return text or ""
kind = "image" if all(p.rsplit(".", 1)[-1].lower() in IMG_EXTS for p in paths) else "file"
marker = ("[The user attached %s(s). View them with the Read tool: " % kind
+ ", ".join(paths) + "]")
return ((text or "").strip() + "\n\n" + marker).strip()
def _extract_user_images(text):
"""Parse the injected attachment marker back into image urls (the inbox path's
basename IS the image id). Hardened against spoofing: every path must live in OUR
inbox and carry a valid-id basename — a human typing the marker phrase can't
produce inbox paths, so their text is left untouched. Returns (urls, clean_text)."""
m = _IMG_MARKER.search(text or "")
if not m:
return [], [], text
urls, files = [], []
for p in m.group(1).split(","):
p = p.strip()
if os.path.dirname(p) != INBOX: # not a path WE wrote → treat as ordinary text
return [], [], text
base = os.path.basename(p)
if "__" in base: # general file: <id>__<name>
iid, fname = base.split("__", 1)
if not _safe_iid(iid):
return [], [], text
files.append({"url": "api/img/" + iid, "name": fname})
else: # image: <id>.<ext>
iid = base.rsplit(".", 1)[0]
if not _safe_iid(iid):
return [], [], text
urls.append("api/img/" + iid)
if not urls and not files:
return [], [], text
clean = _IMG_MARKER.sub("", text).strip()
return urls, files, clean
# agent-produced images: upload a local file ONCE (cached by path+mtime+size) -> url
_UPLOAD_CACHE = os.path.join(HOME, ".session-keeper", "uploaded-imgs.json")
_uploaded = None
def _uploaded_map():
global _uploaded
if _uploaded is None:
try:
_uploaded = json.load(open(_UPLOAD_CACHE))
except Exception:
_uploaded = {}
return _uploaded
def _save_uploaded():
try:
os.makedirs(os.path.dirname(_UPLOAD_CACHE), exist_ok=True)
tmp = _UPLOAD_CACHE + ".tmp"
with open(tmp, "w") as f:
json.dump(dict(list(_uploaded_map().items())[-1000:]), f)
os.replace(tmp, _UPLOAD_CACHE)
except Exception:
pass
def _upload_local_image(path):
try:
st = os.stat(path)
except Exception:
return None
if st.st_size <= 0 or st.st_size > IMG_MAX_BYTES:
return None
try:
with open(path, "rb") as _sf:
_sample = _sf.read(65536) # cheap content fingerprint (avoids path|mtime|size collisions)
except Exception:
return None
key = "%s|%d|%d|%s" % (path, int(st.st_mtime), st.st_size, hashlib.sha1(_sample).hexdigest()[:12])
cache = _uploaded_map()
if key in cache:
return cache[key]
mt = MEDIA_BY_EXT.get(path.rsplit(".", 1)[-1].lower())
if not mt:
return None
try:
with open(path, "rb") as f:
raw = f.read()
body = json.dumps({"media_type": mt, "data_b64": base64.b64encode(raw).decode(),
"name": os.path.basename(path)}).encode()
req = urllib.request.Request(SERVICE + "/api/img", data=body, method="POST", headers=_headers())
with urllib.request.urlopen(req, timeout=45) as r:
out = json.loads(r.read())
url = out.get("url")
if url:
cache[key] = url
_save_uploaded()
return url
except Exception:
return None
def _agent_images_on():
return os.environ.get("SM_KEEPER_AGENT_IMAGES", "1").strip().lower() not in ("0", "false", "off", "no")
def _within(realp, real_cwd):
return realp == real_cwd or realp.startswith(real_cwd.rstrip("/") + "/")
def _extract_agent_images(text, cwd=None):
"""Find real image files an assistant message names by ABSOLUTE PATH and upload them
(bounded + cached). SECURITY: only files INSIDE the session's cwd subtree, never a
hidden/dot component (.ssh/.aws/.config/.git/...), never a symlink that escapes cwd
(realpath re-checked), never anything when cwd is unknown or the kill-switch is off.
This stops an agent (or a prompt-injection) from exfiltrating arbitrary local files."""
if not _agent_images_on() or not cwd:
return []
try:
real_cwd = os.path.realpath(cwd)
except Exception:
return []
urls, seen = [], set()
for m in _IMG_PATH_RE.findall(text or ""):
if m in seen:
continue
seen.add(m)
try:
rp = os.path.realpath(m) # resolves symlinks + .. (traversal can't escape below)
except Exception:
continue
if not _within(rp, real_cwd): # escaped cwd (incl. via symlink / ..) → skip
continue
# only the REMAINDER relative to cwd may not contain a hidden/dot component — a
# session whose cwd itself is under a dotfiles dir still renders its in-cwd images.
rel = rp[len(real_cwd):]
if any(part.startswith(".") for part in rel.split("/") if part):
continue
if not os.path.isfile(rp):
continue
u = _upload_local_image(rp)
if u:
urls.append(u)
if len(urls) >= 4:
break
return urls
def owner_identity():
"""Tenant key = the Adom/Claude account this container is logged into."""
try:
acct = json.load(open(CONFIG)).get("oauthAccount", {})
return {"email": acct.get("emailAddress"), "name": acct.get("displayName"),
"org": acct.get("organizationName"), "org_uuid": acct.get("organizationUuid")}
except Exception:
return {"email": None}
def machine_id():
host = os.environ.get("VSCODE_PROXY_URI", "") # platform-provided; carries the friendly name
if host:
host = host.split("//", 1)[-1].split("/", 1)[0]
return {"id": socket.gethostname(), "proxy_host": host or None,
"name": os.environ.get("SM_CONTAINER_NAME") or None, # optional user-set display name
"cwd": os.environ.get("PWD", "")}
def _pid_alive(pid):
try:
os.kill(int(pid), 0)
return True
except Exception:
return False
def _pid_is_claude(pid):
"""Guard against PID REUSE: os.kill(pid,0) only proves *some* process holds that
pid — after the OS recycles it, a stale sessions/<pid>.json can read as 'alive'
and wrongly block injection ('held by VS Code') into a session that's actually
closed. Confirm the pid is really a claude process before trusting it."""
try:
with open(f"/proc/{int(pid)}/cmdline", "rb") as f:
cl = f.read().replace(b"\0", b" ").decode("utf-8", "ignore").lower()
return "claude" in cl
except Exception:
return True # no /proc (non-Linux) → don't over-block
def _claude_alive(pid):
return _pid_alive(pid) and _pid_is_claude(pid)
# --- delivery idempotency: never inject the same command twice -----------------
# A lost ack leaves a command 'dispatched' server-side, which the service re-serves
# for lost-ack recovery. We record every command id we've already injected locally
# and, on a re-serve, RE-ACK without re-injecting — so a dropped ack (or a keeper
# crash between inject and ack) can never spawn a duplicate agent turn. Persisted so
# a restart mid-flight can't re-inject.
LEDGER = os.path.join(HOME, ".session-keeper", "done-cmds.json")
_done_ids = None
def _done_set():
global _done_ids
if _done_ids is None:
try:
_done_ids = set(json.load(open(LEDGER)))
except Exception:
_done_ids = set()
return _done_ids
def _mark_injected(cid):
d = _done_set()
d.add(cid)
try:
os.makedirs(os.path.dirname(LEDGER), exist_ok=True)
tmp = LEDGER + ".tmp"
with open(tmp, "w") as f:
json.dump(sorted(d)[-2000:], f) # cap the on-disk ledger (backstop)
os.replace(tmp, LEDGER)
except Exception:
pass
# in-flight: session_id -> pid of a keeper-launched turn still running, so we never
# start a second headless turn on a session whose previous turn hasn't finished.
_inflight = {}
def _pid_zombie(pid):
"""A reaped-not-yet/exited child still passes os.kill(pid,0) — check the actual
process state so a zombie never reads as 'a turn is still running'."""
try:
with open(f"/proc/{int(pid)}/stat") as f:
return f.read().rsplit(")", 1)[1].split()[0] == "Z"
except Exception:
return False # no /proc → don't invent zombies
def _inflight_alive(session_id):
pid = _inflight.get(session_id)
if pid and _pid_alive(pid) and not _pid_zombie(pid):
return True
_inflight.pop(session_id, None)
return False
def _jsonl_for(session_id):
hits = glob.glob(os.path.join(PROJECTS_DIR, "**", session_id + ".jsonl"), recursive=True)
return hits[0] if hits else None
def _is_rc(path):
"""Cheap scan: does this session's transcript carry remote-control markers?"""
try:
with open(path, "rb") as f:
f.seek(0)
head = f.read(200_000).decode("utf-8", "ignore")
return any(m in head for m in RC_MARKERS)
except Exception:
return False
def _compact_q(qs):
"""Trim an AskUserQuestion `questions` array to a small, board-safe shape."""
out = []
for q in (qs or [])[:4]:
out.append({
"header": (q.get("header") or "")[:40],
"question": (q.get("question") or "")[:600],
"multiSelect": bool(q.get("multiSelect")),
"options": [{"label": (o.get("label") or "")[:80],
"description": (o.get("description") or "")[:200]}
for o in (q.get("options") or [])[:6]],
})
return out
def _summarize(path, cwd=None, full=False):
"""Pull title + last user/assistant text + timestamps from a transcript.
Title priority: the user's rename (custom-title) > Claude's ai-title >
a summary entry > the first user message."""
title, last_user, last_user_at, last_asst, last_asst_at, last_at = None, "", None, "", None, None
first_user = custom_title = ai_title = summary_title = None
interrupted = False # is the tail an unresolved '[Request interrupted]'?
pending_q = None # an AskUserQuestion awaiting the user (structured)
last_model = None # the model the most recent assistant turn ran on
t_cwd = None # session cwd from the transcript (agent-image scoping)
msgs = [] # chronological user/assistant messages (for the convo view)
open_tool = None # name of a trailing tool_use no result ever came back for
try:
lines = open(path, errors="ignore").read().splitlines()
except Exception:
return {}
for ln in lines:
try:
o = json.loads(ln)
except Exception:
continue
t, ts = o.get("type"), o.get("timestamp")
if ts:
last_at = ts
if o.get("cwd"):
t_cwd = o["cwd"]
if t == "custom-title" and o.get("customTitle"):
custom_title = o["customTitle"] # the user's manual rename — last one wins
elif t == "ai-title" and o.get("aiTitle"):
ai_title = o["aiTitle"]
elif t == "summary" and o.get("summary"):
summary_title = o["summary"]
msg = o.get("message") or {}
content = msg.get("content")
text = ""
if isinstance(content, str):
text = content
elif isinstance(content, list):
text = "".join(b.get("text", "") for b in content
if isinstance(b, dict) and b.get("type") == "text")
text = text.strip()
# pending AskUserQuestion: a structured question tool_use with no following
# user turn. Detect BEFORE the no-text skip — the tool_use carries no text.
if t == "assistant" and isinstance(content, list):
_tus = [b2.get("name") for b2 in content if isinstance(b2, dict) and b2.get("type") == "tool_use"]
if _tus:
open_tool = _tus[0] # trailing tool_use → possibly awaiting approval
elif text:
open_tool = None # a plain text reply means nothing is pending
for b in content:
if isinstance(b, dict) and b.get("type") == "tool_use" and b.get("name") == "AskUserQuestion":
q = (b.get("input") or {}).get("questions") or []
if q:
pending_q = q
elif t == "user":
pending_q = None # any user turn after the question = answered / moved on
open_tool = None # any user/tool_result entry resolves a pending tool_use
# the harness auto-rejects a tool call whose permission prompt sat
# unanswered (~10 min) — same marker as a manual "don't allow". Either
# way the turn is PAUSED and needs a nudge; flag it like an interrupt.
if "doesn't want to take this action" in ln:
interrupted = True
if not text:
continue
if t == "user" and not o.get("isMeta"): # FIX: was `not str(...)` → always False
# an interruption marker (VS Code timeout OR a manual stop) — the turn is
# PAUSED. Flag it, but don't show it as a real prompt; a later real message
# (below) resolves it. Nothing is ever auto-resumed.
if "[request interrupted" in text.lower():
interrupted = True
continue
# skip tool-result/command-wrapper noise + Claude Code's system-injected
# compaction preamble so they're never mistaken for YOUR message.
if (text.startswith("<") or text.startswith("Caveat:")
or text.startswith("This session is being continued from a previous conversation")):
continue
uimgs, ufiles, text = _extract_user_images(text) # attached image urls + file chips; marker stripped
last_user, last_user_at = text, ts
interrupted = False # a fresh prompt means you've picked it back up
if first_user is None:
first_user = text
msgs.append({"role": "user", "text": text, "at": ts, "images": uimgs or None, "files": ufiles or None})
elif t == "assistant":
last_asst, last_asst_at = text, ts
interrupted = False # a completed/continued reply means it resumed
m = msg.get("model")
if m and m != "<synthetic>": # track the model the latest real turn used
last_model = m
msgs.append({"role": "assistant", "text": text, "at": ts})
title = (custom_title or ai_title or summary_title
or (first_user or "")[:60].strip() or None)
eff_cwd = cwd or t_cwd # scope agent-image extraction to the session's cwd
def _capl(lst, clen):
out = []
for m in lst:
t = m["text"] or ""
if len(t) > clen: # NEVER cut silently — say so, mid-word cuts read as bugs
t = t[:clen] + "\n\n… [message truncated]"
d = {"role": m["role"], "text": t, "at": m["at"]}
if m.get("files"):
d["files"] = m["files"]
imgs = m.get("images")
if m["role"] == "assistant" and not imgs:
imgs = _extract_agent_images(m["text"], eff_cwd) # agent images: ONLY inside cwd
if imgs:
d["images"] = imgs # render inline on the board (both roles)
out.append(d)
return out
def _cap(n, clen):
return _capl(msgs[-n:], clen)
def _recent(cap_turns, clen):
# Focus the card on the CURRENT exchange: anchor on your last REAL message and
# show it + everything after (the agent's reply chain). So a session where the
# agent ran many turns in a row still shows YOUR message, not just agent turns.
anchor = 0
for i in range(len(msgs) - 1, -1, -1):
if msgs[i]["role"] == "user":
anchor = i
break
w = msgs[anchor:]
if len(w) > cap_turns: # huge exchange → keep the anchor + the most recent turns
w = [w[0]] + w[-(cap_turns - 1):]
return _capl(w, clen)
# recent = the convo shown in the OPEN card (audio/text overlay). Anchored on your
# last message so it's always visible; per-message cap 4000 (700 cut replies short).
out = {"title": title, "last_user": last_user, "last_user_at": last_user_at,
"last_agent": last_asst, "last_agent_at": last_asst_at, "last_at": last_at,
"needs_login": _needs_login(last_asst), "interrupted": interrupted,
"pending_question": _compact_q(pending_q) if pending_q else None,
"model": last_model,
"open_tool": open_tool,
"recent": _recent(8, 12000), "transcript": _cap(60, 12000)}
if full: # ENTIRE convo for an open card (on demand only)
out["transcript_full"] = _cap(FULL_MAX, 12000)
out["full_oldest_truncated"] = len(msgs) > FULL_MAX
return out
def _needs_login(la):
"""The container's Claude Code is logged out — a headless `claude -p` returns
'Not logged in · Please run /login' and that lands as the reply. Flag it so the
UI can say 'run /login here' instead of showing it as a normal Claude message."""
la = (la or "").lower()
return "not logged in" in la and "/login" in la
# Sessions older than this (dead + untouched) are dropped from the board. Alive
# or recently-active sessions are always kept. Override with KEEPER_MAX_AGE_DAYS.
MAX_AGE = int(os.environ.get("KEEPER_MAX_AGE_DAYS", "3")) * 86400
# "Waiting on a tool approval" detection: these tools execute in milliseconds once
# approved — if one is the transcript's dangling tail for a while on an IDE-held
# session, a permission prompt is almost certainly sitting unanswered (they auto-
# reject after ~10 min, killing the turn while the user is away — the exact
# blindness SM exists to prevent). Long-running tools (Bash, Agent, ...) are
# excluded: a quiet tail there usually just means the tool is still working.
QUICK_TOOLS = {"Write", "Edit", "MultiEdit", "NotebookEdit", "TodoWrite"}
APPROVAL_QUIET_SEC = int(os.environ.get("KEEPER_APPROVAL_QUIET_SEC", "90"))
def _pending_approval(summ, held, mt, now):
return bool(held and summ.get("open_tool") in QUICK_TOOLS
and (now - mt) > APPROVAL_QUIET_SEC)
MAX_SESSIONS = int(os.environ.get("KEEPER_MAX_SESSIONS", "40"))
def _is_sidechain(path):
"""True for subagent/Task transcripts (every entry carries isSidechain:true) —
those are ephemeral agent runs, not user sessions, so we keep them off the board."""
try:
with open(path, "rb") as f:
head = f.read(120_000).decode("utf-8", "ignore")
return '"isSidechain":true' in head or '"isSidechain": true' in head
except Exception:
return False
def collect_sessions():
"""Container sessions the keeper can drive. Enumerated from the TRANSCRIPTS
(not just sessions/<pid>.json) so a session you CLOSE in the IDE stays on the
board as idle/resumable instead of vanishing — every transcript is reachable
via `claude --resume -p`. sessions/<pid>.json supplies live-process info."""
# 1. live-process info per sessionId (pid file exists only while open)
live = {}
for sf in glob.glob(os.path.join(SESSIONS_DIR, "*.json")):
try:
s = json.load(open(sf))
except Exception:
continue
sid = s.get("sessionId")
if not sid:
continue
a = _claude_alive(s.get("pid"))
if sid not in live or a: # prefer the alive record
live[sid] = (s, a)
# 2. recent transcripts (incl. closed sessions) — newest first, capped
now = time.time()
cand = {}
for jp in glob.glob(os.path.join(PROJECTS_DIR, "**", "*.jsonl"), recursive=True):
sid = os.path.basename(jp)[:-6] # strip ".jsonl"
try:
mt = os.path.getmtime(jp)
except Exception:
continue
if sid not in live and (now - mt) > MAX_AGE:
continue # not live + long-stale → skip
if sid not in cand or mt > cand[sid][1]:
cand[sid] = (jp, mt)
ordered = sorted(cand.items(), key=lambda kv: kv[1][1], reverse=True)[:MAX_SESSIONS * 2]
out = []
for sid, (jp, mt) in ordered:
if len(out) >= MAX_SESSIONS:
break
if _is_sidechain(jp): # subagent/Task run → not a user session
continue
s, alive = live.get(sid, ({}, False))
summ = _summarize(jp, (s or {}).get("cwd"))
held = bool(alive and s.get("kind") == "interactive")
recent = (now - mt) < 45
# live → working/awaiting; closed/no-process → idle (still resumable)
if held:
status = "working" if recent else "awaiting"
else:
status = "working" if recent else "idle"
out.append({
"session_id": sid, "pid": s.get("pid"), "cwd": s.get("cwd"),
"alive": alive, "status": status,
"held_live": held, # open in IDE → can't deliver headlessly
"resumable": True, # keeper can drive it via --resume -p
"rc": _is_rc(jp),
"title": summ.get("title") or sid[:8],
"last_user_at": summ.get("last_user_at"), "last_agent_at": summ.get("last_agent_at"),
"last_user": (summ.get("last_user") or "")[:8000],
"last_agent": (summ.get("last_agent") or "")[:12000],
"recent": summ.get("recent") or [], # last ~3 exchanges for the card convo view
"needs_login": summ.get("needs_login", False),
"interrupted": summ.get("interrupted", False),
"pending_question": summ.get("pending_question"), # structured AskUserQuestion awaiting the user
"pending_approval": _pending_approval(summ, held, mt, now), # a tool-approval prompt is sitting unanswered in the IDE
"model": summ.get("model"), # model the latest turn ran on
"turn_active": _inflight_alive(sid), # a keeper-launched turn is running right now (precise)
"last_at": summ.get("last_at"), "version": s.get("version"),
})
out.sort(key=lambda x: x.get("last_at") or "", reverse=True)
return out
def claude_bin():
return (os.environ.get("CLAUDE_BIN") or shutil.which("claude")
or os.path.expanduser("~/.local/share/code-server/extensions/"
"anthropic.claude-code-2.1.191-linux-x64/resources/native-binary/claude"))
def _session_proc(session_id):
"""The sessions/<pid>.json record for a session (pid, cwd, kind), if any."""
for sf in glob.glob(os.path.join(SESSIONS_DIR, "*.json")):
try:
s = json.load(open(sf))
except Exception:
continue
if s.get("sessionId") == session_id:
return s
return None
def held_live(session_id):
"""Is this session currently held by a LIVE interactive process (IDE)?"""
s = _session_proc(session_id)
return bool(s and _claude_alive(s.get("pid")) and s.get("kind") == "interactive")
def actively_working(session_id):
"""Is a turn being GENERATED right now? (transcript touched in the last ~12s).
Delivery defers only during active generation — NOT for the whole time the IDE
has the session open. (held_live stays true while you're away on your phone, so
waiting on it queued messages forever — the bug this replaces.)"""
jp = _jsonl_for(session_id)
try:
return bool(jp) and (time.time() - os.path.getmtime(jp)) < 12
except Exception:
return False
def _session_cwd(session_id):
"""Recover the session's working dir — from the live proc record, else from
a `cwd` field inside its transcript (so headless `--resume` runs in-place)."""
s = _session_proc(session_id) or {}
if s.get("cwd"):
return s["cwd"]
jp = _jsonl_for(session_id)
if jp:
for ln in open(jp, errors="ignore"):
if '"cwd"' in ln:
try:
cwd = json.loads(ln).get("cwd")
if cwd:
return cwd
except Exception:
pass
return os.getcwd()
def inject(session_id, text, fork=False, model=None, allow_held=False):
"""Deliver one turn into a session via `claude --resume -p` (claude.ai-free).
Returns (ok, result_text, effective_session_id).
NORMAL sends are FIRE-AND-FORGET: we launch the turn detached and NEVER wait-
then-kill (a real turn can run for minutes; killing it corrupts the user's
work). We do a brief, non-destructive delivery CHECK (~4s): a healthy turn
keeps running / writes to the transcript, whereas a logged-out or bad-session
`claude -p` exits fast and non-zero. That lets us report a real failure instead
of a false 'delivered'. Still running past the window → we return success and
leave it detached. BRANCH waits (bounded) to capture the forked session id."""
cwd = _session_cwd(session_id)
cmd = [claude_bin(), "--resume", session_id, "-p", text, "--output-format", "json"]
if model: # per-session model choice (applies to this turn)
cmd += ["--model", model]
if fork:
cmd.append("--fork-session")
try: # branch: need the new id back
p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=180)
out = json.loads(p.stdout or "{}")
return (not out.get("is_error", bool(p.returncode)),
out.get("result"), out.get("session_id") or session_id)
except Exception as e:
return (False, f"inject failed: {e}", session_id)
# FINAL SAFETY: never `claude --resume -p` a session that's LIVE in the IDE. A
# headless resume on a live session forks a turn the editor never sees and can
# DESYNC/lose the session from the editor's list (what bit John's HD thread). The
# drain already gates on held_live, but re-check at the last moment to close any
# detection/timing race. Only force/takeover (deliberate, confirmed) pass allow_held.
if not allow_held and held_live(session_id):
return (False, "not delivered — this session is open in VS Code; resuming a live "
"session can desync it. Close it there, or use Take over / Fork.", session_id)
jp = _jsonl_for(session_id)
try:
mt0 = os.path.getmtime(jp) if jp else 0
except Exception:
mt0 = 0
try:
dn = open(os.devnull, "wb")
p = subprocess.Popen(cmd, cwd=cwd, stdout=dn, stderr=dn, stdin=subprocess.DEVNULL,
start_new_session=True) # detached — runs to completion on its own
except Exception as e:
return (False, f"inject failed: {e}", session_id)
_inflight[session_id] = p.pid # guard against overlapping turns
# REAP the detached child when it exits (a zombie still passes os.kill(pid,0),
# which pinned turn_active True forever). And the moment the turn ENDS, fast-
# report this session — so the service sees turn_active=false together with the
# FINAL reply text, and the "Reply ready" push never quotes a stale message.
def _reap_and_report(proc=p, s_id=session_id):
try:
proc.wait()
except Exception:
pass
try:
report_one(s_id)
except Exception:
pass
threading.Thread(target=_reap_and_report, daemon=True).start()
delivered = "delivered — the agent is working; the reply will appear shortly"
deadline = time.time() + 4
while time.time() < deadline:
rc = p.poll()
if rc is None: # still running → a real turn is underway
try:
if jp and os.path.getmtime(jp) > mt0:
return (True, delivered, session_id)
except Exception:
pass
time.sleep(0.3)
continue
_inflight.pop(session_id, None) # it already exited
if rc == 0:
return (True, "delivered", session_id)
return (False, "delivery failed — the container's Claude Code looks logged out "
"(run /login there) or the session is unavailable", session_id)
return (True, delivered, session_id)
def drain_commands(mid):
"""Pull pending sends for this machine and execute them. Returns the 'watch'
list (sessions a client has OPEN) so the loop can fast-report just those."""
try:
req = urllib.request.Request(SERVICE + "/api/keeper/commands?machine=" + mid, headers=_headers())
with urllib.request.urlopen(req, timeout=15) as r:
resp = json.loads(r.read())
cmds = resp.get("commands", [])
watch = resp.get("watch", [])
fetch = resp.get("fetch", [])
except Exception:
return [], []
for c in cmds:
cid = c.get("id")
sid, mode = c.get("session_id"), c.get("mode") or "auto"
# EXACTLY-ONCE: if we already injected this command (the service re-served it
# because our earlier ack was lost), just re-ack — NEVER re-inject.
if cid in _done_set():
_ack(mid, cid, "done", "delivered")
continue
if mode == "takeover": # steal the session from VS Code
# Kill the live IDE claude process holding this session so it's FREE, then
# deliver headlessly. Destructive by design (interrupts the IDE turn) — the
# UI confirms first. After this the session is SM-driven; reopening it in the
# IDE (claude --resume) hands it back.
if cid in _done_set():
_ack(mid, cid, "done", "delivered"); continue
proc = _session_proc(sid) or {}
pid = proc.get("pid")
if pid and _pid_alive(pid):
try:
os.kill(int(pid), 15) # SIGTERM the IDE-side claude
except Exception:
pass
for _ in range(20): # wait (≤4s) for it to release the session
if not _pid_alive(pid):
break
time.sleep(0.2)
ok, result, eff = inject(sid, c.get("text") or "", fork=False, model=c.get("model"), allow_held=True)
if ok:
_mark_injected(cid)
_ack(mid, cid, "done" if ok else "error",
("took over — now driving from Session Monitor" if ok else result))
continue
if mode == "new_session": # start a fresh session (no target sid)
if cid in _newsession_inflight:
continue
_newsession_inflight.add(cid)
threading.Thread(target=_run_new_session,
args=(mid, cid, c.get("text") or "", c.get("cwd"), c.get("model")),
daemon=True).start()
continue
# A session OPEN in the IDE owns its own stdin (held by VS Code) — a headless
# `claude --resume -p` forks a turn the live session never sees, or hangs and
# times out. There is NO claude.ai-free way to inject into the live process
# (researched). So fail FAST with a clear, actionable status instead of a 300s
# hang. (Branch forks a copy; force injects anyway; or close the session here.)
if mode not in ("branch", "force", "takeover") and held_live(sid):
_held_seen[sid] = time.time() # remember it was live (for the settle window)
# Open in VS Code → we can't inject headlessly WHILE it's held (that forks a
# turn the IDE never sees / hangs). But keep it QUEUED (re-served) and deliver
# the moment the session is free there — instead of a terminal "blocked".
_ack(mid, cid, "waiting",
"queued — this session is open in VS Code; it delivers once it's free there")
continue
# SETTLE: after the editor releases a session (reload/close), give it a few
# seconds to fully re-register before we `--resume` it — delivering the instant
# it frees can race the editor's session re-registration and lose the session.
if mode == "auto" and 0 < (time.time() - _held_seen.get(sid, 0)) < HELD_SETTLE_SEC:
_ack(mid, cid, "waiting", "delivering shortly — letting the editor finish releasing this session")
continue
# don't start a 2nd turn while a previous keeper-launched turn is still running,
# or while a turn is generating right now (avoid overlapping/corrupt transcripts)
if mode != "branch" and _inflight_alive(sid):
_ack(mid, cid, "waiting", "a previous message is still being processed — delivering once it settles")
continue
if mode == "auto" and actively_working(sid):
_ack(mid, cid, "waiting", "agent is mid-response — delivering once it settles")
continue
# attach images: download to the inbox and reference by ABSOLUTE PATH so the
# agent's Read tool (native vision) can view them. Only reached once we're
# actually going to deliver (past the held/waiting guards).
img_ids = c.get("images") or []
img_paths, imgs_ok = fetch_command_images(img_ids)
if img_ids and not imgs_ok:
# some attachments didn't download — do NOT inject text-only or ack done.
# Leave the command 'dispatched' so the server reclaims + retries it (bounded);
# the idempotency ledger still guarantees exactly-once if a later try succeeds.
continue
text = _text_with_images(c.get("text") or "", img_paths)
if mode == "branch":
# A fork can take up to 180s; running it inline would starve report() and
# trip a false "keeper down" alert. Run it off the loop and ack on finish.
if cid in _branch_inflight:
continue # already forking in a background thread
_branch_inflight.add(cid)
threading.Thread(target=_run_branch, args=(mid, cid, sid, text, c.get("model")),
daemon=True).start()
continue
ok, result, eff = inject(sid, text, fork=False, model=c.get("model"),
allow_held=(mode == "force"))
if ok:
_mark_injected(cid) # record BEFORE the ack so a lost ack can't re-inject
_ack(mid, cid, "done" if ok else "error", result, branch_id=None)
return watch, fetch
_newsession_inflight = set() # command ids starting a new session in a bg thread
def _all_sids():
"""Every session id we can see from local transcripts (for new-session detection)."""
return {os.path.basename(jp)[:-6]
for jp in glob.glob(os.path.join(PROJECTS_DIR, "**", "*.jsonl"), recursive=True)}
def _run_new_session(mid, cid, prompt, cwd, model=None):
"""Start a BRAND-NEW headless session (`claude -p`, no --resume) with an initial
prompt, DETACHED so a long first turn is never killed. Poll briefly for the new
session id so the UI can jump to it; if it doesn't appear in the window it still
lands via the normal report sweep."""
try:
cwd = cwd or DEFAULT_NEW_CWD
if not os.path.isdir(cwd):
cwd = HOME
before = _all_sids()
cmd = [claude_bin(), "-p", prompt]
if model:
cmd += ["--model", model]
try:
subprocess.Popen(cmd, cwd=cwd, stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
start_new_session=True)
except Exception as e:
_ack(mid, cid, "error", f"couldn't start a new session: {e}")
return
_mark_injected(cid) # exactly-once: a re-served cmd never re-launches
new_id = None
for _ in range(25): # ~25s to spot the new transcript
time.sleep(1.0)
fresh = _all_sids() - before
if fresh:
# newest by transcript mtime
new_id = max(fresh, key=lambda x: (os.path.getmtime(_jsonl_for(x)) if _jsonl_for(x) else 0))
break
if new_id:
try:
report_one(new_id) # fast-push so the board shows it immediately
except Exception:
pass
_ack(mid, cid, "done", ("new session started" if new_id else "new session starting"),
branch_id=new_id) # branch_id carries the new id → client opens it
finally:
_newsession_inflight.discard(cid)
_held_seen = {} # sid -> ts last seen held_live (settle window)
HELD_SETTLE_SEC = int(os.environ.get("KEEPER_HELD_SETTLE", "8"))
_branch_inflight = set() # command ids being forked in a background thread
def _run_branch(mid, cid, sid, text, model=None):
"""Run a --fork-session inject off the main loop, then ack (so a slow fork can't
starve the keeper's heartbeat). The server keeps the command 'dispatched' and
won't re-serve within the reclaim window; the ledger blocks any late re-serve."""
try:
ok, result, eff = inject(sid, text, fork=True, model=model)
if ok:
_mark_injected(cid)
_ack(mid, cid, "done" if ok else "error", result, branch_id=(eff if ok else None))
finally:
_branch_inflight.discard(cid)
def _ack(mid, cid, status, result, branch_id=None):
body = json.dumps({"machine": mid, "id": cid, "status": status,
"result": (result or "")[:4000], "branch_id": branch_id}).encode()
try:
urllib.request.urlopen(urllib.request.Request(
SERVICE + "/api/keeper/ack", data=body, method="POST",
headers=_headers()), timeout=15).read()
except Exception:
pass
def _logged_out():
"""True when this container has NO usable Anthropic auth (needs /login) — read
locally every cycle, zero API calls. We flag ONLY when none of the auth sources
exist (no oauthAccount, no credentials token, no API-key env), so an expired-but-
refreshable token — which Claude Code refreshes on its own — is never mis-flagged."""
try:
if (json.load(open(CONFIG)).get("oauthAccount") or {}).get("emailAddress"):
return False
except Exception:
pass
try:
cred = json.load(open(os.path.join(HOME, ".claude", ".credentials.json")))
if (cred.get("claudeAiOauth") or {}).get("accessToken"):
return False
except Exception:
pass
return not os.environ.get("ANTHROPIC_API_KEY")
def report():
payload = {"owner": owner_identity(), "machine": machine_id(),
"reported_at": int(time.time()), "keeper_version": KEEPER_VERSION,
"logged_out": _logged_out(), # container-level Anthropic auth state (proactive)
"sessions": collect_sessions()}
data = json.dumps(payload).encode()
req = urllib.request.Request(SERVICE + "/api/keeper/report", data=data,
method="POST", headers=_headers())
with urllib.request.urlopen(req, timeout=20) as r:
return json.loads(r.read()), len(payload["sessions"])
def collect_one(sid):
"""Build the board entry for a SINGLE session (cheap — one transcript)."""
jp = _jsonl_for(sid)
if not jp:
return None
s = _session_proc(sid) or {}
alive = _claude_alive(s.get("pid"))
try:
mt = os.path.getmtime(jp)
except Exception:
mt = time.time()
summ = _summarize(jp, (s or {}).get("cwd") or _session_cwd(sid))
held = bool(alive and s.get("kind") == "interactive")
recent = (time.time() - mt) < 45
status = ("working" if recent else "awaiting") if held else ("working" if recent else "idle")
return {
"session_id": sid, "pid": s.get("pid"), "cwd": s.get("cwd"),
"alive": alive, "status": status, "held_live": held, "resumable": True,
"rc": _is_rc(jp), "title": summ.get("title") or sid[:8],
"last_user_at": summ.get("last_user_at"), "last_agent_at": summ.get("last_agent_at"),
"last_user": (summ.get("last_user") or "")[:8000], "last_agent": (summ.get("last_agent") or "")[:12000],
"recent": summ.get("recent") or [], # card convo view (last ~3 exchanges)
"needs_login": summ.get("needs_login", False),
"interrupted": summ.get("interrupted", False),
"pending_question": summ.get("pending_question"),
"pending_approval": _pending_approval(summ, held, mt, time.time()),
"model": summ.get("model"),
"turn_active": _inflight_alive(sid),
"transcript": summ.get("transcript") or [], # fuller convo for the Detail page (open card only)
"last_at": summ.get("last_at"), "version": s.get("version"),
}
_transcript_sent = {} # sid -> ts of last full-transcript send (throttle)
def send_transcript(sid):
"""Build ONE session's ENTIRE transcript and push it to the service (on demand,
for an open card). Throttled per-session so repeated 'fetch' asks don't rebuild it
every second on a huge session."""
now = time.time()
if now - _transcript_sent.get(sid, 0) < 6:
return
_transcript_sent[sid] = now
jp = _jsonl_for(sid)
if not jp:
return
s = _session_proc(sid) or {}
summ = _summarize(jp, (s or {}).get("cwd") or _session_cwd(sid), full=True)
body = json.dumps({"owner": owner_identity(), "machine": machine_id(),
"session_id": sid, "msgs": summ.get("transcript_full") or [],
"oldest_truncated": summ.get("full_oldest_truncated", False)}).encode()
try:
urllib.request.urlopen(urllib.request.Request(
SERVICE + "/api/keeper/transcript", data=body, method="POST", headers=_headers()), timeout=15).read()
except Exception:
_transcript_sent.pop(sid, None) # let the next fetch retry
def report_one(sid):
"""Fast push of one open card's session so it can feel live."""
e = collect_one(sid)
if not e:
return
body = json.dumps({"owner": owner_identity(), "machine": machine_id(), "session": e}).encode()
try:
urllib.request.urlopen(urllib.request.Request(
SERVICE + "/api/keeper/report-one", data=body, method="POST", headers=_headers()), timeout=10).read()
except Exception:
pass
if __name__ == "__main__":
try: # stamp the running code version so `keeper status` can spot staleness
with open(VERSION_FILE, "w") as _vf:
_vf.write(KEEPER_VERSION)
except Exception:
pass
o = owner_identity(); m = machine_id()
print(f"keeper: v{KEEPER_VERSION} owner={o.get('email')} machine={m.get('proxy_host') or m.get('id')} → {SERVICE}", flush=True)
once = "--once" in os.sys.argv
mid = m.get("id") or m.get("proxy_host")
last_full = 0.0
report_backoff = 0.0 # grows on repeated report failures so a down service / WAF 403 isn't hammered
fails = 0
while True:
now = time.time()
try:
if now - last_full >= REPORT_EVERY + report_backoff: # full sweep: heartbeat + all sessions
res, n = report()
last_full = now; report_backoff = 0.0; fails = 0
print(f"reported {n} rc session(s) → {res.get('ok')}", flush=True)
except Exception as e:
last_full = now # advance even on failure → no hot-spin retry loop
fails += 1
report_backoff = min(120.0, 2.0 ** min(fails, 7))
print(f"report failed ({fails}, backoff {int(report_backoff)}s): {e}", flush=True)
watch, fetch = [], []
try:
watch, fetch = drain_commands(mid) # queued sends + open cards + full-transcript asks
except Exception as e:
print(f"drain failed: {e}", flush=True)
for sid in watch: # fast-push open cards (1 transcript each)
try:
report_one(sid)
except Exception:
pass
for sid in fetch: # ENTIRE convo for a just-opened card (off the loop)
threading.Thread(target=send_transcript, args=(sid,), daemon=True).start()
if once:
break
time.sleep(0.5 if watch else 2.5) # ACTIVE: 0.5s fast-push while a card is open; else relaxed