feat(loop): headless sbx save/publish + hh-loop VM-library skill
Adds the autonomous /loop foundation: a headless `hack-house sbx save|publish`
subcommand so a non-TUI operator can persist a built VM to the host library
through the same canonical registry path the room UI uses (no schema drift).
- snapshot.rs: hoist register_saved_snapshot/publish_snapshot/oci_image_size out
of app.rs into a shared module used by both the TUI and the new CLI.
- registry.rs: advisory cross-process lockfile (~/.hh/registry.lock, O_EXCL spin
+ stale-reclaim) around every read-modify-write, plus atomic temp+rename store,
so concurrent /loop wave members can't clobber each other.
- main.rs: `Sbx { Save, Publish }` subcommand wired to the shared snapshot logic.
- skills/hh-loop: the loop doctrine — value rubric, adaptive 1-3 operator
topology, visible-tmux-by-default run flow, --record logs/film, wave scaling,
and safe `tmux -L hh-loop` teardown.
Proven end-to-end: built+verified a stdlib VM in a sandbox, headless
save+publish -> registry entry shareable:true with a portable tar.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,10 @@ err.log
|
||||
|
||||
# Sandbox save/load snapshots (large runtime tarballs, not source)
|
||||
/hh/hh-snapshots/
|
||||
# VM-library /loop artifacts — exported snapshot tars (repo-root, when `sbx`
|
||||
# is run from here) + per-run logs. Generated; the VMs live in the host registry.
|
||||
/hh-snapshots/
|
||||
/.loop-runs/
|
||||
|
||||
# Out-of-tree experiments (not part of hack-house)
|
||||
/experiments/
|
||||
|
||||
+4
-93
@@ -5,6 +5,7 @@ use crate::layout::{Dir, Layout, Resize};
|
||||
use crate::net::{self, Session};
|
||||
use crate::registry;
|
||||
use crate::sbx;
|
||||
use crate::snapshot;
|
||||
use crate::theme::Theme;
|
||||
use crate::ui;
|
||||
use anyhow::Result;
|
||||
@@ -2321,7 +2322,7 @@ fn handle_command(
|
||||
// Index the snapshot in the host-global registry, caching
|
||||
// its .hh-agent manifest summary. Best-effort: an index
|
||||
// hiccup must never fail the save the user just asked for.
|
||||
register_saved_snapshot(be, &name, &label, &created_by);
|
||||
snapshot::register_saved_snapshot(be, &name, &label, &created_by);
|
||||
Ok::<String, anyhow::Error>(desc)
|
||||
})
|
||||
.await;
|
||||
@@ -2496,7 +2497,7 @@ fn handle_command(
|
||||
let tx = app_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let res = tokio::task::spawn_blocking(move || {
|
||||
publish_snapshot(&label, &tags)
|
||||
snapshot::publish_snapshot(&label, &tags)
|
||||
})
|
||||
.await;
|
||||
let _ = match res {
|
||||
@@ -3102,48 +3103,6 @@ fn closest<'a>(input: &str, candidates: &[&'a str]) -> Option<&'a str> {
|
||||
.map(|(_, c)| c)
|
||||
}
|
||||
|
||||
/// Record a freshly-saved snapshot in the host-global VM registry, caching the
|
||||
/// `.hh-agent` manifest summary read out of the (still-running, for OCI backends)
|
||||
/// container. Best-effort — never panics, never fails the save. Blocking; call
|
||||
/// from inside `spawn_blocking`.
|
||||
fn register_saved_snapshot(be: sbx::Backend, name: &str, label: &str, created_by: &str) {
|
||||
let (artifact_kind, artifact_ref, size_bytes, manifest) = match be {
|
||||
sbx::Backend::Docker | sbx::Backend::Podman => {
|
||||
let engine = be.engine();
|
||||
let image = format!("{}:{}", sbx::SNAP_REPO, label);
|
||||
let size = oci_image_size(engine, &image);
|
||||
let manifest = registry::read_container_manifest(engine, name, "/root");
|
||||
("image".to_string(), image, size, manifest)
|
||||
}
|
||||
// Multipass snapshots live in multipass's own store; the instance is
|
||||
// powered off by save time, so there's no container to read a manifest
|
||||
// from. Record the pointer; reconcile leaves it (no image to probe).
|
||||
sbx::Backend::Multipass => ("snapshot".to_string(), label.to_string(), None, None),
|
||||
sbx::Backend::Local => return, // nothing persistent to index
|
||||
};
|
||||
let (purpose, status, todo) = manifest
|
||||
.as_deref()
|
||||
.map(registry::scan_manifest)
|
||||
.unwrap_or_default();
|
||||
let entry = registry::Entry {
|
||||
label: label.to_string(),
|
||||
backend: be.engine().to_string(),
|
||||
artifact_kind,
|
||||
artifact_ref,
|
||||
size_bytes,
|
||||
created_unix: registry::now_unix(),
|
||||
created_by: created_by.to_string(),
|
||||
repo: registry::cwd_repo(),
|
||||
purpose,
|
||||
status,
|
||||
todo,
|
||||
..Default::default()
|
||||
};
|
||||
if let Err(e) = registry::upsert(entry) {
|
||||
eprintln!("registry upsert failed for '{label}': {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Load a pulled `hh-snap-<label>.tar` into the local engine and record it in the
|
||||
/// host registry, scraping the `.hh-agent` manifest that rides inside the image so
|
||||
/// the receiver's `/sbx browse`/`load` know what the VM is for. Blocking — runs
|
||||
@@ -3160,7 +3119,7 @@ fn register_received_snapshot(tar: &std::path::Path) -> anyhow::Result<String> {
|
||||
backend: engine.clone(),
|
||||
artifact_kind: "image".to_string(),
|
||||
artifact_ref: image.clone(),
|
||||
size_bytes: oci_image_size(&engine, &image),
|
||||
size_bytes: snapshot::oci_image_size(&engine, &image),
|
||||
created_unix: registry::now_unix(),
|
||||
created_by: "pulled".to_string(),
|
||||
repo: registry::cwd_repo(),
|
||||
@@ -3179,54 +3138,6 @@ fn register_received_snapshot(tar: &std::path::Path) -> anyhow::Result<String> {
|
||||
))
|
||||
}
|
||||
|
||||
/// Mark a saved snapshot shareable and ensure it has a portable, sendable file.
|
||||
/// File-backed snaps (`.tar`/`.ova`) are already tradeable; image-backed snaps
|
||||
/// are exported to `hh-snapshots/hh-snap-<label>.tar` first so a peer can receive
|
||||
/// them over `/send`. Blocking — run off the UI thread. Returns a status line.
|
||||
fn publish_snapshot(label: &str, tags: &[String]) -> anyhow::Result<String> {
|
||||
use anyhow::Context;
|
||||
let entry = registry::get(label)
|
||||
.with_context(|| format!("no saved VM labelled '{label}' — `/sbx browse` to list"))?;
|
||||
let share_path = match entry.artifact_kind.as_str() {
|
||||
// Already a standalone file the owner controls — send it as-is.
|
||||
"file" => entry.artifact_ref.clone(),
|
||||
// Lives only in the engine image store — export a portable copy.
|
||||
"image" => {
|
||||
let backend = sbx::Backend::parse(&entry.backend)
|
||||
.with_context(|| format!("unknown backend '{}' for '{label}'", entry.backend))?;
|
||||
sbx::export_image(backend, label)?
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
other => anyhow::bail!(
|
||||
"'{label}' is a {other} snapshot — only file/image snapshots are tradeable"
|
||||
),
|
||||
};
|
||||
let published = registry::publish(label, &share_path, tags)?;
|
||||
let tagnote = if published.tags.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" [{}]", published.tags.join(", "))
|
||||
};
|
||||
Ok(format!(
|
||||
"✓ published '{label}'{tagnote} — shareable artifact at {share_path}; peers can `/sbx pull @<you> {label}`"
|
||||
))
|
||||
}
|
||||
|
||||
/// Size in bytes of an OCI image (`<engine> image inspect … --format {{.Size}}`),
|
||||
/// or None if the engine/image isn't available. Blocking.
|
||||
fn oci_image_size(engine: &str, image: &str) -> Option<u64> {
|
||||
let out = std::process::Command::new(engine)
|
||||
.args(["image", "inspect", image, "--format", "{{.Size}}"])
|
||||
.stderr(std::process::Stdio::null())
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
String::from_utf8_lossy(&out.stdout).trim().parse::<u64>().ok()
|
||||
}
|
||||
|
||||
/// Does an OCI image still exist? Used to reconcile the registry so entries whose
|
||||
/// snapshot was pruned drop out of `/sbx browse`. Blocking.
|
||||
fn oci_image_exists(backend: &str, image: &str) -> bool {
|
||||
|
||||
@@ -11,6 +11,7 @@ mod layout;
|
||||
mod net;
|
||||
mod registry;
|
||||
mod sbx;
|
||||
mod snapshot;
|
||||
mod theme;
|
||||
mod ui;
|
||||
|
||||
@@ -76,6 +77,44 @@ enum Cmd {
|
||||
#[arg(long, default_value_t = false)]
|
||||
insecure: bool,
|
||||
},
|
||||
/// Headless snapshot ops on the VM library — the save/publish surface the
|
||||
/// interactive `/sbx` grammar exposes, usable by an autonomous `/loop` runner
|
||||
/// without a TUI. Writes byte-identical registry entries to the in-room path.
|
||||
Sbx {
|
||||
#[command(subcommand)]
|
||||
action: SbxCmd,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum SbxCmd {
|
||||
/// Commit the running sandbox to a snapshot and index it in the VM registry,
|
||||
/// caching the container's `.hh-agent` manifest summary. Mirrors `/sbx save`.
|
||||
Save {
|
||||
/// Snapshot label (image tag / registry key).
|
||||
label: String,
|
||||
/// Also export a portable `hh-snapshots/hh-snap-<label>.tar` immediately.
|
||||
#[arg(long, default_value_t = false)]
|
||||
local: bool,
|
||||
/// Running container name to commit + read the manifest from.
|
||||
#[arg(long, default_value = "hack-house")]
|
||||
name: String,
|
||||
/// Sandbox backend: docker | podman | multipass | local.
|
||||
#[arg(long, default_value = "podman")]
|
||||
backend: String,
|
||||
/// Origin tag recorded on the entry's `created_by`.
|
||||
#[arg(long, default_value = "operator")]
|
||||
created_by: String,
|
||||
},
|
||||
/// Mark a saved snapshot shareable, exporting a portable artifact if needed,
|
||||
/// so peers can `/sbx pull` it from the library. Mirrors `/sbx publish`.
|
||||
Publish {
|
||||
/// Label of an already-saved snapshot (see `Sbx Save`).
|
||||
label: String,
|
||||
/// Skill tags for catalog filtering (repeatable: `--tag recon --tag kali`).
|
||||
#[arg(long = "tag")]
|
||||
tags: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
@@ -164,6 +203,34 @@ fn main() -> Result<()> {
|
||||
no_tls,
|
||||
insecure,
|
||||
} => handshake(&ip, port, &user, &password, no_tls, insecure),
|
||||
Cmd::Sbx { action } => sbx_cmd(action),
|
||||
}
|
||||
}
|
||||
|
||||
/// Headless `hack-house sbx …` — the save/publish library surface for the
|
||||
/// autonomous `/loop` runner. Delegates to the same `snapshot`/`sbx`/`registry`
|
||||
/// code the interactive TUI uses, so there's exactly one canonical entry shape.
|
||||
fn sbx_cmd(action: SbxCmd) -> Result<()> {
|
||||
match action {
|
||||
SbxCmd::Save {
|
||||
label,
|
||||
local,
|
||||
name,
|
||||
backend,
|
||||
created_by,
|
||||
} => {
|
||||
let be = sbx::Backend::parse(&backend)
|
||||
.with_context(|| format!("unknown backend '{backend}'"))?;
|
||||
let desc = sbx::save_state(be, &name, &label, local)?;
|
||||
snapshot::register_saved_snapshot(be, &name, &label, &created_by);
|
||||
println!("{desc}");
|
||||
Ok(())
|
||||
}
|
||||
SbxCmd::Publish { label, tags } => {
|
||||
let line = snapshot::publish_snapshot(&label, &tags)?;
|
||||
println!("{line}");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+69
-1
@@ -105,12 +105,76 @@ fn store(reg: &Registry) -> Result<()> {
|
||||
std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
|
||||
}
|
||||
let text = serde_json::to_string_pretty(reg).context("serializing registry")?;
|
||||
std::fs::write(&path, text).with_context(|| format!("writing {}", path.display()))?;
|
||||
// Write to a per-process temp then atomically rename into place, so a reader
|
||||
// (or a concurrent wave member taking the lock next) never observes a
|
||||
// half-written file even if this process is killed mid-write.
|
||||
let tmp = path.with_extension(format!("json.tmp.{}", std::process::id()));
|
||||
std::fs::write(&tmp, text).with_context(|| format!("writing {}", tmp.display()))?;
|
||||
std::fs::rename(&tmp, &path).with_context(|| format!("committing {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// How long a lockfile may sit before it's presumed stale (its holder died) and
|
||||
/// forcibly reclaimed. Generous enough for a slow `export_image` of a fat VM.
|
||||
const LOCK_STALE_SECS: u64 = 120;
|
||||
|
||||
/// An advisory cross-process lock on the registry, held for the duration of one
|
||||
/// read-modify-write. Dropping it releases the lock (removes the lockfile).
|
||||
struct RegistryLock {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl Drop for RegistryLock {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
fn lock_path() -> PathBuf {
|
||||
registry_path().with_extension("lock")
|
||||
}
|
||||
|
||||
/// Take the registry lock, spinning until it's free or presumed-stale. The lock
|
||||
/// is a `create_new` (O_EXCL) sentinel file — atomic across processes on a local
|
||||
/// filesystem — which lets concurrent `/loop` wave members serialize their
|
||||
/// registry writes instead of clobbering each other through last-writer-wins.
|
||||
fn acquire_lock() -> Result<RegistryLock> {
|
||||
let path = lock_path();
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
|
||||
}
|
||||
let deadline =
|
||||
std::time::Instant::now() + std::time::Duration::from_secs(LOCK_STALE_SECS + 5);
|
||||
loop {
|
||||
match std::fs::OpenOptions::new().write(true).create_new(true).open(&path) {
|
||||
Ok(mut f) => {
|
||||
use std::io::Write;
|
||||
let _ = writeln!(f, "{}", std::process::id());
|
||||
return Ok(RegistryLock { path });
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
// Reclaim a lock whose holder appears to have died.
|
||||
if let Ok(age) = std::fs::metadata(&path).and_then(|m| m.modified()) {
|
||||
if age.elapsed().map(|d| d.as_secs() > LOCK_STALE_SECS).unwrap_or(false) {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if std::time::Instant::now() > deadline {
|
||||
anyhow::bail!(
|
||||
"registry locked (held >{LOCK_STALE_SECS}s) — another save/publish in progress?"
|
||||
);
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
}
|
||||
Err(e) => return Err(e).with_context(|| format!("opening {}", path.display())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert or replace the entry for `entry.label`.
|
||||
pub fn upsert(entry: Entry) -> Result<()> {
|
||||
let _lock = acquire_lock()?;
|
||||
let mut reg = load();
|
||||
reg.entries.insert(entry.label.clone(), entry);
|
||||
store(®)
|
||||
@@ -126,6 +190,7 @@ pub fn get(label: &str) -> Option<Entry> {
|
||||
/// `share_path` a peer can receive, merges in any `tags`, and flips `shareable`.
|
||||
/// Errors if the label isn't in the registry. Idempotent.
|
||||
pub fn publish(label: &str, share_path: &str, tags: &[String]) -> Result<Entry> {
|
||||
let _lock = acquire_lock()?;
|
||||
let mut reg = load();
|
||||
let entry = reg
|
||||
.entries
|
||||
@@ -173,6 +238,9 @@ pub fn list_shareable() -> Vec<Entry> {
|
||||
/// `image_exists` is injected so the engine probe stays out of this module's deps;
|
||||
/// it is asked only about `artifact_kind == "image"` entries.
|
||||
pub fn list_reconciled(image_exists: impl Fn(&str, &str) -> bool) -> Vec<Entry> {
|
||||
// Best-effort lock: this prunes-and-stores, so serialize it against writers
|
||||
// when we can, but never block a read-only listing if the lock is contended.
|
||||
let _lock = acquire_lock().ok();
|
||||
let mut reg = load();
|
||||
let before = reg.entries.len();
|
||||
reg.entries.retain(|_, e| match e.artifact_kind.as_str() {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
//! Snapshot indexing + publishing — shared between the interactive TUI (`app.rs`,
|
||||
//! the `/sbx save`/`/sbx publish` commands) and the headless `hack-house sbx`
|
||||
//! subcommand (`main.rs`, used by the autonomous `/loop` runner). Both paths call
|
||||
//! these same functions so a VM saved by an operator agent lands a byte-identical
|
||||
//! registry entry to one saved by a human in the room — no schema drift between
|
||||
//! the two save/publish surfaces.
|
||||
|
||||
use crate::registry;
|
||||
use crate::sbx;
|
||||
|
||||
/// Size in bytes of an OCI image (`<engine> image inspect … --format {{.Size}}`),
|
||||
/// or None if the engine/image isn't available. Blocking.
|
||||
pub fn oci_image_size(engine: &str, image: &str) -> Option<u64> {
|
||||
let out = std::process::Command::new(engine)
|
||||
.args(["image", "inspect", image, "--format", "{{.Size}}"])
|
||||
.stderr(std::process::Stdio::null())
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
String::from_utf8_lossy(&out.stdout).trim().parse::<u64>().ok()
|
||||
}
|
||||
|
||||
/// Record a freshly-saved snapshot in the host-global VM registry, caching the
|
||||
/// `.hh-agent` manifest summary read out of the (still-running, for OCI backends)
|
||||
/// container. Best-effort — never panics, never fails the save. Blocking; call
|
||||
/// from inside `spawn_blocking` (TUI) or directly (headless CLI).
|
||||
pub fn register_saved_snapshot(be: sbx::Backend, name: &str, label: &str, created_by: &str) {
|
||||
let (artifact_kind, artifact_ref, size_bytes, manifest) = match be {
|
||||
sbx::Backend::Docker | sbx::Backend::Podman => {
|
||||
let engine = be.engine();
|
||||
let image = format!("{}:{}", sbx::SNAP_REPO, label);
|
||||
let size = oci_image_size(engine, &image);
|
||||
let manifest = registry::read_container_manifest(engine, name, "/root");
|
||||
("image".to_string(), image, size, manifest)
|
||||
}
|
||||
// Multipass snapshots live in multipass's own store; the instance is
|
||||
// powered off by save time, so there's no container to read a manifest
|
||||
// from. Record the pointer; reconcile leaves it (no image to probe).
|
||||
sbx::Backend::Multipass => ("snapshot".to_string(), label.to_string(), None, None),
|
||||
sbx::Backend::Local => return, // nothing persistent to index
|
||||
};
|
||||
let (purpose, status, todo) = manifest
|
||||
.as_deref()
|
||||
.map(registry::scan_manifest)
|
||||
.unwrap_or_default();
|
||||
let entry = registry::Entry {
|
||||
label: label.to_string(),
|
||||
backend: be.engine().to_string(),
|
||||
artifact_kind,
|
||||
artifact_ref,
|
||||
size_bytes,
|
||||
created_unix: registry::now_unix(),
|
||||
created_by: created_by.to_string(),
|
||||
repo: registry::cwd_repo(),
|
||||
purpose,
|
||||
status,
|
||||
todo,
|
||||
..Default::default()
|
||||
};
|
||||
if let Err(e) = registry::upsert(entry) {
|
||||
eprintln!("registry upsert failed for '{label}': {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark a saved snapshot shareable and ensure it has a portable, sendable file.
|
||||
/// File-backed snaps (`.tar`/`.ova`) are already tradeable; image-backed snaps
|
||||
/// are exported to `hh-snapshots/hh-snap-<label>.tar` first so a peer can receive
|
||||
/// them over `/send`. Blocking — run off the UI thread. Returns a status line.
|
||||
pub fn publish_snapshot(label: &str, tags: &[String]) -> anyhow::Result<String> {
|
||||
use anyhow::Context;
|
||||
let entry = registry::get(label)
|
||||
.with_context(|| format!("no saved VM labelled '{label}' — `/sbx browse` to list"))?;
|
||||
let share_path = match entry.artifact_kind.as_str() {
|
||||
// Already a standalone file the owner controls — send it as-is.
|
||||
"file" => entry.artifact_ref.clone(),
|
||||
// Lives only in the engine image store — export a portable copy.
|
||||
"image" => {
|
||||
let backend = sbx::Backend::parse(&entry.backend)
|
||||
.with_context(|| format!("unknown backend '{}' for '{label}'", entry.backend))?;
|
||||
sbx::export_image(backend, label)?
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
other => anyhow::bail!(
|
||||
"'{label}' is a {other} snapshot — only file/image snapshots are tradeable"
|
||||
),
|
||||
};
|
||||
let published = registry::publish(label, &share_path, tags)?;
|
||||
let tagnote = if published.tags.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" [{}]", published.tags.join(", "))
|
||||
};
|
||||
Ok(format!(
|
||||
"✓ published '{label}'{tagnote} — shareable artifact at {share_path}; peers can `/sbx pull @<you> {label}`"
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
---
|
||||
name: hh-loop
|
||||
description: Autonomously grow the hack-house VM library. Each loop execution provisions a room + sandbox, spawns 1–3 Claude operators (planner/builder/tester) to build one high-value, self-describing VM from a brief, verifies it, then saves + publishes it with canonical .hh-agent metadata. Use when asked to run /loop, mass-produce VM library contributions, or batch-build/publish hack-house VMs.
|
||||
---
|
||||
|
||||
# hh-loop — autonomously build & publish hack-house VM-library contributions
|
||||
|
||||
One **loop execution** = one isolated room where a small team of Claude operators
|
||||
builds **one valuable, reusable VM**, proves it works, and publishes it to the
|
||||
host VM library. Many executions run in **waves** to reach a target count (e.g.
|
||||
50). Each VM is a capability another agent can `/sbx pull` and use immediately.
|
||||
|
||||
Built on the `hh-operator` skill (join/read/act/drive a room) plus the headless
|
||||
`hack-house sbx save|publish` library surface. **Read `hh-operator` first** — this
|
||||
skill orchestrates several of those sessions and adds save/publish + batching.
|
||||
|
||||
```bash
|
||||
# Run from the hack-house repo root; pin the venv interpreter.
|
||||
cd ~/coding/learning/hacker-house
|
||||
HH=".venv/bin/python -m cmd_chat.operator" # the hh-bridge CLI
|
||||
HHBIN="hh/target/debug/hack-house" # the Rust client + sbx CLI
|
||||
```
|
||||
|
||||
## What "high value" means — the brief framework
|
||||
|
||||
Every run is guided by the **same value rubric** so output is consistently
|
||||
useful, not busywork. A VM library contribution is high-value when it is:
|
||||
|
||||
1. **Reusable** — a ready-to-run capability, not a one-off script. Another
|
||||
agent/human pulls it and it *just works*.
|
||||
2. **Self-describing** — carries a complete `.hh-agent` manifest: `purpose`,
|
||||
`objective`, `usage[]` (how to run it), `setup[]`, and `status: done`.
|
||||
3. **Verified** — the capability is demonstrated working (tests / PoC green)
|
||||
*before* save. Unverified work is not published.
|
||||
4. **Distinct** — fills a real gap. **Always `registry list` first**; never
|
||||
duplicate an existing label/capability.
|
||||
5. **Bounded** — prefer what's in the base image (`localhost/hh-dev:slim`,
|
||||
Kali/Parrot) so it builds fast and the artifact stays portable.
|
||||
|
||||
A **brief** is the per-run spec the operators receive:
|
||||
|
||||
```
|
||||
label: kali-recon-kit # registry key / snapshot tag (unique!)
|
||||
title: Recon kit — one-command host+service map
|
||||
mission: A sandbox that maps a target to a tidy report in one command.
|
||||
deliverable: /root/recon.sh + README; runs nmap+host enumeration, writes report.md
|
||||
verify: ./recon.sh 127.0.0.1 produces report.md with open-port section
|
||||
tags: recon, kali, security
|
||||
topology: planner+builder+tester # see "adaptive topology" below
|
||||
```
|
||||
|
||||
### Adaptive topology (1–3 operators, chosen by the brief)
|
||||
|
||||
- **solo** (1) — small, fully-specified tool. The operator plans+builds+verifies.
|
||||
- **planner+builder** (2) — needs design then build; planner reviews before save.
|
||||
- **planner+builder+tester** (3) — needs *independent* verification; tester must
|
||||
pass the `verify` criterion before the planner approves save+publish.
|
||||
|
||||
The planner is the operator you join as; it spawns the others with `$HH spawn …
|
||||
--go --skip-permissions` (budgeted via `--depth/--fanout`). Review loops are
|
||||
event-gated: planner `watch`es for the tester's PASS before saving.
|
||||
|
||||
## Run ONE brief (visible tmux is the default)
|
||||
|
||||
Operations run in a **visible tmux session by default** so you can watch the TUI
|
||||
and the operators work; headless is an option, not the default. Use a **dedicated
|
||||
tmux socket** (`-L hh-loop`) and a `hh-loop-` session prefix so teardown can never
|
||||
touch your main tmux work.
|
||||
|
||||
```bash
|
||||
RUN="hh-loop-kali-recon-kit" # session name == run id (label-derived)
|
||||
PORT=8790 # unique per concurrent run (see isolation)
|
||||
PW="loop-$(openssl rand -hex 4)"
|
||||
LABEL="kali-recon-kit"
|
||||
|
||||
# 0. one-time per run: a private room server + the visible tmux session
|
||||
tmux -L hh-loop new-session -d -s "$RUN" -x 220 -y 50
|
||||
tmux -L hh-loop send-keys -t "$RUN" \
|
||||
"cd ~/coding/learning/hacker-house && .venv/bin/python cmd_chat.py serve 127.0.0.1 $PORT --password $PW --no-tls" Enter
|
||||
|
||||
# 1. join as the planner (spawns the bridge daemon), grant the sandbox, summon it
|
||||
$HH up 127.0.0.1 $PORT planner --password "$PW" --no-tls --session "$RUN"
|
||||
$HH sbx launch --image localhost/hh-dev:slim --engine podman
|
||||
# (in a real room the owner /grants; for a self-owned loop room you already drive it)
|
||||
|
||||
# 2. stamp the VM's manifest up front (canonical metadata travels with the work)
|
||||
$HH manifest push --root /root --name "$LABEL" \
|
||||
--purpose "Recon kit — one-command host+service map" \
|
||||
--objective "build recon.sh that maps a target to report.md, verified" \
|
||||
--intent "reusable recon VM for the library" --stop "verify criterion green"
|
||||
|
||||
# 3. spawn the team per topology (objective = brief + value rubric + save contract)
|
||||
$HH spawn "Join 127.0.0.1 $PORT as builder (--password $PW --no-tls). Use the \
|
||||
hh-operator skill. Build the deliverable for brief '$LABEL': <mission/deliverable>. \
|
||||
Load /root/.hh-agent first. When built, say 'BUILD READY'." \
|
||||
--room-host 127.0.0.1 --room-port $PORT --room-name builder --go --skip-permissions
|
||||
# (3-operator briefs also spawn a tester that must emit PASS against `verify`)
|
||||
|
||||
# 4. planner drives the review loop, then records done-state
|
||||
$HH watch --for "PASS|verify (ok|green)" --in events --timeout 1800
|
||||
$HH manifest update --root /root --status done --progress "built + verified" \
|
||||
--done "recon.sh works" --note "ready to publish"
|
||||
```
|
||||
|
||||
### Save + publish — canonical, headless, lock-safe
|
||||
|
||||
This is the payoff. The new **headless** `hack-house sbx` subcommand commits the
|
||||
sandbox and publishes it through the *same* registry path the TUI uses (atomic +
|
||||
file-locked, so concurrent wave members never clobber each other):
|
||||
|
||||
```bash
|
||||
# commit the running sandbox → indexed VM with its manifest summary scraped in
|
||||
$HHBIN sbx save "$LABEL" --name "$LABEL" --backend podman --created-by hh-loop --local
|
||||
|
||||
# mark shareable + export a portable tar so peers can pull it
|
||||
$HHBIN sbx publish "$LABEL" --tag recon --tag kali --tag security
|
||||
|
||||
# verify it landed shareable, with a real artifact
|
||||
$HH registry show "$LABEL" # purpose/status/todo scraped from the manifest
|
||||
ls -la hh/hh-snapshots/hh-snap-"$LABEL".tar
|
||||
```
|
||||
|
||||
A run is **done** only when: `verify` is green, `manifest status: done`, the
|
||||
registry entry is `shareable: true`, and the `.tar` exists.
|
||||
|
||||
## Recording a run (`--record`)
|
||||
|
||||
Capture the operation for review/demos. Two channels, combinable:
|
||||
|
||||
- **logs** (default-cheap): tee the bridge + server output and dump the session.
|
||||
```bash
|
||||
LOGDIR=~/coding/learning/hacker-house/.loop-runs/$RUN; mkdir -p "$LOGDIR"
|
||||
tmux -L hh-loop pipe-pane -t "$RUN" -o "cat >> $LOGDIR/tmux.log" # live tap
|
||||
$HH read --since 0 > "$LOGDIR/events.jsonl" # full event log
|
||||
$HH screen > "$LOGDIR/screen.txt" # final TUI frame
|
||||
```
|
||||
- **film**: record the visible TUI with the video-toolkit tmux capture
|
||||
(`~/coding/video-toolkit/bin/tmux-demo.py`) pointed at socket `hh-loop`,
|
||||
session `$RUN` → an asciinema cast / mp4. Heavier; use for showcase runs.
|
||||
|
||||
## Scale to a target (waves of 4–6)
|
||||
|
||||
The contention points are the **registry.json** (now locked) and the **podman
|
||||
namespace** — *not* git (runs build inside throwaway VMs). Isolate every run so a
|
||||
wave can't collide:
|
||||
|
||||
| Per-run knob | Make unique |
|
||||
|---|---|
|
||||
| room port | `8790 + i` |
|
||||
| tmux session / run id | `hh-loop-<label>` |
|
||||
| container + snapshot label | the brief's `label` (must be unique) |
|
||||
| child config dir | `$HH spawn --config-dir /tmp/cc-$RUN` |
|
||||
|
||||
Drive the queue in **waves of 4–6 concurrent runs** (moderate — keeps the
|
||||
CPU-bound box responsive; recall: this host is CPU-only and saturation drops
|
||||
agent sockets). Pull briefs from the curated **security-focused queue** (the
|
||||
`briefs/` set; one brief → one run), adaptive topology per brief, until the
|
||||
target count of published, shareable VMs is reached. After each wave, reconcile:
|
||||
|
||||
```bash
|
||||
$HH registry list # how many shareable VMs exist now → progress toward 50
|
||||
```
|
||||
|
||||
## Safe teardown (never touch the user's main tmux)
|
||||
|
||||
Everything lives on the `hh-loop` socket, so cleanup is scoped and safe:
|
||||
|
||||
```bash
|
||||
$HH down # leave the room, drop the daemon
|
||||
tmux -L hh-loop kill-session -t "$RUN" # one run
|
||||
tmux -L hh-loop kill-server # whole wave (only the hh-loop socket!)
|
||||
# NEVER `tmux kill-server` on the default socket — that's the user's session.
|
||||
```
|
||||
|
||||
Snapshots/tars persist (that's the product); prune throwaway *containers* with
|
||||
`podman rm -f <label>` if a run aborted before save.
|
||||
|
||||
## Safety
|
||||
|
||||
- Blast radius of any build is its container; never run destructive host commands.
|
||||
- Publish only **verified** VMs — an unverified or duplicate VM is not a
|
||||
contribution. `registry list` before every brief.
|
||||
- Respect the wave cap; oversubscribing the CPU drops agent websockets (mass
|
||||
FAIL/TIMEOUT is saturation, not model failure).
|
||||
- Always `$HH down` + scoped `tmux -L hh-loop kill-*` when done — no orphaned
|
||||
daemons or servers.
|
||||
Reference in New Issue
Block a user