feat: cross-host tmux screenshot capture + daemon integration #238
3 changed files with 158 additions and 18 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue