diff --git a/skills/abox-live/SKILL.md b/skills/abox-live/SKILL.md new file mode 100644 index 0000000..038be1f --- /dev/null +++ b/skills/abox-live/SKILL.md @@ -0,0 +1,325 @@ +--- +name: abox-live +category: orchestration +description: "Monitor Claude agents running inside your containers, from the host or phone — the single entry point (absorbed the old `abox`). Two views: LIVE (process-based, exact container attribution via cgroup) and `ps` (transcript-file view incl. finished sessions). Read any session (`tail`/`watch`/`path`), and act on live ones (`stop`/`say`/`web`). Use when asked 'what are my agents doing', 'list container agents', 'which sessions are live', 'what container is X running in', 'is anything stuck/blocked', 'show me what is doing', or to check on / talk to background container work." +--- + +# abox-live — the single container-agent monitor (live sessions ↔ containers) + +In-container `claude` (billed via the AMD gateway API key) writes its transcript to +`/root/.claude/projects//.jsonl`, bind-mounted to the host's +`~/.claude/projects/...`. So the host watches every container agent by reading those +JSONL files — no docker exec, no resume needed for monitoring. + +**This is the one entry point.** It replaced the old standalone `abox` CLI, whose +reader engine (`ps`/`tail`/`watch`/`path`) now lives here (`abox_report.py`, +`abox_parse.py`). Two complementary views: + +- **`ps`** — lists transcript **files**, so it includes **finished** sessions; but in + the legacy shared dir it cannot attribute the container and prints `shared?`. +- **default (live)** — starts from running **processes**, so container attribution is + **exact** even in the shared dir: + +``` +claude PID ──► /proc/PID/cgroup ──► docker id ──► container name (always exact) +claude PID ──► /proc/PID/cwd ──► project slug ──► session .jsonl (see evidence) +``` + +## Run + +```bash +abox-live # DEFAULT: LIVE table, my containers ↔ sessions (exact attribution) +abox-live # only live sessions in the container matching (substring ok) +abox-live --all # include other people's containers (akao_*, soga_*, …) +abox-live -v # + each session's latest turn +abox-live --pid # add the owning PID column +abox-live --group # grouped-by-container view instead of the table +abox-live --json # machine-readable (full session id + transcript path; has "mine") +# reader engine (absorbed from `abox`): +abox-live ps [min] # ALL recent sessions incl. finished (transcript-file view; may show shared?) +abox-live tail [n] # last n turns of a session (id prefix ok) +abox-live watch # live-follow a session (Ctrl-C to stop) +abox-live path # print the session's JSONL path +# act on a live session: +abox-live stop # stop a live session (prints the target; needs --yes to act) +abox-live say "msg" # send a follow-up to that session via `claude --resume` +abox-live web # LOCAL web UI at http://127.0.0.1:8848 — click a session, read it +# remote dashboard (phone / laptop Claude): +abox-live publish # rebuild the stable HTML + print the Artifact params → then call the Artifact tool +abox-live snapshot -o F # self-contained HTML file for hand-off (SendUserFile), NOT published +``` + +## How to answer common asks + +- **"list agents" / "what's running"** → `abox-live` (live, exact container). Add + `abox-live ps` if the user wants finished sessions too. +- **"what is doing" / "show me the aiter one"** → `abox-live tail 8` + (resolve the id from the table first). +- **"is anything stuck / waiting for input"** → `abox-live -v`; a session whose latest + is a question or `[Request interrupted…]` and is idle is likely waiting; confirm with + `tail`. + +## Talking to a session (`abox-live say`) + +Send a follow-up instruction to a specific agent and get its reply, by continuing the +conversation with `claude --resume -p ""` run in the right place: + +```bash +abox-live say fda052ce "往下繼續找 sglang 今天的重點" +abox-live say 5a699b21 "跳過 conc128,先把結果貼出來" --container jacky-…-inferencemax-0714 +abox-live say fda052ce "…" --force # resume even though a live process holds it +abox-live say fda052ce "…" --dangerous # pass --dangerously-skip-permissions +``` + +How it routes and guards: +- **cwd** comes from the transcript's recorded `cwd` field (authoritative), not the + dir-name slug. +- **Container sessions** resume via `docker exec` into the owning container, gated by + the same bind-mount ownership check as `bridge.sh` (refuses other users' containers). +- **Host sessions** run as **root** with `HOME=/home/yichiche` — host transcripts are + usually `root:root` (a container wrote them through the bind-mounted `~/.claude`), so + running as `yichiche` gives "No conversation found". This was the one non-obvious bug + in building it. +- **Refuses a session with a live process** unless `--force`: two `claude` processes + appending to one transcript interleave and corrupt it. Stop it first, or `--force`. +- Default timeout 600s; the reply is printed. Long tasks may outlive it — then poll + with `abox-live tail`. + +This overlaps `bridge.sh exec`, but that starts a **fresh** headless agent; `say` +**continues an existing session** so the agent keeps all its context. + +## Stopping a session + +```bash +abox-live stop 259baf41 # shows container / cwd / pid / last line, kills nothing +abox-live stop 259baf41 --yes # SIGTERM +abox-live stop 259baf41 --yes --force # SIGKILL if it ignores SIGTERM +``` + +It **refuses** when the row's evidence is `ambiguous` or `none` — the pid could belong +to a different session and killing the wrong agent loses in-flight work — and prints +the pid so the user can act deliberately instead. On `cwd+mtime` it warns but proceeds. + +**The published dashboard cannot do this.** An artifact page has no channel back to the +box (only `downloads` and `mcp` capabilities exist here, and no connectors are +attached), so each session block just prints the copy-paste `abox-live stop` command. +Do not promise a working kill button on the web page. + +## Remote dashboard (phone / laptop) — `abox-live publish` + +The user tracks agent progress away from their desk. The GPU box has **no usable port +forwarding**, so the only thing reachable from a phone is a published Artifact. When the +user says **"update / refresh my dashboard"**, "publish the dashboard", `/abox-live +publish`, or asks to see their agents on **mobile / laptop Claude**, do this two-step flow: + +**1. Regenerate + get the params:** + +```bash +abox-live publish # rebuilds the stable HTML, chowns it, prints the Artifact params +``` + +`publish` owns the stable path, the stable URL, and the title/favicon/description, and +folds in sessions that finished <180 min ago. It ends with a `=== PUBLISH ARTIFACT ===` +block listing `file_path` / `url` / `title` / `favicon` / `description`. + +**2. Call the `Artifact` tool with exactly those printed params** — always including +`url=`. A shell script *cannot* publish; only the Artifact tool can, which is why the +script hands you the parameters instead of doing it itself. + +- **URL (stable, never changes):** https://claude.ai/code/artifact/605cb93f-db89-4777-bbfb-6a4a9f50276c +- **Source file (stable path):** `/home/yichiche/agent-scratch/abox-dash/abox-dashboard.html` + +Omitting `url` from a conversation that did not originally publish it mints a *new* URL +and the user's phone bookmark goes stale — the `publish` block prints the right `url` so +you never have to remember it. To change the URL / title / favicon, edit the constants at +the top of `abox_snapshot.py` (single source of truth), not the call site. + +The user approved publishing **full transcripts** to claude.ai for this dashboard +(artifacts are private to their account). That approval covers this dashboard; it is +not a blanket approval to publish other internal content. + +Keep `` exactly `abox dashboard` — it is republished repeatedly to the same +artifact, and the tag (not the `title` parameter) names it, so a timestamped title would +rename it every run. `publish` already emits the constant title. + +**Auto-refresh (optional):** to keep the artifact current while away, run `abox-live +publish` on a `/loop` (e.g. every 10–15 min) and re-call Artifact with the same `url` +each iteration — same URL, so the phone bookmark keeps showing fresh data. + +## Dashboard file (`abox-live snapshot`) — offline / no-upload path + +**This box has no usable port forwarding to the user's laptop; the Claude conversation +is the only channel.** So do not tell them to open a URL or set up `ssh -L`. Build a +self-contained file and hand it over the conversation instead: + +```bash +abox-live snapshot -o /path/out.html # live sessions + transcripts, embedded +abox-live snapshot --recent 180 # also fold in transcripts that ended <180m ago +abox-live snapshot --turns 200 # more turns per session (default 120) +abox-live snapshot --all # include other people's containers +``` + +Then deliver it with **SendUserFile** (`display: "render"`). One HTML file, no server, +no network: a summary table at the top, then one `<details>` block per session holding +its full transcript (tool calls, results, thinking). + +**All CONTENT must render with zero JavaScript.** The first version drew the whole page +from JS and came out completely blank in this user's file viewer, which sandboxes inline +scripts. `<details>`/`<summary>` gives click-to-expand natively. + +The only JS allowed is *progressive enhancement* that the page works without — today +that is the copy-to-clipboard button on each stop command, where the command itself is +already visible, selectable `<code>` text. Never move content, state, or navigation +into script. + +Keep it small — it travels through the conversation. Defaults (120 turns/session, +4000 chars/block, 400 KB/session) land ~0.4 MB for ~9 sessions. Raise `--turns` only +when asked. + +It is a **snapshot**, not live — regenerate and resend to refresh. Say that plainly +rather than letting them think the page updates itself. + +Do **not** publish this as an Artifact: transcripts are internal work product and an +Artifact would upload them to claude.ai. + +## Web UI (`abox-live web`) — only when ports actually work + +A click-through view of the same data: sessions in a sidebar (state dot, container, +cwd, latest line), transcript in the main pane. Filter box narrows by container / +session / cwd; the list auto-refreshes every 8s and the open transcript refreshes with +it, holding your scroll position unless you're already at the bottom. + +```bash +abox-live web # foreground, Ctrl-C to stop +abox-live web --port 9000 # different port +abox-live web --all # include other people's containers +nohup abox-live web >/tmp/aboxweb.log 2>&1 & # background +``` + +**Binds 127.0.0.1 only** and reads transcripts straight off disk on each request — +nothing leaves the box. This is deliberate: session transcripts are internal work +product, so they must not be published to an external host (no Artifact, no tunnel) +unless the user explicitly asks for that. + +### Reaching it from a laptop — the usual "can't connect" + +`127.0.0.1:8848` in a laptop browser hits **the laptop**, not the GPU box. The server +prints the fix on startup; forward the port first: + +```bash +ssh -L 8848:127.0.0.1:8848 yichiche@<gpu-host> # then open the printed URL +# or in VS Code Remote: PORTS panel -> Forward a Port -> 8848 +``` + +Do **not** "fix" this by binding wider. `--host 0.0.0.0` exists but the box is shared +with other people's containers, and it would expose every transcript to the network. + +### Token + +The GPU box is multi-user, so `127.0.0.1` is *not* private — any local account could +read the transcripts. Startup mints a random token and prints it in the URL +(`http://127.0.0.1:8848/?k=…`); the page then stores it in a cookie, so open the printed +URL once and normal navigation works. `--no-token` disables it. The token changes on +every restart. + +Caps per transcript: last 300 turns, 12000 chars per block — enough for monitoring, and +keeps huge sessions (some are >2 MB) responsive. + +The default is a flat table — one row per live session, columns +`STATE · CONTAINER · SESSION · AGE · EV · CWD` — sorted by container so a container's +sessions sit together, containers before `host`. The container name is **repeated on +every row** (not blanked) so the table stays greppable: + +```bash +abox-live | grep my-new-container # every session in one container +abox-live | grep 🟢 # only what's actively working +``` + +CONTAINER and SESSION are both printed in full so a row can be copied straight into +`docker exec` / `abox-live tail`. `tail` accepts an 8-char prefix. + +**Ownership** is decided by bind mount, not by name: a container is mine when it mounts +my home (`docker inspect` → `.Mounts.Source` under `/home/<me>`). Name prefixes like +`jacky-*` / `yct_*` are not reliable and are never used for this. Host sessions always +count as mine. Everything else is hidden by default, with a one-line count of what was +suppressed. + +## Typical loop (launch → watch) + +This skill is the *watch* half of spinning up work in containers: + +1. Start a container and run `claude` inside it. +2. `abox-live` — the new container appears once its session writes a first turn. + Until then it may show `❔` / no session, which is expected, not an error. +3. `abox-live | grep <container>` to isolate it; 🟢 means it is working, ⚪ means it has + gone quiet and is probably waiting on you. +4. `abox-live tail <session>` to see what it actually said. + +Needs one `sudo -n` (reads `/proc/<pid>/cgroup` and root-owned transcripts). The +wrapper handles it; the script itself must run as root. + +## Reading the output + +State: 🟢 transcript written <2 min ago (actively working) · ⚪ transcript idle, so the +agent is likely **waiting on you** (confirm with `abox-live tail <id>`) · ❔ no transcript +matched. + +**The `EV` column is the point — do not report a session id without it.** The table +prints short codes; `--group` and `--json` print the long form. + +| EV | long form | meaning | trust | +|---|---|---|---| +| `exact` | `resume-flag` | id read from `--resume=<uuid>` on the cmdline | exact | +| `exact` | `child-proc` | id read from a descendant tool-call process | exact | +| `infer` | `cwd+mtime` | newest unclaimed transcript for that cwd | inferred | +| `ambig` | `ambiguous` | N live procs share one cwd — see below | set right, row may be permuted | +| `-` | `none` | no transcript found for that cwd | container still exact | + +A transcript is only ever assigned to one process, and exact evidence always wins, so +an `ambiguous` group of N processes gets the N most recent sessions for that cwd. The +**set** of live sessions is right and the **container** is right; which pid owns which +row inside that group is a guess. This is normal on the host, where several sessions +(and any subagents) share `/home/yichiche`. + +`❔ / none` rows are typically **another user's container** (e.g. `akao_*`) — its +`~/.claude` is not bind-mounted into yours, so the transcript is unreadable from here. +The container name is still exact, which is usually the question being asked. + +## Reporting to the user + +**Default: show the table.** Print the `abox-live` table verbatim as the answer — this +user wants the table, not a prose rewrite of it. A one-line lead above it is fine +("5 live sessions, 2 working") and a short note below for anything the table can't show +(e.g. "the `inferencemax` rows are idle — probably waiting on you"), but do not replace +the table with sentences. + +Say "inferred" out loud whenever a row you quote is `cwd+mtime` or `ambiguous`. To +explain what an agent is *doing*, follow with `abox-live tail <id>` — the live table +answers *where*, `tail`/`ps` answer *what*. + +When the user names a container, use `abox-live <name>` to scope the table to it. + +## Limits + +- Read-only. It cannot steer or resume an agent. +- Transcripts are appended per turn, so a mid-turn agent shows its last completed turn + (≈ one turn of lag); AGE is transcript-write age, not process age. +- Sessions whose process has exited are not shown at all — use `abox-live ps` for history. + +## Container auth (`claude-container-auth.sh`) + +Run **inside a container** during bootstrap (replaces the credential-stripping part of +`claude-code-key.sh` safely). Wires API-key auth via a container-local `CLAUDE_CONFIG_DIR` +and symlinks shared skills/settings — host subscription login is never touched. + +```bash +bash ~/agent-box/skills/abox-live/claude-container-auth.sh +``` + +From `container-dep.sh`, call this instead of `claude-code-key.sh` when migrating. + +## Related + +- **`/create-new-rc`** — persistent Remote Control session for phone monitoring +- `gpu-to-containers` — same cgroup-join trick, for GPU owners +- Implementation: `~/agent-box/skills/abox-live/` diff --git a/skills/abox-live/abox-live b/skills/abox-live/abox-live new file mode 100755 index 0000000..268c783 --- /dev/null +++ b/skills/abox-live/abox-live @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# abox-live — LIVE claude sessions + the container each one runs in. +# +# Starts from running PROCESSES (not transcript files), so it resolves the container +# exactly via cgroup even for sessions in the legacy shared projects dir, where +# `abox-live ps` can only print "shared?". +# +# Usage: +# abox-live table of live sessions (container, session id, cwd, age) +# abox-live <name> only live sessions in the container matching <name> +# abox-live -v same, plus each session's latest turn +# abox-live --json machine-readable +# abox-live ps [min] ALL recent sessions incl. finished (transcript-file view) +# abox-live tail <id> [n] last n turns of a session +# abox-live watch <id> live-follow a session (Ctrl-C to stop) +# abox-live path <id> print the session's JSONL path +# +# The reader engine (ps/tail/watch/path) was formerly the standalone `abox` CLI; +# it is now absorbed here (abox_report.py / abox_parse.py in this dir) so abox-live +# is the single monitoring entry point. Files are root:root 0600 -> one sudo. +set -uo pipefail +HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" +REPORT="$HERE/abox_report.py" +PARSE="$HERE/abox_parse.py" +PROJDIR="${CLAUDE_PROJECTS_DIR:-$HOME/.claude/projects}" + +if [ "${1:-}" = "ps" ]; then + lb="${2:-${LOOKBACK_MIN:-360}}" + exec sudo -n python3 "$REPORT" ps "$lb" "$PROJDIR" +fi +if [ "${1:-}" = "tail" ] || [ "${1:-}" = "show" ] || [ "${1:-}" = "path" ]; then + cmd="$1"; shift + exec sudo -n python3 "$REPORT" "$cmd" "${1:-}" "${2:-}" "$PROJDIR" +fi +if [ "${1:-}" = "watch" ]; then + sel="${2:?usage: abox-live watch <session-id-prefix>}" + f=$(sudo -n python3 "$REPORT" path "$sel" "$PROJDIR" </dev/null 2>/dev/null) + case "$f" in /*) : ;; *) echo "no session matching '$sel'"; exit 1 ;; esac + echo "# watching $(basename "$f" .jsonl) (Ctrl-C to stop)" + exec sudo -n tail -n0 -F "$f" 2>/dev/null | while IFS= read -r ln; do + printf '%s\n' "$ln" | python3 "$PARSE" lastn 1 + done +fi +if [ "${1:-}" = "web" ]; then + shift + exec sudo -n python3 "$HERE/abox_web.py" "$@" +fi +if [ "${1:-}" = "snapshot" ]; then + shift + exec sudo -n python3 "$HERE/abox_snapshot.py" "$@" +fi +if [ "${1:-}" = "publish" ]; then + # Refresh the ONE remote dashboard artifact: regenerate at the stable path, then + # print the exact Artifact-tool params for the agent to publish (same url=). + shift + exec sudo -n python3 "$HERE/abox_snapshot.py" --publish "$@" +fi +if [ "${1:-}" = "stop" ]; then + shift + exec sudo -n python3 "$HERE/abox_stop.py" "$@" +fi +if [ "${1:-}" = "say" ]; then + shift + exec sudo -n python3 "$HERE/abox_say.py" "$@" +fi +exec sudo -n python3 "$HERE/abox_live.py" "$@" diff --git a/skills/abox-live/abox_live.py b/skills/abox-live/abox_live.py new file mode 100755 index 0000000..42fd027 --- /dev/null +++ b/skills/abox-live/abox_live.py @@ -0,0 +1,387 @@ +#!/usr/bin/env python3 +"""abox-live — list LIVE claude agent sessions and the container each runs in. + +Unlike `abox-live ps` (which lists transcript FILES on disk and cannot attribute the +legacy shared projects dir to a container), this starts from live PROCESSES: + + claude PID --> cgroup --> docker id --> container name + claude PID --> cwd --> project slug --> session .jsonl + +Session id is resolved by strongest available evidence: + exact: --resume=<uuid> on the claude cmdline + exact: a descendant tool-call process carrying <uuid>.jsonl / SESSION_ID + infer: newest-mtime transcript in the project dir matching the process cwd + +Run as root (needs /proc/<pid>/cgroup + root-owned transcripts). +""" +import os, re, sys, glob, json, time, subprocess + +SHARED_PROJ = "/home/yichiche/.claude/projects" +UUID = r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" + + +def sh(cmd): + try: + return subprocess.run(cmd, shell=True, capture_output=True, text=True, + timeout=30).stdout + except Exception: + return "" + + +def my_home(): + u = os.environ.get("SUDO_USER") or os.environ.get("USER") or "" + h = "/home/%s" % u if u and u != "root" else "" + return h if h and os.path.isdir(h) else os.path.dirname(SHARED_PROJ.rstrip("/") + .rsplit("/.claude", 1)[0]) + + +def docker_names(): + """full/short container id -> name""" + m = {} + for ln in sh("docker ps --no-trunc --format '{{.ID}}\t{{.Names}}'").splitlines(): + if "\t" in ln: + cid, name = ln.split("\t", 1) + m[cid.strip()] = name.strip() + return m + + +def mine_map(names): + """container name -> True when it bind-mounts MY home (so it's my agent, not a + co-worker's). Name prefixes are unreliable here; the mount is definitive.""" + home = my_home() + out = {} + if not names: + return out + q = " ".join("'%s'" % n for n in names.values()) + raw = sh("docker inspect -f '{{.Name}}|{{range .Mounts}}{{.Source}},{{end}}' " + q) + for ln in raw.splitlines(): + if "|" not in ln: + continue + nm, srcs = ln.split("|", 1) + nm = nm.strip().lstrip("/") + out[nm] = any(s == home or s.startswith(home + "/") + for s in srcs.split(",") if s) + return out + + +def procs(): + """[{pid, ppid, args}] for every process (root view).""" + out = [] + raw = sh("ps -eo pid=,ppid=,args=") + for ln in raw.splitlines(): + parts = ln.strip().split(None, 2) + if len(parts) < 3: + continue + try: + out.append({"pid": int(parts[0]), "ppid": int(parts[1]), "args": parts[2]}) + except ValueError: + continue + return out + + +def read(path): + try: + with open(path, "rb") as f: + return f.read() + except Exception: + return b"" + + +def container_of(pid, names): + cg = read("/proc/%d/cgroup" % pid).decode("utf-8", "replace") + m = re.search(r"docker[-/]([0-9a-f]{12,64})", cg) + if not m: + return "host", None + cid = m.group(1) + for full, name in names.items(): + if full.startswith(cid) or cid.startswith(full): + return name, cid[:12] + return "?" + cid[:12], cid[:12] + + +def cwd_of(pid): + try: + return os.readlink("/proc/%d/cwd" % pid) + except Exception: + return "" + + +def slug(path): + return re.sub(r"[^a-zA-Z0-9]", "-", path) + + +def roots_for(container): + """project dirs to search (bind-mounted ~/.claude/projects).""" + return [SHARED_PROJ] + + +def sessions_in(root, sl): + d = os.path.join(root, sl) + if not os.path.isdir(d): + return [] + out = [] + for f in glob.glob(os.path.join(d, "*.jsonl")): + try: + out.append((os.path.getmtime(f), f)) + except OSError: + pass + return sorted(out, reverse=True) + + +def find_by_id(sid, container): + for root in roots_for(container): + hits = glob.glob(os.path.join(root, "*", sid + ".jsonl")) + if hits: + return hits[0] + return None + + +def descendants(pid, kids): + seen, stack, out = set(), [pid], [] + while stack: + p = stack.pop() + for c in kids.get(p, []): + if c not in seen: + seen.add(c) + out.append(c) + stack.append(c) + return out + + +def last_turn(path, maxbytes=400000): + """(mtime, short text of last assistant/user turn)""" + try: + size = os.path.getsize(path) + with open(path, "rb") as f: + if size > maxbytes: + f.seek(size - maxbytes) + f.readline() + lines = f.read().decode("utf-8", "replace").splitlines() + except Exception: + return 0, "" + for ln in reversed(lines): + try: + o = json.loads(ln) + except Exception: + continue + if o.get("type") not in ("assistant", "user"): + continue + msg = o.get("message", {}) + c = msg.get("content") if isinstance(msg, dict) else None + parts = [] + if isinstance(c, str): + parts = [c] + elif isinstance(c, list): + for p in c: + if not isinstance(p, dict): + continue + if p.get("type") == "text": + parts.append(p.get("text", "")) + elif p.get("type") == "tool_use": + parts.append("[tool:%s]" % p.get("name", "")) + elif p.get("type") == "tool_result": + parts.append("[tool_result]") + t = " ".join(" ".join(parts).split()) + if t: + return os.path.getmtime(path), t + return os.path.getmtime(path), "" + + +def age(sec): + if sec < 0: + return "-" + if sec < 90: + return "%ds" % int(sec) + if sec < 5400: + return "%dm" % int(sec // 60) + return "%dh" % int(sec // 3600) + + +def collect(): + names = docker_names() + all_procs = procs() + kids = {} + for p in all_procs: + kids.setdefault(p["ppid"], []).append(p["pid"]) + by_pid = {p["pid"]: p for p in all_procs} + + claudes = [p for p in all_procs + if re.search(r"(^|/)claude(\s|$)", p["args"].split(" -")[0]) + or re.match(r"^claude(\s|$)", p["args"])] + + # Pass 1 — exact ids only (cmdline / descendant tool-call process). + staged = [] + for p in claudes: + pid, args = p["pid"], p["args"] + container, cid = container_of(pid, names) + sid, ev = None, "" + + m = re.search(r"--resume[= ](" + UUID + ")", args) + if m: + sid, ev = m.group(1), "resume-flag" + + if not sid: + for c in descendants(pid, kids): + a = by_pid.get(c, {}).get("args", "") + m = re.search(r"(" + UUID + r")\.jsonl", a) or \ + re.search(r"SESSION_ID=.?(" + UUID + ")", a) + if m: + sid, ev = m.group(1), "child-proc" + break + + path = find_by_id(sid, container) if sid else None + staged.append({"pid": pid, "container": container, "cwd": cwd_of(pid), + "session": sid, "evidence": ev, "path": path}) + + # Pass 2 — infer the rest from cwd, never re-claiming a transcript that some + # process already owns by exact evidence. + claimed = {s["path"] for s in staged if s["path"]} + need = [s for s in staged if not s["path"] and s["cwd"]] + # When N live procs share one cwd, the newest-first assignment gets the SET of + # sessions right but may permute which pid owns which — flag the whole group. + share = {} + for s in need: + share[(s["container"], s["cwd"])] = share.get((s["container"], s["cwd"]), 0) + 1 + for s in need: + cands = [] + for root in roots_for(s["container"]): + cands += sessions_in(root, slug(s["cwd"])) + cands.sort(reverse=True) + for _, f in cands: + if f in claimed: + continue + s["path"], s["session"] = f, os.path.basename(f)[:-6] + s["evidence"] = "ambiguous" if share[(s["container"], s["cwd"])] > 1 else "cwd+mtime" + claimed.add(f) + break + + mine = mine_map(names) + rows = [] + for s in staged: + mt, latest = (last_turn(s["path"]) if s["path"] else (0, "")) + rows.append({ + "pid": s["pid"], "container": s["container"], "cwd": s["cwd"], + "session": s["session"] or "?", "evidence": s["evidence"] or "none", + "path": s["path"] or "", "mtime": mt, "latest": latest, + "idle": (time.time() - mt) if mt else -1, + # host sessions run as me; containers must mount my home to count as mine + "mine": True if s["container"] == "host" else mine.get(s["container"], False), + }) + rows.sort(key=lambda r: (r["idle"] if r["idle"] >= 0 else 1e9)) + return rows + + +def shorten(name, n): + """drop the noisy image-version prefix container names share""" + s = re.sub(r"^jacky-v[0-9.]+-rocm[0-9]+-mi[0-9a-z]+-", "", name) + return s[:n] + + +def ltrunc(s, n): + return s if len(s) <= n else "…" + s[-(n - 1):] + + +EV_SHORT = {"resume-flag": "exact", "child-proc": "exact", + "cwd+mtime": "infer", "ambiguous": "ambig", "none": "-"} + + +def table(rows, verbose, with_pid): + """Default view: a lookup table, sorted by container so a given container's + sessions sit together and the whole thing stays greppable.""" + rows = sorted(rows, key=lambda r: (r["container"] == "host", r["container"], + r["idle"] if r["idle"] >= 0 else 1e9)) + cw = max([len(r["container"]) for r in rows] + [9]) + cw = min(cw, 46) + head = "%-2s " % "" + fmt = "%-2s " + if with_pid: + head += "%7s " % "PID" + fmt += "%7s " + head += "%-*s %-36s %5s %-5s %s" % (cw, "CONTAINER", "SESSION", "AGE", "EV", "CWD") + fmt += "%-*s %-36s %5s %-5s %s" + print(head) + print("-" * (len(head) + 6)) + for r in rows: + state = "🟢" if 0 <= r["idle"] < 120 else ("⚪" if r["idle"] >= 0 else "❔") + # Every row repeats the container name: this table is meant to be grepped + # (`abox-live | grep <container>`), and blanks would drop the later rows. + args = [state] + ([r["pid"]] if with_pid else []) + [ + cw, r["container"][:cw], r["session"][:36], age(r["idle"]), + EV_SHORT.get(r["evidence"], r["evidence"]), ltrunc(r["cwd"] or "-", 30)] + print(fmt % tuple(args)) + if verbose and r["latest"]: + print("%s└─ %s" % (" " * (12 if with_pid else 3), r["latest"][:88])) + + +def by_container(rows, verbose): + """Default view: full container name, then the sessions running inside it.""" + groups = {} + for r in rows: + groups.setdefault(r["container"], []).append(r) + order = sorted(groups, key=lambda c: (c == "host", c.startswith("?"), c)) + for c in order: + rs = groups[c] + n = len(rs) + print("\n%s (%d session%s)" % (c, n, "" if n == 1 else "s")) + for r in rs: + state = "🟢" if 0 <= r["idle"] < 120 else ("⚪" if r["idle"] >= 0 else "❔") + sid = r["session"] if r["session"] != "?" else "? (transcript not readable from host)" + print(" %s %-36s %5s %-11s %s" % ( + state, sid, age(r["idle"]), r["evidence"], ltrunc(r["cwd"] or "-", 30))) + if verbose and r["latest"]: + print(" └─ %s" % r["latest"][:92]) + + +def main(): + argv = sys.argv[1:] + as_json = "--json" in argv + verbose = "-v" in argv or "--verbose" in argv + group = "--group" in argv + with_pid = "--pid" in argv or "--flat" in argv + show_all = "--all" in argv + # First non-flag positional = container filter (name or substring), e.g. + # `abox-live yct-aiter-test-0720` -> only that container's live sessions. + container_filter = next((a for a in argv if not a.startswith("-")), None) + rows = collect() + if container_filter: + rows = [r for r in rows if container_filter in r["container"]] + # An explicitly named container is shown regardless of ownership — + # the user asked for it by name, so don't hide it as "not mine". + show_all = True + hidden = 0 + if not show_all: + hidden = sum(1 for r in rows if not r["mine"]) + rows = [r for r in rows if r["mine"]] + if as_json: + print(json.dumps(rows, indent=2, ensure_ascii=False)) + return + if not rows: + if container_filter: + print("no live claude sessions in container matching '%s'" % container_filter) + print("(the container may be up but idle — a headless `claude -p` exits when " + "it finishes, leaving no live session; check `docker ps` / `abox-live ps`)") + else: + print("no live claude sessions found") + return + + w = 116 + if group: + by_container(rows, verbose) + else: + table(rows, verbose, with_pid) + print("-" * w) + live = sum(1 for r in rows if 0 <= r["idle"] < 120) + print("%d live claude session%s%s, %d actively writing" % ( + len(rows), "" if len(rows) == 1 else "s", + "" if show_all else " of mine", live)) + print("🟢 wrote <2m ago (working) ⚪ idle (may be waiting on you) " + "EV: exact = id confirmed · infer = newest transcript for that cwd · " + "ambig = procs share a cwd, row may be permuted") + print("next: abox-live tail <session> (8-char prefix is enough)") + if hidden: + print("(%d session%s in other people's containers hidden — `abox-live --all`)" + % (hidden, "" if hidden == 1 else "s")) + + +if __name__ == "__main__": + main() diff --git a/skills/abox-live/abox_parse.py b/skills/abox-live/abox_parse.py new file mode 100755 index 0000000..b32244c --- /dev/null +++ b/skills/abox-live/abox_parse.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Parse a Claude Code session JSONL (from stdin) for the abox monitor. + +Modes: + latest one-line summary of the most recent meaningful turn + lastn <n> last n user/assistant turns (truncated) + meta cwd + git branch + turn count (first-seen fields) +""" +import sys, json + +mode = sys.argv[1] if len(sys.argv) > 1 else "latest" +n = int(sys.argv[2]) if len(sys.argv) > 2 else 6 + + +def turn_text(o): + m = o.get("message", {}) + c = m.get("content") if isinstance(m, dict) else None + if isinstance(c, str): + return " ".join(c.split()) + parts = [] + if isinstance(c, list): + for p in c: + if not isinstance(p, dict): + continue + pt = p.get("type") + if pt == "text": + parts.append(p.get("text", "")) + elif pt == "tool_use": + parts.append("[tool:" + p.get("name", "") + "]") + elif pt == "tool_result": + parts.append("[tool_result]") + return " ".join(" ".join(parts).split()) + + +turns = [] +cwd = "" +for ln in sys.stdin: + try: + o = json.loads(ln) + except Exception: + continue + if not cwd and o.get("cwd"): + cwd = o["cwd"] + if o.get("type") in ("user", "assistant"): + txt = turn_text(o) + if txt: + turns.append((o["type"], txt)) + +if mode == "meta": + print("cwd=%s turns=%d" % (cwd or "?", len(turns))) +elif mode == "latest": + # prefer the last turn that carries real text, not just a tool_result echo + pick = "" + for role, txt in reversed(turns): + if txt and txt != "[tool_result]": + pick = "%s %s" % ("»" if role == "assistant" else "?", txt) + break + if not pick and turns: + pick = turns[-1][1] + print(pick) +else: # lastn + for role, txt in turns[-n:]: + print("[%-9s] %s" % (role, txt[:500])) diff --git a/skills/abox-live/abox_report.py b/skills/abox-live/abox_report.py new file mode 100644 index 0000000..95009fa --- /dev/null +++ b/skills/abox-live/abox_report.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +"""abox report engine — runs as root (single sudo) so it can read every +container-written session JSONL (root:root 0600) without per-file sudo. + +Commands: + ps [lookback_min] table of recent sessions, newest activity first + tail <id> [n] last n user/assistant turns of one session + path <id> print the JSONL path +""" +import os, sys, glob, json, time, re + +# Overridable by a trailing argv (see main); default to the known host path because +# this runs as root under sudo, where ~ would wrongly expand to /root. +PROJ = "/home/yichiche/.claude/projects" + + +def scan_roots(): + """[(projects_dir, container_label)] — bind-mounted ~/.claude/projects.""" + return [(PROJ, "shared")] + + +def iter_sessions(): + for base, container in scan_roots(): + for f in glob.glob(os.path.join(base, "*", "*.jsonl")): + yield f, container + + +def short_container(c): + if c == "shared": + return "shared?" + return re.sub(r"^jacky-v[0-9.]+-rocm[0-9]+-mi[0-9a-z]+-", "", c)[:20] or c[:20] + + +def turn_text(o): + m = o.get("message", {}) + c = m.get("content") if isinstance(m, dict) else None + if isinstance(c, str): + return " ".join(c.split()) + parts = [] + if isinstance(c, list): + for p in c: + if not isinstance(p, dict): + continue + pt = p.get("type") + if pt == "text": + parts.append(p.get("text", "")) + elif pt == "tool_use": + parts.append("[tool:" + p.get("name", "") + "]") + elif pt == "tool_result": + parts.append("[tool_result]") + return " ".join(" ".join(parts).split()) + + +def load_turns(path): + turns, cwd = [], "" + try: + with open(path, errors="ignore") as fh: + for ln in fh: + try: + o = json.loads(ln) + except Exception: + continue + if not cwd and o.get("cwd"): + cwd = o["cwd"] + if o.get("type") in ("user", "assistant"): + t = turn_text(o) + if t: + turns.append((o["type"], t)) + except Exception: + pass + return turns, cwd + + +def latest_line(turns): + for role, txt in reversed(turns): + if txt and txt != "[tool_result]": + return ("» " if role == "assistant" else "? ") + txt + return turns[-1][1] if turns else "" + + +def hage(s): + if s < 90: + return "%ds" % s + if s < 5400: + return "%dm" % round(s / 60) + if s < 86400: + return "%dh" % round(s / 3600) + return "%dd" % round(s / 86400) + + +def hsize(b): + n, u = float(b), ["B", "K", "M", "G"] + i = 0 + while n >= 1024 and i < 3: + n /= 1024.0 + i += 1 + return ("%d%s" if i == 0 else "%.1f%s") % (n, u[i]) + + +def find_one(sel): + for base, _ in scan_roots(): + for pat in ("%s*.jsonl" % sel, "*%s*.jsonl" % sel): + hits = glob.glob(os.path.join(base, "*", pat)) + if hits: + return hits[0] + return None + + +def cmd_ps(lookback_min=360): + now = time.time() + cutoff = now - lookback_min * 60 + rows = [] + for f, container in iter_sessions(): + try: + st = os.stat(f) + except OSError: + continue + if st.st_mtime < cutoff: + continue + rows.append((st.st_mtime, f, st.st_size, container)) + rows.sort(reverse=True) + fmt = "%-2s %-20s %-22s %-8s %5s %7s %s" + print(fmt % ("", "CONTAINER", "PROJECT (cwd)", "SESSION", "AGE", "SIZE", "LATEST")) + print(fmt % ("", "-" * 20, "-" * 22, "-" * 8, "-" * 5, "-" * 7, "-" * 26)) + for mt, f, sz, container in rows: + age = int(now - mt) + state = "\U0001F7E2" if age < 120 else ("⚪" if age < 1800 else "✓") + proj = os.path.basename(os.path.dirname(f))[:22] + sid = os.path.basename(f)[:-6][:8] + turns, _ = load_turns(f) + lat = latest_line(turns)[:52] + print(fmt % (state, short_container(container), proj, sid, hage(age), hsize(sz), lat)) + print() + print("\U0001F7E2 active(<2m) ⚪ idle ✓ older · 'shared?' = legacy shared dir (pre-isolation, container unknown)") + + +def cmd_tail(sel, n=8): + f = find_one(sel) + if not f: + print("no session matching '%s'" % sel) + return 1 + print("# %s %s" % (os.path.basename(os.path.dirname(f)), os.path.basename(f)[:-6])) + turns, _ = load_turns(f) + for role, txt in turns[-n:]: + print("[%-9s] %s" % (role, txt[:500])) + + +def cmd_path(sel): + f = find_one(sel) + if not f: + print("no match") + return 1 + print(f) + + +def main(): + global PROJ + args = list(sys.argv[1:]) + # a trailing arg that is an existing .../projects dir overrides PROJ + if args and args[-1].rstrip("/").endswith("projects") and os.path.isdir(args[-1]): + PROJ = args.pop() + args = [a for a in args if a != ""] # tolerate empty placeholder args from the shell + cmd = args[0] if args else "ps" + rest = args[1:] + if cmd == "ps": + cmd_ps(int(rest[0]) if rest else 360) + elif cmd in ("tail", "show"): + sel = rest[0] if rest else "" + n = int(rest[1]) if len(rest) > 1 else 8 + sys.exit(cmd_tail(sel, n) or 0) + elif cmd == "path": + sys.exit(cmd_path(rest[0] if rest else "") or 0) + else: + print(__doc__) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/skills/abox-live/abox_say.py b/skills/abox-live/abox_say.py new file mode 100644 index 0000000..900ff1d --- /dev/null +++ b/skills/abox-live/abox_say.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""abox-say — send a follow-up instruction to a specific agent session. + +Continues an existing conversation with `claude --resume <session> -p "<message>"`, +run in the right place: inside the owning container for container sessions, on the +host for host sessions. The reply is printed. + + sudo -n python3 abox_say.py <session-id-prefix> "message" [--container C] + [--force] [--timeout S] [--dangerous] +""" +import os, re, sys, glob, json, subprocess + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import abox_live as AL + +HOST_USER = "yichiche" + + +def find_transcript(sel): + """(full_session_id, path) for a prefix under ~/.claude/projects.""" + hits = glob.glob(os.path.join(AL.SHARED_PROJ, "*", sel + "*.jsonl")) + hits = sorted(set(hits)) + if not hits: + return None, None + if len(hits) > 1: + print("'%s' matches %d transcripts:" % (sel, len(hits))) + for h in hits: + print(" %s" % os.path.basename(h)[:-6]) + return None, None + return os.path.basename(hits[0])[:-6], hits[0] + + +def cwd_of(path): + """the cwd recorded in the transcript — authoritative, unlike the dir-name slug""" + try: + with open(path, encoding="utf-8", errors="replace") as f: + for ln in f: + try: + o = json.loads(ln) + except Exception: + continue + if o.get("cwd"): + return o["cwd"] + except Exception: + pass + return "" + + +def owned(container): + """same rule as bridge.sh: ours iff it bind-mounts our home""" + out = AL.sh("docker inspect %s --format '{{range .Mounts}}{{.Source}}{{\"\\n\"}}{{end}}'" + % container) + return any(l.strip() == "/home/" + HOST_USER for l in out.splitlines()) + + +def main(): + argv = sys.argv[1:] + pos = [a for a in argv if not a.startswith("--")] + if len(pos) < 2: + print('usage: abox-live say <session-id-prefix> "message" ' + "[--container C] [--force] [--timeout S] [--dangerous]") + return 2 + sel, message = pos[0], pos[1] + force = "--force" in argv + dangerous = "--dangerous" in argv + timeout = 600 + if "--timeout" in argv: + timeout = int(argv[argv.index("--timeout") + 1]) + container = None + if "--container" in argv: + container = argv[argv.index("--container") + 1] + + sid, path = find_transcript(sel) + if not sid: + print("no transcript matching '%s'" % sel) + return 1 + + live = [r for r in AL.collect() if r["session"] == sid] + cwd = (live[0]["cwd"] if live else "") or cwd_of(path) + if not container: + container = live[0]["container"] if live else None + + print("session %s" % sid) + print("cwd %s" % (cwd or "(unknown)")) + print("target %s" % (container or "host")) + + if live and not force: + # Two processes appending to one transcript interleave and corrupt the + # conversation; make the user choose explicitly. + print("\nREFUSING: a live process (pid %s) is still running this session, so " + "resuming\nit now would write into the same transcript from two places." + "\n\nEither stop it first: abox-live stop %s --yes" + "\nor override: abox-live say %s \"…\" --force" + % (live[0]["pid"], sid[:8], sid[:8])) + return 1 + + if not cwd: + print("\ncannot resume: no cwd recorded for this session") + return 1 + + flags = "--dangerously-skip-permissions " if dangerous else "" + inner = ('export PATH="$HOME/.local/bin:$PATH"; cd %s 2>/dev/null || cd /; ' + 'claude --resume %s -p %s"$PROMPT"' % (quote(cwd), sid, flags)) + + if container and container != "host": + if not owned(container): + print("\nREFUSED: '%s' does not mount /home/%s — not yours. Shared host." + % (container, HOST_USER)) + return 1 + cmd = ["docker", "exec", "-e", "PROMPT=" + message, container, "bash", "-lc", inner] + else: + # Host-side transcripts are frequently root:root (written by a container that + # bind-mounts ~/.claude), so claude must run as root to read them; HOME points + # at the real projects dir so --resume finds the session and its credentials. + cmd = ["env", "HOME=/home/" + HOST_USER, "PROMPT=" + message, + "bash", "-lc", inner] + + print("\n→ resuming (timeout %ds)…\n" % timeout) + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + except subprocess.TimeoutExpired: + print("timed out after %ds — the agent may still be working; " + "check with `abox-live tail %s`" % (timeout, sid[:8])) + return 1 + out = (r.stdout or "") + (r.stderr or "") + print(out.strip() or "(no output)") + return r.returncode + + +def quote(s): + return "'" + s.replace("'", "'\\''") + "'" + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/abox-live/abox_snapshot.py b/skills/abox-live/abox_snapshot.py new file mode 100644 index 0000000..e992b97 --- /dev/null +++ b/skills/abox-live/abox_snapshot.py @@ -0,0 +1,464 @@ +#!/usr/bin/env python3 +"""abox-snapshot — build a SELF-CONTAINED dashboard of live sessions + transcripts. + +No server, no ports: every session and its turns are embedded into one .html file that +works by double-clicking it. Built for the case where the only channel to this box is +the Claude conversation itself (port forwarding unavailable), so the file is handed +over through that channel instead of being served. + + sudo -n python3 abox_snapshot.py [-o out.html] [--all] [--turns N] [--recent MIN] +""" +import os, re, sys, json, glob, time, pwd, subprocess + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import abox_live as AL + +MAX_TURNS = 120 +MAX_BLOCK = 4000 +MAX_SESSION_BYTES = 400_000 + +# --- Remote dashboard: the ONE published artifact --------------------------- +# `--publish` regenerates the snapshot at this stable path and emits the exact +# Artifact-tool call for the agent to run, so every conversation refreshes the +# SAME artifact URL (bookmarked on the user's phone) instead of minting a new one. +# The agent cannot publish from a shell — only the Artifact tool can — so the +# script's job is to (re)build the file and hand the agent the parameters. +PUBLISH_OWNER = "yichiche" +STABLE_OUT = "/home/yichiche/agent-scratch/abox-dash/abox-dashboard.html" +ARTIFACT_URL = "https://claude.ai/code/artifact/605cb93f-db89-4777-bbfb-6a4a9f50276c" +ARTIFACT_TITLE = "abox dashboard" # must match <title>; renames artifact if changed +ARTIFACT_FAVICON = "🛰️" +ARTIFACT_DESC = "Live status of Claude agents running in my containers" + + +def all_roots(): + """[(projects_dir, container_label)] — bind-mounted ~/.claude/projects.""" + return [(AL.SHARED_PROJ, "shared")] + + +def parse_turns(path, max_turns): + out = [] + try: + with open(path, "rb") as f: + raw = f.read().decode("utf-8", "replace") + except Exception as e: + return [{"role": "error", "ts": "", "blocks": [{"kind": "text", + "text": "cannot read transcript: %s" % e}]}] + for ln in raw.splitlines(): + try: + o = json.loads(ln) + except Exception: + continue + if o.get("type") not in ("assistant", "user"): + continue + m = o.get("message", {}) + c = m.get("content") if isinstance(m, dict) else None + blocks = [] + if isinstance(c, str): + blocks.append({"kind": "text", "text": c[:MAX_BLOCK]}) + elif isinstance(c, list): + for p in c: + if not isinstance(p, dict): + continue + k = p.get("type") + if k == "text": + blocks.append({"kind": "text", "text": p.get("text", "")[:MAX_BLOCK]}) + elif k == "thinking": + blocks.append({"kind": "think", "text": p.get("thinking", "")[:MAX_BLOCK]}) + elif k == "tool_use": + blocks.append({"kind": "tool", "text": p.get("name", ""), + "extra": json.dumps(p.get("input", {}))[:1200]}) + elif k == "tool_result": + t = p.get("content") + if isinstance(t, list): + t = " ".join(x.get("text", "") for x in t if isinstance(x, dict)) + blocks.append({"kind": "result", "text": str(t)[:MAX_BLOCK]}) + if blocks: + out.append({"role": o.get("type"), "ts": (o.get("timestamp") or "")[:19], + "blocks": blocks}) + out = out[-max_turns:] + # keep any single session from dominating the file + while len(json.dumps(out)) > MAX_SESSION_BYTES and len(out) > 8: + out = out[len(out) // 4:] + return out + + +def build(show_all, max_turns, recent_min): + rows = AL.collect() + if not show_all: + rows = [r for r in rows if r["mine"]] + seen = {r["path"] for r in rows if r["path"]} + data = [] + for r in rows: + data.append({ + "session": r["session"], "container": r["container"], "cwd": r["cwd"], + "evidence": r["evidence"], "age": AL.age(r["idle"]), "idle": r["idle"], + "pid": r["pid"], "live": True, + "state": "live" if 0 <= r["idle"] < 120 else ("idle" if r["idle"] >= 0 else "unknown"), + "turns": parse_turns(r["path"], max_turns) if r["path"] else [], + }) + + # optionally fold in recently-written transcripts whose process already exited + if recent_min: + cut = time.time() - recent_min * 60 + for root, label in all_roots(): + for f in glob.glob(os.path.join(root, "*", "*.jsonl")): + if f in seen: + continue + try: + mt = os.path.getmtime(f) + except OSError: + continue + if mt < cut: + continue + idle = time.time() - mt + data.append({ + "session": os.path.basename(f)[:-6], + "container": label if label != "shared" else "(ended — container unknown)", + "cwd": os.path.basename(os.path.dirname(f)).replace("-", "/"), + "evidence": "ended", "age": AL.age(idle), "idle": idle, + "pid": "", "live": False, "state": "ended", + "turns": parse_turns(f, max_turns), + }) + + data.sort(key=lambda s: (not s["live"], s["container"] == "host", + s["container"], s["idle"])) + return data + + +CSS = """ +:root{--bg:#fff;--fg:#1a1a1a;--mut:#666;--line:#e0e0e0;--card:#f7f7f8;--acc:#0b6bcb} +@media(prefers-color-scheme:dark){:root{--bg:#16181c;--fg:#e6e6e6;--mut:#9aa0a6;--line:#2c3038;--card:#1d2026;--acc:#5aa9ff}} +*{box-sizing:border-box} +body{margin:0;padding:18px;font:14px/1.55 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;background:var(--bg);color:var(--fg)} +h1{font-size:18px;margin:0 0 4px} +.sub{color:var(--mut);font-size:12px;margin-bottom:16px} +table{border-collapse:collapse;width:100%;margin-bottom:26px;font-size:12px;display:block;overflow-x:auto} +th,td{text-align:left;padding:6px 9px;border-bottom:1px solid var(--line);white-space:nowrap} +th{color:var(--mut);font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:.05em} +td.wrap{white-space:normal} +a{color:var(--acc)} +.dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px} +.live{background:#2ea043}.idle{background:#9aa0a6}.ended{background:#5b5b5b}.unknown{background:#d29922} +details.s{border:1px solid var(--line);border-radius:8px;margin-bottom:12px;background:var(--card)} +details.s>summary{cursor:pointer;padding:10px 12px;font-size:13px;list-style:none} +details.s>summary::-webkit-details-marker{display:none} +details.s>summary:before{content:"\\25b6";color:var(--mut);margin-right:8px;font-size:10px} +details.s[open]>summary:before{content:"\\25bc"} +.body{padding:4px 14px 14px;background:var(--bg);border-top:1px solid var(--line)} +.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace} +.meta{color:var(--mut);font-size:11px;margin-left:6px} +.t{margin:12px 0;border-left:3px solid var(--line);padding-left:10px} +.t.assistant{border-color:var(--acc)} +.role{font-size:10px;color:var(--mut);text-transform:uppercase;letter-spacing:.06em;margin-bottom:3px} +pre{white-space:pre-wrap;word-wrap:break-word;margin:4px 0;font:12px/1.5 ui-monospace,Menlo,monospace;background:var(--card);padding:8px;border-radius:6px;max-height:320px;overflow:auto} +.tool{color:var(--acc);font-weight:600;font-size:12px;margin-top:6px} +.think{opacity:.6;font-style:italic} +.note{color:var(--mut);font-size:12px;border-left:3px solid var(--line);padding-left:10px;margin:18px 0} +.work{color:var(--mut);font-size:11px;margin:8px 0 8px 4px} +.cmd{font-size:11px;color:var(--mut);background:var(--card);border:1px solid var(--line);border-radius:6px;padding:8px 10px;margin:10px 0;display:flex;gap:9px;align-items:center;flex-wrap:wrap} +.cmd code{font-family:ui-monospace,Menlo,monospace;font-size:12px;color:var(--fg);user-select:all} +.cpy{font:inherit;font-size:11px;padding:6px 11px;border-radius:6px;border:1px solid var(--line);background:var(--bg);color:var(--acc);cursor:pointer;min-height:32px} +.cpy:active{opacity:.6} +.t.user{border-color:#d29922} +.t>div:last-child{white-space:pre-wrap;word-wrap:break-word} +.top{position:sticky;bottom:10px;float:right;font-size:12px;background:var(--card);border:1px solid var(--line);border-radius:6px;padding:4px 9px;text-decoration:none} +/* phone: the summary table is the least useful part on a small screen, and the + details rows must stay comfortably tappable */ +@media(max-width:640px){ + body{padding:12px} + h1{font-size:16px} + table{font-size:11px} + td,th{padding:5px 7px} + details.s>summary{padding:13px 12px;font-size:14px} + pre{max-height:220px;font-size:11px} + .hide-sm{display:none} +} +""" + + +def esc(s): + return (str(s or "").replace("&", "&").replace("<", "<").replace(">", ">")) + + +def condense(turns): + """Strip the agent's internal chatter. + + A raw transcript is mostly tool_use/tool_result/thinking — the agent talking to + itself. What tracks PROGRESS is the assistant's prose and the human's instructions. + Everything else collapses into one muted 'N tool calls' line so the work is still + visible without drowning the narrative. + """ + out, pend = [], {} + + def flush(): + if pend: + n = sum(pend.values()) + top = sorted(pend.items(), key=lambda kv: -kv[1])[:4] + out.append({"kind": "work", "n": n, + "names": " ".join("%s×%d" % (k or "?", v) for k, v in top)}) + pend.clear() + + for t in turns: + said = " ".join(b.get("text", "") for b in t["blocks"] + if b.get("kind") == "text").strip() + if said: + flush() + out.append({"kind": "say" if t["role"] == "assistant" else "ask", + "ts": t["ts"], "text": said}) + continue + for b in t["blocks"]: + if b.get("kind") == "tool": + pend[b.get("text")] = pend.get(b.get("text"), 0) + 1 + flush() + return out + + +def render_item(it): + if it["kind"] == "work": + return ('<div class="work">⋯ %d tool calls <span class="meta">%s</span>' + "</div>" % (it["n"], esc(it["names"]))) + who = "agent" if it["kind"] == "say" else "instruction" + return ('<div class="t %s"><div class="role">%s %s</div><div>%s</div></div>' + % ("assistant" if it["kind"] == "say" else "user", who, + esc(it["ts"][11:19]), esc(it["text"]).replace("\n", "<br>"))) + + +def render_turn(t): + out = ['<div class="t %s"><div class="role">%s %s</div>' + % (esc(t["role"]), esc(t["role"]), esc(t["ts"][11:19]))] + for b in t["blocks"]: + k = b.get("kind") + if k == "tool": + out.append('<div class="tool">▸ %s</div>' % esc(b.get("text"))) + if b.get("extra"): + out.append("<pre>%s</pre>" % esc(b["extra"])) + elif k == "result": + out.append("<pre>%s</pre>" % esc(b.get("text"))) + elif k == "think": + out.append('<pre class="think">%s</pre>' % esc(b.get("text"))) + else: + out.append("<div>%s</div>" % esc(b.get("text")).replace("\n", "<br>")) + out.append("</div>") + return "".join(out) + + +def render_html(data, when, raw=False): + """No JavaScript at all: sandboxed viewers (and strict CSP) blank a JS page, and + this file has to survive being opened anywhere. <details> gives the click-to-open + behaviour natively.""" + p = ['<!doctype html><meta charset="utf-8">', + # Stable title on purpose: this is republished to the same artifact URL on a + # schedule, and a timestamped title would rename the artifact every run. + "<title>abox dashboard", + '', + "" % CSS, + "

abox dashboard

", + '
snapshot %s · %d sessions · %d live · ' + "%d containers · static file, does not auto-refresh
" + % (esc(when), len(data), sum(1 for s in data if s["live"]), + len({s["container"] for s in data}))] + + p.append('' + "" + "") + for s in data: + p.append('' + '' + '' + % (s["state"], esc(s["container"]), esc(s["session"][:8]), + esc(s["session"][:8]), esc(s["age"]), len(s["turns"]), + esc(s["evidence"]), esc(s["cwd"]))) + p.append("
containersessionageturnsevcwd
%s%s%s%d%s%s
") + + p.append('
Click a session below to expand its transcript. ' + "Dots: green = wrote <2 min ago, grey = idle (may be waiting on you), " + "dark = process already exited.
") + + for s in data: + p.append('
' + '%s' + '%s · %s · %d turns · %s' + '
%s
' + % (esc(s["session"][:8]), s["state"], esc(s["session"][:8]), + esc(s["container"]), esc(s["age"]), len(s["turns"]), + esc(s["evidence"]), esc(s["cwd"]))) + items = condense(s["turns"]) + if raw: + p.extend(render_turn(t) for t in s["turns"]) + elif items: + cmd = "abox-live stop %s --yes" % s["session"][:8] + p.append('
%s' + '' + "點一下選取指令,貼到 Command Center 送出即停止
" + % esc(cmd)) + p.extend(render_item(i) for i in items[-40:]) + else: + p.append('
no narrative yet — the agent has only run ' + "tools so far, or the transcript is not readable
") + p.append("
") + p.append('↑ top') + # Progressive enhancement ONLY: every command is already visible and selectable as + # text, so the page stays fully usable where inline scripts are sandboxed away. + # Enhancement only. Published artifacts run in a SANDBOXED iframe where + # navigator.clipboard and execCommand('copy') are usually BLOCKED — so a pure + # "copy" button looks dead on a phone ("點了沒反應"). Instead, tapping the command + # SELECTS its text (always works, no permission needed) and then tries a real copy + # as a bonus; either way the user gets immediate visual feedback and can copy from + # the OS menu. The command is already visible text if scripts are stripped entirely. + p.append("""""") + return "\n".join(p) + + +PAGE_HEAD = """ +abox dashboard — %(when)s + + +
+ +
+
+
select a session
← pick one
+ +""" + + +def _own_to_user(path): + """Hand a root-created file back to the box user so later non-sudo runs and the + Artifact tool can read it. No-op if we're not root or the user is unknown.""" + try: + pw = pwd.getpwnam(PUBLISH_OWNER) + os.chown(path, pw.pw_uid, pw.pw_gid) + except (KeyError, PermissionError, OSError): + pass + + +def main(): + publish = "--publish" in sys.argv + # In publish mode the output goes to the stable path unless -o overrides it, so a + # bare `abox-live publish` always refreshes the bookmarked artifact's source file. + out = STABLE_OUT if publish else "abox-dashboard.html" + if "-o" in sys.argv: + out = sys.argv[sys.argv.index("-o") + 1] + turns_n = MAX_TURNS + if "--turns" in sys.argv: + turns_n = int(sys.argv[sys.argv.index("--turns") + 1]) + recent = 0 + if "--recent" in sys.argv: + recent = int(sys.argv[sys.argv.index("--recent") + 1]) + elif publish: + recent = 180 # dashboards want recently-finished sessions too, not just live + data = build("--all" in sys.argv, turns_n, recent) + + when = time.strftime("%Y-%m-%d %H:%M:%S") + os.makedirs(os.path.dirname(os.path.abspath(out)), exist_ok=True) + with open(out, "w", encoding="utf-8") as f: + f.write(render_html(data, when, raw="--raw" in sys.argv)) + size = os.path.getsize(out) + os.chmod(out, 0o644) + _own_to_user(out) + if publish: + _own_to_user(os.path.dirname(os.path.abspath(out))) + print("%s (%d sessions, %.1f MB, snapshot at %s)" % + (out, len(data), size / 1e6, when)) + + if publish: + # The script can't publish; only the agent's Artifact tool can. Emit the exact + # parameters (esp. url= — omitting it mints a NEW url and breaks the bookmark). + print( + "\n=== PUBLISH ARTIFACT — agent: call the Artifact tool now with these ===\n" + "file_path: %s\n" + "url: %s\n" + "title: %s\n" + "favicon: %s\n" + "description: %s\n" + "=====================================================================\n" + "(url= is REQUIRED — it keeps the same artifact so the phone bookmark stays valid.)" + % (os.path.abspath(out), ARTIFACT_URL, ARTIFACT_TITLE, + ARTIFACT_FAVICON, ARTIFACT_DESC)) + + +if __name__ == "__main__": + main() diff --git a/skills/abox-live/abox_stop.py b/skills/abox-live/abox_stop.py new file mode 100644 index 0000000..9f7ef38 --- /dev/null +++ b/skills/abox-live/abox_stop.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""abox-stop — stop a live claude agent session by session id. + +Kills the claude PROCESS that owns a transcript, resolved the same way abox-live +resolves it. Refuses to act on anything it cannot pin down, because killing the wrong +agent loses in-flight work. + + sudo -n python3 abox_stop.py [--force] [--yes] +""" +import os, sys, time, signal + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import abox_live as AL + + +def main(): + args = [a for a in sys.argv[1:] if not a.startswith("-")] + force = "--force" in sys.argv + yes = "--yes" in sys.argv or "-y" in sys.argv + if not args: + print("usage: abox-live stop [--force] [--yes]") + return 2 + sel = args[0] + + rows = [r for r in AL.collect() if r["mine"]] + hits = [r for r in rows if r["session"].startswith(sel)] + if not hits: + print("no live session matching '%s'" % sel) + print("run `abox-live` to see what is live") + return 1 + if len(hits) > 1: + print("'%s' matches %d sessions — be more specific:" % (sel, len(hits))) + for r in hits: + print(" %s %s" % (r["session"], r["container"])) + return 1 + + r = hits[0] + me = os.getppid() + print("session %s" % r["session"]) + print("container %s" % r["container"]) + print("cwd %s" % r["cwd"]) + print("pid %s (evidence: %s)" % (r["pid"], r["evidence"])) + print("last %s" % (r["latest"][:150] or "(nothing)")) + + if r["evidence"] in ("ambiguous", "none"): + # the pid<->session mapping is a guess here; killing on a guess is not ok + print("\nREFUSING: this row's session id is %s, so the pid may belong to a " + "different session.\nKill it explicitly by pid instead if you are sure: " + "sudo kill %s" % (r["evidence"], r["pid"])) + return 1 + + if r["evidence"] == "cwd+mtime": + print("\nNOTE: this pid<->session link is INFERRED from the working directory " + "(only\nsession in that container, so it is very likely right — but it " + "was not confirmed\nfrom the command line).") + + if not yes: + print("\nabout to send SIGTERM to pid %s — re-run with --yes to confirm" + % r["pid"]) + return 0 + + try: + os.kill(r["pid"], signal.SIGKILL if force else signal.SIGTERM) + except ProcessLookupError: + print("\nalready gone") + return 0 + except PermissionError as e: + print("\ncannot signal pid %s: %s" % (r["pid"], e)) + return 1 + + for _ in range(20): + time.sleep(0.25) + try: + os.kill(r["pid"], 0) + except ProcessLookupError: + print("\nstopped %s (%s)" % (r["session"][:8], r["container"])) + return 0 + print("\npid %s still alive after SIGTERM — re-run with --force for SIGKILL" + % r["pid"]) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/abox-live/abox_web.py b/skills/abox-live/abox_web.py new file mode 100644 index 0000000..ccd7b4f --- /dev/null +++ b/skills/abox-live/abox_web.py @@ -0,0 +1,252 @@ +#!/usr/bin/env python3 +"""abox-web — a LOCAL web view of live claude sessions and their transcripts. + +Serves on 127.0.0.1 only. Transcripts are read fresh on every request, so the page +reflects current state; nothing is uploaded anywhere. + + sudo -n python3 abox_web.py [--port 8848] [--all] +""" +import os, re, sys, json, time, html, glob, secrets +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import abox_live as AL + +MAX_TURNS = 300 +MAX_CHARS = 12000 +OPTS = {"all": False} + + +def sessions(): + rows = AL.collect() + if not OPTS["all"]: + rows = [r for r in rows if r["mine"]] + out = [] + for r in rows: + out.append({ + "session": r["session"], "container": r["container"], "cwd": r["cwd"], + "evidence": r["evidence"], "idle": r["idle"], "age": AL.age(r["idle"]), + "latest": r["latest"][:160], "pid": r["pid"], "path": r["path"], + "state": "live" if 0 <= r["idle"] < 120 else ("idle" if r["idle"] >= 0 else "unknown"), + }) + out.sort(key=lambda s: (s["container"] == "host", s["container"], + s["idle"] if s["idle"] >= 0 else 1e9)) + return out + + +def find_path(sid): + for s in sessions(): + if s["session"].startswith(sid) and s["path"]: + return s["path"] + for root in AL.roots_for("host"): + hits = glob.glob(os.path.join(root, "*", sid + "*.jsonl")) + if hits: + return hits[0] + return None + + +def turns(path): + out = [] + try: + with open(path, "rb") as f: + raw = f.read().decode("utf-8", "replace") + except Exception as e: + return [{"role": "error", "text": "cannot read transcript: %s" % e, "ts": ""}] + for ln in raw.splitlines(): + try: + o = json.loads(ln) + except Exception: + continue + if o.get("type") not in ("assistant", "user"): + continue + m = o.get("message", {}) + c = m.get("content") if isinstance(m, dict) else None + blocks = [] + if isinstance(c, str): + blocks.append({"kind": "text", "text": c}) + elif isinstance(c, list): + for p in c: + if not isinstance(p, dict): + continue + k = p.get("type") + if k == "text": + blocks.append({"kind": "text", "text": p.get("text", "")}) + elif k == "thinking": + blocks.append({"kind": "thinking", "text": p.get("thinking", "")}) + elif k == "tool_use": + blocks.append({"kind": "tool", "text": p.get("name", ""), + "input": json.dumps(p.get("input", {}))[:2000]}) + elif k == "tool_result": + t = p.get("content") + if isinstance(t, list): + t = " ".join(x.get("text", "") for x in t if isinstance(x, dict)) + blocks.append({"kind": "result", "text": str(t)[:MAX_CHARS]}) + if not blocks: + continue + for b in blocks: + b["text"] = b.get("text", "")[:MAX_CHARS] + out.append({"role": o.get("type"), "ts": o.get("timestamp", ""), "blocks": blocks}) + return out[-MAX_TURNS:] + + +PAGE = r""" +abox — live sessions + + +
+ +
loading…
+
+
select a session on the left
+""" + + +class H(BaseHTTPRequestHandler): + def _send(self, code, body, ctype): + b = body.encode("utf-8") + self.send_response(code) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(b))) + self.end_headers() + self.wfile.write(b) + + def _authed(self): + """This box is multi-user, so 127.0.0.1 is not private — anyone logged in here + could otherwise read every transcript. One shared token, passed as ?k= once and + then kept in a cookie.""" + tok = OPTS.get("token") + if not tok: + return True + q = self.path.split("?", 1)[1] if "?" in self.path else "" + if ("k=" + tok) in q: + return True + return ("abox_k=" + tok) in (self.headers.get("Cookie") or "") + + def do_GET(self): + p = self.path.split("?")[0] + if not self._authed(): + return self._send(403, "forbidden — open the URL printed by `abox-live web` " + "(it carries the ?k= token)", "text/plain") + if p == "/": + tok = OPTS.get("token") + b = PAGE.encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(b))) + if tok: + self.send_header("Set-Cookie", "abox_k=%s; Path=/; SameSite=Strict" % tok) + self.end_headers() + return self.wfile.write(b) + if p == "/api/sessions": + return self._send(200, json.dumps(sessions()), "application/json") + if p.startswith("/api/session/"): + sid = re.sub(r"[^0-9a-fA-F-]", "", p.rsplit("/", 1)[1])[:36] + path = find_path(sid) if sid else None + if not path: + return self._send(404, json.dumps({"turns": []}), "application/json") + return self._send(200, json.dumps({"turns": turns(path)}), "application/json") + self._send(404, "not found", "text/plain") + + def log_message(self, *a): + pass + + +def main(): + port = 8848 + if "--port" in sys.argv: + port = int(sys.argv[sys.argv.index("--port") + 1]) + host = "127.0.0.1" + if "--host" in sys.argv: + host = sys.argv[sys.argv.index("--host") + 1] + OPTS["all"] = "--all" in sys.argv + OPTS["token"] = None if "--no-token" in sys.argv else secrets.token_urlsafe(9) + + srv = ThreadingHTTPServer((host, port), H) + url = "http://%s:%d/" % ("127.0.0.1" if host == "127.0.0.1" else host, port) + if OPTS["token"]: + url += "?k=" + OPTS["token"] + print("abox-web -> %s" % url) + if host != "127.0.0.1": + print("!! bound to %s — reachable from the network. Transcripts are internal;" + " prefer 127.0.0.1 + an SSH tunnel." % host) + print("this host is multi-user, so the ?k= token gates other local accounts " + "(--no-token to disable)") + print("browsing from your laptop? forward the port first:") + print(" ssh -L %d:127.0.0.1:%d %s@%s" % ( + port, port, os.environ.get("SUDO_USER", "user"), + os.uname().nodename)) + print(" or VS Code: PORTS panel -> Forward a Port -> %d" % port) + sys.stdout.flush() + srv.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/skills/abox-live/claude-container-auth.sh b/skills/abox-live/claude-container-auth.sh new file mode 100755 index 0000000..5914d81 --- /dev/null +++ b/skills/abox-live/claude-container-auth.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Container-side Claude Code setup. +# +# Design goals: +# 1. The HOST's claude.ai login can NEVER be touched by a container. +# 2. Containers authenticate via the AMD gateway API key — no interactive login, ever. +# 3. Shared content (skills / settings / CLAUDE.md / commands) is LIVE-SYNCED by +# symlink, so editing on the host is instantly visible in every container. +# +# Replaces the credential-stripping block in claude-code-key.sh, which wrote its +# "stripped" copy back onto the SAME inode as the host credential file (both +# $HOME and $HOME/.claude were bind-mounted), deleting the host subscription login +# on every container init. +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/../../env.sh" + +# Container-LOCAL config dir. Not a bind mount -> nothing here can reach the host. +CFG="${CLAUDE_CONFIG_DIR:-/root/.claude-local}" +HOST_CLAUDE="${HOST_HOME}/.claude" +mkdir -p "$CFG" + +# ---------------------------------------------------------------- 1. live sync +# Symlink (not copy) -> edit on host, container sees it immediately. No sync step. +link() { [ -e "$1" ] && ln -sfn "$1" "$CFG/$2" || true; } +link "${HOST_HOME}/agent-box/skills" skills +link "${HOST_HOME}/agent-box/setting.json" settings.json +link "${HOST_CLAUDE}/CLAUDE.md" CLAUDE.md +link "${HOST_CLAUDE}/commands" commands +link "${HOST_CLAUDE}/agents" agents +link "${HOST_CLAUDE}/plugins" plugins + +# Deliberately NOT linked (must stay container-local, or they collide across +# containers): projects/, history.jsonl, shell-snapshots/, todos/, sessions/, +# statsig/, .credentials.json + +# ------------------------------------------------- 2. MCP OAuth tokens (Jira) +# Copy host MCP tokens in, MINUS the claude.ai subscription login, so /mcp works +# without a browser flow. Writes ONLY to the container-local dir. +if [ -f "${HOST_CLAUDE}/.credentials.json" ]; then + python3 - "${HOST_CLAUDE}/.credentials.json" "$CFG/.credentials.json" <<'PY' +import json, os, sys +src, dst = sys.argv[1], sys.argv[2] +# The guard that the old script was missing: never write onto the host file. +if os.path.realpath(src) == os.path.realpath(dst): + sys.exit("[claude] REFUSING to write onto the host credential file (same inode)") +creds = json.load(open(src)) +creds.pop("claudeAiOauth", None) # subscription login never enters a container +if creds: + with open(dst, "w") as f: + json.dump(creds, f) + print(f"[claude] MCP OAuth shared into container ({', '.join(sorted(creds))})") +else: + print("[claude] no MCP OAuth tokens on host yet") +PY + chmod 600 "$CFG/.credentials.json" 2>/dev/null || true +fi + +# --------------------------------------------------------- 3. API-key auth +KEY_FILE="${HOST_HOME}/.claude_api_key" +if [ ! -f "$KEY_FILE" ]; then + echo "[claude] ERROR: $KEY_FILE not found — cannot authenticate container." >&2 + exit 1 +fi +CLAUDE_KEY=$(cat "$KEY_FILE") + +# Idempotent: strip any previous block before re-appending. +sed -i '/# >>> claude container env >>>/,/# <<< claude container env <</dev/null || true +cat >> ~/.bashrc <>> claude container env >>> +export CLAUDE_CONFIG_DIR="${CFG}" +export ANTHROPIC_API_KEY="dummy" +export ANTHROPIC_BASE_URL="https://llm-api.amd.com/Anthropic" +export ANTHROPIC_CUSTOM_HEADERS="Ocp-Apim-Subscription-Key:${CLAUDE_KEY}" +export ANTHROPIC_MODEL="claude-opus-4-8[1m]" +export ANTHROPIC_DEFAULT_OPUS_MODEL="claude-opus-4-8[1m]" +export ANTHROPIC_DEFAULT_SONNET_MODEL="claude-sonnet-4-6[1m]" +# <<< claude container env <<< +EOF + +echo "[claude] container config: $CFG (host login untouched, API-key auth wired)"