diff --git a/hive-ag3nt/prompts/agent.md b/hive-ag3nt/prompts/agent.md index 88f48d03..46a1eddc 100644 --- a/hive-ag3nt/prompts/agent.md +++ b/hive-ag3nt/prompts/agent.md @@ -9,10 +9,12 @@ Tools (hyperhive surface): Need new packages, env vars, or other NixOS config for yourself? You can't edit your own config directly — message the manager (recipient `manager`) describing what you need + why. The manager evaluates the request (it doesn't rubber-stamp), edits `/agents/{label}/config/agent.nix` on your behalf, commits, and submits an approval that the operator can accept on the dashboard; on approve hive-c0re rebuilds your container with the new config. -Durable knowledge: write to `/state/notes.md` (free-form) or any other path under `/state/`. That directory is bind-mounted from the host and persists across container destroy/recreate — claude's `--continue` session only carries short-term context, but `/state/` is forever. Read it back at the start of relevant turns to remember things across resets. +Durable knowledge: write to `/agents/{label}/state/notes.md` (free-form) or any other path under `/agents/{label}/state/`. That directory is bind-mounted from the host and persists across container destroy/recreate — claude's `--continue` session only carries short-term context, but `/agents/{label}/state/` is forever. Read it back at the start of relevant turns to remember things across resets. + +Claude session (OAuth credentials) lives at `/root/.claude/` and persists across restarts. **Shared space**: `/shared` is accessible to all agents (read/write). Only put things here you're willing to lose — other agents may delete them. Use for explicit cross-agent communication or shared artifacts when appropriate. -Keep messages short — a few sentences each. For anything big (file listings, long diffs, transcripts, analysis): write the payload to `/state/` and `send` a short pointer ("dropped the cluster audit in /state/cluster-audit-2026-05.md, headline: 3 nodes over 80% mem"). The manager + operator can read your `/state/` from the host as `/agents/{label}/state/`. Sub-agent peers can't read each other's `/state/` directly — go through the manager if a payload needs to reach another sub-agent. +Keep messages short — a few sentences each. For anything big (file listings, long diffs, transcripts, analysis): write the payload to `/agents/{label}/state/` and `send` a short pointer ("dropped the cluster audit in /state/cluster-audit-2026-05.md, headline: 3 nodes over 80% mem"). The manager + operator can read your state from the host as `/agents/{label}/state/`. Sub-agent peers can't read each other's `/state/` directly — go through the manager if a payload needs to reach another sub-agent. When your inbox has a message, handle it and stop. Don't narrate intent — act. diff --git a/hive-ag3nt/prompts/manager.md b/hive-ag3nt/prompts/manager.md index 8a4d9452..c08f5b8a 100644 --- a/hive-ag3nt/prompts/manager.md +++ b/hive-ag3nt/prompts/manager.md @@ -4,12 +4,12 @@ Tools (hyperhive surface): - `mcp__hyperhive__recv(wait_seconds?)` — drain one more message from your inbox. Without `wait_seconds` (or with `0`) it returns immediately — a cheap inbox peek you can drop between actions. To **wait** when you have nothing else to do, call with a long wait (e.g. `wait_seconds: 180`, the max) — you'll wake instantly on new work, otherwise return after the timeout. Use that instead of ending the turn or sleeping in a Bash command. - `mcp__hyperhive__send(to, body)` — message an agent (by name), another peer, or the operator (`operator` surfaces in the dashboard). Use `to: "*"` to broadcast to all agents (they receive a hint that it's a broadcast and may not need action). -- `mcp__hyperhive__request_spawn(name)` — queue a brand-new sub-agent for operator approval (≤9 char name). +- `mcp__hyperhive__request_spawn(name, description?)` — queue a brand-new sub-agent for operator approval (≤9 char name). Pass an optional `description` and it appears on the dashboard approval card — no need to send a separate message explaining the request. - `mcp__hyperhive__kill(name)` — graceful stop on a sub-agent. No approval required. - `mcp__hyperhive__start(name)` — start a stopped sub-agent. No approval required. - `mcp__hyperhive__restart(name)` — stop + start a sub-agent. No approval required. - `mcp__hyperhive__update(name)` — rebuild a sub-agent (re-applies the current hyperhive flake + agent.nix, restarts the container). No approval required — idempotent. Use when you receive a `needs_update` system event. -- `mcp__hyperhive__request_apply_commit(agent, commit_ref)` — submit a config change for any agent (`hm1nd` for self) for operator approval. At submit time hive-c0re fetches your commit into the agent's applied repo and pins it as `proposal/`; from that moment your proposed-side commit can be amended or force-pushed freely without changing what the operator will build. +- `mcp__hyperhive__request_apply_commit(agent, commit_ref, description?)` — submit a config change for any agent (`hm1nd` for self) for operator approval. Pass an optional `description` and it appears on the dashboard approval card so the operator knows what changed without opening the diff. At submit time hive-c0re fetches your commit into the agent's applied repo and pins it as `proposal/`; from that moment your proposed-side commit can be amended or force-pushed freely without changing what the operator will build. - `mcp__hyperhive__ask_operator(question, options?, multi?, ttl_seconds?)` — surface a question on the dashboard. Returns immediately with a question id; the operator's answer arrives later as a system `operator_answered` event in your inbox. Options are advisory: the dashboard always lets the operator type a free-text answer in addition. Set `multi: true` to render options as checkboxes (operator can pick multiple); the answer comes back as `, `-separated. Set `ttl_seconds` to auto-cancel after a deadline — useful when the decision becomes moot if the operator hasn't responded in time; on expiry the answer is `[expired]`. Do not poll inside the same turn — finish the current work and react when the event lands. Approval boundary: lifecycle ops on *existing* sub-agents (`kill`, `start`, `restart`) are at your discretion — no operator approval. *Creating* a new agent (`request_spawn`) and *changing* any agent's config (`request_apply_commit`) still go through the approval queue. The operator only signs off on changes; you run the day-to-day. @@ -32,7 +32,7 @@ in { environment.systemPackages = [ matrixPkg ]; hyperhive.extraMcpServers.matrix = { command = "${matrixPkg}/bin/mcp-matrix"; - args = [ "--config" "/state/matrix.toml" ]; + args = [ "--config" "/agents//state/matrix.toml" ]; # replace with the agent's label allowedTools = [ "send_message" "join_room" ]; }; } diff --git a/hive-ag3nt/src/bin/hive-ag3nt.rs b/hive-ag3nt/src/bin/hive-ag3nt.rs index 8d6741b1..fa9e1fe7 100644 --- a/hive-ag3nt/src/bin/hive-ag3nt.rs +++ b/hive-ag3nt/src/bin/hive-ag3nt.rs @@ -65,7 +65,7 @@ async fn main() -> Result<()> { .and_then(|s| s.parse::().ok()) .unwrap_or(DEFAULT_WEB_PORT); let label = std::env::var("HIVE_LABEL").unwrap_or_else(|_| "hive-ag3nt".into()); - let claude_dir = PathBuf::from(login::DEFAULT_CLAUDE_DIR); + let claude_dir = login::default_dir(); let initial = LoginState::from_dir(&claude_dir); tracing::info!(state = ?initial, claude_dir = %claude_dir.display(), "harness boot"); let login_state = Arc::new(Mutex::new(initial)); diff --git a/hive-ag3nt/src/bin/hive-m1nd.rs b/hive-ag3nt/src/bin/hive-m1nd.rs index b56e9b4c..aa700796 100644 --- a/hive-ag3nt/src/bin/hive-m1nd.rs +++ b/hive-ag3nt/src/bin/hive-m1nd.rs @@ -55,7 +55,7 @@ async fn main() -> Result<()> { .and_then(|s| s.parse::().ok()) .unwrap_or(DEFAULT_WEB_PORT); let label = std::env::var("HIVE_LABEL").unwrap_or_else(|_| "hm1nd".into()); - let claude_dir = PathBuf::from(login::DEFAULT_CLAUDE_DIR); + let claude_dir = login::default_dir(); let initial = LoginState::from_dir(&claude_dir); tracing::info!(state = ?initial, claude_dir = %claude_dir.display(), "hm1nd boot"); let login_state = Arc::new(Mutex::new(initial)); diff --git a/hive-ag3nt/src/events.rs b/hive-ag3nt/src/events.rs index a5c89d2f..c8b0b08e 100644 --- a/hive-ag3nt/src/events.rs +++ b/hive-ag3nt/src/events.rs @@ -20,21 +20,18 @@ const CHANNEL_CAPACITY: usize = 256; /// Max `LiveEvent`s the `Bus` returns from `history()` and keeps in /// sqlite. Older rows are vacuumed on a periodic sweep. const HISTORY_CAPACITY: usize = 2000; -/// Default sqlite db path. Lives under `/state/` so it survives -/// destroy/recreate but goes away on purge. Overridable via the -/// `HYPERHIVE_EVENTS_DB` env var (used in tests and one-shot tools). -const DEFAULT_EVENTS_DB: &str = "/state/hyperhive-events.sqlite"; +/// Path to the persisted event db. Overridable via `HYPERHIVE_EVENTS_DB` +/// for dev / tests; otherwise derived from the agent's state dir. +fn events_db_path() -> PathBuf { + std::env::var_os("HYPERHIVE_EVENTS_DB") + .map_or_else(|| crate::paths::state_dir().join("hyperhive-events.sqlite"), PathBuf::from) +} -/// Persisted model name file. Same lifecycle as the events db — -/// survives destroy/recreate, gone on purge. Empty / missing file -/// falls back to `DEFAULT_MODEL`. -const DEFAULT_MODEL_FILE: &str = "/state/hyperhive-model"; - -/// Path to the persisted model file. Overridable via -/// `HYPERHIVE_MODEL_FILE` for dev / tests. +/// Path to the persisted model file. Overridable via `HYPERHIVE_MODEL_FILE` +/// for dev / tests; otherwise derived from the agent's state dir. fn model_file_path() -> PathBuf { std::env::var_os("HYPERHIVE_MODEL_FILE") - .map_or_else(|| PathBuf::from(DEFAULT_MODEL_FILE), PathBuf::from) + .map_or_else(|| crate::paths::state_dir().join("hyperhive-model"), PathBuf::from) } fn load_model() -> Option { @@ -183,7 +180,7 @@ pub struct Bus { tx: Arc>, /// Persistent event log. `None` only if opening the sqlite db failed /// at construction — we keep going so the harness doesn't die on a - /// missing `/state/` mount in dev / test scenarios. + /// missing state dir mount in dev / test scenarios. store: Option>, /// Current turn-loop state + since-when (unix seconds). state: Arc>, @@ -200,13 +197,11 @@ pub struct Bus { } impl Bus { - /// Open the default events db (`/state/hyperhive-events.sqlite`, or - /// `HYPERHIVE_EVENTS_DB`). On failure, fall back to a no-store bus — - /// the harness still works, just without persistent history. + /// Open the events db (path from `events_db_path()`). On failure, fall back + /// to a no-store bus — the harness still works, just without persistent history. #[must_use] pub fn new() -> Self { - let path = std::env::var_os("HYPERHIVE_EVENTS_DB") - .map_or_else(|| PathBuf::from(DEFAULT_EVENTS_DB), PathBuf::from); + let path = events_db_path(); let store = match EventStore::open(&path) { Ok(s) => Some(Arc::new(s)), Err(e) => { @@ -247,8 +242,8 @@ impl Bus { } /// Switch the model for future turns. The current turn (if any) - /// keeps the model it was already running. Persisted to - /// `/state/hyperhive-model` so the override survives harness + /// keeps the model it was already running. Persisted to the agent's + /// state dir (`hyperhive-model`) so the override survives harness /// restart and container rebuild (gone on `--purge`, matching /// every other piece of agent state). pub fn set_model(&self, name: impl Into) { diff --git a/hive-ag3nt/src/lib.rs b/hive-ag3nt/src/lib.rs index d29f3938..22ced069 100644 --- a/hive-ag3nt/src/lib.rs +++ b/hive-ag3nt/src/lib.rs @@ -6,6 +6,7 @@ pub mod events; pub mod login; pub mod login_session; pub mod mcp; +pub mod paths; pub mod plugins; pub mod turn; pub mod web_ui; diff --git a/hive-ag3nt/src/login.rs b/hive-ag3nt/src/login.rs index 208045b0..68578dbb 100644 --- a/hive-ag3nt/src/login.rs +++ b/hive-ag3nt/src/login.rs @@ -1,6 +1,6 @@ //! Login-state probe for the bind-mounted `~/.claude/` dir. The dir is -//! provided by hive-c0re (Phase 8 step 1) and persists across container -//! destroy/recreate so OAuth tokens survive. +//! provided by hive-c0re and persists across container destroy/recreate so +//! OAuth tokens survive. //! //! "Has session" today means "the dir contains at least one regular file." //! That's a heuristic: a fresh bind-mount starts empty, and `claude auth login` @@ -8,11 +8,15 @@ //! specific credentials filename, or run a no-op `claude` call) once the //! exact layout is locked in. -use std::path::Path; +use std::path::{Path, PathBuf}; -/// Mount point of the per-agent Claude credentials dir inside the container. -/// Matches `hive_c0re::lifecycle::CONTAINER_CLAUDE_MOUNT`. -pub const DEFAULT_CLAUDE_DIR: &str = "/root/.claude"; +/// Returns the Claude credentials directory for this agent, derived from +/// `HIVE_LABEL`. Manager ("hm1nd") uses `/root/.claude`; sub-agents use +/// `/agents/{label}/claude`. Overridable via `HYPERHIVE_CLAUDE_DIR`. +#[must_use] +pub fn default_dir() -> PathBuf { + crate::paths::claude_dir() +} /// Returns `true` if `dir` exists and contains any regular file. Used at /// startup to decide whether to enter the turn loop (logged in) or stay in diff --git a/hive-ag3nt/src/mcp.rs b/hive-ag3nt/src/mcp.rs index a31a791d..90a8e739 100644 --- a/hive-ag3nt/src/mcp.rs +++ b/hive-ag3nt/src/mcp.rs @@ -265,6 +265,10 @@ pub struct RequestSpawnArgs { /// New sub-agent name (≤9 chars). Queues a Spawn approval; the /// operator approves on the dashboard before the container is created. pub name: String, + /// Optional description shown on the dashboard approval card so the + /// operator knows what the new agent is for without a separate message. + #[serde(default)] + pub description: Option, } #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] @@ -320,6 +324,10 @@ pub struct RequestApplyCommitArgs { pub agent: String, /// Git sha (full or short) pointing at the proposed `agent.nix`. pub commit_ref: String, + /// Optional description shown on the dashboard approval card so the + /// operator knows what the change does without opening the diff. + #[serde(default)] + pub description: Option, } #[derive(Debug, Clone)] @@ -395,7 +403,10 @@ impl ManagerServer { let name = args.name.clone(); run_tool_envelope("request_spawn", log, async move { let resp = self - .dispatch(hive_sh4re::ManagerRequest::RequestSpawn { name: args.name }) + .dispatch(hive_sh4re::ManagerRequest::RequestSpawn { + name: args.name, + description: args.description, + }) .await; format_ack( resp, @@ -521,6 +532,7 @@ impl ManagerServer { .dispatch(hive_sh4re::ManagerRequest::RequestApplyCommit { agent: args.agent, commit_ref: args.commit_ref, + description: args.description, }) .await; format_ack( diff --git a/hive-ag3nt/src/paths.rs b/hive-ag3nt/src/paths.rs new file mode 100644 index 00000000..0774f75a --- /dev/null +++ b/hive-ag3nt/src/paths.rs @@ -0,0 +1,39 @@ +//! Per-agent path resolution for state and credential directories. +//! +//! Manager ("hm1nd") keeps `/state`; sub-agents use `/agents/{label}/state`. +//! Claude credentials are always at `/root/.claude` for all agents. +//! +//! Both paths can be overridden via env vars (`HYPERHIVE_STATE_DIR`, +//! `HYPERHIVE_CLAUDE_DIR`) for dev / test scenarios. + +use std::path::PathBuf; + +/// Container label of the manager. Sub-agents get `/agents/{label}/state`; +/// the manager keeps `/state`. Must match `hive-c0re::lifecycle::MANAGER_NAME`. +pub const MANAGER_NAME: &str = "hm1nd"; + +/// Durable state directory for the current agent. Reads `HYPERHIVE_STATE_DIR` +/// first; falls back to `/agents/{label}/state` for sub-agents or `/state` for +/// the manager / any unrecognised label. +#[must_use] +pub fn state_dir() -> PathBuf { + if let Some(p) = std::env::var_os("HYPERHIVE_STATE_DIR") { + return PathBuf::from(p); + } + let label = std::env::var("HIVE_LABEL").unwrap_or_default(); + if label == MANAGER_NAME || label.is_empty() { + PathBuf::from("/state") + } else { + PathBuf::from(format!("/agents/{label}/state")) + } +} + +/// Claude credentials directory for the current agent. Always `/root/.claude` +/// because the `claude` CLI reads `$HOME/.claude` (uid 0 → `/root`), and +/// hive-c0re binds the per-agent credentials dir there for every container. +/// Overridable via `HYPERHIVE_CLAUDE_DIR` for dev / test scenarios. +#[must_use] +pub fn claude_dir() -> PathBuf { + std::env::var_os("HYPERHIVE_CLAUDE_DIR") + .map_or_else(|| PathBuf::from("/root/.claude"), PathBuf::from) +} diff --git a/hive-ag3nt/src/turn.rs b/hive-ag3nt/src/turn.rs index 89a614ce..651f2a72 100644 --- a/hive-ag3nt/src/turn.rs +++ b/hive-ag3nt/src/turn.rs @@ -230,15 +230,13 @@ async fn run_claude(prompt: &str, files: &TurnFiles, bus: &Bus) -> Result )); } let mut cmd = Command::new("claude"); - // Spawn inside /state so any path claude resolves relatively (Read - // foo.md, Bash ls, Write notes.md) lands in the agent's durable - // dir instead of wherever the harness systemd unit started. /state - // is bind-mounted RW from the host so survives destroy/recreate. - // Fall back silently if the dir is missing (dev / test setups - // running without the bind mount) — Command picks up the parent's - // cwd in that case. - if std::path::Path::new("/state").is_dir() { - cmd.current_dir("/state"); + // Spawn inside the agent's state dir so relative paths in tool calls + // (Read foo.md, Bash ls, Write notes.md) land in the durable dir + // instead of wherever the harness systemd unit started. Falls back + // silently if the dir is missing (dev / test without the bind mount). + let state_dir = crate::paths::state_dir(); + if state_dir.is_dir() { + cmd.current_dir(&state_dir); } cmd.arg("--print") .arg("--verbose") diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index 7b075c03..ecaae207 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -684,6 +684,9 @@ 'new sub-agent — container will be created on approve'), ); } + if (a.description) { + li.append(el('div', { class: 'approval-description' }, a.description)); + } // Deny prompts the operator for an optional reason; the // submit handler stashes it into a hidden `note` input that // rides along on the POST and is surfaced to the manager via diff --git a/hive-c0re/assets/dashboard.css b/hive-c0re/assets/dashboard.css index 27269eb1..5f92e40c 100644 --- a/hive-c0re/assets/dashboard.css +++ b/hive-c0re/assets/dashboard.css @@ -258,6 +258,7 @@ code { } .approvals .row { display: flex; align-items: center; flex-wrap: wrap; gap: 0.4em; } .approvals form.inline { display: inline; margin-left: 0.4em; } +.approval-description { font-size: 0.85em; color: var(--fg-dim, #888); margin: 0.2em 0 0.4em 1.2em; } .approval-tabs { display: flex; gap: 0.4em; diff --git a/hive-c0re/src/approvals.rs b/hive-c0re/src/approvals.rs index 995c355b..b5cbca99 100644 --- a/hive-c0re/src/approvals.rs +++ b/hive-c0re/src/approvals.rs @@ -24,6 +24,20 @@ CREATE INDEX IF NOT EXISTS idx_approvals_pending ON approvals (id) WHERE status = 'pending'; "; +/// Add the `description` column to pre-description databases. Manager-supplied +/// note shown on the dashboard approval card at submission time (distinct from +/// `note` which is set on denial/failure). +fn ensure_description_column(conn: &Connection) -> Result<()> { + let has: bool = conn + .prepare("SELECT 1 FROM pragma_table_info('approvals') WHERE name = 'description'")? + .exists([])?; + if !has { + conn.execute_batch("ALTER TABLE approvals ADD COLUMN description TEXT;") + .context("add approvals.description column")?; + } + Ok(()) +} + /// Add the `kind` column to pre-Phase-8 databases. ALTER TABLE ADD COLUMN is /// idempotent here only via a column-existence check (sqlite doesn't support /// IF NOT EXISTS on ADD COLUMN). Defaults legacy rows to `apply_commit`, @@ -72,21 +86,24 @@ impl Approvals { .context("apply approvals schema")?; ensure_kind_column(&conn).context("migrate approvals.kind")?; ensure_fetched_sha_column(&conn).context("migrate approvals.fetched_sha")?; + ensure_description_column(&conn).context("migrate approvals.description")?; Ok(Self { conn: Mutex::new(conn), }) } - pub fn submit(&self, agent: &str, commit_ref: &str) -> Result { - self.submit_kind(agent, ApprovalKind::ApplyCommit, commit_ref) - } - - pub fn submit_kind(&self, agent: &str, kind: ApprovalKind, commit_ref: &str) -> Result { + pub fn submit_kind( + &self, + agent: &str, + kind: ApprovalKind, + commit_ref: &str, + description: Option<&str>, + ) -> Result { let conn = self.conn.lock().unwrap(); conn.execute( - "INSERT INTO approvals (agent, kind, commit_ref, requested_at, status) - VALUES (?1, ?2, ?3, ?4, 'pending')", - params![agent, kind_to_str(kind), commit_ref, now_unix()], + "INSERT INTO approvals (agent, kind, commit_ref, requested_at, status, description) + VALUES (?1, ?2, ?3, ?4, 'pending', ?5)", + params![agent, kind_to_str(kind), commit_ref, now_unix(), description], )?; Ok(conn.last_insert_rowid()) } @@ -107,7 +124,7 @@ impl Approvals { pub fn recent_resolved(&self, limit: u64) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( - "SELECT id, agent, kind, commit_ref, requested_at, status, resolved_at, note, fetched_sha + "SELECT id, agent, kind, commit_ref, requested_at, status, resolved_at, note, fetched_sha, description FROM approvals WHERE status IN ('approved', 'denied', 'failed') ORDER BY resolved_at DESC, id DESC @@ -121,7 +138,7 @@ impl Approvals { pub fn pending(&self) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( - "SELECT id, agent, kind, commit_ref, requested_at, status, resolved_at, note, fetched_sha + "SELECT id, agent, kind, commit_ref, requested_at, status, resolved_at, note, fetched_sha, description FROM approvals WHERE status = 'pending' ORDER BY id ASC", @@ -134,7 +151,7 @@ impl Approvals { pub fn get(&self, id: i64) -> Result> { let conn = self.conn.lock().unwrap(); conn.query_row( - "SELECT id, agent, kind, commit_ref, requested_at, status, resolved_at, note, fetched_sha + "SELECT id, agent, kind, commit_ref, requested_at, status, resolved_at, note, fetched_sha, description FROM approvals WHERE id = ?1", params![id], row_to_approval, @@ -147,9 +164,9 @@ impl Approvals { /// approval so the caller can run the action and pass the agent name. pub fn mark_approved(&self, id: i64) -> Result { let conn = self.conn.lock().unwrap(); - let current: Option<(String, String, String, i64, String, Option)> = conn - .query_row( - "SELECT agent, kind, commit_ref, requested_at, status, fetched_sha + let current: Option<(String, String, String, i64, String, Option, Option)> = + conn.query_row( + "SELECT agent, kind, commit_ref, requested_at, status, fetched_sha, description FROM approvals WHERE id = ?1", params![id], |row| { @@ -160,11 +177,14 @@ impl Approvals { row.get(3)?, row.get(4)?, row.get(5)?, + row.get(6)?, )) }, ) .optional()?; - let Some((agent, kind, commit_ref, requested_at, status, fetched_sha)) = current else { + let Some((agent, kind, commit_ref, requested_at, status, fetched_sha, description)) = + current + else { bail!("approval {id} not found"); }; if status != "pending" { @@ -185,6 +205,7 @@ impl Approvals { resolved_at: Some(resolved_at), note: None, fetched_sha, + description, }) } @@ -224,7 +245,7 @@ impl Approvals { } fn row_to_approval(row: &rusqlite::Row<'_>) -> rusqlite::Result { - // Column order: id, agent, kind, commit_ref, requested_at, status, resolved_at, note, fetched_sha. + // Column order: id, agent, kind, commit_ref, requested_at, status, resolved_at, note, fetched_sha, description. let kind: String = row.get(2)?; let kind = match kind.as_str() { "apply_commit" => ApprovalKind::ApplyCommit, @@ -261,6 +282,7 @@ fn row_to_approval(row: &rusqlite::Row<'_>) -> rusqlite::Result { resolved_at: row.get(6)?, note: row.get(7)?, fetched_sha: row.get(8)?, + description: row.get(9)?, }) } diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 4fdb4171..3e31d57c 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -237,6 +237,9 @@ struct ApprovalView { sha_short: Option, /// Pre-rendered syntax-coloured diff HTML, for `ApplyCommit` only. diff_html: Option, + /// Manager-supplied description shown on the approval card. + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, } /// Replace silent `.unwrap_or_default()` on the data sources behind @@ -605,10 +608,11 @@ async fn build_approval_views(approvals: Vec) -> Vec { let diff = approval_diff(&a.agent, a.id).await; ApprovalView { id: a.id, - agent: a.agent, + agent: a.agent.clone(), kind: "apply_commit", sha_short: Some(sha), diff_html: Some(render_diff_lines(&diff)), + description: a.description, } } hive_sh4re::ApprovalKind::Spawn => ApprovalView { @@ -617,6 +621,7 @@ async fn build_approval_views(approvals: Vec) -> Vec { kind: "spawn", sha_short: None, diff_html: None, + description: a.description, }, }); } @@ -1065,7 +1070,7 @@ async fn post_request_spawn( match state .coord .approvals - .submit_kind(&name, hive_sh4re::ApprovalKind::Spawn, "") + .submit_kind(&name, hive_sh4re::ApprovalKind::Spawn, "", None) { Ok(id) => { tracing::info!(%id, %name, "operator: spawn approval queued via dashboard"); diff --git a/hive-c0re/src/lifecycle.rs b/hive-c0re/src/lifecycle.rs index 2b9656da..760d4b43 100644 --- a/hive-c0re/src/lifecycle.rs +++ b/hive-c0re/src/lifecycle.rs @@ -742,8 +742,22 @@ fn set_nspawn_flags( let path = format!("/etc/nixos-containers/{container}.conf"); let original = std::fs::read_to_string(&path).with_context(|| format!("read {path}"))?; + + // Compute the in-container state mount point. Sub-agents get + // /agents//state; the manager keeps the legacy /state path. + // Claude credentials always land at /root/.claude for all agents so + // the `claude` CLI (which reads $HOME/.claude) finds them without any + // HOME override. + let notes_mount = if container == MANAGER_NAME { + CONTAINER_NOTES_MOUNT.to_owned() + } else { + let agent_name = container.strip_prefix(AGENT_PREFIX).unwrap_or(container); + format!("/agents/{agent_name}/state") + }; + let claude_mount = CONTAINER_CLAUDE_MOUNT; + let mut binds = format!( - "--bind={runtime}:{CONTAINER_RUNTIME_MOUNT} --bind={claude}:{CONTAINER_CLAUDE_MOUNT} --bind={notes}:{CONTAINER_NOTES_MOUNT} --bind={shared}:{CONTAINER_SHARED_MOUNT}", + "--bind={runtime}:{CONTAINER_RUNTIME_MOUNT} --bind={claude}:{claude_mount} --bind={notes}:{notes_mount} --bind={shared}:{CONTAINER_SHARED_MOUNT}", runtime = runtime_dir.display(), claude = claude_dir.display(), notes = notes_dir.display(), diff --git a/hive-c0re/src/manager_server.rs b/hive-c0re/src/manager_server.rs index 47c1854a..aa74a53c 100644 --- a/hive-c0re/src/manager_server.rs +++ b/hive-c0re/src/manager_server.rs @@ -131,12 +131,14 @@ async fn dispatch(req: &ManagerRequest, coord: &Arc) -> ManagerResp message: format!("{e:#}"), }, }, - ManagerRequest::RequestSpawn { name } => { + ManagerRequest::RequestSpawn { name, description } => { tracing::info!(%name, "manager: request_spawn"); - match coord - .approvals - .submit_kind(name, hive_sh4re::ApprovalKind::Spawn, "") - { + match coord.approvals.submit_kind( + name, + hive_sh4re::ApprovalKind::Spawn, + "", + description.as_deref(), + ) { Ok(id) => { tracing::info!(%id, %name, "spawn approval queued"); ManagerResponse::Ok @@ -257,9 +259,13 @@ async fn dispatch(req: &ManagerRequest, coord: &Arc) -> ManagerResp }, } } - ManagerRequest::RequestApplyCommit { agent, commit_ref } => { + ManagerRequest::RequestApplyCommit { + agent, + commit_ref, + description, + } => { tracing::info!(%agent, %commit_ref, "manager: request_apply_commit"); - match submit_apply_commit(coord, agent, commit_ref).await { + match submit_apply_commit(coord, agent, commit_ref, description.as_deref()).await { Ok((id, sha)) => { tracing::info!(%id, %agent, manager_ref = %commit_ref, %sha, "approval queued + proposal tag planted"); ManagerResponse::Ok @@ -287,6 +293,7 @@ async fn submit_apply_commit( coord: &Arc, agent: &str, commit_ref: &str, + description: Option<&str>, ) -> anyhow::Result<(i64, String)> { let proposed_dir = crate::coordinator::Coordinator::agent_proposed_dir(agent); let applied_dir = crate::coordinator::Coordinator::agent_applied_dir(agent); @@ -304,7 +311,12 @@ async fn submit_apply_commit( } let id = coord .approvals - .submit(agent, commit_ref) + .submit_kind( + agent, + hive_sh4re::ApprovalKind::ApplyCommit, + commit_ref, + description, + ) .map_err(|e| anyhow::anyhow!("queue approval row: {e:#}"))?; let tag = format!("proposal/{id}"); let sha = match crate::lifecycle::git_fetch_to_tag( diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index 1a16291b..c11a8948 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -106,7 +106,7 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { tracing::info!(%name, "request_spawn"); let id = coord .approvals - .submit_kind(name, hive_sh4re::ApprovalKind::Spawn, "")?; + .submit_kind(name, hive_sh4re::ApprovalKind::Spawn, "", None)?; tracing::info!(%id, %name, "spawn approval queued"); HostResponse::success() } diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index ed810516..5a6fd0ce 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -83,6 +83,11 @@ pub struct Approval { pub resolved_at: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub note: Option, + /// Optional free-text description the manager attached at submission + /// time — shown on the dashboard approval card so the operator can + /// understand the change without opening the diff. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, } /// What action the approval, when granted, will trigger. @@ -414,6 +419,9 @@ pub enum ManagerRequest { /// agent of the same name already exists, the approval will fail. RequestSpawn { name: String, + /// Optional description shown on the dashboard approval card. + #[serde(default, skip_serializing_if = "Option::is_none")] + description: Option, }, /// Stop a sub-agent (graceful). Kill { @@ -439,6 +447,10 @@ pub enum ManagerRequest { RequestApplyCommit { agent: String, commit_ref: String, + /// Optional description shown on the dashboard approval card so the + /// operator knows what the change does without opening the diff. + #[serde(default, skip_serializing_if = "Option::is_none")] + description: Option, }, /// Ask the operator a question. Returns immediately with the queued /// question id; the operator's answer arrives later as a