feat(maestro): name a session from its first prompt

POST /api/sessions/:id/prompt claims a session's one naming attempt and runs
claude -p --model haiku in the background; the answer is reduced to a short
slug and replaces the generated handle, settling the session at Haiku. A
chosen name, a rename mid-run, a missing binary or a timeout all leave the
handle alone. The prompt goes to the child on stdin and is never logged.

Refs #131
This commit is contained in:
Miguel Palhas
2026-09-02 17:57:17 +01:00
parent 268dbb328d
commit e7b323b412
5 changed files with 476 additions and 3 deletions
+66
View File
@@ -71,6 +71,11 @@ pub struct SessionRecord {
/// (#115), so this is the observable that says its outcomes are safe to
/// assert on. Daemon-local bookkeeping; never rides the wire.
pub settled: bool,
/// Flipped when this session's one autonaming attempt is claimed (#131),
/// so two prompts in quick succession cannot both start a naming run.
/// Daemon-local bookkeeping; never rides the wire.
#[cfg(feature = "haiku-autoname")]
pub naming: bool,
}
#[derive(Debug, thiserror::Error)]
@@ -458,6 +463,8 @@ impl SessionManager {
bridge,
reported_activity: None,
settled: false,
#[cfg(feature = "haiku-autoname")]
naming: false,
},
);
}
@@ -661,6 +668,8 @@ impl SessionManager {
bridge,
reported_activity: None,
settled: false,
#[cfg(feature = "haiku-autoname")]
naming: false,
},
);
let _ = self
@@ -814,6 +823,63 @@ impl SessionManager {
}
}
#[cfg(feature = "haiku-autoname")]
impl SessionManager {
/// Claim this session's one autonaming attempt (#131).
///
/// `Ok(false)` when there is nothing to name: the session already carries
/// a name a person chose or a previous prompt produced, or a naming run
/// is already in flight. Claiming and checking happen under the same lock
/// so two prompts arriving together cannot both win.
pub async fn claim_autoname(&self, id: Uuid) -> Result<bool, SessionError> {
let mut sessions = self.inner.sessions.lock().await;
let rec = sessions.get_mut(&id).ok_or(SessionError::NotFound(id))?;
let provisional = maestro_protocol::display::is_provisional_name(&rec.session);
if rec.naming || !provisional {
return Ok(false);
}
rec.naming = true;
Ok(true)
}
/// Replace a provisional handle with a name derived from the first prompt
/// and settle the session at [`NameSource::Haiku`] (#131).
///
/// A rename that landed while the naming run was out wins: the check is
/// repeated here, under the lock, and a name a person chose is left alone.
pub async fn apply_autoname(&self, id: Uuid, name: &str) -> Result<(), SessionError> {
let Some(name) = maestro_protocol::normalize_session_name(name) else {
return Ok(());
};
let updated = {
let mut sessions = self.inner.sessions.lock().await;
let rec = sessions.get_mut(&id).ok_or(SessionError::NotFound(id))?;
if !maestro_protocol::display::is_provisional_name(&rec.session) {
return Ok(());
}
let mut named = rec.session.clone();
named
.metadata
.insert(NAME_KEY.into(), serde_json::Value::String(name));
named.metadata.insert(
NAME_SOURCE_KEY.into(),
serde_json::Value::String(NameSource::Haiku.as_str().into()),
);
self.inner
.sidecar
.write(&named)
.map_err(SessionError::Sidecar)?;
rec.session = named.clone();
named
};
let _ = self
.inner
.events
.send(CatalogEvent::SessionUpdated { session: updated });
Ok(())
}
}
/// Run a blocking `tmux` subprocess call on the blocking thread pool so it
/// never stalls a tokio worker. `tmux` is a fork+exec that can take tens of
/// milliseconds on a loaded host, and `send_keys` rides the per-keystroke hot