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
+992 -378

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**
`DELETE /sessions/{id}?deleteForeign=true`, behind a switch in the
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
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
+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
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".
Codex is `codex exec --json`, one child process per turn; its driver keeps
the thread id for `exec resume`, persists messages queued behind a turn, and
reads subscription limits through the CLI's app-server protocol.
Codex is one persistent `codex app-server --stdio` process per session; its
driver uses native turn steering and interruption, persists the 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".
`AppRoot.kt` is the navigation `when`; `MainScreen.kt` the root's four tabs
(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)
├─ SessionManager ── Session ── Driver (trait)
│ ├─ 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)
│ └─ EchoDriver (the test rig)
│ 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
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
boundaries, item start/completion records, and the final token usage. The
driver translates those records into the same events as every other session
and persists the reported thread id. Later turns run `codex exec resume <id>
--json`; model, effort and attachments remain launch arguments owned by the
driver rather than branches in routes or screens.
Codex uses one persistent `codex app-server --stdio` per session. The original
2026-09-07 implementation used one `codex exec --json` process per turn, but
that surface cannot steer: a message typed during work was held until the turn
ended, and Pause killed the whole process before starting another resume. The
app-server protocol provides the operations the interface actually promises:
`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
therefore owns a sequence of child processes rather than one permanently idle
child: a clean exit after `turn.completed` means `idle`, while an exit before a
turn boundary is an error. A process in flight still uses `process.json` plus
detached stdout/stderr logs, so it survives and is adopted across a backend
restart exactly like the long-lived CLI. Messages received during a turn are
persisted and start later turns in order. The JSON exec surface has no stdin
steering or interactive approval protocol, so it cannot inject a message at a
tool boundary or answer a question inside the same process; those limits are
reported rather than guessed around.
The process's stdin is a fifo and its output is a detached log, with protocol
state persisted beside the thread id. It therefore survives and is adopted
across a backend restart like the Claude CLI. A steer is sent immediately and
is announced where Codex emits its user-message item; if Codex says the active
turn is not steerable, the message remains queued and starts the next turn
instead of being lost. An interrupt requested while a turn is still starting
is applied once Codex supplies that turn's id, so it cannot leak forward and
hide a later failure.
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
@@ -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
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**
(`serde(deny_unknown_fields)`). A caller that misspells `permissionMode` got
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.
*/
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. */
val permissionMode: String?,
/**
@@ -238,6 +240,7 @@ private fun parseSession(session: JSONObject) =
id = session.getString("id"),
setup = session.getString("setup"),
keepsOwnTranscript = session.optBoolean("keepsOwnTranscript", false),
ownTranscriptName = session.optString("ownTranscriptName").ifEmpty { null },
setupName = session.getString("setupName"),
provider = session.getString("provider"),
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.
*
* [deleteForeign] is the delete this app cannot otherwise reach: Claude Code keeps its own record
* under `~/.claude/projects`, and leaving it is what makes an ordinary delete recoverable. The
* server does both halves, and does the unrecoverable one first, so a machine it cannot reach
* leaves the session exactly where it was rather than half-deleted.
* [deleteForeign] is the delete this app cannot otherwise reach: coding CLIs keep their own durable
* record, and leaving it is what makes an ordinary delete recoverable. The server does both halves,
* and does the unrecoverable one first, so a machine it cannot reach leaves the session exactly
* where it was rather than half-deleted.
*/
fun deleteSession(settings: ServerSettings, sessionId: String, deleteForeign: Boolean = false) {
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
// next. Off to begin with: see [deleteSession].
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(
onDismissRequest = { confirmingDelete = null },
title = { Text("Delete \"${session.title}\"?") },
text = {
// 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
// -- 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.
//
// 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 can't be undone", said of something that can, spends the credibility the
// sentence needs.
@@ -349,12 +351,12 @@ fun SessionListScreen(
// the reassurance being read at the moment it stops being true.
alsoDeleteForeign ->
"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."
else ->
"Stops the process and deletes this app's copy of the " +
"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 " +
"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.
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"Delete Claude Code's transcript too",
"Delete $transcriptOwner's transcript too",
style = MaterialTheme.typography.bodyMedium,
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:
/// bare "claude" would suggest the credit-billed API, which this is not.
ClaudeCli,
/// The Codex CLI's `exec --json` JSONL stream. Each process is one turn;
/// the thread id it reports is resumed by the next process.
/// The Codex CLI's persistent app-server JSONL protocol.
CodexCli,
}
@@ -235,9 +234,18 @@ impl DriverKind {
/// 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.
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 {
Self::ClaudeCli | Self::CodexCli => true,
Self::Echo | Self::LlamaCpp => false,
Self::ClaudeCli => Some("Claude Code"),
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")]
struct DeleteSessionQuery {
/// Also remove the machine's own transcript of this conversation -- the
/// file Claude Code keeps under `~/.claude/projects`, which this server's
/// delete does not otherwise touch.
/// provider-owned file (for example Claude Code's project JSONL or a Codex
/// rollout), which this server's ordinary delete does not otherwise touch.
///
/// 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
@@ -1154,18 +1154,15 @@ async fn delete_session(
// 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
// phone has already promised is gone.
if let Some((setup, session)) = &foreign {
let setup = setup_by_id(&manager, setup)?;
if let Some(foreign) = &foreign {
let setup = setup_by_id(&manager, &foreign.setup)?;
let transport = crate::session::transport::Transport::for_setup(&setup);
// A batch of one: the same call, so there is one description of what
// deleting a foreign transcript means.
crate::session::import::delete(&transport, std::slice::from_ref(session))
.await
.map_err(bad_request)?
.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}");
foreign.delete(&transport).await.map_err(bad_request)?;
tracing::info!(
"deleted {} session {} with ai-app session {id}",
foreign.owner_name(),
foreign.id
);
}
manager.delete_session(&id).map_err(bad_request)?;
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.
use std::collections::VecDeque;
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
@@ -393,9 +392,9 @@ impl ClaudeDriver {
// Fresh logs, because the offsets that index them start at zero and
// everything the previous process said is already in the transcript.
let stdin = make_fifo(&session_dir.join(STDIN_FIFO))?;
let stdout = create_log(&session_dir.join(STDOUT_LOG))?;
let stderr = create_log(&session_dir.join(STDERR_LOG))?;
let stdin = process::make_fifo(&session_dir.join(STDIN_FIFO))?;
let stdout = process::create_log(&session_dir.join(STDOUT_LOG))?;
let stderr = process::create_log(&session_dir.join(STDERR_LOG))?;
let program = provider.program();
let launch = Launch::new(program, args, meta.cwd.as_deref());
@@ -1049,44 +1048,6 @@ fn stderr_tail(path: &Path) -> String {
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> {
let text = std::fs::read_to_string(session_dir.join(RESUME_FILE)).ok()?;
serde_json::from_str::<Value>(&text)
File diff suppressed because it is too large. Load diff
+141 -18
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
//! 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.
use std::collections::HashSet;
use serde_json::{Value, json};
use super::super::driver::{Event, SessionStatus};
@@ -13,11 +15,16 @@ pub(super) struct Translator {
pub(super) thread_id: Option<String>,
completed: bool,
limited: bool,
streamed_messages: HashSet<String>,
pending_usage: Option<u64>,
}
impl Translator {
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") => {
self.thread_id = line
.get("thread_id")
@@ -25,16 +32,65 @@ impl Translator {
.map(str::to_string);
Vec::new()
}
Some("turn.started") => vec![Event::Status {
Some("thread/started") => {
self.thread_id = body
.pointer("/thread/id")
.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") => start_item(&line["item"]),
}]
}
Some("item.started") | Some("item/started") => start_item(&body["item"]),
Some("item.updated") => update_item(&line["item"]),
Some("item.completed") => complete_item(&line["item"]),
Some("turn.completed") => {
Some("item.completed") | Some("item/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;
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 output = number(usage, "output_tokens");
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 {
state: SessionStatus::Idle,
});
events
}
Some("turn.failed") | Some("error") => self.failure(line),
Some("turn.failed") | Some("error") => self.failure(body),
_ => Vec::new(),
}
}
#[cfg(test)]
pub(super) fn completed(&self) -> bool {
self.completed
}
#[cfg(test)]
pub(super) fn limited(&self) -> bool {
self.limited
}
@@ -106,19 +169,24 @@ fn update_item(item: &Value) -> Vec<Event> {
.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) {
Some("agent_message") => item
Some("agent_message" | "agentMessage") => item
.get("text")
.and_then(Value::as_str)
.filter(|text| !text.is_empty())
.filter(|_| {
item.get("id")
.and_then(Value::as_str)
.is_none_or(|id| !streamed_messages.contains(id))
})
.map(|delta| {
vec![Event::AssistantText {
delta: delta.to_string(),
}]
})
.unwrap_or_default(),
Some("reasoning") => Vec::new(),
Some("reasoning" | "userMessage") => Vec::new(),
_ => {
let Some((id, _, _)) = tool(item) else {
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 kind = item.get("type")?.as_str()?;
let (name, input) = match kind {
"command_execution" => (
"command_execution" | "commandExecution" => (
"exec_command".to_string(),
json!({"command": item.get("command").cloned().unwrap_or(Value::Null)}),
),
"file_change" => (
"file_change" | "fileChange" => (
"apply_patch".to_string(),
item.get("changes").cloned().unwrap_or(Value::Null),
),
"mcp_tool_call" => (
"mcp_tool_call" | "mcpToolCall" => (
item.get("tool")
.or_else(|| item.get("name"))
.and_then(Value::as_str)
@@ -149,18 +217,24 @@ fn tool(item: &Value) -> Option<(String, String, Value)> {
.unwrap_or_else(|| "mcp".to_string()),
item.get("arguments").cloned().unwrap_or(Value::Null),
),
"web_search" => (
"web_search" | "webSearch" => (
"web_search".to_string(),
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,
};
Some((id, name, input))
}
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)
&& !text.is_empty()
{
@@ -276,4 +350,53 @@ mod tests {
);
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
}
]
);
}
}
+69 -12
View File
@@ -205,6 +205,9 @@ pub struct SessionInfo {
/// Reported rather than worked out on the phone, because the phone has
/// the provider's *name* and this is a property of its *kind*.
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
/// *show* the current mode rather than assume one -- a picker that
/// 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),
imported,
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),
status: *self.shared.status.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 --
/// see [`Announcements`].
announce: Announcements,
/// Imports and deletes running against a machine's Claude Code
/// sessions: like the notifications, state the phone reads but does not
/// own.
/// Provider-transcript operations running against a machine: like the
/// notifications, state the phone reads but does not own.
pending: Arc<pending::Registry>,
/// What to mark sessions spawned here as -- see
/// [`SessionManager::marking_new_sessions_throwaway`].
@@ -640,6 +643,38 @@ pub struct SessionManager {
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 {
/// Loads the config and brings every persisted session back: its
/// 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()))
}
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 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)?;
let session_dir = self.data_dir.join(&meta.id);
let foreign = match kind {
DriverKind::ClaudeCli => {
let (followed, resuming) = foreign_ids(&session_dir);
// The cursor first: an imported session follows a file that exists
// whether or not a CLI has resumed it yet.
followed
.or(resuming)
.map(|foreign| (meta.setup.clone(), foreign))
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
@@ -1088,6 +1134,8 @@ impl SessionManager {
&meta.setup,
&meta.provider,
),
own_transcript_name: kind_of(&inner.config, &meta.setup, &meta.provider)
.and_then(DriverKind::own_transcript_name),
cwd: meta.cwd.clone(),
status: status_of_unlaunched(&self.data_dir.join(&meta.id)),
last_activity: meta.created,
@@ -2804,6 +2852,8 @@ mod tests {
#[test]
fn only_a_driver_that_keeps_its_own_record_survives_deletion() {
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::LlamaCpp.keeps_own_transcript());
}
@@ -3268,14 +3318,17 @@ mod tests {
let dir = tempfile::tempdir().expect("tempdir");
let config_path = dir.path().join("config.ron");
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(
config_path.clone(),
data_dir.clone(),
data_dir.join("models"),
)
.expect("manager");
let info = manager.spawn_session(echo_spec()).expect("spawn");
.expect("manager")
.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.
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");
assert_eq!(
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
+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.
/// Here rather than beside each caller: two drivers plus the manager had
/// written the same five seconds down separately.