Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions crates/okena-app-core/src/remote_snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
//! Both remote command loops answer `RemoteCommand::GetState` by projecting the
//! same [`WorkspaceData`] onto the same wire DTOs:
//!
//! * GUI: `okena-app`'s `app/remote_commands.rs` `remote_command_loop`
//! (reads an `Entity<Workspace>` / `Entity<ServiceManager>`).
//! * GUI: the daemon client command loop (reads an `Entity<Workspace>` /
//! `Entity<ServiceManager>`).
//! * Headless: `okena-daemon-core`'s `command_loop.rs` `daemon_command_loop`
//! (reads `Arc<Mutex<Workspace>>` / `Arc<Mutex<ServiceManager>>`).
//!
Expand Down Expand Up @@ -42,12 +42,14 @@ pub fn api_project_visibility(project_id: &str, hidden_project_ids: &HashSet<Str
/// caller's `ServiceManager`; absent ⇒ no services).
/// * `hidden_project_ids` — per-window hidden set driving `show_in_overview`.
/// * `size_map` — terminal id → `(cols, rows)` for `layout.to_api_with_sizes`.
/// * `agent_statuses` — terminal id → current runtime-only agent status.
pub fn build_api_project(
p: &ProjectData,
git_statuses: &HashMap<String, ApiGitStatus>,
services_by_project: &HashMap<String, Vec<ApiServiceInfo>>,
hidden_project_ids: &HashSet<String>,
size_map: &HashMap<String, (u16, u16)>,
agent_statuses: &HashMap<String, okena_core::agent_status::AgentStatus>,
) -> ApiProject {
ApiProject {
id: p.id.clone(),
Expand All @@ -56,6 +58,21 @@ pub fn build_api_project(
show_in_overview: api_project_visibility(&p.id, hidden_project_ids),
layout: p.layout.as_ref().map(|l| l.to_api_with_sizes(size_map)),
terminal_names: p.terminal_names.clone(),
terminal_agent_status: p
.layout
.as_ref()
.map(|layout| {
layout
.collect_terminal_ids()
.into_iter()
.filter_map(|terminal_id| {
agent_statuses
.get(&terminal_id)
.map(|status| (terminal_id, status.clone()))
})
.collect()
})
.unwrap_or_default(),
git_status: git_statuses.get(&p.id).cloned(),
folder_color: p.folder_color,
services: services_by_project.get(&p.id).cloned().unwrap_or_default(),
Expand Down Expand Up @@ -91,6 +108,7 @@ pub fn build_api_projects(
services_by_project: &HashMap<String, Vec<ApiServiceInfo>>,
hidden_project_ids: &HashSet<String>,
size_map: &HashMap<String, (u16, u16)>,
agent_statuses: &HashMap<String, okena_core::agent_status::AgentStatus>,
) -> Vec<ApiProject> {
let project_map: HashMap<&str, &ProjectData> =
data.projects.iter().map(|p| (p.id.as_str(), p)).collect();
Expand All @@ -105,6 +123,7 @@ pub fn build_api_projects(
services_by_project,
hidden_project_ids,
size_map,
agent_statuses,
));
};

Expand Down Expand Up @@ -162,6 +181,7 @@ pub fn build_state_response(
services_by_project: &HashMap<String, Vec<ApiServiceInfo>>,
hidden_project_ids: &HashSet<String>,
size_map: &HashMap<String, (u16, u16)>,
agent_statuses: &HashMap<String, okena_core::agent_status::AgentStatus>,
windows: Vec<ApiWindow>,
hooks: Vec<ApiHookExecution>,
) -> StateResponse {
Expand All @@ -171,6 +191,7 @@ pub fn build_state_response(
services_by_project,
hidden_project_ids,
size_map,
agent_statuses,
);
let folders = build_folders(&data.folders);

Expand Down
1 change: 1 addition & 0 deletions crates/okena-app-core/src/workspace/actions/execute/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1208,6 +1208,7 @@ mod reconnect_shell_tests {
is_remote: false,
connection_id: None,
service_terminals: HashMap::new(),
agent_sessions: HashMap::new(),
default_shell,
hook_terminals: HashMap::new(),
pinned: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -744,6 +744,7 @@ mod hook_action_tests {
is_remote: false,
connection_id: None,
service_terminals: HashMap::new(),
agent_sessions: HashMap::new(),
default_shell: None,
hook_terminals,
pinned: false,
Expand Down Expand Up @@ -1048,6 +1049,7 @@ mod set_show_in_overview_tests {
is_remote: false,
connection_id: None,
service_terminals: HashMap::new(),
agent_sessions: Default::default(),
default_shell: None,
hook_terminals: HashMap::new(),
pinned: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -986,6 +986,7 @@ mod tests {
is_remote: false,
connection_id: None,
service_terminals: HashMap::new(),
agent_sessions: HashMap::new(),
default_shell: None,
hook_terminals,
pinned: false,
Expand Down
1 change: 1 addition & 0 deletions crates/okena-app/src/views/overlays/project_switcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,7 @@ mod tests {
is_remote: false,
connection_id: None,
service_terminals: HashMap::new(),
agent_sessions: Default::default(),
default_shell: None::<ShellType>,
hook_terminals: HashMap::<String, HookTerminalEntry>::new(),
pinned: false,
Expand Down
1 change: 1 addition & 0 deletions crates/okena-app/src/views/panels/project_column.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1258,6 +1258,7 @@ mod tests {
is_remote: false,
connection_id: None,
service_terminals: HashMap::new(),
agent_sessions: HashMap::new(),
default_shell: None,
hook_terminals: HashMap::new(),
pinned: false,
Expand Down
1 change: 1 addition & 0 deletions crates/okena-cli/src/resolve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,7 @@ mod tests {
show_in_overview: true,
layout,
terminal_names,
terminal_agent_status: std::collections::HashMap::new(),
git_status: None,
folder_color: FolderColor::Default,
services: vec![],
Expand Down
88 changes: 88 additions & 0 deletions crates/okena-core/src/agent_harness.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
//! Per-harness agent capabilities — *resume a session* and *parse a transcript*
//! — keyed by agent id, kept **gpui-free** so they work in the desktop app AND
//! in headless/remote (where resume/stats may be driven from a mobile client).
//!
//! The agent-status protocol is harness-agnostic: a pane reports its `agent` id
//! (`"claude-code"`, `"codex"`, …) plus a `session_id` via `OSC 9001` (see
//! [`crate::agent_session`]). This registry is how Okena turns that id into
//! actions, without baking any single agent's specifics into the core or app.
//! Each harness implementation lives in its matching `okena-ext-*` crate and is
//! registered at startup via [`init`]; an unknown id simply has no entry, so its
//! session is stored/displayed but not resumable until a harness for it exists.
//! New harnesses are therefore purely additive.

use std::collections::HashMap;
use std::path::Path;
use std::sync::{Arc, OnceLock};

/// Best-effort stats parsed from a session transcript, for the session-info
/// view. Each harness fills what its format exposes; absent fields stay `None`.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct TranscriptStats {
pub user_messages: usize,
pub assistant_messages: usize,
pub tool_calls: usize,
pub input_tokens: Option<u64>,
pub output_tokens: Option<u64>,
}

/// What one agent harness (Claude Code, Codex, …) knows how to do with its own
/// sessions. Implemented in the matching `okena-ext-*` crate. Must stay
/// gpui-free so headless/remote can use it too.
pub trait AgentHarness: Send + Sync {
/// Harness id; must equal the `agent` id agents report on the wire
/// (e.g. `"claude-code"`, `"codex"`).
fn id(&self) -> &str;

/// Build the argv that resumes `session_id` from working dir `cwd`, or
/// `None` if this harness can't / shouldn't resume. `session_id` is already
/// [`is_uuid_like`](crate::agent_session::is_uuid_like)-validated by the
/// caller. The argv is launched as a command in the pane and is **never**
/// passed through a shell, so no quoting/escaping is needed here.
fn resume_command(&self, session_id: &str, cwd: &Path) -> Option<Vec<String>>;

/// Parse a transcript file into [`TranscriptStats`], or `None` when the path
/// is unreadable / unsupported. Best-effort — partial stats are fine.
fn transcript_stats(&self, transcript_path: &Path) -> Option<TranscriptStats> {
let _ = transcript_path;
None
}
}

/// Registry of harnesses keyed by [`AgentHarness::id`]. Built once at startup
/// (desktop + headless) and installed via [`init`].
#[derive(Default)]
pub struct AgentHarnessRegistry {
by_id: HashMap<String, Arc<dyn AgentHarness>>,
}

impl AgentHarnessRegistry {
pub fn new() -> Self {
Self::default()
}

/// Register a harness (last registration for a given id wins).
pub fn register(&mut self, harness: Arc<dyn AgentHarness>) {
self.by_id.insert(harness.id().to_string(), harness);
}

pub fn get(&self, agent_id: &str) -> Option<&Arc<dyn AgentHarness>> {
self.by_id.get(agent_id)
}
}

static REGISTRY: OnceLock<AgentHarnessRegistry> = OnceLock::new();

/// Install the process-wide harness registry. Call once at startup; later calls
/// are ignored (first wins), so desktop and headless each build their own
/// before serving requests.
pub fn init(registry: AgentHarnessRegistry) {
let _ = REGISTRY.set(registry);
}

/// Look up the harness for an agent id, if one is registered. Returns `None`
/// before [`init`] or for an unknown id (caller treats that as "no resume/stats
/// for this agent").
pub fn for_agent(agent_id: &str) -> Option<&'static Arc<dyn AgentHarness>> {
REGISTRY.get()?.get(agent_id)
}
82 changes: 82 additions & 0 deletions crates/okena-core/src/agent_session.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
//! Durable agent session identity captured from the agent-status OSC's `lbl=`.
//!
//! Unlike [`crate::agent_status::AgentStatus`] (ephemeral, runtime-only — it
//! drives the indicator and is dropped on `st=clear` / restart), this is the
//! **sticky** identity of the AI session running in a pane: its `session_id`
//! (and transcript path). It is captured from a `session_id` label, survives
//! `st=clear`, and is meant to be *persisted* so a pane can offer to resume its
//! session (`claude --resume <id>`) after a restart and surface transcript
//! stats. Kept deliberately separate from the ephemeral status so that status
//! can stay runtime-only.
//!
//! The values arrive in-band from an **untrusted** byte stream (any process in
//! the pane can emit the OSC), so [`is_uuid_like`] gates the `session_id` before
//! it is ever stored or handed to a resume command.

use serde::{Deserialize, Serialize};

/// The agent session running (or last run) in a pane, captured from the
/// agent-status OSC `lbl=` `agent` / `session_id` / `transcript_path` keys.
///
/// Deliberately harness-agnostic: the [`agent`](Self::agent) id selects which
/// harness knows how to *resume* it and *parse* its transcript (Claude Code,
/// Codex, …) via the harness registry. An unknown agent id is still stored and
/// displayed — it just has no resume/stats until a harness for it is
/// registered, so new harnesses are additive.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AgentSession {
/// Harness id that produced this session — matches the extension id
/// (`"claude-code"`, `"codex"`, …). Selects the per-harness resume command
/// and transcript parser. Free-form on the wire so a new harness needs no
/// core change.
pub agent: String,
/// The agent's own session id (e.g. Claude Code / Codex `session_id`).
/// Always [`is_uuid_like`]-validated before construction here, since it is
/// untrusted in-band data that may later be passed to a resume command.
pub session_id: String,
/// Absolute path to the session transcript, when the agent reported one.
/// Format/location is the harness's concern; here it is just an opaque path
/// handed to that harness's transcript parser. Drives the stats view.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub transcript_path: Option<String>,
}

/// Conservative check that `s` is a canonical UUID (`8-4-4-4-12` lowercase- or
/// uppercase-hex groups joined by hyphens). Guards the in-band, untrusted
/// `session_id` before it is stored or used to build a resume command, so a
/// hostile pane can't plant an arbitrary string there.
pub fn is_uuid_like(s: &str) -> bool {
const GROUPS: [usize; 5] = [8, 4, 4, 4, 12];
let mut parts = s.split('-');
for len in GROUPS {
match parts.next() {
Some(p) if p.len() == len && p.bytes().all(|b| b.is_ascii_hexdigit()) => {}
_ => return false,
}
}
// Reject trailing junk after the final group ("…-12345-extra").
parts.next().is_none()
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn accepts_canonical_uuid() {
assert!(is_uuid_like("3b9c1f2a-4d5e-6f70-8a9b-0c1d2e3f4a5b"));
assert!(is_uuid_like("3B9C1F2A-4D5E-6F70-8A9B-0C1D2E3F4A5B"));
}

#[test]
fn rejects_non_uuid() {
assert!(!is_uuid_like(""));
assert!(!is_uuid_like("not-a-uuid"));
assert!(!is_uuid_like("3b9c1f2a4d5e6f708a9b0c1d2e3f4a5b")); // no hyphens
assert!(!is_uuid_like("3b9c1f2a-4d5e-6f70-8a9b-0c1d2e3f4a5b-extra")); // trailing
assert!(!is_uuid_like("zzzzzzzz-4d5e-6f70-8a9b-0c1d2e3f4a5b")); // non-hex
assert!(!is_uuid_like("3b9c1f2a-4d5e-6f70-8a9b-0c1d2e3f4a5")); // short group
// Defends against an injection attempt smuggled as a session id.
assert!(!is_uuid_like("$(rm -rf ~)"));
}
}
Loading
Loading