From 5ba8c0c1098eacc372ef59cccceff873a762c964 Mon Sep 17 00:00:00 2001 From: 123kupola Date: Sat, 27 Jun 2026 18:45:45 +0200 Subject: [PATCH 1/2] feat(daemon): auto screenshot capture on task completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When COLIBRI_SCREENSHOT_DIR is set, the daemon generates a UUID via uuidgen, spawns a screenshot command (COLIBRI_SCREENSHOT_CMD, default 'import -window root'), and attaches the UUID to the mother cost push. The dashboard already renders ▸ proof badges on cards with screenshot_uuid. This closes the loop — the daemon now auto-generates screenshots instead of waiting for an external process to set COLIBRI_TASK_SCREENSHOT_UUID. Sam & Hermes --- crates/colibri-daemon/src/daemon.rs | 37 ++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/crates/colibri-daemon/src/daemon.rs b/crates/colibri-daemon/src/daemon.rs index 2278382..58570f1 100644 --- a/crates/colibri-daemon/src/daemon.rs +++ b/crates/colibri-daemon/src/daemon.rs @@ -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,34 @@ 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. +/// +/// The capture command is configurable via COLIBRI_SCREENSHOT_CMD (default: +/// `import -window root` from ImageMagick). The command receives the target +/// path as its last argument: `$COLIBRI_SCREENSHOT_DIR/.png`. +fn maybe_capture_screenshot(task_id: &str) -> Option { + 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())?; + let cmd = std::env::var("COLIBRI_SCREENSHOT_CMD") + .unwrap_or_else(|_| "import -window root".to_string()); + let out_path = format!("{dir}/{uuid}.png"); + + // Fire and forget — screenshot capture can be slow. + 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(); -- 2.45.3 From 87bc45b612e0613079578f1db52f15078d4b2ff1 Mon Sep 17 00:00:00 2001 From: Sam & Hermes Date: Sat, 27 Jun 2026 20:17:56 +0200 Subject: [PATCH 2/2] feat: cross-host tmux screenshot capture + daemon integration tmux-screenshot.py: - Add --host flag for SSH remote pane capture - Add --uuid flag for caller-controlled filenames (daemon integration) - Content hash still computed for verification alongside daemon UUID - Cross-host metadata: capture_host field in JSON daemon.rs maybe_capture_screenshot(): - Two-tier: tmux pane capture (COLIBRI_SCREENSHOT_TMUX_TARGET) preferred over generic screenshot (COLIBRI_SCREENSHOT_CMD fallback) - Format: 'domedog:=1:2' for remote host, '=1:2' for local - Script path configurable via COLIBRI_SCREENSHOT_SCRIPT SKILL.md: cross-host and daemon-controlled UUID sections --- .agent/skills/tmux-screenshot/SKILL.md | 35 +++++++++++ .../skills/tmux-screenshot/tmux-screenshot.py | 51 +++++++++++----- crates/colibri-daemon/src/daemon.rs | 61 +++++++++++++++++-- 3 files changed, 128 insertions(+), 19 deletions(-) diff --git a/.agent/skills/tmux-screenshot/SKILL.md b/.agent/skills/tmux-screenshot/SKILL.md index 320282a..06f8094 100644 --- a/.agent/skills/tmux-screenshot/SKILL.md +++ b/.agent/skills/tmux-screenshot/SKILL.md @@ -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 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 diff --git a/.agent/skills/tmux-screenshot/tmux-screenshot.py b/.agent/skills/tmux-screenshot/tmux-screenshot.py index 941cdc7..161c844 100644 --- a/.agent/skills/tmux-screenshot/tmux-screenshot.py +++ b/.agent/skills/tmux-screenshot/tmux-screenshot.py @@ -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 diff --git a/crates/colibri-daemon/src/daemon.rs b/crates/colibri-daemon/src/daemon.rs index 58570f1..7798b97 100644 --- a/crates/colibri-daemon/src/daemon.rs +++ b/crates/colibri-daemon/src/daemon.rs @@ -401,20 +401,73 @@ 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. /// -/// The capture command is configurable via COLIBRI_SCREENSHOT_CMD (default: -/// `import -window root` from ImageMagick). The command receives the target -/// path as its last argument: `$COLIBRI_SCREENSHOT_DIR/.png`. +/// 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/.png`. fn maybe_capture_screenshot(task_id: &str) -> Option { 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"); - // Fire and forget — screenshot capture can be slow. let _ = std::process::Command::new("sh") .arg("-c") .arg(format!("{cmd} {out_path}")) -- 2.45.3