diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..d10b9e7 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,21 @@ +# UAM terminology + +This glossary distinguishes the process that UAM manages from the conversation +state owned by an agent provider. + +| Term | Meaning | +|---|---| +| **Managed Session** | One persistent UAM record and its detached terminal host. It has a UAM ID, provider, name, workspace, prompt metadata, and lifecycle state. | +| **Provider Conversation** | Conversation state owned by Claude Code, Codex, Copilot, Oh My Pi, OpenCode, or another provider. A provider conversation may have its own identifier and resume rules. It is not the same object as a Managed Session. | +| **Attach** | Connect the current terminal to an already-running Managed Session. The attach operation itself does not start or resume a provider process, although the `uam attach` command first resumes a selected Stopped session when supported. Detaching leaves the running process in place. | +| **Resume** | Start a stopped Managed Session's provider process again, preserving the UAM identity and asking the provider to continue an earlier Provider Conversation. | +| **Workspace** | The working directory shared by a Managed Session and its provider process. Multiple Managed Sessions may use the same Workspace and therefore edit the same files. UAM does not create a worktree or other filesystem isolation automatically. | +| **Explicitly Stopped** | A Managed Session whose provider process was stopped through UAM. This is retained as reason metadata; it still appears in the **Stopped** lifecycle group. | +| **Running** | The Managed Session's provider process is alive. This is based on process liveness, not interpretation of terminal text. | +| **Stopped** | The provider process is not alive. The session record remains available and may be resumable. A clean exit, explicit stop, signal, or nonzero exit all belong to this group; exit detail distinguishes failures where known. | +| **Exact resume** | Resume targets a known Provider Conversation or a provider state directory dedicated to the Managed Session. Other sessions in the same Workspace cannot change the target. | +| **Heuristic resume** | The provider can only continue its most recent conversation or equivalent. When several retained sessions for that provider share a Workspace, UAM requires explicit confirmation because the target cannot be proven. | + +See [Managed Session vs. Provider Conversation](docs/adr/0001-managed-session-vs-provider-conversation.md) +for the decision behind these definitions and [Responsive TUI operations](docs/responsive-tui.md) +for day-to-day controls. diff --git a/README.md b/README.md index aa06c84..21daf8f 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Supported providers: - Runs each managed session under its own lightweight, detached host process (a PTY + terminal emulator + Unix socket) — sessions keep running when the TUI exits, exactly like a tmux server, with no external dependency -- Shows active and closed sessions in one dashboard +- Shows Running and Stopped sessions in one dashboard, with grounded exit detail - Lets you peek at recent output without attaching (4000 lines of scrollback) - Sends replies back into running agent sessions - Persists session metadata across restarts, including each agent's exit code @@ -50,7 +50,8 @@ from the dispatch UI instead of failing the whole app. - Linux (Ubuntu), on amd64 and arm64 - macOS, on Intel and Apple silicon -- Native Windows is not supported. +- Native Windows is not supported. Windows Terminal and PowerShell can be used + only as SSH clients connecting to a Linux or macOS host running `uam`. ## Install @@ -96,10 +97,10 @@ uam new # guided dispatch wizard, then attach uam dispatch [--safe] [--alias ] [#session-name] [prompt] uam ls [--json] uam peek -uam attach +uam attach [--allow-latest] uam last uam stop # kill the session, keep record -uam restart # stop the agent and resume it in place +uam restart [--allow-latest] # stop the agent and resume it in place uam rm # kill the session and remove record uam kill-all # stop every managed session uam version @@ -115,16 +116,22 @@ uam version | `@agent prompt` | Dispatch to a specific agent | | `@agent:alias prompt` | Dispatch with a command alias | | `Tab` | Cycle default agent | -| `Space` | Toggle peek panel | +| `Space` | Toggle Peek for Running; resume Stopped in the background | | `Ctrl+T` | Pin selected session | | `Ctrl+R` | Rename selected session | -| `Ctrl+X` | Stop, restart, or remove the selected session with confirmation | +| `Ctrl+X` | Stop and remove the selected record, or restart it, with confirmation | | `Ctrl+S` | Toggle group-by-directory | | `Shift+↑/↓` | Reorder rows | | `e` | Open the guided dispatch wizard | | `?` | Open help | | `Esc` | Close overlays, clear input, or quit | +The dashboard responds to every terminal resize. Wide terminals show sessions +beside selected details or Peek; standard terminals stack those surfaces; and +compact or keyboard-constrained mobile terminals give Peek and the New Session +wizard the full primary surface. See [Responsive TUI design and operations](docs/responsive-tui.md) +for layout thresholds, mobile guidance, lifecycle markers, and accessibility. + ## Attached sessions `uam attach` (or `Enter` in the TUI) bridges your terminal straight to the @@ -144,26 +151,86 @@ agent's PTY: session would leave it impossible to foreground - Several terminals can attach to the same session at once +`UAM_ATTACH_MOUSE` controls whether provider mouse reporting is preserved: + +- `auto` (the default) enables it locally and disables it when `SSH_CONNECTION` + or `SSH_TTY` indicates an SSH session +- `on` preserves provider mouse reporting everywhere +- `off` suppresses provider mouse modes so the terminal keeps selection and + paste gestures + +Bracketed-paste payload is forwarded byte-for-byte. Control bytes inside a paste +do not trigger UAM's attach shortcuts. UAM cannot access the client clipboard or +turn an unsent mouse gesture into remote input. + +For PowerShell SSH, use Windows Terminal and configure a Windows Terminal paste +binding such as right-click, `Ctrl+Shift+V`, or `Shift+Insert`. Keep +`UAM_ATTACH_MOUSE=auto` (or set it to `off`) on the remote host if provider mouse +tracking captures selection or right-click. Native Windows remains unsupported; +Windows is the SSH client in this setup. + +In the TUI, `Ctrl+X` followed by `y` stops the process **and removes its stored +record**. Use `uam stop ` when you want to stop the process but retain a +Stopped row for later resume. For paste diagnosis, follow the +[SSH troubleshooting steps](docs/responsive-tui.md#ssh-mouse-and-paste). + ## Resuming sessions -Detach/reattach never restarts anything — the agent keeps running under its -host and attach is a plain reconnect (this is the tmux property, kept). -Resume only applies to sessions whose process is gone (reboot, `uam stop`): +Detach/reattach never restarts anything — the provider keeps running under its +host and attach is a plain reconnect. Resume applies only when the provider +process is Stopped, such as after a reboot, clean exit, or `uam stop`. + +UAM distinguishes an **exact resume**, which targets known provider state, from +a **heuristic resume**, which asks the provider to continue its latest +conversation. If a heuristic provider has multiple retained sessions in the +same workspace, UAM fails closed before launching it. The TUI asks for explicit +confirmation; CLI users may retry only when latest-conversation behavior is +acceptable: + +```sh +uam attach --allow-latest +uam restart --allow-latest +``` + +Provider behavior: - **Claude Code**: uam seeds claude's session id with the uam id at dispatch (`--session-id`, when the installed claude supports it) and resumes that exact conversation with `--resume ` — several sessions in the same directory each resume their own conversation. Sessions dispatched before - this feature (or with an older claude) fall back to `--continue`. + this feature (or with an older Claude Code) use the guarded `--continue` + heuristic. - **Copilot**: exact resume — the session is named with the uam id at dispatch (`--name`) and resumed by that exact name (`--resume=`). -- **Codex / OpenCode**: these CLIs cannot preset session ids yet, so resume - uses their "most recent" mode (`codex resume --last`, `opencode -c`). When - an opencode record carries a `provider_session_id` (`ses_...`), uam resumes - that exact session via `--session`. **omp**: `-c`. +- **OpenCode**: UAM learns the current root conversation ID and resumes it + exactly with `--session`. Older records without an identity use guarded + `-c` latest-conversation behavior. +- **Oh My Pi**: new sessions receive a UAM-ID-specific provider state directory, + making `-c` exact. Legacy records without that directory retain guarded + latest-conversation behavior. +- **Codex**: `codex resume --last` is heuristic because Codex cannot currently + be given the UAM ID when the conversation is created. +- **Hermes**: no provider resume command is configured. A stopped Hermes record + cannot be resumed; dispatch a new Managed Session instead. - After a reboot, records survive in the store and resume on attach — a scenario where a tmux session would simply be gone. +### Multiple sessions in one workspace + +UAM can run several Managed Sessions in one project directory. They have +independent UAM IDs, terminal hosts, attach points, and provider conversations, +but they share the same files. The grouped TUI warns when more than one Running +session shares a Workspace. Use separate Git worktrees or checkouts when agents +must not edit the same tree; UAM does not create them automatically. + +OpenCode's `/new` creates a new provider conversation *inside the current +Managed Session*. It intentionally does not create another UAM row or host. UAM +tracks the newly selected root conversation for later exact resume. To get two +independently attachable sessions, use `uam new` or `uam dispatch` again. + +The terminology and compatibility decision are documented in +[Managed Session vs. Provider Conversation](docs/adr/0001-managed-session-vs-provider-conversation.md). + ## Session storage `uam` stores session metadata at: @@ -203,10 +270,25 @@ Use `uam dispatch --safe ...` when you want the provider's default approval behavior instead. Safe mode changes provider arguments; it is not an operating- system sandbox and does not reduce the permissions of the `uam` process itself. +OpenCode is capability-probed before launch. In default yolo mode, UAM adds +`--auto` only when the installed OpenCode advertises that flag; older versions +continue without an unsupported argument. This preserves compatibility but does +not turn OpenCode or UAM into a sandbox. + `uam` does not make git checkpoints, stash changes, or modify your repository on its own. It starts and manages agent sessions; the provider remains responsible for its own execution model. +## Design and operations + +- [Terminology glossary](CONTEXT.md) +- [Managed Session vs. Provider Conversation](docs/adr/0001-managed-session-vs-provider-conversation.md) +- [Terminal ownership over SSH](docs/adr/0002-terminal-ownership-over-ssh.md) +- [Responsive TUI design and operations](docs/responsive-tui.md) + +Session storage remains schema version 3. Updates do not require a data migration, +and unknown JSON fields are preserved when existing records are rewritten. + ## Development ```sh diff --git a/docs/adr/0001-managed-session-vs-provider-conversation.md b/docs/adr/0001-managed-session-vs-provider-conversation.md new file mode 100644 index 0000000..f4aa82c --- /dev/null +++ b/docs/adr/0001-managed-session-vs-provider-conversation.md @@ -0,0 +1,121 @@ +# ADR 0001: Managed Session vs. Provider Conversation + +- Status: Accepted +- Date: 2026-07-12 + +## Context + +UAM controls a detached terminal host while each provider controls its own +conversation database. These lifecycles overlap but are not identical. Attaching +to a live terminal is deterministic; starting a stopped provider may be exact or +may rely on a provider's "continue latest" behavior. + +Treating the two objects as one caused confusing outcomes when multiple retained +sessions used the same provider and working directory. In particular, an old +provider without an addressable conversation ID could resume a different recent +conversation even though the selected UAM row was unambiguous. + +## Decision + +UAM distinguishes a **Managed Session** from a **Provider Conversation**: + +- A Managed Session is UAM's stable record and detached host identity. Its UAM + ID, session name, provider, workspace, display metadata, and persisted order + remain stable across a resume. +- A Provider Conversation is provider-owned state. UAM records a provider ID or + creates isolated provider state when the provider supports it. +- **Attach** reconnects to a running host and never invokes provider resume. +- **Resume** launches a provider for a stopped Managed Session. The launch is + classified as exact or heuristic before it occurs. + +Multiple Managed Sessions may use one Workspace. This is useful for parallel +tasks, but every provider process sees and can edit the same filesystem. UAM +shows a warning when more than one running session shares a Workspace. + +### Exact and heuristic resume + +The current provider behavior is: + +| Provider | Exact when | Heuristic fallback | +|---|---|---| +| Claude Code | UAM seeded and retained a provider session ID | `--continue` for older records or provider versions that could not seed an ID | +| GitHub Copilot CLI | Always for UAM-created records; the UAM ID is used as the provider name | None | +| OpenCode | The UAM identity integration learned a `ses_…` ID | `-c` continues OpenCode's latest conversation | +| Oh My Pi | The Managed Session has its dedicated provider state directory | Legacy records use `-c` without isolated state | +| OpenAI Codex | Not currently available | `resume --last` | +| Hermes Agent | Not currently available | Resume is unsupported; create a new Managed Session | + +Other providers may declare exact, heuristic, or unsupported resume through the +same contract. + +### Ambiguity guard + +Before a heuristic resume, UAM counts retained sessions for the same provider +and normalized Workspace. If more than one exists, UAM stops before launching +the provider: + +- CLI callers must retry `attach` or `restart` with `--allow-latest`. +- The TUI identifies the selected provider, session, and action and asks for + confirmation. Confirming permits the provider's latest-conversation behavior; + cancelling has no side effects. + +This guard is intentionally conservative. A unique retained session can use the +provider fallback without another confirmation, but UAM still cannot prove +provider state that it does not own. + +### OpenCode `/new` + +OpenCode's `/new` command starts another Provider Conversation inside the same +running Managed Session. It does not create a second UAM row or a second +detached host. UAM's OpenCode identity integration follows the current root +conversation so a later exact resume targets the conversation last selected in +that Managed Session. + +Use `uam new` or dispatch another session when two independently attachable UAM +sessions are required. Using the same Workspace is allowed, with the shared-file +risk described above. + +### Workspace isolation + +UAM does not automatically create Git worktrees, branches, stashes, or commits. +Automatic isolation would change repository topology and provider-visible paths, +and could silently alter established workflows. Users who need filesystem +isolation create a worktree or separate checkout first and dispatch each Managed +Session in the desired directory. + +## Alternatives considered + +### Always continue the provider's latest conversation + +Rejected because selecting one UAM row could resume another conversation when +several records share a Workspace. + +### Refuse all heuristic resume + +Rejected because legacy records and providers without addressable IDs would +become unusable after a reboot or stop. Explicit confirmation preserves that +compatibility without pretending the target is exact. + +### Create a worktree for every Managed Session + +Rejected as a default because it changes filesystem and Git behavior, requires +branch-management policy, and does not suit non-Git workspaces. It remains a +user-controlled option. + +### Treat provider `/new` as a new Managed Session + +Rejected because the command occurs inside a provider-owned terminal protocol. +It does not create a new host and cannot provide an independently attachable UAM +session. + +## Consequences + +- Session selection is safe by default when resume would otherwise be + ambiguous. +- Exact resume improves as providers expose stable identifiers; legacy records + remain compatible. +- A Managed Session can outlive several provider-side conversation switches, + which is visible in the terminology but does not multiply dashboard rows. +- Users retain control over worktrees and other repository isolation. +- Schema version 3 remains unchanged. Provider identity fits the existing + record, and unknown fields continue to round-trip. diff --git a/docs/adr/0002-terminal-ownership-over-ssh.md b/docs/adr/0002-terminal-ownership-over-ssh.md new file mode 100644 index 0000000..74176f1 --- /dev/null +++ b/docs/adr/0002-terminal-ownership-over-ssh.md @@ -0,0 +1,115 @@ +# ADR 0002: Terminal ownership over SSH + +- Status: Accepted +- Date: 2026-07-12 + +## Context + +UAM's dashboard, attach client, provider TUI, local terminal emulator, SSH +transport, and remote shell can all change terminal modes. If ownership is not +explicit, a provider can leave the dashboard or shell with overlapping output, +hidden cursors, mouse escape sequences, or bracketed-paste state that no longer +matches the active program. + +SSH adds another boundary: the remote process receives bytes, not the local +clipboard or a right-click event. Terminal applications also disagree about +whether mouse tracking should capture clicks instead of performing local text +selection and paste. + +## Decision + +### Attach owns its terminal screen + +When output is a terminal, the attach client enters an alternate screen that it +owns. Input independently enters raw mode when it is a terminal. Provider output +is rendered inside that boundary. Seven-bit DEC-private CSI sequences used by +supported providers to enter or leave the alternate screen are contained so +they cannot escape into the user's primary shell screen. The filter deliberately +does not interpret a bare C1 byte as CSI because that byte value can occur inside +UTF-8 input; supported provider output uses the seven-bit `ESC [` form. + +On detach or return, UAM drains output within the owned screen and restores the +terminal contract: reset attributes, disable mouse/focus tracking, disable +bracketed paste, show the cursor, leave the owned alternate screen, and return +to a clean line. The dashboard separately repaints after an attached process +returns. Cleanup is intentionally targeted; UAM does not reset the entire +terminal or erase scrollback. + +Non-terminal streams do not receive alternate-screen control sequences. + +### Mouse policy is explicit + +`UAM_ATTACH_MOUSE` accepts: + +| Value | Behavior | +|---|---| +| `auto` or unset | Enable provider mouse reporting locally. Disable it when `SSH_CONNECTION` or `SSH_TTY` indicates an SSH session. | +| `on` | Preserve provider mouse reporting, including over SSH. | +| `off` | Suppress provider mouse-reporting modes while attached. | + +Invalid values use `auto`. With mouse reporting off, UAM removes only the +well-known mouse mode parameters from provider control sequences and preserves +unrelated terminal modes. This makes local selection and terminal-defined paste +more predictable over SSH without rewriting provider text. + +### Paste is a byte stream + +UAM recognizes bracketed-paste start and end markers across input packet +boundaries. While a paste is active, payload bytes pass to the provider +literally. Attach shortcuts such as `Ctrl+B`, `Ctrl+C`, and `Ctrl+Z` are not +interpreted inside that payload. UTF-8, newlines, and carriage returns are not +normalized. + +Outside bracketed paste, the established attach keys retain their meaning. + +### Client limitations stay visible + +UAM can preserve only bytes that the terminal and SSH client send. It cannot +observe the local clipboard, convert a Windows mouse event into remote input, or +make PowerShell's console host behave like another terminal. + +For Windows clients, Windows Terminal is the recommended host for PowerShell +SSH. Configure a local paste binding such as right-click, `Ctrl+Shift+V`, or +`Shift+Insert`; the exact binding belongs to Windows Terminal. Once it sends the +text, UAM forwards it unchanged. If selection or right-click is captured by a +remote provider, keep `UAM_ATTACH_MOUSE=auto` or set it to `off` on the remote +host. + +Windows remains an SSH client in this design. UAM is not a native Windows +process. + +## Rejected alternatives + +### Clipboard emulation and OSC 52 + +Rejected. UAM does not read or synchronize the local clipboard and does not +inject OSC 52. Clipboard access has different security and consent semantics, +is inconsistently supported through SSH, and would not repair a client that +never sends the paste action. + +### Full terminal reset or scrollback clear + +Rejected because it destroys user-visible shell history and resets unrelated +terminal preferences. Targeted cleanup is sufficient to re-establish ownership. + +### Always enable provider mouse reporting + +Rejected because many SSH terminal workflows depend on the local terminal for +selection and paste. Users can still opt in with `UAM_ATTACH_MOUSE=on`. + +### Strip all private terminal modes + +Rejected because providers legitimately use cursor, focus, keyboard, and paste +modes. Filtering is limited to alternate-screen ownership and the configured +mouse policy. + +## Consequences + +- Returning from attach or quitting the dashboard leaves a predictable shell + surface without erasing scrollback. +- SSH defaults favor terminal selection and paste; local use retains provider + mouse interaction. +- Pasted control bytes cannot accidentally trigger UAM detach or suppression + shortcuts. +- Terminal-client configuration remains necessary when the client does not send + a paste operation. diff --git a/docs/responsive-tui.md b/docs/responsive-tui.md new file mode 100644 index 0000000..2b0a583 --- /dev/null +++ b/docs/responsive-tui.md @@ -0,0 +1,141 @@ +# Responsive TUI design and operations + +The UAM dashboard adapts to desktop terminals, split panes, and mobile terminals +where an on-screen keyboard may leave fewer than 24 rows. This guide explains +what stays visible and how to operate sessions safely in each layout. + +## Lifecycle at a glance + +Rows are partitioned by process liveness: + +- **Running** means the provider process is alive. +- **Stopped** means the provider process is gone but the Managed Session record + remains. Clean exits and explicit stops use a neutral marker. A known nonzero + exit or signal uses a failure marker and displays `exit N` or `signal`. + +UAM does not infer "working," "waiting," or "completed" by scraping provider +text. The selected row's prompt is kept as its task summary; failure detail is +added without replacing it. + +Attaching to Running reconnects to the existing host. Acting on Stopped resumes +the provider when supported. If that resume can only select the provider's most +recent conversation and several retained sessions share the Workspace, the TUI +asks for confirmation before launching anything. + +## Layout classes + +The layout is derived from the current terminal dimensions on every resize. + +| Layout | Geometry | Operations view | Peek view | +|---|---|---|---| +| **Wide** | At least 96 columns and 28 rows | Session list and selected-session details are side by side. | Session list remains beside the output tail. | +| **Standard** | At least 58 columns and 24 rows, but below Wide | Selected-session summary appears above the list. | Output tail replaces the list so it has useful width. | +| **Compact** | Fewer than 58 columns or fewer than 24 rows | A bounded selected summary and single-line session rows share the screen. | Output tail becomes the primary surface. | + +The prompt is reserved at the bottom before the remaining rows are allocated. +The New Session wizard is a primary surface in all layouts, so every step remains +usable when a mobile keyboard reduces the available height. Content is truncated +by terminal-cell width without splitting Unicode text. + +## Keyboard map + +| Key | Action | +|---|---| +| `↑` / `↓` | Move the selected row. With Peek open, output follows the selection. | +| `Enter` / `→` | Attach to Running, or resume and attach to Stopped. | +| `Space` | Open or close Peek for Running. For Stopped, resume in the background; this may first require latest-conversation confirmation. | +| Type + `Enter` | Dispatch with the default provider. In Peek, send a reply to the selected session. | +| `@provider:alias #name prompt` | Choose provider, optional command alias, optional name, and prompt inline. | +| `Tab` | Cycle the default provider. In the wizard, cycle provider or complete a path according to the current step. | +| `e` | Open the four-step New Session wizard. | +| `Ctrl+G` | Open `$VISUAL` or `$EDITOR` for the wizard prompt. | +| `Ctrl+T` | Pin or unpin the selected row. | +| `Ctrl+R` | Rename the selected Managed Session. | +| `Ctrl+X` | Confirm stop **and record removal**; press `r` in the confirmation to restart in place. Use CLI `uam stop ` to retain a Stopped row. | +| `Ctrl+S` | Toggle Workspace grouping. | +| `Shift+↑` / `Shift+↓` | Reorder within the same lifecycle, pin, and visible Workspace group. | +| `?` | Open key help. | +| `Esc` | Close the current overlay or input; from the base dashboard, quit. | + +Inside an attached session, `Ctrl+B d` detaches. A bare left arrow also detaches +when the provider input is empty and the quick-detach option is enabled. See the +README for the complete attach-key contract. + +## Workspace grouping and parallel sessions + +`Ctrl+S` groups rows by normalized absolute working directory without resolving +symlinks. The grouping is a presentation projection: turning it off restores the +canonical lifecycle, pin, and manual ordering. + +When two or more Running sessions share one Workspace, the heading shows a +warning. This is not an error and does not serialize the agents. It means the +processes can read and modify the same files concurrently. Use separate Git +worktrees or checkouts when tasks require filesystem isolation; UAM never creates +them automatically. + +Reordering cannot cross a Running/Stopped boundary, a pin boundary, or (while +grouped) a Workspace boundary. Rejected moves leave selection and persisted +order unchanged. + +## Mobile operation + +With an on-screen keyboard, prefer Compact mode intentionally: + +Mobile operation requires a terminal extra-keys row or hardware keyboard that +can send Escape, Tab, arrows, and Control chords. UAM does not currently provide +touch-only substitutes for those terminal keys. + +1. Keep the terminal narrower than 58 columns or let the keyboard reduce it + below 24 rows. +2. Use `Space` to dedicate the primary surface to Peek; use `Esc` to return. +3. Use `e` for the bounded wizard instead of composing a long inline dispatch. +4. Use `Ctrl+G` with a terminal/editor combination that supports external editor + handoff when a multi-line prompt is easier outside the small viewport. + +The bottom prompt and current primary action remain visible as the height +changes. UAM does not assume a fixed phone aspect ratio. + +## SSH, mouse, and paste + +Mouse reporting defaults on for local attachment and off when the environment +contains `SSH_CONNECTION` or `SSH_TTY`. Override it with +`UAM_ATTACH_MOUSE=on|off|auto`. Keeping it off over SSH lets the terminal retain +selection and paste gestures; setting it on restores provider mouse interaction. + +Bracketed-paste payload is forwarded literally, including control bytes, UTF-8, +and line endings. UAM cannot initiate paste from a local clipboard. Windows users +should run SSH in Windows Terminal and configure a Windows Terminal paste +binding. See [ADR 0002](adr/0002-terminal-ownership-over-ssh.md) for the terminal +ownership boundary and limitations. + +## Accessibility and no-color operation + +- `NO_COLOR` disables styling even when the terminal advertises color. +- Lifecycle and pull-request states have distinct glyphs, so meaning is not + encoded by color alone. +- Names, paths, prompts, headings, and status text are sanitized before display + so stored control sequences cannot alter the terminal. +- Width calculations account for emoji, combining characters, and CJK text. +- The prompt, current selection, lifecycle headings, and modal choices remain + textual and keyboard-operable. + +## Operational checks + +If the screen looks corrupted after an older UAM or provider process exits, +start a fresh UAM dashboard so it can establish a new terminal-owned screen. +Current attach cleanup is targeted and should return to a visible cursor on a +clean line without clearing shell scrollback. + +If right-click paste behaves differently through PowerShell SSH: + +1. Confirm the SSH command is running inside Windows Terminal rather than the + legacy console host. +2. Confirm Windows Terminal has a paste binding for the gesture or key chord. +3. Keep the remote setting at `UAM_ATTACH_MOUSE=auto`, or set it to `off`. +4. Test `Ctrl+Shift+V` or `Shift+Insert` to separate a client binding problem + from remote input handling. + +If resuming a stopped row reports ambiguity, read the provider and Workspace in +the message. Confirm only when selecting the provider's latest conversation is +acceptable. Otherwise start a new Managed Session or restore an exact provider +identity. diff --git a/internal/adapter/adapter.go b/internal/adapter/adapter.go index a38afcd..c957cf4 100644 --- a/internal/adapter/adapter.go +++ b/internal/adapter/adapter.go @@ -15,8 +15,9 @@ type Context = context.Context type State string const ( - Active State = "Active" - Failed State = "Failed" + Active State = "Active" + Completed State = "Completed" + Failed State = "Failed" ) type ProcLiveness string @@ -69,11 +70,9 @@ type Session struct { // (-1 when it died on a signal), recorded by the session host. Nil while // the session is live or when no exit has been observed. ExitCode *int - // Closed mirrors store.StatusClosedByUser: true when the user retired - // this session through uam (`uam stop`, or exit-in-session — the host - // marks the record closed when the agent exits). False otherwise — - // including for dead sessions left over from a reboot, which remain in - // the Active group and resume on attach. + // Closed mirrors store.StatusClosedByUser: explicit UAM stop/restart reason + // metadata retained for compatibility. Dashboard lifecycle grouping uses + // ProcAlive, so every exited process is STOPPED regardless of this flag. Closed bool } @@ -96,6 +95,33 @@ type ResumeRequest struct { // instead of their "most recent" heuristic. ProviderSessionID string CreatedAt time.Time + // ExecutablePath is transient launch metadata populated by Agent only + // after alias validation and PATH resolution. Preparation hooks may probe + // it; it is never persisted. + ExecutablePath string +} + +// LaunchPreparation is provider-owned launch metadata computed after the +// canonical backend identity and cwd are known, but before any session is +// created. Slices and maps are copied by Agent before use. +type LaunchPreparation struct { + ExtraArgs []string + Env map[string]string + ProviderSessionID string +} + +type PrepareLaunchFunc func(ctx Context, req ResumeRequest, activity, sessionName, cwd string) (LaunchPreparation, error) + +type ResumeKind string + +const ( + ResumeExact ResumeKind = "exact" + ResumeHeuristic ResumeKind = "heuristic" + ResumeUnsupported ResumeKind = "unsupported" +) + +type ResumeKindAdapter interface { + ResumeKind(ResumeRequest) ResumeKind } type ResumableAdapter interface { diff --git a/internal/adapter/agent.go b/internal/adapter/agent.go index 76036f4..6399c41 100644 --- a/internal/adapter/agent.go +++ b/internal/adapter/agent.go @@ -62,13 +62,20 @@ type Agent struct { YoloArgs []string Backend Backend SessionArgs func(req ResumeRequest, activity string) []string + // PrepareLaunch runs after cwd and canonical session-name resolution. + // Preparation args run first; legacy SessionArgs are appended after them. + // Preparation identity overrides ProviderSession, which overrides the + // request's stored identity. Core UAM env keys cannot be overridden. + PrepareLaunch PrepareLaunchFunc // ProviderSession optionally reports the provider-side session id that // the launched agent will use (e.g. the uuid claude was seeded with via // --session-id), or "" when unknown. It is persisted so a later resume // can target the exact provider session (F-resume). - ProviderSession func(req ResumeRequest, activity string) string - SkipPromptOnResume bool - randomReader io.Reader + ProviderSession func(req ResumeRequest, activity string) string + LiveProviderSessionID func(sessionName string) (string, error) + ResumeKindFor func(req ResumeRequest) ResumeKind + SkipPromptOnResume bool + randomReader io.Reader // now is the clock used to throttle per-session PR captures; overridable in // tests. lastPRScan records, per session name, when its output was last @@ -198,14 +205,40 @@ func (a *Agent) Resume(ctx context.Context, req ResumeRequest) (Session, error) return a.startSession(ctx, req, "resumed") } -func (a *Agent) startSession(ctx context.Context, req ResumeRequest, activity string) (Session, error) { - extra := []string{} - if a.SessionArgs != nil { - extra = append(extra, a.SessionArgs(req, activity)...) +func (a *Agent) ResumeKind(req ResumeRequest) ResumeKind { + if a.ResumeKindFor != nil { + return a.ResumeKindFor(req) } - cmd, err := a.commandForRequest(ctx, req, extra) - if err != nil { - return Session{}, err + if a.SessionArgs == nil { + return ResumeUnsupported + } + if req.ProviderSessionID != "" { + return ResumeExact + } + return ResumeHeuristic +} + +func (a *Agent) startSession(ctx context.Context, req ResumeRequest, activity string) (Session, error) { + // Preparation hooks may probe provider executables. Reject aliases before + // they can observe or execute an untrusted path/name; commandForRequest + // retains the same validation as defense in depth. + if req.CommandAlias != "" { + if err := validateCommandAlias(req.CommandAlias); err != nil { + return Session{}, err + } + if path, err := exec.LookPath(req.CommandAlias); err == nil { + if abs, absErr := filepath.Abs(path); absErr == nil { + path = abs + } + req.ExecutablePath = path + } + } else if command, ok := a.resolveCommand(); ok { + if path, err := exec.LookPath(command[0]); err == nil { + if abs, absErr := filepath.Abs(path); absErr == nil { + path = abs + } + req.ExecutablePath = path + } } cwd, err := resolveSessionCwd(req.Cwd) if err != nil { @@ -215,7 +248,27 @@ func (a *Agent) startSession(ctx context.Context, req ResumeRequest, activity st if sessionName == "" { sessionName = fmt.Sprintf("uam-%s-%s", a.Name(), req.ID[:min(8, len(req.ID))]) } - env := map[string]string{"UAM_AGENT": a.Name(), "UAM_ID": req.ID} + preparation := LaunchPreparation{} + if a.PrepareLaunch != nil { + preparation, err = a.PrepareLaunch(ctx, req, activity, sessionName, cwd) + if err != nil { + return Session{}, fmt.Errorf("prepare %s launch: %w", a.Name(), err) + } + } + extra := append([]string{}, preparation.ExtraArgs...) + if a.SessionArgs != nil { + extra = append(extra, a.SessionArgs(req, activity)...) + } + cmd, err := a.commandForRequest(ctx, req, extra) + if err != nil { + return Session{}, err + } + env := make(map[string]string, len(preparation.Env)+2) + for key, value := range preparation.Env { + env[key] = value + } + env["UAM_AGENT"] = a.Name() + env["UAM_ID"] = req.ID if err := a.Backend.CreateSession(ctx, sessionName, cwd, env, cmd); err != nil { return Session{}, fmt.Errorf("create session %s: %w", sessionName, err) } @@ -242,6 +295,9 @@ func (a *Agent) startSession(ctx context.Context, req ResumeRequest, activity st providerID = id } } + if preparation.ProviderSessionID != "" { + providerID = preparation.ProviderSessionID + } return Session{ID: req.ID, AgentType: a.Name(), CommandAlias: req.CommandAlias, DisplayName: displayName, Prompt: req.Prompt, Cwd: cwd, SessionName: sessionName, ProviderSessionID: providerID, State: Active, ProcAlive: Alive, CreatedAt: created, LastChange: now}, nil } @@ -317,7 +373,16 @@ func (a *Agent) ListFromSnapshot(ctx context.Context, infos []session.Info) ([]S } prRef = ExtractPR(capture) } - out = append(out, Session{ID: id, AgentType: a.Name(), DisplayName: id, Cwd: info.Cwd, SessionName: info.Name, State: state, ProcAlive: alive, LastChange: time.Now(), CreatedAt: created, PR: prRef}) + providerID := "" + if a.LiveProviderSessionID != nil { + var discoverErr error + providerID, discoverErr = a.LiveProviderSessionID(info.Name) + if discoverErr != nil { + log.Debug("provider identity discovery failed", "session", info.Name, "error", discoverErr) + providerID = "" + } + } + out = append(out, Session{ID: id, AgentType: a.Name(), DisplayName: id, Cwd: info.Cwd, SessionName: info.Name, ProviderSessionID: providerID, State: state, ProcAlive: alive, LastChange: time.Now(), CreatedAt: created, PR: prRef}) } a.prunePRScan(seen) return out, nil diff --git a/internal/adapter/agent_test.go b/internal/adapter/agent_test.go index 97e2481..f72227b 100644 --- a/internal/adapter/agent_test.go +++ b/internal/adapter/agent_test.go @@ -151,6 +151,65 @@ func TestAgentResumeUsesPersistedMetadata(t *testing.T) { } } +func TestPrepareLaunchRunsAfterIdentityResolutionAndMergesWithoutMutation(t *testing.T) { + ag, be := newLifecycleAgent(t) + extra := []string{"--prepared"} + preparedEnv := map[string]string{"EXTRA": "value", "UAM_AGENT": "override"} + ag.PrepareLaunch = func(_ Context, req ResumeRequest, activity, sessionName, cwd string) (LaunchPreparation, error) { + if activity != "resumed" || sessionName != "uam-fake-abc12345" || cwd != "/tmp" || req.ID == "" { + t.Fatalf("preparation inputs: activity=%q name=%q cwd=%q req=%+v", activity, sessionName, cwd, req) + } + return LaunchPreparation{ExtraArgs: extra, Env: preparedEnv, ProviderSessionID: "provider-live"}, nil + } + ag.SessionArgs = func(ResumeRequest, string) []string { return []string{"--legacy"} } + sess, err := ag.Resume(context.Background(), ResumeRequest{ID: "abc12345", Cwd: "/tmp", Mode: "safe", SessionName: "uam-fake-abc12345"}) + if err != nil { + t.Fatal(err) + } + create := be.CallsOf("create")[0] + if got := strings.Join(create.Command, " "); !strings.HasSuffix(got, "--prepared --legacy") { + t.Fatalf("hook precedence argv=%q", got) + } + if create.Env["EXTRA"] != "value" || create.Env["UAM_AGENT"] != "fake" || create.Env["UAM_ID"] != "abc12345" { + t.Fatalf("merged env=%v", create.Env) + } + if sess.ProviderSessionID != "provider-live" { + t.Fatalf("provider id=%q", sess.ProviderSessionID) + } + extra[0], preparedEnv["EXTRA"] = "mutated", "mutated" + if create.Command[len(create.Command)-2] != "--prepared" || create.Env["EXTRA"] != "value" { + t.Fatalf("backend inputs alias hook-owned data: %+v", create) + } +} + +func TestPrepareLaunchFailureCreatesNoSession(t *testing.T) { + ag, be := newLifecycleAgent(t) + ag.PrepareLaunch = func(Context, ResumeRequest, string, string, string) (LaunchPreparation, error) { + return LaunchPreparation{}, errors.New("prepare exploded") + } + _, err := ag.Dispatch(context.Background(), DispatchRequest{Cwd: "/tmp"}) + if err == nil || !strings.Contains(err.Error(), "prepare exploded") { + t.Fatalf("Dispatch error=%v", err) + } + if got := be.CallsOf("create"); len(got) != 0 { + t.Fatalf("preparation failure created session: %+v", got) + } +} + +func TestListFromSnapshotUsesLiveProviderIdentity(t *testing.T) { + ag, _ := newLifecycleAgent(t) + ag.LiveProviderSessionID = func(name string) (string, error) { + if name != "uam-fake-abc12345" { + t.Fatalf("name=%q", name) + } + return "ses_live", nil + } + got, err := ag.ListFromSnapshot(context.Background(), []session.Info{{Name: "uam-fake-abc12345", Cwd: "/tmp", Alive: true}}) + if err != nil || len(got) != 1 || got[0].ProviderSessionID != "ses_live" { + t.Fatalf("ListFromSnapshot=%+v, %v", got, err) + } +} + // An alias resolvable via LookPath replaces the default command argv[0] with // the alias's absolute path; mode args still apply. func TestAgentCommandAliasOnPathReplacesDefaultCommand(t *testing.T) { diff --git a/internal/adapter/codex/codex.go b/internal/adapter/codex/codex.go index d424d4c..759ed02 100644 --- a/internal/adapter/codex/codex.go +++ b/internal/adapter/codex/codex.go @@ -19,6 +19,7 @@ func sessionArgs(_ adapter.ResumeRequest, activity string) []string { func New(backend adapter.Backend) adapter.AgentAdapter { a := adapter.NewAgent("codex", "OpenAI Codex", []adapter.CommandCandidate{{Display: "codex", Args: []string{"codex"}}}, []string{"--sandbox", "danger-full-access"}, backend) a.SessionArgs = sessionArgs + a.ResumeKindFor = func(adapter.ResumeRequest) adapter.ResumeKind { return adapter.ResumeHeuristic } a.SkipPromptOnResume = true return a } diff --git a/internal/adapter/codex/codex_test.go b/internal/adapter/codex/codex_test.go index d75dd45..be62908 100644 --- a/internal/adapter/codex/codex_test.go +++ b/internal/adapter/codex/codex_test.go @@ -47,6 +47,13 @@ func TestNewWiresSessionArgs(t *testing.T) { } } +func TestResumeKindRemainsHeuristicEvenWithUnrelatedStoredIdentity(t *testing.T) { + ag := New(nil).(adapter.ResumeKindAdapter) + if got := ag.ResumeKind(adapter.ResumeRequest{ProviderSessionID: "legacy-value"}); got != adapter.ResumeHeuristic { + t.Fatalf("ResumeKind=%q, want heuristic", got) + } +} + // TestResumeAppendsResumeLastAndDoesNotReplayPrompt: resuming an Exited codex // row must use codex's `resume --last` and must NOT replay the original prompt // into the restored session, nor pass the uam UUID. diff --git a/internal/adapter/copilot/copilot.go b/internal/adapter/copilot/copilot.go index 1c9aaa2..d3fea0f 100644 --- a/internal/adapter/copilot/copilot.go +++ b/internal/adapter/copilot/copilot.go @@ -26,6 +26,7 @@ func New(backend adapter.Backend) adapter.AgentAdapter { } return req.ProviderSessionID } + agent.ResumeKindFor = func(adapter.ResumeRequest) adapter.ResumeKind { return adapter.ResumeExact } agent.SkipPromptOnResume = true return agent } diff --git a/internal/adapter/copilot/copilot_test.go b/internal/adapter/copilot/copilot_test.go index 9dac94f..89dfca7 100644 --- a/internal/adapter/copilot/copilot_test.go +++ b/internal/adapter/copilot/copilot_test.go @@ -18,6 +18,13 @@ func TestNew(t *testing.T) { } } +func TestResumeKindIsExactForLegacyRecordsWithoutProviderID(t *testing.T) { + a := New(nil).(adapter.ResumeKindAdapter) + if got := a.ResumeKind(adapter.ResumeRequest{ID: "abc12345"}); got != adapter.ResumeExact { + t.Fatalf("ResumeKind=%q, want exact", got) + } +} + func TestAvailableRequiresCopilotBinary(t *testing.T) { t.Setenv("PATH", t.TempDir()) a := New(nil) diff --git a/internal/adapter/omp/omp.go b/internal/adapter/omp/omp.go index 688d570..2390442 100644 --- a/internal/adapter/omp/omp.go +++ b/internal/adapter/omp/omp.go @@ -1,7 +1,14 @@ package omp import ( + "fmt" + "os" + "path/filepath" + "regexp" + "syscall" + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" + "github.com/RandomCodeSpace/unified-agent-manager/internal/providerstate" ) // omp (Oh My Pi, github.com/can1357/oh-my-pi) launches bare: a plain `omp` @@ -14,11 +21,10 @@ import ( // env var. var yoloArgs = []string{"--auto-approve"} -// sessionArgs appends omp's `-c`/`--continue` flag on resume so picking -// "Resume" on an Exited omp row continues its most recent session instead of -// launching a fresh one (same approach as opencode). omp has no flag to preset -// its session ID at dispatch, so uam can't resume by uam-id directly; `-c` -// continues omp's last session. The uam UUID is never passed. +// sessionArgs appends omp's `-c`/`--continue` flag on resume. New managed +// sessions also use a deterministic per-UAM-ID session directory, making that +// continue exact; legacy records without an existing derived directory keep +// the historical bare `-c` behavior. func sessionArgs(_ adapter.ResumeRequest, activity string) []string { if activity == "resumed" { return []string{"-c"} @@ -29,6 +35,162 @@ func sessionArgs(_ adapter.ResumeRequest, activity string) []string { func New(backend adapter.Backend) adapter.AgentAdapter { a := adapter.NewAgent("omp", "Oh My Pi", []adapter.CommandCandidate{{Display: "omp", Args: []string{"omp"}}}, yoloArgs, backend) a.SessionArgs = sessionArgs + a.PrepareLaunch = prepareLaunch + a.ResumeKindFor = resumeKind a.SkipPromptOnResume = true return a } + +var safeID = regexp.MustCompile(`^[0-9a-f][0-9a-f-]{7,63}$`) + +func stateRoot() (string, error) { + base := os.Getenv("XDG_STATE_HOME") + if base == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + base = filepath.Join(home, ".local", "state") + } + return filepath.Join(base, "uam", "providers", "omp"), nil +} + +func verifyOwnerOnlyDir(path string) error { + info, err := os.Lstat(path) + if err != nil { + return err + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("provider state %s is not a real directory", path) + } + if info.Mode().Perm() != 0o700 { + return fmt.Errorf("provider state %s has unsafe mode %04o", path, info.Mode().Perm()) + } + if st, ok := info.Sys().(*syscall.Stat_t); ok && int(st.Uid) != os.Getuid() { + return fmt.Errorf("provider state %s is not owner-controlled", path) + } + return nil +} + +func sessionDir(id string) (string, error) { + if !safeID.MatchString(id) { + return "", fmt.Errorf("unsafe UAM session id %q", id) + } + root, err := stateRoot() + if err != nil { + return "", err + } + return filepath.Join(root, id), nil +} + +func ensureSessionDir(id string) (string, error) { + dir, err := sessionDir(id) + if err != nil { + return "", err + } + root := filepath.Dir(dir) + base := filepath.Dir(filepath.Dir(filepath.Dir(root))) + if err := providerstate.EnsureTrustedBase(base); err != nil { + return "", err + } + if err := ensureDirChain(base, "uam", "providers", "omp"); err != nil { + return "", err + } + if err := os.Mkdir(dir, 0o700); err != nil && !os.IsExist(err) { + return "", err + } + if err := verifyOwnerOnlyDir(dir); err != nil { + return "", err + } + return dir, nil +} + +func ensureDirChain(base string, components ...string) error { + path := base + for _, component := range components { + path = filepath.Join(path, component) + if err := os.Mkdir(path, 0o700); err != nil && !os.IsExist(err) { + return err + } + if err := verifyOwnerOnlyDir(path); err != nil { + return err + } + } + return nil +} + +func verifyDirChain(base string, components ...string) error { + path := base + for _, component := range components { + path = filepath.Join(path, component) + if err := verifyOwnerOnlyDir(path); err != nil { + return err + } + } + return nil +} + +func inspectSessionDir(id string) (string, bool, error) { + dir, err := sessionDir(id) + if err != nil { + return "", false, err + } + root := filepath.Dir(dir) + base := filepath.Dir(filepath.Dir(filepath.Dir(root))) + if err := providerstate.VerifyTrustedBase(base); err != nil { + if os.IsNotExist(err) { + return "", false, nil + } + return "", false, err + } + if err := verifyDirChain(base, "uam", "providers", "omp"); err != nil { + if os.IsNotExist(err) { + return "", false, nil + } + return "", false, err + } + _, err = os.Lstat(dir) + if os.IsNotExist(err) { + return "", false, nil + } + if err != nil { + return "", false, err + } + if err := verifyOwnerOnlyDir(dir); err != nil { + return "", false, err + } + return dir, true, nil +} + +func existingSessionDir(id string) (string, bool) { + dir, exists, err := inspectSessionDir(id) + if err != nil { + return "", false + } + return dir, exists +} + +func prepareLaunch(_ adapter.Context, req adapter.ResumeRequest, activity, _, _ string) (adapter.LaunchPreparation, error) { + if activity == "dispatched" { + dir, err := ensureSessionDir(req.ID) + if err != nil { + return adapter.LaunchPreparation{}, err + } + return adapter.LaunchPreparation{ExtraArgs: []string{"--session-dir", dir}}, nil + } + dir, exists, err := inspectSessionDir(req.ID) + if err != nil { + return adapter.LaunchPreparation{}, err + } + if exists { + return adapter.LaunchPreparation{ExtraArgs: []string{"--session-dir", dir}}, nil + } + return adapter.LaunchPreparation{}, nil +} + +func resumeKind(req adapter.ResumeRequest) adapter.ResumeKind { + if _, ok := existingSessionDir(req.ID); ok { + return adapter.ResumeExact + } + return adapter.ResumeHeuristic +} diff --git a/internal/adapter/omp/omp_test.go b/internal/adapter/omp/omp_test.go index 3512661..7fb9d71 100644 --- a/internal/adapter/omp/omp_test.go +++ b/internal/adapter/omp/omp_test.go @@ -1,10 +1,15 @@ package omp import ( + "context" + "os" + "path/filepath" "reflect" + "strings" "testing" "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter/adaptertest" ) func TestNew(t *testing.T) { @@ -14,6 +19,144 @@ func TestNew(t *testing.T) { } } +func newTestAgent(t *testing.T) (*adapter.Agent, *adaptertest.Backend) { + t.Helper() + bin := t.TempDir() + path := filepath.Join(bin, "omp") + if err := os.WriteFile(path, []byte("#!/bin/sh\nexit 0\n"), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("XDG_STATE_HOME", t.TempDir()) + be := &adaptertest.Backend{} + return New(be).(*adapter.Agent), be +} + +func TestManagedSessionsUseDistinctIsolatedDirectories(t *testing.T) { + ag, be := newTestAgent(t) + for range 2 { + if _, err := ag.Dispatch(context.Background(), adapter.DispatchRequest{Cwd: "/tmp", Mode: "safe"}); err != nil { + t.Fatal(err) + } + } + creates := be.CallsOf("create") + if len(creates) != 2 { + t.Fatalf("creates=%d", len(creates)) + } + first, second := strings.Join(creates[0].Command, " "), strings.Join(creates[1].Command, " ") + if !strings.Contains(first, "--session-dir") || !strings.Contains(second, "--session-dir") || first == second { + t.Fatalf("isolated argv: %q / %q", first, second) + } +} + +func TestResumeKindUsesExistingSafeDerivedDirectory(t *testing.T) { + ag, be := newTestAgent(t) + id := "aaaaaaaa-dead-beef-cafe-0123456789ab" + dir, err := ensureSessionDir(id) + if err != nil { + t.Fatal(err) + } + if got := ag.ResumeKind(adapter.ResumeRequest{ID: id}); got != adapter.ResumeExact { + t.Fatalf("kind=%q dir=%q", got, dir) + } + if _, err := ag.Resume(context.Background(), adapter.ResumeRequest{ID: id, Cwd: "/tmp", Mode: "safe", SessionName: "uam-omp-aaaaaaaa"}); err != nil { + t.Fatal(err) + } + argv := strings.Join(be.CallsOf("create")[0].Command, " ") + if !strings.Contains(argv, "--session-dir "+dir+" -c") { + t.Fatalf("argv=%q", argv) + } +} + +func TestLegacyResumeWithoutDerivedDirectoryKeepsBareContinue(t *testing.T) { + ag, be := newTestAgent(t) + id := "aaaaaaaa-dead-beef-cafe-0123456789ab" + if got := ag.ResumeKind(adapter.ResumeRequest{ID: id}); got != adapter.ResumeHeuristic { + t.Fatalf("kind=%q", got) + } + if _, err := ag.Resume(context.Background(), adapter.ResumeRequest{ID: id, Cwd: "/tmp", Mode: "safe", SessionName: "uam-omp-aaaaaaaa"}); err != nil { + t.Fatal(err) + } + argv := strings.Join(be.CallsOf("create")[0].Command, " ") + if strings.Contains(argv, "--session-dir") || !strings.HasSuffix(argv, " -c") { + t.Fatalf("argv=%q", argv) + } +} + +func TestResumeRejectsUnsafeExistingDerivedDirectory(t *testing.T) { + ag, be := newTestAgent(t) + id := "aaaaaaaa-dead-beef-cafe-0123456789ab" + dir, err := sessionDir(id) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Chmod(dir, 0o755); err != nil { + t.Fatal(err) + } + _, err = ag.Resume(context.Background(), adapter.ResumeRequest{ID: id, Cwd: "/tmp", Mode: "safe", SessionName: "uam-omp-aaaaaaaa"}) + if err == nil { + t.Fatal("unsafe derived directory accepted") + } + if len(be.CallsOf("create")) != 0 { + t.Fatal("unsafe directory created backend session") + } +} + +func TestResumeRejectsSymlinkDerivedDirectory(t *testing.T) { + ag, be := newTestAgent(t) + id := "aaaaaaaa-dead-beef-cafe-0123456789ab" + dir, err := sessionDir(id) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(dir), 0o700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(t.TempDir(), dir); err != nil { + t.Fatal(err) + } + _, err = ag.Resume(context.Background(), adapter.ResumeRequest{ID: id, Cwd: "/tmp", Mode: "safe", SessionName: "uam-omp-aaaaaaaa"}) + if err == nil { + t.Fatal("symlink derived directory accepted") + } + if len(be.CallsOf("create")) != 0 { + t.Fatal("symlink directory created backend session") + } +} + +func TestDispatchRejectsIntermediateStateSymlink(t *testing.T) { + ag, be := newTestAgent(t) + base := os.Getenv("XDG_STATE_HOME") + if err := os.Symlink(t.TempDir(), filepath.Join(base, "uam")); err != nil { + t.Fatal(err) + } + _, err := ag.Dispatch(context.Background(), adapter.DispatchRequest{Cwd: "/tmp", Mode: "safe"}) + if err == nil { + t.Fatal("intermediate provider-state symlink accepted") + } + if len(be.CallsOf("create")) != 0 { + t.Fatal("unsafe state path created backend session") + } +} + +func TestDispatchRejectsHostileStateBase(t *testing.T) { + ag, be := newTestAgent(t) + base := os.Getenv("XDG_STATE_HOME") + if err := os.Chmod(base, 0o772); err != nil { + t.Fatal(err) + } + _, err := ag.Dispatch(context.Background(), adapter.DispatchRequest{Cwd: "/tmp", Mode: "safe"}) + if err == nil { + t.Fatal("group/other-writable XDG_STATE_HOME accepted") + } + if len(be.CallsOf("create")) != 0 { + t.Fatal("hostile state base created backend session") + } +} + // omp's interactive TUI is launched with the bare `omp` command (no // subcommand); the auto-approve flag rides in YoloArgs, not the candidate. func TestNewUsesBareOmpCommand(t *testing.T) { diff --git a/internal/adapter/opencode/opencode.go b/internal/adapter/opencode/opencode.go index 5e662b7..e692aea 100644 --- a/internal/adapter/opencode/opencode.go +++ b/internal/adapter/opencode/opencode.go @@ -1,29 +1,37 @@ package opencode import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/url" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "sync" + "syscall" + "time" + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" + "github.com/RandomCodeSpace/unified-agent-manager/internal/providerstate" + "github.com/RandomCodeSpace/unified-agent-manager/internal/session" + "github.com/RandomCodeSpace/unified-agent-manager/internal/store" + "golang.org/x/sys/unix" ) -// opencode has no CLI flag for auto-approval / yolo: permission -// policy is read from ~/.config/opencode/config.json (or set via the -// TUI's /permission flow). Passing an unrecognised flag here (e.g. -// --auto-approve) causes yargs to print the help banner and exit 0 -// instead of entering the default TUI command, which makes the pane -// die immediately and drop the user back to the uam session list. -// Therefore opencode is launched with no yolo args. +// OpenCode's --auto support is version-dependent, so it is appended by the +// preparation hook only after probing the resolved executable. Static yolo +// args stay empty to keep older versions safe. var yoloArgs []string // sessionArgs appends opencode's `-c` (continue) flag on resume. -// opencode has no flag for presetting its session ID at dispatch, -// so uam can't resume by uam-id directly; `-c` instead picks -// opencode's most recent session for the current cwd. If multiple -// opencode rows share a cwd, all of them resume to the same -// most-recent session — a limitation of opencode's CLI surface, not -// of this wiring. // sessionArgs picks opencode's resume flags. opencode supports exact resume -// (`--session ses_...`) but cannot preset the id at launch, so uam only knows -// it when a provider session id was recorded some other way; otherwise resume -// falls back to `-c` (continue the project's last session). +// (`--session ses_...`). The event plugin learns that id after launch; +// records without a learned identity retain the guarded `-c` fallback. func sessionArgs(req adapter.ResumeRequest, activity string) []string { if activity == "resumed" { if req.ProviderSessionID != "" { @@ -37,6 +45,315 @@ func sessionArgs(req adapter.ResumeRequest, activity string) []string { func New(backend adapter.Backend) adapter.AgentAdapter { a := adapter.NewAgent("opencode", "OpenCode", []adapter.CommandCandidate{{Display: "opencode", Args: []string{"opencode"}}}, yoloArgs, backend) a.SessionArgs = sessionArgs + a.PrepareLaunch = prepareLaunch + a.LiveProviderSessionID = liveProviderSessionID a.SkipPromptOnResume = true return a } + +const maxIdentityBytes = 1024 + +var providerIDRE = regexp.MustCompile(`^ses_[A-Za-z0-9_-]{3,60}$`) +var autoTokenRE = regexp.MustCompile(`(^|[[:space:],\[])--auto([=[:space:],\]]|$)`) + +type executableIdentity struct { + path string + size, mtime int64 + device, inode uint64 +} + +var autoCache = struct { + sync.Mutex + values map[executableIdentity]bool +}{values: map[executableIdentity]bool{}} + +func resetAutoCacheForTest() { + autoCache.Lock() + defer autoCache.Unlock() + autoCache.values = map[executableIdentity]bool{} +} + +func prepareLaunch(ctx adapter.Context, req adapter.ResumeRequest, _, sessionName, _ string) (adapter.LaunchPreparation, error) { + pluginPath, err := ensureProviderFiles() + if err != nil { + return adapter.LaunchPreparation{}, err + } + handoff, err := identityPath(sessionName) + if err != nil { + return adapter.LaunchPreparation{}, err + } + config, err := mergeConfigContent(os.Getenv("OPENCODE_CONFIG_CONTENT"), (&url.URL{Scheme: "file", Path: pluginPath}).String()) + if err != nil { + return adapter.LaunchPreparation{}, fmt.Errorf("merge OPENCODE_CONFIG_CONTENT: %w", err) + } + prep := adapter.LaunchPreparation{Env: map[string]string{ + "OPENCODE_CONFIG_CONTENT": config, + "UAM_OPENCODE_IDENTITY_FILE": handoff, + session.ProviderIdentityFileEnv: handoff, + }} + if req.Mode != "safe" { + if req.ExecutablePath != "" && supportsAuto(ctx, req.ExecutablePath) { + prep.ExtraArgs = []string{"--auto"} + } + } + return prep, nil +} + +func supportsAuto(ctx context.Context, path string) bool { + info, err := os.Stat(path) + if err != nil || !info.Mode().IsRegular() { + return false + } + key := executableIdentity{path: path, size: info.Size(), mtime: info.ModTime().UnixNano()} + if st, ok := info.Sys().(*syscall.Stat_t); ok { + key.device, key.inode = uint64(st.Dev), st.Ino + } + autoCache.Lock() + defer autoCache.Unlock() + if value, ok := autoCache.values[key]; ok { + return value + } + probeCtx, cancel := context.WithTimeout(ctx, 750*time.Millisecond) + defer cancel() + out, err := exec.CommandContext(probeCtx, path, "--help").CombinedOutput() // #nosec G204 -- LookPath-resolved executable, invoked without a shell. + supported := err == nil && autoTokenRE.Match(out) + autoCache.values[key] = supported + return supported +} + +func stateRoot() (string, error) { + base := os.Getenv("XDG_STATE_HOME") + if base == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + base = filepath.Join(home, ".local", "state") + } + return filepath.Join(base, "uam", "providers", "opencode"), nil +} + +func verifyOwner(path string, wantDir bool, mode os.FileMode) error { + info, err := os.Lstat(path) + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 || (wantDir && !info.IsDir()) || (!wantDir && !info.Mode().IsRegular()) { + return fmt.Errorf("provider state %s has unsafe file type", path) + } + if info.Mode().Perm() != mode { + return fmt.Errorf("provider state %s has unsafe mode %04o; want %04o", path, info.Mode().Perm(), mode) + } + if st, ok := info.Sys().(*syscall.Stat_t); ok && int(st.Uid) != os.Getuid() { + return fmt.Errorf("provider state %s is not owned by the current user", path) + } + return nil +} + +func ensureProviderFiles() (string, error) { + root, err := stateRoot() + if err != nil { + return "", err + } + base := filepath.Dir(filepath.Dir(filepath.Dir(root))) + if err := providerstate.EnsureTrustedBase(base); err != nil { + return "", err + } + if err := ensureDirChain(base, "uam", "providers", "opencode"); err != nil { + return "", err + } + identityDir := filepath.Join(root, "identity") + if err := os.Mkdir(identityDir, 0o700); err != nil && !os.IsExist(err) { + return "", err + } + if err := verifyOwner(identityDir, true, 0o700); err != nil { + return "", err + } + pluginPath := filepath.Join(root, "uam-identity-plugin.mjs") + if _, err := os.Lstat(pluginPath); err == nil { + if err := verifyOwner(pluginPath, false, 0o600); err != nil { + return "", err + } + } else if !os.IsNotExist(err) { + return "", err + } + tmpFile, err := os.CreateTemp(root, ".uam-identity-plugin-*") + if err != nil { + return "", err + } + tmp := tmpFile.Name() + defer func() { _ = os.Remove(tmp) }() + if err := tmpFile.Chmod(0o600); err != nil { + _ = tmpFile.Close() + return "", err + } + if _, err := tmpFile.WriteString(pluginSource); err != nil { + _ = tmpFile.Close() + return "", err + } + if err := tmpFile.Close(); err != nil { + return "", err + } + if err := os.Rename(tmp, pluginPath); err != nil { + return "", err + } + if err := verifyOwner(pluginPath, false, 0o600); err != nil { + return "", err + } + return pluginPath, nil +} + +func ensureDirChain(base string, components ...string) error { + path := base + for _, component := range components { + path = filepath.Join(path, component) + if err := os.Mkdir(path, 0o700); err != nil && !os.IsExist(err) { + return err + } + if err := verifyOwner(path, true, 0o700); err != nil { + return err + } + } + return nil +} + +func identityPath(sessionName string) (string, error) { + if err := session.ValidateName(sessionName); err != nil { + return "", err + } + root, err := stateRoot() + if err != nil { + return "", err + } + return filepath.Join(root, "identity", sessionName+".json"), nil +} + +func liveProviderSessionID(sessionName string) (string, error) { + root, err := stateRoot() + if err != nil { + return "", err + } + base := filepath.Dir(filepath.Dir(filepath.Dir(root))) + if err := providerstate.VerifyTrustedBase(base); err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", err + } + path := base + for _, component := range []string{"uam", "providers", "opencode", "identity"} { + path = filepath.Join(path, component) + if err := verifyOwner(path, true, 0o700); err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", err + } + } + path, err = identityPath(sessionName) + if err != nil { + return "", err + } + id, err := readIdentity(path) + if errors.Is(err, os.ErrNotExist) { + return "", nil + } + return id, err +} + +func readIdentity(path string) (string, error) { + fd, err := unix.Open(path, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) // #nosec G304 -- canonical-session-derived path, opened no-follow. + if err != nil { + return "", err + } + file := os.NewFile(uintptr(fd), path) + defer func() { _ = file.Close() }() + var st unix.Stat_t + if err := unix.Fstat(fd, &st); err != nil { + return "", err + } + if st.Mode&unix.S_IFMT != unix.S_IFREG || os.FileMode(st.Mode).Perm() != 0o600 || int(st.Uid) != os.Getuid() { + return "", fmt.Errorf("identity handoff has unsafe owner, mode, or type") + } + data, err := io.ReadAll(io.LimitReader(file, maxIdentityBytes+1)) + if err != nil { + return "", err + } + if len(data) > maxIdentityBytes { + return "", fmt.Errorf("identity handoff exceeds %d bytes", maxIdentityBytes) + } + var payload struct { + ProviderSessionID string `json:"provider_session_id"` + } + if err := json.Unmarshal(data, &payload); err != nil { + return "", fmt.Errorf("parse identity handoff: %w", err) + } + if !providerIDRE.MatchString(payload.ProviderSessionID) || !store.ValidProviderSessionID(payload.ProviderSessionID) { + return "", fmt.Errorf("invalid OpenCode provider session id") + } + return payload.ProviderSessionID, nil +} + +func mergeConfigContent(inline, pluginURL string) (string, error) { + cfg := map[string]any{} + if strings.TrimSpace(inline) != "" { + if err := json.Unmarshal([]byte(inline), &cfg); err != nil { + return "", err + } + if cfg == nil { + return "", fmt.Errorf("inline config must be a JSON object") + } + } + plugins := []any{} + if existing, ok := cfg["plugin"]; ok { + var compatible bool + plugins, compatible = existing.([]any) + if !compatible { + return "", fmt.Errorf("plugin must be an array") + } + } + for _, existing := range plugins { + if value, ok := existing.(string); ok && value == pluginURL { + encoded, err := json.Marshal(cfg) + return string(encoded), err + } + } + cfg["plugin"] = append(plugins, pluginURL) + encoded, err := json.Marshal(cfg) + return string(encoded), err +} + +const pluginSource = `import { promises as fs } from "node:fs"; +import { randomUUID } from "node:crypto"; +let rootID = ""; +const target = process.env.UAM_OPENCODE_IDENTITY_FILE; +async function record(id) { + if (!target || typeof id !== "string" || !/^ses_[A-Za-z0-9_-]{3,60}$/.test(id)) return; + const tmp = target + ".tmp-" + process.pid + "-" + randomUUID(); + try { + await fs.writeFile(tmp, JSON.stringify({provider_session_id:id}), {mode:0o600}); + await fs.chmod(tmp, 0o600); + await fs.rename(tmp, target); + } catch (_) { + // Identity discovery is best-effort and must never reject provider events. + } finally { + await fs.rm(tmp, {force:true}).catch(() => {}); + } +} +export const UAMIdentityPlugin = async () => ({ + event: async ({event}) => { + const type = event?.type || ""; + const info = event?.properties?.info || event?.properties?.session || event?.info || {}; + const id = info.id || ""; + const activityID = event?.properties?.sessionID || event?.properties?.sessionId || info.sessionID || info.sessionId || ""; + const parent = info.parentID || info.parentId || info.parent_id || ""; + if ((type === "session.created" || type === "session.updated") && id && !parent) { + rootID = id; + await record(rootID); + return; + } + const activity = type === "session.idle" || type === "session.status" || type === "message.updated" || type === "command.executed"; + if (activity && activityID && activityID === rootID) await record(rootID); + } +}); +` diff --git a/internal/adapter/opencode/opencode_test.go b/internal/adapter/opencode/opencode_test.go index 03e5a1e..4353655 100644 --- a/internal/adapter/opencode/opencode_test.go +++ b/internal/adapter/opencode/opencode_test.go @@ -1,10 +1,21 @@ package opencode import ( + "context" + "encoding/json" + "net/url" + "os" + "os/exec" + "path/filepath" "reflect" + "strconv" + "strings" "testing" "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter/adaptertest" + "github.com/RandomCodeSpace/unified-agent-manager/internal/session" + "github.com/RandomCodeSpace/unified-agent-manager/internal/store" ) func TestNew(t *testing.T) { @@ -14,11 +25,331 @@ func TestNew(t *testing.T) { } } -// TestNoYoloArgs locks in that opencode is launched with no -// auto-approve / yolo flag. opencode's CLI does not expose one; -// passing an unrecognised flag like --auto-approve makes opencode -// print help and exit 0 instead of entering the TUI, killing the -// pane on dispatch. Regression guard. +func testOpenCodeAgent(t *testing.T, help string) (*adapter.Agent, *adaptertest.Backend) { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "opencode") + script := "#!/bin/sh\nif [ \"$1\" = --help ]; then printf '%s\\n' '" + help + "'; fi\n" + if err := os.WriteFile(path, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("XDG_STATE_HOME", t.TempDir()) + t.Setenv("OPENCODE_CONFIG_CONTENT", "") + resetAutoCacheForTest() + be := &adaptertest.Backend{} + return New(be).(*adapter.Agent), be +} + +func TestAutoFlagIsVersionAwareAndSafeModeNeverUsesIt(t *testing.T) { + for _, tt := range []struct { + name, help, mode string + want bool + }{ + {"current yolo", "Usage: opencode [--auto]", "yolo", true}, + {"old yolo", "Usage: opencode [--continue]", "yolo", false}, + {"current safe", "Usage: opencode [--auto]", "safe", false}, + } { + t.Run(tt.name, func(t *testing.T) { + ag, be := testOpenCodeAgent(t, tt.help) + if _, err := ag.Dispatch(context.Background(), adapter.DispatchRequest{Cwd: "/tmp", Mode: tt.mode}); err != nil { + t.Fatal(err) + } + got := strings.Contains(strings.Join(be.CallsOf("create")[0].Command, " "), "--auto") + if got != tt.want { + t.Fatalf("auto=%v want=%v argv=%v", got, tt.want, be.CallsOf("create")[0].Command) + } + }) + } +} + +func TestAutoProbeCachesByExecutableStatIdentity(t *testing.T) { + dir := t.TempDir() + path, count := filepath.Join(dir, "opencode"), filepath.Join(dir, "count") + t.Setenv("COUNT", count) + write := func(help string) { + t.Helper() + script := "#!/bin/sh\nprintf x >> \"$COUNT\"\nprintf '%s\\n' '" + help + "'\n" + if err := os.WriteFile(path, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + } + resetAutoCacheForTest() + write("Usage [--auto]") + if !supportsAuto(context.Background(), path) { + t.Fatal("current executable not detected") + } + if !supportsAuto(context.Background(), path) { + t.Fatal("cached executable support not detected") + } + data, _ := os.ReadFile(count) + if string(data) != "x" { + t.Fatalf("probe count=%q", data) + } + write("Usage [--continue] changed-size") + if supportsAuto(context.Background(), path) { + t.Fatal("changed old executable inherited cached support") + } + data, _ = os.ReadFile(count) + if string(data) != "xx" { + t.Fatalf("re-probe count=%q", data) + } +} + +func TestInvalidCommandAliasCannotExecuteDuringPreparation(t *testing.T) { + marker := filepath.Join(t.TempDir(), "executed") + alias := filepath.Join(t.TempDir(), "not-an-alias") + if err := os.WriteFile(alias, []byte("#!/bin/sh\nprintf ran > "+marker+"\n"), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("XDG_STATE_HOME", t.TempDir()) + ag := New(&adaptertest.Backend{}).(*adapter.Agent) + _, err := ag.Dispatch(context.Background(), adapter.DispatchRequest{CommandAlias: alias, Cwd: "/tmp", Mode: "yolo"}) + if err == nil { + t.Fatal("invalid path alias accepted") + } + if _, statErr := os.Stat(marker); !os.IsNotExist(statErr) { + t.Fatalf("invalid alias executed during preparation: %v", statErr) + } +} + +func TestMergeInlineConfigPreservesKeysAndPlugins(t *testing.T) { + got, err := mergeConfigContent(`{"model":"x","nested":{"keep":true},"plugin":["file:///user.js"]}`, "file:///uam.js") + if err != nil { + t.Fatal(err) + } + var cfg map[string]any + if err := json.Unmarshal([]byte(got), &cfg); err != nil { + t.Fatal(err) + } + plugins := cfg["plugin"].([]any) + if cfg["model"] != "x" || len(plugins) != 2 || plugins[0] != "file:///user.js" || plugins[1] != "file:///uam.js" { + t.Fatalf("config=%v", cfg) + } + if _, err := mergeConfigContent(`{"plugin":"wrong"}`, "file:///uam.js"); err == nil { + t.Fatal("incompatible plugin shape accepted") + } + if _, err := mergeConfigContent(`{broken`, "file:///uam.js"); err == nil { + t.Fatal("malformed config accepted") + } +} + +func TestIdentityHandoffRejectsSymlinksModesBoundsAndMalformedIDs(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "identity.json") + write := func(value string, mode os.FileMode) { + t.Helper() + if err := os.WriteFile(path, []byte(value), mode); err != nil { + t.Fatal(err) + } + } + write(`{"provider_session_id":"ses_abc123"}`, 0o600) + if got, err := readIdentity(path); err != nil || got != "ses_abc123" { + t.Fatalf("got=%q err=%v", got, err) + } + if err := os.Chmod(path, 0o644); err != nil { + t.Fatal(err) + } + if _, err := readIdentity(path); err == nil { + t.Fatal("unsafe mode accepted") + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + write(`{"provider_session_id":"bad"}`, 0o600) + if _, err := readIdentity(path); err == nil { + t.Fatal("malformed id accepted") + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + write(strings.Repeat("x", maxIdentityBytes+1), 0o600) + if _, err := readIdentity(path); err == nil { + t.Fatal("oversized handoff accepted") + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + target := filepath.Join(dir, "target") + if err := os.WriteFile(target, []byte(`{"provider_session_id":"ses_ok"}`), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, path); err != nil { + t.Fatal(err) + } + if _, err := readIdentity(path); err == nil { + t.Fatal("symlink accepted") + } +} + +func TestIdentityBoundaryMatchesSchemaV3AndRoundTrips(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "identity.json") + accepted := "ses_" + strings.Repeat("a", 60) + if len(accepted) != 64 { + t.Fatal("bad test boundary") + } + if err := os.WriteFile(path, []byte(`{"provider_session_id":"`+accepted+`"}`), 0o600); err != nil { + t.Fatal(err) + } + id, err := readIdentity(path) + if err != nil || id != accepted { + t.Fatalf("accepted boundary id=%q err=%v", id, err) + } + st, err := store.Open(filepath.Join(t.TempDir(), "sessions.json")) + if err != nil { + t.Fatal(err) + } + if err := st.Update(func(cfg *store.Config) error { + cfg.Sessions["opencode:abc12345"] = store.SessionRecord{ID: "abc12345", Agent: "opencode", SessionName: "uam-opencode-abc12345", Workdir: "/tmp", ProviderSessionID: id} + return nil + }); err != nil { + t.Fatal(err) + } + cfg, err := st.Load() + if err != nil { + t.Fatal(err) + } + if cfg.Sessions["opencode:abc12345"].ProviderSessionID != accepted { + t.Fatalf("boundary identity did not survive reload: %+v", cfg.Sessions) + } + overlong := "ses_" + strings.Repeat("a", 61) + if err := os.WriteFile(path, []byte(`{"provider_session_id":"`+overlong+`"}`), 0o600); err != nil { + t.Fatal(err) + } + if _, err := readIdentity(path); err == nil { + t.Fatal("65-byte provider identity accepted") + } +} + +func TestProviderFilesRejectIntermediateSymlink(t *testing.T) { + base := t.TempDir() + t.Setenv("XDG_STATE_HOME", base) + if err := os.Symlink(t.TempDir(), filepath.Join(base, "uam")); err != nil { + t.Fatal(err) + } + if _, err := ensureProviderFiles(); err == nil { + t.Fatal("intermediate provider-state symlink accepted") + } +} + +func TestProviderFilesRejectHostileStateBase(t *testing.T) { + t.Run("group writable", func(t *testing.T) { + base := t.TempDir() + if err := os.Chmod(base, 0o770); err != nil { + t.Fatal(err) + } + t.Setenv("XDG_STATE_HOME", base) + if _, err := ensureProviderFiles(); err == nil { + t.Fatal("group-writable XDG_STATE_HOME accepted") + } + }) + t.Run("symlink", func(t *testing.T) { + parent, target := t.TempDir(), t.TempDir() + base := filepath.Join(parent, "state") + if err := os.Symlink(target, base); err != nil { + t.Fatal(err) + } + t.Setenv("XDG_STATE_HOME", base) + if _, err := ensureProviderFiles(); err == nil { + t.Fatal("symlinked XDG_STATE_HOME accepted") + } + }) + t.Run("hostile parent", func(t *testing.T) { + parent := filepath.Join(t.TempDir(), "shared") + if err := os.Mkdir(parent, 0o777); err != nil { + t.Fatal(err) + } + if err := os.Chmod(parent, 0o777); err != nil { + t.Fatal(err) + } + t.Setenv("XDG_STATE_HOME", filepath.Join(parent, "state")) + if _, err := ensureProviderFiles(); err == nil { + t.Fatal("state base under writable non-sticky parent accepted") + } + }) +} + +func TestLiveIdentityProducesExactResumeAndMissingKeepsFallback(t *testing.T) { + ag, _ := testOpenCodeAgent(t, "Usage: opencode [--auto]") + if got := ag.ResumeKind(adapter.ResumeRequest{ProviderSessionID: "ses_known"}); got != adapter.ResumeExact { + t.Fatalf("known kind=%q", got) + } + if got := ag.ResumeKind(adapter.ResumeRequest{}); got != adapter.ResumeHeuristic { + t.Fatalf("missing kind=%q", got) + } + path, err := identityPath("uam-opencode-abc12345") + if err != nil { + t.Fatal(err) + } + if _, err := ensureProviderFiles(); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(`{"provider_session_id":"ses_live"}`), 0o600); err != nil { + t.Fatal(err) + } + got, err := ag.ListFromSnapshot(context.Background(), []session.Info{{Name: "uam-opencode-abc12345", Cwd: "/tmp", Alive: true}}) + if err != nil || got[0].ProviderSessionID != "ses_live" { + t.Fatalf("sessions=%+v err=%v", got, err) + } +} + +func TestPluginRootEventsWinAndChildCannotOverwrite(t *testing.T) { + node, err := exec.LookPath("node") + if err != nil { + t.Skip("node unavailable") + } + t.Setenv("XDG_STATE_HOME", t.TempDir()) + plugin, err := ensureProviderFiles() + if err != nil { + t.Fatal(err) + } + handoff, err := identityPath("uam-opencode-abc12345") + if err != nil { + t.Fatal(err) + } + runner := filepath.Join(t.TempDir(), "runner.mjs") + source := `import { promises as fs } from "node:fs"; +import { UAMIdentityPlugin } from ` + strconv.Quote((&url.URL{Scheme: "file", Path: plugin}).String()) + `; +const hooks = await UAMIdentityPlugin(); +await hooks.event({event:{type:"session.created",properties:{info:{id:"ses_root123"}}}}); +await fs.unlink(process.env.UAM_OPENCODE_IDENTITY_FILE); +await hooks.event({event:{type:"message.updated",properties:{info:{id:"msg_not_session"},sessionID:"ses_root123"}}}); +if (JSON.parse(await fs.readFile(process.env.UAM_OPENCODE_IDENTITY_FILE)).provider_session_id !== "ses_root123") throw new Error("root activity not captured"); +await hooks.event({event:{type:"session.updated",properties:{info:{id:"ses_child123",parentID:"ses_root123"}}}}); +await hooks.event({event:{type:"command.executed",properties:{sessionID:"ses_child123"}}}); +if (JSON.parse(await fs.readFile(process.env.UAM_OPENCODE_IDENTITY_FILE)).provider_session_id !== "ses_root123") throw new Error("child overwrote root"); +await hooks.event({event:{type:"session.updated",properties:{info:{id:"ses_switched123"}}}}); +` + if err := os.WriteFile(runner, []byte(source), 0o600); err != nil { + t.Fatal(err) + } + cmd := exec.Command(node, runner) + cmd.Env = append(os.Environ(), "UAM_OPENCODE_IDENTITY_FILE="+handoff) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("plugin: %v: %s", err, out) + } + if got, err := readIdentity(handoff); err != nil || got != "ses_switched123" { + t.Fatalf("id=%q err=%v", got, err) + } +} + +func TestPluginActivityUsesOwningSessionRatherThanMessageID(t *testing.T) { + for _, eventType := range []string{"message.updated", "command.executed"} { + if !strings.Contains(pluginSource, `type === "`+eventType+`"`) { + t.Fatalf("plugin missing %s", eventType) + } + } + if !strings.Contains(pluginSource, "const activityID = event?.properties?.sessionID") || !strings.Contains(pluginSource, "activityID === rootID") { + t.Fatal("activity events do not select and guard the owning root session") + } + if !strings.Contains(pluginSource, "catch (_)") || !strings.Contains(pluginSource, "finally") { + t.Fatal("identity writes are not wrapped as best-effort event handling") + } +} + +// Static yolo args stay empty because --auto is added only after a +// version-aware executable probe in PrepareLaunch. func TestNoYoloArgs(t *testing.T) { got := New(nil) ta, ok := got.(*adapter.Agent) @@ -26,15 +357,14 @@ func TestNoYoloArgs(t *testing.T) { t.Fatalf("expected *adapter.Agent, got %T", got) } if len(ta.YoloArgs) != 0 { - t.Fatalf("opencode YoloArgs must be empty, got %v", ta.YoloArgs) + t.Fatalf("opencode static YoloArgs must be empty, got %v", ta.YoloArgs) } } // TestSessionArgsAppendsContinueOnResume asserts the SessionArgs // hook returns opencode's `-c` (continue) on resume and nothing on -// dispatch. opencode has no flag for presetting its session ID at -// dispatch, so id-based resume isn't possible at v0.1.x; `-c` -// resumes the most recent session in the current cwd. +// dispatch. Identity is learned asynchronously, so records observed before +// discovery still resume the most recent session in the current cwd. func TestSessionArgsAppendsContinueOnResume(t *testing.T) { if got := sessionArgs(adapter.ResumeRequest{ID: "x"}, "dispatched"); got != nil { t.Fatalf("dispatched should add no flags, got %v", got) diff --git a/internal/app/app.go b/internal/app/app.go index 8bdea79..2781c58 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -2,15 +2,18 @@ package app import ( "context" + "errors" "fmt" "os" "os/exec" "path/filepath" "strings" + "sync" "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" "github.com/RandomCodeSpace/unified-agent-manager/internal/agents" @@ -23,6 +26,7 @@ import ( type Model struct { width, height int + sizeKnown bool quitting bool loading bool service *Service @@ -39,25 +43,39 @@ type Model struct { // Enter sends to this session via Service.Reply rather than dispatching a new // agent. Snapshotting the id (mirroring renameTargetID) keeps a reorder under // the cursor from misrouting the reply (F36). - peekTargetID string - helpOpen bool - confirmStop bool - confirmStopID string - renaming bool - renameTargetID string - wizard bool - wizardStep int - wizardAgent string - wizardAlias string - wizardCwd string - groupByDir bool - execProcess func(*exec.Cmd, tea.ExecCallback) tea.Cmd + peekTargetID string + peekTargetAgent string + helpOpen bool + confirmStop bool + confirmStopID string + confirmStopAgent string + confirmLatest bool + confirmLatestAgent string + confirmLatestID string + confirmLatestName string + confirmLatestAction latestAction + renaming bool + renameTargetID string + renameTargetAgent string + wizard bool + wizardStep int + wizardAgent string + wizardAlias string + wizardCwd string + groupByDir bool + execProcess func(*exec.Cmd, tea.ExecCallback) tea.Cmd // reorderSeq increments on every reorder; a debounced flush tick only // persists when its seq still matches, so a held Shift+arrow coalesces into // one store write instead of one fsync per step. reorderPending marks a // scheduled-but-not-yet-flushed reorder so quit can flush it (F59). reorderSeq int reorderPending bool + reorderDirty map[sessionIdentity]struct{} + // Test seams for proving persistence/reload sequencing without timing a + // filesystem lock. Production leaves both nil and uses Service directly. + persistSortIndices func([]adapter.Session) error + reloadSessions func() sessionsLoadedMsg + groupToggle *groupToggleCoordinator // lastPeekAt records, per session id, when its pane was last captured for // the peek panel. The peek-focus ticker re-polls the focused session at most // once per peekFocusInterval; the map is keyed by id (not row index) because @@ -110,6 +128,21 @@ type attachSpecMsg struct { err error } type attachFinishedMsg struct{ err error } +type latestAction string + +const ( + latestResume latestAction = "resume" + latestAttach latestAction = "attach" + latestRestart latestAction = "restart" +) + +type latestRequiredMsg struct { + action latestAction + agent string + id string + name string + err error +} type refreshMsg time.Time type prRefreshMsg time.Time type prRefreshedMsg struct{ err error } @@ -124,6 +157,23 @@ type peekTickMsg time.Time // matches the latest move, dropping ticks superseded by a newer move (F59). type reorderFlushMsg struct{ seq int } +type sessionIdentity struct { + agent string + id string +} + +type groupToggleCoordinator struct { + mu sync.Mutex + generation uint64 +} + +type groupToggleResultMsg struct { + generation uint64 + grouped bool + loaded sessionsLoadedMsg + err error +} + // promptEditedMsg carries the result of editing the wizard prompt in $EDITOR. // The editor is launched via tea.ExecProcess (which suspends the TUI, restores // the terminal, and resumes cleanly); when it exits this message loads the file @@ -207,6 +257,9 @@ func (m Model) refreshStep(now time.Time) (Model, bool) { func (m Model) loadSessionsCmd() tea.Cmd { return func() tea.Msg { + if m.reloadSessions != nil { + return m.reloadSessions() + } sessions, cfg, err := m.service.LoadSessions(context.Background()) return sessionsLoadedMsg{sessions: sessions, defaultAgent: cfg.DefaultAgent, groupByDir: cfg.UI.GroupByDir, err: err} } @@ -255,6 +308,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } return m, m.flushReorder() + case groupToggleResultMsg: + if !m.isLatestGroupToggle(msg.generation) { + return m, nil + } + if msg.err != nil { + m.setGroupByDir(!msg.grouped) + m.setMessage("could not save view setting: " + msg.err.Error()) + return m, nil + } + return m.handleSessionsLoaded(msg.loaded), nil case sessionsLoadedMsg: return m.handleSessionsLoaded(msg), nil case peekLoadedMsg: @@ -264,7 +327,20 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case attachSpecMsg: return m, m.execAttachSpec(msg.spec, msg.err) case attachFinishedMsg: - return m.handleAttachFinished(msg), m.loadSessionsCmd() + return m.handleAttachFinished(msg), tea.Batch(m.loadSessionsCmd(), tea.ClearScreen, tea.WindowSize()) + case latestRequiredMsg: + if !errors.Is(msg.err, ErrAmbiguousResume) { + m.setMessage(msg.err.Error()) + return m, nil + } + m.confirmLatest = true + m.confirmLatestAction = msg.action + m.confirmLatestAgent = msg.agent + m.confirmLatestID = msg.id + m.confirmLatestName = msg.name + m.message = "" + m.messageSetAt = time.Time{} + return m, nil case promptEditedMsg: return m.handlePromptEdited(msg), nil case tea.KeyMsg: @@ -275,6 +351,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Model) handleWindowSize(msg tea.WindowSizeMsg) Model { m.width, m.height = msg.Width, msg.Height + m.sizeKnown = true return m } @@ -303,8 +380,12 @@ func (m Model) handleSessionsLoaded(msg sessionsLoadedMsg) Model { m.setMessage(msg.err.Error()) return m } + selectedAgent, selectedID := "", "" + if sess, ok := m.selectedSession(); ok { + selectedAgent, selectedID = sess.AgentType, sess.ID + } if msg.sessions != nil { - m.sessions = msg.sessions + m.sessions = projectSessions(msg.sessions, msg.groupByDir) m.groupByDir = msg.groupByDir // Drop peek throttle stamps for sessions that no longer exist so the // map cannot grow without bound across many session lifetimes. @@ -324,9 +405,15 @@ func (m Model) handleSessionsLoaded(msg sessionsLoadedMsg) Model { // disabled one (C2-9). m.defaultAgent = m.validateDefaultAgent(msg.defaultAgent) } - if m.selected >= len(m.sessions) { - m.selected = max(0, len(m.sessions)-1) + if selectedID != "" { + for i, sess := range m.sessions { + if sess.AgentType == selectedAgent && sess.ID == selectedID { + m.selected = i + return m + } + } } + m.selected = max(0, min(m.selected, len(m.sessions)-1)) return m } @@ -401,21 +488,35 @@ func (m Model) handleModalKey(msg tea.KeyMsg, key string) (bool, tea.Model, tea. } return true, m, nil } + if m.confirmLatest { + if key == "y" || key == "enter" { + action, agentName, id := m.confirmLatestAction, m.confirmLatestAgent, m.confirmLatestID + m.clearLatestConfirmation() + return true, m, m.retryLatestCmd(action, agentName, id) + } + if key == "n" || key == "esc" { + m.clearLatestConfirmation() + } + return true, m, nil + } if m.confirmStop { if key == "y" || key == "enter" { m.confirmStop = false - id := m.confirmStopID + agentName, id := m.confirmStopAgent, m.confirmStopID + m.confirmStopAgent = "" m.confirmStopID = "" - return true, m, m.stopTargetCmd(id, true) + return true, m, m.stopTargetExactCmd(agentName, id, true) } if key == "r" { m.confirmStop = false - id := m.confirmStopID + agentName, id := m.confirmStopAgent, m.confirmStopID + m.confirmStopAgent = "" m.confirmStopID = "" - return true, m, m.restartTargetCmd(id) + return true, m, m.restartTargetExactCmd(agentName, id) } if key == "n" || key == "esc" { m.confirmStop = false + m.confirmStopAgent = "" m.confirmStopID = "" } return true, m, nil @@ -431,6 +532,39 @@ func (m Model) handleModalKey(msg tea.KeyMsg, key string) (bool, tea.Model, tea. return false, m, nil } +func (m *Model) clearLatestConfirmation() { + m.confirmLatest = false + m.confirmLatestAction = "" + m.confirmLatestAgent = "" + m.confirmLatestID = "" + m.confirmLatestName = "" +} + +func (m Model) retryLatestCmd(action latestAction, agentName, id string) tea.Cmd { + opts := ResumeOptions{AllowLatest: true} + switch action { + case latestAttach: + return func() tea.Msg { + spec, err := m.service.AttachSpecExactWithOptions(context.Background(), agentName, id, opts) + return attachSpecMsg{spec: spec, err: err} + } + case latestRestart: + return func() tea.Msg { + if err := m.service.RestartExactWithOptions(context.Background(), agentName, id, opts); err != nil { + return sessionsLoadedMsg{err: err} + } + return m.loadSessionsCmd()() + } + default: + return func() tea.Msg { + if err := m.service.ResumeBackgroundExactWithOptions(context.Background(), agentName, id, opts); err != nil { + return sessionsLoadedMsg{err: err} + } + return m.loadSessionsCmd()() + } + } +} + func (m *Model) handleMovementKey(key string) (bool, tea.Cmd) { switch key { case "up": @@ -459,6 +593,7 @@ func (m *Model) moveSelectionPeek(delta int) tea.Cmd { // The peek panel follows the cursor; keep the reply target in sync so a reply // goes to the session the user is actually looking at (F36). if sess, ok := m.selectedSession(); ok { + m.peekTargetAgent = sess.AgentType m.peekTargetID = sess.ID } m.peekText = "" @@ -516,14 +651,24 @@ func (m *Model) moveSession(delta int) tea.Cmd { if next < 0 || next >= len(m.sessions) { return nil } - // SortSessions buckets rows by Closed, then Pinned, before honoring + // SortSessions buckets rows by process liveness, then Pinned, before honoring // SortIndex. A swap that crosses either boundary is undone on the next // refresh (the row snaps back to its partition), so reject it and give // honest feedback instead of a move that silently reverts (F34). if !samePartition(m.sessions[m.selected], m.sessions[next]) { - m.setMessage("can't reorder across the active/closed or pinned boundary") + m.setMessage("can't reorder across the running/stopped or pinned boundary") return nil } + if m.groupByDir && workspaceKey(m.sessions[m.selected].Cwd) != workspaceKey(m.sessions[next].Cwd) { + m.setMessage("can't reorder across workspace groups") + return nil + } + groupStart, groupEnd := m.reorderGroupBounds(m.selected) + if reorderGroupHasCollidingIndices(m.sessions[groupStart:groupEnd]) { + m.normalizeReorderGroup(groupStart, groupEnd) + } + m.sessions[m.selected].SortIndex, m.sessions[next].SortIndex = m.sessions[next].SortIndex, m.sessions[m.selected].SortIndex + m.markReorderDirty(m.sessions[m.selected], m.sessions[next]) m.sessions[m.selected], m.sessions[next] = m.sessions[next], m.sessions[m.selected] m.selected = next return m.scheduleReorderFlush() @@ -548,15 +693,83 @@ func (m *Model) flushReorder() tea.Cmd { if !m.reorderPending { return nil } + return m.persistSortIndicesCmd(m.captureReorderDirty()) +} + +func (m *Model) captureReorderDirty() []adapter.Session { + dirty := make([]adapter.Session, 0, len(m.reorderDirty)) + for _, sess := range m.sessions { + if _, ok := m.reorderDirty[sessionIdentity{agent: sess.AgentType, id: sess.ID}]; ok { + dirty = append(dirty, sess) + } + } m.reorderPending = false - return m.persistOrderCmd() + m.reorderDirty = nil + return dirty +} + +func (m *Model) markReorderDirty(sessions ...adapter.Session) { + if m.reorderDirty == nil { + m.reorderDirty = make(map[sessionIdentity]struct{}) + } + for _, sess := range sessions { + m.reorderDirty[sessionIdentity{agent: sess.AgentType, id: sess.ID}] = struct{}{} + } +} + +func (m Model) reorderGroupBounds(index int) (int, int) { + start, end := index, index+1 + for start > 0 && m.sameReorderGroup(m.sessions[index], m.sessions[start-1]) { + start-- + } + for end < len(m.sessions) && m.sameReorderGroup(m.sessions[index], m.sessions[end]) { + end++ + } + return start, end +} + +func (m Model) sameReorderGroup(a, b adapter.Session) bool { + return samePartition(a, b) && (!m.groupByDir || workspaceKey(a.Cwd) == workspaceKey(b.Cwd)) +} + +func reorderGroupHasCollidingIndices(sessions []adapter.Session) bool { + seen := make(map[int]struct{}, len(sessions)) + for _, sess := range sessions { + if _, ok := seen[sess.SortIndex]; ok { + return true + } + seen[sess.SortIndex] = struct{}{} + } + return false +} + +func (m *Model) normalizeReorderGroup(start, end int) { + used := make(map[int]struct{}, len(m.sessions)-(end-start)) + for i, sess := range m.sessions { + if i < start || i >= end { + used[sess.SortIndex] = struct{}{} + } + } + next := m.sessions[start].SortIndex + for i := start; i < end; i++ { + for { + if _, exists := used[next]; !exists { + break + } + next++ + } + m.sessions[i].SortIndex = next + m.markReorderDirty(m.sessions[i]) + used[next] = struct{}{} + next++ + } } // samePartition reports whether two rows sort into the same SortSessions -// partition — they share the same Closed and Pinned flags. Only within a +// partition — they share the same Running/Stopped and Pinned flags. Only within a // partition does SortIndex (and therefore a manual reorder) take effect (F34). func samePartition(a, b adapter.Session) bool { - return a.Closed == b.Closed && a.Pinned == b.Pinned + return a.ProcAlive == b.ProcAlive && a.Pinned == b.Pinned } func (m *Model) handleActionKey(key string) (bool, tea.Cmd) { @@ -572,8 +785,14 @@ func (m *Model) handleActionKey(key string) (bool, tea.Cmd) { case "?": m.helpOpen = true case "ctrl+s": - m.groupByDir = !m.groupByDir - return true, m.persistGroupByDir() + grouped := !m.groupByDir + generation := m.nextGroupToggleGeneration() + var dirty []adapter.Session + if m.reorderPending { + dirty = m.captureReorderDirty() + } + m.setGroupByDir(grouped) + return true, m.persistGroupToggleCmd(dirty, grouped, generation) case "ctrl+t": return true, m.pinSelectedCmd() case "ctrl+r": @@ -581,6 +800,7 @@ func (m *Model) handleActionKey(key string) (bool, tea.Cmd) { case "ctrl+x": if sess, ok := m.selectedSession(); ok { m.confirmStop = true + m.confirmStopAgent = sess.AgentType m.confirmStopID = sess.ID } case " ": @@ -606,6 +826,7 @@ func (m *Model) handleEscKey() tea.Cmd { // Esc backs out of the peek/reply composer WITHOUT sending the in-progress // reply (F36). m.peekOpen = false + m.peekTargetAgent = "" m.peekTargetID = "" return nil } @@ -624,6 +845,7 @@ func (m *Model) startRename() { return } m.renaming = true + m.renameTargetAgent = sess.AgentType m.renameTargetID = sess.ID m.input = sess.DisplayName } @@ -644,10 +866,12 @@ func (m *Model) handleSpaceKey(key string) tea.Cmd { // Snapshot the peeked session so an Enter-to-reply routes to it even if a // refresh reorders the list under the cursor (F36). if sess, ok := m.selectedSession(); ok { + m.peekTargetAgent = sess.AgentType m.peekTargetID = sess.ID } return m.peekSelectedCmd() } + m.peekTargetAgent = "" m.peekTargetID = "" return nil } @@ -676,18 +900,18 @@ func (m *Model) handleEnterKey() tea.Cmd { // reply target is the snapshotted peekTargetID (falling back to the selected // session) so a reorder under the cursor can't misroute it (F36). func (m *Model) replyToPeekCmd() tea.Cmd { - sess, ok := m.sessionByID(m.peekTargetID) + sess, ok := m.sessionByIdentity(m.peekTargetAgent, m.peekTargetID) if !ok { return nil } text := m.input m.input = "" - id := sess.ID + agentName, id := sess.AgentType, sess.ID return func() tea.Msg { - if err := m.service.Reply(context.Background(), id, text); err != nil { + if err := m.service.ReplyExact(context.Background(), agentName, id, text); err != nil { return peekLoadedMsg{err: err} } - p, err := m.service.Peek(context.Background(), id) + p, err := m.service.PeekExact(context.Background(), agentName, id) return peekLoadedMsg{text: p.TailText, err: err} } } @@ -718,9 +942,10 @@ func (m Model) handleRenameKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { key := msg.String() switch key { case "enter": - sess, ok := m.sessionByID(m.renameTargetID) + sess, ok := m.sessionByIdentity(m.renameTargetAgent, m.renameTargetID) name := m.input m.renaming = false + m.renameTargetAgent = "" m.renameTargetID = "" m.input = "" // The target session vanished (killed externally / list emptied) while the @@ -728,10 +953,13 @@ func (m Model) handleRenameKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if !ok { return m, nil } - id := sess.ID - return m, func() tea.Msg { return sessionsLoadedMsg{err: m.service.Rename(context.Background(), id, name)} } + agentName, id := sess.AgentType, sess.ID + return m, func() tea.Msg { + return sessionsLoadedMsg{err: m.service.RenameExact(context.Background(), agentName, id, name)} + } case "esc": m.renaming = false + m.renameTargetAgent = "" m.renameTargetID = "" m.input = "" default: @@ -1032,26 +1260,45 @@ func (m Model) sessionByID(id string) (adapter.Session, bool) { } return adapter.Session{}, false } + +func (m Model) sessionByIdentity(agentName, id string) (adapter.Session, bool) { + if agentName == "" { + return m.sessionByID(id) + } + if id == "" { + return m.selectedSession() + } + for _, sess := range m.sessions { + if sess.AgentType == agentName && sess.ID == id { + return sess, true + } + } + return adapter.Session{}, false +} + func (m Model) peekSelectedCmd() tea.Cmd { sess, ok := m.selectedSession() if !ok { return nil } return func() tea.Msg { - p, err := m.service.Peek(context.Background(), sess.ID) + p, err := m.service.PeekExact(context.Background(), sess.AgentType, sess.ID) return peekLoadedMsg{text: p.TailText, err: err} } } // resumeSelectedCmd restarts the selected session's backend session in the -// background, then reloads so it moves into the ACTIVE group. +// background, then reloads so it moves into RUNNING. func (m Model) resumeSelectedCmd() tea.Cmd { sess, ok := m.selectedSession() if !ok { return nil } return func() tea.Msg { - if err := m.service.ResumeBackground(context.Background(), sess.ID); err != nil { + if err := m.service.ResumeBackgroundExact(context.Background(), sess.AgentType, sess.ID); err != nil { + if errors.Is(err, ErrAmbiguousResume) { + return latestRequiredMsg{action: latestResume, agent: sess.AgentType, id: sess.ID, name: firstNonEmpty(sess.DisplayName, sess.ID), err: err} + } return sessionsLoadedMsg{err: err} } return m.loadSessionsCmd()() @@ -1069,17 +1316,22 @@ func (m *Model) persistDefaultAgent() tea.Cmd { return m.loadSessionsCmd() } -// persistGroupByDir persists the group-by-dir toggle. On failure it surfaces the -// error and reverts the in-memory flag so the UI matches the unchanged stored -// state; on success it returns a reload command (F55). -func (m *Model) persistGroupByDir() tea.Cmd { - grouped := m.groupByDir - if err := m.service.SetUI(func(ui *store.UISettings) { ui.GroupByDir = grouped }); err != nil { - m.groupByDir = !grouped - m.setMessage("could not save view setting: " + err.Error()) - return nil +func (m *Model) setGroupByDir(grouped bool) { + selectedAgent, selectedID := "", "" + if sess, ok := m.selectedSession(); ok { + selectedAgent, selectedID = sess.AgentType, sess.ID + } + canonical := append([]adapter.Session(nil), m.sessions...) + SortSessions(canonical) + m.sessions = projectSessions(canonical, grouped) + m.groupByDir = grouped + for i, sess := range m.sessions { + if sess.AgentType == selectedAgent && sess.ID == selectedID { + m.selected = i + return + } } - return m.loadSessionsCmd() + m.selected = max(0, min(m.selected, len(m.sessions)-1)) } // stopTargetCmd stops the session with the snapshotted id, falling back to the @@ -1096,16 +1348,27 @@ func (m Model) stopTargetCmd(id string, remove bool) tea.Cmd { } } -// restartTargetCmd restarts the session with the snapshotted id (same F29 -// fallback as stopTargetCmd): the agent process is stopped and resumed in -// place, keeping the session's name and provider conversation. -func (m Model) restartTargetCmd(id string) tea.Cmd { - sess, ok := m.sessionByID(id) +func (m Model) stopTargetExactCmd(agentName, id string, remove bool) tea.Cmd { + sess, ok := m.sessionByIdentity(agentName, id) if !ok { return nil } return func() tea.Msg { - if err := m.service.Restart(context.Background(), sess.ID); err != nil { + err := m.service.StopExact(context.Background(), sess.AgentType, sess.ID, remove) + return sessionsLoadedMsg{err: err} + } +} + +func (m Model) restartTargetExactCmd(agentName, id string) tea.Cmd { + sess, ok := m.sessionByIdentity(agentName, id) + if !ok { + return nil + } + return func() tea.Msg { + if err := m.service.RestartExact(context.Background(), sess.AgentType, sess.ID); err != nil { + if errors.Is(err, ErrAmbiguousResume) { + return latestRequiredMsg{action: latestRestart, agent: sess.AgentType, id: sess.ID, name: firstNonEmpty(sess.DisplayName, sess.ID), err: err} + } return sessionsLoadedMsg{err: err} } return m.loadSessionsCmd()() @@ -1117,7 +1380,7 @@ func (m Model) pinSelectedCmd() tea.Cmd { return nil } return func() tea.Msg { - err := m.service.TogglePin(context.Background(), sess.ID) + err := m.service.TogglePinExact(context.Background(), sess.AgentType, sess.ID) return sessionsLoadedMsg{err: err} } } @@ -1127,7 +1390,10 @@ func (m Model) attachSelectedCmd() tea.Cmd { return nil } return func() tea.Msg { - spec, err := m.service.AttachSpec(context.Background(), sess.ID) + spec, err := m.service.AttachSpecExact(context.Background(), sess.AgentType, sess.ID) + if errors.Is(err, ErrAmbiguousResume) { + return latestRequiredMsg{action: latestAttach, agent: sess.AgentType, id: sess.ID, name: firstNonEmpty(sess.DisplayName, sess.ID), err: err} + } return attachSpecMsg{spec: spec, err: err} } } @@ -1167,7 +1433,61 @@ func (m Model) execAttachSpec(spec adapter.AttachSpec, err error) tea.Cmd { func (m Model) persistOrderCmd() tea.Cmd { sessions := append([]adapter.Session(nil), m.sessions...) - return func() tea.Msg { return sessionsLoadedMsg{err: m.service.UpdateSortOrder(sessions)} } + return m.persistSortIndicesCmd(sessions) +} + +func (m Model) persistSortIndicesCmd(sessions []adapter.Session) tea.Cmd { + sessions = append([]adapter.Session(nil), sessions...) + return func() tea.Msg { return sessionsLoadedMsg{err: m.updateSortIndices(sessions)} } +} + +func (m Model) updateSortIndices(sessions []adapter.Session) error { + if m.persistSortIndices != nil { + return m.persistSortIndices(sessions) + } + return m.service.UpdateSortIndices(sessions) +} + +func (m *Model) nextGroupToggleGeneration() uint64 { + if m.groupToggle == nil { + m.groupToggle = &groupToggleCoordinator{} + } + m.groupToggle.mu.Lock() + defer m.groupToggle.mu.Unlock() + m.groupToggle.generation++ + return m.groupToggle.generation +} + +func (m Model) isLatestGroupToggle(generation uint64) bool { + if m.groupToggle == nil { + return false + } + m.groupToggle.mu.Lock() + defer m.groupToggle.mu.Unlock() + return generation == m.groupToggle.generation +} + +func (m Model) persistGroupToggleCmd(sessions []adapter.Session, grouped bool, generation uint64) tea.Cmd { + sessions = append([]adapter.Session(nil), sessions...) + return func() tea.Msg { + if len(sessions) > 0 { + if err := m.updateSortIndices(sessions); err != nil { + return groupToggleResultMsg{generation: generation, grouped: grouped, err: err} + } + } + m.groupToggle.mu.Lock() + if generation != m.groupToggle.generation { + m.groupToggle.mu.Unlock() + return groupToggleResultMsg{generation: generation, grouped: grouped} + } + err := m.service.SetUI(func(ui *store.UISettings) { ui.GroupByDir = grouped }) + m.groupToggle.mu.Unlock() + if err != nil { + return groupToggleResultMsg{generation: generation, grouped: grouped, err: err} + } + loaded, _ := m.loadSessionsCmd()().(sessionsLoadedMsg) + return groupToggleResultMsg{generation: generation, grouped: grouped, loaded: loaded} + } } // ─── theme ─────────────────────────────────────────────────────────────── @@ -1199,12 +1519,60 @@ var ( // bar is the accent rule that marks the brand and command lines. func bar() string { return brandStyle.Render("▌") } -// layoutMode buckets the usable width: 0 narrow (mobile), 1 mid, 2 wide. +// LayoutClass names the three responsive dashboard geometries. It is derived +// from the current terminal dimensions; Model deliberately stores no parallel +// layout booleans that could become contradictory after a resize. +type LayoutClass uint8 + +const ( + LayoutCompact LayoutClass = iota + LayoutStandard + LayoutWide +) + +// DashboardMode is the primary dashboard surface. Like LayoutClass it is +// derived from the existing interaction state, keeping wizard/peek behavior as +// the source of truth for all existing key flows. +type DashboardMode uint8 + +const ( + ModeOperations DashboardMode = iota + ModePeek + ModeNew +) + +func (m Model) layoutClass() LayoutClass { + w, h := m.width, m.height + if w <= 0 { + w = 98 + } + // An unknown height means callers are rendering a component outside a real + // terminal (many legacy unit tests do this), so classify by width only. + if h > 0 && (w < 58 || h < 24) || w < 58 { + return LayoutCompact + } + if w >= 96 && (h == 0 || h >= 28) { + return LayoutWide + } + return LayoutStandard +} + +func (m Model) dashboardMode() DashboardMode { + if m.wizard { + return ModeNew + } + if m.peekOpen { + return ModePeek + } + return ModeOperations +} + +// layoutMode is retained as a compatibility shim for component-level tests. func (m Model) layoutMode() int { - switch w := m.contentWidth(); { - case w >= 76: + switch m.layoutClass() { + case LayoutWide: return 2 - case w >= 48: + case LayoutStandard: return 1 default: return 0 @@ -1215,11 +1583,22 @@ func (m Model) View() string { if m.quitting { return "" } + // Before Bubble Tea sends its first WindowSizeMsg preserve the historical + // unconstrained view. Once dimensions are known all composition is budgeted. + if !m.sizeKnown { + return m.unboundedView() + } + return m.responsiveView() +} + +func (m Model) unboundedView() string { var b strings.Builder b.WriteString(m.renderBranding()) switch { case m.helpOpen: b.WriteString(m.renderHelp()) + case m.confirmLatest: + b.WriteString(m.renderLatestConfirmation()) case m.confirmStop: b.WriteString(m.renderConfirm()) case m.wizard: @@ -1235,6 +1614,299 @@ func (m Model) View() string { return b.String() } +// responsiveView reserves the prompt first, then allocates the remaining rows +// to exactly one primary surface. fitScreen is a final safety rail for terminal +// sizes smaller than any useful composition; normal fixtures fit by budget. +func (m Model) responsiveView() string { + w, h := max(1, m.width), max(0, m.height) + if h == 0 { + return "" + } + header := []string{m.responsiveHeader(w)} + prompt := boundedNonBlankLines(m.renderPrompt(), w) + if m.dashboardMode() == ModeNew { + prompt = m.wizardPromptLines(w) + } + if len(prompt) == 0 { + prompt = []string{bar() + " " + brandStyle.Render("›")} + } + if len(prompt) >= h { + return fitScreen(prompt[:h], w, h) + } + bodyBudget := max(0, h-len(header)-len(prompt)) + body := m.responsiveBody(w, bodyBudget) + lines := append(header, body...) + lines = append(lines, prompt...) + return fitScreen(lines, w, h) +} + +func (m Model) wizardPromptLines(width int) []string { + step := max(0, min(m.wizardStep, 3)) + hints := []string{ + "Tab cycle · Enter confirm · Esc cancel", + "Enter confirm · Esc cancel", + "Tab path · Enter confirm · Esc cancel", + "Ctrl+G edit · Enter start · Esc cancel", + } + field := displaytext.Sanitize(m.input) + if step == 0 && field == "" { + field = firstNonEmpty(m.wizardAgent, m.defaultAgent) + } + return []string{ + ansi.Truncate(bar()+" "+hintStyle.Render("new")+" "+brandStyle.Render("›")+" "+titleStyle.Render(field)+brandStyle.Render("▏"), width, "…"), + ansi.Truncate(" "+hintStyle.Render(hints[step]), width, "…"), + } +} + +func (m Model) responsiveHeader(width int) string { + text := bar() + " " + brandStyle.Render("UAM") + " " + hintStyle.Render("Unified Agent Manager") + if m.layoutClass() != LayoutCompact { + text += " " + hintStyle.Render(version.String()) + } + return ansi.Truncate(text, width, "…") +} + +func (m Model) responsiveBody(width, budget int) []string { + if budget <= 0 { + return nil + } + if m.helpOpen { + return takeLines(boundedNonBlankLines(m.renderHelp(), width), budget) + } + if m.confirmLatest { + return takeLines(boundedNonBlankLines(m.renderLatestConfirmation(), width), budget) + } + if m.confirmStop { + return takeLines(boundedNonBlankLines(m.renderConfirm(), width), budget) + } + if m.dashboardMode() == ModeNew { + return takeLines(boundedNonBlankLines(m.renderWizard(), width), budget) + } + class := m.layoutClass() + if class == LayoutWide { + leftWidth := max(28, width*52/100) + rightWidth := max(20, width-leftWidth-3) + left := m.sessionListLines(leftWidth, budget, class) + right := m.detailPaneLines(rightWidth, budget, m.dashboardMode()) + return joinColumns(left, right, leftWidth, rightWidth, budget) + } + if m.dashboardMode() == ModePeek { + return m.peekSurfaceLines(width, budget, class) + } + detailLimit := 3 + if class == LayoutCompact { + detailLimit = 2 + } + details := takeLines(m.selectedSummaryLines(width, class), min(detailLimit, budget)) + list := m.sessionListLines(width, budget-len(details), class) + return append(details, list...) +} + +func (m Model) selectedSummaryLines(width int, class LayoutClass) []string { + sess, ok := m.selectedSession() + if !ok { + return nil + } + name := truncate(firstNonEmpty(sess.DisplayName, sess.ID), max(1, width-11)) + lines := []string{ansi.Truncate(sectionStyle.Render("SELECTED")+" "+titleStyle.Render(name), width, "…")} + if class == LayoutCompact { + meta := firstNonEmpty(boundedTaskSummary(sess, max(1, width-2)), "agent: "+sess.AgentType) + return append(lines, ansi.Truncate(" "+taskStyle.Render(meta), width, "…")) + } + lines = append(lines, + ansi.Truncate(" "+hintStyle.Render("agent: "+displaytext.Sanitize(firstNonEmpty(sess.AgentType, "?"))), width, "…"), + ansi.Truncate(" "+hintStyle.Render("cwd: "+absCwd(sess.Cwd)), width, "…"), + ) + return lines +} + +func (m Model) detailPaneLines(width, budget int, mode DashboardMode) []string { + if mode == ModePeek { + return m.peekSurfaceLines(width, budget, LayoutWide) + } + return takeLines(m.selectedSummaryLines(width, LayoutWide), budget) +} + +func (m Model) peekSurfaceLines(width, budget int, class LayoutClass) []string { + if budget <= 0 { + return nil + } + lines := []string{sectionStyle.Render("PEEK")} + if sess, ok := m.selectedSession(); ok && budget > 1 { + lines = append(lines, ansi.Truncate(" "+titleStyle.Render(firstNonEmpty(sess.DisplayName, sess.ID)), width, "…")) + } + remaining := budget - len(lines) + if remaining > 0 { + peek := boundedTailLines(m.peekText, remaining, width) + if len(peek) == 0 { + peek = []string{hintStyle.Render("waiting for output…")} + } + lines = append(lines, takeLinesFromEnd(peek, remaining)...) + } + return takeLines(lines, budget) +} + +// boundedTailLines scans backward only far enough to find the requested tail. +// It retains empty physical lines because terminal output spacing is content, +// while avoiding a split/join of a potentially multi-thousand-line pane. +func boundedTailLines(s string, n, width int) []string { + if n <= 0 || s == "" { + return nil + } + start, breaks := 0, 0 + for i := len(s) - 1; i >= 0; i-- { + if s[i] != '\n' { + continue + } + breaks++ + if breaks == n { + start = i + 1 + break + } + } + lines := strings.Split(s[start:], "\n") + if len(lines) > n { + lines = lines[len(lines)-n:] + } + for i := range lines { + lines[i] = ansi.Truncate(lines[i], width, "…") + } + return lines +} + +func (m Model) sessionListLines(width, budget int, class LayoutClass) []string { + if budget <= 0 { + return nil + } + if m.groupByDir { + return m.groupedSessionListLines(width, budget, class) + } + if len(m.sessions) == 0 { + return takeLines([]string{m.renderSectionAtWidth("SESSIONS", "0", width), " " + hintStyle.Render("no sessions")}, budget) + } + stopped := 0 + for _, sess := range m.sessions { + if sess.ProcAlive == adapter.Exited { + stopped++ + } + } + rowBudget := max(0, budget-2) + if class == LayoutCompact { + rowBudget = max(0, budget-1) + } + start, end := visibleWindow(len(m.sessions), m.selected, rowBudget) + nameWidth, taskWidth, showTask := tableWidthsFor(width, class) + lines := make([]string, 0, budget) + if class == LayoutCompact { + right := fmt.Sprintf("%d", len(m.sessions)) + if stopped > 0 { + right = fmt.Sprintf("%d · %d stopped", len(m.sessions), stopped) + } + lines = append(lines, m.renderSectionAtWidth("SESSIONS", right, width)) + } else { + label := "RUNNING" + if m.sessions[start].ProcAlive == adapter.Exited { + label = "STOPPED" + } + lines = append(lines, m.renderSectionAtWidth(label, "", width)) + } + lastAlive := m.sessions[start].ProcAlive + for i := start; i < end && len(lines) < budget; i++ { + sess := m.sessions[i] + if class != LayoutCompact && sess.ProcAlive != lastAlive && len(lines) < budget-1 { + lines = append(lines, m.renderSectionAtWidth("STOPPED", "", width)) + lastAlive = sess.ProcAlive + } + lines = append(lines, ansi.Truncate(renderRow(sess, i == m.selected, nameWidth, taskWidth, showTask), width, "…")) + } + return takeLines(lines, budget) +} + +func (m Model) renderSectionAtWidth(label, right string, width int) string { + head := sectionStyle.Render(label) + rightWidth := ansi.StringWidth(right) + fill := max(0, width-ansi.StringWidth(head)-rightWidth-4) + line := " " + head + if fill > 0 { + line += " " + dividerStyle.Render(strings.Repeat("─", fill)) + } + if right != "" { + line += " " + hintStyle.Render(right) + } + return ansi.Truncate(line, width, "…") +} + +func tableWidthsFor(width int, class LayoutClass) (int, int, bool) { + if class == LayoutCompact || width < 58 { + return max(1, width-5), 0, false + } + name := min(30, max(10, width/3)) + return name, max(1, width-name-8), true +} + +func visibleWindow(length, selected, limit int) (int, int) { + limit = min(length, max(0, limit)) + if limit == 0 { + return 0, 0 + } + selected = max(0, min(selected, length-1)) + start := max(0, selected-limit/2) + start = min(start, length-limit) + return start, start + limit +} + +func boundedNonBlankLines(s string, width int) []string { + raw := strings.Split(strings.Trim(s, "\n"), "\n") + lines := make([]string, 0, len(raw)) + for _, line := range raw { + if line == "" { + continue + } + lines = append(lines, ansi.Truncate(line, width, "…")) + } + return lines +} + +func joinColumns(left, right []string, leftWidth, rightWidth, budget int) []string { + n := min(budget, max(len(left), len(right))) + lines := make([]string, 0, n) + for i := 0; i < n; i++ { + l, r := "", "" + if i < len(left) { + l = left[i] + } + if i < len(right) { + r = right[i] + } + lines = append(lines, padRightANSI(l, leftWidth)+" "+ansi.Truncate(r, rightWidth, "…")) + } + return lines +} + +func padRightANSI(s string, width int) string { + s = ansi.Truncate(s, width, "…") + return s + strings.Repeat(" ", max(0, width-ansi.StringWidth(s))) +} + +func takeLines(lines []string, n int) []string { + return lines[:min(len(lines), max(0, n))] +} + +func takeLinesFromEnd(lines []string, n int) []string { + n = min(len(lines), max(0, n)) + return lines[len(lines)-n:] +} + +func fitScreen(lines []string, width, height int) string { + if len(lines) > height { + lines = lines[len(lines)-height:] + } + for i := range lines { + lines[i] = ansi.Truncate(lines[i], width, "…") + } + return strings.Join(lines, "\n") +} + const uamANSILogo = ` _ _ _ __ __ | | | |/_\ | \/ | | |_| / _ \| |\/| | @@ -1285,7 +1957,7 @@ func (m Model) renderDetails() string { // Show the task/prompt here only when the session list is too narrow to // show it inline (no task column) — that way it stays visible exactly once. if _, _, showTask := m.tableWidths(); !showTask { - b.WriteString(" " + taskStyle.Render(truncate(promptText(sess), max(8, m.contentWidth()-6))) + "\n") + b.WriteString(" " + taskStyle.Render(boundedTaskSummary(sess, max(8, m.contentWidth()-6))) + "\n") } b.WriteString(" " + hintStyle.Render("agent: "+displaytext.Sanitize(firstNonEmpty(sess.AgentType, "?"))) + "\n") if !sess.CreatedAt.IsZero() { @@ -1298,6 +1970,15 @@ func (m Model) renderDetails() string { func (m Model) renderTable() string { var b strings.Builder b.WriteString("\n") + if m.groupByDir { + budget := max(2, len(m.sessions)*3+4) + lines := m.groupedSessionListLines(m.contentWidth(), budget, m.layoutClass()) + b.WriteString(strings.Join(lines, "\n")) + if len(lines) > 0 { + b.WriteString("\n") + } + return b.String() + } if len(m.sessions) == 0 { b.WriteString(m.renderSection("SESSIONS", "0") + "\n") b.WriteString(" " + hintStyle.Render("no sessions — type a prompt, @agent #name prompt, or press e") + "\n") @@ -1305,23 +1986,19 @@ func (m Model) renderTable() string { } nameWidth, taskWidth, showTask := m.tableWidths() start, end := m.visibleSessionWindow() - active, closed := 0, 0 + running, stopped := 0, 0 for _, s := range m.sessions { - if s.Closed { - closed++ + if s.ProcAlive == adapter.Exited { + stopped++ } else { - active++ + running++ } } if start > 0 { b.WriteString(" " + hintStyle.Render(fmt.Sprintf("↑ %d more", start)) + "\n") } - // Two groups: Active (anything not flagged closed_by_user — including - // reboot-survivors that will resume on attach) and Closed (the user - // explicitly retired these via uam stop, exit-in-session, or an external - // kill). - g1 := m.renderGroup(groupRenderOptions{label: "ACTIVE", total: active, start: start, end: end, wantClosed: false, nameWidth: nameWidth, taskWidth: taskWidth, showTask: showTask}) - g2 := m.renderGroup(groupRenderOptions{label: "CLOSED", total: closed, start: start, end: end, wantClosed: true, nameWidth: nameWidth, taskWidth: taskWidth, showTask: showTask}) + g1 := m.renderGroup(groupRenderOptions{label: "RUNNING", total: running, start: start, end: end, wantStopped: false, nameWidth: nameWidth, taskWidth: taskWidth, showTask: showTask}) + g2 := m.renderGroup(groupRenderOptions{label: "STOPPED", total: stopped, start: start, end: end, wantStopped: true, nameWidth: nameWidth, taskWidth: taskWidth, showTask: showTask}) b.WriteString(g1) if g1 != "" && g2 != "" { b.WriteString("\n") @@ -1334,23 +2011,22 @@ func (m Model) renderTable() string { } type groupRenderOptions struct { - label string - total int - start int - end int - wantClosed bool - nameWidth int - taskWidth int - showTask bool -} - -// renderGroup renders the windowed sessions whose Closed flag matches -// wantClosed under a section header. Empty groups render nothing. + label string + total int + start int + end int + wantStopped bool + nameWidth int + taskWidth int + showTask bool +} + +// renderGroup renders one process-liveness partition. func (m Model) renderGroup(opts groupRenderOptions) string { var rows []string for i := opts.start; i < opts.end; i++ { s := m.sessions[i] - if s.Closed != opts.wantClosed { + if (s.ProcAlive == adapter.Exited) != opts.wantStopped { continue } rows = append(rows, renderRow(s, i == m.selected, opts.nameWidth, opts.taskWidth, opts.showTask)) @@ -1387,16 +2063,24 @@ func renderRow(s adapter.Session, selected bool, nameWidth, taskWidth int, showT if selected { nameStyle = selectedStyle } - label := truncate(pin+firstNonEmpty(s.DisplayName, s.ID), nameWidth) + detail := failureExitDetail(s) + labelWidth := nameWidth + if !showTask && detail != "" { + labelWidth = max(1, nameWidth-ansi.StringWidth(detail)-1) + } + label := truncate(pin+firstNonEmpty(s.DisplayName, s.ID), labelWidth) if showTask { // Width-aware padding keeps the task column aligned even when the name // holds wide (CJK/emoji) runes (F28). cell := nameStyle.Render(padRight(label, nameWidth)) - return cursor + gs.Render(glyph) + " " + cell + " " + prCell + " " + taskStyle.Render(truncate(promptText(s), taskWidth)) + return cursor + gs.Render(glyph) + " " + cell + " " + prCell + " " + taskStyle.Render(boundedTaskSummary(s, taskWidth)) } // Narrow layout: state glyph + name only — one line per row. The selected // session's task is carried by the details panel, so rows don't repeat it. row := cursor + gs.Render(glyph) + " " + nameStyle.Render(label) + if detail != "" { + row += " " + failGlyphStyle.Render(detail) + } if s.PR != nil { row += " " + prCell } @@ -1478,14 +2162,46 @@ func promptText(sess adapter.Session) string { return firstNonEmpty(sess.Prompt, livenessLabel(sess), "idle") } +// taskSummaryText preserves the stored task while adding grounded process-exit +// detail once. Name-only compact rows render the detail separately instead. +func taskSummaryText(sess adapter.Session) string { + detail := failureExitDetail(sess) + if strings.TrimSpace(sess.Prompt) == "" && detail != "" { + return detail + } + base := promptText(sess) + if detail == "" || base == detail || strings.HasSuffix(base, " · "+detail) { + return base + } + return base + " · " + detail +} + +// boundedTaskSummary truncates the prompt portion first so grounded failure +// metadata remains visible at the right edge of narrow task/summary surfaces. +func boundedTaskSummary(sess adapter.Session, width int) string { + detail := failureExitDetail(sess) + if detail == "" || strings.TrimSpace(sess.Prompt) == "" { + return truncate(taskSummaryText(sess), width) + } + suffix := " · " + detail + base := promptText(sess) + if base == detail { + return truncate(detail, width) + } + base = strings.TrimSuffix(base, suffix) + available := width - ansi.StringWidth(suffix) + if available <= 0 { + return truncate(detail, width) + } + return truncate(base, available) + suffix +} + // livenessLabel describes a prompt-less session by its liveness and Closed flag // rather than its State enum. func livenessLabel(sess adapter.Session) string { - switch { - case sess.ProcAlive == adapter.Alive: + switch sess.ProcAlive { + case adapter.Alive: return "running" - case sess.Closed: - return "closed" default: return "resumable" } @@ -1504,9 +2220,10 @@ func absCwd(cwd string) string { func (m Model) renderHelp() string { rows := []string{ - "↑/↓ move Enter/→ attach Space peek", + "↑/↓ move Shift+↑/↓ reorder Enter/→ attach/resume", + "Space peek running / resume stopped", "Tab cycle agent Ctrl+T pin Ctrl+R rename", - "Ctrl+X stop/restart/remove Ctrl+S group-by-dir", + "Ctrl+X stop+remove / restart Ctrl+S group-by-dir", "e new session Esc quit", "in session: ← detach (when input empty) Ctrl+B d detach", "dispatch: @agent:alias #name prompt (alias, name & prompt optional)", @@ -1520,13 +2237,23 @@ func (m Model) renderHelp() string { } func (m Model) renderConfirm() string { - sess, _ := m.sessionByID(m.confirmStopID) + sess, _ := m.sessionByIdentity(m.confirmStopAgent, m.confirmStopID) name := displaytext.Sanitize(firstNonEmpty(sess.DisplayName, sess.ID, "session")) return "\n " + sectionStyle.Render("Stop session") + "\n " + hintStyle.Render("Stop and remove ") + titleStyle.Render(name) + hintStyle.Render("?") + " " + brandStyle.Render("y") + hintStyle.Render(" / restart ") + brandStyle.Render("r") + hintStyle.Render(" / ") + titleStyle.Render("N") + "\n" } +func (m Model) renderLatestConfirmation() string { + provider := displaytext.Sanitize(firstNonEmpty(m.confirmLatestAgent, "provider")) + name := displaytext.Sanitize(firstNonEmpty(m.confirmLatestName, m.confirmLatestID, "session")) + return "\n " + sectionStyle.Render("Confirm latest conversation") + "\n " + + hintStyle.Render("Several retained conversations share provider ") + titleStyle.Render(provider) + + hintStyle.Render(" and this workspace. Continuing ") + titleStyle.Render(string(m.confirmLatestAction)+" "+name) + + hintStyle.Render(" may select the provider's latest conversation.") + + " " + brandStyle.Render("y/Enter") + hintStyle.Render(" continue · n/Esc cancel") + "\n" +} + func (m Model) renderWizard() string { steps := []string{ "provider — Tab cycles, Enter confirms: " + firstNonEmpty(m.wizardAgent, m.defaultAgent), @@ -1567,24 +2294,33 @@ var ( failGlyphStyle = lipgloss.NewStyle().Bold(true).Foreground(failColor) ) -// sessionGlyph picks the row glyph from the session's liveness and Closed flag -// rather than its State enum, so a reboot-survivor (Exited but not user-closed) -// renders as a neutral "resumable" dot instead of the red Failed glyph under the -// ACTIVE group (F30). +// sessionGlyph uses process liveness and recorded exit metadata rather than the +// broad State enum. Explicit stops remain neutral even when SIGTERM produced a +// negative compatibility exit code. func sessionGlyph(s adapter.Session) (string, lipgloss.Style) { switch { case s.ProcAlive == adapter.Alive: return "⟳", liveGlyphStyle - case s.Closed: - // User-retired, dead pane: muted resting dot in the CLOSED group. - return "•", hintStyle + case failureExitDetail(s) != "": + return "!", failGlyphStyle default: - // Reboot-survivor / externally-killed but resumable: neutral paused - // glyph, NOT the red failure mark. + // Clean exits and explicit stops are both stopped and resumable. An + // explicit SIGTERM is not a provider failure merely because its stored + // compatibility exit code is -1. return "◦", hintStyle } } +func failureExitDetail(s adapter.Session) string { + if s.ProcAlive != adapter.Exited || s.Closed || s.ExitCode == nil || *s.ExitCode == 0 { + return "" + } + if *s.ExitCode < 0 { + return "signal" + } + return fmt.Sprintf("exit %d", *s.ExitCode) +} + // prStatusDot returns a distinct glyph per PR status (not color-only) so the PR // state survives a monochrome terminal or a screen scrape: open=hollow circle, // merged=filled circle, draft=half circle, closed=cross (F26). diff --git a/internal/app/app_more_test.go b/internal/app/app_more_test.go index f71dd74..805c28f 100644 --- a/internal/app/app_more_test.go +++ b/internal/app/app_more_test.go @@ -16,7 +16,7 @@ import ( func TestModelViewBasics(t *testing.T) { m := modelWithTwoSessions() out := m.View() - if !strings.Contains(out, "ACTIVE") || !strings.Contains(out, "SELECTED") { + if !strings.Contains(out, "RUNNING") || !strings.Contains(out, "SELECTED") { t.Fatalf("view=%s", out) } if strings.Contains(out, "TMUX: LIVE") || strings.Contains(out, "TMUX: DEAD") { @@ -234,6 +234,33 @@ func TestDispatchedMessageAttachesNewSession(t *testing.T) { } } +func TestAttachFinishedClearsReloadsAndRequestsWindowSize(t *testing.T) { + m := NewWithDeps(nil, nil) + m.reloadSessions = func() sessionsLoadedMsg { return sessionsLoadedMsg{} } + model, cmd := m.Update(attachFinishedMsg{}) + if cmd == nil { + t.Fatal("missing attach-return batch") + } + batch, ok := cmd().(tea.BatchMsg) + if !ok || len(batch) != 3 { + t.Fatalf("command = %T len=%d, want tea.BatchMsg len 3", cmd(), len(batch)) + } + seen := map[string]bool{} + for _, member := range batch { + seen[reflect.TypeOf(member()).String()] = true + } + for _, want := range []string{"app.sessionsLoadedMsg", "tea.clearScreenMsg", "tea.windowSizeMsg"} { + if !seen[want] { + t.Fatalf("batch message types = %v, missing %s", seen, want) + } + } + resized, _ := model.(Model).Update(tea.WindowSizeMsg{Width: 47, Height: 19}) + got := resized.(Model) + if got.width != 47 || got.height != 19 { + t.Fatalf("size = %dx%d", got.width, got.height) + } +} + func envContains(env []string, key, value string) bool { want := key + "=" + value for _, item := range env { @@ -294,8 +321,8 @@ func TestViewShowsDetailsOnTopAndTaskInTable(t *testing.T) { t.Fatalf("view should not show aggregate header stats: %s", view) } table := m.renderTable() - if !strings.Contains(table, "ACTIVE") || !strings.Contains(table, "CLOSED") { - t.Fatalf("table should group sessions into ACTIVE and CLOSED: %s", table) + if !strings.Contains(table, "RUNNING") || !strings.Contains(table, "STOPPED") { + t.Fatalf("table should group sessions into RUNNING and STOPPED: %s", table) } if !strings.Contains(table, "one") || !strings.Contains(table, "fix the parser") { t.Fatalf("table should show session name and task: %s", table) @@ -326,8 +353,8 @@ func TestSessionRowsStayStaticAcrossRefresh(t *testing.T) { m := NewWithDeps(nil, nil) m.sessions = []adapter.Session{{ID: "live", DisplayName: "live", ProcAlive: adapter.Alive}, {ID: "dead", DisplayName: "dead", ProcAlive: adapter.Exited, Closed: true}} before := m.renderTable() - if !strings.Contains(before, "ACTIVE") || !strings.Contains(before, "CLOSED") { - t.Fatalf("table should group sessions into ACTIVE and CLOSED: %s", before) + if !strings.Contains(before, "RUNNING") || !strings.Contains(before, "STOPPED") { + t.Fatalf("table should group sessions into RUNNING and STOPPED: %s", before) } if strings.Contains(before, "⠋") || strings.Contains(before, "💀") || strings.Contains(before, "🚀") || strings.Contains(before, "🔴") || strings.Contains(before, "🟢") { t.Fatalf("table should stay glyph-based, no spinner/skull/emoji: %s", before) @@ -375,8 +402,8 @@ func TestMovementAndQuitBranches(t *testing.T) { if handled, cmd := m.handleMovementKey("shift+up"); !handled || cmd != nil { t.Fatalf("boundary move handled=%v cmd=%v", handled, cmd) } - if handled, cmd := m.handleMovementKey("shift+down"); !handled || cmd == nil || m.selected != 1 || m.sessions[1].ID != "1" { - t.Fatalf("move down failed handled=%v cmd=%v selected=%d sessions=%v", handled, cmd, m.selected, m.sessions) + if handled, cmd := m.handleMovementKey("shift+down"); !handled || cmd != nil || m.selected != 0 || m.sessions[0].ID != "1" { + t.Fatalf("cross-lifecycle move was not rejected handled=%v cmd=%v selected=%d sessions=%v", handled, cmd, m.selected, m.sessions) } m = modelWithTwoSessions() diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 9829ddc..6a3f14a 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -12,22 +12,20 @@ import ( func TestRenderTableGroupsSessionsByStatus(t *testing.T) { m := NewWithDeps(nil, nil) - // live-one: live pane, status=active → ACTIVE - // stopped-active: dead pane, status=active (e.g., reboot survivor) → ACTIVE - // closed-one: dead pane, status=closed_by_user → CLOSED + // Process liveness alone determines the RUNNING/STOPPED partition. m.sessions = []adapter.Session{ {ID: "1", AgentType: "claude", DisplayName: "live-one", Prompt: "fix bug", ProcAlive: adapter.Alive}, {ID: "2", AgentType: "codex", DisplayName: "stopped-active", Prompt: "rebooted work", ProcAlive: adapter.Exited}, {ID: "3", AgentType: "claude", DisplayName: "closed-one", Prompt: "old work", ProcAlive: adapter.Exited, Closed: true}, } out := m.renderTable() - for _, want := range []string{"ACTIVE", "CLOSED", "live-one", "stopped-active", "closed-one", "fix bug"} { + for _, want := range []string{"RUNNING", "STOPPED", "live-one", "stopped-active", "closed-one", "fix bug"} { if !strings.Contains(out, want) { t.Fatalf("table missing %q: %s", want, out) } } - if strings.Contains(out, "STOPPED") { - t.Fatalf("STOPPED group should be replaced by CLOSED: %s", out) + if strings.Contains(out, "ACTIVE") || strings.Contains(out, "CLOSED") { + t.Fatalf("legacy lifecycle groups remain: %s", out) } if strings.Contains(out, "⠋") || strings.Contains(out, "💀") || strings.Contains(out, "🚀") || strings.Contains(out, "🔴") || strings.Contains(out, "🟢") { t.Fatalf("table should stay glyph-based, no spinner/emoji: %s", out) @@ -35,9 +33,8 @@ func TestRenderTableGroupsSessionsByStatus(t *testing.T) { if strings.Contains(out, "claude") || strings.Contains(out, "codex") { t.Fatalf("table should not show an agent column: %s", out) } - // stopped-active belongs above CLOSED so its row appears before any closed_one entry. - if strings.Index(out, "stopped-active") > strings.Index(out, "CLOSED") { - t.Fatalf("active-but-stopped sessions should render under ACTIVE, before CLOSED: %s", out) + if strings.Index(out, "stopped-active") < strings.Index(out, "STOPPED") || strings.Index(out, "closed-one") < strings.Index(out, "STOPPED") { + t.Fatalf("all exited sessions must render under STOPPED: %s", out) } } @@ -91,7 +88,7 @@ func TestRenderDetailsShowsPromptOnMobileOnly(t *testing.T) { t.Fatalf("details should not show the session id: %s", out) } if strings.Contains(out, "needs input") || strings.Contains(out, "working") { - t.Fatalf("details should not show the state label (ACTIVE/STOPPED conveys it): %s", out) + t.Fatalf("details should not show the state label (RUNNING/STOPPED conveys it): %s", out) } if strings.Contains(out, "●") || strings.Contains(out, "○") || strings.Contains(out, "TMUX") || strings.Contains(out, "uam-claude-abc12345") { t.Fatalf("details should not show liveness markers or tmux name: %s", out) @@ -108,8 +105,8 @@ func TestRenderTableNarrowShowsNamesWithoutInlineTask(t *testing.T) { m.sessions = []adapter.Session{{ID: "1", DisplayName: "responsive", Prompt: "running the test suite", ProcAlive: adapter.Alive}} out := m.renderTable() - if !strings.Contains(out, "responsive") || !strings.Contains(out, "ACTIVE") { - t.Fatalf("narrow table should show the session name under ACTIVE: %s", out) + if !strings.Contains(out, "responsive") || !strings.Contains(out, "RUNNING") { + t.Fatalf("narrow table should show the session name under RUNNING: %s", out) } if strings.Contains(out, "running the test suite") { t.Fatalf("narrow table rows should not repeat the task inline (the details panel shows it): %s", out) @@ -184,7 +181,7 @@ func TestViewIsCompactAndBorderlessOnNarrowScreens(t *testing.T) { } view := m.View() - for _, want := range []string{"SELECTED", "ACTIVE", "CLOSED", "active-one", "old-one", "fixing spacing"} { + for _, want := range []string{"SELECTED", "RUNNING", "STOPPED", "active-one", "old-one", "fixing spacing"} { if !strings.Contains(view, want) { t.Fatalf("narrow view missing %q:\n%s", want, view) } diff --git a/internal/app/layout_test.go b/internal/app/layout_test.go new file mode 100644 index 0000000..c9df09c --- /dev/null +++ b/internal/app/layout_test.go @@ -0,0 +1,333 @@ +package app + +import ( + "fmt" + "os" + "os/exec" + "strings" + "testing" + "time" + "unicode/utf8" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/ansi" +) + +func TestLayoutClassAndDashboardModeAreDerived(t *testing.T) { + tests := []struct { + width, height int + want LayoutClass + }{ + {120, 40, LayoutWide}, + {80, 30, LayoutStandard}, + {44, 20, LayoutCompact}, + {44, 12, LayoutCompact}, + {120, 20, LayoutCompact}, + } + for _, tc := range tests { + m := Model{width: tc.width, height: tc.height} + if got := m.layoutClass(); got != tc.want { + t.Errorf("layoutClass(%dx%d) = %v, want %v", tc.width, tc.height, got, tc.want) + } + } + + m := Model{} + if got := m.dashboardMode(); got != ModeOperations { + t.Fatalf("default dashboard mode = %v, want operations", got) + } + m.peekOpen = true + if got := m.dashboardMode(); got != ModePeek { + t.Fatalf("peek dashboard mode = %v, want peek", got) + } + m.wizard = true + if got := m.dashboardMode(); got != ModeNew { + t.Fatalf("wizard dashboard mode = %v, want new", got) + } +} + +func TestDashboardRequiredFixturesStayWithinTerminal(t *testing.T) { + sizes := []struct{ width, height int }{{120, 40}, {80, 30}, {44, 20}, {44, 12}} + modes := []struct { + name string + set func(*Model) + want string + }{ + {"operations", func(*Model) {}, "›"}, + {"peek", func(m *Model) { m.peekOpen = true }, "reply"}, + {"new", func(m *Model) { m.wizard = true; m.wizardStep = 3 }, "NEW SESSION"}, + } + for _, size := range sizes { + for _, mode := range modes { + t.Run(fmt.Sprintf("%s/%dx%d", mode.name, size.width, size.height), func(t *testing.T) { + m := responsiveFixture(0, 0) + m = m.handleWindowSize(tea.WindowSizeMsg{Width: size.width, Height: size.height}) + mode.set(&m) + view := m.View() + assertViewGeometry(t, view, size.width, size.height) + if !strings.Contains(view, mode.want) { + t.Fatalf("view lost required %s affordance %q:\n%s", mode.name, mode.want, view) + } + }) + } + } +} + +func TestWideOperationsAndPeekUseTwoPanes(t *testing.T) { + m := responsiveFixture(120, 40) + operations := m.View() + if !lineContainsAll(operations, "RUNNING", "SELECTED") { + t.Fatalf("wide operations should place list and selected pane side-by-side:\n%s", operations) + } + m.peekOpen = true + peek := m.View() + if !lineContainsAll(peek, "RUNNING", "PEEK") { + t.Fatalf("wide peek should retain the list beside the peek pane:\n%s", peek) + } +} + +func TestCompactRenderingKeepsUnicodeValidAndBoundsLongContent(t *testing.T) { + m := responsiveFixture(44, 12) + m.input = "部署 café e\u0301 🚀 " + strings.Repeat("界", 80) + m.message = strings.Repeat("status 🚀 ", 40) + m.peekOpen = true + m.peekText = strings.Repeat("界", 100) + "\n" + strings.Repeat("e\u0301🚀", 80) + view := m.View() + if !utf8.ValidString(view) { + t.Fatal("responsive rendering produced invalid UTF-8") + } + assertViewGeometry(t, view, 44, 12) +} + +func TestCompactModesAreExclusiveAndKeepBottomPrompt(t *testing.T) { + m := responsiveFixture(44, 12) + operations := m.View() + if strings.Contains(operations, uamANSILogo) { + t.Fatalf("compact operations must omit the ASCII logo:\n%s", operations) + } + assertBottomContains(t, operations, "›") + + m.peekOpen = true + peek := m.View() + if strings.Contains(peek, "SESSIONS") || strings.Contains(peek, "RUNNING") { + t.Fatalf("compact peek must replace the sessions surface:\n%s", peek) + } + assertBottomContains(t, peek, "reply") + + m.wizard = true + newView := m.View() + if strings.Contains(newView, "SESSIONS") || strings.Contains(newView, "PEEK") { + t.Fatalf("compact new must replace other primary surfaces:\n%s", newView) + } + assertBottomContains(t, newView, "›") +} + +func TestNoColorResponsiveViewKeepsSemanticGlyphs(t *testing.T) { + cmd := exec.Command(os.Args[0], "-test.run=^TestNoColorResponsiveViewHelper$") + cmd.Env = append(withoutColorEnvironment(os.Environ()), + "UAM_NO_COLOR_HELPER=1", "NO_COLOR=1", "TERM=xterm-256color", "COLORTERM=truecolor") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("NO_COLOR helper failed: %v\n%s", err, out) + } + view := string(out) + if strings.Contains(view, "\x1b[") { + t.Fatalf("NO_COLOR view contains SGR escapes: %q", view) + } + for _, glyph := range []string{"▸", "★", "⟳", "◦", "!", "●"} { + if !strings.Contains(view, glyph) { + t.Fatalf("NO_COLOR view lost semantic glyph %q:\n%s", glyph, view) + } + } +} + +func TestNoColorResponsiveViewHelper(t *testing.T) { + if os.Getenv("UAM_NO_COLOR_HELPER") != "1" { + return + } + if got := colorprofile.Env(os.Environ()); got != colorprofile.Ascii { + t.Fatalf("NO_COLOR did not win over color-capable TERM: %s", got) + } + m := responsiveFixture(0, 0) + m = m.handleWindowSize(tea.WindowSizeMsg{Width: 80, Height: 30}) + m.sessions[0].PR = &adapter.PRRef{Status: adapter.PRMerged} + m.sessions[1].ProcAlive = adapter.Exited + m.sessions[2].ProcAlive = adapter.Exited + m.sessions[2].ExitCode = exitCode(1) + SortSessions(m.sessions) + m.selected = 0 + _, _ = os.Stdout.WriteString(m.View()) +} + +func withoutColorEnvironment(env []string) []string { + filtered := make([]string, 0, len(env)) + for _, item := range env { + key, _, _ := strings.Cut(item, "=") + switch key { + case "NO_COLOR", "TERM", "COLORTERM": + continue + } + filtered = append(filtered, item) + } + return filtered +} + +func TestKnownZeroAndTinyHeightsStayBoundedAndRetainPrompt(t *testing.T) { + m := responsiveFixture(0, 0) + m.message = "refresh failed" + m = m.handleWindowSize(tea.WindowSizeMsg{Width: 44, Height: 0}) + if got := m.View(); got != "" { + t.Fatalf("known zero-height view must be empty, got %q", got) + } + m = m.handleWindowSize(tea.WindowSizeMsg{Width: 44, Height: 1}) + view := m.View() + assertViewGeometry(t, view, 44, 1) + if !strings.Contains(view, "›") || strings.Contains(view, "refresh failed") { + t.Fatalf("height-1 view must prioritize command prompt: %q", view) + } + m.peekOpen = true + view = m.View() + if !strings.Contains(view, "reply") { + t.Fatalf("height-1 peek view must retain reply prompt: %q", view) + } +} + +func TestCompactWizardEveryStepKeepsEssentialAffordances(t *testing.T) { + tests := []struct { + step int + want []string + }{ + {0, []string{"provider", "Tab", "Enter", "Esc"}}, + {1, []string{"command alias", "Enter", "Esc"}}, + {2, []string{"working directory", "Tab", "Enter", "Esc", "⚠ not a git repo"}}, + {3, []string{"#name prompt", "Ctrl+G", "Enter", "Esc"}}, + } + for _, tc := range tests { + t.Run(fmt.Sprintf("step-%d", tc.step+1), func(t *testing.T) { + m := responsiveFixture(0, 0) + m.wizard = true + m.wizardStep = tc.step + m.input = "typed-current-step" + if tc.step == 2 { + m.input = "/definitely-not-a-git-workspace" + } + m = m.handleWindowSize(tea.WindowSizeMsg{Width: 44, Height: 12}) + view := m.View() + assertViewGeometry(t, view, 44, 12) + for _, want := range tc.want { + if !strings.Contains(view, want) { + t.Fatalf("wizard step %d lost %q:\n%s", tc.step+1, want, view) + } + } + assertBottomContains(t, view, m.input) + }) + } +} + +func TestPeekSurfacePreservesBlankPhysicalLines(t *testing.T) { + m := responsiveFixture(44, 12) + m.peekText = "older\nblank-before\n\nblank-after\nnewest" + lines := m.peekSurfaceLines(44, 7, LayoutCompact) + got := strings.Join(lines, "\n") + if !strings.Contains(got, "blank-before\n\nblank-after") { + t.Fatalf("peek collapsed a physical blank line: %q", got) + } +} + +func TestResizeAcrossFixturesPreservesSelectionAndInput(t *testing.T) { + m := responsiveFixture(120, 40) + m.selected = 9 + want, _ := m.selectedSession() + wantInput := m.input + for _, size := range []struct{ width, height int }{{80, 30}, {44, 20}, {44, 12}, {120, 40}} { + m = m.handleWindowSize(tea.WindowSizeMsg{Width: size.width, Height: size.height}) + assertViewGeometry(t, m.View(), size.width, size.height) + got, ok := m.selectedSession() + if !ok || got.AgentType != want.AgentType || got.ID != want.ID { + t.Fatalf("resize to %dx%d changed selection from %s/%s to %+v", size.width, size.height, want.AgentType, want.ID, got) + } + if m.input != wantInput { + t.Fatalf("resize to %dx%d changed input: got %q want %q", size.width, size.height, m.input, wantInput) + } + } +} + +func TestRefreshPreservesSelectionByProviderAndID(t *testing.T) { + m := NewWithDeps(nil, nil) + m.sessions = []adapter.Session{ + {ID: "same", AgentType: "claude", DisplayName: "claude row"}, + {ID: "same", AgentType: "codex", DisplayName: "codex row"}, + {ID: "other", AgentType: "claude", DisplayName: "other row"}, + } + m.selected = 1 + m = m.handleSessionsLoaded(sessionsLoadedMsg{sessions: []adapter.Session{ + {ID: "other", AgentType: "claude", DisplayName: "other row"}, + {ID: "same", AgentType: "claude", DisplayName: "claude row"}, + {ID: "same", AgentType: "codex", DisplayName: "codex row"}, + }}) + selected, ok := m.selectedSession() + if !ok || selected.AgentType != "codex" || selected.ID != "same" { + t.Fatalf("refresh changed selected identity: %+v, ok=%v", selected, ok) + } +} + +func responsiveFixture(width, height int) Model { + m := NewWithDeps(nil, nil) + if width != 0 || height != 0 { + m = m.handleWindowSize(tea.WindowSizeMsg{Width: width, Height: height}) + } + m.defaultAgent = "claude" + m.input = "部署 café e\u0301 🚀" + m.peekText = "first tail line\n" + strings.Repeat("long peek 世界 🚀 ", 20) + "\nlast tail line" + for i := 0; i < 16; i++ { + m.sessions = append(m.sessions, adapter.Session{ + ID: fmt.Sprintf("session-%02d", i), + AgentType: []string{"claude", "codex"}[i%2], + DisplayName: fmt.Sprintf("部署 café é 🚀 session %02d with a long name", i), + Prompt: strings.Repeat("review 世界 ", 12), + Cwd: "/tmp/a/very/long/workspace/path/with/界/and/more/components", + ProcAlive: adapter.Alive, + Pinned: i == 0, + Closed: i >= 12, + CreatedAt: time.Date(2026, time.July, 12, 12, i, 0, 0, time.UTC), + }) + } + m.selected = 7 + return m +} + +func assertViewGeometry(t *testing.T, view string, width, height int) { + t.Helper() + lines := strings.Split(strings.TrimSuffix(view, "\n"), "\n") + if len(lines) > height { + t.Fatalf("view has %d lines, terminal height is %d:\n%s", len(lines), height, view) + } + for i, line := range lines { + if got := ansi.StringWidth(line); got > width { + t.Fatalf("line %d is %d columns, terminal width is %d: %q", i+1, got, width, line) + } + } +} + +func lineContainsAll(view string, needles ...string) bool { + for _, line := range strings.Split(view, "\n") { + all := true + for _, needle := range needles { + all = all && strings.Contains(line, needle) + } + if all { + return true + } + } + return false +} + +func assertBottomContains(t *testing.T, view, needle string) { + t.Helper() + lines := strings.Split(view, "\n") + start := max(0, len(lines)-2) + if !strings.Contains(strings.Join(lines[start:], "\n"), needle) { + t.Fatalf("bottom prompt lost %q:\n%s", needle, view) + } +} diff --git a/internal/app/lifecycle_3c_test.go b/internal/app/lifecycle_3c_test.go new file mode 100644 index 0000000..6e55d6c --- /dev/null +++ b/internal/app/lifecycle_3c_test.go @@ -0,0 +1,295 @@ +package app + +import ( + "errors" + "path/filepath" + "strings" + "testing" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" + "github.com/RandomCodeSpace/unified-agent-manager/internal/store" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/ansi" +) + +func exitCode(n int) *int { return &n } + +func lifecycleFixtures() []adapter.Session { + return []adapter.Session{ + {ID: "run", AgentType: "fake", DisplayName: "running", Cwd: "/tmp/a", ProcAlive: adapter.Alive, SortIndex: 9}, + {ID: "clean", AgentType: "fake", DisplayName: "clean", Cwd: "/tmp/a", ProcAlive: adapter.Exited, ExitCode: exitCode(0), SortIndex: 0}, + {ID: "crash", AgentType: "fake", DisplayName: "crashed", Cwd: "/tmp/a", ProcAlive: adapter.Exited, ExitCode: exitCode(17), SortIndex: 1}, + {ID: "signal", AgentType: "fake", DisplayName: "signaled", Cwd: "/tmp/a", ProcAlive: adapter.Exited, ExitCode: exitCode(-1), SortIndex: 2}, + {ID: "explicit", AgentType: "fake", DisplayName: "explicit", Cwd: "/tmp/a", ProcAlive: adapter.Exited, ExitCode: exitCode(-1), Closed: true, SortIndex: 3}, + } +} + +func ambiguousTUIModel(t *testing.T, alive bool) (Model, *svcFakeAdapter) { + t.Helper() + id := "11111111" + proc := adapter.Exited + if alive { + proc = adapter.Alive + } + fake := &svcFakeAdapter{name: "fake", available: true, resumeKind: adapter.ResumeHeuristic, stopRemoves: true, + sessions: []adapter.Session{{ID: id, AgentType: "fake", DisplayName: "chosen", SessionName: "uam-fake-11111111", Cwd: "/tmp/shared", ProcAlive: proc}}} + st, err := store.Open(filepath.Join(t.TempDir(), "sessions.json")) + if err != nil { + t.Fatal(err) + } + if err := st.Update(func(cfg *store.Config) error { + cfg.Sessions[store.Key("fake", id)] = store.SessionRecord{ID: id, Agent: "fake", Name: "chosen", SessionName: "uam-fake-11111111", Workdir: "/tmp/shared", Status: store.StatusActive} + cfg.Sessions[store.Key("fake", "22222222")] = store.SessionRecord{ID: "22222222", Agent: "fake", Name: "other", SessionName: "uam-fake-22222222", Workdir: "/tmp/shared", Status: store.StatusActive} + return nil + }); err != nil { + t.Fatal(err) + } + m := NewWithDeps(st, adapter.NewRegistry([]adapter.AgentAdapter{fake})) + m.sessions = append([]adapter.Session(nil), fake.sessions...) + return m, fake +} + +func TestAmbiguousSpaceConfirmationDeclineAndSnapshotAccept(t *testing.T) { + m, fake := ambiguousTUIModel(t, false) + msg := m.handleSpaceKey(" ")() + model, _ := m.Update(msg) + m = model.(Model) + if !m.confirmLatest || !strings.Contains(strings.ToLower(m.View()), "several retained conversations") || !strings.Contains(m.View(), "fake") || !strings.Contains(m.View(), "chosen") { + t.Fatalf("missing provider/session-specific confirmation: %s", m.View()) + } + declined, cmd := m.handleKey(tea.KeyMsg{Type: tea.KeyEsc}) + if cmd != nil || declined.(Model).confirmLatest || fake.resumed != nil { + t.Fatal("declining ambiguity confirmation mutated provider") + } + + m, fake = ambiguousTUIModel(t, false) + model, _ = m.Update(m.handleSpaceKey(" ")()) + m = model.(Model) + // A refresh/reorder replaces the row under the cursor while the modal is open. + m.sessions = []adapter.Session{{ID: "22222222", AgentType: "fake", DisplayName: "other", ProcAlive: adapter.Exited}, {ID: "11111111", AgentType: "fake", DisplayName: "chosen", ProcAlive: adapter.Exited}} + m.selected = 0 + accepted, retry := m.handleKey(tea.KeyMsg{Type: tea.KeyEnter}) + if retry == nil || accepted.(Model).confirmLatest { + t.Fatal("accept did not close modal and retry") + } + _ = retry() + if fake.resumed == nil || fake.resumed.ID != "11111111" { + t.Fatalf("accept retargeted resume: %+v", fake.resumed) + } +} + +func TestAmbiguousAttachAndRestartConfirmation(t *testing.T) { + for _, action := range []string{"attach", "restart"} { + t.Run(action, func(t *testing.T) { + m, fake := ambiguousTUIModel(t, action == "restart") + var cmd tea.Cmd + if action == "attach" { + cmd = m.handleEnterKey() + } else { + m.confirmStop, m.confirmStopAgent, m.confirmStopID = true, "fake", "11111111" + _, model, restartCmd := m.handleModalKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}}, "r") + m, cmd = model.(Model), restartCmd + } + model, _ := m.Update(cmd()) + m = model.(Model) + if !m.confirmLatest { + t.Fatalf("%s did not prompt: message=%q", action, m.message) + } + if action == "restart" && (fake.stopped || fake.resumed != nil) { + t.Fatal("ambiguous restart preflight was destructive") + } + model, retry := m.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'y'}}) + m = model.(Model) + result := retry() + if action == "attach" { + attach, ok := result.(attachSpecMsg) + if !ok || attach.err != nil { + t.Fatalf("attach retry = %#v", result) + } + } else if loaded, ok := result.(sessionsLoadedMsg); !ok || loaded.err != nil { + t.Fatalf("restart retry = %#v", result) + } + if fake.resumed == nil || fake.resumed.ID != "11111111" { + t.Fatalf("%s retry target=%+v", action, fake.resumed) + } + }) + } +} + +func TestExactAndUniqueResumeDoNotPrompt(t *testing.T) { + for _, kind := range []adapter.ResumeKind{adapter.ResumeExact, adapter.ResumeHeuristic} { + m, fake := ambiguousTUIModel(t, false) + fake.resumeKind = kind + if kind == adapter.ResumeHeuristic { + if err := m.service.Store.Update(func(cfg *store.Config) error { delete(cfg.Sessions, store.Key("fake", "22222222")); return nil }); err != nil { + t.Fatal(err) + } + } + msg := m.handleSpaceKey(" ")() + if loaded, ok := msg.(sessionsLoadedMsg); !ok || loaded.err != nil || errors.Is(loaded.err, ErrAmbiguousResume) { + t.Fatalf("one-step resume result = %#v", msg) + } + if fake.resumed == nil { + t.Fatal("one-step resume did not run") + } + } +} + +func TestSortSessionsPartitionsByRunningBeforeStoppedRegardlessOfClosed(t *testing.T) { + sessions := lifecycleFixtures() + SortSessions(sessions) + if sessions[0].ID != "run" { + t.Fatalf("first session = %q, want running session; order=%v", sessions[0].ID, sessionIDs(sessions)) + } + if got := sessionIDs(sessions[1:]); strings.Join(got, ",") != "clean,crash,signal,explicit" { + t.Fatalf("stopped manual order = %v", got) + } +} + +func TestRunningStoppedLabelsAcrossResponsiveAndGroupedRenderers(t *testing.T) { + for _, grouped := range []bool{false, true} { + for _, size := range []struct{ width, height int }{{120, 40}, {80, 30}, {44, 20}} { + t.Run(strings.Join([]string{boolName(grouped), string(rune(size.width))}, "/"), func(t *testing.T) { + m := Model{width: size.width, height: size.height, sizeKnown: true, sessions: lifecycleFixtures(), groupByDir: grouped} + SortSessions(m.sessions) + out := m.View() + if strings.Contains(out, "ACTIVE") || strings.Contains(out, "CLOSED") || strings.Contains(strings.ToLower(out), "closed") { + t.Fatalf("legacy lifecycle wording remains: %s", out) + } + if size.width == 44 { + if !strings.Contains(strings.ToLower(out), "stopped") { + t.Fatalf("compact view must expose stopped count: %s", out) + } + } else if !strings.Contains(out, "RUNNING") || !strings.Contains(out, "STOPPED") { + t.Fatalf("responsive view missing lifecycle groups: %s", out) + } + }) + } + } + + m := Model{width: 100, sessions: lifecycleFixtures()} + SortSessions(m.sessions) + out := m.renderTable() + if !strings.Contains(out, "RUNNING") || !strings.Contains(out, "STOPPED") || strings.Contains(out, "ACTIVE") || strings.Contains(out, "CLOSED") { + t.Fatalf("unbounded table lifecycle labels: %s", out) + } +} + +func TestGroupedLifecycleHeadingsDoNotRepeatAcrossPinPartitions(t *testing.T) { + m := Model{width: 80, height: 30, sizeKnown: true, groupByDir: true, sessions: []adapter.Session{ + {ID: "rp", AgentType: "fake", Cwd: "/tmp/p", ProcAlive: adapter.Alive, Pinned: true}, + {ID: "ru", AgentType: "fake", Cwd: "/tmp/u", ProcAlive: adapter.Alive}, + {ID: "sp", AgentType: "fake", Cwd: "/tmp/p", ProcAlive: adapter.Exited, Pinned: true}, + {ID: "su", AgentType: "fake", Cwd: "/tmp/u", ProcAlive: adapter.Exited}, + }} + SortSessions(m.sessions) + out := strings.Join(m.groupedSessionListLines(80, 30, LayoutStandard), "\n") + if got := strings.Count(out, "RUNNING"); got != 1 { + t.Fatalf("RUNNING heading count=%d, want 1:\n%s", got, out) + } + if got := strings.Count(out, "STOPPED"); got != 1 { + t.Fatalf("STOPPED heading count=%d, want 1:\n%s", got, out) + } +} + +func boolName(v bool) string { + if v { + return "grouped" + } + return "plain" +} + +func TestStoppedExitPresentationDistinguishesFailureAndExplicitStop(t *testing.T) { + cases := []struct { + name string + sess adapter.Session + glyph string + detail string + forbid string + }{ + {name: "clean", sess: adapter.Session{DisplayName: "clean", ProcAlive: adapter.Exited, ExitCode: exitCode(0)}, glyph: "◦", forbid: "exit 0"}, + {name: "crash", sess: adapter.Session{DisplayName: "crash", ProcAlive: adapter.Exited, ExitCode: exitCode(23)}, glyph: "!", detail: "exit 23"}, + {name: "signal", sess: adapter.Session{DisplayName: "signal", ProcAlive: adapter.Exited, ExitCode: exitCode(-1)}, glyph: "!", detail: "signal"}, + {name: "explicit", sess: adapter.Session{DisplayName: "explicit", ProcAlive: adapter.Exited, ExitCode: exitCode(-1), Closed: true}, glyph: "◦", forbid: "signal"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + row := renderRow(tc.sess, false, 14, 22, true) + if !strings.Contains(row, tc.glyph) || (tc.detail != "" && !strings.Contains(row, tc.detail)) || (tc.forbid != "" && strings.Contains(row, tc.forbid)) { + t.Fatalf("row = %q", row) + } + if ansi.StringWidth(ansi.Truncate(row, 44, "…")) > 44 { + t.Fatalf("row wider than 44: %q", row) + } + }) + } + long := adapter.Session{DisplayName: strings.Repeat("界", 40), ProcAlive: adapter.Exited, ExitCode: exitCode(123456)} + row := ansi.Truncate(renderRow(long, false, 20, 17, true), 44, "…") + if ansi.StringWidth(row) > 44 || !strings.Contains(row, "exit 123456") { + t.Fatalf("bounded failure detail lost: width=%d row=%q", ansi.StringWidth(row), row) + } + compact := renderRow(long, false, 39, 0, false) + if ansi.StringWidth(compact) > 44 || !strings.Contains(compact, "exit 123456") { + t.Fatalf("compact failure detail lost: width=%d row=%q", ansi.StringWidth(compact), compact) + } +} + +func TestFailureDetailAppendsToPromptWithoutReplacingOrDuplicatingIt(t *testing.T) { + for _, tc := range []struct { + name string + code int + detail string + }{ + {name: "crash", code: 17, detail: "exit 17"}, + {name: "signal", code: -1, detail: "signal"}, + } { + t.Run(tc.name, func(t *testing.T) { + sess := adapter.Session{ID: tc.name, DisplayName: "provider session", Prompt: "preserve this task", ProcAlive: adapter.Exited, ExitCode: exitCode(tc.code)} + row := renderRow(sess, false, 14, 44, true) + want := "preserve this task · " + tc.detail + if !strings.Contains(row, want) || strings.Count(row, tc.detail) != 1 { + t.Fatalf("task-column failure summary = %q, want one %q", row, want) + } + + m := Model{width: 44, height: 20, sizeKnown: true, sessions: []adapter.Session{sess}} + summary := strings.Join(m.selectedSummaryLines(44, LayoutCompact), "\n") + if !strings.Contains(summary, want) || strings.Count(summary, tc.detail) != 1 { + t.Fatalf("selected failure summary = %q, want one %q", summary, want) + } + + compactRow := renderRow(sess, false, 39, 0, false) + if strings.Contains(compactRow, sess.Prompt) || strings.Count(compactRow, tc.detail) != 1 { + t.Fatalf("compact name-only row should show detail once, got %q", compactRow) + } + + sess.Prompt = strings.Repeat("long task ", 20) + boundedRow := renderRow(sess, false, 14, 28, true) + if !strings.Contains(boundedRow, " · "+tc.detail) || ansi.StringWidth(boundedRow) > 50 { + t.Fatalf("bounded task column lost failure suffix: width=%d row=%q", ansi.StringWidth(boundedRow), boundedRow) + } + m.sessions[0] = sess + boundedSummary := strings.Join(m.selectedSummaryLines(44, LayoutCompact), "\n") + if !strings.Contains(boundedSummary, " · "+tc.detail) || ansi.StringWidth(strings.Split(boundedSummary, "\n")[1]) > 44 { + t.Fatalf("bounded selected summary lost failure suffix: %q", boundedSummary) + } + + sess.Prompt = "already recorded · " + tc.detail + if got := boundedTaskSummary(sess, 44); strings.Count(got, tc.detail) != 1 { + t.Fatalf("pre-suffixed prompt duplicated failure detail: %q", got) + } + }) + } +} + +func TestReorderRejectsAcrossRunningStoppedWithoutSideEffects(t *testing.T) { + m := Model{sessions: []adapter.Session{ + {ID: "run", AgentType: "fake", ProcAlive: adapter.Alive, SortIndex: 0}, + {ID: "stop", AgentType: "fake", ProcAlive: adapter.Exited, SortIndex: 1}, + }, selected: 0, peekOpen: true, peekTargetID: "run", peekText: "tail"} + if cmd := m.moveSession(1); cmd != nil { + t.Fatal("cross-lifecycle reorder scheduled persistence") + } + if m.selected != 0 || m.reorderPending || m.reorderSeq != 0 || m.peekTargetID != "run" || m.peekText != "tail" || sessionIDs(m.sessions)[0] != "run" { + t.Fatalf("rejected move mutated state: %+v", m) + } +} diff --git a/internal/app/loadsessions_test.go b/internal/app/loadsessions_test.go index bcd35e0..21cef52 100644 --- a/internal/app/loadsessions_test.go +++ b/internal/app/loadsessions_test.go @@ -121,6 +121,51 @@ func TestRefreshDoesNotClobberConcurrentMutationOnSameSession(t *testing.T) { } } +func TestLiveProviderIdentityWinsAndRefreshPatchOwnsOnlyThatField(t *testing.T) { + now := time.Now() + live := adapter.Session{ID: "aaaa1111", AgentType: "fake", ProviderSessionID: "ses_live", Cwd: "/tmp", SessionName: "uam-fake-aaaa1111", ProcAlive: adapter.Alive, CreatedAt: now} + svc, st, _ := newLoadService(t, []adapter.Session{live}) + key := store.Key("fake", live.ID) + if err := st.Update(func(cfg *store.Config) error { + cfg.Sessions[key] = store.SessionRecord{ID: live.ID, Agent: "fake", Name: "before", Workdir: "/tmp", SessionName: live.SessionName, ProviderSessionID: "ses_stale", Status: store.StatusActive, LastSeenAt: now} + return nil + }); err != nil { + t.Fatal(err) + } + stale, _ := st.Load() + liveMap := map[string]adapter.Session{key: live} + svc.mergeStoredSessions(liveMap, stale, now) + if got := liveMap[key].ProviderSessionID; got != "ses_live" { + t.Fatalf("merged id=%q", got) + } + updates := svc.refreshSessionRecords(context.Background(), liveMap, &stale) + if err := svc.Rename(context.Background(), live.ID, "after"); err != nil { + t.Fatal(err) + } + if err := svc.TogglePin(context.Background(), live.ID); err != nil { + t.Fatal(err) + } + if err := svc.persistRefresh(updates); err != nil { + t.Fatal(err) + } + cfg, _ := st.Load() + rec := cfg.Sessions[key] + if rec.ProviderSessionID != "ses_live" || rec.Name != "after" || !rec.Pinned { + t.Fatalf("field patch clobbered concurrent mutation: %+v", rec) + } + second := svc.refreshSessionRecords(context.Background(), liveMap, &cfg) + if len(second) != 0 { + t.Fatalf("second identity refresh not idempotent: %+v", second) + } +} + +func TestEmptyLiveProviderIdentityRetainsStoredIdentity(t *testing.T) { + got := mergeStoredMetadata(adapter.Session{}, store.SessionRecord{ProviderSessionID: "ses_stored"}) + if got.ProviderSessionID != "ses_stored" { + t.Fatalf("id=%q", got.ProviderSessionID) + } +} + func TestPersistRefreshReturnsReadOnlyStoreError(t *testing.T) { path := filepath.Join(t.TempDir(), "sessions.json") data := []byte(`{"schema_version":999,"default_agent":"opencode","sessions":{},"ui":{}}`) @@ -243,7 +288,7 @@ func TestRefreshDoesNotPersistEmptyIDRecord(t *testing.T) { } // F18 — a session the user closed but whose pane is still alive must reconcile -// to Active (it renders under ACTIVE, not CLOSED, because Closed => Exited). +// to Active (it renders under RUNNING because ProcAlive is authoritative). func TestLoadSessionsReconcilesLiveUserClosedSessionToActive(t *testing.T) { live := adapter.Session{ID: "eeee5555", AgentType: "fake", DisplayName: "e", Cwd: "/tmp", SessionName: "uam-fake-eeee5555", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now()} svc, st, _ := newLoadService(t, []adapter.Session{live}) diff --git a/internal/app/rename_input_test.go b/internal/app/rename_input_test.go index f7b88b0..a53cf6f 100644 --- a/internal/app/rename_input_test.go +++ b/internal/app/rename_input_test.go @@ -205,8 +205,8 @@ func TestStopConfirmTargetsOriginalSessionAfterReorder(t *testing.T) { // F29 snapshot semantics as stop). func TestStopConfirmRestartsOriginalSessionAfterReorder(t *testing.T) { live := []adapter.Session{ - {ID: "alpha", AgentType: "fake", DisplayName: "alpha", SessionName: "uam-fake-alpha", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now()}, - {ID: "beta", AgentType: "fake", DisplayName: "beta", SessionName: "uam-fake-beta", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now()}, + {ID: "alpha", AgentType: "fake", DisplayName: "alpha", Cwd: "/tmp/alpha", SessionName: "uam-fake-alpha", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now()}, + {ID: "beta", AgentType: "fake", DisplayName: "beta", Cwd: "/tmp/beta", SessionName: "uam-fake-beta", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now()}, } dir := t.TempDir() st, err := store.Open(filepath.Join(dir, "sessions.json")) diff --git a/internal/app/render_cluster_test.go b/internal/app/render_cluster_test.go index 0a96f82..5f032e5 100644 --- a/internal/app/render_cluster_test.go +++ b/internal/app/render_cluster_test.go @@ -189,9 +189,8 @@ func TestRenderRowColumnsAlignWithPRDot(t *testing.T) { } } -// F30 — a reboot-survivor dead session (Exited, not user-closed) must NOT show -// the red Failed glyph; it shows a neutral resumable glyph. A user-closed dead -// session and a live session each get their own glyph. +// F30 — clean/external stopped sessions must not show the failure glyph. Only a +// grounded nonzero/signal exit is failed; explicit UAM stops remain neutral. func TestStateGlyphDistinguishesResumableFromFailed(t *testing.T) { live, _ := sessionGlyph(adapter.Session{ProcAlive: adapter.Alive}) resumable, _ := sessionGlyph(adapter.Session{ProcAlive: adapter.Exited}) @@ -354,7 +353,12 @@ func TestTabSurfacesSetDefaultAgentError(t *testing.T) { func TestGroupByDirToggleSurfacesSetUIError(t *testing.T) { st := readOnlyStore(t) m := NewWithDeps(st, adapter.NewRegistry([]adapter.AgentAdapter{&svcFakeAdapter{name: "a", available: true}})) - model, _ := m.handleKey(tea.KeyMsg{Type: tea.KeyCtrlS}) + model, cmd := m.handleKey(tea.KeyMsg{Type: tea.KeyCtrlS}) + m = model.(Model) + if cmd == nil { + t.Fatal("group toggle should persist asynchronously") + } + model, _ = m.Update(cmd()) m = model.(Model) if m.message == "" { t.Fatal("a failed SetUI must surface an error in the status line") diff --git a/internal/app/reorder_partition_test.go b/internal/app/reorder_partition_test.go index 0c9784c..bd591d4 100644 --- a/internal/app/reorder_partition_test.go +++ b/internal/app/reorder_partition_test.go @@ -33,7 +33,7 @@ func TestMoveSessionAcrossActiveClosedBoundaryIsNoOp(t *testing.T) { } // F34 — a pinned/unpinned boundary is also a partition boundary: SortSessions -// sorts Pinned above unpinned within the Active group, so swapping a pinned and +// sorts Pinned above unpinned within the RUNNING group, so swapping a pinned and // an unpinned row would revert on refresh too. func TestMoveSessionAcrossPinnedBoundaryIsNoOp(t *testing.T) { m := NewWithDeps(nil, nil) diff --git a/internal/app/reply_test.go b/internal/app/reply_test.go index c46a87a..c731d77 100644 --- a/internal/app/reply_test.go +++ b/internal/app/reply_test.go @@ -47,6 +47,32 @@ func TestReplyFromPeekSendsToService(t *testing.T) { } } +func TestPeekReplySnapshotKeepsProviderAcrossDuplicateIDRefresh(t *testing.T) { + id := "same0003" + claudeSession := adapter.Session{ID: id, AgentType: "claude", DisplayName: "claude", SessionName: "uam-claude-same0003", ProcAlive: adapter.Alive} + codexSession := adapter.Session{ID: id, AgentType: "codex", DisplayName: "codex", SessionName: "uam-codex-same0003", ProcAlive: adapter.Alive} + claude := &svcFakeAdapter{name: "claude", available: true, sessions: []adapter.Session{claudeSession}} + codex := &svcFakeAdapter{name: "codex", available: true, sessions: []adapter.Session{codexSession}} + m := NewWithDeps(nil, adapter.NewRegistry([]adapter.AgentAdapter{claude, codex})) + m.sessions = []adapter.Session{claudeSession, codexSession} + m.selected = 1 + if cmd := m.handleSpaceKey(" "); cmd == nil { + t.Fatal("opening peek should issue exact peek command") + } + // A refresh moves the other provider's colliding ID under the old index. + m.sessions = []adapter.Session{codexSession, claudeSession} + m.selected = 1 + m.input = "selected provider only" + cmd := m.replyToPeekCmd() + if cmd == nil { + t.Fatal("expected reply command") + } + cmd() + if claude.replied != "" || codex.replied != "selected provider only" { + t.Fatalf("reply crossed provider: claude=%q codex=%q", claude.replied, codex.replied) + } +} + // F36 — sending a reply clears the input so the composer is ready for the next // line, and the peek panel stays open (re-peeked) to show the agent's response. func TestReplyFromPeekClearsInputAndKeepsPeekOpen(t *testing.T) { diff --git a/internal/app/service.go b/internal/app/service.go index 3b66326..d97a564 100644 --- a/internal/app/service.go +++ b/internal/app/service.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "os" + "path/filepath" "sort" "strings" "sync" @@ -110,11 +111,12 @@ func (s *Service) PruneStartup(ctx context.Context) error { // overwrites only the supplied keys, leaving every other record (and any // concurrent mutation to it) intact. type refreshPatch struct { - create *store.SessionRecord - status *store.Status - lastSeenAt *time.Time - pr *store.PRRecord - clearPR bool + create *store.SessionRecord + status *store.Status + lastSeenAt *time.Time + pr *store.PRRecord + clearPR bool + providerSessionID *string } func (s *Service) persistRefresh(updates map[string]refreshPatch) error { @@ -146,6 +148,9 @@ func applyRefreshPatch(cfg *store.Config, key string, patch refreshPatch) { pr := *patch.pr rec.PR = &pr } + if patch.providerSessionID != nil { + rec.ProviderSessionID = *patch.providerSessionID + } cfg.Sessions[key] = rec } @@ -196,14 +201,14 @@ func mergeStoredMetadata(sess adapter.Session, rec store.SessionRecord) adapter. sess.DisplayName = firstNonEmpty(rec.Name, sess.DisplayName) sess.CommandAlias = firstNonEmpty(rec.CommandAlias, sess.CommandAlias) sess.Prompt = firstNonEmpty(rec.Prompt, sess.Prompt) - sess.ProviderSessionID = firstNonEmpty(rec.ProviderSessionID, sess.ProviderSessionID) + // A non-empty live identity is authoritative; an empty discovery result is + // a transient absence and must not erase a known stored identity. + sess.ProviderSessionID = firstNonEmpty(sess.ProviderSessionID, rec.ProviderSessionID) sess.Pinned = rec.Pinned sess.Group = rec.Group sess.SortIndex = rec.SortIndex - // Closed implies the pane has exited: a user-closed record whose pane is - // still alive renders under ACTIVE (the refresh reconciles its persisted - // status back to Active). This keeps a live session from showing under - // CLOSED with a live glyph (F18). + // Closed is retained as explicit-stop reason metadata. A live process is + // always RUNNING, so refresh reconciles a stale user-closed status to Active. sess.Closed = rec.Status == store.StatusClosedByUser && sess.ProcAlive == adapter.Exited if rec.PR != nil && sess.PR == nil { // The store schema does not persist Owner/Repo, but the URL is lossless: @@ -221,7 +226,11 @@ func mergeStoredMetadata(sess adapter.Session, rec store.SessionRecord) adapter. } func deadSessionFromRecord(rec store.SessionRecord, now time.Time) adapter.Session { - return adapter.Session{ExitCode: rec.LastExitCode, ProviderSessionID: rec.ProviderSessionID, ID: rec.ID, AgentType: rec.Agent, CommandAlias: rec.CommandAlias, DisplayName: rec.Name, Prompt: rec.Prompt, Cwd: rec.Workdir, SessionName: rec.SessionName, State: adapter.Failed, ProcAlive: adapter.Exited, CreatedAt: rec.CreatedAt, LastChange: now, Pinned: rec.Pinned, Group: rec.Group, SortIndex: rec.SortIndex, Closed: rec.Status == store.StatusClosedByUser} + state := adapter.Completed + if rec.LastExitCode != nil && *rec.LastExitCode != 0 { + state = adapter.Failed + } + return adapter.Session{ExitCode: rec.LastExitCode, ProviderSessionID: rec.ProviderSessionID, ID: rec.ID, AgentType: rec.Agent, CommandAlias: rec.CommandAlias, DisplayName: rec.Name, Prompt: rec.Prompt, Cwd: rec.Workdir, SessionName: rec.SessionName, State: state, ProcAlive: adapter.Exited, CreatedAt: rec.CreatedAt, LastChange: now, Pinned: rec.Pinned, Group: rec.Group, SortIndex: rec.SortIndex, Closed: rec.Status == store.StatusClosedByUser} } // refreshSessionRecords reconciles live sessions against the loaded config and @@ -271,6 +280,10 @@ func makeRefreshPatch(before, after store.SessionRecord, existed bool) refreshPa patch.pr = &pr } } + if after.ProviderSessionID != "" && before.ProviderSessionID != after.ProviderSessionID { + providerID := after.ProviderSessionID + patch.providerSessionID = &providerID + } return patch } @@ -296,7 +309,7 @@ func reconcileRefreshRecord(cfg *store.Config, key string, sess adapter.Session, changed = true } else if rec.Status == store.StatusClosedByUser && sess.ProcAlive == adapter.Alive { // Anti-flap: the user closed this session but its pane is still alive, so - // it belongs in the Active group. Reset the persisted status to Active, + // it belongs in RUNNING. Reset the persisted status to Active, // otherwise the next merge re-flags it Closed and it flaps (F18). rec.Status = store.StatusActive cfg.Sessions[key] = rec @@ -310,6 +323,11 @@ func reconcileRefreshRecord(cfg *store.Config, key string, sess adapter.Session, cfg.Sessions[key] = rec changed = true } + if sess.ProviderSessionID != "" && sess.ProviderSessionID != rec.ProviderSessionID { + rec.ProviderSessionID = sess.ProviderSessionID + cfg.Sessions[key] = rec + changed = true + } return rec, changed, true } @@ -484,10 +502,10 @@ func sessionsFromMap(live map[string]adapter.Session) []adapter.Session { func SortSessions(sessions []adapter.Session) { sort.SliceStable(sessions, func(i, j int) bool { - // Closed sessions sort below everything else so the renderer's - // Active group stays packed at the top and Closed forms a tail. - if sessions[i].Closed != sessions[j].Closed { - return !sessions[i].Closed + // Process liveness is the dashboard lifecycle source of truth. Persisted + // Closed is reason/compatibility metadata and must not split stopped rows. + if sessions[i].ProcAlive != sessions[j].ProcAlive { + return sessions[i].ProcAlive == adapter.Alive } if sessions[i].Pinned != sessions[j].Pinned { return sessions[i].Pinned @@ -495,9 +513,6 @@ func SortSessions(sessions []adapter.Session) { if sessions[i].SortIndex != sessions[j].SortIndex { return sessions[i].SortIndex < sessions[j].SortIndex } - if sessions[i].ProcAlive != sessions[j].ProcAlive { - return sessions[i].ProcAlive == adapter.Alive - } if !sessions[i].CreatedAt.Equal(sessions[j].CreatedAt) { return sessions[i].CreatedAt.After(sessions[j].CreatedAt) } @@ -552,6 +567,20 @@ func (s *Service) Stop(ctx context.Context, id string, remove bool) error { if err != nil { return err } + return s.stopFound(ctx, sess, remove) +} + +// StopExact is the provider-aware TUI variant of Stop. The ID-only method is +// retained for CLI prefix lookup compatibility. +func (s *Service) StopExact(ctx context.Context, agentName, id string, remove bool) error { + sess, _, err := s.FindExact(ctx, agentName, id) + if err != nil { + return err + } + return s.stopFound(ctx, sess, remove) +} + +func (s *Service) stopFound(ctx context.Context, sess adapter.Session, remove bool) error { if err := s.stopAdapterSession(ctx, sess); err != nil { return err } @@ -564,9 +593,8 @@ func (s *Service) Stop(ctx context.Context, id string, remove bool) error { return nil }) } - // Soft-close: keep the record so the user can resume later, but flag it - // as user-closed so the UI sorts it into the Closed group and Reconcile - // never treats it as a reboot-recovery candidate. + // Soft-close: keep the record so the user can resume later, while preserving + // user-closed as reason metadata. Process liveness places it in STOPPED. return s.Store.Update(func(cfg *store.Config) error { key := store.Key(sess.AgentType, sess.ID) rec, ok := cfg.Sessions[key] @@ -635,6 +663,14 @@ func (s *Service) Rename(ctx context.Context, id, name string) error { return s.applyRecordMutation(sess, func(rec *store.SessionRecord) { rec.Name = name }) } +func (s *Service) RenameExact(ctx context.Context, agentName, id, name string) error { + sess, _, err := s.FindExact(ctx, agentName, id) + if err != nil { + return err + } + return s.applyRecordMutation(sess, func(rec *store.SessionRecord) { rec.Name = name }) +} + func (s *Service) TogglePin(ctx context.Context, id string) error { sess, _, err := s.Find(ctx, id) if err != nil { @@ -643,6 +679,14 @@ func (s *Service) TogglePin(ctx context.Context, id string) error { return s.applyRecordMutation(sess, func(rec *store.SessionRecord) { rec.Pinned = !rec.Pinned }) } +func (s *Service) TogglePinExact(ctx context.Context, agentName, id string) error { + sess, _, err := s.FindExact(ctx, agentName, id) + if err != nil { + return err + } + return s.applyRecordMutation(sess, func(rec *store.SessionRecord) { rec.Pinned = !rec.Pinned }) +} + // applyRecordMutation applies mut to the store record for sess inside an atomic // read-modify-write. If the record is missing (or a zero-value phantom with no // ID) it is backfilled from the live session first, so Rename/TogglePin never @@ -703,6 +747,21 @@ func (s *Service) Find(ctx context.Context, id string) (adapter.Session, store.C return adapter.Session{}, cfg, fmt.Errorf("session %q not found", id) } +// FindExact resolves the unique provider/session pair used by the TUI. Unlike +// Find it deliberately performs no prefix or cross-provider matching. +func (s *Service) FindExact(ctx context.Context, agentName, id string) (adapter.Session, store.Config, error) { + sessions, cfg, err := s.LoadSessions(ctx) + if err != nil { + return adapter.Session{}, cfg, err + } + for _, sess := range sessions { + if sess.AgentType == agentName && sess.ID == id { + return sess, cfg, nil + } + } + return adapter.Session{}, cfg, fmt.Errorf("session %q for provider %q not found", id, agentName) +} + func (s *Service) Peek(ctx context.Context, id string) (adapter.PeekResult, error) { sess, _, err := s.Find(ctx, id) if err != nil { @@ -715,6 +774,18 @@ func (s *Service) Peek(ctx context.Context, id string) (adapter.PeekResult, erro return a.Peek(ctx, sess.ID) } +func (s *Service) PeekExact(ctx context.Context, agentName, id string) (adapter.PeekResult, error) { + sess, _, err := s.FindExact(ctx, agentName, id) + if err != nil { + return adapter.PeekResult{}, err + } + a, ok := s.Registry.Get(sess.AgentType) + if !ok { + return adapter.PeekResult{}, fmt.Errorf(agentUnavailableFormat, sess.AgentType) + } + return a.Peek(ctx, sess.ID) +} + func (s *Service) Reply(ctx context.Context, id, text string) error { sess, _, err := s.Find(ctx, id) if err != nil { @@ -727,7 +798,44 @@ func (s *Service) Reply(ctx context.Context, id, text string) error { return a.Reply(ctx, sess.ID, text) } +func (s *Service) ReplyExact(ctx context.Context, agentName, id, text string) error { + sess, _, err := s.FindExact(ctx, agentName, id) + if err != nil { + return err + } + a, ok := s.Registry.Get(sess.AgentType) + if !ok { + return fmt.Errorf(agentUnavailableFormat, sess.AgentType) + } + return a.Reply(ctx, sess.ID, text) +} + func (s *Service) AttachSpec(ctx context.Context, id string) (adapter.AttachSpec, error) { + return s.AttachSpecWithOptions(ctx, id, ResumeOptions{}) +} + +func (s *Service) AttachSpecExact(ctx context.Context, agentName, id string) (adapter.AttachSpec, error) { + return s.AttachSpecExactWithOptions(ctx, agentName, id, ResumeOptions{}) +} + +func (s *Service) AttachSpecExactWithOptions(ctx context.Context, agentName, id string, opts ResumeOptions) (adapter.AttachSpec, error) { + sess, _, err := s.FindExact(ctx, agentName, id) + if err != nil { + return adapter.AttachSpec{}, err + } + a, ok := s.Registry.Get(sess.AgentType) + if !ok { + return adapter.AttachSpec{}, fmt.Errorf(agentUnavailableFormat, sess.AgentType) + } + if sess.ProcAlive == adapter.Exited { + if err := s.ResumeBackgroundExactWithOptions(ctx, agentName, id, opts); err != nil { + return adapter.AttachSpec{}, err + } + } + return a.Attach(sess.ID) +} + +func (s *Service) AttachSpecWithOptions(ctx context.Context, id string, opts ResumeOptions) (adapter.AttachSpec, error) { sess, _, err := s.Find(ctx, id) if err != nil { return adapter.AttachSpec{}, err @@ -737,7 +845,7 @@ func (s *Service) AttachSpec(ctx context.Context, id string) (adapter.AttachSpec return adapter.AttachSpec{}, fmt.Errorf(agentUnavailableFormat, sess.AgentType) } if sess.ProcAlive == adapter.Exited { - if err := s.ResumeBackground(ctx, sess.ID); err != nil { + if err := s.ResumeBackgroundWithOptions(ctx, sess.ID, opts); err != nil { return adapter.AttachSpec{}, err } } @@ -749,7 +857,36 @@ func (s *Service) AttachSpec(ctx context.Context, id string) (adapter.AttachSpec // under the same name with the provider's resume args so the agent picks its // conversation back up. A session that is already stopped simply resumes. func (s *Service) Restart(ctx context.Context, id string) error { - sess, _, err := s.Find(ctx, id) + return s.RestartWithOptions(ctx, id, ResumeOptions{}) +} + +func (s *Service) RestartExact(ctx context.Context, agentName, id string) error { + return s.RestartExactWithOptions(ctx, agentName, id, ResumeOptions{}) +} + +func (s *Service) RestartExactWithOptions(ctx context.Context, agentName, id string, opts ResumeOptions) error { + sess, cfg, err := s.FindExact(ctx, agentName, id) + if err != nil { + return err + } + resumable, rec, req, err := s.prepareResume(sess, cfg, opts) + if err != nil { + return err + } + if sess.ProcAlive == adapter.Alive { + if err := s.StopExact(ctx, agentName, id, false); err != nil { + return err + } + } + return s.resumePrepared(ctx, resumable, rec, req) +} + +func (s *Service) RestartWithOptions(ctx context.Context, id string, opts ResumeOptions) error { + sess, cfg, err := s.Find(ctx, id) + if err != nil { + return err + } + resumable, rec, req, err := s.prepareResume(sess, cfg, opts) if err != nil { return err } @@ -758,33 +895,80 @@ func (s *Service) Restart(ctx context.Context, id string) error { return err } } - return s.ResumeBackground(ctx, id) + return s.resumePrepared(ctx, resumable, rec, req) } // ResumeBackground restarts a stopped session's backend session without // attaching to it. It is a no-op when the session is already running. func (s *Service) ResumeBackground(ctx context.Context, id string) error { + return s.ResumeBackgroundWithOptions(ctx, id, ResumeOptions{}) +} + +func (s *Service) ResumeBackgroundExact(ctx context.Context, agentName, id string) error { + return s.ResumeBackgroundExactWithOptions(ctx, agentName, id, ResumeOptions{}) +} + +func (s *Service) ResumeBackgroundExactWithOptions(ctx context.Context, agentName, id string, opts ResumeOptions) error { + sess, cfg, err := s.FindExact(ctx, agentName, id) + if err != nil { + return err + } + return s.resumeBackgroundFound(ctx, sess, cfg, opts) +} + +type ResumeKind = adapter.ResumeKind + +const ( + ResumeExact = adapter.ResumeExact + ResumeHeuristic = adapter.ResumeHeuristic + ResumeUnsupported = adapter.ResumeUnsupported +) + +type ResumeOptions struct { + AllowLatest bool +} + +var ErrAmbiguousResume = errors.New("ambiguous heuristic resume") + +func (s *Service) ResumeBackgroundWithOptions(ctx context.Context, id string, opts ResumeOptions) error { sess, cfg, err := s.Find(ctx, id) if err != nil { return err } + return s.resumeBackgroundFound(ctx, sess, cfg, opts) +} + +func (s *Service) resumeBackgroundFound(ctx context.Context, sess adapter.Session, cfg store.Config, opts ResumeOptions) error { if sess.ProcAlive == adapter.Alive { return nil } - a, ok := s.Registry.Get(sess.AgentType) - if !ok { - return fmt.Errorf(agentUnavailableFormat, sess.AgentType) - } - resumable, ok := a.(adapter.ResumableAdapter) - if !ok { - return fmt.Errorf("session %q is not running and agent %q cannot resume it", sess.ID, sess.AgentType) + resumable, rec, req, err := s.prepareResume(sess, cfg, opts) + if err != nil { + return err } - rec := cfg.Sessions[store.Key(sess.AgentType, sess.ID)] - if rec.ID == "" { - rec = RecordFromSession(sess, store.ModeYolo) + return s.resumePrepared(ctx, resumable, rec, req) +} + +// resumePrepared carries one validated resume decision through launch. Restart +// deliberately calls this after stopping the old process so a newly persisted +// sibling cannot trigger a second ambiguity rejection after the destructive +// step. The initial prepareResume call remains the sole decision point. +func (s *Service) resumePrepared(ctx context.Context, resumable adapter.ResumableAdapter, rec store.SessionRecord, req adapter.ResumeRequest) error { + var lifecycle resumeLifecycle + var err error + if s.Store != nil { + lifecycle, err = s.beginResumeLifecycle(rec) + if err != nil { + return err + } } - resumed, err := resumable.Resume(ctx, adapter.ResumeRequest{ID: rec.ID, Name: rec.Name, CommandAlias: rec.CommandAlias, Prompt: rec.Prompt, Cwd: rec.Workdir, Mode: string(rec.Mode), SessionName: rec.SessionName, ProviderSessionID: rec.ProviderSessionID, CreatedAt: rec.CreatedAt}) + resumed, err := resumable.Resume(ctx, req) if err != nil { + if s.Store != nil { + if rollbackErr := s.rollbackResumeLifecycle(lifecycle); rollbackErr != nil { + log.Warn("restore failed resume lifecycle", "session", rec.SessionName, "error", rollbackErr) + } + } return err } if s.Store == nil { @@ -800,13 +984,120 @@ func (s *Service) ResumeBackground(ctx context.Context, id string) error { rec.Workdir = resumed.Cwd rec.CommandAlias = firstNonEmpty(rec.CommandAlias, resumed.CommandAlias) rec.ProviderSessionID = firstNonEmpty(resumed.ProviderSessionID, rec.ProviderSessionID) - rec.LastSeenAt = time.Now() - // Resuming a closed_by_user session reactivates it. The session host - // will flip Status back to closed_by_user on the next exit. + rec.LastSeenAt = nextResumeTimestamp(lifecycle.attemptAt) + cfg.Sessions[key] = rec + return nil + }) +} + +type resumeLifecycle struct { + key string + status store.Status + exitCode *int + lastSeenAt time.Time + attemptAt time.Time +} + +// beginResumeLifecycle clears the previous process result before launch. The +// replacement host can then record an immediate exit without a later success +// write erasing that newer result. +func (s *Service) beginResumeLifecycle(fallback store.SessionRecord) (resumeLifecycle, error) { + key := store.Key(fallback.Agent, fallback.ID) + state := resumeLifecycle{key: key} + err := s.Store.Update(func(cfg *store.Config) error { + rec := cfg.Sessions[key] + if rec.ID == "" { + rec = fallback + } + state.status = rec.Status + if rec.LastExitCode != nil { + code := *rec.LastExitCode + state.exitCode = &code + } + state.lastSeenAt = rec.LastSeenAt + state.attemptAt = nextResumeTimestamp(rec.LastSeenAt) rec.Status = store.StatusActive + rec.LastExitCode = nil + rec.LastSeenAt = state.attemptAt cfg.Sessions[key] = rec return nil }) + return state, err +} + +// rollbackResumeLifecycle restores the prior result only while the lifecycle +// fields still have the pre-launch values. A concurrent host exit or explicit +// stop wins and is never overwritten by rollback. +func (s *Service) rollbackResumeLifecycle(state resumeLifecycle) error { + return s.Store.Update(func(cfg *store.Config) error { + rec := cfg.Sessions[state.key] + if rec.ID == "" || rec.Status != store.StatusActive || rec.LastExitCode != nil || !rec.LastSeenAt.Equal(state.attemptAt) { + return nil + } + rec.Status = state.status + rec.LastSeenAt = state.lastSeenAt + if state.exitCode == nil { + rec.LastExitCode = nil + } else { + code := *state.exitCode + rec.LastExitCode = &code + } + cfg.Sessions[state.key] = rec + return nil + }) +} + +func nextResumeTimestamp(after time.Time) time.Time { + now := time.Now().UTC() + if !now.After(after) { + return after.Add(time.Nanosecond) + } + return now +} + +func (s *Service) prepareResume(sess adapter.Session, cfg store.Config, opts ResumeOptions) (adapter.ResumableAdapter, store.SessionRecord, adapter.ResumeRequest, error) { + a, ok := s.Registry.Get(sess.AgentType) + if !ok { + return nil, store.SessionRecord{}, adapter.ResumeRequest{}, fmt.Errorf(agentUnavailableFormat, sess.AgentType) + } + resumable, ok := a.(adapter.ResumableAdapter) + if !ok { + return nil, store.SessionRecord{}, adapter.ResumeRequest{}, fmt.Errorf("session %q is not running and agent %q cannot resume it", sess.ID, sess.AgentType) + } + rec := cfg.Sessions[store.Key(sess.AgentType, sess.ID)] + if rec.ID == "" { + rec = RecordFromSession(sess, store.ModeYolo) + } + req := adapter.ResumeRequest{ID: rec.ID, Name: rec.Name, CommandAlias: rec.CommandAlias, Prompt: rec.Prompt, Cwd: rec.Workdir, Mode: string(rec.Mode), SessionName: rec.SessionName, ProviderSessionID: rec.ProviderSessionID, CreatedAt: rec.CreatedAt} + kind := adapter.ResumeHeuristic + if classifier, ok := a.(adapter.ResumeKindAdapter); ok { + kind = classifier.ResumeKind(req) + } + if kind == adapter.ResumeUnsupported { + return nil, store.SessionRecord{}, adapter.ResumeRequest{}, fmt.Errorf("session %q is not running and agent %q cannot resume it", sess.ID, sess.AgentType) + } + if kind == adapter.ResumeHeuristic && !opts.AllowLatest && retainedSessionCount(cfg, rec.Agent, rec.Workdir) > 1 { + return nil, store.SessionRecord{}, adapter.ResumeRequest{}, fmt.Errorf("%w: provider %q has multiple retained sessions in %q; retry with --allow-latest", ErrAmbiguousResume, rec.Agent, rec.Workdir) + } + return resumable, rec, req, nil +} + +func retainedSessionCount(cfg store.Config, agentName, workdir string) int { + want := normalizedWorkspace(workdir) + count := 0 + for _, candidate := range cfg.Sessions { + if strings.EqualFold(candidate.Agent, agentName) && normalizedWorkspace(candidate.Workdir) == want { + count++ + } + } + return count +} + +func normalizedWorkspace(workdir string) string { + if abs, err := filepath.Abs(workdir); err == nil { + workdir = abs + } + return filepath.Clean(workdir) } func (s *Service) PrintList(ctx context.Context, asJSON bool) error { @@ -859,6 +1150,27 @@ func (s *Service) UpdateSortOrder(sessions []adapter.Session) error { }) } +// UpdateSortIndices persists the explicit canonical positions carried by each +// session. Grouped presentation order is not itself a global sort order: only +// the pair moved inside a workspace has its positions exchanged. +func (s *Service) UpdateSortIndices(sessions []adapter.Session) error { + if s.Store == nil { + return nil + } + return s.Store.Update(func(cfg *store.Config) error { + for _, sess := range sessions { + key := store.Key(sess.AgentType, sess.ID) + rec := cfg.Sessions[key] + if rec.ID == "" { + rec = RecordFromSession(sess, store.ModeYolo) + } + rec.SortIndex = sess.SortIndex + cfg.Sessions[key] = rec + } + return nil + }) +} + func firstNonEmpty(vals ...string) string { for _, v := range vals { if strings.TrimSpace(v) != "" { diff --git a/internal/app/service_test.go b/internal/app/service_test.go index 9a9e1ad..e6a5c59 100644 --- a/internal/app/service_test.go +++ b/internal/app/service_test.go @@ -8,20 +8,29 @@ import ( "os" "path/filepath" "strings" + "sync" "testing" "time" "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter/claude" + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter/codex" + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter/copilot" + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter/omp" + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter/opencode" "github.com/RandomCodeSpace/unified-agent-manager/internal/store" ) type svcFakeAdapter struct { - name string - sessions []adapter.Session - available bool - stopped bool - replied string - resumed *adapter.ResumeRequest + name string + sessions []adapter.Session + available bool + stopped bool + stoppedID string + peekedID string + attachedID string + replied string + resumed *adapter.ResumeRequest // F04: simulate a failed kill (stopErr) and a still-live pane (alive). The // fake implements adapter.HasSessionAdapter, returning alive from HasSession. stopErr error @@ -33,6 +42,17 @@ type svcFakeAdapter struct { // backend where Kill returns only after the host is fully gone — so a // restart's resume step sees the session as dead. stopRemoves bool + stopHook func() + resumeKind adapter.ResumeKind + resumeHook func(adapter.ResumeRequest) + resumeFunc func(adapter.ResumeRequest) (adapter.Session, error) +} + +func (f *svcFakeAdapter) ResumeKind(adapter.ResumeRequest) adapter.ResumeKind { + if f.resumeKind == "" { + return adapter.ResumeHeuristic + } + return f.resumeKind } func (f *svcFakeAdapter) Name() string { return f.name } @@ -51,12 +71,19 @@ func (f *svcFakeAdapter) Dispatch(ctx adapter.Context, req adapter.DispatchReque } func (f *svcFakeAdapter) Resume(ctx adapter.Context, req adapter.ResumeRequest) (adapter.Session, error) { f.resumed = &req + if f.resumeHook != nil { + f.resumeHook(req) + } + if f.resumeFunc != nil { + return f.resumeFunc(req) + } return adapter.Session{ID: req.ID, AgentType: f.name, CommandAlias: req.CommandAlias, DisplayName: req.Name, Prompt: req.Prompt, Cwd: req.Cwd, SessionName: req.SessionName, State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now()}, nil } func (f *svcFakeAdapter) List(ctx adapter.Context) ([]adapter.Session, error) { return f.sessions, f.listErr } func (f *svcFakeAdapter) Peek(ctx adapter.Context, id string) (adapter.PeekResult, error) { + f.peekedID = id return adapter.PeekResult{TailText: "tail"}, nil } func (f *svcFakeAdapter) Reply(ctx adapter.Context, id, text string) error { @@ -64,15 +91,212 @@ func (f *svcFakeAdapter) Reply(ctx adapter.Context, id, text string) error { return nil } func (f *svcFakeAdapter) Attach(id string) (adapter.AttachSpec, error) { + f.attachedID = id return adapter.AttachSpec{Argv: []string{"echo", id}}, nil } func (f *svcFakeAdapter) Stop(ctx adapter.Context, id string) error { f.stopped = true + f.stoppedID = id + if f.stopHook != nil { + f.stopHook() + } if f.stopRemoves { f.sessions = nil } return f.stopErr } + +func TestRestartCarriesInitialResumeDecisionPastConcurrentRetainedRecord(t *testing.T) { + for _, exact := range []bool{false, true} { + t.Run(map[bool]string{false: "id", true: "exact"}[exact], func(t *testing.T) { + id := "11111111" + live := adapter.Session{ID: id, AgentType: "fake", SessionName: "uam-fake-11111111", Cwd: "/tmp/shared", ProcAlive: adapter.Alive} + fake := &svcFakeAdapter{name: "fake", available: true, resumeKind: adapter.ResumeHeuristic, stopRemoves: true, sessions: []adapter.Session{live}} + st, err := store.Open(filepath.Join(t.TempDir(), "sessions.json")) + if err != nil { + t.Fatal(err) + } + if err := st.Update(func(cfg *store.Config) error { + cfg.Sessions[store.Key("fake", id)] = RecordFromSession(live, store.ModeYolo) + return nil + }); err != nil { + t.Fatal(err) + } + fake.stopHook = func() { + if err := st.Update(func(cfg *store.Config) error { + cfg.Sessions[store.Key("fake", "22222222")] = store.SessionRecord{ID: "22222222", Agent: "fake", Workdir: "/tmp/shared", SessionName: "uam-fake-22222222", Status: store.StatusActive} + return nil + }); err != nil { + t.Errorf("insert competing record: %v", err) + } + } + svc := NewService(st, adapter.NewRegistry([]adapter.AgentAdapter{fake})) + if exact { + err = svc.RestartExactWithOptions(context.Background(), "fake", id, ResumeOptions{}) + } else { + err = svc.RestartWithOptions(context.Background(), id, ResumeOptions{}) + } + if errors.Is(err, ErrAmbiguousResume) && fake.stopped { + t.Fatalf("restart rejected ambiguity only after stopping provider: %v", err) + } + if err != nil { + t.Fatal(err) + } + if !fake.stopped || fake.resumed == nil || fake.resumed.ID != id { + t.Fatalf("validated restart was not carried through: stopped=%v resumed=%+v", fake.stopped, fake.resumed) + } + }) + } +} + +func TestExactResumeAttachRestartOptionsAllowExplicitLatest(t *testing.T) { + for _, action := range []string{"resume", "attach", "restart"} { + t.Run(action, func(t *testing.T) { + id := "11111111" + fake := &svcFakeAdapter{name: "fake", available: true, resumeKind: adapter.ResumeHeuristic, stopRemoves: true} + if action == "restart" { + fake.sessions = []adapter.Session{{ID: id, AgentType: "fake", SessionName: "uam-fake-11111111", Cwd: "/tmp/shared", ProcAlive: adapter.Alive}} + } + st, err := store.Open(filepath.Join(t.TempDir(), "sessions.json")) + if err != nil { + t.Fatal(err) + } + if err := st.Update(func(cfg *store.Config) error { + cfg.Sessions[store.Key("fake", id)] = store.SessionRecord{ID: id, Agent: "fake", Name: "chosen", SessionName: "uam-fake-11111111", Workdir: "/tmp/shared", Status: store.StatusActive} + cfg.Sessions[store.Key("fake", "22222222")] = store.SessionRecord{ID: "22222222", Agent: "fake", Name: "other", SessionName: "uam-fake-22222222", Workdir: "/tmp/shared", Status: store.StatusActive} + return nil + }); err != nil { + t.Fatal(err) + } + svc := NewService(st, adapter.NewRegistry([]adapter.AgentAdapter{fake})) + + var firstErr error + switch action { + case "resume": + firstErr = svc.ResumeBackgroundExactWithOptions(context.Background(), "fake", id, ResumeOptions{}) + case "attach": + _, firstErr = svc.AttachSpecExactWithOptions(context.Background(), "fake", id, ResumeOptions{}) + case "restart": + firstErr = svc.RestartExactWithOptions(context.Background(), "fake", id, ResumeOptions{}) + } + if !errors.Is(firstErr, ErrAmbiguousResume) { + t.Fatalf("preflight error = %v", firstErr) + } + if fake.stopped || fake.resumed != nil { + t.Fatalf("rejected preflight mutated provider: stopped=%v resumed=%+v", fake.stopped, fake.resumed) + } + + opts := ResumeOptions{AllowLatest: true} + switch action { + case "resume": + firstErr = svc.ResumeBackgroundExactWithOptions(context.Background(), "fake", id, opts) + case "attach": + _, firstErr = svc.AttachSpecExactWithOptions(context.Background(), "fake", id, opts) + case "restart": + firstErr = svc.RestartExactWithOptions(context.Background(), "fake", id, opts) + } + if firstErr != nil { + t.Fatal(firstErr) + } + if fake.resumed == nil || fake.resumed.ID != id { + t.Fatalf("resume target = %+v", fake.resumed) + } + }) + } +} + +func TestProviderExactServiceActionsDoNotCrossDuplicateIDs(t *testing.T) { + ctx := context.Background() + id := "same0001" + claude := &svcFakeAdapter{name: "claude", available: true, sessions: []adapter.Session{{ID: id, AgentType: "claude", SessionName: "uam-claude-same0001", ProcAlive: adapter.Alive}}} + codex := &svcFakeAdapter{name: "codex", available: true, sessions: []adapter.Session{{ID: id, AgentType: "codex", SessionName: "uam-codex-same0001", ProcAlive: adapter.Alive}}} + st, err := store.Open(filepath.Join(t.TempDir(), "sessions.json")) + if err != nil { + t.Fatal(err) + } + if err := st.Update(func(cfg *store.Config) error { + cfg.Sessions[store.Key("claude", id)] = RecordFromSession(claude.sessions[0], store.ModeYolo) + cfg.Sessions[store.Key("codex", id)] = RecordFromSession(codex.sessions[0], store.ModeYolo) + return nil + }); err != nil { + t.Fatal(err) + } + svc := NewService(st, adapter.NewRegistry([]adapter.AgentAdapter{claude, codex})) + + found, _, err := svc.FindExact(ctx, "codex", id) + if err != nil || found.AgentType != "codex" { + t.Fatalf("FindExact = %+v, %v", found, err) + } + if err := svc.RenameExact(ctx, "codex", id, "selected codex"); err != nil { + t.Fatal(err) + } + if err := svc.TogglePinExact(ctx, "codex", id); err != nil { + t.Fatal(err) + } + if _, err := svc.PeekExact(ctx, "codex", id); err != nil { + t.Fatal(err) + } + if err := svc.ReplyExact(ctx, "codex", id, "hello codex"); err != nil { + t.Fatal(err) + } + if _, err := svc.AttachSpecExact(ctx, "codex", id); err != nil { + t.Fatal(err) + } + if err := svc.StopExact(ctx, "codex", id, false); err != nil { + t.Fatal(err) + } + if claude.stopped || claude.peekedID != "" || claude.replied != "" || claude.attachedID != "" { + t.Fatalf("claude adapter was targeted: %+v", claude) + } + if codex.stoppedID != id || codex.peekedID != id || codex.replied != "hello codex" || codex.attachedID != id { + t.Fatalf("codex adapter did not receive exact actions: %+v", codex) + } + cfg, err := st.Load() + if err != nil { + t.Fatal(err) + } + if got := cfg.Sessions[store.Key("claude", id)]; got.Name != id || got.Pinned || got.Status == store.StatusClosedByUser { + t.Fatalf("claude record changed: %+v", got) + } + if got := cfg.Sessions[store.Key("codex", id)]; got.Name != "selected codex" || !got.Pinned || got.Status != store.StatusClosedByUser { + t.Fatalf("codex record not changed exactly: %+v", got) + } +} + +func TestProviderExactResumeAndRestartUseSelectedAdapter(t *testing.T) { + for _, action := range []string{"resume", "restart"} { + t.Run(action, func(t *testing.T) { + ctx := context.Background() + id := "same0002" + claude := &svcFakeAdapter{name: "claude", available: true, resumeKind: adapter.ResumeExact} + codexSession := adapter.Session{ID: id, AgentType: "codex", SessionName: "uam-codex-same0002", Cwd: t.TempDir(), ProcAlive: adapter.Exited} + codex := &svcFakeAdapter{name: "codex", available: true, resumeKind: adapter.ResumeExact} + st, err := store.Open(filepath.Join(t.TempDir(), "sessions.json")) + if err != nil { + t.Fatal(err) + } + if err := st.Update(func(cfg *store.Config) error { + cfg.Sessions[store.Key("claude", id)] = store.SessionRecord{ID: id, Agent: "claude", SessionName: "uam-claude-same0002", Workdir: t.TempDir(), Mode: store.ModeYolo} + cfg.Sessions[store.Key("codex", id)] = RecordFromSession(codexSession, store.ModeYolo) + return nil + }); err != nil { + t.Fatal(err) + } + svc := NewService(st, adapter.NewRegistry([]adapter.AgentAdapter{claude, codex})) + if action == "resume" { + err = svc.ResumeBackgroundExact(ctx, "codex", id) + } else { + err = svc.RestartExact(ctx, "codex", id) + } + if err != nil { + t.Fatal(err) + } + if claude.resumed != nil || codex.resumed == nil || codex.resumed.ID != id { + t.Fatalf("resume crossed provider: claude=%+v codex=%+v", claude.resumed, codex.resumed) + } + }) + } +} func (f *svcFakeAdapter) HasSession(ctx adapter.Context, id string) bool { return f.alive } func TestServiceWorkflow(t *testing.T) { @@ -294,7 +518,7 @@ func TestSortSessionsAndRecord(t *testing.T) { now := time.Now() sessions := []adapter.Session{{ID: "dead", ProcAlive: adapter.Exited, CreatedAt: now}, {ID: "live", ProcAlive: adapter.Alive, CreatedAt: now}, {ID: "p", ProcAlive: adapter.Exited, Pinned: true, CreatedAt: now}} SortSessions(sessions) - if sessions[0].ID != "p" || sessions[1].ID != "live" { + if sessions[0].ID != "live" || sessions[1].ID != "p" { t.Fatalf("order=%+v", sessions) } rec := RecordFromSession(adapter.Session{ID: "id", AgentType: "fake", CommandAlias: "ghcp", Prompt: "do work", Cwd: "/tmp", SessionName: "tm", CreatedAt: now}, "") @@ -306,17 +530,17 @@ func TestSortSessionsAndRecord(t *testing.T) { } } -func TestSortSessionsPushesClosedToBottom(t *testing.T) { +func TestSortSessionsGroupsAllStoppedBelowRunning(t *testing.T) { now := time.Now() - // Closed sessions belong below everything else, even pinned ones, so the - // Active group renders without interruption at the top. + // All exited sessions belong below running ones; Closed does not define a + // separate lifecycle partition, while pin order applies within STOPPED. sessions := []adapter.Session{ {ID: "closed-pinned", Pinned: true, Closed: true, ProcAlive: adapter.Exited, CreatedAt: now}, {ID: "live", ProcAlive: adapter.Alive, CreatedAt: now}, {ID: "stopped-active", ProcAlive: adapter.Exited, CreatedAt: now}, } SortSessions(sessions) - if sessions[0].ID != "live" || sessions[1].ID != "stopped-active" || sessions[2].ID != "closed-pinned" { + if sessions[0].ID != "live" || sessions[1].ID != "closed-pinned" || sessions[2].ID != "stopped-active" { t.Fatalf("order=%+v", sessions) } } @@ -651,6 +875,280 @@ func TestResumeBackgroundClearsClosedStatus(t *testing.T) { if cfg.Sessions[store.Key("fake", "12345678")].Status != store.StatusActive { t.Fatalf("status after resume = %q, want %q", cfg.Sessions[store.Key("fake", "12345678")].Status, store.StatusActive) } + if cfg.Sessions[store.Key("fake", "12345678")].LastExitCode != nil { + t.Fatal("successful resume must clear the previous exit code") + } +} + +func TestResumeBackgroundDoesNotEraseImmediateReplacementExit(t *testing.T) { + dir := t.TempDir() + st, err := store.Open(filepath.Join(dir, "sessions.json")) + if err != nil { + t.Fatal(err) + } + oldExit, replacementExit := 2, 17 + const sessionName = "uam-fake-12345678" + if err := st.Update(func(cfg *store.Config) error { + cfg.PutSession(store.Key("fake", "12345678"), store.SessionRecord{ + ID: "12345678", Agent: "fake", Workdir: dir, SessionName: sessionName, + Status: store.StatusActive, LastExitCode: &oldExit, + }) + return nil + }); err != nil { + t.Fatal(err) + } + fake := &svcFakeAdapter{name: "fake", available: true, resumeKind: adapter.ResumeExact} + fake.resumeHook = func(adapter.ResumeRequest) { + matched, err := st.TryRecordSessionExit(store.SessionExit{SessionName: sessionName, ExitCode: replacementExit}) + if err != nil || !matched { + t.Fatalf("record immediate replacement exit: matched=%v err=%v", matched, err) + } + } + svc := NewService(st, adapter.NewRegistry([]adapter.AgentAdapter{fake})) + + if err := svc.ResumeBackground(context.Background(), "12345678"); err != nil { + t.Fatal(err) + } + cfg, err := st.Load() + if err != nil { + t.Fatal(err) + } + rec := cfg.Sessions[store.Key("fake", "12345678")] + if rec.LastExitCode == nil || *rec.LastExitCode != replacementExit { + t.Fatalf("exit code after immediate replacement failure=%v, want %d", rec.LastExitCode, replacementExit) + } + if got := deadSessionFromRecord(rec, time.Now()).State; got != adapter.Failed { + t.Fatalf("dead replacement state=%q, want %q", got, adapter.Failed) + } +} + +func TestFailedConcurrentResumeCannotRollbackSuccessfulResume(t *testing.T) { + dir := t.TempDir() + st, err := store.Open(filepath.Join(dir, "sessions.json")) + if err != nil { + t.Fatal(err) + } + oldExit := 9 + const sessionName = "uam-fake-12345678" + if err := st.Update(func(cfg *store.Config) error { + cfg.PutSession(store.Key("fake", "12345678"), store.SessionRecord{ + ID: "12345678", Agent: "fake", Workdir: dir, SessionName: sessionName, + Status: store.StatusClosedByUser, LastExitCode: &oldExit, + }) + return nil + }); err != nil { + t.Fatal(err) + } + firstEntered := make(chan struct{}) + releaseFirst := make(chan struct{}) + calls := 0 + var callsMu sync.Mutex + fake := &svcFakeAdapter{name: "fake", available: true, resumeKind: adapter.ResumeExact} + fake.resumeFunc = func(req adapter.ResumeRequest) (adapter.Session, error) { + callsMu.Lock() + calls++ + call := calls + callsMu.Unlock() + if call == 1 { + close(firstEntered) + <-releaseFirst + return adapter.Session{}, errors.New("first launch failed") + } + return adapter.Session{ID: req.ID, AgentType: "fake", Cwd: req.Cwd, SessionName: req.SessionName, State: adapter.Active, ProcAlive: adapter.Alive}, nil + } + svc := NewService(st, adapter.NewRegistry([]adapter.AgentAdapter{fake})) + + firstDone := make(chan error, 1) + go func() { firstDone <- svc.ResumeBackground(context.Background(), "12345678") }() + <-firstEntered + if err := svc.ResumeBackground(context.Background(), "12345678"); err != nil { + t.Fatalf("second resume: %v", err) + } + close(releaseFirst) + if err := <-firstDone; err == nil { + t.Fatal("first resume unexpectedly succeeded") + } + + cfg, err := st.Load() + if err != nil { + t.Fatal(err) + } + rec := cfg.Sessions[store.Key("fake", "12345678")] + if rec.Status != store.StatusActive || rec.LastExitCode != nil { + t.Fatalf("winning resume lifecycle overwritten: status=%q exit=%v", rec.Status, rec.LastExitCode) + } +} + +func TestHeuristicResumeRejectsAmbiguousWorkspaceUnlessAllowed(t *testing.T) { + dir := t.TempDir() + st, err := store.Open(filepath.Join(dir, "sessions.json")) + if err != nil { + t.Fatal(err) + } + fake := &svcFakeAdapter{name: "fake", available: true} + svc := NewService(st, adapter.NewRegistry([]adapter.AgentAdapter{fake})) + if err := st.Update(func(cfg *store.Config) error { + for _, id := range []string{"11111111", "22222222"} { + cfg.PutSession(store.Key("fake", id), store.SessionRecord{ID: id, Agent: "fake", Name: id, Workdir: "/tmp/project", SessionName: "uam-fake-" + id, Status: store.StatusActive}) + } + return nil + }); err != nil { + t.Fatal(err) + } + if err := svc.ResumeBackground(context.Background(), "11111111"); !errors.Is(err, ErrAmbiguousResume) { + t.Fatalf("resume error=%v, want ErrAmbiguousResume", err) + } + if fake.resumed != nil { + t.Fatal("ambiguous heuristic resume must fail before provider launch") + } + if err := svc.ResumeBackgroundWithOptions(context.Background(), "11111111", ResumeOptions{AllowLatest: true}); err != nil { + t.Fatalf("allow-latest resume: %v", err) + } + if fake.resumed == nil { + t.Fatal("allow-latest must launch heuristic resume") + } +} + +func TestProductionProviderResumeKindMatrixThroughAmbiguityGuard(t *testing.T) { + tests := []struct { + name, provider, providerID string + newAdapter func() adapter.AgentAdapter + isolatedOMP bool + wantAmbiguous bool + }{ + {"opencode exact", "opencode", "ses_known123", func() adapter.AgentAdapter { return opencode.New(nil) }, false, false}, + {"opencode fallback", "opencode", "", func() adapter.AgentAdapter { return opencode.New(nil) }, false, true}, + {"claude exact", "claude", "abc12345-dead-beef-cafe-0123456789ab", func() adapter.AgentAdapter { return claude.New(nil) }, false, false}, + {"claude fallback", "claude", "", func() adapter.AgentAdapter { return claude.New(nil) }, false, true}, + {"omp isolated", "omp", "", func() adapter.AgentAdapter { return omp.New(nil) }, true, false}, + {"omp legacy", "omp", "", func() adapter.AgentAdapter { return omp.New(nil) }, false, true}, + {"codex remains heuristic", "codex", "legacy-value", func() adapter.AgentAdapter { return codex.New(nil) }, false, true}, + {"copilot derives exact UAM name", "copilot", "", func() adapter.AgentAdapter { return copilot.New(nil) }, false, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + binDir := t.TempDir() + executable := filepath.Join(binDir, tt.provider) + if err := os.WriteFile(executable, []byte("#!/bin/sh\nexit 0\n"), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", binDir) + state := t.TempDir() + t.Setenv("XDG_STATE_HOME", state) + id, other := "11111111-dead-beef-cafe-0123456789ab", "22222222-dead-beef-cafe-0123456789ab" + if tt.isolatedOMP { + dir := filepath.Join(state, "uam", "providers", "omp", id) + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + for path := filepath.Join(state, "uam"); path != dir; { + if err := os.Chmod(path, 0o700); err != nil { + t.Fatal(err) + } + next := filepath.Join(path, strings.Split(strings.TrimPrefix(dir, path+string(os.PathSeparator)), string(os.PathSeparator))[0]) + path = next + } + } + cfg := store.DefaultConfig() + for _, candidate := range []string{id, other} { + providerID := "" + if candidate == id { + providerID = tt.providerID + } + cfg.PutSession(store.Key(tt.provider, candidate), store.SessionRecord{ID: candidate, Agent: tt.provider, Workdir: "/tmp/project", SessionName: "uam-" + tt.provider + "-" + candidate[:8], ProviderSessionID: providerID, Status: store.StatusActive}) + } + svc := NewService(nil, adapter.NewRegistry([]adapter.AgentAdapter{tt.newAdapter()})) + _, _, _, err := svc.prepareResume(adapter.Session{ID: id, AgentType: tt.provider}, cfg, ResumeOptions{}) + if tt.wantAmbiguous { + if !errors.Is(err, ErrAmbiguousResume) { + t.Fatalf("error=%v, want ErrAmbiguousResume", err) + } + } else if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func TestUniqueHeuristicResumeStillWorks(t *testing.T) { + dir := t.TempDir() + st, err := store.Open(filepath.Join(dir, "sessions.json")) + if err != nil { + t.Fatal(err) + } + fake := &svcFakeAdapter{name: "fake", available: true} + svc := NewService(st, adapter.NewRegistry([]adapter.AgentAdapter{fake})) + if err := st.Update(func(cfg *store.Config) error { + cfg.PutSession(store.Key("fake", "11111111"), store.SessionRecord{ID: "11111111", Agent: "fake", Workdir: "/tmp/project", SessionName: "uam-fake-11111111", Status: store.StatusActive}) + return nil + }); err != nil { + t.Fatal(err) + } + if err := svc.ResumeBackground(context.Background(), "11111111"); err != nil { + t.Fatal(err) + } + if fake.resumed == nil { + t.Fatal("unique heuristic resume did not launch") + } +} + +func TestExactResumeIgnoresOtherSessionsInWorkspace(t *testing.T) { + dir := t.TempDir() + st, err := store.Open(filepath.Join(dir, "sessions.json")) + if err != nil { + t.Fatal(err) + } + fake := &svcFakeAdapter{name: "fake", available: true, resumeKind: adapter.ResumeExact} + svc := NewService(st, adapter.NewRegistry([]adapter.AgentAdapter{fake})) + if err := st.Update(func(cfg *store.Config) error { + for _, id := range []string{"11111111", "22222222"} { + cfg.PutSession(store.Key("fake", id), store.SessionRecord{ID: id, Agent: "fake", Workdir: "/tmp/project", SessionName: "uam-fake-" + id, ProviderSessionID: "provider-" + id, Status: store.StatusActive}) + } + return nil + }); err != nil { + t.Fatal(err) + } + if err := svc.ResumeBackground(context.Background(), "11111111"); err != nil { + t.Fatal(err) + } + if fake.resumed == nil { + t.Fatal("exact resume did not launch") + } +} + +func TestAmbiguousRestartDoesNotStopLiveSession(t *testing.T) { + dir := t.TempDir() + st, err := store.Open(filepath.Join(dir, "sessions.json")) + if err != nil { + t.Fatal(err) + } + live := adapter.Session{ID: "11111111", AgentType: "fake", Cwd: "/tmp/project", SessionName: "uam-fake-11111111", ProcAlive: adapter.Alive, State: adapter.Active} + fake := &svcFakeAdapter{name: "fake", available: true, sessions: []adapter.Session{live}, stopRemoves: true} + svc := NewService(st, adapter.NewRegistry([]adapter.AgentAdapter{fake})) + if err := st.Update(func(cfg *store.Config) error { + for _, id := range []string{"11111111", "22222222"} { + cfg.PutSession(store.Key("fake", id), store.SessionRecord{ID: id, Agent: "fake", Workdir: "/tmp/project", SessionName: "uam-fake-" + id, Status: store.StatusActive}) + } + return nil + }); err != nil { + t.Fatal(err) + } + if err := svc.Restart(context.Background(), "11111111"); !errors.Is(err, ErrAmbiguousResume) { + t.Fatalf("restart error=%v, want ErrAmbiguousResume", err) + } + if fake.stopped { + t.Fatal("ambiguous restart must fail before stopping the live session") + } +} + +func TestDeadSessionStateReflectsNaturalExitCode(t *testing.T) { + zero, crash := 0, 7 + if got := deadSessionFromRecord(store.SessionRecord{LastExitCode: &zero}, time.Now()).State; got != adapter.Completed { + t.Fatalf("natural exit 0 state=%q, want Completed", got) + } + if got := deadSessionFromRecord(store.SessionRecord{LastExitCode: &crash}, time.Now()).State; got != adapter.Failed { + t.Fatalf("natural crash state=%q, want Failed", got) + } } // Restart replaces a live session's agent process in place: stop the backend diff --git a/internal/app/workspace_grouping.go b/internal/app/workspace_grouping.go new file mode 100644 index 0000000..ff989f3 --- /dev/null +++ b/internal/app/workspace_grouping.go @@ -0,0 +1,226 @@ +package app + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" + "github.com/RandomCodeSpace/unified-agent-manager/internal/displaytext" + "github.com/charmbracelet/x/ansi" +) + +const unknownWorkspaceKey = "(unknown workspace)" + +// workspaceKey returns the stable presentation key for a working directory. +// It deliberately does not resolve symlinks: rendering must not perform I/O or +// unexpectedly merge two paths that the user chose to keep distinct. +func workspaceKey(cwd string) string { + if strings.TrimSpace(cwd) == "" { + return unknownWorkspaceKey + } + abs, err := filepath.Abs(cwd) + if err != nil { + return filepath.Clean(cwd) + } + return filepath.Clean(abs) +} + +// projectSessions keeps the service's canonical order byte-for-byte when +// grouping is disabled. When enabled, it groups only inside each canonical +// lifecycle/pin partition, ordering workspaces by their first occurrence and +// retaining canonical order inside each workspace. +func projectSessions(canonical []adapter.Session, grouped bool) []adapter.Session { + out := append([]adapter.Session(nil), canonical...) + if !grouped || len(out) < 2 { + return out + } + projected := make([]adapter.Session, 0, len(out)) + for start := 0; start < len(out); { + end := start + 1 + for end < len(out) && samePartition(out[start], out[end]) { + end++ + } + order := make([]string, 0, end-start) + groups := make(map[string][]adapter.Session, end-start) + for _, sess := range out[start:end] { + key := workspaceKey(sess.Cwd) + if _, seen := groups[key]; !seen { + order = append(order, key) + } + groups[key] = append(groups[key], sess) + } + for _, key := range order { + projected = append(projected, groups[key]...) + } + start = end + } + return projected +} + +func workspaceDisplayName(key string) string { + if key == unknownWorkspaceKey { + return "Unknown workspace" + } + name := filepath.Base(key) + if name == "." || name == string(filepath.Separator) || name == "" { + return key + } + return name +} + +func workspaceHeadingLine(key string, count, width int) string { + label := " WORKSPACE " + displaytext.Sanitize(workspaceDisplayName(key)) + if key != unknownWorkspaceKey { + label += " · " + displaytext.Sanitize(key) + } + right := fmt.Sprintf("%d", count) + available := max(1, width-ansi.StringWidth(right)-2) + return ansi.Truncate(label, available, "…") + " " + right +} + +type groupedRenderLine struct { + text string + sessionIndex int + workspace bool +} + +type groupedRenderContext struct { + width, nameWidth, taskWidth int + showTask bool + live map[string]int + warned map[string]bool +} + +func (m Model) groupedSessionListLines(width, budget int, class LayoutClass) []string { + if budget <= 0 { + return nil + } + if len(m.sessions) == 0 { + return takeLines([]string{m.renderSectionAtWidth("SESSIONS", "0", width), " " + hintStyle.Render("no sessions")}, budget) + } + entries := m.groupedRenderEntries(width, class) + return windowGroupedEntries(entries, m.selected, budget) +} + +func (m Model) groupedRenderEntries(width int, class LayoutClass) []groupedRenderLine { + stopped := countStoppedSessions(m.sessions) + right := fmt.Sprintf("%d", len(m.sessions)) + if class == LayoutCompact && stopped > 0 { + right = fmt.Sprintf("%d · %d stopped", len(m.sessions), stopped) + } + entries := []groupedRenderLine{{text: m.renderSectionAtWidth("SESSIONS", right, width), sessionIndex: -1}} + liveByWorkspace := liveWorkspaceCounts(m.sessions) + warnedWorkspaces := make(map[string]bool) + nameWidth, taskWidth, showTask := tableWidthsFor(width, class) + ctx := groupedRenderContext{width: width, nameWidth: nameWidth, taskWidth: taskWidth, showTask: showTask, live: liveByWorkspace, warned: warnedWorkspaces} + var lastLifecycle adapter.ProcLiveness + for start := 0; start < len(m.sessions); { + partitionEnd := sessionPartitionEnd(m.sessions, start) + if class != LayoutCompact && m.sessions[start].ProcAlive != lastLifecycle { + entries = append(entries, groupedRenderLine{text: m.renderSectionAtWidth(lifecycleLabel(m.sessions[start]), "", width), sessionIndex: -1}) + lastLifecycle = m.sessions[start].ProcAlive + } + entries = append(entries, m.workspacePartitionEntries(start, partitionEnd, ctx)...) + start = partitionEnd + } + return entries +} + +func windowGroupedEntries(entries []groupedRenderLine, selected, budget int) []string { + selectedLine := selectedGroupedLine(entries, selected) + start, end := visibleWindow(len(entries), selectedLine, budget) + lines := make([]string, 0, end-start) + for _, entry := range entries[start:end] { + lines = append(lines, entry.text) + } + keepSelectedWorkspaceHeading(entries, lines, selectedLine, start, end) + return lines +} + +func selectedGroupedLine(entries []groupedRenderLine, selected int) int { + for i, entry := range entries { + if entry.sessionIndex == selected { + return i + } + } + return 0 +} + +func keepSelectedWorkspaceHeading(entries []groupedRenderLine, lines []string, selectedLine, start, end int) { + if selectedLine < start || selectedLine >= end || len(lines) == 0 { + return + } + for i := selectedLine - 1; i >= 0; i-- { + if entries[i].workspace { + if i < start { + lines[0] = entries[i].text + } + return + } + } +} + +func (m Model) workspacePartitionEntries(start, end int, ctx groupedRenderContext) []groupedRenderLine { + var entries []groupedRenderLine + for groupStart := start; groupStart < end; { + key := workspaceKey(m.sessions[groupStart].Cwd) + groupEnd := workspaceGroupEnd(m.sessions, groupStart, end, key) + entries = append(entries, groupedRenderLine{text: workspaceHeadingLine(key, groupEnd-groupStart, ctx.width), sessionIndex: -1, workspace: true}) + if ctx.live[key] > 1 && !ctx.warned[key] { + warning := fmt.Sprintf(" ⚠ %d sessions share this workspace", ctx.live[key]) + entries = append(entries, groupedRenderLine{text: ansi.Truncate(warnStyle.Render(warning), ctx.width, "…"), sessionIndex: -1}) + ctx.warned[key] = true + } + for i := groupStart; i < groupEnd; i++ { + row := ansi.Truncate(renderRow(m.sessions[i], i == m.selected, ctx.nameWidth, ctx.taskWidth, ctx.showTask), ctx.width, "…") + entries = append(entries, groupedRenderLine{text: row, sessionIndex: i}) + } + groupStart = groupEnd + } + return entries +} + +func countStoppedSessions(sessions []adapter.Session) int { + count := 0 + for _, sess := range sessions { + if sess.ProcAlive == adapter.Exited { + count++ + } + } + return count +} + +func liveWorkspaceCounts(sessions []adapter.Session) map[string]int { + counts := make(map[string]int) + for _, sess := range sessions { + key := workspaceKey(sess.Cwd) + if key != unknownWorkspaceKey && sess.ProcAlive == adapter.Alive { + counts[key]++ + } + } + return counts +} + +func sessionPartitionEnd(sessions []adapter.Session, start int) int { + end := start + 1 + for end < len(sessions) && samePartition(sessions[start], sessions[end]) { + end++ + } + return end +} + +func workspaceGroupEnd(sessions []adapter.Session, start, limit int, key string) int { + end := start + 1 + for end < limit && workspaceKey(sessions[end].Cwd) == key { + end++ + } + return end +} + +func lifecycleLabel(sess adapter.Session) string { + if sess.ProcAlive == adapter.Exited { + return "STOPPED" + } + return "RUNNING" +} diff --git a/internal/app/workspace_grouping_repair_test.go b/internal/app/workspace_grouping_repair_test.go new file mode 100644 index 0000000..225c0c4 --- /dev/null +++ b/internal/app/workspace_grouping_repair_test.go @@ -0,0 +1,245 @@ +package app + +import ( + "path/filepath" + "reflect" + "testing" + "time" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" + "github.com/RandomCodeSpace/unified-agent-manager/internal/store" + tea "github.com/charmbracelet/bubbletea" +) + +func TestGroupToggleWaitsForPendingReorderBeforeReload(t *testing.T) { + persistStarted := make(chan struct{}) + allowPersist := make(chan struct{}) + reloadStarted := make(chan struct{}) + m := NewWithDeps(nil, nil) + m.sessions = []adapter.Session{ + {ID: "a", AgentType: "fake", Cwd: "/tmp/a", ProcAlive: adapter.Alive, SortIndex: 0}, + {ID: "b", AgentType: "fake", Cwd: "/tmp/a", ProcAlive: adapter.Alive, SortIndex: 1}, + } + m.persistSortIndices = func([]adapter.Session) error { + close(persistStarted) + <-allowPersist + return nil + } + m.reloadSessions = func() sessionsLoadedMsg { + close(reloadStarted) + return sessionsLoadedMsg{sessions: m.sessions, groupByDir: true} + } + m.selected = 0 + _ = m.moveSession(1) + + model, cmd := m.handleKey(tea.KeyMsg{Type: tea.KeyCtrlS}) + m = model.(Model) + if cmd == nil { + t.Fatal("pending reorder toggle needs a sequenced command") + } + done := make(chan tea.Msg, 1) + go func() { done <- cmd() }() + select { + case <-persistStarted: + case <-time.After(time.Second): + t.Fatal("reorder persistence did not start") + } + select { + case <-reloadStarted: + t.Fatal("reload began before reorder persistence completed") + case <-time.After(30 * time.Millisecond): + } + close(allowPersist) + select { + case <-reloadStarted: + case <-time.After(time.Second): + t.Fatal("reload did not begin after reorder persistence completed") + } + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("sequenced toggle command did not complete") + } +} + +func TestGroupedFlushDoesNotOverwriteConcurrentUnrelatedProviderRecord(t *testing.T) { + dir := t.TempDir() + st, err := store.Open(filepath.Join(dir, "sessions.json")) + if err != nil { + t.Fatal(err) + } + sessions := []adapter.Session{ + {ID: "a1", AgentType: "fake", Cwd: filepath.Join(dir, "a"), ProcAlive: adapter.Alive, SortIndex: 0}, + {ID: "a2", AgentType: "fake", Cwd: filepath.Join(dir, "a"), ProcAlive: adapter.Alive, SortIndex: 1}, + {ID: "a1", AgentType: "other", Cwd: filepath.Join(dir, "other"), ProcAlive: adapter.Alive, SortIndex: 2}, + } + svc := NewService(st, nil) + if err := svc.UpdateSortIndices(sessions); err != nil { + t.Fatal(err) + } + m := NewWithDeps(st, nil) + m.sessions = sessions + m.groupByDir = true + m.selected = 0 + _ = m.moveSession(1) + if err := st.Update(func(cfg *store.Config) error { + rec := cfg.Sessions[store.Key("other", "a1")] + rec.SortIndex = 99 + cfg.Sessions[store.Key("other", "a1")] = rec + return nil + }); err != nil { + t.Fatal(err) + } + drainCmd(m.flushReorder()) + + cfg, err := st.Load() + if err != nil { + t.Fatal(err) + } + if got := cfg.Sessions[store.Key("other", "a1")].SortIndex; got != 99 { + t.Fatalf("stale grouped flush overwrote unrelated provider record: got %d want 99", got) + } +} + +func TestGroupedReorderNormalizesCollidingIndicesWithinAllowedGroup(t *testing.T) { + dir := t.TempDir() + st, err := store.Open(filepath.Join(dir, "sessions.json")) + if err != nil { + t.Fatal(err) + } + m := NewWithDeps(st, nil) + m.groupByDir = true + m.sessions = []adapter.Session{ + {ID: "a", AgentType: "fake", Cwd: dir, ProcAlive: adapter.Alive, SortIndex: 0}, + {ID: "b", AgentType: "fake", Cwd: dir, ProcAlive: adapter.Alive, SortIndex: 0}, + {ID: "c", AgentType: "fake", Cwd: dir, ProcAlive: adapter.Alive, SortIndex: 0}, + } + m.selected = 0 + _ = m.moveSession(1) + if got := sessionIDs(m.sessions); !reflect.DeepEqual(got, []string{"b", "a", "c"}) { + t.Fatalf("requested adjacent pair did not swap: %v", got) + } + drainCmd(m.flushReorder()) + cfg, err := st.Load() + if err != nil { + t.Fatal(err) + } + reloaded := append([]adapter.Session(nil), m.sessions...) + for i := range reloaded { + reloaded[i].SortIndex = cfg.Sessions[store.Key(reloaded[i].AgentType, reloaded[i].ID)].SortIndex + } + SortSessions(reloaded) + if got := sessionIDs(reloaded); !reflect.DeepEqual(got, []string{"b", "a", "c"}) { + t.Fatalf("colliding indices changed a third row after persist/reload: %v", got) + } + for i, sess := range reloaded { + if sess.SortIndex != i { + t.Fatalf("normalized group index for %s = %d, want %d", sess.ID, sess.SortIndex, i) + } + } +} + +func TestFailedSequencedReorderDoesNotPersistGroupSetting(t *testing.T) { + dir := t.TempDir() + st, err := store.Open(filepath.Join(dir, "sessions.json")) + if err != nil { + t.Fatal(err) + } + m := NewWithDeps(st, nil) + m.sessions = []adapter.Session{ + {ID: "a", AgentType: "fake", Cwd: dir, ProcAlive: adapter.Alive, SortIndex: 0}, + {ID: "b", AgentType: "fake", Cwd: dir, ProcAlive: adapter.Alive, SortIndex: 1}, + } + m.persistSortIndices = func([]adapter.Session) error { return errTestBoom } + m.selected = 0 + _ = m.moveSession(1) + model, cmd := m.handleKey(tea.KeyMsg{Type: tea.KeyCtrlS}) + m = model.(Model) + msg := cmd() + model, _ = m.Update(msg) + m = model.(Model) + if m.groupByDir || m.message == "" { + t.Fatalf("failed reorder persistence must revert grouping with feedback: grouped=%v message=%q", m.groupByDir, m.message) + } + cfg, err := st.Load() + if err != nil { + t.Fatal(err) + } + if cfg.UI.GroupByDir { + t.Fatal("view setting was persisted even though prerequisite reorder write failed") + } +} + +func TestRapidGroupToggleLastGenerationWinsModelAndStore(t *testing.T) { + dir := t.TempDir() + st, err := store.Open(filepath.Join(dir, "sessions.json")) + if err != nil { + t.Fatal(err) + } + firstPersistStarted := make(chan struct{}) + allowFirstPersist := make(chan struct{}) + m := NewWithDeps(st, nil) + m.sessions = []adapter.Session{ + {ID: "a", AgentType: "fake", Cwd: dir, ProcAlive: adapter.Alive, SortIndex: 0}, + {ID: "b", AgentType: "fake", Cwd: dir, ProcAlive: adapter.Alive, SortIndex: 1}, + } + m.persistSortIndices = func(sessions []adapter.Session) error { + if len(sessions) > 0 { + close(firstPersistStarted) + <-allowFirstPersist + } + return nil + } + m.reloadSessions = func() sessionsLoadedMsg { + cfg, err := st.Load() + return sessionsLoadedMsg{sessions: append([]adapter.Session(nil), m.sessions...), groupByDir: cfg.UI.GroupByDir, err: err} + } + m.selected = 0 + _ = m.moveSession(1) + + model, firstCmd := m.handleKey(tea.KeyMsg{Type: tea.KeyCtrlS}) + m = model.(Model) + firstDone := make(chan tea.Msg, 1) + go func() { firstDone <- firstCmd() }() + select { + case <-firstPersistStarted: + case <-time.After(time.Second): + t.Fatal("first toggle did not block in reorder persistence") + } + + model, secondCmd := m.handleKey(tea.KeyMsg{Type: tea.KeyCtrlS}) + m = model.(Model) + secondMsg := secondCmd() + model, _ = m.Update(secondMsg) + m = model.(Model) + if m.groupByDir { + t.Fatal("second toggle should leave model ungrouped") + } + cfg, err := st.Load() + if err != nil { + t.Fatal(err) + } + if cfg.UI.GroupByDir { + t.Fatal("second toggle should persist ungrouped setting") + } + + close(allowFirstPersist) + var firstMsg tea.Msg + select { + case firstMsg = <-firstDone: + case <-time.After(time.Second): + t.Fatal("first toggle did not finish after release") + } + model, _ = m.Update(firstMsg) + m = model.(Model) + if m.groupByDir { + t.Fatal("stale first completion overwrote latest model toggle") + } + cfg, err = st.Load() + if err != nil { + t.Fatal(err) + } + if cfg.UI.GroupByDir { + t.Fatal("stale first command overwrote latest stored toggle") + } +} diff --git a/internal/app/workspace_grouping_test.go b/internal/app/workspace_grouping_test.go new file mode 100644 index 0000000..f2a43b6 --- /dev/null +++ b/internal/app/workspace_grouping_test.go @@ -0,0 +1,319 @@ +package app + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" + "github.com/RandomCodeSpace/unified-agent-manager/internal/store" + tea "github.com/charmbracelet/bubbletea" +) + +func TestWorkspaceProjectionPreservesCanonicalOrderWhenDisabled(t *testing.T) { + sessions := groupingFixture(t) + want := sessionIDs(sessions) + + got := projectSessions(sessions, false) + if !reflect.DeepEqual(sessionIDs(got), want) { + t.Fatalf("grouping off changed canonical order: got %v want %v", sessionIDs(got), want) + } +} + +func TestWorkspaceProjectionGroupsWithinLifecycleAndPinPartitions(t *testing.T) { + sessions := groupingFixture(t) + got := projectSessions(sessions, true) + want := []string{"a1", "a3", "a2", "u1", "u2", "c1", "c2"} + if !reflect.DeepEqual(sessionIDs(got), want) { + t.Fatalf("grouped projection = %v, want %v", sessionIDs(got), want) + } +} + +func TestWorkspaceKeyIsAbsoluteCleanAndDoesNotResolveSymlinks(t *testing.T) { + if got := workspaceKey(""); got != unknownWorkspaceKey { + t.Fatalf("blank cwd key = %q, want %q", got, unknownWorkspaceKey) + } + + root := t.TempDir() + realDir := filepath.Join(root, "real") + if err := os.Mkdir(realDir, 0o755); err != nil { + t.Fatal(err) + } + linkDir := filepath.Join(root, "link") + if err := os.Symlink(realDir, linkDir); err != nil { + t.Fatal(err) + } + got := workspaceKey(filepath.Join(linkDir, "..", "link")) + if got != linkDir { + t.Fatalf("workspace key resolved or failed to clean symlink path: got %q want %q", got, linkDir) + } +} + +func TestGroupToggleRestoresProjectionAndSelectedIdentity(t *testing.T) { + m := NewWithDeps(nil, nil) + m.sessions = groupingFixture(t) + m.selected = 2 // a3 + + model, _ := m.handleKey(tea.KeyMsg{Type: tea.KeyCtrlS}) + m = model.(Model) + if got := sessionIDs(m.sessions); !reflect.DeepEqual(got, []string{"a1", "a3", "a2", "u1", "u2", "c1", "c2"}) { + t.Fatalf("toggle on projection = %v", got) + } + if selected, ok := m.selectedSession(); !ok || selected.ID != "a3" { + t.Fatalf("toggle on changed selection: %+v, ok=%v", selected, ok) + } + + model, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyCtrlS}) + m = model.(Model) + if got := sessionIDs(m.sessions); !reflect.DeepEqual(got, []string{"a1", "a2", "a3", "u1", "u2", "c1", "c2"}) { + t.Fatalf("toggle off did not restore canonical projection: %v", got) + } + if selected, ok := m.selectedSession(); !ok || selected.ID != "a3" { + t.Fatalf("toggle off changed selection: %+v, ok=%v", selected, ok) + } +} + +func TestGroupedViewShowsBoundedWorkspaceHeadingAndLiveSharingWarning(t *testing.T) { + root := filepath.Join(t.TempDir(), "alpha-workspace") + m := NewWithDeps(nil, nil) + m.groupByDir = true + m.sessions = []adapter.Session{ + {ID: "a", AgentType: "fake", DisplayName: "a", Cwd: root, ProcAlive: adapter.Alive}, + {ID: "b", AgentType: "fake", DisplayName: "b", Cwd: filepath.Join(root, "."), ProcAlive: adapter.Alive}, + {ID: "dead", AgentType: "fake", DisplayName: "dead", Cwd: root, ProcAlive: adapter.Exited}, + } + m = m.handleWindowSize(tea.WindowSizeMsg{Width: 44, Height: 20}) + view := m.View() + assertViewGeometry(t, view, 44, 20) + for _, want := range []string{"alpha-workspace", "3", "⚠ 2 sessions share this workspace"} { + if !strings.Contains(view, want) { + t.Fatalf("grouped view missing %q:\n%s", want, view) + } + } +} + +func TestGroupedViewCountsLiveWorkspaceSharingAcrossPinPartitions(t *testing.T) { + root := filepath.Join(t.TempDir(), "shared-across-pins") + m := NewWithDeps(nil, nil) + m.groupByDir = true + m.sessions = projectSessions([]adapter.Session{ + {ID: "pinned", AgentType: "fake", Cwd: root, ProcAlive: adapter.Alive, Pinned: true}, + {ID: "plain", AgentType: "fake", Cwd: root, ProcAlive: adapter.Alive}, + }, true) + m = m.handleWindowSize(tea.WindowSizeMsg{Width: 80, Height: 30}) + view := m.View() + if got := strings.Count(view, "⚠ 2 sessions share this workspace"); got != 1 { + t.Fatalf("sharing count must span pin partitions and render once, got %d:\n%s", got, view) + } +} + +func TestGroupedViewHandlesBlankWorkspaceWithoutSharingWarning(t *testing.T) { + m := NewWithDeps(nil, nil) + m.groupByDir = true + m.sessions = []adapter.Session{ + {ID: "blank", AgentType: "fake", DisplayName: "blank", ProcAlive: adapter.Alive}, + {ID: "also-blank", AgentType: "fake", DisplayName: "also-blank", ProcAlive: adapter.Alive}, + {ID: "closed", AgentType: "fake", DisplayName: "closed", ProcAlive: adapter.Exited, Closed: true}, + } + m = m.handleWindowSize(tea.WindowSizeMsg{Width: 80, Height: 30}) + view := m.View() + assertViewGeometry(t, view, 80, 30) + if !strings.Contains(view, "Unknown workspace") { + t.Fatalf("blank cwd needs a safe heading:\n%s", view) + } + if strings.Contains(view, "sessions share this workspace") { + t.Fatalf("closed/non-live sessions must not produce a sharing warning:\n%s", view) + } +} + +func TestGroupedReorderRejectsWorkspaceBoundaryWithoutSideEffects(t *testing.T) { + m := NewWithDeps(nil, nil) + m.groupByDir = true + m.sessions = []adapter.Session{ + {ID: "a", AgentType: "fake", Cwd: "/tmp/a", ProcAlive: adapter.Alive}, + {ID: "b", AgentType: "fake", Cwd: "/tmp/b", ProcAlive: adapter.Alive}, + } + m.selected = 0 + m.peekOpen = true + m.peekTargetAgent = "fake" + m.peekTargetID = "a" + m.peekText = "keep this tail" + + if cmd := m.moveSession(1); cmd != nil { + t.Fatal("cross-workspace move must not schedule persistence") + } + if m.selected != 0 || m.reorderPending || m.reorderSeq != 0 { + t.Fatalf("rejected move changed selection or persistence state: selected=%d pending=%v seq=%d", m.selected, m.reorderPending, m.reorderSeq) + } + if m.peekTargetID != "a" || m.peekText != "keep this tail" { + t.Fatalf("rejected move changed peek state: target=%q text=%q", m.peekTargetID, m.peekText) + } + if got := sessionIDs(m.sessions); !reflect.DeepEqual(got, []string{"a", "b"}) { + t.Fatalf("rejected move changed rows: %v", got) + } + if !strings.Contains(m.message, "workspace") { + t.Fatalf("workspace boundary feedback = %q", m.message) + } +} + +func TestGroupedReorderAllowsNormalizedSameWorkspace(t *testing.T) { + root := t.TempDir() + m := NewWithDeps(nil, nil) + m.groupByDir = true + m.sessions = []adapter.Session{ + {ID: "a", AgentType: "fake", Cwd: root, ProcAlive: adapter.Alive}, + {ID: "b", AgentType: "fake", Cwd: filepath.Join(root, "sub", ".."), ProcAlive: adapter.Alive}, + } + m.selected = 0 + if cmd := m.moveSession(1); cmd == nil { + t.Fatal("same normalized workspace move must schedule persistence") + } + if got := sessionIDs(m.sessions); !reflect.DeepEqual(got, []string{"b", "a"}) || m.selected != 1 || !m.reorderPending { + t.Fatalf("allowed grouped reorder did not apply: ids=%v selected=%d pending=%v", got, m.selected, m.reorderPending) + } +} + +func TestGroupedReorderSwapsOnlyMovedSortIndicesWhenGroupingTurnsOff(t *testing.T) { + root := t.TempDir() + m := NewWithDeps(nil, nil) + m.sessions = []adapter.Session{ + {ID: "a1", AgentType: "fake", Cwd: filepath.Join(root, "a"), ProcAlive: adapter.Alive, SortIndex: 0}, + {ID: "b", AgentType: "fake", Cwd: filepath.Join(root, "b"), ProcAlive: adapter.Alive, SortIndex: 1}, + {ID: "a2", AgentType: "fake", Cwd: filepath.Join(root, "a"), ProcAlive: adapter.Alive, SortIndex: 2}, + } + m.setGroupByDir(true) + if got := sessionIDs(m.sessions); !reflect.DeepEqual(got, []string{"a1", "a2", "b"}) { + t.Fatalf("initial grouped projection = %v", got) + } + m.selected = 0 + if cmd := m.moveSession(1); cmd == nil { + t.Fatal("within-workspace move must schedule persistence") + } + if m.sessions[0].SortIndex != 0 || m.sessions[1].SortIndex != 2 || m.sessions[2].SortIndex != 1 { + t.Fatalf("move must swap only the pair's existing sort indices: %+v", m.sessions) + } + + m.setGroupByDir(false) + if got := sessionIDs(m.sessions); !reflect.DeepEqual(got, []string{"a2", "b", "a1"}) { + t.Fatalf("ungrouped order lost unrelated canonical interleaving: %v", got) + } +} + +func TestGroupToggleCapturesPendingReorderBeforeProjection(t *testing.T) { + root := t.TempDir() + m := NewWithDeps(nil, nil) + m.sessions = []adapter.Session{ + {ID: "a1", AgentType: "fake", Cwd: filepath.Join(root, "a"), ProcAlive: adapter.Alive, SortIndex: 0}, + {ID: "b", AgentType: "fake", Cwd: filepath.Join(root, "b"), ProcAlive: adapter.Alive, SortIndex: 1}, + {ID: "a2", AgentType: "fake", Cwd: filepath.Join(root, "a"), ProcAlive: adapter.Alive, SortIndex: 2}, + } + m.setGroupByDir(true) + m.selected = 0 + _ = m.moveSession(1) + if !m.reorderPending { + t.Fatal("precondition: grouped reorder should be pending") + } + + model, cmd := m.handleKey(tea.KeyMsg{Type: tea.KeyCtrlS}) + m = model.(Model) + if cmd == nil { + t.Fatal("toggle should retain commands for the captured reorder and view setting") + } + if m.reorderPending { + t.Fatal("toggle must capture and clear the pending reorder before reprojecting") + } + if got := sessionIDs(m.sessions); !reflect.DeepEqual(got, []string{"a2", "b", "a1"}) { + t.Fatalf("toggle lost pending grouped move: %v", got) + } +} + +func TestGroupedReorderPersistsPairIndicesWithoutReindexingOtherWorkspaces(t *testing.T) { + dir := t.TempDir() + st, err := store.Open(filepath.Join(dir, "sessions.json")) + if err != nil { + t.Fatal(err) + } + m := NewWithDeps(st, nil) + m.sessions = []adapter.Session{ + {ID: "a1", AgentType: "fake", Cwd: filepath.Join(dir, "a"), ProcAlive: adapter.Alive, SortIndex: 0}, + {ID: "b", AgentType: "fake", Cwd: filepath.Join(dir, "b"), ProcAlive: adapter.Alive, SortIndex: 1}, + {ID: "a2", AgentType: "fake", Cwd: filepath.Join(dir, "a"), ProcAlive: adapter.Alive, SortIndex: 2}, + } + if err := m.service.UpdateSortIndices(m.sessions); err != nil { + t.Fatal(err) + } + m.setGroupByDir(true) + m.selected = 0 + _ = m.moveSession(1) + drainCmd(m.flushReorder()) + + cfg, err := st.Load() + if err != nil { + t.Fatal(err) + } + got := map[string]int{} + for _, id := range []string{"a1", "a2", "b"} { + got[id] = cfg.Sessions[store.Key("fake", id)].SortIndex + } + want := map[string]int{"a1": 2, "a2": 0, "b": 1} + if !reflect.DeepEqual(got, want) { + t.Fatalf("persisted grouped indices = %v, want %v", got, want) + } +} + +func TestGroupedNarrowWindowKeepsSelectedWorkspaceHeading(t *testing.T) { + root := filepath.Join(t.TempDir(), "selected-workspace") + m := NewWithDeps(nil, nil) + m.groupByDir = true + for i := 0; i < 10; i++ { + m.sessions = append(m.sessions, adapter.Session{ + ID: string(rune('a' + i)), + AgentType: "fake", + DisplayName: "row-" + string(rune('a'+i)), + Cwd: root, + ProcAlive: adapter.Alive, + SortIndex: i, + }) + } + m.selected = 6 + lines := m.groupedSessionListLines(44, 4, LayoutCompact) + view := strings.Join(lines, "\n") + if !strings.Contains(view, "selected-workspace") || !strings.Contains(view, "row-g") { + t.Fatalf("scrolled grouped window orphaned selected row from its heading:\n%s", view) + } +} + +func TestWorkspaceHeadingIncludesPathAndCountWhenTheyFit(t *testing.T) { + line := workspaceHeadingLine("/tmp/ws", 2, 44) + for _, want := range []string{"ws", "/tmp/ws", "2"} { + if !strings.Contains(line, want) { + t.Fatalf("workspace heading missing %q: %q", want, line) + } + } +} + +func groupingFixture(t *testing.T) []adapter.Session { + t.Helper() + root := t.TempDir() + sessions := []adapter.Session{ + {ID: "a1", AgentType: "fake", Cwd: filepath.Join(root, "one"), ProcAlive: adapter.Alive, Pinned: true, SortIndex: 0}, + {ID: "a2", AgentType: "fake", Cwd: filepath.Join(root, "two"), ProcAlive: adapter.Alive, Pinned: true, SortIndex: 1}, + {ID: "a3", AgentType: "fake", Cwd: filepath.Join(root, "one", "."), ProcAlive: adapter.Alive, Pinned: true, SortIndex: 2}, + {ID: "u1", AgentType: "fake", Cwd: filepath.Join(root, "two"), ProcAlive: adapter.Alive, SortIndex: 3}, + {ID: "u2", AgentType: "fake", Cwd: filepath.Join(root, "one"), ProcAlive: adapter.Alive, SortIndex: 4}, + {ID: "c1", AgentType: "fake", Cwd: filepath.Join(root, "two"), ProcAlive: adapter.Exited, Closed: true, SortIndex: 5}, + {ID: "c2", AgentType: "fake", Cwd: filepath.Join(root, "one"), ProcAlive: adapter.Exited, Closed: true, SortIndex: 6}, + } + SortSessions(sessions) + return sessions +} + +func sessionIDs(sessions []adapter.Session) []string { + ids := make([]string, len(sessions)) + for i := range sessions { + ids[i] = sessions[i].ID + } + return ids +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 58c0c1d..d0a8194 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -12,6 +12,7 @@ import ( "strings" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/term" "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" "github.com/RandomCodeSpace/unified-agent-manager/internal/agents" @@ -63,13 +64,13 @@ func Usage() { fmt.Fprintln(os.Stderr, " uam open the TUI") fmt.Fprintln(os.Stderr, " uam new guided dispatch wizard") fmt.Fprintln(os.Stderr, " uam dispatch [--safe] [--alias ] [#session-name] [prompt]") - fmt.Fprintln(os.Stderr, " uam attach ") + fmt.Fprintln(os.Stderr, " uam attach [--allow-latest] ") fmt.Fprintln(os.Stderr, " uam last") fmt.Fprintln(os.Stderr, " uam version") fmt.Fprintln(os.Stderr, " uam ls [--json]") fmt.Fprintln(os.Stderr, " uam peek ") fmt.Fprintln(os.Stderr, " uam stop ") - fmt.Fprintln(os.Stderr, " uam restart stop the agent and resume it in place") + fmt.Fprintln(os.Stderr, " uam restart [--allow-latest] stop the agent and resume it in place") fmt.Fprintln(os.Stderr, " uam rm ") fmt.Fprintln(os.Stderr, " uam kill-all stop every managed session") fmt.Fprintln(os.Stderr, " uam notify-closed (internal: flag a record user-closed)") @@ -160,11 +161,16 @@ func runCommand(ctx context.Context, svc *app.Service, args []string, runTUI fun case "notify-closed": return runNotifyClosed(svc, args[1:]) case "attach": - id, err := requireArg(args[1:], "attach requires ") + fs := flag.NewFlagSet("attach", flag.ContinueOnError) + allowLatest := fs.Bool("allow-latest", false, "allow heuristic resume of the provider's latest session") + if err := fs.Parse(args[1:]); err != nil { + return err + } + id, err := requireArg(fs.Args(), "attach requires ") if err != nil { return err } - return execAttach(ctx, svc, id, runTUI) + return execAttachWithOptions(ctx, svc, id, app.ResumeOptions{AllowLatest: *allowLatest}, runTUI) case "last": return runLast(ctx, svc, runTUI) default: @@ -205,11 +211,16 @@ func runStop(ctx context.Context, svc *app.Service, cmd string, args []string) e // runRestart stops the session's agent process and resumes it in place: same // session name and record, with the provider's resume args. func runRestart(ctx context.Context, svc *app.Service, args []string) error { - id, err := requireArg(args, "restart requires ") + fs := flag.NewFlagSet("restart", flag.ContinueOnError) + allowLatest := fs.Bool("allow-latest", false, "allow heuristic resume of the provider's latest session") + if err := fs.Parse(args); err != nil { + return err + } + id, err := requireArg(fs.Args(), "restart requires ") if err != nil { return err } - return svc.Restart(ctx, id) + return svc.RestartWithOptions(ctx, id, app.ResumeOptions{AllowLatest: *allowLatest}) } // runNotifyClosed flags the matching record as user-closed. Session hosts @@ -292,6 +303,20 @@ func NewService(st *store.Store) *app.Service { func RunTUI(ctx context.Context, model tea.Model) error { p := tea.NewProgram(model, tea.WithAltScreen(), tea.WithContext(ctx)) _, err := p.Run() + if term.IsTerminal(os.Stdout.Fd()) { + _ = writeTUIExitCleanup(os.Stdout) + } + return err +} + +const tuiExitCleanup = "\x1b[0m" + + "\x1b[?1000;1002;1003;1004;1005;1006;1015l" + + "\x1b[?2004l" + + "\x1b[?25h" + + "\x1b[2K\r" + +func writeTUIExitCleanup(w io.Writer) error { + _, err := io.WriteString(w, tuiExitCleanup) return err } @@ -435,7 +460,11 @@ func parseNameAndPrompt(parts []string) (string, string) { } func execAttach(ctx context.Context, svc *app.Service, id string, runTUI func(context.Context, tea.Model) error) error { - spec, err := svc.AttachSpec(ctx, id) + return execAttachWithOptions(ctx, svc, id, app.ResumeOptions{}, runTUI) +} + +func execAttachWithOptions(ctx context.Context, svc *app.Service, id string, opts app.ResumeOptions, runTUI func(context.Context, tea.Model) error) error { + spec, err := svc.AttachSpecWithOptions(ctx, id, opts) if err != nil { return err } diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 6bffe19..9cb08f3 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -14,6 +14,7 @@ import ( "time" tea "github.com/charmbracelet/bubbletea" + "github.com/creack/pty" "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" "github.com/RandomCodeSpace/unified-agent-manager/internal/app" @@ -67,6 +68,68 @@ func (f *cliFakeAdapter) Resume(ctx adapter.Context, req adapter.ResumeRequest) func noopRunTUI(context.Context, tea.Model) error { return nil } +func TestTUIExitCleanupIsMinimalAndOrdered(t *testing.T) { + var out bytes.Buffer + if err := writeTUIExitCleanup(&out); err != nil { + t.Fatal(err) + } + want := "\x1b[0m\x1b[?1000;1002;1003;1004;1005;1006;1015l\x1b[?2004l\x1b[?25h\x1b[2K\r" + if out.String() != want { + t.Fatalf("cleanup = %q, want %q", out.String(), want) + } + for _, forbidden := range []string{"\x1b[2J", "\x1b[3J", "\x1bc", "\x1b[?1049l"} { + if strings.Contains(out.String(), forbidden) { + t.Fatalf("cleanup contains destructive sequence %q", forbidden) + } + } +} + +type resizeSynchronizedQuitModel struct{} + +func (resizeSynchronizedQuitModel) Init() tea.Cmd { return nil } +func (m resizeSynchronizedQuitModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if _, ok := msg.(tea.WindowSizeMsg); ok { + return m, tea.Quit + } + return m, nil +} +func (resizeSynchronizedQuitModel) View() string { return "dashboard-marker" } + +func TestRunTUICleansPrimaryLineAfterBubbleTeaExit(t *testing.T) { + ptmx, tty, err := pty.Open() + if err != nil { + t.Fatal(err) + } + defer func() { _ = ptmx.Close() }() + oldStdout := os.Stdout + oldStdin := os.Stdin + os.Stdout = tty + os.Stdin = tty + t.Cleanup(func() { os.Stdout, os.Stdin = oldStdout, oldStdin }) + var out bytes.Buffer + readDone := make(chan struct{}) + go func() { _, _ = io.Copy(&out, ptmx); close(readDone) }() + // Bubble Tea starts its initial size query in an untracked goroutine. Quit + // only after its WindowSizeMsg establishes that the PTY Fd query completed; + // then closing our test-owned slave cannot race that query. + if err := RunTUI(context.Background(), resizeSynchronizedQuitModel{}); err != nil { + t.Fatal(err) + } + os.Stdout = oldStdout + os.Stdin = oldStdin + _ = tty.Close() + <-readDone + got := out.String() + cleanupAt := strings.LastIndex(got, tuiExitCleanup) + altExitAt := strings.LastIndex(got, "\x1b[?1049l") + if cleanupAt < 0 || altExitAt < 0 || cleanupAt <= altExitAt { + t.Fatalf("cleanup not after Bubble Tea alt exit: %q", got) + } + if got[cleanupAt:] != tuiExitCleanup { + t.Fatalf("unexpected bytes after cleanup: %q", got[cleanupAt:]) + } +} + func TestRunDispatchListPeekAndStop(t *testing.T) { svc, fake := newCLITestService(t) id := dispatchAndCaptureID(t, svc, []string{"--cwd", "/tmp", "fake", "#bugfix", "fix", "thing"}) @@ -98,6 +161,18 @@ func TestRunRestart(t *testing.T) { } } +func TestAttachAndRestartAcceptAllowLatest(t *testing.T) { + svc, fake := newCLITestService(t) + id := dispatchAndCaptureID(t, svc, []string{"--cwd", "/tmp", "fake", "work"}) + must(t, runRestart(context.Background(), svc, []string{"--allow-latest", id})) + if !fake.resumed { + t.Fatal("restart --allow-latest did not resume") + } + if err := runCommand(context.Background(), svc, []string{"attach", "--allow-latest", id}, noopRunTUI); err != nil { + t.Fatalf("attach --allow-latest: %v", err) + } +} + func TestCLIArgumentValidationAndParsing(t *testing.T) { svc, _ := newCLITestService(t) if err := RunDispatch(context.Background(), svc, nil); err == nil { diff --git a/internal/providerstate/root.go b/internal/providerstate/root.go new file mode 100644 index 0000000..91de4ed --- /dev/null +++ b/internal/providerstate/root.go @@ -0,0 +1,141 @@ +// Package providerstate secures the persistent root used for provider-owned +// launch metadata. Once the base and every controlled child are owned by the +// current user and not group/other-writable, another local user cannot swap a +// checked component between validation and use. +package providerstate + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "syscall" +) + +// EnsureTrustedBase creates base when absent and verifies that it is a real, +// current-user-owned directory with no group/other write permission. +func EnsureTrustedBase(base string) error { + return walkTrustedPath(base, true) +} + +// VerifyTrustedBase performs the read-side form without mutation. +func VerifyTrustedBase(base string) error { + return walkTrustedPath(base, false) +} + +func walkTrustedPath(base string, create bool) error { + if !filepath.IsAbs(base) { + return fmt.Errorf("provider state base must be absolute") + } + clean := filepath.Clean(base) + volume := filepath.VolumeName(clean) + path := string(os.PathSeparator) + if volume != "" { + path = volume + string(os.PathSeparator) + } + rootInfo, err := os.Lstat(path) + if err != nil { + return err + } + if err := verifyComponent(path, rootInfo); err != nil { + return err + } + rawParts := strings.Split(strings.TrimPrefix(clean, path), string(os.PathSeparator)) + parts := make([]string, 0, len(rawParts)) + for _, part := range rawParts { + if part != "" { + parts = append(parts, part) + } + } + for i, part := range parts { + candidate := filepath.Join(path, part) + info, err := os.Lstat(candidate) + if os.IsNotExist(err) && create { + if err := os.Mkdir(candidate, 0o700); err != nil && !os.IsExist(err) { + return err + } + info, err = os.Lstat(candidate) + } + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 { + st, ok := info.Sys().(*syscall.Stat_t) + if !ok || !allowRootOwnedSymlink(int(st.Uid), i == len(parts)-1) { + return fmt.Errorf("provider state ancestor %s is an untrusted symlink", candidate) + } + resolved, err := filepath.EvalSymlinks(candidate) + if err != nil { + return err + } + if err := verifyResolvedAncestry(resolved); err != nil { + return err + } + path = resolved + continue + } + if err := verifyComponent(candidate, info); err != nil { + return err + } + path = candidate + } + return nil +} + +func allowRootOwnedSymlink(uid int, isFinal bool) bool { return uid == 0 && !isFinal } + +// verifyResolvedAncestry validates the physical target of an eligible +// root-owned system symlink without following any further unchecked link. +func verifyResolvedAncestry(target string) error { + if !filepath.IsAbs(target) { + return fmt.Errorf("resolved provider state path must be absolute") + } + clean := filepath.Clean(target) + volume := filepath.VolumeName(clean) + path := string(os.PathSeparator) + if volume != "" { + path = volume + string(os.PathSeparator) + } + info, err := os.Lstat(path) + if err != nil { + return err + } + if err := verifyComponent(path, info); err != nil { + return err + } + for _, part := range strings.Split(strings.TrimPrefix(clean, path), string(os.PathSeparator)) { + if part == "" { + continue + } + path = filepath.Join(path, part) + info, err = os.Lstat(path) + if err != nil { + return err + } + if err := verifyComponent(path, info); err != nil { + return err + } + } + return nil +} + +func verifyComponent(path string, info os.FileInfo) error { + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("provider state ancestor %s is not a real directory", path) + } + st, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return fmt.Errorf("provider state ancestor %s has unknown ownership", path) + } + uid := int(st.Uid) + if uid != 0 && uid != os.Getuid() { + return fmt.Errorf("provider state ancestor %s is owned by uid %d", path, uid) + } + if info.Mode().Perm()&0o022 != 0 { + rootSticky := uid == 0 && info.Mode()&os.ModeSticky != 0 + if !rootSticky { + return fmt.Errorf("provider state ancestor %s is group/other-writable (%04o)", path, info.Mode().Perm()) + } + } + return nil +} diff --git a/internal/providerstate/root_test.go b/internal/providerstate/root_test.go new file mode 100644 index 0000000..a9046a8 --- /dev/null +++ b/internal/providerstate/root_test.go @@ -0,0 +1,76 @@ +package providerstate + +import ( + "os" + "path/filepath" + "syscall" + "testing" +) + +func TestRootOwnedSymlinkPolicyIsAncestorOnly(t *testing.T) { + if !allowRootOwnedSymlink(0, false) { + t.Fatal("root-owned system ancestor should be eligible") + } + if allowRootOwnedSymlink(0, true) { + t.Fatal("the provider-state base itself must not be a symlink") + } + if allowRootOwnedSymlink(os.Getuid(), false) && os.Getuid() != 0 { + t.Fatal("user-owned ancestor symlink accepted") + } +} + +func TestTrustedBaseRejectsSymlinkAtBase(t *testing.T) { + parent, target := t.TempDir(), t.TempDir() + base := filepath.Join(parent, "state") + if err := os.Symlink(target, base); err != nil { + t.Fatal(err) + } + if err := EnsureTrustedBase(base); err == nil { + t.Fatal("symlinked provider-state base accepted") + } +} + +func TestResolvedAncestryRejectsWritableComponent(t *testing.T) { + unsafe := filepath.Join(t.TempDir(), "unsafe") + if err := os.Mkdir(unsafe, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Chmod(unsafe, 0o770); err != nil { + t.Fatal(err) + } + if err := verifyResolvedAncestry(unsafe); err == nil { + t.Fatal("writable resolved target ancestry accepted") + } +} + +func TestRootOwnedSystemSymlinkAncestorUsesTrustedResolvedTarget(t *testing.T) { + for _, link := range []string{"/var", "/lib", "/lib64"} { + info, err := os.Lstat(link) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + continue + } + st, ok := info.Sys().(*syscall.Stat_t) + if !ok || st.Uid != 0 { + continue + } + resolved, err := filepath.EvalSymlinks(link) + if err != nil { + continue + } + entries, err := os.ReadDir(resolved) + if err != nil { + continue + } + for _, entry := range entries { + if !entry.IsDir() { + continue + } + logical := filepath.Join(link, entry.Name()) + if err := VerifyTrustedBase(logical); err != nil { + t.Fatalf("trusted root symlink ancestor %s rejected: %v", logical, err) + } + return + } + } + t.Skip("no root-owned system symlink ancestor with a directory target") +} diff --git a/internal/session/attach.go b/internal/session/attach.go index d3da306..2fb3843 100644 --- a/internal/session/attach.go +++ b/internal/session/attach.go @@ -2,6 +2,7 @@ package session import ( "bufio" + "bytes" "encoding/binary" "errors" "flag" @@ -24,6 +25,23 @@ const detachPrefix = 0x02 const ctrlC = 0x03 const AttachQuietEnv = "UAM_ATTACH_QUIET" +const AttachMouseEnv = "UAM_ATTACH_MOUSE" + +// attachMouseEnabled resolves the per-viewer mouse policy. Local attaches keep +// provider mouse support by default; SSH attaches leave mouse gestures to the +// local terminal so selection and paste continue to work. +func attachMouseEnabled(getenv func(string) string) bool { + switch getenv(AttachMouseEnv) { + case "on": + return true + case "off": + return false + case "", "auto": + return getenv("SSH_CONNECTION") == "" && getenv("SSH_TTY") == "" + default: + return getenv("SSH_CONNECTION") == "" && getenv("SSH_TTY") == "" + } +} type attachOptions struct { quiet bool @@ -173,7 +191,11 @@ func runAttachWithOptions(dir, name string, stdin *os.File, stdout *os.File, opt // pump has fully drained, so a second receive never blocks. done := make(chan error, 1) go func() { - _, err := io.Copy(stdout, br) + filter := newAttachOutputFilter(stdout, attachMouseEnabled(os.Getenv)) + _, err := io.Copy(filter, br) + if flushErr := filter.Flush(); err == nil { + err = flushErr + } done <- err close(done) }() @@ -253,12 +275,18 @@ type stdinFilter struct { // verbatim (a terminal reply to an agent query); strBel allows the OSC // BEL terminator, strEsc tracks a possible ST (ESC \), and strLen caps // runaway sequences at maxStrLen. - strActive bool - strBel bool - strEsc bool - strLen int + strActive bool + strBel bool + strEsc bool + strLen int + inPaste bool + pasteStart int + pasteEnd int } +var pasteBegin = []byte("\x1b[200~") +var pasteEnd = []byte("\x1b[201~") + // boxEmpty reports whether the agent's input box is believed empty. func (f *stdinFilter) boxEmpty() bool { return !f.unknown && f.typed == 0 } @@ -305,10 +333,31 @@ func pumpStdin(stdin io.Reader, frames *frameWriter, backDetach bool) { func (f *stdinFilter) filter(chunk []byte) (out []byte, detach bool) { out = make([]byte, 0, len(chunk)+1) for i, b := range chunk { + if f.inPaste { + out = append(out, b) + f.pasteEnd = advanceExactMatch(pasteEnd, f.pasteEnd, b) + if f.pasteEnd == len(pasteEnd) { + f.inPaste, f.pasteEnd = false, 0 + f.unknown = true + } + continue + } if f.strActive { out = f.strByte(out, b) continue } + f.pasteStart = advanceExactMatch(pasteBegin, f.pasteStart, b) + if f.pasteStart == len(pasteBegin) { + // The marker may have accumulated in esc, or its ESC may already + // have been forwarded at a read boundary. Emit only what remains. + if len(f.esc) > 0 { + out = append(out, f.esc...) + f.esc = nil + } + out = append(out, b) + f.pasteStart, f.inPaste = 0, true + continue + } if f.pendingPrefix { f.pendingPrefix = false switch b { @@ -381,6 +430,168 @@ func (f *stdinFilter) filter(chunk []byte) (out []byte, detach bool) { return out, false } +func advanceExactMatch(pattern []byte, matched int, b byte) int { + if b == pattern[matched] { + return matched + 1 + } + if b == pattern[0] { + return 1 + } + return 0 +} + +const maxAttachCSI = 4096 + +var attachAltModes = map[string]bool{"47": true, "1047": true, "1049": true} +var attachMouseModes = map[string]bool{"1000": true, "1002": true, "1003": true, "1005": true, "1006": true, "1015": true} + +// attachOutputFilter contains provider-owned alternate-screen toggles inside +// the attach screen and optionally leaves mouse modes under terminal control. +// Only seven-bit DEC private h/l sequences are rewritten. +type attachOutputFilter struct { + dst io.Writer + mouse bool + pending []byte + abortedCSI bool + forwardedCSI bool +} + +func newAttachOutputFilter(dst io.Writer, mouse bool) *attachOutputFilter { + return &attachOutputFilter{dst: dst, mouse: mouse} +} + +func (f *attachOutputFilter) Write(p []byte) (int, error) { + // Ordinary output never exceeds the input length. A split control sequence + // can add the small pending prefix back, in which case append grows the + // buffer safely instead of computing a potentially overflowing capacity. + out := make([]byte, 0, len(p)) + for _, b := range p { + switch { + case len(f.pending) == 0: + if b == 0x1b { + f.pending = append(f.pending, b) + f.abortedCSI = f.forwardedCSI + f.forwardedCSI = false + } else { + out = append(out, b) + if f.forwardedCSI && (b == 0x18 || b == 0x1a || b >= 0x40 && b <= 0x7e) { + f.forwardedCSI = false + } + } + case len(f.pending) == 1: + if b == '[' { + f.pending = append(f.pending, b) + } else { + out = append(out, f.pending...) + f.pending = f.pending[:0] + f.abortedCSI = false + if b == 0x1b { + f.pending = append(f.pending, b) + } else { + out = append(out, b) + } + } + case b == 0x18 || b == 0x1a: + // CAN and SUB cancel CSI parsing and return the destination + // terminal to ground. Preserve the provider's cancellation byte; + // no synthetic cancellation is needed for a later filtered mode. + out = append(out, f.pending...) + out = append(out, b) + f.pending = f.pending[:0] + f.abortedCSI = false + f.forwardedCSI = false + case b == 0x1b: + // ESC aborts an in-flight CSI in a real terminal. Flush the old + // prefix and retain this ESC as the start of a new filterable + // sequence instead of letting its '[' terminate the old CSI. + out = append(out, f.pending...) + f.pending = append(f.pending[:0], b) + f.abortedCSI = true + default: + f.pending = append(f.pending, b) + if b >= 0x40 && b <= 0x7e { + rewritten := f.rewriteCSI(f.pending) + if len(rewritten) == 0 && f.abortedCSI { + // The filtered ESC would otherwise leave the previously + // forwarded incomplete CSI active. CAN is the standard CSI + // cancellation control and cannot begin another sequence. + out = append(out, 0x18) + } else { + out = append(out, rewritten...) + } + f.pending = f.pending[:0] + f.abortedCSI = false + } else if len(f.pending) > maxAttachCSI { + out = append(out, f.pending...) + f.pending = f.pending[:0] + f.abortedCSI = false + f.forwardedCSI = true + } + } + } + if err := writeAttachBytes(f.dst, out); err != nil { + return 0, err + } + return len(p), nil +} + +func (f *attachOutputFilter) Flush() error { + err := writeAttachBytes(f.dst, f.pending) + f.pending = f.pending[:0] + return err +} + +func (f *attachOutputFilter) rewriteCSI(seq []byte) []byte { + if len(seq) < 5 || seq[0] != 0x1b || seq[1] != '[' || seq[2] != '?' || (seq[len(seq)-1] != 'h' && seq[len(seq)-1] != 'l') { + return seq + } + params := bytes.Split(seq[3:len(seq)-1], []byte{';'}) + kept := make([][]byte, 0, len(params)) + removed := false + for _, param := range params { + if len(param) == 0 { + return seq + } + for _, b := range param { + if b < '0' || b > '9' { + return seq + } + } + key := string(param) + if attachAltModes[key] || (!f.mouse && attachMouseModes[key]) { + removed = true + continue + } + kept = append(kept, param) + } + if !removed { + return seq + } + if len(kept) == 0 { + return nil + } + out := []byte("\x1b[?") + out = append(out, bytes.Join(kept, []byte{';'})...) + out = append(out, seq[len(seq)-1]) + return out +} + +func writeAttachBytes(dst io.Writer, p []byte) error { + for len(p) > 0 { + n, err := dst.Write(p) + if n > 0 { + p = p[n:] + } + if err != nil { + return err + } + if n == 0 { + return io.ErrShortWrite + } + } + return nil +} + // escByte feeds one byte into a pending escape sequence. It returns the // updated forward buffer and whether the left-arrow quick detach fired. func (f *stdinFilter) escByte(out []byte, b byte) ([]byte, bool) { diff --git a/internal/session/attach_filter_test.go b/internal/session/attach_filter_test.go index d1c7a3f..f0eb8c1 100644 --- a/internal/session/attach_filter_test.go +++ b/internal/session/attach_filter_test.go @@ -2,6 +2,9 @@ package session import ( "bytes" + "errors" + "io" + "strings" "testing" ) @@ -21,6 +24,269 @@ func runFilter(t *testing.T, f *stdinFilter, chunks ...string) (string, bool) { return out.String(), false } +func TestAttachMousePolicy(t *testing.T) { + tests := []struct { + name, value, sshConnection, sshTTY string + want bool + }{ + {"unset local", "", "", "", true}, {"auto local", "auto", "", "", true}, + {"unset ssh connection", "", "client", "", false}, {"auto ssh tty", "auto", "", "/dev/pts/1", false}, + {"on ssh", "on", "client", "", true}, {"off local", "off", "", "", false}, + {"invalid local", "maybe", "", "", true}, {"invalid ssh", "maybe", "client", "", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + env := map[string]string{AttachMouseEnv: tt.value, "SSH_CONNECTION": tt.sshConnection, "SSH_TTY": tt.sshTTY} + if got := attachMouseEnabled(func(key string) string { return env[key] }); got != tt.want { + t.Fatalf("got %v, want %v", got, tt.want) + } + }) + } +} + +func filterHostOutput(t *testing.T, mouse bool, chunks ...[]byte) []byte { + t.Helper() + var out bytes.Buffer + f := newAttachOutputFilter(&out, mouse) + for _, chunk := range chunks { + if n, err := f.Write(chunk); err != nil || n != len(chunk) { + t.Fatalf("Write = %d, %v; want %d, nil", n, err, len(chunk)) + } + } + if err := f.Flush(); err != nil { + t.Fatal(err) + } + return out.Bytes() +} + +func TestAttachOutputFilterOwnedModes(t *testing.T) { + for _, mouse := range []bool{true, false} { + for _, mode := range []string{"47", "1047", "1049"} { + for _, final := range []string{"h", "l"} { + if got := string(filterHostOutput(t, mouse, []byte("before\x1b[?"+mode+final+"after"))); got != "beforeafter" { + t.Fatalf("mouse=%v mode=%s%s got %q", mouse, mode, final, got) + } + } + } + } + input := []byte("部署\x1b[31m\x1b[?1;1000;2004;1006;1049hX\x1b[?1004l\x1b]0;title\a") + if got, want := string(filterHostOutput(t, false, input)), "部署\x1b[31m\x1b[?1;2004hX\x1b[?1004l\x1b]0;title\a"; got != want { + t.Fatalf("mouse off = %q, want %q", got, want) + } + if got, want := string(filterHostOutput(t, true, input)), "部署\x1b[31m\x1b[?1;1000;2004;1006hX\x1b[?1004l\x1b]0;title\a"; got != want { + t.Fatalf("mouse on = %q, want %q", got, want) + } +} + +func TestAttachOutputFilterSplitAndFlush(t *testing.T) { + input := []byte("a\x1b[?1;1000;2004;1049hb") + for split := 0; split <= len(input); split++ { + if got := string(filterHostOutput(t, false, input[:split], input[split:])); got != "a\x1b[?1;2004hb" { + t.Fatalf("split %d = %q", split, got) + } + } + for _, partial := range []string{"\x1b", "\x1b[", "\x1b[?1000"} { + if got := string(filterHostOutput(t, false, []byte(partial))); got != partial { + t.Fatalf("partial %q flushed as %q", partial, got) + } + } + long := "\x1b[?" + strings.Repeat("1", 4097) + if got := string(filterHostOutput(t, false, []byte(long))); got != long { + t.Fatal("over-cap CSI changed") + } +} + +func TestAttachOutputFilterRestartsAfterAbortedCSI(t *testing.T) { + for _, mode := range []string{"47", "1047", "1049"} { + for _, final := range []string{"h", "l"} { + input := []byte("\x1b[12\x1b[?" + mode + final) + want := []byte("\x1b[12\x18") // CAN terminates the forwarded incomplete CSI. + for split := 0; split <= len(input); split++ { + if got := filterHostOutput(t, true, input[:split], input[split:]); !bytes.Equal(got, want) { + t.Fatalf("mode=%s%s split=%d got %q, want %q", mode, final, split, got, want) + } + } + } + } +} + +func TestAttachOutputFilterAbortedCSIMultiChunkAndEOF(t *testing.T) { + input := []byte("\x1b[12\x1b[?1049l") + chunks := make([][]byte, 0, len(input)) + for i := range input { + chunks = append(chunks, input[i:i+1]) + } + if got, want := filterHostOutput(t, true, chunks...), []byte("\x1b[12\x18"); !bytes.Equal(got, want) { + t.Fatalf("byte chunks = %q, want %q", got, want) + } + for _, incomplete := range []string{"\x1b[12\x1b", "\x1b[12\x1b[", "\x1b[12\x1b[?1049"} { + if got := string(filterHostOutput(t, false, []byte(incomplete))); got != incomplete { + t.Fatalf("EOF changed incomplete %q to %q", incomplete, got) + } + } + nonOwned := "\x1b[12\x1b[?2004h" + if got := string(filterHostOutput(t, true, []byte(nonOwned))); got != nonOwned { + t.Fatalf("non-owned abort sequence changed to %q", got) + } +} + +func TestAttachOutputFilterRestartsAtCapEdge(t *testing.T) { + for _, prefixLen := range []int{maxAttachCSI, maxAttachCSI + 1} { + prefix := "\x1b[" + strings.Repeat("1", prefixLen-2) + input := []byte(prefix + "\x1b[?1049l") + want := []byte(prefix + "\x18") + for split := 0; split <= len(input); split++ { + if got := filterHostOutput(t, false, input[:split], input[split:]); !bytes.Equal(got, want) { + t.Fatalf("prefix=%d split=%d tail=%q, want CAN-terminated prefix", prefixLen, split, got[len(got)-min(16, len(got)):]) + } + } + } +} + +func TestAttachOutputFilterHonorsProviderCSICancellation(t *testing.T) { + for _, prefix := range []string{ + "\x1b[12", + "\x1b[" + strings.Repeat("1", maxAttachCSI-1), + } { + for _, cancel := range []byte{0x18, 0x1a} { + for _, next := range []struct { + name string + seq string + want string + }{ + {name: "owned", seq: "\x1b[?1049l", want: prefix + string(cancel)}, + {name: "non-owned", seq: "\x1b[?2004h", want: prefix + string(cancel) + "\x1b[?2004h"}, + } { + input := []byte(prefix + string(cancel) + next.seq) + for split := 0; split <= len(input); split++ { + if got := string(filterHostOutput(t, false, input[:split], input[split:])); got != next.want { + t.Fatalf("prefix=%d cancel=%#x next=%s split=%d tail=%q, want tail=%q", len(prefix), cancel, next.name, split, got[len(got)-min(20, len(got)):], next.want[len(next.want)-min(20, len(next.want)):]) + } + } + } + } + } +} + +func TestAttachOutputFilterPreservesMalformedPrivateModes(t *testing.T) { + for _, input := range []string{ + "\x1b[?1:2;1049h", + "\x1b[?abc;1049h", + "\x1b[?1;;1049h", + "\x1b[?;1049h", + "\x1b[?1;1049$h", + "\x1b[?1.2;1049l", + } { + for split := 0; split <= len(input); split++ { + if got := string(filterHostOutput(t, false, []byte(input[:split]), []byte(input[split:]))); got != input { + t.Fatalf("input=%q split=%d changed to %q", input, split, got) + } + } + } +} + +type failingWriter struct{ err error } + +func (w failingWriter) Write([]byte) (int, error) { return 0, w.err } + +func TestAttachOutputFilterPropagatesDownstreamError(t *testing.T) { + want := errors.New("write failed") + if _, err := io.WriteString(newAttachOutputFilter(failingWriter{want}, false), "plain"); !errors.Is(err, want) { + t.Fatalf("error = %v", err) + } +} + +type chunkWriter struct { + bytes.Buffer + max int +} + +func (w *chunkWriter) Write(p []byte) (int, error) { + if len(p) > w.max { + p = p[:w.max] + } + return w.Buffer.Write(p) +} + +func TestAttachOutputFilterHandlesPartialDownstreamWrites(t *testing.T) { + var dst chunkWriter + dst.max = 2 + f := newAttachOutputFilter(&dst, false) + input := []byte("部署\x1b[?1;1000;2004;1049h tail") + if n, err := f.Write(input); err != nil || n != len(input) { + t.Fatalf("Write = %d, %v", n, err) + } + if err := f.Flush(); err != nil { + t.Fatal(err) + } + if got, want := dst.String(), "部署\x1b[?1;2004h tail"; got != want { + t.Fatalf("got %q, want %q", got, want) + } +} + +func TestBracketedPasteBypassesAttachBindings(t *testing.T) { + paste := []byte("\x1b[200~部署 café 🚀\r\n\x02d\x03\x1a\x1b[D\x1b[201~") + for split := 0; split <= len(paste); split++ { + f := &stdinFilter{backDetach: true} + var got []byte + for _, chunk := range [][]byte{paste[:split], paste[split:]} { + out, detach := f.filter(chunk) + if detach { + t.Fatalf("split %d detached", split) + } + got = append(got, out...) + } + if !bytes.Equal(got, paste) { + t.Fatalf("split %d = %q, want %q", split, got, paste) + } + if _, detach := f.filter([]byte{detachPrefix, 'd'}); !detach { + t.Fatalf("split %d detach did not resume", split) + } + } +} + +func TestBracketMarkerInsideTerminalReplyDoesNotEnterPaste(t *testing.T) { + f := &stdinFilter{backDetach: true} + reply := "\x1b]0;literal \x1b[200~ marker\a" + if out, detach := runFilter(t, f, reply); detach || out != reply { + t.Fatalf("reply = %q detach=%v", out, detach) + } + if _, detach := runFilter(t, f, string([]byte{detachPrefix, 'd'})); !detach { + t.Fatal("marker inside OSC incorrectly entered paste mode") + } +} + +func TestRawUTF8AndCRLFAreByteExact(t *testing.T) { + input := []byte("部署 café 🚀\r\n") + for split := 0; split <= len(input); split++ { + f := &stdinFilter{backDetach: true} + var got []byte + for _, chunk := range [][]byte{input[:split], input[split:]} { + out, detach := f.filter(chunk) + if detach { + t.Fatalf("split %d detached", split) + } + got = append(got, out...) + } + if !bytes.Equal(got, input) { + t.Fatalf("split %d = %q, want %q", split, got, input) + } + } +} + +func TestAttachOutputFilterPreservesUnrelatedControls(t *testing.T) { + for _, input := range [][]byte{ + []byte("\x1b[31mred\x1b[0m"), []byte("\x1b[?1004h\x1b[?1007l\x1b[?2004h"), + []byte("\x1b[?1000$p\x1b[?1000$y"), []byte("\x1b]0;title\a"), + []byte("\x1bP$qm\x1b\\"), []byte("部署 café 🚀؛\r\n"), + {0x9b, '?', '1', '0', '4', '9', 'l'}, + } { + if got := filterHostOutput(t, false, input); !bytes.Equal(got, input) { + t.Fatalf("%q changed to %q", input, got) + } + } +} + func FuzzAttachFiltering(f *testing.F) { for _, seed := range []string{"plain text", "世界🚀", "\x02d", "\x1b[D", "\x1b]11;rgb:ffff/ffff/ffff\x1b\\"} { f.Add(seed, true) diff --git a/internal/session/host.go b/internal/session/host.go index 52ca71c..67f88eb 100644 --- a/internal/session/host.go +++ b/internal/session/host.go @@ -2,9 +2,11 @@ package session import ( "bufio" + "encoding/json" "errors" "flag" "fmt" + "io" "net" "os" "os/exec" @@ -21,8 +23,13 @@ import ( "github.com/RandomCodeSpace/unified-agent-manager/internal/log" "github.com/RandomCodeSpace/unified-agent-manager/internal/store" "github.com/RandomCodeSpace/unified-agent-manager/internal/vterm" + "golang.org/x/sys/unix" ) +// ProviderIdentityFileEnv names the provider-neutral identity handoff read by +// the host after the managed process exits. +const ProviderIdentityFileEnv = "UAM_PROVIDER_IDENTITY_FILE" + // Default PTY geometry for a detached session, matching the old // `tmux new-session -x 200 -y 50` so unattached agents render wide output the // same way they used to. The first attach resizes to the real terminal. @@ -94,7 +101,8 @@ func (s *stringList) String() string { return strings.Join(*s, ",") } func (s *stringList) Set(v string) error { *s = append(*s, v); return nil } type host struct { - dir, name string + dir, name string + providerIdentityFile string mu sync.Mutex term *vterm.Terminal @@ -108,9 +116,9 @@ type host struct { // escalation stops there. cleaned is closed after shutdown has also // removed the runtime files, so a Kill reply means the session is fully // gone — a List immediately after must not see leftovers. - exited chan struct{} - cleaned chan struct{} - stopping atomic.Bool + exited chan struct{} + cleaned chan struct{} + uamStopping atomic.Bool } type attachClient struct { @@ -151,13 +159,14 @@ func runHost(dir, name, cwd, label string, envs, command []string, ready *os.Fil defer func() { _ = ln.Close() }() h := &host{ - dir: dir, - name: name, - label: label, - term: vterm.New(defaultCols, defaultRows, historyLines), - clients: map[*attachClient]struct{}{}, - exited: make(chan struct{}), - cleaned: make(chan struct{}), + dir: dir, + name: name, + providerIdentityFile: envValue(envs, ProviderIdentityFileEnv), + label: label, + term: vterm.New(defaultCols, defaultRows, historyLines), + clients: map[*attachClient]struct{}{}, + exited: make(chan struct{}), + cleaned: make(chan struct{}), } cmd := exec.Command(command[0], command[1:]...) // #nosec G204 -- argv comes from the trusted uam client that spawned this host; no shell is involved. cmd.Dir = cwd @@ -282,7 +291,6 @@ func (h *host) signalLoop() { signal.Notify(ch, syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP) <-ch // Forward termination to the agent; the normal exit path then runs. - h.stopping.Store(true) h.terminateChild() } @@ -325,7 +333,7 @@ func (h *host) handleConn(conn net.Conn) { _ = writeJSONLine(conn, response{OK: true}) _ = conn.Close() case opKill: - h.stopping.Store(true) + h.uamStopping.Store(true) h.terminateChild() select { case <-h.cleaned: @@ -528,10 +536,11 @@ func (h *host) shutdown(exitCode int) { if err := removeSessionFiles(h.dir, h.name); err != nil { log.Warn("remove session files failed", "session", h.name, "error", err) } - h.markClosed(exitCode) + h.recordExit(exitCode) } -func (h *host) markClosed(exitCode int) { +func (h *host) recordExit(exitCode int) { + providerID := readProviderIdentityHandoff(h.providerIdentityFile) deadline := time.Now().Add(markClosedRetryWindow) delay := markClosedRetryBase var lastErr error @@ -539,12 +548,12 @@ func (h *host) markClosed(exitCode int) { st, err := store.Open(store.DefaultPath()) if err == nil { var matched bool - matched, err = st.TryMarkSessionClosed(h.name, exitCode) + matched, err = st.TryRecordSessionExit(store.SessionExit{SessionName: h.name, ProviderSessionID: providerID, ExitCode: exitCode, UAMInitiated: h.uamStopping.Load()}) if err == nil && matched { return } } - if h.stopping.Load() && err == nil { + if h.uamStopping.Load() && err == nil { return } lastErr = err @@ -558,6 +567,43 @@ func (h *host) markClosed(exitCode int) { } } +func envValue(envs []string, key string) string { + prefix := key + "=" + for i := len(envs) - 1; i >= 0; i-- { + if strings.HasPrefix(envs[i], prefix) { + return strings.TrimPrefix(envs[i], prefix) + } + } + return "" +} + +func readProviderIdentityHandoff(path string) string { + if path == "" { + return "" + } + fd, err := unix.Open(path, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) // #nosec G304 -- trusted launch metadata, opened no-follow. + if err != nil { + return "" + } + file := os.NewFile(uintptr(fd), path) + defer func() { _ = file.Close() }() + var st unix.Stat_t + if unix.Fstat(fd, &st) != nil || st.Mode&unix.S_IFMT != unix.S_IFREG || os.FileMode(st.Mode).Perm() != 0o600 || int(st.Uid) != os.Getuid() { + return "" + } + data, err := io.ReadAll(io.LimitReader(file, 1025)) + if err != nil || len(data) > 1024 { + return "" + } + var payload struct { + ProviderSessionID string `json:"provider_session_id"` + } + if json.Unmarshal(data, &payload) != nil || !store.ValidProviderSessionID(payload.ProviderSessionID) { + return "" + } + return payload.ProviderSessionID +} + func (h *host) logMarkClosedFailure(err error) { if err != nil { log.Warn("mark session closed failed after retry", "session", h.name, "error", err) diff --git a/internal/session/session_test.go b/internal/session/session_test.go index d16dcbf..4df40c7 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -2,6 +2,7 @@ package session import ( "context" + "fmt" "os" "path/filepath" "strings" @@ -154,7 +155,7 @@ func TestCreateListCaptureSendKill(t *testing.T) { } } -func TestAgentExitMarksStoreRecordClosed(t *testing.T) { +func TestNaturalAgentCrashRemainsResumableAndRecordsFailure(t *testing.T) { c := newTestClient(t) ctx := context.Background() name := "uam-fake-deadbeef" @@ -173,13 +174,13 @@ func TestAgentExitMarksStoreRecordClosed(t *testing.T) { if err := c.CreateSession(ctx, name, t.TempDir(), nil, []string{"/bin/sh", "-c", "exit 3"}); err != nil { t.Fatalf("CreateSession: %v", err) } - waitFor(t, "record marked closed", func() bool { + waitFor(t, "natural crash recorded", func() bool { cfg, err := st.Load() if err != nil { return false } rec := cfg.Sessions["fake:deadbeef"] - return rec.Status == store.StatusClosedByUser && rec.LastExitCode != nil && *rec.LastExitCode == 3 + return rec.Status == store.StatusActive && rec.LastExitCode != nil && *rec.LastExitCode == 3 }) waitFor(t, "runtime files removed", func() bool { _, err := os.Stat(SocketPath(c.Dir, name)) @@ -187,7 +188,35 @@ func TestAgentExitMarksStoreRecordClosed(t *testing.T) { }) } -func TestAgentExitBeforeRecordPersistenceEventuallyMarksRecordClosed(t *testing.T) { +func TestImmediateExitRecordsProviderIdentityHandoff(t *testing.T) { + c := newTestClient(t) + name := "uam-opencode-a1b2c3d4" + st, err := store.Open(store.DefaultPath()) + if err != nil { + t.Fatal(err) + } + if err := st.Update(func(cfg *store.Config) error { + cfg.PutSession("opencode:a1b2c3d4", store.SessionRecord{ID: "a1b2c3d4", Agent: "opencode", Name: "n", SessionName: name, Status: store.StatusActive}) + return nil + }); err != nil { + t.Fatal(err) + } + handoff := filepath.Join(t.TempDir(), "identity.json") + command := `umask 077; printf '{"provider_session_id":"ses_fast123"}' > "$` + ProviderIdentityFileEnv + `"; exit 0` + if err := c.CreateSession(context.Background(), name, t.TempDir(), map[string]string{ProviderIdentityFileEnv: handoff}, []string{"/bin/sh", "-c", command}); err != nil { + t.Fatal(err) + } + waitFor(t, "fast identity exit persisted", func() bool { + cfg, err := st.Load() + if err != nil { + return false + } + rec := cfg.Sessions["opencode:a1b2c3d4"] + return rec.LastExitCode != nil && *rec.LastExitCode == 0 && rec.ProviderSessionID == "ses_fast123" + }) +} + +func TestNaturalAgentExitBeforeRecordPersistenceEventuallyRecordsExit(t *testing.T) { c := newTestClient(t) shortDir, err := os.MkdirTemp("", "uam-exit-") if err != nil { @@ -220,13 +249,39 @@ func TestAgentExitBeforeRecordPersistenceEventuallyMarksRecordClosed(t *testing. t.Fatal(err) } - waitFor(t, "late record marked closed", func() bool { + waitFor(t, "late natural exit recorded", func() bool { cfg, err := st.Load() if err != nil { return false } rec := cfg.Sessions["fake:feedface"] - return rec.Status == store.StatusClosedByUser && rec.LastExitCode != nil && *rec.LastExitCode == 3 + return rec.Status == store.StatusActive && rec.LastExitCode != nil && *rec.LastExitCode == 3 + }) +} + +func TestNaturalAgentExitZeroRemainsResumable(t *testing.T) { + c := newTestClient(t) + name := "uam-fake-cafebabe" + st, err := store.Open(store.DefaultPath()) + if err != nil { + t.Fatal(err) + } + if err := st.Update(func(cfg *store.Config) error { + cfg.PutSession("fake:cafebabe", store.SessionRecord{ID: "cafebabe", Agent: "fake", SessionName: name, Status: store.StatusActive}) + return nil + }); err != nil { + t.Fatal(err) + } + if err := c.CreateSession(context.Background(), name, t.TempDir(), nil, []string{"/bin/sh", "-c", "exit 0"}); err != nil { + t.Fatal(err) + } + waitFor(t, "natural success recorded", func() bool { + cfg, err := st.Load() + if err != nil { + return false + } + rec := cfg.Sessions["fake:cafebabe"] + return rec.Status == store.StatusActive && rec.LastExitCode != nil && *rec.LastExitCode == 0 }) } @@ -805,6 +860,7 @@ func TestAttachQuietSuppressesPrimaryScreenNotice(t *testing.T) { // has to replay them or arrows and wheel scroll die on every re-entry. // Regression test for the resume/re-attach mode-loss bug. func TestReattachReplaysAgentPrivateModes(t *testing.T) { + t.Setenv(AttachMouseEnv, "on") c := newTestClient(t) ctx := context.Background() name := "uam-fake-cccc1111" @@ -871,6 +927,133 @@ func TestReattachReplaysAgentPrivateModes(t *testing.T) { } } +func TestAttachMouseOffFiltersReplayedProviderModes(t *testing.T) { + t.Setenv(AttachMouseEnv, "off") + c := newTestClient(t) + ctx := context.Background() + name := "uam-fake-78780001" + cmd := []string{"/bin/sh", "-c", "printf '\033[?1;1000;2004;1006hmouse-policy-marker'; sleep 60"} + if err := c.CreateSession(ctx, name, t.TempDir(), nil, cmd); err != nil { + t.Fatal(err) + } + + ptmx, tty, err := pty.Open() + if err != nil { + t.Fatal(err) + } + defer func() { _ = ptmx.Close(); _ = tty.Close() }() + done := make(chan error, 1) + go func() { done <- runAttachWithOptions(c.Dir, name, tty, tty, attachOptions{quiet: true}) }() + var mu sync.Mutex + var output []byte + go func() { + buf := make([]byte, 4096) + for { + n, readErr := ptmx.Read(buf) + if n > 0 { + mu.Lock() + output = append(output, buf[:n]...) + mu.Unlock() + } + if readErr != nil { + return + } + } + }() + snapshot := func() string { mu.Lock(); defer mu.Unlock(); return string(output) } + waitFor(t, "mouse policy marker", func() bool { return strings.Contains(snapshot(), "mouse-policy-marker") }) + beforeDetach := snapshot() + if err := validateMouseOffAttachOutput(beforeDetach); err != nil { + t.Fatalf("mouse-off attach output: %v: %q", err, beforeDetach) + } + if _, err := ptmx.Write([]byte{detachPrefix, 'd'}); err != nil { + t.Fatal(err) + } + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(10 * time.Second): + t.Fatal("detach timed out") + } +} + +func TestValidateMouseOffAttachOutputAcceptsGroupedDarwinModes(t *testing.T) { + for _, darwinOutput := range []string{ + screenEnter + "\x1b[2J\x1b[H\x1b[?1;2004hmouse-policy-marker", + screenEnter + "\x1b[?2004;1hmouse-policy-marker", + } { + if err := validateMouseOffAttachOutput(darwinOutput); err != nil { + t.Fatalf("equivalent grouped private modes rejected: %v", err) + } + } +} + +func TestValidateMouseOffAttachOutputRejectsWeakenedEvidence(t *testing.T) { + tests := []struct{ name, output string }{ + {"missing outer screen", "\x1b[?1;2004hmouse-policy-marker"}, + {"missing cursor mode", screenEnter + "\x1b[?2004hmouse-policy-marker"}, + {"missing paste mode", screenEnter + "\x1b[?1hmouse-policy-marker"}, + {"mouse combined", screenEnter + "\x1b[?1;1000;2004hmouse-policy-marker"}, + {"provider alt enable", screenEnter + "\x1b[?1;47;2004hmouse-policy-marker"}, + {"second outer enable", screenEnter + "\x1b[?1;1049;2004hmouse-policy-marker"}, + {"provider alt disable", screenEnter + "\x1b[?1047l\x1b[?1;2004hmouse-policy-marker"}, + {"missing payload", screenEnter + "\x1b[?1;2004h"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := validateMouseOffAttachOutput(tt.output); err == nil { + t.Fatalf("invalid output accepted: %q", tt.output) + } + }) + } +} + +func validateMouseOffAttachOutput(output string) error { + if !strings.HasPrefix(output, screenEnter) { + return fmt.Errorf("missing attach-owned outer screen") + } + body := output[len(screenEnter):] + marker := strings.Index(body, "mouse-policy-marker") + if marker < 0 { + return fmt.Errorf("missing provider payload marker") + } + body = body[:marker] + foundEnabled := map[string]bool{} + for i := 0; i+3 < len(body); i++ { + if body[i] != 0x1b || body[i+1] != '[' || body[i+2] != '?' { + continue + } + final := i + 3 + for final < len(body) && (body[final] < 0x40 || body[final] > 0x7e) { + final++ + } + if final == len(body) || (body[final] != 'h' && body[final] != 'l') { + continue + } + params := strings.Split(body[i+3:final], ";") + for _, param := range params { + switch param { + case "47", "1047", "1049": + return fmt.Errorf("provider alternate-screen mode %s%c leaked", param, body[final]) + case "1000", "1002", "1003", "1005", "1006", "1015": + return fmt.Errorf("provider mouse mode %s%c leaked", param, body[final]) + } + if body[final] == 'h' { + foundEnabled[param] = true + } + } + i = final + } + for _, mode := range []string{"1", "2004"} { + if !foundEnabled[mode] { + return fmt.Errorf("missing preserved provider mode %s", mode) + } + } + return nil +} + // The left-arrow quick detach works end to end through the real attach // client: with nothing typed since attach, a bare left arrow detaches. func TestAttachLeftArrowDetaches(t *testing.T) { diff --git a/internal/store/store.go b/internal/store/store.go index 0d39ba0..1158215 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -184,6 +184,16 @@ type SessionRecord struct { PR *PRRecord `json:"pr,omitempty"` } +// SessionExit describes how a provider process left its native session host. +// UAMInitiated is true only for an explicit UAM stop/restart request; terminal +// provider exits and externally delivered signals remain natural exits. +type SessionExit struct { + SessionName string + ProviderSessionID string + ExitCode int + UAMInitiated bool +} + type PRRecord struct { URL string `json:"url"` Number int `json:"number"` @@ -432,7 +442,7 @@ func validateRecord(rec SessionRecord) string { // The provider session id is passed as a resume argv value; constrain it // so a hand-edited record cannot smuggle a flag or shell hazard into the // agent's command line. - if rec.ProviderSessionID != "" && !providerSessionIDRE.MatchString(rec.ProviderSessionID) { + if rec.ProviderSessionID != "" && !ValidProviderSessionID(rec.ProviderSessionID) { return "unsafe provider_session_id" } return "" @@ -444,6 +454,11 @@ func validateRecord(rec SessionRecord) string { // starting with '-' could be parsed as a flag by the agent CLI). var providerSessionIDRE = regexp.MustCompile(`^[0-9A-Za-z_][0-9A-Za-z_-]{0,63}$`) +// ValidProviderSessionID is the schema-v3 provider identity grammar. Runtime +// discovery must use this same boundary so it cannot persist a value that a +// later load would reject by dropping the containing session record. +func ValidProviderSessionID(id string) bool { return providerSessionIDRE.MatchString(id) } + func isSafeCommandAlias(alias string) bool { if alias == "" || strings.HasPrefix(alias, "-") { return false @@ -643,22 +658,35 @@ func (s *Store) MarkSessionClosed(sessionName string, exitCode int) error { return err } -// TryMarkSessionClosed is MarkSessionClosed with an explicit match result. The -// host uses the result to distinguish an intentionally idempotent no-op from -// the narrow launch race where the durable record has not been written yet. +// TryMarkSessionClosed is the compatibility entry point for older callers. It +// records an explicit UAM stop and returns whether a durable record matched. func (s *Store) TryMarkSessionClosed(sessionName string, exitCode int) (bool, error) { - if sessionName == "" { + return s.TryRecordSessionExit(SessionExit{SessionName: sessionName, ExitCode: exitCode, UAMInitiated: true}) +} + +// TryRecordSessionExit records the provider's latest exit while preserving +// resumability for natural exits. Only an explicit UAM stop/restart retires +// the record into the closed-by-user group. +func (s *Store) TryRecordSessionExit(exit SessionExit) (bool, error) { + if exit.SessionName == "" { return false, nil } matched := false err := s.Update(func(cfg *Config) error { for key, rec := range cfg.Sessions { - if rec.SessionName != sessionName { + if rec.SessionName != exit.SessionName { continue } - rec.Status = StatusClosedByUser - code := exitCode + if exit.UAMInitiated { + rec.Status = StatusClosedByUser + } else { + rec.Status = StatusActive + } + code := exit.ExitCode rec.LastExitCode = &code + if exit.ProviderSessionID != "" { + rec.ProviderSessionID = exit.ProviderSessionID + } cfg.Sessions[key] = rec matched = true return nil diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 55be9a1..a98cf4b 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -109,6 +109,68 @@ func TestTryMarkSessionClosedReportsWhetherRecordMatched(t *testing.T) { } } +func TestTryRecordSessionExitPreservesResumeLifecycle(t *testing.T) { + tests := []struct { + name string + exit SessionExit + wantStatus Status + wantCode int + }{ + {name: "natural success", exit: SessionExit{SessionName: "uam-fake-deadbeef", ExitCode: 0}, wantStatus: StatusActive, wantCode: 0}, + {name: "natural crash", exit: SessionExit{SessionName: "uam-fake-deadbeef", ExitCode: 9}, wantStatus: StatusActive, wantCode: 9}, + {name: "explicit stop", exit: SessionExit{SessionName: "uam-fake-deadbeef", ExitCode: -1, UAMInitiated: true}, wantStatus: StatusClosedByUser, wantCode: -1}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s, err := Open(filepath.Join(t.TempDir(), "sessions.json")) + if err != nil { + t.Fatal(err) + } + if err := s.Update(func(cfg *Config) error { + cfg.Sessions["fake:deadbeef"] = SessionRecord{ID: "deadbeef", Agent: "fake", SessionName: tt.exit.SessionName, Status: StatusActive} + return nil + }); err != nil { + t.Fatal(err) + } + matched, err := s.TryRecordSessionExit(tt.exit) + if err != nil || !matched { + t.Fatalf("matched=%v err=%v", matched, err) + } + cfg, err := s.Load() + if err != nil { + t.Fatal(err) + } + rec := cfg.Sessions["fake:deadbeef"] + if rec.Status != tt.wantStatus || rec.LastExitCode == nil || *rec.LastExitCode != tt.wantCode { + t.Fatalf("record=%+v, want status=%q code=%d", rec, tt.wantStatus, tt.wantCode) + } + }) + } +} + +func TestTryRecordSessionExitUpdatesOnlyNonEmptyProviderIdentity(t *testing.T) { + s, err := Open(filepath.Join(t.TempDir(), "sessions.json")) + if err != nil { + t.Fatal(err) + } + if err := s.Update(func(cfg *Config) error { + cfg.Sessions["fake:deadbeef"] = SessionRecord{ID: "deadbeef", Agent: "fake", SessionName: "uam-fake-deadbeef", ProviderSessionID: "ses_old"} + return nil + }); err != nil { + t.Fatal(err) + } + if _, err := s.TryRecordSessionExit(SessionExit{SessionName: "uam-fake-deadbeef", ProviderSessionID: "ses_new"}); err != nil { + t.Fatal(err) + } + if _, err := s.TryRecordSessionExit(SessionExit{SessionName: "uam-fake-deadbeef"}); err != nil { + t.Fatal(err) + } + cfg, _ := s.Load() + if got := cfg.Sessions["fake:deadbeef"].ProviderSessionID; got != "ses_new" { + t.Fatalf("id=%q", got) + } +} + func TestSyncDirAcceptsStoreDirectoryAndRejectsMissingDirectory(t *testing.T) { dir := t.TempDir() if err := syncDir(dir); err != nil {