diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4ce5b961b..e2697e586 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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' diff --git a/Cargo.lock b/Cargo.lock index 0bd28e475..c7ec6c8f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5994,6 +5994,7 @@ dependencies = [ "gpui_platform", "log", "mimalloc", + "okena-agent-harnesses", "okena-app", "okena-cli", "okena-core", @@ -6016,6 +6017,14 @@ dependencies = [ "winresource", ] +[[package]] +name = "okena-agent-harnesses" +version = "0.27.0" +dependencies = [ + "log", + "okena-core", +] + [[package]] name = "okena-app" version = "0.27.0" @@ -6122,6 +6131,7 @@ dependencies = [ "anyhow", "env_logger", "log", + "okena-agent-harnesses", "okena-core", "okena-daemon-core", "okena-remote-server", diff --git a/Cargo.toml b/Cargo.toml index 7e73f95e6..a10441c54 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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] @@ -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" } diff --git a/README.md b/README.md index 3036d19d5..e4e51900b 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/crates/okena-agent-harnesses/Cargo.toml b/crates/okena-agent-harnesses/Cargo.toml new file mode 100644 index 000000000..e659b2629 --- /dev/null +++ b/crates/okena-agent-harnesses/Cargo.toml @@ -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" diff --git a/crates/okena-agent-harnesses/src/lib.rs b/crates/okena-agent-harnesses/src/lib.rs new file mode 100644 index 000000000..baad5b2ef --- /dev/null +++ b/crates/okena-agent-harnesses/src/lib.rs @@ -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> { + // `claude --resume ` 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> { + // 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()); + } +} diff --git a/crates/okena-app-core/src/remote_snapshot.rs b/crates/okena-app-core/src/remote_snapshot.rs index 9d8a53f1d..25c212c26 100644 --- a/crates/okena-app-core/src/remote_snapshot.rs +++ b/crates/okena-app-core/src/remote_snapshot.rs @@ -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` / `Entity`). +//! * GUI: the daemon client command loop (reads an `Entity` / +//! `Entity`). //! * Headless: `okena-daemon-core`'s `command_loop.rs` `daemon_command_loop` //! (reads `Arc>` / `Arc>`). //! @@ -42,12 +42,14 @@ pub fn api_project_visibility(project_id: &str, hidden_project_ids: &HashSet