Use native Codex steering and transcript deletion

This commit is contained in:
iris committed 2026-09-09 12:19:11 -04:00
1 parent 00538cc19b
commit 8c88a7e991
12 files changed
+1004 -390

No files matched your search

+1 -1
View File
@@ -200,7 +200,7 @@ never be able to close the app, whatever produced it.
**Deleting a session offers to take the machine's own transcript with it** **Deleting a session offers to take the machine's own transcript with it**
`DELETE /sessions/{id}?deleteForeign=true`, behind a switch in the `DELETE /sessions/{id}?deleteForeign=true`, behind a switch in the
confirmation, and only where the driver keeps a record of its own confirmation, and only where the driver keeps a record of its own
(`keepsOwnTranscript`, which today means Claude Code). Off by default, (`keepsOwnTranscript`, currently Claude Code or Codex). Off by default,
because leaving that copy is what makes an ordinary delete recoverable — and because leaving that copy is what makes an ordinary delete recoverable — and
the dialog's paragraph is rewritten when it is on rather than appended to, the dialog's paragraph is rewritten when it is on rather than appended to,
since the sentence promising the conversation "should still be there to since the sentence promising the conversation "should still be there to
+4 -3
View File
@@ -47,9 +47,10 @@ Module-by-module intent is in PLAN.md's "Backend layout".
readiness poll watches the process as well as the port, since a model that readiness poll watches the process as well as the port, since a model that
will not load exits in a second and was being reported as "gave up after will not load exits in a second and was being reported as "gave up after
300s". See PLAN.md's "Transport" and "llama-server management". 300s". See PLAN.md's "Transport" and "llama-server management".
Codex is `codex exec --json`, one child process per turn; its driver keeps Codex is one persistent `codex app-server --stdio` process per session; its
the thread id for `exec resume`, persists messages queued behind a turn, and driver uses native turn steering and interruption, persists the protocol
reads subscription limits through the CLI's app-server protocol. state and thread id, and reads subscription limits through the same CLI
protocol.
- `app/` — the Compose app, package `com.example.aiapp`, label "AI Sessions". - `app/` — the Compose app, package `com.example.aiapp`, label "AI Sessions".
`AppRoot.kt` is the navigation `when`; `MainScreen.kt` the root's four tabs `AppRoot.kt` is the navigation `when`; `MainScreen.kt` the root's four tabs
(sessions, import, models, setups); `Api.kt`/`EventStream.kt` the REST + SSE (sessions, import, models, setups); `Api.kt`/`EventStream.kt` the REST + SSE
+24 -18
View File
@@ -29,7 +29,7 @@ Android app (Compose)
backend (Rust/Axum, desktop) backend (Rust/Axum, desktop)
├─ SessionManager ── Session ── Driver (trait) ├─ SessionManager ── Session ── Driver (trait)
│ ├─ ClaudeDriver (claude stream-json over stdio) │ ├─ ClaudeDriver (claude stream-json over stdio)
│ ├─ CodexDriver (codex exec --json, one process per turn) │ ├─ CodexDriver (persistent codex app-server JSONL)
│ ├─ LlamaDriver (llama-server over HTTP) │ ├─ LlamaDriver (llama-server over HTTP)
│ └─ EchoDriver (the test rig) │ └─ EchoDriver (the test rig)
│ each driver's process is spawned through a Transport, │ each driver's process is spawned through a Transport,
@@ -193,25 +193,24 @@ is the rule behind the import refusal, the single `ClaudeDriver::launch`
entry point, and the `Exited` correction below; two CLIs on one session file entry point, and the `Exited` correction below; two CLIs on one session file
duplicate the conversation into it and bill the second for re-reading it all. duplicate the conversation into it and bill the second for re-reading it all.
### Codex driver specifics (2026-09-07) ### Codex driver specifics (2026-09-09)
Codex uses `codex exec --json`, whose stdout is JSONL: `thread.started`, turn Codex uses one persistent `codex app-server --stdio` per session. The original
boundaries, item start/completion records, and the final token usage. The 2026-09-07 implementation used one `codex exec --json` process per turn, but
driver translates those records into the same events as every other session that surface cannot steer: a message typed during work was held until the turn
and persists the reported thread id. Later turns run `codex exec resume <id> ended, and Pause killed the whole process before starting another resume. The
--json`; model, effort and attachments remain launch arguments owned by the app-server protocol provides the operations the interface actually promises:
driver rather than branches in routes or screens. `turn/steer` injects a message into the active turn and `turn/interrupt` stops
that turn while leaving the conversation process alive.
One `exec` process is one turn and exits normally at its end. The session The process's stdin is a fifo and its output is a detached log, with protocol
therefore owns a sequence of child processes rather than one permanently idle state persisted beside the thread id. It therefore survives and is adopted
child: a clean exit after `turn.completed` means `idle`, while an exit before a across a backend restart like the Claude CLI. A steer is sent immediately and
turn boundary is an error. A process in flight still uses `process.json` plus is announced where Codex emits its user-message item; if Codex says the active
detached stdout/stderr logs, so it survives and is adopted across a backend turn is not steerable, the message remains queued and starts the next turn
restart exactly like the long-lived CLI. Messages received during a turn are instead of being lost. An interrupt requested while a turn is still starting
persisted and start later turns in order. The JSON exec surface has no stdin is applied once Codex supplies that turn's id, so it cannot leak forward and
steering or interactive approval protocol, so it cannot inject a message at a hide a later failure.
tool boundary or answer a question inside the same process; those limits are
reported rather than guessed around.
Codex subscription limits come from the CLI's `account/rateLimits/read` Codex subscription limits come from the CLI's `account/rateLimits/read`
app-server request on the machine whose setup runs Codex. This keeps login and app-server request on the machine whose setup runs Codex. This keeps login and
@@ -810,6 +809,13 @@ directory under `$XDG_DATA_HOME/ai-app/sessions/` (transcript, attachments,
produced images, process record), owner-only. Deleting a session is the produced images, process record), owner-only. Deleting a session is the
complete path out of everything spawning one created. complete path out of everything spawning one created.
Claude Code and Codex also keep their own durable transcript. The delete
dialog names that owner and can remove its copy too: Claude files are resolved
under `~/.claude/projects`, while a Codex thread id resolves only the matching
rollout under `~/.codex/sessions`. The provider-owned copy is deleted first, so
a remote-machine failure leaves the app session intact rather than reporting a
half-delete as success.
**Every request body refuses fields it does not know** **Every request body refuses fields it does not know**
(`serde(deny_unknown_fields)`). A caller that misspells `permissionMode` got (`serde(deny_unknown_fields)`). A caller that misspells `permissionMode` got
a 200 and a session in the default mode, which is indistinguishable from a 200 and a session in the default mode, which is indistinguishable from
@@ -140,6 +140,8 @@ data class SessionSummary(
* still there, and re-importing is not a restore. * still there, and re-importing is not a restore.
*/ */
val keepsOwnTranscript: Boolean, val keepsOwnTranscript: Boolean,
/** Product whose durable transcript survives an ordinary app deletion. */
val ownTranscriptName: String?,
/** How much the session asks before acting; null when it was never set. */ /** How much the session asks before acting; null when it was never set. */
val permissionMode: String?, val permissionMode: String?,
/** /**
@@ -238,6 +240,7 @@ private fun parseSession(session: JSONObject) =
id = session.getString("id"), id = session.getString("id"),
setup = session.getString("setup"), setup = session.getString("setup"),
keepsOwnTranscript = session.optBoolean("keepsOwnTranscript", false), keepsOwnTranscript = session.optBoolean("keepsOwnTranscript", false),
ownTranscriptName = session.optString("ownTranscriptName").ifEmpty { null },
setupName = session.getString("setupName"), setupName = session.getString("setupName"),
provider = session.getString("provider"), provider = session.getString("provider"),
title = session.getString("title"), title = session.getString("title"),
@@ -1230,10 +1233,10 @@ fun compactSession(settings: ServerSettings, sessionId: String) {
/** /**
* Removes a session, and optionally the machine's own transcript of the same conversation. * Removes a session, and optionally the machine's own transcript of the same conversation.
* *
* [deleteForeign] is the delete this app cannot otherwise reach: Claude Code keeps its own record * [deleteForeign] is the delete this app cannot otherwise reach: coding CLIs keep their own durable
* under `~/.claude/projects`, and leaving it is what makes an ordinary delete recoverable. The * record, and leaving it is what makes an ordinary delete recoverable. The server does both halves,
* server does both halves, and does the unrecoverable one first, so a machine it cannot reach * and does the unrecoverable one first, so a machine it cannot reach leaves the session exactly
* leaves the session exactly where it was rather than half-deleted. * where it was rather than half-deleted.
*/ */
fun deleteSession(settings: ServerSettings, sessionId: String, deleteForeign: Boolean = false) { fun deleteSession(settings: ServerSettings, sessionId: String, deleteForeign: Boolean = false) {
val query = if (deleteForeign) "?deleteForeign=true" else "" val query = if (deleteForeign) "?deleteForeign=true" else ""
@@ -318,17 +318,19 @@ fun SessionListScreen(
// Reset per session, so a toggle turned on for one conversation is not still on for the // Reset per session, so a toggle turned on for one conversation is not still on for the
// next. Off to begin with: see [deleteSession]. // next. Off to begin with: see [deleteSession].
var alsoDeleteForeign by remember(session.id) { mutableStateOf(false) } var alsoDeleteForeign by remember(session.id) { mutableStateOf(false) }
// Old servers reported only the capability, when Claude Code was its sole owner.
val transcriptOwner = session.ownTranscriptName ?: "Claude Code"
AlertDialog( AlertDialog(
onDismissRequest = { confirmingDelete = null }, onDismissRequest = { confirmingDelete = null },
title = { Text("Delete \"${session.title}\"?") }, title = { Text("Delete \"${session.title}\"?") },
text = { text = {
// Two different acts behind one button, so it says which one this is. What // Two different acts behind one button, so it says which one this is. What
// separates them is whether the *driver* keeps its own record of the conversation // separates them is whether the *driver* keeps its own record of the conversation
// -- the Claude Code CLI does, whether this app spawned the session or imported it; // -- the coding CLIs do, whether this app spawned the session or imported it;
// echo and llama.cpp do not. // echo and llama.cpp do not.
// //
// This used to branch on `imported`, above a comment asserting that "a session // This used to branch on `imported`, above a comment asserting that "a session
// started here has no copy anywhere". That was false for every claude-cli session // started here has no copy anywhere". That was false for every coding-CLI session
// this app spawned, and getting it wrong in that direction is the expensive one: // this app spawned, and getting it wrong in that direction is the expensive one:
// "this can't be undone", said of something that can, spends the credibility the // "this can't be undone", said of something that can, spends the credibility the
// sentence needs. // sentence needs.
@@ -349,12 +351,12 @@ fun SessionListScreen(
// the reassurance being read at the moment it stops being true. // the reassurance being read at the moment it stops being true.
alsoDeleteForeign -> alsoDeleteForeign ->
"Kills the process and deletes both copies of the conversation: " + "Kills the process and deletes both copies of the conversation: " +
"this app's, and Claude Code's own transcript on the " + "this app's, and $transcriptOwner's own transcript on the " +
"machine. Nothing keeps another, so this can't be undone." "machine. Nothing keeps another, so this can't be undone."
else -> else ->
"Stops the process and deletes this app's copy of the " + "Stops the process and deletes this app's copy of the " +
"conversation, including any images, peer messages and " + "conversation, including any images, peer messages and " +
"commands recorded only here. Claude Code keeps its own " + "commands recorded only here. $transcriptOwner keeps its own " +
"transcript on the machine, so the conversation itself " + "transcript on the machine, so the conversation itself " +
"should still be there to import again." "should still be there to import again."
} }
@@ -369,7 +371,7 @@ fun SessionListScreen(
// line of text and re-centres whatever shares a row with it. // line of text and re-centres whatever shares a row with it.
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text( Text(
"Delete Claude Code's transcript too", "Delete $transcriptOwner's transcript too",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
+12 -4
View File
@@ -156,8 +156,7 @@ pub enum DriverKind {
/// The Claude Code CLI over stream-json. Named for the CLI specifically: /// The Claude Code CLI over stream-json. Named for the CLI specifically:
/// bare "claude" would suggest the credit-billed API, which this is not. /// bare "claude" would suggest the credit-billed API, which this is not.
ClaudeCli, ClaudeCli,
/// The Codex CLI's `exec --json` JSONL stream. Each process is one turn; /// The Codex CLI's persistent app-server JSONL protocol.
/// the thread id it reports is resumed by the next process.
CodexCli, CodexCli,
} }
@@ -235,9 +234,18 @@ impl DriverKind {
/// the one sentence that has to be true: said of a session that can in fact /// the one sentence that has to be true: said of a session that can in fact
/// be brought back, it spends the credibility the warning needs. /// be brought back, it spends the credibility the warning needs.
pub fn keeps_own_transcript(self) -> bool { pub fn keeps_own_transcript(self) -> bool {
self.own_transcript_name().is_some()
}
/// The product whose durable transcript survives an ordinary app delete.
/// Reported to the phone because a provider's configured name is not the
/// name of its storage, and calling Codex's rollout a Claude transcript is
/// especially misleading on an irreversible switch.
pub fn own_transcript_name(self) -> Option<&'static str> {
match self { match self {
Self::ClaudeCli | Self::CodexCli => true, Self::ClaudeCli => Some("Claude Code"),
Self::Echo | Self::LlamaCpp => false, Self::CodexCli => Some("Codex"),
Self::Echo | Self::LlamaCpp => None,
} }
} }
+10 -13
View File
@@ -1130,8 +1130,8 @@ async fn spawn(manager: &Arc<SessionManager>, body: SpawnRequest) -> Result<Sess
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct DeleteSessionQuery { struct DeleteSessionQuery {
/// Also remove the machine's own transcript of this conversation -- the /// Also remove the machine's own transcript of this conversation -- the
/// file Claude Code keeps under `~/.claude/projects`, which this server's /// provider-owned file (for example Claude Code's project JSONL or a Codex
/// delete does not otherwise touch. /// rollout), which this server's ordinary delete does not otherwise touch.
/// ///
/// Off by default, because the two deletes differ in what they cost: /// Off by default, because the two deletes differ in what they cost:
/// leaving the machine's copy is recoverable and removing it is not, and a /// leaving the machine's copy is recoverable and removing it is not, and a
@@ -1154,18 +1154,15 @@ async fn delete_session(
// And *deleted* before it too, so a machine that cannot be reached leaves // And *deleted* before it too, so a machine that cannot be reached leaves
// everything as it was rather than a deleted session and a transcript the // everything as it was rather than a deleted session and a transcript the
// phone has already promised is gone. // phone has already promised is gone.
if let Some((setup, session)) = &foreign { if let Some(foreign) = &foreign {
let setup = setup_by_id(&manager, setup)?; let setup = setup_by_id(&manager, &foreign.setup)?;
let transport = crate::session::transport::Transport::for_setup(&setup); let transport = crate::session::transport::Transport::for_setup(&setup);
// A batch of one: the same call, so there is one description of what foreign.delete(&transport).await.map_err(bad_request)?;
// deleting a foreign transcript means. tracing::info!(
crate::session::import::delete(&transport, std::slice::from_ref(session)) "deleted {} session {} with ai-app session {id}",
.await foreign.owner_name(),
.map_err(bad_request)? foreign.id
.remove(session) );
.unwrap_or_else(|| Err(format!("nothing was reported about {session}")))
.map_err(|message| bad_request(anyhow::anyhow!("{message}")))?;
tracing::info!("deleted Claude Code session {session} with ai-app session {id}");
} }
manager.delete_session(&id).map_err(bad_request)?; manager.delete_session(&id).map_err(bad_request)?;
tracing::info!("deleted session {id}"); tracing::info!("deleted session {id}");
+3 -42
View File
@@ -47,7 +47,6 @@
//! same way as the rest, against 2.1.237 on 2026-08-29. //! same way as the rest, against 2.1.237 on 2026-08-29.
use std::collections::VecDeque; use std::collections::VecDeque;
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
@@ -393,9 +392,9 @@ impl ClaudeDriver {
// Fresh logs, because the offsets that index them start at zero and // Fresh logs, because the offsets that index them start at zero and
// everything the previous process said is already in the transcript. // everything the previous process said is already in the transcript.
let stdin = make_fifo(&session_dir.join(STDIN_FIFO))?; let stdin = process::make_fifo(&session_dir.join(STDIN_FIFO))?;
let stdout = create_log(&session_dir.join(STDOUT_LOG))?; let stdout = process::create_log(&session_dir.join(STDOUT_LOG))?;
let stderr = create_log(&session_dir.join(STDERR_LOG))?; let stderr = process::create_log(&session_dir.join(STDERR_LOG))?;
let program = provider.program(); let program = provider.program();
let launch = Launch::new(program, args, meta.cwd.as_deref()); let launch = Launch::new(program, args, meta.cwd.as_deref());
@@ -1049,44 +1048,6 @@ fn stderr_tail(path: &Path) -> String {
tail_of(&kept) tail_of(&kept)
} }
/// Creates the stdin fifo if it is not already there, and opens it read-write
/// for the process to inherit.
///
/// Read-write is the whole trick: a fifo opened read-only delivers EOF as soon
/// as the last writer closes, so the process would exit the moment this server
/// did -- exactly what leaving it running has to prevent. Holding it open for
/// writing means the process is its own last writer.
fn make_fifo(path: &Path) -> Result<std::fs::File> {
if !path.exists() {
let c_path = std::ffi::CString::new(path.as_os_str().as_encoded_bytes())
.with_context(|| format!("{} is not a usable path", path.display()))?;
// SAFETY: a nul-terminated path this call only reads, and a mode with
// no bits the kernel can object to. Owner-only, like everything else in
// a session directory: this carries what the person typed.
let made = unsafe { libc::mkfifo(c_path.as_ptr(), 0o600) };
if made != 0 {
return Err(std::io::Error::last_os_error())
.with_context(|| format!("creating the fifo {}", path.display()));
}
}
std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(path)
.with_context(|| format!("opening the fifo {}", path.display()))
}
/// A fresh, empty, owner-only log for one of the process's output streams.
fn create_log(path: &Path) -> Result<std::fs::File> {
std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.mode(0o600)
.open(path)
.with_context(|| format!("creating {}", path.display()))
}
pub(super) fn read_resume_token(session_dir: &Path) -> Option<String> { pub(super) fn read_resume_token(session_dir: &Path) -> Option<String> {
let text = std::fs::read_to_string(session_dir.join(RESUME_FILE)).ok()?; let text = std::fs::read_to_string(session_dir.join(RESUME_FILE)).ok()?;
serde_json::from_str::<Value>(&text) serde_json::from_str::<Value>(&text)
File diff suppressed because it is too large. Load diff
+142 -19
View File
@@ -1,9 +1,11 @@
//! `codex exec --json` lines into the common event model. //! Codex app-server notifications into the common event model.
//! //!
//! The CLI promises JSONL but deliberately leaves room for new item kinds. We //! The CLI promises JSONL but deliberately leaves room for new item kinds. We
//! therefore match only the records that have a useful common equivalent and //! therefore match only the records that have a useful common equivalent and
//! ignore the rest; an added Codex item must not make a live session go deaf. //! ignore the rest; an added Codex item must not make a live session go deaf.
use std::collections::HashSet;
use serde_json::{Value, json}; use serde_json::{Value, json};
use super::super::driver::{Event, SessionStatus}; use super::super::driver::{Event, SessionStatus};
@@ -13,11 +15,16 @@ pub(super) struct Translator {
pub(super) thread_id: Option<String>, pub(super) thread_id: Option<String>,
completed: bool, completed: bool,
limited: bool, limited: bool,
streamed_messages: HashSet<String>,
pending_usage: Option<u64>,
} }
impl Translator { impl Translator {
pub(super) fn translate(&mut self, line: &Value) -> Vec<Event> { pub(super) fn translate(&mut self, line: &Value) -> Vec<Event> {
match line.get("type").and_then(Value::as_str) { let method = line.get("method").and_then(Value::as_str);
let body = method.and_then(|_| line.get("params")).unwrap_or(line);
let kind = method.or_else(|| line.get("type").and_then(Value::as_str));
match kind {
Some("thread.started") => { Some("thread.started") => {
self.thread_id = line self.thread_id = line
.get("thread_id") .get("thread_id")
@@ -25,16 +32,65 @@ impl Translator {
.map(str::to_string); .map(str::to_string);
Vec::new() Vec::new()
} }
Some("turn.started") => vec![Event::Status { Some("thread/started") => {
state: SessionStatus::Running, self.thread_id = body
}], .pointer("/thread/id")
Some("item.started") => start_item(&line["item"]), .and_then(Value::as_str)
.map(str::to_string);
Vec::new()
}
Some("turn.started") | Some("turn/started") => {
self.completed = false;
self.limited = false;
self.pending_usage = None;
self.streamed_messages.clear();
vec![Event::Status {
state: SessionStatus::Running,
}]
}
Some("item.started") | Some("item/started") => start_item(&body["item"]),
Some("item.updated") => update_item(&line["item"]), Some("item.updated") => update_item(&line["item"]),
Some("item.completed") => complete_item(&line["item"]), Some("item.completed") | Some("item/completed") => {
Some("turn.completed") => { complete_item(&body["item"], &self.streamed_messages)
}
Some("item/agentMessage/delta") => {
let Some(delta) = body.get("delta").and_then(Value::as_str) else {
return Vec::new();
};
if let Some(id) = body.get("itemId").and_then(Value::as_str) {
self.streamed_messages.insert(id.to_string());
}
vec![Event::AssistantText {
delta: delta.to_string(),
}]
}
Some("item/commandExecution/outputDelta") => {
let (Some(id), Some(output)) = (
body.get("itemId").and_then(Value::as_str),
body.get("delta").and_then(Value::as_str),
) else {
return Vec::new();
};
vec![Event::ToolUpdate {
id: id.to_string(),
output: output.to_string(),
}]
}
Some("thread/tokenUsage/updated") => {
self.pending_usage = body
.pointer("/tokenUsage/last/totalTokens")
.and_then(Value::as_u64);
Vec::new()
}
Some("turn.completed") | Some("turn/completed") => {
self.completed = true; self.completed = true;
let mut events = Vec::new(); let mut events = Vec::new();
if let Some(usage) = line.get("usage") { if let Some(tokens) = self.pending_usage.take() {
events.push(Event::UsageDelta {
tokens,
context: None,
});
} else if let Some(usage) = line.get("usage") {
let input = number(usage, "input_tokens"); let input = number(usage, "input_tokens");
let output = number(usage, "output_tokens"); let output = number(usage, "output_tokens");
if input.is_some() || output.is_some() { if input.is_some() || output.is_some() {
@@ -46,20 +102,27 @@ impl Translator {
}); });
} }
} }
if let Some(error) = body.pointer("/turn/error")
&& !error.is_null()
{
events.extend(self.failure(error));
}
events.push(Event::Status { events.push(Event::Status {
state: SessionStatus::Idle, state: SessionStatus::Idle,
}); });
events events
} }
Some("turn.failed") | Some("error") => self.failure(line), Some("turn.failed") | Some("error") => self.failure(body),
_ => Vec::new(), _ => Vec::new(),
} }
} }
#[cfg(test)]
pub(super) fn completed(&self) -> bool { pub(super) fn completed(&self) -> bool {
self.completed self.completed
} }
#[cfg(test)]
pub(super) fn limited(&self) -> bool { pub(super) fn limited(&self) -> bool {
self.limited self.limited
} }
@@ -106,19 +169,24 @@ fn update_item(item: &Value) -> Vec<Event> {
.unwrap_or_default() .unwrap_or_default()
} }
fn complete_item(item: &Value) -> Vec<Event> { fn complete_item(item: &Value, streamed_messages: &HashSet<String>) -> Vec<Event> {
match item.get("type").and_then(Value::as_str) { match item.get("type").and_then(Value::as_str) {
Some("agent_message") => item Some("agent_message" | "agentMessage") => item
.get("text") .get("text")
.and_then(Value::as_str) .and_then(Value::as_str)
.filter(|text| !text.is_empty()) .filter(|text| !text.is_empty())
.filter(|_| {
item.get("id")
.and_then(Value::as_str)
.is_none_or(|id| !streamed_messages.contains(id))
})
.map(|delta| { .map(|delta| {
vec![Event::AssistantText { vec![Event::AssistantText {
delta: delta.to_string(), delta: delta.to_string(),
}] }]
}) })
.unwrap_or_default(), .unwrap_or_default(),
Some("reasoning") => Vec::new(), Some("reasoning" | "userMessage") => Vec::new(),
_ => { _ => {
let Some((id, _, _)) = tool(item) else { let Some((id, _, _)) = tool(item) else {
return Vec::new(); return Vec::new();
@@ -133,15 +201,15 @@ fn tool(item: &Value) -> Option<(String, String, Value)> {
let id = item.get("id")?.as_str()?.to_string(); let id = item.get("id")?.as_str()?.to_string();
let kind = item.get("type")?.as_str()?; let kind = item.get("type")?.as_str()?;
let (name, input) = match kind { let (name, input) = match kind {
"command_execution" => ( "command_execution" | "commandExecution" => (
"exec_command".to_string(), "exec_command".to_string(),
json!({"command": item.get("command").cloned().unwrap_or(Value::Null)}), json!({"command": item.get("command").cloned().unwrap_or(Value::Null)}),
), ),
"file_change" => ( "file_change" | "fileChange" => (
"apply_patch".to_string(), "apply_patch".to_string(),
item.get("changes").cloned().unwrap_or(Value::Null), item.get("changes").cloned().unwrap_or(Value::Null),
), ),
"mcp_tool_call" => ( "mcp_tool_call" | "mcpToolCall" => (
item.get("tool") item.get("tool")
.or_else(|| item.get("name")) .or_else(|| item.get("name"))
.and_then(Value::as_str) .and_then(Value::as_str)
@@ -149,18 +217,24 @@ fn tool(item: &Value) -> Option<(String, String, Value)> {
.unwrap_or_else(|| "mcp".to_string()), .unwrap_or_else(|| "mcp".to_string()),
item.get("arguments").cloned().unwrap_or(Value::Null), item.get("arguments").cloned().unwrap_or(Value::Null),
), ),
"web_search" => ( "web_search" | "webSearch" => (
"web_search".to_string(), "web_search".to_string(),
json!({"query": item.get("query").cloned().unwrap_or(Value::Null)}), json!({"query": item.get("query").cloned().unwrap_or(Value::Null)}),
), ),
"todo_list" => ("update_plan".to_string(), item.clone()), "todo_list" | "todoList" | "plan" => ("update_plan".to_string(), item.clone()),
_ => return None, _ => return None,
}; };
Some((id, name, input)) Some((id, name, input))
} }
fn tool_output(item: &Value) -> String { fn tool_output(item: &Value) -> String {
for key in ["aggregated_output", "output", "result", "error"] { for key in [
"aggregated_output",
"aggregatedOutput",
"output",
"result",
"error",
] {
if let Some(text) = item.get(key).and_then(value_text) if let Some(text) = item.get(key).and_then(value_text)
&& !text.is_empty() && !text.is_empty()
{ {
@@ -276,4 +350,53 @@ mod tests {
); );
assert!(translator.limited()); assert!(translator.limited());
} }
#[test]
fn translates_native_app_server_streaming_without_repeating_the_final_item() {
let mut translator = Translator::default();
assert_eq!(
translator.translate(&line(
r#"{"method":"turn/started","params":{"threadId":"thread-1","turn":{"id":"turn-1"}}}"#
)),
vec![Event::Status {
state: SessionStatus::Running
}]
);
assert_eq!(
translator.translate(&line(
r#"{"method":"item/agentMessage/delta","params":{"itemId":"message-1","delta":"hello"}}"#
)),
vec![Event::AssistantText {
delta: "hello".to_string()
}]
);
assert!(
translator
.translate(&line(
r#"{"method":"item/completed","params":{"item":{"id":"message-1","type":"agentMessage","text":"hello"}}}"#
))
.is_empty()
);
assert!(
translator
.translate(&line(
r#"{"method":"thread/tokenUsage/updated","params":{"tokenUsage":{"last":{"totalTokens":42}}}}"#
))
.is_empty()
);
assert_eq!(
translator.translate(&line(
r#"{"method":"turn/completed","params":{"turn":{"id":"turn-1","status":"completed","error":null}}}"#
)),
vec![
Event::UsageDelta {
tokens: 42,
context: None
},
Event::Status {
state: SessionStatus::Idle
}
]
);
}
} }
+71 -14
View File
@@ -205,6 +205,9 @@ pub struct SessionInfo {
/// Reported rather than worked out on the phone, because the phone has /// Reported rather than worked out on the phone, because the phone has
/// the provider's *name* and this is a property of its *kind*. /// the provider's *name* and this is a property of its *kind*.
pub keeps_own_transcript: bool, pub keeps_own_transcript: bool,
/// The CLI whose copy the delete dialog can optionally remove.
#[serde(skip_serializing_if = "Option::is_none")]
pub own_transcript_name: Option<&'static str>,
/// How much this session asks before acting. Reported so the phone can /// How much this session asks before acting. Reported so the phone can
/// *show* the current mode rather than assume one -- a picker that /// *show* the current mode rather than assume one -- a picker that
/// guesses its own value is how you change something you thought you /// guesses its own value is how you change something you thought you
@@ -600,6 +603,7 @@ impl LiveSession {
usage_provider: kind.and_then(DriverKind::usage_provider), usage_provider: kind.and_then(DriverKind::usage_provider),
imported, imported,
keeps_own_transcript: kind.is_some_and(DriverKind::keeps_own_transcript), keeps_own_transcript: kind.is_some_and(DriverKind::keeps_own_transcript),
own_transcript_name: kind.and_then(DriverKind::own_transcript_name),
cwd: cwd.map(Path::to_path_buf), cwd: cwd.map(Path::to_path_buf),
status: *self.shared.status.lock().unwrap(), status: *self.shared.status.lock().unwrap(),
last_activity: *self.shared.last_activity.lock().unwrap(), last_activity: *self.shared.last_activity.lock().unwrap(),
@@ -625,9 +629,8 @@ pub struct SessionManager {
/// Where every session's pump reports what this layer does not act on -- /// Where every session's pump reports what this layer does not act on --
/// see [`Announcements`]. /// see [`Announcements`].
announce: Announcements, announce: Announcements,
/// Imports and deletes running against a machine's Claude Code /// Provider-transcript operations running against a machine: like the
/// sessions: like the notifications, state the phone reads but does not /// notifications, state the phone reads but does not own.
/// own.
pending: Arc<pending::Registry>, pending: Arc<pending::Registry>,
/// What to mark sessions spawned here as -- see /// What to mark sessions spawned here as -- see
/// [`SessionManager::marking_new_sessions_throwaway`]. /// [`SessionManager::marking_new_sessions_throwaway`].
@@ -640,6 +643,38 @@ pub struct SessionManager {
inner: RwLock<Inner>, inner: RwLock<Inner>,
} }
/// The CLI-owned copy optionally removed with an ai-app session.
#[derive(Debug, PartialEq, Eq)]
pub struct ForeignTranscript {
pub setup: String,
pub id: String,
kind: DriverKind,
}
impl ForeignTranscript {
/// Removes the provider's own durable record. The route remains generic:
/// adding another transcript-owning driver extends this provider boundary.
pub async fn delete(&self, transport: &Transport) -> Result<()> {
match self.kind {
DriverKind::ClaudeCli => import::delete(transport, std::slice::from_ref(&self.id))
.await?
.remove(&self.id)
.unwrap_or_else(|| Err(format!("nothing was reported about {}", self.id)))
.map_err(anyhow::Error::msg),
DriverKind::CodexCli => codex::delete_transcript(transport, &self.id).await,
DriverKind::Echo | DriverKind::LlamaCpp => {
anyhow::bail!("this provider keeps no transcript of its own")
}
}
}
pub fn owner_name(&self) -> &'static str {
self.kind
.own_transcript_name()
.expect("a foreign transcript has an owner")
}
}
impl SessionManager { impl SessionManager {
/// Loads the config and brings every persisted session back: its /// Loads the config and brings every persisted session back: its
/// transcript, its pump, and the process it left running where it left /// transcript, its pump, and the process it left running where it left
@@ -1034,15 +1069,26 @@ impl SessionManager {
Some((setup.ssh.clone()?, meta.cwd.clone())) Some((setup.ssh.clone()?, meta.cwd.clone()))
} }
pub fn foreign_transcript(&self, id: &str) -> Option<(String, String)> { pub fn foreign_transcript(&self, id: &str) -> Option<ForeignTranscript> {
let inner = self.inner.read().unwrap(); let inner = self.inner.read().unwrap();
let meta = inner.config.sessions.iter().find(|meta| meta.id == id)?; let meta = inner.config.sessions.iter().find(|meta| meta.id == id)?;
let (followed, resuming) = foreign_ids(&self.data_dir.join(&meta.id)); let kind = kind_of(&inner.config, &meta.setup, &meta.provider)?;
// The cursor first: an imported session follows a file that exists let session_dir = self.data_dir.join(&meta.id);
// whether or not a CLI has resumed it yet. let foreign = match kind {
followed DriverKind::ClaudeCli => {
.or(resuming) let (followed, resuming) = foreign_ids(&session_dir);
.map(|foreign| (meta.setup.clone(), foreign)) // The cursor first: an imported session follows a file that exists
// whether or not a CLI has resumed it yet.
followed.or(resuming)
}
DriverKind::CodexCli => codex::read_thread(&session_dir),
DriverKind::Echo | DriverKind::LlamaCpp => None,
}?;
Some(ForeignTranscript {
setup: meta.setup.clone(),
id: foreign,
kind,
})
} }
/// Every session, in config order, with live status joined in. A /// Every session, in config order, with live status joined in. A
@@ -1088,6 +1134,8 @@ impl SessionManager {
&meta.setup, &meta.setup,
&meta.provider, &meta.provider,
), ),
own_transcript_name: kind_of(&inner.config, &meta.setup, &meta.provider)
.and_then(DriverKind::own_transcript_name),
cwd: meta.cwd.clone(), cwd: meta.cwd.clone(),
status: status_of_unlaunched(&self.data_dir.join(&meta.id)), status: status_of_unlaunched(&self.data_dir.join(&meta.id)),
last_activity: meta.created, last_activity: meta.created,
@@ -2804,6 +2852,8 @@ mod tests {
#[test] #[test]
fn only_a_driver_that_keeps_its_own_record_survives_deletion() { fn only_a_driver_that_keeps_its_own_record_survives_deletion() {
assert!(DriverKind::ClaudeCli.keeps_own_transcript()); assert!(DriverKind::ClaudeCli.keeps_own_transcript());
assert!(DriverKind::CodexCli.keeps_own_transcript());
assert_eq!(DriverKind::CodexCli.own_transcript_name(), Some("Codex"));
assert!(!DriverKind::Echo.keeps_own_transcript()); assert!(!DriverKind::Echo.keeps_own_transcript());
assert!(!DriverKind::LlamaCpp.keeps_own_transcript()); assert!(!DriverKind::LlamaCpp.keeps_own_transcript());
} }
@@ -3268,14 +3318,17 @@ mod tests {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let config_path = dir.path().join("config.ron"); let config_path = dir.path().join("config.ron");
let data_dir = dir.path().join("sessions"); let data_dir = dir.path().join("sessions");
seed_echo_only(&config_path); let provider = seed_stand_in_cli(&config_path, dir.path());
let manager = SessionManager::new( let manager = SessionManager::new(
config_path.clone(), config_path.clone(),
data_dir.clone(), data_dir.clone(),
data_dir.join("models"), data_dir.join("models"),
) )
.expect("manager"); .expect("manager")
let info = manager.spawn_session(echo_spec()).expect("spawn"); .marking_new_sessions_throwaway(true);
let info = manager
.spawn_session(stand_in_spec(&provider))
.expect("spawn");
// Nothing recorded yet, so there is nothing a delete would reach. // Nothing recorded yet, so there is nothing a delete would reach.
assert_eq!(manager.foreign_transcript(&info.id), None); assert_eq!(manager.foreign_transcript(&info.id), None);
@@ -3283,7 +3336,11 @@ mod tests {
claude::write_resume_token(&data_dir.join(&info.id), "5ecf21da-d53f"); claude::write_resume_token(&data_dir.join(&info.id), "5ecf21da-d53f");
assert_eq!( assert_eq!(
manager.foreign_transcript(&info.id), manager.foreign_transcript(&info.id),
Some((info.setup.clone(), "5ecf21da-d53f".to_string())) Some(ForeignTranscript {
setup: info.setup.clone(),
id: "5ecf21da-d53f".to_string(),
kind: DriverKind::ClaudeCli,
})
); );
// A session that is not there has no transcript to name, rather // A session that is not there has no transcript to name, rather
+33
View File
@@ -187,6 +187,39 @@ pub fn clear(session_dir: &Path) {
} }
} }
/// Creates a session stdin fifo and opens it read-write for the child.
///
/// The child holding the write end is what keeps a detached JSON server from
/// reading EOF when ai-server restarts and temporarily closes its own writer.
pub fn make_fifo(path: &Path) -> Result<std::fs::File> {
if !path.exists() {
let c_path = std::ffi::CString::new(path.as_os_str().as_encoded_bytes())
.with_context(|| format!("{} is not a usable path", path.display()))?;
// SAFETY: `c_path` is nul-terminated and this call only reads it.
let made = unsafe { libc::mkfifo(c_path.as_ptr(), 0o600) };
if made != 0 {
return Err(std::io::Error::last_os_error())
.with_context(|| format!("creating the fifo {}", path.display()));
}
}
std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(path)
.with_context(|| format!("opening the fifo {}", path.display()))
}
/// A fresh owner-only log for a detached session process.
pub fn create_log(path: &Path) -> Result<std::fs::File> {
std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.mode(0o600)
.open(path)
.with_context(|| format!("creating {}", path.display()))
}
/// Grace period between asking a session's process to stop and killing it. /// Grace period between asking a session's process to stop and killing it.
/// Here rather than beside each caller: two drivers plus the manager had /// Here rather than beside each caller: two drivers plus the manager had
/// written the same five seconds down separately. /// written the same five seconds down separately.