Use native Codex steering and transcript deletion
This commit is contained in:
1 parent
00538cc19b
commit
8c88a7e991
12 files changed
+1004
-390
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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}");
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+690
-267
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in new issue
Block a user