Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
81fed11
feat(agent-status): lifecycle indicator, session capture, persistence…
matej21 Jun 23, 2026
2df444c
fix(agent-status): complete workspace fixtures
matej21 Aug 3, 2026
3012523
fix(agent-status): read session identity before the display clamp
matej21 Aug 4, 2026
defadd0
fix(agent-status): move session resume and lifecycle onto the daemon
matej21 Aug 4, 2026
e3d3644
feat(agent-status): hydrate agent status from remote snapshots
matej21 Aug 4, 2026
bac54f6
fix(agent-status): address the status at its pane and prefer the live…
matej21 Aug 4, 2026
fe72cc4
fix(navigation): reveal off-screen projects from one window-aware jump
matej21 Aug 4, 2026
26ce4de
fix(agent-status): address panes by their raw id and bound what reach…
matej21 Aug 4, 2026
8cd995e
fix(agent-status): make the harness registry reachable from the daemon
matej21 Aug 4, 2026
622ccac
fix(agent-status): make the hook reach Okena under a session backend
matej21 Aug 4, 2026
2c824cb
fix(agent-status): keep the captured session attached to its pane
matej21 Aug 4, 2026
661143f
fix(agent-status): route every jump through the revealing primitive
matej21 Aug 4, 2026
eddbd4e
docs(agent-status): correct what the docs promise and surface the set…
matej21 Aug 4, 2026
9da5f2f
fix(agent-status): stop tinting the pane border by lifecycle
matej21 Aug 5, 2026
69611a3
fix(agent-status): point hooks at the pane's current pty, not its fir…
matej21 Aug 5, 2026
770b237
style: apply rustfmt to the agent-status changes
matej21 Aug 5, 2026
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
13 changes: 11 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -170,8 +170,17 @@ jobs:
- name: Build web client
run: cd web && bun install --frozen-lockfile && bun run build

- name: Build
run: cargo build --release --target ${{ matrix.target }} -p okena -p okena-daemon
# Two invocations on purpose. Cargo unifies features across every package
# selected in ONE invocation, so `-p okena -p okena-daemon` together would
# turn on `okena-workspace/gpui` (which `okena` needs) for the daemon's
# copy too — linking gpui and its X11/Wayland/Vulkan chain into the very
# binary the GPUI-free gate exists to keep clean. The gate runs
# `cargo tree -p okena-daemon` alone, so it would not see it.
- name: Build app
run: cargo build --release --target ${{ matrix.target }} -p okena

- name: Build daemon (separate resolution — keeps it GPUI-free)
run: cargo build --release --target ${{ matrix.target }} -p okena-daemon

- name: Prepare artifact (Linux)
if: runner.os == 'Linux'
Expand Down
10 changes: 10 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[workspace]
members = [".", "crates/okena-mobile-ffi", "crates/okena-core", "crates/okena-transport", "crates/okena-git", "crates/okena-views-git", "crates/okena-views-services", "crates/okena-views-sidebar", "crates/okena-views-terminal", "crates/okena-terminal", "crates/okena-layout", "crates/okena-state", "crates/okena-hooks", "crates/okena-workspace", "crates/okena-ui", "crates/okena-usage", "crates/okena-files", "crates/okena-markdown", "crates/okena-extensions", "crates/okena-ext-claude", "crates/okena-ext-codex", "crates/okena-ext-github", "crates/okena-ext-updater", "crates/okena-services", "crates/okena-remote-client", "crates/okena-remote-server", "crates/okena-views-remote", "crates/okena-theme", "crates/okena-cli", "crates/okena-app-core", "crates/okena-app", "crates/okena-daemon-core", "crates/okena-daemon", "crates/okena-tui"]
members = [".", "crates/okena-mobile-ffi", "crates/okena-core", "crates/okena-transport", "crates/okena-git", "crates/okena-views-git", "crates/okena-views-services", "crates/okena-views-sidebar", "crates/okena-views-terminal", "crates/okena-terminal", "crates/okena-layout", "crates/okena-state", "crates/okena-hooks", "crates/okena-workspace", "crates/okena-ui", "crates/okena-usage", "crates/okena-files", "crates/okena-markdown", "crates/okena-extensions", "crates/okena-ext-claude", "crates/okena-ext-codex", "crates/okena-ext-github", "crates/okena-ext-updater", "crates/okena-services", "crates/okena-remote-client", "crates/okena-remote-server", "crates/okena-views-remote", "crates/okena-theme", "crates/okena-cli", "crates/okena-app-core", "crates/okena-app", "crates/okena-daemon-core", "crates/okena-daemon", "crates/okena-tui", "crates/okena-agent-harnesses"]
resolver = "2"

[workspace.package]
Expand Down Expand Up @@ -35,6 +35,10 @@ okena-ui = { path = "crates/okena-ui" }
okena-remote-server = { path = "crates/okena-remote-server" }
okena-daemon-core = { path = "crates/okena-daemon-core" }

# Built-in agent harnesses (resume argv per agent id), installed at startup.
# gpui-free on purpose so the standalone daemon binary links the same ones.
okena-agent-harnesses = { path = "crates/okena-agent-harnesses" }

# Extension system — registered at startup in main.rs.
okena-extensions = { path = "crates/okena-extensions" }
okena-ext-claude = { path = "crates/okena-ext-claude" }
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ Settings are stored in the platform's config directory (macOS: `~/Library/Applic
| Guide | Description |
|-------|-------------|
| [Configuration](docs/configuration.md) | Settings, keybindings, custom themes, per-project overrides |
| [Agent Status](docs/agent-status.md) | OSC 9001 agent lifecycle, the Claude Code plugin, session resume |
| [Lifecycle Hooks](docs/hooks.md) | Hook terminals, git hooks, environment variables |
| [Project Services](docs/services.md) | okena.yaml, Docker Compose integration, auto-restart |
| [Git Worktrees](docs/worktrees.md) | Worktree management, sync watcher, path templates |
Expand Down
13 changes: 13 additions & 0 deletions crates/okena-agent-harnesses/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
[package]
name = "okena-agent-harnesses"
version.workspace = true
edition = "2024"
license = "MIT"

# Deliberately minimal, and deliberately GPUI-free: this crate is linked by the
# standalone `okena-daemon` binary, which CI gates against any gpui in its
# dependency tree. Keep it that way — anything needing a UI belongs in the
# matching `okena-ext-*` crate instead.
[dependencies]
okena-core = { path = "../okena-core" }
log = "0.4"
130 changes: 130 additions & 0 deletions crates/okena-agent-harnesses/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
//! The built-in [`AgentHarness`] implementations, and the one place they are
//! installed into the process-wide registry.
//!
//! # Why these don't live in the `okena-ext-*` crates
//!
//! They used to. A harness is dispatched by the `agent` id a pane reports over
//! `OSC 9001`, and the natural home looked like the matching extension crate —
//! `okena-ext-claude` for `"claude-code"`, and so on. But those crates depend on
//! `gpui` (they also ship status-bar widgets and a settings view), and the
//! standalone `okena-daemon` binary is CI-gated to be **GPUI-free**. It
//! therefore could not link them, could not install the registry, and its
//! `for_agent()` lookups all returned `None` — auto-resume was a silent no-op in
//! exactly the deployment that owns restore.
//!
//! A harness is pure data — an id and a fixed argv — so it costs nothing to keep
//! it out of the UI crates. This crate is that home: gpui-free, linked by both
//! the desktop binary and the daemon binary, so both resolve the same harnesses.
//!
//! Adding a harness stays additive: implement [`AgentHarness`] here and register
//! it in [`build_registry`].
//!
//! # Relationship to the extension toggle
//!
//! Registration here is **not** gated on the `okena-extensions` enable/disable
//! toggle that `okena-ext-claude` / `okena-ext-codex` carry. That toggle governs
//! their GPUI status-bar widgets and settings view, and it lives behind a GPUI
//! global the daemon cannot read. Resume has its own opt-in — the
//! `auto_resume_agent_sessions` setting, read daemon-side — which is the gate
//! that actually applies to it.

use okena_core::agent_harness::{AgentHarness, AgentHarnessRegistry};
use std::path::Path;
use std::sync::Arc;

/// Claude Code (`claude` CLI). Agent id `"claude-code"` — matches the extension
/// id and the `OKENA_AGENT` the bundled lifecycle plugin sets.
pub struct ClaudeHarness;

impl AgentHarness for ClaudeHarness {
fn id(&self) -> &str {
"claude-code"
}

fn resume_command(&self, session_id: &str, _cwd: &Path) -> Option<Vec<String>> {
// `claude --resume <id>` resumes a specific conversation. Claude scopes
// session lookup to the cwd it runs in, which is exactly the pane's
// restored working directory — so cwd needs no special handling here.
// `session_id` is already UUID-validated upstream and is passed as a
// distinct argv element (never shell-interpolated).
Some(vec![
"claude".to_string(),
"--resume".to_string(),
session_id.to_string(),
])
}
}

/// Codex (`codex` CLI). Agent id `"codex"` — matches the extension id.
///
/// Registration-only for now: the resume invocation is unconfirmed, so this
/// declines rather than guessing. `okena-ext-codex` already parses
/// `~/.codex/sessions/**.jsonl`, which is where `transcript_stats` would come
/// from once there is a view that consumes it.
pub struct CodexHarness;

impl AgentHarness for CodexHarness {
fn id(&self) -> &str {
"codex"
}

fn resume_command(&self, _session_id: &str, _cwd: &Path) -> Option<Vec<String>> {
// TODO: confirm Codex's resume CLI invocation before enabling
// auto-resume for Codex. `None` = no auto-resume yet (graceful — the
// session is still captured, persisted, and shown).
None
}
}

/// Build the registry of built-in harnesses.
pub fn build_registry() -> AgentHarnessRegistry {
let mut registry = AgentHarnessRegistry::new();
registry.register(Arc::new(ClaudeHarness));
registry.register(Arc::new(CodexHarness));
registry
}

/// Install the built-in harnesses process-wide.
///
/// Call once during startup, from every binary that can reach a restore path:
/// the desktop app, `okena --headless`, and the standalone `okena-daemon`.
/// Missing the call is not a compile error — it just makes every resume lookup
/// return `None` — which is why [`okena_core::agent_harness::init`] logs when a
/// second install is ignored.
pub fn install() {
okena_core::agent_harness::init(build_registry());
}

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

const UUID: &str = "3b9c1f2a-4d5e-6f70-8a9b-0c1d2e3f4a5b";

#[test]
fn claude_resumes_with_a_fixed_argv() {
assert_eq!(ClaudeHarness.id(), "claude-code");
assert_eq!(
ClaudeHarness.resume_command(UUID, Path::new("/proj")),
Some(vec![
"claude".to_string(),
"--resume".to_string(),
UUID.to_string(),
])
);
}

#[test]
fn codex_declines_until_its_invocation_is_confirmed() {
assert_eq!(CodexHarness.id(), "codex");
assert_eq!(CodexHarness.resume_command(UUID, Path::new("/proj")), None);
}

#[test]
fn the_registry_resolves_every_built_in_id() {
let registry = build_registry();
assert!(registry.get("claude-code").is_some());
assert!(registry.get("codex").is_some());
assert!(registry.get("nope").is_none());
}
}
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
5 changes: 5 additions & 0 deletions crates/okena-app-core/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,11 @@ impl SettingsState {
self.save_and_notify(cx);
}
setting_setter!(set_show_shell_selector, show_shell_selector, bool);
setting_setter!(
set_auto_resume_agent_sessions,
auto_resume_agent_sessions,
bool
);
setting_setter!(
set_terminal_ctrl_c_copies_selection,
terminal_ctrl_c_copies_selection,
Expand Down
Loading
Loading