feat: cross-host tmux screenshot capture + daemon integration #238

Closed
clawdie wants to merge 2 commits from feat/screenshot-auto-capture into main
3 changed files with 158 additions and 18 deletions

View file

@ -247,6 +247,41 @@ shows old value — the pane shell hasn't picked it up yet. Open a new window or
3. Read the `.json` output for structured metadata
4. Use the `.png` output only when a human needs visual context
### Cross-Host Capture (SSH)
Capture a tmux pane on a remote host via SSH:
```sh
python3 .agent/skills/tmux-screenshot/tmux-screenshot.py \
--host domedog --session 1 --window 2 \
--outdir /tmp/screenshots
```
The `--host` flag triggers `ssh <host> tmux capture-pane ...` before the
local rendering pipeline. Requires SSH `BatchMode=yes` (key-based auth).
Format: any valid tmux target works — session indices (`1`), session names
(`clawdie`), window indices (`2`), window names (`main`), or pane IDs (`%3`).
### Daemon-Controlled UUID
When called from the Colibri daemon's `maybe_capture_screenshot()`, use
`--uuid` to accept a caller-generated UUID for the filename. This keeps
the UUID consistent between the daemon's cost payload and the dashboard's
screenshot proof linking:
```sh
python3 .agent/skills/tmux-screenshot/tmux-screenshot.py \
--host domedog --session 1 --window 2 \
--uuid a1b2c3d4-e5f6 \
--outdir /var/lib/colibri/screenshots
```
The daemon sets `COLIBRI_SCREENSHOT_TMUX_TARGET=domedog:=1:2` and
`COLIBRI_SCREENSHOT_DIR=/var/lib/colibri/screenshots`, then spawns
this script fire-and-forget. The content hash is still computed and
stored in JSON metadata for verification.
### Quick capture from host
```sh

View file

@ -599,13 +599,17 @@ def strip_ansi(text: str) -> str:
# ═─ capture ───────────────────────────────────────────────────────────────────
def capture_tmux(target: str) -> str:
result = subprocess.run(
['tmux', 'capture-pane', '-t', target, '-e', '-p'],
capture_output=True,
)
def capture_tmux(target: str, host: str | None = None) -> str:
"""Capture a tmux pane. If host is set, SSH to the remote host first."""
if host:
cmd = ['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10',
host, 'tmux', 'capture-pane', '-t', target, '-e', '-p']
else:
cmd = ['tmux', 'capture-pane', '-t', target, '-e', '-p']
result = subprocess.run(cmd, capture_output=True)
if result.returncode != 0:
raise RuntimeError(f"tmux capture failed: {result.stderr.decode('utf-8', errors='replace')}")
err = result.stderr.decode('utf-8', errors='replace')
raise RuntimeError(f"tmux capture failed ({'ssh ' + host if host else 'local'}): {err}")
return result.stdout.decode('utf-8', errors='replace')
@ -1020,19 +1024,22 @@ def capture_screenshot(
pane: str | None = None,
allow_self_capture: bool = False,
base_url: str | None = None,
host: str | None = None,
uuid_override: str | None = None,
) -> dict:
outdir.mkdir(parents=True, exist_ok=True)
capture_time = datetime.now()
target = build_tmux_target(session, window, pane)
guard_self_capture(target, allow_self_capture)
print(f"Capturing: {target}")
raw = capture_tmux(target)
print(f"Capturing: {target}" + (f" via {host}" if host else ""))
raw = capture_tmux(target, host=host)
meta = tmux_meta(target, session)
stripped = strip_ansi(raw)
txt_hash = calculate_hash(stripped)
uuid = txt_hash[:12]
content_uuid = txt_hash[:12]
uuid = uuid_override if uuid_override else content_uuid
sig_result = detect_signatures(stripped)
detected_signatures = sig_result['all']
if sig_result['failures']:
@ -1055,8 +1062,10 @@ def capture_screenshot(
metadata = {
'uuid': uuid,
'content_uuid': content_uuid,
'session': meta['session'],
'capture_target': meta['capture_target'],
'capture_host': host if host else subprocess.getoutput('hostname').strip(),
'captured_at': capture_time.isoformat(),
'captured_at_display': date_str,
'host': subprocess.getoutput('hostname').strip(),
@ -1088,12 +1097,20 @@ def capture_screenshot(
update_manifest(outdir, metadata)
print("Verifying...")
try:
verify_by_filename(uuid, txt_path)
print("Verified OK")
except ValueError as e:
print(f"Verification failed: {e}", file=sys.stderr)
return {'success': False, 'error': str(e)}
if uuid_override:
# Filename is caller-controlled; verify content integrity instead
calculated = calculate_hash(stripped)[:12]
if calculated != content_uuid:
print(f"Verification failed: content hash mismatch ({content_uuid} vs {calculated})", file=sys.stderr)
return {'success': False, 'error': f'content hash mismatch'}
print("Verified OK (content integrity)")
else:
try:
verify_by_filename(uuid, txt_path)
print("Verified OK")
except ValueError as e:
print(f"Verification failed: {e}", file=sys.stderr)
return {'success': False, 'error': str(e)}
print(f"\nScreenshot saved: {uuid}")
print(f" PNG: {png_path} ({img.width}x{img.height}px)")
@ -1121,6 +1138,8 @@ if __name__ == '__main__':
parser.add_argument('--session', '-s', default=DEFAULT_SESSION, help=f'tmux session (default: {DEFAULT_SESSION})')
parser.add_argument('--window', '-w', help='tmux window name or index inside the chosen session')
parser.add_argument('--pane', help='tmux pane id (for example %%3) or pane index when used with --window')
parser.add_argument('--host', help='SSH host for remote tmux capture (e.g. domedog). Omit for local capture.')
parser.add_argument('--uuid', help='caller-controlled UUID for filename (overrides content hash). Use when daemon/caller manages UUIDs')
parser.add_argument('--allow-self-capture', action='store_true', help='allow capturing the invoking tmux pane')
parser.add_argument('--outdir', '-o', default=str(DEFAULT_OUTDIR), help=f'output directory (default: {DEFAULT_OUTDIR})')
parser.add_argument('--base-url', help='optional public base URL for emitted screenshot links')
@ -1152,6 +1171,8 @@ if __name__ == '__main__':
pane=args.pane,
allow_self_capture=args.allow_self_capture,
base_url=args.base_url,
host=args.host,
uuid_override=args.uuid,
)
if result['success'] and args.publish:
import shutil

View file

@ -324,6 +324,12 @@ fn push_cost_to_mother(task_id: &str, tc: &colibri_store::TaskCost) {
};
let node_hostname = std::env::var("HOSTNAME").unwrap_or_else(|_| "unknown".to_string());
// Optional: tmux-screenshot UUID. Captured before the clone so we can
// pass the borrowed task_id to maybe_capture_screenshot.
let screenshot_uuid = std::env::var("COLIBRI_TASK_SCREENSHOT_UUID")
.ok()
.or_else(|| maybe_capture_screenshot(task_id));
// Clone before moving into the spawned thread.
let task_id = task_id.to_string();
let provider = tc.provider.clone();
@ -334,9 +340,6 @@ fn push_cost_to_mother(task_id: &str, tc: &colibri_store::TaskCost) {
let cache_write_tokens = tc.cache_write_tokens;
let cost = tc.cost;
let success = tc.success;
// Optional: tmux-screenshot UUID set by the agent harness on completion.
// The daemon passes it through to mother; mother JOINs with screenshot storage.
let screenshot_uuid = std::env::var("COLIBRI_TASK_SCREENSHOT_UUID").ok();
// Run SSH in a blocking thread — heartbeat is async, SSH is fast (<1s).
std::thread::spawn(move || {
@ -395,6 +398,87 @@ fn push_cost_to_mother(task_id: &str, tc: &colibri_store::TaskCost) {
}
});
}
/// If COLIBRI_SCREENSHOT_DIR is set, generate a UUID and spawn a screenshot
/// capture command. Returns the UUID so it can be attached to the cost payload.
///
/// Two capture modes, tried in order:
///
/// 1. **Tmux pane capture** — when COLIBRI_SCREENSHOT_TMUX_TARGET is set.
/// Format: `[host:]session:window` (e.g. `domedog:=1:2` for remote,
/// `=1:2` for local). Uses the tmux-screenshot.py script (path from
/// COLIBRI_SCREENSHOT_SCRIPT, default `.agent/skills/tmux-screenshot/tmux-screenshot.py`).
/// Renders ANSI-coloured terminal text to PNG via Pillow.
///
/// 2. **Generic screenshot** — fallback when no tmux target is set.
/// Runs COLIBRI_SCREENSHOT_CMD (default: `import -window root` from
/// ImageMagick). The command receives the target path as its last
/// argument: `$COLIBRI_SCREENSHOT_DIR/<uuid>.png`.
fn maybe_capture_screenshot(task_id: &str) -> Option<String> {
let dir = std::env::var("COLIBRI_SCREENSHOT_DIR").ok()?;
let uuid = std::process::Command::new("uuidgen")
.output()
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())?;
// ── Tmux pane capture (cross-host aware) ──────────────────────────
if let Ok(tmux_target) = std::env::var("COLIBRI_SCREENSHOT_TMUX_TARGET") {
let script = std::env::var("COLIBRI_SCREENSHOT_SCRIPT")
.unwrap_or_else(|_| ".agent/skills/tmux-screenshot/tmux-screenshot.py".to_string());
// Parse "host:=session:window" or "=session:window"
let (host, tmux_session_target): (Option<&str>, &str) =
if let Some((candidate, rest)) = tmux_target.split_once(':') {
if candidate.starts_with('=') {
(None, tmux_target.as_str())
} else {
(Some(candidate), rest)
}
} else {
(None, tmux_target.as_str())
};
let mut cmd = std::process::Command::new("python3");
cmd.arg(&script)
.arg("--session")
.arg(tmux_session_target)
.arg("--uuid")
.arg(&uuid)
.arg("--outdir")
.arg(&dir);
if let Some(h) = host {
cmd.arg("--host").arg(h);
}
let _ = cmd
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.ok();
debug!(
task_id = %task_id,
screenshot_uuid = %uuid,
tmux_target = %tmux_target,
"tmux screenshot capture spawned"
);
return Some(uuid);
}
// ── Generic screenshot fallback ────────────────────────────────────
let cmd = std::env::var("COLIBRI_SCREENSHOT_CMD")
.unwrap_or_else(|_| "import -window root".to_string());
let out_path = format!("{dir}/{uuid}.png");
let _ = std::process::Command::new("sh")
.arg("-c")
.arg(format!("{cmd} {out_path}"))
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.ok();
debug!(task_id = %task_id, screenshot_uuid = %uuid, "screenshot capture spawned");
Some(uuid)
}
async fn session_rotation(state: &SharedState) {
let cm_str = state.cost_mode.read().await.clone();
let mut cost_mode = crate::cost::CostMode::parse(&cm_str).unwrap_or_default();