diff --git a/README.md b/README.md index 79e45ca..87a057f 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 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. -- Save and rerun `claude`, `codex`, `grok`, and `omp` tasks with captured conversation IDs. +- 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 diff --git a/docs/README.md b/docs/README.md index 59983eb..8b8c85d 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. +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 b872a39..412eaa7 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`). +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`). -## Workflow +## Two kinds of task -Start a supported agent without flags: +The task kind is determined by how you start it, not by the command text: -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.** 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. -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. To supply flags, type a literal command and run without 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. 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` 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. -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: 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. -- `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 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. -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. +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 @@ -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. +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 -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: +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' +--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. +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 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 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. -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. +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 are injected into both accepted forms: +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' @@ -86,34 +83,40 @@ 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 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, 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, 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. -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. +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, 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. + +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. -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. +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. -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. +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. -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. +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. 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` 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. 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. 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`. 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`. Load the extension on every managed launch without pinning an ID. To resume, pass `--resume ` before the extension argument: ```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`: @@ -125,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. 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. 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`. @@ -145,31 +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. +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 and rerun, accepted commands are rewritten to one of these forms: +Save a managed task as `{ "agent": "", "resume": "" }`. On rerun and reload, place the selector and ID first in 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. +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 -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. +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 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 `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. 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. +- `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. @@ -184,5 +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` | 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 1590d18..2a529cf 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 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 @@ -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) | @@ -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; 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 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. +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. @@ -162,6 +162,14 @@ 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 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). + +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 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/docs/sessions.md b/docs/sessions.md index 35d8a3b..da303b1 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` 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 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 { - "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, 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. +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,31 @@ 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 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. -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" } - ] - } -} -``` +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 | +| -- | -- | +| `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 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 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 +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, command, 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 `. diff --git a/src/app.rs b/src/app.rs index c22d3c3..fcacd6e 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` for literal text, `Agent` for managed +/// launches. +#[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 { @@ -195,6 +203,9 @@ pub struct App { transport: Box, /// Task snapshot received from `Event::Tasks`. pub views: Vec, + /// 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, /// Last `(target, attached)` watch state sent to the core. @@ -220,6 +231,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, + /// 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, + /// 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, + /// 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, @@ -450,6 +473,7 @@ impl App { Self { transport, views: Vec::new(), + agents: Vec::new(), focused_screen: None, watched: None, daemon_backed: false, @@ -460,6 +484,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, @@ -750,6 +778,21 @@ impl App { match ev { // The handshake is handled before the transport is created. Event::HelloOk => {} + // Replace the whole list on each context install; do not merge it with the + // previous list. + Event::Agents(a) => { + self.agents = a; + // 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() { + self.spawn_page = SpawnPage::Command; + } + } + } Event::Tasks(v) => { self.views = v; // The acknowledgement precedes its row. Select only after @@ -1032,15 +1075,41 @@ impl App { .collect() } - /// Lock in `dir` as the spawn target and move to command entry. + /// 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(); self.input.clear(); self.dir_candidates.clear(); + self.spawn_page = SpawnPage::Command; + self.agent_input.clear(); + self.refresh_agent_candidates(); self.mode = Mode::Spawn; } + /// 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 + .agents + .iter() + .filter(|a| a.to_lowercase().contains(&needle)) + .cloned() + .collect(); + self.agent_sel = 0; + } + + /// 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)) + .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) { @@ -1471,11 +1540,52 @@ 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 { + // 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, + 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), + }, + } + } + + /// 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(), + 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(); + } + } + // Do not refilter on caret motion. + _ => { + if on_key_edit(&mut self.agent_input, k) { + self.refresh_agent_candidates(); + } + } + } } fn on_key_peek(&mut self, k: KeyEvent) { @@ -1576,6 +1686,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_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..5c0a729 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, }; @@ -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, @@ -3080,6 +3081,443 @@ fn state_and_dir_mode_spawns_stay_unassigned() { assert_eq!(v.group, None); } +// --- `n` spawn prompt: Agent page -------------------------------------- + +/// Supply core events to the app without a running core. +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 { + /// 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(); + app.transport = Box::new(Unplugged); + let sent = app.record_sends(); + (app, sent) + } + + /// 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)); + 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))); + } + } +} + +/// 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}")); + 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) +} + +/// 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(&[]); + 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); +} + +/// 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"]); + 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"); +} + +/// 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"]); + 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"); +} + +/// 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"); + 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"); + // 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![ + ("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:?}" + ); + } +} + +/// 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"); + 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" + ); +} + +/// 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"); + 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; + + // 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); + 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); +} + +/// 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"]); + 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()); +} + +/// 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"]); + 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()); +} + +/// 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"]); + 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" + ); +} + +/// 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"]); + 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, + }] + ); +} + +/// 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"]); + 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"); +} + +/// 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"]); + 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); +} + +/// 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(&[]); + 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:?}" + ); +} + +/// 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"]); + 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/assets.rs b/src/harness/assets.rs index 07bc8d5..fa91106 100644 --- a/src/harness/assets.rs +++ b/src/harness/assets.rs @@ -16,14 +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. 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 -//! `$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. +//! - `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`, @@ -44,13 +49,16 @@ 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. +/// 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 -# 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. @@ -274,18 +282,31 @@ impl CaptureAssets { }) } - /// Return the installed asset paths plus the capture path for one task run. - /// Including the run number prevents reruns from sharing payloads. + /// 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")), claude_settings: self.claude_settings.clone(), codex_notify: self.codex_notify.clone(), omp_capture: self.omp_capture.clone(), + fleetcom_binary: fleetcom_binary(), } } } +/// 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() + .filter(|exe| super::executable_file(exe)) +} + /// Remove this supervisor's capture namespace on drop. impl Drop for CaptureAssets { fn drop(&mut self) { @@ -305,7 +326,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, executable_file, fixtures::{ID, OTHER}, }, testutil::{dead_pid, install_fake_notifier, temp, write_executable}, @@ -567,60 +588,87 @@ 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. + /// 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, + &format!( + "printf '%s\\n' \"$@\" > '{}'\nprintf '%s' '{ID}' > \"${CAPTURE_ENV}\"\nexit 3", + record.display() + ), + ); + } + + /// 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_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"}"#; + // 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(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. + /// 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"); 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 +677,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 +685,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 +693,14 @@ console.log(Object.keys(handlers).join(" ")); ); } - /// The capture write precedes 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"); 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 +708,22 @@ 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, exec the configured notifier without running the binary. #[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 +731,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"); + } + + /// 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"); + 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 +1046,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/claude.rs b/src/harness/claude.rs index 46b8ed8..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. Bare launches pin a v4 UUID; accepted -//! launches install 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, @@ -18,8 +17,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,35 +31,38 @@ 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 - /// 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. /// - /// 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. + /// 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, @@ -173,10 +174,17 @@ mod tests { use super::*; use crate::{ - harness::fixtures::{ID, OTHER, assert_all_opaque, paths}, + harness::{ + Intent, + fixtures::{ID, OTHER, argv, paths}, + plan, + }, 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","#, @@ -211,60 +219,38 @@ 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`. + /// 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 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); - } - - #[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); - /// A resume command already targets a conversation, so instrumentation adds - /// the settings overlay without pinning another ID. - #[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); + let resume = plan(&Claude, &Intent::Resume(OTHER.into()), None, &paths(), None); assert_eq!( - plan.args_suffix, - " --settings '/tmp/Application Support/fleetcom.json'" + resume.args, + argv(&["--resume", OTHER, "--settings", SETTINGS]) ); - assert_eq!(plan.env.len(), 1, "env still names the capture file"); + assert_eq!(resume.resume_id.as_deref(), Some(OTHER)); + assert_eq!(resume.env, fresh.env); + + // 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); } /// Task leader PID used by the capture-gate cases. diff --git a/src/harness/codex.rs b/src/harness/codex.rs index d58a195..27271aa 100644 --- a/src/harness/codex.rs +++ b/src/harness/codex.rs @@ -1,24 +1,28 @@ -//! 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 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. -//! In `parse_capture`, use the notified thread's rollout header to resolve its -//! session tree's root. Reject threads that cannot be classified. +//! 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 _, - 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, NOTIFY_CHAIN_ENV, SpawnPlan, capture_id, + home_root, is_uuid, resolve_home, }; /// Config override for explicitly launching in embedded mode. @@ -52,72 +56,126 @@ impl Harness for Codex { ("codex", "resume") } - 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 { + /// 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()], + ..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; + } + }; + // 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" + )); + 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 = vec![ + "-c".into(), + toml.into(), + "-c".into(), + EMBEDDED_OVERRIDE.into(), + ]; + 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 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, - home: Option<&Path>, + // Home resolution was completed 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 with only the embedded override. +const CAPTURE_OFF: &str = "codex capture unavailable"; + +/// 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. 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())?; + replace_slot(&capture, &root).ok()?; + Some(root) +} + +/// 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. +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 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())); + 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 /// 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 @@ -195,7 +253,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,16 +416,20 @@ 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::{ + Intent, + 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. + /// 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`]. @@ -376,41 +438,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`]. - const SUFFIX: &str = concat!( - r#" -c 'notify=["/tmp/Application Support/notify.sh"]'"#, - " -c 'features.daemon_auto_start=false'", - ); - - /// 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", + /// 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. + 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(), + ), ] - .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); + } + + /// 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`. + fn embedded_only(why: &str) -> SpawnPlan { + SpawnPlan { + args: argv(&["-c", EMBEDDED_OVERRIDE]), + env: Vec::new(), + resume_id: None, + notice: Some(format!("codex capture unavailable: {why}")), + } } /// Scratch home without a `config.toml`. @@ -418,37 +478,75 @@ mod tests { temp("codex_no_config_home") } - /// Apply the same two overrides to both accepted forms: the notifier, - /// then explicit embedded mode. No ID can be pinned at launch. + /// 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 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.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()), - ], - "{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, full_args()); + 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("")); + } + + /// 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 { + fleetcom_binary: None, + ..paths() + }; + let why = "fleetcom binary replaced; restart the daemon"; + assert_eq!( + Codex.overlay(&paths, Some(&no_config_home())), + embedded_only(why) + ); + 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(); + assert_eq!( + Codex.overlay(&paths, Some(&home)), + embedded_only("`notify` config can't be chained") + ); } /// Pass a representable `notify` assignment through [`NOTIFY_CHAIN_ENV`]. Run /// 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() @@ -458,8 +556,8 @@ 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()); + let plan = Codex.overlay(&paths(), Some(&home)); + assert_eq!(plan.args, full_args()); assert_eq!(chained(&plan), Some("".into())); let cfg = home.join("config.toml"); @@ -470,11 +568,10 @@ mod tests { "model = \"gpt-5\"\nnotify = [\"/my/thing\"]\n", ] { 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:?}"); + let plan = Codex.overlay(&paths(), Some(&home)); + assert_eq!(plan.args, full_args(), "{active:?}"); + assert_eq!(plan.env, full_env("/my/thing"), "{active:?}"); + assert_eq!(plan.notice, None, "{active:?}"); } for inert in [ "# notify = [\"/my/thing\"]\n", @@ -483,24 +580,23 @@ mod tests { "notify = []\n", ] { fs::write(&cfg, inert).unwrap(); - let plan = Codex.instrument(&inv, &paths(), Some(&home)); - assert!(!plan.args_suffix.is_empty(), "{inert:?}"); + let plan = Codex.overlay(&paths(), Some(&home)); + assert_eq!(plan.args, full_args(), "{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!(plan.args, full_args()); assert!(plan.env.contains(&( NOTIFY_CHAIN_ENV.into(), "/Applications/Codex Computer Use.app/Contents/MacOS/SkyComputerUseClient\nturn-ended" @@ -508,12 +604,12 @@ mod tests { ))); } - /// For an unrepresentable route, inject neither override nor environment. + /// 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 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", @@ -531,8 +627,8 @@ mod tests { ] { fs::write(&cfg, opaque).unwrap(); assert_eq!( - Codex.instrument(&inv, &paths(), Some(&home)), - SpawnPlan::default(), + Codex.overlay(&paths(), Some(&home)), + embedded_only("`notify` config can't be chained"), "{opaque:?}" ); } @@ -546,24 +642,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. + // 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() }; - let inv = Codex.detect("codex").unwrap(); - let plan = Codex.instrument(&inv, &paths, Some(&no_config_home())); - let out = std::process::Command::new("sh") - .arg("-c") - .arg(format!("printf '%s\\n'{}", plan.args_suffix)) - .output() - .expect("sh must run"); + let plan = Codex.overlay(&paths, Some(&no_config_home())); 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 + ]) ); } @@ -682,7 +775,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 +801,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 +818,200 @@ mod tests { assert_eq!(parse(""), None); } + /// 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"); + 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 + } + + /// 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"); + 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: 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"]); + assert_eq!( + Codex.parse_capture(&fs::read_to_string(&cap).unwrap(), None, Some(&home)), + Some(ID.to_string()) + ); + } + + /// 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"); + 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: 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"); + 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, 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"); + 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:?}" + ); + } + // Do not write 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); + } + + /// 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"); + 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 +1020,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 +1027,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 +1047,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 +1066,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 +1085,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 +1098,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 +1122,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 +1140,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 +1157,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 +1229,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 +1243,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 +1268,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 +1283,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/grok.rs b/src/harness/grok.rs index 63e5762..6dee2c6 100644 --- a/src/harness/grok.rs +++ b/src/harness/grok.rs @@ -1,9 +1,9 @@ -//! 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. Pin a v4 UUID on fresh launches and specify +//! it on resume. 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") + } + + /// No overlay: grok has no capture channel to install. + fn overlay(&self, _capture: &CapturePaths, _home: Option<&Path>) -> SpawnPlan { + SpawnPlan::default() } } @@ -27,51 +26,24 @@ impl Harness for Grok { mod tests { use super::*; use crate::harness::{ - fixtures::{ID, assert_all_opaque, paths}, - is_uuid, + Intent, + 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); - } - - /// A bare launch gains only the pinned ID because Grok exposes no live - /// capture channel. - #[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"); - } - - /// A resume command needs no pin, overlay, or environment change. + /// Build argv from the intent alone: Grok has no live-capture channel, so add no + /// overlay for fresh launches or resumes. #[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 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()); } #[test] diff --git a/src/harness/mod.rs b/src/harness/mod.rs index 13caf55..1769250 100644 --- a/src/harness/mod.rs +++ b/src/harness/mod.rs @@ -1,18 +1,18 @@ -//! 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. +//! 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. //! -//! 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. +//! 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` 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. +//! 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; @@ -22,15 +22,16 @@ 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, }; pub use claude::Claude; -pub use codex::Codex; +pub use codex::{Codex, record_arrival}; pub use grok::Grok; pub use omp::Omp; @@ -42,7 +43,13 @@ 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"; -/// Detection, capture, and resume behavior for one agent CLI. +/// 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. pub trait Harness: Sync { /// Resolve configuration needed by instrumentation, capture parsing, or /// the live registry from the launch environment. Return `None` when no @@ -51,26 +58,22 @@ 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. 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); - /// 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) + /// 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 } - /// 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; + /// 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 /// task's session leader and `home` the launch-time harness home. When @@ -111,13 +114,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. @@ -143,7 +139,7 @@ struct Agent { summary: &'static dyn crate::preview::SummaryAdapter, } -/// Registered CLIs in detection order. +/// Registered CLIs, in launcher order. static AGENTS: &[Agent] = &[ Agent { harness: &Claude, @@ -163,17 +159,50 @@ static AGENTS: &[Agent] = &[ }, ]; -/// Return the first harness that recognizes `cmd`. -pub fn detect(cmd: &str) -> Option<(&'static dyn Harness, Invocation)> { +/// 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() - .find_map(|a| a.harness.detect(cmd).map(|inv| (a.harness, inv))) + .map(|a| a.harness) + .find(|h| h.shape().0 == program) +} + +/// 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) +} + +/// 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() } -/// 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. +/// 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()) + .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 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()?; @@ -183,26 +212,62 @@ pub fn select(command: &str) -> Option<&'static dyn crate::preview::SummaryAdapt .map(|a| a.summary) } -/// Classification of an accepted agent-CLI command. +/// Conversation selection for a managed launch. #[derive(Debug, Clone, PartialEq, Eq)] -pub enum Invocation { - /// The bare program word: `instrument` may pin a fresh session ID. - Bare, - /// The canonical resume form: the command already targets this ID, so - /// launch-time pinning is off. +pub enum Intent { + /// Start a new conversation. When a [`Harness::session_flag`] is available, mint an ID + /// in the supervisor. + Fresh, + /// Resume the conversation with this ID, validated by [`is_uuid`] at its source: + /// capture, a registry record, or a session file. 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), +/// 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, + 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()), + ) } } } +/// 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, + 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 + } +} + /// Capture paths allocated by [`assets::CaptureAssets::paths_for`]. #[derive(Debug, Clone)] pub struct CapturePaths { @@ -215,71 +280,30 @@ 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, + /// 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, } -/// Spawn-time additions for one instrumented launch. +/// 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 { - /// 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, -} - -/// 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)) + /// Session ID known at launch: the pinned fresh ID or the resumed ID. + pub resume_id: Option, + /// One-line explanation of reduced instrumentation, for the status line after a + /// successful spawn. + pub notice: Option, } -/// 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 @@ -289,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 shell-insertion 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()) @@ -298,7 +322,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") @@ -317,37 +341,23 @@ 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('\'', "'\\''")) -} - -/// Fixtures and assertions for harness detection and capture. +/// Fixtures for harness launch and capture tests. #[cfg(test)] pub(crate) mod fixtures { - use std::path::PathBuf; + 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"; /// 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 { @@ -356,16 +366,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"), - } - } - - /// 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"); + fleetcom_binary: Some(PathBuf::from("/tmp/Application Support/fleetcom")), } } } @@ -377,99 +378,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)); @@ -499,89 +410,127 @@ 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. + /// 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 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}''")] { + 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!( - detect_shape(&format!("claude --resume {token}"), "claude", "--resume"), - None, - "{token:?}" + 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" ); } } - /// A path-form program word matches by basename only while it stays one - /// plain shell word. + /// Only the exact program word is a registered agent: no paths, no + /// prefixes, no basename matching. #[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:?}"); + 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()); } - /// 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. + /// 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 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:?}"); + 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(); - /// 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'" + 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); + + // 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() + .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" ); - 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); } + /// List only registered words, in registry order, regardless of `PATH` order. Return an + /// empty menu for an empty `PATH`. #[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()))); + 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(); } - assert!(detect("vim").is_none()); - assert!(detect("").is_none()); + 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()); } } diff --git a/src/harness/omp.rs b/src/harness/omp.rs index 083a606..71f3981 100644 --- a/src/harness/omp.rs +++ b/src/harness/omp.rs @@ -12,13 +12,10 @@ //! //! 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; -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,23 +24,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, + ..SpawnPlan::default() } } @@ -65,50 +55,38 @@ mod tests { use std::path::PathBuf; use super::*; - use crate::harness::fixtures::{ID, assert_all_opaque, paths}; + use crate::harness::{ + Intent, + fixtures::{ID, argv, paths}, + plan, + }; /// Valid UUIDv7 used in capture payloads. const CAPTURED: &str = "01a0077c-e18e-7000-ae0b-016f4834b6e9"; - /// 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); - } + /// The extension path from [`paths`]. + const EXTENSION: &str = "/tmp/Application Support/omp-capture.js"; - /// 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. + /// 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 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); } /// Extract only a validated ID from the extension payload. 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/main.rs b/src/main.rs index 1fac505..4f2b9ff 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 `: validate one notification JSON from the injected + /// codex notify script. + 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" => { + // 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())?; + 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,15 @@ 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(), + // 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() { + std::process::exit(1); + } + return Ok(()); + } Ok(Invocation::Client { foreground, session, @@ -405,6 +434,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 +471,29 @@ mod tests { assert!(parse(&["--kill", "work"]).is_err()); } + /// 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()); + 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()); + 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/protocol.rs b/src/protocol.rs index 2d4467a..3237740 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,13 +56,21 @@ pub enum Command { cwd: PathBuf, group: Option, }, + /// 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, + 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). 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. 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 }, @@ -178,6 +186,10 @@ pub struct Mods { pub enum Event { /// The daemon accepted a compatible hello frame and stored its launch context. HelloOk, + /// 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), /// The watched task's current screen (attach/peek source). @@ -325,6 +337,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 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, /// Custom display name; `None` means unnamed. @@ -564,6 +579,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 +718,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 +854,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 +867,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 +941,10 @@ 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), + // 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(); for tv in v["tasks"].members() { @@ -916,6 +954,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..546c043 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,121 @@ fn flagship_wire_form() { } } +/// 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 { + 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}" + ); + } +} + +/// 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 [ + 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}" + ); + } +} + +/// 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] { + 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/session.rs b/src/session.rs index 7084c28..14a692c 100644 --- a/src/session.rs +++ b/src/session.rs @@ -12,15 +12,30 @@ 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. +/// 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), + /// 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. 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 cmd: String, + pub kind: EntryKind, pub group: Option, pub name: Option, } @@ -65,9 +80,10 @@ pub fn sessions_dir(root: Option) -> Option { .map(|base| base.join("sessions")) } -/// 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; +/// 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. fn dirs_json(cfg: &SessionConfig) -> jzon::JsonValue { @@ -75,22 +91,67 @@ 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 } +/// 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. +/// +/// 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()); + 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 { @@ -107,11 +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. +/// 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()))?; @@ -120,9 +183,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 +220,18 @@ fn from_json(text: &str) -> io::Result<(Option, SessionConfig)> { return Err(invalid(format!("directory {dir:?}: expected an array"))); } let mut entries = Vec::new(); + // In v1, managed launches could only be stored as command 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 +243,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 +250,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 +560,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 +695,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 +710,266 @@ mod tests { assert!( fs::read_to_string(&file) .unwrap() - .contains("\"version\": 1") + .contains("\"version\": 2") ); assert_eq!(load_in(&dir, "versioned").unwrap(), cfg); } + /// 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"); + 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); + } + + /// 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(); + 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"}}]}}"# + ) + ); + } + + /// 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| { + 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))); + } + + /// Preserve literal agent words in v2 files; migrate only v1 literals. + #[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) + ] + ); + } + + /// 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![ + ("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))), + // 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")), + ( + 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"), + ] + ); + } + + /// 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 = [ + ( + 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 +986,14 @@ mod tests { assert_eq!(cfg["~/proj"], vec![e("vim")]); } - /// A newer format fails with an error naming both versions. + /// 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": 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 +1005,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 +1016,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 +1032,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 +1144,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 +1311,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 +1355,34 @@ mod tests { assert_eq!(list_in(&dir), vec!["old".to_string()]); } + /// 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"); + 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 +1444,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 3054b34..242674a 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, assets}, path, protocol::{ Command, Event, LaunchContext, ScreenView, ScrollAction, TaskView, UNASSIGNED, env_get, }, - session::{self, SessionConfig, SessionEntry}, - task::{Task, WriteRefused}, + session::{self, EntryKind, SessionConfig, SessionEntry}, + task::{Exec, Task, WriteRefused}, }; /// Quiet period after which a live task becomes idle. @@ -44,13 +44,86 @@ const _: () = assert!(MAX_CELLS >= MAX_DIM as u32); /// guardrail, not a working limit. const MAX_TASKS: usize = 256; -/// Maximum command length in bytes. Direct spawns and session loads enforce -/// this limit to bound shell arguments and serialized task snapshots. +/// 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), + /// Managed agent under fleetcom supervision. + 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 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"; +/// 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, + skipped: usize, + failed: usize, + /// Launch notices and failure reasons, in load order. + notes: Vec, +} + +impl LoadOutcome { + /// 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 { + 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 + } +} + +/// 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, + 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`, 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")) +} + /// Environment variable overriding per-task terminal history depth. pub const FLEETCOM_SCROLLBACK: &str = "FLEETCOM_SCROLLBACK"; @@ -131,10 +204,11 @@ 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 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) @@ -155,15 +229,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)) @@ -174,6 +239,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 { .. } @@ -275,6 +341,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. 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. capture: BTreeMap, @@ -299,13 +368,24 @@ 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(), } } - /// Install the launch context used by subsequent spawns. + /// 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) + .into_iter() + .map(String::from) + .collect(); + self.events.push(Event::Agents(agents)); self.launch = Some(ctx); } @@ -315,6 +395,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) { @@ -362,6 +448,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) { @@ -512,6 +599,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), @@ -733,37 +821,62 @@ 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 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, 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); + ) -> io::Result<(Task, Option)> { let mut env = std::borrow::Cow::Borrowed(env); let mut meta = 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()); - meta = Some((h, home, paths.capture_file, resume_id)); - } + let mut notice = None; + let (command, exec) = match launch { + Launch::Literal(text) => (*text, Exec::Literal((*text).to_string())), + Launch::Managed(m) => { + // 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 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, + }; + 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, @@ -779,27 +892,57 @@ impl Supervisor { task.resume_id = resume_id; } task.run = run; - Ok(task) + // 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. - /// The caller enforces `MAX_TASKS` and reports failures 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, - command: &str, + launch: &Launch, cwd: &Path, 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, command, 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 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}")); + } + // 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}")), + } + } + + /// 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!( + "task limit reached ({}), not spawning", + self.max_tasks + )); + return false; + } + true } fn spawn(&mut self, command: &str, cwd: PathBuf, group: Option) { @@ -811,24 +954,37 @@ 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) { - // 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 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; } + let Some(launch) = self.launch_or_refuse() else { + return; + }; + let managed = match resolve_agent(agent, &launch, Intent::Fresh) { + Ok(m) => m, + Err(why) => return self.status(format!("{why}, not spawning")), + }; + 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. If - /// the task has a captured agent session, resume the best-known ID in the - /// replacement. + /// 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}")); @@ -849,23 +1005,41 @@ 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(); + // Only managed tasks have a harness; use it to distinguish the launch mode. + let command; + let relaunch = match self.tasks[i].harness { + Some(agent) => { + 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); + Launch::Managed(ManagedLaunch { + agent, + binary, + intent, + }) + } + None => { + command = self.tasks[i].command.clone(); + 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) { - Ok(mut fresh) => { + match self.spawn_task(id, run, &relaunch, &cwd, &launch.env) { + 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(); - // Derive the resume command before removing the displaced run's capture - // file during retirement. + // 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. @@ -877,8 +1051,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, 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. @@ -888,10 +1063,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(), }); @@ -940,40 +1122,65 @@ 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. 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)> { + /// 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 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() >= MAX_TASKS || entry.cmd.len() > MAX_COMMAND_LEN { - skipped += 1; + if self.tasks.len() >= self.max_tasks { + out.skipped += 1; continue; } + let kind = match &entry.kind { + EntryKind::Literal(text) => { + if text.len() > MAX_COMMAND_LEN { + out.skipped += 1; + continue; + } + Launch::Literal(text) + } + EntryKind::Managed { agent, resume } => { + let intent = resume.clone().map_or(Intent::Fresh, Intent::Resume); + 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. match self.admit( - &entry.cmd, + &kind, &resolved, &launch.env, 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 @@ -1008,21 +1215,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. @@ -1032,18 +1240,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 694c1ec..87e5a42 100644 --- a/src/supervisor_capture_tests.rs +++ b/src/supervisor_capture_tests.rs @@ -1,21 +1,25 @@ 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 ------------------------------------------- -/// Install an executable stub that records `FLEETCOM_CAPTURE_FILE` 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. 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'\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() ), ); @@ -105,6 +109,90 @@ 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() +} + +/// 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!( + got.len() == 1 && got[0].contains(needle), + "expected one notice naming {needle:?}; got {got:?}" + ); +} + +/// Check for a spawn acknowledgement in drained events. +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` 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, + 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() +} + +/// Resume target saved under `name` for the sole managed task. +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() { @@ -115,15 +203,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. +/// 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"); 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 @@ -207,86 +295,87 @@ 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. +/// 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 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"); + // Publish the argument count and capture environment atomically from the stub. + 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"); + // 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 + .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" + install_status_record( + &claude_home, + pid, + &dir, + r#""status":"waiting","waitingFor":"permission prompt""#, ); + 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!( - reap_until(&mut s, Duration::from_secs(5), |s| s.graveyard.is_empty()), - "the displaced run was never collected" - ); - // The resume command retains the ID after the source capture is deleted. - assert!( - !cap.exists(), - "rerun must delete the displaced run's capture file" + !text.contains(CAP_ID), + "the capture-shaped file must be unreachable: {text}" ); } @@ -303,7 +392,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"); @@ -319,7 +408,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" @@ -334,14 +423,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" ); } @@ -353,7 +438,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); @@ -372,13 +457,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(), @@ -393,16 +478,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!( @@ -430,7 +515,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); @@ -440,7 +525,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() @@ -460,8 +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. +/// 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; @@ -475,7 +561,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 @@ -496,6 +582,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"); @@ -516,7 +607,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!( @@ -543,10 +634,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" ); } @@ -558,7 +649,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 @@ -592,11 +683,11 @@ 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 retain the capture or launch ID; an -/// uncaptured task retains its exact authored command. +/// 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_commands() { +fn printed_resume_hints_do_not_change_saved_recovery_or_rerun_targets() { for (tool, hints) in [ ( "claude", @@ -636,8 +727,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 { @@ -653,10 +743,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() @@ -675,9 +761,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"); @@ -704,18 +790,19 @@ 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. +/// 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}; 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", @@ -725,12 +812,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!( @@ -752,7 +849,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] @@ -782,7 +879,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() @@ -792,14 +889,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. @@ -814,10 +907,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(); } @@ -836,13 +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 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. + // 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", @@ -864,7 +954,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() @@ -918,18 +1008,18 @@ 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. +/// 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_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"); @@ -966,10 +1056,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 @@ -992,14 +1080,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!( @@ -1030,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"; -/// 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 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}"}}"# @@ -1044,26 +1124,36 @@ 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 `--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"); - std::fs::write(&cap, payload).unwrap(); - 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 + crate::harness::record_arrival(payload, &|key| match key { + crate::harness::CAPTURE_ENV => Some(cap.clone()), + "CODEX_HOME" => Some(codex_home.to_path_buf()), + _ => None, + }) } -/// 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 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, + "fleetcom", + &format!("printf '%s' '{CAP_ID}' > \"$FLEETCOM_CAPTURE_FILE\""), + ); + bin.join("fleetcom") +} + +/// 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"); @@ -1087,35 +1177,45 @@ 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", &title_turn()), - "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!( - saved_command( - &mut s, - &config, - "child", - &turn_complete(CODEX_CHILD, "pong") - ), - resumes_root, + arrive(&s, &codex_home, &turn_complete(CODEX_CHILD, "pong")).as_deref(), + Some(CODEX_ROOT) + ); + assert_eq!( + 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!( - saved_command(&mut s, &config, "root", &turn_complete(CODEX_ROOT, "done")), - resumes_root, + arrive(&s, &codex_home, &turn_complete(CODEX_ROOT, "done")).as_deref(), + Some(CODEX_ROOT) + ); + assert_eq!( + 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_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. @@ -1135,23 +1235,35 @@ 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", &title_turn()), - 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", &turn_complete(FOREIGN, "hi")), - 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!( - saved_command(&mut s, &config, "root", &turn_complete(CODEX_ROOT, "done")), - format!("codex resume '{CODEX_ROOT}'"), + arrive(&s, &codex_home, &turn_complete(CODEX_ROOT, "done")).as_deref(), + Some(CODEX_ROOT) + ); + assert_eq!( + saved_resume(&mut s, &config, "root").as_deref(), + Some(CODEX_ROOT), "the task's own root thread must still outrank the launch target" ); } @@ -1180,8 +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`. +/// 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"); @@ -1207,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()), @@ -1216,22 +1329,16 @@ 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 .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)] ); } @@ -1248,14 +1355,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. + // 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 // 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, @@ -1268,15 +1380,15 @@ 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())); 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(), @@ -1289,9 +1401,9 @@ fn stale_inherited_notify_chain_is_never_executed() { ); } -/// Without a known ID, an agent recipe retains the original command. +/// Without a known ID, save only the managed agent word; omit `resume`. #[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. @@ -1306,24 +1418,24 @@ 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}" ); } -/// A representable `notify` assignment runs through the injected notifier -/// after the capture write. +/// 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; @@ -1341,18 +1453,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. + // 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( &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( @@ -1361,7 +1475,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=[")), @@ -1385,8 +1499,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 +1509,9 @@ 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. +/// 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"); @@ -1416,12 +1531,13 @@ 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_sole_notice(&mut s, "capture unavailable"); 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. @@ -1431,7 +1547,8 @@ 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!( argv.iter().any(|a| a.starts_with("notify=[")), @@ -1460,14 +1577,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. @@ -1483,7 +1593,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"); @@ -1537,6 +1647,409 @@ 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`: 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"); + // 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 +} + +/// `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() +} + +/// 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")); + // Simulate Claude's SessionStart hook after `/clear`: write the changed ID and stamp + // the capture with the stub's 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" + ); + + // 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(); + 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" + ); +} + +/// 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"); + 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_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_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"); + + 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_sole_notice(&mut s, "task limit"); + spawn(&mut s, "sleep 1", dir.to_path_buf()); + assert_sole_notice(&mut s, "task limit"); + assert_eq!(s.tasks.len(), 1, "the ceiling holds for both launch kinds"); +} + +/// 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 +} + +/// 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"); + 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(); + + 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!( + 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")); + 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" + ); + + // 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 }); + assert_sole_notice(&mut s, "not found on PATH"); + assert_eq!(s.tasks[0].run, 1, "the finished task is preserved"); +} + +/// 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"); + 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); +} + +/// 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")); + // 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(); + } + // 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", + &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( + 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 { + group: Some("agents".into()), + ..managed("claude", Some(CAP_ID)) + }, + managed("omp", None), + ] + )]) + ); + + 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, "{:?}", notices(&mut s)); + let reloaded = &s.tasks[2]; + 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_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), + Some("-e"), + "the reloaded omp entry must load the capture extension: {omp_argv:?}" + ); +} + +/// 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"); + 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)) + }, + ], + ); + 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); + 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] + ); + + 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"); +} + +/// 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"); + 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_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; + 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_sole_notice(&mut s, "capture unavailable"); + 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, @@ -1582,7 +2095,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 45ee31a..b8e56b1 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 @@ -2081,10 +2068,143 @@ 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") +} + +/// 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() + .components() + .filter(|c| matches!(c, Component::Normal(_))) + .count(); + std::iter::repeat_n("..", ups) + .collect::() + .join(dir.strip_prefix("/").unwrap()) +} + +/// 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"); + 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()); +} + +/// 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"); + 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:?}" + ); + + 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(); @@ -2238,7 +2358,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, }; @@ -2297,12 +2417,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 0774926..7a66003 100644 --- a/src/task.rs +++ b/src/task.rs @@ -96,20 +96,24 @@ pub struct Task { pub group: Option, /// Custom display name; `None` means unnamed. pub name: Option, - /// Agent harness selected for session capture. + /// 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, - /// 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 - /// 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 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`. preview: PreviewState, @@ -204,16 +208,31 @@ fn wait_code(status: &rustix::process::WaitIdStatus) -> i32 { .unwrap_or(1) } +/// 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), + /// 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, + }, +} + 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 +249,28 @@ 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) => { + // 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()); + 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 +368,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..ccbf72d 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,63 @@ fn exited_leader_stays_a_zombie_until_drop() { assert!(kill(pid, None).is_err(), "Drop did not collect the zombie"); } +/// 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"); + // 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"); + 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 +209,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 +402,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 +451,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 +492,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 +539,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 +607,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 +656,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 +723,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(); diff --git a/src/ui.rs b/src/ui.rs index 88990c0..5bb47c2 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)? @@ -208,13 +212,13 @@ 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, - )?; + // 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()), + }; + 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 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") + } 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 { + // 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(), Mode::Rename(_) => " rename task: ".to_string(), @@ -841,6 +857,41 @@ fn render_find(out: &mut impl Write, app: &App) -> io::Result<()> { ) } +/// 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() + } else { + format!( + " (no installed agent matches · {} not found on this host)", + missing.join(", ") + ) + } +} + +/// 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); + 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 +1254,13 @@ mod tests { ); } + /// 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"); + assert_eq!(spawn_page_hint(3), " enter run · tab agents (3) · esc"); + } + /// Directory and group destinations appear only when present. #[test] fn spawn_prompt_decoration_shapes() { @@ -1229,6 +1287,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..7c77a81 --- /dev/null +++ b/tests/daemon_agents.rs @@ -0,0 +1,184 @@ +//! 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; + +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: the stub in `bin`, an empty `nobin`, and isolated runtime, config, and +/// registry paths. +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 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()), + ("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); + // 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 to record argv and exit. +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 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 { + 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"); + + // 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"]}"# + ); + + // 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(); + 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:?}" + ); + + // Refuse an unregistered word with a notice and no new 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}" + ); + + // 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; + 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); +} diff --git a/tests/daemon_resume.rs b/tests/daemon_resume.rs index 452ba23..831b3b0 100644 --- a/tests/daemon_resume.rs +++ b/tests/daemon_resume.rs @@ -1,7 +1,6 @@ -//! 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. +//! 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; @@ -9,20 +8,34 @@ use std::{ io::Write, os::unix::{fs::PermissionsExt, net::UnixStream}, path::{Path, PathBuf}, + process::{Command, Stdio}, time::Duration, }; use common::{ - control_frame, read_frame, shake_hands_env, spawn_frame, start_daemon_raw, stop_daemon, + 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. 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 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 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`. +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 +86,11 @@ impl Scratch { self.root.join(format!("{tool}-argv")) } + /// Marker touched by the `codex` stub after both 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 +176,36 @@ 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 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}' 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); } @@ -233,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 contain the resume form. +/// 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!( @@ -249,32 +282,42 @@ 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) - }) - }) - }) - }) +/// 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() + .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 -/// into the recipe, then reloads the same conversation. +/// 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 }}") +} + +/// 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"); @@ -283,7 +326,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") @@ -296,7 +341,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!( @@ -325,8 +370,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. +/// 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"); @@ -335,7 +381,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(); @@ -351,13 +399,31 @@ 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"); + // 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()); + 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}'")), + recipe.contains(&managed_entry("codex", CODEX_ID)), "the recipe must resume the captured thread: {recipe}" ); @@ -381,8 +447,54 @@ fn codex_capture_file_drives_save_and_load_resumes() { stop_daemon(&mut daemon); } -/// A persisted resume command survives daemon replacement and targets the same -/// ID afterward. +/// 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"); + 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"); +} + +/// 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"); @@ -393,13 +505,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); @@ -420,7 +532,7 @@ 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);