From 6d67ccc84137031f05e650ec4f1ac47d59b9deb1 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Mon, 5 Oct 2026 17:58:35 -0700 Subject: [PATCH 01/13] fix(codex): validate notifications at arrival, keep embedded mode unconditional --- docs/agent-resume.md | 23 +- docs/commands.md | 2 +- src/harness/assets.rs | 204 +++++++++++--- src/harness/codex.rs | 473 ++++++++++++++++++++++++++------ src/harness/mod.rs | 17 +- src/harness/omp.rs | 1 + src/main.rs | 75 ++++- src/supervisor.rs | 6 + src/supervisor_capture_tests.rs | 156 ++++++++--- tests/daemon_resume.rs | 177 +++++++++--- 10 files changed, 913 insertions(+), 221 deletions(-) diff --git a/docs/agent-resume.md b/docs/agent-resume.md index b872a39..023e45f 100644 --- a/docs/agent-resume.md +++ b/docs/agent-resume.md @@ -86,23 +86,29 @@ You cannot choose a Codex ID at launch. Two overrides are injected into both acc Since Codex 0.157.0, running plain `codex` attaches to a shared background server. With the notify override alone, embedded mode is used and a startup warning is displayed. Set `features.daemon_auto_start=false` to explicitly use embedded mode without the warning. -With embedded mode, the conversation is kept in the task's process. For an instrumented task, the shared server is never used: you cannot find the task in `codex agents` or reach it through `codex queue`. +With embedded mode, the conversation is kept in the task's process. For an instrumented task, the shared server is never used: you cannot find the task in `codex agents` or reach it through `codex queue`. The embedded override is therefore added on every instrumented launch, including those where capture is unavailable: without it, killing the task would kill only the TUI client and leave the conversation running in the shared server. -After each turn, the `agent-turn-complete` JSON argument is written to `FLEETCOM_CAPTURE_FILE`. Notifications are emitted for several threads in one Codex process: the conversation on screen, each spawned sub-agent, and a hidden thread used to title a new session. Only the first is the task's conversation, so do not use the notified `thread-id` directly. Find that thread's rollout under `/sessions///
/` and read its first line to resolve the root of its session tree. Use the thread's own ID for a root, or the root ID recorded in a sub-agent's header. After a completed turn in the resumed conversation, an in-TUI session change is captured this way. +After each turn, Codex runs the notify script with the `agent-turn-complete` JSON as its argument. Notifications are emitted for several threads in one Codex process: the conversation on screen, each spawned sub-agent, and a hidden thread used to title a new session. Only the first is the task's conversation, so do not use the notified `thread-id` directly. The title thread's notification lands before the root's in some sessions and after it in others, so a notification must be validated when it arrives, not when the capture file is read: a later title notification written verbatim would erase the root. -Accept a thread only when exactly one rollout is named for it, with a header in either the root format or the sub-agent format with a root ID. No rollout is saved for the title thread, so always reject it. If the capture is rejected, use the spawn-time ID or preserve the authored command. +The script therefore runs `"$FLEETCOM_BINARY" --codex-notify-v1 "$1"`, an internal mode of the daemon's own binary. The mode accepts only an `agent-turn-complete` payload with a strict UUID `thread-id`. It resolves the Codex home from its own environment as at launch (`CODEX_HOME`, then `$HOME/.codex`), finds the thread's rollout under `/sessions///
/`, and reads its first line to resolve the root of the thread's session tree: the thread's own ID for a root, or the root ID recorded in a sub-agent's header. When the thread is notified, its rollout and header are already on disk. Accept a thread only when exactly one rollout is named for it, with a header in either the root format or the sub-agent format with a root ID. No rollout is saved for the title thread, so always reject it. + +On acceptance, the mode writes the bare root UUID, with no newline, to `..tmp` beside the capture file and renames it over `FLEETCOM_CAPTURE_FILE`, so a reader never sees a partial file. On refusal it writes nothing: the slot only ever moves from one accepted root to another. It prints nothing; its exit status is informational, and the script ignores it. After a completed turn in the resumed conversation, an in-TUI session change is captured this way. + +The capture file format is frozen as v1: exactly one bare UUID. When the file is read, accept it only when its whole contents pass the UUID check, with no trimming. Nothing is reparsed and no rollout is read at that point, so an accepted root stays accepted after Codex compresses old rollouts, and the notification's conversation text never reaches disk. An older script's JSON payload fails that check and is refused: the safe fallback, since the spawn-time ID or the authored command is used instead. Look up only the thread ID from the task's own notification. List the dated directories to find its rollout, then read only the first line. Do not infer ownership from other sessions in the store. -To preserve a configured notifier, read bare top-level keys in `$CODEX_HOME/config.toml` up to the first table header. For a one-line `notify` array of non-empty basic strings, invoke the notifier after writing the capture file. Store its argv in `FLEETCOM_NOTIFY_CHAIN`, joined by newlines, and append the notification payload before invocation. With an absent setting or empty array, run only the capture hook. +`FLEETCOM_BINARY` is the daemon's own executable, probed at each `codex` spawn: it must be an existing regular file with an execute bit. On Linux, `current_exe` reads `/proc/self/exe`, which becomes ` (deleted)` once the binary is replaced on disk under a running daemon; the path then fails the probe. On macOS the path survives a reinstall and names the new binary, so an older release there degrades to an unrecognized flag and no write. Either way, the chained notifier still runs. + +To preserve a configured notifier, read bare top-level keys in `$CODEX_HOME/config.toml` up to the first table header. For a one-line `notify` array of non-empty basic strings, invoke the notifier after the validation step. Store its argv in `FLEETCOM_NOTIFY_CHAIN`, joined by newlines, and append the notification payload before invocation. The script `exec`s the chain whatever happens to fleetcom's part: a missing binary, a refusal, or a crash. With an absent setting or empty array, run only the validation step. -Do not inject the capture hook for empty arguments, newlines, or NUL: these cannot be represented in the chain encoding. Also skip injection when `config.toml` exists but cannot be read, or when a line above the first table header is not a bare key assigned a recognized single-line value. Neither override is added in these cases: no ID is captured, and the mode is selected by Codex. +The notify override and its environment are injected only when the configured route can be chained and the binary passes the probe. Do not inject them for empty arguments, newlines, or NUL: these cannot be represented in the chain encoding. Also skip them when `config.toml` exists but cannot be read, or when a line above the first table header is not a bare key assigned a recognized single-line value. In each case the launch carries the embedded override alone, no ID is captured, and a status line says why: `` `notify` config can't be chained ``, or `fleetcom binary replaced; restart the daemon`. -Live validation on 2026-09-28 was performed with the official macOS arm64 Codex 0.157.1 and 0.158.0 executables, isolated homes, and a scripted loopback Responses server. In both releases, the notification JSON was preserved in the capture file and passed to the configured notifier by the real capture script. After saving and loading through foreground fleetcom, the same thread ID was present and its history was reopened. Both releases were tested with `--no-daemon`. The ID was also captured and saved in separate default-launch probes, but no managed Codex daemon was installed or running in those fresh homes. Real-account behavior was not verified. +Live validation on 2026-09-28 was performed with the official macOS arm64 Codex 0.157.1 and 0.158.0 executables, isolated homes, and a scripted loopback Responses server. In both releases, the notification JSON was written by the capture script of that date, which copied it verbatim, and passed to the configured notifier. After saving and loading through foreground fleetcom, the same thread ID was present and its history was reopened. Both releases were tested with `--no-daemon`. The ID was also captured and saved in separate default-launch probes, but no managed Codex daemon was installed or running in those fresh homes. Real-account behavior was not verified. The [recorded terminal fixtures](../tests/corpus/codex_0158_terminal/README.md) preserve resumed-history and clipboard output from those runs. Offline replay checks the recorded display and OSC 52 forwarding; it does not run the notifier or execute save/load. The separate `tests/daemon_resume.rs` integration tests exercise session resume with stub agents. -Live validation on 2026-10-04 was performed with codex-cli 0.160.0. Three thread IDs were reported from one process: the unsaved title thread, a sub-agent, and the root. When resuming the title thread, "No saved session found" was reported; for the sub-agent, "cannot resume an unloaded multi-agent v2 sub-agent through its parent" was reported. With the notify override alone, `⚠ 1 warning` was displayed in the footer; with both overrides, none was displayed. With an unknown `features.*` key, startup completed with an unrecognized-setting warning. No Codex version predating `daemon_auto_start` was tested. On the same date, 493 local rollout headers from Codex 0.135.0 through 0.160.0 were examined: `id` and `session_id` were present in each. In Codex 0.140.0 and 0.141.0, a sub-agent's own ID was stored as its `session_id`; that format is rejected. +Live validation on 2026-10-04 was performed with codex-cli 0.160.0 over nine TUI sessions. Three thread IDs were reported from one process: the unsaved title thread, a sub-agent, and the root. The title thread's notification arrived before the root's in three sessions and after it in six, and no rollout appeared for it even minutes later. For a root or sub-agent turn, the thread's rollout header was on disk at least 2.5 s before its notification, and `task_complete` landed 21–256 ms before it. When resuming the title thread, "No saved session found" was reported; for the sub-agent, "cannot resume an unloaded multi-agent v2 sub-agent through its parent" was reported. With the notify override alone, `⚠ 1 warning` was displayed in the footer; with both overrides, none was displayed. With an unknown `features.*` key, startup completed with an unrecognized-setting warning. No Codex version predating `daemon_auto_start` was tested. On the same date, 493 local rollout headers from Codex 0.135.0 through 0.160.0 were examined: `id` and `session_id` were present in each. In Codex 0.140.0 and 0.141.0, a sub-agent's own ID was stored as its `session_id`; that format is rejected. ### `grok` @@ -169,7 +175,7 @@ Before inserting a captured ID into a shell command, accept only lowercase hexad Implement the `Harness` trait in [`src/harness/mod.rs`](../src/harness/mod.rs) for each tool. Register it in that file's `AGENTS` table, the sole registry for detection. Include a `SummaryAdapter` for the dashboard preview with each entry; see the implementations in [`src/harness/summary.rs`](../src/harness/summary.rs). Separate detection, evidence collection, and command construction through these methods: - `shape`: supply the program word and resume selector. Accepted and canonical forms are derived from that pair by the default `detect` and `resume_command` implementations. -- `instrument`: return spawn-time arguments, environment entries, and an optional pinned ID. +- `instrument`: return spawn-time arguments, environment entries, an optional pinned ID, and an optional notice explaining reduced instrumentation, which the supervisor reports on the status line once the task spawns. - `parse_capture`: read an ID from the capture file's contents. The task leader's PID and the launch-time home are supplied; when other processes can reach the capture channel, use them to refuse a payload the task's own process did not write. - `live_session_id`: read the ID published on disk for a live session. Return `None` by default when no registry is available. - `live_blocked_status`: read that registry for blocked-on-user status. Return preview text, never an ID; return `None` by default. @@ -184,5 +190,6 @@ The launch environment is passed to `resolve_home` by the supervisor. For Claude | `FLEETCOM_RUNTIME_DIR` | Explicit capture-asset root as well as the daemon runtime override. | | `FLEETCOM_CAPTURE_FILE` | Per-run capture file used by the injected hook, notifier, or extension module. | | `FLEETCOM_NOTIFY_CHAIN` | Newline-joined argv for the configured Codex notifier; empty when none is active. | +| `FLEETCOM_BINARY` | The daemon's own executable, which the injected Codex notifier runs as `--codex-notify-v1` to validate each notification; absent when that path is unusable. | | `CLAUDE_CONFIG_DIR` | Claude home holding the `sessions/.json` registry; defaults to `$HOME/.claude`. | | `CODEX_HOME` | Codex home holding `config.toml` for notify routing and the `sessions/` rollouts; defaults to `$HOME/.codex`. | diff --git a/docs/commands.md b/docs/commands.md index 1590d18..bf094f9 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -14,7 +14,7 @@ Choose the operating mode with launch arguments. Use keys to control the dashboa | `fleetcom --help` / `-h` | Print usage and exit | | `fleetcom --version` / `-V` | Print the version and exit | -`--daemon` is internal. The daemon is started automatically when needed. The first non-`-` argument is treated as the session name; a second is rejected. +`--daemon` and `--codex-notify-v1` are internal. The daemon is started automatically when needed; the notify mode is run by the notifier injected into `codex` tasks, see [agent session resume](agent-resume.md#codex). The first non-`-` argument is treated as the session name; a second is rejected. ## Dashboard diff --git a/src/harness/assets.rs b/src/harness/assets.rs index 07bc8d5..acfee88 100644 --- a/src/harness/assets.rs +++ b/src/harness/assets.rs @@ -17,13 +17,20 @@ //! environment. Write the parent Claude process's PID on the first line, //! followed by the JSON payload from stdin, unmodified. //! - `codex`: `-c notify=[""]` names an executable that -//! `codex` invokes with notification JSON. The script writes its first -//! argument verbatim (no trailing newline) over `$FLEETCOM_CAPTURE_FILE`, -//! skipping the write when the variable is unset or empty. When +//! `codex` invokes with notification JSON once per completed turn of any +//! thread: the conversation on screen, each sub-agent, and the hidden +//! title thread. When `$FLEETCOM_CAPTURE_FILE` and `$FLEETCOM_BINARY` are +//! both non-empty, the script runs +//! `"$FLEETCOM_BINARY" --codex-notify-v1 "$1"` and ignores its status. +//! That mode validates at arrival: it resolves the notified thread to its +//! root through the rollout header and replaces the capture file with the +//! bare root UUID, atomically, only for an accepted root. A refused thread +//! writes nothing, so the title thread cannot erase the root. The slot +//! format is frozen as v1: exactly one UUID, no newline. When //! `$FLEETCOM_NOTIFY_CHAIN` is non-empty the script then execs that //! newline-joined argv with the payload appended, so the displaced notifier //! receives the same final argument `codex` would have passed; otherwise -//! exit 0. +//! exit 0. The chain runs whatever happens to fleetcom's part. //! - `omp`: load an extension module inside the agent's own process through //! `-e `, appending to the user's extensions. On //! `session_start`, `session_switch`, `session_branch`, and `agent_end`, @@ -44,13 +51,17 @@ use std::{ use super::CapturePaths; use crate::task::{pid_is_dead, positive_pid}; -/// Notify program injected into `codex`. It writes the capture payload when a -/// path exists, then replaces itself with the configured notifier when present. +/// Notify program injected into `codex`. It hands the payload to fleetcom's +/// `--codex-notify-v1` mode when a capture path and binary are both set, then +/// replaces itself with the configured notifier when present. const CODEX_NOTIFY_SCRIPT: &str = r#"#!/bin/sh -# Write the capture before replacing this process with the chained notifier. -if [ -n "$FLEETCOM_CAPTURE_FILE" ]; then - # Write the notification JSON without a trailing newline. - printf '%s' "$1" > "$FLEETCOM_CAPTURE_FILE" +# Validate the notification in fleetcom before replacing this process with +# the chained notifier. The binary rewrites the capture file only for an +# accepted root thread; a refusal writes nothing. Run it as a child, never +# exec it, and ignore its status: the chain below must run whether the binary +# is missing, refuses, or crashes. +if [ -n "$FLEETCOM_CAPTURE_FILE" ] && [ -n "$FLEETCOM_BINARY" ]; then + "$FLEETCOM_BINARY" --codex-notify-v1 "$1" fi [ -n "$FLEETCOM_NOTIFY_CHAIN" ] || exit 0 # The chain variable holds the displaced notifier's argv, newline-joined. @@ -275,17 +286,36 @@ impl CaptureAssets { } /// Return the installed asset paths plus the capture path for one task run. - /// Including the run number prevents reruns from sharing payloads. + /// Including the run number prevents reruns from sharing payloads. The + /// binary is probed on every call: it can disappear between launches. pub fn paths_for(&self, task_id: u64, run: u32) -> CapturePaths { CapturePaths { capture_file: self.dir.join(format!("task-{task_id}-{run}.json")), claude_settings: self.claude_settings.clone(), codex_notify: self.codex_notify.clone(), omp_capture: self.omp_capture.clone(), + fleetcom_binary: fleetcom_binary(), } } } +/// This process's executable when it is still a regular file with an +/// execute bit. On Linux, `current_exe` reads `/proc/self/exe`, which becomes +/// ` (deleted)` once the binary is replaced on disk; that path fails +/// the metadata check. On macOS the path survives a reinstall and names the +/// new binary, so an older release there degrades to an unrecognized flag +/// and no capture write. +fn fleetcom_binary() -> Option { + std::env::current_exe() + .ok() + .filter(|exe| executable_file(exe)) +} + +/// Whether `path` is a regular file with any execute bit, following symlinks. +fn executable_file(path: &Path) -> bool { + fs::metadata(path).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0) +} + /// Remove this supervisor's capture namespace on drop. impl Drop for CaptureAssets { fn drop(&mut self) { @@ -305,7 +335,7 @@ mod tests { use super::*; use crate::{ harness::{ - CAPTURE_ENV, Claude, Harness, NOTIFY_CHAIN_ENV, Omp, + BINARY_ENV, CAPTURE_ENV, Claude, Harness, NOTIFY_CHAIN_ENV, Omp, fixtures::{ID, OTHER}, }, testutil::{dead_pid, install_fake_notifier, temp, write_executable}, @@ -567,60 +597,89 @@ console.log(Object.keys(handlers).join(" ")); } } - /// The notifier overwrites the capture file with its first argument. With - /// no capture path or chain, it exits without producing output. + /// Stand-in for `fleetcom --codex-notify-v1` at `path`: record argv into + /// `record`, write [`ID`] over the inherited capture file, and exit 3. + /// The nonzero status proves the script ignores it. + fn install_fake_binary(path: &Path, record: &Path) { + write_executable( + path, + &format!( + "printf '%s\\n' \"$@\" > '{}'\nprintf '%s' '{ID}' > \"${CAPTURE_ENV}\"\nexit 3", + record.display() + ), + ); + } + + /// The script runs the binary only when both the capture path and the + /// binary are set, passing the payload as the mode's sole argument. With + /// neither chain nor binary, it exits 0 without output. #[test] - fn notify_script_writes_the_argument_verbatim() { + fn notify_script_runs_the_binary_only_with_a_capture_path_and_binary() { let root = temp("assets_notify"); let assets = CaptureAssets::install(&root, std::process::id()).unwrap(); let cap = assets.paths_for(1, 0).capture_file; + let binary = root.join("fleetcom"); + let record = root.join("binary-argv"); + install_fake_binary(&binary, &record); let payload = r#"{"type":"agent-turn-complete","turn-id":"t1"}"#; - // Unset and empty env: exit 0, no output, no file. - for setup in [None, Some("")] { + // Unset or empty capture path, or unset or empty binary: exit 0, no + // output, no binary run, no file. + for (capture, bin) in [ + (None, Some(binary.as_os_str())), + (Some(""), Some(binary.as_os_str())), + (Some(cap.to_str().unwrap()), None), + (Some(cap.to_str().unwrap()), Some("".as_ref())), + ] { let mut cmd = Command::new("sh"); cmd.arg(&assets.codex_notify).arg(payload); cmd.env_remove(NOTIFY_CHAIN_ENV); - match setup { + match capture { Some(v) => cmd.env(CAPTURE_ENV, v), None => cmd.env_remove(CAPTURE_ENV), }; + match bin { + Some(v) => cmd.env(BINARY_ENV, v), + None => cmd.env_remove(BINARY_ENV), + }; let out = cmd.output().unwrap(); - assert!(out.status.success(), "{setup:?}"); - assert!(out.stdout.is_empty() && out.stderr.is_empty(), "{setup:?}"); - assert!(!cap.exists(), "{setup:?}"); + assert!(out.status.success(), "{capture:?} {bin:?}"); + assert!( + out.stdout.is_empty() && out.stderr.is_empty(), + "{capture:?} {bin:?}" + ); + assert!(!record.exists(), "{capture:?} {bin:?}: the binary ran"); + assert!(!cap.exists(), "{capture:?} {bin:?}"); } - let out = Command::new("sh") - .arg(&assets.codex_notify) - .arg(payload) - .env(CAPTURE_ENV, &cap) - .env_remove(NOTIFY_CHAIN_ENV) - .output() - .unwrap(); - assert!(out.status.success()); - assert_eq!(fs::read(&cap).unwrap(), payload.as_bytes()); - - // Direct execution overwrites rather than appends. An empty chain - // reads as absent: write, then exit 0 without exec. - let second = r#"{"type":"agent-turn-complete","turn-id":"t2"}"#; + // Both set and an empty chain, which reads as absent: the binary runs + // with the mode flag and the payload, its status is dropped, and the + // script exits 0 without exec. let out = Command::new(&assets.codex_notify) - .arg(second) + .arg(payload) .env(CAPTURE_ENV, &cap) + .env(BINARY_ENV, &binary) .env(NOTIFY_CHAIN_ENV, "") .output() .unwrap(); - assert!(out.status.success()); - assert_eq!(fs::read(&cap).unwrap(), second.as_bytes()); + assert!(out.status.success(), "the binary's exit 3 must be ignored"); + assert!(out.stdout.is_empty() && out.stderr.is_empty()); + assert_eq!( + fs::read_to_string(&record).unwrap(), + format!("--codex-notify-v1\n{payload}\n") + ); + assert_eq!(fs::read_to_string(&cap).unwrap(), ID); } - /// A configured chain runs after capture and receives its original argv - /// followed by the payload. Spaces within an argument remain intact. + /// A configured chain runs after the binary and receives its original + /// argv followed by the payload. Spaces within an argument remain intact. #[test] fn notify_script_chains_the_displaced_notifier() { let root = temp("assets_chain"); let assets = CaptureAssets::install(&root, std::process::id()).unwrap(); let cap = assets.paths_for(3, 0).capture_file; + let binary = root.join("fleetcom"); + install_fake_binary(&binary, &root.join("binary-argv")); let notifier = root.join("Fake App.app").join("Sky Client"); let record = root.join("record"); install_fake_notifier(¬ifier, &record); @@ -629,6 +688,7 @@ console.log(Object.keys(handlers).join(" ")); let out = Command::new(&assets.codex_notify) .arg(payload) .env(CAPTURE_ENV, &cap) + .env(BINARY_ENV, &binary) .env( NOTIFY_CHAIN_ENV, format!("{}\nturn-ended", notifier.display()), @@ -636,7 +696,7 @@ console.log(Object.keys(handlers).join(" ")); .output() .unwrap(); assert!(out.status.success()); - assert_eq!(fs::read(&cap).unwrap(), payload.as_bytes()); + assert_eq!(fs::read_to_string(&cap).unwrap(), ID); assert_eq!( fs::read_to_string(&record).unwrap(), format!("turn-ended\n{payload}\n"), @@ -644,13 +704,15 @@ console.log(Object.keys(handlers).join(" ")); ); } - /// The capture write precedes the chained notifier, whose exit status - /// passes through. + /// The binary runs before the chained notifier, whose exit status passes + /// through. #[test] fn notify_script_capture_survives_a_failing_chain() { let root = temp("assets_chain_fail"); let assets = CaptureAssets::install(&root, std::process::id()).unwrap(); let cap = assets.paths_for(4, 0).capture_file; + let binary = root.join("fleetcom"); + install_fake_binary(&binary, &root.join("binary-argv")); let notifier = root.join("failing"); write_executable(¬ifier, "exit 1"); @@ -658,18 +720,23 @@ console.log(Object.keys(handlers).join(" ")); let out = Command::new(&assets.codex_notify) .arg(payload) .env(CAPTURE_ENV, &cap) + .env(BINARY_ENV, &binary) .env(NOTIFY_CHAIN_ENV, ¬ifier) .output() .unwrap(); assert!(!out.status.success(), "exec forwards the notifier's status"); - assert_eq!(fs::read(&cap).unwrap(), payload.as_bytes()); + assert_eq!(fs::read_to_string(&cap).unwrap(), ID); } - /// Without a capture path, the script still execs the configured chain. + /// Without a capture path, the script still execs the configured chain + /// and leaves the binary alone. #[test] fn notify_script_chains_without_a_capture_path() { let root = temp("assets_chain_nocap"); let assets = CaptureAssets::install(&root, std::process::id()).unwrap(); + let binary = root.join("fleetcom"); + let binary_record = root.join("binary-argv"); + install_fake_binary(&binary, &binary_record); let notifier = root.join("bare"); let record = root.join("record"); install_fake_notifier(¬ifier, &record); @@ -677,11 +744,36 @@ console.log(Object.keys(handlers).join(" ")); let out = Command::new(&assets.codex_notify) .arg("payload") .env_remove(CAPTURE_ENV) + .env(BINARY_ENV, &binary) .env(NOTIFY_CHAIN_ENV, ¬ifier) .output() .unwrap(); assert!(out.status.success()); assert_eq!(fs::read_to_string(&record).unwrap(), "payload\n"); + assert!(!binary_record.exists(), "no capture path, no binary run"); + } + + /// A binary that no longer exists, as after a Linux reinstall under a + /// running daemon, must not keep the chain from running. + #[test] + fn notify_script_chains_past_a_missing_binary() { + let root = temp("assets_chain_nobin"); + let assets = CaptureAssets::install(&root, std::process::id()).unwrap(); + let cap = assets.paths_for(6, 0).capture_file; + let notifier = root.join("bare"); + let record = root.join("record"); + install_fake_notifier(¬ifier, &record); + + let out = Command::new(&assets.codex_notify) + .arg("payload") + .env(CAPTURE_ENV, &cap) + .env(BINARY_ENV, root.join("fleetcom (deleted)")) + .env(NOTIFY_CHAIN_ENV, ¬ifier) + .output() + .unwrap(); + assert!(out.status.success(), "the chain's status is the script's"); + assert_eq!(fs::read_to_string(&record).unwrap(), "payload\n"); + assert!(!cap.exists(), "nothing may write the slot but the binary"); } /// Require valid JSON, disabled agent view, and the exact stamped hook @@ -967,5 +1059,29 @@ console.log(Object.keys(handlers).join(" ")); assert_eq!(paths.claude_settings, ns.join("claude-settings.json")); assert_eq!(paths.codex_notify, ns.join("codex-notify.sh")); assert_eq!(paths.omp_capture, ns.join("omp-capture.js")); + // The test executable is a regular file with an execute bit. + assert_eq!( + paths.fleetcom_binary, + Some(std::env::current_exe().unwrap()), + "the daemon's own executable rides every plan" + ); + } + + /// Accept only an existing regular file with an execute bit: a missing + /// path, a plain file, and a directory are all unusable. + #[test] + fn fleetcom_binary_requires_an_executable_regular_file() { + let root = temp("assets_binary"); + let plain = root.join("plain"); + fs::write(&plain, "#!/bin/sh\n").unwrap(); + assert!(!executable_file(&plain), "no execute bit"); + fs::set_permissions(&plain, fs::Permissions::from_mode(0o700)).unwrap(); + assert!(executable_file(&plain)); + assert!( + !executable_file(&root.join("fleetcom (deleted)")), + "missing" + ); + assert!(!executable_file(&root), "a directory"); + assert_eq!(fleetcom_binary(), Some(std::env::current_exe().unwrap())); } } diff --git a/src/harness/codex.rs b/src/harness/codex.rs index d58a195..0512550 100644 --- a/src/harness/codex.rs +++ b/src/harness/codex.rs @@ -1,24 +1,29 @@ -//! A Codex ID cannot be selected at launch. Inject a `notify` override, -//! chain compatible configured notifiers, and explicitly enable embedded mode -//! with a second override. +//! A Codex ID cannot be selected at launch. Explicitly enable embedded mode +//! with one override on every launch, and when the configured notifier can +//! be chained and this binary is usable, inject a `notify` override as well. //! //! Notifications are emitted for every thread in one Codex process: the //! conversation on screen, each spawned sub-agent, and the hidden thread used //! to title a new session. Only the first is the task's conversation. Resuming //! a sub-agent with an unloaded parent or the unsaved title thread exits 1. -//! In `parse_capture`, use the notified thread's rollout header to resolve its -//! session tree's root. Reject threads that cannot be classified. +//! The injected script hands each notification to `fleetcom --codex-notify-v1` +//! ([`record_arrival`]), which resolves the notified thread to its session +//! tree's root through the rollout header and replaces the capture file with +//! the bare root UUID. A thread that cannot be classified writes nothing, so +//! the title thread, whose notification lands before or after the root's, +//! never erases an accepted root. `parse_capture` then accepts the slot only +//! as that one UUID. use std::{ fmt::Write as _, - fs, + fs, io, io::{BufRead, BufReader, Read}, path::{Path, PathBuf}, }; use super::{ - CAPTURE_ENV, CapturePaths, Harness, Invocation, NOTIFY_CHAIN_ENV, SpawnPlan, capture_id, - home_root, resolve_home, shell_quote, + BINARY_ENV, CAPTURE_ENV, CapturePaths, Harness, Invocation, NOTIFY_CHAIN_ENV, SpawnPlan, + capture_id, home_root, is_uuid, resolve_home, shell_quote, }; /// Config override for explicitly launching in embedded mode. @@ -52,6 +57,11 @@ impl Harness for Codex { ("codex", "resume") } + /// Every launch carries the embedded override: without it, `codex` + /// attaches to its shared background server and killing the task kills + /// only the TUI client. The notify override and its environment ride + /// along only when the configured route can be chained and this binary + /// is usable; otherwise the plan explains why capture is off. fn instrument( &self, // Both accepted shapes take the same injection; codex cannot pin an @@ -60,59 +70,119 @@ impl Harness for Codex { capture: &CapturePaths, home: Option<&Path>, ) -> SpawnPlan { + let mut plan = SpawnPlan { + args_suffix: format!(" -c {}", shell_quote(EMBEDDED_OVERRIDE)), + ..SpawnPlan::default() + }; let chain = match config_notify_route(home) { // Set an explicit empty value to exclude an inherited chain from the // injected script. NotifyRoute::Vacant => String::new(), - // Chain the configured notifier after the capture write: exec this argv - // with the payload appended from the injected script. + // Chain the configured notifier after the validation step: exec this + // argv with the payload appended from the injected script. NotifyRoute::Chain(argv) => argv.join("\n"), // Skip injection when the configured route cannot be encoded. - NotifyRoute::Opaque => return SpawnPlan::default(), + NotifyRoute::Opaque => { + plan.notice = Some(format!("{CAPTURE_OFF}: `notify` config can't be chained")); + return plan; + } + }; + // The script runs this binary to validate each notification. Without + // a usable path there is nothing to run, so inject nothing: an + // unvalidated slot would be worse than none. + let Some(binary) = &capture.fleetcom_binary else { + plan.notice = Some(format!( + "{CAPTURE_OFF}: fleetcom binary replaced; restart the daemon" + )); + return plan; }; let toml = format!( "notify=[\"{}\"]", toml_escape(&capture.codex_notify.to_string_lossy()) ); - SpawnPlan { - args_suffix: format!( - " -c {} -c {}", - shell_quote(&toml), - shell_quote(EMBEDDED_OVERRIDE) + plan.args_suffix = format!( + " -c {} -c {}", + shell_quote(&toml), + shell_quote(EMBEDDED_OVERRIDE) + ); + plan.env = vec![ + ( + CAPTURE_ENV.into(), + capture.capture_file.clone().into_os_string(), ), - env: vec![ - ( - CAPTURE_ENV.into(), - capture.capture_file.clone().into_os_string(), - ), - (NOTIFY_CHAIN_ENV.into(), chain.into()), - ], - injected_id: None, - } + (NOTIFY_CHAIN_ENV.into(), chain.into()), + (BINARY_ENV.into(), binary.clone().into_os_string()), + ]; + plan } - /// Accept an `agent-turn-complete` notification and return the root - /// thread of the notified thread's session tree: the conversation the - /// task's TUI is on. For a sub-agent, return its root. If the thread cannot - /// be classified in [`root_thread`], return `None` to try the next ID source. - /// - /// Look up only the thread ID from the task's own notification and - /// classify it from its rollout header. Do not infer conversation - /// ownership from other sessions in the store. + /// Accept the slot only as exactly one bare root UUID: the v1 format + /// written by [`record_arrival`]. No trimming: the writer emits no + /// newline, so trailing whitespace marks a different writer. Validation + /// happened at arrival, when the rollout header was on disk; the payload + /// is not reparsed and no rollout is read here, so a root stays accepted + /// after codex compresses its rollout. An old-format JSON slot fails + /// [`is_uuid`] and is refused. fn parse_capture( &self, payload: &str, // Notifications for every thread originate in the task's own process. _pid: Option, - home: Option<&Path>, + // The home was consumed at arrival. + _home: Option<&Path>, ) -> Option { - let v = jzon::parse(payload).ok()?; - if v["type"].as_str() != Some("agent-turn-complete") { - return None; - } - let thread = capture_id(&v, "thread-id")?; - root_thread(&home_root(home, ".codex")?, &thread) + is_uuid(payload).then(|| payload.to_string()) + } +} + +/// Status-line prefix for a launch that carries the embedded override alone. +const CAPTURE_OFF: &str = "codex capture unavailable"; + +/// The `--codex-notify-v1` mode: validate one notification at arrival and +/// replace the capture file with its root UUID. `env` is the process's own +/// environment, inherited from the `codex` launch: [`CAPTURE_ENV`] names the +/// slot, and the home resolves as in [`Codex::resolve_home`]. +/// +/// Return the root written, or `None` when nothing was written: no capture +/// path, a payload that is not an `agent-turn-complete` for a strict thread +/// ID, a thread [`root_thread`] cannot classify, or a failed write. The slot +/// only ever moves from one accepted root to another. +pub fn record_arrival(payload: &str, env: &dyn Fn(&str) -> Option) -> Option { + let capture = env(CAPTURE_ENV).filter(|p| !p.as_os_str().is_empty())?; + let root = arrival_root(payload, Codex.resolve_home(env).as_deref())?; + replace_slot(&capture, &root).ok()?; + Some(root) +} + +/// Resolve an `agent-turn-complete` notification to the root thread of the +/// notified thread's session tree: the conversation the task's TUI is on. +/// For a sub-agent, that is its root. Return `None` for any other payload +/// type, a non-strict thread ID, or a thread [`root_thread`] cannot classify. +/// +/// Look up only the thread ID from the task's own notification and classify +/// it from its rollout header. Do not infer conversation ownership from +/// other sessions in the store. +fn arrival_root(payload: &str, home: Option<&Path>) -> Option { + let v = jzon::parse(payload).ok()?; + if v["type"].as_str() != Some("agent-turn-complete") { + return None; + } + let thread = capture_id(&v, "thread-id")?; + root_thread(&home_root(home, ".codex")?, &thread) +} + +/// Write `root` to `..tmp` beside the capture file and rename +/// it over the destination, so a reader never sees a partial slot. Remove the +/// temporary file when either step fails. +fn replace_slot(capture: &Path, root: &str) -> io::Result<()> { + let mut tmp = capture.as_os_str().to_owned(); + tmp.push(format!(".{}.tmp", std::process::id())); + let tmp = PathBuf::from(tmp); + let result = fs::write(&tmp, root).and_then(|()| fs::rename(&tmp, capture)); + if result.is_err() { + let _ = fs::remove_file(&tmp); } + result } /// Resolve `thread` to the root thread of its session tree from the header of @@ -195,7 +265,7 @@ fn read_header(path: &Path) -> Option { enum NotifyRoute { /// No active route, so the capture notifier can run alone. Vacant, - /// One representable route, executed after the capture write. + /// One representable route, executed after the validation step. Chain(Vec), /// A route that cannot be represented without changing its argv. Capture /// injection is disabled so the route remains untouched. @@ -358,11 +428,11 @@ fn toml_escape(s: &str) -> String { #[cfg(test)] mod tests { - use std::path::PathBuf; + use std::{ffi::OsString, path::PathBuf}; use super::*; use crate::{ - harness::fixtures::{assert_all_opaque, paths}, + harness::fixtures::{OTHER, assert_all_opaque, paths}, testutil::{Scratch, codex_session_meta, install_codex_rollout, install_codex_root, temp}, }; @@ -376,11 +446,39 @@ mod tests { /// Hidden title thread ID, reported through notify but never saved in a rollout. const TITLE: &str = "019f5453-de9f-7e61-8c0d-1a2b3c4d5e6f"; - /// Suffix of every instrumented launch that uses [`paths`]. + /// Suffix of every fully instrumented launch that uses [`paths`]. const SUFFIX: &str = concat!( r#" -c 'notify=["/tmp/Application Support/notify.sh"]'"#, " -c 'features.daemon_auto_start=false'", ); + /// Suffix of a launch whose capture is off: the embedded override alone. + const EMBEDDED_ONLY: &str = " -c 'features.daemon_auto_start=false'"; + + /// Environment of a fully instrumented launch that uses [`paths`] with + /// `chain` as the encoded notifier argv. + fn full_env(chain: &str) -> Vec<(OsString, OsString)> { + vec![ + ( + CAPTURE_ENV.into(), + PathBuf::from("/tmp/cap/session.json").into_os_string(), + ), + (NOTIFY_CHAIN_ENV.into(), chain.into()), + ( + BINARY_ENV.into(), + PathBuf::from("/tmp/Application Support/fleetcom").into_os_string(), + ), + ] + } + + /// The plan of a launch whose capture is off, explained by `why`. + fn embedded_only(why: &str) -> SpawnPlan { + SpawnPlan { + args_suffix: EMBEDDED_ONLY.into(), + env: Vec::new(), + injected_id: None, + notice: Some(format!("codex capture unavailable: {why}")), + } + } /// Codex-specific opaque shapes: subcommands (including `exec` and /// single-letter aliases), flags, `-c` overrides, `--resume` (the wrong @@ -419,7 +517,9 @@ mod tests { } /// Apply the same two overrides to both accepted forms: the notifier, - /// then explicit embedded mode. No ID can be pinned at launch. + /// then explicit embedded mode. The environment names the capture file, + /// an explicitly empty chain so no inherited value reaches the script, + /// and the binary the script runs. No ID can be pinned at launch. #[test] fn instrument_installs_the_notify_and_embedded_overrides() { for cmd in ["codex".to_string(), format!("codex resume {ID}")] { @@ -427,19 +527,35 @@ mod tests { let plan = Codex.instrument(&inv, &paths(), Some(&no_config_home())); assert_eq!(plan.args_suffix, SUFFIX, "{cmd}"); assert_eq!(plan.injected_id, None, "{cmd}"); + assert_eq!(plan.notice, None, "{cmd}"); + assert_eq!(plan.env, full_env(""), "{cmd}"); + } + } + + /// Without a usable binary, the script would have nothing to run, so the + /// launch carries the embedded override alone and says why. The route + /// is still read first: an opaque route reports its own reason. + #[test] + fn instrument_without_a_usable_binary_keeps_only_the_embedded_override() { + let paths = CapturePaths { + fleetcom_binary: None, + ..paths() + }; + for cmd in ["codex".to_string(), format!("codex resume {ID}")] { + let inv = Codex.detect(&cmd).unwrap(); assert_eq!( - plan.env, - vec![ - ( - CAPTURE_ENV.into(), - PathBuf::from("/tmp/cap/session.json").into_os_string() - ), - // Override any inherited chain with an explicit empty value. - (NOTIFY_CHAIN_ENV.into(), "".into()), - ], + Codex.instrument(&inv, &paths, Some(&no_config_home())), + embedded_only("fleetcom binary replaced; restart the daemon"), "{cmd}" ); } + let home = temp("codex_no_binary_opaque"); + fs::write(home.join("config.toml"), "notify = [1]\n").unwrap(); + let inv = Codex.detect("codex").unwrap(); + assert_eq!( + Codex.instrument(&inv, &paths, Some(&home)), + embedded_only("`notify` config can't be chained") + ); } /// Pass a representable `notify` assignment through [`NOTIFY_CHAIN_ENV`]. Run @@ -459,7 +575,7 @@ mod tests { // Missing config file: plain injection, and the chain is present but // empty. let plan = Codex.instrument(&inv, &paths(), Some(&home)); - assert!(!plan.args_suffix.is_empty()); + assert_eq!(plan.args_suffix, SUFFIX); assert_eq!(chained(&plan), Some("".into())); let cfg = home.join("config.toml"); @@ -471,10 +587,9 @@ mod tests { ] { fs::write(&cfg, active).unwrap(); let plan = Codex.instrument(&inv, &paths(), Some(&home)); - assert!(!plan.args_suffix.is_empty(), "{active:?}"); - assert_eq!(chained(&plan), Some("/my/thing".into()), "{active:?}"); - // Include the capture environment when chaining a notifier. - assert!(plan.env.iter().any(|(k, _)| k == CAPTURE_ENV), "{active:?}"); + assert_eq!(plan.args_suffix, SUFFIX, "{active:?}"); + assert_eq!(plan.env, full_env("/my/thing"), "{active:?}"); + assert_eq!(plan.notice, None, "{active:?}"); } for inert in [ "# notify = [\"/my/thing\"]\n", @@ -484,7 +599,7 @@ mod tests { ] { fs::write(&cfg, inert).unwrap(); let plan = Codex.instrument(&inv, &paths(), Some(&home)); - assert!(!plan.args_suffix.is_empty(), "{inert:?}"); + assert_eq!(plan.args_suffix, SUFFIX, "{inert:?}"); assert_eq!(chained(&plan), Some("".into()), "{inert:?}"); } } @@ -508,7 +623,8 @@ mod tests { ))); } - /// For an unrepresentable route, inject neither override nor environment. + /// For an unrepresentable route, inject the embedded override alone, no + /// environment, and a notice: the task stays in embedded mode either way. #[test] fn instrument_skips_an_unrepresentable_config_notify() { let home = temp("codex_opaque_notify"); @@ -532,7 +648,7 @@ mod tests { fs::write(&cfg, opaque).unwrap(); assert_eq!( Codex.instrument(&inv, &paths(), Some(&home)), - SpawnPlan::default(), + embedded_only("`notify` config can't be chained"), "{opaque:?}" ); } @@ -682,7 +798,7 @@ mod tests { /// Resolve a completed-turn notification for `thread` against `home`. fn resolve(home: &Path, thread: &str) -> Option { - Codex.parse_capture(&turn_complete(thread), None, Some(home)) + arrival_root(&turn_complete(thread), Some(home)) } /// `session_meta` payload with raw JSON for `source`. @@ -708,10 +824,10 @@ mod tests { /// Validate the payload before looking up its rollout. Even with a root /// rollout present, accept only a turn-complete payload with a strict ID. #[test] - fn parse_capture_accepts_only_turn_complete_payloads() { + fn arrival_root_accepts_only_turn_complete_payloads() { let home = temp("codex_capture_payload"); install_codex_root(&home, ID); - let parse = |payload: &str| Codex.parse_capture(payload, None, Some(&home)); + let parse = |payload: &str| arrival_root(payload, Some(&home)); assert_eq!(parse(&turn_complete(ID)).as_deref(), Some(ID)); let wrong_type = format!(r#"{{"type":"other","thread-id":"{ID}"}}"#); @@ -725,10 +841,202 @@ mod tests { assert_eq!(parse(""), None); } + /// The slot is exactly one bare UUID. Refuse the pre-v1 JSON payload, a + /// trailing newline (a different writer), and malformed or empty input, + /// whatever the PID and home: nothing is reparsed or looked up at read. + #[test] + fn parse_capture_accepts_only_a_bare_uuid_slot() { + let home = temp("codex_capture_slot"); + install_codex_root(&home, ID); + for (pid, home) in [(None, None), (Some(4242), Some(&*home))] { + let parse = |slot: &str| Codex.parse_capture(slot, pid, home); + assert_eq!(parse(ID).as_deref(), Some(ID)); + assert_eq!(parse(&turn_complete(ID)), None, "old JSON format"); + assert_eq!(parse(&format!("{ID}\n")), None, "trailing newline"); + assert_eq!(parse(&format!(" {ID}")), None, "leading space"); + assert_eq!(parse(&ID.to_uppercase()), None, "uppercase"); + assert_eq!(parse(&ID[..35]), None, "truncated"); + assert_eq!(parse("not a uuid"), None); + assert_eq!(parse(""), None); + } + } + + /// Environment of a `--codex-notify-v1` run: the capture slot, the + /// scratch `CODEX_HOME`, and `HOME` for the fallback case. + fn arrival_env<'a>( + capture: Option<&'a Path>, + codex_home: Option<&'a Path>, + home: Option<&'a Path>, + ) -> impl Fn(&str) -> Option + 'a { + move |key| { + match key { + "FLEETCOM_CAPTURE_FILE" => capture, + "CODEX_HOME" => codex_home, + "HOME" => home, + _ => None, + } + .map(Path::to_path_buf) + } + } + + /// Names in the directory holding `cap`, sorted. + fn siblings(cap: &Path) -> Vec { + let mut names: Vec = fs::read_dir(cap.parent().unwrap()) + .unwrap() + .map(|entry| entry.unwrap().file_name().into_string().unwrap()) + .collect(); + names.sort(); + names + } + + /// The headline regression: the title thread's notification, which has + /// no rollout, lands before the root's in some sessions and after it in + /// others. Either way the slot ends up holding the root, because a + /// refusal writes nothing. No temporary file is left behind. + #[test] + fn record_arrival_keeps_the_root_across_a_title_thread_notification() { + let home = temp("codex_arrival_title"); + install_codex_root(&home, ID); + let slot = temp("codex_arrival_title_slot"); + let cap = slot.join("task-1-0.json"); + let env = arrival_env(Some(&cap), Some(&home), None); + + // Title first: nothing is written at all. + assert_eq!(record_arrival(&turn_complete(TITLE), &env), None); + assert!(!cap.exists(), "a refused thread must not create the slot"); + assert_eq!(siblings(&cap), Vec::::new()); + + assert_eq!( + record_arrival(&turn_complete(ID), &env).as_deref(), + Some(ID) + ); + assert_eq!(fs::read_to_string(&cap).unwrap(), ID); + + // Title after the root: the slot keeps the root. + assert_eq!(record_arrival(&turn_complete(TITLE), &env), None); + assert_eq!(fs::read_to_string(&cap).unwrap(), ID); + assert_eq!(siblings(&cap), ["task-1-0.json"]); + assert_eq!( + Codex.parse_capture(&fs::read_to_string(&cap).unwrap(), None, Some(&home)), + Some(ID.to_string()) + ); + } + + /// A sub-agent's notification writes its root, not its own ID, replacing + /// an earlier, longer slot in full. + #[test] + fn record_arrival_writes_a_sub_agents_root() { + let home = temp("codex_arrival_child"); + install_codex_rollout( + &home, + CHILD, + codex_session_meta(&child_meta(CHILD, ID, ID, &thread_spawn(ID, 1))), + ); + let slot = temp("codex_arrival_child_slot"); + let cap = slot.join("task-2-0.json"); + fs::write(&cap, "9".repeat(4096)).unwrap(); + let env = arrival_env(Some(&cap), Some(&home), None); + assert_eq!( + record_arrival(&turn_complete(CHILD), &env).as_deref(), + Some(ID) + ); + assert_eq!(fs::read_to_string(&cap).unwrap(), ID); + assert_eq!(siblings(&cap), ["task-2-0.json"]); + } + + /// Refuse before touching the slot: a non-turn-complete type, a non-UUID + /// thread, malformed JSON, and a thread without a rollout all leave an + /// existing slot as it was. + #[test] + fn record_arrival_refuses_without_writing() { + let home = temp("codex_arrival_refuse"); + install_codex_root(&home, ID); + let slot = temp("codex_arrival_refuse_slot"); + let cap = slot.join("task-3-0.json"); + let env = arrival_env(Some(&cap), Some(&home), None); + for payload in [ + format!(r#"{{"type":"agent-turn-start","thread-id":"{ID}"}}"#), + format!( + r#"{{"type":"agent-turn-complete","thread-id":"{}"}}"#, + ID.to_uppercase() + ), + r#"{"type":"agent-turn-complete","thread-id":"../../x"}"#.to_string(), + r#"{"type":"agent-turn-complete"}"#.to_string(), + "not json".to_string(), + String::new(), + turn_complete(TITLE), + ] { + assert_eq!(record_arrival(&payload, &env), None, "{payload:?}"); + assert!(!cap.exists(), "{payload:?} must write nothing"); + } + fs::write(&cap, OTHER).unwrap(); + for payload in [turn_complete(TITLE), "not json".to_string()] { + assert_eq!(record_arrival(&payload, &env), None, "{payload:?}"); + assert_eq!(fs::read_to_string(&cap).unwrap(), OTHER, "{payload:?}"); + } + assert_eq!(siblings(&cap), ["task-3-0.json"]); + } + + /// Without a capture path, the mode is a no-op even for an accepted root. + /// An empty value reads as unset, as in the script. + #[test] + fn record_arrival_is_a_no_op_without_a_capture_path() { + let home = temp("codex_arrival_nocap"); + install_codex_root(&home, ID); + for capture in [None, Some(Path::new(""))] { + let env = arrival_env(capture, Some(&home), None); + assert_eq!( + record_arrival(&turn_complete(ID), &env), + None, + "{capture:?}" + ); + } + // Nothing lands beside the store, where an empty path could resolve. + assert_eq!(siblings(&home.join("sessions")), ["sessions"]); + } + + /// Resolve the home as `Codex::resolve_home` does: `CODEX_HOME` first, + /// then `$HOME/.codex`. A rollout under the wrong home is not found. + #[test] + fn record_arrival_resolves_the_home_from_its_environment() { + let user_home = temp("codex_arrival_home"); + install_codex_root(&user_home.join(".codex"), ID); + let other = temp("codex_arrival_other_home"); + let slot = temp("codex_arrival_home_slot"); + let cap = slot.join("task-4-0.json"); + + let env = arrival_env(Some(&cap), Some(&other), Some(&user_home)); + assert_eq!( + record_arrival(&turn_complete(ID), &env), + None, + "CODEX_HOME wins" + ); + assert!(!cap.exists()); + + let env = arrival_env(Some(&cap), None, Some(&user_home)); + assert_eq!( + record_arrival(&turn_complete(ID), &env).as_deref(), + Some(ID) + ); + assert_eq!(fs::read_to_string(&cap).unwrap(), ID); + } + + /// A slot whose directory is gone, as after the daemon's namespace is + /// dropped, is a refusal with no temporary file left anywhere. + #[test] + fn record_arrival_reports_a_failed_write() { + let home = temp("codex_arrival_badslot"); + install_codex_root(&home, ID); + let cap = home.join("missing").join("task-5-0.json"); + let env = arrival_env(Some(&cap), Some(&home), None); + assert_eq!(record_arrival(&turn_complete(ID), &env), None); + assert!(!cap.parent().unwrap().exists()); + } + /// For a root thread, `session_id` is its own ID under every observed - /// string `source`. Accept the capture regardless of the task's PID. + /// string `source`. #[test] - fn parse_capture_accepts_a_root_thread() { + fn arrival_root_accepts_a_root_thread() { for source in ["cli", "vscode", "exec"] { let home = temp("codex_capture_root"); install_codex_rollout( @@ -737,13 +1045,6 @@ mod tests { codex_session_meta(&meta(ID, ID, &format!("\"{source}\""))), ); assert_eq!(resolve(&home, ID).as_deref(), Some(ID), "{source}"); - assert_eq!( - Codex - .parse_capture(&turn_complete(ID), Some(4242), Some(&home)) - .as_deref(), - Some(ID), - "{source}" - ); } } @@ -751,7 +1052,7 @@ mod tests { /// second-level thread, the parent is itself a sub-agent. Omit the root's /// rollout to verify that no lookup is needed for it. #[test] - fn parse_capture_maps_a_spawned_sub_agent_to_its_root() { + fn arrival_root_maps_a_spawned_sub_agent_to_its_root() { let home = temp("codex_capture_spawned"); install_codex_rollout( &home, @@ -771,7 +1072,7 @@ mod tests { /// No parent is specified in a guardian sub-agent's `source`; classify /// it by the `subagent` member alone. #[test] - fn parse_capture_maps_a_guardian_sub_agent_to_its_root() { + fn arrival_root_maps_a_guardian_sub_agent_to_its_root() { let home = temp("codex_capture_guardian"); install_codex_rollout( &home, @@ -790,7 +1091,7 @@ mod tests { /// `session_id`. Reject `id == session_id` with a `subagent` source: /// the root is unknown, and the sub-agent's ID is not resumable. #[test] - fn parse_capture_refuses_a_sub_agent_that_names_itself_as_root() { + fn arrival_root_refuses_a_sub_agent_that_names_itself_as_root() { let home = temp("codex_capture_self_rooted"); for source in [ thread_spawn(ID, 1), @@ -809,7 +1110,7 @@ mod tests { /// No rollout is saved for the title thread. Reject its notification /// with an absent store, an empty store, or rollouts for other threads. #[test] - fn parse_capture_refuses_a_thread_without_a_rollout() { + fn arrival_root_refuses_a_thread_without_a_rollout() { let home = temp("codex_capture_title"); assert_eq!(resolve(&home, TITLE), None, "no sessions directory"); fs::create_dir_all(home.join("sessions/2026/10/04")).unwrap(); @@ -822,7 +1123,7 @@ mod tests { /// Look for rollouts exactly three directories below `sessions`. /// Ignore matching names at other depths and files among the directories. #[test] - fn parse_capture_finds_rollouts_only_in_day_directories() { + fn arrival_root_finds_rollouts_only_in_day_directories() { let home = temp("codex_capture_depth"); let name = format!("rollout-2026-10-04T13-49-56-{ID}.jsonl"); let header = codex_session_meta(&meta(ID, ID, r#""cli""#)); @@ -846,7 +1147,7 @@ mod tests { /// Reject duplicate rollouts for one thread, within or across day /// directories. Accept the capture after removing the extra rollout. #[test] - fn parse_capture_refuses_two_rollouts_for_one_thread() { + fn arrival_root_refuses_two_rollouts_for_one_thread() { let header = codex_session_meta(&meta(ID, ID, r#""cli""#)); for day in ["2026/10/04", "2026/10/05", "2027/01/01"] { let home = temp("codex_capture_twins"); @@ -864,7 +1165,7 @@ mod tests { /// Reject the capture if a day directory cannot be listed: a second /// rollout may be present even when only one is visible. #[test] - fn parse_capture_refuses_a_store_it_cannot_list() { + fn arrival_root_refuses_a_store_it_cannot_list() { use std::os::unix::fs::PermissionsExt; let home = temp("codex_capture_unlistable"); install_codex_root(&home, ID); @@ -881,7 +1182,7 @@ mod tests { /// Require the notified thread's ID and its session tree in the header. /// Reject every other format as unclassified. #[test] - fn parse_capture_refuses_an_unclassified_header() { + fn arrival_root_refuses_an_unclassified_header() { let spawned = thread_spawn(ID, 1); for (what, header) in [ ( @@ -953,7 +1254,7 @@ mod tests { /// only a terminated line; reject an empty file or a complete object /// without its newline. #[test] - fn parse_capture_refuses_empty_and_torn_headers() { + fn arrival_root_refuses_empty_and_torn_headers() { let home = temp("codex_capture_torn"); let header = codex_session_meta(&meta(ID, ID, r#""cli""#)); for cut in 0..header.len() { @@ -967,7 +1268,7 @@ mod tests { /// Require a newline within the first [`HEADER_MAX`] bytes. Bound the /// line length, not the file size. #[test] - fn parse_capture_bounds_the_header_line() { + fn arrival_root_bounds_the_header_line() { let home = temp("codex_capture_bound"); let header = |pad: usize| { codex_session_meta(&format!( @@ -992,7 +1293,7 @@ mod tests { /// Accept a header alone or followed by bytes that are neither JSON nor /// UTF-8: nothing past the first line is examined. #[test] - fn parse_capture_reads_nothing_after_the_header() { + fn arrival_root_reads_nothing_after_the_header() { let home = temp("codex_capture_body"); let header = codex_session_meta(&meta(ID, ID, r#""cli""#)); install_codex_rollout(&home, ID, &header); @@ -1007,7 +1308,7 @@ mod tests { /// Reject unreadable rollouts and non-UTF-8 first lines. Place the invalid /// byte in a field unused for classification to test strict decoding. #[test] - fn parse_capture_refuses_an_unreadable_header() { + fn arrival_root_refuses_an_unreadable_header() { let home = temp("codex_capture_unreadable"); let path = install_codex_root(&home, ID); fs::remove_file(&path).unwrap(); diff --git a/src/harness/mod.rs b/src/harness/mod.rs index 13caf55..de0badc 100644 --- a/src/harness/mod.rs +++ b/src/harness/mod.rs @@ -30,7 +30,7 @@ use std::{ }; pub use claude::Claude; -pub use codex::Codex; +pub use codex::{Codex, record_arrival}; pub use grok::Grok; pub use omp::Omp; @@ -42,6 +42,12 @@ pub const CAPTURE_ENV: &str = "FLEETCOM_CAPTURE_FILE"; /// configured so inherited values cannot reach the capture script. pub const NOTIFY_CHAIN_ENV: &str = "FLEETCOM_NOTIFY_CHAIN"; +/// Environment variable naming the fleetcom binary that the injected `codex` +/// notify script runs as `--codex-notify-v1`. Set per launch from the +/// daemon's own path so the script validates with the binary that built it; +/// absent when that path is unusable, so the script skips the call. +pub const BINARY_ENV: &str = "FLEETCOM_BINARY"; + /// Detection, capture, and resume behavior for one agent CLI. pub trait Harness: Sync { /// Resolve configuration needed by instrumentation, capture parsing, or @@ -215,6 +221,11 @@ pub struct CapturePaths { /// Extension module loaded by `omp -e`, which appends to the user's own /// extensions rather than replacing them. pub omp_capture: PathBuf, + /// This daemon's own executable, checked at allocation to be a regular + /// file with an execute bit. `None` when `current_exe` is unusable: on + /// Linux, `/proc/self/exe` reads ` (deleted)` once the binary is + /// replaced on disk under the running daemon. + pub fleetcom_binary: Option, } /// Spawn-time additions for one instrumented launch. @@ -227,6 +238,9 @@ pub struct SpawnPlan { pub env: Vec<(OsString, OsString)>, /// The session ID chosen at launch, when the harness can pin one. pub injected_id: Option, + /// One-line reason the launch carries less instrumentation than usual. + /// The supervisor reports it on the status line once the task spawns. + pub notice: Option, } /// Shell metacharacters that make the program token unsafe to instrument. @@ -356,6 +370,7 @@ pub(crate) mod fixtures { claude_settings: PathBuf::from("/tmp/Application Support/fleetcom.json"), codex_notify: PathBuf::from("/tmp/Application Support/notify.sh"), omp_capture: PathBuf::from("/tmp/Application Support/omp-capture.js"), + fleetcom_binary: Some(PathBuf::from("/tmp/Application Support/fleetcom")), } } diff --git a/src/harness/omp.rs b/src/harness/omp.rs index 083a606..b132dc1 100644 --- a/src/harness/omp.rs +++ b/src/harness/omp.rs @@ -44,6 +44,7 @@ impl Harness for Omp { capture.capture_file.clone().into_os_string(), )], injected_id: None, + notice: None, } } diff --git a/src/main.rs b/src/main.rs index 1fac505..0f53d19 100644 --- a/src/main.rs +++ b/src/main.rs @@ -79,6 +79,8 @@ Usage: fleetcom --kill kill the daemon and every task it owns fleetcom --daemon run the daemon (internal; the first fleetcom starts it automatically) + fleetcom --codex-notify-v1 record a codex turn (internal; the + injected notify script runs it) Options: -h, --help print this help @@ -96,6 +98,9 @@ enum Invocation { Version, Daemon, Kill, + /// `--codex-notify-v1 `: the injected codex notify script's + /// validation step, carrying one notification JSON. + CodexNotify(String), Client { foreground: bool, session: Option, @@ -111,6 +116,7 @@ fn parse_args(args: &[String]) -> Result { let (mut daemon, mut kill, mut foreground) = (false, false, false); let mut session: Option = None; let mut scrollback: Option = None; + let mut notify: Option = None; let mut it = args.iter(); while let Some(a) = it.next() { match a.as_str() { @@ -118,6 +124,14 @@ fn parse_args(args: &[String]) -> Result { "-V" | "--version" => return Ok(Invocation::Version), "--daemon" => daemon = true, "--kill" => kill = true, + "--codex-notify-v1" => { + // The payload is taken verbatim: JSON never starts with `-`, + // and a flag-shaped value is refused later as non-JSON. + let v = it + .next() + .ok_or_else(|| "--codex-notify-v1 requires a value".to_string())?; + notify = Some(v.clone()); + } "--foreground" => foreground = true, "--scrollback" => { let v = it @@ -139,15 +153,21 @@ fn parse_args(args: &[String]) -> Result { } } } - if daemon && (kill || foreground || session.is_some() || scrollback.is_some()) { + if daemon + && (kill || notify.is_some() || foreground || session.is_some() || scrollback.is_some()) + { return Err("--daemon takes no other arguments".to_string()); } - if kill && (foreground || session.is_some() || scrollback.is_some()) { + if kill && (notify.is_some() || foreground || session.is_some() || scrollback.is_some()) { return Err("--kill takes no other arguments".to_string()); } - match (daemon, kill) { - (true, _) => Ok(Invocation::Daemon), - (_, true) => Ok(Invocation::Kill), + if notify.is_some() && (foreground || session.is_some() || scrollback.is_some()) { + return Err("--codex-notify-v1 takes no other arguments".to_string()); + } + match (daemon, kill, notify) { + (true, _, _) => Ok(Invocation::Daemon), + (_, true, _) => Ok(Invocation::Kill), + (_, _, Some(payload)) => Ok(Invocation::CodexNotify(payload)), _ => Ok(Invocation::Client { foreground, session, @@ -185,6 +205,16 @@ fn run() -> io::Result<()> { Ok(Invocation::Daemon) => return daemon::run_daemon(), // `fleetcom --kill`: tell a running daemon to kill everything and exit. Ok(Invocation::Kill) => return daemon::run_kill(), + // The codex notify script's validation step: headless, silent, and + // exiting 1 on refusal. The status is informational only; the + // script ignores it. + Ok(Invocation::CodexNotify(payload)) => { + let env = |key: &str| std::env::var_os(key).map(std::path::PathBuf::from); + if harness::record_arrival(&payload, &env).is_none() { + std::process::exit(1); + } + return Ok(()); + } Ok(Invocation::Client { foreground, session, @@ -405,6 +435,12 @@ mod tests { fn modes_parse() { assert_eq!(parse(&["--daemon"]), Ok(Invocation::Daemon)); assert_eq!(parse(&["--kill"]), Ok(Invocation::Kill)); + assert_eq!( + parse(&["--codex-notify-v1", r#"{"type":"agent-turn-complete"}"#]), + Ok(Invocation::CodexNotify( + r#"{"type":"agent-turn-complete"}"#.into() + )) + ); assert_eq!(parse(&["-h"]), Ok(Invocation::Help)); assert_eq!(parse(&["--help"]), Ok(Invocation::Help)); assert_eq!(parse(&["-V"]), Ok(Invocation::Version)); @@ -436,6 +472,35 @@ mod tests { assert!(parse(&["--kill", "work"]).is_err()); } + /// The notify mode takes exactly its payload: no value, a second mode, + /// a session name, or a client flag is an error in either order. + #[test] + fn codex_notify_mode_takes_only_its_payload() { + assert!(parse(&["--codex-notify-v1"]).is_err()); + for other in ["--daemon", "--kill", "--foreground", "work"] { + assert!( + parse(&["--codex-notify-v1", "{}", other]).is_err(), + "{other}" + ); + assert!( + parse(&[other, "--codex-notify-v1", "{}"]).is_err(), + "{other}" + ); + } + assert!(parse(&["--codex-notify-v1", "{}", "--scrollback", "5"]).is_err()); + assert!(parse(&["--scrollback", "5", "--codex-notify-v1", "{}"]).is_err()); + // The payload is verbatim, even when it looks like a flag; the mode + // itself then refuses it as non-JSON. + assert_eq!( + parse(&["--codex-notify-v1", "--kill"]), + Ok(Invocation::CodexNotify("--kill".into())) + ); + assert_eq!( + parse(&["--help", "--codex-notify-v1", "{}"]), + Ok(Invocation::Help) + ); + } + #[test] fn second_session_name_is_rejected() { assert!(parse(&["one", "two"]).is_err()); diff --git a/src/supervisor.rs b/src/supervisor.rs index 3054b34..338d50d 100644 --- a/src/supervisor.rs +++ b/src/supervisor.rs @@ -750,6 +750,7 @@ impl Supervisor { let mut exec = std::borrow::Cow::Borrowed(command); let mut env = std::borrow::Cow::Borrowed(env); let mut meta = None; + let mut notice = None; if let Some((h, inv)) = harness::detect(command) && let Some(paths) = self.ensure_capture_assets().map(|a| a.paths_for(id, run)) { @@ -758,6 +759,7 @@ impl Supervisor { exec = format!("{command}{}", plan.args_suffix).into(); env.to_mut().extend(plan.env); let resume_id = plan.injected_id.or_else(|| inv.known_id()); + notice = plan.notice; meta = Some((h, home, paths.capture_file, resume_id)); } let mut task = Task::spawn( @@ -779,6 +781,10 @@ impl Supervisor { task.resume_id = resume_id; } task.run = run; + // Report reduced instrumentation only for a task that exists. + if let Some(notice) = notice { + self.status(format!("task {id}: {notice}")); + } Ok(task) } diff --git a/src/supervisor_capture_tests.rs b/src/supervisor_capture_tests.rs index 694c1ec..e9c0915 100644 --- a/src/supervisor_capture_tests.rs +++ b/src/supervisor_capture_tests.rs @@ -7,14 +7,15 @@ use crate::{ // --- session-capture wiring ------------------------------------------- -/// Install an executable stub that records `FLEETCOM_CAPTURE_FILE` and -/// its argv, one token per line, then exits. +/// Install an executable stub that records `FLEETCOM_CAPTURE_FILE`, +/// `FLEETCOM_BINARY`, and its argv, one token per line, then exits. fn install_stub(bin: &Path, name: &str, out: &Path) { install_script( bin, name, &format!( "printf '%s' \"$FLEETCOM_CAPTURE_FILE\" > '{out}/capenv'\n\ + printf '%s' \"$FLEETCOM_BINARY\" > '{out}/binenv'\n\ printf '%s\\n' \"$@\" > '{out}/argv'", out = out.display() ), @@ -461,7 +462,8 @@ fn remove_deletes_the_capture_file_under_the_spawn_root() { } /// For a `codex` spawn, add `notify=[...]` with the executable capture script, -/// then the override for embedded mode. +/// then the override for embedded mode, and name this process's own +/// executable in `FLEETCOM_BINARY` for the script's validation step. #[test] fn spawn_codex_installs_the_notify_and_embedded_overrides() { use std::os::unix::fs::PermissionsExt; @@ -496,6 +498,11 @@ fn spawn_codex_installs_the_notify_and_embedded_overrides() { meta.permissions().mode() & 0o111 != 0, "codex execs the notify program directly; it must be executable" ); + assert_eq!( + std::fs::read_to_string(dir.join("binenv")).unwrap(), + std::env::current_exe().unwrap().to_string_lossy(), + "the child env must name the supervisor's own executable" + ); let t = &s.tasks[0]; assert_eq!(t.command, "codex"); assert!(t.resume_id.is_none(), "codex cannot pin an id at launch"); @@ -1030,9 +1037,9 @@ const CODEX_ROOT: &str = "019f5453-de22-7240-b2e5-0d32692aa6d9"; const CODEX_CHILD: &str = "019f5454-0c11-7b33-9a4e-5f0e6d7c8b9a"; const CODEX_TITLE: &str = "019f5453-de9f-7e61-8c0d-1a2b3c4d5e6f"; -/// Capture contents for a completed turn of `thread`: the notification JSON -/// written by the injected notifier. `last` is the final assistant message, -/// already escaped for a JSON string. +/// Notification JSON for a completed turn of `thread`, as codex passes it +/// to the injected notifier. `last` is the final assistant message, already +/// escaped for a JSON string. fn turn_complete(thread: &str, last: &str) -> String { format!( r#"{{"type":"agent-turn-complete","thread-id":"{thread}","turn-id":"t","cwd":"/w","input-messages":["ping"],"last-assistant-message":"{last}"}}"# @@ -1044,11 +1051,20 @@ fn title_turn() -> String { turn_complete(CODEX_TITLE, r#"{\"title\":\"Ping the sub-agent\"}"#) } -/// Replace the sole task's capture with `payload`, save under `name`, and -/// return the persisted command. -fn saved_command(s: &mut Supervisor, config: &Path, name: &str, payload: &str) -> String { +/// Run the `--codex-notify-v1` step in-process for the sole task, as the +/// injected script's binary call would: the task's capture file and +/// `codex_home` are its environment. Return the root it wrote. +fn arrive(s: &Supervisor, codex_home: &Path, payload: &str) -> Option { let cap = s.tasks[0].capture_file.clone().expect("capture file set"); - std::fs::write(&cap, payload).unwrap(); + crate::harness::record_arrival(payload, &|key| match key { + crate::harness::CAPTURE_ENV => Some(cap.clone()), + "CODEX_HOME" => Some(codex_home.to_path_buf()), + _ => None, + }) +} + +/// Save under `name` and return the persisted command of the sole task. +fn saved_command(s: &mut Supervisor, config: &Path, name: &str) -> String { save_and_read(s, config, name); session::load_in(&config.join("sessions"), name) .unwrap() @@ -1059,11 +1075,26 @@ fn saved_command(s: &mut Supervisor, config: &Path, name: &str, payload: &str) - .cmd } -/// Report three threads from one codex process in the order observed after -/// the first prompt: the title thread, a sub-agent, then the conversation. -/// Replace the capture on each write. With no rollout for the title thread, -/// preserve the authored command. Resolve the sub-agent to its conversation -/// and the conversation to itself. +/// Install a stand-in for `fleetcom --codex-notify-v1` at `/fleetcom` +/// that writes `CAP_ID` over the capture file. The real mode cannot run from +/// the unit-test binary, whose `main` is the test harness, so stubs that +/// invoke the injected script name this file in `FLEETCOM_BINARY`. +fn install_fake_fleetcom(bin: &Path) -> PathBuf { + install_script( + bin, + "fleetcom", + &format!("printf '%s' '{CAP_ID}' > \"$FLEETCOM_CAPTURE_FILE\""), + ); + bin.join("fleetcom") +} + +/// Report three threads from one codex process through the arrival step in +/// the order observed after the first prompt: the title thread, a sub-agent, +/// then the conversation, and the title thread again, which lands after the +/// root in most sessions. With no rollout for the title thread, nothing is +/// written and the authored command is preserved. Resolve the sub-agent to +/// its conversation and the conversation to itself, and keep that root +/// across the later title notification. #[test] fn codex_capture_resolves_each_notifying_thread_to_the_root() { let dir = scratch("codex_threads"); @@ -1090,27 +1121,37 @@ fn codex_capture_resolves_each_notifying_thread_to_the_root() { spawn(&mut s, "codex", dir.to_path_buf()); assert!(s.tasks[0].resume_id.is_none(), "codex pins no id at launch"); + assert_eq!(arrive(&s, &codex_home, &title_turn()), None); assert_eq!( - saved_command(&mut s, &config, "title", &title_turn()), + saved_command(&mut s, &config, "title"), "codex", "the title thread must not become the resume target" ); let resumes_root = format!("codex resume '{CODEX_ROOT}'"); assert_eq!( - saved_command( - &mut s, - &config, - "child", - &turn_complete(CODEX_CHILD, "pong") - ), + arrive(&s, &codex_home, &turn_complete(CODEX_CHILD, "pong")).as_deref(), + Some(CODEX_ROOT) + ); + assert_eq!( + saved_command(&mut s, &config, "child"), resumes_root, "a sub-agent's turn must resume the conversation that spawned it" ); assert_eq!( - saved_command(&mut s, &config, "root", &turn_complete(CODEX_ROOT, "done")), + arrive(&s, &codex_home, &turn_complete(CODEX_ROOT, "done")).as_deref(), + Some(CODEX_ROOT) + ); + assert_eq!( + saved_command(&mut s, &config, "root"), resumes_root, "the conversation's own turn must resume it" ); + assert_eq!(arrive(&s, &codex_home, &title_turn()), None); + assert_eq!( + saved_command(&mut s, &config, "after_title"), + resumes_root, + "a title notification after the root's must leave the root in place" + ); } /// For `codex resume`, preserve the launch ID after rejecting notifications @@ -1139,18 +1180,24 @@ fn refused_codex_capture_keeps_the_launch_target() { spawn(&mut s, &authored, dir.to_path_buf()); assert_eq!(s.tasks[0].resume_id.as_deref(), Some(CAP_ID)); + assert_eq!(arrive(&s, &codex_home, &title_turn()), None); assert_eq!( - saved_command(&mut s, &config, "title", &title_turn()), + saved_command(&mut s, &config, "title"), authored, "the title thread must not displace the launch target" ); + assert_eq!(arrive(&s, &codex_home, &turn_complete(FOREIGN, "hi")), None); assert_eq!( - saved_command(&mut s, &config, "foreign", &turn_complete(FOREIGN, "hi")), + saved_command(&mut s, &config, "foreign"), authored, "a thread outside the task's Codex home must not displace the launch target" ); assert_eq!( - saved_command(&mut s, &config, "root", &turn_complete(CODEX_ROOT, "done")), + arrive(&s, &codex_home, &turn_complete(CODEX_ROOT, "done")).as_deref(), + Some(CODEX_ROOT) + ); + assert_eq!( + saved_command(&mut s, &config, "root"), format!("codex resume '{CODEX_ROOT}'"), "the task's own root thread must still outrank the launch target" ); @@ -1181,7 +1228,8 @@ fn harness_home_prefers_the_tool_var_then_home() { } /// With only `HOME` in the launch environment, notify routing reads -/// `/.codex/config.toml`. +/// `/.codex/config.toml`: an unchainable route there leaves the launch +/// with the embedded override alone. #[test] fn home_only_launch_env_targets_the_clients_dot_codex() { let dir = scratch("home_resolve"); @@ -1216,8 +1264,8 @@ fn home_only_launch_env_targets_the_clients_dot_codex() { let argv = wait_argv(&mut s, &dir.join("argv")); assert_eq!( argv, - [""], - "the guard must read /.codex/config.toml and inject nothing" + ["-c", "features.daemon_auto_start=false"], + "the guard must read /.codex/config.toml and inject only embedded mode" ); assert!(reap_until(&mut s, Duration::from_secs(5), |s| s.tasks[0] .finished @@ -1248,14 +1296,19 @@ fn stale_inherited_notify_chain_is_never_executed() { let record = dir.join("stale-record"); write_executable(&stale, &format!("touch '{}'", record.display())); - // The stub invokes the injected notify script the way codex would. + // The stub invokes the injected notify script the way codex would, + // naming the fake binary for the script's validation step. let payload = format!(r#"{{"type":"agent-turn-complete","thread-id":"{CAP_ID}"}}"#); + let fake = install_fake_fleetcom(&bin); // The notify script sits beside the capture file, in a namespace // whose nonce is unknowable before spawn: derive it from the env. install_script( &bin, "codex", - &format!("\"${{FLEETCOM_CAPTURE_FILE%/*}}/codex-notify.sh\" '{payload}'"), + &format!( + "FLEETCOM_BINARY='{}' \"${{FLEETCOM_CAPTURE_FILE%/*}}/codex-notify.sh\" '{payload}'", + fake.display() + ), ); let mut ctx = agent_ctx_plus( &bin, @@ -1275,8 +1328,8 @@ fn stale_inherited_notify_chain_is_never_executed() { let cap = s.tasks[0].capture_file.clone().expect("capture file set"); assert_eq!( std::fs::read_to_string(&cap).unwrap(), - payload, - "the capture write must land before the script exits" + CAP_ID, + "the validation step must land before the script exits" ); assert!( !record.exists(), @@ -1323,7 +1376,7 @@ fn agent_save_without_any_id_keeps_the_plain_command() { } /// A representable `notify` assignment runs through the injected notifier -/// after the capture write. +/// after the validation step. #[test] fn config_toml_notify_chains_through_the_injected_script() { use crate::harness::NOTIFY_CHAIN_ENV; @@ -1342,17 +1395,20 @@ fn config_toml_notify_chains_through_the_injected_script() { .unwrap(); // The stub records argv and the chain env, then invokes the notify - // script with notification JSON as the final argument. + // script with notification JSON as the final argument, naming the fake + // binary for the script's validation step. let payload = format!(r#"{{"type":"agent-turn-complete","thread-id":"{CAP_ID}"}}"#); + let fake = install_fake_fleetcom(&bin); install_script( &bin, "codex", &format!( "printf '%s\\n' \"$@\" > '{out}/argv'\n\ printf '%s' \"${chain}\" > '{out}/chainenv'\n\ - \"${{FLEETCOM_CAPTURE_FILE%/*}}/codex-notify.sh\" '{payload}'", + FLEETCOM_BINARY='{fake}' \"${{FLEETCOM_CAPTURE_FILE%/*}}/codex-notify.sh\" '{payload}'", out = dir.display(), chain = NOTIFY_CHAIN_ENV, + fake = fake.display(), ), ); let mut s = sup_ctx(agent_ctx_plus( @@ -1385,8 +1441,8 @@ fn config_toml_notify_chains_through_the_injected_script() { let cap = s.tasks[0].capture_file.clone().unwrap(); assert_eq!( std::fs::read_to_string(&cap).unwrap(), - payload, - "the capture write must precede the chain handoff" + CAP_ID, + "the validation step must precede the chain handoff" ); assert_eq!( current_resume_id(&s.tasks[0]).as_deref(), @@ -1395,8 +1451,10 @@ fn config_toml_notify_chains_through_the_injected_script() { ); } -/// An unrepresentable `notify` value disables injection, while a commented -/// assignment defines no route and leaves injection enabled. +/// An unrepresentable `notify` value disables capture injection: the launch +/// carries the embedded override alone and the status line says why. A +/// commented assignment defines no route, so injection returns and no notice +/// is sent. #[test] fn unrepresentable_config_notify_suppresses_injection() { let dir = scratch("cfg_guard"); @@ -1416,12 +1474,25 @@ fn unrepresentable_config_notify_suppresses_injection() { dir.to_path_buf(), &[("CODEX_HOME", &codex_home)], )); + let notices = |s: &mut Supervisor| -> Vec { + s.drain() + .into_iter() + .filter_map(|e| match e { + Event::Status(m) => Some(m), + _ => None, + }) + .collect() + }; spawn(&mut s, "codex", dir.to_path_buf()); + assert_eq!( + notices(&mut s), + ["task 1: codex capture unavailable: `notify` config can't be chained"] + ); let argv = wait_argv(&mut s, &dir.join("argv")); assert_eq!( argv, - [""], - "fleetcom must preserve an unrepresentable notify and inject nothing" + ["-c", "features.daemon_auto_start=false"], + "fleetcom must preserve an unrepresentable notify and inject only embedded mode" ); // The same route commented out is inert: the injection returns. @@ -1432,6 +1503,7 @@ fn unrepresentable_config_notify_suppresses_injection() { .unwrap(); std::fs::remove_file(dir.join("argv")).unwrap(); spawn(&mut s, "codex", dir.to_path_buf()); + assert_eq!(notices(&mut s), Vec::::new()); let argv = wait_argv(&mut s, &dir.join("argv")); assert!( argv.iter().any(|a| a.starts_with("notify=[")), diff --git a/tests/daemon_resume.rs b/tests/daemon_resume.rs index 452ba23..45b3d7f 100644 --- a/tests/daemon_resume.rs +++ b/tests/daemon_resume.rs @@ -9,6 +9,7 @@ use std::{ io::Write, os::unix::{fs::PermissionsExt, net::UnixStream}, path::{Path, PathBuf}, + process::{Command, Stdio}, time::Duration, }; @@ -20,9 +21,22 @@ use common::{ /// Delimiter separating argv records in a stub's append-only output. const RUN_MARKER: &str = "-- run --"; -/// Fixed v7-shaped thread ID reported by the `codex` stub. +/// Fixed v7-shaped thread ID reported by the `codex` stub as its root. const CODEX_ID: &str = "019f5453-de22-7240-b2e5-0d32692aa6d9"; +/// Hidden title thread the `codex` stub reports after the root. Like the +/// real one, it never gets a rollout. +const CODEX_TITLE: &str = "019f5453-de9f-7e61-8c0d-1a2b3c4d5e6f"; + +/// Root rollout header the `codex` stub saves before notifying: `session_id` +/// is the thread's own ID and `source` is a string. +const CODEX_HEADER: &str = r#"{"timestamp":"2026-10-04T17:49:56.012Z","ordinal":0,"type":"session_meta","payload":{"id":"019f5453-de22-7240-b2e5-0d32692aa6d9","session_id":"019f5453-de22-7240-b2e5-0d32692aa6d9","source":"cli"}}"#; + +/// Notification JSON for a completed turn of `thread`. +fn turn_complete(thread: &str) -> String { + format!(r#"{{"type":"agent-turn-complete","thread-id":"{thread}","turn-id":"t","cwd":"/w"}}"#) +} + /// Config override for embedded mode, placed after the notify override on /// every instrumented `codex` launch. const CODEX_EMBEDDED: &str = "features.daemon_auto_start=false"; @@ -73,6 +87,11 @@ impl Scratch { self.root.join(format!("{tool}-argv")) } + /// Marker the `codex` stub touches after both of its notifications. + fn notified(&self) -> PathBuf { + self.root.join("codex-notified") + } + /// Explicit handshake environment with every resolved path under `root`. fn hello_env(&self) -> Vec<(String, String)> { vec![ @@ -158,21 +177,38 @@ printf 'Resume this session with:\nclaude --resume %s\n' "$id""#, install_stub(s, "claude", &body); } -/// Install a `codex` stub to record argv and report `CODEX_ID` only through -/// the capture file. Before notifying, save a root rollout header under -/// `$CODEX_HOME`: set `session_id` to `id` and use a string `source`. +/// Install a `codex` stub to record argv and report through the injected +/// notify program alone, as the real TUI does. It saves the root's rollout +/// header under `$CODEX_HOME`, notifies for the root, then notifies for the +/// title thread, which has no rollout, and finally touches the `notified` +/// marker. The notify program is read back from the `notify=[""]` +/// override in its own argv. fn install_codex_stub(s: &Scratch) { let body = format!( r#"printf '%s\n' '{marker}' "$@" >> '{rec}' if [ -n "$FLEETCOM_CAPTURE_FILE" ]; then day="$CODEX_HOME/sessions/2026/10/04" mkdir -p "$day" - printf '%s\n' '{{"timestamp":"2026-10-04T17:49:56.012Z","ordinal":0,"type":"session_meta","payload":{{"id":"{id}","session_id":"{id}","source":"cli"}}}}' > "$day/rollout-2026-10-04T13-49-56-{id}.jsonl" - printf '{{"type":"agent-turn-complete","thread-id":"{id}"}}' > "$FLEETCOM_CAPTURE_FILE" + printf '%s\n' '{header}' > "$day/rollout-2026-10-04T13-49-56-{id}.jsonl" + script='' + prev='' + for a in "$@"; do + if [ "$prev" = -c ]; then + case "$a" in notify=*) script=${{a#notify=}}; script=${{script#'["'}}; script=${{script%'"]'}} ;; esac + fi + prev="$a" + done + "$script" '{root}' + "$script" '{title}' + : > '{notified}' fi"#, marker = RUN_MARKER, rec = s.record("codex").display(), + header = CODEX_HEADER, id = CODEX_ID, + root = turn_complete(CODEX_ID), + title = turn_complete(CODEX_TITLE), + notified = s.notified().display(), ); install_stub(s, "codex", &body); } @@ -249,28 +285,33 @@ fn save_once(stream: &mut UnixStream, recipe: &Path, name: &str) -> String { std::fs::read_to_string(recipe).unwrap() } -/// Check whether a daemon namespace contains a non-empty task capture file. -/// Assets live under `/-/`, so the runtime root itself -/// contains no task files. -fn has_capture(runtime: &Path) -> bool { - std::fs::read_dir(runtime).is_ok_and(|namespaces| { - namespaces - .flatten() - .filter(|ns| ns.path().is_dir()) - .any(|ns| { - std::fs::read_dir(ns.path()).is_ok_and(|files| { - files.flatten().any(|e| { - let name = e.file_name(); - let Some(n) = name.to_str() else { - return false; - }; - n.starts_with("task-") - && n.ends_with(".json") - && e.metadata().is_ok_and(|m| m.len() > 0) - }) - }) - }) - }) +/// Every file in the daemon namespaces under `runtime`, as +/// `(name, contents)` sorted by name. Assets live under +/// `/-/`, so the runtime root itself holds no task files. +fn namespace_files(runtime: &Path) -> Vec<(String, String)> { + let mut files: Vec<(String, String)> = std::fs::read_dir(runtime) + .into_iter() + .flatten() + .flatten() + .filter(|ns| ns.path().is_dir()) + .flat_map(|ns| std::fs::read_dir(ns.path()).into_iter().flatten().flatten()) + .map(|e| { + ( + e.file_name().to_string_lossy().into_owned(), + std::fs::read_to_string(e.path()).unwrap_or_default(), + ) + }) + .collect(); + files.sort(); + files +} + +/// Contents of the single task capture file under `runtime`, when present. +fn capture_slot(runtime: &Path) -> Option { + namespace_files(runtime) + .into_iter() + .find(|(name, _)| name.starts_with("task-") && name.ends_with(".json")) + .map(|(_, contents)| contents) } /// Claude instrumentation captures an ID without leaking its injected flags @@ -325,8 +366,9 @@ fn claude_spawn_save_load_resumes_the_conversation() { stop_daemon(&mut daemon); } -/// A Codex capture payload persists the resume command, and loading that command -/// reapplies the notifier instrumentation. +/// A Codex notification validated by the daemon's own binary persists the +/// resume command, the title thread's later notification leaves it intact, +/// and loading that command reapplies the notifier instrumentation. #[test] fn codex_capture_file_drives_save_and_load_resumes() { let s = Scratch::new("codex"); @@ -351,10 +393,29 @@ fn codex_capture_file_drives_save_and_load_resumes() { "a bare spawn must receive the two overrides and nothing else" ); - // The stub exits silently, so its capture write is the only id channel; - // wait for the file, then a single save must persist the resuming form. - let ok = wait_until(Duration::from_secs(10), || has_capture(&s.runtime())); - assert!(ok, "the codex stub never wrote its capture file"); + // The stub exits silently, so the notify script is the only id channel. + // Wait for both notifications: the root's must have written the bare + // UUID and the title thread's must have left it alone, with no temporary + // file beside it. A single save must then persist the resuming form. + let ok = wait_until(Duration::from_secs(10), || s.notified().exists()); + assert!(ok, "the codex stub never finished notifying"); + let files = namespace_files(&s.runtime()); + let names: Vec<&str> = files.iter().map(|(name, _)| name.as_str()).collect(); + assert_eq!( + names, + [ + "claude-settings.json", + "codex-notify.sh", + "omp-capture.js", + "task-1-0.json" + ], + "the slot must be renamed into place with nothing left behind" + ); + assert_eq!( + capture_slot(&s.runtime()).as_deref(), + Some(CODEX_ID), + "the slot must hold the bare root after the title thread's notification" + ); let recipe = save_once(&mut stream, &s.recipe("story"), "story"); assert!( recipe.contains(&format!("codex resume '{CODEX_ID}'")), @@ -381,6 +442,54 @@ fn codex_capture_file_drives_save_and_load_resumes() { stop_daemon(&mut daemon); } +/// The notify mode runs headless, before the terminal check and daemon +/// autostart: without a tty it writes the bare root and exits 0, prints +/// nothing, refuses the title thread with exit 1 and no write, and is a +/// no-op without a capture path. +#[test] +fn codex_notify_mode_runs_headless() { + let s = Scratch::new("notify_mode"); + let home = s.root.join("codex-home"); + let day = home.join("sessions/2026/10/04"); + std::fs::create_dir_all(&day).unwrap(); + std::fs::write( + day.join(format!("rollout-2026-10-04T13-49-56-{CODEX_ID}.jsonl")), + format!("{CODEX_HEADER}\n"), + ) + .unwrap(); + let cap = s.runtime().join("task-1-0.json"); + let notify = |payload: &str, capture: Option<&Path>| { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_fleetcom")); + cmd.arg("--codex-notify-v1") + .arg(payload) + .env("CODEX_HOME", &home) + .stdin(Stdio::null()); + match capture { + Some(path) => cmd.env("FLEETCOM_CAPTURE_FILE", path), + None => cmd.env_remove("FLEETCOM_CAPTURE_FILE"), + }; + let out = cmd.output().unwrap(); + assert!(out.stdout.is_empty(), "the mode prints nothing: {out:?}"); + assert!(out.stderr.is_empty(), "the mode prints nothing: {out:?}"); + out.status.code() + }; + + assert_eq!(notify(&turn_complete(CODEX_ID), None), Some(1)); + assert!(!cap.exists(), "no capture path, no write"); + assert_eq!(notify(&turn_complete(CODEX_TITLE), Some(&cap)), Some(1)); + assert!(!cap.exists(), "a refused thread writes nothing"); + assert_eq!(notify(&turn_complete(CODEX_ID), Some(&cap)), Some(0)); + assert_eq!(std::fs::read_to_string(&cap).unwrap(), CODEX_ID); + assert_eq!(notify(&turn_complete(CODEX_TITLE), Some(&cap)), Some(1)); + assert_eq!(std::fs::read_to_string(&cap).unwrap(), CODEX_ID); + let mut names: Vec = std::fs::read_dir(s.runtime()) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + names.sort(); + assert_eq!(names, ["task-1-0.json"], "no temporary file may remain"); +} + /// A persisted resume command survives daemon replacement and targets the same /// ID afterward. #[test] From 6c8b2a1b8fcefa53827873c041f34990c9394e7c Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Mon, 5 Oct 2026 18:21:28 -0700 Subject: [PATCH 02/13] feat(launch): managed agent launches with no shell between --- docs/agent-resume.md | 3 +- src/harness/assets.rs | 9 +- src/harness/claude.rs | 96 ++++++--- src/harness/codex.rs | 172 ++++++++++------ src/harness/grok.rs | 57 +++--- src/harness/mod.rs | 351 +++++++++++++++++++++++++++----- src/harness/omp.rs | 85 ++++---- src/supervisor.rs | 234 +++++++++++++++++---- src/supervisor_capture_tests.rs | 350 ++++++++++++++++++++++++++++++- src/task.rs | 69 +++++-- src/task_tests.rs | 172 +++++++++++++++- 11 files changed, 1306 insertions(+), 292 deletions(-) diff --git a/docs/agent-resume.md b/docs/agent-resume.md index 023e45f..b42f242 100644 --- a/docs/agent-resume.md +++ b/docs/agent-resume.md @@ -175,7 +175,8 @@ Before inserting a captured ID into a shell command, accept only lowercase hexad Implement the `Harness` trait in [`src/harness/mod.rs`](../src/harness/mod.rs) for each tool. Register it in that file's `AGENTS` table, the sole registry for detection. Include a `SummaryAdapter` for the dashboard preview with each entry; see the implementations in [`src/harness/summary.rs`](../src/harness/summary.rs). Separate detection, evidence collection, and command construction through these methods: - `shape`: supply the program word and resume selector. Accepted and canonical forms are derived from that pair by the default `detect` and `resume_command` implementations. -- `instrument`: return spawn-time arguments, environment entries, an optional pinned ID, and an optional notice explaining reduced instrumentation, which the supervisor reports on the status line once the task spawns. +- `session_flag`: name the flag that pins a session ID on a fresh launch, or return `None` (the default) when the tool assigns its own. `harness::plan` builds the conversation-selecting argv from this flag and the resume selector. +- `overlay`: return the argv elements, environment entries, and optional notice every launch of the tool carries after the conversation selection; the supervisor reports the notice on the status line once the task spawns. A managed launch passes the elements to the binary directly; a detected literal command gets them appended as shell text. - `parse_capture`: read an ID from the capture file's contents. The task leader's PID and the launch-time home are supplied; when other processes can reach the capture channel, use them to refuse a payload the task's own process did not write. - `live_session_id`: read the ID published on disk for a live session. Return `None` by default when no registry is available. - `live_blocked_status`: read that registry for blocked-on-user status. Return preview text, never an ID; return `None` by default. diff --git a/src/harness/assets.rs b/src/harness/assets.rs index acfee88..55f4547 100644 --- a/src/harness/assets.rs +++ b/src/harness/assets.rs @@ -308,12 +308,7 @@ impl CaptureAssets { fn fleetcom_binary() -> Option { std::env::current_exe() .ok() - .filter(|exe| executable_file(exe)) -} - -/// Whether `path` is a regular file with any execute bit, following symlinks. -fn executable_file(path: &Path) -> bool { - fs::metadata(path).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0) + .filter(|exe| super::executable_file(exe)) } /// Remove this supervisor's capture namespace on drop. @@ -335,7 +330,7 @@ mod tests { use super::*; use crate::{ harness::{ - BINARY_ENV, CAPTURE_ENV, Claude, Harness, NOTIFY_CHAIN_ENV, Omp, + BINARY_ENV, CAPTURE_ENV, Claude, Harness, NOTIFY_CHAIN_ENV, Omp, executable_file, fixtures::{ID, OTHER}, }, testutil::{dead_pid, install_fake_notifier, temp, write_executable}, diff --git a/src/harness/claude.rs b/src/harness/claude.rs index 46b8ed8..5b6b77f 100644 --- a/src/harness/claude.rs +++ b/src/harness/claude.rs @@ -18,8 +18,7 @@ use std::{ use super::summary::AWAITING_APPROVAL; use super::{ - CAPTURE_ENV, CapturePaths, Harness, Invocation, SpawnPlan, capture_id, home_root, is_uuid, - pin_plan, resolve_home, shell_quote, + CAPTURE_ENV, CapturePaths, Harness, SpawnPlan, capture_id, home_root, is_uuid, resolve_home, }; pub struct Claude; @@ -33,22 +32,27 @@ impl Harness for Claude { ("claude", "--resume") } - fn instrument( + fn session_flag(&self) -> Option<&'static str> { + Some("--session-id") + } + + fn overlay( &self, - inv: &Invocation, capture: &CapturePaths, // The settings overlay does not depend on the Claude home path. _home: Option<&Path>, ) -> SpawnPlan { - let mut plan = pin_plan(inv); - plan.args_suffix.push_str(" --settings "); - plan.args_suffix - .push_str(&shell_quote(&capture.claude_settings.to_string_lossy())); - plan.env = vec![( - CAPTURE_ENV.into(), - capture.capture_file.clone().into_os_string(), - )]; - plan + SpawnPlan { + args: vec![ + "--settings".into(), + capture.claude_settings.clone().into_os_string(), + ], + env: vec![( + CAPTURE_ENV.into(), + capture.capture_file.clone().into_os_string(), + )], + ..SpawnPlan::default() + } } /// Accept a hook payload only from the task's own process. The first line @@ -173,10 +177,17 @@ mod tests { use super::*; use crate::{ - harness::fixtures::{ID, OTHER, assert_all_opaque, paths}, + harness::{ + Intent, + fixtures::{ID, OTHER, argv, assert_all_opaque, paths}, + plan, shell_words, + }, testutil::temp, }; + /// The overlay path from [`paths`]. + const SETTINGS: &str = "/tmp/Application Support/fleetcom.json"; + /// Complete registry fixture with [`OTHER`] as its session ID. const LIVE_RECORD: &str = concat!( r#"{"pid":83849,"sessionId":"11111111-2222-4333-8444-555555555555","#, @@ -234,37 +245,62 @@ mod tests { assert_all_opaque(&Claude, ID, &opaque); } + /// Managed argv: a fresh launch pins the minted ID, a resume names its + /// conversation, and the settings overlay follows either. Both carry the + /// capture file in the environment and report the ID they target. #[test] - fn instrument_pins_an_id_and_layers_settings_on_bare_launches() { - let inv = Claude.detect("claude").unwrap(); - let plan = Claude.instrument(&inv, &paths(), None); - let id = plan.injected_id.expect("a bare launch pins an id"); - assert!(is_uuid(&id)); + fn managed_argv_pins_or_resumes_then_layers_settings() { + let fresh = plan(&Claude, &Intent::Fresh, Some(ID), &paths(), None); assert_eq!( - plan.args_suffix, - format!(" --session-id '{id}' --settings '/tmp/Application Support/fleetcom.json'") + fresh.args, + argv(&["--session-id", ID, "--settings", SETTINGS]) ); + assert_eq!(fresh.resume_id.as_deref(), Some(ID)); assert_eq!( - plan.env, + fresh.env, vec![( CAPTURE_ENV.into(), PathBuf::from("/tmp/cap/session.json").into_os_string() )] ); + assert_eq!(fresh.notice, None); + + let resume = plan(&Claude, &Intent::Resume(OTHER.into()), None, &paths(), None); + assert_eq!( + resume.args, + argv(&["--resume", OTHER, "--settings", SETTINGS]) + ); + assert_eq!(resume.resume_id.as_deref(), Some(OTHER)); + assert_eq!(resume.env, fresh.env); + + // A mint failure launches unpinned: the overlay alone, no ID. + let unpinned = plan(&Claude, &Intent::Fresh, None, &paths(), None); + assert_eq!(unpinned.args, argv(&["--settings", SETTINGS])); + assert_eq!(unpinned.resume_id, None); + } + + /// The literal suffix for a bare `claude`: the fresh intent part, then + /// the overlay, as shell text. + #[test] + fn bare_literal_suffix_pins_an_id_and_layers_settings() { + let fresh = plan(&Claude, &Intent::Fresh, Some(ID), &paths(), None); + assert_eq!( + shell_words(&fresh.args), + format!(" --session-id '{ID}' --settings '/tmp/Application Support/fleetcom.json'") + ); } - /// A resume command already targets a conversation, so instrumentation adds - /// the settings overlay without pinning another ID. + /// The typed resume form already targets a conversation, so its suffix + /// is the settings overlay alone. #[test] - fn instrument_adds_only_settings_to_the_resume_form() { - let inv = Claude.detect(&format!("claude --resume {ID}")).unwrap(); - let plan = Claude.instrument(&inv, &paths(), None); - assert_eq!(plan.injected_id, None); + fn resume_literal_suffix_adds_only_settings() { + let overlay = Claude.overlay(&paths(), None); + assert_eq!(overlay.resume_id, None); assert_eq!( - plan.args_suffix, + shell_words(&overlay.args), " --settings '/tmp/Application Support/fleetcom.json'" ); - assert_eq!(plan.env.len(), 1, "env still names the capture file"); + assert_eq!(overlay.env.len(), 1, "env still names the capture file"); } /// Task leader PID used by the capture-gate cases. diff --git a/src/harness/codex.rs b/src/harness/codex.rs index 0512550..db99316 100644 --- a/src/harness/codex.rs +++ b/src/harness/codex.rs @@ -22,8 +22,8 @@ use std::{ }; use super::{ - BINARY_ENV, CAPTURE_ENV, CapturePaths, Harness, Invocation, NOTIFY_CHAIN_ENV, SpawnPlan, - capture_id, home_root, is_uuid, resolve_home, shell_quote, + BINARY_ENV, CAPTURE_ENV, CapturePaths, Harness, NOTIFY_CHAIN_ENV, SpawnPlan, capture_id, + home_root, is_uuid, resolve_home, }; /// Config override for explicitly launching in embedded mode. @@ -61,17 +61,11 @@ impl Harness for Codex { /// attaches to its shared background server and killing the task kills /// only the TUI client. The notify override and its environment ride /// along only when the configured route can be chained and this binary - /// is usable; otherwise the plan explains why capture is off. - fn instrument( - &self, - // Both accepted shapes take the same injection; codex cannot pin an - // ID at launch either way. - _inv: &Invocation, - capture: &CapturePaths, - home: Option<&Path>, - ) -> SpawnPlan { + /// is usable; otherwise the plan explains why capture is off. No session + /// flag: codex cannot pin an ID at launch. + fn overlay(&self, capture: &CapturePaths, home: Option<&Path>) -> SpawnPlan { let mut plan = SpawnPlan { - args_suffix: format!(" -c {}", shell_quote(EMBEDDED_OVERRIDE)), + args: vec!["-c".into(), EMBEDDED_OVERRIDE.into()], ..SpawnPlan::default() }; let chain = match config_notify_route(home) { @@ -100,11 +94,12 @@ impl Harness for Codex { "notify=[\"{}\"]", toml_escape(&capture.codex_notify.to_string_lossy()) ); - plan.args_suffix = format!( - " -c {} -c {}", - shell_quote(&toml), - shell_quote(EMBEDDED_OVERRIDE) - ); + plan.args = vec![ + "-c".into(), + toml.into(), + "-c".into(), + EMBEDDED_OVERRIDE.into(), + ]; plan.env = vec![ ( CAPTURE_ENV.into(), @@ -432,7 +427,11 @@ mod tests { use super::*; use crate::{ - harness::fixtures::{OTHER, assert_all_opaque, paths}, + harness::{ + Intent, + fixtures::{OTHER, argv, assert_all_opaque, paths}, + plan, shell_words, + }, testutil::{Scratch, codex_session_meta, install_codex_rollout, install_codex_root, temp}, }; @@ -470,12 +469,15 @@ mod tests { ] } - /// The plan of a launch whose capture is off, explained by `why`. + /// The notify override that [`paths`] produces. + const NOTIFY: &str = r#"notify=["/tmp/Application Support/notify.sh"]"#; + + /// The overlay of a launch whose capture is off, explained by `why`. fn embedded_only(why: &str) -> SpawnPlan { SpawnPlan { - args_suffix: EMBEDDED_ONLY.into(), + args: argv(&["-c", EMBEDDED_OVERRIDE]), env: Vec::new(), - injected_id: None, + resume_id: None, notice: Some(format!("codex capture unavailable: {why}")), } } @@ -516,44 +518,90 @@ mod tests { temp("codex_no_config_home") } - /// Apply the same two overrides to both accepted forms: the notifier, - /// then explicit embedded mode. The environment names the capture file, - /// an explicitly empty chain so no inherited value reaches the script, - /// and the binary the script runs. No ID can be pinned at launch. + /// Managed argv: a fresh launch carries the two overrides alone (no ID + /// can be pinned, so the minted one is ignored); a resume leads with the + /// `resume` subcommand and the ID, then the same overrides. The + /// environment names the capture file, an explicitly empty chain so no + /// inherited value reaches the script, and the binary the script runs. #[test] - fn instrument_installs_the_notify_and_embedded_overrides() { - for cmd in ["codex".to_string(), format!("codex resume {ID}")] { - let inv = Codex.detect(&cmd).unwrap(); - let plan = Codex.instrument(&inv, &paths(), Some(&no_config_home())); - assert_eq!(plan.args_suffix, SUFFIX, "{cmd}"); - assert_eq!(plan.injected_id, None, "{cmd}"); - assert_eq!(plan.notice, None, "{cmd}"); - assert_eq!(plan.env, full_env(""), "{cmd}"); - } + fn managed_argv_installs_the_notify_and_embedded_overrides() { + let home = no_config_home(); + let fresh = plan(&Codex, &Intent::Fresh, Some(ID), &paths(), Some(&home)); + assert_eq!(fresh.args, argv(&["-c", NOTIFY, "-c", EMBEDDED_OVERRIDE])); + assert_eq!(fresh.resume_id, None, "the minted id is ignored"); + assert_eq!(fresh.notice, None); + assert_eq!(fresh.env, full_env("")); + + let resume = plan( + &Codex, + &Intent::Resume(ID.into()), + None, + &paths(), + Some(&home), + ); + assert_eq!( + resume.args, + argv(&["resume", ID, "-c", NOTIFY, "-c", EMBEDDED_OVERRIDE]) + ); + assert_eq!(resume.resume_id.as_deref(), Some(ID)); + assert_eq!(resume.env, full_env("")); + } + + /// Both literal shapes get the same suffix: the notifier, then explicit + /// embedded mode. + #[test] + fn literal_suffix_installs_the_notify_and_embedded_overrides() { + let overlay = Codex.overlay(&paths(), Some(&no_config_home())); + assert_eq!(shell_words(&overlay.args), SUFFIX); + assert_eq!(overlay.resume_id, None); + // The bare word's fresh intent part is empty, so its suffix is the + // overlay's. + let fresh = plan( + &Codex, + &Intent::Fresh, + Some(ID), + &paths(), + Some(&no_config_home()), + ); + assert_eq!(shell_words(&fresh.args), SUFFIX); } /// Without a usable binary, the script would have nothing to run, so the /// launch carries the embedded override alone and says why. The route - /// is still read first: an opaque route reports its own reason. + /// is still read first: an opaque route reports its own reason. A + /// managed resume keeps its intent part ahead of the reduced overlay. #[test] - fn instrument_without_a_usable_binary_keeps_only_the_embedded_override() { + fn overlay_without_a_usable_binary_keeps_only_the_embedded_override() { let paths = CapturePaths { fleetcom_binary: None, ..paths() }; - for cmd in ["codex".to_string(), format!("codex resume {ID}")] { - let inv = Codex.detect(&cmd).unwrap(); - assert_eq!( - Codex.instrument(&inv, &paths, Some(&no_config_home())), - embedded_only("fleetcom binary replaced; restart the daemon"), - "{cmd}" - ); - } + let why = "fleetcom binary replaced; restart the daemon"; + assert_eq!( + Codex.overlay(&paths, Some(&no_config_home())), + embedded_only(why) + ); + assert_eq!( + shell_words(&Codex.overlay(&paths, Some(&no_config_home())).args), + EMBEDDED_ONLY + ); + let resume = plan( + &Codex, + &Intent::Resume(ID.into()), + None, + &paths, + Some(&no_config_home()), + ); + assert_eq!(resume.args, argv(&["resume", ID, "-c", EMBEDDED_OVERRIDE])); + assert_eq!( + resume.notice.as_deref(), + Some(&*format!("{CAPTURE_OFF}: {why}")) + ); + let home = temp("codex_no_binary_opaque"); fs::write(home.join("config.toml"), "notify = [1]\n").unwrap(); - let inv = Codex.detect("codex").unwrap(); assert_eq!( - Codex.instrument(&inv, &paths, Some(&home)), + Codex.overlay(&paths, Some(&home)), embedded_only("`notify` config can't be chained") ); } @@ -562,9 +610,8 @@ mod tests { /// capture alone for comments, longer keys, and missing files: no route is /// configured. #[test] - fn instrument_chains_a_config_toml_notify() { + fn overlay_chains_a_config_toml_notify() { let home = temp("codex_cfg_notify"); - let inv = Codex.detect("codex").unwrap(); let chained = |plan: &SpawnPlan| { plan.env .iter() @@ -574,8 +621,8 @@ mod tests { // Missing config file: plain injection, and the chain is present but // empty. - let plan = Codex.instrument(&inv, &paths(), Some(&home)); - assert_eq!(plan.args_suffix, SUFFIX); + let plan = Codex.overlay(&paths(), Some(&home)); + assert_eq!(shell_words(&plan.args), SUFFIX); assert_eq!(chained(&plan), Some("".into())); let cfg = home.join("config.toml"); @@ -586,8 +633,8 @@ mod tests { "model = \"gpt-5\"\nnotify = [\"/my/thing\"]\n", ] { fs::write(&cfg, active).unwrap(); - let plan = Codex.instrument(&inv, &paths(), Some(&home)); - assert_eq!(plan.args_suffix, SUFFIX, "{active:?}"); + let plan = Codex.overlay(&paths(), Some(&home)); + assert_eq!(shell_words(&plan.args), SUFFIX, "{active:?}"); assert_eq!(plan.env, full_env("/my/thing"), "{active:?}"); assert_eq!(plan.notice, None, "{active:?}"); } @@ -598,24 +645,23 @@ mod tests { "notify = []\n", ] { fs::write(&cfg, inert).unwrap(); - let plan = Codex.instrument(&inv, &paths(), Some(&home)); - assert_eq!(plan.args_suffix, SUFFIX, "{inert:?}"); + let plan = Codex.overlay(&paths(), Some(&home)); + assert_eq!(shell_words(&plan.args), SUFFIX, "{inert:?}"); assert_eq!(chained(&plan), Some("".into()), "{inert:?}"); } } /// Preserve spaces within argv elements in the newline-joined chain. #[test] - fn instrument_chains_the_vendor_desktop_entry() { + fn overlay_chains_the_vendor_desktop_entry() { let home = temp("codex_vendor_notify"); fs::write( home.join("config.toml"), "notify = [\"/Applications/Codex Computer Use.app/Contents/MacOS/SkyComputerUseClient\", \"turn-ended\"]\n", ) .unwrap(); - let inv = Codex.detect("codex").unwrap(); - let plan = Codex.instrument(&inv, &paths(), Some(&home)); - assert_eq!(plan.args_suffix, SUFFIX); + let plan = Codex.overlay(&paths(), Some(&home)); + assert_eq!(shell_words(&plan.args), SUFFIX); assert!(plan.env.contains(&( NOTIFY_CHAIN_ENV.into(), "/Applications/Codex Computer Use.app/Contents/MacOS/SkyComputerUseClient\nturn-ended" @@ -626,10 +672,9 @@ mod tests { /// For an unrepresentable route, inject the embedded override alone, no /// environment, and a notice: the task stays in embedded mode either way. #[test] - fn instrument_skips_an_unrepresentable_config_notify() { + fn overlay_skips_an_unrepresentable_config_notify() { let home = temp("codex_opaque_notify"); let cfg = home.join("config.toml"); - let inv = Codex.detect("codex").unwrap(); for opaque in [ // Malformed TOML cannot identify an active route. "notify = [\n", @@ -647,7 +692,7 @@ mod tests { ] { fs::write(&cfg, opaque).unwrap(); assert_eq!( - Codex.instrument(&inv, &paths(), Some(&home)), + Codex.overlay(&paths(), Some(&home)), embedded_only("`notify` config can't be chained"), "{opaque:?}" ); @@ -667,11 +712,10 @@ mod tests { codex_notify: PathBuf::from(r#"/Odd Path/it's "here"\now"#), ..paths() }; - let inv = Codex.detect("codex").unwrap(); - let plan = Codex.instrument(&inv, &paths, Some(&no_config_home())); + let plan = Codex.overlay(&paths, Some(&no_config_home())); let out = std::process::Command::new("sh") .arg("-c") - .arg(format!("printf '%s\\n'{}", plan.args_suffix)) + .arg(format!("printf '%s\\n'{}", shell_words(&plan.args))) .output() .expect("sh must run"); assert_eq!( diff --git a/src/harness/grok.rs b/src/harness/grok.rs index 63e5762..13f9bfa 100644 --- a/src/harness/grok.rs +++ b/src/harness/grok.rs @@ -3,7 +3,7 @@ use std::path::Path; -use super::{CapturePaths, Harness, Invocation, SpawnPlan, pin_plan}; +use super::{CapturePaths, Harness, SpawnPlan}; pub struct Grok; @@ -12,14 +12,13 @@ impl Harness for Grok { ("grok", "--resume") } - fn instrument( - &self, - inv: &Invocation, - // Grok instrumentation does not use capture paths or home config. - _capture: &CapturePaths, - _home: Option<&Path>, - ) -> SpawnPlan { - pin_plan(inv) + fn session_flag(&self) -> Option<&'static str> { + Some("--session-id") + } + + /// Nothing rides along: grok has no capture channel to install. + fn overlay(&self, _capture: &CapturePaths, _home: Option<&Path>) -> SpawnPlan { + SpawnPlan::default() } } @@ -27,8 +26,9 @@ impl Harness for Grok { mod tests { use super::*; use crate::harness::{ - fixtures::{ID, assert_all_opaque, paths}, - is_uuid, + Intent, + fixtures::{ID, OTHER, argv, assert_all_opaque, paths}, + plan, shell_words, }; /// Grok-specific opaque shapes: flags, the `-r`/`-s`/`=` spellings the @@ -55,23 +55,32 @@ mod tests { assert_all_opaque(&Grok, ID, &opaque); } - /// A bare launch gains only the pinned ID because Grok exposes no live - /// capture channel. + /// Managed argv is the intent part alone: Grok exposes no live capture + /// channel, so nothing rides along either way. + #[test] + fn managed_argv_is_the_pin_or_the_resume_and_nothing_else() { + let fresh = plan(&Grok, &Intent::Fresh, Some(ID), &paths(), None); + assert_eq!(fresh.args, argv(&["--session-id", ID])); + assert_eq!(fresh.resume_id.as_deref(), Some(ID)); + assert!(fresh.env.is_empty(), "no capture channel, no capture env"); + + let resume = plan(&Grok, &Intent::Resume(OTHER.into()), None, &paths(), None); + assert_eq!(resume.args, argv(&["--resume", OTHER])); + assert_eq!(resume.resume_id.as_deref(), Some(OTHER)); + assert!(resume.env.is_empty()); + } + + /// A bare literal `grok` gains only the pinned ID. #[test] - fn instrument_pins_an_id_and_nothing_else() { - let inv = Grok.detect("grok").unwrap(); - let plan = Grok.instrument(&inv, &paths(), None); - let id = plan.injected_id.expect("a bare launch pins an id"); - assert!(is_uuid(&id)); - assert_eq!(plan.args_suffix, format!(" --session-id '{id}'")); - assert!(plan.env.is_empty(), "no capture channel, no capture env"); + fn bare_literal_suffix_pins_an_id_and_nothing_else() { + let fresh = plan(&Grok, &Intent::Fresh, Some(ID), &paths(), None); + assert_eq!(shell_words(&fresh.args), format!(" --session-id '{ID}'")); } - /// A resume command needs no pin, overlay, or environment change. + /// The typed resume form needs no pin, overlay, or environment change. #[test] - fn instrument_leaves_the_resume_form_untouched() { - let inv = Grok.detect(&format!("grok --resume {ID}")).unwrap(); - assert_eq!(Grok.instrument(&inv, &paths(), None), SpawnPlan::default()); + fn overlay_leaves_the_resume_form_untouched() { + assert_eq!(Grok.overlay(&paths(), None), SpawnPlan::default()); } #[test] diff --git a/src/harness/mod.rs b/src/harness/mod.rs index de0badc..22c98f0 100644 --- a/src/harness/mod.rs +++ b/src/harness/mod.rs @@ -1,18 +1,23 @@ //! A saved agent command is incomplete without its conversation ID. Relaunching -//! the command can otherwise start a new conversation. Each harness detects a -//! narrow set of commands, captures a validated ID, and builds the corresponding -//! resume command. +//! the command can otherwise start a new conversation. Each harness captures a +//! validated ID and builds the argv that opens or resumes a conversation. //! -//! Detection accepts only a bare program word or its canonical resume form: -//! program word, fixed selector, one strict UUID, and end of line. Everything -//! else remains opaque and runs and saves verbatim. +//! A managed launch is built here from an [`Intent`]: the binary found on +//! `PATH`, the conversation selection, then the harness's overlay (capture +//! hook, config overrides, environment). No shell parses that argv. +//! +//! A literal command still goes through detection in this release: a bare +//! program word or its canonical resume form (program word, fixed selector, +//! one strict UUID, end of line) gets the same argv elements appended as +//! shell text. Everything else remains opaque and runs and saves verbatim. //! //! # Security invariant //! -//! Every ID returned by `parse_capture` or `live_session_id` eventually enters a shell -//! command. These methods return only strings accepted by [`is_uuid`]; return `None` -//! for free text, paths, and malformed IDs. Summary adapters and `live_blocked_status` -//! are display-only. +//! Every ID returned by `parse_capture` or `live_session_id` eventually enters a +//! shell command (a literal task's resume form) or an agent's argv (a managed +//! task's). These methods return only strings accepted by [`is_uuid`]; return +//! `None` for free text, paths, and malformed IDs. Summary adapters and +//! `live_blocked_status` are display-only. pub mod assets; mod claude; @@ -22,9 +27,10 @@ mod omp; pub mod summary; use std::{ - ffi::OsString, - fs::File, + ffi::{OsStr, OsString}, + fs::{self, File}, io::Read, + os::unix::fs::PermissionsExt, path::{Path, PathBuf}, time::SystemTime, }; @@ -68,15 +74,20 @@ pub trait Harness: Sync { detect_shape(cmd, program, selector) } - /// Build spawn-time command and environment additions. `home` is resolved - /// from the launch environment; `None` uses the harness's platform-home - /// fallback. - fn instrument( - &self, - inv: &Invocation, - capture: &CapturePaths, - home: Option<&Path>, - ) -> SpawnPlan; + /// The flag that pins a session ID on a fresh launch, for tools that + /// accept one. `None` means a fresh conversation gets its ID from the + /// tool, and only capture or the registry can report it. + fn session_flag(&self) -> Option<&'static str> { + None + } + + /// Spawn-time additions every launch of this tool carries, whichever + /// conversation it opens: argv elements after the conversation selection, + /// environment pairs, and a notice when the launch carries less than + /// usual. `home` is resolved from the launch environment; `None` uses the + /// harness's platform-home fallback. Leaves `resume_id` unset; [`plan`] + /// fills it from the intent. + fn overlay(&self, capture: &CapturePaths, home: Option<&Path>) -> SpawnPlan; /// Extract a session ID from the capture file's contents. `pid` is the /// task's session leader and `home` the launch-time harness home. When @@ -176,6 +187,33 @@ pub fn detect(cmd: &str) -> Option<(&'static dyn Harness, Invocation)> { .find_map(|a| a.harness.detect(cmd).map(|inv| (a.harness, inv))) } +/// The harness registered under exactly `program`: the word a managed +/// launch names, never a path or a basename match. +pub fn registered(program: &str) -> Option<&'static dyn Harness> { + AGENTS + .iter() + .map(|a| a.harness) + .find(|h| h.shape().0 == program) +} + +/// The first executable regular file named `program` in `path`, returned as +/// found. The path is never canonicalized: a self-updater that repoints +/// `~/.local/bin/claude` is followed at the next launch. Components that are +/// not absolute (empty, `.`, `bin`) are skipped: they would resolve against +/// the daemon's cwd here and against the task's cwd at exec, so the two +/// could name different files. +pub fn find_on_path(program: &str, path: &OsStr) -> Option { + std::env::split_paths(path) + .filter(|dir| dir.is_absolute()) + .map(|dir| dir.join(program)) + .find(|candidate| executable_file(candidate)) +} + +/// Whether `path` is a regular file with any execute bit, following symlinks. +fn executable_file(path: &Path) -> bool { + fs::metadata(path).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0) +} + /// Select a summary adapter by the basename of the command's first /// whitespace-separated word. Arguments are accepted; do not select an adapter /// for environment prefixes or compound shell commands. Selection is @@ -192,23 +230,94 @@ pub fn select(command: &str) -> Option<&'static dyn crate::preview::SummaryAdapt /// Classification of an accepted agent-CLI command. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Invocation { - /// The bare program word: `instrument` may pin a fresh session ID. + /// The bare program word: the launch takes the `Fresh` intent part. Bare, - /// The canonical resume form: the command already targets this ID, so - /// launch-time pinning is off. + /// The canonical resume form: the command already carries the selector + /// and this ID, so the launch takes the overlay alone. Resume(String), } -impl Invocation { - /// The session ID the command already targets. - pub fn known_id(self) -> Option { - match self { - Self::Bare => None, - Self::Resume(id) => Some(id), +/// Which conversation a managed launch opens. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Intent { + /// A new conversation. A tool with a [`Harness::session_flag`] gets an + /// ID minted by the supervisor. + Fresh, + /// The conversation with this ID, which passed [`is_uuid`] at its + /// source: a capture, a registry record, or a detected command. + Resume(String), +} + +/// Argv that selects the conversation, and the ID the launch targets when +/// one is known at launch. `Fresh` pins `fresh_id` through the tool's session +/// flag where it has one; `None` (a mint failure) launches unpinned, as every +/// release has. `Resume` names the ID after the tool's resume selector. The +/// ID is one argv element and no shell parses it, so [`is_uuid`] is a second +/// check here rather than the only one. +pub fn intent_args( + h: &dyn Harness, + intent: &Intent, + fresh_id: Option<&str>, +) -> (Vec, Option) { + match intent { + Intent::Fresh => match (h.session_flag(), fresh_id) { + (Some(flag), Some(id)) => (vec![flag.into(), id.into()], Some(id.to_string())), + _ => (Vec::new(), None), + }, + Intent::Resume(id) => { + debug_assert!(is_uuid(id), "Intent::Resume carries a validated ID"); + ( + vec![h.shape().1.into(), id.as_str().into()], + Some(id.clone()), + ) } } } +/// The complete plan for one launch of `h`: the intent part first, then the +/// overlay. Every tool takes its subcommand or session flag before config +/// flags (`codex resume -c …`), so the order is fixed here, once. +pub fn plan( + h: &dyn Harness, + intent: &Intent, + fresh_id: Option<&str>, + capture: &CapturePaths, + home: Option<&Path>, +) -> SpawnPlan { + let (mut args, resume_id) = intent_args(h, intent, fresh_id); + let overlay = h.overlay(capture, home); + args.extend(overlay.args); + SpawnPlan { + args, + resume_id, + ..overlay + } +} + +/// Shell text for `args`, appended to a detected literal command before it +/// is passed to `$SHELL -c`. Every element gets a leading space. Values are +/// single-quoted. A bare flag (`-` followed by word characters, which no +/// POSIX shell expands) stays unquoted, so the exec string is byte-identical +/// to what instrumentation wrote before managed launches existed; the shell +/// builds the same argv either way. Transitional: the literal path loses +/// instrumentation once managed launches carry it alone. +pub fn shell_words(args: &[OsString]) -> String { + args.iter() + .map(|a| { + let word = a.to_string_lossy(); + let bare_flag = word.starts_with('-') + && word + .bytes() + .all(|b| b == b'-' || b == b'_' || b.is_ascii_alphanumeric()); + if bare_flag { + format!(" {word}") + } else { + format!(" {}", shell_quote(&word)) + } + }) + .collect() +} + /// Capture paths allocated by [`assets::CaptureAssets::paths_for`]. #[derive(Debug, Clone)] pub struct CapturePaths { @@ -228,16 +337,18 @@ pub struct CapturePaths { pub fleetcom_binary: Option, } -/// Spawn-time additions for one instrumented launch. +/// Spawn-time additions for one launch: argv elements, never shell text. A +/// managed launch passes `args` to the binary directly; a detected literal +/// command gets them appended through [`shell_words`]. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct SpawnPlan { - /// Appended verbatim to the user's command string before it is passed to - /// `$SHELL -c`. Starts with a space when non-empty. - pub args_suffix: String, + /// Argv elements after the binary. + pub args: Vec, /// Environment pairs added to the child. pub env: Vec<(OsString, OsString)>, - /// The session ID chosen at launch, when the harness can pin one. - pub injected_id: Option, + /// The session ID the launch targets, when known at launch: the pinned + /// fresh ID or the resumed one. + pub resume_id: Option, /// One-line reason the launch carries less instrumentation than usual. /// The supervisor reports it on the status line once the task spawns. pub notice: Option, @@ -312,7 +423,7 @@ fn capture_id(v: &jzon::JsonValue, key: &str) -> Option { /// Generate a v4 UUID from `/dev/urandom`. Return `None` on a read failure; launch /// without pinning an ID in that case. -fn uuid_v4() -> Option { +pub(crate) fn uuid_v4() -> Option { use std::fmt::Write; let mut bytes = [0u8; 16]; File::open("/dev/urandom") @@ -331,20 +442,6 @@ fn uuid_v4() -> Option { Some(out) } -/// Spawn plan for the launch-time ID pin: a bare launch pins a fresh v4 UUID -/// through `--session-id`, the resume form already targets its conversation, -/// and a `uuid_v4` failure launches without pinning. -fn pin_plan(inv: &Invocation) -> SpawnPlan { - let mut plan = SpawnPlan::default(); - if *inv == Invocation::Bare - && let Some(id) = uuid_v4() - { - plan.args_suffix = format!(" --session-id {}", shell_quote(&id)); - plan.injected_id = Some(id); - } - plan -} - /// Single-quote `s` for `$SHELL -c`, encoding embedded `'` as `'\''`. fn shell_quote(s: &str) -> String { format!("'{}'", s.replace('\'', "'\\''")) @@ -353,7 +450,7 @@ fn shell_quote(s: &str) -> String { /// Fixtures and assertions for harness detection and capture. #[cfg(test)] pub(crate) mod fixtures { - use std::path::PathBuf; + use std::{ffi::OsString, path::PathBuf}; use super::{CapturePaths, Harness}; @@ -362,6 +459,11 @@ pub(crate) mod fixtures { /// A second distinct ID for requote and precedence cases. pub(crate) const OTHER: &str = "11111111-2222-4333-8444-555555555555"; + /// Argv elements from string literals, for snapshot assertions. + pub(super) fn argv(words: &[&str]) -> Vec { + words.iter().map(OsString::from).collect() + } + /// Capture-path fixture. Include spaces in asset paths to test shell and TOML /// quoting. pub(super) fn paths() -> CapturePaths { @@ -587,6 +689,151 @@ mod tests { assert_eq!(String::from_utf8(out.stdout).unwrap(), path); } + /// The pin goes only where a session flag exists; a resume always names + /// the ID after the tool's selector; the reported ID follows the intent. + #[test] + fn intent_args_pin_only_behind_a_session_flag_and_always_resume() { + for a in AGENTS { + let h = a.harness; + let (prog, sel) = h.shape(); + let expected = match h.session_flag() { + Some(flag) => (vec![OsString::from(flag), ID.into()], Some(ID.to_string())), + None => (Vec::new(), None), + }; + assert_eq!(intent_args(h, &Intent::Fresh, Some(ID)), expected, "{prog}"); + assert_eq!( + intent_args(h, &Intent::Fresh, None), + (Vec::new(), None), + "{prog}: a mint failure launches unpinned" + ); + assert_eq!( + intent_args(h, &Intent::Resume(OTHER.into()), Some(ID)), + ( + vec![OsString::from(sel), OTHER.into()], + Some(OTHER.to_string()) + ), + "{prog}: a resume ignores the minted id" + ); + } + } + + /// Values are quoted; bare flags are not; the shell rebuilds the same + /// argv from the text. + #[test] + fn shell_words_quotes_values_and_leaves_bare_flags() { + let args: Vec = [ + "--session-id", + ID, + "-c", + "notify=[\"/App Support/n.sh\"]", + "-e", + "it's", + "resume", + "--x_y", + "-", + "", + "--not$bare", + ] + .iter() + .map(OsString::from) + .collect(); + let text = shell_words(&args); + assert_eq!( + text, + format!( + " --session-id '{ID}' -c 'notify=[\"/App Support/n.sh\"]' -e 'it'\\''s' \ + 'resume' --x_y - '' '--not$bare'" + ) + ); + assert_eq!(shell_words(&[]), ""); + let out = std::process::Command::new("sh") + .arg("-c") + .arg(format!("printf '%s\\n'{text}")) + .output() + .expect("sh must run"); + let words: Vec<&str> = std::str::from_utf8(&out.stdout) + .unwrap() + .split_terminator('\n') + .collect(); + let expected: Vec<&str> = args.iter().map(|a| a.to_str().unwrap()).collect(); + assert_eq!(words, expected, "the shell must rebuild every element"); + } + + /// Only the exact program word is a registered agent: no paths, no + /// prefixes, no basename matching. + #[test] + fn registered_matches_the_exact_program_word() { + for a in AGENTS { + let prog = a.harness.shape().0; + assert_eq!(registered(prog).map(|h| h.shape().0), Some(prog)); + assert!(registered(&format!("{BIN}/{prog}")).is_none(), "{prog}"); + assert!(registered(&format!("{prog}x")).is_none(), "{prog}"); + } + assert!(registered("vim").is_none()); + assert!(registered("").is_none()); + } + + /// Resolution walks absolute components only, skips files without an + /// execute bit and directories, takes the first hit, and returns a + /// symlink as found rather than its target. + #[test] + fn find_on_path_takes_the_first_executable_file_in_an_absolute_dir() { + use crate::testutil::{temp, write_executable}; + use std::{os::unix::fs::symlink, path::Component}; + let dir = temp("find_on_path"); + let (a, b, c, l) = (dir.join("a"), dir.join("b"), dir.join("c"), dir.join("l")); + for d in [&a, &b, &c] { + fs::create_dir_all(d).unwrap(); + } + fs::write(a.join("claude"), "#!/bin/sh\n").unwrap(); // no execute bit + fs::create_dir_all(a.join("codex")).unwrap(); // a directory + write_executable(&b.join("claude"), ""); + write_executable(&c.join("claude"), ""); + write_executable(&c.join("grok"), ""); + symlink(&c, &l).unwrap(); + let join = |dirs: &[&Path]| std::env::join_paths(dirs).unwrap(); + + assert_eq!( + find_on_path("claude", &join(&[&a, &b, &c])), + Some(b.join("claude")), + "the first executable regular file wins" + ); + assert_eq!(find_on_path("codex", &join(&[&a, &b, &c])), None); + assert_eq!(find_on_path("omp", &join(&[&a, &b, &c])), None); + assert_eq!( + find_on_path("grok", &join(&[&l, &c])), + Some(l.join("grok")), + "a link is returned as found, never canonicalized" + ); + assert_eq!(find_on_path("claude", OsStr::new("")), None); + + // A relative spelling of `b` resolves from this process's cwd, and + // is still skipped: the task would resolve it from its own. + let cwd = std::env::current_dir().unwrap(); + let ups = cwd + .components() + .filter(|c| matches!(c, Component::Normal(_))) + .count(); + let rel: PathBuf = std::iter::repeat_n("..", ups) + .collect::() + .join(b.strip_prefix("/").unwrap()); + assert!( + executable_file(&rel.join("claude")), + "premise: the relative spelling reaches the binary from here" + ); + let mut relative = vec![PathBuf::new(), ".".into(), "bin".into(), rel]; + assert_eq!( + find_on_path("claude", &std::env::join_paths(&relative).unwrap()), + None + ); + relative.push(b.clone()); + assert_eq!( + find_on_path("claude", &std::env::join_paths(&relative).unwrap()), + Some(b.join("claude")), + "the absolute component behind them still resolves" + ); + } + #[test] fn registry_detect_routes_to_the_matching_harness() { for a in AGENTS { diff --git a/src/harness/omp.rs b/src/harness/omp.rs index b132dc1..9ec9239 100644 --- a/src/harness/omp.rs +++ b/src/harness/omp.rs @@ -18,7 +18,7 @@ use std::path::Path; -use super::{CAPTURE_ENV, CapturePaths, Harness, Invocation, SpawnPlan, capture_id, shell_quote}; +use super::{CAPTURE_ENV, CapturePaths, Harness, SpawnPlan, capture_id}; pub struct Omp; @@ -27,24 +27,16 @@ impl Harness for Omp { ("omp", "--resume") } - /// Append the capture extension with `-e` for both accepted command shapes. - fn instrument( - &self, - _inv: &Invocation, - capture: &CapturePaths, - _home: Option<&Path>, - ) -> SpawnPlan { + /// Load the capture extension with `-e`. No session flag: omp has no + /// `--session-id`, so a pinned UUID could not be resumed. + fn overlay(&self, capture: &CapturePaths, _home: Option<&Path>) -> SpawnPlan { SpawnPlan { - args_suffix: format!( - " -e {}", - shell_quote(&capture.omp_capture.to_string_lossy()) - ), + args: vec!["-e".into(), capture.omp_capture.clone().into_os_string()], env: vec![( CAPTURE_ENV.into(), capture.capture_file.clone().into_os_string(), )], - injected_id: None, - notice: None, + ..SpawnPlan::default() } } @@ -66,11 +58,18 @@ mod tests { use std::path::PathBuf; use super::*; - use crate::harness::fixtures::{ID, assert_all_opaque, paths}; + use crate::harness::{ + Intent, + fixtures::{ID, argv, assert_all_opaque, paths}, + plan, shell_words, + }; /// Valid UUIDv7 used in capture payloads. const CAPTURED: &str = "01a0077c-e18e-7000-ae0b-016f4834b6e9"; + /// The extension path from [`paths`]. + const EXTENSION: &str = "/tmp/Application Support/omp-capture.js"; + /// omp-specific aliases, shortcuts, prompts, and malformed resume forms /// remain opaque. #[test] @@ -88,28 +87,42 @@ mod tests { assert_all_opaque(&Omp, ID, &opaque); } - /// Load the extension and specify the capture file for both accepted shapes. Pin no - /// ID: omp has no `--session-id`, so a pinned UUID could not be resumed. Include a - /// space in the fixture's asset path to verify quoting as one word. + /// Managed argv: a fresh launch loads the extension alone, since omp has + /// no `--session-id` and a minted ID would be unresumable; a resume names + /// its conversation first. Both name the capture file in the environment. #[test] - fn instrument_loads_the_extension_for_either_accepted_shape() { - for cmd in ["omp".to_string(), format!("omp --resume {ID}")] { - let inv = Omp.detect(&cmd).unwrap(); - let plan = Omp.instrument(&inv, &paths(), None); - assert_eq!(plan.injected_id, None, "{cmd}"); - assert_eq!( - plan.args_suffix, " -e '/tmp/Application Support/omp-capture.js'", - "{cmd}" - ); - assert_eq!( - plan.env, - vec![( - CAPTURE_ENV.into(), - PathBuf::from("/tmp/cap/session.json").into_os_string() - )], - "{cmd}" - ); - } + fn managed_argv_loads_the_extension_and_pins_nothing() { + let fresh = plan(&Omp, &Intent::Fresh, Some(ID), &paths(), None); + assert_eq!(fresh.args, argv(&["-e", EXTENSION])); + assert_eq!(fresh.resume_id, None, "the minted id is ignored"); + assert_eq!( + fresh.env, + vec![( + CAPTURE_ENV.into(), + PathBuf::from("/tmp/cap/session.json").into_os_string() + )] + ); + + let resume = plan(&Omp, &Intent::Resume(CAPTURED.into()), None, &paths(), None); + assert_eq!(resume.args, argv(&["--resume", CAPTURED, "-e", EXTENSION])); + assert_eq!(resume.resume_id.as_deref(), Some(CAPTURED)); + assert_eq!(resume.env, fresh.env); + } + + /// Both literal shapes get the extension as one quoted word (the + /// fixture path has a space) and no pin. + #[test] + fn literal_suffix_loads_the_extension_for_either_accepted_shape() { + let overlay = Omp.overlay(&paths(), None); + assert_eq!(overlay.resume_id, None); + assert_eq!( + shell_words(&overlay.args), + " -e '/tmp/Application Support/omp-capture.js'" + ); + // The bare word's fresh intent part is empty, so its suffix is the + // overlay's. + let fresh = plan(&Omp, &Intent::Fresh, Some(ID), &paths(), None); + assert_eq!(fresh.args, overlay.args); } /// Extract only a validated ID from the extension payload. diff --git a/src/supervisor.rs b/src/supervisor.rs index 338d50d..631d892 100644 --- a/src/supervisor.rs +++ b/src/supervisor.rs @@ -15,13 +15,13 @@ use std::{ use crate::{ core::{Wake, Waker}, - harness::{self, assets}, + harness::{self, Harness, Intent, Invocation, assets}, path, protocol::{ Command, Event, LaunchContext, ScreenView, ScrollAction, TaskView, UNASSIGNED, env_get, }, session::{self, SessionConfig, SessionEntry}, - task::{Task, WriteRefused}, + task::{Exec, Task, WriteRefused}, }; /// Quiet period after which a live task becomes idle. @@ -44,6 +44,26 @@ const _: () = assert!(MAX_CELLS >= MAX_DIM as u32); /// guardrail, not a working limit. const MAX_TASKS: usize = 256; +/// What a spawn was asked to run. The enum is the boundary: a literal task's +/// text is never edited by managed code, and a managed task's argv never +/// passes through a shell. +enum Launch<'a> { + /// The text the user typed, run verbatim under `$SHELL -c`. In this + /// release a bare agent word or its resume form still gets detected and + /// instrumented; that path goes once managed launches carry it alone. + Literal(&'a str), + /// An agent fleetcom launches and owns. + Managed(ManagedLaunch), +} + +/// A managed agent launch: which tool, which binary, which conversation. +struct ManagedLaunch { + agent: &'static dyn Harness, + /// Found on the launch context's `PATH` at this spawn; executed as-is. + binary: PathBuf, + intent: Intent, +} + /// Maximum command length in bytes. Direct spawns and session loads enforce /// this limit to bound shell arguments and serialized task snapshots. const MAX_COMMAND_LEN: usize = 64 * 1024; @@ -275,6 +295,9 @@ pub struct Supervisor { /// TERM→KILL escalation window. `KILL_GRACE` in production; a field so tests /// shrink it instead of sleeping through real seconds. kill_grace: Duration, + /// Live-task ceiling. `MAX_TASKS` in production; a field so tests reach + /// it without opening hundreds of PTYs. + max_tasks: usize, /// Capture assets keyed by canonicalized root and reused for this /// supervisor's lifetime. capture: BTreeMap, @@ -299,6 +322,7 @@ impl Supervisor { events: Vec::new(), waker: Arc::new(Mutex::new(None)), kill_grace: KILL_GRACE, + max_tasks: MAX_TASKS, capture: BTreeMap::new(), recovery: Recovery::new(), } @@ -315,6 +339,12 @@ impl Supervisor { self.kill_grace = grace; } + /// Lower the live-task ceiling so limit tests need a handful of tasks. + #[cfg(test)] + pub fn set_max_tasks(&mut self, limit: usize) { + self.max_tasks = limit; + } + /// Enable recovery with test-specific timings. #[cfg(test)] pub fn set_recovery_timing(&mut self, debounce: Duration, cadence: Duration) { @@ -733,39 +763,93 @@ impl Supervisor { Some(self.capture.entry(key).or_insert(installed)) } - /// Spawn a direct command, rerun, or session entry. Instrument only the executed - /// shell string; retain the requested command for display and persistence. + /// Spawn a direct command, managed agent, rerun, or session entry. The + /// task's `command` is display and recipe text: a literal's typed text, a + /// managed agent's program word. Only the exec form carries + /// instrumentation. fn spawn_task( &mut self, id: u64, run: u32, - command: &str, + launch: &Launch, cwd: &Path, env: &[(OsString, OsString)], ) -> io::Result { - // Instrumentation, when active, contributes an exec suffix, extra env, - // and post-spawn metadata; a plain command contributes nothing. A - // detected harness without capture assets (install failure) spawns - // plain: instrumentation is disabled, not the task. - let mut exec = std::borrow::Cow::Borrowed(command); let mut env = std::borrow::Cow::Borrowed(env); let mut meta = None; let mut notice = None; - if let Some((h, inv)) = harness::detect(command) - && let Some(paths) = self.ensure_capture_assets().map(|a| a.paths_for(id, run)) - { - let home = harness_home(&env, h); - let plan = h.instrument(&inv, &paths, home.as_deref()); - exec = format!("{command}{}", plan.args_suffix).into(); - env.to_mut().extend(plan.env); - let resume_id = plan.injected_id.or_else(|| inv.known_id()); - notice = plan.notice; - meta = Some((h, home, paths.capture_file, resume_id)); - } + let (command, exec) = match launch { + Launch::Literal(text) => { + // Detection, when it fires, contributes a shell suffix, extra + // env, and post-spawn metadata; a plain command contributes + // nothing. A detected harness without capture assets (install + // failure) spawns plain: instrumentation is disabled, not the + // task. + let mut exec = text.to_string(); + if let Some((h, inv)) = harness::detect(text) + && let Some(paths) = self.ensure_capture_assets().map(|a| a.paths_for(id, run)) + { + let home = harness_home(&env, h); + let plan = match inv { + Invocation::Bare => harness::plan( + h, + &Intent::Fresh, + harness::uuid_v4().as_deref(), + &paths, + home.as_deref(), + ), + // The typed text already carries the selector and ID. + Invocation::Resume(known) => harness::SpawnPlan { + resume_id: Some(known), + ..h.overlay(&paths, home.as_deref()) + }, + }; + exec.push_str(&harness::shell_words(&plan.args)); + env.to_mut().extend(plan.env); + notice = plan.notice; + meta = Some((h, home, paths.capture_file, plan.resume_id)); + } + (*text, Exec::Literal(exec)) + } + Launch::Managed(m) => { + // Ownership needs the overlay: without the embedded override a + // managed codex would hand its conversation to the shared + // server. Refuse rather than launch something fleetcom does + // not own. + let Some(paths) = self.ensure_capture_assets().map(|a| a.paths_for(id, run)) else { + return Err(io::Error::other( + "capture assets unavailable; a managed launch needs them", + )); + }; + let home = harness_home(&env, m.agent); + // Mint only for a fresh conversation: a resume names its own. + let fresh = match m.intent { + Intent::Fresh => harness::uuid_v4(), + Intent::Resume(_) => None, + }; + let plan = harness::plan( + m.agent, + &m.intent, + fresh.as_deref(), + &paths, + home.as_deref(), + ); + env.to_mut().extend(plan.env); + notice = plan.notice; + meta = Some((m.agent, home, paths.capture_file, plan.resume_id)); + ( + m.agent.shape().0, + Exec::Managed { + binary: m.binary.clone(), + args: plan.args, + }, + ) + } + }; let mut task = Task::spawn( id, command, - &exec, + exec, cwd, self.rows, self.cols, @@ -789,18 +873,18 @@ impl Supervisor { } /// Spawn a task under the next id, normalize its labels, and return the id. - /// The caller enforces `MAX_TASKS` and reports failures because direct + /// The caller enforces the task ceiling and reports failures because direct /// spawns and session loads handle them differently. fn admit( &mut self, - command: &str, + launch: &Launch, cwd: &Path, env: &[(OsString, OsString)], group: Option, name: Option, ) -> io::Result { let id = self.next_id; - let mut task = self.spawn_task(id, 0, command, cwd, env)?; + let mut task = self.spawn_task(id, 0, launch, cwd, env)?; task.group = normalize_group(group); task.name = normalize_label(name); self.next_id += 1; @@ -808,6 +892,18 @@ impl Supervisor { Ok(id) } + /// Whether the fleet has room for one more task, reporting when not. + fn below_task_ceiling(&mut self) -> bool { + if self.tasks.len() >= self.max_tasks { + self.status(format!( + "task limit reached ({}), not spawning", + self.max_tasks + )); + return false; + } + true + } + fn spawn(&mut self, command: &str, cwd: PathBuf, group: Option) { if command.len() > MAX_COMMAND_LEN { self.status(format!( @@ -817,14 +913,13 @@ impl Supervisor { )); return; } - if self.tasks.len() >= MAX_TASKS { - self.status(format!("task limit reached ({MAX_TASKS}), not spawning")); + if !self.below_task_ceiling() { return; } let Some(launch) = self.launch_or_refuse() else { return; }; - match self.admit(command, &cwd, &launch.env, group, None) { + match self.admit(&Launch::Literal(command), &cwd, &launch.env, group, None) { // Preserve event order: the acknowledgement precedes the next // `Tasks` snapshot containing this id. Ok(id) => self.events.push(Event::Spawned { id }), @@ -832,9 +927,51 @@ impl Supervisor { } } - /// Rerun a finished task in place while preserving its ID, tag, group, and name. If - /// the task has a captured agent session, resume the best-known ID in the - /// replacement. + /// Launch `agent`, a registered program word, as a managed task with a + /// fresh conversation. The binary is whatever the launch context's `PATH` + /// finds now, never a path the client sent; no binary means no spawn. + /// Nothing reaches this entry point yet: `Command::SpawnAgent` lands in + /// the next phase. + #[allow(dead_code)] + pub fn spawn_agent(&mut self, agent: &str, cwd: PathBuf, group: Option) { + let Some(h) = harness::registered(agent) else { + self.status(format!("no agent named {agent:?}, not spawning")); + return; + }; + if !self.below_task_ceiling() { + return; + } + let Some(launch) = self.launch_or_refuse() else { + return; + }; + let Some(binary) = self.agent_binary(agent, &launch) else { + return; + }; + let managed = Launch::Managed(ManagedLaunch { + agent: h, + binary, + intent: Intent::Fresh, + }); + match self.admit(&managed, &cwd, &launch.env, group, None) { + Ok(id) => self.events.push(Event::Spawned { id }), + Err(e) => self.status(format!("spawn failed: {e}")), + } + } + + /// Resolve `agent` on `launch`'s `PATH`, reporting a miss. + fn agent_binary(&mut self, agent: &str, launch: &LaunchContext) -> Option { + let path = env_get(&launch.env, "PATH").unwrap_or_default(); + let found = harness::find_on_path(agent, path); + if found.is_none() { + self.status(format!("{agent} not found on PATH, not spawning")); + } + found + } + + /// Rerun a finished task in place while preserving its ID, tag, group, and name. + /// A managed task resumes its best-known ID through the harness, or starts + /// fresh without one, from whatever binary `PATH` finds now. A literal + /// task with a captured agent session reruns its resume form. fn rerun(&mut self, id: u64) { let Some(i) = self.index_of(id) else { self.status(format!("rerun failed: no task {id}")); @@ -855,17 +992,34 @@ impl Supervisor { let Some(launch) = self.launch_or_refuse() else { return; }; - // Store the resuming form so detection treats the replacement as a - // targeted conversation. - let (command, cwd) = { - let old = &self.tasks[i]; - let command = recipe_command(old); - (command, old.cwd.clone()) + let cwd = self.tasks[i].cwd.clone(); + let command; + let relaunch = match (self.tasks[i].managed, self.tasks[i].harness) { + // A managed task always carries its harness; the tuple keeps the + // literal arm as the only other path rather than panicking. + (true, Some(agent)) => { + let Some(binary) = self.agent_binary(agent.shape().0, &launch) else { + return; + }; + let intent = + current_resume_id(&self.tasks[i]).map_or(Intent::Fresh, Intent::Resume); + Launch::Managed(ManagedLaunch { + agent, + binary, + intent, + }) + } + // Store the resuming form so detection treats the replacement as + // a targeted conversation. + _ => { + command = recipe_command(&self.tasks[i]); + Launch::Literal(&command) + } }; // Preserve the finished task if its replacement cannot start. Use a distinct // run number to isolate the replacement's capture file. let run = self.tasks[i].run + 1; - match self.spawn_task(id, run, &command, &cwd, &launch.env) { + match self.spawn_task(id, run, &relaunch, &cwd, &launch.env) { Ok(mut fresh) => { fresh.tagged = self.tasks[i].tagged; fresh.group = self.tasks[i].group.clone(); @@ -961,13 +1115,13 @@ impl Supervisor { continue; } for entry in entries { - if self.tasks.len() >= MAX_TASKS || entry.cmd.len() > MAX_COMMAND_LEN { + if self.tasks.len() >= self.max_tasks || entry.cmd.len() > MAX_COMMAND_LEN { skipped += 1; continue; } // `admit` normalizes the persisted labels before assignment. match self.admit( - &entry.cmd, + &Launch::Literal(&entry.cmd), &resolved, &launch.env, entry.group.clone(), diff --git a/src/supervisor_capture_tests.rs b/src/supervisor_capture_tests.rs index e9c0915..98d5836 100644 --- a/src/supervisor_capture_tests.rs +++ b/src/supervisor_capture_tests.rs @@ -1,8 +1,8 @@ use super::*; use crate::{ harness::fixtures::{ID as CAP_ID, OTHER as CAP_OTHER}, - protocol::{Preview, PreviewSource}, - testutil::{codex_session_meta, install_codex_rollout, install_codex_root}, + protocol::{Lifecycle, Preview, PreviewSource}, + testutil::{codex_session_meta, dead_pid, install_codex_rollout, install_codex_root}, }; // --- session-capture wiring ------------------------------------------- @@ -106,6 +106,22 @@ fn save_and_read(s: &mut Supervisor, config: &Path, name: &str) -> String { std::fs::read_to_string(config.join("sessions").join(format!("{name}.json"))).unwrap() } +/// Drain the queued events and return the status lines among them. +fn notices(s: &mut Supervisor) -> Vec { + s.drain() + .into_iter() + .filter_map(|e| match e { + Event::Status(m) => Some(m), + _ => None, + }) + .collect() +} + +/// Whether a drained event batch acknowledges a spawn. +fn acknowledged(events: &[Event]) -> bool { + events.iter().any(|e| matches!(e, Event::Spawned { .. })) +} + /// The FNV-1a discriminator is stable and separates distinct config roots. #[test] fn fnv_discriminator_is_stable_and_distinguishes_roots() { @@ -1474,15 +1490,6 @@ fn unrepresentable_config_notify_suppresses_injection() { dir.to_path_buf(), &[("CODEX_HOME", &codex_home)], )); - let notices = |s: &mut Supervisor| -> Vec { - s.drain() - .into_iter() - .filter_map(|e| match e { - Event::Status(m) => Some(m), - _ => None, - }) - .collect() - }; spawn(&mut s, "codex", dir.to_path_buf()); assert_eq!( notices(&mut s), @@ -1609,6 +1616,327 @@ fn recovery_cadence_rewrites_on_capture_drift_and_skips_when_static() { assert_eq!(names.len(), 1, "one incarnation owns one snapshot file"); } +// --- managed launches -------------------------------------------------- + +/// Install a resident shell at `/resident-sh`: it runs its `-c` text as +/// a child and stays the leader, as tcsh and csh do. A launch context naming +/// it as `SHELL` proves the shell plays no part in a managed launch. +fn install_resident_shell(dir: &Path) -> PathBuf { + let shell = dir.join("resident-sh"); + // `:` after the child keeps sh from exec'ing it as the script's last + // command. + write_executable(&shell, "[ \"$1\" = -c ] || exit 2\n/bin/sh -c \"$2\"\n:"); + shell +} + +/// `agent_ctx` with `SHELL` pointing at the resident shell. +fn resident_shell_ctx(bin: &Path, runtime: &Path, dir: &Path) -> LaunchContext { + let shell = install_resident_shell(dir); + let mut ctx = agent_ctx(bin, runtime, dir.to_path_buf()); + ctx.env.retain(|(k, _)| k != "SHELL"); + ctx.env.push(("SHELL".into(), shell.into_os_string())); + ctx +} + +/// The settings overlay beside the sole task's capture file. +fn settings_beside(s: &Supervisor) -> String { + s.tasks[0] + .capture_file + .as_deref() + .and_then(Path::parent) + .expect("a managed task has a namespaced capture file") + .join("claude-settings.json") + .display() + .to_string() +} + +/// A managed claude is the task leader whatever `SHELL` names, so a capture +/// its own hook stamps with `$$` passes the gate; the same payload stamped +/// by any other process is refused and the pinned ID stands. +#[test] +fn managed_claude_accepts_its_own_capture_and_refuses_a_foreign_stamp() { + let dir = scratch("managed_capture"); + let (bin, runtime) = (dir.join("bin"), dir.join("run")); + // The stub plays claude running its SessionStart hook after `/clear`: + // the capture names a drifted ID under the stub's own pid. + install_script( + &bin, + "claude", + &format!( + "printf '%s\\n' \"$@\" > '{out}/argv'\n\ + printf '%s\\n{{\"session_id\":\"{CAP_OTHER}\",\"hook_event_name\":\"SessionStart\",\"source\":\"clear\"}}\\n' \"$$\" > \"$FLEETCOM_CAPTURE_FILE\"", + out = dir.display() + ), + ); + let mut s = sup_ctx(resident_shell_ctx(&bin, &runtime, &dir)); + s.spawn_agent("claude", dir.to_path_buf(), None); + assert!(acknowledged(&s.drain()), "a managed spawn is acknowledged"); + let argv = wait_argv(&mut s, &dir.join("argv")); + let id = s.tasks[0].id; + wait_for_lifecycle(&mut s, id, |l| l == Lifecycle::Ok); + + let t = &s.tasks[0]; + assert!(t.managed); + assert_eq!(t.command, "claude", "the program word is the display text"); + assert!(t.harness.is_some()); + let pinned = t + .resume_id + .clone() + .expect("a fresh claude launch pins an id"); + assert_eq!( + argv, + ["--session-id", &pinned, "--settings", &settings_beside(&s)] + ); + assert_eq!( + current_resume_id(&s.tasks[0]).as_deref(), + Some(CAP_OTHER), + "the stub's `$$` is the leader pid: no shell sat between" + ); + + // Any other process's stamp over the same payload is refused. + let cap = s.tasks[0].capture_file.clone().unwrap(); + let text = std::fs::read_to_string(&cap).unwrap(); + let (_, json) = text.split_once('\n').unwrap(); + std::fs::write(&cap, format!("{}\n{json}", dead_pid())).unwrap(); + assert_eq!( + current_resume_id(&s.tasks[0]).as_deref(), + Some(pinned.as_str()), + "a foreign stamp must fall through to the pinned id" + ); +} + +/// An unknown word, a word with no binary on `PATH`, and a full fleet each +/// produce one status line and no task; the task ceiling is the one `Spawn` +/// uses. +#[test] +fn managed_spawn_refuses_an_unknown_word_a_missing_binary_and_a_full_fleet() { + let dir = scratch("managed_refusals"); + let (bin, runtime) = (dir.join("bin"), dir.join("run")); + let mut s = sup_ctx(agent_ctx(&bin, &runtime, dir.to_path_buf())); + + s.spawn_agent("vim", dir.to_path_buf(), None); + let events = s.drain(); + assert!(!acknowledged(&events)); + assert_eq!( + notices(&mut sup_with(events)), + ["no agent named \"vim\", not spawning"] + ); + s.spawn_agent("claude", dir.to_path_buf(), None); + let events = s.drain(); + assert!(!acknowledged(&events)); + assert_eq!( + notices(&mut sup_with(events)), + ["claude not found on PATH, not spawning"] + ); + assert!(s.tasks.is_empty(), "a refused launch creates nothing"); + assert!(!runtime.exists(), "a refused launch installs nothing"); + + install_stub(&bin, "claude", &dir); + s.set_max_tasks(1); + s.spawn_agent("claude", dir.to_path_buf(), None); + assert!(acknowledged(&s.drain())); + assert_eq!(s.tasks.len(), 1); + s.spawn_agent("claude", dir.to_path_buf(), None); + assert_eq!(notices(&mut s), ["task limit reached (1), not spawning"]); + spawn(&mut s, "sleep 1", dir.to_path_buf()); + assert_eq!(notices(&mut s), ["task limit reached (1), not spawning"]); + assert_eq!(s.tasks.len(), 1, "the ceiling holds for both launch kinds"); +} + +/// A supervisor holding only `events`, so `notices` can filter them. +fn sup_with(events: Vec) -> Supervisor { + let mut s = Supervisor::new(24, 80, 0); + s.events = events; + s +} + +/// A managed rerun relaunches through the harness: it resumes the captured +/// ID ahead of the overlay, keeps id, tag, group, name and the program word, +/// bumps the run, and finds the binary on `PATH` again, so a binary removed +/// since the launch refuses the rerun and leaves the finished task alone. +#[test] +fn managed_rerun_resumes_the_captured_id_from_the_binary_path_finds_now() { + let dir = scratch("managed_rerun"); + let (bin, runtime) = (dir.join("bin"), dir.join("run")); + install_stub(&bin, "claude", &dir); + let mut s = sup_ctx(agent_ctx(&bin, &runtime, dir.to_path_buf())); + s.spawn_agent("claude", dir.to_path_buf(), Some("agents".into())); + let _ = wait_argv(&mut s, &dir.join("argv")); + let id = s.tasks[0].id; + s.tasks[0].tagged = true; + s.tasks[0].name = Some("pilot".into()); + wait_for_lifecycle(&mut s, id, |l| l == Lifecycle::Ok); + std::fs::write( + s.tasks[0].capture_file.as_ref().unwrap(), + stamped( + &s.tasks[0], + &format!( + r#"{{"session_id":"{CAP_OTHER}","hook_event_name":"SessionStart","source":"clear"}}"# + ), + ), + ) + .unwrap(); + std::fs::remove_file(dir.join("argv")).unwrap(); + + s.apply(Command::Restart { id }); + let argv = wait_argv(&mut s, &dir.join("argv")); + assert_eq!( + argv, + ["--resume", CAP_OTHER, "--settings", &settings_beside(&s)], + "the intent part leads, the overlay follows, no second pin" + ); + let t = &s.tasks[0]; + assert!(t.managed, "a rerun keeps the task managed"); + assert_eq!((t.id, t.run, t.command.as_str()), (id, 1, "claude")); + assert_eq!(t.resume_id.as_deref(), Some(CAP_OTHER)); + assert!(t.tagged); + assert_eq!(t.group.as_deref(), Some("agents")); + assert_eq!(t.name.as_deref(), Some("pilot")); + + // The binary is resolved again at each rerun: a removed one refuses. + wait_for_lifecycle(&mut s, id, |l| l == Lifecycle::Ok); + std::fs::remove_file(bin.join("claude")).unwrap(); + s.apply(Command::Restart { id }); + assert_eq!(notices(&mut s), ["claude not found on PATH, not spawning"]); + assert_eq!(s.tasks[0].run, 1, "the finished task is preserved"); +} + +/// Without any known ID, a managed rerun starts a fresh conversation: omp +/// pins nothing, so both runs carry the overlay alone. +#[test] +fn managed_rerun_without_an_id_starts_fresh() { + let dir = scratch("managed_rerun_fresh"); + let (bin, runtime) = (dir.join("bin"), dir.join("run")); + install_stub(&bin, "omp", &dir); + let mut s = sup_ctx(agent_ctx(&bin, &runtime, dir.to_path_buf())); + s.spawn_agent("omp", dir.to_path_buf(), None); + let first = wait_argv(&mut s, &dir.join("argv")); + let id = s.tasks[0].id; + assert_eq!(first[0], "-e"); + assert_eq!(first.len(), 2); + assert!(s.tasks[0].resume_id.is_none(), "omp cannot pin an id"); + wait_for_lifecycle(&mut s, id, |l| l == Lifecycle::Ok); + std::fs::remove_file(dir.join("argv")).unwrap(); + + s.apply(Command::Restart { id }); + let again = wait_argv(&mut s, &dir.join("argv")); + assert_eq!(again, first, "no id known: the rerun is a fresh launch"); + assert!(s.tasks[0].managed); + assert_eq!(s.tasks[0].run, 1); +} + +/// Session format v1 has no managed entry. A managed task saves as the bare +/// program word or its resume form, and that string loads as a detected +/// literal that resumes the same conversation. +#[test] +fn managed_task_saves_in_the_v1_resume_form_and_reloads_as_a_detected_literal() { + let dir = scratch("managed_save"); + let (bin, runtime, config) = (dir.join("bin"), dir.join("run"), dir.join("config")); + install_stub(&bin, "claude", &dir); + install_stub(&bin, "omp", &dir); + let mut s = sup_ctx(agent_ctx_plus( + &bin, + &runtime, + dir.to_path_buf(), + &[("FLEETCOM_CONFIG_DIR", &config)], + )); + s.spawn_agent("claude", dir.to_path_buf(), Some("agents".into())); + s.spawn_agent("omp", dir.to_path_buf(), None); + assert!(s.tasks.iter().all(|t| t.managed)); + let claude = s.tasks[0].id; + wait_for_lifecycle(&mut s, claude, |l| l == Lifecycle::Ok); + std::fs::write( + s.tasks[0].capture_file.as_ref().unwrap(), + stamped(&s.tasks[0], &format!(r#"{{"session_id":"{CAP_ID}"}}"#)), + ) + .unwrap(); + + save_and_read(&mut s, &config, "managed"); + assert_eq!( + session::load_in(&config.join("sessions"), "managed").unwrap(), + SessionConfig::from([( + path::abbreviate(&dir), + vec![ + SessionEntry { + cmd: format!("claude --resume '{CAP_ID}'"), + group: Some("agents".into()), + name: None, + }, + SessionEntry { + cmd: "omp".into(), + group: None, + name: None, + }, + ] + )]) + ); + + // The saved strings load as literal tasks that detection instruments. + std::fs::remove_file(dir.join("argv")).unwrap(); + s.apply(Command::LoadSession { + name: "managed".into(), + }); + assert_eq!(s.tasks.len(), 4); + let reloaded = &s.tasks[2]; + assert!(!reloaded.managed, "v1 has no managed entry"); + assert_eq!(reloaded.command, format!("claude --resume '{CAP_ID}'")); + assert!(reloaded.harness.is_some(), "detection instruments the form"); + assert_eq!(reloaded.resume_id.as_deref(), Some(CAP_ID)); + assert_eq!(reloaded.group.as_deref(), Some("agents")); + assert!(!s.tasks[3].managed); + assert_eq!(s.tasks[3].command, "omp"); + assert!(s.tasks[3].harness.is_some()); + // Two reloaded stubs race for one argv file; both lead with the resume + // or extension element, so either record proves the detected launch. + let argv = wait_argv(&mut s, &dir.join("argv")); + assert!( + argv.starts_with(&["--resume".into(), CAP_ID.into()]) || argv[0] == "-e", + "a reloaded entry must resume or load its extension: {argv:?}" + ); +} + +/// A managed codex carries the embedded override even when capture is off, +/// reports why, and leads a rerun with `resume ` ahead of the overrides. +#[test] +fn managed_codex_reports_the_capture_notice_and_leads_a_rerun_with_resume() { + let dir = scratch("managed_codex"); + let (bin, runtime, codex_home) = (dir.join("bin"), dir.join("run"), dir.join("codex_home")); + std::fs::create_dir_all(&codex_home).unwrap(); + std::fs::write(codex_home.join("config.toml"), "notify = [1]\n").unwrap(); + install_stub(&bin, "codex", &dir); + let mut s = sup_ctx(agent_ctx_plus( + &bin, + &runtime, + dir.to_path_buf(), + &[("CODEX_HOME", &codex_home)], + )); + s.spawn_agent("codex", dir.to_path_buf(), None); + assert_eq!( + notices(&mut s), + ["task 1: codex capture unavailable: `notify` config can't be chained"] + ); + let argv = wait_argv(&mut s, &dir.join("argv")); + assert_eq!(argv, ["-c", "features.daemon_auto_start=false"]); + let id = s.tasks[0].id; + assert!(s.tasks[0].managed); + assert!(s.tasks[0].resume_id.is_none(), "codex cannot pin an id"); + wait_for_lifecycle(&mut s, id, |l| l == Lifecycle::Ok); + + // The v1 slot holds one bare root UUID. + std::fs::write(s.tasks[0].capture_file.as_ref().unwrap(), CAP_ID).unwrap(); + std::fs::remove_file(dir.join("argv")).unwrap(); + s.apply(Command::Restart { id }); + assert_eq!( + notices(&mut s), + ["task 1: codex capture unavailable: `notify` config can't be chained"] + ); + assert_eq!( + wait_argv(&mut s, &dir.join("argv")), + ["resume", CAP_ID, "-c", "features.daemon_auto_start=false"] + ); + assert_eq!(s.tasks[0].resume_id.as_deref(), Some(CAP_ID)); +} + // --- live registry blocked status -------------------------------------- /// Tick until the sole task's preview satisfies `pred` or the budget expires, diff --git a/src/task.rs b/src/task.rs index 0774926..31e15cb 100644 --- a/src/task.rs +++ b/src/task.rs @@ -110,6 +110,11 @@ pub struct Task { pub resume_id: Option, /// Capture path allocated for this task run. pub capture_file: Option, + /// Whether fleetcom owns this task's launch: the argv came from the + /// harness and ran with no shell, and a rerun resumes through the + /// harness again. A literal task's text is never edited, whatever it + /// names. + pub managed: bool, /// Dashboard-preview resolution state; resets with the task on rerun /// because a rerun replaces the whole `Task`. preview: PreviewState, @@ -204,16 +209,33 @@ fn wait_code(status: &rustix::process::WaitIdStatus) -> i32 { .unwrap_or(1) } +/// How a task's child starts: the point where the literal/managed boundary +/// becomes a process. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Exec { + /// The text as typed (plus any detected-literal suffix), run as + /// `$SHELL -c `. + Literal(String), + /// `binary` with `args`, no shell between. The task leader is the agent + /// process in every login shell: claude's capture gate and registry + /// reader key on the leader pid, and a resident shell (tcsh, csh, a + /// wrapper script) would otherwise hold it. + Managed { + binary: PathBuf, + args: Vec, + }, +} + impl Task { - /// Spawn `exec_command` under `$SHELL -c` in a fresh `rows`×`cols` PTY with - /// `scrollback` history rows. Retain `command` for the UI and recipes; instrument - /// only `exec_command`. Pass exactly `env` to the child and notify the core through - /// `waker` on terminal output. + /// Spawn `exec` in a fresh `rows`×`cols` PTY with `scrollback` history + /// rows. Retain `command` for the UI and recipes: a literal task's typed + /// text, a managed task's program word. Pass exactly `env` to the child + /// and notify the core through `waker` on terminal output. #[allow(clippy::too_many_arguments)] // All arguments define task launch state. pub fn spawn( id: u64, command: &str, - exec_command: &str, + exec: Exec, cwd: &Path, rows: u16, cols: u16, @@ -230,18 +252,30 @@ impl Task { }) .map_err(io_err)?; - // The daemon inherits the first client's environment, which may name a - // different shell from the connecting client's. Read SHELL from the launch - // context so a zsh client attached to a bash-started daemon still gets zsh - // word-splitting. Without SHELL, use the portable default. - let shell = env_get(env, "SHELL") - .map(OsString::from) - .unwrap_or_else(|| "/bin/sh".into()); - let mut cmd = CommandBuilder::new(shell); - // Use a non-interactive shell. Interactive startup files, aliases, and - // shell functions are not loaded. - cmd.arg("-c"); - cmd.arg(exec_command); + let managed = matches!(exec, Exec::Managed { .. }); + let mut cmd = match exec { + Exec::Literal(text) => { + // The daemon inherits the first client's environment, which may + // name a different shell from the connecting client's. Read SHELL + // from the launch context so a zsh client attached to a + // bash-started daemon still gets zsh word-splitting. Without + // SHELL, use the portable default. + let shell = env_get(env, "SHELL") + .map(OsString::from) + .unwrap_or_else(|| "/bin/sh".into()); + let mut cmd = CommandBuilder::new(shell); + // Use a non-interactive shell. Interactive startup files, + // aliases, and shell functions are not loaded. + cmd.arg("-c"); + cmd.arg(text); + cmd + } + Exec::Managed { binary, args } => { + let mut cmd = CommandBuilder::new(binary); + cmd.args(args); + cmd + } + }; // The builder inherits the daemon's environment. Clear it before applying // the client's environment so keys absent from the client cannot leak in // from the client that first autostarted the daemon. @@ -339,6 +373,7 @@ impl Task { run: 0, resume_id: None, capture_file: None, + managed, preview: PreviewState::new(), blocked: None, blocked_probed: None, diff --git a/src/task_tests.rs b/src/task_tests.rs index bfc9e8f..d65200c 100644 --- a/src/task_tests.rs +++ b/src/task_tests.rs @@ -1,16 +1,21 @@ use super::*; -use crate::testutil::{env_here, here, read_pid, sh_env, temp, wait_until}; +use crate::testutil::{env_here, here, read_pid, sh_env, temp, wait_until, write_executable}; /// Tests drive the reader directly, so there is no core loop to wake. fn no_waker() -> Waker { Arc::new(Mutex::new(None)) } +/// The exec form of a typed command. +fn literal(command: &str) -> Exec { + Exec::Literal(command.to_string()) +} + fn spawn(id: u64, command: &str) -> Task { Task::spawn( id, command, - command, + literal(command), &here(), 24, 80, @@ -137,6 +142,65 @@ fn exited_leader_stays_a_zombie_until_drop() { assert!(kill(pid, None).is_err(), "Drop did not collect the zombie"); } +/// A managed launch makes the agent the task leader by construction: no +/// shell sits between the PTY and the binary. The same agent launched as a +/// literal through a resident shell (one that runs its `-c` text as a child +/// instead of replacing itself, as tcsh and csh do) is not the leader. That +/// gap is why managed launch exists: claude's capture gate and registry +/// reader key on the leader pid. +#[test] +fn managed_launch_makes_the_agent_the_task_leader_under_any_shell() { + let dir = temp("task_identity"); + let shell = dir.join("resident-sh"); + // `:` after the child keeps sh from exec'ing it as the script's last + // command, so the shell stays resident as the leader. + write_executable(&shell, "[ \"$1\" = -c ] || exit 2\n/bin/sh -c \"$2\"\n:"); + let agent = dir.join("agent"); + let pid_file = dir.join("pid"); + write_executable( + &agent, + &format!("printf '%s' \"$$\" > '{}'", pid_file.display()), + ); + let mut env = sh_env(); + env.retain(|(k, _)| k != "SHELL"); + env.push(("SHELL".into(), shell.as_os_str().to_os_string())); + + let managed = Exec::Managed { + binary: agent.clone(), + args: Vec::new(), + }; + let mut t = Task::spawn(1, "agent", managed, &here(), 24, 80, 2000, &env, no_waker()).unwrap(); + wait_finished(&mut t); + assert!(t.managed); + assert_eq!( + read_pid(&pid_file).as_raw() as u32, + t.pid().unwrap(), + "the agent must be the task leader with no shell in between" + ); + + std::fs::remove_file(&pid_file).unwrap(); + let typed = format!("'{}'", agent.display()); + let mut t = Task::spawn( + 2, + &typed, + literal(&typed), + &here(), + 24, + 80, + 2000, + &env, + no_waker(), + ) + .unwrap(); + wait_finished(&mut t); + assert!(!t.managed); + assert_ne!( + read_pid(&pid_file).as_raw() as u32, + t.pid().unwrap(), + "premise: under a resident shell the literal agent is a child, not the leader" + ); +} + /// `terminate` reaches live group members after the leader exits. #[test] fn terminate_reaches_stragglers_after_leader_exit() { @@ -147,7 +211,18 @@ fn terminate_reaches_stragglers_after_leader_exit() { // must survive its session leader's exit (leader death HUPs the // foreground group) to *be* a straggler. let cmd = format!("trap '' HUP; sleep 300 & echo $! > {}", spid.display()); - let mut t = Task::spawn(5, &cmd, &cmd, &here(), 24, 80, 2000, &sh_env(), no_waker()).unwrap(); + let mut t = Task::spawn( + 5, + &cmd, + literal(&cmd), + &here(), + 24, + 80, + 2000, + &sh_env(), + no_waker(), + ) + .unwrap(); wait_finished(&mut t); // leader exits as soon as the background job is up let straggler = read_pid(&spid); assert!(kill(straggler, None).is_ok(), "straggler should be alive"); @@ -329,7 +404,18 @@ fn input_hints_track_child_modes() { #[test] fn finalize_preview_waits_for_reader_eof() { let cmd = "printf 'test result: ok\\n'"; - let mut t = Task::spawn(20, cmd, cmd, &here(), 24, 80, 2000, &sh_env(), no_waker()).unwrap(); + let mut t = Task::spawn( + 20, + cmd, + literal(cmd), + &here(), + 24, + 80, + 2000, + &sh_env(), + no_waker(), + ) + .unwrap(); assert!( wait_until(Duration::from_secs(60), || { t.poll_exit().unwrap(); @@ -367,7 +453,18 @@ fn finalize_preview_waits_for_reader_eof() { #[test] fn finalize_preview_lands_an_open_sync_frame() { let cmd = "printf '\\033[?2026htest result: ok\\n'"; - let mut t = Task::spawn(21, cmd, cmd, &here(), 24, 80, 2000, &sh_env(), no_waker()).unwrap(); + let mut t = Task::spawn( + 21, + cmd, + literal(cmd), + &here(), + 24, + 80, + 2000, + &sh_env(), + no_waker(), + ) + .unwrap(); assert!( wait_until(Duration::from_secs(60), || { t.poll_exit().unwrap(); @@ -397,7 +494,18 @@ fn finalize_preview_freezes_the_final_primary_line() { "until [ -e '{}' ]; do sleep 0.05; done; printf 'test result: ok\\n'", flag.display() ); - let mut t = Task::spawn(40, &cmd, &cmd, &here(), 24, 80, 2000, &sh_env(), no_waker()).unwrap(); + let mut t = Task::spawn( + 40, + &cmd, + literal(&cmd), + &here(), + 24, + 80, + 2000, + &sh_env(), + no_waker(), + ) + .unwrap(); // The last live resolution predates every byte of output. let early = t.resolve_preview(Instant::now()); assert!(!early.frozen); @@ -433,7 +541,18 @@ fn finalize_preview_keeps_the_last_render_across_alt_teardown() { td = teardown.display(), ex = exit.display() ); - let mut t = Task::spawn(41, &cmd, &cmd, &here(), 24, 80, 2000, &sh_env(), no_waker()).unwrap(); + let mut t = Task::spawn( + 41, + &cmd, + literal(&cmd), + &here(), + 24, + 80, + 2000, + &sh_env(), + no_waker(), + ) + .unwrap(); assert!( wait_until(Duration::from_secs(5), || { t.resolve_preview(Instant::now()).source == PreviewSource::Title @@ -490,7 +609,18 @@ fn finalize_preview_freezes_primary_output_after_alt_teardown() { printf '\\033[?1049ldone\\n'", flag.display() ); - let mut t = Task::spawn(43, &cmd, &cmd, &here(), 24, 80, 2000, &sh_env(), no_waker()).unwrap(); + let mut t = Task::spawn( + 43, + &cmd, + literal(&cmd), + &here(), + 24, + 80, + 2000, + &sh_env(), + no_waker(), + ) + .unwrap(); assert!( wait_until(Duration::from_secs(5), || { t.resolve_preview(Instant::now()).source == PreviewSource::Title @@ -528,7 +658,18 @@ fn summary_adapter_anchors_live_and_freezes_completion_at_exit() { printf '\\033[H\\033[2J• Ran echo ok\\n\\n› \\n synth-model high · 2 in · 3 out'", f = flag.display() ); - let mut t = Task::spawn(42, &cmd, &cmd, &here(), 24, 80, 2000, &sh_env(), no_waker()).unwrap(); + let mut t = Task::spawn( + 42, + &cmd, + literal(&cmd), + &here(), + 24, + 80, + 2000, + &sh_env(), + no_waker(), + ) + .unwrap(); assert!(t.summary_adapter.is_none(), "printf selects nothing"); t.summary_adapter = crate::harness::select("codex"); assert!(t.summary_adapter.is_some()); @@ -584,7 +725,18 @@ fn probe_replies_reach_the_child_through_the_allowlist() { head -c 11 > {}", out.display() ); - let mut t = Task::spawn(11, &cmd, &cmd, &here(), 24, 80, 2000, &sh_env(), no_waker()).unwrap(); + let mut t = Task::spawn( + 11, + &cmd, + literal(&cmd), + &here(), + 24, + 80, + 2000, + &sh_env(), + no_waker(), + ) + .unwrap(); let mut got = Vec::new(); wait_until(Duration::from_secs(5), || { got = std::fs::read(&out).unwrap_or_default(); From a71793d165723233568c6d8227274978a87a7317 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Mon, 5 Oct 2026 18:37:06 -0700 Subject: [PATCH 03/13] feat(protocol): agent discovery and managed launches over the wire (v14) --- src/app.rs | 7 ++ src/app_readme_tests.rs | 21 ++++ src/app_tests.rs | 1 + src/harness/mod.rs | 33 ++++++ src/protocol.rs | 42 +++++++- src/protocol_tests.rs | 150 ++++++++++++++++++++++++-- src/supervisor.rs | 20 +++- src/supervisor_capture_tests.rs | 32 ++++-- src/supervisor_tests.rs | 160 +++++++++++++++++++++++++++ src/ui.rs | 1 + tests/common/mod.rs | 10 +- tests/daemon_agents.rs | 186 ++++++++++++++++++++++++++++++++ 12 files changed, 639 insertions(+), 24 deletions(-) create mode 100644 tests/daemon_agents.rs diff --git a/src/app.rs b/src/app.rs index c22d3c3..00ddfe2 100644 --- a/src/app.rs +++ b/src/app.rs @@ -195,6 +195,10 @@ pub struct App { transport: Box, /// Task snapshot received from `Event::Tasks`. pub views: Vec, + /// Agents installed on the core's launch `PATH`, from the latest + /// `Event::Agents`; program words in registry order. Nothing renders it + /// until the spawn prompt's Agent page lands. + pub agents: Vec, /// The watched task's screen (attach/peek), from `Event::Screen`. focused_screen: Option, /// Last `(target, attached)` watch state sent to the core. @@ -450,6 +454,7 @@ impl App { Self { transport, views: Vec::new(), + agents: Vec::new(), focused_screen: None, watched: None, daemon_backed: false, @@ -750,6 +755,8 @@ impl App { match ev { // The handshake is handled before the transport is created. Event::HelloOk => {} + // Each context install resends the whole list; replace, never merge. + Event::Agents(a) => self.agents = a, Event::Tasks(v) => { self.views = v; // The acknowledgement precedes its row. Select only after diff --git a/src/app_readme_tests.rs b/src/app_readme_tests.rs index 9b9ddb8..b0fb96a 100644 --- a/src/app_readme_tests.rs +++ b/src/app_readme_tests.rs @@ -125,6 +125,7 @@ fn live_fleet(dirs: &Dirs) -> Vec { cwd: dirs.fleetcom.clone(), tagged: true, flagship: false, + managed: false, group: Some("dashboard".to_string()), name: Some("Dashboard Refine".to_string()), lifecycle: Lifecycle::Active, @@ -142,6 +143,7 @@ fn live_fleet(dirs: &Dirs) -> Vec { cwd: dirs.fleetcom.clone(), tagged: true, flagship: false, + managed: false, group: Some("dashboard".to_string()), name: Some("Summary Refine".to_string()), lifecycle: Lifecycle::Active, @@ -156,6 +158,7 @@ fn live_fleet(dirs: &Dirs) -> Vec { cwd: dirs.fleetcom.clone(), tagged: false, flagship: false, + managed: false, group: Some("dashboard".to_string()), name: Some("Grok Language".to_string()), lifecycle: Lifecycle::Active, @@ -170,6 +173,7 @@ fn live_fleet(dirs: &Dirs) -> Vec { cwd: dirs.fleetcom.clone(), tagged: false, flagship: false, + managed: false, group: Some("dashboard".to_string()), name: Some("Codex Language".to_string()), lifecycle: Lifecycle::Active, @@ -184,6 +188,7 @@ fn live_fleet(dirs: &Dirs) -> Vec { cwd: dirs.fleetcom.clone(), tagged: false, flagship: false, + managed: false, group: Some("dashboard".to_string()), name: Some("Codex Review".to_string()), lifecycle: Lifecycle::Active, @@ -198,6 +203,7 @@ fn live_fleet(dirs: &Dirs) -> Vec { cwd: dirs.fleetcom.clone(), tagged: false, flagship: false, + managed: false, group: Some("tests".to_string()), name: None, lifecycle: Lifecycle::Ok, @@ -212,6 +218,7 @@ fn live_fleet(dirs: &Dirs) -> Vec { cwd: dirs.fleetcom.clone(), tagged: false, flagship: false, + managed: false, group: Some("tests".to_string()), name: None, lifecycle: Lifecycle::Failed, @@ -226,6 +233,7 @@ fn live_fleet(dirs: &Dirs) -> Vec { cwd: dirs.home.clone(), tagged: false, flagship: false, + managed: false, group: Some("desktop".to_string()), name: Some("claude agents".to_string()), lifecycle: Lifecycle::Active, @@ -240,6 +248,7 @@ fn live_fleet(dirs: &Dirs) -> Vec { cwd: dirs.home.clone(), tagged: false, flagship: true, + managed: false, group: Some("desktop".to_string()), name: Some("Zellij".to_string()), lifecycle: Lifecycle::Active, @@ -254,6 +263,7 @@ fn live_fleet(dirs: &Dirs) -> Vec { cwd: dirs.home.clone(), tagged: false, flagship: false, + managed: false, group: None, name: None, lifecycle: Lifecycle::Idle, @@ -268,6 +278,7 @@ fn live_fleet(dirs: &Dirs) -> Vec { cwd: dirs.home.clone(), tagged: false, flagship: false, + managed: false, group: Some("desktop".to_string()), name: None, lifecycle: Lifecycle::Ok, @@ -282,6 +293,7 @@ fn live_fleet(dirs: &Dirs) -> Vec { cwd: dirs.turret.clone(), tagged: false, flagship: false, + managed: false, group: Some("turret".to_string()), name: Some("Game Infra Review".to_string()), lifecycle: Lifecycle::Active, @@ -296,6 +308,7 @@ fn live_fleet(dirs: &Dirs) -> Vec { cwd: dirs.turret.clone(), tagged: false, flagship: false, + managed: false, group: Some("turret".to_string()), name: Some("Missile Nerf".to_string()), lifecycle: Lifecycle::Active, @@ -312,6 +325,7 @@ fn live_fleet(dirs: &Dirs) -> Vec { cwd: dirs.turret.clone(), tagged: false, flagship: false, + managed: false, group: Some("turret".to_string()), name: Some("EMP Nerf".to_string()), lifecycle: Lifecycle::Active, @@ -328,6 +342,7 @@ fn live_fleet(dirs: &Dirs) -> Vec { cwd: dirs.crabapple.clone(), tagged: false, flagship: false, + managed: false, group: Some("tests".to_string()), name: None, lifecycle: Lifecycle::Ok, @@ -342,6 +357,7 @@ fn live_fleet(dirs: &Dirs) -> Vec { cwd: dirs.crabstep.clone(), tagged: false, flagship: false, + managed: false, group: Some("tests".to_string()), name: None, lifecycle: Lifecycle::Ok, @@ -356,6 +372,7 @@ fn live_fleet(dirs: &Dirs) -> Vec { cwd: dirs.imessage.clone(), tagged: false, flagship: false, + managed: false, group: None, name: None, lifecycle: Lifecycle::Active, @@ -370,6 +387,7 @@ fn live_fleet(dirs: &Dirs) -> Vec { cwd: dirs.imessage.clone(), tagged: false, flagship: false, + managed: false, group: Some("tests".to_string()), name: None, lifecycle: Lifecycle::Ok, @@ -384,6 +402,7 @@ fn live_fleet(dirs: &Dirs) -> Vec { cwd: dirs.logria.clone(), tagged: false, flagship: false, + managed: false, group: Some("tests".to_string()), name: None, lifecycle: Lifecycle::Ok, @@ -398,6 +417,7 @@ fn live_fleet(dirs: &Dirs) -> Vec { cwd: dirs.logria.clone(), tagged: false, flagship: false, + managed: false, group: Some("tests".to_string()), name: None, lifecycle: Lifecycle::Active, @@ -412,6 +432,7 @@ fn live_fleet(dirs: &Dirs) -> Vec { cwd: dirs.logria.clone(), tagged: false, flagship: false, + managed: false, group: Some("tests".to_string()), name: None, lifecycle: Lifecycle::Idle, diff --git a/src/app_tests.rs b/src/app_tests.rs index c4fc709..ed87c2a 100644 --- a/src/app_tests.rs +++ b/src/app_tests.rs @@ -114,6 +114,7 @@ fn view(id: u64, cwd: PathBuf, tagged: bool, group: Option<&str>) -> TaskView { cwd, tagged, flagship: false, + managed: false, group: group.map(str::to_string), name: None, lifecycle: Lifecycle::Active, diff --git a/src/harness/mod.rs b/src/harness/mod.rs index 22c98f0..192fa6e 100644 --- a/src/harness/mod.rs +++ b/src/harness/mod.rs @@ -196,6 +196,18 @@ pub fn registered(program: &str) -> Option<&'static dyn Harness> { .find(|h| h.shape().0 == program) } +/// Program words of the registered agents that [`find_on_path`] resolves on +/// `path`, in registry order: the launcher's menu. Each word is searched +/// independently, so the result never depends on `path` order. An empty +/// `path` yields an empty list. +pub fn installed(path: &OsStr) -> Vec<&'static str> { + AGENTS + .iter() + .map(|a| a.harness.shape().0) + .filter(|program| find_on_path(program, path).is_some()) + .collect() +} + /// The first executable regular file named `program` in `path`, returned as /// found. The path is never canonicalized: a self-updater that repoints /// `~/.local/bin/claude` is followed at the next launch. Components that are @@ -834,6 +846,27 @@ mod tests { ); } + /// The menu follows registry order whatever `PATH` order says, lists + /// only registered words, and is empty for an empty `PATH`. + #[test] + fn installed_follows_registry_order_not_path_order() { + use crate::testutil::{temp, write_executable}; + let dir = temp("installed"); + let (a, b) = (dir.join("a"), dir.join("b")); + for d in [&a, &b] { + fs::create_dir_all(d).unwrap(); + } + for name in ["omp", "grok", "vim"] { + write_executable(&a.join(name), ""); + } + write_executable(&b.join("claude"), ""); + let join = |dirs: &[&Path]| std::env::join_paths(dirs).unwrap(); + + assert_eq!(installed(&join(&[&a, &b])), ["claude", "grok", "omp"]); + assert_eq!(installed(&join(&[&b])), ["claude"]); + assert_eq!(installed(OsStr::new("")), Vec::<&str>::new()); + } + #[test] fn registry_detect_routes_to_the_matching_harness() { for a in AGENTS { diff --git a/src/protocol.rs b/src/protocol.rs index 2d4467a..74277e7 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -12,7 +12,7 @@ use base64::{Engine as _, engine::general_purpose::STANDARD as B64}; use crate::frame::{KIND_CONTROL, KIND_HELLO, KIND_SCREEN}; /// Wire-protocol version; mismatched peers are rejected during the handshake. -pub const PROTOCOL_VERSION: u32 = 13; +pub const PROTOCOL_VERSION: u32 = 14; /// Reserved dashboard label for tasks without a custom group. pub const UNASSIGNED: &str = "Unassigned"; @@ -56,6 +56,15 @@ pub enum Command { cwd: PathBuf, group: Option, }, + /// Launch a registered agent as a managed task in `cwd`. `agent` is a + /// program word from `Event::Agents`, never a path: the core checks it + /// against its registry and resolves the binary on its own launch + /// context's `PATH` at spawn time. + SpawnAgent { + agent: String, + cwd: PathBuf, + group: Option, + }, /// Signal-kill a live task's process group; classify it as Completed on reap. Kill { id: u64 }, /// Drop a task from the set entirely (used on already-finished tasks). @@ -178,6 +187,11 @@ pub struct Mods { pub enum Event { /// The daemon accepted a compatible hello frame and stored its launch context. HelloOk, + /// Program words of the registered agents found on the launch context's + /// `PATH`, in registry order. Sent whenever a context is installed, so a + /// reconnect replaces the list; empty when none resolve. Binary paths + /// stay in the core. + Agents(Vec), /// Full task-set snapshot; replaces the client's mirror wholesale. Tasks(Vec), /// The watched task's current screen (attach/peek source). @@ -325,6 +339,9 @@ pub struct TaskView { /// Whether this task is the flagship. True for at most one task per /// snapshot, and never for a finished task. pub flagship: bool, + /// Whether fleetcom launched this task as a managed agent: `command` is + /// then the agent's program word, and a rerun resumes through the harness. + pub managed: bool, /// Dashboard group; `None` means unassigned. pub group: Option, /// Custom display name; `None` means unnamed. @@ -564,6 +581,15 @@ pub fn encode_command(cmd: &Command) -> (u8, Vec) { insert_opt_str(&mut o, "group", group); o } + Command::SpawnAgent { agent, cwd, group } => { + let mut o = jzon::object! { + "t": "spawn_agent", + "agent": agent.as_str(), + "cwd": path_b64(cwd), + }; + insert_opt_str(&mut o, "group", group); + o + } Command::Kill { id } => jzon::object! { "t": "kill", "id": *id }, Command::Remove { id } => jzon::object! { "t": "remove", "id": *id }, Command::Restart { id } => jzon::object! { "t": "restart", "id": *id }, @@ -694,6 +720,11 @@ pub fn decode_command(kind: u8, payload: &[u8]) -> Option { // Missing and null group fields both decode as unassigned. group: opt_str(&v["group"])?, }, + "spawn_agent" => Command::SpawnAgent { + agent: v["agent"].as_str()?.to_string(), + cwd: path_from_b64(&v["cwd"])?, + group: opt_str(&v["group"])?, + }, "kill" => Command::Kill { id: v["id"].as_u64()?, }, @@ -825,6 +856,10 @@ pub fn decode_command(kind: u8, payload: &[u8]) -> Option { pub fn encode_event(ev: &Event) -> (u8, Vec) { let o = match ev { Event::HelloOk => jzon::object! { "t": "hello_ok" }, + Event::Agents(agents) => jzon::object! { + "t": "agents", + "agents": agents.iter().map(String::as_str).collect::>(), + }, Event::Tasks(views) => { let mut arr = jzon::JsonValue::new_array(); for tv in views { @@ -834,6 +869,7 @@ pub fn encode_event(ev: &Event) -> (u8, Vec) { let _ = o.insert("cwd", path_b64(&tv.cwd)); let _ = o.insert("tagged", tv.tagged); let _ = o.insert("flagship", tv.flagship); + let _ = o.insert("managed", tv.managed); // Group and name fields are present only when set. insert_opt_str(&mut o, "group", &tv.group); insert_opt_str(&mut o, "name", &tv.name); @@ -907,6 +943,9 @@ pub fn decode_event(kind: u8, payload: &[u8]) -> Option { let v = jzon::parse(std::str::from_utf8(payload).ok()?).ok()?; match v["t"].as_str()? { "hello_ok" => Some(Event::HelloOk), + // `str_vec` reads a missing or non-array value as empty; an + // empty menu is an explicit `[]`, so anything else is malformed. + "agents" if v["agents"].is_array() => Some(Event::Agents(str_vec(&v["agents"])?)), "tasks" => { let mut views = Vec::new(); for tv in v["tasks"].members() { @@ -916,6 +955,7 @@ pub fn decode_event(kind: u8, payload: &[u8]) -> Option { cwd: path_from_b64(&tv["cwd"])?, tagged: tv["tagged"].as_bool()?, flagship: tv["flagship"].as_bool()?, + managed: tv["managed"].as_bool()?, // Missing and null both mean unassigned/unnamed. group: opt_str(&tv["group"])?, name: opt_str(&tv["name"])?, diff --git a/src/protocol_tests.rs b/src/protocol_tests.rs index 57dc843..a9d1ca3 100644 --- a/src/protocol_tests.rs +++ b/src/protocol_tests.rs @@ -25,6 +25,16 @@ fn command_round_trips() { cwd: PathBuf::from("/tmp"), group: Some("build".into()), }, + Command::SpawnAgent { + agent: "claude".into(), + cwd: PathBuf::from("/tmp/with space"), + group: None, + }, + Command::SpawnAgent { + agent: "codex".into(), + cwd: PathBuf::from(OsString::from_vec(b"/tmp/\xff\xfe dir".to_vec())), + group: Some("agents".into()), + }, Command::Kill { id: 7 }, Command::Remove { id: 3 }, Command::Restart { id: 4 }, @@ -166,9 +176,10 @@ fn command_round_trips() { Command::ListSessions => 16, Command::Shutdown => 17, Command::Flagship { .. } => 18, + Command::SpawnAgent { .. } => 19, } } - let mut seen = [false; 19]; + let mut seen = [false; 20]; for c in cases { seen[variant_index(&c)] = true; let (k, p) = encode_command(&c); @@ -191,6 +202,7 @@ fn event_round_trips() { let screen = r#"{"id":9,"cursor":[3,12],"hide":false,"mouse":true,"alt":false,"ascr":true,"sb":42,"lines":["row0"]}"#; let cases = [ Event::HelloOk, + Event::Agents(vec!["claude".into(), "omp".into()]), Event::Tasks(vec![tv(1)]), decode_event(KIND_SCREEN, &screen_payload(screen)).expect("valid screen header"), Event::Status("saved 'x'".into()), @@ -219,9 +231,10 @@ fn event_round_trips() { Event::Spawned { .. } => 4, Event::Sessions { .. } => 5, Event::ClipboardCopy { .. } => 6, + Event::Agents(_) => 7, } } - let mut seen = [false; 7]; + let mut seen = [false; 8]; for ev in cases { seen[variant_index(&ev)] = true; let (k, p) = encode_event(&ev); @@ -356,6 +369,7 @@ fn tv(id: u64) -> TaskView { cwd: PathBuf::from("/"), tagged: false, flagship: false, + managed: false, group: None, name: None, lifecycle: Lifecycle::Ok, @@ -385,11 +399,11 @@ fn mistyped_event_members_are_rejected() { for json in [ r#"{"t":"tasks","tasks":[{"id":"nope"}]}"#, // The cwd must be a base64 string. - r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"/x","tagged":true,"flagship":false,"life":"ok","preview":"","src":"floor","frozen":false,"started_ms":0}]}"#, + r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"/x","tagged":true,"flagship":false,"managed":false,"life":"ok","preview":"","src":"floor","frozen":false,"started_ms":0}]}"#, // A present group must be a string; only missing/null means unassigned. - r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":true,"flagship":false,"life":"ok","preview":"","src":"floor","frozen":false,"started_ms":0,"group":5}]}"#, + r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":true,"flagship":false,"managed":false,"life":"ok","preview":"","src":"floor","frozen":false,"started_ms":0,"group":5}]}"#, // A present name must be a string. - r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":true,"flagship":false,"life":"ok","preview":"","src":"floor","frozen":false,"started_ms":0,"name":5}]}"#, + r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":true,"flagship":false,"managed":false,"life":"ok","preview":"","src":"floor","frozen":false,"started_ms":0,"name":5}]}"#, r#"{"t":"tasks","tasks":["flat"]}"#, // Numeric member in `names`. r#"{"t":"sessions","names":["ok",5]}"#, @@ -419,6 +433,7 @@ fn tasks_and_status_round_trip() { cwd: PathBuf::from("/home/x"), tagged: true, flagship: true, + managed: true, group: Some("x".into()), name: Some("editor".into()), lifecycle: Lifecycle::Idle, @@ -509,7 +524,7 @@ fn set_name_wire_form() { #[test] fn tasks_frame_group_key_is_optional() { // "Lw==" is the base64 encoding of "/". - let ungrouped = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"flagship":false,"life":"ok","preview":"","src":"floor","frozen":false,"started_ms":0}]}"#; + let ungrouped = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"flagship":false,"managed":false,"life":"ok","preview":"","src":"floor","frozen":false,"started_ms":0}]}"#; match decode_event(KIND_CONTROL, ungrouped.as_bytes()) { Some(Event::Tasks(v)) => assert_eq!(v[0].group, None), other => panic!("expected tasks event, got {other:?}"), @@ -518,7 +533,7 @@ fn tasks_frame_group_key_is_optional() { let (_, p) = encode_event(&Event::Tasks(vec![tv(1)])); assert_eq!(std::str::from_utf8(&p).unwrap(), ungrouped); - let grouped = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"flagship":false,"group":"infra","life":"ok","preview":"","src":"floor","frozen":false,"started_ms":0}]}"#; + let grouped = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"flagship":false,"managed":false,"group":"infra","life":"ok","preview":"","src":"floor","frozen":false,"started_ms":0}]}"#; match decode_event(KIND_CONTROL, grouped.as_bytes()) { Some(Event::Tasks(v)) => assert_eq!(v[0].group.as_deref(), Some("infra")), other => panic!("expected tasks event, got {other:?}"), @@ -530,7 +545,7 @@ fn tasks_frame_group_key_is_optional() { #[test] fn tasks_frame_name_key_is_optional() { // "Lw==" is the base64 encoding of "/". - let unnamed = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"flagship":false,"life":"ok","preview":"","src":"floor","frozen":false,"started_ms":0}]}"#; + let unnamed = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"flagship":false,"managed":false,"life":"ok","preview":"","src":"floor","frozen":false,"started_ms":0}]}"#; match decode_event(KIND_CONTROL, unnamed.as_bytes()) { Some(Event::Tasks(v)) => assert_eq!(v[0].name, None), other => panic!("expected tasks event, got {other:?}"), @@ -539,7 +554,7 @@ fn tasks_frame_name_key_is_optional() { let (_, p) = encode_event(&Event::Tasks(vec![tv(1)])); assert_eq!(std::str::from_utf8(&p).unwrap(), unnamed); - let named = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"flagship":false,"name":"build","life":"ok","preview":"","src":"floor","frozen":false,"started_ms":0}]}"#; + let named = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"flagship":false,"managed":false,"name":"build","life":"ok","preview":"","src":"floor","frozen":false,"started_ms":0}]}"#; match decode_event(KIND_CONTROL, named.as_bytes()) { Some(Event::Tasks(v)) => assert_eq!(v[0].name.as_deref(), Some("build")), other => panic!("expected tasks event, got {other:?}"), @@ -845,6 +860,123 @@ fn flagship_wire_form() { } } +/// `SpawnAgent` names the agent by program word and encodes the cwd as +/// base64, omitting `"group"` when unassigned, as `Spawn` does. The decoder +/// requires a string word; refusing a path or an unknown word is the core's +/// registry check, not the decoder's. +#[test] +fn spawn_agent_wire_form() { + let (k, p) = encode_command(&Command::SpawnAgent { + agent: "claude".into(), + cwd: PathBuf::from("/"), + group: None, + }); + assert_eq!(k, KIND_CONTROL); + assert_eq!( + std::str::from_utf8(&p).unwrap(), + r#"{"t":"spawn_agent","agent":"claude","cwd":"Lw=="}"# + ); + let (_, p) = encode_command(&Command::SpawnAgent { + agent: "claude".into(), + cwd: PathBuf::from("/"), + group: Some("agents".into()), + }); + assert_eq!( + std::str::from_utf8(&p).unwrap(), + r#"{"t":"spawn_agent","agent":"claude","cwd":"Lw==","group":"agents"}"# + ); + // An explicit null group is unassigned, same as an omitted key. + assert_eq!( + decode_command( + KIND_CONTROL, + br#"{"t":"spawn_agent","agent":"claude","cwd":"Lw==","group":null}"# + ), + Some(Command::SpawnAgent { + agent: "claude".into(), + cwd: PathBuf::from("/"), + group: None, + }) + ); + for json in [ + r#"{"t":"spawn_agent","cwd":"Lw=="}"#, // missing agent + r#"{"t":"spawn_agent","agent":5,"cwd":"Lw=="}"#, // agent must be a string + r#"{"t":"spawn_agent","agent":null,"cwd":"Lw=="}"#, // null is not a word + r#"{"t":"spawn_agent","agent":"claude"}"#, // missing cwd + r#"{"t":"spawn_agent","agent":"claude","cwd":"/tmp/x"}"#, // plain path + r#"{"t":"spawn_agent","agent":"claude","cwd":"Lw==","group":5}"#, // group must be a string + ] { + assert_eq!( + decode_command(KIND_CONTROL, json.as_bytes()), + None, + "should reject {json}" + ); + } +} + +/// `Agents` round-trips empty and populated lists in order and pins its +/// wire shape. A missing list, a non-array, or a non-string member rejects +/// the event rather than reading as "no agents": the client indexes the +/// list by position, and an empty menu is an explicit `[]`. +#[test] +fn agents_event_round_trips_and_pins_the_wire_shape() { + for agents in [ + Vec::new(), + vec!["claude".to_string()], + vec!["claude".to_string(), "codex".to_string(), "omp".to_string()], + ] { + let ev = Event::Agents(agents); + let (k, p) = encode_event(&ev); + assert_eq!(k, KIND_CONTROL); + assert_eq!(decode_event(k, &p).as_ref(), Some(&ev), "round-trip {ev:?}"); + } + let (_, p) = encode_event(&Event::Agents(vec!["claude".into(), "omp".into()])); + assert_eq!( + std::str::from_utf8(&p).unwrap(), + r#"{"t":"agents","agents":["claude","omp"]}"# + ); + let (_, p) = encode_event(&Event::Agents(Vec::new())); + assert_eq!( + std::str::from_utf8(&p).unwrap(), + r#"{"t":"agents","agents":[]}"# + ); + for json in [ + r#"{"t":"agents"}"#, + r#"{"t":"agents","agents":null}"#, + r#"{"t":"agents","agents":"claude"}"#, + r#"{"t":"agents","agents":["claude",5]}"#, + ] { + assert_eq!( + decode_event(KIND_CONTROL, json.as_bytes()), + None, + "should reject {json}" + ); + } +} + +/// Every task row carries `managed` as a required boolean, both ways. +#[test] +fn tasks_frame_managed_flag_round_trips_and_is_required() { + for managed in [false, true] { + let tasks = Event::Tasks(vec![TaskView { managed, ..tv(1) }]); + let (k, p) = encode_event(&tasks); + let s = std::str::from_utf8(&p).unwrap(); + assert!( + s.contains(&format!("\"managed\":{managed}")), + "frame was {s}" + ); + assert_eq!(decode_event(k, &p), Some(tasks)); + } + let (k, p) = encode_event(&Event::Tasks(vec![tv(1)])); + let valid = String::from_utf8(p).unwrap(); + let missing = valid.replace("\"managed\":false,", ""); + assert_ne!(missing, valid, "premise: the encoder writes the key"); + assert_eq!(decode_event(k, missing.as_bytes()), None, "missing managed"); + for bad in ["null", "0", "\"true\"", "[]"] { + let frame = valid.replace("\"managed\":false", &format!("\"managed\":{bad}")); + assert_eq!(decode_event(k, frame.as_bytes()), None, "frame was {frame}"); + } +} + /// `LoadRecovery` requires a string stem in its wire representation. #[test] fn load_recovery_wire_form() { diff --git a/src/supervisor.rs b/src/supervisor.rs index 631d892..e509e32 100644 --- a/src/supervisor.rs +++ b/src/supervisor.rs @@ -194,6 +194,7 @@ fn harness_home(env: &[(OsString, OsString)], h: &dyn harness::Harness) -> Optio fn affects_recipe(cmd: &Command) -> bool { match cmd { Command::Spawn { .. } + | Command::SpawnAgent { .. } | Command::Remove { .. } | Command::Restart { .. } | Command::SetGroup { .. } @@ -328,8 +329,18 @@ impl Supervisor { } } - /// Install the launch context used by subsequent spawns. + /// Install the launch context used by subsequent spawns, and queue the + /// agents found on its `PATH` as `Event::Agents`. Discovery runs here + /// rather than at daemon start because the daemon lives for weeks and the + /// context changes with every client; an empty list is still sent so a + /// reconnect from a poorer `PATH` clears the previous client's menu. pub fn set_launch_context(&mut self, ctx: LaunchContext) { + let path = env_get(&ctx.env, "PATH").unwrap_or_default(); + let agents = harness::installed(path) + .into_iter() + .map(String::from) + .collect(); + self.events.push(Event::Agents(agents)); self.launch = Some(ctx); } @@ -392,6 +403,7 @@ impl Supervisor { cwd, group, } => self.spawn(&command, cwd, group), + Command::SpawnAgent { agent, cwd, group } => self.spawn_agent(&agent, cwd, group), Command::Kill { id } => self.with_task(id, Task::terminate), Command::Remove { id } => { if let Some(i) = self.index_of(id) { @@ -542,6 +554,7 @@ impl Supervisor { cwd: t.cwd.clone(), tagged: t.tagged, flagship: self.flagship == Some(t.id), + managed: t.managed, group: t.group.clone(), name: t.name.clone(), lifecycle: t.lifecycle(now, IDLE_AFTER), @@ -930,10 +943,7 @@ impl Supervisor { /// Launch `agent`, a registered program word, as a managed task with a /// fresh conversation. The binary is whatever the launch context's `PATH` /// finds now, never a path the client sent; no binary means no spawn. - /// Nothing reaches this entry point yet: `Command::SpawnAgent` lands in - /// the next phase. - #[allow(dead_code)] - pub fn spawn_agent(&mut self, agent: &str, cwd: PathBuf, group: Option) { + fn spawn_agent(&mut self, agent: &str, cwd: PathBuf, group: Option) { let Some(h) = harness::registered(agent) else { self.status(format!("no agent named {agent:?}, not spawning")); return; diff --git a/src/supervisor_capture_tests.rs b/src/supervisor_capture_tests.rs index 98d5836..5440896 100644 --- a/src/supervisor_capture_tests.rs +++ b/src/supervisor_capture_tests.rs @@ -1832,8 +1832,15 @@ fn managed_rerun_without_an_id_starts_fresh() { fn managed_task_saves_in_the_v1_resume_form_and_reloads_as_a_detected_literal() { let dir = scratch("managed_save"); let (bin, runtime, config) = (dir.join("bin"), dir.join("run"), dir.join("config")); - install_stub(&bin, "claude", &dir); - install_stub(&bin, "omp", &dir); + // One record directory per stub: the reloaded tasks launch together, and + // a shared argv file would be truncated by one while the test reads the + // other's. + let (claude_out, omp_out) = (dir.join("claude-out"), dir.join("omp-out")); + for out in [&claude_out, &omp_out] { + std::fs::create_dir_all(out).unwrap(); + } + install_stub(&bin, "claude", &claude_out); + install_stub(&bin, "omp", &omp_out); let mut s = sup_ctx(agent_ctx_plus( &bin, &runtime, @@ -1872,7 +1879,12 @@ fn managed_task_saves_in_the_v1_resume_form_and_reloads_as_a_detected_literal() ); // The saved strings load as literal tasks that detection instruments. - std::fs::remove_file(dir.join("argv")).unwrap(); + // Wait for both first-run records before clearing them, so a late write + // from the original launch cannot pass for the reloaded one. + wait_argv(&mut s, &claude_out.join("argv")); + wait_argv(&mut s, &omp_out.join("argv")); + std::fs::remove_file(claude_out.join("argv")).unwrap(); + std::fs::remove_file(omp_out.join("argv")).unwrap(); s.apply(Command::LoadSession { name: "managed".into(), }); @@ -1886,12 +1898,16 @@ fn managed_task_saves_in_the_v1_resume_form_and_reloads_as_a_detected_literal() assert!(!s.tasks[3].managed); assert_eq!(s.tasks[3].command, "omp"); assert!(s.tasks[3].harness.is_some()); - // Two reloaded stubs race for one argv file; both lead with the resume - // or extension element, so either record proves the detected launch. - let argv = wait_argv(&mut s, &dir.join("argv")); + let claude_argv = wait_argv(&mut s, &claude_out.join("argv")); assert!( - argv.starts_with(&["--resume".into(), CAP_ID.into()]) || argv[0] == "-e", - "a reloaded entry must resume or load its extension: {argv:?}" + claude_argv.starts_with(&["--resume".into(), CAP_ID.into()]), + "the reloaded claude entry must resume its captured ID: {claude_argv:?}" + ); + let omp_argv = wait_argv(&mut s, &omp_out.join("argv")); + assert_eq!( + omp_argv.first().map(String::as_str), + Some("-e"), + "the reloaded omp entry must load the capture extension: {omp_argv:?}" ); } diff --git a/src/supervisor_tests.rs b/src/supervisor_tests.rs index 45ee31a..7ed9062 100644 --- a/src/supervisor_tests.rs +++ b/src/supervisor_tests.rs @@ -2081,10 +2081,170 @@ fn recovery_arms_on_structural_mutations_not_tag() { }); assert!(take_dirty(&mut s), "LoadRecovery must arm"); + s.apply(Command::SpawnAgent { + agent: "vim".into(), + cwd: here(), + group: None, + }); + assert!(take_dirty(&mut s), "SpawnAgent must arm, even when refused"); + s.apply(Command::Remove { id }); assert!(take_dirty(&mut s), "Remove must arm"); } +/// Drain and return the newest `Agents` list, without ticking. +fn agents_of(s: &mut Supervisor) -> Vec { + s.drain() + .into_iter() + .rev() + .find_map(|e| match e { + Event::Agents(a) => Some(a), + _ => None, + }) + .expect("expected an Agents event") +} + +/// `dir` spelled relative to this process's cwd, through `..` components. +/// The spelling reaches `dir` from here, which is exactly why discovery +/// must not use it: the task would resolve it from its own cwd. +fn relative_spelling(dir: &Path) -> PathBuf { + use std::path::Component; + let ups = here() + .components() + .filter(|c| matches!(c, Component::Normal(_))) + .count(); + std::iter::repeat_n("..", ups) + .collect::() + .join(dir.strip_prefix("/").unwrap()) +} + +/// Installing a context queues the registered agents its `PATH` resolves, in +/// registry order rather than `PATH` order. An unregistered executable and a +/// relative component contribute nothing. Each install resends the whole +/// list, and a context without `PATH` sends an empty one, so a reconnect +/// never inherits a stale menu. +#[test] +fn launch_context_install_discovers_agents_in_registry_order() { + let dir = scratch("discover"); + let (a, b, c) = (dir.join("a"), dir.join("b"), dir.join("c")); + for d in [&a, &b, &c] { + std::fs::create_dir_all(d).unwrap(); + } + write_executable(&a.join("claude"), ""); + write_executable(&a.join("vim"), ""); + write_executable(&b.join("omp"), ""); + write_executable(&b.join("codex"), ""); + write_executable(&c.join("grok"), ""); + let rel = relative_spelling(&c); + assert!( + std::fs::metadata(rel.join("grok")).is_ok(), + "premise: the relative spelling reaches grok from here" + ); + let ctx = |dirs: &[&Path]| LaunchContext { + env: vec![("PATH".into(), std::env::join_paths(dirs).unwrap())], + cwd: dir.to_path_buf(), + }; + + let mut s = Supervisor::new(24, 80, 2000); + assert!( + s.drain().is_empty(), + "no discovery before a context is installed" + ); + s.set_launch_context(ctx(&[&b, &rel, &a])); + assert_eq!(agents_of(&mut s), ["claude", "codex", "omp"]); + s.set_launch_context(ctx(&[&a])); + assert_eq!(agents_of(&mut s), ["claude"]); + s.set_launch_context(LaunchContext { + env: Vec::new(), + cwd: dir.to_path_buf(), + }); + assert_eq!(agents_of(&mut s), Vec::::new()); +} + +/// `Command::SpawnAgent` admits a managed task for a registered word on the +/// context's `PATH`, acking with `Spawned` and reporting `managed` in the +/// snapshot; an unregistered word or a missing binary gets a notice and no +/// task. A literal spawn of the same word stays unmanaged. +#[test] +fn spawn_agent_admits_a_managed_task_and_refuses_the_rest() { + let dir = scratch("spawn_agent"); + let bin = dir.join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + let rec = dir.join("argv"); + write_executable( + &bin.join("claude"), + &format!("printf '%s\\n' \"$@\" > '{}'", rec.display()), + ); + // Keep capture assets and the registry read inside the scratch tree. + let mut s = sup_ctx(LaunchContext { + env: vec![ + ("PATH".into(), bin.as_os_str().to_os_string()), + ( + path::FLEETCOM_RUNTIME_DIR.into(), + dir.join("run").into_os_string(), + ), + ( + "CLAUDE_CONFIG_DIR".into(), + dir.join("claude-home").into_os_string(), + ), + ], + cwd: dir.to_path_buf(), + }); + assert_eq!(agents_of(&mut s), ["claude"]); + + s.apply(Command::SpawnAgent { + agent: "claude".into(), + cwd: dir.to_path_buf(), + group: Some("agents".into()), + }); + let id = spawned_id(&mut s); + let v = view_of(&mut s, id); + assert!(v.managed, "a SpawnAgent task must report managed"); + assert_eq!(v.command, "claude", "the row shows the program word"); + assert_eq!(v.group.as_deref(), Some("agents")); + assert!( + wait_until(Duration::from_secs(5), || rec.exists()), + "the stub never ran" + ); + let argv = std::fs::read_to_string(&rec).unwrap(); + assert!( + argv.lines().any(|l| l == "--session-id"), + "the launch must carry the harness argv: {argv:?}" + ); + + for (agent, needle) in [("vim", "no agent named"), ("codex", "not found on PATH")] { + s.apply(Command::SpawnAgent { + agent: agent.into(), + cwd: dir.to_path_buf(), + group: None, + }); + let evs = s.drain(); + assert!( + evs.iter() + .any(|e| matches!(e, Event::Status(m) if m.contains(needle))), + "{agent}: expected a refusal; got {evs:?}" + ); + assert!( + !evs.iter().any(|e| matches!(e, Event::Spawned { .. })), + "{agent}: a refusal must not ack; got {evs:?}" + ); + } + s.tick(); + assert!( + s.drain() + .iter() + .any(|e| matches!(e, Event::Tasks(v) if v.len() == 1)), + "refusals must add no task" + ); + + spawn(&mut s, "claude", dir.to_path_buf()); + let literal = spawned_id(&mut s); + assert!( + !view_of(&mut s, literal).managed, + "a typed word is a literal task" + ); +} + /// Tick once and return the flagship ids from the snapshot. fn flagship_ids(s: &mut Supervisor) -> Vec { s.tick(); diff --git a/src/ui.rs b/src/ui.rs index 88990c0..7045d18 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1229,6 +1229,7 @@ mod tests { cwd: std::path::PathBuf::from("/tmp"), tagged: false, flagship: false, + managed: false, group: None, name: name.map(str::to_string), lifecycle: Lifecycle::Active, diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 3dac5a1..d76b17d 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -17,7 +17,7 @@ use std::{ /// Protocol version used by this test suite; must match /// `protocol::PROTOCOL_VERSION`. -pub const PROTOCOL_VERSION: u32 = 13; +pub const PROTOCOL_VERSION: u32 = 14; /// One frame of the given kind: `[u32 len][kind][payload]`. pub fn frame(kind: u8, payload: &[u8]) -> Vec { @@ -132,6 +132,14 @@ pub fn spawn_frame(command: &str, cwd: &Path) -> Vec { )) } +/// Build a spawn-agent control frame naming `agent` by program word. +pub fn spawn_agent_frame(agent: &str, cwd: &Path) -> Vec { + control_frame(&format!( + r#"{{"t":"spawn_agent","agent":"{agent}","cwd":"{}"}}"#, + b64(cwd.as_os_str().as_bytes()) + )) +} + /// Spawn `command` (which must write its own `$$` to `pidfile`) and return the /// task's leader pid (== pgid: portable-pty `setsid`s it). pub fn spawn_task( diff --git a/tests/daemon_agents.rs b/tests/daemon_agents.rs new file mode 100644 index 0000000..ef38867 --- /dev/null +++ b/tests/daemon_agents.rs @@ -0,0 +1,186 @@ +//! Agent discovery and managed launches across the socket: the daemon reports +//! which registered agents the hello's `PATH` resolves, launches one by program +//! word as a managed task, refuses a word it does not know, and resends the +//! list on reconnect. A stub `claude` on a scratch `PATH` keeps every path +//! inside the test's tree. + +mod common; + +use std::{ + io::Write, + os::unix::{fs::PermissionsExt, net::UnixStream}, + path::PathBuf, + time::{Duration, Instant}, +}; + +use common::{ + read_frame, shake_hands_env, spawn_agent_frame, start_daemon_raw, stop_daemon, wait_until, +}; + +/// Scratch tree: `bin` holds the stub, `nobin` holds nothing, and the rest +/// keep the daemon's runtime, config, and registry reads off the host. +struct Scratch { + root: PathBuf, +} + +impl Scratch { + fn new(tag: &str) -> Self { + let root = std::env::temp_dir().join(format!( + "fleetcom_it_agents_scratch_{tag}_{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + for sub in ["bin", "nobin", "run", "config", "claude-home", "work"] { + std::fs::create_dir_all(root.join(sub)).unwrap(); + } + Self { root } + } + + fn work(&self) -> PathBuf { + self.root.join("work") + } + + /// The stub's argv record, one element per line. + fn record(&self) -> PathBuf { + self.root.join("claude-argv") + } + + /// Handshake environment whose `PATH` is exactly `/`: a + /// managed launch needs no shell, so nothing else must resolve. + fn hello_env(&self, bin: &str) -> Vec<(String, String)> { + vec![ + ("PATH".into(), self.root.join(bin).display().to_string()), + ("SHELL".into(), "/bin/sh".into()), + ( + "FLEETCOM_CONFIG_DIR".into(), + self.root.join("config").display().to_string(), + ), + ( + "FLEETCOM_RUNTIME_DIR".into(), + self.root.join("run").display().to_string(), + ), + ( + "CLAUDE_CONFIG_DIR".into(), + self.root.join("claude-home").display().to_string(), + ), + ] + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + if !std::thread::panicking() { + let _ = std::fs::remove_dir_all(&self.root); + } + } +} + +/// Send a handshake whose `PATH` is the scratch tree's `bin` subdirectory. +fn hello(stream: &mut UnixStream, s: &Scratch, bin: &str) { + let owned = s.hello_env(bin); + let env: Vec<(&[u8], &[u8])> = owned + .iter() + .map(|(k, v)| (k.as_bytes(), v.as_bytes())) + .collect(); + shake_hands_env(stream, &s.work().display().to_string(), &env); + // A stalled daemon fails the test instead of hanging it. + stream + .set_read_timeout(Some(Duration::from_secs(10))) + .unwrap(); +} + +/// Install a `claude` stub that records its argv and exits. +fn install_claude_stub(s: &Scratch) { + let path = s.root.join("bin").join("claude"); + std::fs::write( + &path, + format!( + "#!/bin/sh\nprintf '%s\\n' \"$@\" > '{}'\n", + s.record().display() + ), + ) + .unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap(); +} + +/// Read control frames until one tagged `tag` satisfies `pred`, within 10 s. +/// The reply shares the stream with periodic `tasks` snapshots. +fn next_frame(stream: &mut UnixStream, tag: &str, pred: impl Fn(&str) -> bool) -> String { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + assert!(Instant::now() < deadline, "no {tag} frame arrived"); + let (kind, payload) = read_frame(stream).expect("stream closed"); + let text = String::from_utf8_lossy(&payload).into_owned(); + if kind == 1 && text.contains(&format!(r#""t":"{tag}""#)) && pred(&text) { + return text; + } + } +} + +#[test] +fn hello_discovers_agents_and_spawn_agent_launches_a_managed_task() { + let s = Scratch::new("managed"); + install_claude_stub(&s); + let (dir, mut daemon, mut stream) = start_daemon_raw("agents", |_| {}); + hello(&mut stream, &s, "bin"); + + // Discovery follows the handshake: only the stub is on this PATH. + assert_eq!( + next_frame(&mut stream, "agents", |_| true), + r#"{"t":"agents","agents":["claude"]}"# + ); + + // A registered word launches a managed task, acked like a direct spawn. + stream + .write_all(&spawn_agent_frame("claude", &s.work())) + .unwrap(); + let spawned = next_frame(&mut stream, "spawned", |_| true); + assert!(spawned.contains(r#""id":1"#), "{spawned}"); + let tasks = next_frame(&mut stream, "tasks", |t| { + t.contains(r#""command":"claude""#) + }); + assert!( + tasks.contains(r#""managed":true"#), + "the view must say managed: {tasks}" + ); + assert!( + wait_until(Duration::from_secs(5), || s.record().exists()), + "the stub never ran" + ); + let argv = std::fs::read_to_string(s.record()).unwrap(); + assert!( + argv.lines().any(|l| l == "--session-id"), + "the launch must carry the harness argv: {argv:?}" + ); + + // An unregistered word is refused with a notice and adds no task. + stream + .write_all(&spawn_agent_frame("vim", &s.work())) + .unwrap(); + let status = next_frame(&mut stream, "status", |t| t.contains("vim")); + assert!(status.contains("no agent named"), "{status}"); + let tasks = next_frame(&mut stream, "tasks", |_| true); + assert_eq!( + tasks.matches(r#""id":"#).count(), + 1, + "a refused word must add no task: {tasks}" + ); + + // A reconnect from a PATH without the stub resends the list empty, so + // the new client never inherits the previous one's menu. + drop(stream); + let sock = dir.join("default.sock"); + let mut again = None; + wait_until(Duration::from_secs(5), || { + again = UnixStream::connect(&sock).ok(); + again.is_some() + }); + let mut again = again.expect("reconnect failed"); + hello(&mut again, &s, "nobin"); + assert_eq!( + next_frame(&mut again, "agents", |_| true), + r#"{"t":"agents","agents":[]}"# + ); + + stop_daemon(&mut daemon); +} From 55e04044d10a401be5cca8d156fbee0fc4b4e664 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Mon, 5 Oct 2026 18:40:54 -0700 Subject: [PATCH 04/13] test(capture): publish stub records atomically --- src/supervisor_capture_tests.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/supervisor_capture_tests.rs b/src/supervisor_capture_tests.rs index 5440896..44debb2 100644 --- a/src/supervisor_capture_tests.rs +++ b/src/supervisor_capture_tests.rs @@ -13,10 +13,14 @@ fn install_stub(bin: &Path, name: &str, out: &Path) { install_script( bin, name, + // Write each record to a per-process file beside its destination and + // rename it into place: `>` truncates first and `printf` may write + // one argument at a time, so a reader polling for a non-empty file + // could otherwise see a prefix of the argv. &format!( - "printf '%s' \"$FLEETCOM_CAPTURE_FILE\" > '{out}/capenv'\n\ - printf '%s' \"$FLEETCOM_BINARY\" > '{out}/binenv'\n\ - printf '%s\\n' \"$@\" > '{out}/argv'", + "printf '%s' \"$FLEETCOM_CAPTURE_FILE\" > '{out}/capenv.'$$'.tmp' && mv '{out}/capenv.'$$'.tmp' '{out}/capenv'\n\ + printf '%s' \"$FLEETCOM_BINARY\" > '{out}/binenv.'$$'.tmp' && mv '{out}/binenv.'$$'.tmp' '{out}/binenv'\n\ + printf '%s\\n' \"$@\" > '{out}/argv.'$$'.tmp' && mv '{out}/argv.'$$'.tmp' '{out}/argv'", out = out.display() ), ); From e7752b21b1f9c346a3f04c025f39a78600094c74 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Mon, 5 Oct 2026 18:58:25 -0700 Subject: [PATCH 05/13] feat(app): Agent page on the spawn prompt --- docs/commands.md | 10 +- src/app.rs | 127 +++++++++++++-- src/app_tests.rs | 381 ++++++++++++++++++++++++++++++++++++++++++++- src/harness/mod.rs | 10 +- src/ui.rs | 83 +++++++++- 5 files changed, 589 insertions(+), 22 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index bf094f9..ab36615 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -24,8 +24,8 @@ Choose the operating mode with launch arguments. Use keys to control the dashboa | `Tab` / `Shift-Tab` | Jump the selection to the next / previous section | | `Enter` | Attach to the selected task | | `Space` | Peek at the selected task | -| `n` | New command in the invocation directory | -| `@` | New command in a directory you pick | +| `n` | New command or agent in the invocation directory (through the [spawn prompt](#the-n-spawn-prompt)) | +| `@` | New command or agent in a directory you pick | | `/` | Jump the selection to a task by name, command, or group (through the [find palette](#the--find-palette)) | | `s` | Cycle grouping: by state / by directory / by custom group | | `m` | Tag the selected task "in use" (toggles) | @@ -162,6 +162,12 @@ For a named task, the name is displayed in place of the command in the dashboard The daemon removes control characters and surrounding whitespace from display names, then limits them to 64 characters. An empty result means no name. Unlike groups, `Unassigned` is a legal display name; the label is reserved only in the [group picker](#the-g-group-picker). +## The `n` spawn prompt + +Press `n` to run something in the invocation directory, or pick the directory first with `@`. The prompt has two pages. The Command page runs the typed text exactly as typed, through your shell. Press `Tab` (or `Shift-Tab`) for the Agent page: the supported agent CLIs found on the daemon's `PATH`, in a fixed order. `Enter` launches the highlighted one as a managed task: `fleetcom` builds the launch itself and owns the agent's session, see [agent session resume](agent-resume.md). Typing filters the list by a case-insensitive substring of the program name, so `x` finds `codex`; the first match is highlighted. Use `↑`/`↓` to move the highlight, `Enter` to launch in the prompt's directory and group, or `Esc` to cancel. When nothing matches, the prompt says so and names any supported agents not found on this host. + +Each page keeps its own text, and the filter is empty every time the prompt opens, so `Tab` always shows the full list first. With no agents found, `Tab` is unbound and omitted from the hint. + ## The `@` directory picker Press `@` to open the directory picker: a bottom panel with a path field and matching directories. Use `Enter` according to the selected row type: diff --git a/src/app.rs b/src/app.rs index 00ddfe2..ce0dacc 100644 --- a/src/app.rs +++ b/src/app.rs @@ -27,7 +27,7 @@ use crossterm::{ use crate::{ editbuf::EditBuffer, format::collation_key, - path, + harness, path, protocol::{ ClipboardKind, Command, Event, Key, Lifecycle, Mods, MouseBtn, MouseKind, RecoveryEntry, ScreenView, ScrollAction, TaskView, UNASSIGNED, @@ -139,6 +139,14 @@ pub enum SessionPage { Recovery, } +/// Active page of the spawn prompt. `Command` runs typed text literally; +/// `Agent` launches a picked agent as a managed task. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum SpawnPage { + Command, + Agent, +} + /// What Enter does with a picker row. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum DirKind { @@ -196,8 +204,8 @@ pub struct App { /// Task snapshot received from `Event::Tasks`. pub views: Vec, /// Agents installed on the core's launch `PATH`, from the latest - /// `Event::Agents`; program words in registry order. Nothing renders it - /// until the spawn prompt's Agent page lands. + /// `Event::Agents`; program words in registry order. The spawn prompt's + /// Agent page lists it and exists only while it is non-empty. pub agents: Vec, /// The watched task's screen (attach/peek), from `Event::Screen`. focused_screen: Option, @@ -224,6 +232,18 @@ pub struct App { /// Group assigned to the next spawn. Custom mode snapshots the selected /// task's group; State and Dir modes leave the spawn unassigned. pub spawn_group: Option, + /// The displayed spawn-prompt page; opening the prompt resets it to + /// `Command`. Tab toggles it only while `agents` is non-empty. + pub spawn_page: SpawnPage, + /// The Agent page's filter: its own buffer, so typed command text is + /// never consumed as a filter and both survive a page flip. Empty at + /// every open, so Tab always shows the full list first. + pub agent_input: EditBuffer, + /// Program words of `agents` matching `agent_input`, in registry order. + pub agent_candidates: Vec, + /// Highlighted row of `agent_candidates`; reset to the first match on + /// every filter edit. + pub agent_sel: usize, /// Id of the attached task, if any: by id (not index) so it survives the /// task list changing underneath it. pub focused_id: Option, @@ -465,6 +485,10 @@ impl App { input: EditBuffer::default(), spawn_cwd: invocation_dir.clone(), spawn_group: None, + spawn_page: SpawnPage::Command, + agent_input: EditBuffer::default(), + agent_candidates: Vec::new(), + agent_sel: 0, focused_id: None, return_to: None, terminal_focused: true, @@ -756,7 +780,19 @@ impl App { // The handshake is handled before the transport is created. Event::HelloOk => {} // Each context install resends the whole list; replace, never merge. - Event::Agents(a) => self.agents = a, + Event::Agents(a) => { + self.agents = a; + // A reconnect can replace the list under an open prompt. + // Refilter so the rows match, and leave an Agent page that + // Tab could no longer reach: the loader's emptied-recovery + // rule. + if self.mode == Mode::Spawn { + self.refresh_agent_candidates(); + if self.agents.is_empty() { + self.spawn_page = SpawnPage::Command; + } + } + } Event::Tasks(v) => { self.views = v; // The acknowledgement precedes its row. Select only after @@ -1039,15 +1075,42 @@ impl App { .collect() } - /// Lock in `dir` as the spawn target and move to command entry. + /// Lock in `dir` as the spawn target and move to command entry, with the + /// Agent page's filter cleared so Tab shows the full list. fn open_spawn_prompt(&mut self, dir: PathBuf) { self.spawn_cwd = dir; self.spawn_group = self.inherited_group(); self.input.clear(); self.dir_candidates.clear(); + self.spawn_page = SpawnPage::Command; + self.agent_input.clear(); + self.refresh_agent_candidates(); self.mode = Mode::Spawn; } + /// Rebuild the Agent page as the installed agents whose program word + /// contains the lowercased filter, in registry order, and select the + /// first. Order never follows match position: `o` lists codex, grok, omp + /// as the registry does. + fn refresh_agent_candidates(&mut self) { + let needle = self.agent_input.to_lowercase(); + self.agent_candidates = self + .agents + .iter() + .filter(|a| a.to_lowercase().contains(&needle)) + .cloned() + .collect(); + self.agent_sel = 0; + } + + /// Registered agents absent from `agents`: what the no-match text names + /// so a filter for an uninstalled agent explains itself. + pub fn missing_agents(&self) -> Vec<&'static str> { + harness::program_words() + .filter(|w| !self.agents.iter().any(|a| a == w)) + .collect() + } + /// Navigate into `dir`: retype the input as its path (trailing slash) so /// completion continues inside it, with the dir itself selected as row 0. fn enter_dir(&mut self, dir: PathBuf) { @@ -1478,11 +1541,53 @@ impl App { } fn on_key_spawn(&mut self, k: KeyEvent) { - self.on_key_textinput(k, |app, cmd| { - if !cmd.is_empty() { - app.spawn_task(cmd); + match k.code { + // Pure page toggle, gated as the loader gates its recovery page. + // Without agents Tab falls through to the editor, which ignores it. + KeyCode::Tab | KeyCode::BackTab if !self.agents.is_empty() => { + self.spawn_page = match self.spawn_page { + SpawnPage::Command => SpawnPage::Agent, + SpawnPage::Agent => SpawnPage::Command, + }; } - }); + _ => match self.spawn_page { + SpawnPage::Command => self.on_key_textinput(k, |app, cmd| { + if !cmd.is_empty() { + app.spawn_task(cmd); + } + }), + SpawnPage::Agent => self.on_key_agent(k), + }, + } + } + + /// Agent-page keys: filter, pick, and launch the highlighted agent as a + /// managed task in the prompt's directory and group. Enter on no match + /// leaves the prompt open. + fn on_key_agent(&mut self, k: KeyEvent) { + match k.code { + KeyCode::Esc => self.close_prompt(), + KeyCode::Up => self.agent_sel = self.agent_sel.saturating_sub(1), + KeyCode::Down => { + self.agent_sel = step_down(self.agent_sel, self.agent_candidates.len()) + } + KeyCode::Enter => { + if let Some(agent) = self.agent_candidates.get(self.agent_sel).cloned() { + self.transport.send(Command::SpawnAgent { + agent, + cwd: self.spawn_cwd.clone(), + group: self.spawn_group.clone(), + }); + self.close_prompt(); + } + } + // Caret motion does not affect the matches. + _ => { + if on_key_edit(&mut self.agent_input, k) { + self.refresh_agent_candidates(); + } + } + } } fn on_key_peek(&mut self, k: KeyEvent) { @@ -1583,6 +1688,10 @@ impl App { }); } } + Mode::Spawn if self.spawn_page == SpawnPage::Agent => { + paste_into(&mut self.agent_input, s); + self.refresh_agent_candidates(); + } Mode::Spawn | Mode::SaveSession | Mode::Rename(_) => { paste_into(&mut self.input, s); } diff --git a/src/app_tests.rs b/src/app_tests.rs index ed87c2a..c91d881 100644 --- a/src/app_tests.rs +++ b/src/app_tests.rs @@ -2,7 +2,7 @@ use super::*; use crate::{ protocol::{Preview, PreviewSource}, supervisor::Supervisor, - testutil::{Scratch, temp, wait_until}, + testutil::{Scratch, temp, wait_until, write_executable}, transport::LocalTransport, ui::scroll_window, }; @@ -3081,6 +3081,385 @@ fn state_and_dir_mode_spawns_stay_unassigned() { assert_eq!(v.group, None); } +// --- `n` spawn prompt: Agent page -------------------------------------- + +/// Hand the core's events to the app with no core behind them. +struct Scripted(Vec); + +impl Transport for Scripted { + fn send(&mut self, _cmd: Command) {} + + fn poll(&mut self) -> Vec { + std::mem::take(&mut self.0) + } + + fn connected(&self) -> bool { + true + } + + fn shutdown(&mut self, _intent: ExitIntent) {} +} + +impl App { + /// A detached app that lists `agents`: `new_local` never pumps here, so + /// the machine's real agents never replace the list, and Enter reaches + /// no core. Returns the send log. + fn with_agents(agents: &[&str]) -> (Self, Arc>>) { + let mut app = Self::new_local(30, 100); + app.agents = agents.iter().map(|a| a.to_string()).collect(); + app.transport = Box::new(Unplugged); + let sent = app.record_sends(); + (app, sent) + } + + /// `n`, then Tab onto the Agent page. + fn open_agent_page(&mut self) { + self.on_key_dashboard(key(KeyCode::Char('n'))); + self.on_key_spawn(key(KeyCode::Tab)); + assert_eq!(self.spawn_page, SpawnPage::Agent); + } + + /// Type `text` into the open spawn prompt one key at a time. + fn type_spawn(&mut self, text: &str) { + for c in text.chars() { + self.on_key_spawn(key(KeyCode::Char(c))); + } + } +} + +/// An app whose core's `PATH` holds a stub `claude` and nothing else, so +/// discovery lists exactly that and a managed launch runs the stub rather +/// than a real agent. Capture assets and the claude registry read stay in +/// the scratch tree. +fn stub_claude_fixture(tag: &str) -> (App, Scratch) { + let dir = temp(&format!("agent_{tag}")); + let bin = dir.join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + write_executable(&bin.join("claude"), "exit 0"); + let ctx = crate::protocol::LaunchContext { + env: vec![ + ("PATH".into(), bin.as_os_str().to_os_string()), + ( + path::FLEETCOM_RUNTIME_DIR.into(), + dir.join("run").into_os_string(), + ), + ( + "CLAUDE_CONFIG_DIR".into(), + dir.join("claude-home").into_os_string(), + ), + ], + cwd: dir.to_path_buf(), + }; + let mut app = App::new_local_with_ctx(30, 100, ctx); + app.pump(); + assert_eq!(app.agents, ["claude"], "discovery must see only the stub"); + (app, dir) +} + +/// Tab flips between the Command and Agent pages only while the core lists +/// agents; without any, both Tab forms fall through and the page holds. +#[test] +fn spawn_tab_toggles_pages_only_with_agents() { + let (mut app, _) = App::with_agents(&[]); + app.on_key_dashboard(key(KeyCode::Char('n'))); + assert_eq!(app.spawn_page, SpawnPage::Command); + app.on_key_spawn(key(KeyCode::Tab)); + assert_eq!( + app.spawn_page, + SpawnPage::Command, + "no agents: Tab is unbound" + ); + app.on_key_spawn(key(KeyCode::BackTab)); + assert_eq!(app.spawn_page, SpawnPage::Command); + assert_eq!(app.mode, Mode::Spawn, "and the prompt stays open"); + app.on_key_spawn(key(KeyCode::Esc)); + + app.agents = vec!["claude".to_string()]; + app.on_key_dashboard(key(KeyCode::Char('n'))); + app.on_key_spawn(key(KeyCode::Tab)); + assert_eq!(app.spawn_page, SpawnPage::Agent); + app.on_key_spawn(key(KeyCode::Tab)); + assert_eq!(app.spawn_page, SpawnPage::Command); + app.on_key_spawn(key(KeyCode::BackTab)); + assert_eq!(app.spawn_page, SpawnPage::Agent); +} + +/// Typing filters by a lowercase substring of the program word. The rows +/// keep registry order whatever the match position, and the first match is +/// selected after every edit. +#[test] +fn agent_filter_matches_substrings_in_registry_order() { + let (mut app, _) = App::with_agents(&["claude", "codex", "grok", "omp"]); + app.open_agent_page(); + assert_eq!(app.agent_candidates, ["claude", "codex", "grok", "omp"]); + assert_eq!(app.agent_sel, 0); + + // `o` is at index 1 of codex, 2 of grok and 0 of omp. + app.type_spawn("O"); + assert_eq!( + app.agent_candidates, + ["codex", "grok", "omp"], + "case folds; the order is the registry's, not the match position" + ); + app.type_spawn("k"); + assert_eq!( + app.agent_candidates, + ["grok"], + "\"ok\" is a substring, not a prefix" + ); + app.type_spawn("z"); + assert!(app.agent_candidates.is_empty()); + app.on_key_spawn(key(KeyCode::Backspace)); + assert_eq!(app.agent_candidates, ["grok"], "backspace re-widens"); +} + +/// Down and Up step through the matches and clamp at both ends; a filter +/// edit returns the highlight to the first match. +#[test] +fn agent_arrows_step_within_the_matches() { + let (mut app, _) = App::with_agents(&["claude", "codex", "grok", "omp"]); + app.open_agent_page(); + app.type_spawn("o"); // codex, grok, omp + app.on_key_spawn(key(KeyCode::Up)); + assert_eq!(app.agent_sel, 0, "Up clamps at the first match"); + for _ in 0..3 { + app.on_key_spawn(key(KeyCode::Down)); + } + assert_eq!(app.agent_sel, 2, "Down clamps at the last match"); + app.on_key_spawn(key(KeyCode::Up)); + assert_eq!(app.agent_sel, 1); + app.type_spawn("m"); + assert_eq!(app.agent_candidates, ["omp"]); + assert_eq!(app.agent_sel, 0, "a filter edit reselects the first match"); +} + +/// The common case, `n` → Tab → Enter: `SpawnAgent` for the first installed +/// agent in the invocation directory, the prompt closed, and the core admits +/// the task as managed with the program word as its row label. +#[test] +fn agent_enter_launches_the_highlighted_agent_managed() { + let (mut app, _dir) = stub_claude_fixture("enter"); + let sent = app.record_sends(); + app.open_agent_page(); + app.on_key_spawn(key(KeyCode::Enter)); + assert_eq!(app.mode, Mode::Dashboard, "Enter closes the prompt"); + assert_eq!( + sent.lock().unwrap().as_slice(), + [Command::SpawnAgent { + agent: "claude".to_string(), + cwd: app.invocation_dir.clone(), + group: None, + }] + ); + app.pump(); + assert_eq!(app.views.len(), 1); + assert!(app.views[0].managed, "the core must admit a managed task"); + assert_eq!( + app.views[0].command, "claude", + "the row shows the program word" + ); +} + +/// Enter on a stepped-to row names that row, with the directory `@` picked +/// and the group custom mode inherits: the destination a Command-page Enter +/// would use. +#[test] +fn agent_enter_uses_the_picked_directory_and_inherited_group() { + let root = temp("agent_dest"); + let (mut app, sent) = App::with_agents(&["claude", "codex"]); + app.views = vec![view(1, app.invocation_dir.clone(), false, Some("alpha"))]; + app.selected_id = Some(1); + app.group_mode = GroupMode::Custom; + + // The trailing slash leaves no fragment, so row 0 (the resolved path, + // DirKind::Use) stays selected and Enter hands off to Spawn. + type_pickdir(&mut app, &format!("{}/", root.display())); + app.on_key_pickdir(key(KeyCode::Enter)); + assert_eq!(app.mode, Mode::Spawn); + assert_eq!(app.spawn_cwd, *root); + assert_eq!(app.spawn_group.as_deref(), Some("alpha")); + + app.on_key_spawn(key(KeyCode::Tab)); + app.on_key_spawn(key(KeyCode::Down)); + app.on_key_spawn(key(KeyCode::Enter)); + assert_eq!( + sent.lock().unwrap().as_slice(), + [Command::SpawnAgent { + agent: "codex".to_string(), + cwd: root.to_path_buf(), + group: Some("alpha".to_string()), + }] + ); + assert_eq!(app.mode, Mode::Dashboard); +} + +/// Enter with no match sends nothing and leaves the prompt open on the +/// Agent page. +#[test] +fn agent_enter_with_no_match_sends_nothing() { + let (mut app, sent) = App::with_agents(&["claude"]); + app.open_agent_page(); + app.type_spawn("x"); + assert!(app.agent_candidates.is_empty()); + app.on_key_spawn(key(KeyCode::Enter)); + assert_eq!(app.mode, Mode::Spawn, "no match: Enter must not close"); + assert_eq!(app.spawn_page, SpawnPage::Agent); + assert!(sent.lock().unwrap().is_empty()); +} + +/// Esc closes the prompt from the Agent page without sending. +#[test] +fn agent_page_esc_closes_the_prompt_without_sending() { + let (mut app, sent) = App::with_agents(&["claude"]); + app.open_agent_page(); + app.type_spawn("cl"); + app.on_key_spawn(key(KeyCode::Esc)); + assert_eq!(app.mode, Mode::Dashboard); + assert!(sent.lock().unwrap().is_empty()); +} + +/// Each page keeps its own buffer across Tab: typed command text is never a +/// filter, and both survive the flip. Reopening the prompt clears both and +/// lands on the Command page, so Tab shows the full list again. +#[test] +fn spawn_pages_keep_separate_buffers_until_the_prompt_reopens() { + let (mut app, _) = App::with_agents(&["claude", "codex"]); + app.on_key_dashboard(key(KeyCode::Char('n'))); + app.type_spawn("cargo"); + app.on_key_spawn(key(KeyCode::Tab)); + assert!(app.agent_input.is_empty(), "command text is not a filter"); + assert_eq!(app.agent_candidates, ["claude", "codex"]); + app.type_spawn("co"); + assert_eq!(app.agent_candidates, ["codex"]); + app.on_key_spawn(key(KeyCode::Tab)); + assert_eq!(app.input.as_str(), "cargo", "the command survives the flip"); + app.on_key_spawn(key(KeyCode::Tab)); + assert_eq!(app.agent_input.as_str(), "co", "and so does the filter"); + assert_eq!(app.agent_candidates, ["codex"]); + + app.on_key_spawn(key(KeyCode::Esc)); + app.on_key_dashboard(key(KeyCode::Char('n'))); + assert_eq!(app.spawn_page, SpawnPage::Command, "reopens on Command"); + assert!(app.input.is_empty()); + assert!( + app.agent_input.is_empty(), + "the filter is empty at every open" + ); + assert_eq!( + app.agent_candidates, + ["claude", "codex"], + "so Tab shows the full list" + ); +} + +/// With agents listed, the Command page still runs typed text literally: +/// a typed `claude` is a `Spawn`, never a `SpawnAgent`. +#[test] +fn command_page_enter_still_spawns_the_literal_text() { + let (mut app, sent) = App::with_agents(&["claude"]); + app.on_key_dashboard(key(KeyCode::Char('n'))); + app.type_spawn("claude"); + app.on_key_spawn(key(KeyCode::Enter)); + assert_eq!(app.mode, Mode::Dashboard); + assert_eq!( + sent.lock().unwrap().as_slice(), + [Command::Spawn { + command: "claude".to_string(), + cwd: app.invocation_dir.clone(), + group: None, + }] + ); +} + +/// A paste on the Agent page lands in the filter, not the command buffer. +#[test] +fn agent_page_paste_filters_the_rows() { + let (mut app, _) = App::with_agents(&["claude", "codex"]); + app.open_agent_page(); + app.on_paste("cod"); + assert_eq!(app.agent_input.as_str(), "cod"); + assert_eq!(app.agent_candidates, ["codex"]); + assert!(app.input.is_empty(), "the command buffer is untouched"); +} + +/// A reconnect's `Agents` event refilters an open Agent page, and an empty +/// list returns it to the Command page, where Tab can no longer leave. +#[test] +fn agents_event_refilters_and_an_empty_list_leaves_the_agent_page() { + let (mut app, _) = App::with_agents(&["claude", "codex"]); + app.open_agent_page(); + app.type_spawn("c"); + + app.transport = Box::new(Scripted(vec![Event::Agents(vec!["codex".to_string()])])); + app.sync(); + assert_eq!(app.spawn_page, SpawnPage::Agent); + assert_eq!( + app.agent_candidates, + ["codex"], + "the rows follow the new list" + ); + + app.transport = Box::new(Scripted(vec![Event::Agents(Vec::new())])); + app.sync(); + assert_eq!(app.spawn_page, SpawnPage::Command); + assert_eq!(app.mode, Mode::Spawn, "the prompt itself stays open"); + app.on_key_spawn(key(KeyCode::Tab)); + assert_eq!(app.spawn_page, SpawnPage::Command); +} + +/// The Command page footer advertises the Agent page only when agents exist; +/// the Agent page paints the `❯` destination, the filter, the matching rows +/// with the first highlighted, and its own hint. +#[test] +fn agent_page_paints_the_rows_and_highlights_the_first_match() { + let (mut app, _) = App::with_agents(&[]); + app.cols = 80; + app.on_key_dashboard(key(KeyCode::Char('n'))); + let frame = painted(&mut app); + assert!(frame.contains(" enter run · esc"), "{frame:?}"); + assert!(!frame.contains("tab agents"), "no agents: no Agent page"); + app.on_key_spawn(key(KeyCode::Esc)); + + app.agents = ["claude", "codex"].map(String::from).to_vec(); + app.on_key_dashboard(key(KeyCode::Char('n'))); + app.spawn_cwd = PathBuf::from("/x"); + app.spawn_group = Some("alpha".to_string()); + let frame = painted(&mut app); + assert!( + frame.contains("enter run · tab agents (2) · esc"), + "{frame:?}" + ); + + app.on_key_spawn(key(KeyCode::Tab)); + app.type_spawn("c"); + let frame = painted(&mut app); + assert!(frame.contains(" ❯ /x ▸ alpha ▸ agent: c"), "{frame:?}"); + assert!(frame.contains(" ▸ claude"), "{frame:?}"); + assert!(frame.contains(" codex"), "{frame:?}"); + assert!(!frame.contains("▸ codex"), "only the first match is marked"); + assert!( + frame.contains("↑↓ pick · enter launch · tab command · esc"), + "{frame:?}" + ); +} + +/// A filter that matches nothing names the registered agents this host +/// lacks, in registry order. +#[test] +fn agent_page_no_match_names_the_missing_agents() { + let (mut app, _) = App::with_agents(&["claude", "codex"]); + app.cols = 80; + assert_eq!(app.missing_agents(), ["grok", "omp"]); + app.open_agent_page(); + app.type_spawn("zz"); + let frame = painted(&mut app); + assert!( + frame.contains("(no installed agent matches · grok, omp not found on this host)"), + "{frame:?}" + ); + assert!(!frame.contains("▸ claude"), "{frame:?}"); +} + // --- OSC 52 clipboard emission ------------------------------------------ /// An attached clipboard store emits one BEL-terminated OSC 52 sequence. diff --git a/src/harness/mod.rs b/src/harness/mod.rs index 192fa6e..464699e 100644 --- a/src/harness/mod.rs +++ b/src/harness/mod.rs @@ -196,14 +196,18 @@ pub fn registered(program: &str) -> Option<&'static dyn Harness> { .find(|h| h.shape().0 == program) } +/// Program words of every registered agent, in registry order. The launcher +/// subtracts `installed` from this to name the agents a host lacks. +pub fn program_words() -> impl Iterator { + AGENTS.iter().map(|a| a.harness.shape().0) +} + /// Program words of the registered agents that [`find_on_path`] resolves on /// `path`, in registry order: the launcher's menu. Each word is searched /// independently, so the result never depends on `path` order. An empty /// `path` yields an empty list. pub fn installed(path: &OsStr) -> Vec<&'static str> { - AGENTS - .iter() - .map(|a| a.harness.shape().0) + program_words() .filter(|program| find_on_path(program, path).is_some()) .collect() } diff --git a/src/ui.rs b/src/ui.rs index 7045d18..6638856 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -17,7 +17,7 @@ use crossterm::{ use unicode_width::UnicodeWidthStr; use crate::{ - app::{App, DirKind, GroupMode, Mode, Row, SessionPage, Target}, + app::{App, DirKind, GroupMode, Mode, Row, SessionPage, SpawnPage, Target}, editbuf::EditBuffer, format::{pad, rel_time, truncate}, path, @@ -56,6 +56,10 @@ pub fn render(out: &mut impl Write, app: &mut App) -> io::Result { render_dashboard(&mut buf, app)?; render_session_picker(&mut buf, app)?; } + Mode::Spawn if app.spawn_page == SpawnPage::Agent => { + render_dashboard(&mut buf, app)?; + render_agent_page(&mut buf, app)?; + } Mode::Disconnected => render_disconnected(&mut buf, app)?, Mode::Dashboard | Mode::Spawn | Mode::SaveSession | Mode::Rename(_) => { render_dashboard(&mut buf, app)? @@ -209,12 +213,12 @@ fn render_dashboard(out: &mut impl Write, app: &App) -> io::Result<()> { } // Keep common actions visible and route the remaining bindings through `?`. - dim( - out, - rows.saturating_sub(1), - &dashboard_hint(app.chord_target()), - cols, - )?; + // The spawn prompt owns its footer: that is where it advertises Tab. + let footer = match app.mode { + Mode::Spawn => spawn_page_hint(app.agents.len()), + _ => dashboard_hint(app.chord_target()), + }; + dim(out, rows.saturating_sub(1), &footer, cols)?; match &cmd { Some((_, cx)) => queue!(out, MoveTo(*cx, cmd_y), Show)?, @@ -238,11 +242,23 @@ fn transient_line(notice: Option<&str>, status: Option<&str>) -> Option notice.or(status).map(|s| format!(" {s}")) } +/// Build the spawn prompt's Command-page footer. Include the Agent page only +/// when the core found agents, as the loader includes recovery. +fn spawn_page_hint(agents: usize) -> String { + if agents > 0 { + format!(" enter run · tab agents ({agents}) · esc") + } else { + " enter run · esc".to_string() + } +} + /// The editable bottom line for the text-input modes (the rendered line and /// the caret's display column), or `None` when the command line should show a /// hint/status instead. fn cmdline(app: &App) -> Option<(String, u16)> { let prefix = match app.mode { + // The Agent page paints its own field in its panel. + Mode::Spawn if app.spawn_page == SpawnPage::Agent => return None, Mode::Spawn => spawn_prefix(app), Mode::SaveSession => " save session as: ".to_string(), Mode::Rename(_) => " rename task: ".to_string(), @@ -841,6 +857,42 @@ fn render_find(out: &mut impl Write, app: &App) -> io::Result<()> { ) } +/// The Agent page's placeholder when the filter matches nothing. Name the +/// registered agents this host lacks, so a filter for one of them explains +/// itself; `missing` is in registry order. +fn agent_empty_text(missing: &[&str]) -> String { + if missing.is_empty() { + " (no installed agent matches)".to_string() + } else { + format!( + " (no installed agent matches · {} not found on this host)", + missing.join(", ") + ) + } +} + +/// The spawn prompt's Agent page: a bottom panel with the same `❯` destination +/// prefix as the Command page, the filter field, and the matching installed +/// agents in registry order with `agent_sel` highlighted. +fn render_agent_page(out: &mut impl Write, app: &App) -> io::Result<()> { + let prefix = format!("{}agent: ", spawn_prefix(app)); + let (line, cx) = caret_line(&prefix, &app.agent_input, app.cols as usize); + let empty = agent_empty_text(&app.missing_agents()); + render_panel( + out, + app, + &Panel { + header: format!("{line} "), + labels: &app.agent_candidates, + sel: app.agent_sel, + max_rows: 8, + hint: "↑↓ pick · enter launch · tab command · esc".to_string(), + empty: Some(&empty), + cursor: Some(cx), + }, + ) +} + /// Format a recovery row with its variable-length label last for clipping. fn recovery_row(e: &RecoveryEntry) -> String { let unit = if e.tasks == 1 { "task" } else { "tasks" }; @@ -1203,6 +1255,23 @@ mod tests { ); } + /// Advertise the Agent page in the spawn footer only when agents exist. + #[test] + fn spawn_page_hint_shows_the_count_only_when_nonzero() { + assert_eq!(spawn_page_hint(0), " enter run · esc"); + assert_eq!(spawn_page_hint(3), " enter run · tab agents (3) · esc"); + } + + /// Name the missing agents only when some are missing. + #[test] + fn agent_empty_text_names_the_missing_agents() { + assert_eq!(agent_empty_text(&[]), " (no installed agent matches)"); + assert_eq!( + agent_empty_text(&["grok", "omp"]), + " (no installed agent matches · grok, omp not found on this host)" + ); + } + /// Directory and group destinations appear only when present. #[test] fn spawn_prompt_decoration_shapes() { From 0db92617a3a1d72001d2e04544fad4a79d5ffc55 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Mon, 5 Oct 2026 19:28:47 -0700 Subject: [PATCH 06/13] feat(session): format v2 with managed entries; typed commands run verbatim --- README.md | 4 +- docs/README.md | 4 +- docs/agent-resume.md | 91 +++-- docs/commands.md | 6 +- docs/sessions.md | 48 +-- src/harness/claude.rs | 65 +--- src/harness/codex.rs | 102 ++---- src/harness/grok.rs | 43 +-- src/harness/mod.rs | 396 ++-------------------- src/harness/omp.rs | 40 +-- src/harness/summary_tests.rs | 4 +- src/protocol.rs | 6 +- src/session.rs | 493 ++++++++++++++++++++++++--- src/supervisor.rs | 166 +++++---- src/supervisor_capture_tests.rs | 584 +++++++++++++++++++------------- src/supervisor_tests.rs | 33 +- src/task.rs | 13 +- tests/daemon_resume.rs | 82 +++-- 18 files changed, 1101 insertions(+), 1079 deletions(-) diff --git a/README.md b/README.md index 79e45ca..3e89096 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Organize related tasks into named groups across working directories. ### Resume agent sessions -Start `claude`, `codex`, `grok`, or `omp` normally. Rerun the task or reload a saved session to resume the conversation. +Launch `claude`, `codex`, `grok`, or `omp` from the Agent page (`n`, then `Tab`). Rerun the task or reload a saved session and the conversation picks up where it left off. ## Operational model @@ -40,7 +40,7 @@ Managing several long-lived commands across terminal panes is pesky, especially - Keep tasks running under a daemon across client disconnects. - Save and reload task recipes: directories, commands, group assignments, and display names. - Rerun a completed task in place, keeping its identity, group, and name. -- Save and rerun `claude`, `codex`, `grok`, and `omp` tasks with captured conversation IDs. +- Launch `claude`, `codex`, `grok`, and `omp` as managed agents whose conversations survive rerun and reload. - Recover the current task set from automatic snapshots. ## Documentation diff --git a/docs/README.md b/docs/README.md index 59983eb..69861d7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -167,9 +167,9 @@ Full command lines are persisted in recipes, including any embedded secrets. A t The client environment is not persisted. Each client's environment and working directory are sent during the connection handshake and retained in daemon memory. Only directories, commands, group assignments, and display names are stored in session and recovery files. -### Captured IDs in shell commands +### Captured IDs -To resume an agent conversation, insert its captured ID into a command run through `$SHELL -c`. Before shell insertion, accept only lowercase hexadecimal in the `8-4-4-4-12` UUID format. Validate capture payloads, Codex rollout headers, live session records, and the final command construction. Never read session IDs from terminal output; instrument only a bare program word or its canonical resume form. See [Agent session resume](agent-resume.md#validation-boundary) for both boundaries. +A captured ID enters an agent's argv as one element and a session file as its `resume` field; it is never inserted into a shell command. Before either, accept only lowercase hexadecimal in the `8-4-4-4-12` UUID format. Validate capture payloads, Codex rollout headers, live session records, and session files on load. Never read session IDs from terminal output, and capture only for agents launched from the Agent page: a typed command runs as typed and gets no channel. See [Agent session resume](agent-resume.md#validation-boundary). ### Copying text through the terminal diff --git a/docs/agent-resume.md b/docs/agent-resume.md index b42f242..3b0dc3c 100644 --- a/docs/agent-resume.md +++ b/docs/agent-resume.md @@ -1,33 +1,30 @@ # Agent session resume -Session files store launch commands, not process state. If you relaunch a bare `claude`, `codex`, `grok`, or `omp` command, you ordinarily start another conversation. To preserve that conversation, `fleetcom` captures a validated ID when one is available for an accepted command. It uses that ID to construct a canonical resume command when you save a session or rerun a finished task (`r`). +Session files store launch recipes, not process state. A relaunched agent ordinarily starts a new conversation. `fleetcom` keeps the conversation only for agents it launched itself: a managed task, started from the spawn prompt's Agent page, is launched with the argv and environment that let `fleetcom` capture a validated session ID, and that ID is what a save, a recovery snapshot, or a rerun (`r`) reopens. -## Workflow +## Two kinds of task -Start a supported agent without flags: +Every task is one of two kinds, decided by how it was started, never by what the command says. -1. Press `n` and run `claude`, `codex`, `grok`, or `omp`. The task is listed under the command you typed. Only the string executed through `$SHELL -c` is instrumented; the requested command is still displayed for a direct spawn. -2. Work in it. Press `Enter` to attach; press `Ctrl-\` to return to the dashboard. Depending on the agent, an ID is pinned at launch and may be updated from a hook, notifier, extension, or matching live registry record. -3. Press `w`, enter a session name, and press `Enter`. A captured bare command is saved in canonical resume form, such as `claude --resume ''`. Without a known ID, the authored command is preserved. -4. Run `fleetcom `, or press `o` in the dashboard, to start new processes from the saved commands. Use a stored resume command to reopen the captured conversation. +- **Literal.** You typed a command on the Command page, or a session file stored one. `fleetcom` runs exactly that text under `$SHELL -c` and never edits it. That holds when the text is exactly `claude`: a typed agent gets no instrumentation, no capture, no registry read, and no resume. Rerun runs the same text, and a save stores it. The dashboard still reads its screen: the summary adapter chosen by the command's first word scrapes the same previews it always did. +- **Managed.** You picked an agent on the Agent page (`n`, then `Tab`), or a session file stored a managed entry. `fleetcom` resolves the program word on the connected client's `PATH`, runs the binary directly with no shell between, and owns the session: it builds the argv, injects its capture channel, reads the ID back, and reopens that conversation on rerun and reload. The row shows the program word. -Press `r` on a finished agent task to rerun it with the captured launch, hook, notifier, extension, or registry ID. A registry record is still eligible after exit if present. The task's ID, tag, group, and name are preserved. After a successful rewrite, the resume command is displayed in the row and stored as the launch recipe. The same string is written to a [saved session](sessions.md). +There are no settings for managed launches. An agent that needs flags is a literal command, and gives up management. -Capture is best-effort and narrow by design. Commands with prompts, extra flags, or shell syntax are treated as opaque and saved verbatim. Accepted commands with no available ID are also saved unchanged. In both cases, the original command is rerun on load. +## Workflow -## Accepted command boundary +1. Press `n`, or `@` to pick a directory first, then `Tab` for the Agent page. The page lists the supported agents found on the daemon's `PATH`: `claude`, `codex`, `grok`, `omp`. Pick one and press `Enter`. +2. Work in it. Press `Enter` to attach; press `Ctrl-\` to return to the dashboard. Depending on the agent, an ID is pinned at launch and may be updated from a hook, notifier, extension, or matching live registry record. +3. Press `w`, enter a session name, and press `Enter`. The task is saved as a managed entry naming the agent and its current session ID; see [Sessions](sessions.md#format). +4. Run `fleetcom `, or press `o` in the dashboard, to start new processes from the recipe. A managed entry launches the agent again and resumes the stored ID. -The capture boundary is intentionally narrow. Only these forms are accepted: +Press `r` on a finished managed task to rerun it with its best-known ID: the capture, registry, or launch ID. A registry record is still eligible after exit if present. Without any ID (an `omp` saved before its first turn, a `codex` whose notifier could not be chained), the rerun or reload starts a fresh conversation. The task's ID, tag, group, and name are preserved. -- `claude`, `codex`, `grok`, or `omp` -- `claude --resume ` -- `codex resume ` -- `grok --resume ` -- `omp --resume ` +## Process identity -The program word may be a path such as `/usr/local/bin/claude` when its basename matches and the token contains no shell syntax. A resume UUID may be bare or single-quoted, but it must be the final argument. +A managed agent is the task leader by construction: `fleetcom` executes the binary found on `PATH` directly, with no shell between. Claude's capture gate and registry reader key on the leader PID, so this holds in every login shell, including csh and tcsh, which keep the leader for themselves when a launch goes through `$SHELL -c`. Shebang scripts keep the identity: the kernel runs the interpreter in the same process. A wrapper script that starts the real binary as a child breaks it, the same way a resident shell would. -Everything else is treated as opaque and executed, displayed, and saved verbatim. This includes prompts, flags, alternate resume spellings, subcommands, trailing arguments, and shell syntax. With this boundary, injected arguments cannot be bound to a different shell command than the one recognized during detection. +A managed agent inherits the environment of the `fleetcom` client it was launched from. Every spawn runs with the connected client's environment, sent at connect and reconnect, and that client's shell sourced its startup files before the environment was captured. A dotfile edit takes effect only after a client is started from a new shell. Reruns use the context current at the rerun. Verified 2026-10-05 with marker exports in a scratch `ZDOTDIR`: Claude Code 2.1.289's tool commands saw the launch environment plus `.zshenv` exports, and codex-cli 0.160.1 ran each command as `/bin/zsh -lc`, so neither loses anything a shell trampoline would have added. ## Capture state and isolation @@ -40,25 +37,25 @@ For each supervisor installation, a private mode-`0700` `/-` n - `omp-capture.js`, mode `0600`: imported as a module, so no executable bit is required - `task--.json` capture paths -The random nonce isolates concurrent supervisors and prevents PID reuse from selecting an existing namespace. Each rerun also gets a distinct run number, so a displaced process cannot overwrite the replacement run's capture file. Every other root entry is left unchanged during installation. +The random nonce isolates concurrent supervisors and prevents PID reuse from selecting an existing namespace. Each rerun also gets a distinct run number, so a displaced process cannot overwrite the replacement run's capture file. Every other root entry is left unchanged during installation. A managed launch whose assets cannot be installed is refused: without the overlay, a `codex` would hand its conversation to the shared server. ## Evidence sources -Each agent process is supervised by the `fleetcom` daemon. A conversation handed off to the agent CLI's own background service is outside that task. For accepted commands, keep the conversation in the task's process and accept captured IDs only from that process. +Each managed agent process is supervised by the `fleetcom` daemon. A conversation handed off to the agent CLI's own background service is outside that task. Keep the conversation in the task's process and accept captured IDs only from that process. All validation on 2026-10-04 was performed on macOS; Linux was not tested. ### `claude` -Claude accepts an ID at launch. For a bare command, the harness therefore generates a v4 UUID and adds it with the settings overlay: +Claude accepts an ID at launch. For a fresh launch, the harness therefore generates a v4 UUID and passes it ahead of the settings overlay: ```text ---session-id '' --settings '/claude-settings.json' +--session-id --settings /claude-settings.json ``` -For a canonical resume command, the conversation ID is already specified, so only `--settings` is added. Two keys are layered over your settings for the launched process alone; your settings files are not modified. +A resume names its conversation with `--resume ` instead, and only `--settings` follows. Two keys are layered over your settings for the launched process alone; your settings files are not modified. -With the first key, `"disableAgentView": true`, agent view is disabled: `claude agents`, `--bg`, `/background`, and Claude's on-demand daemon. With agent view enabled, a conversation can be handed off to that daemon, outside the task's process. In an instrumented task, you therefore cannot background a conversation: `/background` is unavailable, you cannot park the conversation by pressing Left arrow on an empty prompt, and with `/fork` you start a sub-agent inside the task's process instead of a background session. +With the first key, `"disableAgentView": true`, agent view is disabled: `claude agents`, `--bg`, `/background`, and Claude's on-demand daemon. With agent view enabled, a conversation can be handed off to that daemon, outside the task's process. In a managed task, you therefore cannot background a conversation: `/background` is unavailable, you cannot park the conversation by pressing Left arrow on an empty prompt, and with `/fork` you start a sub-agent inside the task's process instead of a background session. A `SessionStart` hook is registered through the second key: @@ -70,15 +67,15 @@ The first line of the capture file is the parent Claude process's PID; the rest Claude session records are also available at `/sessions/.json`, one per session. The direct path for the task leader's PID is read. -A record is accepted only when its `kind` is `interactive` and its `pid`, `cwd`, and `startedAt` match the task. The PID must match the filename, the working directories must be identical or resolve to the same path, and the process start must fall within 30 seconds of the task spawn. Missing, malformed, or mismatched records are ignored. A matching record's `waiting` status is also mapped to the top tier of the [preview cascade](commands.md#peek); the preview is unchanged for other statuses. +A record is accepted only when its `kind` is `interactive` and its `pid`, `cwd`, and `startedAt` match the task. The PID must match the filename, the working directories must be identical or resolve to the same path, and the process start must fall within 30 seconds of the task spawn. Missing, malformed, or mismatched records are ignored. A matching record's `waiting` status is also mapped to the top tier of the [preview cascade](commands.md#peek); the preview is unchanged for other statuses. A literal `claude` never gets this tier: its blocked status comes from the screen alone. -For both hook validation and registry lookup, use the task leader's PID. Claude must therefore be the task leader after an exec from `$SHELL -c`. With the shell retained as task leader, Claude's PID is stamped in the capture, and no registry record is available for the leader. The capture is rejected, no ID is read from the registry, and the spawn-time ID is used. A conversation selected after launch, including through `/clear`, is then not followed. +Both hook validation and registry lookup use the task leader's PID, which a managed launch makes the Claude process by construction. A stamp from any other PID is rejected and the next source is tried, so a conversation selected after launch, including through `/clear`, is followed through the registry. Live validation on 2026-10-04 was performed with Claude Code 2.1.289. With agent view enabled, after using `/fork`, `/background`, or Left arrow on an empty prompt, the capture file was written by a process other than the task. When rerunning with such an ID, the exit status was 1 with "… is running in the background. Run `claude attach ` …". After pressing Left arrow, the original ID was still in the registry; on rerun with that ID, a snapshot was reopened without the later turns. With `disableAgentView` in the overlay, no handoffs were observed. The sub-agent started through `/fork ` was run in-process, and the same ID was present in the capture file, registry, and spawn-time metadata. The task's PID was stamped in the capture at startup and again after `/clear`. ### `codex` -You cannot choose a Codex ID at launch. Two overrides are injected into both accepted forms: +You cannot choose a Codex ID at launch. Two overrides ride every managed launch, after `resume ` when resuming: ```text -c 'notify=["/codex-notify.sh"]' -c 'features.daemon_auto_start=false' @@ -86,7 +83,7 @@ You cannot choose a Codex ID at launch. Two overrides are injected into both acc Since Codex 0.157.0, running plain `codex` attaches to a shared background server. With the notify override alone, embedded mode is used and a startup warning is displayed. Set `features.daemon_auto_start=false` to explicitly use embedded mode without the warning. -With embedded mode, the conversation is kept in the task's process. For an instrumented task, the shared server is never used: you cannot find the task in `codex agents` or reach it through `codex queue`. The embedded override is therefore added on every instrumented launch, including those where capture is unavailable: without it, killing the task would kill only the TUI client and leave the conversation running in the shared server. +With embedded mode, the conversation is kept in the task's process. For a managed task, the shared server is never used: you cannot find the task in `codex agents` or reach it through `codex queue`. The embedded override is therefore added on every managed launch, including those where capture is unavailable: without it, killing the task would kill only the TUI client and leave the conversation running in the shared server. After each turn, Codex runs the notify script with the `agent-turn-complete` JSON as its argument. Notifications are emitted for several threads in one Codex process: the conversation on screen, each spawned sub-agent, and a hidden thread used to title a new session. Only the first is the task's conversation, so do not use the notified `thread-id` directly. The title thread's notification lands before the root's in some sessions and after it in others, so a notification must be validated when it arrives, not when the capture file is read: a later title notification written verbatim would erase the root. @@ -94,7 +91,7 @@ The script therefore runs `"$FLEETCOM_BINARY" --codex-notify-v1 "$1"`, an intern On acceptance, the mode writes the bare root UUID, with no newline, to `..tmp` beside the capture file and renames it over `FLEETCOM_CAPTURE_FILE`, so a reader never sees a partial file. On refusal it writes nothing: the slot only ever moves from one accepted root to another. It prints nothing; its exit status is informational, and the script ignores it. After a completed turn in the resumed conversation, an in-TUI session change is captured this way. -The capture file format is frozen as v1: exactly one bare UUID. When the file is read, accept it only when its whole contents pass the UUID check, with no trimming. Nothing is reparsed and no rollout is read at that point, so an accepted root stays accepted after Codex compresses old rollouts, and the notification's conversation text never reaches disk. An older script's JSON payload fails that check and is refused: the safe fallback, since the spawn-time ID or the authored command is used instead. +The capture file format is frozen as v1: exactly one bare UUID. When the file is read, accept it only when its whole contents pass the UUID check, with no trimming. Nothing is reparsed and no rollout is read at that point, so an accepted root stays accepted after Codex compresses old rollouts, and the notification's conversation text never reaches disk. An older script's JSON payload fails that check and is refused: the safe fallback, since the launch ID or no ID is used instead. Look up only the thread ID from the task's own notification. List the dated directories to find its rollout, then read only the first line. Do not infer ownership from other sessions in the store. @@ -112,14 +109,14 @@ Live validation on 2026-10-04 was performed with codex-cli 0.160.0 over nine TUI ### `grok` -You can specify a Grok ID at launch, but cannot inject a live-capture channel. For a bare command, `--session-id ''` is added; no instrumentation is required for a canonical resume command. +You can specify a Grok ID at launch, but cannot inject a live-capture channel. A fresh launch passes `--session-id `; a resume passes `--resume `. Nothing else rides along. ### `omp` -You cannot pin an omp ID at launch: no `--session-id` flag is available, and an existing session is required for `--resume`. The same injection is therefore added to both accepted forms, without a pinned ID: +You cannot pin an omp ID at launch: no `--session-id` flag is available, and an existing session is required for `--resume`. Every managed launch therefore loads the extension and pins nothing; a resume names its conversation with `--resume ` first: ```text --e '/omp-capture.js' +-e /omp-capture.js ``` Load the JavaScript module into the agent process through `-e`, appending it to your extensions. On `session_start`, `session_switch`, `session_branch`, and `agent_end`, write `sessionId` as JSON to `FLEETCOM_CAPTURE_FILE`, including after in-TUI `/resume` changes. Report only IDs usable with `omp --resume`: @@ -131,19 +128,17 @@ At `agent_end`, after every turn, report two changes without dedicated events: s As a result: -- For a bare `omp` task, no ID is captured until the end of the first turn. Save before then to retain the authored `omp`. -- Before omp 18.3.2, `ctx.agent` is unavailable, so no session ID is captured: a bare `omp` is always saved as authored. +- For a fresh `omp` task, no ID is captured until the end of the first turn. Save before then and the entry carries no ID: it reloads fresh. +- Before omp 18.3.2, `ctx.agent` is unavailable, so no session ID is captured: an `omp` task is always saved without an ID. - After switching or branching to a session without a file on disk, continue using the previous ID until the end of the next turn. Write each report to a temporary file beside the capture file, then rename it over the destination to avoid reads of partial payloads. Capture is best-effort: return immediately for an empty capture path and ignore write errors. Live validation on 2026-10-04 was performed with omp 18.6.1 and a local model. Nothing was written before the first prompt. At the first `agent_end`, the top-level session's ID was written, with its session file present on disk. The handlers were also invoked for a sub-agent at `session_start` and `agent_end`, with the sub-agent's own ID; the top-level ID was preserved in the capture. After `/fork`, the new ID was reported through `session_switch`. After a rewind through `/branch`, the session ID was unchanged and no event was emitted. When resuming a session already leased by another live process, a new ID was assigned at the first write and reported at the next `agent_end`. No `session_branch` event was observed; its handling was determined from the oh-my-pi source at `v18.6.1`. -The aliases `-r`, `--session`, and `-c` remain opaque because only the exactly detected canonical form is rewritten. - ## ID precedence -Different conversation IDs may be available from different channels during one task. The first available ID is selected in this order: +Different conversation IDs may be available from different channels during one managed task. The first available ID is selected in this order: 1. The current capture-file payload, if accepted by the harness. 2. The live session registry, implemented by `claude`. @@ -151,32 +146,32 @@ Different conversation IDs may be available from different channels during one t If a capture is rejected, try the next source. Use the same precedence for named saves, recovery snapshots, and reruns. Do not infer conversation ownership from session stores: you cannot determine the owning task from a nearby transcript or rollout. -Session IDs are never read from terminal output: another conversation's valid UUID may be present in examples, quoted commands, or tool output. An ID available only in an exit hint is not recovered. Without a capture, registry, or launch ID, the authored command remains unchanged. +Session IDs are never read from terminal output: another conversation's valid UUID may be present in examples, quoted commands, or tool output. An ID available only in an exit hint is not recovered. Without a capture, registry, or launch ID, the entry carries no ID and the next launch is fresh. A registry ID is preferred over the spawn pin because it may have been selected after launch, including through `/clear`. A capture-file ID is preferred over the registry ID. -On save and rerun, accepted commands are rewritten to one of these forms: +On save, a managed task is written as `{ "agent": "", "resume": "" }`. On rerun and reload, the harness places the selector and the ID first in the argv: ```text -claude --resume '' -codex resume '' -grok --resume '' -omp --resume '' +claude --resume +codex resume +grok --resume +omp --resume ``` -The program word is preserved as typed. If no valid ID is available, the original command remains unchanged. On rerun, the run number is incremented before the replacement is spawned, isolating it from capture data for the displaced run. +The ID is one argv element; no shell parses it. On rerun, the run number is incremented before the replacement is spawned, isolating it from capture data for the displaced run. ## Validation boundary -Before inserting a captured ID into a shell command, accept only lowercase hexadecimal characters in the `8-4-4-4-12` UUID format. Validate capture payloads, Codex rollout headers, registry records, and the final command construction. Ignore malformed values rather than interpolating them. Conversation ownership cannot be determined from UUID validity alone. +A captured ID enters an agent's argv as one element and a session file as the `resume` field. Before either, accept only lowercase hexadecimal characters in the `8-4-4-4-12` UUID format. Validate capture payloads, Codex rollout headers, registry records, and session files on load. Ignore malformed values from capture channels; refuse a session file that carries one. Conversation ownership cannot be determined from UUID validity alone. ## Extending capture -Implement the `Harness` trait in [`src/harness/mod.rs`](../src/harness/mod.rs) for each tool. Register it in that file's `AGENTS` table, the sole registry for detection. Include a `SummaryAdapter` for the dashboard preview with each entry; see the implementations in [`src/harness/summary.rs`](../src/harness/summary.rs). Separate detection, evidence collection, and command construction through these methods: +Implement the `Harness` trait in [`src/harness/mod.rs`](../src/harness/mod.rs) for each tool. Register it in that file's `AGENTS` table: the one registry the Agent page, session files, and the summary adapters read. Include a `SummaryAdapter` for the dashboard preview with each entry; see the implementations in [`src/harness/summary.rs`](../src/harness/summary.rs). Separate launch construction from evidence collection through these methods: -- `shape`: supply the program word and resume selector. Accepted and canonical forms are derived from that pair by the default `detect` and `resume_command` implementations. +- `shape`: supply the program word and resume selector. The word names the tool on the Agent page and in session files; `harness::intent_args` places the selector and the ID first in a resuming argv. - `session_flag`: name the flag that pins a session ID on a fresh launch, or return `None` (the default) when the tool assigns its own. `harness::plan` builds the conversation-selecting argv from this flag and the resume selector. -- `overlay`: return the argv elements, environment entries, and optional notice every launch of the tool carries after the conversation selection; the supervisor reports the notice on the status line once the task spawns. A managed launch passes the elements to the binary directly; a detected literal command gets them appended as shell text. +- `overlay`: return the argv elements, environment entries, and optional notice every launch of the tool carries after the conversation selection; the supervisor reports the notice on the status line once the task spawns. The elements are passed to the binary directly. - `parse_capture`: read an ID from the capture file's contents. The task leader's PID and the launch-time home are supplied; when other processes can reach the capture channel, use them to refuse a payload the task's own process did not write. - `live_session_id`: read the ID published on disk for a live session. Return `None` by default when no registry is available. - `live_blocked_status`: read that registry for blocked-on-user status. Return preview text, never an ID; return `None` by default. diff --git a/docs/commands.md b/docs/commands.md index ab36615..741d53c 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -14,7 +14,7 @@ Choose the operating mode with launch arguments. Use keys to control the dashboa | `fleetcom --help` / `-h` | Print usage and exit | | `fleetcom --version` / `-V` | Print the version and exit | -`--daemon` and `--codex-notify-v1` are internal. The daemon is started automatically when needed; the notify mode is run by the notifier injected into `codex` tasks, see [agent session resume](agent-resume.md#codex). The first non-`-` argument is treated as the session name; a second is rejected. +`--daemon` and `--codex-notify-v1` are internal. The daemon is started automatically when needed; the notify mode is run by the notifier injected into managed `codex` tasks, see [agent session resume](agent-resume.md#codex). The first non-`-` argument is treated as the session name; a second is rejected. ## Dashboard @@ -34,7 +34,7 @@ Choose the operating mode with launch arguments. Use keys to control the dashboa | `Ctrl-]` | Attach to the flagship | | `g` | Assign the selected task to a group (through the group picker) | | `R` | Rename the selected task: a display name shown in place of the command | -| `r` | Rerun a finished task; use the captured resume command for supported agent tasks | +| `r` | Rerun a finished task; a managed agent resumes its conversation | | `X` | Kill a running task (`TERM`, then `KILL` after 2 s), or remove a finished one | | `w` | Save the current tasks as a session | | `o` | Load a saved session or a recovery snapshot (through the [session picker](#the-o-session-picker)) | @@ -111,7 +111,7 @@ Press uppercase `X` to kill a running task or remove a finished one. On removal, Press `r` to rerun a finished task. Running tasks cannot be rerun without first being killed and are left untouched. -`fleetcom` starts the replacement in the same directory, using the requesting client's environment and the stored command. For a supported `claude`, `codex`, `grok`, or `omp` task with a valid captured conversation ID, it uses the resume command instead. +`fleetcom` starts the replacement in the same directory, using the requesting client's environment. A literal task reruns its exact text. A managed agent is launched again from whatever `PATH` finds now and resumes its captured conversation when an ID is known; see [agent session resume](agent-resume.md). On rerun, the task's ID, `◆` tag, group, name, and spawn order are preserved; its clock and screen are reset. Under lifecycle sorting, the restarted task may be listed in another section. You can also rerun inside peek without closing the overlay. diff --git a/docs/sessions.md b/docs/sessions.md index 35d8a3b..60a61bb 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -18,16 +18,21 @@ The filename is derived from the session name: leading and trailing whitespace i ## Format -A session file is a JSON object with three fields. `version` identifies the format version, currently 1. `name` holds the session name as typed, trimmed but not sanitized. `dirs` maps each working directory to an ordered list of entries. An entry with neither a group nor a name is a command string. An entry carrying either is an object with `cmd` plus the optional `group` and `name` fields: +A session file is a JSON object with three fields. `version` identifies the format version, currently 2. `name` holds the session name as typed, trimmed but not sanitized. `dirs` maps each working directory to an ordered list of entries. There are two kinds of entry, matching the two kinds of task: + +- A **literal** entry stores a typed command. Without a group or name it is a bare string; with either it is an object with `cmd` plus the optional `group` and `name` fields. On load the text runs exactly as stored, through your shell, whatever it names: a stored `"claude"` is a literal `claude`. +- A **managed** entry stores an agent launched from the spawn prompt's Agent page: an object with `agent`, the program word (`claude`, `codex`, `grok`, or `omp`), an optional `resume` holding the session ID to reopen, and the optional `group` and `name`. On load, `fleetcom` launches that agent again as a managed task and resumes the ID; without `resume`, the agent starts a fresh conversation. See [Agent session resume](agent-resume.md). ```json { - "version": 1, + "version": 2, "name": "work/api", "dirs": { "~/work/api": [ "cargo watch -x test", - { "cmd": "cargo run", "group": "api", "name": "api server" } + { "cmd": "cargo run", "group": "api", "name": "api server" }, + { "agent": "claude", "resume": "c8c4a5cc-0b32-4ba0-a6b4-6ed08c218e0d", "group": "agents" }, + { "agent": "omp" } ], "/tmp": [ "top" @@ -38,10 +43,10 @@ A session file is a JSON object with three fields. `version` identifies the form - The stored `name` distinguishes session names that sanitize to the same filename: `a/b` and `a.b` both become `a_b.json`. Before saving, `fleetcom` compares the stored and incoming names and refuses a mismatch, reporting both names in the error. The load picker displays the stored name as `a/b`, not `a_b`. - Keys under `dirs` are directory paths: each task's working directory. On save, directories under `$HOME` are written as `~/...`; other paths are kept absolute. On load, `~` is expanded to `$HOME`, and relative keys are resolved against the invocation directory of the client loading the session. -- Values are ordered lists. A string member is a bare shell command; in the object form, an optional group and display name can also be specified for assignment on load. Order is preserved, and each command is run in its own PTY under that directory. -- Directories are serialized alphabetically. Command order remains stable within each directory. +- Values are ordered lists. Order is preserved, and each entry starts its own PTY under that directory. +- Directories are serialized alphabetically. Entry order remains stable within each directory. -The `version` field must be an integer from 1 through the newest format supported by the running `fleetcom`. A missing field means version 1. Invalid or unsupported versions are refused on load, with the file's value and supported version in the error. +The `version` field must be an integer from 1 through the newest format supported by the running `fleetcom`. A missing field means version 1. Invalid or unsupported versions are refused on load, with the file's value and supported version in the error. A version 2 file read by an older `fleetcom` is refused by that same gate. The schema is identified by shape, independently of the version. With an object-valued `dirs`, the wrapped form above is used. A flat map is also accepted, with directories as top-level keys and entry arrays as values. In that form, an array-valued key named `dirs` remains a directory entry, but a top-level `version` member is always the format version, never a directory. Flat-map files are listed by filename stem because no name is stored. On save, the wrapped form is written, without a stored-name collision check. @@ -49,32 +54,29 @@ Saves are atomic: a private temporary file is written and synced in the session The file is plain JSON and practical to edit by hand. Edit `name` to change the stored session identity: a subsequent save under the old name will be refused by the collision check. A readable stored name is still used for collision checks and picker labels when the command body is invalid or the version unsupported. -The entire recipe is validated before any commands are started. The root must be an object, every directory value must be an array, and every entry must be a command string or an object with a string `cmd`. Strings, `null`, or omission are accepted for optional entry `group` and `name` fields and the wrapped session's `name`; other types are rejected. Unknown fields in wrapped metadata and entry objects are ignored. Empty maps, arrays, and strings are valid. With invalid JSON or any malformed field, the whole load is refused; existing tasks and the recipe file are left intact. In schema errors, the quoted directory key, entry number (starting at 1), and offending field are included where applicable. +The entire recipe is validated before any commands are started. The root must be an object, every directory value must be an array, and every entry must be a command string or an object with a string `cmd` or a string `agent`, never both. `agent` must be a supported program word, and `resume`, when present, must be a lowercase `8-4-4-4-12` UUID or `null`. Strings, `null`, or omission are accepted for optional entry `group` and `name` fields and the wrapped session's `name`; other types are rejected. Unknown fields in wrapped metadata and entry objects are ignored. Empty maps, arrays, and strings are valid. With invalid JSON or any malformed field, the whole load is refused; existing tasks and the recipe file are left intact. In schema errors, the quoted directory key, entry number (starting at 1), and offending field are included where applicable. -After validation, group and display names are stripped of control characters and surrounding whitespace and limited to 64 characters. `Unassigned` is treated as no group but is a legal display name. Missing directories and entries exceeding task or command limits are still skipped and counted in the load status. Spawn failures are reported separately; commands already started by a structurally valid recipe keep running. +After validation, group and display names are stripped of control characters and surrounding whitespace and limited to 64 characters. `Unassigned` is treated as no group but is a legal display name. Missing directories, entries past the task limit, and literal commands past the length limit are skipped and counted in the load status. A managed entry whose agent is not found on the connected client's `PATH` is reported on the status line and counted as a failed spawn; the other entries still load. Spawn failures are reported separately; tasks already started by a structurally valid recipe keep running. Use the string form for commands with neither a group nor a name. String and object entries can appear in the same directory array. -A bare agent command specifies no conversation ID, so saving it verbatim would start a new conversation on load. When `fleetcom` has captured an ID for `claude`, `codex`, `grok`, or `omp`, it stores the resume form instead. Without a known ID, it preserves the authored command in named saves and recovery snapshots. You can run the resulting command directly in a shell: +### Loading version 1 files -```json -{ - "version": 1, - "name": "agents", - "dirs": { - "~/work/api": [ - "claude --resume 'c8c4a5cc-0b32-4ba0-a6b4-6ed08c218e0d'", - { "cmd": "codex resume '019f5453-de22-7240-b2e5-0d32692aa6d9'", "name": "reviewer" } - ] - } -} -``` +Format 1 had no managed entry. A managed task was saved as the string `fleetcom` instrumented at the time: the bare program word, or the program word followed by its resume selector and a single-quoted UUID. When a file whose `version` is 1 or missing is loaded, each literal entry is checked against exactly those two shapes and converted: + +| Version 1 entry | Loads as | +| -- | -- | +| `claude`, `codex`, `grok`, or `omp` alone | managed, fresh conversation | +| `claude --resume ''`, `codex resume ''`, `grok --resume ''`, or `omp --resume ''`, with or without the quotes | managed, resuming that ID | +| anything else | literal, unchanged | + +Tabs and repeated spaces between the tokens are accepted, as they were. A path-qualified word (`/usr/local/bin/claude`, `~/bin/claude`) loads as literal: you chose that binary, and a managed launch would run whatever `PATH` finds first. It keeps its binary and loses resume. Extra arguments, prompts, other resume spellings, and shell syntax load as literal, which is what they always were. A version 2 file is never converted: its literal `claude` is literal by construction. Recovery snapshots are rewritten continually, so they convert within a session; a saved session converts the next time it is saved. The conversion is removed one release after format 2 ships; from then on, an unconverted version 1 file loads every entry as literal. ## Recovery The current task set is automatically snapshotted under `recovery/` inside the session directory. A separate file is written for each daemon (or `--foreground` core), named for its start time and process ID. After each write, the new file and files whose process IDs are still live are protected; among the remaining files, the nine newest names are retained. A shared recovery directory can therefore contain more than ten snapshots while multiple writers are live. -A snapshot pass is scheduled two seconds after the last command that can change a saved recipe, coalescing a burst of commands. A nonempty recipe is written when its content or destination changed, or when the expected snapshot file is missing. Stored-command changes, such as a newly captured agent resume ID, are also checked every 60 seconds. +A snapshot pass is scheduled two seconds after the last command that can change a saved recipe, coalescing a burst of commands. A nonempty recipe is written when its content or destination changed, or when the expected snapshot file is missing. Content changes that no command caused, such as a managed task's newly captured session ID, are also checked every 60 seconds. - No snapshot is written for an empty fleet. After removing every task, the previous snapshot is retained. - Snapshots are left in place after quitting, disconnecting, or `fleetcom --kill`. @@ -87,7 +89,7 @@ As with saved recipes, full command lines are persisted in recovery files, inclu ## Saving and loading -- Save: press `w` in the dashboard, type a name, and press `Enter`. This writes the session name plus each task's directory, command, and optional group and name to `.json`. +- Save: press `w` in the dashboard, type a name, and press `Enter`. This writes the session name plus each task's directory, its command or its agent and session ID, and optional group and name to `.json`. - Load in-app: `o`, pick from the list, `Enter`. - Load at launch: `fleetcom `. diff --git a/src/harness/claude.rs b/src/harness/claude.rs index 5b6b77f..b788edc 100644 --- a/src/harness/claude.rs +++ b/src/harness/claude.rs @@ -1,6 +1,6 @@ //! Claude session capture uses a launch-time `--session-id`, a `SessionStart` -//! hook, and the live session registry. Bare launches pin a v4 UUID; accepted -//! launches install the hook through `--settings`. +//! hook, and the live session registry. A fresh launch pins a v4 UUID; every +//! launch installs the hook through `--settings`. //! Live lookup reads `/sessions/.json`. //! //! With agent view enabled, a conversation can be moved into Claude's own @@ -61,11 +61,11 @@ impl Harness for Claude { /// cannot be determined from the JSON: `source: "fork"` is reported for /// both `/branch` in the task's process and a background fork. /// - /// The task leader is the Claude process only after an exec from - /// `$SHELL -c`, as required for [`record_for_pid`]. With a shell retained - /// as task leader, the stamped PID differs: reject the capture and fall - /// back to the registry, then the spawn-time ID. Without a task PID, - /// ownership cannot be checked. + /// A managed launch runs the Claude binary directly, so the task leader + /// is the Claude process, as [`record_for_pid`] also requires. A stamp + /// from any other PID (a background fork, a wrapper that stayed resident) + /// is rejected: fall back to the registry, then the spawn-time ID. + /// Without a task PID, ownership cannot be checked. fn parse_capture( &self, payload: &str, @@ -179,8 +179,8 @@ mod tests { use crate::{ harness::{ Intent, - fixtures::{ID, OTHER, argv, assert_all_opaque, paths}, - plan, shell_words, + fixtures::{ID, OTHER, argv, paths}, + plan, }, testutil::temp, }; @@ -222,29 +222,6 @@ mod tests { std::time::UNIX_EPOCH + std::time::Duration::from_millis(ms) } - /// Claude-specific opaque shapes: flags, `--continue`/`-c`, subcommands, - /// the short/`=` resume spellings, and `--session-id`. The syntax shared - /// by every harness is covered by the table test in `harness::tests`. - #[test] - fn everything_else_is_opaque_and_never_rewritten() { - let opaque: Vec = [ - "claude --model opus", - "claude --continue", - "claude -c", - "claude mcp list", - "claudius", - ] - .iter() - .map(|s| s.to_string()) - .chain([ - format!("claude -r {ID}"), - format!("claude --resume {ID} --model opus"), - format!("claude --session-id {ID}"), - ]) - .collect(); - assert_all_opaque(&Claude, ID, &opaque); - } - /// Managed argv: a fresh launch pins the minted ID, a resume names its /// conversation, and the settings overlay follows either. Both carry the /// capture file in the environment and report the ID they target. @@ -279,30 +256,6 @@ mod tests { assert_eq!(unpinned.resume_id, None); } - /// The literal suffix for a bare `claude`: the fresh intent part, then - /// the overlay, as shell text. - #[test] - fn bare_literal_suffix_pins_an_id_and_layers_settings() { - let fresh = plan(&Claude, &Intent::Fresh, Some(ID), &paths(), None); - assert_eq!( - shell_words(&fresh.args), - format!(" --session-id '{ID}' --settings '/tmp/Application Support/fleetcom.json'") - ); - } - - /// The typed resume form already targets a conversation, so its suffix - /// is the settings overlay alone. - #[test] - fn resume_literal_suffix_adds_only_settings() { - let overlay = Claude.overlay(&paths(), None); - assert_eq!(overlay.resume_id, None); - assert_eq!( - shell_words(&overlay.args), - " --settings '/tmp/Application Support/fleetcom.json'" - ); - assert_eq!(overlay.env.len(), 1, "env still names the capture file"); - } - /// Task leader PID used by the capture-gate cases. const OWNER: u32 = 4242; diff --git a/src/harness/codex.rs b/src/harness/codex.rs index db99316..84678eb 100644 --- a/src/harness/codex.rs +++ b/src/harness/codex.rs @@ -429,14 +429,14 @@ mod tests { use crate::{ harness::{ Intent, - fixtures::{OTHER, argv, assert_all_opaque, paths}, - plan, shell_words, + fixtures::{OTHER, argv, paths}, + plan, }, testutil::{Scratch, codex_session_meta, install_codex_rollout, install_codex_root, temp}, }; /// Codex's own launch and resume commands carry v7 IDs; the shared v4 - /// fixture stays valid for detection, which is version-agnostic. + /// fixture stays valid because `is_uuid` is version-agnostic. const ID: &str = "019f5453-de22-7240-b2e5-0d32692aa6d9"; /// A sub-agent thread and a second-level sub-agent thread in the session /// rooted at [`ID`]. @@ -445,13 +445,11 @@ mod tests { /// Hidden title thread ID, reported through notify but never saved in a rollout. const TITLE: &str = "019f5453-de9f-7e61-8c0d-1a2b3c4d5e6f"; - /// Suffix of every fully instrumented launch that uses [`paths`]. - const SUFFIX: &str = concat!( - r#" -c 'notify=["/tmp/Application Support/notify.sh"]'"#, - " -c 'features.daemon_auto_start=false'", - ); - /// Suffix of a launch whose capture is off: the embedded override alone. - const EMBEDDED_ONLY: &str = " -c 'features.daemon_auto_start=false'"; + /// Overlay argv of every fully instrumented launch that uses [`paths`]: + /// the notifier, then explicit embedded mode. + fn full_args() -> Vec { + argv(&["-c", NOTIFY, "-c", EMBEDDED_OVERRIDE]) + } /// Environment of a fully instrumented launch that uses [`paths`] with /// `chain` as the encoded notifier argv. @@ -482,37 +480,6 @@ mod tests { } } - /// Codex-specific opaque shapes: subcommands (including `exec` and - /// single-letter aliases), flags, `-c` overrides, `--resume` (the wrong - /// selector), and out-of-position or named resume forms. The syntax - /// shared by every harness is covered by the table test in - /// `harness::tests`. - #[test] - fn everything_else_is_opaque_and_never_rewritten() { - let opaque: Vec = [ - "codex resume my-thread", - "codex resume --last", - "codex -m gpt-5", - "codex -m gpt-5 'do x'", - "codex e 'x'", - "codex exec 'x'", - "codex a", - "codex -p team", - r#"codex -c 'notify=["/my/hook"]'"#, - "codex -\u{e9}x", - "codexx", - ] - .iter() - .map(|s| s.to_string()) - .chain([ - format!("codex resume {ID} -m gpt-5"), - format!("codex -m gpt-5 resume {ID}"), - format!("codex --resume {ID}"), - ]) - .collect(); - assert_all_opaque(&Codex, ID, &opaque); - } - /// Scratch home without a `config.toml`. fn no_config_home() -> Scratch { temp("codex_no_config_home") @@ -527,7 +494,7 @@ mod tests { fn managed_argv_installs_the_notify_and_embedded_overrides() { let home = no_config_home(); let fresh = plan(&Codex, &Intent::Fresh, Some(ID), &paths(), Some(&home)); - assert_eq!(fresh.args, argv(&["-c", NOTIFY, "-c", EMBEDDED_OVERRIDE])); + assert_eq!(fresh.args, full_args()); assert_eq!(fresh.resume_id, None, "the minted id is ignored"); assert_eq!(fresh.notice, None); assert_eq!(fresh.env, full_env("")); @@ -547,25 +514,6 @@ mod tests { assert_eq!(resume.env, full_env("")); } - /// Both literal shapes get the same suffix: the notifier, then explicit - /// embedded mode. - #[test] - fn literal_suffix_installs_the_notify_and_embedded_overrides() { - let overlay = Codex.overlay(&paths(), Some(&no_config_home())); - assert_eq!(shell_words(&overlay.args), SUFFIX); - assert_eq!(overlay.resume_id, None); - // The bare word's fresh intent part is empty, so its suffix is the - // overlay's. - let fresh = plan( - &Codex, - &Intent::Fresh, - Some(ID), - &paths(), - Some(&no_config_home()), - ); - assert_eq!(shell_words(&fresh.args), SUFFIX); - } - /// Without a usable binary, the script would have nothing to run, so the /// launch carries the embedded override alone and says why. The route /// is still read first: an opaque route reports its own reason. A @@ -581,10 +529,6 @@ mod tests { Codex.overlay(&paths, Some(&no_config_home())), embedded_only(why) ); - assert_eq!( - shell_words(&Codex.overlay(&paths, Some(&no_config_home())).args), - EMBEDDED_ONLY - ); let resume = plan( &Codex, &Intent::Resume(ID.into()), @@ -622,7 +566,7 @@ mod tests { // Missing config file: plain injection, and the chain is present but // empty. let plan = Codex.overlay(&paths(), Some(&home)); - assert_eq!(shell_words(&plan.args), SUFFIX); + assert_eq!(plan.args, full_args()); assert_eq!(chained(&plan), Some("".into())); let cfg = home.join("config.toml"); @@ -634,7 +578,7 @@ mod tests { ] { fs::write(&cfg, active).unwrap(); let plan = Codex.overlay(&paths(), Some(&home)); - assert_eq!(shell_words(&plan.args), SUFFIX, "{active:?}"); + assert_eq!(plan.args, full_args(), "{active:?}"); assert_eq!(plan.env, full_env("/my/thing"), "{active:?}"); assert_eq!(plan.notice, None, "{active:?}"); } @@ -646,7 +590,7 @@ mod tests { ] { fs::write(&cfg, inert).unwrap(); let plan = Codex.overlay(&paths(), Some(&home)); - assert_eq!(shell_words(&plan.args), SUFFIX, "{inert:?}"); + assert_eq!(plan.args, full_args(), "{inert:?}"); assert_eq!(chained(&plan), Some("".into()), "{inert:?}"); } } @@ -661,7 +605,7 @@ mod tests { ) .unwrap(); let plan = Codex.overlay(&paths(), Some(&home)); - assert_eq!(shell_words(&plan.args), SUFFIX); + assert_eq!(plan.args, full_args()); assert!(plan.env.contains(&( NOTIFY_CHAIN_ENV.into(), "/Applications/Codex Computer Use.app/Contents/MacOS/SkyComputerUseClient\nturn-ended" @@ -707,23 +651,21 @@ mod tests { r#"/with space/and\"quote\\slash"# ); assert_eq!(toml_escape("a\tb"), "a\\u0009b"); - // Execute the suffix through a shell and inspect the resulting words. + // The override is one argv element: the escaping is TOML's alone, with + // no shell layer to double it. let paths = CapturePaths { codex_notify: PathBuf::from(r#"/Odd Path/it's "here"\now"#), ..paths() }; let plan = Codex.overlay(&paths, Some(&no_config_home())); - let out = std::process::Command::new("sh") - .arg("-c") - .arg(format!("printf '%s\\n'{}", shell_words(&plan.args))) - .output() - .expect("sh must run"); assert_eq!( - String::from_utf8(out.stdout).unwrap(), - format!( - "-c\n{}\n-c\nfeatures.daemon_auto_start=false\n", - r#"notify=["/Odd Path/it's \"here\"\\now"]"# - ) + plan.args, + argv(&[ + "-c", + r#"notify=["/Odd Path/it's \"here\"\\now"]"#, + "-c", + EMBEDDED_OVERRIDE + ]) ); } diff --git a/src/harness/grok.rs b/src/harness/grok.rs index 13f9bfa..daa3648 100644 --- a/src/harness/grok.rs +++ b/src/harness/grok.rs @@ -1,5 +1,5 @@ -//! Grok has no injectable live-capture channel. Bare launches instead pin a v4 -//! UUID; canonical resume commands retain their explicit ID. +//! Grok has no injectable live-capture channel. A fresh launch instead pins a +//! v4 UUID, and a resume names it. use std::path::Path; @@ -27,34 +27,10 @@ mod tests { use super::*; use crate::harness::{ Intent, - fixtures::{ID, OTHER, argv, assert_all_opaque, paths}, - plan, shell_words, + fixtures::{ID, OTHER, argv, paths}, + plan, }; - /// Grok-specific opaque shapes: flags, the `-r`/`-s`/`=` spellings the - /// tool prints but detection refuses, and subcommands. The syntax shared - /// by every harness is covered by the table test in `harness::tests`. - #[test] - fn everything_else_is_opaque_and_never_rewritten() { - let opaque: Vec = [ - "grok --model grok-4", - "grok --continue", - "grok -r", - "grok -r my-session", - "grok sessions list", - "grokk", - ] - .iter() - .map(|s| s.to_string()) - .chain([ - format!("grok -r {ID}"), - format!("grok -s {ID}"), - format!("grok --resume {ID} --debug"), - ]) - .collect(); - assert_all_opaque(&Grok, ID, &opaque); - } - /// Managed argv is the intent part alone: Grok exposes no live capture /// channel, so nothing rides along either way. #[test] @@ -70,16 +46,9 @@ mod tests { assert!(resume.env.is_empty()); } - /// A bare literal `grok` gains only the pinned ID. - #[test] - fn bare_literal_suffix_pins_an_id_and_nothing_else() { - let fresh = plan(&Grok, &Intent::Fresh, Some(ID), &paths(), None); - assert_eq!(shell_words(&fresh.args), format!(" --session-id '{ID}'")); - } - - /// The typed resume form needs no pin, overlay, or environment change. + /// The overlay is empty: no flag, no environment, no notice. #[test] - fn overlay_leaves_the_resume_form_untouched() { + fn overlay_is_empty() { assert_eq!(Grok.overlay(&paths(), None), SpawnPlan::default()); } diff --git a/src/harness/mod.rs b/src/harness/mod.rs index 464699e..3683839 100644 --- a/src/harness/mod.rs +++ b/src/harness/mod.rs @@ -1,23 +1,20 @@ -//! A saved agent command is incomplete without its conversation ID. Relaunching -//! the command can otherwise start a new conversation. Each harness captures a +//! A saved agent is incomplete without its conversation ID. Relaunching the +//! agent can otherwise start a new conversation. Each harness captures a //! validated ID and builds the argv that opens or resumes a conversation. //! //! A managed launch is built here from an [`Intent`]: the binary found on //! `PATH`, the conversation selection, then the harness's overlay (capture -//! hook, config overrides, environment). No shell parses that argv. -//! -//! A literal command still goes through detection in this release: a bare -//! program word or its canonical resume form (program word, fixed selector, -//! one strict UUID, end of line) gets the same argv elements appended as -//! shell text. Everything else remains opaque and runs and saves verbatim. +//! hook, config overrides, environment). No shell parses that argv. A literal +//! command never comes here: it runs and saves verbatim, whatever it names, +//! and only the display-only summary adapter ([`select`]) reads its text. //! //! # Security invariant //! -//! Every ID returned by `parse_capture` or `live_session_id` eventually enters a -//! shell command (a literal task's resume form) or an agent's argv (a managed -//! task's). These methods return only strings accepted by [`is_uuid`]; return -//! `None` for free text, paths, and malformed IDs. Summary adapters and -//! `live_blocked_status` are display-only. +//! Every ID returned by `parse_capture` or `live_session_id` enters an agent's +//! argv as one element and a session file as its `resume` field. These +//! methods return only strings accepted by [`is_uuid`]; return `None` for free +//! text, paths, and malformed IDs. Summary adapters and `live_blocked_status` +//! are display-only. pub mod assets; mod claude; @@ -54,7 +51,7 @@ pub const NOTIFY_CHAIN_ENV: &str = "FLEETCOM_NOTIFY_CHAIN"; /// absent when that path is unusable, so the script skips the call. pub const BINARY_ENV: &str = "FLEETCOM_BINARY"; -/// Detection, capture, and resume behavior for one agent CLI. +/// Launch, capture, and resume behavior for one agent CLI. pub trait Harness: Sync { /// Resolve configuration needed by instrumentation, capture parsing, or /// the live registry from the launch environment. Return `None` when no @@ -63,17 +60,12 @@ pub trait Harness: Sync { None } - /// Program word and canonical resume selector. The default detection and - /// resume rewriting derive from this pair. + /// Program word and resume selector. The word is how the registry, the + /// launcher, and session files name the tool; the selector is the flag or + /// subcommand that takes a conversation ID, which [`intent_args`] places + /// first in a resuming argv. fn shape(&self) -> (&'static str, &'static str); - /// Classify a command. Return `None` for another tool or an unsupported - /// command shape. - fn detect(&self, cmd: &str) -> Option { - let (program, selector) = self.shape(); - detect_shape(cmd, program, selector) - } - /// The flag that pins a session ID on a fresh launch, for tools that /// accept one. `None` means a fresh conversation gets its ID from the /// tool, and only capture or the registry can report it. @@ -128,13 +120,6 @@ pub trait Harness: Sync { ) -> Option<(String, &'static str)> { None } - - /// Rewrite an accepted `cmd` into the canonical command that resumes - /// `id`. - fn resume_command(&self, cmd: &str, id: &str) -> String { - let (program, selector) = self.shape(); - resume_shape(cmd, program, selector, id) - } } /// Resolve a tool-specific override before the launch environment's home. @@ -160,7 +145,7 @@ struct Agent { summary: &'static dyn crate::preview::SummaryAdapter, } -/// Registered CLIs in detection order. +/// Registered CLIs in registry order: the order the launcher lists them. static AGENTS: &[Agent] = &[ Agent { harness: &Claude, @@ -180,15 +165,8 @@ static AGENTS: &[Agent] = &[ }, ]; -/// Return the first harness that recognizes `cmd`. -pub fn detect(cmd: &str) -> Option<(&'static dyn Harness, Invocation)> { - AGENTS - .iter() - .find_map(|a| a.harness.detect(cmd).map(|inv| (a.harness, inv))) -} - /// The harness registered under exactly `program`: the word a managed -/// launch names, never a path or a basename match. +/// launch or a session entry names, never a path or a basename match. pub fn registered(program: &str) -> Option<&'static dyn Harness> { AGENTS .iter() @@ -232,8 +210,9 @@ fn executable_file(path: &Path) -> bool { /// Select a summary adapter by the basename of the command's first /// whitespace-separated word. Arguments are accepted; do not select an adapter -/// for environment prefixes or compound shell commands. Selection is -/// independent of session-capture instrumentation. +/// for environment prefixes or compound shell commands. Selection is display +/// only and applies to literal and managed tasks alike: it never grants a +/// literal task a harness channel. pub fn select(command: &str) -> Option<&'static dyn crate::preview::SummaryAdapter> { let first = command.split_whitespace().next()?; let name = Path::new(first).file_name()?.to_str()?; @@ -243,16 +222,6 @@ pub fn select(command: &str) -> Option<&'static dyn crate::preview::SummaryAdapt .map(|a| a.summary) } -/// Classification of an accepted agent-CLI command. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Invocation { - /// The bare program word: the launch takes the `Fresh` intent part. - Bare, - /// The canonical resume form: the command already carries the selector - /// and this ID, so the launch takes the overlay alone. - Resume(String), -} - /// Which conversation a managed launch opens. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Intent { @@ -260,7 +229,7 @@ pub enum Intent { /// ID minted by the supervisor. Fresh, /// The conversation with this ID, which passed [`is_uuid`] at its - /// source: a capture, a registry record, or a detected command. + /// source: a capture, a registry record, or a session file. Resume(String), } @@ -310,30 +279,6 @@ pub fn plan( } } -/// Shell text for `args`, appended to a detected literal command before it -/// is passed to `$SHELL -c`. Every element gets a leading space. Values are -/// single-quoted. A bare flag (`-` followed by word characters, which no -/// POSIX shell expands) stays unquoted, so the exec string is byte-identical -/// to what instrumentation wrote before managed launches existed; the shell -/// builds the same argv either way. Transitional: the literal path loses -/// instrumentation once managed launches carry it alone. -pub fn shell_words(args: &[OsString]) -> String { - args.iter() - .map(|a| { - let word = a.to_string_lossy(); - let bare_flag = word.starts_with('-') - && word - .bytes() - .all(|b| b == b'-' || b == b'_' || b.is_ascii_alphanumeric()); - if bare_flag { - format!(" {word}") - } else { - format!(" {}", shell_quote(&word)) - } - }) - .collect() -} - /// Capture paths allocated by [`assets::CaptureAssets::paths_for`]. #[derive(Debug, Clone)] pub struct CapturePaths { @@ -353,9 +298,8 @@ pub struct CapturePaths { pub fleetcom_binary: Option, } -/// Spawn-time additions for one launch: argv elements, never shell text. A -/// managed launch passes `args` to the binary directly; a detected literal -/// command gets them appended through [`shell_words`]. +/// Spawn-time additions for one managed launch: argv elements passed to the +/// binary directly, never shell text. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct SpawnPlan { /// Argv elements after the binary. @@ -370,57 +314,8 @@ pub struct SpawnPlan { pub notice: Option, } -/// Shell metacharacters that make the program token unsafe to instrument. -/// `=` can turn it into an environment assignment, `*?[]` and `{}` can expand -/// it into different words, and the remaining characters can separate, quote, -/// expand, or comment out shell input. Tilde remains valid because it expands -/// to one word with the same basename. -const PROGRAM_WORD_REFUSALS: &[char] = &[ - '|', ';', '&', '<', '>', '$', '#', '`', '(', ')', '\\', '\'', '"', '=', '\n', '\r', '*', '?', - '[', ']', '{', '}', -]; - -/// Match `cmd` against a bare `program` or its canonical resume form. The -/// program matches by basename, and the strict UUID may be bare or wrapped in -/// the single quote pair emitted by `resume_command`. Extra arguments, prompts, -/// alternate selectors, and shell syntax do not match. -fn detect_shape(cmd: &str, program: &str, selector: &str) -> Option { - let mut words = cmd.split([' ', '\t']).filter(|w| !w.is_empty()); - let first = words.next()?; - if first.contains(PROGRAM_WORD_REFUSALS) || Path::new(first).file_name()?.to_str()? != program { - return None; - } - let Some(sel) = words.next() else { - return Some(Invocation::Bare); - }; - let id = unquote(words.next()?); - (sel == selector && is_uuid(id) && words.next().is_none()) - .then(|| Invocation::Resume(id.to_string())) -} - -/// Strip the optional single quote pair emitted by `resume_command`. -fn unquote(token: &str) -> &str { - token - .strip_prefix('\'') - .and_then(|t| t.strip_suffix('\'')) - .unwrap_or(token) -} - -/// Rewrite an accepted command as -/// ` ''`. Invalid IDs and unsupported -/// command shapes pass through unchanged. -fn resume_shape(cmd: &str, program: &str, selector: &str, id: &str) -> String { - if !is_uuid(id) || detect_shape(cmd, program, selector).is_none() { - return cmd.to_string(); - } - let first = cmd - .split([' ', '\t']) - .find(|w| !w.is_empty()) - .expect("detect_shape accepted a program word"); - format!("{first} {selector} {}", shell_quote(id)) -} - -/// Validate the shell-insertion boundary: exactly `8-4-4-4-12` lowercase hex. +/// Validate a session ID at the argv and session-file boundary: exactly +/// `8-4-4-4-12` lowercase hex. pub fn is_uuid(s: &str) -> bool { let b = s.as_bytes(); b.len() == 36 @@ -431,7 +326,7 @@ pub fn is_uuid(s: &str) -> bool { } /// Validated session ID at `key` in a capture payload or a Codex rollout -/// header; [`is_uuid`] is the shell-insertion boundary. +/// header; [`is_uuid`] is the boundary. fn capture_id(v: &jzon::JsonValue, key: &str) -> Option { let id = v[key].as_str()?; is_uuid(id).then(|| id.to_string()) @@ -458,17 +353,12 @@ pub(crate) fn uuid_v4() -> Option { Some(out) } -/// Single-quote `s` for `$SHELL -c`, encoding embedded `'` as `'\''`. -fn shell_quote(s: &str) -> String { - format!("'{}'", s.replace('\'', "'\\''")) -} - -/// Fixtures and assertions for harness detection and capture. +/// Fixtures for harness launch and capture tests. #[cfg(test)] pub(crate) mod fixtures { use std::{ffi::OsString, path::PathBuf}; - use super::{CapturePaths, Harness}; + use super::CapturePaths; /// Strict v4 UUID used wherever a valid session ID is needed. pub(crate) const ID: &str = "c8c4a5cc-0b32-4ba0-a6b4-6ed08c218e0d"; @@ -491,16 +381,6 @@ pub(crate) mod fixtures { fleetcom_binary: Some(PathBuf::from("/tmp/Application Support/fleetcom")), } } - - /// Assert that every command is opaque to `h`: detection fails and resume - /// leaves the command unchanged. - pub(super) fn assert_all_opaque(h: &dyn Harness, id: &str, cmds: &[String]) { - for cmd in cmds { - assert_eq!(h.detect(cmd), None, "{cmd:?} must be opaque"); - let resumed = h.resume_command(cmd, id); - assert_eq!(resumed, *cmd, "an opaque command must never be rewritten"); - } - } } #[cfg(test)] @@ -510,99 +390,9 @@ mod tests { *, }; - /// Path prefix used to verify basename matching. + /// Path prefix used to verify that registration never matches by basename. const BIN: &str = "/usr/local/bin"; - /// Each registered harness accepts bare and canonical resume forms, - /// including path-qualified programs and quoted IDs. - #[test] - fn every_harness_detects_the_two_authored_shapes() { - for a in AGENTS { - let h = a.harness; - let (prog, sel) = h.shape(); - assert_eq!(h.detect(prog), Some(Invocation::Bare), "{prog}"); - assert_eq!( - h.detect(&format!("{BIN}/{prog}")), - Some(Invocation::Bare), - "{prog}" - ); - for cmd in [ - format!("{prog} {sel} {ID}"), - format!("{prog} {sel} '{ID}'"), - format!("{BIN}/{prog} {sel} '{ID}'"), - ] { - assert_eq!(h.detect(&cmd), Some(Invocation::Resume(ID.into())), "{cmd}"); - } - } - } - - /// Regenerate the canonical resume form for both accepted shapes, preserving the - /// program word as typed. Keep the command unchanged for invalid IDs. - #[test] - fn every_harness_regenerates_the_canonical_resume_form() { - for a in AGENTS { - let h = a.harness; - let (prog, sel) = h.shape(); - let canonical = format!("{prog} {sel} '{ID}'"); - assert_eq!(h.resume_command(prog, ID), canonical, "{prog}"); - assert_eq!( - h.resume_command(&format!("{BIN}/{prog}"), ID), - format!("{BIN}/{prog} {sel} '{ID}'") - ); - assert_eq!( - h.resume_command(&format!("{prog} {sel} '{OTHER}'"), ID), - canonical - ); - assert_eq!( - h.resume_command(&format!("{prog} {sel} {OTHER}"), ID), - canonical - ); - for bad in ["evil'", "not-an-id"] { - assert_eq!(h.resume_command(prog, bad), prog, "{prog} {bad:?}"); - } - } - } - - /// Shared shell-syntax shapes are opaque for every harness and are never - /// rewritten: prompts, a bare or malformed selector, `=`-joined IDs, - /// quoted-ID-plus-prompt, token-extending IDs, pipes, separators, env - /// prefixes, other tools, and the empty command. Harness-specific - /// opacity cases stay in each harness's own test module. - #[test] - fn every_harness_keeps_shared_shell_syntax_opaque() { - for a in AGENTS { - let h = a.harness; - let (prog, sel) = h.shape(); - let opaque = [ - format!("{prog} 'fix the tests'"), - format!("{prog} {sel}"), - format!("{prog} {sel} not-a-uuid"), - format!("{prog} {sel} $ID"), - format!("{prog} {sel}={ID}"), - format!("{prog} {sel} '{ID}' 'and do x'"), - format!("{prog} {sel} {ID}ff"), - format!("{prog} | tee log"), - format!("{prog}; ls"), - format!("FOO=bar {prog}"), - String::new(), - ]; - for cmd in opaque { - assert_eq!(h.detect(&cmd), None, "{cmd:?} must be opaque"); - assert_eq!( - h.resume_command(&cmd, ID), - cmd, - "an opaque command must never be rewritten" - ); - } - // Another tool's program word never matches. - for other in AGENTS.iter().map(|o| o.harness.shape().0) { - if other != prog { - assert_eq!(h.detect(other), None, "{other:?} is not {prog}"); - } - } - } - } - #[test] fn is_uuid_accepts_only_the_strict_shape() { assert!(is_uuid(ID)); @@ -632,79 +422,6 @@ mod tests { assert_ne!(a, b); } - /// The UUID token may be bare or in exactly one single-quote pair; - /// anything half-quoted or nested fails the strict check. - #[test] - fn detect_shape_strips_exactly_one_quote_pair() { - assert_eq!( - detect_shape(&format!("claude --resume '{ID}'"), "claude", "--resume"), - Some(Invocation::Resume(ID.into())) - ); - for token in [format!("'{ID}"), format!("{ID}'"), format!("''{ID}''")] { - assert_eq!( - detect_shape(&format!("claude --resume {token}"), "claude", "--resume"), - None, - "{token:?}" - ); - } - } - - /// A path-form program word matches by basename only while it stays one - /// plain shell word. - #[test] - fn detect_shape_matches_basenames_and_refuses_shell_syntax_in_them() { - assert_eq!( - detect_shape("/usr/local/bin/claude", "claude", "--resume"), - Some(Invocation::Bare) - ); - for cmd in [ - "$HOME/bin/claude", - "a=b/claude", - "'/bin/claude'", - "/tmp/x;y/claude", - "/tmp/x`y`/claude", - "claude\nls", - ] { - assert_eq!(detect_shape(cmd, "claude", "--resume"), None, "{cmd:?}"); - } - } - - /// Glob and brace metacharacters can expand the program word into - /// several words or a different path, losing flag binding; tilde expands - /// to one word with the same basename, so it stays accepted. - #[test] - fn detect_shape_refuses_expanding_metacharacters_but_accepts_tilde() { - assert_eq!( - detect_shape("~/bin/claude", "claude", "--resume"), - Some(Invocation::Bare) - ); - for (cmd, prog, sel) in [ - ("tools/*/claude", "claude", "--resume"), - ("/opt/{stable,beta}/codex", "codex", "resume"), - ("a?b/claude", "claude", "--resume"), - ("[a]/grok", "grok", "--resume"), - ] { - assert_eq!(detect_shape(cmd, prog, sel), None, "{cmd:?}"); - } - } - - /// Shell quoting preserves spaces and embedded single quotes. - #[test] - fn shell_quote_survives_spaces_and_single_quotes() { - let path = "/Users/x/Application Support/it's here/settings.json"; - let quoted = shell_quote(path); - assert_eq!( - quoted, - "'/Users/x/Application Support/it'\\''s here/settings.json'" - ); - let out = std::process::Command::new("sh") - .arg("-c") - .arg(format!("printf '%s' {quoted}")) - .output() - .expect("sh must run"); - assert_eq!(String::from_utf8(out.stdout).unwrap(), path); - } - /// The pin goes only where a session flag exists; a resume always names /// the ID after the tool's selector; the reported ID follows the intent. #[test] @@ -733,48 +450,6 @@ mod tests { } } - /// Values are quoted; bare flags are not; the shell rebuilds the same - /// argv from the text. - #[test] - fn shell_words_quotes_values_and_leaves_bare_flags() { - let args: Vec = [ - "--session-id", - ID, - "-c", - "notify=[\"/App Support/n.sh\"]", - "-e", - "it's", - "resume", - "--x_y", - "-", - "", - "--not$bare", - ] - .iter() - .map(OsString::from) - .collect(); - let text = shell_words(&args); - assert_eq!( - text, - format!( - " --session-id '{ID}' -c 'notify=[\"/App Support/n.sh\"]' -e 'it'\\''s' \ - 'resume' --x_y - '' '--not$bare'" - ) - ); - assert_eq!(shell_words(&[]), ""); - let out = std::process::Command::new("sh") - .arg("-c") - .arg(format!("printf '%s\\n'{text}")) - .output() - .expect("sh must run"); - let words: Vec<&str> = std::str::from_utf8(&out.stdout) - .unwrap() - .split_terminator('\n') - .collect(); - let expected: Vec<&str> = args.iter().map(|a| a.to_str().unwrap()).collect(); - assert_eq!(words, expected, "the shell must rebuild every element"); - } - /// Only the exact program word is a registered agent: no paths, no /// prefixes, no basename matching. #[test] @@ -870,17 +545,4 @@ mod tests { assert_eq!(installed(&join(&[&b])), ["claude"]); assert_eq!(installed(OsStr::new("")), Vec::<&str>::new()); } - - #[test] - fn registry_detect_routes_to_the_matching_harness() { - for a in AGENTS { - let (prog, sel) = a.harness.shape(); - let (h, inv) = detect(prog).unwrap(); - assert_eq!((h.shape().0, inv), (prog, Invocation::Bare)); - let (h, inv) = detect(&format!("{prog} {sel} {ID}")).unwrap(); - assert_eq!((h.shape().0, inv), (prog, Invocation::Resume(ID.into()))); - } - assert!(detect("vim").is_none()); - assert!(detect("").is_none()); - } } diff --git a/src/harness/omp.rs b/src/harness/omp.rs index 9ec9239..c748b0b 100644 --- a/src/harness/omp.rs +++ b/src/harness/omp.rs @@ -12,9 +12,6 @@ //! //! omp's IDs are UUIDv7. [`is_uuid`](super::is_uuid) validates the 8-4-4-4-12 //! lowercase-hex shape and not the version field, so they pass unchanged. -//! -//! `-r`, `--session`, and `-c` resume as well, but detection stays on the -//! canonical pair: a command fleetcom cannot rewrite exactly is left verbatim. use std::path::Path; @@ -60,8 +57,8 @@ mod tests { use super::*; use crate::harness::{ Intent, - fixtures::{ID, argv, assert_all_opaque, paths}, - plan, shell_words, + fixtures::{ID, argv, paths}, + plan, }; /// Valid UUIDv7 used in capture payloads. @@ -70,23 +67,6 @@ mod tests { /// The extension path from [`paths`]. const EXTENSION: &str = "/tmp/Application Support/omp-capture.js"; - /// omp-specific aliases, shortcuts, prompts, and malformed resume forms - /// remain opaque. - #[test] - fn everything_else_is_opaque_and_never_rewritten() { - let opaque: Vec = ["omp -c", "omp --continue", "omp --resume", "ompx"] - .iter() - .map(|s| s.to_string()) - .chain([ - format!("omp -r {ID}"), - format!("omp --session {ID}"), - format!("omp --resume {}", &ID[..8]), - "omp -p 'fix the tests'".to_string(), - ]) - .collect(); - assert_all_opaque(&Omp, ID, &opaque); - } - /// Managed argv: a fresh launch loads the extension alone, since omp has /// no `--session-id` and a minted ID would be unresumable; a resume names /// its conversation first. Both name the capture file in the environment. @@ -109,22 +89,6 @@ mod tests { assert_eq!(resume.env, fresh.env); } - /// Both literal shapes get the extension as one quoted word (the - /// fixture path has a space) and no pin. - #[test] - fn literal_suffix_loads_the_extension_for_either_accepted_shape() { - let overlay = Omp.overlay(&paths(), None); - assert_eq!(overlay.resume_id, None); - assert_eq!( - shell_words(&overlay.args), - " -e '/tmp/Application Support/omp-capture.js'" - ); - // The bare word's fresh intent part is empty, so its suffix is the - // overlay's. - let fresh = plan(&Omp, &Intent::Fresh, Some(ID), &paths(), None); - assert_eq!(fresh.args, overlay.args); - } - /// Extract only a validated ID from the extension payload. #[test] fn parse_capture_returns_only_strict_ids() { diff --git a/src/harness/summary_tests.rs b/src/harness/summary_tests.rs index 37cf0e2..efc8bad 100644 --- a/src/harness/summary_tests.rs +++ b/src/harness/summary_tests.rs @@ -62,8 +62,8 @@ fn titled(text: &str) -> (String, PreviewSource, Option<&'static str>) { (text.to_string(), PreviewSource::Title, None) } -/// Selection is a basename match on the first word only: wider than -/// harness detection (arguments are tolerated), but env prefixes and +/// Selection is a basename match on the first word only: arguments and a +/// path are tolerated, since this is display only, but env prefixes and /// shell syntax glued to the word select nothing. #[test] fn select_matches_first_word_basenames_only() { diff --git a/src/protocol.rs b/src/protocol.rs index 74277e7..9261549 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -69,9 +69,9 @@ pub enum Command { Kill { id: u64 }, /// Drop a task from the set entirely (used on already-finished tasks). Remove { id: u64 }, - /// Re-run a finished task with the same id, cwd, tag, group, and name. Use the - /// resume form when an agent session has been captured. Reject this request for - /// running tasks. + /// Re-run a finished task with the same id, cwd, tag, group, and name. A + /// literal task reruns its exact text; a managed task resumes its captured + /// session. Reject this request for running tasks. Restart { id: u64 }, /// Set the manual "in use" tag. Tag { id: u64, on: bool }, diff --git a/src/session.rs b/src/session.rs index 7084c28..dce9a65 100644 --- a/src/session.rs +++ b/src/session.rs @@ -12,15 +12,33 @@ use std::{ }; use crate::{ + harness, protocol::{RecoveryEntry, insert_opt_str, opt_str}, task::{pid_is_dead, positive_pid}, }; -/// One recipe entry. Serialize entries without a group or name as strings; use objects -/// for other entries, writing optional fields only when set. +/// What a recipe entry starts. The split mirrors the task it came from: a +/// literal task's text is stored and rerun untouched, and a managed task is +/// stored as the agent it names plus the conversation it last held, never as +/// a command string. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EntryKind { + /// Typed text, run under `$SHELL -c` exactly as stored. + Literal(String), + /// An agent fleetcom launches and owns, named by its registered program + /// word. `resume` is the session to reopen; `None` starts a fresh one. + Managed { + agent: String, + resume: Option, + }, +} + +/// One recipe entry. A literal without a group or name serializes as a bare +/// string; every other entry is an object, writing optional fields only when +/// set. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SessionEntry { - pub cmd: String, + pub kind: EntryKind, pub group: Option, pub name: Option, } @@ -66,8 +84,9 @@ pub fn sessions_dir(root: Option) -> Option { } /// Session format version written by `to_json` and accepted by `from_json`. -/// Missing versions are interpreted as version 1; unsupported versions fail. -const FORMAT_VERSION: u64 = 1; +/// Version 2 added managed entries. A missing version is version 1, whose +/// literal entries pass through [`migrate_v1`]; newer versions fail. +const FORMAT_VERSION: u64 = 2; /// Build the recipe's `dirs` object. fn dirs_json(cfg: &SessionConfig) -> jzon::JsonValue { @@ -75,22 +94,68 @@ fn dirs_json(cfg: &SessionConfig) -> jzon::JsonValue { for (dir, entries) in cfg { let mut arr = jzon::JsonValue::new_array(); for e in entries { - let member = if e.group.is_none() && e.name.is_none() { - // Use the string form for entries without optional labels. - jzon::JsonValue::from(e.cmd.as_str()) - } else { - let mut m = jzon::object! { "cmd": e.cmd.as_str() }; - insert_opt_str(&mut m, "group", &e.group); - insert_opt_str(&mut m, "name", &e.name); - m + let mut m = match &e.kind { + // Use the string form for a literal without optional labels. + EntryKind::Literal(cmd) if e.group.is_none() && e.name.is_none() => { + let _ = arr.push(cmd.as_str()); + continue; + } + EntryKind::Literal(cmd) => jzon::object! { "cmd": cmd.as_str() }, + EntryKind::Managed { agent, resume } => { + let mut m = jzon::object! { "agent": agent.as_str() }; + insert_opt_str(&mut m, "resume", resume); + m + } }; - let _ = arr.push(member); + insert_opt_str(&mut m, "group", &e.group); + insert_opt_str(&mut m, "name", &e.name); + let _ = arr.push(m); } let _ = dirs.insert(dir, arr); } dirs } +/// Map a v1 literal onto the entry this build writes for the same task. Under +/// v1 the bare program word and its resume form were instrumented and resumed, +/// so loading either as literal would silently drop the conversation. Exactly +/// those two shapes convert, with v1's whitespace tolerance between tokens and +/// the single-quote pair v1 wrote around the ID. The first token must be the +/// registered word itself: a path (`/usr/local/bin/claude`, `~/bin/claude`) +/// names a binary the user chose, and a managed launch would run whatever +/// `PATH` finds first, so it stays literal and loses resume. Everything else +/// stays literal. +/// +/// Deleted one release after format v2 ships. From then on an unconverted v1 +/// file loads every entry as literal: no load fails, only resume is lost. +fn migrate_v1(cmd: &str) -> EntryKind { + let literal = || EntryKind::Literal(cmd.to_string()); + let mut words = cmd.split([' ', '\t']).filter(|w| !w.is_empty()); + let Some(h) = words.next().and_then(harness::registered) else { + return literal(); + }; + let (agent, selector) = h.shape(); + let managed = |resume| EntryKind::Managed { + agent: agent.to_string(), + resume, + }; + match (words.next(), words.next(), words.next()) { + (None, _, _) => managed(None), + (Some(sel), Some(id), None) if sel == selector => { + let id = id + .strip_prefix('\'') + .and_then(|t| t.strip_suffix('\'')) + .unwrap_or(id); + if harness::is_uuid(id) { + managed(Some(id.to_string())) + } else { + literal() + } + } + _ => literal(), + } +} + /// Serialize the versioned wrapped schema. Store the name to distinguish names /// sanitized to the same filename. fn to_json(name: &str, cfg: &SessionConfig) -> String { @@ -111,7 +176,11 @@ pub fn fingerprint_json(cfg: &SessionConfig) -> String { /// A wrapped file has an object-valued `dirs`; flat files have entry arrays at /// the top level, including when a directory is literally named `dirs`. /// A top-level `version` must be an integer from 1 through [`FORMAT_VERSION`]; -/// a missing version is interpreted as 1. +/// a missing version is interpreted as 1, and a version-1 file's literal +/// entries pass through [`migrate_v1`]. Entry shapes are accepted by shape, +/// whatever the version: a string or `{"cmd"}` object is literal, an +/// `{"agent"}` object is managed. An object with both keys, an unregistered +/// agent word, or a `resume` that is not a strict UUID rejects the whole file. fn from_json(text: &str) -> io::Result<(Option, SessionConfig)> { let invalid = |message| io::Error::new(io::ErrorKind::InvalidData, message); let parsed = jzon::parse(text).map_err(|e| invalid(e.to_string()))?; @@ -120,9 +189,10 @@ fn from_json(text: &str) -> io::Result<(Option, SessionConfig)> { } // Validate version metadata before detecting the schema shape. let version = &parsed["version"]; + let mut v1 = true; if parsed.has_key("version") { match version.as_u64() { - Some(n) if (1..=FORMAT_VERSION).contains(&n) => {} + Some(n) if (1..=FORMAT_VERSION).contains(&n) => v1 = n == 1, Some(n) if n > FORMAT_VERSION => { return Err(invalid(format!( "session format version {n} is newer than this fleetcom \ @@ -156,10 +226,18 @@ fn from_json(text: &str) -> io::Result<(Option, SessionConfig)> { return Err(invalid(format!("directory {dir:?}: expected an array"))); } let mut entries = Vec::new(); + // A v1 literal may be an agent launch v1 could only spell as text. + let literal = |cmd: &str| { + if v1 { + migrate_v1(cmd) + } else { + EntryKind::Literal(cmd.to_string()) + } + }; for (index, member) in val.members().enumerate() { if let Some(cmd) = member.as_str() { entries.push(SessionEntry { - cmd: cmd.to_string(), + kind: literal(cmd), group: None, name: None, }); @@ -171,9 +249,6 @@ fn from_json(text: &str) -> io::Result<(Option, SessionConfig)> { "{location}: expected a command string or an object" ))); } - let cmd = member["cmd"] - .as_str() - .ok_or_else(|| invalid(format!("{location}, field \"cmd\": expected a string")))?; let label = |field| { opt_str(&member[field]).ok_or_else(|| { invalid(format!( @@ -181,8 +256,41 @@ fn from_json(text: &str) -> io::Result<(Option, SessionConfig)> { )) }) }; + let kind = if member.has_key("agent") { + if member.has_key("cmd") { + return Err(invalid(format!( + "{location}: has both \"cmd\" and \"agent\"; an entry is one or the other" + ))); + } + let agent = member["agent"].as_str().ok_or_else(|| { + invalid(format!("{location}, field \"agent\": expected a string")) + })?; + if harness::registered(agent).is_none() { + return Err(invalid(format!( + "{location}, field \"agent\": no agent named {agent:?}" + ))); + } + let resume = label("resume")?; + if let Some(id) = &resume + && !harness::is_uuid(id) + { + return Err(invalid(format!( + "{location}, field \"resume\": expected a session ID \ + (8-4-4-4-12 lowercase hex) or null" + ))); + } + EntryKind::Managed { + agent: agent.to_string(), + resume, + } + } else { + let cmd = member["cmd"].as_str().ok_or_else(|| { + invalid(format!("{location}, field \"cmd\": expected a string")) + })?; + literal(cmd) + }; entries.push(SessionEntry { - cmd: cmd.to_string(), + kind, group: label("group")?, name: label("name")?, }); @@ -458,41 +566,54 @@ fn prune_recovery(dir: &Path, keep_stem: &str) { #[cfg(test)] mod tests { use super::*; - use crate::testutil::{dead_pid, temp}; + use crate::{ + harness::fixtures::{ID, OTHER}, + testutil::{dead_pid, temp}, + }; - /// Unadorned entry: the plain-string member form. + /// Unadorned literal: the plain-string member form. fn e(cmd: &str) -> SessionEntry { SessionEntry { - cmd: cmd.into(), + kind: EntryKind::Literal(cmd.into()), group: None, name: None, } } - /// Grouped entry: the `{"cmd", "group"}` member form. + /// Grouped literal: the `{"cmd", "group"}` member form. fn ge(cmd: &str, group: &str) -> SessionEntry { SessionEntry { - cmd: cmd.into(), group: Some(group.into()), - name: None, + ..e(cmd) } } - /// Named entry: the `{"cmd", "name"}` member form. + /// Named literal: the `{"cmd", "name"}` member form. fn ne(cmd: &str, name: &str) -> SessionEntry { SessionEntry { - cmd: cmd.into(), - group: None, name: Some(name.into()), + ..e(cmd) } } - /// Grouped and named entry: the full `{"cmd", "group", "name"}` form. + /// Grouped and named literal: the full `{"cmd", "group", "name"}` form. fn gne(cmd: &str, group: &str, name: &str) -> SessionEntry { SessionEntry { - cmd: cmd.into(), group: Some(group.into()), name: Some(name.into()), + ..e(cmd) + } + } + + /// Unadorned managed entry: the `{"agent"[, "resume"]}` member form. + fn m(agent: &str, resume: Option<&str>) -> SessionEntry { + SessionEntry { + kind: EntryKind::Managed { + agent: agent.into(), + resume: resume.map(String::from), + }, + group: None, + name: None, } } @@ -580,13 +701,13 @@ mod tests { cfg.insert("~/proj".into(), vec![e("cargo test"), e("vim")]); cfg.insert("/tmp".into(), vec![e("top")]); - let expected = "{\n \"version\": 1,\n \"name\": \"work\",\n \"dirs\": {\n \"/tmp\": [\n \"top\"\n ],\n \"~/proj\": [\n \"cargo test\",\n \"vim\"\n ]\n }\n}"; + let expected = "{\n \"version\": 2,\n \"name\": \"work\",\n \"dirs\": {\n \"/tmp\": [\n \"top\"\n ],\n \"~/proj\": [\n \"cargo test\",\n \"vim\"\n ]\n }\n}"; assert_eq!(to_json("work", &cfg), expected); } /// Saved files include the accepted format version. #[test] - fn save_writes_version_1_and_load_accepts_it() { + fn save_writes_version_2_and_load_accepts_it() { let dir = temp("session_version_roundtrip"); let mut cfg = SessionConfig::new(); cfg.insert("~/p".into(), vec![e("vim")]); @@ -595,11 +716,266 @@ mod tests { assert!( fs::read_to_string(&file) .unwrap() - .contains("\"version\": 1") + .contains("\"version\": 2") ); assert_eq!(load_in(&dir, "versioned").unwrap(), cfg); } + /// Managed entries round-trip in every label combination, with and + /// without a resume ID, beside literal ones. + #[test] + fn round_trips_managed_entries() { + let dir = temp("session_managed_roundtrip"); + let mut cfg = SessionConfig::new(); + cfg.insert( + "~/proj".into(), + vec![ + m("claude", Some(ID)), + m("omp", None), + SessionEntry { + group: Some("agents".into()), + ..m("codex", Some(OTHER)) + }, + SessionEntry { + name: Some("pilot".into()), + ..m("grok", None) + }, + SessionEntry { + group: Some("agents".into()), + name: Some("reviewer".into()), + ..m("claude", None) + }, + gne("cargo test", "ci", "unit tests"), + e("vim"), + ], + ); + + save_in(&dir, "managed", &cfg).unwrap(); + assert_eq!(load_in(&dir, "managed").unwrap(), cfg); + } + + /// The managed object carries `agent` first, then `resume`, `group` and + /// `name` only when set; a managed entry is never a bare string. + #[test] + fn managed_entries_serialize_as_agent_objects_with_optional_fields() { + let mut cfg = SessionConfig::new(); + cfg.insert( + "~/p".into(), + vec![ + m("omp", None), + SessionEntry { + group: Some("agents".into()), + name: Some("pilot".into()), + ..m("claude", Some(ID)) + }, + ], + ); + assert_eq!( + fingerprint_json(&cfg), + format!( + r#"{{"~/p":[{{"agent":"omp"}},{{"agent":"claude","resume":"{ID}","group":"agents","name":"pilot"}}]}}"# + ) + ); + } + + /// The fingerprint changes with the resume ID, so a cadence pass notices + /// a captured session on an otherwise unchanged fleet. + #[test] + fn fingerprint_tracks_a_managed_entrys_resume_id() { + let fp = |resume| { + let mut cfg = SessionConfig::new(); + cfg.insert("~/p".into(), vec![m("claude", resume)]); + fingerprint_json(&cfg) + }; + assert_ne!(fp(None), fp(Some(ID))); + assert_ne!(fp(Some(ID)), fp(Some(OTHER))); + assert_eq!(fp(Some(ID)), fp(Some(ID))); + } + + /// A v2 file's literal agent word is literal by construction: only v1 + /// files are migrated. + #[test] + fn v2_literal_agent_words_stay_literal() { + let (_, cfg) = from_json(&format!( + r#"{{"version": 2, "name": "v", "dirs": {{"d": ["claude", + {{"cmd": "codex resume '{ID}'", "group": "g"}}, {{"agent": "omp"}}]}}}}"# + )) + .unwrap(); + assert_eq!( + cfg["d"], + vec![ + e("claude"), + ge(&format!("codex resume '{ID}'"), "g"), + m("omp", None) + ] + ); + } + + /// The v1 → v2 mapping: the two shapes v1 instrumented become managed, + /// with v1's whitespace tolerance and its quoted ID; a path-qualified + /// word, extra arguments, shell syntax and malformed IDs stay literal. + #[test] + fn v1_literals_migrate_exactly_the_two_instrumented_shapes() { + let cases: Vec<(String, SessionEntry)> = vec![ + ("claude".into(), m("claude", None)), + ("codex".into(), m("codex", None)), + ("grok".into(), m("grok", None)), + ("omp".into(), m("omp", None)), + (format!("claude --resume {ID}"), m("claude", Some(ID))), + (format!("claude --resume '{ID}'"), m("claude", Some(ID))), + (format!("codex resume '{ID}'"), m("codex", Some(ID))), + (format!("grok --resume {ID}"), m("grok", Some(ID))), + (format!("omp --resume '{ID}'"), m("omp", Some(ID))), + // v1's token tolerance: tabs, repeated and surrounding whitespace. + (" claude\t".into(), m("claude", None)), + (format!("claude\t--resume '{ID}' "), m("claude", Some(ID))), + // A path names a binary the user chose: literal, resume lost. + ("/usr/local/bin/claude".into(), e("/usr/local/bin/claude")), + ("~/bin/claude".into(), e("~/bin/claude")), + ( + format!("/usr/local/bin/claude --resume '{ID}'"), + e(&format!("/usr/local/bin/claude --resume '{ID}'")), + ), + // Arguments, prompts, the wrong selector, and shell syntax. + ("claude --model opus".into(), e("claude --model opus")), + ("claude 'fix the tests'".into(), e("claude 'fix the tests'")), + (format!("claude -r {ID}"), e(&format!("claude -r {ID}"))), + ( + format!("codex --resume {ID}"), + e(&format!("codex --resume {ID}")), + ), + ( + format!("claude --resume {ID} --model opus"), + e(&format!("claude --resume {ID} --model opus")), + ), + ("claude --resume".into(), e("claude --resume")), + ( + "claude --resume not-a-uuid".into(), + e("claude --resume not-a-uuid"), + ), + ( + format!("claude --resume '{ID}"), + e(&format!("claude --resume '{ID}")), + ), + ( + format!("claude --resume ''{ID}''"), + e(&format!("claude --resume ''{ID}''")), + ), + ("claude --resume $ID".into(), e("claude --resume $ID")), + ("claude | tee log".into(), e("claude | tee log")), + ("claude; ls".into(), e("claude; ls")), + ("FOO=bar claude".into(), e("FOO=bar claude")), + ("'claude'".into(), e("'claude'")), + ("claudius".into(), e("claudius")), + ("vim".into(), e("vim")), + (String::new(), e("")), + ]; + for (cmd, expected) in &cases { + assert_eq!(migrate_v1(cmd), expected.kind, "{cmd:?}"); + } + // Through the loader: every v1 spelling, wrapped and flat, string and + // object form, with labels carried over. + let member = jzon::JsonValue::from(cases[4].0.as_str()).dump(); + for text in [ + format!(r#"{{"version": 1, "name": "v", "dirs": {{"d": [{member}]}}}}"#), + format!(r#"{{"name": "v", "dirs": {{"d": [{member}]}}}}"#), + format!(r#"{{"d": [{member}]}}"#), + format!(r#"{{"version": 1, "d": [{member}]}}"#), + ] { + let (_, cfg) = from_json(&text).unwrap(); + assert_eq!(cfg["d"], vec![m("claude", Some(ID))], "{text}"); + } + let (_, cfg) = from_json( + r#"{"d": [{"cmd": "claude", "group": "agents", "name": "pilot"}, + {"cmd": "/usr/local/bin/claude", "name": "mine"}]}"#, + ) + .unwrap(); + assert_eq!( + cfg["d"], + vec![ + SessionEntry { + group: Some("agents".into()), + name: Some("pilot".into()), + ..m("claude", None) + }, + ne("/usr/local/bin/claude", "mine"), + ] + ); + } + + /// A managed entry that is also a command, names no registered agent, or + /// carries a malformed resume ID rejects the whole file. + #[test] + fn rejects_malformed_managed_entries() { + let cases = [ + ( + r#"{"cmd": "claude", "agent": "claude"}"#.to_string(), + "directory \"d\", entry 2: has both \"cmd\" and \"agent\"; \ + an entry is one or the other" + .to_string(), + ), + ( + r#"{"agent": "vim"}"#.into(), + "directory \"d\", entry 2, field \"agent\": no agent named \"vim\"".into(), + ), + ( + r#"{"agent": "/usr/local/bin/claude"}"#.into(), + "directory \"d\", entry 2, field \"agent\": no agent named \ + \"/usr/local/bin/claude\"" + .into(), + ), + ( + r#"{"agent": null}"#.into(), + "directory \"d\", entry 2, field \"agent\": expected a string".into(), + ), + ( + r#"{"agent": 42}"#.into(), + "directory \"d\", entry 2, field \"agent\": expected a string".into(), + ), + ( + r#"{"agent": "claude", "resume": "not-a-uuid"}"#.into(), + "directory \"d\", entry 2, field \"resume\": expected a session ID \ + (8-4-4-4-12 lowercase hex) or null" + .into(), + ), + ( + format!( + r#"{{"agent": "claude", "resume": "{}"}}"#, + ID.to_uppercase() + ), + "directory \"d\", entry 2, field \"resume\": expected a session ID \ + (8-4-4-4-12 lowercase hex) or null" + .into(), + ), + ( + format!(r#"{{"agent": "claude", "resume": "'{ID}'"}}"#), + "directory \"d\", entry 2, field \"resume\": expected a session ID \ + (8-4-4-4-12 lowercase hex) or null" + .into(), + ), + ( + r#"{"agent": "claude", "resume": 42}"#.into(), + "directory \"d\", entry 2, field \"resume\": expected a string or null".into(), + ), + ]; + for (entry, expected) in cases { + for version in ["", r#""version": 1, "#, r#""version": 2, "#] { + let dirs = format!(r#""d": ["valid-command", {entry}]"#); + for text in [ + format!("{{{version}{dirs}}}"), + format!("{{{version}\"dirs\": {{{dirs}}}}}"), + ] { + let err = from_json(&text).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidData, "{text}"); + assert_eq!(err.to_string(), expected, "{text}"); + } + } + } + // A null resume is "fresh", the same as an absent one. + let (_, cfg) = from_json(r#"{"d": [{"agent": "claude", "resume": null}]}"#).unwrap(); + assert_eq!(cfg["d"], vec![m("claude", None)]); + } + /// A missing version is interpreted as version 1. #[test] fn missing_version_means_version_1() { @@ -616,13 +992,14 @@ mod tests { assert_eq!(cfg["~/proj"], vec![e("vim")]); } - /// A newer format fails with an error naming both versions. + /// A newer format fails with an error naming both versions: the gate a + /// v1 build applies to this build's files, and this build to the next. #[test] fn newer_version_refuses_naming_both_versions() { - let err = from_json(r#"{"version": 2, "name": "v", "dirs": {}}"#).unwrap_err(); + let err = from_json(r#"{"version": 3, "name": "v", "dirs": {}}"#).unwrap_err(); assert_eq!( err.to_string(), - "session format version 2 is newer than this fleetcom (supports 1); \ + "session format version 3 is newer than this fleetcom (supports 2); \ load it with a newer build" ); } @@ -634,7 +1011,7 @@ mod tests { assert_eq!( err.to_string(), "session format version 0 is not one this fleetcom reads \ - (supports 1); load it with a newer build" + (supports 2); load it with a newer build" ); } @@ -645,7 +1022,7 @@ mod tests { assert_eq!( err.to_string(), "session format version \"2.0\" is not one this fleetcom reads \ - (supports 1); load it with a newer build" + (supports 2); load it with a newer build" ); } @@ -661,7 +1038,7 @@ mod tests { let err = load_in(&dir, "future").unwrap_err(); assert!(err.to_string().contains("version 3"), "{err}"); - assert!(err.to_string().contains("supports 1"), "{err}"); + assert!(err.to_string().contains("supports 2"), "{err}"); } /// A flat schema treats `version` as metadata, not a directory. @@ -773,7 +1150,7 @@ mod tests { err.to_string().contains(&format!("version {value}")), "{err}" ); - assert!(err.to_string().contains("supports 1"), "{err}"); + assert!(err.to_string().contains("supports 2"), "{err}"); } } } @@ -940,7 +1317,7 @@ mod tests { for text in [ r#"{"name": "a/b", "dirs": {"d": ["valid", {"cmd": false}]}}"#, r#"{"name": "a/b", "dirs": {"d": null}}"#, - r#"{"version": 2, "name": "a/b", "dirs": {}}"#, + r#"{"version": 3, "name": "a/b", "dirs": {}}"#, ] { fs::write(&file, text).unwrap(); assert!(load_in(&dir, "a/b").is_err()); @@ -984,6 +1361,34 @@ mod tests { assert_eq!(list_in(&dir), vec!["old".to_string()]); } + /// A v1 recovery snapshot with the strings v1 wrote for managed tasks + /// loads them as managed entries, so recovery survives the upgrade. + #[test] + fn v1_recovery_snapshots_migrate_on_load() { + let base = temp("session_recovery_migrate"); + let rec = recovery_dir(&base); + fs::create_dir_all(&rec).unwrap(); + fs::write( + rec.join("20260714-093015-11.json"), + format!( + r#"{{"version": 1, "name": "autosaved 2026-07-14 09:30", "dirs": {{"~/p": [ + {{"cmd": "claude --resume '{ID}'", "group": "agents"}}, "omp", "cargo test"]}}}}"# + ), + ) + .unwrap(); + assert_eq!( + load_recovery_in(&rec, "20260714-093015-11").unwrap()["~/p"], + vec![ + SessionEntry { + group: Some("agents".into()), + ..m("claude", Some(ID)) + }, + m("omp", None), + e("cargo test"), + ] + ); + } + /// A flat-schema file can be replaced under its filename stem. #[test] fn legacy_file_resaves_under_its_own_stem() { @@ -1045,7 +1450,7 @@ mod tests { let file = save_recovery_in(&rec, &stem, &label, &cfg).unwrap(); assert_eq!(file, rec.join("20260714-093015-4242.json")); let text = fs::read_to_string(&file).unwrap(); - assert!(text.contains("\"version\": 1"), "{text}"); + assert!(text.contains("\"version\": 2"), "{text}"); let (stored, parsed) = from_json(&text).unwrap(); assert_eq!(stored.as_deref(), Some("autosaved 2026-07-14 09:30")); assert_eq!(parsed, cfg); diff --git a/src/supervisor.rs b/src/supervisor.rs index e509e32..c1a5735 100644 --- a/src/supervisor.rs +++ b/src/supervisor.rs @@ -15,12 +15,12 @@ use std::{ use crate::{ core::{Wake, Waker}, - harness::{self, Harness, Intent, Invocation, assets}, + harness::{self, Harness, Intent, assets}, path, protocol::{ Command, Event, LaunchContext, ScreenView, ScrollAction, TaskView, UNASSIGNED, env_get, }, - session::{self, SessionConfig, SessionEntry}, + session::{self, EntryKind, SessionConfig, SessionEntry}, task::{Exec, Task, WriteRefused}, }; @@ -48,9 +48,7 @@ const MAX_TASKS: usize = 256; /// text is never edited by managed code, and a managed task's argv never /// passes through a shell. enum Launch<'a> { - /// The text the user typed, run verbatim under `$SHELL -c`. In this - /// release a bare agent word or its resume form still gets detected and - /// instrumented; that path goes once managed launches carry it alone. + /// The text the user typed, run verbatim under `$SHELL -c`. Literal(&'a str), /// An agent fleetcom launches and owns. Managed(ManagedLaunch), @@ -64,8 +62,9 @@ struct ManagedLaunch { intent: Intent, } -/// Maximum command length in bytes. Direct spawns and session loads enforce -/// this limit to bound shell arguments and serialized task snapshots. +/// Maximum literal command length in bytes. Direct spawns and session loads +/// enforce this limit to bound shell arguments and serialized task snapshots. +/// A managed entry carries a program word and a UUID, so it needs no cap. const MAX_COMMAND_LEN: usize = 64 * 1024; /// Conditions counted as skipped by `materialize`. @@ -151,10 +150,12 @@ fn fnv1a_hex(bytes: &[u8]) -> String { format!("{h:016x}") } -/// Resolve the session ID in precedence order: capture file, live registry, -/// then spawn-time ID. Prefer the first two sources because a session may have -/// been selected after launch. Validate the capture with the task's leader PID -/// and launch-time home through the harness. If rejected, try the next source. +/// Resolve a managed task's session ID in precedence order: capture file, +/// live registry, then spawn-time ID. Prefer the first two sources because a +/// session may have been selected after launch. Validate the capture with the +/// task's leader PID and launch-time home through the harness. If rejected, +/// try the next source. A literal task has no harness, so every source is +/// skipped and the result is `None`. fn current_resume_id(task: &Task) -> Option { if let (Some(h), Some(path)) = (task.harness, &task.capture_file) && let Ok(payload) = std::fs::read_to_string(path) @@ -175,15 +176,6 @@ fn current_resume_id(task: &Task) -> Option { task.resume_id.clone() } -/// Build the saved or rerun command from the task's best-known ID. -/// Without an ID, preserve the requested command exactly. -fn recipe_command(t: &Task) -> String { - match (t.harness, current_resume_id(t)) { - (Some(h), Some(id)) => h.resume_command(&t.command, &id), - _ => t.command.clone(), - } -} - /// Resolve harness configuration from the task's launch environment. fn harness_home(env: &[(OsString, OsString)], h: &dyn harness::Harness) -> Option { h.resolve_home(&|key| env_get(env, key).map(PathBuf::from)) @@ -778,8 +770,9 @@ impl Supervisor { /// Spawn a direct command, managed agent, rerun, or session entry. The /// task's `command` is display and recipe text: a literal's typed text, a - /// managed agent's program word. Only the exec form carries - /// instrumentation. + /// managed agent's program word. Only a managed launch carries + /// instrumentation: a literal's text reaches the shell untouched, whatever + /// it names. fn spawn_task( &mut self, id: u64, @@ -792,38 +785,7 @@ impl Supervisor { let mut meta = None; let mut notice = None; let (command, exec) = match launch { - Launch::Literal(text) => { - // Detection, when it fires, contributes a shell suffix, extra - // env, and post-spawn metadata; a plain command contributes - // nothing. A detected harness without capture assets (install - // failure) spawns plain: instrumentation is disabled, not the - // task. - let mut exec = text.to_string(); - if let Some((h, inv)) = harness::detect(text) - && let Some(paths) = self.ensure_capture_assets().map(|a| a.paths_for(id, run)) - { - let home = harness_home(&env, h); - let plan = match inv { - Invocation::Bare => harness::plan( - h, - &Intent::Fresh, - harness::uuid_v4().as_deref(), - &paths, - home.as_deref(), - ), - // The typed text already carries the selector and ID. - Invocation::Resume(known) => harness::SpawnPlan { - resume_id: Some(known), - ..h.overlay(&paths, home.as_deref()) - }, - }; - exec.push_str(&harness::shell_words(&plan.args)); - env.to_mut().extend(plan.env); - notice = plan.notice; - meta = Some((h, home, paths.capture_file, plan.resume_id)); - } - (*text, Exec::Literal(exec)) - } + Launch::Literal(text) => (*text, Exec::Literal((*text).to_string())), Launch::Managed(m) => { // Ownership needs the overlay: without the embedded override a // managed codex would hand its conversation to the shared @@ -944,30 +906,42 @@ impl Supervisor { /// fresh conversation. The binary is whatever the launch context's `PATH` /// finds now, never a path the client sent; no binary means no spawn. fn spawn_agent(&mut self, agent: &str, cwd: PathBuf, group: Option) { - let Some(h) = harness::registered(agent) else { - self.status(format!("no agent named {agent:?}, not spawning")); - return; - }; if !self.below_task_ceiling() { return; } let Some(launch) = self.launch_or_refuse() else { return; }; - let Some(binary) = self.agent_binary(agent, &launch) else { + let Some(managed) = self.resolve_agent(agent, &launch, Intent::Fresh) else { return; }; - let managed = Launch::Managed(ManagedLaunch { - agent: h, - binary, - intent: Intent::Fresh, - }); - match self.admit(&managed, &cwd, &launch.env, group, None) { + match self.admit(&Launch::Managed(managed), &cwd, &launch.env, group, None) { Ok(id) => self.events.push(Event::Spawned { id }), Err(e) => self.status(format!("spawn failed: {e}")), } } + /// Build the managed launch of `agent` with `intent`: the registered + /// harness and the binary `launch`'s `PATH` finds now. Either miss is a + /// status line and `None`. + fn resolve_agent( + &mut self, + agent: &str, + launch: &LaunchContext, + intent: Intent, + ) -> Option { + let Some(h) = harness::registered(agent) else { + self.status(format!("no agent named {agent:?}, not spawning")); + return None; + }; + let binary = self.agent_binary(agent, launch)?; + Some(ManagedLaunch { + agent: h, + binary, + intent, + }) + } + /// Resolve `agent` on `launch`'s `PATH`, reporting a miss. fn agent_binary(&mut self, agent: &str, launch: &LaunchContext) -> Option { let path = env_get(&launch.env, "PATH").unwrap_or_default(); @@ -981,7 +955,7 @@ impl Supervisor { /// Rerun a finished task in place while preserving its ID, tag, group, and name. /// A managed task resumes its best-known ID through the harness, or starts /// fresh without one, from whatever binary `PATH` finds now. A literal - /// task with a captured agent session reruns its resume form. + /// task reruns its exact text. fn rerun(&mut self, id: u64) { let Some(i) = self.index_of(id) else { self.status(format!("rerun failed: no task {id}")); @@ -1003,11 +977,10 @@ impl Supervisor { return; }; let cwd = self.tasks[i].cwd.clone(); + // Only a managed task carries a harness: the match is the kind split. let command; - let relaunch = match (self.tasks[i].managed, self.tasks[i].harness) { - // A managed task always carries its harness; the tuple keeps the - // literal arm as the only other path rather than panicking. - (true, Some(agent)) => { + let relaunch = match self.tasks[i].harness { + Some(agent) => { let Some(binary) = self.agent_binary(agent.shape().0, &launch) else { return; }; @@ -1019,10 +992,8 @@ impl Supervisor { intent, }) } - // Store the resuming form so detection treats the replacement as - // a targeted conversation. - _ => { - command = recipe_command(&self.tasks[i]); + None => { + command = self.tasks[i].command.clone(); Launch::Literal(&command) } }; @@ -1034,8 +1005,8 @@ impl Supervisor { fresh.tagged = self.tasks[i].tagged; fresh.group = self.tasks[i].group.clone(); fresh.name = self.tasks[i].name.clone(); - // Derive the resume command before removing the displaced run's capture - // file during retirement. + // The resume ID was read above, before retirement removes the + // displaced run's capture file. let old = std::mem::replace(&mut self.tasks[i], fresh); self.retire(old); // Reset the fingerprint for the replacement task's screen. @@ -1047,8 +1018,9 @@ impl Supervisor { } } - /// Build `{dir: [entries]}` in spawn order. Groups and names remain intact; - /// agent entries use the command returned by `recipe_command`. + /// Build `{dir: [entries]}` in spawn order. Groups and names remain intact. + /// A managed task is written as its agent word and best-known session ID; + /// a literal task as its exact text. fn session_config(&self) -> SessionConfig { // `admit` appends monotonic IDs; `rerun` preserves both index and ID; // removal preserves relative order. @@ -1058,10 +1030,17 @@ impl Supervisor { ); let mut cfg = SessionConfig::new(); for t in &self.tasks { + let kind = match t.harness { + Some(h) => EntryKind::Managed { + agent: h.shape().0.to_string(), + resume: current_resume_id(t), + }, + None => EntryKind::Literal(t.command.clone()), + }; cfg.entry(path::abbreviate(&t.cwd)) .or_default() .push(SessionEntry { - cmd: recipe_command(t), + kind, group: t.group.clone(), name: t.name.clone(), }); @@ -1112,9 +1091,11 @@ impl Supervisor { /// Spawn every entry of a loaded recipe, each in its (existing) dir. /// Missing dirs are skipped rather than spawning tasks doomed to fail on - /// chdir. Returns `(spawned, skipped, failed)` for the caller's notice, or - /// `None` when no launch context is installed (already refused with its - /// own notice). + /// chdir. A managed entry goes through the same resolution as the Agent + /// page: an agent with no binary on this context's `PATH` is a status + /// line and a failed entry, the rest still load. Returns `(spawned, + /// skipped, failed)` for the caller's notice, or `None` when no launch + /// context is installed (already refused with its own notice). fn materialize(&mut self, cfg: &SessionConfig) -> Option<(usize, usize, usize)> { let launch = self.launch_or_refuse()?; let (mut spawned, mut skipped, mut failed) = (0usize, 0usize, 0usize); @@ -1125,13 +1106,30 @@ impl Supervisor { continue; } for entry in entries { - if self.tasks.len() >= self.max_tasks || entry.cmd.len() > MAX_COMMAND_LEN { + if self.tasks.len() >= self.max_tasks { skipped += 1; continue; } + let kind = match &entry.kind { + EntryKind::Literal(text) => { + if text.len() > MAX_COMMAND_LEN { + skipped += 1; + continue; + } + Launch::Literal(text) + } + EntryKind::Managed { agent, resume } => { + let intent = resume.clone().map_or(Intent::Fresh, Intent::Resume); + let Some(m) = self.resolve_agent(agent, &launch, intent) else { + failed += 1; + continue; + }; + Launch::Managed(m) + } + }; // `admit` normalizes the persisted labels before assignment. match self.admit( - &Launch::Literal(&entry.cmd), + &kind, &resolved, &launch.env, entry.group.clone(), diff --git a/src/supervisor_capture_tests.rs b/src/supervisor_capture_tests.rs index 44debb2..faaa152 100644 --- a/src/supervisor_capture_tests.rs +++ b/src/supervisor_capture_tests.rs @@ -126,6 +126,64 @@ fn acknowledged(events: &[Event]) -> bool { events.iter().any(|e| matches!(e, Event::Spawned { .. })) } +/// An unlabelled managed entry. +fn managed(agent: &str, resume: Option<&str>) -> SessionEntry { + SessionEntry { + kind: EntryKind::Managed { + agent: agent.into(), + resume: resume.map(String::from), + }, + group: None, + name: None, + } +} + +/// An unlabelled literal entry. +fn literal(cmd: &str) -> SessionEntry { + SessionEntry { + kind: EntryKind::Literal(cmd.into()), + group: None, + name: None, + } +} + +/// Save `entries` for `dir` as the named recipe through the real serializer, +/// then load it: the only way a task starts with a chosen resume target. +fn load_recipe( + s: &mut Supervisor, + config: &Path, + name: &str, + dir: &Path, + entries: Vec, +) { + let cfg = SessionConfig::from([(dir.to_string_lossy().into_owned(), entries)]); + session::save_in(&config.join("sessions"), name, &cfg).unwrap(); + s.apply(Command::LoadSession { name: name.into() }); +} + +/// The entries of the sole directory in the saved recipe `name`. +fn saved_entries(s: &mut Supervisor, config: &Path, name: &str) -> Vec { + save_and_read(s, config, name); + session::load_in(&config.join("sessions"), name) + .unwrap() + .into_values() + .flatten() + .collect() +} + +/// The resume target the sole task saves under `name`, which must be managed. +fn saved_resume(s: &mut Supervisor, config: &Path, name: &str) -> Option { + match saved_entries(s, config, name).as_slice() { + [ + SessionEntry { + kind: EntryKind::Managed { resume, .. }, + .. + }, + ] => resume.clone(), + other => panic!("expected one managed entry, got {other:?}"), + } +} + /// The FNV-1a discriminator is stable and separates distinct config roots. #[test] fn fnv_discriminator_is_stable_and_distinguishes_roots() { @@ -136,15 +194,15 @@ fn fnv_discriminator_is_stable_and_distinguishes_roots() { assert_ne!(fnv1a_hex(b"/cfg/one"), fnv1a_hex(b"/cfg/two")); } -/// A `claude` spawn receives a pinned ID, settings overlay, and capture -/// environment without changing the stored command. +/// A managed `claude` receives a pinned ID, settings overlay, and capture +/// environment; its display text stays the program word. #[test] fn spawn_claude_pins_an_id_and_layers_settings() { let dir = scratch("cap_claude"); let (bin, runtime) = (dir.join("bin"), dir.join("run")); install_stub(&bin, "claude", &dir); let mut s = sup_ctx(agent_ctx(&bin, &runtime, dir.to_path_buf())); - spawn(&mut s, "claude", dir.to_path_buf()); + s.spawn_agent("claude", dir.to_path_buf(), None); let argv = wait_argv(&mut s, &dir.join("argv")); let si = argv @@ -228,86 +286,89 @@ fn spawn_non_agent_command_is_not_instrumented() { assert!(!runtime.exists()); } -/// A resuming `claude` launch retains its target ID and adds only the -/// capture overlay. +/// A typed `claude` is a literal task: the shell gets exactly that text, no +/// harness channel is attached, and the recipe stores the text. A +/// capture-shaped file at the path a managed task of the same id would use +/// and a matching registry record change nothing: nothing reads them. #[test] -fn spawn_resuming_claude_injects_only_the_capture_channel() { - let dir = scratch("cap_resume"); - let (bin, runtime) = (dir.join("bin"), dir.join("run")); - install_stub(&bin, "claude", &dir); - let mut s = sup_ctx(agent_ctx(&bin, &runtime, dir.to_path_buf())); - spawn( - &mut s, - format!("claude --resume {CAP_ID}"), +fn typed_agent_word_is_literal_runs_verbatim_and_saves_as_text() { + let dir = scratch("literal_claude"); + let (bin, runtime, config) = (dir.join("bin"), dir.join("run"), dir.join("config")); + let claude_home = dir.join("claude_home"); + // The stub publishes its argument count and the capture env atomically. + install_script( + &bin, + "claude", + &format!( + "printf '%s\\n' \"$#\" \"$FLEETCOM_CAPTURE_FILE\" > '{out}/argv.'$$'.tmp' \ + && mv '{out}/argv.'$$'.tmp' '{out}/argv'", + out = dir.display() + ), + ); + let mut s = sup_ctx(agent_ctx_plus( + &bin, + &runtime, dir.to_path_buf(), + &[ + ("FLEETCOM_CONFIG_DIR", &config), + ("CLAUDE_CONFIG_DIR", &claude_home), + ], + )); + spawn(&mut s, "claude", dir.to_path_buf()); + assert_eq!( + wait_argv(&mut s, &dir.join("argv")), + ["0", ""], + "a literal launch adds no argument and names no capture file" ); - - let argv = wait_argv(&mut s, &dir.join("argv")); + let t = &s.tasks[0]; + assert!(!t.managed); + assert!(t.harness.is_none(), "a literal task has no harness channel"); + assert!(t.capture_file.is_none()); + assert!(t.resume_id.is_none()); + assert_eq!(t.command, "claude"); assert!( - !argv.iter().any(|a| a == "--session-id"), - "a resuming launch must never pin a second id; argv: {argv:?}" + t.summary_adapter.is_some(), + "the screen-only summary adapter still applies" ); assert!( - argv.iter().any(|a| a == "--settings"), - "the settings overlay must still ride along; argv: {argv:?}" + s.capture.is_empty(), + "no assets are installed for a literal" ); - let t = &s.tasks[0]; - assert_eq!(t.command, format!("claude --resume {CAP_ID}")); - assert_eq!(t.resume_id.as_deref(), Some(CAP_ID)); -} - -/// Rerun prefers the capture-file ID, stores the resulting resume command, -/// and deletes the displaced run's capture after deriving the resume command. -#[test] -fn rerun_resumes_the_captured_conversation() { - use crate::protocol::Lifecycle; - let dir = scratch("cap_rerun"); - let (bin, runtime) = (dir.join("bin"), dir.join("run")); - install_stub(&bin, "claude", &dir); - let mut s = sup_ctx(agent_ctx(&bin, &runtime, dir.to_path_buf())); - spawn(&mut s, "claude", dir.to_path_buf()); - let _ = wait_argv(&mut s, &dir.join("argv")); - let id = s.tasks[0].id; - wait_for_lifecycle(&mut s, id, |l| l == Lifecycle::Ok); + assert!(!runtime.exists()); + let pid = t.pid().expect("a spawned task has a pid"); - // The capture payload reports a different ID from the pinned one. - let cap = s.tasks[0].capture_file.clone().expect("capture file set"); + // A managed sibling installs the namespace; a capture-shaped file for + // task 1 lands there, stamped with task 1's own leader pid, and a + // registry record is keyed to task 1 as well. + s.spawn_agent("claude", dir.to_path_buf(), None); + let ns = s.tasks[1] + .capture_file + .as_deref() + .and_then(Path::parent) + .expect("a managed task has a namespaced capture file") + .to_path_buf(); std::fs::write( - &cap, - stamped( - &s.tasks[0], - &format!( - r#"{{"session_id":"{CAP_OTHER}","hook_event_name":"SessionStart","source":"clear"}}"# - ), - ), + ns.join("task-1-0.json"), + stamped(&s.tasks[0], &format!(r#"{{"session_id":"{CAP_ID}"}}"#)), ) .unwrap(); - std::fs::remove_file(dir.join("argv")).unwrap(); - - s.apply(Command::Restart { id }); - let argv = wait_argv(&mut s, &dir.join("argv")); - assert_eq!( - s.tasks[0].command, - format!("claude --resume '{CAP_OTHER}'"), - "the stored command must become the resuming one" - ); - let ri = argv - .iter() - .position(|a| a == "--resume") - .expect("the respawn must resume"); - assert_eq!(argv[ri + 1], CAP_OTHER); - assert!( - !argv.iter().any(|a| a == "--session-id"), - "re-detection classifies the respawn as resuming: no second id" - ); - assert!( - reap_until(&mut s, Duration::from_secs(5), |s| s.graveyard.is_empty()), - "the displaced run was never collected" + install_status_record( + &claude_home, + pid, + &dir, + r#""status":"waiting","waitingFor":"permission prompt""#, ); - // The resume command retains the ID after the source capture is deleted. + assert_eq!(current_resume_id(&s.tasks[0]), None); + let entries = saved_entries(&mut s, &config, "typed"); + assert_eq!(entries[0], literal("claude")); + assert!(matches!( + &entries[1].kind, + EntryKind::Managed { agent, resume: Some(_) } if agent == "claude" + )); + let text = std::fs::read_to_string(config.join("sessions/typed.json")).unwrap(); assert!( - !cap.exists(), - "rerun must delete the displaced run's capture file" + !text.contains(CAP_ID), + "the capture-shaped file must be unreachable: {text}" ); } @@ -324,7 +385,7 @@ fn rerun_cannot_read_the_old_runs_stale_capture() { dir.to_path_buf(), &[("FLEETCOM_CONFIG_DIR", &config)], )); - spawn(&mut s, "claude", dir.to_path_buf()); + s.spawn_agent("claude", dir.to_path_buf(), None); let id = s.tasks[0].id; // The old run's final capture becomes the new run's launch ID. let old_cap = s.tasks[0].capture_file.clone().expect("capture file set"); @@ -340,7 +401,7 @@ fn rerun_cannot_read_the_old_runs_stale_capture() { s.apply(Command::Restart { id }); let new_cap = s.tasks[0].capture_file.clone().expect("capture file set"); assert_ne!(new_cap, old_cap, "the fresh run needs its own capture file"); - assert_eq!(s.tasks[0].command, format!("claude --resume '{CAP_ID}'")); + assert_eq!(s.tasks[0].resume_id.as_deref(), Some(CAP_ID)); assert!( !old_cap.exists(), "rerun must delete the displaced run's capture file" @@ -355,14 +416,10 @@ fn rerun_cannot_read_the_old_runs_stale_capture() { ), ); std::fs::write(&old_cap, &stale).unwrap(); - let text = save_and_read(&mut s, &config, "stalecap"); - assert!( - text.contains(&format!("claude --resume '{CAP_ID}'")), - "the fresh run must save the drifted session; got {text}" - ); - assert!( - !text.contains(CAP_OTHER), - "the old run's stale capture must be unreachable; got {text}" + assert_eq!( + saved_resume(&mut s, &config, "stalecap").as_deref(), + Some(CAP_ID), + "the fresh run must save its launch target, never the old run's stale capture" ); } @@ -374,7 +431,7 @@ fn remove_deletes_the_capture_file() { let (bin, runtime) = (dir.join("bin"), dir.join("run")); install_stub(&bin, "claude", &dir); let mut s = sup_ctx(agent_ctx(&bin, &runtime, dir.to_path_buf())); - spawn(&mut s, "claude", dir.to_path_buf()); + s.spawn_agent("claude", dir.to_path_buf(), None); let _ = wait_argv(&mut s, &dir.join("argv")); let id = s.tasks[0].id; wait_for_lifecycle(&mut s, id, |l| l == Lifecycle::Ok); @@ -393,13 +450,13 @@ fn reconnect_with_unchanged_root_preserves_capture_files() { let (bin, runtime) = (dir.join("bin"), dir.join("run")); install_stub(&bin, "claude", &dir); let mut s = sup_ctx(agent_ctx(&bin, &runtime, dir.to_path_buf())); - spawn(&mut s, "claude", dir.to_path_buf()); + s.spawn_agent("claude", dir.to_path_buf(), None); let cap = s.tasks[0].capture_file.clone().expect("capture file set"); std::fs::write(&cap, "{}").unwrap(); // The client reconnects with an identical env and spawns again. s.set_launch_context(agent_ctx(&bin, &runtime, dir.to_path_buf())); - spawn(&mut s, "claude", dir.to_path_buf()); + s.spawn_agent("claude", dir.to_path_buf(), None); assert_eq!(s.tasks.len(), 2); assert!( cap.exists(), @@ -414,16 +471,16 @@ fn returning_to_a_prior_root_preserves_its_live_captures() { let (bin, root_a, root_b) = (dir.join("bin"), dir.join("run-a"), dir.join("run-b")); install_stub(&bin, "claude", &dir); let mut s = sup_ctx(agent_ctx(&bin, &root_a, dir.to_path_buf())); - spawn(&mut s, "claude", dir.to_path_buf()); + s.spawn_agent("claude", dir.to_path_buf(), None); let cap_a = s.tasks[0].capture_file.clone().expect("capture file set"); std::fs::write(&cap_a, "{}").unwrap(); // The client reconnects under root B, spawns, then returns to A and // spawns again. s.set_launch_context(agent_ctx(&bin, &root_b, dir.to_path_buf())); - spawn(&mut s, "claude", dir.to_path_buf()); + s.spawn_agent("claude", dir.to_path_buf(), None); s.set_launch_context(agent_ctx(&bin, &root_a, dir.to_path_buf())); - spawn(&mut s, "claude", dir.to_path_buf()); + s.spawn_agent("claude", dir.to_path_buf(), None); assert_eq!(s.tasks.len(), 3); assert!( @@ -451,7 +508,7 @@ fn remove_deletes_the_capture_file_under_the_spawn_root() { let (bin, root_a, root_b) = (dir.join("bin"), dir.join("run-a"), dir.join("run-b")); install_stub(&bin, "claude", &dir); let mut s = sup_ctx(agent_ctx(&bin, &root_a, dir.to_path_buf())); - spawn(&mut s, "claude", dir.to_path_buf()); + s.spawn_agent("claude", dir.to_path_buf(), None); let _ = wait_argv(&mut s, &dir.join("argv")); let id = s.tasks[0].id; wait_for_lifecycle(&mut s, id, |l| l == Lifecycle::Ok); @@ -461,7 +518,7 @@ fn remove_deletes_the_capture_file_under_the_spawn_root() { // Root B is installed by a newer spawn; a same-id file under it must // survive the A task's removal. s.set_launch_context(agent_ctx(&bin, &root_b, dir.to_path_buf())); - spawn(&mut s, "claude", dir.to_path_buf()); + s.spawn_agent("claude", dir.to_path_buf(), None); let decoy = s.tasks[1] .capture_file .as_deref() @@ -497,7 +554,7 @@ fn spawn_codex_installs_the_notify_and_embedded_overrides() { dir.to_path_buf(), &[("CODEX_HOME", &dir.join("codex_home"))], )); - spawn(&mut s, "codex", dir.to_path_buf()); + s.spawn_agent("codex", dir.to_path_buf(), None); let argv = wait_argv(&mut s, &dir.join("argv")); let ci = argv @@ -543,7 +600,7 @@ fn spawn_grok_pins_an_id_and_injects_nothing_else() { dir.to_path_buf(), &[("FLEETCOM_CONFIG_DIR", &config)], )); - spawn(&mut s, "grok", dir.to_path_buf()); + s.spawn_agent("grok", dir.to_path_buf(), None); let argv = wait_argv(&mut s, &dir.join("argv")); assert_eq!( @@ -570,10 +627,10 @@ fn spawn_grok_pins_an_id_and_injects_nothing_else() { ); assert_eq!(t.resume_id.as_deref(), Some(id.as_str())); - let text = save_and_read(&mut s, &config, "grokpin"); - assert!( - text.contains(&format!("grok --resume '{id}'")), - "the recipe must resume the pinned session; got {text}" + assert_eq!( + saved_entries(&mut s, &config, "grokpin"), + [managed("grok", Some(&id))], + "the recipe must resume the pinned session" ); } @@ -585,7 +642,7 @@ fn spawn_omp_loads_the_capture_extension() { let (bin, runtime) = (dir.join("bin"), dir.join("run")); install_stub(&bin, "omp", &dir); let mut s = sup_ctx(agent_ctx(&bin, &runtime, dir.to_path_buf())); - spawn(&mut s, "omp", dir.to_path_buf()); + s.spawn_agent("omp", dir.to_path_buf(), None); let argv = wait_argv(&mut s, &dir.join("argv")); let ei = argv @@ -620,10 +677,10 @@ fn spawn_omp_loads_the_capture_extension() { } /// Printed hints are display content even after exit and reader EOF. Named -/// saves, recovery snapshots, and reruns retain the capture or launch ID; an -/// uncaptured task retains its exact authored command. +/// saves, recovery snapshots, and reruns carry the capture or launch ID; a +/// task with neither saves and reruns fresh. #[test] -fn printed_resume_hints_do_not_change_saved_recovery_or_rerun_commands() { +fn printed_resume_hints_do_not_change_saved_recovery_or_rerun_targets() { for (tool, hints) in [ ( "claude", @@ -663,8 +720,7 @@ fn printed_resume_hints_do_not_change_saved_recovery_or_rerun_commands() { ], )); s.set_recovery_timing(Duration::from_millis(20), Duration::from_millis(100)); - let authored = format!(" {}/{}\t", bin.display(), tool); - spawn(&mut s, &authored, dir.to_path_buf()); + s.spawn_agent(tool, dir.to_path_buf(), None); s.tasks[0].group = Some("agents".into()); s.tasks[0].name = Some(tool.into()); let expected_id = match tool { @@ -680,10 +736,6 @@ fn printed_resume_hints_do_not_change_saved_recovery_or_rerun_commands() { _ => None, }; assert_ne!(expected_id.as_deref(), Some(CAP_ID)); - let command = match expected_id.as_deref() { - Some(id) => format!("{}/{} --resume '{id}'", bin.display(), tool), - None => authored, - }; assert!( reap_until(&mut s, Duration::from_secs(5), |s| { s.tasks[0].finished.is_some() && s.tasks[0].reader_done() @@ -702,9 +754,9 @@ fn printed_resume_hints_do_not_change_saved_recovery_or_rerun_commands() { let expected = SessionConfig::from([( path::abbreviate(&dir), vec![SessionEntry { - cmd: command.clone(), group: Some("agents".into()), name: Some(tool.into()), + ..managed(tool, expected_id.as_deref()) }], )]); save_and_read(&mut s, &config, "hints"); @@ -731,18 +783,20 @@ fn printed_resume_hints_do_not_change_saved_recovery_or_rerun_commands() { let id = s.tasks[0].id; s.apply(Command::Restart { id }); assert_eq!(s.tasks[0].run, 1, "{tool}: rerun must replace the task"); - assert_eq!(s.tasks[0].command, command, "{tool}: rerun command"); + assert_eq!(s.tasks[0].command, tool, "{tool}: rerun keeps the word"); assert_eq!(s.tasks[0].resume_id, expected_id, "{tool}: rerun ID"); } } /// Rerun latches a recent exit before checking eligibility and retains the -/// explicit launch ID when terminal output names another conversation. +/// explicit launch ID when terminal output names another conversation. The +/// launch ID comes from a loaded recipe: the one way a managed task starts +/// on a chosen conversation. #[test] fn rerun_latches_exit_without_reap_and_preserves_the_launch_id() { use rustix::process::{Pid, WaitId, WaitIdOptions, waitid}; let dir = scratch("rerun_without_reap"); - let (bin, runtime) = (dir.join("bin"), dir.join("run")); + let (bin, runtime, config) = (dir.join("bin"), dir.join("run"), dir.join("config")); install_script( &bin, "grok", @@ -752,12 +806,22 @@ fn rerun_latches_exit_without_reap_and_preserves_the_launch_id() { dir.display() ), ); - let mut s = sup_ctx(agent_ctx(&bin, &runtime, dir.to_path_buf())); - spawn( - &mut s, - format!("grok --resume {CAP_OTHER}"), + let mut s = sup_ctx(agent_ctx_plus( + &bin, + &runtime, dir.to_path_buf(), + &[("FLEETCOM_CONFIG_DIR", &config)], + )); + load_recipe( + &mut s, + &config, + "target", + &dir, + vec![managed("grok", Some(CAP_OTHER))], ); + assert_eq!(s.tasks.len(), 1, "{:?}", notices(&mut s)); + assert!(s.tasks[0].managed); + assert_eq!(s.tasks[0].resume_id.as_deref(), Some(CAP_OTHER)); let id = s.tasks[0].id; let pid = Pid::from_raw(s.tasks[0].pid().unwrap() as i32).unwrap(); assert!( @@ -779,7 +843,7 @@ fn rerun_latches_exit_without_reap_and_preserves_the_launch_id() { std::fs::remove_file(dir.join("argv")).unwrap(); s.apply(Command::Restart { id }); assert_eq!(s.tasks[0].run, 1); - assert_eq!(s.tasks[0].command, format!("grok --resume '{CAP_OTHER}'")); + assert_eq!(s.tasks[0].resume_id.as_deref(), Some(CAP_OTHER)); assert_eq!( wait_argv(&mut s, &dir.join("argv")), ["--resume", CAP_OTHER] @@ -809,7 +873,7 @@ fn resume_id_precedence_registry_over_spawn_under_capture() { ("CLAUDE_CONFIG_DIR", &claude_home), ], )); - spawn(&mut s, "claude", dir.to_path_buf()); + s.spawn_agent("claude", dir.to_path_buf(), None); let injected = s.tasks[0] .resume_id .clone() @@ -819,14 +883,10 @@ fn resume_id_precedence_registry_over_spawn_under_capture() { let pid = s.tasks[0].pid().expect("a live task has a pid"); install_status_record(&claude_home, pid, &dir, r#""status":"idle""#); - let text = save_and_read(&mut s, &config, "registry"); - assert!( - text.contains(&format!("claude --resume '{CAP_ID}'")), - "the registry must beat the injected id; got {text}" - ); - assert!( - !text.contains(&injected), - "the injected id must not survive the registry; got {text}" + assert_eq!( + saved_resume(&mut s, &config, "registry").as_deref(), + Some(CAP_ID), + "the registry must beat the injected id {injected}" ); // A capture-file ID outranks the registry ID. @@ -841,10 +901,10 @@ fn resume_id_precedence_registry_over_spawn_under_capture() { ), ) .unwrap(); - let text = save_and_read(&mut s, &config, "capture"); - assert!( - text.contains(&format!("claude --resume '{CAP_OTHER}'")), - "the capture file must beat the registry; got {text}" + assert_eq!( + saved_resume(&mut s, &config, "capture").as_deref(), + Some(CAP_OTHER), + "the capture file must beat the registry" ); std::fs::write(&done, b"").unwrap(); } @@ -863,13 +923,12 @@ fn capture_stamped_by_a_foreign_process_falls_back_to_the_next_source() { let json = |id: &str, source: &str| { format!(r#"{{"session_id":"{id}","hook_event_name":"SessionStart","source":"{source}"}}"#) }; - // The stub is the task leader only when `/bin/sh -c` replaces itself with - // it, which Ubuntu's `/bin/sh` does not. The test therefore writes the - // task's own capture itself, with the leader's stamp. The stub runs the - // installed hook under an intermediate shell with the same inherited - // environment, as in Claude's daemon. Append `:` to prevent an exec of - // the pipeline's last command: the hook's `$PPID` must be the - // intermediate shell, which is never the leader. + // The test writes the task's own capture itself, with the leader's + // stamp. The stub then runs the installed hook under an intermediate + // shell with the same inherited environment, as in Claude's daemon. + // Append `:` to prevent an exec of the pipeline's last command: the + // hook's `$PPID` must be the intermediate shell, which is never the + // leader. install_script( &bin, "claude", @@ -891,7 +950,7 @@ until [ -e '{d}/done' ]; do sleep 0.05; done"#, ("CLAUDE_CONFIG_DIR", &claude_home), ], )); - spawn(&mut s, "claude", dir.to_path_buf()); + s.spawn_agent("claude", dir.to_path_buf(), None); let pinned = s.tasks[0] .resume_id .clone() @@ -945,18 +1004,19 @@ until [ -e '{d}/done' ]; do sleep 0.05; done"#, Some(CAP_ID), "a foreign capture must fall through to the registry" ); - let text = save_and_read(&mut s, &config, "foreign"); - assert!( - text.contains(&format!("claude --resume '{CAP_ID}'")) && !text.contains(FOREIGN_ID), - "the recipe must never resume the foreign session; got {text}" + assert_eq!( + saved_resume(&mut s, &config, "foreign").as_deref(), + Some(CAP_ID), + "the recipe must never resume the foreign session {FOREIGN_ID}" ); std::fs::write(dir.join("done"), b"").unwrap(); } -/// Two tasks sharing a directory do not own a nearby rollout. Named saves -/// and recovery must preserve both authored commands when capture is silent. +/// Two managed tasks sharing a directory do not own a nearby rollout. Named +/// saves and recovery must write both without a resume ID when capture is +/// silent. #[test] -fn silent_codex_tasks_keep_authored_commands_in_saves_and_recovery() { +fn silent_codex_tasks_save_and_recover_without_a_resume_id() { let dir = scratch("uncaptured_codex"); let (bin, runtime, config) = (dir.join("bin"), dir.join("run"), dir.join("config")); let codex_home = dir.join("codex_home"); @@ -993,10 +1053,8 @@ fn silent_codex_tasks_keep_authored_commands_in_saves_and_recovery() { ], )); s.set_recovery_timing(Duration::from_millis(20), Duration::from_millis(100)); - let commands = ["codex".to_string(), format!(" {}/codex\t", bin.display())]; - for command in &commands { - spawn(&mut s, command, dir.to_path_buf()); - } + s.spawn_agent("codex", dir.to_path_buf(), None); + s.spawn_agent("codex", dir.to_path_buf(), None); assert_eq!(s.tasks.len(), 2); assert!(reap_until(&mut s, Duration::from_secs(5), |s| s .tasks @@ -1019,14 +1077,7 @@ fn silent_codex_tasks_keep_authored_commands_in_saves_and_recovery() { } let expected = SessionConfig::from([( path::abbreviate(&dir), - commands - .iter() - .map(|cmd| SessionEntry { - cmd: cmd.clone(), - group: None, - name: None, - }) - .collect(), + vec![managed("codex", None), managed("codex", None)], )]); save_and_read(&mut s, &config, "silent"); assert_eq!( @@ -1083,18 +1134,6 @@ fn arrive(s: &Supervisor, codex_home: &Path, payload: &str) -> Option { }) } -/// Save under `name` and return the persisted command of the sole task. -fn saved_command(s: &mut Supervisor, config: &Path, name: &str) -> String { - save_and_read(s, config, name); - session::load_in(&config.join("sessions"), name) - .unwrap() - .into_values() - .flatten() - .next() - .expect("the recipe must hold the task") - .cmd -} - /// Install a stand-in for `fleetcom --codex-notify-v1` at `/fleetcom` /// that writes `CAP_ID` over the capture file. The real mode cannot run from /// the unit-test binary, whose `main` is the test harness, so stubs that @@ -1138,23 +1177,22 @@ fn codex_capture_resolves_each_notifying_thread_to_the_root() { ("CODEX_HOME", &codex_home), ], )); - spawn(&mut s, "codex", dir.to_path_buf()); + s.spawn_agent("codex", dir.to_path_buf(), None); assert!(s.tasks[0].resume_id.is_none(), "codex pins no id at launch"); assert_eq!(arrive(&s, &codex_home, &title_turn()), None); assert_eq!( - saved_command(&mut s, &config, "title"), - "codex", + saved_resume(&mut s, &config, "title"), + None, "the title thread must not become the resume target" ); - let resumes_root = format!("codex resume '{CODEX_ROOT}'"); assert_eq!( arrive(&s, &codex_home, &turn_complete(CODEX_CHILD, "pong")).as_deref(), Some(CODEX_ROOT) ); assert_eq!( - saved_command(&mut s, &config, "child"), - resumes_root, + saved_resume(&mut s, &config, "child").as_deref(), + Some(CODEX_ROOT), "a sub-agent's turn must resume the conversation that spawned it" ); assert_eq!( @@ -1162,21 +1200,22 @@ fn codex_capture_resolves_each_notifying_thread_to_the_root() { Some(CODEX_ROOT) ); assert_eq!( - saved_command(&mut s, &config, "root"), - resumes_root, + saved_resume(&mut s, &config, "root").as_deref(), + Some(CODEX_ROOT), "the conversation's own turn must resume it" ); assert_eq!(arrive(&s, &codex_home, &title_turn()), None); assert_eq!( - saved_command(&mut s, &config, "after_title"), - resumes_root, + saved_resume(&mut s, &config, "after_title").as_deref(), + Some(CODEX_ROOT), "a title notification after the root's must leave the root in place" ); } -/// For `codex resume`, preserve the launch ID after rejecting notifications -/// for the title thread or a rollout outside the launch-time Codex home. -/// Prefer a captured root thread in the task's own home over the launch ID. +/// For a codex task loaded on a chosen conversation, preserve the launch ID +/// after rejecting notifications for the title thread or a rollout outside +/// the launch-time Codex home. Prefer a captured root thread in the task's +/// own home over the launch ID. #[test] fn refused_codex_capture_keeps_the_launch_target() { /// A root thread saved under another Codex home. @@ -1196,20 +1235,26 @@ fn refused_codex_capture_keeps_the_launch_target() { ("CODEX_HOME", &codex_home), ], )); - let authored = format!("codex resume '{CAP_ID}'"); - spawn(&mut s, &authored, dir.to_path_buf()); + load_recipe( + &mut s, + &config, + "target", + &dir, + vec![managed("codex", Some(CAP_ID))], + ); + assert_eq!(s.tasks.len(), 1, "{:?}", notices(&mut s)); assert_eq!(s.tasks[0].resume_id.as_deref(), Some(CAP_ID)); assert_eq!(arrive(&s, &codex_home, &title_turn()), None); assert_eq!( - saved_command(&mut s, &config, "title"), - authored, + saved_resume(&mut s, &config, "title").as_deref(), + Some(CAP_ID), "the title thread must not displace the launch target" ); assert_eq!(arrive(&s, &codex_home, &turn_complete(FOREIGN, "hi")), None); assert_eq!( - saved_command(&mut s, &config, "foreign"), - authored, + saved_resume(&mut s, &config, "foreign").as_deref(), + Some(CAP_ID), "a thread outside the task's Codex home must not displace the launch target" ); assert_eq!( @@ -1217,8 +1262,8 @@ fn refused_codex_capture_keeps_the_launch_target() { Some(CODEX_ROOT) ); assert_eq!( - saved_command(&mut s, &config, "root"), - format!("codex resume '{CODEX_ROOT}'"), + saved_resume(&mut s, &config, "root").as_deref(), + Some(CODEX_ROOT), "the task's own root thread must still outrank the launch target" ); } @@ -1275,7 +1320,7 @@ fn home_only_launch_env_targets_the_clients_dot_codex() { dir.to_path_buf(), &[("FLEETCOM_CONFIG_DIR", &config), ("HOME", &home)], )); - spawn(&mut s, "codex", dir.to_path_buf()); + s.spawn_agent("codex", dir.to_path_buf(), None); assert_eq!( s.tasks[0].harness_home.as_deref(), Some(codex_home.as_path()), @@ -1291,15 +1336,9 @@ fn home_only_launch_env_targets_the_clients_dot_codex() { .finished .is_some())); - save_and_read(&mut s, &config, "homeonly"); - let cfg = session::load_in(&config.join("sessions"), "homeonly").unwrap(); assert_eq!( - cfg[&path::abbreviate(&dir)], - vec![SessionEntry { - cmd: "codex".into(), - group: None, - name: None, - }] + saved_entries(&mut s, &config, "homeonly"), + [managed("codex", None)] ); } @@ -1341,7 +1380,7 @@ fn stale_inherited_notify_chain_is_never_executed() { stale.as_os_str().to_os_string(), )); let mut s = sup_ctx(ctx); - spawn(&mut s, "codex", dir.to_path_buf()); + s.spawn_agent("codex", dir.to_path_buf(), None); assert!(reap_until(&mut s, Duration::from_secs(5), |s| s.tasks[0] .finished .is_some())); @@ -1362,9 +1401,10 @@ fn stale_inherited_notify_chain_is_never_executed() { ); } -/// Without a known ID, an agent recipe retains the original command. +/// Without a known ID, a managed entry carries the agent word alone: no +/// `resume` field at all. #[test] -fn agent_save_without_any_id_keeps_the_plain_command() { +fn managed_save_without_any_id_omits_resume() { let dir = scratch("no_id"); let (bin, runtime, config) = (dir.join("bin"), dir.join("run"), dir.join("config")); // No config.toml exists, so notifier routing has nothing to read. @@ -1379,19 +1419,19 @@ fn agent_save_without_any_id_keeps_the_plain_command() { ("CODEX_HOME", &codex_home), ], )); - spawn(&mut s, "codex", dir.to_path_buf()); + s.spawn_agent("codex", dir.to_path_buf(), None); assert!(reap_until(&mut s, Duration::from_secs(5), |s| s.tasks[0] .finished .is_some())); let text = save_and_read(&mut s, &config, "plainagent"); assert!( - text.contains("\"codex\""), - "the plain command must survive; got {text}" + text.contains("\"agent\": \"codex\""), + "the agent word must be written; got {text}" ); assert!( !text.contains("resume"), - "no id exists, so nothing may be rewritten; got {text}" + "no id exists, so no resume field may be written; got {text}" ); } @@ -1437,7 +1477,7 @@ fn config_toml_notify_chains_through_the_injected_script() { dir.to_path_buf(), &[("CODEX_HOME", &codex_home)], )); - spawn(&mut s, "codex", dir.to_path_buf()); + s.spawn_agent("codex", dir.to_path_buf(), None); let argv = wait_argv(&mut s, &dir.join("argv")); assert!( argv.iter().any(|a| a.starts_with("notify=[")), @@ -1494,7 +1534,7 @@ fn unrepresentable_config_notify_suppresses_injection() { dir.to_path_buf(), &[("CODEX_HOME", &codex_home)], )); - spawn(&mut s, "codex", dir.to_path_buf()); + s.spawn_agent("codex", dir.to_path_buf(), None); assert_eq!( notices(&mut s), ["task 1: codex capture unavailable: `notify` config can't be chained"] @@ -1513,7 +1553,7 @@ fn unrepresentable_config_notify_suppresses_injection() { ) .unwrap(); std::fs::remove_file(dir.join("argv")).unwrap(); - spawn(&mut s, "codex", dir.to_path_buf()); + s.spawn_agent("codex", dir.to_path_buf(), None); assert_eq!(notices(&mut s), Vec::::new()); let argv = wait_argv(&mut s, &dir.join("argv")); assert!( @@ -1543,14 +1583,7 @@ fn non_agent_entries_survive_save_as_plain_strings() { "no object form for an unadorned entry; got {text}" ); let cfg = session::load_in(&config.join("sessions"), "plain").unwrap(); - assert_eq!( - cfg[&path::abbreviate(&dir)], - vec![SessionEntry { - cmd: "sleep 30".into(), - group: None, - name: None, - }] - ); + assert_eq!(cfg[&path::abbreviate(&dir)], vec![literal("sleep 30")]); } /// Cadence passes persist changed capture IDs without rewriting stable recipes. @@ -1566,7 +1599,7 @@ fn recovery_cadence_rewrites_on_capture_drift_and_skips_when_static() { &[("FLEETCOM_CONFIG_DIR", &config)], )); s.set_recovery_timing(Duration::from_millis(20), Duration::from_millis(100)); - spawn(&mut s, "claude", dir.to_path_buf()); + s.spawn_agent("claude", dir.to_path_buf(), None); let _ = wait_argv(&mut s, &dir.join("argv")); let rec = config.join("sessions").join("recovery"); @@ -1782,6 +1815,7 @@ fn managed_rerun_resumes_the_captured_id_from_the_binary_path_finds_now() { .unwrap(); std::fs::remove_file(dir.join("argv")).unwrap(); + let old_cap = s.tasks[0].capture_file.clone().unwrap(); s.apply(Command::Restart { id }); let argv = wait_argv(&mut s, &dir.join("argv")); assert_eq!( @@ -1796,6 +1830,14 @@ fn managed_rerun_resumes_the_captured_id_from_the_binary_path_finds_now() { assert!(t.tagged); assert_eq!(t.group.as_deref(), Some("agents")); assert_eq!(t.name.as_deref(), Some("pilot")); + assert!( + reap_until(&mut s, Duration::from_secs(5), |s| s.graveyard.is_empty()), + "the displaced run was never collected" + ); + assert!( + !old_cap.exists(), + "rerun must delete the displaced run's capture file; the ID was read first" + ); // The binary is resolved again at each rerun: a removed one refuses. wait_for_lifecycle(&mut s, id, |l| l == Lifecycle::Ok); @@ -1829,11 +1871,12 @@ fn managed_rerun_without_an_id_starts_fresh() { assert_eq!(s.tasks[0].run, 1); } -/// Session format v1 has no managed entry. A managed task saves as the bare -/// program word or its resume form, and that string loads as a detected -/// literal that resumes the same conversation. +/// A managed task saves as a managed entry carrying its current session ID, +/// and that entry reloads as a managed task that resumes it: the argv leads +/// with the selector and ID, the binary runs as a direct child even under a +/// resident shell, and the group carries over. #[test] -fn managed_task_saves_in_the_v1_resume_form_and_reloads_as_a_detected_literal() { +fn managed_task_saves_as_a_managed_entry_and_reloads_managed() { let dir = scratch("managed_save"); let (bin, runtime, config) = (dir.join("bin"), dir.join("run"), dir.join("config")); // One record directory per stub: the reloaded tasks launch together, and @@ -1843,17 +1886,32 @@ fn managed_task_saves_in_the_v1_resume_form_and_reloads_as_a_detected_literal() for out in [&claude_out, &omp_out] { std::fs::create_dir_all(out).unwrap(); } - install_stub(&bin, "claude", &claude_out); - install_stub(&bin, "omp", &omp_out); - let mut s = sup_ctx(agent_ctx_plus( + // The claude stub plays its SessionStart hook: a capture stamped with + // its own `$$`, which passes the gate only if the stub is the leader. + install_script( &bin, - &runtime, - dir.to_path_buf(), - &[("FLEETCOM_CONFIG_DIR", &config)], + "claude", + &format!( + "printf '%s\\n{{\"session_id\":\"{CAP_OTHER}\"}}\\n' \"$$\" > \"$FLEETCOM_CAPTURE_FILE\"\n\ + printf '%s\\n' \"$@\" > '{out}/argv.'$$'.tmp' && mv '{out}/argv.'$$'.tmp' '{out}/argv'", + out = claude_out.display() + ), + ); + install_stub(&bin, "omp", &omp_out); + let mut ctx = resident_shell_ctx(&bin, &runtime, &dir); + ctx.env.push(( + "FLEETCOM_CONFIG_DIR".into(), + config.as_os_str().to_os_string(), )); + let mut s = sup_ctx(ctx); s.spawn_agent("claude", dir.to_path_buf(), Some("agents".into())); s.spawn_agent("omp", dir.to_path_buf(), None); assert!(s.tasks.iter().all(|t| t.managed)); + // Wait for both first-run records before touching captures or clearing + // them, so a late write from the original launch cannot pass for the + // reloaded one. + wait_argv(&mut s, &claude_out.join("argv")); + wait_argv(&mut s, &omp_out.join("argv")); let claude = s.tasks[0].id; wait_for_lifecycle(&mut s, claude, |l| l == Lifecycle::Ok); std::fs::write( @@ -1869,44 +1927,40 @@ fn managed_task_saves_in_the_v1_resume_form_and_reloads_as_a_detected_literal() path::abbreviate(&dir), vec![ SessionEntry { - cmd: format!("claude --resume '{CAP_ID}'"), group: Some("agents".into()), - name: None, - }, - SessionEntry { - cmd: "omp".into(), - group: None, - name: None, + ..managed("claude", Some(CAP_ID)) }, + managed("omp", None), ] )]) ); - // The saved strings load as literal tasks that detection instruments. - // Wait for both first-run records before clearing them, so a late write - // from the original launch cannot pass for the reloaded one. - wait_argv(&mut s, &claude_out.join("argv")); - wait_argv(&mut s, &omp_out.join("argv")); std::fs::remove_file(claude_out.join("argv")).unwrap(); std::fs::remove_file(omp_out.join("argv")).unwrap(); s.apply(Command::LoadSession { name: "managed".into(), }); - assert_eq!(s.tasks.len(), 4); + assert_eq!(s.tasks.len(), 4, "{:?}", notices(&mut s)); let reloaded = &s.tasks[2]; - assert!(!reloaded.managed, "v1 has no managed entry"); - assert_eq!(reloaded.command, format!("claude --resume '{CAP_ID}'")); - assert!(reloaded.harness.is_some(), "detection instruments the form"); + assert!(reloaded.managed, "a managed entry reloads managed"); + assert_eq!(reloaded.command, "claude"); + assert!(reloaded.harness.is_some()); assert_eq!(reloaded.resume_id.as_deref(), Some(CAP_ID)); assert_eq!(reloaded.group.as_deref(), Some("agents")); - assert!(!s.tasks[3].managed); + assert!(s.tasks[3].managed); assert_eq!(s.tasks[3].command, "omp"); assert!(s.tasks[3].harness.is_some()); + assert!(s.tasks[3].resume_id.is_none()); let claude_argv = wait_argv(&mut s, &claude_out.join("argv")); assert!( claude_argv.starts_with(&["--resume".into(), CAP_ID.into()]), "the reloaded claude entry must resume its captured ID: {claude_argv:?}" ); + assert_eq!( + current_resume_id(&s.tasks[2]).as_deref(), + Some(CAP_OTHER), + "the reloaded stub's `$$` stamp passes the gate: it ran as a direct child" + ); let omp_argv = wait_argv(&mut s, &omp_out.join("argv")); assert_eq!( omp_argv.first().map(String::as_str), @@ -1915,6 +1969,52 @@ fn managed_task_saves_in_the_v1_resume_form_and_reloads_as_a_detected_literal() ); } +/// A recipe naming an agent this host lacks reports it and loads the rest: +/// the literal and the installed agent spawn, the missing one counts as +/// failed. +#[test] +fn load_reports_a_missing_agent_and_loads_the_rest() { + let dir = scratch("load_missing_agent"); + let (bin, runtime, config) = (dir.join("bin"), dir.join("run"), dir.join("config")); + install_stub(&bin, "claude", &dir); + let mut s = sup_ctx(agent_ctx_plus( + &bin, + &runtime, + dir.to_path_buf(), + &[("FLEETCOM_CONFIG_DIR", &config)], + )); + load_recipe( + &mut s, + &config, + "fleet", + &dir, + vec![ + managed("grok", None), + literal("true"), + SessionEntry { + name: Some("pilot".into()), + ..managed("claude", Some(CAP_ID)) + }, + ], + ); + assert_eq!( + notices(&mut s), + [ + "grok not found on PATH, not spawning", + "loaded 'fleet': 2 task(s), 1 failed to spawn" + ] + ); + assert_eq!(s.tasks.len(), 2); + assert!(!s.tasks[0].managed); + assert_eq!(s.tasks[0].command, "true"); + assert!(s.tasks[1].managed); + assert_eq!(s.tasks[1].name.as_deref(), Some("pilot")); + assert_eq!( + wait_argv(&mut s, &dir.join("argv"))[..2], + ["--resume", CAP_ID] + ); +} + /// A managed codex carries the embedded override even when capture is off, /// reports why, and leads a rerun with `resume ` ahead of the overrides. #[test] @@ -2002,7 +2102,7 @@ fn registry_waiting_status_reaches_the_dashboard_preview() { dir.to_path_buf(), &[("CLAUDE_CONFIG_DIR", &claude_home)], )); - spawn(&mut s, "claude", dir.to_path_buf()); + s.spawn_agent("claude", dir.to_path_buf(), None); // Key the record to the task's leader PID. let pid = s.tasks[0].pid().expect("a live task has a pid"); diff --git a/src/supervisor_tests.rs b/src/supervisor_tests.rs index 7ed9062..f216c17 100644 --- a/src/supervisor_tests.rs +++ b/src/supervisor_tests.rs @@ -65,29 +65,16 @@ fn session_config_groups_by_dir_in_spawn_order() { spawn(&mut s, "c", here()); let cfg = s.session_config(); + let literal = |cmd: &str| SessionEntry { + kind: EntryKind::Literal(cmd.into()), + group: None, + name: None, + }; assert_eq!( cfg[&path::abbreviate(&here())], - vec![ - SessionEntry { - cmd: "a".into(), - group: None, - name: None, - }, - SessionEntry { - cmd: "c".into(), - group: None, - name: None, - }, - ] - ); - assert_eq!( - cfg["/tmp"], - vec![SessionEntry { - cmd: "b".into(), - group: None, - name: None, - }] + vec![literal("a"), literal("c")] ); + assert_eq!(cfg["/tmp"], vec![literal("b")]); } /// `tick` emits exactly a `Tasks` snapshot while nothing is watched, and @@ -2398,7 +2385,7 @@ fn list_sessions_includes_recovery_snapshots_newest_first() { let rec = config.join("sessions").join("recovery"); let entry = |cmd: &str| SessionEntry { - cmd: cmd.into(), + kind: EntryKind::Literal(cmd.into()), group: None, name: None, }; @@ -2457,12 +2444,12 @@ fn load_recovery_materializes_the_fleet_and_notices() { dir.to_string_lossy().into_owned(), vec![ SessionEntry { - cmd: "sleep 30".into(), + kind: EntryKind::Literal("sleep 30".into()), group: Some("api".into()), name: None, }, SessionEntry { - cmd: "sleep 31".into(), + kind: EntryKind::Literal("sleep 31".into()), group: None, name: Some("web".into()), }, diff --git a/src/task.rs b/src/task.rs index 31e15cb..1100969 100644 --- a/src/task.rs +++ b/src/task.rs @@ -96,16 +96,18 @@ pub struct Task { pub group: Option, /// Custom display name; `None` means unnamed. pub name: Option, - /// Agent harness selected for session capture. + /// Agent harness of a managed task: the channel its session ID and + /// blocked status are read through. `None` for every literal task, so no + /// harness channel is ever read for one. pub harness: Option<&'static dyn crate::harness::Harness>, /// Harness home resolved from this run's launch environment. pub harness_home: Option, - /// Display-only summary adapter selected from the requested command, - /// independently of session-capture instrumentation. + /// Display-only summary adapter selected from the command's first word, + /// for literal and managed tasks alike. pub summary_adapter: Option<&'static dyn crate::preview::SummaryAdapter>, /// Run number used to give each rerun a distinct capture path. pub run: u32, - /// Session ID injected or recognized at spawn. Capture data or a live + /// Session ID pinned or resumed at spawn. Capture data or a live /// registry record can supersede it. pub resume_id: Option, /// Capture path allocated for this task run. @@ -213,8 +215,7 @@ fn wait_code(status: &rustix::process::WaitIdStatus) -> i32 { /// becomes a process. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Exec { - /// The text as typed (plus any detected-literal suffix), run as - /// `$SHELL -c `. + /// The text as typed, run as `$SHELL -c `. Literal(String), /// `binary` with `args`, no shell between. The task leader is the agent /// process in every login shell: claude's capture gate and registry diff --git a/tests/daemon_resume.rs b/tests/daemon_resume.rs index 45b3d7f..27be740 100644 --- a/tests/daemon_resume.rs +++ b/tests/daemon_resume.rs @@ -1,7 +1,7 @@ -//! Agent resume crosses command parsing, daemon framing, child instrumentation, -//! session persistence, and reload. These tests exercise that complete path -//! with stub `claude` and `codex` executables. An explicit handshake keeps every -//! path inside the test's scratch tree. +//! Agent resume crosses the managed launch, daemon framing, child +//! instrumentation, session persistence, and reload. These tests exercise that +//! complete path with stub `claude` and `codex` executables. An explicit +//! handshake keeps every path inside the test's scratch tree. mod common; @@ -14,8 +14,8 @@ use std::{ }; use common::{ - control_frame, read_frame, shake_hands_env, spawn_frame, start_daemon_raw, stop_daemon, - wait_until, + control_frame, read_frame, shake_hands_env, spawn_agent_frame, spawn_frame, start_daemon_raw, + stop_daemon, wait_until, }; /// Delimiter separating argv records in a stub's append-only output. @@ -271,7 +271,7 @@ fn assert_daemon_namespaced(asset: &Path, daemon_pid: u32, what: &str, argv: &[S /// Save once and return the persisted recipe. Each caller first waits for its /// ID channel, and the daemon scrapes finished tasks before reading IDs. As a -/// result, one save must already contain the resume form. +/// result, one save must already carry the resume ID. fn save_once(stream: &mut UnixStream, recipe: &Path, name: &str) -> String { stream .write_all(&control_frame(&format!( @@ -314,8 +314,14 @@ fn capture_slot(runtime: &Path) -> Option { .map(|(_, contents)| contents) } -/// Claude instrumentation captures an ID without leaking its injected flags -/// into the recipe, then reloads the same conversation. +/// The managed entry a recipe writes for `agent` resuming `id`, as `to_json` +/// pretty-prints it. +fn managed_entry(agent: &str, id: &str) -> String { + format!("{{\n \"agent\": \"{agent}\",\n \"resume\": \"{id}\"\n }}") +} + +/// A managed claude captures an ID without leaking its injected flags into +/// the recipe, then reloads the same conversation. #[test] fn claude_spawn_save_load_resumes_the_conversation() { let s = Scratch::new("claude"); @@ -324,7 +330,9 @@ fn claude_spawn_save_load_resumes_the_conversation() { hello(&mut stream, &s); drain_events(&stream); - stream.write_all(&spawn_frame("claude", &s.work())).unwrap(); + stream + .write_all(&spawn_agent_frame("claude", &s.work())) + .unwrap(); let rec = s.record("claude"); let argv = wait_run(&rec, 0, |a| { a.iter().any(|t| t == "--session-id") && a.iter().any(|t| t == "--settings") @@ -337,7 +345,7 @@ fn claude_spawn_save_load_resumes_the_conversation() { // The pinned ID is stored in `resume_id` at spawn: save once to verify it. let recipe = save_once(&mut stream, &s.recipe("story"), "story"); assert!( - recipe.contains(&format!("claude --resume '{id}'")), + recipe.contains(&managed_entry("claude", &id)), "the recipe must resume the pinned id: {recipe}" ); assert!( @@ -367,8 +375,8 @@ fn claude_spawn_save_load_resumes_the_conversation() { } /// A Codex notification validated by the daemon's own binary persists the -/// resume command, the title thread's later notification leaves it intact, -/// and loading that command reapplies the notifier instrumentation. +/// resume ID, the title thread's later notification leaves it intact, and +/// loading that entry reapplies the notifier instrumentation. #[test] fn codex_capture_file_drives_save_and_load_resumes() { let s = Scratch::new("codex"); @@ -377,7 +385,9 @@ fn codex_capture_file_drives_save_and_load_resumes() { hello(&mut stream, &s); drain_events(&stream); - stream.write_all(&spawn_frame("codex", &s.work())).unwrap(); + stream + .write_all(&spawn_agent_frame("codex", &s.work())) + .unwrap(); let rec = s.record("codex"); let argv = wait_run(&rec, 0, |a| a.iter().any(|t| t.starts_with("notify=["))); let notify = value_after(&argv, "-c").to_string(); @@ -418,7 +428,7 @@ fn codex_capture_file_drives_save_and_load_resumes() { ); let recipe = save_once(&mut stream, &s.recipe("story"), "story"); assert!( - recipe.contains(&format!("codex resume '{CODEX_ID}'")), + recipe.contains(&managed_entry("codex", CODEX_ID)), "the recipe must resume the captured thread: {recipe}" ); @@ -490,7 +500,7 @@ fn codex_notify_mode_runs_headless() { assert_eq!(names, ["task-1-0.json"], "no temporary file may remain"); } -/// A persisted resume command survives daemon replacement and targets the same +/// A persisted managed entry survives daemon replacement and targets the same /// ID afterward. #[test] fn saved_recipe_resumes_across_a_daemon_restart() { @@ -502,13 +512,13 @@ fn saved_recipe_resumes_across_a_daemon_restart() { hello(&mut stream_a, &s); drain_events(&stream_a); stream_a - .write_all(&spawn_frame("claude", &s.work())) + .write_all(&spawn_agent_frame("claude", &s.work())) .unwrap(); let argv = wait_run(&rec, 0, |a| a.iter().any(|t| t == "--session-id")); let id = value_after(&argv, "--session-id").to_string(); let recipe = save_once(&mut stream_a, &s.recipe("overnight"), "overnight"); assert!( - recipe.contains(&format!("claude --resume '{id}'")), + recipe.contains(&managed_entry("claude", &id)), "the recipe must resume the pinned id: {recipe}" ); stop_daemon(&mut daemon_a); @@ -529,8 +539,42 @@ fn saved_recipe_resumes_across_a_daemon_restart() { ); assert!( !argv.iter().any(|t| t == "--session-id"), - "the loaded command is a resume; no second id: {argv:?}" + "the loaded entry is a resume; no second id: {argv:?}" ); stop_daemon(&mut daemon_b); } + +/// A typed `claude` is a literal task on the wire too: the stub runs with no +/// argument, the recipe stores the text as a bare string, and reloading runs +/// the same text again. +#[test] +fn typed_claude_runs_verbatim_and_saves_as_text() { + let s = Scratch::new("literal"); + install_claude_stub(&s); + let (_dir, mut daemon, mut stream) = start_daemon_raw("resume_literal", |_| {}); + hello(&mut stream, &s); + drain_events(&stream); + + stream.write_all(&spawn_frame("claude", &s.work())).unwrap(); + let rec = s.record("claude"); + let argv = wait_run(&rec, 0, |a| a.is_empty()); + assert!(argv.is_empty(), "a literal launch adds nothing: {argv:?}"); + + let recipe = save_once(&mut stream, &s.recipe("typed"), "typed"); + // The scratch path itself contains "resume", so match the JSON keys. + assert!( + recipe.contains("\"claude\"") + && !recipe.contains("\"agent\"") + && !recipe.contains("\"resume\""), + "the recipe must hold the typed text and no managed entry: {recipe}" + ); + + stream + .write_all(&control_frame(r#"{"t":"load","name":"typed"}"#)) + .unwrap(); + let argv = wait_run(&rec, 1, |a| a.is_empty()); + assert!(argv.is_empty(), "the reloaded text runs verbatim: {argv:?}"); + + stop_daemon(&mut daemon); +} From 9d8d0f9c5f7bab8675717fc9d14b1995238f8156 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Mon, 5 Oct 2026 21:21:32 -0700 Subject: [PATCH 07/13] feat(supervisor): enhance session load reporting with detailed task notices --- src/app_tests.rs | 65 +++++++++ src/harness/grok.rs | 6 - src/main.rs | 6 - src/supervisor.rs | 233 ++++++++++++++++++++------------ src/supervisor_capture_tests.rs | 76 ++++++----- src/supervisor_tests.rs | 30 +--- src/ui.rs | 10 -- tests/daemon_resume.rs | 38 +----- 8 files changed, 256 insertions(+), 208 deletions(-) diff --git a/src/app_tests.rs b/src/app_tests.rs index c91d881..cb89a75 100644 --- a/src/app_tests.rs +++ b/src/app_tests.rs @@ -3236,6 +3236,71 @@ fn agent_arrows_step_within_the_matches() { /// The common case, `n` → Tab → Enter: `SpawnAgent` for the first installed /// agent in the invocation directory, the prompt closed, and the core admits /// the task as managed with the program word as its row label. +/// A session load reports every task's launch notice and every failure in +/// the status the app actually displays. The core queues the per-task +/// reasons and the summary in one batch, and `sync` keeps only the last +/// status, so the summary itself must carry them. +#[test] +fn session_load_status_keeps_capture_notices_and_failures() { + let dir = temp("load_status"); + let (bin, config, codex_home) = (dir.join("bin"), dir.join("config"), dir.join("codex")); + for d in [&bin, &codex_home] { + std::fs::create_dir_all(d).unwrap(); + } + write_executable(&bin.join("codex"), "exit 0"); + // A notify route fleetcom cannot chain: codex launches without capture. + std::fs::write(codex_home.join("config.toml"), "notify = [1]\n").unwrap(); + let ctx = crate::protocol::LaunchContext { + env: vec![ + ("PATH".into(), bin.as_os_str().to_os_string()), + ( + path::FLEETCOM_RUNTIME_DIR.into(), + dir.join("run").into_os_string(), + ), + ( + "FLEETCOM_CONFIG_DIR".into(), + config.as_os_str().to_os_string(), + ), + ("CODEX_HOME".into(), codex_home.as_os_str().to_os_string()), + ], + cwd: dir.to_path_buf(), + }; + let managed = |agent: &str| crate::session::SessionEntry { + kind: crate::session::EntryKind::Managed { + agent: agent.into(), + resume: None, + }, + group: None, + name: None, + }; + let cfg = crate::session::SessionConfig::from([( + dir.to_string_lossy().into_owned(), + vec![managed("codex"), managed("grok"), managed("codex")], + )]); + crate::session::save_in(&config.join("sessions"), "fleet", &cfg).unwrap(); + + let mut app = App::new_local_with_ctx(30, 100, ctx); + app.pump(); + app.transport.send(Command::LoadSession { + name: "fleet".into(), + }); + app.pump(); + let status = app.status.clone().unwrap_or_default(); + assert_eq!(app.views.len(), 2, "both codex entries load: {status}"); + for needle in [ + "loaded 'fleet'", + "1 failed to spawn", + "grok not found on PATH", + "codex capture unavailable", + "(2 tasks)", + ] { + assert!( + status.contains(needle), + "{needle:?} missing from {status:?}" + ); + } +} + #[test] fn agent_enter_launches_the_highlighted_agent_managed() { let (mut app, _dir) = stub_claude_fixture("enter"); diff --git a/src/harness/grok.rs b/src/harness/grok.rs index daa3648..4a130b5 100644 --- a/src/harness/grok.rs +++ b/src/harness/grok.rs @@ -46,12 +46,6 @@ mod tests { assert!(resume.env.is_empty()); } - /// The overlay is empty: no flag, no environment, no notice. - #[test] - fn overlay_is_empty() { - assert_eq!(Grok.overlay(&paths(), None), SpawnPlan::default()); - } - #[test] fn parse_capture_is_unconditionally_none() { // No injected channel exists, so no payload is ever trusted. diff --git a/src/main.rs b/src/main.rs index 0f53d19..d76da02 100644 --- a/src/main.rs +++ b/src/main.rs @@ -489,12 +489,6 @@ mod tests { } assert!(parse(&["--codex-notify-v1", "{}", "--scrollback", "5"]).is_err()); assert!(parse(&["--scrollback", "5", "--codex-notify-v1", "{}"]).is_err()); - // The payload is verbatim, even when it looks like a flag; the mode - // itself then refuses it as non-JSON. - assert_eq!( - parse(&["--codex-notify-v1", "--kill"]), - Ok(Invocation::CodexNotify("--kill".into())) - ); assert_eq!( parse(&["--help", "--codex-notify-v1", "{}"]), Ok(Invocation::Help) diff --git a/src/supervisor.rs b/src/supervisor.rs index c1a5735..5f73ee5 100644 --- a/src/supervisor.rs +++ b/src/supervisor.rs @@ -70,6 +70,64 @@ const MAX_COMMAND_LEN: usize = 64 * 1024; /// Conditions counted as skipped by `materialize`. const SKIP_REASONS: &str = "missing dir, task limit, or command too long"; +/// What `materialize` did with a recipe: the counts for the summary line, and +/// the per-entry reasons behind them. A load reports through that one line +/// because the client keeps only the latest status of a poll, so any status +/// queued ahead of the summary would never be seen. +#[derive(Default)] +struct LoadOutcome { + spawned: usize, + skipped: usize, + failed: usize, + /// Launch notices and failure reasons, in load order. + notes: Vec, +} + +impl LoadOutcome { + /// Append the distinct notes to `summary`, each once, with a count when + /// several entries share it: every codex in a recipe reports the same + /// unchainable notifier. + fn annotate(&self, mut summary: String) -> String { + let mut distinct: Vec<(&str, usize)> = Vec::new(); + for note in &self.notes { + match distinct.iter_mut().find(|(n, _)| *n == note) { + Some((_, count)) => *count += 1, + None => distinct.push((note, 1)), + } + } + for (note, count) in distinct { + summary.push_str("; "); + summary.push_str(note); + if count > 1 { + summary.push_str(&format!(" ({count} tasks)")); + } + } + summary + } +} + +/// Build the managed launch of `agent` with `intent`: the registered harness +/// and the binary `launch`'s `PATH` finds now. Either miss is the reason, for +/// the caller to report. +fn resolve_agent( + agent: &str, + launch: &LaunchContext, + intent: Intent, +) -> Result { + let h = harness::registered(agent).ok_or_else(|| format!("no agent named {agent:?}"))?; + Ok(ManagedLaunch { + agent: h, + binary: agent_binary(agent, launch)?, + intent, + }) +} + +/// Resolve `agent` on `launch`'s `PATH`; a miss is the reason. +fn agent_binary(agent: &str, launch: &LaunchContext) -> Result { + let path = env_get(&launch.env, "PATH").unwrap_or_default(); + harness::find_on_path(agent, path).ok_or_else(|| format!("{agent} not found on PATH")) +} + /// Environment variable overriding per-task terminal history depth. pub const FLEETCOM_SCROLLBACK: &str = "FLEETCOM_SCROLLBACK"; @@ -772,7 +830,8 @@ impl Supervisor { /// task's `command` is display and recipe text: a literal's typed text, a /// managed agent's program word. Only a managed launch carries /// instrumentation: a literal's text reaches the shell untouched, whatever - /// it names. + /// it names. The second value is the plan's notice when the launch carries + /// less instrumentation than usual; the caller decides how to report it. fn spawn_task( &mut self, id: u64, @@ -780,7 +839,7 @@ impl Supervisor { launch: &Launch, cwd: &Path, env: &[(OsString, OsString)], - ) -> io::Result { + ) -> io::Result<(Task, Option)> { let mut env = std::borrow::Cow::Borrowed(env); let mut meta = None; let mut notice = None; @@ -840,16 +899,16 @@ impl Supervisor { task.resume_id = resume_id; } task.run = run; - // Report reduced instrumentation only for a task that exists. - if let Some(notice) = notice { - self.status(format!("task {id}: {notice}")); - } - Ok(task) + // Returned rather than queued: a session load reports every task's + // notice in its one summary line, where a separate status would be + // overwritten by the summary in the same client poll. + Ok((task, notice)) } - /// Spawn a task under the next id, normalize its labels, and return the id. - /// The caller enforces the task ceiling and reports failures because direct - /// spawns and session loads handle them differently. + /// Spawn a task under the next id, normalize its labels, and return the id + /// with any launch notice. The caller enforces the task ceiling and reports + /// failures and notices because direct spawns and session loads handle them + /// differently. fn admit( &mut self, launch: &Launch, @@ -857,14 +916,30 @@ impl Supervisor { env: &[(OsString, OsString)], group: Option, name: Option, - ) -> io::Result { + ) -> io::Result<(u64, Option)> { let id = self.next_id; - let mut task = self.spawn_task(id, 0, launch, cwd, env)?; + let (mut task, notice) = self.spawn_task(id, 0, launch, cwd, env)?; task.group = normalize_group(group); task.name = normalize_label(name); self.next_id += 1; self.tasks.push(task); - Ok(id) + Ok((id, notice)) + } + + /// Report one interactive spawn: its notice, then the acknowledgement that + /// selects the new row, or the failure. + fn report_spawn(&mut self, admitted: io::Result<(u64, Option)>) { + match admitted { + Ok((id, notice)) => { + if let Some(notice) = notice { + self.status(format!("task {id}: {notice}")); + } + // Preserve event order: the acknowledgement precedes the next + // `Tasks` snapshot containing this id. + self.events.push(Event::Spawned { id }); + } + Err(e) => self.status(format!("spawn failed: {e}")), + } } /// Whether the fleet has room for one more task, reporting when not. @@ -894,12 +969,8 @@ impl Supervisor { let Some(launch) = self.launch_or_refuse() else { return; }; - match self.admit(&Launch::Literal(command), &cwd, &launch.env, group, None) { - // Preserve event order: the acknowledgement precedes the next - // `Tasks` snapshot containing this id. - Ok(id) => self.events.push(Event::Spawned { id }), - Err(e) => self.status(format!("spawn failed: {e}")), - } + let admitted = self.admit(&Launch::Literal(command), &cwd, &launch.env, group, None); + self.report_spawn(admitted); } /// Launch `agent`, a registered program word, as a managed task with a @@ -912,44 +983,12 @@ impl Supervisor { let Some(launch) = self.launch_or_refuse() else { return; }; - let Some(managed) = self.resolve_agent(agent, &launch, Intent::Fresh) else { - return; + let managed = match resolve_agent(agent, &launch, Intent::Fresh) { + Ok(m) => m, + Err(why) => return self.status(format!("{why}, not spawning")), }; - match self.admit(&Launch::Managed(managed), &cwd, &launch.env, group, None) { - Ok(id) => self.events.push(Event::Spawned { id }), - Err(e) => self.status(format!("spawn failed: {e}")), - } - } - - /// Build the managed launch of `agent` with `intent`: the registered - /// harness and the binary `launch`'s `PATH` finds now. Either miss is a - /// status line and `None`. - fn resolve_agent( - &mut self, - agent: &str, - launch: &LaunchContext, - intent: Intent, - ) -> Option { - let Some(h) = harness::registered(agent) else { - self.status(format!("no agent named {agent:?}, not spawning")); - return None; - }; - let binary = self.agent_binary(agent, launch)?; - Some(ManagedLaunch { - agent: h, - binary, - intent, - }) - } - - /// Resolve `agent` on `launch`'s `PATH`, reporting a miss. - fn agent_binary(&mut self, agent: &str, launch: &LaunchContext) -> Option { - let path = env_get(&launch.env, "PATH").unwrap_or_default(); - let found = harness::find_on_path(agent, path); - if found.is_none() { - self.status(format!("{agent} not found on PATH, not spawning")); - } - found + let admitted = self.admit(&Launch::Managed(managed), &cwd, &launch.env, group, None); + self.report_spawn(admitted); } /// Rerun a finished task in place while preserving its ID, tag, group, and name. @@ -981,8 +1020,9 @@ impl Supervisor { let command; let relaunch = match self.tasks[i].harness { Some(agent) => { - let Some(binary) = self.agent_binary(agent.shape().0, &launch) else { - return; + let binary = match agent_binary(agent.shape().0, &launch) { + Ok(b) => b, + Err(why) => return self.status(format!("{why}, not spawning")), }; let intent = current_resume_id(&self.tasks[i]).map_or(Intent::Fresh, Intent::Resume); @@ -1001,7 +1041,10 @@ impl Supervisor { // run number to isolate the replacement's capture file. let run = self.tasks[i].run + 1; match self.spawn_task(id, run, &relaunch, &cwd, &launch.env) { - Ok(mut fresh) => { + Ok((mut fresh, notice)) => { + if let Some(notice) = notice { + self.status(format!("task {id}: {notice}")); + } fresh.tagged = self.tasks[i].tagged; fresh.group = self.tasks[i].group.clone(); fresh.name = self.tasks[i].name.clone(); @@ -1092,39 +1135,42 @@ impl Supervisor { /// Spawn every entry of a loaded recipe, each in its (existing) dir. /// Missing dirs are skipped rather than spawning tasks doomed to fail on /// chdir. A managed entry goes through the same resolution as the Agent - /// page: an agent with no binary on this context's `PATH` is a status - /// line and a failed entry, the rest still load. Returns `(spawned, - /// skipped, failed)` for the caller's notice, or `None` when no launch - /// context is installed (already refused with its own notice). - fn materialize(&mut self, cfg: &SessionConfig) -> Option<(usize, usize, usize)> { + /// page: an agent with no binary on this context's `PATH` is a reason and + /// a failed entry, the rest still load. Returns the outcome for + /// the caller's one summary line, or `None` when no launch context is + /// installed (already refused with its own notice). + fn materialize(&mut self, cfg: &SessionConfig) -> Option { let launch = self.launch_or_refuse()?; - let (mut spawned, mut skipped, mut failed) = (0usize, 0usize, 0usize); + let mut out = LoadOutcome::default(); for (dir, entries) in cfg { let resolved = path::resolve(&launch.cwd, dir); if !resolved.is_dir() { - skipped += entries.len(); + out.skipped += entries.len(); continue; } for entry in entries { if self.tasks.len() >= self.max_tasks { - skipped += 1; + out.skipped += 1; continue; } let kind = match &entry.kind { EntryKind::Literal(text) => { if text.len() > MAX_COMMAND_LEN { - skipped += 1; + out.skipped += 1; continue; } Launch::Literal(text) } EntryKind::Managed { agent, resume } => { let intent = resume.clone().map_or(Intent::Fresh, Intent::Resume); - let Some(m) = self.resolve_agent(agent, &launch, intent) else { - failed += 1; - continue; - }; - Launch::Managed(m) + match resolve_agent(agent, &launch, intent) { + Ok(m) => Launch::Managed(m), + Err(why) => { + out.failed += 1; + out.notes.push(why); + continue; + } + } } }; // `admit` normalizes the persisted labels before assignment. @@ -1135,13 +1181,19 @@ impl Supervisor { entry.group.clone(), entry.name.clone(), ) { - Ok(_) => spawned += 1, + Ok((_, notice)) => { + out.spawned += 1; + out.notes.extend(notice); + } // Track spawn failures separately from skipped entries. - Err(_) => failed += 1, + Err(e) => { + out.failed += 1; + out.notes.push(format!("spawn failed: {e}")); + } } } } - Some((spawned, skipped, failed)) + Some(out) } /// Run a config loader against the sessions root, prefixing errors with @@ -1176,21 +1228,22 @@ impl Supervisor { }) else { return; }; - let Some((spawned, skipped, failed)) = self.materialize(&cfg) else { + let Some(out) = self.materialize(&cfg) else { return; }; // Report zero tasks only for an empty recipe. let mut parts = Vec::new(); - if spawned > 0 || (skipped == 0 && failed == 0) { - parts.push(format!("{spawned} task(s)")); + if out.spawned > 0 || (out.skipped == 0 && out.failed == 0) { + parts.push(format!("{} task(s)", out.spawned)); } - if skipped > 0 { - parts.push(format!("{skipped} skipped ({SKIP_REASONS})")); + if out.skipped > 0 { + parts.push(format!("{} skipped ({SKIP_REASONS})", out.skipped)); } - if failed > 0 { - parts.push(format!("{failed} failed to spawn")); + if out.failed > 0 { + parts.push(format!("{} failed to spawn", out.failed)); } - self.status(format!("loaded '{name}': {}", parts.join(", "))); + let msg = format!("loaded '{name}': {}", parts.join(", ")); + self.status(out.annotate(msg)); } /// Load a recovery snapshot by stem and suggest saving it as a named session. @@ -1200,18 +1253,18 @@ impl Supervisor { }) else { return; }; - let Some((_, skipped, failed)) = self.materialize(&cfg) else { + let Some(out) = self.materialize(&cfg) else { return; }; // Append optional clauses to the fixed message prefix. let mut msg = String::from("loaded recovery snapshot; save to name it"); - if skipped > 0 { - msg.push_str(&format!(", {skipped} skipped ({SKIP_REASONS})")); + if out.skipped > 0 { + msg.push_str(&format!(", {} skipped ({SKIP_REASONS})", out.skipped)); } - if failed > 0 { - msg.push_str(&format!(", {failed} failed to spawn")); + if out.failed > 0 { + msg.push_str(&format!(", {} failed to spawn", out.failed)); } - self.status(msg); + self.status(out.annotate(msg)); } } diff --git a/src/supervisor_capture_tests.rs b/src/supervisor_capture_tests.rs index faaa152..d855b26 100644 --- a/src/supervisor_capture_tests.rs +++ b/src/supervisor_capture_tests.rs @@ -121,6 +121,17 @@ fn notices(s: &mut Supervisor) -> Vec { .collect() } +/// Assert that `s` queued exactly one status line and that it names `needle`. +/// Match on the distinctive token, not the full sentence, so rewording a +/// message never breaks a test of the behavior behind it. +fn assert_sole_notice(s: &mut Supervisor, needle: &str) { + let got = notices(s); + assert!( + got.len() == 1 && got[0].contains(needle), + "expected one notice naming {needle:?}; got {got:?}" + ); +} + /// Whether a drained event batch acknowledges a spawn. fn acknowledged(events: &[Event]) -> bool { events.iter().any(|e| matches!(e, Event::Spawned { .. })) @@ -1535,10 +1546,7 @@ fn unrepresentable_config_notify_suppresses_injection() { &[("CODEX_HOME", &codex_home)], )); s.spawn_agent("codex", dir.to_path_buf(), None); - assert_eq!( - notices(&mut s), - ["task 1: codex capture unavailable: `notify` config can't be chained"] - ); + assert_sole_notice(&mut s, "capture unavailable"); let argv = wait_argv(&mut s, &dir.join("argv")); assert_eq!( argv, @@ -1754,17 +1762,11 @@ fn managed_spawn_refuses_an_unknown_word_a_missing_binary_and_a_full_fleet() { s.spawn_agent("vim", dir.to_path_buf(), None); let events = s.drain(); assert!(!acknowledged(&events)); - assert_eq!( - notices(&mut sup_with(events)), - ["no agent named \"vim\", not spawning"] - ); + assert_sole_notice(&mut sup_with(events), "no agent named"); s.spawn_agent("claude", dir.to_path_buf(), None); let events = s.drain(); assert!(!acknowledged(&events)); - assert_eq!( - notices(&mut sup_with(events)), - ["claude not found on PATH, not spawning"] - ); + assert_sole_notice(&mut sup_with(events), "not found on PATH"); assert!(s.tasks.is_empty(), "a refused launch creates nothing"); assert!(!runtime.exists(), "a refused launch installs nothing"); @@ -1774,9 +1776,9 @@ fn managed_spawn_refuses_an_unknown_word_a_missing_binary_and_a_full_fleet() { assert!(acknowledged(&s.drain())); assert_eq!(s.tasks.len(), 1); s.spawn_agent("claude", dir.to_path_buf(), None); - assert_eq!(notices(&mut s), ["task limit reached (1), not spawning"]); + assert_sole_notice(&mut s, "task limit"); spawn(&mut s, "sleep 1", dir.to_path_buf()); - assert_eq!(notices(&mut s), ["task limit reached (1), not spawning"]); + assert_sole_notice(&mut s, "task limit"); assert_eq!(s.tasks.len(), 1, "the ceiling holds for both launch kinds"); } @@ -1843,7 +1845,7 @@ fn managed_rerun_resumes_the_captured_id_from_the_binary_path_finds_now() { wait_for_lifecycle(&mut s, id, |l| l == Lifecycle::Ok); std::fs::remove_file(bin.join("claude")).unwrap(); s.apply(Command::Restart { id }); - assert_eq!(notices(&mut s), ["claude not found on PATH, not spawning"]); + assert_sole_notice(&mut s, "not found on PATH"); assert_eq!(s.tasks[0].run, 1, "the finished task is preserved"); } @@ -1969,9 +1971,10 @@ fn managed_task_saves_as_a_managed_entry_and_reloads_managed() { ); } -/// A recipe naming an agent this host lacks reports it and loads the rest: -/// the literal and the installed agent spawn, the missing one counts as -/// failed. +/// A recipe naming an agent this host lacks loads the rest, and the one +/// summary line carries the reason: the client shows only a poll's last +/// status, so a separate line queued before the summary would be lost. +/// Recovery loads report the same way. #[test] fn load_reports_a_missing_agent_and_loads_the_rest() { let dir = scratch("load_missing_agent"); @@ -1997,12 +2000,12 @@ fn load_reports_a_missing_agent_and_loads_the_rest() { }, ], ); - assert_eq!( - notices(&mut s), - [ - "grok not found on PATH, not spawning", - "loaded 'fleet': 2 task(s), 1 failed to spawn" - ] + let got = notices(&mut s); + assert!( + got.len() == 1 + && got[0].contains("1 failed to spawn") + && got[0].contains("grok not found on PATH"), + "one summary naming the missing agent; got {got:?}" ); assert_eq!(s.tasks.len(), 2); assert!(!s.tasks[0].managed); @@ -2013,6 +2016,21 @@ fn load_reports_a_missing_agent_and_loads_the_rest() { wait_argv(&mut s, &dir.join("argv"))[..2], ["--resume", CAP_ID] ); + + let stem = "20990101-000000-1"; + let cfg = SessionConfig::from([( + dir.to_string_lossy().into_owned(), + vec![managed("grok", None)], + )]); + session::save_recovery_in( + &session::recovery_dir(&config.join("sessions")), + stem, + "snapshot", + &cfg, + ) + .unwrap(); + s.apply(Command::LoadRecovery { stem: stem.into() }); + assert_sole_notice(&mut s, "grok not found on PATH"); } /// A managed codex carries the embedded override even when capture is off, @@ -2031,10 +2049,7 @@ fn managed_codex_reports_the_capture_notice_and_leads_a_rerun_with_resume() { &[("CODEX_HOME", &codex_home)], )); s.spawn_agent("codex", dir.to_path_buf(), None); - assert_eq!( - notices(&mut s), - ["task 1: codex capture unavailable: `notify` config can't be chained"] - ); + assert_sole_notice(&mut s, "capture unavailable"); let argv = wait_argv(&mut s, &dir.join("argv")); assert_eq!(argv, ["-c", "features.daemon_auto_start=false"]); let id = s.tasks[0].id; @@ -2046,10 +2061,7 @@ fn managed_codex_reports_the_capture_notice_and_leads_a_rerun_with_resume() { std::fs::write(s.tasks[0].capture_file.as_ref().unwrap(), CAP_ID).unwrap(); std::fs::remove_file(dir.join("argv")).unwrap(); s.apply(Command::Restart { id }); - assert_eq!( - notices(&mut s), - ["task 1: codex capture unavailable: `notify` config can't be chained"] - ); + assert_sole_notice(&mut s, "capture unavailable"); assert_eq!( wait_argv(&mut s, &dir.join("argv")), ["resume", CAP_ID, "-c", "features.daemon_auto_start=false"] diff --git a/src/supervisor_tests.rs b/src/supervisor_tests.rs index f216c17..067d76c 100644 --- a/src/supervisor_tests.rs +++ b/src/supervisor_tests.rs @@ -2150,10 +2150,9 @@ fn launch_context_install_discovers_agents_in_registry_order() { /// `Command::SpawnAgent` admits a managed task for a registered word on the /// context's `PATH`, acking with `Spawned` and reporting `managed` in the -/// snapshot; an unregistered word or a missing binary gets a notice and no -/// task. A literal spawn of the same word stays unmanaged. +/// snapshot. A literal spawn of the same word stays unmanaged. #[test] -fn spawn_agent_admits_a_managed_task_and_refuses_the_rest() { +fn spawn_agent_admits_a_managed_task() { let dir = scratch("spawn_agent"); let bin = dir.join("bin"); std::fs::create_dir_all(&bin).unwrap(); @@ -2199,31 +2198,6 @@ fn spawn_agent_admits_a_managed_task_and_refuses_the_rest() { "the launch must carry the harness argv: {argv:?}" ); - for (agent, needle) in [("vim", "no agent named"), ("codex", "not found on PATH")] { - s.apply(Command::SpawnAgent { - agent: agent.into(), - cwd: dir.to_path_buf(), - group: None, - }); - let evs = s.drain(); - assert!( - evs.iter() - .any(|e| matches!(e, Event::Status(m) if m.contains(needle))), - "{agent}: expected a refusal; got {evs:?}" - ); - assert!( - !evs.iter().any(|e| matches!(e, Event::Spawned { .. })), - "{agent}: a refusal must not ack; got {evs:?}" - ); - } - s.tick(); - assert!( - s.drain() - .iter() - .any(|e| matches!(e, Event::Tasks(v) if v.len() == 1)), - "refusals must add no task" - ); - spawn(&mut s, "claude", dir.to_path_buf()); let literal = spawned_id(&mut s); assert!( diff --git a/src/ui.rs b/src/ui.rs index 6638856..a97499d 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1262,16 +1262,6 @@ mod tests { assert_eq!(spawn_page_hint(3), " enter run · tab agents (3) · esc"); } - /// Name the missing agents only when some are missing. - #[test] - fn agent_empty_text_names_the_missing_agents() { - assert_eq!(agent_empty_text(&[]), " (no installed agent matches)"); - assert_eq!( - agent_empty_text(&["grok", "omp"]), - " (no installed agent matches · grok, omp not found on this host)" - ); - } - /// Directory and group destinations appear only when present. #[test] fn spawn_prompt_decoration_shapes() { diff --git a/tests/daemon_resume.rs b/tests/daemon_resume.rs index 27be740..7dc4e98 100644 --- a/tests/daemon_resume.rs +++ b/tests/daemon_resume.rs @@ -14,8 +14,8 @@ use std::{ }; use common::{ - control_frame, read_frame, shake_hands_env, spawn_agent_frame, spawn_frame, start_daemon_raw, - stop_daemon, wait_until, + control_frame, read_frame, shake_hands_env, spawn_agent_frame, start_daemon_raw, stop_daemon, + wait_until, }; /// Delimiter separating argv records in a stub's append-only output. @@ -544,37 +544,3 @@ fn saved_recipe_resumes_across_a_daemon_restart() { stop_daemon(&mut daemon_b); } - -/// A typed `claude` is a literal task on the wire too: the stub runs with no -/// argument, the recipe stores the text as a bare string, and reloading runs -/// the same text again. -#[test] -fn typed_claude_runs_verbatim_and_saves_as_text() { - let s = Scratch::new("literal"); - install_claude_stub(&s); - let (_dir, mut daemon, mut stream) = start_daemon_raw("resume_literal", |_| {}); - hello(&mut stream, &s); - drain_events(&stream); - - stream.write_all(&spawn_frame("claude", &s.work())).unwrap(); - let rec = s.record("claude"); - let argv = wait_run(&rec, 0, |a| a.is_empty()); - assert!(argv.is_empty(), "a literal launch adds nothing: {argv:?}"); - - let recipe = save_once(&mut stream, &s.recipe("typed"), "typed"); - // The scratch path itself contains "resume", so match the JSON keys. - assert!( - recipe.contains("\"claude\"") - && !recipe.contains("\"agent\"") - && !recipe.contains("\"resume\""), - "the recipe must hold the typed text and no managed entry: {recipe}" - ); - - stream - .write_all(&control_frame(r#"{"t":"load","name":"typed"}"#)) - .unwrap(); - let argv = wait_run(&rec, 1, |a| a.is_empty()); - assert!(argv.is_empty(), "the reloaded text runs verbatim: {argv:?}"); - - stop_daemon(&mut daemon); -} From 6e722ab80838e92cd8bd1406b4abc7fe7372c6a5 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Mon, 5 Oct 2026 23:20:21 -0700 Subject: [PATCH 08/13] feat(docs): update README, agent-resume, commands, and sessions for clarity and consistency --- docs/README.md | 2 +- docs/agent-resume.md | 74 ++++++++++++++++++++++---------------------- docs/commands.md | 10 +++--- docs/sessions.md | 20 ++++++------ 4 files changed, 55 insertions(+), 51 deletions(-) diff --git a/docs/README.md b/docs/README.md index 69861d7..8b8c85d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -169,7 +169,7 @@ The client environment is not persisted. Each client's environment and working d ### Captured IDs -A captured ID enters an agent's argv as one element and a session file as its `resume` field; it is never inserted into a shell command. Before either, accept only lowercase hexadecimal in the `8-4-4-4-12` UUID format. Validate capture payloads, Codex rollout headers, live session records, and session files on load. Never read session IDs from terminal output, and capture only for agents launched from the Agent page: a typed command runs as typed and gets no channel. See [Agent session resume](agent-resume.md#validation-boundary). +Pass a captured ID as one agent argv element and store it as the session file's `resume` field; never insert it into a shell command. Before either use, validate it as lowercase hexadecimal in the `8-4-4-4-12` UUID format. Validate capture payloads, Codex rollout headers, live session records, and session files on load. Never read session IDs from terminal output. Enable capture only for managed agents launched from the Agent page or loaded from managed session entries; run typed commands unchanged without a capture channel. See [Agent session resume](agent-resume.md#validation-boundary). ### Copying text through the terminal diff --git a/docs/agent-resume.md b/docs/agent-resume.md index 3b0dc3c..412eaa7 100644 --- a/docs/agent-resume.md +++ b/docs/agent-resume.md @@ -1,30 +1,30 @@ # Agent session resume -Session files store launch recipes, not process state. A relaunched agent ordinarily starts a new conversation. `fleetcom` keeps the conversation only for agents it launched itself: a managed task, started from the spawn prompt's Agent page, is launched with the argv and environment that let `fleetcom` capture a validated session ID, and that ID is what a save, a recovery snapshot, or a rerun (`r`) reopens. +To reopen an agent conversation from a saved recipe, you need its session ID. No process state is stored in session files, and without an ID, a relaunched agent ordinarily starts fresh. Launch a managed task from the spawn prompt's Agent page to enable ID capture. The validated ID is then used for named saves, recovery snapshots, and reruns (`r`). ## Two kinds of task -Every task is one of two kinds, decided by how it was started, never by what the command says. +The task kind is determined by how you start it, not by the command text: -- **Literal.** You typed a command on the Command page, or a session file stored one. `fleetcom` runs exactly that text under `$SHELL -c` and never edits it. That holds when the text is exactly `claude`: a typed agent gets no instrumentation, no capture, no registry read, and no resume. Rerun runs the same text, and a save stores it. The dashboard still reads its screen: the summary adapter chosen by the command's first word scrapes the same previews it always did. -- **Managed.** You picked an agent on the Agent page (`n`, then `Tab`), or a session file stored a managed entry. `fleetcom` resolves the program word on the connected client's `PATH`, runs the binary directly with no shell between, and owns the session: it builds the argv, injects its capture channel, reads the ID back, and reopens that conversation on rerun and reload. The row shows the program word. +- **Literal.** Type a command on the Command page or load a literal session entry to run its text unchanged under `$SHELL -c`. Even a bare `claude` is run without instrumentation, capture, registry reads, or automatic resume. The same text is used on rerun and save. Screen previews are still available through the display-only summary adapter selected by the command's first word. +- **Managed.** Pick an agent on the Agent page (`n`, then `Tab`) or load a managed session entry. The binary is resolved on the connected client's `PATH` and executed directly without a shell. Launch argv and capture are configured by `fleetcom`; the captured ID is used to reopen the conversation on rerun and reload. The program word is displayed in the task row. -There are no settings for managed launches. An agent that needs flags is a literal command, and gives up management. +There are no settings for managed launches. To supply flags, type a literal command and run without management. ## Workflow -1. Press `n`, or `@` to pick a directory first, then `Tab` for the Agent page. The page lists the supported agents found on the daemon's `PATH`: `claude`, `codex`, `grok`, `omp`. Pick one and press `Enter`. +1. Press `n`, or `@` to pick a directory first, then `Tab` for the Agent page. Choose among the supported agents found on the launch `PATH`: `claude`, `codex`, `grok`, `omp`. Select one and press `Enter`. 2. Work in it. Press `Enter` to attach; press `Ctrl-\` to return to the dashboard. Depending on the agent, an ID is pinned at launch and may be updated from a hook, notifier, extension, or matching live registry record. -3. Press `w`, enter a session name, and press `Enter`. The task is saved as a managed entry naming the agent and its current session ID; see [Sessions](sessions.md#format). -4. Run `fleetcom `, or press `o` in the dashboard, to start new processes from the recipe. A managed entry launches the agent again and resumes the stored ID. +3. Press `w`, enter a session name, and press `Enter` to save the task as a managed entry with the agent word and current session ID; see [Sessions](sessions.md#format). +4. Run `fleetcom `, or press `o` in the dashboard, to start new processes from the recipe. For managed entries, the agents are relaunched with the stored resume IDs. -Press `r` on a finished managed task to rerun it with its best-known ID: the capture, registry, or launch ID. A registry record is still eligible after exit if present. Without any ID (an `omp` saved before its first turn, a `codex` whose notifier could not be chained), the rerun or reload starts a fresh conversation. The task's ID, tag, group, and name are preserved. +Press `r` on a finished managed task to rerun it with its best-known ID: capture, registry, then launch ID. A registry record is still eligible after exit if present. Without any ID (an `omp` saved before its first turn or a `codex` whose notifier could not be chained), you start a fresh conversation on rerun or reload. The task's ID, tag, group, and name are preserved. ## Process identity -A managed agent is the task leader by construction: `fleetcom` executes the binary found on `PATH` directly, with no shell between. Claude's capture gate and registry reader key on the leader PID, so this holds in every login shell, including csh and tcsh, which keep the leader for themselves when a launch goes through `$SHELL -c`. Shebang scripts keep the identity: the kernel runs the interpreter in the same process. A wrapper script that starts the real binary as a child breaks it, the same way a resident shell would. +A managed agent is the task leader because its binary is executed directly, without a shell. Claude capture and registry records are validated against that leader PID. This works regardless of your login shell, including csh and tcsh, under which the shell would remain the leader when using `$SHELL -c`. For a shebang script, the interpreter runs in the same process, preserving the PID. For a wrapper that starts the real binary as a child, the wrapper is the leader instead, just as with a resident shell. -A managed agent inherits the environment of the `fleetcom` client it was launched from. Every spawn runs with the connected client's environment, sent at connect and reconnect, and that client's shell sourced its startup files before the environment was captured. A dotfile edit takes effect only after a client is started from a new shell. Reruns use the context current at the rerun. Verified 2026-10-05 with marker exports in a scratch `ZDOTDIR`: Claude Code 2.1.289's tool commands saw the launch environment plus `.zshenv` exports, and codex-cli 0.160.1 ran each command as `/bin/zsh -lc`, so neither loses anything a shell trampoline would have added. +Managed agents are launched with the connected client's environment, sent at connect and reconnect. That environment was captured after sourcing the client's shell startup files. To apply a dotfile edit, start a client from a new shell. On rerun, the current launch context is used. This was verified on 2026-10-05 with marker exports in a scratch `ZDOTDIR`: in Claude Code 2.1.289 tool commands, the launch environment and `.zshenv` exports were available; in codex-cli 0.160.1, each command was run as `/bin/zsh -lc`. In those probes, no environment additions from an intermediate shell were lost. ## Capture state and isolation @@ -37,7 +37,7 @@ For each supervisor installation, a private mode-`0700` `/-` n - `omp-capture.js`, mode `0600`: imported as a module, so no executable bit is required - `task--.json` capture paths -The random nonce isolates concurrent supervisors and prevents PID reuse from selecting an existing namespace. Each rerun also gets a distinct run number, so a displaced process cannot overwrite the replacement run's capture file. Every other root entry is left unchanged during installation. A managed launch whose assets cannot be installed is refused: without the overlay, a `codex` would hand its conversation to the shared server. +Use a random nonce to separate concurrent supervisors and avoid reusing a namespace after PID reuse. Use a distinct run number on each rerun so a displaced process cannot overwrite the replacement's capture file. Leave other root entries unchanged during installation. Refuse a managed launch if its assets cannot be installed: without the overlay, a `codex` conversation would run in the shared server. ## Evidence sources @@ -47,13 +47,13 @@ All validation on 2026-10-04 was performed on macOS; Linux was not tested. ### `claude` -Claude accepts an ID at launch. For a fresh launch, the harness therefore generates a v4 UUID and passes it ahead of the settings overlay: +You can specify a Claude ID at launch. For a fresh managed launch, a v4 UUID is generated and passed before the settings overlay: ```text --session-id --settings /claude-settings.json ``` -A resume names its conversation with `--resume ` instead, and only `--settings` follows. Two keys are layered over your settings for the launched process alone; your settings files are not modified. +To resume, pass `--resume ` followed by `--settings`. Two keys are layered over your settings for the launched process alone; your settings files are not modified. With the first key, `"disableAgentView": true`, agent view is disabled: `claude agents`, `--bg`, `/background`, and Claude's on-demand daemon. With agent view enabled, a conversation can be handed off to that daemon, outside the task's process. In a managed task, you therefore cannot background a conversation: `/background` is unavailable, you cannot park the conversation by pressing Left arrow on an empty prompt, and with `/fork` you start a sub-agent inside the task's process instead of a background session. @@ -67,15 +67,15 @@ The first line of the capture file is the parent Claude process's PID; the rest Claude session records are also available at `/sessions/.json`, one per session. The direct path for the task leader's PID is read. -A record is accepted only when its `kind` is `interactive` and its `pid`, `cwd`, and `startedAt` match the task. The PID must match the filename, the working directories must be identical or resolve to the same path, and the process start must fall within 30 seconds of the task spawn. Missing, malformed, or mismatched records are ignored. A matching record's `waiting` status is also mapped to the top tier of the [preview cascade](commands.md#peek); the preview is unchanged for other statuses. A literal `claude` never gets this tier: its blocked status comes from the screen alone. +A record is accepted only when its `kind` is `interactive` and its `pid`, `cwd`, and `startedAt` match the task. The PID must match the filename, the working directories must be identical or resolve to the same path, and the process start must fall within 30 seconds of the task spawn. Missing, malformed, or mismatched records are ignored. A matching record's `waiting` status is mapped to the top tier of the [preview cascade](commands.md#peek); the preview is unchanged for other statuses. For literal `claude` tasks, blocked status is determined from the screen alone, without this registry tier. -Both hook validation and registry lookup use the task leader's PID, which a managed launch makes the Claude process by construction. A stamp from any other PID is rejected and the next source is tried, so a conversation selected after launch, including through `/clear`, is followed through the registry. +Validate hook captures and look up registry records using the task leader's PID. With direct execution in a managed launch, this is the Claude process. Reject other PID stamps and try the next source, so you can still follow a conversation selected after launch, including through `/clear`, through its registry record. Live validation on 2026-10-04 was performed with Claude Code 2.1.289. With agent view enabled, after using `/fork`, `/background`, or Left arrow on an empty prompt, the capture file was written by a process other than the task. When rerunning with such an ID, the exit status was 1 with "… is running in the background. Run `claude attach ` …". After pressing Left arrow, the original ID was still in the registry; on rerun with that ID, a snapshot was reopened without the later turns. With `disableAgentView` in the overlay, no handoffs were observed. The sub-agent started through `/fork ` was run in-process, and the same ID was present in the capture file, registry, and spawn-time metadata. The task's PID was stamped in the capture at startup and again after `/clear`. ### `codex` -You cannot choose a Codex ID at launch. Two overrides ride every managed launch, after `resume ` when resuming: +You cannot choose a Codex ID at launch. Add these overrides for managed launches, after `resume ` when resuming; omit the notify override when capture is unavailable, as described below: ```text -c 'notify=["/codex-notify.sh"]' -c 'features.daemon_auto_start=false' @@ -85,35 +85,35 @@ Since Codex 0.157.0, running plain `codex` attaches to a shared background serve With embedded mode, the conversation is kept in the task's process. For a managed task, the shared server is never used: you cannot find the task in `codex agents` or reach it through `codex queue`. The embedded override is therefore added on every managed launch, including those where capture is unavailable: without it, killing the task would kill only the TUI client and leave the conversation running in the shared server. -After each turn, Codex runs the notify script with the `agent-turn-complete` JSON as its argument. Notifications are emitted for several threads in one Codex process: the conversation on screen, each spawned sub-agent, and a hidden thread used to title a new session. Only the first is the task's conversation, so do not use the notified `thread-id` directly. The title thread's notification lands before the root's in some sessions and after it in others, so a notification must be validated when it arrives, not when the capture file is read: a later title notification written verbatim would erase the root. +After each turn, the notify script is invoked with `agent-turn-complete` JSON as its argument. Notifications are emitted for several threads in one Codex process: the conversation on screen, each spawned sub-agent, and a hidden title thread. Only the first is the task's conversation, so do not use the notified `thread-id` directly. Validate notifications at arrival: title notifications have been observed both before and after root notifications, and writing a later title payload verbatim would overwrite the accepted root. -The script therefore runs `"$FLEETCOM_BINARY" --codex-notify-v1 "$1"`, an internal mode of the daemon's own binary. The mode accepts only an `agent-turn-complete` payload with a strict UUID `thread-id`. It resolves the Codex home from its own environment as at launch (`CODEX_HOME`, then `$HOME/.codex`), finds the thread's rollout under `/sessions///
/`, and reads its first line to resolve the root of the thread's session tree: the thread's own ID for a root, or the root ID recorded in a sub-agent's header. When the thread is notified, its rollout and header are already on disk. Accept a thread only when exactly one rollout is named for it, with a header in either the root format or the sub-agent format with a root ID. No rollout is saved for the title thread, so always reject it. +Validate through `"$FLEETCOM_BINARY" --codex-notify-v1 "$1"`, an internal mode of the daemon's binary. Accept only an `agent-turn-complete` payload with a strict UUID `thread-id`. Resolve the Codex home from the launch environment (`CODEX_HOME`, then `$HOME/.codex`), find the notified thread's rollout under `/sessions///
/`, and read its first line. Use the thread's own ID for a root, or the root ID recorded in a sub-agent's header. The rollout and header are already on disk at notification time. Accept the thread only when exactly one rollout is named for it and the header matches one of those formats. No rollout is saved for the title thread, so always reject it. -On acceptance, the mode writes the bare root UUID, with no newline, to `..tmp` beside the capture file and renames it over `FLEETCOM_CAPTURE_FILE`, so a reader never sees a partial file. On refusal it writes nothing: the slot only ever moves from one accepted root to another. It prints nothing; its exit status is informational, and the script ignores it. After a completed turn in the resumed conversation, an in-TUI session change is captured this way. +On acceptance, write the bare root UUID without a newline to `..tmp` beside the capture file, then rename it over `FLEETCOM_CAPTURE_FILE` to prevent reads of a partial file. On refusal, preserve the slot without writing. Print nothing and ignore the informational exit status in the calling script. In-TUI session changes are captured after a completed turn in the resumed conversation. -The capture file format is frozen as v1: exactly one bare UUID. When the file is read, accept it only when its whole contents pass the UUID check, with no trimming. Nothing is reparsed and no rollout is read at that point, so an accepted root stays accepted after Codex compresses old rollouts, and the notification's conversation text never reaches disk. An older script's JSON payload fails that check and is refused: the safe fallback, since the launch ID or no ID is used instead. +Keep the v1 capture format fixed: exactly one bare UUID. On read, require a valid UUID across the entire contents, without trimming. Do not reparse the notification or read the rollout: the root remains accepted after rollout compression, and no conversation text is written to the capture file. Reject an older script's JSON payload and fall back to the launch ID, or no ID if none is known. Look up only the thread ID from the task's own notification. List the dated directories to find its rollout, then read only the first line. Do not infer ownership from other sessions in the store. -`FLEETCOM_BINARY` is the daemon's own executable, probed at each `codex` spawn: it must be an existing regular file with an execute bit. On Linux, `current_exe` reads `/proc/self/exe`, which becomes ` (deleted)` once the binary is replaced on disk under a running daemon; the path then fails the probe. On macOS the path survives a reinstall and names the new binary, so an older release there degrades to an unrecognized flag and no write. Either way, the chained notifier still runs. +Set `FLEETCOM_BINARY` to the daemon's executable and probe it at each `codex` spawn for an existing regular file with an execute bit. On Linux, the path read through `/proc/self/exe` ends in ` (deleted)` after the binary is replaced under a running daemon; reject that nonexistent path. On macOS, the same path refers to the newly installed binary. If replaced with an older release, the notify flag is unrecognized and no capture is written. In either case, continue running the chained notifier. -To preserve a configured notifier, read bare top-level keys in `$CODEX_HOME/config.toml` up to the first table header. For a one-line `notify` array of non-empty basic strings, invoke the notifier after the validation step. Store its argv in `FLEETCOM_NOTIFY_CHAIN`, joined by newlines, and append the notification payload before invocation. The script `exec`s the chain whatever happens to fleetcom's part: a missing binary, a refusal, or a crash. With an absent setting or empty array, run only the validation step. +To preserve a configured notifier, read bare top-level keys in `$CODEX_HOME/config.toml` up to the first table header. For a one-line `notify` array of non-empty basic strings, invoke the notifier after validation. Store its argv in `FLEETCOM_NOTIFY_CHAIN`, joined by newlines, and append the notification payload before invocation. Exec the chain even if the validation binary is missing, refuses the payload, or crashes. With an absent setting or empty array, run only validation. -The notify override and its environment are injected only when the configured route can be chained and the binary passes the probe. Do not inject them for empty arguments, newlines, or NUL: these cannot be represented in the chain encoding. Also skip them when `config.toml` exists but cannot be read, or when a line above the first table header is not a bare key assigned a recognized single-line value. In each case the launch carries the embedded override alone, no ID is captured, and a status line says why: `` `notify` config can't be chained ``, or `fleetcom binary replaced; restart the daemon`. +Inject the notify override and environment only when the configured route can be chained and the binary passes the probe. Do not inject them for empty arguments, newlines, or NUL, which cannot be represented in the chain encoding. Also skip them when `config.toml` exists but cannot be read, or when a line above the first table header is not a bare key assigned a recognized single-line value. In each case, launch with only the embedded override and no capture. Report the reason on the status line: `` `notify` config can't be chained ``, or `fleetcom binary replaced; restart the daemon`. Live validation on 2026-09-28 was performed with the official macOS arm64 Codex 0.157.1 and 0.158.0 executables, isolated homes, and a scripted loopback Responses server. In both releases, the notification JSON was written by the capture script of that date, which copied it verbatim, and passed to the configured notifier. After saving and loading through foreground fleetcom, the same thread ID was present and its history was reopened. Both releases were tested with `--no-daemon`. The ID was also captured and saved in separate default-launch probes, but no managed Codex daemon was installed or running in those fresh homes. Real-account behavior was not verified. The [recorded terminal fixtures](../tests/corpus/codex_0158_terminal/README.md) preserve resumed-history and clipboard output from those runs. Offline replay checks the recorded display and OSC 52 forwarding; it does not run the notifier or execute save/load. The separate `tests/daemon_resume.rs` integration tests exercise session resume with stub agents. -Live validation on 2026-10-04 was performed with codex-cli 0.160.0 over nine TUI sessions. Three thread IDs were reported from one process: the unsaved title thread, a sub-agent, and the root. The title thread's notification arrived before the root's in three sessions and after it in six, and no rollout appeared for it even minutes later. For a root or sub-agent turn, the thread's rollout header was on disk at least 2.5 s before its notification, and `task_complete` landed 21–256 ms before it. When resuming the title thread, "No saved session found" was reported; for the sub-agent, "cannot resume an unloaded multi-agent v2 sub-agent through its parent" was reported. With the notify override alone, `⚠ 1 warning` was displayed in the footer; with both overrides, none was displayed. With an unknown `features.*` key, startup completed with an unrecognized-setting warning. No Codex version predating `daemon_auto_start` was tested. On the same date, 493 local rollout headers from Codex 0.135.0 through 0.160.0 were examined: `id` and `session_id` were present in each. In Codex 0.140.0 and 0.141.0, a sub-agent's own ID was stored as its `session_id`; that format is rejected. +Live validation on 2026-10-04 was performed with codex-cli 0.160.0 over nine TUI sessions. Three thread IDs were reported from one process: the unsaved title thread, a sub-agent, and the root. The title thread's notification arrived before the root's in three sessions and after it in six, and no rollout appeared for it even minutes later. For a root or sub-agent turn, the thread's rollout header was on disk at least 2.5 s before its notification, and `task_complete` was recorded 21–256 ms before it. When resuming the title thread, "No saved session found" was reported; for the sub-agent, "cannot resume an unloaded multi-agent v2 sub-agent through its parent" was reported. With the notify override alone, `⚠ 1 warning` was displayed in the footer; with both overrides, none was displayed. With an unknown `features.*` key, startup completed with an unrecognized-setting warning. No Codex version predating `daemon_auto_start` was tested. On the same date, 493 local rollout headers from Codex 0.135.0 through 0.160.0 were examined: `id` and `session_id` were present in each. In Codex 0.140.0 and 0.141.0, a sub-agent's own ID was stored as its `session_id`; that format is rejected. ### `grok` -You can specify a Grok ID at launch, but cannot inject a live-capture channel. A fresh launch passes `--session-id `; a resume passes `--resume `. Nothing else rides along. +You can specify a Grok ID at launch, but cannot inject a live-capture channel. Pass `--session-id ` for a fresh launch or `--resume ` to resume. No overlay is added. ### `omp` -You cannot pin an omp ID at launch: no `--session-id` flag is available, and an existing session is required for `--resume`. Every managed launch therefore loads the extension and pins nothing; a resume names its conversation with `--resume ` first: +You cannot pin an omp ID at launch: no `--session-id` flag is available, and an existing session is required for `--resume`. Load the extension on every managed launch without pinning an ID. To resume, pass `--resume ` before the extension argument: ```text -e /omp-capture.js @@ -128,7 +128,7 @@ At `agent_end`, after every turn, report two changes without dedicated events: s As a result: -- For a fresh `omp` task, no ID is captured until the end of the first turn. Save before then and the entry carries no ID: it reloads fresh. +- For a fresh `omp` task, no ID is captured until the end of the first turn. If you save before then, no ID is stored, so you start fresh on reload. - Before omp 18.3.2, `ctx.agent` is unavailable, so no session ID is captured: an `omp` task is always saved without an ID. - After switching or branching to a session without a file on disk, continue using the previous ID until the end of the next turn. @@ -146,11 +146,11 @@ Different conversation IDs may be available from different channels during one m If a capture is rejected, try the next source. Use the same precedence for named saves, recovery snapshots, and reruns. Do not infer conversation ownership from session stores: you cannot determine the owning task from a nearby transcript or rollout. -Session IDs are never read from terminal output: another conversation's valid UUID may be present in examples, quoted commands, or tool output. An ID available only in an exit hint is not recovered. Without a capture, registry, or launch ID, the entry carries no ID and the next launch is fresh. +Never read session IDs from terminal output: another conversation's valid UUID may appear in examples, quoted commands, or tool output. Do not recover an ID from an exit hint. Without a capture, registry, or launch ID, save no ID and start fresh on the next launch. A registry ID is preferred over the spawn pin because it may have been selected after launch, including through `/clear`. A capture-file ID is preferred over the registry ID. -On save, a managed task is written as `{ "agent": "", "resume": "" }`. On rerun and reload, the harness places the selector and the ID first in the argv: +Save a managed task as `{ "agent": "", "resume": "" }`. On rerun and reload, place the selector and ID first in argv: ```text claude --resume @@ -159,19 +159,19 @@ grok --resume omp --resume ``` -The ID is one argv element; no shell parses it. On rerun, the run number is incremented before the replacement is spawned, isolating it from capture data for the displaced run. +Pass the ID as one argv element without shell parsing. Before spawning a replacement on rerun, increment the run number to isolate its capture from the displaced run. ## Validation boundary -A captured ID enters an agent's argv as one element and a session file as the `resume` field. Before either, accept only lowercase hexadecimal characters in the `8-4-4-4-12` UUID format. Validate capture payloads, Codex rollout headers, registry records, and session files on load. Ignore malformed values from capture channels; refuse a session file that carries one. Conversation ownership cannot be determined from UUID validity alone. +Pass a captured ID as one agent argv element and store it in the session file's `resume` field. Before either use, validate it as lowercase hexadecimal in the `8-4-4-4-12` UUID format. Validate capture payloads, Codex rollout headers, registry records, and session files on load. Ignore malformed values from capture channels; refuse session files with malformed IDs. UUID validity alone does not establish conversation ownership. ## Extending capture -Implement the `Harness` trait in [`src/harness/mod.rs`](../src/harness/mod.rs) for each tool. Register it in that file's `AGENTS` table: the one registry the Agent page, session files, and the summary adapters read. Include a `SummaryAdapter` for the dashboard preview with each entry; see the implementations in [`src/harness/summary.rs`](../src/harness/summary.rs). Separate launch construction from evidence collection through these methods: +Implement `Harness` in [`src/harness/mod.rs`](../src/harness/mod.rs) for each tool. Register it in that file's `AGENTS` table for use in the Agent page, session loading, and summary selection. Include a `SummaryAdapter` for the dashboard preview with each entry; see [`src/harness/summary.rs`](../src/harness/summary.rs). Separate launch construction from evidence collection through these methods: -- `shape`: supply the program word and resume selector. The word names the tool on the Agent page and in session files; `harness::intent_args` places the selector and the ID first in a resuming argv. -- `session_flag`: name the flag that pins a session ID on a fresh launch, or return `None` (the default) when the tool assigns its own. `harness::plan` builds the conversation-selecting argv from this flag and the resume selector. -- `overlay`: return the argv elements, environment entries, and optional notice every launch of the tool carries after the conversation selection; the supervisor reports the notice on the status line once the task spawns. The elements are passed to the binary directly. +- `shape`: supply the program word and resume selector. Use the word to identify the tool on the Agent page and in session files. In `harness::intent_args`, place the selector and ID first in resume argv. +- `session_flag`: specify the flag for pinning a session ID on a fresh launch, or return `None` (the default) for tool-assigned IDs. In `harness::plan`, build the conversation-selection argv from this flag and the resume selector. +- `overlay`: return argv elements to append after conversation selection, environment entries, and any launch notice. Pass the elements directly to the binary and report the notice through the supervisor after a successful spawn. - `parse_capture`: read an ID from the capture file's contents. The task leader's PID and the launch-time home are supplied; when other processes can reach the capture channel, use them to refuse a payload the task's own process did not write. - `live_session_id`: read the ID published on disk for a live session. Return `None` by default when no registry is available. - `live_blocked_status`: read that registry for blocked-on-user status. Return preview text, never an ID; return `None` by default. @@ -186,6 +186,6 @@ The launch environment is passed to `resolve_home` by the supervisor. For Claude | `FLEETCOM_RUNTIME_DIR` | Explicit capture-asset root as well as the daemon runtime override. | | `FLEETCOM_CAPTURE_FILE` | Per-run capture file used by the injected hook, notifier, or extension module. | | `FLEETCOM_NOTIFY_CHAIN` | Newline-joined argv for the configured Codex notifier; empty when none is active. | -| `FLEETCOM_BINARY` | The daemon's own executable, which the injected Codex notifier runs as `--codex-notify-v1` to validate each notification; absent when that path is unusable. | +| `FLEETCOM_BINARY` | Daemon executable used for `--codex-notify-v1` validation by the injected notifier; absent when the path is unusable. | | `CLAUDE_CONFIG_DIR` | Claude home holding the `sessions/.json` registry; defaults to `$HOME/.claude`. | | `CODEX_HOME` | Codex home holding `config.toml` for notify routing and the `sessions/` rollouts; defaults to `$HOME/.codex`. | diff --git a/docs/commands.md b/docs/commands.md index 741d53c..2a529cf 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -34,7 +34,7 @@ Choose the operating mode with launch arguments. Use keys to control the dashboa | `Ctrl-]` | Attach to the flagship | | `g` | Assign the selected task to a group (through the group picker) | | `R` | Rename the selected task: a display name shown in place of the command | -| `r` | Rerun a finished task; a managed agent resumes its conversation | +| `r` | Rerun a finished task; resume the conversation for a managed agent | | `X` | Kill a running task (`TERM`, then `KILL` after 2 s), or remove a finished one | | `w` | Save the current tasks as a session | | `o` | Load a saved session or a recovery snapshot (through the [session picker](#the-o-session-picker)) | @@ -111,7 +111,7 @@ Press uppercase `X` to kill a running task or remove a finished one. On removal, Press `r` to rerun a finished task. Running tasks cannot be rerun without first being killed and are left untouched. -`fleetcom` starts the replacement in the same directory, using the requesting client's environment. A literal task reruns its exact text. A managed agent is launched again from whatever `PATH` finds now and resumes its captured conversation when an ID is known; see [agent session resume](agent-resume.md). +On rerun, the replacement is started in the same directory with the requesting client's environment. For a literal task, the exact command text is run again. For a managed agent, the binary is resolved on the current `PATH`, and the captured conversation is resumed when an ID is known; see [agent session resume](agent-resume.md). On rerun, the task's ID, `◆` tag, group, name, and spawn order are preserved; its clock and screen are reset. Under lifecycle sorting, the restarted task may be listed in another section. You can also rerun inside peek without closing the overlay. @@ -164,9 +164,11 @@ The daemon removes control characters and surrounding whitespace from display na ## The `n` spawn prompt -Press `n` to run something in the invocation directory, or pick the directory first with `@`. The prompt has two pages. The Command page runs the typed text exactly as typed, through your shell. Press `Tab` (or `Shift-Tab`) for the Agent page: the supported agent CLIs found on the daemon's `PATH`, in a fixed order. `Enter` launches the highlighted one as a managed task: `fleetcom` builds the launch itself and owns the agent's session, see [agent session resume](agent-resume.md). Typing filters the list by a case-insensitive substring of the program name, so `x` finds `codex`; the first match is highlighted. Use `↑`/`↓` to move the highlight, `Enter` to launch in the prompt's directory and group, or `Esc` to cancel. When nothing matches, the prompt says so and names any supported agents not found on this host. +Press `n` to open the spawn prompt in the invocation directory, or pick a directory first with `@`. On the Command page, enter text to run unchanged through your shell. Press `Tab` or `Shift-Tab` for the Agent page, where the supported CLIs found on the launch `PATH` are listed in a fixed order. Select an agent and press `Enter` to start a managed task, with argv and capture configured by `fleetcom`; see [agent session resume](agent-resume.md). -Each page keeps its own text, and the filter is empty every time the prompt opens, so `Tab` always shows the full list first. With no agents found, `Tab` is unbound and omitted from the hint. +Type a case-insensitive substring of a program name to filter the list: for example, `x` to find `codex`. The first match is highlighted. Use `↑`/`↓` to select, `Enter` to launch in the prompt's directory and group, or `Esc` to cancel. If nothing matches, the missing supported agents are listed in the prompt. + +Your command and filter text are preserved separately across page changes. Each time you open the prompt, the filter is cleared, so you can press `Tab` to view the full agent list. With no agents found, `Tab` is unbound and omitted from the hint. ## The `@` directory picker diff --git a/docs/sessions.md b/docs/sessions.md index 60a61bb..da303b1 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -18,10 +18,10 @@ The filename is derived from the session name: leading and trailing whitespace i ## Format -A session file is a JSON object with three fields. `version` identifies the format version, currently 2. `name` holds the session name as typed, trimmed but not sanitized. `dirs` maps each working directory to an ordered list of entries. There are two kinds of entry, matching the two kinds of task: +A session file is a JSON object with three fields: `version` for the format version, currently 2; `name` for the typed session name, trimmed but not sanitized; and `dirs` for an ordered list of entries per working directory. Use one of two entry forms, according to the task's launch mode: -- A **literal** entry stores a typed command. Without a group or name it is a bare string; with either it is an object with `cmd` plus the optional `group` and `name` fields. On load the text runs exactly as stored, through your shell, whatever it names: a stored `"claude"` is a literal `claude`. -- A **managed** entry stores an agent launched from the spawn prompt's Agent page: an object with `agent`, the program word (`claude`, `codex`, `grok`, or `omp`), an optional `resume` holding the session ID to reopen, and the optional `group` and `name`. On load, `fleetcom` launches that agent again as a managed task and resumes the ID; without `resume`, the agent starts a fresh conversation. See [Agent session resume](agent-resume.md). +- A **literal** entry is a typed command. Use a bare string without labels, or an object with `cmd` and optional `group` and `name` fields. On load, the stored text is run unchanged through your shell: even `"claude"` is a literal command. +- A **managed** entry is an object with `agent`, the registered program word (`claude`, `codex`, `grok`, or `omp`), plus optional `resume`, `group`, and `name` fields. Tasks launched from the Agent page are saved in this form. On load, the agent is launched as managed; with `resume`, the stored session ID is reopened, and without it, a fresh conversation is started. See [Agent session resume](agent-resume.md). ```json { @@ -43,8 +43,8 @@ A session file is a JSON object with three fields. `version` identifies the form - The stored `name` distinguishes session names that sanitize to the same filename: `a/b` and `a.b` both become `a_b.json`. Before saving, `fleetcom` compares the stored and incoming names and refuses a mismatch, reporting both names in the error. The load picker displays the stored name as `a/b`, not `a_b`. - Keys under `dirs` are directory paths: each task's working directory. On save, directories under `$HOME` are written as `~/...`; other paths are kept absolute. On load, `~` is expanded to `$HOME`, and relative keys are resolved against the invocation directory of the client loading the session. -- Values are ordered lists. Order is preserved, and each entry starts its own PTY under that directory. -- Directories are serialized alphabetically. Entry order remains stable within each directory. +- Values are ordered lists, loaded in order. Each task is started in its own PTY under the specified directory. +- Directories are serialized alphabetically, with entry order preserved within each directory. The `version` field must be an integer from 1 through the newest format supported by the running `fleetcom`. A missing field means version 1. Invalid or unsupported versions are refused on load, with the file's value and supported version in the error. A version 2 file read by an older `fleetcom` is refused by that same gate. @@ -56,13 +56,13 @@ The file is plain JSON and practical to edit by hand. Edit `name` to change the The entire recipe is validated before any commands are started. The root must be an object, every directory value must be an array, and every entry must be a command string or an object with a string `cmd` or a string `agent`, never both. `agent` must be a supported program word, and `resume`, when present, must be a lowercase `8-4-4-4-12` UUID or `null`. Strings, `null`, or omission are accepted for optional entry `group` and `name` fields and the wrapped session's `name`; other types are rejected. Unknown fields in wrapped metadata and entry objects are ignored. Empty maps, arrays, and strings are valid. With invalid JSON or any malformed field, the whole load is refused; existing tasks and the recipe file are left intact. In schema errors, the quoted directory key, entry number (starting at 1), and offending field are included where applicable. -After validation, group and display names are stripped of control characters and surrounding whitespace and limited to 64 characters. `Unassigned` is treated as no group but is a legal display name. Missing directories, entries past the task limit, and literal commands past the length limit are skipped and counted in the load status. A managed entry whose agent is not found on the connected client's `PATH` is reported on the status line and counted as a failed spawn; the other entries still load. Spawn failures are reported separately; tasks already started by a structurally valid recipe keep running. +After validation, group and display names are stripped of control characters and surrounding whitespace and limited to 64 characters. `Unassigned` is treated as no group but is a legal display name. Missing directories, entries past the task limit, and literal commands past the length limit are skipped and counted in the load status. A managed entry whose agent is not found on the connected client's `PATH` is reported on the status line and counted as a failed spawn; the remaining entries are still loaded. Spawn failures are reported separately; tasks already started from a structurally valid recipe are left running. Use the string form for commands with neither a group nor a name. String and object entries can appear in the same directory array. ### Loading version 1 files -Format 1 had no managed entry. A managed task was saved as the string `fleetcom` instrumented at the time: the bare program word, or the program word followed by its resume selector and a single-quoted UUID. When a file whose `version` is 1 or missing is loaded, each literal entry is checked against exactly those two shapes and converted: +In format 1, instrumented tasks were stored as command strings: a bare program word, or the word followed by its resume selector and a single-quoted UUID. When loading a file with `version` 1 or no version field, convert exactly those two forms to managed entries: | Version 1 entry | Loads as | | -- | -- | @@ -70,7 +70,9 @@ Format 1 had no managed entry. A managed task was saved as the string `fleetcom` | `claude --resume ''`, `codex resume ''`, `grok --resume ''`, or `omp --resume ''`, with or without the quotes | managed, resuming that ID | | anything else | literal, unchanged | -Tabs and repeated spaces between the tokens are accepted, as they were. A path-qualified word (`/usr/local/bin/claude`, `~/bin/claude`) loads as literal: you chose that binary, and a managed launch would run whatever `PATH` finds first. It keeps its binary and loses resume. Extra arguments, prompts, other resume spellings, and shell syntax load as literal, which is what they always were. A version 2 file is never converted: its literal `claude` is literal by construction. Recovery snapshots are rewritten continually, so they convert within a session; a saved session converts the next time it is saved. The conversion is removed one release after format 2 ships; from then on, an unconverted version 1 file loads every entry as literal. +Tabs and repeated spaces between tokens are still accepted. Commands with paths (`/usr/local/bin/claude`, `~/bin/claude`) are loaded as literals to preserve your chosen binary instead of substituting one found on `PATH`; managed resume tracking is then unavailable. Commands with extra arguments, prompts, other resume spellings, or shell syntax remain literal, as before. No conversion is applied to version 2 files: a literal `claude` stays literal. + +Recovery snapshots are rewritten continually in the new format; named sessions are converted on the next save. This conversion will be removed one release after format 2 ships. After that, all entries in unconverted version 1 files will be loaded as literals. ## Recovery @@ -89,7 +91,7 @@ As with saved recipes, full command lines are persisted in recovery files, inclu ## Saving and loading -- Save: press `w` in the dashboard, type a name, and press `Enter`. This writes the session name plus each task's directory, its command or its agent and session ID, and optional group and name to `.json`. +- Save: press `w` in the dashboard, type a name, and press `Enter` to write `.json` with the session name and each task's directory, command or managed agent and session ID, and optional group and name. - Load in-app: `o`, pick from the list, `Enter`. - Load at launch: `fleetcom `. From ea8a3c2609116d5d3b307ee702a4d592b195041f Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Mon, 5 Oct 2026 23:21:11 -0700 Subject: [PATCH 09/13] feat(docs): refine comments for clarity and consistency across harness modules --- src/harness/assets.rs | 80 ++++++++++----------- src/harness/claude.rs | 45 ++++++------ src/harness/codex.rs | 159 ++++++++++++++++++++---------------------- src/harness/grok.rs | 10 +-- src/harness/mod.rs | 154 +++++++++++++++++++--------------------- src/harness/omp.rs | 6 +- 6 files changed, 210 insertions(+), 244 deletions(-) diff --git a/src/harness/assets.rs b/src/harness/assets.rs index 55f4547..fa91106 100644 --- a/src/harness/assets.rs +++ b/src/harness/assets.rs @@ -16,21 +16,19 @@ //! the file at [`CAPTURE_ENV`](super::CAPTURE_ENV), set in the task's launch //! environment. Write the parent Claude process's PID on the first line, //! followed by the JSON payload from stdin, unmodified. -//! - `codex`: `-c notify=[""]` names an executable that -//! `codex` invokes with notification JSON once per completed turn of any -//! thread: the conversation on screen, each sub-agent, and the hidden -//! title thread. When `$FLEETCOM_CAPTURE_FILE` and `$FLEETCOM_BINARY` are -//! both non-empty, the script runs -//! `"$FLEETCOM_BINARY" --codex-notify-v1 "$1"` and ignores its status. -//! That mode validates at arrival: it resolves the notified thread to its -//! root through the rollout header and replaces the capture file with the -//! bare root UUID, atomically, only for an accepted root. A refused thread -//! writes nothing, so the title thread cannot erase the root. The slot -//! format is frozen as v1: exactly one UUID, no newline. When -//! `$FLEETCOM_NOTIFY_CHAIN` is non-empty the script then execs that -//! newline-joined argv with the payload appended, so the displaced notifier -//! receives the same final argument `codex` would have passed; otherwise -//! exit 0. The chain runs whatever happens to fleetcom's part. +//! - `codex`: register the notify executable with +//! `-c notify=[""]`. Receive notification JSON after each +//! completed turn: the conversation on screen, each sub-agent, and the +//! hidden title thread. With non-empty `$FLEETCOM_CAPTURE_FILE` and +//! `$FLEETCOM_BINARY`, run `"$FLEETCOM_BINARY" --codex-notify-v1 "$1"` and +//! ignore its status. Validate at arrival: resolve the notified thread to +//! its root through the rollout header, then atomically replace the capture +//! file with the bare root UUID. Write nothing for a refused thread, so a +//! title notification cannot overwrite an accepted root. Keep the v1 slot +//! format fixed: exactly one UUID, no newline. With a non-empty +//! `$FLEETCOM_NOTIFY_CHAIN`, exec the newline-joined argv with the payload +//! appended, preserving the notifier's original arguments and final payload +//! argument; otherwise, exit 0. Run the chain regardless of validation failure. //! - `omp`: load an extension module inside the agent's own process through //! `-e `, appending to the user's extensions. On //! `session_start`, `session_switch`, `session_branch`, and `agent_end`, @@ -51,9 +49,8 @@ use std::{ use super::CapturePaths; use crate::task::{pid_is_dead, positive_pid}; -/// Notify program injected into `codex`. It hands the payload to fleetcom's -/// `--codex-notify-v1` mode when a capture path and binary are both set, then -/// replaces itself with the configured notifier when present. +/// Codex notify program: pass the payload to `fleetcom --codex-notify-v1` when both a +/// capture path and binary are set. Then exec the configured notifier, if present. const CODEX_NOTIFY_SCRIPT: &str = r#"#!/bin/sh # Validate the notification in fleetcom before replacing this process with # the chained notifier. The binary rewrites the capture file only for an @@ -285,9 +282,9 @@ impl CaptureAssets { }) } - /// Return the installed asset paths plus the capture path for one task run. - /// Including the run number prevents reruns from sharing payloads. The - /// binary is probed on every call: it can disappear between launches. + /// Return the installed asset paths and capture path for one task run. Include the run + /// number to isolate reruns. Probe the binary on every call in case it was removed + /// between launches. pub fn paths_for(&self, task_id: u64, run: u32) -> CapturePaths { CapturePaths { capture_file: self.dir.join(format!("task-{task_id}-{run}.json")), @@ -299,12 +296,11 @@ impl CaptureAssets { } } -/// This process's executable when it is still a regular file with an -/// execute bit. On Linux, `current_exe` reads `/proc/self/exe`, which becomes -/// ` (deleted)` once the binary is replaced on disk; that path fails -/// the metadata check. On macOS the path survives a reinstall and names the -/// new binary, so an older release there degrades to an unrecognized flag -/// and no capture write. +/// Look up this process's executable and require a regular file with an execute bit. On +/// Linux, the path read through `/proc/self/exe` ends in ` (deleted)` after the binary is +/// replaced on disk; reject that nonexistent path. On macOS, the same path refers to the +/// newly installed binary. If replaced with an older release, validation fails with an +/// unrecognized flag and no capture write. fn fleetcom_binary() -> Option { std::env::current_exe() .ok() @@ -592,9 +588,9 @@ console.log(Object.keys(handlers).join(" ")); } } - /// Stand-in for `fleetcom --codex-notify-v1` at `path`: record argv into - /// `record`, write [`ID`] over the inherited capture file, and exit 3. - /// The nonzero status proves the script ignores it. + /// Install a stand-in for `fleetcom --codex-notify-v1` at `path`: record argv into + /// `record`, write [`ID`] over the inherited capture file, and exit 3. Verify that this + /// nonzero status is ignored by the calling script. fn install_fake_binary(path: &Path, record: &Path) { write_executable( path, @@ -605,9 +601,8 @@ console.log(Object.keys(handlers).join(" ")); ); } - /// The script runs the binary only when both the capture path and the - /// binary are set, passing the payload as the mode's sole argument. With - /// neither chain nor binary, it exits 0 without output. + /// Run the binary only with both capture path and binary set, passing the payload as + /// the sole mode argument. With neither a chain nor a binary, exit 0 without output. #[test] fn notify_script_runs_the_binary_only_with_a_capture_path_and_binary() { let root = temp("assets_notify"); @@ -647,9 +642,8 @@ console.log(Object.keys(handlers).join(" ")); assert!(!cap.exists(), "{capture:?} {bin:?}"); } - // Both set and an empty chain, which reads as absent: the binary runs - // with the mode flag and the payload, its status is dropped, and the - // script exits 0 without exec. + // With both variables set and an empty chain, run the binary with the mode flag and + // payload, ignore its status, and exit 0 without exec. let out = Command::new(&assets.codex_notify) .arg(payload) .env(CAPTURE_ENV, &cap) @@ -666,8 +660,8 @@ console.log(Object.keys(handlers).join(" ")); assert_eq!(fs::read_to_string(&cap).unwrap(), ID); } - /// A configured chain runs after the binary and receives its original - /// argv followed by the payload. Spaces within an argument remain intact. + /// Run the configured notifier after the binary with its original argv followed by the + /// payload. Preserve spaces within arguments. #[test] fn notify_script_chains_the_displaced_notifier() { let root = temp("assets_chain"); @@ -699,8 +693,7 @@ console.log(Object.keys(handlers).join(" ")); ); } - /// The binary runs before the chained notifier, whose exit status passes - /// through. + /// Run the binary before the chained notifier and forward the notifier's exit status. #[test] fn notify_script_capture_survives_a_failing_chain() { let root = temp("assets_chain_fail"); @@ -723,8 +716,7 @@ console.log(Object.keys(handlers).join(" ")); assert_eq!(fs::read_to_string(&cap).unwrap(), ID); } - /// Without a capture path, the script still execs the configured chain - /// and leaves the binary alone. + /// Without a capture path, exec the configured notifier without running the binary. #[test] fn notify_script_chains_without_a_capture_path() { let root = temp("assets_chain_nocap"); @@ -748,8 +740,8 @@ console.log(Object.keys(handlers).join(" ")); assert!(!binary_record.exists(), "no capture path, no binary run"); } - /// A binary that no longer exists, as after a Linux reinstall under a - /// running daemon, must not keep the chain from running. + /// Run the configured notifier even when the binary no longer exists, as after a Linux + /// reinstall under a running daemon. #[test] fn notify_script_chains_past_a_missing_binary() { let root = temp("assets_chain_nobin"); diff --git a/src/harness/claude.rs b/src/harness/claude.rs index b788edc..93cf7c0 100644 --- a/src/harness/claude.rs +++ b/src/harness/claude.rs @@ -1,14 +1,13 @@ -//! Claude session capture uses a launch-time `--session-id`, a `SessionStart` -//! hook, and the live session registry. A fresh launch pins a v4 UUID; every -//! launch installs the hook through `--settings`. -//! Live lookup reads `/sessions/.json`. +//! Capture Claude sessions through a launch-time `--session-id`, a `SessionStart` hook, and +//! the live registry. Pin a v4 UUID on fresh launches and install the hook through +//! `--settings` on every launch. Read live records from +//! `/sessions/.json`. //! -//! With agent view enabled, a conversation can be moved into Claude's own -//! background daemon. In that daemon's processes, the inherited capture -//! environment and `--settings` flag are used to run the same hook with IDs -//! from other conversations. Disable agent view in the overlay to prevent -//! those handoffs. Stamp each payload with the parent Claude process's PID -//! and accept only captures from the task's own process. +//! With agent view enabled, a conversation can be moved into Claude's background daemon. In +//! that daemon's processes, the inherited capture environment and `--settings` flag are +//! used to run the same hook with IDs from other conversations. Disable agent view in the +//! overlay to prevent those handoffs. Stamp each payload with the parent Claude process's +//! PID and accept only captures from the task's own process. use std::{ fs, @@ -55,17 +54,15 @@ impl Harness for Claude { } } - /// Accept a hook payload only from the task's own process. The first line - /// is the PID of the Claude process that ran the hook and must be exactly - /// the decimal form of `pid`; the remainder is the hook's JSON. Ownership - /// cannot be determined from the JSON: `source: "fork"` is reported for - /// both `/branch` in the task's process and a background fork. + /// Accept a hook payload only from the task's own process. Require the first line to + /// equal the decimal `pid` of the Claude process that ran the hook; parse the remainder + /// as the hook's JSON. Ownership cannot be determined from JSON: `source: "fork"` is + /// reported for both `/branch` in the task's process and a background fork. /// - /// A managed launch runs the Claude binary directly, so the task leader - /// is the Claude process, as [`record_for_pid`] also requires. A stamp - /// from any other PID (a background fork, a wrapper that stayed resident) - /// is rejected: fall back to the registry, then the spawn-time ID. - /// Without a task PID, ownership cannot be checked. + /// Run the Claude binary directly for managed tasks so the Claude process is the task + /// leader, as required by [`record_for_pid`]. Reject stamps from other PIDs (a + /// background fork or a resident wrapper) and fall back to the registry, then the + /// spawn-time ID. Without a task PID, ownership cannot be checked. fn parse_capture( &self, payload: &str, @@ -222,9 +219,9 @@ mod tests { std::time::UNIX_EPOCH + std::time::Duration::from_millis(ms) } - /// Managed argv: a fresh launch pins the minted ID, a resume names its - /// conversation, and the settings overlay follows either. Both carry the - /// capture file in the environment and report the ID they target. + /// For fresh launches, pin the minted ID; for resumes, specify the conversation ID. + /// Append the settings overlay in both cases, set the capture file in the environment, + /// and report the target ID. #[test] fn managed_argv_pins_or_resumes_then_layers_settings() { let fresh = plan(&Claude, &Intent::Fresh, Some(ID), &paths(), None); @@ -250,7 +247,7 @@ mod tests { assert_eq!(resume.resume_id.as_deref(), Some(OTHER)); assert_eq!(resume.env, fresh.env); - // A mint failure launches unpinned: the overlay alone, no ID. + // If minting fails, launch with only the overlay and no pinned ID. let unpinned = plan(&Claude, &Intent::Fresh, None, &paths(), None); assert_eq!(unpinned.args, argv(&["--settings", SETTINGS])); assert_eq!(unpinned.resume_id, None); diff --git a/src/harness/codex.rs b/src/harness/codex.rs index 84678eb..27271aa 100644 --- a/src/harness/codex.rs +++ b/src/harness/codex.rs @@ -1,18 +1,17 @@ -//! A Codex ID cannot be selected at launch. Explicitly enable embedded mode -//! with one override on every launch, and when the configured notifier can -//! be chained and this binary is usable, inject a `notify` override as well. +//! A Codex ID cannot be selected at launch. Explicitly enable embedded mode on every +//! launch. Also inject a `notify` override when the configured notifier can be chained and +//! this binary is usable. //! -//! Notifications are emitted for every thread in one Codex process: the -//! conversation on screen, each spawned sub-agent, and the hidden thread used -//! to title a new session. Only the first is the task's conversation. Resuming -//! a sub-agent with an unloaded parent or the unsaved title thread exits 1. -//! The injected script hands each notification to `fleetcom --codex-notify-v1` -//! ([`record_arrival`]), which resolves the notified thread to its session -//! tree's root through the rollout header and replaces the capture file with -//! the bare root UUID. A thread that cannot be classified writes nothing, so -//! the title thread, whose notification lands before or after the root's, -//! never erases an accepted root. `parse_capture` then accepts the slot only -//! as that one UUID. +//! Notifications are emitted for every thread in one Codex process: the conversation on +//! screen, each spawned sub-agent, and the hidden title thread. Only the first is the +//! task's conversation. Attempting to resume a sub-agent with an unloaded parent or the +//! unsaved title thread exits 1. +//! +//! Pass each notification to `fleetcom --codex-notify-v1` ([`record_arrival`]). Resolve the +//! notified thread to its session tree's root through the rollout header, then replace the +//! capture file with the bare root UUID. Write nothing for an unclassified thread. Preserve +//! an accepted root regardless of whether the title notification arrives before or after +//! the root notification. In `parse_capture`, accept only that one bare UUID. use std::{ fmt::Write as _, @@ -57,12 +56,11 @@ impl Harness for Codex { ("codex", "resume") } - /// Every launch carries the embedded override: without it, `codex` - /// attaches to its shared background server and killing the task kills - /// only the TUI client. The notify override and its environment ride - /// along only when the configured route can be chained and this binary - /// is usable; otherwise the plan explains why capture is off. No session - /// flag: codex cannot pin an ID at launch. + /// Include the embedded override on every launch. Otherwise, `codex` attaches to its + /// shared background server, and killing the task terminates only the TUI client. + /// Inject the notify override and environment only when the configured route can be + /// chained and this binary is usable; otherwise, include a notice explaining why + /// capture is disabled. Omit the session flag: codex cannot pin an ID at launch. fn overlay(&self, capture: &CapturePaths, home: Option<&Path>) -> SpawnPlan { let mut plan = SpawnPlan { args: vec!["-c".into(), EMBEDDED_OVERRIDE.into()], @@ -81,9 +79,8 @@ impl Harness for Codex { return plan; } }; - // The script runs this binary to validate each notification. Without - // a usable path there is nothing to run, so inject nothing: an - // unvalidated slot would be worse than none. + // Validate each notification with this binary. Without a usable executable path, + // skip injection so no unvalidated slot is written. let Some(binary) = &capture.fleetcom_binary else { plan.notice = Some(format!( "{CAPTURE_OFF}: fleetcom binary replaced; restart the daemon" @@ -111,37 +108,34 @@ impl Harness for Codex { plan } - /// Accept the slot only as exactly one bare root UUID: the v1 format - /// written by [`record_arrival`]. No trimming: the writer emits no - /// newline, so trailing whitespace marks a different writer. Validation - /// happened at arrival, when the rollout header was on disk; the payload - /// is not reparsed and no rollout is read here, so a root stays accepted - /// after codex compresses its rollout. An old-format JSON slot fails - /// [`is_uuid`] and is refused. + /// Accept exactly one bare root UUID: the v1 format written by [`record_arrival`]. Do + /// not trim whitespace; no newline is written in that format. Validation was completed + /// at arrival while the rollout header was on disk. Do not reparse the payload or read + /// the rollout here, so the ID remains accepted after rollout compression. Reject + /// old-format JSON with [`is_uuid`]. fn parse_capture( &self, payload: &str, // Notifications for every thread originate in the task's own process. _pid: Option, - // The home was consumed at arrival. + // Home resolution was completed at arrival. _home: Option<&Path>, ) -> Option { is_uuid(payload).then(|| payload.to_string()) } } -/// Status-line prefix for a launch that carries the embedded override alone. +/// Status-line prefix for a launch with only the embedded override. const CAPTURE_OFF: &str = "codex capture unavailable"; -/// The `--codex-notify-v1` mode: validate one notification at arrival and -/// replace the capture file with its root UUID. `env` is the process's own -/// environment, inherited from the `codex` launch: [`CAPTURE_ENV`] names the -/// slot, and the home resolves as in [`Codex::resolve_home`]. +/// Validate one notification at arrival through `--codex-notify-v1` and replace the capture +/// file with its root UUID. Read `env` from the process environment inherited at the +/// `codex` launch: use [`CAPTURE_ENV`] for the slot and resolve the home as in +/// [`Codex::resolve_home`]. /// -/// Return the root written, or `None` when nothing was written: no capture -/// path, a payload that is not an `agent-turn-complete` for a strict thread -/// ID, a thread [`root_thread`] cannot classify, or a failed write. The slot -/// only ever moves from one accepted root to another. +/// Return the root written. Return `None` without writing if no capture path is set, the +/// payload is not an `agent-turn-complete` for a strict thread ID, classification in +/// [`root_thread`] fails, or the write fails. Replace the slot only with an accepted root. pub fn record_arrival(payload: &str, env: &dyn Fn(&str) -> Option) -> Option { let capture = env(CAPTURE_ENV).filter(|p| !p.as_os_str().is_empty())?; let root = arrival_root(payload, Codex.resolve_home(env).as_deref())?; @@ -149,14 +143,13 @@ pub fn record_arrival(payload: &str, env: &dyn Fn(&str) -> Option) -> O Some(root) } -/// Resolve an `agent-turn-complete` notification to the root thread of the -/// notified thread's session tree: the conversation the task's TUI is on. -/// For a sub-agent, that is its root. Return `None` for any other payload -/// type, a non-strict thread ID, or a thread [`root_thread`] cannot classify. +/// Resolve an `agent-turn-complete` notification to the root of the notified thread's +/// session tree: the conversation displayed in the task's TUI. For a sub-agent, use its +/// root. Return `None` for any other payload type, a non-strict thread ID, or failed +/// classification in [`root_thread`]. /// -/// Look up only the thread ID from the task's own notification and classify -/// it from its rollout header. Do not infer conversation ownership from -/// other sessions in the store. +/// Look up only the thread ID from the task's own notification and classify it from its +/// rollout header. Do not infer conversation ownership from other sessions in the store. fn arrival_root(payload: &str, home: Option<&Path>) -> Option { let v = jzon::parse(payload).ok()?; if v["type"].as_str() != Some("agent-turn-complete") { @@ -166,9 +159,9 @@ fn arrival_root(payload: &str, home: Option<&Path>) -> Option { root_thread(&home_root(home, ".codex")?, &thread) } -/// Write `root` to `..tmp` beside the capture file and rename -/// it over the destination, so a reader never sees a partial slot. Remove the -/// temporary file when either step fails. +/// Write `root` to `..tmp` beside the capture file and rename it over the +/// destination to prevent reads of a partial slot. Remove the temporary file if either step +/// fails. fn replace_slot(capture: &Path, root: &str) -> io::Result<()> { let mut tmp = capture.as_os_str().to_owned(); tmp.push(format!(".{}.tmp", std::process::id())); @@ -182,7 +175,7 @@ fn replace_slot(capture: &Path, root: &str) -> io::Result<()> { /// Resolve `thread` to the root thread of its session tree from the header of /// its rollout under the Codex `home`. `thread` must already satisfy -/// [`is_uuid`](super::is_uuid): it is matched against file names. +/// [`is_uuid`]: it is matched against file names. /// /// Each thread is saved as /// `sessions///
/rollout--.jsonl`. The first @@ -435,8 +428,8 @@ mod tests { testutil::{Scratch, codex_session_meta, install_codex_rollout, install_codex_root, temp}, }; - /// Codex's own launch and resume commands carry v7 IDs; the shared v4 - /// fixture stays valid because `is_uuid` is version-agnostic. + /// Use v7 IDs in Codex launch and resume commands. Also accept the shared v4 fixture: + /// UUID version is not checked by `is_uuid`. const ID: &str = "019f5453-de22-7240-b2e5-0d32692aa6d9"; /// A sub-agent thread and a second-level sub-agent thread in the session /// rooted at [`ID`]. @@ -467,7 +460,7 @@ mod tests { ] } - /// The notify override that [`paths`] produces. + /// The notify override built from [`paths`]. const NOTIFY: &str = r#"notify=["/tmp/Application Support/notify.sh"]"#; /// The overlay of a launch whose capture is off, explained by `why`. @@ -485,11 +478,10 @@ mod tests { temp("codex_no_config_home") } - /// Managed argv: a fresh launch carries the two overrides alone (no ID - /// can be pinned, so the minted one is ignored); a resume leads with the - /// `resume` subcommand and the ID, then the same overrides. The - /// environment names the capture file, an explicitly empty chain so no - /// inherited value reaches the script, and the binary the script runs. + /// For fresh managed launches, include only the two overrides; ignore the minted ID + /// because pinning is unsupported. For resumes, place `resume` and the ID before those + /// overrides. Set the capture file, an explicitly empty chain to exclude inherited + /// values, and the validation binary in the environment. #[test] fn managed_argv_installs_the_notify_and_embedded_overrides() { let home = no_config_home(); @@ -514,10 +506,9 @@ mod tests { assert_eq!(resume.env, full_env("")); } - /// Without a usable binary, the script would have nothing to run, so the - /// launch carries the embedded override alone and says why. The route - /// is still read first: an opaque route reports its own reason. A - /// managed resume keeps its intent part ahead of the reduced overlay. + /// Without a usable binary, include only the embedded override and a notice. Read the + /// route first and report an opaque route before checking the binary. On managed + /// resumes, preserve the conversation selection before the reduced overlay. #[test] fn overlay_without_a_usable_binary_keeps_only_the_embedded_override() { let paths = CapturePaths { @@ -613,8 +604,8 @@ mod tests { ))); } - /// For an unrepresentable route, inject the embedded override alone, no - /// environment, and a notice: the task stays in embedded mode either way. + /// For an unrepresentable route, inject only the embedded override, no environment, and + /// a notice. Keep the task in embedded mode even without capture. #[test] fn overlay_skips_an_unrepresentable_config_notify() { let home = temp("codex_opaque_notify"); @@ -651,8 +642,8 @@ mod tests { r#"/with space/and\"quote\\slash"# ); assert_eq!(toml_escape("a\tb"), "a\\u0009b"); - // The override is one argv element: the escaping is TOML's alone, with - // no shell layer to double it. + // Pass the override as one argv element. Escape TOML only; no shell escaping is + // needed. let paths = CapturePaths { codex_notify: PathBuf::from(r#"/Odd Path/it's "here"\now"#), ..paths() @@ -827,9 +818,9 @@ mod tests { assert_eq!(parse(""), None); } - /// The slot is exactly one bare UUID. Refuse the pre-v1 JSON payload, a - /// trailing newline (a different writer), and malformed or empty input, - /// whatever the PID and home: nothing is reparsed or looked up at read. + /// Accept exactly one bare UUID. Refuse pre-v1 JSON, trailing newlines from other + /// writers, and malformed or empty input regardless of PID or home. Do not reparse or + /// look up the ID at read time. #[test] fn parse_capture_accepts_only_a_bare_uuid_slot() { let home = temp("codex_capture_slot"); @@ -875,10 +866,9 @@ mod tests { names } - /// The headline regression: the title thread's notification, which has - /// no rollout, lands before the root's in some sessions and after it in - /// others. Either way the slot ends up holding the root, because a - /// refusal writes nothing. No temporary file is left behind. + /// Preserve the root when the title notification arrives before or after it. No rollout + /// exists for the title thread, so refuse its notification without writing. Leave no + /// temporary file behind. #[test] fn record_arrival_keeps_the_root_across_a_title_thread_notification() { let home = temp("codex_arrival_title"); @@ -898,7 +888,7 @@ mod tests { ); assert_eq!(fs::read_to_string(&cap).unwrap(), ID); - // Title after the root: the slot keeps the root. + // Title after the root: preserve the accepted root. assert_eq!(record_arrival(&turn_complete(TITLE), &env), None); assert_eq!(fs::read_to_string(&cap).unwrap(), ID); assert_eq!(siblings(&cap), ["task-1-0.json"]); @@ -908,8 +898,8 @@ mod tests { ); } - /// A sub-agent's notification writes its root, not its own ID, replacing - /// an earlier, longer slot in full. + /// On a sub-agent notification, write the root ID instead of the sub-agent's ID. + /// Replace the entire previous slot, including any excess bytes. #[test] fn record_arrival_writes_a_sub_agents_root() { let home = temp("codex_arrival_child"); @@ -930,9 +920,8 @@ mod tests { assert_eq!(siblings(&cap), ["task-2-0.json"]); } - /// Refuse before touching the slot: a non-turn-complete type, a non-UUID - /// thread, malformed JSON, and a thread without a rollout all leave an - /// existing slot as it was. + /// Refuse before touching the slot: preserve existing contents for a non-turn-complete + /// type, a non-UUID thread, malformed JSON, or a thread without a rollout. #[test] fn record_arrival_refuses_without_writing() { let home = temp("codex_arrival_refuse"); @@ -963,8 +952,8 @@ mod tests { assert_eq!(siblings(&cap), ["task-3-0.json"]); } - /// Without a capture path, the mode is a no-op even for an accepted root. - /// An empty value reads as unset, as in the script. + /// Without a capture path, write nothing even for an accepted root. Treat an empty path + /// as unset, as in the script. #[test] fn record_arrival_is_a_no_op_without_a_capture_path() { let home = temp("codex_arrival_nocap"); @@ -977,7 +966,7 @@ mod tests { "{capture:?}" ); } - // Nothing lands beside the store, where an empty path could resolve. + // Do not write beside the store, where an empty path could resolve. assert_eq!(siblings(&home.join("sessions")), ["sessions"]); } @@ -1007,8 +996,8 @@ mod tests { assert_eq!(fs::read_to_string(&cap).unwrap(), ID); } - /// A slot whose directory is gone, as after the daemon's namespace is - /// dropped, is a refusal with no temporary file left anywhere. + /// Refuse a write when the slot's directory is gone, as after dropping the daemon's + /// namespace. Leave no temporary file behind. #[test] fn record_arrival_reports_a_failed_write() { let home = temp("codex_arrival_badslot"); diff --git a/src/harness/grok.rs b/src/harness/grok.rs index 4a130b5..6dee2c6 100644 --- a/src/harness/grok.rs +++ b/src/harness/grok.rs @@ -1,5 +1,5 @@ -//! Grok has no injectable live-capture channel. A fresh launch instead pins a -//! v4 UUID, and a resume names it. +//! Grok has no injectable live-capture channel. Pin a v4 UUID on fresh launches and specify +//! it on resume. use std::path::Path; @@ -16,7 +16,7 @@ impl Harness for Grok { Some("--session-id") } - /// Nothing rides along: grok has no capture channel to install. + /// No overlay: grok has no capture channel to install. fn overlay(&self, _capture: &CapturePaths, _home: Option<&Path>) -> SpawnPlan { SpawnPlan::default() } @@ -31,8 +31,8 @@ mod tests { plan, }; - /// Managed argv is the intent part alone: Grok exposes no live capture - /// channel, so nothing rides along either way. + /// Build argv from the intent alone: Grok has no live-capture channel, so add no + /// overlay for fresh launches or resumes. #[test] fn managed_argv_is_the_pin_or_the_resume_and_nothing_else() { let fresh = plan(&Grok, &Intent::Fresh, Some(ID), &paths(), None); diff --git a/src/harness/mod.rs b/src/harness/mod.rs index 3683839..1769250 100644 --- a/src/harness/mod.rs +++ b/src/harness/mod.rs @@ -1,20 +1,18 @@ -//! A saved agent is incomplete without its conversation ID. Relaunching the -//! agent can otherwise start a new conversation. Each harness captures a -//! validated ID and builds the argv that opens or resumes a conversation. +//! Save an agent's conversation ID to resume it later; without the ID, relaunching may +//! start a new conversation. Capture and validate the ID through the harness, then build +//! the argv to open or resume it. //! -//! A managed launch is built here from an [`Intent`]: the binary found on -//! `PATH`, the conversation selection, then the harness's overlay (capture -//! hook, config overrides, environment). No shell parses that argv. A literal -//! command never comes here: it runs and saves verbatim, whatever it names, -//! and only the display-only summary adapter ([`select`]) reads its text. +//! Build managed launches from an [`Intent`]: the binary found on `PATH`, the conversation +//! selection, then the harness overlay (capture hook, config overrides, environment). +//! Execute that argv directly, without shell parsing. Run and save literal commands +//! verbatim; inspect their text only to select a display adapter ([`select`]). //! //! # Security invariant //! -//! Every ID returned by `parse_capture` or `live_session_id` enters an agent's -//! argv as one element and a session file as its `resume` field. These -//! methods return only strings accepted by [`is_uuid`]; return `None` for free -//! text, paths, and malformed IDs. Summary adapters and `live_blocked_status` -//! are display-only. +//! Use each ID from `parse_capture` or `live_session_id` as one argv element and as the +//! `resume` field in a session file. Return only IDs accepted by [`is_uuid`]; return `None` +//! for free text, paths, and malformed IDs. Use summary adapters and `live_blocked_status` +//! only for display. pub mod assets; mod claude; @@ -45,10 +43,10 @@ pub const CAPTURE_ENV: &str = "FLEETCOM_CAPTURE_FILE"; /// configured so inherited values cannot reach the capture script. pub const NOTIFY_CHAIN_ENV: &str = "FLEETCOM_NOTIFY_CHAIN"; -/// Environment variable naming the fleetcom binary that the injected `codex` -/// notify script runs as `--codex-notify-v1`. Set per launch from the -/// daemon's own path so the script validates with the binary that built it; -/// absent when that path is unusable, so the script skips the call. +/// Environment variable for the fleetcom executable used by the injected `codex` notifier. +/// Set it from the daemon's path on each launch so notifications are validated through +/// `--codex-notify-v1` with the same binary used to install the script. When the path is +/// unusable, omit the variable and skip validation. pub const BINARY_ENV: &str = "FLEETCOM_BINARY"; /// Launch, capture, and resume behavior for one agent CLI. @@ -60,25 +58,21 @@ pub trait Harness: Sync { None } - /// Program word and resume selector. The word is how the registry, the - /// launcher, and session files name the tool; the selector is the flag or - /// subcommand that takes a conversation ID, which [`intent_args`] places - /// first in a resuming argv. + /// Program word and resume selector. Identify the tool by its word in the registry, + /// launcher, and session files. Use the selector flag or subcommand before the + /// conversation ID in [`intent_args`]. fn shape(&self) -> (&'static str, &'static str); - /// The flag that pins a session ID on a fresh launch, for tools that - /// accept one. `None` means a fresh conversation gets its ID from the - /// tool, and only capture or the registry can report it. + /// Flag for pinning a session ID at launch, if supported. With `None`, use the ID + /// assigned by the tool and read it from capture or the registry. fn session_flag(&self) -> Option<&'static str> { None } - /// Spawn-time additions every launch of this tool carries, whichever - /// conversation it opens: argv elements after the conversation selection, - /// environment pairs, and a notice when the launch carries less than - /// usual. `home` is resolved from the launch environment; `None` uses the - /// harness's platform-home fallback. Leaves `resume_id` unset; [`plan`] - /// fills it from the intent. + /// Instrumentation for every launch of this tool: argv elements after the conversation + /// selection, environment pairs, and a notice for reduced instrumentation. Resolve + /// `home` from the launch environment; with `None`, use the platform-home fallback. + /// Leave `resume_id` unset here and assign it from the intent in [`plan`]. fn overlay(&self, capture: &CapturePaths, home: Option<&Path>) -> SpawnPlan; /// Extract a session ID from the capture file's contents. `pid` is the @@ -145,7 +139,7 @@ struct Agent { summary: &'static dyn crate::preview::SummaryAdapter, } -/// Registered CLIs in registry order: the order the launcher lists them. +/// Registered CLIs, in launcher order. static AGENTS: &[Agent] = &[ Agent { harness: &Claude, @@ -165,8 +159,8 @@ static AGENTS: &[Agent] = &[ }, ]; -/// The harness registered under exactly `program`: the word a managed -/// launch or a session entry names, never a path or a basename match. +/// Look up a harness by its exact registered `program` word, as used for managed launches +/// and session entries. Do not match paths or basenames. pub fn registered(program: &str) -> Option<&'static dyn Harness> { AGENTS .iter() @@ -174,28 +168,25 @@ pub fn registered(program: &str) -> Option<&'static dyn Harness> { .find(|h| h.shape().0 == program) } -/// Program words of every registered agent, in registry order. The launcher -/// subtracts `installed` from this to name the agents a host lacks. +/// List every registered program word in registry order. Subtract `installed` to identify +/// agents missing from the host. pub fn program_words() -> impl Iterator { AGENTS.iter().map(|a| a.harness.shape().0) } -/// Program words of the registered agents that [`find_on_path`] resolves on -/// `path`, in registry order: the launcher's menu. Each word is searched -/// independently, so the result never depends on `path` order. An empty -/// `path` yields an empty list. +/// List registered program words found on `path`, in registry order, for the launcher menu. +/// Search for each word independently. Keep menu order independent of `path` order; return +/// an empty list for an empty `path`. pub fn installed(path: &OsStr) -> Vec<&'static str> { program_words() .filter(|program| find_on_path(program, path).is_some()) .collect() } -/// The first executable regular file named `program` in `path`, returned as -/// found. The path is never canonicalized: a self-updater that repoints -/// `~/.local/bin/claude` is followed at the next launch. Components that are -/// not absolute (empty, `.`, `bin`) are skipped: they would resolve against -/// the daemon's cwd here and against the task's cwd at exec, so the two -/// could name different files. +/// Find the first executable regular file named `program` in `path`. Preserve the path as +/// found: after a self-update of `~/.local/bin/claude`, follow the new symlink target on +/// the next launch. Skip relative components (empty, `.`, `bin`): resolving them against +/// the daemon's cwd during lookup and the task's cwd at exec could select different files. pub fn find_on_path(program: &str, path: &OsStr) -> Option { std::env::split_paths(path) .filter(|dir| dir.is_absolute()) @@ -208,11 +199,10 @@ fn executable_file(path: &Path) -> bool { fs::metadata(path).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0) } -/// Select a summary adapter by the basename of the command's first -/// whitespace-separated word. Arguments are accepted; do not select an adapter -/// for environment prefixes or compound shell commands. Selection is display -/// only and applies to literal and managed tasks alike: it never grants a -/// literal task a harness channel. +/// Select a display-only summary adapter by the basename of the command's first +/// whitespace-separated word. Accept arguments; do not select an adapter for environment +/// prefixes or compound shell commands. Apply this selection to literal and managed tasks +/// without enabling harness reads for literal tasks. pub fn select(command: &str) -> Option<&'static dyn crate::preview::SummaryAdapter> { let first = command.split_whitespace().next()?; let name = Path::new(first).file_name()?.to_str()?; @@ -222,23 +212,22 @@ pub fn select(command: &str) -> Option<&'static dyn crate::preview::SummaryAdapt .map(|a| a.summary) } -/// Which conversation a managed launch opens. +/// Conversation selection for a managed launch. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Intent { - /// A new conversation. A tool with a [`Harness::session_flag`] gets an - /// ID minted by the supervisor. + /// Start a new conversation. When a [`Harness::session_flag`] is available, mint an ID + /// in the supervisor. Fresh, - /// The conversation with this ID, which passed [`is_uuid`] at its - /// source: a capture, a registry record, or a session file. + /// Resume the conversation with this ID, validated by [`is_uuid`] at its source: + /// capture, a registry record, or a session file. Resume(String), } -/// Argv that selects the conversation, and the ID the launch targets when -/// one is known at launch. `Fresh` pins `fresh_id` through the tool's session -/// flag where it has one; `None` (a mint failure) launches unpinned, as every -/// release has. `Resume` names the ID after the tool's resume selector. The -/// ID is one argv element and no shell parses it, so [`is_uuid`] is a second -/// check here rather than the only one. +/// Build conversation-selection argv and report the target ID when known. For `Fresh`, pin +/// `fresh_id` through the tool's session flag, if supported. With `None` (a mint failure), +/// launch unpinned, as in prior releases. For `Resume`, place the ID after the resume +/// selector. Pass the ID as one argv element without shell parsing; also validate it with +/// [`is_uuid`]. pub fn intent_args( h: &dyn Harness, intent: &Intent, @@ -259,9 +248,9 @@ pub fn intent_args( } } -/// The complete plan for one launch of `h`: the intent part first, then the -/// overlay. Every tool takes its subcommand or session flag before config -/// flags (`codex resume -c …`), so the order is fixed here, once. +/// Build the complete launch plan for `h`: conversation selection first, then the overlay. +/// Use this order for every tool because each accepts its subcommand or session flag before +/// config flags (`codex resume -c …`). pub fn plan( h: &dyn Harness, intent: &Intent, @@ -291,10 +280,10 @@ pub struct CapturePaths { /// Extension module loaded by `omp -e`, which appends to the user's own /// extensions rather than replacing them. pub omp_capture: PathBuf, - /// This daemon's own executable, checked at allocation to be a regular - /// file with an execute bit. `None` when `current_exe` is unusable: on - /// Linux, `/proc/self/exe` reads ` (deleted)` once the binary is - /// replaced on disk under the running daemon. + /// The daemon's executable, checked at allocation for a regular file with an execute + /// bit. Use `None` when `current_exe` is unusable. On Linux, the path read through + /// `/proc/self/exe` ends in ` (deleted)` after the binary is replaced on disk under the + /// running daemon. pub fleetcom_binary: Option, } @@ -306,11 +295,10 @@ pub struct SpawnPlan { pub args: Vec, /// Environment pairs added to the child. pub env: Vec<(OsString, OsString)>, - /// The session ID the launch targets, when known at launch: the pinned - /// fresh ID or the resumed one. + /// Session ID known at launch: the pinned fresh ID or the resumed ID. pub resume_id: Option, - /// One-line reason the launch carries less instrumentation than usual. - /// The supervisor reports it on the status line once the task spawns. + /// One-line explanation of reduced instrumentation, for the status line after a + /// successful spawn. pub notice: Option, } @@ -325,8 +313,8 @@ pub fn is_uuid(s: &str) -> bool { }) } -/// Validated session ID at `key` in a capture payload or a Codex rollout -/// header; [`is_uuid`] is the boundary. +/// Read a validated session ID at `key` in a capture payload or Codex rollout header. +/// Validate with [`is_uuid`]. fn capture_id(v: &jzon::JsonValue, key: &str) -> Option { let id = v[key].as_str()?; is_uuid(id).then(|| id.to_string()) @@ -422,8 +410,8 @@ mod tests { assert_ne!(a, b); } - /// The pin goes only where a session flag exists; a resume always names - /// the ID after the tool's selector; the reported ID follows the intent. + /// Pin an ID only when a session flag is supported. On resume, place the ID after the + /// tool's selector. Report the ID selected by the intent. #[test] fn intent_args_pin_only_behind_a_session_flag_and_always_resume() { for a in AGENTS { @@ -464,9 +452,9 @@ mod tests { assert!(registered("").is_none()); } - /// Resolution walks absolute components only, skips files without an - /// execute bit and directories, takes the first hit, and returns a - /// symlink as found rather than its target. + /// Search absolute PATH components only. Skip directories and files without an execute + /// bit; stop at the first match. Preserve symlinks as found without canonicalizing the + /// target. #[test] fn find_on_path_takes_the_first_executable_file_in_an_absolute_dir() { use crate::testutil::{temp, write_executable}; @@ -498,8 +486,8 @@ mod tests { ); assert_eq!(find_on_path("claude", OsStr::new("")), None); - // A relative spelling of `b` resolves from this process's cwd, and - // is still skipped: the task would resolve it from its own. + // Skip this relative spelling of `b` even though it is reachable from this + // process's cwd: at exec, it would be resolved against the task's cwd. let cwd = std::env::current_dir().unwrap(); let ups = cwd .components() @@ -525,8 +513,8 @@ mod tests { ); } - /// The menu follows registry order whatever `PATH` order says, lists - /// only registered words, and is empty for an empty `PATH`. + /// List only registered words, in registry order, regardless of `PATH` order. Return an + /// empty menu for an empty `PATH`. #[test] fn installed_follows_registry_order_not_path_order() { use crate::testutil::{temp, write_executable}; diff --git a/src/harness/omp.rs b/src/harness/omp.rs index c748b0b..71f3981 100644 --- a/src/harness/omp.rs +++ b/src/harness/omp.rs @@ -67,9 +67,9 @@ mod tests { /// The extension path from [`paths`]. const EXTENSION: &str = "/tmp/Application Support/omp-capture.js"; - /// Managed argv: a fresh launch loads the extension alone, since omp has - /// no `--session-id` and a minted ID would be unresumable; a resume names - /// its conversation first. Both name the capture file in the environment. + /// For fresh launches, load only the extension: omp has no `--session-id`, so a minted + /// ID would not be resumable. For resumes, specify the conversation first. Set the + /// capture file in the environment in both cases. #[test] fn managed_argv_loads_the_extension_and_pins_nothing() { let fresh = plan(&Omp, &Intent::Fresh, Some(ID), &paths(), None); From ff54f656c51663458798e3498055707a5fd4a8d1 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Mon, 5 Oct 2026 23:21:17 -0700 Subject: [PATCH 10/13] feat(tests): refine comments for clarity in daemon agent and resume tests --- tests/daemon_agents.rs | 34 ++++++++++----------- tests/daemon_resume.rs | 69 +++++++++++++++++++----------------------- 2 files changed, 47 insertions(+), 56 deletions(-) diff --git a/tests/daemon_agents.rs b/tests/daemon_agents.rs index ef38867..7c77a81 100644 --- a/tests/daemon_agents.rs +++ b/tests/daemon_agents.rs @@ -1,8 +1,6 @@ -//! Agent discovery and managed launches across the socket: the daemon reports -//! which registered agents the hello's `PATH` resolves, launches one by program -//! word as a managed task, refuses a word it does not know, and resends the -//! list on reconnect. A stub `claude` on a scratch `PATH` keeps every path -//! inside the test's tree. +//! Verify discovery and managed launches over the socket: list registered agents found on +//! the hello's `PATH`, launch by program word, refuse unknown words, and resend the list on +//! reconnect. Use a stub `claude` and scratch `PATH` to isolate all test paths. mod common; @@ -17,8 +15,8 @@ use common::{ read_frame, shake_hands_env, spawn_agent_frame, start_daemon_raw, stop_daemon, wait_until, }; -/// Scratch tree: `bin` holds the stub, `nobin` holds nothing, and the rest -/// keep the daemon's runtime, config, and registry reads off the host. +/// Scratch tree: the stub in `bin`, an empty `nobin`, and isolated runtime, config, and +/// registry paths. struct Scratch { root: PathBuf, } @@ -45,8 +43,8 @@ impl Scratch { self.root.join("claude-argv") } - /// Handshake environment whose `PATH` is exactly `/`: a - /// managed launch needs no shell, so nothing else must resolve. + /// Handshake environment with exactly `/` as `PATH`. Managed launches + /// require no shell lookup, so no other PATH entries are needed. fn hello_env(&self, bin: &str) -> Vec<(String, String)> { vec![ ("PATH".into(), self.root.join(bin).display().to_string()), @@ -83,13 +81,13 @@ fn hello(stream: &mut UnixStream, s: &Scratch, bin: &str) { .map(|(k, v)| (k.as_bytes(), v.as_bytes())) .collect(); shake_hands_env(stream, &s.work().display().to_string(), &env); - // A stalled daemon fails the test instead of hanging it. + // Time out if the daemon stalls instead of hanging the test. stream .set_read_timeout(Some(Duration::from_secs(10))) .unwrap(); } -/// Install a `claude` stub that records its argv and exits. +/// Install a `claude` stub to record argv and exit. fn install_claude_stub(s: &Scratch) { let path = s.root.join("bin").join("claude"); std::fs::write( @@ -103,8 +101,8 @@ fn install_claude_stub(s: &Scratch) { std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap(); } -/// Read control frames until one tagged `tag` satisfies `pred`, within 10 s. -/// The reply shares the stream with periodic `tasks` snapshots. +/// Read control frames for up to 10 s until a frame tagged `tag` satisfies `pred`. Skip +/// periodic `tasks` snapshots on the same stream. fn next_frame(stream: &mut UnixStream, tag: &str, pred: impl Fn(&str) -> bool) -> String { let deadline = Instant::now() + Duration::from_secs(10); loop { @@ -124,13 +122,13 @@ fn hello_discovers_agents_and_spawn_agent_launches_a_managed_task() { let (dir, mut daemon, mut stream) = start_daemon_raw("agents", |_| {}); hello(&mut stream, &s, "bin"); - // Discovery follows the handshake: only the stub is on this PATH. + // Discover agents after the handshake; only the stub is on this PATH. assert_eq!( next_frame(&mut stream, "agents", |_| true), r#"{"t":"agents","agents":["claude"]}"# ); - // A registered word launches a managed task, acked like a direct spawn. + // Launch a registered word as a managed task and acknowledge it as for a direct spawn. stream .write_all(&spawn_agent_frame("claude", &s.work())) .unwrap(); @@ -153,7 +151,7 @@ fn hello_discovers_agents_and_spawn_agent_launches_a_managed_task() { "the launch must carry the harness argv: {argv:?}" ); - // An unregistered word is refused with a notice and adds no task. + // Refuse an unregistered word with a notice and no new task. stream .write_all(&spawn_agent_frame("vim", &s.work())) .unwrap(); @@ -166,8 +164,8 @@ fn hello_discovers_agents_and_spawn_agent_launches_a_managed_task() { "a refused word must add no task: {tasks}" ); - // A reconnect from a PATH without the stub resends the list empty, so - // the new client never inherits the previous one's menu. + // Reconnect with a PATH without the stub. Resend an empty list to clear the previous + // client's menu. drop(stream); let sock = dir.join("default.sock"); let mut again = None; diff --git a/tests/daemon_resume.rs b/tests/daemon_resume.rs index 7dc4e98..831b3b0 100644 --- a/tests/daemon_resume.rs +++ b/tests/daemon_resume.rs @@ -1,7 +1,6 @@ -//! Agent resume crosses the managed launch, daemon framing, child -//! instrumentation, session persistence, and reload. These tests exercise that -//! complete path with stub `claude` and `codex` executables. An explicit -//! handshake keeps every path inside the test's scratch tree. +//! Verify agent resume through managed launch, daemon framing, child instrumentation, +//! persistence, and reload. Use stub `claude` and `codex` executables and an explicit +//! handshake to keep all paths inside the scratch tree. mod common; @@ -24,12 +23,12 @@ const RUN_MARKER: &str = "-- run --"; /// Fixed v7-shaped thread ID reported by the `codex` stub as its root. const CODEX_ID: &str = "019f5453-de22-7240-b2e5-0d32692aa6d9"; -/// Hidden title thread the `codex` stub reports after the root. Like the -/// real one, it never gets a rollout. +/// Hidden title thread reported by the `codex` stub after the root. Omit its rollout, as +/// for real title threads. const CODEX_TITLE: &str = "019f5453-de9f-7e61-8c0d-1a2b3c4d5e6f"; -/// Root rollout header the `codex` stub saves before notifying: `session_id` -/// is the thread's own ID and `source` is a string. +/// Root rollout header saved by the `codex` stub before notifying: `session_id` equals the +/// thread's ID, and `source` is a string. const CODEX_HEADER: &str = r#"{"timestamp":"2026-10-04T17:49:56.012Z","ordinal":0,"type":"session_meta","payload":{"id":"019f5453-de22-7240-b2e5-0d32692aa6d9","session_id":"019f5453-de22-7240-b2e5-0d32692aa6d9","source":"cli"}}"#; /// Notification JSON for a completed turn of `thread`. @@ -87,7 +86,7 @@ impl Scratch { self.root.join(format!("{tool}-argv")) } - /// Marker the `codex` stub touches after both of its notifications. + /// Marker touched by the `codex` stub after both notifications. fn notified(&self) -> PathBuf { self.root.join("codex-notified") } @@ -177,12 +176,10 @@ printf 'Resume this session with:\nclaude --resume %s\n' "$id""#, install_stub(s, "claude", &body); } -/// Install a `codex` stub to record argv and report through the injected -/// notify program alone, as the real TUI does. It saves the root's rollout -/// header under `$CODEX_HOME`, notifies for the root, then notifies for the -/// title thread, which has no rollout, and finally touches the `notified` -/// marker. The notify program is read back from the `notify=[""]` -/// override in its own argv. +/// Install a `codex` stub to record argv and report only through the injected notifier, as +/// in the real TUI. Save the root rollout header under `$CODEX_HOME`; notify for the root, +/// then the title thread without a rollout; finally, touch `notified`. Read the notifier +/// path from the `notify=[""]` argv override. fn install_codex_stub(s: &Scratch) { let body = format!( r#"printf '%s\n' '{marker}' "$@" >> '{rec}' @@ -269,9 +266,9 @@ fn assert_daemon_namespaced(asset: &Path, daemon_pid: u32, what: &str, argv: &[S ); } -/// Save once and return the persisted recipe. Each caller first waits for its -/// ID channel, and the daemon scrapes finished tasks before reading IDs. As a -/// result, one save must already carry the resume ID. +/// Save once and return the persisted recipe. Wait for the ID channel at each call site; +/// scrape finished tasks in the daemon before reading IDs. Require the resume ID in that +/// first save. fn save_once(stream: &mut UnixStream, recipe: &Path, name: &str) -> String { stream .write_all(&control_frame(&format!( @@ -285,9 +282,9 @@ fn save_once(stream: &mut UnixStream, recipe: &Path, name: &str) -> String { std::fs::read_to_string(recipe).unwrap() } -/// Every file in the daemon namespaces under `runtime`, as -/// `(name, contents)` sorted by name. Assets live under -/// `/-/`, so the runtime root itself holds no task files. +/// Read every file in daemon namespaces under `runtime` as `(name, contents)`, sorted by +/// name. Look under `/-/`; no task files are stored directly in the +/// runtime root. fn namespace_files(runtime: &Path) -> Vec<(String, String)> { let mut files: Vec<(String, String)> = std::fs::read_dir(runtime) .into_iter() @@ -314,14 +311,13 @@ fn capture_slot(runtime: &Path) -> Option { .map(|(_, contents)| contents) } -/// The managed entry a recipe writes for `agent` resuming `id`, as `to_json` -/// pretty-prints it. +/// Managed entry for `agent` resuming `id`, in the pretty-printed `to_json` format. fn managed_entry(agent: &str, id: &str) -> String { format!("{{\n \"agent\": \"{agent}\",\n \"resume\": \"{id}\"\n }}") } -/// A managed claude captures an ID without leaking its injected flags into -/// the recipe, then reloads the same conversation. +/// Capture a managed Claude ID, save without injected flags in the recipe, and reload the +/// same conversation. #[test] fn claude_spawn_save_load_resumes_the_conversation() { let s = Scratch::new("claude"); @@ -374,9 +370,9 @@ fn claude_spawn_save_load_resumes_the_conversation() { stop_daemon(&mut daemon); } -/// A Codex notification validated by the daemon's own binary persists the -/// resume ID, the title thread's later notification leaves it intact, and -/// loading that entry reapplies the notifier instrumentation. +/// Persist the resume ID after validating a Codex notification through the daemon's binary. +/// Preserve it after a later title notification and reapply notifier instrumentation on +/// reload. #[test] fn codex_capture_file_drives_save_and_load_resumes() { let s = Scratch::new("codex"); @@ -403,10 +399,9 @@ fn codex_capture_file_drives_save_and_load_resumes() { "a bare spawn must receive the two overrides and nothing else" ); - // The stub exits silently, so the notify script is the only id channel. - // Wait for both notifications: the root's must have written the bare - // UUID and the title thread's must have left it alone, with no temporary - // file beside it. A single save must then persist the resuming form. + // Exit the stub silently so the notify script is the only ID channel. Wait for both + // notifications, then require a bare root UUID and no temporary file. Preserve the root + // after the title notification and persist the resume ID on the first save. let ok = wait_until(Duration::from_secs(10), || s.notified().exists()); assert!(ok, "the codex stub never finished notifying"); let files = namespace_files(&s.runtime()); @@ -452,10 +447,9 @@ fn codex_capture_file_drives_save_and_load_resumes() { stop_daemon(&mut daemon); } -/// The notify mode runs headless, before the terminal check and daemon -/// autostart: without a tty it writes the bare root and exits 0, prints -/// nothing, refuses the title thread with exit 1 and no write, and is a -/// no-op without a capture path. +/// Run notify mode without terminal setup or daemon autostart. With no TTY, write the bare +/// root and exit 0 without output. Refuse the title thread with exit 1 and no write. +/// Without a capture path, write nothing. #[test] fn codex_notify_mode_runs_headless() { let s = Scratch::new("notify_mode"); @@ -500,8 +494,7 @@ fn codex_notify_mode_runs_headless() { assert_eq!(names, ["task-1-0.json"], "no temporary file may remain"); } -/// A persisted managed entry survives daemon replacement and targets the same -/// ID afterward. +/// Persist a managed entry, replace the daemon, and resume the same ID afterward. #[test] fn saved_recipe_resumes_across_a_daemon_restart() { let s = Scratch::new("restart"); From ad927f02b3678516691450ef1f87b05cd50f34db Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Mon, 5 Oct 2026 23:21:31 -0700 Subject: [PATCH 11/13] refactor: Improve documentation and comments across multiple modules --- src/app.rs | 56 ++++++++++--------- src/main.rs | 17 +++--- src/protocol.rs | 29 +++++----- src/session.rs | 96 ++++++++++++++++----------------- src/supervisor.rs | 135 +++++++++++++++++++++------------------------- src/task.rs | 34 +++++------- src/ui.rs | 23 ++++---- 7 files changed, 180 insertions(+), 210 deletions(-) diff --git a/src/app.rs b/src/app.rs index ce0dacc..fcacd6e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -139,8 +139,8 @@ pub enum SessionPage { Recovery, } -/// Active page of the spawn prompt. `Command` runs typed text literally; -/// `Agent` launches a picked agent as a managed task. +/// Active page of the spawn prompt: `Command` for literal text, `Agent` for managed +/// launches. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum SpawnPage { Command, @@ -203,9 +203,8 @@ pub struct App { transport: Box, /// Task snapshot received from `Event::Tasks`. pub views: Vec, - /// Agents installed on the core's launch `PATH`, from the latest - /// `Event::Agents`; program words in registry order. The spawn prompt's - /// Agent page lists it and exists only while it is non-empty. + /// Installed agents from the latest `Event::Agents`, in registry order. Show the Agent + /// page only when this list is non-empty. pub agents: Vec, /// The watched task's screen (attach/peek), from `Event::Screen`. focused_screen: Option, @@ -232,12 +231,12 @@ pub struct App { /// Group assigned to the next spawn. Custom mode snapshots the selected /// task's group; State and Dir modes leave the spawn unassigned. pub spawn_group: Option, - /// The displayed spawn-prompt page; opening the prompt resets it to - /// `Command`. Tab toggles it only while `agents` is non-empty. + /// Displayed spawn-prompt page. Reset to `Command` when opening the prompt; allow Tab + /// to switch pages only when `agents` is non-empty. pub spawn_page: SpawnPage, - /// The Agent page's filter: its own buffer, so typed command text is - /// never consumed as a filter and both survive a page flip. Empty at - /// every open, so Tab always shows the full list first. + /// Agent filter buffer, separate from the command buffer. Preserve both across page + /// changes. Clear the filter when opening the prompt so the initial Agent list is + /// unfiltered. pub agent_input: EditBuffer, /// Program words of `agents` matching `agent_input`, in registry order. pub agent_candidates: Vec, @@ -779,13 +778,14 @@ impl App { match ev { // The handshake is handled before the transport is created. Event::HelloOk => {} - // Each context install resends the whole list; replace, never merge. + // Replace the whole list on each context install; do not merge it with the + // previous list. Event::Agents(a) => { self.agents = a; - // A reconnect can replace the list under an open prompt. - // Refilter so the rows match, and leave an Agent page that - // Tab could no longer reach: the loader's emptied-recovery - // rule. + // After reconnecting, refilter an open prompt against the new list. If + // no agents are installed, switch to Command: the Agent page is no + // longer accessible through Tab. Apply the same rule as for an emptied + // Recovery page. if self.mode == Mode::Spawn { self.refresh_agent_candidates(); if self.agents.is_empty() { @@ -1075,8 +1075,8 @@ impl App { .collect() } - /// Lock in `dir` as the spawn target and move to command entry, with the - /// Agent page's filter cleared so Tab shows the full list. + /// Set `dir` as the spawn target and open command entry. Clear the Agent filter so the + /// full list is available on Tab. fn open_spawn_prompt(&mut self, dir: PathBuf) { self.spawn_cwd = dir; self.spawn_group = self.inherited_group(); @@ -1088,10 +1088,9 @@ impl App { self.mode = Mode::Spawn; } - /// Rebuild the Agent page as the installed agents whose program word - /// contains the lowercased filter, in registry order, and select the - /// first. Order never follows match position: `o` lists codex, grok, omp - /// as the registry does. + /// Filter installed agents by a case-insensitive substring of the program word and + /// select the first match. Preserve registry order rather than sorting by match + /// position: for `o`, list codex, grok, omp. fn refresh_agent_candidates(&mut self) { let needle = self.agent_input.to_lowercase(); self.agent_candidates = self @@ -1103,8 +1102,8 @@ impl App { self.agent_sel = 0; } - /// Registered agents absent from `agents`: what the no-match text names - /// so a filter for an uninstalled agent explains itself. + /// Registered agents absent from `agents`, for the no-match message. Identify missing + /// installations when the user filters for an unavailable agent. pub fn missing_agents(&self) -> Vec<&'static str> { harness::program_words() .filter(|w| !self.agents.iter().any(|a| a == w)) @@ -1542,8 +1541,8 @@ impl App { fn on_key_spawn(&mut self, k: KeyEvent) { match k.code { - // Pure page toggle, gated as the loader gates its recovery page. - // Without agents Tab falls through to the editor, which ignores it. + // Allow page changes only when agents are installed, as with the Recovery page. + // Otherwise, pass Tab to the editor, where it is ignored. KeyCode::Tab | KeyCode::BackTab if !self.agents.is_empty() => { self.spawn_page = match self.spawn_page { SpawnPage::Command => SpawnPage::Agent, @@ -1561,9 +1560,8 @@ impl App { } } - /// Agent-page keys: filter, pick, and launch the highlighted agent as a - /// managed task in the prompt's directory and group. Enter on no match - /// leaves the prompt open. + /// Handle Agent-page filtering, selection, and launch in the prompt's directory and + /// group. Keep the prompt open on Enter when there is no match. fn on_key_agent(&mut self, k: KeyEvent) { match k.code { KeyCode::Esc => self.close_prompt(), @@ -1581,7 +1579,7 @@ impl App { self.close_prompt(); } } - // Caret motion does not affect the matches. + // Do not refilter on caret motion. _ => { if on_key_edit(&mut self.agent_input, k) { self.refresh_agent_candidates(); diff --git a/src/main.rs b/src/main.rs index d76da02..4f2b9ff 100644 --- a/src/main.rs +++ b/src/main.rs @@ -98,8 +98,8 @@ enum Invocation { Version, Daemon, Kill, - /// `--codex-notify-v1 `: the injected codex notify script's - /// validation step, carrying one notification JSON. + /// `--codex-notify-v1 `: validate one notification JSON from the injected + /// codex notify script. CodexNotify(String), Client { foreground: bool, @@ -125,8 +125,8 @@ fn parse_args(args: &[String]) -> Result { "--daemon" => daemon = true, "--kill" => kill = true, "--codex-notify-v1" => { - // The payload is taken verbatim: JSON never starts with `-`, - // and a flag-shaped value is refused later as non-JSON. + // Take the payload verbatim. JSON cannot begin with `-`; reject a + // flag-shaped value later as non-JSON. let v = it .next() .ok_or_else(|| "--codex-notify-v1 requires a value".to_string())?; @@ -205,9 +205,8 @@ fn run() -> io::Result<()> { Ok(Invocation::Daemon) => return daemon::run_daemon(), // `fleetcom --kill`: tell a running daemon to kill everything and exit. Ok(Invocation::Kill) => return daemon::run_kill(), - // The codex notify script's validation step: headless, silent, and - // exiting 1 on refusal. The status is informational only; the - // script ignores it. + // Validate the codex notification without terminal setup or output. Exit 1 on + // refusal; the calling script ignores this informational status. Ok(Invocation::CodexNotify(payload)) => { let env = |key: &str| std::env::var_os(key).map(std::path::PathBuf::from); if harness::record_arrival(&payload, &env).is_none() { @@ -472,8 +471,8 @@ mod tests { assert!(parse(&["--kill", "work"]).is_err()); } - /// The notify mode takes exactly its payload: no value, a second mode, - /// a session name, or a client flag is an error in either order. + /// Accept only the notify payload. Reject a missing value, another mode, a session + /// name, or a client flag, in either argument order. #[test] fn codex_notify_mode_takes_only_its_payload() { assert!(parse(&["--codex-notify-v1"]).is_err()); diff --git a/src/protocol.rs b/src/protocol.rs index 9261549..3237740 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -56,10 +56,9 @@ pub enum Command { cwd: PathBuf, group: Option, }, - /// Launch a registered agent as a managed task in `cwd`. `agent` is a - /// program word from `Event::Agents`, never a path: the core checks it - /// against its registry and resolves the binary on its own launch - /// context's `PATH` at spawn time. + /// Launch a registered agent as a managed task in `cwd`. Accept a program word from + /// `Event::Agents`, never a path. Check the core's registry and resolve the binary on + /// the launch context's `PATH` at spawn time. SpawnAgent { agent: String, cwd: PathBuf, @@ -69,9 +68,9 @@ pub enum Command { Kill { id: u64 }, /// Drop a task from the set entirely (used on already-finished tasks). Remove { id: u64 }, - /// Re-run a finished task with the same id, cwd, tag, group, and name. A - /// literal task reruns its exact text; a managed task resumes its captured - /// session. Reject this request for running tasks. + /// Re-run a finished task with the same id, cwd, tag, group, and name. For a literal + /// task, rerun the exact text; for a managed task, resume the captured session. Reject + /// this request for running tasks. Restart { id: u64 }, /// Set the manual "in use" tag. Tag { id: u64, on: bool }, @@ -187,10 +186,9 @@ pub struct Mods { pub enum Event { /// The daemon accepted a compatible hello frame and stored its launch context. HelloOk, - /// Program words of the registered agents found on the launch context's - /// `PATH`, in registry order. Sent whenever a context is installed, so a - /// reconnect replaces the list; empty when none resolve. Binary paths - /// stay in the core. + /// Registered program words found on the launch context's `PATH`, in registry order. + /// Send the complete list on every context install, including an empty list when none + /// are found, to replace the previous list on reconnect. Keep binary paths in the core. Agents(Vec), /// Full task-set snapshot; replaces the client's mirror wholesale. Tasks(Vec), @@ -339,8 +337,8 @@ pub struct TaskView { /// Whether this task is the flagship. True for at most one task per /// snapshot, and never for a finished task. pub flagship: bool, - /// Whether fleetcom launched this task as a managed agent: `command` is - /// then the agent's program word, and a rerun resumes through the harness. + /// Whether this task was launched as a managed agent. If true, `command` is the agent's + /// program word; use the harness to resume on rerun. pub managed: bool, /// Dashboard group; `None` means unassigned. pub group: Option, @@ -943,8 +941,9 @@ pub fn decode_event(kind: u8, payload: &[u8]) -> Option { let v = jzon::parse(std::str::from_utf8(payload).ok()?).ok()?; match v["t"].as_str()? { "hello_ok" => Some(Event::HelloOk), - // `str_vec` reads a missing or non-array value as empty; an - // empty menu is an explicit `[]`, so anything else is malformed. + // Require an explicit array, including `[]` for an empty menu. Without this + // check, missing and non-array values would be accepted as empty by + // `str_vec`. "agents" if v["agents"].is_array() => Some(Event::Agents(str_vec(&v["agents"])?)), "tasks" => { let mut views = Vec::new(); diff --git a/src/session.rs b/src/session.rs index dce9a65..14a692c 100644 --- a/src/session.rs +++ b/src/session.rs @@ -17,25 +17,22 @@ use crate::{ task::{pid_is_dead, positive_pid}, }; -/// What a recipe entry starts. The split mirrors the task it came from: a -/// literal task's text is stored and rerun untouched, and a managed task is -/// stored as the agent it names plus the conversation it last held, never as -/// a command string. +/// Launch specification for one recipe entry. Preserve a literal task's command text. For a +/// managed task, store the agent word and last conversation ID instead of a command string. #[derive(Debug, Clone, PartialEq, Eq)] pub enum EntryKind { /// Typed text, run under `$SHELL -c` exactly as stored. Literal(String), - /// An agent fleetcom launches and owns, named by its registered program - /// word. `resume` is the session to reopen; `None` starts a fresh one. + /// Managed agent identified by its registered program word. Use `resume` to reopen a + /// session, or `None` to start fresh. Managed { agent: String, resume: Option, }, } -/// One recipe entry. A literal without a group or name serializes as a bare -/// string; every other entry is an object, writing optional fields only when -/// set. +/// One recipe entry. Serialize a literal without group or name as a bare string. Use an +/// object for every other entry, writing optional fields only when set. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SessionEntry { pub kind: EntryKind, @@ -83,9 +80,9 @@ pub fn sessions_dir(root: Option) -> Option { .map(|base| base.join("sessions")) } -/// Session format version written by `to_json` and accepted by `from_json`. -/// Version 2 added managed entries. A missing version is version 1, whose -/// literal entries pass through [`migrate_v1`]; newer versions fail. +/// Session format version written by `to_json` and accepted by `from_json`. Managed entries +/// were introduced in version 2. Treat a missing version as 1 and convert its literals +/// through [`migrate_v1`]. Reject newer versions. const FORMAT_VERSION: u64 = 2; /// Build the recipe's `dirs` object. @@ -116,18 +113,17 @@ fn dirs_json(cfg: &SessionConfig) -> jzon::JsonValue { dirs } -/// Map a v1 literal onto the entry this build writes for the same task. Under -/// v1 the bare program word and its resume form were instrumented and resumed, -/// so loading either as literal would silently drop the conversation. Exactly -/// those two shapes convert, with v1's whitespace tolerance between tokens and -/// the single-quote pair v1 wrote around the ID. The first token must be the -/// registered word itself: a path (`/usr/local/bin/claude`, `~/bin/claude`) -/// names a binary the user chose, and a managed launch would run whatever -/// `PATH` finds first, so it stays literal and loses resume. Everything else -/// stays literal. +/// Convert v1 literal entries to the corresponding managed form. In v1, bare program words +/// and canonical resume commands were instrumented; preserving them as literals would +/// silently disable conversation tracking. Convert exactly those two forms, accepting v1's +/// whitespace between tokens and single-quoted IDs. Require an exact registered word as the +/// first token. With a path (`/usr/local/bin/claude`, `~/bin/claude`), preserve the user's +/// chosen binary as a literal rather than substitute the first binary found on `PATH`; +/// managed resume tracking is then unavailable. Leave all other commands literal. /// -/// Deleted one release after format v2 ships. From then on an unconverted v1 -/// file loads every entry as literal: no load fails, only resume is lost. +/// Remove this conversion one release after format v2 ships. After removal, load +/// unconverted v1 entries as literals: continue to accept the file, but without managed +/// resume tracking. fn migrate_v1(cmd: &str) -> EntryKind { let literal = || EntryKind::Literal(cmd.to_string()); let mut words = cmd.split([' ', '\t']).filter(|w| !w.is_empty()); @@ -172,15 +168,13 @@ pub fn fingerprint_json(cfg: &SessionConfig) -> String { dirs_json(cfg).dump() } -/// Parse wrapped and flat schemas, returning the stored name when present. -/// A wrapped file has an object-valued `dirs`; flat files have entry arrays at -/// the top level, including when a directory is literally named `dirs`. -/// A top-level `version` must be an integer from 1 through [`FORMAT_VERSION`]; -/// a missing version is interpreted as 1, and a version-1 file's literal -/// entries pass through [`migrate_v1`]. Entry shapes are accepted by shape, -/// whatever the version: a string or `{"cmd"}` object is literal, an -/// `{"agent"}` object is managed. An object with both keys, an unregistered -/// agent word, or a `resume` that is not a strict UUID rejects the whole file. +/// Parse wrapped and flat schemas, returning the stored name when present. Identify a +/// wrapped file by object-valued `dirs`; accept top-level entry arrays as a flat map, +/// including an array under a directory named `dirs`. Require an integer `version` from 1 +/// through [`FORMAT_VERSION`]. Treat a missing version as 1 and convert its literal entries +/// through [`migrate_v1`]. Accept entry forms regardless of version: strings and `{"cmd"}` +/// objects for literals, `{"agent"}` objects for managed agents. Reject the whole file for +/// objects with both keys, unregistered agents, or resume IDs that are not strict UUIDs. fn from_json(text: &str) -> io::Result<(Option, SessionConfig)> { let invalid = |message| io::Error::new(io::ErrorKind::InvalidData, message); let parsed = jzon::parse(text).map_err(|e| invalid(e.to_string()))?; @@ -226,7 +220,7 @@ fn from_json(text: &str) -> io::Result<(Option, SessionConfig)> { return Err(invalid(format!("directory {dir:?}: expected an array"))); } let mut entries = Vec::new(); - // A v1 literal may be an agent launch v1 could only spell as text. + // In v1, managed launches could only be stored as command text. let literal = |cmd: &str| { if v1 { migrate_v1(cmd) @@ -721,8 +715,8 @@ mod tests { assert_eq!(load_in(&dir, "versioned").unwrap(), cfg); } - /// Managed entries round-trip in every label combination, with and - /// without a resume ID, beside literal ones. + /// Round-trip managed entries in every label combination, with and without resume IDs, + /// alongside literals. #[test] fn round_trips_managed_entries() { let dir = temp("session_managed_roundtrip"); @@ -754,8 +748,8 @@ mod tests { assert_eq!(load_in(&dir, "managed").unwrap(), cfg); } - /// The managed object carries `agent` first, then `resume`, `group` and - /// `name` only when set; a managed entry is never a bare string. + /// Serialize managed entries as objects with `agent` first. Include `resume`, `group`, + /// and `name` only when set; never use a bare string. #[test] fn managed_entries_serialize_as_agent_objects_with_optional_fields() { let mut cfg = SessionConfig::new(); @@ -778,8 +772,8 @@ mod tests { ); } - /// The fingerprint changes with the resume ID, so a cadence pass notices - /// a captured session on an otherwise unchanged fleet. + /// Include the resume ID in the fingerprint so a cadence pass detects newly captured + /// sessions without other fleet changes. #[test] fn fingerprint_tracks_a_managed_entrys_resume_id() { let fp = |resume| { @@ -792,8 +786,7 @@ mod tests { assert_eq!(fp(Some(ID)), fp(Some(ID))); } - /// A v2 file's literal agent word is literal by construction: only v1 - /// files are migrated. + /// Preserve literal agent words in v2 files; migrate only v1 literals. #[test] fn v2_literal_agent_words_stay_literal() { let (_, cfg) = from_json(&format!( @@ -811,9 +804,9 @@ mod tests { ); } - /// The v1 → v2 mapping: the two shapes v1 instrumented become managed, - /// with v1's whitespace tolerance and its quoted ID; a path-qualified - /// word, extra arguments, shell syntax and malformed IDs stay literal. + /// Convert the two instrumented v1 forms to managed entries, accepting the original + /// whitespace and quoted IDs. Keep paths, extra arguments, shell syntax, and malformed + /// IDs literal. #[test] fn v1_literals_migrate_exactly_the_two_instrumented_shapes() { let cases: Vec<(String, SessionEntry)> = vec![ @@ -829,7 +822,8 @@ mod tests { // v1's token tolerance: tabs, repeated and surrounding whitespace. (" claude\t".into(), m("claude", None)), (format!("claude\t--resume '{ID}' "), m("claude", Some(ID))), - // A path names a binary the user chose: literal, resume lost. + // Preserve the user's chosen binary path as a literal, without managed resume + // tracking. ("/usr/local/bin/claude".into(), e("/usr/local/bin/claude")), ("~/bin/claude".into(), e("~/bin/claude")), ( @@ -903,8 +897,8 @@ mod tests { ); } - /// A managed entry that is also a command, names no registered agent, or - /// carries a malformed resume ID rejects the whole file. + /// Reject the entire file for a managed entry with a `cmd` field, an unregistered + /// agent, or a malformed resume ID. #[test] fn rejects_malformed_managed_entries() { let cases = [ @@ -992,8 +986,8 @@ mod tests { assert_eq!(cfg["~/proj"], vec![e("vim")]); } - /// A newer format fails with an error naming both versions: the gate a - /// v1 build applies to this build's files, and this build to the next. + /// Reject newer formats and name both versions in the error. Apply the same version + /// check as v1 builds when reading v2 files. #[test] fn newer_version_refuses_naming_both_versions() { let err = from_json(r#"{"version": 3, "name": "v", "dirs": {}}"#).unwrap_err(); @@ -1361,8 +1355,8 @@ mod tests { assert_eq!(list_in(&dir), vec!["old".to_string()]); } - /// A v1 recovery snapshot with the strings v1 wrote for managed tasks - /// loads them as managed entries, so recovery survives the upgrade. + /// Load v1 recovery snapshots as managed entries when the stored strings match the old + /// instrumented forms. Preserve recovery across the upgrade. #[test] fn v1_recovery_snapshots_migrate_on_load() { let base = temp("session_recovery_migrate"); diff --git a/src/supervisor.rs b/src/supervisor.rs index 5f73ee5..242674a 100644 --- a/src/supervisor.rs +++ b/src/supervisor.rs @@ -44,13 +44,12 @@ const _: () = assert!(MAX_CELLS >= MAX_DIM as u32); /// guardrail, not a working limit. const MAX_TASKS: usize = 256; -/// What a spawn was asked to run. The enum is the boundary: a literal task's -/// text is never edited by managed code, and a managed task's argv never -/// passes through a shell. +/// Requested launch mode. Preserve literal text and pass it to the shell unchanged; execute +/// managed argv directly without shell parsing. enum Launch<'a> { /// The text the user typed, run verbatim under `$SHELL -c`. Literal(&'a str), - /// An agent fleetcom launches and owns. + /// Managed agent under fleetcom supervision. Managed(ManagedLaunch), } @@ -62,18 +61,17 @@ struct ManagedLaunch { intent: Intent, } -/// Maximum literal command length in bytes. Direct spawns and session loads -/// enforce this limit to bound shell arguments and serialized task snapshots. -/// A managed entry carries a program word and a UUID, so it needs no cap. +/// Maximum literal command length in bytes. Enforce this limit for direct spawns and +/// session loads to bound shell arguments and serialized snapshots. Managed entries contain +/// only a registered program word and a UUID; no cap is needed. const MAX_COMMAND_LEN: usize = 64 * 1024; /// Conditions counted as skipped by `materialize`. const SKIP_REASONS: &str = "missing dir, task limit, or command too long"; -/// What `materialize` did with a recipe: the counts for the summary line, and -/// the per-entry reasons behind them. A load reports through that one line -/// because the client keeps only the latest status of a poll, so any status -/// queued ahead of the summary would never be seen. +/// Recipe-load counts and per-entry diagnostics from `materialize`. Report them together in +/// the summary: only the last status in a client poll is retained, so earlier diagnostics +/// would be overwritten. #[derive(Default)] struct LoadOutcome { spawned: usize, @@ -84,9 +82,8 @@ struct LoadOutcome { } impl LoadOutcome { - /// Append the distinct notes to `summary`, each once, with a count when - /// several entries share it: every codex in a recipe reports the same - /// unchainable notifier. + /// Append each distinct note to `summary` once, with a count for repeated notes. For + /// example, report an unchainable Codex notifier once for all affected entries. fn annotate(&self, mut summary: String) -> String { let mut distinct: Vec<(&str, usize)> = Vec::new(); for note in &self.notes { @@ -106,9 +103,8 @@ impl LoadOutcome { } } -/// Build the managed launch of `agent` with `intent`: the registered harness -/// and the binary `launch`'s `PATH` finds now. Either miss is the reason, for -/// the caller to report. +/// Resolve the registered harness and current binary on `launch`'s `PATH`, then build a +/// managed launch with `intent`. Return a diagnostic for the caller if either lookup fails. fn resolve_agent( agent: &str, launch: &LaunchContext, @@ -122,7 +118,7 @@ fn resolve_agent( }) } -/// Resolve `agent` on `launch`'s `PATH`; a miss is the reason. +/// Resolve `agent` on `launch`'s `PATH`, or return a diagnostic for the caller. fn agent_binary(agent: &str, launch: &LaunchContext) -> Result { let path = env_get(&launch.env, "PATH").unwrap_or_default(); harness::find_on_path(agent, path).ok_or_else(|| format!("{agent} not found on PATH")) @@ -208,12 +204,11 @@ fn fnv1a_hex(bytes: &[u8]) -> String { format!("{h:016x}") } -/// Resolve a managed task's session ID in precedence order: capture file, -/// live registry, then spawn-time ID. Prefer the first two sources because a -/// session may have been selected after launch. Validate the capture with the -/// task's leader PID and launch-time home through the harness. If rejected, -/// try the next source. A literal task has no harness, so every source is -/// skipped and the result is `None`. +/// Resolve a managed task's session ID in precedence order: capture file, live registry, +/// then spawn-time ID. Prefer the first two sources because a session may have been +/// selected after launch. Validate capture through the harness using the task's leader PID +/// and launch-time home. On rejection, try the next source. For literal tasks, skip all +/// sources and return `None`. fn current_resume_id(task: &Task) -> Option { if let (Some(h), Some(path)) = (task.harness, &task.capture_file) && let Ok(payload) = std::fs::read_to_string(path) @@ -346,8 +341,8 @@ pub struct Supervisor { /// TERM→KILL escalation window. `KILL_GRACE` in production; a field so tests /// shrink it instead of sleeping through real seconds. kill_grace: Duration, - /// Live-task ceiling. `MAX_TASKS` in production; a field so tests reach - /// it without opening hundreds of PTYs. + /// Live-task ceiling. Use `MAX_TASKS` in production; lower the field in tests to avoid + /// opening hundreds of PTYs. max_tasks: usize, /// Capture assets keyed by canonicalized root and reused for this /// supervisor's lifetime. @@ -379,11 +374,11 @@ impl Supervisor { } } - /// Install the launch context used by subsequent spawns, and queue the - /// agents found on its `PATH` as `Event::Agents`. Discovery runs here - /// rather than at daemon start because the daemon lives for weeks and the - /// context changes with every client; an empty list is still sent so a - /// reconnect from a poorer `PATH` clears the previous client's menu. + /// Install the launch context for subsequent spawns and queue the agents found on its + /// `PATH` as `Event::Agents`. Discover agents on every context install: the daemon may + /// run for weeks, but a different environment can be supplied by each client. Send an + /// empty list when no agents are found to clear the previous client's menu on + /// reconnect. pub fn set_launch_context(&mut self, ctx: LaunchContext) { let path = env_get(&ctx.env, "PATH").unwrap_or_default(); let agents = harness::installed(path) @@ -826,12 +821,10 @@ impl Supervisor { Some(self.capture.entry(key).or_insert(installed)) } - /// Spawn a direct command, managed agent, rerun, or session entry. The - /// task's `command` is display and recipe text: a literal's typed text, a - /// managed agent's program word. Only a managed launch carries - /// instrumentation: a literal's text reaches the shell untouched, whatever - /// it names. The second value is the plan's notice when the launch carries - /// less instrumentation than usual; the caller decides how to report it. + /// Spawn a literal command, managed agent, rerun, or session entry. Retain the typed + /// text or managed program word in `command` for display and persistence. Instrument + /// only managed launches; pass literal text to the shell unchanged. Return any + /// reduced-instrumentation notice alongside the task for the caller to report. fn spawn_task( &mut self, id: u64, @@ -846,17 +839,17 @@ impl Supervisor { let (command, exec) = match launch { Launch::Literal(text) => (*text, Exec::Literal((*text).to_string())), Launch::Managed(m) => { - // Ownership needs the overlay: without the embedded override a - // managed codex would hand its conversation to the shared - // server. Refuse rather than launch something fleetcom does - // not own. + // Require the overlay to retain process ownership. Without the embedded + // override, codex would use its shared server outside fleetcom supervision. + // Refuse the launch if the assets cannot be installed. let Some(paths) = self.ensure_capture_assets().map(|a| a.paths_for(id, run)) else { return Err(io::Error::other( "capture assets unavailable; a managed launch needs them", )); }; let home = harness_home(&env, m.agent); - // Mint only for a fresh conversation: a resume names its own. + // Mint an ID only for fresh conversations; reuse the specified ID on + // resume. let fresh = match m.intent { Intent::Fresh => harness::uuid_v4(), Intent::Resume(_) => None, @@ -899,16 +892,14 @@ impl Supervisor { task.resume_id = resume_id; } task.run = run; - // Returned rather than queued: a session load reports every task's - // notice in its one summary line, where a separate status would be - // overwritten by the summary in the same client poll. + // Return the notice for inclusion in the load summary. A separate status queued + // here would be overwritten by that summary in the same client poll. Ok((task, notice)) } - /// Spawn a task under the next id, normalize its labels, and return the id - /// with any launch notice. The caller enforces the task ceiling and reports - /// failures and notices because direct spawns and session loads handle them - /// differently. + /// Spawn under the next id, normalize labels, and return the id with any launch notice. + /// Enforce the task ceiling and report failures and notices at the call site, according + /// to whether this is a direct spawn or session load. fn admit( &mut self, launch: &Launch, @@ -926,23 +917,23 @@ impl Supervisor { Ok((id, notice)) } - /// Report one interactive spawn: its notice, then the acknowledgement that - /// selects the new row, or the failure. + /// Report an interactive spawn: send its notice, then acknowledge the new row for + /// selection. On failure, report the error. fn report_spawn(&mut self, admitted: io::Result<(u64, Option)>) { match admitted { Ok((id, notice)) => { if let Some(notice) = notice { self.status(format!("task {id}: {notice}")); } - // Preserve event order: the acknowledgement precedes the next - // `Tasks` snapshot containing this id. + // Send the acknowledgement before the next `Tasks` snapshot containing this + // id. self.events.push(Event::Spawned { id }); } Err(e) => self.status(format!("spawn failed: {e}")), } } - /// Whether the fleet has room for one more task, reporting when not. + /// Check the task ceiling and report when no more tasks can be admitted. fn below_task_ceiling(&mut self) -> bool { if self.tasks.len() >= self.max_tasks { self.status(format!( @@ -973,9 +964,9 @@ impl Supervisor { self.report_spawn(admitted); } - /// Launch `agent`, a registered program word, as a managed task with a - /// fresh conversation. The binary is whatever the launch context's `PATH` - /// finds now, never a path the client sent; no binary means no spawn. + /// Launch registered `agent` as a managed task with a fresh conversation. Resolve the + /// binary on the launch context's current `PATH`; do not accept a path from the client. + /// Refuse to spawn when no binary is found. fn spawn_agent(&mut self, agent: &str, cwd: PathBuf, group: Option) { if !self.below_task_ceiling() { return; @@ -991,10 +982,9 @@ impl Supervisor { self.report_spawn(admitted); } - /// Rerun a finished task in place while preserving its ID, tag, group, and name. - /// A managed task resumes its best-known ID through the harness, or starts - /// fresh without one, from whatever binary `PATH` finds now. A literal - /// task reruns its exact text. + /// Rerun a finished task in place, preserving its ID, tag, group, and name. For managed + /// tasks, resolve the binary on the current `PATH` and resume the best-known session + /// ID, or start fresh if none is known. For literal tasks, rerun the exact text. fn rerun(&mut self, id: u64) { let Some(i) = self.index_of(id) else { self.status(format!("rerun failed: no task {id}")); @@ -1016,7 +1006,7 @@ impl Supervisor { return; }; let cwd = self.tasks[i].cwd.clone(); - // Only a managed task carries a harness: the match is the kind split. + // Only managed tasks have a harness; use it to distinguish the launch mode. let command; let relaunch = match self.tasks[i].harness { Some(agent) => { @@ -1048,8 +1038,8 @@ impl Supervisor { fresh.tagged = self.tasks[i].tagged; fresh.group = self.tasks[i].group.clone(); fresh.name = self.tasks[i].name.clone(); - // The resume ID was read above, before retirement removes the - // displaced run's capture file. + // Read the resume ID before retiring the displaced run and removing its + // capture file. let old = std::mem::replace(&mut self.tasks[i], fresh); self.retire(old); // Reset the fingerprint for the replacement task's screen. @@ -1061,9 +1051,9 @@ impl Supervisor { } } - /// Build `{dir: [entries]}` in spawn order. Groups and names remain intact. - /// A managed task is written as its agent word and best-known session ID; - /// a literal task as its exact text. + /// Build `{dir: [entries]}` in spawn order, preserving groups and names. Save managed + /// tasks as agent words with the best-known session IDs; save literals as their exact + /// text. fn session_config(&self) -> SessionConfig { // `admit` appends monotonic IDs; `rerun` preserves both index and ID; // removal preserves relative order. @@ -1132,13 +1122,10 @@ impl Supervisor { self.events.push(Event::Sessions { names, recovery }); } - /// Spawn every entry of a loaded recipe, each in its (existing) dir. - /// Missing dirs are skipped rather than spawning tasks doomed to fail on - /// chdir. A managed entry goes through the same resolution as the Agent - /// page: an agent with no binary on this context's `PATH` is a reason and - /// a failed entry, the rest still load. Returns the outcome for - /// the caller's one summary line, or `None` when no launch context is - /// installed (already refused with its own notice). + /// Spawn each recipe entry in its existing directory; skip missing directories. Resolve + /// managed binaries as on the Agent page. Record a missing binary as a failed entry + /// with a diagnostic, then continue loading. Return the outcome for one summary line, + /// or `None` after reporting a missing launch context. fn materialize(&mut self, cfg: &SessionConfig) -> Option { let launch = self.launch_or_refuse()?; let mut out = LoadOutcome::default(); diff --git a/src/task.rs b/src/task.rs index 1100969..7a66003 100644 --- a/src/task.rs +++ b/src/task.rs @@ -96,9 +96,8 @@ pub struct Task { pub group: Option, /// Custom display name; `None` means unnamed. pub name: Option, - /// Agent harness of a managed task: the channel its session ID and - /// blocked status are read through. `None` for every literal task, so no - /// harness channel is ever read for one. + /// Harness for a managed task's session ID and blocked-status reads. `None` for literal + /// tasks: never read a harness channel for those tasks. pub harness: Option<&'static dyn crate::harness::Harness>, /// Harness home resolved from this run's launch environment. pub harness_home: Option, @@ -107,15 +106,13 @@ pub struct Task { pub summary_adapter: Option<&'static dyn crate::preview::SummaryAdapter>, /// Run number used to give each rerun a distinct capture path. pub run: u32, - /// Session ID pinned or resumed at spawn. Capture data or a live - /// registry record can supersede it. + /// Session ID pinned or resumed at spawn. Prefer a captured ID or matching live + /// registry ID when available. pub resume_id: Option, /// Capture path allocated for this task run. pub capture_file: Option, - /// Whether fleetcom owns this task's launch: the argv came from the - /// harness and ran with no shell, and a rerun resumes through the - /// harness again. A literal task's text is never edited, whatever it - /// names. + /// Whether this task was launched directly with harness-built argv. If true, use the + /// harness again on rerun. Never edit literal command text. pub managed: bool, /// Dashboard-preview resolution state; resets with the task on rerun /// because a rerun replaces the whole `Task`. @@ -211,16 +208,15 @@ fn wait_code(status: &rustix::process::WaitIdStatus) -> i32 { .unwrap_or(1) } -/// How a task's child starts: the point where the literal/managed boundary -/// becomes a process. +/// Child execution mode: literal shell text or managed argv. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Exec { /// The text as typed, run as `$SHELL -c `. Literal(String), - /// `binary` with `args`, no shell between. The task leader is the agent - /// process in every login shell: claude's capture gate and registry - /// reader key on the leader pid, and a resident shell (tcsh, csh, a - /// wrapper script) would otherwise hold it. + /// Run `binary` with `args` directly. Keep the agent as task leader regardless of the + /// login shell: validate Claude capture and registry records against that PID. With a + /// resident shell (tcsh, csh, or a wrapper script), the shell would be the leader + /// instead. Managed { binary: PathBuf, args: Vec, @@ -256,11 +252,9 @@ impl Task { let managed = matches!(exec, Exec::Managed { .. }); let mut cmd = match exec { Exec::Literal(text) => { - // The daemon inherits the first client's environment, which may - // name a different shell from the connecting client's. Read SHELL - // from the launch context so a zsh client attached to a - // bash-started daemon still gets zsh word-splitting. Without - // SHELL, use the portable default. + // Read SHELL from the launch context, not the daemon's environment: a zsh + // client may connect to a daemon started from bash and still require zsh + // word-splitting. Without SHELL, use the portable default. let shell = env_get(env, "SHELL") .map(OsString::from) .unwrap_or_else(|| "/bin/sh".into()); diff --git a/src/ui.rs b/src/ui.rs index a97499d..5bb47c2 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -212,8 +212,8 @@ fn render_dashboard(out: &mut impl Write, app: &App) -> io::Result<()> { }, } - // Keep common actions visible and route the remaining bindings through `?`. - // The spawn prompt owns its footer: that is where it advertises Tab. + // Keep common actions visible and route the remaining bindings through `?`. In the + // spawn prompt, use the footer for the Tab hint. let footer = match app.mode { Mode::Spawn => spawn_page_hint(app.agents.len()), _ => dashboard_hint(app.chord_target()), @@ -242,8 +242,8 @@ fn transient_line(notice: Option<&str>, status: Option<&str>) -> Option notice.or(status).map(|s| format!(" {s}")) } -/// Build the spawn prompt's Command-page footer. Include the Agent page only -/// when the core found agents, as the loader includes recovery. +/// Build the Command-page footer. Include the Agent-page hint only when agents are +/// installed, as with the Recovery-page hint in the session picker. fn spawn_page_hint(agents: usize) -> String { if agents > 0 { format!(" enter run · tab agents ({agents}) · esc") @@ -257,7 +257,7 @@ fn spawn_page_hint(agents: usize) -> String { /// hint/status instead. fn cmdline(app: &App) -> Option<(String, u16)> { let prefix = match app.mode { - // The Agent page paints its own field in its panel. + // Render the Agent input field in its panel instead. Mode::Spawn if app.spawn_page == SpawnPage::Agent => return None, Mode::Spawn => spawn_prefix(app), Mode::SaveSession => " save session as: ".to_string(), @@ -857,9 +857,8 @@ fn render_find(out: &mut impl Write, app: &App) -> io::Result<()> { ) } -/// The Agent page's placeholder when the filter matches nothing. Name the -/// registered agents this host lacks, so a filter for one of them explains -/// itself; `missing` is in registry order. +/// Build the Agent-page placeholder for an empty result. List missing registered agents to +/// explain why an unavailable agent was not matched; `missing` is in registry order. fn agent_empty_text(missing: &[&str]) -> String { if missing.is_empty() { " (no installed agent matches)".to_string() @@ -871,9 +870,9 @@ fn agent_empty_text(missing: &[&str]) -> String { } } -/// The spawn prompt's Agent page: a bottom panel with the same `❯` destination -/// prefix as the Command page, the filter field, and the matching installed -/// agents in registry order with `agent_sel` highlighted. +/// Render the Agent page in a bottom panel: the same `❯` destination prefix as on Command, +/// the filter field, and matching installed agents in registry order. Highlight +/// `agent_sel`. fn render_agent_page(out: &mut impl Write, app: &App) -> io::Result<()> { let prefix = format!("{}agent: ", spawn_prefix(app)); let (line, cx) = caret_line(&prefix, &app.agent_input, app.cols as usize); @@ -1255,7 +1254,7 @@ mod tests { ); } - /// Advertise the Agent page in the spawn footer only when agents exist. + /// Include the Agent-page hint in the spawn footer only when agents are installed. #[test] fn spawn_page_hint_shows_the_count_only_when_nonzero() { assert_eq!(spawn_page_hint(0), " enter run · esc"); From 2d5db37993fc2b845689009e49c2ff9f5048a9e6 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Mon, 5 Oct 2026 23:21:35 -0700 Subject: [PATCH 12/13] feat(docs): enhance comments for clarity and consistency across various modules --- src/app_tests.rs | 81 ++++++------- src/protocol_tests.rs | 16 ++- src/supervisor_capture_tests.rs | 205 +++++++++++++++----------------- src/supervisor_tests.rs | 21 ++-- src/task_tests.rs | 14 +-- 5 files changed, 153 insertions(+), 184 deletions(-) diff --git a/src/app_tests.rs b/src/app_tests.rs index cb89a75..5c0a729 100644 --- a/src/app_tests.rs +++ b/src/app_tests.rs @@ -3083,7 +3083,7 @@ fn state_and_dir_mode_spawns_stay_unassigned() { // --- `n` spawn prompt: Agent page -------------------------------------- -/// Hand the core's events to the app with no core behind them. +/// Supply core events to the app without a running core. struct Scripted(Vec); impl Transport for Scripted { @@ -3101,9 +3101,9 @@ impl Transport for Scripted { } impl App { - /// A detached app that lists `agents`: `new_local` never pumps here, so - /// the machine's real agents never replace the list, and Enter reaches - /// no core. Returns the send log. + /// Create a detached app with the supplied `agents` list and return the send log. Do + /// not pump `new_local`, so the host's installed agents are never read into the list. + /// Do not forward Enter to a core. fn with_agents(agents: &[&str]) -> (Self, Arc>>) { let mut app = Self::new_local(30, 100); app.agents = agents.iter().map(|a| a.to_string()).collect(); @@ -3112,7 +3112,7 @@ impl App { (app, sent) } - /// `n`, then Tab onto the Agent page. + /// Press `n`, then Tab to open the Agent page. fn open_agent_page(&mut self) { self.on_key_dashboard(key(KeyCode::Char('n'))); self.on_key_spawn(key(KeyCode::Tab)); @@ -3127,9 +3127,8 @@ impl App { } } -/// An app whose core's `PATH` holds a stub `claude` and nothing else, so -/// discovery lists exactly that and a managed launch runs the stub rather -/// than a real agent. Capture assets and the claude registry read stay in +/// Create an app with only a stub `claude` on the core's `PATH`. Discover and launch that +/// stub without running a real agent. Keep capture assets and Claude registry reads inside /// the scratch tree. fn stub_claude_fixture(tag: &str) -> (App, Scratch) { let dir = temp(&format!("agent_{tag}")); @@ -3156,8 +3155,8 @@ fn stub_claude_fixture(tag: &str) -> (App, Scratch) { (app, dir) } -/// Tab flips between the Command and Agent pages only while the core lists -/// agents; without any, both Tab forms fall through and the page holds. +/// Switch between Command and Agent with Tab only when agents are installed. With no +/// agents, ignore Tab and BackTab and keep the current page. #[test] fn spawn_tab_toggles_pages_only_with_agents() { let (mut app, _) = App::with_agents(&[]); @@ -3184,9 +3183,8 @@ fn spawn_tab_toggles_pages_only_with_agents() { assert_eq!(app.spawn_page, SpawnPage::Agent); } -/// Typing filters by a lowercase substring of the program word. The rows -/// keep registry order whatever the match position, and the first match is -/// selected after every edit. +/// Filter by a case-insensitive substring of the program word. Preserve registry order +/// regardless of match position, and select the first match after every edit. #[test] fn agent_filter_matches_substrings_in_registry_order() { let (mut app, _) = App::with_agents(&["claude", "codex", "grok", "omp"]); @@ -3213,8 +3211,8 @@ fn agent_filter_matches_substrings_in_registry_order() { assert_eq!(app.agent_candidates, ["grok"], "backspace re-widens"); } -/// Down and Up step through the matches and clamp at both ends; a filter -/// edit returns the highlight to the first match. +/// Move through matches with Down and Up, clamping at both ends. Select the first match +/// after a filter edit. #[test] fn agent_arrows_step_within_the_matches() { let (mut app, _) = App::with_agents(&["claude", "codex", "grok", "omp"]); @@ -3233,13 +3231,8 @@ fn agent_arrows_step_within_the_matches() { assert_eq!(app.agent_sel, 0, "a filter edit reselects the first match"); } -/// The common case, `n` → Tab → Enter: `SpawnAgent` for the first installed -/// agent in the invocation directory, the prompt closed, and the core admits -/// the task as managed with the program word as its row label. -/// A session load reports every task's launch notice and every failure in -/// the status the app actually displays. The core queues the per-task -/// reasons and the summary in one batch, and `sync` keeps only the last -/// status, so the summary itself must carry them. +/// Include every launch notice and failure in the displayed session-load status. In `sync`, +/// only the last status in a poll is retained, so include all reasons in the final summary. #[test] fn session_load_status_keeps_capture_notices_and_failures() { let dir = temp("load_status"); @@ -3248,7 +3241,7 @@ fn session_load_status_keeps_capture_notices_and_failures() { std::fs::create_dir_all(d).unwrap(); } write_executable(&bin.join("codex"), "exit 0"); - // A notify route fleetcom cannot chain: codex launches without capture. + // Use an unchainable notify route to launch codex without capture. std::fs::write(codex_home.join("config.toml"), "notify = [1]\n").unwrap(); let ctx = crate::protocol::LaunchContext { env: vec![ @@ -3301,6 +3294,9 @@ fn session_load_status_keeps_capture_notices_and_failures() { } } +/// Press `n`, Tab, then Enter to launch the first installed agent in the invocation +/// directory. Close the prompt, admit a managed task, and label its row with the +/// program word. #[test] fn agent_enter_launches_the_highlighted_agent_managed() { let (mut app, _dir) = stub_claude_fixture("enter"); @@ -3325,9 +3321,8 @@ fn agent_enter_launches_the_highlighted_agent_managed() { ); } -/// Enter on a stepped-to row names that row, with the directory `@` picked -/// and the group custom mode inherits: the destination a Command-page Enter -/// would use. +/// Launch the selected row on Enter, using the directory selected through `@` and the group +/// inherited in Custom mode. Use the same destination as for Command-page launches. #[test] fn agent_enter_uses_the_picked_directory_and_inherited_group() { let root = temp("agent_dest"); @@ -3336,8 +3331,8 @@ fn agent_enter_uses_the_picked_directory_and_inherited_group() { app.selected_id = Some(1); app.group_mode = GroupMode::Custom; - // The trailing slash leaves no fragment, so row 0 (the resolved path, - // DirKind::Use) stays selected and Enter hands off to Spawn. + // With a trailing slash, there is no path fragment. Keep row 0 selected (the resolved + // path, `DirKind::Use`) and open Spawn on Enter. type_pickdir(&mut app, &format!("{}/", root.display())); app.on_key_pickdir(key(KeyCode::Enter)); assert_eq!(app.mode, Mode::Spawn); @@ -3358,8 +3353,7 @@ fn agent_enter_uses_the_picked_directory_and_inherited_group() { assert_eq!(app.mode, Mode::Dashboard); } -/// Enter with no match sends nothing and leaves the prompt open on the -/// Agent page. +/// With no match, send nothing on Enter and keep the prompt open on the Agent page. #[test] fn agent_enter_with_no_match_sends_nothing() { let (mut app, sent) = App::with_agents(&["claude"]); @@ -3372,7 +3366,7 @@ fn agent_enter_with_no_match_sends_nothing() { assert!(sent.lock().unwrap().is_empty()); } -/// Esc closes the prompt from the Agent page without sending. +/// Close the prompt on Esc without sending a command. #[test] fn agent_page_esc_closes_the_prompt_without_sending() { let (mut app, sent) = App::with_agents(&["claude"]); @@ -3383,9 +3377,8 @@ fn agent_page_esc_closes_the_prompt_without_sending() { assert!(sent.lock().unwrap().is_empty()); } -/// Each page keeps its own buffer across Tab: typed command text is never a -/// filter, and both survive the flip. Reopening the prompt clears both and -/// lands on the Command page, so Tab shows the full list again. +/// Preserve separate command and filter buffers across Tab. Clear both when reopening the +/// prompt on the Command page, so the full agent list is available on Tab. #[test] fn spawn_pages_keep_separate_buffers_until_the_prompt_reopens() { let (mut app, _) = App::with_agents(&["claude", "codex"]); @@ -3417,8 +3410,8 @@ fn spawn_pages_keep_separate_buffers_until_the_prompt_reopens() { ); } -/// With agents listed, the Command page still runs typed text literally: -/// a typed `claude` is a `Spawn`, never a `SpawnAgent`. +/// Run Command-page text literally even when agents are installed: submit a typed `claude` +/// as `Spawn`, never `SpawnAgent`. #[test] fn command_page_enter_still_spawns_the_literal_text() { let (mut app, sent) = App::with_agents(&["claude"]); @@ -3436,7 +3429,7 @@ fn command_page_enter_still_spawns_the_literal_text() { ); } -/// A paste on the Agent page lands in the filter, not the command buffer. +/// Paste into the filter on the Agent page, without changing the command buffer. #[test] fn agent_page_paste_filters_the_rows() { let (mut app, _) = App::with_agents(&["claude", "codex"]); @@ -3447,8 +3440,8 @@ fn agent_page_paste_filters_the_rows() { assert!(app.input.is_empty(), "the command buffer is untouched"); } -/// A reconnect's `Agents` event refilters an open Agent page, and an empty -/// list returns it to the Command page, where Tab can no longer leave. +/// Refilter an open Agent page after receiving `Agents` on reconnect. With an empty list, +/// switch to Command and disable Tab. #[test] fn agents_event_refilters_and_an_empty_list_leaves_the_agent_page() { let (mut app, _) = App::with_agents(&["claude", "codex"]); @@ -3472,9 +3465,9 @@ fn agents_event_refilters_and_an_empty_list_leaves_the_agent_page() { assert_eq!(app.spawn_page, SpawnPage::Command); } -/// The Command page footer advertises the Agent page only when agents exist; -/// the Agent page paints the `❯` destination, the filter, the matching rows -/// with the first highlighted, and its own hint. +/// Include the Agent-page hint in the Command footer only when agents are installed. On the +/// Agent page, render the `❯` destination, filter, matching rows, first-row highlight, and +/// page hint. #[test] fn agent_page_paints_the_rows_and_highlights_the_first_match() { let (mut app, _) = App::with_agents(&[]); @@ -3508,8 +3501,8 @@ fn agent_page_paints_the_rows_and_highlights_the_first_match() { ); } -/// A filter that matches nothing names the registered agents this host -/// lacks, in registry order. +/// When nothing matches, list the registered agents missing from the host in registry +/// order. #[test] fn agent_page_no_match_names_the_missing_agents() { let (mut app, _) = App::with_agents(&["claude", "codex"]); diff --git a/src/protocol_tests.rs b/src/protocol_tests.rs index a9d1ca3..546c043 100644 --- a/src/protocol_tests.rs +++ b/src/protocol_tests.rs @@ -860,10 +860,9 @@ fn flagship_wire_form() { } } -/// `SpawnAgent` names the agent by program word and encodes the cwd as -/// base64, omitting `"group"` when unassigned, as `Spawn` does. The decoder -/// requires a string word; refusing a path or an unknown word is the core's -/// registry check, not the decoder's. +/// Encode the `SpawnAgent` program word as a string and cwd as base64. Omit `"group"` when +/// unassigned, as for `Spawn`. Require a string word during decoding; reject paths and +/// unknown words in the core's registry check. #[test] fn spawn_agent_wire_form() { let (k, p) = encode_command(&Command::SpawnAgent { @@ -913,10 +912,9 @@ fn spawn_agent_wire_form() { } } -/// `Agents` round-trips empty and populated lists in order and pins its -/// wire shape. A missing list, a non-array, or a non-string member rejects -/// the event rather than reading as "no agents": the client indexes the -/// list by position, and an empty menu is an explicit `[]`. +/// Round-trip empty and populated `Agents` lists in order and check the wire format. Reject +/// missing lists, non-arrays, and non-string members. Encode an empty menu as `[]`; do not +/// treat malformed input as "no agents". #[test] fn agents_event_round_trips_and_pins_the_wire_shape() { for agents in [ @@ -953,7 +951,7 @@ fn agents_event_round_trips_and_pins_the_wire_shape() { } } -/// Every task row carries `managed` as a required boolean, both ways. +/// Require a boolean `managed` field on every task row during encoding and decoding. #[test] fn tasks_frame_managed_flag_round_trips_and_is_required() { for managed in [false, true] { diff --git a/src/supervisor_capture_tests.rs b/src/supervisor_capture_tests.rs index d855b26..87e5a42 100644 --- a/src/supervisor_capture_tests.rs +++ b/src/supervisor_capture_tests.rs @@ -7,16 +7,15 @@ use crate::{ // --- session-capture wiring ------------------------------------------- -/// Install an executable stub that records `FLEETCOM_CAPTURE_FILE`, -/// `FLEETCOM_BINARY`, and its argv, one token per line, then exits. +/// Install an executable stub to record `FLEETCOM_CAPTURE_FILE`, `FLEETCOM_BINARY`, and +/// argv, one token per line, then exit. fn install_stub(bin: &Path, name: &str, out: &Path) { install_script( bin, name, - // Write each record to a per-process file beside its destination and - // rename it into place: `>` truncates first and `printf` may write - // one argument at a time, so a reader polling for a non-empty file - // could otherwise see a prefix of the argv. + // Write each record to a per-process file beside its destination and rename it into + // place. Redirection truncates before writing, and `printf` may write arguments + // separately; polling for a non-empty file could otherwise accept incomplete argv. &format!( "printf '%s' \"$FLEETCOM_CAPTURE_FILE\" > '{out}/capenv.'$$'.tmp' && mv '{out}/capenv.'$$'.tmp' '{out}/capenv'\n\ printf '%s' \"$FLEETCOM_BINARY\" > '{out}/binenv.'$$'.tmp' && mv '{out}/binenv.'$$'.tmp' '{out}/binenv'\n\ @@ -121,9 +120,8 @@ fn notices(s: &mut Supervisor) -> Vec { .collect() } -/// Assert that `s` queued exactly one status line and that it names `needle`. -/// Match on the distinctive token, not the full sentence, so rewording a -/// message never breaks a test of the behavior behind it. +/// Require exactly one queued status containing `needle`. Match the distinctive token so +/// message wording can change without affecting the behavior check. fn assert_sole_notice(s: &mut Supervisor, needle: &str) { let got = notices(s); assert!( @@ -132,7 +130,7 @@ fn assert_sole_notice(s: &mut Supervisor, needle: &str) { ); } -/// Whether a drained event batch acknowledges a spawn. +/// Check for a spawn acknowledgement in drained events. fn acknowledged(events: &[Event]) -> bool { events.iter().any(|e| matches!(e, Event::Spawned { .. })) } @@ -158,8 +156,8 @@ fn literal(cmd: &str) -> SessionEntry { } } -/// Save `entries` for `dir` as the named recipe through the real serializer, -/// then load it: the only way a task starts with a chosen resume target. +/// Save `entries` for `dir` through the real serializer, then load the named recipe. Use +/// this path to start a managed task with a specified resume target. fn load_recipe( s: &mut Supervisor, config: &Path, @@ -182,7 +180,7 @@ fn saved_entries(s: &mut Supervisor, config: &Path, name: &str) -> Vec Option { match saved_entries(s, config, name).as_slice() { [ @@ -205,8 +203,8 @@ fn fnv_discriminator_is_stable_and_distinguishes_roots() { assert_ne!(fnv1a_hex(b"/cfg/one"), fnv1a_hex(b"/cfg/two")); } -/// A managed `claude` receives a pinned ID, settings overlay, and capture -/// environment; its display text stays the program word. +/// Launch managed `claude` with a pinned ID, settings overlay, and capture environment. +/// Preserve the program word for display. #[test] fn spawn_claude_pins_an_id_and_layers_settings() { let dir = scratch("cap_claude"); @@ -297,16 +295,15 @@ fn spawn_non_agent_command_is_not_instrumented() { assert!(!runtime.exists()); } -/// A typed `claude` is a literal task: the shell gets exactly that text, no -/// harness channel is attached, and the recipe stores the text. A -/// capture-shaped file at the path a managed task of the same id would use -/// and a matching registry record change nothing: nothing reads them. +/// Run a typed `claude` literally, with no harness channel, and save the exact text. Ignore +/// both a capture file at the path for a managed task with the same ID and a matching +/// registry record. #[test] fn typed_agent_word_is_literal_runs_verbatim_and_saves_as_text() { let dir = scratch("literal_claude"); let (bin, runtime, config) = (dir.join("bin"), dir.join("run"), dir.join("config")); let claude_home = dir.join("claude_home"); - // The stub publishes its argument count and the capture env atomically. + // Publish the argument count and capture environment atomically from the stub. install_script( &bin, "claude", @@ -348,9 +345,8 @@ fn typed_agent_word_is_literal_runs_verbatim_and_saves_as_text() { assert!(!runtime.exists()); let pid = t.pid().expect("a spawned task has a pid"); - // A managed sibling installs the namespace; a capture-shaped file for - // task 1 lands there, stamped with task 1's own leader pid, and a - // registry record is keyed to task 1 as well. + // Install a namespace by launching a managed sibling. Write a capture for task 1 there, + // stamped with task 1's leader PID, and create a registry record keyed to that PID. s.spawn_agent("claude", dir.to_path_buf(), None); let ns = s.tasks[1] .capture_file @@ -549,9 +545,9 @@ fn remove_deletes_the_capture_file_under_the_spawn_root() { ); } -/// For a `codex` spawn, add `notify=[...]` with the executable capture script, -/// then the override for embedded mode, and name this process's own -/// executable in `FLEETCOM_BINARY` for the script's validation step. +/// For a `codex` spawn, add `notify=[...]` with the executable capture script, then the +/// embedded-mode override. Set `FLEETCOM_BINARY` to this process's executable for +/// validation from the script. #[test] fn spawn_codex_installs_the_notify_and_embedded_overrides() { use std::os::unix::fs::PermissionsExt; @@ -687,9 +683,9 @@ fn spawn_omp_loads_the_capture_extension() { assert!(t.resume_id.is_none(), "omp cannot pin an id at launch"); } -/// Printed hints are display content even after exit and reader EOF. Named -/// saves, recovery snapshots, and reruns carry the capture or launch ID; a -/// task with neither saves and reruns fresh. +/// Treat printed hints as display content, even after exit and reader EOF. Use capture or +/// launch IDs for named saves, recovery snapshots, and reruns; without either ID, save and +/// rerun fresh. #[test] fn printed_resume_hints_do_not_change_saved_recovery_or_rerun_targets() { for (tool, hints) in [ @@ -799,10 +795,9 @@ fn printed_resume_hints_do_not_change_saved_recovery_or_rerun_targets() { } } -/// Rerun latches a recent exit before checking eligibility and retains the -/// explicit launch ID when terminal output names another conversation. The -/// launch ID comes from a loaded recipe: the one way a managed task starts -/// on a chosen conversation. +/// Latch a recent exit before checking rerun eligibility. Retain the explicit launch ID +/// even when another conversation is named in terminal output. Load a recipe to start the +/// managed task on a specified conversation. #[test] fn rerun_latches_exit_without_reap_and_preserves_the_launch_id() { use rustix::process::{Pid, WaitId, WaitIdOptions, waitid}; @@ -934,12 +929,10 @@ fn capture_stamped_by_a_foreign_process_falls_back_to_the_next_source() { let json = |id: &str, source: &str| { format!(r#"{{"session_id":"{id}","hook_event_name":"SessionStart","source":"{source}"}}"#) }; - // The test writes the task's own capture itself, with the leader's - // stamp. The stub then runs the installed hook under an intermediate - // shell with the same inherited environment, as in Claude's daemon. - // Append `:` to prevent an exec of the pipeline's last command: the - // hook's `$PPID` must be the intermediate shell, which is never the - // leader. + // Write a capture stamped with the task leader's PID. Then run the installed hook from + // the stub through an intermediate shell, with the same inherited environment as in + // Claude's daemon. Append `:` to prevent exec of the pipeline's last command: the + // hook's `$PPID` must be the intermediate shell, never the leader. install_script( &bin, "claude", @@ -1023,9 +1016,8 @@ until [ -e '{d}/done' ]; do sleep 0.05; done"#, std::fs::write(dir.join("done"), b"").unwrap(); } -/// Two managed tasks sharing a directory do not own a nearby rollout. Named -/// saves and recovery must write both without a resume ID when capture is -/// silent. +/// Do not infer ownership of a nearby rollout from a shared directory. With no capture, +/// save both managed tasks without resume IDs in named sessions and recovery snapshots. #[test] fn silent_codex_tasks_save_and_recover_without_a_resume_id() { let dir = scratch("uncaptured_codex"); @@ -1119,9 +1111,8 @@ const CODEX_ROOT: &str = "019f5453-de22-7240-b2e5-0d32692aa6d9"; const CODEX_CHILD: &str = "019f5454-0c11-7b33-9a4e-5f0e6d7c8b9a"; const CODEX_TITLE: &str = "019f5453-de9f-7e61-8c0d-1a2b3c4d5e6f"; -/// Notification JSON for a completed turn of `thread`, as codex passes it -/// to the injected notifier. `last` is the final assistant message, already -/// escaped for a JSON string. +/// Notification JSON passed to the injected notifier after a completed turn of `thread`. +/// `last` is the final assistant message, already escaped for a JSON string. fn turn_complete(thread: &str, last: &str) -> String { format!( r#"{{"type":"agent-turn-complete","thread-id":"{thread}","turn-id":"t","cwd":"/w","input-messages":["ping"],"last-assistant-message":"{last}"}}"# @@ -1133,9 +1124,9 @@ fn title_turn() -> String { turn_complete(CODEX_TITLE, r#"{\"title\":\"Ping the sub-agent\"}"#) } -/// Run the `--codex-notify-v1` step in-process for the sole task, as the -/// injected script's binary call would: the task's capture file and -/// `codex_home` are its environment. Return the root it wrote. +/// Run `--codex-notify-v1` in-process for the sole task, using its capture file and +/// `codex_home` as the environment. Return the written root, as for a binary invocation +/// from the injected script. fn arrive(s: &Supervisor, codex_home: &Path, payload: &str) -> Option { let cap = s.tasks[0].capture_file.clone().expect("capture file set"); crate::harness::record_arrival(payload, &|key| match key { @@ -1145,10 +1136,10 @@ fn arrive(s: &Supervisor, codex_home: &Path, payload: &str) -> Option { }) } -/// Install a stand-in for `fleetcom --codex-notify-v1` at `/fleetcom` -/// that writes `CAP_ID` over the capture file. The real mode cannot run from -/// the unit-test binary, whose `main` is the test harness, so stubs that -/// invoke the injected script name this file in `FLEETCOM_BINARY`. +/// Install a stand-in at `/fleetcom` to write `CAP_ID` over the capture file. The real +/// notify mode cannot run from the unit-test executable because its `main` is the test +/// harness. Set `FLEETCOM_BINARY` to this stand-in when invoking the injected script from a +/// stub. fn install_fake_fleetcom(bin: &Path) -> PathBuf { install_script( bin, @@ -1158,13 +1149,11 @@ fn install_fake_fleetcom(bin: &Path) -> PathBuf { bin.join("fleetcom") } -/// Report three threads from one codex process through the arrival step in -/// the order observed after the first prompt: the title thread, a sub-agent, -/// then the conversation, and the title thread again, which lands after the -/// root in most sessions. With no rollout for the title thread, nothing is -/// written and the authored command is preserved. Resolve the sub-agent to -/// its conversation and the conversation to itself, and keep that root -/// across the later title notification. +/// Report notifications from one codex process in the observed order: title thread, +/// sub-agent, root conversation, then title again to cover its later arrival in most +/// sessions. Without a title rollout, write nothing and preserve the authored command. +/// Resolve the sub-agent to its root and the conversation to itself; preserve that root +/// after the later title notification. #[test] fn codex_capture_resolves_each_notifying_thread_to_the_root() { let dir = scratch("codex_threads"); @@ -1303,9 +1292,9 @@ fn harness_home_prefers_the_tool_var_then_home() { assert_eq!(harness_home(&[], &Codex), None); } -/// With only `HOME` in the launch environment, notify routing reads -/// `/.codex/config.toml`: an unchainable route there leaves the launch -/// with the embedded override alone. +/// With only `HOME` in the launch environment, read notify routing from +/// `/.codex/config.toml`. For an unchainable route, include only the embedded +/// override. #[test] fn home_only_launch_env_targets_the_clients_dot_codex() { let dir = scratch("home_resolve"); @@ -1366,8 +1355,8 @@ fn stale_inherited_notify_chain_is_never_executed() { let record = dir.join("stale-record"); write_executable(&stale, &format!("touch '{}'", record.display())); - // The stub invokes the injected notify script the way codex would, - // naming the fake binary for the script's validation step. + // Invoke the injected notifier from the stub as from codex, with the fake binary set + // for validation. let payload = format!(r#"{{"type":"agent-turn-complete","thread-id":"{CAP_ID}"}}"#); let fake = install_fake_fleetcom(&bin); // The notify script sits beside the capture file, in a namespace @@ -1412,8 +1401,7 @@ fn stale_inherited_notify_chain_is_never_executed() { ); } -/// Without a known ID, a managed entry carries the agent word alone: no -/// `resume` field at all. +/// Without a known ID, save only the managed agent word; omit `resume`. #[test] fn managed_save_without_any_id_omits_resume() { let dir = scratch("no_id"); @@ -1446,8 +1434,8 @@ fn managed_save_without_any_id_omits_resume() { ); } -/// A representable `notify` assignment runs through the injected notifier -/// after the validation step. +/// For a representable `notify` assignment, run the configured notifier after validation +/// through the injected script. #[test] fn config_toml_notify_chains_through_the_injected_script() { use crate::harness::NOTIFY_CHAIN_ENV; @@ -1465,9 +1453,8 @@ fn config_toml_notify_chains_through_the_injected_script() { ) .unwrap(); - // The stub records argv and the chain env, then invokes the notify - // script with notification JSON as the final argument, naming the fake - // binary for the script's validation step. + // Record argv and the chain environment in the stub, then invoke the notify script with + // notification JSON as the final argument. Use the fake binary for validation. let payload = format!(r#"{{"type":"agent-turn-complete","thread-id":"{CAP_ID}"}}"#); let fake = install_fake_fleetcom(&bin); install_script( @@ -1522,10 +1509,9 @@ fn config_toml_notify_chains_through_the_injected_script() { ); } -/// An unrepresentable `notify` value disables capture injection: the launch -/// carries the embedded override alone and the status line says why. A -/// commented assignment defines no route, so injection returns and no notice -/// is sent. +/// For an unrepresentable `notify` value, disable capture injection, include only the +/// embedded override, and report the reason. With the assignment commented out, inject +/// capture again without a notice. #[test] fn unrepresentable_config_notify_suppresses_injection() { let dir = scratch("cfg_guard"); @@ -1663,13 +1649,12 @@ fn recovery_cadence_rewrites_on_capture_drift_and_skips_when_static() { // --- managed launches -------------------------------------------------- -/// Install a resident shell at `/resident-sh`: it runs its `-c` text as -/// a child and stays the leader, as tcsh and csh do. A launch context naming -/// it as `SHELL` proves the shell plays no part in a managed launch. +/// Install a resident shell at `/resident-sh`: run its `-c` text as a child and retain +/// the shell as leader, as with tcsh and csh. Set it as `SHELL` to verify that managed +/// launches bypass the shell. fn install_resident_shell(dir: &Path) -> PathBuf { let shell = dir.join("resident-sh"); - // `:` after the child keeps sh from exec'ing it as the script's last - // command. + // Append `:` to prevent sh from execing the child as the last command. write_executable(&shell, "[ \"$1\" = -c ] || exit 2\n/bin/sh -c \"$2\"\n:"); shell } @@ -1695,15 +1680,15 @@ fn settings_beside(s: &Supervisor) -> String { .to_string() } -/// A managed claude is the task leader whatever `SHELL` names, so a capture -/// its own hook stamps with `$$` passes the gate; the same payload stamped -/// by any other process is refused and the pinned ID stands. +/// Run managed claude as the task leader regardless of `SHELL`. Accept its capture stamped +/// with `$$`; reject the same payload stamped with another PID and fall back to the pinned +/// ID. #[test] fn managed_claude_accepts_its_own_capture_and_refuses_a_foreign_stamp() { let dir = scratch("managed_capture"); let (bin, runtime) = (dir.join("bin"), dir.join("run")); - // The stub plays claude running its SessionStart hook after `/clear`: - // the capture names a drifted ID under the stub's own pid. + // Simulate Claude's SessionStart hook after `/clear`: write the changed ID and stamp + // the capture with the stub's PID. install_script( &bin, "claude", @@ -1738,7 +1723,7 @@ fn managed_claude_accepts_its_own_capture_and_refuses_a_foreign_stamp() { "the stub's `$$` is the leader pid: no shell sat between" ); - // Any other process's stamp over the same payload is refused. + // Reject the same payload when stamped with another process's PID. let cap = s.tasks[0].capture_file.clone().unwrap(); let text = std::fs::read_to_string(&cap).unwrap(); let (_, json) = text.split_once('\n').unwrap(); @@ -1750,9 +1735,8 @@ fn managed_claude_accepts_its_own_capture_and_refuses_a_foreign_stamp() { ); } -/// An unknown word, a word with no binary on `PATH`, and a full fleet each -/// produce one status line and no task; the task ceiling is the one `Spawn` -/// uses. +/// Refuse unknown words, missing binaries, and launches beyond the task ceiling. Report one +/// status and create no task in each case. Apply the same ceiling as for `Spawn`. #[test] fn managed_spawn_refuses_an_unknown_word_a_missing_binary_and_a_full_fleet() { let dir = scratch("managed_refusals"); @@ -1782,17 +1766,17 @@ fn managed_spawn_refuses_an_unknown_word_a_missing_binary_and_a_full_fleet() { assert_eq!(s.tasks.len(), 1, "the ceiling holds for both launch kinds"); } -/// A supervisor holding only `events`, so `notices` can filter them. +/// Build a supervisor with only the supplied `events` for filtering through `notices`. fn sup_with(events: Vec) -> Supervisor { let mut s = Supervisor::new(24, 80, 0); s.events = events; s } -/// A managed rerun relaunches through the harness: it resumes the captured -/// ID ahead of the overlay, keeps id, tag, group, name and the program word, -/// bumps the run, and finds the binary on `PATH` again, so a binary removed -/// since the launch refuses the rerun and leaves the finished task alone. +/// Rerun a managed task through its harness: place the captured ID before the overlay; +/// preserve id, tag, group, name, and program word; increment the run number. Resolve the +/// binary on `PATH` again. If removed since launch, refuse the rerun and preserve the +/// finished task. #[test] fn managed_rerun_resumes_the_captured_id_from_the_binary_path_finds_now() { let dir = scratch("managed_rerun"); @@ -1841,7 +1825,7 @@ fn managed_rerun_resumes_the_captured_id_from_the_binary_path_finds_now() { "rerun must delete the displaced run's capture file; the ID was read first" ); - // The binary is resolved again at each rerun: a removed one refuses. + // Resolve the binary again on rerun; refuse if it has been removed. wait_for_lifecycle(&mut s, id, |l| l == Lifecycle::Ok); std::fs::remove_file(bin.join("claude")).unwrap(); s.apply(Command::Restart { id }); @@ -1849,8 +1833,8 @@ fn managed_rerun_resumes_the_captured_id_from_the_binary_path_finds_now() { assert_eq!(s.tasks[0].run, 1, "the finished task is preserved"); } -/// Without any known ID, a managed rerun starts a fresh conversation: omp -/// pins nothing, so both runs carry the overlay alone. +/// Without a known ID, start a fresh conversation on rerun. With no pin support in omp, use +/// only the overlay on both runs. #[test] fn managed_rerun_without_an_id_starts_fresh() { let dir = scratch("managed_rerun_fresh"); @@ -1873,23 +1857,21 @@ fn managed_rerun_without_an_id_starts_fresh() { assert_eq!(s.tasks[0].run, 1); } -/// A managed task saves as a managed entry carrying its current session ID, -/// and that entry reloads as a managed task that resumes it: the argv leads -/// with the selector and ID, the binary runs as a direct child even under a -/// resident shell, and the group carries over. +/// Save a managed entry with its current session ID, then reload it as managed. Place the +/// selector and ID first in argv, execute the binary directly even with a resident `SHELL`, +/// and preserve the group. #[test] fn managed_task_saves_as_a_managed_entry_and_reloads_managed() { let dir = scratch("managed_save"); let (bin, runtime, config) = (dir.join("bin"), dir.join("run"), dir.join("config")); - // One record directory per stub: the reloaded tasks launch together, and - // a shared argv file would be truncated by one while the test reads the - // other's. + // Use one record directory per stub. Reload tasks together without a shared argv file + // that could be truncated during a read. let (claude_out, omp_out) = (dir.join("claude-out"), dir.join("omp-out")); for out in [&claude_out, &omp_out] { std::fs::create_dir_all(out).unwrap(); } - // The claude stub plays its SessionStart hook: a capture stamped with - // its own `$$`, which passes the gate only if the stub is the leader. + // Simulate Claude's SessionStart hook in the stub. Stamp the capture with `$$`, which + // is accepted only when the stub is the task leader. install_script( &bin, "claude", @@ -1971,10 +1953,9 @@ fn managed_task_saves_as_a_managed_entry_and_reloads_managed() { ); } -/// A recipe naming an agent this host lacks loads the rest, and the one -/// summary line carries the reason: the client shows only a poll's last -/// status, so a separate line queued before the summary would be lost. -/// Recovery loads report the same way. +/// Continue loading a recipe after a missing agent, including the failure reason in the +/// final summary. Only the last status in a client poll is displayed, so a separate earlier +/// diagnostic would be lost. Report recovery loads the same way. #[test] fn load_reports_a_missing_agent_and_loads_the_rest() { let dir = scratch("load_missing_agent"); @@ -2033,8 +2014,8 @@ fn load_reports_a_missing_agent_and_loads_the_rest() { assert_sole_notice(&mut s, "grok not found on PATH"); } -/// A managed codex carries the embedded override even when capture is off, -/// reports why, and leads a rerun with `resume ` ahead of the overrides. +/// Include the embedded override on managed codex launches even with capture disabled, and +/// report the reason. On rerun, place `resume ` before the overrides. #[test] fn managed_codex_reports_the_capture_notice_and_leads_a_rerun_with_resume() { let dir = scratch("managed_codex"); diff --git a/src/supervisor_tests.rs b/src/supervisor_tests.rs index 067d76c..b8e56b1 100644 --- a/src/supervisor_tests.rs +++ b/src/supervisor_tests.rs @@ -2091,9 +2091,9 @@ fn agents_of(s: &mut Supervisor) -> Vec { .expect("expected an Agents event") } -/// `dir` spelled relative to this process's cwd, through `..` components. -/// The spelling reaches `dir` from here, which is exactly why discovery -/// must not use it: the task would resolve it from its own cwd. +/// Spell `dir` relative to this process's cwd through `..` components. Although reachable +/// from here, this path must be excluded from discovery: at exec, it would be resolved +/// against the task's cwd. fn relative_spelling(dir: &Path) -> PathBuf { use std::path::Component; let ups = here() @@ -2105,11 +2105,10 @@ fn relative_spelling(dir: &Path) -> PathBuf { .join(dir.strip_prefix("/").unwrap()) } -/// Installing a context queues the registered agents its `PATH` resolves, in -/// registry order rather than `PATH` order. An unregistered executable and a -/// relative component contribute nothing. Each install resends the whole -/// list, and a context without `PATH` sends an empty one, so a reconnect -/// never inherits a stale menu. +/// On context install, queue the registered agents found on its `PATH` in registry order. +/// Ignore unregistered executables and relative components. Send the whole list on every +/// install, including an empty list without `PATH`, to clear stale menu entries on +/// reconnect. #[test] fn launch_context_install_discovers_agents_in_registry_order() { let dir = scratch("discover"); @@ -2148,9 +2147,9 @@ fn launch_context_install_discovers_agents_in_registry_order() { assert_eq!(agents_of(&mut s), Vec::::new()); } -/// `Command::SpawnAgent` admits a managed task for a registered word on the -/// context's `PATH`, acking with `Spawned` and reporting `managed` in the -/// snapshot. A literal spawn of the same word stays unmanaged. +/// Admit a managed task for a registered word found on the context's `PATH` through +/// `Command::SpawnAgent`. Acknowledge with `Spawned` and set `managed` in the snapshot. +/// Keep literal spawns of the same word unmanaged. #[test] fn spawn_agent_admits_a_managed_task() { let dir = scratch("spawn_agent"); diff --git a/src/task_tests.rs b/src/task_tests.rs index d65200c..ccbf72d 100644 --- a/src/task_tests.rs +++ b/src/task_tests.rs @@ -142,18 +142,16 @@ fn exited_leader_stays_a_zombie_until_drop() { assert!(kill(pid, None).is_err(), "Drop did not collect the zombie"); } -/// A managed launch makes the agent the task leader by construction: no -/// shell sits between the PTY and the binary. The same agent launched as a -/// literal through a resident shell (one that runs its `-c` text as a child -/// instead of replacing itself, as tcsh and csh do) is not the leader. That -/// gap is why managed launch exists: claude's capture gate and registry -/// reader key on the leader pid. +/// Verify the agent's PID is the task leader's under a managed launch. A literal launch +/// through a resident shell, such as tcsh or csh, runs the agent as a child without +/// replacing the shell. Direct execution is required because Claude capture and registry +/// records are validated against the leader PID. #[test] fn managed_launch_makes_the_agent_the_task_leader_under_any_shell() { let dir = temp("task_identity"); let shell = dir.join("resident-sh"); - // `:` after the child keeps sh from exec'ing it as the script's last - // command, so the shell stays resident as the leader. + // Add `:` after the child command to prevent sh from execing that command. Retain the + // shell as task leader. write_executable(&shell, "[ \"$1\" = -c ] || exit 2\n/bin/sh -c \"$2\"\n:"); let agent = dir.join("agent"); let pid_file = dir.join("pid"); From 0b24b4fa7b7a0cd7a723e24f034f6d90810fcac7 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Mon, 5 Oct 2026 23:22:02 -0700 Subject: [PATCH 13/13] feat(docs): clarify language in agent session instructions for consistency --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3e89096..87a057f 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Organize related tasks into named groups across working directories. ### Resume agent sessions -Launch `claude`, `codex`, `grok`, or `omp` from the Agent page (`n`, then `Tab`). Rerun the task or reload a saved session and the conversation picks up where it left off. +Launch `claude`, `codex`, `grok`, or `omp` from the Agent page (`n`, then `Tab`). Rerun the task or reload a saved session to resume the conversation. ## Operational model @@ -40,7 +40,7 @@ Managing several long-lived commands across terminal panes is pesky, especially - Keep tasks running under a daemon across client disconnects. - Save and reload task recipes: directories, commands, group assignments, and display names. - Rerun a completed task in place, keeping its identity, group, and name. -- Launch `claude`, `codex`, `grok`, and `omp` as managed agents whose conversations survive rerun and reload. +- Launch `claude`, `codex`, `grok`, and `omp` as managed agents and resume their conversations on rerun and reload. - Recover the current task set from automatic snapshots. ## Documentation