Phase 2 core: ClaudeDriver over stream-json, permissions and questions on the phone
The second driver behind the same trait: claude -p with stream-json both ways, the hidden --permission-prompt-tool stdio flag (without which no permission ever reaches a client), text deltas streamed from raw API events, tool_use/tool_result mapped to tool events, and can_use_tool control requests surfaced as Question events -- plain permissions as Allow/Deny, AskUserQuestion as one Question per sub-question with the chosen labels sent back in updatedInput.answers keyed by question text (wire shapes pinned by live probes against CLI 2.1.237, recorded in the module doc). The CLI session id is persisted per session dir, so a backend restart respawns with --resume and loses nothing. set_model rides the control protocol and persists through the manager; the spawn screen grows model/cwd/permission-mode fields. Also: the dev CA now carries proper keyUsage/basicConstraints extensions (strict verifiers reject it otherwise) -- regenerated and re-pinned before any real phone has installed the app. Verified: 20 unit tests + clippy clean; scripted end-to-end over the HTTP API (AskUserQuestion round trip, Bash permission allow, streaming, restart with --resume remembering earlier work, delete); and on the emulator, a live haiku session asking Tea-or-coffee and acknowledging the tapped answer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
This commit is contained in:
1 parent
f3cebeea78
commit
95d389e2b8
10 files changed
+882
-37
No files matched your search
@@ -9,6 +9,7 @@
|
||||
//! SSE subscribers. The transcript is the source of truth -- subscribers
|
||||
//! that fall behind or reconnect catch up from the file by cursor.
|
||||
|
||||
pub mod claude;
|
||||
pub mod driver;
|
||||
pub mod echo;
|
||||
pub mod transcript;
|
||||
@@ -23,6 +24,7 @@ use serde::Serialize;
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
|
||||
use crate::config::{Config, SessionConfig, SessionKind, TokenEntry};
|
||||
use claude::ClaudeDriver;
|
||||
use driver::{Driver, Event, ImageRef, SessionStatus};
|
||||
use echo::EchoDriver;
|
||||
use transcript::{SeqEvent, Transcript};
|
||||
@@ -79,9 +81,12 @@ pub struct LiveSession {
|
||||
}
|
||||
|
||||
/// The pump-maintained view of a session, read by the list endpoint.
|
||||
/// `model` also lives here (not in the immutable meta) because it can
|
||||
/// change mid-session via `set_model`.
|
||||
struct Shared {
|
||||
status: Mutex<SessionStatus>,
|
||||
last_activity: Mutex<f64>,
|
||||
model: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
impl LiveSession {
|
||||
@@ -105,13 +110,6 @@ impl LiveSession {
|
||||
self.driver.interrupt();
|
||||
}
|
||||
|
||||
/// Hands the change to the driver. The persisted `model` field follows
|
||||
/// when a driver that actually honors this lands (phase 2) -- echo
|
||||
/// sessions just report the request as an error event.
|
||||
pub fn set_model(&self, model: &str) {
|
||||
self.driver.set_model(model);
|
||||
}
|
||||
|
||||
pub fn compact(&self) {
|
||||
self.driver.compact();
|
||||
}
|
||||
@@ -130,7 +128,7 @@ impl LiveSession {
|
||||
kind: self.meta.kind,
|
||||
title: self.meta.title.clone(),
|
||||
host: self.meta.host.clone(),
|
||||
model: self.meta.model.clone(),
|
||||
model: self.shared.model.lock().unwrap().clone(),
|
||||
cwd: self.meta.cwd.clone(),
|
||||
status: *self.shared.status.lock().unwrap(),
|
||||
last_activity: *self.shared.last_activity.lock().unwrap(),
|
||||
@@ -263,6 +261,28 @@ impl SessionManager {
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// Changes a session's model: persisted (so a respawn keeps it and the
|
||||
/// list shows it) and handed to the driver, which switches in place
|
||||
/// where its dialect can. Through the manager, not the session, so the
|
||||
/// config and the live view can't disagree.
|
||||
pub fn set_session_model(&self, id: &str, model: &str) -> Result<()> {
|
||||
let mut inner = self.inner.write().unwrap();
|
||||
if !inner.config.sessions.iter().any(|meta| meta.id == id) {
|
||||
bail!("no session {id}");
|
||||
}
|
||||
let mut candidate = inner.config.clone();
|
||||
for meta in candidate.sessions.iter_mut().filter(|meta| meta.id == id) {
|
||||
meta.model = Some(model.to_string());
|
||||
}
|
||||
candidate.save(&self.config_path)?;
|
||||
inner.config = candidate;
|
||||
if let Some(session) = inner.live.get(id) {
|
||||
*session.shared.model.lock().unwrap() = Some(model.to_string());
|
||||
session.driver.set_model(model);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Kills the process, releases everything the spawn created, and
|
||||
/// deletes the transcript and files -- the complete path out.
|
||||
pub fn delete_session(&self, id: &str) -> Result<()> {
|
||||
@@ -288,6 +308,7 @@ impl SessionManager {
|
||||
fn default_title(kind: SessionKind) -> String {
|
||||
match kind {
|
||||
SessionKind::Echo => "Echo session".to_string(),
|
||||
SessionKind::Claude => "Claude session".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,10 +340,12 @@ fn launch(meta: SessionConfig, data_dir: &Path) -> Result<Arc<LiveSession>> {
|
||||
let shared = Arc::new(Shared {
|
||||
status: Mutex::new(SessionStatus::Idle),
|
||||
last_activity: Mutex::new(now()),
|
||||
model: Mutex::new(meta.model.clone()),
|
||||
});
|
||||
|
||||
let driver: Box<dyn Driver> = match meta.kind {
|
||||
SessionKind::Echo => Box::new(EchoDriver::new(sink.clone())),
|
||||
SessionKind::Claude => Box::new(ClaudeDriver::spawn(&meta, &dir, sink.clone())?),
|
||||
};
|
||||
|
||||
tokio::spawn(pump(transcript, source, Arc::clone(&shared), events.clone()));
|
||||
|
||||
Reference in new issue
Block a user