use crate::server::app::AppState;
use crate::server::errors::{error, method_not_allowed};
use crate::server::routes::query::query_value;
use crate::server::sse;
use axum::body::Bytes;
use axum::extract::rejection::BytesRejection;
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::{DefaultBodyLimit, State};
use axum::http::{HeaderMap, StatusCode, Uri};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, patch, post};
use axum::{Json, Router};
use chrono::{SecondsFormat, Utc};
use futures_util::StreamExt;
use nomoreide_core::agent_sessions::{
default_store_path, save_agent_session, AgentSession as RecordedAgentSession,
};
use nomoreide_core::agent_transcripts::{
default_transcript_homes, list_agent_transcripts, AgentTranscript, DEFAULT_TRANSCRIPT_LIMIT,
};
use nomoreide_core::config::Config;
use nomoreide_core::context_library::{ContextAttachment, ContextRef, CONTEXT_KINDS};
use nomoreide_core::one_time_skills::{
compose_one_time_skill_prompt, resolve_one_time_skill, OneTimeSkillSelection,
};
use nomoreide_core::snapshot_manager::{SnapshotManager, DEFAULT_KEEP};
use nomoreide_core::terminal::{
agent_binary, derive_agent_invocation, encode_agent_prompt_paste, normalize_agent_label,
resolve_service_terminal, ServiceTerminal, TerminalSession, TerminalSpawnSpec,
MAX_AGENT_PROMPT_BYTES,
};
use nomoreide_daemon_client::protocol::{
TerminalExitInfo, TerminalSessionEnvelope, TerminalSessionInfo, TerminalSessionsEnvelope,
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::ffi::OsString;
fn settings_store() -> nomoreide_core::app_settings::AppSettingsStore {
nomoreide_core::app_settings::AppSettingsStore::new(
nomoreide_core::app_settings::default_settings_path(),
)
}
const TERMINAL_CONTROL_HEADER: &str = "x-nomoreide-terminal-control";
pub(crate) fn routes() -> Router<AppState> {
Router::new()
.route("/api/terminal/capabilities", get(capabilities))
.route("/api/terminal/events", get(events))
.route("/api/terminal/socket", get(socket))
.route("/api/terminal/transcripts", get(transcripts))
.route(
"/api/terminal/sessions",
get(list_sessions).post(create_session),
)
.route(
"/api/terminal/sessions/:id",
patch(rename).delete(close).fallback(method_not_allowed),
)
.route(
"/api/terminal/sessions/:id/open-system-terminal",
post(open_system_terminal).fallback(method_not_allowed),
)
.route(
"/api/terminal/sessions/:id/reclaim-dock",
post(reclaim_dock).fallback(method_not_allowed),
)
.route(
"/api/terminal/sessions/:id/insert-prompt",
post(insert_prompt)
.fallback(method_not_allowed)
.layer(DefaultBodyLimit::max(MAX_INSERT_PROMPT_BODY_BYTES + 4_096)),
)
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Capabilities {
external_terminal: bool,
}
async fn capabilities() -> Response {
Json(Capabilities {
external_terminal: cfg!(target_os = "macos"),
})
.into_response()
}
async fn events(State(state): State<AppState>) -> Response {
let replay: Vec<TerminalSessionInfo> = state
.terminal
.list_sessions()
.into_iter()
.map(wire)
.collect();
sse::stream(
sse::CONNECTED_AND_KEEPALIVE,
replay
.into_iter()
.map(|session| sse::named("session", session))
.collect(),
state.event_stream.clone(),
|event| {
if event.name != TERMINAL_SESSION_CHANGED {
return None;
}
serde_json::from_value::<TerminalSession>(event.payload)
.ok()
.map(|session| sse::named("session", wire(session)))
},
)
}
const TERMINAL_SESSION_CHANGED: &str = "terminal-session-changed";
#[derive(Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
enum SocketCommand {
Input { data: String },
Resize { cols: u16, rows: u16 },
Repair { cols: u16, rows: u16 },
Restart { cols: u16, rows: u16 },
Stop,
}
#[derive(Serialize)]
#[serde(tag = "type", rename_all = "lowercase")]
enum SocketMessage {
State {
state: String,
cwd: String,
shell: String,
error: Option<String>,
cols: u16,
rows: u16,
},
Output {
data: String,
},
Error {
error: String,
},
}
async fn socket(State(state): State<AppState>, uri: Uri, upgrade: WebSocketUpgrade) -> Response {
let Some(id) = query_value(&uri, "id").filter(|id| is_existing_id(id)) else {
return error(StatusCode::BAD_REQUEST, "Invalid terminal session id.");
};
if !state
.terminal
.list_sessions()
.iter()
.any(|session| session.id == id)
{
return error(
StatusCode::NOT_FOUND,
&format!("Unknown terminal session: {id}"),
);
}
upgrade
.protocols(["nomoreide"])
.on_upgrade(move |socket| serve_socket(socket, state, id))
}
async fn serve_socket(mut socket: WebSocket, state: AppState, id: String) {
let mut events = state.event_stream.subscribe();
let Some(session) = state
.terminal
.list_sessions()
.into_iter()
.find(|session| session.id == id)
else {
let _ = send_socket_message(
&mut socket,
&SocketMessage::Error {
error: format!("Unknown terminal session: {id}"),
},
)
.await;
return;
};
if send_socket_message(&mut socket, &socket_state(&session))
.await
.is_err()
{
return;
}
if let Some(pending) = state.terminal.attach_output(&id) {
if !pending.is_empty()
&& send_socket_message(
&mut socket,
&SocketMessage::Output {
data: String::from_utf8_lossy(&pending).into_owned(),
},
)
.await
.is_err()
{
return;
}
}
loop {
tokio::select! {
incoming = socket.next() => {
let Some(Ok(message)) = incoming else { return; };
let Message::Text(text) = message else {
if matches!(message, Message::Close(_)) { return; }
continue;
};
let command = match serde_json::from_str::<SocketCommand>(&text) {
Ok(command) => command,
Err(_) => {
if send_socket_message(&mut socket, &SocketMessage::Error {
error: "Invalid terminal socket message.".to_string(),
}).await.is_err() { return; }
continue;
}
};
match run_socket_command(&state, &id, command).await {
Ok(Some(message)) => {
if send_socket_message(&mut socket, &message).await.is_err() { return; }
}
Ok(None) => {}
Err(message) => {
if send_socket_message(&mut socket, &SocketMessage::Error { error: message })
.await
.is_err()
{
return;
}
}
}
}
event = events.recv() => {
let event = match event {
Ok(event) => event,
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
};
let message = if event.name == format!("terminal-output-{id}") {
event.payload.as_str().map(|data| SocketMessage::Output { data: data.to_string() })
} else if event.name == TERMINAL_SESSION_CHANGED {
serde_json::from_value::<TerminalSession>(event.payload)
.ok()
.filter(|session| session.id == id)
.map(|session| socket_state(&session))
} else {
None
};
if let Some(message) = message {
if send_socket_message(&mut socket, &message).await.is_err() { return; }
}
}
}
}
}
async fn run_socket_command(
state: &AppState,
id: &str,
command: SocketCommand,
) -> Result<Option<SocketMessage>, String> {
match command {
SocketCommand::Input { data } => {
state.terminal.write_input(id, data.as_bytes())?;
Ok(None)
}
SocketCommand::Resize { cols, rows } => {
state.terminal.resize(id, cols, rows)?;
Ok(None)
}
SocketCommand::Repair { cols, rows } | SocketCommand::Restart { cols, rows } => {
let manager = state.terminal.clone();
let sink = state.events.clone();
let id = id.to_string();
let session =
tokio::task::spawn_blocking(move || manager.restart_session(sink, &id, cols, rows))
.await
.map_err(|error| error.to_string())??;
Ok(Some(socket_state(&session)))
}
SocketCommand::Stop => {
let mut session = state
.terminal
.list_sessions()
.into_iter()
.find(|session| session.id == id)
.ok_or_else(|| format!("Unknown terminal session: {id}"))?;
let manager = state.terminal.clone();
let id = id.to_string();
tokio::task::spawn_blocking(move || manager.close_session(&id))
.await
.map_err(|error| error.to_string())??;
session.state = "exited".to_string();
session.exit = None;
Ok(Some(socket_state(&session)))
}
}
}
fn socket_state(session: &TerminalSession) -> SocketMessage {
SocketMessage::State {
state: session.state.clone(),
cwd: session.cwd.clone(),
shell: session.shell.clone(),
error: session.error.clone(),
cols: session.cols,
rows: session.rows,
}
}
async fn send_socket_message(
socket: &mut WebSocket,
message: &SocketMessage,
) -> Result<(), axum::Error> {
socket
.send(Message::Text(
serde_json::to_string(message).expect("terminal socket messages serialize"),
))
.await
}
async fn list_sessions(State(state): State<AppState>) -> Response {
Json(TerminalSessionsEnvelope {
ok: true,
sessions: state
.terminal
.list_sessions()
.into_iter()
.map(wire)
.collect(),
})
.into_response()
}
async fn open_system_terminal(
State(state): State<AppState>,
headers: HeaderMap,
uri: Uri,
) -> Response {
let id = match action_id(&headers, &uri) {
Ok(id) => id,
Err((status, message)) => return error(status, message),
};
let preference = settings_store()
.load()
.await
.map(|settings| settings.terminal.external_terminal)
.unwrap_or_else(|_| "automatic".to_string());
let app = nomoreide_core::external_terminal::resolve_external_terminal(&preference);
let manager = state.terminal.clone();
let sink = state.events.clone();
let opened =
tokio::task::spawn_blocking(move || manager.open_in_terminal(sink, &id, app)).await;
match opened {
Ok(Ok(session)) => session_response(session),
Ok(Err(message)) => session_failure(message),
Err(join) => error(StatusCode::INTERNAL_SERVER_ERROR, &join.to_string()),
}
}
async fn reclaim_dock(State(state): State<AppState>, headers: HeaderMap, uri: Uri) -> Response {
let id = match action_id(&headers, &uri) {
Ok(id) => id,
Err((status, message)) => return error(status, message),
};
match state.terminal.reclaim_to_dock(state.events.as_ref(), &id) {
Ok(session) => session_response(session),
Err(message) => session_failure(message),
}
}
type Refusal = (StatusCode, &'static str);
fn action_id(headers: &HeaderMap, uri: &Uri) -> Result<String, Refusal> {
if headers
.get(TERMINAL_CONTROL_HEADER)
.and_then(|value| value.to_str().ok())
!= Some("1")
{
return Err((
StatusCode::FORBIDDEN,
"Terminal control header is required.",
));
}
session_id(uri)
.filter(|id| is_action_id(id))
.ok_or((StatusCode::BAD_REQUEST, "Invalid terminal session id."))
}
fn existing_id(uri: &Uri) -> Result<String, Refusal> {
session_id(uri)
.filter(|id| is_existing_id(id))
.ok_or((StatusCode::BAD_REQUEST, "Invalid terminal session id."))
}
fn session_id(uri: &Uri) -> Option<String> {
let raw = uri.path().split('/').nth(4)?;
let bytes = raw.as_bytes();
let mut decoded = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'%' {
let hex = raw.get(index + 1..index + 3)?;
if !hex.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return None;
}
decoded.push(u8::from_str_radix(hex, 16).ok()?);
index += 3;
} else {
decoded.push(bytes[index]);
index += 1;
}
}
String::from_utf8(decoded).ok()
}
fn is_action_id(id: &str) -> bool {
!id.is_empty()
&& utf16_len(id) <= 200
&& !id.contains('/')
&& !id.contains('\\')
&& !has_control_characters(id)
}
fn is_existing_id(id: &str) -> bool {
!id.is_empty() && utf16_len(id) <= 1_000 && !has_control_characters(id)
}
fn has_control_characters(value: &str) -> bool {
value.chars().any(|character| {
let code = character as u32;
code <= 31 || code == 127
})
}
fn utf16_len(value: &str) -> usize {
value.chars().map(char::len_utf16).sum()
}
const MAX_INSERT_PROMPT_BODY_BYTES: usize = MAX_AGENT_PROMPT_BYTES * 6 + 1_024;
async fn insert_prompt(
State(state): State<AppState>,
headers: HeaderMap,
uri: Uri,
body: Result<Bytes, BytesRejection>,
) -> Response {
let id = match action_id(&headers, &uri) {
Ok(id) => id,
Err((status, message)) => return error(status, message),
};
let Ok(body) = body else {
return error(StatusCode::PAYLOAD_TOO_LARGE, "Agent prompt is too large.");
};
if body.len() > MAX_INSERT_PROMPT_BODY_BYTES {
return error(StatusCode::PAYLOAD_TOO_LARGE, "Agent prompt is too large.");
}
let Some(prompt) = insert_prompt_body(&parsed_body(&body)) else {
return error(
StatusCode::BAD_REQUEST,
"A non-empty agent prompt is required.",
);
};
if prompt.len() > MAX_AGENT_PROMPT_BYTES {
return error(StatusCode::PAYLOAD_TOO_LARGE, "Agent prompt is too large.");
}
if let Err(reason) = encode_agent_prompt_paste(&prompt) {
return error(StatusCode::BAD_REQUEST, &reason);
}
let manager = state.terminal.clone();
match tokio::task::spawn_blocking(move || manager.insert_agent_prompt(&id, &prompt)).await {
Ok(Ok(session)) => session_response(session),
Ok(Err(message)) => session_failure(message),
Err(join) => error(StatusCode::INTERNAL_SERVER_ERROR, &join.to_string()),
}
}
async fn rename(State(state): State<AppState>, uri: Uri, body: Bytes) -> Response {
let id = match existing_id(&uri) {
Ok(id) => id,
Err((status, message)) => return error(status, message),
};
let Some(label) = rename_label(&parsed_body(&body)) else {
return error(
StatusCode::BAD_REQUEST,
"Terminal session label must be 1\u{2013}60 characters.",
);
};
match state.terminal.rename_session(&id, label) {
Ok(session) => session_response(session),
Err(message) => session_failure(message),
}
}
async fn close(State(state): State<AppState>, uri: Uri) -> Response {
let id = match existing_id(&uri) {
Ok(id) => id,
Err((status, message)) => return error(status, message),
};
let manager = state.terminal.clone();
let known = manager
.list_sessions()
.iter()
.any(|session| session.id == id);
let closed = known && {
let closing = manager.clone();
matches!(
tokio::task::spawn_blocking(move || closing.close_session(&id)).await,
Ok(Ok(()))
)
};
Json(TerminalSessionsEnvelope {
ok: closed,
sessions: manager.list_sessions().into_iter().map(wire).collect(),
})
.into_response()
}
fn parsed_body(body: &[u8]) -> Value {
serde_json::from_slice::<Value>(body).unwrap_or(Value::Null)
}
fn insert_prompt_body(payload: &Value) -> Option<String> {
let object = payload.as_object()?;
if object.keys().any(|key| key != "prompt") {
return None;
}
let prompt = object.get("prompt")?.as_str()?;
(!prompt.is_empty()).then(|| prompt.to_string())
}
fn rename_label(payload: &Value) -> Option<String> {
let object = payload.as_object()?;
if object.keys().any(|key| key != "label") {
return None;
}
let label = object.get("label")?.as_str()?.trim();
(!label.is_empty() && utf16_len(label) <= 60).then(|| label.to_string())
}
#[derive(Serialize)]
struct TranscriptsEnvelope {
ok: bool,
transcripts: Vec<AgentTranscript>,
}
async fn transcripts(State(state): State<AppState>, uri: Uri) -> Response {
let repo_path = if query_value(&uri, "scope").as_deref() == Some("all") {
None
} else {
Some(transcripts_repo_path(&state).await)
};
let (home, codex_home) = default_transcript_homes();
let listed = tokio::task::spawn_blocking(move || {
list_agent_transcripts(
&home,
&codex_home,
repo_path.as_deref(),
DEFAULT_TRANSCRIPT_LIMIT,
)
})
.await;
match listed {
Ok(transcripts) => Json(TranscriptsEnvelope {
ok: true,
transcripts,
})
.into_response(),
Err(join) => error(StatusCode::INTERNAL_SERVER_ERROR, &join.to_string()),
}
}
async fn transcripts_repo_path(state: &AppState) -> String {
let fallback = std::env::current_dir()
.map(|path| path.to_string_lossy().into_owned())
.unwrap_or_default();
let Ok(config) = state.config_store.load().await else {
return fallback;
};
match nomoreide_core::config::selected_git_repository(&config) {
Some(repository) => repository
.active_worktree_path
.clone()
.unwrap_or_else(|| repository.path.clone()),
None => fallback,
}
}
fn session_failure(message: String) -> Response {
let status = if message.starts_with("Unknown terminal session:") {
StatusCode::NOT_FOUND
} else {
StatusCode::CONFLICT
};
error(status, &message)
}
fn session_response(session: TerminalSession) -> Response {
Json(TerminalSessionEnvelope {
ok: true,
session: wire(session),
})
.into_response()
}
async fn create_session(State(state): State<AppState>, body: Bytes) -> Response {
let payload = parsed_body(&body);
let workspace = state.workspace_cwd().await;
if let Some(agent) = payload.as_object().and_then(|object| object.get("agent")) {
return create_agent_session(&state, agent, workspace).await;
}
let Some(service_name) = payload
.get("serviceName")
.and_then(Value::as_str)
.map(|name| name.trim().to_string())
.filter(|name| !name.is_empty())
else {
let id = state.next_session_id();
return spawn(&state, TerminalSpawnSpec::shell(id, workspace));
};
let config = match state.config_store.load().await {
Ok(config) => config,
Err(failure) => {
return error(StatusCode::INTERNAL_SERVER_ERROR, &failure.to_string());
}
};
let Some(service) = config
.services
.iter()
.find(|service| service.name == service_name)
else {
return error(
StatusCode::NOT_FOUND,
&format!("Unknown service: {service_name}"),
);
};
match resolve_service_terminal(service, format!("svc:{service_name}"), &workspace) {
ServiceTerminal::Unreachable(reason) => error(StatusCode::BAD_REQUEST, &reason),
ServiceTerminal::Spawn(spec) => spawn(&state, *spec),
}
}
#[derive(Debug)]
enum AgentField {
Provider,
ResumeId,
Other,
}
struct AgentSession {
provider: String,
prompt: String,
label: Option<String>,
one_time_skill: Option<OneTimeSkillSelection>,
resume_id: Option<String>,
model: Option<String>,
context: Option<ContextAttachment>,
repository: Option<String>,
}
async fn create_agent_session(state: &AppState, agent: &Value, workspace: String) -> Response {
let request = match agent_session(agent) {
Ok(request) => request,
Err(field) => {
return error(
StatusCode::BAD_REQUEST,
match field {
AgentField::Provider => "Agent provider must be codex or claude.",
AgentField::ResumeId => "Agent resume id is invalid.",
AgentField::Other => "Invalid agent session request.",
},
)
}
};
if request.resume_id.is_some() && request.one_time_skill.is_some() {
return error(
StatusCode::BAD_REQUEST,
"A temporary skill cannot be attached to a resumed session.",
);
}
let workspace = match agent_workspace(state, request.repository.as_deref(), workspace).await {
Ok(workspace) => workspace,
Err(response) => return response,
};
let task_label = agent_task_label(&request.provider, request.label.as_deref(), &request.prompt);
let snapshot_label = if request.prompt.lines().any(|line| !line.trim().is_empty()) {
agent_task_label(&request.provider, None, &request.prompt)
} else {
task_label.clone()
};
let mut prompt = request.prompt;
let _ = &request.context;
if let Some(skill) = &request.one_time_skill {
prompt = match resolve_one_time_skill(skill).await {
Ok(skill_prompt) => match compose_one_time_skill_prompt(&skill_prompt, &prompt) {
Ok(composed) => composed,
Err(message) => return error(StatusCode::UNPROCESSABLE_ENTITY, &message),
},
Err(message) => return error(StatusCode::UNPROCESSABLE_ENTITY, &message),
};
}
let pinned = match state.config_store.load().await {
Ok(config) => config.chat_models.as_ref().and_then(|models| {
if request.provider == "codex" {
models.codex.clone()
} else {
models.claude.clone()
}
}),
Err(_) => None,
};
let model = request.model.or(pinned);
let invocation = match derive_agent_invocation(
&request.provider,
&prompt,
request.resume_id.as_deref(),
model.as_deref(),
&agent_binary("NOMOREIDE_CLAUDE_BIN", "claude"),
&agent_binary("NOMOREIDE_CODEX_BIN", "codex"),
) {
Ok(invocation) => invocation,
Err(message) => return error(StatusCode::BAD_REQUEST, &message),
};
let session_id = state.next_session_id();
let manager = SnapshotManager::new(workspace.clone());
let checkpoint = manager.snapshot(&snapshot_label).await.ok();
if checkpoint.is_some() {
let _ = manager.prune(DEFAULT_KEEP).await;
}
let created = state.terminal.create(
state.events.clone(),
TerminalSpawnSpec {
id: session_id.clone(),
service_name: None,
cwd: workspace.clone(),
shell: OsString::from(invocation.executable),
args: invocation.args,
env: Vec::new(),
label: Some(task_label.clone()),
kind: Some("agent".to_string()),
provider: Some(request.provider.clone()),
},
);
match created {
Ok(session) => {
if let Some(snapshot) = checkpoint {
let started_at = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
let _ = save_agent_session(
&default_store_path(),
RecordedAgentSession {
id: session_id,
label: Some(snapshot_label),
provider: Some(request.provider),
repo_path: workspace,
snapshot_sha: Some(snapshot.sha),
snapshot_ref: Some(snapshot.reference),
started_at: started_at.clone(),
last_tool_at: started_at,
tool_count: 0,
},
);
}
(
StatusCode::CREATED,
Json(TerminalSessionEnvelope {
ok: true,
session: wire(session),
}),
)
.into_response()
}
Err(message) => {
if let Some(snapshot) = checkpoint {
let _ = manager.delete(&snapshot.sha).await;
}
error(StatusCode::INTERNAL_SERVER_ERROR, &message)
}
}
}
async fn agent_workspace(
state: &AppState,
repository: Option<&str>,
selected: String,
) -> Result<String, Response> {
let Some(name) = repository.map(str::trim).filter(|name| !name.is_empty()) else {
return Ok(selected);
};
let config = match state.config_store.load().await {
Ok(config) => config,
Err(failure) => {
return Err(error(
StatusCode::INTERNAL_SERVER_ERROR,
&failure.to_string(),
))
}
};
match repository_workspace(&config, name) {
Some(workspace) => Ok(workspace),
None => Err(error(
StatusCode::NOT_FOUND,
&format!("Unknown repository: {name}"),
)),
}
}
fn repository_workspace(config: &Config, name: &str) -> Option<String> {
let found = config
.git_repositories
.iter()
.find(|repository| repository.name == name)?;
Some(
found
.active_worktree_path
.clone()
.unwrap_or_else(|| found.path.clone()),
)
}
fn agent_task_label(provider: &str, explicit: Option<&str>, prompt: &str) -> String {
let prompt_line = prompt.lines().map(str::trim).find(|line| !line.is_empty());
normalize_agent_label(provider, explicit.or(prompt_line))
}
fn agent_session(value: &Value) -> Result<AgentSession, AgentField> {
let object = value.as_object().ok_or(AgentField::Other)?;
let provider = object
.get("provider")
.and_then(Value::as_str)
.filter(|provider| matches!(*provider, "codex" | "claude"))
.ok_or(AgentField::Provider)?
.to_string();
let prompt = match object.get("prompt") {
None => String::new(),
Some(value) => value.as_str().ok_or(AgentField::Other)?.to_string(),
};
let label = match object.get("label") {
None => None,
Some(value) => Some(value.as_str().ok_or(AgentField::Other)?.to_string()),
};
let one_time_skill = match object.get("oneTimeSkill") {
None => None,
Some(value) => Some(one_time_skill(value).map_err(|()| AgentField::Other)?),
};
let resume_id = match object.get("resumeId") {
None => None,
Some(value) => Some(
value
.as_str()
.filter(|id| is_resume_id(id))
.ok_or(AgentField::ResumeId)?
.to_string(),
),
};
let model = match object.get("model") {
None => None,
Some(value) => Some(bounded(value, 1, 64).map_err(|()| AgentField::Other)?),
};
let context = match object.get("context") {
None => None,
Some(value) => Some(attachment(value).map_err(|()| AgentField::Other)?),
};
let repository = match object.get("repository") {
None => None,
Some(value) => Some(value.as_str().ok_or(AgentField::Other)?.to_string()),
};
Ok(AgentSession {
provider,
prompt,
label,
one_time_skill,
resume_id,
model,
context,
repository,
})
}
fn one_time_skill(value: &Value) -> Result<OneTimeSkillSelection, ()> {
let object = value.as_object().ok_or(())?;
if object.keys().any(|key| key != "name" && key != "source") {
return Err(());
}
Ok(OneTimeSkillSelection {
name: bounded(object.get("name").ok_or(())?, 1, 200)?,
source: bounded(object.get("source").ok_or(())?, 3, 400)?,
})
}
fn attachment(value: &Value) -> Result<ContextAttachment, ()> {
let object = value.as_object().ok_or(())?;
if object
.keys()
.any(|key| key != "refs" && key != "includePinned")
{
return Err(());
}
let refs = object.get("refs").ok_or(())?.as_array().ok_or(())?;
if refs.len() > 200 {
return Err(());
}
Ok(ContextAttachment {
refs: refs
.iter()
.map(context_ref)
.collect::<Result<Vec<_>, ()>>()?,
include_pinned: object.get("includePinned").ok_or(())?.as_bool().ok_or(())?,
})
}
fn context_ref(value: &Value) -> Result<ContextRef, ()> {
let object = value.as_object().ok_or(())?;
if object.keys().any(|key| key != "kind" && key != "id") {
return Err(());
}
let kind = object.get("kind").ok_or(())?.as_str().ok_or(())?;
if !CONTEXT_KINDS.contains(&kind) {
return Err(());
}
Ok(ContextRef {
kind: kind.to_string(),
id: bounded(object.get("id").ok_or(())?, 1, 1_000)?,
})
}
fn bounded(value: &Value, min: usize, max: usize) -> Result<String, ()> {
let text = value.as_str().ok_or(())?.trim();
let length = utf16_len(text);
if length < min || length > max {
return Err(());
}
Ok(text.to_string())
}
fn is_resume_id(id: &str) -> bool {
(8..=64).contains(&id.len())
&& id
.chars()
.all(|character| character.is_ascii_hexdigit() || character == '-')
}
pub(super) fn spawn(state: &AppState, spec: TerminalSpawnSpec) -> Response {
match state.terminal.create(state.events.clone(), spec) {
Ok(session) => (
StatusCode::CREATED,
Json(TerminalSessionEnvelope {
ok: true,
session: wire(session),
}),
)
.into_response(),
Err(message) => error(StatusCode::INTERNAL_SERVER_ERROR, &message),
}
}
fn wire(session: TerminalSession) -> TerminalSessionInfo {
TerminalSessionInfo {
id: session.id,
cols: session.cols,
cwd: session.cwd,
error: session.error,
exit: session.exit.map(|exit| TerminalExitInfo {
exit_code: exit.exit_code,
signal: exit.signal,
}),
kind: session.kind,
label: session.label,
provider: session.provider,
rows: session.rows,
shell: session.shell,
state: session.state,
presentation: match session.presentation {
nomoreide_core::terminal::TerminalPresentation::Dock => "dock",
nomoreide_core::terminal::TerminalPresentation::TerminalLaunching => {
"terminalLaunching"
}
nomoreide_core::terminal::TerminalPresentation::Terminal => "terminal",
}
.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::{agent_session, agent_task_label, repository_workspace, AgentField};
use nomoreide_core::config::Config;
use serde_json::json;
fn config_with_repositories(repositories: serde_json::Value) -> Config {
serde_json::from_value(json!({
"version": 1,
"services": [],
"bundles": [],
"gitRepositories": repositories,
}))
.expect("config")
}
#[test]
fn a_registered_name_resolves_to_its_tree() {
let config = config_with_repositories(json!([
{ "name": "nomoreide", "path": "/repos/nomoreide" },
{ "name": "platform", "path": "/repos/platform" },
]));
assert_eq!(
repository_workspace(&config, "platform").as_deref(),
Some("/repos/platform")
);
}
#[test]
fn the_active_worktree_wins_over_the_repository_root() {
let config = config_with_repositories(json!([{
"name": "nomoreide",
"path": "/repos/nomoreide",
"activeWorktreePath": "/repos/nomoreide-wt/feature",
}]));
assert_eq!(
repository_workspace(&config, "nomoreide").as_deref(),
Some("/repos/nomoreide-wt/feature")
);
}
#[test]
fn an_unregistered_name_resolves_to_nothing() {
let config = config_with_repositories(json!([
{ "name": "nomoreide", "path": "/repos/nomoreide" },
]));
assert_eq!(repository_workspace(&config, "not-registered"), None);
}
#[test]
fn a_path_is_not_a_name() {
let config = config_with_repositories(json!([
{ "name": "nomoreide", "path": "/repos/nomoreide" },
]));
assert_eq!(repository_workspace(&config, "/repos/nomoreide"), None);
assert_eq!(repository_workspace(&config, "../../etc"), None);
}
#[test]
fn an_agent_request_without_a_repository_still_parses() {
let request = agent_session(&json!({ "provider": "claude", "prompt": "hello" }))
.expect("agent request");
assert_eq!(request.repository, None);
}
#[test]
fn an_agent_request_carries_the_repository_it_names() {
let request = agent_session(&json!({
"provider": "codex",
"prompt": "hello",
"repository": "platform",
}))
.expect("agent request");
assert_eq!(request.repository.as_deref(), Some("platform"));
}
#[test]
fn a_bad_repository_does_not_take_over_another_fields_refusal() {
assert!(matches!(
agent_session(&json!({ "provider": "nope", "repository": 7 })),
Err(AgentField::Provider)
));
assert!(matches!(
agent_session(&json!({ "provider": "claude", "repository": 7 })),
Err(AgentField::Other)
));
}
#[test]
fn agent_task_names_follow_the_first_prompt_line() {
assert_eq!(
agent_task_label("codex", None, "\n Fix service env scrolling\nMore context"),
"Fix service env scrolling"
);
assert_eq!(
agent_task_label("claude", Some(" Dependency graph "), "ignored"),
"Dependency graph"
);
assert_eq!(agent_task_label("codex", None, "\n\t"), "Codex task");
}
}