use super::*;
pub(crate) async fn agents(State(app): S) -> Response {
Json(agents_json(&app)).into_response()
}
pub(crate) async fn connect_claude(
State(app): S,
headers: HeaderMap,
Query(q): Query<std::collections::HashMap<String, String>>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
let recorded = app.exe.as_ref().map(|e| e.path.clone());
let ran = tokio::task::spawn_blocking(move || {
let exe = match recorded {
Some(p) => p,
None => std::env::current_exe()?,
};
std::process::Command::new(exe)
.args(["init-claude", "--auto"])
.stdin(std::process::Stdio::null())
.output()
})
.await;
match ran {
Ok(Ok(out)) => {
let said = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
Json(json!({ "ok": out.status.success(), "said": said.trim(), "agents": agents_json(&app) }))
.into_response()
}
Ok(Err(e)) => err(e.into()),
Err(e) => err(anyhow::anyhow!(e)),
}
}
pub(crate) fn agents_json(app: &App) -> serde_json::Value {
let senders = app.store.senders().unwrap_or_default();
let online = app.online.lock().unwrap_or_else(|e| e.into_inner()).clone();
json!({
"program": crate::setup::program().0,
"claude_on_path": crate::platform::find_on_path("claude").is_some(),
"rows": crate::agents::rows(&senders, &online),
"online": online,
"now": crate::store::now(),
})
}
#[derive(Deserialize)]
pub(crate) struct AgentBody {
#[serde(default)]
pub(crate) state: Option<String>,
#[serde(default)]
pub(crate) session: Option<String>,
#[serde(default)]
pub(crate) model: Option<String>,
#[serde(default)]
pub(crate) ctx: Option<CtxBody>,
#[serde(default)]
pub(crate) limits: Option<LimitsBody>,
}
#[derive(Deserialize, Default)]
pub(crate) struct LimitsBody {
#[serde(default)]
pub(crate) five_hour: Option<crate::statusline::Limit>,
#[serde(default)]
pub(crate) seven_day: Option<crate::statusline::Limit>,
}
#[derive(Deserialize, Default)]
pub(crate) struct CtxBody {
#[serde(default)]
pub(crate) pct: Option<f64>,
#[serde(default)]
pub(crate) size: Option<u64>,
#[serde(default)]
pub(crate) input: Option<u64>,
#[serde(default)]
pub(crate) used: Option<u64>,
}
pub(crate) async fn pane_agent(
State(app): S,
headers: HeaderMap,
Path(id): Path<String>,
Json(b): Json<AgentBody>,
) -> Response {
if !authorized(&app, &headers) {
return StatusCode::UNAUTHORIZED.into_response();
}
let state_ok = |s: &str| s.is_empty() || crate::pane::AGENT_STATES.contains(&s);
if !crate::pane::valid_id(&id)
|| !b.state.as_deref().is_none_or(state_ok)
|| !b.session.as_deref().is_none_or(crate::desk::valid_session)
{
return StatusCode::BAD_REQUEST.into_response();
}
let mut live = match &b.state {
Some(state) => app.panes.set_agent(&id, state),
None => app.panes.is_running(&id),
};
if live && (b.model.is_some() || b.ctx.is_some()) {
let c = b.ctx.unwrap_or_default();
let pct = c
.pct
.filter(|p| p.is_finite())
.map(|p| p.clamp(0.0, 100.0).round() as u8);
let changed = app.panes.set_context(
&id,
&crate::statusline::clean(b.model.as_deref().unwrap_or("")),
pct,
c.size.filter(|s| *s > 0),
c.used.or(c.input),
);
live = changed.is_some();
if changed == Some(true) {
emit(&app, "ctx", json!({ "pane": id }));
}
}
if !live {
return StatusCode::NOT_FOUND.into_response();
}
if let Some(l) = &b.limits {
if l.five_hour.is_some() || l.seven_day.is_some() {
*app.quota.lock().unwrap_or_else(|e| e.into_inner()) = Some(json!({
"five_hour": l.five_hour,
"seven_day": l.seven_day,
"at": crate::store::now(),
}));
}
}
if let Some(session) = &b.session {
if let Ok(true) = app.store.set_pane_session(&id, session) {
desks_moved(&app);
}
}
StatusCode::NO_CONTENT.into_response()
}
pub(crate) async fn pane_notes(
State(app): S,
headers: HeaderMap,
Path(id): Path<String>,
) -> Response {
let placed = match agent_pane(&app, &headers, &id) {
Ok(p) => p,
Err(no) => return *no,
};
match app.store.desk_notes(placed.desk_id) {
Ok(mut notes) => {
settle_stages(&app, &mut notes);
for n in notes
.iter_mut()
.filter(|n| n.stage == "working" && n.stage_pane == id)
{
n.stage_panel = "this panel".into();
}
let dir = app.paths.data_dir.join(crate::desk::NOTE_IMAGES);
for n in &mut notes {
n.images = n
.images
.iter()
.map(|x| dir.join(x).to_string_lossy().to_string())
.collect();
}
Json(json!({ "desk": placed.desk_name, "notes": notes })).into_response()
}
Err(e) => err(e),
}
}
#[derive(Deserialize, Default)]
pub(crate) struct TickBody {
#[serde(default)]
pub(crate) by: String,
#[serde(default)]
pub(crate) commit: String,
#[serde(default)]
pub(crate) about: String,
#[serde(default)]
pub(crate) evidence: String,
}
pub(crate) async fn pane_tick_note(
State(app): S,
headers: HeaderMap,
Path((id, note)): Path<(String, i64)>,
body: Option<Json<TickBody>>,
) -> Response {
let placed = match agent_pane(&app, &headers, &id) {
Ok(p) => p,
Err(no) => return *no,
};
let b = body.map(|Json(b)| b).unwrap_or_default();
let (commit, doc, evidence) = (b.commit.trim(), b.about.trim(), b.evidence.trim());
if !commit.is_empty() && !crate::desk::commit_ok(commit) {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "commit must be a hash as git log prints it: 7 to 40 hex digits" })),
)
.into_response();
}
if !doc.is_empty() && !crate::desk::doc_ok(doc) {
return (
StatusCode::BAD_REQUEST,
Json(
json!({ "error": "about must be a document id from send_document: 10 hex digits" }),
),
)
.into_response();
}
if !evidence.is_empty() && !crate::desk::evidence_ok(evidence) {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "evidence must be an http or https URL, one line, at most 500 characters" })),
)
.into_response();
}
let tick = crate::desk::Tick {
by: b.by,
commit: commit.into(),
doc: doc.into(),
evidence: evidence.into(),
pane: id.clone(),
};
match app.store.tick_desk_note(placed.desk_id, note, &tick) {
Ok(true) => {
emit(&app, "desknotes", json!({ "desk": placed.desk_id }));
Json(json!({ "ok": true, "desk": placed.desk_name })).into_response()
}
Ok(false) => (
StatusCode::CONFLICT,
Json(json!({ "error": "no open note by that id on this desk" })),
)
.into_response(),
Err(e) => err(e),
}
}
#[derive(Deserialize, Default)]
pub(crate) struct MarkBody {
#[serde(default)]
pub(crate) stage: String,
#[serde(default)]
pub(crate) by: String,
#[serde(default)]
pub(crate) about: String,
}
pub(crate) async fn pane_mark_note(
State(app): S,
headers: HeaderMap,
Path((id, note)): Path<(String, i64)>,
body: Option<Json<MarkBody>>,
) -> Response {
let placed = match agent_pane(&app, &headers, &id) {
Ok(p) => p,
Err(no) => return *no,
};
let b = body.map(|Json(b)| b).unwrap_or_default();
let (stage, doc) = (b.stage.trim(), b.about.trim());
let say = |why: &str| (StatusCode::BAD_REQUEST, Json(json!({ "error": why }))).into_response();
if !crate::desk::STAGES.contains(&stage) {
return say("stage must be read, planned or working; done is tick_desk_note");
}
if !doc.is_empty() && !crate::desk::doc_ok(doc) {
return say("about must be a document id from send_document: 10 hex digits");
}
if stage == "planned" && doc.is_empty() {
return say("planned needs about: the id of the plan you sent with send_document");
}
let mark = crate::desk::Mark {
stage: stage.into(),
by: b.by,
doc: doc.into(),
pane: id.clone(),
session: placed.pane.agent_session.clone(),
};
match app.store.mark_desk_note(placed.desk_id, note, &mark) {
Ok(true) => {
emit(&app, "desknotes", json!({ "desk": placed.desk_id }));
Json(json!({ "ok": true, "desk": placed.desk_name })).into_response()
}
Ok(false) => (
StatusCode::CONFLICT,
Json(json!({ "error": "no open note by that id on this desk" })),
)
.into_response(),
Err(e) => err(e),
}
}
pub(crate) async fn pane_name(
State(app): S,
headers: HeaderMap,
Path(id): Path<String>,
Json(b): Json<RenameBody>,
) -> Response {
if let Err(no) = agent_pane(&app, &headers, &id) {
return *no;
}
renamed(&app, &id, &b.name)
}
pub(crate) fn agent_pane(
app: &App,
headers: &HeaderMap,
id: &str,
) -> Result<crate::desk::Placed, Box<Response>> {
if !authorized(app, headers) {
return Err(Box::new(StatusCode::UNAUTHORIZED.into_response()));
}
if !crate::pane::valid_id(id) {
return Err(Box::new(StatusCode::BAD_REQUEST.into_response()));
}
if !app.panes.is_running(id) {
return Err(Box::new(StatusCode::NOT_FOUND.into_response()));
}
match app.store.pane(id) {
Ok(Some(p)) => Ok(p),
Ok(None) => Err(Box::new(StatusCode::NOT_FOUND.into_response())),
Err(e) => Err(Box::new(err(e))),
}
}
pub(crate) async fn pane_brief(
State(app): S,
headers: HeaderMap,
Path(id): Path<String>,
) -> Response {
let placed = match agent_pane(&app, &headers, &id) {
Ok(p) => p,
Err(no) => return *no,
};
if !brief_on(&app) {
return Json(json!({ "context": "", "title": "" })).into_response();
}
let Ok(Some(desk)) = app.store.desk(placed.desk_id) else {
return StatusCode::NOT_FOUND.into_response();
};
let mut notes = app.store.desk_notes(desk.id).unwrap_or_default();
settle_stages(&app, &mut notes);
let notes: Vec<_> = notes
.into_iter()
.map(|mut n| {
if n.stage == "working" && n.stage_pane == id {
n.stage_panel.clear();
}
n
})
.collect();
let docs = app.store.desk_docs(desk.id, 1, false).unwrap_or_default();
let last = docs.first().map(|d| crate::brief::LastDoc {
id: &d.id,
title: &d.title,
at: d.received_at,
});
let now = crate::store::now();
app.panes.told(&id, now);
Json(json!({
"context": crate::brief::brief(&desk, placed.pane.slot, ¬es, &desk.keys, last, now),
"title": crate::brief::title(&desk, placed.pane.slot),
}))
.into_response()
}
pub(crate) async fn pane_changes(
State(app): S,
headers: HeaderMap,
Path(id): Path<String>,
) -> Response {
let placed = match agent_pane(&app, &headers, &id) {
Ok(p) => p,
Err(no) => return *no,
};
let quiet = || Json(json!({ "context": "" })).into_response();
if !brief_on(&app) {
return quiet();
}
let now = crate::store::now();
let since = match app.panes.told(&id, now) {
Some(since) if since > 0 => since,
_ => return quiet(),
};
let Ok(Some(desk)) = app.store.desk(placed.desk_id) else {
return StatusCode::NOT_FOUND.into_response();
};
let mut notes = app.store.desk_notes(desk.id).unwrap_or_default();
settle_stages(&app, &mut notes);
let removed = app
.store
.removed_desk_notes_since(desk.id, since)
.unwrap_or_default();
let docs = app
.store
.desk_docs(desk.id, crate::brief::DOCS_LOOKED_AT, false)
.unwrap_or_default();
let context = crate::brief::changes(&crate::brief::Changes {
slot: placed.pane.slot,
pane: &id,
notes: ¬es,
removed: &removed,
docs: &docs,
keys: &desk.keys,
left_off: desk.left_off.as_ref(),
since,
now,
});
Json(json!({ "context": context })).into_response()
}
#[derive(Deserialize, Default)]
pub(crate) struct AgentLeftOffBody {
#[serde(default)]
pub(crate) text: String,
#[serde(default)]
pub(crate) about: String,
#[serde(default)]
pub(crate) by: String,
}
pub(crate) async fn pane_left_off(
State(app): S,
headers: HeaderMap,
Path(id): Path<String>,
Json(b): Json<AgentLeftOffBody>,
) -> Response {
let placed = match agent_pane(&app, &headers, &id) {
Ok(p) => p,
Err(no) => return *no,
};
if b.text.trim().is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "leave_off needs a sentence" })),
)
.into_response();
}
let by = if b.by.trim().is_empty() {
"an agent".to_string()
} else {
b.by
};
let to = crate::desk::LeftOff {
text: b.text,
at: 0,
by,
about: b.about,
pane: id.clone(),
};
match app.store.set_left_off(placed.desk_id, &to) {
Ok(Some(_)) => {
desks_moved(&app);
Json(json!({ "ok": true, "desk": placed.desk_name })).into_response()
}
Ok(None) => StatusCode::NOT_FOUND.into_response(),
Err(e) => err(e),
}
}
#[derive(Deserialize, Default)]
pub(crate) struct SuggestBody {
#[serde(default)]
pub(crate) text: String,
#[serde(default)]
pub(crate) by: String,
}
pub(crate) async fn pane_suggest_note(
State(app): S,
headers: HeaderMap,
Path(id): Path<String>,
Json(b): Json<SuggestBody>,
) -> Response {
let placed = match agent_pane(&app, &headers, &id) {
Ok(p) => p,
Err(no) => return *no,
};
match app.store.suggest_desk_note(placed.desk_id, &b.text, &b.by) {
Ok(crate::desk::Suggested::Note(note)) => {
notes_moved(&app, placed.desk_id);
(StatusCode::CREATED, Json(json!({ "note": note, "desk": placed.desk_name }))).into_response()
}
Ok(crate::desk::Suggested::Empty) => (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "suggest_desk_note needs a line of text" })),
)
.into_response(),
Ok(crate::desk::Suggested::Full) => (
StatusCode::CONFLICT,
Json(json!({ "error": format!("this desk already has {} suggestions waiting for the user (or its list is full); wait until they keep or remove one", crate::desk::SUGGESTIONS_PER_DESK) })),
)
.into_response(),
Ok(crate::desk::Suggested::NoSuchDesk) => StatusCode::NOT_FOUND.into_response(),
Err(e) => err(e),
}
}